Istio Without Sidecars: What Ambient Mode Fixes, and What It Hides

In Part 1 I pointed an unauthorized pod at Istio, Linkerd and Consul and found that all three served it the application’s own response through the endpoint they build for the kubelet’s health probe, with a deny policy in force. Istio’s ambient mode has no such port. I scanned for it and got a connection reset.

Then I held ztunnel down and every meshed pod on the node became unreachable, to everything, including pods that were never in the mesh. Kubernetes did not notice: three pods Ready, zero restarts, Service endpoint intact. Both results come from the same mechanism. The probe does not traverse ambient’s data plane, which is why nothing leaks through it, and equally why it cannot tell you the data plane is dead.

Part 1 compared three meshes. This one compares two modes of one, and that is not a narrowing I chose: ambient exists only in Istio. The other two answered the same question in different directions, and neither answer produces a second mode to test.

Mesh Where the data plane runs A sidecar-less mode?
Istio in every pod, or per node with ambient yes
Linkerd in every pod, a 3 Mi Rust micro-proxy no, by design
Consul in every pod, but with no node agent at all no

Linkerd’s bet is that a proxy small enough makes per-pod fine, and it has no ztunnel equivalent. Consul went sideways instead: it kept the sidecar and deleted the node agent, which is what lets it run on Fargate and GKE Autopilot, where a privileged node agent is not an option. Read the three together and they are three answers to one question, which is what a data plane should cost and where it should live. Only Istio’s second answer needed its own article.

Two layers, because one proxy cannot be cheap and clever at once

Ambient splits the data plane in two. ztunnel is a per-node DaemonSet that does mTLS, L4 authorization and telemetry. Istio’s docs are blunt about its limit: it “does not terminate workload HTTP traffic or parse workload HTTP headers.” Traffic between nodes rides HBONE, an HTTP CONNECT tunnel, on port 15008.

Anything L7 needs a waypoint, which is a normal Envoy Deployment sitting outside your pods, shared per namespace, and entirely optional. Run ztunnel alone and you get encryption and identity. Add a waypoint and you get paths, methods and header matching.

Istio ambient mode data planeA pod containing only the application, with no proxy. On the same node, ztunnel runs as a DaemonSet and handles mutual TLS and layer 4 authorization; it cannot parse HTTP. A waypoint, a separate Envoy shared per namespace, handles layer 7 and is optional. Traffic from a pod on another node arrives at ztunnel over HBONE. The kubelet's probe reaches the application directly, without passing through ztunnel.Two layers, and one path that skips bothpeer podanother nodeTHIS NODEztunnelDaemonSet · mTLS + L4 authz · no HTTP parsingYOUR PODapp :80no proxy container at allwaypointper namespace · L7 · optionalkubeletnot a mesh memberHBONEplaintext, inside the podfor L7probethe probe never touches ztunnel: nothing leaks through it, and it cannot see ztunnel fail

The split is why the numbers below are what they are, and it is also the source of the one configuration trap in this article.

Nothing runs inside your pod any more

Enrolling the namespace was the whole operation, and it cost no restarts:

kubectl label ns shop istio.io/dataplane-mode=ambient
Check Result
containers in the pod afterwards curl only, no sidecar
pod restart count 0
requests after enrolling 200 200 200

In sidecar mode the same step needed rollout restart on both deployments and new pods. That difference is the headline ambient’s own docs lead with, and it holds.

Measured with kubectl top on the same cluster template as Part 1:

Component Scope Memory
istiod cluster 43 Mi
istio-cni-node per node 14 Mi
ztunnel per node 1-2 Mi
waypoint, only if you need L7 per namespace 26 Mi
your pods per pod no proxy at all

ztunnel uses less memory per node than Linkerd’s proxy uses per pod. Put that against Part 1’s figures, for 100 pods across three nodes:

Data plane Arithmetic Total
Istio sidecar 100 x 27.5 Mi ~2750 Mi
Linkerd sidecar 100 x 3 Mi ~300 Mi
Istio ambient, L4 only 3 x 16 Mi ~48 Mi

One caveat, and it is not a small one. I measured a two-pod namespace under light manual traffic. ztunnel is per-node, so its memory tracks the workloads and connections on that node; it will not sit at 2 Mi under real load. The shape of that table survives. The absolute number does not, and anyone quoting 2 Mi back at me is quoting a lab.

The port that leaked in Part 1 is not there

Part 1’s finding was that :15020 on a sidecar-mode pod answered an unauthorized caller with the application’s real response. Same scan, ambient pod, from an unmeshed pod in another namespace straight at the pod IP:

Port Sidecar mode Ambient
:80 200 200
:15020 200, served the marker exit 56, absent
:15021 200 exit 56
:15008 (HBONE) n/a exit 1, not plain HTTP

Ambient is still permissive by default, so on :80 that unmeshed pod read the marker exactly as it did in Part 1. Nothing about ambient closes the front door for you.

Apply the same deny-all AuthorizationPolicy and the refusal changes character. Sidecar mode answered 403 RBAC: access denied, because Envoy had already parsed the request. Ambient gives you curl exit 56, a connection reset, because ztunnel refuses before any HTTP exists. Same intent, different layer, different thing to grep for in an incident.

The workload stayed healthy through it: Ready, zero restarts, endpoint intact. Ambient keeps probes working without exposing a port that serves your data, which is the trade Linkerd and Consul both got wrong in Part 1.

A healthy waypoint that enforces nothing

Here is the trap. The rule below allows one path and should deny everything else.

action: ALLOW
rules:
  - to:
      - operation:
          paths: ["/allowed-only"]
Where an ambient authorization policy is evaluatedThe same authorization policy, containing a URL path rule, takes two different routes depending on how it is attached. Attached with a selector on the workload, it is evaluated by ztunnel, which cannot parse HTTP and so denies every request including the allowed path. Attached with targetRefs on the Service, it is evaluated by the waypoint, which reads the path and enforces the rule as written.One rule, two fatesRULE IN BOTH LANES: ALLOW paths ["/allowed-only"]selector: app=backendattached to the workloadztunnelcannot read a pathexit 56 for every path,including /allowed-onlya waypoint does not change thistargetRefs: Serviceattached to the Servicewaypointfull Envoy, reads paths403 on /, 404 on /allowed-onlyenforced exactly as writtenistioctl analyze reports no validation issues for either lane
How the policy is attached Waypoint GET / GET /allowed-only
selector on the workload no exit 56 exit 56
selector on the workload yes exit 56 exit 56
targetRefs on the Service yes 403 404, allowed

Row one is fail-closed, which is the safe direction, though note it also denies the path the policy exists to allow. Row two is the expensive one. The waypoint is running, healthy, and the policy still does nothing, because a selector policy is handed to ztunnel, and ztunnel cannot read a path. Row three is that identical rule working: 403 on /, and 404 on /allowed-only because the policy let it through and nginx has no such file.

ztunnel names the mechanism in its own log:

connection closed due to policy rejection: allow policies exist, but none allowed

Nothing warns you. On the broken configuration in row two:

$ istioctl analyze -n shop
✔ No validation issues found when analyzing namespace: shop.

So “L7 needs a waypoint” is true and incomplete. The policy has to be attached to the Service with targetRefs, not to the workload with a selector, and until it is you have an outage that every validation tool calls healthy. Two smaller install notes while you are here: the ambient profile does not install the Gateway API CRDs a waypoint needs, and the flag is --wait, not -y.

Kill ztunnel and Kubernetes will not tell you

Deleting one ztunnel pod broke nothing. Ten samples over forty seconds, every one a 200, because the DaemonSet replaced it faster than the test could catch. That drill proves nothing on its own, and I nearly wrote it up as resilience.

Holding ztunnel down is the real test. Pinned to a nodeSelector no node matches, all ztunnel pods gone and staying gone:

Request from the meshed frontend Result
by DNS name exit 6, could not resolve host
by pod IP, bypassing DNS exit 7, connection refused
by Service ClusterIP exit 7
marker readable by any route 0 occurrences
unmeshed pod to the backend pod IP exit 7, and it was 200 before

It fails closed. It does not quietly drop to plaintext, which was the outcome worth checking, and the last row is the one that surprised me: the backend became unreachable to a pod that was never in the mesh, because istio-cni’s redirect still points inbound traffic at a ztunnel that is no longer listening.

Every pod stayed Ready the whole time, restart counts at zero, the Service still advertising its endpoint. The probe bypasses ztunnel, so it cannot see ztunnel missing. The mechanism that closes the security hole is the same one that blinds your health checks, and no readiness probe you can write will catch it, because catching it would mean routing the probe through the thing that is broken.

Recovery was immediate. Restore the DaemonSet and the first sample after it came back was a 200.

What this changes about choosing

Ambient’s trade is honest and it is not mainly about cost, even though the cost difference is enormous. You move the failure domain from one pod to one node, you gain the ability to upgrade the L4 data plane without touching application pods, and you give up the ability to see that data plane fail through Kubernetes’ own health machinery.

If you take one thing from Part 1 and this together: the mesh’s probe path is never a mesh member, and every mesh makes a different bargain about that. Sidecars pay for it with an unauthenticated port that serves your data. Ambient pays for it with an outage your monitoring cannot see.

So monitor ztunnel itself. Alert on the DaemonSet’s ready count per node, not on your application’s readiness, because your application will keep insisting it is fine.