블루 그린 무중단 배포와 Graceful shutdown (1)

원태연·2023년 11월 13일
0
post-thumbnail
post-custom-banner

들어가며

셀럽잇 서비스를 운영하면서 무중단 배포를 하게 되었습니다.
개발 초기 단계이다 보니 다양한 기능들과 피드백 반영으로 배포가 빈번하게 이루어지는 상황이었습니다.
또, 사용자가 존재했기 때문에 매 배포 마다 발생하는 다운타임을 줄일 필요가 있었습니다.

서비스 접속자 수

저희는 여러 배포 전략 중에서, 주어진 자원과 상황을 고려하여 블루-그린 전략을 선택하였습니다.
운영 환경에서 사용 가능한 서버 인스턴스는 한 대였고, docker container를 활용하여 Spring application을 실행 하고 있는 상태였는데요. 추가적인 인스턴스 사용 없이, nginx를 활용하여 하나의 인스턴스에서 컨테이너를 통해 blue 컨테이너 에서, green 컨테이너로 요청을 이동 시키기 용이했기 때문입니다.

무중단배포 전략 이전 포스트


요청

다운타임 없이 배포가 잘 되는지 테스트 해볼 수 있는 간단한 shell script를 구성해보았습니다.
아래 script는 40초동안 0.1초 간격으로 목표 url에 요청을 보내 정상 응답과 실패 응답의 수를 출력합니다.
request-auto.sh

#!/bin/bash

TARGET_URL="{request-url}"  # 원하는 URL로 변경
TOTAL_REQUESTS=0
SUCCESS_COUNT=0
FAILURE_COUNT=0
REQUEST_INTERVAL=0.1
DURATION=40 # 실행 시간
START_TIME=$(date +%s) # 스크립트 시작 시간

# 반복 요청
while true; do
    CURRENT_TIME=$(date +%s)
    ELAPSED_TIME=$((CURRENT_TIME - START_TIME))

    if [ $ELAPSED_TIME -ge $DURATION ]; then
        break     # 실행 시간이 DURATION을 초과하면 종료
    fi

    # URL에 GET 요청을 보내고 결과를 저장 (성공 또는 실패)
    RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" "$URL")

    # 결과에 따라 성공 및 실패 횟수 증가
    if [ "$RESPONSE" = "200" ]; then
        SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
    else
        FAILURE_COUNT=$((FAILURE_COUNT + 1))
    fi
    
    TOTAL_REQUESTS=$((TOTAL_REQUESTS + 1))

    sleep $REQUEST_INTERVAL # 간격 유지
done

# 결과 출력
echo "Total Requests: $TOTAL_REQUESTS"
echo "Success Count: $SUCCESS_COUNT"
echo "Failure Count: $FAILURE_COUNT"

운영 중인 서버에 실행 결과

interval0.1s이긴 하지만 요청-응답 간의 지연시간이 포함되어 40초동안 400번이 아닌 대략 159번의 요청을 호출 한 것을 알 수 있습니다.

일반 배포 방식

위 스크립트를 실행 한 후 기존 방식으로 배포를 진행 하였습니다.
실행 중인 docker container를 종료 및 제거 한 뒤, docker image를 받아 실행하는 방식입니다.
결과는 아래와 같았는데요.

일반적인 방식으로 배포

212번의 요청 중, 110번의 실패가 발생했습니다.
대략 25.9초 가량의 다운 타임을 예상 할 수 있습니다.
(110212100=51.8%,40sec0.518=25.9sec)( ∵ \frac{110}{212} * 100 = 51.8\%, 40sec * 0.518 = 25.9sec)

상당히 긴 시간 동안의 다운타임이 발생하고 있었습니다.
이러한 문제를 무중단 배포를 통해 해결 해보도록 하겠습니다.


무중단 배포 전략

블루-그린 전략을 통해 무중단 배포를 구성하고자 합니다.
하나의 인스턴스만 사용해야 하다 보니, 인스턴스 단위가 아닌 컨테이너 단위로 무중단 배포를 하고자 합니다.

배포가 이루어졌을 때, 전략은 다음과 같습니다.

  1. 사용 가능한 포트를 찾습니다.
  2. 해당 포트로 새로 배포할 버전의 어플리케이션을 실행합니다.
  3. 정상적으로 배포가 완료 되었는지 판단 합니다.
  4. 정상 배포가 이루어졌다면, 로드밸런서의 리디렉션을 해당 포트로 변경합니다.
  5. 기존 버전의 어플리케이션을 종료합니다.

해당 과정들을 하나의 deploy.sh 스크립트에서 구성해보도록 하겠습니다.

1. 사용가능한 포트 찾기

RESPONSE_CODE=$(curl -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health)
if [ ${RESPONSE_CODE} = 200 ];
    then
        IDLE_PORT=8081
        USED_PORT=8080
    else
        IDLE_PORT=8080
        USED_PORT=8081
fi


echo "IDLE_PORT=${IDLE_PORT}"

우선 8080포트에 http 요청을 HIT 하여 상태코드를 통해 사용중인 PORT를 찾고 변수로 지정합니다.

2. 해당 포트로 새로 배포할 버전의 어플리케이션을 실행합니다.

위 작업에서 지정한 비어있는 host의 포트에 컨테이너를 실행합니다.

DOCKER_CONTAINER_NAME=backend-${IDLE_PORT} # 실행되는 포트로 구분하는 컨테이너 이름 지정

docker pull ${DOCKER_HUB_REPOSITORY}:${IMAGE_TAG}
docker run \
-d \
--name ${DOCKER_CONTAINER_NAME} \
-p $IDLE_PORT:8080 \
-e "SPRING_PROFILES_ACTIVE=prod" \
-v ${SERVER_LOG_DIR_PATH}:${DOCKER_LOG_DIR_PATH} \
${DOCKER_HUB_REPOSITORY}:${IMAGE_TAG}

3. 정상적으로 배포가 완료 되었는지 판단 합니다.

우선, 새로운 버전이 실행될 때 까지의 정적의 시간을 기다립니다(sleep 30).
정적으로 설정해둔 시간 이후에 요청을 보낸 뒤, 응답을 기준으로 정상적으로 실행 되었는지 판단합니다.

sleep 30
HEALTHY_CODE=$(curl -o /dev/null -w "%{http_code}" http://localhost:${IDLE_PORT}/actuator/health)
if [ ${HEALTHY_CODE} != 200 ];
    then
            IDLE_CONTAINER_ID=$(docker ps -q --filter "publish=${IDLE_PORT}")
            docker stop ${IDLE_CONTAINER_ID}
            docker rm ${IDLE_CONTAINER_ID}
            echo "TERMINATED"
            exit 1
fi

이때 200 OK 이외의 응답이 오면, 정상적으로 실행되지 않았다고 판단합니다.
IDLE_PORT를 기준으로 네이밍한 점을 고려하여 새로운 컨테이너를 찾고, 해당 컨테이너를 종료합니다. => 배포 실패로 판단한 뒤 script를 종료합니다.

4. 정상 배포가 이루어졌다면, 로드밸런서의 리디렉션을 해당 포트로 변경합니다.

인스턴스 내부에서 nginx를 통해 로드밸런싱을 하고 있는데요.
80, 443포트(http, https)의 요청을 어플리케이션 포트로 포워딩 하고 있습니다.
포인팅하는 포트를, 배포되는 어플리케이션의 포트에 맞게 동적으로 변경해주어야 합니다. service_url 변수를 동해 동적으로 포트를 설정하도록 하였습니다.

echo "set \$service_url http://127.0.0.1:${IDLE_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
sudo service nginx reload

echo 값을 전달 받아 sudo tee /etc/nginx/conf.d/service-url.inc에 값을 추가합니다.
service-url.inc라는 nginx conf 파일에 포함된 코드 블록을 실행하여 service_url의 값을 수정하는 방식으로 동작시킵니다.

아래 nginx 설정을 보면,
include /etc/nginx/conf.d/service-url.inc;
를 통해 service-url.inc을 포함(include)하여 service_url을 동적으로 설정(set)합니다.

default

server {
  listen 443 ssl;
  server_name ${domain};

  include /etc/nginx/conf.d/service-url.inc;
  location / {
    proxy_pass $service_url;
    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;
    proxy_set_header X-Forwarded-Proto $scheme;
  }

5. 기존 버전의 어플리케이션을 종료합니다.

이전 단계들이 잘 동작하면, 이전 버전의 어플리케이션의 컨테이너 아이디를 찾은 뒤, 종료합니다.

USED_CONTAINER_ID=$(docker ps -q --filter "publish=${USED_PORT}")
docker stop ${USED_CONTAINER_ID}
docker rm ${USED_CONTAINER_ID}
docker image prune -f

최종

위 과정들을 아우르는 deploy.sh는 아래와 같습니다.

RESPONSE_CODE=$(curl -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health)
if [ ${RESPONSE_CODE} = 200 ];
    then
        IDLE_PORT=8081
        IDLE_MONITORING_PORT=18082 # 위 방식과 동일하게 모니터링 포트도 분리하여 구성
        USED_PORT=8080
    else
        IDLE_PORT=8080
        IDLE_MONITORING_PORT=18081
        USED_PORT=8081
fi


echo "IDLE_PORT=${IDLE_PORT}"
echo "IDLE_MONITORING_PORT=${IDLE_MONITORING_PORT}"

APP_VERSION_TAG=1.0.1
IMAGE_TAG=back-dev-${APP_VERSION_TAG}
DOCKER_CONTAINER_NAME=backend-${IDLE_PORT}
DOCKER_HUB_REPOSITORY=${레포지토리}
SERVER_LOG_DIR_PATH={host 로그 볼륨 경로}
DOCKER_LOG_DIR_PATH={컨테이너 로그 생성 경로}

docker pull ${DOCKER_HUB_REPOSITORY}:${IMAGE_TAG}
docker run \
-d \
--name ${DOCKER_CONTAINER_NAME} \
-p $IDLE_PORT:8080 \
-p $IDLE_MONITORING_PORT:18080 \
-e "SPRING_PROFILES_ACTIVE=dev" \
-v ${SERVER_LOG_DIR_PATH}:${DOCKER_LOG_DIR_PATH} \
${DOCKER_HUB_REPOSITORY}:${IMAGE_TAG}


# 새로 뜬 컨테이너 확인
sleep 30
HEALTHY_CODE=$(curl -o /dev/null -w "%{http_code}" http://localhost:${IDLE_PORT}/actuator/health)
if [ ${HEALTHY_CODE} != 200 ];
    then
            IDLE_CONTAINER_ID=$(docker ps -q --filter "publish=${IDLE_PORT}")
            docker stop ${IDLE_CONTAINER_ID}
            docker rm ${IDLE_CONTAINER_ID}
            echo "TERMINATED"
            exit 1
fi

echo "set \$service_url http://127.0.0.1:${IDLE_PORT};set \$service_monitoring_url http://127.0.0.1:${IDLE_MONITORING_PORT};" | sudo tee /etc/nginx/conf.d/service-url.inc
sudo service nginx reload

USED_CONTAINER_ID=$(docker ps -q --filter "publish=${USED_PORT}")
docker stop ${USED_CONTAINER_ID}
docker rm ${USED_CONTAINER_ID}
docker image prune -f

무중단 배포를 통해 배포후 만들어두었던 요청 script를 실행하면 잘 동작하는 것을 확인 할 수 있습니다.

Graceful shutdown

위 과정에서 고민해볼 포인트가 있습니다.
새로운 버전의 어플리케이션이 실행되기 전까지, 이전 어플리케이션에선 모든 요청을 처리하고 있는데요. 이때, 처리가 완료 되지 않은 상황에서 새로운 어플리케이션이 실행 되면서 이전 어플리케이션이 종료 되면 사용자의 요청이 정상적으로 처리되지 않을 것 입니다.

이미지로 그려보면 다음과 같은 상황입니다.

만들어두었던 script를 활용하여 테스트 해보겠습니다.

@GetMapping("/test")
ResponseEntity<Void> test() throws InterruptedException {
    Thread.sleep(10000);
    return ResponseEntity.ok().build();
}

처리하는데 10초 정도 걸리는 요청에 대해 요청 script를 실행하면 어떨까요?

script가 동기적으로 수행 되니, 백그라운드로 여러번 실행하여 종합적으로 테스트를 수행하였습니다.

10초 간격으로 요청-응답이 이루어져 20번의 sh을 실행하였습니다.(수동 멀티쓰레드 요청..)

결과는 아래와 같습니다.

예상했던 것처럼, 무중단 배포 전략으로 실행했음에도 불구 하고 몇번의 실패 케이스가 발생했습니다.

다음 글에서 이를 해결하는 방식과 관련하여 graceful shutdown에 대해 살펴보도록 하겠습니다.

profile
앞으로 넘어지기
post-custom-banner

5개의 댓글

comment-user-thumbnail
2024년 1월 3일

이런 거 그림 어디서 그려요?

1개의 답글
comment-user-thumbnail
2024년 4월 5일

안녕하세요 혹시 사용자 수 측정은 어떤 방식으로 하셨나요?

1개의 답글