Writing a Reconciler: ctrl.Request & Reconcile Loop
The reconciler is the heart of an operator: a function Kubernetes calls whenever a watched object needs attention.
ctrl.Request tells you which object to fetch; your code drives the cluster toward the spec and returns when to run again.
Summary
Reconcile(ctx, req) must be idempotent and safe to run many times for the same object.
Fetch the latest CR with r.Get, compare spec to child resources, create or patch gaps, then update status.
Return ctrl.Result{} on success, ctrl.Result{RequeueAfter: duration} to wait, or an error to trigger exponential backoff.
Treat NotFound as success when the object was deleted.
Recipe
Quick-reference recipe card - copy-paste ready.
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var gb webappv1.Guestbook
if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if err := r.ensureFrontend(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}When to reach for this:
- Implementing business logic after kubebuilder scaffolds the controller
- Handling spec changes, resync events, and child object updates
- Encoding backoff when external APIs are temporarily unavailable
- Recording conditions on CR status for kubectl users
Working Example
package controller
import (
"context"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
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"
webappv1 "example.com/guestbook-operator/api/v1"
)
const guestbookFinalizer = "webapp.example.com/finalizer"
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var gb webappv1.Guestbook
if err := r.Get(ctx, req.NamespacedName, &gb); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if gb.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&gb, guestbookFinalizer) {
controllerutil.AddFinalizer(&gb, guestbookFinalizer)
if err := r.Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil
}
} else {
if err := r.teardown(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(&gb, guestbookFinalizer)
if err := r.Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
dep := &appsv1.Deployment{}
depName := types.NamespacedName{Name: gb.Name + "-frontend", Namespace: gb.Namespace}
err := r.Get(ctx, depName, dep)
if apierrors.IsNotFound(err) {
dep = r.desiredDeployment(&gb)
if err := controllerutil.SetControllerReference(&gb, dep, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if err := r.Create(ctx, dep); err != nil {
return ctrl.Result{}, err
}
logger.Info("created Deployment", "name", dep.Name)
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
if err != nil {
return ctrl.Result{}, err
}
gb.Status.Ready = dep.Status.ReadyReplicas >= gb.Spec.FrontendSize
if err := r.Status().Update(ctx, &gb); err != nil {
return ctrl.Result{}, err
}
if !gb.Status.Ready {
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
return ctrl.Result{}, nil
}
func (r *GuestbookReconciler) desiredDeployment(gb *webappv1.Guestbook) *appsv1.Deployment {
replicas := gb.Spec.FrontendSize
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: gb.Name + "-frontend", Namespace: gb.Namespace},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": gb.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": gb.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "frontend",
Image: "nginx:1.27",
}},
},
},
},
}
}
func (r *GuestbookReconciler) teardown(ctx context.Context, gb *webappv1.Guestbook) error {
return nil // delete external DB rows, revoke IAM, etc.
}What this demonstrates:
- Finalizer add/remove with immediate requeue after metadata updates
- Create-on-missing Deployment with owner reference
RequeueAfterwhile waiting for Deployment readiness- Status subresource update separate from spec metadata changes
Deep Dive
How It Works
- The workqueue delivers
ctrl.Request{NamespacedName}entries; it does not pass the object body. - Each reconcile should read fresh state with
Getbecause caches and other controllers may have changed objects mid-flight. - Returning a non-nil
errorrequeues with exponential backoff unless you useRequeueAfterwith nil error. SetupWithManagerconnectsFor(&Guestbook{})so spec/status changes enqueue the parent.
Return Values
| Return | Effect |
|---|---|
ctrl.Result{}, nil | Success; requeue only on future watch events |
ctrl.Result{Requeue: true}, nil | Immediate requeue |
ctrl.Result{RequeueAfter: d}, nil | Delayed requeue without error metric |
ctrl.Result{}, err | Error logged; backoff requeue |
Go Notes
// Patch status conditions without clobbering other fields
meta.SetStatusCondition(&gb.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
Reason: "DeploymentReady",
Message: "frontend replicas ready",
})
// Aggregate multiple errors from child ensures
return ctrl.Result{}, errors.Join(errDeploy, errSvc)Gotchas
- Mutating spec in reconcile without generation checks - fight users and other controllers. Fix: only patch child objects; update CR
specsolely via defaulting webhooks or user edits. - Returning error on NotFound after delete - spurious error metrics. Fix:
client.IgnoreNotFound(err)onGet. - Updating status with
Updateinstead ofStatus().Update- RBAC failures or spec conflicts. Fix: use status subresource client. - Infinite requeue tight loop - hot CPU when external dependency never becomes ready. Fix: always use
RequeueAfterwith sane caps and surfaceDegradedconditions. - Skipping owner references on created children - orphaned Deployments after CR delete. Fix:
SetControllerReferencebeforeCreate. - Assuming reconcile runs exactly once per change - resync and child events retrigger parent. Fix: write level-driven logic, not one-shot side effects.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| controller-runtime Reconciler | Typed kubebuilder operators | You only need a single informer callback |
| Raw workqueue + informer handlers | Fine-grained queue control | You want status helpers and builder pattern |
| Kopf / Java reconcile annotations | Non-Go stacks | You need controller-gen and envtest |
| Job per CR event | Rare batch transforms | Continuous convergence is required |
FAQs
What is inside ctrl.Request?
NamespacedName with Namespace and Name fields.
Fetch the full object yourself with r.Get.
Should reconcile patch or replace child objects?
Use server-side apply or strategic merge patches when you own only part of a shared object.
For owned Deployments you fully control, compare spec and Update or Patch idempotently.
When do I return an error versus RequeueAfter?
Return error for unexpected API failures worth alerting on.
Use RequeueAfter for expected waiting (Deployment not ready yet).
How do child updates retrigger parent reconcile?
Register Owns(&appsv1.Deployment{}) in SetupWithManager so Deployment changes enqueue the owning Guestbook.
What is level-driven design?
Each pass reads the current spec and full cluster state, then converges.
It does not assume you saw every individual event that caused drift.
Can I run long work inside Reconcile?
Keep reconcile fast; delegate long tasks to Jobs or goroutines with careful status tracking.
Long reconcile blocks worker threads and delays other objects.
How do I test reconcile logic?
Use envtest or a fake client, call Reconcile with a constructed ctrl.Request, assert on child objects and status.
What about context cancellation?
Pass ctx to all client calls.
Manager cancellation on shutdown should stop outbound API calls promptly.
Should I use GenerationChangedPredicate?
Use predicates to filter noisy status updates, not to skip necessary level-driven passes on spec changes.
How many workers should I configure?
Default parallelism is usually sufficient.
Raise MaxConcurrentReconciles only when profiling shows queue backlog and API limits allow it.
Related
- controller-runtime Manager, Client & Scheme - Client and Manager wiring
- Watches, Predicates, Owner References & Finalizers - Owns and finalizers
- Operator Testing with envtest & fake client - testing Reconcile
- Kubernetes Operators Best Practices - idempotent design rules
- Kubernetes Operators Basics - first reconciler skeleton
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).