The Front Door That Reports Failure and Serves Every Request
An Istio Gateway is the Gateway API you already know, with an Envoy behind it that belongs to the mesh — and on kind it will tell you, forever, that it is not programmed. It is.
NAME CLASS ADDRESS PROGRAMMED AGE
bookshop-gw istio False 25s
waypoint istio-waypoint 10.96.245.53 True 2m29s
PROGRAMMED=False. No address. The first row is the bookshop’s brand-new front door, and Kubernetes is telling you it has not been programmed.
Now curl it.
GET / -> 200
GET /books -> 200
Two hundreds. The storefront on /, the catalog’s JSON on /books, routed by path through an Envoy that Istio built for us thirty seconds ago and that the status column insists does not work. It will keep saying False for as long as that cluster exists. It will keep returning 200s for exactly as long.
That is the chapter. But it is worth building up to properly, because the reason for the lie is more interesting than the lie, and because there is a second implementation in your own back catalogue that reported the opposite answer under identical conditions.
Everything so far has been indoors
Six chapters in, the bookshop is a mesh. web talks to catalog over mTLS nobody configured. orders has a SPIFFE identity derived from its ServiceAccount. ztunnel authenticates every connection at L4, a waypoint reads HTTP for the services bound to it, and an unauthorized caller gets a connection reset instead of a page.
And not one packet has come from outside the cluster. Every request in this book so far has been fired from a pod, at a Service VIP, on a cluster network — which is an excellent way to learn a mesh and a very poor way to run a bookshop. Customers are not in your cluster. Everything we have built is a beautifully secured conversation between people who were already in the building.
So we need a front door. And the good news, which is the entire point of this chapter’s first half, is that you already built one.
Istio did not install a gateway for you
Before the YAML, notice an absence.
Go back to what the ambient profile actually put on the cluster in the install chapter — istiod, a Deployment; istio-cni-node, a DaemonSet on every node; ztunnel, a DaemonSet on every node. Three things. There was no Envoy anywhere, and there was, in particular, no ingress gateway. Nothing was listening for the outside world, and nothing was going to.
That is a deliberate change from the Istio most tutorials still describe. The old shape was a single, cluster-wide, install-time istio-ingressgateway Deployment in the istio-system namespace, which every team shared, which the install profile owned, and which you configured with an Istio-specific Gateway CRD that happened to have the same name as the Kubernetes one and completely different semantics. It worked. It also meant the front door was infrastructure that arrived with the mesh — and that scaling it, moving it, or giving one team their own was a Helm-values exercise conducted at arm’s length from the people who needed it.
The modern shape is one Envoy per Gateway object, created on demand. You write a Gateway — the controller creates a Deployment and a Service for it, in your namespace, sized and scheduled like anything else you own. Two teams that want two front doors write two Gateways and get two Envoys, with separate blast radii and separate resource budgets. The install profile has no opinion about it, because the install profile is not involved.
Which is why the interesting kubectl get pods -n istio-system output, after everything in this chapter, still does not contain a gateway. The gateway is in bookshop, because that is where I put the object.
You have already written this object
In the Kubernetes series you installed Gateway API, discovered that a Gateway with no controller is a beautiful, inert YAML file, installed NGINX Gateway Fabric to give it a controller, and ran real traffic — path routing and a weighted canary — through it. Everything you learned there is still true. GatewayClass is the infrastructure provider’s object; Gateway is the cluster operator’s; HTTPRoute is the app developer’s. parentRefs attaches a route to a Gateway. allowedRoutes decides who may attach. Six CRDs, one controller, no new nouns.
Here is the bookshop’s Istio Gateway, and I want you to look for what is different:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: bookshop-gw
namespace: bookshop
spec:
gatewayClassName: istio
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same
One string. gatewayClassName: nginx became gatewayClassName: istio. That is the complete diff between the front door you ran two months ago and the one you are running now, and it is not a coincidence or a convenience — it is the thesis of Gateway API arriving on schedule. The API is a specification; the implementation is a plug; you changed the plug.
Apply it, and Istio’s Gateway controller does what NGINX’s did — it reads a declaration and materializes a proxy:
pod/bookshop-gw-istio-59874bdff4-6kjd4 1/1 Running
service/bookshop-gw-istio LoadBalancer 10.96.21.216 <pending> 15021:32548/TCP,80:30531/TCP
A pod and a Service, neither of which appears in any file I wrote. The pod is an Envoy. The Service is how traffic reaches it — a LoadBalancer, because a front door wants an address the outside world can dial. Port 80 is the listener you declared. Port 15021 is Istio’s status port, which the proxy’s own readiness probe uses; I did nothing with it beyond noticing it was there.
Two conventions in that naming are worth internalizing, because they are how you find these things at 3am. The pod’s name is <gateway-name>-<gatewayclass> plus the usual Deployment suffixes: bookshop-gw + istio → bookshop-gw-istio-59874bdff4-6kjd4. The Service is bookshop-gw-istio. Under NGINX Gateway Fabric the same Gateway produced bookshop-gw-nginx. Same rule, different implementation, and the name tells you which controller built it — which is genuinely useful in a cluster where two implementations coexist, and that is a more common state of affairs than anyone plans for.
And because it is an ordinary Deployment, everything you know about Deployments applies to it. It has replicas — one, by default, which is fine for a laptop and indefensible for a front door, since a single-replica ingress means a rollout of the gateway is a rollout of your whole shop. It has resource requests you should set from measurement rather than from vibes. It can be given a PodDisruptionBudget, an anti-affinity rule, its own node pool. None of that is Istio knowledge; it is Kubernetes knowledge, applied to a pod that Istio happens to have written for you — and that is exactly the property you want from a control plane.
What changed is not the API. It is what is behind it.
If the YAML is identical, why does this chapter exist?
Because the proxy Istio put behind that Gateway is a member of the mesh, and NGINX’s was not.
That sounds like a slogan, so let me make it concrete. The bookshop-gw-istio pod runs in the bookshop namespace, which is labelled istio.io/dataplane-mode=ambient. It has a ServiceAccount — therefore a SPIFFE identity, therefore a certificate, the same 24-hour istiod-issued leaf that every other workload in this book carries. When it forwards a request to catalog, that hop is mTLS. When it forwards to a service with a waypoint, the waypoint sees a caller with a real, verifiable identity — and can be told, in an AuthorizationPolicy, that this particular identity is allowed to do exactly one thing and nothing else.
And it shows up in the mesh’s telemetry as an ordinary source workload. Here is a row from the Prometheus query we will spend chapter 12 on, taken from a real query against this cluster:
source=bookshop-gw-istio reporter=source code=200 requests=1
The gateway is not something the mesh watches from outside. It is in the table, alongside web and orders, with a workload name and a response code, because it is a mesh workload that happens to be pointed at the internet.
Set that against the NGINX gateway from the Kubernetes series. That proxy worked perfectly — and it was a stranger. It had no mesh identity. Traffic from it into your services arrived as whatever the pod network made of it. It appeared in no Istio metric. A policy could not name it, because there was no name to give — an AuthorizationPolicy that says “only the ingress gateway may call web” has nothing to bind to when the ingress gateway is not a mesh workload. You would be back to writing rules about IP ranges, which is the thing a mesh exists to stop you doing.
That is the whole argument for running your ingress on the same mesh as your services, and it is a stronger argument than it first appears. It means the identity chain starts at the door. A request from the outside world is anonymous by definition — the customer has no SPIFFE certificate, and no amount of mesh will invent one. The gateway is the point where anonymity ends: it terminates the customer’s connection, and everything it does afterwards happens as a named, authenticated, certificate-bearing workload whose calls are encrypted and whose behaviour is policed. Where the mesh begins is a design decision, and putting it at the front door means there is no plaintext, unnamed hop anywhere in your cluster — not even the first one.
A door with no rules is just a wall
A Gateway on its own routes nothing. It is a listener — a port, a protocol, and a statement about who may attach rules to it — and nothing more. The rules live in an HTTPRoute, which is the object the next chapter is made of, so here I will show only that it attaches:
gateway.gateway.networking.k8s.io/bookshop-gw created
httproute.gateway.networking.k8s.io/bookshop-routes created
The route’s parentRefs names bookshop-gw; the Gateway’s allowedRoutes.namespaces.from: Same permits it, because they share a namespace; and the two objects find each other with no further ceremony — no annotation, no controller flag, no restart. Then the requests at the top of this chapter — GET / to the storefront, GET /books to the catalog, two different Deployments behind one port — return their 200s.
That separation is the role split doing its job — and it is worth saying plainly in an Istio book, because Istio’s old API did not have it. The Gateway is the operator’s object — the port, the certificate, the permission boundary. The HTTPRoute is the developer’s — the paths, the backends, the weights. An app team can ship a new path without touching TLS config, and an operator can rotate a certificate without reading anybody’s routing rules. Under the old Istio Gateway + VirtualService pair, those concerns were split differently and less cleanly, and you will meet that pairing again — the resilience chapter needs a VirtualService, because Gateway API cannot yet express a retry.
The trap: PROGRAMMED=False, forever
Now go back to the status column, because it says the door does not work.
$ kubectl get gateway bookshop-gw -n bookshop -o yaml # (conditions, trimmed)
Accepted=True reason=Accepted
Resource accepted
Programmed=False reason=AddressNotAssigned
Assigned to service(s) bookshop-gw-istio.bookshop.svc.cluster.local:80, but failed to
assign to all requested addresses: address pending for hostname
"bookshop-gw-istio.bookshop.svc.cluster.local"
Read the message rather than the boolean, because the message is doing you a favour.
Accepted=True. Istio’s controller looked at the Gateway, recognized the class, validated the listener, and took ownership — nothing about the object is wrong, and nothing about it is being ignored.
Programmed=False, reason AddressNotAssigned. Not “listener invalid”, not “no backends”, not “config rejected”. The complaint is about an address — specifically that the Service backing this Gateway has been asked for an external address and has not got one:
bookshop-gw-istio LoadBalancer 10.96.21.216 <pending> 15021:32548/TCP,80:30531/TCP 91s
<pending>, in the EXTERNAL-IP column — which you have seen before, and shrugged at before. A LoadBalancer Service asks the cloud for an address, and a kind cluster is three Docker containers on a laptop: there is no cloud, there is no load-balancer controller, and there is nobody to answer. The Service will sit at <pending> forever. Istio therefore cannot put an address in the Gateway’s .status.addresses — and because it will not claim to have programmed a Gateway that has no address, PROGRAMMED stays False.
Forever. On kind, that is not a transient state that resolves in a minute — it is the terminal state, and it is correct.
And in the meantime, the Envoy is running, the listener is bound, the routes are attached, and every request through the NodePort (30531, right there in the Service) gets a 200. The condition is about an address. It is not about whether traffic flows.
If you are gating anything on that column, stop. A CI job that waits for PROGRAMMED=True before running its smoke tests will hang until the timeout kills it — on a cluster where the smoke tests would have passed. The tell is always the same: somebody is debugging a status field while their own curl returns 200 in the next terminal.
Two implementations, two answers, same situation
Here is the part that makes this a lesson rather than a kind quirk.
Go back and read the Kubernetes chapter’s Gateway output. Same cluster shape. Same absent cloud load balancer. Same <pending> external IP. And NGINX Gateway Fabric reported:
PROGRAMMED True ("The Gateway is programmed")
True. With ADDRESS showing <pending> in exactly the way Istio finds unacceptable.
Neither implementation is broken. They have read the same condition in the specification and drawn a different line about what it means to have realized a Gateway. NGINX’s position is defensible — I have a proxy, it is listening, routes are attached, traffic flows: programmed. Istio’s position is defensible — this Gateway declares that it should be reachable at an address, it does not have one, so I have not delivered what was asked for: not programmed. One is talking about the data plane. The other is talking about the promise. Both are answering a question the specification asked and did not fully define.
Both are conformant. Both are shipping. And they disagree, out loud, in a status column, on the same object, in the same cluster.
The lesson generalizes past Istio, and it is the reason I did not cut this section:
A status condition is an implementation’s claim about its own work. It is not a fact about the universe.
AcceptedandProgrammedare defined in the specification; the exact bar for asserting them is not, and two conformant controllers will draw it in different places.
The operational corollary is short. Verify a Gateway with a request, not with a column. The column tells you what the controller thinks. The request tells you what the customer gets, and those are different kinds of true. Read the reason string when a condition is false — AddressNotAssigned is a completely different problem from Invalid or NoValidListeners, and the boolean flattens them all into “bad”.
There is a second corollary, and this one costs money if you miss it. On a real cloud, PROGRAMMED=False with AddressNotAssigned is a genuine, urgent problem — it means your cloud’s load-balancer controller failed to provision, or your quota is exhausted, or your annotations are wrong, and your front door has no address the internet can reach. The condition is right to exist. It is meaningless on kind, and it is meaningless there for one reason only — kind is not a cloud. So do not learn “ignore PROGRAMMED” from this chapter. Learn “know what your cluster can actually promise, and then read the reason string”.
If you want the address on kind anyway, the mechanisms exist — MetalLB, kind’s own load-balancer shim, or cloud-provider-kind. I ran none of them, and this book does not depend on any of them: the NodePort is enough to prove routing, and the missing IP is a more useful teacher than a present one.
Contrast the waypoint, which says True
The clinching evidence that this is about addresses and not about health is sitting in the same table:
NAME CLASS ADDRESS PROGRAMMED AGE
bookshop-gw istio False 91s
waypoint istio-waypoint 10.96.245.53 True 3m35s
Same controller family. Same cluster. Same Gateway API. One says False, one says True — and the difference is the ADDRESS column.
The waypoint’s Service is a ClusterIP. It does not want an external address, has never asked for one, and got 10.96.245.53 from the cluster’s service CIDR the instant it was created. Istio can therefore put an address in its status, and it does, and Programmed goes True.
The ingress Gateway’s Service is a LoadBalancer, which is a request addressed to an entity that does not exist on this laptop. Its address never arrives. Its Programmed never flips.
Two Gateways, one cluster, one difference — the Service type — and the entire status disagreement falls out of it. When you see that pair side by side, the False stops being spooky and becomes arithmetic.
Ingress gateway, waypoint: the same API doing two jobs
It is worth pinning the two roles down explicitly, because they are both Gateways, both Envoys, and both Istio, and people who are comfortable with one are frequently confused by the other.
| ingress gateway | waypoint | |
|---|---|---|
| class | istio | istio-waypoint |
| controller | istio.io/gateway-controller | istio.io/mesh-controller |
| traffic | north–south: outside → mesh | east–west: mesh → mesh |
| Service type | LoadBalancer | ClusterIP |
| you attach | HTTPRoutes with parentRefs → the Gateway | HTTPRoutes with parentRefs → a Service |
| bound by | being named in a route’s parentRefs | the istio.io/use-waypoint label |
| clients | anonymous; the internet | mesh workloads with SPIFFE identities |
The bottom two rows are the interesting ones. An ingress gateway is opted into by the routes that name it — the Gateway is a destination you point at, and a route that does not name it gets nothing from it. A waypoint is opted into by a label on the thing being called, which is what makes it transparent: web does not know it is talking to catalog through a waypoint, has no configuration mentioning it, and would not behave differently if the waypoint were deleted underneath it. The ingress gateway is an address. The waypoint is an interception — and that is not a distinction of degree, it is a different relationship to the traffic.
And the last row is the security story in one line. Everything arriving at the ingress gateway is anonymous, because the internet has no certificates from your CA. Everything arriving at a waypoint has a name. That is why the interesting authorization policies in the next chapters live on the inside — and why “the gateway is in the mesh” matters so much, since it means the very first thing that touches an anonymous request is itself a named workload.
What I did not run, and will not pretend to have run
The Gateway in this chapter listens on port 80, in plaintext, because that is what a laptop can honestly do. Here is what is missing and where you should be careful.
TLS. A production Gateway has a listener with protocol: HTTPS, a tls block with mode: Terminate, and certificateRefs pointing at a kubernetes.io/tls Secret — usually filled in by cert-manager against Let’s Encrypt or your internal CA. I did not stand up a certificate, so I have no output to show you and no gotchas to report. The shape is the same one the Kubernetes chapter described, and the role split holds: the certificate is referenced from the Gateway, which is the operator’s object, so an app team’s HTTPRoute never sees a private key and cannot publish a hostname it does not own.
Note the split this creates and do not blur it. Terminating TLS at the gateway is about the customer’s connection — a public certificate, a public CA, a browser that has to trust it. The mTLS the mesh gives you is about every hop after that — an internal CA, SPIFFE identities, and no browser anywhere. They are separate systems, and Istio’s mTLS does not terminate your public certificate, nor does your public certificate authenticate anything to the mesh.
Cross-namespace routes. Our Gateway sets allowedRoutes.namespaces.from: Same, and the HTTPRoute lives in bookshop alongside it, so nothing had to be granted. The production shape is usually different — one shared Gateway in an istio-ingress namespace, owned by the platform team, with app teams attaching routes from their own namespaces. That requires from: Selector or from: All on the Gateway, and a ReferenceGrant in the target namespace whenever a route points at a Service across a boundary. I did not exercise a cross-namespace route or a ReferenceGrant. The mechanism is real, it is one of the six standard CRDs, and it is the object that lets a team say “yes, that route may point at my Service” — but I am telling you it exists, not showing you it working.
A real load balancer. Covered above — no MetalLB, no cloud shim, no external IP, ever.
Everything else in this chapter — the pod, the Service, the ports, the conditions, the 200s — came off a real cluster.
Final thoughts
The best thing about this chapter is that it is boring. You changed one string in a YAML file you had already written, and a completely different company’s proxy was replaced by Envoy, and your routes kept working. That is what an API standard is for, and it is why the Kubernetes series spent a whole chapter on Gateway API before this book existed: so that “adding Istio’s ingress” would be a one-word diff and a paragraph of consequences, rather than a new vocabulary of Gateway-the-Istio-CRD and VirtualService and the old, bespoke, Istio-only world that most tutorials still teach.
The second-best thing about it is the False. Not because the condition is wrong — on a real cloud it would be a page-worthy alarm — but because it forces the habit that separates people who operate systems from people who read dashboards. Two conformant implementations of the same specification, in the same situation, disagreed about whether the thing they had built was finished. Neither of them lied. The specification simply left room, and they filled it differently, and the only way to find out what was actually happening was to send a request and look at what came back.
Status is a controller’s opinion. Traffic is a fact. When they disagree, the traffic is the one your customers experience.
Speaking of which: the HTTPRoute above got two paths to two services and then I moved on, which is not the treatment it deserves. Next we do it properly — and then we take that same object, add one number, and ship a tenth of the bookshop’s live traffic to a build we do not yet trust, without restarting a single pod.
Comments