Skip to main content
  1. Archives/

Ztunnel DNS Capture 동작원리 및 ServiceEntry IP-autoallocate

·1230 words

Istio Ambient mode에서 ServiceEntry 를 사용하기 위해서는 2가지 옵션을 켜야합니다.

  1. istio-cni values.yaml 에서 values.cni.ambient.dnsCapture=true
  2. values.pilot.env.PILOT_ENABLE_IP_AUTOALLOCATE=true

이 기능은 Istio 1.25부터 default로 활성화되어 있습니다. 이를 이용하면, ServiceEntry 를 이용할 수 있게되지만, key에 명시되어 있는 것처럼 DNS를 capture 한다는 것은 서비스 메시 운영에 큰 임팩트를 끼칠 수 있는 operation처럼 보입니다.

해당 문서에는 해당 Ambient mode에서 ServicEntry 사용을 위해 이 dnsCapture가 왜 필요한지 설명하고, Ztunnel DNS Capture가 어떻게 동작하는지 서술합니다.

How Ztunnel capture DNS #

values.cni.ambient.dnsCapture=true 옵션을 이용해서, istio-cni는 이제 모든 DNS 요청 또한 ztunnel 프록시의 15053 포트로 리디렉션합니다.

Ztunnel의 DNS proxy는 먼저 ServiceEntry 매핑과 Kubernetes 서비스에 정보를 확인합니다. 일치하는 항목을 찾으면 실제 클러스터 IP 또는 240.240.0.0/16(Class E Subnet) 범위에서 자동 할당된 Virtual IP를 반환합니다. 알 수 없는 도메인의 경우, (pod’s) /etc/resolv.conf 설정에 따라 upstream DNS server(CoreDNS)로 요청을 전달합니다. 이 아키텍처는 세 가지 이점을 제공합니다:

  • 로컬 캐싱을 통한 DNS 지연 시간 감소
  • CoreDNS 부하 감소
  • 별도의 DNS 서버 설정 없이 ServiceEntry 해결

왜 IP-Autoallocate? #

DNS capture와 IP 자동 할당(PILOT_ENABLE_IP_AUTOALLOCATE로 제어) 간의 관계는 ambient mode의 근본적인 제약에 대한 해결책을 나타냅니다: ztunnel은 Layer 4에서만 작동하여 IP addr, protocol,만 처리하고 HTTP header나 hostname에 접근할 수 없습니다.

이 제한은 External TCP service에 심각한 제약을 가집니다. 예를 들어, 두 개의 MySQL 데이터베이스가 ServiceEntry 리소스로 정의되고 둘 다 포트 3306을 사용하는 경우를 생각해봅시다. Sidecar 모드에서 Envoy는 HTTP Host 헤더나 SNI를 사용하여 구별할 수 있습니다. 그러나 ztunnel의 Layer 4 작동은 IP:Port 조합만으로 listener를 생성할 수 있습니다. 고유한 IP 주소가 없으면 두 서비스로 라우팅되는 Istio Service object를 생성할 수 없습니다.

IP-Autoallocate는 명시적 주소가 없는 각 ServiceEntry에 고유한 가상 IP를 할당하여 이를 해결합니다. pilot-discovery 컨트롤러는 예약된 Class E 서브넷(IPv4의 경우 240.240.0.0/16)에서 할당하고 이러한 주소로 ServiceEntry 상태를 업데이트합니다. 애플리케이션이 이러한 서비스의 DNS를 쿼리하면 ztunnel DNS 프록시가 자동 할당된 IP를 반환하여 명확한 라우팅을 위한 240.240.0.1:3306240.240.0.2:3306과 같은 고유한 listener를 가능하게 합니다.

Sidecar vs Ambient mode (w/ ServiceEntry) #

ServiceEntry DNS 해결이 사이드카와 ambient 모드 간에 어떻게 작동하는지에 대한 중요한 아키텍처 차이가 존재합니다:

Reference: https://istio.io/latest/docs/ops/configuration/traffic-management/dns/

Sidecar 모드에서:

  • Envoy 사이드카가 아웃바운드 트래픽을 가로채고 DNS 해결을 수행
  • 앱이 외부 서비스 DNS를 쿼리할 때 사이드카는 다음을 사용하여 해결 가능:
    • resolution: DNS - Envoy가 주기적으로 DNS 조회 수행 (30초마다)
    • resolution: STATIC - 사전 정의된 엔드포인트 사용
    • resolution: NONE - 원본 대상 IP 통과
  • 사이드카는 L7 속성(Host 헤더, SNI)을 사용하여 요청 매칭 가능

Ambient 모드에서:

  • Ztunnel DNS 프록시가 트래픽이 pod를 떠나기 전에 ServiceEntry 도메인을 해결해야 함
  • L4에서 라우팅이 올바르게 작동하도록 Virtual IP를 활용함
  • 실제 외부로 라우팅은 ServiceEntry를 attach한 Envoy(Istio Waypoint or Gateway)가 담당합니다.
    • e.g. Waypoint envoy listener config

service-entry-envoy-config

Ztunnel DNS resolve 작동 방식 #

Source code: https://github.com/istio/ztunnel/tree/master/src/dns

  • server.rs : Main DNS 서버 구현, TCP/UDP socket for DNS

    • get_aliases()

      // src/dns/server.rs: 227
      
          /// Enumerates the possible aliases for the requested hostname
          fn get_aliases(&self, client: &Workload, name: &Name) -> Vec<Alias> {
              let mut out = Vec::new();
              let mut added = HashSet::new();
      
              let mut add_alias = |alias: Alias| {
                  if !added.contains(&alias.name) {
                      added.insert(alias.name.clone());
                      out.push(alias);
                  }
              };
      
              // Insert the requested name.
              add_alias(Alias {
                  name: name.clone(),
                  stripped: None,
              });
      
              let namespaced_domain = append_name(as_name(&client.namespace), &self.svc_domain);
      
              // If the name can be expanded to a k8s FQDN, add that as well.
              for kube_fqdn in self.to_kube_fqdns(name, &namespaced_domain) {
                  add_alias(Alias {
                      name: kube_fqdn,
                      stripped: None,
                  });
              }
      
    • get_addresses()

      // src/dns/server.rs: 437
      
          /// Gets the list of addresses of the requested record type from the server.
          fn get_addresses(
              &self,
              client: &Workload,
              server: &Address,
              record_type: RecordType,
          ) -> Vec<IpAddr> {
              let mut addrs: Vec<IpAddr> = match server {
                  Address::Workload(wl) => wl
                      .workload_ips
                      .iter()
                      .filter_map(|addr| {
                          if is_record_type(addr, record_type) && self.record_type_enabled(addr) {
                              Some(*addr)
                          } else {
                              None
                          }
                      })
                      .collect(),
                  Address::Service(service) => {
                      if service.vips.is_empty() {
                          // Headless service. Use the endpoint IPs.
                          let workloads = &self.state.read().workloads;
                          service
                              .endpoints
                              .iter()
                              .filter_map(|ep| {
                                  let Some(wl) = workloads.find_uid(&ep.workload_uid) else {
                                      debug!("failed to fetch workload for {}", ep.workload_uid);
                                      return None;
                                  };
                                  wl.workload_ips.iter().copied().find(|addr| {
                                      is_record_type(addr, record_type) && self.record_type_enabled(addr)
                                  })
                              })
                              .collect()
                      } else {
                          // "Normal" service with VIPs.
                          // Add service VIPs that are callable from the client.
                          service
                              .vips
                              .iter()
                              .filter_map(|vip| {
                                  if is_record_type(&vip.address, record_type)
                                      && client.network == vip.network
                                      && self.record_type_enabled(&vip.address)
                                  {
                                      Some(vip.address)
                                  } else {
                                      None
                                  }
                              })
                              .collect()
                      }
                  }
              };
      
              // Randomize the order of the returned addresses.
              addrs.shuffle(&mut rng());
      
              addrs
          }
      
  • handler.rs : Main request handler

  • resolver.rs : Rust trait for DNS resolution

  • forwarder.rs : Forward to upstream DNS server

  • metrics.rs : Istio 내 dns 관련 metrics 집계

Mermaid editor online

graph TD A[Pod DNS Query] -->|UDP/TCP| B[DNS Server
src/dns/server.rs:62-187] B --> C[DNS Handler
src/dns/handler.rs:32-121] C -->|handle_request| D{Query Type
Supported?} D -->|No| E[Forward to Upstream
src/dns/forwarder.rs:33-121] D -->|Yes A/AAAA| F[Store::lookup
src/dns/server.rs:540-713] F --> G[get_aliases
src/dns/server.rs:227-281] G -->|Expand names| H[find_server
src/dns/server.rs:342-428] H --> I{Service Found?} I -->|No| E I -->|Yes| J{Service Type} J -->|ServiceEntry| K[Return VIP
from XDS cache] J -->|K8s Service| L{Has VIP?} L -->|Yes| M[get_addresses
src/dns/server.rs:437-498] L -->|No Headless| N[Get Endpoint IPs
from Workloads] M --> O[Filter by Network
& Record Type] N --> O K --> O O --> P[Create DNS Records
src/dns/server.rs:673-712] P --> Q[Return Answer
Authoritative=true] E --> R[Upstream DNS
/etc/resolv.conf] R --> S[Cache Response
by TTL] S --> T[Return Answer
Authoritative=false] Q --> U[Pod Receives
DNS Response] T --> U style B fill:#f9f,stroke:#333,stroke-width:4px style F fill:#bbf,stroke:#333,stroke-width:4px style H fill:#bbf,stroke:#333,stroke-width:4px

Gateway API HTTPRoute에서 ServiceEntry 활용하기 #

Istio Ambient Mesh 환경에서 외부 서비스(External Service)에 안전하게 트래픽을 보내야 할 때 가장 표준적인 방식은 ServiceEntry 리소스를 활용하는 방법입니다.

이 문서는 실제 Istio ambient + Gateway API 기반 트래픽 관리에서, HTTPRoute의 backendRefs를 통해 외부 URL로 트래픽을 리라우팅하는 방법을 중심으로 설명합니다.

특히, Istio 소스코드(Go)에서만 “존재”하는 특수한 backendRefs 값인 kind: Hostname 활용법과, 해당 리소스가 실제로는 Kubernetes CRD가 아니라 Istio 코드베이스 내부에서 별도 분기 처리되는 방식에 소스코드를 살펴보겠습니다.

1. External Service 트래픽 관리 기본 개념 #

Istio는 다음 두 가지 방법 중 하나로 외부 트래픽을 식별/관리합니다.

  1. Kubernetes ExternalName 서비스
  2. Istio ServiceEntry (권장 방식)

ServiceEntry를 사용하면, 외부 서비스의 호스트명·포트·프로토콜 등 트래픽 제어 정책을 Istio가 완전히 인지하고 mesh 내부 서비스처럼 트래픽 정책을 통합적으로 제어할 수 있습니다.

Istio에서는 K8s Service 이외에도 Istio가 인지하고 있는 Istio Service가 별도로 존재합니다. ServiceEntry는 해당 object에 외부 호스트명 or IP를 등록합니다.


2. Ambient Mesh + Gateway API 환경에서 외부 서비스 연동 #

2.1. HTTPRoute에서 backendRefs로 외부 URL 라우팅하기 #

Ambient 모드에서 트래픽 라우팅의 핵심은 HTTPRoute 리소스다.

다음 예시는 두 가지 HTTPRoute를 보여준다.

  • 내부 서비스로 라우팅 (일반적인 Service Reference)
backendRefs:
  - name: httpbin
    port: 80
  • 외부 도메인(api.channel.io)으로 라우팅 (Hostname Reference + ServiceEntry 필요)
backendRefs:
  - group: networking.istio.io
    kind: Hostname
    name: api.channel.io
    port: 443
---
# backendRefs[0].name에 있는 hostname을 `ServiceEntry`로 꼭 등록해야함
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
  name: external-api
spec:
  hosts:
    - api.channel.io
  location: MESH_EXTERNAL
  ports:
    - number: 443
      name: https
      protocol: HTTPS
  resolution: DNS_ROUND_ROBIN

주의: 여기서 group: networking.istio.io, kind: Hostname은 Kubernetes CRD에서 정의되는 리소스가 아닙니다. Istio 코드 내부(Go)에서 특수하게 하드코딩되어 있는 타입이며, HTTPRoute가 외부 호스트명을 인식/사용할 수 있게 해준다.

2.2. Istio 코드에서의 backendRefs 동작 방식 #

아래는 pilot/pkg/config/kube/gateway/conversion.go 코드에서 발췌한 일부입니다.

case config.GroupVersionKind{Group: gvk.ServiceEntry.Group, Kind: "Hostname"}:
    if to.Namespace != nil {
        return nil, nil, &ConfigError{Reason: InvalidDestination, Message: "namespace may not be set with Hostname type"}
    }
    hostname = string(to.Name)
    if ctx.LookupHostname(hostname, namespace) == nil {
        invalidBackendErr = &ConfigError{Reason: InvalidDestinationNotFound, Message: fmt.Sprintf("backend(%s) not found", hostname)}
    }
  • kind: Hostname이 오면, 단순히 .name 필드(즉, 호스트명 문자열)를 그대로 받아서 사용.
  • 실제로는 ServiceEntry로 등록된 host 인지 ctx.LookupHostname으로 검증.
  • 즉, ServiceEntry 리소스가 반드시 먼저 존재해야 backendRefs에 해당 host를 쓸 수 있음.

kind: Service일 때는 반대로 K8s 서비스 이름을 기반으로 FQDN을 만들어 처리.

참고: 실제 트래픽 흐름

  • Gateway/Waypoint → HTTPRoute backendRefs (Hostname) → ServiceEntry host 등록 → 외부 도메인으로 HTTPS 트래픽 요청.

3. 정리 및 권장 사용법 #

  • 외부 서비스 호출은 반드시 ServiceEntry를 통해 등록해야 합니다.
  • HTTPRoute의 backendRefs에서 외부 도메인 사용 시, 반드시 group: networking.istio.io, kind: Hostname 사용. 이때 .name에 ServiceEntry에 등록한 host와 정확히 동일해야 합니다.
  • kind: Hostname은 실제 K8s CRD가 아니라 Istio 코드에서 하드코딩된 타입임을 주의.
  • k8s 서비스(kind: Service)와는 처리 방식이 다릅니다.