Skip to content
Project Jelly Blog
Go back

배포 중 5xx는 왜 날까 — K8s 파드 종료를 분해해서 서비스 특성별로 대응하기

Edit page

Table of contents

Open Table of contents

1. 배포할 때 5xx가 뜨는 이유

쿠버네티스에 서비스를 올리고 롤링 배포를 돌리다 보면, 배포 직후에만 잠깐 5xx 그래프가 튀는 걸 본 적이 있을 것이다. 공식 문서는 대응책을 거의 늘 이렇게 요약한다.

preStop hook으로 몇 초 대기하고, 앱에서 graceful shutdown을 구현해라.”

맞는 말인데, 이 둘을 “안전한 종료 체크리스트”처럼 묶어서 소개하고 끝난다. 그래서 왜 둘 다 필요한지, 어느 쪽이 더 지배적인 문제를 푸는지는 남는다. 이 글은 소스 코드 분석과 8가지 조합 실험으로 그걸 파헤친 기록이다.

결론부터 말하면 두 장치는 서로 다른 종류의 5xx를 막고, 어느 쪽이 필수인지는 서비스가 처리하는 요청 길이에 따라 달라진다. 짧은 API 서비스와 LLM 추론 같은 긴 요청 서비스는 대응 우선순위가 다르다.

2. 파드 종료는 두 경로가 경주한다

파드 종료는 두 경로가 병렬로 진행되고, 서로 순서를 맞춰주지 않는다.

네트워크 경로는 Endpoints 변경 → kube-proxy watch → iptables 업데이트 체인을 거치면서 평균 2~4초가 걸린다. 반면 프로세스 경로는 preStop이 없으면 SIGTERM이 즉시 전달되고, Go의 http.Server.Shutdown()은 밀리초 단위로 리스너를 닫는다. 경주 구도가 생기면 프로세스가 먼저 끝나는 경우가 대부분이고, 여기서 배포 직후 5xx가 나올 여지가 생긴다.

그리고 프로세스 경로는 더 쪼개면 “닫기”“죽기” 두 단계로 나뉜다.

K8s의 두 방어책은 이 단계를 하나씩 늦추는 역할로 볼 수 있다.

이 프레임으로 보면 두 장치는 같은 문제의 거울상으로 읽을 수 있다. 둘 다 “프로세스 경로가 너무 빨리 끝나는 것”을 늦추는데, “빨리 끝났다”의 의미가 다를 뿐이다. 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. 실험 환경

요청 길이를 두 종류로 나눠 각각 4가지 조합을 돌렸다. 여기서 “짧다 / 길다”의 기준은 preStop 시간(5초) 이다. preStop보다 짧은 요청은 종료 시점에 대부분 완료되어 있고, preStop보다 긴 요청은 SIGTERM이 올 때도 여전히 처리 중이다 — 이 차이가 실험 결과의 대칭을 깬다.

4. 실험 결과

#요청preStopgraceful실패비율
S1500ms14/5002.8%
S2500ms13/5002.6%
S3500ms0/5000%
S4500ms0/5000%
L120–40s17/10017%
L220–40s0/1000%
L320–40s12/10012%
L420–40s0/1000%

결과는 대칭이 아니다. 짧은 요청에서는 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은 “고정 시간 대기”, 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 podXDeployment가 새 파드 만들지만 먼저 Ready되지는 않음
HPA scale-down아예 생성 없이 줄이기만 함
Node drain (cordon + evict)X다른 노드로 재스케줄
Spot instance reclaimX노드가 갑자기 사라짐

이런 시나리오에서는 완충 파드가 없어서 같은 설정이어도 실패율 숫자는 더 높게 나올 수 있다. 하지만 실패의 원인과 대응 방법은 동일하다 — 여전히 프로세스/네트워크 경로의 경쟁이고, 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로 만들어두면 된다. 각 실행은 부하 생성 파드가 종료될 때까지 대기하므로 직렬로 돌아간다.


Edit page
Share this post on:

Previous Post
모델이 바뀔 때마다 코드를 고치고 싶지는 않았다
Next Post
작은 서비스를 계속 만들다 보니 인증부터 먼저 분리하게 됐다