---
title: W5. 테스트 시나리오 4종 설계 의도 + artifacts 해석
date: 2026-06-07
type: guide
domain: istio
tags: [graceful-termination, testing, envoy, haproxy, http2]
---
> [!abstract]
> graceful termination을 "증명"하려면 먼저 **disruption을 측정 가능한 양으로 만드는 실험 설계**가 필요하다. 이 문서는 그 설계를 다룬다 — 4개 시나리오(S1~S4)가 각각 어떤 단일 변수를 격리하고, 실행 후 artifacts(curl/pcap/timeline)를 어떻게 읽는지. 결론: 측정값을 믿으려면 **① 라우팅 고정(replicas=1)** 과 **② 프로토콜별 RST 가면(HTTP/2 exit 92 vs HTTP/1.1 exit 18/502)을 모두 집계**, 이 두 통제가 선행돼야 한다.
> [!warning] 이 문서가 검증하는 체인은 Istio의 기본 아키텍처가 아니다
> 이 문서(및 W1~W6 시리즈)가 다루는 preStop hc → HAProxy DOWN → Envoy drain 체인은 Istio 1.30 sidecar 모드의 기본 graceful termination 아키텍처가 아니라, HAProxy를 엣지 LB로 두고 그 앞단에서 커스텀 health-check 사이드카가 HAProxy backend 상태를 조작하는 **자체 제작 테스트 하네스**다. 실제 Istio 기본 프로필의 graceful termination은 istio-agent(pilot-agent)가 SIGTERM 수신 → Envoy admin API(drain_listeners) 호출 → terminationDrainDuration(기본 5s) 대기 → quitquitquit 종료의 경로를 따르며, 외부 HAProxy나 별도 hc 사이드카가 필요 없다. 따라서 이 시리즈의 실측 수치(8.25s, fall 2 등)를 실제 Istio 사이드카 종료 지연에 그대로 대응시켜서는 안 되고, 축1/축2라는 **측정 설계 원칙 자체만** 재사용 가능하다.
> **대상**: graceful termination 시리즈 W5. **선행**: W1 big-picture(전체 체인), W2 hc FSM. **각도**: 스크립트의 *왜*(설계 의도) + artifacts *읽는 법*. **대응 코드 워크스루**: [W5 tests walkthrough](/docs/istio/graceful-termination/tests-walkthrough/)(스크립트 01~05 라인별 해설). **FSM 명명**: `OPEN/DRAINING/CLOSING/CLOSED/FAULT`, 신규 `POST /reopen`.
---
## 1. 배경 — 왜 "그냥 테스트"가 아니라 "실험 설계"인가
graceful termination이 동작하는지 보려면 단순히 "요청 던지고 끊기나 본다"로는 부족하다. 끊김이 보이면 그게 **graceful 메커니즘의 실패**인지, 아니면 **측정 환경이 만든 artifact**인지 구분할 수 없기 때문이다. 두 가지가 실제 거동을 가린다.
- **라우팅이 분산되면** 한 pod을 죽여도 traffic이 다른 pod으로 새서 "끊김 0"이 나온다 — 이건 graceful이 잘된 게 아니라 *그 pod을 한 번도 안 때린 것*이다.
- **하나의 disruption이 프로토콜에 따라 다른 얼굴**을 한다. 같은 TCP RST가 HTTP/1.1에선 502로, HTTP/2에선 connection-level exit으로 나타난다. 5xx만 세면 후자를 놓쳐 "끊김 없음"으로 오판한다.
따라서 이 시리즈의 핵심 질문은 "drain이 되나?"가 아니라 **"내가 보는 숫자가 진짜 거동인가, 측정 artifact인가?"** 다. 4개 시나리오는 그 신뢰성을 확보하려고 각자 변수 하나만 흔들고 나머지를 고정한다. (전체 종료 체인 — preStop hc → HAProxy DOWN → Envoy drain → grace period — 의 메커니즘은 [W1 big-picture](/docs/istio/graceful-termination/w1-big-picture/), [W2 hc FSM](/docs/istio/graceful-termination/w2-hc-fsm/) 참조. 본 문서는 그 위에서 *어떻게 측정하나*를 다룬다.)
---
## 2. 멘탈모델 앵커 — "단 하나의 disruption을 측정 가능하게 만든다"
머릿속에 둘 하나의 그림: **모든 시나리오는 disruption 하나를 격리·관측 가능하게 만드는 실험이다.** 이 앵커에서 두 축이 따라 나온다.
```
측정 가능한 disruption
▲
┌───────────────┴───────────────┐
축1: 라우팅 고정 축2: 집계 완전성
(replicas=1) (5xx + conn err)
"그 pod에 귀속" "RST 가면 모두 카운트"
│ │
분산되면 traffic이 같은 RST가 HTTP/2는
샌다 -> 끊김 0 오판 exit92, HTTP/1.1은 502
```
- **축1 (라우팅 고정)**: replicas=1이어야 disruption을 *특정 pod의 종료*에 귀속시킬 수 있다. multi-replica + roundrobin이면 죽인 pod 대신 살아있는 pod이 응답해 "끊김 없음"이 측정 artifact로 찍힌다.
- **축2 (집계 완전성)**: 단일 TCP RST가 프로토콜·시나리오에 따라 5xx에 잡히기도(S1) 빠지기도(S4) 한다. 그래서 disruption 집계는 5xx와 connection error 양쪽을 모두 세야 한다.
이 두 축을 통제하지 못하면 실험은 진짜 거동이 아니라 측정 artifact를 본다. §3~§4가 각 축을 메커니즘 수준으로 푼다.
### 2.1 변수 격리 매트릭스
각 시나리오는 관심 변수 하나만 바꾸고 나머지는 상수 고정하거나 의도적으로 배제한다.
| # | 이름 | 잡는 변수 | 잡지 못하는 변수 |
|---|---|---|---|
| S1 | `01-baseline-long-request.sh` | 단일 in-flight HTTP 요청 RST 시점 (current vs improved) | 다중 동시 conn 거동, streaming chunk 손실 |
| S2 | `02-improved-long-request.sh` | improved drain.sh active 폴링 FSM 전이 타이밍 | LB 동작 (improved에서 backend는 60s 내내 UP) |
| S3 | `03-continuous-traffic.sh` | continuous load + rollout 시 disruption rate (5xx + conn err) | 단일 long-request, streaming chunk 손실 |
| S4 | `04-rst-capture.sh` | streaming chunk 손실 시점 + TCP RST 패킷 가시화 | non-streaming (S1과 상호 보완) |
**설계 원칙 (왜 S1/S2를 쌍으로)**: S1·S2는 동일한 `/sleep?seconds=60`을 보내되 S1은 `expected_failure=true`(baseline: 끊김 확인), S2는 `expected_failure=false`(validation: 안 끊김 확인). 입력을 똑같이 두고 **current vs improved drain.sh** 만 바꾸므로, 결과의 차이가 곧 개선의 효과다 — 변수 하나만 다르게 만드는 격리 설계의 교과서적 적용이다.
---
## 3. 축1 메커니즘 — replicas=1이 왜 전제인가
S1·S2·S4가 replicas=1을 강제하는 이유는 **HAProxy `balance roundrobin`의 traffic isolation 효과** 때문이다. replica가 2개면 죽인 pod의 끊김이 살아있는 pod의 정상 응답에 묻힌다.
```
replicas=2 함정 (S1 1·2차 시도):
curl -> HAProxy(roundrobin) -> worker1(pod-A) [삭제 대상]
-> worker2(pod-B) [정상]
pod-A delete -> 다음 요청이 pod-B로 -> 200/60s -> 가설 미입증
replicas=1 해법 (S1 3차 시도):
curl -> HAProxy -> worker1(pod-A) [유일 endpoint]
pod-A delete -> preStop hc 503 flip -> HAProxy fall 2(~4s) 감지
-> DOWN 마킹 -> shutdown-sessions RST -> 502/8.25s -> 가설 입증
```
`200/60s`(끊김 없음)는 graceful이 잘돼서가 아니라 **curl이 죽는 pod을 한 번도 안 때렸기 때문**이다. 이게 축1을 통제하지 않으면 생기는 측정 artifact의 전형이다. replicas=1로 endpoint를 유일하게 만들어야 `502/8.25s`가 재현된다. 이 masking 효과 자체는 homelab 재현 테스트로도 실증됐다 — 동일한 강제 pod 삭제를 replicas=2에서 돌리면 60회 요청 중 논-200 0건(완전 masking), replicas=1에서는 60회 중 8건 실패로 뚜렷이 갈렸다([T35 실측](files/verify/T35/result.txt)).
### 3.1 8.25s는 다단계 지연의 누적
delete 직후 즉시 DOWN이 아니다. preStop hook이 hc를 503으로 flip → HAProxy가 `inter 2s`/`fall 2`(~4s)로 backend를 감지 → DOWN 마킹이라는 **다단계 지연**을 거친다. 8.25s 타이밍은 이 체인이 누적된 결과다(상세 메커니즘: [W1 big-picture](/docs/istio/graceful-termination/w1-big-picture/)). 이 체인을 모르면 "왜 즉시 502가 아니고 8초 뒤냐"가 설명되지 않는다.
### 3.2 예외 S3와 부수 효과
S3만 replicas=2(master1 untaint 후 실질 3)를 쓰는 이유는 **운영 rollout 시나리오** 모사다 — single-pod에서 `kubectl rollout restart`는 의미가 없다. 즉 S3는 "한 pod 격리"가 아니라 "rolling update 중 disruption rate"를 측정하므로 일부러 분산을 둔다.
**replicas=1 부수 효과**: ReplicaSet 컨트롤러는 `pod.metadata.deletionTimestamp != nil`이면 `IsPodActive=false`로 즉시 새 pod을 schedule한다. grace period 210s 동안 target이 terminating이어도 동시에 새 pod이 생성됨 — S4 current artifacts에서 target terminating ~1초 후 worker2에 신규 pod 생성을 events 로그로 확인 가능하다.
> [!warning] 210s는 자동 상속값이 아님
> 이 210s는 스크립트가 `kubectl delete --grace-period=210`을 **명시**했기 때문이지, pod spec의 `terminationGracePeriodSeconds`가 자동 상속되는 값이 아니다. 다만 메커니즘은 "kubectl이 기존 값을 무시하고 강제로 30초로 잘라 보낸다"가 아니다 — `--grace-period` 미지정(기본값 -1)은 "해당 리소스의 기본값을 사용"하라는 뜻이고, 그 기본값이 바로 Pod의 `spec.terminationGracePeriodSeconds` 필드다. 이 필드를 pod manifest에 아예 적지 않으면 API 서버가 생성 시점에 30초로 채워 넣으므로 결과적으로 30초가 되는 것뿐이다. 즉 spec에 `terminationGracePeriodSeconds: 210`을 미리 박아 두면 `kubectl delete`에 `--grace-period`를 안 줘도 210초가 그대로 적용된다 — 이 스크립트가 매번 CLI로 `--grace-period=210`을 명시하는 이유는 pod manifest 쪽에 그 필드를 넣지 않고 delete 시점 플래그로만 값을 주는 방식을 택했기 때문이다([tests walkthrough](/docs/istio/graceful-termination/tests-walkthrough/)의 correction, [T32 실측](/docs/istio/graceful-termination/tests-walkthrough/files/verify/T32/) 참조).
---
## 4. 축2 메커니즘 — 같은 RST, 다른 가면
> [W1 big-picture](/docs/istio/graceful-termination/w1-big-picture/)는 이 분기를 요약만 하고 **본 절을 상세 정본으로 가리킨다**. 따라서 RST 표현 차이는 여기를 신뢰한다.
HAProxy `bind *:443 ... alpn h2,http/1.1`로 h2를 먼저 광고하고 Mac curl은 기본 HTTP/2를 시도한다. **동일한 TCP RST 사건이지만 프로토콜과 시나리오에 따라 curl 결과가 다르다** — 이게 축2를 통제해야 하는 이유다.
| 프로토콜 | curl 결과 | 원인 |
|---|---|---|
| HTTP/1.1 | exit 18("transfer closed with outstanding read data") 또는 502 | TCP RST → 미완료 응답 바디 |
| HTTP/2 | exit 92("stream was not closed cleanly: CANCEL") | `shutdown-sessions`가 HTTP/2 stream을 RST_STREAM CANCEL로 종료 |
핵심은 집계 누락의 메커니즘이다. S1 long-request는 HAProxy retry 소진으로 **502**(→ 5xx 필터에 잡힘), S4 streaming은 HTTP/2 stream 직접 cancel로 **exit 92**(→ HTTP status가 안 찍히는 connection-level 끊김 → 5xx 필터에서 빠짐). 즉 같은 TCP RST 1건이 프로토콜·시나리오에 따라 5xx 집계에 포함되기도(S1) 누락되기도(S4) 한다. **disruption 집계 시 5xx + conn err 양쪽을 모두 카운트**해야 S4 current의 end-to-end 끊김이 "연결 오류"로 분류돼 누락되는 걸 막는다.
```mermaid
flowchart TD
RST["TCP RST 1건
(shutdown-sessions)"]
RST --> H1{HTTP/1.1?}
RST --> H2{HTTP/2?}
H1 --> H1L["long-request (S1)"]
H1 --> H1S["streaming"]
H2 --> H2L["long-request"]
H2 --> H2S["streaming (S4)"]
H1L --> R1["502
retry 소진"]
H1S --> R2["exit 18
incomplete body"]
H2L --> R3["retry 소진 502"]
H2S --> R4["exit 92
stream CANCEL"]
R1 --> Y1["5xx 집계 포함 O"]
R2 --> N2["5xx 집계 포함 X
(conn err)"]
R3 --> Y3["5xx 집계 포함 O"]
R4 --> N4["5xx 집계 포함 X
(conn err) - 누락 위험"]
```
> [!note] 위 표·flowchart는 HAProxy `shutdown-sessions` 스택 한정 관측이다
> 동일한 강제 pod 종료를 HAProxy 없이 **순수 Istio Envoy 사이드카** 환경(homelab k8s 1.30.6, Istio 1.30.0)에서 재현하면 위 표와 다른 코드가 나왔다. drain 조정 없이 replicas=1 backend를 강제 종료했을 때, HTTP/1.1 in-flight 요청(`/delay/10`)은 502가 아니라 `http_code=000`(curl exit=28, timeout)으로 그냥 매달렸고, HTTP/2(h2c prior-knowledge) 스트리밍 요청(`/drip`)은 오히려 헤더가 kill 이전에 이미 도착해 `http_code=200`이 찍힌 채 바디만 멈췄다 — 두 경우 모두 502·exit 18·exit 92는 관찰되지 않았다([T33 실측](/docs/istio/graceful-termination/tests-walkthrough/files/verify/T33/)). 즉 "5xx와 connection error를 모두 세야 한다"는 축2의 원칙 자체는 여전히 유효하지만, 이 문서가 예시로 든 **502 vs exit 92라는 구체적 코드 조합은 HAProxy `shutdown-sessions`가 있는 이 스택 한정 관측**이며 Istio 사이드카 단독 환경의 보편적 동작이 아니다.
---
## 5. artifacts 통합 — `05-collect-timestamps.sh`
두 축을 통제해 disruption을 격리했다면, 다음은 **서로 다른 소스의 이벤트를 하나의 시간축에 올려** "무엇이 언제, 어떤 순서로 일어났나"를 보는 일이다. `05`는 6개 입력 파일을 공통 JSON 이벤트 스트림으로 변환한다.
```
입력 파일 이벤트 타입 핵심 파싱
───────────────────── ─────────────────────── ──────────────────────────────
hc.log -> hc_state "event=transition" 라인 ts/from/to
envoy.log -> envoy_drain_listeners "drain_listeners" 라인 ISO ts (두 포맷)
envoy-active.tsv -> envoy_active_count TSV 3열:
haproxy-stat- -> haproxy_backend_down/up snapshot 경계 감지 후 status diff
timeline.csv
rst-*.pcap -> tcp_rst tcpdump -tttt 파싱 (src:port > dst:port)
kubectl-events.txt -> k8s_pod_terminated service-a-igw + Kill/Terminat 패턴
```
**python3 vs awk fallback (왜 둘 다 두나)**:
- python3: `datetime.fromisoformat` 정밀 정렬 + `intervals.json`(drain_to_active_zero, active_zero_to_lb_down, lb_down_to_first_rst 초단위).
- awk: lexicographic 정렬. **ISO-8601 문자열은 lexicographic = chronological**이므로 정렬은 정확. 단 interval 계산엔 timestamp 산술이 필요해 awk에선 `intervals.json`이 `null`, raw timestamp만 출력된다. 즉 "환경에 python3 없어도 순서는 보장, 간격 숫자만 포기"라는 graceful degradation 설계다.
**상태 변화 감지**(haproxy-stat-timeline.csv): Python은 `prev_snap` dict로 snapshot 전환 시 status diff emit, awk는 `prev[key]`로 동일 로직을 한 pass에. 둘 다 `(pxname, svname)` 튜플 키, status가 DOWN/MAINT/DRAIN이면 `backend_down`, 그 외 `backend_up`.
---
## 6. 예시 — artifacts 디렉터리 읽고 timeline 만들기
지금까지의 설계가 실제로 무엇을 남기는지, 디렉터리 구조와 각 파일이 답하는 질문으로 본다.
```
tests/artifacts///
├── curl.out / curl.err — curl -w 출력 / stderr
├── hc.log / envoy.log — 컨테이너 kubectl logs --follow 캡처
├── envoy-active.tsv — (S2) Envoy /stats?filter=rq_active 2초 폴링
├── fsm-timeline.txt — (S2) hc.log "event=transition" grep
├── haproxy-stat-{before,during,after}.csv
├── haproxy-stat-timeline.csv — (S3) 5초 연속 snapshot
├── load.out / curl-loop.tsv / rollout-{restart,status}.log — (S3)
├── rst-{lb,w1,w2}.pcap — (S4) 노드별 tcpdump
├── rst-summary.txt / rst-counts.txt
├── run.log — log() tee (UTC ts)
└── summary.json — 시나리오별 핵심 수치
```
각 파일이 답하는 질문:
| 파일 | 답하는 질문 |
|---|---|
| curl.out / curl.err | 클라이언트 입장 (응답 코드, 총 시간, exit) |
| hc.log | hc FSM 전이 시점 |
| envoy.log | drain_listeners 호출 시점, access log |
| envoy-active.tsv | in-flight 수가 언제 0이 됐나 |
| haproxy-stat-*.csv | backend status UP→DOWN 시점 |
| rst-*.pcap | RST 패킷이 어느 구간에서 언제 |
| timeline.jsonl | 모든 소스를 공통 시간축 정렬한 event stream |
| intervals.json | 핵심 간격 3종 |
**최신 run 확인 + timeline 생성**:
```bash
ls -lt tests/artifacts/ | head -5
cat tests/artifacts///summary.json
cat tests/artifacts///timeline-summary.txt # 05 실행 후
```
S1 replicas=1 run이면 `summary.json`에 `502`/`8.25s`, S4 current면 `exit=92`가 찍혀야 한다 — 이게 안 보이면 축1/축2 통제가 깨진 것(예: 끊김 없는 `200`은 §3의 라우팅 분산 artifact).
### 6.1 04 ↔ 05 디렉터리 합치기 (가장 흔한 함정)
`04-rst-capture.sh`는 S1/S2/S4와 **별도 터미널에서 동시 실행**해야 의미가 있다. pcap을 떠야 RST가 "언제, 어느 구간에서" 났는지 timeline에 들어온다.
```bash
# 터미널 1
bash tests/01-baseline-long-request.sh
# 터미널 2 (동시에)
bash tests/04-rst-capture.sh S1-current 120
```
`04`는 자체 `init_artifacts`를 호출하므로 ART_DIR이 별개다. `05`는 단일 `///` 디렉터리 안에서 `rst-*.pcap`을 찾으므로, 04가 만든 pcap을 메인 run 디렉터리로 합쳐줘야 timeline에 `tcp_rst` 이벤트가 들어온다. 두 가지 방법:
```bash
# 방법 A: 04 실행 전 메인 run의 ART_DIR을 export해 같은 디렉터리에 쓰게 함
export ART_DIR=tests/artifacts//S1-current
bash tests/04-rst-capture.sh S1-current 120 # init_artifacts가 ART_DIR 존중 시
# 방법 B: 사후 복사 — 04 디렉터리의 pcap을 05가 보는 경로로 옮김
cp tests/artifacts/<04-ts>/S1-current/rst-*.pcap \
tests/artifacts//S1-current/
bash tests/05-collect-timestamps.sh tests/artifacts//S1-current
```
핵심은 **05에 넘기는 디렉터리 안에 `hc.log`/`envoy.log`/pcap이 모두 모여 있어야** 6 소스 통합이 성립한다는 점이다.
### 6.2 인라인 vs 스크립트 — 숫자의 출처
EXECUTION-LOG.md의 S1~S4 결과(502/8.25s, exit=92 등)는 대부분 **인라인 명령**으로 실행했다. 본 시리즈 스크립트(01~05)는 그 인라인 실험을 재현 가능하게 코드화한 것이다. 따라서 스크립트 실행 시 같은 현상을 **재현**할 수 있어야 하되 완전히 동일한 숫자는 아닐 수 있고, EXECUTION-LOG.md의 artifact 경로는 스크립트 버전 디렉터리 구조와 미묘하게 다를 수 있다.
---
## 7. 회상 quiz
Q1. S1·S2가 replicas≥2를 체크하면서도 목적은 single-pod 격리인 이유?
**A**: `READY_COUNT -lt 2` 체크는 "deployment 정상" 확인용 pre-flight일 뿐. 실제 실험은 `pick_target_pod()`이 `head -1`로 한 pod만 골라 delete한다. 단일 격리가 성립하려면 운영자가 사전에 replicas=1로 설정하거나 roundrobin이 그 pod에 traffic을 고정하도록 배치해야 한다 — 스크립트가 replicas 변경을 강제하진 않는다.
Q2. haproxy-stat-timeline.csv 파싱에서 Python vs awk 본질적 차이?
**A**: 기능은 동일(status flip 감지)하나 Python은 마지막 snapshot을 별도 flush 블록으로 처리해 파일 끝 separator 없어도 마지막 변화를 emit하고, status_col을 헤더에서 동적 감지한다. awk는 `$18`로 고정 — HAProxy 버전에 따라 CSV 컬럼 순서가 다르면 awk가 틀릴 수 있다.
Q3. S3 curl-fallback의 p50/p99 계산과 hey의 차이, 왜 수치가 다른가?
**A**: hey는 내부 histogram으로 백분위 계산. curl-fallback은 `curl -w '%{time_total}'`를 tsv에 쌓고 `sort -n` 후 index로 백분위 계산. curl-fallback은 while 루프 20개로 concurrency=20이지만 각 loop 내부가 동기적이라 throughput이 낮고, tsv 쓰기 경합·OS scheduling latency가 time_total에 반영되어 hey 대비 p99가 더 높게 나오는 경향.
---
## 핵심 정리
- **실험 설계의 목적은 "측정값이 진짜 거동인가 artifact인가"를 가르는 것**이다 — 4 시나리오는 각각 단일 변수를 격리해 disruption을 특정 원인에 귀속시킨다.
- **축1 (replicas=1)**: roundrobin traffic isolation을 피해야 disruption을 죽인 pod에 귀속할 수 있다. `200/60s`(분산 artifact) → replicas=1 → `502/8.25s`(입증). 이 masking 효과는 homelab 실측(0/60 vs 8/60)으로도 확인됨([T35](files/verify/T35/result.txt)).
- **8.25s = 다단계 지연**: preStop hc 503 flip → HAProxy `fall 2`(~4s) 감지 → DOWN 마킹의 누적. 즉시 DOWN이 아니다.
- **축2 (집계 완전성)**: 동일 TCP RST도 HTTP/2(exit 92 CANCEL) vs HTTP/1.1(exit 18/502)로 가면이 갈리므로 disruption 집계는 5xx + conn err 양쪽을 모두 카운트(단, 이 구체적 코드 조합은 HAProxy `shutdown-sessions` 스택 한정 관측 — 순수 Envoy 사이드카 재현 결과는 §4 note 참고).
- **`05` 통합**: ISO-8601 lexicographic = chronological 성질로 awk fallback에서도 정렬은 정확하나 interval 계산은 python3 필요(graceful degradation).
## What you might be missing
- **`--grace-period=210`은 이 실험에서 매번 명시해야만 적용된다** — 원인은 "kubectl이 기존 값을 30s로 자른다"가 아니라, 이 스크립트가 pod manifest에 `terminationGracePeriodSeconds`를 넣지 않고 delete 시점 플래그로만 210s를 주는 방식을 택했기 때문이다. 그 필드가 spec에 없으면 API 서버가 30초로 채워 넣으므로, 명시하지 않으면 drain 완료 전에 30s에서 SIGKILL돼 실험 자체가 무효가 된다(§3 warning).
- **5xx 카운트만으로는 disruption을 다 못 잡는다**. S4 streaming의 exit 92(HTTP/2 stream CANCEL)는 HTTP status가 안 찍히는 connection-level 끊김이라 5xx 필터에서 빠진다. 집계는 5xx + conn err를 합산해야 한다(§4 flowchart) — 단 502/exit 92라는 구체적 코드는 HAProxy 스택 한정 관측이고, 순수 Envoy 환경에서는 다른 코드(000/28)로 나타난다(§4 note).
- **delete 직후 즉시 DOWN이 아니다**. preStop hc 503 flip → HAProxy `fall 2`(~4s) 감지 → DOWN의 다단계 지연이 8.25s 타이밍의 근거다. 이 체인을 건너뛰면 타이밍 숫자가 설명되지 않는다.
- **awk fallback은 정렬은 맞지만 interval은 못 준다**. ISO-8601이 lexicographic=chronological이라 정렬은 정확하나, `intervals.json`(drain→active0→lb-down→first-rst 초단위)은 python3 없으면 `null`이다.
- **04의 ART_DIR은 별개 디렉터리**라 05가 pcap을 못 찾는다. 메인 run 디렉터리로 합쳐야 timeline에 `tcp_rst`가 들어온다(§6.1).
- **이 체인은 Istio 기본 아키텍처가 아니다**. HAProxy 엣지 LB + 커스텀 hc 사이드카는 이 시리즈의 자체 제작 테스트 하네스이고, 실제 Istio sidecar 모드는 istio-agent가 SIGTERM → Envoy drain_listeners → terminationDrainDuration(기본 5s) 경로로 종료한다(문서 상단 warning 참조).
## 이어 보기
- 코드 워크스루: [W5 tests walkthrough](/docs/istio/graceful-termination/tests-walkthrough/) — 스크립트 01~05 라인별 해설
- 이전: [W3 IGW custom deployment](/docs/istio/graceful-termination/w3-igw-deployment/)
- 다음: [W6 production apply](/docs/istio/graceful-termination/w6-production-apply/) — 프로덕션 온프렘 매핑
- Big Picture hub: [W1 big-picture](/docs/istio/graceful-termination/w1-big-picture/)
- 시리즈 MOC: [graceful termination MOC](/docs/istio/graceful-termination/)
---
## 검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)
검증 방법: 공식 문서 대조(Kubernetes/HAProxy/Envoy/curl/ISO-8601 자료) + homelab 클러스터 실측(masking 재현 테스트, grace-period 실측, Istio 사이드카 단독 RST 재현 테스트).
| 주장 | 판정 | 근거 |
|---|---|---|
| C1. replicas≥2 roundrobin이면 죽인 pod의 disruption이 살아있는 pod 응답에 가려져 "끊김 0"으로 오판됨 → replicas=1 강제 필요 | ✅ 실측 확인 | [T35 실측](files/verify/T35/result.txt) |
| C2. 동일 TCP RST가 HTTP/1.1은 502(5xx 집계 포함), HTTP/2는 상태코드 없는 connection cancel(5xx 집계 누락)로 다르게 나타남 | 🔬 실측 반증 — 본문 교정 | [tests-walkthrough T33 실측](/docs/istio/graceful-termination/tests-walkthrough/files/verify/T33/) |
| C3. `kubectl delete`는 `--grace-period` 미지정 시 무조건 기본 30초로 잘라 강제 종료한다 | ❌ 오류 — 본문 교정 | [kubernetes.io/.../kubectl_delete](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_delete/) · [tests-walkthrough T32 실측](/docs/istio/graceful-termination/tests-walkthrough/files/verify/T32/) |
| C4. ReplicaSet 컨트롤러는 `deletionTimestamp != nil`인 순간 `IsPodActive=false`로 즉시 신규 pod을 schedule | ✅ 문헌 확인 | [github.com/.../controller_utils.go](https://github.com/kubernetes/kubernetes/blob/master/pkg/controller/controller_utils.go) |
| C5. HAProxy `fall N`/`inter T`는 연속 N회 실패해야 DOWN 마킹(기본 inter=2000ms, fall=3, rise=2) | ✅ 문헌 확인 | [haproxy.com/.../health-checks](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/reliability/health-checks/) |
| C6. HAProxy `on-marked-down shutdown-sessions`는 DOWN 마킹 즉시 기존 세션을 강제 종료 | ✅ 문헌 확인 | [discourse.haproxy.org/.../shutdown-sessions](https://discourse.haproxy.org/t/on-marked-down-shutdown-sessions-not-working/6456) |
| C7. curl exit 18은 TCP RST로 인한 미완료 응답 바디("transfer closed with outstanding read data") | ✅ 문헌 확인 | [curl.se/libcurl/c/libcurl-errors.html](https://curl.se/libcurl/c/libcurl-errors.html) |
| C8. curl exit 92는 HTTP/2 stream이 CANCEL로 종료될 때("stream was not closed cleanly: CANCEL") | ✅ 문헌 확인 | [curl.se/libcurl/c/libcurl-errors.html](https://curl.se/libcurl/c/libcurl-errors.html) |
| C9. HAProxy `bind ... alpn h2,http/1.1`은 h2를 먼저 광고해 클라이언트가 기본적으로 HTTP/2를 시도 | ✅ 문헌 확인 | [haproxy.com/.../http](https://www.haproxy.com/documentation/haproxy-configuration-tutorials/load-balancing/http/) |
| C10. Envoy drain 시 HTTP/1.1엔 `Connection: close`, HTTP/2엔 GOAWAY를 보내고 요청 완료 후 연결 종료 | ✅ 문헌 확인 | [envoyproxy.io/.../draining](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/operations/draining) |
| C11. Envoy admin `/stats?filter=`는 이름이 매칭되는 stat만 필터링, in-flight 요청 수는 `rq_active`류 게이지로 폴링 가능 | ✅ 문헌 확인 | [envoyproxy.io/.../admin](https://www.envoyproxy.io/docs/envoy/latest/operations/admin) |
| C12. ISO-8601 타임스탬프는 lexicographic 정렬이 곧 chronological 정렬과 같음 | ✅ 문헌 확인 | [wikipedia.org/wiki/ISO_8601](https://en.wikipedia.org/wiki/ISO_8601) |
| C13. 이 문서의 종료 체인(preStop hc → HAProxy DOWN → Envoy drain)이 Istio sidecar 모드의 기본 graceful termination 아키텍처와 동일하다는 암묵적 전제 | ❌ 오류 — 본문 교정 | [istio.io/.../architecture](https://istio.io/latest/docs/ops/deployment/architecture/) |