Blue-Green 배포 구현하기 (Nginx + Podman + Spring Boot)
배경
K8S 를 사용할 때는 쉽게 무중단 배포를 구현할 수 있다. 하지만 Kubernetes(K8S)를 작은 프로젝트에 도입할 수는 없다. 과도한 엔지니어링이 되어버리기 때문이다. K8S 를 사용하지 않는 경우에는 어떻게 무중단 배포를 할 수 있을까?
현재는 Podman 을 이용하여 K-DEVCON 서비스를 운영하고 있다. Spring Boot 서버는 Nginx Proxy 를 거쳐 운영되고 있다. 현재 구성은 컨테이너를 재생성하는 동안 순단이 발생한다. 서비스 규모가 작아서 큰 문제는 아니었지만, 항상 아쉬움이 있었기 때문에, K8S 없이 Blue-Green 배포 방식으로 무중단 배포를 구현해보기로 했다.
기존 구조
Nginx (Podman 컨테이너) → host.containers.internal:8080 → App 컨테이너
- Nginx도 Podman 컨테이너로 운영 중
- 단일 슬롯(8080)으로 운영
podman-compose up --force-recreate로 배포 → 컨테이너 재시작 동안 순단 발생
목표 구조
Nginx → upstream (8080 or 8081) → Blue 또는 Green 컨테이너
- Blue(8080)와 Green(8081) 두 슬롯을 두고
- 비활성 슬롯에 새 버전을 배포하고 헬스체크 통과 후
- Nginx upstream 전환으로 트래픽을 무중단 전환
1. Nginx 설정 변경
기존 conf 파일에서 upstream 블록을 추가하고, proxy_pass가 upstream을 참조하도록 변경한다.
upstream k_devcon_backend {
server host.containers.internal:8080; # blue
# server host.containers.internal:8081; # green (전환 시 주석 교체)
keepalive 8;
}
server {
listen 443 ssl;
http2 on;
server_name api.k-devcon.com;
# SSL, 보안 헤더 등 기존 설정은 그대로 유지
location / {
proxy_pass http://k_devcon_backend; # 직접 IP 대신 upstream 참조
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_pass 부분만 upstream 블록으로 빼면 되기 때문에 SSL, 보안 헤더, WebSocket 설정 등은 건드리지 않아도 된다.
keepalive 8은 Nginx worker 프로세스 1개당 upstream과 유지할 최대 유휴 연결 수다. 이 설정이 있으면 Nginx가 upstream과의 연결을 재사용하므로 매 요청마다 TCP 핸드셰이크를 하지 않아도 된다. reload 시에는 기존 keepalive 연결이 정리되면서 새 upstream으로 자연스럽게 전환되기 때문에 Blue-Green 전환이 더 깔끔해진다. 소규모 서비스 기준으로 8 정도면 충분하다고 하여 일단 8으로 잡았다.
배포 스크립트에서 sed로 upstream의 주석을 교체하는 방식으로 트래픽을 전환한다. 배포 스크립트는 아래에서 다시 이야기 한다.
2. podman-compose.yml 구성
Blue와 Green 두 서비스를 정의할 때, 환경변수/볼륨/로깅 같은 공통 설정이 중복된다. YAML anchor(&)와 alias(*)를 활용하면 한 곳에서 관리할 수 있다.
x-server-common: &server-common
image: k-devcon-server:latest
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://...
- SPRING_DATASOURCE_USERNAME=...
- SPRING_DATASOURCE_PASSWORD=...
- SPRING_JPA_SHOW_SQL=false
- SPRING_PROFILES_ACTIVE=...
volumes:
- ./logs:/var/log/app:U
- ./og-html:/app/og-html:U
- ./resolv.conf:/etc/resolv.conf:ro
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "10"
services:
k-devcon-server-blue:
<<: *server-common
container_name: k-devcon-server-blue
ports:
- "8080:8080"
restart: "unless-stopped"
k-devcon-server-green:
<<: *server-common
container_name: k-devcon-server-green
ports:
- "8081:8080" # 호스트 8081 → 컨테이너 내부 8080 (내부 포트는 Blue와 동일)
restart: "no"
x- prefix
x- prefix는 Docker/Podman Compose 스펙에서 사용자 정의 확장 필드를 나타낸다. Compose 파서는 services, volumes, networks 같은 예약 키워드 외의 최상위 키를 만나면 오류를 내는데, x- prefix가 붙으면 “커스텀 필드”로 인식하고 무시한다.
x-→ Compose 스펙 (파싱 오류 방지)&→ YAML 자체 기능 (anchor 선언,<<: *로 참조)
container_name 주의
Blue와 Green에 동일한 container_name을 쓰면 이름 충돌로 두 번째 컨테이너가 실행되지 않는다. 반드시 k-devcon-server-blue, k-devcon-server-green으로 구분해야 한다.
Green의 restart 정책
Green 슬롯은 평소에 중지 상태로 유지되므로 restart: "no"로 설정한다. 자동 재시작이 필요 없다.
3. 배포 스크립트
#!/bin/bash
# deploy-all.sh
set -e
echo "🌟 [ALL] Blue-Green 배포를 시작합니다..."
NGINX_CONTAINER="k-devcon-nginx"
NGINX_CONF="/etc/nginx/conf.d/api.k-devcon.com.conf"
ACTIVE_FILE="$HOME/k-devcon/.active_slot"
# 현재 활성 슬롯 확인
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
ACTIVE="blue"
fi
if [ "$ACTIVE" = "blue" ]; then
NEXT="green"
NEXT_PORT=8081
NEXT_SERVICE="k-devcon-server-green"
PREV_SERVICE="k-devcon-server-blue"
else
NEXT="blue"
NEXT_PORT=8080
NEXT_SERVICE="k-devcon-server-blue"
PREV_SERVICE="k-devcon-server-green"
fi
echo "▶ 현재 활성: $ACTIVE → 배포 대상: $NEXT (:$NEXT_PORT)"
# 1. 비활성 슬롯에 새 버전 띄우기
echo "--- [DEPLOY TO $NEXT] ---"
podman-compose up -d --force-recreate $NEXT_SERVICE
# 2. 헬스체크 (최대 60초)
echo "⏳ 헬스체크 중... (http://localhost:$NEXT_PORT/actuator/health)"
for i in $(seq 1 20); do
STATUS=$(curl -sf "http://localhost:$NEXT_PORT/actuator/health" \
| grep -o '"status":"UP"' || true)
if [ -n "$STATUS" ]; then
echo "✔ 헬스체크 통과!"
break
fi
if [ $i -eq 20 ]; then
echo "✘ 헬스체크 실패 — 배포 중단 (기존 $ACTIVE 유지)"
podman-compose stop $NEXT_SERVICE
exit 1
fi
echo " 대기 중... ($i/20)"
sleep 3
done
# 3. Nginx upstream 전환
echo "--- [SWITCH TRAFFIC → $NEXT] ---"
if [ "$NEXT" = "green" ]; then
podman exec $NGINX_CONTAINER sed -i \
-e 's|server host.containers.internal:8080;.*# blue|# server host.containers.internal:8080; # blue|' \
-e 's|# server host.containers.internal:8081;.*# green|server host.containers.internal:8081; # green|' \
"$NGINX_CONF"
else
podman exec $NGINX_CONTAINER sed -i \
-e 's|# server host.containers.internal:8080;.*# blue|server host.containers.internal:8080; # blue|' \
-e 's|server host.containers.internal:8081;.*# green|# server host.containers.internal:8081; # green|' \
"$NGINX_CONF"
fi
podman exec $NGINX_CONTAINER nginx -t && podman exec $NGINX_CONTAINER nginx -s reload
echo "✔ 트래픽이 $NEXT (:$NEXT_PORT)로 전환됨"
# 4. 기존 연결 drain 대기 후 이전 슬롯 중지
echo "--- [STOP $ACTIVE] ---"
for i in $(seq 10 -1 1); do
echo -ne "기존 연결 drain 대기... ${i}초\r"
sleep 1
done
echo ""
podman-compose stop $PREV_SERVICE
echo "$NEXT" > "$ACTIVE_FILE"
echo ""
echo "✨ 배포 완료! 활성 슬롯: $NEXT"
마지막에 10초 대기가 필요했던 이유
Nginx reload 후 바로 이전 슬롯을 중지하면, 아직 처리 중이던 요청이 강제 종료되어 순단이 발생한다. 10초 대기로 기존 연결이 처리 완료될 시간을 확보하여 이 문제를 줄이고자 하였다.
전체 배포 흐름 요약
1. deploy-all.sh → 비활성 슬롯에 새 버전 배포 + 헬스체크
2. 헬스체크 통과 → Nginx upstream 전환 (nginx -s reload)
3. drain 대기 → 기존 연결 처리 완료 대기
4. 이전 슬롯 중지
Nginx reload는 기존 연결을 끊지 않고 새 연결만 새 upstream으로 보내기 때문에 무중단 전환이 가능하다.
마무리
Kubernetes 없이도 Podman + Nginx 조합으로 충분히 Blue-Green 무중단 배포를 구현할 수 있었다. 핵심은 간단하다. 두 개의 슬롯을 두고, Nginx upstream 전환으로 트래픽을 넘기는 것이다.
실제로 적용해보니 배포 중 순단이 완전히 사라지지는 않았다. 아주 약간의 오류 응답이 발생되었다. 가능한 원인으로는 Nginx reload 시점에 기존 keep-alive 커넥션이 이전 슬롯으로 계속 유지되는 경우나, Spring Boot의 graceful shutdown이 설정되지 않아 컨테이너 중지 시 처리 중인 요청이 끊기는 경우 등을 의심하고 있다. 이 부분은 조금 더 파악해 봐야겠다. 하지만 그럼에도 다운타임을 줄일 수 있었다는 것에 일단은 만족해보고자 한다.
작은 프로젝트에 K8S를 도입하는 건 과하다고 생각되지만, 무중단 배포를 시도하고 싶다는 비슷한 고민을 하고 있다면 이 방식을 참고해 보면 좋겠다.