🏠 목록 MOC — Istio Graceful Termination 📄 MD 원본 🌓 테마
istiograceful-terminationproduction-prep

MOC — Istio Graceful Termination

NOTE

이 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 + 코드 워크스루)로 드릴다운하는 항법도다.

ℹ FSM 명명 매핑 (시리즈 정본 — 여기를 기준으로)

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를 직접 제어하는 문제가 아니라, 우리가 쥔 유일한 레버(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 /statsdownstream_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 순서 하나로 환원된다.

시리즈 구조 다이어그램

아래는 산출물 사이의 학습 의존 그래프다. Hub(이 MOC)에서 W1으로 들어가 멘탈 모델을 세우고, 도메인 워크스루(W2~W5)로 메커니즘을 코드 수준까지 내린 뒤, W6/runbook으로 사내 적용에 수렴한다.

MOCGraceful Terminationw1-big-picturemental modelquickstartreplay cmdsw2-hc-fsm / appsbackend+hc+drainw3-igw / manifestsIGW DeploymenthaproxyL7 + on-marked-downw5 / tests4 scenariosw6-productionstrategyrunbookchecklist
그림 1. MOC 허브: big-picture가 멘탈모델을 잡고 4개 도메인 walkthrough(hc/igw/haproxy/tests)로 분기, w6 적용전략과 runbook으로 수렴.

2. Learning Roadmap — anchor를 따라 드릴다운

처음 펼친 사람: §0~§1로 멘탈 모델을 세운 뒤 아래 순서대로. 다시 펼친 사람: 5분이면 W1 + quickstart만, 30분이면 W2~W5 quiz만, §3 회상 카드로 즉시 복구.

Step 1 — Big Picture (15분) · "신호 정렬 문제를 본다"

  1. W1 — Big Picture — 트래픽 경로, 6 events FSM, 4 시나리오 비교, current vs improved 시퀀스 다이어그램
  2. Quickstart — 5분 안에 실험 다시 돌리기 (시나리오 재현 명령)

Step 2 — 도메인별 메커니즘 (각 30분) · "레버를 쥐는 부품을 본다"

  1. W2 — hc FSM + Apps walkthrough — Go 코드: backend Flusher 함정, hc FSM 5-state, drain.sh 7단계
  2. W3 — IGW deployment + Manifests walkthrough — K8s 매니페스트: IGW 커스텀 Deployment, NodePort 분리, anti-affinity deadlock, SDS UDS volumes
  3. HAProxy walkthrough — HAProxy: 5 frontend 결정 트리, on-marked-down, retries 3 5xx 흡수, master1 backend 추가
  4. W5 — Test scenarios + Tests walkthrough — 시나리오 4종 변수 격리, replicas=1 의도, HTTP/2 vs HTTP/1.1 RST 차이, artifacts 해석

Step 3 — 사내 적용 검토 (60분) · "홈랩 결론을 온프렘으로 매핑한다"

  1. W6 — production apply + Runbook — Citrix↔HAProxy 매핑, 미적용 운영 디테일 정공법, 6단계 체크리스트, 장애 대응 시나리오

Step 4 — 깊이 보강 (선택) · "Envoy drain 내부를 본다"

  1. Envoy drain listeners/drain_listeners?graceful&skip_exit API 동작

노트 카탈로그 (역할별 빠른 점프)

멘탈 모델 (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가지 (다시 펼쳤을 때 빠른 회상)

  1. Citrix downStateFlush ≡ HAProxy on-marked-down shutdown-sessions — 4초 detect + in-flight RST. 본 홈랩 실험으로 1:1 대응 입증.
  2. drain.sh 본질은 Envoy drain이 아니라 health 200 유지 — active=0까지 /health_check.html=200을 지속해 LB pool membership을 간접 제어. LB 명령 권한 없이도 LB 동작 제어 가능.
  3. HAProxy retries 3이 5xx를 흡수 — backend DOWN 시 자동 retry로 다른 backend에 재전송. 짧은 요청은 5xx=0이지만 long/streaming은 RST 노출. disruption은 5xx + connection_err 둘 다 봐야.
  4. anti-affinity required + maxUnavailable=0 + N=N nodes는 deadlock — 새 RS pod이 들어갈 좌석 없음. master1 untaint 또는 maxUnavailable=1로 해소.
  5. externalTrafficPolicy: Local + 컨테이너 ImagePullBackOff = 노드 NodePort 미응답 도미노 — 한 컨테이너 이미지 문제가 노드 외부 traffic 거부로 증폭.
  6. Go middleware의 옵셔널 인터페이스 forwarding 함정*statusRecorderFlusher promote 안 함 → /stream type assertion 실패. 명시적 forward 메소드 필요 (또는 Go 1.20+ http.ResponseController).
  7. 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.
  8. tests/01-baseline(mode) vs tests/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 (후속 검증 필요)

인접 도메인 (별도 MOC 없음 — 컨텍스트만 표기)


What you might be missing