7 min read

Building Kubernetes Operators in Go: Managing Stateful Applications

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.


Advertisement

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:

  1. Provision Infrastructure: Create the StatefulSet, Services, and PVCs dynamically based on the CRD specification.
  2. Bootstrap the Application: Inject configurations, handle leader election, and initialize data replicas.
  3. 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

  1. Idempotency: The Reconcile function 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.
  2. Status Updates: While the user interacts with the Spec, the Operator updates the Status. This is how users and other systems monitor the health and progress of the application.
  3. RBAC: The //+kubebuilder:rbac markers 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.

Advertisement

You Might Also Like

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement
Advanced Go Concurrency Patterns
tech

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