codeWithYoha logo
Code with Yoha
HomeArticlesAboutContact
Kubernetes

Building Production-Ready Kubernetes Operators with Java & Fabric8

CodeWithYoha
CodeWithYoha
18 min read
Building Production-Ready Kubernetes Operators with Java & Fabric8

Introduction

The Kubernetes ecosystem thrives on automation. While Kubernetes itself provides powerful primitives for managing containerized applications, many complex, domain-specific operational tasks still require manual intervention. This is where Kubernetes Operators come into play. Operators are a method of packaging, deploying, and managing a Kubernetes application. They extend the Kubernetes API with custom resources (CRDs) and automate operational tasks, effectively turning human operational knowledge into code.

Building robust, production-ready Operators requires careful design and implementation. For Java developers, the Fabric8 Kubernetes Client offers a powerful and idiomatic way to interact with the Kubernetes API. This comprehensive guide will walk you through the process of building a production-grade Kubernetes Operator using Java and the Fabric8 client, covering everything from Custom Resource Definition (CRD) design to advanced reconciliation logic, best practices, and deployment strategies.

Prerequisites

Before diving into the implementation, ensure you have the following:

  • Java Development Kit (JDK) 11 or higher
  • Maven or Gradle for dependency management
  • A running Kubernetes cluster (e.g., Minikube, Kind, or a cloud-managed cluster)
  • Basic understanding of Kubernetes concepts (Pods, Deployments, Services, etc.)
  • kubectl configured to interact with your cluster

Understanding Kubernetes Operators

At their core, Kubernetes Operators follow the controller pattern. They continuously watch the Kubernetes API for changes to specific resource types (often Custom Resources), compare the observed state with the desired state (defined in the CR), and then take actions to reconcile any differences. This allows Operators to automate complex tasks such as:

  • Application Deployment and Management: Deploying multi-component applications, handling upgrades, rollbacks.
  • Backup and Restore: Automating data backup and recovery processes for stateful applications.
  • Database Management: Provisioning databases, managing users, scaling, and high availability.
  • Complex Configuration: Managing application-specific configurations that go beyond standard ConfigMaps.
  • Day 2 Operations: Self-healing, scaling, monitoring integration, and lifecycle management.

Operators extend Kubernetes' declarative nature, allowing users to declare what they want (e.g., "I want a MyDatabase instance of version X with Y replicas"), and the Operator handles how to achieve and maintain that state.

Introducing Custom Resource Definitions (CRDs)

The foundation of any Kubernetes Operator is the Custom Resource Definition (CRD). A CRD allows you to define a new, custom resource type in your Kubernetes cluster, making it first-class citizen alongside built-in resources like Pods and Deployments. These custom resources (CRs) are instances of your CRD.

When designing your CRD, you define its schema, validation rules, and scope (namespaced or cluster-scoped). The schema is crucial for ensuring that users provide valid configurations for your custom resource.

Here's an example of a simple CRD for a hypothetical MyApp:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.stable.example.com
spec:
  group: stable.example.com
  names:
    plural: myapps
    singular: myapp
    kind: MyApp
    shortNames: [ma]
  scope: Namespaced
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                image: { type: string, description: "The Docker image to deploy." }
                replicas: { type: integer, minimum: 1, description: "Number of replicas." }
                port: { type: integer, minimum: 80, maximum: 65535, description: "Port to expose." }
              required:
                - image
                - replicas
                - port
            status:
              type: object
              properties:
                availableReplicas: { type: integer }
                conditions:
                  type: array
                  items:
                    type: object
                    properties:
                      type: { type: string }
                      status: { type: string }
                      message: { type: string }

To deploy this CRD to your cluster, save it as myapp-crd.yaml and run kubectl apply -f myapp-crd.yaml.

Fabric8 Kubernetes Client for Java

The Fabric8 Kubernetes Client is a powerful, fluent, and comprehensive Java client for Kubernetes and OpenShift. It provides a type-safe API, making it easy to interact with Kubernetes resources using Java objects. It handles connection management, authentication, and API versioning, abstracting away much of the complexity of the Kubernetes REST API.

To include the Fabric8 client in your Maven project, add the following dependency:

<dependencies>
    <dependency>
        <groupId>io.fabric8</groupId>
        <artifactId>kubernetes-client</artifactId>
        <version>6.11.0</version> <!-- Use the latest stable version -->
    </dependency>
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-simple</artifactId>
        <version>2.0.7</version> <!-- Or your preferred logging implementation -->
    </dependency>
</dependencies>

For Gradle:

dependencies {
    implementation 'io.fabric8:kubernetes-client:6.11.0' // Use the latest stable version
    implementation 'org.slf4j:slf4j-simple:2.0.7' // Or your preferred logging implementation
}

Designing Your Custom Resource (CR) Java Objects

Once your CRD is defined, you need corresponding Java POJOs (Plain Old Java Objects) to represent your custom resources. These POJOs will mirror the spec and status fields defined in your CRD schema. Fabric8 provides base classes (CustomResource, CustomResourceList) that simplify this.

Let's create the Java classes for our MyApp CRD:

package com.example.operator.myapp;

import io.fabric8.kubernetes.api.model.Namespaced; // For namespaced resources
import io.fabric8.kubernetes.client.CustomResource;
import io.fabric8.kubernetes.model.annotation.Group;
import io.fabric8.kubernetes.model.annotation.Version;

@Version("v1")
@Group("stable.example.com")
public class MyApp extends CustomResource<MyAppSpec, MyAppStatus> implements Namespaced {
    // Fabric8 automatically handles metadata, apiVersion, kind
}

// MyAppSpec.java
package com.example.operator.myapp;

import java.util.Objects;

public class MyAppSpec {
    private String image;
    private Integer replicas;
    private Integer port;

    // Getters and Setters
    public String getImage() { return image; }
    public void setImage(String image) { this.image = image; }

    public Integer getReplicas() { return replicas; }
    public void setReplicas(Integer replicas) { this.replicas = replicas; }

    public Integer getPort() { return port; }
    public void setPort(Integer port) { this.port = port; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MyAppSpec myAppSpec = (MyAppSpec) o;
        return Objects.equals(image, myAppSpec.image) &&
               Objects.equals(replicas, myAppSpec.replicas) &&
               Objects.equals(port, myAppSpec.port);
    }

    @Override
    public int hashCode() {
        return Objects.hash(image, replicas, port);
    }
}

// MyAppStatus.java
package com.example.operator.myapp;

import java.util.List;
import java.util.Objects;

public class MyAppStatus {
    private Integer availableReplicas;
    private List<MyAppCondition> conditions;

    // Getters and Setters
    public Integer getAvailableReplicas() { return availableReplicas; }
    public void setAvailableReplicas(Integer availableReplicas) { this.availableReplicas = availableReplicas; }

    public List<MyAppCondition> getConditions() { return conditions; }
    public void setConditions(List<MyAppCondition> conditions) { this.conditions = conditions; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MyAppStatus that = (MyAppStatus) o;
        return Objects.equals(availableReplicas, that.availableReplicas) &&
               Objects.equals(conditions, that.conditions);
    }

    @Override
    public int hashCode() {
        return Objects.hash(availableReplicas, conditions);
    }
}

// MyAppCondition.java (Helper for status)
package com.example.operator.myapp;

import java.util.Objects;

public class MyAppCondition {
    private String type;
    private String status; // e.g., "True", "False", "Unknown"
    private String message;

    // Getters and Setters
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }

    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }

    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        MyAppCondition that = (MyAppCondition) o;
        return Objects.equals(type, that.type) &&
               Objects.equals(status, that.status) &&
               Objects.equals(message, that.message);
    }

    @Override
    public int hashCode() {
        return Objects.hash(type, status, message);
    }
}

Notice the @Group and @Version annotations on MyApp. These are crucial for Fabric8 to correctly map your Java object to the corresponding CRD.

Building the Operator Controller

The core of your Operator is the controller, which implements the reconciliation loop. Fabric8's KubernetesClient provides various ways to watch resources, but for Operators, the SharedIndexInformer pattern is highly recommended. Informers provide a local cache of resources, reducing API server load and enabling efficient event processing.

package com.example.operator.controller;

import com.example.operator.myapp.MyApp;
import com.example.operator.myapp.MyAppSpec;
import com.example.operator.myapp.MyAppStatus;
import io.fabric8.kubernetes.api.model.apps.Deployment;
import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
import io.fabric8.kubernetes.api.model.Service;
import io.fabric8.kubernetes.api.model.ServiceBuilder;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.informers.ResourceEventHandler;
import io.fabric8.kubernetes.client.informers.SharedIndexInformer;
import io.fabric8.kubernetes.client.informers.SharedInformerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

public class MyAppOperatorController {

    private static final Logger logger = LoggerFactory.getLogger(MyAppOperatorController.class);
    private final KubernetesClient client;
    private final SharedInformerFactory informerFactory;

    public MyAppOperatorController(KubernetesClient client) {
        this.client = client;
        this.informerFactory = client.informers();
    }

    public void start() {
        logger.info("Starting MyApp Operator Controller...");

        // Get a CustomResourceClient for MyApp
        // This allows interacting with MyApp resources using the Fabric8 client
        var myAppClient = client.resources(MyApp.class);

        SharedIndexInformer<MyApp> myAppInformer = informerFactory.sharedIndexInformerFor(MyApp.class, Duration.ofMinutes(1));

        myAppInformer.addEventHandler(new ResourceEventHandler<MyApp>() {
            @Override
            public void onAdd(MyApp myApp) {
                logger.info("MyApp {}/{} added. Reconciling...", myApp.getMetadata().getNamespace(), myApp.getMetadata().getName());
                reconcile(myApp);
            }

            @Override
            public void onUpdate(MyApp oldMyApp, MyApp newMyApp) {
                // Only reconcile if spec has changed to avoid unnecessary work
                if (!oldMyApp.getSpec().equals(newMyApp.getSpec())) {
                    logger.info("MyApp {}/{} updated. Reconciling...", newMyApp.getMetadata().getNamespace(), newMyApp.getMetadata().getName());
                    reconcile(newMyApp);
                } else {
                    logger.debug("MyApp {}/{} updated, but spec unchanged. Skipping reconciliation.", newMyApp.getMetadata().getNamespace(), newMyApp.getMetadata().getName());
                }
            }

            @Override
            public void onDelete(MyApp myApp, boolean deletedFinalStateUnknown) {
                logger.info("MyApp {}/{} deleted. Reconciling...", myApp.getMetadata().getNamespace(), myApp.getMetadata().getName());
                // For deletion, we might clean up dependent resources
                cleanup(myApp);
            }
        });

        informerFactory.startAllRegisteredInformers();
        logger.info("MyApp Operator Controller started watching for MyApp resources.");
    }

    // Main reconciliation logic will go here
    private void reconcile(MyApp myApp) {
        // ... implementation in the next section ...
    }

    // Cleanup logic for deleted resources
    private void cleanup(MyApp myApp) {
        // Delete associated Deployment and Service
        String appName = myApp.getMetadata().getName();
        String namespace = myApp.getMetadata().getNamespace();

        logger.info("Cleaning up resources for MyApp {}/{}...", namespace, appName);

        client.apps().deployments().inNamespace(namespace).withName(appName).delete();
        client.services().inNamespace(namespace).withName(appName).delete();

        logger.info("Cleaned up Deployment and Service for MyApp {}/{}", namespace, appName);
    }

    public static void main(String[] args) {
        try (KubernetesClient client = new io.fabric8.kubernetes.client.DefaultKubernetesClient()) {
            MyAppOperatorController controller = new MyAppOperatorController(client);
            controller.start();
            // Keep the main thread alive to allow informers to run
            Thread.currentThread().join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            logger.error("Operator interrupted: {}", e.getMessage());
        } catch (Exception e) {
            logger.error("Error starting operator: {}", e.getMessage(), e);
        }
    }
}

In this basic setup:

  • We initialize a KubernetesClient and a SharedInformerFactory.
  • We create a SharedIndexInformer for our MyApp custom resource.
  • We add an ResourceEventHandler to react to onAdd, onUpdate, and onDelete events.
  • The onUpdate method includes a crucial check to only reconcile if the spec of the MyApp resource has actually changed. This prevents infinite loops if only the status is updated by the operator itself.
  • The cleanup method demonstrates how to delete child resources when a MyApp instance is deleted.

Implementing the Reconciliation Logic

The reconcile method is where the core logic of your Operator resides. It compares the desired state (from MyApp.spec) with the observed state (actual Kubernetes resources) and takes corrective actions.

For our MyApp example, the reconciliation will involve ensuring a Kubernetes Deployment and Service exist and match the MyApp's spec.

Let's fill in the reconcile method in MyAppOperatorController:

    private void reconcile(MyApp myApp) {
        String appName = myApp.getMetadata().getName();
        String namespace = myApp.getMetadata().getNamespace();
        MyAppSpec spec = myApp.getSpec();

        logger.info("Reconciling MyApp {}/{} with spec: image={}, replicas={}, port={}",
                namespace, appName, spec.getImage(), spec.getReplicas(), spec.getPort());

        // 1. Ensure Deployment exists and matches spec
        Deployment desiredDeployment = createDesiredDeployment(appName, namespace, spec, myApp);
        Deployment currentDeployment = client.apps().deployments().inNamespace(namespace).withName(appName).get();

        if (currentDeployment == null) {
            logger.info("Creating Deployment {}/{}...", namespace, appName);
            client.apps().deployments().inNamespace(namespace).resource(desiredDeployment).create();
        } else if (!isDeploymentEqual(currentDeployment, desiredDeployment)) {
            logger.info("Updating Deployment {}/{}...", namespace, appName);
            client.apps().deployments().inNamespace(namespace)
                  .resource(desiredDeployment)
                  .replace(); // Use replace for full update
        } else {
            logger.debug("Deployment {}/{} is up-to-date.", namespace, appName);
        }

        // 2. Ensure Service exists and matches spec
        Service desiredService = createDesiredService(appName, namespace, spec, myApp);
        Service currentService = client.services().inNamespace(namespace).withName(appName).get();

        if (currentService == null) {
            logger.info("Creating Service {}/{}...", namespace, appName);
            client.services().inNamespace(namespace).resource(desiredService).create();
        } else if (!isServiceEqual(currentService, desiredService)) {
            logger.info("Updating Service {}/{}...", namespace, appName);
            client.services().inNamespace(namespace)
                  .resource(desiredService)
                  .replace();
        } else {
            logger.debug("Service {}/{} is up-to-date.", namespace, appName);
        }

        // 3. Update MyApp status based on observed state
        updateMyAppStatus(myApp, currentDeployment);
    }

    // Helper to create the desired Deployment object
    private Deployment createDesiredDeployment(String appName, String namespace, MyAppSpec spec, MyApp ownerRef) {
        Map<String, String> labels = Collections.singletonMap("app", appName);
        return new DeploymentBuilder()
                .withNewMetadata()
                    .withName(appName)
                    .withNamespace(namespace)
                    .withLabels(labels)
                    .addNewOwnerReference() // Crucial for garbage collection
                        .withApiVersion(ownerRef.getApiVersion())
                        .withKind(ownerRef.getKind())
                        .withName(ownerRef.getMetadata().getName())
                        .withUid(ownerRef.getMetadata().getUid())
                        .withController(true)
                        .withBlockOwnerDeletion(true)
                    .endOwnerReference()
                .endMetadata()
                .withNewSpec()
                    .withReplicas(spec.getReplicas())
                    .withNewSelector().withMatchLabels(labels).endSelector()
                    .withNewTemplate()
                        .withNewMetadata().withLabels(labels).endMetadata()
                        .withNewSpec()
                            .addNewContainer()
                                .withName(appName)
                                .withImage(spec.getImage())
                                .addNewPort()
                                    .withContainerPort(spec.getPort())
                                .endPort()
                            .endContainer()
                        .endSpec()
                    .endTemplate()
                .endSpec()
                .build();
    }

    // Helper to create the desired Service object
    private Service createDesiredService(String appName, String namespace, MyAppSpec spec, MyApp ownerRef) {
        Map<String, String> labels = Collections.singletonMap("app", appName);
        return new ServiceBuilder()
                .withNewMetadata()
                    .withName(appName)
                    .withNamespace(namespace)
                    .withLabels(labels)
                    .addNewOwnerReference()
                        .withApiVersion(ownerRef.getApiVersion())
                        .withKind(ownerRef.getKind())
                        .withName(ownerRef.getMetadata().getName())
                        .withUid(ownerRef.getMetadata().getUid())
                        .withController(true)
                        .withBlockOwnerDeletion(true)
                    .endOwnerReference()
                .endMetadata()
                .withNewSpec()
                    .withSelector(labels)
                    .addNewPort()
                        .withProtocol("TCP")
                        .withPort(spec.getPort())
                        .withTargetPort(spec.getPort())
                    .endPort()
                    .withType("ClusterIP") // Or NodePort, LoadBalancer
                .endSpec()
                .build();
    }

    // Simple equality check for Deployment spec. In real world, use deep comparison or hash.
    private boolean isDeploymentEqual(Deployment current, Deployment desired) {
        return current.getSpec().getReplicas().equals(desired.getSpec().getReplicas()) &&
               current.getSpec().getTemplate().getSpec().getContainers().get(0).getImage().equals(desired.getSpec().getTemplate().getSpec().getContainers().get(0).getImage());
        // Add more comprehensive checks for volumes, env vars, etc.
    }

    // Simple equality check for Service spec.
    private boolean isServiceEqual(Service current, Service desired) {
        return current.getSpec().getPorts().get(0).getPort().equals(desired.getSpec().getPorts().get(0).getPort()) &&
               current.getSpec().getPorts().get(0).getTargetPort().getIntVal().equals(desired.getSpec().getPorts().get(0).getTargetPort().getIntVal());
        // Add more comprehensive checks
    }

Key points in the reconciliation logic:

  • Idempotency: The reconcile method should be idempotent. Calling it multiple times with the same desired state should produce the same result without unintended side effects. We achieve this by checking if resources already exist and only creating/updating if necessary.
  • Owner References: When creating dependent resources (Deployment, Service), it's crucial to set an OwnerReference pointing back to the MyApp custom resource. This enables Kubernetes' garbage collector to automatically delete dependent resources when the MyApp instance is deleted.
  • Desired vs. Current State: Fetch the current state of resources from the Kubernetes API, compare it with the desired state derived from the MyApp.spec, and apply changes.
  • Replace vs. Patch: For simple updates, replace() works. For more complex, granular updates, patch() might be more efficient, but requires careful handling of JSON Patch or Merge Patch.

Handling Status Updates

Updating the status field of your custom resource is vital for providing feedback to users and for other controllers or tools that might depend on your Operator. The status should reflect the actual state of the resources managed by your Operator.

Let's add the updateMyAppStatus method:

    private void updateMyAppStatus(MyApp myApp, Deployment currentDeployment) {
        String appName = myApp.getMetadata().getName();
        String namespace = myApp.getMetadata().getNamespace();

        MyAppStatus status = new MyAppStatus();
        if (currentDeployment != null && currentDeployment.getStatus() != null) {
            status.setAvailableReplicas(currentDeployment.getStatus().getAvailableReplicas());

            // Set conditions based on Deployment status
            if (currentDeployment.getStatus().getConditions() != null) {
                List<MyAppCondition> conditions = currentDeployment.getStatus().getConditions().stream()
                    .map(depCondition -> {
                        MyAppCondition myAppCondition = new MyAppCondition();
                        myAppCondition.setType(depCondition.getType());
                        myAppCondition.setStatus(depCondition.getStatus());
                        myAppCondition.setMessage(depCondition.getMessage());
                        return myAppCondition;
                    })
                    .collect(Collectors.toList());
                status.setConditions(conditions);
            }
        } else {
            status.setAvailableReplicas(0);
            MyAppCondition pending = new MyAppCondition();
            pending.setType("Available");
            pending.setStatus("False");
            pending.setMessage("Deployment not yet created or status unavailable.");
            status.setConditions(Collections.singletonList(pending));
        }

        // Only update if status has actually changed
        if (!Objects.equals(myApp.getStatus(), status)) {
            logger.info("Updating status for MyApp {}/{}. New status: availableReplicas={}",
                    namespace, appName, status.getAvailableReplicas());
            myApp.setStatus(status);
            client.resources(MyApp.class).inNamespace(namespace).resource(myApp).updateStatus();
        } else {
            logger.debug("Status for MyApp {}/{} is up-to-date. Skipping update.", namespace, appName);
        }
    }

Key considerations for status updates:

  • Reflect Actual State: The status should accurately reflect the state of the managed resources, not just the desired state.
  • Conditions: Use conditions (e.g., Available, Progressing, Degraded) to provide high-level status information.
  • Avoid Infinite Loops: Ensure that updating the status does not trigger another onUpdate event for the MyApp resource's spec. Fabric8's updateStatus() method specifically updates only the status subresource, which typically does not trigger spec changes.
  • Idempotency: Only update the status if it has genuinely changed.

Best Practices for Production Operators

Building an Operator for production requires more than just functional code. Here are some best practices:

  1. Idempotency: Every reconciliation step must be idempotent. Applying the same desired state multiple times should yield the same result without side effects. This is critical for resilience and recovery.
  2. Error Handling and Retries: Operators must gracefully handle API errors, network issues, and transient failures. Implement retry mechanisms with exponential backoff for API calls. Fabric8 client has built-in retry mechanisms, but you might need custom logic for your reconciliation steps.
  3. Logging and Metrics: Implement comprehensive logging (using SLF4J, Logback, etc.) to trace reconciliation steps, errors, and events. Expose Prometheus metrics to monitor the Operator's health, reconciliation loop duration, and resource counts.
  4. Resource Ownership and Garbage Collection: Always set OwnerReference on dependent resources. This ensures that when a custom resource is deleted, all its managed resources are also cleaned up by Kubernetes' garbage collector.
  5. Immutability of Spec: Treat the spec of your custom resource as immutable within your reconciliation logic. Only read from it; never modify it. All changes to the desired state should come from the user updating the CR.
  6. Status as Source of Truth: The status field should be the single source of truth for the observed state of your application. Other controllers or external systems should rely on the status rather than directly querying dependent resources.
  7. Testing: Implement unit tests for your reconciliation logic and integration tests that deploy your Operator to a test cluster (e.g., Kind) and verify its behavior with actual CRs.
  8. Resource Limits and Requests: Configure appropriate CPU and memory limits/requests for your Operator's Pod to prevent it from consuming excessive resources or being throttled.
  9. Security Context: Run your Operator with the least privileges necessary. Use a dedicated ServiceAccount and Role-Based Access Control (RBAC) to grant only the required permissions to manage specific resource types.
  10. Finalizers: For complex cleanup (e.g., external database deprovisioning), use Kubernetes Finalizers. When a resource with a finalizer is marked for deletion, Kubernetes doesn't remove it until all finalizers are removed by the controller. This allows the Operator to perform cleanup before the resource is fully gone.

Common Pitfalls and How to Avoid Them

  1. Infinite Reconciliation Loops: If your operator updates the spec of its own custom resource or if your onUpdate handler doesn't properly differentiate between spec and status changes, you can trigger endless reconciliation loops. Avoid: Only update the status subresource. In onUpdate, compare oldMyApp.getSpec() with newMyApp.getSpec().
  2. Race Conditions: Multiple instances of your operator or concurrent updates can lead to race conditions. Avoid: Kubernetes' optimistic concurrency control (using resourceVersion) helps, but your reconciliation logic should be robust. Consider leader election for singleton operators or design for eventual consistency.
  3. Resource Leaks: Failing to clean up dependent resources when a custom resource is deleted. Avoid: Always use OwnerReference for managed Kubernetes resources. For external resources, implement finalizers.
  4. Privilege Escalation: Granting your Operator excessive permissions. Avoid: Follow the principle of least privilege. Create a specific ServiceAccount and Role/ClusterRole with only the necessary verbs (get, list, watch, create, update, patch, delete) on the required resources and apiGroups.
  5. Blocking Operations: Performing long-running synchronous operations within the reconciliation loop. This can starve the informer and prevent the Operator from reacting to other events. Avoid: If external calls are slow, consider offloading them to a work queue or using asynchronous patterns.
  6. Ignoring API Errors: Not handling KubernetesClientException or other API errors can lead to an unhealthy operator that fails silently. Avoid: Implement robust try-catch blocks and retry logic.

Deployment and Testing

To deploy your Operator to a Kubernetes cluster, you'll typically containerize it as a Docker image and then deploy it using Kubernetes Deployment and RBAC resources.

Dockerfile Example

# Use a slim JRE base image
FROM openjdk:17-jre-slim

# Set working directory
WORKDIR /app

# Copy the built JAR file
# Assuming your Maven build produces a JAR in target/
COPY target/my-operator-1.0-SNAPSHOT-jar-with-dependencies.jar my-operator.jar

# Expose any ports if your operator has a metrics endpoint (e.g., Prometheus)
# EXPOSE 8080

# Command to run the operator
CMD ["java", "-jar", "my-operator.jar"]

Build the Docker image: docker build -t your-repo/myapp-operator:v1.0.0 . Push to a registry: docker push your-repo/myapp-operator:v1.0.0

Kubernetes Deployment and RBAC

You'll need a ServiceAccount, Role (or ClusterRole), RoleBinding (or ClusterRoleBinding), and a Deployment for your Operator.

# rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp-operator-sa
  namespace: default # Or your operator's namespace
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: myapp-operator-role
rules:
  - apiGroups: ["stable.example.com"] # Your CRD's API group
    resources: ["myapps", "myapps/status", "myapps/finalizers"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: ["apps"] # For Deployments
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""] # For Services, Pods (if needed), Events
    resources: ["services", "pods", "events"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: myapp-operator-binding
subjects:
  - kind: ServiceAccount
    name: myapp-operator-sa
    namespace: default # Must match ServiceAccount namespace
roleRef:
  kind: ClusterRole
  name: myapp-operator-role
  apiGroup: rbac.authorization.k8s.io
---
# operator-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-operator
  namespace: default
  labels:
    app: myapp-operator
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp-operator
  template:
    metadata:
      labels:
        app: myapp-operator
    spec:
      serviceAccountName: myapp-operator-sa
      containers:
        - name: operator
          image: your-repo/myapp-operator:v1.0.0 # Replace with your image
          imagePullPolicy: Always
          # Add resource limits/requests for production
          # resources:
          #   limits:
          #     cpu: "500m"
          #     memory: "512Mi"
          #   requests:
          #     cpu: "200m"
          #     memory: "256Mi"

Deploy these manifests: kubectl apply -f rbac.yaml -f operator-deployment.yaml

Testing Your Operator

After deployment, you can create instances of your MyApp custom resource and observe your Operator's behavior:

# myapp-instance.yaml
apiVersion: stable.example.com/v1
kind: MyApp
metadata:
  name: myapp-demo
  namespace: default
spec:
  image: nginx:latest
  replicas: 3
  port: 80

Apply the custom resource: kubectl apply -f myapp-instance.yaml

Then, verify:

  • kubectl get myapp myapp-demo (check status)
  • kubectl get deployment myapp-demo
  • kubectl get service myapp-demo
  • kubectl logs -f deployment/myapp-operator (to see operator logs)

Experiment with updating the replicas or image in myapp-instance.yaml and re-applying it to observe the update logic.

Conclusion

Building Kubernetes Operators with Java and the Fabric8 client empowers you to automate complex operational tasks and extend Kubernetes' capabilities in a type-safe and idiomatic way. By following the controller pattern, designing robust CRDs, implementing careful reconciliation logic, and adhering to best practices, you can create production-ready Operators that enhance the reliability and efficiency of your cloud-native applications.

This guide provided a solid foundation, from CRD definition and Java object mapping to controller implementation, status management, and deployment. As you build more complex Operators, delve deeper into advanced topics like finalizers for complex cleanup, webhooks for admission control and validation, and robust testing frameworks to ensure your Operator is truly production-grade.

CodewithYoha

Written by

CodewithYoha

Full-Stack Software Engineer with 5+ years of experience in Java, Spring Boot, and cloud architecture across AWS, Azure, and GCP. Writing production-grade engineering patterns for developers who ship real software.

Related Articles