---
type: src
tags: [istio, graceful-termination, go, golang, drain, fsm]
created: 2026-06-07
---
# W2. Backend + hc + drain.sh — Go 코드 메커니즘 (출처: 홈랩 graceful termination 학습 시리즈 W2)
> [!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)](gt__src-apps-walkthrough.html)에 위임한다.
> **시리즈 위치**: W2. [W1 big picture](gt__src-w1-big-picture.html)의 6 events·4 시나리오를 코드 수준으로 추적. 대응 라인별 코드 워크스루는 [apps-walkthrough](gt__src-apps-walkthrough.html).
> **대상독자**: 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: 새 연결 거부 시작, in-flight 유지
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가 신규 거부·in-flight 유지 → active=0 폴링으로 "마지막 요청이 끝난 순간"을 포착 → 그제서야 `/close-lb`로 health를 503으로 내려 HAProxy가 backend를 빼게 함 → `LB_BUFFER=10s`로 HAProxy의 마킹 전파를 기다린 뒤 `/close`로 readiness까지 내려 K8s endpoint 제거.
> 위 7단계의 **라인별 정본**(환경변수 기본값·awk 폴링·각 핸들러 호출): [apps-walkthrough §3 (drain.sh)](gt__src-apps-walkthrough.html). Envoy `drain_listeners` 자체의 동작은 [envoy drain listeners](gt__src-envoy-drain-listeners.html).
---
## 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 패치)](gt__src-apps-walkthrough.html). 본 절은 함정의 메커니즘만 다룬다.
---
## 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](gt__src-apps-walkthrough.html)에 있다. 코드 변경 검증은 그쪽 §1/§3 기준.
## 이어 보기
- 라인별 코드 워크스루: [apps-walkthrough §1·§3](gt__src-apps-walkthrough.html)
- W1: [graceful termination big picture](gt__src-w1-big-picture.html)
- 다음(W3): [IGW custom deployment](gt__src-w3-igw-deployment.html) — IGW Deployment + IstioOperator
- 시리즈 인덱스: [graceful termination MOC](gt__MOC-graceful-termination.html)