# 작업 D — TLS passthrough(L4/TCP) 경로에서는 A·B의 L7 보호가 사라지는가

**Date:** 2026-07-09 · **cluster:** homelab · **ns:** dns-lab · **Istio:** 1.30.0
**시나리오:** `scenarios/50-dns-resolution/` · **신규 manifest:** `48`(SE, 유지), `49`(DR, D-3 전용·종료 후 삭제)
**신규 스크립트(repo):** `scripts/dns-passthrough-churn.sh`(D-1), `scripts/dns-passthrough-inflight.sh`(D-2), `scripts/dns-passthrough-outlier.sh`(D-3)
**선행:** `2026-07-02_112113_dns-inflight-part2.md`(A — in-flight 4점 스냅샷), `2026-07-02_121151_dns-mode2-churn.md`(B — dead-IP churn, HTTP 경로 무손실+지연세 확인)

> 초안(construction 로그). "합격/불합격 표"가 아니라 무엇을 구성했고 그때 무엇이 보였는지, 그리고
> **예측과 다르게 나온 부분(있었다)을 숨기지 않고** 그대로 남긴다.

---

## 0. 결론 먼저 (TL;DR)

가설(client가 직접 TLS를 맺고 sidecar가 SNI passthrough만 하면 A·B의 L7 보호가 사라진다)은 **지지된다** —
단, 두 가지를 실측 전에는 몰랐던 중요한 조건이 붙는다.

1. **랩 구성 자체에 함정이 있었다.** `gslb-pt.lab.internal`의 DNS 응답을 backend-a/b의 **Service
   ClusterIP**로 주면 이 실험이 통째로 무의미해진다 — Istio sidecar가 이미 알고 있는 in-mesh
   Service VIP마다 그 IP:port 전용 리스너를 만들어두기 때문에, client가 어떤 이름·SNI로 접속했든
   목적지 IP가 그 VIP와 같으면 그 전용 리스너(원래 Service의 자체 cluster)가 SNI 캐치올보다 먼저
   가로챈다. 실측으로 이걸 잡아냈다(§2) — curl은 200을 받았지만 우리가 만든 passthrough cluster의
   카운터는 Δ0이었다. **해결: DNS를 backend pod IP(Service VIP 아님)로 가리키게 했다.**
2. **"TCP cluster엔 upstream_rq_* 가 없다"는 예상은 실측에서 기각됐다** — 트래픽이 흐르면 이 스탯들은
   분명히 존재한다(§4-2). 진짜 차이는 스탯의 유무가 아니라 **route/retry policy의 유무**였다.

핵심 실측 결과:

- **D-1 (dead-IP churn, 같은 조건 대조):** passthrough **23/41 성공(56.1%), 18/41 실패(43.9%)** —
  실패는 client 타임아웃(exit 28, 13건) + TLS 연결 실패(exit 35, 5건)로 client에게 **그대로** 전달됐다.
  같은 40초 창, 같은 dead-IP 조건에서 기존 HTTP 경로는 **86/86 성공(100%), 실패 0**— report B와 동일한
  메커니즘(`upstream_rq_retry_success`가 정확히 46/46 = HTTP 클러스터의 connect-fail 46건 전부가
  자동으로 backend-b로 재시도되어 성공)으로 재확인됐다.
- **D-2 (in-flight + flip):** 40초짜리 스트림(`/slow`)이 flip(+5s) 이후에도 **완주**(200,
  409610바이트, backend-a 전량) — STRICT_DNS의 host 제거가 이미 맺은 raw TCP 연결을 끊지 않는다는
  예상이 확인됐다. 단 **메커니즘은 HTTP와 다르다**: HTTP는 "connection pool의 drain 유예"가 원인이었고
  (report A), TCP passthrough는 애초에 pool이라는 개념 자체가 없어 커널 소켓이 CDS/EDS 갱신과
  완전히 무관하게 살아있을 뿐이다 — destroy 카운터가 flip 시점이 아니라 **요청 자연 종료 시점**에만
  틱하는 패턴은 두 경로가 똑같이 보이지만(§4-3), 그 이유는 서로 다르다.
- **D-3 (outlier 적용):** 적용 전 실패율 51.7%(15/29) → 적용 후 **0.21%(6/2905)**로 급감했지만
  **정확히 0은 아니었다** — L7 retry가 없으므로 ejection이 되기 전(consecutive local-origin
  failure 3회 채우는 동안)의 초반 실패는 client에게 그대로 전달된다는 예상이 그대로 맞아떨어졌다.

---

## 1. 배경 — 무엇을 대조하려 했나

A·B(2026-07-02)에서 관측한 "dead-IP 무손실"과 "in-flight 보호"는 client가 평문 HTTP로 호출하고
sidecar가 TLS origination(L7)을 수행하는 경로에서 나온 결과였다. 이 구조에서는 Envoy가 요청/응답
경계를 알기 때문에 (a) HTTP route의 retry policy, (b) connect-failure에 대한 Envoy의 내부 자동
재시도, (c) HTTP connection pool의 drain 유예 — 이 세 가지가 전부 작동할 수 있었다. 이번 실험은
"client가 직접 TLS를 맺고 sidecar는 SNI만 보고 통과시키는" 반대 극단을 만들어, 저 세 가지 보호가
정말 사라지는지 같은 조건에서 대조 측정하는 것이 목표였다.

---

## 2. 구성 — passthrough 경로 추가

### 2-1. ServiceEntry (48) — VS/DR 없이 TLS 프로토콜만

```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 아님) + VS/DR 부재 → Istio가 이 포트를 HTTP로 승격할 방법이 없어 SNI 기반
TCP passthrough로만 처리한다. 기존 `gslb.lab.internal`(40 SE/42 VS/43 DR)은 전혀 건드리지 않았다.

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

SE 적용 직후 config 증거(cluster type/listener SNI chain)는 전부 정상으로 보였다:

```
$ istioctl proxy-config cluster deploy/netshoot.dns-lab --fqdn gslb-pt.lab.internal -o json
  name= outbound|443||gslb-pt.lab.internal
  type= STRICT_DNS
  has typed_extension_protocol_options: False    <- HTTP protocol option 없음(순수 TCP)

$ istioctl proxy-config listeners deploy/netshoot.dns-lab   (ADDRESSES 0.0.0.0 PORT 443 행)
  0.0.0.0   443   SNI: gslb-pt.lab.internal   Cluster: outbound|443||gslb-pt.lab.internal
```

그런데 `gslb-pt.lab.internal`을 backend-a의 **Service ClusterIP**(10.250.161.145)로 가리킨 채
`curl -sk https://gslb-pt.lab.internal/`를 3회 반복해도 **`gslb-pt` 클러스터의 카운터가 전혀
증가하지 않았다**(`upstream_cx_total`/`downstream_cx_total` 모두 0 유지, curl 자체는 200/backend-a로
성공). 원인을 리스너 덤프에서 찾았다:

```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되는 순간, 우리가 만든
passthrough SE는 완전히 무시되고 트래픽은 backend-a Service 자체의 cluster로 샌다.

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

**해결책**: `gslb-pt.lab.internal`의 DNS 레코드를 backend pod의 **pod IP**(`status.podIP`,
Service ClusterIP 아님)로 바꿨다. pod IP는 in-mesh Service VIP가 아니므로 전용 리스너가 없고,
확인 결과 실제로 없었다:

```
$ (pod IP 10.255.194.119/10.255.126.38 에 대해 전용 listener 존재 여부 검색)
no dedicated listener found for backend pod IPs -- good, pod IP not shadowed
```

이후 재시도하니 카운터가 정상적으로 증가했다:

```
dig gslb-pt.lab.internal -> 10.255.194.119 (pod IP)
curl -sk https://gslb-pt.lab.internal/ -> 200 backend-a
  upstream_cx_total: 0 -> 1        (Δ1)
  tcp....downstream_cx_total: 0 -> 1  (Δ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(항상 고정)로 이 번거로움을
피해온 이유를 이번에 거꾸로 체감했다.

### 2-3. lab-dns — zone 추가(additive, 기존 zone 불변)

```yaml
hosts /hosts/addn gslb.lab.internal gslb-pt.lab.internal {
    ttl 5
    reload 2s
    fallthrough
}
```

CoreDNS의 `hosts` 플러그인은 zones 인자에 없는 이름은 아예 매치하지 않고 바로 다음 플러그인(forward)으로
넘어가므로, `gslb-pt.lab.internal`을 zones에 추가하지 않으면 이 이름은 authoritative 응답을 못 받고
NXDOMAIN이 났다(최초 시도에서 실제로 이렇게 실패했다 — 파일엔 있는데 dig가 실패). 이 Corefile엔
top-level `reload` 플러그인이 없어서 ConfigMap을 바꿔도 CoreDNS 프로세스가 자동으로 재적재하지
않는다는 것도 이번에 처음 명시적으로 확인했다(`kubectl rollout restart deploy/lab-dns` 필요) —
기존 hosts 파일 재작성(`/hosts/addn`)의 2초 reload와는 완전히 다른 메커니즘이다.

### 2-4. 리스너 필터체인 — SNI 매치 → 직접 tcp_proxy (Route/RDS 없음)

```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",
        "accessLog": [...]
     }}
  ],
  "transportSocketConnectTimeout": "15s"
}
```

`maxConnectAttempts` 필드가 명시돼 있지 않다(Envoy 기본값 = 1). 대조로 HTTP 경로의 리스너는
`0.0.0.0:443 Trans: raw_buffer; App: http/1.1,h2c → Route: 443`처럼 **RDS route table**을
거친다 — 이 차이(직접 cluster 바인딩 vs RDS route)가 route-level retry policy의 존재/부재를
구조적으로 결정한다.

---

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

### 3-1. 조건 구성

```
POD_A=10.255.194.119 POD_B=10.255.126.38  (당시 backend-a/b pod IP)
gslb.lab.internal    -> {10.250.161.145(A svc), 10.250.183.248(B svc)}   (flip_both, 기존 방식)
gslb-pt.lab.internal -> {10.255.194.119(A pod), 10.255.126.38(B pod)}   (flip_both, pod IP)
>> KILL backend-a (scale 0) — Service ClusterIP=0 endpoint, pod IP=경로 제거(같은 근본 원인,
   dead-IP 조건이 두 경로에 "동시에" 걸림)
```

40초 동안 두 curl 루프를 **같은 스크립트 안에서 백그라운드로 동시 실행**(타이밍 어긋남 방지):

- passthrough: `curl -sk --connect-timeout 3 -m 10 -w "%{exitcode} %{http_code} %{time_total}" https://gslb-pt.lab.internal/`
- HTTP(대조, DR43 그대로 — 별도 churn DR을 적용하지 않았다. 이유는 §3-3): `curl --connect-timeout 3 -m 10 -w "%{exitcode} %{http_code} %{time_total}" http://gslb.lab.internal/`

### 3-2. 결과 테이블

| 경로 | 시도 | 성공 | 실패 | 실패 에러 유형 | cx_connect_fail Δ | rq_retry(존재 여부) |
|---|---:|---:|---:|---|---:|---|
| **passthrough (TCP, gslb-pt)** | 41 | 23 (56.1%) | 18 (43.9%) | exit=28(connect timeout) 13건, exit=35(SSL connect error) 5건 | +11 (connect_timeout +11, connect_attempts_exceeded +17) | **route 자체가 없어 VS retry 정책 적용 불가**. 단 cluster 스탯엔 `upstream_rq_pending_failure_eject`(+17)가 존재 — Envoy tcp_proxy의 내부 connect-attempt 회계(§4-2) |
| **HTTP (gslb.lab.internal, DR43 그대로)** | 86 | 86 (100%) | 0 | 없음 | +46 | **있음, 그리고 실제로 46/46 성공** (`upstream_rq_retry_success`=46) |

원시 꼬리(passthrough, 마지막 5행):
```
04:09:14 n=37 0 200 0.006198
04:09:14 n=38 0 200 0.006131
04:09:14 n=39 0 200 0.006536
04:09:14 n=40 0 200 0.006747
04:09:17 n=41 28 000 3.001225
```

HTTP 클러스터 BEFORE→AFTER 원시 델타(netshoot 사이드카):
```
                              BEFORE    AFTER    delta
upstream_cx_connect_fail          0       46      +46
upstream_cx_destroy               2       48      +46
upstream_rq_pending_failure_eject 0       46      +46
upstream_rq_retry                 0       46      +46
upstream_rq_retry_success         0       46      +46   <- 46건 전부 backend-b로 재시도 성공
upstream_rq_total                 2       88      +86   <- client 요청수(86)와 정확히 일치
```

**읽는 법**: gslb.lab.internal 쪽은 report B(2026-07-02)와 **완전히 동일한 메커니즘**이 재현됐다 —
LB가 dead backend-a를 고르면 connect가 실패하지만, 이건 "아직 아무 데도 보내지지 않은 pending
request"로 취급돼 VS의 retry policy(VS42엔 없음) 여부와 무관하게 Envoy가 자동으로 backend-b에
재시도한다. 46번 전부 성공했으므로 client는 정확히 0번의 실패를 본다.

gslb-pt(passthrough) 쪽은 이 안전망이 없다 — LB가 dead pod IP를 고른 커넥션은 (a) TLS 핸드셰이크
중간에 리셋되거나(exit 35), (b) pod IP가 이미 사라져 라우트가 없어 SYN이 응답 없이 blackhole되고
client의 `--connect-timeout 3`이 만료되거나(exit 28) — 어느 쪽이든 **client에게 그대로 전달**된다.
18/41(43.9%)이라는 수치는 2-member 클러스터에서 LB가 죽은 host를 대략 절반 확률로 고르는 것과
거의 일치한다(내부 connect-retry가 일부 작동해 이보다 조금 낮아졌을 가능성 — §4-2).

### 3-3. 의도적으로 하지 않은 것 — HTTP 쪽에 churn DR(46/47)을 적용하지 않은 이유

CLAUDE.md의 범위 제한("SE 41/VS 42/DR 43 변경 금지")을 문자 그대로 지키기 위해, HTTP 대조 루프는
기존 DR43(무churn) 그대로 두고 돌렸다. 이러면 "매 curl이 새 upstream 연결을 강제로 만드는가"가
보장되지 않아 대조가 약해질 위험이 있었는데, 실측 결과 오히려 **더 강력한 증거**가 나왔다 — DR43(무churn)
상태에서도 `upstream_rq_total`(+86)이 client 요청수와 정확히 일치했다는 것은 (2026-07-02 mode2-churn
리포트의 "downstream 소켓만 churn되고 upstream pool은 재사용된다"는 발견과 달리) **이번엔 실제로
매 요청이 새 upstream 연결을 만들고 있었다**는 뜻이다(원인 미확정 — 아마도 direct curl 프로세스 반복
호출이 이전 fortio -keepalive=false 실험과 달리 실제로 매번 새 소켓을 만들었고, 마침 upstream
쪽도 이번엔 재사용되지 않은 것으로 보인다). 즉 churn을 강제하지 않고도 46번의 dead-IP 히트가
실제로 발생했고, 그 46번이 전부 회복됐다 — churn DR 없이도 결론은 동일하되 더 담백하게 확인된 셈이다.

---

## 4. 증거 — TCP cluster의 스탯/구조 (예상과 달랐던 부분)

### 4-1. cluster type/리스너는 예상대로

```
outbound|443||gslb-pt.lab.internal:  type=STRICT_DNS, typed_extension_protocol_options 없음(순수 TCP)
0.0.0.0:443 리스너: SNI: gslb-pt.lab.internal -> Cluster: outbound|443||gslb-pt.lab.internal (직접 바인딩, Route 없음)
```

대조로 HTTP 경로: `0.0.0.0:443 Trans: raw_buffer; App: http/1.1,h2c -> Route: 443`(RDS route table 경유).

### 4-2. "upstream_rq_* 가 아예 없다"는 처음의 확인은 틀렸다 — 트래픽이 흐르면 존재한다

가설 문서에 적힌 "TCP cluster에는 upstream_rq_* 계열 스탯이 아예 없음을 grep으로 확인"을 그대로
시도했더니, **요청이 0건일 때는** 실제로 없었다:

```
$ (사전 sanity curl 1회 후 바로) pilot-agent request GET "stats?filter=gslb-pt" | grep upstream_rq_
(no upstream_rq_* stats for gslb-pt cluster — 이 시점엔 정말 없었다)
```

그런데 D-1 부하(41건) 이후 같은 클러스터를 다시 덤프하면 `upstream_rq_total`, `upstream_rq_active`,
`upstream_rq_pending_failure_eject`, `upstream_rq_pending_total` 등이 **분명히 존재하고 값도 있었다**
(§3-2 표). 즉 "존재 여부"는 트래픽량에 따라 늦게 나타나는 것으로 보인다(Envoy가 특정 카운터를 첫
증분 시점에 lazily 노출하는 것으로 추정 — 정확한 내부 조건은 미확인). **정정**: TCP passthrough
cluster가 HTTP cluster와 진짜로 다른 지점은 "rq 스탯의 존재/부재"가 아니라:

1. **route가 없다** — RDS/VirtualService가 아예 관여하지 않으므로 route-level retry policy가
   구조적으로 붙을 자리가 없다(§4 리스너 증거).
2. **tcp_proxy의 자체 connect-attempt 처리**가 존재하지만(`upstream_cx_connect_attempts_exceeded`,
   `upstream_rq_pending_failure_eject` 둘 다 관측됨 — TcpProxy 필터 자체에 "connect 실패 시 다른
   host로 재시도"하는 내부 로직이 있고, `max_connect_attempts`가 설정 안 된 기본값(1)이라도 이
   카운터들이 움직인다는 것은 최소 1회의 "이 시도가 실패했다"는 회계가 이루어짐을 뜻한다) — 하지만
   이건 **connect 단계에서만** 유효하고, **한번 바이트가 오간 뒤(established stream)에는 재시도할
   방법이 없다** — D-2가 이 "established 이후"의 영역을 다룬다.
3. 정량적으로 HTTP 쪽 재시도(46/46 = 100% 성공)와 passthrough 쪽 결과(18/41 실패, 43.9%)의 격차
   자체가 "재시도가 있고 없고"의 실질적 효과를 보여준다 — 스탯의 유무보다 **결과의 격차**가 더 정직한
   증거였다.

---

## 5. D-2 — in-flight 스트림 + flip

### 5-1. 절차 (4점 스냅샷, dns-inflight-4snap.sh 방식 재사용)

```
CURL_LAUNCH=13:20:39  https://gslb-pt.lab.internal/slow (POD_A=10.255.194.107)
FLIP_ISSUED=13:20:44 (t=+5s)  gslb-pt: 10.255.194.107 -> 10.255.126.38(POD_B)
BEFORE(t=0) / MID1(t=+12) / MID2(t=+30) / AFTER(t=+43)
```

### 5-2. 결과 테이블

| counter | BEFORE | MID1(+12s) | MID2(+30s) | AFTER(+43s) |
|---|---|---|---|---|
| dig 응답 | pod-A | **pod-B**(flip 이미 반영) | pod-B | pod-B |
| endpoint 덤프(cluster) | pod-A만 | **여전히 pod-A만**(cluster의 자체 DNS refresh가 client dig보다 느림) | **pod-B로 교체**(membership_change 4→5) | pod-B만 |
| upstream_cx_active | 0 | **1** | **1** | **0** |
| upstream_cx_destroy | 38 | 38 (Δ0) | 38 (Δ0) | **39 (Δ+1, 완료 직후)** |
| upstream_cx_destroy_with_active_rq | 25 | 25 (Δ0) | 25 (Δ0) | **26 (Δ+1)** |

curl 결과: `CURL_EXIT=0 SIZE=409610 TIME=40.050531 HTTPCODE=200`, 수신 파일 첫 줄 = `backend-a`
(pod-A의 응답 그대로, 스트림 끝까지 pod-A에서 옴).

### 5-3. 읽는 법

스트림은 **완주**했다 — flip이 +5초에 반영되고 cluster membership이 +18~30초 사이 새 값(pod-B)으로
바뀌었음에도, 이미 맺은 TCP 연결은 `cx_active=1`을 그대로 유지하다가 curl이 자연 완료된 순간에만
`destroy`가 틱했다. **패턴 자체는 report A(HTTP, STRICT_DNS)와 똑같다** — 하지만 원인은 다르다.
HTTP 쪽은 "connection pool이 제거된 host의 연결을 active request가 끝날 때까지 유예 후 drain"하는
것이었고(report A §결론), TCP passthrough엔 **connection pool 자체가 없다** — tcp_proxy는 한 번
연결을 맺으면 그 커널 소켓을 그대로 붙들고 바이트를 릴레이할 뿐, CDS/EDS가 나중에 그 host를
지워도 이미 진행 중인 연결과는 무관하다(재확인할 대상 자체가 없다). 즉 **결과(스트림 생존)는
같지만, "왜 안 끊기는가"의 메커니즘은 서로 다르다** — HTTP는 "적극적으로 유예", TCP는 "애초에
개입할 지점이 없음".

부수 발견: 클러스터 자체의 DNS 재해석은 client의 `dig`(lab-dns에 직접 질의, ttl 5s)보다 훨씬
느리게 반영됐다(MID1 +12s 시점에 아직 안 바뀜, MID2 +30s에야 반영) — Envoy(c-ares)의 STRICT_DNS
refresh 주기가 CoreDNS의 hosts ttl/reload 주기와 별개로 움직인다는 걸 이번에 처음 시간차로 확인했다.

---

## 6. D-3 — outlier detection 적용 전/후

### 6-1. DR49

```yaml
trafficPolicy:
  connectionPool:
    tcp: { connectTimeout: 1s }
  outlierDetection:
    splitExternalLocalOriginErrors: true
    consecutiveLocalOriginFailures: 3
    interval: 5s
    baseEjectionTime: 30s
    maxEjectionPercent: 50
    minHealthPercent: 0
```

HTTP 전용 지표(consecutive5xxErrors 등)는 의미가 없어 생략하고, TCP connect 실패를 "local origin
failure"로 잡는 `consecutiveLocalOriginFailures` + `splitExternalLocalOriginErrors:true` 조합을 썼다.

### 6-2. Stage A(outlier 없음) vs Stage B(outlier 적용) — 같은 dead-IP 조건

| stage | 총시도 | 성공 | 실패 | 실패율 | ejections_enforced_total | health_flags(dead pod) |
|---|---:|---:|---:|---:|---:|---|
| A (outlier 없음) | 29 | 14 | 15 | **51.7%** | N/A(미설정) | healthy(격리 안 됨) |
| B (+outlier) | 2905 | 2899 | 6 | **0.21%** | 2 | **/failed_outlier_check** |

Stage B 원시 꼬리(적용 직후 초반 5건 — 이 구간에서만 실패 발생):
```
04:33:50 n=1 35 000 1.005092    <- 아직 ejection 전
04:33:50 n=2 0 200 0.007206
04:33:50 n=3 0 200 0.006113
04:33:51 n=4 35 000 1.003700
04:33:52 n=5 35 000 1.005250
04:33:52 n=6 0 200 0.007775      <- 이후 ~2894건 연속 성공(ejection 발효)
...
04:34:27 n=2900 35 000 1.002670  <- 40s 창 끝 무렵 다시 2~3건 실패(2차 ejection 이벤트로 추정)
04:34:29 n=2905 35 000 1.004071
```

Stage B AFTER 스냅샷:
```
outlier_detection.ejections_enforced_consecutive_local_origin_failure: 2
outlier_detection.ejections_enforced_total: 2
outbound|443||gslb-pt.lab.internal::10.255.194.107:443::health_flags::/failed_outlier_check
outbound|443||gslb-pt.lab.internal::10.255.126.38:443::health_flags::healthy
```

### 6-3. 읽는 법

**실패율이 51.7% → 0.21%로 급감**했지만 **정확히 0은 아니었다** — 예상 그대로다. 이유는 두 갈래로
갈린다: (a) ejection이 발효되기 전 초반 실패(consecutiveLocalOriginFailures:3을 채우는 동안의
몇 건, n=1~5), (b) 40초 창 끝 무렵(n=2900~2905) 다시 소수 실패가 튀었는데, `ejections_enforced_total`이
2로(1이 아니라) 늘어난 것으로 보아 **한 번 격리됐던 host가 뭔가의 이유로 재검증 대상에 다시 오르며
2차 실패-재격리 사이클**이 있었던 것으로 추정된다(정확한 재검증 트리거는 미확인 — outlier detection의
`interval:5s`마다 판정 로직이 도는데, dead host의 헬스체크 자체는 없으므로 왜 재편입 시도가
있었는지는 이번 실험 설계로는 분리되지 않는다). **핵심은 이 잔여 실패가 사라지지 않았다는 점** —
HTTP 경로의 outlier(report B stage2/3)는 Envoy의 connect-failure 자동 재시도와 결합해 사실상
완전히 0으로 수렴했지만, passthrough엔 그 재시도 메커니즘 자체가 없어(route 없음) ejection이
아무리 빨라도 "격리되기 전의 실패"와 "재검증 중 실패"는 client에게 그대로 전달된다.

---

## 7. 종합 판정

**가설은 지지된다.** client가 직접 TLS를 맺고 sidecar가 SNI passthrough만 하는 경로에서는:

- **route가 구조적으로 없다** — VirtualService/RDS가 관여하지 않으므로 route-level retry policy가
  붙을 자리 자체가 없다(리스너 증거로 확정, §4-1).
- **dead-IP 실패가 client에게 그대로 전달된다** — 같은 40초·같은 dead-IP 조건에서 HTTP 100% 무손실
  vs passthrough 43.9% 실패(D-1). HTTP 쪽의 무손실은 "요청 유실이 원래 없었다"(report B의 결론)가
  아니라 **connect-failure에 대한 Envoy의 내부 자동 재시도가 실제로 작동해서**라는 것을 이번에도
  수치로 재확인했다(`upstream_rq_retry_success`=46/46).
- **in-flight 보호는 결과적으로 동일하게 보이지만 메커니즘은 다르다**(D-2) — HTTP는 "connection
  pool의 능동적 drain 유예", TCP는 "애초에 개입할 대상(pool)이 없어 그냥 살아있음". 결과의 유사성에
  속아 "TCP도 안전하다"고 오해하면 안 된다 — established 이후의 우연한 생존과 dead-IP를 향한 신규
  연결 시도의 무방비는 별개의 문제다.
- **outlier detection은 여전히 유효한 완화책이지만 0에 수렴하지 않는다**(D-3) — HTTP의 outlier가
  "이미 있던 재시도의 지연 세금을 없애는" 역할이었다면(report B), passthrough의 outlier는 "재시도가
  아예 없는 상태에서 실패 자체의 빈도를 낮추는" 역할이다 — 근본적으로 다른 안전판이고, 이것조차 없으면
  실패율은 LB가 dead host를 고르는 확률(대략 1/N)에 그대로 노출된다.

가장 중요한 실무 함의: **"우리는 mTLS/TLS passthrough를 쓰니까 사이드카가 알아서 재시도해줄 것"이라는
가정은 틀렸다.** L7 보호(retry, outlier의 완전한 효과, 세밀한 요청 단위 장애 복구)를 원하면 sidecar가
프로토콜을 볼 수 있어야 하고, 그러려면 TLS origination(client 평문 또는 sidecar가 종단)이 필요하다.
passthrough를 선택하는 순간(예: mTLS를 앱이 직접 하고 싶다거나, 인증서를 sidecar에 맡기고 싶지 않다거나)
그 대가로 이 안전망들을 스스로(앱 레벨 재시도, L4 헬스체크 기반 빠른 장애 감지 등) 구현해야 한다는
결론이 나온다.

---

## 8. 이상 징후 · 미해결 의문

- **Service ClusterIP 충돌 함정(§2-2)**: 이번 세션 최대 수확 중 하나. ServiceEntry로 만든 "외부처럼
  보이는" 호스트가 우연히 클러스터 내부 Service의 ClusterIP와 같은 IP로 resolve되면, SNI/TLS 설정이
  전부 맞아도 트래픽이 조용히 다른 곳으로 샌다 — **curl이 200을 반환하니 겉으로는 전혀 티가 안 난다.**
  프로덕션에서 GSLB/외부 도메인을 흉내내는 랩·테스트 환경을 만들 때 일반적으로 재발할 수 있는 함정으로
  보인다(egress 랩 등 다른 시나리오에서도 재확인 가치 있음).
- **client 타임아웃(3s) vs Envoy 기본 connectTimeout(10s) 불일치**: D-1에서 client-side 실패 수(18)와
  Envoy 자체 connect_fail/timeout 카운터(11)가 정확히 일치하지 않았다 — client가 Envoy보다 먼저
  포기하는 경우 Envoy 쪽 카운터엔 아직 안 잡히는 것으로 보인다. connectTimeout을 DR로 짧게(예: 1s)
  맞췄다면 이 불일치가 줄었을 것 — D-3에서만 이 값을 세팅했다(§6). D-1도 동일하게 맞춰 재실행하면
  더 깔끔한 1:1 대응을 볼 수 있을 것 같다(다음 작업 후보).
- **D-3 stage B 후반부(n≈2900) 2차 실패 클러스터**: `ejections_enforced_total`이 1이 아니라 2로
  늘어난 이유(재검증 사이클?)를 이번 실험 설계로는 분리하지 못했다 — outlier의 `baseEjectionTime`
  만료/재편입 로직을 초 단위로 촘촘히 스냅샷하면(2026-07-02 mode2-churn 리포트 §7에서도 동일한
  다음 작업 후보가 남아있었다) 규명 가능할 것.
- **"TCP cluster엔 upstream_rq_* 가 없다"는 최초 가정이 트래픽 유무에 따라 달라지는 이유**: Envoy가
  특정 스탯을 lazy하게 생성하는지, 아니면 단순히 내가 처음 검사한 시점(사전 sanity curl 1건 직후)의
  타이밍 문제였는지 확정하지 못했다 — Envoy 소스 레벨 확인이 필요하면 다음 작업 후보.
- **HTTP 대조 루프가 churn DR 없이도 매 요청 신규 upstream 연결을 만든 것으로 보이는 이유**(§3-3):
  2026-07-02 mode2-churn 리포트의 "downstream churn만으론 upstream pool 재사용을 못 이긴다"는 발견과
  겉보기엔 모순된다 — 이번엔 fortio가 아니라 순수 curl 반복 호출이었다는 차이가 있는데, 정확히 무엇이
  이번엔 upstream도 churn시켰는지는 재현·확인하지 않았다.

---

## 9. 원시 로그 경로 + 종료 상태

세션 중 실제 실행 원본(raw stdout, 이번 실행분)은 스크래치패드에 있다:

```
/tmp/claude-1000/-mnt-homelab-kakaopay-istio/9efbaa2c-8577-4534-8d98-d97a7cc0747e/scratchpad/
├── d1-passthrough-churn.sh, d1/full_run.txt, before.txt, after.txt, loop_pt.txt, loop_http.txt
├── d2-passthrough-inflight.sh, d2/full_run.txt, curl_out.txt, curl_stderr.txt
└── d3-passthrough-outlier.sh, d3/full_run.txt, stageA_loop.txt, stageB_loop.txt
```

재현 가능한 하네스는 repo `scripts/`로 승격했다(기존 `dns-flip-test.sh`/`dns-churn-stages.sh`/
`dns-inflight-4snap.sh`와 동일한 명명·구조 관례):

```
scripts/dns-passthrough-churn.sh      (D-1 — dead-IP churn 대조, 재실행 시 tmp/dns-passthrough/d1/ 에 로그 남김)
scripts/dns-passthrough-inflight.sh   (D-2 — in-flight + flip 4점 스냅샷, tmp/dns-passthrough/d2/)
scripts/dns-passthrough-outlier.sh    (D-3 — outlier 전/후 대조, tmp/dns-passthrough/d3/, DR49 apply+delete까지 포함)
```

신규/수정 repo manifest:

```
scenarios/50-dns-resolution/48-serviceentry-passthrough.yaml        (신규, 유지 — SE만, VS/DR 없음)
scenarios/50-dns-resolution/49-destinationrule-passthrough-outlier.yaml (신규, D-3 전용 — 클러스터에선 삭제됨, 파일은 재현용으로 보존)
scenarios/50-dns-resolution/10-lab-dns.yaml                          (수정: hosts zones에 gslb-pt.lab.internal 추가, additive)
```

종료 상태 확인(2026-07-09 13:42):

```
$ kubectl --context=homelab -n dns-lab get serviceentry,virtualservice,destinationrule
serviceentry.../gslb-passthrough   ["gslb-pt.lab.internal"]   MESH_EXTERNAL   DNS   <- 유지(요청대로)
serviceentry.../gslb-strict        ["gslb.lab.internal"]      MESH_EXTERNAL   DNS   <- 변경 없음
virtualservice.../gslb-80-to-443   ["gslb.lab.internal"]                            <- 변경 없음(42)
destinationrule.../gslb-tls-origination  gslb.lab.internal                          <- 변경 없음(43), DR49는 삭제됨

$ kubectl --context=homelab -n dns-lab get pods
backend-a   1/1 Running   backend-b   1/1 Running   fortio 2/2   lab-dns 2/2   netshoot 2/2   (전부 READY)

$ dig: gslb.lab.internal -> 10.250.161.145(backend-a svc)   gslb-pt.lab.internal -> 10.255.194.124(backend-a 현재 pod IP)

$ curl http://gslb.lab.internal/   -> backend-a [http_code=200]
$ curl -sk https://gslb-pt.lab.internal/ -> backend-a [http_code=200]

$ istioctl --context=homelab analyze -n dns-lab
Warning [IST0129] ...TLS mode SIMPLE, no caCertificates...   <- 기존부터 있던 경고, 신규 아님
Info [IST0135] ...sidecar.istio.io/inject deprecated...      <- 기존부터 있던 정보성 메시지 (x3)
(에러 0, 신규 경고 0)
```

DNS→backend-a(두 도메인 모두), backend-a/b scale 1 READY, 기존 HTTP 경로 sanity 정상, D-3의 DR49
삭제 완료 — 랩은 clean 상태다.
