W6. 프로덕션 적용 가이드 — 실험 결론 → 온프렘 매핑
머릿속 한 장: 서비스 팀은 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을 보내고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. 이들의 순서가 무결성을 결정한다.
- 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 종료 후로 미룬다.
이 한 줄에서 모든 게 따라 나온다. 메커니즘을 인과로 펼치면:
- drain.sh가 Envoy drain을 먼저 켠다 (
drain_listeners?graceful&skip_exit). 이 순간부터 새 TCP conn은 거부되지만 기존 in-flight는 그대로 산다. 즉 active 수는 단조 감소만 한다(새 유입 차단). - drain.sh가
rq_active를 폴링한다. 새 유입이 없으니 active는 0으로 수렴한다. 0을 볼 때까지/health_check.html은 200 유지 — LB는 아무 일 없다고 본다. - 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에 내장된 셈).
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.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의 모니터링 메트릭 표를 따른다. 본 문서는 사내 적용 시 반드시 봐야 할 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의 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.
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는 HAProxyon-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
- 이전: W5 테스트 시나리오
- 시리즈 hub: W1 big picture · graceful termination MOC
- HAProxy 상세: HAProxy 워크스루
- Envoy drain 메커니즘: drain listeners