Why a successor to Ingress
Ingress has one object for everything. So everything a particular controller could do beyond host and path (weights, header rules, rewrites, timeouts) leaked into annotations - a different dialect per controller. And one team's Ingress could break a shared host for everyone. Gateway API is the newer, official replacement: the same job, split across objects owned by different people.
What you need to know already: Ingress, the controller, IngressClass, annotations and the retired ingress-nginx (16.19), LoadBalancer and MetalLB (16.6, 16.8), Services and EndpointSlices (16.1), namespaces and labels (15.26), HTTP headers (9.21).
Three objects, three owners
GatewayClass cluster-scoped. "This kind of gateway, run by that controller."
Owned by whoever installs the implementation (platform team).
Gateway a namespace's actual entry point: listeners (port, protocol,
hostname, TLS) and which namespaces may attach routes.
Owned by the platform / cluster operators.
HTTPRoute routing rules for one app: hostnames, matches, filters,
weighted backends. Owned by the app team, in its own namespace.
"Cluster-scoped" = not inside any namespace (like nodes). A listener is one port + protocol the Gateway accepts traffic on. The GatewayClass plays the role IngressClass played; the Gateway is the proxy itself; the HTTPRoute is the rules part of an Ingress.
In Ingress every one of those concerns was the same object - and an annotation. Here each has its own API with validation, and a status that tells you exactly why it is not working.
# once NGINX Gateway Fabric is installed (the Gateway mission)
k get gatewayclass
NAME CONTROLLER ACCEPTED AGE
nginx gateway.nginx.org/nginx-gateway-controller True 107s
CONTROLLER = which implementation handles the class; ACCEPTED True = that controller has taken it on.
Installing it
These kinds are not built into Kubernetes. They come as CRDs (custom resource definitions: files that add new object kinds to the API server, like MetalLB's IPAddressPool in 16.6). So you install two things:
- the Gateway API CRDs (the "standard channel" release), then
- an implementation: a controller plus the proxy it runs. Here that is NGINX Gateway Fabric; others are Envoy Gateway, Istio, Cilium, Traefik, kgateway, and cloud providers' own.
Without the CRDs, kubectl does not know the kind: error: resource mapping not found for name: "web" ... no matches for kind "Gateway" in version "gateway.networking.k8s.io/v1" ensure CRDs are installed first.
A Gateway
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: edge
namespace: gw-lab
spec:
gatewayClassName: nginx
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Same # Same (default) | All | Selector
One listener, plain HTTP on port 80. allowedRoutes says which namespaces' HTTPRoutes may attach: only this one (Same), any (All), or those whose labels match a selector (Selector).
NGINX Gateway Fabric provisions (creates for you) a proxy per Gateway - a Deployment and a LoadBalancer Service named <gateway>-<class> in the Gateway's namespace - so on this cluster MetalLB gives it an address:
# gw-lab: the Gateway above (the Gateway mission)
k get gateway -n gw-lab
NAME CLASS ADDRESS PROGRAMMED AGE
edge nginx 10.64.0.241 True 15s
k get pods,svc -n gw-lab -l gateway.networking.k8s.io/gateway-name=edge
NAME READY STATUS RESTARTS AGE
pod/edge-nginx-7s8wm9zp26-5vh5p 1/1 Running 0 13s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/edge-nginx LoadBalancer 10.96.204.113 10.64.0.241 80:31746/TCP 14s
ADDRESS = where clients send traffic; PROGRAMMED True = the proxy is configured and running. PROGRAMMED False with reason AddressNotAssigned means the implementation could not get an address - on bare metal, no MetalLB pool.
An HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: gw-lab
spec:
parentRefs:
- name: edge # attach to this Gateway (optionally sectionName: http)
hostnames: [shop.lab]
rules:
- matches:
- headers:
- name: x-canary
value: "true"
backendRefs:
- name: v2
port: 80
- backendRefs: # no matches = PathPrefix /
- name: v1
port: 80
weight: 90
- name: v2
port: 80
weight: 10
The parts:
parentRefs which Gateway (and optionally which listener, sectionName) to attach to
hostnames the Host headers this route handles
rules a list; each has matches (when) and backendRefs (where to send)
backendRefs Services + ports; weight = share of requests
So: requests with header x-canary: true go to v2; all others are split 90/10 between v1 and v2. That is a canary release: a new version gets a small slice of real traffic first, and you watch it before sending more.
Things Ingress needed annotations for, built in:
- matches on path (
PathPrefix,Exact,RegularExpression), headers, query params, method. - weights across backends - a canary without extra tools.
- filters (change the request on its way):
RequestHeaderModifier,RequestRedirect(http->https),URLRewrite(what rewrite-target did),RequestMirror(send a copy to another backend).
The most specific match wins (longer paths, then more header matches).
Status is the debugger
Every route records, per Gateway it tried to attach to, a list of conditions: a type, status True/False, a machine reason and a human message. grep -A20 '^status' shows the 20 lines after status::
# the HTTPRoute above (the Gateway mission)
k get httproute shop -n gw-lab -o yaml | grep -A20 '^status'
status:
parents:
- conditions:
- message: The Route is accepted
reason: Accepted
status: "True"
type: Accepted
- message: All references are resolved
reason: ResolvedRefs
status: "True"
type: ResolvedRefs
controllerName: gateway.nginx.org/nginx-gateway-controller
parentRef:
name: edge
The two conditions to check: Accepted (the Gateway took the route) and ResolvedRefs (every Service it points at was found and allowed).
The reasons you will meet when one is False:
NotAllowedByListeners the Gateway's allowedRoutes does not admit your namespace
NoMatchingListenerHostname your hostnames do not fit any listener's hostname
NoMatchingParent wrong Gateway name, or its class is not handled
BackendNotFound the Service does not exist
RefNotPermitted a Service in another namespace, without permission (below)
Crossing namespaces: ReferenceGrant
An HTTPRoute in gw-lab pointing at a Service in payments is refused (ResolvedRefs False, RefNotPermitted, and the proxy answers 500) until the target namespace opts in with a ReferenceGrant:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gw-lab-routes
namespace: payments # lives with the thing being referenced
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: gw-lab
to:
- group: ""
kind: Service
Read it as: "HTTPRoutes from namespace gw-lab may point at Services in payments". (group: "" is the core API group, where Service lives.)
The owner of the Service decides who may route to it - the security model Ingress never had (an Ingress could only point at Services in its own namespace at all).
Ingress vs Gateway, in one table
Ingress Gateway API
one object GatewayClass / Gateway / HTTPRoute (+GRPCRoute, TLSRoute...)
controller features in annotations typed fields (weights, headers, rewrites, redirects)
same-namespace backends only cross-namespace with ReferenceGrant
status: an address per-route, per-listener conditions with reasons
GA, stable, not deprecated GA (v1) since 2023, the future
(GA = "generally available": the stable, supported version of an API.)
ingress2gateway (a tool from the Kubernetes project) converts Ingress manifests, including common ingress-nginx annotations - the usual first step of a migration off the retired controller.
What you can now do:
- create a Gateway and attach an HTTPRoute to it
- split traffic by weight and by header for a canary
- read route conditions, and fix NotAllowedByListeners and RefNotPermitted