feat(stalwart): spread each node group over nodes and give each one a PDB
A two-replica node group was redundancy on paper: nothing kept both pods off one node, and no PodDisruptionBudget kept a drain from taking both at once. Both objects are now rendered per node group, gated on that group's COMPUTED replica count being >= 2. The distribution is a promise, not an effort: maxSkew 1 on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule, selecting only this group's own pods, so an "mx" and a "full" pod may share a node. The selector comes from the chart (stalwart.groupLabels), never from the operator - a spread constraint whose selector matches no pod is trivially satisfied, distributes nothing and reports nothing. nodeAffinityPolicy: Ignore is set explicitly, and that is the load-bearing detail. Its API default is Honor, under which a narrowing nodeSelector shrinks the set of counted domains until both replicas fit into one: skew 0, both pods co-located, no Pending, no event, and a selector that is correct - so no check can see it. With Ignore the same narrowing gives skew 2 and the second pod is Pending. A render abort was the alternative and would also have forbidden a nodeSelector spanning five nodes, which is legal. A user entry colliding with the chart's own on (topologyKey, whenUnsatisfiable) aborts the render naming both values, because the API rejects the duplicate with a message about a list index and an apply-based path merges the two over patchMergeKey: topologyKey silently, dropping one selector. Minor rather than major, proved with a diff rather than argued: with the shipped defaults (full: 1, mx: 0) and with ci/stalwart/test-values.yaml, helm template renders byte-for-byte what 1.0.1+upv0.16.24 rendered - both diffs empty. New in CI, over every chart on every PR: ci/run-selector-match-test.sh holds every rendered PodDisruptionBudget selector and every topologySpreadConstraints labelSelector against the labels of the pod templates in the same render. Helm itself is the YAML parser (ci/yaml2json), because the runners have neither pyyaml nor yq and a hand-written parser could read nothing and report nothing. podAffinity/podAntiAffinity are out of scope; matchExpressions-only selectors are reported as not checked rather than counted as passing. Two anti-vacuum guards: a chart with an overlay under ci/selector-match/ that renders no selector fails, and a run that checks no selector at all fails. Fixes #96
This commit was merged in pull request #97.
This commit is contained in:
1 parent
1698fc0aeb
commit
6ade74db80
14 files changed
+1125
-8
No files matched your search
@@ -300,6 +300,137 @@ nodes:
|
||||
# Every group needs http, including mx: it is the port all three probes
|
||||
# use. It is not published for mx.
|
||||
exposedPorts: [smtp, submissions, imaps, http]
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Distribute THIS GROUP's pods over distinct nodes (Gitea #96)
|
||||
# -----------------------------------------------------------------------
|
||||
# Renders a topologySpreadConstraints entry on topologyKey
|
||||
# kubernetes.io/hostname with maxSkew: 1 and a labelSelector the CHART
|
||||
# fills in — the two labels it puts on its own pods anyway. You never
|
||||
# write that selector, and that is the point: a spread constraint whose
|
||||
# selector matches no pod is TRIVIALLY SATISFIED. It distributes nothing,
|
||||
# the rollout is green, and nothing anywhere says so.
|
||||
#
|
||||
# ONLY WITHIN THIS GROUP. The selector names the release AND the node
|
||||
# group, so "full" pods are spread against "full" pods and "mx" pods
|
||||
# against "mx" pods. A "full" and an "mx" pod on the same node is
|
||||
# deliberately fine — they are different roles, not redundant copies.
|
||||
#
|
||||
# RENDERED ONLY FROM TWO EFFECTIVE REPLICAS UPWARDS. With one pod any
|
||||
# placement satisfies it, so it would describe nothing; the count used is
|
||||
# the computed one, so scaleDown: true removes it too.
|
||||
#
|
||||
# whenUnsatisfiable IS DoNotSchedule, i.e. THIS IS A PROMISE AND NOT AN
|
||||
# EFFORT — and the price is a pod that stays Pending rather than one that
|
||||
# lands somewhere useless. ScheduleAnyway was the original proposal and was
|
||||
# rejected: with one worker at 97 % requested CPU the scheduler may put both
|
||||
# replicas of a group on the same remaining node, entirely within the rule
|
||||
# and with the redundancy gone. The cases where the promise costs something:
|
||||
#
|
||||
# - TWO OF THREE WORKERS GONE: the second replica stays Pending instead
|
||||
# of joining the survivor — but only WHILE THE Node OBJECTS STILL
|
||||
# EXIST. A NotReady node still counts as an (empty) domain, so the
|
||||
# third replica would raise the skew to 2. Delete the Node objects and
|
||||
# the domains disappear with them and the pod schedules. Same
|
||||
# mechanism, opposite outcome, one step apart.
|
||||
# - A NARROWING nodeSelector/affinity BELOW: see nodeSelector further
|
||||
# down. That combination blocks itself, deliberately and loudly.
|
||||
#
|
||||
# A Pending pod with no scheduling event reads like a different problem
|
||||
# than a distribution rule, so NOTES.txt names this rule whenever it is
|
||||
# rendered.
|
||||
#
|
||||
# THE FIELD THAT CAN BE DROPPED WITHOUT A WORD: the constraint carries
|
||||
# nodeAffinityPolicy: Ignore, and that field is BETA — on by default since
|
||||
# Kubernetes 1.26, graduated to GA with the feature gate
|
||||
# NodeInclusionPolicyInPodTopologySpread in 1.33. On an API server older
|
||||
# than 1.26, or one with the gate switched off, the field is pruned
|
||||
# SILENTLY, and the gap it was set against comes straight back (the
|
||||
# nodeSelector case below). Nothing reports it: the object still looks
|
||||
# correct in `kubectl get sts -o yaml`, minus one line. So 1.26 is the
|
||||
# minimum version this chart can make the promise on at all. NOT MEASURED
|
||||
# against an older server — this is the documented pruning behaviour for a
|
||||
# field behind a disabled gate, not something tried here. Check the
|
||||
# rendered StatefulSet against the live one if the server is near that
|
||||
# boundary.
|
||||
#
|
||||
# WHAT IS *NOT* A CONSEQUENCE OF THAT FIELD, because the two
|
||||
# node-inclusion policies are not a package: the ceiling on replicas
|
||||
# described at nodeSelector below comes from nodeTaintsPolicy, which this
|
||||
# chart leaves at its own default. It was there before nodeAffinityPolicy
|
||||
# was set and is not its price.
|
||||
#
|
||||
# ONE PRECONDITION, AND IT IS NOT IN THIS FILE: this works because the
|
||||
# StatefulSet has NO volumeClaimTemplates. Open measurement 1 on #65 asks
|
||||
# whether anything under /var/lib/stalwart has to survive a restart; if
|
||||
# that answer ever becomes yes, a PVC per group appears, and with a
|
||||
# node-local or topology-bound storage class the pod is pinned to wherever
|
||||
# its volume was first provisioned. Distribution is then decided at
|
||||
# provisioning time rather than by this rule, and DoNotSchedule can keep a
|
||||
# returning pod Pending for good. This rule holds only while the chart
|
||||
# stays PVC-free.
|
||||
#
|
||||
# AND THE COST OF TURNING IT ON BY SCALING UP: going from 1 to 2 replicas
|
||||
# changes the POD TEMPLATE (the constraint appears in it), so the existing
|
||||
# pod is replaced in the same rollout round rather than merely joined by a
|
||||
# second one. Expect a restart of the running node, not just an addition.
|
||||
spreadAcrossNodes: true
|
||||
|
||||
# A PodDisruptionBudget for this group, rendered only while the group's
|
||||
# COMPUTED replica count is >= 2 — the spread constraint's companion: that
|
||||
# one decides the two pods sit on different nodes, this one decides only
|
||||
# one of those nodes may be emptied at a time.
|
||||
#
|
||||
# The condition is not tidiness. minAvailable: 1 with exactly one replica
|
||||
# forbids every voluntary eviction, so `kubectl drain` blocks forever on a
|
||||
# pod nothing may move — and the message names the PDB rather than the
|
||||
# replica count, which sends the reader to the wrong file. Precedent and
|
||||
# full argument: keycloak (README, guideline 11).
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
# minAvailable, not maxUnavailable: the promise is about the service —
|
||||
# at least one pod of this group keeps answering through a drain.
|
||||
minAvailable: 1
|
||||
|
||||
# Generic pass-through into this group's pod spec. Rendered verbatim with
|
||||
# toYaml; the chart reads none of their contents, which is why they are
|
||||
# loosely typed in values.schema.json — Kubernetes owns their schema. An
|
||||
# empty value renders no field at all.
|
||||
#
|
||||
# THE ONE COMBINATION THAT BLOCKS ITSELF, and it does so on purpose:
|
||||
# narrowing the eligible nodes below this group's replica count while
|
||||
# spreadAcrossNodes is on leaves a replica permanently Pending. With
|
||||
# nodeAffinityPolicy: Ignore on the chart's constraint, a nodeSelector
|
||||
# down to one node gives 2,0,0 — skew 2 — rather than the one eligible
|
||||
# domain and skew 0 it would give under the API default of Honor. The
|
||||
# second shape is what we do NOT want: both pods co-located, nothing
|
||||
# Pending, no event, and a selector that is perfectly correct, so no check
|
||||
# in CI can see it either. Loud beats silent; that is the whole trade.
|
||||
#
|
||||
# AND THE CEILING, WHICH IS A RULE AND NOT A NUMBER. The chart leaves
|
||||
# nodeTaintsPolicy at its API default of Ignore, so EVERY node counts as a
|
||||
# domain — control-plane nodes included, and no Stalwart pod ever runs
|
||||
# there. Those domains hold the minimum at 0 permanently, so with
|
||||
# maxSkew: 1 a group can never place more replicas than there are
|
||||
# SCHEDULABLE WORKERS. Add a worker and the ceiling rises with it; remove
|
||||
# one and it falls. Do not write today's count down here — it would be
|
||||
# wrong at the next cluster rebuild and nobody would come back for it.
|
||||
# Raising a group past that ceiling needs spreadAcrossNodes: false, or an
|
||||
# own topologySpreadConstraints entry with ScheduleAnyway.
|
||||
affinity: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
# Appended AFTER the chart's own constraint and really AND-ed with it, so
|
||||
# a second key — topology.kubernetes.io/zone, say — works as expected.
|
||||
#
|
||||
# An entry on kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule
|
||||
# (or omitted, which IS DoNotSchedule) ABORTS THE RENDER naming both this
|
||||
# value and spreadAcrossNodes. Kubernetes keys a constraint on exactly
|
||||
# that pair: `kubectl create` rejects the duplicate with a message about a
|
||||
# list index instead of a value, and an apply-based path merges the two
|
||||
# over patchMergeKey: topologyKey SILENTLY — one labelSelector is dropped
|
||||
# and the result is green. A named abort is better than either.
|
||||
topologySpreadConstraints: []
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
@@ -324,6 +455,25 @@ nodes:
|
||||
# fail-open above), which is the opposite of what this group is for.
|
||||
replicas: 0
|
||||
exposedPorts: [smtp, http]
|
||||
# Same six keys as nodes.full, same defaults, and argued in full there —
|
||||
# including the three things that are easy to get wrong: that
|
||||
# nodeAffinityPolicy can be pruned silently below Kubernetes 1.26, that the
|
||||
# replica ceiling is "as many as there are schedulable workers" rather than
|
||||
# a number, and that the ceiling comes from nodeTaintsPolicy and not from
|
||||
# the field this chart sets.
|
||||
#
|
||||
# Neither object is rendered for this group while replicas is 0, and the
|
||||
# condition is on the computed count: raising it to 2 (after the "mx"
|
||||
# ClusterRole exists — see above) brings both the spread constraint and the
|
||||
# PodDisruptionBudget with it, independently of what "full" does.
|
||||
spreadAcrossNodes: true
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
affinity: {}
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
topologySpreadConstraints: []
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
Reference in new issue
Block a user