🏠 목록 GSLB 뒤 DNS resolution 재현 랩 — "세션이 끊길 수도 있다"를 눈으로 보기 📄 MD 원본 📁 Files 🔒 Private 🌓 테마
istioegressserviceentrydnsgslbresolutionenvoymental-model

GSLB 뒤 DNS resolution 재현 랩 — "세션이 끊길 수도 있다"를 눈으로 보기

NOTE

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)"의 선택이고, 그 선택이 곧 세션 생존 여부다.

대상 환경: 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가지 재현 모드 ⑤ 함정


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를 바꾸는 순간 내 세션이 죽느냐 사느냐"다.

resolution: DNS              -> Envoy STRICT_DNS
   host set = A record 전체.  DNS refresh 시 사라진 IP의 host를 cluster에서 제거
   -> 그 host로 물려있던 커넥션을 정리(drain)   => 기존 세션 끊김           [Mode 1]
   -> refresh 전 "죽었지만 아직 목록에 있는" IP를 LB가 고르면 connect 실패 => 유실 [Mode 2]

resolution: DNS_ROUND_ROBIN  -> Envoy LOGICAL_DNS
   host = 논리적 endpoint 1개.  새 커넥션만 최신 DNS로 연결
   기존 커넥션은 "절대 건드리지 않음"          => 기존 세션 유지            [Mode 1 대조]
   대가: 물고 있는 IP가 나중에 죽어도 Envoy는 DNS로는 못 알아챔            [Mode 3]

세 대괄호 표시(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 passthrough 랩의 함정과 반대)

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

+------------------- ns: dns-lab (istio-injection=enabled) --------------------+
|                                                                              |
|  [client: fortio/netshoot]  dnsPolicy:None                                  |
|     dnsConfig.nameservers = [lab-dns ClusterIP]                             |
|        |  http://gslb.lab.internal  (평문 HTTP, keepalive)                  |
|        v  istio-proxy: SE.resolution -> STRICT_DNS | LOGICAL_DNS            |
|     Envoy(c-ares) --A? gslb.lab.internal--> [lab-dns: CoreDNS]              |
|        |                         ^   flip = writer(busybox)가               |
|        |                         |   /hosts/addn 재작성 (+reload 2s)        |
|        |   VS:80->443 + DR:TLS origination(SIMPLE)                          |
|        +--TLS--> [backend-a svc/ClusterIP-1] -> "backend-a"                |
|        +--TLS--> [backend-b svc/ClusterIP-2] -> "backend-b"                |
|                                                                              |
|   lab-dns: gslb.lab.internal 만 authoritative(ttl 5s),                      |
|            나머지(istiod 등)는 cluster DNS로 forward                        |
+------------------------------------------------------------------------------+
   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를 핀할 것.


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)

관측 지표(모드 공통):

# 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'

이 세 모드를 명령 한 줄씩으로 묶은 하네스가 scripts/dns-flip-test.sh <strict|logical> <mode1|mode2|mode3>다 (📎 첨부 참조). 부트스트랩은 scripts/dns-lab-setup.sh.


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


8. 라이브 실측 결과 (2026-07-01)

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 영역이라 별도 시나리오가 맞다).

원시 로그·대조표 전문: 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.


What you might be missing


참조

아카이브 내부 - ServiceEntry resolution 정본 — 이 랩이 재현하는 이론(STRICT_DNS/LOGICAL_DNS, dead IP, GSLB 권장) - circuit-breaking 메커니즘 (outlier detection 정본) · Cluster 해부 - Egress 4-CRD 멘탈모델 — 이 랩의 VS/DR TLS origination 패턴이 기대는 4-CRD 골격 - 클러스터 콜드부팅 복구 — 이 랩을 설계하던 중 겪은 인프라 장애

관련 IaC (실제 manifest·스크립트) - 📎 README (설계 내러티브 전문) - 📎 00-namespace · 📎 10-lab-dns · 📎 20-backends · 📎 30-client - 📎 40-serviceentry-strict · 📎 41-serviceentry-logical - 📎 42-virtualservice-80to443 · 📎 43-destinationrule-tls - 📎 44-destinationrule-tls-outlier · 📎 45-virtualservice-80to443-retry - 📎 dns-lab-setup.sh · 📎 dns-flip-test.sh