CI/CDNginxDevOpsGitHub Actions

무중단 배포(Zero-Downtime)를 위한 Nginx와 GitHub Actions 연동기

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

Srue2026년 4월 9일
무중단 배포(Zero-Downtime)를 위한 Nginx와 GitHub Actions 연동기

처음 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번)를 둡니다.

  1. 사용자는 Nginx(80 포트)로 구글링해서 들어옵니다.
  2. Nginx는 현재 구동 중인 8081번 포트(Blue) 서버로 손님을 보냅니다.
  3. 배포(Green)가 시작됩니다! 이번 스크립트는 기존 서버를 끄는 게 아니라, 비어 있는 8082번 포트(Green)에 새로운 버전을 켭니다.
  4. 새로 띄운 8082번 포트가 완전히 정상 작동 중인지(Health Check) 확인합니다.
  5. 이상이 없다면 Nginx 설정 파일에서 트래픽의 방향을 8082번(Green)으로 '스위칭'하고 Nginx를 재장전(Reload)시킵니다.
  6. 이제 트래픽이 정상적으로 Green으로 흐르는 것을 확인 후, 옛날 버전인 8081번(Blue) 포트를 조용히 꺼버립니다.

이론은 깔끔한데 이 모든 과정을 GitHub Actions의 쉘 스크립트 하나에 녹여내야 했습니다.

스크립트 구현, 생각보다 지난했던 쉘(Shell) 다루기

서버 내부를 판별할 로컬 변수 구조를 짜는 스크립트는 이렇습니다. (자세한 설정은 생략하고 코어만 남겼습니다)

처음에는 curl -s http://localhost/actuator/env | grep server.port 결과를 그대로 -eq로 숫자 비교해서 현재 포트를 판별했는데, 이 방식은 쉽게 깨졌습니다. grep이 돌려주는 건 숫자가 아니라 JSON 조각이 섞인 한 줄이라 정수 비교가 성립하지 않았고, 애초에 /actuator/env가 항상 server.port를 노출한다는 보장도 없었습니다.

그래서 "지금 라이브인 포트"를 앱에 물어보는 대신, 고정 포트 두 개를 변수로 두고 Nginx가 실제로 바라보는 상태 파일 하나를 단일 소스로 삼는 방식으로 바꿨습니다.

deploy.sh
#!/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 자동화의 종착지가 아닐까 싶습니다.