--- 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 경로는 미실측이다.