본문 바로가기
Java/트러블 슈팅

Blue/Green 방식으로 서비스 중단없이 배포하기

by oneny 2023. 12. 17.

Blue/Green 방식으로 서비스 중단없이 배포하기

어플리케이션을 배포하는데 있어 Rolling 배포, Blue/Green 배포, 그리고 Canary 배포와 같은 다양한 배포 전략들이 있다. 각 전략의 특징과 장담점을 살펴보고 프로젝트에 어떤 전략을 선택할지 고민해보려고 한다.

 

Rolling 배포

롤링 배포는 각 서버를 한 개씩 새로운 버전으로 (점진적) 배포하는 방법을 말한다. 즉, 위 그림처럼 기존 버전에서 새 버전으로 서비스를 전환하는 과정을 순차적으로 진행한다.

서비스 중인 인스턴스 하나를 로드밸런서에 라우팅하지 않도록 한 뒤 새 버전을 적용하여 다시 라우팅하는 방식으로 구성된 자원을 그대로 유지한 채로 무중단 배포가 가능하므로 관리가 편하다는 장점이 있지만 배포가 진행되는 동안 구버전과 신버전이 공존하기 때문에 호환성 문제가 발생할 수 있다는 단점이 있다.

 

Canary 배포

Canary라는 용어는 새를 일컫는 말인데, 이 새는 일산화탄소 및 유독가스에 매우 민감하다고 한다. 그래서 과거 광부들이 이 새를 여펭 두고 광산에서 일을 하다가 카나리아가 갑자기 죽게 되면 대피를 했다고 한다. 이처럼 Canary 배포는 카나리아 새처럼 위험을 빠르게 감지할 수 있는 배포 기법이다. 구 버전의 서버와 새 버전의 서버들을 구성하고 일부 트래픽을 새 버전으로 분산하여 오류 여부를 판단한다. 이 기법으로 A/B 테스트도 가능하고, 그 결과에 따라 새 버전이 운영 환경을 대체할 수도 있고, 다시 구 버전으로 돌아가기 쉽다는 장점이 있다. 하지만 피드백을 얻기 위해 사용자의 반응을 기다려야 하고, 작은 규모의 배포 단계를 관리하기 위해서도 앞서 말한 비용이 들고 복잡하다는 단점이 있다.

 

Blue/Green 배포

Blue/Green 배포 방식은 두 개의 환경(Blue, Green)을 갖고, 현재 활성화된 환경과 비활성화된 환경을 번갈아가며 사용한다. 새로운 버전을 준비된 환경에 배포하고, 전환 시에는 트래픽을 전환하여 신속하게 업데이트를 적용할 수 있다.

Blue/Green 배포 방식은 롤링, 카나리아 배포 방식에서 발생할 수 있는 구 버전과 신 버전이 동시에 운영되는 타이밍이 존재한다는 문제점을 해결할 수 있고, 문제가 발생하면 구 버전의 서버가 백업용으로 떠 있기 때문에 이전 환경으로 빠르게 롤백할 수 있다는 장점이 있다. 따라서 프로젝트를 진행하는데 있어 Blue/Green 배포 방식을 선택했다. 하지만 단점으로 두 개의 환경을 유지하기 위해 추가 자원이 필요하는데 이를 해결하기 위해서 새로운 인스턴스 서버를 생성하기 보다는 8080과 8081 포트를 통해 배포 환경을 바꿔가며 Blue/Green 배포를 진행할 예정이다.

 

Blue/Green 배포 구조

구성한 Blue/Green 배포 흐름은 아래와 같다.

  1. 새로운 버전이 Git에 병합되면, Github Webhook을 통해 Jenkins에 신호가 들어오고, 젠킨스는 최신버전의 Jar 파일을 빌드한다.
  2. 젠킨스는 Blue에 Health Check를 한다. Blue가 살아있다면 신버전을 Green에 배포하면 되고, 살아있지 않다면 Blue에 배포하면 된다.
  3. Blue가 포트 번호 8080에 살아 있다는 것으로 판단하고 포트 번호 8081인 Green에 배포한다고 가정한다.
  4. 젠킨스는 원격지에 맨 처음 빌드해둔 Jar 파일을 전송하고, 포트번호 8081로 실행한다.
  5. Green의 애플리케이션이 구동되었는지 10초 주기로 Health Check를 한다. Green 애플리케이션이 기동됨을 확인하면 다음으로 넘어간다.
  6. Nginx의 리버스 프록시 방향을 Blue에서 Green으로 변경(8080 -> 8081)한다. 이제 클라이언트의 모든 트래픽이 신버전 애플리케이션으로 향한다.
  7. Blue 인스턴스의 애플리케이션 프로세스를 죽인다.

 

Nginx 설정을 통한 로드 밸런싱

 

Reverse Proxy & Load Balancing

Reverse Proxy Nginx의 Reverse Proxy는 클라이언트 요청을 다른 서버로 전달하고 해당 서버로부터 받은 응답을 클라이언트에게 반환하는 역할을 하는 중간 서버이다. 이를 통해 Nginx는 여러 가용성 및 보

oneny.tistory.com

무중단 배포 환경을 적용하면서 안정적인 서비스 제공을 위해 로드 밸런싱 환경도 함께 구축했다. 로드 밸런싱은 여러 대의 서버나 네트워크 장치에게 트래픽을 분산시켜 서비스의 가용성과 성능을 향상시키는 기술을 말한다. 로드 밸런서는 클라이언트 요청을 받아 여러 서버로 분산시켜 서버 간의 부하를 균등하게 분배하거나 특정 서버로의 부하를 최소화함으로써 시스템 전체의 안정성과 성능을 유지하는 역할을 한다. 자세한 내용은 위 Reverse Proxy & Load Balancing 게시글에서 다룬 적이 있다.

 

upstream was-prod1 {
        server 10.41.182.186:8080;
        server 10.41.217.101:8081;
}

upstream was-prod2 {
        server 10.41.182.186:8081;
        server 10.41.217.101:8081;
}

server {
        listen 80;
        server_name i-dol-u;

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

        location / {
                proxy_pass $service_url;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                proxy_set_header X-Request-ID $request_id;
        }
}

위 설정을 보면 include 라는 지시어를 사용하는 것을 확인할 수 있다. 이는 외부에서 설정 파일을 불러올 수 있는 Nginx의 기능인데 location의 proxy_pass를 보면 $service_url로 리버스 프록시 요청을 보내는 것을 알 수 있다. 각 헤더들의 특징에 대해서도 위 블로그에 작성했으니 참고하면 된다.

 

set $service_url http://was-prod1;

기본적으로 8080 포트로 향하도록 설정을 했다. service_url.inc에서 설정한 $service_url 변수 값을 통해서 젠킨스가 이 파일을 직접 수정하고, reload하면 리버스 프록시 방향을 바꿀 수 있다.

 

shell script 작성

Jenkins에서 배포 후에 실행할 shello script를 작성했다. deploy.sh의 전체적인 흐름은 위에서 살펴본 배포 구조 흐름대로 작성했다.

 

전체 스크립트 내용

JASYPT_KEY=$1
IP_LIST=($2 $3)
PORT_A=8080
PORT_B=8081
REPOSITORY=/root/app/
PROJECT_NAME=$(ls /root/app/ | grep SNAPSHOT.jar | head -1)

PROFILE=""
BLUE_PORT=""
GREEN_PORT=""

echo "> Blue 서버 Health Check"
if curl -s "http://${IP_LIST[0]}:${PORT_A}/actuator/health" > /dev/null; then
        BLUE_PORT=$PORT_A
        GREEN_PORT=$PORT_B
        PROFILE=prod2
else
        BLUE_PORT=$PORT_B
        GREEN_PORT=$PORT_A
        PROFILE=prod1
fi

echo "> 배포"
for IP in ${IP_LIST[@]}
do
        scp -p $REPOSITORY$PROJECT_NAME root@$IP:$REPOSIROTY$PROJECT_NAME
        ssh root@$IP "nohup java -jar -Dspring.profiles.active=${PROFILE} ${REPOSITORY}${PROJECT_NAME} --JASYPT_PASSWORD=$JASYPT_KEY >> $REPOSITORYnohup.out &"
done

echo "> 10초 후 Green 서버 Heatlh Check"
sleep 10
for IP in ${IP_LIST[@]}
do
        
        for retry_count in $(seq 10)
        do
                response=$(curl -s http://$IP:$GREEN_PORT/actuator/health)
                up_count=$(echo $response | grep 'UP' | wc -l)
                echo "> ${retry_count} : ${response} : ${up_count}"

                if [ $up_count -ge 1 ]
                then
                  echo "> 서버 Health check 성공"
                  break
                fi

                if [ $retry_count -eq 10 ]
                then                
                  echo "> 서버 Health check 실패"
                  exit 1
                fi

                echo "> 실패 10초 후 재시도"
                sleep 10
        done
done

echo "> 리버스 프록시 방향 변경"
echo "set \$service_url http://was-$PROFILE;" > /etc/nginx/conf.d/service-url.inc
systemctl reload nginx

echo "> Blue 서버 종료"
for IP in ${IP_LIST[@]}
do
        ssh root@$IP "fuser -s -k ${BLUE_PORT}/tcp"
done

배포 후 실행할 deploy.sh는 위와 같고, 쉘 스크립트를 하나씩 자세히 살펴보자.

 

Blue Health Check

if curl -s "http://${IP_LIST[0]}:${PORT_A}/actuator/health" > /dev/null; then
        BLUE_PORT=$PORT_A
        GREEN_PORT=$PORT_B
        PROFILE=prod2
else
        BLUE_PORT=$PORT_B
        GREEN_PORT=$PORT_A
        PROFILE=prod1
fi

Blue 버전의 어플리케이션이 작동중인지 curl로 확인한다. /dev/null은 리눅스에서 null device로 불리며, 이 파일에 쓰여진 데이터는 보관하지 않고 무시하는 데 사용된다. 그리고 curl 성공 여부에 따라 if문 분기처리를 통해 BLUE_PORT와 GREEN_PORT를 설정한다.

 

jar파일 전송 및 실행

for IP in ${IP_LIST[@]}
do
        scp -p $REPOSIROTY$PROJECT_NAME root@$IP:$REPOSIROTY$PROJECT_NAME
        ssh root@$IP "nohup java -jar -Dspring.profiles.active=${PROFILE} ${REPOSITORY}${PROJECT_NAME} --JASYPT_PASSWORD=$JASYPT_KEY >> $REPOSITORYnohup.out &"
done

scp 명령을 통해 빌드된 jar 파일을 전송하고, ssh 명령으로 원격 서버에서 Java 어플리케이션을 실행한다.

 

Green Health Check

for IP in ${IP_LIST[@]}
do
        
        for retry_count in $(seq 10)
        do
                response=$(curl -s http://$IP:$GREEN_PORT/actuator/health)
                up_count=$(echo $response | grep 'UP' | wc -l)
                echo "> ${retry_count} : ${response} : ${up_count}"

                if [ $up_count -ge 1 ]
                then
                  echo "> 서버 Health check 성공"
                  break
                fi

                if [ $retry_count -eq 10 ]
                then                
                  echo "> 서버 Health check 실패"
                  exit 1
                fi

                echo "> 실패 10초 후 재시도"
                sleep 10
        done
done

Green 버전의 어플리케이션 서비스의 상태를 확인하기 위해 Health Check를 수행한다. 주어진 호스트와 포트에 대해 지정된 횟수만큼 상태를 체크하며, 서비스가 "UP" 상태가 되면 성공으로 판단하고, 일정 횟수만큼 시도한 후에도 "UP" 상태가 아니라면 실패로 판단하여 스크립트를 종료한다.

 

Nginx 리버스 프록시 방향 변경

echo "set \$service_url http://was-$PROFILE;" > /etc/nginx/conf.d/service-url.inc
systemctl reload nginx

 

위 명령어를 통해서 Nginx에서 설정한 두 upstream에 대해 GREEN 버전에 맞게 요청할 수 있도록 service-url.inc 파일 내용을 수정한 후 nginx reload를 하여 방향을 변경할 수 있다. 롤링, 카나리 배포 방식에 비해 reload하는 타이밍만 발생하기 때문에 호환성 측면에서 장점이 있다고 생각한다.

 

Blue 버전 어플리케이션 종료

for IP in ${IP_LIST[@]}
do
        ssh root@$IP "fuser -s -k ${BLUE_PORT}/tcp"
done

 

fuser 명령은 특정 포트를 사용하는 프로세스를 찾아 해당 프로세스를 종료시키는데 사용할 수 있다. -s 옵션은 프로세스를 찾을 때 출력을 하지 않고, 오류 메시지만 출력하고, -k 옵션은 프로세스를 강제로 종료한다. 즉, ${BLUE_PORT}/tcp를 사용하고 있는 프로세스를 찾아 해당 프로세스를 강제로 종료한다.

 

위 스크립트를 작성하여 분산 환경에서 무중단 배포를 성공적으로 구현할 수 있었다.

 

 

출처

젠킨스 서버 SSH Key 생성 & 등록

리눅스 scp 명령어 사용법 (파일 전송 프로토콜 / 파일 보내기 / 파일 받기)

Jenkins와 Nginx로 스프링부트 애플리케이션 무중단 배포하기

배포전략: Rolling, Blue/Green, Canary