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"
After installing the CRD, Kubernetes understood the new resource.
I could run:
kubectl get greetings
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"
I want the controller to create a ConfigMap like:
apiVersion: v1
kind: ConfigMap
metadata:
name: hello-greeting
data:
message: "Hello from Platform Lab"
So the flow becomes:
Greeting
|
v
Greeting Controller
|
v
ConfigMap
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/
The Maven wrapper stays at the root of the repository:
platform-lab/
├── .mvn/
├── mvnw
├── Taskfile.yml
│
└── operators/
└── greeting-operator/
└── pom.xml
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
I have also wrapped this inside the Taskfile, so normally I just run:
task operator:build
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
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 {
}
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;
}
}
So this YAML:
spec:
message: "Hello from Platform Lab"
becomes:
greeting.getSpec().getMessage()
inside the controller.
For now, I also created an empty status class:
public class GreetingStatus {
}
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
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();
}
}
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
I describe what I want the ConfigMap to look like.
Greeting
|
v
desired()
|
v
ConfigMap
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();
}
}
At first this looked slightly strange to me.
There is no code here saying:
createConfigMap();
That work is defined by the dependent resource.
The relationship is basically:
GreetingReconciler
|
v
GreetingConfigMapDependentResource
|
v
ConfigMap
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();
}
}
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
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
Internally it runs:
./mvnw \
-f operators/greeting-operator/pom.xml \
compile exec:java
So the complete setup is now very small.
First create the cluster:
task cluster:create
Install the CRD:
task crd:install
Build the operator:
task operator:build
Run it:
task operator:run
I keep this terminal running.
Then from another terminal I create the Greeting:
task greeting:create
and verify it:
task greeting:get
The First Result
After creating the Greeting:
apiVersion: platform.shubforge.dev/v1alpha1
kind: Greeting
metadata:
name: hello
spec:
message: "Hello from Platform Lab"
I can check the ConfigMaps:
kubectl get configmaps
and now I see:
NAME DATA AGE
hello-greeting 1 10s
I can inspect it:
kubectl get configmap hello-greeting -o yaml
and see:
data:
message: Hello from Platform Lab
So now the complete flow is working:
Greeting YAML
|
v
Kubernetes API
|
v
Greeting Resource
|
v
Java Controller
|
v
ConfigMap
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"
and apply it again:
task greeting:create
The desired state has changed.
The controller sees the new Greeting and updates the ConfigMap.
Now:
kubectl get configmap hello-greeting -o yaml
contains:
data:
message: Hello from the updated Greeting
So we now have:
Greeting changed
|
v
Controller reconciles
|
v
ConfigMap changed
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
At this point the actual state no longer matches what the controller expects.
The Greeting still exists:
Greeting: hello
and according to our controller, this means this ConfigMap should exist:
ConfigMap: hello-greeting
The controller notices that the managed resource is missing and creates it again.
So instead of:
Create ConfigMap once
the behavior is closer to:
Desired State
|
v
Compare with Actual State
|
v
Make them match
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
The controller gives that API behavior:
Greeting
|
v
Controller
|
v
ConfigMap
Without the controller:
Greeting
|
v
Stored in Kubernetes
With the controller:
Greeting
|
v
Something actually happens
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 ...
Instead, I now have commands like:
task cluster:create
task crd:install
task operator:build
task operator:run
task greeting:create
task greeting:get
I can see everything available using:
task --list
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>
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
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
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
And that already makes Kubernetes operators much easier for me to understand.
Top comments (0)