1. 개요
꾸준히 기능을 개발하고, 발전시켜나가는 코지메이트 프로젝트에서는 요일별로 반복되는 투두(할 일 목록)를 자동으로 추가하는 기능이 있습니다. 그런데 어느 날 투두가 추가되지 않는 문제가 발생했다는 것을 알았습니다. 이 기능은 사용자들이 반복해야하는 투두 생성을 줄이고자 만든 기능이었기 때문에, 어떤 상황에서든지 문제가 발생하면 정말 안되는 중요한 기능이었는데요. 원인을 분석해보니, CI/CD 자동 배포 중 서버가 재시작되면서 해당 시간에 실행되었어야 할 Cron Job이 실행되지 않았던 것이었습니다. 이를 해결하기 위해 무중단 배포를 구현하여 CI/CD 과정에서도 서버가 정상적으로 동작하도록 했습니다.
무중단 배포 방식은 여러가지 종류가 있는데요. 저는 그 중 가장 쉽게 구현이 가능하다고 생각한 블루-그린 방식으로 무중단 배포를 구현했습니다.
2. 필요성
- 배포 과정에서 서버가 재시작되면서 Cron Job이 누락되는 문제를 방지하자
- 서버의 Downtime을 최소화해서 애플리케이션을 이용하는 유저들의 불편함을 줄이자
3. 블루-그린 배포란?
다른 좋은 설명을 해주신 분들이 많기 때문에 개념에 대한 설명은 링크로 남기고 넘어갈게요.
https://velog.io/@hooni_/Blue-Green-무중단-배포
Blue Green 무중단 배포
멈추지 않는 우리 서비스 바톤.
velog.io
4. 구현 과정
프리티어 환경에서 무중단 배포를 구현해야했기 때문에, EC2 서버 1대를 사용하였고 이 서버 안에서 2개의 스프링 애플리케이션을 실행해서 교체하는 방식으로 구성했습니다.
이를 쉽게 구성하는 방법은 따로 없고, Nginx로 리버스 프록시 방식으로 배포할 스프링 포트를 관리하면서 내부에서 서버를 바꿔치기하는 과정으로 스크립트를 짰습니다.
배포 절차는 아래와 같습니다.
- 새롭게 배포할 서버의 포트 체크
- 새로운 서버를 우선 배포
- 배포한 서버의 Health Check
- Nginx에서 트래픽을 보내는 내부 서버의 포트 변경
- 기존 서버를 정리하고 완료
1) 기존 서버의 내부 포트 확인 & 새롭게 배포할 서버의 포트 확인
새로운 서버를 배포하기 전 어떤 포트에 배포해야하는지에 대한 정보를 알아야하겠죠? 아래 코드처럼 저는 Nginx에서 트래픽을 보낼 서버의 주소를 가져오도록 한 뒤 포트를 추출하는 방식으로 구성했습니다.
### portUtils.sh
#!/bin/bash
# echoInfo, echoError
source ./echoMessage.sh
# cleanUpByPort, stopAllContainers
source ./containerUtils.sh
# 현재 NGINX에서 서비스 중인 포트 반환
getCurrentPort() {
local NGINX_SERVICE_PORT=$(grep -oP 'set \$service_url http://127.0.0.1:\K[0-9]+' /etc/nginx/conf.d/service-url.inc)
if [ -z "$NGINX_SERVICE_PORT" ]; then
echoError "NGINX 설정에서 서비스 포트를 찾을 수 없습니다. 8080으로 세팅합니다."
stopAllContainers >&2
echo "8080"
exit 0
fi
echo "$NGINX_SERVICE_PORT"
exit 0
}
# 현재 실행 중인 포트를 가져와 반대 포트 반환
getDeployPort() {
# 현재 실행 중인 포트 가져오기
local CURRENT_PORT=$(getCurrentPort)
if [ "$?" -ne 0 ]; then
echoError "포트정보가 잘못되어 배포를 진행할 수 없습니다."
exit 1
fi
# 현재 포트가 8080이면 8081 반환, 8081이면 8080 반환
if [ "$CURRENT_PORT" == "8080" ]; then
echo 8081
exit 0
elif [ "$CURRENT_PORT" == "8081" ]; then
echo 8080
exit 0
else
echoError "현재 실행 중인 포트가 8080 또는 8081이 아닙니다."
exit 1
fi
}
이렇게 구성하게 되면, 실제 서비스되고있는 서버의 포트 정보를 쉽게 알 수 있고, 이를 바탕으로 새롭게 배포할 서버의 포트도 구할 수 있게 됩니다.
처음에는 Nginx에서 포트 정보를 가져오고, 또 docker ps 명령어를 사용해서 실행 중인 컨테이너의 포트를 확인하려고 했었는데요. 만약 서버가 이미 내려간 상태라면 이 방식(docker ps)으로는 배포할 포트를 찾을 수 없겠더라고요. 그래서 서버가 항상 떠 있다는 가정을 버리고, Nginx 설정에서만 현재 트래픽이 전달되는 포트를 가져오도록 변경했습니다. 그런데 만약 Nginx에서도 포트 정보를 가져오지 못하면 어떻게 해야 할까 고민하다가, 기본적으로 8080 포트를 사용해 배포하도록 했답니다. 이렇게 하면 어떤 상황에서도 새로운 서버가 정상적으로 배포될 수 있도록 할 수 있으니까요.
여기서 echoMessage에서 echoInfo, echoError를 함수로 구현해서 또 사용하고 있는데요. 이건 제가 로그를 수집하고, 알림을 보내기 위해서 만든 커스텀 출력도구입니다. 저 스크립트는 아래처럼 구성되어있어요.
### echoMessage.sh
#!/bin/bash
# setting.env 파일 로드
# DISCORD_WEBHOOK, TZ
if [ -f ./setting.env ]; then
export $(grep -v '^#' ./setting.env | xargs)
else
echoError "setting.env 파일이 존재하지 않습니다."
fi
# 일반 메시지 전송 (color: 1127128)
echoInfo() {
local MESSAGE="$*"
local COLOR="1127128"
echo "[INFO] $MESSAGE"
sendDiscordMessage "$MESSAGE" "$COLOR"
}
# 에러 메시지 전송 (color: 14177041)
echoError() {
local MESSAGE="$*"
local COLOR="14177041"
echo "[ERROR] $MESSAGE" >&2
sendDiscordMessage "$MESSAGE" "$COLOR"
}
# 공통 메시지 전송 함수
sendDiscordMessage() {
local MESSAGE="$1"
local COLOR="$2"
# JSON에서 안전하게 사용할 수 있도록 변환
local MESSAGE_JSON=$(jq -R -s '.' <<< "$MESSAGE")
# JSON 생성
local JSON_EMBED=$(jq -n --arg title "$MESSAGE" --arg color "$COLOR" '[{ "title": $title, "color": ($color | tonumber) }]')
# Discord Webhook Payload 생성
local PAYLOAD=$(jq -n --argjson embeds "$JSON_EMBED" '{ "embeds": $embeds }')
# Discord 메시지 전송
curl -H "Content-Type: application/json" \
-X POST \
-d "$PAYLOAD" \
"$DISCORD_WEBHOOK"
}
기능은 단순히 터미널에 메시지를 출력하고(INFO or ERROR 태그를 붙여서) 디스코드 웹훅으로 알림을 보내는 방식으로 구성되어 있습니다. 이렇게 사용하면, echo처럼 터미널에서 보여주기에도 편하고, 자동으로 진행상황 관리도 되기 때문에 배포 과정에서 추적이 더 용이하답니다.
또 Nginx에서 conf.d의 경로에 트래픽을 보낼 경로를 지정해두고 있습니다. service-url.inc라는 파일을 만들어서 환경 변수처럼 관리를 하고 있고, 아래처럼 작성되어있습니다.
### service-url.inc
set $service_url http://127.0.0.1:8080;
이 변수는 nginx의 site-available에서 트래픽을 보내는 경로를 지정할 때 사용하는데요. 일부를 가져와보면 아래처럼 사용됩니다.
###
# 무중단 배포 설정
###
# service_url
include /etc/nginx/conf.d/*.inc;
# 내부 서버로 Proxy Redirect, 기본 경로
location / {
proxy_pass $service_url;
proxy_set_header Host $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;
# 추가 헤더
proxy_set_header X-Request-ID $request_id;
}
이렇게 Nginx를 설정하고, 포트 정보를 가져와보면 아래처럼 포트 정보를 가져오는 모습을 볼 수 있습니다.
이제 첫 번째 스텝, 배포할 서버의 포트를 가져오는 작업을 마쳤습니다.
2) 새로운 서버 배포
이 과정은 기존의 CI/CD 과정을 재탕하면 끝입니다.
ECR에 로그인하고, 새로 업로드된 이미지를 가져온 다음, Docker Run을 실행하면 그만인 아주 단순한 과정이에요. 여기서 컨테이너 이름이랑, Port 정보만 좀 주의해서 변수로 입력해주면 쉽게 끝낼 수 있습니다.
### deploy.sh
###
# Docker 세팅
###
# 1. ECR 로그인
echoInfo "(1/6) ECR 로그인..."
aws ecr get-login-password --region ap-northeast-2 | sudo docker login --username AWS --password-stdin $ECR_URI
if [ "$?" -ne 0 ]; then
echoError "(1/6) 로그인 에러 발생, deploy.log에서 확인해주세요"
exit 1
fi
echoInfo "(1/6) ...완료"
# 2. 이미지 가져오기
echoInfo "(2/6) Docker Pull $IMAGE_TAG ..."
sudo docker pull $ECR_URI:$IMAGE_TAG
if [ "$?" -ne 0 ]; then # 가져온 이미지 삭제 & 기존 서버, 포트 유지
echoError "(2/6) Pull 에러 발생, deploy.log에서 확인해주세요"
exit 1
fi
echoInfo "(2/6) ...완료"
# 3. Docker Run 실행
echoInfo "(3/6) Docker Run..."
CONTAINER_NAME="cozymate-prod-server-${AFTER_PORT}"
sudo docker run -d \
--name ${CONTAINER_NAME} \
-p ${AFTER_PORT}:8080 \
-e SPRING_PROFILE=${SPRING_PROFILES} \
-e TZ=Asia/Seoul \
-e SERVER=true \
-v /home/ubuntu/cozymate/log:/log \
${ECR_URI}:${IMAGE_TAG}
if [ "$?" -ne 0 ]; then # 기존 서버 살아있는지 체크, 죽었으면 다시 실행
echoError "(3/6) Dokcer Run 에러 발생, deploy.log에서 확인해주세요"
cleanUpByPort $AFTER_PORT
exit 1
fi
echoInfo "(3/6) ...완료"
이 과정을 거치면 이제 새로운 서버도 빠르게 배포가 됩니다.
3) 배포한 서버의 Health Check
새로운 서버가 배포되었다고 바로 Port 정보를 바꿔버리면 안됩니다. 스프링 애플리케이션은 실행되는데 어느정도 시간이 필요하기 때문이죠. 그래서 우리는 스크립트로 서버가 제대로 실행되고 응답을 받을 수 있는 상황인지 Health Check를 진행할겁니다.
### healthCheck.sh
#!/bin/bash
# echoError, echoInfo
source ./echoMessage.sh
# 현재 실행 중인 서버의 포트 가져오기
CURRENT_PORT=$1
PORT_RESULT=$?
if [ "$PORT_RESULT" == "1" ]; then
echoError "잘못된 포트 데이터: $CURRENT_PORT"
exit 1
else
echoInfo "현재 열린 포트: $CURRENT_PORT"
fi
HEALTH_CHECK_URL="http://localhost:$CURRENT_PORT/actuator/health
MAX_RETRIES=20 # 최대 재시도 횟수
RETRY_INTERVAL=6 # 재시도 간격 (초)
ATTEMPTS=0 # 시도 횟수 초기화
echoInfo "헬스 체크 시작: $HEALTH_CHECK_URL"
while [ $ATTEMPTS -lt $MAX_RETRIES ]; do
HTTP_STATUS=$(curl -L -o /dev/null -s -w "%{http_code}" "$HEALTH_CHECK_URL")
if [ "$HTTP_STATUS" == "200" ]; then
echoInfo "헬스 체크 성공! (HTTP 200)"
exit 0
else
echoInfo "헬스 체크 실패 $RETRY_INTERVAL초 후 재시도... ($((ATTEMPTS+1))/$MAX_RETRIES)"
((ATTEMPTS++))
sleep $RETRY_INTERVAL
fi
done
echoError "헬스 체크 10번 실패, 서버 비정상 상태!"
exit 1
확인은 총 6초마다 20번을 진행하도록 작성했습니다. 프리티어에서 트래픽이 어느정도 몰리는 상황에서는(20명의 사용자) CPU 사용량이 많아져서 서버 실행시간이 1분을 넘기더라고요. 그래서 최대 2분동안 기다리도록 했고, 일반적으로는 40초정도에 실행이 마무리되며, 느려도 1분 30초 내에는 모두 끝나는 것을 확인했습니다. (이건 프로젝트마다 실행되는 시간이 다를테니 유동적으로 조절해야하는 부분이에요.)
이렇게 코드를 실행시키면 아래처럼 진행상황을 체크할 수 있습니다.
4) Nginx에서 트래픽을 보내는 내부 서버의 포트 변경
이제 여기까지 정상적으로 실행되었다면 모든 과정이 마무리되어갑니다.
이제는 그냥 단순하게 Nginx에서 트래픽을 보내는 내부 경로의 포트만 살짝 바꾸고, 다시 로드하면 되는 상황이에요. 그 과정은 아래처럼 됩니다.
### portUtils.sh
switchPort() {
# Nginx 설정 파일 경로
local SERVICE_URL_FILE="/etc/nginx/conf.d/service-url.inc"
# 새로운 포트 값이 입력되지 않았으면 오류 반환
if [ -z "$1" ]; then
echoError "포트 값이 입력되지 않았습니다."
exit 1
fi
local AFTER_PORT="$1"
local BEFORE_PORT=$(grep -oP 'set \$service_url http://127.0.0.1:\K[0-9]+' "$SERVICE_URL_FILE")
if [[ "$AFTER_PORT" == "8080" || "$AFTER_PORT" == "8081" ]]; then
echoInfo "변경될 포트: $AFTER_PORT"
else
echoError "잘못된 변경 포트: 값이 8080 또는 8081이 아닙니다."
exit 1
fi
# 현재 포트와 동일하면 변경하지 않음
if [ "$BEFORE_PORT" == "$AFTER_PORT" ]; then
echoError "현재 설정된 포트와 동일합니다. 변경하지 않습니다. ($BEFORE_PORT)"
exit 1
fi
# URL 변경
echo "set \$service_url http://127.0.0.1:$AFTER_PORT;" | sudo tee "$SERVICE_URL_FILE" > /dev/null
echoInfo "포트 변경 완료: $BEFORE_PORT → $AFTER_PORT"
# Nginx 재시작 (변경 사항 적용)
echoInfo "Nginx 재시작 중..."
sudo systemctl reload nginx
echoInfo "Nginx 재시작 완료!"
}
위에 정리되어있는 배포할 Port를 찾는 함수와 동일한 파일에 구성했습니다.
인자로 받는 포트 값이 있는지, 포트 값이 유효한지를 확인하고 내부 경로를 service-url.inc 파일에 덮어쓰기 하면서 변경하게 됩니다. 이후에는 sudo systemctm reload nginx를 통해서 다시 로드하게 되고요. (이 과정에서 Downtime이 0.1초정도 발생하긴 합니다.)
5) 기존 서버를 정리하고 완료
이제 모든게 끝났습니다. 마무리로 기존에 존재했던 서버를 내리고, 정리하면 끝입니다.
### containerUtils.sh
#!/bin/bash
# echoInfo, echoError
source ./echoMessage.sh
cleanUpByPort() {
local TARGET_PORT="$1"
if [ -z "$TARGET_PORT" ]; then
echoError "삭제할 컨테이너의 포트 번호를 입력하세요."
return 1
fi
echoInfo "$TARGET_PORT"의 리소스를 정리합니다.
local CONTAINER_ID
CONTAINER_ID=$(sudo docker ps --format "{{.ID}} {{.Ports}}" | grep ":$TARGET_PORT->" | awk '{print $1}')
if [ -n "$CONTAINER_ID" ]; then
sudo docker stop "$CONTAINER_ID"
sudo docker rm "$CONTAINER_ID"
fi
sudo docker system prune -af
}
Port 정보를 인자로 넘기게 되면, 실행되고 있던 컨테이너를 종료하고 삭제합니다. 이후 docker system prune -af로 불필요한 리소스를 모두 정리하고 마무리합니다.
저는 추가로 다 끝나게 되면, 간략한 정보를 표기하게 했습니다. 해당 배포는 약 70초가 걸렸네요. (이건 이미 이미지가 pull 되어있던 상태여서 70초이고, 실제로는 2~3분정도 소요됩니다.)
5. 고려해야 할 사항
이렇게 간단하게 블루-그린 무중단 배포를 하는 스크립트를 공유하고 정리해봤는데요. 이 과정중에서는 상당히 여러 종류의 에러가 발생하게 됩니다. (테스트할 때도 그렇고 이런저런 예외케이스들이 좀 보였습니다.)
그래서 각 과정에서 발생할 수 있는 문제를 대비해서 단계별 예외 처리 절차를 구성해야합니다. 저는 port를 변경하기 전 후로 나누어 전에는 새로운 서버를 종료, 정리하는 단계를 구성했고, 후에는 기존 서버를 종료, 정리하는 단계로 구성했습니다.
또한 배포 과정에서 예상치 못한 문제가 발생할 수 있기 때문에, 로그를 수집하고 실시간 모니터링 시스템(디스코드 알림)을 구축해 신속하게 대응할 수 있도록 했습니다.
6. 결과 및 결론
1) 문제를 해결했는가?
이렇게 무중단 배포를 구현한 이후, 기존의 문제였던 Cron Job을 테스트할 환경을 구성해본 결과 정상적으로 투두 데이터가 추가되는 것을 확인했습니다. 이는 제가 생각하고 구성한 방법이 유효했음을 의미하며 문제가 해결되었음을 의미합니다.
2) 추가로 고민할 부분이 있는가?
무중단 배포를 구성했지만, 그래도 Nginx가 Reload될 때 0.1초라는 Downtime이 존재합니다. 이는 트래픽이 적은 현 상황에서는 문제가 없을지도 모르나, 테스트 환경을 구성하고 진행해봤을 때 에러가 아예 발생하지 않는 상황은 아니라는 것을 알았습니다.
이 문제를 해결하기 위해 여러 자료를 찾아보았는데, https://haon.blog/haon/infra/ci-cd/zero-downtime/ 에서 해볼만한 정보를 제공해주고 있다는 것을 확인했고, 한번 공부해보고 적용해볼까 합니다. 하지만 아직 어떤 방식으로 Downtime을 제거하는지 온전히 이해하지는 못해서 공부 시간이 좀 필요할 듯 합니다.
Jenkins, Nginx 배포 환경에서 발생하는 다운타임을 온전히 제거할 수 있을까?
학습배경 [CI/CD & Nginx] Worker Process 튜닝으로 다운타임을 0.015초로 줄이기 까지의 개선과정 에서 다루었듯이, Nginx 는 reload 시 아주 짧은 다운타임이 발생합니다. 그러나 Nginx 공식문서에서는 graceful
haon.blog
3) Github Actions와의 연동은 안하는가?
설명을 필요한 부분만 잘라서 했지만, 아직은 Github Actions와 연결하지 않아서 완전한 자동화는 아닙니다. 현재 프로젝트에서 배포 서버에 반영해야할 PR이 많은데, 전체적인 진행 상에서 지금 반영하기에는 애매한 상황이어서 지금은 이렇게 스크립트를 직접 실행해서 배포할 수밖에 없었습니다. 하지만, 근 시일내에 반영을 하게 되면 다시 해당 글을 수정해서 Github Actions에서부터 서버에 반영되는 과정을 크게 설명하도록 하겠습니다.
'💻 개발' 카테고리의 다른 글
FastAPI 프로젝트에 Ruff 포맷팅을 적용해보자 (0) | 2025.02.03 |
---|---|
Spring Boot에서 DB에 초기 데이터를 넣어보자 (0) | 2025.02.02 |
@OneToOne 연관에서 발생한 N+1 문제를 해결하자 (0) | 2025.01.17 |
Spring Boot에서 Sentry가 쿼리를 추적하게 해보자 (1) | 2025.01.13 |