olrlobt

[INFRA] Blue/Green 무중단 배포(Zero Downtime Deployment) 본문

Infra

[INFRA] Blue/Green 무중단 배포(Zero Downtime Deployment)

olrlobt 2024. 7. 13. 22:11

 

SSAFY 2학기 공통 프로젝트에서 INFRA를 맡았었다. 처음 해보는 인프라였기에 개발서버와 운영서버를 분리할 생각조차 하지 못했고, 프로젝트를 진행하면서 프론트 API 연결작업을 할 때는 운영서버의 API를 가져와 사용했다. 그러다 보니, 배포 과정에서 Downtime이 생기게 되어 서버가 재실행되는 동안 FE에서 API 연결 작업을 할 수 없는 불편함을 느꼈다. 이후, 개발서버 분리와 무중단 배포에 대해서 알게 되었고, 개인적으로 도전하고 싶어서 자율 프로젝트 때 팀원들에게 이 부분은 꼭 내가 하고 싶다고 말했었다. 

 

이번 포스팅에서는 무중단 배포에 대해 알아보고, 자율 프로젝트에서 어떤 식으로 구현했는지 정리해 본다.


다운타임 (Downtime)

서버가 올라가는 동안 API 호출이 불가능한 상태를 일반적으로 "다운타임(downtime)"이라고 한다. 다운타임은 서버나 서비스가 일시적으로 작동하지 않아 사용자나 다른 시스템이 해당 서버에 접근할 수 없는 상태를 의미한다.

 

이때, 서버 유지보수, 업데이트, 하드웨어 업그레이드 등 계획된 작업으로 인해 발생하는 다운타임을 계획된 다운타임 (Planned Downtime)이라고 하며, 서버 오류, 하드웨어 고장, 네트워크 문제 등 예기치 못한 상황으로 인해 발생하는 다운타임을 비계획 다운타임 (Unplanned Downtime)이라 한다.

 

무중단 배포 (Zero Downtime Deployment)

무중단 배포는 애플리케이션이나 서비스의 새 버전을 배포하는 동안 서비스 중단 없이 사용자가 지속적으로 서비스를 이용할 수 있도록 하는 배포 전략이다이름에서 알 수 있듯이 다운타임을 제로로 만드는 방법이며, 무중단 배포는 다음과 같은 장단점을 갖는다.

 

 

무중단 배포의 장점

  • 고가용성: 서비스 중단 없이 새로운 기능을 배포할 수 있어, 사용자 경험이 유지된다.
  • 빠른 롤백: 문제 발생 시 신속하게 이전 버전으로 롤백할 수 있어, 서비스의 안정성이 높아진다.
  • 연속적인 업데이트: 빈번한 배포와 업데이트가 가능해, 빠른 기능 추가와 버그 수정을 지원한다.

무중단 배포의 단점

  • 복잡성 증가: 동시 운영 환경, 트래픽 전환, 모니터링 등 관리가 복잡해질 수 있다.
  • 리소스 요구: 두 개의 운영 환경을 유지해야 하므로, 리소스와 비용이 증가할 수 있다.
  • 인프라 요구: 무중단 배포를 지원하는 인프라와 도구가 필요하다.

 

 

무중단 배포의 종류

무중단 배포의 주요 전략으로는 Blue/Green 배포, 롤링 업데이트, 카나리 배포가 있다. 여기서 Blue/Green의 경우 한 번에 전환이 이루어지는 방식이고, 롤링 업데이트카나리 배포점진적인 방식이다.

 

1. Blue/Green 배포

  • 환경 분리: 두 개의 운영 환경(Blue와 Green)을 유지한다. 현재 운영 중인 환경을 Blue라고 하고, 새 버전이 배포될 환경을 Green이라고 한다.
  • 배포 및 테스트: Green 환경에 새 버전을 배포하고, 충분히 테스트한다.
  • 트래픽 전환: Green 환경이 안정적이라면, 로드 밸런서를 통해 트래픽을 Blue 환경에서 Green 환경으로 전환한다. 문제가 발생하면 즉시 Blue 환경으로 롤백한다.

2. 롤링 업데이트

  • 순차적 업데이트: 서버나 컨테이너 그룹을 순차적으로 업데이트하여, 모든 서버가 새 버전으로 전환될 때까지 점진적으로 진행한다.
  • 부하 분산: 일부 서버가 업데이트되는 동안 나머지 서버는 여전히 트래픽을 처리하므로, 전체 서비스가 중단되지 않는다.

3. 카나리 배포

  • 소규모 배포: 새 버전을 소수의 사용자에게 먼저 배포하여, 실사용 환경에서 문제를 확인한다.
  • 점진적 확대: 초기 사용자 그룹에서 문제가 없으면 점진적으로 배포 범위를 확대하여 전체 사용자에게 배포한다.

 

모든 기술과 전략이 그렇지만, 항상 내가 사용하는 환경과 요구사항에 적합한지를 고려해서 선택해야 한다. Blue/Green 배포는 빠른 롤백과 무중단 배포가 필요한 경우에 적합하지만, 두 개의 운영 환경을 띄워야 하므로 충분한 여유 공간이 필요하다. 롤링 업데이트카나리 배포의 경우 점진적인 배포로 사용자 피드백에 따라 유연한 처리가 가능하지만, 이에 따라 전체적인 배포 시간은 오래 걸리는 편이다.

 

나의 경우에는 인프라가 충분히 확보되어 있고, 서비스 중단을 줄이는 것이 목적이며, 개발 환경에서 포트폴리오용 프로젝트이기 때문에 대규모 변경이 많이 일어나는 환경이었다. 따라서 점진적 배포는 불필요하였고, Blue/Green 전략을 사용하기로 했다.


Blue/Green 배포 전략

앞서 간단히 설명을 했지만, 다시 한번 살펴보자.

Blue/Green 배포 전략은 두 개의 운영 환경(Blue와 Green)을 사용하는 배포 전략이다. 이때, 이론적으로 Blue 환경은 현재 프로덕션에서 운영 중인 환경을 뜻하고, Green 환경은 새 버전의 애플리케이션이 배포될 환경을 의미한다. 새로운 애플리케이션 버전이 Green 환경에 배포되고 테스트가 완료되면, 로드 밸런서를 사용하여 트래픽을 Blue 환경에서 Green 환경으로 전환하는 방식이다. 이때, 문제가 발생하면 Blue환경이 아직 종료가 되지 않았기 때문에, 즉시 트래픽을 다시 Blue 환경으로 롤백할 수 있다.

 

여기서 '이론적으로'라는 표현을 쓴 이유는 특정 색이 항상 운영 중인 환경이어야 할 필요가 없기 때문이다. 중요한 점은 두 환경 중 하나가 항상 운영 중이어서 사용자에게 서비스를 제공하고, 다른 하나는 새로운 버전 배포와 테스트를 위해 사용된다는 것이다.

 

 

현재 프로젝트의 로드밸런서는 Nginx를 사용하고 있다
현재 프로젝트의 로드밸런서는 Nginx를 사용하고 있다

 

현재 내 프로젝트의 경우, Nginx를 로드 밸런서로 사용하고 있다. 따라서, 전체적인 동작 과정은 다음과 같다.

 

1. Docker에 기존에 운영 중인 환경을 Blue로 명칭 한다.

2. 새로 릴리즈할 환경을 Green으로 명칭 하여 동시에 Ec2서버에 띄운다. 

3. Green 환경을 테스트하고 HeathCheck 한다.

4. Nginx의 트래픽을 Green으로 변경하고, 기존 Blue 서버를 중단한다.

 

 

 


Blue/Green 무중단 배포 적용하기

Health Checks를 위한 Spring Actuator

Spring Actuator는 Spring Boot 애플리케이션의 모니터링 및 관리 기능을 제공하는 라이브러리이다. 개발자가 애플리케이션의 상태와 운영 정보를 쉽게 확인하고 관리할 수 있도록 도와주는 역할을 하는데, 여기서 우리가 이용할 기능은 헬스 체크(Health Checks)이다.

 

헬스 체크는 간단하게 /actuator/health 엔드포인트를 통해 애플리케이션의 상태를 확인할 수 있는데, 이를 통해 릴리즈 환경이 제대로 실행되었는지 확인하는 작업을 할 것이다.

 

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-actuator', version: '3.2.5'

 

간단하게 Build.gradle에 위처럼 의존성을 추가해 주면 바로 사용이 가능하고, 

 

management:
  endpoints:
    web:
      exposure:
        include: health
        exclude: '*'

 

'application.properties' 또는 'application.yml'에서 설정을 통해 불필요한 엔드포인트 접근을 방지할 수 있다. 이번 포스팅에서 사용하는 것은 헬스체크뿐이기 때문에, 불필요한 엔드포인트는 접근을 막아주자

 

 

application.yml 동적 포트 설정(선택)

Blue/Green 배포에서는 새로운 버전의 애플리케이션을 배포할 때 일시적으로 두 버전이 동시에 실행된다. 따라서 두 버전이 같은 포트를 사용하면 포트 충돌이 발생하여 문제를 발생시킬 수 있으므로, 동적 포트를 할당해 주어야 한다. 하지만, Docker를 사용하는 환경에서는 외부포트와 내부포트가 분리되기 때문에, 접근을 위한 외부 포트만 변경해 주면 된다.

 

따라서 Docker를 사용하는 경우 이 설정은 선택적이며, profile을 설정함에 따라 로깅 작업을 할 때 더 편리함이 있음만 알아두고 넘어가자.

spring:
  profiles:
    active: blue                // 기본적으로 활성화할 프로파일
  application:
    name: blog-widget
server:
  servlet:
    encoding:                // 기타 다른 설정들
      charset: utf-8
      enabled: true
      force: true


---                        // Blue 프로파일 설정 : 8080 포트를 사용
spring:
  config:
    activate:
      on-profile: blue
server:
  port: 8080

---                        // Green 프로파일 설정 : 8081 포트를 사용
spring:
  config:
    activate:
      on-profile: green
server:
  port: 8081

 

 

docker-compose.yml 설정 

앞서 설정한 application.yml 설정 파일이 적용될 수 있도록, docker-compose에서 environment 설정을 해 준다. 마찬가지로 외부 포트를 동일시할 경우에는 똑같이 8080 포트를 바라보게 하면 되고, 분리를 하였다면 그에 맞추어 설정을 추가해 주자.

version: '3'

services:
  spring-blue:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      - TZ=Asia/Seoul
      - SPRING_PROFILES_ACTIVE=production // ,blue

  spring-green:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8081:8080" // application.yml 설정에 따라 변경
	environment:
	  - TZ=Asia/Seoul
	  - SPRING_PROFILES_ACTIVE=production // , green
      
/* 기타 다른 서비스 */

 

 

 

Bash를 이용한 Blue/Green 적용

먼저, Bash를 이용한 방법으로 Blue/Green 무중단 배포를 적용해 보았다. 정상적으로 작동하는지 궁금했고, Blue/Green에 대한 이해가 필요했다. 아래 과정은 메모장에 deploy.sh 파일로 적어서 EC2로 이동해도 되고, ubuntu환경에서 직접 만들어서 사용해도 상관없다.

 

#1 릴리즈 환경 색상 판별과 실행

#1
EXIST_BLUE=$(docker-compose ps | grep "spring-blue" | grep Up)

if [ -z "$EXIST_BLUE" ]; then
    docker-compose up -d spring-blue
    BEFORE_COLOR="green"
    AFTER_COLOR="blue"
    BEFORE_PORT=8081
    AFTER_PORT=8080
else
    docker-compose up -d spring-green
    BEFORE_COLOR="blue"
    AFTER_COLOR="green"
    BEFORE_PORT=8080
    AFTER_PORT=8081
fi

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

 

앞서 Blue/Green 색상은 별 의미가 없다고 언급했었다. 따라서, 현재 어떤 색상의 이름으로 서비스가 실행되고 있는 지를 판별할 필요가 있다. 

 

먼저 EXIST_BLUE 변수에 운영환경이 어떤 색상의 이름으로 실행 중인지 판별하여 저장해야 한다.

따라서, `docker-compose ps` 명령을 사용하여 현재 실행 중인 컨테이너 목록을 확인한다. 여기서 `grep` 명령어를 통해 

'spring-blue'라는 이름의 서비스를 필터링하고, 이 서비스가 Status가 'Up' 실행 중인지 확인한다.

 

실행 중이라면, EXIST_BLUE에는 True라는 값이 들어가고, 실행 중이지 않다면 빈 값이 들어가게 된다.

 

#1 명령어 실행 모습
#1 명령어 실행 모습

 

이후 `if [ -z "$EXIST_BLUE" ];` 조건식으로 알맞은 변수를 설정해 준다. 예를 들어 Blue서비스가 이미 실행 중이라면 EXIST_BLUE 변수에 값이 있으므로, else 블록이 실행된다. 이 블록에서는 `spring-green` 서비스를 백그라운드에서 실행하며, 뒤에서 사용할 변수들을 지정해 준다.

 

echo문은 필요하지 않다면 제거해도 좋다.

 

#2 릴리즈 환경 응답 확인

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

if [ $cnt -eq 10 ]
then
    echo "===== 서버 실행 실패 ====="
    exit 1
fi

 

#1의 과정에서 색상을 확인하고, 그에 반대되는 색상으로 서비스를 실행시켰다.

#2에서는 Spring Actuator를 이용하여 릴리즈 환경이 정상적으로 실행되었음을 확인하는 과정이다.  이때, Spring의 경우 실행하는 시간까지 고려하여 10초간 대기하는 과정을 10번 반복하게 코드를 작성하였다. 확인해 보니, 내 서비스의 경우 5번이 반복되기 전에 실행이 완료된다. 따라서, 개인 프로젝트에 맞게 시간과 반복 횟수를 조절하면 좋을 것이다.

 

또한, exit 부분에서 서버 실행이 실패되었음에도 Docker에서 프로젝트가 돌아가는 경우가 발생할 수 있다. 이 부분에 ` docker-compose down -v ${AFTER_COLOR}`를 추가해서 서비스를 종료하는 코드를 추가적으로 넣는 걸 고려해 보자.

 

 

#3 로드 밸런서 Nginx 설정 변경

# 3
echo "===== Nginx 설정 변경 ====="
docker exec -it nginx /bin/bash -c "sed -i 's/:${BEFORE_PORT}/:${AFTER_PORT}/' /etc/nginx/conf.d/default.conf && nginx -s reload"

 

#2에서 서버가 정상적으로 실행이 되었다면, 로드밸런서를 이용하여 트래픽을 릴리즈 서버로 전환시켜 주어야 한다.

나는 Nginx를 컨테이너로 사용하고 있기 때문에 `docker exec` 명령어를 사용하였다. 그리고 `sed -i ` 명령어를 사용하여 ' /etc/nginx/conf.d/default.conf' 경로의 Nginx 설정 파일의 ':BEFORE_PORT를 ':AFTER_PORT'로 치환해 주었다.

 

 

nginx.conf의 Server 블록

즉, #3의 과정을 거치면 nginx의 default.conf 파일 sever 블록단에 있는 8080 포트가, 8081 포트로 바뀌게 되는 것이다.

server {
    listen 443 ssl;
    server_name blogwidget.com;
    server_tokens off;

    ssl_certificate /etc/letsencrypt/live/blogwidget.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blogwidget.com/privkey.pem;


    location / {
        proxy_pass http://blogwidget.com:8080; // 이 부분이 8081로 변경 됨.
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

 

 

#4 기존 운영서버 서비스 종료

# 4
echo "$BEFORE_COLOR server down(port:${BEFORE_PORT})"
docker-compose stop spring-${BEFORE_COLOR}

 

트래픽 전환까지 마무리가 되었다면, 기존 사용 중인 서버를 종료해주어야 한다. 간단하게 `docker-compose stop` 명령어로 종료해 준다.

 

 

# deploy.sh 전체 코드

#1
EXIST_BLUE=$(docker-compose ps | grep "spring-blue" | grep Up)

if [ -z "$EXIST_BLUE" ]; then
    docker-compose up -d spring-blue
    BEFORE_COLOR="green"
    AFTER_COLOR="blue"
    BEFORE_PORT=8081
    AFTER_PORT=8080
else
    docker-compose up -d spring-green
    BEFORE_COLOR="blue"
    AFTER_COLOR="green"
    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://blogwidget.com:${AFTER_PORT}/actuator/health)
    if [ -z "${UP}" ]
        then
            sleep 10
            continue
        else
            break
    fi
done

if [ $cnt -eq 10 ]
then
    echo "===== 서버 실행 실패 ====="
    exit 1
fi

# 3
echo "===== Nginx 설정 변경 ====="
docker exec -it nginx /bin/bash -c "sed -i 's/${BEFORE_PORT}/${AFTER_PORT}/' /etc/nginx/conf.d/default.conf && nginx -s reload"

echo "$BEFORE_COLOR server down(port:${BEFORE_PORT})"
docker-compose stop spring-${BEFORE_COLOR}

 

 

실행

ubuntu로 작성한 스크립트를 이동시켜 준다.

scp -i /path/to/private/key /fakePath/deploy.sh ubuntu@remote_host:/home/ubuntu/deploy.sh

 

현재는 home 위치로 이동시켰고, 해당 위치에서 bash를 실행하면 docker-compose 명령어가 현재 위치에서 시작되기 때문에, 같은 위치에 docker-compose.yml을 위치시켜야 한다. 또한 해당 docker-compose 설정에 따라 Dockerfile과. jar파일도 적절히 위치시켜야 한다.

 

그런 다음 명령어를 통해 Bash스크립트를 실행한다.

$ ./deploy.sh

Blue가 이미 실행중 / Green이 이미 실행중
Blue가 이미 실행중 / Green이 이미 실행중

 

스크립트에  따라 docker에서 실행 중 컨테이너 색상을 찾고, 이에 맞게 배포를 실행하는 것을 알 수 있다.


Jenkins를 이용한 Blue/Green 적용

 

Bash뿐만 아니라, Jenkins의 CI/CD에서도 스크립트를 통하여 무중단 배포를 적용할 수 있다.

전체적인 구성 방법은 앞선 Bash 방법과 같으며, 문법의 차이만 있으니 구체적인 설명은 생략한다.

 

먼저, 앞선 방법과 똑같이 Health Checks를 위한 Spring Actuator, 동적 포트를 할당하고 진행한다.

 

Jenkins CD PipeLine

pipeline { 
    agent any

    stages {
        stage ('[Test]') {
            /** 생략 **/
        }
        stage ('[Build]') {
             /** 생략 **/
        }
        stage ('[Deploy]') {
           	 /** 생략 **/
        }
        stage ('Service Start') {
            steps {
                 script {
                    def status = sh(script: '''
                        EXIST_BLUE=$(docker-compose ps | grep "backend-blue" | grep Up || echo "NoActive")
                        echo "EXIST_BLUE: $EXIST_BLUE"
                    ''', returnStdout: true).trim()

                    def BEFORE_COLOR = status.contains("NoActive") ? "green" : "blue"
                    def AFTER_COLOR = status.contains("NoActive") ? "blue" : "green"
                    def BEFORE_PORT = status.contains("NoActive") ? 8001 : 8000
                    def AFTER_PORT = status.contains("NoActive") ? 8000 : 8001

                    sh "scp -i /ssh/private/.pem /var/jenkins_home/workspace/lightswitch-web-cd/prometheus.yml ubuntu@ip-172-26-5-31:/home/ubuntu/prometheus/prometheus.yml"
                    sh "export COMPOSE_PROFILES=$AFTER_COLOR && docker-compose up -d --build"

                    int retryCount = 0
                    while (retryCount++ < 5) {
                        echo "===== Checking server response (${retryCount}/10) ====="
                        def UP = sh(script: "curl -s http://lightswitch.kr:${AFTER_PORT}/api/actuator/health || echo 'NoActive'", returnStdout: true).trim()
                        if (UP == "NoActive") {
                            sleep(10)
                        } else {
                            break
                        }
                    }

                    if (retryCount == 5) {
                        error("Server startup failed")
                    }

                    sh """
                        docker exec nginx /bin/bash -c "cp /etc/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf.bak && sed 's/${BEFORE_COLOR}:${BEFORE_PORT}/${AFTER_COLOR}:${AFTER_PORT}/' /etc/nginx/conf.d/default.conf.bak > /etc/nginx/conf.d/default.conf && nginx -s reload"

                        echo "${BEFORE_COLOR} server down(port:${BEFORE_PORT})"
                        docker-compose stop backend-${BEFORE_COLOR}
                    """
                }
            }
            post{
                success {
                    echo 'Success Service Start'
                }
                failure {
                    error 'Fail Service Start'
                }
            }
        }
    }
   
}

 

Comments