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
An ordinary ClusterIP Service provides a virtual address and, with cluster DNS, a stable discovery name for its lifetime. A selector-based Service also declares which Pods in its namespace are intended backends. Other forms differ: headless Services allocate no virtual IP, ExternalName uses DNS indirection, and selectorless Services can use separately managed EndpointSlices.
The address is stable while the Service object exists, rather than forever after a name first appears. Recreating it may allocate a different address. Clients should use the intended discovery contract and refresh resolution appropriately, instead of treating a copied ClusterIP as permanent infrastructure identity.
Selector is the live membership
EndpointSlice controllers reconcile matching Pods and their endpoint conditions asynchronously. An unready Pod can remain represented with ready=false, rather than disappearing from every endpoint object. Consumers use those conditions for routing. publishNotReadyAddresses changes the normal readiness interpretation, and termination handling adds further detail.
Label agreement alone does not prove healthy traffic. Wrong ports, unsuitable probes or stale datapath programming can still fail requests. Inspect both the desired selector and the observed EndpointSlices, then verify actual application behavior. Automatic reconciliation avoids hand-maintaining ordinary backend lists, but does not promise instantaneous updates or eliminate every form of drift.
Stable even as pods churn
A stable destination allows backend replacement without publishing every Pod IP to callers. It does not guarantee uninterrupted requests. Existing connections can remain attached to old backends, new connections depend on converged routing state, and a Service with no usable endpoints cannot create missing capacity.
Readiness, graceful termination, rollout capacity and client retry policy therefore remain necessary parts of availability design. A DNS name surviving a deployment is one useful property; successful traffic and bounded recovery are separate properties that need observation. The allocation exercise below deliberately tests neither of them.
When it breaks
The pool is finite, and exhaustion is the most literal failure. A virtual-IP Service consumes an address for each configured IP family from the service address pools, 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: verify ownership before removing unused Services, and plan supported pool expansion for the installed cluster version.
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 is intended for internal service routing; external reachability depends on explicit network configuration.
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. Both language models passed with Python 3.12.14 on Linux in this review. The first-host reservation and allocation order are teaching choices, not Kubernetes allocator guarantees.
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-10-15.
Virtual IPs and Service Proxies, documents ClusterIP allocation and kube-proxy modes, checked 2026-10-15.
Primary reference, endpoint conditions and asynchronous discovery, checked 2026-10-15.