Article
Custom Resources: What Happens After Defining the Schema
This is part 2 of a four-part series on building a Kubernetes operator: CRDs , Custom Resources (this post), Operators and Kubebuilder .
More Than YAML That Passes Validation
Last time, I defined BackupCheck as a Custom Resource Definition (CRD) and applied a Custom Resource against it:
1apiVersion: backups.example.io/v1alpha1
2kind: BackupCheck
3metadata:
4 name: nightly-verification
5 namespace: data-platform
6spec:
7 targetCluster: postgres-prod
8 schedule: "0 3 * * *"
9 retentionDays: 14
In that last post, I created a CRD focussed on the schema, describing what shape the object, the resulting Custom Resource (CR), is supposed to take. What I have not mentioned is everything that happens to the object after it is created: how the API server tracks changes to it, how a controller safely coordinates concurrent writes, how deletion actually works, and how child objects it creates get cleaned up automatically. This is going to be functionally more interesting.
Objects Carry More Than spec and status
Looking at the full metadata of any object applied, there is more there than you probably wrote:
1metadata:
2 name: nightly-verification
3 namespace: data-platform
4 uid: 3fa1b2c4-9e21-4a7f-b8e0-1234567890ab
5 resourceVersion: "48213"
6 generation: 2
7 creationTimestamp: "2026-08-30T03:00:00Z"
uidis assigned once and never changes, the object’s true identity, independent of the name; this is what owner references actually point at under the hood.resourceVersionis an opaque string that is bumped on every write to the object that actually changes the object (spec, status, labels, anything). You should not attach meaning to it as a traditional counter, it purely exists for concurrency control.generationincrements only when.specordeletionTimestampchanges; changing status, label or annotation does not touch it. That distinction is what makes it useful.
Optimistic Concurrency, No Locking
Kubernetes does not lock objects for editing. Instead, writes usually include the respective resourceVersion. If someone else has written to the object in the meantime, the write is rejected with a conflict rather than silently overwriting the previous change.
For a controller, this shows up constantly: You fetch an object, do some work, and try to update its status. If anything else touched the object in between (a user editing spec, or your own controller processing a stale cache entry), the update fails and the object needs to be re-fetched and the edit retried. client-go’s retry.RetryOnConflict exists specifically for this loop, and it is worth using it rather than hand-rolling the same functionality.
The newer alternative is server-side apply: Instead of read-modify-write against the whole object, fields are tagged with the field manager which last set it (visible in metadata.managedFields), and the merge gets applied only at the field level. kubectl apply --server-side uses this path, and this is increasingly how controllers manage the specific fields they own without needing to know or preserve anything else of the object.
generation vs. observedGeneration
generation only moves when spec changes. It is therefore the basis for a very common status convention: a controller sets status.observedGeneration to the metadata.generation it has actually finished reconciling.
1status:
2 observedGeneration: 2
3 phase: Running
4 lastVerifiedTime: "2026-08-30T03:04:12Z"
If metadata.generation is ahead of status.observedGeneration, anything looking at the object (e.g. a dashboard, kubectl or another controller) can directly tell that a spec change is still waiting to be picked up, without needing to inspect timestamps or diff anything.
Finalisers: Pausing Deletion Until Cleanup Is Done
Deleting a Kubernetes object with kubectl delete removes it by default more or less immediately. This immediate removal can be prevented with a finaliser. This is a string in metadata.finalizers: When you delete an object with a finaliser present, the API server sets metadata.deletionTimestamp but keeps the object in etcd (K3s defauls to SQLite and other distributions might have another database) until every finaliser of this object has been removed.
1metadata:
2 finalizers:
3 - backups.example.io/cleanup
4 deletionTimestamp: "2026-08-15T09:00:00Z"
The pattern of the controller is to check on every reconcile whether the deletionTimestamp is set. If it is, it runs the cleanup logic instead of normal reconciliation. In case of our BackupCheck, that could mean tearing down the scratch restore instance and its volume; the finaliser should only be removed once that teardown is confirmed. Removing it too early results in leaking resources; forgetting to remove it at all results in the object being stuck in Terminating forever, which is an early footgun with this pattern.
Owner References and Garbage Collection
When the controller creates a child object, like a Job that performs the actual restore-and-verify for a BackupCheck, it is worth setting an owner reference on it:
1apiVersion: batch/v1
2kind: Job
3metadata:
4 name: nightly-verification-restore
5 namespace: data-platform
6 ownerReferences:
7 - apiVersion: backups.example.io/v1alpha1
8 kind: BackupCheck
9 name: nightly-verification
10 uid: 3fa1b2c4-9e21-4a7f-b8e0-1234567890ab
11 controller: true
12 blockOwnerDeletion: true
This links the two objects, the Job and the BackupCheck, by UID. Kubernetes’ built-in garbage collector then uses it to cascade deletes: When the BackupCheck is deleted, the Job gets cleaned up automatically and no extra bookkeeping in your controller is required. It is a small thing, but it means the controller does not need to remember every child it ever created and you do not have to take care of this, the ownership graph does that for you.
Foreground Propagation != Background Propagation
blockOwnerDeletion: true deserves a note, because it does nothing in the previously described sequence; it only takes effect under foreground cascading deletion, where the garbage collector puts a foregroundDeletion finaliser on the owner and keeps it in place until every dependent blocker is gone.
kubectl delete uses background cascading deletion by default, so the BackupCheck disappears first and the Job is automatically collected after it. It is in the example because controllerutil.SetControllerReference sets it for you, and because the guarantee costs nothing if someone does delete with --cascade=foreground. There is one string attached: setting it requires update on the owner’s finalizers subresource, which is why kubebuilder scaffolds an RBAC marker for backupchecks/finalizers.
Status Writes Go Through Different Endpoint
Because the CRD from the last post enables the status subresource, there is a gotcha worth flagging: Once that subresource exists, writes to .status through the main resource endpoint are silently ignored. You have to write to it through /status specifically.
For controller-runtime’s client, this means calling r.Status().Update(ctx, obj), not r.Update(ctx, obj). Calling the regular update after setting obj.Status.Phase and wondering why it does not stick might be confusing; it did however exactly what it was supposed to do, just not to the field you were watching.
A Day in the Life of a BackupCheck Object
Putting it together can roughly be described as follows:
- Object is created:
generationstarts at 1 andresourceVersionreceives some number from the data store. - Controller reconciles, creates
Jobwith owner reference back to theBackupCheck, and writesstatus.phase = Runningvia the status subresource:resourceVersiongets bumped,generationdoes not, becausespecdid not change. Jobfinishes: controller updatesstatus.phase = Succeededandstatus.observedGeneration = 1.- User edits
spec.retentionDays:generationbecomes 2,observedGenerationis now stale and the controller picks that up on its next reconcile. - Someone runs
kubectl delete backupcheck nightly-verification:deletionTimestampis set and the object stays visible butTerminating,generationis incremented. - Controller notices: tears down whatever scratch resources need explicit cleanup, and removes its finaliser.
- Object disappears from datastore: garbage collector cascades deletion to the owned
Job.
None of this happens on its own, every step from 2 onwards is something a controller has to actually implement. That will be the subject of the next post: the reconciliation loop itself, and why it is built the way it is.