fix: wait for volumes to detach before deleting a server - #56
Draft
pkieszcz wants to merge 1 commit into
Draft
Conversation
Karpenter core waits for a node's VolumeAttachment objects to be removed before it terminates the instance -- awaitDrain, awaitVolumeDetachment, then awaitInstanceTermination, in that order. But a VolumeAttachment is removed by the attach-detach controller, and its removal does not prove the cloud provider has finished detaching anything. On Hetzner the two are not simultaneous. The window that leaves is small and expensive. A server destroyed while a volume is still attached tears a live ext4 away mid-write; the volume comes back needing recovery, and on reattach the mount can hang in the kernel inside ext4_fill_super, uninterruptible, retried by kubelet until someone intervenes. Deleting the server force-detaches, so the damage is done by the very call that was meant to clean up. Delete now reads the server first and, if volumes are still attached, returns without deleting. Core's contract makes that sufficient: in awaitInstanceTermination a nil error means "still terminating" and core requeues after 5s, while only a NodeClaimNotFoundError ends the loop. So the provider simply declines until it is safe, and nothing else has to change -- no core change, no new RBAC, no extra controller, and no cost at all for a node with no volumes. BOUNDED, DELIBERATELY. An unbounded wait would convert a stuck detach into a node that can never terminate, which is worse than the unclean detach it avoids: the node holds its volume, its capacity, and its bill indefinitely. Past the grace we delete anyway. That is precisely the behaviour that existed before this commit, so the worst case is today's behaviour rather than a wedged node, and the grace is measured from the NodeClaim's deletion timestamp -- durable, and unlike an in-process timer it survives a restart mid-termination. NOT IN THE INSTANCE PROVIDER. Both delete paths funnel through instance.Provider.Delete, and the other caller is the orphaned-server sweep. An orphan whose volume is stuck must stay reclaimable; blocking it would strand a billing server forever, which is the exact failure that sweep exists to prevent. The wait therefore sits at the CloudProvider layer, which is also the only layer that has the NodeClaim the grace is measured against. Every uncertain path proceeds with the delete rather than waiting: a nil deletion timestamp (no clock to bound against), a server that cannot be read, a server already gone. A check that cannot answer must not be able to block termination, and an unreadable server must still surface NodeClaimNotFoundError so core learns termination finished. Five minutes: core has already waited for the Kubernetes VolumeAttachment by the time Delete is called, so the remaining hcloud-side detach should be seconds. Generous for that, short enough not to stall a rolling consolidation. Left as a constant rather than another configuration knob until there is evidence one is needed. Each guard is mutation-tested -- reverting any one makes exactly its test fail, including the nil-timestamp check, whose removal is a nil dereference rather than a wrong answer.
Contributor
|
On the five minutes: keep it a constant. Promoting it to an operator option before anyone has asked for a different value is a knob without a constituency; the constant plus the comment explaining its derivation is the right resting state. (govulncheck failure is unrelated — fixed on main by #57, rebase to clear.) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Deleting a Hetzner server force-detaches whatever is attached to it. If a detach is already in flight when that happens, the filesystem is torn away mid-write — and the volume can come back in a state where the mount hangs in the kernel, uninterruptibly, on every retry:
Not on-disk corruption — the same filesystem mounts in seconds on a different node, journal replay completes, no
e2fsckneeded. But the node that first tried to mount it accumulates one D-statemountper kubelet retry and can never mount that volume again.Deletecurrently issues the server delete without ever looking atserver.Volumes.What this looks like in practice
From the CSI driver logs of an incident that cost 73 minutes of a replicated store being down to one replica:
Sibling volumes on the same node did log
"volume not attached to a server"at 08:03:20 — clean detaches. The affected one never did. Three others failed outright with"failed to detach volume" err="context canceled", and their retries hit"cannot perform operation because server is locked"— the lock being the deletion already in progress.So the volume was still attached, with a detach outstanding, when the server was destroyed underneath it.
Why Karpenter core does not already prevent this
It very nearly does.
pkg/controllers/node/terminationrunsawaitDrain, thenawaitVolumeDetachment, thenawaitInstanceTermination, andcloudProvider.Deleteis only reached from the third. So the provider is normally called on a node whose volumes are already gone.The gap is what "detached" means. Core waits for the Kubernetes
VolumeAttachmentobject to disappear, which is the attach-detach controller's view. That is not the same fact as the cloud provider having finished.I measured the ordering on a synthetic consolidation — a throwaway PVC with a dirty ext4, pod deleted, polling both sides once a second:
On the happy path Hetzner finishes ~3s before Kubernetes removes the attachment, so by the time
Deleteis called there is nothing attached and this change does nothing at all. It is only when a detach stalls or fails — as above — that the two diverge and the provider gets called on a server that still has volumes.That measurement is worth stating plainly because it sets the cost: inert in normal operation, active only on the failure path.
The change
Deletereads the server first and, ifserver.Volumesis non-empty, returns without deleting.Core's contract makes that sufficient by itself. In
awaitInstanceTerminationanilerror means "still terminating" and core requeues after 5s; onlyNodeClaimNotFoundErrorends the loop. So the provider declines until it is safe and core does the polling — no core change, no new RBAC, no extra controller.Three decisions worth a reviewer's attention
It is bounded. An unbounded wait turns a stuck detach into a node that can never terminate, holding its volume, its capacity and its bill — worse than the unclean detach it set out to prevent. Past the grace we delete anyway, which is exactly the behaviour that exists today, so the worst case is the status quo rather than a wedged node.
The grace comes from
nodeClaim.DeletionTimestamp, not an in-process timer, so an operator restart mid-termination does not silently reset the clock and start the wait over.It is not in the instance provider. Both delete paths funnel through
instance.Provider.Delete, and the other caller is the orphaned-server sweep. An orphan whose volume is stuck must stay reclaimable — blocking it strands a billing server indefinitely, which is the precise failure that sweep exists to prevent. The wait therefore lives at theCloudProviderlayer, which is also the only layer holding the NodeClaim the grace is measured against.Failure behaviour
Every uncertain path proceeds with the delete rather than waiting: a nil deletion timestamp (no clock to bound against), a server that cannot be read, a server already gone. A check that cannot answer must not be able to block termination — and an unreadable server must still surface
NodeClaimNotFoundError, or core never learns termination finished.On the five minutes
Core has already waited for the Kubernetes attachment by the time
Deleteis called, so the remaining provider-side detach should be seconds; in the incident above the driver was still retrying a minute later. Five minutes is generous for the first and bounded well short of the second. Left as a constant rather than another configuration knob until there is evidence one is wanted — happy to promote it to an operator option.Tests
Written RED-first, and each guard mutation-tested: reverting any one makes exactly its test fail. The nil-timestamp check is the interesting one — removing it is a nil dereference rather than a wrong answer, so it is load-bearing for a different reason than the others.
Covered: volume still attached (waits), no volumes (deletes immediately), grace elapsed (deletes anyway), no deletion timestamp (deletes), missing server (still reports
NodeClaimNotFound). The two pre-existing delete tests pass unchanged.What this does not fix
It does not stop a detach from stalling, and it is not a substitute for finding out why five simultaneous detaches stalled with attaches to the replacement nodes racing them, or what cancelled three of them —
context canceledon aControllerUnpublishmeans the caller gave up, and that is in the CSI layer, not here.What it does is stop this provider from turning a slow or failed detach into a destroyed filesystem.