Building Kubernetes Operators in Go: Managing Stateful Applications
Table of Contents
- Understanding the Operator Pattern
- Custom Resource Definitions (CRDs)
- Control Loops (The Reconciliation Loop)
- Managing Stateful Applications in Kubernetes
- Building an Operator with Kubebuilder
- Scaffolding the Project
- Defining the Custom Resource
- Implementing the Reconciliation Loop
- Key Takeaways from the Reconcile Function
- Conclusion
- You Might Also Like
Kubernetes has become the de-facto operating system for the cloud, providing immense power in orchestrating containerized workloads. While it seamlessly handles stateless applications out-of-the-box, deploying and managing stateful applications—like databases, message queues, and caching systems—presents a different class of challenges. Stateful applications require domain-specific knowledge to handle tasks such as backups, clustering, scaling, and graceful failovers. This is where the Kubernetes Operator pattern comes into play.
In this comprehensive guide, we will dive deep into the Operator pattern, explore its core components including Custom Resource Definitions (CRDs) and Control Loops, discuss the nuances of managing stateful applications, and provide practical Go code snippets using Kubebuilder.
Understanding the Operator Pattern
At its core, a Kubernetes Operator is a method of packaging, deploying, and managing a Kubernetes application. The Operator pattern aims to capture the operational knowledge of a human administrator and encode it into software that runs natively within the cluster. It leverages Kubernetes' declarative APIs and its robust automation capabilities to manage applications intelligently.
Custom Resource Definitions (CRDs)
In Kubernetes, a "Resource" is an endpoint in the API that stores a collection of API objects of a certain kind. While Kubernetes comes with built-in resources like Pods, Deployments, and Services, it also allows you to extend the API using Custom Resource Definitions (CRDs).
A CRD allows you to define a new, customized object kind that Kubernetes will store and serve just like its native resources. For example, if you are building an operator to manage a PostgreSQL database, you might define a PostgreSQLCluster CRD. When a user submits a manifest for a PostgreSQLCluster, the Operator reads it and provisions the necessary underlying resources (StatefulSets, Services, PersistentVolumeClaims, etc.) to bring that database to life.
Control Loops (The Reconciliation Loop)
If CRDs are the nouns in the Kubernetes API, the Control Loop (or Controller) is the verb. Kubernetes operates on a declarative model: you declare the desired state, and the system continuously acts to make the current state match the desired state.
This continuous process is called the reconciliation loop. An Operator's controller watches for changes to your Custom Resources (and the child resources they own). Whenever an event occurs (a resource is created, updated, or deleted), the controller's Reconcile function is triggered. The controller inspects the current state of the cluster, compares it against the desired state defined in the CRD, and executes the necessary operational steps (e.g., creating a new pod, resizing a volume, taking a backup) to align the two.
Managing Stateful Applications in Kubernetes
Stateless applications can be killed and restarted anywhere in the cluster without consequence. Stateful applications, however, demand persistent storage, stable network identities, and ordered deployment and scaling.
Kubernetes provides primitive workloads like StatefulSets to help with this. StatefulSets offer sticky identities and stable storage. However, they are often not enough on their own for complex distributed systems. For instance, scaling a database isn't just about adding a pod; it often requires reconfiguring the primary node, initiating data replication, and updating connection strings.
An Operator absorbs this complexity. When managing stateful workloads, an operator will typically:
- Provision Infrastructure: Create the StatefulSet, Services, and PVCs dynamically based on the CRD specification.
- Bootstrap the Application: Inject configurations, handle leader election, and initialize data replicas.
- Handle Lifecycle Events: Automate backups, perform zero-downtime upgrades, and gracefully handle node failures by promoting secondary replicas to primary status.
By embedding this operational logic into an Operator, organizations can deploy stateful applications with the same confidence and automation as stateless microservices.
Building an Operator with Kubebuilder
To write an Operator in Go, developers typically use a framework. Kubebuilder (along with the Operator SDK) is the industry standard. It scaffolds the boilerplate code, generates CRD manifests, and sets up the controller logic so you can focus on the operational domain logic.
Scaffolding the Project
First, you initialize the project and create an API using Kubebuilder. Assuming you have Kubebuilder installed:
kubebuilder init --domain mycompany.com --repo github.com/mycompany/my-operator
kubebuilder create api --group db --version v1alpha1 --kind DatabaseCluster
This generates the necessary Go structs representing your CRD and a controller file where your reconciliation logic will live.
Defining the Custom Resource
In the generated api/v1alpha1/databasecluster_types.go file, you define the schema for your Custom Resource. This includes the desired state (Spec) and the observed state (Status).
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// DatabaseClusterSpec defines the desired state of DatabaseCluster
type DatabaseClusterSpec struct {
// Replicas defines the number of database nodes
Replicas int32 `json:"replicas,omitempty"`
// StorageSize defines the size of the persistent volume
StorageSize string `json:"storageSize,omitempty"`
// Image specifies the database container image
Image string `json:"image,omitempty"`
}
// DatabaseClusterStatus defines the observed state of DatabaseCluster
type DatabaseClusterStatus struct {
// ReadyReplicas indicates how many replicas are fully operational
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// Conditions store the current health and state of the cluster
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// DatabaseCluster is the Schema for the databaseclusters API
type DatabaseCluster struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec DatabaseClusterSpec `json:"spec,omitempty"`
Status DatabaseClusterStatus `json:"status,omitempty"`
}
Implementing the Reconciliation Loop
The core logic of your Operator resides in the Reconcile method within controllers/databasecluster_controller.go. The controller-runtime library simplifies interacting with the Kubernetes API.
package controllers
import (
"context"
"time"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
dbv1alpha1 "github.com/mycompany/my-operator/api/v1alpha1"
)
// DatabaseClusterReconciler reconciles a DatabaseCluster object
type DatabaseClusterReconciler struct {
client.Client
Scheme *runtime.Scheme
}
//+kubebuilder:rbac:groups=db.mycompany.com,resources=databaseclusters,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=db.mycompany.com,resources=databaseclusters/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
func (r *DatabaseClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. Fetch the DatabaseCluster instance
var dbCluster dbv1alpha1.DatabaseCluster
if err := r.Get(ctx, req.NamespacedName, &dbCluster); err != nil {
if errors.IsNotFound(err) {
// Object not found, could have been deleted after reconcile request.
logger.Info("DatabaseCluster resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
// Error reading the object
logger.Error(err, "Failed to get DatabaseCluster")
return ctrl.Result{}, err
}
logger.Info("Reconciling DatabaseCluster", "Name", dbCluster.Name, "Replicas", dbCluster.Spec.Replicas)
// 2. Perform Operational Logic
// Here, you would typically:
// - Check if a StatefulSet for this DatabaseCluster exists.
// - If it doesn't, construct a new StatefulSet object and use r.Create() to deploy it.
// - If it does exist, ensure its configuration (like replicas or image) matches dbCluster.Spec.
// - Handle database-specific tasks like bootstrapping replication or taking backups.
// Example: Update status to reflect current progress
dbCluster.Status.ReadyReplicas = 1 // Simplified for demonstration
if err := r.Status().Update(ctx, &dbCluster); err != nil {
logger.Error(err, "Failed to update DatabaseCluster status")
return ctrl.Result{}, err
}
// Return an empty result if successful. If you need to re-queue the request,
// you can return ctrl.Result{RequeueAfter: time.Minute * 1}
return ctrl.Result{}, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *DatabaseClusterReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&dbv1alpha1.DatabaseCluster{}).
// Owns(&appsv1.StatefulSet{}) indicates this controller manages StatefulSets created by the CRD
Complete(r)
}
Key Takeaways from the Reconcile Function
- Idempotency: The
Reconcilefunction must be idempotent. It can be called numerous times, often with the same state. It should only make changes if the current state diverges from the desired state. - Status Updates: While the user interacts with the
Spec, the Operator updates theStatus. This is how users and other systems monitor the health and progress of the application. - RBAC: The
//+kubebuilder:rbacmarkers are crucial. They automatically generate the Role-Based Access Control manifests needed for the Operator to manage resources like StatefulSets and Services within the cluster.
Conclusion
Managing stateful applications in Kubernetes doesn't have to be a manual, error-prone endeavor. By embracing the Operator pattern, teams can codify their operational runbooks into robust software controllers.
Using tools like Kubebuilder, developing these Operators in Go has become highly accessible. By defining clear Custom Resource Definitions and building resilient reconciliation loops, you can elevate your Kubernetes infrastructure, ensuring that your stateful databases, messaging queues, and caches are as highly available, automated, and self-healing as your stateless microservices.
Whether you're managing a third-party data store or wrapping a bespoke internal application, writing a custom Kubernetes Operator is a powerful step toward true infrastructure automation.
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

The Evolution of Cloud Native Security in 2026
Cloud native security in 2026: eBPF runtime defense, automated SLSA compliance, zero-trust service mesh mTLS, and identity-first workload protection.
Read more
Kubernetes Custom Resource Definitions
Design and deploy Kubernetes Custom Resource Definitions (CRDs) with Go operators: automate cloud-native infrastructure and reconcile state loops.
Read more
Advanced Go Concurrency Patterns
Master advanced Go concurrency patterns: goroutine worker pools, channel fan-in/fan-out, context cancellation, and race condition prevention strategies.
Read more