본문 바로가기

우아한테크코스

Jenkins 와 Docker 를 사용해서 SpringBoot Application 무중단 배포하기

🔥 무중단 배포 도입 전

✔️ 스탬프크러쉬의 인프라 상황

한 대의 클라우드 서버에서 운영 서버를 가지고 있으며, 외부의 한 대의 또다른 클라우드 서버를 배포 관련 서버로 사용중이다. 해당 서버에서는 젠킨스를 통해서 배포를 자동화하고 있다.

✔️ 배포 방식

현재 스탬프크러쉬는 jenkins 서버에서 아래의 4가지 단계로 배포를 자동화하고 있다.

  1. 우리가 트래킹하는 브랜치(main, develop)에 push가 되면, 젠킨스는 깃허브로부터 최신 코드를 가져온다.
  2. 최신 코드를 빌드해 .jar 파일을 만든다.
  3. .jar 파일을 운영서버로 전달한다.
  4. 운영서버의 run.sh 스크립트 파일 실행한다. 해당 스크립트 파일에는 spring boot application 을 실행하는 내용이 있다.

✔️ 배포 스크립트

아래는 무중단 배포 도입 전 스탬프크러쉬의 배포 관련 스크립트이다.

 

[젠킨스 파이프라인]

더보기

pipeline {
   agent any
   stages {
       stage('Github') { // 깃허브로부터 최신 코드를 pull해오는 단계
           steps {
                checkout scmGit(
                    branches: [[name: '*/develop']], // develop 브랜치의 코드를 pull 한다는 뜻
                    extensions: [submodule(parentCredentials: true, trackingSubmodules: true, recursiveSubmodules: false, disableSubmodules: false)], // 서브모듈의 내용을 함께 가져옴.
                    userRemoteConfigs: [[credentialsId: 'leo-git', url: 'https://github.com/woowacourse-teams/2023-stamp-crush']] // credential과 소스 코드의 저장 위치 
                )
           }
       }
   stage('Build') { // 받아온 코드를 빌드하는 단계
           steps {
               dir('backend') {
                   sh 'pwd'
                   sh "./gradlew bootJar" // jar 파일을 빌드한다. 
               }
           }
       }
   stage('Deploy') { // jar 파일을 배포 서버에서 실행하는 단계 
       steps {
           dir('backend/build/libs') {
               sshagent(credentials: ['key-stamp-crush']) {
                    sh 'scp -o StrictHostKeyChecking=no backend-0.0.1-SNAPSHOT.jar ubuntu@192.XXX.X.XX:/home/ubuntu'
                    sh 'ssh ubuntu@192.168.1.173 "sudo sh run.sh" &' // 운영 서버에 들어있는 스크립트인 run.sh를 실행한다.
                    sh 'ssh ubuntu@192.168.1.173 "sh sleep.sh"' // 배포가 끝나기 전에 이 단계를 종료해버리지 않도록 10초 간 멈추도록 한다.
               }
           }
       }
   }
}

 

위의 코드에서 StrictHostKeyChecking=no 는 배포 서버에 보내는 key의 유효성을 검증하지 않도록 하는 명령어인데, 뺄 수 있다면 빼는 것이 좋다.

 

[운영 서버의 배포 스크립트 run.sh]

더보기

#! /bin/bash
PROJECT_NAME=backend

// 8080 포트에 띄워져 있는 프로세스가 있다면 종료한다. 
CURRENT_PID=`sudo lsof -i :8080 -t` 
echo $CURRENT_PID 
if [ -z "$CURRENT_PID" ]; then
    echo " 실행중인 애플리케이션이 없으므로 종료하지 않습니다."
else
    echo " 실행중인 애플리케이션을 종료했습니다. (pid : $CURRENT_PID)"
    sudo kill -9 $CURRENT_PID
    sleep 5
fi

// 최신 코드로 빌드된 jar 파일을 실행한다. 
echo "\n SpringBoot 애플리케이션을 실행합니다.\n"
JAR_NAME=$(ls | grep .jar | head -n 1)
echo $JAR_NAME
sudo nohup java -jar -Dspring.profiles.active=prod -Duser.timezone=Asia/Seoul /home/ubuntu/$JAR_NAME &

✔️ 다운타임

무중단 배포 도입 전에 총 20초의 다운 타임이 존재했다.

기존에 실행되던 프로그램을 종료하는 데 10초, 새로운 프로그램을 띄우고 기다리는 데 10초가 소요되었다.

✔️ 무중단 배포의 필요성

스탬프크러쉬 서비스를 실제 카페에서 사용하게 되었고, 우리 카페는 매일매일 아침 11시부터 저녁 11시까지 운영 한다. 따라서 우리가 주로 일하는 시간인 낮에 코드에 변경사항이 있더라도 배포하는 것이 어려워졌다.

따라서 우리는 무중단 배포를 도입하기로 결정했다.

🔥 무중단 배포 계획

우리는 운영 서버로 사용할 EC2 서버가 1개이기 때문에 서로 다른 서버를 이용해서 무중단 배포를 하는 일반적인 방식을 사용할 수는 없었다. 따라서 하나의 서버 내에서 2개의 포트를 사용해 각 포트에 도커 컨테이너를 띄우고 각각의 컨테이너에서 애플리케이션을 돌아가면서 배포하기로 결정했다.

우리는 이전과 달리, 배포를 할 때에 .jar 파일을 운영 서버로 직접 보내는 방식 대신 Docker Hub를 사용해서 젠킨스에서 도커 허브로 도커 이미지를 push하는 방식을 채택하기로 했다.

그렇게 되면 자연스럽게 운영 서버에서는 젠킨스 서버로부터 파일을 받는 대신에 도커 허브로부터 도커 이미지를 pull 받아 오면 된다.

🔥 무중단 배포 자동화 방법

✔️ 도커 이미지를 빌드하기 위한 Dockerfile을 프로젝트 코드 내에 생성한다.

Dockerfile은 도커 컨테이너를 구성하는 데 사용되는 텍스트 파일로, 도커 컨테이너 내부의 파일 시스템을 정의하는 명령문들로 구성되어 있다.

 

FROM openjdk:17-jdk

// 우리 프로젝트에서 빌드가 되었을때의 .jar 파일의 위치를 환경변수로 설정해준다
ARG JAR_FILE=build/libs/backend-0.0.1-SNAPSHOT.jar

// 젠킨스에서 build한 .jar 파일을 도커 이미지 내부로 복사 
// 이 과정을 해야만 docker image를 다른 환경에서 실행하더라도 같은 .jar 파일을 실행하게 된다. 
COPY ${JAR_FILE} stampcrush.jar

// 실제 도커 이미지가 실행될 때, 도커 컨테이너 내에서 실행될 명령어
CMD ["java", "-jar", "-Dspring.profiles.active=dev", "-Duser.timezone=Asia/Seoul", "stampcrush.jar"]

✔️ Jenkins에서 최신 코드로 .jar 파일을 도커 이미지로 빌드한다.

우리는 젠킨스를 도커 환경에서 실행중이다.

따라서 도커가 이미지를 사용해 안정적으로 배포 환경을 관리하는 장점을 최대한 살리기 위해서, 배포할 때 코드 및 실행 환경을 도커 이미지로 관리하기로 했다.

따라서 아래 명령어를 통해 최신 코드를 pull 받아 빌드 한 뒤 생성된 .jar 파일을 도커 이미지로 빌드한다.

 

sh 'docker build --platform linux/arm64/v8 -t stampcrush/stampcrush-dev -f Dockerfile-dev .'

 

docker build를 해주는 명령어를 통해 도커 이미지를 생성한다.

-t 이후에 있는 내용은 도커 허브의 어느 위치에 도커 이미지를 저장할 지 결정하는 내용으로, {docker hub 계정}/{repository명} 이다.

-f 이후는 사용할 도커 파일의 경로이다. 우리는 Root 디렉토리에 파일을 생성해두었기 때문에, 앞에 별다른 경로 없이 도커 파일의 이름만을 적어주었다. dev 브랜치에 대한 도커 파일은 Dockerfile-dev에 정의해 두었다.

✔️ Jenkins에서 도커 이미지를 Docker Hub에 push한다.

 sh 'docker login -u [도커 허브 id] -p [도커 허브 pw]'
 sh 'docker push stampcrush/stampcrush-dev'

 

도커에 로그인할 수 있도록 로그인과 관련된 정보를 준다.

이후에 docker push를 통해서 생성된 이미지를 도커 허브에 푸시한다.

✔️ Jenkins에서 운영 서버의 배포 스크립트를 실행한다.

stage('Deploy') {
    steps {
            sshagent(credentials: ['key-stamp-crush']) {
                sh 'ssh -o "StrictHostKeyChecking=no" ubuntu@192.168.1.173 "sudo sh deploy.sh"'
        }
    }
}

 

deploy.sh는 우리 운영 서버에 들어 있는 배포 스크립트다. 젠킨스를 통해 해당 배포 스크립트를 실행한다.

배포 스크립트의 내용은 아래와 같다.

우리가 사용할 두 개의 포트에 올라갈 각 컨테이너의 이름을 우리 팀원인 깃짱(GITCHAN), 레오(LEO)를 따서 정했다.

 

#1 
EXIST_GITCHAN=$(sudo docker-compose -p test-gitchan -f docker-compose.gitchan.yml ps | grep Up)

if [ -z "$EXIST_GITCHAN" ]; then
    echo "GITCHAN 컨테이너 실행"
    sudo docker-compose -p test-gitchan -f /home/ubuntu/docker-compose.gitchan.yml up -d
    BEFORE_COLOR="leo"
    AFTER_COLOR="gitchan"
    BEFORE_PORT=8081
    AFTER_PORT=8080
else
    echo "LEO 컨테이너 실행"
    sudo docker-compose -p test-leo -f /home/ubuntu/docker-compose.leo.yml up -d
    BEFORE_COLOR="gitchan"
    AFTER_COLOR="leo"
    BEFORE_PORT=8080
    AFTER_PORT=8081
fi

echo "${AFTER_COLOR} server up(port:${AFTER_PORT})"

# 2
for cnt in {1..10}
do
    echo "서버 응답 확인중(${cnt}/10)";
    UP=$(curl -s <http://localhost>:${AFTER_PORT}/health-check)
    if [ "${UP}" != "up" ]
        then
            sleep 10
            continue
        else
            break
    fi
done

if [ $cnt -eq 10 ]
then
    echo "서버가 정상적으로 구동되지 않았습니다."
    exit 1
fi

# 3
sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/" /etc/nginx/conf.d/service-url.inc
sudo nginx -s reload
echo "Deploy Completed!!"

# 4
echo "$BEFORE_COLOR server down(port:${BEFORE_PORT})"
sudo docker-compose -p test-${BEFORE_COLOR} -f docker-compose.${BEFORE_COLOR}.yml down

 

  1. 프로세스가 실행중이지 않은 도커 컨테이너를 찾는다.
    • 깃짱 컨테이너가 실행중이면, 레오 컨테이너를 실행한다.
    • 깃짱 컨테이너가 실행중이지 않으면, 깃짱 컨테이너를 실행한다.
  2. 새롭게 실행된 컨테이너에 /health-check 라는 end point로 요청을 보내서, 컨테이너가 잘 실행되었는지 확인한다.
    • 이 과정을 총 10초 씩 기다려가면서 최대 10번까지 반복하므로, 최대 100초까지 기다려준다.
    • end point는 미리 “up” 문자열을 반환하도록 코드를 작성해 놓아야 한다.
    • 서버 응답이 확인되지 않으면, 위의 1단계에서 컨테이너가 제대로 실행되지 않았음을 의미하므로, 배포 과정을 중단한다. 이 경우에는, 기존에 사용하던 서버는 그대로 유지되므로 그냥 없던 일로 할 수 있다.
  3. 새롭게 실행된 컨테이너 쪽으로 Nginx의 포트 포워딩 설정을 변경하고, Nginx를 reload한다.
    • sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/" /etc/nginx/conf.d/service-url.inc 는 Nginx 내 service-url이라는 변수를 변경하는 명령어다.
    • 예를 들어서, 8080 포트에서 실행중이던 깃짱 컨테이너가 이전 코드를 실행중이고, 8081 포트에 새롭게 띄운 레오 컨테이너는 최신 코드를 실행한다면 8080으로 향하던 Nginx의 포트 포워딩을 8081로 변경한다.
    • reload를 통해서 서버를 중단하지 않고 새로운 설정 파일을 적용할 수 있다. 참고로, restart 명령어는 새로운 설정을 적용하고 모든 연결을 끊어서 서비스 중단이 발생한다.
  4. 이전 코드를 실행중인 도커 컨테이너를 종료시킨다.

🔥 배포 스크립트 전체

✔️ Jenkins Pipeline 스크립트

아래는 우리 서비스의 Jenkins pipeline 전체다.

무중단 배포와 관련된 단계는 Docker Build and Push 와 Deploy다.

 

pipeline {
    agent any
    stages {
        stage('Github') {
            steps {
                checkout scmGit(
                    branches: [[name: '*/develop']],
                    extensions: [submodule(parentCredentials: true, trackingSubmodules: true, recursiveSubmodules: false, disableSubmodules: false)],
                    userRemoteConfigs: [[credentialsId: 'leo-git', url: '<https://github.com/woowacourse-teams/2023-stamp-crush>']]
                )
            }
        }
        stage('Build') {
            steps {
                dir('backend') {
                    sh 'pwd'
                    sh "./gradlew bootJar"
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                dir('backend') {
                    sh 'pwd'
                    sh 'docker build --platform linux/arm64/v8 -t stampcrush/stampcrush-dev -f Dockerfile-dev .'
                    sh 'docker login -u [도커 허브 id] -p [도커 허브 pw]'
                    sh 'docker push stampcrush/stampcrush-dev'
                }
            }
        }
        stage('Deploy') {
            steps {
                    sshagent(credentials: ['key-stamp-crush']) {
                        sh 'ssh -o "StrictHostKeyChecking no" ubuntu@192.168.1.173 "sudo sh deploy.sh"'
                }
            }
        }
    }
}

✔️ 운영 서버의 배포 스크립트(deploy.sh)

 

#1
EXIST_GITCHAN=$(sudo docker-compose -p test-gitchan -f docker-compose.gitchan.yml ps | grep Up)

if [ -z "$EXIST_GITCHAN" ]; then
    echo "GITCHAN 컨테이너 실행"
    sudo docker-compose -p test-gitchan -f /home/ubuntu/docker-compose.gitchan.yml up -d
    BEFORE_COLOR="leo"
    AFTER_COLOR="gitchan"
    BEFORE_PORT=8081
    AFTER_PORT=8080
else
    echo "LEO 컨테이너 실행"
    sudo docker-compose -p test-leo -f /home/ubuntu/docker-compose.leo.yml up -d
    BEFORE_COLOR="gitchan"
    AFTER_COLOR="leo"
    BEFORE_PORT=8080
    AFTER_PORT=8081
fi

echo "${AFTER_COLOR} server up(port:${AFTER_PORT})"

# 2
for cnt in {1..10}
do
    echo "서버 응답 확인중(${cnt}/10)";
    UP=$(curl -s <http://127.0.0.1>:${AFTER_PORT}/health-check)
    if [ "${UP}" != "up" ]
        then
            sleep 10
            continue
        else
            break
    fi
done

if [ $cnt -eq 10 ]
then
    echo "서버가 정상적으로 구동되지 않았습니다."
    exit 1
fi

# 3
sudo sed -i "s/${BEFORE_PORT}/${AFTER_PORT}/" /etc/nginx/conf.d/service-url.inc
sudo nginx -s reload
echo "Deploy Completed!!"

# 4
echo "$BEFORE_COLOR server down(port:${BEFORE_PORT})"
sudo docker-compose -p test-${BEFORE_COLOR} -f docker-compose.${BEFORE_COLOR}.yml down

✔️ docker-compose.yml 파일

 

[docker-compose.gitchan.yml]

version: '3.1'

services:
  api:
    image: stampcrush/stampcrush-dev:latest
    container_name: test-gitchan
    environment:
      - LANG=ko_KR.UTF-8
      - HTTP_PORT=8080
    ports:
      - '8080:8080'

 

[docker-compose.leo.yml]

version: '3.1'

services:
  api:
    image: stampcrush/stampcrush-dev:latest
    container_name: test-leo
    environment:
      - LANG=ko_KR.UTF-8
      - HTTP_PORT=8081
    ports:
      - '8081:8080'

✔️ Nginx 설정파일

nginx 로 reverse proxy를 변경해, 새로 띄우고자 하는 컨테이너의 포트에 요청이 가도록 설정한다.

 

[service-url.inc]

  • 이 파일은 /etc/nginx/conf.d/ 위치에 새로 생성을 해줘야 한다
set $service_url http://127.0.0.1:8080;

 

[Nginx 설정]

server {
  include /etc/nginx/conf.d/service-url.inc;

    location /api {
            proxy_pass $service_url;
        }
}

참고자료