--- type: src tags: [istio, graceful-termination, envoy, haproxy, k8s, homelab] created: 2026-06-07 --- # W1. Big Picture — 트래픽 경로 + 6 events + 4 시나리오 > [!abstract] > graceful-termination 학습 시리즈(W1~W6)의 첫 hub 노트. **무중단 Pod 종료**를 한 문장으로 환원한다: 우리는 LB를 직접 제어할 수 없고 오직 **health 신호(200/503) 하나**만 LB에 줄 수 있으므로, "끊김 없는 종료"는 **6개 timestamp를 옳은 순서로 정렬해 LB가 backend를 *늦게* 빼게 만드는 타이밍 문제**다. 이 문서는 그 무대(트래픽 5-hop 경로), 그 변수(종료 시 6개 시점), 그 측정(4 시나리오)을 세운다. > **시리즈 위치**: 대응 코드 워크스루는 [코드 워크스루(quickstart)](gt__src-quickstart.html). 다음 단계는 [W2 hc FSM](gt__src-w2-hc-fsm.html), 전체 인덱스는 [graceful-termination MOC](gt__MOC-graceful-termination.html). > **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](gt__src-w2-hc-fsm.html) §전이/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 전체의 출발점이다. 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](gt__src-w3-igw-deployment.html) §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가 일어나도 끊을 게 없다. ```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: Envoy는 SIGTERM 후 terminationDrainDuration(상향값, W6 산출식)간
listener drain 시도하지만 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 테스트 시나리오 설계](gt__src-w5-test-scenarios.html) §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 종료 후) ``` 실제 순서: **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](gt__src-w2-hc-fsm.html) §응답표와 정합된다. --- ## 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 테스트 시나리오 설계](gt__src-w5-test-scenarios.html) §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 프로덕션 적용](gt__src-w6-production-apply.html)·[runbook](gt__src-runbook.html)에 위임된다. 한 문서에 옛 실험값이 박제되면 다른 문서와 어긋나므로 정본을 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 코드 워크스루](gt__src-quickstart.html) - 다음: [W2 hc FSM 멘탈 모델](gt__src-w2-hc-fsm.html) — hc FSM 5-state + drain.sh 7단계 - 프로젝트 인덱스: [graceful-termination MOC](gt__MOC-graceful-termination.html) - 운영 매핑: [HAProxy 워크스루](gt__src-haproxy-walkthrough.html)(on-marked-down shutdown-sessions), [Envoy drain listeners](gt__src-envoy-drain-listeners.html)(graceful drain)