25.6 Kubebuilder: Framework for Writing Controllers
Right, so you’ve decided to build an Operator. Good for you. You’ve outgrown Helm charts and you’re tired of manually kubectl apply-ing a sequence of YAML files to get your stateful, complex application running. You want to encode your operational knowledge directly into Kubernetes itself. This is where the real magic happens, and kubebuilder is the wand we’re going to use. It’s not the only option, but it’s the one that has become the de facto standard, built on top of the same libraries the Kubernetes API server itself uses. Think of it as getting the blueprints from the architects.
First things first, let’s get our workspace in order. You’ll need the kubebuilder CLI tool. I’ll assume you’ve already installed it (brew install kubebuilder or grab a binary from their releases). Now, let’s start a project for a truly groundbreaking operator: one for a highly available, stateful application we’ll call MemeServer.
mkdir memeserver-operator
cd memeserver-operator
kubebuilder init --domain yourdomain.com --repo github.com/yourusername/memeserver-operator
This command scaffolds everything. It creates the Go module, pulls in all the necessary dependencies (like the controller-runtime library), and sets up the basic structure for your project. The --domain is crucial; it’s the top-level group for your API. Don’t use example.com unless you want your code to scream “tutorial.”
The Basic Scaffolding: What Just Happened?
Let’s break down what kubebuilder init just created, because it’s easy to be overwhelmed:
main.go: The entry point. This is where your controller manager is started, and where you’ll eventually register your controllers.go.mod&go.sum: Your Go module dependencies.controller-runtime,apimachinery, and the rest of the Kubernetes client go libraries are already here.Makefile: Your new best friend. It has targets for building, generating manifests, running tests, and, most importantly, generating code. You will usemake manifestsreligiously.- The
api/andcontrollers/directories: These are currently empty. They’re where you’ll spend 95% of your time.
Defining Your API: The CRD
Your operator needs to speak its own language. We need to define a Custom Resource, which means defining its Spec (the desired state) and its Status (the observed state). Let’s create the API for our MemeServer. We’ll keep it simple to start.
kubebuilder create api --group memes --version v1 --kind MemeServer --resource true --controller true
This command does a lot of heavy lifting. It creates the boilerplate for your API type in api/v1/memeserver_types.go and for your controller in controllers/memeserver_controller.go. Now, open up memeserver_types.go. This is where you define your custom resource’s structure.
Let’s define a spec that allows users to set an image and a replica count. We use struct tags like +kubebuilder:validation: to add OpenAPI validation schema—this is pure magic that makes invalid requests get rejected before they even hit your controller logic.
// api/v1/memeserver_types.go
type MemeServerSpec struct {
// +kubebuilder:validation:MinLength=1
// The meme image to deploy (e.g., "doge:latest")
Image string `json:"image"`
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=1
// Number of replicas for the meme server deployment.
Replicas *int32 `json:"replicas,omitempty"`
}
type MemeServerStatus struct {
// Represents the observations of the MemeServer's current state.
Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
// The actual number of replicas running
ReadyReplicas int32 `json:"readyReplicas"`
}
Now, run make manifests. This command runs the controller-gen tool, which looks at those struct tags and generates the actual CustomResourceDefinition YAML in config/crd/bases/. It also generates a lot of other helpful boilerplate in the config/ directory. This is why you use a framework—this generated validation schema is something you do not want to write by hand.
The Reconcile Loop: The Heart of the Matter
Now, open controllers/memeserver_controller.go. Find the Reconcile method. This function is the entire brain of your operator. It’s called every time something happens to a MemeServer resource—it’s created, updated, deleted, or even if a child resource (like a Deployment it created) changes.
The goal of reconcile is simple, yet devilishly complex to get right: make the actual state of the world match the desired state described in the MemeServer.spec. The method is passed a Request (a namespace/name key) and your job is to fetch the custom resource and then do whatever is necessary.
Here’s a simplified flow inside Reconcile:
- Fetch the Custom Resource: Use the
client.Getto fetch theMemeServerinstance that triggered this reconciliation. - Check for Deletion: See if the resource is being deleted. If it is, you need to handle cleanup (often using finalizers, a more advanced topic we’ll grimace about later).
- Reconcile Core Logic: This is where you check if the Deployment exists. If not, create it. If it does, check if its spec matches your desired spec (e.g., did the image or replica count change?). If not, update it. This is where the real imperative-to-declarative translation happens.
- Update Status: Finally, you update the
MemeServer.statusfield with the current state (e.g., the number of ready pods from the Deployment). This is separate from the spec and is crucial for users to see what’s actually happening.
A critical pitfall here is that you must design your reconcile loop to be idempotent. You should be able to run it a thousand times with the same spec and the outcome should be the same. This is key to handling failures, requeues, and all the chaos a real cluster can throw at you.
Don’t Forget the RBAC!
Look at the top of your controller file. You’ll see comments like:
// +kubebuilder:rbac:groups=memes.yourdomain.com,resources=memeservers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=memes.yourdomain.com,resources=memeservers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=memes.yourdomain.com,resources=memeservers/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
These aren’t just comments. When you run make manifests, controller-gen turns these into actual RBAC rules in your config/rbac/ directory. Your controller pod will need these permissions to talk to the API server. Forgetting to add the RBAC tag for a new resource you start using is a classic “why can’t my controller see this?!” debugging nightmare. Always add the RBAC marker first, before you write the client code.