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