--- 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)