fix(controllers): stop exempting the bootstrap seed from self-heal - #354
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughBootstrap seeds can undergo crash-loop replacement after quorum forms. Tests cover bootstrap-window and single-member protections. Documentation describes the updated behavior and the seed re-bootstrap gap. E2E coverage targets the bootstrap seed deterministically. ChangesBootstrap seed self-healing
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Crash-loop self-heal was gated on `!member.Spec.Bootstrap`. That field is set once, when the cluster controller creates the seed, and is never cleared — so the member that bootstrapped the cluster carried the exemption for the entire life of the cluster. A seed whose data dir was lost or corrupted crash-looped forever with no recovery path, long after bootstrap was over and it had become an ordinary voter. The guard was protecting the right thing with the wrong predicate. Deleting the seed *while the cluster is still forming* would destroy the only copy of a cluster no other member has joined yet; that is a property of the phase, not of the member, and it expires. Gating on identity instead made it permanent. The bootstrap window turns out to already be protected by the quorum gate standing right next to it, at no extra cost: the cluster controller does not run updateStatus until status.clusterID is latched, so ReadyMembers is 0 for the whole window and clusterHasQuorumWithout cannot be satisfied at any replica count. The same arithmetic permanently protects the sole member of a 1-replica cluster. Dropping the conjunct therefore removes a redundant guard rather than loosening a real one. That a cluster can run with no Bootstrap=true member is already routine: every cluster adopted by cmd/etcd-migrate is created that way, and the memory pod-loss self-heal a few lines above has always deleted seeds without checking the field. Replace the test that pinned the old behaviour with three that pin the new predicate: a formed cluster's stuck seed is replaced, a stuck seed mid-bootstrap is not, and a 1-replica cluster's only member is not. The e2e now corrupts the seed deliberately instead of indexing into a name-sorted list — that both exercises the path this fixes and removes a ~1-in-3 flake, since member names are random suffixes and the old victim selection landed on the exempt seed about a third of the time. Documents a related gap this does not close: buildPod still derives --initial-cluster-state from spec.bootstrap, so a re-created seed Pod is handed `=new` forever. That is inert unless the seed's data dir returns empty rather than corrupt, in which case etcd bootstraps a fresh one-member cluster and comes up *ready* — which no self-heal trigger can see. Signed-off-by: Timofei Larkin <lllamnyp@gmail.com>
7df9527 to
70c93f8
Compare
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/concepts.md`:
- Line 91: Update the crash-loop self-heal reference in the documented seed
behavior to use the existing heading fragment `#crash-loop-self-heal` instead of
`#crash-loop-self-heal-pvc-members`.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 7f3dcf48-683e-4c5c-8a71-faed0537828f
📒 Files selected for processing (5)
controllers/etcdmember_controller.gocontrollers/etcdmember_controller_test.godocs/concepts.mddocs/operations.mdtest/e2e/member_selfheal_test.go
The intra-doc link pointed at #crash-loop-self-heal-pvc-members, which matches no heading; the target is '### Crash-loop self-heal' (slug #crash-loop-self-heal). Correct the anchor so the link resolves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Andrey Kolkov <androndo@gmail.com>
Andrey Kolkov (androndo)
left a comment
There was a problem hiding this comment.
LGTM.
Reviewed fix/seed-member-not-special at cf4615a against main.
The core change is a one-conjunct deletion of !member.Spec.Bootstrap from the self-heal gate, making replacement purely state-driven (etcdContainerStuck(pod) && clusterHasQuorumWithout(...)). Verified the load-bearing safety claims independently:
- Bootstrap window is protected by the quorum gate alone. While
Status.ClusterID == "",Reconcilereturns viabootstrap()/tryDiscoverCluster()and never reaches the ready-countingupdateStatus, soReadyMembersstays 0 andreadyOthers >= desired/2+1can never pass. The only path that does reach it (desired == 0) short-circuits inclusterHasQuorumWithoutanyway. - 1-replica sole member stays protected (
desired/2+1 == 1,readyOthers→ 0 after self-subtraction), pinned byTestUpdateStatus_KeepsStuckSoleMemberat the stale-high worst case. - No regression — the only changed behavior is a formed cluster's stuck seed now healing instead of wedging permanently.
Tests define the full contract (replace-after-bootstrap / keep-during-bootstrap / keep-sole-member); build, vet, and go test ./controllers/ green. Docs kept in sync and their claims confirmed against code; anchors now resolve. The 'seed re-bootstrap on empty data-dir' gap is pre-existing, correctly documented, and correctly scoped to a separate change.
The dead-anchor issue from the first pass is resolved.
Rebased onto
mainafter #352.The bug
Crash-loop self-heal is gated on
!member.Spec.Bootstrap:Spec.Bootstrapis set once, when the cluster controller creates the seed(
etcdcluster_controller.go:434), and is never cleared anywhere in the tree. Sothe member that bootstrapped the cluster carries the exemption for the entire
life of the cluster — long after bootstrap is over and it has become an
ordinary voter indistinguishable from its peers.
The result: a seed whose data dir is lost or corrupted crash-loops forever. The
one mechanism the operator has for recovering exactly that failure refuses to
look at it, permanently, because of what the member was months ago.
This also contradicts what the docs already promise in three places — that the
seed flag is "the discovery anchor and is otherwise just historical metadata"
(
concepts.md), that "onceclusterIDis set, the operator never re-readsspec.bootstrapfor any decision" (concepts.md), and that the seed "has nopermanent special role and can be removed like any other member"
(
operations.md). The self-heal gate was the one place the code disagreed.Why the guard existed, and why the predicate was wrong
The guard is protecting something real. Deleting the seed while the cluster is
still forming would destroy the only copy of a cluster that no other member has
joined yet — there is nothing to gap-fill from, and the finalizer's
MemberRemovehas no peers to run against.But that is a property of the phase, not of the member. "The cluster is
still bootstrapping" is true for a minute and then false forever. "This member
bootstrapped the cluster" is true forever. Gating on the second to protect the
first is what makes the exemption permanent — the guard has no way to expire
because the fact it reads never changes.
Why dropping it is safe
The bootstrap window is protected already, by the quorum gate standing
immediately next to it, at no extra cost:
clusterHasQuorumWithoutrequiresreadyOthers >= desired/2+1, readingreadyOthersfromcluster.Status.ReadyMembers. Whilestatus.clusterIDisunlatched,
Reconcileroutes intobootstrap()/tryDiscoverCluster()andnever reaches
updateStatus— soReadyMembersis0for the wholebootstrap window. Zero never satisfies the gate, at any replica count. The seed
cannot be self-healed during bootstrap whether or not anyone checks
spec.bootstrap.The same arithmetic permanently protects a 1-replica cluster's only member:
desired/2+1 == 1, andreadyOthersafter subtracting the member itself is0. It is never deleted at any point in the cluster's life. (The stale-highReadyMemberscase is what the existing subtract-yourself logic in thatfunction handles, and there is a test for it.)
So this removes a redundant guard, not a load-bearing one.
spec.bootstrapkeeps every other job it has — it is still how
bootstrap(),tryDiscoverCluster()andhasPendingBootstrap()locate the seed, all of whichrun only while
clusterIDis unlatched.Two things already in production confirm that a cluster with no
Bootstrap=truemember is a normal, supported state:internal/migrate/adopt.go:283createsevery member with
Bootstrap: falseand pre-latchesstatus.clusterID. Nomigrated cluster has a seed at all.
etcdmember_controller.go:149has never consultedspec.bootstrap; amemory-backed seed whose Pod is lost is deleted and gap-filled today.
Production impact
Not CI-only. To hit it, a seed's data dir has to become unreadable while the
cluster membership has moved on — a volume lost on node failure, a corrupt
bbolt/WAL header, a storage backend hiccup. That is precisely the scenario
#336 was written for; it just declines to handle it on one member in every
cluster.
How it presents:
readyMemberssits at 2/3 indefinitely. The Pod accumulatesrestarts well past
dataLossRestartThreshold(the failing CI runs reached 7against a threshold of 5) with no operator log line explaining why nothing is
being done, because the gate rejects the member before the
log.Infothatannounces a replacement. Nothing distinguishes it from a member that simply has
not crossed the threshold yet.
Severity is worse than one lost member: the cluster is stuck at 2/3 with no
redundancy left — one more failure loses quorum — and it stays there until a
human deletes the
EtcdMemberby hand. It also interacts badly with themax-learners=1constraint described in #352: a wedged member occupying thesingle learner slot blocks further replacement, so an unhealable seed can
prevent recovery of other members too.
What changed
Drop the
!member.Spec.Bootstrapconjunct. With #352 having removed thememory-medium exclusion, the gate is now entirely state-based — nothing in it
names a member:
etcdContainerStuckstill covers not-ready, past restart threshold, notOOMKilled and Pod-not-terminating; the quorum gate is unchanged.
Tests.
TestUpdateStatus_KeepsStuckBootstrapMemberpinned the oldbehaviour; it is replaced by three tests that pin the new predicate:
TestUpdateStatus_ReplacesStuckSeedAfterBootstrap— a formed cluster's stuckseed is replaced like any other member. Fails on
mainwithexpected a formed cluster's seed to be self-healed like any other member— the bugitself.
TestUpdateStatus_KeepsStuckSeedDuringBootstrap— mid-bootstrap(
ReadyMembers=0), the seed is left alone. This is the guard the oldconjunct was reaching for, now pinned against the mechanism that actually
provides it.
TestUpdateStatus_KeepsStuckSoleMember— a 1-replica cluster's only member isnever deleted, tested against the stale-high
ReadyMembersworst case.E2E.
TestPVCMemberCrashLoopSelfHealnow corrupts the seed deliberately,via a
selfHealSeedMemberhelper that also asserts the single-seed invariantthe cluster controller relies on.
This is worth spelling out, because the same test is the subject of #345. That
PR diagnoses a real ~1-in-3 flake correctly: the test picked
original[0]froma name-sorted list, member names are apiserver-assigned random suffixes, so it
landed on the exempt seed about a third of the time and burned its full 15-minute
timeout waiting for a deletion that could never come. Its fix is to select a
non-seed victim. Both changes make the victim deterministic and both end the
flake; the difference is that avoiding the seed leaves the operator behaviour
untouched, while targeting it turns the test into a regression guard for the bug
— it would have failed on
mainfor the right reason. #345's other improvement,the misleading timeout message, is carried here too: it claimed "crash-loop not
yet past threshold" when the member was well past it and held back by a gate, and
now names both possibilities.
Docs.
concepts.mdandoperations.mdgain a bullet stating thephase-versus-identity rule explicitly, and drop the last "non-bootstrap"
qualifier from the crash-loop description.
A related gap this does not close
buildPodalso readsspec.bootstrappast latch, to choose--initial-cluster-state=newvs=existing. Since the field is never cleared,a re-created seed Pod is handed
=newfor the life of the cluster, and theseed's
--initial-clusteris frozen at bootstrap listing only itself.etcd consults both flags only when the data dir is empty, so this is inert on
ordinary restarts and inert on a corrupt data dir — that just fails to boot and
is now self-healed. It is not inert when the seed's data dir returns empty
with the PVC binding intact (re-provisioned volume, PV restored blank,
node-local storage lost on reimage). A non-seed member in that state gets
=existingagainst a stale--initial-clusterand fails loudly. The seed gets=newagainst an--initial-clusternaming only itself — a complete, internallyconsistent bootstrap instruction. etcd does not error; it forms a fresh
one-member cluster on the empty dir and comes up ready.
Ready is the problem: no self-heal trigger can see it. The
Status.PodUIDcheckneeds a lost Pod,
etcdContainerStuckneeds a not-ready container. Meanwhile<cluster>-clientselects every member Pod with no role filter, so a share ofclient traffic reaches a member serving an empty keyspace.
Worth noting: etcd derives both cluster ID and member ID from the initial
peer-URL set plus
--initial-cluster-token, all unchanged here — so there-bootstrapped seed is expected to return under the same cluster ID rather
than being rejected on a mismatch. That is reasoned from etcd's ID derivation,
not from an observed incident, and is flagged as such in the docs.
The likely fix keeps
spec.bootstrapas an immutable origin record andre-derives the flag from whether the member has ever joined
(
member.Spec.Bootstrap && member.Status.MemberID == ""), rather than clearingthe field. Left to its own change — it alters what etcd is told at boot and
deserves a separate bisect point.
Not a one-off
This is the third instance of one shape: a self-heal exclusion that leaves a
member permanently unrecoverable. #352 removed the memory-medium exclusion
from this same gate for the same reason — a wedged member stays wedged forever.
#343 covers the terminal case where every member is gone and the cluster
silently reports healthy.
Each exclusion was written for a real hazard and expressed as a permanent
property of the member rather than a condition that can clear. The general rule
worth adopting: self-heal may be gated on cluster state, never on member
identity — state can expire, identity cannot, and an exclusion that cannot
expire is an unrecoverable member waiting for the right failure. With this
change the gate holds to that rule exactly:
etcdContainerStuckand the quorumgate are both pure state.
Note
docs/member-rollout-design.md(on the unmergeddesign/mutable-settings-rolloutbranch) tells the rollout engine to "prefer anon-seed" victim and cites this gate as the reason. That rationale goes away
once this lands — a non-leader preference still stands on its own, but the seed
no longer needs avoiding.
Verification
go build ./...,go vet -tags e2e ./test/e2e/andgo test ./controllers/are green on the rebased branch. The new seed test was confirmed to fail against
current
main's gate before the fix was applied. No cluster was touched.Summary by CodeRabbit
Bug Fixes
Documentation
Tests