--- 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)