--- type: src tags: [istio, graceful-termination, citrix-netscaler, production-rollout, observability, terminationgraceperiod] created: 2026-06-07 --- # W6. 프로덕션 적용 가이드 — 실험 결론 → 온프렘 매핑 > [!abstract] > **머릿속 한 장**: 서비스 팀은 LB를 직접 명령할 권한이 없지만 health 응답(200↔503)은 제어할 수 있다 — hc 사이드카 + `graceful-drain.sh`가 Envoy의 `rq_active==0`이 될 때까지 health 200을 붙잡아 LB의 backend 제거 시점을 **간접 제어**하는 것이 이 시리즈 전체의 골격이다. W6(종합편)은 graceful termination 시리즈 W1~W5의 실험 결론을 사내 온프렘(Citrix NetScaler LB + 워커 containerd)으로 매핑하고, 적용을 막는 운영 디테일을 정면 돌파한다. 결론: 사내 LB가 Citrix `downStateFlush ENABLED`면 홈랩 HAProxy 결론이 **그대로 직접 적용**되고, 남는 유일한 미지수는 **사내 long request p99 분포**다. 적용 절차·체크리스트 정본은 [graceful termination runbook](gt__src-runbook.html), 6 events 정의 정본은 [W1 big picture](gt__src-w1-big-picture.html). > **시리즈 위치**: W6(종합). W1~W5 실험 결론을 사내 온프렘 환경으로 매핑하고 미적용 운영 디테일을 정면 돌파. > **FSM 명명**: `OPEN/DRAINING/CLOSING/CLOSED/FAULT`. 신규 `POST /reopen`. --- ## 1. 배경 — 왜 이 문제가 존재하는가 **풀어야 할 문제 한 줄**: pod 하나가 종료될 때(배포·스케일다운·노드 드레인) **이미 들어와 처리 중인 요청을 끊지 않으면서** 그 pod를 트래픽 경로에서 빼는 것. 이게 왜 어려운가. pod 종료는 두 주체가 **동시에 비동기로** 움직인다. - **K8s 쪽**: kubelet이 `SIGTERM`을 보내고 `terminationGracePeriodSeconds` 후 `SIGKILL`. 동시에 EndpointSlice에서 pod를 빼 service 경로를 끊는다. - **외부 LB 쪽**: LB는 K8s를 모른다. 자기 health check(주기적 probe)가 실패해야 비로소 backend를 pool에서 뺀다. 두 타임라인이 어긋나면 in-flight 요청이 끊긴다. 가장 흔한 사고는 **LB가 backend를 빼는 순간 아직 처리 중인 요청이 남아 있는 것** — LB가 `downStateFlush`/`shutdown-sessions`로 기존 세션까지 RST하면 그 요청들이 502/connection error로 죽는다. 여기서 핵심 제약이 등장한다. **서비스 팀은 LB(Citrix/HAProxy)를 직접 명령할 권한이 없다.** "지금 이 backend 빼"라고 LB에 명령할 API가 없다. 가진 건 단 하나 — LB가 주기적으로 찌르는 health endpoint의 응답을 **200으로 줄지 503으로 줄지 결정할 권한**이다. 이 좁은 제어 표면만으로 LB의 backend 제거 타이밍을 원하는 순간으로 미루는 것이 이 시리즈 전체가 푸는 문제다. **선행 개념 3개** (없으면 아래가 안 풀린다): - **6 events**: pod 종료 시 일어나야 하는 6개 사건(drain start, active=0, health fail, readiness fail, LB down, RST). 정의 정본은 [W1](gt__src-w1-big-picture.html). 이들의 **순서**가 무결성을 결정한다. - **Envoy drain**: Envoy admin `:15000/drain_listeners?graceful&skip_exit`로 새 conn은 거부하되 기존 in-flight는 유지하는 상태. - **rq_active**: Envoy `/stats`의 `downstream_rq_active + upstream_rq_active`. "지금 이 pod를 통과 중인 요청 수". 이게 0이면 끊을 게 없다는 뜻. - **대상 환경**: 프로덕션 온프렘 (Citrix NetScaler LB, 워커 containerd, Helm 배포 IGW). | **대상 독자**: 홈랩 결론을 프로덕션에 옮기려는 SRE. | **범위**: 매핑·적용 결정·미적용 디테일. 실험 자체는 W1~W5. | **선행**: 위 3개 + HAProxy `on-marked-down`. ## 2. 입증된 메커니즘 — 간접 제어가 왜 통하는가 (W1~W5 종합) **이 문서의 앵커 한 문장**: LB는 health 응답(200 vs 503) **하나로만** backend pool membership을 정한다. 그러므로 "active=0이 될 때까지 health 200을 붙잡는다"는 **단 한 줄의 정책**이 LB의 backend 제거를 in-flight 종료 후로 미룬다. 이 한 줄에서 모든 게 따라 나온다. 메커니즘을 인과로 펼치면: 1. **drain.sh가 Envoy drain을 먼저 켠다** (`drain_listeners?graceful&skip_exit`). 이 순간부터 **새 TCP conn은 거부**되지만 기존 in-flight는 그대로 산다. 즉 active 수는 단조 감소만 한다(새 유입 차단). 2. **drain.sh가 `rq_active`를 폴링**한다. 새 유입이 없으니 active는 0으로 수렴한다. 0을 볼 때까지 `/health_check.html`은 **200 유지** — LB는 아무 일 없다고 본다. 3. **active==0을 확인한 뒤에야** health를 503으로 flip한다. 이제 LB가 backend를 DOWN 마킹하고 `downStateFlush`로 세션을 flush해도 **끊을 in-flight가 0이다**. RST는 발사되지만 대상이 없다. 핵심 통찰은 **순서를 뒤집는 것**이다. 순진한 종료는 "health 죽이고 → LB가 뺀다 → 그제야 in-flight 정리"라 RST가 살아있는 요청을 친다. improved는 "in-flight 먼저 비우고(active=0) → 그다음 health 죽인다"로 순서를 반대로 깔아, LB가 backend를 빼는 시점엔 이미 끊을 게 없게 만든다. > **왜 `DRAIN_TIMEOUT`은 무결성이 아닌가**: active=0 폴링이 **무결성을 보장**한다(0을 봐야 health를 죽이므로). `DRAIN_TIMEOUT`은 "여기까지만 기다린다"는 **상한**일 뿐이다. 이 값을 넘는 long request는 강제 종료되므로, 진짜 안전선은 `DRAIN_TIMEOUT`이 아니라 그 값을 결정하는 **long request p99**다 — §4-4가 이 미지수를 다룬다. W1~W5 각 실험이 이 메커니즘의 어느 조각을 입증했는지: | 워크스루 | 핵심 발견 | 사내 적용 관련도 | |---|---|---| | [W1](gt__src-w1-big-picture.html) (6 events) | 6 이벤트 순서가 어긋나면 in-flight 끊김. improved는 순서 정렬로 무결 | 동일한 6 events 순서 설계 필요 | | [W2](gt__src-w2-hc-fsm.html) (FSM+drain.sh) | DRAINING에서 `/health_check.html=200` 유지가 핵심. DRAIN_TIMEOUT은 **무결성이 아닌 최대 대기 시간** 보장 | drain timeout이 사내 long request p99에 종속 | | [W3](gt__src-w3-igw-deployment.html) (manifest) | 홈랩 커스텀 Deployment vs 사내 Helm `additionalContainers`. `externalTrafficPolicy: Local` 없으면 health/traffic 포트가 다른 pod 가리킴 | Helm chart 구조에 따라 주입 방법 결정 | | [W4](gt__src-haproxy-walkthrough.html) (HAProxy) | `on-marked-down shutdown-sessions`는 Citrix `downStateFlush ENABLED`의 정확한 모사. `retries 3`이 5xx 흡수 → 5xx=0이어도 conn_err=9 | **disruption 지표는 5xx + conn_err 둘 다** | | [W5](gt__src-w5-test-scenarios.html) (테스트) | replicas=1이 단일 pod 격리 전제. S4 streaming chunk=12/60(current) vs 59/60(improved) | rolling update 검증은 replicas=N 시나리오로 | ### 2-1. LB 모델별 매핑 — "health=membership"이 어디까지 성립하나 위 앵커("health 응답 하나로 membership 결정")는 **Citrix/HAProxy류에서 정확히 성립**한다. 그러나 LB가 다른 모델이면 health 외 다른 변수가 끼어든다 — 그래서 매핑이 필요하다. | LB | downstream-flush 동등 옵션 | 동작 | 본 실험 모사 | 사내 주의 | |---|---|---|---|---| | **Citrix NetScaler** | `downStateFlush ENABLED` | backend DOWN 즉시 active session flush(RST) | HAProxy `on-marked-down shutdown-sessions` **정확히 일치** | 직접 적용. monitor의 `inter` 동등값(`inter 2s + fall 2`=4s) 확인 | | **F5 BIG-IP** | `Action On Service Down: reset`/`reject` | reset=TCP RST, reject=connection refuse | RST와 유사하나 HTTP profile keepalive 연동 다를 수 있음 | staging에서 S1 재검증 필수 | | **AWS NLB** | Connection draining(timeout 기반) | timeout 후 graceful close — 즉시 RST 아님 | **다름**: drain 동안 새 conn 차단 + 기존은 timeout까지 유지 | hc FSM 불필요할 수도. Deregistration delay(기본 300s)가 drain.sh 역할 | | **Envoy/Istio (내부 LB)** | health fail → endpoint pool 제외 | outlier detection + active HC 정책에 따라 | 부분 모사 — active HC 구조 동일, outlier ejection timeout이 변수 | `HealthCheck`+`OutlierDetection`으로 drain window 제어 | **왜 Citrix면 직접 적용인가**: Citrix `downStateFlush ENABLED`는 "DOWN 마킹 즉시 기존 세션 RST"라서 HAProxy `on-marked-down shutdown-sessions`와 인과가 동일하다 — 홈랩에서 검증한 in-flight 보호 윈도우가 그대로 옮겨진다. **F5는 keepalive 연동**이 추가 변수라 staging에서 S1 재검증이 필요하고, **AWS NLB는 아예 timeout 기반 모델**이라 hc FSM 없이 Deregistration delay만으로 같은 효과를 낼 수도 있다(간접 제어가 LB에 내장된 셈). ```mermaid flowchart TD A[LB model?] -->|Citrix NetScaler
downStateFlush ENABLED| B[Direct apply
HAProxy result matches] A -->|F5 BIG-IP
Action On Service Down| C[Re-verify S1 in staging
keepalive interaction differs] A -->|AWS NLB
connection draining| D[Different model
Deregistration delay = drain.sh role
hc FSM may be unneeded] A -->|Envoy/Istio internal LB| E[Partial
HealthCheck + OutlierDetection
controls drain window] ``` ## 3. 사내 적용 단계별 검토 — 매핑을 실제 manifest로 메커니즘이 옮겨진다는 걸 알았으면, 이제 "홈랩 데모를 사내 Helm IGW에 어떻게 박는가"가 남는다. 결정 4개를 순서대로. ### 3-1. hc 사이드카 주입 경로 — 왜 같은 pod여야 하나 ``` 방법 A: Helm values additionalContainers overlay + Helm 릴리즈 생명주기 관리 - upgrade 시 초기화 위험 -> values.yaml GitOps 필수, upgrade 후 pod 재기동 확인 방법 B: 별도 DaemonSet/Deployment + hostNetwork + IGW 배포와 독립 - pod network namespace 공유 안 됨 -> hc가 localhost:15000(Envoy admin) 접근 불가. 불가 방법 C: Kustomize post-render patch + Helm 구조 유지 + patch로 hc 삽입 - Helm v3 post-render 필요, patch 관리 포인트 추가 ``` **결론**: 방법 A가 가장 현실적. values.yaml을 ArgoCD/Flux GitOps로 관리하고, upgrade 후 `kubectl rollout status`로 신규 pod의 2-container를 확인한다. **B가 불가능한 이유가 이 절의 핵심**: hc는 같은 pod 내 Envoy admin에 `localhost:15000`으로 접근해 drain을 켜고 `rq_active`를 읽어야 한다. 별도 pod면 network namespace가 달라 `localhost`가 Envoy를 못 가리킨다 — §2의 메커니즘 자체가 성립하지 않는다. ### 3-2. health check 포트 결정 ``` NodePort (홈랩 채택): hc:18180 -> NodePort 30180 -> LB check 30180 + 단순, externalTrafficPolicy:Local로 정확한 pod 격리 - NodePort 범위(30000-32767), 노드 방화벽 정책 확인 hostPort: hc:18180 -> pod hostPort 18180 -> LB check 18180 + NodePort 범위 불사용 - 같은 노드에 여러 hc pod 불가(포트 충돌), anti-affinity required 강제 ClusterIP + kube-proxy: LB가 클러스터 내부 IP 직접 접근 가능한 경우만 ``` `externalTrafficPolicy: Local`이 중요한 이유: 없으면 kube-proxy가 health probe를 **다른 노드의 다른 pod로 SNAT**할 수 있어, LB가 "이 pod 살아있나"를 물었는데 엉뚱한 pod 답을 받는다(W3 함정). 사내 방화벽이 NodePort 범위 전체를 허용하지 않으면 특정 포트만 허용하는 규칙 추가를 협의해야 한다. ### 3-3. readinessProbe 분리 — 세 endpoint가 답하는 서로 다른 질문 Istio 기본 `/healthz/ready`(15021)는 **Envoy 자체 상태**만 반영해 "지금 drain 중이니 LB는 빼되 K8s endpoint는 아직 유지"라는 의도를 표현할 수단이 없다. 그래서 endpoint를 셋으로 쪼갠다 — 각각이 **다른 주체에게 다른 답**을 준다: ``` /health_check.html -> HAProxy/Citrix health (LB pool 제어). DRAINING에서도 200 유지 /health -> K8s readiness (EndpointSlice 제어). CLOSED 진입 후 503 /live -> K8s liveness. drain 중에도 200 (안 죽었으니까) ``` 이 분리가 있어야 §5의 순서 제어가 가능하다 — LB용 health와 K8s용 readiness를 **서로 다른 시점에** 죽일 수 있어야 "LB 먼저 빼고, in-flight 빠진 뒤 K8s endpoint 제거"가 된다. **current 함정**: hc readinessProbe를 `/health_check.html`로 (잘못) 설정하면 drain 시작 즉시 readiness 503 → K8s endpoint 제거 + HAProxy DOWN이 **동시에** 일어나 in-flight 보호 윈도우가 통째로 사라진다. ### 3-4. terminationGracePeriodSeconds 산정 — 유일한 미지수를 숫자로 `terminationGracePeriodSeconds`는 SIGTERM~SIGKILL 사이 시간이다. 이게 drain보다 짧으면 kubelet이 drain 도중 pod를 죽여 모든 보호가 무의미해진다. 그래서 **drain에 필요한 모든 시간의 합보다 커야** 한다: ``` terminationGracePeriodSeconds = max(DRAIN_TIMEOUT + LB_BUFFER, terminationDrainDuration) + 여유 홈랩값: DRAIN_TIMEOUT = 120s (active=0 폴링 최대 대기) LB_BUFFER = 10s (HAProxy inter 2s + fall 2 = 4s detect + 여유 6s) terminationDrainDuration = 150s (Envoy drain 완료 최대 대기) 여유 = 30s (kubelet->container SIGTERM 지연, 측정 오차) max(120+10, 150) + 30 = 150 + 30 = 180 (150이 dominant) 실험은 210 사용: terminationDrainDuration 150 + 여유 50 + LB_BUFFER 10 사내 산정 절차: 1. long request p99 측정 (Envoy access log histogram / APM) 2. DRAIN_TIMEOUT = p99 x 1.2 3. LB_BUFFER = 사내 LB health inter x fall_count (Citrix면 monitor interval) 4. terminationDrainDuration >= DRAIN_TIMEOUT (Envoy가 먼저 죽으면 drain 중단) 5. terminationGracePeriodSeconds = max(DRAIN_TIMEOUT + LB_BUFFER, terminationDrainDuration) + 30 ``` **산정의 본질**: 1단계의 `long request p99`만 측정되면 나머지는 전부 산수다. 즉 사내 적용을 막는 **유일한 미지수는 p99 하나**고, 그게 없으면 `DRAIN_TIMEOUT`(=2단계)부터 근거 없는 숫자라 모든 timeout이 추측이 된다. > **정본 위임**: 본 산정 공식의 정본은 이 §3-4다([drain listeners 문서](gt__src-envoy-drain-listeners.html)도 W6를 가리킨다). [runbook](gt__src-runbook.html)은 공식 재기술 없이 **적용 절차**만 담는다. ## 4. 미적용 운영 디테일 4종 — 홈랩이 우회한 것들 홈랩에서 임시로 우회했지만 프로덕션에선 제대로 풀어야 하는 운영 격차 4개. 메커니즘과 직접 상관없어 보이지만 **하나라도 빠지면 hc pod가 안 뜨거나 rollout이 deadlock**된다. ### 4-1. 워커 containerd config_path (사설 registry) 홈랩에서 `ctr import`로 우회한 이유: 워커 containerd에 사설 registry mirror가 없어 `imagePullPolicy: Always`가 동작 안 함. 사내는 보통 Harbor/Nexus를 운영하므로 `hosts.toml` 기반 등록이 올바른 방법이다. ``` /etc/containerd/config.toml (containerd 1.6+): [plugins."io.containerd.grpc.v1.cri".registry] config_path = "/etc/containerd/certs.d" /etc/containerd/certs.d//hosts.toml: server = "https://" [host."https://"] capabilities = ["pull", "resolve"] ca = "/etc/containerd/certs.d//ca.crt" ``` kubespray 환경은 `group_vars/all/containerd.yml`의 `containerd_registries_mirrors` + `containerd_registry_auth`로 일괄 적용. config.toml 인라인 `[plugins."io.containerd.grpc.v1.cri".registry.mirrors]` 방식은 containerd 1.x 후반부터 deprecated이며 `config_path`/`hosts.toml`로의 이행이 권장된다(정확한 제거 버전은 containerd 릴리스 노트 확인 필요). **적용 포인트**: 새 worker 추가 시 hosts.toml 자동 배포 여부 확인. 수동 관리면 누락 쉬움(홈랩 master1 hc 이미지 누락으로 NodePort 미응답이 같은 도미노). ### 4-2. rolling update — anti-affinity + maxUnavailable ``` worker1, worker2 (2대), replicas: 2, required anti-affinity, maxUnavailable: 0, maxSurge: 1 1. surge pod-C 시도 -> 빈 노드 없음 -> Pending 2. maxUnavailable=0이라 pod-A/B 못 지움 -> Deadlock ``` | 옵션 | 내용 | 트레이드오프 | |---|---|---| | `maxUnavailable=1` | 기존 pod 1개 먼저 종료 허용 | 순간 capacity -1. graceful-drain.sh가 in-flight 보호 → 실제 disruption 0 | | anti-affinity `preferred` | required → preferred | 같은 노드 2개 가능 → single-node SPOF | | 노드 +1 | surge pod 자리 확보 | 비용↑, 단기 해결 | **권장**: `maxUnavailable=1` + `anti-affinity required`. maxUnavailable=1이어도 graceful-drain.sh가 DRAIN_TIMEOUT 동안 in-flight를 보호하므로 실제 disruption은 0 — 이게 W2 "drain window 확보"의 실질 가치다. canary rollout(1%→10%→100%)이면 위험도가 더 낮다. ### 4-3. observability — 사내 적용 시 핵심 관찰 신호 3개 전체 메트릭 목록·Grafana 패널 정의는 정본인 [graceful termination runbook](gt__src-runbook.html)의 모니터링 메트릭 표를 따른다. 본 문서는 사내 적용 시 **반드시 봐야 할 3개 신호**만 정리한다. - **`envoy_http_downstream_rq_active` 수렴** — drain 진행률 그 자체. 시작 시점부터 0으로 수렴해야 정상. DRAIN_TIMEOUT을 넘도록 `> 0`이면 SRE 알림 → 강제 종료할지 timeout을 늘릴지 정책 결정. - **HAProxy backend UP/DOWN flip** — `haproxy_backend_status`. health 503 flip 후 LB가 backend를 실제로 뺐는지 확인(active=0 이후에 일어나야 in-flight 보호). - **5xx + conn_err 합산** — W4 교훈. `retries`가 5xx를 흡수해 5xx=0이어도 TCP RST로 conn_err은 올라간다. 둘을 합산해야 진짜 disruption이 보인다. ### 4-4. long request p99 — 측정해야 할 단 하나의 미지수 §3-4가 보여줬듯 모든 timeout 값이 이 분포 하나에 종속된다. 사내 적용 전 **반드시 먼저 측정**해야 하는 숫자다. - 측정원: Envoy access log의 `%DURATION%` histogram, 또는 APM(latency p99 by route). - 함정: 평균/p50이 아니라 **p99**여야 한다. graceful termination이 보호해야 하는 건 꼬리에 있는 느린 요청이고, p50으로 DRAIN_TIMEOUT을 잡으면 절반에 가까운 long request가 강제 종료된다. - 산출: `DRAIN_TIMEOUT = p99 × 1.2` → 나머지 §3-4 공식에 대입. ## 5. 적용 결과 — 6 events 순서가 만드는 차이 (worked example) 이제 추상을 숫자로 떨군다. improved 모드에서 실제로 어떤 순서로 사건이 일어나고, 그게 어떤 측정값을 만드는가. > 괄호 숫자 `[n]`은 [W1 big picture](gt__src-w1-big-picture.html)의 canonical 6 events 번호이며, 아래는 그것을 **improved 모드의 실제 실행 시간순**으로 재배열한 것이다(번호가 뒤섞인 이유). 핵심은 `[4] active_zero`가 `[1] health_fail`보다 **먼저** 일어나도록 순서를 뒤집어, LB가 backend를 빼는 시점엔 끊을 in-flight가 0이 되게 만드는 것. ``` [3] envoy_drain_start -> drain.sh가 Envoy :15000/drain_listeners?graceful&skip_exit -> 새 TCP conn 거부 시작 (기존 in-flight 유지) [4] active_zero -> drain.sh /stats?filter=rq_active 폴링 sum==0 -> 보장: 이 시점 Envoy 통한 in-flight 없음 [1] health_fail -> drain.sh hc POST /close-lb -> CLOSING -> /health_check.html 503 -> Citrix: inter x fall 후 backend DOWN -> shutdown-sessions -> active_zero이므로 끊을 연결 없음 [5] lb_down -> LB backend DOWN 마킹 [(6) rst 무력화] -> downStateFlush 발동되나 active=0이라 RST 대상 없음 [2] readiness_fail -> sleep LB_BUFFER 후 hc POST /close -> CLOSED -> /health 503 -> K8s EndpointSlice 제거 -> kubelet SIGTERM ``` **측정 결과** (단일 pod 격리, replicas=1): - **S1 (단순 요청)**: current 모드는 event 3·4 없이 1→5→6이 바로 발생해 in-flight RST → **502 / 8.25s**. improved는 위 순서로 → **200 / 60.01s**(요청이 끝까지 완료). 같은 시나리오, 순서 하나 차이. - **S3 (HAProxy retries)**: backend DOWN 시 `retries 3`이 같은 요청을 다른 backend로 재전송 → HTTP 레벨 **5xx=0**. 그러나 TCP RST는 이미 발생해 **conn_err=9** 기록. → 5xx만 보면 순단이 invisible(§4-3의 근거). - **S4 (streaming)**: 60s 동안 chunk를 받는 요청. current는 **12/60** chunk만 받고 끊김, improved는 **59/60**(거의 완주). 단 이건 60s 후 자연 종료되는 짧은 streaming이라 통한 것 — 무한 streaming은 §6-1. ```mermaid flowchart LR A["[4] active_zero
(precedes)"] --> B["[1] health_fail
503"] B --> C["[5] LB backend DOWN"] C --> D["[6] downStateFlush fires"] D --> E["RST targets = 0
(no in-flight to cut)"] A -.invalidates.-> E ``` ## 6. 본 실험의 한계 — 후속 검증 필요 §5의 성공은 모두 **요청이 유한한 시간에 끝난다**는 전제 위에 있다. 이 전제가 깨지는 4개 영역은 별도 설계가 필요하다. ### 6-1. WebSocket / gRPC streaming `downstream_rq_active`가 connection 유지 시간 내내 `> 0`으로 남아 DRAIN_TIMEOUT이 커버 못 하면 강제 종료된다. ``` WebSocket: 명시적 close 없으면 active=1 무한 지속 gRPC bidi streaming: stream 완료 전까지 active > 0 대응: A: streaming을 별도 IGW(long-lived pool)로 분리 B: streaming에 max duration (Envoy route max_stream_duration) C: DRAIN_TIMEOUT을 streaming p99보다 크게 -> downtime 길어짐 D: drain 중 streaming 강제 종료 허용 (클라이언트 retry로 흡수) ``` S4 improved가 chunk 59/60을 받은 건 streaming이 60s 후 자연 종료되는 짧은 케이스. 무한 streaming은 다른 이야기. ### 6-2. HTTP/2 multiplex LB-to-backend가 HTTP/2 multiplexed면, backend DOWN 시 하나의 TCP conn 위 여러 stream을 LB가 RST_STREAM으로 개별 취소한다. `shutdown-sessions`는 TCP 세션을 종료하므로 모든 active stream이 한꺼번에 CANCEL — 단일 요청보다 더 많은 disruption이 한 번에. ### 6-3. 다중 IGW Pod 분산 시 부하 spike replicas=N에서 한 pod drain 시 나머지 N-1로 트래픽이 몰린다. drain 기간(최대 120s) 동안 N-1이 평소 N배 부하를 처리 — CPU/memory 여유와 HAProxy connection 제한 확인 필요. ### 6-4. HTTP/3 (QUIC) QUIC는 TCP conn 개념 없이 connection ID로 동작. `shutdown-sessions`가 TCP RST를 보내도 QUIC client는 다른 path로 연결을 이어갈 수 있다. 현재 미사용이라도 도입 시 drain 설계 재검토 필요. ## 7. 회상 quiz
Q1. Citrix downStateFlush ENABLED 상황에서 improved가 in-flight를 보호하는 핵심 이유? **A**: drain.sh가 `downstream_rq_active + upstream_rq_active == 0`을 폴링하며 0 확인까지 `/health_check.html=200`을 유지한다. Citrix는 monitor 응답(200 vs non-200)으로만 DOWN/UP을 판단하므로 health 200이 유지되는 한 downStateFlush가 트리거되지 않는다. active=0 후 503으로 flip하면 Citrix가 DOWN 마킹+downStateFlush를 실행해도 끊을 in-flight가 없다.
Q2. terminationGracePeriodSeconds=210의 근거 공식과 사내 결정 시 유일한 미지수? **A**: 공식은 §3-4 정본 참조(`max(DRAIN_TIMEOUT+LB_BUFFER, terminationDrainDuration) + 여유`). 유일한 미지수는 **long request p99 분포** — 이 값으로 DRAIN_TIMEOUT을 결정해야 "모든 요청 보호 최소 drain window"가 나온다.
Q3. S3에서 5xx=0인데 conn_err=9가 기록된 이유와 사내 모니터링 함정 조건? **A**: HAProxy `retries 3`이 backend DOWN 시 동일 요청을 UP 상태 다른 backend로 재전송 → HTTP 레벨 200, 5xx=0. 하지만 TCP RST는 이미 발생해 conn_err은 올라간다. 사내에서 **LB 앞단 5xx rate만 모니터링**하면 retry가 흡수한 순단이 invisible. LB retry 정책 확인 + connection 레벨 오류(TCP RST count, `upstream_cx_connect_fail`)를 함께 수집해야 한다.
--- ## 핵심 정리 - **간접 제어**: hc + drain.sh는 LB 직접 명령 권한 없이 health 신호(200↔503)만으로 LB의 backend 제거 타이밍을 제어한다 — 서비스 팀 단독 적용 가능 패턴. - **무결성의 비밀은 순서**: `active_zero`를 `health_fail`보다 **먼저** 일으켜, LB가 backend를 빼는 시점에 끊을 in-flight를 0으로 만든다. S1에서 502/8.25s(current) vs 200/60.01s(improved)가 이 순서 하나의 차이. - **Citrix면 직접 적용**: `downStateFlush ENABLED`는 HAProxy `on-marked-down shutdown-sessions`와 인과가 정확히 일치 → 검증 레이어 하나 감소. F5/NLB는 거동이 달라 별도 검증. - **유일한 미지수 = long request p99**: 이 값으로 DRAIN_TIMEOUT을 정해야 모든 timeout이 근거를 갖는다. p99 없이는 terminationGracePeriodSeconds가 추측. - **5xx만 보면 순단이 안 보인다**: retries가 흡수해 5xx=0이어도 conn_err은 오른다 — 둘을 합산해야 진짜 disruption. - **커버 못 하는 영역**: WebSocket/gRPC/HTTP3 long-lived stream과 multi-replica 부하 spike는 후속 검증 항목. ## What you might be missing - **5xx만 보면 순단이 안 보인다.** HAProxy `retries`가 backend DOWN을 다른 backend로 흡수해 5xx=0을 만들지만, TCP RST는 이미 발생해 `conn_err`/`upstream_cx_connect_fail`로만 드러난다. LB 앞단 5xx rate만 모니터링하면 retry가 가린 disruption이 invisible. - **DRAIN_TIMEOUT은 무결성이 아니라 상한이다.** active=0 폴링이 무결성을 보장하고, DRAIN_TIMEOUT은 "여기까지만 기다린다"는 최대 대기일 뿐. 이 값을 넘는 long request는 강제 종료되므로, 진짜 안전선은 long request p99에 종속된다. - **세 health endpoint는 세 주체에게 답한다.** `/health_check.html`(LB pool), `/health`(K8s readiness), `/live`(K8s liveness)를 분리해야 LB와 K8s를 **다른 시점에** 죽일 수 있고, 그래야 §5의 순서 제어가 성립한다. 하나로 합치면 in-flight 보호 윈도우가 사라진다. - **streaming은 별개 세계다.** `downstream_rq_active`가 connection 수명 내내 `> 0`인 WebSocket/gRPC bidi는 DRAIN_TIMEOUT으로 커버 못 한다. S4가 chunk 59/60을 받은 건 60s 후 자연 종료되는 짧은 케이스일 뿐, 무한 stream은 별도 IGW 분리나 `max_stream_duration`이 필요. - **maxUnavailable=0 + required anti-affinity는 deadlock이다.** 노드 수 = replicas면 surge pod 자리가 없어 rolling update가 멈춘다. `maxUnavailable=1`이어도 drain.sh가 in-flight를 보호하므로 실제 disruption은 0이다. ## 이어 보기 - 적용 절차·체크리스트 정본: [graceful termination runbook](gt__src-runbook.html) - 이전: [W5 테스트 시나리오](gt__src-w5-test-scenarios.html) - 시리즈 hub: [W1 big picture](gt__src-w1-big-picture.html) · [graceful termination MOC](gt__MOC-graceful-termination.html) - HAProxy 상세: [HAProxy 워크스루](gt__src-haproxy-walkthrough.html) - Envoy drain 메커니즘: [drain listeners](gt__src-envoy-drain-listeners.html)