Overview

A Kubernetes Operator is a pattern that packages the operational knowledge of a specific application (like deployment, scaling, backup, recovery) into automated controller software. Instead of manually editing YAML or running kubectl commands, Operators monitor custom resources and automatically reconcile the cluster toward the desired state.

This guide provides a deep dive into Operator development—from CRD design principles and Controller reconciliation loops to operator-sdk/kubebuilder hands-on development, testing, and release.

Based on Kubernetes v1.30, operator-sdk v1.37, kubebuilder v4. Use either tool; both produce equivalent results.

Operator Core Concepts

What Is an Operator

An Operator = Custom Resource Definition (CRD) + Controller.

┌─────────────────────────────────────────────────────────────┐
│                      Operator                                 │
│  ┌────────────────────┐    ┌────────────────────────────┐   │
│  │   Custom Resource   │    │       Controller            │   │
│  │   Definition (CRD)  │    │   ┌─────────────────────┐  │   │
│  │                      │    │   │   Watch CR changes   │  │   │   │
│  │   apiVersion: app/v1 │    │   └──────────┬──────────┘  │   │
│  │   kind: MyApp         │    │              ▼              │   │
│  │   spec:               │    │   ┌─────────────────────┐  │   │
│  │     replicas: 3       │    │   │   Compare & Diff     │  │   │
│  │     image: nginx      │    │   └──────────┬──────────┘  │   │
│  │     port: 80          │    │              ▼              │   │
│  │   status:             │    │   ┌─────────────────────┐  │   │
│  │     readyReplicas: 3  │    │   │   Reconcile (Act)    │  │   │
│  └────────────────────┘    │   └─────────────────────┘  │   │
│                              └────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Relationship with Built-in Controllers

K8s built-in controllers (Deployment, StatefulSet, etc.) are generic—they handle common patterns but can’t cover application-specific operational logic. Operators extend this pattern to any application:

FeatureBuilt-in ControllerOperator
Resource typeBuilt-in (Deployment, etc.)Custom (CRD)
Domain logicGenericApplication-specific
Operational knowledgeNoneEncodes expert knowledge
Automation levelBasic lifecycleFull lifecycle + domain ops
ExampleDeploy NginxDeploy and manage PostgreSQL HA cluster

When to Use an Operator

ScenarioUse Operator?Reason
Stateless web appNoDeployment + HPA suffices
Database HA clusterYesRequires complex failover, backup logic
Message queue clusterYesPartition rebalancing, order constraints
ML training pipelineYesNeeds GPU scheduling, checkpoint management
Custom config managementMaybeConsider ConfigMap + init container first
Multi-service app stackYesCross-service orchestration

Operator Capability Levels

The Operator Capability Model defines five levels:

LevelNameDescriptionExample
1Basic InstallDeploy and configure appCreate Deployment, Service
2Seamless UpgradesManaged upgradesRolling update, schema migration
3Full LifecycleBackup, recovery, scalingPeriodic backup, restore
4Deep InsightsMetrics, alertsPrometheus metrics, health checks
5Auto PilotAuto-tuning, auto-healingAuto-scaling, auto-failover

CRD Design

CRD Structure

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: myapps.app.example.com
spec:
  group: app.example.com
  names:
    kind: MyApp
    listKind: MyAppList
    singular: myapp
    plural: myapps
    shortNames:
    - ma
  scope: Namespaced              # Namespaced or Cluster
  versions:
  - name: v1alpha1
    served: true
    storage: true                # Only one version can be storage=true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            required:
            - replicas
            - image
            properties:
              replicas:
                type: integer
                minimum: 1
                maximum: 100
                default: 1
              image:
                type: string
              port:
                type: integer
                minimum: 1
                maximum: 65535
                default: 80
              resources:
                type: object
                properties:
                  requests:
                    type: object
                    properties:
                      cpu:
                        type: string
                      memory:
                        type: string
              env:
                type: array
                items:
                  type: object
                  properties:
                    name:
                      type: string
                    value:
                      type: string
          status:
            type: object
            properties:
              readyReplicas:
                type: integer
              phase:
                type: string
                enum:
                - Pending
                - Running
                - Failed
              conditions:
                type: array
                items:
                  type: object
                  properties:
                    type:
                      type: string
                    status:
                      type: string
                      enum: ["True", "False", "Unknown"]
                    lastTransitionTime:
                      type: string
                      format: date-time
                    reason:
                      type: string
                    message:
                      type: string
    subresources:
      status: {}                 # Enable /status subresource
      scale:                     # Enable /scale subresource
        specReplicasPath: .spec.replicas
        statusReplicasPath: .status.readyReplicas

Design Principles

  1. Declarative, not imperative: Users declare desired state, not steps to execute.
  2. idempotent: Re-applying the same spec should have no side effects.
  3. status reflects reality: status fields must accurately reflect actual cluster state.
  4. Don’t duplicate built-in resources: Don’t create another Deployment; extend or compose.
  5. Version from the start: Start with v1alpha1, not v1.

API Versioning Strategy

VersionDescriptionStability
v1alpha1Initial developmentMay break
v1beta1Pre-release, likely stableMinor changes
v1Stable GANo breaking changes

Conversion between versions is handled by a Conversion Webhook.

Controller Principles

Reconciliation Loop

The Controller’s core pattern is the Reconciliation Loop (Reconcile loop):

┌─────────────────────────────────────────────────────────────┐
│                     Reconcile Loop                            │
│                                                              │
│   ┌──────────────┐                                           │
│   │  Watch events │ ← CR changes, managed resource changes,  │
│   │  (Watch)      │   periodic resync                        │
│   └──────┬───────┘                                           │
│          ▼                                                   │
│   ┌──────────────┐                                           │
│   │  Read CR spec │ ← Desired state                          │
│   └──────┬───────┘                                           │
│          ▼                                                   │
│   ┌──────────────┐                                           │
│   │  Read cluster │ ← Actual state                           │
│   │  resources    │                                          │
│   └──────┬───────┘                                           │
│          ▼                                                   │
│   ┌──────────────┐                                           │
│   │  Diff & Decide│                                          │
│   │  what to do   │                                          │
│   └──────┬───────┘                                           │
│          ▼                                                   │
│   ┌──────────────┐                                           │
│  │  Act: Create/ │ → Create/Update/Delete resources         │
│  │  Update/Delete│                                          │
│   └──────┬───────┘                                           │
│          ▼                                                   │
│   ┌──────────────┐                                           │
│   │  Update status│ → Write actual state back to CR status  │
│   └──────────────┘                                           │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Reconcile Function

The Reconcile function is the Controller’s heart:

// Reconcile is part of the main kubernetes reconciliation loop
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. Get the CR instance
    var myApp appv1.MyApp
    if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
        if errors.IsNotFound(err) {
            return ctrl.Result{}, nil    // CR deleted, nothing to do
        }
        return ctrl.Result{}, err
    }

    // 2. Check if deletion is in progress
    if myApp.DeletionTimestamp.IsZero() {
        // Not being deleted — ensure finalizer exists
        if !controllerutil.ContainsFinalizer(&myApp, "myapp.finalizer.app.example.com") {
            controllerutil.AddFinalizer(&myApp, "myapp.finalizer.app.example.com")
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        // Being deleted — run cleanup logic
        return r.reconcileDelete(ctx, &myApp)
    }

    // 3. Reconcile to desired state
    if err := r.reconcileDeployment(ctx, &myApp); err != nil {
        return ctrl.Result{}, err
    }

    if err := r.reconcileService(ctx, &myApp); err != nil {
        return ctrl.Result{}, err
    }

    // 4. Update status
    if err := r.updateStatus(ctx, &myApp); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

Reconcile Key Principles

  1. Don’t pass req to helper functions: Pass CR instance explicitly.
  2. Don’t return too early: Every branch should return a Result.
  3. Always update status: Reflect actual state in CR status.
  4. Handle not-found gracefully: Resource may have been deleted.
  5. Use RequeueAfter for periodic checks: For polling external state.
Return ValueMeaning
ctrl.Result{}, nilDone, no requeue
ctrl.Result{Requeue: true}, nilRequeue immediately
ctrl.Result{RequeueAfter: 30s}, nilRequeue after 30s
ctrl.Result{}, errError, requeue with backoff

Level-Driven vs Edge-Triggered

K8s controllers are level-driven, not edge-triggered:

Edge-triggered: Act only on events (e.g., when CR changes, do X)
Level-driven:   Always compare desired state vs actual state, fix any drift

This means even without events, the Controller periodically reconciles to catch any drift caused by external changes.

Development with operator-sdk

Project Initialization

# Install operator-sdk
curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.37.0/operator-sdk_linux_amd64
chmod +x operator-sdk_linux_amd64
sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

# Create project
mkdir myapp-operator
cd myapp-operator
operator-sdk init \
  --domain example.com \
  --repo github.com/example/myapp-operator

# Create API (CRD + Controller)
operator-sdk create api \
  --group app \
  --version v1alpha1 \
  --kind MyApp \
  --resource \
  --controller

Project Structure

myapp-operator/
├── api/
│   └── v1alpha1/
│       ├── myapp_types.go          # CRD type definitions
│       ├── groupversion_info.go    # GroupVersion info
│       ├── zz_generated.deepcopy.go # Auto-generated DeepCopy methods
│       └── zz_generated.openapi.go  # Auto-generated OpenAPI schemas
├── cmd/
│   └── main.go                      # Controller Manager entry point
├── config/
│   ├── crd/                         # CRD manifests
│   ├── default/                     # Default config
│   ├── manager/                     # Controller Manager deployment
│   ├── manifests/                   # Kustomize manifests
│   ├── prometheus/                  # ServiceMonitor
│   ├── rbac/                        # RBAC permissions
│   └── samples/                     # CR samples
├── internal/
│   └── controller/
│       ├── myapp_controller.go      # Controller implementation
│       └── myapp_controller_test.go  # Controller tests
├── Dockerfile
├── Makefile
├── go.mod
└── PROJECT

Type Definitions

// api/v1alpha1/myapp_types.go
package v1alpha1

import (
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// MyAppSpec defines the desired state of MyApp
type MyAppSpec struct {
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=100
    // +kubebuilder:default=1
    Replicas int32 `json:"replicas"`

    // +kubebuilder:validation:Required
    Image string `json:"image"`

    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=65535
    // +kubebuilder:default=80
    Port int32 `json:"port,omitempty"`

    Resources corev1.ResourceRequirements `json:"resources,omitempty"`

    // +kubebuilder:validation:Enum=RollingUpdate;Recreate
    // +kubebuilder:default=RollingUpdate
    Strategy string `json:"strategy,omitempty"`
}

// MyAppStatus defines the observed state of MyApp
type MyAppStatus struct {
    ReadyReplicas int32 `json:"readyReplicas"`

    // +kubebuilder:validation:Enum=Pending;Running;Failed
    Phase string `json:"phase,omitempty"`

    Conditions []metav1.Condition `json:"conditions,omitempty"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.readyReplicas

type MyApp struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   MyAppSpec   `json:"spec,omitempty"`
    Status MyAppStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

type MyAppList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []MyApp `json:"items"`
}

func init() {
    SchemeBuilder.Register(&MyApp{}, &MyAppList{})
}

Controller Implementation

// internal/controller/myapp_controller.go
package controller

import (
    "context"
    "fmt"
    "time"

    appv1 "github.com/example/myapp-operator/api/v1alpha1"
    "github.com/go-logr/logr"
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/api/resource"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/util/intstr"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/log"
)

const finalizerName = "myapp.finalizer.app.example.com"

type MyAppReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=app.example.com,resources=myapps,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=app.example.com,resources=myapps/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=app.example.com,resources=myapps/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch

func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. Get MyApp instance
    var myApp appv1.MyApp
    if err := r.Get(ctx, req.NamespacedName, &myApp); err != nil {
        if errors.IsNotFound(err) {
            logger.Info("MyApp resource not found, ignoring")
            return ctrl.Result{}, nil
        }
        logger.Error(err, "failed to get MyApp")
        return ctrl.Result{}, err
    }

    // 2. Handle finalizer
    if myApp.DeletionTimestamp.IsZero() {
        if !controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            controllerutil.AddFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
            return ctrl.Result{Requeue: true}, nil
        }
    } else {
        // Being deleted — run cleanup
        if controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            if err := r.cleanupResources(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
            controllerutil.RemoveFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // 3. Reconcile Deployment
    if err := r.reconcileDeployment(ctx, &myApp); err != nil {
        logger.Error(err, "failed to reconcile Deployment")
        r.setCondition(&myApp, "Ready", "False", "DeploymentReconcileFailed", err.Error())
        _ = r.updateStatus(ctx, &myApp)
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }

    // 4. Reconcile Service
    if err := r.reconcileService(ctx, &myApp); err != nil {
        logger.Error(err, "failed to reconcile Service")
        return ctrl.Result{}, err
    }

    // 5. Update status
    if err := r.updateStatus(ctx, &myApp); err != nil {
        logger.Error(err, "failed to update status")
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

func (r *MyAppReconciler) reconcileDeployment(ctx context.Context, myApp *appv1.MyApp) error {
    logger := log.FromContext(ctx)

    var deployment appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{
        Name:      myApp.Name,
        Namespace: myApp.Namespace,
    }, &deployment)

    if errors.IsNotFound(err) {
        // Create new Deployment
        newDeploy := r.buildDeployment(myApp)
        if err := controllerutil.SetControllerReference(myApp, newDeploy, r.Scheme); err != nil {
            return err
        }
        logger.Info("Creating Deployment", "name", newDeploy.Name)
        return r.Create(ctx, newDeploy)
    }
    if err != nil {
        return err
    }

    // Update existing Deployment
    updated := r.buildDeployment(myApp)
    deployment.Spec = updated.Spec
    logger.Info("Updating Deployment", "name", deployment.Name)
    return r.Update(ctx, &deployment)
}

func (r *MyAppReconciler) buildDeployment(myApp *appv1.MyApp) *appsv1.Deployment {
    replicas := myApp.Spec.Replicas
    return &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      myApp.Name,
            Namespace: myApp.Namespace,
            Labels: map[string]string{
                "app.kubernetes.io/name":     "myapp",
                "app.kubernetes.io/instance": myApp.Name,
            },
        },
        Spec: appsv1.DeploymentSpec{
            Replicas: &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{
                    "app.kubernetes.io/name":     "myapp",
                    "app.kubernetes.io/instance": myApp.Name,
                },
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{
                        "app.kubernetes.io/name":     "myapp",
                        "app.kubernetes.io/instance": myApp.Name,
                    },
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "app",
                            Image: myApp.Spec.Image,
                            Ports: []corev1.ContainerPort{
                                {
                                    ContainerPort: myApp.Spec.Port,
                                    Protocol:      corev1.ProtocolTCP,
                                },
                            },
                            Resources: myApp.Spec.Resources,
                        },
                    },
                },
            },
        },
    }
}

func (r *MyAppReconciler) reconcileService(ctx context.Context, myApp *appv1.MyApp) error {
    var svc corev1.Service
    err := r.Get(ctx, client.ObjectKey{
        Name:      myApp.Name,
        Namespace: myApp.Namespace,
    }, &svc)

    if errors.IsNotFound(err) {
        newSvc := r.buildService(myApp)
        if err := controllerutil.SetControllerReference(myApp, newSvc, r.Scheme); err != nil {
            return err
        }
        return r.Create(ctx, newSvc)
    }
    if err != nil {
        return err
    }

    // Update Service ports if changed
    updated := r.buildService(myApp)
    svc.Spec.Ports = updated.Spec.Ports
    return r.Update(ctx, &svc)
}

func (r *MyAppReconciler) buildService(myApp *appv1.MyApp) *corev1.Service {
    return &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      myApp.Name,
            Namespace: myApp.Namespace,
            Labels: map[string]string{
                "app.kubernetes.io/name":     "myapp",
                "app.kubernetes.io/instance": myApp.Name,
            },
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{
                "app.kubernetes.io/name":     "myapp",
                "app.kubernetes.io/instance": myApp.Name,
            },
            Ports: []corev1.ServicePort{
                {
                    Port:       myApp.Spec.Port,
                    TargetPort: intstr.FromInt(int(myApp.Spec.Port)),
                    Protocol:   corev1.ProtocolTCP,
                },
            },
            Type: corev1.ServiceTypeClusterIP,
        },
    }
}

func (r *MyAppReconciler) updateStatus(ctx context.Context, myApp *appv1.MyApp) error {
    // Read Deployment to get ready replicas
    var deploy appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{
        Name:      myApp.Name,
        Namespace: myApp.Namespace,
    }, &deploy)
    if err != nil && !errors.IsNotFound(err) {
        return err
    }

    readyReplicas := int32(0)
    if err == nil {
        readyReplicas = deploy.Status.ReadyReplicas
    }

    // Update status only if changed
    if myApp.Status.ReadyReplicas != readyReplicas {
        myApp.Status.ReadyReplicas = readyReplicas
        if readyReplicas == myApp.Spec.Replicas {
            myApp.Status.Phase = "Running"
            r.setCondition(myApp, "Ready", "True", "AllReplicasReady",
                fmt.Sprintf("All %d replicas are ready", readyReplicas))
        } else if readyReplicas > 0 {
            myApp.Status.Phase = "Pending"
            r.setCondition(myApp, "Ready", "False", "NotAllReplicasReady",
                fmt.Sprintf("%d/%d replicas ready", readyReplicas, myApp.Spec.Replicas))
        } else {
            myApp.Status.Phase = "Pending"
            r.setCondition(myApp, "Ready", "False", "NoReplicasReady",
                "No replicas are ready")
        }
        return r.Status().Update(ctx, myApp)
    }
    return nil
}

func (r *MyAppReconciler) setCondition(
    myApp *appv1.MyApp,
    conditionType string,
    status string,
    reason string,
    message string,
) {
    metav1.SetStatusCondition(&myApp.Status.Conditions, metav1.Condition{
        Type:               conditionType,
        Status:             metav1.ConditionStatus(status),
        Reason:             reason,
        Message:            message,
        LastTransitionTime: metav1.Now(),
    })
}

func (r *MyAppReconciler) cleanupResources(ctx context.Context, myApp *appv1.MyApp) error {
    // Delete Deployment
    var deploy appsv1.Deployment
    if err := r.Delete(ctx, &deploy, &client.DeleteOptions{
        Preconditions: &metav1.Preconditions{},
    }); err != nil && !errors.IsNotFound(err) {
        return err
    }
    return nil
}

// SetupWithManager sets up the controller with the Manager
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&appv1.MyApp{}).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Complete(r)
}

main.go Configuration

// cmd/main.go
package main

import (
    "flag"
    "os"

    appv1 "github.com/example/myapp-operator/api/v1alpha1"
    "github.com/example/myapp-operator/internal/controller"
    "k8s.io/apimachinery/pkg/runtime"
    utilruntime "k8s.io/apimachinery/pkg/util/runtime"
    clientgoscheme "k8s.io/client-go/kubernetes/scheme"
    _ "k8s.io/client-go/plugin/pkg/client/auth"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/healthz"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
)

var scheme = runtime.NewScheme()

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))
    utilruntime.Must(appv1.AddToScheme(scheme))
}

func main() {
    var metricsAddr string
    var enableLeaderElection bool
    var probeAddr string
    flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Metrics address")
    flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Probe address")
    flag.BoolVar(&enableLeaderElection, "leader-elect", false,
        "Enable leader election for controller manager")
    opts := zap.Options{
        Development: true,
    }
    opts.BindFlags(flag.CommandLine)
    flag.Parse()

    ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        Metrics:                metricsserver.Options{BindAddress: metricsAddr},
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "myapp-operator.app.example.com",
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

    if err := (&controller.MyAppReconciler{
        Client: mgr.GetClient(),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "MyApp")
        os.Exit(1)
    }

    if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
        setupLog.Error(err, "unable to set up health check")
        os.Exit(1)
    }
    if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
        setupLog.Error(err, "unable to set up ready check")
        os.Exit(1)
    }

    setupLog.Info("starting manager")
    if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
        setupLog.Error(err, "problem running manager")
        os.Exit(1)
    }
}

Finalizer

What Is a Finalizer

A Finalizer is a special marker that prevents resource deletion until cleanup completes:

1. User deletes CR
2. K8s sets DeletionTimestamp but does NOT delete resource
3. Controller sees DeletionTimestamp, runs cleanup logic
4. Controller removes finalizer
5. K8s deletes resource

Finalizer Implementation

const finalizerName = "myapp.finalizer.app.example.com"

func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // ...

    if myApp.DeletionTimestamp.IsZero() {
        // Not being deleted — ensure finalizer exists
        if !controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            controllerutil.AddFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
    } else {
        // Being deleted — run cleanup
        if controllerutil.ContainsFinalizer(&myApp, finalizerName) {
            // Run cleanup logic (e.g., delete external resources)
            if err := r.cleanupExternalResources(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }

            // Remove finalizer
            controllerutil.RemoveFinalizer(&myApp, finalizerName)
            if err := r.Update(ctx, &myApp); err != nil {
                return ctrl.Result{}, err
            }
        }
        return ctrl.Result{}, nil
    }

    // ... normal reconciliation
}

func (r *MyAppReconciler) cleanupExternalResources(ctx context.Context, myApp *appv1.MyApp) error {
    // Clean up external resources (e.g., cloud load balancers, DNS records, databases)
    // ...
    return nil
}

Note: If finalizer removal fails, the CR gets stuck in Terminating state. Ensure cleanup logic is robust.

Leader Election

Why Leader Election

In production, the Controller Manager runs with multiple replicas for HA. If all replicas reconcile simultaneously, conflicts occur. Leader election ensures only one active controller:

3 replicas → 1 Leader (active) + 2 Standby (idle)
Leader fails → One Standby becomes new Leader

Enabling Leader Election

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    // ...
    LeaderElection:   true,
    LeaderElectionID: "myapp-operator.app.example.com",
    // Lease parameters
    LeaseDuration: ptr.To(15 * time.Second),  // Lease duration
    RenewDeadline:  ptr.To(10 * time.Second),  // Renew interval
    RetryPeriod:    ptr.To(2 * time.Second),   // Retry interval
})
# config/manager/manager.yaml
spec:
  replicas: 3    # Run 3 replicas for HA
  template:
    spec:
      containers:
      - command:
        - /manager
        args:
        - --leader-elect    # Enable leader election
        - --leader-election-id=myapp-operator.app.example.com

Testing

Unit Testing

// internal/controller/myapp_controller_test.go
package controller

import (
    "context"
    "testing"

    appv1 "github.com/example/myapp-operator/api/v1alpha1"
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/resource"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/types"
    "k8s.io/client-go/kubernetes/scheme"
    "sigs.k8s.io/controller-runtime/pkg/client/fake"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
)

func TestReconcile_Create(t *testing.T) {
    // Create a fake MyApp CR
    myApp := &appv1.MyApp{
        ObjectMeta: metav1.ObjectMeta{
            Name:      "test-app",
            Namespace: "default",
        },
        Spec: appv1.MyAppSpec{
            Replicas: 3,
            Image:    "nginx:1.25",
            Port:     80,
            Resources: corev1.ResourceRequirements{
                Requests: corev1.ResourceList{
                    corev1.ResourceCPU:    resource.MustParse("100m"),
                    corev1.ResourceMemory: resource.MustParse("128Mi"),
                },
            },
        },
    }

    // Build fake client
    cl := fake.NewClientBuilder().
        WithScheme(scheme.Scheme).
        WithObjects(myApp).
        WithStatusSubresource(&appv1.MyApp{}).
        Build()

    r := &MyAppReconciler{
        Client: cl,
        Scheme: scheme.Scheme,
    }

    // Call Reconcile
    req := reconcile.Request{
        NamespacedName: types.NamespacedName{
            Name:      "test-app",
            Namespace: "default",
        },
    }
    _, err := r.Reconcile(context.Background(), req)
    if err != nil {
        t.Fatalf("reconcile failed: %v", err)
    }

    // Verify Deployment was created
    var deploy appsv1.Deployment
    err = cl.Get(context.Background(), req.NamespacedName, &deploy)
    if err != nil {
        t.Fatalf("failed to get Deployment: %v", err)
    }
    if *deploy.Spec.Replicas != 3 {
        t.Errorf("expected replicas=3, got %d", *deploy.Spec.Replicas)
    }

    // Verify Service was created
    var svc corev1.Service
    err = cl.Get(context.Background(), req.NamespacedName, &svc)
    if err != nil {
        t.Fatalf("failed to get Service: %v", err)
    }
}

Envtest Integration Testing

# Install envtest (sets up a real etcd + kube-apiserver)
make envtest

# Run integration tests
make test
// internal/controller/myapp_controller_test.go
func TestReconcile_WithEnvtest(t *testing.T) {
    // Start test environment
    testEnv := &envtest.Environment{
        CRDDirectoryPaths:     []string{filepath.Join("..", "..", "config", "crd", "bases")},
        ErrorIfCRDPathMissing: true,
    }
    cfg, _ := testEnv.Start()
    defer testEnv.Stop()

    // Create Manager
    scheme := runtime.NewScheme()
    _ = appv1.AddToScheme(scheme)
    _ = clientgoscheme.AddToScheme(scheme)

    k8sClient, _ := client.New(cfg, client.Options{Scheme: scheme})

    // Create CR
    myApp := &appv1.MyApp{...}
    _ = k8sClient.Create(context.Background(), myApp)

    // Run Reconcile
    // ...
}

Build and Deploy

Build the Image

# Generate manifests
make manifests

# Generate code (DeepCopy etc.)
make generate

# Build and push image
make docker-build docker-push IMG=harbor.example.com/myapp-operator:v0.1.0

Deploy to Cluster

# Install CRD
make install

# Deploy Controller Manager
make deploy IMG=harbor.example.com/myapp-operator:v0.1.0

# Verify
kubectl get pods -n myapp-operator-system
kubectl get crd myapps.app.example.com

Create a Custom Resource

# config/samples/app_v1alpha1_myapp.yaml
apiVersion: app.example.com/v1alpha1
kind: MyApp
metadata:
  name: myapp-sample
spec:
  replicas: 3
  image: nginx:1.25-alpine
  port: 80
  resources:
    requests:
      cpu: 100m
      memory: 128Mi
    limits:
      cpu: 200m
      memory: 256Mi
kubectl apply -f config/samples/app_v1alpha1_myapp.yaml

# Verify
kubectl get myapp myapp-sample -o yaml
kubectl get deployment myapp-sample
kubectl get svc myapp-sample

Observability

Metrics

import (
    "sigs.k8s.io/controller-runtime/pkg/metrics"
    "github.com/prometheus/client_golang/prometheus"
)

var (
    reconcileTotal = prometheus.NewCounter(
        prometheus.CounterOpts{
            Name: "myapp_reconcile_total",
            Help: "Total number of reconciliations",
        },
    )
    reconcileErrors = prometheus.NewCounter(
        prometheus.CounterOpts{
            Name: "myapp_reconcile_errors_total",
            Help: "Total number of reconciliation errors",
        },
    )
    reconcileDuration = prometheus.NewHistogram(
        prometheus.HistogramOpts{
            Name:    "myapp_reconcile_duration_seconds",
            Help:    "Reconciliation duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
    )
)

func init() {
    metrics.Registry.MustRegister(reconcileTotal)
    metrics.Registry.MustRegister(reconcileErrors)
    metrics.Registry.MustRegister(reconcileDuration)
}

Events

import "k8s.io/client-go/tools/record"

type MyAppReconciler struct {
    client.Client
    Scheme   *runtime.Scheme
    Recorder record.EventRecorder
}

// In Reconcile:
r.Recorder.Eventf(&myApp, corev1.EventTypeNormal, "Created",
    "Deployment %s created", myApp.Name)

r.Recorder.Eventf(&myApp, corev1.EventTypeWarning, "Failed",
    "Failed to create Deployment: %v", err)

ServiceMonitor

# config/prometheus/monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: myapp-operator-metrics
  labels:
    app: myapp-operator
spec:
  selector:
    matchLabels:
      app: myapp-operator
  endpoints:
  - port: metrics
    path: /metrics
    interval: 30s

Common Patterns

Owner References

// Set owner reference so child resources are garbage-collected with parent
if err := controllerutil.SetControllerReference(myApp, deployment, r.Scheme); err != nil {
    return err
}

Status Subresource

// Update status separately from spec
// This prevents accidentally overwriting spec when updating status
if err := r.Status().Update(ctx, myApp); err != nil {
    return err
}

Optimistic Locking

// Avoid conflicts with concurrent updates
patch := client.MergeFrom(myApp.DeepCopy())
myApp.Status.ReadyReplicas = readyReplicas
if err := r.Status().Patch(ctx, myApp, patch); err != nil {
    return err
}

Event Filtering

// Only reconcile when relevant fields change
// Use predicates to filter events
func (r *MyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&appv1.MyApp{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Complete(r)
}

Common Pitfalls

PitfallImpactSolution
Forgetting FinalizerExternal resources leakAdd finalizer on creation
Status update overwrites specConfiguration lostUse .Status().Update()
No error handling in ReconcileSilent failuresLog all errors
Non-idempotent operationsDuplicated resourcesCheck before create
Watching too many resourcesPerformance issuesFilter events
Ignoring context cancellationGoroutine leakPass context
Overwriting OwnerReferencesGarbage collection failsUse SetControllerReference

Summary

Operator development is a powerful pattern for automating K8s application management. Key takeaways:

  1. CRD design is critical: Well-designed API is more important than Controller implementation. Follow declarative, idempotent, and versioned principles.
  2. Reconcile loop is the core: Understand the level-driven reconciliation pattern. Don’t think in terms of events; think in terms of desired vs actual state.
  3. Always use Finalizers: External resource cleanup is essential to prevent leaks.
  4. Leader Election for HA: Production deployments must use leader election to avoid multi-active conflicts.
  5. Test thoroughly: Use envtest for integration testing to catch real API behavior.
  6. Observability from the start: Metrics, events, and logs are essential for production debugging.
  7. Use operator-sdk/kubebuilder: Don’t write Controller from scratch—use scaffolding tools to reduce boilerplate.
  8. Start simple, iterate: Begin with Level 1 (Basic Install) and add capabilities incrementally.

Operators are not a silver bullet—they’re appropriate for stateful, complex applications with significant operational knowledge. For simple stateless apps, Deployment + HPA is sufficient.

References & Acknowledgments

This article referenced the following materials during writing. We thank the original authors for their contributions:

  1. Operator 模式文档 — Kubernetes Official, referenced for Operator 模式文档
  2. Operator Capability Model — Operatorhub, referenced for Operator Capability Model