--- type: src tags: [istio, graceful-termination, envoy, haproxy, test-harness, k8s] created: 2026-06-07 --- # Istio Graceful Termination 테스트 하니스 코드 워크스루 (출처: 홈랩 실험 코드 정리) > [!abstract] > 홈랩 Istio graceful-termination 실험의 `tests/` 디렉터리(5개 실행 스크립트 + 공통 라이브러리)를 **코드 레벨**로 해부한다. 이 하니스가 측정하려는 단 하나의 명제 — "in-flight 요청 drain이 LB의 backend DOWN 마킹보다 먼저 끝나는가" — 를 축으로, 각 스크립트가 "왜 그렇게 짰는가"(측정 격리, exit code 분리, bash 관용구의 macOS 3.x 호환)를 메커니즘 수준에서 설명하고 Envoy/HAProxy 동작과 연결한다. 결론: S1(current)은 502/≈8.25s로 끊김을 입증하고 S2(improved)는 drain 덕에 200/60s로 살아남으며, 5종 스크립트가 6개 이벤트 소스를 `timeline.jsonl`로 통합해 그 차이를 시간축에서 증명한다. > [!note] 정본 역할 분리 > 이 문서는 **코드/bash 관용구 정본**(스크립트가 어떻게 동작하는가). 시나리오 변수격리 매트릭스·artifacts 해석·재현 회상 Q&A는 [W5 테스트 시나리오 설계](gt__src-w5-test-scenarios.html)가 정본이다. 아래 replicas=1·05 통합 표는 "코드 구현 관점"만 남기고 설계 철학은 W5로 위임한다. > [!warning] 정본 소스 파일 부재 > 이 문서가 해부하는 `tests/{lib/common.sh, 01-baseline-long-request.sh, 02-improved-long-request.sh, 03-continuous-traffic.sh, 04-rst-capture.sh, 05-collect-timestamps.sh}`는 현재 레포 스냅샷 어디에도 실파일로 존재하지 않는다(전체 디스크 검색으로 확인). 아래 인라인 발췌(cleanup trap, Envoy poller, pull_pcap 등)는 **부분 fragment이자 유일하게 남은 기록**이며, 완전한 스크립트 전문은 아니다. ## 1. 배경: 이 하니스는 무엇을 증명하려고 존재하나 graceful termination은 "pod이 죽을 때 이미 받아 처리 중이던(in-flight) 요청을 끝까지 살려보내는가"의 문제다. K8s가 pod을 지우면 두 시계가 동시에 돈다 — 하나는 **앱이 in-flight 요청을 처리·배수(drain)하는 시계**, 다른 하나는 **상위 로드밸런서(여기선 HAProxy)가 그 backend를 죽었다고 판정하고 세션을 끊는 시계**다. 이 둘의 **순서**가 모든 것을 가른다. drain이 먼저 끝나면 LB가 끊어도 잃을 요청이 없다(안전). LB가 먼저 끊으면 아직 처리 중인 요청 위로 TCP RST가 날아가 502가 된다(끊김). 말로는 자명하지만, 실제 분산 시스템에서 이 순서를 **증명**하기는 어렵다. 두 시계는 서로 다른 호스트(앱 pod vs lb-haproxy 노드)에서 돌고, 각자 다른 로그·메트릭·패킷에 흔적을 남긴다. "끊겼다/안 끊겼다"는 curl 한 줄로 보이지만 **왜** 그 순서였는지는 6군데 증거를 하나의 시간축에 겹쳐야만 읽힌다. 이 `tests/` 하니스 전체가 바로 그 작업 — 흩어진 증거를 한 시간축으로 모아 순서의 부호를 읽는 측정 장치다. 선행 개념은 셋이다: (1) K8s pod termination 라이프사이클(preStop hook → SIGTERM → terminationGracePeriodSeconds 후 SIGKILL), (2) HAProxy active health check가 backend를 DOWN으로 마킹하는 detection window, (3) Envoy sidecar가 in-flight 요청 수를 노출하는 admin `/stats`. 이 셋이 위 "두 시계"의 구체적 구현체다. 대상독자는 이 실험을 재현·디버그하려는 SRE이며, 범위는 **스크립트 코드의 동작 원리**다(시나리오 설계 철학은 W5). ## 2. 핵심 mental model: 하나의 판정선, 두 시계, 6개 프로브 **머릿속에 담을 한 장면**: 이 하니스 전체는 결국 단 하나의 판정선을 측정하기 위한 장치다 — `active=0`(in-flight drain 완료)이 `HAProxy backend DOWN`보다 **먼저** 오느냐 **나중에** 오느냐. 먼저면 drain 성공(S2), 나중이면 끊김(S1). 5개 스크립트는 이 두 이벤트의 순서·부호를 시간축에 찍기 위한 6개 측정 프로브이고, `05`가 그것을 하나의 timeline으로 합쳐 부호를 읽어낸다. 이 한 문장에서 나머지 모든 설계가 따라 나온다. "판정선이 단 하나"이므로 측정은 그 선 양쪽 이벤트의 **타임스탬프**만 정확하면 된다 — 그래서 모든 프로브가 UTC ISO-8601로 시각을 찍고(사전순=시간순), 로그 줄 순서가 OS 스케줄링에 흔들려도 `05`가 타임스탬프 기준으로 다시 정렬한다. "두 시계의 순서"가 본질이므로 **다른 pod의 트래픽이 섞이면 인과가 깨진다** — 그래서 replicas=1 격리가 전제가 된다. "in-flight 카운트"가 판정선의 한쪽 축이므로 그 수를 노출하는 Envoy admin `/stats`(15000)가 핵심 프로브가 된다. ``` measurement target = sign of one interval ------------------------------------------------------------ drain start | clock A: |====== in-flight draining ======> active=0 clock B: |=== HAProxy detection window ===> backend DOWN | S2 (safe): active=0 THEN backend DOWN (positive gap) S1 (broken): backend DOWN while active>0 (RST over live rq) ``` 판정선의 양쪽을 찍는 데 **6개 이벤트 소스**가 동원되고, 그 소스를 만들어내는 게 5개 스크립트다. 의존 관계는: `common.sh`(공통 헬퍼·artifacts 골격) → `01`/`02`(S1/S2 본실험, in-flight 1건의 운명) → `03`(continuous load, disruption rate) → `04`(RST 패킷 증거, 01/02와 병렬) → `05`(post-test, 6 소스 → 한 timeline). 아래는 이 순서대로 코드를 해부한다. ``` tests/ ├── lib/common.sh — 공통 헬퍼 (init_artifacts, log, kctx, pick_target_pod, cleanup trap) ├── 01-baseline-long-request.sh — S1: current 모드 + /sleep?60 + pod delete → 502 입증 ├── 02-improved-long-request.sh — S2: improved 모드 + drain → 200 + FSM grep 검증 ├── 03-continuous-traffic.sh — S3: continuous load + rollout restart → disruption rate ├── 04-rst-capture.sh — S4: tcpdump RST 패킷 캡처 (S1/S2와 병렬) ├── 05-collect-timestamps.sh — post-test: 6 소스 → timeline.jsonl + intervals.json └── artifacts/// ``` ## 3. replicas=1 격리 — 왜 측정의 전제인가 (코드 관점) 판정선이 "단일 pod에 묶인 in-flight 요청의 운명"인 이상, 그 pod 하나의 인과만 깨끗이 추적돼야 한다. roundrobin 분산이 살아 있으면(replicas≥2) 삭제되는 pod의 in-flight가 다른 pod 트래픽과 섞여 인과 추적이 불가능하다 — 이것이 S1·S2·S4가 `replicas=1`을 요구하는 이유다. **코드 관점의 핵심은 스크립트가 replicas를 강제하지 않는다는 점**이다: `pick_target_pod()`이 `head -1`로 한 pod만 골라 delete할 뿐, replicas=1 보장은 운영자가 사전에 `kubectl scale`로 세팅해야 한다. 더구나 replicas≥2에서는 `head -1`이 "항상 같은 pod"을 고른다는 보장이 없어(아래 §4 참조) 격리가 이중으로 깨진다. 격리 함정의 전체 서사(S1 1·2차 미입증 → 3차 replicas=1 재현)와 변수격리 매트릭스는 [W5](gt__src-w5-test-scenarios.html#2-replicas1-vs-replicas2)에서 다룬다. ## 4. common.sh — bash 관용구의 의도 (왜 이렇게 짰나) 공통 라이브러리의 모든 관용구는 두 제약에서 나온다: **macOS 기본 bash 3.x 호환**(개발 머신이 Mac)과 **비결정적 종료 경로 대응**(시그널로 죽는 프로세스의 exit code는 못 믿는다). 이 두 렌즈로 보면 아래가 전부 일관된다. `init_artifacts `는 `tests/artifacts///`를 만들고 `ART_DIR`을 export한다. source된 스크립트가 공유 컨텍스트이므로 export 변수가 호출 스크립트에 그대로 보인다. timestamp가 초 단위라 같은 초에 두 스크립트가 시작하면 충돌 위험이 있으나, scenario 서브디렉터리가 분리돼 있어 S1·S4 동시 실행도 안전하다. `log()`는 UTC ISO-8601 + `[SCENARIO]` 태그를 stdout과 `run.log`에 `tee -a`로 동시 기록한다. 여러 배경 프로세스(log follower, poller)가 모두 같은 run.log에 흔적을 남기는 설계지만, `tee`가 서브쉘에서 돌기 때문에 로그 줄 순서는 OS 스케줄링에 의존한다 — **그래서** 정확한 시간 정렬은 `05-collect-timestamps.sh`가 타임스탬프 기준으로 다시 한다(판정선이 타임스탬프이지 줄 순서가 아니므로). `kctx()`는 `--context homelab` 문자열만 반환하는 함수로, `kubectl $(kctx) ...` 패턴으로 쓰인다. `$()`는 매번 fork+exec를 일으켜 hot loop에서는 비효율적이지만 테스트에선 무시 가능하고, context 변경 지점을 한 곳으로 모으는 이점이 더 크다. `pick_target_pod()`는 `kubectl get pod -l app=... -o name | head -1 | sed 's|^pod/||'`로 첫 pod을 고른다. kubectl 출력은 **대체로** 이름순으로 오지만 보장된 사전순은 아니다 — 정렬은 server-side 반환 순서에 의존하며 버전/캐시에 따라 흔들릴 수 있다. 엄밀한 결정성이 필요하면 `--sort-by=.metadata.name`을 붙여야 한다. replicas≥2에서는 `head -1`이 "항상 같은 pod"을 고른다는 보장이 없어 측정 격리가 깨질 수 있으므로(이것이 replicas=1 전제의 또 다른 이유), 어느 worker의 어느 pod이 선택됐는지를 실험자가 의식해야 한다. `haproxy_show_stat()`은 Mac → homelab → lb-haproxy(203.0.113.211)의 **double-hop SSH**로 `socat`을 통해 HAProxy admin socket에 직접 접근한다. 비대화식 동작을 위해 원격에 `sudo NOPASSWD` 설정이 필요하고, 실패해도 `|| true`로 진행한다(stat snapshot은 부가 데이터). cleanup trap의 핵심은 빈 배열 안전 확장이다: ```bash _CLEANUP_CMDS=() cleanup() { local cmd for cmd in "${_CLEANUP_CMDS[@]+"${_CLEANUP_CMDS[@]}"}"; do eval "$cmd" || true done } trap cleanup EXIT ``` `"${array[@]+"${array[@]}"}"`는 `set -u`(nounset)에서 빈 배열을 참조해도 unbound 오류가 나지 않게 하는 관용구다. `mapfile` 대신 `+=` append를 쓰는 것도 macOS 기본 bash 3.x 호환 때문. 배경 프로세스 PID를 `register_cleanup "kill $PID 2>/dev/null || true"`로 등록하면 정상 종료·실패·Ctrl-C 어느 경로든 정리된다 — 비결정적 종료 경로에서도 poller/follower 좀비를 안 남긴다. ## 5. S1 (01-baseline-long-request.sh) — current 모드 끊김 입증 목적: current 모드에서 in-flight long request가 끊김(expected_failure=true)을 입증. 즉 §2 판정선에서 **LB DOWN이 active=0보다 먼저** 오는 쪽을 의도적으로 재현한다. 1. **Pre-flight (이중 체크)**: ① Deployment의 `metadata.labels.mode != "current"`면 즉시 exit 1 (모드 오염 방지), ② `READY_COUNT -lt 2` 등 deployment 정상 가동 확인. 두 검사는 목적이 다르다 — mode 라벨은 "current/improved 중 어느 manifest가 떠 있나"를, READY_COUNT는 "측정 가능한 상태로 떠 있나"를 본다. **단, 어느 쪽도 replicas=1을 강제하진 않는다**(격리는 운영자 책임). S1·S2는 변수 오염 위험이 커 엄격히 실패시키고(S3는 경고만). READY_COUNT 체크의 의미와 한계는 [W5 회상 Q&A](gt__src-w5-test-scenarios.html)에서 더 다룬다. 2. **Log followers**: hc·istio-proxy 컨테이너 로그를 `--follow --tail=0`으로 백그라운드 캡처(`hc.log`, `envoy.log`). `tail=0`이라 시작 이전 로그는 제외. 3. **Long request 발사**: 서브쉘로 `curl /sleep?seconds=60`을 백그라운드 실행하고 `echo "exit=$?"`를 curl.out에 append. `wait $PID`의 반환값은 프로세스가 signal로 죽으면 불안정하므로, exit code를 파일에 직접 기록해 신뢰성을 확보(비결정적 종료 경로 대응). 4. **T+5s pod delete**: `--grace-period=210 --wait=false`. grace-period는 Deployment의 `terminationGracePeriodSeconds: 210`과 일치(아래 correction 참고), `--wait=false`로 즉시 반환해 curl 흐름으로 복귀. 5. **Summary**: HAProxy stat before/after diff를 `awk -F, '{print $1","$2","$18}'`로 추출(컬럼 18 = status). S1 예상 결과: `curl_http_code=502, time_total≈8.25s`. 메커니즘 체인 — T+5s delete → preStop hc가 즉시 `/health_check.html=503` → HAProxy detection window(`inter 2s × fall 2 = 4s`) 후 backend DOWN → `on-marked-down shutdown-sessions` → in-flight TCP RST → retry pool에 살아있는 backend 없어 502. `inter 2s`/`fall 2`/`on-marked-down shutdown-sessions` 값은 HAProxy 설정의 http-backend server 라인에서 나온다 — 출처와 detection window 산식은 [HAProxy walkthrough](gt__src-haproxy-walkthrough.html)에 정의돼 있다. **타이밍 산식**: curl 발사(T+0) → T+5 delete → 503 flip 직후 detection window ~4s → ~T+9 DOWN 마킹 → 즉시 RST. curl 입장의 `time_total≈8.25s`는 발사부터 RST까지의 벽시계 시간으로, "delete까지 5s + DOWN 감지·RST까지 ~3~4s" 합과 일치한다(probe 위상에 따라 ±2s). ```mermaid flowchart LR T0["T+0
curl /sleep?60"] --> T5["T+5s
pod delete
--grace-period=210"] T5 --> P["preStop hc
503 flip"] P --> D["detection window
inter 2s x fall 2 = 4s"] D --> T9["~T+9s
backend DOWN"] T9 --> RST["on-marked-down
shutdown-sessions
TCP RST"] RST --> E["curl 502
time_total ~8.25s"] ``` **FSM 상태명 매핑** — 2026-04-26에 게이트 비유로 개명됐고 옛 artifacts는 옛 명칭을 보존하므로, 로그/artifacts 해석 시 다음 대응을 의식해야 한다: | 옛 명칭 (artifacts) | 새 명칭 (현재 코드/로그) | |---|---| | READY | OPEN | | DRAINING | DRAINING | | DRAINED_WAIT_LB | CLOSING | | TERMINATING | CLOSED | | FAILED | FAULT | > [!correction] kubectl delete pod의 grace-period 기본값 > 원본 회상 Q1의 "kubectl delete pod 기본값은 terminationGracePeriodSeconds(210s)를 따른다"는 표현은 부정확하다. `kubectl delete pod`의 클라이언트 측 기본 grace period는 spec 값이 아니라 **30초로 하드코딩**돼 있다(`--grace-period`를 생략하면 kubectl이 30을 보냄). spec의 `terminationGracePeriodSeconds`는 grace period를 **명시하지 않은 다른 경로**(예: API로 직접 삭제, controller의 pod 교체)에서 적용된다. 그래서 이 스크립트가 `--grace-period=210`을 **명시**하는 것이다 — 명시하지 않으면 30초로 잘려 drain이 끝나기 전에 SIGKILL이 나간다. ## 6. S2 (02-improved-long-request.sh) — improved 모드 보호 입증 S2는 §2 판정선에서 **active=0이 LB DOWN보다 먼저** 오는 쪽, 즉 보호 성공을 입증한다. S1과 골격은 같고 3가지가 다르다. **차이 1**: pre-flight의 mode 라벨 체크가 `mode != "improved"`면 exit 1 (READY_COUNT deployment-health 체크는 S1과 동일). **차이 2 — Envoy active-request poller**: 판정선 한쪽 축(active count)을 실시간으로 찍는 프로브다. pod이 사라질 때까지 2초 간격으로 Envoy admin API를 폴링한다. ```bash ( while kubectl $(kctx) -n service-a get pod "${TARGET}" >/dev/null 2>&1; do STAT="$(kubectl $(kctx) exec -n service-a "${TARGET}" -c istio-proxy -- \ curl -s 'http://127.0.0.1:15000/stats?filter=downstream_rq_active%7Cupstream_rq_active' \ 2>/dev/null || echo 'exec-failed')" printf '%s\t%s\n' "$(date -u +%FT%TZ)" "$STAT" >> "${ART_DIR}/envoy-active.tsv" sleep 2 done ) & ``` `%7C`는 URL-encoded `|`. 폴링 대상은 **Envoy admin(istio-proxy 컨테이너 내부 `127.0.0.1:15000`, localhost-only bind)**의 `/stats`다. 15000은 sidecar admin 포트로 localhost에만 바인드되므로 메시·외부에서 직접 닿지 않고, 그래서 `kubectl exec`로 컨테이너 안에 들어가야만 접근된다. 메트릭 수집용 포트(15090 merged prometheus, 15020 agent merged metrics·probe)와 혼동하지 말 것 — 15000은 진단·디버그용 admin이다. `downstream_rq_active`(클라이언트→Envoy in-flight)와 `upstream_rq_active`(Envoy→backend in-flight)를 따로 노출하며, 이 tsv가 `05`의 `envoy_active_count` 이벤트 소스다. **차이 3 — FSM 전이 검증**: `grep 'event=transition' hc.log`로 전이 타임라인을 뽑고, `from=CLOSING to=CLOSED` 전이가 hc.log에 있고 동시에 http_code=200이어야 `validation_ok=true`. 두 조건 중 하나라도 실패하면 exit 2. exit 1(pre-flight 환경 문제)과 exit 2(graceful drain 미작동)를 구분해 CI에서 원인을 분리할 수 있게 한 설계 — "환경이 잘못됐다"와 "drain이 안 됐다"는 다른 버그이고, exit code로 갈라야 자동화에서 분기된다. S2 예상 결과: `200 / 60.008s`. drain.sh가 active=0까지 `/health_check.html=200`을 유지해 HAProxy가 backend를 DOWN 마킹하지 않으므로 60초 요청이 끝까지 살아남는다. 즉 §2 판정선이 양수(active=0 → 그 후 DOWN)로 찍힌다. ## 7. S3 (03-continuous-traffic.sh) — rolling update disruption rate S1/S2가 in-flight **1건**의 운명을 본다면, S3는 **지속 부하** 하에서 rolling update의 총 disruption rate를 본다(판정선의 집계 버전). continuous load 중 `kubectl rollout restart`를 걸어 5xx + connection error를 측정한다. 부하 도구는 `hey → wrk → curl-fallback` 우선순위로 탐지한다. 각자 한계가 다르다: - **hey**: `--resolve`/`--cacert` 없음 → IP 직접 URL + `-host` 헤더 + `-insecure`. - **wrk**: `--resolve` 없음 → Lua로 Host 헤더 주입. - **curl-fallback**: `--resolve`+`--cacert`로 TLS 검증은 정확하나, 20 loop가 serial이라 실효 throughput이 낮음. warm-up 30s 후 `rollout restart` 트리거 + `rollout status --timeout=300s`를 foreground 대기(이때 부하 generator는 백그라운드 유지). **S3의 deadlock 함정**: anti-affinity `required` + `maxUnavailable=0` + `replicas=N_workers`면 rollout이 데드락한다. 새 ReplicaSet의 pod를 스케줄할 빈 노드가 없고(모든 노드가 점유), 기존 pod를 먼저 죽일 수도 없다(maxUnavailable=0). 홈랩에서는 master1 untaint + HAProxy backend 추가로 여유 노드를 만들어 해소했다. python3가 있으면 인라인 Python으로 `haproxy-stat-timeline.csv`를 파싱해 worker DOWN 구간을 interval 배열로 계산(`istio` in pxname + `worker` in svname 필터, 연속 DOWN bucket 병합). ## 8. S4 (04-rst-capture.sh) — RST 패킷 캡처 S4는 판정선의 "끊김"을 **패킷 증거**로 못박는 프로브다. lb-haproxy, worker1, worker2에서 동시에 tcpdump를 돌려 TCP RST를 pcap으로 저장하고, S1/S2와 병렬 실행해 끊김이 관찰된 그 시점의 패킷 증거를 확보한다. 각 호스트마다 `sudo -n true`를 먼저 확인하고, 3곳 모두 실패해야 graceful skip(exit 0). pcap pull은 base64 우선, dd fallback: ```bash pull_pcap() { if b64="$(eval "${ssh_cmd}" "\"base64 ${remote_path}\"")"; then printf '%s' "$b64" | base64 -d > "${local_path}"; return 0 fi eval "${ssh_cmd}" "\"sudo dd if=${remote_path}\"" > "${local_path}" } ``` binary pcap을 SSH stdout으로 직송하면 SSH multiplex나 터미널 제어 문자가 바이트를 오염시킬 수 있다. base64 → decode가 안전하고, base64 명령이 없는 환경을 위해 dd를 둔다. 디코딩은 로컬 tcpdump/tshark → 둘 다 없으면 `ssh homelab "tcpdump -r /dev/stdin" < pcap` 원격 디코딩. ## 9. S5 (05-collect-timestamps.sh) — 6 소스 타임라인 통합 (판정선을 읽는 단계) 여기서 판정선이 실제로 읽힌다. 6개 이벤트 소스를 공통 시간축으로 정렬해 `timeline.jsonl`을 만들고, 거기서 핵심 구간(intervals)을 뽑는다: | 이벤트 | 소스 | 파싱 | |---|---|---| | `hc_state` | hc.log | `event=transition` 줄 → ts/from/to 정규식 | | `envoy_drain_listeners` | envoy.log | `drain_listeners` 포함 줄 | | `envoy_active_count` | envoy-active.tsv | ISO-ts + downstream + upstream | | `haproxy_backend_down/up` | haproxy-stat-timeline.csv | snapshot 경계 diff | | `tcp_rst` | rst-*.pcap | tcpdump/tshark 파싱 | | `k8s_pod_terminated` | kubectl-events.txt | `service-a-igw` + Kill/Terminat 필터 | 핵심 interval 계산에서 `active_zero` 조건은 `downstream + upstream == 0`이다. 둘 중 하나만 0인 과도 상태(upstream은 정리됐으나 downstream이 남음)를 active=0으로 오판하지 않기 위해서다 — 판정선 한쪽 축의 정의를 엄격히 한 것. python3가 없으면 awk fallback으로 전환하되, `sort -t'"' -k4,4`로 `"ts":"..."` 4번째 필드 기준 정렬한다(ISO-8601은 사전순=시간순이라 정확). 단 intervals.json은 null로 채우고 pcap은 tshark fallback 없이 tcpdump만 시도한다. **통합 결과 읽는 법** — `timeline.jsonl`은 한 줄당 하나의 정규화된 이벤트(JSON Lines)다: ```jsonl {"ts":"2026-05-31T07:12:34Z","event":"hc_state","from":"OPEN","to":"DRAINING"} {"ts":"2026-05-31T07:12:36Z","event":"envoy_drain_listeners"} {"ts":"2026-05-31T07:12:51Z","event":"envoy_active_count","downstream":0,"upstream":0} {"ts":"2026-05-31T07:12:53Z","event":"haproxy_backend_down","svname":"worker2"} {"ts":"2026-05-31T07:12:53Z","event":"tcp_rst","host":"worker2"} ``` `intervals.json`은 이 timeline에서 뽑은 핵심 구간(초)이다. current(S1)에서는 끊김이, improved(S2)에서는 안전 순서가 드러난다: ```json { "drain_to_active_zero": 17.2, "active_zero_to_lb_down": 2.1, "lb_down_to_first_rst": 0.0 } ``` S2(improved)는 `drain → active=0 → (그 후에야) HAProxy DOWN` 순서라 `active_zero_to_lb_down`이 양수여서 in-flight가 보호되고, S1(current)은 active>0인 상태에서 DOWN+RST가 나 `lb_down_to_first_rst≈0`으로 끊김이 찍힌다. 이 세 간격의 부호·순서가 "drain이 작동했는가"의 판정선이다 — 바로 §2 mental model이 숫자로 환원된 형태다. 두 경로의 이벤트 순서를 나란히 두면 판정선이 한눈에 들어온다 — current는 LB DOWN과 RST가 active>0 위에서 겹치고, improved는 active=0이 LB DOWN보다 먼저 온다: ```mermaid flowchart TB subgraph S1["S1 current — broken"] direction LR A1["drain start"] --> B1["LB DOWN
(active still > 0)"] B1 --> C1["first RST
lb_down_to_first_rst ~= 0"] end subgraph S2["S2 improved — protected"] direction LR A2["drain start"] --> B2["active = 0
(rq drained)"] B2 --> C2["LB DOWN
active_zero_to_lb_down > 0"] C2 --> D2["no in-flight RST"] end ``` ## 10. 검증·재현 ```bash # S1: 502 재현 kubectl --context homelab -n service-a scale deploy/service-a-igw --replicas=1 bash tests/01-baseline-long-request.sh # S2: 200 + FSM 전이 검증 kubectl --context homelab -n service-a scale deploy/service-a-igw --replicas=1 bash tests/02-improved-long-request.sh # S3: continuous + rollout restart (replicas=2, master1 untaint) bash tests/03-continuous-traffic.sh improved # S4: RST 캡처 (별도 터미널) bash tests/04-rst-capture.sh S1-current 120 # Post-test: 타임라인 통합 bash tests/05-collect-timestamps.sh tests/artifacts/// ``` 기대 판정: S1은 `intervals.json`의 `lb_down_to_first_rst≈0` + curl 502/≈8.25s로 "active>0 위에 RST"가 찍히고, S2는 `active_zero_to_lb_down`이 양수 + curl 200/60.008s + hc.log에 `from=CLOSING to=CLOSED` 전이가 떠야 성공이다. ## 핵심 정리 - 하니스 전체의 측정 대상은 **단 하나의 판정선** — `active=0`이 `HAProxy backend DOWN`보다 먼저(S2 보호) 오나 나중에(S1 끊김) 오나. 5개 스크립트 = 6개 프로브, `05`가 한 timeline으로 합쳐 부호를 읽음. - replicas=1 격리는 "in-flight 요청의 운명"을 단일 pod 인과로 추적하기 위한 필수 전제. 스크립트는 강제하지 않으므로 운영자가 사전에 scale. S3만 disruption rate 목적상 replicas≥2. - pre-flight는 mode 라벨 체크 + READY_COUNT deployment-health 체크의 이중 구조. exit code 분리(1=pre-flight, 2=validation 실패)와 `expected_failure`/`validation_ok` 이중 검증으로 환경 문제와 drain 미작동을 구분. - bash 관용구(빈 배열 안전 확장, exit code 파일 기록, cleanup trap)는 모두 macOS bash 3.x 호환 + 비결정적 종료 경로 대응이 목적. - `--grace-period=210`을 명시하는 이유는 kubectl 기본값 30초가 drain을 자르기 때문(spec의 terminationGracePeriodSeconds는 명시 없는 경로에만 적용). ## What you might be missing - **`time_total≈8.25s`는 결정값이 아니다.** HAProxy detection은 probe 위상(phase)에 의존한다 — 503 flip이 직전 probe 직후에 떨어지면 거의 풀 4s를 기다리고, 직전이면 거의 0s에 잡힌다. 그래서 8.25s는 "delete 5s + DOWN 감지 ~3.25s"의 한 표본일 뿐 ±2s 변동이 정상이다. - **15000 vs 15090/15020 혼동.** S2 poller가 15000 admin을 쓰는 건 `_active` 게이지(in-flight 카운트)가 prometheus merged metrics(15090/15020)에는 그 형태로 안 나오기 때문이다. admin은 localhost-only라 `exec` 필수 — 메시 네트워크로 긁으려다 실패하는 함정. - **`head -1` 비결정성.** kubectl `-o name` 순서는 보장된 사전순이 아니다. replicas≥2에서 head -1을 믿으면 매 실행 다른 pod을 delete할 수 있어 격리가 깨진다. 엄밀하게는 `--sort-by=.metadata.name`. - **FSM 명칭 시점 의존.** 옛 artifacts(READY/DRAINED_WAIT_LB/...)와 현재 로그(OPEN/CLOSING/...)를 섞어 grep하면 `from=CLOSING to=CLOSED` 검증이 옛 로그에선 매칭되지 않는다. 위 매핑 표로 정규화 후 비교할 것. ## 이어 보기 - 설계 철학·시나리오 변수격리 매트릭스·회상 Q&A: [W5 테스트 시나리오](gt__src-w5-test-scenarios.html) - 재현 quickstart: [graceful-termination quickstart](gt__src-quickstart.html) - drain·listener 메커니즘 세부: [Envoy drain listeners](gt__src-envoy-drain-listeners.html) - HAProxy on-marked-down·detection window 출처: [HAProxy walkthrough](gt__src-haproxy-walkthrough.html) - 허브: [Graceful Termination MOC](gt__MOC-graceful-termination.html)