---
type: src
tags: [istio, graceful-termination, testing, envoy, haproxy, http2]
created: 2026-06-07
---
# W5. 테스트 시나리오 4종 설계 의도 + artifacts 해석
> [!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)을 모두 집계**, 이 두 통제가 선행돼야 한다.
> **대상**: graceful termination 시리즈 W5. **선행**: W1 big-picture(전체 체인), W2 hc FSM. **각도**: 스크립트의 *왜*(설계 의도) + artifacts *읽는 법*. **대응 코드 워크스루**: [W5 tests walkthrough](gt__src-tests-walkthrough.html)(스크립트 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](gt__src-w1-big-picture.html), [W2 hc FSM](gt__src-w2-hc-fsm.html) 참조. 본 문서는 그 위에서 *어떻게 측정하나*를 다룬다.)
---
## 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`가 재현된다.
### 3.1 8.25s는 다단계 지연의 누적
delete 직후 즉시 DOWN이 아니다. preStop hook이 hc를 503으로 flip → HAProxy가 `inter 2s`/`fall 2`(~4s)로 backend를 감지 → DOWN 마킹이라는 **다단계 지연**을 거친다. 8.25s 타이밍은 이 체인이 누적된 결과다(상세 메커니즘: [W1 big-picture](gt__src-w1-big-picture.html)). 이 체인을 모르면 "왜 즉시 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 delete`는 `--grace-period` 미지정 시 **기본 30s로 잘라** 보내므로, 명시하지 않으면 drain이 30s에 강제 종료된다([tests walkthrough](gt__src-tests-walkthrough.html)의 correction 참조).
---
## 4. 축2 메커니즘 — 같은 RST, 다른 가면
> [W1 big-picture](gt__src-w1-big-picture.html)는 이 분기를 요약만 하고 **본 절을 상세 정본으로 가리킨다**. 따라서 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) - 누락 위험"]
```
---
## 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`(입증).
- **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 양쪽을 모두 카운트.
- **`05` 통합**: ISO-8601 lexicographic = chronological 성질로 awk fallback에서도 정렬은 정확하나 interval 계산은 python3 필요(graceful degradation).
## What you might be missing
- **`--grace-period=210`은 명시해야만 적용**된다. `kubectl delete`는 미지정 시 30s로 잘라 보내므로, drain 검증을 한다며 grace를 명시하지 않으면 30s에 강제 SIGKILL돼 실험 자체가 무효가 된다(§3 warning).
- **5xx 카운트만으로는 disruption을 다 못 잡는다**. S4 streaming의 exit 92(HTTP/2 stream CANCEL)는 HTTP status가 안 찍히는 connection-level 끊김이라 5xx 필터에서 빠진다. 집계는 5xx + conn err를 합산해야 한다(§4 flowchart).
- **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).
## 이어 보기
- 코드 워크스루: [W5 tests walkthrough](gt__src-tests-walkthrough.html) — 스크립트 01~05 라인별 해설
- 이전: [W3 IGW custom deployment](gt__src-w3-igw-deployment.html)
- 다음: [W6 production apply](gt__src-w6-production-apply.html) — 프로덕션 온프렘 매핑
- Big Picture hub: [W1 big-picture](gt__src-w1-big-picture.html)
- 시리즈 MOC: [graceful termination MOC](gt__MOC-graceful-termination.html)