--- title: Q&A — GSLB가 IP를 바꾸면 내 요청은 어떻게 되나 (in-flight의 운명, 쉬운 정리) date: 2026-07-09 type: note domain: istio tags: [istio, egress, dns, gslb, service-entry, tls-passthrough, qa] --- > [!abstract] > "STRICT_DNS는 A레코드가 바뀌면 기존 연결을 끊는다"는 통념에서 출발해, 실측 결과와 부딪히며 나온 > 실제 Q&A를 그대로 정리한 문서다. 결론 요약 — **① 진행 중(in-flight)이던 요청은 어떤 조합에서도 > 성공한다. ② "연결을 끊는다"도 맞는 말인데, 시점이 "요청 한가운데"가 아니라 "요청 경계"다. > ③ 설정 차이가 드러나는 곳은 지금 요청이 아니라 "다음 요청"이다.** 정밀한 매트릭스·카운터 증거는 > [SE resolution × TLS passthrough — in-flight 요청의 운명](/docs/istio/egress/se-resolution-passthrough-inflight/)에, > 재현 절차는 [DNS/GSLB 재현 랩](/docs/istio/egress/dns-gslb-repro-lab/)에 있다. 이 문서는 그 결과를 > "질문 순서대로" 이해하기 위한 입구다. **전제 환경**: Istio 1.30.0, `ServiceEntry`로 등록한 외부 도메인, 그 도메인의 A레코드를 GSLB(실험에선 사설 DNS)가 바꾸는 상황. "L7"은 sidecar가 HTTP를 보고 TLS를 대신 맺는 경로(TLS origination), "L4"는 앱이 직접 TLS를 맺고 sidecar는 통과만 시키는 경로(TLS passthrough). --- ## Q1. STRICT_DNS에서 요청이 진행 중인데 A레코드가 바뀌면 — 그 요청은 실패인가 성공인가? **성공한다. 클라이언트는 아무것도 못 느낀다.** 진행 중이던 요청은 **이미 연결돼 있던 옛 서버가 끝까지 응답**한다. 40초짜리 응답을 받는 도중에 A레코드를 바꿔봤지만, 4가지 조합(STRICT/LOGICAL × L7/L4) 전부에서 200으로 전량 수신됐다(실측). ``` client ----[request in progress]----> old server (10.0.0.1) | | <- DNS now says 10.0.0.2 (A record changed) | client <---[response continues]------ old server (10.0.0.1) OK 200 ``` 그런데 "STRICT는 기존 연결을 끊는다"는 이해도 **틀린 게 아니다**. 끊는다 — 시점이 이렇다: ``` idle connection (요청 사이, 놀고 있는 keepalive): flip ----> 즉시 끊음 <- 실측: flip 직후 destroy +2, 다음 요청 -> 새 IP 5초 내 트래픽이 새 서버로 busy connection (요청이 실려 있는 중): flip ----> host는 즉시 목록에서 제거 <- 실측: +12s에 membership +1 연결은 살려둠 (스트림 계속) 연결은 +30s에도 active 요청 완료 (+40s) ----> 그 순간 끊음 <- 실측: 완료 직후에만 destroy +1 다음 요청 -> 새 IP ``` 반증된 것은 딱 하나 — **"요청 한가운데를 자른다"**. 절단 카운터(`destroy_with_active_rq`)는 모든 런에서 0이었다. > [!tip] > "long connection"과 "long request"를 구분하면 혼란이 사라진다. keepalive로 짧은 요청 여러 개를 > 나르는 **장수명 커넥션**은 flip 시 요청 경계에서 사실상 즉시 끊긴다(통념대로). 40초 다운로드나 > gRPC 스트림 같은 **장수명 단일 요청**은 완료까지 유예된다(이번 실측의 새 발견). → 원본 수치: 실측 부록 B(STRICT 4점 스냅샷) · 부록 E(mode1, idle 연결 destroy+2) --- ## Q2. 그럼 뭐가 달라지는 건가? 지금 요청이 아니라 **"다음 요청"에서 갈린다.** 차이 지점은 두 개다. **① GSLB를 얼마나 빨리 따라가나 (평시 전환)** ``` STRICT : now ----[req done]--> next request goes to B (within seconds) LOGICAL: now ----[req done]--> next request STILL goes A (connection reused) ... A keeps getting traffic as long as conn lives = "stale pin" ``` - STRICT: 요청이 끝나자마자 Envoy가 옛 연결을 닫아준다 → **다음 요청부터 새 IP**. - LOGICAL: 옛 연결이 pool에 살아있는 한 재사용 → **계속 옛 IP로 간다**. GSLB가 트래픽을 빼려던 서버에 며칠이고 붙어있을 수 있다. **② 옛 IP가 죽었을 때 누가 구해주나 (장애 상황)** - L7: VS에 retry를 안 써도 Istio가 route에 기본 재시도(`retry_on: connect-failure`, 2회, 다른 host로)를 심어둔다 → 실패가 클라이언트에 안 보인다(실측 86/86 성공, 비용은 ~1초 지연). 단, VS에 `retries.attempts: 0`을 명시하면 이 보호가 꺼진다. - L4 passthrough: Envoy는 바이트만 나르므로 재시도 불가 → **실패가 앱까지 그대로**(실측 44% 실패). outlier detection으로 크게 줄일 수 있지만 0은 안 된다(실측 51.7%→0.21%). **그래서 권장이 뒤집혔다:** | | 실측 전 통념 | 실측 후 | |---|---|---| | STRICT | "flip마다 세션 끊는 위험한 놈" | in-flight는 안 자르면서 GSLB는 즉시 추종 — **안전한 기본값** | | LOGICAL | "세션 보호하는 안전한 놈" | 보호가 아니라 **방치** — stale pin(GSLB 의도 무시)이 진짜 리스크 | "세션 끊길까봐 LOGICAL 쓴다"의 근거가 실측으로 무너졌다. 세션은 어차피 안 끊기고, 갈리는 건 GSLB 추종 속도와 장애 시 구제 여부다. → 원본 수치: 실측 부록 C(LOGICAL stale pin의 cx_active 잔존) · 부록 E(D-1 dead-IP 회복률, D-3 outlier) --- ## Q3. HTTP는 Envoy가 신규 요청을 만드니 그렇다 치고 — HTTPS passthrough는 IP가 바뀌어도 기존 TCP를 안 끊고 "기다려주는" 건가? 결과는 같은데(안 끊김), 정확히는 **"기다림"이 아니라 "불간섭"이다.** 연결의 주인이 다르기 때문이다. ``` HTTP (L7): app --conn1--> [Envoy] --conn2--> server Envoy OWNS conn2: sees each request, closes conn2 right after request done <- active management HTTPS (L4): app --------- one TCP pipe ----------> server Envoy is just a pipe: sees only bytes, never closes it. IP change = no effect <- no management ``` - L7: Envoy가 요청을 받아 **자기 연결로 신규 요청**을 만든다. 연결 주인이 Envoy라서 flip이 오면 "지금 요청만 끝내고 닫자"를 **할 수 있다**. - L4: Envoy에겐 요청이라는 개념 자체가 없다(암호화된 바이트만 통과). IP가 바뀌어도 소켓에 아무 짓도 안 한다. 실측에서 스트림이 완주한 것도, 연결이 닫힌 시점이 **curl이 스스로 끝낸 순간**이었던 것도 이 때문이다 — Envoy가 배려해서 기다린 게 아니라 관여 자체를 안 한 것. **따라오는 실무 함의**: L7 STRICT의 "요청 끝나면 닫아줌 → 다음 요청은 새 IP" 보장이 passthrough에는 **없다**. 요청이 끝나도 Envoy가 안 닫아주니까, 앱이 그 TLS 연결을 keepalive로 재사용하면 STRICT여도 계속 옛 IP로 간다 *(메커니즘 추론 — 40초 스트림 생존까지가 실측이고, 재사용 연결의 행선지는 미실측)*. 즉 **passthrough에서 GSLB 추종 속도를 결정하는 건 Istio 설정이 아니라 앱의 연결 수명 관리**다. 손댈 레버: ① 앱 클라이언트의 connection TTL ② 서버 쪽 keepalive timeout(원격이 닫으면 앱이 재연결하며 새 IP를 탄다) ③ DNS TTL. Istio 쪽 레버는 사실상 없다. → 원본 수치: 실측 부록 D(L4 STRICT/LOGICAL membership·destroy 타이밍) --- ## Q4. 이 주장들은 어떻게 테스트했나? 실험장의 핵심 장치 3개: ``` +----------+ +---------------+ +------------------+ | client | --> | sidecar Envoy | ---> | backend-a (nginx)| /slow = 400KB | (curl) | | (measured!) | | | backend-b (nginx)| at 10KB/s = 40s +----------+ +---------------+ | +------------------+ streaming | +-----------------------+ | lab-dns (private DNS) | A record under OUR control +-----------------------+ ``` 1. **사설 DNS를 직접 flip** — GSLB 시뮬레이션. 2. **40초 스트리밍 응답**(`/slow`) — "요청이 진행 중인 동안" flip을 걸 시간창. 3. **4점 카운터 스냅샷**(시작 전/+12s/+30s/종료+3s) — client sidecar의 Envoy admin 카운터로 연결의 생사를 초 단위 추적. 카운터는 누적이라 항상 같은 런의 델타로만 판정. | 주장 | 증거 | |---|---| | flip이 요청 도중 처리됐다 | +12s에 `membership_change +1`, endpoint 목록 a→b 교체 | | 연결은 그동안 살아있었다 | +12s·+30s에 `cx_active=1` 유지, destroy 계열 정지 | | 완료 직후 Envoy가 닫았다 (L7) | 종료+3s에만 `destroy_local +1` | | 한가운데 절단은 없었다 | `destroy_with_active_rq` 전 런 0 + curl 전량 수신 | | passthrough는 진짜 L4다 | listener 덤프: SNI match → `tcp_proxy`, route 없음 | | passthrough를 닫은 건 curl이다 | destroy가 정확히 curl 종료 시점에만 발생 + 1:1 파이프 구조 | 측정 무효화 함정도 하나 잡았다: passthrough 실험에서 DNS를 Service ClusterIP로 걸면 per-VIP listener가 SNI보다 먼저 매칭해 **트래픽이 조용히 다른 경로로 새는데 curl은 200이 나온다**. "측정 대상 cluster의 카운터가 실제로 움직이는가"를 먼저 확인하고 pod IP로 교정한 뒤에만 본 실험을 진행했다. 미실측으로 남긴 것: drain 유예의 상한(수 분급 스트림), HTTP/2 GOAWAY 경로, passthrough 재사용 연결의 행선지. → 상세 절차·스크립트·annotation: 실측 부록 A (이 Q는 개념 요약, 부록 A가 재현 가능한 디테일) --- ## Q5. 공식 문서(envoyproxy.io)는 정확히 뭐라고 써놨나 [Envoy service discovery 아키텍처 문서](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery) 원문과 번역: > **Strict DNS** — "Envoy will continuously and asynchronously resolve the specified DNS targets. > Each returned IP address in the DNS result will be considered an explicit host in the upstream > cluster. … If a host is removed from the result Envoy assumes it no longer exists and **will > drain traffic from any existing connection pools**. … Note that Envoy never synchronously > resolves DNS in the forwarding path." → DNS 대상을 **지속적·비동기적으로** 해석한다. 반환된 각 IP는 cluster의 명시적 host로 간주된다. host가 결과에서 사라지면 더 이상 존재하지 않는 것으로 간주하고 기존 connection pool로부터 트래픽을 **drain한다**. Envoy는 포워딩 경로에서 절대 동기적으로 DNS를 해석하지 않는다. > **Logical DNS** — "…a logical DNS cluster only uses the first IP address returned **when a new > connection needs to be initiated**. Thus, a single logical connection pool may contain physical > connections to a variety of different upstream hosts. **Connections are never drained**, > including on a successful DNS resolution that returns 0 hosts. With logical DNS, connections > stay alive until they get cycled." → **새 연결을 시작해야 하는 시점에** 반환돼 있는 첫 번째 IP만 사용한다. 그래서 하나의 논리적 connection pool 안에 서로 다른 실제 host로의 물리 연결들이 공존할 수 있다. 연결은 **절대 drain되지 않는다** — DNS가 host 0개를 반환한 경우조차. 연결은 순환(교체)될 때까지 살아있다. 여기서 통념이 어긋나는 지점 — Strict DNS 문장 하나에 **서로 다른 두 사건**이 들어 있다: | 사건 | 문서 표현 | 실제 시점 | |---|---|---| | ① host 목록(라우팅 후보) 갱신 | "assumes it no longer exists" | **즉시** — 새 연결/요청은 바로 새 IP로 | | ② 기존 연결의 처분 | "**drain** traffic from existing connection pools" | **정중하게** — 새 배정만 중단, 쓰던 것은 끝나면 닫음 | 동사가 close가 아니라 **drain**이다. drain = 강제 절단의 반대말 — 새 트래픽 배정을 멈추고, 진행 중인 사용이 끝나기를 기다렸다가 닫는 우아한 종료. 결정적 증거로, "변경 즉시 기존 연결 정리"는 [별도의 opt-in 필드](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto)로 존재한다: > `CommonLbConfig.close_connections_on_host_set_change` — "If set to `true`, the cluster manager > will drain all existing connections to upstream hosts whenever hosts are added or removed from > the cluster." (기본값 **false**) 기본 동작이 강제 정리라면 이 필드가 존재할 이유가 없다. → 실측으로 확인된 drain 의미론: 실측 부록 B(요청 완료 직후에만 destroy 틱 = 문서의 "drain"과 일치) --- ## Q6. LOGICAL은 "새 연결을 시작할 때 DNS 질의"를 한다는 뜻인가? **아니다. 질의(resolve)와 소비(사용)를 구분해야 한다.** - **질의**: STRICT든 LOGICAL이든 **백그라운드 주기로 동일하게** 돈다("continuously and asynchronously", 주기는 refresh rate/DNS TTL이 결정). 연결을 만드는 순간에 동기 질의를 하는 일은 절대 없다 — "Envoy never synchronously resolves DNS in the forwarding path" (그랬다간 느린 DNS 응답이 트래픽을 블로킹한다). - **소비**: 여기서 갈린다. - STRICT: 백그라운드 결과가 도착하는 **즉시 host 목록 자체를 교체**한다. - LOGICAL: 결과를 옆에 캐시해두고, **새 연결을 만들어야 하는 순간에 "가장 최근 결과의 첫 IP"를 참조**할 뿐이다. 그래서 "하나의 논리 pool 안에 여러 실제 host로의 연결이 공존"하게 된다 — 시점이 다른 연결마다 그때그때의 최신 IP로 맺어졌기 때문. 우리가 실측한 관측 함정(endpoint 덤프 라벨은 새 IP인데 실제 바이트는 옛 IP로 흐름)의 뿌리가 바로 이 구조다. → 원본 수치: 실측 부록 C(membership Δ0인데 endpoint 라벨은 갱신) · 부록 D(F-0, membership 개념 자체 없음) --- ## Q7. "0 hosts여도 산다", "cycled될 때까지 살아있다"가 무슨 말인가 **"0 hosts"**: DNS 서버가 *정상 응답*했는데 A레코드가 하나도 없는 극단 상황이다. - STRICT: "Envoy will assume that the cluster does not have any hosts" — 목록이 비고, 기존 pool은 drain되고, 새 요청은 no healthy upstream으로 실패한다. DNS를 권위로 믿는다. - LOGICAL: **그 극단에서조차 이미 맺어둔 연결을 건드리지 않는다.** DNS가 "이 서비스 이제 없음"이라고 말해도, 살아있는 연결로는 계속 통신한다. 연결의 생존을 DNS보다 우선한다. **"cycled(순환)"**: 연결이 **자연적인 수명 이벤트로 교체**되는 것이다 — 서버가 닫거나(keepalive timeout), idle timeout, 클라이언트 종료, 연결당 요청 수 상한 도달 등으로 연결이 죽고, **다음 새 연결이 그 시점의 최신 DNS 결과를 탄다**. 즉 LOGICAL에서 IP 변경의 전파는 "연결 교체 주기에 실려서만" 일어난다 — Envoy가 능동적으로 갈아타 주는 일은 없다. 실측에서도 LOGICAL 런이 남긴 연결이 한참 뒤 nginx keepalive timeout으로(원격이 닫아서) 정리되는 걸 관측했는데, 그게 바로 cycling이다. → 원본 수치: 실측 부록 C("런 간 노이즈" — run1이 남긴 연결을 원격 keepalive timeout이 정리한 사례) --- ## 그래서 GSLB 앞에서는 — STRICT vs LOGICAL 장단점과 의견 GSLB가 다른 VIP의 A레코드를 반환하는 것은 **의도적 조향**(failover·점검 배수·지역 전환)이다. 이 전제에서 두 설정을 비교하면: | | STRICT_DNS | LOGICAL_DNS | |---|---|---| | GSLB 의도 추종 | **즉시** — 다음 요청부터 새 VIP (실측 ~5초) | 연결 수명에 종속 — **stale pin**, 전환 시점 예측 불가 | | in-flight 요청 | 보호 (drain 유예, 실측) | 보호 (불간섭, 실측) | | 죽은 VIP 정리 | DNS에서 빠지면 목록 제거 + pool drain | 기존 연결은 TCP 실패로만 발견 | | DNS flap 내성 | 약함 — flap마다 연결 churn (pool 재생성·TLS 비용) | 강함 — 0 hosts에도 면역 | | multi-A 레코드 | 지원 (여러 VIP 분산) | 불가 (Istio에선 CDS NACK) | | 관측성 | 목록이 실제를 반영, membership 이벤트 발생 | 라벨 함정 (덤프 ≠ 실제 행선지) | | 문서상 설계 용도 | DNS 결과 = cluster 구성 그 자체 | 매 질의마다 다른 IP를 주는 대형 웹서비스 | **의견 (이번 실측 전체에 근거):** 1. **GSLB 앞 + L7(origination) 경로면 STRICT가 기본값이다.** GSLB를 두는 이유 자체가 조향 의도인데 LOGICAL은 그 의도를 구조적으로 무시한다(stale pin). "세션이 끊길까봐 LOGICAL"이라는 반론은 실측으로 근거를 잃었다 — in-flight는 drain이 보호하고, 죽은 VIP는 기본 retry가 구제한다(`retries.attempts: 0` 금지 조건). 2. **단, GSLB가 짧은 주기로 flap하는 환경이라면** STRICT의 연결 churn 비용(pool 재생성·TLS handshake)을 먼저 확인하라. DNS TTL·refresh 주기가 완충 장치다. 3. **L4 passthrough 경로면 두 설정의 차이가 크게 줄어든다** — 어느 쪽도 기존 연결에 불간섭이고, 신규 연결만 새 VIP를 탄다. 그래도 STRICT를 권한다(죽은 VIP 목록 정리·multi-A·관측성). 이때 실제 조향 속도는 Istio가 아니라 **앱의 연결 TTL·서버 keepalive**가 결정하므로, outlier detection과 클라이언트 재시도를 반드시 함께 설계해야 한다. 4. **LOGICAL이 맞는 자리**: 목적지가 조향 대상이 아니라 "매 질의마다 다른 IP 부분집합을 주는 대형 SaaS/CDN"이고, 연결 안정성이 전환 속도보다 중요하며, 개별 IP 식별이 무의미할 때 — 공식 문서가 말하는 원래 용도가 정확히 이것이다. --- ## 한 장 요약 | | L7 (origination) | L4 (passthrough) | |---|---|---| | in-flight 요청 | **성공** (완료까지 유예 후 폐쇄) | **성공** (아예 불간섭) | | 다음 요청의 GSLB 추종 | STRICT: 즉시 / LOGICAL: stale pin | Istio 설정 무관 — **앱 연결 관리가 결정** | | 죽은 IP 구제 | 기본 retry가 자동 구제 | 없음 — 클라 재시도 + outlier 필수 | - origination 경로라면: **STRICT + 기본 retry 유지**가 안전한 기본값 (`retries.attempts: 0` 금지). - passthrough 경로라면: 클라이언트 재시도·outlier·연결 TTL을 **앱/설정 레벨에서** 챙겨야 한다. - 관측 주의: drain 중인 연결은 endpoint 덤프에 안 보인다(cluster 합계 `cx_active`로만 보임). --- ## 실측 부록 — 테스트 절차와 원본 수치 > 위 Q1~Q7과 표는 아래 원시 리포트의 수치를 그대로 인용해 재구성한 것이다. Q4가 실험장을 > "개념"(3개 장치, 무엇을 왜 만들었나)으로 설명했다면, 이 부록은 "재현 가능한 상세"다 — 실제 > 스크립트 이름, annotation, 판정 절차, 그리고 표 원문 그 자체를 남긴다. 원시 리포트: > `docs/test-reports/2026-07-02_112113_dns-inflight-part2.md`, > `docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md`, > `docs/test-reports/2026-07-09_144251_dns-passthrough-logical-inflight.md`, > `docs/test-reports/2026-07-01_dns-resolution.md`(mode1 원본, §4). ### 부록 A. 공통 절차 — 재현 가능한 상세 **실험장 구성** - **사설 DNS**: `lab-dns`(CoreDNS + `writer` busybox 사이드카)가 `gslb.lab.internal`/`gslb-pt.lab.internal`을 authoritative(`ttl 5s`, `reload 2s`)로 응답한다. `writer`가 `/hosts/addn`을 재작성하는 것이 곧 "GSLB가 IP를 바꾸는" 순간을 임의 시점에 만드는 버튼이다. - **40초 `/slow`**: backend-a/b(nginx)가 409610바이트 payload를 `limit_rate 10k`로 스트리밍 — 약 40초짜리 단일 요청. payload 첫 줄에 `backend-a`/`backend-b` 식별 문자열을 심어, access log가 무의미한 L4 passthrough 경로에서도 body만으로 "어느 서버가 응답했는가"를 확정할 수 있게 했다. - **4점 스냅샷**: BEFORE(시작 직전) → MID1(+12s) → MID2(+30s) → AFTER(curl 종료+3s). flip은 항상 +5s에 걸어, 스트림이 절반도 안 지난 시점에 host를 갈아치운 뒤 "그 요청이 살아남는가"를 본다. **타이밍 고정 — 단일 스크립트**: 모든 4점 스냅샷은 `curl & → sleep(절대시각) → flip → wait`을 한 스크립트 안에서 백그라운드로 처리했다. 사람이 손으로 시각을 맞추면 flip과 스냅샷 사이에 초 단위 오차가 생겨 "MID1에 flip이 반영됐는지"를 오판할 위험이 있다. 스크립트 계보: - `run4snap.sh`(스크래치패드, part2 §4) — 4점 스냅샷 패턴의 최초 구현. - `scripts/dns-inflight-4snap.sh`(repo로 승격 — D report §5.1에서 "동일 방식 재사용") — L7 STRICT/LOGICAL in-flight 공용. - `scripts/dns-passthrough-inflight.sh`(D-2/F-0 공용) · `scripts/dns-passthrough-churn.sh`(D-1) · `scripts/dns-passthrough-outlier.sh`(D-3) — L4 전용 3종. - 기존 `scripts/dns-flip-test.sh` — SE 전환(strict↔logical) 자체는 이 스크립트로 통일해, "flip이 어떻게 실행됐는가"가 런마다 달라지는 변수를 없앴다. **델타 판정 원칙**: Envoy admin 카운터는 프로세스 기동 이래 누적값이라 절대값 비교는 의미가 없다 — **같은 런 안의 BEFORE→AFTER(또는 3a→3b) 델타만 판정 근거로 쓴다.** 런과 런 사이에 생기는 변화(예: 부록 C의 run1 종료~run2 시작 사이 8분 유휴 동안 원격 nginx keepalive timeout이 만든 `destroy` 증가)는 그 런의 델타가 아니라 "런 간 노이즈"로 별도 표기했다 — 이걸 런 내부 델타로 착각하면 "LOGICAL도 결국 알아서 정리되네"라고 오판하게 된다. **proxyStatsMatcher annotation**: Istio 기본 stats matcher는 cluster 단위 `upstream_cx_*`/ `membership_change` 카운터를 노이즈로 보고 억제한다. client pod에 아래 annotation이 없으면 이 부록의 표 자체가 안 나온다: ```yaml proxy.istio.io/config: | proxyStatsMatcher: inclusionRegexps: [".*gslb.*"] ``` **ClusterIP 함정 검증 절차** (부록 D의 L4 실험에서 실제로 걸렸던 함정): SE의 host가 backend Service의 **ClusterIP**로 resolve되면, Istio sidecar가 미리 만들어 둔 그 IP:port 전용 리스너가 `SO_ORIGINAL_DST`로 목적지를 가로채 0.0.0.0:443의 SNI 캐치올보다 먼저 매치한다 — curl은 200을 반환하므로 겉보기엔 정상이다. 검증 순서: 1. curl을 몇 회 반복한 뒤 대상 cluster(`outbound|443||`)의 `upstream_cx_total`이 실제로 늘었는지 확인. 2. 안 늘었으면 `istioctl proxy-config listeners`에서 그 IP:port 전용 filter chain(`tcp_proxy`의 cluster가 우리가 만든 SE cluster가 아니라 backend Service cluster로 잡히는지)을 확인. 3. 걸렸으면 DNS 레코드를 backend Service ClusterIP가 아니라 **pod IP**(`status.podIP`)로 바꿔 전용 리스너 자체가 없는 경로로 우회한다. 이 절차를 통과한 뒤에 나온 값만 부록 D의 표에 실었다. ### 부록 B. L7 STRICT 4점 스냅샷 (2026-07-02_112113_dns-inflight-part2.md §1) curl: `CURL_EXIT=0 SIZE=409610 TIME=40.045811 CODE=200`, body=`backend-a`(무절단 완주). | counter | BEFORE (t=0) | MID1 (t=+12s) | MID2 (t=+30s) | AFTER (t=+43s) | |---|---|---|---|---| | membership_change | 17 | **18 (+1)** | 18 | 18 | | upstream_cx_active | 0 | **1** | **1** | **0** | | upstream_cx_total | 51 | 52 (+1) | 52 | 52 | | upstream_rq_total | 415 | 416 (+1) | 416 | 416 | | upstream_cx_destroy | 51 | 51 | 51 | **52 (+1)** | | upstream_cx_destroy_local | 7 | 7 | 7 | **8 (+1)** | | upstream_cx_destroy_with_active_rq | 1 | 1 | 1 | 1 (Δ0) | | endpoint 덤프(cluster) | 10.250.147.30(a) | **10.250.27.55(b)** cx_active=0 | 동일 | 동일 | **읽는 법**: MID1에 flip은 이미 반영됐다(dig=b, endpoint 덤프 a→b, `membership_change` +1) — 그런데 destroy 계열은 전부 정지돼 있다. cluster 합계 `cx_active=1`(우리 요청)인데 per-endpoint 덤프의 backend-b는 `cx_active=0`이다 — **활성 연결이 "이미 제거된 host(a)"에 물려 있어 endpoint 목록에서는 안 보이지만 cluster 합계에는 잡힌다.** MID2(+30s)까지 18초간 이 상태가 그대로 유지되다가, AFTER(요청 자연 완료+3s)에야 `destroy`/`destroy_local`이 +1 틱하며 `cx_active`가 0으로 떨어진다. `destroy_with_active_rq`는 전 구간 Δ0 — 닫히는 순간 그 연결 위에 활성 요청이 없었다(이미 완료됐으므로). → 원본: `docs/test-reports/2026-07-02_112113_dns-inflight-part2.md` §1 ### 부록 C. L7 LOGICAL 2런 (2026-07-02_112113_dns-inflight-part2.md §2) SE 전환 확인: `istioctl proxy-config cluster ... --fqdn gslb.lab.internal --port 443` → `"type": "LOGICAL_DNS"`. flip은 단일 IP→단일 IP만(LOGICAL + multi-IP = CDS NACK 함정 회피). **Run 1** (curl: `SIZE=409610 TIME=40.045900 CODE=200` body=`backend-a`) | counter | BEFORE | MID1 (+12s) | MID2 (+30s) | AFTER (+43s) | |---|---|---|---|---| | membership_change | 20 | 20 (Δ0) | 20 | 20 | | upstream_cx_active | 0 | 1 | 1 | **1 (계속 유지)** | | upstream_cx_total | 52 | 53 (+1) | 53 | 53 | | upstream_cx_destroy | 52 | 52 | 52 | **52 (Δ0)** | | upstream_cx_destroy_local | 8 | 8 | 8 | 8 (Δ0) | | endpoint 덤프 | 10.250.147.30(a) | **10.250.27.55(b)** cx_active=1 rq_active=1 | 동일 | cx_active=1 rq_active=0 | **Run 2** (curl: `SIZE=409610 TIME=40.045509 CODE=200` body=`backend-a`, 2/2 런 델타 패턴 동일 — 결정적) | counter | BEFORE | MID1 (+12s) | MID2 (+30s) | AFTER (+44s) | |---|---|---|---|---| | membership_change | 20 | 20 (Δ0) | 20 | 20 | | upstream_cx_active | 0 | 1 | 1 | **1 (유지)** | | upstream_cx_total | 53 | 54 (+1) | 54 | 54 | | upstream_cx_destroy | 53 | 53 | 53 | **53 (Δ0)** | | endpoint 덤프 | 10.250.147.30(a) | 10.250.27.55(b) cx_active=1 rq_active=1 | 동일 | cx_active=1 rq_active=0 | **런 간 노이즈(런 내부 델타 아님)**: run1 AFTER→run2 BEFORE 사이 `destroy 52→53`, `destroy_remote 44→45` — 약 8분 유휴 동안 run1이 pool에 남긴 backend-a 연결을 **원격(nginx keepalive timeout)** 이 닫은 것. 이게 Q7의 "cycled"에 해당하는 실측 사례다. **STRICT vs LOGICAL 요약(L7 한정)** | 관측 | STRICT_DNS | LOGICAL_DNS | |---|---|---| | curl 결과 | 완주(200, backend-a) | 완주(200, backend-a) — 클라이언트 체감 동일 | | membership_change | **+1**(flip 반영 시) | **0**(논리 host 1개 유지) | | upstream_cx_destroy(_local) | **+1, 요청 완료 직후에만** | **0 — 아예 발생 안 함** | | 완료 후 연결 | **즉시 폐쇄**(cx_active 1→0) | **pool에 유지**(cx_active=1 잔존) | | 다음 요청의 행선지 | backend-b(새 연결) | 재사용되면 backend-a 계속 | → 원본: `docs/test-reports/2026-07-02_112113_dns-inflight-part2.md` §2 ### 부록 D. L4 passthrough STRICT/LOGICAL **STRICT × L4** (D-2, `2026-07-09_134250_dns-passthrough-tcp.md` §5) — curl: `SIZE=409610 TIME=40.050531 HTTPCODE=200`, 첫 줄=`backend-a` | 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)** | **LOGICAL × L4** (F-0, `2026-07-09_144251_dns-passthrough-logical-inflight.md` §3) — Run1 curl: `SIZE=409610 TIME=40.055717 HTTPCODE=200`, Run2: `TIME=40.047827 HTTPCODE=200` — 2/2 런 모두 수신 파일 식별 문자열이 flip 이전 접속처(backend-a)로 고정. | 시점(Run1) | dig | endpoint(admin) | membership_change | upstream_cx_active | upstream_cx_destroy | |---|---|---|---|---|---| | BEFORE | 10.255.194.124(A) | A, HEALTHY | 11 | 0 | 2973 | | MID1+12s | 10.255.126.38(B) | B, HEALTHY | 11 | 1 | 2973 | | MID2+30s | B | B, HEALTHY | 11 | 1 | 2973 | | AFTER+43s | B | B, HEALTHY | 11 | 0 | **2974 (+1)** | **membership 기록 여부의 차이**: STRICT×L4는 `membership_change`가 **+1**을 기록한다(단 지연 반영, +12s엔 아직·+30s에야 4→5). LOGICAL×L4는 BEFORE→AFTER 전 구간 `11→11` **완전 고정** — flip이 반영돼도 "논리적으로는 여전히 host 1개"라 membership 갱신이라는 개념 자체가 없다. 반면 `upstream_cx_destroy`는 두 경로 모두 flip 창(BEFORE→MID2) 동안 미동이다가 curl 완료 직후에만 +1(자연 종료) — 이 부분은 STRICT/LOGICAL 무관하게 동일하다(raw TCP라 어느 쪽도 애초에 끊을 방법이 없어서). → 원본: `docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md` §5(D-2), `docs/test-reports/2026-07-09_144251_dns-passthrough-logical-inflight.md` §3~4(F-0) ### 부록 E. dead-IP 대조와 outlier **D-1 — dead-IP churn 대조** (`2026-07-09_134250_dns-passthrough-tcp.md` §3): 같은 40초 창, 같은 dead-IP 조건(backend-a scale 0)에서 passthrough와 HTTP를 백그라운드로 동시 실행. | 경로 | 시도 | 성공 | 실패 | 실패 유형 | rq_retry | |---|---:|---:|---:|---|---| | passthrough(TCP) | 41 | 23(56.1%) | 18(43.9%) | exit=28(connect timeout) 13건, exit=35(TLS 연결 실패) 5건 | route 자체가 없어 적용 불가 | | HTTP(gslb.lab.internal) | 86 | **86(100%)** | 0 | 없음 | 46/46 성공(`upstream_rq_retry_success`=46) | HTTP 클러스터 BEFORE→AFTER: `upstream_cx_connect_fail` 0→46(+46), `upstream_rq_retry` 0→46(+46), `upstream_rq_retry_success` 0→46(+46), `upstream_rq_total` 2→88(+86, client 요청수와 정확히 일치). **D-3 — outlier detection 적용 전/후** (같은 문서 §6): 같은 dead-IP 조건. | stage | 총시도 | 성공 | 실패 | 실패율 | ejections_enforced_total | |---|---:|---:|---:|---:|---:| | A(outlier 없음) | 29 | 14 | 15 | **51.7%** | N/A | | B(+outlier, DR49) | 2905 | 2899 | 6 | **0.21%** | 2 | Stage B는 정확히 0에 닿지 않았다 — ejection 발효 전(`consecutiveLocalOriginFailures:3` 채우는 동안) 초반 n=1~5 구간 실패 + 40초 창 끝 무렵(n≈2900) 재검증-재격리로 추정되는 2차 실패. **mode1 — L7 STRICT idle 연결의 즉시 절단** (`2026-07-01_dns-resolution.md` §4, Q1의 "idle connection" 근거): keepalive 롱세션 워밍 중 flip. 3a→3b 델타: `membership_change` 2→3(+1), `upstream_cx_destroy` 0→**2**, `upstream_cx_destroy_local` 0→**2**(Envoy 능동 drain), `upstream_cx_total` 2→4(+2, backend-b 재연결). who= 응답은 **flip +5초**에 backend-a→backend-b로 전환. fortio downstream은 Sockets 2, 100% 200(요청 자체가 1ms라 무손실이었을 뿐 — 대조로 LOGICAL은 같은 flip에 `membership_change`/`upstream_cx_destroy` 델타 전부 0, who=는 40초 내내 backend-a). → 원본: `docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md` §3(D-1)·§6(D-3), `docs/test-reports/2026-07-01_dns-resolution.md` §4(mode1) --- ## 참조 - [SE resolution × TLS passthrough — in-flight 요청의 운명](/docs/istio/egress/se-resolution-passthrough-inflight/) — 이 Q&A의 정밀판(2×2 매트릭스, 카운터 증거, 다이어그램) - [DNS/GSLB resolution 재현 랩](/docs/istio/egress/dns-gslb-repro-lab/) — 재현 절차·킷, §8 후속 실측 - [ServiceEntry `resolution: DNS` 동작 runbook](/docs/istio/egress/report-dns-resolution/) — 이론 정본 - 원시 리포트: repo `docs/test-reports/2026-07-02_112113_dns-inflight-part2.md`, `2026-07-09_134250_dns-passthrough-tcp.md`, `2026-07-09_144251_dns-passthrough-logical-inflight.md` — 원본 파일은 [files.homelab89.com/istio-egress/dns-resolution/](https://files.homelab89.com/istio-egress/dns-resolution/) - 공식 문서: [Envoy — Service discovery (Strict/Logical DNS)](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery), [cluster.proto — CommonLbConfig](https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/cluster/v3/cluster.proto)