In my previous article, Optimizing Spring Boot dynamic configuration using Kubernetes ConfigMap (part 1), I discussed a ConfigMap integration approach different from the one suggested by the Spring Cloud team, focusing on the Kubernetes implementation. Now, let’s dive into the Spring Boot side and explore how seamlessly we can integrate ConfigMap key-value pairs into a Spring Boot application.
Defining Properties
We'll start by defining the necessary properties in application.properties to integrate with the ConfigMap. This allows the service to use default values in case Kubernetes does not provide them:
kubernetes.config.map.external-api-enabled=true
kubernetes.config.map.internal-api-enabled=true
Next, we’ll configure a reactive WebClient that fetches the ConfigMap via the Kubernetes API:
kubernetes.config.map.client.uri=https://<Kubernetes host>:443/api/v1/namespaces/production/configmaps/${spring.application.name}
kubernetes.config.map.client.token=${token}
kubernetes.config.map.client.timeout=500
kubernetes.config.map.client.max-attempts=5
kubernetes.config.map.client.min-backoff=100
kubernetes.config.map.client.delay-millis=${random.int(5000)}
Note: Ensure that the spring.application.name matches the name configured in Kubernetes. You will also need to create an API token for secure access to the Kubernetes API.
Configuring the WebClient
To configure the WebClient, you can refer to any Spring WebClient setup tutorial, as I won’t cover the WebClient creation in detail here. Instead, we’ll focus on creating a configuration properties bean that simplifies access to these properties as an injectable singleton.
@Validated
@ConfigurationProperties(prefix = "kubernetes.config.map.client", ignoreInvalidFields = true)
public record ConfigMapClientProperties(
@NotNull @NotEmpty String uri,
@NotNull @NotEmpty String token,
@Positive int timeout,
@Positive int maxAttempts,
@Positive int minBackoff,
@Positive int delayMillis
) {
}
Working with ConfigMap Data
To correctly use the ConfigMap file, we define a POJO to map the data:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class ConfigMap {
private ConfigMapData data;
private boolean isLoadCompleted;
public static boolean isNotEmpty(ConfigMap configMap) {
return nonNull(configMap) && nonNull(configMap.getData());
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ConfigMapData {
@Builder.Default
@JsonProperty("kubernetes.config.map.external-api-enabled")
boolean externalApiEnabled = true;
@Builder.Default
@JsonProperty("kubernetes.config.map.internal-api-enabled")
boolean internalApiEnabled = true;
}
}
Main Components
Now that we’ve created our model, we can move on to the key components of the solution:
Custom actuator endpoint: Receives POST requests from the Spring Cloud Kubernetes Configuration Watcher.
ConfigMap loader: Fetches the ConfigMap using a reactive WebClient.
Service: Updates the values of the ConfigMap properties across the application’s services.
Initializer: Connects to the Kubernetes API at application startup to load the ConfigMap values, ensuring consistency across pods after restarts or new deployments.
Let’s dive into each class.
ConfigMap actuator endpoint
This component will receive POST request from Spring Cloud Kubernetes Configuration Watcher:
@Slf4j
@Component
@RestControllerEndpoint(id = "configmap")
public class ConfigMapEndpoint {
private final ConfigMapService configMapService;
public ConfigMapEndpoint(ConfigMapService configMapService) {
this.configMapService = configMapService;
}
@PostMapping(path = {"/refresh"})
public Mono<Void> configMapRefresh() {
return configMapService.refreshProperties();
}
@GetMapping(path = "/")
public Mono<ConfigMap> getConfigMap() {
return configMapService.loadConfigMap();
}
}
ConfigMap Loader
This component uses the WebClient to load the ConfigMap from the Kubernetes API:
public interface ConfigMapRefreshable {
void refreshProperty(ConfigMap.ConfigMapData configMapData);
}
@Slf4j
@Component
@EnableConfigurationProperties(ConfigMapClientProperties.class)
public class ConfigMapLoader {
private final WebClient webClient;
@Getter
private final ConfigMapClientProperties properties;
public ConfigMapLoader(WebClient webClient, ConfigMapClientProperties properties) {
this.webClient = webClient;
this.properties = properties;
}
public Mono<ConfigMap> load() {
return webClient.get()
.uri(properties.uri())
.headers(this::populateHeaders)
.accept(APPLICATION_JSON)
.retrieve()
.bodyToMono(ConfigMap.class)
.timeout(Duration.ofMillis(properties.timeout()))
.retryWhen(getRetryBackoffSpec(properties))
.doOnSuccess(this::logSuccess)
.doOnError(this::logError)
.map(this::updateLoadStatus)
.delaySubscription(Duration.ofMillis(properties.delayMillis()));
}
private void populateHeaders(HttpHeaders headers) {
headers.setBearerAuth(properties.token());
}
private RetryBackoffSpec getRetryBackoffSpec(ConfigMapClientProperties properties) {
return Retry.backoff(
properties.maxAttempts(),
Duration.ofMillis(properties.minBackoff())
);
}
private void logSuccess(ConfigMap configMap) {
log.info("Retrieving ConfigMap succeeded, configMap: [{}]", configMap);
}
private void logError(Throwable exception) {
String message = getMessage(exception);
log.error("Retrieving ConfigMap from Kubernetes API failed, exception: [{}]", message);
}
private ConfigMap updateLoadStatus(ConfigMap configMap) {
if (isNotEmpty(configMap)) {
configMap.setLoadCompleted(true);
return configMap;
} else {
return ConfigMap.builder().build();
}
}
}
ConfigMap Service
This service refreshes the application’s properties with the newly loaded ConfigMap values:
@Slf4j
@Service
public class ConfigMapService {
private final ConfigMapLoader loader;
private final Set<ConfigMapRefreshable> services;
public ConfigMapService(ConfigMapLoader loader, Set<ConfigMapRefreshable> services) {
this.loader = loader;
this.services = services;
}
public Mono<Void> refreshProperties() {
final ConfigMapClientProperties properties = loader.getProperties();
return loadConfigMap()
.filter(ConfigMap::isLoadCompleted)
.repeatWhenEmpty(
properties.maxAttempts(),
flux -> flux.delayElements(Duration.ofMillis(properties.minBackoff()))
)
.doOnNext(this::refreshProperties)
.then();
}
public Mono<ConfigMap> loadConfigMap() {
return loader.load();
}
private void refreshProperties(ConfigMap configMap) {
if (configMap.isLoadCompleted()) {
ConfigMap.ConfigMapData data = configMap.getData();
services.forEach(service -> service.refreshProperty(data));
log.warn("Refreshing properties with new ConfigMap values succeeded, data: [{}], isLoadCompleted: [true]", data);
} else {
log.error("Refreshing properties with new ConfigMap values failed, isLoadCompleted: [false]");
}
}
}
ConfigMap initializer
This component connects to the Kubernetes API at the application startup to load the ConfigMap values, ensuring consistency across pods after restarts or new deployments.
@Slf4j
@Component
public class ConfigMapInitializer {
private final ConfigMapService configMapService;
public ConfigMapInitializer(ConfigMapService configMapService) {
this.configMapService = configMapService;
}
@EventListener(classes = {ContextRefreshedEvent.class})
public void handleConfigMapInitializing() {
configMapService.refreshProperties()
.publishOn(Schedulers.boundedElastic())
.subscribe();
}
}
Conclusion
With this setup, your Spring Boot application can dynamically load configuration values from a Kubernetes ConfigMap, ensuring that your application is always up-to-date with the latest configuration changes. This approach also allows seamless integration between Kubernetes and Spring Boot while leveraging reactive programming for efficient data retrieval.
References
- Kubernetes ConfigMaps documentation
- Spring Cloud Kubernetes Configuration Watcher documentation
- Spring Cloud Kubernetes Configuration Watcher source code
- Spring Boot Actuator: Production-ready Features
Finding my articles helpful? You could give me a caffeine boost to keep them coming! Your coffee donation will keep my keyboard clacking and my ideas brewing. But remember, it's completely optional. Stay tuned, stay informed, and perhaps, keep the coffee flowing!
Top comments (0)