Service Discovery & Configuration

DNS-based and registry-based discovery, centralized configuration, and liveness vs readiness health checks.

The problem: instances move

In a monolith, you hardcode localhost, or at worst one known database host. In a microservices system, instances are created and destroyed constantly — autoscaling adds and removes them, rolling deploys replace them one at a time, crashes restart them — and each one can come back with a different IP address. Service discovery answers a question that has to be resolved at runtime, not at deploy time: what address do I use to reach the Inventory service right now?

DNS-based discovery (Kubernetes-native)

If you're running on Kubernetes, this problem is mostly already solved for you. A Kubernetes Service object gets one stable DNS name that stays constant no matter how many pods back it or how often they're replaced:

YAML
apiVersion: v1
kind: Service
metadata:
  name: inventory-service
spec:
  selector:
    app: inventory
  ports:
    - port: 80
      targetPort: 8080

Any other pod in the cluster can call http://inventory-service (or the fully-qualified http://inventory-service.default.svc.cluster.local from another namespace). Kubernetes' internal DNS (CoreDNS) resolves that name to a stable virtual IP, and kube-proxy transparently load-balances requests across whichever pods are currently healthy. No client-side discovery library, no registry to run — the platform does it for you.

Client-side / registry-based discovery

Outside Kubernetes — or when a caller needs to see and choose among individual instances itself, rather than going through one virtual IP — services register themselves in a registry, and callers query the registry before calling.

A Consul example:

Bash
# Each service instance registers itself on startup, with a health check Consul will poll
curl -X PUT --data '{
  "ID": "inventory-service-1",
  "Name": "inventory-service",
  "Address": "10.0.4.12",
  "Port": 8080,
  "Check": { "HTTP": "http://10.0.4.12:8080/health", "Interval": "10s" }
}' http://consul:8500/v1/agent/service/register
Bash
# A caller asks Consul for only the currently-healthy instances before picking one
curl http://consul:8500/v1/health/service/inventory-service?passing=true

Netflix Eureka (long a Spring Cloud staple) works the same way conceptually — self-registration plus a client-side load balancer picking among the returned instances — just with its own registration/heartbeat API instead of Consul's.

Centralized configuration

As the number of services grows, config drift — each service with its own hardcoded value, or worse, subtly mismatched ones across environments — turns into a real operational hazard. A config server centralizes environment-specific values so services fetch them at startup instead of baking them into the deployed image.

YAML
# inventory-service's application.yml — pulls its config instead of hardcoding it
spring:
  application:
    name: inventory-service
  config:
    import: "configserver:http://config-server:8888"

The config server itself typically serves per-environment files (inventory-service-dev.yml, inventory-service-prod.yml) backed by a Git repository, so configuration changes are version-controlled and auditable exactly like code. Consul's key-value store, or Kubernetes ConfigMap/Secret objects, solve the same problem without a dedicated config service.

Health checks

Discovery only helps if unhealthy instances actually get excluded from it. Two different checks matter, and confusing them is a very common mistake:

  • Liveness — is the process still running and not deadlocked? Failing this gets the instance restarted.
  • Readiness — is this instance ready to accept traffic right now (DB connection established, cache warmed)? Failing this just removes it from the routing pool — no restart.
Java
@GetMapping("/health/live")
public ResponseEntity<String> liveness() {
    return ResponseEntity.ok("OK"); // the process is up; don't check dependencies here
}

@GetMapping("/health/ready")
public ResponseEntity<String> readiness() {
    if (!database.isConnected()) {
        return ResponseEntity.status(503).body("DB unavailable");
    }
    return ResponseEntity.ok("OK");
}
YAML
# Kubernetes pod spec, wired to the two endpoints above
livenessProbe:
  httpGet: { path: /health/live, port: 8080 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /health/ready, port: 8080 }
  periodSeconds: 5

Implementation in Spring Boot, ASP.NET Core and Go

A complete example of the registry-based pattern described above: a service registering itself on startup, and a caller discovering and calling it, in each stack.

Spring Boot — Netflix Eureka

YAML
# inventory-service's application.yml
spring:
  application:
    name: inventory-service
eureka:
  client:
    service-url:
      defaultZone: http://eureka-server:8761/eureka
  instance:
    prefer-ip-address: true
Java
@SpringBootApplication
@EnableDiscoveryClient
public class InventoryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(InventoryServiceApplication.class, args);
    }
}

@EnableDiscoveryClient is what makes inventory-service register itself with Eureka on startup and send periodic heartbeats — no manual registration call required. A caller can either query DiscoveryClient directly for the current instances, or use an @LoadBalanced RestTemplate, which resolves a logical service name to a healthy instance automatically:

Java
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
    return new RestTemplate();
}
Java
@Autowired
private RestTemplate restTemplate; // @LoadBalanced — "inventory-service" below is resolved via Eureka, not a hardcoded host

public InventoryResponse reserveStock(String orderId, List<Item> items) {
    return restTemplate.postForObject(
        "http://inventory-service/api/reservations",
        new ReservationRequest(orderId, items),
        InventoryResponse.class);
}

ASP.NET Core — registering with Consul

C#
builder.Services.AddSingleton<IConsulClient, ConsulClient>(_ =>
    new ConsulClient(config => config.Address = new Uri("http://consul:8500")));

var app = builder.Build();

var consulClient = app.Services.GetRequiredService<IConsulClient>();
var registration = new AgentServiceRegistration
{
    ID = $"orders-service-{Environment.MachineName}",
    Name = "orders-service",
    Address = "10.0.4.20",
    Port = 8080,
    Check = new AgentServiceCheck
    {
        HTTP = "http://10.0.4.20:8080/health",
        Interval = TimeSpan.FromSeconds(10),
        Timeout = TimeSpan.FromSeconds(5),
    }
};

app.Lifetime.ApplicationStarted.Register(() => consulClient.Agent.ServiceRegister(registration));
app.Lifetime.ApplicationStopping.Register(() => consulClient.Agent.ServiceDeregister(registration.ID));

app.MapGet("/health", () => Results.Ok("healthy"));

app.Run();

Registering on ApplicationStarted and explicitly deregistering on ApplicationStopping matters just as much as the registration itself — without the deregistration hook, Consul keeps routing traffic to an instance that's already mid-shutdown until its health check finally fails, causing exactly the "traffic sent to a dying instance" problem the Common mistakes section below calls out.

Go — registering with and querying Consul

Go
config := consulapi.DefaultConfig()
config.Address = "consul:8500"

client, err := consulapi.NewClient(config)
if err != nil {
    log.Fatal(err)
}

registration := &consulapi.AgentServiceRegistration{
    ID:      "inventory-service-1",
    Name:    "inventory-service",
    Address: "10.0.4.12",
    Port:    8080,
    Check: &consulapi.AgentServiceCheck{
        HTTP:     "http://10.0.4.12:8080/health",
        Interval: "10s",
        Timeout:  "5s",
    },
}

if err := client.Agent().ServiceRegister(registration); err != nil {
    log.Fatal(err)
}
Go
// A caller queries Consul for only the currently-passing (healthy) instances before picking one
services, _, err := client.Health().Service("inventory-service", "", true, nil)
if err != nil {
    log.Fatal(err)
}

for _, entry := range services {
    fmt.Printf("healthy instance: %s:%d\n", entry.Service.Address, entry.Service.Port)
}

client.Health().Service("inventory-service", "", true, nil) is the Go equivalent of the ?passing=true query shown earlier — the third argument (true) filters the result down to only instances currently passing their health check, so a caller never has to filter out unhealthy instances itself.

Common mistakes

  • Using the same endpoint for liveness and readiness. A slow downstream dependency then gets a perfectly healthy process killed and restarted, instead of just being quietly pulled from traffic until the dependency recovers.
  • Hardcoding a downstream service's host, port, or config value instead of resolving it through discovery/config — it works in local dev and breaks the moment the deployment topology changes.
  • No deregistration on graceful shutdown — a registry (or a stale DNS/client-side cache) keeps routing traffic to an instance that's already mid-shutdown, causing a burst of failed requests during every deploy.