Reliability · Operations

Graceful Shutdown:
아무도 테스트하지 않는 신뢰성 기능

Kubernetes가 요청 처리 도중 SIGTERM을 보냈을 때 무슨 일이 벌어지는지에 대한 통합 테스트를 작성하는 사람은 없다. 그런데 순서를 잘못 잡으면, 이건 배포할 때마다 요청이 유실되고 502가 발생하는 가장 확실한 원인 중 하나이기도 하다.

컨테이너 오케스트레이션 환경 — Kubernetes, ECS, 대신 컨테이너를 시작하고 종료해 주는 무엇이든 — 에서는 모든 배포, 모든 오토스케일 다운, 모든 노드 드레인이 동일한 신호를 보낸다: SIGTERM, 그리고 그 뒤에 SIGKILL이 뒤따르기까지의 카운트다운. 그 시간 동안 프로세스가 무엇을 하느냐에 따라, 아무도 눈치채지 못하고 지나가는 배포가 될 수도 있고, 어딘가의 대시보드에 실패한 요청이 급증하는 배포가 될 수도 있다.

순서, 그리고 왜 순서 자체가 핵심인가

여섯 단계가 있고, 반드시 이 순서대로 일어나야 한다: 오케스트레이터가 SIGTERM을 보낸다; readiness probe가 즉시 실패로 전환되어 로드밸런서가 새 트래픽 전송을 멈춘다; 진행 중인(in-flight) 요청들에 마무리할 시간이 주어진다; HTTP 서버가 종료된다; DB 커넥션, 메시지 큐 커넥션 같은 리소스가 정리된다; 프로세스가 코드 0으로 깔끔하게 종료된다.

readiness 전환은 HTTP 서버가 종료되기 전에 일어나야 한다. 순서를 뒤바꾸면, 로드밸런서가 여전히 해당 인스턴스를 정상으로 여기고 이미 활발히 종료 중인 서버로 트래픽을 계속 라우팅하는 구간이 생긴다 — 바로 이것이 "배포 중 랜덤 502" 장애의 전형적인 모양새다. 배포 중에만 간헐적으로 나타나고, 코드 변경과는 딱히 관련 없어 보이는 그 장애 말이다.

Liveness와 Readiness는 같은 probe가 아니다

Liveness는 "프로세스가 살아 있는가"에 답한다 — 실패하면 컨테이너가 재시작된다. Readiness는 "트래픽을 받을 준비가 되었는가"에 답한다 — 실패하면 로드밸런서에서 제외될 뿐, 그 이상의 극단적인 조치는 없다. 종료(shutdown) 중에는 liveness가 계속 200을 반환해야 하고(아직 살아 있고 마무리 중일 뿐이므로), readiness는 503을 반환해야 한다(더 이상 아무것도 새로 보내지 말라는 뜻으로).

isShuttingDown = false

// on receiving SIGTERM
isShuttingDown = true

// GET /health/ready
if (isShuttingDown) return 503
return 200

// GET /health/live
return 200  // always
흔한 실수

종료 중에 liveness도 503을 반환하면, 오케스트레이터는 이를 "프로세스가 비정상"이라고 읽고 컨테이너를 재시작한다 — 진행 중인 요청을 깔끔하게 다 처리하기도 전에, 종료 도중에 말이다. Liveness는 종료 상태와 무관하게 항상 200을 반환해야 하며, 오직 readiness만 값이 바뀌어도 된다.

terminationGracePeriodSeconds

이는 오케스트레이터가 SIGTERM을 보낸 뒤 SIGKILL로 격상하기까지 기다리는 시간이며, SIGKILL은 그 이상의 정리 작업 없이 프로세스를 강제 종료한다. 서비스의 p99 요청 처리 시간보다 여유 있게 잡아라 — 일반적인 HTTP 서비스라면 보통 30초면 충분하다. 종료 시점에 배치나 스케줄 작업이 진행 중일 수 있다면, HTTP p99뿐 아니라 그 작업들의 최대 처리 시간도 함께 감안해야 한다.

# Kubernetes example
spec:
  terminationGracePeriodSeconds: 30
  containers:
    - livenessProbe:
        httpGet:
          path: /health/live
    - readinessProbe:
        httpGet:
          path: /health/ready

프로세스를 래퍼 없이 직접 실행하기

# correct — runs the process directly as PID 1
CMD ["node", "dist/main.js"]

# wrong — npm sits in between and delays SIGTERM delivery
CMD ["npm", "run", "start:prod"]

npm이나 yarn이 래퍼로 그 사이에 끼어 있으면, SIGTERM은 애플리케이션에 직접 전달되는 게 아니라 그 래퍼 프로세스로 전달된다 — 실제 앱으로의 전달이 지연되거나, 일부 환경에서는 SIGKILL이 오기 전까지 아예 전달되지 않을 수도 있다. 애플리케이션을 직접 실행하면 컨테이너 안에서 PID 1이 되므로, 오케스트레이터가 SIGTERM을 보내는 순간 그대로 받게 되고, 신호와 그에 반응해야 할 코드 사이에 아무것도 끼어들지 않는다.

프레임워크 설정 한 줄 vs. 시퀀스를 직접 작성하기

위 시퀀스 중 얼마나 많은 부분을 직접 작성해야 하는지는 전적으로 프레임워크가 이미 이에 대한 입장을 갖고 있는지에 달려 있다. Spring Boot는 거의 전체를 설정 한 줄로 바꿔 준다:

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

server.shutdown: graceful는 Spring 자체가 새 요청 수신을 멈추고 진행 중인 요청이 끝날 때까지 기다린 뒤 종료하도록 만들며, Actuator의 liveness/readiness probe는 이미 별도의 엔드포인트로 기본 제공된다 — 여섯 단계 시퀀스의 대부분은 애플리케이션 코드가 아니라 프레임워크의 몫이 된다.

Go에는 그 역할을 대신해 줄 프레임워크가 없으므로, 시퀀스의 모든 단계를 문서가 규정한 순서 그대로 명시적으로 작성해야 한다:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

go func() {
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server error", "error", err)
		os.Exit(1)
	}
}()

<-ctx.Done() // blocks until SIGTERM/SIGINT is received

// Must be called before srv.Shutdown(ctx) — the orchestrator only cuts off new traffic
// after readiness flips to 503, so readiness must fail first, before the HTTP server
// actually stops, for a seamless cutover.
healthHandler.StartShutdown()

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// Waits for in-flight requests to finish while rejecting new connections.
if err := srv.Shutdown(shutdownCtx); err != nil {
	slog.Error("graceful shutdown failed", "error", err)
}
// DB connections are cleaned up only after the HTTP server is fully closed

여기 있는 것 중 Go만의 특별한 노하우는 없다 — 이 글 맨 위에서 소개한 여섯 단계와 똑같고, 다만 순서를 감춰 주는 프레임워크가 없을 뿐이다. SIGTERM이 오면 ctx가 취소되는데, 이는 프로세스 안의 다른 모든 백그라운드 루프 — Outbox poller, Task Queue consumer, 스케줄러 — 를 함께 멈추는 신호이기도 하다. 모두가 같은 context를 지켜보고 있기 때문에, 신호 하나로 컴포넌트마다 별도의 shutdown hook 없이도 모든 것이 깔끔하게 정리된다.

정리 순서, 그리고 그 도중에 하지 말아야 할 것

리소스 정리 — DB 커넥션 해제, 메시지 큐 클라이언트 종료 — 는 HTTP 서버가 닫힌 이후에 실행되어야 하며, 그 전에 실행되어서는 안 된다. 진행 중인 요청들은 마무리되는 동안에도 여전히 데이터베이스에 접근해야 한다; 커넥션 풀을 먼저 해제해 버리면, 애초에 정상적으로 끝마치게 해 주려던 바로 그 요청들의 발밑을 걷어차는 셈이 된다.

✓ Shut down the HTTP server → release the DB connection   (correct order)
✗ Release the DB connection → shut down the HTTP server   (in-flight requests can't use the DB)

그리고 정리 작업 자체는 예외를 던지면 안 된다. 정리 단계 하나가 예외를 발생시킨다면, try-catch로 감싸고 그냥 로그만 남겨라 — 정리 도중 잡히지 않은 예외가 발생하면 그 이후에 시퀀스에 남아 있던 모든 리소스 해제 단계가 건너뛰어져, "DB 커넥션이 최대한 깔끔하게 닫히지는 못했다" 정도의 문제가 "코드가 거기까지 도달하지 못해서 메시지 큐 커넥션까지 함께 leak됐다"로 번지게 된다.

체크리스트

  • SIGTERM이 도착하는 즉시, 다른 무엇보다 먼저 readiness가 실패로 전환되는가?
  • 종료 과정 내내 liveness는 무슨 일이 있어도 200을 유지하는가?
  • 프로세스가 npm/yarn 래퍼 없이 PID 1로 실행되는가?
  • terminationGracePeriodSeconds가 배치 작업까지 포함해 p99 대비 실질적인 여유를 두고 설정되어 있는가?
  • 리소스 정리가 HTTP 서버가 새 작업 수신을 멈춘 이후에만 엄격하게 실행되는가?
  • 정리 단계 하나가 실패해도 나머지가 건너뛰어지지 않도록 각 단계가 감싸져 있는가?
저장소 내 추가 자료

docs/architecture/graceful-shutdown.md — 전체 종료 시퀀스와 probe 설정 레퍼런스 · docs/architecture/container.md — 이 글이 전제하는 Dockerfile CMD 컨벤션