Blue-Green 배포 시 기존 컨테이너를 즉시 내리면 안 되는 이유 — Graceful Shutdown 적용기
배포 구조
EC2 한 대에 Docker Compose로 Nginx + Spring Boot 앱을 올려서 사용하고 있습니다. 배포는 Blue-Green 방식이고, 흐름은 아래와 같습니다.
- Blue 컨테이너가 트래픽을 받고 있는 상태에서
- Green 컨테이너를 새로 띄우고
- Nginx upstream을 Green으로 전환하고
- Blue를 내립니다
┌─────────────────────────────────────────┐
│ EC2 │
│ │
│ ┌───────┐ ┌──────┐ ┌──────┐ │
│ │ Nginx │────▶│ Blue │ or │Green │ │
│ └───────┘ └──────┘ └──────┘ │
│ (Docker Compose) │
└─────────────────────────────────────────┘
GitHub Actions에서 이미지를 빌드해서 Docker Hub에 올리고, EC2에 SSH로 접속해서 deploy.sh를 실행하는 구조입니다.
문제: Blue를 즉시 내리면 요청이 유실됩니다
배포할 때 Nginx를 Green으로 전환하자마자 Blue를 docker-compose down으로 즉시 종료하고 있었습니다. 평소에는 별 문제가 없었는데, Blue에서 처리 시간이 좀 걸리는 요청이 진행 중일 때 배포가 되면 문제가 생겼습니다.
구체적으로는 @Async로 비동기 처리를 처리한 뒤 트랜잭션이 커밋되기 전에 컨테이너가 죽어버리는 상황이었습니다. 비동기 로직 자체는 실행됐는데, 정작 DB에는 반영이 안 되는 불일치가 발생했습니다.
기대하는 동작은 단순합니다. Nginx 전환 이후 새 요청은 Green으로 가되, Blue에서 이미 처리 중인 요청은 끝까지 완료된 다음에 컨테이너가 내려가야 합니다.
해결 방향: Graceful Shutdown
결국 문제는 Blue 컨테이너가 "지금 처리 중인 요청이 있는지"를 신경 쓰지 않고 바로 꺼진다는 것입니다. 그러면 컨테이너를 종료할 때 "처리 중인 요청이 있으면 기다렸다가 꺼지게" 만들면 됩니다. 이게 Graceful Shutdown입니다.
Spring Boot는 2.3부터 server.shutdown=graceful 설정을 제공합니다. SIGTERM을 받으면 새 요청은 거부하고, 이미 처리 중인 요청은 완료될 때까지 대기한 뒤 종료합니다.
그리고 Docker의 docker stop 명령은 컨테이너에 SIGTERM을 보낸 뒤 일정 시간 기다려주는 구조입니다. 이 두 가지를 조합하면 배포 스크립트에 별도 sleep을 넣지 않고도, Blue가 진행 중인 요청을 마무리한 뒤 알아서 종료되도록 만들 수 있습니다.
정리하면 이런 흐름입니다:
- Nginx 전환 — 새 요청은 Green으로 라우팅
docker stop -t로 Blue에SIGTERM전송 — Spring Boot가 진행 중인 요청을 마저 처리- 요청 완료 후 Blue 자동 종료 — 처리가 끝나면 바로 꺼지고, 최대 대기 시간이 지나면 강제 종료
docker stop -t의 동작 방식
docker stop -t이 구체적으로 어떻게 동작하는지 살펴보겠습니다.
docker stop -t 150 app-blue
│
▼
1) SIGTERM 전송
│
▼
2) Spring Boot가 SIGTERM을 받으면
└─ server.shutdown=graceful 설정이 있을 때:
- 새 요청 수신을 거부 (서버 소켓을 닫음)
- 이미 처리 중인 요청은 계속 처리
│
▼
3) 처리가 다 끝나면 → 바로 종료
150초가 지나도 안 끝나면 → SIGKILL로 강제 종료
여기서 중요한 건 -t 150이 "150초 동안 sleep 한다"가 아니라 "최대 150초까지 기다린다"는 점입니다. 처리가 3초면 3초 만에, 30초면 30초 만에 컨테이너가 내려갑니다. 배포 스크립트에서 sleep 60 같은 걸 넣을 필요가 없습니다.
Nginx reload 후 기존 요청은 어떻게 되나?
nginx -s reload를 하면 새로운 worker를 띄우고, 기존 worker는 처리 중인 커넥션을 다 끝낸 다음에 종료됩니다. Blue로 이미 프록시된 요청은 TCP 커넥션이 유지되는 한 응답이 클라이언트까지 전달됩니다.
[클라이언트] ←─ TCP 유지 ─→ [Nginx old worker] ←─ 커넥션 유지 ─→ [Blue]
│ reload 이후에도 이 파이프라인 유지
│ 응답 완료 후 old worker 종료
[새 클라이언트] ───────────→ [Nginx new worker] ──→ [Green]
단, Blue 컨테이너가 먼저 종료되면 old worker가 upstream 연결을 잃으면서 502를 반환합니다. 그래서 반드시 Nginx 전환 → Blue graceful stop 순서를 지켜야 합니다.
적용한 설정
Spring Boot — application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 60s # SIGTERM 후 최대 60초간 기존 HTTP 요청 처리 대기
task:
execution:
shutdown:
await-termination: true
await-termination-period: 60s # @Async 작업 완료 대기
scheduling:
shutdown:
await-termination: true
await-termination-period: 60s # @Scheduled 작업 완료 대기
server.shutdown=graceful만 넣으면 HTTP 요청만 기다려줍니다. @Async로 돌고 있는 비동기 작업이나 @Scheduled 스케줄러는 SIGTERM과 동시에 스레드풀이 바로 꺼져버립니다. 조회수 동기화 같은 스케줄러도 돌고 있어서 spring.task 쪽 설정도 같이 추가했습니다.
Docker Compose — stop_grace_period
services:
app-blue:
image: app:blue
stop_grace_period: 150s # SIGTERM → 150초 대기 → SIGKILL
app-green:
image: app:green
stop_grace_period: 150s
stop_grace_period는 Spring Boot의 전체 shutdown 시간보다 넉넉하게 잡아야 합니다.
Spring Boot의 shutdown은 순차적으로 진행됩니다:
SIGTERM
│
▼
1) Tomcat graceful shutdown (timeout-per-shutdown-phase: 60s)
- 처리 중인 HTTP 요청 완료 대기
│
▼
2) Spring Context 종료 → Bean destruction
- TaskExecutor 종료 (await-termination-period: 60s)
- TaskScheduler 종료 (await-termination-period: 60s)
│
▼
3) JPA EntityManager, HikariPool 종료
최악의 경우 60s(HTTP 대기) + 60s(Task 정리) = 120s가 필요합니다. stop_grace_period가 이보다 짧으면 Spring Boot가 아직 정리 중인데 Docker가 SIGKILL을 보내서 graceful shutdown이 의미가 없어집니다. 여유를 두고 150s로 설정했습니다.
배포 스크립트 — deploy.sh
Blue → Green 전환 부분만 발췌하면 이런 흐름입니다.
#!/bin/bash
IS_GREEN=$(sudo docker ps | grep green)
if [ -z "$IS_GREEN" ]; then
echo "### BLUE => GREEN ###"
echo "1. get green image"
sudo docker compose -f /home/ubuntu/docker-compose.yml pull dev-green
echo "2. green container up"
sudo docker compose -f /home/ubuntu/docker-compose.yml up -d dev-green
while [ 1 = 1 ]; do
echo "3. green health check..."
sleep 5
REQUEST=$(curl -s http://127.0.0.1:8082/health)
if [ "$REQUEST" = "Service is up" ]; then
echo "health check success"
break
fi
done;
echo "4. reload nginx"
sudo cp ./nginx/constant/service-container-green.inc ./nginx/conf.d/service-container.inc
sudo docker exec nginx nginx -s reload
echo "5. blue container down (Background Graceful Shutdown)"
nohup bash -c 'sudo docker compose -f /home/ubuntu/docker-compose.yml stop dev-blue && sudo docker compose -f /home/ubuntu/docker-compose.yml rm -f dev-blue' > /dev/null 2>&1 &
echo "Deployment completed. Blue container is shutting down in the background."
fi
몇 가지 포인트:
nohup으로 백그라운드 프로세스 보호: GitHub Actions에서 SSH로deploy.sh를 실행하는 구조이기 때문에, 스크립트가 끝나면 SSH 세션이 종료됩니다. 이때 백그라운드 프로세스에SIGHUP이 날아가면서docker compose stop이 중간에 끊길 수 있습니다.nohup을 붙이면 SSH 세션이 끝나도 프로세스가 유지됩니다.
배포 스크립트의 책임은 Green 시작 → 헬스체크 → Nginx 전환까지입니다. 이 시점에서 새 요청은 이미 Green으로만 가고 있으니, Blue 종료를 블로킹하면서 기다릴 이유가 없습니다. 백그라운드로 넘기면 워크플로우는 바로 끝나고, Blue는 진행 중인 요청을 마친 뒤 알아서 내려갑니다.
시간 흐름
시간 ──────────────────────────────────────────────────────────────▶
Green 시작 헬스체크 OK Nginx 전환 워크플로우 종료
│ │ │ │
▼ ▼ ▼ ▼
[Green 부팅중] [Green Ready] [새 요청→Green] [배포 끝]
│
│ (백그라운드)
▼
[Blue SIGTERM]
[진행 중 요청 처리 완료]
[Blue 종료]
검증
실제로 동작하는지 확인하기 위해 30초짜리 느린 API를 하나 만들었습니다.
@GetMapping("/api/health/slow")
public String checkHealthSlow() throws InterruptedException {
Thread.sleep(30000);
return "Slow response completed";
}
Blue가 떠있는 상태에서 이 API를 호출하고, 응답이 오기 전에 배포를 실행합니다.
# 1. Blue에 30초짜리 요청을 보냄
curl https://<SERVER>/api/health/slow &
# 2. 배포 실행 (Green 시작 → 헬스체크 → Nginx 전환 → Blue SIGTERM)
./deploy.sh
이 요청은 Nginx를 경유하기 때문에, Nginx reload 이후에도 old worker가 Blue와의 커넥션을 유지한 채 응답을 전달해주는지까지 함께 검증됩니다.
Blue 컨테이너 로그를 보면 SIGTERM을 받은 시점에 아래 로그가 먼저 뜹니다.
INFO --- [tomcat-shutdown] --- [o.s.b.w.e.tomcat.GracefulShutdown]: Commencing graceful shutdown. Waiting for active requests to complete
Tomcat이 처리 중인 요청이 있다는 걸 감지하고, 새 요청은 거부하면서 기존 요청이 끝나기를 대기하는 상태입니다. 30초짜리 요청이 완료되면 아래 로그가 뜨면서 컨테이너가 종료됩니다.
INFO --- [tomcat-shutdown] --- [o.s.b.w.e.tomcat.GracefulShutdown]: Graceful shutdown complete
INFO --- [SpringApplicationShutdownHook] --- [o.s.o.j.LocalContainerEntityManagerFactoryBean]: Closing JPA EntityManagerFactory for persistence unit 'default'
INFO --- [SpringApplicationShutdownHook] --- [com.zaxxer.hikari.HikariDataSource]: HikariPool-1 - Shutdown initiated...
INFO --- [SpringApplicationShutdownHook] --- [com.zaxxer.hikari.HikariDataSource]: HikariPool-1 - Shutdown completed.
Graceful shutdown complete 이후에 JPA EntityManager → HikariPool 순서로 리소스가 정리되는 걸 확인할 수 있습니다. curl 응답도 200 OK로 정상 수신됩니다.
반대로, 처리 중인 요청이 없는 상태에서 SIGTERM을 받으면 Commencing graceful shutdown 로그 없이 바로 종료됩니다. 대기할 요청이 없으니 즉시 꺼지는 것이 정상입니다.
Reference
'공부 > 인프라' 카테고리의 다른 글
| Spring Boot CI/CD 빌드 시간 단축하기 - Gradle 최적화 전략 (0) | 2026.02.09 |
|---|---|
| bluegreen-배포-환경에서-prometheus-메트릭이-끊겨-보이던-이유와-해결-방법 (0) | 2026.01.02 |
| Grafana 및 prometheus 활용 #2 (0) | 2025.12.31 |
| Grafana 및 prometheus 활용 #1 (0) | 2025.12.31 |