DEV Community

shubham goel
shubham goel

Posted on

Building My First Kubernetes Controller in Java

In the previous post, I created my first Kubernetes Custom Resource called Greeting.

It looked like this:

apiVersion: platform.shubforge.dev/v1alpha1
kind: Greeting

metadata:
  name: hello

spec:
  message: "Hello from Platform Lab"
Enter fullscreen mode Exit fullscreen mode

After installing the CRD, Kubernetes understood the new resource.

I could run:

kubectl get greetings
Enter fullscreen mode Exit fullscreen mode

and Kubernetes could store, validate, update, and delete the resource.

But there was one important thing missing.

Nothing actually happened when I created a Greeting.

That is what I wanted to solve next.

What I Want to Build

For now, I want to keep the controller very simple.

Whenever I create:

apiVersion: platform.shubforge.dev/v1alpha1
kind: Greeting

metadata:
  name: hello

spec:
  message: "Hello from Platform Lab"
Enter fullscreen mode Exit fullscreen mode

I want the controller to create a ConfigMap like:

apiVersion: v1
kind: ConfigMap

metadata:
  name: hello-greeting

data:
  message: "Hello from Platform Lab"
Enter fullscreen mode Exit fullscreen mode

So the flow becomes:

Greeting
    |
    v
Greeting Controller
    |
    v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

This is a very small example, but it gives me a way to understand how Kubernetes controllers actually work.

Creating the Java Operator

For the controller, I am using Java and the Java Operator SDK.

I created a separate module inside the project:

operators/
└── greeting-operator/
    ├── pom.xml
    └── src/
        └── main/
            └── java/
Enter fullscreen mode Exit fullscreen mode

The Maven wrapper stays at the root of the repository:

platform-lab/
├── .mvn/
├── mvnw
├── Taskfile.yml
│
└── operators/
    └── greeting-operator/
        └── pom.xml
Enter fullscreen mode Exit fullscreen mode

This allows me to keep one Maven wrapper for the repository.

The operator can be built using:

./mvnw \
  -f operators/greeting-operator/pom.xml \
  clean package
Enter fullscreen mode Exit fullscreen mode

I have also wrapped this inside the Taskfile, so normally I just run:

task operator:build
Enter fullscreen mode Exit fullscreen mode

Representing the Greeting in Java

The first thing the controller needs is a Java representation of the Kubernetes resource.

The CRD already defines:

Group:   platform.shubforge.dev
Version: v1alpha1
Kind:    Greeting
Enter fullscreen mode Exit fullscreen mode

I represent the same thing in Java:

@Group("platform.shubforge.dev")
@Version("v1alpha1")
@Kind("Greeting")
@Plural("greetings")
public class Greeting
        extends CustomResource<GreetingSpec, GreetingStatus>
        implements Namespaced {
}
Enter fullscreen mode Exit fullscreen mode

The important part here is that this Java class represents the custom Kubernetes resource.

The spec is represented by another class:

public class GreetingSpec {

    private String message;

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}
Enter fullscreen mode Exit fullscreen mode

So this YAML:

spec:
  message: "Hello from Platform Lab"
Enter fullscreen mode Exit fullscreen mode

becomes:

greeting.getSpec().getMessage()
Enter fullscreen mode Exit fullscreen mode

inside the controller.

For now, I also created an empty status class:

public class GreetingStatus {
}
Enter fullscreen mode Exit fullscreen mode

I will come back to status handling later.

Describing the Desired ConfigMap

The next step is defining what should exist for a Greeting.

I created:

GreetingConfigMapDependentResource.java
Enter fullscreen mode Exit fullscreen mode

The main part looks like this:

@KubernetesDependent
public class GreetingConfigMapDependentResource
        extends CRUDKubernetesDependentResource<ConfigMap, Greeting> {

    public GreetingConfigMapDependentResource() {
        super(ConfigMap.class);
    }

    @Override
    protected ConfigMap desired(
            Greeting greeting,
            Context<Greeting> context) {

        var name = greeting.getMetadata().getName();
        var namespace = greeting.getMetadata().getNamespace();

        return new ConfigMapBuilder()
                .withNewMetadata()
                    .withName(name + "-greeting")
                    .withNamespace(namespace)
                    .addToLabels(
                        "app.kubernetes.io/managed-by",
                        "greeting-operator")
                .endMetadata()
                .addToData(
                    "message",
                    greeting.getSpec().getMessage())
                .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

I found the desired() method interesting.

Instead of thinking:

Does ConfigMap exist?

If not:
    create it

If it exists:
    compare it

If message changed:
    update it
Enter fullscreen mode Exit fullscreen mode

I describe what I want the ConfigMap to look like.

Greeting
    |
    v
desired()
    |
    v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

The controller framework then works toward keeping the actual state aligned with this desired state.

This is where reconciliation starts becoming easier to understand.

Creating the Reconciler

Next, I created the actual reconciler.

@Workflow(
    dependents = {
        @Dependent(
            type = GreetingConfigMapDependentResource.class
        )
    }
)
@ControllerConfiguration
public class GreetingReconciler
        implements Reconciler<Greeting> {

    private static final Logger log =
            LoggerFactory.getLogger(GreetingReconciler.class);

    @Override
    public UpdateControl<Greeting> reconcile(
            Greeting greeting,
            Context<Greeting> context) {

        log.info(
            "Reconciled Greeting {}/{}",
            greeting.getMetadata().getNamespace(),
            greeting.getMetadata().getName()
        );

        return UpdateControl.noUpdate();
    }
}
Enter fullscreen mode Exit fullscreen mode

At first this looked slightly strange to me.

There is no code here saying:

createConfigMap();
Enter fullscreen mode Exit fullscreen mode

That work is defined by the dependent resource.

The relationship is basically:

GreetingReconciler
        |
        v
GreetingConfigMapDependentResource
        |
        v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

The reconciler handles the Greeting, while the dependent resource describes the ConfigMap that should exist for it.

Starting the Operator

I then created a simple Java application:

public class GreetingOperatorApplication {

    public static void main(String[] args) {

        Operator operator = new Operator();

        operator.register(
            new GreetingReconciler()
        );

        operator.installShutdownHook();
        operator.start();
    }
}
Enter fullscreen mode Exit fullscreen mode

For now, I am running the controller locally on my machine.

So the setup currently looks like:

My Machine
    |
    | Java Process
    |
    v
Greeting Controller
    |
    | kubeconfig
    |
    v
Kind Kubernetes Cluster
Enter fullscreen mode Exit fullscreen mode

The controller uses my local Kubernetes configuration to communicate with the Kind cluster.

The operator itself is not running inside Kubernetes yet.

I want to keep that as a separate step.

Running the Controller

I added another command to the Taskfile:

task operator:run
Enter fullscreen mode Exit fullscreen mode

Internally it runs:

./mvnw \
  -f operators/greeting-operator/pom.xml \
  compile exec:java
Enter fullscreen mode Exit fullscreen mode

So the complete setup is now very small.

First create the cluster:

task cluster:create
Enter fullscreen mode Exit fullscreen mode

Install the CRD:

task crd:install
Enter fullscreen mode Exit fullscreen mode

Build the operator:

task operator:build
Enter fullscreen mode Exit fullscreen mode

Run it:

task operator:run
Enter fullscreen mode Exit fullscreen mode

I keep this terminal running.

Then from another terminal I create the Greeting:

task greeting:create
Enter fullscreen mode Exit fullscreen mode

and verify it:

task greeting:get
Enter fullscreen mode Exit fullscreen mode

The First Result

After creating the Greeting:

apiVersion: platform.shubforge.dev/v1alpha1
kind: Greeting

metadata:
  name: hello

spec:
  message: "Hello from Platform Lab"
Enter fullscreen mode Exit fullscreen mode

I can check the ConfigMaps:

kubectl get configmaps
Enter fullscreen mode Exit fullscreen mode

and now I see:

NAME             DATA   AGE
hello-greeting   1      10s
Enter fullscreen mode Exit fullscreen mode

I can inspect it:

kubectl get configmap hello-greeting -o yaml
Enter fullscreen mode Exit fullscreen mode

and see:

data:
  message: Hello from Platform Lab
Enter fullscreen mode Exit fullscreen mode

So now the complete flow is working:

Greeting YAML
      |
      v
Kubernetes API
      |
      v
Greeting Resource
      |
      v
Java Controller
      |
      v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

This was the point where the idea of a Kubernetes controller started becoming much clearer to me.

What Happens If I Change the Greeting?

Now I can change the resource:

spec:
  message: "Hello from the updated Greeting"
Enter fullscreen mode Exit fullscreen mode

and apply it again:

task greeting:create
Enter fullscreen mode Exit fullscreen mode

The desired state has changed.

The controller sees the new Greeting and updates the ConfigMap.

Now:

kubectl get configmap hello-greeting -o yaml
Enter fullscreen mode Exit fullscreen mode

contains:

data:
  message: Hello from the updated Greeting
Enter fullscreen mode Exit fullscreen mode

So we now have:

Greeting changed
      |
      v
Controller reconciles
      |
      v
ConfigMap changed
Enter fullscreen mode Exit fullscreen mode

What If I Delete the ConfigMap?

This is the part I found even more interesting.

I manually delete the ConfigMap:

kubectl delete configmap hello-greeting
Enter fullscreen mode Exit fullscreen mode

At this point the actual state no longer matches what the controller expects.

The Greeting still exists:

Greeting: hello
Enter fullscreen mode Exit fullscreen mode

and according to our controller, this means this ConfigMap should exist:

ConfigMap: hello-greeting
Enter fullscreen mode Exit fullscreen mode

The controller notices that the managed resource is missing and creates it again.

So instead of:

Create ConfigMap once
Enter fullscreen mode Exit fullscreen mode

the behavior is closer to:

Desired State
     |
     v
Compare with Actual State
     |
     v
Make them match
Enter fullscreen mode Exit fullscreen mode

This is reconciliation.

CRD vs Controller

This experiment also helped me understand the difference between a CRD and a controller.

The CRD gives Kubernetes the API:

Greeting
Enter fullscreen mode Exit fullscreen mode

The controller gives that API behavior:

Greeting
    |
    v
Controller
    |
    v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

Without the controller:

Greeting
    |
    v
Stored in Kubernetes
Enter fullscreen mode Exit fullscreen mode

With the controller:

Greeting
    |
    v
Something actually happens
Enter fullscreen mode Exit fullscreen mode

That distinction was probably the most useful part of this experiment for me.

Why I Am Using a Taskfile

As I add more steps, the number of commands also starts increasing.

Without the Taskfile, I would need to remember things like:

kind create cluster ...
kubectl apply -f ...
./mvnw -f operators/greeting-operator/pom.xml ...
kubectl get ...
Enter fullscreen mode Exit fullscreen mode

Instead, I now have commands like:

task cluster:create
task crd:install
task operator:build
task operator:run
task greeting:create
task greeting:get
Enter fullscreen mode Exit fullscreen mode

I can see everything available using:

task --list
Enter fullscreen mode Exit fullscreen mode

The Taskfile is not doing anything special to Kubernetes.

It is just giving the repository a simple and consistent developer interface.

As the project grows, I want to keep most common operations available through:

task <something>
Enter fullscreen mode Exit fullscreen mode

instead of maintaining setup commands separately.

Source Code

I am keeping the complete implementation in my Platform Lab repository.

Repository: Platform Lab

The repository contains the complete project setup, including:

  • Kind cluster configuration
  • SDKMAN Java setup
  • Taskfile
  • Greeting CRD
  • sample Greeting
  • Java controller
  • ConfigMap dependent resource

The changes for this post can be seen separately in:

Pull Request: Add Greeting Controller

This makes it easier to see exactly what changed at this stage without looking through later changes in the repository.

What's Next?

Right now, the controller runs as a Java process on my machine:

Laptop
   |
   v
Java Controller
   |
   v
Kind Cluster
Enter fullscreen mode Exit fullscreen mode

The next thing I want to do is run the controller inside Kubernetes itself.

That will introduce a few new pieces:

Greeting Operator
      |
      v
Docker Image
      |
      v
Kubernetes Deployment
      |
      v
ServiceAccount
      |
      v
RBAC
Enter fullscreen mode Exit fullscreen mode

I also want to understand why the controller needs permissions to watch Greeting resources and create ConfigMaps.

After that, I can start looking more deeply into reconciliation, status, error handling, and testing.

For now, the small flow is working:

Greeting
    |
    v
Java Controller
    |
    v
ConfigMap
Enter fullscreen mode Exit fullscreen mode

And that already makes Kubernetes operators much easier for me to understand.

Top comments (0)