Building Kubernetes Operators in Java: A Guide to Controller Runtime

Article

The Kubernetes operator pattern extends the Kubernetes API to manage complex applications using custom resources. While Go has been the traditional language for operators, Controller Runtime provides a powerful Java framework for building production-grade Kubernetes operators. This enables Java teams to leverage their existing expertise while embracing cloud-native patterns.


What is Controller Runtime?

Controller Runtime is a Java framework part of the Java Operator SDK that simplifies building Kubernetes operators. It provides abstractions for watching resources, managing reconciliation loops, and handling the complexities of the controller pattern.

Key Components:

  • Controller: The core component that watches resources and triggers reconciliations
  • Reconciler: The business logic that brings the current state closer to desired state
  • Event Source: Sources of events that trigger reconciliation
  • Manager: Coordinates multiple controllers and shared dependencies

Why Java for Kubernetes Operators?

  • Leverage Existing Skills: Utilize your team's Java expertise
  • Rich Ecosystem: Access to Java libraries for databases, messaging, etc.
  • Strong Typing: Compile-time safety for complex business logic
  • Spring Integration: Seamless integration with Spring ecosystem
  • Production Ready: Mature tooling for monitoring, logging, and diagnostics

Setting Up Dependencies

Maven Configuration

<properties>
<java-operator-sdk.version>4.4.0</java-operator-sdk.version>
<fabric8-client.version>6.8.1</fabric8-client.version>
</properties>
<dependencies>
<!-- Java Operator SDK -->
<dependency>
<groupId>io.javaoperatorsdk</groupId>
<artifactId>operator-framework</artifactId>
<version>${java-operator-sdk.version}</version>
</dependency>
<!-- Kubernetes Client -->
<dependency>
<groupId>io.fabric8</groupId>
<artifactId>kubernetes-client</artifactId>
<version>${fabric8-client.version}</version>
</dependency>
<!-- For Spring Boot integration -->
<dependency>
<groupId>io.javaoperatorsdk</groupId>
<artifactId>operator-framework-spring-boot-starter</artifactId>
<version>${java-operator-sdk.version}</version>
</dependency>
<!-- For testing -->
<dependency>
<groupId>io.javaoperatorsdk</groupId>
<artifactId>operator-framework-junit</artifactId>
<version>${java-operator-sdk.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Gradle Configuration

dependencies {
implementation 'io.javaoperatorsdk:operator-framework:4.4.0'
implementation 'io.fabric8:kubernetes-client:6.8.1'
implementation 'io.javaoperatorsdk:operator-framework-spring-boot-starter:4.4.0'
testImplementation 'io.javaoperatorsdk:operator-framework-junit:4.4.0'
}

Building Your First Operator

1. Define a Custom Resource

Example: DatabaseSchema Custom Resource

# database-schema-crd.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databaseschemas.example.com
spec:
group: example.com
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
databaseName:
type: string
schemaScript:
type: string
username:
type: string
scope: Namespaced
names:
plural: databaseschemas
singular: databaseschema
kind: DatabaseSchema
shortNames:
- dbschema

2. Create the Java Model

package com.example.operator.model;
import io.fabric8.kubernetes.client.CustomResource;
import io.fabric8.kubernetes.model.annotation.Group;
import io.fabric8.kubernetes.model.annotation.Version;
@Group("example.com")
@Version("v1")
public class DatabaseSchema extends CustomResource<DatabaseSchemaSpec, DatabaseSchemaStatus> {
@Override
protected DatabaseSchemaSpec initSpec() {
return new DatabaseSchemaSpec();
}
@Override
protected DatabaseSchemaStatus initStatus() {
return new DatabaseSchemaStatus();
}
}
// Spec definition
package com.example.operator.model;
public class DatabaseSchemaSpec {
private String databaseName;
private String schemaScript;
private String username;
// Getters and setters
public String getDatabaseName() { return databaseName; }
public void setDatabaseName(String databaseName) { this.databaseName = databaseName; }
public String getSchemaScript() { return schemaScript; }
public void setSchemaScript(String schemaScript) { this.schemaScript = schemaScript; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}
// Status definition
package com.example.operator.model;
public class DatabaseSchemaStatus {
private String phase;
private String message;
private String lastApplied;
// Getters and setters
public String getPhase() { return phase; }
public void setPhase(String phase) { this.phase = phase; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getLastApplied() { return lastApplied; }
public void setLastApplied(String lastApplied) { this.lastApplied = lastApplied; }
}

3. Implement the Reconciler

package com.example.operator.controller;
import com.example.operator.model.DatabaseSchema;
import com.example.operator.model.DatabaseSchemaStatus;
import io.javaoperatorsdk.operator.api.reconciler.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
@ControllerConfiguration
public class DatabaseSchemaReconciler implements Reconciler<DatabaseSchema> {
private static final Logger log = LoggerFactory.getLogger(DatabaseSchemaReconciler.class);
private final DatabaseService databaseService;
public DatabaseSchemaReconciler(DatabaseService databaseService) {
this.databaseService = databaseService;
}
@Override
public UpdateControl<DatabaseSchema> reconcile(DatabaseSchema resource, Context context) {
log.info("Reconciling DatabaseSchema: {}/{}", 
resource.getMetadata().getNamespace(), 
resource.getMetadata().getName());
try {
// Extract specifications
String databaseName = resource.getSpec().getDatabaseName();
String schemaScript = resource.getSpec().getSchemaScript();
String username = resource.getSpec().getUsername();
// Apply database schema
databaseService.applySchema(databaseName, username, schemaScript);
// Update status
DatabaseSchemaStatus status = resource.getStatus();
status.setPhase("Ready");
status.setMessage("Schema successfully applied");
status.setLastApplied(Instant.now().toString());
log.info("Successfully reconciled DatabaseSchema: {}/{}", 
resource.getMetadata().getNamespace(),
resource.getMetadata().getName());
return UpdateControl.updateStatus(resource);
} catch (Exception e) {
log.error("Failed to reconcile DatabaseSchema: {}/{}", 
resource.getMetadata().getNamespace(),
resource.getMetadata().getName(), e);
// Update status with error
DatabaseSchemaStatus status = resource.getStatus();
status.setPhase("Error");
status.setMessage("Failed to apply schema: " + e.getMessage());
return UpdateControl.updateStatus(resource);
}
}
}

4. Supporting Service Class

package com.example.operator.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
@Service
public class DatabaseService {
private final JdbcTemplate jdbcTemplate;
public DatabaseService(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
public void applySchema(String databaseName, String username, String schemaScript) {
// Create database if not exists
jdbcTemplate.execute("CREATE DATABASE IF NOT EXISTS " + databaseName);
// Use the database
jdbcTemplate.execute("USE " + databaseName);
// Create user if not exists
jdbcTemplate.execute("CREATE USER IF NOT EXISTS '" + username + "'@'%' IDENTIFIED BY 'temp_password'");
// Grant permissions
jdbcTemplate.execute("GRANT ALL PRIVILEGES ON " + databaseName + ".* TO '" + username + "'@'%'");
// Execute schema script
String[] statements = schemaScript.split(";");
for (String statement : statements) {
if (!statement.trim().isEmpty()) {
jdbcTemplate.execute(statement);
}
}
// Flush privileges
jdbcTemplate.execute("FLUSH PRIVILEGES");
}
}

Advanced Controller Patterns

1. Watching Multiple Resources

@ControllerConfiguration
public class MultiResourceReconciler implements Reconciler<DatabaseSchema> {
@Override
public UpdateControl<DatabaseSchema> reconcile(DatabaseSchema resource, Context context) {
return UpdateControl.noUpdate();
}
@EventSource
public InformerEventSource<ConfigMap, DatabaseSchema> configMapEventSource(
KubernetesClient client) {
return new InformerEventSource<>(
InformerConfiguration.from(ConfigMap.class, client)
.withNamespacesInheritedFromController(true)
.build(),
client);
}
@EventHandler
public void onConfigMapChange(ConfigMap configMap, Context<DatabaseSchema> context) {
// Handle ConfigMap changes that affect DatabaseSchema resources
log.info("ConfigMap changed: {}", configMap.getMetadata().getName());
}
}

2. Dependent Resource Management

@Component
public class SecretDependentResource extends AbstractDependentResource<Secret, DatabaseSchema> {
public SecretDependentResource(KubernetesClient client) {
super(Secret.class, client);
}
@Override
protected Secret desired(DatabaseSchema primary, Context<DatabaseSchema> context) {
Secret secret = new Secret();
secret.setMetadata(createSecretMetadata(primary));
secret.setData(createSecretData(primary));
return secret;
}
private ObjectMeta createSecretMetadata(DatabaseSchema primary) {
return new ObjectMetaBuilder()
.withName(primary.getMetadata().getName() + "-secret")
.withNamespace(primary.getMetadata().getNamespace())
.build();
}
private Map<String, String> createSecretData(DatabaseSchema primary) {
Map<String, String> data = new HashMap<>();
data.put("DATABASE_NAME", primary.getSpec().getDatabaseName());
data.put("USERNAME", primary.getSpec().getUsername());
// In production, generate proper passwords
data.put("PASSWORD", Base64.getEncoder().encodeToString(
UUID.randomUUID().toString().getBytes()));
return data;
}
}

3. Leader Election for High Availability

@Configuration
public class OperatorConfiguration {
@Bean
public LeaderElectionConfig leaderElectionConfig() {
return new LeaderElectionConfig()
.withLeaseName("database-operator-leader")
.withLeaseDuration(Duration.ofSeconds(15))
.withRenewDeadline(Duration.ofSeconds(10))
.withRetryPeriod(Duration.ofSeconds(2));
}
}

Spring Boot Integration

1. Spring Boot Main Application

package com.example.operator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DatabaseOperatorApplication {
public static void main(String[] args) {
SpringApplication.run(DatabaseOperatorApplication.class, args);
}
}

2. Operator Configuration

@Configuration
public class OperatorConfig {
@Bean
public DatabaseSchemaReconciler databaseSchemaReconciler(
DatabaseService databaseService) {
return new DatabaseSchemaReconciler(databaseService);
}
@Bean
public Operator operator(List<Reconciler<?>> reconcilers) {
return new Operator(overrider -> reconcilers.forEach(overrider::register));
}
}

3. Application Properties

# application.yaml
spring:
datasource:
url: jdbc:mysql://mysql.default.svc.cluster.local:3306
username: operator
password: ${DATABASE_PASSWORD}
operator:
client:
namespace: ${NAMESPACE:default}
logging:
level:
com.example.operator: DEBUG
io.javaoperatorsdk: INFO

Testing Your Operator

JUnit 5 Test with Kubernetes Server

package com.example.operator;
import com.example.operator.controller.DatabaseSchemaReconciler;
import com.example.operator.model.DatabaseSchema;
import com.example.operator.model.DatabaseSchemaSpec;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.junit.OperatorExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@SpringBootTest
class DatabaseSchemaReconcilerTest {
@MockBean
private DatabaseService databaseService;
@RegisterExtension
OperatorExtension operator = OperatorExtension.builder()
.withReconciler(new DatabaseSchemaReconciler(databaseService))
.build();
@Test
void testDatabaseSchemaReconciliation() {
// Given
DatabaseSchema schema = createTestDatabaseSchema();
// When
operator.create(DatabaseSchema.class, schema);
// Then
verify(databaseService, timeout(5000).times(1))
.applySchema("test-db", "test-user", "CREATE TABLE test (id INT);");
DatabaseSchema updated = operator.get(DatabaseSchema.class, 
schema.getMetadata().getName());
assertEquals("Ready", updated.getStatus().getPhase());
}
private DatabaseSchema createTestDatabaseSchema() {
DatabaseSchema schema = new DatabaseSchema();
schema.getMetadata().setName("test-schema");
DatabaseSchemaSpec spec = new DatabaseSchemaSpec();
spec.setDatabaseName("test-db");
spec.setUsername("test-user");
spec.setSchemaScript("CREATE TABLE test (id INT);");
schema.setSpec(spec);
return schema;
}
}

Deployment to Kubernetes

Dockerfile

FROM eclipse-temurin:17-jre as builder
WORKDIR /app
COPY target/operator-*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre
RUN useradd -ms /bin/bash operator
USER operator
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: database-operator
namespace: operator-system
spec:
replicas: 2
selector:
matchLabels:
app: database-operator
template:
metadata:
labels:
app: database-operator
spec:
serviceAccountName: database-operator
containers:
- name: operator
image: ghcr.io/mycompany/database-operator:1.0.0
env:
- name: NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: password
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: database-operator
namespace: operator-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: database-operator
rules:
- apiGroups: ["example.com"]
resources: ["databaseschemas"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: database-operator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: database-operator
subjects:
- kind: ServiceAccount
name: database-operator
namespace: operator-system

Best Practices for Java Operators

1. Idempotent Reconciliation

@Override
public UpdateControl<DatabaseSchema> reconcile(DatabaseSchema resource, Context context) {
// Always check current state before making changes
if (!isSchemaApplied(resource)) {
applySchema(resource);
}
return UpdateControl.updateStatus(resource);
}

2. Proper Error Handling

@Override
public UpdateControl<DatabaseSchema> reconcile(DatabaseSchema resource, Context context) {
try {
return doReconcile(resource, context);
} catch (RetryableException e) {
// Will be retried
return UpdateControl.noUpdate().rescheduleAfter(30, TimeUnit.SECONDS);
} catch (NonRetryableException e) {
// Won't be retried, update status
resource.getStatus().setPhase("Failed");
return UpdateControl.updateStatus(resource);
}
}

3. Efficient Resource Watching

@ControllerConfiguration(
labelSelector = "app.kubernetes.io/managed-by=database-operator",
generationAwareEventProcessing = false
)
public class EfficientReconciler implements Reconciler<DatabaseSchema> {
// Only processes relevant resources
}

Conclusion

Controller Runtime with Java Operator SDK provides a robust foundation for building Kubernetes operators in Java. It enables Java teams to:

  • Leverage existing expertise in Java and Spring ecosystems
  • Build production-grade operators with proper error handling and monitoring
  • Integrate seamlessly with existing Java infrastructure and libraries
  • Implement complex business logic with strong typing and compile-time safety

For organizations with significant Java investment, this approach bridges the gap between cloud-native operator patterns and traditional Java development, enabling faster adoption of Kubernetes-native application management while maximizing existing team capabilities.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper