--- title: W1. Big Picture — 트래픽 경로 + 6 events + 4 시나리오 date: 2026-06-07 type: guide domain: istio tags: [graceful-termination, envoy, haproxy, health-check] --- > [!abstract] > graceful-termination 학습 시리즈(W1~W6)의 첫 hub 노트. **무중단 Pod 종료**를 한 문장으로 환원한다: 우리는 LB를 직접 제어할 수 없고 오직 **health 신호(200/503) 하나**만 LB에 줄 수 있으므로, "끊김 없는 종료"는 **6개 timestamp를 옳은 순서로 정렬해 LB가 backend를 *늦게* 빼게 만드는 타이밍 문제**다. 이 문서는 그 무대(트래픽 5-hop 경로), 그 변수(종료 시 6개 시점), 그 측정(4 시나리오)을 세운다. > **시리즈 위치**: 대응 코드 워크스루는 [코드 워크스루(quickstart)](/docs/istio/graceful-termination/quickstart/). 다음 단계는 [W2 hc FSM](/docs/istio/graceful-termination/w2-hc-fsm/), 전체 인덱스는 [graceful-termination MOC](/docs/istio/graceful-termination/). > **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](/docs/istio/graceful-termination/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를 비동기로 뺀다. - **istio-proxy 컨테이너(PID 1 = istio-agent)** — SIGTERM을 받는 것은 Envoy 자신이 아니라 컨테이너의 PID 1인 istio-agent(pilot-agent)다. istio-agent가 Envoy admin API(`/drain_listeners?graceful`)를 호출해 drain을 *지시*하고, `terminationDrainDuration`만큼 대기한 뒤 `/quitquitquit`로 Envoy를 종료시킨다 — Envoy 프로세스 자체가 SIGTERM에 반응해 자율적으로 drain을 시작하는 것이 아니다. - **외부 LB(HAProxy/Citrix)** — health check 응답만 보고 backend를 UP/DOWN으로 판정한다. > [!note] Istio 1.30 기준 정정 — native sidecar는 이 비동기 전제를 부분적으로 깬다 > 위 "네 주체가 완전히 비동기"라는 프레이밍은 sidecar가 일반 컨테이너로 주입되던 시절의 논의다. Istio 1.27부터 `ENABLE_NATIVE_SIDECARS`가 기본 true로 승격되어 istio-proxy가 Kubernetes native sidecar(initContainers + `restartPolicy: Always`, K8s 1.29+ beta로 기본 활성화)로 주입되는 것이 기본값이다. 이 경우 kubelet은 **앱 컨테이너가 모두 종료된 뒤에야** istio-proxy에 SIGTERM을 보내는 순서를 보장하므로, kubelet↔istio-proxy 사이의 "서로 모른 채 동시에 움직인다"는 서술은 Istio 1.30 기본 프로필에서는 더 이상 정확하지 않다. 다만 **외부 LB가 health 신호 하나로만 판단한다는 핵심 제약(§1의 결론)은 native sidecar 여부와 무관하게 그대로 유효**하다 — 아래 논의는 이 제약 위에서 성립한다. 여기서 두 가지 **제약**이 모든 설계를 지배한다. 이 둘이 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: | | (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](/docs/istio/graceful-termination/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 | > [!note] 실측 보정 — `skip_exit`의 "종료 방지" 효과는 admin API 레벨만으로는 관측되지 않았다 > Envoy 공식 문서는 `skip_exit`이 있으면 drain 기간이 끝나도 Envoy 프로세스를 종료시키지 않는다고 설명하지만, homelab 클러스터 실측(T27, 상세는 문서 하단 검증 기록)에서는 `skip_exit` 유무와 무관하게 `terminationDrainDuration`이 한참 지나도 istio-proxy의 Envoy PID가 그대로 살아 있었다. 위 §1 정정에서 다뤘듯 실제 프로세스 종료(`/quitquitquit`)는 `drain_listeners` 호출 자체가 아니라 istio-agent가 별도로 트리거하는 후속 단계이기 때문으로 보인다 — Envoy 혼자서는 drain 종료 후 자동으로 종료(exit)하지 않으므로, 표준 종료 경로(istio-agent가 관리)에서는 `skip_exit`의 차이가 admin API만 보고는 드러나지 않는다. in-flight 요청이 drain 도중에도 끝까지 완주한다는 "graceful" 효과 자체는 실측으로 지지된다. **왜 "순서" 문제인가**: 이벤트 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가 일어나도 끊을 게 없다. ```mermaid flowchart LR subgraph "통제 대상: 이 순서를 만든다" E3["3. drain start"] --> E4["4. active=0"] E4 --> E1["1. health 503"] E1 --> E5["5. LB DOWN"] end E5 -->|"on-marked-down
shutdown-sessions"| E6["6. RST"] E6 -.->|"active=0이면
끊을 게 없음 = 무결"| OK["no disruption"] E5 -.->|"active>0인데 먼저 DOWN
= in-flight 죽음"| BAD["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 이후에야 시작돼 너무 늦다. ```mermaid sequenceDiagram autonumber participant K as kubelet participant HC as hc participant E as Envoy participant LB as HAProxy participant C as Client K->>HC: preStop "/drain -> /close-lb -> sleep 30" HC->>HC: state=DRAINING -> CLOSING (즉시) Note over HC: /health_check.html 200->503 (event 1) Note over HC: /health 200 그대로 (event 2 미발생) LB->>HC: GET /health_check.html -> 503 (1번째 fail) LB->>HC: GET /health_check.html -> 503 (2번째 fail, fall=2) LB->>LB: backend DOWN (event 5) LB-->>E: shutdown-sessions: in-flight TCP RST (event 6) LB-->>C: 502 Bad Gateway (S1 long-request) Note over E: SIGTERM은 실제로 istio-agent(PID 1)가 받아 Envoy에 drain을 지시하며
terminationDrainDuration(상향값, W6 산출식)간 대기하지만 connection은 이미 LB가 끊은 상태 ``` 실제 순서: **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 테스트 시나리오 설계](/docs/istio/graceful-termination/w5-test-scenarios/) §HTTP/2 vs HTTP/1.1 RST 표현. ### improved 모드 — 순서 정렬로 무결 핵심 뒤집기: **health=200을 유지한 채 먼저 Envoy를 drain하고(이벤트 3), active=0을 폴링으로 확인한 뒤에야(이벤트 4) health를 503으로 떨어뜨린다(이벤트 1).** 이게 §2 anchor의 "거짓말처럼 200을 유지하다가 0이 된 그때 떨어뜨린다". ```mermaid sequenceDiagram autonumber participant K as kubelet participant HC as hc participant E as Envoy participant LB as HAProxy participant C as Client K->>HC: preStop /opt/hc/graceful-drain.sh HC->>HC: state=DRAINING (/health_check.html 200 그대로) HC->>E: POST /drain_listeners?graceful&skip_exit (event 3) loop 폴링 until active=0 HC->>E: GET /stats?filter=...rq_active end Note over E: 60초 응답 완료 HC-->>C: 200 OK (curl 정상 수신) Note over E: active=0 (event 4) HC->>HC: state=CLOSING Note over HC: /health_check.html 503 (event 1) LB->>HC: 503 (4초 후) -> backend DOWN (event 5) LB-->>E: shutdown-sessions 트리거되지만 active 이미 0이라 끊을 게 없음 HC->>HC: sleep LB_BUFFER (10s) HC->>HC: state=CLOSED (/health 503, event 2) Note over K,HC: K8s가 readiness 503 관측 -> endpoint 비동기 제거
(SIGTERM 타이밍과 독립; active 이미 0이라 무결성에 non-critical) K->>E: SIGTERM (preStop 종료 후 — 실제 수신자는 istio-agent, Envoy는 그 지시를 받음) ``` 실제 순서: **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](/docs/istio/graceful-termination/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 테스트 시나리오 설계](/docs/istio/graceful-termination/w5-test-scenarios/) §1.) **S3의 함정 — 측정 지표 자체가 거짓말한다**: 양 모드 모두 `5xx=0`이라 current가 멀쩡해 보이지만, 실제 disruption은 `conn_err=9`에 숨어 있다. HAProxy `retries 3`(defaults block)이 backend DOWN 시 다른 worker로 자동 retry해 5xx를 흡수하기 때문. **disruption은 5xx가 아니라 connection/전송 레벨 오류로 측정해야 한다.** 다만 예시로 든 curl exit 코드는 정정이 필요하다 — exit **7**(connect 실패)은 신규 TCP 연결 수립 자체의 실패를 뜻하는 코드라 이미 맺어진 연결이 RST되는 상황과는 실패 지점이 다르므로 이 목록에서 빼야 한다. homelab 실측(T33, 상세는 문서 하단 검증 기록)에서는 backend를 `--grace-period=0`으로 강제 종료했을 때 56/92/18이 아니라 **curl exit 28(timeout)** — backend가 응답 없이 사라져 client가 그대로 행(hang)하다 타임아웃되는 패턴이 관측됐다. 즉 정확히 어떤 exit code가 뜨는지는 종료 경로(강제 kill vs graceful RST vs HTTP/2 스트림 종류)에 따라 달라지며, 핵심은 특정 코드 하나로 단정하는 것이 아니라 "5xx로는 안 보인다"는 방향이다. ### 회상 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은 아님 — §4 정정 참조). **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 프로덕션 적용](/docs/istio/graceful-termination/w6-production-apply/)·[runbook](/docs/istio/graceful-termination/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/전송 레벨 오류(강제종료 실측에서는 exit 28/timeout, RST 시에는 56/92 계열 — exit 7은 신규 connect 실패라 해당 없음)로만 드러난다. - **두 채널의 분리가 전제다.** health probe(30180→hc:18180)와 traffic(30080→envoy:8080)이 따로이고, hc→envoy admin 15000이 단방향으로 연결돼 있어야 "active를 폴링해 health를 늦게 떨어뜨리는" 트릭이 성립한다. 이 토폴로지가 없으면 메커니즘 자체가 불가능하다. ## 이어 보기 - 코드 워크스루: [quickstart 코드 워크스루](/docs/istio/graceful-termination/quickstart/) - 다음: [W2 hc FSM 멘탈 모델](/docs/istio/graceful-termination/w2-hc-fsm/) — hc FSM 5-state + drain.sh 7단계 - 프로젝트 인덱스: [graceful-termination MOC](/docs/istio/graceful-termination/) - 운영 매핑: [HAProxy 워크스루](/docs/istio/graceful-termination/haproxy-walkthrough/)(on-marked-down shutdown-sessions), [Envoy drain listeners](/docs/istio/graceful-termination/envoy-drain-listeners/)(graceful drain) --- ## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6) 검증 방법: 각 주장을 공식 문서(Envoy/Istio/HAProxy/curl)와 대조하고, homelab 클러스터(Istio 1.30 default profile, k8s 1.30.6)에서 재현 가능한 항목은 실측으로 교차 검증함. | 주장 | 판정 | 근거 | |---|---|---| | C1. 핵심 멘탈모델 — health 신호 하나로 LB의 backend 제거 타이밍을 조율한다 | ✅ 실측 확인 | [envoyproxy.io/.../draining](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/operations/draining) · [apps-walkthrough T27](/docs/istio/graceful-termination/apps-walkthrough/files/verify/T27/) | | C2. `drain_listeners?graceful&skip_exit`가 신규 요청을 억제하고 skip_exit로 프로세스를 유지시킨다 | 🔬 실측 반증 — 본문 교정 | [envoyproxy.io/.../admin](https://www.envoyproxy.io/docs/envoy/latest/operations/admin) · [apps-walkthrough T27](/docs/istio/graceful-termination/apps-walkthrough/files/verify/T27/) | | C3. `downstream_rq_active`/`upstream_rq_active`는 in-flight 판정용 게이지다 | ✅ 실측 확인 | [envoyproxy.io/.../http_conn_man/stats](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/stats) · [apps-walkthrough T27](/docs/istio/graceful-termination/apps-walkthrough/files/verify/T27/) | | C4. 15000=Envoy admin API, 15021=status/health로 역할이 분리된다 | ✅ 실측 확인 | [envoyproxy.io/.../admin](https://www.envoyproxy.io/docs/envoy/latest/operations/admin) · [envoy-admin-api-diagnosis T62](/docs/istio/xds-envoy/envoy-admin-api-diagnosis/files/verify/T62/) | | C5. `terminationDrainDuration` 기본값은 5초다 | ✅ 실측 확인 | [github.com/istio/api/.../proxy.proto](https://raw.githubusercontent.com/istio/api/master/mesh/v1alpha1/proxy.proto) · [runbook T30](/docs/istio/graceful-termination/runbook/files/verify/T30/) | | C6. "Envoy가 SIGTERM을 받아 스스로 drain을 시작한다" | ❌ 오류 — 본문 교정 | [github.com/istio/istio/.../agent.go](https://github.com/istio/istio/blob/master/pkg/envoy/agent.go) · [runbook T30](/docs/istio/graceful-termination/runbook/files/verify/T30/) | | C7. kubelet/제어plane/Envoy/LB 네 주체가 완전히 비동기로 움직인다 | ⚠️ 구버전 서술 — 갱신 | [istio.io/.../1.27 change-notes](https://istio.io/latest/news/releases/1.27.x/announcing-1.27/change-notes/) · [runbook T30](/docs/istio/graceful-termination/runbook/files/verify/T30/) | | C8. `on-marked-down shutdown-sessions`는 DOWN 마킹 즉시 기존 세션을 RST한다 | ✅ 문헌 확인 | [discourse.haproxy.org/.../on-marked-down](https://discourse.haproxy.org/t/on-marked-down-shutdown-sessions-not-working/6456) | | C9. HAProxy `retries` 기본값은 3이다 | ✅ 문헌 확인 | [haproxy.com/.../retries](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/retries/) | | C10. `inter`/`rise`/`fall`의 의미(간격/UP 판정/DOWN 판정 횟수) | ✅ 문헌 확인 | [haproxy.com/.../health-checks](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/) | | C11. disruption은 5xx가 아니라 connection error(curl exit 7/92/18)로 측정해야 한다 | ❌ 오류 — 본문 교정 | [curl.se/libcurl/.../errors](https://curl.se/libcurl/c/libcurl-errors.html) · [tests-walkthrough T33](/docs/istio/graceful-termination/tests-walkthrough/files/verify/T33/) |