Service: A Fixed Address in Front of Short-Lived Pods

· tech

#kubernetes#concept#networking

📑 Contents

The second post left a question open: if Pods are short-lived and get a new IP when replaced, how do other services find them reliably? You can’t hard-code one Pod’s IP into a config — it might be gone the next second. This post’s protagonist, the Service, is K8s’s answer to that question: a fixed address standing in front of a group of Pods that keep changing.

See the problem clearly first: Pod IPs can’t be remembered

Every rolling update in the previous post replaces all the old Pods with new ones — and new Pods have new IPs. When a ReplicaSet replaces a crashed Pod, the replacement also has a brand-new IP. In other words, a Pod’s IP is inherently floating. Any approach of “look up the backend Pod’s IP, remember it, connect to it” is doomed to break after some reschedule or some rollout. You need something that doesn’t change to act as the intermediary.

Service: one fixed IP + one DNS name

The Service is that unchanging intermediary. You give it a label selector (say app=web), and it stands for every Pod carrying that label; outwardly it has a fixed virtual IP (the ClusterIP) and a fixed DNS name. Callers always hit this address, and the Service load-balances the traffic across the healthy Pods behind it:

caller knows only the name web Service: web fixed IP 10.96.0.10 · DNS web.*.svc selector: app=web load-balances to healthy Pods Pod · 10.1.2.7 ✓ healthy Pod · IP changes died, replaced: .8 → .31 Pod · 10.1.4.2 ✓ healthy
The caller always hits the Service's fixed address; Pods behind it come and go and IPs keep changing, and it notices nothing — that's the Service's core value: a stable abstraction shielding a changing reality

Declaring a Service is short; the point is that selector:

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  selector:
    app: web           # select every Pod with this label (however many, whatever their IPs)
  ports:
    - port: 80         # the port the Service exposes
      targetPort: 8080 # the port it actually forwards to on the Pod

With it, other services in the cluster connect by namehttp://web (same namespace) or http://web.default.svc.cluster.local (the full form across namespaces). Nobody needs to know which Pods are behind it, how many there are, or what their IPs are.

Behind it: a list that updates itself

How does the Service know which Pods should get traffic right now? Behind it sits an Endpoints list (EndpointSlice in newer versions) of “the Pod IPs that currently match the selector and pass their health check”. And you don’t maintain that list — it’s the reconcile loop again:

  • Pods added, removed, rescheduled, rolled out → the controller updates the list immediately.
  • A Pod not yet ready (readiness probe failing) → not added to the list, no traffic sent to it.

This is exactly what lets a rolling update be “uninterrupted”: a new Pod has to be genuinely ready before it enters the list and receives traffic, and an old one is removed only after it has drained. The pattern you saw in the first post — declare a desired state, let the loop converge reality — shows up here once more; this time the thing being converged is “which Pods should receive traffic”.

From inside the cluster to the public internet: Service types

The ClusterIP above is reachable only inside the cluster. But some services have to be reachable from outside, so Services come in several types of increasing “exposure”:

ClusterIP default · reachable only inside the cluster (backend to backend) NodePort a port on every Node; reach it via Node:Port (handy for testing) LoadBalancer the cloud provisions a public IP / LB; the production way Ingress L7 entry: one IP routes by host/path to many Services more exposed
Layer by layer, the service is pushed from inside the cluster towards the public internet. In practice external exposure is mostly "an Ingress behind a LoadBalancer, routing traffic by path to the various ClusterIP Services" — they all end up pointing at the same kind of internal address

The most common combination in practice is one external Ingress (or LoadBalancer) + a bunch of internal ClusterIP Services: only one entry is opened to the outside, and traffic is routed by URL to each service once inside (how L7 routing and TLS termination work is the subject of post 9). As for one variant of ClusterIP — the headless Service (no virtual IP, no load balancing; instead DNS gives you every Pod directly) — stateful services (databases, StatefulSets) use it, and post 6 covers it.

Reflections

”A stable abstraction in front of a changing reality” is K8s’s signature move

The more I think about the Service design, the more beautiful I find it: it didn’t make Pods more stable; it accepted that Pods will change, then put a permanent address in front of them. It’s the other face of the same philosophy as the second post‘s “Pods are cattle, not pets” — you don’t tame the thing that changes; you put an unchanging interface in front of it. I enjoyed the benefit in the Airflow + Spark on K8s post: Spark executors come and go, but the driver they need to find is reachable by one fixed Service name, regardless of which node the driver Pod landed on or what its IP is. Once you’re used to the pattern of “wrap short-lived entities in a stable abstraction”, the rest of K8s — Volume, StatefulSet, Ingress — is variations on it.

Services recognise each other by “name”, not by IP

Moving from hard-coded IPs to connecting by DNS name is a bigger shift than it looks. The most painful part of microservices calling each other used to be the brittle coupling of “that machine’s IP changed, that port moved”; in K8s, the name http://web is valid almost forever, however the Pods underneath move, scale up or down. It changes how I think about service boundaries entirely — what I depend on is a stable contract (name + port), not a fragile location (IP). It’s also why, looking at any system now, my first question is: “is the coupling here to a name or to a location?” Coupling to a location sooner or later pays the price of that location changing.

The reconcile loop again — only with a different thing being converged

Writing this post made me more certain of the first post‘s judgment: the reconcile loop is the master key to understanding K8s. A Deployment converges “the number of Pods”; the Endpoints behind a Service converge “which Pods should receive traffic”; it’s the same loop in essence. That’s especially useful for debugging: when “the service can’t reach its backend”, I don’t guess blindly; I go straight to that list — are the Endpoints empty? Odds are a selector label is misspelled, or a Pod’s readiness probe never passed so it never made the list. Reduce every feature to “who is converging what”, and problems stop being voodoo and become a line you can follow on the map.