Skip to content
Back to all concepts
MicroservicesInfrastructureNetworking

Service Discovery

A guide to microservice discovery in dynamic cloud environments, covering client-side vs server-side patterns, health checks, DNS-based discovery, and illustrative integrations using Consul, etcd, Eureka, and Kubernetes services.

By ScaleWikiUpdated 3 min readIntermediate

The Dynamic Infrastructure Problem

Old World (2005):
Web Server: 192.168.1.10 (hardcoded)
Database: 192.168.1.20 (never changes)config.yml: DB_HOST=192.168.1.20 ✓

New World (2025 - Kubernetes):
Containers start/stop every minute
IP addresses change constantly
Autoscaling adds/removes instances
config.yml: DB_HOST=??? ❌

Solution: Service Discovery - A dynamic phonebook for your infrastructure.


What is Service Discovery?

Service Discovery enables services to find and communicate with each other without hardcoded IPs, adapting automatically to infrastructure changes.

The Pattern

1. Service Registration:
   Service A starts → "I'm alive at 10.0.0.5:8080"

2. Health Checking:
   Registry pings Service A every 10s → Still alive?

3. Service Lookup:
   Service B needs Service A → Query registry

4. Connection:
   Service B connects to 10.0.0.5:8080

Example boundaries

The integrations below are excerpts requiring the named libraries, a configured registry and application-specific handlers. They omit production authentication/TLS and complete error handling. The direct etcd example is for a separate teaching registry: applications must not write arbitrary service keys into a Kubernetes control-plane etcd database. Use Kubernetes Services and the API instead.

Two Patterns

1. Client-Side Discovery

Client queries registry and chooses instance.

Implementation (Netflix Eureka):

java
// Service A - Registration
@SpringBootApplication
@EnableEurekaClient
public class ServiceA {
    public static void main(String[] args) {
        SpringApplication.run(ServiceA.class, args);
    }
}

// application.yml
eureka:
  client:
    serviceUrl:
      defaultZone: http://eureka:8761/eureka/
  instance:
    preferIpAddress: true
    leaseRenewalIntervalInSeconds: 10  # Heartbeat

// Service B - Discovery
@RestController
public class ServiceB {
    @Autowired
    private DiscoveryClient discoveryClient;
    
    @Autowired
    private RestTemplate restTemplate;
    
    @GetMapping("/call-service-a")
    public String callServiceA() {
        // 1. Get instances from registry
        List<ServiceInstance> instances = 
            discoveryClient.getInstances("SERVICE-A");
        
        if (instances.isEmpty()) {
            return "No instances available";
        }
        
        // 2. Client-side load balancing
        ServiceInstance instance = instances.get(
            new Random().nextInt(instances.size())
        );
        
        // 3. Make request
        String url = String.format("http://%s:%d/api/data",
            instance.getHost(), instance.getPort());
            
        return restTemplate.getForObject(url, String.class);
    }
}

2. Server-Side Discovery

Load balancer queries registry and forwards traffic.

Implementation (Kubernetes):

yaml
# Service A - Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: service-a
spec:
  replicas: 3
  selector:
    matchLabels:
      app: service-a
  template:
    metadata:
      labels:
        app: service-a
    spec:
      containers:
      - name: service-a
        image: service-a:1.0
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

---
# Service A - Service (Load Balancer)
apiVersion: v1
kind: Service
metadata:
  name: service-a
spec:
  selector:
    app: service-a
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: ClusterIP

---
# Service B - Calls Service A
# Just use DNS: http://service-a/api/data
# Kubernetes DNS resolves to service VIP
# Kube-proxy load balances to pods

Health Checks

Problem: Instance is registered but crashed/unhealthy.

Solution: Continuous health monitoring.

Types of Health Checks

1. HTTP Health Check

javascript
// Express.js
const express = require('express');
const app = express();

// Liveness: Is process alive?
app.get('/health/alive', (req, res) => {
  res.status(200).send('OK');
});

// Readiness: Can it handle traffic?
app.get('/health/ready', async (req, res) => {
  try {
    // Check dependencies
    await db.ping();
    await redis.ping();
    
    res.status(200).send('Ready');
  } catch (error) {
    res.status(503).send('Not Ready');
  }
});

app.listen(8080);

2. TCP Health Check

python
# Consul config
{
  "service": {
    "name": "web",
    "port": 8080,
    "check": {
      "tcp": "localhost:8080",
      "interval": "10s",
      "timeout": "1s"
    }
  }
}

3. TTL Health Check

python
import consul
import time

c = consul.Consul()

# Register service
c.agent.service.register(
    'my-service',
    service_id='my-service-1',
    port=8080,
    check=consul.Check.ttl('15s')  # Must check in every 15s
)

# Heartbeat loop
while True:
    try:
        # Pass health check
        c.agent.check.ttl_pass('service:my-service-1')
        time.sleep(10)
    except Exception:
        c.agent.check.ttl_fail('service:my-service-1')

Service Registry Implementations

1. Consul (HashiCorp)

python
import consul
import requests

# Register service
c = consul.Consul(host='consul', port=8500)

c.agent.service.register(
    'user-service',
    service_id='user-service-1',
    address='10.0.0.5',
    port=8080,
    tags=['v1', 'production'],
    check=consul.Check.http('http://10.0.0.5:8080/health', interval='10s')
)

# Discover service
def get_service_address(service_name):
    _, services = c.health.service(service_name, passing=True)
    
    if not services:
        raise Exception(f"No healthy instances of {service_name}")
    
    # Client-side load balancing
    import random
    service = random.choice(services)
    
    return f"http://{service['Service']['Address']}:{service['Service']['Port']}"

# Use
url = get_service_address('user-service')
response = requests.get(f"{url}/api/users", timeout=5)
response.raise_for_status()

2. etcd (Kubernetes Backend)

go
package main

import (
    "context"
    "fmt"
    "go.etcd.io/etcd/client/v3"
    "time"
)

func registerService() {
    cli, _ := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    defer cli.Close()

    // Register with TTL lease
    lease, _ := cli.Grant(context.TODO(), 10)
    
    _, _ = cli.Put(context.TODO(),
        "/services/user-service/10.0.0.5:8080",
        `{"address":"10.0.0.5","port":8080}`,
        clientv3.WithLease(lease.ID))
    
    // Keep-alive
    ch, _ := cli.KeepAlive(context.TODO(), lease.ID)
    
    for range ch {
        // Renewed
    }
}

func discoverService(serviceName string) []string {
    cli, _ := clientv3.New(clientv3.Config{
        Endpoints:   []string{"localhost:2379"},
        DialTimeout: 5 * time.Second,
    })
    defer cli.Close()

    resp, _ := cli.Get(context.TODO(),
        fmt.Sprintf("/services/%s/", serviceName),
        clientv3.WithPrefix())
    
    var endpoints []string
    for _, kv := range resp.Kvs {
        // Parse JSON value to get address
        endpoints = append(endpoints, string(kv.Value))
    }
    
    return endpoints
}

3. Eureka (Netflix OSS)

javascript
// Node.js Eureka client
const Eureka = require('eureka-js-client').Eureka;

const client = new Eureka({
  instance: {
    app: 'user-service',
    hostName: 'localhost',
    ipAddr: '10.0.0.5',
    port: {
      '$': 8080,
      '@enabled': true,
    },
    vipAddress: 'user-service',
    dataCenterInfo: {
      '@class': 'com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo',
      name: 'MyOwn',
    },
  },
  eureka: {
    host: 'eureka',
    port: 8761,
    servicePath: '/eureka/apps/',
  },
});

// Register
client.start();

// Discover
function getServiceInstances(appName) {
  const instances = client.getInstancesByAppId(appName);
  return instances.map(i => ({
    host: i.ipAddr,
    port: i.port.$
  }));
}

DNS-Based Discovery

Simplest approach: Use DNS SRV records.

# DNS SRV record
_http._tcp.user-service.local. 86400 IN SRV 0 5 8080 instance1.local.
_http._tcp.user-service.local. 86400 IN SRV 0 5 8080 instance2.local.
_http._tcp.user-service.local. 86400 IN SRV 0 5 8080 instance3.local.

Format: priority weight port host

Implementation:

python
import dns.resolver

def discover_service_dns(service_name):
    """Discover service via DNS SRV"""
    try:
        answers = dns.resolver.resolve(f'_http._tcp.{service_name}', 'SRV')
        
        instances = []
        for rdata in answers:
            instances.append({
                'host': str(rdata.target).rstrip('.'),
                'port': rdata.port,
                'priority': rdata.priority,
                'weight': rdata.weight
            })
        
        # Return only the lowest-priority group. SRV weight is probabilistic,
        # not a descending-sort preference.
        if not instances:
            return []
        priority = min(item['priority'] for item in instances)
        return [item for item in instances if item['priority'] == priority and item['host'] != '']
        
    except dns.resolver.NXDOMAIN:
        return []

# Usage
instances = discover_service_dns('user-service.local')
if instances:
    import random
    weights = [item['weight'] for item in instances]
    instance = random.choices(instances, weights=weights if any(weights) else None, k=1)[0]
    url = f"http://{instance['host']}:{instance['port']}"

Real-World Patterns

1. Historical Eureka and Ribbon pattern

Architecture:
- Eureka: Service registry
- Ribbon: Client-side load balancer
- Hystrix: Circuit breaker

Flow:
1. Services register with Eureka
2. Ribbon queries Eureka for service list
3. Ribbon balances requests across instances
4. Hystrix wraps calls for fault tolerance

2. Kubernetes (DNS + kube-proxy)

Architecture:
- CoreDNS: DNS-based service discovery
- kube-proxy: iptables load balancing
- Service: Stable VIP for pod set

Flow:
1. Pods labeled (app=user-service)
2. Service selects pods by label
3. DNS resolves service name to VIP
4. kube-proxy routes VIP to pod IPs

3. AWS (ELB + Route 53)

Architecture:
- ELB: Load balancer (server-side discovery)
- Route 53: DNS service
- Auto Scaling: Dynamic instance management

Flow:
1. Instances register with ELB
2. Route 53 points to ELB DNS
3. ELB health checks instances
4. Clients call ELB, never instances directly

Comparison

PatternProsConsBest For
Client-SideControl over selection and retriesComplex client, language-specificMicroservices mesh
Server-SideSimple client, language-agnosticNeeds load-balancer capacity and HAPolyglot systems
DNSUniversal, simpleCache staleness; health integration depends on providerLegacy integration

Interview Tips 💡

When discussing service discovery in interviews:

  1. Problem: "In cloud, IPs change constantly - can't hardcode addresses..."
  2. Two patterns: "Client-side (client picks instance) vs server-side (LB picks)..."
  3. Health checks: "Registry must know which instances are healthy..."
  4. Tools: "Consul for service mesh, etcd for Kubernetes, Eureka for Netflix stack..."
  5. K8s example: "Service creates stable VIP, DNS resolves to it, kube-proxy routes to pods..."
  6. Trade-offs: "Client-side gives control but complex; server-side simple but LB bottleneck..."

Worked failure: an instance dies after discovery

A registry returns three healthy endpoints. One fails before the client opens a connection. No discovery mechanism can remove that race, so callers still need connection deadlines, bounded retries and operation-level idempotency. Keep the last usable endpoint set during a transient registry outage if the consistency requirements allow it; discarding it immediately can turn a control-plane problem into a full data-plane outage.

A useful test terminates an instance during traffic and measures failed requests until recovery. Separate registry detection time, client cache time and existing connection lifetime. A low DNS TTL only changes one of those intervals. Kubernetes Service discovery and DNS SRV selection rules, RFC 2782 define different parts of this behavior.