W1. Big Picture — 트래픽 경로 + 6 events + 4 시나리오
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가 사라질 때 최소 네 주체가 서로의 진행을 모른 채 동시에 움직인다.
- kubelet — 종료를 결정하고 preStop hook을 돌린 뒤 SIGTERM을 보낸다.
- K8s control plane — readiness가 깨지면 EndpointSlice에서 이 Pod를 비동기로 뺀다.
- Envoy(istio-proxy) — SIGTERM을 받으면 listener를 drain하기 시작한다.
- 외부 LB(HAProxy/Citrix) — health check 응답만 보고 backend를 UP/DOWN으로 판정한다.
여기서 두 가지 제약이 모든 설계를 지배한다. 이 둘이 W1~W6 전체의 출발점이다.
- 권한 제약 — 서비스 팀은 보통 LB에
backend down같은 명령을 내릴 권한이 없다. LB를 직접 조작할 수 없다. - 판단 근거 제약 — 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 포트 역할 구분:
15000은 Envoy admin API(관리:/drain_listeners,/stats,/config_dump) — drain 제어·active count 폴링이 여기로 간다.15021은 status/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가 일어나도 끊을 게 없다.
핵심은 이벤트 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 이후에야 시작돼 너무 늦다.
실제 순서: 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). HAProxyalpn 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이 된 그때 떨어뜨린다".
실제 순서: 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 둘 다** 봐야.핵심 정리
- 한 문장 멘탈모델: LB는 health 신호만 보는 눈먼 pool 관리자다. graceful termination = active=0이 될 때까지 health=200을 유지하다 0이 된 그때 503으로 떨어뜨려, LB가 backend를 뒤늦게 빼게 만드는 타이밍 조율.
- Pod 종료의 무결성은 6개 timestamp(health 503 → readiness 503 → Envoy drain → active=0 → LB DOWN → RST)의 정렬 문제이며, 통제 가능한 사슬은
3(drain) → 4(active=0) → 1(health 503)하나뿐이다. - 이벤트 5(LB DOWN)→6(RST)은
on-marked-down shutdown-sessions로 항상 묶여 있다. 4(active=0) 뒤에 5가 오면 RST가 끊을 게 없어 무해, 앞에 오면 in-flight가 죽는다. - 변수 격리를 위해 S1·S2·S4는 replicas=1, rollout 모사용 S3만 replicas=2.
- HAProxy
retries 3이 5xx를 흡수하므로 disruption은 5xx가 아니라 connection error로 측정해야 한다.
What you might be missing
terminationDrainDuration는 W1에서 단정하지 않는다. 이 값은 drain 합계보다 길게(Istio default 5s에서 상향) 잡되, 구체 수치 산출식은 W6 프로덕션 적용·runbook에 위임된다. 한 문서에 옛 실험값이 박제되면 다른 문서와 어긋나므로 정본을 cross-ref로만 참조하라.- 15000(admin) ≠ 15021(status). drain 제어·active 폴링은 admin 15000으로 가고, 15021은 istio-proxy의 status/health(W3에서 별도 NodePort 노출)다. 두 포트를 같은 "health"로 묶으면 진단이 어긋난다.
- event 2(readiness 503)는 무결성의 인과 경로가 아니다. improved 모드에서 무중단을 보장하는 것은 event 3·4(drain → active=0)와 event 1(health 503)의 순서이며, K8s endpoint 제거는 이미 active=0인 뒤의 비동기 후행 정리일 뿐이다. SIGTERM과 endpoint 제거의 선후를 인과로 오해하지 말 것.
- disruption은 5xx로 안 보인다. HAProxy
retries 3가 backend DOWN 시 다른 worker로 재시도해 5xx를 흡수한다. 진짜 끊김은 connection error(RST, curl exit 7/92/18)로만 드러난다. - 두 채널의 분리가 전제다. health probe(30180→hc:18180)와 traffic(30080→envoy:8080)이 따로이고, hc→envoy admin 15000이 단방향으로 연결돼 있어야 "active를 폴링해 health를 늦게 떨어뜨리는" 트릭이 성립한다. 이 토폴로지가 없으면 메커니즘 자체가 불가능하다.
이어 보기
- 코드 워크스루: quickstart 코드 워크스루
- 다음: W2 hc FSM 멘탈 모델 — hc FSM 5-state + drain.sh 7단계
- 프로젝트 인덱스: graceful-termination MOC
- 운영 매핑: HAProxy 워크스루(on-marked-down shutdown-sessions), Envoy drain listeners(graceful drain)