Service Discovery
Service Discovery, mikroservis mimarisinde servislerin birbirini dinamik olarak bulabilmesi için kritik bir bileşendir. Bu bölümde Spring Cloud Netflix Eureka ve alternatif çözümlerin detaylı implementasyonunu inceleyeceğiz.
Hızlı Karar
| Durum | Yaklaşım | Dikkat |
|---|---|---|
| Kubernetes içinde servisler | Kubernetes Service/DNS | Ayrı registry gerekmeyebilir |
| VM/bare-metal mikroservis | Consul/Eureka | Health check doğruluğu kritik |
| Multi-region discovery | Region-aware registry | Yanlış region latency artırır |
| Az sayıda statik servis | Statik config yeterli olabilir | YAGNI kontrolü yap |
Üretim Kontrol Listesi
- Problem: Servis adresleri gerçekten dinamik mi, yoksa sabit config yeterli mi?
- Çözüm: Registration, health check, deregistration, TTL ve client-side cache davranışı net mi?
- Trade-off: Discovery esneklik sağlar; stale endpoint ve registry bağımlılığı ekler.
- Hata durumu: Registry outage, stale DNS, unhealthy instance routing ve split-brain registry ele alınmalı.
- Ölçüm: Registration count, lookup latency, health check failure, stale route ve registry error rate izlenmeli.
- Güvenlik/maliyet: Registry sadece güvenilir servisleri kabul etmeli; yanlış discovery tüm trafiği etkiler.
Spring Cloud Eureka
Eureka Server Configuration
Server Setup:
@EnableEurekaServer
@SpringBootApplication
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}Application Properties (eureka-server):
server:
port: 8761
eureka:
instance:
hostname: localhost
prefer-ip-address: true
instance-id: ${spring.application.name}:${vcap.application.instance_id:${spring.application.instance_id:${random.value}}}
lease-renewal-interval-in-seconds: 30
lease-expiration-duration-in-seconds: 90
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
enable-self-preservation: true
eviction-interval-timer-in-ms: 60000
renewal-percent-threshold: 0.85
response-cache-auto-expiration-in-seconds: 180
response-cache-update-interval-ms: 30000
dashboard:
enabled: true
path: /
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
metrics:
export:
prometheus:
enabled: true
logging:
level:
com.netflix.eureka: DEBUG
com.netflix.discovery: DEBUGHigh Availability Eureka Cluster:
# eureka-server-1 (application-peer1.yml)
server:
port: 8761
eureka:
instance:
hostname: eureka-server-1
prefer-ip-address: false
client:
service-url:
defaultZone: http://eureka-server-2:8762/eureka/,http://eureka-server-3:8763/eureka/
---
# eureka-server-2 (application-peer2.yml)
server:
port: 8762
eureka:
instance:
hostname: eureka-server-2
prefer-ip-address: false
client:
service-url:
defaultZone: http://eureka-server-1:8761/eureka/,http://eureka-server-3:8763/eureka/
---
# eureka-server-3 (application-peer3.yml)
server:
port: 8763
eureka:
instance:
hostname: eureka-server-3
prefer-ip-address: false
client:
service-url:
defaultZone: http://eureka-server-1:8761/eureka/,http://eureka-server-2:8762/eureka/Eureka Client Configuration
Client Service Implementation:
@EnableEurekaClient
@EnableDiscoveryClient
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
@Bean
@LoadBalanced
public WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
}Client Application Properties:
spring:
application:
name: order-service
server:
port: 0 # Random port
eureka:
client:
service-url:
defaultZone: http://eureka-server-1:8761/eureka/,http://eureka-server-2:8762/eureka/,http://eureka-server-3:8763/eureka/
fetch-registry: true
register-with-eureka: true
registry-fetch-interval-seconds: 30
instance-info-replication-interval-seconds: 30
instance:
prefer-ip-address: true
instance-id: ${spring.application.name}:${random.value}
lease-renewal-interval-in-seconds: 30
lease-expiration-duration-in-seconds: 90
metadata-map:
version: ${app.version:1.0.0}
environment: ${spring.profiles.active:development}
management.context-path: ${management.server.servlet.context-path:/actuator}
management:
endpoints:
web:
exposure:
include: health,info,metrics,refresh
endpoint:
health:
show-details: always
health:
diskspace:
enabled: true
eureka:
enabled: true
# Custom health indicators
app:
health:
database:
enabled: true
external-service:
enabled: true
timeout: 5000Advanced Client Configuration:
@Configuration
public class EurekaClientConfig {
@Bean
public EurekaInstanceConfigBean eurekaInstanceConfig() {
EurekaInstanceConfigBean instance = new EurekaInstanceConfigBean();
instance.setPreferIpAddress(true);
instance.setLeaseRenewalIntervalInSeconds(30);
instance.setLeaseExpirationDurationInSeconds(90);
// Custom metadata
Map<String, String> metadata = new HashMap<>();
metadata.put("version", getClass().getPackage().getImplementationVersion());
metadata.put("startup-time", Instant.now().toString());
metadata.put("zone", System.getProperty("eureka.instance.availability-zone", "default"));
instance.setMetadataMap(metadata);
return instance;
}
@Bean
public DiscoveryClientOptionalArgs discoveryClientOptionalArgs() {
DiscoveryClientOptionalArgs args = new DiscoveryClientOptionalArgs();
args.setEventListeners(List.of(new CustomEurekaEventListener()));
return args;
}
@Component
public static class CustomEurekaEventListener implements EurekaEventListener {
@Override
public void onEvent(EurekaEvent event) {
if (event instanceof CacheRefreshedEvent) {
log.info("Eureka cache refreshed");
} else if (event instanceof StatusChangeEvent) {
StatusChangeEvent statusEvent = (StatusChangeEvent) event;
log.info("Instance status changed: {} -> {}",
statusEvent.getPreviousStatus(), statusEvent.getStatus());
}
}
}
}Load Balancing with Spring Cloud LoadBalancer
Load Balancer Configuration:
@Configuration
public class LoadBalancerConfig {
@Bean
@LoadBalanced
public WebClient webClient() {
return WebClient.builder()
.filter(loadBalancerExchangeFilterFunction())
.build();
}
@Bean
public ReactorLoadBalancerExchangeFilterFunction loadBalancerExchangeFilterFunction() {
return new ReactorLoadBalancerExchangeFilterFunction(loadBalancerClientFactory());
}
@Bean
public LoadBalancerClientFactory loadBalancerClientFactory() {
LoadBalancerClientFactory factory = new LoadBalancerClientFactory();
factory.setConfigurations(loadBalancerConfigurations());
return factory;
}
private List<LoadBalancerClientSpecification> loadBalancerConfigurations() {
return List.of(
new LoadBalancerClientSpecification("user-service",
new Class[]{UserServiceLoadBalancerConfig.class}),
new LoadBalancerClientSpecification("payment-service",
new Class[]{PaymentServiceLoadBalancerConfig.class})
);
}
}Custom Load Balancing Strategy:
@Configuration
public class UserServiceLoadBalancerConfig {
@Bean
public ReactorServiceInstanceLoadBalancer reactorServiceInstanceLoadBalancer(
Environment environment,
LoadBalancerClientFactory loadBalancerClientFactory) {
String name = environment.getProperty(LoadBalancerClientFactory.PROPERTY_NAME);
return new WeightedResponseTimeLoadBalancer(
loadBalancerClientFactory.getLazyProvider(name, ServiceInstanceListSupplier.class),
name);
}
}
@Slf4j
public class WeightedResponseTimeLoadBalancer implements ReactorServiceInstanceLoadBalancer {
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
private final String serviceId;
private final Map<String, Double> responseTimeWeights = new ConcurrentHashMap<>();
@Override
public Mono<Response<ServiceInstance>> choose(Request request) {
ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider
.getIfAvailable(NoopServiceInstanceListSupplier::new);
return supplier.get(request)
.next()
.map(serviceInstances -> processInstanceResponse(serviceInstances, request));
}
private Response<ServiceInstance> processInstanceResponse(
List<ServiceInstance> instances, Request request) {
if (instances.isEmpty()) {
log.warn("No servers available for service: {}", serviceId);
return new EmptyResponse();
}
ServiceInstance selectedInstance = selectInstanceByResponseTime(instances);
return new DefaultResponse(selectedInstance);
}
private ServiceInstance selectInstanceByResponseTime(List<ServiceInstance> instances) {
if (responseTimeWeights.isEmpty()) {
// First request - use round robin
return instances.get(ThreadLocalRandom.current().nextInt(instances.size()));
}
// Calculate weighted probabilities based on response times
double totalWeight = instances.stream()
.mapToDouble(instance -> {
String instanceId = instance.getInstanceId();
Double weight = responseTimeWeights.get(instanceId);
return weight != null ? 1.0 / weight : 1.0; // Lower response time = higher weight
})
.sum();
double random = ThreadLocalRandom.current().nextDouble() * totalWeight;
double currentWeight = 0;
for (ServiceInstance instance : instances) {
String instanceId = instance.getInstanceId();
Double responseTime = responseTimeWeights.get(instanceId);
currentWeight += responseTime != null ? 1.0 / responseTime : 1.0;
if (random <= currentWeight) {
return instance;
}
}
return instances.get(0); // Fallback
}
public void updateResponseTime(String instanceId, long responseTimeMs) {
responseTimeWeights.put(instanceId, (double) responseTimeMs);
}
}Alternative Service Discovery Solutions
Consul Integration
Consul Configuration:
@Configuration
@EnableDiscoveryClient
public class ConsulConfig {
@Bean
public ConsulProperties consulProperties() {
ConsulProperties properties = new ConsulProperties();
properties.setHost("consul-server");
properties.setPort(8500);
properties.setEnabled(true);
return properties;
}
@Bean
public ConsulDiscoveryProperties consulDiscoveryProperties() {
ConsulDiscoveryProperties properties = new ConsulDiscoveryProperties();
properties.setHealthCheckInterval("10s");
properties.setHealthCheckTimeout("5s");
properties.setHealthCheckCriticalTimeout("30s");
properties.setInstanceId("${spring.application.name}-${random.value}");
properties.setPreferIpAddress(true);
properties.setTags(Arrays.asList("version=1.0", "environment=prod"));
return properties;
}
}Consul Application Properties:
spring:
application:
name: order-service
cloud:
consul:
host: consul-server
port: 8500
discovery:
enabled: true
register: true
service-name: ${spring.application.name}
instance-id: ${spring.application.name}-${random.value}
health-check-interval: 10s
health-check-timeout: 5s
health-check-critical-timeout: 30s
prefer-ip-address: true
ip-address: ${spring.cloud.client.ip-address}
port: ${server.port}
tags:
- version=1.0.0
- environment=${spring.profiles.active:development}
metadata:
management.context-path: /actuator
health.path: /actuator/health
config:
enabled: true
prefix: config
default-context: application
profile-separator: ","
format: YAML
data-key: data
watch:
enabled: true
delay: 1000Kubernetes Native Discovery
Kubernetes Service Configuration:
apiVersion: v1
kind: Service
metadata:
name: order-service
labels:
app: order-service
version: v1
spec:
selector:
app: order-service
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
labels:
app: order-service
version: v1
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
version: v1
spec:
containers:
- name: order-service
image: order-service:1.0.0
ports:
- containerPort: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: "kubernetes"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 5
periodSeconds: 5Spring Boot Kubernetes Configuration:
@Configuration
@Profile("kubernetes")
public class KubernetesDiscoveryConfig {
@Bean
@ConditionalOnMissingBean
public KubernetesDiscoveryProperties kubernetesDiscoveryProperties() {
KubernetesDiscoveryProperties properties = new KubernetesDiscoveryProperties();
properties.setEnabled(true);
properties.setAllNamespaces(false);
properties.setWaitCacheReady(true);
properties.setCacheLoadingTimeoutSeconds(60);
return properties;
}
@Bean
public KubernetesClient kubernetesClient() {
return new DefaultKubernetesClient();
}
}Application Properties for Kubernetes:
spring:
application:
name: order-service
cloud:
kubernetes:
discovery:
enabled: true
all-namespaces: false
wait-cache-ready: true
cache-loading-timeout-seconds: 60
service-labels:
environment: ${ENVIRONMENT:development}
metadata:
add-labels: true
add-annotations: true
labels-prefix: k8s
annotations-prefix: k8s
management:
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: trueAdvanced Service Discovery Patterns
Client-Side Discovery with Custom Registry
@Component
@Slf4j
public class CustomServiceRegistry {
private final Map<String, List<ServiceInstance>> serviceRegistry = new ConcurrentHashMap<>();
private final Map<String, ServiceHealth> serviceHealthMap = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(5);
@PostConstruct
public void initializeHealthChecks() {
scheduler.scheduleAtFixedRate(this::performHealthChecks, 10, 30, TimeUnit.SECONDS);
}
public void registerService(String serviceName, ServiceInstance instance) {
serviceRegistry.computeIfAbsent(serviceName, k -> new CopyOnWriteArrayList<>())
.add(instance);
log.info("Registered service instance: {} - {}", serviceName, instance.getInstanceId());
}
public void deregisterService(String serviceName, String instanceId) {
List<ServiceInstance> instances = serviceRegistry.get(serviceName);
if (instances != null) {
instances.removeIf(instance -> instance.getInstanceId().equals(instanceId));
log.info("Deregistered service instance: {} - {}", serviceName, instanceId);
}
}
public List<ServiceInstance> getHealthyInstances(String serviceName) {
List<ServiceInstance> instances = serviceRegistry.get(serviceName);
if (instances == null) {
return Collections.emptyList();
}
return instances.stream()
.filter(this::isInstanceHealthy)
.collect(Collectors.toList());
}
private boolean isInstanceHealthy(ServiceInstance instance) {
ServiceHealth health = serviceHealthMap.get(instance.getInstanceId());
return health != null && health.isHealthy();
}
private void performHealthChecks() {
serviceRegistry.values().stream()
.flatMap(List::stream)
.forEach(this::checkInstanceHealth);
}
private void checkInstanceHealth(ServiceInstance instance) {
CompletableFuture.supplyAsync(() -> {
try {
String healthUrl = "http://" + instance.getHost() + ":" +
instance.getPort() + "/actuator/health";
ResponseEntity<Map> response = restTemplate.getForEntity(healthUrl, Map.class);
boolean healthy = response.getStatusCode().is2xxSuccessful() &&
"UP".equals(response.getBody().get("status"));
serviceHealthMap.put(instance.getInstanceId(),
new ServiceHealth(healthy, Instant.now()));
return healthy;
} catch (Exception e) {
log.warn("Health check failed for instance: {}", instance.getInstanceId(), e);
serviceHealthMap.put(instance.getInstanceId(),
new ServiceHealth(false, Instant.now()));
return false;
}
}).exceptionally(throwable -> {
log.error("Health check error for instance: {}", instance.getInstanceId(), throwable);
return false;
});
}
@Data
@AllArgsConstructor
public static class ServiceHealth {
private boolean healthy;
private Instant lastChecked;
}
}Service Mesh Integration with Istio
Istio VirtualService Configuration:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: order-service
spec:
hosts:
- order-service
http:
- match:
- headers:
version:
exact: v2
route:
- destination:
host: order-service
subset: v2
- route:
- destination:
host: order-service
subset: v1
weight: 90
- destination:
host: order-service
subset: v2
weight: 10
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: order-service
spec:
host: order-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
http1MaxPendingRequests: 50
maxRequestsPerConnection: 10
circuitBreaker:
consecutiveErrors: 5
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
subsets:
- name: v1
labels:
version: v1
- name: v2
labels:
version: v2Spring Boot Istio Integration:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/{orderId}")
public ResponseEntity<OrderDto> getOrder(@PathVariable Long orderId,
HttpServletRequest request) {
// Istio injects tracing headers
String traceId = request.getHeader("x-trace-id");
String spanId = request.getHeader("x-span-id");
MDC.put("traceId", traceId);
MDC.put("spanId", spanId);
try {
OrderDto order = orderService.findById(orderId);
return ResponseEntity.ok(order);
} finally {
MDC.clear();
}
}
@PostMapping
public ResponseEntity<OrderDto> createOrder(@RequestBody CreateOrderRequest request,
HttpServletRequest httpRequest) {
// Extract user information from Istio JWT
String userInfo = httpRequest.getHeader("x-user-info");
if (userInfo != null) {
// Process JWT claims injected by Istio
UserInfo user = parseUserInfo(userInfo);
request.setUserId(user.getId());
}
OrderDto createdOrder = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdOrder);
}
}Service Discovery Events and Monitoring
@Component
@Slf4j
public class ServiceDiscoveryEventHandler {
@EventListener
public void handleInstanceRegistered(InstanceRegisteredEvent event) {
log.info("Service instance registered: {} - {}",
event.getInstanceInfo().getAppName(),
event.getInstanceInfo().getInstanceId());
// Update load balancer weights
updateLoadBalancerWeights(event.getInstanceInfo());
// Send notification
notificationService.notifyInstanceRegistered(event.getInstanceInfo());
}
@EventListener
public void handleInstanceCanceled(InstanceCanceledEvent event) {
log.warn("Service instance canceled: {} - {}",
event.getAppName(), event.getServerId());
// Remove from load balancer
removeFromLoadBalancer(event.getAppName(), event.getServerId());
// Send alert
alertingService.notifyInstanceDown(event.getAppName(), event.getServerId());
}
@EventListener
public void handleServiceDown(ServiceDownEvent event) {
log.error("Service down: {}", event.getServiceName());
// Trigger failover procedures
failoverService.triggerFailover(event.getServiceName());
// Send critical alert
alertingService.sendCriticalAlert("Service Down",
"Service " + event.getServiceName() + " is down");
}
private void updateLoadBalancerWeights(InstanceInfo instanceInfo) {
String serviceName = instanceInfo.getAppName();
String instanceId = instanceInfo.getInstanceId();
// Extract custom metadata for weight calculation
Map<String, String> metadata = instanceInfo.getMetadata();
int weight = Integer.parseInt(metadata.getOrDefault("weight", "100"));
loadBalancerService.updateInstanceWeight(serviceName, instanceId, weight);
}
}Bu kapsamlı Service Discovery implementasyonu, Spring Cloud Eureka'dan başlayarak modern alternatiflere kadar tüm çözümleri production-ready örneklerle sunmaktadır. Her yaklaşımın güçlü yanları, kullanım senaryoları ve best practices detaylı olarak ele alınmıştır.
