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
@@ -23,6 +23,8 @@ are emptyDirs. That is also why there is no fsGroup in the pod context.
|
||||
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
|
||||
{{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }}
|
||||
{{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }}
|
||||
{{- $replicas := int (include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas)) }}
|
||||
{{- $settings := fromYaml (include "stalwart.groupSettings" (dict "group" $group "given" $spec)) }}
|
||||
---
|
||||
{{ if $valkeyHost -}}
|
||||
# In-memory store this release expects: {{ $valkeyHost }}:{{ $root.Values.externalServices.valkey.port }}
|
||||
@@ -36,10 +38,9 @@ metadata:
|
||||
name: {{ $name }}-sfs
|
||||
labels:
|
||||
app: {{ $name }}-sfs
|
||||
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
|
||||
stalwart.jk-dev.de/node-group: {{ $group }}
|
||||
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ include "stalwart.replicas" (dict "scaleDown" $root.Values.scaleDown "replicas" $spec.replicas) }}
|
||||
replicas: {{ $replicas }}
|
||||
serviceName: {{ $name }}-headless
|
||||
selector:
|
||||
matchLabels:
|
||||
@@ -51,8 +52,15 @@ spec:
|
||||
app: {{ $name }}-sfs
|
||||
# Scopes every per-port Service to THIS release, so a second stalwart
|
||||
# release in the same namespace does not get its pods selected.
|
||||
stalwart.jk-dev.de/release: {{ include "stalwart.fullname" $root }}
|
||||
stalwart.jk-dev.de/node-group: {{ $group }}
|
||||
{{- /* These same two labels are also what the group's
|
||||
PodDisruptionBudget and the chart's topology spread constraint
|
||||
select on, which is why they come from ONE definition: a
|
||||
selector matching no pod fails silently in both of those objects
|
||||
- see the helper's comment. Kept as a rendered YAML comment above
|
||||
rather than folded in here so that the manifest this chart
|
||||
produced before Gitea #96 and the one it produces now are
|
||||
byte-identical for the default values. */}}
|
||||
{{- include "stalwart.groupLabels" (dict "root" $root "group" $group) | nindent 8 }}
|
||||
{{- /* One label per port this group may be reached on. The per-port
|
||||
Services select on these: NodePorts are cluster-globally
|
||||
unique, so "full" and "mx" can only accept SMTP behind the SAME
|
||||
@@ -81,6 +89,36 @@ spec:
|
||||
automountServiceAccountToken: false
|
||||
securityContext:
|
||||
{{- include "stalwart.podSecurityContext" $root.Values.securityContext | nindent 8 }}
|
||||
{{- /* SCHEDULING. The first three are pass-through: the chart never reads
|
||||
their contents, Kubernetes owns their schema, and an empty block
|
||||
renders nothing at all rather than an empty field.
|
||||
|
||||
MIND THE COMBINATION WITH THE SPREAD CONSTRAINT BELOW. A
|
||||
nodeSelector or an affinity that narrows the eligible nodes below
|
||||
the group's replica count turns the constraint into a permanent
|
||||
Pending - which is the INTENDED, loud outcome (see values.yaml),
|
||||
but it reads like a different problem if you have not been told. */}}
|
||||
{{- with $settings.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $settings.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with $settings.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- /* The chart's own per-node constraint plus any the user appended, or
|
||||
nothing while the group is below two effective replicas. The helper
|
||||
aborts the render on a collision instead of letting two entries
|
||||
with the same (topologyKey, whenUnsatisfiable) be merged silently. */}}
|
||||
{{- $spread := include "stalwart.spreadConstraints" (dict "root" $root "group" $group "settings" $settings "replicas" $replicas) }}
|
||||
{{- if $spread }}
|
||||
topologySpreadConstraints:
|
||||
{{- $spread | nindent 8 }}
|
||||
{{- end }}
|
||||
initContainers:
|
||||
# config.json is copied out of the ConfigMap into the emptyDir rather
|
||||
# than mounted from it, because STALWART WRITES THIS FILE ITSELF: on
|
||||
|
||||
Reference in new issue
Block a user