KubeGlanceDownload
Pod stuck in Terminating: what is actually blocking the delete

troubleshooting

Pod stuck in Terminating: what is actually blocking the delete

A pod stuck in Terminating is one of four things: a grace period, an unreachable node, a finalizer, or a volume that will not detach. How to tell which.

· 11 min read

Terminating is not a phase. It is what kubectl prints for any pod whose metadata.deletionTimestamp is set and which still exists. The pod object is waiting for something, and there are only four candidates: the grace period has not elapsed, the kubelet that owns the pod cannot be reached, a finalizer is still attached, or a volume will not detach.

Each one has a different fix, and only one of them is fixed by --force.

The fastest check

kubectl get pod <pod> -o jsonpath='{.metadata.deletionTimestamp}{"\t"}{.metadata.deletionGracePeriodSeconds}{"\t"}{.metadata.finalizers}{"\n"}'

On a pod held by a finalizer, that prints something like:

2026-08-25T11:06:20Z	30	["example.com/blocks-deletion"]

Three fields, and they eliminate three of the four causes between them:

  • deletionTimestamp empty. The pod is not terminating at all. Your delete never reached the API server.
  • deletionGracePeriodSeconds still counting down against the timestamp. Wait. This is normal shutdown, not a stuck pod.
  • finalizers non-empty. Something has claimed the right to run before this object may be removed, and until it withdraws that claim the API server will not delete the record. This is the case --force does not solve.

If all three look fine and the pod is still there, the node is the problem.

What deletion actually does

Deleting a pod is not one operation. It is a request to the API server, which sets a timestamp, after which two independent parties have to finish their work: the kubelet that runs the containers, and every controller holding a finalizer.

The API server never deletes the record itself until the grace period has elapsed and the finalizer list is empty.

The important property is that the API server is a bookkeeper here, not an executor. It marks the object and waits. Nothing in that sequence has a timeout that eventually gives up — a finalizer whose controller is gone will hold the object indefinitely, and that is by design.

1. The grace period has not elapsed

The default is 30 seconds, and it starts when the deletion is accepted, not when the process exits. The kubelet sends SIGTERM, waits, then sends SIGKILL.

kubectl get pod <pod> -o jsonpath='{.spec.terminationGracePeriodSeconds}{"\n"}'

A pod that ignores SIGTERM will always take the full grace period. If yours is set to 3600 for a batch job, a “stuck” pod is a pod behaving exactly as configured. Check this before anything else — it is the most common false alarm.

2. The node is gone

If the kubelet cannot report back, nobody can confirm the containers stopped, and the API server will not remove a pod record while it still believes containers may be running. This is the failure mode people hit after a node crashes.

kubectl get pod <pod> -o wide
kubectl get node <node>

A node in NotReady with a pod in Terminating on it is this case. Kubernetes will not resolve it on its own for pods without a controller, because it cannot distinguish “node is dead” from “node is briefly unreachable and still running your container”. Deleting the pod record while the container is genuinely still running is precisely what causes two instances of a StatefulSet replica to write to the same volume.

This is the one case --force --grace-period=0 is for. It tells the API server to stop waiting for confirmation. Read the warning it prints, because it is accurate:

Warning: Immediate deletion does not wait for confirmation that the running resource has been terminated. The resource may continue to run on the cluster indefinitely.

Do not do this to a StatefulSet pod on a node you have not confirmed is dead. The whole guarantee a StatefulSet offers is at-most-one semantics per ordinal, and force-deleting the record is you asserting the container is gone. If the node comes back, the replacement pod and the original will both be running, both attached to the same identity, and you have to fix the data by hand. Fence or confirm the node first, then force.

3. A finalizer is holding it

A finalizer is a string in metadata.finalizers. It means “do not remove this object until I say so”. Something has to remove that string, and if the controller that added it has been uninstalled, nothing ever will.

Verified on 1.36: --force --grace-period=0 does not clear finalizers. Running it against a pod with one attached, the command reports the pod force deleted and the object is still there afterwards — the following patch succeeds against it:

kubectl patch pod <pod> -p '{"metadata":{"finalizers":null}}' --type=merge
pod/finalized patched

Once the list is empty the object disappears immediately, because the grace period had already expired.

Before you clear it, look at what the finalizer is named after. It is usually the controller that owns some external resource — a cloud load balancer, a volume, a certificate. Removing the string tells Kubernetes to forget the object, which does nothing about the resource the controller was going to clean up. That resource becomes an orphan you will pay for or trip over later. Clearing a finalizer is a legitimate last step; it is not a diagnosis.

When the patch itself is rejected: Job pods and batch.kubernetes.io/job-tracking

Pods created by a Job carry a finalizer of their own:

["batch.kubernetes.io/job-tracking"]

The Job controller adds it so it can count each pod’s result before the record disappears, and removes it once it has. Normally that takes seconds. When it does not, the patch above is the obvious next step — and on some clusters it fails with an error that has nothing to do with finalizers:

The Pod "report-28871-x7k2p" is invalid: spec: Forbidden: pod updates may not change fields other than `spec.containers[*].image`,`spec.initContainers[*].image`,...

or

spec.initContainers: Forbidden: pod updates may not add or remove containers

or, on older clusters, Pod QoS is immutable. (Current source checks the same thing on status.qosClass and reports it with the generic field is immutable message, so the exact wording depends on your version.)

The first form is followed by a diff of the old and new spec. Read it: it shows exactly which fields changed, and you did not change any of them.

You asked to change one list in metadata. The API server is complaining about the pod’s spec. That mismatch is the clue: something rewrote your patch on the way in. A mutating admission webhook that is registered for pod UPDATE — a sidecar injector, a policy engine that adds resource limits, a security agent — sees the finalizer patch as an update like any other and re-applies its changes. The API server then compares the mutated pod with the stored one, finds a spec change that a running pod is not allowed to have, and rejects the whole request. Your finalizer removal never happens. The Job controller’s own attempt to remove it fails the same way, which is why the pod was stuck in the first place.

This is the diagnosis Kubernetes maintainers gave in three separate reports (#121828, #127917, #129473). Find the webhook:

kubectl get mutatingwebhookconfigurations \
  -o custom-columns='NAME:.metadata.name,OPERATIONS:.webhooks[*].rules[*].operations,RESOURCES:.webhooks[*].rules[*].resources'

Any entry that matches pods with UPDATE (or *) is a suspect. The fix is on the webhook side: restrict it to CREATE, which is almost always what a pod mutator meant, or have it skip pods that already have a deletionTimestamp. Once it stops rewriting updates, the Job controller removes its own finalizer and the pods go without you touching them.

Force-deleting does not help here, for the reason covered above: the finalizer is still there. And do not disable the webhook cluster-wide as a quick fix if it is enforcing something — scope it, then let the controller catch up.

4. A volume will not detach

The pod’s containers stopped, but the volume is still attached to a node and the attach/detach controller is retrying. The pod stays until it is released.

kubectl describe pod <pod> | grep -A5 Events
kubectl get volumeattachment

The events name the volume. In practice this is either a CSI driver that is not running on the node, or a cloud provider that thinks the disk is still mounted elsewhere. It resolves on its own once the detach succeeds, and force-deleting the pod does not speed up the detach — it just removes your visibility into it.

Which one is it

Four causes, and only one of them is fixed by force delete.

The --force habit is the actual problem

kubectl delete pod --force --grace-period=0 works often enough that it becomes reflex, and each of the four causes it appears to fix, it fixes differently:

CauseWhat --force does
Grace period runningSkips the wait. Your container gets SIGKILL with no chance to flush
Node unreachableThe correct fix, after confirming the node is dead
FinalizerNothing. The object remains
Volume detachingRemoves the pod record, leaves the detach in progress

Two of those four are silent damage. If it is your default, you are trading a diagnosis for a disappearing symptom.

Preventing it

  • Handle SIGTERM. A process that exits on the signal terminates in milliseconds. One that ignores it costs you the full grace period every time, on every deploy.
  • Set terminationGracePeriodSeconds deliberately. The default 30 is fine for a web service and far too short for something draining a queue. Make it a decision rather than an inherited number.
  • Audit finalizers you did not add. kubectl get pods -o json | jq '.items[] | select(.metadata.finalizers) | .metadata.name' after uninstalling an operator is a cheap check, and uninstalled operators are where orphan finalizers come from.
  • Never force-delete a StatefulSet pod as a habit. If it is routine, the real problem is the shutdown path, and you are papering over it.

Where the pod never terminated because it never started properly, the symptom usually points elsewhere: a container that will not stay up is covered in how to fix CrashLoopBackOff, and one that was killed for memory in OOMKilled and exit code 137. If kubectl delete is not reaching the API server at all, start with kubectl connection and TLS errors.

Deletion timestamps and finalizers are two taps away, not two jsonpath expressions. KubeGlance is a native Kubernetes client for iPhone and iPad, with a full Mac app on the same core — pod detail, events and workload state straight from your kubeconfig.

Get KubeGlance
#terminating #finalizers #pods #kubectl #troubleshooting

The Kubernetes dashboard that fits in your pocket

KubeGlance is a native Kubernetes client for iPhone and iPad — the real dashboard, not a companion — with a full Mac app on the same core. Pods, workloads, logs and events, straight from your kubeconfig.

Download KubeGlance