Gateway API: The Successor to Ingress

K
Kai··6 min read

Article 48 ended where Ingress is frozen: header-based routing, percentage traffic splitting, or separating permissions between the infrastructure owner and the application author — none of these will ever make it into Ingress. Gateway API is where they get done. It's the successor API SIG Network recommends, still running on the same Cilium we used for Ingress in the previous article.

Three objects instead of one

Ingress crams everything into one object. Gateway API splits it into three, matching three different roles in an organization:

   GatewayClass   ── whoever installs the controller (like Cilium) registers it; like IngressClass
        │
        ▼
   Gateway        ── the cluster operator opens a "gate": listener port 80/443, which hosts
        │             may attach routes. This is where the address + TLS cert are provided.
        ▼
   HTTPRoute      ── the app team attaches routes to the Gateway: host, path, header → backend Service.
                     Each team owns its own routes without touching the shared Gateway.

This split solves a real Ingress problem: in Ingress, the application author has to edit the same object as the person managing TLS and addresses. Gateway API lets the infrastructure owner hold the Gateway while teams only create HTTPRoutes pointing at it.

Enable Gateway API on Cilium

Unlike Ingress, Gateway API has no built-in CRDs in Kubernetes — you install them separately. Cilium 1.19 needs the standard channel CRDs at v1.4.1:

BASE=https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.4.1/config/crd/standard
for r in gatewayclasses gateways httproutes referencegrants grpcroutes; do
  kubectl apply -f $BASE/gateway.networking.k8s.io_${r}.yaml
done

Then enable it on Cilium (still --reuse-values to keep the kube-proxy-less + ingress config from the previous articles), and restart operator + agent like in Article 48 — helm upgrade only edits the ConfigMap, the pods don't reload on their own:

helm upgrade cilium cilium/cilium --version 1.19.4 -n kube-system --reuse-values \
  --set gatewayAPI.enabled=true
kubectl -n kube-system rollout restart deploy/cilium-operator ds/cilium

Cilium registers a GatewayClass named cilium:

kubectl get gatewayclass
NAME     CONTROLLER                     ACCEPTED   AGE
cilium   io.cilium/gateway-controller   True       23s

ACCEPTED True means the controller accepts this class. Gateway API requires kubeProxyReplacement=true and the Envoy L7 proxy — both in place since Article 46.

Gateway and HTTPRoute

Create a Gateway opening an HTTP listener on port 80, allowing only routes in the same namespace to attach:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: web-gw
  namespace: gw-demo
spec:
  gatewayClassName: cilium
  listeners:
  - name: http
    port: 80
    protocol: HTTP
    allowedRoutes:
      namespaces:
        from: Same

Then an HTTPRoute attached to that Gateway (parentRefs), routing by host apps.kkloud.local and path:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: fanout
  namespace: gw-demo
spec:
  parentRefs:
  - name: web-gw
  hostnames:
  - apps.kkloud.local
  rules:
  - matches:
    - path: {type: PathPrefix, value: /foo}
    backendRefs:
    - {name: foo, port: 80}
  - matches:
    - path: {type: PathPrefix, value: /bar}
    backendRefs:
    - {name: bar, port: 80}

The backends are two Services foo and bar (each a http-echo Deployment returning FOO/BAR, like Article 48). Once applied, Cilium creates a Service for the Gateway and an Envoy listener:

kubectl -n gw-demo get gateway web-gw
kubectl -n gw-demo get svc | grep gateway
NAME     CLASS    ADDRESS   PROGRAMMED   AGE
web-gw   cilium             False        52s

NAME                    TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE
cilium-gateway-web-gw   LoadBalancer   10.32.0.205   <pending>     80:30731/TCP   12s

Why the Gateway isn't Programmed yet

PROGRAMMED False and EXTERNAL-IP <pending> aren't config errors — they're a consequence of running on bare metal. Check the specific reason:

kubectl -n gw-demo get gateway web-gw -o jsonpath='{range .status.conditions[*]}{.type}={.status} {.reason}{"\n"}{end}'
Accepted=True Accepted
Programmed=False AddressNotAssigned

The Gateway creates a LoadBalancer-type Service and waits to be assigned an external address. The self-built EC2 cluster has no cloud controller to hand out an IP, and we haven't enabled Cilium's LB IPAM yet (saved for Article 50), so the Service stays <pending> and the Gateway reports AddressNotAssigned. But the data plane is ready — the Envoy listener is set up:

kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg envoy admin listeners | grep gateway
gw-demo/cilium-gateway-web-gw/listener::127.0.0.1:17803

A LoadBalancer Service always gets a NodePort assigned alongside it (here 30731), so we test through that. Remember from Article 48: Cilium's NodePort doesn't listen on loopback, so use the node IP.

Routing and traffic splitting

Test from a worker, sending requests with a Host header:

IP=10.0.1.20 ; NP=30731
curl -s -H "Host: apps.kkloud.local" $IP:$NP/foo
curl -s -H "Host: apps.kkloud.local" $IP:$NP/bar
curl -s -o /dev/null -w "HTTP %{http_code}\n" -H "Host: apps.kkloud.local" $IP:$NP/baz
FOO          # /foo -> Service foo
BAR          # /bar -> Service bar
HTTP 404     # path matches no rule

So far Gateway API does exactly what Ingress does. The part Ingress can't do is here: a second HTTPRoute splits traffic between two backends by weight, with no controller-specific annotation:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: split
  namespace: gw-demo
spec:
  parentRefs:
  - name: web-gw
  hostnames:
  - split.kkloud.local
  rules:
  - backendRefs:
    - {name: foo, port: 80, weight: 80}
    - {name: bar, port: 80, weight: 20}

Call it 20 times and count:

for i in $(seq 20); do curl -s -H "Host: split.kkloud.local" $IP:$NP/; done | sort | uniq -c
   3 BAR
  17 FOO

17/3 out of 20 calls, tracking the declared 80/20 ratio. This is how you do a canary straight in the standard API: lower the weight of the old version, gradually raise the new one, with no dependency on a specific controller's annotations — which is exactly what makes Ingress config hard to port between controllers.

🧹 Cleanup

kubectl delete namespace gw-demo        # removes Gateway, 2 HTTPRoutes, foo, bar

The cilium-gateway-web-gw Service belongs to the Gateway, so it goes with the namespace. Keep the Gateway API CRDs and gatewayAPI.enabled on Cilium — Article 50 uses them next when assigning a real address to the LoadBalancer. Manifests at github.com/nghiadaulau/kubernetes-from-scratch, folder 49-gateway-api.

Wrap-up

Gateway API splits inbound traffic into three objects across three roles: GatewayClass (controller registration, Cilium creates the cilium class), Gateway (opens a listener, provides the address + TLS), HTTPRoute (the team attaches routes by host/path/header to the Gateway). We installed the v1.4.1 CRDs, enabled gatewayAPI.enabled on Cilium (remember to restart operator + agent), then stood up an HTTP Gateway on port 80 with two HTTPRoutes: path fanout (/foo→foo, /bar→bar, unknown path → 404) and weighted traffic splitting (80/20 → counted out to 17/3). On bare metal, the Gateway stops at Programmed=False / AddressNotAssigned because the LoadBalancer Service has no external IP yet, but the Envoy listener is running so we test through the accompanying NodePort. Compared to Ingress: same host/path routing, but Gateway API adds traffic splitting and role separation right in the standard API, not through per-controller annotations.

There's still a loose end: the Gateway and the LoadBalancer Service from Article 48 both wait for an external IP the bare-metal cluster can't provide. Article 50 fills that gap — Cilium's LB IPAM assigns IPs to LoadBalancer Services, along with the inbound traffic routing options (externalTrafficPolicy, topology-aware routing) that complete the path into the cluster.