You cannot memorise a Pod that will be recreated. A Service fixes an address in front of them, keeping callers stable while pods churn.

One address in front of many

A Deployment gives you multiple, interchangeable pods, and each one has its own IP that will soon be recreated. A Service is the stable home in front of them: you declare a name and a selector, and the platform hands the Service a virtual cluster IP plus a DNS name that stay fixed. Cluster DNS resolves that name, so components (other pods, an Ingress, a load balancer) refer to the Service, never to an individual pod address. That is the point of the abstraction, the address is durable even though everything standing behind it is disposable.

Selection drives which pods respond

A pod does not join a Service by being listed; it joins by carrying the labels the Service’s selector asks for, and it leaves when it no longer matches or is deleted. The platform maintains the endpoint set from the selector, adding a pod once it is running and ready, removing it on scale-down or failure. So the ‘backend list’ is a derived, always-changing view rather than a hand-edited roster. Because membership is derived from labels plus readiness, a pod that becomes unhealthy is dropped automatically and traffic is never aimed at a pod that should not serve.

Traffic goes to whatever matches

All of this gives you indirection that survives churn. Clients hold one name; the set behind it can be scaled, replaced, or rolled without them changing anything. A rolling update swaps pods one by one, readiness-gated, and the Service keeps forwarding to the current healthy matching set, so callers see no interruption. The guarantee you rely on is: as long as the selector is precise and the pods match it, the Service routes to the right set at every moment. Keep the selector tight, gate readiness, and this stable home stays stable even when every backer is new.

The stable name is a promise to callers

A companion article explains how the virtual address itself is allocated; this one is about what that stability means to whoever calls it. A well-behaved client resolves the Service name once, keeps using it for the life of the process, and survives every backend being replaced without a config push. That promise holds only while three habits hold: the selector keeps matching exactly the intended pods, readiness keeps unready pods out of the set, and no caller cheats by caching a pod IP it observed once. Treat the Service name as the only supported handle, and let scaling, crashes, and rollouts happen behind it.

When it breaks

The quietest failure is a selector that matches nothing. A typo such as app: webs instead of app: web, or a Deployment that stopped setting the expected label, leaves the Service with zero endpoints. The cluster IP and DNS name still resolve, so the outage looks like refused connections rather than a missing name. When a Service answers lookups but carries no traffic, inspect its endpoint set first; an empty set points at labels, not at the network.

A headless Service chosen by habit is the next trap. Setting clusterIP: None removes the virtual IP, so DNS returns the changing pod addresses and each client must balance across them. Code written for one stable endpoint suddenly faces many shifting ones, with uneven retries and broken connection reuse. Choose headless only when the caller explicitly wants discovery.

Ports break the promise from the other side. A Service port pointed at the wrong container targetPort accepts traffic it can never deliver, and a hand-picked NodePort can collide with another Service on the same nodes. Finally, leaving sessionAffinity: ClientIP on a stateless pool pins each client to one backend, so one dying pod looks like a total outage. Keep affinity off unless stickiness is the requirement.

Check it quickly

Save the following as service_endpoints.py in a scratch directory and run python3 service_endpoints.py. It models endpoint convergence with plain dictionaries and changes nothing outside the process: one function derives the endpoint set from a selector plus pod labels and readiness, and four scenarios exercise it, including a deliberate empty selector as the negative control.

"""Simulate Service endpoint convergence from selector plus readiness."""

PODS = [
    {"name": "web-1", "labels": {"app": "web"}, "ready": True},
    {"name": "web-2", "labels": {"app": "web"}, "ready": True},
    {"name": "web-3", "labels": {"app": "web"}, "ready": False},
    {"name": "worker-1", "labels": {"app": "worker"}, "ready": True},
]


def select_endpoints(pods, selector):
    """Derive the endpoint set; an empty selector warns instead of matching."""
    if not selector:
        return [], "warning: empty selector matches nothing"
    matched = sorted(
        pod["name"]
        for pod in pods
        if pod.get("ready")
        and all(pod.get("labels", {}).get(key) == value for key, value in selector.items())
    )
    return matched, ""


def check(condition, message):
    if not condition:
        raise AssertionError(message)


def main():
    endpoints, warning = select_endpoints(PODS, {"app": "web"})
    check(endpoints == ["web-1", "web-2"], "ready web pods should form the set")
    check(warning == "", "a precise selector must not warn")
    print("steady state:", endpoints)

    rolling = [dict(pod, ready=True) if pod["name"] == "web-3" else pod for pod in PODS]
    rolling = [pod for pod in rolling if pod["name"] != "web-1"]
    endpoints, _ = select_endpoints(rolling, {"app": "web"})
    check(endpoints == ["web-2", "web-3"], "rollover must swap one ready pod for another")
    print("after rollover:", endpoints)

    endpoints, _ = select_endpoints(PODS, {"app": "web", "track": "stable"})
    check(endpoints == [], "an over-narrow selector should converge to empty, not error")
    print("over-narrow selector:", endpoints)

    endpoints, warning = select_endpoints(PODS, {})
    check(endpoints == [] and "empty selector" in warning, "empty selector must warn loudly")
    print("empty selector:", repr(warning))

    print("PASS: four convergences; readiness-gated selection, rollover, and empty warning hold")


if __name__ == "__main__":
    main()

The four scenarios converge as a healthy Service should: the steady state holds the two ready web pods while the unready one stays out, and the rollover swaps one ready pod for another without changing the selector. The empty selector is the negative control: it warns loudly instead of silently matching everything or nothing. All assertions passed with Python 3.14.4 on macOS.

The simulation checks label-plus-readiness convergence only. It does not run kube-proxy, cluster DNS, or real probe timing. Before deploying, inspect the live endpoint set alongside pod labels and readiness, and repeat the lookup against the real Service name.

Further reading

Sources