🔄 The Control Loop — Kubernetes' Core Idea

Every Kubernetes controller — Deployment controller, ReplicaSet controller, your operator — runs the same fundamental loop:

Observe read desired + actual Diff desired ≠ actual? Act create / update / delete Done requeue? watch event or periodic resync triggers next loop

This loop runs continuously. The controller never "finishes" — it just reconciles on demand whenever the world drifts from the desired state.

What an Operator adds on top

A plain Kubernetes controller manages built-in types (Pods, Services). An Operator extends this pattern to domain-specific operations by combining:

  1. A CRD that models domain concepts (e.g. Database, KafkaCluster, Certificate)
  2. A controller that watches those CRDs and reconciles them — creating Pods, PVCs, Secrets, Services, calling external APIs
  3. Operational knowledge encoded in code: backup logic, upgrade sequences, failure recovery, topology-aware scaling

The Reconcile function signature

In controller-runtime (the Go library used by Kubebuilder and Operator SDK), every controller implements one method:

// Reconcile is called whenever an event affects a Database resource
func (r *DatabaseReconciler) Reconcile(
    ctx context.Context,
    req ctrl.Request,           // contains namespace + name of the resource
) (ctrl.Result, error) {

    // 1. Fetch the current state of the resource
    db := &myorgv1.Database{}
    if err := r.Get(ctx, req.NamespacedName, db); err != nil {
        if apierrors.IsNotFound(err) {
            return ctrl.Result{}, nil  // deleted — nothing to do
        }
        return ctrl.Result{}, err
    }

    // 2. Compute desired state and reconcile child resources
    if err := r.reconcileStatefulSet(ctx, db); err != nil {
        return ctrl.Result{}, err
    }
    if err := r.reconcileService(ctx, db); err != nil {
        return ctrl.Result{}, err
    }

    // 3. Update status to reflect current state
    db.Status.Phase = "Ready"
    if err := r.Status().Update(ctx, db); err != nil {
        return ctrl.Result{}, err
    }

    // 4. Return — optionally requeue after a period
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}
💡 Return values
  • ctrl.Result{} — success, no requeue (watch events will re-trigger)
  • ctrl.Result{Requeue: true} — requeue immediately (use sparingly)
  • ctrl.Result{RequeueAfter: 30s} — requeue after a delay (for polling external state)
  • ctrl.Result{}, err — error, requeue with exponential backoff

♻️ Idempotency — Run It a Million Times

A reconcile function must be idempotent: running it once or a thousand times with the same desired state must produce the same outcome with no side effects. The controller has no idea how many times it's been called — it must always be safe to call again.

Idempotent resource management pattern

// createOrUpdate — the idempotent resource management primitive
func (r *DatabaseReconciler) reconcileService(
    ctx context.Context,
    db *myorgv1.Database,
) error {
    desired := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      db.Name + "-svc",
            Namespace: db.Namespace,
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{"app": db.Name},
            Ports: []corev1.ServicePort{{
                Port:       5432,
                TargetPort: intstr.FromInt(5432),
            }},
        },
    }

    // Set the Database as the owner so it's GC'd when the CR is deleted
    ctrl.SetControllerReference(db, desired, r.Scheme)

    // CreateOrUpdate: safe to call on every reconcile
    _, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired,
        func() error {
            desired.Spec.Ports = []corev1.ServicePort{{
                Port:       5432,
                TargetPort: intstr.FromInt(5432),
            }}
            return nil
        })
    return err
}

Non-idempotent operations — handle carefully

Some operations are inherently non-idempotent (sending an email, charging a card, triggering a backup). Handle these with:

  • Conditions in status — record "BackupInitiated" with a timestamp; skip if already set
  • Annotations as semaphoreslast-backup: "2024-01-15T10:00:00Z"
  • Generation tracking — only act if db.Generation != db.Status.ObservedGeneration
// Skip backup if already done for this generation
if db.Status.ObservedGeneration == db.Generation &&
   db.Status.LastBackupTime != nil {
    return ctrl.Result{}, nil
}

// Trigger backup exactly once per generation change
if err := r.triggerBackup(ctx, db); err != nil {
    return ctrl.Result{}, err
}
db.Status.ObservedGeneration = db.Generation
db.Status.LastBackupTime = &metav1.Time{Time: time.Now()}
return ctrl.Result{}, r.Status().Update(ctx, db)

🌍 Real-World Operator Examples

OperatorCRDWhat it manages
cert-managerCertificate, Issuer, ClusterIssuerTLS cert lifecycle — requests, renewals, ACME challenges
Prometheus OperatorPrometheus, ServiceMonitor, PrometheusRulePrometheus instances, scrape config, alert rules
StrimziKafka, KafkaTopic, KafkaUserKafka cluster lifecycle, topic creation, ACLs
CloudNativePGCluster, Backup, ScheduledBackupPostgreSQL HA clusters, streaming replication, PITR backups
ArgoCDApplication, AppProjectGitOps deployments, sync status, rollback
CrossplaneComposition, CompositeResourceCloud infrastructure (S3, RDS, VPC) as Kubernetes resources

Operator maturity levels

The Operator Framework defines five capability levels — a useful way to scope your operator's ambition:

Level 1 — Basic Install

Automates installation and configuration. CRD creates the necessary Kubernetes resources.

Level 2 — Seamless Upgrades

Handles patch and minor version upgrades with zero downtime. Knows upgrade ordering rules.

Level 3 — Full Lifecycle

Backup, restore, failure recovery. Understands domain-specific failure modes.

Level 4 — Deep Insights

Exposes metrics, alerts, SLO dashboards. Surfaces operational health in status conditions.

🔵 Level 5 — Auto Pilot Horizontal/vertical auto-scaling, auto-config tuning, anomaly detection, self-healing beyond simple restarts. Most production operators target Level 3–4.

⚡ Level-Triggered vs Edge-Triggered

This is one of the most important design concepts in Kubernetes controllers — and the source of many operator bugs when misunderstood.

Edge-triggeredLevel-triggered
Reacts to State changes (events: "Pod created", "Deployment updated") Current state ("desired replicas = 3, actual = 2")
Risk Miss an event → controller gets stuck in wrong state forever Must re-read state on every reconcile — slightly more work
Resilience Fragile — crash between events = lost update Robust — restarting the controller catches up automatically
Kubernetes approach Watch events used only to trigger a reconcile, not to carry data Reconcile always re-reads current state from API server

The golden rule: never trust the event, always re-read

A watch event tells you "something changed". It does NOT tell you exactly what changed, and the event payload may be stale by the time you process it. The correct pattern:

// ❌ WRONG — edge-triggered: using the event payload directly
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    event := getEventFromQueue()          // stale! another update may have happened
    if event.Type == "ADDED" {
        r.createStatefulSet(event.Object) // using potentially stale data
    }
    return ctrl.Result{}, nil
}

// ✅ CORRECT — level-triggered: always re-read current state
func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    db := &myorgv1.Database{}
    r.Get(ctx, req.NamespacedName, db)    // fresh read from API server cache

    // Work from current desired state, not from event payload
    desired := r.buildStatefulSet(db)
    existing := &appsv1.StatefulSet{}

    err := r.Get(ctx, client.ObjectKeyFromObject(desired), existing)
    if apierrors.IsNotFound(err) {
        return ctrl.Result{}, r.Create(ctx, desired)
    }
    // Compare and patch if different
    if !reflect.DeepEqual(existing.Spec, desired.Spec) {
        existing.Spec = desired.Spec
        return ctrl.Result{}, r.Update(ctx, existing)
    }
    return ctrl.Result{}, nil
}

Periodic resync — the safety net

Even with level-triggered reconciliation, watches can miss events (network partition, controller restart). controller-runtime's resync period re-enqueues every watched object periodically as a safety net:

// Set resync period when building the controller manager
mgr, _ := ctrl.NewManager(cfg, ctrl.Options{
    SyncPeriod: func() *time.Duration {
        d := 10 * time.Minute
        return &d
    }(),
})

// Or per-controller via WithOptions
ctrl.NewControllerManagedBy(mgr).
    For(&myorgv1.Database{}).
    WithOptions(controller.Options{
        MaxConcurrentReconciles: 5,
    }).
    Complete(r)
⚠️ Avoid time.Sleep in Reconcile Never use time.Sleep to wait for external state — it blocks the goroutine and reduces controller throughput. Instead, return ctrl.Result{RequeueAfter: duration} so the work queue can handle other items while waiting.

🧠 Knowledge Check

Q1. A Reconcile function returns ctrl.Result{}, err. What happens next?

A) The controller crashes and the pod restarts
B) The item is dropped from the queue permanently
C) The item is requeued with exponential backoff for retry
D) The error is written to the CRD's status field automatically

Q2. Why should a Reconcile function always re-read the resource from the API server rather than using the watch event payload?

A) The API server has lower latency than the watch stream
B) The event payload may be stale — re-reading ensures you work from current state (level-triggered correctness)
C) Watch events only contain metadata, not the full object
D) The API server caches objects faster than the informer

Q3. Your operator needs to send an email notification when a Database CR is first provisioned. How do you ensure the email is sent exactly once even if Reconcile runs multiple times?

A) Use a global in-memory map to track which CRs have been notified
B) Add if reconcileCount == 1 logic at the start of Reconcile
C) Check a status condition (e.g. "EmailSent: True") before sending; set it after the first successful send
D) Use ctrl.Result{Requeue: false} to prevent any further reconciliation

Q4. What is the difference between returning ctrl.Result{Requeue: true} and ctrl.Result{RequeueAfter: 30s}?

A) They are identical — both requeue the item immediately
B) Requeue: true only works after an error
C) RequeueAfter cancels all pending watch events
D) Requeue: true re-enqueues immediately; RequeueAfter schedules a delayed requeue — use After for slow external polling