homelab89 Docs Logs Legacy Files ☰ TOC 🌓
noteistio 2026-07-09istioegressdnsgslbservice-entrytls-passthroughqa

Q&A — GSLB가 IP를 바꾸면 내 요청은 어떻게 되나 (in-flight의 운명, 쉬운 정리)

ABSTRACT

“STRICT_DNS는 A레코드가 바뀌면 기존 연결을 끊는다"는 통념에서 출발해, 실측 결과와 부딪히며 나온 실제 Q&A를 그대로 정리한 문서다. 결론 요약 — ① 진행 중(in-flight)이던 요청은 어떤 조합에서도 성공한다. ② “연결을 끊는다"도 맞는 말인데, 시점이 “요청 한가운데"가 아니라 “요청 경계"다. ③ 설정 차이가 드러나는 곳은 지금 요청이 아니라 “다음 요청"이다. 정밀한 매트릭스·카운터 증거는 SE resolution × TLS passthrough — in-flight 요청의 운명에, 재현 절차는 DNS/GSLB 재현 랩에 있다. 이 문서는 그 결과를 “질문 순서대로” 이해하기 위한 입구다.

전제 환경: 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 아키텍처 문서 원문과 번역:

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 필드로 존재한다:

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이 없으면 이 부록의 표 자체가 안 나온다:

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||<host>)의 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)


참조

Files