System designDeep dive9 min

Service discovery & registration

In a fleet that scales, deploys and fails all day, no address stays valid for long. Service discovery keeps a live list of which instances of each service exist and are healthy, and gets that list to every caller fast enough to matter. The hard parts are the moments when the list is wrong.

1 · The problem

Instance addresses change faster than anyone could configure them

An online chess platform runs a game service, a matchmaking service and a ratings service on Kubernetes. Matchmaking calls ratings for every pairing. At 18:00 the evening peak arrives and the autoscaler grows ratings from 4 instances to 20; at 01:00 it shrinks back. In between, a deploy replaces every instance once, and a failing node moves three of them elsewhere. Every one of those events gives instances new IP addresses:

Event

What happens to the list of ratings instances

Scale out at 18:00

16 new addresses appear within minutes

Rolling deploy

Every address is replaced, a few at a time

Node failure

Three addresses vanish and three new ones appear elsewhere

Scale in at 01:00

16 addresses disappear, some mid-request

A config file listing ratings' addresses would be wrong within the hour. Service discovery replaces it with two mechanisms:

  • Registration: keeping a live record of which instances of each service exist, where, and whether they are healthy. The record is the service registry.
  • Discovery: getting that record, or a request routed by it, to every caller.

The caller asks for "ratings", a stable name, and never stores an address for long.

2 · Registration

A registry is a phone book that removes the dead on its own

There are two ways for instances to get into the registry:

  • Self-registration: the instance registers itself at startup, renews its entry with periodic heartbeats, and deregisters at shutdown. Netflix's Eureka works this way.
  • Third-party registration: the platform registers instances for them. Kubernetes watches which pods match a service and are ready, and keeps that list in EndpointSlices; the application does nothing.

With Eureka's defaults, a crashed instance stays listed until 90 seconds pass without a renewal, and clients' cached copies can lag up to about 2 minutes more. Callers must survive calling an instance that is already gone.

Registration alone isn't enough, because a registered process can be alive and useless. Good registries also run health checks and list only instances that pass them: Consul returns only healthy instances, and Kubernetes includes only pods whose readiness check passes (Health checks & failover). Third-party registration has one more advantage: a frozen process can't deregister itself, but the platform can remove it.

The registry is now something every call depends on, so it is replicated. Consul and etcd, which backs Kubernetes, keep their data consistent across several servers using consensus (Consensus & leader election). Eureka leans the other way, toward staying available: its clients cache the registry, and its servers would rather keep stale entries than wipe them (more below).

3 · Discovery

Either the caller picks an instance, or something in the path does

Once the registry knows the instances, a request must reach one of them. In client-side discovery, the caller fetches the list and load-balances itself:

Matchmakinglibrary picks oneRegistryratings: 20 addressesRatings #9fetch list, cache itcall directlyMatchmakinglibrary picks oneRegistryratings: 20 addressesRatings #9fetch list, cache itcall directly

No extra hop and full control of the balancing, but every service, in every language, needs a discovery library that does it correctly.

In server-side discovery, the caller sends the request to a stable address and something in between picks the instance. That can be a load balancer, a virtual IP handled on every machine, or a proxy running next to each service:

Mechanism

The caller uses

Who picks the instance

Watch out for

Client library

A registry API (Eureka, Consul)

The caller's own code

One library per language, kept up to date

Load balancer

One fixed address per service

A central balancer

An extra hop; the balancer must be redundant

Kubernetes Service

A DNS name that resolves to a virtual IP

kube-proxy on each node, per connection

Balances connections, not requests (below)

DNS with instance records

A name that returns instance addresses

The caller, from the DNS answer

Caching and record limits (below)

Service mesh sidecar

localhost; a proxy beside each service

The proxy, fed by a control plane

One more component in every request path

A service mesh puts a proxy such as Envoy next to every service. A control plane pushes each proxy the current endpoints of every service it calls (Envoy's Endpoint Discovery Service), so the application only ever talks to localhost. It gets client-side discovery's direct calls and per-request balancing without a library in each language, at the cost of running the mesh.

4 · DNS

DNS is the universal discovery interface, and caching is its catch

Every language can resolve a name, so most systems expose discovery through DNS. In Kubernetes, the ratings service is reachable as ratings.chess.svc.cluster.local; Consul answers names like ratings.service.consul with only healthy instances. SRV records add the port to the address, for services that don't run on a fixed one.

Two kinds of answer behave very differently:

  • A normal Kubernetes Service resolves to one stable virtual IP. The name never changes its answer; the list behind it changes, and kube-proxy on each machine follows it.
  • A headless Service (no virtual IP) and Consul DNS resolve to the instance addresses themselves, so the answer changes as instances come and go.

Aspect

Normal Kubernetes Service

Headless Service, Consul DNS

The name resolves to

One stable virtual IP

The instance addresses

When instances change

The answer stays; kube-proxy follows the list

The answer changes

A client caching the answer

Harmless

Keeps calling old addresses until the cache expires

The second kind inherits every DNS caching problem. A client that caches an answer keeps calling the old addresses until the cache expires, and some runtimes cache until the process restarts (DNS resolution). DNS answers are also limited in size and say nothing about load, zones or which instance is new. That is why meshes push endpoint lists over their own protocol instead of relying on DNS.

5 · The trap

Long-lived connections hide new instances from discovery

Matchmaking calls ratings over gRPC, which sends all requests over one long-lived HTTP/2 connection per destination. Through a normal Kubernetes Service, kube-proxy chooses a pod once per connection. At 18:00 ratings scales from 4 to 8 pods. Matchmaking's connections were opened at 17:00, so this is how the load looks after the scale-out:

Each of pods 1–4 (connected)
2,000 requests/s
Each of pods 5–8 (new)
0 requests/s
Each pod if balanced per request
1,000 requests/s

8,000 requests a second spread over the four pods that already had connections: 2,000 each, while the four new pods idle. Spread per request over all 8 it would be 1,000 each. Illustrative numbers for the effect the Kubernetes project documents for gRPC.

Discovery worked: the new pods are registered and ready. Balancing per connection just never consults the new list, and in the worst case, a single caller with a single connection, one pod gets everything. The fixes move balancing to the request:

  • Client-side balancing across instance addresses from a headless Service, with the client opening connections to each.
  • A layer 7 proxy or service mesh that balances each request across the current endpoints.
  • Limiting connection age, such as gRPC's MAX_CONNECTION_AGE, so clients reconnect periodically and land on the new pods. It is a partial fix: balance improves only as connections are renewed.

6 · In practice

The list is always a little wrong, so design every call for it

Every discovery system lags reality. In Eureka, changes can take up to about 2 minutes to reach all clients; DNS answers live until their TTL expires; health checks need several failures before acting. A caller will sometimes pick an instance that has just died, and a new instance will sometimes wait to receive traffic. Make that harmless:

  • Short timeouts and a retry on a different instance for idempotent calls (Timeouts, retries, backoff & jitter).
  • Passive ejection of instances that keep failing, on the caller's side, without waiting for the registry (Health checks & failover).
  • Keep the last known list when the registry is unreachable, rather than failing every call.

Eureka takes the last point furthest. When renewals suddenly drop below about 85% of what it expects, it assumes a network problem rather than a mass die-off, enters self-preservation and stops evicting anyone. A stale list is a smaller problem than an empty one.

Instances leaving is the easiest case to get right, because it is planned. A graceful shutdown in the right order means no caller ever picks a dying instance:

  1. Leave the list first

    Fail the readiness check or deregister, so the registry, proxies and callers stop sending new requests. Kubernetes marks the endpoint as terminating.

  2. Wait for the news to spread

    Keep serving for a few seconds while every proxy and client cache catches up.

  3. Finish in-flight work

    Complete the requests already accepted, then close connections so clients reconnect elsewhere.

  4. Exit

    Only now stop the process. Exiting first turns every in-flight and in-transit request into an error.

Check yourself

4 questions

1. A service instance freezes: the process is alive but answers nothing. With self-registration, what keeps it from receiving traffic?
2. What is the main drawback of client-side discovery compared with a load balancer or mesh?
3. gRPC clients call a Kubernetes Service. After it scales from 4 to 8 pods, the new pods get no traffic. Why?
4. An instance is being shut down. What should it do first?

Takeaways

Remember this

  • Service discovery keeps a live registry of healthy instances and gets callers to them by a stable name, never a stored address.
  • Instances register themselves with heartbeats, or the platform registers them; health checks decide who stays listed.
  • Callers pick instances themselves (client-side) or send requests to something that picks for them: a load balancer, a virtual IP or a mesh proxy.
  • DNS is the universal interface but caches stale answers; long-lived connections can hide new instances unless balancing happens per request.
  • The list is always slightly stale: use timeouts, retries elsewhere and passive ejection, and shut down by leaving the list first and exiting last.