Graceful node shutdown leaves Completed pods that nothing cleans up
After each rolling reboot, Deployment pods stayed behind in phase Succeeded. The pod garbage collector only acts above 12,500 terminated pods, and lowering that would also eat CronJob history. A small CronJob that deletes only orphans owned by ReplicaSets, StatefulSets and DaemonSets does the job.
A health check of my cluster counted 61 pods in Completed state. Most of that
is normal: a CronJob keeps the pods of its last few runs. But some of these
belonged to Deployments, and a Deployment pod has no business being Completed.
Sorting them by owner
The quickest way to see what you’re looking at is to group terminated pods by the kind of their owner:
kubectl get pods -A --field-selector=status.phase==Succeeded -o json \
| jq -r '.items[] | .metadata.ownerReferences[0].kind // "none"' | sort | uniq -c48 were owned by Jobs, which means CronJob history, bounded by
successfulJobsHistoryLimit and failedJobsHistoryLimit. 13 were owned by
ReplicaSets. Their creation dates matched the days I’d rebooted the nodes, one
after another.
Where they come from
Talos shuts nodes down gracefully, and the kubelet’s graceful node shutdown
terminates the pods on the node before it goes. Those pods end in phase
Succeeded or Failed. The ReplicaSet doesn’t care: it creates new pods
elsewhere, and the old objects stay behind.
Kubernetes does have a garbage collector for terminated pods, in the
kube-controller-manager. It only starts deleting when there are more than
--terminated-pod-gc-threshold of them, and the default is 12,500. A small
cluster never gets there, so these pods stay forever.
The options I looked at:
- Lowering the threshold is a control-plane flag (machine config on Talos, not something I can manage through GitOps). It would also delete Job pods, and with them the logs that are my CronJob history.
ttlSecondsAfterFinishedon the CronJobs is about Job pods, not these.- The descheduler’s
RemoveFailedPodshandlesFailed, notSucceeded.
A small CronJob
So I wrote one. Once a day it lists pods in phase Succeeded or Failed, keeps
the ones owned by a ReplicaSet, StatefulSet or DaemonSet that finished more than
an hour ago, and deletes them. Everything else is left alone. Job pods are the
history. A pod with no owner was created by hand, and someone may still be
reading it.
The script is plain Python with only the standard library, so the image is
python:3.13-alpine, and it talks to the API with the ServiceAccount’s token
and CA certificate from /var/run/secrets/kubernetes.io/serviceaccount. The
official kubectl image has no shell, which makes filtering by owner awkward.
The finish time is the latest finishedAt of the pod’s containers, falling back
to the Ready condition’s transition time and then the creation time.
I tested the selection against a snapshot before giving it delete rights:
kubectl get pods -A -o json > pods.json
python3 -I pod_cleanup.py --from-file pods.jsonIt picked exactly the 13 orphans out of 173 pods. The first real run deleted them, and only Job-owned pods remained.