🏠 목록 W6. 프로덕션 적용 가이드 — 실험 결론 → 온프렘 매핑 📄 MD 원본 🌓 테마
istiograceful-terminationcitrix-netscalerproduction-rolloutobservabilityterminationgraceperiod

W6. 프로덕션 적용 가이드 — 실험 결론 → 온프렘 매핑

NOTE

머릿속 한 장: 서비스 팀은 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 종료는 두 주체가 동시에 비동기로 움직인다.

두 타임라인이 어긋나면 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로 새 conn은 거부하되 기존 in-flight는 유지하는 상태. - rq_active: Envoy /statsdownstream_rq_active + upstream_rq_active. "지금 이 pod를 통과 중인 요청 수". 이게 0이면 끊을 게 없다는 뜻.

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

LB model?Citrix NetScalerdownStateFlush ENABLED→ Direct applyF5 BIG-IPAction On Service Down→ staging S1 재검증AWS NLBconnection draining→ Dereg delay=drain 역할Envoy/Istio internal LBHealthCheck + OutlierDetection이 drain window 제어→ Partial
그림 1. 프로덕션 LB별 이식 전략: Citrix downStateFlush면 direct apply, F5/NLB은 모델 차이로 재검증, Istio 내부 LB는 HealthCheck/OutlierDetection으로 부분 적용.

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개 신호만 정리한다.

4-4. long request p99 — 측정해야 할 단 하나의 미지수

§3-4가 보여줬듯 모든 timeout 값이 이 분포 하나에 종속된다. 사내 적용 전 반드시 먼저 측정해야 하는 숫자다.

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
                          -> 새 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.

[4] active_zeroprecedes[1] health_fail503[5] backend DOWN[6] downStateFlushfiresRST targets = 0no in-flightinvalidates (active=0이면 끊을 게 없음)
그림 2. [4] active_zero가 [1]health_fail·[5]DOWN·[6]downStateFlush보다 선행하면 RST 대상이 0 → downStateFlush가 fire해도 무해. 무중단 종료의 핵심 순서 보장.

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`)를 함께 수집해야 한다.

핵심 정리

What you might be missing

이어 보기