Istio Ambient mode 내부 동작 - ztunnel과 waypoint proxy는 실제로 어떻게 패킷을 주고받는가
올해 KubeCon EU에서 가장 많이 들었던 단어 중 하나가 ambient mode였다. 사이드카 없이 mesh를 한다는 컨셉은 매력적이지만, "사이드카가 사라진 자리에서 트래픽은 도대체 어떤 경로를 타는가?"가 제대로 그려지지 않으면 운영이 어렵다. 우리 팀도 4월부터 스테이징 클러스터에 ambient mode를 깔고 한 달 넘게 트래픽 흐름을 들여다봤는데, 사실 내부적으로는 생각보다 단순하지 않다.
이 글은 그 한 달 동안 tcpdump와 ztunnel 로그를 번갈아 보면서 정리한 내용이다. 1.24 기준이고, 일부는 1.25-rc 노트도 섞여 있다.
ztunnel이 뜨는 순간 노드에서 일어나는 일
ambient를 활성화하면 DaemonSet으로 ztunnel이 노드마다 한 개씩 깔린다. 그런데 ztunnel 파드 자체가 트래픽을 가로채는 건 아니다. 진짜 마법은 CNI 플러그인이 kernel level에서 거는 redirection이다.
Istio CNI는 노드 위에 다음 두 가지를 세팅한다.
istioin,istioout이라는 두 개의 logical interface (geneve tunnel 기반)- iptables 룰을 통해 ambient 네임스페이스에 속한 파드의 outbound/inbound 트래픽을 ztunnel로 우회
예를 들어 파드 A가 같은 mesh 안의 파드 B(다른 노드)로 요청을 보낸다고 해보자. 사이드카 시절에는 같은 파드 안의 envoy를 거쳐 나갔는데, ambient에서는 이렇게 흐른다.
파드 A → 노드의 iptables redirect → ztunnel (mTLS 시작) →
HBONE tunnel (포트 15008/TCP, CONNECT) →
대상 노드의 ztunnel (mTLS 종료) → 파드 B
여기서 핵심은 HBONE이다. HTTP/2 CONNECT 기반의 tunnel인데, ztunnel끼리는 항상 이 포트로만 통신한다. L4 트래픽이 HTTP/2 frame 안에 캡슐화되어 흐른다는 게 처음 보면 어색한데, mTLS 핸드셰이크와 멀티플렉싱을 한 채널에서 해결하려고 이렇게 설계됐다. 그래서 tcpdump를 떠도 두 노드 사이엔 15008 포트의 TLS 패킷만 보인다. 트러블슈팅할 때 이게 좀 헷갈렸다.
L4까지만이라는 말의 진짜 의미
ztunnel은 L4까지만 본다고 흔히 말하는데, 정확히는 "L7 파싱을 안 한다"는 뜻이다. 그래도 ztunnel이 처리하는 것은 의외로 많다.
mTLS 종료/시작, SPIFFE identity 검증, L4 AuthorizationPolicy, basic telemetry (TCP byte count, connection 수), 그리고 outbound destination이 mesh 안인지 밖인지를 판단하는 라우팅까지. 이 마지막이 좀 까다로운데, ztunnel은 컨트롤 플레인(istiod)으로부터 받은 워크로드 목록을 가지고 "이 dst IP가 mesh 멤버인가?"를 lookup한다. mesh 멤버면 HBONE으로, 아니면 그냥 평문(또는 다른 정책)으로 보낸다.
이게 운영상 함정이 된다. 컨트롤 플레인과 ztunnel 사이의 워크로드 동기화가 늦으면 새로 뜬 파드로 가는 트래픽이 잠깐 plaintext로 빠지는 구간이 생긴다. 우리는 이걸 Hubble로 잡았는데, 사이드카 시절보다 디버깅 surface가 노드 레벨로 옮겨가서 처음엔 어디를 봐야 할지 막막했다.
waypoint proxy는 언제, 어떻게 끼어드는가
L7 기능(HTTP route, header-based routing, JWT 검증, L7 AuthorizationPolicy 등)이 필요한 service 또는 namespace에만 waypoint를 붙인다. waypoint는 envoy 기반이고 Gateway API의 Gateway 리소스로 정의된다. 사이드카처럼 강제 주입되지 않고, 명시적으로 띄워야 한다는 점에서 운영 모델이 깔끔하다.
waypoint가 있는 서비스로 보내는 트래픽의 흐름은 이렇다.
파드 A → ztunnel(소스 노드) → HBONE →
waypoint proxy (envoy, 서비스마다 1개) →
ztunnel(대상 노드) → 파드 B
체인이 더 길어 보이지만, waypoint가 같은 노드에 있을 필요는 없다는 게 포인트다. waypoint는 별도의 deployment고, 자체적인 HPA를 가질 수 있다. 한 서비스의 트래픽이 폭주해도 그 waypoint만 스케일 아웃하면 된다. 사이드카 모델에선 워크로드 파드 수에 비례해 envoy 개수가 늘었는데, 이제는 트래픽 패턴에 맞춰 별도 튜닝이 가능해졌다.
다만 추가 hop이 생기는 건 분명하다. 우리 스테이징에서 측정해보니 동일 노드 통신에서 평균 P50 레이턴시가 사이드카 대비 0.6ms 늘었고, P99는 1.5ms 정도 증가했다. waypoint가 다른 노드에 있으면 cross-AZ 경우 더 늘어난다. L7 기능이 정말 필요한 서비스에만 붙이라는 권고가 괜히 있는 게 아니다.
사이드카 모델과 비교했을 때 뭐가 달라지는가
자원 측면은 명백하다. 100개 파드짜리 deployment가 있으면 사이드카 모델은 envoy 100개가 떴는데, ambient에선 ztunnel 한 개(노드당)와 waypoint 몇 개(서비스당)로 줄어든다. 메모리/CPU 절감폭은 워크로드에 따라 다른데, 우리 케이스(Java 위주, 파드당 메모리 1Gi+)에서는 envoy sidecar가 차지하던 ~50Mi × 파드 수가 통째로 빠졌다. 클러스터 규모가 클수록 효과가 커진다.
반면 디버깅 모델은 바뀐다. 사이드카 시절엔 "이 파드의 envoy 로그를 보자"가 명확했는데, ambient에선 "이 노드의 ztunnel 로그"와 "이 서비스의 waypoint envoy 로그"를 별개로 봐야 한다. 두 컴포넌트가 컨트롤 플레인과 어떻게 sync되는지 모르면 정합성 문제를 추적하기 어렵다.
또 하나, ambient는 ztunnel이 fail하면 그 노드의 모든 mesh 트래픽이 영향을 받는다. 사이드카는 파드별 격리였는데, ambient는 노드 레벨의 SPOF가 생긴다. 그래서 ztunnel 자체의 readiness probe, restart 정책, PDB를 신경 써야 한다. 우리는 ztunnel DaemonSet에 priorityClass를 system-node-critical로 두고, 노드당 메모리 limit을 넉넉하게 잡아뒀다.
마이그레이션을 고민한다면
스테이징에서 한 달 굴려본 소감을 정리하자면, ambient는 "사이드카를 그냥 갈아끼우는 것"이 아니라 트래픽 모델 자체를 새로 설계하는 작업에 가깝다. 어떤 서비스가 L7이 필요한지(즉 waypoint가 필요한지)를 먼저 정리해야 하고, 그 과정에서 기존 VirtualService/DestinationRule을 Gateway API 리소스로 다시 써야 하는 경우가 많다.
우리 팀은 결국 production 적용은 보류했다. 이유는 두 가지인데, 첫째로 ztunnel의 워크로드 sync 지연이 가끔 보였고 (재현이 일정하지 않아 추적 중), 둘째로 사내 L7 정책이 envoy filter chain에 의존하는 게 많아서 waypoint로 옮기는 비용이 크다. 1.25에서 sync 메커니즘 개선이 들어온다고 하니, 그걸 검증한 다음에 다시 보려고 한다.
사이드카에서 ambient로 가는 길이 일방통행은 아니다. 같은 클러스터에서 namespace별로 두 모델을 섞어 쓸 수 있다(istio.io/dataplane-mode 라벨로 제어). 점진적으로 옮길 수 있다는 점이 그나마 위안이다.
다음에는 ztunnel의 워크로드 sync 지연 이슈를 추적한 과정을 따로 정리해보려고 한다. 혹시 production에서 ambient 굴리고 계신 분이 있다면, 어떤 운영 이슈를 겪었는지 듣고 싶다.