Skip to content

fix(controllers): switch the PDB from maxUnavailable to minAvailable - #351

Open
K.J. Valencik (kjvalencik) wants to merge 1 commit into
cozystack:mainfrom
kjvalencik:kj/pdb-min-available
Open

fix(controllers): switch the PDB from maxUnavailable to minAvailable#351
K.J. Valencik (kjvalencik) wants to merge 1 commit into
cozystack:mainfrom
kjvalencik:kj/pdb-min-available

Conversation

@kjvalencik

@kjvalencik K.J. Valencik (kjvalencik) commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Production impact

During a node rotation (Karpenter), a healthy 5-voter cluster was drained to 2 voters — below its steady-state quorum of 3 — for ~36 minutes. The budget was maxUnavailable: 2, which should have prevented this, but it re-bases under churn: allowed = currentHealthy - (expectedCount - maxUnavailable), where expectedCount derives from the /scale subresources of the currently matching pods. Each removed member shrank expectedCount and refilled the budget mid-drain (evicted-but-terminating voters also briefly kept counting as healthy). No single eviction violated the budget, yet the cluster ended up at 2-of-2.

The window stayed open that long because recovery didn't converge on its own: a replacement pod crashlooped, having been created with a static config that expected a number of voters that had since shrunk.

Why minAvailable, and why the target anchors it

An integer minAvailable has no expectedCount term — allowed = currentHealthy - minAvailable — so the floor cannot re-base as membership shrinks. The floor is the quorum (n/2 + 1) of max(live voters, status.observed.replicas):

  • Steady state: identical disruptions to the old budget (n - (n-1)/2 = n/2 + 1).
  • Unplanned churn: the latched target holds the floor at steady-state quorum while the operator refills membership. This would have stopped the incident at the first over-budget eviction.
  • Intentional scale-down: the live count dominates, so the floor tracks the live quorum down as members are removed (via MemberRemove, not the eviction API) — a 5→3 shrink never wedges drains.
  • Bootstrap / scale-up: the target dominates; voter evictions block until the cluster reaches size. Learners are outside the selector and unaffected.

Existing PDBs are migrated in place: the reconciler clears maxUnavailable in the same patch that sets minAvailable (a PDB with both is invalid). No API change; selector, delete-at-zero-voters, additionalMetadata, and the /scale contract are untouched.

Future direction

This is deliberately the least impactful fix: no new API, steady-state semantics unchanged. What I'd actually like is to allow only a single voter to be replaced at a time regardless of cluster size (minAvailable = n - 1) — rotations replace nodes serially anyway, and quorum is the emergency floor, not a comfortable operating point. That needs configuration and more complex code, so it's left to a future improvement. Feedback on that design is welcome.

Summary by CodeRabbit

  • Bug Fixes

    • Improved PodDisruptionBudget handling to preserve quorum during disruptions, scaling, and membership changes.
    • Migrated existing budgets from maximum-unavailable rules to minimum-available quorum protection.
    • PodDisruptionBudgets are removed when a cluster has no voting members.
  • Documentation

    • Updated user guidance and API descriptions to explain quorum-based disruption protection, scaling behavior, and node-draining expectations.

The generated PodDisruptionBudget set maxUnavailable = (voters-1)/2 over
the role=voter pods. That form re-bases under churn: the disruption
controller computes allowed = currentHealthy - (expectedCount -
maxUnavailable), and expectedCount is derived from the EtcdMember /scale
subresources of the currently-matching pods. During a node rotation each
removed member shrank expectedCount, refilling the budget mid-drain — a
3-of-5 production cluster was legally evicted down to 2-of-2 quorum for
~36 minutes (2026-07-30 incident).

An integer minAvailable has no expectedCount term (allowed =
currentHealthy - minAvailable), so the floor cannot move as membership
shrinks. The floor is the quorum (n/2+1) of max(live voters,
status.observed.replicas):

  - Steady state: identical disruptions to the old budget, since
    n - (n-1)/2 = n/2 + 1.
  - Unplanned churn: the latched target holds the floor at steady-state
    quorum while the operator refills membership; this would have
    clamped the incident.
  - Intentional scale-down: the live count dominates and steps the
    floor down as members are removed via MemberRemove (not the
    eviction API), so a 5->3 shrink never wedges drains.
  - Bootstrap/scale-up: the target dominates and voter evictions block
    until the cluster reaches size; learners are outside the selector
    and unaffected.

The update path now also migrates PDBs left by previous versions:
maxUnavailable is cleared in the same patch that sets minAvailable
(a PDB with both fields is invalid). Delete-at-zero-voters, the
role=voter selector, and additionalMetadata merging are unchanged;
no API surface is added.

The EtcdMember /scale contract also stays, but its comments are
corrected: they claimed the PDB controller requires /scale and goes
SyncFailed without it, which is only true of maxUnavailable and
percentage minAvailable budgets — an integer minAvailable takes
expectedCount = len(selected pods) and never resolves scale. The
subresource is kept for any user-created budget over member Pods that
does resolve scale.

Signed-off-by: K.J. Valencik <kjvalencik@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added api-change controllers documentation Improvements or additions to documentation labels Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The operator now manages PodDisruptionBudgets with quorum-based MinAvailable, anchored to the greater of live voters and observed target replicas. Tests cover migration and churn, while API comments and documentation describe the updated /scale, PDB, and drain behavior.

Changes

PodDisruptionBudget quorum behavior

Layer / File(s) Summary
Scale subresource contract
api/v1alpha2/etcdmember_types.go, charts/etcd-operator/crd-bases/..., controllers/etcdmember_controller.go
Replica and selector documentation now describes /scale exposure and scale-resolving disruption budgets.
MinAvailable reconciliation
controllers/etcdcluster_controller.go
PDB reconciliation computes quorum from live voters and observed target replicas, creates MinAvailable budgets, and migrates existing MaxUnavailable budgets.
Behavior validation and guidance
controllers/etcdcluster_controller_test.go, README.md, docs/concepts.md, docs/operations.md
Tests cover quorum calculations, migration, churn, and zero-voter deletion; documentation explains voter selection, quorum anchoring, and drain behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested labels: bugfix

Suggested reviewers: androndo

Sequence Diagram(s)

sequenceDiagram
  participant EtcdClusterController
  participant ClusterStatus
  participant PodDisruptionBudget
  EtcdClusterController->>ClusterStatus: read observed replicas
  EtcdClusterController->>EtcdClusterController: calculate quorum MinAvailable
  EtcdClusterController->>PodDisruptionBudget: create or patch MinAvailable
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: switching the PodDisruptionBudget logic from maxUnavailable to minAvailable.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 387: Update the quorum-floor documentation: in docs/concepts.md:387-387,
replace the claim that voter evictions are entirely blocked below target with
the behavior that voluntary disruptions are prevented only below quorum(max(live
voters, target)), while scale-down follows the larger live-voter anchor; in
README.md:24-24, mention the max(live voters, intended target) anchor or link to
the detailed formula; in docs/operations.md:541-541, remove the claim that all
voter evictions are blocked until membership is whole.
- Around line 399-402: The scale-up safety discussion around “Scale-up (after
promote)” incorrectly permits N=1, where evicting the unlabelled promoted voter
violates quorum. Update the implementation or documented topology to ensure the
incoming voter is PDB-selected before promotion, or explicitly prevent 1→2
scale-ups, and add a regression case covering this scenario; do not rely on the
quorum floor to protect Pods outside the selector.
🪄 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: 3ebde05c-b355-4ae6-8e5f-48e83b229e54

📥 Commits

Reviewing files that changed from the base of the PR and between 9c5d896 and d56e6ca.

📒 Files selected for processing (8)
  • README.md
  • api/v1alpha2/etcdmember_types.go
  • charts/etcd-operator/crd-bases/etcd-operator.cozystack.io_etcdmembers.yaml
  • controllers/etcdcluster_controller.go
  • controllers/etcdcluster_controller_test.go
  • controllers/etcdmember_controller.go
  • docs/concepts.md
  • docs/operations.md

Comment thread docs/concepts.md
- **MaxUnavailable**: `(votingMembers - 1) / 2`, integer-divided so the result floors automatically. For 1 voter → 0 (any disruption is quorum loss). For 3 → 1, 4 → 1, 5 → 2, 7 → 3.
- **MinAvailable**: the quorum (`n/2 + 1`, integer-divided) of `max(votingMembers, status.observed.replicas)`. For 1 voter → 1, 3 → 2, 4 → 3, 5 → 3, 7 → 4.

Allowed disruptions = healthy voters − `minAvailable`; there is no `expectedCount` term for churn to re-base the budget against. Anchored to `max(live, target)`, the floor holds at the target's quorum while a node rotation shrinks live membership, steps down with the live count during an intentional scale-down (member removal goes through `MemberRemove`, not the eviction API, so the PDB never blocks it), and blocks voter evictions entirely while the cluster is below target (bootstrap, scale-up). Learners are outside the selector and evict freely throughout.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Describe a quorum floor, not a full-target availability requirement.

minAvailable = quorum(max(live voters, target)) does not block every eviction below target: with target 5 and 4 healthy voters, minAvailable is 3, so one eviction remains allowed. Document that it prevents voluntary disruption below the anchored quorum and that scale-down uses the larger live-voter anchor.

  • docs/concepts.md#L387-L387: replace “blocks voter evictions entirely while the cluster is below target” with quorum-floor semantics.
  • README.md#L24-L24: mention the max(live voters, intended target) anchor or link to the detailed formula.
  • docs/operations.md#L541-L541: remove the claim that all voter evictions block until membership is whole.
📍 Affects 3 files
  • docs/concepts.md#L387-L387 (this comment)
  • README.md#L24-L24
  • docs/operations.md#L541-L541
🤖 Prompt for 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.

In `@docs/concepts.md` at line 387, Update the quorum-floor documentation: in
docs/concepts.md:387-387, replace the claim that voter evictions are entirely
blocked below target with the behavior that voluntary disruptions are prevented
only below quorum(max(live voters, target)), while scale-down follows the larger
live-voter anchor; in README.md:24-24, mention the max(live voters, intended
target) anchor or link to the detailed formula; in docs/operations.md:541-541,
remove the claim that all voter evictions are blocked until membership is whole.

Comment thread docs/concepts.md
Comment on lines +399 to 402
- **Scale-up (after promote).** Etcd's `MemberList` reports N+1 voters but `Status.IsVoter` for the freshly-promoted member hasn't been patched yet, so the PDB selects only the N old voter Pods. A drain in this window could evict the unlabelled new voter (no PDB protection) — etcd is left with N voters running of N+1 registered, which an M=N+1-voter cluster (write quorum `⌊M/2⌋+1`) tolerates for any N ≥ 2. That exposure is unchanged from the old budget. The N labelled voters are protected at least as strongly as before: the floor is the quorum of the scale-up *target* (≥ N+1), so `allowed = currentHealthy - minAvailable` in this window is never more permissive than the old `(N-1)/2` budget, and is often 0.
- **Scale-down (after `MemberRemove`).** Etcd has N-1 voters but the victim's Pod is briefly Terminating. The PDB's own selector still matches the Terminating Pod, but the k8s PDB controller's `currentHealthy` counts only Pods whose `Ready` condition is `True` — once kubelet flips the Terminating Pod's `Ready` to `False` (which happens at the start of graceful shutdown, before the Pod is gone), it stops counting toward the budget's healthy total. Under `minAvailable` that is the whole story: `allowed = currentHealthy - minAvailable`, and — unlike `maxUnavailable` — there is no `expectedCount` for the disappearing member's scale subresource to shrink, so an in-flight removal consumes budget instead of refilling it.

Both windows are one reconcile cycle wide.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Protect the unlabelled voter during a 1→2 scale-up.

At Line 399, N=1 is outside the stated safety condition: evicting the unlabelled promoted member leaves one running voter in a two-voter membership, below its quorum of two. Ensure the incoming member is PDB-selected before promotion (or prevent this topology), and add a regression case; the floor cannot protect a Pod outside its selector.

🤖 Prompt for 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.

In `@docs/concepts.md` around lines 399 - 402, The scale-up safety discussion
around “Scale-up (after promote)” incorrectly permits N=1, where evicting the
unlabelled promoted voter violates quorum. Update the implementation or
documented topology to ensure the incoming voter is PDB-selected before
promotion, or explicitly prevent 1→2 scale-ups, and add a regression case
covering this scenario; do not rely on the quorum floor to protect Pods outside
the selector.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api-change controllers documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant