---
type: src
tags: [istio, envoy, graceful-termination, drain-listeners]
created: 2026-06-07
---
# Envoy Graceful Drain
> [!abstract]
> 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 오케스트레이션](gt__src-apps-walkthrough.html) · [hc FSM](gt__src-w2-hc-fsm.html)
## 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 — 외부 노출 금지가 보안 모범 사례).
```bash
# 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](gt__src-w2-hc-fsm.html) 정본이 다루므로, 여기서는 admin-API 흐름만 표현한다.
```mermaid
sequenceDiagram
participant Op as preStop/Op
participant Adm as Envoy admin :15000
participant Lis as Listener
participant Cl as Client
Op->>Adm: POST /drain_listeners?graceful&skip_exit
Adm-->>Op: OK
Adm->>Lis: enter graceful drain (faucet OFF)
Note over Lis,Cl: 신규 유입 discourage
H1: Connection: close / H2: GOAWAY
Cl->>Lis: 기존 in-flight rq 계속 처리
loop poll until drained
Op->>Adm: GET /stats downstream_rq_active
Adm-->>Op: rq_active = N (단조감소)
end
Note over Op: rq_active == 0 도달
Lis->>Lis: listener close (skip_exit → 프로세스는 유지)
```
## 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..downstream_rq_active` | Envoy로 들어온 HTTP request 중 응답 미완료 개수 |
| `cluster..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](gt__src-runbook.html)).
## 6. drain.sh 오케스트레이션 (정본 분리)
drain.sh 7단계 preStop 오케스트레이션의 라인별 분석(환경변수·`do_curl` 래퍼·awk 합산·LB_BUFFER 순서)은 중복이므로 여기서 다시 쓰지 않는다 — 정본은 [drain.sh walkthrough](gt__src-apps-walkthrough.html) §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_listeners` API 자체가 아니라 **그 위 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](gt__src-w2-hc-fsm.html).
시나리오와 관측 결과:
- 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 + 여유`)은 중복이므로 생략 — 정본: [프로덕션 적용](gt__src-w6-production-apply.html), [runbook](gt__src-runbook.html). 핵심 제약 하나만 남긴다: **`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가 결정.** Istio `terminationDrainDuration`(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_listeners` API(이 문서)와 그 위 hc FSM(drain.sh, [hc FSM](gt__src-w2-hc-fsm.html))은 다른 추상화다. 이 API는 Envoy 내부 동작(신규 유입 차단), FSM은 health 신호/LB 디투입을 오케스트레이션하는 상위 정책(in-flight 보호)이다.
- **LB 디투입과의 경합**: drain을 시작해도 외부 LB가 아직 backend를 UP으로 보면 신규 요청이 계속 들어와 폴링이 안 끝난다. 그래서 LB detection time(+rise/fall 지연)을 drain 합계에 반드시 포함해야 한다(→ [runbook](gt__src-runbook.html) 산출 공식).
## See also
- [graceful termination MOC](gt__MOC-graceful-termination.html) — 진입점
- [W1 big picture](gt__src-w1-big-picture.html) — 전체 그림
- [W2 hc FSM](gt__src-w2-hc-fsm.html) — health-check FSM / drain.sh 호출부
- [drain.sh walkthrough](gt__src-apps-walkthrough.html) — drain.sh 라인별 정본