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

Traffic to node address 192.0.2.21 on nodePort 31080 is forwarded to the Service's cluster IP 10.96.112.45 on port 80, which forwards to a pod at 10.244.1.12 on targetPort 8000, the container's port. Any node 192.0.2.21:31080 Service meter-api 10.96.112.45:80 Pod 10.244.1.12:8000 nodePort port targetPort
Three ports, three places. Only NodePort and LoadBalancer Services have a nodePort; every Service has a port and a targetPort.

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 browser connects to the Gateway kestrel-public on HTTPS port 443, which belongs to a GatewayClass that names the controller. The HTTPRoute portal is attached to the Gateway and sends requests for paths under /api to the Service meter-api and all other paths to the Service portal. Each Service forwards to its pods. Browser Gateway kestrel-public HTTPS :443, *.example.com GatewayClass which controller runs it HTTPRoute portal host portal.example.com Service portal :80 PathPrefix / Service meter-api :80 PathPrefix /api Pod Pod Pod Pod
The platform team owns the Gateway; the portal team owns its HTTPRoute and can change routing without touching the shared entry point.

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 -f with the release's standard-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.