homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-07graceful-terminationhealth-checkhaproxyenvoygo

W2. Backend + hc + drain.sh — Go 코드 메커니즘

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)에 위임한다.

시리즈 위치: 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 하나가 답한다.

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 /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가 서로 다른 청중을 위해 서로 다른 타이밍에 떨어진다는 점이다.

  • /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이 되는 순간을 잡는 것이다.

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<br/>/health_check.html 여전히 200
    SH->>E: POST /drain_listeners?graceful&skip_exit
    Note over E: 신규 연결은 여전히 accept(discourage)<br/>HTTP/1.1 Connection: close·HTTP/2 GOAWAY로 신호, in-flight 유지<br/>실제 신규 연결 차단은 drain-time-s(기본 600s) 경과 후
    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<br/>/health_check.html -> 503
    LB->>HC: GET /health_check.html -> 503 (fall=2, ~4s)
    LB->>LB: backend DOWN (shutdown-sessions)<br/>but active=0 이므로 끊을 연결 없음
    SH->>SH: sleep LB_BUFFER=10s
    SH->>HC: POST /close
    Note over HC: CLOSING -> CLOSED<br/>/health -> 503 (K8s endpoint 제거)
    Note over SH: preStop 완료 -> kubelet SIGTERM

읽는 순서대로 시점이 결정된다: /drain으로 새 연결을 막되 health는 200 유지(아직 빼지 마라) → drain_listeners로 Envoy가 신규 연결은 여전히 받아들이되 discourage 신호(HTTP/1.1 Connection: close/HTTP/2 GOAWAY)를 보내고 in-flight는 유지(실제 신규 연결 차단은 drain-time-s 경과 후) → 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의 불변식을 확인한다.


핵심 정리

  • 앵커: 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에 있다. 코드 변경 검증은 그쪽 §1/§3 기준.

이어 보기

검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법: 공식 문서(HAProxy/Envoy/Go 스펙) 대조 + hc의 자체 구현분은 §2.1 명세를 그대로 재현한 hc-mock 레퍼런스로 homelab 클러스터 실측.

주장 판정 근거
C1. HAProxy는 health check 응답 코드만으로 UP/DOWN 판단(200=UP, 503 fall N회 후 DOWN) ✅ 문헌 확인 haproxy.com/…/health-checks
C2. health 503 → fall 2(~4s) 후 DOWN → on-marked-down shutdown-sessions로 in-flight 강제 RST ✅ 문헌 확인 discourse.haproxy.org on-marked-down-shutdown-sessions
C3. hc는 state 변수 하나 + advance(expectedFrom, to, reason) 하나로 구성, 불일치 시 409로 illegal transition 봉쇄 실측 불가 hc는 자체 Go 사이드카라 공식 문서 검증 대상 아니며 실제 소스 미제공(별도 테스트 없음)
C4. OPEN→CLOSING 직접 점프 불가, drain.sh는 반드시 /drain → /close-lb 2-step ✅ 실측 확인 T34 실측
C5. POST /reopen은 DRAINING/CLOSING→OPEN 유일한 역방향 경로. DRAINING은 즉시 OPEN, CLOSING은 rise 2(~4s) 필요 ✅ 실측 확인 haproxy.com/…/health-checks · T34 실측(hc 쪽 즉시 전이만 확인 — HAProxy rise 2 지연 자체는 harness에 HAProxy 없어 검증 범위 밖)
C6. 하나의 state가 세 endpoint 응답 코드를 결정(§2.1 표) ✅ 실측 확인 T34 실측
C7. DRAIN_TIMEOUT(120s)은 무결성이 아니라 최대 대기 시간만 보장 — timeout 도달 시 in-flight 있어도 강제 CLOSING ✅ 실측 확인 T34 실측 (환경 한계: 이 클러스터 사이드카가 downstream_rq_active 통계 패밀리 자체를 노출하지 않아, 강제 전이 시점의 active>0을 이 통계로 직접 증명하지는 못함 — hc-mock 강제 로직 자체는 이 카운터에 의존하지 않아 판정에는 영향 없음)
C8. drain_listeners?graceful&skip_exit 호출 시 “새 연결 거부 시작, in-flight 유지” ❌ 오류 — 본문 교정 envoyproxy.io draining · T27 실측
C9. drain.sh는 downstream_rq_active|upstream_rq_active gauge 합이 0이 되는 순간을 완료로 판정 ✅ 문헌 확인 · ✅ 실측 확인 envoyproxy.io http_conn_man stats · T27 실측
C10. Envoy admin은 :15000, hc는 :18180에서 청취(15000은 Istio sidecar 기본값) ✅ 문헌 확인 istio.io application-requirements
C11. Go struct embedding은 embed된 interface에 선언된 메소드만 promote(정적 결정, 런타임 무관) ✅ 문헌 확인 go.dev/ref/spec#Struct_types
C12. ResponseWriter embed는 Flush() 미promote → type assertion 실패, 명시적 Flush() 위임으로 해결 ✅ 문헌 확인 doxsey.net fixing-interface-erasure-in-go
C13. httputil.ReverseProxy도 같은 이유로 Hijacker/Pusher/Flusher를 별도 처리 ✅ 문헌 확인(최신 Go 1.20+는 http.NewResponseController로 진화 — 문서 미언급 보충 정보) github.com golang/go reverseproxy.go
C14. Health 응답에 Cache-Control: max-age=1 + ETag로 폴링 회피, 동일 상태 재요청 시 304 실측 불가 hc 자체 구현이라 소스 확인 필요
C15. GET /drain/status는 top-level ready/state + sub-object progress 계층 구조, OpenAPI는 swag CLI로 자동 생성 실측 불가 hc 자체 구현이라 소스 확인 필요

Files