공부/인프라

Blue-Green 배포 시 Graceful Shutdown 적용기

chanyoun 2026. 4. 6. 14:49
Blue-Green 배포 시 Graceful Shutdown 적용기-refactor-v1

Blue-Green 배포 시 기존 컨테이너를 즉시 내리면 안 되는 이유 — Graceful Shutdown 적용기

 

배포 구조

EC2 한 대에 Docker Compose로 Nginx + Spring Boot 앱을 올려서 사용하고 있습니다. 배포는 Blue-Green 방식이고, 흐름은 아래와 같습니다.

  1. Blue 컨테이너가 트래픽을 받고 있는 상태에서
  2. Green 컨테이너를 새로 띄우고
  3. Nginx upstream을 Green으로 전환하고
  4. 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가 진행 중인 요청을 마무리한 뒤 알아서 종료되도록 만들 수 있습니다.

정리하면 이런 흐름입니다:

  1. Nginx 전환 — 새 요청은 Green으로 라우팅
  2. docker stop -t로 Blue에 SIGTERM 전송 — Spring Boot가 진행 중인 요청을 마저 처리
  3. 요청 완료 후 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