Skip to content

fix(etcd): require an even spread of members over nodes by default - #4986

Merged
Aleksei Sviridkin (lexfrei) merged 3 commits into
cozystack:mainfrom
yankawai:fix/etcd-strict-member-spread
Nov 13, 2026
Merged

Aleksei Sviridkin (lexfrei) merged 3 commits into
cozystack:mainfrom
yankawai:fix/etcd-strict-member-spread

Conversation

@yankawai

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

Copy link
Copy Markdown
Contributor

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). 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), 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). It copies the value from there into the spec of an EtcdMember only when it creates one, at bootstrap (#L446) and on scale-up (#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, #L545-L549, #L860). So running members keep ScheduleAnyway, and so does a member whose Pod is evicted, deleted or replaced after a terminal phase (#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, 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), 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, removeMemberFromEtcd), its Pod and its data-<member> PVC are owned by it and deleted with it (#L403, #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, scaleUp). 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), 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), 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.

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), the tenant chart copies _cluster into the Secret of each tenant namespace (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), and it clears the deadline when the cluster converges (#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), 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.

$ 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.

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.

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.

The default spread of the etcd members was ScheduleAnyway, which only
adds to the score of a node, and other scores can outweigh it. A new
etcd on a three-node cluster got its first two members on one node.
On a node-local StorageClass a member cannot leave its node, so losing
that node takes two of the three members and the quorum.

DoNotSchedule with maxSkew 1 still lets an etcd with more members than
nodes start, because the skew is counted between the nodes that exist.
One node takes all three members and two nodes take two and one.
nodeTaintsPolicy is Ignore, so a cordoned node still counts and a
member waits for it instead of landing next to another member.

The operator copies the constraints into an EtcdMember when it creates
it and builds the Pod from that copy, so members that exist keep the
old setting and only members created later get the new one.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <[email protected]>
@github-actions github-actions Bot added area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) kind/bug Categorizes issue or PR as related to a bug size/M This PR changes 30-99 lines, ignoring generated files labels Oct 20, 2026
@coderabbitai

coderabbitai Bot commented Oct 20, 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 9f6fdd7 and 3fce040.

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

The etcd default topology spread constraint now uses DoNotSchedule and ignores node taints. Tests cover the default constraint and a configured ScheduleAnyway policy. Affinity descriptions document scheduling constraints and when affinity updates apply.

Changes: etcd topology spread

Layer / File(s) Summary
Topology spread constraint, rendering tests, and affinity descriptions
packages/extra/etcd/templates/etcd-cluster.yaml, packages/extra/etcd/tests/topology-spread_test.yaml, packages/extra/etcd/README.md, packages/extra/etcd/values.yaml, packages/extra/etcd/values.schema.json, packages/system/etcd-rd/cozyrds/etcd.yaml
The default per-host constraint uses DoNotSchedule and nodeTaintsPolicy: Ignore. Tests check the complete default constraint and verify that a configured cluster-wide policy renders as ScheduleAnyway. Affinity descriptions cover topology spread defaults, conditions that can leave members Pending, and the effect of affinity changes as members are replaced.

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

Change: Bug fix

Merge Risk: Minimal · previous review 9f6fd

No actionable merge-blocking risk was identified in the reviewed change.

Pre-merge checks: Passed 5

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: enforcing even member spreading across nodes by default in etcd.
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.
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. (5 skipped: 5 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

Commits

Reviewing files that changed from the base of the PR and between f1f30b3 and 9f6fdd7.

Files selected for processing: 5

  • packages/extra/etcd/README.md
  • packages/extra/etcd/templates/etcd-cluster.yaml
  • packages/extra/etcd/values.schema.json
  • packages/extra/etcd/values.yaml
  • packages/system/etcd-rd/cozyrds/etcd.yaml
Files skipped from review as they are similar to previous changes: 1

  • packages/extra/etcd/templates/etcd-cluster.yaml

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

@myasnikovdaniil myasnikovdaniil 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.

Requesting changes only to declare two cases, fix itself is right: ScheduleAnyway is only a score and it lost to the extender score, so members ended up together.

  1. Member can get stuck Pending after cluster grows. If etcd is created while there are fewer nodes than members, DoNotSchedule still lets two members share a node (3 on one node, 2/1 on two). After a node is added, next recreation of one of those pods goes through the hard constraint. Graceful reboot is enough, operator recreates Succeeded pod from member's own copy. Its node already has one matching pod and new node has none, so 1+1-0 is over maxSkew and its own node is rejected, while node-local volume rules out every other node. Two nodes grown to three gives etcd running 2 of 3, and operator PDB then blocks drain of either other node. One node grown to three - reboot of that node brings back one of three pods and quorum is gone. With ScheduleAnyway pod just went back to its node. affinity description in values.yaml and comment above affinity block still explain soft default by this same case, so they need update too.

  2. Release note says etcd with more members than nodes still starts. With nodeTaintsPolicy: Ignore a node pods can't tolerate counts as node with zero members, so no node can take second member. One schedulable node plus tainted control plane gives one running member and two Pending, no quorum, before all three ran. Description mentions it, release note says opposite.

Please put both in release note with the way out: global ScheduleAnyway policy and how to get stranded member running again (I expect deleting its EtcdMember works while quorum holds, replacement goes to empty node, but please confirm). Then fix two docs, regenerate README and carry same release note to #4988. If you prefer to close case 1 in code, it belongs to etcd-operator (DoNotSchedule only while member PVC is unbound), not this PR.

The affinity description and the comment above the affinity block
explained the soft default by a member that would stay Pending on its
node-local volume. Now that the spread is DoNotSchedule by default, the
spread keeps such a member Pending as well, once its node holds another
member and some other node holds none. Both texts now explain the soft
default by what is left, that a required rule forbids two members on
one node even when every node has one, and state the node-local case
for both rules.

The description also says that the operator copies affinity into each
EtcdMember when it creates it, so a recreated Pod keeps the rule its
member was created with. README, values.schema.json and the
ApplicationDefinition are regenerated from it.

Assisted-by: LLM
Signed-off-by: Yan Bondarenko <[email protected]>
@yankawai

Copy link
Copy Markdown
Contributor Author

Both cases and the way out are in the release note now, here and on #4988, and 9f6fdd7 rewrites the affinity description and the comment above the affinity block so they no longer explain the soft default by the first case, with README, schema and ApplicationDefinition regenerated. Deleting the stranded member's EtcdMember while quorum holds works as you expected, since in v0.5.5 and v0.5.6 the finalizer runs MemberRemove, the Pod and PVC go with the member and the scale-up can place the replacement only on the node without a member. We moved a co-located member on a three-node cluster exactly that way, and quorum held throughout. For the taint case a replacement gets ScheduleAnyway only after the operator adopts the global policy into status.observed, which needs all members Ready or a passed progress deadline, so the note asks to set the policy before the upgrade.

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

Copy link
Copy Markdown
Contributor

europrinter (@yankawai) I merged main into your branch (3fce040) 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) LGTM. Every point of the earlier change request is covered: the release note has both cases and the way out, #4988 carries the same note, and the regenerated README, schema and ApplicationDefinition match.

Optional: the f1f30b3 message still says DoNotSchedule "lets an etcd with more members than nodes start"; if you rewrite the branch, add "when every node can run the Pods".

@lexfrei
Aleksei Sviridkin (lexfrei) merged commit e4d059c into cozystack:main Nov 13, 2026
24 checks passed
Aleksei Sviridkin (lexfrei) added a commit that referenced this pull request Nov 13, 2026
## What this PR does

On three 3-node clusters running v1.6.4 and v1.6.5 we found a tenant
etcd with two of its three members on one node, a tenant Redis with two
of its three Sentinels on the node that also ran the master, and several
VictoriaMetrics and VictoriaLogs pairs (vmstorage, vmselect,
vmalertmanager, vlstorage) sharing a node. Nothing alerted. Losing one
of those nodes would have cost the etcd or Sentinel quorum, or both
copies of a storage tier. Spread constraints keep new pods apart once
they are in place (#4986 for etcd, #4989 for monitoring, #4990 for
Redis), but a placement that already exists, or that comes from a soft
rule or a drain, stays as it is, because the scheduler does not move
running pods.

This adds `PodReplicasColocated`, a warning in a new
`workload-placement` group under
`packages/system/monitoring-agents/alerts/`. It fires when two or more
running pods of one StatefulSet, ReplicaSet, CNPG `Cluster` or
`StrimziPodSet` have been on one node for an hour while the cluster has
at least as many schedulable nodes as the workload has running pods, so
five replicas on three nodes stay quiet. The alert carries `cluster`,
`namespace`, `owner_kind`, `owner_name` and `node`, plus the `severity`,
`exported_instance` and `service` labels the other platform alerts set,
and its value is the number of pods on that node. DaemonSets, Jobs and
every other owner kind are not in the list, so a DaemonSet surge pod
next to its predecessor does not count.

etcd-operator gives every member pod its own `EtcdMember` as controller
owner
([etcdmember_controller.go#L546](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdmember_controller.go#L546)),
so grouping by owner would see one pod per group. Members get their
names from `GenerateName: <cluster>-`
([etcdcluster_controller.go#L434](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdcluster_controller.go#L434),
[#L839](https://github.com/cozystack/etcd-operator/blob/v0.5.6/controllers/etcdcluster_controller.go#L839)),
and members adopted from the old StatefulSet keep names like `etcd-0`
([adopt.go#L103](https://github.com/cozystack/etcd-operator/blob/v0.5.6/internal/migrate/adopt.go#L103)).
The rule drops the last dash-separated part of the member name and
reports the group as `EtcdCluster <name>`. With the etcd package that is
one group per tenant namespace, and two etcd clusters in one namespace
would still be told apart. CNPG instance pods carry a controller
reference to their `Cluster`
([cluster_create.go#L1580](https://github.com/cloudnative-pg/cloudnative-pg/blob/v1.30.0/internal/controller/cluster_create.go#L1580)),
and Strimzi pods one to their `StrimziPodSet`
([StrimziPodSetController.java#L369](https://github.com/strimzi/strimzi-kafka-operator/blob/0.45.1-rc1/cluster-operator/src/main/java/io/strimzi/operator/cluster/operator/assembly/StrimziPodSetController.java#L369)).

Owner and node both come from `kube_pod_info`. kube-state-metrics fills
`created_by_kind` and `created_by_name` from the controller owner
reference and `node` from `spec.nodeName`
([pod.go#L638-L653](https://github.com/kubernetes/kube-state-metrics/blob/v2.15.0/internal/store/pod.go#L638-L653)).
`kube_pod_owner` and `kube_pod_status_phase` have no node label of their
own, and the kube-state-metrics scrape adds `node` from
`__meta_kubernetes_pod_node_name` with `honorLabels: true`
(`templates/kube-state-metrics-scrape.yaml`), so on those series `node`
is the node of the kube-state-metrics pod. The test data keeps that
label on the phase series. A pod counts while
`kube_pod_status_phase{phase="Running"}` is 1, so a Failed or Pending
pod on the same node does not. `kube_pod_labels` does carry every pod
label here (`--metric-labels-allowlist=pods=[*]`), but the rule does not
depend on it.

A node counts as schedulable unless it has a `NoSchedule` or `NoExecute`
taint. A cordon shows up as the
`node.kubernetes.io/unschedulable:NoSchedule` taint
([node_lifecycle_controller.go#L549-L555](https://github.com/kubernetes/kubernetes/blob/v1.33.12/pkg/controller/nodelifecycle/node_lifecycle_controller.go#L549-L555)),
and not ready and unreachable nodes get theirs from their conditions
([#L87-L99](https://github.com/kubernetes/kubernetes/blob/v1.33.12/pkg/controller/nodelifecycle/node_lifecycle_controller.go#L87-L99)),
so one clause covers all three, and a tainted control plane does not
count either. Pods that a drain had to put together on the remaining
nodes therefore stay quiet while the node is out, and the hour starts
once it is back.

The alert waits an hour. A replacement pod runs next to its terminating
predecessor only for the predecessor's grace period, and node
maintenance takes the drained node out of the count, while a placement
that has held for an hour does not fix itself. The alert is about a risk
rather than an outage, so the wait costs nothing. The VMAlert sets both
`remoteWrite` and `remoteRead`, so the pending state survives a vmalert
restart ([vmalert
docs](https://docs.victoriametrics.com/victoriametrics/vmalert/#alerts-state-on-restarts)).

Not covered are workloads that run one pod per owner, such as ClickHouse
with one StatefulSet per replica
([patterns.go#L45-L53](https://github.com/Altinity/clickhouse-operator/blob/release-0.25.2/pkg/model/chi/namer/patterns.go#L45-L53))
and the KubeVirt VMs behind a tenant Kubernetes node group. They would
need grouping by pod labels, which this rule avoids. The node count is
cluster-wide, so a workload that a node selector or affinity restricts
to fewer nodes than it has pods fires too.

`hack/workload-placement-alerts.bats` takes `.spec` of the VMRule and
replays it through `vmalert-tool` v1.136.0 pinned by digest, the release
vm-operator v0.68.4 deploys by default
([config.go#L38](https://github.com/VictoriaMetrics/operator/blob/v0.68.4/internal/config/config.go#L38))
and the same image #4963 and #4985 use.
`hack/testdata/workload-placement-alerts/unittest.yaml` has 13 cases
with 24 checks. Two of three StatefulSet pods on one node fire at 61
minutes and not at 59, with labels and annotations pinned. Spread pods,
five ReplicaSet pods on three nodes, a DaemonSet surge pod, two pods of
one Job, a Failed pod next to a running one, and one pod each in two
clusters that share workload and node names stay quiet. etcd members
fire as one `EtcdCluster` per namespace, for generated and adopted
member names. A replacement pod next to its terminating predecessor for
twenty minutes stays quiet, and the same overlap held past the hour
fires and resolves when it ends. A cordoned node keeps the alert quiet
until it is uncordoned, and a node with unreachable taints keeps it
quiet throughout. ReplicaSet, CNPG `Cluster` and `StrimziPodSet` pods
fire with their owner kind.

```console
$ bats hack/workload-placement-alerts.bats
1..1
ok 1 workload placement alerts pass their vmalert-tool cases
```

With the expression replaced by one that never fires, the suite prints
`not ok 1` and vmalert-tool reports 5 of the 24 checks, which are all
the checks that expect an alert. On main, where the rule file does not
exist, the suite fails as well. Each of these changes to the rule fails
at least one check, with the number of failing checks in brackets. `for:
15m` (5), no phase filter (1), no node count (6), no `cluster` label
(6), DaemonSet and Job in the kind list (3), no taint clause (4), `<`
instead of `<=` (5), etcd members grouped per member (1).

With Bats 1.14.0 at the commit CI pins, the suite passes together with
`alert-severity-contract`, `bats-strict-setup`,
`bats-no-negated-assert`, `bats-no-exit-trap`, `bats-runner-coverage`,
`bats-runner-contract`, `image-pin-consistency` and
`check-commit-trailers`, 109 of 109, and the severity contract lists the
new alert as `warning`. helm-unittest 1.1.2, the version CI pins, passes
the monitoring-agents chart with 19 of 19 tests in 6 suites, and `helm
template` with Helm 3.17.3 renders the VMRule the same as the file. Like
the migration suites the new suite needs docker, and only the image pull
touches the network. I have not run the rule in vmalert on a live
cluster.

On release-1.6 kube-state-metrics 2.15.0, its scrape config, the alerts
template, the VMAlert and vm-operator v0.68.4 are the same, and
etcd-operator v0.5.5 and CNPG 1.28.2 set the same owners and member
names. The new files apply there without changes. That backport is a
separate PR with one more commit that drops the `load test_helper` line,
because release-1.6 runs `hack/*.bats` through `hack/cozytest.sh`.

### Screenshots

Not a UI change.

### Downstream repositories

A new alert file under `packages/system/monitoring-agents/alerts/`, a
Bats suite under `hack/` and its test data. No values, schema, README,
image or `ApplicationDefinition` change, and the website does not list
platform alerts by name, 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
feat(monitoring): new warning alert PodReplicasColocated fires when two or more running pods of one StatefulSet, ReplicaSet, CNPG Cluster, StrimziPodSet or etcd cluster have shared a node for an hour while the cluster has a schedulable node for each of them.
```


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

## Summary by CodeRabbit

* **New Features**
* Added monitoring for workloads whose running pod replicas are
colocated on the same node.
* Alerts include workload, node, cluster, and severity details, along
with quorum-loss guidance.
* Coverage includes StatefulSets, ReplicaSets, Clusters, StrimziPodSets,
and EtcdMembers.

* **Tests**
* Added validation for workload-placement alerts across scheduling,
availability, cluster-isolation, and pod-replacement scenarios.

<!-- 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/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) 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