---
title: egress ServiceEntry는 resolution: DNS(STRICT)를 써도 된다 — 세션 단절 검증과 기본값 선택 근거
date: 2026-07-10
type: guide
domain: istio
tags: [istio, egress, dns, gslb, service-entry, tls-passthrough, decision-brief]
---
> [!abstract]
> **사내 공유용 완성본 (단독으로 읽히도록 작성 — 외부 참조 없음).** egress gateway를 지나는
> 외부 도메인의 `ServiceEntry`를 `resolution: DNS`(STRICT)로 표준화하는 결정과 그 근거를 담았다.
> 결론 두 줄:
> **① 도입 초기에 걱정했던 "GSLB가 IP를 바꾸면 기존 세션이 끊긴다"는 기우였다** — 공식 문서와
> 직접 실측 양쪽으로 확인했고, 실측 원시 데이터를 이 문서 말미 부록에 발췌 첨부했다.
> **② 기조는 "반드시 STRICT"가 아니라 "STRICT를 그대로 써도 문제가 없다"이다.** 그 위에서,
> 우리 환경에서는 3절의 장점 네 가지로 STRICT를 **기본값**으로 쓰고, LOGICAL이 더 맞는 상황의
> 판별 기준은 4절에 적었다.
> 절체가 실제로 일어난 순간의 운영 시나리오(계획·장애 절체 runbook)는 별도 운영 가이드 문서로
> 제공한다.
> [!note]
> **근거 등급** — 이 문서의 모든 주장에는 근거 등급을 붙였다: **[실측]** = 재현 랩에서
> 직접 측정(원시 데이터 발췌를 부록에 첨부), **[공식 문서]** = Envoy 공식 문서 원문 인용,
> **[논리 귀결]** = 앞의 두 사실로부터의 직접 도출. 이 세 등급에 들지 못하는 추정성 서술은
> 싣지 않았다.
## 1. 환경 설명
```mermaid
flowchart LR
APP["client app
HTTPS를 직접 맺음"] --> SC["sidecar
암호화된 바이트만 통과"]
SC -->|"Istio mTLS 터널"| GW["egress gateway
SNI만 보고 통과 - L4"]
GW --> VA["VIP-A"]
GW -.-> VB["VIP-B"]
DNS["GSLB DNS
VIP 하나만 반환이 기본
+ 장애/점검 시 절체"] -.->|"IP 목록 갱신"| GW
```
실제 구성은 sidecar↔egress gateway 구간을 Istio mTLS로 감싸는 **mTLS passthrough**다.
앱이 TLS를 직접 맺으므로 sidecar와 egress gateway는 HTTP 요청/응답을 볼 수 없고 암호화된
바이트를 나르기만 한다(L4). sidecar↔gateway 구간의 Istio mTLS는 이 사실을 바꾸지 않는다 —
mTLS는 나르는 통로를 한 겹 더 감쌀 뿐이고, gateway가 앱의 TLS를 열지 못하는 것은 같다. 그래서
테스트 랩은 mTLS 구간을 뺀 단순 passthrough로 구성했고, 이 문서의 실측은 그 구성에서 나온
것이다. 이 "L4"라는 사실이 문서 전체를 지배한다 — **Envoy가 HTTP 요청 단위로 개입하는
기능(요청 재시도 등)은 이 경로에 없다.**
이 gateway가 처리하는 외부 도메인은 DNS 응답 관점에서 두 부류다:
| 부류 | DNS 응답 | 예 |
|---|---|---|
| 사내 GSLB 도메인 | **VIP 하나만 반환이 기본** — 장애·점검 때 다른 VIP로 바꿔서 반환 | 사내·제휴 서비스 |
| 외부 일반 도메인 | **복수 IP를 반환할 수 있음** | 파트너 API, SaaS |
이 문서에서 **절체**란 GSLB가 도메인의 A레코드(도메인→IP 매핑)를 다른 VIP로 바꾸는 일을
말한다 — 계획된 점검 배수든, 장애 대응이든.
`ServiceEntry.resolution`은 "Envoy가 이 도메인의 IP 목록을 어떻게 관리하는가"를 정한다:
| 설정 | Envoy 내부 이름 | 동작을 한 줄로 |
|---|---|---|
| `DNS` | STRICT_DNS | DNS 응답을 **IP 목록 그 자체**로 쓴다. DNS가 갱신되면 목록도 갱신 |
| `DNS_ROUND_ROBIN` | LOGICAL_DNS | 목록을 안 만든다. **새 연결을 만드는 순간에만** 최신 DNS 응답의 첫 IP를 쓴다 |
## 2. STRICT DNS여도 세션 단절은 없다
걱정의 논리는 이랬다: "STRICT는 DNS가 갱신되면 IP 목록을 즉시 교체한다니까, 그 순간 그 IP로
통신 중이던 연결도 끊길 것이다." 두 방향에서 검증했다.
**공식 문서** — [Envoy service discovery](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery)의
STRICT_DNS 원문은 "will **drain** traffic from any existing connection pools"다. drain은
"지금 하는 일은 끝내게 두고 새 일만 안 준다"는 뜻이지, 끊는다(close)가 아니다. "IP가 바뀌면
기존 연결을 즉시 정리"는 오히려 **일부러 켜야 하는 별도 옵션**으로 존재한다
([`close_connections_on_host_set_change`](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto),
기본 false). 기본이 즉시 정리라면 이 옵션이 있을 이유가 없다.
**실측** — 40초짜리 응답을 받는 도중에 절체(A레코드를 옛 VIP → 새 VIP로 변경)를 일으키고,
Envoy의 상태를 4개 시점에서 찍었다:
```mermaid
sequenceDiagram
participant C as client (curl)
participant E as Envoy (L4)
participant A as server A (기존 IP)
participant D as GSLB DNS
C->>E: HTTPS 요청 (40초짜리 응답)
E->>A: TCP 연결, 스트리밍 시작
Note over D: +5초 - A레코드를 B로 변경
D-->>E: 백그라운드 갱신 - IP 목록은 B로
Note over E,A: 기존 연결은 그대로 둠
A-->>C: 스트리밍 계속
A-->>C: +40초 - 409,610바이트 전량 수신, HTTP 200
```
위 그림의 각 시점에서 실제로 확인된 것을 말로 옮기면:
| 시점 | DNS는 뭐라 답하나 | Envoy의 IP 목록 | 진행 중이던 40초 요청은 |
|---|---|---|---|
| 시작 | 옛 VIP(A) | 옛 VIP(A) | 스트리밍 시작 |
| 절체 +7초 | **새 VIP(B) — 절체 반영됨** | 아직 옛 VIP (갱신 주기 전) | 계속 스트리밍 |
| 절체 +25초 | 새 VIP(B) | **새 VIP(B)로 교체 완료** | **목록이 바뀌었는데도 계속 스트리밍** |
| 요청 완료 | 새 VIP(B) | 새 VIP(B) | 409,610바이트 전량 수신, HTTP 200 — **연결이 닫힌 것은 이때뿐** |
(카운터 원값: "연결 종료" 누적 카운터는 절체 전 38 → 요청 진행 내내 38 그대로 → 요청이 끝난
뒤에야 39. 활성 연결 수는 요청 내내 1을 유지. 실행 기록 원본은 부록 A.)
IP 목록이 새 VIP로 바뀐 뒤에도 기존 연결은 살아서 스트리밍을 계속했고, 연결이 닫힌 것은
요청을 마친 클라이언트가 닫은 시점이다. STRICT/LOGICAL 양쪽, 그리고 sidecar가 TLS를 대신 맺는
L7 경로까지 **4가지 조합 전부에서 진행 중 요청은 완주**했다.
**결론: "세션 끊김" 걱정으로 resolution을 고를 필요가 없다 — 어느 쪽도 안 끊는다.**
## 3. STRICT DNS를 써도 된다 — 그리고 기본값으로 쓰는 이유
먼저 이 절의 기조를 분명히 한다. **결론은 "반드시 STRICT"가 아니다.** 2절에서 확인했듯
STRICT를 그대로 써도 세션 문제는 없다는 것이고, 그 위에서 우리 환경의 기본값으로는 STRICT가
유리하다는 것이다. LOGICAL이 더 맞는 상황과 그 판별 기준은 4절에서 따로 다룬다.
전제 — 두 설정이 **같은** 부분부터:
- **로드밸런싱 동작은 사실상 같다.** Envoy는 연결을 만드는 순간에 DNS를 묻지 않는다
— "Envoy never synchronously resolves DNS in the forwarding path" **[공식 문서]**. 어느
설정이든 백그라운드 주기(TTL)로 갱신된 최신 결과를 쓸 뿐이므로, LOGICAL을 골라도 더 고운
분산을 얻는 것이 아니다 **[논리 귀결]**.
- **사내 GSLB 도메인처럼 A레코드가 항상 VIP 하나뿐이면 실질 동작 차이는 거의 없다.** 목록이
항상 1개라서 무엇을 골라도 같은 곳으로 간다.
### 기본값을 STRICT로 두는 이유 네 가지
1. **유지 비용이 없다 [실측].** STRICT의 유일한 비용(IP 목록이 바뀔 때 놀고 있는 연결이
정리되는 것)은 L4인 우리 환경에서 발생하지 않는다 — 기존 연결을 아예 안 건드린다(2절 실측).
2. **기본값 하나로 더 넓게 커버되는 쪽이 STRICT다 [실측].** 사내 GSLB 도메인은 단일 VIP가
기본이지만, 같은 egress가 처리하는 외부 일반 도메인(파트너 API·SaaS)은 지금도 복수 IP를
반환할 수 있다(1절의 두 부류). 같은 2-IP 상태에서 STRICT는 실제로 두 IP 모두에 트래픽을
분산했고(curl 12회 중 7/5), LOGICAL은 통신 자체는 정상이지만 첫 IP 하나만 썼다(curl 15회
전부) — 나머지 IP는 조용히 무시된다. **DNS가 복수 IP를 준 의도(분산·페일오버 후보)를
LOGICAL은 실현하지 못한다.** 기본값을 LOGICAL로 두면 복수 IP 도메인마다 예외 판단이
필요해지지만, STRICT를 기본값으로 두면 예외가 필요한 쪽은 4절에서 설명하는 "회전형 대형
도메인"뿐이다.
3. **절체가 기록된다 [실측 — 원시 근거는 부록 E].** STRICT는 절체가 membership 이벤트로 Envoy
카운터에 남아 "언제 절체됐나"를 장애 시각과 대조할 수 있다. LOGICAL은 무기록이다. (단, 과장하지 않는다 —
STRICT도 사각지대가 있다. 목록에서 제거된 옛 VIP에 아직 살아있는 연결은 endpoint 목록
조회에는 보이지 않고 cluster 합계 카운터로만 보인다[실측]. 우위는 "기록이 있다" 수준이다.)
4. **복수 IP 도메인에서는 죽은 IP 자동 격리(outlier detection)가 실제로 작동한다
[실측 — 복수 endpoint 조건].** 목록에 살아있는 대안이 함께 있는 조건에서 실패율
51.7% → 0.21%를 실측했다(부록 D). LOGICAL은 같은 상태에서도 첫 IP 하나만 쓰므로 이 장치가
성립하지 않는다. outlier detection이 하는 일을 정확히 말하면: 연결 실패가 누적된 IP를 일정
시간 목록에서 "일시 제외"하고 새 연결을 남은 IP로만 보내는 장치다 — DNS 재질의를 유발하지도,
이미 실패한 요청을 살려내지도 못하며, **목록에 대안이 없으면 우회시킬 곳도 없다.**
그래서 이것도 정직하게 적어둔다 — 단일 VIP인 사내 GSLB 도메인에서는 장애 순간 목록에 죽은 VIP
하나뿐이라 Envoy 차원의 우회가 성립하지 않고, 장애 절체의 실질 레버는 Envoy가 아니라 **GSLB의
감지 속도와 DNS TTL**이다 [논리 귀결].
### 권장 설정
```yaml
# 외부 도메인 등록 — resolution: DNS(STRICT)
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: partner-api
spec:
hosts: ["api.partner.example"] # GSLB가 VIP를 관리하는 외부 도메인
location: MESH_EXTERNAL
resolution: DNS # STRICT_DNS: DNS 응답 = IP 목록
ports:
- number: 443
name: tls
protocol: TLS # 앱이 직접 TLS를 맺음 → Envoy는 통과만(L4)
---
# 죽은 IP 자동 격리 — 실측에서 실패율 51.7% → 0.21%를 만든 설정 (복수 IP 도메인에서 유효)
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: partner-api-outlier
spec:
host: api.partner.example
trafficPolicy:
connectionPool:
tcp:
connectTimeout: 1s # 죽은 IP의 연결 실패를 1초 안에 판정
outlierDetection:
splitExternalLocalOriginErrors: true
consecutiveLocalOriginFailures: 3 # 연결 실패(연속 3회)면
interval: 5s # 5초마다 판정해서
baseEjectionTime: 30s # 그 IP를 30초간 목록에서 일시 제외
maxEjectionPercent: 50 # 단 목록의 절반까지만 (전멸 방지)
minHealthPercent: 0
```
앱 팀에 요청할 두 가지: ① 연결 실패(connect timeout/reset) 한정 재시도 1~2회 + connect
timeout 1~3초, ② 커넥션 풀 max lifetime을 절체 완료 목표 시간 이하로. ②의 이유: L4에서는
요청이 끝나도 Envoy가 연결을 회수하지 않아, 앱이 keepalive로 쥔 연결은 절체 후에도 계속 옛
VIP로 간다(부록 B 실측) — 계획 절체 때 옛 VIP가 조용해지는 시간을 결정하는 것은 Istio가
아니라 앱의 커넥션 수명이다.
## 4. 두 설정이 갈리는 지점 — LOGICAL이 맞는 자리
3절의 결론(기본값 STRICT)과 별개로, LOGICAL을 골라야 할 자리를 판별할 수 있어야 한다.
두 설정의 진짜 차이부터.
차이는 "명단 관리를 하느냐"다. STRICT는 DNS 응답을 IP 명단 그 자체로 관리한다
— 갱신 주기마다 응답에 새로 나타난 IP는 명단에 추가하고, **응답에서 사라진 IP는 명단에서
제거**한다. LOGICAL은 명단을 아예 만들지 않는다 — 새 연결을 만드는 순간 최신 응답의 첫 IP
하나만 쓴다. 이 차이 때문에 **그 도메인의 DNS 응답이 어떤 성격이냐**에 따라 맞는 쪽이 갈린다.
**LOGICAL이 맞는 자리 — 응답이 매번 크게 달라지는 대형 도메인.** 수백~수천 대 서버 풀에서
질의마다 몇 개씩만 잘라 돌려주는 대형 SaaS·CDN형 도메인이 있다. 이런 응답에서 어떤 IP가
이번에 안 나왔다는 것은 그 서버가 죽었다는 뜻이 아니라 **이번 응답에 안 뽑혔다는 뜻일 뿐**이다
(빠진 서버도 멀쩡히 살아 있다). 그런데 STRICT는 그 빠짐을 "제거"로 받아들이므로, 갱신
주기마다 멀쩡한 서버를 명단에서 뺐다 넣었다 반복하게 된다 — 의미 없는 명단 갈아엎기다. Envoy
공식 문서도 LOGICAL의 용도를 정확히 이 상황으로 설명한다: 질의마다 다른 결과를 돌려주는
대규모 DNS 라운드로빈 서비스에는 명단을 유지하지 않는 LOGICAL이 적합하다 **[공식 문서]**.
**STRICT가 맞는 자리 — 응답의 빠짐에 의미가 있는 도메인.** 사내 GSLB 도메인처럼 VIP 몇 개짜리
작은 고정 풀에서는, 응답에서 VIP가 빠지거나 바뀌는 것 자체가 신호(장애·점검·절체)다. 그 신호를
명단에 반영해 주는 STRICT가 자연스럽다.
판별 질문은 한 줄이다: **"DNS 응답에서 IP가 빠졌을 때, 그 빠짐에 의미가 있는가?"** 의미가
있으면(작은 고정 풀, 빠짐 = 절체 신호) STRICT, 없으면(거대 풀의 회전 응답, 빠짐 = 이번 추첨
탈락) LOGICAL. 주의할 점 하나 — "질의마다 응답이 다르다"는 겉모습만으로는 판별할 수 없다.
사내 GSLB도 리전 분산으로 질의마다 다른 VIP를 줄 수 있지만, 그 풀은 작고 고정이며 빠짐에
의미가 있으므로 STRICT 쪽이다.
덧붙여 우리 환경(L4)에서는 회전형 대형 도메인에 STRICT를 쓰더라도 사고가 나지는 않는다 —
명단이 자주 갈리는 군더더기가 생길 뿐, 기존 연결은 건드리지 않는다(2절 실측). 즉 **어느 쪽을
골라도 "문제가 생기는" 조합은 없다.** 남는 질문은 "기본값을 무엇으로 두는 것이 운영에
유리한가"였고, 그 답이 3절의 네 가지 이유다.
## 5. 실측 데이터
모든 수치는 재현 랩(Kubernetes 1.30 + Istio 1.30.0)에서 직접 측정했다. 외부 도메인과 GSLB는
랩 DNS(CoreDNS)와 backend 2개로 모사했고, 절체는 랩 DNS의 A레코드를 실험 도중 바꾸는 방식으로
일으켰다. 요약:
| 실측 항목 | 방법 | 결과 | 원시 기록 |
|---|---|---|---|
| 진행 중 요청 생존 — 4조합 (STRICT·LOGICAL × L7·L4) | 40초 스트리밍 응답 도중 절체, 4개 시점에서 DNS 응답·Envoy IP 목록·연결 카운터 스냅샷 | **4조합 전부 완주** (409,610바이트, HTTP 200) | 부록 A |
| keepalive 연결 고정 | 하나의 TLS 연결을 유지한 채 순차 요청, 도중 절체 (2런) | 같은 연결은 **계속 옛 VIP**, 연결을 새로 맺어야 새 VIP (연결 생성 카운터로 교차 증명) | 부록 B |
| 복수 A레코드 활용 | DNS가 2-IP를 응답하는 상태에서 반복 curl | STRICT는 두 IP 분산(7/5), LOGICAL은 첫 IP만(15/15) — 통신은 양쪽 다 정상 | 부록 C |
| 죽은 IP + 자동 격리 | 목록에 죽은 IP가 남은 상태로 연속 요청, outlier 적용 전/후 비교 | L4는 완화 없이 요청 44%가 실패로 앱 도달, outlier 적용 시 실패율 **51.7% → 0.21%** (복수 endpoint 조건) | 부록 D |
각 실측의 실행 기록 원본은 이 문서 말미 부록에 발췌해 실었다. 잘라내지 않은 전문(원시 리포트
11편 + 측정 스크립트의 무가공 트랜스크립트 15개, 총 26파일)과 재현용 manifest·스크립트 일체는
보존돼 있으며 요청 시 전달한다.
## 공식 문서 근거
- Envoy — Service discovery (STRICT_DNS/LOGICAL_DNS의 동작 정의, drain 원문):
- Envoy — cluster.proto (`close_connections_on_host_set_change`, 기본 false):
- Envoy — outlier_detection.proto (격리 동작·`always_eject_one_host` 기본 false):
## 부록 — 실측 원시 데이터 발췌
부록 0에 공통 환경·설정·명령을, 부록 A~E에 실측별 테스트 방법과 결과 로그를 실었다. 카운터는
전부 트래픽이 지나는 지점의 Envoy(sidecar)에서 찍은 **누적값**이라, 같은 런 안의
델타(변화량)로만 판정했다.
### 부록 0 — 공통: 실측 환경과 실제 설정
구성 요소:
| 구성 요소 | 역할 |
|---|---|
| netshoot pod (sidecar 주입) | client — 여기서 curl/probe 실행, 이 pod의 sidecar 카운터를 관측 |
| backend-a / backend-b (nginx) | 절체 전/후의 두 목적지. `/slow` = 40초 스트리밍 응답 endpoint |
| lab-dns (CoreDNS) | GSLB 모사 — hosts 파일로 A레코드 서빙 (TTL 5초, 파일 변경 2초 주기 반영) |
**실측에 사용한 실제 ServiceEntry** (L4 passthrough 경로 — 이 문서 시나리오의 대상):
```yaml
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
name: gslb-passthrough
namespace: dns-lab
spec:
exportTo: ["."] # 가시성을 실험 네임스페이스로 한정 (랩 안전장치)
hosts: ["gslb-pt.lab.internal"] # GSLB 도메인 모사
location: MESH_EXTERNAL
ports:
- { number: 443, name: tls, protocol: TLS } # 앱이 직접 TLS → Envoy는 SNI만 보고 통과(L4)
resolution: DNS # STRICT_DNS
```
- **LOGICAL 대조군**: 위 SE에서 `resolution: DNS_ROUND_ROBIN`으로만 바꾼 것. 같은 host를 가진
SE는 동시 적용이 안 되므로 delete 후 apply로 교체했고, 매 교체 후
`istioctl proxy-config cluster --fqdn `로 cluster type(STRICT_DNS/LOGICAL_DNS)이 실제로
바뀌었는지 확인한 뒤에만 측정을 시작했다.
- **L7 대조 경로**: 같은 backend를 `protocol: HTTP` SE + sidecar가 원격 TLS를 대신
맺는(TLS origination) DestinationRule로 등록한 경로 — Envoy가 HTTP 요청 단위로 개입할 수
있는 대조군이다. 부록 A-3/A-4와 부록 D의 "L7 대조"가 이 경로다.
- `protocol: TLS`에 VirtualService/DestinationRule을 붙이지 않으면 Istio는 이 포트를 SNI 기반
TCP proxy로만 처리한다 — 그래서 위 SE 하나가 passthrough 경로의 설정 전부다.
오해 방지 — 아래 명령의 `/hosts/addn`은 **client의 /etc/hosts가 아니다.** lab-dns(CoreDNS)
pod 안의 존 데이터 파일이고, CoreDNS의 `hosts` 플러그인이 이 파일을 읽어 A레코드로
응답한다(TTL 5초, 파일 변경은 2초 주기로 재적재). client pod는 `dnsPolicy: None` +
`dnsConfig`로 nameserver가 lab-dns를 가리키며, app 컨테이너(dig·curl)와 istio-proxy(Envoy)가
같은 resolv.conf를 공유하므로 **양쪽 모두 실제 DNS 질의(UDP 53)로 lab-dns에 묻는다.** 즉 파일
재기록은 "GSLB가 권한 DNS에서 레코드를 바꾸는 행위"의 모사다. 실측 로그가 그 증거다 — 부록 C의
dig 응답에 TTL이 카운트다운되고(4→3), 절체 직후의 dig는 아직 옛 IP였다가 수 초 뒤 새 IP로
바뀌며(재적재+캐시 전파), Envoy의 IP 목록은 dig보다도 늦게 반영된다(자기 주기로 별도 질의).
/etc/hosts 조작이었다면 이런 전파 지연·TTL 동역학 자체가 존재하지 않는다.
**공통 명령** (절체와 관측 — 모든 실측에서 동일):
```bash
# 절체 — 랩 DNS(CoreDNS)의 존 데이터 파일 재기록 (GSLB의 A레코드 변경을 모사)
kubectl -n dns-lab exec deploy/lab-dns -c writer -- sh -c \
"printf '%s gslb-pt.lab.internal\n' '<새 IP>' > /hosts/addn"
# DNS 응답 확인 (client가 보는 값)
kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb-pt.lab.internal
# Envoy의 IP 목록(endpoint) 확인
istioctl proxy-config endpoints deploy/netshoot.dns-lab \
--cluster "outbound|443||gslb-pt.lab.internal"
# Envoy 연결 카운터 (누적값)
kubectl -n dns-lab exec deploy/netshoot -c istio-proxy -- \
pilot-agent request GET "stats?filter=gslb-pt"
# cluster 멤버 전체 덤프 (부록 C의 복수 A 실측에서 사용)
kubectl -n dns-lab exec deploy/netshoot -c istio-proxy -- \
pilot-agent request GET clusters | grep gslb
```
### 부록 A — 진행 중 요청 생존 (40초 스트림 도중 절체)
**A-1. L4 passthrough × STRICT** (2026-07-09) — 실제 사용한 명령과 응답 로그 전문.
`/slow`(40초 응답)를 수신하는 도중 t=+5초에 A레코드를 pod-A(10.255.194.107) →
pod-B(10.255.126.38)로 변경하고, 4개 시점(BEFORE / MID1 +12s / MID2 +30s / AFTER 종료+3s)에서
같은 관측 3종(DNS 응답, Envoy endpoint 목록, Envoy 연결 카운터)을 반복했다.
실제 사용한 명령:
```bash
# 40초 스트림 요청 — client pod(netshoot) 안에서 백그라운드 실행
curl -sk -m 90 -o /tmp/ptX.bin \
-w "CURL_EXIT=%{exitcode} SIZE=%{size_download} TIME=%{time_total} HTTPCODE=%{http_code}" \
https://gslb-pt.lab.internal/slow
# t=+5s 절체 — 랩 DNS(CoreDNS hosts 파일)의 A레코드를 pod-B로 재기록
kubectl -n dns-lab exec deploy/lab-dns -c writer -- sh -c \
"printf '%s gslb.lab.internal\n%s gslb-pt.lab.internal\n' '' '' > /hosts/addn"
# 각 시점 스냅샷 3종 (BEFORE/MID1/MID2/AFTER 4회 동일하게 실행)
kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb-pt.lab.internal
istioctl proxy-config endpoints deploy/netshoot.dns-lab \
--cluster "outbound|443||gslb-pt.lab.internal"
kubectl -n dns-lab exec deploy/netshoot -c istio-proxy -- \
pilot-agent request GET "stats?filter=gslb-pt" \
| grep -E 'upstream_cx_total|upstream_cx_active|upstream_cx_destroy|membership_change|downstream_cx_total'
```
요약 표 (아래 로그 전문에서 뽑은 값):
| counter | BEFORE | MID1(+12s) | MID2(+30s) | AFTER(+43s) |
|---|---|---|---|---|
| dig 응답 | pod-A | **pod-B** (절체 반영) | pod-B | pod-B |
| endpoint 덤프(cluster) | pod-A만 | 여전히 pod-A만 | **pod-B로 교체** | pod-B만 |
| upstream_cx_active | 0 | **1** | **1** | **0** |
| upstream_cx_destroy | 38 | 38 (Δ0) | 38 (Δ0) | **39 (Δ+1, 완료 직후)** |
실행 로그 전문 (가공 없음, 측정 하네스가 남긴 그대로):
```
# D-2 passthrough in-flight + flip — 2026-07-09_132039
=== BEFORE @ 13:20:39 ===
-- dig gslb-pt --
10.255.194.107
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.194.107:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 4
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 0
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 38
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 43
CURL_LAUNCH=13:20:39 (T0=1783570839) -- https://gslb-pt.lab.internal/slow, -k (SNI passthrough, cert CN mismatch expected)
FLIP_ISSUED=13:20:44 (t=+5s) gslb-pt: 10.255.194.107 -> 10.255.126.38
FLIP_DONE=13:20:44 (t=+5s)
[t=+12s]
=== MID1 @ 13:20:51 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.194.107:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 4
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 1
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
[t=+30s]
=== MID2 @ 13:21:09 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.126.38:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 5
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 1
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 38
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 22
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 25
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
CURL_WAIT_DONE=13:21:19 (t=+40s)
-- curl result --
CURL_EXIT=0 SIZE=409610 TIME=40.050531 HTTPCODE=200
-- curl stderr --
[t=+43s]
=== AFTER @ 13:21:22 ===
-- dig gslb-pt --
10.255.126.38
-- endpoints (netshoot, gslb-pt cluster) --
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.255.126.38:443 HEALTHY OK outbound|443||gslb-pt.lab.internal
-- cluster stats (gslb-pt, netshoot sidecar) --
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 5
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_active: 0
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy: 39
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local: 16
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_local_with_active_rq: 3
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote: 23
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_remote_with_active_rq: 23
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_destroy_with_active_rq: 26
cluster.outbound|443||gslb-pt.lab.internal;.upstream_cx_total: 39
tcp.outbound|443||gslb-pt.lab.internal.downstream_cx_total: 44
-- received file check (netshoot) --
409610 /tmp/ptX.bin
backend-a
XXXXX
## 복구: gslb-pt -> backend-a 현재 pod IP로 재원복
10.255.194.107
backend-a
[http_code=200]
```
로그 읽는 안내: MID1에서 dig는 이미 pod-B인데 endpoint 목록은 아직 pod-A(Envoy의 자체 DNS
갱신 주기가 아직 안 돌았음), MID2에서 `membership_change 4→5`로 목록이 pod-B로 교체됐다.
그 전 구간 내내 `upstream_cx_active: 1`(우리의 스트림)이 유지되고 destroy 계열은 전부
정지 상태 — destroy가 +1 되는 것은 curl이 40초를 다 받고 끝난 AFTER 시점뿐이다. 수신 파일
검사(409,610바이트, 첫 줄 `backend-a`)가 "전량을 절체 전 backend에서 받았다"의 확인이다.
**A-2. L4 passthrough × LOGICAL** (2026-07-09, 2런 동일 패턴). 테스트 방법: A-1과 같은
하네스·같은 시나리오에서 SE만 `resolution: DNS_ROUND_ROBIN` 버전으로 교체(부록 0의 교체 절차)
하고 2런 반복:
| 시점 | dig 결과 | membership_change | upstream_cx_active | upstream_cx_destroy |
|---|---|---|---|---|
| BEFORE | pod-A | 11 | 0 | 2973 |
| MID1 +12s | pod-B (절체 반영) | **11 (Δ0 — 기록조차 없음)** | 1 | 2973 |
| MID2 +30s | pod-B | 11 | 1 | 2973 |
| AFTER +43s | pod-B | 11 | 0 | 2974 |
```
run1: CURL_EXIT=0 SIZE=409610 TIME=40.055717 HTTPCODE=200
run2: CURL_EXIT=0 SIZE=409610 TIME=40.047827 HTTPCODE=200
```
**A-3. L7(sidecar가 TLS를 대신 맺는 경로) × STRICT** (2026-07-02). 테스트 방법: 부록 0의 L7
대조 경로에서 client가 평문으로 `http://gslb.lab.internal/slow`를 호출(sidecar가 원격 TLS를
대신 맺음), 스트림·절체·4점 스냅샷 타이밍은 A-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 | 51 | 51 (Δ0) | 51 (Δ0) | **52 (+1)** |
| upstream_cx_destroy_with_active_rq | 1 | 1 | 1 | 1 (Δ0) |
```
CURL_EXIT=0 SIZE=409610 TIME=40.045811 CODE=200 (body = backend-a — 무절단 완주)
```
연결 종료(destroy)가 절체 시점이 아니라 **요청 완료 직후에만** +1 — "IP 목록에서 host를 제거해도
in-flight 연결은 완료까지 유예 후 폐쇄(graceful drain)"의 직접 증거다.
`destroy_with_active_rq`(활성 요청을 문 채 끊긴 연결)는 전 구간 Δ0 — 요청을 문 채 끊긴 연결은
한 건도 없었다.
**A-4. L7 × LOGICAL** (2026-07-02, 2런 동일): membership_change·destroy 계열 전 구간 Δ0,
curl 완주(`SIZE=409610 CODE=200` × 2런). **네 조합 모두 완주** — 차이는 요청이 끝난 뒤 연결의
운명뿐이다(STRICT는 완료 즉시 폐쇄해 다음 요청부터 새 IP, LOGICAL은 연결이 pool에 남아 재사용
시 옛 IP 지속).
### 부록 B — keepalive 연결 고정 (계획 절체 시나리오)
테스트 방법: "같은 TLS 연결을 재사용하는 요청은 절체 후 어디로 가는가"를 보기 위해, curl 대신
python 표준 라이브러리로 연결 하나를 쥔 채 순차 요청을 보냈다 — 요청마다 로컬
포트(`getsockname`)와 실제 접속지(`getpeername`)를 소켓에서 직접 찍을 수 있어 "같은 연결인가 /
어디로 붙어 있는가"가 그 자체로 1차 증거가 된다. 절체(t=+5초)와 카운터 관측은 부록 0의 공통
명령 그대로.
실제 사용한 probe 핵심부:
```python
conn = http.client.HTTPSConnection("gslb-pt.lab.internal", 443, context=ctx) # 검증 off, SNI 유지
do_get(conn, "REQ1_t0") # t=0 — 각 요청마다 getsockname()/getpeername()/body 기록
time.sleep(15)
do_get(conn, "REQ2_t15") # t=15 (절체는 외부에서 t=+5s에 발사됨)
time.sleep(10)
do_get(conn, "REQ3_t25") # t=25
conn.close()
conn2 = new_conn()
do_get(conn2, "REQ4_newconn") # 연결을 닫고 새로 맺은 뒤의 대조점
```
probe 출력 원문 (Run 1, 가공 없음):
```
RUN_START wallclock=11:39:27.714
[REQ1_t0] wallclock=11:39:27.724 OK status=200 local=('10.255.126.8', 40186) peer=('10.255.194.124', 443) body='backend-a'
SLEEP_15 wallclock=11:39:27.724
[REQ2_t15] wallclock=11:39:42.725 OK status=200 local=('10.255.126.8', 40186) peer=('10.255.194.124', 443) body='backend-a'
SLEEP_10 wallclock=11:39:42.726
[REQ3_t25] wallclock=11:39:52.726 OK status=200 local=('10.255.126.8', 40186) peer=('10.255.194.124', 443) body='backend-a'
CONN_CLOSED wallclock=11:39:52.728
[REQ4_newconn] wallclock=11:39:52.734 OK status=200 local=('10.255.126.8', 59332) peer=('10.255.126.38', 443) body='backend-b'
RUN_END wallclock=11:39:52.735
```
(probe 내부 시계는 UTC라 위 로그가 11:39로 찍혔고, 오케스트레이션 쪽 기록은 KST 20:39다 —
같은 시각이다. 절체는 REQ1 직후 t=+5초에 발사됐고, t=+10초에 dig가 새 IP를 반환하는 것까지
확인된 뒤 REQ2(t=+15초)가 나갔다 — 즉 REQ2/REQ3는 DNS가 이미 바뀐 상태에서의 요청이다.)
위 원문을 표로 정리하면 (2026-07-09, Run 1):
| t | 이벤트 | 로컬포트 | peer (실제 접속지) | body |
|---|---|---|---|---|
| t=0 | REQ1 (연결 오픈) | 40186 | 10.255.194.124 (pod-a) | backend-a |
| t=+5s | **절체 발사** pod-a → pod-b (+9s dig로 반영 확인) | — | — | — |
| t=15 | REQ2 (같은 연결) | 40186 (불변) | 10.255.194.124 (**여전히 옛 IP**) | backend-a |
| t=25 | REQ3 (같은 연결) | 40186 (불변) | 10.255.194.124 | backend-a |
| t=25+ | REQ4 (close 후 새 연결) | 59332 (**변경**) | 10.255.126.38 (**pod-b**) | backend-b |
Envoy 쪽 교차 증거 — 연결 생성 누적 카운터 `upstream_cx_total`:
`2984 → 2985(+1, REQ1) → 2985(Δ0, REQ2/3 재사용) → 2986(+1, REQ4)` — 요청 4번에 연결 생성은
정확히 2번. Run 2도 동일 패턴(+1, Δ0, +1) 재현. 서버 keepalive_timeout(65초) 안에서의 정직한
재사용이며, 재연결 폴백 분기는 2런 모두 트리거되지 않았다.
### 부록 C — 복수 A레코드 활용 (STRICT vs LOGICAL)
테스트 방법 (2026-07-09): 랩 DNS가 같은 도메인에 A레코드 **2개**를 반환하도록 만든 뒤, LOGICAL
상태에서 반복 요청 → 같은 상태 그대로 SE만 STRICT로 교체 → 다시 반복 요청. 응답 body(어느
backend가 답했나)와 Envoy cluster 멤버 덤프로 판정했다.
실제 사용한 명령:
```bash
# 2-A레코드 상태 만들기 — 랩 DNS hosts 파일에 같은 도메인 두 줄 주입
kubectl -n dns-lab exec deploy/lab-dns -c writer -- sh -c \
"printf '10.250.161.145 gslb.lab.internal\n10.250.183.248 gslb.lab.internal\n' > /hosts/addn"
# SE 교체 (같은 host라 delete 후 apply) + cluster type 확인 후 측정 시작
kubectl -n dns-lab delete serviceentry gslb-strict
kubectl apply -f serviceentry-logical.yaml # resolution: DNS_ROUND_ROBIN 버전
istioctl proxy-config cluster deploy/netshoot.dns-lab --fqdn gslb.lab.internal # type 확인
# 반복 요청 (응답 body로 어느 backend인지 확인) + 멤버 덤프
kubectl -n dns-lab exec deploy/netshoot -- curl -s http://gslb.lab.internal/
kubectl -n dns-lab exec deploy/netshoot -c istio-proxy -- \
pilot-agent request GET clusters | grep gslb.lab.internal
```
DNS가 실제로 2개를 반환함을 먼저 확인 (5회 반복, 매회 동일):
```
gslb.lab.internal. 4 IN A 10.250.161.145 <- backend-a
gslb.lab.internal. 4 IN A 10.250.183.248 <- backend-b
```
**LOGICAL(DNS_ROUND_ROBIN)** — curl 결과 원문(12회 중 앞 4회, 나머지 8회도 전부 동일했고
baseline 3회 포함 15회 전부 backend-a):
```
req#1: backend-a
[http_code=200]
req#2: backend-a
[http_code=200]
req#3: backend-a
[http_code=200]
req#4: backend-a
[http_code=200]
... (req#5~12 전부 backend-a, 200)
```
cluster 멤버 덤프 — 멤버는 첫 IP 하나뿐:
```
$ pilot-agent request GET clusters | grep gslb.lab.internal
outbound|443||gslb.lab.internal::10.250.161.145:443::rq_total::15
outbound|443||gslb.lab.internal::10.250.161.145:443::rq_success::15
(10.250.183.248 멤버 행 자체가 없음 — 두 번째 IP는 조용히 무시.
에러·설정 거부 없음: istiod 로그 reject 매치 0, cds.update_rejected 0)
```
**같은 2-A레코드 상태에서 STRICT로 교체** — 멤버 2개, curl 12회 응답(순서는 로그 원문 그대로,
배치만 3열로 정리):
```
req#1: backend-a req#5: backend-b req#9: backend-a
req#2: backend-a req#6: backend-a req#10: backend-a
req#3: backend-a req#7: backend-b req#11: backend-b
req#4: backend-b req#8: backend-a req#12: backend-b
(전부 http_code=200 — backend-a 7 / backend-b 5로 실제 분산)
$ pilot-agent request GET clusters | grep gslb.lab.internal
outbound|443||gslb.lab.internal::10.250.161.145:443::... <- backend-a
outbound|443||gslb.lab.internal::10.250.183.248:443::... <- backend-b (멤버 2개)
```
### 부록 D — 죽은 IP + 자동 격리 (outlier detection)
테스트 방법 (2026-07-09): DNS 응답(IP 2개)은 그대로 둔 채 backend-a만 내려서 **목록에 죽은
IP와 정상 IP가 공존**하는 조건을 만들고, 40초 창 동안 L4 passthrough 경로와 L7 대조 경로에
같은 스크립트에서 동시에 부하를 걸었다.
실제 사용한 명령:
```bash
# dead-IP 조건 — backend-a를 내림 (DNS A레코드에는 그 IP가 그대로 남아 있음)
kubectl -n dns-lab scale deploy/backend-a --replicas=0
# L4 passthrough 루프 (40초 창)
curl -sk --connect-timeout 3 -m 10 \
-w "%{exitcode} %{http_code} %{time_total}" https://gslb-pt.lab.internal/
# L7 대조 루프 (같은 창에서 동시 실행)
curl --connect-timeout 3 -m 10 \
-w "%{exitcode} %{http_code} %{time_total}" http://gslb.lab.internal/
```
결과:
| 경로 | 시도 | 성공 | 실패 | 실패 유형 |
|---|---:|---:|---:|---|
| L4 passthrough | 41 | 23 (56.1%) | **18 (43.9%)** | connect timeout 13건 + TLS 연결 실패 5건 — **전부 client에 그대로 전달** |
| L7 (대조) | 86 | **86 (100%)** | 0 | Envoy가 연결 실패를 자동 재시도 — `upstream_rq_retry_success` 46/46 |
L4 루프 로그 꼬리 원문 (정상 응답과 실패가 섞여 client에 그대로 도달):
```
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 <- exit 28 = connect timeout(3초), 실패가 client에 도달
```
L7 대조 경로의 카운터 델타 원문 (연결 실패 46건 전부가 자동 재시도로 회복된 증거):
```
BEFORE AFTER delta
upstream_cx_connect_fail 0 46 +46
upstream_rq_retry 0 46 +46
upstream_rq_retry_success 0 46 +46 <- 46건 전부 다른 IP로 재시도 성공
upstream_rq_total 2 88 +86 <- client 요청 수(86)와 정확히 일치
```
이어서 같은 dead-IP 조건의 L4에 적용한 outlier 설정 (실제 적용본 — 3절 권장 설정과 동일 값):
```yaml
trafficPolicy:
connectionPool:
tcp: { connectTimeout: 1s }
outlierDetection:
splitExternalLocalOriginErrors: true
consecutiveLocalOriginFailures: 3
interval: 5s
baseEjectionTime: 30s
maxEjectionPercent: 50
minHealthPercent: 0
```
적용 전/후:
| stage | 총시도 | 성공 | 실패 | 실패율 |
|---|---:|---:|---:|---:|
| outlier 없음 | 29 | 14 | 15 | **51.7%** |
| outlier 적용 | 2905 | 2899 | 6 | **0.21%** |
적용 직후 루프 로그 원문 (격리 판정 전 초반 실패가 client에 도달하는 구간):
```
04:33:50 n=1 35 000 1.005092 <- 아직 격리 전 (exit 35 = TLS 연결 실패, 1초 판정)
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건 연속 성공 (격리 발효)
```
격리 상태 스냅샷:
```
outlier_detection.ejections_enforced_total: 2
...::10.255.194.107:443::health_flags::/failed_outlier_check <- 죽은 IP가 격리됨
...::10.255.126.38:443::health_flags::healthy <- 새 연결은 전부 이쪽으로
```
0.21%가 정확히 0이 아닌 이유: 격리 판정(연속 실패 3회)을 채우는 동안의 초반 실패는 client에
도달한다 — L4에는 이를 흡수할 요청 단위 재시도가 없다. 그리고 이 효과 전체가 **"목록에
살아있는 대안이 있다"는 전제 위에서만** 성립한다(3절 근거 4의 조건).
### 부록 E — 절체 기록 (3절 근거 3의 원시 근거)
주장: STRICT는 절체가 `membership_change` 카운터 증가(멤버 명단 변경 이벤트)로 남고, LOGICAL은
같은 절체에도 아무 기록이 없다. 별도 실험이 아니라 **부록 A·B의 같은 런들에서 해당 카운터
줄만 모은 것**이다:
| 실측 런 | 설정 | 절체 전 | 절체 후 | 기록 |
|---|---|---|---|---|
| 부록 A-1 (L4 in-flight) | STRICT | membership_change: 4 | **5 (+1)** | 남음 |
| 부록 B Run 1 (keepalive) | STRICT | membership_change: 12 | **13 (+1)** | 남음 |
| 부록 A-3 (L7 in-flight) | STRICT | membership_change: 17 | **18 (+1)** | 남음 |
| 부록 A-2 (L4 LOGICAL, 2런) | LOGICAL | membership_change: 11 | **11 (Δ0)** | **없음** |
| 부록 A-4 (L7 LOGICAL, 2런) | LOGICAL | membership_change: 20 | **20 (Δ0)** | **없음** |
원시 로그의 해당 줄 (부록 A-1 전문에서 그대로):
```
=== MID1 @ 13:20:51 === (절체 +7초 — Envoy 목록엔 아직 반영 전)
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 4
=== MID2 @ 13:21:09 === (절체 +25초 — 목록 교체가 반영된 시점)
cluster.outbound|443||gslb-pt.lab.internal;.membership_change: 5
```
근거 3의 단서(사각지대)의 원시 근거도 여기 있다 — L7 STRICT 런(부록 A-3)의 MID1(+12s) 시점,
cluster 합계에는 활성 연결이 1개(진행 중인 우리의 스트림)인데 endpoint 목록에는 새 IP만 보이고
그 연결이 실제로 물려 있는 옛 IP는 이미 목록에서 사라져 보이지 않았다:
```
(MID1, +12s) endpoint 덤프: 10.250.27.55(backend-b) cx_active=0 <- 목록엔 새 IP뿐
cluster 합계: upstream_cx_active: 1 <- 활성 연결 1개는 옛 IP에 잔존
```
즉 이 카운터 줄들이 "절체가 기록된다(STRICT) / 기록조차 없다(LOGICAL)"의 증거이자, "endpoint
목록만 봐서는 옛 VIP의 잔존 연결이 안 보인다"는 사각지대의 증거다.
> [!note]
> 사내 공유 전에 확인할 것: ① **GSLB 응답 패턴** — dig를 여러 번 쳐서 A레코드가 몇 개
> 오는지(복수면 outlier 효과의 전제가 충족되고 STRICT 필수 근거도 강해짐), 고정형인지
> 회전형인지, TTL 몇 초인지. ② **절체 완료 목표 시간** — 앱 커넥션 max lifetime 권장값이
> 여기서 나옴. ③ **초장기 스트리밍 워크로드 유무** — 이번 실측은 40초급 스트림까지 검증했고,
> 수 분급 스트림의 유예 상한과 HTTP/2 경로는 미실측이다.