homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-07-01istioegressdnsgslbresolution

DNS/GSLB resolution 재현 랩 — 세션 단절 재현

ABSTRACT

ServiceEntry resolution 정본DNS(STRICT_DNS)와 DNS_ROUND_ROBIN(LOGICAL_DNS)의 차이를 이론으로 정리했다. 이 문서는 그 이론을 자기완결형 랩으로 라이브 재현한다 — 공유 인프라(egress gateway·cluster CoreDNS)를 한 줄도 안 건드리고, 사설 GSLB 시뮬레이터 하나로 “IP가 매번 바뀌는 도메인"을 통제해 ① 기존 세션이 끊기는 순간 ② 죽은 IP로 트래픽이 새는 순간 ③ LOGICAL_DNS가 그 대가로 stale IP를 뒤늦게 붙잡고 있는 순간을 각각 만든다. 결론 한 문장: resolution 필드는 “GSLB의 변덕을 Envoy가 펼쳐서 매번 반영하느냐(STRICT), 접어서 기존 연결은 안 건드리느냐(LOGICAL)“의 선택이고, 그 선택이 곧 세션 생존 여부다. 랩 전체가 이 번들의 킷(files/lab/)으로 패키징돼 있어 어느 클러스터에서든 그대로 재현할 수 있다 — 절차와 기대 출력은 본문의 “따라하기 실습” 절.

대상 환경: homelab (kubespray bare-metal, k8s v1.30.6, CNI Calico), Istio 1.30.0 대상 독자: STRICT_DNS/LOGICAL_DNS 차이를 이론으로는 아는데, “진짜 세션이 끊기는 장면"을 직접 보고 싶은 사람 선행 개념: ServiceEntry resolution 정본(STRICT_DNS/LOGICAL_DNS, DNS refresh ≠ health check), TLS origination vs passthrough, outlier detection 다루는 것: ① 왜 자체 GSLB 시뮬레이터인가 ② 멘탈모델·부품표 ③ 실제 구성(전체 YAML + 이유) ④ 따라하기 실습(번들 킷으로 직접 재현) ⑤ 3가지 재현 모드 ⑥ 함정 ⑦ A·B 후속 실측(in-flight 절단 여부·dead-IP 지연세) ⑧ D 후속 실측(TLS passthrough 대조 — L7 보호가 어디까지 유효한가) ⑨ 남은 작업 검증: 이 문서의 거동 주장은 자기 번들 실측 T92(2026-07-05 — 아래 실습과 동일한 킷 files/lab/만으로 cleanup→setup→3모드→cleanup 전체 생애주기를 라이브 재현, 문서 끝 “검증 기록” C13~C16) + 자기 번들 실측 T96(2026-07-09 — flip 순간 in-flight 요청의 운명: graceful drain 확정, C17~C19)로 확인했다. 이론 대응·경계 주장은 교차 참조 실측 T49·T03에 기댄다(C1~C12) — 자기 번들 실측과 교차 참조는 표에서 구분해 표기했다. §8-1·§8-2의 in-flight/churn 후속 실측(2026-07-02)과 §8-3의 TLS passthrough 대조(2026-07-09)는 T92보다 앞서(또는 별도로) 돌린 라이브 측정이라 검증 기록 표(C1~C16)에는 포함되지 않았다 — 원자료는 각 절 하단의 repo docs/test-reports/2026-07-02_*, docs/test-reports/2026-07-09_* 참조. 단 §8-1의 핵심 결론(in-flight 완주 = graceful drain)은 T96이 번들 킷만으로 독립 재현해 C17~C19로 표에 편입했다.


1. 배경 — 이론은 있는데 왜 “재현"이 따로 필요한가

기존 runbook은 resolution 필드가 Envoy cluster type을 가른다는 것, DNS refresh가 liveness가 아니라는 것, GSLB 뒤엔 LOGICAL_DNS가 낫다는 것까지 전부 정리했다. 그런데 이 문서들은 전부 정적 조사다 — “endpoint가 몇 개 잡히는가"는 dig+proxy-config로 스냅샷을 찍으면 끝나지만, “GSLB가 IP를 바꾸는 그 순간 이미 맺혀 있던 연결이 끊기는가/유지되는가"는 시간축이 있는 이벤트라 스냅샷으로는 못 본다. 이걸 보려면:

  1. GSLB를 내가 통제해야 한다 — 실제 GSLB는 언제 IP를 바꿀지 내가 못 정한다.
  2. 롱커넥션이 있어야 한다 — 매 요청 새 커넥션이면 애초에 “끊길 기존 세션"이 없어 STRICT든 LOGICAL이든 차이가 안 보인다.
  3. 공유 인프라를 안 건드려야 한다 — egress gateway나 cluster CoreDNS를 실험용으로 고치면 다른 시나리오 (10/20/30-*)에 영향이 번진다.

이 세 요구를 동시에 만족하려고 scenarios/50-dns-resolution/사설 GSLB 시뮬레이터 + keepalive 부하생성기로 된 독립 ns(dns-lab)를 만들었다. 아래는 그 구성을 어떻게, 왜 그렇게 만들었는지다.


2. 멘탈모델 한 문장

GSLB는 “같은 도메인, 매번 다른 IP"를 주는 존재일 뿐이다. resolution 필드는 그 변화를 Envoy가 “펼쳐서 매번 반영”(STRICT_DNS)할지 “접어서 기존 연결은 안 건드리고 신규만 반영”(LOGICAL_DNS)할지를 정하고, 그 선택이 곧 “GSLB가 IP를 바꾸는 순간 내 세션이 죽느냐 사느냐"다.

flowchart TD
    R["ServiceEntry<br/>resolution"]
    S["Envoy STRICT_DNS<br/>host set = A record 전체 (펼침)"]
    L["Envoy LOGICAL_DNS<br/>논리 host 1개 (접음)"]
    M1["Mode 1<br/>기존 세션 끊김"]
    M2["Mode 2<br/>죽은 IP로 유실"]
    M1c["Mode 1 대조<br/>기존 세션 유지"]
    M3["Mode 3<br/>stale IP를 뒤늦게 붙잡음"]
    R -->|"DNS"| S
    R -->|"DNS_ROUND_ROBIN"| L
    S -->|"refresh 시 사라진 IP의 host 제거<br/>→ 물려있던 커넥션 drain"| M1
    S -->|"refresh 전 죽은 IP가 아직 목록에 잔존<br/>→ LB가 고르면 connect 실패"| M2
    L -->|"기존 커넥션은 절대 안 건드림<br/>신규 커넥션만 최신 DNS"| M1c
    L -->|"물고 있는 IP가 나중에 죽어도<br/>DNS로는 못 알아챔"| M3

다이어그램 끝의 세 모드(Mode 1/2/3)가 이 랩이 실제로 만드는 세 장면이다. 이 한 문장 + 세 장면만 머리에 있으면 나머지 구성은 전부 “이 장면을 어떻게 세팅하나"의 디테일이다.


3. 부품표 — 각 조각 = 그게 없으면 안 되는 이유

답해야 할 질문 부품 왜 이게 필요한가
“GSLB를 어떻게 내 손으로 흉내내나?” lab-dns(CoreDNS + writer 사이드카) gslb.lab.internal 1개 이름을 authoritative(ttl 5s)로 응답. writer가 A record를 실시간 재작성 = “GSLB가 IP를 바꾸는 순간"을 내가 만든 버튼
“그 도메인을 누가 질의하나?” client dnsConfig sidecar Envoy(c-ares)가 cluster DNS 대신 lab-dns를 보게 강제 — egress gateway·cluster CoreDNS 무변경으로 GSLB를 격리
“펼치나 접나?” ServiceEntry.resolution DNS=STRICT_DNS(펼침) / DNS_ROUND_ROBIN=LOGICAL_DNS(접음) — 이 랩이 대조하는 유일한 독립변수
“drain이 눈에 보이는 층(L7)에서 일어나게 하려면?” VS(80→443) + DR(TLS Origination) client가 평문 HTTP로 부르고 sidecar가 TLS로 승격해야 Envoy가 upstream을 HTTP conn pool(L7)로 관리 → host 제거 시 GOAWAY/close가 관측 가능해짐. https 직접 호출이면 SNI passthrough(L4)라 drain이 안 보임
“죽은 IP를 어떻게 밀어내나?” DR outlierDetection + VS retries Mode 2(유실)를 0으로 수렴시키는 복구 데모 — TLS Origination(L7)이라 http.retries가 실제로 동작(기존 egress TLS Passthrough 랩의 함정과 반대)

4. 공간 지도 — 무엇이 어디에 앉나

flowchart TB
    subgraph NS["ns: dns-lab (istio-injection=enabled)"]
        subgraph CP["client pod"]
            APP["fortio / netshoot<br/>dnsPolicy: None<br/>nameservers = [lab-dns ClusterIP]"]
            SC["istio-proxy (Envoy, c-ares)<br/>SE.resolution → STRICT_DNS 또는 LOGICAL_DNS<br/>VS 80→443 + DR TLS Origination(SIMPLE)"]
        end
        subgraph GP["lab-dns pod (sidecar 미주입 = 인프라)"]
            DNS["CoreDNS<br/>gslb.lab.internal 만 authoritative<br/>(ttl 5s · reload 2s)"]
            W["writer (busybox)<br/>/hosts/addn 재작성<br/>= GSLB flip 버튼"]
        end
        A["backend-a<br/>svc ClusterIP-1<br/>응답 body: backend-a"]
        B["backend-b<br/>svc ClusterIP-2<br/>응답 body: backend-b"]
    end
    K["cluster CoreDNS<br/>(무변경)"]

    APP -->|"http://gslb.lab.internal<br/>(평문 HTTP 80, keepalive)"| SC
    W --> DNS
    SC -.->|"A? gslb.lab.internal"| DNS
    DNS -.->|"그 외 이름(istiod 등)은 forward"| K
    SC ==>|"TLS"| A
    SC ==>|"TLS"| B

화살표 구분: 점선 = DNS 질의 경로(Envoy c-ares → lab-dns, 나머지 이름은 cluster CoreDNS로 forward), 굵은 실선 = 데이터 트래픽(TLS Origination 후 — GSLB의 A record가 가리키는 쪽으로만 실제로 흐름), writer → CoreDNS가 flip 스위치다.

egress gateway 미경유 · cluster CoreDNS 무변경 → blast radius = dns-lab 내부.

공간 배치가 말해주는 것: GSLB 시뮬레이터(lab-dns)와 그 flip 스위치(writer)는 인프라 쪽에, 두 backend는 “GSLB가 고르는 IP 후보"로서 대칭 배치, client만 실험 변수(resolution)에 반응한다. 이 구조라야 변경 지점이 client의 dnsConfig 하나로 좁혀져 공유 인프라에 손대지 않고 실험이 끝난다.


5. 구성 따라가기 — 실제로 이렇게 만들었다

의존 순서대로 namespace → lab-dns → backends → client → (SE 변형 중 택1) → VS/DR로 채운다.

5.1 lab-dns — GSLB 시뮬레이터 (여기가 이 랩의 심장)

apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns-corefile
  namespace: dns-lab
data:
  Corefile: |
    .:53 {
        errors
        log
        # gslb.lab.internal 만 hosts 파일로 응답(ttl 5s), 매칭 없으면 fallthrough → forward
        hosts /hosts/addn gslb.lab.internal {
            ttl 5
            reload 2s
            fallthrough
        }
        forward . __CLUSTER_DNS__
        cache 5
    }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: lab-dns, namespace: dns-lab, labels: { app: lab-dns } }
spec:
  replicas: 1
  selector: { matchLabels: { app: lab-dns } }
  template:
    metadata:
      labels: { app: lab-dns }
      annotations: { sidecar.istio.io/inject: "false" }   # 인프라 컴포넌트 — 메시 불필요
    spec:
      containers:
        - name: coredns
          image: registry.k8s.io/coredns/coredns:v1.11.3
          args: ["-conf", "/etc/coredns/Corefile"]
          ports: [{ containerPort: 53, protocol: UDP }, { containerPort: 53, protocol: TCP }]
          volumeMounts:
            - { name: corefile, mountPath: /etc/coredns }
            - { name: hosts,    mountPath: /hosts }
        # writer — busybox: shared /hosts/addn 를 재작성해 GSLB flip을 수행하는 통로.
        - name: writer
          image: busybox:1.36
          command: ["sh", "-c", "touch /hosts/addn; while true; do sleep 3600; done"]
          volumeMounts: [{ name: hosts, mountPath: /hosts }]
      volumes:
        - { name: corefile, configMap: { name: coredns-corefile } }
        - { name: hosts, emptyDir: {} }

왜 이렇게 (비자명한 결정 3가지):

  • hosts 플러그인에 gslb.lab.internal 매칭하고 fallthrough로 나머지는 forward . __CLUSTER_DNS__. 안 그러면 client sidecar가 xDS 대상인 istiod.istio-system.svc도 lab-dns에 물어보게 되는데 lab-dns는 그 이름을 모른다 → forward가 없으면 sidecar가 컨트롤플레인을 못 찾아 죽는다(자기완결형 랩의 함정).
  • CoreDNS 공식 이미지는 distroless라 shell이 없어 kubectl exec로 파일을 못 고친다 → 같은 pod에 busybox writer를 얹어 그 컨테이너로 /hosts/addn을 재작성한다. GSLB flip = 이 파일 한 줄을 바꾸는 것.
  • reload 2s가 파일 변경을 자동 재적재하므로, flip 후 별도 재시작 없이 최대 2초 내 새 응답이 나간다. ttl 5는 그 응답을 client 쪽 Envoy가 5초 넘게 캐시하지 않게 강제한다.

5.2 client — 관측 지점

apiVersion: apps/v1
kind: Deployment
metadata: { name: fortio, namespace: dns-lab, labels: { app: fortio } }
spec:
  replicas: 1
  selector: { matchLabels: { app: fortio } }
  template:
    metadata: { labels: { app: fortio } }
    spec:
      dnsPolicy: None
      dnsConfig:
        nameservers: ["__LABDNS_IP__"]
        # searches 도메인은 setup.sh 가 클러스터 coredns 에서 자동 발견해 치환(__CLUSTER_DOMAIN__).
        # 이 클러스터는 cluster.local 이 아니라 homelab.local — 하드코딩했다가 sidecar 가 안 떴다(§7 함정).
        searches: [dns-lab.svc.__CLUSTER_DOMAIN__, svc.__CLUSTER_DOMAIN__, __CLUSTER_DOMAIN__]
        options: [{ name: ndots, value: "5" }]
      # (pod metadata.annotations) Istio 기본 stats matcher 가 억제하는 cluster cx 카운터를 노출:
      #   proxy.istio.io/config: |
      #     proxyStatsMatcher: { inclusionRegexps: [".*gslb.*"] }
      containers:
        - { name: fortio, image: fortio/fortio:latest, args: ["server"], ports: [{ containerPort: 8080 }] }

왜 이렇게: dnsPolicy: None + dnsConfig.nameservers가 이 pod의 /etc/resolv.conf를 통째로 lab-dns로 덮는다. app 컨테이너와 istio-proxy는 같은 resolv.conf를 공유하므로, Envoy(c-ares)의 gslb.lab.internal 해석도 자동으로 lab-dns를 탄다 — 이게 “GSLB를 내가 통제한다"의 실제 배관이다. searches를 남겨 둔 이유는 §5.1과 같다(istiod 등 클러스터 이름도 계속 풀려야 하므로) — 단 그 도메인이 관건이다. 이 클러스터의 cluster domain은 기본값 cluster.local이 아니라 **homelab.local**이라, 하드코딩하면 istiod.istio-system.svc가 어떤 search로도 실존 이름이 안 돼 NXDOMAIN → sidecar가 XDS에 못 붙어 Envoy가 안 뜬다(§7에서 상술). 그래서 setup 스크립트가 coredns configmap에서 도메인을 발견해 __CLUSTER_DOMAIN__을 치환한다.

5.3 ServiceEntry 변형 — 이 랩의 유일한 독립변수

# [변형 A] resolution: DNS  ->  STRICT_DNS
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata: { name: gslb-strict, namespace: dns-lab }
spec:
  exportTo: ["."]                                 # dns-lab 내부로만 가시화(gateway 전역 누수 차단)
  hosts: ["gslb.lab.internal"]
  location: MESH_EXTERNAL
  ports:
    - { number: 80,  name: http,  protocol: HTTP }
    - { number: 443, name: https, protocol: HTTP } # origination 후 내부는 HTTP로 다룸
  resolution: DNS
# [변형 B] resolution: DNS_ROUND_ROBIN  ->  LOGICAL_DNS  (host 동일 -> 동시 적용 불가, 하나만 apply)
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata: { name: gslb-logical, namespace: dns-lab }
spec:
  exportTo: ["."]
  hosts: ["gslb.lab.internal"]
  location: MESH_EXTERNAL
  ports:
    - { number: 80,  name: http,  protocol: HTTP }
    - { number: 443, name: https, protocol: HTTP }
  resolution: DNS_ROUND_ROBIN

왜 포트 443을 protocol: HTTP로 선언하나: TLS Origination 뒤의 내부 트래픽은 평문 HTTP이므로, Envoy가 이 cluster를 HTTP conn pool(L7)로 관리하게 하려면 SE 포트도 HTTP로 선언해야 한다. TLS로 두면 passthrough(L4)로 다뤄져 이 랩의 핵심(§6에서 볼 drain 가시성)이 안 보인다.

5.4 VS(80→443) + DR(TLS Origination) — 평문 진입을 TLS로 승격

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata: { name: gslb-80-to-443, namespace: dns-lab }
spec:
  exportTo: ["."]
  hosts: ["gslb.lab.internal"]
  http:
    - match: [{ port: 80 }]
      route: [{ destination: { host: gslb.lab.internal, port: { number: 443 } } }]
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata: { name: gslb-tls-origination, namespace: dns-lab }
spec:
  exportTo: ["."]
  host: gslb.lab.internal
  trafficPolicy:
    portLevelSettings:
      - port: { number: 443 }
        tls: { mode: SIMPLE, sni: gslb.lab.internal, insecureSkipVerify: true }

왜 이렇게: client는 http://gslb.lab.internal(평문, 80)로만 부른다. VS가 80→443으로 넘기고, DR이 그 443 트래픽에 SIMPLE TLS를 얹어 sidecar가 실제로는 TLS로 나가게 만든다 — 이게 “client는 평문으로 호출하지만 실제론 TLS Origination"의 배선이다. client가 처음부터 https://로 직접 불렀다면 SNI passthrough(L4)가 되어 §6의 L7 draining이 관측되지 않는다. insecureSkipVerify: true는 랩 단순화용 (백엔드가 self-signed 인증서만 있으면 됨) — prod는 caCertificates/credentialName으로 CA를 핀할 것.


따라하기 실습 — 번들 킷으로 직접 재현

§5가 “왜 이렇게 생겼나”(구성 해부)라면, 이 절은 “받아서 직접 돌려보기"다. 아래 기대 출력은 전부 2026-07-05에 이 킷만으로 cleanup→setup→3모드→cleanup 전체 생애주기를 라이브 재현한 T92 실측 (result.txt)에서 발췌했다 — 독자 클러스터에서는 IP·cluster domain 값만 다르고 형태는 같아야 한다. 각 모드가 재현하는 장면의 정의·타임라인은 §6, 실측 해석 전문은 §8.

소요 시간: setup 12분(이미지 pull 제외) · **모드당 12분**(fortio 부하 40~60초 포함) · cleanup 약 40초.

실습 0. 사전 조건

  • 도구: kubectl(대상 클러스터 컨텍스트 설정 완료), istioctl(하네스가 endpoint 덤프에 사용), openssl(setup이 self-signed 인증서 생성), bash
  • Istio가 설치돼 있고 sidecar injection이 동작해야 한다. T92 기준 Istio 1.30.0 (client/control plane/data plane 일치):
istioctl version                    # client/CP/DP 버전 일치 확인
kubectl -n istio-system get pods    # istiod Running 확인
  • 어느 클러스터에서든 동작한다setup.sh가 cluster DNS ClusterIP와 cluster domain(예: cluster.local이 아닌 homelab.local)을 coredns configmap에서 자동 발견해 치환한다(§7 하드코딩 함정 회피). 다른 컨텍스트를 쓰려면 CTX=<context>를 앞에 붙인다.
  • 랩은 전용 ns dns-lab만 만들고 지운다 — egress gateway·cluster CoreDNS 등 공유 인프라는 건드리지 않는다.

실습 1. 랩 킷 받기

킷은 이 문서 번들의 개별 파일이다 (주의: 디렉토리 링크는 404 — 아래처럼 파일 단위로만 받는다):

파일 역할
00-namespace.yaml ns dns-lab (istio-injection=enabled)
10-lab-dns.yaml GSLB 시뮬레이터 — CoreDNS + writer (§5.1)
20-backends.yaml backend-a/b — “GSLB가 고르는 IP 2개"의 실체 (nginx TLS)
30-client.yaml fortio + netshoot — dnsConfig로 lab-dns 주입 (§5.2)
40-serviceentry-strict.yaml · 41-serviceentry-logical.yaml SE 변형 A/B — 유일한 독립변수 (§5.3)
42-virtualservice-80to443.yaml · 43-destinationrule-tls.yaml VS 80→443 + DR TLS Origination (§5.4)
44-destinationrule-tls-outlier.yaml · 45-virtualservice-80to443-retry.yaml mode2 복구용 — outlier + retry (확장 실험)
setup.sh · flip-test.sh · cleanup.sh 부트스트랩 · 3모드 하네스 · 철거 (전부 멱등)

한 줄 다운로드:

mkdir -p dns-gslb-lab && cd dns-gslb-lab
base=https://blog.homelab89.com/docs/istio/egress/dns-gslb-repro-lab/files/lab
for f in 00-namespace.yaml 10-lab-dns.yaml 20-backends.yaml 30-client.yaml \
         40-serviceentry-strict.yaml 41-serviceentry-logical.yaml \
         42-virtualservice-80to443.yaml 43-destinationrule-tls.yaml \
         44-destinationrule-tls-outlier.yaml 45-virtualservice-80to443-retry.yaml \
         setup.sh flip-test.sh cleanup.sh; do
  curl -fsSLO "$base/$f"
done
chmod +x setup.sh flip-test.sh cleanup.sh

실습 2. setup.sh — 랩 부트스트랩

bash setup.sh          # 다른 클러스터면: CTX=<context> bash setup.sh

기대 출력 (T92 발췌 — IP·도메인은 클러스터마다 다름):

== [0] preflight (context=homelab) ==
== [1] server cert(self-signed, SAN=gslb.lab.internal) ==
== [2] namespace + secret ==
namespace/dns-lab created
secret/gslb-tls created
== [3] lab-dns (cluster DNS ClusterIP 치환) ==
   cluster DNS = 10.250.0.3
deployment "lab-dns" successfully rolled out
== [4] backends ==
deployment "backend-a" successfully rolled out
deployment "backend-b" successfully rolled out
== [4b] GSLB 초기값 = backend-a ==
   backend-a=10.250.4.141  backend-b=10.250.196.43  (gslb.lab.internal -> 10.250.4.141)
== [5] client (lab-dns ClusterIP + cluster domain 치환) ==
   lab-dns = 10.250.47.57  ·  cluster domain = homelab.local
deployment "fortio" successfully rolled out
deployment "netshoot" successfully rolled out
== ready ==

읽는 법 — 자동 발견·치환 세 가지를 확인한다: ① cluster DNS ClusterIP(lab-dns의 forward 대상) ② cluster domain(T92에선 homelab.localcluster.local이 아닌데도 sidecar가 정상 rollout됐다는 것 자체가 §7 하드코딩 함정을 킷이 회피한다는 증거) ③ GSLB 초기값 = backend-a.

DNS 배관 확인:

kubectl -n dns-lab exec deploy/netshoot -- dig +short gslb.lab.internal
# 10.250.4.141   <- backend-a ClusterIP (GSLB 초기값)
WARNING

이 시점의 curl http://gslb.lab.internal/503이 정상이다 — setup은 SE/VS/DR을 적용하지 않으므로(변형 선택은 flip-test의 몫) 평문 80 요청이 TLS만 listen하는 backend에 그대로 부딪힌다. 고장이 아니다(§7). flip-test.sh가 변형을 적용하는 순간부터 200이 나온다.

실습 3. STRICT 세션 드롭 — flip-test.sh strict mode1

bash flip-test.sh strict mode1     # 전체 리포트: reports/<timestamp>_dns-strict-mode1.md

하네스가 하는 일: SE(gslb-strict)+VS+DR 적용 → GSLB=A 초기화 → fortio keepalive 2커넥션 40초 부하와 who= 루프를 백그라운드로 걸고 → 부하 유지 중 3a 스냅샷 → flip(A→B) → 3b 스냅샷. 기대 출력 핵심 (T92):

ENDPOINT 덤프:  3a 10.250.4.141(backend-a)  ->  3b 10.250.196.43(backend-b)   [교체]

cluster|443 cx 카운터 (3a MID-PRE-FLIP -> 3b MID-POST-FLIP = 델타):
  membership_change                   1  ->  2   = +1
  upstream_cx_destroy                 0  ->  2   = +2
  upstream_cx_destroy_local           0  ->  2   = +2   (Envoy 능동 drain)
  upstream_cx_destroy_with_active_rq  0  ->  0   =  0
  upstream_cx_total                   2  ->  4   = +2   (backend-b 신규 재연결)

who= 타임라인: flip +3초에 who=backend-a -> backend-b 전환
fortio(keepalive): Sockets used: 2 · Code 200 : 320 (100.0 %)

읽는 법:

  • 판정은 절대값이 아니라 3a→3b 델타다 — cx 카운터는 Envoy 기동 이래 누적이라 run을 반복하면 절대값이 이월된다(§7).
  • destroy_local +2 = 사라진 host(backend-a)의 keepalive 커넥션 2개를 Envoy가 능동으로 끊음 — STRICT의 서명. cx_total +2 = backend-b로 재연결.
  • who= 전환 시점은 CoreDNS ttl 5s + reload 2s 창 안에서 흔들린다 — T92 +3초, 2026-07-01 +5초 (정상 지터).
  • who= 루프의 타임스탬프는 컨테이너 UTC, >> FLIP 마커는 host 로컬시간이다(KST면 9시간 차 — §7). 두 시계를 맞춰 읽어야 한다.
  • Code 200 : 320 (100.0 %)인데도 세션은 끊겼다 — 무손실은 요청이 짧아서다(§8 · What you might be missing).

실습 4. LOGICAL 세션 보존 — flip-test.sh logical mode1

bash flip-test.sh logical mode1

같은 flip인데 결과가 정반대다. 기대 출력 핵심 (T92 — 직전 strict run의 잔재로 절대값이 3/4에서 시작한다. 델타만 본다):

ENDPOINT 덤프:  3a 10.250.4.141(backend-a)  ->  3b 10.250.196.43(backend-b)   [라벨만 갱신]

cluster|443 cx 카운터 (3a -> 3b = 델타):
  membership_change              3  ->  3   =  0
  upstream_cx_destroy            4  ->  4   =  0   (기존 세션 보존)
  upstream_cx_total              6  ->  6   =  0   (신규 재연결 없음)

who= 타임라인: 40초 내내 who=backend-a (backend-b로 안 감)
fortio(keepalive): Sockets used: 2 · Code 200 : 320 (100.0 %)

“덤프≠바이트” 함정을 직접 확인하는 지점이 여기다: 3b의 endpoint 덤프는 backend-b(새 resolved IP)로 라벨을 갱신해 놓았는데, 실제 응답 body(who=)는 40초 내내 backend-a다. istioctl proxy-config endpoints만 믿으면 “트래픽이 b로 넘어갔다"고 오판한다(§7) — 진실은 응답 body에만 있다. 리포트의 3b 스냅샷에서 endpoint IP와 who= 타임라인을 나란히 놓고 어긋남을 눈으로 확인할 것.

실습 5. LOGICAL 트레이드오프 — flip-test.sh logical mode3

bash flip-test.sh logical mode3    # 60초 부하: 시작 +10s에 flip, +30s에 backend-a kill

기대 출력 핵심 (T92):

who= 타임라인:
  (flip 전)              who=backend-a
  >> FLIP A->B           이후 20초간 여전히 who=backend-a   <- DNS는 이미 B, 세션은 A에 pin
  >> KILL backend-a
  (같은 초부터)          who=backend-b                      <- kill 순간에야 전환
전 구간 code=200 — code!=200 = 0건 (무손실)

cluster|443 cx 카운터 (run 전 -> 후):
  upstream_cx_destroy_remote          0  ->  2   = +2   (죽은 backend-a 쪽이 원격 종료)
  upstream_cx_destroy_with_active_rq  0  ->  0   =  0   (활성 요청 중 절단 없음)
  upstream_cx_total                   6  ->  7   = +1   (backend-b로 1회 재연결)

여기서 관찰하는 트레이드오프: LOGICAL은 GSLB의 flip 신호를 무시하고(20초간 A에 pin), pinned IP가 실제로 죽어야 — 그것도 DNS가 아니라 커넥션 단절(destroy_remote)로 — 알아채고 재연결한다. 이번엔 요청이 1ms로 짧아 무손실이었지만, 장수명 스트림이면 그 절단이 in-flight를 자른다(§8 Mode 3).

실습 6. cleanup.sh — 철거

bash cleanup.sh

기대 출력 (T92):

== [1] SE/VS/DR 삭제 (dns-lab, ns 삭제 전에 트래픽 라우팅 먼저 회수) ==
serviceentry.networking.istio.io "gslb-logical" deleted from dns-lab namespace
virtualservice.networking.istio.io "gslb-80-to-443" deleted from dns-lab namespace
destinationrule.networking.istio.io "gslb-tls-origination" deleted from dns-lab namespace
== [2] namespace 삭제 ==
namespace "dns-lab" deleted
== [3] 확인 ==
   ns/dns-lab NotFound — 정리 완료.
kubectl get ns dns-lab
# Error from server (NotFound): namespaces "dns-lab" not found

멱등이다 — 이미 없는 상태에서 재실행해도 --ignore-not-found로 에러 없이 통과하고(T92 [6]), 랩이 살아있는 중간에 실행해도 정상 동작한다(T92 [0] — mid-lab 철거로 시작했다).

확장 실험 (독자 과제) — mode2: 죽은 IP 유실과 outlier/retry 복구

flip-test.sh strict mode2도 하네스에 포함돼 있다 — A·B 둘 다 DNS에 등록 → A만 kill(죽었지만 목록에 잔존) → 44 outlier + 45 retry로 복구를 관찰하는 흐름이다. 단 §8 Mode 2 실측처럼 warm connection이 유실을 마스킹한다 — client가 이미 backend-b로 워밍된 커넥션을 재사용해 죽은 A로 새 연결을 안 열기 때문이다. 유실을 실제로 보려면 connection churn(예: keepalive 없는 부하, 커넥션 수 확대)을 강제하는 변형이 필요하다 — 이 킷을 출발점으로 직접 설계해 볼 것.

[2026-07-02 후속 실측으로 해결] 여기서 “직접 설계해 볼 것"이라 남겨둔 connection churn 강제 방법은 실제로 막다른 길을 두 번 거친 뒤에야 찾아졌다 — client keepalive를 꺼도 upstream pool은 그대로 재사용되고, connectionPool 설정은 DR의 portLevelSettings 블록 에 넣지 않으면 조용히 무시된다. 그 과정에서 위 44번 파일 자체가 같은 함정(top-level에 둔 outlierDetection이 무시되는 것)에 빠져 있었다는 것도 드러났다 — 이 킷에 반영된 버전은 교정본이다. 결과와 재해석은 §8-2 참조.


6. 세 가지 재현 모드

모드 variant 흐름 실측 관측 (2026-07-01)
Mode 1 세션 드롭 strict / logical 각각 keepalive 부하 중 GSLB flip(A→B) STRICT: upstream_cx_destroy 0→2(destroy_local=Envoy 능동 drain), membership_change+1, endpoint A→B, who= flip +5초에 backend-b로 전환. LOGICAL: 같은 flip에 cx_destroy 델타 0, endpoint 논리 1개, who=backend-a 40초 내내 유지
Mode 2 유실+복구 strict A,B 둘 다 등록된 채 A만 kill(죽었지만 DNS엔 잔존) 예상과 달리 유실 0 — client가 backend-b로 워밍된 커넥션을 재사용해 죽은 A로 새 연결을 안 엶. STRICT dead-IP 유실은 “새 연결이 죽은 endpoint를 뽑는 순간"에만 발생(warm connection이 마스킹). §7 참조
Mode 3 LOGICAL 트레이드오프 logical flip(A→B) 후 세션 유지, 그 뒤 A를 kill flip엔 who=backend-a 유지(=Mode1 LOGICAL) → A kill 순간 who=backend-b로 전환, code!=200 0건(무손실). 예측한 “재연결 blip"은 안 나옴 — 짧은 요청이라 죽은 커넥션이 요청 사이에 감지됨(destroy_with_active_rq: 0)

Mode 1을 시간축에 펼치면 — 같은 flip 하나에 두 resolution이 갈라지는 지점이 한눈에 보인다 (수치는 T92 실측, 2026-07-05):

sequenceDiagram
    participant W as writer (flip 버튼)
    participant D as lab-dns (CoreDNS)
    participant E as client Envoy
    participant A as backend-a
    participant B as backend-b

    Note over E,A: fortio keepalive 2커넥션 워밍 — who=backend-a
    W->>D: /hosts/addn 재작성 A→B (reload ≤2s)
    E->>D: 주기 DNS refresh (ttl 5s)
    D-->>E: gslb.lab.internal = backend-b IP

    alt STRICT_DNS (resolution=DNS)
        E-xA: 사라진 host의 기존 커넥션 능동 drain<br/>destroy_local +2 · membership_change +1
        E->>B: 신규 재연결 (cx_total +2)
        Note over E,B: who= flip +3초에 backend-b (T92)<br/>320/320 전부 200 — 무손실은 요청이 짧아서
    else LOGICAL_DNS (resolution=DNS_ROUND_ROBIN)
        Note over E,A: 델타 전부 0 — 기존 커넥션 그대로<br/>who=backend-a 40초 유지 (T92)
        Note over E,B: endpoint 덤프만 backend-b로 라벨 갱신<br/>= 덤프≠바이트 함정 (§7)
    end

Mode 3의 시간축 — flip은 무시되고, pinned IP의 죽음만이 재연결을 만든다:

sequenceDiagram
    participant G as lab-dns (GSLB)
    participant E as client Envoy (LOGICAL_DNS)
    participant A as backend-a
    participant B as backend-b

    Note over E,A: fortio keepalive 1커넥션 — who=backend-a
    Note over G: t+10s FLIP A→B
    Note over E,A: 이후 20초간 여전히 who=backend-a<br/>DNS는 이미 B를 가리킴 — pinned IP 유지
    A-xE: t+30s backend-a KILL — 커넥션 원격 종료<br/>(destroy_remote +2)
    E->>B: 사망 감지 즉시 재연결 (cx_total +1)
    Note over E,B: 같은 초에 who=backend-b 전환<br/>code!=200 0건 · destroy_with_active_rq 0 (T92)

관측 지표(모드 공통):

# endpoint 집합 — STRICT는 flip 따라 바뀜, LOGICAL은 논리 1개(단 라벨은 새 IP로 갱신 = §7 stale-pin 함정)
istioctl proxy-config endpoints deploy/fortio.dns-lab --cluster "outbound|443||gslb.lab.internal"
# 커넥션 정리 카운터. 함정: Istio 기본 stats matcher 가 cluster 단위 upstream_cx_* 를 억제하므로
# client pod 에 annotation(proxyStatsMatcher.inclusionRegexps: [".*gslb.*"])이 있어야 이 값이 노출된다.
kubectl -n dns-lab exec deploy/fortio -c istio-proxy -- pilot-agent request GET \
  "stats?filter=gslb" | grep -E 'upstream_cx_destroy|membership_change'
# 억제와 무관하게 항상 나오는 per-endpoint cx — 어느 backend에 실제 연결이 붙어있나:
kubectl -n dns-lab exec deploy/fortio -c istio-proxy -- pilot-agent request GET clusters \
  | grep 'outbound|443||gslb.lab.internal::' | grep -E 'cx_active|cx_total|rq_total'

이 세 모드를 명령 한 줄씩으로 묶은 하네스가 번들 킷의 flip-test.sh <strict|logical> <mode1|mode2|mode3>다. 부트스트랩은 setup.sh — 단계별 절차와 기대 출력은 위 “따라하기 실습” 절. (원본 출처: legacy 📎 dns-lab-setup.sh/dns-flip-test.sh — 참조 절.)


7. 함정 (메커니즘까지)

  • cluster domain 하드코딩 = sidecar 안 뜸 (이번에 실제로 밟은 함정). 이 클러스터의 cluster domain은 기본값 cluster.local이 아니라 **homelab.local**이다. client dnsConfig.searchescluster.local을 하드코딩했더니 istiod.istio-system.svc가 어떤 search 조합으로도 실존 이름이 안 되어 전부 NXDOMAIN → sidecar가 XDS/CA에 못 붙음 → Envoy 미기동 → pod rollout timeout(istio-proxy 15021 startup probe connection refused). 증상만 보면 sidecar 버그 같지만 실체는 DNS 도메인 불일치다. 수정: dns-lab-setup.shkubectl -n kube-system get cm coredns -o jsonpath='{.data.Corefile}' | grep -oE 'kubernetes[[:space:]]+[^ ]+'로 도메인을 발견해 __CLUSTER_DOMAIN__을 치환. 사내 클러스터가 커스텀 도메인이면 per-pod DNS를 덮는 어떤 워크로드든 동일하게 물린다 — “cluster.local 하드코딩 금지, 도메인은 발견해서 주입"이 이식 규칙.

  • LOGICAL_DNS의 관측 함정 — 덤프는 새 IP, 바이트는 옛 IP. flip 후 proxy-config endpoints/clusters 덤프는 endpoint 라벨을 backend-b(새 resolved IP)로 바꿔 보여주지만, cx_total은 안 늘고 실제 응답 body는 여전히 backend-a다(논리 host 1개라 기존 커넥션을 그대로 재사용). 관측 도구만 믿으면 “트래픽이 새 IP로 옮겨갔다"고 오판한다 — 진실은 응답 body(또는 패킷캡처)에만 있다. 실측에서 who=backend-a인데 endpoint 덤프는 backend-b였다.

  • LOGICAL_DNS + multi-IP A record = CDS NACK. LOGICAL_DNS cluster는 lb_endpoint가 1개여야 하는데, A record가 다중 IP가 되는 순간 이 SE를 받는 프록시가 CDS push를 통째로 NACK할 수 있다(같은 push의 listener까지 드롭). 그래서 Mode 2(멀티 IP)는 STRICT 전용, Mode 1/3의 flip은 항상 “단일 IP → 단일 IP"로만 한다. 실제로 겪은 사고: serviceentry-example-logicaldns.yaml에서 example.com이 멀티 IP로 바뀌며 이 SE를 받던 모든 egress gateway가 NACK — exportTo: ["."]로 가시성을 좁혀야 재발을 막는다. (검증 메모: 이 인과관계는 이번 검증에서 homelab 실측으로는 확정하지 못했다 — §검증 기록 C4 참조.)

    정정(2026-07-10, C4 갱신): 위 문단의 표제(“LOGICAL_DNS + multi-IP A record = CDS NACK”)는 오류로 판명됐다. 2026-07-09 실측(repo docs/test-reports/2026-07-09_211314-dns-logical-multia.md)에서 LOGICAL_DNS SE host가 실제로 A레코드 2개(backend-a, backend-b)를 응답하는 상태를 직접 만들어 확인한 결과: NACK 없음 — cds/lds update_rejected 델타 0, istiod 로그에 nack/reject 매치 없음, 통신 정상(curl 15회 전부 200). 단 cluster 멤버에는 dig 응답의 첫 IP 하나만 등록돼 backend-b 응답은 0회였다. 같은 2-레코드 상태를 STRICT로 바꾸면 두 IP 모두 cluster 멤버로 잡혀 실제 분산됐다(curl 12회, backend-a 7 / backend-b 5). “CDS NACK"이 실제로 걸리는 지점은 SE.spec.endpoints에 endpoint를 여러 개 명시적으로 선언할 때뿐이며, 그때도 CDS가 아니라 kubectl apply 단계의 admission webhook이 거부한다(The ServiceEntry ... is invalid: ... DNS_ROUND_ROBIN mode cannot have multiple endpoints) — CDS/xDS까지 가지도 않으므로 NACK이 날 지점 자체가 없다. 즉 “Mode 2(멀티 IP)는 STRICT 전용"이라는 실무 결론 자체는 여전히 맞다(LOGICAL은 첫 IP만 써서 멀티 IP를 활용 못 하므로) — 다만 그 이유는 “NACK으로 통신이 깨진다"가 아니라 “LOGICAL은 나머지 IP를 조용히 무시한다"이다. 2026-06-09 example.com 전역 노출 사고의 실제 원인은 이와 별개이며 vs-scoping의 T03 실측에서 이미 “재현 안 됨(doc-unverifiable)“으로 정리돼 있다.

  • 두 SE 변형은 host가 같아 동시 적용 불가. gslb-strict/gslb-logical 둘 다 hosts: [gslb.lab.internal]이므로 하나를 적용하기 전 다른 하나를 반드시 delete한다(하네스가 자동 처리).

  • client가 https로 직접 호출하면 이 랩 전체가 무의미해진다. SNI passthrough(L4)가 되어 Envoy가 upstream을 HTTP conn pool로 안 보므로, host 제거가 “커넥션 정리"로 이어지긴 해도 L7 신호(GOAWAY 등)가 없어 관측이 흐려진다. 반드시 평문 진입 + TLS Origination 경로를 지킨다(§5.4).

  • keepalive 없는 요청 루프는 “끊길 세션” 자체가 없다. 매 요청 새 커넥션이면 STRICT든 LOGICAL이든 다음 요청이 최신 DNS를 따라갈 뿐이라 차이가 안 보인다 — 반드시 fortio -keepalive처럼 커넥션을 오래 물고 있는 부하가 있어야 한다.

  • cx 카운터는 Envoy 기동 이래 누적값이다 — 판정은 절대값이 아니라 “run 내 델타"로 (T92에서 확인). 같은 fortio pod로 strict→logical run을 연속 실행하면 이전 run의 값이 그대로 이월된다(T92 실측: logical mode1의 시작값이 membership_change 3 / cx_destroy 4였다). flip-test.sh가 3a/3b 스냅샷을 같은 run 안에서 찍는 이유가 이것이다 — 절대값을 기대치와 비교하면 “LOGICAL인데 destroy가 있다"고 오판한다.

  • who= 루프 타임스탬프는 컨테이너 UTC, FLIP/KILL 마커는 host 로컬시간이다 (T92에서 확인). loop는 netshoot 컨테이너 안에서 date를 찍고(UTC), >> FLIP/>> KILL 마커는 스크립트가 host에서 찍는다 (KST면 +9h). T92에서 flip이 host 21:04:12인데 who= 전환이 12:04:15로 보인 이유다 — 시간대를 안 맞추면 “9시간 지연"으로 오판한다.

  • setup 직후의 curl 503은 고장이 아니다 (T92에서 확인). setup.sh는 SE/VS/DR을 적용하지 않는다 (변형 선택은 flip-test.sh의 몫 — 두 SE는 host가 같아 동시 적용이 불가하므로). 그 상태의 http://gslb.lab.internal/은 평문 80으로 나가는데 backend는 443 TLS만 listen하므로 503이 정상이고, flip-test.sh가 변형을 적용하는 순간부터 200이 나온다.


8. 라이브 실측 결과 (2026-07-01 · 2026-07-02 후속 A·B 추가 · 2026-07-09 후속 D 추가)

NOTE

이 절은 2026-07-01 최초 실측의 역사적 기록이다. 2026-07-05에 번들 킷(files/lab/)만으로 T92가 동일 재현을 확인했다 — 유일한 편차는 STRICT who= 전환 +5초→+3초로, CoreDNS ttl 5s + reload 2s 창 내의 정상 지터다. 전문: files/verify/T92/result.txt, 실습 절차는 “따라하기 실습” 절.

scripts/dns-lab-setup.shdns-flip-test.sh로 3모드를 실제로 돌렸다. 먼저 SE.resolution이 Envoy cluster type으로 1:1 매핑됨을 config_dump로 확증했다:

istioctl pc cluster deploy/fortio.dns-lab --fqdn gslb.lab.internal --port 443 -o json | grep '"type"'
# resolution: DNS             -> "type": "STRICT_DNS"
# resolution: DNS_ROUND_ROBIN -> "type": "LOGICAL_DNS"

핵심 대조 (Mode 1, flip A→B 전후를 load 유지 중에 스냅샷 → cx_destroy 델타 격리):

관측 지표 STRICT_DNS LOGICAL_DNS
endpoint(덤프) backend-a → backend-b (교체) backend-a → backend-b (라벨만)
membership_change +1 0
upstream_cx_destroy +2 0
upstream_cx_destroy_local +2 (Envoy 능동 drain) 0
upstream_cx_total +2 (신규 재연결) 0
who=(실제 응답 backend) flip +5초에 backend-b 40초 내내 backend-a
fortio downstream Sockets 2, 100% 200 Sockets 2, 100% 200

읽는 법: downstream(client↔사이드카)은 두 경우 동일하고, 차이는 전부 **upstream(사이드카↔backend)**에 있다. STRICT은 flip에 기존 연결을 destroy_local로 능동 drain하고 backend-b로 재연결(트래픽 이전), LOGICAL은 cx_destroy 델타 0으로 기존 연결을 보존(세션 유지, 옛 IP 고정). 두 경우 fortio는 Sockets 2 / 100% 200으로 동일했는데, 그게 downstream 지표라 아무 차이도 안 보였다는 것 자체가 “정본 지표는 upstream cx_destroy“임을 말한다.

Mode 3 (LOGICAL 트레이드오프): flip엔 who=backend-a 유지 → backend-a kill 순간 who=backend-b로 전환, code!=200 0건. 예측한 “재연결 blip"은 실측에서 안 나왔다 — 요청이 1ms로 짧아 죽은 커넥션이 요청 사이에 감지되고(destroy_with_active_rq: 0) Envoy가 이미 DNS가 가리키는 backend-b로 조용히 재연결. 따라서 LOGICAL의 진짜 비용은 “에러"가 아니라 ① GSLB 의도 무시(먼 backend에 계속 핀) ② 장수명 스트림이면 pinned backend 사망 시 그 in-flight가 절단(destroy_with_active_rq가 오르는 경우).

Mode 2 (STRICT dead-IP): 의도(죽은 IP로 유실)와 달리 유실 0이었다 — client가 backend-b로 워밍된 커넥션을 재사용해 죽은 A로 새 연결을 안 열었기 때문. STRICT의 dead-IP 유실은 새 연결이 죽은 endpoint를 뽑는 순간에만 발생하고 warm connection이 그걸 마스킹한다. 이 mode는 유실을 보려면 connection churn을 강제해야 해서 미완으로 남겼다(outlier detection 영역이라 별도 시나리오가 맞다).

[2026-07-02 후속 실측으로 교정] 위 Mode 2·Mode 3 문단의 “장수명이면 잘릴 것"이라는 추정과 “churn 강제는 미완"이라는 두 남은 과제는 아래 §8-1·§8-2에서 실제로 메워졌다 — 단, 결과는 원래 예상과 달랐다. 지우지 않고 아래에 이어 적는다.

원시 로그·대조표 전문: repo docs/test-reports/2026-07-01_dns-resolution.md(종합) + _193631/_193800/_195325/_195854_*(모드별). 회사망 다운로드: /files/istio-egress/dns-resolution/.

프로덕션 함의 한 줄: STRICT=최신성(GSLB 추종) 우선·세션 희생, LOGICAL=세션 연속성 우선·최신성 희생. GSLB 뒤 장수명 연결(gRPC/DB/스트리밍)은 LOGICAL, GSLB 신호를 빨리 따라가야 하는 짧은 HTTP는 STRICT.

[2026-07-02 보완] “STRICT=세션 희생"은 여전히 맞지만, “진행 중인 요청이 그 자리에서 잘린다"는 뜻이 아니다 — §8-1에서 보듯 in-flight 요청은 STRICT에서도 완료까지 보호된다. 희생되는 건 요청이 아니라 “요청이 끝난 뒤 그 커넥션을 재사용할 수 있느냐"다.

8-1. 후속 A — STRICT flip은 in-flight 요청을 자르는가 (2026-07-02)

이 문서의 “What you might be missing” 절이 원래 담고 있던 주장 — “STRICT의 무손실은 요청이 짧아서지 안전해서가 아니다” — 이 던진 질문을 실제로 30~60초짜리 요청으로 검증했다. 20-backends.yaml/slow location(nginx limit_rate 10k + busybox initContainer가 결정적으로 만드는 400KB payload = 약 40초 스트리밍)을 추가하고, curl 시작 → +5초 flip → 완주까지를 하나의 스크립트(dns-inflight-4snap.sh)로 묶어 실행했다.

타이밍 함정 한 가지(그대로 기록): curl 시작과 flip을 서로 다른 top-level 백그라운드 호출 2개로 나눠 보냈더니 실제 착수 시각이 최대 2분 이상 어긋나 무효 시도가 두 번 났다. curl과 flip을 같은 셸 스크립트 안에서 &+sleep+wait로 묶어 단일 호출로 실행해야 타이밍이 보장된다.

결과 (STRICT, 2/2 런 + 4점 스냅샷 1런):

시점 membership_change upstream_cx_active destroy / destroy_local endpoint 덤프
BEFORE 0 backend-a
MID1 (flip+12s) +1(반영됨) 1(in-flight 유지) 정지 backend-b(라벨만 바뀜)
MID2 (flip+30s) 동일 1(계속 유지) 정지 backend-b
AFTER (완주+3s) 동일 0 +1 / +1 (여기서만 발생) backend-b

curl은 두 런 모두 CODE=200, SIZE=409610(전량), TIME≈40.05sflip이 요청 진행률 12.5% 지점에서 왔는데도 절단이 재현되지 않았다. upstream_cx_destroy_with_active_rq 델타가 두 런 다 0인 것이 “in-flight 연결이 아니라 이미 끝난 연결이 정리됐다"의 직접 증거다.

확정된 메커니즘: STRICT_DNS는 host를 제거해도 그 host에 물린 in-flight 연결을 즉시 끊지 않는다. MID1 시점에 이미 membership은 갱신됐지만(endpoint 덤프 A→B) 요청은 계속 스트리밍됐고, destroy_local은 요청이 자연 완료된 직후에만(AFTER) 발생했다 — graceful drain이다. LOGICAL_DNS 대조군(동일 시나리오 2런)도 in-flight 보호는 STRICT와 동일했고, 차이는 완료 후뿐이었다: STRICT는 완료 즉시 연결을 폐쇄(다음 요청은 반드시 새 IP), LOGICAL은 연결을 pool에 남겨(다음 요청도 옛 IP 재사용 가능).

관측 사각지대 발견: MID1/MID2에서 cluster 합계(upstream_cx_active)는 1인데, per-endpoint 덤프 (proxy-config endpoints)에는 그 연결이 안 보인다 — 이미 제거된 host 소속이라 목록에서 사라졌기 때문이다. 운영에서 proxy-config endpoints만 보고 “drain 끝났다"고 판단하면 착시가 생긴다 — cluster 레벨 upstream_cx_active를 반드시 함께 봐야 한다.

원시 로그: repo docs/test-reports/2026-07-02_104250_dns-strict-inflight.md(2점 스냅샷, 절단 미재현 2/2), _112113_dns-inflight-part2.md(4점 스냅샷 + LOGICAL 대조군).

[재검증 T96 — 2026-07-09, 자기 번들] 위 결론을 이 번들의 킷만으로(setup.sh + t96-backends-slow.yaml 오버레이) 독립 재현해 검증 번들로 남겼다 — run.sh · result.txt · verdict.json, run 2회 동일 재현. STRICT: flip +12s에 endpoint 덤프는 이미 backend-b인데 in-flight 연결은 cx_active 1로 생존 → 30초 완주(200 · 307,200B · rc=0 · X-Backend: backend-a) 후에야 destroy_local +1, destroy_with_active_rq 전 구간 0. LOGICAL: 동일 완주 + 완료 후에도 연결 유지(pin). 덤으로 하나 더: 그 pinned 연결이 유휴 수 분 뒤 backend nginx keepalive_timeout 65s원격 종료(destroy_remote)로 닫히자 다음 요청이 최신 DNS(backend-b)를 따라갔다 — stale pin의 수명 = 연결 수명이고, kill(Mode 3) 같은 극단이 아니라 평범한 서버측 유휴 타임아웃으로도 pin은 풀린다. 요청 간격이 65s보다 짧아 서버측 종료 분기가 트리거되지 않았던 keepalive-pin 실측(Q&A 부록)의 공백을 메우는 데이터 포인트다(C19).

8-2. 후속 B — dead-IP는 “유실"이 아니라 “지연세” (2026-07-02)

§8 Mode 2가 남긴 과제(“connection churn을 강제해야 진짜 유실을 볼 수 있다”)를 실제로 메웠다. 두 번의 함정을 거쳐야 진짜 churn이 나왔다:

  1. client 쪽 keepalive 끄기는 안 통한다. fortio -keepalive=false로 82 요청을 쳐도 downstream 소켓만 82개 새로 열릴 뿐(Sockets used: 82), upstream(Envoy→backend) 신규 커넥션은 겨우 4개였다 — Envoy가 downstream 여닫힘과 무관하게 upstream pool을 독립적으로 재사용하기 때문. 이게 Mode 2가 처음부터 유실을 못 만든 이유였다.
  2. DR connectionPool.http.maxRequestsPerConnection: 1이 유일한 해법인데, portLevelSettings 함정 때문에 처음엔 이것도 안 먹었다. top-level에 두면 portLevelSettings[443]이 존재하는 포트는 top-level의 다른 필드를 아예 상속하지 않는다(config_dump로 확증) — portLevelSettings[443] 블록 으로 옮기자 비로소 반영되어, 82 요청에 82개의 신규 upstream 연결(1:1 churn)이 확인됐다.
# 46-destinationrule-tls-churn.yaml 핵심부 — portLevelSettings 블록 "안"에 넣어야 반영된다
trafficPolicy:
  portLevelSettings:
    - port: { number: 443 }
      connectionPool:
        tcp: { connectTimeout: 1s }           # dead ClusterIP connect hang 방지
        http: { maxRequestsPerConnection: 1 } # 매 요청 upstream 연결 폐기+재생성 = churn 강제
      tls: { mode: SIMPLE, sni: gslb.lab.internal, insecureSkipVerify: true }

부수 발견 — 기존 44-destinationrule-tls-outlier.yaml도 같은 함정에 빠져 있었다. outlierDetection을 top-level에 두고 있던 원래 44는 config_dump 확인 결과 443 클러스터에 outlier_detection 필드 자체가 없었다 — 2026-07-01부터 이 DR은 443 트래픽에 outlier detection이 적용된 적이 없었다(더 미완이었던 Mode 2가 이 단계까지 도달하지 못해 그동안 미발견). 44를 portLevelSettings 안으로 교정하고(이 킷에 반영됨), churn(46)+outlier(44 교정판)를 합본한 신규 47-destinationrule-tls-churn-outlier.yaml을 stage2/3용으로 만들었다.

3-stage 결과 (STRICT_DNS, backend-a는 DNS엔 남긴 채 replicas:0, 40초 churn 부하):

stage 총요청 HTTP 실패 ≥0.9s 지연 요청 비고
1. churn만(no outlier/retry) 149 0 37(24.8%) 요청 1/4이 매번 ~1s(connectTimeout) 날아감
2. +outlier 5221 0 1(0.02%) 처리량 35배 증가
3. +outlier+retry 5219 0 1(0.02%) stage2 대비 유의미한 추가 개선 없음

재해석 1 — “유실 0"의 실체는 Istio 기본 retry policy다. VS42는 retries 필드가 없는데도 upstream_rq_retry가 stage1부터 81 증가했다. config_dump 확인 결과, VS에 retry 정책이 없어도 Istio는 모든 HTTP route에 기본으로 retry_on: connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes, num_retries: 2, retry_host_predicate: previous_hosts를 주입한다. dead-IP connect 실패는 “아직 바이트가 안 오간 pending request"로 분류돼 이 기본 정책에 따라 VS 정책과 무관하게 자동으로 다른 host로 재시도된다.

함의: 누군가 VS에 retries.attempts: 0을 명시하면 이 보호가 사라져 실제 유실이 발생한다. “retry를 안 걸었으니 기본적으로 안전"이라는 가정은 틀렸다 — 기본값이 이미 이 재시도를 켜 두고 있고, 끄는 쪽이 명시적 행동이다.

재해석 2 — dead-IP의 실질 비용은 유실이 아니라 지연세(latency tax)다. stage1의 실제 비용은 에러가 아니라 dead IP를 뽑을 때마다 connectTimeout(1s)만큼 대기했다가 조용히 다른 host로 넘어가는 지연이었다(37/149, 24.8%가 이 세금을 냄). stage2에서 outlier detection이 격리를 수행하자(ejections_enforced_total+2, health_flags:/failed_outlier_check) 이 세금을 무는 비율이 0.02%로 떨어졌고, 같은 40초 창의 처리량이 35배 늘었다. outlier detection의 진짜 가치는 “에러 방지"가 아니라 **“이 지연세 제거”**다.

재해석 3 — VS45(retry)의 순효과는 이번 설계로는 분리되지 않았다. connect-failure 재시도를 Envoy가 이미 기본으로 수행하고 있었기 때문에 stage3가 stage2 대비 뚜렷한 개선을 보이지 않았다. “명시적 retry 정책이 추가로 기여하는 지점"은 열린 항목으로 남는다(§9).

원시 로그: repo docs/test-reports/2026-07-02_121151_dns-mode2-churn.md.

8-3. 후속 D — TLS passthrough에서는 A·B의 L7 보호가 사라지는가 (2026-07-09)

§8-1·§8-2가 확인한 “STRICT flip은 in-flight를 안 자른다”, “dead-IP 유실은 사실 지연세다"는 결론은 전부 client가 평문 HTTP로 호출하고 sidecar가 TLS를 **origination(L7)**하는 경로(40/41 SE + 42 VS + 43 DR)에서 나왔다 — Envoy가 요청/응답 경계를 알기 때문에 route retry, connect-failure 자동 재시도, connection pool drain 유예가 전부 작동할 수 있었다. “client가 직접 TLS를 맺고 sidecar는 SNI만 보고 통과시키면 이 세 가지가 정말 사라지는가"를 대조하려고 별도 hostname gslb-pt.lab.internal에 새 SE만 추가했다 — 기존 gslb.lab.internal 경로(40/41/42/43)는 전혀 건드리지 않았다.

# scenarios/50-dns-resolution/48-serviceentry-passthrough.yaml (신규)
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: gslb-passthrough
  namespace: dns-lab
spec:
  exportTo: ["."]
  hosts: ["gslb-pt.lab.internal"]
  location: MESH_EXTERNAL
  ports:
    - { number: 443, name: tls, protocol: TLS }   # HTTP 아님 — HTTP로 승격할 방법이 없어 SNI passthrough로만 처리
  resolution: DNS

이 host엔 VirtualService/DestinationRule을 아예 붙이지 않는다. 리스너 덤프로 확인한 구조적 차이 — SNI 매치 후 곧바로 tcp_proxy, RDS route가 없다:

gslb-pt.lab.internal 리스너: SNI match -> 직접 tcp_proxy(cluster 바인딩), Route 없음
gslb.lab.internal(HTTP)   리스너: App: http/1.1,h2c -> Route: 443 (RDS route table 경유)

실측 함정 — DNS를 Service ClusterIP로 가리키면 실험이 조용히 무효화된다. gslb-pt.lab.internal을 backend-a의 Service ClusterIP로 가리킨 채 curl을 반복하면 curl 자체는 200/backend-a로 성공하는데 passthrough cluster의 카운터(upstream_cx_total)가 전혀 증가하지 않았다 — 겉으로는 전혀 티가 안 난다. 원인: Istio sidecar가 in-mesh Service ClusterIP마다 그 IP:port 전용 리스너를 미리 만들어 둔다. outbound 캡처는 SO_ORIGINAL_DST로 읽은 “원본 목적지 IP” 기준이라, client가 어떤 이름·SNI로 접속했든 목적지가 이미 알려진 Service VIP와 같으면 그 전용 리스너가 0.0.0.0:443의 SNI 캐치올보다 항상 먼저 매치돼 만들어둔 passthrough SE는 완전히 무시되고 트래픽이 그 Service 자체의 cluster로 샌다. 기존 gslb.lab.internal이 이 문제를 안 겪는 이유는 client가 포트 80으로 붙기 때문(backend-a Service엔 80이 없어 충돌 리스너가 없음). 해결책: DNS 레코드를 backend pod의 pod IP(Service ClusterIP 아님)로 바꿨다 — pod IP는 in-mesh Service VIP가 아니므로 전용 리스너가 없다. 이 함정은 GSLB/외부 도메인을 흉내내는 랩·테스트 환경 전반에서 재발할 수 있는 일반적 위험으로 보인다(egress 랩 등에서도 점검 가치 있음).

D-1 — dead-IP churn 대조 (핵심): 같은 40초 창, 같은 dead-IP 조건(backend-a scale 0)에서 passthrough와 HTTP 경로를 동시에 돌렸다.

경로 시도 성공 실패 실패 유형 기본 재시도
passthrough (gslb-pt) 41 23 (56.1%) 18 (43.9%) exit=28(connect timeout) 13건, exit=35(TLS 연결 실패) 5건 route가 없어 적용 불가
HTTP (gslb.lab.internal, DR43 그대로) 86 86 (100%) 0 없음 46/46 성공(upstream_rq_retry_success=46)

HTTP 쪽은 §8-2 “재해석 1"과 완전히 동일한 메커니즘이 재현됐다. passthrough 쪽엔 이 안전망이 없다 — dead pod IP를 고른 커넥션은 TLS 핸드셰이크 중간에 리셋되거나(exit 35), SYN이 blackhole되어 client의 --connect-timeout 3이 만료되거나(exit 28) — 어느 쪽이든 client에게 그대로 전달된다. “TCP cluster엔 upstream_rq_*가 아예 없다"는 최초 가정도 정정됐다 — 트래픽이 흐르면 이 스탯들은 존재한다. 진짜 차이는 스탯의 유무가 아니라 route의 유무였다.

D-2 — in-flight 스트림 + flip: /slow(40초 스트림)를 passthrough로 호출하고 +5초에 flip해도 완주했다 (200, 409610바이트, backend-a 전량) — §8-1과 똑같은 패턴이다. 그런데 원인은 다르다. HTTP 쪽은 “connection pool이 제거된 host의 연결을 active request가 끝날 때까지 유예 후 drain"하는 능동적 기제였다. TCP passthrough엔 connection pool 자체가 없다 — tcp_proxy는 한 번 연결을 맺으면 그 커널 소켓을 그대로 붙들고 바이트를 릴레이할 뿐, CDS/EDS가 나중에 그 host를 지워도 이미 진행 중인 연결과는 무관하다. 결과(스트림 생존)는 같지만, HTTP는 “적극적으로 유예"하는 것이고 TCP는 “애초에 개입할 지점이 없어 그냥 살아있는” 것이다 — 우연히 안전한 것이지 설계로 안전한 게 아니다.

D-3 — outlier detection 적용 전/후: 같은 dead-IP 조건에서 outlier 적용 전/후를 대조했다.

stage 총시도 성공 실패 실패율 ejections_enforced_total
A (outlier 없음) 29 14 15 51.7% N/A
B (+outlier) 2905 2899 6 0.21% 2

§8-2 “재해석 2"의 HTTP 쪽 outlier가 “이미 있던 재시도의 지연세를 없애는” 역할이었다면, passthrough의 outlier는 “재시도가 아예 없는 상태에서 실패 자체의 빈도를 낮추는” 역할이다 — 근본적으로 다른 안전판이다. 51.7%→0.21%로 급감했지만 정확히 0은 아니었다 — ejection이 발효되기 전(consecutiveLocalOriginFailures:3을 채우는 동안) 초반 실패는 L7 retry가 없어 구제할 방법이 없기 때문이다.

A·B 결론에 붙는 조건 — 정리: 가설(client가 직접 TLS를 맺고 sidecar가 SNI passthrough만 하면 A·B의 L7 보호가 사라진다)은 지지된다.

  • §8-2 “재해석 1·2"의 “VS에 retries가 없어도 Istio 기본 정책이 재시도해 유실이 지연세로 바뀐다"는 결론은 RDS/VirtualService route가 존재하는 L7(TLS origination) 경로 한정이다 — route 자체가 없는 passthrough엔 이 재시도가 걸릴 자리가 없고, 같은 조건에서 43.9%가 실제 유실로 전달됐다.
  • in-flight 보호(§8-1)는 결과가 같지만 메커니즘이 다르다 — “능동적 drain 유예” vs “개입할 대상이 없어 그냥 살아있음”. 결과의 유사성에 속아 “TCP도 안전하다"고 일반화하면 안 된다.
  • outlier detection은 passthrough에서도 유효한 완화책이지만 0에 수렴하지 않는다 — HTTP의 outlier가 “재시도의 지연세 제거"라면, passthrough의 outlier는 “재시도가 없는 상태에서 실패 빈도를 낮추는” 역할이다.

프로덕션 함의: “TLS passthrough를 쓰니까 사이드카가 알아서 재시도해줄 것"이라는 가정은 틀렸다. passthrough(예: 앱이 mTLS를 직접 하거나 인증서를 sidecar에 맡기고 싶지 않은 경우)를 선택하는 순간 L7 보호(retry, outlier의 완전한 효과, 요청 단위 장애 복구)는 sidecar가 대신해주지 않는다 — 보호 수단이 클라이언트 자체 재시도 + outlier detection + GSLB TTL 설계로 이동한다. 관측 수단도 달라진다 — TCP cluster에 트래픽이 흐르면 upstream_rq_* 스탯 자체는 존재하지만, route가 없으니 “재시도가 몇 번 성공했는가” 같은 L7 지표는 애초에 나올 자리가 없다.

검증 범위 안내: 이 §8-3은 T92(2026-07-05, 자기 번들 킷 재현)보다 뒤에 별도로 돌린 라이브 측정이라 §8-1·§8-2와 마찬가지로 검증 기록 표(C1~C16)에는 포함되지 않는다 — 원자료는 repo docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md 전문 참조.

원시 로그: repo docs/test-reports/2026-07-09_134250_dns-passthrough-tcp.md. 신규/수정 manifest: scenarios/50-dns-resolution/48-serviceentry-passthrough.yaml(신규, 유지), 49-destinationrule-passthrough-outlier.yaml(신규, D-3 전용 — 클러스터에선 실험 종료 후 삭제, 파일은 재현용 보존), 10-lab-dns.yaml(수정, hosts zone에 gslb-pt.lab.internal 추가, additive). 다운로드 가능 사본은 “참조” 절 하단 fileshare 링크.

F-0 후속(2026-07-09) — LOGICAL_DNS + passthrough로 매트릭스 마지막 칸을 채웠다. §8-3(D-2)은 STRICT_DNS + passthrough만 봤다 — resolution: DNS_ROUND_ROBIN(LOGICAL_DNS)을 passthrough SE에 적용한 대응쌍(50-serviceentry-passthrough-logical.yaml, 신규)으로 같은 40초 in-flight + flip을 재실측하니 2/2 런 모두 완주했고, membership_change가 BEFORE→AFTER 전 구간 고정(Δ0)이라는 점에서 STRICT×passthrough보다 flip에 한 단계 더 무관심함을 확인했다(STRICT는 membership 자체는 갱신되고 소켓만 안 끊기는 반면, LOGICAL은 membership 갱신이라는 개념 자체가 없다). resolution × 경로(L7 origination/L4 passthrough) 2×2 매트릭스가 이걸로 완결됐다 — 네 칸의 실측 대조·타임라인은 SE resolution × TLS passthrough(L4) — in-flight 요청의 운명에 정리했다. 원시 로그는 docs/test-reports/2026-07-09_144251_dns-passthrough-logical-inflight.md.


9. 남은 작업 (2026-07-09 갱신 — 초안 → 리뷰)

완료됨(§8-1, §8-2):

  • mode2를 connection-churn으로 재설계해 STRICT dead-IP 유실 실제로 보이기 — 완료. 단 “유실"이 아니라 “지연세"로 결론이 바뀌었다.
  • 장수명 스트리밍으로 STRICT flip이 in-flight를 자르는지 직접 시연 — 완료. 결과는 예상과 반대(자르지 않음).

완료됨(§8-3, 2026-07-09):

  • A·B의 L7 보호(기본 재시도로 유실이 지연세로 바뀜, in-flight graceful drain)가 client가 직접 TLS를 맺고 sidecar는 SNI passthrough만 하는 경로에서도 유지되는가 — 완료. 결과는 반대 — route가 없어 재시도가 걸릴 자리가 없고, 같은 dead-IP 조건에서 43.9%가 실제 유실로 전달됐다. in-flight 보호와 outlier의 완화 효과는 유지되지만 전자는 메커니즘이 다르고(“pool이 없어 그냥 안 건드려짐”) 후자는 0에 수렴하지 않는다.

여전히 열려 있음:

  • STRICT drain 유예의 상한 미검증. 40초 스트림은 완료까지 계속 보호됐지만, drain timeout이 따로 있는지는 확인 못 했다 — 수분 단위 장수명 스트림으로 재확인 필요.
  • HTTP/2(GOAWAY 경로) 미검증. 이번 실험은 전부 HTTP/1.1 — gRPC 등 HTTP/2에서 동일하게 in-flight가 보호되는지는 별도 확인이 필요하다.
  • VS45(retry)의 순효과 미분리. DR46(churn만, outlier 없음)+VS45(retry) 조합의 별도 stage로, “retry 정책 자체가 추가하는 가치"를 outlier와 갈라서 봐야 한다.
  • connectTimeout이 지연세 크기를 결정한다는 가설 미정량화. 기본값(10s)으로 되돌리면 지연세가 커지는지 실측 필요.
  • DNS TTL / Envoy dnsRefreshRate 값을 바꿔 flip 반영 지연을 정량화(기존 항목, 미착수).
  • 44와 같은 portLevelSettings top-level 상속 함정이 다른 DR에도 잠복해 있는지 전수 점검 — 이번엔 우연히 발견됐을 뿐, 다른 시나리오의 DR도 같은 패턴이면 조용히 미적용 상태일 수 있다.
  • (신규, §8-3) Service ClusterIP 섀도잉 함정의 재발 가능성. ServiceEntry가 in-mesh Service의 ClusterIP와 같은 IP로 resolve되면 트래픽이 조용히 그 Service의 cluster로 새는(curl 200이라 티가 안 남) 이번 발견이, GSLB/외부 도메인을 흉내내는 다른 랩·테스트 환경(예: egress 시나리오)에서도 재발하는지 점검 가치가 있다.
  • (신규, §8-3) client connect-timeout(3s)과 Envoy 기본 connectTimeout(10s) 불일치. D-1에서 client-side 실패 수와 Envoy 자체 카운터가 정확히 1:1로 대응하지 않았다 — DR로 connectTimeout을 client timeout과 맞추면 더 깔끔한 대응을 볼 수 있을 것.
  • (신규, §8-3) D-3 outlier 적용 후반부 2차 실패 클러스터. ejections_enforced_total이 1이 아니라 2로 늘어난 이유(재검증 사이클?)를 분리하지 못했다 — 다음 작업 후보.

What you might be missing

  • 이 랩은 sidecar-direct 경로다. DNS 조회 주체가 client sidecar라서 자기완결형(공유 인프라 무변경)이 가능했다. 하지만 KakaoPay의 실제 egress 구성처럼 egress gateway를 경유하면 DNS 조회 주체가 gateway의 Envoy로 바뀐다(정본 §9) — 그 경로에서 재현하려면 gateway의 dnsConfig/resolv.conf를 건드려야 해서 blast radius가 커진다. 이 랩은 “메커니즘 증명"이지 “프로덕션 토폴로지 재현"은 아니다.
  • LOGICAL_DNS의 “단일 endpoint” 제약은 버그가 아니라 신호다 — 단, 이 문단이 원래 짚은 트리거는 틀렸다(2026-07-10 정정, §7 C4 참조). 원래는 “Envoy가 여러 IP 중 대표를 못 골라 CDS push를 거부한다"고 썼지만, 실측 결과 런타임 DNS가 실제로 여러 IP를 응답해도 Envoy는 거부하지 않고 스스로 첫 IP를 대표로 정해 계속 쓴다(NACK 없음). 제약이 실제로 강제되는 지점은 SE.spec.endpoints에 여러 개를 명시적으로 선언할 때뿐이고, 그때도 CDS가 아니라 istiod admission webhook의 apply-time 거부다. “GSLB 뒤에서는 LB 권한을 DNS에 위임한다"는 §9(정본)의 트레이드오프 자체는 여전히 맞다 — 다만 그 위임이 코드로 강제되는 방식은 “여러 IP를 보면 거부"가 아니라 “여러 IP를 봐도 그중 하나만 조용히 쓰고 나머지는 무시"이다. Mode 2를 LOGICAL로 못 돌리는(정확히는, 돌려도 의미가 없는) 것 자체는 여전히 이론의 증거다.
  • fortio의 keepalive 커넥션 1개(-c1)는 “죽을 때까지” 그 IP를 문다. 이게 Mode 1에서 “세션 유지"의 시각적 증거이자 Mode 3(뒤늦은 재연결)의 씨앗이다 — 같은 메커니즘이 좋은 소식과 나쁜 소식을 동시에 만든다.
  • STRICT의 “무손실"은 요청이 짧아서지 STRICT가 안전해서가 아니다 — 라는 이 문단 자체가 2026-07-02에 반박됐다. 40초 장수명 요청으로 재실측한 결과(§8-1), STRICT flip은 요청 길이와 무관하게 in-flight 스트림을 완료까지 보호한다(graceful drain) — “긴 연결엔 위험"이 아니라 “긴 연결도 진행 중엔 안전하고, 끝난 뒤의 재사용만 갈린다"가 정확한 요약이다. 다만 이 보호에 상한이 있는지(drain timeout)는 미검증이라 남겨둔다(§9).
  • VS에 retries가 없다고 재시도가 없는 게 아니다(§8-2 재해석 1) — 단 이건 HTTP route가 있을 때 이야기다. Istio는 모든 HTTP route에 기본으로 connect-failure 재시도를 주입한다 — dead-IP를 만나도 이 기본값 때문에 조용히 다른 host로 넘어간다. “재시도 정책을 안 걸었다"가 곧 “재시도가 없다"는 뜻이 아니라는 게 이 랩에서 가장 반직관적인 발견이었는데, 그 반직관성 자체가 route(RDS/VirtualService)의 존재를 전제한다는 걸 §8-3에서 알게 됐다 — route가 아예 없는 TLS passthrough에서는 이 “숨은 안전망"도 존재하지 않는다.
  • “사이드카가 있으니 알아서 재시도해줄 것"이라는 가정은 client가 직접 TLS를 맺는 순간 깨진다(§8-3). mTLS/TLS passthrough를 쓰는 이유가 무엇이든(앱이 인증서를 직접 관리하고 싶다거나) — sidecar가 SNI만 보고 바이트를 통과시키는 경로에서는 route도, route-level retry policy도, HTTP connection pool의 drain 유예도 구조적으로 존재하지 않는다. 이 랩 전체의 “L7 보호"는 전부 sidecar가 프로토콜을 볼 수 있어야만 성립하는 보호였다 — passthrough를 선택하는 순간 그 보호를 클라이언트 자체 재시도와 outlier detection으로 스스로 대체해야 한다.

참조

아카이브 내부

관련 IaC (실제 manifest·스크립트)


검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법 요약: 공식 문서(Istio/Envoy reference) 대조 + homelab 클러스터 실측(fortio keepalive 부하 중 CoreDNS A record flip, istioctl proxy-config cluster/endpoints 스냅샷). 2026-07-05에는 번들 랩 킷(files/lab/)만으로 cleanup→setup→3모드→cleanup 전체 생애주기를 재실측했다(T92, 자기 번들 — 원자료: manifest.yaml · run.sh · result.txt · verdict.json). 2026-07-09에는 “flip 순간 in-flight 요청의 운명”(§8-1의 핵심 결론)을 번들 킷 + 느린 응답 오버레이(300KB @ 10KB/s ≈ 30초)로 독립 재현했다(T96, 자기 번들 — 원자료: run.sh · t96-backends-slow.yaml · result.txt · verdict.json).

주장 판정 근거
C1. STRICT_DNS에서 GSLB flip으로 사라진 host의 기존 커넥션을 drain — 세션 끊김 ✅ 실측 확인 envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery.html · T49 실측
C2. LOGICAL_DNS에서는 새 커넥션만 최신 DNS를 따르고 기존 커넥션은 안 건드림 — 세션 유지 ✅ 실측 확인 envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/service_discovery.html · T49 실측(신규 커넥션의 lazy 재조회는 이 claim과 모순 아님)
C3. ServiceEntry resolution: DNS→STRICT_DNS / DNS_ROUND_ROBIN→LOGICAL_DNS 1:1 대응 ✅ 문헌 확인 istio.io/latest/docs/reference/config/networking/service-entry/
C4. LOGICAL_DNS cluster가 런타임에 다중 IP A record를 만나면 CDS push를 NACK할 수 있다 🔬 실측 반증(2026-07-10) — 본문 교정 github.com/istio/istio/issues/49315 · T03 실측(0-endpoint 트리거는 실재하나 “런타임 다중 IP” 트리거는 미확정) · 2026-07-09 실측(런타임 복수 A는 NACK 없이 첫 IP만 사용 — 제약은 SE.endpoints 복수 명시 시 admission 거부로만 존재)
C5. TLS origination 쓰려면 SE 443 포트를 protocol: HTTP로 선언해야 L7 conn pool로 관리됨(TLS 선언 시 L4 passthrough) ✅ 문헌 확인 istio.io/latest/docs/tasks/traffic-management/egress/egress-tls-origination/
C6. client가 처음부터 https://로 직접 호출(SNI passthrough)하면 L7 신호(GOAWAY 등)가 관측 지점에 안 보임 ✅ 문헌 확인 istio.io/latest/docs/tasks/traffic-management/egress/egress-tls-origination/
C7. Istio 기본 stats matcher가 cluster 단위 upstream_cx_* 등을 억제 — inclusionRegexps 설정 필요 ✅ 문헌 확인 istio.io/latest/docs/ops/configuration/telemetry/envoy-stats/
C8. pod dnsPolicy: None + dnsConfig.nameservers가 resolv.conf를 교체 — app/istio-proxy 공유로 Envoy(c-ares) 조회도 적용됨 ✅ 문헌 확인 envoyproxy.io/docs/envoy/latest/api-v3/extensions/network/dns_resolver/cares/v3/cares_dns_resolver.proto
C9. istio-proxy(Envoy)는 기본 c-ares로 DNS 해석 — STRICT_DNS/LOGICAL_DNS cluster의 resolver로도 사용 ✅ 문헌 확인 envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/dns_resolution
C10. ServiceEntry exportTo: [”."]는 정의된 namespace 내부로만 가시화, 전역 노출 안 됨 ✅ 문헌 확인 istio.io/latest/docs/reference/config/networking/service-entry/
C11. istio-proxy 상태 포트 기본값 15021, readiness probe(/healthz/ready) 실패 시 아직 컨트롤플레인 미연결 ✅ 문헌 확인 istio.io/latest/docs/ops/configuration/mesh/app-health-check/
C12. membership_change=멤버십 변경 총량, upstream_cx_destroy_local=능동 종료 커넥션 수, upstream_cx_destroy_with_active_rq=활성 요청 있는 상태의 파괴 커넥션 수 ✅ 문헌 확인 envoyproxy.io/docs/envoy/latest/configuration/upstream/cluster_manager/cluster_stats
C13. 번들 랩 킷(files/lab/)만으로 전체 생애주기(cleanup→setup→strict/logical mode1→logical mode3→cleanup) 재현 가능 — cluster domain 자동 발견(homelab.local) 포함, cleanup 멱등 ✅ 실측 확인 (자기 번들 T92) result.txt · verdict.json
C14. (C1 라이브 재확인) STRICT_DNS flip 시 run 내 델타: membership_change +1 · upstream_cx_destroy +2(전부 destroy_local) · upstream_cx_total +2, endpoint A→B 교체, who= flip +3초에 backend-b (ttl 5s+reload 2s 창 내 지터) ✅ 실측 확인 (자기 번들 T92) result.txt
C15. (C2 라이브 재확인) LOGICAL_DNS flip 시 run 내 델타 전부 0, who=backend-a 40초 유지 — 단 endpoint 덤프는 backend-b로 라벨만 갱신(덤프≠바이트 함정 동시 재현) ✅ 실측 확인 (자기 번들 T92) result.txt
C16. LOGICAL_DNS는 pinned IP가 죽어야 재연결 — flip 후 20초 pin 유지, backend-a kill 순간 who=backend-b 전환, code!=200 0건, destroy_remote +2, destroy_with_active_rq 0 (무손실) ✅ 실측 확인 (자기 번들 T92) result.txt
C17. (§8-1 재확인) STRICT_DNS의 host 제거 drain은 graceful — flip 순간 진행 중(in-flight)이던 HTTP/1.1 요청은 절단되지 않고 완주하며, 연결 종료(destroy_local)는 요청 완료 후로 미뤄짐 (destroy_with_active_rq 전 구간 0, run 2회 재현) ✅ 실측 확인 (자기 번들 T96) result.txt · verdict.json
C18. (§8-1 재확인) LOGICAL_DNS도 flip 순간 in-flight 요청은 완주 — 연결은 완료 후에도 종료되지 않고 옛 IP에 pin 유지 (STRICT와의 차이는 “완료 후 그 연결을 닫느냐"뿐) ✅ 실측 확인 (자기 번들 T96) result.txt
C19. LOGICAL의 stale pin 수명 = 연결 수명 — backend가 서버측 유휴 타임아웃(nginx keepalive_timeout 65s)으로 원격 종료(destroy_remote)하면 다음 요청은 최신 DNS를 따름 ✅ 실측 확인 (자기 번들 T96) result.txt

Files