🏠 목록 Apps Walkthrough — backend + hc + graceful-drain.sh 📄 MD 원본 🌓 테마
istiograceful-terminationgogolangdrainhomelab

Apps Walkthrough — backend + hc + graceful-drain.sh

NOTE

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, Envoy drain 메커니즘 상세는 Envoy drain listeners에 둔다. 본 문서는 라인별 코드 추적에 집중한다.

명명 매핑(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을 먼저 본다. 대상 독자: 이 세 신호를 순서대로 끄는 코드가 실제로 어떤 줄에서 그 일을 하는지 확인하려는 SRE/DevOps.


2. 아키텍처 — 분리된 신호 = 분리된 코드

멘탈모델 앵커 (이 한 그림만 머리에 남겨라): 세 파일은 graceful termination이라는 한 동사를 명사 세 개로 쪼갠 것이다 — 대상(backend) · 상태(hc FSM) · 순서(drain.sh). drain.sh가 "지금 readiness를 꺼라"라고 명령하면, hc FSM이 그 명령을 상태 전이로 받아 health endpoint 응답을 바꾸고, backend는 그 와중에도 in-flight 요청을 끝까지 보호한다. 즉 drain.sh = 지휘자, hc = 신호 패널, backend = 보호 대상.

drain.sh (preStop) 순서 — orchestration [1] POST /drain [2] /drain_listeners [3] poll rq==0 [4] POST /close-lb [6] POST /close hc FSM — 상태 which signal when OPEN→DRAINING →CLOSING→CLOSED /health_check.html HAProxy probe /health K8s readiness Envoy admin :15000 backend — 대상 in-flight to drain /sleep /stream long-req srv.Shutdown ctx 300s [1]→FSM [2]→Envoy [3] GET /stats discourage new conn protected by
그림 1. 세 박스의 협업. drain.sh가 순서를 만들고(orchestration), hc FSM이 같은 상태에서 health/readiness 신호를 서로 다른 타이밍에 끄며(상태), backend의 long-req가 srv.Shutdown으로 보호받는다(대상) — 이 시차가 drain window를 만든다.

핵심은 비대칭이다: 같은 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() 패치

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

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 패턴

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

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

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.ErrServerClosedShutdownListenAndServe가 반환하는 정상 종료 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 상태 정의

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)

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 범위

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)와 분리한다.

// 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으로 유지한다. 반면 handleReadinessStateClosing까지 200을 반환해 K8s endpoint를 보존한다. 상태별 응답 전략의 전체 매핑표는 W2 — HC FSM을 정본으로 참조.

응답 포맷·캐시·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() — 리스너 설정

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 + 환경변수 기본값

#!/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 의미

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

# [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 참조.


L43~73: [3] Envoy stats 폴링 — awk 합산 로직

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

# [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 응답 시퀀스

kubeletdrain.shhc FSMEnvoy adminHAProxy/K8sSIGTERM → preStop[1] /drain (OPEN→DRAINING)/health_check.html, /health 200[2] /drain_listeners신규 conn discourage, 프로세스 유지loop: [3] poll until active==0GET /stats rq_activeactive gauge sum[4] /close-lb (→CLOSING)/health_check.html → 503, HAProxy DOWN[5] sleep LB_BUFFER[6] /close (→CLOSED)/health → 503, endpoint 제거[7] done → SIGTERM exit
그림 1. preStop drain.sh의 7단계: drain→listener drain→active 폴링→close-lb→LB_BUFFER→close→exit. LB가 먼저 down되고 in-flight가 빠진 뒤 endpoint 제거.

6. 예시와 검증 — 코드가 정말 그렇게 동작하는가

(a) 정적 검증 — 위에서 인용한 라인이 실재하는지 (§4 검증 명령은 이 세 파일을 대상으로 실행):

# 코드 라인 검증
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 컨테이너 안에서:

# 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를 끝까지 보호한다 — 그래서 신규 유입은 막되 진행 중 요청은 안 끊긴다.

핵심 정리

What you might be missing

관련 파일 · 참조

본 walkthrough가 라인별로 인용하는 세 소스의 스냅샷. §4 검증 명령(grep -n, go build, wc -l)은 이 파일들을 대상으로 실행한다.