A type: LoadBalancer Service gives you one cloud load balancer per Service, each with its own IP and its own bill. Ingress and Gateway API exist to put one entry point in front of many Services and route by hostname and path.
Topic 1: Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: api
port:
name: http
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
The Ingress object does nothing on its own. It is a request that an ingress controller — nginx, Traefik, HAProxy, an AWS ALB controller — must be running to fulfil. An Ingress on a cluster with no controller sits there with no address, forever. That is the single most common “my Ingress does not work”.
kubectl get ingressclass
kubectl get pods -A | grep -i ingress
kubectl get ingress web # ADDRESS empty = no controller acting on it
pathType matters:
| Type | Behaviour |
|---|---|
Exact | Exact string match |
Prefix | Match on path segments — /api matches /api/v1 but not /apifoo |
ImplementationSpecific | Whatever the controller wants — usually regex |
Use Prefix unless you need regex. ImplementationSpecific is where portability dies.
Topic 2: Why Ingress Stopped Being Enough
Ingress reached networking.k8s.io/v1 in 1.19 and has barely changed since, because its design has structural limits:
1. It only really models HTTP. No TCP, no UDP, no gRPC as a first-class concept.
2. Everything beyond basic routing is an annotation. Timeouts, retries, rewrites, header manipulation, canary weights, rate limits, CORS, auth — all vendor-specific annotations. An Ingress is therefore not portable: moving from nginx to Traefik means rewriting every annotation, and the annotations are not validated, so a typo silently does nothing.
3. No separation of concerns. One object mixes infrastructure decisions (which load balancer, which TLS certificate, which port) with application routing (send /api to this Service). In a real organisation those belong to different people with different permissions, and Ingress gives you no way to split them.
4. No traffic splitting. Canary by percentage requires — you guessed it — controller-specific annotations.
Topic 3: Gateway API
Gateway API went GA in v1.0 (Oct 2023) and is now at v1.4 (Oct 2025). It is the successor, and it is installed as CRDs, not built into the API server:
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml
kubectl get crd | grep gateway
Its central idea is three objects for three roles:
# 1. INFRASTRUCTURE PROVIDER — "this cluster has an nginx-based gateway implementation"
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: nginx
spec:
controllerName: gateway.nginx.org/nginx-gateway-controller
---
# 2. CLUSTER OPERATOR — "here is a gateway, its ports, and its certificates"
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: prod-gateway
namespace: infra
spec:
gatewayClassName: nginx
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- name: wildcard-example-tls
allowedRoutes:
namespaces:
from: Selector # WHO may attach routes here
selector:
matchLabels: { gateway-access: "true" }
---
# 3. APPLICATION DEVELOPER — "route my traffic"
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api
namespace: payments
spec:
parentRefs:
- name: prod-gateway
namespace: infra
hostnames: ["shop.example.com"]
rules:
- matches:
- path: { type: PathPrefix, value: /api }
headers:
- name: x-api-version
value: "2"
filters:
- type: RequestHeaderModifier
requestHeaderModifier:
set:
- name: x-forwarded-prefix
value: /api
backendRefs:
- name: api-v2
port: 8080
weight: 90 # ← native traffic splitting
- name: api-canary
port: 8080
weight: 10
timeouts:
request: 30s
What that buys you:
Role separation with real permissions. The platform team owns the Gateway (certificates, ports, which load balancer). Application teams own HTTPRoute objects in their own namespaces. allowedRoutes controls who may attach — enforced by the API, not by convention.
Portable, typed configuration. Header matching, rewrites, redirects, timeouts, retries and traffic weights are fields in the spec, validated by the API server. No annotations.
Cross-namespace routing with explicit permission (a ReferenceGrant in the target namespace), rather than the ambient trust Ingress assumes.
More than HTTP: HTTPRoute, GRPCRoute (GA in 1.1), TLSRoute, TCPRoute, UDPRoute.
Traffic splitting is native — the canary above needs no controller-specific anything.
Topic 4: Ingress vs Gateway API
| Capability | Ingress | Gateway API |
|---|---|---|
| HTTP routing | Yes | Yes |
| TCP / UDP / TLS passthrough | No | Yes |
| gRPC as a first-class type | No | GRPCRoute |
| Traffic splitting / canary | Annotations | Native weight |
| Header/method/query matching | Annotations | Native |
| Header rewrite, redirect | Annotations | Native filters |
| Timeouts, retries | Annotations | Native |
| Role separation | None | GatewayClass / Gateway / Route |
| Cross-namespace | Implicit | Explicit ReferenceGrant |
| Portable between implementations | In practice, no | Yes |
| Status reporting | Minimal | Per-route conditions |
That last row is underrated: an HTTPRoute reports whether it was accepted by the Gateway and why not:
kubectl get httproute api -o jsonpath='{.status.parents[0].conditions}' | jq
# [{"type":"Accepted","status":"False","reason":"NoMatchingListenerHostname", ...}]
Ingress gives you nothing comparable — a misconfigured Ingress is simply ignored.
Topic 5: Migration and Choosing
You do not need a flag day. Both can serve simultaneously through the same controller, and several implementations offer conversion tooling (ingress2gateway).
A workable sequence:
- Install the Gateway API CRDs and an implementation (many controllers already support both).
- Create one
Gatewayalongside the existing Ingress. - Migrate one low-risk
HTTPRouteand verify. - Move the rest incrementally; retire the Ingress objects last.
When to stay on Ingress: a small cluster, simple host/path routing, an existing controller that works, and no need for canaries or non-HTTP protocols. Ingress is not deprecated and is not going away. But it is frozen — every new capability lands in Gateway API, so anything greenfield should start there.
When a service mesh instead? Ingress and Gateway API handle north-south traffic (into the cluster). A mesh handles east-west (service-to-service): mTLS between pods, per-service retries and circuit breaking, and distributed tracing without application changes. They are complementary, not alternatives, and a mesh is a substantial operational commitment — adopt it for a specific requirement, not for completeness.
Topic 6: Debugging
# Is a controller running and is the class right?
kubectl get ingressclass
kubectl get gatewayclass
kubectl get pods -n ingress-nginx
# Did the object get an address?
kubectl get ingress web
kubectl get gateway prod-gateway -o wide
# Gateway API tells you WHY, in status
kubectl get gateway prod-gateway -o jsonpath='{.status.conditions}' | jq
kubectl get httproute api -o jsonpath='{.status.parents}' | jq
# Does the backend Service actually have endpoints?
kubectl get endpointslices -l kubernetes.io/service-name=api
# The controller's own view
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail=100
kubectl exec -n ingress-nginx deploy/ingress-nginx-controller -- cat /etc/nginx/nginx.conf | grep -A10 'shop.example.com'
# Bypass DNS and test the routing directly
curl -sS -H 'Host: shop.example.com' http://<ingress-ip>/api/healthz -v
| Symptom | Cause |
|---|---|
Ingress ADDRESS empty | No controller, or ingressClassName does not match one |
| 404 from the controller | No rule matched — check host and path, and pathType |
| 503 | Backend Service has no endpoints — pods not ready |
| TLS warnings | Secret missing, wrong namespace, or not type kubernetes.io/tls |
| Works by IP, not by hostname | DNS is not pointing at the load balancer |
| Route silently ignored | Ingress: nothing tells you. Gateway: read status.parents[].conditions |
The curl -H 'Host: ...' trick is the fastest way to separate DNS problems from routing problems: if it works by IP with the right Host header, your routing is correct and DNS is the issue.
Try it yourself: Create an HTTPRoute whose hostnames do not match any listener on the Gateway. Read status.parents[0].conditions and note that the API tells you NoMatchingListenerHostname explicitly. Then do the equivalent with an Ingress and observe that nothing at all is reported.
Common mistake: Expecting an Ingress to work with no controller installed. The object is accepted by the API server because it is valid — validity and being acted upon are different things, and Kubernetes will happily store a request that nobody is listening for.