Skip to content

feat: quorum-aware PodDisruptionBudget per EtcdCluster#399

Open
xrl wants to merge 5 commits into
etcd-io:mainfrom
xrl:pr/pdb-reconciler
Open

feat: quorum-aware PodDisruptionBudget per EtcdCluster#399
xrl wants to merge 5 commits into
etcd-io:mainfrom
xrl:pr/pdb-reconciler

Conversation

@xrl

@xrl xrl commented Jul 7, 2026

Copy link
Copy Markdown

What

The operator now maintains a quorum-aware PodDisruptionBudget for every EtcdCluster — named after the cluster, owned by it (ownerRef, so GC'd on delete), selecting the StatefulSet pods via the same app/controller labels as the headless Service.

Without a PDB, a node drain or any voluntary eviction can take down enough members at once to break quorum. Kubernetes-native etcd operators are expected to guard against this out of the box; today a kubectl drain across two nodes of a 3-member cluster is a quorum loss.

Sizing (minAvailable)

A label selector cannot tell voters from learners (or from the pod of a just-removed member), so the budget assumes every permitted eviction lands on a voter:

minAvailable = total members − (voters − quorum(voters))

Pending scale steps are priced in before membership mutations run, closing the transition windows:

state example minAvailable
steady 3 voters 3/3 2
steady 5 voters 5/5 3
learner present (promotion pending) 3 voters + 1 learner 3
scale-in pending (removed member's pod lingers) 3 → 2 3
scale-out pending (learner pod incoming) 3 → 5 3

Reconciliation behavior

  • The PDB is reconciled even when health checks failhealthCheck still returns the member list for unhealthy clusters, which is exactly when disruption protection matters most. A PDB write failure is logged and surfaced for backoff only when nothing else requeues; it never masks health-check errors or blocks cluster-state reconciliation.
  • With no observed members, any existing PDB is left untouched rather than weakened: a stale-but-protective PDB during an outage beats deleting it, and minAvailable: 0 protects nothing.
  • A pre-existing PDB with the same name that the operator did not create is skipped, never adopted — patching it would clobber a user-managed spec, and SetControllerReference would wedge the requeue path forever.
  • SetupWithManager adds Owns(&policyv1.PodDisruptionBudget{}), so drift (manual edits/deletes) is repaired on the next event.

Files

  • NEW internal/controller/poddisruptionbudget.go — sizing math + reconcilePodDisruptionBudget (CreateOrPatch).
  • internal/controller/etcdcluster_controller.go — wire into Reconcile, Owns watch, policy RBAC marker.
  • internal/controller/utils.go — extract shared etcdPodLabels (StatefulSet / headless Service / PDB select on one set).
  • config/rbac/role.yaml, docs/api-references/docs.md, api/v1alpha1/zz_generated.deepcopy.go — regenerated via make generate manifests api-docs (includes output that was stale on main).
  • NEW internal/controller/poddisruptionbudget_test.go — envtest + table tests.

Review

An adversarial review pass produced 5 findings, all blocking/important ones resolved (second commit): learner pods widening the eviction budget, the scale-in window where a removed member's lingering pod permits a quorum-breaking eviction, the symmetric scale-out window, adoption of a foreign same-name PDB (wedging on SetControllerReference), and PDB write errors masking health-check errors.

Testing

  • Table tests over the sizing math (TestPDBMinAvailable, TestVotingMemberCount): steady 1/2/3/5, learner mixes, pending scale-in/out including multi-step (5→2).
  • envtest (TestReconcilePodDisruptionBudget): create, resize on membership change, learner handling, pending-scale sizing, no-members no-op, foreign-PDB non-adoption — all against a real apiserver, consistent with the repo's controller-helper testing pattern.
  • Full make test and make verify pass.

Descoped follow-ups

Trimmed to keep this PR #382/#384-sized: (1) no user-facing API field (e.g. spec.podDisruptionBudget template/override a la ECK) — operator-managed PDB only, so zero CRD/deepcopy/api-docs churn; (2) no PDB during bootstrap before the first etcd member is observed (minAvailable 0 protects nothing; PDB appears on the first loop that sees a member); (3) no deletion/GC logic — ownerRef garbage collection covers cluster deletion, and the reconciler deliberately never deletes a PDB on transient member-list failures; (4) no maxUnavailable mode, no unhealthyPodEvictionPolicy; (5) no e2e/kind test (test/e2e) — envtest-only coverage, consistent with the repo's controller-helper testing pattern; (6) no status surfacing of the PDB in EtcdClusterStatus.

🤖 Generated with Claude Code


PR series — operability fixes, TLS & EtcdMirror

Small single-purpose PRs from live kind-cluster testing of the operator. Each stands alone unless an After is listed. → = this PR.

PR Lands After
Reviewable now — small, any order
🟢 #374 Requeue instead of swallowing the client-cert provisioning error
🟢 #391 Grant events.k8s.io RBAC so operator Events are actually recorded
🟢 #392 Correlate members[]/leaderID from one health snapshot (consistent leader)
🟢 #393 Propagate user-supplied altNames.ipAddresses into certificates
🟢 #394 Accept day-suffix validityDuration (365d, 100d12h) as documented
🟢 #395 Surface early reconcile errors as a Degraded condition (was empty status)
🟢 #379 Configurable reconcile worker pool (--max-concurrent-reconciles)
🟢 #369 kind-based stress/scale e2e harness (1/3/7 members, churn, quorum watcher)
🟢 #398 podTemplate.spec scheduling fields: topologySpread, resources, priorityClass, schedulerName
🟢 #397 First-class Helm chart covering the full config/ surface
🟢→ #399 Quorum-aware PodDisruptionBudget per EtcdCluster
🟢 #400 Scale-in removes the right member and transfers leadership first
🟢 #401 Backend quota + auto-compaction spec fields, NOSPACE alarm surfacing
🟢 #402 Reconcile-loop gates replace the blocking StatefulSet-ready wait
🟢 #403 Quorum-gated one-pod-at-a-time version upgrades via OnDelete
TLS stack — in order
🟢 #376 Independent spec.tls.{peer,client} surfaces (breaking alpha API)
#377 TLSReady condition + TLS lifecycle Events #376
#378 Multi-member TLS quorum e2e + PeerCANotShared #377
EtcdMirror — in order
🟢 #406 EtcdMirror CRD: one-way cross-cluster replication API (types + CEL)
🟢 #407 pkg/mirroragent replication engine (fenced checkpoint-in-target) #406
🟢 #408 Periodic reconciliation pass wired into the engine's steady-state loop #407
🟢 #412 mirror-agent binary: config/TLS-reload/statusz/metrics + kind e2e #408
🟢 #413 EtcdMirror controller: Deployment rendering, statusz-driven conditions, guards, fenced finalizer #412
🟢 #414 CRD/sample/editor+viewer-role kustomize wiring #413
🟢 #415 Controller-side phase/condition gauges, shipped PrometheusRule + dashboard #414
🟢 #416 Full-lifecycle kind e2e: fence overlap, compaction+prune, restart resume, cert rotation, cutover/reversal #415
Parked as drafts pending #363
#382 Per-cluster domain metrics on the operator /metrics endpoint
#384 EtcdCluster admission webhooks (consolidating with #328) #363
#386 EtcdBackup CR → object storage (S3/GCS) #363
#387 Automatic quorum-loss disaster recovery (bootstrap-latch guarded) #363

🟢 ready · ⚪ draft

xrl and others added 2 commits July 7, 2026 03:14
The operator now maintains a PDB named after the cluster, with
minAvailable set to the quorum of observed voting members (learners
excluded) and the same label selector as the StatefulSet pods. The PDB
is reconciled even when health checks fail, since healthCheck still
returns the member list; with no observed members any existing PDB is
left untouched rather than weakened.

Includes regenerated config/rbac/role.yaml plus generate/api-docs
output that was stale on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Xavier Lange <xrlange@gmail.com>
Size minAvailable as total members minus evictable voters, since the
label selector cannot distinguish voters from learners: learner pods no
longer widen the eviction budget. Pending scale steps are priced in
before membership mutations run, closing the windows where a removed
member's lingering pod or an incoming learner pod would otherwise
permit quorum-breaking evictions.

Never adopt a PDB the operator did not create: an existing object
without our controller ownerRef is skipped instead of patched (or
wedging on SetControllerReference). PDB write failures are logged and
retried without masking health-check errors or blocking cluster state
reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Xavier Lange <xrlange@gmail.com>
@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: xrl
Once this PR has been reviewed and has the lgtm label, please assign ivanvc for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubernetes-prow

Copy link
Copy Markdown

Hi @xrl. Thanks for your PR.

I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

This was referenced Jul 7, 2026
xrl added 3 commits July 8, 2026 03:20
After RemoveMember but before the StatefulSet shrinks, the removed
member's pod still matches the PDB selector; sizing off the member list
alone dropped minAvailable and let a node drain evict a live voter of
the already-shrunk voter set.

Signed-off-by: Xavier Lange <xrlange@gmail.com>
(cherry picked from commit 6f0f407)
Returning pdbErr directly bypassed the captured err the deferred
closure reads, so ObserveReconcile recorded success and Degraded stayed
False while the loop was error-backing-off.

Signed-off-by: Xavier Lange <xrlange@gmail.com>
(cherry picked from commit 2da8b58)
Covers the sizing table and that sizes 1-2 block node drains by design,
since the eviction error does not point back at the operator.

Signed-off-by: Xavier Lange <xrlange@gmail.com>
(cherry picked from commit c1184d5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant