homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-07istiograceful-terminationenvoycitrix-netscalercontainerd

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, 6 events 정의 정본은 W1 big picture.

시리즈 위치: W6(종합). W1~W5 실험 결론을 사내 온프렘 환경으로 매핑하고 미적용 운영 디테일을 정면 돌파. FSM 명명: OPEN/DRAINING/CLOSING/CLOSED/FAULT. 신규 POST /reopen.


1. 배경 — 왜 이 문제가 존재하는가

풀어야 할 문제 한 줄: pod 하나가 종료될 때(배포·스케일다운·노드 드레인) 이미 들어와 처리 중인 요청을 끊지 않으면서 그 pod를 트래픽 경로에서 빼는 것.

이게 왜 어려운가. pod 종료는 두 주체가 동시에 비동기로 움직인다.

  • K8s 쪽: kubelet이 SIGTERM을 보내고 terminationGracePeriodSecondsSIGKILL. 동시에 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. 이들의 순서가 무결성을 결정한다.

  • Envoy drain: Envoy admin :15000/drain_listeners?graceful&skip_exit로 트리거하는 상태. graceful은 drain-time-s 유예 기간 동안 기존 in-flight는 그대로 유지하면서, 새 연결에는 즉시 거부가 아니라 Connection: close(HTTP/1)/GOAWAY(HTTP/2)로 재사용만 만류한다 — 리스너가 신규 연결을 완전히 막는 시점은 유예 시간이 끝난 뒤다. skip_exit는 그 유예 시간이 지나도 Envoy 프로세스를 계속 살려 두는 옵션이며, homelab 실측상 이를 빼더라도 반드시 즉시 재시작되는 건 아니다 — istio-proxy에 별도 livenessProbe가 없으면 15021 readiness만 응답을 멈추고 프로세스 자체는 한동안 살아있을 수 있다(운영 적용 시 livenessProbe 구성을 함께 확인할 것).

  • rq_active: Envoy /statsdownstream_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). 이 순간부터 기존 in-flight는 그대로 산다 — 다만 새 TCP conn이 그 즉시 거부되는 건 아니다. graceful drain은 drain-time-s 유예 시간 동안 새 연결도 계속 accept하며 Connection: close/GOAWAY로 재사용만 만류하다가, 유예 시간이 다 차야 리스너가 완전히 닫힌다. 그래도 새 유입이 점차 줄고 기존 요청이 빠지면서 active 수는 결국 수렴한다.
  2. drain.sh가 rq_active를 폴링한다. 새 유입이 없으니 active는 0으로 수렴한다. 0을 볼 때까지 /health_check.html200 유지 — 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 (6 events) 6 이벤트 순서가 어긋나면 in-flight 끊김. improved는 순서 정렬로 무결 동일한 6 events 순서 설계 필요
W2 (FSM+drain.sh) DRAINING에서 /health_check.html=200 유지가 핵심. DRAIN_TIMEOUT은 무결성이 아닌 최대 대기 시간 보장 drain timeout이 사내 long request p99에 종속
W3 (manifest) 홈랩 커스텀 Deployment vs 사내 Helm additionalContainers. externalTrafficPolicy: Local 없으면 health/traffic 포트가 다른 pod 가리킴 Helm chart 구조에 따라 주입 방법 결정
W4 (HAProxy) on-marked-down shutdown-sessions는 Citrix downStateFlush ENABLED의 정확한 모사. retries 3이 5xx 흡수 → 5xx=0이어도 conn_err=9 disruption 지표는 5xx + conn_err 둘 다
W5 (테스트) 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: Reject/Drop/Reselect(옵션명은 reset이 아님) Reject=양방향 TCP RST, Drop=RST 없이 조용히 종료, Reselect=다른 pool member로 재연결 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 제외 passive outlierDetection 정책에 따라 — Istio DestinationRule에는 능동(active) HealthCheck 필드가 없음 부분 모사 — Istio 네이티브는 passive outlierDetection만 제공, active HC는 EnvoyFilter 우회 필요 outlierDetection(consecutive5xxErrors/baseEjectionTime 등)으로 drain window 제어. HealthCheck 필드는 DestinationRule에 존재하지 않음

왜 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에 내장된 셈).

flowchart TD
    A[LB model?] -->|Citrix NetScaler<br/>downStateFlush ENABLED| B[Direct apply<br/>HAProxy result matches]
    A -->|F5 BIG-IP<br/>Action On Service Down| C[Re-verify S1 in staging<br/>keepalive interaction differs]
    A -->|AWS NLB<br/>connection draining| D[Different model<br/>Deregistration delay = drain.sh role<br/>hc FSM may be unneeded]
    A -->|Envoy/Istio internal LB| E[Partial<br/>outlierDetection only (passive)<br/>no native HealthCheck field]

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 문서도 W6를 가리킨다). runbook은 공식 재기술 없이 적용 절차만 담는다.

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/<registry-host>/hosts.toml:
  server = "https://<registry-host>"
  [host."https://<registry-host>"]
    capabilities = ["pull", "resolve"]
    ca = "/etc/containerd/certs.d/<registry-host>/ca.crt"

kubespray 환경은 group_vars/all/containerd.ymlcontainerd_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의 모니터링 메트릭 표를 따른다. 본 문서는 사내 적용 시 반드시 봐야 할 3개 신호만 정리한다.

  • envoy_http_downstream_rq_active 수렴 — drain 진행률 그 자체. 시작 시점부터 0으로 수렴해야 정상. DRAIN_TIMEOUT을 넘도록 > 0이면 SRE 알림 → 강제 종료할지 timeout을 늘릴지 정책 결정. 단, Istio 1.30 기본 프로파일은 이 stat을 기본으로 노출하지 않는다 — Envoy는 기본적으로 cluster_manager/listener_manager/server/cluster.xds-grpc 4개 카테고리만 기록하므로, downstream_rq_active를 관찰하려면 사전에 meshConfig.defaultConfig.proxyStatsMatcher.inclusionRegexps(또는 파드 단위 proxy.istio.io/config 어노테이션)에 해당 패턴을 명시적으로 추가해야 한다.
  • HAProxy backend UP/DOWN fliphaproxy_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의 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
                          -> graceful 유예(drain-time-s) 시작: 새 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.
flowchart LR
    A["[4] active_zero<br/>(precedes)"] --> B["[1] health_fail<br/>503"]
    B --> C["[5] LB backend DOWN"]
    C --> D["[6] downStateFlush fires"]
    D --> E["RST targets = 0<br/>(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_zerohealth_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이다.

이어 보기


검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법: 공식 문서 대조(Envoy/Istio/HAProxy/NetScaler/AWS/F5/containerd 자료) + homelab 클러스터 실측.

주장 판정 근거
C1. 간접 제어 핵심 메커니즘 — health 신호(200↔503) 하나로 LB backend membership 결정, active=0 확인 후 503 flip 🔬 실측 반증 — 본문 교정 netscaler.com/…/downstate-flush · T27 실측
C2. Envoy admin :15000/drain_listeners?graceful&skip_exit 엔드포인트/파라미터 실재 ✅ 문헌 확인 envoyproxy.io/…/operations/admin
C3. graceful drain 시작 시점에 새 TCP conn이 즉시 거부됨 ❌ 오류 — 본문 교정 envoyproxy.io/…/operations/draining · T27 실측
C4. rq_active = downstream_rq_active + upstream_rq_active (통과 중인 요청 수) ✅ 문헌 확인 envoyproxy.io/…/http_conn_man/stats
C5. drain.sh의 /stats?filter=rq_active 폴링이 기본 설정 그대로 관찰 가능하다는 전제 ❌ 오류 — 본문 교정 istio.io/…/envoy-stats · T31 실측
C6. Istio 기본 15021 readiness는 Envoy/pilot-agent 자체 상태만 반영, 앱 헬스와 무관 ✅ 실측 확인 github.com/istio/istio#58165 · T83 실측
C7. terminationDrainDuration 초과 시 아직 drain 중이어도 강제 종료 ✅ 문헌 확인 istio.io/…/istio.mesh.v1alpha1
C8. Istio HealthCheck+OutlierDetection으로 drain window 제어 ❌ 오류 — 본문 교정 istio.io/…/destination-rule
C9. HAProxy on-marked-down shutdown-sessions는 DOWN 판정 즉시 기존 세션 강제 종료 ✅ 문헌 확인 discourse.haproxy.org/…/6456
C10. Citrix NetScaler downStateFlush ENABLED(기본값)는 DOWN 즉시 세션 flush(RST) ✅ 문헌 확인 netscaler.com/…/downstate-flush
C11. AWS NLB Deregistration delay 기본값 300s, connection draining은 timeout 기반 ✅ 문헌 확인 docs.aws.amazon.com/…/target-groups
C12. containerd 인라인 registry.mirrors는 deprecated이며 config_path/hosts.toml로 이행 권장 ✅ 문헌 확인 github.com/containerd/…/hosts.md
C13. Envoy route max_stream_duration으로 streaming max duration 강제 가능 ✅ 문헌 확인 envoyproxy.io/…/route_components.proto
C14. F5 BIG-IP ‘Action On Service Down’ 옵션값 = reset/reject ❌ 오류 — 본문 교정 my.f5.com/…/K15095

Files