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.
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.
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-side | server-side | DNS | |
|---|---|---|---|
| caller needs | registry client library | one address | a resolver (every OS has one) |
| hops per request | 1 | 2 | 1 |
| who balances | the caller | the load balancer | mostly nobody: first record |
| how fresh | cache or watch | watch: at once | TTL, plus caches that ignore it |
| examples | Eureka + Ribbon, gRPC xDS | AWS ALB, Kubernetes Service | Consul 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).