Service Discovery: Eureka, Consul — or Neither
Why hardcoded hostnames fail at scale, how a registry works, Eureka's self-preservation mode, and why Kubernetes may already have solved this for you.
The Problem, Stated Honestly
Two services need to talk. The naive answer is configuration:
inventory:
base-url: https://inventory.internal:8080Which is fine — genuinely fine — for a long time. It breaks when instances are dynamic: autoscaling changes their count, deployments replace them, and a crashed pod comes back with a different IP. A fixed hostname cannot name a set that changes every few minutes.
Service discovery replaces the address with a name, resolved at call time to whatever instances currently exist.
Read This Before Adopting Eureka
The roadmap covers Eureka and Consul, and they're worth understanding. But the honest guidance first:
If you deploy on Kubernetes, you probably already have service discovery and don't need Eureka. A Kubernetes Service gives you a stable DNS name (inventory.default.svc.cluster.local) that load-balances across healthy pods, with readiness probes deciding membership. That's the same problem solved by the platform — and running Eureka alongside means two registries, two failure modes, and two sources of truth about which instances are healthy.
Eureka earns its place on VMs, on bare metal, in older cloud setups without platform-level discovery, and in existing systems already built around it. For a greenfield service on Kubernetes, inventory.internal as a Service name plus RestClient is simpler and has fewer moving parts.
That said, understanding registries is still worth it — the failure modes recur in any discovery mechanism, Kubernetes included.
Eureka
A dedicated server:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}server:
port: 8761
eureka:
client:
register-with-eureka: false # the server doesn't register with itself
fetch-registry: falseAnd each service as a client:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
}Spring Cloud dependencies need their own BOM. spring-boot-dependencies does not manage Spring Cloud versions, so without it Gradle cannot resolve a version and the build fails with "Could not find org.springframework.cloud:spring-cloud-starter-netflix-eureka-client". Add the release train once, in every service that uses Spring Cloud:
ext {
set('springCloudVersion', '2025.0.0')
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}Spring Cloud ships as a release train with its own calendar-style version, and each train supports specific Spring Boot versions — pairing them wrongly is the most common Spring Cloud setup failure. Check the compatibility matrix rather than taking the newest of each. Every dependency block in this phase assumes this BOM is present.
spring:
application:
name: inventory # the name others will use — required
eureka:
client:
service-url:
defaultZone: http://discovery:8761/eureka/
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 30
lease-expiration-duration-in-seconds: 90spring.application.name is the registered name. Get it wrong and callers can't find the service — and since it's also the name in your metrics and traces, it's worth setting carefully once.
The lifecycle: register on startup, heartbeat every 30 seconds, deregister on graceful shutdown. Miss heartbeats for 90 seconds and you're evicted.
Eviction is slow. With those defaults, an instance that dies ungracefully stays in the registry for up to 90 seconds — and clients cache the registry for another 30 by default, so a dead instance can receive traffic for two minutes.
This is why discovery is not sufficient on its own. Callers need connect timeouts, retries against a different instance, and a circuit breaker — the measures from the outbound HTTP guide and from guide 4 of this phase. Discovery tells you who should be there; resilience handles who actually isn't.
Self-preservation mode
Eureka's most misunderstood feature, and a genuine design decision worth understanding.
If Eureka stops receiving the expected heartbeat volume, it assumes the network is partitioned rather than that every service died and stops evicting instances. You'll see:
EMERGENCY! EUREKA MAY BE INCORRECTLY CLAIMING INSTANCES ARE UP WHEN THEY'RE NOT.
This is Eureka choosing availability over consistency. In a partition it would rather serve stale data — possibly including dead instances — than wrongly empty the registry and halt the whole system.
That is usually the right call for discovery: a stale registry entry plus a circuit breaker degrades gracefully; an empty registry stops everything. Disabling self-preservation is common in development, where few heartbeats make it trigger spuriously, and rarely right in production:
eureka:
server:
enable-self-preservation: false # development onlyConsul
HashiCorp Consul offers discovery plus active health checking and a KV store:
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-consul-discovery'
}spring:
cloud:
consul:
host: consul
port: 8500
discovery:
health-check-path: /actuator/health/readiness
health-check-interval: 10sThe important difference: Consul polls your health endpoint rather than waiting for heartbeats. Detection is faster and reflects whether the service is actually able to serve, not merely that its process is running — which is why pointing it at /actuator/health/readiness rather than plain /health matters, per the liveness/readiness distinction.
| Eureka | Consul | Kubernetes | |
|---|---|---|---|
| Model | Heartbeat (client push) | Health check (server pull) | Readiness probe |
| CAP preference | AP | CP (Raft) | CP (etcd) |
| Failure detection | 90s default | ~10–30s | ~10–30s |
| Extras | — | KV store, DNS, mesh | ConfigMaps, Services |
| Operate it yourself | Yes | Yes | Already there |
Consul's Raft consensus means it prefers consistency: a quorum loss makes it unavailable for writes. Eureka prefers availability. Neither is wrong — they're different answers to the partition question.
Using Discovered Services
@Bean
@LoadBalanced
RestClient.Builder loadBalancedRestClientBuilder() {
return RestClient.builder();
}@Service
public class InventoryService {
private final RestClient client;
public InventoryService(@LoadBalanced RestClient.Builder builder) {
this.client = builder.baseUrl("http://inventory").build(); // the service NAME
}
public StockLevel stockFor(String sku) {
return client.get().uri("/stock/{sku}", sku).retrieve().body(StockLevel.class);
}
}http://inventory is not a hostname — @LoadBalanced intercepts the call, resolves the name against the registry, and picks an instance. Guide 3 covers that selection.
Or query the registry directly, when you need the instance list:
@Service
public class InventoryTopology {
private final DiscoveryClient discovery;
public List<String> instanceUris() {
return discovery.getInstances("inventory").stream()
.map(i -> i.getUri().toString())
.toList();
}
}The registry becomes critical infrastructure. If nothing can resolve a name, nothing can call anything. So run it with at least three replicas and peer replication, and make sure clients cache the registry and keep working when it's unreachable. Eureka clients do cache by default — which is the right behaviour, and means a registry outage degrades rather than stops the system. Verify it, though: a client configured with a short cache and no fallback turns a registry blip into a total outage.
Check yourself
A service instance is killed with SIGKILL (no graceful shutdown). Eureka uses default lease settings and clients use default registry caching. For roughly how long can callers still send requests to the dead instance?
When You Don't Need Any of This
Worth stating directly, because Spring Cloud is often adopted before the problem exists:
- One service. Nothing to discover.
- A handful of services with stable addresses. Configuration is simpler and has fewer failure modes.
- On Kubernetes.
ServiceDNS already does this. - Behind a load balancer per service. A single stable name already fronts the instances.
Service discovery addresses dynamic instance sets without platform support. If that's not your situation, it's infrastructure you operate for no gain — and a registry you have to keep available.
The Mental Model, Restated
- Discovery replaces addresses with names, resolved at call time.
- On Kubernetes you likely already have it. Two registries is worse than one.
spring.application.nameis the registered name — also your metrics and trace identity.- Eureka is heartbeat-based with slow eviction — a dead instance can take ~2 minutes to disappear.
- Self-preservation chooses availability over consistency in a partition. Usually correct.
- Consul actively polls health, so point it at readiness, not liveness.
- Discovery needs resilience alongside it — timeouts, retries, circuit breakers.
- The registry is critical infrastructure. Replicate it; make clients cache.
What's Next
With services able to find each other, the next question is how traffic from outside enters the system. The next guide covers Spring Cloud Gateway: routing by predicate, filters for cross-cutting concerns, rate limiting with Redis, and which responsibilities belong at the edge rather than in every service.