
목차
- 문제: 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"]
참고 링크:
- Kubernetes 공식 문서 — Container Lifecycle Hooks
- Kubernetes 공식 문서 — Pod Termination
- KEP-3960: Pod Lifecycle Sleep Action
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
이 두 줄이 하는 일:
- SIGTERM 수신 →
ContextClosedEvent발행 - 내장 웹서버(Tomcat/Netty)가 새 커넥션 수신 거부
- 현재 처리 중인 요청(in-flight) 완료 대기
timeout-per-shutdown-phase초과 시 강제 종료- Bean destruction (
@PreDestroy) 실행
메시지 리스너 종료
웹서버만으로는 부족하다. Kafka Consumer, RabbitMQ Listener, 비동기 Executor가 있다면 각각 drain 설정이 필요하다.
Kafka Consumer:
factory.getContainerProperties().
setShutdownTimeout(20_000L);
shutdownTimeout은 Spring이 Kafka consumer container에게 "처리 중인 레코드를 마무리하고 오프셋을 커밋할 시간"을 주는 값이다. 이 시간 안에 끝나지 않으면 컨테이너가 강제
중단된다.
@KafkaListener에 id를 부여하면 로그에서 어떤 리스너가 종료되었는지 식별할 수 있다:
@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 |
댓글