---
title: W2. Backend + hc + drain.sh — Go 코드 메커니즘
date: 2026-06-07
type: guide
domain: istio
tags: [graceful-termination, health-check, haproxy, envoy, go]
---
> [!abstract]
> **머릿속에 담을 한 장**: health endpoint의 응답 코드는 LB의 backend pool membership을 제어하는 *원격 스위치*다. hc 사이드카는 **5-state FSM**(OPEN/DRAINING/CLOSING/CLOSED/FAULT)으로 이 스위치를 쥐고, drain.sh는 "active=0 확인 전까지 `/health_check.html` 200 유지"로 스위치를 끄는 *시점*을 모든 요청 완료 이후로 밀어내 HAProxy가 in-flight 연결을 끊지 않게 한다. 본 문서는 이 동작을 **멘탈 모델/왜** 층에서 다루며, 라인별 정본 추적은 [코드 워크스루(apps-walkthrough)](/docs/istio/graceful-termination/apps-walkthrough/)에 위임한다.
> **시리즈 위치**: W2. [W1 big picture](/docs/istio/graceful-termination/w1-big-picture/)의 6 events·4 시나리오를 코드 수준으로 추적. 대응 라인별 코드 워크스루는 [apps-walkthrough](/docs/istio/graceful-termination/apps-walkthrough/).
> **대상독자**: graceful termination을 LB-level까지 추적하려는 SRE. **선행개념**: HAProxy health check(`fall`/`rise`/`shutdown-sessions`), Envoy `drain_listeners`, Go interface embedding.
---
## 1. 배경 — 왜 health endpoint를 코드로 쥐어야 하나
문제는 한 문장이다: **바닐라 K8s에서는 LB가 backend를 pool에서 빼는 *타이밍*을 직접 제어할 수 없다.** Pod이 종료될 때 endpoint 제거와 새 트래픽 차단은 비동기로 일어나고, 그 사이에 LB가 in-flight 연결을 끊으면 클라이언트는 RST(5xx)를 본다. graceful termination의 본질은 "endpoint를 빼는 순간"을 "마지막 요청이 끝난 순간" 뒤로 미루는 것이다.
이 타이밍을 손에 넣는 도구가 **health endpoint**다. HAProxy는 traffic을 보낼지 말지를 오직 health check 응답 코드로 판단한다 — 200이면 UP(트래픽 받음), 503이면 `fall N`회 연속 후 DOWN. 그래서 health 응답 코드는 "이 backend로 트래픽을 보내라/말라"를 외부에서 켜고 끄는 **원격 스위치**다. 종료 시퀀스가 이 스위치를 *언제* 503으로 내리느냐가 전부를 결정한다.
여기서 결정적 전제: **health 포트와 traffic 포트가 분리되어 있어야** 스위치를 독립적으로 쥘 수 있다.
```
HAProxy
traffic | | health
30080 | | 30180
v v
+------------------------+
| IGW Pod |
| backend :8080 (traffic, 항상 200)
| hc :18180 (health, FSM이 코드 결정)
+------------------------+
```
traffic(30080→IGW→backend:8080)과 health(30180→hc:18180)가 다른 포트이기 때문에, hc는 **요청을 계속 받으면서도(traffic 정상)** health만 503으로 떨어뜨려 "나를 빼라"고 신호할 수 있다. 이 분리가 없으면 health를 내리는 순간 traffic도 같이 죽어 graceful이 불가능하다. 핵심 인과 사슬은 한 줄로 압축된다:
> **health 503 → HAProxy가 `fall 2`(~4s) 후 DOWN 마킹 → `on-marked-down shutdown-sessions`로 in-flight RST.**
drain.sh의 전 전략은 이 사슬의 마지막 RST가 터지는 *시점*을 active=0(모든 요청 완료) 이후로 미루는 것이다. §2~§4가 그 시점을 만들어내는 상태 기계·응답 표·시퀀스이고, §5가 "왜 그게 핵심인가"의 결론이다.
W1의 전체 경로(`Mac → HAProxy → worker NodePort 30080 → IGW Pod → backend Service`)에서 **hc**는 IGW Pod 안의 사이드카 컨테이너로 포트 18180 청취, **backend**는 ClusterIP 8080으로 도달되는 별도 Deployment Pod이다.
---
## 2. 핵심 아키텍처 — 스위치를 쥔 5-state FSM
**앵커**: hc는 단 하나의 `state` 변수와 그 변수를 바꾸는 단 하나의 함수 `advance(expectedFrom, to, reason)`로 이루어진 기계다. 외부에서 들어오는 모든 제어(`POST /drain`, `/close-lb`, `/close`, `/reopen`, `/fault`)는 이 함수를 통해서만 상태를 옮기고, 그 상태가 health 응답 코드를 결정한다. 즉 "지금 어떤 코드를 돌려줄까"는 분기문이 아니라 **현재 state 하나**가 답한다.
```mermaid
stateDiagram-v2
[*] --> OPEN : process start
OPEN --> DRAINING : POST /drain (preStop)
DRAINING --> CLOSING : POST /close-lb (active=0)
CLOSING --> CLOSED : POST /close (after LB_BUFFER)
DRAINING --> OPEN : POST /reopen (abort)
CLOSING --> OPEN : POST /reopen (LB rise2, +4s)
OPEN --> FAULT : POST /fault
DRAINING --> FAULT : POST /fault
CLOSING --> FAULT : POST /fault
CLOSED --> FAULT : POST /fault
CLOSED --> [*] : preStop done, SIGTERM
FAULT --> [*] : fail(), forced exit
```
**왜 FSM인가 — 불변식을 한 곳에 가둔다.** 각 전이는 `advance(expectedFrom, to, reason)`로 실행되고 `expectedFrom != current`면 HTTP 409 Conflict를 던진다. 이 한 줄이 illegal transition을 코드 레벨에서 봉쇄한다. 가장 중요한 결과가 **OPEN → CLOSING 직접 점프 불가** — drain.sh가 반드시 `[1] /drain → [4] /close-lb`의 2-step을 밟아야 하는 이유다. 상태 불변식을 호출부(여러 핸들러)에 흩지 않고 전이 함수 한 곳에서 강제하므로, 새 핸들러를 추가해도 불법 전이가 새지 않는다. `sync.RWMutex`로 보호해 동시 요청에서도 state가 찢어지지 않는다.
**왜 reopen이 필요한가 — drain은 취소 가능해야 한다.** 운영 중 preStop이 발동했는데 종료를 철회해야 할 때가 있다. `POST /reopen`은 `advance(expectedFrom=DRAINING|CLOSING, to=OPEN)`로 drain을 abort하는 **유일한 역방향 경로**다. DRAINING에서는 즉시 OPEN. CLOSING에서는 `/health_check.html`이 다시 200을 반환하지만 HAProxy가 곧바로 복귀하지 않고 `rise 2`(~4s)를 채워야 UP으로 되돌아온다. **CLOSED는 K8s endpoint가 이미 제거된 상태라 reopen 불가(409)** — 종착은 정상 SIGTERM뿐이다.
**왜 종착이 둘인가 — FAULT vs CLOSED.** `FAULT`는 `fail()`에 의한 **비정상 강제 종료**(any 상태 → FAULT 가능)이고, `CLOSED → [*]`는 정상 drain 완료 후 preStop이 끝나며 kubelet이 보내는 **정상 SIGTERM**이다. 다이어그램의 `[*]`가 둘로 갈라지는 것은 "성공적 graceful 종료"와 "강제 abort 종료"가 의미상 다른 사건이기 때문이다.
### 2.1 상태가 응답 코드로 번역되는 표
state 하나가 세 endpoint의 HTTP 코드를 어떻게 결정하는지가 FSM의 출력 면이다.
| State | `/health_check.html` (HAProxy) | `/health` (K8s readiness) | `/live` (K8s liveness) |
|---|---|---|---|
| **OPEN** | 200 | 200 | 200 |
| **DRAINING** | 200 | 200 | 200 |
| **CLOSING** | 503 DRAIN | 200 | 200 |
| **CLOSED** | 503 DRAIN | 503 (JSON) | 200 |
| **FAULT** | 503 DRAIN | 503 (JSON) | 500 (JSON) |
표를 읽는 핵심은 **세 endpoint가 서로 다른 청중을 위해 서로 다른 타이밍에 떨어진다**는 점이다.
- `/health_check.html`(HAProxy 청중): **DRAINING에서도 200 유지**가 전부의 출발점. drain.sh가 active=0을 확인하기 전까지 HAProxy는 backend를 UP으로 보고 shutdown-sessions를 트리거하지 않는다. CLOSING에서 비로소 503으로 flip.
- `/health`(K8s readiness 청중): **CLOSING까지 200 유지**. endpoint가 살아 있어야 LB buffer 대기 중에도 새 연결을 받을 수 있다. `CLOSING → CLOSED` 전이(`POST /close`, `LB_BUFFER=10s` 대기 후)에서 200 → 503으로 flip하며 K8s endpoint가 제거된다. 표의 CLOSING 행(200)과 CLOSED 행(503 JSON) 사이 전환 트리거가 바로 이 시점이다.
- `/live`(K8s liveness 청중): FAULT 전까지 항상 200/정상. liveness가 500이면 kubelet이 프로세스를 죽이므로, 정상 drain 동안에는 절대 떨어지면 안 된다. 오직 `fail()`(FAULT)에서만 500.
> **응답 포맷·캐시·spec**
> - `GET /drain/status`: top-level `ready`/`state` + sub-object `progress`(전이 진척·timestamps)로 계층화.
> - Health 응답에 `Cache-Control: max-age=1` + `ETag` — LB 측 과도한 폴링 회피, 동일 상태 재요청 시 304 가능.
> - OpenAPI spec 자동 생성: `apps/hc/api/swagger.yaml`(swag CLI). 핸들러 주석을 단일 소스로 유지.
### 2.2 drain.sh — 스위치를 끄는 시점을 계산하는 7단계
FSM이 "스위치"라면 drain.sh(preStop hook)는 "스위치를 언제 끌지 계산하는 컨트롤러"다. 핵심 루프는 Envoy admin에서 active 요청 수를 폴링하다 0이 되는 순간을 잡는 것이다.
```mermaid
sequenceDiagram
autonumber
participant SH as drain.sh (preStop)
participant HC as hc :18180
participant E as Envoy admin :15000
participant LB as HAProxy
SH->>HC: POST /drain
Note over HC: OPEN -> DRAINING
/health_check.html 여전히 200
SH->>E: POST /drain_listeners?graceful&skip_exit
Note over E: 신규 연결은 여전히 accept(discourage)
HTTP/1.1 Connection: close·HTTP/2 GOAWAY로 신호, in-flight 유지
실제 신규 연결 차단은 drain-time-s(기본 600s) 경과 후
loop POLL_INTERVAL=2s, DRAIN_TIMEOUT=120s
SH->>E: GET /stats?filter=downstream_rq_active|upstream_rq_active
E-->>SH: gauge 값 반환
Note over SH: awk sum 계산, active > 0 -> 계속 대기
end
Note over SH: active=0 감지 (or timeout)
SH->>HC: POST /close-lb
Note over HC: DRAINING -> CLOSING
/health_check.html -> 503
LB->>HC: GET /health_check.html -> 503 (fall=2, ~4s)
LB->>LB: backend DOWN (shutdown-sessions)
but active=0 이므로 끊을 연결 없음
SH->>SH: sleep LB_BUFFER=10s
SH->>HC: POST /close
Note over HC: CLOSING -> CLOSED
/health -> 503 (K8s endpoint 제거)
Note over SH: preStop 완료 -> kubelet SIGTERM
```
읽는 순서대로 *시점*이 결정된다: `/drain`으로 새 연결을 막되 health는 200 유지(아직 빼지 마라) → `drain_listeners`로 Envoy가 신규 연결은 여전히 받아들이되 discourage 신호(HTTP/1.1 `Connection: close`/HTTP/2 `GOAWAY`)를 보내고 in-flight는 유지(실제 신규 연결 차단은 `drain-time-s` 경과 후) → active=0 폴링으로 "마지막 요청이 끝난 순간"을 포착 → 그제서야 `/close-lb`로 health를 503으로 내려 HAProxy가 backend를 빼게 함 → `LB_BUFFER=10s`로 HAProxy의 마킹 전파를 기다린 뒤 `/close`로 readiness까지 내려 K8s endpoint 제거.
> 위 7단계의 **라인별 정본**(환경변수 기본값·awk 폴링·각 핸들러 호출): [apps-walkthrough §3 (drain.sh)](/docs/istio/graceful-termination/apps-walkthrough/). Envoy `drain_listeners` 자체의 동작은 [envoy drain listeners](/docs/istio/graceful-termination/envoy-drain-listeners/).
---
## 3. 결론적 "왜" — active=0까지 health 200을 유지하는 단 하나의 이유
§1~§2를 한 점으로 모으면 이렇다. HAProxy는 `/health_check.html` 503을 보면 `fall 2`(2회 연속 실패, ~4초) 후 backend를 DOWN 마킹하고 `on-marked-down shutdown-sessions`를 실행한다. 이 순간 in-flight 연결이 있으면 **무조건 RST**다. HAProxy에게 "끊지 말고 기다려라"라고 말할 방법은 없다 — 마킹되는 순간 끊는다.
그래서 유일한 안전장치는 **DOWN 마킹이 일어날 때 끊을 연결이 0이도록** 만드는 것이다. drain.sh가 `downstream_rq_active + upstream_rq_active == 0`을 폴링하며 health 200을 유지하는 이유가 정확히 이것 — LB가 backend를 빼는 시점을 "모든 요청 완료 이후"로 강제로 밀어낸다. active=0 확인 후 비로소 503으로 flip하므로, HAProxy가 DOWN을 마킹하고 shutdown-sessions를 실행해도 끊을 in-flight 연결이 없다. graceful은 "끊지 않기"가 아니라 "끊을 게 없게 만들기"로 달성된다.
이것이 health endpoint를 단순한 liveness 신호가 아니라 **LB 멤버십의 원격 제어 스위치**로 재해석한 W2 전체의 결론이다.
---
## 4. 부수 메커니즘 — Backend의 Flusher 함정
위 FSM/drain 메커니즘과 별개로, backend가 SSE(streaming)를 돌릴 때 Go의 미묘한 함정 하나가 graceful 검증을 막았다. 원리 수준에서 짚어둔다.
### 문제: interface embedding은 메소드 셋을 promote하지 않는다
```
http.ResponseWriter (interface) http.Flusher (interface)
├── Header() http.Header └── Flush() <- ResponseWriter에 없음
├── Write([]byte) (int, error)
└── WriteHeader(statusCode int)
```
`statusRecorder`는 `http.ResponseWriter`를 embed한다. Go struct embedding은 **embed한 interface에 선언된 메소드만** promote한다. `http.ResponseWriter`에는 `Flush()`가 없으므로, 실제 concrete type(`*http.response`)이 `Flush()`를 구현해도 `*statusRecorder`를 통한 `w.(http.Flusher)` type assertion이 실패한다. (메소드 셋은 *정적*으로 embed된 interface 타입에서 결정되지, 런타임의 concrete 값에서 결정되지 않는다 — 이게 핵심.)
```
*statusRecorder (패치 전) *statusRecorder (패치 후)
+-------------------+ +-------------------+
| Header() | | Header() |
| Write() | | Write() |
| WriteHeader() ovr | | WriteHeader() ovr |
+-------------------+ | Flush() <- 추가 |
+-------------------+
내부에서 r.ResponseWriter.(http.Flusher)
assertion으로 실제 Flush() 위임
```
### 결과
`handleStream`은 `w.(http.Flusher)` 체크로 시작한다. `w`가 `loggingMiddleware`에서 `*statusRecorder`로 wrap된 상태라, `Flush()`가 없으면 500 "streaming unsupported"를 반환하고 종료된다. 명시적 `statusRecorder.Flush()` 추가가 이 함정을 해결한다 — 내부에서 wrap된 원본 `ResponseWriter`로 다시 assertion해 위임한다.
> **일반화**: ResponseWriter를 wrap할 때 `http.Hijacker`, `http.Pusher`, `http.Flusher` 등 선택적 interface를 체인 안에서 유지하려면 **각각 명시적 메소드를 추가**해야 한다. 표준 `httputil.ReverseProxy`도 같은 이유로 이들을 별도 처리한다.
> **라인별 정본**: backend의 Flusher 패치 실제 코드(`statusRecorder.Flush()` 추가 diff): [apps-walkthrough §1 (Flusher 패치)](/docs/istio/graceful-termination/apps-walkthrough/). 본 절은 함정의 메커니즘만 다룬다.
---
## 5. 떴는지 한 번 확인 — 상태 전이와 응답 코드 관측
멘탈 모델이 맞는지는 "전이 명령 → health 코드 변화"를 직접 관측해 검증한다. hc는 `:18180`, Envoy admin은 `:15000`.
```bash
# 1) OPEN 상태: 세 endpoint 모두 200/정상 기대
curl -s -o /dev/null -w '%{http_code}\n' localhost:18180/health_check.html # 200
curl -s -o /dev/null -w '%{http_code}\n' localhost:18180/health # 200
# 2) drain 시작: DRAINING 이지만 health_check.html은 여전히 200 (핵심!)
curl -s -X POST localhost:18180/drain
curl -s -o /dev/null -w '%{http_code}\n' localhost:18180/health_check.html # 200 <- 아직 빼지 마라
# 3) active 요청 폴링 (drain.sh 내부와 동일 stat)
curl -s 'localhost:15000/stats?filter=downstream_rq_active|upstream_rq_active'
# downstream_rq_active: 0
# upstream_rq_active: 0 <- 합이 0이면 close-lb 진행
# 4) close-lb: CLOSING, 이제서야 health_check.html 503 flip
curl -s -X POST localhost:18180/close-lb
curl -s -o /dev/null -w '%{http_code}\n' localhost:18180/health_check.html # 503
curl -s -o /dev/null -w '%{http_code}\n' localhost:18180/health # 200 <- readiness는 아직 살아있음
# 5) 불법 전이는 409로 봉쇄되는지 확인 (OPEN->CLOSING 직접 점프 금지)
# 이미 CLOSING이므로 close-lb 재호출은 expectedFrom 불일치 -> 409
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:18180/close-lb # 409 Conflict
```
검증 포인트는 단계 2(전이는 했지만 HAProxy용 코드는 안 내려감)와 단계 4(active=0 확인 후에야 503)의 **시점 차이**다. 이 두 줄이 "왜 graceful한가"의 경험적 증거다. 단계 5의 409는 FSM이 불법 전이를 전이 함수 한 곳에서 막는다는 §2의 불변식을 확인한다.
---
## 핵심 정리
- **앵커**: health 응답 코드 = LB 멤버십 원격 스위치. hc FSM이 이 스위치를 쥐고, drain.sh가 *언제 끌지*를 active=0 폴링으로 계산한다.
- 5-state FSM(OPEN/DRAINING/CLOSING/CLOSED/FAULT). 모든 전이는 `advance(expectedFrom, to, reason)` 한 곳을 통과하고 불일치 시 409 — 그래서 OPEN→CLOSING 직접 점프 불가, drain.sh는 2-step.
- health/traffic 포트 분리가 전제. DRAINING에서 `/health_check.html=200` 유지 → active=0 후 503 flip → DOWN 마킹 시 끊을 연결이 0. graceful = "끊지 않기"가 아니라 "끊을 게 없게 만들기".
- 세 endpoint는 다른 청중·다른 타이밍: HAProxy용은 CLOSING에서, K8s readiness는 CLOSED에서, liveness는 FAULT에서만 떨어진다.
- `POST /reopen`은 DRAINING/CLOSING → OPEN으로 drain을 abort하는 유일한 역방향 경로. CLOSED는 endpoint가 이미 빠져 409.
- DRAIN_TIMEOUT(120s)은 무결성이 아니라 최대 대기 시간만 보장. timeout 시 in-flight가 있어도 강제 CLOSING.
- Go interface embedding은 embed한 interface에 선언된 메소드만 promote하므로, ResponseWriter wrapper는 Flush/Hijack 등을 명시적으로 다시 구현해야 한다.
## What you might be missing
- **FAULT vs CLOSED 종착의 차이**: FAULT는 `fail()`에 의한 비정상 강제 종료(any → FAULT 가능)이고, 정상 경로의 종착은 CLOSED 이후 preStop 완료 → kubelet SIGTERM이다. 다이어그램의 `[*]`가 둘로 갈라지는 이유.
- **CLOSING에서 reopen하면 즉시 200이 아니다**: `/health_check.html`이 200을 다시 반환해도 HAProxy는 `rise 2`(~4s)를 채워야 UP 복귀한다. abort 타이밍 분석 시 이 지연을 빼먹기 쉽다.
- **readiness(`/health`)와 HAProxy(`/health_check.html`)가 같은 시점에 안 떨어진다**: 전자는 CLOSING→CLOSED에서, 후자는 DRAINING→CLOSING에서 flip. 둘을 하나로 묶어 생각하면 LB_BUFFER 구간(HAProxy는 503인데 K8s endpoint는 아직 살아있음)을 놓친다.
- **W2는 멘탈 모델 층**: drain.sh 라인·환경변수 기본값·Flusher 패치 diff 같은 정본은 [apps-walkthrough](/docs/istio/graceful-termination/apps-walkthrough/)에 있다. 코드 변경 검증은 그쪽 §1/§3 기준.
## 이어 보기
- 라인별 코드 워크스루: [apps-walkthrough §1·§3](/docs/istio/graceful-termination/apps-walkthrough/)
- W1: [graceful termination big picture](/docs/istio/graceful-termination/w1-big-picture/)
- 다음(W3): [IGW custom deployment](/docs/istio/graceful-termination/w3-igw-deployment/) — IGW Deployment + IstioOperator
- 시리즈 인덱스: [graceful termination MOC](/docs/istio/graceful-termination/)
## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)
검증 방법: 공식 문서(HAProxy/Envoy/Go 스펙) 대조 + hc의 자체 구현분은 §2.1 명세를 그대로 재현한 hc-mock 레퍼런스로 homelab 클러스터 실측.
| 주장 | 판정 | 근거 |
|---|---|---|
| C1. HAProxy는 health check 응답 코드만으로 UP/DOWN 판단(200=UP, 503 fall N회 후 DOWN) | ✅ 문헌 확인 | [haproxy.com/.../health-checks](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/) |
| C2. health 503 → fall 2(~4s) 후 DOWN → on-marked-down shutdown-sessions로 in-flight 강제 RST | ✅ 문헌 확인 | [discourse.haproxy.org on-marked-down-shutdown-sessions](https://discourse.haproxy.org/t/on-marked-down-shutdown-sessions-not-working/6456) |
| C3. hc는 state 변수 하나 + advance(expectedFrom, to, reason) 하나로 구성, 불일치 시 409로 illegal transition 봉쇄 | 실측 불가 | hc는 자체 Go 사이드카라 공식 문서 검증 대상 아니며 실제 소스 미제공(별도 테스트 없음) |
| C4. OPEN→CLOSING 직접 점프 불가, drain.sh는 반드시 /drain → /close-lb 2-step | ✅ 실측 확인 | [T34 실측](files/verify/T34/) |
| C5. POST /reopen은 DRAINING/CLOSING→OPEN 유일한 역방향 경로. DRAINING은 즉시 OPEN, CLOSING은 rise 2(~4s) 필요 | ✅ 실측 확인 | [haproxy.com/.../health-checks](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/) · [T34 실측](files/verify/T34/)(hc 쪽 즉시 전이만 확인 — HAProxy rise 2 지연 자체는 harness에 HAProxy 없어 검증 범위 밖) |
| C6. 하나의 state가 세 endpoint 응답 코드를 결정(§2.1 표) | ✅ 실측 확인 | [T34 실측](files/verify/T34/) |
| C7. DRAIN_TIMEOUT(120s)은 무결성이 아니라 최대 대기 시간만 보장 — timeout 도달 시 in-flight 있어도 강제 CLOSING | ✅ 실측 확인 | [T34 실측](files/verify/T34/) (환경 한계: 이 클러스터 사이드카가 downstream_rq_active 통계 패밀리 자체를 노출하지 않아, 강제 전이 시점의 active>0을 이 통계로 직접 증명하지는 못함 — hc-mock 강제 로직 자체는 이 카운터에 의존하지 않아 판정에는 영향 없음) |
| C8. drain_listeners?graceful&skip_exit 호출 시 "새 연결 거부 시작, in-flight 유지" | ❌ 오류 — 본문 교정 | [envoyproxy.io draining](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/operations/draining) · [T27 실측](/docs/istio/graceful-termination/apps-walkthrough/files/verify/T27/) |
| C9. drain.sh는 downstream_rq_active\|upstream_rq_active gauge 합이 0이 되는 순간을 완료로 판정 | ✅ 문헌 확인 · ✅ 실측 확인 | [envoyproxy.io http_conn_man stats](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/stats) · [T27 실측](/docs/istio/graceful-termination/apps-walkthrough/files/verify/T27/) |
| C10. Envoy admin은 :15000, hc는 :18180에서 청취(15000은 Istio sidecar 기본값) | ✅ 문헌 확인 | [istio.io application-requirements](https://istio.io/latest/docs/ops/deployment/application-requirements/) |
| C11. Go struct embedding은 embed된 interface에 선언된 메소드만 promote(정적 결정, 런타임 무관) | ✅ 문헌 확인 | [go.dev/ref/spec#Struct_types](https://go.dev/ref/spec#Struct_types) |
| C12. ResponseWriter embed는 Flush() 미promote → type assertion 실패, 명시적 Flush() 위임으로 해결 | ✅ 문헌 확인 | [doxsey.net fixing-interface-erasure-in-go](https://www.doxsey.net/blog/fixing-interface-erasure-in-go/) |
| C13. httputil.ReverseProxy도 같은 이유로 Hijacker/Pusher/Flusher를 별도 처리 | ✅ 문헌 확인(최신 Go 1.20+는 http.NewResponseController로 진화 — 문서 미언급 보충 정보) | [github.com golang/go reverseproxy.go](https://github.com/golang/go/blob/master/src/net/http/httputil/reverseproxy.go) |
| C14. Health 응답에 Cache-Control: max-age=1 + ETag로 폴링 회피, 동일 상태 재요청 시 304 | 실측 불가 | hc 자체 구현이라 소스 확인 필요 |
| C15. GET /drain/status는 top-level ready/state + sub-object progress 계층 구조, OpenAPI는 swag CLI로 자동 생성 | 실측 불가 | hc 자체 구현이라 소스 확인 필요 |