Envoy Graceful Drain
Pod 종료 시 502를 없애는 Envoy 데이터플레인 진입점은 admin API POST /drain_listeners?graceful&skip_exit 하나다. 핵심 오해를 먼저 깬다: 이 API는 in-flight 요청을 "보호"하지 않는다. 새 연결 유입만 discourage해서 downstream_rq_active 곡선을 단조감소시키는 유입 차단기일 뿐이다. 실제 in-flight 보호는 상위 hc FSM이 health 신호를 200으로 유지해 LB가 backend를 빼지 않게 막는 데서 온다. 이 문서는 그 차단기 자체 — graceful 모드의 신규 연결 신호 방식(H1 Connection: close / H2 GOAWAY), skip_exit, 완료 판정이 왜 cx가 아니라 rq인지 — 에 집중한다.
대상환경 Istio 1.30 / Envoy sidecar · 대상독자 graceful termination을 메커니즘 수준에서 이해하려는 DevOps/SRE · 범위 Envoy admin drain_listeners API 한 계층 · 선행개념 sidecar(15000 admin), HTTP keepalive, HTTP/2 GOAWAY · 상위 계층 drain.sh 오케스트레이션 · hc FSM
1. 배경: "누가 in-flight를 지키는가"라는 질문
Pod가 죽을 때 가장 단순한 종료는 listener를 즉시 close하는 것이다. 그러면 그 순간 처리 중이던 요청이 TCP RST로 끊겨 클라이언트가 502를 본다. graceful termination의 목표는 "신규 연결은 받지 않으면서 진행 중인 요청은 끝까지 처리"하는 것이고, 이를 달성하려면 두 개의 독립된 일을 해야 한다.
- (a) 새 요청이 더 안 들어오게 막는다 — 그래야 처리 중 요청 수가 시간이 지나며 줄어든다.
- (b) 줄어드는 동안 기존 요청이 안 끊기게 지킨다 — 그래야 마지막 한 건까지 완주한다.
여기서 핵심 함정: drain_listeners?graceful은 (a)만 한다. Envoy listener에 "이제 신규는 discourage하라"고 지시할 뿐이다. (b)는 Envoy admin API의 일이 아니라 상위 hc FSM의 일이다 — drain.sh가 /health_check.html을 200으로 유지해서 외부 LB가 backend를 endpoint pool에서 빼지 않게 붙잡아둔다. LB가 backend를 UP으로 보는 한 기존 연결은 살아 있고, 그래서 in-flight가 보호된다.
이 분리를 못 잡으면 "drain_listeners를 부르면 알아서 무중단이 된다"고 오해하게 된다. 실제로는 (a) 없이 (b)만 하면 폴링이 영원히 안 끝나고, (b) 없이 (a)만 하면 LB가 backend를 빼버려 in-flight가 RST된다. 둘 다 있어야 한다. 이 문서는 (a)를 책임지는 한 계층을 정확히 본다.
2. 멘탈모델 앵커 + admin API
머릿속 그림 하나: drain_listeners?graceful은 "입구 수도꼭지를 잠그는" 동작이다. 물(in-flight rq)이 다 빠질 때까지 기다리는 건 폴링하는 쪽의 몫이고, 빠지는 동안 통(LB endpoint)을 안 비우는 건 hc FSM의 몫이다. 그래서 완료 판정도 통에 든 물의 양 = request(rq)로 하지, 수도관이 연결돼 있는지(connection cx)로 하지 않는다.
API는 Envoy admin endpoint다. Istio 기본 바인딩은 127.0.0.1:15000으로 Pod 내 sidecar에서만 접근 가능하다(localhost-only — 외부 노출 금지가 보안 모범 사례).
# graceful drain 시작 → 응답 본문은 "OK"
curl -X POST "http://127.0.0.1:15000/drain_listeners?graceful&skip_exit"
# OK
# active request 계측 → gauge 라인 형태로 출력
curl -s "http://127.0.0.1:15000/stats?filter=downstream_rq_active|upstream_rq_active"
# http.inbound_0.0.0.0_8080.downstream_rq_active: 1
# cluster.outbound|80||backend.svc.cluster.local.upstream_rq_active: 0
호출 한 번이 곧 "유입 차단" 신호고, 그 뒤로는 위 stats gauge가 0이 되는지를 호출자가 폴링한다. API 자체는 "기다려준다"가 없다 — fire-and-forget으로 listener 상태만 바꾼다.
3. graceful 모드: 신규 연결을 "어떻게" 막는가
graceful 파라미터 없이 호출하면 listener를 즉시 close한다(= 위험한 그 동작, 502 유발). graceful을 붙이면 drain period 동안 새 연결을 discourage하는 형태로 바뀐다. 여기서 비자명한 디테일은 "discourage"가 프로토콜마다 다른 신호로 구현된다는 점이다 — TCP 레벨에서 강제로 끊는 게 아니라, 애플리케이션 프로토콜 신호로 클라이언트가 스스로 새 연결로 옮기게 유도하기 때문에 진행 중 요청이 안 깨진다.
| 프로토콜 | 신규 유입 discourage 신호 | 메커니즘 |
|---|---|---|
| HTTP/1.1 | 응답에 Connection: close 헤더 |
keepalive 재사용을 막아 다음 요청은 새 연결로 가게 함 |
| HTTP/2 (gRPC 포함) | GOAWAY 프레임 | Connection 헤더가 없는 프로토콜이라, GOAWAY로 graceful close를 신호 → 클라이언트가 새 stream을 다른 connection으로 |
- 기존 connection: 계속 처리 — 신호는 "다음부터 새 데로 가라"이지 "지금 걸 끊어라"가 아니다. 그래서 in-flight
rq가 보호된다. - drain period 종료 후: listener를 실제로 close한다. 즉 graceful은 무한 유예가 아니라 유예 창(window) 이다.
drain 기간은 누가 정하나 — 등치하면 안 되는 두 값
drain period의 길이는 Envoy 자체 파라미터가 결정한다: --drain-time-s와 drain strategy(gradual/immediate)가 점진 종료 시간을 정한다. 반면 Istio terminationDrainDuration(default 5s, 운영에선 상향)은 istio-agent가 SIGTERM 후 sidecar drain 완료를 기다리는 상한이다 — 즉 "Envoy가 drain을 끝낼 때까지 agent가 프로세스를 안 죽이고 버티는 최대 시간"이지, drain_listeners?graceful이 실제로 discourage하는 기간과 1:1로 같지 않다. 둘을 등치하면 타임아웃 계산이 어긋난다.
아래는 Envoy admin 계층 관점의 drain 시퀀스다. hc FSM 전이는 hc FSM 정본이 다루므로, 여기서는 admin-API 흐름만 표현한다.
4. skip_exit: 왜 프로세스를 살려두는가
skip_exit를 붙이면 drain이 끝나도 Envoy 프로세스가 exit하지 않는다. 이게 필요한 이유는 종료의 주도권을 K8s에 넘기기 위해서다 — preStop hook에서 Envoy를 직접 죽이지 않고 kubelet의 자연스러운 SIGTERM → grace period → SIGKILL 흐름을 그대로 유지하려는 것이다.
skip_exit 없이 호출하면 drain 완료 직후 Envoy가 스스로 종료해버린다. 그러면 §3 폴링이 아직 active=0을 확인하기 전에 데이터플레인이 사라지거나, kubelet의 SIGTERM 타이밍과 충돌해 시퀀스가 어긋날 수 있다. skip_exit는 "drain은 하되 죽는 건 K8s가 정한다"를 보장하는 스위치다.
5. 완료 판정: cx가 아니라 rq인 이유
폴링이 보는 게 무엇이냐가 drain 정확성을 좌우한다.
| metric | 의미 |
|---|---|
http.<config_name>.downstream_rq_active |
Envoy로 들어온 HTTP request 중 응답 미완료 개수 |
cluster.<cluster_name>.upstream_rq_active |
Envoy → upstream(backend)으로 보낸 request 중 응답 미완료 개수 |
cx= connection(TCP). HTTP keepalive가 active면 실제 in-flight 요청이 0이어도cx_active > 0이다.rq= request. 실제 처리 중인 HTTP 요청 수.
왜 rq인가: graceful drain은 §3에서 봤듯 클라이언트에게 "다음부터 새 연결로 가라"고 신호할 뿐, 기존 keepalive connection을 즉시 끊지 않는다. 그래서 요청이 다 끝나도 connection은 한동안 살아 있을 수 있다. cx 기반으로 완료를 판정하면 keepalive connection이 살아 있는 한 영원히 0에 안 닿아 무한 대기에 빠진다. 실제 종료해도 되는 시점 = "처리 중 요청이 없다" = rq_active == 0이므로, drain.sh도 downstream_rq_active + upstream_rq_active == 0을 폴링한다.
streaming/gRPC 함정
WebSocket·gRPC streaming은 long-lived 요청이라 downstream_rq_active가 떨어지지 않는다. 하나의 stream이 분 단위로 살아 있으면 rq_active는 계속 1 이상이고, 폴링은 영영 안 끝난다. 그래서 rq 판정에도 상한(drain timeout) 이 필요하다 — drain.sh의 DRAIN_TIMEOUT이 그 역할로, 초과 시 남은 in-flight를 끊는 걸 감수하고 진행한다(산정: runbook).
6. drain.sh 오케스트레이션 (정본 분리)
drain.sh 7단계 preStop 오케스트레이션의 라인별 분석(환경변수·do_curl 래퍼·awk 합산·LB_BUFFER 순서)은 중복이므로 여기서 다시 쓰지 않는다 — 정본은 drain.sh walkthrough §3. 본 문서는 그 스크립트가 호출하는 Envoy admin API 자체(§2~5)에 집중한다. 한 줄 매핑만 남기면: drain.sh step [2] POST /drain_listeners?graceful&skip_exit가 곧 §2~4이고, step [3] *_rq_active==0 폴링이 곧 §5다.
7. 워크드 예시: in-flight가 끊기지 않는지 직접 본다 (S2 improved)
주의: 이 검증은
drain_listenersAPI 자체가 아니라 그 위 hc FSM(drain.sh) 거동(CLOSING 진입 여부,/health_check.html=200유지, HAProxy backend UP)이다. 여기서 in-flight가 보호되는 근본 이유는 §1·§3 그대로 — graceful 모드가 기존rq를 유지하고, FSM은rq_active>0이라 CLOSING으로 전이하지 않아 health를 200으로 유지하기 때문이다. FSM 전이 상세는 hc FSM.
시나리오와 관측 결과:
- 60초가 걸리는 GET
/sleep(60)요청을 보내는 도중 Pod delete를 친다. - Envoy
downstream_rq_active=1이 60초 내내 유지된다(§5: long request라rq가 안 떨어짐). - graceful-drain.sh: active=0을 감지하지 못함 → CLOSING 진입 안 함 →
/health_check.html=200유지(= LB가 backend를 안 뺌). - HAProxy: worker1 backend가 60초 내내 UP 유지.
- 결과:
curl 200/time_total 60.01s— in-flight 요청이 끊기지 않고 정상 완료.
이 한 실험이 §1의 분리를 그대로 보여준다: graceful 모드가 기존 rq를 살려두고(차단기는 신규만 막음), hc가 health 200을 유지해 LB가 backend를 안 빼서(보호는 상위 계층), 그래서 60초짜리 한 건이 502 없이 완주한다.
8. 추천 설정 (정본 분리)
terminationDrainDuration/terminationGracePeriodSeconds 권장값과 산출 공식(max_request_duration + LB_detection_time + LB_BUFFER + drainDuration + 여유)은 중복이므로 생략 — 정본: 프로덕션 적용, runbook. 핵심 제약 하나만 남긴다: terminationGracePeriodSeconds가 전체 drain 시퀀스 합계보다 짧으면 kubelet이 SIGKILL을 먼저 보내 in-flight 요청을 끊는다. 그러면 §7의 보호가 무너지므로, grace period는 drain 합계보다 반드시 길게 잡는다.
핵심 정리
- 차단기 ≠ 보호기.
POST /drain_listeners?graceful은 신규 유입만 discourage(H1=Connection: close, H2=GOAWAY)해rq_active곡선을 단조감소시킨다. in-flight 보호는 hc FSM이 health 200을 유지해 LB가 backend를 안 빼는 상위 계층의 일이다. - 완료 판정은
downstream_rq_active(request).cx(connection)는 keepalive 때문에 안 떨어져 무한 대기 위험. skip_exit은 종료 주도권을 K8s에 넘긴다. drain 후에도 Envoy를 살려둬 폴링이 끝날 때까지 데이터플레인 유지 + kubelet SIGTERM 흐름 보존.- drain 기간은 Envoy
--drain-time-s/strategy가 결정. IstioterminationDrainDuration(default 5s)은 agent가 drain 완료를 기다리는 상한일 뿐 1:1 등치 아님. - gRPC/WebSocket long-lived는
rq가 안 떨어지므로 별도 drain timeout 필요. terminationGracePeriodSeconds는 drain 시퀀스 합계보다 길게 — 아니면 SIGKILL이 in-flight를 끊는다.
What you might be missing
- graceful ≠ 즉시 무중단: graceful은 신규 요청을 discourage할 뿐, 클라이언트가
Connection: close/GOAWAY를 무시하고 같은 connection으로 계속 요청하면 drain period가 끝날 때 강제 close된다. 무한 유예가 아니라 유예 창이다. - 계층 혼동 주의:
drain_listenersAPI(이 문서)와 그 위 hc FSM(drain.sh, hc FSM)은 다른 추상화다. 이 API는 Envoy 내부 동작(신규 유입 차단), FSM은 health 신호/LB 디투입을 오케스트레이션하는 상위 정책(in-flight 보호)이다. - LB 디투입과의 경합: drain을 시작해도 외부 LB가 아직 backend를 UP으로 보면 신규 요청이 계속 들어와 폴링이 안 끝난다. 그래서 LB detection time(+rise/fall 지연)을 drain 합계에 반드시 포함해야 한다(→ runbook 산출 공식).
See also
- graceful termination MOC — 진입점
- W1 big picture — 전체 그림
- W2 hc FSM — health-check FSM / drain.sh 호출부
- drain.sh walkthrough — drain.sh 라인별 정본