W5. 테스트 시나리오 4종 설계 의도 + artifacts 해석
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(스크립트 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, 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가 재현된다.
3.1 8.25s는 다단계 지연의 누적
delete 직후 즉시 DOWN이 아니다. preStop hook이 hc를 503으로 flip → HAProxy가 inter 2s/fall 2(~4s)로 backend를 감지 → DOWN 마킹이라는 다단계 지연을 거친다. 8.25s 타이밍은 이 체인이 누적된 결과다(상세 메커니즘: 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 로그로 확인 가능하다.
이 210s는 스크립트가 kubectl delete --grace-period=210을 명시했기 때문이지, pod spec의 terminationGracePeriodSeconds가 자동 상속되는 값이 아니다. kubectl delete는 --grace-period 미지정 시 기본 30s로 잘라 보내므로, 명시하지 않으면 drain이 30s에 강제 종료된다(tests walkthrough의 correction 참조).
4. 축2 메커니즘 — 같은 RST, 다른 가면
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 끊김이 "연결 오류"로 분류돼 누락되는 걸 막는다.
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열: <ISO-ts> <downstream> <upstream>
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/<YYYYMMDD-HHMMSS>/<scenario>/
├── 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 생성:
ls -lt tests/artifacts/ | head -5
cat tests/artifacts/<ts>/<scenario>/summary.json
cat tests/artifacts/<ts>/<scenario>/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에 들어온다.
# 터미널 1
bash tests/01-baseline-long-request.sh
# 터미널 2 (동시에)
bash tests/04-rst-capture.sh S1-current 120
04는 자체 init_artifacts를 호출하므로 ART_DIR이 별개다. 05는 단일 <artifacts>/<ts>/<scenario>/ 디렉터리 안에서 rst-*.pcap을 찾으므로, 04가 만든 pcap을 메인 run 디렉터리로 합쳐줘야 timeline에 tcp_rst 이벤트가 들어온다. 두 가지 방법:
# 방법 A: 04 실행 전 메인 run의 ART_DIR을 export해 같은 디렉터리에 쓰게 함
export ART_DIR=tests/artifacts/<main-ts>/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/<main-ts>/S1-current/
bash tests/05-collect-timestamps.sh tests/artifacts/<main-ts>/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 — 스크립트 01~05 라인별 해설
- 이전: W3 IGW custom deployment
- 다음: W6 production apply — 프로덕션 온프렘 매핑
- Big Picture hub: W1 big-picture
- 시리즈 MOC: graceful termination MOC