Solid, Dangling, Waiting: How the Garbage Collector Decides to Delete
The garbage collector classifies every owner reference into solid, dangling or waiting-for-dependents-deletion, using a metadata-only client so only PartialObjectMetadata crosses the wire.
TL;DR: The garbage collector never deletes an object just because it has owner references. It asks the API server — through a metadata-only client, so only
PartialObjectMetadatacrosses the wire — whether each owner still exists, and sorts the references into three buckets: solid (the owner exists), dangling (it does not) and waiting (it exists, is being deleted, and carries theforegroundDeletionfinalizer). Solid owners mean “hands off”; dangling owners mean “delete, or strip the reference”; waiting owners mean “not yet”. There is also a cycle breaker: two objects that are each waiting on the other are unblocked by patching the owner references to non-blocking and deleting withDeletePropagationForeground.
Ownership in Kubernetes is a promise, not a lock. A Deployment sets an
owner reference on its ReplicaSets, a ReplicaSet sets one on its Pods,
and a Job sets one on the pods it creates. When the owner goes away, the
dependents should go away with it — but “should” is doing a lot of work in that
sentence, because a dependent can have several owners, one owner can be
deleted and recreated with the same name but a different UID, and the API
server will happily accept a reference to an object that never existed.
The garbage collector is the component that has to reconcile all of that without ever holding the whole object graph in memory at once, and without racing the API server’s own cascading delete.
Three buckets, not one
The classification happens in one place, and it is short enough to read in full:
// pkg/controller/garbagecollector/garbagecollector.go:455
// classify the latestReferences to three categories:
// solid: the owner exists, and is not "waitingForDependentsDeletion"
// dangling: the owner does not exist
// waitingForDependentsDeletion: the owner exists, its deletionTimestamp is non-nil, and it has
// FinalizerDeletingDependents
// This function communicates with the server.
func (gc *GarbageCollector) classifyReferences(ctx context.Context, item *node, latestReferences []metav1.OwnerReference) (
solid, dangling, waitingForDependentsDeletion []metav1.OwnerReference, err error) {
for _, reference := range latestReferences {
isDangling, owner, err := gc.isDangling(ctx, reference, item)
if err != nil {
return nil, nil, nil, err
}
if isDangling {
dangling = append(dangling, reference)
continue
}
ownerAccessor, err := meta.Accessor(owner)
if err != nil {
return nil, nil, nil, err
}
if ownerAccessor.GetDeletionTimestamp() != nil && hasDeleteDependentsFinalizer(ownerAccessor) {
waitingForDependentsDeletion = append(waitingForDependentsDeletion, reference)
} else {
solid = append(solid, reference)
}
}
return solid, dangling, waitingForDependentsDeletion, nil
}Two things are decided here. “Does the owner exist?” — that is isDangling.
And “if it exists, is it in the middle of being torn down?” — that is the
DeletionTimestamp != nil && hasDeleteDependentsFinalizer test, which is what
separates waiting from solid.
The finalizer in question is metav1.FinalizerDeleteDependents, which the
API server adds when a delete is requested with
DeletePropagationForeground. Its presence is the API server’s way of saying
“I am waiting for my dependents to go away before I disappear”.
Asking the server, cheaply
The liveness question is answered by the API server, not by the GC’s own cache. That matters: the GC’s graph is built from informers and can lag, and a lagging cache would turn into a false “the owner is gone” and a mass deletion.
// pkg/controller/garbagecollector/garbagecollector.go:428
// TODO: It's only necessary to talk to the API server if the owner node
// is a "virtual" node. The local graph could lag behind the real
// status, but in practice, the difference is small.
owner, err = gc.metadataClient.Resource(resource).Namespace(resourceDefaultNamespace(namespaced, item.identity.Namespace)).Get(ctx, reference.Name, metav1.GetOptions{})
switch {
case errors.IsNotFound(err):
gc.absentOwnerCache.Add(absentOwnerCacheKey)
logger.V(5).Info("item's owner is not found",
"item", item.identity,
"owner", reference,
)
return true, nil, nil
case err != nil:
return false, nil, err
}
if owner.GetUID() != reference.UID {
logger.V(5).Info("item's owner is not found, UID mismatch",
"item", item.identity,
"owner", reference,
)
gc.absentOwnerCache.Add(absentOwnerCacheKey)
return true, nil, nil
}
return false, owner, nilTwo details are load-bearing.
First, gc.metadataClient is a metadata.Interface — the metadata-only
client from k8s.io/client-go/metadata. The returned object is a
*metav1.PartialObjectMetadata, which contains only TypeMeta and
ObjectMeta. The GC never needs the owner’s spec or status, only its
existence, its UID, its deletion timestamp and its finalizers — and fetching a
full Pod object to learn four facts about it is exactly the kind of waste
that makes a control plane fall over.
Second, the UID check. A name alone is not identity. If a Deployment is
deleted and recreated with the same name, its old ReplicaSet still carries an
owner reference with the old UID. The name resolves; the UID does not match;
the reference is dangling. Without that check, every recreated owner would
orphan a generation of dependents.
The absent owner cache
The GET above is a live API call, and the GC makes a lot of them. The
absentOwnerCache is the escape hatch — a small LRU of owner references known
to be gone:
// pkg/controller/garbagecollector/uid_cache.go:24
type ReferenceCache struct {
cache *lru.Cache
}
// NewReferenceCache returns a ReferenceCache.
func NewReferenceCache(maxCacheEntries int) *ReferenceCache {
return &ReferenceCache{
cache: lru.New(maxCacheEntries),
}
}It is created with a hard-coded size:
// pkg/controller/garbagecollector/graph_builder.go:165
absentOwnerCache := NewReferenceCache(500)500 entries, no flag, no config API. A reference that is known-absent is remembered so the GC does not have to ask again. The cache is checked before the API call, twice, because a cluster-scoped owner and a namespaced owner with the same name are different objects:
// pkg/controller/garbagecollector/garbagecollector.go:384
// check for recorded absent cluster-scoped parent
absentOwnerCacheKey := objectReference{OwnerReference: ownerReferenceCoordinates(reference)}
if gc.absentOwnerCache.Has(absentOwnerCacheKey) {
logger.V(5).Info("according to the absentOwnerCache, item's owner does not exist",
"item", item.identity,
"owner", reference,
)
return true, nil, nil
}
// check for recorded absent namespaced parent
absentOwnerCacheKey.Namespace = item.identity.Namespace
if gc.absentOwnerCache.Has(absentOwnerCacheKey) {
logger.V(5).Info("according to the absentOwnerCache, item's owner does not exist in namespace",
"item", item.identity,
"owner", reference,
)
return true, nil, nil
}The cache is a correctness optimisation, not just a performance one: a dangling reference that is also in the cache short-circuits before the API call, which means the GC does not have to distinguish “owner gone” from “owner gone but a new one exists with the same name” for the cached entries.
The three-way switch
Now the decision. attemptToDeleteItem reads the live object, classifies its
owner references, and branches three ways:
// pkg/controller/garbagecollector/garbagecollector.go:567
switch {
case len(solid) != 0:
logger.V(2).Info("item has at least one existing owner, will not garbage collect",
"item", item.identity,
"owner", solid,
)
if len(dangling) == 0 && len(waitingForDependentsDeletion) == 0 {
return nil
}
logger.V(2).Info("remove dangling references and waiting references for item",
"item", item.identity,
"dangling", dangling,
"waitingForDependentsDeletion", waitingForDependentsDeletion,
)
// waitingForDependentsDeletion needs to be deleted from the
// ownerReferences, otherwise the referenced objects will be stuck with
// the FinalizerDeletingDependents and never get deleted.
ownerUIDs := append(ownerRefsToUIDs(dangling), ownerRefsToUIDs(waitingForDependentsDeletion)...)
p, err := c.GenerateDeleteOwnerRefStrategicMergeBytes(item.identity.UID, ownerUIDs)
if err != nil {
return err
}
_, err = gc.patch(item, p, func(n *node) ([]byte, error) {
return gc.deleteOwnerRefJSONMergePatch(n, ownerUIDs...)
})
return err
case len(waitingForDependentsDeletion) != 0 && item.dependentsLength() != 0:Read the first branch carefully. A single solid owner is enough to prevent deletion — the object stays, no matter what else is wrong. But the GC does not simply leave it alone: if some of the other references are dangling, it patches them off. An object with one live owner and three references to long-dead owners keeps its live owner and loses the ghosts, because a dangling reference to a UID that never existed is a permanent trip hazard for anything that walks the graph.
The second branch is the interesting one, and it is the cycle breaker:
// pkg/controller/garbagecollector/garbagecollector.go:593
case len(waitingForDependentsDeletion) != 0 && item.dependentsLength() != 0:
deps := item.getDependents()
for _, dep := range deps {
if dep.isDeletingDependents() {
// this circle detection has false positives, we need to
// apply a more rigorous detection if this turns out to be a
// problem.
// there are multiple workers run attemptToDeleteItem in
// parallel, the circle detection can fail in a race condition.
logger.V(2).Info("processing item, some of its owners and its dependent have FinalizerDeletingDependents, to prevent potential cycle, its ownerReferences are going to be modified to be non-blocking, then the item is going to be deleted with Foreground",
"item", item.identity,
"dependent", dep.identity,
)
patch, err := item.unblockOwnerReferencesStrategicMergePatch()
if err != nil {
return err
}
if _, err := gc.patch(item, patch, gc.unblockOwnerReferencesJSONMergePatch); err != nil {
return err
}
break
}
}
logger.V(2).Info("at least one owner of item has FinalizerDeletingDependents, and the item itself has dependents, so it is going to be deleted in Foreground",
"item", item.identity,
)
// the deletion event will be observed by the graphBuilder, so the item
// will be processed again in processDeletingDependentsItem. If it
// doesn't have dependents, the function will remove the
// FinalizerDeletingDependents from the item, resulting in the final
// deletion of the item.
policy := metav1.DeletePropagationForeground
err := gc.deleteObject(item.identity, latest.ResourceVersion, latest.OwnerReferences, &policy)Here is the deadlock this prevents. Object A owns object B. Both are being deleted with foreground propagation. A is waiting for B to disappear; B is waiting for A to disappear. Neither will ever go away, because each one’s deletion is gated on a dependent that is itself gated.
The fix is to break the blocking nature of the owner references rather than
the deletion. unblockOwnerReferencesStrategicMergePatch rewrites the owner
references so they no longer carry the blocking flag, and then the item is
deleted with DeletePropagationForeground anyway. The foreground deletion
still waits for the dependents — it is the owner→dependent direction that
gets unblocked.
And when the owner is merely waiting — no cycle, just an owner in the middle of being deleted — the GC does not touch the object at all. It stays put until its owner goes away.
The third branch is the actual delete:
// pkg/controller/garbagecollector/garbagecollector.go:631
default:
// item doesn't have any solid owner, so it needs to be garbage
// collected. Also, none of item's owners is waiting for the deletion of
// the dependents, so set propagationPolicy based on existing finalizers.
var policy metav1.DeletionPropagation
switch {
case hasOrphanFinalizer(latest):
// if an existing orphan finalizer is already on the object, honor it.
policy = metav1.DeletePropagationOrphan
case hasDeleteDependentsFinalizer(latest):
// if an existing foreground finalizer is already on the object, honor it.
policy = metav1.DeletePropagationForeground
default:
// otherwise, default to background.
policy = metav1.DeletePropagationBackground
}Every owner reference is dangling, so the object is garbage. The propagation
policy is not chosen freely: an object that already carries the orphan
finalizer gets DeletePropagationOrphan, one that already carries the
foreground finalizer gets DeletePropagationForeground, and everything else
gets background — which is the cheap one, because background deletion lets the
API server cascade without waiting for the dependents to be confirmed gone.
Finishing the foreground deletion
The foreground path needs a second pass. When the GC sees an object that is already deleting its dependents, it checks whether any dependent is still blocking:
// pkg/controller/garbagecollector/garbagecollector.go:660
// process item that's waiting for its dependents to be deleted
func (gc *GarbageCollector) processDeletingDependentsItem(logger klog.Logger, item *node) error {
blockingDependents := item.blockingDependents()
if len(blockingDependents) == 0 {
logger.V(2).Info("remove DeleteDependents finalizer for item", "item", item.identity)
return gc.removeFinalizer(logger, item, metav1.FinalizerDeleteDependents)
}
for _, dep := range blockingDependents {
if !dep.isDeletingDependents() {
logger.V(2).Info("adding dependent to attemptToDelete, because its owner is deletingDependents",
"item", item.identity,
"dependent", dep.identity,
)
gc.attemptToDelete.Add(dep)
}
}
return nil
}If nothing is blocking any more, the foregroundDeletion finalizer is
removed, and the API server — which is what actually enforces the finalizer —
completes the deletion. If something is still blocking, the dependent is
pushed back onto the delete queue and the loop continues.
Orphaning is the mirror image, and it is the one place the GC fans out:
// pkg/controller/garbagecollector/garbagecollector.go:679
// dependents are copies of pointers to the owner's dependents, they don't need to be locked.
func (gc *GarbageCollector) orphanDependents(logger klog.Logger, owner objectReference, dependents []*node) error {
errCh := make(chan error, len(dependents))
wg := sync.WaitGroup{}
wg.Add(len(dependents))
for i := range dependents {
go func(dependent *node) {
defer wg.Done()
// the dependent.identity.UID is used as precondition
p, err := c.GenerateDeleteOwnerRefStrategicMergeBytes(dependent.identity.UID, []types.UID{owner.UID})
if err != nil {
errCh <- fmt.Errorf("orphaning %s failed, %v", dependent.identity, err)
return
}
_, err = gc.patch(dependent, p, func(n *node) ([]byte, error) {
return gc.deleteOwnerRefJSONMergePatch(n, owner.UID)
})
// note that if the target ownerReference doesn't exist in the
// dependent, strategic merge patch will NOT return an error.
if err != nil && !errors.IsNotFound(err) {
errCh <- fmt.Errorf("orphaning %s failed, %v", dependent.identity, err)
}
}(dependents[i])
}
wg.Wait()
close(errCh)One goroutine per dependent, because orphaning a Deployment with 200
ReplicaSets means 200 patches, and they are independent. The comment about
strategic merge patch not erroring on a missing owner reference is the
important part: it makes the orphan operation idempotent, so a retry after a
partial failure does not fail.
The graph behind it
All of this runs against a dependency graph that the GC maintains from
informers. The graph is a map[UID]*node where each node has a set of
dependents, and the concurrency model is spelled out in the type itself:
// pkg/controller/garbagecollector/graph.go:58
// The single-threaded GraphBuilder.processGraphChanges() is the sole writer of the
// nodes. The multi-threaded GarbageCollector.attemptToDeleteItem() reads the nodes.
// WARNING: node has different locks on different fields. setters and getters
// use the respective locks, so the return values of the getters can be
// inconsistent.
type node struct {
identity objectReference
// dependents will be read by the orphan() routine, we need to protect it with a lock.
dependentsLock sync.RWMutex
// dependents are the nodes that have node.identity as a
// metadata.ownerReference.
dependents map[*node]struct{}
// this is set by processGraphChanges() if the object has non-nil DeletionTimestamp
// and has the FinalizerDeleteDependents.
deletingDependents bool
deletingDependentsLock sync.RWMutex
// this records if the object's deletionTimestamp is non-nil.
beingDeleted bool
beingDeletedLock sync.RWMutex
// this records if the object was constructed virtually and never observed via informer event
virtual bool
virtualLock sync.RWMutex
// when processing an Update event, we need to compare the updated
// ownerReferences with the owners recorded in the graph.
owners []metav1.OwnerReference
ownersLock sync.RWMutex
}One writer, many readers, and per-field locks rather than one lock for the whole node. That is why the comment warns that two getters can be inconsistent with each other: the GC deliberately does not want the cost of a consistent snapshot of the whole node, because the fields it reads are independent facts.
The graph also contains nodes that do not correspond to real objects. When an informer event names an owner the GC has never seen, it creates a virtual node for it:
// pkg/controller/garbagecollector/graph_builder.go:446
// addDependentToOwners adds n to owners' dependents list. If the owner does not
// exist in the gb.uidToNode yet, a "virtual" node will be created to represent
// the owner. The "virtual" node will be enqueued to the attemptToDelete, so that
// attemptToDeleteItem() will verify if the owner exists according to the API server.
func (gb *GraphBuilder) addDependentToOwners(logger klog.Logger, n *node, owners []metav1.OwnerReference) {
// track if some of the referenced owners already exist in the graph and have been observed,
// and the dependent's ownerRef does not match their observed coordinates
hasPotentiallyInvalidOwnerReference := false
for _, owner := range owners {
ownerNode, ok := gb.uidToNode.Read(owner.UID)
if !ok {
// Create a "virtual" node in the graph for the owner if it doesn't
// exist in the graph yet.
ownerNode = &node{
identity: objectReference{
OwnerReference: ownerReferenceCoordinates(owner),
Namespace: n.identity.Namespace,
},
dependents: make(map[*node]struct{}),
virtual: true,
}
logger.V(5).Info("add virtual item", "identity", ownerNode.identity)
gb.uidToNode.Write(ownerNode)
}
ownerNode.addDependent(n)
if !ok {
// Enqueue the virtual node into attemptToDelete.
// The garbage processor will enqueue a virtual delete
// event to delete it from the graph if API server confirms this
// owner doesn't exist.
gb.attemptToDelete.Add(ownerNode)
} else if !hasPotentiallyInvalidOwnerReference {The virtual node is the GC’s way of saying “I do not know this owner yet”. It is put on the delete queue, and when the GC processes it, the API server is asked whether it exists. If it does not, the virtual node is removed from the graph with a synthetic delete event:
// pkg/controller/garbagecollector/graph_builder.go:431
// enqueueVirtualDeleteEvent is used to add a virtual delete event to be processed for virtual nodes
// once it is determined they do not have backing objects in storage
func (gb *GraphBuilder) enqueueVirtualDeleteEvent(ref objectReference) {
gv, _ := schema.ParseGroupVersion(ref.APIVersion)
gb.graphChanges.Add(&event{
virtual: true,
eventType: deleteEvent,
gvk: gv.WithKind(ref.Kind),
obj: &metaonly.MetadataOnlyObject{
TypeMeta: metav1.TypeMeta{APIVersion: ref.APIVersion, Kind: ref.Kind},
ObjectMeta: metav1.ObjectMeta{Namespace: ref.Namespace, UID: ref.UID, Name: ref.Name},
},
})
}Note the object in that synthetic event is a metaonly.MetadataOnlyObject —
the same metadata-only type used by the API client. The GC’s graph builder
never needs the owner’s spec either.
The knobs
There are surprisingly few. The worker count is the main one:
// pkg/controller/garbagecollector/config/v1alpha1/defaults.go:37
if obj.ConcurrentGCSyncs == 0 {
obj.ConcurrentGCSyncs = 20
}which is the --concurrent-gc-syncs flag on kube-controller-manager
(“The number of garbage collector workers that are allowed to sync
concurrently”, cmd/kube-controller-manager/app/options/garbagecollectorcontroller.go:36).
The GC’s client QPS is doubled, because a deletion is two API calls:
// cmd/kube-controller-manager/app/core.go:664
// Increase garbage collector controller's throughput: each object deletion takes two API calls,
// so to get |config.QPS| deletion rate we need to allow 2x more requests for this controller.
config.QPS *= 2
metadataClient, err := metadata.NewForConfig(config)Those two calls are the metadata GET that establishes liveness and the DELETE
that removes the object. Both go through the same config, so doubling the
QPS doubles both.
The absence of a knob for the absentOwnerCache size (500) is worth noting:
it is a fixed constant in graph_builder.go:165, not a config API field.
Wrapping up
The one thing to remember: the GC deletes only when every owner
reference is dangling. The classification lives in classifyReferences
(pkg/controller/garbagecollector/garbagecollector.go:461), and it is built on
a live metadata.Interface GET plus a UID comparison — never on the
informer’s cache alone.
The part people get wrong: that a “solid” owner protects the object. It
does not protect it cleanly. A solid owner plus some dangling references
means the GC still patches the dangling references off the object
(garbagecollector.go:589), and an owner that is waiting (being deleted
with foreground propagation) can end with the GC unblocking its owner
references to break a mutual-delete deadlock (garbagecollector.go:606).
What to go do about it: if an object is not being cleaned up, look at its
metadata.ownerReferences and check each owner by UID with
kubectl get <owner> -o jsonpath='{.metadata.uid}' — a name that resolves is
not enough. If an object is stuck with a foregroundDeletion finalizer, the
blocker is a dependent, not the owner; find it with
kubectl get <owner> -o jsonpath='{.metadata.finalizers}' and inspect the
dependents’ own owner references. And if you are running a large cluster and
see GC API traffic dominating the control plane, the QPS budget is
--kube-api-qps doubled (cmd/kube-controller-manager/app/core.go:666) and
the parallelism is --concurrent-gc-syncs (default 20).