πŸ“šBook Signing at KubeCon EU 2026Meet us at Booking.com HQ (Mon 18:30-21:00) & vCluster booth #521 (Tue 24 Mar, 12:30-1:30pm) β€” free book giveaway!RSVP Booking.com Event
Deployments advanced ⏱ 15 minutes K8s 1.28+

K8s Operator Pattern: Build Controllers

Build Kubernetes operators with the controller pattern. Reconciliation loops, watch events, owner references, finalizers, and operator frameworks comparison.

By Luca Berton β€’ β€’ πŸ“– 5 min read

πŸ’‘ Quick Answer: The operator pattern encodes human operational knowledge in software. A controller watches custom resources (CRDs), compares desired state (spec) with actual state, and reconciles differences. Frameworks: Kubebuilder (Go, official), Operator SDK (Go/Ansible/Helm), kopf (Python), KUDO (declarative). Key concepts: reconciliation loop, owner references, finalizers, status updates.

The Problem

Complex applications need operational logic:

  • Database: create replicas, configure replication, manage backups, handle failover
  • Certificate: issue, renew, distribute, revoke
  • Application: deploy, configure, upgrade, scale, heal

Deployments and StatefulSets can’t encode this application-specific logic.

The Solution

Operator Concept

User creates CR:
  Database (spec: {replicas: 3, engine: postgres, version: 16})
        β”‚
        β–Ό
Controller watches for changes:
  Reconcile Loop:
    1. Read desired state (CR spec)
    2. Read actual state (existing pods, PVCs, Services)
    3. Compare desired vs actual
    4. Take action to converge (create/update/delete resources)
    5. Update CR status
    6. Requeue if not converged

Controller Reconcile Pattern (Go)

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)
    
    // 1. Fetch the Database CR
    var db examplev1.Database
    if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }
    
    // 2. Handle deletion (finalizer)
    if !db.DeletionTimestamp.IsZero() {
        return r.handleDeletion(ctx, &db)
    }
    
    // 3. Ensure StatefulSet exists
    sts := r.desiredStatefulSet(&db)
    controllerutil.SetControllerReference(&db, sts, r.Scheme)
    
    if err := r.CreateOrUpdate(ctx, sts, func() error {
        sts.Spec.Replicas = &db.Spec.Replicas
        sts.Spec.Template.Spec.Containers[0].Image = 
            fmt.Sprintf("postgres:%s", db.Spec.Version)
        return nil
    }); err != nil {
        return ctrl.Result{}, err
    }
    
    // 4. Ensure Service exists
    svc := r.desiredService(&db)
    controllerutil.SetControllerReference(&db, svc, r.Scheme)
    if err := r.CreateOrUpdate(ctx, svc, func() error { return nil }); err != nil {
        return ctrl.Result{}, err
    }
    
    // 5. Update status
    db.Status.Phase = "Ready"
    db.Status.ReadyReplicas = sts.Status.ReadyReplicas
    if err := r.Status().Update(ctx, &db); err != nil {
        return ctrl.Result{}, err
    }
    
    // 6. Requeue after 30s for health check
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

Owner References

# Child resource (StatefulSet) created by operator:
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: production-db
  ownerReferences:
  - apiVersion: example.com/v1
    kind: Database
    name: production-db
    uid: abc-123-def
    controller: true
    blockOwnerDeletion: true

# When Database CR is deleted β†’ StatefulSet is garbage collected
# No orphaned resources!

Finalizers

// Finalizers prevent deletion until cleanup is done
const finalizerName = "databases.example.com/cleanup"

func (r *DatabaseReconciler) handleDeletion(ctx context.Context, db *examplev1.Database) (ctrl.Result, error) {
    if controllerutil.ContainsFinalizer(db, finalizerName) {
        // Perform cleanup
        if err := r.deleteBackups(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
        if err := r.revokeCredentials(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
        
        // Remove finalizer β†’ allows deletion to proceed
        controllerutil.RemoveFinalizer(db, finalizerName)
        if err := r.Update(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
    }
    return ctrl.Result{}, nil
}

Kubebuilder Quickstart

# Initialize operator project
kubebuilder init --domain example.com --repo github.com/example/database-operator

# Create API (CRD + controller)
kubebuilder create api --group database --version v1 --kind Database
# Create Resource [y/n]: y
# Create Controller [y/n]: y

# Edit API types
# api/v1/database_types.go β†’ define spec/status fields

# Edit controller
# internal/controller/database_controller.go β†’ reconcile logic

# Generate CRD manifests
make manifests

# Install CRDs
make install

# Run locally
make run

# Build and push container
make docker-build docker-push IMG=registry.example.com/db-operator:v1

# Deploy to cluster
make deploy IMG=registry.example.com/db-operator:v1

Operator SDK (Alternative)

# Initialize with Operator SDK
operator-sdk init --domain example.com --repo github.com/example/db-operator

# Helm-based operator (no Go code!)
operator-sdk init --plugins helm --domain example.com
operator-sdk create api --group database --version v1 --kind Database \
  --helm-chart=bitnami/postgresql

# Ansible-based operator
operator-sdk init --plugins ansible --domain example.com
operator-sdk create api --group database --version v1 --kind Database
# Edit roles/database/tasks/main.yml with Ansible tasks

Framework Comparison

FrameworkLanguageComplexityBest For
KubebuilderGoMediumProduction Go operators
Operator SDKGo/Ansible/HelmLow-HighRed Hat ecosystem
kopfPythonLowQuick prototypes
KUDODeclarativeLowStateful apps
MetacontrollerJSON hooksLowSimple controllers
shell-operatorBash/PythonLowScripts as operators

Operator Best Practices

Reconciliation:
  βœ… Idempotent β€” running twice produces same result
  βœ… Level-triggered β€” react to current state, not events
  βœ… Handle partial failures β€” don't leave resources in bad state
  βœ… Use owner references β€” automatic garbage collection
  βœ… Update status β€” users need to know what's happening

Robustness:
  βœ… Exponential backoff on errors
  βœ… Finalizers for cleanup on deletion
  βœ… Leader election for HA (multiple replicas)
  βœ… Rate limiting on reconciliation
  βœ… Metrics and health endpoints

Common Issues

Controller not reconciling

RBAC missing β€” controller can’t watch or modify resources. Check: kubectl logs <operator-pod>. Add necessary RBAC rules.

Infinite reconciliation loop

Controller updates status β†’ triggers watch β†’ reconciles again. Use status subresource to avoid triggering on status-only changes.

Orphaned resources after CR deletion

Missing owner references. Set controllerutil.SetControllerReference() on all created resources.

Best Practices

  • Idempotent reconciliation β€” always converge to desired state
  • Owner references on all child resources β€” automatic cleanup
  • Finalizers for external cleanup β€” backups, cloud resources
  • Status updates β€” communicate state to users
  • Leader election β€” run 2+ replicas for HA

Key Takeaways

  • Operators encode operational knowledge as code (CRD + controller)
  • Reconciliation loop: read desired state β†’ compare actual β†’ converge
  • Owner references enable automatic garbage collection of child resources
  • Finalizers ensure cleanup before deletion (external resources, backups)
  • Kubebuilder and Operator SDK are the main frameworks for building operators
#operators #controllers #crd #automation #development
Luca Berton
Written by Luca Berton

Principal Solutions Architect specializing in Kubernetes, AI/GPU infrastructure, and cloud-native platforms. Author of Kubernetes Recipes and creator of CopyPasteLearn courses.

Kubernetes Recipes book cover

Want More Kubernetes Recipes?

This recipe is from Kubernetes Recipes, our 750-page practical guide with hundreds of production-ready patterns.

Luca Berton Ansible Pilot Ansible by Example Open Empower K8s Recipes Terraform Pilot CopyPasteLearn ProteinLens