Table of contents
Open Table of contents
1. 배포할 때 5xx가 뜨는 이유
쿠버네티스에 서비스를 올리고 롤링 배포를 돌리다 보면, 배포 직후에만 잠깐 5xx 그래프가 튀는 걸 본 적이 있을 것이다. 공식 문서는 대응책을 거의 늘 이렇게 요약한다.
“
preStophook으로 몇 초 대기하고, 앱에서 graceful shutdown을 구현해라.”
맞는 말인데, 이 둘을 “안전한 종료 체크리스트”처럼 묶어서 소개하고 끝난다. 그래서 왜 둘 다 필요한지, 어느 쪽이 더 지배적인 문제를 푸는지는 남는다. 이 글은 소스 코드 분석과 8가지 조합 실험으로 그걸 파헤친 기록이다.
결론부터 말하면 두 장치는 서로 다른 종류의 5xx를 막고, 어느 쪽이 필수인지는 서비스가 처리하는 요청 길이에 따라 달라진다. 짧은 API 서비스와 LLM 추론 같은 긴 요청 서비스는 대응 우선순위가 다르다.
2. 파드 종료는 두 경로가 경주한다
파드 종료는 두 경로가 병렬로 진행되고, 서로 순서를 맞춰주지 않는다.
네트워크 경로는 Endpoints 변경 → kube-proxy watch → iptables 업데이트 체인을 거치면서 평균 2~4초가 걸린다. 반면 프로세스 경로는 preStop이 없으면 SIGTERM이 즉시 전달되고, Go의 http.Server.Shutdown()은 밀리초 단위로 리스너를 닫는다. 경주 구도가 생기면 프로세스가 먼저 끝나는 경우가 대부분이고, 여기서 배포 직후 5xx가 나올 여지가 생긴다.
그리고 프로세스 경로는 더 쪼개면 “닫기” 와 “죽기” 두 단계로 나뉜다.
K8s의 두 방어책은 이 단계를 하나씩 늦추는 역할로 볼 수 있다.
preStop— “닫기”를 늦춤. SIGTERM 자체를 지연시켜서 네트워크 경로가 먼저 끝날 시간을 벌어준다. 즉 “프로세스가 네트워크보다 먼저 닫히는 것” 을 줄이는 장치다.- graceful shutdown — “죽기”를 늦춤.
Shutdown()이 리스너는 닫지만 프로세스는 in-flight 요청이 끝날 때까지 살려둔다. 즉 “프로세스가 in-flight 요청보다 먼저 죽는 것” 을 줄이는 장치다.
이 프레임으로 보면 두 장치는 같은 문제의 거울상으로 읽을 수 있다. 둘 다 “프로세스 경로가 너무 빨리 끝나는 것”을 늦추는데, “빨리 끝났다”의 의미가 다를 뿐이다. preStop은 “닫기가 네트워크보다 빨리” 일어나는 것을, graceful은 “죽기가 in-flight 요청보다 빨리” 일어나는 것을 막아준다.
그리고 이 위에 SIGKILL 타이머가 상한선으로 걸린다. terminationGracePeriodSeconds를 넘기면 kubelet이 프로세스를 강제로 죽이고, drain 중이던 요청도 같이 죽는다. preStop과 graceful은 “늦추는” 장치, SIGKILL은 “더 이상 못 늦춘다”는 상한선이다. 세 값을 함께 설계해야 한다.
SIGTERM / SIGKILL이 궁금하다면
SIGTERM과 SIGKILL은 Unix signal이다.
| 신호 | 의미 | 프로세스가 무시/가로채기 |
|---|---|---|
| SIGTERM (15) | “정리하고 종료해 줘” | 핸들러로 가로챌 수 있음 (drain, 정리 작업 가능) |
| SIGKILL (9) | 커널이 프로세스를 강제 종료 | 불가능, 핸들러도 실행 안 됨 |
kubelet이 직접 kill()을 호출하는 건 아니다. CRI gRPC로 컨테이너 런타임(containerd)에게 StopContainer(timeout)을 요청하면, 런타임이 SIGTERM → 대기 → SIGKILL 순서로 전달한다.
앱이 핸들러를 등록하지 않으면 SIGTERM 기본 동작은 “그냥 종료”다. Go에서는 signal.Notify로 가로챌 수 있다.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM)
<-sigCh
srv.Shutdown(ctx) // in-flight 요청 drain
즉 graceful shutdown은 “SIGTERM을 받으면 바로 죽는” 기본 동작을 “drain 후에 죽는” 동작으로 교체하는 것이다. SIGKILL은 가로챌 수 없으므로 terminationGracePeriodSeconds를 넘기면 graceful이든 뭐든 무의미해진다.
3. 실험 환경
- 앱: Go HTTP 서버 (소스 코드, 이미지
cagojeiger/graceful-demo:latest) —GRACEFUL,SHUTDOWN_TIMEOUT_SEC환경변수로 동작 전환 - Deployment:
replicas=2,maxUnavailable=0,maxSurge=1 - 부하: busybox에서 10개 워커가 동시에 요청 반복
- 트리거:
kubectl rollout restart로 롤링 업데이트
요청 길이를 두 종류로 나눠 각각 4가지 조합을 돌렸다. 여기서 “짧다 / 길다”의 기준은 preStop 시간(5초) 이다. preStop보다 짧은 요청은 종료 시점에 대부분 완료되어 있고, preStop보다 긴 요청은 SIGTERM이 올 때도 여전히 처리 중이다 — 이 차이가 실험 결과의 대칭을 깬다.
- S 시리즈 — 짧은 요청:
/slow?ms=500(500밀리초, preStop보다 훨씬 짧음) - L 시리즈 — 긴 요청:
/slow?ms=<20000~40000 랜덤>(preStop보다 훨씬 길음, LLM 추론 시뮬레이션)
4. 실험 결과
| # | 요청 | preStop | graceful | 실패 | 비율 |
|---|---|---|---|---|---|
| S1 | 500ms | ✗ | ✗ | 14/500 | 2.8% |
| S2 | 500ms | ✗ | ✓ | 13/500 | 2.6% |
| S3 | 500ms | ✓ | ✗ | 0/500 | 0% |
| S4 | 500ms | ✓ | ✓ | 0/500 | 0% |
| L1 | 20–40s | ✗ | ✗ | 17/100 | 17% |
| L2 | 20–40s | ✗ | ✓ | 0/100 | 0% |
| L3 | 20–40s | ✓ | ✗ | 12/100 | 12% |
| L4 | 20–40s | ✓ | ✓ | 0/100 | 0% |
결과는 대칭이 아니다. 짧은 요청에서는 preStop만으로도 실패가 거의 사라졌고(S3), 긴 요청에서는 graceful만으로도 실패가 거의 사라졌다(L2). 두 장치가 같은 문제를 풀고 있다면 같은 방향으로 움직여야 할 텐데, 실제로는 요청 길이에 따라 다른 실패 모드가 지배적이었다는 뜻으로 읽힌다.
5. 왜 요청 길이에 따라 답이 달라지나
비대칭은 “워커가 종료 순간에 뭘 하고 있느냐”를 생각하면 풀린다.
짧은 요청 — 워커는 계속 새 요청을 쏟아낸다
요청이 빠르게 끝나니 종료 시점에 in-flight는 거의 없고, 워커는 계속 새 요청을 쏟아낸다. 주된 실패 원인은 “새 요청이 죽어가는 파드로 가는 것” 이고, preStop이 iptables 반영 시간을 벌어주면 이 실패가 크게 줄어든다.
시퀀스 다이어그램
긴 요청 — 워커는 하나의 요청에 붙잡혀 있다
워커가 긴 요청 하나에 붙잡혀 있으면 새 요청을 자주 보내지 못한다. 주된 실패 원인은 “in-flight 요청이 SIGTERM에 잘리는 것” 이고, graceful의 drain이 이걸 대부분 받아낸다.
시퀀스 다이어그램
6. “그럼 preStop만 길게 하면 되는 거 아닌가?”
자연스러운 의문이다. 긴 요청에서 graceful이 하는 일이 in-flight 보호라면, preStop을 요청 길이만큼 길게 잡아도 되지 않을까?
이론적으로는 맞다. preStop을 요청 최대 길이보다 길게 잡으면 SIGTERM 시점에는 in-flight가 거의 없을 가능성이 높고, 그러면 ungraceful이어도 잘릴 요청이 거의 없다. 다만 실무에서는 두 가지 이유로 잘 쓰지 않는다.
- 배포가 느려진다. preStop sleep은 항상 고정 시간을 기다린다. replicas가 20개고 preStop이 50초면 배포 한 번에 17분이다. graceful은 실제 drain이 필요한 만큼만 기다린다.
- 편차에 약하다. LLM 추론은 2초에 끝날 수도 120초까지 갈 수도 있다. preStop으로 커버하려면 최악인 120초를 기준으로 잡아야 하고, 99%의 짧은 케이스에서도 매번 120초를 낭비한다.
요약하면 **preStop은 “고정 시간 대기”, graceful은 “필요한 만큼만 대기”**다. 네트워크 반영은 편차가 작아서 고정 시간으로 커버해도 되지만(preStop 5초), in-flight drain은 편차가 커서 graceful에게 맡기는 게 효율적이다.
7. 그래서 둘 다 걸자
실전에서는 요청 길이가 섞이니 두 개 다 거는 게 기본이다.
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
image: ...
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
env:
- name: SHUTDOWN_TIMEOUT_SEC
value: "55"
# SIGTERM → http.Server.Shutdown()
# main은 shutdown 완료까지 대기
세 값은 다음 공식으로 맞춘다.
terminationGracePeriodSeconds ≥ preStop + max(요청 길이) + 여유
SHUTDOWN_TIMEOUT_SEC ≈ terminationGracePeriodSeconds - preStop
8. 정리 — 서비스 특성별 체크리스트
배포 직후 5xx의 상당수는 파드 종료 과정의 두 가지 미스매치에서 나올 가능성이 높다. 프로세스가 네트워크보다 먼저 닫히거나(새 요청 실패), 프로세스가 in-flight보다 먼저 죽거나(처리 중 요청 실패). 같은 “너무 빨리 끝나는” 문제의 거울상으로 볼 수 있고, 각각에 맞는 장치가 따로 있다.
| 서비스 특성 | 예시 | 지배적 실패 모드 | 우선 대응 |
|---|---|---|---|
| 짧은 요청 위주 | 일반 CRUD API, 조회 서비스 | 새 요청 실패 | preStop 필수 |
| 긴 요청 위주 | LLM 추론, 대용량 업로드, 배치 | in-flight 실패 | graceful 필수 |
| 혼재 | 대부분의 웹 서비스 | 둘 다 | 둘 다 필수 |
그 위에 terminationGracePeriodSeconds(SIGKILL 타이머) 가 전체 상한선으로 걸린다. 이 값이 가장 긴 요청보다 짧으면 drain이 중간에 잘려서 graceful이 제 역할을 하지 못할 수 있다. 세 값을 함께 설계하는 편이 안전하다.
배포 직후 5xx가 의심되면, 먼저 서비스의 요청 길이 분포부터 확인해보자. 짧은 서비스에 graceful만 걸어두었거나, 긴 서비스에 preStop만 걸어두었다면 이 글의 실험 결과가 그대로 재현될 가능성이 높다.
롤링 업데이트가 아닌 경우 (단순 삭제, HPA scale-down, drain)
이 글의 실험은 롤링 업데이트 (maxSurge=1, maxUnavailable=0) 기준이다. 롤링은 구 파드가 종료되기 전에 새 파드가 먼저 Ready되어 Endpoints에 합류하기 때문에, 종료 창 동안 3파드가 트래픽을 나눠 받는 완충 효과가 있다.
하지만 파드가 종료되는 이벤트는 롤링 업데이트만이 아니다.
| 이벤트 | 새 파드 먼저 Ready? | 비고 |
|---|---|---|
| 롤링 업데이트 (maxSurge=1) | O | 완충 있음 |
kubectl delete pod | X | Deployment가 새 파드 만들지만 먼저 Ready되지는 않음 |
| HPA scale-down | — | 아예 생성 없이 줄이기만 함 |
| Node drain (cordon + evict) | X | 다른 노드로 재스케줄 |
| Spot instance reclaim | X | 노드가 갑자기 사라짐 |
이런 시나리오에서는 완충 파드가 없어서 같은 설정이어도 실패율 숫자는 더 높게 나올 수 있다. 하지만 실패의 원인과 대응 방법은 동일하다 — 여전히 프로세스/네트워크 경로의 경쟁이고, preStop과 graceful shutdown의 역할도 그대로다. 오히려 완충이 없는 만큼 두 장치의 중요성이 더 커진다.
Appendix: 실험 코드
데모 앱 소스 코드는 cagojeiger/auto-action에 있고, 이미지는 cagojeiger/graceful-demo:latest로 DockerHub에 배포되어 있다.
실험 실행 스크립트 (bash)
아래 스크립트를 graceful-test.sh로 저장하고 ./graceful-test.sh <id> <graceful> <prestop> <req_type> 형태로 실행하면 8가지 조합을 하나씩 돌릴 수 있다. 예: ./graceful-test.sh L4 true true long
#!/bin/bash
# Usage: ./graceful-test.sh <id> <graceful> <prestop> <req_type>
# graceful: true | false
# prestop: true | false
# req_type: short | long
set +e
ID=$1
GRACEFUL=$2
PRESTOP=$3
REQ_TYPE=$4
NS=graceful-test
PRESTOP_YAML=""
if [ "$PRESTOP" = "true" ]; then
PRESTOP_YAML="
lifecycle:
preStop:
exec:
command: [\"sleep\", \"5\"]"
fi
GRACE_SEC=60
if [ "$REQ_TYPE" = "short" ]; then
GRACE_SEC=40
fi
cat <<EOF | kubectl apply -n $NS -f - >/dev/null
apiVersion: apps/v1
kind: Deployment
metadata:
name: demo
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app: demo
template:
metadata:
labels:
app: demo
spec:
terminationGracePeriodSeconds: $GRACE_SEC
containers:
- name: demo
image: cagojeiger/graceful-demo:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
env:
- name: GRACEFUL
value: "$GRACEFUL"
- name: SHUTDOWN_TIMEOUT_SEC
value: "55"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 2
periodSeconds: 3$PRESTOP_YAML
EOF
kubectl rollout status deployment/demo -n $NS --timeout=120s >/dev/null 2>&1
if [ "$REQ_TYPE" = "short" ]; then
ITERATIONS=50
REQ_CMD='ms=500'
TIMEOUT=10
else
ITERATIONS=10
REQ_CMD='ms=$((20000 + (RANDOM % 20001)))'
TIMEOUT=60
fi
kubectl delete pod loadgen -n $NS --ignore-not-found >/dev/null 2>&1
kubectl run loadgen -n $NS --image=busybox --restart=Never -- sh -c "
for w in 1 2 3 4 5 6 7 8 9 10; do
(
i=0
while [ \$i -lt $ITERATIONS ]; do
$REQ_CMD
result=\$(wget -qO- --timeout=$TIMEOUT \"http://demo/slow?ms=\$ms\" 2>&1)
if [ \$? -eq 0 ]; then echo \"OK w=\$w i=\$i\"
else echo \"FAIL w=\$w i=\$i\"; fi
i=\$((i+1))
done
) &
done
wait
echo ALL_DONE
" >/dev/null 2>&1
sleep 8
kubectl rollout restart deployment/demo -n $NS >/dev/null 2>&1
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod/loadgen -n $NS --timeout=900s >/dev/null 2>&1
LOGS=$(kubectl logs loadgen -n $NS 2>&1)
FAILS=$(echo "$LOGS" | grep -c "FAIL")
OKS=$(echo "$LOGS" | grep -c "OK")
echo "$ID | prestop=$PRESTOP graceful=$GRACEFUL req=$REQ_TYPE | fail=$FAILS ok=$OKS"
네임스페이스는 미리 kubectl create ns graceful-test로 만들어두면 된다. 각 실행은 부하 생성 파드가 종료될 때까지 대기하므로 직렬로 돌아간다.