Your Waypoint Says None, and It Is Working Perfectly

A waypoint is an Envoy you deploy on purpose, and it binds to Services — not to pods. The per-pod table will tell you it isn't there. It is.

The last chapter ended with a proxy that can tell you who is calling and nothing whatsoever about what they are asking for. ztunnel authenticated every connection in the bookshop, wrapped it in mTLS you never configured, and denied an unauthorized caller with a connection reset — and then, when we asked it to enforce a rule about an HTTP method, it shrugged and let the request through, because ztunnel is an L4 proxy and an HTTP method is not a fact that exists at L4.

That is not a bug and it is not a gap Istio forgot to fill. It is the design. The whole ambient bet is that most of your traffic needs identity, encryption, and telemetry — and only some of it needs a proxy that parses HTTP. So Istio gives you the first three for free, on every pod in the namespace, at the cost of one label and zero restarts. And when you want the fourth, you deploy the thing that does it, where you want it, and you pay for it there.

That thing is a waypoint. It is an Envoy. You deploy it deliberately, it sits on the path to the services you bind it to, and it is the only component in ambient mode that has ever read a URL.

This chapter is about what a waypoint is, what it buys you, what it costs you, and one fact that is going to convince you your waypoint is broken when it is working perfectly. That last one is not hyperbole. It is the single most reliable way to lose an hour in ambient mode, and I want you to lose it here rather than in an incident channel.

The problem, stated precisely

Here is what ztunnel can enforce: this connection came from a workload whose ServiceAccount is orders; the destination is the catalog service; allow or deny the whole connection. Identity, port, and a yes-or-no about the TCP stream. That is a genuinely useful set of powers — it is more than most clusters have — and it is where the taxonomy stops.

Here is what it cannot enforce, and it is a longer list than people expect: GET but not DELETE. /books but not /admin. A retry on a 503. A one-second timeout. A 90/10 traffic split. A response-code metric. A rewritten header. Every one of those is a sentence about a request — and ztunnel has never seen a request in its life. It sees a connection, and a connection is a pipe with two identities stapled to it.

Every item on that list requires the same thing — something in the path that terminates the connection, parses the bytes as HTTP, forms an opinion about a request, and then makes a new request to the backend. There is no clever way around it. If you want to route on a path, something has to read the path.

In the sidecar model, that something is an Envoy in every single pod. That is why the sidecar model can do L7 anywhere: it has already paid for L7 everywhere, in memory, in CPU, and in a pod restart for every workload you enroll. Ambient’s answer is to unbundle that bill. Layer 4 — identity, mTLS, L4 policy, connection metrics — comes from the per-node ztunnel and costs you nothing per pod. Layer 7 comes from a waypoint, and you deploy one only where an L7 feature is worth an extra network hop.

Opt-in L7 is the whole product. Everything else in this chapter is mechanism.

Deploying one

istioctl has a subcommand for it, and it does three things you should be able to name.

$ istioctl waypoint apply -n bookshop --enroll-namespace --wait
 ✅ waypoint bookshop/waypoint applied
 ✅ waypoint bookshop/waypoint is ready!
 ✅ namespace bookshop labeled with "istio.io/use-waypoint: waypoint"

Line one: it created a Gateway object named waypoint. Line two: it waited for the pod behind that Gateway to come up. Line three — and this is the one that actually turns anything on — it put the label istio.io/use-waypoint: waypoint on the bookshop namespace.

Take those apart, because the CLI is a convenience and the objects are the truth.

The Gateway is a Gateway API Gateway, the same kind you wrote in the Kubernetes series when you put a front door on the bookshop — except that its gatewayClassName is istio-waypoint rather than something that serves the outside world. Istio registers three classes at install time, and you saw them in the install chapter:

NAME             CONTROLLER                    ACCEPTED   AGE
istio            istio.io/gateway-controller   True       4m37s
istio-remote     istio.io/unmanaged-gateway    True       4m37s
istio-waypoint   istio.io/mesh-controller      True       4m37s

Read the controller strings. istio.io/gateway-controller is the north–south one — an ingress gateway, the front door, the next chapter. istio.io/mesh-controller is this one — a proxy that lives inside the mesh and handles east–west traffic between your own services. (istio-remote is for a gateway Istio does not manage the lifecycle of; it is a multicluster concern and it is not this book’s.) Two different jobs, one API, and the only difference in your YAML is a string in gatewayClassName. That is Gateway API doing exactly what it was designed for — and it is why the Kubernetes series spent a chapter on it.

The Gateway controller sees the object and materializes a pod:

waypoint-6b8b5b8b9c-vnm6m   1/1   Running   0     12s

One Envoy. Not one per pod, not one per node — one, for the namespace, running as an ordinary Deployment that you can scale, schedule, and give resources to like any other workload, because that is exactly what it is. (An aside on --wait: there is a widely repeated claim that it reports “ready” while the pod is still 0/1. On 1.30.2 it did not reproduce for me — the CLI printed is ready! and the pod was already 1/1 Running. Waiting on the pod yourself is still good hygiene in a script; the specific misbehaviour is not something I can show you.)

And the Gateway reports itself healthy:

NAME       CLASS            ADDRESS         PROGRAMMED   AGE
waypoint   istio-waypoint   10.96.245.53    True         2m29s

PROGRAMMED=True, with an address. Hold onto that row — the next chapter’s Gateway does not say True, ever, and the difference between them is the most instructive thing in it.

The label is the switch

The pod is not the point. The label is the point.

istio.io/use-waypoint: waypoint on the bookshop namespace is what tells ztunnel that traffic destined for services in this namespace should be sent through the waypoint named waypoint first. Delete the label and the waypoint keeps running, keeps reporting PROGRAMMED=True, keeps consuming memory — and enforces nothing, because nothing is being sent to it. Put the label back and every L7 rule you wrote springs to life without a restart of anything.

This matters more than it sounds. There are two separate objects in play — a proxy and a pointer at it — and people conflate them constantly:

  • The waypoint is a proxy. It exists, or it does not.
  • The binding is a label. It says which traffic goes through that proxy.

An unbound waypoint is a running Envoy that nobody uses — healthy, idle, and billing you. A binding to a waypoint that does not exist is a namespace whose traffic has nowhere to go. And — this is the failure mode the security chapter is built on — an L7 rule with no binding is not a stricter rule. It is an unenforced one, and it fails open.

Which is why the CLI’s third line of output is the one to read twice. istioctl waypoint apply did not “turn on L7 for the namespace” in some opaque internal way; it wrote a label on an object, and you can write that label yourself, remove it yourself, and diff it in Git like anything else. Nothing in ambient’s binding model is hidden from kubectl.

The fact that will convince you it is broken

Now the trap. It is going to feel wrong, and I want to give you the wrong feeling on purpose.

You have a waypoint. It is running. Traffic is going through it — we will prove that in a moment. You do the natural thing, which is to ask ztunnel about the workloads it knows about:

$ istioctl ztunnel-config workload
NAMESPACE   POD NAME                    ADDRESS       NODE              WAYPOINT   PROTOCOL
bookshop    catalog-7c74bb4c8c-hc22b    10.244.2.51   k8s-lab-worker2   None       HBONE
bookshop    catalog-7c74bb4c8c-v649p    10.244.1.64   k8s-lab-worker    None       HBONE

WAYPOINT: None. On both catalog pods. On every pod in the namespace, in fact, including the ones whose L7 policy you have just watched return a 403.

There is a column called WAYPOINT, it is telling you there is no waypoint, and there is a waypoint. Every instinct you have says the binding failed — so you re-run istioctl waypoint apply, and you check the namespace label, and it is there, and you delete the waypoint pod to make it come back fresh, and it comes back and still says None, and by now you are twenty minutes in and reading the ztunnel source.

Stop. WAYPOINT: None in the workload table is correct. It is what a healthy, fully-functioning, actively-enforcing waypoint looks like from that table, and it will say None forever, because you are asking the wrong table.

Ask the service table instead:

$ istioctl ztunnel-config service
NAMESPACE   SERVICE NAME   SERVICE VIP     WAYPOINT   ENDPOINTS
bookshop    catalog        10.96.228.164   waypoint   2/2
bookshop    orders         10.96.2.2       waypoint   1/1
bookshop    postgres       10.96.56.90     waypoint   1/1
bookshop    waypoint       10.96.245.53    None       1/1

There it is. catalog … waypoint. orders … waypoint. Same cluster, same second, same ztunnel, opposite answer — because the two tables are describing two different things, and only one of them is a thing that can have a waypoint.

Waypoints bind to Services, not to workloads. A pod does not have a waypoint. A Service has a waypoint. The per-pod view is not lying to you and it is not stale; it is answering a question whose answer is genuinely None.

Why it works that way, and why it is right

This is not an implementation quirk to memorize. It falls straight out of how ambient routes a request, and once you see the path you will never check the wrong table again.

Your web pod calls http://catalog/books. DNS resolves catalog to a Service VIP — 10.96.228.164, right there in the table above — exactly as it did before you had ever heard of Istio, because a Service is a virtual IP that kube-proxy programs into the node and Istio did not change that. The packet leaves the pod addressed to the VIP.

ztunnel, on that node, intercepts it. And here is the branch: ztunnel looks up the destination Service, sees that it is bound to a waypoint, and — instead of picking a backend pod and opening an HBONE tunnel straight to it, which is what it did in the last chapter — it opens an HBONE tunnel to the waypoint, carrying the caller’s identity along with it.

The waypoint, an Envoy, terminates that tunnel and now has something ztunnel never had: a complete HTTP request, with a method, a path, headers, and a body. It applies whatever L7 rules you have written — route, retry, time out, authorize, count — and then makes its own call onward to a backend pod of the catalog Service, over HBONE, with mTLS.

And that onward call is made as the waypoint, not as the caller. This matters more than it sounds like it does, so here is the measurement. Put an L4 policy on the catalog workload — a selector: policy, the kind ztunnel enforces — and vary who it allows:

L4 policy on app: catalogorders → catalogweb → catalog
ALLOW only sa/orders200503
ALLOW only sa/waypoint200200
ALLOW only sa/nobody-at-all200503

Read the middle row. web — which is not sa/waypoint — gets through when, and only when, the policy allows the waypoint’s identity. The destination’s ztunnel is authorizing the proxy that spoke to it, because the proxy is what spoke to it.

There is a corollary and it is sharp: once a waypoint is bound to a Service, an L4 identity policy on that Service’s pods no longer distinguishes between the mesh peers calling through it. They all arrive wearing the same badge. If you want “only orders may call catalog” to be true of in-mesh callers, that is an L7 rule at the waypoint (targetRefs on the Service), not an L4 rule on the workload — and the authorization chapter is where we write it. The L4 rule still does real work: it is what stops anything that bypasses the waypoint, which is exactly the unenrolled pod in another namespace.

(There is one row I cannot explain: orders returns 200 in all three cases, including the one where nothing is allowed. I ran it four times, with a clean thirty-second settle each time, and I do not have an account of it. I am telling you rather than quietly cropping the table.)

So: the traffic was addressed to a service. ztunnel’s decision was made about a service. The waypoint was selected on behalf of a service. At no point did anything ask “does this pod have a waypoint?”, because that question does not arise. The waypoint is not in the pod, it is not beside the pod, and the pod does not know it exists.

That design has consequences worth stating out loud, because they are the reason ambient behaves differently from what your sidecar habits expect.

A pod can back several Services. Put catalog and catalog-internal in front of the same pods with different selectors and you can bind one to a waypoint and leave the other bare. Under a sidecar there is exactly one proxy in that pod and it is in the path of everything, whichever name you called. Service-scoped binding is strictly more expressive; it just is not what your fingers expect.

Policy is naturally about a service, not about a host. “Who may call catalog, and with what method” is a sentence about the service, and it stays true when the ReplicaSet rolls and every pod IP in it changes. Ambient enforces it at the object the sentence is actually about.

And the waypoint has no waypoint. Look at the last row: waypoint … WAYPOINT: None. The waypoint’s own Service is not routed through itself, which would be an infinite regress and a very funny incident. The exclusion is not a special case someone bolted on; it is the same service-scoped rule, and the waypoint’s Service simply is not bound.

There is one more consequence, and it is the one that separates ambient from your sidecar instincts most sharply. A waypoint, bound this way, sits in front of the destination. It is catalog’s waypoint — not web’s — and it is selected by where the traffic is going, not by where it came from. A sidecar is both ends at once: the caller’s sidecar filters what leaves, the callee’s sidecar filters what arrives, and a policy can attach to either. Ambient’s default is the destination end, which is the end that can actually be trusted, since the caller’s proxy is the thing an attacker would have compromised. (Istio does have a knob for the other direction — an istio.io/waypoint-for value that binds a waypoint for workload-addressed traffic rather than service-addressed traffic. I did not run it, and I am not going to describe behaviour I did not see; know that the door exists and that the default is the one described here.)

What you actually get for the hop

Everything in this list is a chapter, and every one of them is unavailable to you until a waypoint is in the path. This is the shopping list, and it is why you would bother.

HTTP routing. Path and header matching, weighted traffic splits, rewrites. An HTTPRoute whose parent is a Service rather than a Gateway is enforced by that service’s waypoint — and it is how you shape traffic inside the mesh rather than at its edge. That is the next-but-one chapter.

Timeouts and retries and outlier detection. A one-second request timeout. Three retries on a 5xx. Ejecting a backend pod that is failing everything while still passing its readiness probe — which is a real state, not a thought experiment, and it is the single best argument for a mesh I know. Chapter 10.

L7 authorization. GET yes, DELETE no. This is the one we already tried and watched fail open — and it is the chapter this whole book is built around. Chapter 11.

L7 telemetry. Request counts by method, path, and response code — not bytes and connections, which is all ztunnel can give you, but requests, with the status codes your users actually saw. Istio’s metrics carry a reporter label, and reporter=waypoint is the ambient tell — it means an Envoy in the mesh counted that request, and it means there was an Envoy there to count it. Chapter 12.

Notice that these are not four features. They are four consequences of one fact — something in the path can now read a request. That is all a waypoint is, and it is the reason the feature list of every service mesh ever built looks approximately like that list.

What the hop costs

I would rather you heard this from me than from a latency graph.

It is a real network hop. Before the waypoint, web talked to a catalog pod through one ztunnel tunnel. Now web talks to the waypoint, and the waypoint talks to a catalog pod. That is a second machine — quite possibly on a different node — parsing HTTP, running your filter chain, and re-encrypting. It is fast. It is not free. Any claim that a service mesh adds “negligible” latency is a claim someone is making about a benchmark and not about your cluster, and you should measure it on yours.

It is an Envoy you have to run. A pod, with requests and limits, that you have to size against the traffic of every service bound to it. A namespace-wide waypoint carries the whole namespace’s east–west traffic through one Deployment. If that Deployment is one replica with default resources and your namespace does forty thousand requests a second, you have built yourself a bottleneck with a friendly name. Scale it; it is an ordinary Deployment and it takes an ordinary replicas. This is genuinely one of ambient’s better properties, and it is worth saying why: the sidecar model gave you no dial here at all. You could not scale the proxy independently of the app, because the proxy was the app’s pod — to double your L7 capacity you doubled your application replicas, and to give the proxy more memory you gave every copy of your application more memory. A waypoint is a separate Deployment with a separate replicas and separate resources, which means “the proxy needs more CPU” and “the service needs more CPU” are finally two different sentences with two different fixes.

It is a new failure domain. The waypoint can be OOM-killed, mis-scheduled, or evicted, and when it is, the traffic it was carrying has a problem that has nothing to do with your application. There is an honest counterpoint — a sidecar shares its pod’s fate, so a crashed sidecar takes exactly one application instance down with it, whereas a crashed waypoint is a shared blast radius across every service bound to it. The mitigation is the boring one: more than one replica, a PodDisruptionBudget, resources you actually measured. But the shape of the risk changed when you deployed it, and you should know that you moved it rather than removed it. You traded some of your application’s failure modes for some of the proxy’s. That is a trade, not a free upgrade.

And it puts an HTTP proxy in front of things that are not HTTP. Look again at that service table and find the row for postgres. A namespace-wide waypoint binds every service in the namespace — including the database, which speaks the Postgres wire protocol and has never sent an HTTP header in its life. Istio decides how to treat a port largely from the port’s name, and istioctl analyze was already unhappy about ours:

Info [IST0118] (Service bookshop/postgres) Port name pg (port: 5432, targetPort: pg)
doesn't follow the naming convention of Istio port.

Istio wants tcp-… or http-… prefixed port names so that it knows what a port carries instead of having to sniff it. That warning is not an Istio bug; it is our own manifest, written before there was a mesh, being asked a question it never had to answer. I did not measure what the waypoint does to Postgres traffic — I have no numbers for you there, and I am not going to invent any. What I will say is that “an L7 proxy is now in front of my database and I did not think about it” is not a sentence you want to discover during a slow query, and it is the sharpest argument there is for the next section.

Granularity: namespace, or one service

--enroll-namespace is the blunt instrument, and it is the right one to start with because it gets you to a working L7 path in one command. It is not the only option, and it is often not the right one.

The binding label goes on a namespace or on a Service. Namespace-wide, which is what we ran:

$ kubectl label namespace bookshop istio.io/use-waypoint=waypoint

Or scoped to exactly one Service — the same label, one object down:

$ kubectl label service catalog -n bookshop istio.io/use-waypoint=waypoint

That second form binds catalog and leaves orders, web, and — crucially — postgres on the plain ztunnel path: still encrypted, still identified, still L4-policed, with no Envoy in front of them. Everything you do not need L7 for keeps costing you nothing.

I ran the namespace form. The ground truth in this book is the namespace form. The Service-scoped label is the same mechanism applied to a narrower object, and I did not exercise it in this lab — so treat it as documented rather than measured, which is a distinction this book intends to keep making. The mechanism you can verify from the output you already have: the namespace label is what istioctl waypoint apply --enroll-namespace printed, and ztunnel-config service is where the binding shows up. Whatever object carries the label, the service table is where you go to find out whether it took.

The rule of thumb I would give someone starting today:

  • One waypoint per namespace while you are learning, or when a namespace is one team’s app and its traffic is homogeneous — simple, one thing to size, one thing to watch.
  • A waypoint per service when a single service needs L7 and the rest of the namespace does not, when one service’s traffic would swamp a shared proxy, or when a service is not HTTP at all and you want it nowhere near an HTTP filter chain.

And there is a third option people forget — no waypoint. If all you needed was mTLS between your services and a policy that says “only orders may call catalog”, you already have that, it costs you nothing per pod, and you should not deploy an Envoy to prove a point. The most common mistake I expect readers of this chapter to make is not misconfiguring a waypoint. It is deploying one they did not need, because the tutorial had one.

Checking your work

The commands, in the order that actually resolves the question:

$ kubectl get gateway -n bookshop           # does the waypoint object exist, and is it PROGRAMMED?
$ kubectl get pods -n bookshop -l gateway.networking.k8s.io/gateway-name=waypoint
$ istioctl ztunnel-config service            # ^ THE ONE THAT MATTERS: is the SERVICE bound?

And the one to stop reaching for: istioctl ztunnel-config workload, whose WAYPOINT column will say None, correctly, while everything works.

If the service table shows the binding and your L7 rule still is not taking effect, the problem is the rule, not the waypoint — and the security chapter will show you exactly where Istio hides the evidence for that.

Final thoughts

The WAYPOINT: None trap is a small thing that teaches a large one. Two commands, run one second apart against the same healthy mesh, disagree — not because either is wrong, but because ambient made a modelling decision that the tooling faithfully reflects and the tooling’s column names do not explain. The workload table is about workloads. Workloads do not have waypoints. Nothing is broken; the model in your head is.

That model, in one sentence: in ambient, L4 is a property of a pod and L7 is a property of a service. ztunnel is on every node because identity and encryption belong to every workload, always, and cost nothing to give away. The waypoint is somewhere else because reading HTTP is expensive, and Istio would rather you chose where to pay for it than quietly bill you in every pod’s memory footprint. The sidecar model’s answer to “where should the L7 proxy live?” was everywhere, so we never have to ask. Ambient’s answer is tell us.

Which means the interesting design work in an ambient mesh is not writing the policy. It is deciding, service by service, whether this one is worth a hop. That is a real engineering judgment with a real cost on both sides, and it is one a sidecar mesh never let you make.

So far, every packet in this book has been born inside the cluster. The bookshop still has no front door. Next we open one — and the Gateway that serves it will report, in a status column, that it does not work.

Next: The Front Door That Reports Failure and Serves Every Request

Comments