Q&A — GSLB가 IP를 바꾸면 내 요청은 어떻게 되나 (in-flight의 운명, 쉬운 정리)
“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이었다.
“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
+-----------------------+
- 사설 DNS를 직접 flip — GSLB 시뮬레이션.
- 40초 스트리밍 응답(
/slow) — “요청이 진행 중인 동안” flip을 걸 시간창. - 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 totrue, 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를 주는 대형 웹서비스 |
의견 (이번 실측 전체에 근거):
- GSLB 앞 + L7(origination) 경로면 STRICT가 기본값이다. GSLB를 두는 이유 자체가 조향
의도인데 LOGICAL은 그 의도를 구조적으로 무시한다(stale pin). “세션이 끊길까봐 LOGICAL"이라는
반론은 실측으로 근거를 잃었다 — in-flight는 drain이 보호하고, 죽은 VIP는 기본 retry가
구제한다(
retries.attempts: 0금지 조건). - 단, GSLB가 짧은 주기로 flap하는 환경이라면 STRICT의 연결 churn 비용(pool 재생성·TLS handshake)을 먼저 확인하라. DNS TTL·refresh 주기가 완충 장치다.
- L4 passthrough 경로면 두 설정의 차이가 크게 줄어든다 — 어느 쪽도 기존 연결에 불간섭이고, 신규 연결만 새 VIP를 탄다. 그래도 STRICT를 권한다(죽은 VIP 목록 정리·multi-A·관측성). 이때 실제 조향 속도는 Istio가 아니라 앱의 연결 TTL·서버 keepalive가 결정하므로, outlier detection과 클라이언트 재시도를 반드시 함께 설계해야 한다.
- 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 +writerbusybox 사이드카)가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을
반환하므로 겉보기엔 정상이다. 검증 순서:
- curl을 몇 회 반복한 뒤 대상 cluster(
outbound|443||<host>)의upstream_cx_total이 실제로 늘었는지 확인. - 안 늘었으면
istioctl proxy-config listeners에서 그 IP:port 전용 filter chain(tcp_proxy의 cluster가 우리가 만든 SE cluster가 아니라 backend Service cluster로 잡히는지)을 확인. - 걸렸으면 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 요청의 운명 — 이 Q&A의 정밀판(2×2 매트릭스, 카운터 증거, 다이어그램)
- DNS/GSLB resolution 재현 랩 — 재현 절차·킷, §8 후속 실측
- ServiceEntry
resolution: DNS동작 runbook — 이론 정본 - 원시 리포트: 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/ - 공식 문서: Envoy — Service discovery (Strict/Logical DNS), cluster.proto — CommonLbConfig