# STRICT_DNS flip — in-flight 요청 절단 재현 시도 (raw) **Date:** 2026-07-02 · **cluster:** homelab · **ns:** dns-lab · **Istio:** 1.30.0 **시나리오:** `scenarios/50-dns-resolution/` · **하네스 재사용:** `scripts/dns-lab-setup.sh` estat() 패턴, `scripts/dns-flip-test.sh` flip_single() 패턴 **전제 상태(작업 시작 시 재확인):** SE=`gslb-strict`(resolution:DNS), VS=`gslb-80-to-443`(no-retry), DR=`gslb-tls-origination`(no-outlier), DNS→backend-a, 전 파드 READY. backend-a=10.250.147.30, backend-b=10.250.27.55, lab-dns=10.250.217.19 (2026-07-01 mode1 실측과 IP 동일 — 재확인 완료) > 초안(construction 로그). "합격/불합격"이 아니라 무엇을 했고 그때 무엇이 보였는지를 그대로 남긴다. > 가설(요청이 in-flight일 때 flip이 오면 절단된다)은 **2/2 런에서 재현되지 않았다** — 그 자체가 이번 발견이다. --- ## 0. 사전 확인 ``` $ kubectl --context=homelab -n dns-lab get pods backend-a-c7987cbc8-zd5cz 1/1 Running backend-b-66c75d5fc6-hkgsw 1/1 Running fortio-7b8b646bf9-7phzf 2/2 Running lab-dns-5747479844-w69qq 2/2 Running netshoot-7fbd9f9bf4-2c7cc 2/2 Running $ kubectl --context=homelab -n dns-lab get serviceentry gslb-strict ["gslb.lab.internal"] MESH_EXTERNAL DNS $ kubectl --context=homelab -n dns-lab exec deploy/netshoot -- dig +short gslb.lab.internal 10.250.147.30 $ kubectl --context=homelab -n dns-lab get virtualservice,destinationrule virtualservice.../gslb-80-to-443 ["gslb.lab.internal"] destinationrule.../gslb-tls-origination gslb.lab.internal ``` --- ## 1. 수정한 repo 파일 ### `scenarios/50-dns-resolution/20-backends.yaml` (수정, 신규 생성 아님) 두 ConfigMap(`backend-a-conf`, `backend-b-conf`)에 `/slow` location 추가, 두 Deployment(`backend-a`, `backend-b`)에 `initContainer`(busybox) + `emptyDir` volume 추가. **핵심 변경 요지:** - `/slow` = `limit_rate 10k`로 전송을 늦춘 400KB(+"backend-x\n" 헤더 10바이트 = 409610B) 정적 파일 응답. 10KB/s ÷ 409610B ≈ **40초** — 요구된 30~60s 창 중앙에 위치하도록 설계(마진 확보). - 어느 backend가 서빙했는지는 **`X-Backend` 응답 헤더**(body 스트리밍 전에 먼저 전송되어 즉시 식별 가능)와 **body 첫 줄**("backend-a\n"/"backend-b\n", 기존 `/`의 who= 관례와 일관) 둘 다로 식별 가능하게 함. - payload(400KB)는 git에 커밋하는 대신 **busybox initContainer가 pod 시작 시 결정적으로 생성** (`head -c 409600 /dev/zero | tr '\0' 'X'`) — 재현 가능하면서 repo에 대용량 블롭을 남기지 않음. - 코드 변경 diff는 git 커밋 이력이 없어(레포에 아직 첫 커밋 없음) 표준 diff로 남길 수 없음 — 아래가 실제 추가된 블록. ```yaml # backend-a-conf, backend-b-conf 공통 추가 location (backend-b는 문자열만 "b"로 치환) location /slow { add_header X-Backend "backend-a" always; limit_rate 10k; alias /var/www/slow/payload.bin; } ``` ```yaml # backend-a, backend-b Deployment 공통 추가 initContainers: - name: slow-payload-gen image: busybox:1.36 command: ["sh", "-c", "echo backend-a > /slow/payload.bin; head -c 409600 /dev/zero | tr '\\0' 'X' >> /slow/payload.bin"] volumeMounts: - { name: slow-payload, mountPath: /slow } # containers.nginx.volumeMounts 에 추가: - { name: slow-payload, mountPath: /var/www/slow, readOnly: true } # volumes 에 추가: - name: slow-payload emptyDir: {} ``` 적용: ``` $ kubectl --context=homelab apply --dry-run=server -f scenarios/50-dns-resolution/20-backends.yaml configmap/backend-a-conf configured (server dry run) configmap/backend-b-conf configured (server dry run) deployment.apps/backend-a configured (server dry run) deployment.apps/backend-b configured (server dry run) $ kubectl --context=homelab apply -f scenarios/50-dns-resolution/20-backends.yaml configmap/backend-a-conf configured configmap/backend-b-conf configured deployment.apps/backend-a configured deployment.apps/backend-b configured $ kubectl --context=homelab -n dns-lab rollout status deploy/backend-a --timeout=90s deployment "backend-a" successfully rolled out $ kubectl --context=homelab -n dns-lab rollout status deploy/backend-b --timeout=90s deployment "backend-b" successfully rolled out ``` ### `/slow` 사전검증 (partial-read, 3초 컷) ``` $ curl -sk -D - -m 3 https://:443/slow -H 'Host: gslb.lab.internal' HTTP/1.1 200 OK Content-Length: 409610 X-Backend: backend-a Accept-Ranges: bytes → 3초 동안 30454 bytes 수신 (≈10151 B/s, limit_rate 10k와 일치) — 스트리밍 확인, 버퍼링 없음. $ curl -sk -D - -m 3 https://:443/slow -H 'Host: gslb.lab.internal' X-Backend: backend-b → 동일 패턴, body 첫 줄 "backend-b" $ time curl -sk -D - -m 3 http://gslb.lab.internal/slow (client sidecar 경유, mesh 전체 경로) x-backend: backend-a (Envoy가 lower-case로 재기록) x-envoy-upstream-service-time: 2 → mesh 경유 시에도 스트리밍/헤더 동작 확인. ``` --- ## 2. 실행 히스토리 — 타이밍 함정 2건 (그대로 기록) **시행착오 1 (trial0):** 첫 curl 호출을 `kubectl exec`로 **foreground** 실행 → Bash 툴이 40초를 기다렸다가 결과를 반환, 그 사이 flip을 끼워넣을 수 없었음. DNS는 그대로 backend-a, 요청은 무손상 완주(SIZE=409610, CODE=200). **flip 없는 대조 데이터일 뿐, 유효 런 아님.** **시행착오 2 (attempt1/2 mistimed):** 두 개의 독립된 top-level `run_in_background:true` Bash 호출(① curl 시작, ② `sleep 5`+flip)을 **같은 메시지**로 보냈으나, 실제로는 동시에 디스패치되지 않음 — flip이 curl 시작 후 **2분 9초**, **1분 24초** 뒤에야 실제로 실행됨 (curl은 이미 40초 만에 완주 종료된 뒤). 원인: 이 환경에서 서로 다른 top-level 백그라운드 Bash 호출 간 실제 착수 시각이 크게 어긋날 수 있음(오케스트레이션 지연, 순번 처리 등 — 근본 원인 미확진). **교정:** curl을 **같은 셸 스크립트 안에서 `&`로 백그라운드**시키고, 그 뒤에 동일 스크립트 안에서 `sleep 5` → flip → `wait $CURLPID`를 이어붙여 **하나의 Bash 툴 호출**로 실행 — 이러면 호출 간 디스패치 지연이 타이밍에 개입할 여지가 없다. 이후 Run 1/Run 2 모두 스크립트 로그상 flip이 curl 시작 정확히 **+5초**에 실행됨을 확인. ``` RUN1_SCRIPT_START=10:28:03 CURL_BG_PID=4110424 launched at 10:28:03 FLIP1_ISSUED_LOCAL=10:28:08 <- +5s 정확히 FLIP1_DONE_LOCAL=10:28:08 RUN1_SCRIPT_CURL_WAIT_DONE=10:28:43 <- curl 총 40s 소요, 완주 RUN2_SCRIPT_START=10:38:53 CURL_BG_PID=4179605 launched at 10:38:53 FLIP2_ISSUED_LOCAL=10:38:58 <- +5s 정확히 FLIP2_DONE_LOCAL=10:38:58 RUN2_SCRIPT_CURL_WAIT_DONE=10:39:33 <- curl 총 40s 소요, 완주 ``` --- ## 3. 런별 델타 테이블 (netshoot 사이드카 — **실제 트래픽 발생 지점**) > 주의: `dns-flip-test.sh`의 `estat()`은 관례상 `deploy/fortio -c istio-proxy`를 찍지만, 이번 실험은 **netshoot pod**에서 > curl을 실행했으므로 트래픽이 통과하는 실제 사이드카는 netshoot의 것이다. netshoot deployment(`30-client.yaml`)도 > fortio와 동일한 `proxyStatsMatcher: [".*gslb.*"]` annotation을 갖고 있어 cluster 단위 cx 카운터가 동일하게 노출된다 > (`pilot-agent request GET clusters`의 per-endpoint 카운터는 애초에 matcher와 무관하게 항상 노출). ### Run 1 (10:28:03 시작, flip 10:28:08, 완주 10:28:43) | counter | before (10:27:52) | after (10:30:01) | delta | |---|---|---|---| | membership_change | 13 | 14 | **+1** | | upstream_cx_active | 0 | 0 | 0 | | upstream_cx_connect_fail | 32 | 32 | 0 | | upstream_cx_destroy | 48 | 49 | **+1** | | upstream_cx_destroy_local | 5 | 6 | **+1** | | upstream_cx_destroy_local_with_active_rq | 1 | 1 | **0** | | upstream_cx_destroy_remote | 43 | 43 | 0 | | upstream_cx_destroy_remote_with_active_rq | 0 | 0 | 0 | | **upstream_cx_destroy_with_active_rq** | 1 | 1 | **0** | | upstream_cx_total | 48 | 49 | **+1** | | upstream_rq_total | 412 | 413 | **+1** | | endpoint(clusters) | 10.250.147.30(a) cx_total=0 | 10.250.27.55(b) cx_total=0 | endpoint 교체(A→B), 신규 B는 아직 rq 없음 | ### Run 2 (10:38:53 시작, flip 10:38:58, 완주 10:39:33) | counter | before (10:38:15) | after (10:41:12) | delta | |---|---|---|---| | membership_change | 15 | 16 | **+1** | | upstream_cx_active | 0 | 0 | 0 | | upstream_cx_connect_fail | 32 | 32 | 0 | | upstream_cx_destroy | 49 | 50 | **+1** | | upstream_cx_destroy_local | 6 | 7 | **+1** | | upstream_cx_destroy_local_with_active_rq | 1 | 1 | **0** | | upstream_cx_destroy_remote | 43 | 43 | 0 | | upstream_cx_destroy_remote_with_active_rq | 0 | 0 | 0 | | **upstream_cx_destroy_with_active_rq** | 1 | 1 | **0** | | upstream_cx_total | 49 | 50 | **+1** | | upstream_rq_total | 413 | 414 | **+1** | | endpoint(clusters) | 10.250.147.30(a) cx_total=0 | 10.250.27.55(b) cx_total=0 | endpoint 교체(A→B), 신규 B는 아직 rq 없음 | **두 런이 정확히 동일한 델타 패턴을 보임 — 재현성 확인(2/2).** --- ## 4. curl 결과 | | Run 1 | Run 2 | |---|---|---| | exit code | 0 | 0 | | CURL_EXIT (%{exitcode}) | 0 | 0 | | HTTP CODE | 200 | 200 | | 수신 바이트 | 409610 / 409610 (기대치 전량) | 409610 / 409610 (기대치 전량) | | time_total | 40.050818s | 40.047956s | | 응답 body 첫 줄 | "backend-a" | "backend-a" | | 에러 메시지 | (없음) | (없음) | 두 런 모두 **에러 없이, 기대 바이트 전량, flip 전 backend(backend-a)로부터** 응답을 완주했다. --- ## 5. 타임라인 (초 단위, 로컬 KST 기준 — pod 내부 UTC 타임스탬프와 9h 오프셋 확인됨) ``` Run 1 Run 2 t=0 10:28:03 curl 시작(backend-a) 10:38:53 curl 시작(backend-a) t=+5 10:28:08 flip A→B 실행 10:38:58 flip A→B 실행 t=+40 10:28:43 curl 완주(200, 전량) 10:39:33 curl 완주(200, 전량) ``` flip은 요청 진행률 12.5%(5/40s) 지점에서 발생 — 확실히 in-flight 구간. DNS TTL 5s + CoreDNS `reload 2s` + Envoy STRICT_DNS 기본 refresh(~5s) 를 감안하면 membership_change(A 제거, B만 남음)는 대략 t=+10~+20s 사이에 이미 일어났을 것으로 추정됨(직접 이 시각을 세분 관측하진 않았으나, DNS TTL 자체가 5s이므로 flip+5s 이내 반영이 정상 — 2026-07-01 mode1 리포트에서도 "flip +약 5s"로 이미 관측됨). 즉 **요청이 살아있는 동안 최소 20초 이상, Envoy 클러스터는 이미 backend-a를 유효 host에서 제거한 상태였는데도** 기존 연결은 끊기지 않고 응답을 계속 스트리밍했다. --- ## 6. 판정 **절단 미재현 (2/2 런).** `upstream_cx_destroy_with_active_rq` 델타는 두 런 모두 **0**이었고, curl은 두 런 모두 exit 0 / CODE 200 / 기대 바이트 전량으로 완주했다. **원인 분석 (카운터 + 타임라인 기반):** - 두 런 모두 `upstream_cx_destroy`(+1)와 `upstream_cx_destroy_local`(+1)이 함께, `membership_change`(+1)와 함께 증가했다 — 이는 이전 mode1 실측(2026-07-01, `destroy_local +2`)과 방향은 일치한다: STRICT_DNS는 flip 시 실제로 Envoy가 능동적으로 구 endpoint(backend-a)에 연결을 정리(drain)한다. - 그러나 이번 런은 그 drain된 연결이 **우리의 in-flight 요청이 물려있던 연결이 아니었다**는 것을 `destroy_with_active_rq` 델타 0으로 강하게 시사한다. 만약 in-flight 연결 자체가 강제 종료됐다면 이 카운터가 최소 +1 됐어야 한다(Envoy는 "요청이 살아있는 채로 연결이 파괴됨"을 이 카운터로 구분해서 집계한다). 대신 curl은 자연스러운 완료(200, 전량)로 끝났다. - 가장 유력한 설명(Envoy의 알려진 cluster-membership 갱신 동작과 일치): STRICT_DNS가 backend-a host를 제거해도, **이미 진행 중인 스트림이 물려 있는 기존 연결은 즉시 강제 종료되지 않고, 그 스트림이 자연 종료될 때까지 유지된다.** 새 요청만 더 이상 그 host로 라우팅되지 않을 뿐이다. `destroy_local +1`은 우리 요청이 자연 완료된 **이후에(또는 그와 무관하게)** 이제는 무효 host(backend-a)로 남아있던 유휴/재사용 대상 연결을 Envoy가 정리한 이벤트로 해석하는 것이 가장 정합적이다 (요청이 활성 상태였다면 `_with_active_rq`가 올랐을 것이므로). - `upstream_cx_total +1`은 신규 연결(사실상 backend-b 대상, endpoint 덤프에서 A→B 교체로 확인)이 하나 생성됐음을 보여주지만, `upstream_rq_total +1`은 **우리 자신의 요청 1건**으로 전부 설명되며 이 신규 연결에는 아직 요청이 실리지 않았다 (AFTER 스냅샷의 backend-b endpoint `cx_total=0`이 이를 뒷받침 — cluster 레벨 총량과 endpoint 레벨 카운터의 리셋/집계 시점 차이는 2026-07-01 리포트 §6에서도 이미 관측된 현상). - 결론적으로 **2026-07-01 mode1의 "요청이 짧아서 destroy_with_active_rq가 0이었다"는 가설(요청 사이에 drain이 끼어들었을 뿐이다)은, 요청을 40초로 늘린 이 실험에서도 여전히 유지되지 않았다** — 오히려 이는 "요청 길이와 무관하게, Envoy가 active stream이 있는 연결은 보호하고 유휴/완료된 연결만 drain한다"는 더 강한 설명을 뒷받침한다. 즉 HTTP/1.1 origination 경로에서는 STRICT_DNS flip이 **in-flight 요청을 즉시 자르지 않고, 그 요청이 끝날 때까지 유예한 뒤 연결을 정리**하는 것으로 보인다(graceful drain semantics). --- ## 7. 이상 징후 · 미해결 의문 - **오케스트레이션 타이밍 함정(§2)**: 별개의 top-level 백그라운드 Bash 호출 2개가 "동시 디스패치"를 보장하지 않음 — 최대 2분 이상 어긋남을 실측. 이번엔 단일 스크립트(`curl & ... sleep 5 ... flip ... wait`)로 우회했지만, 근본 원인(에이전트 오케스트레이션 지연인지, 백그라운드 큐잉인지)은 규명하지 않았다. **다음 실험에서 멀티 프로세스 타이밍이 필요하면 반드시 단일 스크립트 패턴을 기본으로 쓸 것.** - **destroy_local의 정확한 대상 연결 미확증**: `destroy_with_active_rq=0`으로 "in-flight 연결이 아니다"는 강하게 시사되지만, 어떤 연결이 정확히 파괴됐는지(진짜 유휴 pooled 연결인지, 아니면 in-flight 연결이 완료 *직후* 파괴되어 카운터 상 "이미 활성 상태가 아니었다"로 집계된 것인지)는 이 스냅샷 방식(before/after만, 중간 시점 없음)으로는 구분 불가능하다. 이전 mode1처럼 flip 전후로 **중간 스냅샷(MID-PRE-FLIP/MID-POST-FLIP)**을 20-30초 시점에도 찍었다면 membership_change가 실제로 언제 일어났는지, 그 시점에 destroy가 즉시 따라왔는지 지연됐는지 더 정밀하게 구분 가능했을 것. **후속 실험 후보.** - **endpoint 레벨 cx_total=0 (AFTER, backend-b)**: 신규 생성된 연결에 아직 요청이 없었다는 뜻인데, 그렇다면 cx_total(cluster 전체) +1이 정말 backend-b향인지 아니면 다른 이유(예: 헬스체크성 프로브 연결)로 생긴 것인지 100% 확증하지 못했다. endpoint 덤프가 A→B로 바뀐 것과 시점이 맞물려 있어 정황상 backend-b가 맞다고 보되, 별도 트래픽으로 그 연결에 실제 요청이 흐르는지 확인하는 후속 스텝은 하지 않았다. - 두 런 모두 완전히 동일한 델타(48→49, 5→6 등 절대값은 누적이라 다르지만 **델타 패턴은 100% 동일**)를 보인 것은 이 거동이 flaky하지 않고 **결정적(deterministic)**임을 시사 — STRICT_DNS + HTTP/1.1 origination + 40s 스트리밍 조합에서는 "in-flight 절단"이 우연이 아니라 구조적으로 일어나지 않는 것으로 보인다. --- ## 8. 원시 로그 파일 경로 스크래치패드(세션 로컬, repo 밖): ``` /tmp/claude-1000/-mnt-homelab-kakaopay-istio/9efbaa2c-8577-4534-8d98-d97a7cc0747e/scratchpad/dns-strict-inflight/ ├── trial0_before.txt (foreground 시행착오 — flip 없음) ├── trial0_no_flip_curl_out.txt ├── attempt1_mistimed_*.txt (flip이 curl 완주 2분9초 뒤에 실행된 무효 시도) ├── attempt2_mistimed_*.txt (flip이 curl 완주 1분24초 뒤에 실행된 무효 시도) ├── NOTE_timing_lesson.txt ├── run1_before.txt / run1_timeline.txt / run1_curl_out.txt / run1_curl_stderr.txt / run1_after.txt (유효 Run 1) └── run2_before.txt / run2_timeline.txt / run2_curl_out.txt / run2_curl_stderr.txt / run2_after.txt (유효 Run 2) ``` ## 9. 랩 종료 상태 확인 ``` $ kubectl --context=homelab -n dns-lab exec deploy/netshoot -- dig +short gslb.lab.internal 10.250.147.30 <- backend-a 원복 확인 $ kubectl --context=homelab -n dns-lab get serviceentry gslb-strict ["gslb.lab.internal"] MESH_EXTERNAL DNS <- 유지 $ kubectl --context=homelab -n dns-lab get virtualservice,destinationrule gslb-80-to-443 (no-retry) / gslb-tls-origination (no-outlier) <- 변경 없음 $ kubectl --context=homelab -n dns-lab get pods backend-a 1/1 Running backend-b 1/1 Running fortio 2/2 Running lab-dns 2/2 Running netshoot 2/2 Running <- 전 파드 READY $ kubectl --context=homelab -n dns-lab exec deploy/netshoot -- curl -s -m 5 http://gslb.lab.internal/ backend-a <- 최종 sanity, mesh 경로 정상 ``` `/slow` 엔드포인트 manifest(`20-backends.yaml`의 초기컨테이너+location 변경분)는 **그대로 남겨둠** — 다음 LOGICAL_DNS 대조군 실험에서 재사용 예정.