Kubernetes Operators in Go
An end-to-end walkthrough of building a real operator: a custom API, a controller
that reconciles it, and a genuine deployment to a real cluster — not a toy
snippet. This is the most involved page on this site, and it's held to a higher bar
of proof than "it compiles": almost everything below was actually run, either
against a real embedded Kubernetes API server or a real kind cluster.
Where something is a conceptual simplification rather than something we ran, it's
labeled as such.
Every version, API signature, and marker-comment shown below was checked against
live documentation, then cross-checked against the real, current tools (Go 1.26,
controller-runtime v0.24.1, Kubebuilder v4.15.0) by actually running them, not
just reading about them. The Go code was compiled and go vet-checked.
The reconciler's logic was proven against a real embedded Kubernetes API server
(envtest: a real kube-apiserver and etcd,
but no kubelet or controller-manager). Everything that specifically requires a
kubelet and a real garbage collector — actual running Pods, and actual
cascading deletion — was verified separately against a real
kind (Kubernetes-in-Docker) cluster, with the exact kubectl
output captured below.
What's an operator?
Kubernetes itself is built from controllers: small loops that watch one kind of object and work to make reality match it. The built-in Deployment controller watches Deployments and makes sure the right number of Pods exist. An operator is the same pattern applied to a resource type you define: a Custom Resource Definition (CRD) teaches the Kubernetes API about your new kind, and a Go program — the controller — watches it and reconciles.
The single most important thing to understand about this loop, stated in the controller-runtime documentation itself: reconciliation is level-based, not edge-based.
“Reconciliation is level-based, meaning action isn't driven off changes in individual Events, but instead is driven by actual cluster state read from the apiserver or a local cache. For example if responding to a Pod Delete Event, the Request won't contain that a Pod was deleted, instead the reconcile function observes this when reading the cluster state and seeing the Pod as missing.”
In practice: your Reconcile function is never told what
changed. It's just told which object might need attention, and its job is
to look at the current state of the world and correct any gap, every single time,
regardless of what caused the gap. The interactive visual further down makes this
concrete.
Project setup guide
Before writing the CRD or the reconcile loop, we need to initialize the project directory, set up the Go module, and generate the foundational scaffolding. We use Kubebuilder to handle this boilerplate.
This sequence establishes your root directory and sets up the required boilerplate. The kubebuilder init command configures the project layout, while create api stubs out the specific Go types and controller files that you will modify to create your custom resource.
Designing the API: a Website CRD
The example built for this page is a Website resource: you specify how
many replicas you want and a message to display, and the operator makes a real
website exist showing that message. Built with Kubebuilder
v4.15.0, the commands from the setup guide generated the initial scaffolding.
Critically, Kubebuilder generates the CRD's YAML schema
directly from Go struct tags and marker comments — you never hand-write the
CRD YAML.
type WebsiteSpec struct {
// replicas is the desired number of running copies of the website.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=1
Replicas int32 `json:"replicas"`
// message is the text served on the website's homepage.
// +kubebuilder:validation:MinLength=1
Message string `json:"message"`
}
type WebsiteStatus struct {
// readyReplicas is the number of replicas currently passing readiness
// checks, as last observed by the controller.
// +optional
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
// conditions represent the current state of the Website resource.
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Message",type=string,JSONPath=`.spec.message`
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Website struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitzero"`
Spec WebsiteSpec `json:"spec"`
Status WebsiteStatus `json:"status,omitzero"`
}
Notice json:"metadata,omitzero", not the older omitempty.
This is what Kubebuilder v4.15.0 actually scaffolds today — older tutorials
and blog posts online still show omitempty here, which was the
convention before this changed. If you're following along from an older guide and
your generated code doesn't match it, this is why.
Running make manifests generates the real CRD from that Go file. Here's
the actually-generated result (the standard Kubernetes Condition schema
block, which is boilerplate identical across virtually every operator, is collapsed
for length):
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: websites.web.example.com
spec:
group: web.example.com
names:
kind: Website
plural: websites
scope: Namespaced
versions:
- name: v1
additionalPrinterColumns:
- jsonPath: .spec.message
name: Message
type: string
- jsonPath: .spec.replicas
name: Replicas
type: integer
- jsonPath: .status.readyReplicas
name: Ready
type: integer
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
schema:
openAPIV3Schema:
properties:
spec:
properties:
message:
minLength: 1
type: string
replicas:
default: 1
format: int32
minimum: 1
type: integer
required: [message, replicas]
type: object
status:
properties:
readyReplicas:
format: int32
type: integer
conditions:
# ... standard metav1.Condition array schema ...
type: object
served: true
storage: true
subresources:
status: {}
.spec is the desired state a user writes; .status is the
observed state the controller reports back — this split, and the separate
/status subresource it enables, is a Kubernetes-wide convention, not
something specific to CRDs. The minLength/minimum rules are
enforced by the real API server itself, before a request ever reaches the
controller — confirmed directly further down.
The reconcile loop
Click through the visual below, then compare it against the real
Reconcile function underneath — the visual is a simplified
simulation of exactly what that code does.
// +kubebuilder:rbac:groups=web.example.com,resources=websites,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=web.example.com,resources=websites/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=web.example.com,resources=websites/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch;delete
func (r *WebsiteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var website webv1.Website
if err := r.Get(ctx, req.NamespacedName, &website); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if !website.DeletionTimestamp.IsZero() {
// ... finalizer cleanup; see "Finalizers" below ...
return ctrl.Result{}, nil
}
if !controllerutil.ContainsFinalizer(&website, websiteFinalizer) {
controllerutil.AddFinalizer(&website, websiteFinalizer)
if err := r.Update(ctx, &website); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil // this Update re-triggers Reconcile
}
if err := r.reconcileConfigMap(ctx, &website); err != nil {
return ctrl.Result{}, err
}
deployment, err := r.reconcileDeployment(ctx, &website)
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, r.updateStatus(ctx, &website, deployment)
}
reconcileConfigMap and reconcileDeployment both use
controllerutil.CreateOrUpdate: fetch-or-create, then mutate to the
desired shape, then save. Every owned object also gets
controllerutil.SetControllerReference(website, obj, r.Scheme) —
that one call is what makes the next two sections possible.
While verifying this against a live cluster, the controller's log genuinely
showed: Operation cannot be fulfilled on deployments.apps "website-sample":
the object has been modified; please apply your changes to the latest version and
try again. That's a normal optimistic-concurrency conflict — something
else touched the object between this reconcile's read and write. controller-runtime
automatically requeues on any returned error, and because reconciliation is
level-based and idempotent, the very next attempt just succeeded cleanly. This
wasn't edited out: it's exactly why the level-based design matters in practice,
not just in theory.
Wiring up the controller: SetupWithManager
The reconcile function exists inside a controller, which needs to be
told what to watch and how the watch events should trigger reconciliation.
This wiring lives in SetupWithManager — the function generated by
the Kubebuilder scaffold:
func (r *WebsiteReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
// Watch Website changes directly
For(&webv1.Website{}).
// Also watch owned Deployments — changes re-trigger parent reconcile
Owns(&appsv1.Deployment{}).
// Skip reconcile for resource-generation-only changes
WithEventFilter(predicate.GenerationChangedPredicate{}).
Complete(r)
}
For vs Owns vs Watches
For(&webv1.Website{}) enqueues a reconcile request every time a
Website changes (create, update, delete). Owns(&appsv1.Deployment{})
watches Deployments that have a ControllerReference pointing to a
Website — when the Deployment changes, it enqueues a reconcile for its
owner. The lower-level Watches function is for everything else
(unowned objects, external events, custom event sources).
GenerationChangedPredicate: avoiding pointless reconciles
By default, controller-runtime enqueues a reconcile request on every
update to a watched object — including status updates that only change
metadata.generation. Without GenerationChangedPredicate,
your controller would constantly re-reconcile in response to its own status
writes, creating an infinite loop. This predicate filters out all events except
those where .metadata.generation actually changed — which only
happens on spec changes, not status changes.
Rate limiting and requeuing
When Reconcile returns an error, controller-runtime automatically
requeues the request — but not immediately. A built-in rate limiter
controls how often the same request can be retried, starting with a short delay and
backing off exponentially (base delay ~5ms, max ~16 minutes). This prevents a
permanently-failing request from burning CPU in a tight loop.
There are three ways a reconcile can ask to be retried, and they differ in an important way:
return ctrl.Result{}, err— retry with the rate limiter's exponential backoff. Use for transient errors (network, conflict).return ctrl.Result{Requeue: true}, nil— retry immediately (or on the rate limiter's schedule). Use when you need to poll for a condition that may not be ready yet.return ctrl.Result{RequeueAfter: 30 * time.Second}, nil— retry after a fixed delay, ignoring the rate limiter. Use for periodic reconciliation (e.g., certificate renewal checks).
The default rate limiter (based on workqueue.DefaultControllerRateLimiter)
has a per-item exponential backoff capped at ~16 minutes and a generous per-item
retry limit — but a single reconcile that always returns an error will
eventually stop being retried, so make sure the error is genuinely transient before returning it.
Owner references & garbage collection
SetControllerReference stamps the child object's metadata with a
reference back to its owner. Kubernetes' garbage collector uses exactly this to
cascade-delete children when the owner goes away — by default, deleting a
Website should also delete its ConfigMap and Deployment, with no code in this
operator ever issuing a delete for either.
This specific proof needs a real cluster — the garbage collector is part of
kube-controller-manager, which the faster, cluster-free
envtest setup used later on this page deliberately does not run. The
"Deploying for real" section below shows it actually happening, live, as the last
step of the same deployment walked through there.
Finalizers
Owner references clean up objects inside Kubernetes automatically. If an operator manages something outside the cluster — a cloud load balancer, a DNS record, a billing record — nothing does that cleanup for you. A finalizer is a string on the object's metadata that blocks real deletion until the controller explicitly removes it, giving it a guaranteed chance to run that cleanup first.
Deleting an object with a finalizer doesn't remove it from etcd: the API server
sets metadata.deletionTimestamp and waits. The controller sees the
timestamp, does whatever cleanup it needs, then removes its finalizer string. Only
once the finalizers list is empty does the object actually disappear. This example
has nothing external to clean up, so the finalizer only demonstrates the hook firing
— and it really did, captured from the controller's own logs during the
deletion walked through in "Deploying for real" below:
Testing without a cluster: envtest
You don't need Docker or a real cluster to genuinely test a controller.
sigs.k8s.io/controller-runtime/pkg/envtest downloads and runs the real
kube-apiserver and etcd binaries as local processes —
a real Kubernetes API, with real validation and real storage, just without a kubelet
or controller-manager. That boundary matters: it means Pods can exist as API objects
but never actually run, and nothing drives a Deployment's status forward on its own.
That directory holds three real binaries (etcd, kube-apiserver,
kubectl), about 51MB compressed, no root required. Point
KUBEBUILDER_ASSETS at it and a normal Go test gets a real API server for
the life of the test run. Because there's no real Deployment controller, testing the
status-reporting path means simulating what one would eventually report:
// envtest runs no real Deployment controller, so nothing will ever
// make dep.Status.ReadyReplicas move on its own. Simulate what one
// would eventually report, the same way a real cluster's Deployment
// controller and kubelet would once the Pods actually came up.
dep := &appsv1.Deployment{}
Expect(k8sClient.Get(ctx, typeNamespacedName, dep)).To(Succeed())
dep.Status.Replicas = 2
dep.Status.ReadyReplicas = 2
Expect(k8sClient.Status().Update(ctx, dep)).To(Succeed())
_, err = reconciler().Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName})
Expect(err).NotTo(HaveOccurred())
website := &webv1.Website{}
Expect(k8sClient.Get(ctx, typeNamespacedName, website)).To(Succeed())
Expect(website.Status.ReadyReplicas).To(Equal(int32(2)))
A separate test asserts the inverse of the CRD validation shown earlier — that the real (embedded) API server genuinely rejects an invalid spec before the controller ever sees it:
invalid := &webv1.Website{
ObjectMeta: metav1.ObjectMeta{Name: "invalid-resource", Namespace: resourceNamespace},
Spec: webv1.WebsiteSpec{Replicas: 0, Message: ""},
}
err := k8sClient.Create(ctx, invalid)
Expect(err).To(HaveOccurred())
Expect(errors.IsInvalid(err)).To(BeTrue())
Running the full suite, for real, against the real embedded API server:
Deploying for real
Everything above ran on this machine with no Docker or cluster. This last part is
the genuine, complete path from source to a running website, verified against an
actual kind cluster created for this page:
Roughly ten seconds later, both the custom printer columns and the real workload agree:
And the payoff — actually exec into one of those real Pods and ask it for its homepage, proving the ConfigMap the operator wrote is genuinely what nginx is serving:
Last step: delete the Website and confirm the owner-reference cleanup and finalizer behavior from earlier on this page actually happen, on the same cluster, with the same Website still running from above:
A few seconds later, the finalizer's cleanup log line appears, then everything owned is gone:
The Website, its ConfigMap, its Deployment, and both Pods are all gone — and this operator's code never once called delete on the ConfigMap or the Deployment.
Every command output on this page is a real, unedited capture (aside from trimming timestamps and the standard Kubernetes Condition schema block for length). The reconciler compiles against the real controller-runtime v0.24.1 API, passes a real test suite against a real embedded API server, and manages a real Deployment, ConfigMap, and Pods on a real cluster — including genuinely cascade-deleting them on parent deletion. Where a limitation exists (envtest's lack of a kubelet or garbage collector), it's called out rather than papered over.
Production concerns
The deployment sequence above runs the controller as a single replica inside the cluster. Before putting any controller into a real environment, three more things need attention:
If you run multiple replicas of your controller (recommended for high availability),
every replica will reconcile every object — wasting resources and causing
write conflicts. Leader election ensures only one replica actively
reconciles while the others stand by. Kubebuilder enables it by default: the
--leader-elect flag (set to true in your
config/manager/manager.yaml) causes each replica to attempt to acquire a
lease via coordination.k8s.io/Leases. Only the holder of the lease runs
reconciliation; the others block until the leader's lease expires. This is
transparent to the reconciler itself — no code changes needed.
The Kubebuilder scaffold registers /healthz and /readyz
endpoints on your manager. Kubernetes uses these for liveness probes (restart the
Pod if the controller is stuck) and readiness probes (stop sending it traffic until
it's ready). In production, configure the liveness probe to check
/healthz and the readiness probe to check /readyz in your
manager Deployment. The readiness endpoint includes your leader election status
— a standby replica reports not ready, so traffic reaches only the active
leader.
When a Pod is terminated (scale-down, rolling update, node drain), Kubernetes sends
a SIGTERM to the controller process. The manager handles this by
gracefully shutting down: it stops event sources, cancels all running reconcile
loops (via the context), and gives each one up to GracefulShutdownTimeout
(default 30 seconds) to finish. Any reconcile that takes longer than the timeout
is cut short — design your reconciliation to be fast enough, or idempotent
enough, that an interrupted reconcile leaves no inconsistent state.
Interview Questions
Common interview questions about Kubernetes operators, CRDs, and controller-runtime — organized by experience level. Click a tab to filter.
Q1. What is a Kubernetes operator?
An operator is a controller that encodes operational knowledge for a specific application or system, watching custom resources and driving the cluster toward the state those resources describe. It extends the Kubernetes API with domain-specific objects (via a CRD) and a control loop that manages them, instead of relying on a human running scripts.
Q2. What is a Custom Resource Definition (CRD)?
A CRD is a Kubernetes API object that registers a new resource type — for example Website — with its own schema, versions, and endpoint under the cluster's API server. Once installed, instances of that type (custom resources) can be created, read, updated, and watched just like built-in resources such as Pod or Deployment.
Q3. What is reconciliation in the context of an operator?
Reconciliation is the process of comparing the desired state (the spec of a custom resource) against the actual state of the cluster and taking action to close the gap. It's implemented as a Reconcile function that controller-runtime calls whenever a watched object or a related resource changes.
Q4. What does a control loop mean?
A control loop is the repeating cycle of observe, compare, and act that a controller runs continuously: it observes cluster state via watches, compares it to the desired state, and acts to correct any drift. Kubernetes itself is built from many such loops running independently and converging the cluster toward its declared state.
Q5. What tool is used to scaffold a new operator project, and what does it generate?
Kubebuilder scaffolds operator projects — running kubebuilder init followed by kubebuilder create api generates the Go module layout, a manager entrypoint, CRD types under api/, a controller skeleton under internal/controller/, and Kustomize manifests for CRDs, RBAC, and the deployment.
Q6. What is the difference between the spec and status fields on a custom resource?
Spec holds the desired state, written by the user or another client. Status holds the observed, actual state, written only by the controller. Keeping them separate lets the controller report progress and conditions without a client's write ever being clobbered by the controller, and vice versa.
Q7. What client does a controller-runtime controller use to talk to the API server?
It uses the controller-runtime client.Client obtained from the Manager, which wraps a cached client backed by informers for reads and a direct client for writes. This gives the reconciler Get/List/Create/Update/Delete methods without each controller needing to manage its own informers.
Q8. What does it mean to "requeue" a reconcile request?
Requeueing means returning a result from Reconcile that tells controller-runtime to call it again later for the same object, either immediately (Requeue: true), after a delay (RequeueAfter), or via a returned error which triggers requeue with backoff. It's how a controller polls for eventual conditions, like waiting for a dependent resource to become ready.
Q9. What is envtest used for?
envtest is a package that spins up a real kube-apiserver and etcd binary pair (no kubelet, no scheduler, no container runtime) so integration tests can exercise a controller against a genuine API server. It's faster and more hermetic than a full cluster while still validating CRD schemas, defaulting, and webhook behavior for real.
Q1. Why must Reconcile be idempotent?
Reconcile can be called repeatedly for the same object — on startup, after any watched resource changes, on requeue, or after a crash and restart — and controller-runtime gives no guarantee it runs exactly once per change. If applying the same reconcile twice produced different results or duplicate side effects (like creating a second child object), the controller would corrupt state instead of converging on it.
Q2. Why requeue with a delay instead of retrying in a tight loop?
Returning RequeueAfter (or letting an error trigger exponential backoff) avoids hammering the API server and burning CPU while waiting on a condition that resolves on its own timescale, such as a Pod becoming Ready. A tight retry loop with no backoff also risks overwhelming the API server across many objects reconciling simultaneously.
Q3. What's the difference between an owner reference and a finalizer?
An owner reference is metadata on a dependent object pointing back to its owner; Kubernetes' garbage collector uses it to automatically delete dependents once the owner is deleted, with no controller code required. A finalizer is a string on the owning object itself that blocks its deletion until the controller that added it removes external, non-Kubernetes resources (like a cloud load balancer) and then strips the finalizer.
Q4. Why keep status as a separate subresource from spec?
Enabling the /status subresource means updates to status go through a distinct endpoint with its own resourceVersion checks, so a controller writing status can't accidentally overwrite a user's concurrent spec edit and vice versa. It also lets RBAC grant users write access to spec while restricting status writes to the controller's service account.
Q5. What's the risk of a controller mutating cached objects returned by client.Get?
The controller-runtime client's reads come from a shared informer cache, so the object returned by Get is the same in-memory object other goroutines may be reading. Mutating it in place without calling DeepCopy first can corrupt the cache and produce data races across reconciles.
Q6. Why should a controller set an owner reference on every resource it creates?
Owner references let Kubernetes garbage-collect child resources automatically when the parent custom resource is deleted, so the controller doesn't need custom deletion logic for ordinary cleanup. They also let controllerutil.SetControllerReference wire watches so changes to a child (like a Deployment) trigger reconciliation of its owning custom resource.
Q7. What happens if a reconciler is deleted from a resource without removing its finalizer?
The object stays stuck with a non-nil deletionTimestamp forever: the API server won't actually remove it from etcd until every finalizer string is cleared from its metadata. This is a common cause of resources that appear to hang in "Terminating" state.
Q8. Why does Kubebuilder generate RBAC markers as code comments above the Reconcile method?
Markers like // +kubebuilder:rbac:groups=...,resources=...,verbs=... are parsed by controller-gen to produce the ClusterRole manifest, keeping the permissions a controller actually needs declared next to the code that uses them. This avoids hand-maintaining RBAC YAML that drifts out of sync with what the reconciler really does.
Q9. Why use envtest instead of only unit tests for controller logic?
Unit tests with fakes can validate reconciler logic in isolation, but they don't catch problems with CRD schema validation, defaulting, or how the reconciler behaves against real API server semantics like optimistic concurrency conflicts. envtest runs the actual reconcile loop against a genuine (if kubelet-less) API server, catching integration bugs unit tests would miss, at a fraction of the cost of a full cluster.
Q1. What does it mean for reconciliation to be level-triggered rather than edge-triggered, and why does controller-runtime favor it?
Edge-triggered means reacting to a specific change event (this field went from A to B); level-triggered means reacting to the current observed state regardless of what event caused it, recomputing the full desired-vs-actual diff each time. Level-triggering is more resilient because it self-heals from missed or coalesced events — if a watch drops an update, the next reconcile still converges to correct state instead of leaving the system permanently out of sync.
Q2. How should a controller handle a conflict error when updating a resource's status?
A 409 conflict means the object's resourceVersion changed since it was read, so the update was rejected by optimistic concurrency control rather than silently overwriting someone else's write. The correct response is to re-Get the object, reapply the intended change, and retry — controller-runtime's reconcile-on-error-with-backoff naturally does this if the error is simply returned, and retry.RetryOnConflict can be used for tighter, in-loop retries.
Q3. How do you design a finalizer to avoid a resource getting permanently stuck in "Terminating"?
Cleanup logic in the finalizer branch must handle the "external resource already gone" case idempotently (treat not-found as success) and must not loop forever on a transient error without bound, and the finalizer should only be removed after cleanup is confirmed to have succeeded. It also helps to make the cleanup step observable via events or status conditions so an operator that can't finish (for example, a cloud API is unreachable) is diagnosable rather than silently hung.
Q4. What's the tradeoff between testing with envtest versus a real kind cluster?
envtest runs only the API server and etcd, so it's fast and hermetic but can't validate anything that depends on the scheduler, kubelet, container runtime, or actual networking — like whether a Pod actually starts or a Service actually routes traffic. A kind cluster runs the full stack, catching those gaps, at the cost of slower, less isolated CI runs, so most operator test suites use envtest for controller logic and reserve a kind-based e2e suite for a smaller set of end-to-end smoke tests.
Q5. How should RBAC be scoped for an operator following least privilege?
The ClusterRole generated from +kubebuilder:rbac markers should list only the exact API groups, resources, and verbs the reconciler actually calls — for example get;list;watch;create;update;patch;delete on the CRD's own resources and only get;update;patch on its /status subresource, with wildcard resources or verbs avoided. If the operator only ever needs to act within one namespace, a Role/RoleBinding scoped to that namespace is preferable to a cluster-wide binding.
Q6. Why is leader election needed when running multiple replicas of a controller manager?
Running more than one active reconciler for the same objects risks duplicate or conflicting writes and wasted API server load, so controller-runtime's Manager supports leader election via a Lease object in the API server: only the replica holding the lease runs reconciles, while the rest sit idle as hot standbys. If the leader crashes or its lease expires, another replica acquires it and takes over, giving high availability without multiple writers.
Q7. What is a predicate, and when would you use one to filter events?
A predicate is a filter attached to a watch (via builder.WithPredicates) that decides whether an event should actually trigger a reconcile, such as predicate.GenerationChangedPredicate to ignore status-only or metadata-only updates. This reduces reconcile churn from irrelevant events — important because every watched object update otherwise re-enqueues a reconcile even if nothing the controller cares about changed.
Q8. How do you handle a CRD schema change (a new API version) without breaking existing stored objects?
Kubernetes CRDs support multiple served versions with one marked as the storage version; a conversion webhook (or, for simple additive changes, relying on lossless round-tripping) translates objects between versions on read and write. The safe path is to add the new version alongside the old, mark it served but not yet storage, migrate clients, implement conversion, and only then flip the storage version and eventually drop the old served version.
Q9. Why can a naive reconciler that lists and deletes all "unwanted" children create a race with the garbage collector or with concurrent creates?
If a reconciler independently lists children and deletes anything it doesn't recognize as desired, it can delete an object another reconcile just created (or one whose desired-state computation it read a stale version of), because there is no single serialization point across reconciles. The safer pattern is to make ownership and naming deterministic (so "desired" children can be computed and diffed precisely) and to let owner references and the garbage collector, rather than ad hoc listing, handle removal of truly orphaned objects.