ACE Journal

Building Custom Kubernetes Operators for Scalable Deployments

Abstract Dive into the design and implementation of custom Kubernetes operators to manage stateful applications. Learn about operator patterns, SDKs, and testing strategies to streamline deployment lifecycles.

Introduction

Kubernetes operators extend Kubernetes’ declarative model to automate complex application management tasks. Custom operators encapsulate operational knowledge for deploying, scaling, and healing stateful applications—such as databases, message queues, and storage systems—by implementing custom controllers. Instead of relying on generic Helm charts or ad hoc scripts, operators embed application-specific logic into Kubernetes-native APIs.

In this article, we explore:

  1. Operator Patterns: Common design paradigms—Reconcile loops, CRDs (Custom Resource Definitions), and finalizers—to manage stateful workloads.
  2. Operator SDKs and Frameworks: Overview of popular SDKs (Operator SDK, Kubebuilder) and how they simplify code generation.
  3. Implementation Guidance: Step-by-step process to scaffold, build, and package an operator with Go and the Operator SDK.
  4. Testing Strategies: Unit testing, integration testing, and end-to-end (E2E) tests to ensure reliability.
  5. Best Practices: Tips for handling upgrades, managing CRD versions, and ensuring high availability.

By the end, you’ll understand how to build operators that manage complex stateful applications at scale, reducing manual intervention and improving reliability.

1. Operator Patterns and Architecture

1.1 Custom Resource Definitions (CRDs)

At the heart of any operator is a Custom Resource Definition (CRD). CRDs enable users to extend the Kubernetes API with their own resource types. For a MySQL operator, for example, a CRD might define a MySQLCluster resource with fields like:

apiVersion: databases.example.com/v1alpha1
kind: MySQLCluster
metadata:
  name: sample-cluster
spec:
  replicas: 3
  version: "8.0"
  resources:
    requests:
      cpu: "500m"
      memory: "1Gi"
    limits:
      cpu: "1"
      memory: "2Gi"

Kubernetes stores the CR fields in etcd. When a MySQLCluster object is created, updated, or deleted, the operator’s controller is notified.

1.2 Reconciliation Loop

An operator’s controller implements a Reconcile function, which is invoked whenever the desired state (CR) changes or periodically for sanity checks. The Reconcile function:

  1. Retrieves the custom resource instance.
  2. Reads the current state of application components (e.g., StatefulSets, Services).
  3. Compares current state vs. desired state from the CR’s spec.
  4. Takes actions to converge: creating or updating Kubernetes objects, managing upgrades, and handling failures.

Pseudo-code:

func (r *MySQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
  // 1. Fetch MySQLCluster instance
  var cluster databasesv1alpha1.MySQLCluster
  if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
    return ctrl.Result{}, client.IgnoreNotFound(err)
  }
  
  // 2. Read existing StatefulSet
  var sts appsv1.StatefulSet
  err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, &sts)
  
  // 3. Compare spec:
  desiredReplicas := int32(cluster.Spec.Replicas)
  if sts.Spec.Replicas == nil || *sts.Spec.Replicas != desiredReplicas {
    // 4a. Update or create StatefulSet
    newSts := generateStatefulSet(cluster)
    if err := r.CreateOrUpdate(ctx, &newSts); err != nil {
      return ctrl.Result{}, err
    }
  }

  // 5. Update status
  cluster.Status.ReadyReplicas = sts.Status.ReadyReplicas
  if err := r.Status().Update(ctx, &cluster); err != nil {
    return ctrl.Result{}, err
  }

  return ctrl.Result{RequeueAfter: time.Minute * 5}, nil
}

1.3 Finalizers and Cleanup

When a CR is deleted, its associated resources must be cleaned up gracefully (e.g., deleting PVCs or backups). Operators use finalizers—strings added to the CR’s metadata—to delay deletion until cleanup is complete.

  1. Add Finalizer: Upon creating a CR, operator adds finalizers: ["mysqlcluster.finalizers.example.com"].
  2. Detect Deletion: In Reconcile, if DeletionTimestamp is set, perform cleanup tasks (e.g., deleteBackupResources()).
  3. Remove Finalizer: After cleanup, remove finalizer and update CR; Kubernetes then deletes the CR.
if !cluster.ObjectMeta.DeletionTimestamp.IsZero() {
  if containsString(cluster.Finalizers, finalizerName) {
    // Perform cleanup logic
    if err := r.deleteBackupResources(cluster); err != nil {
      return ctrl.Result{}, err
    }
    
    // Remove finalizer
    cluster.Finalizers = removeString(cluster.Finalizers, finalizerName)
    if err := r.Update(ctx, &cluster); err != nil {
      return ctrl.Result{}, err
    }
  }
  return ctrl.Result{}, nil
}

2. Operator SDKs and Frameworks

Several frameworks simplify operator development by generating boilerplate and providing utilities.

2.1 Operator SDK (by Operator Framework)

2.2 Kubebuilder

An alternative framework leveraging controller-runtime:

  1. Initialize Project: kubebuilder init --domain example.com --repo github.com/example/mysql-operator.
  2. Create API: kubebuilder create api --group databases --version v1alpha1 --kind MySQLCluster.
  3. Edit CRD Schema: Modify api/v1alpha1/mysqlcluster_types.go to define spec fields, default values, and validation.
  4. Generate Manifests: make manifests to create CRD YAML in config/crd/bases.
  5. Implement Controller: Edit controllers/mysqlcluster_controller.go to add Reconcile logic.
  6. Run Locally: make run starts the manager with webhook and controller.
  7. Build Docker Image: make docker-build IMG=example/mysql-operator:v0.1.0.

2.3 Java and Python Operator Frameworks

import kopf
import kubernetes.client as k8s_client

@kopf.on.create('databases.example.com', 'v1alpha1', 'mysqlclusters')
def create_fn(spec, name, namespace, **kwargs):
    replicas = spec.get('replicas', 1)
    # Create StatefulSet via k8s_client
    sts = generate_statefulset_object(name, namespace, replicas)
    api = k8s_client.AppsV1Api()
    api.create_namespaced_stateful_set(namespace, sts)

While Go SDKs remain most common, Python and Java frameworks suit teams more comfortable in those languages.

3. Step-by-Step Operator Implementation

We outline building a simple MySQL operator using the Operator SDK (Go).

3.1 Scaffold the Project

# Initialize project
operator-sdk init --domain example.com --repo github.com/yourorg/mysql-operator

# Create API and Controller
operator-sdk create api --group databases --version v1alpha1 --kind MySQLCluster --resource --controller

# Generate CRD manifests
make manifests

The scaffold generates:

3.2 Define the CRD Schema

Edit api/v1alpha1/mysqlcluster_types.go:

// MySQLClusterSpec defines the desired state
type MySQLClusterSpec struct {
  // Number of MySQL replicas
  Replicas int32 `json:"replicas" validate:"min=1"`
  
  // MySQL version
  Version string `json:"version"`
  
  // Resource requirements for pods
  Resources corev1.ResourceRequirements `json:"resources,omitempty"`
}

// MySQLClusterStatus defines the observed state
type MySQLClusterStatus struct {
  // Number of ready replicas
  ReadyReplicas int32 `json:"readyReplicas"`
  
  // Phase: Pending, Running, Failed
  Phase string `json:"phase"`
}

Generate deep copy and CRD files:

make generate
make manifests

3.3 Implement the Reconciler

In controllers/mysqlcluster_controller.go:

func (r *MySQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 1. Fetch custom resource
    var cluster databasesv1alpha1.MySQLCluster
    if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // 2. Ensure finalizer
    finalizerName := "mysqlcluster.finalizers.example.com"
    if cluster.ObjectMeta.DeletionTimestamp.IsZero() {
        if !containsString(cluster.ObjectMeta.Finalizers, finalizerName) {
            cluster.ObjectMeta.Finalizers = append(cluster.ObjectMeta.Finalizers, finalizerName)
            if err := r.Update(ctx, &cluster); err != nil {
                return ctrl.Result{}, err
            }
        }
    }

    // 3. Handle deletion
    if !cluster.ObjectMeta.DeletionTimestamp.IsZero() {
        if containsString(cluster.ObjectMeta.Finalizers, finalizerName) {
            if err := r.cleanupResources(ctx, &cluster); err != nil {
                return ctrl.Result{}, err
            }
            cluster.ObjectMeta.Finalizers = removeString(cluster.ObjectMeta.Finalizers, finalizerName)
            if err := r.Update(ctx, &cluster); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // 4. Reconcile StatefulSet
    sts := &appsv1.StatefulSet{}
    err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, sts)
    if err != nil && apierrors.IsNotFound(err) {
        // Create new StatefulSet
        newSts := r.generateStatefulSet(&cluster)
        if err := r.Create(ctx, newSts); err != nil {
            return ctrl.Result{}, err
        }
    } else if err != nil {
        return ctrl.Result{}, err
    } else {
        // Update existing if spec changed
        desiredReplicas := cluster.Spec.Replicas
        if sts.Spec.Replicas == nil || *sts.Spec.Replicas != desiredReplicas {
            sts.Spec.Replicas = &desiredReplicas
            if err := r.Update(ctx, sts); err != nil {
                return ctrl.Result{}, err
            }
        }
    }

    // 5. Update status
    var updatedSts appsv1.StatefulSet
    if err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, &updatedSts); err != nil {
        return ctrl.Result{}, err
    }
    cluster.Status.ReadyReplicas = updatedSts.Status.ReadyReplicas
    cluster.Status.Phase = determinePhase(updatedSts)
    if err := r.Status().Update(ctx, &cluster); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: time.Minute * 5}, nil
}

Helper functions:

func (r *MySQLClusterReconciler) generateStatefulSet(cluster *databasesv1alpha1.MySQLCluster) *appsv1.StatefulSet {
    labels := map[string]string{"app": cluster.Name}
    replicas := cluster.Spec.Replicas
    return &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      cluster.Name,
            Namespace: cluster.Namespace,
        },
        Spec: appsv1.StatefulSetSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{MatchLabels: labels},
            ServiceName: cluster.Name,
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{Labels: labels},
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container,
                        Resources: cluster.Spec.Resources,
                        Env: []corev1.EnvVar,
                    }},
                },
            },
            VolumeClaimTemplates: []corev1.PersistentVolumeClaim{...},
        },
    }
}

func determinePhase(sts appsv1.StatefulSet) string {
    if sts.Status.ReadyReplicas == *sts.Spec.Replicas {
        return "Running"
    }
    return "Pending"
}

func (r *MySQLClusterReconciler) cleanupResources(ctx context.Context, cluster *databasesv1alpha1.MySQLCluster) error {
    // Delete backups, PVCs, etc.
    return nil
}

3.4 Build and Deploy the Operator

# Build operator image
docker build -t yourorg/mysql-operator:v0.1.0 .

# Push to registry
docker push yourorg/mysql-operator:v0.1.0

# Apply CRDs and RBAC
overlay/kustomize/ or config/manager/* YAMLs
kubectl apply -f config/crd/bases
kubectl apply -f config/manager/service_account.yaml
kubectl apply -f config/manager/role.yaml
kubectl apply -f config/manager/role_binding.yaml
kubectl apply -f config/manager/manager.yaml

After deploying, the operator’s Pod watches for MySQLCluster CRs. Create a sample CR:

apiVersion: databases.example.com/v1alpha1
kind: MySQLCluster
metadata:
  name: example-cluster
spec:
  replicas: 3
  version: "8.0"
  resources:
    requests:
      cpu: "500m"
      memory: "1Gi"
    limits:
      cpu: "1"
      memory: "2Gi"

Verify the operator creates a StatefulSet and corresponding Pods.

4. Testing Strategies

Robust testing ensures operators behave correctly under different scenarios.

4.1 Unit Testing Reconcile Logic

func TestReconcileCreatesStatefulSet(t *testing.T) {
    scheme := runtime.NewScheme()
    _ = databasesv1alpha1.AddToScheme(scheme)
    _ = appsv1.AddToScheme(scheme)

    // Initialize fake client with a new MySQLCluster
    cluster := &databasesv1alpha1.MySQLCluster{...}
    fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster).Build()
    reconciler := &MySQLClusterReconciler{Client: fakeClient, Scheme: scheme}

    req := ctrl.Request{NamespacedName: types.NamespacedName{Name: cluster.Name, Namespace: "default"}}
    _, err := reconciler.Reconcile(context.Background(), req)
    require.NoError(t, err)

    // Expect a StatefulSet to be created
    var createdSts appsv1.StatefulSet
    err = fakeClient.Get(context.Background(), types.NamespacedName{Name: cluster.Name, Namespace: "default"}, &createdSts)
    require.NoError(t, err)
    assert.Equal(t, int32(3), *createdSts.Spec.Replicas)
}

4.2 Integration Testing with Kind or k3s

# Create kind cluster
kind create cluster --name operator-test

# Load operator image
kind load docker-image yourorg/mysql-operator:v0.1.0 --name operator-test

# Deploy CRDs and operator
overlay/kustomize apply

# Create sample CR
kubectl apply -f config/samples/databases_v1alpha1_mysqlcluster.yaml

# Validate resources
kubectl get statefulsets
kubectl get pods

Automate this in CI pipelines—if resources are not created or Pods crash, tests fail.

4.3 E2E Tests with Helm and Minikube

For operators deployed via Helm charts, use Helm test hooks:

# Package chart
helm package charts/mysql-operator

# Install chart
git checkout v0.1.0
helm install mysql-operator ./mysql-operator-0.1.0.tgz --namespace test-operator

# Run tests
helm test mysql-operator --namespace test-operator

E2E tests might involve creating a MySQLCluster CR and verifying a sample query against one of the MySQL instances.

5. Best Practices for Scalable Operators

5.1 Versioning and CRD Evolution

5.2 Idempotence and Error Handling

5.3 Managing Secrets and Credentials

5.4 High Availability and Scaling

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
  Scheme:             scheme,
  MetricsBindAddress: metricsAddr,
  LeaderElection:     true,
  LeaderElectionID:   "mysqlcluster-controller",
})

5.5 Observability and Metrics

Conclusion

Custom Kubernetes operators enable robust, self-healing management of stateful applications at scale. By leveraging CRDs, Reconcile loops, and finalizers, operators embody operational expertise and reduce manual intervention. SDKs—such as the Operator SDK and Kubebuilder—drastically cut boilerplate, allowing developers to focus on business logic. Rigorous testing—encompassing unit, integration, and E2E tests—ensures operators behave reliably under diverse scenarios.

Following best practices around CRD versioning, idempotence, secret management, high availability, and observability ensures that operators scale gracefully as the number of managed resources grows. Whether deploying a MySQL cluster, Redis cache, or bespoke data pipeline, building a custom operator transforms Kubernetes into an automated platform that maintains desired state without human toil.

References

  1. Operator Framework. (2021). Operator SDK Documentation.
  2. Kubebuilder. (2021). Kubebuilder Book.
  3. Kubernetessigs. (2022). Controller-Runtime GitHub Repository.
  4. CoreOS. (2018). Building Operators with the Operator SDK.
  5. HashiCorp. (2021). Terraform Kubernetes Provider.
  6. Google. (2021). Kind: Kubernetes in Docker.
  7. Heptio. (2019). Sonobuoy: Kubernetes Conformance Testing.
  8. Sigstore. (2023). Venafi and Cosign for Operator Image Signing.
  9. Prometheus. (2022). Client Go: Instrumenting Operators.
  10. CNCF. (2020). Certified Kubernetes Operator Best Practices.