Istio Ambient의 ztunnel 뜯어보기: HBONE 터널링과 노드 단위 L4
Istio Ambient mode가 1.24에서 GA 된 게 벌써 1년 반 전이다. 우리 팀도 사이드카 지옥에서 벗어나려고 작년 말부터 조금씩 옮겨왔는데, 처음에는 그냥 "사이드카가 사라진 서비스 메시" 정도로 이해하고 시작했다. 그러다 프로덕션에서 이상한 이슈 몇 개 만나면서 결국 ztunnel 내부를 뜯어보게 됐다. 이 글은 그 과정에서 정리한 내용이다.
한 가지 미리 얘기하자면, 이 글은 "Ambient가 좋다/나쁘다" 얘기는 아니다. 내부 동작이 어떻게 되어 있는지, 그리고 왜 그렇게 설계됐는지에 대한 노트에 가깝다.
ztunnel은 왜 노드마다 하나만 뜨는가
Ambient mode의 첫 번째 특징은 데이터 플레인이 두 층으로 쪼개져 있다는 것이다. L4(TCP, mTLS)는 ztunnel이, L7(HTTP 라우팅, JWT 검증 등)은 waypoint proxy가 처리한다. 사이드카 시절에는 이 둘이 한 Envoy 안에 다 들어 있었다.
ztunnel은 노드당 하나씩 DaemonSet으로 뜬다. 이게 왜 중요하냐면, 사이드카 방식에서는 Pod 하나마다 Envoy가 하나씩 붙어서 메모리를 잡아먹었다. 100개 Pod가 있는 노드에서 사이드카 Envoy만 100개다. 각각 최소 40~60MB씩 잡는다고 치면 그것만 5GB 가까이 된다. Ambient에서는 ztunnel 하나가 그 노드 전체를 담당하므로 이 오버헤드가 사라진다.
그런데 여기서 재미있는 게, ztunnel은 Envoy가 아니라 Rust로 새로 쓴 별도 프로세스다. 왜 Envoy를 안 쓰고 새로 만들었느냐. 이유는 L7이 필요 없기 때문이다. L4 mTLS와 정책 결정만 하면 되므로 Envoy 같은 무거운 것을 안 써도 된다. Rust로 쓴 이유는 낮은 메모리 풋프린트를 유지하면서 안전성을 챙기려는 것으로 보인다. 실제로 우리 클러스터에서 ztunnel 한 인스턴스 메모리 사용량은 대략 30MB 언저리다.
HBONE: L4 위에 HTTP를 얹은 이유
Ambient에서 가장 헷갈리는 개념이 HBONE(HTTP-Based Overlay Network Environment)이다. 처음 봤을 때 "L4 처리한다면서 왜 HTTP를 얹지?" 싶었다.
핵심은 이렇다. ztunnel끼리 통신할 때는 워크로드 트래픽을 그대로 mTLS로 감싸서 보내는 게 아니라, HTTP/2의 CONNECT 메서드로 터널을 만들어서 그 안에 흘려보낸다. 즉 다음과 같은 흐름이다.
[워크로드 A] → [노드1 ztunnel] → HTTP/2 CONNECT (mTLS) → [노드2 ztunnel] → [워크로드 B]
왜 이렇게 하는가. 세 가지 이유가 있다.
첫째, 노드 단위 ztunnel 하나가 여러 워크로드의 트래픽을 다중화해야 한다. 그냥 TCP에 mTLS 씌우면 워크로드마다 커넥션이 따로 유지돼야 하는데, HTTP/2로 멀티플렉싱하면 커넥션 재사용이 가능하다.
둘째, 각 스트림에 워크로드 identity를 실어 보낼 수 있다. CONNECT 요청의 헤더에 "이 스트림의 원본 워크로드는 이거다"라는 정보를 넣어서 수신 측 ztunnel이 정책을 적용할 수 있게 한다.
셋째, waypoint proxy로 트래픽을 넘길 때도 같은 프로토콜로 넘기면 되므로 데이터 플레인 전체가 일관된다. Waypoint proxy 앞뒤 홉이 모두 HBONE으로 통신하므로 mTLS identity가 끊기지 않는다.
이 부분은 처음에 좀 이상해 보였는데, 실제로 여러 워크로드가 노드 하나에 몰려 있는 상황을 상상해 보면 왜 이렇게 설계했는지 이해가 된다. "L4다"라는 말에 낚이지 말고, "L4 정책 결정을 위해 최소한의 identity 정보만 얹은 얇은 오버레이"라고 생각하면 편하다.
트래픽 리다이렉션은 iptables가 아닌 이유
사이드카 시절에는 istio-init 컨테이너가 iptables 룰을 설치해서 Pod의 트래픽을 사이드카로 우회시켰다. Ambient에서는 이게 어떻게 될까.
정답은 노드의 CNI 플러그인 레벨에서 처리한다는 것이다. istio-cni-node DaemonSet이 노드에 뜨고, 이게 Pod가 시작될 때 노드의 네트워크 네임스페이스에서 트래픽을 ztunnel로 리다이렉트하는 룰을 설치한다. 사용하는 기술은 클러스터 상황에 따라 다른데, 최근 버전에서는 노드의 network namespace 안에 있는 워크로드 IP를 감지해서 iptables + tproxy 조합으로 ztunnel의 15008 포트로 넘긴다.
이렇게 하는 이유는 Pod 자체에 뭔가 주입하지 않기 위해서다. Sidecar injection이 없으므로 Pod spec은 원본 그대로다. 이게 마이그레이션 관점에서 큰 장점이다. 우리 팀에서는 앱 팀에 아무 부담도 안 주고 특정 네임스페이스를 Ambient로 옮길 수 있었다. kubectl label ns <ns> istio.io/dataplane-mode=ambient 한 줄로 끝난다.
물론 그 트레이드오프도 있다. 노드 컴포넌트가 죽으면 그 노드 위의 모든 워크로드가 영향받는다. 사이드카 시절에는 사이드카 하나 죽어도 그 Pod만 영향받았지만, ztunnel이 죽으면 노드 전체가 잠깐 mTLS를 못 하는 상태가 된다. 그래서 ztunnel의 안정성이 매우 중요하고, Rust로 쓴 것도 이 맥락에서 이해가 된다.
실제로 문제가 생겼던 지점: waypoint 없이 L7 정책이 필요한 경우
이론상 L4는 ztunnel, L7은 waypoint라고 깔끔하게 나뉜다. 그런데 실제로는 이 경계가 애매한 경우가 있다.
우리가 최근에 겪은 케이스: PeerAuthentication에서 특정 워크로드의 mTLS를 STRICT로 잠갔는데, 특정 헤더가 있는 요청만 예외로 통과시키고 싶었다. 이건 L7 정보(헤더)를 봐야 하는 정책이다. 그런데 이 워크로드는 waypoint를 안 붙였다.
결과: 정책이 적용 안 됐다. ztunnel은 L4까지만 보므로 헤더를 못 본다. 이 케이스에서는 waypoint proxy를 배포해서 해당 워크로드의 트래픽을 waypoint로 우회시키는 수밖에 없었다. Waypoint를 붙이면 ztunnel에서 waypoint로, 그리고 waypoint에서 다시 워크로드로 홉이 하나 늘어난다. 지연이 약간 는다.
교훈은, "Ambient는 사이드카를 없앤다"에서 끝나면 안 되고, "어떤 정책이 L4에서 커버되고 어떤 정책이 L7 waypoint가 필요한지"를 사전에 정리해두는 게 좋다. 우리 팀은 이걸 나중에 알아서 waypoint를 후행으로 붙였는데, 처음부터 정책 매트릭스를 만들었으면 덜 헤맸을 것 같다.
정리하면서 남는 질문들
ztunnel과 HBONE 구조를 이해하고 나니 이제 궁금해지는 것들이 있다. 노드 단위 데이터 플레인이라 노드 하나가 죽을 때 트래픽이 어떻게 재라우팅되는지, EndpointSlice 변화가 ztunnel에 얼마나 빨리 반영되는지, 그리고 대규모에서 xDS 부하가 어떻게 나뉘는지 같은 것들이다. 이 부분은 아직 우리도 프로덕션 트래픽으로 검증 중이라 다음 글에서 더 다뤄보려고 한다.
혹시 Ambient mode 프로덕션 도입한 팀이 있으면, waypoint 배치 전략이나 정책 마이그레이션 순서 같은 부분에서 어떻게 접근했는지 궁금하다. 댓글로 남겨주면 좋겠다.
태그
Istio, Ambient Mesh, ztunnel, HBONE, service mesh, 쿠버네티스