Apps Walkthrough — backend + hc + graceful-drain.sh
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 = 보호 대상.
핵심은 비대칭이다: 같은 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.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 상태 정의
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으로 유지한다. 반면 handleReadiness는 StateClosing까지 200을 반환해 K8s endpoint를 보존한다. 상태별 응답 전략의 전체 매핑표는 W2 — HC FSM을 정본으로 참조.
응답 포맷·캐시·spec 추가 -
GET /drain/status: top-levelready/state+ sub-objectprogress(전이 진척·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 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
# [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 응답 시퀀스
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를 끝까지 보호한다 — 그래서 신규 유입은 막되 진행 중 요청은 안 끊긴다.
핵심 정리
- 핵심 분해: 대상(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 모드에서 HAProxyshutdown-sessionsRST가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헤더가 알리듯 HAProxyrise 2(=+4s) 만큼 backend가 pool에 다시 잡힐 때까지 지연된다 — abort가 즉시 트래픽 복귀를 의미하지 않는다. - 상태표·전략 개요는 W2 — HC FSM, Envoy drain 내부 메커니즘은 Envoy drain listeners를 정본으로 본다.
관련 파일 · 참조
본 walkthrough가 라인별로 인용하는 세 소스의 스냅샷. §4 검증 명령(grep -n, go build, wc -l)은 이 파일들을 대상으로 실행한다.
- 📎 backend/main.go — long-request 핸들러(
/sleep·/stream) + graceful shutdown - 📎 hc/main.go — 5-state FSM health server (9 endpoints)
- 📎 graceful-drain.sh — 7-step preStop 오케스트레이션
- 개요·전략: W2 — HC FSM · drain 메커니즘: Envoy drain listeners