fix: stop reaping a Node the live replacement kubelet already adopted (#14) - #15
Merged
Conversation
…#14) A cell whose Node carried a previous incarnation's identity label was reaped as stale on the strength of that label alone. But a replacement's kubelet registers under the reused node name and ADOPTS the existing Node object, keeping labels it did not set — so a live cell looks exactly like a leftover. Deleting it is unrecoverable: a kubelet whose Node is removed under it never re-registers, it just logs "Error updating node status, will retry" while the cell waits in Joining forever. Observed on hardware; only restarting the guest's kubelet recovered it. Liveness is now the discriminator. NodeState carries KubeletLive, read from the kubelet's heartbeat Lease, falling back to the Ready condition's heartbeat and finally to "live" when neither can be read — an unknown must never authorise a delete. A Node with a foreign label and a live kubelet is adopted and re-labelled instead of reaped. That alone was not enough. A retired cell's row is dropped at the moment its kubelet has only just died, so its Node still looks live and is correctly left alone — and nothing remembers the cell afterwards, so a one-shot cleanup there reaps nothing. Measured: the Node sat NotReady for ten minutes with no further attempt, waiting for the next cell at that index to adopt it. Orphan cleanup is therefore an idempotent sweep keyed on the pool LABEL, which converges rather than getting one chance, and never touches a Node the pool does not own. The workload-cluster observer role gains coordination.k8s.io/leases get. Without it the operator degrades to the Ready-condition heartbeat (up to 5 minutes stale) rather than failing. Signed-off-by: William Rizzo <william.rizzo@gmail.com>
Merged
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.
Closes #14.
The bug
A Node carrying a previous incarnation's
cells.kubeswift.io/instancelabel was reaped as stale on that label alone. But a replacement's kubelet registers under the reused node name and adopts the existing Node object, keeping labels it did not set — so a live cell is indistinguishable from a leftover by label. Deleting it is unrecoverable: a kubelet whose Node is removed under it never re-registers, it logsError updating node status, will retryforever and the cell waits inJoining. Only restarting the guest's kubelet recovered it.The fix
Liveness is the discriminator.
NodeState.KubeletLivecomes from the kubelet's heartbeat Lease, falling back to the Ready condition's heartbeat, and finally to live when neither can be read — an unknown must never authorise a delete. Foreign label + live kubelet → adopt and re-label. Foreign label + nothing heartbeating → phantom, reap.Orphan cleanup is a sweep, not a one-shot. My first attempt cleaned up when a retired cell's status row was dropped. That is the exact moment its kubelet has only just died, so the Node still looks live and is correctly left alone — and nothing remembers the cell afterwards. Measured on hardware: the Node sat
NotReadyfor ten minutes with no further attempt, waiting for the next cell at that index to adopt it. So orphan cleanup is now keyed on the pool label and runs every reconcile, converging instead of getting one chance.Hardware validation (dev cluster, GTX 1080 cell, real k0s workload cluster)
Adoption path — relabelled the live cell's Node to a foreign instance value:
The real race, caught in the wild — killed the guest, let the pool build a replacement:
The replacement's kubelet had renewed the lease while the Node was still
NotReady— precisely the instant that used to be the fatal delete.Sweep — A/B on the same abandoned Node:
Also
coordination.k8s.io/leases: [get]added to the workload-cluster observer role — absent, the operator degrades to the Ready-condition heartbeat rather than failing.TestReconcileLeavesForeignNodesAlonekeeps the sweep from becoming a licence to delete: an unlabelled, cold Node is untouched.make lint0 issues; full suite green including the four node-lifecycle harness tests.🤖 Generated with Claude Code