Kubernetes
Gateway API
GatewayClass, Gateway and Route objects, who owns each one, and how to find why a route is receiving no traffic.
Cheatsheet #
| Task | Command |
|---|---|
| Is the Gateway programmed | kubectl get gateway -A |
| Why not | kubectl describe gateway web -n infra |
| Is the route attached | kubectl get httproute -A -o wide |
| Attachment status | kubectl get httproute api -o jsonpath='{.status.parents[*].conditions[*]}' | jq |
| Listener address | kubectl get gateway web -o jsonpath='{.status.addresses[0].value}' |
| Backends behind a route | kubectl get endpointslice -l kubernetes.io/service-name=api |
| Test a host without DNS | curl -H 'Host: api.example.com' http://<gw-ip>/ |
| Which classes exist | kubectl get gatewayclass |
| Controller logs | kubectl logs -n envoy-gateway-system deploy/envoy-gateway |
| Cross-namespace permission | kubectl get referencegrant -A |
A route that is not receiving traffic #
Traffic needs three things to line up: the Gateway has an address and is Programmed, the Route reports Accepted and ResolvedRefs on the parent it asked for, and the backend Service has ready endpoints. Every failure is one of those three.
kubectl get gateway -A
kubectl get httproute -A -o wide
kubectl describe httproute api -n apps | sed -n '/Status:/,$p'
kubectl get endpointslice -n apps -l kubernetes.io/service-name=api| Condition | Meaning |
|---|---|
Gateway Accepted=False | The GatewayClass controller rejected the spec, usually TLS or listener conflict |
Gateway Programmed=False | Accepted but the dataplane is not ready; no load balancer address yet |
Route Accepted=False | The listener refused attachment: hostname mismatch, allowedRoutes namespace selector, or wrong sectionName |
Route ResolvedRefs=False | Backend Service missing, wrong port, or a cross-namespace reference without a ReferenceGrant |
| All green, still 503 | No ready endpoints behind the Service |
The resource model #
| Resource | Owned by | Purpose |
|---|---|---|
GatewayClass | Infrastructure | Names the controller implementation, cluster-scoped |
Gateway | Platform team | Listeners, ports, TLS certificates, which routes may attach |
HTTPRoute, GRPCRoute, TCPRoute | Application team | Matching and forwarding rules, in the application’s namespace |
ReferenceGrant | The namespace being referenced | Permission for a cross-namespace reference |
BackendTLSPolicy | Either | TLS from the gateway to the backend |
The split is the point: the platform team owns certificates and public addresses, application teams own their own routing, and neither needs edit access to the other’s namespace.
A Gateway #
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: web
namespace: infra
spec:
gatewayClassName: envoy
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- { kind: Secret, name: wildcard-example-com }
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels: { gateway-access: "true" }
- name: http
protocol: HTTP
port: 80
hostname: "*.example.com"allowedRoutes.namespaces.from is Same by default, so routes in other namespaces are silently ignored until it is set to Selector or All. Each listener reports its own attachedRoutes count, which is the fastest confirmation that attachment worked.
An HTTPRoute #
Matching is most-specific-first and deterministic: exact path beats prefix, longer prefix beats shorter, then header and query matches break ties. Rule order in the file does not decide precedence.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api
namespace: apps
spec:
parentRefs:
- { name: web, namespace: infra, sectionName: https }
hostnames: ["api.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: /v2 }
backendRefs:
- { name: api-v2, port: 80, weight: 90 }
- { name: api-canary, port: 80, weight: 10 }
- matches:
- path: { type: Exact, value: /healthz }
backendRefs: [{ name: api, port: 80 }]
timeouts: { request: 2s }
- matches:
- headers: [{ name: x-beta, value: "true" }]
backendRefs: [{ name: api-beta, port: 80 }]Weights split traffic proportionally across backends in the same rule, which is the whole canary mechanism — no extra CRD, no sidecar. A weight of 0 drains a backend without removing it.
Cross-namespace references #
A Route in one namespace may only reference a Service or Secret in another if that namespace publishes a ReferenceGrant. The grant lives with the resource being referenced, so the owner of the data decides.
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-apps-routes
namespace: shared
spec:
from:
- { group: gateway.networking.k8s.io, kind: HTTPRoute, namespace: apps }
to:
- { group: "", kind: Service, name: shared-api }Missing grants surface as ResolvedRefs=False with RefNotPermitted, not as a 404.
Filters #
Filters run in the order listed, before the backend is selected.
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set: [{ name: x-env, value: prod }]
remove: ["x-internal"]
- type: RequestRedirect
requestRedirect: { scheme: https, statusCode: 301 }
- type: URLRewrite
urlRewrite:
path: { type: ReplacePrefixMatch, replacePrefixMatch: / }
- type: RequestMirror
requestMirror: { backendRef: { name: api-shadow, port: 80 } }RequestMirror copies traffic and discards the response, which makes it safe for shadow-testing a new version under real load.
Migrating from Ingress #
| Ingress | Gateway API |
|---|---|
ingressClassName | gatewayClassName on the Gateway |
spec.tls | listeners[].tls.certificateRefs |
spec.rules[].host | hostnames on the Route |
Path type Prefix | path.type: PathPrefix |
nginx.ingress.kubernetes.io/rewrite-target | URLRewrite filter |
| Controller-specific canary annotations | backendRefs[].weight |
Both can serve the same hostname during a migration as long as they hold different addresses; move DNS last.
Oneliners #
# Every route and the parent it claims
kubectl get httproute -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.spec.parentRefs[*].name}{"\n"}{end}'
# Routes that failed to attach
kubectl get httproute -A -o json | jq -r '.items[] | select([.status.parents[].conditions[] | select(.type=="Accepted" and .status!="True")] | length > 0) | "\(.metadata.namespace)/\(.metadata.name)"'
# Attached route count per listener
kubectl get gateway -A -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .status.listeners[*]}{.name}={.attachedRoutes}{" "}{end}{"\n"}{end}'
# Gateway addresses
kubectl get gateway -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name}{"\t"}{.status.addresses[*].value}{"\n"}{end}'
# Hit a host through the gateway IP, ignoring DNS
curl -sv --resolve api.example.com:443:"$(kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}')" https://api.example.com/healthz
# Certificates referenced by every listener
kubectl get gateway -A -o json | jq -r '.items[].spec.listeners[]?.tls?.certificateRefs[]?.name' | sort -u
# Backends with no endpoints
kubectl get httproute -A -o json | jq -r '.items[].spec.rules[].backendRefs[].name' | sort -u | xargs -I{} sh -c 'kubectl get endpointslice -l kubernetes.io/service-name={} -A --no-headers | grep -q . || echo "{} has no endpoints"'
# Controller errors, whichever implementation is installed
kubectl logs -n envoy-gateway-system deploy/envoy-gateway --tail 200 | grep -iE 'error|reject'