🏠 목록 Runbook — ServiceEntry resolution: DNS 동작과 진단 (STRICT_DNS) 📄 MD 원본 🌓 테마
istioegressdnsserviceentryenvoy

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

NOTE

외부 도메인(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 통합 리포트


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개가 이 문서의 뼈대다 — 이 순서대로 읽으면 길을 잃지 않는다:

resolution  ->  cluster type  ->  갱신 주기  ->  죽은 IP  ->  회피 구성
 (어떻게         (STRICT_DNS    (DNS TTL을     (DNS는        (outlier
  IP를 안다)      / LOGICAL)     따라 재질의)    생사 모름)     detection+retry)

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


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 빠지겠지"라는 가장 흔한 오해에 빠진다.


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·연결 재사용)는 전부 이 한 델타에서 따라온다. resolution: DNS(STRICT_DNS) 변환 결과:

ServiceEntry(resolution: DNS, hosts: httpbin.org)
        |  istiod 변환
        v
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초

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 죽음]
client --> egress GW --> [죽은 IP 선택] --X TCP connect 실패
                          +-- retry O      --> 다른 IP 재시도 --> 성공
                          +-- retry X      --> 요청 실패 (503/connect error)

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 참조.


6. GSLB 환경 권장 — LOGICAL_DNS

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

근거: 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에 둘 것인가"의 선택.


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

DNS refresh는 liveness를 모르므로(§5), 죽은 IP 회피는 아래로 명시한다. outlier detection 필드 자체의 일반 의미(consecutive5xxErrors/baseEjectionTime 배수 증가 등)는 circuit-breaking 메커니즘 note가 정본이고, 여기서는 egress 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) 구성으로 전환해야 한다. - 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 시 분기):

egress GW Envoy c-ares; no dns_resolver /etc/resolv.conf ns 169.254.25.10 nodelocaldns 169.254.25.10 CoreDNS (cluster DNS) upstream DNS (recursive) authoritative A record not cluster domain A record back istio-agent DNS proxy :53 capture ON internal: NDS local / external: forward
그림 1. DNS 질의 경로 — 실선=capture OFF 실측(Envoy c-ares가 resolv.conf→nodelocaldns→CoreDNS→upstream→authoritative), 점선=capture ON 시 istio-agent DNS proxy 분기.

핵심: 질의 주체는 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

핵심 정리

What you might be missing

참조


관련 파일 · 참조