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:
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:
# 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
# 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.
# 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.
@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");
}
# 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
# 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
@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:
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
@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
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
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)
}
// 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.