Skip to content

fix(seaweedfs): keep the masters on separate nodes through a drain - #4947

Open
europrinter (yankawai) wants to merge 2 commits into
cozystack:mainfrom
yankawai:fix/seaweedfs-master-spread
Open

europrinter (yankawai) wants to merge 2 commits into
cozystack:mainfrom
yankawai:fix/seaweedfs-master-spread

Conversation

@yankawai

@yankawai europrinter (yankawai) commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

The SeaweedFS master StatefulSet only prefers separate nodes, which decides where a master starts but not where a drain puts it back. On a three-node cluster both other nodes already run a master when one is evicted, so the replacement starts next to one of them, and nothing moves it back once the drained node returns. We found two of the three masters on one node on two clusters running v1.6.4. On the first, a three-node cluster, both had started there in the same second during node maintenance. On the second they arrived a day apart. Losing that node takes two of the three raft members and the quorum, and losing two masters at once is also how #4607 starts.

This adds a hostname spread to the masters with whenUnsatisfiable: DoNotSchedule and maxSkew: 1. The cordoned node still counts, with no master on it, so the replacement waits Pending and comes back to that node on uncordon while the other two keep quorum. With four or more nodes it moves to a node without a master instead. It does not bring back the Pending that #3197 removed, because the skew is measured between the nodes that exist, so two nodes still run two and one masters and a single node runs all three. nodeTaintsPolicy is Ignore on purpose, since a cordoned node carries node.kubernetes.io/unschedulable and Honor drops it from the count. The soft anti-affinity from #3197 stays. On upgrade the StatefulSet rolls once, one master at a time, and where three nodes can take the masters that rollout also separates masters that already share a node.

This has two costs I want to be upfront about. A node the masters never run on, such as a tainted control plane, is counted as well, so a cluster with only two nodes the masters can use next to such a node keeps the third master Pending, and an upgrade there stalls with the rollout stuck at one updated master. On a two-node cluster a drain of the node that runs two masters now leaves both Pending until it is back, where today they restart on the other node.

I checked the scheduling against kube-scheduler v1.34.12 in a local kwok cluster, with the master StatefulSet rendered from the chart on fake nodes. With main, draining a node brought its master back on the node of another master, and it stayed there after the uncordon. With this change it waited Pending (1 node(s) were unschedulable, 2 node(s) didn't match pod topology spread constraints) and came back to its own node. On four nodes it moved to the free one, two nodes ran two and one, a single node ran all three, and a rollout from a piled-up layout on three nodes ended one master per node. Two nodes plus a tainted one left the third master Pending, and with Honor instead of Ignore the replacement piled up again.

The seaweedfs chart passes 16 suites and 84 tests. The two new cases in master_affinity_test.yaml fail without the values change, and switching to Honor, ScheduleAnyway or maxSkew: 2 fails one case each. They name their template explicitly, so they keep working when #4879 adds the master ConfigMap to that suite. The e2e suites were not run here.

An earlier version of this PR also had a monitoring commit with a preferred anti-affinity. I dropped it, #4989 covers the same replicas with a hard spread.

I would like your view on two points. Is the tainted-node case acceptable as documented, or would you rather have an opt-out on the SeaweedFS app, for example a soft or hard choice like topologySpreadPolicy in the opensearch app? And should two-node clusters keep moving both masters during a drain, which would need that same opt-out?

Screenshots

Not a UI change.

Downstream repositories

The diff touches only the values, templates and tests of packages/system/seaweedfs. No app values, schema, default, name or tooling changes, so no trigger map entry applies, and the website docs carry no placement workaround this would make obsolete.

Release note

fix(seaweedfs): SeaweedFS masters now wait for a drained node instead of starting next to another master, so node maintenance no longer leaves two of the three raft members on one node.

The masters only prefer separate nodes, which decides where they start
but not where a drain puts them back. On a three-node cluster both other
nodes already run a master when one is evicted, so the replacement
starts next to one of them and stays there after the drained node
returns. Nothing moves it back, and losing that node then takes two of
the three raft members and the quorum with them.

A hostname spread with DoNotSchedule and maxSkew 1 keeps counting the
cordoned node, which now runs no master, as the minimum. The replacement
waits Pending for it and starts there once it is uncordoned, while the
other two masters keep quorum. Unlike the required anti-affinity of the
upstream chart it still lets two nodes run two and one masters and a
single node all three, since the skew is measured between the nodes
that exist.

nodeTaintsPolicy stays Ignore because a cordoned node carries the
unschedulable taint and Honor would drop it from the count. The cost is
that a node the masters never run on counts as well, so next to a
tainted control plane they need three nodes they can use, or the third
one stays Pending.

The soft anti-affinity stays for scoring. Where three nodes can take the
masters, the rolling update this causes also separates masters that
already share a node.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <[email protected]>
@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files area/monitoring Issues or PRs related to the monitoring stack (vlogs, vmstack, grafana, workloadmonitor) area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) kind/bug Categorizes issue or PR as related to a bug labels Oct 18, 2026
@coderabbitai

coderabbitai Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration

Commits

Reviewing files that changed from the base of the PR and between b47fed7 and 943a1d7.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review

No actionable comments were generated in the recent review.

Walkthrough

Monitoring templates now apply preferred hostname anti-affinity to Alertmanager and monitoring cluster components. SeaweedFS master Pods gain a hostname topology spread constraint. Helm tests cover the selectors and placement settings.

Changes: Monitoring pod anti-affinity

Layer / File(s) Summary
Define and apply monitoring anti-affinity
packages/system/monitoring/templates/_helpers.tpl, packages/system/monitoring/templates/alerta/alerta.yaml, packages/system/monitoring/templates/vm/vmcluster.yaml, packages/system/monitoring/templates/vlogs/vlogs.yaml, packages/system/monitoring/templates/vtraces/vtraces.yaml, packages/system/monitoring/tests/spread_over_nodes_test.yaml
A shared template renders weight-100 preferred hostname anti-affinity using component and resource-instance labels. Alertmanager and VMCluster, VLCluster, and VTCluster components use it. Helm tests check the selectors and confirm that required anti-affinity is absent in the specified cases.

SeaweedFS master topology spread

Layer / File(s) Summary
Configure and test master spreading
packages/system/seaweedfs/values.yaml, packages/system/seaweedfs/tests/master_affinity_test.yaml
Master Pods gain a hostname topology spread constraint with maxSkew: 1 and DoNotSchedule. The tests check the constraint settings and release-specific selector labels.

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix

Suggested reviewers: ivanhunters

Merge Risk: Minimal · previous review ac2a1

No actionable placement defect remains. The change is mergeable after normal checks, with the documented SeaweedFS scheduling tradeoffs understood.

Pre-merge checks: Passed 5

Check name Status Explanation
Docstring Coverage Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
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 and concisely describes the SeaweedFS scheduling change that keeps master replicas separated during node drains.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0 files. (8 skipped: 8 unsupported.)

Finishing Touches

🧪 Generate unit tests (beta)

  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

ℹ️ Recent review info

⚙️ Run configuration

  • Configuration used: Repository: cozystack/cozystack/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3283f8a7-233b-4b67-baab-e5a420eb62a7
📥 Commits

Reviewing files that changed from the base of the PR and between aed7fa8 and ac2a1d3.

📒 Files selected for processing: 8

  • packages/system/monitoring/templates/_helpers.tpl
  • packages/system/monitoring/templates/alerta/alerta.yaml
  • packages/system/monitoring/templates/vlogs/vlogs.yaml
  • packages/system/monitoring/templates/vm/vmcluster.yaml
  • packages/system/monitoring/templates/vtraces/vtraces.yaml
  • packages/system/monitoring/tests/spread_over_nodes_test.yaml
  • packages/system/seaweedfs/tests/master_affinity_test.yaml
  • packages/system/seaweedfs/values.yaml

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.

@github-actions github-actions Bot added size/M This PR changes 30-99 lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Oct 21, 2026
@yankawai europrinter (yankawai) changed the title fix(seaweedfs, monitoring): keep HA replicas from piling up on one node fix(seaweedfs): keep the masters on separate nodes through a drain Oct 21, 2026

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The topology-spread selector matches the rendered master pod labels byte-for-byte (not a no-op), confirmed on the live render and with per-field mutations (ScheduleAnyway, maxSkew:2, Honor each fail one new case). The upgrade is purely additive (one rolling restart, quorum held on a healthy 3-node cluster). helm-unittest 84/84. No blocking issues.

One design note worth a decision before merge (non-blocking):

  • packages/system/seaweedfs/values.yaml:141: the hard spread (whenUnsatisfiable: DoNotSchedule) is unconditional with no opt-out, and nodeTaintsPolicy: Ignore counts nodes the masters can never use (they carry no tolerations and no nodeSelector). On a cluster with only two usable nodes plus a tainted node, the third master stays Pending (helm install --wait never returns, Flux loops), which is the #3197 failure class for that narrower topology; on a two-node cluster, draining the node running two masters leaves both Pending for the maintenance window. Three or more usable nodes (the HA target) work correctly, and the regressed topologies cannot provide master HA anyway. Suggest the opt-out toggle the PR already proposes, defaulted to the hard spread. The PR documents both costs and asks reviewers to decide.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <[email protected]>
@lexfrei

Copy link
Copy Markdown
Contributor

europrinter (@yankawai) I merged main into your branch (943a1d7) so CI runs against the current base. Your commits are unchanged.

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.

europrinter (@yankawai) NOT LGTM, one fix inline: the hard spread needs an opt-out before it merges.

# replacement pile up again. The price is that a node the masters never
# run on, such as a tainted control plane, counts as empty too, so with
# one in the cluster the masters need three nodes they can run on.
topologySpreadConstraints: |

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.

This needs the opt-out you asked about before it merges. nodeTaintsPolicy: Ignore counts a node the masters can't use (a kubeadm control plane, a tainted GPU worker) at zero masters, so with two usable nodes next to one the third master never schedules: a fresh install never gets ready, and an upgrade stalls on the first master it rolls. That's the hang #3197 fixed, now for a narrower topology, and the default variant waits on SeaweedFS. The two-node drain case is the same knob.

Fix: add master.topologySpreadPolicy (hard | soft) to the SeaweedFS app, the way the opensearch app has topologySpreadPolicy, and on soft pass an empty topologySpreadConstraints to this chart so only the preferred anti-affinity is left. Keep hard as the default and name the tainted-node case and the knob in the release note.

Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Nov 13, 2026
…4986)

## What this PR does

The tenant etcd chart spreads members with `maxSkew: 1` on
`kubernetes.io/hostname` and `whenUnsatisfiable: ScheduleAnyway`, the
default since #119. `ScheduleAnyway` only adds to the score of a node
and other scores can outweigh it, so two members of one etcd can still
start on the same node, where a node-local volume then keeps them. This
PR makes the default `DoNotSchedule` with `nodeTaintsPolicy: Ignore`.
The branch for `_cluster.scheduling.globalAppTopologySpreadConstraints`
is untouched, so a platform that sets its own policy renders exactly as
before, and the soft pod anti-affinity from #3962 stays.

Seen on a three-node cluster running v1.6.4, whose `etcd-cluster.yaml`
and etcd-operator v0.5.5 are the same as in v1.6.5. A new tenant etcd
got the Pods of its first two members on one node, created 26 seconds
apart, and the third on another node 18 seconds later. The Pods carry
the `ScheduleAnyway` constraint and were scheduled by
`linstor-scheduler`, whose profile adds the LINSTOR extender's
prioritize score to the same sum with weight 5. The data volumes are on
a node-local StorageClass with `WaitForFirstConsumer` and
`allowRemoteVolumeAccess: "false"`, so the two members cannot leave that
node, and losing it takes the quorum. A second tenant etcd created there
the next day came up the same way. On a test cluster four tenant etcds
created within one week each had two members on one node, and we moved
one member of each by hand.

`DoNotSchedule` does not stop an etcd with more members than nodes from
starting when every node can run its Pods, unlike a required
anti-affinity, because the skew is counted between the nodes that exist.
kube-scheduler rejects a node only when the matching Pods on it plus the
new one exceed the node with the fewest by more than `maxSkew`
([filtering.go](https://github.com/kubernetes/kubernetes/blob/v1.34.1/pkg/scheduler/framework/plugins/podtopologyspread/filtering.go#L334-L352)).
One node takes all three members, two nodes take two and one, and three
or more nodes take one each. A member whose only allowed node cannot
take it, for example because the storage pool there is full, now waits
Pending instead of joining another member.

`nodeTaintsPolicy: Ignore` is the Kubernetes default and is written out
on purpose. A cordoned node carries `node.kubernetes.io/unschedulable`,
and `Honor` would take it out of the count
([common.go](https://github.com/kubernetes/kubernetes/blob/v1.34.1/pkg/scheduler/framework/plugins/podtopologyspread/common.go#L50-L54)),
so a member created during a drain would start next to another member
and stay there on a node-local volume. With `Ignore` it waits Pending
until the node is back, while the other two keep quorum. The cost is
that a node no member can run on, such as a tainted control plane,
counts as an empty node too, so on a cluster with such a node each
schedulable node takes at most one member and the members beyond that do
not start. #4947 makes the same choice for the SeaweedFS masters and
checked both policies in kwok. A platform that wants the old behaviour
can set `scheduling.globalAppTopologySpreadConstraints` to
`ScheduleAnyway`, which the other charts that read it pick up as well,
and a new test case pins that the chart renders such a policy as
written.

On upgrade nothing is recreated. The etcd-operator adopts a changed
`spec.topologySpreadConstraints` into `status.observed` once the cluster
has reached its current target, or its progress deadline has passed
([etcdcluster_controller.go#L203-L214](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdcluster_controller.go#L203-L214)).
It copies the value from there into the spec of an `EtcdMember` only
when it creates one, at bootstrap
([#L446](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdcluster_controller.go#L446))
and on scale-up
([#L851](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdcluster_controller.go#L851)),
and nothing in the operator writes it into an existing `EtcdMember`. The
member controller creates a Pod only when it is missing and builds it
from that member's own copy
([etcdmember_controller.go#L499-L512](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdmember_controller.go#L499-L512),
[#L545-L549](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdmember_controller.go#L545-L549),
[#L860](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdmember_controller.go#L860)).
So running members keep `ScheduleAnyway`, and so does a member whose Pod
is evicted, deleted or replaced after a terminal phase
([#L164-L186](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdmember_controller.go#L164-L186)),
since the new Pod is built from the same copy. Only members created
after the upgrade get `DoNotSchedule`, which means a scale-up, a
crash-loop replacement or a member whose `EtcdMember` was deleted. No
member is rolled, neither one at a time nor all at once. This matches
the field docs
([etcdcluster_types.go#L542-L549](https://github.com/cozystack/etcd-operator/blob/v0.5.6/api/v1alpha2/etcdcluster_types.go#L542-L549),
[etcdmember_types.go#L112-L116](https://github.com/cozystack/etcd-operator/blob/v0.5.6/api/v1alpha2/etcdmember_types.go#L112-L116)).
The code paths are the same in v0.5.8, and in v0.5.5, which v1.6.4 and
v1.6.5 ship, except that v0.5.5 does not yet replace a Pod left in a
terminal phase.

Members that already share a node therefore stay there. One of them
moves only when a new `EtcdMember` takes its place, and the constraint
then sends the replacement to the node without a member. That is how we
moved the members on the test cluster, with `DoNotSchedule` set on the
`EtcdCluster` by hand. The operator's docs say a scheduling change can
be applied by deleting one Pod at a time
([concepts.md#L272](https://github.com/cozystack/etcd-operator/blob/v0.5.6/docs/concepts.md#L272)),
but the recreated Pod gets the member's old copy, so that does not move
it.

The release note now declares two cases from review. In the first, an
etcd created while the cluster had fewer nodes than members has two or
three members on one node, and once a node is added a recreated Pod of
one of them stays Pending, since `maxSkew` rejects its own node and its
node-local volume rules out the others. Deleting that member's
`EtcdMember` while the other members keep quorum replaces it on a node
without a member. In v0.5.5 the member finalizer removes the member from
etcd through the remaining members before the object goes
([`handleDeletion`](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdmember_controller.go#L195-L231),
[`removeMemberFromEtcd`](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdmember_controller.go#L243-L310)),
its Pod and its `data-<member>` PVC are owned by it and deleted with it
([#L403](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdmember_controller.go#L403),
[#L490](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdmember_controller.go#L490)),
and the cluster controller waits for the deletion, then adds a member
under a new name with the constraints from `status.observed`
([etcdcluster_controller.go#L254-L312](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdcluster_controller.go#L254-L312),
[`scaleUp`](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdcluster_controller.go#L820-L851)).
Its new PVC binds where its Pod lands, and under `DoNotSchedule` only a
node without a member passes. The operator documents the same steps for
a broken member, along with the limit that `MemberRemove` cannot run
without quorum
([operations.md#L208-L231](https://github.com/cozystack/etcd-operator/blob/v0.5.5/docs/operations.md#L208-L231)),
and v0.5.6, which main and `release-1.6` ship now, has the same path.
The upstream chart of the operator installs a policy that denies
`EtcdMember` deletion without the
`etcd-operator.cozystack.io/allow-deletion=true` annotation
([member-deletion-policy.yaml](https://github.com/cozystack/etcd-operator/blob/v0.5.5/charts/etcd-operator/templates/member-deletion-policy.yaml)),
and the `etcd-operator` package here does not install it.

On a three-node cluster running v0.5.5 a tenant etcd had two members on
one node with `DoNotSchedule` in effect. We deleted the `EtcdMember` of
one of the two while the other members kept quorum. The replacement's
Pod and its new node-local PVC were scheduled on the node without a
member, the member joined, and the `EtcdCluster` stayed `Available` with
quorum the whole time, polled every 5 seconds.

In the second case `nodeTaintsPolicy: Ignore` counts a node the Pods
cannot tolerate as one with zero members, so on a cluster with such a
node and fewer schedulable nodes than members the members beyond the
schedulable nodes do not start. The way out is the soft global policy in
the platform `Package`, set before the upgrade.

```yaml
apiVersion: cozystack.io/v1alpha1
kind: Package
metadata:
  name: cozystack.cozystack-platform
spec:
  components:
    platform:
      values:
        scheduling:
          globalAppTopologySpreadConstraints: |
            topologySpreadConstraints:
            - maxSkew: 1
              topologyKey: kubernetes.io/hostname
              whenUnsatisfiable: ScheduleAnyway
```

The platform chart writes `scheduling` into `_cluster` of the
`cozystack-values` Secret
([apps.yaml#L292-L295](https://github.com/cozystack/cozystack/blob/55aad6f91456cb545601a83aaecf39118e943b04/packages/core/platform/templates/apps.yaml#L292-L295)),
the tenant chart copies `_cluster` into the Secret of each tenant
namespace
([namespace.yaml#L109-L120](https://github.com/cozystack/cozystack/blob/55aad6f91456cb545601a83aaecf39118e943b04/packages/apps/tenant/templates/namespace.yaml#L109-L120)),
and this chart renders the value as written, which the new test case
pins. Rendering the platform and tenant charts with the value above, on
main and on `release-1.6`, gives the same string in both Secrets. The
operator adopts a changed spec into `status.observed` only once all
members are Ready or the progress deadline has passed
([etcdcluster_controller.go#L196-L213](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdcluster_controller.go#L196-L213)),
and it clears the deadline when the cluster converges
([#L1448-L1456](https://github.com/cozystack/etcd-operator/blob/v0.5.5/controllers/etcdcluster_controller.go#L1448-L1456)).
A new etcd stuck short of its members adopts the policy once its first
deadline passes, and deleting the `EtcdMember` of the Pending member
then brings a replacement that starts. An etcd that had converged before
one of its replacements went Pending adopts the policy only after the
deadline is forced as the operator docs describe
([operations.md#L197-L206](https://github.com/cozystack/etcd-operator/blob/v0.5.5/docs/operations.md#L197-L206)),
which is why the release note says to set it before the upgrade.

#4817 moves this block into `_etcd.tpl` in cozy-lib without changing it,
so whichever of the two lands second has to carry the two lines over.

`tests/topology-spread_test.yaml` now pins the whole default constraint,
and a new case pins that a `ScheduleAnyway` policy from
`globalAppTopologySpreadConstraints` renders as written, without
`nodeTaintsPolicy`. The existing zone override case is unchanged.
Against the template on main the suite fails only the default case, 1
failed and 25 passed. Switching the default to `Honor`, to
`ScheduleAnyway` or to `maxSkew: 2` fails the default case, and adding
`nodeTaintsPolicy` to the override branch fails the new case.

```console
$ docker run --rm -v "$PWD":/apps -w /apps/packages/extra/etcd helmunittest/helm-unittest:4.2.3-1.1.2 .
Charts:      1 passed, 1 total
Test Suites: 9 passed, 9 total
Tests:       26 passed, 26 total
```

On main the same command gives 25 tests in 9 suites, with helm-unittest
1.1.2 as CI pins it. With default values `helm template` differs from
main in the two lines and in the comment above the affinity block, and
with a global policy set only in that comment. The docs commit changes
the `affinity` description, from which `make generate` rewrites the
README, `values.schema.json` and the `ApplicationDefinition` with
nothing but that text, and the suite stays at 26 tests. I have not
rolled this chart change out on a cluster.

The spread block and the test file are the same on release-1.6 and
release-1.7. On release-1.6 the template has no affinity block yet, so
the fix commit cherry-picks without conflicts and the docs commit has
nothing to change there. That backport is a separate PR.

### Screenshots

Not a UI change.

### Downstream repositories

One changed and one added line in
`packages/extra/etcd/templates/etcd-cluster.yaml` and its test suite,
and new text for the `affinity` description and the comment above the
affinity block. The description reaches `values.schema.json`, the README
and the `ApplicationDefinition` as text only, with no key, type or
default change, and the website rebuilds the etcd reference page from
the README on release. The website mentions
`globalAppTopologySpreadConstraints` only as a platform setting, which
stays as it is, so no trigger map entry applies.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(etcd): tenant etcd members now spread over nodes with whenUnsatisfiable DoNotSchedule by default, so two members of one etcd no longer start on the same node while another node has none. Members that exist are not moved, only members created after the upgrade get the new constraint.

An etcd created while the cluster had fewer nodes than members puts two or three members on one node. Once a node is added, such a member whose Pod is recreated (drain, eviction, graceful node shutdown) stays Pending, because maxSkew rejects its own node and its node-local volume rules out the others. With two nodes grown to three the etcd runs two of three members and its PodDisruptionBudget blocks draining the other nodes. With one node grown to three a reboot of that node brings back one member of three and quorum is lost. A stranded member is replaced by deleting its EtcdMember while the other members keep quorum. Its Pod and PVC go with it, and the replacement gets a new Pod and PVC on the node without a member. Right after adding nodes, doing this for one co-located member at a time while all members are Ready avoids the case.

With nodeTaintsPolicy Ignore a node the etcd Pods cannot tolerate, such as a tainted control plane, counts as a node with zero members, so no node can take a second member. On a cluster with such a node and fewer schedulable nodes than members, a new etcd starts only as many members as there are schedulable nodes, one of three on a single schedulable node. An existing etcd there keeps its members, but a member it replaces stays Pending the same way.

Either case is avoided by setting scheduling.globalAppTopologySpreadConstraints in spec.components.platform.values of the cozystack.cozystack-platform Package to a topologySpreadConstraints entry with maxSkew 1, topologyKey kubernetes.io/hostname and whenUnsatisfiable ScheduleAnyway before the upgrade. Every app chart that reads this value applies it, two members of a new etcd can share a node again, and the operator gives it only to members created after it adopts the changed EtcdCluster spec into status.observed. Once status.observed.topologySpreadConstraints of an EtcdCluster shows ScheduleAnyway, a member of it that stays Pending is replaced by deleting its EtcdMember.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **Bug Fixes**
- Default etcd placement now requires even spreading across hosts and
ignores node taints when evaluating placement.
- Configurable cluster-wide spreading continues to use a flexible
scheduling policy when selected.
- **Documentation**
- Clarified how affinity and topology spreading work together, when
required anti-affinity or node-local storage can leave members pending,
and that affinity changes take effect as members are replaced.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/monitoring Issues or PRs related to the monitoring stack (vlogs, vmstack, grafana, workloadmonitor) area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) kind/bug Categorizes issue or PR as related to a bug size/M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants