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.
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"]
| 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.