Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Service Discovery & Registration

Client-side versus server-side discovery, running Eureka properly including self-preservation, Consul as an alternative, and why Kubernetes usually makes a separate registry unnecessary.

Intermediate5 min readUpdated
On this page

Service discovery answers one question: given a logical service name, which network addresses are currently healthy? Everything else — load balancing, failover, rolling deploys — depends on that answer being fresh.

Key Takeaways

  • Client-side discovery: the caller queries a registry and balances itself. Server-side: a load balancer does it.
  • Eureka is AP, not CP — it prefers returning slightly stale data to returning nothing.
  • Self-preservation stops mass eviction during a network partition. Leave it on in production.
  • On Kubernetes, a Service plus DNS replaces the registry entirely.
  • Whichever you use, the caller still needs timeouts and a circuit breaker.

The two models

Client-side removes a hop and gives the caller control. Server-side centralises the logic where the platform owns it.

Client-side is what Spring Cloud offers natively. The trade-off is that every service needs a discovery client, which is fine in a homogeneous Java estate and becomes a burden the moment a Python or Go service joins.

Registration itself comes in two flavours worth distinguishing, because they fail differently. Self-registration has the instance announce itself and send heartbeats, which is what Eureka does — simple, and it couples every service to the registry's client library and its availability at startup. Third-party registration has the platform register instances on the service's behalf, which is how Kubernetes works: the kubelet reports readiness and the endpoint list updates without the application knowing a registry exists. The second scales better across languages precisely because the application contributes nothing but a health endpoint.

Eureka

EurekaServerApplication.java
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}
eureka-server application.yml
server:
  port: 8761
eureka:
  instance:
    hostname: eureka-1
  client:
    # A server should not register with itself, but SHOULD replicate to peers.
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: 'http://eureka-2:8761/eureka/,http://eureka-3:8761/eureka/'
  server:
    # Stops mass eviction when the network, not the instances, is the problem.
    enable-self-preservation: true
    renewal-percent-threshold: 0.85
    eviction-interval-timer-in-ms: 15000
client application.yml
spring:
  application:
    name: payment-service      # this becomes the discovery name
eureka:
  client:
    service-url:
      defaultZone: 'http://eureka-1:8761/eureka/'
    registry-fetch-interval-seconds: 10
  instance:
    prefer-ip-address: true    # essential in containers, where hostnames rarely resolve
    lease-renewal-interval-in-seconds: 10
    lease-expiration-duration-in-seconds: 30
    # Point Eureka at the readiness endpoint so it reflects real health.
    health-check-url-path: /actuator/health/readiness

Then call by name and let spring-cloud-loadbalancer resolve it:

DiscoveryAwareClient.java
@Bean
@LoadBalanced
RestClient.Builder loadBalancedRestClient() {
    return RestClient.builder();
}
 
@Service
public class PaymentClient {
    private final RestClient client;
 
    public PaymentClient(@LoadBalanced RestClient.Builder builder) {
        // The hostname is the registered application name, not a real host.
        this.client = builder.baseUrl("http://payment-service").build();
    }
 
    public Receipt charge(ChargeRequest request) {
        return client.post().uri("/v2/charges").body(request).retrieve().body(Receipt.class);
    }
}

Eventual consistency, and what it costs

Eureka chooses availability over consistency, which has practical consequences worth planning for. Registration takes up to 30 seconds to propagate through server caches and client fetch intervals, so a freshly started instance receives no traffic for a while. Worse, a stopped instance stays in the registry for a similar window, so callers will try dead addresses.

There is no configuration that eliminates this — it is inherent to a heartbeat-and-cache design. The mitigation is on the caller: short connection timeouts, a retry that tries the next instance, and a circuit breaker so a persistently dead instance stops being selected. Discovery tells you where a service probably is; resilience handles the case where it is not.

Deregister explicitly on shutdown so the window is as short as possible. Spring does this automatically on a graceful shutdown, which is another reason SIGTERM handling matters.

Consul

Consul adds richer health checking — HTTP, TCP, script and TTL checks — plus a key/value store and multi-datacenter support. It is CP rather than AP, so it will refuse to answer rather than answer staleley during a partition.

application.yml
spring:
  cloud:
    consul:
      host: consul.internal
      port: 8500
      discovery:
        prefer-ip-address: true
        health-check-path: /actuator/health/readiness
        health-check-interval: 10s
        # Consul removes an instance failing checks for this long.
        health-check-critical-timeout: 60s
        tags:
          - 'version=${app.version}'
          - 'zone=${app.zone}'

Tags are genuinely useful: they let a caller filter to a version or an availability zone, which is how you do canary routing or zone-aware balancing without a service mesh.

Kubernetes

On Kubernetes, discovery is already solved. A Service gives a stable DNS name and a virtual IP; kube-proxy load balances across the pod endpoints, and readiness probes control membership.

service.yaml
apiVersion: v1
kind: Service
metadata:
  name: payment-service
spec:
  selector: { app: payment-service }
  ports: [{ port: 80, targetPort: 8080 }]
PlainDnsClient.java
// No discovery client, no registry, no annotations. Just DNS.
RestClient.builder().baseUrl("http://payment-service.default.svc.cluster.local").build();

Running Eureka inside Kubernetes gives you two registries that can disagree, two sets of health semantics, and a failure mode where Kubernetes has removed a pod while Eureka still advertises it. Unless you are mid-migration, use the platform's discovery.

spring-cloud-kubernetes is worth knowing about for the middle ground: it implements the Spring DiscoveryClient interface backed by the Kubernetes API, so existing @LoadBalanced code keeps working while the source of truth becomes the cluster.

Zone awareness

In a multi-zone deployment, cross-zone traffic costs money and adds latency. Both Eureka and Kubernetes support preferring same-zone instances — Eureka through prefer-same-zone-eureka, Kubernetes through topology-aware routing. Enable it once you span zones, and make sure the fallback to other zones still works, or a single-zone outage becomes a total one.

What to take away

Use the platform's discovery when you have one — on Kubernetes that means Services and DNS. Reach for Eureka or Consul when you do not. Whichever you choose, remember the registry's view is always slightly stale, and put the real reliability in timeouts, retries against the next instance, and a circuit breaker.

Frequently Asked Questions

Do I still need Eureka on Kubernetes?
Almost never. Kubernetes Services plus cluster DNS already provide discovery and load balancing, with health checking driven by readiness probes. Running Eureka on top adds a second registry that can disagree with the first. Use plain DNS names and let Kubernetes route.
What is Eureka self-preservation and should I disable it?
When Eureka receives fewer heartbeats than expected it stops expiring instances, assuming a network problem rather than mass instance death. That is right in production and infuriating in development, where it keeps dead instances registered for a long time. Disable it locally, leave it on in production.
Client-side or server-side discovery?
Client-side gives the caller full control over load balancing and removes a network hop, at the cost of a discovery client in every service and every language. Server-side puts the logic in one place the platform owns, which is why Kubernetes and cloud load balancers use it.

Related tutorials