Services, Ingress and the Gateway API
Giving pods a stable address: Service types ClusterIP, NodePort and LoadBalancer, cluster DNS, and routing HTTP traffic into the cluster with Ingress and the Gateway API.
Why pods need a Service
Pods come and go: a rollout replaces them, a node failure moves them, and each new pod gets a new IP address. Clients therefore never use pod addresses. A Kubernetes service is a stable front: a virtual IP address and a DNS name that stay the same while the pods behind it change. It finds its pods with a label selector, and only pods that pass their readiness probe receive traffic.
kubectl can generate a Service for Kestrel's meter-api, which listens on port 8000:
$ kubectl create service clusterip meter-api --tcp=80:8000 --dry-run=client -o yaml
apiVersion: v1
kind: Service
metadata:
labels:
app: meter-api
name: meter-api
spec:
ports:
- name: 80-8000
port: 80
protocol: TCP
targetPort: 8000
selector:
app: meter-api
type: ClusterIP
status:
loadBalancer: {}
The generator assumes the pods carry the label
app: meter-api and names the port 80-8000.
Kestrel's pods use the recommended labels, so the selector has to be
adapted — this is the most common reason why a new Service sends
traffic nowhere. The cleaned-up version:
apiVersion: v1
kind: Service
metadata:
name: meter-api
namespace: kestrel-portal
spec:
type: ClusterIP # the default
selector:
app.kubernetes.io/name: meter-api # must match the pod labels exactly
ports:
- name: http
port: 80 # what clients connect to
targetPort: http # the named containerPort (8000) in the pod
protocol: TCP
Using a port name as targetPort means the
Service keeps working if a later image moves the container to
another port, as long as the pod spec names it http.
port, targetPort and nodePort
The containerPort in the pod spec is mostly
documentation: the application listens where it listens. What
matters is that targetPort matches the port the process
actually uses — and that the process listens on all interfaces
(0.0.0.0), not only on 127.0.0.1 inside
the container.
EndpointSlices
The Service itself holds no list of pods. A controller keeps EndpointSlice objects up to date with the addresses of the matching pods and whether each is ready; kube-proxy on every node turns them into forwarding rules. (The older Endpoints API is being phased out in favor of EndpointSlices.) This model shows which pods a Service would use — and what a typo in the selector does:
PODS = [
{"name": "meter-api-6d9f7b8c5-l2kpw", "ip": "10.244.1.12", "ready": True,
"labels": {"app.kubernetes.io/name": "meter-api"}},
{"name": "meter-api-6d9f7b8c5-r7wqx", "ip": "10.244.2.7", "ready": True,
"labels": {"app.kubernetes.io/name": "meter-api"}},
{"name": "meter-api-6d9f7b8c5-n5tjh", "ip": "10.244.2.9", "ready": False,
"labels": {"app.kubernetes.io/name": "meter-api"}},
{"name": "portal-7c9d8b6f4d-x2v9k", "ip": "10.244.1.15", "ready": True,
"labels": {"app.kubernetes.io/name": "portal"}},
]
def endpoints(service_name, selector, target_port):
selected = [p for p in PODS
if all(p["labels"].get(k) == v for k, v in selector.items())]
ready = [f"{p['ip']}:{target_port}" for p in selected if p["ready"]]
not_ready = [p["name"] for p in selected if not p["ready"]]
print(f"Service {service_name}: {len(selected)} pod(s) selected")
print(" traffic goes to:", ", ".join(ready) if ready else "NOTHING (no ready endpoints)")
if not_ready:
print(" not ready, no traffic:", ", ".join(not_ready))
endpoints("meter-api", {"app.kubernetes.io/name": "meter-api"}, 8000)
endpoints("meter-api (typo)", {"app.kubernetes.io/name": "meter_api"}, 8000)
Service meter-api: 3 pod(s) selected traffic goes to: 10.244.1.12:8000, 10.244.2.7:8000 not ready, no traffic: meter-api-6d9f7b8c5-n5tjh Service meter-api (typo): 0 pod(s) selected traffic goes to: NOTHING (no ready endpoints)
In a cluster you check the same thing with:
$ kubectl get endpointslices -n kestrel-portal -l kubernetes.io/service-name=meter-api
NAME ADDRESSTYPE PORTS ENDPOINTS AGE meter-api-7xk2p IPv4 8000 10.244.1.12,10.244.2.7,10.244.2.9 6d
The ENDPOINTS column lists every selected pod, ready or not;
kubectl describe endpointslice or
-o yaml shows each one's ready condition.
Service types
The type field decides from where a Service can be
reached. Each type builds on the previous one.
ClusterIP
ClusterIP is the default Service
type: a virtual IP address that is reachable only from inside the
cluster. It is right for everything internal — the portal calling
the meter-api, the meter-api calling PostgreSQL. With
clusterIP: None you get a
headless Service: no virtual IP, and DNS returns
the individual pod addresses instead, which StatefulSets use to give
each pod its own name (see
storage in Kubernetes).
NodePort
A NodePort Service additionally opens the same port on every node, by default chosen from the range 30000–32767. Traffic to any node's address on that port reaches the Service. It is useful for tests and as the building block behind an external load balancer you manage yourself. It is a poor way to expose production traffic: clients need to know node addresses, and ports like 80 or 443 are outside the range.
LoadBalancer
A
LoadBalancer service
asks the environment for an external
load balancer
that forwards to the Service. In a cloud, the cloud controller
manager creates one and fills in EXTERNAL-IP. On
premises nothing happens unless the cluster has an implementation
(such as MetalLB), and EXTERNAL-IP stays
<pending>. One load balancer per Service gets
expensive with many Services, which is why HTTP traffic usually
enters through one shared Gateway or Ingress instead.
ExternalName
An ExternalName Service has no pods at all: its DNS
name returns a CNAME (canonical name) record for a name outside the
cluster, such as a managed database. It lets workloads use an
internal name that you can repoint later.
$ kubectl get services -n kestrel-portal
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE meter-api ClusterIP 10.96.112.45 <none> 80/TCP 6d portal ClusterIP 10.96.40.7 <none> 80/TCP 6d portal-test NodePort 10.96.201.33 <none> 80:31080/TCP 2h
80:31080/TCP reads as "port 80 on the cluster IP, port
31080 on every node".
Cluster DNS
Cluster DNS is the DNS service
inside a Kubernetes cluster, usually CoreDNS, that gives every
Service a name. By default the full name is
<service>.<namespace>.svc.cluster.local.
Every pod's /etc/resolv.conf points to it and lists
search domains, so short names work too:
$ kubectl exec -n kestrel-portal deploy/portal -- cat /etc/resolv.conf
search kestrel-portal.svc.cluster.local svc.cluster.local cluster.local nameserver 10.96.0.10 options ndots:5
| Name used by the portal | Resolves to |
|---|---|
meter-api |
Service meter-api in the portal's own namespace |
db.kestrel-data |
Service db in namespace kestrel-data |
db.kestrel-data.svc.cluster.local |
The same, fully qualified — works from any namespace |
portal.example.com |
Not a cluster name; after the search domains fail it is passed to the upstream DNS |
ndots:5 means that a name with fewer than five dots is
first tried with each search domain appended. That is convenient for
short names, but every lookup of an external name such as
api.example.com first costs several failed internal
queries. For busy clients, writing external names as an
FQDN with a trailing dot
(api.example.com.) skips them. How DNS resolution works
in general is explained on
DNS.
Getting HTTP traffic into the cluster
Customers reach https://portal.example.com from the
internet: north-south traffic, in the terms of
data center traffic patterns. What they need is a
reverse proxy
at the edge of the cluster that does
TLS termination, picks a backend by host name and path, and forwards to ClusterIP
Services. Kubernetes has two APIs to configure such a proxy: the
older Ingress and the newer Gateway API. Both only describe what you
want; a separate controller does the actual work.
Ingress
An
Ingress holds
host and path rules that point to Services. An
ingress controller is the
proxy software running in the cluster that watches Ingress objects
and routes traffic according to them; without one, an Ingress does
nothing at all. ingressClassName says which controller
should handle it.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: portal
namespace: kestrel-portal
spec:
ingressClassName: example-ingress-class
tls:
- hosts: [portal.example.com]
secretName: portal-tls # certificate and key, a TLS Secret
rules:
- host: portal.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: meter-api
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: portal
port:
number: 80
The Ingress API has been stable since Kubernetes 1.19 and is frozen: the Kubernetes project recommends the Gateway API instead and adds no new features to Ingress, but it has no plans to remove it. Anything beyond host and path — rewrites, timeouts, header rules, canary weights — had to be added through controller-specific annotations, which do not carry over from one controller to another. That limitation is the main reason the Gateway API exists.
The retirement of ingress-nginx
For years the community controller ingress-nginx was the default choice; a January 2026 statement by the Kubernetes Steering and Security Response Committees put it at about half of all cloud native environments. On 11 November 2025, Kubernetes SIG Network and the Security Response Committee announced its retirement. Best-effort maintenance ended in March 2026: the last releases, controller-v1.15.1, v1.14.5 and v1.13.9, came out on 19 March 2026, and the GitHub repository is now archived. There will be no further releases, no bug fixes and no fixes for security vulnerabilities. Existing installations keep working and the published images stay available — which is exactly the danger, because nothing breaks until a vulnerability is found.
Tomasz Nowak, of Kestrel's network operations center (NOC), starts the migration with an inventory:
$ kubectl get ingress -A
$ kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx
NAMESPACE NAME CLASS HOSTS ADDRESS PORTS AGE kestrel-billing billing-reports nginx reports.example.com 203.0.113.20 80, 443 1y kestrel-portal portal nginx portal.example.com 203.0.113.20 80, 443 2y
The Kubernetes project points to two ways forward: move to the
Gateway API, or move to another, maintained Ingress controller. The
project's ingress2gateway tool converts Ingress
manifests into Gateway API resources as a starting point;
annotations specific to ingress-nginx still need a person to
translate them.
The Gateway API
The Gateway API is the Kubernetes project's set of resources for routing traffic into and within a cluster, the recommended successor to Ingress. It is not the same thing as an API gateway product; the similar names are a coincidence of words. It splits the configuration by role:
| Resource | Owned by | Describes |
|---|---|---|
GatewayClass |
Infrastructure provider | Which controller implements Gateways of this class (cluster-wide) |
Gateway |
Cluster operator | An entry point: listeners with ports, protocols, host names, certificates, and which namespaces may attach routes |
HTTPRoute (and others) |
Application developer | Routing rules from a Gateway to Services |
An HTTPRoute attaches to a Gateway listener and routes HTTP requests by host name, path, headers, query parameters or method to one or more backend Services, optionally with weights for canary releases, redirects and header changes — features that Ingress could only offer through annotations. Other route kinds cover gRPC, TLS, TCP and UDP.
A Gateway and an HTTPRoute
The NOC runs one shared Gateway in its own namespace and lets
namespaces with a certain label attach routes. The portal team adds
its HTTPRoute in kestrel-portal:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: kestrel-public
namespace: kestrel-gateway
spec:
gatewayClassName: example-gateway-class # provided by the installed controller
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: "*.example.com"
tls:
mode: Terminate
certificateRefs:
- name: kestrel-wildcard-tls # TLS Secret in kestrel-gateway
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kestrel.example.com/public: "true"
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: portal
namespace: kestrel-portal
spec:
parentRefs:
- name: kestrel-public
namespace: kestrel-gateway
sectionName: https
hostnames:
- portal.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /api
backendRefs:
- name: meter-api
port: 80
- backendRefs: # no match = PathPrefix /
- name: portal
port: 80
When several rules match a request, the Gateway API defines the
winner: an exact path beats a prefix, a longer prefix beats a
shorter one, and prefixes match whole path segments (/api
matches /api/readings but not /apix). A
simplified model with an extra exact-match rule:
RULES = [ # (hostname, match type, path, backend) from two HTTPRoutes
("portal.example.com", "PathPrefix", "/", "portal:80"),
("portal.example.com", "PathPrefix", "/api", "meter-api:80"),
("portal.example.com", "Exact", "/api/status", "status-page:80"),
]
def path_matches(match_type, rule_path, path):
if match_type == "Exact":
return path == rule_path
# PathPrefix matches whole path segments: /api matches /api and /api/x, not /apix
prefix = rule_path.rstrip("/")
return path == prefix or path.startswith(prefix + "/")
def route(host, path):
candidates = [r for r in RULES if r[0] == host and path_matches(r[1], r[2], path)]
if not candidates:
return "404 (no matching route)"
# precedence: Exact before PathPrefix, then the longest path wins
best = max(candidates, key=lambda r: (r[1] == "Exact", len(r[2])))
return f"{best[3]} ({best[1]} {best[2]})"
for host, path in [
("portal.example.com", "/"),
("portal.example.com", "/account/invoices"),
("portal.example.com", "/api/readings/2026-10"),
("portal.example.com", "/apix"),
("portal.example.com", "/api/status"),
("api.example.com", "/api/readings"),
]:
print(f"{host + path:41} -> {route(host, path)}")
portal.example.com/ -> portal:80 (PathPrefix /) portal.example.com/account/invoices -> portal:80 (PathPrefix /) portal.example.com/api/readings/2026-10 -> meter-api:80 (PathPrefix /api) portal.example.com/apix -> portal:80 (PathPrefix /) portal.example.com/api/status -> status-page:80 (Exact /api/status) api.example.com/api/readings -> 404 (no matching route)
The real precedence rules also weigh methods, headers and query parameters; the specification lists them in full.
$ kubectl get gateway -n kestrel-gateway
$ kubectl get httproute -n kestrel-portal
NAME CLASS ADDRESS PROGRAMMED AGE kestrel-public example-gateway-class 203.0.113.25 True 12d NAME HOSTNAMES AGE portal ["portal.example.com"] 12d
PROGRAMMED True means the controller has configured its
proxy. A route that does not work usually shows why in its status:
kubectl describe httproute portal -n kestrel-portal
reports per parent Gateway whether the route was
Accepted (for example
NotAllowedByListeners when the namespace lacks the
label) and whether its backend references resolved.
Installation and status, as of October 2026
-
The Gateway API is not built into Kubernetes. Its
resources are CRDs (custom resource definitions) that you install
separately —
kubectl apply --server-side -fwith the release'sstandard-install.yaml— or that your Gateway controller or managed Kubernetes service installs for you. A Gateway controller (from a proxy vendor, a service mesh or your cloud) is needed as well. -
Gateway, GatewayClass and HTTPRoute have been generally available
since version 1.0 (2023). Version 1.5 (February 2026) moved
ListenerSet, TLSRoute and ReferenceGrant into the Standard
channel. Version 1.6 (end of June 2026) moved TCPRoute and
UDPRoute into Standard and put experimental resources into a
separate API group,
gateway.networking.x-k8s.io. The latest patch release is v1.6.2. - The Standard channel contains the stable resources; the Experimental channel adds features still being tested. Production clusters normally install Standard only.