controller-runtime — Middle¶
1. From "log it" to "make it real"¶
A junior reconciler reads an object and logs. A middle-level reconciler observes a custom resource, materializes the dependent resources the user implied (Deployments, Services, ConfigMaps), updates the status to say what's happening, and cleans up on delete.
The shape of every real controller fits one template:
func (r *WidgetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := ctrl.LoggerFrom(ctx)
var w appsv1alpha1.Widget
if err := r.Get(ctx, req.NamespacedName, &w); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 1. Finalizer / deletion
if !w.DeletionTimestamp.IsZero() {
return r.handleDelete(ctx, &w)
}
if err := r.ensureFinalizer(ctx, &w); err != nil {
return ctrl.Result{}, err
}
// 2. Desired children
if err := r.reconcileDeployment(ctx, &w); err != nil {
return ctrl.Result{}, err
}
// 3. Status
return ctrl.Result{}, r.updateStatus(ctx, &w)
}
Three phases — delete handling, desired-state reconciliation, status — in that order. Memorize this template.
2. For, Owns, Watches¶
ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.Widget{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.mapConfigMap)).
Complete(r)
| Method | Triggers reconcile of... | When... |
|---|---|---|
For(&Widget{}) | the Widget itself | the Widget changes |
Owns(&Deployment{}) | the owner Widget | a Widget-owned Deployment changes |
Watches(cm, mapFn) | whatever mapFn returns | the ConfigMap changes |
Owns is implemented via owner references: each child's metadata.ownerReferences points at the parent. controllerutil.SetControllerReference writes that field. Without it, Owns cannot find the parent.
3. Owner references — set them every time¶
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: w.Name,
Namespace: w.Namespace,
},
Spec: desiredSpec(&w),
}
if err := controllerutil.SetControllerReference(&w, dep, r.Scheme); err != nil {
return err
}
Why this matters:
Ownsuses the owner ref to map a child event back to the parent'sRequest.- Kubernetes garbage-collects the child when the parent is deleted — no cleanup code needed for these.
- The owner ref encodes "this thing is mine" — another controller won't fight over it.
SetControllerReference returns an error if a different controller already claims this object. That's a feature, not a bug.
4. CreateOrUpdate — the idempotent upsert¶
desired := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: w.Name, Namespace: w.Namespace},
}
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired, func() error {
desired.Spec.Replicas = &w.Spec.Replicas
desired.Spec.Selector = labelSelector(&w)
desired.Spec.Template = podTemplate(&w)
return controllerutil.SetControllerReference(&w, desired, r.Scheme)
})
What happens inside:
Getthe object from the cache.- If found: copy the existing object into
desired, then call the mutator. - If not found: call the mutator on the empty
desired, thenCreate. - If found and the mutator made changes:
Update.
The function returns controllerutil.OperationResultCreated, Updated, or Unchanged. Useful for metrics.
Pitfall. The mutator must set every field you care about. CreateOrUpdate does not diff against a "previous desired" — it diffs against the current cluster state and writes whatever your mutator put on the object.
5. Status updates¶
Status lives on a separate subresource. A regular Update to the parent does not write status, and writing both at once with Update is forbidden by API-server policy on resources with the status subresource enabled.
w.Status.AvailableReplicas = dep.Status.AvailableReplicas
w.Status.Phase = "Ready"
if err := r.Status().Update(ctx, &w); err != nil {
return err
}
Two patterns to keep status sane:
- Patch instead of update when you only change one field — avoids conflict storms.
- Only update if changed — compare against the fetched object first. Otherwise every reconcile writes status, every status write fires the watch, every event re-runs reconcile → hot loop.
6. Finalizers — the full pattern¶
const finalizer = "widgets.example.com/cleanup"
if w.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&w, finalizer) {
controllerutil.AddFinalizer(&w, finalizer)
return ctrl.Result{}, r.Update(ctx, &w)
}
} else {
if controllerutil.ContainsFinalizer(&w, finalizer) {
if err := r.cleanupExternalResource(ctx, &w); err != nil {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(&w, finalizer)
if err := r.Update(ctx, &w); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
Rules:
- The finalizer string is unique per controller. Use your domain.
- Add it as the first action after fetching the object.
- On delete, the API server sets
deletionTimestampbut keeps the object alive as long as any finalizer is present. Cleanup, then remove the finalizer. - If you skip the finalizer, your cleanup never runs — Kubernetes deletes the object the instant the user issues
kubectl delete.
7. Requeue patterns¶
// Not ready yet — check in 10s
if !ready(&w) {
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// Transient error — let the work-queue back off
if err := r.callExternal(); err != nil {
return ctrl.Result{}, err
}
// Done; trust events to bring us back
return ctrl.Result{}, nil
| Situation | Use |
|---|---|
| Waiting on an external resource | RequeueAfter: someTime |
| Polling a slow rollout | RequeueAfter with backoff |
| Transient API error | return the error |
| Steady state, no work to do | Result{}, nil |
Returning Result{Requeue: true} is rarely what you want — it's "re-queue immediately under backoff", which usually just delays the same outcome.
8. The four-tier RBAC marker set¶
For a controller that owns Widgets and creates Deployments:
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.example.com,resources=widgets/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=,resources=events,verbs=create;patch
| Marker | Why |
|---|---|
widgets | Read and modify the CR itself |
widgets/status | Status subresource is separate |
widgets/finalizers | The API server checks this verb when you write metadata.finalizers |
| Owned kinds | Whatever you Owns(...) |
events | If you emit Kubernetes events via the EventRecorder |
make manifests (in a Kubebuilder project) runs controller-gen on these markers and emits a ClusterRole YAML.
9. Event recorders¶
r.Recorder.Event(&w, corev1.EventTypeNormal, "Reconciled", "Deployment is ready")
r.Recorder.Eventf(&w, corev1.EventTypeWarning, "FailedScale", "scale failed: %v", err)
Get a recorder from the manager:
Events show up in kubectl describe widget and in audit trails. Use them for human-readable lifecycle messages; use logs for debug.
10. Predicates — quieting the loop¶
Without filtering, status writes by your own controller re-fire the watch and re-trigger reconcile. Predicates filter at the source.
ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.Widget{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
Owns(&appsv1.Deployment{}).
Complete(r)
| Predicate | Fires on |
|---|---|
GenerationChangedPredicate{} | .spec (or any other generation-tracked field) changes |
LabelChangedPredicate{} | Labels change |
AnnotationChangedPredicate{} | Annotations change |
predicate.Funcs{CreateFunc: ..., UpdateFunc: ...} | Custom logic |
GenerationChangedPredicate is the single most useful one — it suppresses the status-write-fires-update echo by definition.
11. Multiple resources, one controller¶
Watches with a MapFunc lets you react to changes in other objects. Suppose every Widget references a ConfigMap by name:
func (r *WidgetReconciler) mapConfigMap(ctx context.Context, obj client.Object) []ctrl.Request {
var list appsv1alpha1.WidgetList
_ = r.List(ctx, &list, client.InNamespace(obj.GetNamespace()))
var out []ctrl.Request
for _, w := range list.Items {
if w.Spec.ConfigName == obj.GetName() {
out = append(out, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(&w)})
}
}
return out
}
Builder wiring:
Whenever a ConfigMap changes, every Widget that references it gets re-queued. The cache makes the listing cheap.
12. Logging context¶
LoggerFrom(ctx) returns the logger the manager injected into the context for this reconcile. It already carries controller=widget-controller and name=.... Add anything domain-specific with WithValues.
13. Common middle-level mistakes¶
| Mistake | Effect |
|---|---|
Forgetting SetControllerReference | Owns doesn't fire on child events |
| Writing status on every reconcile | Hot loop with predicate.GenerationChangedPredicate not set |
Using Update for status | Returns "the body of your request is invalid: must not modify status" |
Skipping client.IgnoreNotFound | Deleted objects produce spurious errors and retries |
| Reading after writing in the same reconcile | Cache hasn't seen your write yet — use the returned object or refetch |
Long-running work inside Reconcile | Blocks the work queue; chunk it and re-queue with RequeueAfter |
Returning Result{Requeue: true} after success | Re-queues forever |
14. Summary¶
A middle-level reconciler is structured: handle deletion via finalizers first, materialize children with CreateOrUpdate plus SetControllerReference, then update status via the subresource client only when something changed. Filter the event firehose with predicates — GenerationChangedPredicate is the default for most controllers. Use Owns for kinds your controller authors, Watches with a MapFunc for everything else. RBAC markers live next to the reconciler and generate the role at build time.
Further reading¶
- Kubebuilder book — Reconcile and Watches: https://book.kubebuilder.io/cronjob-tutorial/controller-overview.html
- Owner refs and garbage collection: https://kubernetes.io/docs/concepts/architecture/garbage-collection/
- Finalizers: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/
controllerutilgodoc: https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/controller/controllerutil