Schedule icon
Get icon
Script icon
Log icon
If icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Patch icon

Reap Kubernetes pods stuck Terminating on a dead finalizer

Detect pods stuck Terminating on an uncleared finalizer, report which finalizer blocks each one, strip it behind an approval gate then re-verify.

Categories
Infrastructure

Safety

Removing a finalizer tells Kubernetes that the cleanup the finalizer stands for is finished, when it may not be. A volume can still be attached, a cloud load balancer entry can be left orphaned, buffered data can go unflushed. That is why this flow is built to refuse by default and why every guard is load bearing:

  • It reports which finalizer is blocking each pod, by name, before anything is written, so the operator can judge whether that cleanup matters.
  • A grace window keeps it away from pods that are simply mid-termination.
  • A write allowlist means a namespace you did not list cannot be touched even in live mode.
  • A denylist refuses the storage and ordering finalizers outright rather than stripping them blindly.
  • A per-run ceiling caps the damage if the detector is wrong.
  • auto_remediate false plus dry_run true plus an explicit pause approval all have to be cleared before a single patch goes out.
  • The patch returning 200 is not treated as success. The flow re-reads the API and fails the run if a pod is still there.

How it works

  1. list_pods (io.kestra.plugin.kubernetes.kubectl.Get) reads pod metadata. scan_namespace empty reads every namespace, a name reads one.
  2. classify_stuck (io.kestra.plugin.scripts.python.Script) keeps the pods that carry both a metadata.deletionTimestamp older than grace_minutes and a non-empty metadata.finalizers, then sorts each into reapable, within_grace, namespace_not_allowed, protected_finalizer or over_ceiling. A pod with no deletionTimestamp is running normally. A pod with a deletionTimestamp and no finalizer is already leaving. Neither is ever a candidate.
  3. log_findings (io.kestra.plugin.core.log.Log) prints one line per stuck pod with its age, every finalizer on it plus the verdict.
  4. handle_findings (io.kestra.plugin.core.flow.If) runs only when something is stuck. notify_findings posts the same table to Slack.
  5. route_remediation (io.kestra.plugin.core.flow.Switch) on auto_remediate decides whether remediation is considered. check_dry_run then requires dry_run false and at least one reapable pod.
  6. approve_strip (io.kestra.plugin.core.flow.Pause) waits for a human. The gate reads onResume.approved ?? false, so a pause resumed with no answer is a refusal.
  7. strip_remediation (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) patches one pod at a time. clear_pod_finalizers (io.kestra.plugin.kubernetes.kubectl.Patch) sends a JSON merge patch emptying metadata.finalizers.
  8. reread_pods reads the API again and verify_gone exits non-zero if any target is still present, which fails the run and fires the Slack errors block.

The outputs worth reading afterwards: {{ outputs.classify_stuck.vars.stuck }} is the full table with a verdict per pod, {{ outputs.classify_stuck.vars.targets }} is just the pods the run is willing to patch, {{ outputs.classify_stuck.vars.report }} is the text block that goes to Slack and {{ outputs.verify_gone.vars.survivors }} lists any pod that was still in the API after the patch. {{ outputs.list_pods.metadataItems }} holds the raw ObjectMeta for every pod the scan read.

What you get

  • Recovery from a pod that kubectl delete cannot remove, because the object is already marked for deletion and the delete is a no-op.
  • The blocking finalizer named in the report, so the operator decides with the fact in front of them rather than guessing.
  • A freed pod name, pod IP plus volume attachment, which is what a StatefulSet needs before it can recreate the replica.
  • A run that fails loudly when the patch landed yet the pod is still in the API.

Who it's for

Platform and SRE teams running clusters where an operator or admission controller has been removed, crashed or lost its RBAC, leaving pods that nothing will ever finish deleting.

Why orchestrate this with Kestra

The manual fix is kubectl patch pod x -p '{"metadata":{"finalizers":[]}}' --type=merge, copied from a forum thread during an incident, with no record of which finalizer was dropped and no check that the pod actually went. As a flow it is auditable, it is gated, the credentials live in secrets, the approval is recorded against the execution plus the re-verify makes a silent no-op impossible. The Pause task is what makes a genuinely dangerous action safe to automate: the detection, the reporting plus the write are one audited unit, with a human in the middle of it.

Tested end to end

Run against k3s v1.31.5 with the Kubernetes plugin pointed at the real API server. Pods were stuck by adding a custom finalizer then deleting them, confirmed with kubectl get pod -o jsonpath='{.metadata.deletionTimestamp} {.metadata.finalizers}'.

  • Clean cluster: 0 stuck, nothing reported.
  • Three pods stuck on example.com/block: detected, each named with its finalizer.
  • dry_run: true: the report named every pod plus every finalizer and the finalizers were still on the pods afterwards.
  • Approved pause: finalizers cleared, the re-read confirmed the pods were gone from the API.
  • Declined pause: the finalizers were still in place afterwards.
  • A pod deleted three minutes earlier against a five minute window: held back as within_grace.
  • A stuck pod in a namespace off the allowlist: reported, never patched.
  • A pod carrying kubernetes.io/pv-protection: refused by the denylist.
  • A third reapable pod with the ceiling at two: held back for the next run.
  • A pod recreated under the same name between detection and patch: the patch succeeded, the re-verify found the name still present and failed the run.
  • An unreachable API server: the run failed on the first read plus the Slack errors block fired.
  • Two control pods that were never deleted were untouched in every run.

Prerequisites

  • A Kubernetes cluster plus a ServiceAccount token that can list pods and patch pod metadata.
  • Kestra 2.0 with the Kubernetes plugin plus the Docker task runner for the Python steps.
  • A Slack incoming webhook.

Secrets

Secret What it is
K8S_MASTER_URL API server URL reachable from the Kestra worker
K8S_OAUTH_TOKEN ServiceAccount bearer token with pod list plus pod patch rights
K8S_CA_CERT_DATA base64 encoded PEM CA bundle for the API server
SLACK_WEBHOOK_URL Slack incoming webhook for the findings plus the failure alert

Inputs

Input Type Default What it does
k8s_master_url STRING secret('K8S_MASTER_URL') API server URL
k8s_oauth_token STRING secret('K8S_OAUTH_TOKEN') ServiceAccount bearer token
k8s_ca_cert_data STRING secret('K8S_CA_CERT_DATA') base64 PEM CA bundle
scan_namespace STRING "" namespace to read, empty reads the whole cluster
reapable_namespaces ARRAY ["default"] write allowlist, anything else is report only
grace_minutes INT 15 how long a deletionTimestamp must be old before the pod counts as stuck
protected_finalizers ARRAY pv-protection, pvc-protection, csi-driver, foregroundDeletion finalizers that are refused rather than stripped
max_pods_per_run INT 3 per-run ceiling, oldest first
dry_run BOOL true report without writing
auto_remediate BOOL false allow remediation to be considered
slack_channel STRING #platform-alerts channel for the report plus the failure alert

Quick start

  1. Add the four secrets above to your Kestra instance.
  2. Run it as it ships. dry_run true with auto_remediate false reports what is stuck plus which finalizer is holding each pod. Nothing is written.
  3. Read the report. If a finalizer looks like something that still has work to do, fix the controller instead of stripping it.
  4. Add the namespaces you are willing to write to in reapable_namespaces, then run with dry_run: false and auto_remediate: true. The execution pauses and waits for you.
  5. Approve it. The flow clears the finalizers, re-reads the API and fails the run if any pod is still there.
  6. Enable the schedule once you trust the report. It forces report-only mode.

How to extend

  • Point scan_namespace at one namespace plus give each team its own schedule with its own allowlist.
  • Raise grace_minutes on clusters where pods legitimately take a long time to drain.
  • Add the finalizers your own operators register to protected_finalizers, so your cleanup is never short-circuited.
  • Replace the Slack tasks with PagerDuty when a stuck pod blocks a StatefulSet replica in production.
  • Record which finalizer shows up most often, which points at the operator that is actually broken.

Links

This blueprint was created by zkasuran.

See How

New to Kestra?

Use blueprints to kickstart your first workflows.