Istio Graceful Termination 사내 도입 런북 (홈랩 실험 → 사내 적용 가이드)
홈랩 graceful-termination 실험 결과를 프로덕션 IGW 환경에 이식하는 6단계 런북이다. 머릿속에 담을 한 장면: LB는 backend의 살아있음 여부를 오직 health check 응답으로만 판정한다 — 그래서 LB를 직접 명령할 권한이 없어도, hc 사이드카가 preStop 동안 /health_check.html의 200/503 타이밍을 단계적으로 바꾸면 LB의 "이 backend를 DOWN 마킹할지"를 간접 조종할 수 있다. 이 한 줄이 6단계 전부를 푼다. 본 문서는 각 단계가 무엇을 검증·제어하는지 + 어디서 깨지는지에 집중하고, FSM 상세는 HC FSM 정본, grace period 산정은 프로덕션 적용을 참조한다.
대상환경: 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 정본이 정본이다.
왜 두 종류의 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하는지 본다.
# long request 중에 backend를 LB에서 수동 disable
curl -o /dev/null -w "http=%{http_code} t=%{time_total}\n" "https://<staging>/sleep?seconds=60" &
# (Citrix GUI: LB Vserver → Service → Disable)
sudo tcpdump -n -i <iface> '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 응답을 우리가 제어할 수 있다.
gateways:
istio-ingressgateway:
additionalContainers:
- name: hc
image: <사내-registry>/service-a-hc:<tag>
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 상태"를 정확히 반영해야 한다.
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를 정한다. 산정식은 프로덕션 적용 정본 §3-4와 통일한다.
DRAIN_TIMEOUT = ceil(p99 × 1.2)
LB_BUFFER = <monitor interval> × <fall count> + 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 모니터링 메트릭 표를 정본으로 두고, 여기서는 배선(어디서 무엇을 긁어 어떤 alert로 묶나) 만 다룬다.
drain timeout 초과를 잡는 alert:
- 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
<DRAIN_TIMEOUT>은 단계 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가 동시에 빠져 무용지물이 된다.
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의 어느 단계가 깨졌는지로 매핑된다.
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).kubectl logs <igw-pod> -c hc | grep "event=transition"→ DRAINING→CLOSING이 너무 빠르면 current 모드(drain.sh 미사용, 단계 2).- Envoy
/stats?filter=downstream_rq_active|upstream_rq_active→ drain 중 active>0이면 아직 처리 중(DRAINING 정상 동작). tcpdump 'tcp[tcpflags] & tcp-rst != 0' -tttt→ RST 시점 ↔ HAProxy DOWN 마킹 시점 일치 여부(503 선행 여부, §2 앵커).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 ENABLEDLB가 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를 키우는 건 답이 아니다.