🏠 목록 W1. Big Picture — 트래픽 경로 + 6 events + 4 시나리오 📄 MD 원본 🌓 테마
istiograceful-terminationenvoyhaproxyk8shomelab

W1. Big Picture — 트래픽 경로 + 6 events + 4 시나리오

NOTE

graceful-termination 학습 시리즈(W1~W6)의 첫 hub 노트. 무중단 Pod 종료를 한 문장으로 환원한다: 우리는 LB를 직접 제어할 수 없고 오직 health 신호(200/503) 하나만 LB에 줄 수 있으므로, "끊김 없는 종료"는 6개 timestamp를 옳은 순서로 정렬해 LB가 backend를 늦게 빼게 만드는 타이밍 문제다. 이 문서는 그 무대(트래픽 5-hop 경로), 그 변수(종료 시 6개 시점), 그 측정(4 시나리오)을 세운다.

시리즈 위치: 대응 코드 워크스루는 코드 워크스루(quickstart). 다음 단계는 W2 hc FSM, 전체 인덱스는 graceful-termination MOC. FSM 명명: 2026-04-26 이후 OPEN/DRAINING/CLOSING/CLOSED/FAULT(게이트 비유). 옛 명칭(READY/DRAINING/DRAINED_WAIT_LB/TERMINATING/FAILED)은 옛 artifacts에만 남아 있음. 신규 endpoint: POST /reopen — DRAINING/CLOSING → OPEN abort. CLOSED는 unrecoverable(409). CLOSING→OPEN 응답엔 Warning: 199 ... 헤더. 이때 LB는 inter 2s × rise 2 = +4s 후 backend를 다시 UP 마킹(재투입). reopen 시나리오·전이 상세는 W2 hc FSM §전이/reopen.


1. 배경 지식 — 왜 Pod 종료가 어려운가

kubectl delete pod 한 줄이면 끝날 것 같지만, 그 순간 in-flight 요청이 끊긴다. 원인을 이해하려면 K8s Pod 종료가 여러 독립 주체의 비동기 신호 경쟁(async signal race)이라는 사실에서 출발해야 한다.

Pod가 사라질 때 최소 네 주체가 서로의 진행을 모른 채 동시에 움직인다.

여기서 두 가지 제약이 모든 설계를 지배한다. 이 둘이 W1~W6 전체의 출발점이다.

  1. 권한 제약 — 서비스 팀은 보통 LB에 backend down 같은 명령을 내릴 권한이 없다. LB를 직접 조작할 수 없다.
  2. 판단 근거 제약 — LB는 backend의 내부 상태를 모른다. 오직 health check 응답 200 vs 503 하나로만 UP/DOWN을 판단한다.

두 제약을 합치면 결론이 강제된다: 우리가 LB에 줄 수 있는 입력은 health 신호 한 채널뿐이다. 따라서 "in-flight를 안 끊는다"는 목표는 LB를 멈추는 문제가 아니라, health 신호의 타이밍으로 LB가 backend를 빼는 순간을 미루는 문제로 바뀐다. LB가 backend를 빼는 순간(= on-marked-down shutdown-sessions로 기존 connection을 RST하는 순간)이 in-flight 요청이 다 끝난 에 오도록 신호를 늦추면 무중단이 된다.

선행 개념: K8s preStop hook / SIGTERM / readiness probe / EndpointSlice, Envoy listener drain, HAProxy backend health check(inter/rise/fall, on-marked-down). 각 부품의 디테일은 후속 W2~W6에 위임하고, W1은 이들이 어떤 순서로 맞물려야 하는가만 본다.

대상 환경: Istio 1.30 IGW + 외부 HAProxy(L7 TLS offload) + bare-metal worker NodePort. 대상 독자: 무중단 배포/종료를 직접 튜닝하는 DevOps/SRE. 범위: W1은 문제 정의와 측정 골격까지; 코드·FSM·운영 산출식은 cross-ref.


2. 멘탈모델 + 트래픽 경로 — 무대 세우기

ANCHOR (머리에 하나만 담을 그림): LB는 눈먼 backend pool 관리자다. 그가 보는 신호는 health 200/503 하나, 그가 할 수 있는 행동은 "backend를 빼고 그 connection을 끊는다" 하나. 그러므로 graceful termination = "backend의 in-flight가 0이 될 때까지 health=200을 거짓말처럼 유지하다가, 0이 된 그때 비로소 503으로 떨어뜨려 LB가 뒤늦게 빼게 만드는" 타이밍 조율. 나머지 모든 디테일은 이 한 그림에서 따라 나온다.

이 조율이 어디서 일어나는지 보려면 트래픽이 지나는 hop을 알아야 한다. health 신호를 읽는 주체(HAProxy)와 drain 대상(IGW Envoy)이 경로상 어디에 앉아 어떤 포트로 분리돼 있는지가 §3 events의 무대다.

Mac client (curl)
   |  HTTPS, --resolve example.local:443:203.0.113.211
   v
[ lb-haproxy 203.0.113.211 ]
   |  HAProxy bind *:443 ssl alpn h2,http/1.1
   |  L7 TLS offload, X-Forwarded-Proto/Host/For 주입
   |  backend istio-http-backend balance roundrobin
   |  option httpchk GET /health_check.html
   |  check port 30180  inter 2s rise 2 fall 2
   |  on-marked-down shutdown-sessions
   v
[ worker NodePort 30080 (traffic) / 30180 (health) ]
   |  externalTrafficPolicy: Local (이 노드에 ready endpoint 있어야 응답)
   v
[ IGW Pod (replicas=1 or 2, podAntiAffinity required hostname) ]
   |  +- container istio-proxy (Envoy, port 8080, admin 15000)
   |  |    proxy.istio.io/config terminationDrainDuration: <drain 합계보다 길게>
   |  |      (Istio default 5s에서 상향; 산출식은 W6/runbook)
   |  +- container hc (port 18180, FSM 5-state)
   v
[ backend Service ClusterIP 8080 ]  -> Deployment replicas=2, podAntiAffinity preferred
   v
[ backend Pod ]
     /fast        : 즉시 200
     /sleep?N     : N초 sleep 후 200
     /stream?N&M  : M초마다 chunk flush, 총 N초

이 경로에서 두 채널이 분리돼 있다는 점이 핵심이다. 트래픽은 30080 → envoy:8080으로 흐르고, health probe는 30180 → hc:18180으로 따로 흐른다. 즉 hc 사이드카는 트래픽 경로 밖에서 LB에게 "이 backend 살아있어?"의 답만 쥐고 있다 — 바로 §1에서 말한 "유일한 입력 채널". hc가 health를 언제 503으로 떨어뜨리느냐가 LB가 backend를 빼는 시점을 결정한다.

핵심 포트 맵:

호스트:포트 역할
Mac → 203.0.113.211:443 HAProxy L7 TLS offload (example.local 인증서)
Mac → 203.0.113.211:6443 apiserver (TCP passthrough, 별도 frontend)
HAProxy → worker:30080 IGW Envoy traffic (TLS termination 후 plaintext)
HAProxy → worker:30180 hc 사이드카 health check
HAProxy → master1:30080/30180 2026-04-25 master1 untaint 후 추가
외부 → Pod (a) envoy:8080 트래픽 진입, (b) hc:18180 헬스 probe
Pod 내부 단방향 hc → envoy admin:15000 (drain.sh의 graceful drain 제어. 역방향 없음)

마지막 줄이 메커니즘의 심장이다: hc → envoy admin 15000이 단방향으로 연결돼 있어, hc가 Envoy에게 "drain 시작해" / "active 몇 개야?"를 물을 수 있다. 이 채널이 있어야 §3 improved 모드의 "active=0 폴링 후 health를 떨어뜨린다"가 가능해진다.

15000 vs 15021 포트 역할 구분: 15000Envoy admin API(관리: /drain_listeners, /stats, /config_dump) — drain 제어·active count 폴링이 여기로 간다. 15021status/health 포트(별도)로, W3에서 IGW가 NodePort 32021로 노출한다(W3 IGW deployment §status 노출 참조). 본 시리즈의 hc graceful drain은 15000(admin)만 쓰며, 15021은 istio-proxy 자체 readiness/health 용도다 — 두 포트를 혼동하지 말 것.


3. 핵심 메커니즘 — 6 events를 정렬 문제로 보기

종료 순간 §1의 네 주체가 만드는 사건을 측정 가능한 6개 timestamp로 못 박는다. 이 6개의 상대 순서가 곧 무결성의 전부다.

# 이벤트 측정 위치
1 health_fail: hc /health_check.html 200→503 flip hc.log event=transition
2 readiness_fail: hc /health 200→503 (K8s endpoint 제거 트리거) hc.log + EndpointSlice
3 envoy_drain_start: /drain_listeners?graceful&skip_exit 호출 envoy.log "drain" 라인
4 active_zero: downstream_rq_active + upstream_rq_active == 0 Envoy /stats?filter=...
5 lb_down: HAProxy show stat에서 backend status UP→DOWN stat-timeline.csv
6 rst: tcpdump 첫 TCP RST (또는 HTTP/2 stream CANCEL) tcpdump pcap

왜 "순서" 문제인가: 이벤트 5(LB DOWN)는 항상 이벤트 6(RST)을 유발한다 — HAProxy on-marked-down shutdown-sessions가 backend를 빼는 즉시 그 backend로 가던 모든 connection을 RST하기 때문이다. 따라서 우리가 통제할 수 있는 단 하나의 레버는 "5가 4(active=0)보다 에 오게 하는 것"이다. 4보다 5가 먼저 오면(=in-flight가 남았는데 LB가 backend를 뺌) 6의 RST가 살아있는 요청을 죽인다. 4 다음에 5가 오면 RST가 일어나도 끊을 게 없다.

통제 대상: 이 순서를 만든다 (3 → 4 → 1 → 5) 3. drain start 4. active=0 1. health 503 5. LB DOWN 6. RST on-marked-down shutdown-sessions no disruption active=0 = 끊을 게 없음 disruption active>0인데 먼저 DOWN in-flight 죽음
그림 1. 이벤트 정렬이 무결성을 가른다. 통제 가능한 사슬은 3→4→1이고 5(LB DOWN)와 6(RST)은 간접 제어다. active=0이 먼저면 RST가 무해(no disruption), active>0인데 LB가 먼저 DOWN되면 in-flight가 절단된다(disruption).

핵심은 이벤트 5는 우리가 직접 못 누른다는 것. LB는 health 503을 두 번(fall 2) 본 뒤에야 DOWN을 찍으므로, 이벤트 1(health 503)을 언제 발생시키느냐로 이벤트 5를 간접 제어한다. 그래서 통제 가능한 사슬은 3 → 4 → 1이고, 5 → 6은 그 사슬이 정렬돼 있는 한 무해해진다.

current 모드 — 순서 어긋남으로 끊김

기존 preStop은 "drain → close-lb → sleep 30"인데, health를 먼저 떨어뜨려 버린다(이벤트 1을 4보다 앞에 둔다). Envoy drain(이벤트 3·4)은 SIGTERM 이후에야 시작돼 너무 늦다.

kubelet hc Envoy HAProxy Client preStop /drain → /close-lb → sleep 30 state=DRAINING → CLOSING (즉시) /health_check 200→503 (e1) /health 200 그대로 (e2 미발생) GET /health_check → 503 (1번째 fail) GET /health_check → 503 (2번째 fail, fall=2) backend DOWN (e5) shutdown-sessions: in-flight TCP RST (e6) 502 Bad Gateway (S1 long-request) curl 502 (끊김) Envoy는 SIGTERM 후 terminationDrainDuration간 listener drain 시도하나 connection은 이미 LB가 끊음 이벤트 순서 1 → 5 → 6 (e3·e4 누락, drain 너무 늦음)
그림 1. current 모드: hc가 즉시 CLOSING으로 가 /health_check 503 → HAProxy fall=2 후 backend DOWN → shutdown-sessions가 active>0 연결을 RST → S1 장기요청 502. Envoy drain은 pod kill 후라 너무 늦다(e3·e4 누락).

실제 순서: 1 → 5 → 6. event 3·4(Envoy drain)가 빠진 채 LB가 먼저 backend를 빼서 in-flight가 RST된다.

시나리오별 client 표현 차이 — 위 시퀀스는 long-request(S1), 결과는 502. streaming(S4)은 같은 RST 사건이지만 200 + chunk 일부가 client에 도달한 후 끊김 → curl exit 92(HTTP/2 stream CANCEL) 또는 HTTP/1.1이면 exit 18(transfer closed). HAProxy alpn h2,http/1.1 협상 결과로 표현이 달라짐. 자세한 차이는 W5 테스트 시나리오 설계 §HTTP/2 vs HTTP/1.1 RST 표현.

improved 모드 — 순서 정렬로 무결

핵심 뒤집기: health=200을 유지한 채 먼저 Envoy를 drain하고(이벤트 3), active=0을 폴링으로 확인한 뒤에야(이벤트 4) health를 503으로 떨어뜨린다(이벤트 1). 이게 §2 anchor의 "거짓말처럼 200을 유지하다가 0이 된 그때 떨어뜨린다".

kubelethcEnvoyHAProxyClientpreStop graceful-drain.shDRAINING (/health_check 200 유지)/drain_listeners (e3)loop: 폴링 until active=0GET /stats rq_active60초 응답 완료200 OK (curl 정상)active=0 (e4)CLOSING → /health_check 503 (e1)503 (4s 후) → backend DOWN (e5)shutdown-sessions 트리거되나 active=0sleep LB_BUFFER (10s)CLOSED (/health 503, e2)readiness 503 → endpoint 비동기 제거SIGTERM (preStop 종료 후)
그림 2. improved 모드: active=0까지 폴링 후 CLOSING → backend DOWN 시점엔 끊을 연결이 없어 무중단. 장기 요청도 200으로 완료.

실제 순서: 3 → 4 → 1 → 5 → (6 무력) → 2. event 6이 발생해도 active=0이라 끊을 게 없다. drain → active=0 → health 503 사슬이 정렬됐으므로 5 → 6은 무해.

event 2(readiness 503)의 위치 주의: 위 시퀀스에서 event 2를 마지막에 그렸지만, 이는 이미 active=0인 상태에서의 후행 정리 단계다. readiness 503 → K8s endpoint 제거는 SIGTERM과 인과적으로 묶인 순서가 아니라 비동기로 일어나며(preStop과 SIGTERM은 거의 동시 시작), 무결성에 critical하지 않다. CLOSED 상태에서만 /health가 503을 반환하는 응답 매핑은 W2 hc FSM §응답표와 정합된다.


4. 예시·결과 — 4 시나리오로 변수 격리 측정

메커니즘이 맞는지는 측정으로 증명한다. 4 시나리오(S1~S4)는 각각 한 변수만 격리한다 — 같은 종료 사건을 long-request / drain 단독 / rollout / streaming 네 각도로 본다.

# replicas 시나리오 current improved 시사점
S1 1 long-request /sleep?seconds=60 + Pod delete @ T+5s 502 / 8.25s (HAProxy retry exhausted) 200 / 60.01s LB가 backend 빼는 순간 in-flight RST
S2 1 improved 단독 측정 (drain 거동) active=1 60초 유지, hc OPEN→DRAINING drain.sh가 active 폴링으로 health 200 유지
S3 2 continuous traffic 90s + rollout restart conn_err=9 / 5xx=0 / p50=5.7ms conn_err=0 / 5xx=0 / p50=5.1ms HAProxy retries 3이 5xx 흡수 — disruption은 conn_err로 봐야
S4 1 streaming /stream?seconds=60&interval=1 + Pod delete @ T+8s chunks=12 / curl_exit=92 (HTTP/2 CANCEL) chunks=59/60 / curl_exit=0 T+8s delete + ~4s detect = chunk 12 시점 끊김

검증의 결론: S1이 가장 깨끗한 대조군이다 — current는 502 / 8.25s(LB가 5번 → 6번을 만들어 in-flight를 죽임), improved는 200 / 60.01s(60초 sleep 응답이 온전히 완료). 같은 종료 사건에서 6 events 정렬 여부 하나만 바뀌었는데 결과가 끊김↔무결로 갈린다.

왜 S1·S2·S4는 replicas=1, S3만 replicas=2? roundrobin 환경에서 multi-replica + 한 pod만 delete하면 curl traffic이 다른 pod으로 가서 영향 격리가 안 된다 → single-pod로 통제. S3는 운영 rollout 시나리오라 multi-replica가 필요하다. (시나리오별로 어느 변수를 격리하고 무엇을 못 잡는지의 매트릭스: W5 테스트 시나리오 설계 §1.)

S3의 함정 — 측정 지표 자체가 거짓말한다: 양 모드 모두 5xx=0이라 current가 멀쩡해 보이지만, 실제 disruption은 conn_err=9에 숨어 있다. HAProxy retries 3(defaults block)이 backend DOWN 시 다른 worker로 자동 retry해 5xx를 흡수하기 때문. disruption은 5xx가 아니라 connection error(RST, curl exit 7/92/18)로 측정해야 한다.

회상 quiz

Q1. T+5s에 `kubectl delete pod` 시 current 모드에서 HAProxy가 backend DOWN 마킹하는 시점은? **A**: 약 T+9s. preStop이 즉시 hc 503 flip → HAProxy `inter 2s` 첫 fail check → 4초 후(`fall 2`) DOWN. 정확히는 probe 타이밍에 따라 ±2초 변동.
Q2. improved 모드에서 60초 동안 Envoy `downstream_rq_active`가 1로 유지된 이유는? **A**: backend의 `/sleep?seconds=60` 핸들러가 60초 sleep 후 응답하므로, Envoy 입장에서 in-flight HTTP request 1건이 60초 내내 미완료. drain.sh 폴링 루프가 active>0 조건에서 health 200 유지 → HAProxy backend UP 유지 → in-flight 안전.
Q3. S3에서 양 모드 모두 5xx=0인데 왜 current가 broken인가? **A**: HAProxy `retries 3`(defaults block)이 backend DOWN 시 다른 backend(UP인 worker)로 자동 retry → client는 200. 진짜 disruption은 connection level에서 RST된 9건의 `connection_err`(curl exit 7 등)로 나타남. **disruption 측정 시 5xx + connection_err 둘 다** 봐야.

핵심 정리

What you might be missing

이어 보기