--- type: src tags: [istio, graceful-termination, runbook, envoy, haproxy, observability] created: 2026-06-07 --- # Istio Graceful Termination 사내 도입 런북 (홈랩 실험 → 사내 적용 가이드) > [!abstract] > 홈랩 graceful-termination 실험 결과를 프로덕션 IGW 환경에 이식하는 6단계 런북이다. **머릿속에 담을 한 장면**: LB는 backend의 살아있음 여부를 *오직 health check 응답으로만* 판정한다 — 그래서 LB를 직접 명령할 권한이 없어도, hc 사이드카가 preStop 동안 `/health_check.html`의 200/503 타이밍을 단계적으로 바꾸면 LB의 "이 backend를 DOWN 마킹할지"를 간접 조종할 수 있다. 이 한 줄이 6단계 전부를 푼다. 본 문서는 각 단계가 *무엇을 검증·제어하는지* + *어디서 깨지는지*에 집중하고, FSM 상세는 [HC FSM 정본](gt__src-w2-hc-fsm.html), grace period 산정은 [프로덕션 적용](gt__src-w6-production-apply.html)을 참조한다. > **대상환경**: Istio 1.30 IGW(istio-ingressgateway) + 외부 L4/L7 LB(Citrix NetScaler 또는 HAProxy). **대상독자**: rolling update 중 5xx/connection drop을 0으로 만들려는 SRE. **선행개념**: hc 사이드카 FSM(OPEN/DRAINING/CLOSING/CLOSED/FAULT), Envoy drain. FSM 상태명은 게이트 비유를 사용한다. ## 1. 왜 이 런북이 존재하나 — 문제와 전제 평범한 rolling update에서 IGW pod 하나가 종료될 때 무슨 일이 벌어지는지 보자. kubelet이 SIGTERM을 보내고, 거의 동시에 Service의 endpoint에서 빠지지만, **외부 LB는 그 사실을 즉시 모른다**. LB는 자기 health check 주기(`inter × fall`)가 돌아 backend를 DOWN으로 마킹할 때까지 계속 신규 요청을 그 pod로 보낸다. 더 나쁜 건, LB가 DOWN을 마킹하는 *바로 그 순간* `downStateFlush ENABLED`(HAProxy로 치면 `on-marked-down shutdown-sessions`) 옵션이 켜져 있으면, 아직 처리 중이던 in-flight 요청까지 **즉시 RST로 끊어버린다**. 클라이언트 입장에서는 502/connection reset이다. 여기서 근본 제약이 하나 더 있다: **서비스 팀에는 LB에 "이 backend를 빼라"고 명령할 API 권한이 없다.** LB는 인프라 팀 소유다. 그러면 어떻게 무중단 종료를 만드나? 핵심 관찰은 이것이다 — LB가 backend의 생사를 판정하는 *유일한* 입력은 health check 응답 코드다. 즉 우리가 health 응답을 쥐고 있으면, LB의 backend pool membership을 *간접적으로* 제어할 수 있다. 이 레버를 쥐는 주체가 IGW pod 안의 **hc 사이드카**이고, preStop 동안 응답을 어떤 순서로 바꾸느냐가 graceful termination의 전부다. 이 런북은 그 레버를 사내 LB(Citrix)·사내 Helm chart·사내 Prometheus에 안전하게 배선하는 절차다. > 원본은 행동·검증·위험신호 체크리스트 중심이므로, 여기서는 **각 단계가 어떤 메커니즘을 검증·제어하는지**와 LB 동작 모델(HAProxy ↔ Citrix 대응)을 보충한다. ## 2. 핵심 메커니즘 — health 응답 순서가 곧 LB 제어 레버 ### 앵커: "먼저 다 흘려보내고, 그 다음에 503" 런북에서 알아야 할 인과는 단 하나다 — **LB의 backend 판정은 health check 응답에만 의존**한다. 그래서 hc 사이드카는 preStop 동안 `/health_check.html` 응답을 *단계적으로* 바꿔 LB를 간접 제어한다. 정정된 시퀀스의 핵심은 직관과 정반대다: **"503을 먼저 띄우지 말고, in-flight를 다 흘려보낸 뒤(active=0)에야 503을 띄운다."** 왜? 503을 먼저 띄우면 `downStateFlush ENABLED` LB가 backend를 DOWN 마킹하면서 in-flight를 **즉시 RST**하기 때문이다. 순서를 뒤집는 순간 graceful이 깨진다. 그래서 graceful의 진짜 안전 구간은 health 200을 *유지*하는 DRAINING이고, 503 flip은 보호할 게 없어진 뒤에만 일어난다. ### 3단계 응답표 — 각 단계가 답하는 질문 | 단계 | `/health_check.html` | hc가 하는 일 | LB 거동 | 이 단계가 보장하는 것 | |---|---|---|---|---| | **DRAINING** | **200 유지** | `downstream_rq_active + upstream_rq_active == 0`을 폴링 | UP 유지 — 기존 요청 정상 종료 | in-flight 보호 (RST 없음) | | **CLOSING** | active=0 확인 후 **503 flip**(`/close-lb`) | LB가 DOWN 마킹하도록 신호 | `inter × fall`(예: 2s×2=4s) 만에 DOWN → 신규 차단 | 신규 유입 차단 | | **CLOSED** | `/health`(K8s readiness) **503** | `LB_BUFFER` 대기 후 readiness까지 내림 → pod 종료 | per-node 판정도 DOWN | endpoint 완전 제거 후 SIGTERM | 읽는 법: 각 행은 "이 단계가 어떤 위험을 막는가"로 보면 된다. DRAINING은 *RST*를, CLOSING은 *신규 요청 유입*을, CLOSED는 *조기 종료*를 막는다. 세 위험이 서로 다른 시점에 발생하므로 단계가 셋으로 나뉜다. 즉 graceful의 심장은 **DRAINING(health 200 유지)** 이며, 이 200 유지가 본 문서 핵심 결론("health 200 유지로 in-flight 보호")의 실제 메커니즘이다. OPEN(정상 200)과 FAULT(drain timeout 초과 등 비정상)를 포함한 상태별 health 응답표·전이 제약, 그리고 `POST /reopen` abort 경로와 `Warning: 199` 헤더(CLOSING→OPEN 재투입) 메커니즘은 [HC FSM 정본](gt__src-w2-hc-fsm.html)이 정본이다. ```mermaid sequenceDiagram participant K as kubelet participant H as hc sidecar participant LB as LB (Citrix/HAProxy) K->>H: SIGTERM / preStop graceful-drain.sh Note over H,LB: DRAINING — /health_check.html = 200 loop until active == 0 (or DRAIN_TIMEOUT) H->>H: poll downstream+upstream_rq_active LB->>H: GET /health_check.html -> 200 (stay UP) end Note over H,LB: CLOSING — /close-lb flips 503 H->>LB: /health_check.html = 503 LB->>LB: mark DOWN after inter x fall Note over H,LB: LB_BUFFER wait Note over H: CLOSED — /close flips /health (readiness) 503 H->>K: exit -> pod terminates ``` ### 왜 두 종류의 timeout이 필요한가 이 메커니즘에는 시간 변수가 둘 있고, 혼동하면 그대로 장애가 된다. - **`terminationDrainDuration`** — Envoy(ProxyConfig)가 drain을 끝낼 때까지 기다리는 시간. Envoy가 drain.sh보다 먼저 죽으면 보호할 데이터 경로가 사라진다. - **`terminationGracePeriodSeconds`** — kubelet이 SIGKILL을 보내기 전 유예. 이게 drain·LB_BUFFER 합보다 작으면, drain이 끝나기 전에 pod가 강제로 죽는다. 핵심 불변식: `terminationGracePeriodSeconds`는 항상 `terminationDrainDuration`과 `DRAIN_TIMEOUT + LB_BUFFER` 둘 다보다 커야 한다. 이 산정식이 §5 단계 4의 본체다. ## 3. 6단계 도입 런북 ### 단계 1 — Staging LB의 downStateFlush 동등 옵션 검증 이 런북의 모든 결론은 "사내 LB가 backend disable 시 in-flight를 즉시 RST한다"는 전제에 걸려 있다. 먼저 그 전제부터 확인한다. backend disable 시 in-flight를 즉시 RST하는지(=current 모드 현상), 아니면 graceful drain하는지 본다. ```bash # long request 중에 backend를 LB에서 수동 disable curl -o /dev/null -w "http=%{http_code} t=%{time_total}\n" "https:///sleep?seconds=60" & # (Citrix GUI: LB Vserver → Service → Disable) sudo tcpdump -n -i 'tcp[tcpflags] & tcp-rst != 0' -tttt -w /tmp/rst-staging.pcap ``` - 즉시 502/RST → downStateFlush ENABLED → drain.sh의 health 조작이 유효(이 런북이 의미 있음). - 60s 후 정상 응답 → 이미 graceful → drain.sh 역할이 "pool에서 먼저 제외"로 축소됨. - HTTP keepalive가 켜져 있으면 TCP session 재사용으로 RST 거동이 달라질 수 있음(위험 신호). ### 단계 2 — IGW Helm chart에 hc 사이드카 주입 레버를 쥘 주체(hc)를 IGW pod 안에 넣는다. 사이드카가 들어가야 health 응답을 우리가 제어할 수 있다. ```yaml gateways: istio-ingressgateway: additionalContainers: - name: hc image: <사내-registry>/service-a-hc: ports: [{ containerPort: 18180 }] env: - { name: DRAIN_TIMEOUT, value: "120" } - { name: LB_BUFFER, value: "10" } - { name: POLL_INTERVAL, value: "2" } readinessProbe: { httpGet: { path: /health, port: 18180 }, initialDelaySeconds: 3, periodSeconds: 5 } livenessProbe: { httpGet: { path: /live, port: 18180 }, initialDelaySeconds: 10, periodSeconds: 10 } lifecycle: preStop: { exec: { command: ["/opt/hc/graceful-drain.sh"] } } ``` 검증: `kubectl get pod -l app=istio-ingressgateway -o jsonpath` 로 2-container(`istio-proxy hc`) 확인 + hc `/health_check.html` → 200. 위험 신호: container 없음 → `additionalContainers` 병합 우선순위 / ImagePullBackOff → imagePullSecret. ### 단계 3 — LB → hc health endpoint 도달 경로 LB가 우리 health 응답을 *실제로 읽을* 경로를 깐다. 그리고 이 경로가 "그 노드의 pod 상태"를 정확히 반영해야 한다. ```yaml spec: type: NodePort externalTrafficPolicy: Local # 노드↔pod 1:1, source IP 보존 + 해당 노드 ready pod 없으면 LB가 그 노드 DOWN 판정 ports: - { name: http2, port: 80, nodePort: 30080, targetPort: 8080 } - { name: hc, port: 18180, nodePort: 30180, targetPort: 18180 } ``` `externalTrafficPolicy: Local`이 중요한 이유: `Cluster`면 NodePort가 어느 노드로 들어와도 kube-proxy가 임의 pod로 분산해, health check가 "그 노드의 pod 상태"를 반영하지 못한다 — 즉 우리가 한 pod에서 503을 띄워도 LB는 *다른* 노드의 200을 받아 계속 UP으로 보고 레버가 먹히지 않는다. `Local`은 해당 노드의 pod만 응답하므로 LB의 per-node 판정이 정확해진다. 위험 신호: 30180 timeout → 그 노드에 ready pod 없음(Local 특성) → IGW readiness 확인. ### 단계 4 — terminationGracePeriodSeconds 산정 §2에서 본 두 timeout 불변식을 실제 숫자로 푼다. long request p99을 측정해 drain window를 정한다. 산정식은 [프로덕션 적용 정본](gt__src-w6-production-apply.html) §3-4와 통일한다. ``` DRAIN_TIMEOUT = ceil(p99 × 1.2) LB_BUFFER = × + 5s # terminationDrainDuration: Envoy가 drain.sh보다 먼저 죽지 않도록, # Envoy drain 완료 최대 대기를 >= DRAIN_TIMEOUT로 둔다(고정 마진 임의 가산 X). terminationDrainDuration >= DRAIN_TIMEOUT # 최소 여유만 추가 가능 terminationGracePeriodSeconds = max(DRAIN_TIMEOUT + LB_BUFFER, terminationDrainDuration) + 30 예: p99=30s → DRAIN_TIMEOUT=36, LB_BUFFER=2×2+5=9, terminationDrainDuration=36 → max(36+9, 36)+30 = max(45,36)+30 = 75s (w6 실측은 DRAIN_TIMEOUT 대비 terminationDrainDuration=150을 사용 — 실측 p99에 맞춰 키운 사례) ``` **terminationDrainDuration을 어디에 설정하나** (Istio 1.30, ProxyConfig 필드): - gateway Deployment의 pod annotation으로 개별 설정: `proxy.istio.io/config: '{"terminationDrainDuration":"150s"}'` - 또는 mesh 전역 `MeshConfig.defaultConfig.terminationDrainDuration: 150s`. - 커스텀 Deployment/Helm 사이드카 주입 시에는 annotation 방식이 게이트웨이 단위로 명시적이라 권장. WebSocket/long-lived connection이 p99을 끌어올리면 별도 처리 전략 필요(단계 6 참고). ### 단계 5 — Observability 연결 레버가 잘 먹는지 *눈에 보이게* 한다. drain이 시간 안에 수렴하는지, 안 되면 알람이 뜨는지. Envoy 15090(또는 istio-agent 병합 엔드포인트 15020 `/stats/prometheus`)이 이미 Prometheus로 scrape 중이면 추가 설정 없이 수집된다. Istio 표준 scrape 대상은 보통 15020(병합)이고 15090은 Envoy 자체 prometheus 포트이므로, **사내가 어느 포트를 긁는지 먼저 확인**한다. 실제 수집할 메트릭 목록은 [§6 모니터링 메트릭](#6-모니터링-메트릭-disruption-지표-수집-원칙) 표를 정본으로 두고, 여기서는 **배선(어디서 무엇을 긁어 어떤 alert로 묶나)** 만 다룬다. drain timeout 초과를 잡는 alert: ```yaml - alert: IGWDrainTimeoutExceeded # 발화 조건: pod 삭제 후 DRAIN_TIMEOUT(=120) 초과해도 in-flight가 남아 있음 expr: | (envoy_http_downstream_rq_active > 0) and on(pod) (time() - kube_pod_deletion_timestamp > 120) for: 30s ``` - ``은 단계 4에서 산정한 실제 초(예: 120)로 치환. - `kube_pod_deletion_timestamp`는 **kube-state-metrics가 켜져 있어야** 존재하는 메트릭이다(미설치 시 expr가 항상 빈 결과 → alert 무력화). - 검증: pod 삭제 직후 Grafana에서 `envoy_http_downstream_rq_active` 시계열이 DRAIN_TIMEOUT 안에 0으로 수렴하는지 패널로 확인. ### 단계 6 — Rollout 정책 + canary 레버가 한 pod에서 동작해도, rollout 정책이 잘못되면 *전체* IGW가 동시에 빠져 무용지물이 된다. ```yaml spec: strategy: rollingUpdate: { maxUnavailable: 1, maxSurge: 1 } # 0→1 (deadlock 방지) template: spec: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: # required는 노드수≥replicas 보장 시만 - weight: 100 podAffinityTerm: topologyKey: kubernetes.io/hostname labelSelector: { matchLabels: { app: istio-ingressgateway } } ``` 주의: `maxUnavailable=0` + anti-affinity `required` + replicas=N노드면 새 pod가 앉을 노드가 없어 rollout이 deadlock된다. `maxUnavailable=1`로 두되, in-flight 보호는 graceful-drain.sh가 담당한다(maxUnavailable은 "동시 종료 수" 제약이지 "in-flight 보호" 제약이 아니다). canary: staging에서 S1/S3 재현(connection_err=0 확인) → 프로덕션 1% → 10% → 50% → 100%. Flagger/Argo Rollouts로 weight 기반 가능. ## 4. 실험이 입증한 결과 (이 런북이 작동한다는 증거) 홈랩에서 이 레버를 켰을 때(`improved`)와 껐을 때(`current`)의 측정값이다. 추상적 주장 대신 숫자로 본다. | 시나리오 | current | improved | |---|---|---| | S1 long-request (replicas=1) | delete@T+5s → T+9s HAProxy DOWN → RST → **502 / 8.25s** | drain.sh가 active=0까지 health 200 유지 → DOWN 안 됨 → **200 / 60.01s** | | S3 continuous (replicas=2→3, rollout) | 5xx=0, **connection_err=9** (retries 3이 5xx 흡수, RST는 기록) | 5xx=0, **connection_err=0** | | S4 streaming (replicas=1) | chunks=12/60, curl_exit=92 (HTTP/2 CANCEL) | chunks=59/60, curl_exit=0 | 읽는 법: - **S1**이 메커니즘을 가장 깨끗하게 보여준다. current는 health 200을 유지하지 않아 T+9s에 DOWN→RST→502(8.25s에 끊김). improved는 active=0까지 health 200을 잡아 60s 요청을 끝까지 흘려보낸다(60.01s, 200). - **S3**은 "5xx=0이 곧 무중단이 아니다"의 증거다. current에서도 5xx=0이지만 connection_err=9 — HAProxy `retries 3`이 backend RST를 재시도로 흡수해 클라이언트 5xx는 0이 됐을 뿐, 그 사이 RST가 9번 발생했다. improved에서 connection_err=0. - **S4 streaming**은 별개 세계 신호다. improved도 chunks 59/60(완벽 60은 아님) — `downstream_rq_active`가 connection 수명 내내 `>0`인 무한 stream은 DRAIN_TIMEOUT으로 못 덮는다는 한계를 드러낸다(단계 6/§What 참조). **근본 결론**: LB에 명령 권한이 없어도 health check 응답을 조작하면 LB 동작을 간접 제어할 수 있다. 사내 Citrix가 `downStateFlush ENABLED`(= backend DOWN 시 in-flight 즉시 RST, HAProxy `on-marked-down shutdown-sessions`와 동치)라면 이 결론이 직접 적용된다. ## 5. 떴는지 한 번 확인 (장애 대응 점검 순서: rolling update 중 5xx burst) 레버가 어디서 풀렸는지 위에서 아래로 좁힌다. 각 줄은 §2~§3의 어느 단계가 깨졌는지로 매핑된다. 1. `echo show stat | sudo socat /run/haproxy/admin.sock stdio | awk -F, '{print $1,$2,$18,$19}'` → backend DOWN/UP. worker 전부 DOWN이면 hc endpoint 문제(단계 3). 2. `kubectl logs -c hc | grep "event=transition"` → DRAINING→CLOSING이 너무 빠르면 current 모드(drain.sh 미사용, 단계 2). 3. Envoy `/stats?filter=downstream_rq_active|upstream_rq_active` → drain 중 active>0이면 아직 처리 중(DRAINING 정상 동작). 4. `tcpdump 'tcp[tcpflags] & tcp-rst != 0' -tttt` → RST 시점 ↔ HAProxy DOWN 마킹 시점 일치 여부(503 선행 여부, §2 앵커). 5. `kubectl describe pod` → preStop 실행 여부, grace period 초과 여부(단계 4). drain timeout 초과(active>0가 DRAIN_TIMEOUT 넘김) 시: DRAIN_TIMEOUT 증가(grace period도 함께) vs 강제 종료 허용(초과 in-flight RST 감수). WebSocket이 원인이면 별도 전략. ## 6. 모니터링 메트릭 (disruption 지표 수집 원칙) | 메트릭 | 소스 | 의미 | |---|---|---| | `envoy_http_downstream_rq_active` | Envoy 15090 | IGW in-flight 수 | | `envoy_cluster_upstream_rq_active` | Envoy 15090 | IGW→backend in-flight | | `haproxy_server_status` | haproxy_exporter | 1=UP, 0=DOWN | | `haproxy_backend_connection_errors_total` | haproxy_exporter | TCP 연결 에러 (5xx보다 민감) | **핵심 함정**: `5xx rate`만 보면 disruption이 invisible할 수 있다. HAProxy `retries 3` 같은 LB retry가 backend RST를 재시도로 흡수하면 클라이언트는 5xx를 안 받지만, 그 사이 connection error/지연이 발생한다(S3에서 5xx=0, connection_err=9로 입증). 그래서 **5xx + connection error rate를 둘 다** 수집해야 한다. 클라이언트 측에서는 curl exit 7(connection refused), 92(HTTP/2 CANCEL) 같은 non-200·non-5xx도 별도 집계. ## 핵심 정리 - **LB의 backend 판정 = health check 응답 한 입력**뿐. 그래서 LB 제어권 없이도 health 200/503 타이밍으로 backend 판정을 제어 = graceful termination의 핵심 레버. Citrix `downStateFlush ENABLED` 환경에 직접 이식 가능. - 순서가 곧 메커니즘: **DRAINING(health 200 유지로 in-flight 보호) → CLOSING(active=0 후 503 flip) → CLOSED(readiness 503)**. 503을 먼저 띄우면 in-flight가 RST된다. - `externalTrafficPolicy: Local`은 per-node health 판정 정확성의 전제. Cluster면 분산 때문에 health가 노드 상태를 반영 못 함(다른 노드 200을 LB가 받아 레버 무력화). - 두 timeout을 구분하라: `terminationGracePeriodSeconds`(kubelet SIGKILL 유예)는 항상 `terminationDrainDuration`(Envoy drain 대기)·`DRAIN_TIMEOUT+LB_BUFFER`보다 커야 한다. - maxUnavailable=0 + anti-affinity required + replicas=N노드 = rollout deadlock. maxUnavailable=1 + graceful-drain.sh면 disruption 0 유지(maxUnavailable은 "동시 종료 수" 제약이지 "in-flight 보호" 제약이 아니다). - disruption은 5xx만으로 안 보인다. LB retry가 흡수하므로 connection error rate를 반드시 병행 수집. ## What you might be missing - **상태 순서를 거꾸로 외우기 쉽다.** "drain하면 바로 LB에서 빼야지"라는 직관과 달리, DRAINING에서는 health 200을 **유지**해 in-flight를 끝까지 흘려보낸 뒤(active=0) CLOSING에서야 503으로 flip한다. 503 선행 = `downStateFlush ENABLED` LB가 in-flight 즉시 RST = graceful 실패. - **terminationDrainDuration vs terminationGracePeriodSeconds 혼동.** 전자는 Envoy(ProxyConfig)의 drain 대기, 후자는 kubelet의 강제 kill 전 유예. terminationGracePeriodSeconds < terminationDrainDuration이면 Envoy가 drain을 끝내기 전에 SIGKILL 당한다. 그래서 grace는 항상 drain·LB_BUFFER 합보다 크게 잡는다. - **alert가 조용히 무력화될 수 있다.** `kube_pod_deletion_timestamp`는 kube-state-metrics 의존 메트릭이라, 미설치 환경에선 expr가 빈 결과를 내며 IGWDrainTimeoutExceeded가 절대 발화하지 않는다. alert "정상(미발화)"과 "메트릭 부재"를 구분하려면 `absent()` 보조 alert를 함께 둔다. - **5xx=0이 곧 무중단은 아니다.** LB retry(HAProxy `retries 3`)가 backend RST를 흡수하면 클라이언트 5xx는 0이지만 connection error·지연은 발생한다(S3 실측). disruption SLO는 5xx + connection error rate를 함께 본다. - **streaming은 이 레버의 사각지대다.** `downstream_rq_active`가 connection 수명 내내 `>0`인 WebSocket/gRPC bidi는 active=0 폴링이 끝나지 않아 DRAIN_TIMEOUT으로 못 덮는다(S4의 59/60이 그 경계). 무한 stream은 IGW 분리나 `max_stream_duration` 같은 별도 전략이 필요 — grace period를 키우는 건 답이 아니다.