Getting Started with K8s Operators: Building a Todo Operator


It’s been a while since I last wrote a blog. The first Kubernetes controller I wrote was years ago—it now feels like a lifetime ago. So I decided to return to two things I missed: writing and building with Kubernetes.
In this blog, we’ll create a simple yet powerful Todo Operator from scratch that interacts with an external Todo API. Whether you're new to Operators or looking to reinforce your understanding of Kubernetes controllers, this blog provides a hands-on walkthrough of CRDs, reconciliation loops, and finalizers. We’ll also delve into status updates and conditions—an area that often confuses folks with subtle pitfalls.
If you’ve ever wondered how to automate external systems using Kubernetes-native patterns, this is the guide for you. Read on to bring your custom resources to life—one Todo at a time.
Enter the Todo Operator.
🎓 What Are Kubernetes Operators?
Operators enable you to extend Kubernetes to manage applications and infrastructure beyond the built-in resources, such as Pods and Services. Think of them as powerful automation tools that bring the Kubernetes control loop to external systems.
But what exactly makes up an Operator?
At its core, an Operator consists of:
A Custom Resource (CR): your domain-specific object (e.g., a
Todo
).A Controller 🎮 : a loop that watches these CRs and takes actions when they change.
In other words, an Operator listens for changes to your custom objects and reacts accordingly, creating, updating, or deleting external resources as needed. It follows the principle of eventual consistency, where it continuously works to align the observed state of the system with the desired state defined in the custom resource.
📄 What Resources Do Operators Work With?
Operators work with Custom Resource Definitions (CRDs)—these tell Kubernetes what your new object types look like.
Resource Type | Example |
Built-in | Pod , Service |
Custom | Todo , Database , Backup ,Pizza |
In our case, we’ll define a Todo Custom Resource (CR) using a Custom Resource Definition (CRD).
Think of a CRD as a blueprint or schema, and a CR as an instance created from that blueprint.
By creating a CRD, we teach Kubernetes about a new type of object of Kind Todo
—that it didn’t support out of the box.
🤔 Why Use Operators?
Operators shine when you want:
Declarative control over external systems
To eliminate manual, repetitive scripts
Kubernetes to be the source of truth
They bring Kubernetes-native automation to workloads that live outside the cluster.
✨ Find the Source Code
You could find the Todo-Operator Project source code in this repository for reference.
🚀 Let’s Build: The Todo Operator
We’ll keep it simple and focus on:
Defining a CRD for
Todo
Applying a
Todo
Custom ResourceWriting a Controller that:
Creates/Deletes Todo items via an external API
Uses finalizers for cleanup
Updates the CR status with an ID and Condition
🧠 Visual: How the Operator Works
⚙️ Prerequisites
kubectl
kubebuilder
controller-runtime
Set Up the Todo Operator Project
To start building our Kubernetes Operator, we’ll scaffold the basic project structure using Kubebuilder—a powerful SDK for building Kubernetes APIs using Go.
📦 Initialize the Project
kubebuilder init --domain example.com --repo github.com/example/todo-operator
This command sets up a new Go module with:
A clean directory structure
Project files (
PROJECT
,go.mod
, etc.)Dependencies including controller-runtime
A Makefile for building, testing, and deploying your operator
➕ Create the API and Controller
kubebuilder create api --group task --version v1alpha1 --kind Todo
This command does a lot behind the scenes. It scaffolds:
Component | Purpose |
api/v1alpha1/todo_types.go | Define your TodoSpec and TodoStatus (i.e., the schema of your CR) |
internal/controller/todo_controller.go | Reconcile logic placeholder for the Todo resource |
config/crd/bases/ | CRD YAML manifests used to install your resource definitions |
config/samples/ | Sample YAMLs for testing your custom resource |
With just these two commands, you now have:
A custom Kubernetes API called
Todo
A controller ready to implement reconciliation logic
Everything is wired together to start watching and reacting to changes in your CR
Next, we’ll define the
Spec
andStatus
for ourTodo
resource, then explore how the controller syncs it with an external API.
✍️ Define the Todo Spec and Status
The heart of any Custom Resource Definition (CRD) lies in its schema—what it should track and how it communicates its state to the controller.
Update the todo_types.go
file to define the structure of your Todo
object. This includes:
🧱 Spec vs Status
Spec
: Represents the desired state—what the user wants.Status
: Represents the current state—what the controller has observed and tracked
// Spec = Desired State
Title string `json:"title,omitempty"`
// Status = Current State
ID int `json:"id,omitempty"`
Condition []metav1.Condition `json:"conditions,omitempty"`
Using metav1.Condition
allows us to follow Kubernetes API conventions for status updates.
This includes timestamps, reasons, and messages, making debugging and observability much easier.
⚙️ Generate and Apply CRDs
After defining the types, run the following to generate and apply the CRDs to your cluster:
make manifests # Generates CRD YAMLs under config/crd/
make install # Applies those CRDs to your cluster
💡
make manifests
andmake install
serve different purposes:
make manifests
: Generates the YAML files based on your Go type definitions.
make install
: Applies those manifests to your cluster so Kubernetes understands your new resource type.
Kubebuilder also auto-generates deepcopy methods and sample CR YAMLs, which are handy for testing and validation.
Now that we’ve taught Kubernetes about our custom resource—both its structure (CRD) and instances (CR)—let’s move on to the core part of an Operator: Reconciliation.
💡 What Is Reconciliation?
Reconciliation is the core loop of every Kubernetes controller. It's the continuous process where the controller observes the actual state of the world and compares it against the desired state defined by the user (in the Custom Resource). If there's a mismatch, the controller takes action to bring them in sync.
In our case:
If a
Todo
Custom Resource is created, and we call the external API to add the Todo.If it's being deleted, we remove it from the API before final deletion.
📝 Our External Todo API
To simulate integration with an external system, we built a simple Todo API in Go. In our project, this corresponds to the file api/v1/todo_request.go
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
}
func AddTodoHandler(w http.ResponseWriter, r *http.Request) { ... }
func ListTodosHandler(w http.ResponseWriter, r *http.Request) { ... }
func DeleteTodoHandler(w http.ResponseWriter, r *http.Request) { ... }
http.ListenAndServe(":8080", nil)
⚙️ How It Fits In
This API isn’t deployed inside the Kubernetes cluster—it's just a lightweight web server running locally or on a VM, used for demonstration purposes.
Your Todo Controller interacts with this API:
Creates a Todo API Object when a new Custom Resource is applied.
Deletes the Todo API object on resource deletion
🔁 Controller Reconcile Logic
The core of any Kubernetes controller lies in its Reconcile loop—this is where the magic happens.
In our project, the reconciliation logic is located in: internal/controller/todo_controller.go
func (r *TodoReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
...
...
return ctrl.Result{}, nil
}
This function gets triggered whenever there's a change to a Todo
Custom Resource (create, update, or delete). Its job is to observe the current state, compare it to the desired state, and take necessary actions to make them match.
🔧 What Does It Do?
Here’s a high-level overview of what our Reconcile
Function handles in the Todo-Operator Project:
Fetch the Todo CR
Get the latest state of the custom resource from the Kubernetes API.Handle Deletion
If.metadata.deletionTimestamp
is set, trigger finalizer logic to remove the corresponding Todo from the external API.Sync External API State
If the external Todo doesn't exist yet and the CR is valid, create a new Todo in the external API.Update Status
Once the external Todo is created, update the CR's status with the generated ID and set appropriate conditions.
🧠 Event-Driven Loop
This loop follows the eventual consistency model:
Kubernetes continuously requeues and retries reconciliation until the actual state matches the desired state, making your system robust and self-healing
If you noticed in ourtodo_controller.go
We aren’t directly creating a Todo, but also using Finalizers, but what are they?
🪜 What Are Finalizers?
Finalizers tell Kubernetes:
“Wait! Don’t delete this resource just yet—I have some cleanup to do.”
In our case, before a Todo
CR is fully deleted, we want to:
Delete the corresponding Todo from the external API
Then remove the finalizer so Kubernetes can proceed
const finalizer = "todo.task.example.com"
if !todo.ObjectMeta.DeletionTimestamp.IsZero() {
if ctrlutil.ContainsFinalizer(todo, finalizer) {
// Call DeleteTodo in API
// Remove finalizer
}
}
This ensures external resources aren't left dangling when a Kubernetes resource is deleted.
✏️ Updating CR Status and Conditions
Your controller can update the status
field in the CR to reflect the current state.
This includes updating Conditions, which are standardized fields to track lifecycle and health.
🧠 Why Use Conditions?
They give users visibility into what’s happening.
Each condition includes:
LastTransitionTime
Reason
Message
For example, when the Todo CR is successfully reconciled
condition := metav1.Condition{
Type: "TodoReconciled",
Status: metav1.ConditionTrue,
Reason: "Created",
Message: "Todo item created in external API",
}
todo.Status.Conditions = append(todo.Status.Conditions, condition)
If the external API is unreachable, you might set the condition status to Unknown
. Shutting down the server after the first reconciliation and restarting the Operator will mark the Status as Unknown. You can verify that with kubectl get Todo <todo-object> -o yaml
📘 For best practices, refer to Kubernetes API conventions.
Running the Operator
Finally, to test the reconciliation logic, start the operator using the command below.
go run main.go
🌐 Starting the External Todo API Server
If you're using the external API (like the Todo
server in this example), start it separately:
go run main.go server
Make sure the Todo Custom Resource is applied, and this server is running and accessible before the controller attempts to call external API endpoints.
🧩 Hope the Todo Operator is up and running smoothly—ready to sync Todos like a pro!
📖 References
🌟 Conclusion
Writing a Kubernetes Operator may sound intimidating, but it doesn’t have to be.
This Todo Operator walks through the fundamentals—CRDs, controllers, finalizers, and status updates—in a way that’s practical and approachable.
It’s a solid starting point if you’re building custom automation into Kubernetes
Subscribe to my newsletter
Read articles from Ruhika B directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by