TRENDING
A mountain gorilla looks at the camera from green forest undergrowth
October 29, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
October 29, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
October 29, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
October 29, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 29, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
29 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A flock of snow geese in flight, seen as dark silhouettes against an orange and red sunset sky with layered clouds
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
An old green steel truss bridge resting on temporary supports beside a new arch bridge under completion on a river in Portland, Oregon
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
Hundreds of yellow rubber ducks floating in a river enclosure during a duck race
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 270 Posts
News 273 Posts
Learning Hub 243 Posts
Home/Learning Hub/How to Build a Kubernetes Operator From Scratch With Kubebuilder and Go
Learning Hub

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.

July 29, 2026 21 Min Read
61

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 sudo access. 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. kind runs 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 kubectl commands (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. Spec is desired state: whoever creates or edits a Greeting writes it, and the controller only ever reads it. Status is observed state: only the controller writes it, to record what it actually found and did. The // +kubebuilder:subresource:status marker (already present above the Greeting struct) 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=1 and the (implicit, from the missing omitempty in context) required Name field 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 omits message gets "Hello" filled in automatically by the API server at admission time, not by your Go code.
  • // +kubebuilder:printcolumn markers control what extra columns show up when someone runs kubectl get greeting. Built-in types like Pod get their READY and STATUS columns 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.Condition is 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 a Type, a Status (True/False/Unknown), a machine-readable Reason, and a human-readable Message.

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:

  1. Fetch the Greeting. req only 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 returns NotFound, 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).
  2. 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.
  3. Compare against actual state, and act. This switch is 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.
  4. 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 run hides RBAC problems. Because local runs use your own kubeconfig credentials, a reconciler can work perfectly under make run and then fail with Forbidden errors 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:rbac marker for it (like the core,resources=configmaps line in Step 5). Always re-run make manifests after adding a new client call, and treat an in-cluster deploy as the real RBAC test, not make run.
  • Writing to .status with a plain Update() instead of Status().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 generate after editing greeting_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:

  1. kubectl get greeting shows your Greeting with a non-empty CONFIGMAP column.
  2. kubectl get greeting welcome -o jsonpath='{.status.conditions[0].status}' prints True.
  3. kubectl get configmap welcome-greeting -o jsonpath='{.data.greeting\.txt}' matches what the Greeting’s spec currently asks for.
  4. Editing spec.message (Test 1) changes the ConfigMap within a second or two.
  5. Editing the ConfigMap directly (Test 2) gets reverted within a second or two.
  6. Deleting the Greeting (Test 3) deletes the ConfigMap along with it.
  7. 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:tag followed by make deploy IMG=... packages the manager as a container and runs it under the generated ServiceAccount and RBAC role, the real production path instead of the make run shortcut used throughout this tutorial. If you don’t have a registry handy, kind load docker-image loads 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.

Tags:

Cloud NativeGoKubebuilderKubernetesKubernetes Operators

Share

IBM Quantum System Two quantum computing hardware at its Ikerbasque inauguration in Donostia-San Sebastian
Previous Post

Red Hat’s RHEL 10 Turns Post-Quantum Migration Into a Financial Services Deadline

Mark Zuckerberg speaking on stage at Facebook's F8 developer conference
Next Post

Zuckerberg Tells Investors Meta’s AI Agents Will Reach Billions of People in Five Years

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
28 Oct
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
Trending
October 28, 2026
How to Run Zero-Downtime Schema Migrations on PostgreSQL 18 and Block Unsafe ALTER TABLE Statements With a Python Lint Gate
October 28, 2026
The Angular 2 Rewrite Turns a Migration Lesson Into a Security Bill: AngularJS Still Draws 533,480 Downloads a Week, and 10 Advisories Have No Open-Source Fix
October 28, 2026
WordPress 7.1.4 Closes an Unauthenticated Race in Its Own Update Routine, but 19 Older Branches Had No Backport Hours After Release
October 28, 2026
How to Compress Time-Series Metrics in Python With Gorilla Delta-of-Delta and XOR Encoding
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026