Gateway API
Configure GatewayClass, Gateway and Route objects, know which team owns each, and find why a route receives no traffic.
On this page
Cheatsheet#
Written against Gateway API v1.6 (standard channel). Every implementation (Envoy Gateway, Cilium, Istio, NGINX Gateway Fabric, Traefik and others) reads the same objects; controller names and log locations differ.
| Task | Command |
|---|---|
| Installed API version | kubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}' |
| Which classes exist, and are they accepted | kubectl get gatewayclass |
| Is the Gateway programmed, what address | kubectl get gateway -A |
| Why not | kubectl describe gateway web -n infra |
| Routes and hostnames | kubectl get httproute -A |
| Route attachment status | kubectl get httproute my-app -n my-namespace -o jsonpath='{.status.parents}' | jq |
| Listener address | kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}' |
| Backends behind a route | kubectl get endpointslice -n my-namespace -l kubernetes.io/service-name=my-app |
| Test a host without DNS | curl -H 'Host: app.example.com' http://<gateway-ip>/ |
| Cross-namespace permissions | kubectl get referencegrant -A |
| Controller logs (Envoy Gateway example) | kubectl logs -n envoy-gateway-system deploy/envoy-gateway |
A route that is not receiving traffic#
Traffic needs three things to line up. The Gateway has an address and reports Programmed=True. The Route reports Accepted=True and ResolvedRefs=True on the parent it asked for. The backend Service has ready endpoints. Every failure is one of those three.
kubectl get gateway -A # PROGRAMMED column and ADDRESS
kubectl get httproute -A
kubectl describe httproute my-app -n my-namespace | sed -n '/Status:/,$p'
kubectl get endpointslice -n my-namespace -l kubernetes.io/service-name=my-app| Condition (reason) | Meaning |
|---|---|
GatewayClass Accepted=False | No controller claims this controllerName, or its parameters are invalid |
Gateway Accepted=False | The controller rejected the spec: unsupported protocol, port or address |
Gateway Programmed=False (AddressNotAssigned) | Accepted, but the dataplane or load balancer is not ready yet |
Listener Conflicted=True (HostnameConflict, ProtocolConflict) | Two listeners on one port clash; the conflicting listener is not served |
Listener ResolvedRefs=False (InvalidCertificateRef) | TLS Secret missing, malformed, or in another namespace without a ReferenceGrant |
Route Accepted=False (NotAllowedByListeners) | The listener’s allowedRoutes does not admit this namespace or kind |
Route Accepted=False (NoMatchingListenerHostname) | Route hostnames do not intersect the listener hostname |
Route Accepted=False (NoMatchingParent) | sectionName or port in parentRefs matches no listener |
Route ResolvedRefs=False (BackendNotFound) | Backend Service missing or wrong port |
Route ResolvedRefs=False (RefNotPermitted) | Cross-namespace backend without a ReferenceGrant |
Route PartiallyInvalid=True | Some rules are invalid and dropped, the rest are served |
| All conditions true, still 503 or 500 | No ready endpoints behind the Service, or the backend is refusing connections |
| All conditions true, 404 | No rule matched the request path, method or headers |
A route with no status.parents at all has not been seen by any controller. Check that parentRefs names an existing Gateway and that its GatewayClass is accepted.
The resource model#
| Resource | Owned by | Purpose |
|---|---|---|
GatewayClass | Infrastructure provider | Names the controller implementation; cluster-scoped |
Gateway | Platform team | Listeners, ports, TLS certificates, which routes may attach |
ListenerSet | Application or platform team | Adds listeners to an existing Gateway from another namespace (standard since v1.5) |
HTTPRoute, GRPCRoute | Application team | L7 matching and forwarding, in the application’s namespace |
TLSRoute, TCPRoute, UDPRoute | Application team | SNI or port-based forwarding (TLSRoute standard since v1.5, TCP and UDP since v1.6) |
ReferenceGrant | Owner of the namespace being referenced | Permission for a cross-namespace reference |
BackendTLSPolicy | Service owner | TLS from the gateway to the backend (standard since v1.4) |
The split is the point. The platform team owns certificates and public addresses, application teams own their own routing, and neither needs write access to the other’s namespace.
Gateway API is a set of CRDs, not part of core Kubernetes. Install the CRD bundle your implementation supports. From v1.5, a ValidatingAdmissionPolicy named safe-upgrades.gateway.networking.k8s.io blocks installing experimental CRDs over standard ones and downgrading below v1.5.
A Gateway#
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: web
namespace: infra
spec:
gatewayClassName: my-gateway-class
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- { kind: Secret, name: wildcard-example-com } # same namespace as the Gateway
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels: { gateway-access: "true" }
- name: http
protocol: HTTP
port: 80
hostname: "*.example.com"allowedRoutes.namespaces.from defaults to Same, so routes in other namespaces are rejected with NotAllowedByListeners until it is set to Selector or All. Each listener reports status.listeners[].attachedRoutes, the fastest confirmation that attachment worked. For certificate handling, see TLS.
An HTTPRoute#
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: my-app
namespace: my-namespace
spec:
parentRefs:
- { name: web, namespace: infra, sectionName: https }
hostnames: ["app.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: /v2 }
backendRefs:
- { name: my-app-v2, port: 80, weight: 90 }
- { name: my-app-canary, port: 80, weight: 10 }
- matches:
- path: { type: Exact, value: /healthz }
backendRefs: [{ name: my-app, port: 80 }]
timeouts: { request: 2s } # extended support; check your implementation
- matches:
- headers: [{ name: x-beta, value: "true" }]
backendRefs: [{ name: my-app-beta, port: 80 }]Across every route attached to a listener, matches take precedence in this order: an Exact path, then the longest PathPrefix, then a method match, then the most header matches, then the most query parameter matches. Remaining ties go to the oldest route, then alphabetical namespace/name, then the first matching rule in list order. A request that matches nothing returns 404. RegularExpression path precedence is implementation-specific.
Weights split traffic proportionally across the backends of one rule. That is the whole canary mechanism: no extra CRD, no sidecar. A weight of 0 drains a backend without removing it. If one backend in a weighted rule is invalid, its share of requests gets a 500.
Cross-namespace references#
A Route may reference a Service in another namespace, and a Gateway may reference a certificate Secret in another namespace, only if that namespace contains a ReferenceGrant. The grant lives with the resource being referenced, so the owner of the data decides. Attaching a Route to a Gateway in another namespace is controlled by the listener’s allowedRoutes instead.
apiVersion: gateway.networking.k8s.io/v1 # v1 since Gateway API v1.5; use v1beta1 on older CRDs
kind: ReferenceGrant
metadata:
name: allow-my-namespace-routes
namespace: shared
spec:
from:
- { group: gateway.networking.k8s.io, kind: HTTPRoute, namespace: my-namespace }
to:
- { group: "", kind: Service, name: shared-api }A missing grant shows as ResolvedRefs=False with reason RefNotPermitted, not as a 404.
Filters#
Filters modify requests or responses for the rule they sit in. Implementations should apply them in the order listed. URLRewrite and RequestRedirect cannot share a rule, RequestRedirect cannot be combined with backendRefs, and most filter types may appear only once per rule.
rules:
- matches: [{ path: { type: PathPrefix, value: /api } }]
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set: [{ name: x-env, value: prod }]
remove: ["x-internal"]
- type: URLRewrite
urlRewrite:
path: { type: ReplacePrefixMatch, replacePrefixMatch: / } # /api/users -> /users
- type: RequestMirror
requestMirror:
backendRef: { name: my-app-shadow, port: 80 }
percent: 10 # mirror 10% of requests
backendRefs: [{ name: my-app, port: 80 }]
---
# HTTP to HTTPS redirect: a separate route attached to the port 80 listener, with no backendRefs
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: https-redirect, namespace: infra }
spec:
parentRefs: [{ name: web, sectionName: http }]
rules:
- filters:
- type: RequestRedirect
requestRedirect: { scheme: https, statusCode: 301 }RequestMirror sends a copy of each request and discards the response, which suits shadow-testing a new version under real load. Mirrored writes still happen, so point it only at backends that are safe to receive duplicate traffic. The CORS filter is standard since v1.5.
| Filter type | Level | What it does |
|---|---|---|
RequestHeaderModifier | Core | set, add, remove request headers before forwarding |
ResponseHeaderModifier | Core | Same operations on the response |
RequestRedirect | Core | Return a 301/302 with a new scheme, hostname, port or path; no backend is contacted |
URLRewrite | Extended | Change hostname or path (ReplaceFullPath, ReplacePrefixMatch) before forwarding |
RequestMirror | Extended | Copy requests to another backend; percent or fraction limits the share |
CORS | Extended | Preflight handling and Access-Control-* headers at the gateway |
ExtensionRef | Implementation-specific | Reference a controller’s own CRD, for example a Traefik Middleware or Envoy Gateway policy |
A filter can also sit inside a single backendRefs[] entry, in which case it applies only to requests sent to that backend. That is how you add an x-canary: true header for the canary half of a weighted split without touching the stable half.
Matching in detail#
A rule’s matches list is a logical OR; the fields inside one match are ANDed. A rule with no matches matches every request on the hostname.
rules:
- matches:
- path: { type: PathPrefix, value: /api }
method: GET # standard HTTP method names, upper case
queryParams: [{ name: version, value: "2" }] # Exact by default
- path: { type: RegularExpression, value: "^/v[0-9]+/users/[0-9]+$" } # implementation-specific dialect
headers:
- { type: RegularExpression, name: user-agent, value: "^Mozilla.*" }
backendRefs: [{ name: my-app, port: 80 }]PathPrefix matches on path element boundaries, so /api matches /api and /api/users but not /apiary. Path values must start with / and are compared before any URLRewrite runs. Header names are case-insensitive and only the first value of a repeated header is considered. Hostnames on the route may be a precise name or a wildcard starting with *.; a wildcard matches exactly one or more labels, so *.example.com covers a.example.com and a.b.example.com but not example.com. When both the listener and the route set a hostname, the route is served only for the intersection, and a more specific route hostname beats a wildcard when two routes overlap.
kubectl explain is the quickest way to see what your installed CRD version actually accepts:
kubectl explain httproute.spec.rules.matches.path # allowed path types
kubectl explain httproute.spec.rules.filters --recursive | head -60
kubectl explain httproute.spec.rules.retry 2>/dev/null || echo "retry not in this CRD bundle"TLS modes#
A listener has two TLS modes. Terminate decrypts at the gateway using certificateRefs, so HTTPRoutes can inspect paths and headers; this is the default and the only mode that works with HTTPRoute. Passthrough forwards the encrypted stream by SNI to a backend that holds its own certificate, and only TLSRoute can attach to such a listener.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: { name: edge, namespace: infra }
spec:
gatewayClassName: my-gateway-class
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- { kind: Secret, name: wildcard-example-com, namespace: certs } # needs a ReferenceGrant in certs
options:
example.com/min-tls-version: "1.2" # options are implementation-defined key/value pairs
- name: tls-passthrough
protocol: TLS
port: 8443
hostname: db.example.com
tls: { mode: Passthrough } # no certificateRefs; the backend terminates
allowedRoutes: { kinds: [{ kind: TLSRoute }] }
---
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata: { name: db, namespace: my-namespace }
spec:
parentRefs: [{ name: edge, namespace: infra, sectionName: tls-passthrough }]
hostnames: [db.example.com]
rules:
- backendRefs: [{ name: my-db, port: 5432 }]
---
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata: { name: allow-infra-gateways, namespace: certs }
spec:
from: [{ group: gateway.networking.k8s.io, kind: Gateway, namespace: infra }]
to: [{ group: "", kind: Secret }] # omit name to allow every Secret in the namespaceThe certificate Secret must be kubernetes.io/tls with tls.crt and tls.key. A Terminate listener with several certificateRefs selects by SNI; a client that sends no SNI gets the implementation’s default, usually the first. Re-encrypting to the backend is a separate object, BackendTLSPolicy, attached to the Service rather than the route so the service owner controls how their backend is verified:
apiVersion: gateway.networking.k8s.io/v1 # v1 since Gateway API v1.4
kind: BackendTLSPolicy
metadata: { name: my-app-tls, namespace: my-namespace }
spec:
targetRefs: [{ group: "", kind: Service, name: my-app }]
validation:
hostname: my-app.my-namespace.svc.cluster.local # SNI sent and name verified on the backend certificate
caCertificateRefs: [{ group: "", kind: ConfigMap, name: internal-ca }] # ConfigMap with a ca.crt key
# wellKnownCACertificates: System # alternative: trust the gateway's system CA bundleVerify what the gateway presents with openssl s_client -connect <ip>:443 -servername app.example.com </dev/null | openssl x509 -noout -subject -dates; a mismatch between -servername and the listener hostname is the usual reason for “wrong certificate”.
Traffic splitting and progressive delivery#
Weights alone give a blind percentage canary. Combining a header match with weights gives testers a deterministic path to the new version while the general population is split:
rules:
- matches: [{ headers: [{ name: x-canary, value: always }] }] # more header matches wins precedence
backendRefs: [{ name: my-app-v2, port: 80 }]
- matches: [{ path: { type: PathPrefix, value: / } }]
backendRefs:
- { name: my-app-v1, port: 80, weight: 95 }
- { name: my-app-v2, port: 80, weight: 5 }Weights are integers from 0 to 1,000,000 and are relative, so 95/5 and 19/1 are the same split. Distribution is per request, not per client, so a browser session will flip between versions unless the application is stateless or sessionPersistence is supported by the implementation. A backend whose Service exists but has no ready endpoints still receives its share and those requests fail with 503, which is why a promotion script should check EndpointSlices before moving weight. Move weight in steps by patching the route rather than editing it:
kubectl patch httproute my-app -n my-namespace --type json -p '[
{"op":"replace","path":"/spec/rules/1/backendRefs/0/weight","value":50},
{"op":"replace","path":"/spec/rules/1/backendRefs/1/weight","value":50}]'Argo Rollouts and Flagger both drive exactly this field, so a manual split and an automated one are interchangeable; see Argo CD for the GitOps side.
GRPCRoute#
gRPC is HTTP/2 with the method encoded in the path, so GRPCRoute matches on service and method instead of path and gets the same filters and weights:
apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata: { name: my-grpc, namespace: my-namespace }
spec:
parentRefs: [{ name: web, namespace: infra, sectionName: https }]
hostnames: [grpc.example.com]
rules:
- matches:
- method: { service: my.package.UserService, method: GetUser } # type Exact by default
backendRefs: [{ name: my-grpc, port: 9090 }]The listener must be HTTPS (or HTTP with an implementation that allows h2c). An HTTPRoute and a GRPCRoute on the same hostname is a conflict; the older route wins and the other reports Accepted=False.
Timeouts and retries#
timeouts.request bounds the whole exchange from the gateway’s point of view; timeouts.backendRequest bounds one attempt to a backend and must be no larger than request. Values are Go durations such as 30s or 250ms, and 0s disables the timeout where the implementation permits. Both are standard since v1.1 at Extended support, which means an implementation may reject them with UnsupportedValue.
rules:
- backendRefs: [{ name: my-app, port: 80 }]
timeouts: { request: 10s, backendRequest: 3s }
retry: # GEP-1731; check kubectl explain before relying on it
attempts: 2 # retries after the first attempt
codes: [502, 503, 504] # 500-504 must be supported; connection errors always retry
backoff: 100msRetries only make sense for idempotent requests; the API does not filter by method, so put POST endpoints in their own rule without retry. When both a timeout and retries are set, the request timeout caps the total including every retry.
Policy attachment#
Anything the core API does not model (rate limits, authentication, access logging, connection limits) is expressed by an implementation’s own CRD that targets a Gateway, listener, route or Service through targetRefs. Envoy Gateway’s BackendTrafficPolicy and SecurityPolicy, Cilium’s CiliumNetworkPolicy and Traefik’s Middleware via ExtensionRef all fit this pattern. A policy on the Gateway applies to every route unless a route-level policy overrides it, so list them per implementation when a route behaves differently from its spec:
kubectl api-resources --api-group=gateway.envoyproxy.io -o name # which policy kinds exist
kubectl get backendtrafficpolicy,securitypolicy -A 2>/dev/null # what is attached whereMigrating from Ingress#
Ingress-NGINX was retired in March 2026 and receives no further fixes. ingress2gateway converts Ingress objects and common annotations to Gateway API resources as a starting point.
| 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 |
nginx.ingress.kubernetes.io/ssl-redirect | RequestRedirect route on the HTTP listener |
| Controller-specific canary annotations | backendRefs[].weight |
Ingress-NGINX has behaviours that do not carry over, such as regex path matching and annotation-driven defaults. Run both side by side on different addresses, compare responses with curl --resolve, and move DNS last. See Before you migrate.
Troubleshooting Gateway API#
| Symptom | Likely cause | Check |
|---|---|---|
| Gateway has no address | Controller not running, or the load balancer is still provisioning | kubectl describe gateway, controller logs, kubectl get svc -n <controller-namespace> |
attachedRoutes: 0 on the listener | Namespace, hostname or sectionName mismatch | Route status.parents[].conditions, see the table above |
| TLS handshake fails or serves the wrong certificate | Listener ResolvedRefs=False, or SNI does not match hostname | openssl s_client -connect <ip>:443 -servername app.example.com |
Works by IP with Host header, fails by name | DNS points somewhere else | dig app.example.com, DNS |
| Intermittent 503 | Some backends failing readiness | EndpointSlice membership over time |
| Wrong backend answers | A more specific match on another route wins | List every route on that hostname and compare matches |
| Route ignored after CRD upgrade | CRD versions older than the implementation expects | Bundle version annotation on the CRDs, implementation’s compatibility matrix |
Accepted=False reason UnsupportedValue | Field such as timeouts, retry or a regex path not supported by this implementation | kubectl describe httproute message text, implementation’s conformance report |
| Certificate in another namespace never loads | Missing ReferenceGrant from: Gateway in the Secret’s namespace | kubectl get referencegrant -n <certs-ns> -o yaml, listener ResolvedRefs reason |
| Passthrough listener accepts but nothing connects | HTTPRoute attached to a TLS listener, or client sends no SNI | allowedRoutes.kinds, openssl s_client -servername vs without |
| Canary weight changed but traffic did not move | Controller reconciled an older cached route, or endpoints for the new backend are not ready | status.parents[].conditions[].observedGeneration vs metadata.generation, EndpointSlice for the canary |
gRPC calls fail with UNIMPLEMENTED | Method match too narrow, or HTTPRoute on the same hostname won the conflict | kubectl get grpcroute,httproute -A filtered by hostname |
| Redirect loop on HTTPS | RequestRedirect rule attached to the HTTPS listener as well as HTTP | parentRefs[].sectionName on the redirect route |
| Header filter has no effect | Filter placed under a backendRefs[] entry, not the rule, or a policy on the Gateway overrides it | kubectl get httproute -o yaml, implementation policy CRDs |
For timing and status code analysis of the requests themselves, see HTTP.
Oneliners#
# Every route and the parents 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 to at least one parent
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.namespace}/{.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 address, ignoring DNS and keeping SNI correct
curl -sv --resolve app.example.com:443:"$(kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}')" https://app.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 referenced by routes that have no EndpointSlice (same-namespace refs)
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.rules[].backendRefs[]? | "\(.namespace // $ns) \(.name)"' | sort -u | while read -r ns svc; do kubectl get endpointslice -n "$ns" -l kubernetes.io/service-name="$svc" --no-headers 2>/dev/null | grep -q . || echo "$ns/$svc has no endpoints"; done
# Controller errors (Envoy Gateway; substitute your implementation's namespace and deployment)
kubectl logs -n envoy-gateway-system deploy/envoy-gateway --tail 200 | grep -iE 'error|reject'
# Installed CRD bundle version and channel
kubectl get crd gateways.gateway.networking.k8s.io -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}{"\t"}{.metadata.annotations.gateway\.networking\.k8s\.io/channel}{"\n"}'
# Which Gateway API kinds are installed at all
kubectl api-resources --api-group=gateway.networking.k8s.io -o name
# GatewayClasses and whether a controller has accepted them
kubectl get gatewayclass -o custom-columns='NAME:.metadata.name,CONTROLLER:.spec.controllerName,ACCEPTED:.status.conditions[?(@.type=="Accepted")].status'
# Every hostname served by any route, with the route that claims it
kubectl get httproute,grpcroute,tlsroute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .kind as $k | .spec.hostnames[]? | "\(.)\t\($k) \($ns)/\($n)"' | sort
# Hostnames claimed by more than one route (precedence surprises live here)
kubectl get httproute -A -o json | jq -r '.items[].spec.hostnames[]?' | sort | uniq -d
# Routes whose status lags their spec (controller has not reconciled the latest edit)
kubectl get httproute -A -o json | jq -r '.items[] | select(.metadata.generation != (.status.parents[0].conditions[0].observedGeneration // 0)) | "\(.metadata.namespace)/\(.metadata.name) gen=\(.metadata.generation)"'
# Every listener with its protocol, port, hostname and TLS mode
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.listeners[] | "\($ns)/\($n)\t\(.name)\t\(.protocol):\(.port)\t\(.hostname // "*")\t\(.tls.mode // "-")"'
# Listeners in conflict
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .status.listeners[]? | select(any(.conditions[]; .type=="Conflicted" and .status=="True")) | "\($ns)/\($n) listener \(.name)"'
# Weighted splits currently in force
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.rules[] | select((.backendRefs // []) | length > 1) | "\($ns)/\($n)\t" + ([.backendRefs[] | "\(.name)=\(.weight // 1)"] | join(" "))'
# Shift a canary to 50/50 (rule index 1, backends 0 and 1)
kubectl patch httproute my-app -n my-namespace --type json -p '[{"op":"replace","path":"/spec/rules/1/backendRefs/0/weight","value":50},{"op":"replace","path":"/spec/rules/1/backendRefs/1/weight","value":50}]'
# All ReferenceGrants and what they permit
kubectl get referencegrant -A -o json | jq -r '.items[] | .metadata.namespace as $ns | "\($ns)/\(.metadata.name)\tfrom " + ([.spec.from[] | "\(.kind)@\(.namespace)"] | join(",")) + "\tto " + ([.spec.to[] | "\(.kind)/\(.name // "*")"] | join(","))'
# Cross-namespace backendRefs that have no matching ReferenceGrant
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.rules[].backendRefs[]? | select(.namespace != null and .namespace != $ns) | "\($ns) -> \(.namespace)/\(.name)"' | sort -u | while read -r from _ to; do ns=${to%%/*}; kubectl get referencegrant -n "$ns" -o json | jq -e --arg f "$from" '.items[].spec.from[] | select(.kind=="HTTPRoute" and .namespace==$f)' >/dev/null 2>&1 || echo "no grant in $ns for routes from $from"; done
# Certificate the gateway actually serves for a hostname
openssl s_client -connect "$(kubectl get gateway web -n infra -o jsonpath='{.status.addresses[0].value}')":443 -servername app.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates
# Days until each listener certificate expires
kubectl get gateway -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .spec.listeners[].tls.certificateRefs[]? | "\(.namespace // $ns) \(.name)"' | sort -u | while read -r ns s; do exp=$(kubectl get secret -n "$ns" "$s" -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -noout -enddate | cut -d= -f2); echo "$ns/$s $(( ($(date -d "$exp" +%s) - $(date +%s)) / 86400 ))d"; done
# Watch a route's conditions change while you edit it
kubectl get httproute my-app -n my-namespace -w -o jsonpath='{.status.parents[0].conditions[*].reason}{"\n"}'
# Does a header-based canary match: send the header and read which backend answered
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' -H 'x-canary: always' --resolve app.example.com:443:"$GW_IP" https://app.example.com/
# Fire 100 requests and count status codes during a weight shift
for _ in $(seq 100); do curl -s -o /dev/null -w '%{http_code}\n' --resolve app.example.com:443:"$GW_IP" https://app.example.com/; done | sort | uniq -c
# Live-render what kubectl would send before applying a route edit
kubectl apply -f route.yaml --dry-run=server -o yaml | yq '.status // "no status: object is new"'
# Delete every route that points at a Service which no longer exists (prints first; remove echo to act)
kubectl get httproute -A -o json | jq -r '.items[] | .metadata.namespace as $ns | .metadata.name as $n | .spec.rules[].backendRefs[]? | "\($ns) \($n) \(.namespace // $ns) \(.name)"' | sort -u | while read -r rns rn sns svc; do kubectl get svc -n "$sns" "$svc" >/dev/null 2>&1 || echo kubectl delete httproute -n "$rns" "$rn"; doneScripts#
Report every Gateway and Route in the cluster with its readiness, so a broken attachment shows up in one screen rather than one describe at a time.
#!/usr/bin/env bash
# gateway-status.sh: one line per Gateway listener and per Route parent with the conditions that matter
set -euo pipefail
cond() { jq -r --arg t "$2" '[.[]? | select(.type==$t)][0] | "\(.status // "?")/\(.reason // "-")"' <<<"$1"; }
printf '== Gateways ==\n'
kubectl get gateway -A -o json | jq -c '.items[]' | while read -r gw; do
ns=$(jq -r .metadata.namespace <<<"$gw"); name=$(jq -r .metadata.name <<<"$gw")
addr=$(jq -r '[.status.addresses[]?.value] | join(",") // "-"' <<<"$gw")
prog=$(cond "$(jq '.status.conditions' <<<"$gw")" Programmed)
printf '%s/%s addr=%s programmed=%s\n' "$ns" "$name" "${addr:--}" "$prog"
jq -c '.status.listeners[]?' <<<"$gw" | while read -r l; do
printf ' listener %-16s routes=%-3s accepted=%s resolved=%s conflicted=%s\n' \
"$(jq -r .name <<<"$l")" "$(jq -r .attachedRoutes <<<"$l")" \
"$(cond "$(jq .conditions <<<"$l")" Accepted)" "$(cond "$(jq .conditions <<<"$l")" ResolvedRefs)" \
"$(cond "$(jq .conditions <<<"$l")" Conflicted)"
done
done
printf '\n== Routes ==\n'
for kind in httproute grpcroute tlsroute; do
kubectl get "$kind" -A -o json 2>/dev/null | jq -c '.items[]' | while read -r r; do
ns=$(jq -r .metadata.namespace <<<"$r"); name=$(jq -r .metadata.name <<<"$r")
parents=$(jq -c '.status.parents // []' <<<"$r")
if [ "$parents" = "[]" ]; then printf '%s %s/%s NO STATUS (unseen by any controller)\n' "$kind" "$ns" "$name"; continue; fi
jq -c '.[]' <<<"$parents" | while read -r p; do
printf '%s %s/%s -> %s/%s accepted=%s resolved=%s\n' "$kind" "$ns" "$name" \
"$(jq -r '.parentRef.namespace // "'"$ns"'"' <<<"$p")" "$(jq -r .parentRef.name <<<"$p")" \
"$(cond "$(jq .conditions <<<"$p")" Accepted)" "$(cond "$(jq .conditions <<<"$p")" ResolvedRefs)"
done
done
donePromote a canary in steps, checking that the new backend has ready endpoints and that error rate stays low before each increase; on any failure it resets the weight to zero.
#!/usr/bin/env bash
# canary-promote.sh ROUTE NAMESPACE RULE_INDEX STABLE_IDX CANARY_IDX HOST
# Steps the canary weight through 5, 25, 50, 100 and rolls back on a 5xx rate above 2%.
set -euo pipefail
route=$1 ns=$2 rule=$3 stable=$4 canary=$5 host=$6
gw_ip=${GW_IP:?set GW_IP to the gateway address}
canary_svc=$(kubectl get httproute "$route" -n "$ns" -o jsonpath="{.spec.rules[$rule].backendRefs[$canary].name}")
set_weights() {
kubectl patch httproute "$route" -n "$ns" --type json -p "[
{\"op\":\"replace\",\"path\":\"/spec/rules/$rule/backendRefs/$stable/weight\",\"value\":$((100 - $1))},
{\"op\":\"replace\",\"path\":\"/spec/rules/$rule/backendRefs/$canary/weight\",\"value\":$1}]" >/dev/null
}
rollback() { echo "rolling back: canary weight 0" >&2; set_weights 0; exit 1; }
trap rollback ERR
ready=$(kubectl get endpointslice -n "$ns" -l kubernetes.io/service-name="$canary_svc" -o json | jq '[.items[].endpoints[]? | select(.conditions.ready==true)] | length')
[ "$ready" -gt 0 ] || { echo "no ready endpoints behind $canary_svc" >&2; exit 1; }
for w in 5 25 50 100; do
set_weights "$w"; echo "canary=$w% stable=$((100 - w))%"
sleep "${SETTLE:-30}"
total=0 errors=0
for _ in $(seq "${PROBES:-100}"); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 --resolve "$host:443:$gw_ip" "https://$host/" || echo 000)
total=$((total + 1)); case $code in 5*|000) errors=$((errors + 1));; esac
done
echo " probes=$total errors=$errors"
[ $((errors * 100 / total)) -le 2 ] || rollback
done
echo "promotion complete"Upstream reference: Gateway API documentation, API specification.