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
@@ -354,6 +354,131 @@ accept SMTP behind the SAME Service object.
|
||||
{{- printf "stalwart.jk-dev.de/port-%s" . -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The labels that identify ONE node group's pods - and the ONLY definition of
|
||||
them. Input: dict "root" <the root context> "group" <name>. Output: YAML.
|
||||
|
||||
Three places consume this: the pod template's labels, the labelSelector of the
|
||||
chart's own topology spread constraint, and the selector of the group's
|
||||
PodDisruptionBudget. All three have to agree, and the failure of a disagreement
|
||||
is silent in both of the latter two:
|
||||
|
||||
- a spread constraint whose labelSelector matches no pod is TRIVIALLY
|
||||
SATISFIED. It distributes nothing, the rollout is green, and nothing is
|
||||
logged. With whenUnsatisfiable: DoNotSchedule that is worse rather than
|
||||
better, because the object then looks like a guarantee.
|
||||
- a PDB whose selector matches no pod reports ALLOWED DISRUPTIONS equal to
|
||||
its own minAvailable arithmetic over zero pods and never blocks an
|
||||
eviction, so a drain takes every replica at once - the thing the object was
|
||||
added to prevent.
|
||||
|
||||
One definition is the precedent set by stalwart.env.dbPassword in this file,
|
||||
for exactly the same reason: a mismatch between two copies is not a render
|
||||
error. ci/run-selector-match-test.sh is the second half of that - it holds
|
||||
every rendered selector in this repository against the labels of the pod
|
||||
templates in the same render, because one definition only helps while nobody
|
||||
writes a second one.
|
||||
*/}}
|
||||
{{- define "stalwart.groupLabels" -}}
|
||||
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" .root }}
|
||||
stalwart.jk-dev.de/node-group: {{ .group }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The effective scheduling and disruption settings of one node group (#99 shape).
|
||||
Input: dict "group" <name> "given" <the group's spec from stalwart.nodes>.
|
||||
|
||||
Only the two switches the chart interprets carry defaults. affinity,
|
||||
nodeSelector, tolerations and topologySpreadConstraints are pass-through: the
|
||||
chart never reads their contents, Kubernetes owns their schema, and a default
|
||||
would be a value this chart cannot reason about. They therefore arrive here
|
||||
unchanged, including absent.
|
||||
*/}}
|
||||
{{- define "stalwart.groupSettings" -}}
|
||||
{{- $defaults := dict
|
||||
"spreadAcrossNodes" true
|
||||
"podDisruptionBudget" (dict "enabled" true "minAvailable" 1) -}}
|
||||
{{- include "stalwart.defaultedDict" (dict "defaults" $defaults "given" .given "path" (printf "nodes.%s" .group)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The topologySpreadConstraints of one node group: the chart's own entry followed
|
||||
by the user's, or nothing at all.
|
||||
Input: dict "root" <root context> "group" <name> "settings" <stalwart.groupSettings>
|
||||
"replicas" <the COMPUTED replica count, as an int>.
|
||||
|
||||
WHY THE CHART-OWNED ENTRY NEEDS >= 2 REPLICAS. With one pod the constraint is
|
||||
satisfied by any placement, so it describes nothing; and the group that ships
|
||||
with replicas: 0 ("mx") would carry an object about pods that do not exist. The
|
||||
condition is on the COMPUTED count, so scaleDown: true removes it as well. The
|
||||
side effect is the one that made this a minor rather than a major change: a
|
||||
default install (full: 1, mx: 0) renders byte-for-byte what it rendered before.
|
||||
|
||||
WHY nodeAffinityPolicy: Ignore IS SET EXPLICITLY, AND IT IS NOT A STYLE CHOICE.
|
||||
The two node-inclusion policies have OPPOSITE defaults, straight from the API
|
||||
definition: nodeAffinityPolicy defaults to Honor, nodeTaintsPolicy to Ignore.
|
||||
Under Honor, only nodes matching the pod's nodeAffinity/nodeSelector count as
|
||||
domains - so a nodeSelector narrowing a 2-replica group onto ONE node leaves one
|
||||
eligible domain, both pods land in it, the skew is 0, and the constraint is
|
||||
satisfied. Both replicas are co-located, nothing is Pending, no event is
|
||||
emitted, and the selector in the object is correct - so the CI selector check
|
||||
cannot see it either. With Ignore every node counts, the same narrowing gives
|
||||
2,0,0, the skew is 2, and the second pod is Pending: the silent half becomes the
|
||||
loud one. A render abort was the alternative and was rejected, because it would
|
||||
also have forbidden a nodeSelector spanning five nodes, which is legal and
|
||||
works.
|
||||
|
||||
Note what this field does NOT add: the ceiling on replicas per group comes from
|
||||
nodeTaintsPolicy being Ignore, and it existed before this value did. values.yaml
|
||||
argues the two separately for that reason.
|
||||
|
||||
WHY A COLLISION ABORTS. Kubernetes keys a spread constraint on
|
||||
(topologyKey, whenUnsatisfiable), whose API default is DoNotSchedule - so a
|
||||
user entry on kubernetes.io/hostname without an explicit whenUnsatisfiable
|
||||
collides with the chart's own. Both ways that collision resolves are worse than
|
||||
an abort: `kubectl create` fails validation with a message naming a LIST INDEX
|
||||
instead of a value, and an apply-based path merges the two entries over
|
||||
patchMergeKey: topologyKey - SILENTLY, dropping one labelSelector and leaving a
|
||||
green result. The abort names both values instead, and it only fires while the
|
||||
chart's own entry is actually rendered, since without it there is no duplicate.
|
||||
*/}}
|
||||
{{- define "stalwart.spreadConstraints" -}}
|
||||
{{- $root := .root -}}
|
||||
{{- $group := .group -}}
|
||||
{{- $settings := .settings -}}
|
||||
{{- $own := list -}}
|
||||
{{- if and $settings.spreadAcrossNodes (ge (int .replicas) 2) -}}
|
||||
{{- $own = list (dict
|
||||
"maxSkew" 1
|
||||
"topologyKey" "kubernetes.io/hostname"
|
||||
"whenUnsatisfiable" "DoNotSchedule"
|
||||
"nodeAffinityPolicy" "Ignore"
|
||||
"labelSelector" (dict "matchLabels" (fromYaml (include "stalwart.groupLabels" (dict "root" $root "group" $group))))) -}}
|
||||
{{- end -}}
|
||||
{{- $given := $settings.topologySpreadConstraints -}}
|
||||
{{- if kindIs "invalid" $given -}}
|
||||
{{- $given = list -}}
|
||||
{{- end -}}
|
||||
{{- if not (kindIs "slice" $given) -}}
|
||||
{{- fail (printf "nodes.%s.topologySpreadConstraints must be a list of topology spread constraints or null, got %s (%v)" $group (kindOf $given) $given) -}}
|
||||
{{- end -}}
|
||||
{{- if $own -}}
|
||||
{{- range $i, $constraint := $given -}}
|
||||
{{- if not (kindIs "map" $constraint) -}}
|
||||
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] must be a mapping, got %s (%v)" $group $i (kindOf $constraint) $constraint) -}}
|
||||
{{- end -}}
|
||||
{{- $when := $constraint.whenUnsatisfiable | default "DoNotSchedule" -}}
|
||||
{{- if and (eq ($constraint.topologyKey | default "") "kubernetes.io/hostname") (eq $when "DoNotSchedule") -}}
|
||||
{{- fail (printf "nodes.%s.topologySpreadConstraints[%d] collides with the constraint nodes.%s.spreadAcrossNodes renders: both are keyed on topologyKey kubernetes.io/hostname with whenUnsatisfiable DoNotSchedule (the API default, so an omitted whenUnsatisfiable counts), and Kubernetes keys a spread constraint on exactly that pair. Two entries sharing it are rejected by `kubectl create` with a message naming a list index rather than a value, and on an apply-based path they are MERGED over patchMergeKey: topologyKey - silently, so one labelSelector is dropped and the result is green. Pick one: set nodes.%s.spreadAcrossNodes: false and keep your entry (it then owns the per-node distribution of this group, and the chart's nodeAffinityPolicy: Ignore is yours to repeat), or drop your entry and keep the chart's. Entries on any OTHER topologyKey - topology.kubernetes.io/zone, for instance - are appended and really do apply as an AND" $group $i $group $group) -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- $all := concat $own $given -}}
|
||||
{{- if $all -}}
|
||||
{{- toYaml $all -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
The address of the in-memory store, and the render abort that goes with it.
|
||||
Input: the root context. Returns the host, or "" when none is needed.
|
||||
|
||||
Reference in new issue
Block a user