# ServiceEntry resolution(STRICT_DNS vs LOGICAL_DNS) GSLB 세션 연속성 라이브 검증

**Date:** 2026-07-01 (본문) · **2026-07-02 후속 실측 A·B 추가**(§9~§13) · **2026-07-09 후속 실측 D 추가**(§14) · **cluster:** homelab · **ns:** dns-lab · **Istio:** 1.30.0
**시나리오:** `scenarios/50-dns-resolution/` · **하네스:** `scripts/dns-lab-setup.sh`, `scripts/dns-flip-test.sh`,
`scripts/dns-inflight-4snap.sh`(신규, §9~§10), `scripts/dns-churn-stages.sh`(신규, §11),
`scripts/dns-passthrough-churn.sh`·`dns-passthrough-inflight.sh`·`dns-passthrough-outlier.sh`(신규, §14)
**원시 로그(2026-07-01):** `2026-07-01_193631_dns-strict-mode1.md`, `_193800_dns-logical-mode1.md`, `_195325_dns-logical-mode3.md`, `_195854_dns-strict-mode2.md`
**원시 로그(2026-07-02, 후속):** `2026-07-02_104250_dns-strict-inflight.md`(§9), `_112113_dns-inflight-part2.md`(§10), `_121151_dns-mode2-churn.md`(§11)
**원시 로그(2026-07-09, 후속 D):** `2026-07-09_134250_dns-passthrough-tcp.md`(§14)
**후속 실측 신규/수정 manifest:** `scenarios/50-dns-resolution/20-backends.yaml`(수정, `/slow` 엔드포인트 추가),
`44-destinationrule-tls-outlier.yaml`(교정, portLevelSettings 함정), `46-destinationrule-tls-churn.yaml`(신규),
`47-destinationrule-tls-churn-outlier.yaml`(신규), `48-serviceentry-passthrough.yaml`(신규, §14),
`49-destinationrule-passthrough-outlier.yaml`(신규, §14 D-3 전용), `10-lab-dns.yaml`(수정, §14 zone 추가)

> 초안(construction 로그). "기대 vs 실제 합격표"가 아니라 **어떻게 구성했고, 그때 어떤 숫자가 보였고, 왜 그런가**의 기록.
> mode2·mode3에서 예측과 다른 결과가 나온 부분은 숨기지 않고 그대로 남긴다. §9 이후는 2026-07-02 후속 실측, §14는
> 2026-07-09 후속 실측(TLS passthrough 대조) — 2026-07-01 본문과 어긋나는 서술은 지우지 않고 §12에 "당시 해석 →
> 재실측 교정"으로 표로 남겼다. §14는 "교정"이 아니라 "적용 경계"에 가깝다 — A·B의 결론 자체는 뒤집히지 않았고,
> 그 결론이 성립하는 경로(L7/TLS origination)의 범위가 드러났다.

---

## 1. 무엇을 / 왜

`ServiceEntry.resolution` 두 값이 GSLB(질의마다 다른 IP를 주는 DNS) 앞에서 **기존 세션을 어떻게 다루는가**를 홈랩에 재현했다.
사내 Istio egress 도입 시, 외부 GSLB 도메인을 SE로 등록할 때 이 한 필드가 "장애 시 세션이 끊기느냐/붙어있느냐"를 가른다 —
프로덕션에 옮기기 전에 **숫자로** 확인하려는 것이 목적.

- `resolution: DNS` → Envoy `STRICT_DNS`
- `resolution: DNS_ROUND_ROBIN` → Envoy `LOGICAL_DNS`

이 매핑은 config_dump로 확증했다(§3).

## 2. 랩을 어떻게 구성했나

```
+------------------+   http://gslb.lab.internal:80   +------------------+
|  client pod      | ------------------------------> |  client sidecar  |
|  (fortio +       |   (dnsPolicy:None →             |  (Envoy)         |
|   netshoot,      |    /etc/resolv.conf = lab-dns)  |                  |
|   sidecar 주입)  |                                 |  VS: 80 -> 443   |
+------------------+                                 |  DR: TLS orig.   |
        |                                            +--------+---------+
        | dig gslb.lab.internal                               | TLS :443
        v                                                     v
+------------------+   A record flip                 +------------------+   +------------------+
|  lab-dns         |   (writer sidecar가             |  backend-a :443  |   |  backend-b :443  |
|  (CoreDNS,       |    /hosts/addn 재기록)          |  body "backend-a"|   |  body "backend-b"|
|   hosts plugin,  | <-----------------------------  |  (sidecar 없음)  |   |  (sidecar 없음)  |
|   ttl 5, reload) |                                 +------------------+   +------------------+
+------------------+
```

핵심 설계 결정과 이유:

- **사설 GSLB 시뮬레이터 = CoreDNS `hosts` plugin.** `gslb.lab.internal`의 A레코드를 파일(`/hosts/addn`)로 서빙하고,
  `ttl 5 / reload 2s`로 두어 파일만 다시 쓰면 몇 초 내 응답 IP가 바뀐다 = GSLB가 다른 IP를 주는 상황. distroless coredns라
  shell이 없어 **busybox writer 사이드카**가 같은 emptyDir(`/hosts`)에 파일을 쓴다.
- **클러스터 공용 인프라 무변경.** cluster CoreDNS를 건드리지 않고, client 파드만 `dnsPolicy:None + dnsConfig.nameservers=[lab-dns]`로
  자기 resolv.conf를 사설 DNS로 덮는다. `gslb.lab.internal` 외 이름(istiod 등)은 lab-dns가 `forward`로 cluster DNS에 넘겨 계속 resolve.
- **L7이 보이게 TLS origination.** client는 평문 `:80`으로 부르고, VS가 `:443`으로 재작성, DR이 `SIMPLE` TLS로 origination한다.
  passthrough(L4 SNI)로 하면 draining이 Envoy 눈에 안 잡혀 STRICT/LOGICAL 차이가 사라지므로 **일부러 L7 경로**로 구성.
- **관측 채널 2개 분리.** ① `fortio load -keepalive`(장기 세션, backend에 upstream 연결을 워밍) ② `netshoot`의 매 요청 curl 루프
  (응답 body로 "지금 누가 응답하나 who=" 판정). 세션이 붙어있는지와 트래픽이 어디로 가는지를 각각 본다.
- **cx 카운터를 강제 노출.** Istio 기본 stats matcher가 cardinality 절감 차원에서 cluster 단위 `upstream_cx_*`를 억제한다.
  client 파드에 `proxy.istio.io/config: proxyStatsMatcher.inclusionRegexps: [".*gslb.*"]` annotation을 붙여 `upstream_cx_destroy`
  같은 정본 카운터를 되살렸다. 억제와 무관하게 항상 나오는 `pilot-agent request GET clusters`의 per-endpoint cx도 병행 채집.

## 3. resolution → Envoy 클러스터 타입 (정본)

```
# resolution: DNS
istioctl pc cluster deploy/fortio.dns-lab --fqdn gslb.lab.internal --port 443 -o json | grep '"type"'
  "type": "STRICT_DNS"

# resolution: DNS_ROUND_ROBIN
  "type": "LOGICAL_DNS"
```

SE의 한 필드가 Envoy CDS 클러스터 타입으로 1:1 매핑된다. 이후 거동 차이는 전부 이 타입 차이에서 나온다.

## 4. 핵심 대조 — STRICT vs LOGICAL, IP flip 시 (mode1)

동일 시나리오: backend-a로 keepalive 롱세션 워밍 → **flip A→B** → load 유지 중 flip 전(3a)·후(3b) 스냅샷.
카운터는 누적이므로 **같은 런 안의 3a→3b 델타**만 본다.

| 관측 지표 | **STRICT_DNS** 3a→3b | **LOGICAL_DNS** 3a→3b | 해석 |
|---|---|---|---|
| endpoint(clusters) | backend-a → **backend-b만** | backend-a → backend-b(라벨만) | STRICT은 endpoint 교체, LOGICAL은 논리 host 1개 |
| `membership_change` | 2 → **3** (+1) | 4 → **4** (0) | STRICT만 멤버십 변동 발생 |
| `upstream_cx_destroy` | 0 → **2** | 4 → **4** (0) | **STRICT은 기존 연결 2개 파괴, LOGICAL은 0** |
| `upstream_cx_destroy_local` | 0 → **2** | (변화 없음) | STRICT의 파괴 주체 = **Envoy(사이드카)** = drain |
| `upstream_cx_total` | 2 → **4** (+2) | 6 → **6** (0) | STRICT만 신규 재연결, LOGICAL은 신규 0 |
| **who= (실제 응답 backend)** | flip +5s에 **backend-a→backend-b** | 40s 내내 **backend-a** | **STRICT은 트래픽 이전, LOGICAL은 세션 유지** |
| fortio downstream | Sockets 2, 100% 200 | Sockets 2, 100% 200 | **동일** — 차이는 전부 upstream에 |

읽는 법:

- **STRICT_DNS**: flip → Envoy가 재해석 → A endpoint 제거 → 그 위의 연결을 **능동 drain(`destroy_local +2`)** → B로 재연결(`cx_total +2`).
  트래픽은 항상 GSLB를 따라가지만, **기존 세션은 끊긴다**. 여기선 요청이 1ms로 짧아 `destroy_with_active_rq: 0`(진행 중 요청 없이
  깔끔히 drain)이라 무손실이었지만, **장수명 gRPC/스트리밍이면 이 지점에서 그 스트림이 잘린다.**

  > **[2026-07-02 재실측 교정]** 마지막 문장("장수명 스트리밍이면 이 지점에서 그 스트림이 잘린다")은 이후 40초짜리
  > 실제 장수명 요청(`/slow`, §9~§10)으로 직접 실측한 결과 **반박되었다**. STRICT flip은 in-flight 스트림을 즉시
  > 자르지 않는다 — host가 제거된 뒤에도 그 위에 물린 요청은 완료될 때까지 보호되고, `destroy_local`은 요청이
  > 자연 종료된 **직후**에만 발생한다(graceful drain). "1ms라 무손실이었을 뿐 장수명이면 잘린다"는 당시 가장 그럴듯한
  > 가설이었지만, 요청 길이를 40초까지 늘려 재검증한 결과와 맞지 않는다. 상세: §9~§10.
- **LOGICAL_DNS**: flip → 논리 host 1개 유지 → 기존 연결 그대로(`cx_destroy` 델타 0) → 커넥션 재사용으로 **who=backend-a 지속**.
  **세션은 살지만 GSLB 의도(새 IP)를 무시**한다.

### 4-1. LOGICAL의 관측 함정 — 덤프는 새 IP, 바이트는 옛 IP

LOGICAL 3b에서 `clusters`/`proxy-config endpoints` 덤프는 endpoint를 `10.250.27.55`(backend-b)로 **라벨을 바꿔 보여줬다**
(논리 host의 resolved 주소 갱신). 그런데 `cx_total`은 그대로였고 who=backend-a였다. 즉:

> **관측 도구는 "backend-b에 연결됨"이라 말하지만, 실제 패킷은 여전히 backend-a로 흐른다.**

운영에서 `istioctl proxy-config endpoints`만 믿고 "트래픽이 새 IP로 옮겨갔다"고 판단하면 틀린다. 진실은 응답 body(또는 패킷캡처)에만 있다.

## 5. LOGICAL 심화 — 핀된 backend가 죽으면? (mode3)

flip A→B(세션 유지 확인) 후, **backend-a를 kill**해서 stale pin이 어떻게 풀리는지 봤다.

- flip A→B(19:53:45): who=backend-a 지속(§4 재확인).
- **backend-a KILL(19:54:05): 그 즉시 who=backend-b로 전환. `code!=200` 카운트 = 0 (무손실).**
- `upstream_cx_destroy_remote +1`(backend-a 사망이 연결을 remote로 닫음), `upstream_cx_total +2`(backend-b로 신규),
  `destroy_with_active_rq: 0`.

예측과 달랐던 점(정직하게 남김): 스크립트 주석은 "KILL 시 잠깐 `code!=200`(재연결 blip)"을 예상했으나 **실측은 에러 0**.
이유 — 요청이 짧아 죽은 연결이 요청 **사이**에 감지되고, 이미 DNS가 backend-b를 가리키므로 Envoy가 조용히 backend-b로 재연결.
따라서 **LOGICAL의 진짜 비용은 "에러"가 아니라**:
1. **GSLB 의도 무시** — 더 가깝/싼 backend로 옮기라는 신호를 무시하고 옛 IP에 계속 핀(로컬리티·비용 손해).
2. **장수명 요청 절단** — 핀된 backend가 죽을 때 그 위의 in-flight 스트림은 잘린다(`destroy_with_active_rq`가 오르는 경우).

## 6. STRICT dead-IP — 유실이 warm connection에 마스킹됨 (mode2, 부분 성공)

의도: STRICT에서 DNS에 죽은 IP(backend-a)가 남아있을 때 유실이 나고, outlier detection + retry로 복구되는 것을 보이려 함.

실측: `flip_both`(A,B 둘 다 등록) 후 backend-a kill → **유실 구간 35요청 전부 200/backend-b, 복구 구간도 전부 200. 유실 0.**

원인 진단 — 버그가 아니라 메커니즘:
- netshoot 루프가 **backend-b로 이미 워밍된 upstream 연결을 재사용** → 죽은 A로 **새 연결을 열지 않으니** LB가 A를 뽑을 일이 없음.
- 즉 **STRICT의 dead-IP 유실은 "새 연결이 죽은 endpoint를 뽑는 순간"에만 발생**하고, warm connection이 있으면 가려진다.
  순진한 "50% 유실" 예측보다 이게 더 정확한 운영 통찰.
- (부수: AFTER 스냅샷의 `rq_total=0`은 outlier DR 적용이 클러스터를 **재빌드**해 카운터가 리셋된 것.)

→ **한계로 기록**: 이 mode는 유실을 재현하려면 keepalive를 끄거나 새 연결을 강제(connection churn)해야 한다. 이는 DNS resolution 본질이
아니라 **outlier detection** 영역이므로, 별도 시나리오(30-security 인접)로 분리하는 것이 맞다. 지금 하네스에서는 미완으로 둔다.

> **[2026-07-02 재실측 교정]** 이 한계는 §11에서 실제로 메워졌다 — DR `maxRequestsPerConnection: 1`로 upstream 커넥션
> churn을 강제하니 dead-IP를 매 요청 뽑을 기회가 생겼다. 단 결과는 예상("유실이 나고 outlier/retry로 0에 수렴")과
> 달랐다: **유실은 처음부터 0이었다**(Envoy가 VS retry 정책 유무와 무관하게 connect-failure를 자동 재시도하기 때문).
> 실제 비용은 유실이 아니라 **지연세**(dead IP를 뽑을 때마다 `connectTimeout`만큼 대기)였고, outlier detection의 진짜
> 효과는 "유실 방지"가 아니라 "이 지연세 제거"였다. §11 참조.

## 7. 트러블슈팅 — 이 랩이 드러낸 환경 함정 (프로덕션 이식에 직결)

첫 셋업에서 client 파드의 sidecar가 안 떴다(`fortio` rollout timeout, 15021 startup probe refused). 로그:

```
dial tcp: lookup istiod.istio-system.svc on 10.250.217.19:53: no such host
```

근본 원인: **이 클러스터의 cluster domain이 `cluster.local`이 아니라 `homelab.local`.**
매니페스트가 kubespray 기본값 `cluster.local`을 가정해 client `dnsConfig.searches`에 `svc.cluster.local`을 박았는데,
이 이름은 이 클러스터에 존재하지 않아 istiod FQDN이 전부 NXDOMAIN → sidecar가 XDS/CA에 못 붙음 → Envoy 안 뜸 → rollout 실패.

- 정상 파드 resolv.conf가 정답을 갖고 있었다: `search ... svc.homelab.local homelab.local`.
- istiod 실제 FQDN = `istiod.istio-system.svc.homelab.local` (→ 10.250.46.228).

**수정(하드코딩 제거, 이식성 확보):** `scripts/dns-lab-setup.sh`가 클러스터 coredns configmap에서 도메인을 자동 발견해 치환하도록 변경.

```bash
CLUSTER_DOMAIN="$(kubectl -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' \
                 | grep -oE 'kubernetes[[:space:]]+[^ ]+' | awk '{print $2}')"   # -> homelab.local
CLUSTER_DOMAIN="${CLUSTER_DOMAIN:-cluster.local}"   # 실패 시 표준값 폴백
sed -e "s/__LABDNS_IP__/.../g" -e "s/__CLUSTER_DOMAIN__/$CLUSTER_DOMAIN/g" 30-client.yaml | kubectl apply -f -
```

이건 랩만의 문제가 아니다. **사내 클러스터가 커스텀 domain을 쓰면, per-pod DNS를 덮는 어떤 구성(이 랩, 또는 DNS proxy off 워크로드)도
같은 함정에 빠진다.** "cluster.local 하드코딩 금지, domain은 발견해서 주입"이 이식 규칙.

(추가 강건화: 셋업이 gslb hosts 파일을 client rollout **전에** 기록하도록 순서 변경 — 부분 실패에도 gslb 응답이 유지되게.)

## 8. 프로덕션 함의 (사내 egress 도입 시)

| 상황 | 권장 resolution | 이유 |
|---|---|---|
| GSLB/이동성 도메인 + **장수명 연결**(gRPC, DB, 스트리밍) | `DNS_ROUND_ROBIN`(LOGICAL) | flip에 세션이 안 끊김. 단 stale pin·관측 함정(§4-1) 인지 필요 |
| GSLB 신호를 **빠르게 따라가야** 하는 짧은 HTTP | `DNS`(STRICT) | 항상 최신 IP. 대신 flip 시 기존 연결 drain 감수 |
| 멀티-IP A레코드를 **동시 로드밸런싱** | `DNS`(STRICT) | LOGICAL은 multi-IP A레코드에서 CDS NACK(단일 논리 endpoint 제약) |

핵심 트레이드오프 한 줄: **STRICT=최신성(GSLB 추종) 우선·세션 희생, LOGICAL=세션 연속성 우선·최신성 희생.**

> **[2026-07-02 재실측 보완]** "STRICT는 flip 시 세션을 희생한다"는 문장은 **신규 연결이 어느 IP로 붙는가**의
> 관점에서는 여전히 맞다(다음 요청은 반드시 새 IP). 다만 "이미 진행 중인 요청이 그 자리에서 잘린다"는 뜻으로 읽으면
> 안 된다는 게 §9~§10의 발견이다 — in-flight 요청은 STRICT에서도 완료까지 보호된다. "희생되는 것"은 진행 중인
> 요청이 아니라 **그 요청이 붙어 있던 커넥션의 수명**(완료 즉시 폐기되어 다음 요청은 재사용을 못 함)이다.

---

## 9. 후속 실측 A(1) — STRICT flip이 in-flight 요청을 자르는가 (2026-07-02)

§4/§8이 남긴 질문 그대로: "요청이 1ms라 무손실이었을 뿐, 장수명이면 flip 순간 그 스트림이 잘리는가?" 이걸 짧은
요청을 늘리는 게 아니라 **실제로 30~60초짜리 요청을 만들어** 직접 봤다.

**무엇을 만들었나 — `scenarios/50-dns-resolution/20-backends.yaml` 수정:**

두 backend의 nginx ConfigMap에 `/slow` location을 추가하고, Deployment에 busybox initContainer로 결정적 payload를
생성하게 했다(레포에 400KB 블롭을 커밋하는 대신 pod 시작 시 생성).

```yaml
location /slow {
    add_header X-Backend "backend-a" always;   # backend-b는 문자열만 치환
    limit_rate 10k;                             # 10KB/s 로 제한 -> 409610B/10KB/s ≈ 40초
    alias /var/www/slow/payload.bin;
}
```

```yaml
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: {} }
```

**왜 이렇게:** `limit_rate 10k`로 400KB 응답을 강제로 40초에 걸쳐 스트리밍시켜, "요청 진행 중(in-flight)에 flip이
끼어드는" 창을 확실히 확보했다(요구된 30~60s 구간 중앙). 어느 backend가 응답했는지는 `X-Backend` 헤더와 body 첫
줄 양쪽으로 식별 가능하게 해 flip 전/후 판정을 이중화했다.

**실행 중 겪은 타이밍 함정 (그대로 기록):** curl 시작과 flip을 별개의 top-level 백그라운드 Bash 호출 2개로
나눠 보냈더니, 실제 착수 시각이 **최대 2분 이상** 어긋나(curl이 이미 완주된 뒤에야 flip 실행) 무효 시도가 두 번
났다. 해결책은 **curl과 flip을 같은 셸 스크립트 안에서 `&`+`sleep`+`wait`로 묶어 단일 Bash 호출로 실행**하는
것 — 이 패턴이 `scripts/dns-inflight-4snap.sh`(C-0로 repo 편입)의 골격이다.

**결과 (2/2 런, `docs/test-reports/2026-07-02_104250_dns-strict-inflight.md` 전문):**

| | Run 1 | Run 2 |
|---|---|---|
| flip 시점 | 요청 시작 +5s (진행률 12.5%) | 요청 시작 +5s |
| curl 결과 | `CODE=200`, `SIZE=409610`(전량), `TIME=40.05s` | `CODE=200`, `SIZE=409610`(전량), `TIME=40.05s` |
| `upstream_cx_destroy_with_active_rq` 델타 | **0** | **0** |
| `membership_change` 델타 | +1 (host 제거 반영) | +1 |
| `upstream_cx_destroy_local` 델타 | +1 | +1 |

두 런 모두 **절단 미재현** — flip이 요청 진행 중(t=+5s, 완주까지 아직 35초 남은 시점)에 일어났는데도 curl은
에러 없이 기대 바이트 전량을 받았다. `destroy_with_active_rq`(요청이 살아있는 채로 연결이 파괴됨을 세는 카운터)가
두 런 모두 0이었다는 것이 "이 연결이 in-flight 상태로 끊긴 게 아니다"의 직접 증거다. 단, before/after 2점
스냅샷만으로는 `destroy_local +1`이 **정확히 언제** 일어났는지(요청 중간인지 완료 후인지)까지는 구분할 수
없었다 — 그래서 후속 A(2)로 스냅샷을 4점으로 늘렸다.

---

## 10. 후속 실측 A(2) — destroy_local의 정확한 시점 + LOGICAL 대조군 (2026-07-02)

**무엇을 만들었나:** A(1)의 단일 스크립트 패턴을 **4점 스냅샷**(BEFORE / MID1 flip+12s / MID2 flip+30s / AFTER
완주+3s)으로 확장한 하네스 — `scripts/dns-inflight-4snap.sh`(스크래치패드 `run4snap.sh`를 repo 편입, C-0).
**왜 4점인가:** 2점(before/after)으로는 "flip 반영"과 "연결 정리"가 같은 시점인지 순서가 있는지 구분이 안 된다.
중간에 두 번(+12s, +30s) 더 찍으면 "언제 membership이 바뀌고, 언제 destroy가 따라오는지"를 시계열로 확정할 수 있다.

**STRICT 4점 결과 (netshoot 사이드카, 1런):**

| counter | BEFORE | MID1(+12s) | MID2(+30s) | AFTER(+43s) |
|---|---|---|---|---|
| membership_change | 17 | **18(+1)** | 18 | 18 |
| upstream_cx_active | 0 | **1** | **1** | **0** |
| upstream_cx_destroy / destroy_local | 51 / 7 | 51 / 7 (정지) | 51 / 7 (정지) | **52(+1) / 8(+1)** |
| endpoint 덤프 | backend-a | **backend-b** | backend-b | backend-b |

읽는 법: **MID1 시점에 이미 host는 제거됐다**(membership_change +1, endpoint 덤프 A→B). 그런데 destroy 계열은
MID1·MID2 내내 완전히 정지해 있다 — 그 사이 `cx_active=1`(우리의 in-flight 연결)이 그대로 유지된다. `destroy`는
**요청이 자연 완료된 직후(AFTER)**에야 +1 된다. 즉:

> **결론 (part1의 "유휴 연결을 정리했을 뿐" 가설을 기각하고 확정): STRICT_DNS는 host를 제거해도 그 host에 물린
> in-flight 연결을 즉시 끊지 않는다. active stream이 끝날 때까지 유예(graceful drain)했다가, 완료되는 즉시
> Envoy가 로컬에서 그 연결을 폐쇄한다.**

**부수 발견 — 관측 사각지대:** MID1/MID2에서 cluster 합계는 `cx_active=1`인데, **per-endpoint 덤프(`proxy-config
endpoints`)에는 그 연결이 아예 안 보인다** — 이미 제거된 host 소속이라 endpoint 목록에서 사라졌기 때문이다.
운영에서 `proxy-config endpoints`만 보고 "drain 끝났다"고 판단하면 착시가 생길 수 있다 — cluster 레벨
`upstream_cx_active`를 반드시 같이 봐야 한다.

**LOGICAL 대조군 (동일 시나리오, 2런, 결정적 재현):**

| 관측 | STRICT_DNS | LOGICAL_DNS |
|---|---|---|
| in-flight 보호 | 보호(완료까지 유지) | 보호(완료까지 유지) — **양쪽 동일** |
| 요청 완료 후 연결 | **즉시 폐쇄**(`destroy_local`+1, `cx_active` 1→0) | **pool에 존속**(`cx_active=1` 잔존, `destroy` 델타 0) |
| 다음 요청의 행선지 | backend-b(새 연결 강제) | 이 연결이 재사용되면 계속 backend-a |

**핵심 재정리**: in-flight 보호는 STRICT/LOGICAL 양쪽 다 동일하다 — 차이는 **요청이 끝난 뒤**에만 갈린다.
GSLB 추종 속도 차이(§8)는 "진행 중 요청을 자르느냐"가 아니라 "완료된 연결을 재사용시키느냐"에서 발생한다.

원시 로그·전 카운터 표: `docs/test-reports/2026-07-02_112113_dns-inflight-part2.md`.

---

## 11. 후속 실측 B — dead-IP churn 강제, "유실"이 아니라 "지연세" (2026-07-02)

§6(mode2)이 남긴 과제 그대로: "warm connection이 유실을 가렸다 → connection churn을 강제해야 진짜 유실을 볼 수
있다." 이번엔 그 churn을 실제로 만들었다. 상세 원시 로그: `docs/test-reports/2026-07-02_121151_dns-mode2-churn.md`.

**churn을 만드는 두 번의 실측 함정:**

1. **client 쪽 keepalive 끄기/curl 반복은 안 통한다.** `fortio -keepalive=false`로 82개 요청을 쳤더니 downstream
   소켓은 82개 새로 열렸지만(`Sockets used: 82`), **upstream(Envoy→backend) 신규 커넥션은 겨우 4개**뿐이었다
   (`upstream_cx_total` 델타 +4/82). Envoy는 downstream이 몇 번 여닫히든 upstream pool을 독립적으로 재사용한다 —
   이게 2026-07-01 mode2가 유실을 못 만든 정확한 이유였고, client 쪽 설정으로는 못 고친다.
2. **DR `connectionPool.http.maxRequestsPerConnection: 1`이 유일한 해법인데, 처음엔 이것도 안 먹었다.** 새 파일
   `46-destinationrule-tls-churn.yaml`에 이 필드를 `trafficPolicy` **top-level**에 뒀더니 여전히 델타 4/82로
   무변화. `config_dump`로 원인 확인: **`portLevelSettings`에 자기 포트(443) 항목이 있으면, 그 포트는 top-level의
   다른 필드(connectionPool 등)를 아예 상속하지 않는다.** `portLevelSettings[443]` 블록 **안**으로 옮기자 비로소
   `typed_extension_protocol_options.common_http_protocol_options.max_requests_per_connection: 1`이 클러스터에
   반영됐고, 재실행 결과 `upstream_cx_total` 델타가 **+82(요청수와 정확히 1:1)**로 뛰었다 — 이때부터 진짜 churn.

```yaml
# 46-destinationrule-tls-churn.yaml 핵심부 (교정 후, portLevelSettings 안)
trafficPolicy:
  portLevelSettings:
    - port: { number: 443 }
      connectionPool:
        tcp: { connectTimeout: 1s }         # dead ClusterIP connect hang 방지
        http: { maxRequestsPerConnection: 1 } # 매 요청마다 upstream 연결 폐기+재생성 = churn 강제
      tls: { mode: SIMPLE, sni: gslb.lab.internal, insecureSkipVerify: true }
```

**부수 발견 — 기존 `44-destinationrule-tls-outlier.yaml`도 같은 함정에 빠져 있었다.** `outlierDetection`을
top-level에 두고 `portLevelSettings[443]`엔 `tls`만 있던 원래 44는, config_dump로 확인한 결과 **443 클러스터에
`outlier_detection` 필드 자체가 없었다** — 즉 **이 DR은 2026-07-01부터 처음부터 443 트래픽에 outlier detection이
적용된 적이 없었다**(mode2가 이 단계까지 도달하지 못해 그동안 미발견). 44를 `portLevelSettings` 안으로 교정하고,
churn(46)과 outlier(44 교정판)를 합본한 신규 `47-destinationrule-tls-churn-outlier.yaml`을 만들어 stage2/3에 썼다.

**3-stage 실험 (하네스: `scripts/dns-churn-stages.sh`, 스크래치패드 `run_churn_stages.sh` 를 C-0로 repo 편입) —
STRICT_DNS, backend-a는 DNS에 남긴 채 `replicas: 0`(dead-IP), 40초 churn 부하:**

| stage | DR/VS | 총요청 | HTTP 실패 | ≥0.9s 지연 요청 | 비고 |
|---|---|---:|---:|---:|---|
| 1. baseline+churn | 46 + VS42(no-retry) | 149 | **0** | 37(24.8%) | 요청의 1/4이 매번 ~1s(connectTimeout) 날아감 |
| 2. +outlier | 47 + VS42(no-retry) | 5221 | **0** | 1(0.02%) | 처리량 **35배** 증가(같은 40초 창) |
| 3. +outlier+retry | 47 + VS45(retry) | 5219 | **0** | 1(0.02%) | stage2 대비 유의미한 추가 개선 없음 |

**핵심 재해석 1 — "유실 0"의 실체는 Istio 기본 retry policy다.** VS42는 `retries` 필드가 아예 없는데도
`upstream_rq_retry` 카운터가 stage1에서부터 81 증가했다. fortio sidecar의 `config_dump`로 확인하니, **VS에
명시적 retry 정책이 없어도 Istio가 모든 HTTP route에 기본으로 `retry_on: connect-failure,refused-stream,
unavailable,cancelled,retriable-status-codes`, `num_retries: 2`, `retry_host_predicate: previous_hosts`를
주입한다.** dead-IP로의 connect 실패는 "아직 아무 바이트도 안 오간 pending request"로 분류되어, 이 기본
정책에 따라 **VS 정책과 무관하게** 자동으로 다른 host(`previous_hosts` predicate가 죽은 host가 아닌 다른
host를 고르게 만든다)로 재시도된다. **함의: 누군가 VS에 `retries.attempts: 0`을 명시하면 이 보호가 사라져
실제 유실이 발생한다** — "retry를 안 걸었으니 안전하게 기본 동작"이라는 가정은 틀렸다. 기본값은 이미 켜져
있고, 끄는 쪽이 명시적 행동이다.

> **[2026-07-09 조건 추가 — §14]** 이 "Istio 기본 retry policy"는 **RDS/VirtualService route가 존재하는
> L7(TLS origination) 경로에서만** 붙는다. client가 직접 TLS를 맺고 sidecar가 SNI만 보고 통과시키는 경로는
> 애초에 route 자체가 없어 이 기본 정책이 걸릴 자리가 없다 — 같은 dead-IP 조건에서 passthrough는 41건 중
> 18건(43.9%)이 client에게 그대로 실패로 전달됐다(§14 D-1). "VS에 retries가 없어도 기본으로 안전하다"는
> 이 절의 결론은 **HTTP(L7) 경로에 한정된 안전망**이었다는 뜻이다.

**핵심 재해석 2 — dead-IP의 실질 비용은 유실이 아니라 "지연세"(latency tax)다.** stage1에서 클라이언트가
지불한 비용은 에러가 아니라 **dead IP를 뽑을 때마다 `connectTimeout`(1s)만큼 기다렸다가 조용히 다른 host로
넘어가는 지연**이었다(37/149 = 24.8%가 이 세금을 냄). stage2에서 outlier detection이 격리(`ejections_enforced_total`
+2, `health_flags:/failed_outlier_check`)를 수행하자 이 세금을 무는 요청이 5221건 중 1건(0.02%)으로 떨어졌고,
**그 결과로 같은 40초 창의 처리량이 35배 늘었다**(직렬 루프라 1초씩 날아가던 시간이 사라진 만큼 다음 요청이
더 빨리 나갔기 때문). **outlier detection의 진짜 가치는 "에러 방지"가 아니라 "이 지연세 제거"다.**

> **[2026-07-09 조건 추가 — §14]** "유실이 아니라 지연세"라는 이 결론도 **재시도가 발동할 route가 있는 L7
> 경로 한정**이다. TLS passthrough에는 재시도 메커니즘 자체가 없어(§핵심 재해석 1의 조건과 동일한 이유) 지연세
> 대신 **진짜 유실**이 남는다 — outlier detection은 passthrough에서도 여전히 유효한 완화책이지만(51.7%→0.21%,
> §14 D-3), HTTP 경로처럼 0으로 수렴하지는 않는다. "outlier만 걸면 어차피 재시도가 구제해준다"는 가정은
> route가 없는 경로에서는 성립하지 않는다.

**핵심 재해석 3 — VS45(retry)의 순효과는 이번 설계로는 분리되지 않았다.** stage3가 stage2 대비 뚜렷한 개선을
안 보인 이유는, §"핵심 재해석 1"에서 보듯 connect-failure 재시도를 Envoy가 이미 기본으로 하고 있었기 때문이다.
"VS의 명시적 retry 정책이 추가로 기여하는 지점"(예: outlier 없이 retry만 있는 경우, 또는 connect 단계를 넘어선
5xx 애플리케이션 에러)은 이번 3-stage 설계에 없었다 — 열린 항목으로 남긴다(§13).

**부수 발견 — `portLevelSettings` 함정이 DR 44에 2026-07-01부터 잠복해 있었다(§6과 교차).** 위 44 교정을
계기로, 2026-06-07 이후 작성된 다른 DR도 같은 패턴(top-level에 필드, portLevelSettings는 tls만)이 없는지
재점검이 필요하다는 결론에 도달했다 — 이번 리포트 범위 밖이라 별도 점검 항목으로 남긴다(§13).

**부수 발견 — stage3 종료 직후 `no healthy upstream`(503) 1회.** DNS를 단일 backend-a로 되돌린 시점에 outlier가
아직 그 host를 격리 중이었고(유일한 host가 격리 상태), Envoy가 정상적으로 요청을 거부한 것. DR을 43(no-outlier)으로
재적용해 클러스터를 재빌드하자 즉시 해소 — 실험 종료 절차의 일부로만 발생, 정상 트래픽 경로에는 영향 없음.

---

## 12. 이번 재실측이 교정한 기존 서술 (당시 해석 → 재실측 교정)

| # | 위치 | 당시 서술 (2026-07-01) | 재실측(2026-07-02) 결과 | 교정 |
|---|---|---|---|---|
| 1 | §4 STRICT 읽는 법 | "요청이 1ms로 짧아 무손실이었지만, 장수명 스트리밍이면 이 지점에서 그 스트림이 잘린다" | 40초 요청 2/2 런 모두 절단 미재현, `destroy_with_active_rq` 델타 0 | in-flight는 요청 길이와 무관하게 보호됨 — §9 |
| 2 | §6 mode2 한계 기록 | "유실을 재현하려면 churn을 강제해야 한다(미완)" | churn 강제 후에도 유실은 0/149·0/5221·0/5219 | 유실이 아니라 latency tax — §11 |
| 3 | §8 트레이드오프 문장 | "STRICT=세션 희생" | in-flight는 보호되고, "희생"되는 건 완료된 연결의 재사용 가능성뿐 | §9~§10에서 재정의(주석 추가) |
| 4 | (암묵적 가정, 명시 서술 없음) | "VS에 retries가 없으면 재시도가 없다" | connect-failure는 VS 정책 무관하게 Istio 기본 정책으로 항상 재시도됨 | §11 "핵심 재해석 1" — `retries.attempts:0` 명시가 위험하다는 새 사실 |
| 5 | (암묵적 가정, 44 리뷰 시 미발견) | "44 적용 = outlier가 443에 켜짐" | portLevelSettings가 top-level을 상속하지 않아 44는 2026-07-01부터 outlier 미적용 상태였음 | 44를 portLevelSettings 안으로 교정 — §11 |
| 6 | §11 핵심 재해석 1·2 (2026-07-02) | "VS에 retries가 없어도 Istio 기본 정책이 connect-failure를 재시도해 유실이 지연세로 바뀐다" — 경로 조건 명시 없이 일반 서술 | client가 직접 TLS를 맺고 sidecar가 SNI passthrough만 하는 경로(작업 D, 2026-07-09)로 같은 dead-IP 조건을 대조하니 route 자체가 없어 이 재시도가 전혀 발동하지 않았고, 41건 중 18건(43.9%)이 실제 유실로 client에 전달됨 | "기본 재시도로 유실이 지연세가 된다"는 **RDS/VirtualService route가 존재하는 L7 경로 한정** 조건이 붙는다 — §14 |

---

## 13. 남은 것 (2026-07-09 갱신 — 초안 → 리뷰)

**완료(2026-07-02):**
- ~~mode2를 connection-churn 버전으로 재설계해 STRICT dead-IP 유실을 실제로 보이기~~ → §11에서 완료. 단 결과는
  "유실"이 아니라 "지연세"로 판명 — 원래 가설과 다른 결론.
- ~~장수명 스트리밍으로 `destroy_with_active_rq > 0`를 유도해 STRICT flip이 in-flight를 자르는지 시연~~ → §9~§10에서
  완료. 결과는 **반대**(자르지 않음, graceful drain).

**완료(2026-07-09):**
- ~~A·B의 L7 보호(기본 재시도로 유실이 지연세로 바뀜, in-flight graceful drain)가 client가 직접 TLS를 맺고
  sidecar는 SNI passthrough만 하는 경로에서도 유지되는가~~ → §14에서 완료. 결과: **반대** — passthrough엔 route
  자체가 없어 재시도가 걸릴 자리가 없고, 같은 dead-IP 조건에서 43.9%가 실제 유실로 client에 전달됐다(D-1).
  in-flight 보호(D-2)와 outlier의 완화 효과(D-3)는 유지되지만, 전자는 메커니즘이 다르고("pool이 없어 그냥
  안 건드려짐") 후자는 0에 수렴하지 않는다.

**여전히 열려 있음:**
- **STRICT drain 유예의 상한 미검증.** 40초 스트림에서는 완료까지 계속 보호됐지만, Envoy가 무한정 기다리는지
  drain timeout이 있는지는 확인하지 않았다 — 수분 단위 장수명 스트림으로 재확인 필요.
- **HTTP/2(GOAWAY 경로) 미검증.** 이번 실험은 전부 HTTP/1.1이었다. gRPC 등 HTTP/2에서 host 제거 시 GOAWAY 프레임
  기반 drain이 동일하게 in-flight를 보호하는지 별도 확인 필요.
- **VS45(retry)의 순효과 미분리(§11 "핵심 재해석 3").** DR46(churn만, outlier 없음)+VS45(retry)의 별도 stage로
  "retry 정책 자체가 추가하는 가치"를 outlier와 분리해서 봐야 한다. `retryOn`에서 `connect-failure`를 빼고
  5xx/refused-stream만 남긴 변형으로 Envoy 내장 재시도와 VS 재시도를 실제로 갈라볼 것.
- **connectTimeout이 지연세의 크기를 결정한다는 가설 미정량화.** 46/47은 `connectTimeout:1s`를 명시했다 —
  기본값(10s)으로 되돌리면 지연세가 최대 10배까지 커지는지 실측 필요.
- DNS TTL / Envoy `dnsRefreshRate` 값을 바꿔 flip 반영 지연(여기선 flip +약 5s)이 어떻게 움직이는지 정량화(기존 항목, 미착수).
- **44와 유사한 `portLevelSettings` top-level 상속 함정이 다른 DR에도 잠복해 있는지 전수 점검.** 이번 발견이
  우연히 44/46에서만 드러났을 뿐, 이전에 작성된 다른 시나리오(20-egress 등)의 DR도 같은 패턴이면 동일하게
  적용 안 된 필드가 있을 수 있다.
- **(신규, §14) Service ClusterIP 섀도잉 함정의 재발 가능성.** ServiceEntry가 in-mesh Service의 ClusterIP와
  같은 IP로 resolve되면 SNI/TLS 설정이 전부 맞아도 트래픽이 조용히 그 Service의 cluster로 샌다(curl 200이라
  겉으로 티가 안 남) — GSLB/외부 도메인을 흉내내는 랩·테스트 환경 전반, 특히 `20-egress` 시나리오에서 같은
  함정이 재발하는지 점검 가치가 있다.
- **(신규, §14) client connect-timeout(3s)과 DR/Envoy 기본 connectTimeout(10s) 불일치.** D-1에서 client-side
  실패 수(18)와 Envoy 자체 connect_fail/timeout 카운터(11)가 정확히 일치하지 않았다 — D-3처럼 DR로
  `connectTimeout`을 client timeout과 맞추고(예: 1s) D-1을 재실행하면 더 깔끔한 1:1 대응을 볼 수 있을 것.
- **(신규, §14) D-3 stage B 후반부(n≈2900) 2차 실패 클러스터.** `ejections_enforced_total`이 1이 아니라 2로
  늘어난 이유(재검증 사이클?)를 분리하지 못했다 — outlier `baseEjectionTime` 만료/재편입 로직을 초 단위로
  촘촘히 스냅샷하면 규명 가능할 것(§11 stage3 종료 시점 이슈와 유사한 다음 작업 후보).
- **(신규, §14) "TCP cluster엔 upstream_rq_*가 없다"는 최초 가정이 트래픽 유무에 따라 달라지는 이유 미확정.**
  Envoy가 특정 스탯을 lazy하게 노출하는지, 단순 타이밍 문제였는지 Envoy 소스 레벨 확인이 필요하면 다음 작업 후보.

---

## 14. L7 보호의 적용 경계 — TLS passthrough 대조 (2026-07-09)

§8~§11(A·B)이 확인한 "STRICT flip은 in-flight를 안 자른다", "dead-IP 유실은 사실 지연세다"는 결론은 전부
client가 평문 HTTP로 부르고 sidecar가 TLS를 origination하는 경로(40/41 SE + 42 VS + 43 DR)에서 나왔다. 이
경로에서는 Envoy가 요청/응답 경계를 알기 때문에 route retry, connect-failure 자동 재시도, connection pool
drain 유예가 전부 작동할 수 있었다. "client가 직접 TLS를 맺고 sidecar는 SNI만 보고 통과시키면 이 세 가지가
정말 사라지는가"를 대조하는 것이 작업 D의 목적이었다. 원시 로그 전문은
`docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md`.

### 14-1. 무엇을 만들었나 — SE 48(TLS protocol, VS/DR 없이)

기존 `gslb.lab.internal`(40/41 SE, 42 VS, 43 DR)은 전혀 건드리지 않고, 별도 hostname으로 새 SE만 추가했다:

```yaml
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: gslb-passthrough
  namespace: dns-lab
spec:
  exportTo: ["."]
  hosts: ["gslb-pt.lab.internal"]
  location: MESH_EXTERNAL
  ports:
    - { number: 443, name: tls, protocol: TLS }
  resolution: DNS
```

`protocol: TLS`(HTTP 아님) + 이 host에 VirtualService/DestinationRule을 아예 붙이지 않는다는 두 가지 조합이
핵심이다. Istio는 이 포트를 HTTP로 승격할 방법이 없어, outbound listener의 tls_inspector가 ClientHello의
SNI만 읽어 일치하는 filter chain으로 raw TCP를 relay하는 SNI passthrough로만 처리한다. `gslb-pt.lab.internal`을
lab-dns의 hosts zone에 추가로 등록해야 authoritative 응답이 나가는데(`10-lab-dns.yaml`, additive), 이 Corefile엔
top-level `reload` 플러그인이 없어 ConfigMap을 바꿔도 CoreDNS가 자동 재적재하지 않는다는 것도 이번에 처음
명시적으로 확인했다(`kubectl rollout restart deploy/lab-dns` 필요 — 기존 hosts 파일 재작성의 2초 reload와는
별개 메커니즘).

리스너 필터체인으로 확인한 구조적 차이 — SNI 매치 후 곧바로 tcp_proxy, RDS route 없음:

```json
{
  "filterChainMatch": {"serverNames": ["gslb-pt.lab.internal"]},
  "filters": [
    {"name": "istio.stats", ...},
    {"name": "envoy.filters.network.tcp_proxy",
     "typedConfig": {
        "statPrefix": "outbound|443||gslb-pt.lab.internal",
        "cluster": "outbound|443||gslb-pt.lab.internal"
     }}
  ]
}
```

대조로 HTTP 경로의 리스너는 `Trans: raw_buffer; App: http/1.1,h2c → Route: 443`처럼 RDS route table을
거친다. 이 차이(직접 cluster 바인딩 vs RDS route)가 이후 모든 관측의 근본 원인이다.

### 14-2. 실측 함정 — DNS를 Service ClusterIP로 가리키면 실험이 조용히 무효화된다

config 증거(cluster type, 리스너 SNI chain)는 처음부터 정상으로 보였다. 그런데 `gslb-pt.lab.internal`을
backend-a의 **Service ClusterIP**(10.250.161.145)로 가리킨 채 curl을 반복해도, curl 자체는 200/backend-a로
성공하는데 **`gslb-pt` 클러스터의 카운터(`upstream_cx_total`)가 전혀 증가하지 않았다.** 리스너 덤프에서
원인을 찾았다:

```json
{
  "name": "10.250.161.145_443",
  "address": {"socketAddress": {"address": "10.250.161.145", "portValue": 443}},
  "filterChains": [{"filters": [{"name": "envoy.filters.network.tcp_proxy",
    "typedConfig": {"cluster": "outbound|443||backend-a.dns-lab.svc.homelab.local", ...}}]}]
}
```

Istio sidecar는 in-mesh Service ClusterIP마다 **그 IP:port 전용 리스너를 미리 만들어 둔다.** outbound
캡처는 iptables REDIRECT + `SO_ORIGINAL_DST`로 "원본 목적지 IP"를 읽어 그 IP에 맞는 리스너로 디스패치하는
방식이라, client가 어떤 이름·SNI로 접속했든 목적지가 이미 알려진 Service VIP와 같으면 **그 전용 리스너가
0.0.0.0:443의 SNI 캐치올보다 항상 먼저 매치된다.** 즉 `gslb-pt.lab.internal`이 우연히 backend-a Service의
ClusterIP로 resolve되는 순간, SE(48)로 만든 passthrough cluster는 완전히 무시되고 트래픽은 backend-a
Service 자체의 cluster로 샜다 — **curl은 200을 반환하니 겉으로는 전혀 티가 안 난다.**

기존 `gslb.lab.internal`(HTTP 경로)이 이 문제를 겪지 않는 이유는 client가 포트 **80**으로 붙기 때문이다
(backend-a Service엔 80이 없어 충돌 리스너 자체가 없다) — 이 랩의 HTTP 경로가 왜 포트 80을 쓰는지에 대한
기존 설명(§5.4)에는 이 이유가 명시돼 있지 않았는데, 이번에 그 배경을 하나 더 알게 됐다.

**해결책:** `gslb-pt.lab.internal`의 DNS 레코드를 backend pod의 **pod IP**(`status.podIP`, Service ClusterIP
아님)로 바꿨다. pod IP는 in-mesh Service VIP가 아니므로 전용 리스너가 없고, 재시도하니 카운터가 정상적으로
증가했다(`upstream_cx_total` 0→1, `tcp...downstream_cx_total` 0→1). 이 함정과 해법은 `48-serviceentry-passthrough.yaml`,
`10-lab-dns.yaml`의 주석에 남겨뒀다. **파생 결과:** pod IP는 pod가 재시작될 때마다 바뀌므로(scale 0→1은 항상
새 IP), dead-IP 조건을 만들 때마다(D-1, D-3의 backend-a scale 0→1 사이클마다) 스크립트가 매번 현재 pod IP를
재조회해 hosts 파일을 다시 써야 했다 — 기존 HTTP 경로가 Service ClusterIP(항상 고정)로 이 번거로움을
피해온 이유를 이번엔 거꾸로 체감했다. 이 함정은 GSLB/외부 도메인을 흉내내는 랩·테스트 환경 전반에서
재발할 수 있는 일반적 위험으로 보인다(§13 신규 항목).

### 14-3. D-1 — dead-IP churn 대조 (핵심)

같은 40초 창, 같은 dead-IP 조건(backend-a scale 0)에서 passthrough와 HTTP 경로를 백그라운드로 동시에
돌렸다:

| 경로 | 시도 | 성공 | 실패 | 실패 유형 | `upstream_rq_retry`(존재/성공) |
|---|---:|---:|---:|---|---|
| **passthrough (gslb-pt)** | 41 | 23 (56.1%) | 18 (43.9%) | exit=28(connect timeout) 13건, exit=35(TLS 연결 실패) 5건 | route가 없어 적용 불가 |
| **HTTP (gslb.lab.internal, DR43 그대로)** | 86 | 86 (100%) | 0 | 없음 | 46/46 성공 (`upstream_rq_retry_success`=46) |

HTTP 쪽은 §11 "재해석 1"과 완전히 동일한 메커니즘 — LB가 dead backend-a를 고른 connect 실패는 "아직 아무
데도 보내지지 않은 pending request"로 취급되어 VS의 retry 정책 유무와 무관하게 Envoy가 자동으로 backend-b에
재시도한다. passthrough 쪽엔 이 안전망이 없다 — dead pod IP를 고른 커넥션은 TLS 핸드셰이크 중간에 리셋되거나
(exit 35), SYN이 blackhole되어 client의 `--connect-timeout 3`이 만료되거나(exit 28) — 어느 쪽이든 **client에게
그대로 전달된다.** 18/41(43.9%)은 2-member 클러스터에서 LB가 죽은 host를 대략 절반 확률로 고르는 것과 거의
일치한다.

의도적으로 HTTP 쪽에 churn DR(46/47)을 적용하지 않았다 — repo CLAUDE.md의 범위 제한(SE 41/VS 42/DR 43 변경
금지)을 지키기 위해서였는데, 결과적으로 더 담백한 대조가 나왔다: DR43(무churn) 그대로도 `upstream_rq_total`
델타(+86)가 client 요청수와 정확히 일치했다 — churn을 강제하지 않고도 46번의 dead-IP 히트가 실제로 발생했고,
그 46번이 전부 회복됐다.

**"TCP cluster엔 upstream_rq_*가 없다"는 처음 확인도 정정됐다.** 요청이 0건일 때는 실제로 없었지만, D-1
부하(41건) 이후엔 `upstream_rq_total` 등이 분명히 존재하고 값도 있었다. 즉 진짜 차이는 "스탯의 유무"가
아니라 **route의 유무**다 — RDS/VirtualService가 관여하지 않으므로 route-level retry policy가 구조적으로
붙을 자리가 없다는 점, 그리고 tcp_proxy 자체의 connect-attempt 회계(`upstream_cx_connect_attempts_exceeded`
등)는 **connect 단계에서만** 유효하고 한 번 바이트가 오간 뒤(established stream)엔 재시도할 방법이 없다는
점이 진짜 경계선이다.

### 14-4. D-2 — in-flight 스트림 + flip: 결과는 같지만 메커니즘이 다르다

`/slow`(40초 스트림)를 passthrough로 호출하고 +5초에 flip해도 **완주**했다(200, 409610바이트, backend-a
전량). 4점 스냅샷(BEFORE/MID1 +12s/MID2 +30s/AFTER)으로 보면 membership은 MID1에 이미 교체됐는데
`upstream_cx_active`는 MID1·MID2 내내 1을 유지하다가 curl이 자연 완료된 AFTER에만 `destroy`가 틱했다 — §9~§10
(report A)와 **똑같은 패턴**이다.

그런데 원인은 다르다. HTTP 쪽은 "connection pool이 제거된 host의 연결을 active request가 끝날 때까지
유예 후 drain"하는 능동적 기제였다(report A 결론). TCP passthrough엔 **connection pool 자체가 없다** —
tcp_proxy는 한 번 연결을 맺으면 그 커널 소켓을 그대로 붙들고 바이트를 릴레이할 뿐, CDS/EDS가 나중에 그
host를 지워도 이미 진행 중인 연결과는 무관하다(재확인할 대상 자체가 없다). 결과(스트림 생존)는 같지만,
HTTP는 "적극적으로 유예"하는 것이고 TCP는 "애초에 개입할 지점이 없어 그냥 살아있는" 것이다 — 우연히 안전한
것이지 설계로 안전한 게 아니다. 이 차이는 §10에서 확정한 in-flight 보호 결론 자체를 뒤집지는 않지만, "왜
안전한가"의 답은 경로마다 다르다는 것을 보여준다.

### 14-5. D-3 — outlier detection 적용 전/후: 유효하지만 0엔 안 닿는다

같은 dead-IP 조건에서 outlier 적용 전(Stage A)과 후(Stage B)를 대조했다:

| stage | 총시도 | 성공 | 실패 | 실패율 | `ejections_enforced_total` |
|---|---:|---:|---:|---:|---:|
| A (outlier 없음) | 29 | 14 | 15 | 51.7% | N/A |
| B (+outlier, DR49) | 2905 | 2899 | 6 | **0.21%** | 2 |

§11 "재해석 2"의 HTTP 쪽 outlier가 "이미 있던 재시도의 지연세를 없애는" 역할이었다면, passthrough의 outlier는
"재시도가 아예 없는 상태에서 실패 자체의 빈도를 낮추는" 역할이다 — 근본적으로 다른 안전판이다. 51.7%→0.21%로
급감했지만 **정확히 0은 아니었다.** 이유는 두 갈래다: (a) ejection이 발효되기 전(`consecutiveLocalOriginFailures:3`을
채우는 동안) 초반 몇 건의 실패, (b) 40초 창 끝 무렵 다시 소수 실패가 튀었는데 `ejections_enforced_total`이
1이 아니라 2로 늘어난 것으로 보아 재검증-재격리 사이클이 있었던 것으로 추정된다(정확한 트리거는 미확인,
§13 신규 항목). L7 retry가 없으므로 이 잔여 실패를 구제할 방법 자체가 없다는 것이 D-1~D-3 전체를 관통하는
결론이다.

### 14-6. A·B 결론에 붙는 조건 — 정리

가설(client가 직접 TLS를 맺고 sidecar가 SNI passthrough만 하면 A·B의 L7 보호가 사라진다)은 **지지된다.**
정리하면:

- **route가 구조적으로 없다** — VirtualService/RDS가 관여하지 않으므로 route-level retry policy가 붙을 자리
  자체가 없다(§14-1 리스너 증거).
- **dead-IP 실패가 client에게 그대로 전달된다** — 같은 조건에서 HTTP 100% 무손실 vs passthrough 43.9% 실패
  (§14-3). §11 "재해석 1·2"의 "기본 재시도로 유실이 지연세로 바뀐다"는 결론은 **RDS/VirtualService route가
  존재하는 L7(TLS origination) 경로 한정**이다 — §12 표 #6에 교정 이력으로 남겼다.
- **in-flight 보호는 결과적으로 동일하지만 메커니즘은 다르다**(§14-4) — HTTP는 "능동적 drain 유예", TCP는
  "애초에 개입할 대상(pool)이 없어 그냥 살아있음". 결과의 유사성에 속아 "TCP도 안전하다"고 일반화하면 안
  된다 — established 이후의 우연한 생존과 dead-IP를 향한 신규 연결 시도의 무방비는 별개의 문제다.
- **outlier detection은 passthrough에서도 유효한 완화책이지만 0에 수렴하지 않는다**(§14-5) — HTTP의 outlier가
  "이미 있던 재시도의 지연세를 없애는" 역할이라면, passthrough의 outlier는 "재시도가 아예 없는 상태에서
  실패 빈도를 낮추는" 역할로, 근본적으로 다른 안전판이다.

프로덕션 함의: **"mTLS/TLS passthrough를 쓰니까 사이드카가 알아서 재시도해줄 것"이라는 가정은 틀렸다.**
사내 egress 도입 시 passthrough(예: 앱이 mTLS를 직접 하거나, 인증서를 sidecar에 맡기고 싶지 않은 경우)를
선택하는 순간 L7 보호(retry, outlier의 완전한 효과, 요청 단위 장애 복구)를 sidecar가 대신해주지 않는다 —
그 대가로 보호 수단이 **클라이언트 자체 재시도 + outlier detection + GSLB TTL 설계**로 이동한다. 관측
수단도 달라진다: TCP cluster에는 트래픽이 흐르면 `upstream_rq_*` 스탯 자체는 존재하지만(§14-3에서 정정),
route가 없으니 "재시도가 몇 번 성공했는가" 같은 L7 지표는 애초에 나올 자리가 없다.

### 14-7. F-0(2026-07-09) — LOGICAL_DNS + passthrough in-flight: 매트릭스 마지막 칸

§14-4(D-2)는 STRICT_DNS + passthrough의 in-flight만 봤다 — `resolution` × 경로(L7 origination/L4
passthrough) 2×2 매트릭스에서 LOGICAL_DNS × L4 passthrough 칸이 비어 있었다. 이 칸을 채우려고 48
SE와 host가 같은 신규 variant `scenarios/50-dns-resolution/50-serviceentry-passthrough-logical.yaml`
(48의 `resolution`만 `DNS_ROUND_ROBIN`으로 바꾼 대응쌍 — 40/41 strict/logical 패턴을 passthrough에도
적용)을 추가해 같은 40초 `/slow` in-flight + flip(+5s) + 4점 스냅샷을 재실행했다(2/2 런). 원시 로그는
`docs/test-reports/2026-07-09_144251_dns-passthrough-logical-inflight.md`.

결과: **완주**(200, 409610바이트, body 식별 문자열이 flip 이전 접속처(backend-a)로 시종 고정, 2/2
결정론적). D-2(STRICT passthrough)와 비교하면:

| 관측 | STRICT × L4(§14-4, D-2) | LOGICAL × L4(F-0) |
|---|---|---|
| `membership_change` | flip 이후 **지연 반영**(cluster 자체 DNS refresh가 client dig보다 느림 — MID1+12s엔 아직, MID2+30s에 4→5) | BEFORE→AFTER 전 구간 **11→11 고정(Δ0)** — flip이 반영돼도 "논리적으로는 여전히 host 1개"라 membership 갱신 개념 자체가 없음 |
| endpoint(admin) 표시 | membership_change와 같은 시점에 동반 교체 | membership_change와 무관하게 **더 빠르게**(MID1+12s에 이미) 새 IP로 표시 — 단 이건 "다음 커넥션이 참조할 해석 캐시" 갱신이지 membership 이벤트가 아님 |
| `upstream_cx_destroy` | flip 창(BEFORE→MID2) 동안 미동, curl 완료 직후에만 +1(자연 종료) | 동일 — flip 창 동안 미동(2973/2976 고정), 완료 직후에만 +1 |

**한 문장 요약**: STRICT와 LOGICAL 둘 다 "raw TCP라 in-flight는 안 끊긴다"는 결과는 같지만, LOGICAL은
flip을 membership 이벤트로도 취급하지 않는다는 점에서 **한 단계 더 무관심하다** — STRICT는 "정리
대상이지만 TCP라 정리할 방법이 없어서" 살아남고, LOGICAL은 "애초에 정리 대상 자체로 안 잡혀서"
살아남는다.

이걸로 `resolution` × 경로 2×2 매트릭스(STRICT/LOGICAL × L7 origination/L4 passthrough)의 네 칸이
전부 실측으로 채워졌다. 매트릭스 자체와 칸 사이 메커니즘 비교, in-flight 타임라인 시각화는 별도 문서
[SE resolution × TLS passthrough(L4) — in-flight 요청의 운명](https://blog.homelab89.com/docs/istio/egress/se-resolution-passthrough-inflight/)에
정리했다(blog, `~/blog/content/docs/istio/egress/se-resolution-passthrough-inflight/`) — 이 리포트가
원자료, 그 문서가 종합·시각화를 맡는다. 신규 manifest: `50-serviceentry-passthrough-logical.yaml`(48과
host가 같아 동시 적용 불가, 종료 후 48로 원복하고 클러스터엔 미적용 상태로 repo에만 variant 보존).

**부수 발견 — `dns-passthrough-inflight.sh`의 `/hosts/addn` 덮어쓰기 버그.** 이 하니스는
`printf ... > /hosts/addn`로 파일을 통째로 덮어쓰는데, `/hosts/addn`은 `gslb.lab.internal`(HTTP
경로)과 `gslb-pt.lab.internal`(passthrough 경로)이 공유하는 파일이다. F-0 실행 중 이 사실을 놓쳐
첫 flip 호출에서 `gslb.lab.internal` 줄이 통째로 사라졌고, 그 뒤 HTTP 경로 sanity가 `curl exit 6`
(Could not resolve host)로 실패해서야 발견했다(수동 복구 완료, 원인은 §5 참조). 스크립트를
`dns-passthrough-churn.sh`/`dns-passthrough-outlier.sh`와 같은 패턴(매 write마다 두 hostname
줄을 함께 재기록)으로 교정했다 — 다음 사용자가 몰래 재발시키지 않도록 리포트에 남긴다.
