Finalizers, ownerReferences and garbage collection

K
Kai··7 min read

In Article 24 we deleted a Deployment and saw it "take the ReplicaSet and Pods with it"; in Article 27, deleting a CronJob took the child Jobs too. Each time I wrote "garbage collection at work" and moved on. This article stops to dissect that mechanism — the lifecycle and linkage between objects: ownerReferences linking parent-child, the garbage collector auto-cleaning children when the parent is gone, and finalizers blocking deletion until cleanup is done. This is the hidden machinery behind most delete operations in Kubernetes.

ownerReferences: who owns whom

The docs open garbage collection with owner references: "Owner references tell the control plane which objects are dependent on others. Kubernetes uses owner references to give the control plane ... the opportunity to clean up related resources before deleting an object. In most cases, Kubernetes manages owner references automatically." Two roles: owner (parent) and dependent (child). We've seen this chain many times (Pod→ReplicaSet→Deployment in Article 24, Job→CronJob in Article 27) but now let's look at the actual field. Create a ReplicaSet directly:

apiVersion: apps/v1
kind: ReplicaSet
metadata: {name: rs-demo}
spec:
  replicas: 2
  selector: {matchLabels: {app: rs-demo}}
  template:
    metadata: {labels: {app: rs-demo}}
    spec:
      containers: [{name: c, image: busybox:1.36, command: ["sleep","3600"]}]
POD=$(kubectl get pods -l app=rs-demo -o jsonpath='{.items[0].metadata.name}')
kubectl get pod $POD -o jsonpath='owner.kind={.metadata.ownerReferences[0].kind} owner.name={.metadata.ownerReferences[0].name} controller={.metadata.ownerReferences[0].controller} blockOwnerDeletion={.metadata.ownerReferences[0].blockOwnerDeletion}{"\n"}'
owner.kind=ReplicaSet owner.name=rs-demo controller=true blockOwnerDeletion=true

Each pod carries an ownerReferences pointing back to the ReplicaSet rs-demo, with controller=true (this RS is the controller managing it) and blockOwnerDeletion=true (this child can block the deletion of the parent in foreground mode — more below). The ReplicaSet attaches this itself when creating the pod; it's the thread by which the garbage collector knows "delete rs-demo and clean up these two pods too". A design constraint from the docs: "Cross-namespace owner references are disallowed ... A namespaced owner must exist in the same namespace as the dependent." Parent and child must be in the same namespace (tying back to the namespace concept from Article 28).

Garbage collection: cleaning children when the parent is gone

Deleting the parent cleans up the children, but how comes in three flavors. The default is background, per the docs: "In background cascading deletion, the Kubernetes API server deletes the owner object immediately and the garbage collector controller ... cleans up the dependent objects in the background." Delete rs-demo and watch:

kubectl delete rs rs-demo          # default cascade = background
kubectl get pods -l app=rs-demo
replicaset.apps "rs-demo" deleted

NAME            READY   STATUS        ...
rs-demo-g2wkg   1/1     Terminating
rs-demo-rhcjd   1/1     Terminating

The ReplicaSet vanishes immediately (deleted), then the two child pods go to Terminating, and the garbage collector cleans them up afterward, in the background. This is exactly what happens every time we kubectl delete deployment: the parent object goes first, the GC cleans up the children after. The docs nail the detection mechanism: "Kubernetes checks for and deletes objects that no longer have owner references, like the pods left behind when you delete a ReplicaSet."

Orphan mode: keep the children

But you don't always want to lose the children when deleting the parent. --cascade=orphan deletes only the parent, leaving the children behind — "the dependents left behind are called orphan objects." Recreate an RS, then delete it orphan-style:

kubectl delete rs rs-orphan --cascade=orphan
kubectl get pods -l app=rs-orphan
kubectl get pod <pod> -o jsonpath='{.metadata.ownerReferences}'
NAME              READY   STATUS    ...
rs-orphan-2m6pk   1/1     Running
rs-orphan-7jhqx   1/1     Running

[]

Complete contrast with background: the RS is gone but the pods are still Running, and their ownerReferences is now empty ([]) — the parent-child thread has been cut, and the pods become independent objects. Orphan is useful when you want to replace a controller without disrupting running pods (e.g. recreating a ReplicaSet to manage the same old set of pods).

Foreground: children first, parent after

The third flavor, foreground, reverses the background order. Docs: "the owner object you're deleting first enters a deletion in progress state ... the controller deletes dependents it knows about. After deleting all the dependent objects it knows about, the controller deletes the owner object." In this case the API server gives the parent a deletionTimestamp and a finalizer named foregroundDeletion, keeping the parent present (read-only) until all children — specifically those with blockOwnerDeletion=true — are deleted. Use kubectl delete ... --cascade=foreground when you need to be sure the children are clean before the parent vanishes. Note the word "finalizer" appears here — the last concept of this article.

Finalizers: block deletion until cleanup is done

foregroundDeletion is a built-in finalizer. Finalizers in general, per the docs: "Finalizers are namespaced keys that tell Kubernetes to wait until specific conditions are met before it fully deletes resources that are marked for deletion. Finalizers alert controllers to clean up resources the deleted object owned." The deletion mechanism when a finalizer is present:

"the API server ... Modifies the object to add a metadata.deletionTimestamp field ... Prevents the object from being removed until all items are removed from its metadata.finalizers field ... Returns a 202 status code (HTTP "Accepted")."

That is, the object does not vanish immediately — it enters a "pending deletion" state (has a deletionTimestamp) and sits there until the finalizer list is empty. Set up a ConfigMap with a self-assigned finalizer and try to delete it:

apiVersion: v1
kind: ConfigMap
metadata:
  name: cm-final
  finalizers: ["kkloud.io/cleanup-demo"]
data: {k: "v"}
kubectl delete configmap cm-final
kubectl get configmap cm-final -o jsonpath='deletionTimestamp={.metadata.deletionTimestamp} finalizers={.metadata.finalizers}{"\n"}'
configmap "cm-final" deleted          # <- kubectl prints this, but...

deletionTimestamp=2026-05-23T16:35:12Z finalizers=["kkloud.io/cleanup-demo"]

kubectl prints "deleted" (because it got 202 Accepted), but the object is still there: it now has a deletionTimestamp and still holds finalizers=["kkloud.io/cleanup-demo"]. This is an object stuck in "Terminating" — the familiar sight when a namespace or PVC "won't go away no matter how you delete it". Normally, the controller responsible for the finalizer would finish the cleanup (delete external resources, detach volumes...) then remove the key from finalizers itself; here the finalizer is one we made up, with no one to remove it, so it's stuck forever. Remove it by hand to actually delete it:

kubectl patch configmap cm-final --type=json -p='[{"op":"remove","path":"/metadata/finalizers"}]'
kubectl get configmap cm-final
configmap/cm-final patched
Error from server (NotFound): configmaps "cm-final" not found

The moment the finalizer list is empty, the object vanishes immediately — exactly as the docs say: "When the finalizers field is emptied, an object with a deletionTimestamp field set is automatically deleted." The operational lesson: when you hit an object stuck Terminating forever, don't blindly hammer --force; check metadata.finalizers to see what is blocking it, then deal with the right controller (or remove it by hand if you're sure the cleanup is done). Common built-in finalizers: kubernetes.io/pv-protection (guarding against accidental PersistentVolume deletion), and the kubernetes finalizer on a namespace (holding the namespace until every object inside is clean).

🧹 Cleanup

kubectl delete pod -l app=rs-orphan --now   # orphan pods must be deleted by hand
# cm-final already deleted; rs-demo + pods already GC'd

The orphan pods from the orphan test have no parent left to GC them, so they must be deleted by hand — true to the spirit of "an orphan has no one to clean up after it". The cluster returns to two CoreDNS pods. Manifests at github.com/nghiadaulau/kubernetes-from-scratch, directory 29-finalizers-gc.

Wrap-up

Three mechanisms for managing object lifecycle and linkage. ownerReferences link child→parent (we saw a pod pointing back to a ReplicaSet with controller=true, blockOwnerDeletion=true; parent and child must be in the same namespace), the thread for cascade cleanup. Garbage collection deletes children when the parent is gone, in three flavors: background (default, the parent goes immediately, GC cleans children in the background, we saw pods Terminating), foreground (children first, parent after, the parent carries a foregroundDeletion finalizer waiting for blockOwnerDeletion children to be clean), and orphan (--cascade=orphan cuts ownerReferences, the pods stay Running). A finalizer is a key blocking deletion: an object hit with DELETE only gets a deletionTimestamp and stays stuck Terminating until metadata.finalizers is empty (we saw the ConfigMap stuck, then disappear once we removed the finalizer). That's how you free those objects that "won't go away no matter what".

Article 30 closes Part V with object management at the practical level: the three management styles (imperative command, imperative object, declarative apply), the recommended app.kubernetes.io/* label set, and the concept of an API's storage version — the foundation for operating objects consistently and for upgrades later.