The selector turns the Service from a fixed Endpoint into a live view: refresh happens in the background, and a client never edits its config when pods restart.
Declared, then given an address
A Service turns the constant churn of pods into a stable name. You declare a selector (which pods), a port, and a protocol, and the control plane assigns a virtual IP (cluster IP) and a DNS name that stay fixed while everything behind them keeps changing. The stable name is the contract for every other resource: a Deployment, an Ingress, or another pod can address a service by name without caring which concrete pod exists right now. That indirection is what lets the runtime rearrange the membership without a client ever noticing.
Selector is the live membership
The backend list a Service routes to is not a fixed set, it is rebuilt continuously from the selector the moment pods match or stop matching it. A pod that fails its readiness probe is dropped from the endpoints; a new pod that passes joins; the update happens in the background. Because membership is derived, you never hand-edit the list and you never drift: as long as the selector and the pod labels line up, the Service automatically reflects the current, healthy backends. That readme is what makes a Service ahead of static lists you would have to maintain by hand.
Stable even as pods churn
Clients talk only to the virtual address, never to an individual pod IP, which is exactly what decouples the caller from the fleet. You can scale up, scale down, replace, or kill a pod and the Service keeps answering through the survivors; the client sees no change because its target never changes. That stability is the whole reason Services allow rolling restarts and horizontal scaling without a coordinated announcement. Keep the selector precise and probe-gated, and the Service gives you a stable facade over an inherently unstable set of workers.
When it breaks
The pool is finite, and exhaustion is the most literal failure. Every ordinary Service takes one address from --service-cluster-ip-range, so a small CIDR or leftover Services eventually reject creation with an allocation error while the pods look fine. Treat failed to allocate IP as capacity accounting: delete orphaned Services before widening the range, or widening only postpones the outage.
Mixing headless and ordinary Services is the next confusion. Setting clusterIP: None allocates no virtual address, so DNS returns pod IPs instead of one stable VIP, and a client written for a single endpoint must suddenly balance across shifting addresses. The reverse also hurts: discovery code expecting per-pod DNS keeps hitting one VIP after someone recreates the Service with a ClusterIP. Because clusterIP is effectively immutable, switching modes means recreating the Service.
The last two failures live below the API. Iptables and IPVS modes program the same VIP differently, and a stale or restarting kube-proxy can leave an allocated ClusterIP resolving but blackholing traffic until rules resync. Finally, copying a ClusterIP into configs freezes a value the control plane may reassign: the address is stable only while the Service object lives. Callers must use the DNS name, and remember the VIP routes only inside the cluster.
Check it quickly
Save the following as clusterip_pool.py in a scratch directory and run python3 clusterip_pool.py. It models a miniature allocator over 10.96.0.0/29 with the first address reserved, using only the standard library and changing nothing outside the process: one class hands out addresses, refuses duplicates and out-of-range requests, lets headless Services bypass the pool, and reuses released addresses, with five scenarios asserting each rule.
"""Simulate ClusterIP allocation from a small service CIDR."""
import ipaddress
class ClusterIPPool:
"""Hand out virtual addresses; headless Services bypass the pool."""
def __init__(self, cidr, reserved=1):
network = ipaddress.ip_network(cidr)
hosts = list(network.hosts())
self._all = set(hosts)
self._reserved = set(hosts[:reserved])
self.free = sorted(set(hosts) - self._reserved)
self.allocated = {}
def allocate(self, service, requested=None, headless=False):
if headless:
return None
if service in self.allocated:
raise ValueError(f"{service} already holds {self.allocated[service]}")
if requested is not None:
ip = ipaddress.ip_address(requested)
if ip in self._reserved or ip not in self._all:
raise ValueError(f"{requested} is outside the allocatable range")
if ip not in self.free:
raise ValueError(f"{requested} is already taken")
self.free.remove(ip)
self.allocated[service] = ip
return str(ip)
if not self.free:
raise RuntimeError("service CIDR exhausted: no ClusterIP left")
ip = self.free.pop(0)
self.allocated[service] = ip
return str(ip)
def release(self, service):
ip = self.allocated.pop(service, None)
if ip is None:
raise ValueError(f"{service} holds nothing to release")
self.free.append(ip)
self.free.sort()
return str(ip)
def check(condition, message):
if not condition:
raise AssertionError(message)
def main():
pool = ClusterIPPool("10.96.0.0/29", reserved=1)
web = pool.allocate("web")
api = pool.allocate("api", requested="10.96.0.5")
check(web != api, "sequential Services must get distinct addresses")
print("allocated:", web, api)
try:
pool.allocate("worker", requested="10.96.0.5")
raise SystemExit("conflict was silently shared")
except ValueError as exc:
print("conflict rejected:", exc)
try:
pool.allocate("evil", requested="10.99.0.1")
raise SystemExit("out-of-range request was accepted")
except ValueError as exc:
print("out-of-range rejected:", exc)
headless = pool.allocate("db-headless", headless=True)
check(headless is None, "headless Service must bypass the pool")
check(len(pool.allocated) == 2, "headless must not consume the pool")
print("headless bypass: None")
freed = pool.release("web")
worker = pool.allocate("worker")
check(worker == freed, "released address must be reusable")
print("reused:", freed, "-> worker")
pool.allocate("extra-1")
pool.allocate("extra-2")
pool.allocate("extra-3")
try:
pool.allocate("overflow")
raise SystemExit("exhaustion was not reported")
except RuntimeError as exc:
print("exhaustion reported:", exc)
print("PASS: allocation, conflict rejection, release reuse, and exhaustion hold")
if __name__ == "__main__":
main()
The run behaves like a healthy allocator: sequential Services get distinct addresses, a pinned request holds its address, and the deliberate double allocation is rejected instead of shared. Releasing one Service makes its address reusable, and filling the last slot proves exhaustion errors instead of recycling a live address. All assertions passed with Python 3.14.4 on macOS.
The simulation checks bookkeeping only. It does not run the API server, etcd, or kube-proxy, and its tiny /29 stands in for a real service CIDR. Before deploying, inspect the live service CIDR, allocated ClusterIPs, and proxy rules on a node.
Further reading
Sources
- Kubernetes Service, documents service semantics, checked 2026-06-01.
- Virtual IPs and Service Proxies, documents ClusterIP allocation and kube-proxy modes, checked 2026-06-01.