Container Orchestration/Kubernetes

preStop으로 끝이 아니다 — Graceful Shutdown 타이밍을 측정하고 검증하기

Somaz 2026. 7. 23. 00:00
728x90
반응형

Overview

1편(EKS 무중단 배포의 4가지 값)에서 preStop · terminationGracePeriodSeconds · ALB deregistration_delay 세 시간의 관계를 다뤘다. 그런데 그 글을 다시 읽어보면 한 가지 질문이 남는다.

 

"preStop 35초, grace 60초, dereg 30초 — 이 숫자들은 대체 어떻게 정한 것인가? 그리고 정한 값이 실제로 그렇게 도는지는 어떻게 확인하는가?"

 

 

1편이 "세 값이 왜 이런 부등식을 이뤄야 하는가"였다면, 이 글은 그 숫자의 근거(배포 전 측정)검증(배포 후 확인) 을 다룬다. 감으로 35·60·30을 박는 것과, 측정해서 박는 것은 완전히 다른 일이다.

 

대상은 1편과 동일하게 라이브 트래픽을 받는 게임 서버 파드(이하 A 서비스, Stateless HTTP / Fastify 기반)다.

 

 

 

 

 


 

0. 종료 순서와 역산 공식

먼저 파드가 종료될 때의 순서를 시간축으로 정리한다.

t=0    파드 Terminating → Service Endpoint에서 제외 + preStop sleep 시작
       (파드는 살아서 기존 요청을 계속 처리한다)
t=P    preStop 끝 → 컨테이너 PID 1로 SIGTERM 전달
t=G    grace 만료 → SIGKILL 강제 종료

 

 

 

이 순서에서 세 시간의 관계는 두 개의 부등식으로 압축된다.

preStop sleep   ≥ ALB deregistration_delay (+ 여유)
grace           > preStop + 앱 자체 drain(p99) + 여유
  • preStop ≥ ALB dereg — ALB가 종료 중인 IP 타겟의 등록 해제를 끝낼 때까지 파드를 살려둬, 새 요청 유입을 0으로 만든다.
  • grace > preStop — preStop이 끝난 뒤 SIGTERM이 정상 전달되고, 남은 시간 동안 앱이 in-flight 요청을 마저 처리한다.

 

 

이 흐름이 성립하려면 ingress에 `target-type: ip` 와 `deregistration_delay` 가 선행돼야 한다는 점은 1편에서 다룬 그대로다.

 

핵심은 이 부등식의 우변에 들어가는 세 숫자(ALB dereg, preStop, 앱 drain)를 측정으로 채워야 한다는 것이다. 그래야 좌변의 grace가 비로소 정당한 값이 된다.

 

 

 

 

 

 


 

 

 

 

1. 설정값을 정하기 위한 측정 (배포 전)

목적은 단순하다. 세 숫자를 감이 아니라 측정 근거로 정하는 것이다.

 

 

 

ⓐ ALB deregistration_delay — preStop의 하한을 정한다

"종료 중인 타겟으로 새 요청이 끊기는 데 걸리는 시간"이다. 기본값은 300초(5분)지만, 긴 keepalive 연결을 쓰지 않는 짧은 HTTP 트래픽이라면 더 짧게 잡을 수 있다.

 

 

A 서비스는 이 값을 30초로 결정했고, 이 30초가 곧 `preStop sleep` 의 하한이 된다. ingress 어노테이션으로 설정한다.

# Ingress (AWS Load Balancer Controller)
metadata:
  annotations:
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/target-group-attributes: deregistration_delay.timeout_seconds=30
  • ALB 타겟 그룹의 `deregistration_delay.timeout_seconds` 기본값은 300초이므로, 위처럼 명시적으로 낮춰주지 않으면 preStop을 아무리 잡아도 부등식이 깨진다.

 

 

 

ⓑ 앱이 SIGTERM에 graceful한가 — in-flight 유지 vs 끊김

다음으로 확인할 것은 "이 앱이 SIGTERM을 받았을 때 처리 중이던 요청을 끝까지 처리하는가, 아니면 그냥 끊는가"다. 단독 컨테이너에 느린 응답을 in-flight 상태로 만들어 놓고 SIGTERM을 쏴서, 200으로 끝나는지 / 끊기는지를 본다.

# 컨테이너 안에서: 느린 엔드포인트를 in-flight 로 만들고 SIGTERM 전달
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' localhost:8080/<느린API> &
sleep 1; kill -15 1; wait        # 200=graceful / 000·Empty reply=끊김

# 또는 컨테이너 외부에서 종료 소요시간 측정
/usr/bin/time -f '%E' docker stop <container>

주의: 컨테이너 기본 셸(dash/busybox)의 kill은 - SIGTERM 표기를 받지 못한다.
반드시 `kill -15` 또는 `kill -TERM` 으로 보내야 한다.

 

 

 

여기서 한 가지 더 말해보자면, SIGTERM을 받았을 때의 행동은 런타임마다 천차만별이다. ASP.NET Core처럼 알아서 ~30초 drain 하는 것도 있고, Node.js·Go처럼 핸들러가 없으면 즉시 죽는 것도 있고,

 

nginx·PHP-FPM처럼 SIGTERM을 fast shutdown으로 해석해 오히려 in-flight를 끊어버리는 함정도 있다. 런타임별 SIGTERM 행동표는 1편에 정리해 두었으니, 측정 전에 자기 런타임이 어느 부류인지 먼저 확인하는 것을 권한다.

 

 

 

 

ⓒ 앱 drain이 몇 초인가 — graceful일 때만, p99 기준

앱이 graceful하다면(ⓑ가 200으로 끝났다면), 그 drain이 몇 초나 걸리는지를 재야 한다. 이게 grace 부등식의 마지막 항이다. K8s에서 grace를 넉넉히 둔 뒤, 앱 로그의 SIGTERM 수신 시각 ~ 마지막 요청 완료 시각 차이를 잰다. 동시에 지속 부하를 흘리며 실측한다.

hey -z 90s -c 20 https://app.example.com/health &
kubectl -n app rollout restart deployment/app
kubectl -n app logs -l app.kubernetes.io/name=app --prefix | grep -i 'SIGTERM\|shutdown\|closed'
  • `preStop sleep` 값은 worst-case가 아니라 p99 처리시간을 기준으로 역산하는 것이 정석이다. 극단적인 outlier 한두 건 때문에 전체 종료 윈도우를 늘리면, 매 배포가 그만큼 느려진다.

 

 

 

도출

세 측정을 모으면 숫자가 나온다.

preStop sleep   = ALB dereg + 여유          = 30 + 5  = 35
grace           > preStop + 앱 drain + 여유  = 35 + drain + buffer

 

 

 

 

 

 

 


 

 

 

 

 

2. 정한 값이 맞는지 검증 (배포 후)

값을 박았으면 끝이 아니다. 실제로 그렇게 도는지를 확인해야 한다. 이때 가장 중요한 원칙은 각 구간을 독립적으로 본다는 것이다.

 

흔한 실수가 "파드 삭제에 걸린 총시간"만 보고 안심하는 것이다. 그런데 총 삭제시간 하나로는 preStop과 grace를 구분할 수 없다. 앱의 SIGTERM 반응에 따라 같은 총시간도 해석이 완전히 갈리기 때문이다. (이 부분은 4장 실측에서 극적으로 드러난다.)

 

검증 대상 방법 합격 기준
preStop 35s `delete --wait=false` 후 종료 중 파드에서 `ps -ef | grep '[s]leep 35'` 0~35s 존재, 이후 소멸
grace 60s `time kubectl -n app delete pod $POD` 총 ≈ 60s (SIGKILL 천장)
앱 SIGTERM `kubectl exec $POD -- sh -c 'kill -15 1; sleep 5; kill -0 1 && echo IGNORED || echo EXITED'` + RESTARTS 확인 IGNORED / EXITED 판정
ALB dereg 30s `aws elbv2 describe-target-health --target-group-arn <TG-ARN>` podIP가 draining → ~30s 내 제거
종단 (zero-502) `hey -z 90s -c 20 https://app.example.com/health` 중 `kubectl rollout restart` 5xx = 0 ← 최종 합격

 

 

 

구간 분해의 핵심

`time kubectl delete` 의 총시간은 grace 천장(SIGKILL) 만 보여준다. 여기서 preStop(35)을 따로 떼어내려면, 종료 중 파드 안의 `sleep 35` 프로세스 생존 구간을 직접 들여다봐야 한다.

POD=$(kubectl -n app get pod -l app.kubernetes.io/name=app -o name | head -1 | cut -d/ -f2)
kubectl -n app delete pod $POD --wait=false
# 0~35초: 보임 / 35초 후: 사라짐  → preStop 이 정확히 35초 실행됨을 증명
kubectl -n app exec $POD -- ps -ef | grep '[s]leep 35'
  • 총시간이라는 "블랙박스"를 preStop 구간과 grace 구간으로 쪼개 보는 것 — 이게 검증의 전부다.

 

 

 

 

 

3. 실측 결과

현재 배포된 값은 `terminationGracePeriodSeconds: 60`, `preStop: sleep 35`, `ALB deregistration_delay=30` 이다.

측정 명령 결과 의미
grace 천장 `time kubectl delete pod` 62s grace 60 + 오버헤드 — SIGKILL 천장 확인
앱 SIGTERM `kill -15 1` + RESTARTS 확인 IGNORED, RESTARTS=0 앱이 SIGTERM을 무시(핸들러 없음)

 

 

 

두 결과를 합치면 62초의 정체가 확정된다.

t=0    Terminating + preStop sleep 35 시작
t=35   preStop 끝 → SIGTERM 전달   ← 앱이 무시
t=60   grace 만료 → SIGKILL
─────  총 ≈ 62초
       (preStop 실패가 아니라, SIGTERM 무시로 grace 천장까지 버틴 것)

여기서 2장에서 강조한 "구간 분해"의 가치가 드러난다.

 

만약 `time kubectl delete` 의 62초만 봤다면 "음, grace 60초가 잘 도는구나" 하고 넘어갔을 것이다.

 

하지만 앱 SIGTERM 반응(IGNORED)을 따로 측정했기 때문에, 그 62초의 정체가 "정상적인 graceful drain"이 아니라 "SIGTERM 무시로 SIGKILL을 기다린 죽은 시간" 이었음을 알 수 있었다.

 

 

 

결론

  • preStop 35 > ALB dereg 30 → 맞음. 502 방지(트래픽이 빠질 때까지 파드 생존)는 이 부등식으로 정확히 작동한다. 종료 중 파드로 들어오는 새 요청은 0이 된다.
  • grace 60 > preStop 35 → 유효하나 현재는 과대. 앱이 SIGTERM을 무시하므로 t=35~60의 25초는 그저 SIGKILL을 기다리는 죽은 시간이다. 502에는 무해하지만, 파드 삭제가 매번 60초씩 걸린다.
  • 이 60초가 의미를 가지려면 Fastify에 SIGTERM 핸들러(enableShutdownHooks + graceful close) 를 추가해야 한다. 그러면 측정 ⓒ의 "앱 drain"이 비로소 0이 아닌 값으로 생기고, 35 + drain + 여유 < 60 으로 grace가 정당해진다. 핸들러를 추가하지 않을 거라면 grace를 40초 정도로 줄여 teardown을 빠르게 할 수도 있다(502 정확성에는 영향 없다).

 

 

 

 

한 줄 요약

측정(ⓑ·ⓒ)으로 앱 drain을 먼저 알아내야 grace 숫자가 정해지고, 검증(2·3장)으로 그 숫자가 실제로 그렇게 도는지 + 502가 0인지 확인한다.

 

 

A 서비스는 "앱 drain = 0(SIGTERM 무시)"인 상태라서, `preStop=35` 만으로 502는 이미 막혀 있고, `grace=60` 은 미래의 SIGTERM 핸들러를 위한 여유로 남아 있는 셈이다. 숫자를 감으로 박았다면 결코 알 수 없었을 사실이다.

 

 

 

 

 

 

 

 

 


Reference

 

 

 

 

 

Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist

728x90
반응형