homelab89 Docs Logs Legacy Files ☰ TOC 🌓
noteistio 2026-06-07dnsegressenvoyservice-entryoutlier-detection

Runbook — ServiceEntry `resolution: DNS` 동작과 진단 (STRICT_DNS)

ABSTRACT

외부 도메인(httpbin.org)을 ServiceEntry(resolution: DNS)로 등록하면 istiod가 이를 Envoy의 STRICT_DNS cluster로 변환한다. 이 문서는 그 변환·DNS 갱신 메커니즘, “죽은 IP” 처리, ambient DNS 질의 경로를 홈랩 실측·재현 명령과 함께 한 줄의 멘탈모델로 꿴다. 결론 한 문장: DNS refresh는 health check가 아니다 — DNS는 “목록"만 줄 뿐 IP의 생사를 모르므로, 죽은 IP 회피는 outlier detection으로 명시해야 한다.

환경: cluster homelab(k8s v1.30.6, istiod 1.30.0 / istioctl 1.27.0 client·skew) / egress gateway 경유 / 관련: ingress·egress 통합 리포트 검증: 이 문서의 주장은 공식 문서 대조 + 자기 번들 실측 T49·T50(cluster type 변환 · DNS refresh ≠ liveness) + 교차 참조 실측 T47(L4에서 retry 무시)로 확인했다 — 판정 전체는 문서 끝 “검증 기록” 표(C1~C15). §5·§6의 거동(STRICT drain vs LOGICAL 보존, stale pin)은 자매 랩 dns-gslb-repro-labT92로 라이브 재현했다.


1. 배경 — 왜 mesh가 외부 도메인의 IP를 직접 들어야 하나

mesh 안 트래픽은 전부 Envoy가 가로채 라우팅한다(sidecar/gateway). 그런데 Envoy는 “IP:port의 묶음"인 cluster 단위로만 upstream을 안다 — Envoy의 세계에는 호스트네임이 없고 endpoint 집합만 있다. mesh 내부 서비스는 istiod가 k8s Endpoints/EndpointSlice를 watch해서 cluster를 자동으로 채워준다. 하지만 httpbin.org 같은 mesh 밖 외부 도메인은 k8s에 Endpoints가 없다. istiod가 그 IP를 알 길이 없다.

ServiceEntry는 바로 이 공백을 메우는 CRD다 — “이 호스트는 mesh의 일부로 취급하라, IP는 이렇게 알아내라"를 선언한다. 그 “IP를 알아내는 방식"이 spec.resolution 필드이고, 이 한 필드가 Envoy cluster의 종류를 결정한다. 즉 외부 도메인 한 줄을 등록할 때 진짜로 고르는 것은 “Envoy가 IP를 들고 LB하는 전략"이다.

여기서 자연스럽게 따라오는 질문 4개가 이 문서의 뼈대다 — 이 순서대로 읽으면 길을 잃지 않고, 각 노드의 §번호가 곧 이 문서의 지도다(진단 도구 층인 §8·§9는 이 사슬 전체를 관통한다):

flowchart LR
    A["resolution<br/>어떻게 IP를 아나<br/>(§3)"]
    B["cluster type<br/>STRICT_DNS / LOGICAL_DNS<br/>(§3)"]
    C["갱신 주기<br/>DNS TTL 따라 재질의<br/>(§4)"]
    D["죽은 IP<br/>DNS는 생사를 모름<br/>(§5 · §6)"]
    E["회피 구성<br/>outlier detection + retry<br/>(§7)"]
    A --> B --> C --> D --> E

선행 개념: Envoy cluster = endpoint(IP:port) 집합 + LB 정책 / cluster 이름 규칙 direction|port|subset|fqdn (예: outbound|443||httpbin.org) / egress gateway = mesh 밖으로 나가는 트래픽을 한 노드로 모으는 전용 Envoy.

이 문서는 resolution 필드의 이론 정본이고, 이웃 문서들과 역할을 나눈다 — “어느 질문을 여기서 답하고, 어느 질문을 넘기는가"를 먼저 확정해 두면 교차 참조에서 길을 잃지 않는다:

문서 정본으로 답하는 질문 이 문서와의 관계
이 문서 resolution이 무엇을 결정하나 / DNS 갱신·죽은 IP는 어떻게 되나 resolution 필드의 이론 정본
circuit-breaking 메커니즘 outlier detection 각 필드는 무슨 뜻인가 죽은 IP 축출 필드의 정본 — 이 문서(§7)는 egress 특수성만 다룸
Cluster 해부 Envoy cluster는 어떤 구조물인가 §3이 딛고 서는 cluster 구조의 정본
DNS/GSLB 재현 랩 그 이론이 실제로 어떤 장면을 만드나 §5·§6 주장의 라이브 재현(T92) — 번들 킷으로 직접 재현 가능

2. 머릿속에 둘 그림 — DNS는 “목록”, 생사는 별도 책임

이 문서 전체를 꿰는 앵커 한 문장:

resolution이 Envoy cluster type을 정하고, cluster type이 “IP를 어떻게 들고 LB할지"를 정한다. 그러나 어느 type이든 DNS는 IP “목록"만 줄 뿐 그 IP가 살아있는지는 모른다 — liveness는 outlier detection의 별도 책임이다.

이 한 문장에서 나머지가 전부 파생된다. resolution을 STRICT_DNS로 두면 A record의 모든 IP를 펼쳐 Envoy가 직접 LB하고(§3), 갱신 주기는 DNS TTL을 따른다(§4). 하지만 TTL 만료 전에 IP가 죽으면 Envoy는 그 IP를 여전히 HEALTHY로 들고 있어 연결이 실패한다(§5) — DNS refresh는 단지 “목록 다시 받기"이지 “이 IP 살아있냐"를 묻는 게 아니기 때문이다. 그래서 죽은 IP 회피는 outlier detection + retry로 명시해야 하고(§7), GSLB 뒤라면 LB 권한을 DNS에 위임하는 LOGICAL_DNS가 더 맞는다(§6). 마지막으로 “그 DNS 질의를 실제로 누가 쏘는가"가 진단의 출발점이다 — egress 경유 시 그 주체는 egress gateway의 Envoy(c-ares)다(§8).

왜 이렇게 책임을 쪼갰는가? DNS와 health는 원래 다른 시스템이기 때문이다. DNS 서버는 “이 이름에 어떤 IP들이 매핑되는가"의 권위자일 뿐, 그 IP 뒤 프로세스가 지금 요청을 받을 수 있는지는 모른다(특히 같은 A record 안에서 일부만 죽은 경우). Envoy는 이 둘을 의도적으로 분리한다 — DNS는 endpoint 발견(discovery), outlier detection은 endpoint 축출(ejection). 이 분리를 모르면 “DNS만 믿으면 알아서 죽은 IP 빠지겠지"라는 가장 흔한 오해에 빠진다.

직접 눈으로 보려면

이 앵커의 두 절반 — “STRICT는 목록을 갈아끼우며 기존 연결을 끊고(§5), LOGICAL은 옛 IP를 붙잡는다(§6)” — 은 자매 랩 DNS/GSLB resolution 재현 랩이 사설 GSLB 시뮬레이터로 라이브 재현했다(T92). 번들 킷(files/lab/)을 받아 어느 클러스터에서든 그대로 돌려볼 수 있다. 주의: 여기서 “연결을 끊는다"는 이 랩의 짧은(1ms) keepalive 요청 사이의 idle 연결 얘기다 — 요청이 진행 중(in-flight)일 때도 그렇게 즉시 끊기는지는 별개 질문이고, §6 교정 콜아웃이 그 답(안 끊긴다, drain으로 완료까지 보호)을 담고 있다.


3. 변환 구조 — resolution이 cluster type을 결정한다

ServiceEntry.spec.resolution 필드가 Envoy cluster type을 결정한다. 이 매핑을 먼저 확립해야 이후 갱신 주기(§4)·죽은 IP(§5)를 STRICT/LOGICAL 대비로 읽을 수 있다.

resolution Envoy type endpoint 적재 적합 대상
DNS STRICT_DNS A record 모든 IP를 펼침 개별 IP를 Envoy가 직접 LB하고 싶을 때
DNS_ROUND_ROBIN LOGICAL_DNS 첫 IP 1개만, 연결 재사용 CDN/GSLB/대형 LB 뒤 “논리적 단일 endpoint”

차이의 본질은 “endpoint를 펼치느냐(STRICT) vs 한 점으로 접느냐(LOGICAL)” 한 곳이다 — 나머지(갱신·LB·연결 재사용)는 전부 이 한 델타에서 따라온다. 같은 ServiceEntry가 resolution 값 하나로 어떻게 갈라지는지:

flowchart LR
    SE["ServiceEntry<br/>hosts: httpbin.org"]
    P["istiod<br/>CDS 변환"]
    subgraph SD["STRICT_DNS — 펼침"]
        S["cluster type: STRICT_DNS"]
        E1["endpoint IP-1"]
        E2["endpoint IP-2"]
        EN["… endpoint IP-8<br/>(A record 전체)"]
        S --> E1
        S --> E2
        S --> EN
    end
    subgraph LD["LOGICAL_DNS — 접음"]
        L["cluster type: LOGICAL_DNS"]
        LE["논리 endpoint 1개<br/>(첫 IP · 연결 재사용)"]
        L --> LE
    end
    SE --> P
    P -->|"resolution: DNS"| S
    P -->|"resolution: DNS_ROUND_ROBIN"| L

resolution: DNS(STRICT_DNS) 변환 결과의 cluster 필드(config_dump 발췌):

Envoy cluster "outbound|443||httpbin.org"
   type: STRICT_DNS          <- Envoy가 직접 DNS 질의
   respect_dns_ttl: true     <- 갱신 주기 = DNS 응답의 TTL
   dns_refresh_rate: 60s     <- TTL이 0/없을 때만 쓰는 fallback
   endpoints: [8 IPs]        <- A record 전체를 펼쳐서 보관

경유 구성 주의: 트래픽 경로가 sleep → egress gateway → httpbin.org이므로 실제 외부 DNS를 도는 주체는 egress gateway의 Envoy다. sleep proxy에도 같은 cluster가 보이지만 라우팅이 egress gateway로 향하므로 실질 사용처가 아니다. 진단은 egress gateway 기준.

STRICT_DNS vs LOGICAL_DNS 실측 (example.comDNS_ROUND_ROBIN으로 별도 등록):

SERVICE FQDN    TYPE          endpoints(proxy-config)
httpbin.org     STRICT_DNS    다수 (A record 전체)
example.com     LOGICAL_DNS   1

istioctl proxy-config endpoints <pod> --cluster "outbound|443||<host>"의 endpoint 개수가 STRICT=다수 / LOGICAL=1로 갈리는 것이 Envoy 반영의 결정적 증거다. LOGICAL_DNS의 핵심은 “DNS 레코드가 자주 바뀌어도 기존 연결을 유지(connection draining/cycling 제거)“라서, 공식 문서가 “large web scale services accessed via DNS"에 권장한다(상세 권장 근거는 §6).


4. 갱신 주기 결정 규칙 — 왜 60s가 함정값인가

필드 현재 값 의미
respect_dns_ttl true DNS 응답 TTL을 갱신 주기로 사용
dns_refresh_rate 60s TTL이 0/없을 때만 쓰는 fallback
httpbin.org 실제 TTL 10s (dig) 실제 갱신 ≈ 10초

dig 출력에서 TTL을 읽는 자리(둘째 컬럼 = 첫 숫자 컬럼, 단위 초 — 형태 예시, 명령은 §8.1 (4)):

httpbin.org.   10   IN   A   3.230.204.70     <- "10" = TTL(s) = Envoy의 실제 재질의 주기
httpbin.org.   10   IN   A   54.242.115.239      (... A record 총 8개, §3의 endpoints와 일치)

dns_refresh_rate: 60s는 함정값이다 — config_dump에 60s가 박혀 있어도 respect_dns_ttl: true면 거의 안 쓰인다(TTL=0이거나 응답에 TTL이 없을 때만 fallback으로 등판). 즉 실제 갱신 주기를 알려면 cluster 설정이 아니라 권위 DNS의 TTL을 봐야 한다 — config만 읽고 “60초마다 갱신되겠지"라고 단정하면 틀린다(실측은 10초).

mesh 전역 기본은 meshConfig.dnsRefreshRate 필드(configmap/istio.data.mesh)로 조정한다. 단 효력 범위는 좁다: respect_dns_ttl=true인 외부 DNS cluster에서는 TTL이 우선이라 거의 무효이고, TTL을 무시하는 cluster나 NDS(DNS auto-allocation) 경로에 실질 영향을 준다. 확인:

kubectl --context homelab -n istio-system get cm istio -o jsonpath='{.data.mesh}' | grep -i dnsRefreshRate
# 미설정이면 출력 없음 = Envoy 기본 fallback(위 60s)

5. “죽은 IP” 처리 — 이 문서의 심장

§2 앵커의 핵심이 여기서 구체화된다. DNS refresh는 liveness 체크가 아니므로, TTL 만료 전에 IP가 죽으면 Envoy는 그 IP를 HEALTHY로 들고 있다가 LB가 고르면 연결 실패한다. 갱신 주기를 아무리 줄여도 이 창은 닫히지 않는다 — TTL이 10초여도, 죽고 나서 다음 재질의 전까지(그리고 권위 DNS가 그 IP를 빼줄 때까지)는 무방비다.

이 “무방비 창"을 시간축에 펼치면 — 창을 여는 것은 IP의 죽음(t1)이고, 닫는 것은 **권위 DNS의 제외(t3)**다. 그 사이의 TTL 재질의(t2)는 죽은 IP가 그대로 든 목록만 다시 받아올 뿐이라 창에 아무 영향이 없다:

sequenceDiagram
    participant C as client
    participant E as egress GW Envoy (STRICT_DNS)
    participant D as 권위 DNS
    participant X as endpoint IP-3

    E->>D: t0 — TTL 재질의
    D-->>E: 목록: IP-1, IP-2, IP-3 (받은 IP는 전부 HEALTHY 간주)
    Note over X: t1 — IP-3 사망<br/>Envoy도 권위 DNS도 아직 모름
    Note over C,X: 여기부터 무방비 창 — 갱신 주기와 무관하게 열린다
    C->>E: 요청
    E-xX: LB가 IP-3 선택 → TCP connect 실패
    alt retry O
        E->>E: 다른 IP로 재시도 → 성공 (사용자 무감)
    else retry X
        E-->>C: 요청 실패 (503 / connect error)
    end
    E->>D: t2 — TTL(10s) 만료 → 재질의
    D-->>E: IP-3 여전히 포함 — 권위 DNS의 health 반영은 TTL과 별개
    E-xX: 다시 IP-3 선택 가능 → 실패 반복 (창 유지)
    Note over D: t3 — 권위 DNS가 자체 health로 IP-3 제외
    E->>D: 다음 재질의
    D-->>E: IP-3 빠진 목록 → 이제야 창이 닫힘
    Note over C,X: 창의 길이 = t1→t3 — TTL 단축은 t2를 앞당길 뿐, t3을 못 앞당긴다

IP가 LB 목록에서 빠지는 경로는 3가지이고 서로 독립적이다 — 어느 것을 켜느냐가 죽은 IP 회피의 설계다:

메커니즘 트리거 기본 구성
DNS refresh TTL 만료 → 재질의 시 DNS가 해당 IP 제외 O (수동적)
Outlier detection (passive HC) 연속 5xx/connect 실패 → 일시 ejection X (DestinationRule 미설정)
Active health check Envoy가 직접 주기적 probe X

세 경로를 갈라 보는 이유: DNS refresh는 권위 DNS가 빼주길 기다리는 수동적 경로라 내 통제 밖이다. outlier detection은 내가 본 실패로 즉시 빼는 능동적 경로라 통제 안에 있다. 그래서 —

프로덕션 결론: 죽은 IP 회피는 DNS TTL에 기대지 말고 DestinationRule.trafficPolicy.outlierDetection + VirtualService.http.retries로 명시 설정한다. 구체 구성(YAML)·passthrough 함정은 §7, outlier 필드 의미의 정본은 circuit-breaking 메커니즘 note 참조.

이 절의 장면을 직접 보려면: 재현 랩의 Mode 1이 STRICT가 DNS 변경에 기존 연결을 능동 drain하는 순간을, Mode 2가 죽은 IP가 목록에 잔존하는 상태를 라이브로 만든다(T92) — 단 Mode 2 실측은 warm connection이 유실을 마스킹한다는 반전까지 보여준다.


6. GSLB 환경 권장 — LOGICAL_DNS

GSLB(DNS가 클라이언트 위치·health로 최적 IP를 고르는 모델) 뒤 호스트는 LOGICAL_DNS 권장. 이유를 한 축으로 압축하면 “LB 권한을 누구에게 둘 것인가” — GSLB가 이미 최적 IP를 골라 주는데 STRICT_DNS로 전체 IP를 펼치면 Envoy가 그 위에서 또 LB하며 GSLB 결정을 덮어쓴다.

구체 시나리오로 보면: 같은 서비스가 서울·도쿄 리전에 떠 있고, GSLB는 서울 클라이언트의 질의에 지역 기반으로 서울 리전 IP를 골라(또는 앞세워) 응답한다. 이때 A record에 두 리전 IP가 함께 실려 오면 STRICT_DNS는 그 전부를 endpoint로 펼쳐 자체 LB로 분산한다 — 트래픽 절반이 GSLB가 고르지 않은 도쿄 리전으로 흘러 왕복 지연이 붙고, GSLB의 지역 라우팅은 무력화된다. LOGICAL_DNS는 응답의 첫 IP(서울)만 쓰므로 GSLB의 결정이 그대로 관철된다:

flowchart TB
    C["서울 클라이언트의 질의"] --> G["GSLB DNS<br/>지역·health 기반으로<br/>서울 리전 IP를 앞세워 응답"]
    G -->|"resolution: DNS"| ES
    G -->|"resolution: DNS_ROUND_ROBIN"| EL
    subgraph SD["STRICT_DNS — LB 권한이 Envoy로 넘어감"]
        ES["Envoy: A record 전체를<br/>endpoint로 펼침"]
        S1["서울 리전 IP<br/>(GSLB가 고른 것)"]
        T1["도쿄 리전 IP<br/>(원격 리전)"]
        ES -->|"재-LB: 분산"| S1
        ES -->|"재-LB: 분산<br/>= GSLB 의도 무력화"| T1
    end
    subgraph LD["LOGICAL_DNS — LB 권한이 GSLB에 남음"]
        EL["Envoy: 첫 IP 1개만<br/>논리 endpoint"]
        S2["서울 리전 IP<br/>= GSLB 결정 그대로"]
        EL --> S2
    end

근거:

  1. GSLB 결정 존중: STRICT_DNS는 A record 전부를 펼쳐 Envoy가 자체 LB → GSLB가 고른 IP 외 원격 리전까지 섞어 GSLB 의도 무력화. LOGICAL_DNS는 DNS가 준 첫 IP만 써 결정을 따른다.
  2. 동적 변경 + 짧은 TTL 친화: LOGICAL_DNS는 새 연결 시 재해석해 최신 GSLB 결정 반영. STRICT_DNS는 전체 IP에 conn pool 유지 → stale·원격 연결 부담.
  3. 자원 낭비 방지: GSLB 뒤는 사실상 “하나의 논리적 endpoint” → 전체 IP에 사전 연결은 낭비.

Trade-off: LOGICAL_DNS는 endpoint가 1개라 Envoy outlier/세밀 LB 효력이 약화 → “장애 판정을 GSLB에 위임"하는 셈. GSLB health가 느슨하면 STRICT_DNS + outlier가 나을 수도. 본질은 “LB 권한을 DNS(GSLB)에 둘 것인가 Envoy에 둘 것인가"의 선택.

이 trade-off의 어두운 절반 — LOGICAL이 GSLB의 IP 변경 신호를 무시하고 옛 IP에 pin된 채, 그 IP가 실제로 죽어야(커넥션 단절로만) 알아채는 장면 — 은 재현 랩의 Mode 3이 라이브 재현했다(T92). “직접 눈으로 보려면” 그쪽의 flip-test 킷이 가장 빠르다.

[2026-07-09 실측으로 교정] 위 “GSLB 뒤엔 LOGICAL_DNS가 낫다” 권장이 뒤집혔다

이 §6의 권장은 “STRICT는 flip 시 세션을 끊는다"는 전제 위에 서 있었다. 40초짜리 in-flight 스트림으로 직접 flip을 걸어 재실측한 결과(2026-07-02·2026-07-09), 그 전제는 절반만 맞았다 — 진행 중이던 요청은 STRICT에서도 drain으로 보호된다(절단 카운터 upstream_cx_destroy_with_active_rq는 모든 런에서 0, destroy_local은 host가 제거된 시점이 아니라 요청이 자연 종료된 직후에만 틱했다). 그리고 죽은 VIP는 Envoy 기본 재시도가 구제한다(VirtualService.retries를 아무것도 안 써도 connect-failure는 자동으로 다른 host에 재시도되어 46/46 성공 — 단 retries.attempts: 0을 명시하면 이 보호가 꺼진다). 이 두 실측을 합치면 결론이 바뀐다 — GSLB처럼 조향 의도가 뚜렷한 DNS 앞에서는 STRICT_DNS가 기본값이어야 한다. GSLB를 두는 이유 자체가 “지금 이 IP로 트래픽을 보내라"는 신호인데, LOGICAL은 완료된 연결을 재사용하며 그 신호를 구조적으로 무시한다(stale pin) — 이 문서가 §6에서 그 방향을 정확히 반대로 권했던 셈이다. 단 이 뒤집힘은 L7(TLS origination) 경로에 한정된다 — L4(TLS passthrough)에서는 두 설정의 차이가 크게 줄어드는데(연결이 pool 밖에 있어 STRICT도 in-flight를 못 건드리는 건 같지만, 대신 dead-IP 자동 재시도 자체가 없다는 별도 문제가 생긴다), 이 문서 §7이 그 passthrough 특수성을 이미 다루고 있다. 상세 근거·수치: Q&A — GSLB가 IP를 바꾸면 내 요청은 어떻게 되나, SE resolution × TLS passthrough — in-flight 요청의 운명(정밀판).


7. 죽은 IP 회피 구성 — outlier detection + retry

DNS refresh는 liveness를 모르므로(§5), 죽은 IP 회피는 아래로 명시한다. outlier detection 필드 자체의 일반 의미(consecutive5xxErrors/baseEjectionTime 배수 증가 등)는 circuit-breaking 메커니즘 note가 정본이고, 여기서는 egress TLS Passthrough(SNI passthrough, L4) 특수성에 집중한다. 이 특수성 한 줄이 §7 전체를 지배한다: egress gateway가 TLS Passthrough면 HTTP/5xx를 못 보므로, L7에 기대는 회피 장치는 전부 무력화된다.

outlier detection (DestinationRule.trafficPolicy.outlierDetection — 적용 후 Envoy 반영 확인됨):

trafficPolicy:
  connectionPool: { tcp: { connectTimeout: 2s } }  # connect 실패 빨리 판정
  outlierDetection:
    consecutiveLocalOriginFailures: 3   # ★ connect 실패(L4) 3회 → eject  (passthrough 핵심)
    splitExternalLocalOriginErrors: true
    consecutive5xxErrors: 5             # 5xx(L7) 5회 → eject
    interval: 10s
    baseEjectionTime: 30s
    maxEjectionPercent: 50              # 전멸 방지
    minHealthPercent: 40

egress 특화 포인트: TLS Passthrough(L4)에서 egress gateway는 HTTP/5xx를 볼 수 없으므로 consecutive5xxErrors는 발화하지 않는다. consecutiveLocalOriginFailures(connect 실패 기반)가 유일한 자동 회피 경로다. 따라서 짧은 connectTimeout과 함께 써야 빠르게 eject된다.

retry (VirtualService.http.retries) — L7 HTTP route에서만 동작:

http:
  - route: [...]
    retries:
      attempts: 3
      perTryTimeout: 2s
      retryOn: connect-failure,refused-stream,5xx,gateway-error

⚠️ 함정: 현재 egress는 TLS Passthrough(L4) 라 egress gateway가 HTTP를 못 본다 → http.retries조용히 무시된다. retry를 쓰려면 TLS Origination(L7) 구성으로 전환해야 한다.

  • TLS Passthrough(L4): outlierDetection(consecutiveLocalOriginFailures) + 짧은 connectTimeout 이 유일한 자동 회피
  • TLS Origination(L7): 위 + http.retries(retryOn: connect-failure)

8. 적용·검증 (재현 가능)

8.1 진단 명령 — cluster가 어떻게 IP를 들고 있나

CTX=homelab
GW=deploy/istio-egressgateway.istio-system
CL="outbound|443||httpbin.org"

# (1) 현재 endpoint IP 목록 + outlier 상태
istioctl --context $CTX proxy-config endpoints $GW --cluster "$CL"

# (2) cluster DNS 설정 (type / refresh_rate / respect_dns_ttl)
kubectl --context $CTX -n istio-system exec deploy/istio-egressgateway -c istio-proxy -- \
  curl -s "localhost:15000/config_dump?resource=dynamic_active_clusters" \
  | grep -A10 '"'"$CL"'"'

# (3) 런타임 endpoint 상태(success_rate, healthy flags)
kubectl --context $CTX -n istio-system exec deploy/istio-egressgateway -c istio-proxy -- \
  curl -s "localhost:15000/clusters" | grep "httpbin.org"

# (4) DNS 원천 데이터(IP + TTL)
dig +noall +answer httpbin.org           # TTL = 첫 컬럼 숫자

# (5) DNS 갱신 카운터를 보려면 sidecar stats 필터를 풀어야 함(기본 미노출):
#   pod annotation proxy.istio.io/config 의 proxyStatsMatcher.inclusionRegexps 에 ".*httpbin\.org.*" 추가

검증 실험 — “DNS 주기 = endpoint 갱신 주기”: dig IP 집합과 proxy-config endpoints를 ~10초 간격 2회씩 떠서, IP 변동 시 endpoint도 따라 바뀌면 둘이 동기임이 확인된다. 이게 §4의 “실제 갱신 ≈ TTL(10s)“의 실측 근거다.

8.2 retry가 정말 무시됨을 입증 (gap finding: 기대 vs 실제)

§7의 “L4에서 http.retries는 조용히 무시"라는 주장은 추정이 아니라 두 관측으로 입증된다 — HTTP route가 없으면 retry 정책이 붙을 곳이 없고, 붙을 곳이 없으면 retry 카운터가 영영 0이다:

# (a) egress gateway에 HTTP route가 없음 = retry 정책이 붙을 곳이 없음을 확인.
#     passthrough는 TCP/SNI 매칭만 존재 → routes 출력이 비거나 TCP proxy만 보임.
istioctl --context homelab proxy-config routes deploy/istio-egressgateway.istio-system \
  | grep -i httpbin            # 기대: 매칭 없음(HTTP route 부재)

# (b) retry 카운터가 0으로 고정 = 재시도가 한 번도 발생하지 않음.
kubectl --context homelab -n istio-system exec deploy/istio-egressgateway -c istio-proxy -- \
  curl -s localhost:15000/stats | grep 'httpbin.*upstream_rq_retry'
#   기대: upstream_rq_retry: 0 (혹은 카운터 자체 부재) — L4라 retry 경로 미존재

9. “ambient DNS” — 도메인 질의는 어디로 가나 (실측)

§2 앵커의 마지막 가닥: “그 DNS 질의를 누가 쏘는가"가 진단의 출발점이다. config_dump의 cluster를 아무리 들여다봐도, 실제 질의를 누가/어디로 보내는지를 모르면 “왜 stale IP가 안 빠지나"를 추적할 수 없다.

공식 문서의 “querying the ambient DNS"에서 ambient는 Istio ambient mesh가 아니라 “그 프록시 환경에 깔린 기본 resolver(/etc/resolv.conf)“라는 영어 일반어다. STRICT/LOGICAL 둘 다 동일.

측정 결과 질의 경로(실선 = DNS capture OFF 실측 경로, 점선 = capture ON 시 분기):

flowchart LR
  GW["egress GW Envoy<br/>(c-ares; no dns_resolver)"]
  RC["/etc/resolv.conf<br/>nameserver 169.254.25.10"]
  NL["nodelocaldns<br/>169.254.25.10 (link-local)"]
  CD["CoreDNS<br/>(cluster DNS)"]
  UP["upstream DNS<br/>(recursive)"]
  AU["authoritative<br/>A record"]
  AG["istio-agent<br/>DNS proxy :53"]

  GW --> RC --> NL --> CD
  CD -->|"not cluster domain"| UP --> AU
  AU -. "A record back" .-> GW
  GW -. "capture ON" .-> AG
  AG -. "internal: NDS local / external: forward" .-> UP

핵심: **질의 주체는 egress gateway의 Envoy(c-ares)**이며, capture OFF에서는 istio-agent를 거치지 않고 resolv.conf의 nodelocaldns → CoreDNS → upstream으로 직접 나간다.

확인된 사실:

  • 질의 주체 = Envoy의 c-ares (typed_dns_resolver_config: envoy.network.dns_resolver.cares). resolver 주소 미지정 → c-ares가 resolv.conf를 그대로 읽음.
  • sleep/egress gateway pod의 nameserver = 169.254.25.10 (nodelocaldns) → CoreDNS → 외부 upstream.
  • Istio DNS capture(ISTIO_META_DNS_CAPTURE)는 비활성 (meshConfig·pod env 모두 미주입) → istio-agent DNS proxy를 안 거치고 Envoy가 직접 나간다.

DNS capture를 켜면: istio-agent가 53을 가로채(DNS proxy), 메시 내부 호스트는 NDS로 로컬 응답, 외부만 upstream 포워딩 → “질의 위치"가 istio-agent로 바뀐다. 진단 전에 capture 여부를 먼저 봐야 하는 이유.

진단 명령:

# resolver(=ambient DNS) 확인
kubectl -n mesh-test exec deploy/sleep -c istio-proxy -- cat /etc/resolv.conf
# Envoy가 쓰는 resolver 종류
kubectl -n istio-system exec deploy/istio-egressgateway -c istio-proxy -- \
  curl -s "localhost:15000/config_dump?resource=dynamic_active_clusters" \
  | grep -A60 '"outbound|443||httpbin.org"' | grep -i dns_resolver
# DNS capture 활성 여부
kubectl -n istio-system get cm istio -o jsonpath='{.data.mesh}' | grep -i DNS_CAPTURE

핵심 정리

  • resolution이 cluster type을 결정: DNS→STRICT_DNS(A record 전 IP 펼침, Envoy 자체 LB), DNS_ROUND_ROBIN→LOGICAL_DNS(첫 IP 1개, 연결 재사용). endpoint 개수가 결정적 증거.
  • DNS refresh ≠ health check. DNS는 endpoint 발견, outlier detection은 endpoint 축출 — liveness는 outlier detection / active HC의 몫이지 DNS TTL이 아니다.
  • respect_dns_ttl=true면 실제 갱신 주기는 권위 DNS TTL(실측 10s)이지 dns_refresh_rate(60s)가 아니다.
  • egress gateway 경유 시 DNS 질의 주체는 gateway의 Envoy(c-ares), 클라이언트 sidecar가 아니다.
  • TLS Passthrough(L4)에서는 consecutiveLocalOriginFailures가 유일한 자동 회피http.retries·consecutive5xxErrors는 HTTP를 못 봐서 무효(§7).
  • GSLB 뒤 호스트는 LOGICAL_DNS — LB 권한을 DNS에 위임. STRICT_DNS는 GSLB 의도를 무력화. → [2026-07-09 교정] in-flight flip 실측 이후 이 권장은 L7(TLS origination) 경로 한정으로 뒤집혔다 — STRICT가 기본값이고 LOGICAL은 stale pin 리스크다. 근거는 §6 교정 콜아웃 참조.

What you might be missing

  • 갱신 주기를 줄여도 죽은 IP 창은 안 닫힌다: TTL을 10초로 줘도, IP가 죽고 다음 재질의 + 권위 DNS가 빼줄 때까지는 무방비. 능동 축출(outlier)이 없으면 DNS 주기 튜닝은 근본 해법이 아니다(§5).
  • dnsRefreshRate의 좁은 효력: meshConfig 필드지만 respect_dns_ttl=true 외부 cluster에선 TTL이 우선이라 거의 무효다. 실제로는 TTL을 무시하는 cluster나 NDS auto-allocation 경로에만 작동(§4).
  • “silent” 함정: http.retries는 TLS Passthrough에서 에러 없이 그냥 무시된다. proxy-config에 HTTP route가 없고 retry 카운터가 0인지로만 확인 가능(§8.2 (a)(b)).
  • “ambient DNS"는 Istio ambient mesh가 아니다 — 프록시 환경의 기본 resolver(resolv.conf)를 뜻하는 일반어.
  • DNS capture 토글이 질의 위치를 바꾼다: capture ON이면 istio-agent가 53을 가로채 질의 주체가 바뀌므로, 진단 시 capture 활성 여부를 먼저 확인해야 경로 추론이 어긋나지 않는다(§9).

참조


관련 파일 · 참조

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

검증 방법: 공식 문서(istio/api proto, Envoy 문서, istio.io reference) 대조 + homelab 클러스터(istiod 1.30.0) 실측. 본문의 모든 주장은 문헌상 확인되었고, 핵심 주장(cluster type 변환, DNS refresh는 liveness check가 아님, TLS PASSTHROUGH에서 retry 무시)은 클러스터 실측으로 추가 확정되어 본문 수정 없이 유지함. §5·§6의 거동(STRICT drain vs LOGICAL 보존, stale pin)은 자매 랩 dns-gslb-repro-lab이 T92로 라이브 재현함(해당 문서 검증 기록 C13~C16).

주장 판정 근거
C1. resolution: DNS → Envoy STRICT_DNS cluster 변환 ✅ 실측 확인 istio/api service_entry.proto · T49 실측
C2. resolution: DNS_ROUND_ROBIN → Envoy LOGICAL_DNS cluster 변환 ✅ 문헌 확인 istio/api service_entry.proto
C3. STRICT_DNS는 A record 전체 IP를 endpoint로 적재 ✅ 문헌 확인 envoyproxy.io service_discovery
C4. LOGICAL_DNS는 첫 IP만 사용, 연결 유지(draining 없음) ✅ 문헌 확인 envoyproxy.io service_discovery
C5. respect_dns_ttl=truedns_refresh_rate는 TTL=0/실패시만 쓰는 fallback ✅ 문헌 확인 envoyproxy.io cluster.proto
C6. DNS refresh는 liveness check가 아니다(문서의 핵심 명제) ✅ 실측 확인 envoyproxy.io outlier · T50 실측
C7. outlier detection은 기본 off, DestinationRule에 명시해야 opt-in ✅ 문헌 확인 istio.io destination-rule
C8. meshConfig.dnsRefreshRaterespect_dns_ttl=true 외부 cluster에는 거의 무효 ✅ 문헌 확인 istio.io istio.mesh.v1alpha1
C9. LOGICAL_DNS는 GSLB/CDN류 “large web scale services"에 공식 권장 ✅ 문헌 확인 envoyproxy.io service_discovery
C10. TLS PASSTHROUGH(L4)에서 consecutive5xxErrors 무효, consecutiveLocalOriginFailures만 유효 ✅ 문헌 확인 envoyproxy.io outlier
C11. TLS PASSTHROUGH에서 http.retries는 조용히 무시됨 ✅ 실측 확인 istio.io virtual-service · T47 실측
C12. “querying the ambient DNS"의 ambient는 Istio ambient mesh가 아니라 일반어 ✅ 문헌 확인 istio/api service_entry.proto
C13. resolver 미지정 시 Envoy 기본 DNS resolver는 c-ares ✅ 문헌 확인 envoyproxy.io cares_dns_resolver.proto
C14. sidecar DNS capture는 기본 비활성(opt-in), ambient는 1.25+ 기본 활성 ✅ 문헌 확인 istio.io dns-proxy
C15. Envoy/Istio cluster 이름 규칙 direction|port|subset|fqdn ✅ 문헌 확인 istio.io proxy-cmd

Files