🏠 목록 W2. Backend + hc + drain.sh — Go 코드 메커니즘 (출처: 홈랩 graceful termination 학습 시리즈 W2) 📄 MD 원본 🌓 테마
istiograceful-terminationgogolangdrainfsm

W2. Backend + hc + drain.sh — Go 코드 메커니즘 (출처: 홈랩 graceful termination 학습 시리즈 W2)

NOTE

머릿속에 담을 한 장: 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)에 위임한다.

시리즈 위치: W2. W1 big picture의 6 events·4 시나리오를 코드 수준으로 추적. 대응 라인별 코드 워크스루는 apps-walkthrough. 대상독자: 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 하나가 답한다.

OPENDRAININGCLOSINGCLOSEDFAULT/drain/close-lb(active=0)/close (LB_BUFFER)/reopen/reopen +4s/faultSIGTERM
그림 1. hc FSM: OPEN→DRAINING(/drain)→CLOSING(/close-lb, active=0)→CLOSED(/close). /reopen으로 abort 복귀, 어느 상태든 /fault로 강제 FAULT.

왜 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 /reopenadvance(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. FAULTfail()에 의한 비정상 강제 종료(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가 서로 다른 청중을 위해 서로 다른 타이밍에 떨어진다는 점이다.

응답 포맷·캐시·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이 되는 순간을 잡는 것이다.

drain.shhc :18180Envoy :15000HAProxyPOST /drainOPEN→DRAINING, /health_check 200/drain_listeners새 연결 거부, in-flight 유지loop: POLL 2s, TIMEOUT 120sGET /stats rq_activegauge 반환active=0 감지 (or timeout)POST /close-lbDRAINING→CLOSING, 503/health_check 503 (fall=2, ~4s)DOWN but active=0 끊을 연결 없음sleep LB_BUFFER=10sPOST /closeCLOSING→CLOSED, /health 503
그림 2. drain.sh가 hc FSM과 Envoy admin을 구동: /drain → listener drain → active 폴링 → /close-lb → LB_BUFFER → /close. active=0 보장이 무중단 핵심.

읽는 순서대로 시점이 결정된다: /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). Envoy drain_listeners 자체의 동작은 envoy drain listeners.


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)

statusRecorderhttp.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() 위임

결과

handleStreamw.(http.Flusher) 체크로 시작한다. wloggingMiddleware에서 *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 패치). 본 절은 함정의 메커니즘만 다룬다.


5. 떴는지 한 번 확인 — 상태 전이와 응답 코드 관측

멘탈 모델이 맞는지는 "전이 명령 → health 코드 변화"를 직접 관측해 검증한다. hc는 :18180, Envoy admin은 :15000.

# 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의 불변식을 확인한다.


핵심 정리

What you might be missing

이어 보기