Gateway API

The successor to Ingress, and why it exists.

Advanced8 min · lesson 37 of 65
In plain terms
Gateway API is the same reception idea, but with clearer job roles — the building owner installs the door, and each team writes its own “send my visitors here” note without touching the shared door.

Ingress isn't dead. It's frozen. The Kubernetes community stopped adding features to it years ago, and that one fact is the whole reason Gateway API exists. Here's what Ingress does, in plain terms. Your app runs inside Pods (a Pod is the smallest unit Kubernetes runs, basically a wrapper around one running container), and because Pods come and go, Kubernetes puts a stable name in front of a group of them called a Service. Ingress takes a web address, a hostname and path like app.example.com/checkout, and points it at that Service over HTTP, the everyday protocol behind web pages. It does that one job cleanly. The trouble starts the moment you want anything more. Match on a request header. Send 10% of traffic to a new build as a test. Carry raw TCP or UDP (the low-level protocols underneath most network traffic) instead of web requests. Let two teams share one entry point without stepping on each other's config. For any of those, Ingress makes you paste in vendor-specific annotations, scribbled margin-notes on the config that only one company's software understands and that stop working the day you move to another cluster.

The cracks Ingress couldn't patch

Three problems, and they stack up. First, Ingress is built HTTP-shaped, so anything that isn't a web request always felt bolted on: raw TCP, UDP, or gRPC (a fast way for small back-end services to call each other, the plumbing inside modern apps). Second, every interesting feature hides inside an annotation like nginx.ingress.kubernetes.io/canary-weight. An annotation is just a labeled note stapled to the object, and that particular note means nothing to a different vendor's software, so your config gets welded to one product. Move clusters, and you rewrite everything. Third, and this one bites big teams hardest: a single Ingress object smashes together two jobs that belong to two different people. The person who owns the load balancer (the LB, the internet-facing box that holds the public IP address and the TLS certificates, TLS being the lock behind HTTPS) and the person who just wants /checkout sent to their Pod are both editing the same file. One fat-fingered TLS block, and routing breaks for every app sitting behind that shared front door.

Think of an office building. Old way: any team that wanted visitors sent up to their floor had to walk down and reconfigure the front door themselves. New way: the building owner installs and owns the front door, and each team just drops a labeled note in the mailroom, "my visitors go to floor 3." Nobody touches the door except the owner. That clean split is the whole point of Gateway API.

Three objects, three owners

Gateway API takes that one overloaded Ingress object and splits it into a chain, and each link has an obvious owner. GatewayClass is the template for a kind of gateway, published by whoever wrote the software that powers it (a cloud provider, or a project like Cilium, NGINX, or Istio). You almost never create a GatewayClass. You pick one off the shelf. Gateway is the live listener, the actual front door, with an IP address, ports, and certificates, and the cluster operators (the platform team) own it. Routes are where an app team says "send this traffic to that Service": an HTTPRoute for web traffic, a GRPCRoute for service-to-service calls, a TCPRoute for raw connections. App teams own their Routes and attach each one to a Gateway by naming it. None of this works until someone installs the objects that teach Kubernetes these new kinds. Those are CRDs, Custom Resource Definitions, and installing them extends the API server (the cluster's single front desk that every kubectl command talks to) with vocabulary it didn't ship with. Apply the standard-channel set with kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.0/standard-install.yaml, then look at what controller your cluster actually offers.

check-class.sh
kubectl get gatewayclass
gatewayclass.out
NAME CONTROLLER ACCEPTED AGE
nginx gateway.nginx.org/nginx-gateway-controller True 6m

The Gateway: the front door

The cluster operator creates the Gateway inside an infrastructure namespace (a namespace is just a folder that walls off one team's objects from everyone else's). Now the part that trips people up: creating the Gateway object does nothing by itself. Writing it down is like filing a building permit. The paper sitting in the drawer doesn't pour any concrete. Somewhere in the cluster a controller is watching, and a controller is a program that runs in a loop, notices new requests, and does the real work to make the world match what you asked for. It's the contractor who reads your permit and actually builds. This one sees the new Gateway, provisions a real load balancer or configures a proxy, then writes the result back onto the object as status conditions, small stamped verdicts like "done" or "blocked." The condition you watch is Programmed. When Programmed flips to True and an ADDRESS shows up, the data plane (the part that actually carries your users' traffic) is genuinely live. Look at the allowedRoutes block below. It decides which namespaces are allowed to attach Routes to this listener. The field is tiny and it quietly decides whether the whole thing works at all.

gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: prod-gw
namespace: infra
spec:
gatewayClassName: nginx
listeners:
- name: web
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
get-gateway.out
$ kubectl get gateway prod-gw -n infra
NAME CLASS ADDRESS PROGRAMMED AGE
prod-gw nginx 203.0.113.10 True 90s

The Route: app teams attach their own

Now the app team, over in their own namespace, writes an HTTPRoute and points its parentRefs at the Gateway. The word parentRef just means "the Gateway I want to hang off of." The controller runs its loop again and asks two plain questions. Will a listener on that Gateway actually accept this Route? And does the backend Service this Route names really exist? It writes both answers back as conditions, which work like the tracking on a parcel. Accepted: True is "the depot took your package." ResolvedRefs: True is "the delivery address is real." Get both greens and traffic flows. Those two conditions are almost your entire debugging surface, so get comfortable reading them with kubectl describe.

httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: web
namespace: team-web
spec:
parentRefs:
- name: prod-gw
namespace: infra
hostnames:
- app.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: web
port: 80
describe-route.out
$ kubectl describe httproute web -n team-web
...
Status:
Parents:
Controller Name: gateway.nginx.org/nginx-gateway-controller
Parent Ref:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: prod-gw
Namespace: infra
Conditions:
Type: Accepted
Status: True
Reason: Accepted
Message: The route is accepted
Type: ResolvedRefs
Status: True
Reason: ResolvedRefs
Message: All references are resolved

When a Route misbehaves, don't guess. Read those two conditions. Accepted: False with reason NotAllowedByListeners means the Gateway listener refused to attach it, usually a namespace permission problem. Accepted: False with NoMatchingListenerHostname means your hostname doesn't line up with the listener. ResolvedRefs: False with BackendNotFound means you typed the Service name or port wrong. ResolvedRefs: False with RefNotPermitted means the backend lives in another namespace and you never granted permission (that's what a ReferenceGrant object is for, an explicit "yes, this namespace may send traffic to my Service").

Gateway API role separation
Infrastructure provider (writes the controller)
GatewayClass
nginx / cilium / istio; picks the data plane you get
Cluster operator (platform team), namespace: infra
Gateway
the front door: IP 203.0.113.10, ports, TLS certs
listeners + allowedRoutes
decides which namespaces may attach
App team web, namespace: team-web
HTTPRoute
hostnames + path matches to a Service
App team payments, namespace: team-pay
GRPCRoute
gRPC method routing to a Service
A Route can attach to nothing and say almost nothing
The most common Gateway API failure in production: an app team creates a perfect HTTPRoute, the backend Service is healthy, and requests still 404. The cause is usually the listener's allowedRoutes.namespaces, which defaults to Same. That default means a Gateway in the infra namespace will only accept Routes that also live in infra. A Route in team-web is silently rejected. The only clue is Accepted: False, reason NotAllowedByListeners on the Route, which nobody reads until they know to. Fix it by setting from: Selector on the listener and labeling the app namespace (kubectl label namespace team-web gateway-access=true), or use from: All if any namespace may attach. Set this policy on day one, because the failure is invisible from the app side.

Without a GatewayClass implementation, CRDs are inert. Same lesson as Ingress controllers.

Migration is gradual. Many clusters run Ingress and Gateway side by side during the move.

Policy attachment and timeouts become first-class instead of vendor annotations. Read the route status conditions.

Try this

List GatewayClass and Gateway objects if the API is installed. Compare the separation of Gateway (infra) and HTTPRoute (app) to a classic Ingress annotation pile.

terminal
$ kubectl get gatewayclass
$ kubectl get gateway prod-gw -n infra
NAME CLASS ADDRESS PROGRAMMED AGE
prod-gw nginx 203.0.113.10 True 90s
$ kubectl describe httproute web -n team-web
...
Status:
Parents:
Controller Name: gateway.nginx.org/nginx-gateway-controller
Parent Ref:
Group: gateway.networking.k8s.io
Kind: Gateway
Name: prod-gw
Namespace: infra
Conditions:
Type: Accepted
Status: True
Reason: Accepted
Message: The route is accepted
Type: ResolvedRefs
Status: True
Reason: ResolvedRefs
Message: All references are resolved

Takeaway

Gateway API splits infrastructure and application routing roles. It is the successor direction to Ingress for portable, expressive HTTP and more.

Quick check
01An app team created an HTTPRoute in namespace team-web pointing at Gateway prod-gw in namespace infra. The backend Service exists and is healthy, but requests to the hostname return 404, and kubectl describe httproute shows Accepted: False with reason NotAllowedByListeners. What's actually wrong?
Correct — Listeners gate which namespaces may attach, and the default is from: Same. NotAllowedByListeners is the exact signal for this. Fix it with from: Selector plus a matching namespace label, or from: All.
Incorrect — That's a real feature, but a missing cross-namespace backend grant surfaces as ResolvedRefs: False with reason RefNotPermitted, not Accepted: False / NotAllowedByListeners.
Incorrect — If the class or controller weren't working, the Gateway itself would show Programmed: False with no ADDRESS, and the Route wouldn't get a per-listener acceptance decision at all.
Incorrect — A hostname that doesn't line up reports reason NoMatchingListenerHostname, a different condition entirely from NotAllowedByListeners.
02You apply a Gateway object and immediately run kubectl get gateway, but the ADDRESS column is empty and PROGRAMMED does not show True. Based on how Gateway API works, what has to happen before traffic can flow?
Incorrect — accepting the YAML only records your request; the API server provisions no data plane on its own.
Incorrect — the address is provisioned by the controller and written back to status, not something you hand-set in the spec.
Correct — the Gateway is like a filed permit, and a controller does the real work, stamping Programmed: True with an address once the data plane is up.
Incorrect — you pick a GatewayClass off the shelf and never recreate it; the Gateway alone triggers its controller.
03An HTTPRoute in namespace team-web attaches cleanly to the Gateway (Accepted: True), but its backendRefs point at a Service in namespace shared-svc. kubectl describe shows ResolvedRefs: False with reason RefNotPermitted, and requests fail. What fixes it?
Incorrect — the Route is already Accepted: True; namespace labels gate listener attachment, not cross-namespace backend access.
Correct — RefNotPermitted means a cross-namespace backend reference is not authorized, and a ReferenceGrant in the target namespace is the explicit permission.
Incorrect — that widens which namespaces may attach Routes, but this Route already attached; the failure is on the backend reference.
Incorrect — a hostname mismatch reports NoMatchingListenerHostname and blocks acceptance, but this Route is already Accepted: True.

Related