---
type: src
tags: [istio, graceful-termination, go, golang, drain, homelab]
created: 2026-06-07
---
# Apps Walkthrough — backend + hc + graceful-drain.sh
> [!abstract]
> graceful termination은 "Pod를 끈다"가 **단일 행위가 아니라**, *서로 분리된 4개 신호(LB health, K8s readiness, Envoy listener, in-flight 요청)를 정해진 순서로 하나씩 끄는 것*이라는 사실을 코드로 증명하는 문서다. 홈랩 실험의 두 Go 애플리케이션(`apps/backend`, `apps/hc`)과 preStop 훅(`apps/hc/graceful-drain.sh`)을 **라인 수준까지 추적**한다.
> 결론부터: backend는 끌 *대상*(in-flight 요청)을 만들고, hc FSM은 *어떤 신호를 언제 끌지*를 5-state로 인코딩하며, drain.sh는 그 *순서*를 best-effort로 오케스트레이션한다. 세 파일은 같은 한 문제의 세 측면이다.
> FSM/health 응답표·active=0 전략의 **개요 정본**은 [W2 — HC FSM](gt__src-w2-hc-fsm.html), Envoy drain 메커니즘 상세는 [Envoy drain listeners](gt__src-envoy-drain-listeners.html)에 둔다. 본 문서는 **라인별 코드 추적**에 집중한다.
> **명명 매핑(2026-04-26)**: READY/DRAINING/DRAINED_WAIT_LB/TERMINATING/FAILED → OPEN/DRAINING/CLOSING/CLOSED/FAULT(게이트 비유). 옛 artifacts(`tests/artifacts/2026*/`)는 옛 명칭으로 보존.
> **신규**: `POST /reopen` — DRAINING/CLOSING → OPEN abort. CLOSED는 unrecoverable(409). CLOSING→OPEN 응답엔 `Warning: 199 ...` 헤더(LB rise 2 = +4s 재투입).
---
## 1. 배경 — 왜 "Pod 종료"가 코드 세 개를 부르는가
**문제의 출발점**: kubelet이 Pod를 죽일 때 일어나는 일들은 *서로 비동기다*. SIGTERM 전달, Service endpoint 제거, 외부 LB(HAProxy)의 health-check 재판정 — 이 셋은 각자의 시계로 돈다. endpoint가 제거되기 전에 컨테이너가 죽으면, 그 사이 도착한 요청은 죽은 Pod로 라우팅돼 끊긴다(connection reset). 이게 graceful termination이 풀려는 단 하나의 문제다.
순진한 해법("preStop에서 `sleep 15` 후 종료")이 부족한 이유: 고정 sleep은 **실제 in-flight 요청이 끝났는지를 모른다**. 60초짜리 `/sleep` 요청 한 개가 살아 있어도 15초 후 그냥 죽는다. 반대로 트래픽이 없어도 15초를 낭비한다. 필요한 건 *시간이 아니라 신호* — "지금 실제로 처리 중인 요청이 0인가?"를 관측하고, 그 후에야 LB·endpoint를 끊는 것이다.
여기서 4개 신호가 등장한다. 각각이 *서로 다른 관측자*에게 "이 Pod 쓰지 마"를 알리는 채널이다:
| 신호 | 누가 보나(관측자) | 끄면 무슨 일 |
|---|---|---|
| HAProxy health (`/health_check.html`) | 외부 L4/L7 LB | LB pool에서 backend 제외(신규 유입 차단) |
| K8s readiness (`/health`) | kube-proxy / Service | Service endpoint 제거(클러스터 내부 유입 차단) |
| Envoy listener (`drain_listeners`) | 사이드카 데이터플레인 | 신규 conn에 `Connection: close` 권유 |
| in-flight 요청 | backend 프로세스 자신 | (끄는 게 아니라 *소진* 대상) |
**선행 개념**: HTTP keep-alive와 connection draining, Go `http.Server.Shutdown` 시맨틱, Envoy admin API(`:15000`), K8s probe(readiness vs liveness)의 분리. 모르면 [W2 — HC FSM](gt__src-w2-hc-fsm.html)을 먼저 본다. **대상 독자**: 이 세 신호를 *순서대로* 끄는 코드가 실제로 어떤 줄에서 그 일을 하는지 확인하려는 SRE/DevOps.
---
## 2. 아키텍처 — 분리된 신호 = 분리된 코드
**멘탈모델 앵커 (이 한 그림만 머리에 남겨라)**: 세 파일은 graceful termination이라는 한 동사를 *명사 세 개*로 쪼갠 것이다 — **대상(backend) · 상태(hc FSM) · 순서(drain.sh)**. drain.sh가 "지금 readiness를 꺼라"라고 명령하면, hc FSM이 그 명령을 *상태 전이*로 받아 health endpoint 응답을 바꾸고, backend는 그 와중에도 in-flight 요청을 끝까지 보호한다. 즉 **drain.sh = 지휘자, hc = 신호 패널, backend = 보호 대상.**
```mermaid
flowchart TB
subgraph drain["drain.sh (preStop) — 순서(orchestration)"]
S1["[1] POST /drain"]
S2["[2] POST /drain_listeners"]
S3["[3] poll rq_active==0"]
S4["[4] POST /close-lb"]
S6["[6] POST /close"]
end
subgraph hc["hc FSM — 상태(which signal when)"]
FSM["OPEN -> DRAINING -> CLOSING -> CLOSED"]
HEALTH["/health_check.html (HAProxy)"]
READY["/health (K8s readiness)"]
end
subgraph be["backend — 대상(in-flight to drain)"]
SLEEP["/sleep /stream long-req"]
SHUT["srv.Shutdown(ctx 300s)"]
end
ENVOY["Envoy admin :15000"]
S1 --> FSM
S2 --> ENVOY
S3 -.GET /stats.-> ENVOY
S4 --> FSM
S6 --> FSM
FSM --> HEALTH
FSM --> READY
SLEEP -.protected by.-> SHUT
ENVOY -.discourage new conn.-> SLEEP
```
핵심은 **비대칭**이다: 같은 FSM 상태라도 endpoint마다 응답이 다르다. `/health_check.html`은 DRAINING까지 200, `/health`는 CLOSING까지 200. 하나의 상태가 여러 신호를 *서로 다른 타이밍에* 끄도록 매핑돼 있고, 이 시차가 곧 "신규 유입은 막되 in-flight는 살아 있는" **drain window**를 만든다(§2의 hc 핸들러표가 정본). 아래 세 절은 이 그림의 세 박스를 각각 라인 단위로 연다.
---
## 3. `apps/backend/main.go` — 끌 대상(in-flight)을 만드는 코드
backend의 역할은 앵커의 **대상 박스**다. `/sleep`·`/stream`은 의도적으로 오래 살아있는 in-flight 요청을 생성해, drain 순서가 틀리면 끊겨버릴 트래픽을 재현한다. 따라서 이 파일에서 봐야 할 핵심은 두 가지다 — (a) 요청이 *어떻게 오래 살아남는가*(`select` + `r.Context().Done()`), (b) shutdown이 그 요청을 *어떻게 보호하는가*(`srv.Shutdown(ctx)`). 아래 코드 추적은 모두 이 두 축으로 수렴한다.
### L28~45: statusRecorder + Flush() 패치
```go
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Forward http.Flusher so chunked-streaming handlers can flush via *statusRecorder.
// Embedded http.ResponseWriter does not promote Flush() — that method is on the
// concrete net/http response type, not on the ResponseWriter interface.
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
```
**왜 이렇게**: `statusRecorder`는 응답 status code를 로그에 남기기 위해 `WriteHeader`를 override한다. 문제는 Go의 struct embedding 규칙에 있다. `http.ResponseWriter`를 embed하면 interface에 선언된 3개 메소드(`Header`, `Write`, `WriteHeader`)만 promote된다. `http.Flusher.Flush()`는 `http.ResponseWriter` interface 명세에 없으므로, 실제 concrete type(`*http.response`)이 `Flush()`를 구현하고 있어도 `*statusRecorder` 변수를 통한 `w.(http.Flusher)` type assertion은 실패한다. `handleStream`은 시작 시 이 assertion을 체크하기 때문에, Flush() 메소드가 없으면 500 "streaming unsupported"를 즉시 반환한다. 명시적 `Flush()` 메소드가 이 함정을 우회한다.
---
### L47~63: loggingMiddleware
```go
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rec, r)
path := r.URL.RequestURI()
log.Printf("ts=%s method=%s path=%s remote=%s hostname=%s status=%d duration_ms=%d", /* ...args */)
})
}
```
**왜 이렇게**: `status: 200`을 기본값으로 초기화한다. 핸들러가 `WriteHeader`를 명시적으로 호출하지 않으면(`fmt.Fprint`만 쓰면) `WriteHeader(200)`이 묵시적으로 호출되지 않을 수 있으므로, 기본값 200을 pre-set해둬야 로그에 올바른 상태가 찍힌다. 미들웨어는 `main()`에서 `mux` 전체를 wrap하므로 모든 경로에 단일 포인트로 적용된다.
---
### L69~87: handleSleep — ctx-cancel 패턴
```go
func handleSleep(w http.ResponseWriter, r *http.Request) {
n := 1
if s := r.URL.Query().Get("seconds"); s != "" {
v, err := strconv.Atoi(s)
if err == nil && v >= 0 && v <= 600 {
n = v
}
}
select {
case <-time.After(time.Duration(n) * time.Second):
fmt.Fprintf(w, "slept %ds\n", n)
case <-r.Context().Done():
log.Printf("... msg=client_disconnected")
}
}
```
**왜 이렇게**: `select`의 두 번째 case가 핵심이다. `r.Context().Done()`은 클라이언트 연결 끊김(RST, connection close), 또는 `http.Server.Shutdown()`이 호출될 때 cancel된다. Shutdown은 in-flight 요청 context를 cancel하지 않고 새 연결만 거부하므로, sleep이 완료될 때까지 goroutine이 살아 있다. 반면 HAProxy가 `shutdown-sessions`로 TCP RST를 보내면 `r.Context().Done()`이 닫혀 goroutine이 즉시 종료된다. 이것이 current 모드에서 S1 실험이 실패하는 Go 수준 이유다.
---
### L89~145: handleStream — chunked + flusher
```go
func handleStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Transfer-Encoding", "chunked")
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
// ticker 설정 …
for {
select {
case t := <-ticker.C:
fmt.Fprintf(w, "chunk %d @ %dms\n", i, ms)
flusher.Flush()
i++
case <-r.Context().Done():
return
}
}
}
```
**왜 이렇게**: `w.(http.Flusher)` assertion이 성공하려면 §L28~45의 `statusRecorder.Flush()` 패치가 필수다(미들웨어가 `*statusRecorder`로 wrap). Go net/http는 Content-Length 미설정 시 자동으로 chunked 인코딩하므로 `Transfer-Encoding: chunked` 헤더의 수동 설정은 net/http 자동 관리와 중복이며 streaming 보장의 본질이 아니다 — 본질은 매 `ticker.C`마다 `flusher.Flush()`를 호출해 chunk를 즉시 내보내는 것이다. flush가 있어야 Envoy도 응답 버퍼링 없이 downstream으로 포워딩한다(Envoy의 streaming은 TE 헤더 명시가 아니라 upstream flush 타이밍·버퍼링 설정에 좌우된다). flush 없이 `Fprintf`만 하면 write buffer에 축적된다.
---
### L155~197: main() — graceful shutdown
```go
func main() {
srv := &http.Server{Addr: ":" + port, Handler: loggingMiddleware(mux)}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil { /* 로그 */ }
}
```
**왜 이렇게**: `srv.Shutdown(ctx)`는 새 연결 수락을 즉시 중단하고 기존 in-flight 요청 완료까지 최대 300초 대기한다. `http.ErrServerClosed`는 `Shutdown` 후 `ListenAndServe`가 반환하는 정상 종료 error라 fatal 처리에서 제외한다.
---
## 4. `apps/hc/main.go` — 어떤 신호를 언제 끌지(상태)
hc의 역할은 앵커의 **상태 박스**, 즉 **"어떤 신호를 언제 끌지"를 5-state로 인코딩**하는 것이다. §2에서 본 비대칭(같은 상태, endpoint별 다른 응답: `/health_check.html`는 DRAINING까지 200, `/health`는 CLOSING까지 200)이 *바로 이 파일의 핸들러 switch문에서* 구현된다 — 그게 drain window의 정체다. 아래 정의·struct는 이 매핑을 thread-safe하게 구동하는 기계 장치다(핸들러표는 §4.L208~319이 정본).
### L17~42: FSM 상태 정의
```go
type State int
const (
StateOpen State = iota
StateDraining
StateClosing
StateClosed
StateFault
)
```
**왜 이렇게**: `iota`로 0~4 정수 값 부여. `String()` 메소드가 각 값을 문자열로 변환하며 로그·JSON·ring buffer에 사용된다. `StateFault`는 유일하게 "any → FAULT" 전이가 가능한 상태로 `fail()`이 별도 구현된다.
---
### L57~79: ring buffer (100 entries)
```go
const maxTransitions = 100
type ringBuffer struct {
buf [maxTransitions]Transition
head int // next write index
size int
}
func (r *ringBuffer) push(t Transition) {
r.buf[r.head] = t
r.head = (r.head + 1) % maxTransitions
if r.size < maxTransitions {
r.size++
}
}
```
**왜 이렇게**: slice 대신 고정 크기 배열(`[100]Transition`)로 heap allocation을 줄인다. `head`가 원형 순환하며 오래된 항목을 덮어쓴다. `snapshot()`은 `(head - size + 100) % 100`으로 시작 인덱스를 계산해 삽입 순서로 재구성. 한 Pod lifetime의 전이는 4~5회라 100 한도 초과 손실은 사실상 없다.
---
### L85~133: FSM struct + advance() + RWMutex 범위
```go
type FSM struct {
mu sync.RWMutex
state State
since time.Time
transitions ringBuffer
}
func (f *FSM) advance(expectedFrom, to State, reason string) (State, bool) {
f.mu.Lock()
defer f.mu.Unlock()
if f.state != expectedFrom {
return f.state, false
}
f.doTransition(to, reason)
return f.state, true
}
func (f *FSM) current() State {
f.mu.RLock()
defer f.mu.RUnlock()
return f.state
}
```
**왜 이렇게**: 쓰기 전이(`advance`, `fail`)는 `mu.Lock()`(exclusive), 읽기(`current`, `snapshot`)는 `mu.RLock()`(shared)으로 분리. 함정은 **`doTransition` 내부에서 `logTransition`(`fmt.Printf`)을 `mu.Lock()` 잡힌 채 호출**한다는 점 — I/O 동안 다른 goroutine read가 차단된다. health check가 2초 간격이라 보통 무해하지만 느린 I/O 환경에서는 contention 발생 가능. 실험 규모에서는 무시 가능한 트레이드오프.
---
### L208~319: health/transition endpoint 핸들러 + 응답 로직
| endpoint | method | 소비자 | 동작 |
|---|---|---|---|
| `/health_check.html` | GET | HAProxy | OPEN/DRAINING → 200 "OK", 그 외 → 503 "DRAIN" |
| `/health` | GET | K8s readiness | OPEN/DRAINING/CLOSING → 200, CLOSED/FAULT → 503 |
| `/live` | GET | K8s liveness | FAULT만 500, 그 외 200 (drain 중 재시작 방지) |
| `/drain` | POST | drain.sh [1] | OPEN → DRAINING 전이 |
| `/close-lb` | POST | drain.sh [4] | DRAINING → CLOSING (HAProxy health 503화) |
| `/close` | POST | drain.sh [6] | CLOSING → CLOSED (readiness 503화) |
| `/reopen` | POST | 운영자 abort | DRAINING/CLOSING → OPEN, CLOSED → 409, `Warning: 199` 헤더 |
| `/drain/status` | GET | LB/운영자 | `ready`/`state` + `progress` 계층 응답 |
| `/fault` | POST | 장애 주입 | any → FAULT (`fail()`) |
엔드포인트 수는 9개이며, callout이 강조한 `/reopen`은 DRAINING/CLOSING 상태에서 OPEN으로 **abort**시킨다(CLOSED는 unrecoverable 409). 응답에 `Warning: 199` 헤더를 붙여 LB rise 2(=+4s) 재투입 지연을 알린다. `/live`는 liveness probe 전용으로 FAULT에서만 500을 반환해, drain 중(DRAINING/CLOSING) kubelet이 Pod를 재시작하지 않도록 readiness(`/health`)와 분리한다.
```go
// OPEN, DRAINING → 200 "OK"
// CLOSING, CLOSED, FAULT → 503 "DRAIN"
func handleHAProxyHealth(fsm *FSM) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s := fsm.current()
switch s {
case StateOpen, StateDraining:
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "OK")
default:
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprint(w, "DRAIN")
}
}
}
```
**왜 이렇게**: 각 핸들러는 `fsm *FSM`을 클로저로 캡처하는 `http.HandlerFunc`를 반환 — 전역 변수 없는 의존성 주입. 핵심은 `StateDraining`이 200을 반환한다는 점이다. drain.sh step [1]에서 `POST /drain`으로 DRAINING으로 전이한 후 stats 폴링(step [3])이 끝날 때까지 HAProxy는 backend를 UP으로 유지한다. 반면 `handleReadiness`는 `StateClosing`까지 200을 반환해 K8s endpoint를 보존한다. 상태별 응답 전략의 전체 매핑표는 [W2 — HC FSM](gt__src-w2-hc-fsm.html)을 정본으로 참조.
> **응답 포맷·캐시·spec 추가**
> - `GET /drain/status`: top-level `ready`/`state` + sub-object `progress`(전이 진척·timestamps)로 계층화. LB 단순 폴링과 운영자 디버그를 같은 endpoint에서 분리 제공.
> - Health 응답에 `Cache-Control: max-age=1` + `ETag` 부착 — 동일 상태 재요청 시 304 가능, LB 과도 폴링 비용 완화.
> - OpenAPI spec 자동 생성: `apps/hc/api/swagger.yaml`(swag CLI). 핸들러 주석이 spec·검증 테스트의 단일 소스.
---
### L325~362: main() — 리스너 설정
```go
func main() {
addr := ":18180"
if v := os.Getenv("HC_ADDR"); v != "" {
addr = v
}
fsm := newFSM()
mux := http.NewServeMux()
mux.HandleFunc("/health_check.html", logging(methodGuard(http.MethodGet, handleHAProxyHealth(fsm))))
mux.HandleFunc("/drain", logging(methodGuard(http.MethodPost, handleDrainStart(fsm))))
// 나머지 핸들러 …
srv := &http.Server{Addr: addr, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second}
if err := srv.ListenAndServe(); err != nil { os.Exit(1) }
}
```
**왜 이렇게**: hc는 backend와 달리 graceful shutdown을 구현하지 않는다. hc의 lifetime은 drain.sh(preStop)가 관리하기 때문 — drain.sh 완료 후 kubelet이 SIGTERM을 보내면 hc는 즉시 종료해도 된다. graceful shutdown이 필요한 쪽은 in-flight long request를 처리하는 backend다. `methodGuard`는 method mismatch를 405로 처리해 `/drain`에 GET을 보내는 실수를 방지한다.
---
## 5. `apps/hc/graceful-drain.sh` — 순서(orchestration) 7단계
drain.sh는 앵커의 **순서 박스(지휘자)**다. §3의 backend(대상)와 §4의 hc(상태)를 *정확한 순서로* 호출해, "신규 유입 차단 → in-flight 소진 관측 → LB 제거 → endpoint 제거"라는 단조 진행을 만든다. 전체 시퀀스는 step [1]~[7]: **[1]** `POST /drain`(OPEN→DRAINING) → **[2]** Envoy `POST /drain_listeners`(신규 conn discourage) → **[3]** `downstream/upstream_rq_active==0` 폴링 → **[4]** `POST /close-lb`(HAProxy health 503) → **[5]** `LB_BUFFER` sleep → **[6]** `POST /close`(readiness 503) → **[7]** done. 아래는 각 step의 라인별 코드다.
### L1~16: shebang + 환경변수 기본값
```sh
#!/bin/sh
set -u
HC_URL="${HC_URL:-http://127.0.0.1:18180}"
ENVOY_ADMIN="${ENVOY_ADMIN:-http://127.0.0.1:15000}"
DRAIN_TIMEOUT="${DRAIN_TIMEOUT:-120}"
POLL_INTERVAL="${POLL_INTERVAL:-2}"
LB_BUFFER="${LB_BUFFER:-10}"
START_TS=$(date +%s)
```
**왜 이렇게**: `#!/bin/sh`는 alpine 컨테이너 호환을 위한 POSIX 선택(bash 없는 환경에서도 동작). `set -u`는 미선언 변수 참조를 오류로 처리해 silent 버그 방지. `${VAR:-default}`는 POSIX 표준 기본값 구문. `DRAIN_TIMEOUT=120`은 `/sleep?seconds=60`의 2배.
---
### L19~27: do_curl 래퍼 — `-sf -m 5` 의미
```sh
do_curl() {
out=$(curl -sf -m 5 "$@" 2>&1)
rc=$?
if [ $rc -ne 0 ]; then
log "level=warn msg=curl_failed args=$* rc=$rc output=${out}"
fi
printf '%s' "$out"
return $rc
}
```
| 플래그 | 의미 |
|---|---|
| `-s` | silent: progress/error 메시지 숨김. `2>&1`로 캡처 가능 |
| `-f` | fail on HTTP error: 4xx/5xx 시 exit code non-zero |
| `-m 5` | max-time 5s: 연결+응답 전체 5초 타임아웃 |
**왜 이렇게**: 개별 curl 실패가 preStop을 abort하지 않도록 `|| true`와 함께 호출한다. 반환값을 호출자가 확인하지 않으므로 실패는 로그에만 남고 스크립트는 계속 진행 — "best-effort" 설계.
---
### L31~41: [1] FSM DRAINING 전이 + [2] Envoy listener drain
```sh
# [1] FSM OPEN → DRAINING (HAProxy/readiness 200 유지)
do_curl -X POST "${HC_URL}/drain" || true
# [2] Envoy listener graceful drain (신규 conn discourage, exit 보류)
do_curl -X POST "${ENVOY_ADMIN}/drain_listeners?graceful&skip_exit" || true
```
**왜 이렇게**: step [1]은 hc FSM을 OPEN→DRAINING으로 전이시키되 `/health_check.html`·`/health`는 아직 200을 유지한다(§4 핸들러표) — LB/endpoint를 끊지 않은 채 drain window를 연다. step [2]는 Envoy admin(`127.0.0.1:15000`)의 `POST /drain_listeners?graceful&skip_exit`를 호출한다: `graceful`은 listener를 즉시 닫지 않고 신규 연결을 점진적으로 discourage(`Connection: close`)하며, `skip_exit`은 drain 후 Envoy 프로세스가 종료되지 않도록 막아 step [3] 폴링이 끝날 때까지 데이터플레인을 살려둔다. 이 단계가 있어야 이후 `downstream/upstream_rq_active` 폴링이 "신규 유입 없이 in-flight만 소진되는" 단조감소 곡선을 보인다. drain listener 메커니즘 상세는 [Envoy drain listeners](gt__src-envoy-drain-listeners.html) 참조.
---
### L43~73: [3] Envoy stats 폴링 — awk 합산 로직
```sh
DEADLINE=$((START_TS + DRAIN_TIMEOUT))
while true; do
NOW=$(date +%s)
STATS=$(curl -sf -m 5 \
"${ENVOY_ADMIN}/stats?filter=downstream_rq_active%7Cupstream_rq_active" 2>/dev/null || true)
ACTIVE=$(printf '%s\n' "$STATS" | awk -F': ' '
/downstream_rq_active|upstream_rq_active/ { sum += $2 }
END { print (sum+0) }
')
if [ "${ACTIVE}" -eq 0 ] 2>/dev/null; then
break
fi
if [ "$NOW" -ge "$DEADLINE" ]; then
log "level=warn msg=drain_timeout_exceeded elapsed=$((NOW - START_TS))s"
break
fi
sleep "${POLL_INTERVAL}"
done
```
**왜 이렇게**: `%7C`는 `|`의 URL 인코딩. Envoy stats API `filter`에 `downstream_rq_active|upstream_rq_active` 정규식을 넘겨 두 gauge를 함께 필터.
awk 패턴: `-F': '`(Envoy 형식 `metric: value`), 매칭 라인 필터, `sum += $2` 누산, `END { print (sum+0) }`는 STATS가 빈 문자열일 때 0 강제 출력. `[ "${ACTIVE}" -eq 0 ] 2>/dev/null`은 ACTIVE가 빈 문자열일 때 정수 비교 쉘 오류를 버려 루프가 안전하게 계속되게 한다.
---
### L75~97: [4]~[7] LB fail + buffer + readiness disable
```sh
# [4] Flip /health_check.html → 503
do_curl -X POST "${HC_URL}/close-lb" || true
# [5] Wait HAProxy detection
sleep "${LB_BUFFER}"
# [6] Flip /health → 503
do_curl -X POST "${HC_URL}/close" || true
# [7] Done
ELAPSED=$(( $(date +%s) - START_TS ))
log "[done] preStop completed in ${ELAPSED}s"
```
**왜 이렇게**: step [4]와 [6] 사이 `LB_BUFFER=10s` sleep은 HAProxy 감지 지연(`inter 2s fall 2` → 최대 4초)을 흡수한다(10s ≫ 4s). K8s readiness 503(`/health`)이 endpoint 제거를 트리거하는데 이것이 HAProxy DOWN 판정보다 늦어야 한다. 순서: HAProxy DOWN → `shutdown-sessions`(active=0이라 무해) → K8s endpoint 제거(비동기).
---
### drain.sh step ↔ FSM 전이 ↔ health 응답 시퀀스
```mermaid
sequenceDiagram
participant K as kubelet (preStop)
participant D as drain.sh
participant H as hc FSM
participant E as Envoy admin
participant LB as HAProxy / K8s
K->>D: SIGTERM -> run preStop
D->>H: [1] POST /drain (OPEN -> DRAINING)
Note over H,LB: /health_check.html, /health still 200
D->>E: [2] POST /drain_listeners?graceful&skip_exit
Note over E: discourage new conns, keep process alive
loop [3] poll until active==0 or timeout
D->>E: GET /stats (downstream|upstream_rq_active)
E-->>D: active gauge sum
end
D->>H: [4] POST /close-lb (DRAINING -> CLOSING)
Note over H,LB: /health_check.html -> 503, HAProxy DOWN
D->>D: [5] sleep LB_BUFFER (absorb fall detect)
D->>H: [6] POST /close (CLOSING -> CLOSED)
Note over H,LB: /health -> 503, K8s endpoint removed
D-->>K: [7] done -> SIGTERM hc/backend exit
```
---
## 6. 예시와 검증 — 코드가 정말 그렇게 동작하는가
**(a) 정적 검증 — 위에서 인용한 라인이 실재하는지** (§4 검증 명령은 이 세 파일을 대상으로 실행):
```bash
# 코드 라인 검증
grep -nE "Flush|statusRecorder" apps/backend/main.go
grep -nE "func.*State|case " apps/hc/main.go
wc -l apps/hc/graceful-drain.sh
# FSM 전이 핸들러
grep -n "advance\|handleDrain\|handleDisable" apps/hc/main.go
# drain.sh awk 패턴
grep -n "awk\|downstream_rq\|upstream_rq" apps/hc/graceful-drain.sh
# 빌드 동작
cd apps/backend && go build ./... && echo "backend: OK"
cd apps/hc && go build ./... && echo "hc: OK"
```
**(b) 동작 검증 — 비대칭 응답을 직접 관측** (앵커의 핵심인 "같은 상태, endpoint별 다른 응답"이 실제로 나오는지). hc 컨테이너 안에서:
```bash
# DRAINING으로 전이 후 두 health endpoint를 동시에 찍는다
curl -s -X POST http://127.0.0.1:18180/drain # OPEN -> DRAINING
curl -s -o /dev/null -w 'haproxy=%{http_code}\n' http://127.0.0.1:18180/health_check.html
curl -s -o /dev/null -w 'ready=%{http_code}\n' http://127.0.0.1:18180/health
```
기대 출력 — DRAINING 상태에서 **두 신호가 갈린다**(이게 drain window의 증거):
```
haproxy=200 # /health_check.html: DRAINING까지 200 (LB는 아직 backend 유지)
ready=200 # /health: CLOSING까지 200 (endpoint도 아직 유지)
```
이어서 `POST /close-lb`(→CLOSING) 후 다시 찍으면 `haproxy=503, ready=200`으로 **HAProxy 신호만** 꺼진다. `POST /close`(→CLOSED) 후엔 `ready=503`까지 떨어진다. 상태가 한 칸 전이할 때마다 *신호 하나씩* 꺼지는 것을 코드가 아니라 HTTP status로 확인하는 셈이다.
---
## 7. 회상 Quiz
Q1: backend의 handleSleep에서 Shutdown() 호출 후 in-flight /sleep?seconds=60 요청은?
`http.Server.Shutdown()`은 in-flight 요청 context를 cancel하지 않는다. `r.Context().Done()`이 닫히지 않으므로 `time.After(60s)` case 완료까지 goroutine 유지. Shutdown의 300초 timeout 이내라면 정상 응답 후 종료.
Q2: step [3]에서 curl 실패 시 ACTIVE 값은?
`curl ... || true`라 STATS는 빈 문자열. awk는 매칭 라인이 없어 `END { print (sum+0) }`에서 0 출력 → ACTIVE=0이 되어 루프 조기 종료 가능. "best-effort" 설계의 한계 — Envoy 일시 응답 불가 시 active 요청이 있어도 0으로 판단할 수 있다.
Q3: hc가 graceful shutdown 없이 ListenAndServe를 직접 종료하는 이유는?
drain.sh(preStop) 완료 후 kubelet이 SIGTERM을 보내는 시점엔 이미 FSM이 CLOSED, HAProxy backend DOWN, K8s endpoint 제거 중이라 새 health check 요청이 들어올 이유가 없다. hc를 즉시 종료해도 in-flight가 없으므로 graceful 로직 불필요. backend는 `/sleep`·`/stream` in-flight 완료 보장이 필요해 Shutdown(ctx) 사용.
---
---
**한 줄 멘탈모델**: graceful termination = *한 동사(Pod 종료)를 명사 셋(대상 backend · 상태 hc FSM · 순서 drain.sh)으로 분해*하는 것. drain.sh가 명령하면 hc가 상태 전이로 신호 응답을 바꾸고, backend는 그 와중에도 in-flight를 끝까지 보호한다 — 그래서 신규 유입은 막되 진행 중 요청은 안 끊긴다.
## 핵심 정리
- 핵심 분해: **대상(backend in-flight) · 상태(hc 5-state FSM) · 순서(drain.sh 7-step)**. 세 파일은 같은 한 문제의 세 측면이고, 신호 4개(HAProxy health·K8s readiness·Envoy listener·in-flight)를 *순서대로* 끄는 것이 graceful termination이다.
- backend의 `statusRecorder.Flush()` 명시 패치는 Go embedding이 `http.Flusher.Flush()`를 promote하지 않는 함정을 우회하기 위함 — 없으면 streaming이 500으로 죽는다.
- backend는 `srv.Shutdown(ctx, 300s)`로 in-flight long request를 보호하지만, current 모드에서 HAProxy `shutdown-sessions` RST가 `r.Context().Done()`을 닫아 S1이 실패한다.
- hc는 5-state FSM으로 HAProxy health(`StateDraining`까지 200)와 K8s readiness(`StateClosing`까지 200)를 분리 제어해 drain window를 확보한다.
- drain.sh 7단계: [1] `/drain`(DRAINING) → [2] Envoy `/drain_listeners` → [3] `*_rq_active==0` 폴링 → [4] `/close-lb`(LB 503) → [5] LB_BUFFER → [6] `/close`(readiness 503) → [7] done.
## What you might be missing
- **drain_listeners는 in-flight를 보호하지 않는다.** step [2]의 `POST /drain_listeners`는 신규 연결만 discourage할 뿐, 진행 중 요청의 보호 본질은 hc가 `/health_check.html`을 200으로 유지(DRAINING)해 LB가 backend를 빼지 않는 것이다. drain_listeners가 active=0을 만들어주는 게 아니라 새 유입을 막아 폴링 곡선을 단조감소시키는 보조 장치다.
- **liveness(`/live`)와 readiness(`/health`)의 분리가 drain 중 재시작을 막는다.** `/live`는 FAULT에서만 500이라 DRAINING/CLOSING 동안 kubelet이 liveness 실패로 Pod를 죽이지 않는다. 둘을 합치면 drain window 중 강제 재시작이 발생한다.
- **reopen abort 경로엔 재투입 지연이 있다.** CLOSING→OPEN으로 abort해도 `Warning: 199` 헤더가 알리듯 HAProxy `rise 2`(=+4s) 만큼 backend가 pool에 다시 잡힐 때까지 지연된다 — abort가 즉시 트래픽 복귀를 의미하지 않는다.
- 상태표·전략 개요는 [W2 — HC FSM](gt__src-w2-hc-fsm.html), Envoy drain 내부 메커니즘은 [Envoy drain listeners](gt__src-envoy-drain-listeners.html)를 정본으로 본다.
## 관련 파일 · 참조
본 walkthrough가 라인별로 인용하는 세 소스의 스냅샷. §4 검증 명령(`grep -n`, `go build`, `wc -l`)은 이 파일들을 대상으로 실행한다.
- 📎 [backend/main.go](attachment/scenarios/50-graceful-termination/apps/backend/main.go) — long-request 핸들러(`/sleep`·`/stream`) + graceful shutdown
- 📎 [hc/main.go](attachment/scenarios/50-graceful-termination/apps/hc/main.go) — 5-state FSM health server (9 endpoints)
- 📎 [graceful-drain.sh](attachment/scenarios/50-graceful-termination/apps/hc/graceful-drain.sh) — 7-step preStop 오케스트레이션
- 개요·전략: [W2 — HC FSM](gt__src-w2-hc-fsm.html) · drain 메커니즘: [Envoy drain listeners](gt__src-envoy-drain-listeners.html)