🔄 The Control Loop — Kubernetes' Core Idea
Every Kubernetes controller — Deployment controller, ReplicaSet controller, your operator — runs the same fundamental 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:
- A CRD that models domain concepts (e.g.
Database,KafkaCluster,Certificate) - A controller that watches those CRDs and reconciles them — creating Pods, PVCs, Secrets, Services, calling external APIs
- 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
}
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 semaphores —
last-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
| Operator | CRD | What it manages |
|---|---|---|
| cert-manager | Certificate, Issuer, ClusterIssuer | TLS cert lifecycle — requests, renewals, ACME challenges |
| Prometheus Operator | Prometheus, ServiceMonitor, PrometheusRule | Prometheus instances, scrape config, alert rules |
| Strimzi | Kafka, KafkaTopic, KafkaUser | Kafka cluster lifecycle, topic creation, ACLs |
| CloudNativePG | Cluster, Backup, ScheduledBackup | PostgreSQL HA clusters, streaming replication, PITR backups |
| ArgoCD | Application, AppProject | GitOps deployments, sync status, rollback |
| Crossplane | Composition, CompositeResource | Cloud 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-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-triggered | Level-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)
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?
Q2. Why should a Reconcile function always re-read the resource from the API server rather than using the watch event payload?
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?
if reconcileCount == 1 logic at the start of Reconcilectrl.Result{Requeue: false} to prevent any further reconciliation