# F-0 — LOGICAL_DNS + TLS passthrough 경로 in-flight 생존 실측 (매트릭스 마지막 칸)

**date:** 2026-07-09
**ns:** dns-lab / context: homelab
**목적:** resolution × 경로 매트릭스의 마지막 빈 칸 — `resolution: DNS_ROUND_ROBIN`(LOGICAL_DNS)
+ TLS passthrough(L4, SNI, VS/DR 없음) 경로에서 40초 in-flight raw TCP 스트림이 DNS flip을
버티는지. STRICT_DNS + passthrough는 D-2(`2026-07-09_134250_dns-passthrough-tcp.md` §5)에서
이미 생존 확인됨. LOGICAL이므로 생존은 예상대로였고, 이 실측은 그 예상을 membership_change/
upstream_cx_destroy 델타로 확정하는 것이 목적.

이 문서는 신규 파일이다. 기존 종합 리포트 `docs/test-reports/2026-07-01_dns-resolution.md`는
다른 작업이 편집 중이라 건드리지 않았다.

---

## 0. 시작 전 상태 확인 (절차 [1])

```
kubectl --context=homelab -n dns-lab get pods -o wide
```
```
NAME                         READY   STATUS    RESTARTS   AGE   IP
backend-a-b69577fc-kmbvp     1/1     Running   0          30m   10.255.194.124
backend-b-69bd4df99b-jlnpn   1/1     Running   0          85m   10.255.126.38
fortio-6bbcfcc5ff-jzfht      2/2     Running   0          85m   10.255.194.103
lab-dns-666f84996b-64d88     2/2     Running   0          75m   10.255.194.106
netshoot-76c869b576-h4fqh    2/2     Running   0          85m   10.255.126.8
```

pod IP 재확인 결과: backend-a=`10.255.194.124`, backend-b=`10.255.126.38`. lab-dns의
`/hosts/addn`은 이미 `10.255.194.124 gslb-pt.lab.internal`로 최신 상태(재조회 불필요, D 이후
pod 재시작 없었음).

```
kubectl -n dns-lab exec deploy/netshoot -- curl -sk -m 5 -w '\n[http_code=%{http_code}]\n' https://gslb-pt.lab.internal/
```
```
backend-a
[http_code=200]
```

sanity 통과. 시작 시점 SE 상태: `gslb-passthrough`(host `gslb-pt.lab.internal`, resolution `DNS`) —
STRICT_DNS 베이스라인 확인:
```
istioctl proxy-config cluster deploy/netshoot.dns-lab --fqdn gslb-pt.lab.internal -o json | grep '"type"'
  "type": "STRICT_DNS",
```

---

## 1. SE를 LOGICAL로 전환 (절차 [2])

신규 variant manifest 작성: `scenarios/50-dns-resolution/50-serviceentry-passthrough-logical.yaml`
(40/41 strict/logical 쌍 패턴을 48 passthrough SE에 적용 — 다음 사용 가능 번호가 50이라
`scenarios/50-dns-resolution/50-...`가 됐다. 폴더명과 파일 접두 숫자가 같은 "50"이지만 서로 다른
네임스페이스라 충돌 없음). host/port/protocol/exportTo는 48과 동일, `resolution`만
`DNS_ROUND_ROBIN`으로 변경. 48과 host가 같아 동시 적용 불가 → 정확히 하나만 유지.

```
kubectl --context=homelab delete -f scenarios/50-dns-resolution/48-serviceentry-passthrough.yaml
  serviceentry.networking.istio.io "gslb-passthrough" deleted from dns-lab namespace

kubectl --context=homelab apply -f scenarios/50-dns-resolution/50-serviceentry-passthrough-logical.yaml
  serviceentry.networking.istio.io/gslb-passthrough-logical created

kubectl --context=homelab -n dns-lab get serviceentry
NAME                       HOSTS                      LOCATION        RESOLUTION        AGE
gslb-passthrough-logical   ["gslb-pt.lab.internal"]   MESH_EXTERNAL   DNS_ROUND_ROBIN   1s
gslb-strict                ["gslb.lab.internal"]      MESH_EXTERNAL   DNS               89m
```

---

## 2. 전환 검증 — cluster type (절차 [3])

```
istioctl --context=homelab proxy-config cluster deploy/netshoot.dns-lab --fqdn gslb-pt.lab.internal -o json
```

핵심 요지:
```json
{
    "name": "outbound|443||gslb-pt.lab.internal",
    "type": "LOGICAL_DNS",
    "lbPolicy": "LEAST_REQUEST",
    "loadAssignment": {
        "endpoints": [{ "lbEndpoints": [{ "endpoint": {
            "address": { "socketAddress": { "address": "gslb-pt.lab.internal", "portValue": 443 } }
        }}]}]
    },
    "dnsRefreshRate": "60s",
    "dnsJitter": "0.100s",
    "respectDnsTtl": true,
    "dnsLookupFamily": "V4_ONLY",
    "filters": [{ "name": "istio.metadata_exchange" }]
}
```

`type: LOGICAL_DNS` 확인. `loadAssignment.endpoints[].lbEndpoints[].endpoint.address`가
IP가 아니라 hostname 문자열(`gslb-pt.lab.internal`) 자체로 박혀있다 — STRICT_DNS(48)의
"A record 전체 = 여러 lb_endpoint"와 달리 LOGICAL_DNS는 정적으로는 논리 1개 endpoint만
갖고, 실제 IP 해석은 Envoy 내부 c-ares 리졸버가 커넥션 시점에 수행한다.
`filters`에 `istio.metadata_exchange`만 있고 HTTP 관련 필터/protocol option은 없음
(48과 동일 — TLS protocol 선언이라 순수 TCP 클러스터, HTTP 승격 없음). CDS NACK 없음
(analyze 결과는 §6 참조), sanity curl 재확인 200/`backend-a`.

`istioctl analyze -n dns-lab`에서 이 SE 관련 경고 없음(기존 `gslb-tls-origination` DR
IST0129 경고만 그대로 — 무관한 사전 존재 경고).

---

## 3. in-flight 테스트 2런 (절차 [4])

하니스: `scripts/dns-passthrough-inflight.sh` (D-2와 동일, 무수정). 40s `/slow` 스트림
(payload 409610 bytes, `limit_rate 10k`) 도중 +5s에 `gslb-pt.lab.internal`을
backend-a IP → backend-b IP로 flip. 4점 스냅샷: BEFORE / MID1(+12s) / MID2(+30s) / AFTER(+43s).

### Run 1 (14:18:55 시작)

| 시점 | dig 결과 | endpoint(admin) | membership_change | upstream_cx_active | upstream_cx_destroy |
|---|---|---|---|---|---|
| BEFORE  | 10.255.194.124 (A) | 10.255.194.124:443 HEALTHY | 11 | 0 | 2973 |
| MID1 +12s | 10.255.126.38 (B, flip @+5s 반영됨) | 10.255.126.38:443 HEALTHY | 11 | 1 | 2973 |
| MID2 +30s | 10.255.126.38 (B) | 10.255.126.38:443 HEALTHY | 11 | 1 | 2973 |
| AFTER +43s | 10.255.126.38 (B) | 10.255.126.38:443 HEALTHY | 11 | 0 | 2974 |

curl 결과: `CURL_EXIT=0 SIZE=409610 TIME=40.055717 HTTPCODE=200`
received file 식별(`head -c 15 /tmp/ptX.bin`): `backend-a\nXXXXX` — 스트림 전체가
**flip 이전에 접속한 backend-a**로부터 온 바이트임을 payload 내 식별 문자열로 확인.

### Run 2 (14:37:18 시작)

| 시점 | dig 결과 | endpoint(admin) | membership_change | upstream_cx_active | upstream_cx_destroy |
|---|---|---|---|---|---|
| BEFORE  | 10.255.194.124 (A) | 10.255.194.124:443 HEALTHY | 11 | 0 | 2976 |
| MID1 +12s | 10.255.126.38 (B) | 10.255.126.38:443 HEALTHY | 11 | 1 | 2976 |
| MID2 +30s | 10.255.126.38 (B) | 10.255.126.38:443 HEALTHY | 11 | 1 | 2976 |
| AFTER +43s | 10.255.126.38 (B) | 10.255.126.38:443 HEALTHY | 11 | 0 | 2977 |

curl 결과: `CURL_EXIT=0 SIZE=409610 TIME=40.047827 HTTPCODE=200`
received file 식별: `backend-a\nXXXXX` — run 1과 동일하게 flip 전 backend-a 유지.

두 런 모두 커브 사이 원복(script 자체가 curl 완료 후 gslb-pt를 backend-a 현재 IP로 재작성,
sleep 6, dig+curl로 확인)이 성공했고, run2 시작 전 재확인한 baseline도 `10.255.194.124`/
`backend-a`/200으로 정상.

---

## 4. 판정

**생존함 — 2/2런 모두 curl exit 0, HTTPCODE 200, SIZE 409610(전체 payload), 스트림 내
식별 문자열이 flip 이전 접속처(backend-a)로 시종 고정.** LOGICAL_DNS + TLS passthrough는
40초 in-flight 동안 DNS가 A→B로 바뀌어도 기존 raw TCP 연결을 그대로 유지한다.

STRICT passthrough(D-2)와의 델타 패턴: **두 경로 모두 "생존"이라는 결과는 같지만, 그 근거가
되는 카운터 거동이 다르다.** D-2(STRICT_DNS)는 host set에서 사라진 IP가 제거되는 이벤트가
cluster membership 갱신으로 잡히므로 flip 시점에 `membership_change`가 증가할 여지가
있는 구조였다(raw TCP라 실제 destroy는 안 일어나지만 membership 자체는 EDS류 갱신
대상). 반면 이번 LOGICAL_DNS 런에서는 `membership_change`가 BEFORE→AFTER 전 구간에서
**11→11로 완전히 고정**이었다 — flip이 일어나도 "논리적으로는 여전히 host 1개"이므로
membership 갱신이라는 개념 자체가 발생하지 않는다(admin의 endpoint IP 표시는 바뀌지만
이는 다음 커넥션이 참조할 해석 결과가 갱신된 것이지 cluster의 host 집합 변경이 아니다).
`upstream_cx_destroy`도 flip 윈도우(BEFORE→MID2) 동안 미동(2973/2976 고정), curl 완료
직후에야 +1(자연 종료) — flip에 의한 강제 종료가 아니라 스트림이 끝나서 닫힌 정상 종료.
즉 STRICT와 LOGICAL 둘 다 "raw TCP라 in-flight는 안 끊긴다"는 결과는 같지만, **LOGICAL은
애초에 flip을 membership 이벤트로 취급하지 않는다는 점에서 한 단계 더 무관심하다** —
STRICT는 "정리 대상이지만 TCP라 정리할 방법이 없어서" 살아남고, LOGICAL은 "애초에 정리
대상 자체로 안 잡혀서" 살아남는다는 차이.

---

## 5. 원상복구 (절차 [5])

```
kubectl --context=homelab delete -f scenarios/50-dns-resolution/50-serviceentry-passthrough-logical.yaml
  serviceentry.networking.istio.io "gslb-passthrough-logical" deleted from dns-lab namespace

kubectl --context=homelab apply -f scenarios/50-dns-resolution/48-serviceentry-passthrough.yaml
  serviceentry.networking.istio.io/gslb-passthrough created

kubectl --context=homelab -n dns-lab get serviceentry
NAME               HOSTS                      LOCATION        RESOLUTION   AGE
gslb-passthrough   ["gslb-pt.lab.internal"]   MESH_EXTERNAL   DNS          0s
gslb-strict        ["gslb.lab.internal"]      MESH_EXTERNAL   DNS          112m

istioctl proxy-config cluster deploy/netshoot.dns-lab --fqdn gslb-pt.lab.internal -o json | grep '"type"'
  "type": "STRICT_DNS",
```

cluster type STRICT_DNS로 정상 원복 확인.

### 트러블슈팅 — `/hosts/addn` 통째 덮어쓰기 사고

원복 직후 기존 HTTP 경로(`http://gslb.lab.internal/`) sanity가 `curl exit 6`
(Could not resolve host)로 실패했다. 원인: `dns-passthrough-inflight.sh`가
`/hosts/addn`을 매 flip마다 `printf ... > /hosts/addn`(append가 아니라 **덮어쓰기**)로
쓴다 — D-2 작성 당시엔 이 host 하나만 `/hosts/addn`에 있었지만, 지금은 이 파일에
`gslb.lab.internal`(HTTP 경로)과 `gslb-pt.lab.internal`(passthrough 경로) 두 줄이
같이 들어있었다. 스크립트를 그대로 재사용하면서 이 사실을 놓쳐, 첫 flip 호출 시점에
`gslb.lab.internal` 줄이 통째로 사라졌다(passthrough 경로 자체는 영향 없음 — 그 줄만
다시 쓰였으므로 이 작업의 curl 결과에는 영향 없음).

복구:
```
kubectl -n dns-lab exec deploy/lab-dns -c writer -- sh -c \
  "printf '%s gslb.lab.internal\n%s gslb-pt.lab.internal\n' '10.250.161.145' '10.255.194.124' > /hosts/addn"
```
(`10.250.161.145` = backend-a Service ClusterIP, 원래 gslb.lab.internal이 가리키던 값 —
`kubectl -n dns-lab get svc backend-a`로 재확인해서 복원). 복구 직후 첫 호출은
`503 no healthy upstream`(CoreDNS reload/헬스체크 반영 지연으로 보이는 일시적 현상)이었고,
1초 뒤 재시도 3회 연속 200으로 안정화 확인.

최종 확인:
```
kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb.lab.internal      -> 10.250.161.145
kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb-pt.lab.internal   -> 10.255.194.124
kubectl -n dns-lab exec deploy/netshoot -- curl -s  -m5 http://gslb.lab.internal/   -> backend-a [200] (3회 연속)
kubectl -n dns-lab exec deploy/netshoot -- curl -sk -m5 https://gslb-pt.lab.internal/ -> backend-a [200]
kubectl -n dns-lab get pods  -> 전 파드 READY, RESTARTS 0
```

**주의(후속 작업 필요):** `scripts/dns-passthrough-inflight.sh`의 `/hosts/addn` 덮어쓰기
로직은 다른 host가 이 파일을 공유하는 한 재사용할 때마다 이 사고가 재발한다. 스크립트를
append 방식(예: `grep -v` 로 대상 줄만 치환 후 재조합)으로 고치거나, 최소한 실행 전에
`/hosts/addn` 스냅샷을 뜨고 실행 후 병합 복원하는 절차를 하네스에 넣는 게 맞다 — 이번엔
수동으로 눈치채서 복구했지만 다음 사용자가 이걸 모르고 돌리면 무음으로 HTTP 경로가
끊긴다.

---

## 6. 종료 상태 / 이상 징후 / 원시 로그

- 최종 SE: `gslb-passthrough`(DNS/STRICT_DNS), `gslb-strict`(DNS/STRICT_DNS) 2개만 존재.
  신규 `gslb-passthrough-logical`은 삭제 완료(cluster에 잔존 없음).
- 신규 repo 파일: `scenarios/50-dns-resolution/50-serviceentry-passthrough-logical.yaml`
  (STRICT 원복 후에도 repo에는 variant manifest로 남겨둠 — 재실험용, 클러스터엔 미적용 상태).
- 전 파드 READY, RESTARTS 0 (backend-a/b, fortio, lab-dns, netshoot).
- `istioctl analyze -n dns-lab` 경고: 기존 IST0129(`gslb-tls-origination` DR, caCertificates
  미설정 — 사전 존재, 이 작업과 무관) 외 신규 경고 없음.
- 이상 징후: §5의 `/hosts/addn` 덮어쓰기 사고(복구 완료) 외에는 없음. flip 자체나
  LOGICAL_DNS 전환 과정에서 CDS NACK, sidecar 비정상, curl 실패 없었음.
- 원시 로그:
  - `tmp/dns-passthrough/f0-logical/run1.txt`, `tmp/dns-passthrough/f0-logical/run2.txt`
    (이 리포트의 표 원본, 콘솔 tee 출력)
  - `tmp/dns-passthrough/d2/full_run.txt` (하니스 자체 tee 대상 — run1/run2 공통 경로라
    run2 실행 시 run1 내용이 덮어써짐; 표는 `f0-logical/run{1,2}.txt`가 1차 출처)
