Istio 2편: Envoy config로 해부하는 Ambient mode
Jetty, 정재홍 • DevOps Engineer
- 엔지니어링
안녕하세요, 채널코퍼레이션 DevOps팀 엔지니어 재티, 딜런입니다.
1편에서는 Ambient mode의 구성요소와 동작 원리를 간략하게 다뤘습니다. HBONE이 HTTP/2 CONNECT와 mTLS의 조합이라는 것, ztunnel이 트래픽을 transparently redirect한다는 것까지 설명했지만, 실제로 이것이 어떻게 구현되어 있는지는 다루지 않았습니다.
이번 글에서는 Envoy config를 직접 들여다보면서, 1편에서 설명한 개념들이 어떻게 구현되어 있는지 살펴보겠습니다. HTTP 요청 하나가 Istio Gateway에서 Pod까지 전달되는 과정을 Envoy config를 이용하여 추적합니다.
2편: Envoy config로 해부하는 Ambient mode (현재 글)
3편: 프로덕션에서 만난 당황스러운 이슈들과 트러블슈팅
Envoy 기본 구조
Listener → Route → Cluster → Endpoint
Envoy의 내부 구조를 이미 알고 계신 분은 이 섹션을 건너뛰셔도 됩니다.
Istio에서 Gateway, Sidecar, Waypoint는 역할에 따라 구분되지만, 실제로는 서로 다른 설정이 주입된 Envoy Proxy입니다. Envoy 동작을 파악하기 위해서는 먼저 Envoy가 요청을 처리하는 기본 흐름을 이해해야 합니다.
Envoy는 요청을 다음 순서로 처리합니다.
Listener: 특정 포트에서 트래픽을 수신하고, 어떤 Filter Chain으로 처리할지 결정합니다.
Filter Chain: 수신한 트래픽에 대한 처리 로직을 정의합니다. HTTP 트래픽의 경우
HttpConnectionManager가 대표적입니다.Route: Virtual Host 기반으로 요청을 어떤 Cluster로 보낼지 결정합니다. 도메인 이름과 경로 패턴을 보고 라우팅합니다.
Cluster: 같은 서비스를 제공하는 endpoint들의 논리적 그룹입니다. 로드밸런싱 정책(Round Robin, Least Request 등)이 여기에 해당됩니다.
Endpoint: 실제 트래픽이 전달되는 최종 목적지입니다. 일반적으로 Pod의
IP:Port조합입니다.
Istio에서는 이 설정들을 istiod(control plane)가 xDS API를 통해 각 Envoy proxy로 전파합니다. Pod가 추가/삭제되거나 라우팅 정책이 변경되는 등 쿠버네티스의 클러스터 상태가 바뀔 때마다 istiod가 각 Envoy에 변경된 config를 전달합니다.
Gateway의 Envoy config 따라가기
예시로 "api.channel.io"로 들어온 HTTP 요청이 Gateway를 통과해 Pod(e.g. ch-dropwizard-public)까지 전달되는 과정을 단계별로 추적해보겠습니다. HTTP 요청의 전체 흐름을 먼저 설명하겠습니다. 세부 사항은 이후 각 단계에서 설명합니다.
전체 아키텍처 개요
위 그림은 현재 채널팀의 Istio Gateway 구성입니다. Public Internet에서 받는 HTTP 요청은 AWS ALB → Istio Gateway → Istio Waypoint 순서로 destination Pod에 도달합니다.
여기에서 유의해야 할 점은 위 아키텍처가 Istio Ambient mode의 기본 동작은 아니라는 점입니다. Istio Ambient mode의 기본 동작에서는 Istio Gateway가 destination Pod의 ztunnel을 통해 직접 backend Pod으로 트래픽을 전달합니다. 즉, 기본 설정에서 north-south 트래픽은 Gateway → Waypoint → Pod경로가 아닌 Gateway → Pod 경로로 트래픽이 전달됩니다. Waypoint proxy는 본래 in-mesh Pod 간의 east-west 트래픽에서 L7 정책을 적용하기 위해 존재하는 컴포넌트입니다.
만약, Gateway에서 Waypoint을 경유하도록 하려면
istio.io/use-waypointlabel과 함께istio.io/ingress-use-waypoint=true레이블을 명시적으로 추가해야 합니다.
그러면 왜 채널팀은 기본 동작을 쓰지 않고 Waypoint을 경유하도록 했을까요? 기본 동작을 사용하면 public 트래픽(north-south)과 in-mesh 트래픽(east-west)에 대해 routing policy는 각각 다른 곳에서 적용됩니다(Gateway에서 한 번, Waypoint에서 한 번). 동일한 서비스에 대해 라우팅 정책을 두 곳에서 중복 관리해야 하는 인지 부하가 생긴다고 판단했습니다.
저희는 이 문제를 해결하기 위해 역할을 명확히 분리하는 방식을 채택했습니다. Gateway는 hostname 매칭과 같은 edge 역할만 담당하고, routing policy와 L7 정책은 모두 waypoint에서 통합 관리합니다. 이를 위해 istio.io/ingress-use-waypoint=true를 기본적으로 사용하며, 이 글에서도 Gateway → Waypoint → Pod 경로를 기준으로 Envoy config를 설명합니다.
더 자세한 내용은 Ambient Mesh docs를 확인해주세요: https://ambientmesh.io/docs/setup/configure-waypoints/#istio-ingress-gateway
위 다이어그램은 Gateway Envoy에서 요청이 처리되는 전체 흐름을 정리한 것입니다. Listener에서 시작해 Route → Cluster → Endpoint 순서로 진행되며, destination의 mesh 참여 상태에 따라 Endpoint 단계에서 경로가 갈립니다. 이제 각 단계의 실제 Envoy config를 하나씩 살펴보겠습니다.
앞으로 다룰 Envoy config는 istioctl proxy-config 명령어로 확인할 수 있습니다.
1. Active Listener: 가상호스트 매칭과 라우팅
Gateway의 0.0.0.0:443 리스너가 HTTP 요청을 수신합니다. 이 리스너의 Filter Chain에는 HttpConnectionManager가 설정되어 있고, RDS(Route Discovery Service)를 통해 http.443 이름의 route config를 동적으로 받아옵니다.
Route config 안에는 Virtual Host 목록이 있고, 요청의 Host 헤더와 매칭되는 Virtual Host를 찾아 해당 Cluster로 라우팅합니다.
여기까지는 일반적인 Envoy proxy와 크게 다르지 않습니다. api.channel.io로 들어온 요청은 outbound|8080||ch-dropwizard-public.channel.svc.cluster.local 클러스터로 라우팅됩니다. 이 클러스터 이름은 Istio가 자동으로 생성하는 convention으로, outbound|{port}||{service}.{namespace}.svc.cluster.local 형식입니다.
2. Endpoint 선택과 Transport Socket
클러스터가 결정되면 다음은 해당 클러스터 내 어떤 Endpoint로 보낼지 결정하는 단계입니다. 여기가 Ambient mode에서 가장 흥미로운 지점입니다. 같은 클러스터(outbound|8080||ch-dropwizard-public)인데, destination Pod의 mesh 참여 상태에 따라 endpoint 설정이 완전히 달라집니다.
가장 간단한 케이스부터 시작해서, 하나씩 살펴보겠습니다.
Destination이 Out-of-mesh인 경우
먼저 mesh에 참여하지 않는 Pod로 향하는 경우입니다.
socket_address를 그대로 사용하여 Pod IP로 직접 연결합니다. transport_socket_match에 tunnel 키가 없으므로 매칭 규칙에 따라 default인 tlsMode-disabled(RawBuffer)가 적용됩니다. HBONE 터널링 없이 plaintext로 직접 전달되며, internal listener를 거치지 않습니다.
Destination이 in-mesh인 경우
이제 destination Pod이 mesh에 포함된 경우를 보겠습니다. out-of-mesh와 비교하면 구조가 확연히 달라집니다.
처음 보면 낯선 구조입니다. 하나씩 짚어보겠습니다.
envoy_internal_address는 실제 네트워크 주소가 아니라, Envoy 내부의 user space 통신을 가리킵니다. server_listener_name: connect_originate는 Envoy 내부에 존재하는 connect_originate라는 이름의 internal listener로 연결하겠다는 의미입니다. 즉, 트래픽이 네트워크 밖으로 나가는 게 아니라 Envoy 내부의 다른 listener로 전달되는 것입니다.
metadata 쪽도 중요합니다. envoy.transport_socket_match에 tunnel: http가 설정되어 있으면, 클러스터의 transport_socket_matches 규칙에 따라 InternalUpstreamTransport라는 특별한 transport socket이 선택됩니다. 이 socket은 envoy.filters.listener.original_dst 네임스페이스의 메타데이터를 internal listener로 전달하는 역할을 합니다. 결과적으로 실제 destination 주소(local: 10.90.165.200:8080)가 internal listener까지 도달할 수 있게 됩니다.
Destination(in-mesh)에 Waypoint가 설정된 경우
마지막으로 mesh에 포함된 destination에 waypoint proxy가 설정된 경우입니다. 기본 구조는 in-mesh와 비슷하지만, 메타데이터에 waypoint 키가 추가됩니다.
in-mesh와의 핵심 차이는 original_dst 메타데이터에 waypoint 키가 추가된다는 것입니다. endpoint_id가 waypoint proxy의 주소를 가리키고, workload 메타데이터도 istio-waypoint를 가리킵니다. local 값은 최종 Pod IP가 아닌 Service ClusterIP(172.20.134.88:8080)입니다. 최종 Pod 선택은 waypoint가 담당하기 때문입니다. 결과적으로 Gateway → HBONE → waypoint → HBONE → 목적지 Pod 순서로 트래픽이 흐릅니다.
정리하면, 세 케이스 모두 같은 클러스터를 사용하지만 endpoint의 메타데이터와 transport socket 설정에 따라 트래픽 경로가 완전히 달라집니다. 이 분기의 핵심은 transport_socket_match입니다.
3. Internal Listener와 HBONE 터널링
in-mesh(또는 Waypoint) endpoint에서 envoy_internal_address를 통해 connect_originate internal listener로 넘어온 이후의 흐름입니다. 이 과정이 바로 1편에서 설명한 "HBONE"의 실제 구현입니다.
connect_originate 리스너는 internal_listener로 선언됩니다. 일반 listener와 달리 네트워크 포트를 열지 않고 Envoy 내부 user space에서만 동작합니다.
먼저 original_dst listener filter가 이전 단계에서 InternalUpstreamTransport를 통해 전달된 메타데이터를 읽습니다. 그 다음 tcp_proxy 필터가 connect_originate 클러스터로 연결하면서, tunneling_config에 설정된 hostname을 HTTP/2 CONNECT 요청의 :authority 헤더로 사용합니다.
connect_originate 클러스터는 ORIGINAL_DST 타입의 특수한 클러스터입니다.
이 클러스터는 EDS(Endpoint Discovery Service)를 사용하지 않고, 다운스트림 connection 메타데이터로부터 upstream host를 동적으로 결정합니다. upstream_port_override: 15008로 포트를 ztunnel의 HBONE 수신 포트로 override합니다. transport socket에는 UpstreamTlsContext가 설정되어, ALPN h2(HTTP/2)와 SPIFFE ID 기반 mTLS 인증이 적용됩니다.
Waypoint 케이스에서는 original_dst_lb_config에 metadata_key가 추가로 설정되어, 앞서 endpoint에서 전달한 waypoint 메타데이터를 읽어 destination address를 waypoint 주소로 동적 override합니다. 이때 :authority 헤더에는 원래 의도한 destination(Service ClusterIP)이 설정되어, waypoint가 이를 보고 최종 Pod로 라우팅할 수 있습니다.
전체 과정을 다이어그램으로 정리하면 다음과 같습니다.
1편에서 "HBONE은 이름만 보면 복잡한 별도의 프로토콜처럼 느껴지지만, 실체는 Envoy의 기존 기능들을 조합한 것"이라고 했습니다. 이제 그 조합이 구체적으로 무엇인지 확인한 셈입니다. InternalUpstreamTransport로 메타데이터를 전달하고, tcp_proxy의 tunneling_config으로 HTTP/2 CONNECT 요청을 생성하고, UpstreamTlsContext로 mTLS를 수립하는 것. 이 세 가지가 HBONE의 핵심입니다.
ztunnel의 트래픽 리다이렉션: iptables와 크로스 네임스페이스 소켓
위에서는 Gateway에서 destination node의 ztunnel까지 트래픽이 도달하는 과정을 봤습니다. 이제 반대쪽, 즉 ztunnel이 어떻게 Pod의 트래픽을 intercept할 수 있는지 살펴보겠습니다.
1편에서는 "istio-cni가 iptables 규칙을 주입하고, ztunnel이 트래픽을 투명하게 리다이렉트한다"고만 설명했는데, 실제 구현을 들여다보면 생각보다 정교한 메커니즘이 작동하고 있습니다.
istio-cni의 역할과 ztunnel 소켓 생성
ztunnel은 Pod와는 별개의 DaemonSet입니다. 그런데 어떻게 Pod의 트래픽을 가로챌 수 있을까요? 핵심은 ztunnel이 Pod의 네트워크 네임스페이스 안에 직접 소켓을 생성한다는 것입니다.
이 과정은 세 컴포넌트의 협력으로 이루어집니다.
istio-cni plugin(바이너리)은 체인드 CNI 플러그인으로 설치되어, Pod 생성 이벤트를 감지하고 이를 istio-cni node agent로 전달합니다. istio-cni node agent가 실제 네트워크 설정을 담당합니다. Pod의 네트워크 네임스페이스에 진입하여 iptables 규칙을 설정하고, Unix Domain Socket을 통해 ztunnel에게 Pod 정보와 네트워크 네임스페이스 파일 디스크립터(FD)를 전달합니다.
ztunnel은 전달받은 네트워크 네임스페이스 FD를 이용해 Linux 저수준 소켓 API로 Pod 네임스페이스 내에 직접 listening 소켓을 생성합니다. 이것이 1편에서 언급한 "in-pod ztunnel"의 실체입니다. Pod 안에서 보면 localhost의 15001/15006/15008에 listening 소켓이 있지만, 이 소켓은 ztunnel DaemonSet 프로세스가 소유합니다.
https://istio.io/latest/docs/ambient/architecture/traffic-redirection/
결과적으로 위와 같은 구조가 만들어집니다.
1편에서 "리다이렉션의 실제 대상은 ztunnel 컨테이너가 아니라 ztunnel과 연결된 Pod 내부 socket이다"라고 언급했는데, 이제 그 메커니즘이 구체적으로 보입니다. ztunnel 프로세스는 Node level에서 동작하지만, 크로스 네임스페이스 소켓 생성을 통해 Pod 네트워크 안에 발을 걸치고 있는 것입니다.
iptables 규칙 분석
istio-cni node agent가 Pod 네트워크 네임스페이스에 설정하는 실제 iptables 규칙을 살펴보겠습니다.
규칙이 복잡해 보이지만, 크게 Ingress(들어오는 트래픽)와 Egress(나가는 트래픽) 두 방향으로 나누면 이해하기 쉬워집니다.
169.254.7.127은 kubelet health check probe를 구분하기 위한 SNAT IP입니다. (cni/pkg/nodeagent/options.go:44)
Ingress: ISTIO_PRERT 체인
Pod으로 들어오는 모든 TCP 트래픽은 PREROUTING 단계에서 ISTIO_PRERT 체인을 거칩니다.
Egress: ISTIO_OUTPUT 체인
Pod에서 나가는 모든 TCP 트래픽은 OUTPUT 단계에서 ISTIO_OUTPUT 체인을 거칩니다.
모든 TCP 송신 트래픽은 ztunnel의 15001 포트로 REDIRECT됩니다. ztunnel은 여기서 HBONE 캡슐화를 적용한 후 목적지로 전송합니다.
패킷 마킹
여기서 중요한 것은 패킷 마킹입니다. ztunnel은 자신이 보내는 패킷에 0x539 mark를 설정합니다. 이 mark는 두 가지 역할을 수행합니다.
ztunnel이 보낸 패킷이 다시 iptables에 의해 ztunnel로 리다이렉트되는 무한루프를 방지합니다. Ingress(
ISTIO_PRERT)와 Egress(ISTIO_OUTPUT) 모든 REDIRECT 규칙에! --mark 0x539조건이 걸려 있어, ztunnel이 보낸 패킷은 리다이렉트를 건너뜁니다.CONNMARK를 통해 응답 패킷의 리다이렉트도 방지합니다. ztunnel이 앱에 트래픽을 전달할 때(mark 0x539),PREROUTING의 mangle 테이블에서 해당 커넥션에0x111connmark가 기록됩니다. 이후 앱이 같은 커넥션으로 응답을 보내면, OUTPUT의 mangle 테이블에서 connmark0x111이 패킷 mark로 복원되고, nat 테이블의 mark0x111 → ACCEPT규칙에 의해 REDIRECT 없이 통과합니다. 즉, ztunnel이 시작한 커넥션의 응답은 ztunnel을 다시 거치지 않고 바로 나갑니다.
정리하면:
0x539(packet mark): ztunnel이 직접 보내는 패킷 식별 → 리다이렉트 방지0x111(connection mark): ztunnel이 시작한 커넥션의 응답 패킷 식별 → 리다이렉트 방지
정리: 요청 하나의 전체 여정
지금까지 각 단계별로 살펴본 내용을 하나의 흐름으로 조합해 보겠습니다.
Client 요청이 Gateway Listener에 도착하면, Route에서 Virtual Host 매칭으로 Cluster를 결정합니다. Cluster의 Endpoint가 envoy_internal_address로 설정되어 있으면 Envoy 내부의 connect_originate Internal Listener로 연결됩니다. Internal Listener의 TCP Proxy가 tunneling_config으로 HTTP/2 CONNECT 요청을 생성하고, UpstreamTlsContext로 mTLS를 수립하여 HBONE 터널을 완성합니다. 이 터널을 통해 Destination node의 Pod에 도달한 트래픽은, Pod 네트워크 네임스페이스 내에서 직접 listening하고 있는 ztunnel의 15008 소켓이 수신하여 HBONE을 디캡슐레이션한 뒤, 최종 Application Pod에 전달됩니다.
1편에서 개념적으로 설명했던 HBONE, ztunnel, traffic redirection이 실제로는 Envoy의 internal listener, transport socket match, tunneling config, 그리고 iptables REDIRECT라는 구체적인 메커니즘으로 구현되어 있음을 확인했습니다.
다음 글 예고
이 글에서는 정상적인 요청 흐름을 Envoy config 수준에서 추적했습니다. 다음 글에서는 Ambient mode를 프로덕션에 적용하면서 만났던 이슈들, 특히 503 에러와 Half-Open(stale) Connection 문제를 어떻게 추적하고 해결했는지 다루겠습니다.
이 글은 Istio Ambient Mode 도입기 시리즈의 두 번째 글입니다.
2편: Envoy config로 해부하는 Ambient mode (현재 글)
3편: 프로덕션에서 만난 당황스러운 이슈들과 트러블슈팅
