무중단 배포(Zero-Downtime)를 위한 Nginx와 GitHub Actions 연동기
배포할 때마다 10초씩 에러 페이지가 뜨는 치명적인 문제를 방지하기 위해, Nginx와 GitHub Actions를 활용해 무중단 배포(Blue-Green)를 구축한 과정을 담았습니다.

처음 CI/CD 파이프라인을 구축했을 때 남긴 GitHub Actions로 배포 자동화 설정하기 글을 돌아보면, 초창기에는 단순 명료함을 무기로 배포 스크립트를 이렇게 끝냈습니다.
pkill -f 'my-service' || true
nohup java -jar build/libs/my-service.jar > app.log 2>&1 &하지만 유저 트래픽이 있는 시점에 무심코 merge 버튼을 눌렀다가 큰코다친 적이 있습니다. 웹서버가 종료되고 새 .jar 코드가 포트를 붙들고 Spring Context가 로드되기까지의 약 10초~15초 구간 동안, 접근하는 모든 유저가 "502 Bad Gateway"나 무한 로딩 화면을 만나게 되는 치명적인 '배포 중단(Downtime)' 현상이 발생한 것입니다.
이래서는 새벽 3시에 눈 비비고 일어나 수동 배포를 하는 꼴과 다를 바가 없었습니다. 떳떳하게 오후 2시 티타임 중에도 릴리즈할 수 있도록 Nginx의 리버스 프록시(Reverse Proxy)를 이용한 무중단 배포(Blue-Green 방식) 환경으로 환골탈태를 시도했습니다.
핵심 개념: 길라잡이 Nginx의 역방향 프록시
컨셉은 이렇습니다. Nginx가 건물을 지키는 듬직한 문지기 역할을 하고 뒤에 포트 번호가 다른 서버 두 대(8081번, 8082번)를 둡니다.
- 사용자는 Nginx(80 포트)로 구글링해서 들어옵니다.
- Nginx는 현재 구동 중인 8081번 포트(Blue) 서버로 손님을 보냅니다.
- 배포(Green)가 시작됩니다! 이번 스크립트는 기존 서버를 끄는 게 아니라, 비어 있는 8082번 포트(Green)에 새로운 버전을 켭니다.
- 새로 띄운 8082번 포트가 완전히 정상 작동 중인지(Health Check) 확인합니다.
- 이상이 없다면 Nginx 설정 파일에서 트래픽의 방향을 8082번(Green)으로 '스위칭'하고 Nginx를 재장전(Reload)시킵니다.
- 이제 트래픽이 정상적으로 Green으로 흐르는 것을 확인 후, 옛날 버전인 8081번(Blue) 포트를 조용히 꺼버립니다.
이론은 깔끔한데 이 모든 과정을 GitHub Actions의 쉘 스크립트 하나에 녹여내야 했습니다.
스크립트 구현, 생각보다 지난했던 쉘(Shell) 다루기
서버 내부를 판별할 로컬 변수 구조를 짜는 스크립트는 이렇습니다. (자세한 설정은 생략하고 코어만 남겼습니다)
처음에는 curl -s http://localhost/actuator/env | grep server.port 결과를 그대로 -eq로 숫자 비교해서 현재 포트를 판별했는데, 이 방식은 쉽게 깨졌습니다. grep이 돌려주는 건 숫자가 아니라 JSON 조각이 섞인 한 줄이라 정수 비교가 성립하지 않았고, 애초에 /actuator/env가 항상 server.port를 노출한다는 보장도 없었습니다.
그래서 "지금 라이브인 포트"를 앱에 물어보는 대신, 고정 포트 두 개를 변수로 두고 Nginx가 실제로 바라보는 상태 파일 하나를 단일 소스로 삼는 방식으로 바꿨습니다.
#!/bin/bash
set -euo pipefail
# 고정 포트 두 개를 변수로 정의하고, "지금 라이브인 포트"는 앱에 묻지 않는다.
# Nginx가 실제로 참조하는 상태 파일(service-env.inc)을 단일 소스로 삼아 판별한다.
BLUE_PORT=8081
GREEN_PORT=8082
STATE_FILE=/etc/nginx/conf.d/service-env.inc
JAR=build/libs/my-service.jar
# 1. 현재 라이브 포트 판별 — 상태 파일이 GREEN을 가리키면 다음 타겟은 BLUE, 아니면 GREEN.
# (상태 파일이 없으면 최초 배포로 보고 BLUE를 라이브로 간주)
if [ -f "$STATE_FILE" ] && grep -q ":$GREEN_PORT" "$STATE_FILE"; then
CURRENT_PORT=$GREEN_PORT
NEW_PORT=$BLUE_PORT
else
CURRENT_PORT=$BLUE_PORT
NEW_PORT=$GREEN_PORT
fi
echo ">> 현재 라이브 포트: $CURRENT_PORT / 배포 타겟 포트: $NEW_PORT"
# 2. 어플리케이션을 신규 포트로 구동
nohup java -jar -Dserver.port=$NEW_PORT "$JAR" > /dev/null 2>&1 &
# 3. 신규 어플리케이션 정상 구동 확인 (최대 30초 대기)
echo ">> Health Check 시작"
HEALTHY=0
for RETRY_COUNT in {1..10}; do
# HTTP 2xx(-sf) 와 "status":"UP" 을 함께 확인한다.
if curl -sf "http://localhost:$NEW_PORT/actuator/health" | grep -q '"status":"UP"'; then
echo ">> 상태 확인 성공 (Port: $NEW_PORT, 시도 ${RETRY_COUNT}회)"
HEALTHY=1
break
fi
echo ">> 아직 준비 안 됨, 재시도 ${RETRY_COUNT}/10"
sleep 3
done
# 3-1. Health Check 실패 시 스위칭하지 않고 신규 프로세스를 정리한 뒤 배포 실패로 종료
if [ "$HEALTHY" -ne 1 ]; then
echo ">> Health Check 실패 — 트래픽 스위칭을 중단하고 신규 포트($NEW_PORT)를 내립니다."
NEW_PID=$(lsof -ti tcp:$NEW_PORT || true)
[ -n "$NEW_PID" ] && kill -15 $NEW_PID
exit 1
fi
# 4. Nginx 스위칭 — 상태 파일을 신규 포트로 덮어써 단일 소스를 갱신
echo "set \$service_url http://127.0.0.1:$NEW_PORT;" > "$STATE_FILE"
# 설정 문법 검증(nginx -t) 후, 재시작이 아닌 부드러운 장전(Reload)
sudo nginx -t && sudo nginx -s reload
# 5. 구 버전 프로세스 종료
OLD_PID=$(lsof -ti tcp:$CURRENT_PORT || true)
if [ -n "$OLD_PID" ]; then
kill -15 $OLD_PID
echo ">> 예전 포트($CURRENT_PORT) 종료 완료."
fi이 for 재시도 루프 덕분에, 새 버전이 컨텍스트 로딩 때문에 한두 번 health check에 실패하더라도 스위칭 전에 다시 확인하고 넘어갈 수 있었습니다. 실제로 이 재시도가 배포를 살린 적이 있습니다.
주의할 점
이때 Nginx의 설정 파일인 /etc/nginx/nginx.conf 안에 동적인 위치를 참조하도록 아래의 한 줄을 추가해 두어야 service-env.inc 내용 갱신만으로 훅훅 방향이 바뀝니다.
include /etc/nginx/conf.d/service-env.inc;
location / {
proxy_pass $service_url;
# ... 기본 프록시 설정들
}완성 후, 두려움이 사라진 배포 버튼
스크립트 에러 때문에 kill -9 명령어를 터미널에 수십 번 쳐가며 고생은 했지만, 막상 구현되고 나니 그 쾌감이 엄청났습니다.
무엇보다 배포 순간의 지표가 눈에 띄게 달라졌습니다.
무중단 배포를 달성한 뒤로 얻은 가장 큰 이득은 "아무 때나, 작은 버그도 수정 즉시 배포" 할 수 있게 된 심리적인 자유입니다. 사용자는 뒤에서 배포가 10번이 일어나든 전혀 모릅니다. GitHub Actions의 초록 불이 들어옴과 동시에 리플래쉬 없이 새 기능이 짠 하고 나타나게 제어권을 확보한 것, 이것이 진정한 DevOps 자동화의 종착지가 아닐까 싶습니다.