The Java Operator SDK (JOSDK) is a powerful framework that enables Java developers to build Kubernetes operators efficiently. Operators are Kubernetes controllers that extend the Kubernetes API to manage complex applications and their lifecycle.
What are Kubernetes Operators?
Operators are software extensions to Kubernetes that use custom resources to manage applications and their components. They:
- Automate complex application management
- Encode operational knowledge
- Handle day-2 operations (backups, upgrades, scaling)
- Extend Kubernetes API with custom resources
Java Operator SDK Architecture
[Custom Resource] → [Kubernetes API] → [Java Operator] → [Managed Application] | | | | Define desired Watch for Reconcile state Deploy and manage application state changes and take actions application components
Hands-On Tutorial: Building a Complete Database Operator
Let's build a comprehensive Database operator that manages PostgreSQL instances with automated backups, scaling, and monitoring.
Step 1: Project Setup and Dependencies
Maven Dependencies (pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>database-operator</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Operator SDK -->
<operator-sdk.version>4.5.0</operator-sdk.version>
<fabric8-client.version>6.8.1</fabric8-client.version>
<!-- Spring Boot -->
<spring-boot.version>3.2.0</spring-boot.version>
<!-- Testing -->
<junit.version>5.10.1</junit.version>
<testcontainers.version>1.19.3</testcontainers.version>
</properties>
<dependencies>
<!-- Java Operator SDK -->
<dependency>
<groupId>io.javaoperatorsdk</groupId>
<artifactId>operator-framework</artifactId>
<version>${operator-sdk.version}</version>
</dependency>
<!-- Kubernetes Client -->
<dependency>
<groupId>io.fabric8</groupId>
<artifactId>kubernetes-client</artifactId>
<version>${fabric8-client.version}</version>
</dependency>
<!-- Spring Boot for additional features -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.16.1</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.9</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.javaoperatorsdk</groupId>
<artifactId>operator-framework-junit-5</artifactId>
<version>${operator-sdk.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Step 2: Custom Resource Definitions
src/main/java/com/example/operator/crd/Database.java:
package com.example.operator.crd;
import io.fabric8.kubernetes.client.CustomResource;
import io.fabric8.kubernetes.model.annotation.Group;
import io.fabric8.kubernetes.model.annotation.Kind;
import io.fabric8.kubernetes.model.annotation.Plural;
import io.fabric8.kubernetes.model.annotation.Singular;
import io.fabric8.kubernetes.model.annotation.Version;
@Group("database.example.com")
@Version("v1alpha1")
@Kind("Database")
@Singular("database")
@Plural("databases")
public class Database extends CustomResource<DatabaseSpec, DatabaseStatus> {
@Override
protected DatabaseStatus initStatus() {
return new DatabaseStatus();
}
}
src/main/java/com/example/operator/crd/DatabaseSpec.java:
package com.example.operator.crd;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.Valid;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonDeserialize
public class DatabaseSpec {
@NotBlank(message = "Database engine is required")
@JsonProperty("engine")
private String engine;
@NotBlank(message = "Database version is required")
@JsonProperty("version")
private String version;
@NotNull(message = "Size specification is required")
@Valid
@JsonProperty("size")
private DatabaseSize size;
@Valid
@JsonProperty("backup")
private BackupConfig backup;
@Valid
@JsonProperty("monitoring")
private MonitoringConfig monitoring;
@Valid
@JsonProperty("resources")
private ResourceRequirements resources;
@JsonProperty("config")
private Map<String, String> config;
@JsonProperty("storage")
private StorageConfig storage;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DatabaseSize {
@NotBlank(message = "Instance type is required")
@JsonProperty("instanceType")
private String instanceType;
@Min(value = 1, message = "Replicas must be at least 1")
@Max(value = 10, message = "Replicas cannot exceed 10")
@JsonProperty("replicas")
private Integer replicas;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BackupConfig {
@JsonProperty("enabled")
private Boolean enabled;
@JsonProperty("schedule")
private String schedule; // Cron expression
@JsonProperty("retentionDays")
@Min(value = 1, message = "Retention days must be at least 1")
private Integer retentionDays;
@JsonProperty("storageClass")
private String storageClass;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class MonitoringConfig {
@JsonProperty("enabled")
private Boolean enabled;
@JsonProperty("metrics")
private Boolean metrics;
@JsonProperty("logs")
private Boolean logs;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ResourceRequirements {
@Valid
@JsonProperty("requests")
private Resource requests;
@Valid
@JsonProperty("limits")
private Resource limits;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Resource {
@JsonProperty("cpu")
private String cpu;
@JsonProperty("memory")
private String memory;
@JsonProperty("storage")
private String storage;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class StorageConfig {
@NotBlank(message = "Storage class is required")
@JsonProperty("storageClass")
private String storageClass;
@NotBlank(message = "Storage size is required")
@JsonProperty("size")
private String size;
@JsonProperty("encrypted")
private Boolean encrypted;
}
}
src/main/java/com/example/operator/crd/DatabaseStatus.java:
package com.example.operator.crd;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class DatabaseStatus {
public enum Phase {
PENDING,
PROVISIONING,
RUNNING,
UPDATING,
BACKING_UP,
RESTORING,
FAILED,
TERMINATING
}
@JsonProperty("phase")
private Phase phase;
@JsonProperty("message")
private String message;
@JsonProperty("observedGeneration")
private Long observedGeneration;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
@JsonProperty("lastTransitionTime")
private LocalDateTime lastTransitionTime;
@JsonProperty("conditions")
private List<Condition> conditions;
@JsonProperty("endpoints")
private Endpoints endpoints;
@JsonProperty("backupInfo")
private BackupInfo backupInfo;
@JsonProperty("replicas")
private ReplicaStatus replicas;
public void addCondition(Condition condition) {
if (conditions == null) {
conditions = new ArrayList<>();
}
conditions.add(condition);
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Condition {
@JsonProperty("type")
private String type;
@JsonProperty("status")
private String status;
@JsonProperty("reason")
private String reason;
@JsonProperty("message")
private String message;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
@JsonProperty("lastTransitionTime")
private LocalDateTime lastTransitionTime;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Endpoints {
@JsonProperty("primary")
private String primary;
@JsonProperty("readReplicas")
private List<String> readReplicas;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class BackupInfo {
@JsonProperty("lastBackupTime")
private LocalDateTime lastBackupTime;
@JsonProperty("lastBackupSize")
private String lastBackupSize;
@JsonProperty("backupCount")
private Integer backupCount;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ReplicaStatus {
@JsonProperty("current")
private Integer current;
@JsonProperty("ready")
private Integer ready;
@JsonProperty("available")
private Integer available;
}
}
Step 3: Core Database Operator
src/main/java/com/example/operator/DatabaseReconciler.java:
package com.example.operator;
import com.example.operator.crd.Database;
import com.example.operator.crd.DatabaseStatus;
import io.javaoperatorsdk.operator.api.reconciler.*;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
@Slf4j
@Component
@ControllerConfiguration(
dependents = {
@Dependent(type = SecretDependentResource.class),
@Dependent(type = ConfigMapDependentResource.class),
@Dependent(type = StatefulSetDependentResource.class),
@Dependent(type = ServiceDependentResource.class),
@Dependent(type = BackupCronJobDependentResource.class),
@Dependent(type = ServiceMonitorDependentResource.class)
}
)
public class DatabaseReconciler implements Reconciler<Database> {
private final EventRecorder eventRecorder;
private final StatusUpdater statusUpdater;
public DatabaseReconciler(EventRecorder eventRecorder, StatusUpdater statusUpdater) {
this.eventRecorder = eventRecorder;
this.statusUpdater = statusUpdater;
}
@Override
public UpdateControl<Database> reconcile(Database database, Context<Database> context) {
log.info("Reconciling database: {}/{}",
database.getMetadata().getNamespace(),
database.getMetadata().getName());
try {
// Update status to provisioning
statusUpdater.updateStatus(database, DatabaseStatus.Phase.PROVISIONING,
"Starting database provisioning");
// Validate the database specification
validateDatabaseSpec(database);
// Check if this is a new database or an update
if (isNewDatabase(database)) {
log.info("Creating new database instance: {}", database.getMetadata().getName());
eventRecorder.recordEvent(database, "Normal", "Created",
"Database instance creation started");
} else {
log.info("Updating existing database instance: {}", database.getMetadata().getName());
eventRecorder.recordEvent(database, "Normal", "Updated",
"Database instance update started");
}
// The actual reconciliation is handled by dependent resources
// This main reconciler coordinates the overall process
// Update status to running if all dependents are ready
if (areDependentsReady(context)) {
statusUpdater.updateStatus(database, DatabaseStatus.Phase.RUNNING,
"Database is running and ready");
eventRecorder.recordEvent(database, "Normal", "Ready",
"Database instance is ready");
}
return UpdateControl.patchStatus(database);
} catch (Exception e) {
log.error("Failed to reconcile database: {}/{}",
database.getMetadata().getNamespace(),
database.getMetadata().getName(), e);
statusUpdater.updateStatus(database, DatabaseStatus.Phase.FAILED,
"Reconciliation failed: " + e.getMessage());
eventRecorder.recordEvent(database, "Warning", "ReconcileFailed",
"Database reconciliation failed: " + e.getMessage());
return UpdateControl.patchStatus(database);
}
}
@Override
public DeleteControl cleanup(Database database, Context<Database> context) {
log.info("Cleaning up database: {}/{}",
database.getMetadata().getNamespace(),
database.getMetadata().getName());
eventRecorder.recordEvent(database, "Normal", "Deleting",
"Database instance deletion in progress");
// Cleanup logic is handled by garbage collection of dependent resources
// Additional cleanup can be performed here if needed
return DeleteControl.defaultDelete();
}
private void validateDatabaseSpec(Database database) {
if (database.getSpec() == null) {
throw new IllegalArgumentException("Database spec cannot be null");
}
if (!"postgresql".equalsIgnoreCase(database.getSpec().getEngine())) {
throw new IllegalArgumentException("Only PostgreSQL engine is currently supported");
}
// Additional validation logic
if (database.getSpec().getSize() == null) {
throw new IllegalArgumentException("Database size configuration is required");
}
}
private boolean isNewDatabase(Database database) {
return database.getStatus() == null ||
database.getStatus().getPhase() == null ||
database.getStatus().getPhase() == DatabaseStatus.Phase.PENDING;
}
private boolean areDependentsReady(Context<Database> context) {
// Check if all dependent resources are ready
return context.managedDependentResourceContext()
.getWorkflowReconcileResult()
.orElseThrow()
.allDependentResourcesReady();
}
}
Step 4: Dependent Resources
src/main/java/com/example/operator/dependent/SecretDependentResource.java:
package com.example.operator.dependent;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.api.model.Secret;
import io.fabric8.kubernetes.api.model.SecretBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Component
@KubernetesDependent
public class SecretDependentResource implements DependentResource<Secret, Database> {
@Override
public Secret desired(Database database, Context<Database> context) {
String name = database.getMetadata().getName();
String namespace = database.getMetadata().getNamespace();
Map<String, String> data = new HashMap<>();
// Generate passwords if not exists
String postgresPassword = generatePassword();
String replicationPassword = generatePassword();
data.put("postgres-password", encodeBase64(postgresPassword));
data.put("replication-password", encodeBase64(replicationPassword));
data.put("database-name", encodeBase64("appdb"));
data.put("username", encodeBase64("postgres"));
return new SecretBuilder()
.withNewMetadata()
.withName(getSecretName(database))
.withNamespace(namespace)
.addNewOwnerReference()
.withApiVersion(database.getApiVersion())
.withKind(database.getKind())
.withName(name)
.withUid(database.getMetadata().getUid())
.withBlockOwnerDeletion(true)
.withController(true)
.endOwnerReference()
.addToLabels(createLabels(database))
.endMetadata()
.withType("Opaque")
.withData(data)
.build();
}
@Override
public Class<Secret> resourceType() {
return Secret.class;
}
private String generatePassword() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
private String encodeBase64(String value) {
return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private String getSecretName(Database database) {
return database.getMetadata().getName() + "-credentials";
}
private Map<String, String> createLabels(Database database) {
Map<String, String> labels = new HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/component", "database");
labels.put("app.kubernetes.io/part-of", "database-operator");
labels.put("app.kubernetes.io/managed-by", "database-operator");
return labels;
}
}
src/main/java/com/example/operator/dependent/StatefulSetDependentResource.java:
package com.example.operator.dependent;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.api.model.*;
import io.fabric8.kubernetes.api.model.apps.StatefulSet;
import io.fabric8.kubernetes.api.model.apps.StatefulSetBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
@KubernetesDependent
public class StatefulSetDependentResource implements DependentResource<StatefulSet, Database> {
@Override
public StatefulSet desired(Database database, Context<Database> context) {
String name = database.getMetadata().getName();
String namespace = database.getMetadata().getNamespace();
return new StatefulSetBuilder()
.withNewMetadata()
.withName(getStatefulSetName(database))
.withNamespace(namespace)
.addNewOwnerReference()
.withApiVersion(database.getApiVersion())
.withKind(database.getKind())
.withName(name)
.withUid(database.getMetadata().getUid())
.withBlockOwnerDeletion(true)
.withController(true)
.endOwnerReference()
.addToLabels(createLabels(database))
.endMetadata()
.withSpec(createStatefulSetSpec(database))
.build();
}
@Override
public Class<StatefulSet> resourceType() {
return StatefulSet.class;
}
private StatefulSetSpec createStatefulSetSpec(Database database) {
return new StatefulSetSpecBuilder()
.withServiceName(getServiceName(database))
.withReplicas(database.getSpec().getSize().getReplicas())
.withSelector(createLabelSelector(database))
.withTemplate(createPodTemplate(database))
.withVolumeClaimTemplates(createVolumeClaims(database))
.build();
}
private LabelSelector createLabelSelector(Database database) {
return new LabelSelectorBuilder()
.withMatchLabels(createSelectorLabels(database))
.build();
}
private PodTemplateSpec createPodTemplate(Database database) {
return new PodTemplateSpecBuilder()
.withMetadata(new ObjectMetaBuilder()
.withLabels(createPodLabels(database))
.build())
.withSpec(createPodSpec(database))
.build();
}
private PodSpec createPodSpec(Database database) {
List<Container> containers = new ArrayList<>();
containers.add(createPostgresContainer(database));
return new PodSpecBuilder()
.withContainers(containers)
.withRestartPolicy("Always")
.withTerminationGracePeriodSeconds(30L)
.build();
}
private Container createPostgresContainer(Database database) {
return new ContainerBuilder()
.withName("postgres")
.withImage("postgres:" + database.getSpec().getVersion())
.withPorts(createContainerPorts())
.withEnv(createEnvironmentVariables(database))
.withResources(createResourceRequirements(database))
.withVolumeMounts(createVolumeMounts())
.withLivenessProbe(createLivenessProbe())
.withReadinessProbe(createReadinessProbe())
.build();
}
private List<ContainerPort> createContainerPorts() {
List<ContainerPort> ports = new ArrayList<>();
ports.add(new ContainerPortBuilder()
.withName("postgres")
.withContainerPort(5432)
.withProtocol("TCP")
.build());
return ports;
}
private List<EnvVar> createEnvironmentVariables(Database database) {
List<EnvVar> envVars = new ArrayList<>();
envVars.add(new EnvVarBuilder()
.withName("POSTGRES_DB")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(getSecretName(database))
.withKey("database-name")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(new EnvVarBuilder()
.withName("POSTGRES_USER")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(getSecretName(database))
.withKey("username")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
envVars.add(new EnvVarBuilder()
.withName("POSTGRES_PASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(getSecretName(database))
.withKey("postgres-password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build());
// Add custom configuration
if (database.getSpec().getConfig() != null) {
database.getSpec().getConfig().forEach((key, value) -> {
envVars.add(new EnvVarBuilder()
.withName(key.toUpperCase().replace('-', '_'))
.withValue(value)
.build());
});
}
return envVars;
}
private ResourceRequirements createResourceRequirements(Database database) {
ResourceRequirements requirements = new ResourceRequirements();
if (database.getSpec().getResources() != null) {
if (database.getSpec().getResources().getRequests() != null) {
requirements.setRequests(createResourceList(database.getSpec().getResources().getRequests()));
}
if (database.getSpec().getResources().getLimits() != null) {
requirements.setLimits(createResourceList(database.getSpec().getResources().getLimits()));
}
}
return requirements;
}
private Map<String, Quantity> createResourceList(DatabaseSpec.Resource resource) {
Map<String, Quantity> resourceList = new HashMap<>();
if (resource.getCpu() != null) {
resourceList.put("cpu", new Quantity(resource.getCpu()));
}
if (resource.getMemory() != null) {
resourceList.put("memory", new Quantity(resource.getMemory()));
}
return resourceList;
}
private List<VolumeMount> createVolumeMounts() {
List<VolumeMount> mounts = new ArrayList<>();
mounts.add(new VolumeMountBuilder()
.withName("data")
.withMountPath("/var/lib/postgresql/data")
.build());
return mounts;
}
private List<PersistentVolumeClaim> createVolumeClaims(Database database) {
List<PersistentVolumeClaim> claims = new ArrayList<>();
claims.add(new PersistentVolumeClaimBuilder()
.withNewMetadata()
.withName("data")
.endMetadata()
.withSpec(createVolumeClaimSpec(database))
.build());
return claims;
}
private PersistentVolumeClaimSpec createVolumeClaimSpec(Database database) {
return new PersistentVolumeClaimSpecBuilder()
.withAccessModes("ReadWriteOnce")
.withNewResources()
.withRequests(Map.of("storage", new Quantity(database.getSpec().getStorage().getSize())))
.endResources()
.withStorageClassName(database.getSpec().getStorage().getStorageClass())
.build();
}
private Probe createLivenessProbe() {
return new ProbeBuilder()
.withExec(new ExecActionBuilder()
.withCommand("pg_isready", "-U", "postgres")
.build())
.withInitialDelaySeconds(30)
.withPeriodSeconds(10)
.withTimeoutSeconds(5)
.build();
}
private Probe createReadinessProbe() {
return new ProbeBuilder()
.withExec(new ExecActionBuilder()
.withCommand("pg_isready", "-U", "postgres")
.build())
.withInitialDelaySeconds(5)
.withPeriodSeconds(5)
.withTimeoutSeconds(3)
.build();
}
private String getStatefulSetName(Database database) {
return database.getMetadata().getName();
}
private String getServiceName(Database database) {
return database.getMetadata().getName() + "-service";
}
private String getSecretName(Database database) {
return database.getMetadata().getName() + "-credentials";
}
private Map<String, String> createLabels(Database database) {
return createCommonLabels(database);
}
private Map<String, String> createPodLabels(Database database) {
return createCommonLabels(database);
}
private Map<String, String> createSelectorLabels(Database database) {
Map<String, String> labels = createCommonLabels(database);
labels.put("app.kubernetes.io/component", "database");
return labels;
}
private Map<String, String> createCommonLabels(Database database) {
Map<String, String> labels = new HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/instance", database.getMetadata().getName());
labels.put("app.kubernetes.io/component", "database");
labels.put("app.kubernetes.io/part-of", "database-operator");
labels.put("app.kubernetes.io/managed-by", "database-operator");
labels.put("app.kubernetes.io/version", database.getSpec().getVersion());
return labels;
}
}
Step 5: Service Dependent Resource
src/main/java/com/example/operator/dependent/ServiceDependentResource.java:
package com.example.operator.dependent;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServiceBuilder;
import io.fabric8.kubernetes.api.model.ServicePortBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@KubernetesDependent
public class ServiceDependentResource implements DependentResource<Service, Database> {
@Override
public Service desired(Database database, Context<Database> context) {
String name = database.getMetadata().getName();
String namespace = database.getMetadata().getNamespace();
return new ServiceBuilder()
.withNewMetadata()
.withName(getServiceName(database))
.withNamespace(namespace)
.addNewOwnerReference()
.withApiVersion(database.getApiVersion())
.withKind(database.getKind())
.withName(name)
.withUid(database.getMetadata().getUid())
.withBlockOwnerDeletion(true)
.withController(true)
.endOwnerReference()
.addToLabels(createLabels(database))
.endMetadata()
.withSpec(createServiceSpec(database))
.build();
}
@Override
public Class<Service> resourceType() {
return Service.class;
}
private io.fabric8.kubernetes.api.model.ServiceSpec createServiceSpec(Database database) {
return new io.fabric8.kubernetes.api.model.ServiceSpecBuilder()
.withSelector(createSelectorLabels(database))
.withPorts(createServicePorts())
.withType("ClusterIP")
.build();
}
private java.util.List<io.fabric8.kubernetes.api.model.ServicePort> createServicePorts() {
return java.util.List.of(
new ServicePortBuilder()
.withName("postgres")
.withPort(5432)
.withTargetPort(new io.fabric8.kubernetes.api.model.IntOrString(5432))
.withProtocol("TCP")
.build()
);
}
private String getServiceName(Database database) {
return database.getMetadata().getName() + "-service";
}
private Map<String, String> createLabels(Database database) {
Map<String, String> labels = new HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/component", "database");
labels.put("app.kubernetes.io/part-of", "database-operator");
labels.put("app.kubernetes.io/managed-by", "database-operator");
return labels;
}
private Map<String, String> createSelectorLabels(Database database) {
Map<String, String> labels = new HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/component", "database");
return labels;
}
}
Step 6: Backup CronJob Dependent Resource
src/main/java/com/example/operator/dependent/BackupCronJobDependentResource.java:
package com.example.operator.dependent;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.api.model.batch.v1.CronJob;
import io.fabric8.kubernetes.api.model.batch.v1.CronJobBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@KubernetesDependent
public class BackupCronJobDependentResource implements DependentResource<CronJob, Database> {
@Override
public CronJob desired(Database database, Context<Database> context) {
if (!isBackupEnabled(database)) {
return null; // No backup cronjob needed
}
String name = database.getMetadata().getName();
String namespace = database.getMetadata().getNamespace();
return new CronJobBuilder()
.withNewMetadata()
.withName(getCronJobName(database))
.withNamespace(namespace)
.addNewOwnerReference()
.withApiVersion(database.getApiVersion())
.withKind(database.getKind())
.withName(name)
.withUid(database.getMetadata().getUid())
.withBlockOwnerDeletion(true)
.withController(true)
.endOwnerReference()
.addToLabels(createLabels(database))
.endMetadata()
.withSpec(createCronJobSpec(database))
.build();
}
@Override
public Class<CronJob> resourceType() {
return CronJob.class;
}
private io.fabric8.kubernetes.api.model.batch.v1.CronJobSpec createCronJobSpec(Database database) {
return new io.fabric8.kubernetes.api.model.batch.v1.CronJobSpecBuilder()
.withSchedule(database.getSpec().getBackup().getSchedule())
.withJobTemplate(createJobTemplate(database))
.withSuccessfulJobsHistoryLimit(5)
.withFailedJobsHistoryLimit(3)
.build();
}
private io.fabric8.kubernetes.api.model.batch.v1.JobTemplateSpec createJobTemplate(Database database) {
return new io.fabric8.kubernetes.api.model.batch.v1.JobTemplateSpecBuilder()
.withSpec(createJobSpec(database))
.build();
}
private io.fabric8.kubernetes.api.model.batch.v1.JobSpec createJobSpec(Database database) {
return new io.fabric8.kubernetes.api.model.batch.v1.JobSpecBuilder()
.withTemplate(createPodTemplate(database))
.build();
}
private io.fabric8.kubernetes.api.model.PodTemplateSpec createPodTemplate(Database database) {
return new io.fabric8.kubernetes.api.model.PodTemplateSpecBuilder()
.withSpec(createPodSpec(database))
.build();
}
private io.fabric8.kubernetes.api.model.PodSpec createPodSpec(Database database) {
return new io.fabric8.kubernetes.api.model.PodSpecBuilder()
.withContainers(createBackupContainer(database))
.withRestartPolicy("OnFailure")
.build();
}
private java.util.List<io.fabric8.kubernetes.api.model.Container> createBackupContainer(Database database) {
return java.util.List.of(
new io.fabric8.kubernetes.api.model.ContainerBuilder()
.withName("backup")
.withImage("postgres:" + database.getSpec().getVersion())
.withCommand(createBackupCommand(database))
.withEnv(createBackupEnvVars(database))
.withVolumeMounts(createBackupVolumeMounts())
.build()
);
}
private java.util.List<String> createBackupCommand(Database database) {
return java.util.List.of(
"/bin/bash",
"-c",
"pg_dump -h " + getServiceName(database) + " -U postgres appdb | gzip > /backup/backup-$(date +%Y%m%d-%H%M%S).sql.gz"
);
}
private java.util.List<io.fabric8.kubernetes.api.model.EnvVar> createBackupEnvVars(Database database) {
return java.util.List.of(
new io.fabric8.kubernetes.api.model.EnvVarBuilder()
.withName("PGPASSWORD")
.withNewValueFrom()
.withNewSecretKeyRef()
.withName(getSecretName(database))
.withKey("postgres-password")
.withOptional(false)
.endSecretKeyRef()
.endValueFrom()
.build()
);
}
private java.util.List<io.fabric8.kubernetes.api.model.VolumeMount> createBackupVolumeMounts() {
return java.util.List.of(
new io.fabric8.kubernetes.api.model.VolumeMountBuilder()
.withName("backup-storage")
.withMountPath("/backup")
.build()
);
}
private boolean isBackupEnabled(Database database) {
return database.getSpec().getBackup() != null &&
Boolean.TRUE.equals(database.getSpec().getBackup().getEnabled());
}
private String getCronJobName(Database database) {
return database.getMetadata().getName() + "-backup";
}
private String getServiceName(Database database) {
return database.getMetadata().getName() + "-service";
}
private String getSecretName(Database database) {
return database.getMetadata().getName() + "-credentials";
}
private Map<String, String> createLabels(Database database) {
Map<String, String> labels = new HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/component", "backup");
labels.put("app.kubernetes.io/part-of", "database-operator");
labels.put("app.kubernetes.io/managed-by", "database-operator");
return labels;
}
}
Step 7: Supporting Services
src/main/java/com/example/operator/service/EventRecorder.java:
package com.example.operator.service;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.api.model.Event;
import io.fabric8.kubernetes.api.model.EventBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class EventRecorder {
private final KubernetesClient kubernetesClient;
public void recordEvent(Database database, String type, String reason, String message) {
try {
Event event = new EventBuilder()
.withNewMetadata()
.withName(database.getMetadata().getName() + "-" + UUID.randomUUID().toString().substring(0, 8))
.withNamespace(database.getMetadata().getNamespace())
.withLabels(createEventLabels(database))
.endMetadata()
.withInvolvedObject(createObjectReference(database))
.withType(type)
.withReason(reason)
.withMessage(message)
.withFirstTimestamp(ZonedDateTime.now(ZoneOffset.UTC).toString())
.withLastTimestamp(ZonedDateTime.now(ZoneOffset.UTC).toString())
.withCount(1)
.withNewSource()
.withComponent("database-operator")
.endSource()
.build();
kubernetesClient.v1().events().inNamespace(database.getMetadata().getNamespace()).create(event);
log.debug("Recorded event: {} - {} - {}", type, reason, message);
} catch (Exception e) {
log.warn("Failed to record event: {}", e.getMessage());
}
}
private io.fabric8.kubernetes.api.model.ObjectReference createObjectReference(Database database) {
return new io.fabric8.kubernetes.api.model.ObjectReferenceBuilder()
.withApiVersion(database.getApiVersion())
.withKind(database.getKind())
.withName(database.getMetadata().getName())
.withNamespace(database.getMetadata().getNamespace())
.withUid(database.getMetadata().getUid())
.build();
}
private java.util.Map<String, String> createEventLabels(Database database) {
java.util.Map<String, String> labels = new java.util.HashMap<>();
labels.put("app.kubernetes.io/name", database.getMetadata().getName());
labels.put("app.kubernetes.io/managed-by", "database-operator");
return labels;
}
}
src/main/java/com/example/operator/service/StatusUpdater.java:
package com.example.operator.service;
import com.example.operator.crd.Database;
import com.example.operator.crd.DatabaseStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Slf4j
@Service
@RequiredArgsConstructor
public class StatusUpdater {
public void updateStatus(Database database, DatabaseStatus.Phase phase, String message) {
DatabaseStatus status = database.getStatus();
if (status == null) {
status = new DatabaseStatus();
database.setStatus(status);
}
// Update phase and message
status.setPhase(phase);
status.setMessage(message);
status.setObservedGeneration(database.getMetadata().getGeneration());
status.setLastTransitionTime(LocalDateTime.now());
// Add condition
addCondition(status, phase.toString(), "True", getReasonForPhase(phase), message);
log.debug("Updated status for database {}/{}: {} - {}",
database.getMetadata().getNamespace(),
database.getMetadata().getName(),
phase,
message);
}
private void addCondition(DatabaseStatus status, String type, String conditionStatus, String reason, String message) {
DatabaseStatus.Condition condition = DatabaseStatus.Condition.builder()
.type(type)
.status(conditionStatus)
.reason(reason)
.message(message)
.lastTransitionTime(LocalDateTime.now())
.build();
status.addCondition(condition);
}
private String getReasonForPhase(DatabaseStatus.Phase phase) {
switch (phase) {
case PENDING: return "Initializing";
case PROVISIONING: return "Provisioning";
case RUNNING: return "Ready";
case UPDATING: return "Updating";
case BACKING_UP: return "BackingUp";
case RESTORING: return "Restoring";
case FAILED: return "Failed";
case TERMINATING: return "Terminating";
default: return "Unknown";
}
}
}
Step 8: Operator Configuration and Main Class
src/main/java/com/example/operator/config/OperatorConfig.java:
package com.example.operator.config;
import com.example.operator.crd.Database;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Slf4j
@Configuration
public class OperatorConfig {
@Bean
public Operator operator(
KubernetesClient kubernetesClient,
ConfigurationService configurationService,
List<Reconciler<?>> reconcilers) {
Operator operator = new Operator(kubernetesClient, configurationService);
// Register all reconcilers
reconcilers.forEach(reconciler -> {
operator.register(reconciler);
log.info("Registered reconciler: {}", reconciler.getClass().getSimpleName());
});
return operator;
}
@Bean
public ConfigurationService configurationService() {
return io.javaoperatorsdk.operator.api.config.ConfigurationService.instance();
}
@Bean
public KubernetesClient kubernetesClient() {
return io.fabric8.kubernetes.client.DefaultKubernetesClient.create();
}
}
src/main/java/com/example/operator/DatabaseOperatorApplication.java:
package com.example.operator;
import io.javaoperatorsdk.operator.Operator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@Slf4j
@SpringBootApplication
public class DatabaseOperatorApplication implements CommandLineRunner {
private final Operator operator;
public DatabaseOperatorApplication(Operator operator) {
this.operator = operator;
}
public static void main(String[] args) {
SpringApplication.run(DatabaseOperatorApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
operator.start();
log.info("Database Operator started successfully");
// Add shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
log.info("Shutting down Database Operator...");
operator.stop();
}));
}
}
Step 9: Custom Resource Examples
src/main/resources/examples/database-cr.yaml:
apiVersion: database.example.com/v1alpha1 kind: Database metadata: name: postgres-production namespace: default labels: environment: production team: platform spec: engine: postgresql version: "15" size: instanceType: large replicas: 3 backup: enabled: true schedule: "0 2 * * *" retentionDays: 30 storageClass: fast-ssd monitoring: enabled: true metrics: true logs: true resources: requests: cpu: "1000m" memory: "2Gi" storage: "100Gi" limits: cpu: "2000m" memory: "4Gi" storage: storageClass: fast-ssd size: "100Gi" encrypted: true config: max_connections: "200" shared_buffers: "1GB" effective_cache_size: "3GB"
src/main/resources/examples/database-dev-cr.yaml:
apiVersion: database.example.com/v1alpha1 kind: Database metadata: name: postgres-development namespace: development labels: environment: development team: development spec: engine: postgresql version: "15" size: instanceType: small replicas: 1 backup: enabled: false monitoring: enabled: true metrics: true logs: false resources: requests: cpu: "500m" memory: "1Gi" storage: "20Gi" limits: cpu: "1000m" memory: "2Gi" storage: storageClass: standard size: "20Gi" encrypted: false
Step 10: Testing the Operator
src/test/java/com/example/operator/DatabaseReconcilerTest.java:
package com.example.operator;
import com.example.operator.crd.Database;
import com.example.operator.crd.DatabaseSpec;
import io.fabric8.kubernetes.api.model.ObjectMeta;
import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.awaitility.Awaitility.await;
import static java.util.concurrent.TimeUnit.SECONDS;
class DatabaseReconcilerTest {
@RegisterExtension
LocallyRunOperatorExtension operator = LocallyRunOperatorExtension.builder()
.withReconciler(new DatabaseReconciler())
.build();
@Test
void testDatabaseCreation() {
// Given
Database database = createTestDatabase();
// When
operator.create(database);
// Then
await().atMost(30, SECONDS).until(() -> {
Database updated = operator.get(Database.class, "test-database");
return updated.getStatus() != null &&
updated.getStatus().getPhase() == DatabaseStatus.Phase.RUNNING;
});
}
private Database createTestDatabase() {
Database database = new Database();
ObjectMeta metadata = new ObjectMeta();
metadata.setName("test-database");
metadata.setNamespace("default");
database.setMetadata(metadata);
DatabaseSpec spec = new DatabaseSpec();
spec.setEngine("postgresql");
spec.setVersion("15");
DatabaseSpec.DatabaseSize size = new DatabaseSpec.DatabaseSize();
size.setInstanceType("small");
size.setReplicas(1);
spec.setSize(size);
DatabaseSpec.StorageConfig storage = new DatabaseSpec.StorageConfig();
storage.setStorageClass("standard");
storage.setSize("10Gi");
storage.setEncrypted(false);
spec.setStorage(storage);
database.setSpec(spec);
return database;
}
}
Step 11: Building and Deploying
Dockerfile:
FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY target/database-operator-1.0.0.jar app.jar # Install curl for health checks RUN apk add --no-cache curl # Create non-root user RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1 ENTRYPOINT ["java", "-jar", "app.jar"]
deploy/operator-deployment.yaml:
apiVersion: apps/v1 kind: Deployment metadata: name: database-operator namespace: database-operator-system spec: replicas: 1 selector: matchLabels: app.kubernetes.io/name: database-operator template: metadata: labels: app.kubernetes.io/name: database-operator app.kubernetes.io/version: "1.0.0" spec: serviceAccountName: database-operator containers: - name: operator image: ghcr.io/your-username/database-operator:1.0.0 ports: - containerPort: 8080 name: metrics env: - name: WATCH_NAMESPACE value: "" resources: requests: memory: "256Mi" cpu: "100m" limits: memory: "512Mi" cpu: "500m" livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 10 periodSeconds: 10 readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 5 periodSeconds: 5 --- apiVersion: v1 kind: ServiceAccount metadata: name: database-operator namespace: database-operator-system --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: database-operator rules: - apiGroups: ["database.example.com"] resources: ["databases"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: ["database.example.com"] resources: ["databases/status"] verbs: ["get", "update", "patch"] - apiGroups: [""] resources: ["secrets", "configmaps", "services", "events", "pods", "pods/log"] verbs: ["*"] - apiGroups: ["apps"] resources: ["statefulsets"] verbs: ["*"] - apiGroups: ["batch"] resources: ["cronjobs", "jobs"] verbs: ["*"] - apiGroups: ["monitoring.coreos.com"] resources: ["servicemonitors"] verbs: ["*"] --- 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: database-operator-system
Step 12: Usage Examples
Deploy the Operator:
# Apply CRD kubectl apply -f src/main/resources/crd/database-crd.yaml # Deploy operator kubectl create namespace database-operator-system kubectl apply -f deploy/operator-deployment.yaml # Create a database instance kubectl apply -f src/main/resources/examples/database-cr.yaml
Check Database Status:
# Check database resource kubectl get databases # Check detailed status kubectl describe database postgres-production # Check created resources kubectl get statefulsets,services,secrets -l app.kubernetes.io/name=postgres-production # Check operator logs kubectl logs -l app.kubernetes.io/name=database-operator -n database-operator-system
Best Practices
1. Operator Design
- Idempotent reconciliations - handle multiple calls safely
- Event-driven - respond only to relevant changes
- Resource ownership - use owner references for cleanup
- Status management - provide clear status information
2. Error Handling
- Retry mechanisms for transient failures
- Clear error messages in status and events
- Circuit breakers for persistent failures
- Graceful degradation when possible
3. Performance
- Efficient watches with resource versions
- Bulk operations when appropriate
- Caching for frequently accessed data
- Rate limiting for API calls
Benefits of Java Operator SDK
- Type Safety: Strong typing with custom resources
- Familiar Ecosystem: Leverage existing Java knowledge
- Robust Testing: Comprehensive testing framework
- Spring Integration: Seamless Spring Boot integration
- Production Ready: Built-in best practices and patterns
Conclusion
The Java Operator SDK provides a powerful framework for building robust Kubernetes operators in Java that:
- Encodes operational knowledge for complex applications
- Provides type-safe custom resource management
- Integrates seamlessly with Kubernetes ecosystem
- Supports comprehensive testing and validation
- Enables production-grade operator development
By following this comprehensive guide, you can build sophisticated operators that automate the management of complex applications, reducing operational burden and increasing reliability in your Kubernetes environments.