Distributedservice discoveryservice registryclient-side discoveryserver-side discoveryhealth check

Service Discovery

When instances are created and destroyed by autoscalers, schedulers and rolling deploys, a caller cannot be configured with addresses; it asks a registry that instances join with heartbeats and leave when they stop — and the registry’s staleness window, its own availability, and who does the lookup (client, load balancer or sidecar) are the design.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

Static addresses break the moment infrastructure becomes dynamic — an autoscaler adds instances nobody configured, a deploy replaces every IP, a container reschedules to another host — and a caller with a stale list sends traffic to machines that no longer exist.

Why static configuration stops working

With three fixed servers, orders.internal = [10.0.1.4, 10.0.1.5, 10.0.1.6] in a config file is fine, and it stays fine for years. It breaks on the day the fleet becomes dynamic. An autoscaler adds two instances at 9 a.m. that no config mentions, so they receive no traffic and the scale-out achieves nothing. A rolling deploy replaces every instance with a new one at a new address, so the old list is 100% wrong for the duration of the deploy. A container scheduler moves a crashed instance to another host with a different IP and port. In each case the truth about "where is the order service" changes many times a day, and something must publish that truth to whoever needs it. That something is the service registry: a small, highly available database of service → [instance address, port, health, metadata], kept current by the instances themselves or by the platform that runs them.

Register, look up, call
register + heartbeatregister + heartbeatderegisterlookup "orders" (cached)callcallService AOrder Service #3 (draining)Order Service #1Order Service #2Service Registry (Consul / etcd / K8s API)
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Client-side vs server-side discovery

In client-side discovery, the caller queries the registry, caches the instance list, picks one (round robin, least loaded, ring-hash for affinity as in Consistent Hashing) and calls it directly. One hop, full control over balancing, and the client library must be written and kept current in every language the fleet uses. In server-side discovery, the caller sends to a stable name — a load balancer, a Kubernetes Service virtual IP, a gateway — and *that* component queries the registry and forwards. The client is trivial and language-agnostic; the cost is an extra hop and a component that must itself be highly available. Most platforms today are server-side by default (Kubernetes Service + kube-proxy, cloud load balancers with target groups) with client-side used where affinity or latency matters.

Who does the lookup
AspectClient-side discoveryServer-side discoveryService mesh sidecar
Lookup performed byThe calling service, via a libraryA load balancer or virtual IPA local proxy next to each instance
Extra network hopNoneOne (through the LB)One local (loopback), negligible
Balancing controlFull: affinity, locality, custom weightsWhat the LB offersFull, configured centrally, applied uniformly
Language/library burdenA client library per languageNoneNone — the proxy is the library
Failure isolationRegistry down → clients use cached listLB down → everything down (make it redundant)Control plane down → sidecars keep last config
Typical implementationEureka/Ribbon, Consul + custom client, gRPC resolversKubernetes Service, AWS ALB target groups, HAProxyIstio/Linkerd with Envoy; xDS pushes endpoints

Registration, health, and the TTL that decides everything

Instances get into the registry by self-registration (the process registers on start and deregisters on shutdown) or by third-party registration, where the orchestrator that created the instance registers it — Kubernetes populates Endpoints from pod readiness, so the application never talks to the registry at all. Self-registration is simpler to reason about and fails when the process dies without running its shutdown hook — kill -9, OOM, a host that loses power. Hence health: either the registry actively probes each instance (an HTTP GET /health every few seconds, with a threshold of consecutive failures) or each instance sends a heartbeat that renews a TTL, and an entry whose TTL expires is removed.

The TTL is the number that decides how long dead instances receive traffic. With a heartbeat every 10 s and a TTL of 30 s, a crashed instance is served to callers for up to 30 s. With a TTL of 5 minutes — the setting in the challenge traffic-to-dead-instances — an autoscaler that terminates a third of the fleet leaves a third of all calls failing for five minutes, on every scale-in, every day. Shorter TTLs mean more heartbeat load on the registry and more false removals under GC pauses or network blips; the usual compromise is 10–30 s, plus passive health: the caller marks an instance bad after a connection error and skips it, without waiting for the registry at all. Deregistration on graceful shutdown should come first: deregister, wait for in-flight requests to drain, then exit — the rolling-deploy path in Stateless vs Stateful Services.

A registry entry: what the caller actually receives
service: orders
  - id: orders-7f3a   addr: 10.0.3.17:8080   status: passing   ttl: 30s (last beat 4s ago)   meta: {version: 2.14, zone: eu-west-1a}
  - id: orders-9c21   addr: 10.0.3.42:8080   status: passing   ttl: 30s (last beat 2s ago)   meta: {version: 2.14, zone: eu-west-1b}
  - id: orders-b0e5   addr: 10.0.3.58:8080   status: critical  ttl: 30s (last beat 41s ago)   ◄── expired, being removed
  - id: orders-d114   addr: 10.0.3.61:8080   status: draining  (deregistering, 3 in-flight)

DNS-based discovery and the service mesh

DNS is the oldest registry, and often enough. A name resolves to the current set of healthy addresses (A records, or SRV records that carry ports too); Kubernetes headless services and Consul both serve discovery this way. The trap is caching: resolvers, JVMs and HTTP client libraries cache DNS answers, sometimes ignoring the TTL, so a caller can hold a dead address for minutes. Set short TTLs (5–30 s), make the client library re-resolve, and treat a DNS answer as a hint that passive health checking must confirm. DNS also cannot express weights, zones or draining state, which is where a proper registry API earns its place.

A service mesh moves the whole problem out of the application. Every instance gets a sidecar proxy (Envoy) on loopback; the application calls localhost, and the sidecar — fed by a control plane over xDS with the live endpoint list — does discovery, balancing, retries, timeouts, circuit breaking, mTLS and telemetry uniformly for every language. The cost is a proxy per instance (memory, a millisecond of latency), a control plane to run, and a large new thing to understand when something breaks. It pays off for large polyglot fleets that would otherwise reimplement Reliability Patterns in five languages; it is overkill for ten services in one language behind a load balancer.

How discovery fails

The registry is a dependency on the critical path of every call, which means it must be more available than anything that uses it. Consul, etcd and ZooKeeper achieve this with a Raft/ZAB quorum of 3 or 5 nodes and by being tiny; callers achieve it by caching the last known good list and continuing to use it when the registry is unreachable — a registry outage should degrade to "no new instances are discovered", never to "no calls succeed". The second failure is staleness in the other direction: the registry says an instance is healthy because its heartbeat arrived, but the instance is deadlocked, out of connections, or has a full disk; a heartbeat proves the heartbeat thread is alive, not that the service works. Health checks should exercise the real path (a lightweight query, not return 200), and passive checks at the caller catch what the registry misses. The third failure is the thundering herd on the registry: 2,000 instances re-resolving every second is 2,000 requests/s that a three-node quorum was not sized for; use watches/long-polling or push (xDS) so callers are notified of changes rather than polling for them.

Key points

  • Dynamic fleets — autoscaling, rolling deploys, rescheduling — make static addresses wrong many times a day; a registry publishes the current truth.
  • Client-side discovery: one hop, full balancing control, a library per language. Server-side: trivial clients, an extra hop, a component that must be redundant. Sidecars: both, at the cost of a proxy per instance.
  • Registration by the instance or the orchestrator; liveness by active probes or heartbeat TTLs. The TTL is the window during which dead instances receive traffic.
  • A registry outage must degrade to "no new instances", never to "no calls": cache the last known good list and check health passively at the caller.
  • A heartbeat proves the heartbeat thread is alive; health checks should exercise the real path, and callers should skip instances that fail.

Service discovery in a dynamic fleet

Service discovery in a dynamic fleet
Instances of B register and heartbeat; A finds them through the registry. Kill instances, autoscale, and watch traffic hit entries that are dead but not yet expired.
Discovery
lookup BcallheartbeatService AService B ×nRegistry
B-1 aliveB-2 aliveB-3 alive
registered
3
alive
3
stale entries
0
failed calls
0 / 0
t+0B-1, B-2, B-3 registered
Every registered entry is alive. Client-side discovery: A holds the registry client and the load-balancing logic; one fewer hop, one more thing every language runtime has to implement. The ratio TTL : heartbeat is the tradeoff — short TTL means fast eviction but more heartbeat traffic and false expiries under GC pauses. And the registry is itself a dependency: cache the last known list so a registry outage degrades to stale routing instead of no routing.
t = 0 s

How data moves through it

One request or event, hop by hop.

  1. 1Order Service #1 → Registry: on start, PUT /service/orders/orders-7f3a {addr, port, meta}; then a heartbeat every 10 s renewing a 30 s TTL.
  2. 2Service A → Registry: on first call, GET /service/orders?passing=true; the list is cached locally and refreshed via a watch.
  3. 3Service A → Order Service #2: the call, balanced across the cached healthy list; a connection error marks #2 bad locally for 30 s.
  4. 4Order Service #3 → Registry: on SIGTERM, deregisters, drains in-flight requests for up to 20 s, exits; callers stop selecting it within one refresh.
  5. 5Registry → Service A: a watch notification pushes the updated endpoint set; no polling storm.

When to use — and when not

Use it when
  • Any fleet where instances are created or destroyed automatically — autoscaling groups, containers, serverless-adjacent platforms.
  • Rolling deploys that replace instances rather than updating them in place.
  • Many services calling each other directly (client-side) or through a mesh, where addresses must be resolved at runtime.
Avoid it when
  • A handful of fixed servers behind one load balancer: the LB’s target list plus health checks is discovery enough, and a registry adds a quorum to run.
  • A monolith calling a managed database: use the provider’s stable endpoint; discovery is their problem.
  • Adopting a service mesh to solve discovery for five services in one language — a client library or Kubernetes Services is a tenth of the operational weight.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Discovery is eventually consistent by nature — the registry lags reality by up to one TTL — and everything downstream must tolerate that lag with passive health checks and retries to a different instance.

How it fails

  • Heartbeat TTL of 5 minutes: after every scale-in, a third of calls go to terminated instances for five minutes (the traffic-to-dead-instances challenge).
  • Registry unreachable and clients fail closed: no lookups succeed, so no calls succeed, even though every instance is healthy.
  • Health check returns 200 unconditionally: a deadlocked instance stays "passing" and receives its full share of traffic.
  • DNS answers cached beyond their TTL by a client library: the caller keeps a dead IP for minutes after the registry has moved on.
  • Instances killed without deregistering, plus a long TTL: the registry advertises ghosts until the TTL expires.

How it scales

  • Registry read load scales with callers × refresh rate; use watches or push (xDS, Consul blocking queries) so 10,000 callers cost a few connections rather than 10,000 polls per second.
  • Registry write load scales with instances × heartbeat rate; a 10 s heartbeat across 5,000 instances is 500 writes/s, fine for a Raft quorum, but 1 s heartbeats are not.
  • Partition the registry by datacenter or region and federate; cross-region lookups should be the exception, and locality-aware balancing should prefer the local zone.

How it interacts with databases, queues, caches, APIs and external systems

  • Load balancers: server-side discovery is a load balancer fed by the registry (or by the orchestrator); see Load Balancing.
  • API gateway: the gateway resolves upstream services through the registry rather than static upstream lists; see API Gateway.
  • Databases and caches: usually reached through stable managed endpoints, but Redis Cluster and Cassandra run their own discovery via gossip.
  • Message brokers: Kafka bootstrap servers are a form of discovery — the client contacts one address and learns the cluster’s live brokers from it; see Kafka-Style Logs: Topics, Partitions, Offsets.
  • Service mesh sidecars: consume the registry through the control plane and apply discovery, balancing and Circuit Breaker policy uniformly.