Services

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A Pod’s IP address is not something anything else should ever depend on: it is assigned when the Pod starts, and a Deployment rolling update, a reschedule after a node failure, or a simple kubectl delete pod replaces it with a Pod at a different address. Services are the object that gives a set of Pods a single, stable way to be reached despite that constant churn — a fixed virtual IP and DNS name in front of a label selector, load-balanced across whichever Pods currently match it. This page describes Services as they stand in Kubernetes 1.37 with kube-proxy 1.37; every Bookshelf manifest below targets the core v1 API, which is where Service has lived, unchanged in shape, since Kubernetes 1.0.

Why Services exist

Rensin’s Kubernetes: Scheduling the Future at Cloud Scale (2015), ch. 3, "Organize, Grow, and Go", already frames the problem this way: a client — another Pod, or a person outside the cluster — needs a stable address to talk to "the catalog API", not to any one of its interchangeable replicas, and that address has to keep working as individual Pods are replaced, rescheduled or scaled. A Service solves this with three cooperating pieces:

  • a label selector (spec.selector), matched the same way a ReplicaSet’s selector is, against every Pod’s labels in the Service’s namespace;

  • a stable virtual IP (the ClusterIP) and DNS name, covered further in DNS and Service Discovery, that never changes for the life of the Service object, regardless of which Pods are currently behind it;

  • an up-to-date list of the matching Pods' addresses, computed continuously by the EndpointSlice controller (below) and consumed by kube-proxy to actually load-balance traffic to one of them.

Book 1’s "client request path" — DNS lookup, then a load balancer, then the Service, then a Pod — is still exactly the shape of an external request today; only the components behind each arrow changed, most visibly the frontend load balancer, discussed in The portal IP, CreateExternalLoadBalancer, and the userspace proxy, as history below.

The four `type`s: ClusterIP, NodePort, LoadBalancer, ExternalName

spec.type selects how a Service is reachable. All four keep the same selector-driven Endpoints/EndpointSlices machinery underneath except ExternalName, which is a special case with no selector, no proxying and no Endpoints at all.

type What it does Reachable from

ClusterIP (default)

Allocates a stable virtual IP from the cluster’s Service CIDR (see Cluster Networking, CNI and Service Mesh) that load-balances across every matching Pod.

Inside the cluster only.

NodePort

Everything ClusterIP does, plus the same port opened on every node’s IP (default range 30000-32767, configurable at API-server level).

Any node’s IP, from inside or outside the cluster — still cluster-internal in the sense that no cloud infrastructure is provisioned.

LoadBalancer

Everything NodePort does, plus a request to the cloud provider’s controller to provision an external load balancer that forwards to the NodePort.

The public network the cloud load balancer is attached to. The only type with an externally routable, provider-managed address on its own.

ExternalName

No selector, no ClusterIP, no Endpoints. spec.externalName is returned verbatim as a CNAME whenever CoreDNS resolves the Service’s own DNS name.

Wherever the CNAME target itself is reachable from — the Service adds no connectivity of its own, only a name that in-cluster clients can look up without hard-coding an external hostname.

Four Kubernetes Service types compared ClusterIP NodePort LoadBalancer and ExternalName
apiVersion: v1
kind: Service
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: catalog-api
  ports:
    - port: 80
      targetPort: 8080

ExternalName is worth dwelling on because it looks like the odd one out but is in fact the simplest of the four — a pure DNS alias, evaluated fresh on every lookup, with no IP of its own and no health checking:

apiVersion: v1
kind: Service
metadata:
  name: billing-legacy
  namespace: bookshelf
spec:
  type: ExternalName
  externalName: billing.example.com

Headless Services

Setting spec.clusterIP: None on an otherwise ordinary Service turns off virtual-IP load-balancing entirely: CoreDNS resolves the Service’s name to the individual Pod IPs directly (an A/AAAA record per ready Pod) instead of to one proxied address, and, when the Service is also referenced by a StatefulSet's spec.serviceName, one additional per-Pod DNS record is created for each ordinal. A client that needs to reach every backend, or one specific backend, rather than "any one of them picked for me", uses this instead of a load-balanced ClusterIP —  StatefulSets is the worked example with a full manifest and diagram.

apiVersion: v1
kind: Service
metadata:
  name: catalog-api-headless
  namespace: bookshelf
spec:
  clusterIP: None
  selector:
    app.kubernetes.io/name: catalog-api
  ports:
    - port: 80
      targetPort: 8080

Selectorless Services, manual EndpointSlices, and per-namespace resolution

Dropping spec.selector entirely produces a Service that the EndpointSlice controller does not manage at all — nothing populates its endpoints automatically, because there is no selector to match Pods against. Hightower, Burns & Beda’s Kubernetes: Up and Running (1st ed., 2017 sampler), ch. 4 (book ch. 13), "Integrating Storage Solutions and Kubernetes", builds exactly this to import an external service into the cluster’s own naming: a legacy database, or a service still running outside Kubernetes, gets a Service name and DNS entry that in-cluster code can call like any other backend, with the actual address supplied by hand instead of by a label selector. The book wires this with the old, now-deprecated Endpoints object (ExternalName did not yet exist in every case it needed, and EndpointSlice did not exist at all); the current way to do the same thing is a selectorless Service paired with a manually created EndpointSlice:

apiVersion: v1
kind: Service
metadata:
  name: legacy-inventory
  namespace: bookshelf
spec:
  ports:
    - port: 5432
      targetPort: 5432
      # no selector: nothing populates Endpoints/EndpointSlices for this Service automatically
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: legacy-inventory-manual
  namespace: bookshelf
  labels:
    kubernetes.io/service-name: legacy-inventory   # binds this slice to the Service above
addressType: IPv4
ports:
  - name: ""
    protocol: TCP
    port: 5432
endpoints:
  - addresses:
      - "203.0.113.42"     # the legacy database's real, out-of-cluster address

The same chapter also uses a selectorless Service (there, an ExternalName one) for per-namespace resolution: a bookshelf-test namespace and a bookshelf-prod namespace each define a Service named identically (legacy-inventory) but pointing at their own environment’s backend — test at a sandbox database, prod at the real one. In-cluster code that simply calls legacy-inventory (or, with the fully qualified name, legacy-inventory.<namespace>.svc.cluster.local, see DNS and Service Discovery) then gets test/prod parity for free: the exact same manifest and the exact same client code run unmodified in either namespace, because the difference lives entirely in which namespace’s Service object is doing the resolving, not in any code path. This is also the pattern the book uses for a Service that has no health checks of its own to run against an external backend — the cluster trusts whatever address it is given, with no readiness or liveness probing on Kubernetes' side (see Health Probes and Container Lifecycle for the probing Kubernetes does run, which never applies to a manually populated EndpointSlice).

The deprecated Endpoints API

Every Service, selector-based or manual, used to be backed by exactly one Endpoints object (kind: Endpoints, core v1) holding a flat list of every matching address and port — the object the book’s ch. 4 manifests target directly. That single-object design does not scale: a Service behind thousands of Pods produced one enormous Endpoints object that every kube-proxy on every node had to re-read in full on each change. EndpointSlices (discovery.k8s.io/v1, stable since Kubernetes 1.21) replace it with several smaller, bounded objects per Service (up to 100 endpoints each by default, configurable via the endpoint-controller’s --max-endpoints-per-slice flag), each carrying an addressType, its own ports and per-endpoint conditions (ready, serving, terminating) and topology fields (zone, nodeName) that a flat Endpoints object has no room for — exactly what externalTrafficPolicy and internalTrafficPolicy and topology-aware routing below need. The Kubernetes project deprecated the Endpoints API in 1.33: the API is still served for compatibility (nothing in a 1.37 cluster removes it), the control plane keeps writing a mirrored Endpoints object for every Service so old clients keep working, but new code — and the manual manifest above — should read and write EndpointSlice directly.

kubectl get endpointslices -n bookshelf -l kubernetes.io/service-name=catalog-api
kubectl describe endpointslice -n bookshelf catalog-api-abc12

Multi-port and named ports, and sessionAffinity

A Service’s spec.ports is a list, not a single value: one Service can expose several ports at once (an HTTP port and a separate metrics port, say), each entry independently mapping its own port to a targetPort on the Pod. Once a Service (or a Pod) has more than one port, every port/targetPort needs a distinct name, because that name — not a bare number — is what a multi-port Pod spec and things like a an Ingress backend reference unambiguously.

apiVersion: v1
kind: Service
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  selector:
    app.kubernetes.io/name: catalog-api
  ports:
    - name: http
      port: 80
      targetPort: http        # matches a named containerPort on the Pod
    - name: metrics
      port: 9090
      targetPort: metrics
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800    # 3 hours, the default

sessionAffinity controls whether repeated requests from the same client are routed to the same Pod whenever possible. The default, None, load-balances every request independently (round-robin-ish, depending on the kube-proxy mode below); ClientIP instead pins a client’s source IP to whichever Pod it was first routed to, for sessionAffinityConfig.clientIP.timeoutSeconds (default three hours) since its last request. This is a coarse tool — it affinities by IP, not by an application-level session token, so it groups every client behind the same NAT gateway together — and it has nothing to do with externalTrafficPolicy below, which is about which node handles a packet, not which Pod a repeat client lands on.

externalTrafficPolicy and internalTrafficPolicy

By default, any node that receives traffic for a NodePort or LoadBalancer Service may forward it to any matching Pod cluster-wide, even one on a different node — convenient, but it means the packet’s original source IP is usually replaced by the forwarding node’s IP by the time it reaches the Pod (SNAT), and it costs an extra network hop whenever the chosen Pod is not local. spec.externalTrafficPolicy: Local changes that for externally-arriving traffic: a node only forwards to Pods running on itself, dropping the packet if it has none, in exchange for the client’s real source IP being preserved end to end and one fewer network hop. spec.internalTrafficPolicy: Local is the same hard rule applied to traffic that originates inside the cluster (a Pod calling the Service’s ClusterIP): kube-proxy only routes to a Pod on the caller’s own node and drops the traffic when that node has none — there is no cluster-wide fallback. It suits a node-local agent that runs on every node (a DaemonSet); for a soft same-node preference that does fall back, use trafficDistribution: PreferSameNode (below) instead. Both default to Cluster (the original, no-locality behaviour); both are read straight off EndpointSlice conditions and nodeName, which is exactly what the flat Endpoints API could not represent per-endpoint.

apiVersion: v1
kind: Service
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local     # preserve the client's source IP, avoid an extra hop
  internalTrafficPolicy: Local     # in-cluster callers ONLY reach a same-node Pod; traffic is dropped on a node
                                   # without a catalog-api Pod -- see trafficDistribution below for a soft preference
  selector:
    app.kubernetes.io/name: catalog-api
  ports:
    - port: 80
      targetPort: 8080

trafficDistribution: topology-aware routing

spec.trafficDistribution expresses a preference for keeping traffic close, without externalTrafficPolicy: Local’s hard "same node or drop it" rule: `PreferSameZone prefers an endpoint in the client’s own zone and falls back to any healthy endpoint cluster-wide if none is available there; PreferSameNode prefers an endpoint on the client’s own node first, then falls back to same-zone, then cluster-wide. (PreferClose is a deprecated alias for PreferSameZone.) The field went GA in Kubernetes 1.33 and the PreferSameZone/PreferSameNode values in 1.35. It is the simpler, more predictable successor of Topology Aware Routing, the service.kubernetes.io/topology-mode: Auto annotation (which replaced the older service.kubernetes.io/topology-aware-hints). When that annotation is set to Auto it takes precedence over trafficDistribution, and a traffic policy set to Local takes precedence for its traffic type as well. The annotation’s heuristic spreads traffic across zones by allocatable CPU and falls back cluster-wide unless there are enough endpoints (at least three per zone); trafficDistribution has no such safeguard — if a zone has any endpoint, it receives all of that zone’s traffic, so keeping each zone’s endpoints from being overloaded is up to you.

apiVersion: v1
kind: Service
metadata:
  name: catalog-api
  namespace: bookshelf
spec:
  selector:
    app.kubernetes.io/name: catalog-api
  trafficDistribution: PreferSameZone   # keep traffic in-zone when a healthy in-zone Pod exists
  ports:
    - port: 80
      targetPort: 8080

kube-proxy modes: nftables, iptables, and IPVS

kube-proxy is the node-local component that actually programs the packet-forwarding rules an EndpointSlice’s list of addresses describes, in one of three modes:

Mode Status in Kubernetes 1.37

nftables

The recommended mode for new Linux clusters, stable since 1.33, built on the kernel’s modern nftables subsystem. Better scaling behaviour than iptables as the number of Services and endpoints grows.

iptables

The long-standing default. Still fully supported, but superseded by nftables for new clusters; a future release is expected to change the default to nftables (kube-proxy still defaults to iptables in 1.37 when no mode is configured explicitly).

IPVS

Deprecated in Kubernetes 1.35. The Linux kernel’s IPVS API turned out to be a poor fit for everything the Service API needs to express, and the IPVS backend could never correctly implement every edge case the iptables/nftables backends handle. It logs a deprecation warning and is planned to be disabled by default in 1.40 and removed entirely in 1.43; new clusters should use nftables instead.

externalIPs: deprecated in Kubernetes 1.36

spec.externalIPs lets a Service claim to be reachable on one or more IPs that kube-proxy simply assumes are already routed to a node, with no cloud integration and no ownership check of any kind — exactly the gap CVE-2020-8554 exploited, since any user allowed to create a Service could claim an IP address that was never theirs to claim and intercept traffic meant for it. The field has been formally deprecated since Kubernetes 1.36 (the project already recommended disabling it, via the DenyServiceExternalIPs admission controller, since 1.21), with removal planned for 1.43; type: LoadBalancer, NodePort, or the Gateway API (Gateway API) are the supported replacements for whatever externalIPs was being used for.

kubectl expose

kubectl expose creates a Service from an already-running Deployment, ReplicaSet, Pod or (with --protocol) another Service, inferring the selector from the target’s own labels instead of it being typed out by hand — the quick path from "I have a running Deployment" to "it has a Service in front of it", and the one Hightower, Burns & Beda’s ch. 5 (book ch. 14) uses throughout to put a Service in front of each app it deploys.

kubectl expose deployment catalog-api -n bookshelf \
  --port=80 --target-port=8080 --name=catalog-api
service/catalog-api exposed

The portal IP, CreateExternalLoadBalancer, and the userspace proxy, as history

Rensin’s ch. 3 describes exposing a Service through a hand-run firewall rule, a DIY HAProxy, or a cloud load balancer — all built by the operator, outside Kubernetes' own API — because at the time neither NodePort nor type: LoadBalancer existed yet: the field the book’s era called the "portal IP" is exactly today’s ClusterIP under an earlier name, and CreateExternalLoadBalancer: true was the pre-1.0 predecessor of today’s spec.type: LoadBalancer, which replaced it once cloud-provider integration moved into the Service API itself. kube-proxy’s own implementation has moved twice since: the original userspace proxy mode actually terminated every connection in a kube-proxy process and re-dialed a backend Pod itself (a real, user-space round-robin, and the literal origin of "the userspace proxy" Book 1 alludes to), which iptables mode replaced with kernel-level packet rewriting and no per-connection userspace hop; iptables mode has itself now been overtaken by nftables as the recommended mode above. None of these three legacy pieces — portal IP, CreateExternalLoadBalancer, or the userspace proxy — exist in a 1.37 API server or kube-proxy binary; every Bookshelf Service in this section is written, and works, purely against type: ClusterIP / NodePort / LoadBalancer / ExternalName.

kube-proxy programming a node from EndpointSlices

flowchart TB API["API server"] ES["EndpointSlice controller"] EPS["EndpointSlice objects\n(per Service, up to 100 endpoints each)"] KP1["kube-proxy on node A"] KP2["kube-proxy on node B"] RULES1["nftables/iptables/IPVS rules on node A"] RULES2["nftables/iptables/IPVS rules on node B"] PODSEL["Pods matching Service selector"] -->|"watch"| ES ES -->|"create/update"| EPS EPS -->|"stored via"| API API -->|"watch"| KP1 API -->|"watch"| KP2 KP1 -->|"apply externalTrafficPolicy/\ninternalTrafficPolicy/trafficDistribution"| RULES1 KP2 -->|"apply externalTrafficPolicy/\ninternalTrafficPolicy/trafficDistribution"| RULES2

Every node’s kube-proxy watches the same EndpointSlice objects independently and programs its own local rules from them; externalTrafficPolicy, internalTrafficPolicy and trafficDistribution are all just filters kube-proxy applies to that same watched list before writing rules — which is why they need no per-node configuration of their own, only the Service’s spec. Source: Kubernetes Docs — EndpointSlices and Kubernetes Docs — Virtual IPs and Service Proxies.

References