Article
Spring Cloud Kubernetes bridges the gap between Spring Cloud's familiar programming model and Kubernetes' powerful container orchestration capabilities. It allows Java developers to leverage Kubernetes native features like ConfigMaps, Secrets, and service discovery while using the familiar Spring Cloud abstractions. This article explores how to build, deploy, and manage Java applications using Spring Cloud Kubernetes in production environments.
What is Spring Cloud Kubernetes?
Spring Cloud Kubernetes provides Spring Cloud common interface implementations that consume Kubernetes native services. Key features include:
- Configuration Management: Access ConfigMaps and Secrets as Spring properties
- Service Discovery: Discover services using Kubernetes API instead of Eureka
- Load Balancing: Integrate with Kubernetes services for client-side load balancing
- Pod Metadata: Access pod information and lifecycle events
- Profile-based Configuration: Use Kubernetes namespaces as Spring profiles
Architecture Overview
Spring Boot Application ↓ Spring Cloud Kubernetes ↓ Kubernetes API Server ↓ Kubernetes Resources (ConfigMaps, Secrets, Services, Pods)
Setting Up Spring Cloud Kubernetes
1. Dependencies Setup
<properties>
<spring-cloud.version>2022.0.4</spring-cloud.version>
<spring-cloud-kubernetes.version>3.0.4</spring-cloud-kubernetes.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Spring Cloud Kubernetes -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-fabric8-all</artifactId>
<version>${spring-cloud-kubernetes.version}</version>
</dependency>
<!-- Configuration Support -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-fabric8-config</artifactId>
<version>${spring-cloud-kubernetes.version}</version>
</dependency>
<!-- Discovery Client -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-fabric8-discovery</artifactId>
<version>${spring-cloud-kubernetes.version}</version>
</dependency>
<!-- Load Balancer -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-kubernetes-fabric8-loadbalancer</artifactId>
<version>${spring-cloud-kubernetes.version}</version>
</dependency>
<!-- For reactive support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
2. Basic Configuration
application.yml:
spring:
application:
name: user-service
cloud:
kubernetes:
config:
name: ${spring.application.name}
namespace: default
sources:
- name: ${spring.application.name}
- name: common-config
enable-api: true
discovery:
all-namespaces: false
primary-port-name: http
reload:
enabled: true
mode: event
strategy: refresh
management:
endpoints:
web:
exposure:
include: health,info,metrics,configmaps
endpoint:
health:
show-details: always
configmaps:
enabled: true
server:
port: 8080
logging:
level:
org.springframework.cloud.kubernetes: DEBUG
Configuration Management with ConfigMaps and Secrets
1. Creating ConfigMaps
configmap.yaml:
apiVersion: v1 kind: ConfigMap metadata: name: user-service namespace: default data: application.yml: | app: database: url: jdbc:postgresql://postgresql:5432/users pool-size: 10 cache: enabled: true ttl: 3600 features: notification: true analytics: false logging: level: com.example.userservice: INFO server: servlet: context-path: /api/v1 --- apiVersion: v1 kind: ConfigMap metadata: name: common-config namespace: default data: application.yml: | spring: jackson: date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z' time-zone: UTC management: health: db: enabled: true diskspace: enabled: true
2. Creating Secrets
secret.yaml:
apiVersion: v1 kind: Secret metadata: name: user-service-secrets namespace: default type: Opaque data: database-password: cG9zdGdyZXNfcGFzc3dvcmQ= # base64 encoded api-key: YXBpX2tleV9zZWNyZXQ= # base64 encoded jwt-secret: amJ0X3NlY3JldF9rZXk= # base64 encoded
3. Java Configuration Classes
@Configuration
@ConfigurationProperties(prefix = "app")
@Data
public class AppConfig {
private DatabaseConfig database;
private CacheConfig cache;
private FeaturesConfig features;
@Data
public static class DatabaseConfig {
private String url;
private int poolSize;
}
@Data
public static class CacheConfig {
private boolean enabled;
private long ttl;
}
@Data
public static class FeaturesConfig {
private boolean notification;
private boolean analytics;
}
}
@Component
@Slf4j
public class SecretManager {
@Value("${database-password:}")
private String databasePassword;
@Value("${api-key:}")
private String apiKey;
@Value("${jwt-secret:}")
private String jwtSecret;
@PostConstruct
public void init() {
log.info("Secrets loaded successfully");
// Secrets are automatically injected from Kubernetes Secrets
}
}
Service Discovery and Load Balancing
1. Service Discovery Configuration
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
@Service
@Slf4j
public class OrderServiceClient {
private final WebClient webClient;
private final DiscoveryClient discoveryClient;
public OrderServiceClient(WebClient.Builder webClientBuilder,
DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
this.webClient = webClientBuilder.build();
}
// Method 1: Using service name directly with load balancer
public Mono<Order> getOrderById(String orderId) {
return webClient.get()
.uri("http://order-service/api/v1/orders/{orderId}", orderId)
.retrieve()
.bodyToMono(Order.class)
.doOnSuccess(order -> log.info("Retrieved order: {}", order.getId()))
.doOnError(error -> log.error("Failed to retrieve order: {}", orderId, error));
}
// Method 2: Manual service discovery
public List<ServiceInstance> discoverOrderServiceInstances() {
return discoveryClient.getInstances("order-service");
}
// Method 3: Direct service instance access
public Mono<String> getOrderServiceInfo() {
List<ServiceInstance> instances = discoveryClient.getInstances("order-service");
if (!instances.isEmpty()) {
ServiceInstance instance = instances.get(0);
String url = instance.getUri().toString();
return Mono.just("Order service available at: " + url);
}
return Mono.just("Order service not available");
}
}
2. Load Balanced REST Client
@Configuration
public class LoadBalancerConfig {
@Bean
@LoadBalanced
public WebClient.Builder loadBalancedWebClientBuilder() {
return WebClient.builder();
}
@Bean
@LoadBalanced
public RestTemplate loadBalancedRestTemplate() {
return new RestTemplate();
}
}
@RestController
@RequestMapping("/api/v1/users")
@Slf4j
public class UserController {
private final RestTemplate restTemplate;
private final OrderServiceClient orderServiceClient;
public UserController(@LoadBalanced RestTemplate restTemplate,
OrderServiceClient orderServiceClient) {
this.restTemplate = restTemplate;
this.orderServiceClient = orderServiceClient;
}
@GetMapping("/{userId}/orders")
public ResponseEntity<List<Order>> getUserOrders(@PathVariable String userId) {
log.info("Fetching orders for user: {}", userId);
// Using RestTemplate with service discovery
String url = "http://order-service/api/v1/orders?userId=" + userId;
Order[] orders = restTemplate.getForObject(url, Order[].class);
return ResponseEntity.ok(Arrays.asList(orders != null ? orders : new Order[0]));
}
@GetMapping("/{userId}/orders/reactive")
public Mono<ResponseEntity<List<Order>>> getUserOrdersReactive(@PathVariable String userId) {
return orderServiceClient.getOrderServiceInfo()
.doOnNext(info -> log.info("Service info: {}", info))
.thenMany(Flux.range(1, 3)
.flatMap(i -> orderServiceClient.getOrderById(userId + "-" + i))
.collectList())
.map(ResponseEntity::ok)
.onErrorReturn(ResponseEntity.internalServerError().build());
}
}
Configuration Reloading
1. Dynamic Configuration Updates
@Configuration
@Slf4j
public class DynamicConfigReload {
@Autowired
private ConfigReloadProperties reloadProperties;
@EventListener
public void onConfigUpdate(ConfigMapChangeEvent event) {
log.info("ConfigMap changed: {}", event.getConfigMapName());
log.info("Updated keys: {}", event.getUpdatedKeys());
// Perform application-specific reload logic
reloadConfiguration();
}
@EventListener
public void onSecretUpdate(SecretChangeEvent event) {
log.info("Secret changed: {}", event.getSecretName());
log.info("Updated keys: {}", event.getUpdatedKeys());
// Reload sensitive configuration
reloadSecrets();
}
private void reloadConfiguration() {
log.info("Reloading application configuration...");
// Add custom reload logic here
}
private void reloadSecrets() {
log.info("Reloading secrets...");
// Add secret reload logic here
}
}
@Component
@RefreshScope
public class RefreshableService {
@Value("${app.cache.enabled:false}")
private boolean cacheEnabled;
@Value("${app.cache.ttl:300}")
private long cacheTtl;
public void updateCacheConfiguration() {
log.info("Cache enabled: {}, TTL: {} seconds", cacheEnabled, cacheTtl);
}
}
Kubernetes Native Features
1. Pod Information and Metadata
@Component
@Slf4j
public class KubernetesMetadataService {
private final PodUtils podUtils;
private final KubernetesClient kubernetesClient;
public KubernetesMetadataService(PodUtils podUtils,
KubernetesClient kubernetesClient) {
this.podUtils = podUtils;
this.kubernetesClient = kubernetesClient;
}
public PodInfo getCurrentPodInfo() {
try {
Pod currentPod = podUtils.currentPod().get();
return PodInfo.builder()
.name(currentPod.getMetadata().getName())
.namespace(currentPod.getMetadata().getNamespace())
.ip(currentPod.getStatus().getPodIP())
.nodeName(currentPod.getSpec().getNodeName())
.startTime(currentPod.getStatus().getStartTime())
.build();
} catch (Exception e) {
log.warn("Could not retrieve current pod information", e);
return PodInfo.builder().build();
}
}
public List<PodInfo> getApplicationPods() {
String appName = System.getenv("APP_NAME");
if (appName == null) {
appName = "user-service";
}
return kubernetesClient.pods()
.inNamespace("default")
.withLabel("app", appName)
.list()
.getItems()
.stream()
.map(pod -> PodInfo.builder()
.name(pod.getMetadata().getName())
.namespace(pod.getMetadata().getNamespace())
.ip(pod.getStatus().getPodIP())
.phase(pod.getStatus().getPhase())
.build())
.collect(Collectors.toList());
}
}
@Data
@Builder
class PodInfo {
private String name;
private String namespace;
private String ip;
private String nodeName;
private String phase;
private String startTime;
}
2. Custom Health Indicators
@Component
@Slf4j
public class KubernetesHealthIndicator implements HealthIndicator {
private final KubernetesClient kubernetesClient;
private final DiscoveryClient discoveryClient;
public KubernetesHealthIndicator(KubernetesClient kubernetesClient,
DiscoveryClient discoveryClient) {
this.kubernetesClient = kubernetesClient;
this.discoveryClient = discoveryClient;
}
@Override
public Health health() {
try {
// Check Kubernetes API server connectivity
kubernetesClient.pods().inNamespace("default").list();
// Check service discovery
List<String> services = discoveryClient.getServices();
Map<String, Object> details = new HashMap<>();
details.put("kubernetesApi", "reachable");
details.put("discoveredServices", services.size());
details.put("timestamp", Instant.now().toString());
return Health.up()
.withDetails(details)
.build();
} catch (Exception e) {
log.error("Kubernetes health check failed", e);
return Health.down()
.withException(e)
.build();
}
}
}
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
// Try to execute a simple query
try (Statement statement = connection.createStatement()) {
statement.execute("SELECT 1");
}
return Health.up()
.withDetail("database", "connected")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("database", "disconnected")
.withException(e)
.build();
}
}
}
Advanced Features
1. Leader Election
@Component
@Slf4j
public class KubernetesLeaderElection {
private final KubernetesClient kubernetesClient;
private volatile boolean isLeader = false;
public KubernetesLeaderElection(KubernetesClient kubernetesClient) {
this.kubernetesClient = kubernetesClient;
}
@EventListener
public void onApplicationReady(ApplicationReadyEvent event) {
startLeaderElection();
}
private void startLeaderElection() {
String podName = System.getenv("HOSTNAME");
if (podName == null) {
podName = "unknown-pod";
}
LeaderElector leaderElector = kubernetesClient.leaderElector()
.withConfig(new LeaderElectionConfigBuilder()
.withName("user-service-leader")
.withLeaseDuration(Duration.ofSeconds(15))
.withRenewDeadline(Duration.ofSeconds(10))
.withRetryPeriod(Duration.ofSeconds(2))
.withLeaderElectorBuilder(new LeaderElectorBuilder()
.withConfig(new io.fabric8.kubernetes.client.extended.leaderelection.LeaderElectionConfigBuilder()
.withName("user-service-leader")
.withNamespace("default")
.withLeaseDuration(15000)
.withRenewDeadline(10000)
.withRetryPeriod(2000)
.withLeaderCallbacks(new LeaderCallbacks(
() -> onNewLeader(podName),
() -> onStartedLeading(),
() -> onStoppedLeading()
))
.build()))
.build());
// Start leader election in background
CompletableFuture.runAsync(leaderElector::run);
}
private void onNewLeader(String leaderId) {
log.info("New leader elected: {}", leaderId);
}
private void onStartedLeading() {
isLeader = true;
log.info("This pod is now the leader");
startLeaderTasks();
}
private void onStoppedLeading() {
isLeader = false;
log.info("This pod is no longer the leader");
stopLeaderTasks();
}
private void startLeaderTasks() {
// Start tasks that should only run on the leader
log.info("Starting leader-specific tasks...");
}
private void stopLeaderTasks() {
// Stop leader-specific tasks
log.info("Stopping leader-specific tasks...");
}
public boolean isLeader() {
return isLeader;
}
}
2. Custom Resource Definitions (CRD) Support
@Group("example.com")
@Version("v1")
public class ApplicationConfig extends CustomResource {
private ApplicationConfigSpec spec;
public ApplicationConfigSpec getSpec() {
return spec;
}
public void setSpec(ApplicationConfigSpec spec) {
this.spec = spec;
}
public static class ApplicationConfigSpec {
private String environment;
private int replicas;
private Map<String, String> config;
// getters and setters
public String getEnvironment() { return environment; }
public void setEnvironment(String environment) { this.environment = environment; }
public int getReplicas() { return replicas; }
public void setReplicas(int replicas) { this.replicas = replicas; }
public Map<String, String> getConfig() { return config; }
public void setConfig(Map<String, String> config) { this.config = config; }
}
}
@Component
@Slf4j
public class ApplicationConfigWatcher {
private final KubernetesClient kubernetesClient;
public ApplicationConfigWatcher(KubernetesClient kubernetesClient) {
this.kubernetesClient = kubernetesClient;
}
@EventListener
public void onApplicationReady(ApplicationReadyEvent event) {
watchApplicationConfigs();
}
private void watchApplicationConfigs() {
kubernetesClient.resources(ApplicationConfig.class)
.inNamespace("default")
.watch(new Watcher<ApplicationConfig>() {
@Override
public void eventReceived(Action action, ApplicationConfig resource) {
log.info("ApplicationConfig {}: {}", action, resource.getMetadata().getName());
handleApplicationConfigChange(action, resource);
}
@Override
public void onClose(WatcherException cause) {
log.warn("ApplicationConfig watcher closed", cause);
// Implement reconnection logic
}
});
}
private void handleApplicationConfigChange(Watcher.Action action, ApplicationConfig config) {
switch (action) {
case ADDED, MODIFIED -> applyApplicationConfig(config);
case DELETED -> removeApplicationConfig(config);
}
}
private void applyApplicationConfig(ApplicationConfig config) {
log.info("Applying ApplicationConfig: {}", config.getMetadata().getName());
// Apply configuration changes to the application
}
private void removeApplicationConfig(ApplicationConfig config) {
log.info("Removing ApplicationConfig: {}", config.getMetadata().getName());
// Handle configuration removal
}
}
Deployment Manifests
1. Deployment Configuration
deployment.yaml:
apiVersion: apps/v1 kind: Deployment metadata: name: user-service namespace: default labels: app: user-service version: v1 spec: replicas: 3 selector: matchLabels: app: user-service template: metadata: labels: app: user-service version: v1 annotations: prometheus.io/scrape: "true" prometheus.io/port: "8080" prometheus.io/path: "/actuator/prometheus" spec: serviceAccountName: user-service-account containers: - name: user-service image: myregistry/user-service:1.0.0 ports: - name: http containerPort: 8080 protocol: TCP env: - name: SPRING_PROFILES_ACTIVE value: "kubernetes" - name: APP_NAME value: "user-service" - name: JAVA_OPTS value: "-Xmx512m -Xms256m" livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 60 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 30 periodSeconds: 5 resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "1Gi" cpu: "500m" --- apiVersion: v1 kind: Service metadata: name: user-service namespace: default labels: app: user-service spec: ports: - name: http port: 8080 targetPort: 8080 selector: app: user-service type: ClusterIP
2. RBAC Configuration
rbac.yaml:
apiVersion: v1 kind: ServiceAccount metadata: name: user-service-account namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: user-service-role rules: - apiGroups: [""] resources: ["pods", "services", "configmaps", "secrets"] verbs: ["get", "list", "watch"] - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list"] - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch", "create", "update", "patch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: user-service-role-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: user-service-role subjects: - kind: ServiceAccount name: user-service-account namespace: default
Best Practices
1. Configuration Management
- Use ConfigMaps for non-sensitive configuration
- Use Secrets for sensitive data (with proper encryption)
- Implement configuration reloading for dynamic updates
- Use namespaces for environment isolation
2. Service Discovery
- Leverage Kubernetes native service discovery
- Implement proper health checks and readiness probes
- Use circuit breakers for inter-service communication
3. Security
- Implement proper RBAC rules
- Use service accounts with minimal permissions
- Secure API server communication
- Regularly rotate secrets
Conclusion
Spring Cloud Kubernetes provides a powerful bridge between Spring Boot applications and Kubernetes-native features. By leveraging this integration, Java developers can:
- Simplify configuration management using ConfigMaps and Secrets
- Implement robust service discovery without additional service registry
- Enable dynamic configuration updates with zero downtime
- Access Kubernetes metadata for application logic
- Implement leader election for distributed coordination
- Monitor application health with Kubernetes-native probes
This combination allows Java applications to fully embrace cloud-native principles while maintaining the developer productivity and ecosystem benefits of the Spring framework. The integration provides a production-ready foundation for building scalable, resilient, and maintainable microservices on Kubernetes.