
지난주 화요일 오전이었다. 평소처럼 백오피스 서비스 배포를 돌렸는데, 운영팀 슬랙에 "1분쯤 전에 잠깐 페이지가 안 떴어요"라는 메시지가 떴다. 처음 듣는 얘기는 아니다. 사실 우리 팀에서는 배포할 때 5xx가 한두 건 튀는 걸 그냥 "Kubernetes의 미세한 빈틈"이라고 부르며 넘기고 있었다. 근데 이번엔 운영팀 화면에 명확히 보일 정도로 길었다는 게 문제였다.
Grafana로 들어가서 Ingress 컨트롤러의 5xx 그래프를 봤다. 배포 시점에 503이 약 7초간 spike. 평소에는 1초 미만이었는데 이번엔 길었다. 우리 환경은 EKS 1.32, NGINX Ingress Controller, replicas 6짜리 평범한 Deployment. 이쯤이면 "또 종료 시퀀스 문제겠지" 싶었지만, 막상 파보니 생각보다 흥미로웠다.
무엇부터 의심했나
처음엔 readiness probe쪽을 의심했다. Pod이 새로 뜰 때 readiness가 늦게 통과해서 트래픽이 받을 준비가 안 된 상태로 라우팅되나? 근데 메트릭을 보니 신규 Pod 쪽 503이 아니라, 종료되는 Pod 쪽에서 connection refused가 나고 있었다. 즉 트래픽이 이미 죽고 있는 Pod로 가고 있다는 뜻이다.
이건 사실 Kubernetes 종료 시퀀스의 고전적인 경합 문제다. 다이어그램 그릴 필요도 없을 만큼 유명한 그림인데, 실제로 어디서 끊기는지는 매번 다르다. 시퀀스를 다시 정리하면 대략 이렇다.
- kubectl delete 또는 rolling update가 시작되면 API server에서 Pod의 deletionTimestamp가 설정된다.
- 거의 동시에 두 가지가 비동기로 일어난다.
- kubelet이 컨테이너에 PreStop hook 실행 → SIGTERM 전송.
- Endpoint controller가 Pod을 Endpoints에서 제거. 그러면 kube-proxy/iptables 규칙이 업데이트되고, Ingress 컨트롤러도 자기 캐시에서 backend를 뺀다.
- 문제는 2번의 두 동작이 누가 먼저 끝나는지 보장이 없다는 점이다. 보통은 PreStop → SIGTERM 쪽이 더 빠르고, Endpoints 전파는 몇 초가 걸린다.
- 그 사이에 들어온 요청은 이미 SIGTERM 받고 listener 닫는 중인 Pod로 라우팅돼서 connection refused가 난다.
이걸 회피하는 정석은 PreStop에 sleep을 넣는 거다. SIGTERM을 받기 전에 일부러 몇 초 멍 때리게 만들어서, 그 사이 Endpoints 전파가 끝나도록 시간을 버는 패턴.
우리 차트에는 이미 sleep이 있었다
여기서 한 번 멘탈이 나갔다. 우리 Helm chart 공통 템플릿에 이미 다음 코드가 있었기 때문이다.
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10"]
"있는데 왜 안 먹지?" 하고 Pod manifest를 다시 봤다. 그런데 문제의 서비스는 distroless 이미지를 쓰고 있었다. distroless에는 sh가 없다.
kubectl describe pod을 자세히 보면 Events에 PreStop 실행 실패가 살짝 찍혀 있었는데, 그동안 아무도 안 봤던 거다. exec 형식 PreStop hook은 컨테이너 내부의 바이너리에 의존하기 때문에, distroless로 옮긴 시점부터 PreStop이 조용히 실패하고 있었다. 그러면 sleep 효과가 사라지니까 SIGTERM이 바로 들어오고, 5xx가 늘어나는 게 당연했다.
마침 K8s 1.34에 Sleep action이 GA됐다
이 시점에서 처음 떠올린 게 K8s 1.34에서 GA된 Sleep action이었다. v1.29에 알파로 들어왔다가 v1.33 beta 거쳐서 1.34에 stable로 올라왔다. 우리 클러스터는 아직 1.32라 GA 기준은 못 만족하지만, beta feature gate(기본 on)로 사용할 수 있는 상태였다.
기존 exec 방식 대신 이렇게 쓴다.
lifecycle:
preStop:
sleep:
seconds: 10
차이가 뭐냐. exec 방식은 컨테이너 안에서 sleep 바이너리를 실행하지만, sleep action은 kubelet이 직접 카운트한다. 따라서 distroless든 scratch든 컨테이너에 sh가 없어도 동작한다. 하나의 yaml로 모든 베이스 이미지에서 동일하게 작동한다는 게 큰 이점이다.
처음엔 "고작 sleep 하나에 별도 액션 타입이 필요한가" 싶었는데, 실제로 운영하다 보니 베이스 이미지 다양성을 늘리는 시점부터 이게 굉장히 중요해진다. distroless, alpine, scratch, ubi-minimal이 한 클러스터에 다 섞여 있는 상황에서 PreStop을 일관되게 보장하는 가장 단순한 방법이다.
그래서 실제로 뭘 했나
당장은 1.34로 클러스터를 올릴 일정이 아니라서, 우선 두 가지를 동시에 적용했다.
하나는 공통 Helm chart에서 distroless 이미지를 쓰는 서비스만 PreStop을 httpGet으로 바꾸는 것. 우리 서비스들은 어차피 /healthz가 있으니까 hook을 httpGet + 응답 본문 OK 형태로 만들면 sh 없이도 sleep 비슷한 효과를 낼 수 있다. 다만 진짜 sleep만큼 명확하지 않아서, 헬스체크 핸들러 내부에 짧은 sleep을 직접 박는 식으로 처리했다.
둘은 terminationGracePeriodSeconds를 명시적으로 30초로 늘리고, 애플리케이션 쪽에서 SIGTERM을 받으면 즉시 종료하지 않고 readiness를 fail로 바꾼 뒤 N초 정도 더 트래픽을 받는 패턴을 추가했다. Spring Boot는 graceful shutdown 옵션이 있고, Go 서비스는 직접 시그널 핸들러를 손봤다.
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM)
<-sig
// readiness만 먼저 fail 시킨다
readinessOK.Store(false)
time.Sleep(15 * time.Second) // 그 사이 Endpoints 전파 + LB drain
// 그 다음 실제 종료
srv.Shutdown(ctx)
이 둘을 같이 깔고 나서 배포 시점 5xx는 사실상 0에 수렴했다.
정리하면
종료 시퀀스 문제는 절대 한 가지 원인으로 안 나온다. PreStop hook, Endpoints 전파 속도, Ingress controller 캐시 갱신 주기, 애플리케이션의 시그널 처리, terminationGracePeriod 길이가 모두 얽혀 있다. 이번 케이스의 진짜 트리거는 "distroless 도입으로 PreStop이 조용히 깨졌다"는 것이었는데, 이게 몇 달 전부터 누적되고 있었다는 사실에 좀 충격이었다. Events에 한 줄 찍히는 종류의 실패는 의외로 오래 살아남는다.
1.34 올라가면 PreStop sleep action으로 통일하려고 한다. 컨테이너 이미지 베이스가 뭐든 같은 yaml로 동일 동작이 보장된다는 점, 일단 그게 운영 입장에서 제일 크다. 혹시 비슷한 패턴으로 503 잡으신 분 있으면 어떻게 푸셨는지 궁금하다.
'IT > Kubernets' 카테고리의 다른 글
| startupProbe 모르고 슬로우 스타트 앱 운영하지 마세요 (0) | 2026.05.13 |
|---|---|
| kube-proxy 내부 동작 - iptables, IPVS, nftables 모드는 패킷을 어떻게 처리하나 (0) | 2026.05.12 |
| KEDA SQS scaler에서 새벽에 만난 함정 (0) | 2026.05.11 |
| Bottlerocket vs Talos, 워커노드 OS는 뭘 쓸까 (0) | 2026.05.11 |
| distroless 컨테이너에 sh가 없을 때, kubectl debug 한 줄로 끝내기 (0) | 2026.05.10 |