Building Your First Kubernetes Controller with Kubebuilder: A Pod Annotator
If you already know kubectl and Kubernetes YAML but are new to Go and Kubernetes controllers, building a small controller is one of the best ways to understand how Kubernetes operators actually work. In this tutorial,…
If you already know kubectl and Kubernetes YAML but are new to Go and Kubernetes controllers, building a small controller is one of the best ways to understand how Kubernetes operators actually work.
In this tutorial, we’ll build a Pod Annotator controller using Kubebuilder.
Our controller will watch Kubernetes Pods and automatically add an annotation when a Pod matches a specific namespace and label.
For our example, the controller will look for:
- Namespace:
tenant - Label key:
prometheus
When both conditions match, the controller adds:
metadata: annotations: telemetry-compass.com/reconciled: "true"
This project is deliberately small.
Instead of immediately jumping into Custom Resource Definitions (CRDs), we’ll first learn the most important concept behind Kubernetes operators:
The reconciliation loop.
By the end of this tutorial, we’ll have a controller running inside Kubernetes and automatically modifying matching Pods.
What Are We Building?
The desired behavior is simple:
Pod Created / Updated | vController sees event | vIs namespace "tenant"? | Yes | vDoes Pod have the "prometheus" label? | Yes | vDoes it already havetelemetry-compass.com/reconciled="true"? | No | vAdd annotation | vUpdate Pod
For example, suppose this Pod is created:
apiVersion: v1kind: Podmetadata: name: prometheus-test namespace: tenant labels: prometheus: prometheus-serverspec: containers: - name: nginx image: nginx
Our controller should automatically add:
metadata: annotations: telemetry-compass.com/reconciled: "true" labels: prometheus: prometheus-server
Nobody needs to manually run kubectl annotate.
The controller does it for us.
1. What Is a Kubernetes Controller?
Before touching Kubebuilder, it helps to understand what we are actually building.
Think of a Kubernetes controller like a thermostat.
You configure a thermostat with:
Keep the room at 22°C.
The thermostat continuously compares:
Desired temperature: 22°CActual temperature: 19°C
There is a difference, so the thermostat turns on the heating.
Eventually:
Desired temperature: 22°CActual temperature: 22°C
Now it doesn’t need to do anything.
A Kubernetes controller follows essentially the same pattern:
Observe current state | vCompare with desired state | vSomething wrong? | Yes | vMake a change
This process is called reconciliation.
Our desired state is:
Every Pod in the
tenantnamespace with theprometheuslabel should contain the annotationtelemetry-compass.com/reconciled: "true".
Our controller’s job is to make that statement true.
2. Prerequisites
Before starting, verify that the required tools are installed.
You should have:
- Go
- Kubebuilder
kubectl- Access to a Kubernetes cluster
- GNU Make
- Git
- Docker or another compatible container runtime
Check Go:
go version
Check Kubebuilder:
kubebuilder version
Check kubectl:
kubectl version --client
Check Make:
make --version
And verify that Kubernetes is accessible:
kubectl get nodes
If that works, we’re ready to build the controller.
3. Create the Kubebuilder Project
Create a project directory:
mkdir -p /opt/pod-annotatorcd /opt/pod-annotator
Initialize the Kubebuilder project:
kubebuilder init \ --domain example.com \ --repo github.com/machani/pod-annotator
Kubebuilder creates the basic project structure for us.
It will look roughly like:
pod-annotator/├── cmd/│ └── main.go├── config/│ ├── default/│ ├── manager/│ └── rbac/├── internal/│ └── controller/├── Dockerfile├── Makefile├── PROJECT└── go.mod
Don’t worry if this looks like a lot.
We only need to understand a few important pieces initially.
cmd/main.go
This starts the controller manager.
Think of the manager as the process responsible for running our controllers.
internal/controller/
This is where our reconciliation logic lives.
Most of the interesting code we’ll write will be here.
config/
This contains Kubernetes manifests used to deploy the controller.
That includes things such as:
- RBAC
- Deployment configuration
- ServiceAccount
- Kustomize configuration
Makefile
Kubebuilder gives us convenient commands such as:
make runmake testmake manifestsmake docker-buildmake docker-pushmake deploy
We’ll use these throughout the project.
4. Do We Need a CRD?
When people hear Kubebuilder, they often immediately think about Custom Resource Definitions.
For example:
apiVersion: web.example.com/v1kind: Website
But our controller doesn’t need a custom resource yet.
We’re watching an existing Kubernetes resource:
apiVersion: v1kind: Pod
So we only need a controller.
We don’t need to invent a new Kubernetes API.
This makes the Pod Annotator a useful first project because we can concentrate on understanding reconciliation before adding CRDs, API schemas, status fields, finalizers, and other operator concepts.
5. Scaffold the Pod Controller
Generate the controller:
kubebuilder create api \ --group core \ --version v1 \ --kind Pod \ --controller=true \ --resource=false
The most important part here is:
--resource=false
We’re telling Kubebuilder:
Create controller scaffolding, but don’t create a new custom resource.
Why?
Because Pod already exists in Kubernetes.
6. Meet the Reconcile Function
The heart of our controller is a function similar to:
func (r *PodReconciler) Reconcile( ctx context.Context, req ctrl.Request,) (ctrl.Result, error)
This is the function controller-runtime calls when a relevant Pod event occurs.
The request identifies the resource that needs reconciliation.
Conceptually, it contains information like:
Namespace: tenantName: prometheus-test
One important thing to understand is that the event itself isn’t our source of truth.
Instead, the controller uses the request to retrieve the current version of the object from the Kubernetes API.
That’s a fundamental controller pattern:
Event says:"Something changed!" | vController asks Kubernetes:"What does the object look like right now?"
7. Define the Desired Rules
Let’s make our controller’s configuration clear.
We want:
Namespace:tenantRequired label key:prometheusAnnotation:telemetry-compass.com/reconciled=true
In Go, we can represent these as constants:
const ( targetNamespace = "tenant" targetLabelKey = "prometheus" annotationKey = "telemetry-compass.com/reconciled" annotationValue = "true")
Using constants makes our reconciliation logic easier to understand.
Instead of repeatedly writing:
"telemetry-compass.com/reconciled"
we can write:
annotationKey
8. Fetch the Pod
When reconciliation begins, we first need the current Pod.
Create a Pod object:
pod := &corev1.Pod{}
Then retrieve it from Kubernetes:
if err := r.Get(ctx, req.NamespacedName, pod); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err)}
This is roughly equivalent to asking Kubernetes:
Give me the Pod identified by:namespace + name
But why do we use:
client.IgnoreNotFound(err)
instead of simply returning every error?
Because Kubernetes resources can disappear at any moment.
Imagine this sequence:
Pod created | vEvent queued | vPod deleted | vController processes event | vGET Pod | vNot Found
Nothing is actually wrong.
The Pod simply disappeared before we processed its event.
Controllers must expect this kind of behavior.
9. Filter by Namespace
Now we check whether the Pod belongs to our target namespace.
if pod.Namespace != targetNamespace { return ctrl.Result{}, nil}
If it isn’t in:
tenant
we don’t care about it.
We simply stop reconciliation.
Notice the style we’re using:
Wrong namespace? | Yes | vReturn
Instead of nesting everything inside multiple if blocks, controller code commonly uses early returns.
That keeps the reconciliation flow easy to follow.
10. Filter by Label
Next, check whether the Pod contains the prometheus label.
if _, ok := pod.Labels[targetLabelKey]; !ok { return ctrl.Result{}, nil}
We’re effectively asking:
Does the Pod contain a label with the key:prometheus
For example, this matches:
labels: prometheus: prometheus-server
And so does:
labels: prometheus: monitoring
The important part for this controller is the presence of the prometheus label key.
If the label isn’t present, we ignore the Pod.
Our logic now looks like:
Get Pod | vCorrect namespace? | +--- No ---> Stop | Yes | vHas "prometheus" label? | +--- No ---> Stop | Yes | vContinue
11. Check Whether the Annotation Already Exists
Now comes one of the most important concepts in controller development:
Idempotency.
Before modifying the Pod, check whether it is already correct:
if pod.Annotations != nil && pod.Annotations[annotationKey] == annotationValue { return ctrl.Result{}, nil}
Suppose the Pod already contains:
annotations: telemetry-compass.com/reconciled: "true"
There is nothing to do.
So we return.
Why is this important?
Imagine that we updated the Pod every time reconciliation ran.
An update can cause another event:
Controller updates Pod | vPod changed | vNew event | vReconcile | vController updates Pod again | vAnother event
That’s exactly the kind of unnecessary loop we want to avoid.
A good reconciliation loop asks:
Is the current state already what I want?
If yes:
Do nothing.
That is idempotent behavior.
12. Add the Annotation
Now we know:
- The namespace matches.
- The
prometheuslabel exists. - The annotation isn’t already correct.
So we need to add it.
There’s one Go detail we need to handle first.
The annotation map may be nil.
So initialize it when necessary:
if pod.Annotations == nil { pod.Annotations = make(map[string]string)}
Now add our annotation:
pod.Annotations[annotationKey] = annotationValue
Our in-memory Pod now contains:
annotations: telemetry-compass.com/reconciled: "true"
But changing the Go object isn’t enough.
We need to send the change back to Kubernetes:
if err := r.Update(ctx, pod); err != nil { return ctrl.Result{}, err}
And finally:
return ctrl.Result{}, nil
Reconciliation is complete.
13. The Complete Reconciliation Logic
Putting the important pieces together, the core logic looks like this:
func (r *PodReconciler) Reconcile( ctx context.Context, req ctrl.Request,) (ctrl.Result, error) { pod := &corev1.Pod{} if err := r.Get(ctx, req.NamespacedName, pod); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } if pod.Namespace != targetNamespace { return ctrl.Result{}, nil } if _, ok := pod.Labels[targetLabelKey]; !ok { return ctrl.Result{}, nil } if pod.Annotations != nil && pod.Annotations[annotationKey] == annotationValue { return ctrl.Result{}, nil } if pod.Annotations == nil { pod.Annotations = make(map[string]string) } pod.Annotations[annotationKey] = annotationValue if err := r.Update(ctx, pod); err != nil { return ctrl.Result{}, err } return ctrl.Result{}, nil}
The entire algorithm can be summarized as:
Fetch Pod | vWrong namespace? -------------> Stop | vNo "prometheus" label? -------> Stop | vAnnotation already correct? --> Stop | vInitialize annotation map | vAdd telemetry-compass.com/reconciled=true | vUpdate Pod
This is the core of our controller.
14. Tell Kubebuilder to Watch Pods
Writing reconciliation logic isn’t enough.
We also need to tell controller-runtime:
Which Kubernetes resource should trigger this controller?
That’s done in SetupWithManager.
For our controller, it looks similar to:
func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&corev1.Pod{}). Named("pod"). Complete(r)}
The critical line is:
For(&corev1.Pod{})
That tells controller-runtime to watch Pods.
Conceptually:
Kubernetes API | | Pod events vcontroller-runtime | vWork Queue | vReconcile()
The controller doesn’t need to continuously run:
kubectl get pods
controller-runtime handles the watch mechanism and reconciliation queue for us.
15. Add RBAC Permissions
There is another important piece.
Watching Pods doesn’t automatically mean our controller is allowed to access them.
Kubernetes authorization still applies.
Our controller needs permissions to:
- Get Pods
- List Pods
- Watch Pods
- Update Pods
- Patch Pods
Kubebuilder lets us describe those permissions using markers in Go code.
Add an RBAC marker similar to:
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;update;patch
Notice:
groups=""
Why is the group empty?
Because Pods belong to the Kubernetes core API group.
Now generate the RBAC manifests:
make manifests
Kubebuilder’s tooling reads the markers and generates the appropriate Kubernetes RBAC YAML.
This gives us an important distinction.
This:
For(&corev1.Pod{})
means:
Watch Pods.
While this:
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;update;patch
means:
Kubernetes should authorize this controller to perform these operations on Pods.
You generally need both.
16. Format and Validate the Code
Go has standardized formatting.
Run:
make fmt
Then:
make vet
We can also run the project’s tests:
make test
A Kubebuilder test workflow may involve:
controller-gen | vgo fmt | vgo vet | venvtest | vgo test
Kubebuilder commonly uses envtest for controller tests.
envtest provides lightweight Kubernetes control-plane components such as an API server and etcd for tests.
That lets our tests interact with a Kubernetes API without requiring an entire production cluster.
17. Run the Controller Locally
Now comes the fun part.
We can run the controller directly from our development machine while it talks to our Kubernetes cluster.
First check the current context:
kubectl config current-context
Then run:
make run
The controller starts locally and uses your kubeconfig to communicate with Kubernetes.
Keep that terminal running.
Open another terminal for testing.
18. Create a Test Pod
Make sure our target namespace exists:
kubectl create namespace tenant
If it already exists, Kubernetes will simply tell you so.
Now create:
test-pod.yaml
with:
apiVersion: v1kind: Podmetadata: name: annotation-test namespace: tenant labels: prometheus: prometheus-serverspec: containers: - name: nginx image: nginx
Apply it:
kubectl apply -f test-pod.yaml
Now inspect the Pod:
kubectl get pod annotation-test \ -n tenant \ -o yaml
Look at:
metadata: annotations:
We should see:
annotations: telemetry-compass.com/reconciled: "true"
Congratulations.
We just built a working Kubernetes controller.
19. Test the Negative Cases
Testing only the happy path isn’t enough.
Our controller has several conditions, so we should verify them.
Test 1: Correct Namespace + Prometheus Label
Namespace:tenantLabel:prometheus=prometheus-server
Expected:
telemetry-compass.com/reconciled: "true"
Test 2: Correct Namespace + No Prometheus Label
For example:
labels: app: nginx
Expected:
No annotation.
Test 3: Wrong Namespace + Prometheus Label
For example:
metadata: namespace: default labels: prometheus: prometheus-server
Expected:
No annotation.
Test 4: Annotation Already Exists
Suppose the Pod already contains:
annotations: telemetry-compass.com/reconciled: "true"
Expected:
No unnecessary update.
These tests verify the actual policy implemented by our controller.
20. Local Controller vs In-Cluster Controller
At this point our architecture looks like:
Laptop / Development Machinemake run | | kubeconfig | vKubernetes API Server | vPods
This is excellent during development.
But eventually we want:
Kubernetes Cluster | +--- Pod Annotator Controller | +--- Application Pods
To do that, we need to package the controller into a container image.
21. Build the Controller Image
Build the image:
make docker-build IMG=<your-registry>/pod-annotator:v0.1.0
For example:
make docker-build IMG=ghcr.io/example/pod-annotator:v0.1.0
The Kubebuilder project already includes a Dockerfile.
The build process compiles our Go controller and packages it into a container image.
Then push it:
make docker-push IMG=ghcr.io/example/pod-annotator:v0.1.0
Replace the example registry and repository with your own.
Using explicit version tags such as:
v0.1.0v0.2.0v1.0.0
also makes deployments easier to understand than relying entirely on:
latest
22. Deploy the Controller to Kubernetes
Once the image is available in the registry, deploy it:
make deploy IMG=ghcr.io/example/pod-annotator:v0.1.0
Kubebuilder uses the manifests under:
config/
to install the required Kubernetes resources.
These include the controller manager and its RBAC configuration.
Check the deployment:
kubectl get pods -n pod-annotator-system
Eventually we should see something similar to:
NAME READY STATUS RESTARTS AGEpod-annotator-controller-manager-xxxxxxxxxx-xxxxx 1/1 Running 0 2m
Now our controller is running inside Kubernetes.
23. Check the Controller Logs
We can inspect its logs with:
kubectl logs \ -n pod-annotator-system \ deployment/pod-annotator-controller-manager
Logs become extremely useful when debugging controllers.
When something isn’t working, some of the first things worth checking are:
kubectl get pods -n pod-annotator-system
Then:
kubectl logs \ -n pod-annotator-system \ deployment/pod-annotator-controller-manager
And:
kubectl describe pod \ -n pod-annotator-system \ <controller-pod-name>
24. A Common Deployment Problem: go: No Such File or Directory
During development, you might encounter something like:
make: go: No such file or directory
followed by a controller-gen error.
This can be confusing because you might think:
I’m deploying a container. Why does my machine need Go?
The reason is that:
make deploy
does more than simply execute:
kubectl apply
The Makefile may first run generation steps such as:
controller-gen
And controller-gen may need the Go toolchain to inspect and load your Go packages.
So the machine where you run Kubebuilder’s Makefile should have Go correctly installed and available in:
$PATH
Check:
which go
and:
go version
If Go is installed but those commands fail, inspect:
echo $PATH
This is an important distinction between:
Running the compiled controller
and:
Running the Kubebuilder development/build tooling.
The final controller container doesn’t need your development Go installation.
But your development environment does.
25. The Complete Architecture
Once deployed, our system looks like this:
Kubernetes API Server
|
| Pod events
v
controller-runtime watch
|
v
Work Queue
|
v
Reconcile(request)
|
v
Fetch Pod
|
v
Check namespace + label
|
v
Check current annotation
|
v
Update Pod
|
v
Kubernetes API
There are several important ideas hidden inside this simple diagram.
The controller is:
Event driven
It reacts to changes rather than repeatedly running kubectl.
Declarative
We describe the state we want.
Idempotent
If the object is already correct, we don’t modify it again.
Eventually consistent
The system continuously works toward the desired state.
26. Controller vs Operator
We’ve built a Kubernetes controller.
So is it also an operator?
The terms are closely related, but they aren’t exactly identical.
A controller implements a reconciliation loop.
An operator typically uses controllers to automate operational knowledge for an application or system.
Operators frequently introduce their own Custom Resource Definitions.
For example, instead of hardcoding this:
targetNamespace = "tenant"
we could eventually create something like:
apiVersion: automation.example.com/v1alpha1kind: PodAnnotationPolicymetadata: name: prometheus-policyspec: namespace: tenant labelSelector: labelKey: prometheus annotations: telemetry-compass.com/reconciled: "true"
Now users could configure the controller through Kubernetes YAML instead of modifying Go code.
That’s where Kubebuilder becomes even more powerful.
27. What We Learned
Even though our Pod Annotator is small, we’ve already covered many of the concepts used by real Kubernetes operators.
We learned how to:
- Initialize a Kubebuilder project
- Scaffold a controller
- Watch a built-in Kubernetes resource
- Understand the reconciliation loop
- Fetch objects from the Kubernetes API
- Filter Pods by namespace
- Filter Pods by labels
- Modify annotations
- Write idempotent reconciliation logic
- Understand controller-runtime watches
- Define RBAC using Kubebuilder markers
- Generate manifests
- Run controller tests
- Run a controller locally
- Build a controller container
- Push the image to a registry
- Deploy the controller into Kubernetes
- Inspect the running controller
But the most important lesson is the mental model.
We started with the familiar Kubernetes world:
kubectl+YAML
Now we can start thinking like a controller:
Observe | vCompare | vReconcile | vRepeat
That mental model is far more important than memorizing Kubebuilder commands.
28. Where Should We Go Next?
Our current controller has one obvious limitation.
These values are hardcoded:
NamespaceLabelAnnotation
Changing the policy requires changing Go code and rebuilding the controller.
Kubernetes gives us a much better way.
We can define our own Kubernetes API.
For example:
apiVersion: web.example.com/v1alpha1kind: Websitemetadata: name: my-sitespec: image: nginx:latest replicas: 2
Then our controller could automatically create:
Website | +--- Deployment | | | +--- Pods | +--- Service
That project would introduce several major Kubebuilder concepts:
- Custom Resource Definitions
- API types
SpecStatus- Generated DeepCopy code
- CRD validation
- Owner references
- Status conditions
- Finalizers
- Watching owned resources
- More advanced reconciliation
Our Pod Annotator therefore isn’t the end of the journey.
It’s the foundation.
Conclusion
Kubebuilder can look intimidating when you first generate a project.
Suddenly there are:
Go filesMakefilesRBAC markersKustomize manifestscontroller-runtimeManagersReconcilersClientsSchemes
But underneath all those abstractions, the core idea is remarkably simple:
Observe current state | vCompare with desired state | vMake them match
Our Pod Annotator demonstrates exactly that.
A Pod in the tenant namespace with a prometheus label appears.
The controller observes it.
It discovers that:
telemetry-compass.com/reconciled: "true"
is missing.
It updates the Pod.
Then, when reconciliation happens again, it sees that the Pod is already correct and does nothing.
That simple loop is the foundation on which sophisticated Kubernetes operators are built.
And once that idea clicks, Kubebuilder starts making a lot more sense.
Github Repo link: https://github.com/machani/pod-annotator
Cheers!
Responses
No comments yet · be the first