Assumes you're comfortable with the reconciliation loop and the Operator pattern's conceptual shape from Part 4, and RBAC/ServiceAccount mechanics from Part 11 — this chapter goes deep on the actual client-go and controller-runtime mechanics Part 4 only sketched, rather than re-introducing the Operator pattern itself.
Table of Contents#
- Why This Part Exists
- Recap: The Reconciliation Loop, and Where Part 4 Left Off
- client-go Architecture: Informers, Listers, and the Local Cache
- Inside the SharedInformer: Reflector, DeltaFIFO, and the Indexer
- The Workqueue: Deduplication, Rate Limiting, and Backoff
- A Minimal Raw client-go Controller, End to End
- controller-runtime's Manager, Cache, and Client
- Kubebuilder Project Scaffolding, Walked Through
- Designing the
TenantOnboardingOperator - Writing the Reconciler
- Owner References and Garbage Collection Between Custom Resources
- Finalizers in Practice — Safe Cleanup Before Deletion
- CRD Versioning and Conversion Webhooks in Practice
- Validating and Mutating Admission Webhooks for Custom Resources
- Status Subresources and Conditions
- Testing Controllers with envtest
- Leader Election and Running Operators Highly Available
- Upgrading an Operator Safely — CRD and Controller Rollout Order
- Observability for Operators — Metrics, Events, and Logs
- Common Controller Anti-Patterns, Explained Two Levels Deep
- A Full Worked Scenario: Debugging a Runaway Reconcile Loop in Production
- A Full Worked Scenario: A Botched CRD Migration Without a Conversion Webhook
- Part 19 CLI Cheat Sheet
- Common Mistakes and Interview Traps
- Worked Practice Problems
- Summary and What's Next
- Where to Go From Here
- Closing the Series
Why This Part Exists#
Part 4 introduced the Operator pattern conceptually — a Reconcile function sketch, the "controller-runtime
handles the boilerplate" framing, finalizers and idempotency named as principles — and left every mechanism
underneath that sketch unexplained: how does a controller actually learn that an object changed, without
polling the API server in a tight loop? What exactly queues a reconcile request, and what stops the same
object from being reconciled twice concurrently? This chapter answers those questions at the actual
client-go/controller-runtime mechanics level, then builds a real, working Operator from scaffolding
through tests.
The throughline gains its most code-heavy addition yet: a TenantOnboarding custom resource and its
tenant-operator, automating exactly the manual checklist Part 13 introduced for bringing a new tenant
namespace onto the shared cluster — a ResourceQuota, NetworkPolicy, and baseline RoleBinding, applied
consistently instead of by hand. Building this operator over the course of the chapter is a deliberate
illustration that Part 13's onboarding checklist and this chapter's reconciler are the same operational
knowledge, expressed two different ways — a human-followed runbook versus continuously-running code.
Note
Every code example in this chapter is Go, since that's the language client-go, controller-runtime, Kubebuilder, and the overwhelming majority of real-world Operators are written in — there's no serious alternative ecosystem for this specific job today, unlike, say, choosing a scripting language for a CI pipeline.
Recap: The Reconciliation Loop, and Where Part 4 Left Off#
Every controller in Kubernetes, built-in or custom, follows the same three-step loop Part 1 introduced and Part 4 applied to Operators specifically: observe the current state, compare it to the desired state, act to close any gap. What Part 4 didn't cover is how a controller efficiently observes state for millions of objects across a live cluster without either polling constantly or missing changes — that's the specific gap the next three sections close, in the order a request for a new object actually flows through the machinery.
| Part 4 covered | This chapter covers |
|---|---|
| The reconciliation loop, conceptually | The exact mechanism that triggers each Reconcile call |
A representative Reconcile function sketch | A complete, working Reconciler type, scaffolded and tested |
| "Finalizers handle cleanup," named as a principle | A worked finalizer implementation, including the failure mode where it goes wrong |
| CRD versioning's hub-and-spoke idea | A working conversion webhook, generated and wired up |
client-go Architecture: Informers, Listers, and the Local Cache#
A naive controller could kubectl get every object it cares about on every reconcile — this would work
correctness-wise and destroy the API server's performance the moment more than a handful of controllers did
it simultaneously. The Informer pattern exists specifically to solve this: watch once, cache locally, read
from the cache everywhere.
Every controller watching the same resource type shares this exact same cache — a SharedInformer means
one watch connection to the API server serves every controller in the process, not one watch per controller.
The critical performance property worth stating precisely: a Lister's Get/List calls never touch the
API server at all — they read directly from the local Indexer, an in-memory cache kept current by the
Reflector's ongoing watch. A controller's Reconcile function calling Get a thousand times across a busy
reconcile cycle costs the API server exactly zero additional requests, which is precisely what makes it safe
for dozens of controllers to run in the same cluster without coordinating their read patterns.
Important
SharedInformerFactory is the mechanism that makes the "shared" part real — creating informers for the
same GroupVersionResource through the same factory returns the same underlying informer and cache to
every caller, rather than each controller standing up its own independent watch. Two controllers
independently calling client-go's bare NewSharedInformer (bypassing the factory) each open their own
separate watch connection to the API server — a real, avoidable load multiplier that the factory pattern
exists specifically to prevent.
Inside the SharedInformer: Reflector, DeltaFIFO, and the Indexer#
Tracing exactly what happens between "an object changed on the API server" and "the local cache reflects it" is worth doing once precisely, since a surprising number of subtle controller bugs trace back to a misunderstanding of this exact sequence.
The cache update and the handler notification happen from the same Pop() call — an event handler can
always trust that the Indexer already reflects the change it was just notified about, never a stale prior
version.
DeltaFIFO's specific job is worth naming precisely: it's a FIFO queue of deltas (add/update/delete),
not a queue of full objects, and it compresses redundant deltas for the same key — if an object is updated
three times before a consumer processes it, DeltaFIFO collapses those into the object's current state
rather than replaying three separate stale intermediate versions. This compression is exactly why a
well-written Reconcile must never assume it's being told "what changed" — by the time it runs, several
raw watch events for the same object may have already been folded into one.
The Indexer adds one further capability beyond a plain cache: secondary indexes, letting a controller
efficiently look up objects by something other than name — e.g., "every TenantOnboarding whose
spec.namespace matches X" — without a linear scan across every cached object, the same underlying idea as
a database index applied to an in-memory object cache.
The Workqueue: Deduplication, Rate Limiting, and Backoff#
A raw stream of watch events, delivered straight to Reconcile one at a time, has a real problem: if the
same object changes five times in a second, a naive controller would run Reconcile five times, when one
reconcile against the object's current state would have been enough — the workqueue exists specifically to
collapse this down and add retry discipline on top.
queue := workqueue.NewTypedRateLimitingQueue(
workqueue.DefaultTypedControllerRateLimiter[string](),
)
// Event handler: never pushes the object itself, only its key
informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
key, _ := cache.MetaNamespaceKeyFunc(obj)
queue.Add(key)
},
UpdateFunc: func(old, new interface{}) {
key, _ := cache.MetaNamespaceKeyFunc(new)
queue.Add(key)
},
})| Workqueue property | What it guarantees |
|---|---|
| Deduplication | Adding the same key while it's already queued (or already being processed) is a no-op — the key is only ever processed once per "batch" of changes, not once per individual event |
AddRateLimited(key) | Requeues a key after a failed reconcile, with exponential backoff — a handful of retries in the first second, widening out to roughly every 16-17 minutes for a persistently failing key |
Forget(key) | Clears a key's backoff history after a successful reconcile — the next unrelated failure starts backoff fresh, rather than inheriting an old failure streak |
| Keys, never objects | The queue holds only namespace/name strings — the actual current object is always re-fetched from the Lister's cache at processing time, never carried through the queue itself |
Important
Queueing keys instead of objects is the single design decision that makes the "level-based, not
edge-triggered" principle from Part 4 actually work in practice. By the time a queued key is popped and
processed, the object may have changed again — re-fetching it fresh from the Lister at that moment,
rather than processing whatever version triggered the original enqueue, guarantees Reconcile always acts
on genuinely current state, never a stale snapshot from whenever the event first fired.
A Minimal Raw client-go Controller, End to End#
Seeing the informer, workqueue, and a bare reconcile function wired together directly — without controller-runtime's abstraction over them — is worth doing once, purely so the abstraction in the next section reads as "the same thing, with the boilerplate factored out" rather than as unfamiliar magic.
func Run(ctx context.Context, client kubernetes.Interface) error {
factory := informers.NewSharedInformerFactory(client, 30*time.Second)
podInformer := factory.Core().V1().Pods()
queue := workqueue.NewTypedRateLimitingQueue(
workqueue.DefaultTypedControllerRateLimiter[string](),
)
podInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(o interface{}) { enqueue(queue, o) },
UpdateFunc: func(_, o interface{}) { enqueue(queue, o) },
DeleteFunc: func(o interface{}) { enqueue(queue, o) },
})
factory.Start(ctx.Done())
factory.WaitForCacheSync(ctx.Done()) // block until the initial List completes
for processNextItem(queue, podInformer.Lister()) {
}
return nil
}
func processNextItem(q workqueue.TypedRateLimitingInterface[string], lister v1.PodLister) bool {
key, shutdown := q.Get()
if shutdown {
return false
}
defer q.Done(key)
if err := reconcile(key, lister); err != nil {
q.AddRateLimited(key) // retry with backoff
return true
}
q.Forget(key)
return true
}WaitForCacheSync deserves its own emphasis: reconciling before the informer's initial List has
finished populating the cache means every Get/List call sees a partially-populated, misleadingly-empty
view of the cluster — a controller that skips this call can make genuinely wrong decisions on startup
(e.g., concluding an object doesn't exist yet, when it simply hasn't synced into the cache), a real and
easy-to-introduce bug in a hand-rolled controller that controller-runtime's Manager handles automatically,
covered next.
controller-runtime's Manager, Cache, and Client#
Almost no real-world Operator is written directly against raw client-go anymore — controller-runtime
wraps informers, listers, and the workqueue behind a small set of well-designed abstractions, and Kubebuilder
and Operator SDK both scaffold projects on top of it.
The Client your Reconciler calls looks like a single, ordinary Kubernetes client — reads are
transparently served from the Cache (the same Indexer mechanism from earlier), writes go straight to the
API server, and the Reconciler code never has to know or care about that split.
| Component | Role |
|---|---|
Manager | The top-level object every controller-runtime binary runs — owns the shared Cache, the Client, leader election, health/metrics servers, and the lifecycle of every registered Controller |
Cache | A collection of SharedInformers, one per watched GroupVersionKind — the same Reflector/DeltaFIFO/Indexer machinery from earlier sections, just no longer hand-wired |
Client | A read-through-cache, write-through-API-server client — the only thing a Reconciler typically ever touches directly |
Controller | Owns exactly one workqueue and watches one primary resource type (plus any number of secondary "owned" or "watched" types that also trigger reconciles) |
Kubebuilder Project Scaffolding, Walked Through#
Kubebuilder generates a complete, working project skeleton from a single command — worth understanding what each generated piece actually is, rather than treating the scaffold as opaque boilerplate to work around.
kubebuilder init --domain platform.example.com --repo github.com/example/tenant-operator
kubebuilder create api --group platform --version v1alpha1 --kind TenantOnboarding --resource --controllerTwo of these generated pieces are worth calling out specifically, since they're easy to under-appreciate as "just generated boilerplate":
config/rbac/role.yamlis generated from//+kubebuilder:rbacmarker comments directly above theReconcilefunction — the exact least-privilege discipline Part 11 covered manually for application ServiceAccounts is here derived automatically from the code that actually needs each permission, rather than hand-maintained separately from what the controller does.config/crd/bases/*.yamlis generated from Go struct tags on theSpec/Statustypes — the CRD's OpenAPI schema (required fields, enums, validation) lives as Go type annotations, not as hand-written YAML kept in sync with the code by hand.
//+kubebuilder:rbac:groups=platform.example.com,resources=tenantonboardings,verbs=get;list;watch;update
//+kubebuilder:rbac:groups="",resources=resourcequotas;namespaces,verbs=get;list;watch;create;update
//+kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update
func (r *TenantOnboardingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// ...
}Designing the TenantOnboarding Operator#
Before writing the Reconcile function itself, the CRD's schema is the actual design decision — what a
platform team writes to onboard a tenant, and what the operator is responsible for turning that into.
apiVersion: platform.example.com/v1alpha1
kind: TenantOnboarding
metadata:
name: recommendations-team
spec:
namespace: recommendations
resourceQuota:
cpu: "20"
memory: 64Gi
gpuCount: 4
networkPolicy:
allowEgressTo: ["catalog", "kube-system"]This is a direct, literal translation of Part 13's onboarding checklist and Part 14's GPU quota discussion
into a declarative spec — a platform engineer applies one TenantOnboarding object instead of manually
running through a multi-step checklist, and the operator is responsible for making the Namespace,
ResourceQuota, and NetworkPolicy objects match what that spec declares, continuously, the same
"encode the runbook as code" principle Part 4 introduced generally.
| Object the operator manages | Sourced from |
|---|---|
Namespace (created if missing) | spec.namespace |
ResourceQuota (including requests.nvidia.com/gpu, Part 14) | spec.resourceQuota |
NetworkPolicy (default-deny plus explicit allows, Part 11) | spec.networkPolicy.allowEgressTo |
Writing the Reconciler#
The Reconcile function itself, written the correct, idempotent, level-based way Part 4 named as a
principle — every call re-derives the full desired state and applies it, regardless of what specifically
triggered this particular invocation.
func (r *TenantOnboardingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var onboarding platformv1alpha1.TenantOnboarding
if err := r.Get(ctx, req.NamespacedName, &onboarding); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: onboarding.Spec.Namespace}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ns, func() error {
return nil // Namespace has no spec fields this operator manages beyond existence
}); err != nil {
return ctrl.Result{}, err
}
quota := buildResourceQuota(&onboarding)
if err := controllerutil.SetControllerReference(&onboarding, quota, r.Scheme); err != nil {
return ctrl.Result{}, err
}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, quota, func() error {
quota.Spec = buildResourceQuotaSpec(&onboarding)
return nil
}); err != nil {
return ctrl.Result{}, err
}
onboarding.Status.Ready = true
if err := r.Status().Update(ctx, &onboarding); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}controllerutil.CreateOrUpdate is doing the actual "observe, compare, act" work in three lines: it reads
the current object if one exists, applies the mutation function, and only issues a write if something
actually changed — this is the concrete mechanism behind "idempotent, level-based reconciliation," not an
abstract principle floating separate from real code.
From the Trenches: An early version of this reconciler set the
ResourceQuota's spec unconditionally on every reconcile, without wrapping the mutation inCreateOrUpdate's diff-and-only-write-if-changed behavior. Under normal operation this was invisible — but during a control-plane slowdown that caused theTenantOnboardinginformer to redeliver a backlog of stale-looking update events, the operator issued thousands of no-opResourceQuotawrites in a few minutes, each one triggering its own watch event to every other controller watchingResourceQuotaobjects cluster-wide. The immediate cause was skipping the diff-before-write step; the underlying condition was that "idempotent" was treated as "safe to call repeatedly" without also asking whether repeated calls were cheap for the rest of the cluster watching the same object type — a distinction that only matters at exactly the moment a backlog of events actually arrives.
Owner References and Garbage Collection Between Custom Resources#
Every object the TenantOnboarding reconciler creates should carry an owner reference back to the
TenantOnboarding object itself — controllerutil.SetControllerReference, used in the reconciler above, is
what actually establishes this, and it's what makes deleting the parent object clean up everything it
created automatically, without the reconciler ever writing explicit delete logic for most of its children.
Owner references get this specific cleanup case (namespaced children of a namespaced parent) for free from
Kubernetes's built-in garbage collector — no reconciler code is needed to delete the ResourceQuota/
NetworkPolicy when the TenantOnboarding object is deleted.
Note
Owner references only support cascading deletion within the same namespace for namespaced owners — a
cluster-scoped resource (like the Namespace object itself, created earlier in the reconciler) cannot be
owned by a namespaced TenantOnboarding, since owner references require the owner and the owned object to
share a namespace unless the owner is itself cluster-scoped. This is exactly why the Namespace object in
this chapter's reconciler is created directly rather than owner-referenced — it needs its own explicit
finalizer-based cleanup logic instead, covered next.
Finalizers in Practice — Safe Cleanup Before Deletion#
The Namespace object the previous section flagged as un-owner-referenceable is exactly the case a
finalizer solves: cleanup logic the operator must run before Kubernetes considers the TenantOnboarding
object actually gone, for anything owner references can't reach automatically.
const tenantFinalizer = "platform.example.com/tenant-cleanup"
func (r *TenantOnboardingReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var onboarding platformv1alpha1.TenantOnboarding
if err := r.Get(ctx, req.NamespacedName, &onboarding); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if onboarding.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&onboarding, tenantFinalizer) {
controllerutil.AddFinalizer(&onboarding, tenantFinalizer)
return ctrl.Result{}, r.Update(ctx, &onboarding)
}
} else {
// Object is being deleted — run cleanup BEFORE removing the finalizer
if controllerutil.ContainsFinalizer(&onboarding, tenantFinalizer) {
if err := r.deleteNamespace(ctx, onboarding.Spec.Namespace); err != nil {
return ctrl.Result{}, err // retry — finalizer stays, object stays Terminating
}
controllerutil.RemoveFinalizer(&onboarding, tenantFinalizer)
return ctrl.Result{}, r.Update(ctx, &onboarding)
}
return ctrl.Result{}, nil
}
// ... normal reconciliation continues below
return ctrl.Result{}, nil
}An object stuck in Terminating forever is almost always this exact diagram, stuck on the self-loop —
cleanup keeps failing, or the controller that owns the finalizer isn't running at all to ever attempt it.
Warning
The most common real-world cause of an object "stuck in Terminating" is a finalizer whose owning
controller no longer exists — the operator was uninstalled, its Deployment scaled to zero, or its CRD
was deleted without first removing finalizers from every instance. Kubernetes has no timeout or fallback
for an unmet finalizer; it holds the object in Terminating indefinitely. Before uninstalling any
Operator, confirm no live custom resources still carry its finalizers — kubectl patch to manually strip
the finalizer is the correct emergency recovery, but it means whatever external cleanup that finalizer was
supposed to guarantee (here, actually deleting the tenant's Namespace) never happens, and needs to be
done by hand afterward.
CRD Versioning and Conversion Webhooks in Practice#
Part 4 covered the hub-and-spoke concept abstractly — here's what actually changes when
TenantOnboarding evolves from v1alpha1 to v1, renaming spec.resourceQuota to a more general
spec.quota that can eventually cover non-GPU resource types uniformly.
kubebuilder create api --group platform --version v1 --kind TenantOnboarding --resource --controller=false// v1 is the hub — v1alpha1 implements Convertible against it
func (src *TenantOnboarding) ConvertTo(dstRaw conversion.Hub) error {
dst := dstRaw.(*v1.TenantOnboarding)
dst.Spec.Quota = src.Spec.ResourceQuota // field rename happens here
dst.Spec.Namespace = src.Spec.Namespace
return nil
}
func (dst *TenantOnboarding) ConvertFrom(srcRaw conversion.Hub) error {
src := srcRaw.(*v1.TenantOnboarding)
dst.Spec.ResourceQuota = src.Spec.Quota
dst.Spec.Namespace = src.Spec.Namespace
return nil
}# config/crd/bases — kubebuilder-generated, wiring the webhook in
versions:
- name: v1alpha1
served: true
storage: false
- name: v1
served: true
storage: true
conversion:
strategy: Webhook
webhook:
conversionReviewVersions: ["v1alpha1", "v1"]controller-runtime generates and serves the /convert HTTP endpoint automatically from the
ConvertTo/ConvertFrom methods above — inside the same manager binary as the reconciler itself, with no
separate webhook deployment to run. The only code an operator author writes is the two conversion functions;
the HTTP handling, TLS, and API server integration are all generated scaffolding.
Tip
Migrate the storage version deliberately and explicitly rather than assuming it happens automatically —
flipping storage: true to the new version only changes what new writes get stored as. Every existing
v1alpha1-shaped object already in etcd stays stored in its original form until it's next written, unless
a one-time kube-storage-version-migrator job (or an equivalent forced rewrite, the same pattern Part 11
used for retroactive Secret encryption) explicitly re-persists every existing object under the new
storage version.
Validating and Mutating Admission Webhooks for Custom Resources#
Part 1's admission chain and Part 4's sidecar-injection mutating webhook both applied to built-in resources — the exact same admission mechanism is available for a custom resource too, and Kubebuilder scaffolds it the same way it scaffolds a conversion webhook.
Validation runs strictly after mutation — a validating webhook always sees the final, already-defaulted object, never the raw request a user actually submitted.
//+kubebuilder:webhook:path=/validate-platform-example-com-v1-tenantonboarding,mutating=false,failurePolicy=fail,groups=platform.example.com,resources=tenantonboardings,verbs=create;update,versions=v1,name=vtenantonboarding.kb.io
func (v *TenantOnboardingValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
onboarding := obj.(*platformv1alpha1.TenantOnboarding)
if onboarding.Spec.ResourceQuota.GPUCount > clusterAvailableGPUs(ctx) {
return nil, fmt.Errorf(
"requested GPU count %d exceeds cluster capacity", onboarding.Spec.ResourceQuota.GPUCount,
)
}
return nil, nil
}| Webhook kind | Runs when | Typical use in a custom Operator |
|---|---|---|
| Mutating | Before validation | Fill in sensible defaults (spec.resourceQuota.cpu if a platform team leaves it unset), inject standard labels |
| Validating | After mutation, before persistence | Reject a spec that's structurally valid YAML but semantically wrong (a GPU request exceeding real cluster capacity, per Part 14's supply-constrained GPU quota problem) |
Tip
Prefer expressing simple, static validation rules (a field's allowed enum values, a required-together
field pair) directly in the CRD's OpenAPI schema via Kubebuilder's //+kubebuilder:validation markers,
and reserve an actual validating webhook for rules that need to check against live cluster state —
like this section's GPU capacity check — which a static schema can't express at all. Reaching for a
webhook when a schema marker would do adds an extra network hop and a new availability dependency
(the webhook service itself) to every single admission of that resource type, for no benefit over the
simpler, static option.
Warning
failurePolicy: fail (the default, and generally the correct choice for a validating webhook enforcing a
real invariant) means the API server rejects the request outright if the webhook itself is unreachable —
which also means a bug or outage in the webhook's own Deployment can block every create/update of that
resource type cluster-wide, including from the Operator's own reconciler if it ever needs to update the
object. Run the webhook server with the same leader-election-independent high availability as the main
controller (multiple replicas behind a Service, never a single pod) precisely because its blast radius on
failure is the entire resource type, not just this one Operator's own reconciliation.
Status Subresources and Conditions#
The Status().Update() call in this chapter's reconciler writes to a genuinely separate API endpoint from
a plain Update() — the status subresource — and understanding why that split exists prevents a specific,
common source of unnecessary reconcile churn.
type TenantOnboardingStatus struct {
Ready bool `json:"ready"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}| Update kind | Endpoint | Triggers a reconcile in controllers watching this type? |
|---|---|---|
r.Update(ctx, obj) | Main resource endpoint (spec and metadata) | Yes — any watcher of this type sees the change |
r.Status().Update(ctx, obj) | The /status subresource | Yes, but only the status portion changed — a controller can filter on this to avoid reconciling on its own status writes |
Conditions (a standard, structured slice of type/status/reason/message/lastTransitionTime)
is the conventional way an Operator communicates why it's in a given state, not just whether it is —
kubectl describe tenantonboarding renders these directly, giving a platform engineer the same kind of
human-readable status a built-in Deployment's kubectl describe output already provides.
meta.SetStatusCondition(&onboarding.Status.Conditions, metav1.Condition{
Type: "QuotaProvisioned",
Status: metav1.ConditionTrue,
Reason: "ResourceQuotaCreated",
Message: "ResourceQuota successfully applied to the tenant namespace",
})Important
A Reconciler that watches its own primary resource type and updates that same object's status on every
reconcile must guard against re-triggering itself in an infinite loop — this is precisely why the split
above exists. Filtering out status-only self-triggered events (controller-runtime's predicate package
supports this directly) is the standard fix, covered again in this chapter's anti-patterns section under
"reconcile loop explosions."
Testing Controllers with envtest#
envtest starts a real kube-apiserver and etcd binary locally — no mocking of the Kubernetes API at
all — making it the right tool for testing what a Reconciler actually does against real API server
behavior (admission, defaulting, status subresource semantics) rather than a hand-built fake that might not
match.
var _ = Describe("TenantOnboarding controller", func() {
It("creates a ResourceQuota matching the spec", func() {
onboarding := &platformv1alpha1.TenantOnboarding{
ObjectMeta: metav1.ObjectMeta{Name: "test-tenant"},
Spec: platformv1alpha1.TenantOnboardingSpec{
Namespace: "test-tenant-ns",
ResourceQuota: platformv1alpha1.QuotaSpec{CPU: "4", Memory: "8Gi"},
},
}
Expect(k8sClient.Create(ctx, onboarding)).To(Succeed())
Eventually(func() error {
var quota corev1.ResourceQuota
return k8sClient.Get(ctx, types.NamespacedName{
Name: "test-tenant-quota", Namespace: "test-tenant-ns",
}, "a)
}, "10s", "250ms").Should(Succeed())
})
})| Test layer | Tool | Catches |
|---|---|---|
Pure logic (e.g., buildResourceQuotaSpec) | Plain Go unit tests, or the fake client | Business-logic bugs with no real API server interaction needed |
| Reconciler behavior end to end | envtest (real API server + etcd, no scheduler/kubelet) | Whether the reconciler's actual API calls produce the right objects, including status subresource and finalizer behavior |
| Full runtime behavior (pod scheduling, actual container startup) | A real cluster (kind, a cloud dev cluster) | Anything envtest explicitly doesn't simulate — pods never actually run under envtest |
Tip
Use Eventually(), never a bare synchronous assertion immediately after a Create/Update call, for
anything a controller-runtime reconciler is expected to react to — the reconcile loop runs asynchronously
relative to the test's own Create call, exactly as it would against a real cluster, and a test that
doesn't account for that timing will be flaky rather than reliably wrong or reliably right.
Leader Election and Running Operators Highly Available#
Running two replicas of an Operator for availability creates an obvious risk if both are allowed to reconcile simultaneously: two concurrent, possibly conflicting writes to the same object from two processes that don't know about each other — leader election exists specifically to prevent this.
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
LeaderElection: true,
LeaderElectionID: "tenant-operator-leader",
})Only the elected leader replica actually runs any Reconcile calls — every standby replica's Manager
sits idle, ready to acquire leadership (via a Lease object, the same primitive Part 1's own control-plane
leader election covers for kube-controller-manager/kube-scheduler) the moment the current leader stops
renewing it, whether from a graceful shutdown or a crash. This gives an Operator deployment genuine
high availability — a second replica ready to take over within the lease's configured renewal window —
without ever risking two active reconcilers stepping on each other's writes.
Upgrading an Operator Safely — CRD and Controller Rollout Order#
Part 15 covered cluster upgrade sequencing in general — an Operator upgrade has its own, narrower version of the same "order matters" lesson, specifically around the relationship between a CRD's schema and the controller code that reads it.
The same "expand, then contract" migration discipline a relational-database schema change uses applies directly here — add the new field as optional first, roll out code that can handle both its presence and absence, and only make it mandatory once every object in the cluster is confirmed to already have it.
| Rollout order | Risk |
|---|---|
| New controller before new CRD schema | The controller's Go struct expects fields the CRD doesn't yet define/allow — the API server rejects writes the controller tries to make |
| New CRD schema (with a new required field) before any existing objects have it | Every existing object instantly fails validation on its next write, including the controller's own routine status updates |
| New CRD schema (new field optional, sensibly defaulted) before the new controller | Existing objects remain valid; the old controller simply ignores the new field it doesn't know about yet — genuinely safe |
Important
This is exactly why this chapter's CRD versioning section treated adding a new field as something to introduce as optional first, tightened to required only later — the "expand, then contract" pattern above is the general form of that same specific lesson, and it applies to plain in-place schema additions just as much as to a full version bump with a conversion webhook. A required field added directly, with no expand phase, breaks every object that predates it the instant the new schema is applied.
Observability for Operators — Metrics, Events, and Logs#
controller-runtime exposes a Prometheus metrics endpoint automatically, without any extra code, giving every custom controller the same observability baseline Part 10 established for cluster-wide monitoring generally.
| Metric | What it reveals |
|---|---|
controller_runtime_reconcile_total{result="success"|"error"} | Overall reconcile success/error rate, per controller |
controller_runtime_reconcile_time_seconds | Reconcile latency distribution — a rising p99 often precedes a full reconcile-loop explosion |
workqueue_depth | How many keys are currently queued but not yet processed — a sustained rise means the controller can't keep up with incoming events |
workqueue_retries_total | How often keys are being requeued after failure — a leading indicator for the anti-patterns in the next section |
Kubernetes Event objects are the other half of Operator observability, and the half most often
skipped — emitting a normal, human-readable Event (r.Recorder.Event(&onboarding, corev1.EventTypeNormal, "QuotaProvisioned", "ResourceQuota created for tenant namespace")) surfaces directly in
kubectl describe tenantonboarding, giving a platform engineer immediate, in-context troubleshooting
information without needing to correlate a separate log stream or metrics dashboard at all.
Common Controller Anti-Patterns, Explained Two Levels Deep#
Every pattern below has caused a real, recurring class of production incident across the Operator ecosystem — worth internalizing the "why," not just memorizing the list.
- Edge-triggered logic instead of level-based. Symptom: a controller behaves correctly under normal
operation but silently drifts from desired state after a missed event (a controller restart during a busy
window, a dropped watch reconnect). Immediate cause:
Reconcilebranches on "what just changed" instead of re-deriving the full desired state from scratch every time. Underlying condition:client-go/controller-runtimeexplicitly do not guarantee every individual event is delivered — restarts, resyncs, andDeltaFIFOcompression (covered earlier) can all legitimately collapse or skip intermediate states, and only a reconciler that treats every invocation as "figure out what's needed right now" is actually correct under that guarantee. - Reconcile loop explosions from self-triggered status updates. Symptom: a controller's CPU and API
server request volume climb steadily with no corresponding change in real cluster activity. Immediate
cause: the reconciler watches its own resource type and unconditionally writes to
statuson every reconcile, and that status write itself re-triggers a watch event, creating a tight self-sustaining loop. Underlying condition: the reconciler never applied the predicate-based status-change filtering covered earlier, so it can't distinguish "something meaningful changed" from "I just wrote my own status update a moment ago." - Missing or dangling finalizers. Symptom: objects stuck in
Terminatingindefinitely, or (the opposite failure) external resources silently orphaned after a CR is deleted. Immediate cause: either a finalizer whose owning controller isn't running, or a controller that manages external state but never registered a finalizer to guarantee cleanup runs at all. Underlying condition: finalizer lifecycle wasn't treated as part of the Operator's own uninstall/upgrade runbook — this chapter's earlier warning about checking for live finalizers before uninstalling an Operator exists precisely because this gets missed. - No rate limiting or backoff on a persistently failing reconcile. Symptom: a single misconfigured
custom resource generates a continuous, high-volume stream of API server requests. Immediate cause: a
hand-rolled retry loop that calls
Reconcileagain immediately on error, bypassing the workqueue's built-inAddRateLimitedbackoff entirely. Underlying condition: treating the workqueue as optional plumbing rather than the actual mechanism responsible for retry discipline — this is exactly why the raw client-go example earlier in this chapter routes every retry throughq.AddRateLimited, never a bespoke loop. - Overly broad RBAC "to keep things working." Symptom: an Operator's ServiceAccount can read/write
far more than its actual reconciler logic touches. Immediate cause: a wildcard grant added once to make
an early RBAC error go away, never revisited. Underlying condition: the
//+kubebuilder:rbacmarker discipline from the scaffolding section exists specifically to keep generated RBAC tied to what the code actually does — bypassing it with a hand-edited wildcardRolebreaks that guarantee the same way Part 11 flagged for application ServiceAccounts generally.
A Full Worked Scenario: Debugging a Runaway Reconcile Loop in Production#
tenant-operator's workqueue_depth metric alarms at 3 a.m. — the queue is growing continuously, and the
operator's Deployment is being OOMKilled (Part 10) every few minutes and restarting.
The investigation finds recommendations-team's TenantOnboarding object being reconciled roughly 40
times per second, while every other tenant reconciles normally — pointing straight at the "single object,
disproportionate volume" branch, not a general event storm. Reading the reconciler code confirms this
chapter's own earlier "From the Trenches" mistake was reintroduced during a recent refactor: a new
onboarding.Status.LastSyncTime = time.Now() line was added to the reconciler unconditionally, on every
pass, with no predicate filtering it out — every reconcile now writes a status change that immediately
re-triggers the next reconcile. The fix is exactly the predicate-based status filtering from the earlier
Status Subresources section, applied in code review going forward as a specific thing to check for on any
diff touching a Reconcile function's status-writing path.
A Full Worked Scenario: A Botched CRD Migration Without a Conversion Webhook#
A different team, maintaining a separate CachePolicy CRD, renames spec.ttlSeconds to spec.ttl (a
string with unit suffixes, like "5m") directly in the existing CRD's schema — without adding a second
served version or a conversion webhook — and rolls the new controller version out.
- Every existing
CachePolicyobject already in etcd was written under the old schema —spec.ttlSeconds: 300, an integer field the new schema no longer defines. - The new controller's Go struct expects
spec.ttlas a string — reading an existing object either silently drops the now-unrecognizedttlSecondsfield (if using an unstructured/loose decode path) or fails validation outright on the next write, depending on exactly how the schema change was applied. - Every pre-existing
CachePolicyobject is now either broken or has quietly lost its configured TTL, discovered only when several caches stop expiring entries on schedule days later. - The correct approach, using this chapter's tools: add
v1alongside the existingv1alpha1version rather than mutating the single existing version's schema in place, implementConvertTo/ConvertFromtranslatingttlSeconds(int) tottl(string) and back, keepv1alpha1asstorage: trueuntil every client and controller is confirmed running the new code, and only then flip the storage version and run the migrator job to rewrite existing objects.
Caution
Changing a served CRD version's schema in place, rather than introducing a new version, is safe only if every existing stored object either doesn't use the changed field or is guaranteed to be rewritten before anything reads it under the new schema — a guarantee that's much harder to actually verify than it sounds, especially across a cluster with objects created months or years earlier. The hub-and-spoke conversion pattern this chapter covered exists specifically to make this kind of change safe without needing that guarantee at all — treat "just edit the existing version's schema" as the anti-pattern this scenario shows it to be, not a shortcut available when a change "seems small."
Part 19 CLI Cheat Sheet#
| Command | Purpose |
|---|---|
kubebuilder create api --group <g> --version <v> --kind <K> | Scaffold a new CRD type and its controller |
make manifests | Regenerate CRD YAML and RBAC from //+kubebuilder markers |
make generate | Regenerate DeepCopy methods and other generated Go code |
make test | Run envtest-based controller tests |
kubectl get <crd> -o yaml | grep -A5 conditions | Inspect an object's status.conditions directly |
kubectl get events --field-selector involvedObject.name=<name> | See every Event a controller emitted for a specific object |
kubectl patch <crd> <name> -p '{"metadata":{"finalizers":[]}}' --type=merge | Emergency-strip a stuck finalizer (Part 19's own warning applies — external cleanup won't run) |
curl localhost:8080/metrics | grep controller_runtime_reconcile | Inspect a controller's own reconcile metrics directly, e.g. via kubectl port-forward |
Common Mistakes and Interview Traps#
| Mistake | Why it's wrong | Correct approach |
|---|---|---|
Branching Reconcile logic on "what changed" rather than re-deriving full desired state | client-go/controller-runtime never guarantee every individual event is delivered exactly once | Always compute desired state fresh and apply it, regardless of what triggered this reconcile |
Skipping WaitForCacheSync before processing events | Reconciling against a partially-populated cache can produce wrong decisions on startup | Always wait for the initial cache sync before processing the workqueue |
Writing status unconditionally on every reconcile with no change filtering | Self-triggers an immediate re-reconcile, causing a tight loop | Use a status-change predicate, or only write status when it actually changed |
| Editing an existing CRD version's schema in place instead of adding a new version | Existing stored objects under the old schema silently break or lose data | Add a new served version with a conversion webhook, per the hub-and-spoke pattern |
| Uninstalling an Operator without checking for live finalizers on its CRDs | Every remaining object with that finalizer gets stuck in Terminating forever | Confirm zero live custom resources still carry the Operator's finalizer before removing it |
Hand-rolling retry loops instead of using the workqueue's AddRateLimited | Bypasses built-in exponential backoff, risking a request-volume storm against the API server | Always route retries through the workqueue, never a bespoke immediate-retry loop |
| Granting an Operator's ServiceAccount broad, hand-edited RBAC "to be safe" | Breaks the least-privilege guarantee the //+kubebuilder:rbac marker discipline is designed to enforce | Keep RBAC generated from markers tied to actual reconciler code, per Part 11's least-privilege discipline |
Worked Practice Problems#
Problem 1: A Reconciler's workqueue depth grows steadily even though controller_runtime_reconcile_total
shows a healthy, low error rate. What does this combination of signals suggest, and what should the next
diagnostic step be?
Answer: A low error rate rules out reconciles failing and re-queuing via AddRateLimited as the cause —
the queue is growing because keys are being added faster than they're being processed successfully, not
because of retries. The next step is checking whether one specific object is being enqueued at a
disproportionate rate compared to others (this chapter's runaway-reconcile worked scenario), which usually
points to a self-triggering status write or an external system generating an abnormal volume of legitimate
update events, rather than a broad, uniform event-volume increase across every watched object.
Problem 2: A team adds a finalizer to their CRD's objects to guarantee an external cloud resource gets deprovisioned on deletion, tests it successfully, and later uninstalls the Operator entirely as part of a platform migration — without first checking for any remaining live custom resources. What happens to any object that still had the finalizer set, and what's the correct recovery?
Answer: Every remaining object with that finalizer becomes permanently stuck in Terminating the moment
someone tries to delete it, since nothing is left running to observe the deletion, perform the external
cleanup, and remove the finalizer — Kubernetes itself has no timeout or override for an unmet finalizer.
The correct recovery is manually patching the finalizer list to empty (kubectl patch ... --type=merge) to
unblock the object's actual deletion, while explicitly accepting that the external cloud resource the
finalizer was supposed to guarantee gets cleaned up will now need to be found and deprovisioned by hand,
since the code that would have done it no longer exists.
Problem 3: A CRD's v1alpha1 schema has spec.replicas as an integer. A new v1 version changes this
to spec.scaling.replicas, nested one level deeper, with a conversion webhook correctly implemented and
v1 set as the storage version. Six months later, an old client that has never been updated still submits
objects using the flat v1alpha1 shape. Does this client's request succeed, and why?
Answer: Yes — this is exactly the scenario conversion webhooks exist to support. The API server accepts
the v1alpha1-shaped request, and because v1alpha1 is still a served version with a working conversion
webhook, the server calls the webhook to convert the request into the v1 (storage) shape before
persisting it, and can convert it back to v1alpha1 shape whenever that old client subsequently reads it
back. The old client can keep working indefinitely without ever knowing v1 exists, as long as
v1alpha1 remains both served and correctly convertible — the entire point of the hub-and-spoke pattern is
letting exactly this kind of client keep working through an API evolution it's never been updated to know
about.
Summary and What's Next#
Building a real Kubernetes controller comes down to a small number of well-defined layers stacked on each
other: a SharedInformer maintaining a live local cache so Reconcile never has to hit the API server just
to read state, a workqueue turning a noisy stream of watch events into deduplicated, rate-limited,
retryable work items, and a Reconcile function that's correct precisely because it re-derives full desired
state on every call rather than trusting what specifically triggered it. controller-runtime and Kubebuilder
don't replace any of that mechanism — they generate the wiring around it, so an Operator author's real job is
writing the domain-specific reconciliation logic (this chapter's TenantOnboarding operator, turning Part
13's manual onboarding checklist into continuously-enforced code) plus getting owner references, finalizers,
and CRD versioning right for the specific object graph and schema evolution a real Operator eventually needs.
The anti-patterns covered in this chapter — edge-triggered logic, self-triggering status loops, dangling
finalizers, bypassed rate limiting, and unsafe in-place schema changes — account for the overwhelming
majority of real Operator incidents in production, and every one of them traces back to skipping a mechanism
this chapter walked through deliberately: the workqueue's deduplication and backoff, the status-subresource
split, the hub-and-spoke conversion pattern, and envtest's ability to catch these classes of bug against a
real API server before they ever reach a live cluster. These same foundations — client-go's informer
architecture, controller-runtime's reconciliation model, and the operational discipline this chapter's
worked scenarios walked through — are exactly what every Operator this series has referenced throughout
(the Prometheus Operator, KServe, the Kubeflow Training Operator, cert-manager, and this chapter's own
tenant-operator) is built on, and they continue to be the right lens for evaluating any new Operator this
series' throughline system adopts as its platform requirements keep growing.
Where to Go From Here#
This series was scoped, from Part 10 onward, against the certification landscape that maps most directly onto real production Kubernetes ownership — worth naming explicitly now that all nineteen parts are complete.
| Certification | This series' coverage |
|---|---|
| CKA (Certified Kubernetes Administrator) | Parts 1-3 (architecture, workloads, networking/storage), Part 10 (troubleshooting — CKA's single largest domain), Part 15 (cluster lifecycle), Part 16 (capacity planning) |
| CKAD (Certified Kubernetes Application Developer) | Parts 2-3 (workload/application objects), Part 12 (autoscaling from an application-owner's perspective) |
| CKS (Certified Kubernetes Security Specialist) | Part 11, mapped domain-by-domain against the official CKS curriculum, plus Part 17's Cilium network-policy depth |
| KCNA/KCSA (Cloud Native Associate tracks) | Parts 1, 4, 8-9 (fundamentals, service mesh, Gateway API) |
| CNCF-adjacent (Cilium/eBPF, Backstage-style platform engineering) | Part 17 (Cilium & eBPF), Part 13 (multi-tenancy/platform engineering), Part 19 (this chapter — Operator authorship) |
For hands-on practice beyond this series' worked examples, the companion repository
devops-msrashed-com-handson holds real, clonable exercises for several of the patterns covered across
these nineteen parts — check it for init/start/complete progressions matching a given part's topic
before building a lab environment from scratch. A local kind or k3d cluster is enough to practice most
of this series' cluster-level exercises (Part 15's kubeadm upgrade sequence, this chapter's envtest-based
Operator testing) safely and repeatedly, without any of the cost or blast-radius concerns of practicing
against real cloud infrastructure first — reserve a real cloud cluster for the parts that are genuinely
provider-specific (Parts 5, 7, 9, 14's GPU node pools).
Closing the Series#
Nineteen parts ago, this series opened with the single question every infrastructure engineer eventually
has to answer for themselves: what problem does Kubernetes actually solve? The answer — continuously
reconciling declared intent against real-world state — turned out to be the thread running through every
part since: the Scheduler reconciling pod placement against node capacity, a Deployment reconciling replica
count against a rolling update, an HPA reconciling replica count against a load metric, a Capsule Tenant
reconciling policy against a growing set of namespaces, an operator (Part 15) reconciling a cluster's
actual running version against the version it needs to be at, and finally, in this chapter, the exact
SharedInformer/workqueue/Reconcile machinery that makes every one of those reconciliation loops
possible in the first place — the mechanism this whole series kept pointing at was, this whole time, simply
a controller like the one built in this chapter.
No single part of this series stands alone. Troubleshooting (Part 10) is architecture knowledge run in
reverse. Security (Part 11) is the admission chain from Part 1 taken to its full depth. Autoscaling (Part
12) depends on the resource model from Part 2. Multi-tenancy (Part 13) composes RBAC, NetworkPolicy, and
quotas from across the whole series. AI/ML workloads (Part 14) apply every one of those same primitives
under GPU-shaped constraints. Cluster upgrades (Part 15) depend on nearly everything that came before them.
Capacity planning (Part 16) turns Part 12's reactive autoscaling into a deliberately-sized ceiling. Cilium
and eBPF (Part 17) replace Part 3's foundational networking model with a faster, identity-based one without
changing any of the concepts built on top of it. Backup and disaster recovery (Part 18) is Part 4's etcd
backup story extended to the workload data etcd alone never covered. And this closing chapter is the
controller-runtime machinery every Operator referenced across all eighteen prior parts was quietly built
on.
The throughline system — checkout, catalog, inventory, recommendations — was never really the
point; it was a fixed, familiar reference point to keep every new mechanism grounded in something concrete
rather than abstract. The actual point was always the reconciliation loop itself, showing up again and again
at every layer, from a single Pod's container state to an entire fleet of clusters staying current across
their supported version window, to the custom controller code in this very chapter. That's the idea worth
carrying forward past the last page of this series: Kubernetes is not a fixed set of features to memorize,
it's one small idea — desired state, continuously reconciled — applied consistently at every layer of a
genuinely large, genuinely production system, all the way down to the Go code that makes the idea real.
That consistency is also why this series was worth writing as nineteen connected parts rather than
nineteen independent ones. A reader who works through the whole thing shouldn't come away having memorized
nineteen separate tool inventories — HPA's formula, Capsule's Tenant CRD, vLLM's queue-depth metric,
kubeadm's exact upgrade command sequence, Cilium's identity-based policy model, a workqueue's backoff
behavior. They should come away recognizing the same handful of ideas wearing different clothes each time:
a controller watching for drift and correcting it, a staged rollout that proves safety before committing to
it, and a boundary — RBAC, a NetworkPolicy, a ResourceQuota, a version skew tolerance, a CRD schema —
drawn deliberately around exactly the blast radius that boundary is meant to contain. Recognizing that
pattern is what makes the next Kubernetes feature, the one this series didn't cover because it doesn't
exist yet, learnable in an afternoon instead of a fresh multi-week research project.