How to Build a Kubernetes Operator From Scratch With Kubebuilder and Go
Build a real Kubernetes Operator from scratch with kubebuilder and Go, then prove drift correction and automatic cleanup on a live local cluster.
Kubernetes already knows how to keep a Deployment’s replica count correct, how to route traffic through a Service, and how to reissue a Pod that got evicted. It does this with a small set of built-in controllers, each running an endless loop that compares what you asked for against what actually exists, and nudges reality back into line. An Operator is what you get when you point that exact same machinery at a resource type Kubernetes doesn’t know about natively, one you define yourself. This tutorial builds a real one from scratch: a Greeting custom resource and a Go controller that manages a ConfigMap for it, running against a real local cluster, with every command and every piece of output captured from an actual run rather than written from memory.
Table Of Content
- What Is a Kubernetes Operator, Really?
- Prerequisites
- Step 1: Install the Toolchain
- Install Docker
- Install kubectl
- Install kind and create a cluster
- Install Go
- Install kubebuilder
- Step 2: Scaffold the Operator Project
- Step 3: Define the Greeting Custom Resource
- Step 4: Generate the CRD and Understand What Changed
- Step 5: Write the Reconciler
- Step 6: Install the CRD and Run the Operator
- Step 7: Create a Greeting and Watch the Operator Work
- Step 8: Prove the Reconcile Loop Is Real
- Test 1: Editing the spec propagates to the ConfigMap
- Test 2: Manual drift on the ConfigMap gets corrected
- Test 3: Deleting the Greeting garbage-collects the ConfigMap
- Test 4: Invalid Greetings get rejected before your code ever runs
- Common Mistakes and Gotchas
- How to Verify Everything Works End to End
- Clean Up
- Next Steps
By the end, you will have a working operator that creates a ConfigMap from a custom resource, updates it when you change your mind, silently repairs it if someone edits it by hand, and cleans up after itself when the custom resource is deleted, and you will understand exactly why each of those behaviors happens. This build was done with Go 1.25.4, kubebuilder v4.15.0, kubectl v1.34.1, kind v0.30.0, and a local Kubernetes v1.34.0 cluster on Ubuntu; the same steps apply to any recent Kubernetes 1.3x cluster.
What Is a Kubernetes Operator, Really?
Three terms get used almost interchangeably here, and it’s worth being precise about each one before writing any code:
- CustomResourceDefinition (CRD). The official Kubernetes documentation describes it plainly: “The CustomResourceDefinition API resource allows you to define custom resources. Defining a CRD object creates a new custom resource with a name and schema that you specify,” and once it’s registered, “the Kubernetes API serves and handles the storage of your custom resource” for you. On its own, a CRD is just a schema. Nothing acts on it yet.
- Controller. The kubebuilder project’s own documentation puts it simply: “It’s a controller’s job to ensure that, for any given object, the actual state of the world matches the desired state in the object,” and it calls that process reconciling. Any code that runs this loop against any resource type, built-in or custom, is a controller.
- Operator. A controller (or small set of controllers) that targets a custom resource and encodes enough domain knowledge to manage its full lifecycle. Every operator is a controller; not every controller is an operator, since plenty of controllers (like the ones shipped inside Kubernetes itself) only ever manage built-in resource types.
The Kubernetes documentation also states the underlying principle these all rest on: “You declare the desired state of your resource. The Kubernetes controller keeps the current state of Kubernetes objects in sync with your declared desired state.” A CRD gives you a place to declare that desired state for something Kubernetes doesn’t understand out of the box. A controller is the code that keeps reality lined up with it. Put them together and you have an operator.
Why not just run a script or a CronJob instead? A one-off script or CI job only acts when triggered, and does nothing about the gap that opens up the moment something drifts afterward. A CronJob closes that gap on a timer, at the cost of staleness between runs and no way to react instantly when something changes. An operator watches continuously: the API server notifies it the instant its resource is created, changed, or deleted, and it keeps reconciling for that resource’s entire lifetime. That is more machinery to build and run than a script, so it earns its keep specifically when the problem is keeping something continuously correct, not just applying it once. That is exactly what you are about to build.
Prerequisites
- A 64-bit Linux machine with root or passwordless
sudoaccess. This tutorial was run on Ubuntu 26.04 LTS; Ubuntu 24.04 LTS or any recent Debian-based distribution works the same way. - At least a few GB of free RAM and disk space.
kindruns an entire Kubernetes node as a Docker container, and the Go module cache for kubebuilder’s dependencies takes a few hundred MB on first build. - Outbound internet access, to download Go, kubebuilder, container images, and Go modules.
- Comfort with basic
kubectlcommands (get,apply,delete) and reading YAML. No prior Go experience is assumed; every line of Go you write is explained as you write it.
You will install these tools in Step 1: Docker (kind runs cluster nodes as containers), kubectl, kind (Kubernetes-in-Docker, for a disposable local test cluster), Go, and kubebuilder (the CLI that scaffolds an operator project).
Step 1: Install the Toolchain
Install Docker
kind needs a container runtime to run Kubernetes nodes in. On Ubuntu or Debian, the packaged Docker Engine is the simplest path:
sudo apt-get update
sudo apt-get install -y docker.io
sudo systemctl enable --now docker
Confirm the daemon is actually running before moving on, since a package being installed is not the same as its service being active:
sudo docker run --rm hello-world
A correct install prints “Hello from Docker!” along with a short explanation of the steps Docker just took. If you’d rather not type sudo before every Docker command, add your user to the docker group with sudo usermod -aG docker $USER and start a new shell session.
Install kubectl
kubectl is the command-line client you use to talk to any Kubernetes API server, local or remote. Kubernetes warns if your client and server versions drift more than one minor version apart, so it’s worth matching kind’s default node version (1.34 at the time of this tutorial):
curl -sLo kubectl "https://dl.k8s.io/release/v1.34.1/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
kubectl version --client
Expected output: Client Version: v1.34.1 (plus a Kustomize version line). Check the official Kubernetes releases page for the current stable version if you’re following along later.
Install kind and create a cluster
kind (Kubernetes IN Docker) runs a throwaway, fully real Kubernetes cluster inside Docker containers. It’s the standard way to test an operator locally before it ever touches a production cluster:
curl -sLo kind "https://kind.sigs.k8s.io/dl/v0.30.0/kind-linux-amd64"
chmod +x kind
sudo mv kind /usr/local/bin/
kind create cluster --name operator-demo
This downloads a prebuilt node image and boots a single-node cluster; it takes under a minute on a reasonable connection. It ends with:
Set kubectl context to "kind-operator-demo"
You can now use your cluster with:
kubectl cluster-info --context kind-operator-demo
kind also points your default kubectl context at the new cluster automatically. Confirm the node is healthy before continuing, since a still-initializing node will otherwise cause confusing failures a few steps from now:
kubectl get nodes
NAME STATUS ROLES AGE VERSION
operator-demo-control-plane Ready control-plane 32s v1.34.0
If STATUS still says NotReady, wait a few seconds and run it again; the CNI plugin and core system Pods take a moment to start after the node itself comes up.
Install Go
kubebuilder scaffolds a real Go module and needs a matching Go toolchain to build it:
curl -sL https://go.dev/dl/go1.25.4.linux-amd64.tar.gz -o go.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go.tar.gz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
export PATH=$PATH:/usr/local/go/bin
go version
Expected output: go version go1.25.4 linux/amd64. Check the official Go downloads page for the current release; kubebuilder tracks recent Go versions closely, so an old Go install is a common source of confusing build errors.
Install kubebuilder
curl -sLo kubebuilder "https://github.com/kubernetes-sigs/kubebuilder/releases/latest/download/kubebuilder_linux_amd64"
chmod +x kubebuilder
sudo mv kubebuilder /usr/local/bin/
kubebuilder version
This tutorial used KubeBuilder: v4.15.0. kubebuilder is a single static binary. Version 4.x scaffolds a noticeably different (and simpler) project layout than the 3.x tutorials you’ll still find scattered around the web, so if a blog post’s generated file paths don’t match what you see here, that’s almost always why.
Step 2: Scaffold the Operator Project
Create a project directory and initialize it. The --domain flag becomes the API group suffix for every CRD you define in this project; in a real project you’d use a domain your organization actually controls, to avoid ever colliding with someone else’s CRD group. This tutorial uses sxz.io as a concrete example throughout; substitute your own domain (or example.com for pure practice) in your own projects:
mkdir greeting-operator && cd greeting-operator
kubebuilder init --domain sxz.io --repo sxz.io/greeting-operator --plugins go/v4
This writes a Go module (go.mod), a cmd/main.go entry point that wires up a controller-runtime manager (the process that runs one or more controllers and exposes shared plumbing like leader election, health checks, and the metrics endpoint), and a Makefile with all the developer commands you’ll use throughout this tutorial. It finishes with a hint for the next command:
Next: define a resource with:
$ kubebuilder create api
Now scaffold the actual API, the Greeting kind, in the apps API group at version v1alpha1:
kubebuilder create api --group apps --version v1alpha1 --kind Greeting --resource --controller
--resource tells it to generate the CRD’s Go types; --controller tells it to also generate a reconciler skeleton for that type. This creates three files that matter for this tutorial:
api/v1alpha1/greeting_types.go, where you define what a Greeting’s spec and status actually look like.internal/controller/greeting_controller.go, where the reconcile loop itself lives.config/crd/bases/apps.sxz.io_greetings.yaml, a generated CRD manifest you will never hand-edit directly (more on this in the next step).
Step 3: Define the Greeting Custom Resource
Open api/v1alpha1/greeting_types.go. kubebuilder scaffolds it with a placeholder Foo field and comments telling you this file is yours to edit. Replace GreetingSpec and GreetingStatus with real fields:
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// GreetingSpec defines the desired state of Greeting
type GreetingSpec struct {
// name is who the greeting is addressed to.
// +required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// message is the greeting phrase to use. Defaults to "Hello" when omitted.
// +optional
// +kubebuilder:default="Hello"
Message string `json:"message,omitempty"`
}
// GreetingStatus defines the observed state of Greeting.
type GreetingStatus struct {
// configMapName is the name of the ConfigMap this Greeting is currently managing.
// +optional
ConfigMapName string `json:"configMapName,omitempty"`
// observedGeneration is the .metadata.generation that the status was last reconciled from.
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// conditions represent the current state of the Greeting resource.
// Each condition has a unique type and reflects the status of a specific aspect of the resource.
//
// Standard condition types include:
// - "Available": the resource is fully functional
// - "Progressing": the resource is being created or updated
// - "Degraded": the resource failed to reach or maintain its desired state
//
// The status of each condition is one of True, False, or Unknown.
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.spec.message`
// +kubebuilder:printcolumn:name="ConfigMap",type=string,JSONPath=`.status.configMapName`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Greeting is the Schema for the greetings API
type Greeting struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// spec defines the desired state of Greeting
// +required
Spec GreetingSpec `json:"spec"`
// status defines the observed state of Greeting
// +optional
Status GreetingStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// GreetingList contains a list of Greeting
type GreetingList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []Greeting `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &Greeting{}, &GreetingList{})
return nil
})
}
A few things worth calling out line by line:
- Spec versus Status is not a style choice.
Specis desired state: whoever creates or edits a Greeting writes it, and the controller only ever reads it.Statusis observed state: only the controller writes it, to record what it actually found and did. The// +kubebuilder:subresource:statusmarker (already present above theGreetingstruct) enforces this at the API server level by splitting status into its own subresource with its own update endpoint, so a client writing to status directly is working around the controller rather than through it. // +kubebuilder:validation:MinLength=1and the (implicit, from the missingomitemptyin context) requiredNamefield become real OpenAPI schema validation on the CRD itself. The Kubernetes API server rejects an invalid Greeting before your controller ever sees it, which you’ll prove in Step 8.// +kubebuilder:default="Hello"means a Greeting that omitsmessagegets"Hello"filled in automatically by the API server at admission time, not by your Go code.// +kubebuilder:printcolumnmarkers control what extra columns show up when someone runskubectl get greeting. Built-in types like Pod get theirREADYandSTATUScolumns from that same underlying server-side table-printing capability, just hard-coded into the API server instead of declared through a CRD’s printer-column field the way yours is.Conditions []metav1.Conditionis the standard, structured way Kubernetes objects report status. It’s the same field shape you’d see on a Deployment or a Node, which is why every condition below carries aType, aStatus(True/False/Unknown), a machine-readableReason, and a human-readableMessage.
Step 4: Generate the CRD and Understand What Changed
You never hand-write the actual CRD YAML that gets installed on the cluster. kubebuilder generates it from the Go struct and its markers, using a tool called controller-gen:
make manifests generate
"/root/greeting-operator/bin/controller-gen" rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
"/root/greeting-operator/bin/controller-gen" object:headerFile="hack/boilerplate.go.txt",year=2026 paths="./..."
make manifests regenerates the CRD YAML and RBAC role from your markers; make generate regenerates zz_generated.deepcopy.go, the boilerplate DeepCopy() methods every Kubernetes API type needs so the client libraries can safely clone objects. Run both together after any change to greeting_types.go. If you see a stray warning like Warning: unrecognized format "int64", that’s controller-gen being pedantic about the OpenAPI schema for the int64 status field; it’s harmless and the CRD still installs correctly.
Now check that the build actually compiles before you touch a cluster:
go build -o bin/manager ./cmd/...
No output means success. If this is a fresh module and Go complains about VCS status, run git init in the project directory first; Go’s build tooling expects to stamp version-control metadata into the binary and gets confused outside a git repository.
Step 5: Write the Reconciler
This is the actual operator logic. Open internal/controller/greeting_controller.go and replace the scaffolded Reconcile function (which starts as a two-line stub with a // TODO(user): your logic here comment) with the real implementation:
package controller
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
appsv1alpha1 "sxz.io/greeting-operator/api/v1alpha1"
)
// GreetingReconciler reconciles a Greeting object
type GreetingReconciler struct {
client.Client
Scheme *runtime.Scheme
}
// +kubebuilder:rbac:groups=apps.sxz.io,resources=greetings,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.sxz.io,resources=greetings/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.sxz.io,resources=greetings/finalizers,verbs=update
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
// Reconcile drives the actual state of a Greeting's ConfigMap toward the
// state described in Greeting.Spec. It runs whenever a Greeting changes,
// whenever a ConfigMap it owns changes, and periodically as a safety net,
// so it must be safe to run repeatedly against state that hasn't changed
// (idempotent).
func (r *GreetingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
// 1. Fetch the Greeting that triggered this reconcile. If it's gone,
// there is nothing to do: Kubernetes garbage collection already
// removed the ConfigMap for us via the owner reference set below.
var greeting appsv1alpha1.Greeting
if err := r.Get(ctx, req.NamespacedName, &greeting); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("fetching Greeting: %w", err)
}
// 2. Compute the desired state: one ConfigMap holding the rendered
// greeting text, named after the Greeting that owns it.
configMapName := greeting.Name + "-greeting"
greetingText := fmt.Sprintf("%s, %s!", greeting.Spec.Message, greeting.Spec.Name)
var cm corev1.ConfigMap
err := r.Get(ctx, types.NamespacedName{Name: configMapName, Namespace: greeting.Namespace}, &cm)
switch {
case apierrors.IsNotFound(err):
// 3a. The ConfigMap doesn't exist yet: create it. SetControllerReference
// makes the Greeting the owner, so deleting the Greeting cascades to the
// ConfigMap automatically, and editing/deleting the ConfigMap directly
// triggers a re-reconcile through the Owns() watch registered below.
cm = corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: configMapName,
Namespace: greeting.Namespace,
Labels: map[string]string{
"app.kubernetes.io/managed-by": "greeting-operator",
"apps.sxz.io/greeting": greeting.Name,
},
},
Data: map[string]string{"greeting.txt": greetingText},
}
if err := controllerutil.SetControllerReference(&greeting, &cm, r.Scheme); err != nil {
return ctrl.Result{}, fmt.Errorf("setting owner reference: %w", err)
}
if err := r.Create(ctx, &cm); err != nil {
return ctrl.Result{}, fmt.Errorf("creating ConfigMap: %w", err)
}
log.Info("created ConfigMap", "configMap", configMapName)
case err != nil:
return ctrl.Result{}, fmt.Errorf("fetching ConfigMap: %w", err)
case cm.Data["greeting.txt"] != greetingText:
// 3b. The ConfigMap exists but its content doesn't match what the
// spec asks for. This branch fires both when someone edits
// Greeting.Spec (an intentional change to reconcile toward) and
// when someone edits or wipes the ConfigMap directly (drift to
// correct). The reconciler can't tell those apart, and it
// doesn't need to: both are just "actual state != desired
// state," so the fix is the same either way.
if cm.Data == nil {
cm.Data = map[string]string{}
}
cm.Data["greeting.txt"] = greetingText
if err := r.Update(ctx, &cm); err != nil {
return ctrl.Result{}, fmt.Errorf("updating ConfigMap: %w", err)
}
log.Info("corrected ConfigMap", "configMap", configMapName)
default:
// 3c. Actual state already matches desired state. Do nothing.
// This is the common case once a Greeting has settled, and it's
// why the loop can safely re-run on every resync without
// side effects.
}
// 4. Record what happened in Status so `kubectl get greeting` and
// `kubectl describe greeting` reflect reality. Status is a separate
// subresource: updating it never touches .spec, and updating .spec
// never touches .status.
greeting.Status.ConfigMapName = configMapName
greeting.Status.ObservedGeneration = greeting.Generation
meta.SetStatusCondition(&greeting.Status.Conditions, metav1.Condition{
Type: "Available",
Status: metav1.ConditionTrue,
Reason: "ConfigMapReady",
Message: fmt.Sprintf("ConfigMap %q holds the current greeting", configMapName),
ObservedGeneration: greeting.Generation,
})
if err := r.Status().Update(ctx, &greeting); err != nil {
return ctrl.Result{}, fmt.Errorf("updating Greeting status: %w", err)
}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *GreetingReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.Greeting{}).
Owns(&corev1.ConfigMap{}).
Named("greeting").
Complete(r)
}
Walking through what actually happens on every reconcile, in order:
- Fetch the Greeting.
reqonly carries a name and namespace, never the object’s data. That’s deliberate: by the time your code runs, the object may already have changed again, so you always fetch fresh rather than trusting anything passed in from an earlier event. If the Get returnsNotFound, the Greeting was deleted; there’s nothing to do, since Kubernetes already garbage-collected its ConfigMap for you (that’s what Step 6’s owner reference is for). - Compute desired state. Pure, boring string logic: no cluster calls, no side effects yet. Keeping this step side-effect-free makes the function easy to reason about and to test.
- Compare against actual state, and act. This
switchis the heart of the reconcile loop. It reads the real ConfigMap from the cluster and takes exactly one of three branches: create it if missing, correct it if its content doesn’t match, or do nothing if it already matches. Notice that the “correct it” branch fires identically whether the mismatch came from you editing the Greeting’s spec or from someone directly editing the ConfigMap by hand. The reconciler doesn’t need to know which happened, and doesn’t try to; both are just “actual state does not equal desired state,” and the fix is the same either way. That collapsing of cause into a single effect is what makes reconciliation loops so much simpler to reason about than a system built from bespoke event handlers for every possible trigger. - Record status.
r.Status().Update()writes only to the status subresource, never touching spec, which is why editing a Greeting’s spec and the controller updating its status never race against each other.
controllerutil.SetControllerReference in the create branch deserves its own callout: it sets an ownerReference on the ConfigMap pointing back at the Greeting. That one call is doing two jobs at once. First, it means Kubernetes’ built-in garbage collector deletes the ConfigMap automatically when the Greeting is deleted; your Go code never has to implement cleanup logic itself. Second, combined with Owns(&corev1.ConfigMap{}) in SetupWithManager below, it means the controller-runtime watches ConfigMaps too, not just Greetings, and maps any change on an owned ConfigMap back to a reconcile of its owning Greeting. That second part is exactly what makes drift correction happen within roughly a second of someone editing the ConfigMap by hand, rather than only on the next time the Greeting itself changes.
Step 6: Install the CRD and Run the Operator
With the cluster already up from Step 1, install the generated CRD:
make install
customresourcedefinition.apiextensions.k8s.io/greetings.apps.sxz.io created
Confirm the API server actually registered your schema, fields and all, by asking it directly:
kubectl explain greeting.spec
GROUP: apps.sxz.io
KIND: Greeting
VERSION: v1alpha1
FIELD: spec <Object>
DESCRIPTION:
spec defines the desired state of Greeting
FIELDS:
message <string>
message is the greeting phrase to use. Defaults to "Hello" when omitted.
name <string> -required-
name is who the greeting is addressed to.
That output is generated entirely from the comments and markers you wrote in Step 3; kubectl explain is reading it straight from the CRD’s OpenAPI schema on the API server, which is a genuinely useful way to double-check a CRD schema without opening any YAML.
Now run the operator itself, outside the cluster, directly on your machine:
make run
The kubebuilder book is direct about why this is safe to do without setting up any RBAC first: “This uses whatever credentials you use to connect to the cluster, so you do not need to worry about RBAC just yet.” Your own kubeconfig, the one kind gave you cluster-admin access with, is what the manager process runs as. A correct startup looks like this:
INFO setup Starting manager
INFO starting server {"name": "health probe", "addr": "[::]:8081"}
INFO Starting EventSource {"controller": "greeting", ... "source": "kind source: *v1.ConfigMap"}
INFO Starting EventSource {"controller": "greeting", ... "source": "kind source: *v1alpha1.Greeting"}
INFO Starting Controller {"controller": "greeting", ...}
INFO Starting workers {"controller": "greeting", ..., "worker count": 1}
Notice there are two EventSource lines, one for Greeting and one for ConfigMap. That second one only exists because of the Owns() call from Step 5; without it, the controller would only ever watch Greetings, and everything about drift correction in Step 8 would stop working. Leave this running in its own terminal (or background it) for the rest of the tutorial.
Step 7: Create a Greeting and Watch the Operator Work
In a second terminal, apply your first Greeting:
cat <<'EOF' > greeting-sample.yaml
apiVersion: apps.sxz.io/v1alpha1
kind: Greeting
metadata:
name: welcome
namespace: default
spec:
name: Amir
message: Hello
EOF
kubectl apply -f greeting-sample.yaml
greeting.apps.sxz.io/welcome created
Check the Greeting itself; the extra columns are the printcolumns you defined in Step 3:
kubectl get greeting welcome
NAME MESSAGE CONFIGMAP AGE
welcome Hello welcome-greeting 2s
And check the ConfigMap the operator created on your behalf:
kubectl get configmap welcome-greeting -o yaml
apiVersion: v1
data:
greeting.txt: Hello, Amir!
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/managed-by: greeting-operator
apps.sxz.io/greeting: welcome
name: welcome-greeting
namespace: default
ownerReferences:
- apiVersion: apps.sxz.io/v1alpha1
blockOwnerDeletion: true
controller: true
kind: Greeting
name: welcome
uid: 106fba12-d8b9-4bc1-b375-ea8134fb2265
That ownerReferences block is SetControllerReference from Step 5, made visible. You’ll rely on it directly in Step 8.
Now look at the operator’s own log output in your first terminal. On this run, right after the “created ConfigMap” line, a second reconcile logged an error:
INFO created ConfigMap {... "configMap": "welcome-greeting"}
ERROR Reconciler error {... "error": "updating Greeting status: Operation cannot be fulfilled on greetings.apps.sxz.io \"welcome\": the object has been modified; please apply your changes to the latest version and try again"}
Don’t panic if you see this too; it’s expected, not a bug in the code above. Creating the ConfigMap in step 3a triggers its own watch event (because of Owns()), which queues a second, near-simultaneous reconcile for the same Greeting. That second reconcile’s status update loses a race against the first one and gets an HTTP 409 conflict, Kubernetes’ optimistic-concurrency check refusing a write based on stale data. controller-runtime’s own documentation for the Reconciler interface spells out what happens next: “If the returned error is non-nil, the Result is ignored and the request will be requeued using exponential backoff.” No code of yours has to handle this. Check the Greeting’s status a moment later and it’s already consistent:
kubectl get greeting welcome -o jsonpath='{.status}'
{"conditions":[{"lastTransitionTime":"...","message":"ConfigMap \"welcome-greeting\" holds the current greeting","observedGeneration":1,"reason":"ConfigMapReady","status":"True","type":"Available"}],"configMapName":"welcome-greeting","observedGeneration":1}
Step 8: Prove the Reconcile Loop Is Real
Anyone can create an object once. What makes this an operator and not a one-off script is that it keeps that object correct, on an ongoing basis, no matter what caused it to drift. Four small experiments prove that, each using only kubectl, no code changes.
Test 1: Editing the spec propagates to the ConfigMap
kubectl patch greeting welcome --type=merge -p '{"spec":{"message":"Welcome aboard"}}'
kubectl get configmap welcome-greeting -o jsonpath='{.data.greeting\.txt}'
Welcome aboard, Amir!
Editing spec.message changed nothing on the cluster directly; it only changed what the Greeting wants. The controller’s own watch on Greetings picked up the change and rewrote the ConfigMap to match, exactly the “3b” branch from Step 5’s reconciler.
Test 2: Manual drift on the ConfigMap gets corrected
kubectl patch configmap welcome-greeting --type=merge -p '{"data":{"greeting.txt":"HACKED BY MISTAKE"}}'
kubectl get configmap welcome-greeting -o jsonpath='{.data.greeting\.txt}'
On this run, the value already read back as Welcome aboard, Amir! again by the time the second command ran; the correction happened in well under a second, faster than typing the next command. The operator’s own log proves it happened twice, once for Test 1’s spec edit and once for this test’s manual patch, each with its own reconcileID and timestamp two seconds apart:
INFO corrected ConfigMap {"...", "reconcileID": "dd968999-...", "configMap": "welcome-greeting"}
INFO corrected ConfigMap {"...", "reconcileID": "b70982c5-...", "configMap": "welcome-greeting"}
The first line is Test 1 catching up (the spec edit from a moment earlier); the second, two seconds later, is this test’s manual patch getting corrected.
This is the entire reason an operator earns its cost over a CronJob-based approach: nobody had to notice the tampering and nobody had to run anything by hand. The Owns() watch from Step 5 is doing the noticing. Without it, the controller would never see the ConfigMap change at all, and this drift would sit uncorrected until something else happened to touch the owning Greeting.
Test 3: Deleting the Greeting garbage-collects the ConfigMap
kubectl delete greeting welcome
kubectl get configmap welcome-greeting
greeting.apps.sxz.io "welcome" deleted from default namespace
Error from server (NotFound): configmaps "welcome-greeting" not found
Nothing in greeting_controller.go deletes ConfigMaps. This is Kubernetes’ own garbage collector acting on the ownerReferences block from Step 7, cascading the delete automatically. This is also why the Greeting’s Reconcile function starts by checking for NotFound and just returning: by the time a reconcile fires for a deleted Greeting, there is nothing left to do.
Test 4: Invalid Greetings get rejected before your code ever runs
cat <<'EOF' > greeting-bad.yaml
apiVersion: apps.sxz.io/v1alpha1
kind: Greeting
metadata:
name: bad-greeting
namespace: default
spec:
message: "Hi"
EOF
kubectl apply -f greeting-bad.yaml
The Greeting "bad-greeting" is invalid: spec.name: Required value
This rejection happens at the API server, using the OpenAPI schema controller-gen generated from your // +kubebuilder:validation markers in Step 3. Your reconciler’s Go code never runs at all for this object, since it was never admitted into the cluster in the first place. That’s a meaningfully cheaper and safer place to catch a missing required field than inside your reconcile loop.
Recreate the working Greeting before moving on, since Test 3 deleted it:
kubectl apply -f greeting-sample.yaml
Common Mistakes and Gotchas
- Forgetting
Owns(&corev1.ConfigMap{}). The reconciler code would still compile and would still work the first time. What breaks silently is Test 2: without that line, the controller only watches Greetings, so drift on the ConfigMap sits uncorrected until something else happens to touch the owning Greeting. This is an easy thing to omit and a hard thing to notice you’ve omitted, since everything looks fine until someone (or something) edits the child object directly. - Treating the “object has been modified” conflict error as a bug. As Step 7 showed, this is optimistic concurrency control working as intended, and controller-runtime automatically requeues with backoff on any non-nil error. Chasing this error away by ignoring it, instead of just letting the automatic retry handle it, is more likely to hide a real bug than fix one.
make runhides RBAC problems. Because local runs use your own kubeconfig credentials, a reconciler can work perfectly undermake runand then fail withForbiddenerrors the moment it’s deployed in-cluster under its generated ServiceAccount, if you added a call to a resource type without adding a matching// +kubebuilder:rbacmarker for it (like thecore,resources=configmapsline in Step 5). Always re-runmake manifestsafter adding a new client call, and treat an in-cluster deploy as the real RBAC test, notmake run.- Writing to
.statuswith a plainUpdate()instead ofStatus().Update(). Because the CRD declares+kubebuilder:subresource:status, the API server ignores status changes sent through the main object update endpoint. A reconciler that calls the wrong one will appear to succeed (no error) while status silently never changes. - Forgetting
make manifests generateafter editinggreeting_types.go. The Go struct and the installed CRD are two different artifacts kept in sync by code generation, not automatically. Skipping this step means your Go code and the schema the API server actually enforces quietly disagree with each other.
How to Verify Everything Works End to End
Before trusting an operator, or moving on to build a more complex one, confirm the whole loop in one pass:
kubectl get greetingshows your Greeting with a non-emptyCONFIGMAPcolumn.kubectl get greeting welcome -o jsonpath='{.status.conditions[0].status}'printsTrue.kubectl get configmap welcome-greeting -o jsonpath='{.data.greeting\.txt}'matches what the Greeting’s spec currently asks for.- Editing
spec.message(Test 1) changes the ConfigMap within a second or two. - Editing the ConfigMap directly (Test 2) gets reverted within a second or two.
- Deleting the Greeting (Test 3) deletes the ConfigMap along with it.
- Applying a Greeting without
spec.name(Test 4) is rejected outright.
If all seven hold, you have a genuinely working reconciliation loop, not just a resource that happened to get created once.
Clean Up
This tutorial’s cluster and processes are disposable; tear them down when you’re done so you’re not leaving a Go process and a Docker-backed Kubernetes node running in the background:
# stop the "make run" process with Ctrl+C in its terminal, then:
kind delete cluster --name operator-demo
Next Steps
- Deploy in-cluster instead of running locally.
make docker-build docker-push IMG=<registry>/greeting-operator:tagfollowed bymake deploy IMG=...packages the manager as a container and runs it under the generated ServiceAccount and RBAC role, the real production path instead of themake runshortcut used throughout this tutorial. If you don’t have a registry handy,kind load docker-imageloads a locally built image straight into the kind cluster’s nodes. - Add a finalizer for resources Kubernetes can’t garbage-collect on its own. Owner references only work for objects living inside the same Kubernetes cluster. If a real operator provisioned something external, a cloud load balancer, a database, an API key at a third-party service, you’d add a finalizer instead: a string on the Greeting that blocks its deletion until your reconciler explicitly cleans up the external resource and removes the finalizer itself.
- Read the CronJob tutorial in the official kubebuilder book. It walks through a more elaborate example (a real CronJob-like controller) using the same project layout you just built, and covers webhooks and testing in more depth than fits here.
- Look at a production operator’s source for comparison. Projects like the Prometheus Operator or cert-manager apply everything in this tutorial (CRDs, reconcile loops, owned resources, status conditions) at real production scale, with many more edge cases handled.








No Comment! Be the first one.