무중단 배포인데 왜 간헐적 connection refused가 뜨는가 — 파드 종료와 엔드포인트 제거의 경쟁 조건

seonwoo_jung·어제

1. 무중단이라 믿었는데 RST가 찍혔다

롤링 업데이트나 스케일 인은 흔히 "무중단"이라고 부른다. 그런데 배포를 돌릴 때마다 클라이언트 로그에 간헐적으로 connection refused와 TCP RST가 찍혔다. 재현도 들쭉날쭉했다. 처음엔 terminationGracePeriodSeconds를 넉넉히 주면 끝날 문제라고 생각했지만 아니었다.

원인은 하나였다. 파드가 죽는 절차와, 서비스 엔드포인트에서 그 파드가 빠지는 절차 사이에는 순서 보장이 없다. 이 글은 그 경쟁 조건(race condition)이 정확히 어디서 생기는지, 왜 grace period가 아니라 preStop 지연으로 풀어야 하는지를 공식 문서를 따라가며 정리한 것이다.

2. 파드 삭제 한 번이 두 경로를 동시에 깨운다

파드를 지우면 — kubectl delete든 스케일 인이든 롤링 교체든 eviction이든 동일하다 — API 서버가 오브젝트에 metadata.deletionTimestamp를 찍고 terminationGracePeriodSeconds(기본 30초) 카운트다운을 시작한다.

여기서 중요한 건, 이 상태 변화 하나가 watch로 연결된 여러 컨트롤러를 각자 깨운다는 점이다. 서로를 기다리지 않는다.

          delete Pod (deletionTimestamp 세팅)
                       │
        ┌──────────────┴───────────────────────┐
        ▼                                        ▼
  [ kubelet (해당 노드) ]              [ EndpointSlice 컨트롤러 (control plane) ]
        │                                        │
   preStop 훅 실행                     엔드포인트 condition 갱신:
        │                              ready=false, terminating=true
   컨테이너에 SIGTERM                            │
        │                                        ▼
   grace 만료까지 대기                  [ 모든 노드의 kube-proxy ]
        │                              watch 수신 → iptables/IPVS 재프로그래밍
   SIGKILL(강제 종료)                          (파드 IP DNAT 룰 제거)

왼쪽(kubelet) 경로는 로컬이다. 파드가 떠 있는 그 노드의 kubelet이 preStop 훅을 먼저 실행하고, 끝나면 컨테이너 PID 1에 SIGTERM을 보낸다. grace가 지나면 SIGKILL이다. 같은 노드 안에서 일어나므로 거의 즉시다.

오른쪽(라우팅) 경로는 전역이다. EndpointSlice 컨트롤러가 "종료 중"을 관측해 엔드포인트 condition을 바꾸면, 그 변경을 모든 노드의 kube-proxy가 각자 watch로 받아 자기 노드의 iptables/IPVS 룰에서 파드 IP를 뺀다. 즉 라우팅이 실제로 끊기기까지는 컨트롤러 처리 지연 + watch 전파 + N개 노드 재프로그래밍이라는 비동기 지연이 여러 홉 쌓인다.

두 경로 사이에는 배리어가 없고, 로컬 SIGTERM이 전역 라우팅 철거보다 먼저 도달하기 쉽다. 그래서 "앱은 이미 SIGTERM을 받고 리스너를 닫았는데, 어떤 노드의 kube-proxy는 아직 이 파드로 새 연결을 DNAT" 하는 구간이 생긴다. 그 연결은 대상 포트에 리스너가 없으니 커널이 RST를 돌려주고 → 클라이언트에는 connection refused로 찍힌다.

3. 엔드포인트가 원자적으로 사라지지 않는 이유

EndpointSlice의 각 엔드포인트에는 boolean 세 개가 있다: ready, serving, terminating. 종료 중인 파드는 terminating=true가 되고 ready는 false로 떨어진다. 그런데 serving은 파드가 여전히 요청을 처리할 수 있는 동안 true로 남을 수 있다.

이 분리에는 이유가 있다. ready 엔드포인트가 하나도 없는 상황(예: 급격한 롤링)에서 kube-proxy가 "serving 중인 terminating 엔드포인트"로라도 트래픽을 흘려 블랙홀을 피하게 하려는 것이다(공식 문서상 ProxyTerminatingEndpoints 동작으로 알려져 있다). 바꿔 말하면 종료 중 파드가 라우팅에서 사라지는 것은 원자적이 아니라 조건 기반의 점진적 과정이고, 이 점진성이 곧 위에서 말한 "창(window)"의 실체다.

4. grace를 늘리는 건 답이 아니다 — preStop 지연

흔히 저지르는 오해가 terminationGracePeriodSeconds를 키우면 무중단이 된다는 것이다. 하지만 grace는 SIGTERM과 SIGKILL 사이의 시간일 뿐, 엔드포인트 전파 경쟁과는 무관하다. grace만 늘리면 죽는 시점이 뒤로 밀릴 뿐 경쟁 자체는 그대로다.

핵심은 라우팅 철거가 전파될 때까지 앱이 계속 정상 응답하도록 SIGTERM을 늦추는 것이다. 관용적으로 preStop에 짧은 sleep을 둔다.

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 10"]   # 라우팅 전파 대기용
terminationGracePeriodSeconds: 45              # sleep(10) + 실제 드레이닝 여유

sleep 동안 파드는 여전히 살아 있어 새 요청과 in-flight 요청을 정상 처리한다. 그 사이 kube-proxy 룰이 이 파드를 뺀다. sleep이 끝나면 그제서야 SIGTERM이 오고, 앱은 새 연결을 끊고 남은 요청만 마무리하면 된다.

주의할 점 하나. preStop 실행 시간도 grace 예산에서 차감된다. sleep 10 + 앱의 실제 드레이닝 시간이 terminationGracePeriodSeconds를 넘기면 뒷부분이 SIGKILL로 잘려 드레이닝이 실패한다. 그래서 위 예시처럼 grace를 sleep보다 넉넉히 잡아야 한다.

개념적 타임라인으로 보면 왜 sleep이 창을 없애는지가 분명하다.

[preStop 없음]
t=0.00  delete 수신, grace=30 시작
t=0.00  kubelet: 즉시 SIGTERM → 앱이 리스너 close
t=0.05  EndpointSlice 컨트롤러가 ready=false write
t=0.20  노드 A kube-proxy 갱신 완료
t=0.35  노드 B kube-proxy 아직 갱신 전  ← 이 창에서 노드 B발 새 연결 → RST

[preStop sleep 10]
t=0.00  delete 수신 → preStop sleep 시작 (앱은 그대로 살아 응답)
t=0.35  라우팅 철거 전파 완료 (앱은 아직 정상)
t=10.0  sleep 종료 → SIGTERM → 이 시점엔 새 연결이 오지 않음

5. PID 1 함정 — sleep을 넣어도 안 되는 경우

여기까지 맞춰도 SIGTERM이 씹히면 드레이닝이 통째로 실패한다. 컨테이너 엔트리가 sh -c "java ..." 같은 셸이면 그 셸이 PID 1이 되고 SIGTERM을 자식 프로세스(실제 앱)에 전달하지 않는다. 앱은 시그널을 못 받아 드레이닝을 시작하지도 못하고 grace 만료 후 SIGKILL로 강제 종료된다.

해결은 Dockerfile에서 exec 폼(CMD ["java", "-jar", "app.jar"])을 쓰거나 tini 같은 시그널 전달 가능한 init을 PID 1에 두는 것이다. 무중단 설정을 아무리 정교하게 짜도 이 지점이 깨져 있으면 전부 무효가 된다.

6. 정리

파드 삭제 한 번은 SIGTERM 경로(로컬·즉시)와 엔드포인트 철거 경로(전역·점진)를 동시에·독립적으로 깨우며, 둘 사이에 순서 보장이 없다. 그래서 무중단은 grace가 아니라 preStop 지연 + 앱 드레이닝 + 올바른 PID 1 세 가지가 맞아야 성립한다.

공식 "Termination of Pods" 문서가 엔드포인트 제거를 "at the same time as the kubelet is starting graceful shutdown"이라고 명시한다는 점이 이 글의 핵심 근거다. "먼저 빼고 나중에 죽인다"가 아니라 "동시에, 순서 없이"다.

더 파고들 만한 주제:

  • EndpointSlice 컨트롤러의 배칭/mirroring이 대규모 서비스에서 전파 지연에 어떻게 영향을 주는지.
  • kube-proxy가 실제 라우팅 결정에서 ready/serving/terminating을 어떻게 우선순위화하는지 소스로 확인.

참고 자료

  • Kubernetes Docs — Pods / Pod Lifecycle: "Termination of Pods"
  • Kubernetes Docs — Service, EndpointSlices (endpoint conditions: ready / serving / terminating)
  • Kubernetes Docs — ProxyTerminatingEndpoints (kube-proxy)

0개의 댓글