Containerization Lesson 30 of 113

Adopting the Kubernetes Gateway API: GatewayClass, HTTPRoute Traffic Splitting, and Migrating off Ingress

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:

The request path at a glance

Gateway API request path: a client through the Gateway to weighted HTTPRoute backends

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: GatewayClass is the kind of load balancer available (like a StorageClass). Gateway is one provisioned instance of it. HTTPRoute is 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:

  1. Exact path beats PathPrefix beats RegularExpression.
  2. Among prefixes, the longest prefix wins.
  3. Then the rule with the most matching conditions (a rule matching path and header beats one matching path alone).
  4. 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:

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

ingress2gateway translates 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:

  1. Install CRDs + controller. The new Gateway provisions its own load balancer/IP, fully isolated from the Ingress LB.
  2. Apply the converted Gateway + HTTPRoutes. Existing Ingress keeps serving production untouched.
  3. Smoke-test the new path by sending traffic to the Gateway’s IP with the production Host header, 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"
  1. 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.
  2. 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:

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:

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

Common beginner mistakes

These are misconceptions, not typos — each one comes from a wrong mental model, so the fix is the model.

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

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.

kubernetesgateway-apiingresstraffic-managementnetworking
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments