The Ingress resource froze in time. It has been v1 and effectively feature-complete since Kubernetes 1.19, which is why every controller bolted its real capabilities — canaries, header routing, rewrites, gRPC — onto a sprawl of nginx.ingress.kubernetes.io/* annotations that don’t port between vendors. The Gateway API is the official replacement: a typed, role-separated, expressive successor that graduated its core resources (GatewayClass, Gateway, HTTPRoute) to v1 (GA) in October 2023 and has been extended every release since. This guide takes you from the resource model through weighted traffic splitting and a dual-running cutover off Ingress — the way a platform team actually rolls it out.
Everything here targets Gateway API v1.x as it ships today. Commands are real and current; where a feature is still in v1alpha2/experimental I flag it rather than imply it is stable.
In a nutshell
Level: Advanced · Time: ~30–35 min
If you have ever wired up an Ingress, you have met its ceiling: the moment you needed a canary, a header rule, or a rewrite, you reached for a pile of nginx.ingress.kubernetes.io/... annotations that only worked on one controller. The Gateway API is the official, built-for-purpose replacement — the modern successor to Ingress. It expresses the same job (get outside traffic to the right Service) but with typed fields for routing, and, crucially, it splits one overloaded object into a few resources that different teams own.
Picture a shared office building. The facilities team runs the front desk: the doors, the badge readers, the lobby — this is the Gateway, with its listeners, ports, and TLS. Each tenant company posts its own directory at that desk: “visitors for us → 4th floor, but anyone carrying a beta pass → the new annex” — this is an HTTPRoute, with hostnames, path/header matching, and weighted splits. Nobody hands a tenant the keys to the building’s electrical room, and no tenant can break another tenant’s signage. Ingress was one clipboard everyone scribbled on; Gateway API gives facilities and tenants separate, well-defined jobs.
Three resources carry that model: a GatewayClass says what kind of load balancer is available (like a StorageClass), a Gateway is one running instance of it with real listeners and an IP, and an HTTPRoute is one app’s routing rules attached to that Gateway. Add weighted backendRefs and you get a 90/10 canary as a first-class field — no annotations, no controller-specific magic. That is the whole idea; the rest of this lesson makes it concrete.
Prerequisites and what you’ll be able to do
You’ll get the most from this lesson if you’re comfortable with Services and their ClusterIP/port model, and have used an Ingress at least once. If either is fuzzy, skim Kubernetes Services, Endpoints & DNS and Ingress Controllers, TLS & Routing first — this lesson is the sequel to both.
After working through it you will be able to:
- Explain the
GatewayClass→Gateway→HTTPRoute→Servicechain and say which team owns each link. - Stand up a conformant controller and confirm a
GatewayClassreportsAccepted=True. - Write an
HTTPRoutethat matches on path, header, or method and forwards to the right backend. - Express a canary as weighted
backendRefsand shift the split with a singlekubectl patch. - Gate cross-namespace access with
allowedRoutesandReferenceGrant. - Migrate off Ingress with
ingress2gatewayusing a dual-run cutover that can’t drop live traffic. - Read a route’s
status.conditionsto tell “applied” from “actually serving.”
The request path at a glance
Read it left to right. A client opens an HTTPS connection carrying the production Host header. It lands on a Gateway the platform team owns — a listener on :443 that terminates TLS and, via allowedRoutes, decides which namespaces may attach. The app team’s HTTPRoute attaches through parentRefs, matches the request, and hands it to weighted backendRefs — 90% to the stable v1 Service, 10% to the v2 canary. The controller continuously writes back status conditions (Accepted, ResolvedRefs, attachedRoutes); a green status is the difference between a route that exists and one that serves, and a red alert like NotAllowedByListeners is exactly where a silent misconfiguration shows up. Each numbered badge is a control point the lesson returns to.
1. The resource model and why it is split three ways
Ingress conflates two audiences into one object: the cluster operator who owns the load balancer and TLS, and the app team who owns routing. Gateway API splits that into a layered model where each resource has one owner.
| Resource | API version | Owned by | Responsibility |
|---|---|---|---|
GatewayClass |
v1 |
Infra provider | Cluster-scoped template; binds to a controller implementation |
Gateway |
v1 |
Infra/platform team | Listeners, ports, protocols, TLS termination, a real LB |
HTTPRoute |
v1 |
Application team | Hostname/path matching, filters, weighted backends |
ReferenceGrant |
v1beta1 |
Owner of the target namespace | Explicit opt-in for cross-namespace references |
The chain is GatewayClass <- Gateway <- HTTPRoute -> Service. A GatewayClass names a controllerName (for example gateway.envoyproxy.io/gatewayclass-controller). A Gateway references a GatewayClass and declares listeners. An HTTPRoute attaches to a Gateway via parentRefs and forwards to backend Services.
Mental model:
GatewayClassis the kind of load balancer available (like a StorageClass).Gatewayis one provisioned instance of it.HTTPRouteis a tenant renting a hostname on that instance. Ownership, RBAC, and namespaces fall on those seams cleanly — which is the entire point.
Beginners trip on why three objects. The answer is ownership. A Gateway needs a real IP, a load balancer, and a TLS private key — things a cluster operator provisions and an app team should never touch. An HTTPRoute needs hostnames, path rules, and canary weights — things the app team changes ten times a day and the operator shouldn’t gate. Ingress crammed both into one object, so every routing tweak was a shared-blast-radius edit on the object that also held the cert. Splitting them means the operator sets the boundary once and app teams move fast inside it.
If you already think in Ingress, this table is the whole translation:
| If you’re thinking in Ingress terms… | …the Gateway API equivalent is | Owned by |
|---|---|---|
IngressClass |
GatewayClass |
Infra provider |
The controller + spec.tls block on an Ingress |
Gateway listeners + tls |
Platform team |
spec.rules[].http.paths[] |
HTTPRoute rules + matches |
App team |
nginx.ingress.kubernetes.io/canary-weight |
backendRefs[].weight (typed field) |
App team |
nginx.ingress.kubernetes.io/rewrite-target |
URLRewrite filter |
App team |
nginx.ingress.kubernetes.io/canary-by-header |
a headers match rule |
App team |
| (nothing — cross-namespace was implicit) | ReferenceGrant (explicit opt-in) |
Target-ns owner |
Everything a single Ingress plus its annotations expressed now has a typed home, and each home has exactly one owner.
2. Role separation and namespace boundaries
The split is not cosmetic; it changes who can grant what. Two controls enforce the boundary:
Gateway.spec.listeners[].allowedRoutes decides which namespaces may attach routes to a listener. ReferenceGrant decides whether an HTTPRoute in namespace A may forward to a Service in namespace B. Both default to closed: same-namespace only.
A platform team typically runs one shared Gateway and lets vetted app namespaces attach to it:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: gateway-infra
spec:
gatewayClassName: envoy-gateway
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.apps.kloudvin.io"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: apps-wildcard-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway-access: "true"
Now only namespaces carrying gateway-access: true can bind. An app team’s route in such a namespace references the shared Gateway across the namespace boundary:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: team-shop
spec:
parentRefs:
- name: shared-gateway
namespace: gateway-infra
hostnames:
- shop.apps.kloudvin.io
rules:
- backendRefs:
- name: shop-svc
port: 8080
If shop-svc lived in a different namespace than the route, you would also need a ReferenceGrant in that target namespace authorizing the cross-namespace backendRef. Same-namespace forwarding, as above, needs none.
3. Install a conformant implementation
Gateway API is a set of CRDs plus a controller that implements them. The CRDs install once; the controller is your choice — Envoy Gateway, NGINX Gateway Fabric, Istio, Cilium, and the managed offerings (GKE Gateway, AWS Gateway API controller) are all conformant. I’ll use Envoy Gateway as it is a clean, dedicated implementation.
Install the standard-channel CRDs, then the controller:
# Standard channel: GA resources (Gateway, GatewayClass, HTTPRoute) + ReferenceGrant
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.3.0/standard-install.yaml
# Envoy Gateway controller
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.4.0 -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
The CRDs ship in two channels. Standard carries GA resources. Experimental adds alpha fields and resources like
TCPRoute,TLSRoute, and newer policy attachments. Pick one channel cluster-wide and never mix them — installing experimental over standard is fine, but reverting drops fields and can orphan objects.
Each implementation ships its own GatewayClass. Create one that binds to Envoy Gateway’s controller:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: envoy-gateway
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
Confirm the class is accepted before going further — an unaccepted class means nothing downstream will provision:
kubectl get gatewayclass envoy-gateway -o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}'
# Expect: True
4. Listeners, TLS termination, and matching in HTTPRoute
A Gateway listener defines the L4/TLS entry point; the HTTPRoute does L7 matching. TLS is terminated at the Gateway via mode: Terminate referencing a Kubernetes Secret of type kubernetes.io/tls — the same secret shape Ingress used, so your existing cert-manager Certificate objects carry over untouched.
Matching in an HTTPRoute is far richer than Ingress paths. You match on path (Exact, PathPrefix, RegularExpression), headers, query params, and method, all within a single rule:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api
namespace: team-shop
spec:
parentRefs:
- name: shared-gateway
namespace: gateway-infra
hostnames:
- api.apps.kloudvin.io
rules:
- matches:
- path:
type: PathPrefix
value: /v2
method: GET
backendRefs:
- name: api-v2
port: 80
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: api-v1
port: 80
Rule ordering is specification-defined, not first-match-wins guesswork. The spec mandates precedence: an exact path beats a prefix, a longer prefix beats a shorter one, and more matching criteria beat fewer. That determinism is a real upgrade over annotation-driven Ingress, where ordering was per-controller behavior you had to memorize.
When two rules could match, the spec ranks them deterministically — you never guess:
- Exact path beats PathPrefix beats RegularExpression.
- Among prefixes, the longest prefix wins.
- Then the rule with the most matching conditions (a rule matching path and header beats one matching path alone).
- Ties break by rule order within the route, then the oldest
HTTPRoute(creation timestamp), then namespace/name alphabetically.
Because that ranking is identical on every conformant controller, a route that behaves one way on NGINX Gateway Fabric behaves the same on Envoy Gateway — the portability annotation-driven Ingress never gave you.
5. Weighted backends: canary and blue-green
This is where the annotation era dies. Traffic splitting is a first-class field: list multiple backendRefs under one rule and assign each a weight. Weights are relative, not percentages — {90, 10} and {900, 100} are identical.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-canary
namespace: team-shop
spec:
parentRefs:
- name: shared-gateway
namespace: gateway-infra
hostnames:
- shop.apps.kloudvin.io
rules:
- backendRefs:
- name: shop-stable
port: 8080
weight: 90
- name: shop-canary
port: 8080
weight: 10
Shift weight by patching the route — no controller-specific annotation, no rebuild:
kubectl -n team-shop patch httproute shop-canary --type=json -p='[
{"op":"replace","path":"/spec/rules/0/backendRefs/0/weight","value":50},
{"op":"replace","path":"/spec/rules/0/backendRefs/1/weight","value":50}
]'
A weight: 0 backend receives no traffic but stays a declared, ready target — exactly the blue-green primitive: keep both colors at weight, flip 100/0 to 0/100 in one apply, and roll back by reversing it. Because this is a plain Kubernetes field, progressive delivery controllers (Argo Rollouts, Flagger) drive these weights natively via the Gateway API plugin instead of templating vendor annotations.
A few weight rules worth committing to memory: an omitted weight defaults to 1; the valid range is 0–1,000,000; a backend at weight: 0 is a declared, health-checked target that simply receives nothing; and if every backend in a rule is weight: 0, conformant controllers return a 5xx (typically 503) rather than silently dropping the request. Always validate the realized split with traffic — the uniq -c loop in Verify is the source of truth, never the manifest.
For routing by client attributes rather than ratio, match on headers or query params and send each match to a different backend:
rules:
- matches:
- headers:
- name: x-canary
value: "true"
backendRefs:
- name: shop-canary
port: 8080
- backendRefs: # default rule, no matches
- name: shop-stable
port: 8080
Header value matching is Exact by default; set type: RegularExpression for patterns. This gives you opt-in canaries (internal users send x-canary: true) with zero blast radius on real traffic.
6. Filters: mirror, redirect, rewrite
Filters run on a rule’s matched requests. The portable ones in the standard channel:
RequestHeaderModifier/ResponseHeaderModifier— set, add, or remove headersRequestMirror— fork a copy of traffic to a second backend, responses discardedRequestRedirect— issue 301/302 (scheme, host, port, path, status)URLRewrite— rewrite hostname or path before forwarding
Shadow production traffic to a new build to test it under real load without affecting responses:
rules:
- filters:
- type: RequestMirror
requestMirror:
backendRef:
name: shop-canary
port: 8080
backendRefs:
- name: shop-stable
port: 8080
RequestMirror is fire-and-forget: the mirror backend’s latency and errors never reach the client, which makes it the safest way to validate a release. Combine a path rewrite with the redirect-to-HTTPS pattern most teams need on day one:
rules:
# Strip /legacy prefix before forwarding
- matches:
- path: { type: PathPrefix, value: /legacy }
filters:
- type: URLRewrite
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: shop-svc
port: 8080
For the HTTP-to-HTTPS redirect, add a plain HTTP listener on port 80 and a tiny route whose only job is a RequestRedirect filter with scheme: https and statusCode: 301 and no backendRefs.
7. Side-by-side migration off Ingress
Do not flip DNS and pray. Run Gateway API beside Ingress, validate, then cut over. The kubernetes-sigs ingress2gateway tool converts existing Ingress (and several vendors’ annotations) into Gateway API YAML so you start from your real config, not a blank file.
# Install
go install github.com/kubernetes-sigs/ingress2gateway@v0.4.0
# Convert live Ingress in a namespace; --providers maps vendor annotations
ingress2gateway print --namespace team-shop --providers ingress-nginx > generated-gw.yaml
ingress2gatewaytranslates what maps cleanly. Annotations with no Gateway API equivalent (auth snippets, rate limits, raw config blocks) are dropped or flagged, not silently faked. Treat the output as a reviewed first draft. Diff it, fill the gaps with the right policy attachment, and never apply it blind.
The dual-run sequence that avoids an outage:
- Install CRDs + controller. The new Gateway provisions its own load balancer/IP, fully isolated from the Ingress LB.
- Apply the converted
Gateway+HTTPRoutes. Existing Ingress keeps serving production untouched. - Smoke-test the new path by sending traffic to the Gateway’s IP with the production
Hostheader, bypassing DNS:
GW_IP=$(kubectl -n gateway-infra get gateway shared-gateway \
-o jsonpath='{.status.addresses[0].value}')
curl -sS -k --resolve shop.apps.kloudvin.io:443:$GW_IP \
https://shop.apps.kloudvin.io/healthz -o /dev/null -w "%{http_code}\n"
- Cut over at DNS: repoint the hostname’s record from the Ingress LB to the Gateway address, or shift weight at a global load balancer for a gradual cutover.
- Bake for a release cycle so rollback is a single DNS change, then delete the Ingress objects and controller.
The key safety property: the two data planes have separate IPs the whole time, so nothing about installing or testing the Gateway can perturb live Ingress traffic.
Verify
A Gateway API rollout has a precise, machine-readable health model — use the status conditions, not curl alone.
# 1. GatewayClass accepted by its controller
kubectl get gatewayclass envoy-gateway \
-o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}{"\n"}'
# 2. Gateway is Programmed (LB provisioned) and has an address
kubectl -n gateway-infra get gateway shared-gateway \
-o jsonpath='Programmed={.status.conditions[?(@.type=="Programmed")].status} addr={.status.addresses[0].value}{"\n"}'
# 3. Per-listener attached route count — catches selector/namespace mistakes
kubectl -n gateway-infra get gateway shared-gateway \
-o jsonpath='{range .status.listeners[*]}{.name}={.attachedRoutes}{"\n"}{end}'
# 4. THE critical check: is the route accepted AND resolved by its parent?
kubectl -n team-shop get httproute shop-canary \
-o jsonpath='{range .status.parents[*]}Accepted={.conditions[?(@.type=="Accepted")].status} ResolvedRefs={.conditions[?(@.type=="ResolvedRefs")].status}{"\n"}{end}'
The single most common failure is an unattached route: the HTTPRoute exists but attachedRoutes on the Gateway stays 0 and the route reports Accepted=False. Read the condition reason:
NotAllowedByListeners— the listener’sallowedRoutesrejects this route’s namespace. Fix the label/selector.NoMatchingParent/NoMatchingListenerHostname—parentRefsname, namespace,sectionName, or hostname don’t line up with any listener.ResolvedRefs=Falsewith reasonBackendNotFoundorRefNotPermitted— the Service doesn’t exist, the port is wrong, or a cross-namespacebackendReflacks aReferenceGrant.
kubectl describe httproute surfaces these reason/message pairs directly. Confirm the data plane end to end once status is green:
for i in $(seq 1 20); do
curl -sS --resolve shop.apps.kloudvin.io:443:$GW_IP \
https://shop.apps.kloudvin.io/version
done | sort | uniq -c # ~90/10 split across stable/canary responses
Enterprise scenario
A retail platform team ran a single NGINX Ingress controller as a shared front door for ~120 app teams. The pain was governance: any team could ship an Ingress whose nginx.ingress.kubernetes.io/server-snippet annotation injected raw config into the shared NGINX, and one bad snippet had once reload-looped the controller and took down unrelated tenants. Ingress gave them no way to let teams self-serve routing while denying them control over the shared proxy.
They moved to Gateway API specifically for the ownership seam. Platform owned one Gateway per environment with allowedRoutes gated on a namespace label that only their admission policy could set. App teams got full HTTPRoute self-service — weighted canaries, header routing, rewrites — but HTTPRoute has no raw-config escape hatch, so a tenant could no longer reach into the shared data plane. Cross-namespace backends required an explicit ReferenceGrant that the target team had to author, turning an implicit trust into a reviewed one.
The constraint that nearly stalled them: roughly 30 of the converted Ingresses relied on external-auth and rate-limit annotations that ingress2gateway correctly dropped. Rather than block the migration on a single policy story, they kept those few routes on Ingress during the bake and reimplemented the auth as the implementation’s policy attachment (a SecurityPolicy referencing the route), migrating them last:
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: shop-extauth
namespace: team-shop
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: shop-canary
extAuth:
http:
backendRefs:
- name: ext-authz
port: 9000
Outcome: 90 of 120 teams migrated in the first two sprints on the portable core; the long tail rode the dual-run until their policy attachments landed. The win wasn’t routing features — it was that the new model made “self-service routing” and “no shared-proxy blast radius” the same design instead of opposing ones.
Note the API group on that
SecurityPolicy:gateway.envoyproxy.io/v1alpha1. Policy attachment is implementation-specific and still alpha across vendors. The routing is portable Gateway API; the policy (auth, rate limiting, mTLS to backends) ties you to one implementation today. Plan that coupling deliberately.
Going deeper
The core three resources cover the majority of what teams need. The remaining depth — other protocols, cross-cutting policy, and picking a controller — is where the senior decisions live.
The full route-type family
HTTPRoute is one of several route kinds that all attach to a Gateway the same way. Reach for the others when the protocol isn’t plain HTTP:
| Route kind | API version | Channel | Use it for |
|---|---|---|---|
HTTPRoute |
v1 |
Standard (GA) | HTTP/HTTPS L7 routing, filters, weighted splits |
GRPCRoute |
v1 |
Standard (GA) | gRPC by service/method, h2c and trailers handled natively |
TLSRoute |
v1alpha2 |
Experimental | SNI-based routing with passthrough (no termination) |
TCPRoute |
v1alpha2 |
Experimental | Raw L4 TCP forwarding (databases, brokers) |
UDPRoute |
v1alpha2 |
Experimental | Raw L4 UDP (DNS, QUIC front ends) |
GRPCRoute graduated to GA and is the right tool for gRPC — matching on service/method and doing weighted splits without the header gymnastics gRPC-over-HTTPRoute required. The L4 routes stay experimental: they express far less than their L7 cousins, so treat them as niche until your controller marks them supported.
Policy attachment: the portable/non-portable seam
Routing is portable. Cross-cutting behavior — auth, rate limiting, retries, timeouts, backend (re-)encryption — is not part of a route; it attaches to one via a separate policy object whose targetRefs name the resource it configures. The pattern (GEP-713) comes in two shapes:
- Direct attachment — the policy targets one specific resource and all its settings apply there (for example, a
BackendTLSPolicytargeting aServiceso the Gateway re-encrypts to that backend). - Inherited attachment — a policy set on a
Gatewaycascades to every route and listener beneath it, with defaults-and-overrides semantics, so a platform team can set a floor (say, a global timeout) that app teams can tighten but not remove.
The catch, and it’s the one that bites in production: most policy CRDs are implementation-specific and still alpha. Envoy Gateway’s BackendTrafficPolicy/SecurityPolicy, Istio’s AuthorizationPolicy, and Cilium’s policies are not interchangeable. A handful are standardizing — BackendTLSPolicy (Gateway→backend TLS) is the furthest along on the standard track (v1alpha3) — but plan for the coupling: portable routing, vendor-specific policy.
GAMMA: the same routes for east-west mesh
The GAMMA initiative (Gateway API for Mesh) lets an HTTPRoute attach to a Service as its parentRef instead of a Gateway. That single change points the exact same routing/splitting grammar at service-to-service traffic inside a mesh — so a 90/10 canary between payments-v1 and payments-v2 for internal callers is written the same way as one at the edge. Meshes that implement it (Istio, Linkerd, Cilium) let you retire bespoke mesh routing CRDs in favor of one grammar for north-south and east-west.
Choosing a controller
The CRDs are universal; the controller is a real decision. All of these pass core conformance — the differences are data plane, mesh story, and which extended features/policies they ship:
| Controller | Data plane | Also a mesh? | Notable for |
|---|---|---|---|
| Envoy Gateway | Envoy | via Istio | Clean, dedicated Gateway API impl; fast-moving |
| NGINX Gateway Fabric | NGINX | No | An NGINX shop’s native path off Ingress-NGINX |
| Istio | Envoy | Yes | Same HTTPRoute for edge + mesh (GAMMA) |
| Cilium | eBPF/Envoy | Yes | eBPF data plane; ties into network policy |
| Kong / Traefik | own proxy | No | API-gateway features, plugin ecosystems |
| GKE Gateway | Google Cloud LB | via CSM | Managed; provisions cloud L7 LBs |
| AWS Gateway API Controller | VPC Lattice | — | Routes onto AWS VPC Lattice |
| Azure App Gateway for Containers | Azure ALB | — | Managed Azure L7 with Gateway API |
Read the controller’s conformance report (published at gateway-api.sigs.k8s.io) before promising a feature. Conformance profiles state exactly which extended capabilities and route types an implementation supports, so “does it do header-based canaries?” has a documented answer, not a vendor-blog answer.
Maturity and the two channels, precisely
| Resource | Channel | API version | State |
|---|---|---|---|
GatewayClass, Gateway, HTTPRoute |
Standard | v1 |
GA (since Oct 2023) |
GRPCRoute |
Standard | v1 |
GA |
ReferenceGrant |
Standard | v1beta1 |
Stable |
TCPRoute, TLSRoute, UDPRoute |
Experimental | v1alpha2 |
Experimental |
BackendTLSPolicy |
Experimental | v1alpha3 |
Alpha (standard track) |
The GA core follows the normal Kubernetes deprecation policy — you can build on v1 the way you build on Deployment. The project ships roughly quarterly; new capability lands in the experimental channel first and graduates to standard once it’s proven. That’s why the channel choice back in Install matters: it isn’t cosmetic, it’s your stability contract.
Status conditions are the API’s contract
The Verify section runs the checks; here is the model behind them. Every parent a route attaches to gets its own entry in status.parents[], each carrying Accepted (the parent recognized the route) and ResolvedRefs (its backends resolve). The Gateway carries Accepted and Programmed (the data plane — LB, listeners — is actually configured). The subtle one is observedGeneration: each condition reports which metadata.generation it reflects. If a condition’s observedGeneration lags the object’s current generation, the controller hasn’t reconciled your latest edit yet — the status you’re reading is stale, and “it says Accepted” may describe the previous version of the route. Senior operators diff those two numbers before trusting any condition.
Checklist
Pitfalls
- Mixing CRD channels. Standard and experimental ship different field sets. Reverting from experimental drops fields and can orphan objects mid-flight. Pick one channel and pin it.
- Reading “route exists” as “route serves.” An
HTTPRoutecan be perfectly valid YAML and attach to nothing.attachedRoutes: 0plusAccepted=Falseis the truth; the manifest applying cleanly is not. - Forgetting
ReferenceGrantfor cross-namespace backends. Same-namespace forwarding is free; crossing a namespace silently fails withRefNotPermitteduntil the target namespace grants it. - Treating weights as percentages. They are relative.
{1, 1}is a 50/50 split, not 1%. Validate the realized ratio with theuniq -cloop, not the manifest. - Assuming all features are portable. Routing, splitting, and the standard filters are portable. Auth, rate limiting, and backend mTLS are policy attachments that are implementation-specific and still alpha — that coupling is real, plan for it.
- Flipping DNS before status is green. The Gateway gets its own IP precisely so you can validate via
--resolvefirst. Cut over only after conditions read healthy and the smoke test passes.
Common beginner mistakes
These are misconceptions, not typos — each one comes from a wrong mental model, so the fix is the model.
- “GatewayClass, Gateway, and HTTPRoute are three words for the same thing.” They’re a chain of three owners.
GatewayClassis a cluster-scoped template (which controller),Gatewayis one instance with a real IP and listeners,HTTPRouteis your rules attached to that instance. Put listeners in an HTTPRoute or hostnames in a GatewayClass and nothing provisions. Right model: class = kind of LB, Gateway = one LB, HTTPRoute = a tenant on it. - “
kubectl applysucceeded, so my route is live.” Applying validates YAML and stores the object; it does not attach it to a listener. A flawless HTTPRoute can sit atattachedRoutes: 0,Accepted=False, serving nothing. Right model: the truth is instatus.conditions, not the exit code ofapply. - “I’ll just point a
backendRefat a Service in another namespace.” Two different walls default closed. Crossing a namespace to attach a route needs the Gateway listener’sallowedRoutes; forwarding to a Service in another namespace needs aReferenceGrantin that target namespace. Same-namespace needs neither. Right model: every namespace boundary is opt-in, granted by the side being reached into. - “Any conformant controller supports every feature.” Core routing is portable; extended filters and all policy attachment are not. Envoy Gateway’s
SecurityPolicymeans nothing to Istio. Right model: check the controller’s conformance report for the specific feature before you design around it. - “My Ingress annotations will carry over.” They will not.
ingress2gatewaymaps the structural parts (hosts, paths, TLS); annotation-encoded behavior (auth, rate limits, snippets) has no Gateway field and becomes policy attachment you re-author. Pastingnginx.ingress.kubernetes.io/...onto a Gateway resource is inert. Right model: migration converts structure and re-implements behavior — it isn’t a copy. - “Weights are percentages.” They’re relative integers.
{3, 1}is 75/25, not “3% canary,” and all-zero weights return 5xx, not “no split.” Right model: weight is a ratio; the realized split is proven by traffic, not by the number.
Practice challenges
Work these against the resource names used above (shared-gateway in gateway-infra; app routes in team-shop). Each solution is one expand-to-check block.
1 — Beginner: a minimal route. Write the smallest HTTPRoute that sends shop.apps.kloudvin.io to shop-svc:8080, attached to shared-gateway.
<details> <summary>Solution</summary>
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop
namespace: team-shop
spec:
parentRefs:
- name: shared-gateway
namespace: gateway-infra
hostnames:
- shop.apps.kloudvin.io
rules:
- backendRefs:
- name: shop-svc
port: 8080
A rule with no matches is a catch-all: every request for the hostname goes to the one backend.
</details>
2 — Beginner → Intermediate: a 90/10 canary. Turn that route into a 90% shop-stable / 10% shop-canary split (both on port 8080).
<details> <summary>Solution</summary>
rules:
- backendRefs:
- name: shop-stable
port: 8080
weight: 90
- name: shop-canary
port: 8080
weight: 10
Weights are relative — {90,10} and {9,1} behave identically. Prove the ratio with a curl loop, not the manifest.
</details>
3 — Intermediate: opt-in header canary. Route requests carrying x-canary: true to shop-canary, everything else to shop-stable, in one route.
<details> <summary>Solution</summary>
rules:
- matches:
- headers:
- name: x-canary
value: "true"
backendRefs:
- name: shop-canary
port: 8080
- backendRefs: # no matches = default
- name: shop-stable
port: 8080
The more-specific rule wins by spec-defined precedence, so the header rule is evaluated before the catch-all regardless of order. Zero blast radius on real traffic. </details>
4 — Intermediate: shift the split live. Move the weighted route from 90/10 to 50/50 without editing or re-applying the YAML.
<details> <summary>Solution</summary>
kubectl -n team-shop patch httproute shop-canary --type=json -p='[
{"op":"replace","path":"/spec/rules/0/backendRefs/0/weight","value":50},
{"op":"replace","path":"/spec/rules/0/backendRefs/1/weight","value":50}
]'
The weight is a plain field, so a JSON patch retargets traffic in place — this is exactly the hook Argo Rollouts and Flagger drive automatically. </details>
5 — Advanced: cross-namespace backend. The route in team-shop must forward to payments-svc in the payments namespace. Attaching to the Gateway already works. What else is required, and where does it live?
<details> <summary>Solution</summary>
A ReferenceGrant in the target namespace (payments) authorizing HTTPRoutes in team-shop to reference Services there:
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-shop-to-payments
namespace: payments
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: team-shop
to:
- group: ""
kind: Service
Without it the route reports ResolvedRefs=False, reason RefNotPermitted, and the backend 503s. The grant lives with the team being reached into — they consent, not the caller.
</details>
6 — Advanced: diagnose a dark route. A freshly applied route returns 503s. status.parents[0] shows Accepted=True but ResolvedRefs=False with reason BackendNotFound. Name the two most likely causes and the fix.
<details> <summary>Solution</summary>
Accepted=True means it attached, so the Gateway/listener/namespace wiring is fine — the failure is the backend. Either the backendRefs name/port doesn’t match a real Service (kubectl -n team-shop get svc; fix the typo or port), or the Service exists but has no ready endpoints (kubectl -n team-shop get endpointslices — a Deployment scaled to 0 or failing readiness). If the backend were cross-namespace, the reason would instead be RefNotPermitted → add the ReferenceGrant from challenge 5.
</details>
Glossary
- GatewayClass — cluster-scoped template naming the
controllerNamethat implements it; the “kind of load balancer,” like aStorageClass. Owned by the infra provider. - Gateway — one running instance of a
GatewayClass: listeners, ports, protocols, TLS, and a real IP/load balancer. Owned by the platform team. - Listener — an entry point on a
Gateway(port + protocol + optional hostname + TLS config), withallowedRoutescontrolling which namespaces may attach. - HTTPRoute — application-owned L7 routing: hostnames, path/header/method matches, filters, and weighted
backendRefs. Attaches to a Gateway viaparentRefs. - parentRefs — the field on a route naming the Gateway(s) (and optional
sectionName/listener) it attaches to. - backendRefs — the target Services (name + port) a matched rule forwards to; each may carry a
weight. - weight — a relative integer (default 1, range 0–1,000,000) setting a backend’s share of traffic. Not a percentage.
- allowedRoutes — a listener setting that gates which namespaces (by
Same,All, or a labelSelector) may attach routes. - ReferenceGrant — an object in a target namespace that opts into being referenced across the namespace boundary (e.g., a route in ns A → Service in ns B).
- Filter — per-rule request/response processing:
RequestHeaderModifier,ResponseHeaderModifier,RequestMirror,RequestRedirect,URLRewrite. - RequestMirror — a filter that forks a copy of matched traffic to a second backend and discards its response; safe shadow testing.
- GRPCRoute — GA route kind for gRPC, matching on service/method.
- TCPRoute / TLSRoute / UDPRoute — experimental L4 route kinds (raw TCP, SNI passthrough, UDP).
- Policy attachment — the GEP-713 pattern where a separate object’s
targetRefsattach cross-cutting behavior (auth, rate limit, backend TLS) to a route or Gateway; direct (one target) or inherited (cascades down). - GAMMA — the initiative letting an
HTTPRouteattach to aServiceto configure east-west mesh traffic with the same grammar. - Conformance — the published test suite and profiles proving which Gateway API features a controller actually implements.
- Standard / Experimental channel — the two CRD bundles: Standard = GA/stable resources; Experimental = alpha fields and L4 routes. Pick one cluster-wide.
- Programmed — a Gateway status condition meaning its data plane (LB, listeners) is actually configured, as opposed to merely
Accepted. - ResolvedRefs — a route (or Gateway) status condition meaning every backend reference (and cross-ns grant) resolves.
- attachedRoutes — a per-listener counter on the Gateway;
0means no route is bound — the fastest tell for a dark route. - ingress2gateway — the kubernetes-sigs tool that converts existing Ingress (and some vendor annotations) into Gateway API YAML as a reviewed first draft.
Next steps: wire the route status checks (Accepted, ResolvedRefs, attachedRoutes) into a CI gate so a broken HTTPRoute fails the PR instead of silently serving nothing, and hand canary weight control to Argo Rollouts via its Gateway API plugin so progressive delivery drives the weight fields instead of a human running kubectl patch.