MOC — Istio Graceful Termination
이 MOC는 homelab Istio graceful-termination 실험의 허브이자, 그 실험이 "왜 이렇게 설계됐는가"를 한 번에 세워주는 멘탈 모델 게이트웨이다. 핵심 결론 한 줄: 서비스 팀은 LB를 직접 명령할 권한이 없으므로, "끊김 없는 Pod 종료"는 health 신호(200↔503)의 타이밍으로 LB의 backend 제거 시점을 간접 제어하는 문제로 환원된다. 사내 LB(Citrix downStateFlush 류)가 backend DOWN 마킹 시 in-flight RST하는 환경에서도, hc 사이드카 + Envoy graceful drain + active-request 폴링 패턴으로 long/streaming 요청 끊김을 막을 수 있음을 입증했다. 아래는 그 메커니즘을 먼저 세우고, 산출물(W1~W6 + 코드 워크스루)로 드릴다운하는 항법도다.
READY/DRAINING/DRAINED_WAIT_LB/TERMINATING/FAILED → OPEN/DRAINING/CLOSING/CLOSED/FAULT (게이트 비유). 옛 artifacts(tests/artifacts/2026*/)는 옛 명칭으로 보존됨.
POST /reopen: DRAINING/CLOSING → OPEN abort. CLOSED는 unrecoverable(409). CLOSING→OPEN 응답엔 Warning: 199 ... 헤더(LB rise 2 = +4s 재투입).
개별 src 노트는 이 callout을 중복 서술하지 말고 본 MOC를 cross-ref할 것.
코드 원본 위치: ~/labs/istio/. 결과 기록: ~/labs/istio/{PLAN.md, EXECUTION-LOG.md}.
0. 배경 — 왜 이 문제가 어려운가
K8s Pod 종료는 본질적으로 여러 비동기 신호의 경쟁(race)이다. kubelet이 SIGTERM을 보내는 순간, LB가 backend를 pool에서 빼는 순간, Envoy가 listener를 drain하는 순간 — 이 셋은 서로 독립적으로 진행되며 누구도 다른 쪽을 기다려주지 않는다. 종료되는 Pod이 들고 있던 long/streaming 요청은, 이 신호들의 순서가 어긋나는 순간 RST(강제 연결 절단)로 죽는다.
여기서 핵심 제약은 권한과 판단 근거 두 가지다.
- 권한: 서비스 팀은 보통 사내 LB(HAProxy/Citrix NetScaler)에 "이 backend를 빼라"는 명령을 직접 내릴 권한이 없다. LB 운영은 인프라 팀 소관이다.
- 판단 근거: LB는 오로지 health check 응답(200 vs 503) 하나만으로 backend의 UP/DOWN을 판단한다. 즉 서비스 팀이 LB에 줄 수 있는 입력은 health 신호 하나뿐이다.
이 두 제약을 합치면 문제의 형태가 바뀐다. "끊김 없는 종료"는 LB를 직접 제어하는 문제가 아니라, 우리가 쥔 유일한 레버(health 응답)의 타이밍으로 LB가 backend를 빼는 시점을 간접 제어하는 문제다. 이 한 줄이 시리즈 전체(W1~W6)의 출발점이다. 더 나쁜 점은, 사내 LB(downStateFlush ENABLED / on-marked-down shutdown-sessions)가 backend를 DOWN 마킹하는 순간 그 backend가 들고 있던 in-flight 세션을 즉시 flush(RST)한다는 것 — graceful close가 아니라 즉시 절단이다. 그래서 "DOWN 마킹 시점"이 곧 "끊김 시점"이고, 우리의 과제는 그 시점을 모든 요청이 끝난 뒤로 미루는 것이 된다.
선행 개념: K8s Pod lifecycle(preStop/SIGTERM/terminationGracePeriodSeconds), Service
externalTrafficPolicy: Local, Envoy admin API, HAProxy health check(inter/rise/fall). 모르면 W1 Big Picture §1·§2가 무대를 깔아준다.
1. 핵심 메커니즘 — 멘탈 모델 ANCHOR
머릿속에 담을 단 한 장면부터.
event 5(LB DOWN @ ~4s)는 두 모드에서 똑같이 일어난다. 모든 차이는 그 전에 event 1(health 200→503 flip)이 언제 발생하느냐 하나다.
이 한 줄에서 시리즈의 모든 디테일이 파생된다. health 503 flip을 즉시 하면(current) LB는 아직 active 요청을 들고 있는 backend를 DOWN 마킹하고 → in-flight RST. flip을 active=0 뒤로 미루면(improved) LB가 DOWN 마킹할 때는 이미 끊을 게 없어 → RST가 무력화된다. "503 flip을 active=0 뒤로 미룬다" — 이게 전부다.
이 anchor가 동작하는 구조
세 부품이 health 레버를 함께 쥔다.
| 부품 | 그게 답하는 질문 | 정본 노트 |
|---|---|---|
| 포트 분리 (data 30080 / health 30180) | 어떻게 "요청은 계속 받으면서 health만 503"을 만드나? | W1 Big Picture §2 |
| hc FSM 5-state (OPEN/DRAINING/CLOSING/CLOSED/FAULT) | health 레버를 누가, 어떤 규칙으로 쥐나? | W2 hc FSM §2~3 |
| drain.sh active 폴링 | flip 시점을 어떻게 active=0까지 미루나? | W2 hc FSM §4~5 |
포트 분리가 출발점이다. HAProxy는 traffic 포워딩(worker:30080)과 health 체크(check port 30180 → hc:18180)를 분리된 포트로 한다. 두 포트가 독립이기 때문에 hc는 traffic은 200으로 계속 받으면서 health만 503으로 떨어뜨려 LB에게만 "나를 빼라"고 신호할 수 있다.
hc FSM이 그 레버를 규칙으로 묶는다. 핵심은 DRAINING 상태에서도 /health_check.html이 여전히 200을 반환한다는 것 — drain이 시작돼도 LB는 아직 backend를 UP으로 본다. active=0이 확인돼 CLOSING으로 전이할 때 비로소 /health_check.html이 503으로 flip한다. illegal transition(OPEN→CLOSING 직접 점프 등)은 전이 함수 advance(expectedFrom, to) 한 곳에서 409로 봉쇄된다.
drain.sh가 flip 시점을 만든다. preStop 훅에서 ① hc를 DRAINING으로 보내고(health 여전히 200) ② Envoy admin 15000으로 POST /drain_listeners?graceful&skip_exit(새 연결 거부, in-flight는 유지) ③ Envoy /stats의 downstream_rq_active + upstream_rq_active를 폴링하며 active=0이 될 때까지 health 200을 붙잡고 ④ active=0이면 hc를 CLOSING으로(여기서 503 flip) → LB가 ~4초 후 DOWN 마킹해도 끊을 in-flight가 없음 ⑤ LB_BUFFER 대기 후 CLOSED로(/health 503 → K8s endpoint 제거).
6 events FSM — 신호 정렬 문제의 구체화
Pod terminated(kubectl delete)는 T+0 트리거이며 별도 event로 세지 않는다. 정본 W1과 동일하게 event는 1~6의 6개다.
T+0 (trigger) Pod terminated (kubectl delete)
T+0 [event 1] hc /health_check.html 200->503 flip <- current는 즉시, improved는 active=0 후
T+later [event 2] hc /health 200->503 flip -> K8s endpoint 제거
T+0~5 [event 3] Envoy /drain_listeners?graceful&skip_exit <- improved에서만 즉시
T+0~60 [event 4] active=0 (응답 완료 시) <- improved에서만 폴링
T+~4s [event 5] HAProxy backend DOWN 마킹 (inter 2s x fall 2)
T+~4s [event 6] on-marked-down shutdown-sessions -> in-flight TCP RST
두 모드의 차이는 event 순서 하나로 환원된다.
- current 모드:
1 → 5 → 6(RST). event 3·4가 통째로 빠진다 — Envoy drain은 Pod kill 후에야 시작돼 너무 늦다. health 503이 즉시 떠 LB가 active 요청을 든 채 DOWN 마킹 → event 6 RST. - improved 모드:
3 → 4 → 1 → 5 → (6 무력) → 2. active=0(event 4)까지 health 200을 유지하므로, LB가 DOWN 마킹(event 5)할 땐 이미 in-flight가 없어 event 6이 무력화된다. event 2(readiness 503 → endpoint 제거)는 이미 active=0인 뒤의 비동기 후행 정리일 뿐, 무결성의 인과 경로가 아니다.
시리즈 구조 다이어그램
아래는 산출물 사이의 학습 의존 그래프다. Hub(이 MOC)에서 W1으로 들어가 멘탈 모델을 세우고, 도메인 워크스루(W2~W5)로 메커니즘을 코드 수준까지 내린 뒤, W6/runbook으로 사내 적용에 수렴한다.
2. Learning Roadmap — anchor를 따라 드릴다운
처음 펼친 사람: §0~§1로 멘탈 모델을 세운 뒤 아래 순서대로. 다시 펼친 사람: 5분이면 W1 + quickstart만, 30분이면 W2~W5 quiz만, §3 회상 카드로 즉시 복구.
Step 1 — Big Picture (15분) · "신호 정렬 문제를 본다"
- W1 — Big Picture — 트래픽 경로, 6 events FSM, 4 시나리오 비교, current vs improved 시퀀스 다이어그램
- Quickstart — 5분 안에 실험 다시 돌리기 (시나리오 재현 명령)
Step 2 — 도메인별 메커니즘 (각 30분) · "레버를 쥐는 부품을 본다"
- W2 — hc FSM + Apps walkthrough — Go 코드: backend Flusher 함정, hc FSM 5-state, drain.sh 7단계
- W3 — IGW deployment + Manifests walkthrough — K8s 매니페스트: IGW 커스텀 Deployment, NodePort 분리, anti-affinity deadlock, SDS UDS volumes
- HAProxy walkthrough — HAProxy: 5 frontend 결정 트리, on-marked-down, retries 3 5xx 흡수, master1 backend 추가
- W5 — Test scenarios + Tests walkthrough — 시나리오 4종 변수 격리, replicas=1 의도, HTTP/2 vs HTTP/1.1 RST 차이, artifacts 해석
Step 3 — 사내 적용 검토 (60분) · "홈랩 결론을 온프렘으로 매핑한다"
- W6 — production apply + Runbook — Citrix↔HAProxy 매핑, 미적용 운영 디테일 정공법, 6단계 체크리스트, 장애 대응 시나리오
Step 4 — 깊이 보강 (선택) · "Envoy drain 내부를 본다"
- Envoy drain listeners —
/drain_listeners?graceful&skip_exitAPI 동작
노트 카탈로그 (역할별 빠른 점프)
멘탈 모델 (Why·What)
| 노트 | 핵심 질문 |
|---|---|
| W1 — Big Picture | 트래픽이 어디서 흐르고 6 events 어떻게 정렬되나? |
| W2 — hc FSM | hc FSM 5-state는 어떤 응답 매트릭스인가? drain.sh가 어떻게 active=0까지 health 200을 유지하나? |
| W3 — IGW deployment | 왜 표준 IngressGateway가 아니라 커스텀 Deployment인가? Service NodePort 분리 의미? anti-affinity deadlock? |
| HAProxy walkthrough | Citrix downStateFlush와 어떤 매핑? retries 3이 5xx를 어떻게 흡수하나? |
| W5 — Test scenarios | 시나리오 4개가 각각 잡는 변수와 못 잡는 변수는? replicas=1 의도? |
| W6 — production apply | 사내 LB가 어느 모델? 미적용 운영 디테일 정공법? |
코드 워크스루 (Where·How)
| 노트 | 코드 라인 가이드 |
|---|---|
| Quickstart | 시나리오 1줄 재현 명령 (모드 전환, S1/S3/S4 인라인) |
| Apps walkthrough | apps/backend/main.go L28~145, apps/hc/main.go L17~362, graceful-drain.sh L1~97 |
| Manifests walkthrough | 7개 yaml + current vs improved 정확한 5가지 차이 표 |
| HAProxy walkthrough | haproxy-current.cfg 5 frontend block 라인별 + diff vs improved |
| Tests walkthrough | tests/lib/common.sh + 01~05.sh 핵심 라인 + artifacts 디렉터리 가이드 |
| Runbook | 사내 도입 6단계 + 모니터링 메트릭 + 장애 대응 절차 |
결과·결정 기록 (외부 파일 — 이 문서 사이트 밖이라 링크 대상 없음)
| 위치 | 내용 |
|---|---|
~/labs/istio/PLAN.md |
7-Phase 설계, Verification Matrix, §9.3 결정 기록 |
~/labs/istio/EXECUTION-LOG.md |
Phase 0~7 + 보정 1·2 + 학습 노트 9가지 누적 |
~/labs/istio/docs/README.md |
코드 디렉터리 측 시리즈 인덱스 |
3. 예시·결과 — anchor가 실측으로 검증되는 곳
멘탈 모델이 맞다면, "503 flip을 active=0 뒤로 미룬다"는 단 하나의 변화가 4개 시나리오 모두에서 끊김을 0으로 바꿔야 한다. 실제로 그랬다. (시나리오 변수 격리 정본: W5 — Test scenarios, 6 events 정의 정본: W1 — Big Picture.)
| # | replicas | 시나리오 | current | improved | anchor 검증 포인트 |
|---|---|---|---|---|---|
| S1 | 1 | long /sleep?seconds=60 + delete @ T+5s |
502 / 8.25s (retry exhausted) | 200 / 60.01s | flip 즉시 → LB가 in-flight 든 채 DOWN → RST. 미루면 60초 완주 |
| S2 | 1 | improved 단독 (drain 거동) | — | active=1 60초 유지, hc OPEN→DRAINING | drain.sh가 active>0 동안 health 200을 붙잡음을 직접 관측 |
| S3 | 2 | continuous 90s + rollout restart | conn_err=9 / 5xx=0 | conn_err=0 / 5xx=0 | retries 3이 5xx를 흡수 → disruption은 conn_err로만 보임 |
| S4 | 1 | streaming /stream?seconds=60&interval=1 + delete @ T+8s |
chunks=12 / curl_exit=92 (HTTP/2 CANCEL) | chunks=59/60 / curl_exit=0 | T+8s delete + ~4s detect = chunk 12에서 RST. 미루면 거의 완주 |
왜 S1·S2·S4는 replicas=1, S3만 replicas=2? roundrobin에서 multi-replica + 한 pod만 delete하면 curl traffic이 다른 pod으로 흘러 영향이 격리되지 않는다 → single-pod로 변수 통제. S3는 운영 rollout 모사라 multi-replica가 필요하다.
검증법(어떻게 "끊김 0"을 판정했나): S1/S4는 curl exit code와 수신 chunk 수, S3는 5xx 그리고 connection_err 둘 다 — retries 3이 5xx를 다른 worker로 흡수하므로 5xx=0만 보면 깨끗해 보이지만 conn_err=9가 실제 끊김을 드러낸다. 신호 타이밍은 hc.log(event=transition), Envoy /stats(active gauge), HAProxy show stat(UP→DOWN), tcpdump(첫 RST)를 한 타임라인에 정렬해 6 events로 읽었다.
정리 — 멘탈 모델 요약
이 시리즈를 한 문장으로 압축하면: graceful termination은 Envoy를 drain하는 문제가 아니라, 우리가 쥔 유일한 레버(health 200↔503)의 flip 시점을 active=0 뒤로 미뤄 LB의 backend 제거가 in-flight를 끊지 못하게 만드는 타이밍 정렬 문제다. event 5(LB DOWN)는 어차피 일어나니, event 1(health flip)을 그 전이 아니라 event 4(active=0) 뒤에 놓는 것 — 나머지는 전부 이 한 줄의 구현 디테일이다.
핵심 정리 — 핵심 발견 8가지 (다시 펼쳤을 때 빠른 회상)
- Citrix downStateFlush ≡ HAProxy
on-marked-down shutdown-sessions— 4초 detect + in-flight RST. 본 홈랩 실험으로 1:1 대응 입증. - drain.sh 본질은 Envoy drain이 아니라 health 200 유지 — active=0까지
/health_check.html=200을 지속해 LB pool membership을 간접 제어. LB 명령 권한 없이도 LB 동작 제어 가능. - HAProxy
retries 3이 5xx를 흡수 — backend DOWN 시 자동 retry로 다른 backend에 재전송. 짧은 요청은 5xx=0이지만 long/streaming은 RST 노출. disruption은 5xx + connection_err 둘 다 봐야. - anti-affinity required + maxUnavailable=0 + N=N nodes는 deadlock — 새 RS pod이 들어갈 좌석 없음. master1 untaint 또는 maxUnavailable=1로 해소.
externalTrafficPolicy: Local+ 컨테이너 ImagePullBackOff = 노드 NodePort 미응답 도미노 — 한 컨테이너 이미지 문제가 노드 외부 traffic 거부로 증폭.- Go middleware의 옵셔널 인터페이스 forwarding 함정 —
*statusRecorder가Flusherpromote 안 함 →/streamtype assertion 실패. 명시적 forward 메소드 필요 (또는 Go 1.20+http.ResponseController). - HTTP/1.1 vs HTTP/2 RST 표현 차이 — HAProxy
alpn h2,http/1.1시 동일 RST 사건이 HTTP/2 stream cancellation(curl exit 92)으로 표현. HTTP/1.1이면 502 또는 exit 18. tests/01-baseline(mode) vstests/03-continuous(deploy-mode) 라벨 키 불일치 — 매니페스트에 두 라벨 모두 필요한 함정.
한 페이지 회상 카드 (offline 버전)
| 영역 | 5초 회상 | 30초 회상 |
|---|---|---|
| 트래픽 경로 | client → HAProxy:443(L7+TLS) → worker:30080 → IGW(istio-proxy+hc) → backend | + check port 30180(hc 별도), externalTrafficPolicy=Local, anti-affinity required(hostname) |
| 6 events 순서 (good) | drain start → active=0 → health 503 → LB DOWN → endpoint 제거 | improved 모드. current는 health 503 즉시 → LB DOWN → RST |
| current 결과 | S1: 502/8.25s. S4: HTTP/2 CANCEL @ chunk12 | S3: conn_err=9 (5xx=0이지만 retries 3이 흡수) |
| improved 결과 | S1: 200/60s. S4: chunks 59/60. S3: conn_err=0 | active 1 폴링 60초 유지, hc OPEN→DRAINING만 발생 |
| 사내 적용 핵심 | hc 사이드카 주입 + 사내 LB 매핑 검증 + p99 측정으로 timeout 산정 | terminationGracePeriodSeconds = max(preStop, drainDuration) + 여유 |
Open Questions (후속 검증 필요)
- [ ] WebSocket / gRPC streaming 환경에서 active count가 long-lived로 유지되면 drain timeout 강제 종료 정책을 어떻게 설계할지
- [ ] HTTP/3 (QUIC) 환경에서 backend DOWN 시 거동 (UDP라 TCP RST 개념 없음)
- [ ] 사내 LB가 Citrix가 아닌 F5 BIG-IP면
Action On Service Down동작이 정확히 같은지 staging 검증 - [ ] 다중 IGW Pod (replicas≫2) 환경에서 한 pod 종료가 다른 pod에 미치는 부하 spike 측정
- [ ] 사내 long request p99 분포가 실제로 얼마인가 —
terminationGracePeriodSeconds산정의 유일한 입력값. APM/Envoy access log histogram 측정 필요. - [ ] 워커 containerd
config_path정상화로imagePullPolicy: Always복원 (현재IfNotPresent+ctr import우회)
인접 도메인 (별도 MOC 없음 — 컨텍스트만 표기)
- Networking — L3/L4 패킷 처리, NodePort + iptables. 본 실험은 L7/HAProxy/Envoy 중심이나 NodePort 경로에서 겹침.
- DevOps — Kubernetes rollout 정책. anti-affinity deadlock + maxUnavailable 패턴이 여기 속함.
- Observability — Envoy
/stats, Prometheus scrape (운영 모니터링 메트릭).
What you might be missing
- drain의 주체는 LB membership이지 Envoy가 아니다. 흔한 오해는 "Envoy graceful drain만 켜면 끊김이 없다"는 것이다. 실제 보호의 핵심은 active=0까지
/health_check.html=200을 유지해 LB가 backend를 pool에 묶어두게 하는 데 있다(발견 #2). Envoy/drain_listeners?graceful&skip_exit는 신규 연결을 받지 않게 할 뿐, 이미 LB가 DOWN 마킹하면 on-marked-down RST를 막지 못한다. - disruption 지표는 5xx 단독으로 부족하다. HAProxy
retries 3이 5xx를 다른 backend로 흡수하므로 짧은 요청은 5xx=0으로 깨끗해 보인다. long/streaming은 retry가 무력해 connection_err로만 드러난다(발견 #3). 사내 적용 시 SLO를 5xx rate만으로 잡으면 streaming 끊김을 놓친다. - 이벤트 번호는 W1 정본(1~6) 기준.
Pod terminated는 event가 아니라 T+0 트리거다. 옛 artifacts·일부 노트에 0~6 7개로 적힌 흔적이 있으면 무시하고 6개 체계로 통일해 회상할 것. - FSM 명칭은 2종이 공존한다. OPEN/DRAINING/CLOSING/CLOSED/FAULT(현행)와 READY/.../FAILED(옛 artifacts). 옛
tests/artifacts/2026*/로그를 읽을 때 명칭 매핑(§abstract callout)을 먼저 떠올릴 것. - event 2(readiness 503)는 무결성의 인과 경로가 아니다. improved 모드에서 무중단을 보장하는 것은 event 3·4(drain → active=0)와 event 1(health 503)의 순서이며, K8s endpoint 제거는 이미 active=0인 뒤의 비동기 후행 정리일 뿐이다. SIGTERM과 endpoint 제거의 선후를 인과로 오해하지 말 것.
- 이 MOC가 가리키는 코드 리포 파일(PLAN.md/EXECUTION-LOG.md 등)은 문서 사이트 밖이다. 링크가 없는 것은 누락이 아니라 의도된 것 — 코드 리포(
~/labs/istio/)에서 직접 열어야 한다.