본문 바로가기
Engineering

K8s + Spring Boot: Graceful Shutdown 제대로 이해하기

by kghworks 2026. 8. 12.

목차

  • 문제: Rolling Update 시 502가 나는 이유
  • Spring Boot에서의 구현: 애플리케이션 계층
  • K8s 배포 설정: 인프라 계층
  • 최종 설정 및 마무리

문제: Rolling Update 시 502가 나는 이유

Pod는 어떻게 죽는가

Pod 삭제 요청이 API Server에 도달하면 두 가지가 동시에 실행된다.

 

핵심은 경로 A와 B가 비동기라는 것이다.

Race Condition: 502의 정체

preStop hook이 없으면 kubelet은 즉시 SIGTERM을 보낸다. Spring Boot는 SIGTERM을 받는 순간 새 요청 수신을 거부한다. 그런데 경로 B의 Endpoint 제거/전파는 아직 진행 중이다. kube-proxy가 iptables 규칙을 갱신하기까지, Ingress Controller가 upstream 목록을 업데이트하기까지 수 초가 걸린다.

시간 경로 A (kubelet) 경로 B (Endpoint Controller) 결과
t=0 SIGTERM 즉시 전송 EndpointSlice 제거 시작 Spring shutdown 시작, 새 요청 거부
t=0~3 Spring이 in-flight 처리 중 kube-proxy iptables 전파 중 아직 트래픽 라우팅됨 → 502
t=3 전파 완료, 트래픽 차단 이제야 안전하지만 이미 늦음

 

이 수 초의 gap이 502의 정체다.

preStop이 있으면

시간 preStop 없음 preStop sleep 5
t=0 SIGTERM → Spring shutdown 시작 preStop sleep 시작
t=1 Endpoint 전파 중, 502 발생 sleep 중 (SIGTERM 아직 안 감)
t=3 Endpoint 전파 완료 sleep 중, Endpoint 전파 완료
t=5 SIGTERM → Spring shutdown 시작
t=25 in-flight 처리 완료, 정상 종료

 

preStop은 SIGTERM 전송을 지연시킨다. 그 사이에 Endpoint 전파가 완료되어, 더 이상 새 트래픽이 들어오지 않는 상태에서 안전하게 shutdown을 시작할 수 있다.

K8s 공식 프로젝트의 인정

이 패턴이 너무 보편적이라 Kubernetes는 KEP-3960을 통해 preStop hook에 native sleep 액션을 추가했다 (v1.29 alpha → v1.34 GA). 기존에는 exec: ["sleep", "5"]로 sleep 바이너리를 호출해야 했지만, 이제는:

lifecycle:
  preStop:
    sleep:
      seconds: 5

 

* 구버전 클러스터라면 exec 방식을 사용하면 된다: command: ["/bin/sh", "-c", "sleep 5"]

 

참고 링크:

 

Container Lifecycle Hooks

This page describes how kubelet managed Containers can use the Container lifecycle hook framework to run code triggered by events during their management lifecycle. OverviewAnalogous to many programming language frameworks that have component lifecycle hoo

kubernetes.io

 

 

Pod Lifecycle

This page describes the lifecycle of a Pod. Pods follow a defined lifecycle, starting in the Pending phase, moving through Running if at least one of its primary containers starts OK, and then through either the Succeeded or Failed phases depending on whet

kubernetes.io

 

 

enhancements/keps/sig-node/3960-pod-lifecycle-sleep-action/README.md at master · kubernetes/enhancements

Enhancements tracking repo for Kubernetes. Contribute to kubernetes/enhancements development by creating an account on GitHub.

github.com


Spring Boot에서의 구현: 애플리케이션 계층

웹서버 Graceful Shutdown (Spring Boot 2.3+)

server:
  shutdown: graceful

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

이 두 줄이 하는 일:

  1. SIGTERM 수신 → ContextClosedEvent 발행
  2. 내장 웹서버(Tomcat/Netty)가 새 커넥션 수신 거부
  3. 현재 처리 중인 요청(in-flight) 완료 대기
  4. timeout-per-shutdown-phase 초과 시 강제 종료
  5. Bean destruction (@PreDestroy) 실행

메시지 리스너 종료

웹서버만으로는 부족하다. Kafka Consumer, RabbitMQ Listener, 비동기 Executor가 있다면 각각 drain 설정이 필요하다.

Kafka Consumer:

factory.getContainerProperties().

setShutdownTimeout(20_000L);

 

shutdownTimeout은 Spring이 Kafka consumer container에게 "처리 중인 레코드를 마무리하고 오프셋을 커밋할 시간"을 주는 값이다. 이 시간 안에 끝나지 않으면 컨테이너가 강제
중단된다.

@KafkaListenerid를 부여하면 로그에서 어떤 리스너가 종료되었는지 식별할 수 있다:

@KafkaListener(id = "tripConsumer", topics = {"${app.topic}"}, ...)

 

RabbitMQ:

@Bean
fun rabbitListenerContainerFactory(
    configurer: SimpleRabbitListenerContainerFactoryConfigurer,
    connectionFactory: ConnectionFactory
): SimpleRabbitListenerContainerFactory {
    val factory = SimpleRabbitListenerContainerFactory()
    configurer.configure(factory, connectionFactory)
    factory.setShutdownTimeout(20_000)
    return factory
}

 

비동기 Executor (@Async, ScheduledExecutorService):

@PreDestroy
fun destroy() {
    scheduler.shutdown()
    if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
        scheduler.shutdownNow()
    }
}

daemon thread로 만든 executor는 JVM 종료 시 아무 보장 없이 사라진다. @PreDestroy에서 명시적으로 drain해야 한다.

타임아웃 계층 피라미드

각 계층의 타임아웃은 내부 계층보다 커야 한다. 그렇지 않으면 상위에서 SIGKILL이 먼저 날아온다.

K8s terminationGracePeriodSeconds = 35s
  └─ preStop sleep (5s) + Spring shutdown phase (25s) = 30s
       └─ Kafka/RabbitMQ shutdownTimeout = 20s
            └─ 개별 메시지 처리 시간 < 20s

왜 이 숫자인가:

  • preStop 5s: Endpoint 전파에 충분한 시간 (대부분의 클러스터에서 2~3초면 완료)
  • Spring 25s: 가장 느린 in-flight 요청이 마무리될 시간
  • Listener 20s: Spring phase 안에서 마무리되어야 하므로 25s보다 작게
  • K8s 35s: preStop(5) + Spring(25) + 여유(5) = 35s

K8s 배포 설정: 인프라 계층

최소 필수 설정

spec:
  terminationGracePeriodSeconds: 35
  containers:
    - name: app
      lifecycle:
        preStop:
          exec:
            command: [ "/bin/sh", "-c", "sleep 5" ]
      # K8s 1.29+ 에서는:
      # lifecycle:
      #   preStop:
      #     sleep:
      #       seconds: 5

terminationGracePeriodSeconds 계산 공식

terminationGracePeriodSeconds ≥ preStop_seconds + timeout-per-shutdown-phase + buffer
                              = 5 + 25 + 5
                              = 35

 

이 값보다 실제 shutdown이 오래 걸리면 kubelet이 SIGKILL을 보낸다. SIGKILL은 graceful이 아니다.

readinessProbe와의 협력

readinessProbe가 설정되어 있으면, Spring이 shutdown을 시작하면서 health endpoint가 503을 반환하게 된다. 그러면 kubelet이 Pod를 "not ready"로 마킹하고,
Service에서 제거한다.

하지만 이것만으로는 부족하다. readiness 체크 간격(periodSeconds) 동안 트래픽이 유입될 수 있기 때문이다. 이것이 preStop이 필요한 또 하나의 이유다.


관측과 트러블슈팅

Shutdown 로그 설계

shutdown이 "잘" 되었는지 확인하려면 시작과 끝을 로그로 남겨야 한다:

@Component
public class GracefulShutdownLogger implements ApplicationListener<ContextClosedEvent> {

    private final KafkaListenerEndpointRegistry registry;
    private final AtomicLong shutdownStartedAt = new AtomicLong(0);

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        if (!shutdownStartedAt.compareAndSet(0, System.currentTimeMillis())) return;

        log.info("event=graceful_shutdown_started service={} pod={} listenerCount={}",
                serviceName, podName, registry.getListenerContainers().size());
    }

    @PreDestroy
    public void destroy() {
        long duration = System.currentTimeMillis() - shutdownStartedAt.get();
        log.info("event=graceful_shutdown_completed service={} pod={} durationMs={}",
                serviceName, podName, duration);
    }
}

ContextClosedEvent는 shutdown 시작 시점, @PreDestroy는 Bean destruction 직전이므로 이 두 지점의 차이가 실제 shutdown duration이다.

흔한 실수 4가지

증상 원인 해결
server.shutdown=graceful 설정했는데 502 발생 preStop hook 누락 → Endpoint 전파 전에 shutdown 시작 preStop sleep 5 추가
preStop 넣었는데 Pod가 SIGKILL로 죽음 terminationGracePeriodSeconds(기본 30s) < preStop + shutdown 시간 값을 35s 이상으로 증가
Kafka consumer가 rebalance를 유발 shutdownTimeout > session.timeout.ms이면 브로커가 먼저 consumer를 dead로 판정 session.timeout.ms를 shutdownTimeout보다 크게 설정
@Async 작업이 중간에 끊김 executor가 daemon thread거나 waitForTasksToComplete 미설정 @PreDestroy에서 명시적 drain

최종 설정 및 마무리

application.yml:

server:
  shutdown: graceful

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

Kafka factory (Java/Kotlin):

factory.getContainerProperties().

setShutdownTimeout(20_000L);

Helm values (또는 Pod spec):

terminationGracePeriodSeconds: 35
lifecycle:
  preStop:
    exec:
      command: [ "/bin/sh", "-c", "sleep 5" ]

 

Graceful Shutdown은 "설정 3줄"의 문제가 아니다. K8s의 Pod 종료 시퀀스를 이해하고, 각 계층의 타임아웃을 의도적으로 설계해야 비로소 zero-downtime에 도달한다. 핵심은 SIGTERM이 도착하기 전에 트래픽이 먼저 끊겨야 한다.

'Engineering' 카테고리의 다른 글

카프카가 빠른 이유  (0) 2026.08.15
[HTTP] POST, PUT, PATCH 그리고 멱등성  (1) 2024.01.02

댓글