The idea: ask by name, not by address

A service like orders runs as several instances. They start, stop, crash and move to new machines, and each time their addresses change. Writing 10.0.1.11:8080 into the caller's config does not work for long. Service discovery puts a registry in the middle: every instance tells the registry where it is, and a caller asks the registry by name. Consul, etcd, ZooKeeper, Netflix Eureka and the Kubernetes API server all play this role.

Three instances of orders register with the registry. Left: client-side discovery, web asks the registry for the list and connects to an instance itself. Right: server-side discovery, web calls a load balancer, which watches the registry and forwards to an instance
Instances register; a caller either looks up the list itself (client-side) or calls a load balancer that does it (server-side).

On the canvas, orders-1 and orders-2 are running, orders-3 is not started, and web is the caller. The lookup menu picks how web finds an instance.

Register on start, deregister on stop

When an instance starts, it sends its name, its id, its address and a health check to the registry. In Consul that is PUT /v1/agent/service/register; the check here is GET /health every 10 s. The registry adds a row to its catalog and bumps a version number, X-Consul-Index. A new check starts critical, so the instance gets no traffic until its first check answers 200 (Demo 2: register on start, the load balancer picks it up).

On a clean stop (SIGTERM), the instance deregisters first and only then closes its port. Kubernetes does the same with readiness: a pod that is shutting down is taken out of the Service's endpoints before it exits.

Health checks and the crash window

A crashed process cannot deregister. The registry only finds out at the next health check, when GET /health is refused or times out and the row turns critical. Until then it still hands out the dead address (Demo 3: a crash goes unnoticed until the health check). A caller that cached the list learns even later.

A time line from 0 to 40 seconds. orders-2 crashes at t = 2. The health check at t = 10 marks it critical; the load balancer drops it at once. A client cache fetched at t = 0 keeps it until t = 30. Calls to orders-2 fail between the crash and the time each caller stops using it
Each layer adds its own delay: the check interval first, then however long a caller keeps its copy of the list.

Shorter intervals shrink the window but cost more checks. Since the window never goes to zero, callers also retry on another instance when a connection is refused, as web and the load balancer do here. See Retries with backoff and jitter and Circuit Breaker. Consul agents also learn about dead nodes through gossip, as on Gossip and SWIM.

Client-side discovery

The caller asks the registry itself (GET /v1/health/service/orders?passing), keeps the list, and picks an instance with its own load balancing, here round robin. There is no extra hop and the caller can be clever (zones, latency, consistent hashing). The price: every caller needs a registry library, and every caller has its own copy of the list. A Netflix Eureka client refreshes it every 30 s (registryFetchIntervalSeconds), so even a clean deregister reaches it late (Demo 4: stale client cache after a clean stop). Watching the registry (Consul blocking queries, gRPC name resolvers) cuts that delay.

Server-side discovery

The caller sends every request to one fixed name, orders.internal. A load balancer or router holds the list and forwards. It keeps a blocking query open: GET /v1/health/service/orders?passing&index=9 hangs until the index moves past 9, then returns the new list. So one watcher sees every change at once, and callers need nothing but an address. The price is one more hop and one more thing to run and scale. AWS ALB with target groups, nginx or HAProxy with consul-template, and a Kubernetes Service all work this way. When no instance is passing, the load balancer answers 503 at once (Demo 6: no healthy instance left: 503).

client-sideserver-sideDNS
caller needsregistry client libraryone addressa resolver (every OS has one)
hops per request121
who balancesthe callerthe load balancermostly nobody: first record
how freshcache or watchwatch: at onceTTL, plus caches that ignore it
examplesEureka + Ribbon, gRPC xDSAWS ALB, Kubernetes ServiceConsul DNS, CoreDNS

DNS as the lookup

The registry can also answer plain DNS: Consul serves orders.service.consul on port 8600 and returns an A record per passing instance (and SRV records with ports). Every program can use it without a library. Two catches. The answer is cached for its TTL (30 s here), and some resolvers and runtimes cache longer than told. And most clients simply connect to the first address and use the others only when it fails, so the load does not spread (Demo 5: DNS: TTL 30 s, the first record gets everything). Consul shuffles the order of the records on every answer to soften this, which a long cache undoes.

In Kubernetes

Kubernetes builds discovery in. A pod does not register itself: the kubelet runs its readiness probe, and the EndpointSlice controller keeps the list of ready pod IPs for each Service. kube-proxy (or an eBPF data plane) turns the Service's ClusterIP into rules that pick a pod, which is server-side discovery inside each node. CoreDNS maps orders.default.svc.cluster.local to that ClusterIP, and a headless Service returns the pod IPs themselves for client-side balancing. See Kubernetes Service types and Kubernetes networking; a service mesh moves the lookup into a sidecar proxy next to each caller.

What the page leaves out

The registry itself as a replicated store (Consul and etcd run Raft, see Raft and etcd), the local Consul agent on every node and its gossip, check timeouts, deregister_critical_service_after, TTL checks pushed by the service, SRV records and ports, tags and service metadata, zone-aware routing, Eureka's self-preservation mode, and the security around registration (ACL tokens, mTLS).

On the mind map

Open the Distributed Systems mind mapDistributedSystemsReplication: copies of data · what reads seeReplicationPartitioning: split data over nodesPartitioningTime and membership: order events, know who is aliveTime and membershipConsensus: agree on one value or logConsensusTransactions: one change across many servicesTransactionsMessaging: send events between systemsMessagingKafka internals: a replicated append-only logKafka internalsRedis internals: one thread, data in memoryRedis internalsKubernetes: reconcile desired stateKubernetesResilience and speed: stay up and fast under loadResilience and speedCluster architectureCluster architectureapiserver, etcd, scheduler, kubeletsPod lifecyclePod lifecycleschedule, pull, probes, terminateContainer runtimeContainer runtimekubelet to containerd over CRI gRPCLifecycle hooksLifecycle hookspreStop runs before SIGTERMControl plane and nodesControl plane and nodeshub and spoke via the API serverKubernetes schedulerKubernetes schedulerfilter nodes, score them, then bindHPA and Cluster AutoscalerHPA and Cluster Autoscalerceil(replicas x usage / target)Config and secretsConfig and secretsHelm, Argo CD, ESO feed Spring BootService discoveryRegister on start, look up by nameOpen the Distributed Systems mind mapDistributed Systemsreconcile desired stateKubernetesCluster architectureCluster architectureapiserver, etcd, scheduler, kubeletsPod lifecyclePod lifecycleschedule, pull, probes, terminateContainer runtimeContainer runtimekubelet to containerd over CRI gRPCLifecycle hooksLifecycle hookspreStop runs before SIGTERMControl plane and nodesControl plane and nodeshub and spoke via the API serverKubernetes schedulerKubernetes schedulerfilter nodes, score them, then bindHPA and Cluster AutoscalerHPA and Cluster Autoscalerceil(replicas x usage / target)Config and secretsConfig and secretsHelm, Argo CD, ESO feed Spring BootService discoveryRegister on start, look up by name
This page on the Distributed Systems mind map · 79 pages in 10 branches · open it to see the whole map and test yourself in recall mode.