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) 다루기

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

deploy.sh
#!/bin/bash
 
# 1. 현재 돌고 있는 포트 찾기 (health check api 호출)
CURRENT_PORT=$(curl -s http://localhost/actuator/env | grep "server.port" || echo "8081")
 
if [ $CURRENT_PORT -eq 8081 ]; then
  NEW_PORT=8082
else
  NEW_PORT=8081
fi
 
echo ">> 배포할 타겟 포트: $NEW_PORT"
 
# 2. 어플리케이션 신규 포트로 구동 (Profile 변경)
nohup java -jar -Dserver.port=$NEW_PORT build/libs/my-service.jar > /dev/null 2>&1 &
 
# 3. 신규 어플리케이션 정상 구동 확인 (최대 30초 대기)
echo ">> Health Check 시작"
for RETRY_COUNT in {1..10}
do
  UP_COUNT=$(curl -s http://localhost:$NEW_PORT/actuator/health | grep 'UP' | wc -l)
  if [ $UP_COUNT -ge 1 ]; then
    echo ">> 상태 확인 성공 (Port: $NEW_PORT)"
    break
  fi
  sleep 3
done
 
# 4. Nginx 스위칭 (동적 Include 파일 제어)
echo "set \$service_url http://127.0.0.1:$NEW_PORT;" > /etc/nginx/conf.d/service-env.inc
 
# Nginx 재시작이 아닌, 부드러운 장전(Reload) 처리
sudo nginx -s reload
 
# 5. 구 버전 프로세스 종료
OLD_PID=$(lsof -ti tcp:$CURRENT_PORT)
if [ -n "$OLD_PID" ]; then
  kill -15 $OLD_PID
  echo ">> 예전 포트($CURRENT_PORT) 종료 완료."
fi

주의할 점

이때 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 자동화의 종착지가 아닐까 싶습니다.