🏠 목록 HAProxy Walkthrough — L7 offload + on-marked-down 📄 MD 원본 🌓 테마
istiograceful-terminationhaproxytls-offloadon-marked-downhomelab

HAProxy Walkthrough — L7 offload + on-marked-down

NOTE

홈랩 graceful termination 실험의 haproxy-current.cfg(143줄)를 읽는다. 단, 목적은 cfg 줄 해설이 아니라 한 문장의 멘탈모델을 세우는 것: 실험의 전부는 한 backend(443→IGW)에서 벌어지는 "누가 먼저 끊느냐"의 경합이다 — pod의 preStop이 Envoy listener를 먼저 drain하느냐, LB의 on-marked-down shutdown-sessions가 먼저 RST를 쏘느냐. 이 경합을 가능하게 만드는 단 하나의 트릭이 data 포트(30080)와 health 포트(check port 30180)의 분리다. 결론 셋: ① HAProxy cfg는 current/improved가 동일하고 개선 변수는 IGW manifest의 preStop 스크립트에 격리돼 있다, ② retries 3이 5xx를 흡수해 disruption을 감추므로 5xx + connection_err로 측정해야 한다, ③ 나머지 네 포트(80/6443/8443/9000)는 이 실험과 무관한 배경 소품이다.

대상환경 Istio 1.30 + HAProxy(systemd, homelab 203.0.113.211) · 대상독자 graceful-termination 실험을 재현·해석하려는 SRE · 범위 443→IGW backend의 drain 경합 메커니즘 (나머지 포트는 맥락용 요약) · 선행개념 HC FSM, NodePort, TLS offload vs passthrough.

명명 매핑(2026-04-26): hc FSM 상태 READY/DRAINING/DRAINED_WAIT_LB/TERMINATING/FAILED → OPEN/DRAINING/CLOSING/CLOSED/FAULT(게이트 비유). 본 문서에서 'CLOSING'은 신 명칭 = 구 DRAINED_WAIT_LB(LB에만 503 신호, Envoy는 살아 in-flight 처리). FSM 전체는 HC FSM walkthrough 참조.

⚠ 정본 cfg 파일 부재 — 이 문서가 유일한 기록

본 문서가 해석하는 haproxy-current.cfg(143줄)와 haproxy-improved.cfg는 레포 스냅샷 어디에도 실재하지 않는다(전체 디스크 검색으로 확인). 아래 블록별 인용은 정본의 부분 발췌이며, 그 발췌가 현존하는 유일한 기록이다. 따라서 인라인 fragment를 손으로 이어붙여도 완전한 143줄을 보장할 수 없고, 11번의 scp haproxy/haproxy-current.cfg ... 배포 명령은 그 파일이 다시 확보돼야 재현 가능하다.


1. 배경 — 왜 LB cfg가 graceful termination 실험의 무대인가

graceful termination의 본질 질문은 "pod가 죽을 때 진행 중(in-flight) 요청을 어떻게 안 끊는가"다. 이건 pod 혼자 답할 수 없다. pod가 SIGTERM을 받아 Envoy listener를 닫기 시작해도, 그 앞단 LB가 여전히 새 요청을 그 pod로 보내거나, 또는 너무 일찍 기존 연결을 RST로 끊으면 순단이 난다. 즉 graceful termination은 pod와 LB 사이의 타이밍 협상이고, LB의 health check·세션 관리 설정이 그 협상의 절반을 쥔다. 그래서 이 실험에서 HAProxy cfg를 읽는다.

핵심 등장인물은 셋이다.

이 셋의 상호작용을 이해하려면 HAProxy가 무엇을 보고 무엇을 하는지를 알아야 하고, 그게 이 문서다. 단 HAProxy는 다섯 포트를 운용하는데, 실험과 직접 관련된 건 443 backend 하나뿐이다(나머지는 §6 배경 요약). 그러니 다섯 포트 표를 먼저 훑어 "어느 게 무대고 어느 게 소품인지" 가른 뒤, 무대(443→IGW)로 직행한다.

포트 mode TLS 목적 실험 관련
80 http 없음 HTTPS로 301 redirect 소품
443 http offload (terminate) L7 헤더 주입 + IGW plaintext backend 무대
6443 tcp passthrough kube-apiserver (client-cert 유지) 소품
8443 tcp passthrough Istio gRPC / mTLS (end-to-end TLS) 소품
9000 http 없음 stats UI 관측

다섯 포트의 데이터 경로 — TLS가 443에서만 끊기고(평문으로 backend 진입) 6443/8443은 byte stream 그대로 통과한다. 443만 backend health check 대상이고, 거기만 on-marked-down이 붙는다.

IGW Pod:30180 health:30080 dataHAProxyclientt0 SIGTERM, preStop drainCLOSING → 503data 포트는 계속 200loop: fall 2 x inter 2s (≈4s window)GET /health_check.html503 (fail++)t0+4s 부근 server DOWNshutdown-sessions: TCP RST (code D)window 동안 in-flight는 30080 정상신규는 다른 UP 서버 roundrobin
그림 1. health 포트 503 → HAProxy가 inter 2s×fall 2 ≈ 4s 후 DOWN 마킹 → on-marked-down shutdown-sessions로 active TCP RST(log D). detection window 동안 in-flight는 data 포트로 정상 처리.

2. 핵심 메커니즘 — 두 포트 분리가 만드는 drain window

2.1 멘탈모델 anchor

하나만 기억하라: check port 30180(health)이 30080(data)과 다른 포트이기 때문에, hc FSM은 "health에는 503, data에는 200"이라는 모순된 두 답을 동시에 낼 수 있다. 그 모순이 곧 drain window다 — LB는 health 503을 보고 "이 서버 빼자"고 판단하는 동안, data 포트는 살아 있어 in-flight 요청이 계속 처리된다.

만약 health check가 data 포트(30080)를 찔렀다면? Envoy가 살아 있는 한 30080은 항상 200이다. 그러면 hc가 "곧 죽어"라고 LB에 신호할 방법이 없다 → LB는 pod가 진짜 죽는 순간(연결 거부)까지 새 요청을 계속 보냄 → drain window 0 → 순단. 포트를 쪼갰기 때문에 "곧 죽음"을 거짓 신호로 미리 알릴 수 있고, 그 거짓 신호와 진짜 죽음 사이의 간격이 in-flight를 빼낼 시간을 만든다. 이것이 이 실험 설계의 핵심 통찰이고, server 라인 한 줄(§4)에 응축돼 있다.

2.2 경합 — 누가 먼저 끊느냐

drain window가 생겨도, 그 window 안에서 두 행위자가 기존 연결을 끊을 수 있다:

graceful의 성패는 이 둘의 순서다:

preStop drain이 먼저 끝남  →  in-flight 이미 0  →  이후 LB의 RST는 무해     (graceful)
LB의 RST가 먼저 도착       →  in-flight 강제 절단  →  connection_err 발생    (순단)

따라서 설계 제약이 도출된다: preStop drain 시간 ≥ LB detection window. LB가 서버를 DOWN으로 확정하기까지 걸리는 시간(detection window) 안에 preStop이 in-flight를 비워야, RST가 떨어질 때 끊을 게 없다. detection window는 §4의 inter × fall = 4초이고, current와 improved의 차이는 바로 이 preStop 스크립트가 그 4초를 제대로 버티느냐다 — HAProxy는 양쪽에서 똑같다(§5).

2.3 타임라인

아래는 §2.1의 포트 분리와 §2.2의 경합을 하나로 합친 그림이다. 30080(data)은 RST 직전까지 계속 UP, 30180(health)만 먼저 503으로 flip된다.

t0 t0+4s detection window :30180 health 200 flip → 503 (CLOSING) 503 누적 (fall 2×inter 2s) :30080 data 200 — in-flight 정상 처리 (Envoy 살아있음) HAProxy: server DOWN on-marked-down → TCP RST (log code D) preStop drain이 먼저 끝남 in-flight==0 → 이후 RST는 무해
그림 2. 타임라인 합본. t0에 health 포트만 503으로 flip되고 data 포트는 RST 직전까지 200을 유지한다. HAProxy가 4s 후 DOWN을 확정하고 RST를 쏘기 전에 preStop drain이 in-flight를 비워야 graceful — 그래서 제약은 preStop 시간 ≥ detection window(≈4s).

3. 443 frontend/backend — 메커니즘을 떠받치는 설정

§2의 anchor를 떠받치는 실제 cfg는 443 frontend(헤더 주입·TLS offload)와 backend(health check + on-marked-down) 두 블록이다. 핵심 줄만 읽는다.

3.1 frontend: TLS offload + 헤더 주입

frontend istio-https-l7
    mode http
    bind *:443 ssl crt /etc/haproxy/certs/homelab-lb-bundle.pem alpn h2,http/1.1   # (A)
    option forwardfor                                             # (B)
    http-request set-header X-Forwarded-Proto https              # (C)
    http-request set-header X-Forwarded-Port 443
    http-request set-header X-Forwarded-Host %[req.hdr(Host)]
    default_backend istio-http-backend

여기서 443은 TLS를 terminate한다(평문이 backend로). 그 부작용이 (B)(C)의 존재 이유다 — TLS를 끊으면 backend(IGW Envoy)는 원본 client IP·프로토콜·포트·호스트를 못 본다.

3.2 backend: health check가 곧 drain 신호

backend istio-http-backend
    mode http
    balance roundrobin
    option httpchk GET /health_check.html                # (D)
    http-check expect status 200                         # (E)
    server master1 203.0.113.212:30080 check port 30180 inter 2s rise 2 fall 2 on-marked-down shutdown-sessions  # (F)
    server worker1 203.0.113.213:30080 check port 30180 inter 2s rise 2 fall 2 on-marked-down shutdown-sessions
    server worker2 203.0.113.214:30080 check port 30180 inter 2s rise 2 fall 2 on-marked-down shutdown-sessions
파라미터 의미
<ip>:30080 30080 data NodePort: IGW Envoy traffic (plaintext)
check port 30180 30180 health NodePort: hc health probe (포트 분리 = §2.1 anchor)
inter 2s 2초 health check 간격
rise 2 2회 DOWN→UP 복귀 연속 성공
fall 2 2회 UP→DOWN 마킹 연속 실패
on-marked-down shutdown-sessions DOWN 즉시 active TCP session RST (log code D = §2.2 경합)

detection window = inter × fall = 4초. hc가 503 flip 시점에서 최대 4초 후 DOWN 마킹 → 이 4초가 preStop이 in-flight를 비워야 하는 데드라인.

master1 backend가 추가된 이유(토폴로지 함정): IGW pod은 노드당 1개(required hostname anti-affinity)인데 워커가 2대뿐이라, RollingUpdate maxSurge 시 새 pod를 올릴 빈 노드가 없어 surge가 스케줄되지 못하고 deadlock된다 → master1 NoSchedule taint 제거 후 backend에도 추가해 세 번째 스케줄 슬롯 확보. externalTrafficPolicy: Local이라 master1에 IGW pod이 없으면 30080→503→HAProxy DOWN 자연 처리. 배포 토폴로지 상세는 IGW deployment / manifests walkthrough 참조.


4. defaultsretries 3이 측정을 왜곡한다

메커니즘을 알았으니, 이 실험을 잘못 측정하게 만드는 defaults 한 줄을 짚는다.

defaults
    log                     global
    option                  dontlognull      # 빈 요청 무시 (health probe 로그 폭발 방지)
    timeout connect         5s
    timeout client          1h               # streaming long-lived connection 지원
    timeout server          1h
    timeout http-request    10s              # 요청 헤더 수신 완료 타임아웃
    timeout queue           60s
    retries                 3                # (중요) 연결 실패 시 retry

retries가 disruption을 숨기는 경로(current 모드):

(1) worker1 DOWN 마킹
(2) shutdown-sessions -> in-flight TCP RST
(3) curl 입장에선 connection reset  (connection_err++)
(4) HAProxy가 동일 요청을 worker2(UP)로 재전송
(5) worker2 -> 200
(6) curl 최종 exit=0, HTTP 200       (5xx 로그엔 아무것도 안 남음)

5xx=0만 보면 retries가 감춘 순단을 놓친다. 그래서 disruption 지표는 5xx + connection_err(또는 LB termination_code=D 로그)여야 한다. SLO·모니터링 정본은 graceful termination runbook.


5. current vs improved — 변수는 LB 밖에 격리돼 있다

두 cfg 파일은 기능적으로 동일하다. diff는 주석 두 줄(line 5, backend 주석)뿐이고 나머지 모든 라인이 같다. 이건 실험 설계상 의도된 것이다 — 독립변수를 하나로 묶으려면 나머지를 고정해야 한다. HAProxy 행동(detection window, on-marked-down, retries)을 양쪽에서 똑같이 고정하고, IGW manifest의 preStop 스크립트만 바꿔 그 차이가 곧 graceful termination 개선 효과가 되게 했다. improved 레이블은 LB가 아니라 manifest에 붙는다.

이 격리 덕분에 §6의 S3 측정에서 나온 차이는 전부 preStop 탓으로 귀속된다.


6. 예시 — S3 실측과 배포·검증

6.1 S3 결과 (replicas=2, 90초 continuous + rollout restart)

current 모드:  5xx=0 (retries 흡수), connection_err=9, p50=5.7ms
improved 모드: 5xx=0,                connection_err=0, p50=5.1ms

해석: 양쪽 다 5xx=0이라 5xx만 보면 "둘 다 무중단"으로 오판한다. 진실은 connection_err에 있다 — current는 9건의 in-flight 절단(§4의 RST→retry 경로), improved는 0건. improved의 preStop이 §2.2 경합에서 LB RST보다 먼저 drain을 끝냈다는 증거다. HAProxy cfg는 동일하므로(§5) 이 9→0 차이의 출처는 preStop뿐이다.

6.2 배포 + 검증 명령

# 기존 cfg 백업
ssh homelab "ssh [email protected] \
  'sudo cp -a /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%Y%m%d-%H%M%S)'"

# current 모드 배포 (-c 로 syntax 검증 후 restart)
scp haproxy/haproxy-current.cfg homelab:/tmp/haproxy.cfg
ssh homelab "scp /tmp/haproxy.cfg [email protected]:/tmp/ && \
  ssh [email protected] 'sudo install -m 0644 /tmp/haproxy.cfg /etc/haproxy/haproxy.cfg && \
    sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl restart haproxy'"

# backend 상태 확인 (admin socket)
ssh homelab "ssh [email protected] \
  'echo show stat | sudo socat /run/haproxy/admin.sock stdio'" \
  | awk -F, '/istio-http-backend/{print $2"="$18" check="$37}'
# HAProxy stat CSV(1-index): 2=svname, 18=status, 37=check_status(last check result).
# 필드 인덱스가 버전마다 밀릴 수 있으니 의심되면 헤더로 확인:
#   echo "show stat" | socat ... | head -1 | tr ',' '\n' | cat -n
# 또는 인덱스 비의존: echo "show stat typed" | socat ... | grep -E '\.(svname|status|check_status)\.'

admin socket(/run/haproxy/admin.sock, global block의 stats socket ... level admin)은 show stat·set server·disable server로 무중단 백엔드 조작의 진입점이다.

restart vs reload: reload(SIGUSR2)는 기존 process가 현재 연결을 유지하며 새 config 적용(단 새 bind 포트·global 변경은 restart 필요). restart모든 frontend(6443 포함)가 잠시 끊긴다 — kubectl in-flight 시 실패하므로 저활동 시간대에. 이 실험 cfg는 backend server 추가뿐이라 이론상 reload 가능하지만 README는 안전하게 restart 사용(§8 마지막 함정).

Istio + hc sidecar 설치 후 예상 출력:

master1=DOWN check=L7STS   # IGW pod 없으면 DOWN (정상)
worker1=UP check=L7OK
worker2=UP check=L7OK

7. 나머지 네 포트 (배경 소품 + 관측)

실험과 무관하지만 cfg를 완결하려면 필요한 네 모드. 공통 원리는 "TLS를 끊을 권리가 LB에 있나"다 — client-cert/end-to-end mTLS가 필요하면 passthrough(tcp), 아니면 offload/http.

frontend kube-apiserver            # 6443: mTLS client-cert 유지 필요 -> passthrough
    mode tcp
    bind *:6443
    default_backend kube-apiserver
backend kube-apiserver
    mode tcp
    option tcp-check               # SYN-ACK 자체를 health check
    server master1 203.0.113.212:6443 check inter 3s rise 2 fall 3   # fall 3 -> 9s, false positive 억제

frontend http-redirect             # 80: backend 없이 직접 301
    mode http
    bind *:80
    http-request redirect scheme https code 301

frontend istio-grpc-passthrough    # 8443: end-to-end TLS/mTLS -> passthrough
    mode tcp
    bind *:8443
    default_backend istio-grpc-backend
backend istio-grpc-backend
    mode tcp
    option tcp-check
    server worker1 203.0.113.213:31443 check inter 3s rise 2 fall 3
    server worker2 203.0.113.214:31443 check inter 3s rise 2 fall 3

frontend stats                     # 9000: backend 상태 관측 UI
    mode http
    bind *:9000
    stats enable
    stats uri /
    stats refresh 10s
    stats hide-version             # 핑거프린팅 방지

global의 TLS 정책(443에 자동 적용): ssl-min-ver TLSv1.2(TLS 1.0/1.1 비활성) + no-tls-tickets(세션 티켓 비활성 → forward secrecy 보호) + ssl-default-bind-ciphers ECDHE+AESGCM:ECDHE+CHACHA20(ECDHE 기반 FS, RC4·3DES·NULL 배제).


8. 회상 quiz

Q1. alpn 순서가 중요한가? 예. HAProxy는 리스트 순서대로 우선순위를 ALPN에 실어 클라이언트에 제안. `h2,http/1.1`이면 h2 우선. `http/1.1,h2`로 바꾸면 http/1.1 우선이 되어 gRPC-web 같은 h2 의존 기능이 degrade될 수 있다.
Q2. `option dontlognull`이 없으면? health check probe(inter 2s × 3 servers ≈ 초당 1.5회)가 모두 access log에 찍혀 실제 요청 로그가 묻힌다. `dontlognull`은 페이로드 없는 TCP 연결(keep-alive probe 포함)을 로그에서 제외.
Q3. `timeout client 1h`와 `timeout http-request 10s`는 모순 아닌가? 아니다. `http-request 10s`는 **요청 헤더 완전 수신** 제한(slow HTTP attack 방어) — 헤더 수신 완료 시 종료. `client 1h`는 그 이후 **클라이언트 유휴(데이터 미전송)** 제한(스트리밍 중 읽기만 하는 시간). 연결 lifecycle의 다른 단계를 각각 제어.
Q4. check port를 30180이 아니라 data 포트 30080으로 두면? drain window가 0이 된다. Envoy가 살아 있는 한 30080은 항상 200이라 hc FSM이 "곧 죽음" 거짓 신호를 끼워 넣을 곳이 없다 → LB는 pod가 진짜 죽어 연결이 거부될 때까지 새 요청을 계속 보낸다 → in-flight를 빼낼 시간이 사라진다. 포트 분리가 곧 실험의 전제다(§2.1).

핵심 정리


What you might be missing