[CKS] KubernetesCluster destroy hangs indefinitely (no timeout) when SSH-based PV cleanup blocks on a stuck PVC
- Linguagem predominante
- Java
- Estrelas
- 3.1k
- Forks
- 1.4k
- Merge médio
- 6d 19h
- PRs com merge (30d)
- 32
Descrição
### problem
### CloudStack version
4.22.1.0
### Environment
- Hypervisor: KVM
- CKS Kubernetes version: v1.36.1
- CSI driver: csi.cloudstack.apache.org
- Cluster type: CloudManaged (1 control node, size 4)
### Summary
Deleting a CKS Kubernetes cluster can leave it stuck in `Destroying` state
indefinitely (observed 25h+, reproduced twice) because
`KubernetesClusterDestroyWorker` performs PV cleanup via a **blocking SSH
exec with no timeout**, and a second, separate bug silently ignores a
state-machine error on retry, causing repeated attempts to hang the exact
same way instead of failing fast.
### Root cause #1 — no timeout on SSH exec used for PV cleanup
`KubernetesClusterDestroyWorker.destroy()` → `destroyClusterVMs()` calls
`KubernetesClusterResourceModifierActionWorker.deletePVsWithReclaimPolicyDelete()`,
which SSHes into the cluster's control-plane node and runs `kubectl` to
delete PVs with `ReclaimPolicy=Delete` **before** any cluster VM is
stopped/destroyed.
This uses `com.cloud.utils.ssh.SshHelper.sshExecute()`, which has **no
execution timeout** on this code path. If the remote `kubectl` command
blocks — e.g. because a PVC is stuck in `Terminating` (its
`kubernetes.io/pvc-protection` finalizer is waiting on a Pod that is still
using it and was never deleted) — the SSH channel read blocks forever and
so does the whole async destroy job.
Thread dump confirming the hang (management server JVM, thread alive for
91851s / ~25.5h):
"API-Job-Executor-16" ... in Object.wait()
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
at java.lang.Object.wait(Object.java:338)
at com.trilead.ssh2.channel.FifoBuffer.read(FifoBuffer.java:212)
- locked <...> (a com.trilead.ssh2.channel.Channel)
at com.trilead.ssh2.channel.Channel$Output.read(Channel.java:127)
at com.trilead.ssh2.channel.ChannelManager.getChannelData(ChannelManager.java:935)
at com.trilead.ssh2.channel.ChannelInputStream.read(ChannelInputStream.java:58)
at com.trilead.ssh2.channel.ChannelInputStream.read(ChannelInputStream.java:70)
at com.cloud.utils.ssh.SshHelper.sshExecute(SshHelper.java:292)
at com.cloud.kubernetes.cluster.actionworkers.KubernetesClusterResourceModifierActionWorker.deletePVsWithReclaimPolicyDelete(KubernetesClusterResourceModifierActionWorker.java:966)
at com.cloud.kubernetes.cluster.actionworkers.KubernetesClusterDestroyWorker.destroyClusterVMs(KubernetesClusterDestroyWorker.java:110)
at com.cloud.kubernetes.cluster.actionworkers.KubernetesClusterDestroyWorker.destroy(KubernetesClusterDestroyWorker.java:311)
at com.cloud.kubernetes.cluster.KubernetesClusterManagerImpl.destroyKubernetesCluster(KubernetesClusterManagerImpl.java:2439)
TCP-level connectivity to the control node was fine throughout (SSH port
and the k8s API port both accepted new connections instantly) — this was
never a network/node-availability issue, purely a stuck remote command
with an unbounded client-side wait.
Symptom visible to the operator: the cluster stays `Destroying` forever in
`listKubernetesClusters`, and the UI eventually shows a generic **"Error
encountered while fetching async job result"** after giving up polling —
which is misleading, since the job is not erroring, it simply never
completes.
### Root cause #2 — state-transition failure is logged and ignored, not surfaced
Every retry of `deleteKubernetesCluster` against a cluster already in
`Destroying` hits:
WARN [c.c.k.c.a.KubernetesClusterDestroyWorker] Failed to transition
state of the Kubernetes cluster : in state Destroying on event
DestroyRequested
com.cloud.utils.fsm.NoTransitionException: Unable to transition to a new
state from Destroying via DestroyRequested
at com.cloud.kubernetes.cluster.actionworkers.KubernetesClusterActionWorker.stateTransitTo(KubernetesClusterActionWorker.java:672)
at com.cloud.kubernetes.cluster.actionworkers.KubernetesClusterDestroyWorker.destroy(KubernetesClusterDestroyWorker.java:310)
This exception is only logged as a `WARN` — execution continues into a
**brand new** attempt at PV cleanup / VM teardown, instead of failing the
API call immediately or resuming/checking the existing in-flight job. In
practice this means: once a cluster gets wedged by root cause #1, every
subsequent delete click by the operator just reproduces the identical
hang, with no useful error surfaced and no indication that a previous job
is already stuck.
### The steps to reproduce the bug
### Steps to reproduce
1. Create a CKS cluster with at least one PVC provisioned via the
CloudStack CSI driver, bound to a running Pod.
2. Delete that PVC directly (`kubectl delete pvc `) while the Pod is
still running and using it. It will sit in `Terminating` forever
(expected k8s behavior — `pvc-protection` finalizer waiting on the
Pod).
3. Call `deleteKubernetesCluster` on the cluster.
4. Observe: the async job never completes. `jstack` the management server
and confirm the `API-Job-Executor-N` thread for that job is blocked in
`SshHelper.sshExecute` → `deletePVsWithReclaimPolicyDelete`.
5. Retry `deleteKubernetesCluster` — observe the same
`NoTransitionException` WARN followed by the exact same hang.
### Expected behavior
- SSH-based remote command execution for PV cleanup should have a
configurable timeout, after which the cleanup step fails cleanly (and,
ideally, falls back to destroying the VMs anyway rather than blocking
the whole cluster teardown on stuck in-cluster storage state).
- A `NoTransitionException` on a destroy retry should cause the API call
to fail fast with a clear message (e.g. "a destroy operation is already
in progress for this cluster, job "), not silently proceed to
re-run the entire destroy workflow.
### Workaround used
No workaround inside CloudStack itself was available short of a code fix.
Had to: SSH directly into the control-plane node (using the cluster's own
node keypair) to find and manually resolve the stuck PVC/Pod, restart
`cloudstack-management` to kill the wedged thread, then retry the delete.
This is not something a typical operator/admin without JVM-level access
(thread dumps, log correlation across `apilog.log` /
`management-server.log`) could reasonably self-diagnose.
### What to do about it?
**Fix #1 — bound the SSH exec used for PV cleanup**
`KubernetesClusterResourceModifierActionWorker.deletePVsWithReclaimPolicyDelete()`
should call `SshHelper.sshExecute(...)` with an explicit timeout instead of
the unbounded read used today. Concretely:
- Add a timeout parameter (config key, e.g.
`cloud.kubernetes.cluster.pv.cleanup.timeout`, default something like
120s) and pass it through to the SSH exec call.
- On timeout, log a clear WARN/ERROR ("PV cleanup on node X timed out
after Ns, proceeding with VM teardown regardless") and **continue** into
`destroyClusterVMs()`'s VM stop/expunge steps rather than blocking the
whole job. Losing best-effort PV cleanup on a wedged node is strictly
better than an unkillable job — the VMs (and their disks) are about to
be destroyed anyway, so a CSI-level PV delete that didn't get to run is
not a correctness problem for the *cluster* teardown, only a possible
orphaned-volume cleanup task afterward (worth its own follow-up: a
"list volumes with no owning VM/cluster" admin command would help here).
- `trilead.ssh2` supports read timeouts on the channel/session directly
(`Connection.connect(..., timeout)` for connect, and
`InputStream.read` respects the underlying socket's `SO_TIMEOUT` if the
session is opened with one) — this should be a small, contained change
in `SshHelper`, not a rearchitecture.
**Fix #2 — fail fast instead of silently retrying a wedged destroy**
In `KubernetesClusterActionWorker.stateTransitTo()` / the call site in
`KubernetesClusterDestroyWorker.destroy()`, a `NoTransitionException`
should not be caught-and-continue. Concretely:
- If the cluster is already in `Destroying` (or any non-terminal state
that isn't a valid source for `DestroyRequested`), the API command
should throw `InvalidParameterValueException` (or similar) back to the
caller immediately, ideally including the existing job's UUID if one is
still tracked, e.g.: *"Cluster already has a destroy operation
in progress (job ), started at . Wait for it to
complete or check its status directly."*
- This alone would have turned this incident from "silent 25h hang,
repeated on every retry" into "immediate, actionable error on the very
first retry."
**Suggested priority:** Fix #2 is small/low-risk and should be
straightforward to backport across supported branches. Fix #1 is the
actual root cause of the indefinite hang and is more valuable long-term,
but touches shared SSH plumbing (`SshHelper`) used elsewhere in the
codebase, so it deserves a bit more test coverage/review.
Happy to submit a PR for either if that's useful — flagging here first
since I don't have full context on branch/version support policy for this
project.
Guia de contribuição
Avaliação
Esta issue ainda não foi avaliada.