Skip to content
BytePatterns

Ingress vs Gateway API: One Frozen Resource or Three Roles

7 min readBytePatterns

Ingress vs Gateway API: one frozen resource with controller annotations, or GatewayClass, Gateway and HTTPRoute with portable header matching and weights.

Ingress vs Gateway API in one line: Ingress is a single, frozen resource that routes HTTP by host and path to one Service per rule, and everything beyond that lives in annotations each controller invents; Gateway API splits the same job into a GatewayClass, a Gateway and routes such as HTTPRoute, owned by different teams, with header matching and weighted backends written into the spec itself. Both need a controller running in the cluster. Facts come from the Kubernetes and Gateway API documentation listed at the end, as of October 2026.

QuestionIngressGateway API
Objectsone Ingress (plus an IngressClass)GatewayClass, Gateway, HTTPRoute and other route kinds
Header match, traffic splitnot in the spec; controller annotationsin the spec: headers, backendRefs[].weight
API statusGA and frozen: no further changescustom resources, actively developed, recommended for new work

The problem it solves

An Ingress rule says: this host, this path, this Service. Real traffic wants more. Send requests with x-canary: 1 to v2. Send 10% of everything to v2. Rewrite a path, set a timeout, terminate TLS differently for one team. The Ingress spec has no field for any of that, so each controller grew its own annotations, and the same YAML means different things, or nothing, on another controller. Gateway API puts those features into typed fields and splits ownership so a platform team and an app team stop editing the same object.

The intuition

Ingress is one object doing three jobs. It picks the controller (ingressClassName), describes the listener (hosts, TLS secrets) and lists the routes (path to Service). The Kubernetes documentation now states plainly that the Ingress API is frozen: generally available, not going away, but no new features. Anything beyond host and path matching is an annotation, and annotations are strings no API server validates. One of the most popular controllers, Ingress NGINX, was retired by the Kubernetes project, with best-effort maintenance ending in March 2026, partly because its "snippets" annotations let users inject raw proxy configuration.

Gateway API splits those jobs by role.

  • GatewayClass is cluster-scoped and names the controller that implements it, much like a StorageClass names a provisioner. The infrastructure provider owns it.
  • Gateway is one instance of a load balancer or proxy: its listeners, ports, hostnames, TLS, and which namespaces' routes may attach (allowedRoutes). The cluster operator owns it.
  • HTTPRoute attaches to a Gateway through parentRefs and holds the matching and forwarding rules. The application team owns it, in its own namespace.

Matching is defined, not guessed. A rule's matches are ORed; inside one match, path, headers, query parameters and method are ANDed. When several rules match, the spec gives the precedence: an Exact path beats a prefix, a longer prefix beats a shorter one, then a method match, then more header matches, then more query parameter matches, then the older route, then the first rule in list order. A request that matches nothing gets a 404. Weights are relative, not percentages: weight defaults to 1, and 0 sends no traffic to that backend.

Portability is tested. Features carry support levels. Exact header matching and Service backends are Core, which every conformant implementation supports; query parameter matching is Extended, which an implementation declares it supports. The GatewayClass status lists what the controller supports, so "will this work on our other cluster" is a lookup, not an experiment.

When to use which

  • New clusters and new routing work: Gateway API. The Kubernetes project recommends it, and canaries, header routing and cross-namespace delegation are fields, not annotations.
  • Existing Ingress on a maintained controller, simple host and path rules: keep it. Ingress is not being removed; migrate when you next need a feature it lacks.
  • Ingress that depends on controller annotations: plan the move, because the annotations are what will not port. If you ran Ingress NGINX, the move is overdue.
  • Several teams sharing one load balancer: Gateway API, because the operator owns the Gateway and each team owns only its routes.

Watch it run

The lesson's animation follows a request from the outside in. Pods change IPs, a Service gives them one stable name, and a failing readiness probe drops one Pod from the endpoints. ClusterIP turns out to be unreachable from outside, LoadBalancer gives a way in, and one Ingress behind that load balancer routes /api to the api Service and everything else to web. Then the animation shows an Ingress with no controller routing nothing, notes that Ingress carries only HTTP and HTTPS and is frozen, and relabels the front door as a Gateway: the slot this article is about.

Services & Ingress

Step 1 of 12

Pods come and go, and their IPs change with them. No client should chase addresses.

The same interactive animation as the lesson — step through it with the controls.

The code

The same canary written both ways. The Ingress needs a controller-specific annotation for the header and has no field for the split; the HTTPRoute says both in fields any conformant controller reads:

# illustrative: Ingress. Header routing and weights exist only as annotations
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  annotations: { example.com/canary-by-header: "x-canary" }   # controller-specific
spec:
  ingressClassName: example
  rules:
    - host: shop.example.com
      http:
        paths:
          - { path: /api, pathType: Prefix, backend: { service: { name: api-v1, port: { number: 80 } } } }
---
# illustrative: Gateway API. The app team owns this; the Gateway belongs to the platform team
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: { name: shop, namespace: shop }
spec:
  parentRefs: [{ name: public, namespace: infra }]
  hostnames: [shop.example.com]
  rules:
    - matches: [{ path: { type: PathPrefix, value: /api }, headers: [{ name: x-canary, value: "1" }] }]
      backendRefs: [{ name: api-v2, port: 80 }]
    - matches: [{ path: { type: PathPrefix, value: /api } }]
      backendRefs: [{ name: api-v1, port: 80, weight: 90 }, { name: api-v2, port: 80, weight: 10 }]

A toy model of HTTPRoute matching, not a controller. It applies the spec's precedence (exact path, longest prefix, most header matches, then list order) and picks a backend by weight. The exact /api/health rule beats the /api prefix, the header rule beats the plain prefix, /apix does not match the /api prefix because prefixes match whole path elements, and the 90/10 split lands near 90/10:

import random
from collections import Counter

RULES = [  # a toy HTTPRoute: matches (any one may hit) and weighted backends
    {"matches": [{"path": ("PathPrefix", "/api"), "headers": {"x-canary": "1"}}],
     "backends": [("api-v2", 1)]},
    {"matches": [{"path": ("PathPrefix", "/api")}], "backends": [("api-v1", 90), ("api-v2", 10)]},
    {"matches": [{"path": ("Exact", "/api/health")}], "backends": [("health", 1)]},
    {"matches": [{"path": ("PathPrefix", "/")}], "backends": [("web", 1)]},
]

def hits(m, path, headers):
    kind, value = m["path"]
    if kind == "Exact":
        ok = path == value
    else:  # prefix by path element: /api matches /api/x, not /apix
        ok = path == value or path.startswith(value.rstrip("/") + "/")
    return ok and all(headers.get(k) == v for k, v in m.get("headers", {}).items())

def rank(m):  # spec order: Exact, longest prefix, most header matches
    kind, value = m["path"]
    return (kind == "Exact", len(value), len(m.get("headers", {})))

def pick(path, headers):  # brute force: every match of every rule, best rank wins
    found = [(rank(m), -i) for i, r in enumerate(RULES)
             for m in r["matches"] if hits(m, path, headers)]
    return -max(found)[1] if found else None     # tie: first rule in list order

def route(path, headers, rng):
    i = pick(path, headers)
    if i is None:
        return 404
    return rng.choices(*zip(*RULES[i]["backends"]))[0]   # (names, weights)

rng = random.Random(7)
print(route("/api/health", {}, rng))                    # health
print(route("/api/orders", {"x-canary": "1"}, rng))     # api-v2
print(route("/apix", {}, rng))                          # web
print(sorted(Counter(route("/api/orders", {}, rng) for _ in range(10000)).items()))
# [('api-v1', 9004), ('api-v2', 996)]

The Ingress version of the same request ignores the header, because the spec has nowhere to put it. The second half checks the matcher: a table sorted once by precedence, where the first hit wins, agrees with the brute-force search on 5,000 seeded random requests:

ING = [("/api", "api"), ("/", "web")]   # Ingress: host + path, one backend, no headers

def ingress_route(path):
    fits = [r for r in ING if path == r[0] or path.startswith(r[0].rstrip("/") + "/")]
    return max(fits, key=lambda r: len(r[0]))[1] if fits else 404

print(ingress_route("/api/orders"), "<- x-canary has nowhere to go but an annotation")
# api <- x-canary has nowhere to go but an annotation

TABLE = sorted(((rank(m), -i, i, m) for i, r in enumerate(RULES) for m in r["matches"]),
               key=lambda e: e[:2], reverse=True)

def pick_fast(path, headers):   # first hit in precedence order
    return next((i for _, _, i, m in TABLE if hits(m, path, headers)), None)

rng = random.Random(42)
paths = ["/", "/api", "/api/", "/api/health", "/api/health/x", "/api/orders", "/apix", "/web"]
reqs = [(rng.choice(paths), rng.choice([{}, {"x-canary": "1"}, {"x-canary": "0"}]))
        for _ in range(5000)]
ok = all(pick_fast(p, h) == pick(p, h) for p, h in reqs)
print(ok, len(TABLE), "entries")   # True 4 entries

The complexity

  • Per request: both end up as proxy configuration the controller compiles ahead of time. A naive matcher like the toy is linear in the number of matches; real proxies use prefix trees and hash lookups. Neither API changes the data path cost much.
  • Objects to manage: Ingress is one object per app. Gateway API is one GatewayClass per controller, a few shared Gateways, and one or more routes per app: more objects, but fewer people editing each one.
  • Portability: Ingress features beyond host and path are rewritten per controller. Gateway API Core features move unchanged; Extended ones need a check against the implementation's declared support.

Where it goes wrong

  • No controller installed. Gateway API resources are custom resources plus a controller; creating an HTTPRoute in a cluster with neither does nothing, exactly like an Ingress without a controller.
  • Route not attached. A Gateway listener accepts routes from its own namespace unless allowedRoutes says otherwise, so a route from another namespace is not accepted. Check the route's status.parents conditions.
  • Weights read as percentages. 90 and 10 work because they sum to 100, but weight: 1 and weight: 3 are 25/75; the sum does not have to be 100.
  • Porting annotations literally. Rewrite-target, snippets and auth annotations have no one-to-one mapping; each becomes a filter, a policy resource or a feature the new controller does not offer.
  • Precedence surprises. A more specific match in another team's route on the same Gateway and hostname can win; precedence is computed across all attached routes, not only yours.

When it shows up in interviews

As the follow-up to "how does traffic get into the cluster?": after Services and Ingress, the interviewer asks how you would run a canary or route by header. Saying "Gateway API, an HTTPRoute with weighted backendRefs" and naming the role split shows current knowledge. The Service types underneath are in ClusterIP vs NodePort vs LoadBalancer vs Ingress, how an Ingress rule reaches them is in Kubernetes Service vs Ingress, and the request path itself is drawn in the Services and Ingress lesson.

How to say it in an interview

"Ingress is one resource: host and path to a Service, with a class to pick the controller. The API is frozen, so headers, weights and rewrites are controller annotations that do not port. Gateway API splits it: a GatewayClass names the controller, the platform team owns the Gateway with listeners and TLS, and app teams own HTTPRoutes that attach to it. Header matching and weighted backends are spec fields with defined precedence and conformance levels. For new work I would use Gateway API; existing simple Ingresses can stay until they need more."

Frequently asked questions

Is Kubernetes Ingress deprecated?

No. The Ingress API is generally available and the project has no plans to remove it, but it is frozen: no new features will be added. The Kubernetes documentation recommends Gateway API for new work. The community Ingress NGINX controller is a separate matter: it was retired, with best-effort maintenance ending in March 2026.

Can I use Ingress and Gateway API in the same cluster?

Yes. They are separate APIs served by separate controllers, and a cluster can run both while routes move over one host at a time. Give each its own external address, so DNS decides which one a client reaches, and retire the Ingress once its routes exist as HTTPRoutes.

How do I do a canary with Gateway API?

List two backendRefs in one HTTPRoute rule, such as the stable Service with weight: 90 and the canary with weight: 10, then change the weights as confidence grows. For testers only, add a rule that matches a header such as x-canary: 1 and sends it to the canary.

Sources