Der stalwart-Chart kann eine Mehr-Knoten-Installation nicht hochverfügbar machen: Es fehlt jede Möglichkeit, die Pods einer Knotengruppe über Knoten zu verteilen, und ein PodDisruptionBudget gibt es auch nicht. Beides zusammen entscheidet darüber, ob eine zweite Replica etwas gegen den Ausfall bringt, für den sie hinzugefügt wurde.
Anlass
In jk-cluster laufen seit dem 05.10.2026 zwei Knotengruppen (full und mx, je eine Replica, #250). Der nächste Schritt wäre je zwei. Die Lage dort macht das Problem konkret:
jk-worker-1 CPU 97% angefordert
jk-worker-2 CPU 73%
jk-worker-3 CPU 55%
Drei Arbeitsknoten, vier Stalwart-Pods — ohne Verteilungsregel landen beide Replicas einer Gruppe möglicherweise auf demselben Knoten, und die Redundanz ist nominell.
Gemessener Stand (Chart 1.0.1+upv0.16.24)
Kein affinity, kein topologySpreadConstraints, kein nodeSelector, kein tolerations — weder in der values.yaml noch in einem Template.
Kein PDB-Template. Neun Templates, keines davon.
Das StatefulSet setzt weder updateStrategy noch podManagementPolicy. Das ist in Ordnung und soll so bleiben: Die Kubernetes-Vorgaben sind RollingUpdate mit partition: 0 und OrderedReady, ein Update läuft also ohnehin strikt ein Pod zur Zeit und wartet auf Ready. Hier ist nichts hinzuzufügen — der Hinweis steht nur da, damit niemand redundante Schalter einbaut.
Teil 1: Verteilung über Knoten
Vorschlag, zwei Ebenen:
Ein bequemer Schalter je Gruppe, bei dem der Chart den Selektor selbst füllt:
nodes:full:spreadAcrossNodes:true
Gerendert als topologySpreadConstraints auf topologyKey: kubernetes.io/hostname, maxSkew: 1, mit einem labelSelector aus den Labels, die der Chart ohnehin setzt (stalwart.jk-dev.de/release und stalwart.jk-dev.de/node-group).
Generische Durchreichung je Gruppe für alles andere — affinity, nodeSelector, tolerations, topologySpreadConstraints —, mit toYaml | nindent.
Warum beides und nicht nur die Durchreichung: Ein topologySpreadConstraints, dessen labelSelector die Pods nicht trifft, scheitert still. Die Bedingung passt dann auf null Pods, ist damit trivial erfüllt, und verteilt wird nichts — bei grünem Rollout und ohne eine einzige Meldung. Der Chart kennt seine Labels, der Benutzer muss sie nicht abschreiben.
Und whenUnsatisfiable gehört begründet statt vorgegeben. Mit DoNotSchedule bleibt eine zweite Replica bei knapper Kapazität dauerhaft Pending — und ein Pending-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel (dieselbe Klasse Irrweg wie beim inotify-Fall der Actions-Runner). Vorschlag: ScheduleAnyway als Vorgabe, mit einem Kommentar, der sagt, was das kostet — Verteilung nach bestem Bemühen, keine Zusage.
Teil 2: PodDisruptionBudget
Hier gibt es einen Präzedenzfall im selben Repository, und der sollte übernommen werden: keycloak.pdb.yaml mit keycloak.podDisruptionBudget.{enabled, minAvailable}. Dessen Kommentar nennt auch den Grund, warum die Bedingung wichtiger ist als das Objekt:
minAvailable: 1 with exactly ONE replica forbids every voluntary eviction, so kubectl drain blocks forever on a pod nothing is allowed to move. The message then names the PDB and not the replica count, which sends the reader to the wrong file.
Für Stalwart kommt eine Besonderheit dazu: Es gibt Knotengruppen. Also ein PDB je Gruppe mit der Replica-Zahl dieser Gruppe, und die Bedingung „erst ab zwei" gilt entsprechend pro Gruppe — bei full: 2 und mx: 1 entsteht genau ein PDB. Unter scaleDown: true keines, aus demselben Grund wie bei Keycloak.
minAvailable statt maxUnavailable, analog zu Keycloak: Die Zusage betrifft den Dienst, nicht die Störung.
Warum das nicht im Konsumenten-Repository gelöst werden kann
Das PDB allein ginge als eigenes Manifest-Bundle in cluster-config. Die Verteilung nicht: Sie steht im Pod-Template des StatefulSets, und das von außen zu patchen wäre genau der Drift, den correctDrift: unset bis zur nächsten Chart-Änderung festschreibt. Außerdem müsste der Selektor des PDB die Chart-Labels nachbauen und würde bei einer Umbenennung still falsch.
Abnahme
helm template mit nodes.full.replicas: 2 und spreadAcrossNodes: true rendert eine topologySpreadConstraints mit einem Selektor, der auf die Labels des eigenen Pod-Templates passt (am gerenderten Ergebnis abgleichen, nicht nach Augenmaß).
Bei replicas: 1 wird kein PDB gerendert; bei 2 genau eines je Gruppe; bei scaleDown: true keines.
Die generische Durchreichung erlaubt zusätzlich ein affinity ohne den Schalter.
README und NOTES.txt sagen, welcher der Fälle eingetreten ist — bei Keycloak ist das schon so gelöst.
Dringlichkeit
Mittel. Es blockiert nichts, was heute läuft: Mit je einer Replica ist weder ein PDB noch eine Verteilungsregel sinnvoll. Es blockiert aber den nächsten Schritt — und zwar auf die unangenehme Art, weil eine zweite Replica ohne Verteilung aussieht wie Redundanz und keine ist.
Der `stalwart`-Chart kann eine Mehr-Knoten-Installation nicht hochverfügbar machen: Es fehlt jede Möglichkeit, die Pods einer Knotengruppe über Knoten zu **verteilen**, und ein **PodDisruptionBudget** gibt es auch nicht. Beides zusammen entscheidet darüber, ob eine zweite Replica etwas gegen den Ausfall bringt, für den sie hinzugefügt wurde.
## Anlass
In `jk-cluster` laufen seit dem 05.10.2026 zwei Knotengruppen (`full` und `mx`, je eine Replica, #250). Der nächste Schritt wäre je **zwei**. Die Lage dort macht das Problem konkret:
```
jk-worker-1 CPU 97% angefordert
jk-worker-2 CPU 73%
jk-worker-3 CPU 55%
```
Drei Arbeitsknoten, vier Stalwart-Pods — ohne Verteilungsregel landen beide Replicas einer Gruppe möglicherweise auf **demselben** Knoten, und die Redundanz ist nominell.
## Gemessener Stand (Chart `1.0.1+upv0.16.24`)
- **Kein `affinity`, kein `topologySpreadConstraints`, kein `nodeSelector`, kein `tolerations`** — weder in der `values.yaml` noch in einem Template.
- **Kein PDB-Template.** Neun Templates, keines davon.
- Das StatefulSet setzt **weder `updateStrategy` noch `podManagementPolicy`**. Das ist in Ordnung und soll so bleiben: Die Kubernetes-Vorgaben sind `RollingUpdate` mit `partition: 0` und `OrderedReady`, ein Update läuft also ohnehin strikt ein Pod zur Zeit und wartet auf `Ready`. **Hier ist nichts hinzuzufügen** — der Hinweis steht nur da, damit niemand redundante Schalter einbaut.
## Teil 1: Verteilung über Knoten
Vorschlag, zwei Ebenen:
1. **Ein bequemer Schalter je Gruppe**, bei dem der Chart den Selektor selbst füllt:
```yaml
nodes:
full:
spreadAcrossNodes: true
```
Gerendert als `topologySpreadConstraints` auf `topologyKey: kubernetes.io/hostname`, `maxSkew: 1`, mit einem `labelSelector` aus den Labels, die der Chart ohnehin setzt (`stalwart.jk-dev.de/release` und `stalwart.jk-dev.de/node-group`).
2. **Generische Durchreichung je Gruppe** für alles andere — `affinity`, `nodeSelector`, `tolerations`, `topologySpreadConstraints` —, mit `toYaml | nindent`.
**Warum beides und nicht nur die Durchreichung:** Ein `topologySpreadConstraints`, dessen `labelSelector` die Pods nicht trifft, **scheitert still**. Die Bedingung passt dann auf null Pods, ist damit trivial erfüllt, und verteilt wird nichts — bei grünem Rollout und ohne eine einzige Meldung. Der Chart kennt seine Labels, der Benutzer muss sie nicht abschreiben.
**Und `whenUnsatisfiable` gehört begründet statt vorgegeben.** Mit `DoNotSchedule` bleibt eine zweite Replica bei knapper Kapazität dauerhaft `Pending` — und ein `Pending`-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel (dieselbe Klasse Irrweg wie beim inotify-Fall der Actions-Runner). Vorschlag: **`ScheduleAnyway` als Vorgabe**, mit einem Kommentar, der sagt, was das kostet — Verteilung nach bestem Bemühen, keine Zusage.
## Teil 2: PodDisruptionBudget
Hier gibt es einen Präzedenzfall im selben Repository, und der sollte übernommen werden: `keycloak.pdb.yaml` mit `keycloak.podDisruptionBudget.{enabled, minAvailable}`. Dessen Kommentar nennt auch den Grund, warum die Bedingung wichtiger ist als das Objekt:
> `minAvailable: 1` with exactly ONE replica forbids every voluntary eviction, so `kubectl drain` blocks forever on a pod nothing is allowed to move. The message then names the PDB and not the replica count, which sends the reader to the wrong file.
Für Stalwart kommt eine Besonderheit dazu: **Es gibt Knotengruppen.** Also ein PDB **je Gruppe** mit der Replica-Zahl *dieser* Gruppe, und die Bedingung „erst ab zwei" gilt entsprechend pro Gruppe — bei `full: 2` und `mx: 1` entsteht genau ein PDB. Unter `scaleDown: true` keines, aus demselben Grund wie bei Keycloak.
`minAvailable` statt `maxUnavailable`, analog zu Keycloak: Die Zusage betrifft den Dienst, nicht die Störung.
## Warum das nicht im Konsumenten-Repository gelöst werden kann
Das PDB allein ginge als eigenes Manifest-Bundle in `cluster-config`. Die **Verteilung** nicht: Sie steht im Pod-Template des StatefulSets, und das von außen zu patchen wäre genau der Drift, den `correctDrift: unset` bis zur nächsten Chart-Änderung festschreibt. Außerdem müsste der Selektor des PDB die Chart-Labels nachbauen und würde bei einer Umbenennung still falsch.
## Abnahme
- `helm template` mit `nodes.full.replicas: 2` und `spreadAcrossNodes: true` rendert eine `topologySpreadConstraints` mit einem Selektor, der auf die Labels des eigenen Pod-Templates passt (am gerenderten Ergebnis abgleichen, nicht nach Augenmaß).
- Bei `replicas: 1` wird **kein** PDB gerendert; bei `2` genau eines je Gruppe; bei `scaleDown: true` keines.
- Die generische Durchreichung erlaubt zusätzlich ein `affinity` ohne den Schalter.
- README und NOTES.txt sagen, welcher der Fälle eingetreten ist — bei Keycloak ist das schon so gelöst.
## Dringlichkeit
**Mittel.** Es blockiert nichts, was heute läuft: Mit je einer Replica ist weder ein PDB noch eine Verteilungsregel sinnvoll. Es blockiert aber den nächsten Schritt — und zwar auf die unangenehme Art, weil eine zweite Replica ohne Verteilung *aussieht* wie Redundanz und keine ist.
Ergänzung: Entscheidungen und vier Punkte aus der Durchsicht
Die gemessenen Aussagen des Issues sind nachgeprüft und stimmen alle: kein affinity, kein topologySpreadConstraints, kein nodeSelector, keine tolerations — nirgends in stalwart/; neun Templates, kein PDB; weder updateStrategy noch podManagementPolicy gesetzt. Und entscheidend für den Vorschlag: die Labels stalwart.jk-dev.de/release und stalwart.jk-dev.de/node-group stehen im Pod-Template (Zeilen 54–55), nicht nur in der StatefulSet-Metadata. Ein labelSelector trifft Pods, der Entwurf funktioniert also.
Entscheidung 1: whenUnsatisfiable: DoNotSchedule
Der Issue-Vorschlag war ScheduleAnyway. Der Betreuer hat die Anforderung als Zusage formuliert — full-Pods laufen auf verschiedenen Knoten, mx-Pods auf verschiedenen Knoten —, und ScheduleAnyway gibt keine Zusage: bei 97 % angeforderter CPU auf jk-worker-1 kann der Scheduler beide full-Replicas auf jk-worker-3 legen, regelkonform und wirkungslos.
Also DoNotSchedule. Auf dem Zielcluster ist das erfüllbar: drei Arbeitsknoten, je zwei Replicas pro Gruppe, maxSkew: 1 geht als 1+1 auf.
Der Preis gehört an den Wert und in NOTES.txt, und das Issue benennt ihn selbst richtig: lässt sich eine Replica nicht platzieren, bleibt sie dauerhaft Pending — und ein Pending-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel. Konkret tritt das ein, wenn
zwei der drei Arbeitsknoten ausfallen (die zweite Replica läuft dann nicht beim Überlebenden mit), oder
jemand über die generische Durchreichung ein nodeSelector oder tolerations setzt, die weniger Knoten zulassen als die Gruppe Replicas hat.
Der zweite Fall ist der wahrscheinlichere und gehört ausdrücklich dazu: DoNotSchedule plus eine Knoteneinschränkung ist eine Kombination, die sich selbst blockiert.
Entscheidung 2: Verteilung nur innerhalb einer Gruppe
Bestätigt wie im Issue entworfen — labelSelector auf Release und Knotengruppe. Ein mx und ein full auf demselben Knoten ist ausdrücklich in Ordnung. Keine zusätzliche Bedingung, die nur auf release selektiert und damit alle Stalwart-Pods gegeneinander verteilt; die Frage war gestellt und ist mit nein beantwortet.
Punkt 3: Teil 1 hängt an einem offenen Messpunkt von #65
Der Grund, warum die Verteilung überhaupt wirken kann, ist dass die StatefulSet keine volumeClaimTemplates hat — ein Pod ist nicht durch ein topologie-gebundenes Volume an einen Knoten genagelt.
Offener Messpunkt 1 auf #65 lautet: muss unter /var/lib/stalwart etwas einen Neustart überleben? Wenn ja, braucht es doch eine PVC je Knotengruppe. Fällt diese Antwort auf ja, entsteht eine PVC — und mit einer knoten-lokalen oder topologie-gebundenen Storage-Klasse wird der Pod dorthin gebunden, wo sein Volume zuerst angelegt wurde. Die Verteilung entscheidet sich dann beim ersten Provisionieren, nicht mehr über die Regel, und DoNotSchedule kann einen wiederkehrenden Pod sogar dauerhaft Pending lassen, wenn sein Volume auf einem Knoten liegt, der die Skew-Bedingung verletzt.
Keine Blockade für dieses Issue, aber es gehört in die Chart-Dokumentation: diese Verteilungsregel setzt voraus, dass der Chart PVC-frei bleibt.
Punkt 4: die Selektor-Prüfung soll eine CI-Zusicherung werden
Das Abnahmekriterium sagt richtig „am gerenderten Ergebnis abgleichen, nicht nach Augenmaß". Der Fehlerfall kehrt aber zurück, sobald jemand ein Label umbenennt — und er kehrt still zurück: der Selektor trifft null Pods, die Bedingung ist trivial erfüllt, verteilt wird nichts, alles grün. Bei DoNotSchedule ist das noch unangenehmer, weil die Regel dann scheinbar eine Zusage trägt.
Vorbild im Repo: ci/run-quantity-render-test.sh ist eine Zusicherung auf Render-Ebene. Dieselbe Form hier — den labelSelector aus der gerenderten Spread-Bedingung und dem PDB extrahieren und gegen die Labels des gerenderten Pod-Templates halten — macht aus einer einmaligen Prüfung eine dauerhafte. Das halte ich für die wertvollste Ergänzung dieses Durchgangs.
Nebenbefund, außerhalb dieses Issues
Beim Nachprüfen fiel zweierlei auf, das die Objektwahl des Charts betrifft und hier nur festgehalten wird, weil es #96 nicht berührt — topologySpreadConstraints und PDB wirken auf StatefulSet und Deployment identisch:
Der Headless-Service je Gruppe hat keinen Verbraucher. Seine einzige Referenz im ganzen Chart ist serviceName: an der StatefulSet; niemand löst <pod>.<gruppe>-headless auf, weil die Koordination über Valkey-Pub/Sub läuft und nicht über DNS. Er existiert für einen Zweck, den nichts in Anspruch nimmt.
Der aufgeschriebene Grund für die StatefulSet trägt weniger weit als er klingt. Das Template sagt, nur eine StatefulSet gebe einem Pod einen stabilen Hostnamen — das ist wahr, belegt aber nur, dass sie Stabilität liefert, nicht dass Stalwart sie braucht. keycloak in diesem Repo ist derselbe Fall (zustandslos, externe Datenbank, geclusterter eingebetteter Cache, Knotenname je Pod) und holt ihn genauso aus fieldRef: metadata.name — in einem Deployment. Dort genügt, dass der Name eindeutig ist; stabil muss er nicht sein.
Ob Stalwarts Node-ID-Lease einen wechselnden Hostnamen verträgt, ist ungemessen. Leases laufen ab, und Rollen werden über STALWART_ROLE zugewiesen statt über die Knoten-Identität — eine stehengebliebene Lease verdoppelt also keine Aufgabe. Das klingt tragbar, ist aber nicht belegt, und ein Wechsel wäre ein Major-Bump. Eigenes Issue, falls es verfolgt werden soll.
# Ergänzung: Entscheidungen und vier Punkte aus der Durchsicht
Die gemessenen Aussagen des Issues sind nachgeprüft und stimmen alle: kein `affinity`, kein `topologySpreadConstraints`, kein `nodeSelector`, keine `tolerations` — nirgends in `stalwart/`; neun Templates, kein PDB; weder `updateStrategy` noch `podManagementPolicy` gesetzt. Und entscheidend für den Vorschlag: die Labels `stalwart.jk-dev.de/release` und `stalwart.jk-dev.de/node-group` stehen **im Pod-Template** (Zeilen 54–55), nicht nur in der StatefulSet-Metadata. Ein `labelSelector` trifft Pods, der Entwurf funktioniert also.
## Entscheidung 1: `whenUnsatisfiable: DoNotSchedule`
Der Issue-Vorschlag war `ScheduleAnyway`. Der Betreuer hat die Anforderung als **Zusage** formuliert — `full`-Pods laufen auf verschiedenen Knoten, `mx`-Pods auf verschiedenen Knoten —, und `ScheduleAnyway` gibt keine Zusage: bei 97 % angeforderter CPU auf `jk-worker-1` kann der Scheduler beide `full`-Replicas auf `jk-worker-3` legen, regelkonform und wirkungslos.
**Also `DoNotSchedule`.** Auf dem Zielcluster ist das erfüllbar: drei Arbeitsknoten, je zwei Replicas pro Gruppe, `maxSkew: 1` geht als 1+1 auf.
**Der Preis gehört an den Wert und in `NOTES.txt`**, und das Issue benennt ihn selbst richtig: lässt sich eine Replica nicht platzieren, bleibt sie **dauerhaft `Pending`** — und ein `Pending`-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel. Konkret tritt das ein, wenn
- zwei der drei Arbeitsknoten ausfallen (die zweite Replica läuft dann **nicht** beim Überlebenden mit), oder
- jemand über die generische Durchreichung ein `nodeSelector` oder `tolerations` setzt, die weniger Knoten zulassen als die Gruppe Replicas hat.
Der zweite Fall ist der wahrscheinlichere und gehört ausdrücklich dazu: `DoNotSchedule` plus eine Knoteneinschränkung ist eine Kombination, die sich selbst blockiert.
## Entscheidung 2: Verteilung nur innerhalb einer Gruppe
Bestätigt wie im Issue entworfen — `labelSelector` auf **Release und Knotengruppe**. Ein `mx` und ein `full` auf demselben Knoten ist ausdrücklich in Ordnung. **Keine** zusätzliche Bedingung, die nur auf `release` selektiert und damit alle Stalwart-Pods gegeneinander verteilt; die Frage war gestellt und ist mit nein beantwortet.
## Punkt 3: Teil 1 hängt an einem offenen Messpunkt von #65
Der Grund, warum die Verteilung überhaupt wirken *kann*, ist dass die StatefulSet **keine `volumeClaimTemplates`** hat — ein Pod ist nicht durch ein topologie-gebundenes Volume an einen Knoten genagelt.
Offener Messpunkt 1 auf #65 lautet: *muss unter `/var/lib/stalwart` etwas einen Neustart überleben? Wenn ja, braucht es doch eine PVC je Knotengruppe.* Fällt diese Antwort auf ja, entsteht eine PVC — und mit einer knoten-lokalen oder topologie-gebundenen Storage-Klasse wird der Pod dorthin gebunden, wo sein Volume zuerst angelegt wurde. Die Verteilung entscheidet sich dann beim ersten Provisionieren, nicht mehr über die Regel, und `DoNotSchedule` kann einen wiederkehrenden Pod sogar dauerhaft `Pending` lassen, wenn sein Volume auf einem Knoten liegt, der die Skew-Bedingung verletzt.
**Keine Blockade für dieses Issue**, aber es gehört in die Chart-Dokumentation: diese Verteilungsregel setzt voraus, dass der Chart PVC-frei bleibt.
## Punkt 4: die Selektor-Prüfung soll eine CI-Zusicherung werden
Das Abnahmekriterium sagt richtig „am gerenderten Ergebnis abgleichen, nicht nach Augenmaß". Der Fehlerfall kehrt aber zurück, sobald jemand ein Label umbenennt — und er kehrt **still** zurück: der Selektor trifft null Pods, die Bedingung ist trivial erfüllt, verteilt wird nichts, alles grün. Bei `DoNotSchedule` ist das noch unangenehmer, weil die Regel dann *scheinbar* eine Zusage trägt.
Vorbild im Repo: `ci/run-quantity-render-test.sh` ist eine Zusicherung auf Render-Ebene. Dieselbe Form hier — den `labelSelector` aus der gerenderten Spread-Bedingung und dem PDB extrahieren und gegen die Labels des gerenderten Pod-Templates halten — macht aus einer einmaligen Prüfung eine dauerhafte. **Das halte ich für die wertvollste Ergänzung dieses Durchgangs.**
## Nebenbefund, außerhalb dieses Issues
Beim Nachprüfen fiel zweierlei auf, das die **Objektwahl** des Charts betrifft und hier nur festgehalten wird, weil es #96 nicht berührt — `topologySpreadConstraints` und PDB wirken auf StatefulSet und Deployment identisch:
1. **Der Headless-Service je Gruppe hat keinen Verbraucher.** Seine einzige Referenz im ganzen Chart ist `serviceName:` an der StatefulSet; niemand löst `<pod>.<gruppe>-headless` auf, weil die Koordination über Valkey-Pub/Sub läuft und nicht über DNS. Er existiert für einen Zweck, den nichts in Anspruch nimmt.
2. **Der aufgeschriebene Grund für die StatefulSet trägt weniger weit als er klingt.** Das Template sagt, nur eine StatefulSet gebe einem Pod einen stabilen Hostnamen — das ist wahr, belegt aber nur, dass sie Stabilität *liefert*, nicht dass Stalwart sie *braucht*. `keycloak` in diesem Repo ist derselbe Fall (zustandslos, externe Datenbank, geclusterter eingebetteter Cache, Knotenname je Pod) und holt ihn genauso aus `fieldRef: metadata.name` — **in einem Deployment**. Dort genügt, dass der Name eindeutig ist; stabil muss er nicht sein.
Ob Stalwarts Node-ID-**Lease** einen wechselnden Hostnamen verträgt, ist **ungemessen**. Leases laufen ab, und Rollen werden über `STALWART_ROLE` zugewiesen statt über die Knoten-Identität — eine stehengebliebene Lease verdoppelt also keine Aufgabe. Das klingt tragbar, ist aber nicht belegt, und ein Wechsel wäre ein Major-Bump. Eigenes Issue, falls es verfolgt werden soll.
Korrektur meiner Ergänzung, und die offenen Punkte entschieden
Der zweite Preis-Fall meiner Ergänzung war falsch, und zwar in die unangenehme Richtung. Nachgeprüft an der Kubernetes-API-Definition, beide Vorgaben wörtlich:
// nodeAffinityPolicy …// - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations.// If this value is nil, the behavior is equivalent to the Honor policy.// nodeTaintsPolicy …// - Ignore: node taints are ignored. All nodes are included.// If this value is nil, the behavior is equivalent to the Ignore policy.
Daraus folgt die Asymmetrie, die meine Ergänzung nicht nannte:
Einschränkung
Wirkung mit DoNotSchedule, maxSkew: 1, Vorgaben
nodeSelector lässt nur einen Knoten zu, Gruppe hat 2 Replicas
eine zulässige Domäne, beide Pods darin → Skew 0 → beide laufen, ko-lokiert. Kein Pending, kein Ereignis, keine Zusage.
Untolerierter Taint auf zwei von drei Knoten
die zwei zählen weiter als Domänen mit 0 Pods → Minimum 0 → zweiter Pod ergäbe Skew 2 → Pending
Mein Satz „jemand setzt nodeSelector oder tolerations … → dauerhaft Pending, der zweite Fall ist der wahrscheinlichere" stimmt also nur für tolerations. Für nodeSelector verspricht er ein Symptom, das nicht eintritt, während der Schaden unsichtbar bleibt — genau die stille Klasse, gegen die der ganze Entwurf gebaut ist. Und die CI-Zusicherung fängt es nicht: der Selektor ist dann richtig, die Knotenmenge ist falsch.
Die Behebung ist keine der beiden zur Wahl gestellten Varianten — kein Render-Abbruch und nicht nur Dokumentation, sondern ein Feld an der chart-eigenen Bedingung.
Mit nodeAffinityPolicy: Ignore zählen alle Knoten in die Skew-Rechnung. Ein nodeSelector, der auf einen Knoten verengt, ergibt dann 2,0,0 → Skew 2 → Pending. Die stille Hälfte wird zur lauten.
Durchgerechnet, damit es keine zulässige Konfiguration verbietet (genau der Grund gegen einen Abbruch):
Lage
Verteilung
Skew
Ergebnis
kein nodeSelector, 3 Knoten, 2 Replicas
1,1,0
1
zulässig
nodeSelector über 5 Knoten, 2 Replicas
1,1,0,0,0
1
zulässig
nodeSelector über 1 Knoten, 2 Replicas
2,0,0
2
Pending — gewollt
beide Replicas auf einem Knoten
2,0,0
2
abgelehnt — gewollt
Ein Render-Abbruch hätte Zeile 2 mitverboten. Das Feld trifft genau Zeile 3.
Entscheidung: nodeTaintsPolicy bleibt auf der Vorgabe Ignore
Konsequent zur DoNotSchedule-Wahl: die Zusage lässt sich nicht durch das Wegfallen von Domänen aufweichen. Zwei Folgen, die ausdrücklich dokumentiert gehören — in values.yaml am Schalter, in NOTES.txt und auf der Wiki-Seite:
Eine Obergrenze. Bei drei Workern plus drei Control-Plane-Knoten zählen sechs Domänen. Mit maxSkew: 1 kann eine Gruppe damit nie mehr als drei Replicas planen — die vierte ergäbe 2,1,1,0,0,0, also Skew 2. Für den Zielzustand von zwei je Gruppe ohne Belang, aber es ist eine Grenze, die später überrascht.
Verhalten im Ausfall. Fallen zwei Worker aus, bleibt die zweite Replica Pending statt beim Überlebenden mitzulaufen — solange die Node-Objekte noch existieren (NotReady zählt wegen Ignore als leere Domäne). Werden die Knoten gelöscht, verschwinden die Domänen und der Pod läuft mit. Das ist derselbe Mechanismus, ein Satz Unterschied, und beide Hälften gehören hin.
Entscheidung zu Befund 2: anhängen, bei Kollision abbrechen
Übernommen wie vorgeschlagen. Andere topologyKeys werden angehängt und wirklich mit UND verknüpft. Bei Kollision auf (topologyKey, whenUnsatisfiable) — also kubernetes.io/hostname mit leerem oder DoNotSchedule — Render-Abbruch, der nodes.<gruppe>.spreadAcrossNodes und nodes.<gruppe>.topologySpreadConstraints beide namentlich nennt.
Die Begründung trägt: bei create lehnt die API-Validierung zwei Einträge mit gleichem Schlüsselpaar ab, mit einer Meldung auf einen Listenindex statt auf einen Wert; und auf einem Apply-Pfad verschmelzen sie über patchMergeKey: topologyKeystill zu einem, wobei ein Selektor wegfällt und das Ergebnis grün ist. Beides ist schlechter als ein benannter Abbruch.
Befund 3 übernommen
Der 97-%-Knoten gehört an den dritten Replica, nicht an den zweiten: bei je zwei auf drei Workern ist 1,1,0 → Skew 1 → zulässig, die knappe CPU erzeugt dort noch kein Pending. Erst ab drei Replicas je Gruppe muss jeder Knoten einen annehmen. Die Begründungskette im Issue-Anlass gehört entsprechend verschoben, sonst wird aus der Vorsorge eine falsche Warnung.
Die vorbereiteten Punkte, bestätigt
Vorgaben und Versionssprung:podDisruptionBudget.enabled: true und spreadAcrossNodes: true, beide hinter der berechneten Replica-Zahl >= 2 gesperrt, damit scaleDown mitgefangen ist. Eine einfache Installation (full: 1, mx: 0) rendert damit dieselben Objekte wie vorher → Minor, 1.1.0+upv0.16.24. Zu belegen mit einem helm template-Diff gegen origin/main, nicht mit Gefühl. Die Gegenrechnung gehört in values.yaml: ein Hochskalieren von 1 auf 2 ändert das Pod-Template, der bestehende Pod wird in derselben Rollout-Runde ersetzt.
Die CI-Zusicherung generisch über alle Charts, wie vorgeschlagen — Selektoren aus PDB und Spread-Bedingung gegen die Labels jedes gerenderten Pod-Templates desselben Renders. Dass die Runner kein pyyaml und kein yq haben, ist gemessen; Helm selbst als YAML-Parser zu benutzen statt einen eigenen zu schreiben ist die richtige Antwort, weil ein selbstgeschriebener Parser den Test still vakuum-grün machen könnte. podAffinity/podAntiAffinity bleiben draußen, Selektoren mit ausschließlich matchExpressions werden als „nicht geprüft" gemeldet statt als bestanden gezählt. Und das Overlay, damit die Zusicherung bei stalwart nicht vakuum-grün ist, gehört dazu.
Schlüsselwort: Fixes #96. Die Abnahmeliste dieses Issues fordert ausschließlich Render-Aussagen, und die sind belegbar. Ein Part of gegen Kriterien, die das Issue nicht enthält, würde es auf Dauer offen halten — das ist die Buchführung, die hier schon dreimal zu „Issue heißt neuer Chart und wartet auf eine Browser-Prüfung" geführt hat. Dafür gehört in den PR-Text ausdrücklich, was ungemessen bleibt: die tatsächliche Scheduling-Wirkung, das Verhalten des PDB unter kubectl drain, und die Obergrenze aus nodeTaintsPolicy. Der Install-Test fährt eine Replica je Gruppe und rendert PDB und Spread-Bedingung also nicht — das gehört ebenfalls hinein.
# Korrektur meiner Ergänzung, und die offenen Punkte entschieden
Der zweite Preis-Fall meiner Ergänzung war **falsch**, und zwar in die unangenehme Richtung. Nachgeprüft an der Kubernetes-API-Definition, beide Vorgaben wörtlich:
```go
// nodeAffinityPolicy …
// - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations.
// If this value is nil, the behavior is equivalent to the Honor policy.
// nodeTaintsPolicy …
// - Ignore: node taints are ignored. All nodes are included.
// If this value is nil, the behavior is equivalent to the Ignore policy.
```
Daraus folgt die Asymmetrie, die meine Ergänzung nicht nannte:
| Einschränkung | Wirkung mit `DoNotSchedule`, `maxSkew: 1`, Vorgaben |
| --- | --- |
| `nodeSelector` lässt nur einen Knoten zu, Gruppe hat 2 Replicas | eine zulässige Domäne, beide Pods darin → Skew 0 → **beide laufen, ko-lokiert. Kein `Pending`, kein Ereignis, keine Zusage.** |
| Untolerierter Taint auf zwei von drei Knoten | die zwei zählen weiter als Domänen mit 0 Pods → Minimum 0 → zweiter Pod ergäbe Skew 2 → **`Pending`** |
Mein Satz „jemand setzt `nodeSelector` oder `tolerations` … → dauerhaft `Pending`, der zweite Fall ist der wahrscheinlichere" stimmt also nur für `tolerations`. Für `nodeSelector` verspricht er ein Symptom, das nicht eintritt, während der Schaden unsichtbar bleibt — genau die stille Klasse, gegen die der ganze Entwurf gebaut ist. Und die CI-Zusicherung fängt es nicht: der Selektor ist dann richtig, die Knotenmenge ist falsch.
## Entscheidung: `nodeAffinityPolicy: Ignore`, ausdrücklich gesetzt
Die Behebung ist **keine** der beiden zur Wahl gestellten Varianten — kein Render-Abbruch und nicht nur Dokumentation, sondern **ein Feld** an der chart-eigenen Bedingung.
Mit `nodeAffinityPolicy: Ignore` zählen alle Knoten in die Skew-Rechnung. Ein `nodeSelector`, der auf einen Knoten verengt, ergibt dann `2,0,0` → Skew 2 → **`Pending`**. Die stille Hälfte wird zur lauten.
Durchgerechnet, damit es keine zulässige Konfiguration verbietet (genau der Grund gegen einen Abbruch):
| Lage | Verteilung | Skew | Ergebnis |
| --- | --- | --- | --- |
| kein `nodeSelector`, 3 Knoten, 2 Replicas | 1,1,0 | 1 | zulässig |
| `nodeSelector` über 5 Knoten, 2 Replicas | 1,1,0,0,0 | 1 | zulässig |
| `nodeSelector` über **1** Knoten, 2 Replicas | 2,0,0 | 2 | **`Pending`** — gewollt |
| beide Replicas auf einem Knoten | 2,0,0 | 2 | **abgelehnt** — gewollt |
Ein Render-Abbruch hätte Zeile 2 mitverboten. Das Feld trifft genau Zeile 3.
## Entscheidung: `nodeTaintsPolicy` bleibt auf der Vorgabe `Ignore`
Konsequent zur `DoNotSchedule`-Wahl: die Zusage lässt sich nicht durch das Wegfallen von Domänen aufweichen. **Zwei Folgen, die ausdrücklich dokumentiert gehören** — in `values.yaml` am Schalter, in `NOTES.txt` und auf der Wiki-Seite:
1. **Eine Obergrenze.** Bei drei Workern plus drei Control-Plane-Knoten zählen sechs Domänen. Mit `maxSkew: 1` kann eine Gruppe damit nie mehr als **drei** Replicas planen — die vierte ergäbe `2,1,1,0,0,0`, also Skew 2. Für den Zielzustand von zwei je Gruppe ohne Belang, aber es ist eine Grenze, die später überrascht.
2. **Verhalten im Ausfall.** Fallen zwei Worker aus, bleibt die zweite Replica `Pending` statt beim Überlebenden mitzulaufen — **solange die `Node`-Objekte noch existieren** (`NotReady` zählt wegen `Ignore` als leere Domäne). Werden die Knoten *gelöscht*, verschwinden die Domänen und der Pod läuft mit. Das ist derselbe Mechanismus, ein Satz Unterschied, und beide Hälften gehören hin.
## Entscheidung zu Befund 2: anhängen, bei Kollision abbrechen
Übernommen wie vorgeschlagen. Andere `topologyKey`s werden angehängt und wirklich mit UND verknüpft. Bei Kollision auf `(topologyKey, whenUnsatisfiable)` — also `kubernetes.io/hostname` mit leerem oder `DoNotSchedule` — **Render-Abbruch, der `nodes.<gruppe>.spreadAcrossNodes` und `nodes.<gruppe>.topologySpreadConstraints` beide namentlich nennt.**
Die Begründung trägt: bei `create` lehnt die API-Validierung zwei Einträge mit gleichem Schlüsselpaar ab, mit einer Meldung auf einen Listenindex statt auf einen Wert; und auf einem Apply-Pfad verschmelzen sie über `patchMergeKey: topologyKey` **still** zu einem, wobei ein Selektor wegfällt und das Ergebnis grün ist. Beides ist schlechter als ein benannter Abbruch.
## Befund 3 übernommen
Der 97-%-Knoten gehört an den **dritten** Replica, nicht an den zweiten: bei je zwei auf drei Workern ist `1,1,0` → Skew 1 → zulässig, die knappe CPU erzeugt dort noch kein `Pending`. Erst ab drei Replicas je Gruppe muss jeder Knoten einen annehmen. Die Begründungskette im Issue-Anlass gehört entsprechend verschoben, sonst wird aus der Vorsorge eine falsche Warnung.
## Die vorbereiteten Punkte, bestätigt
**Vorgaben und Versionssprung:** `podDisruptionBudget.enabled: true` und `spreadAcrossNodes: true`, beide hinter der **berechneten** Replica-Zahl `>= 2` gesperrt, damit `scaleDown` mitgefangen ist. Eine einfache Installation (`full: 1`, `mx: 0`) rendert damit dieselben Objekte wie vorher → **Minor**, `1.1.0+upv0.16.24`. Zu belegen mit einem `helm template`-Diff gegen `origin/main`, nicht mit Gefühl. Die Gegenrechnung gehört in `values.yaml`: ein Hochskalieren von 1 auf 2 ändert das Pod-Template, der bestehende Pod wird in derselben Rollout-Runde ersetzt.
**Die CI-Zusicherung generisch über alle Charts**, wie vorgeschlagen — Selektoren aus PDB und Spread-Bedingung gegen die Labels jedes gerenderten Pod-Templates desselben Renders. Dass die Runner kein `pyyaml` und kein `yq` haben, ist gemessen; Helm selbst als YAML-Parser zu benutzen statt einen eigenen zu schreiben ist die richtige Antwort, weil ein selbstgeschriebener Parser den Test still vakuum-grün machen könnte. `podAffinity`/`podAntiAffinity` bleiben draußen, Selektoren mit ausschließlich `matchExpressions` werden als „nicht geprüft" **gemeldet** statt als bestanden gezählt. Und das Overlay, damit die Zusicherung bei `stalwart` nicht vakuum-grün ist, gehört dazu.
**Schlüsselwort: `Fixes #96`.** Die Abnahmeliste dieses Issues fordert ausschließlich Render-Aussagen, und die sind belegbar. Ein `Part of` gegen Kriterien, die das Issue nicht enthält, würde es auf Dauer offen halten — das ist die Buchführung, die hier schon dreimal zu „Issue heißt neuer Chart und wartet auf eine Browser-Prüfung" geführt hat. **Dafür gehört in den PR-Text ausdrücklich, was ungemessen bleibt:** die tatsächliche Scheduling-Wirkung, das Verhalten des PDB unter `kubectl drain`, und die Obergrenze aus `nodeTaintsPolicy`. Der Install-Test fährt eine Replica je Gruppe und rendert PDB und Spread-Bedingung also nicht — das gehört ebenfalls hinein.
Drei Präzisierungen zur Entscheidung, eine davon neu
Die Rechnung aus dem Kommentar davor ist unabhängig nachgerechnet und bestätigt — alle vier Fälle, und die Obergrenze. Drei Dinge gehören davon präzisiert in die Dokumentation, und der erste Punkt ist neu.
1. nodeAffinityPolicy kann still verworfen werden — gehört an den Wert
Das Feld ist seit Kubernetes 1.26 Beta und erst mit dem Gate NodeInclusionPolicyInPodTopologySpread GA geworden. Auf einem älteren API-Server wird es stillschweigend verworfen — und dann kehrt genau die stille nodeSelector-Hälfte zurück, gegen die es gesetzt wurde: eine Verengung auf einen Knoten ergibt wieder Skew 0 und damit Ko-Lokation, ohne Meldung.
Das ist dieselbe Gattung wie der Fehler in meiner ersten Ergänzung, nur eine Ebene tiefer: der Schutz ist dann weg, ohne dass etwas davon berichtet. Der Satz gehört an den Wert, nicht als bekannt vorausgesetzt — mit der Mindestversion, gegen die der Chart die Zusage überhaupt geben kann.
2. Die Obergrenze ist keine Zahl, sondern eine Regel
Mein Kommentar sprach von „nie mehr als drei Replicas". Das ist der heutige Wert, nicht der Mechanismus. Richtig ist: so viele Replicas je Gruppe, wie es planbare Worker gibt. Die Control-Plane-Domänen halten das Minimum dauerhaft bei 0, und daraus folgt die Grenze — kommt ein Worker hinzu, steigt sie mit; wird einer entfernt, fällt sie.
So gehört es dokumentiert, sonst steht in values.yaml eine Zahl, die beim nächsten Cluster-Umbau falsch ist und niemand nachzieht.
3. Die beiden Felder sind kein Paket
Die Obergrenze entsteht aus nodeTaintsPolicy ≙ Ignore und bestand damit schon vor diesem Entwurf — nodeAffinityPolicy: Ignore fügt sie nicht hinzu, es schließt nur die stille nodeSelector-Lücke.
In values.yaml sollten die beiden deshalb getrennt begründet stehen und nicht als ein Block erscheinen, sonst liest der nächste die Obergrenze als Preis einer Entscheidung, die sie nicht verursacht hat.
Zum Verfahren
Der vorige Durchgang hat nichts implementiert: der isolierte Worktree des Agenten wurde ihm zum fünften Mal mitten in der Arbeit entzogen, und zwar wieder beim Fortsetzen, nicht beim Start. Er hat angehalten statt im Worktree der Hauptsitzung zu arbeiten — richtig, und die Auflage stand so in seinem Auftrag. Kein Branch, kein Commit, keine PR, kein Wiki-Edit; im fremden Worktree ausschließlich Lesevorgänge.
Ein Hinweis, der dabei auffiel und jede künftige Sitzung betrifft: in der gemeinsamen Arbeitskopie steht main auf afa1efb, origin/main dagegen auf 6e196c2. Der lokale main-Ref ist also nicht origin/main — genau die Blindstelle, die CLAUDE.md beschreibt. Jeder Worktree ist von origin/main zu schneiden.
# Drei Präzisierungen zur Entscheidung, eine davon neu
Die Rechnung aus dem Kommentar davor ist unabhängig nachgerechnet und bestätigt — alle vier Fälle, und die Obergrenze. Drei Dinge gehören davon präzisiert in die Dokumentation, und der erste Punkt ist neu.
## 1. `nodeAffinityPolicy` kann still verworfen werden — gehört an den Wert
Das Feld ist seit Kubernetes **1.26 Beta** und erst mit dem Gate `NodeInclusionPolicyInPodTopologySpread` GA geworden. Auf einem **älteren API-Server wird es stillschweigend verworfen** — und dann kehrt genau die stille `nodeSelector`-Hälfte zurück, gegen die es gesetzt wurde: eine Verengung auf einen Knoten ergibt wieder Skew 0 und damit Ko-Lokation, ohne Meldung.
Das ist dieselbe Gattung wie der Fehler in meiner ersten Ergänzung, nur eine Ebene tiefer: der Schutz ist dann weg, ohne dass etwas davon berichtet. **Der Satz gehört an den Wert**, nicht als bekannt vorausgesetzt — mit der Mindestversion, gegen die der Chart die Zusage überhaupt geben kann.
## 2. Die Obergrenze ist keine Zahl, sondern eine Regel
Mein Kommentar sprach von „nie mehr als **drei** Replicas". Das ist der heutige Wert, nicht der Mechanismus. Richtig ist: **so viele Replicas je Gruppe, wie es planbare Worker gibt.** Die Control-Plane-Domänen halten das Minimum dauerhaft bei 0, und daraus folgt die Grenze — kommt ein Worker hinzu, steigt sie mit; wird einer entfernt, fällt sie.
So gehört es dokumentiert, sonst steht in `values.yaml` eine Zahl, die beim nächsten Cluster-Umbau falsch ist und niemand nachzieht.
## 3. Die beiden Felder sind kein Paket
Die Obergrenze entsteht aus **`nodeTaintsPolicy ≙ Ignore`** und bestand damit schon vor diesem Entwurf — `nodeAffinityPolicy: Ignore` fügt sie **nicht** hinzu, es schließt nur die stille `nodeSelector`-Lücke.
In `values.yaml` sollten die beiden deshalb getrennt begründet stehen und nicht als ein Block erscheinen, sonst liest der nächste die Obergrenze als Preis einer Entscheidung, die sie nicht verursacht hat.
## Zum Verfahren
Der vorige Durchgang hat nichts implementiert: der isolierte Worktree des Agenten wurde ihm **zum fünften Mal** mitten in der Arbeit entzogen, und zwar wieder beim Fortsetzen, nicht beim Start. Er hat angehalten statt im Worktree der Hauptsitzung zu arbeiten — richtig, und die Auflage stand so in seinem Auftrag. Kein Branch, kein Commit, keine PR, kein Wiki-Edit; im fremden Worktree ausschließlich Lesevorgänge.
Ein Hinweis, der dabei auffiel und jede künftige Sitzung betrifft: in der gemeinsamen Arbeitskopie steht `main` auf `afa1efb`, `origin/main` dagegen auf `6e196c2`. Der lokale `main`-Ref ist also nicht `origin/main` — genau die Blindstelle, die CLAUDE.md beschreibt. Jeder Worktree ist von `origin/main` zu schneiden.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Der
stalwart-Chart kann eine Mehr-Knoten-Installation nicht hochverfügbar machen: Es fehlt jede Möglichkeit, die Pods einer Knotengruppe über Knoten zu verteilen, und ein PodDisruptionBudget gibt es auch nicht. Beides zusammen entscheidet darüber, ob eine zweite Replica etwas gegen den Ausfall bringt, für den sie hinzugefügt wurde.Anlass
In
jk-clusterlaufen seit dem 05.10.2026 zwei Knotengruppen (fullundmx, je eine Replica, #250). Der nächste Schritt wäre je zwei. Die Lage dort macht das Problem konkret:Drei Arbeitsknoten, vier Stalwart-Pods — ohne Verteilungsregel landen beide Replicas einer Gruppe möglicherweise auf demselben Knoten, und die Redundanz ist nominell.
Gemessener Stand (Chart
1.0.1+upv0.16.24)affinity, keintopologySpreadConstraints, keinnodeSelector, keintolerations— weder in dervalues.yamlnoch in einem Template.updateStrategynochpodManagementPolicy. Das ist in Ordnung und soll so bleiben: Die Kubernetes-Vorgaben sindRollingUpdatemitpartition: 0undOrderedReady, ein Update läuft also ohnehin strikt ein Pod zur Zeit und wartet aufReady. Hier ist nichts hinzuzufügen — der Hinweis steht nur da, damit niemand redundante Schalter einbaut.Teil 1: Verteilung über Knoten
Vorschlag, zwei Ebenen:
Ein bequemer Schalter je Gruppe, bei dem der Chart den Selektor selbst füllt:
Gerendert als
topologySpreadConstraintsauftopologyKey: kubernetes.io/hostname,maxSkew: 1, mit einemlabelSelectoraus den Labels, die der Chart ohnehin setzt (stalwart.jk-dev.de/releaseundstalwart.jk-dev.de/node-group).Generische Durchreichung je Gruppe für alles andere —
affinity,nodeSelector,tolerations,topologySpreadConstraints—, mittoYaml | nindent.Warum beides und nicht nur die Durchreichung: Ein
topologySpreadConstraints, dessenlabelSelectordie Pods nicht trifft, scheitert still. Die Bedingung passt dann auf null Pods, ist damit trivial erfüllt, und verteilt wird nichts — bei grünem Rollout und ohne eine einzige Meldung. Der Chart kennt seine Labels, der Benutzer muss sie nicht abschreiben.Und
whenUnsatisfiablegehört begründet statt vorgegeben. MitDoNotSchedulebleibt eine zweite Replica bei knapper Kapazität dauerhaftPending— und einPending-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel (dieselbe Klasse Irrweg wie beim inotify-Fall der Actions-Runner). Vorschlag:ScheduleAnywayals Vorgabe, mit einem Kommentar, der sagt, was das kostet — Verteilung nach bestem Bemühen, keine Zusage.Teil 2: PodDisruptionBudget
Hier gibt es einen Präzedenzfall im selben Repository, und der sollte übernommen werden:
keycloak.pdb.yamlmitkeycloak.podDisruptionBudget.{enabled, minAvailable}. Dessen Kommentar nennt auch den Grund, warum die Bedingung wichtiger ist als das Objekt:Für Stalwart kommt eine Besonderheit dazu: Es gibt Knotengruppen. Also ein PDB je Gruppe mit der Replica-Zahl dieser Gruppe, und die Bedingung „erst ab zwei" gilt entsprechend pro Gruppe — bei
full: 2undmx: 1entsteht genau ein PDB. UnterscaleDown: truekeines, aus demselben Grund wie bei Keycloak.minAvailablestattmaxUnavailable, analog zu Keycloak: Die Zusage betrifft den Dienst, nicht die Störung.Warum das nicht im Konsumenten-Repository gelöst werden kann
Das PDB allein ginge als eigenes Manifest-Bundle in
cluster-config. Die Verteilung nicht: Sie steht im Pod-Template des StatefulSets, und das von außen zu patchen wäre genau der Drift, dencorrectDrift: unsetbis zur nächsten Chart-Änderung festschreibt. Außerdem müsste der Selektor des PDB die Chart-Labels nachbauen und würde bei einer Umbenennung still falsch.Abnahme
helm templatemitnodes.full.replicas: 2undspreadAcrossNodes: truerendert einetopologySpreadConstraintsmit einem Selektor, der auf die Labels des eigenen Pod-Templates passt (am gerenderten Ergebnis abgleichen, nicht nach Augenmaß).replicas: 1wird kein PDB gerendert; bei2genau eines je Gruppe; beiscaleDown: truekeines.affinityohne den Schalter.Dringlichkeit
Mittel. Es blockiert nichts, was heute läuft: Mit je einer Replica ist weder ein PDB noch eine Verteilungsregel sinnvoll. Es blockiert aber den nächsten Schritt — und zwar auf die unangenehme Art, weil eine zweite Replica ohne Verteilung aussieht wie Redundanz und keine ist.
Ergänzung: Entscheidungen und vier Punkte aus der Durchsicht
Die gemessenen Aussagen des Issues sind nachgeprüft und stimmen alle: kein
affinity, keintopologySpreadConstraints, keinnodeSelector, keinetolerations— nirgends instalwart/; neun Templates, kein PDB; wederupdateStrategynochpodManagementPolicygesetzt. Und entscheidend für den Vorschlag: die Labelsstalwart.jk-dev.de/releaseundstalwart.jk-dev.de/node-groupstehen im Pod-Template (Zeilen 54–55), nicht nur in der StatefulSet-Metadata. EinlabelSelectortrifft Pods, der Entwurf funktioniert also.Entscheidung 1:
whenUnsatisfiable: DoNotScheduleDer Issue-Vorschlag war
ScheduleAnyway. Der Betreuer hat die Anforderung als Zusage formuliert —full-Pods laufen auf verschiedenen Knoten,mx-Pods auf verschiedenen Knoten —, undScheduleAnywaygibt keine Zusage: bei 97 % angeforderter CPU aufjk-worker-1kann der Scheduler beidefull-Replicas aufjk-worker-3legen, regelkonform und wirkungslos.Also
DoNotSchedule. Auf dem Zielcluster ist das erfüllbar: drei Arbeitsknoten, je zwei Replicas pro Gruppe,maxSkew: 1geht als 1+1 auf.Der Preis gehört an den Wert und in
NOTES.txt, und das Issue benennt ihn selbst richtig: lässt sich eine Replica nicht platzieren, bleibt sie dauerhaftPending— und einPending-Pod ohne Scheduling-Ereignis liest sich wie ein anderes Problem, nicht wie eine Verteilungsregel. Konkret tritt das ein, wennnodeSelectorodertolerationssetzt, die weniger Knoten zulassen als die Gruppe Replicas hat.Der zweite Fall ist der wahrscheinlichere und gehört ausdrücklich dazu:
DoNotScheduleplus eine Knoteneinschränkung ist eine Kombination, die sich selbst blockiert.Entscheidung 2: Verteilung nur innerhalb einer Gruppe
Bestätigt wie im Issue entworfen —
labelSelectorauf Release und Knotengruppe. Einmxund einfullauf demselben Knoten ist ausdrücklich in Ordnung. Keine zusätzliche Bedingung, die nur aufreleaseselektiert und damit alle Stalwart-Pods gegeneinander verteilt; die Frage war gestellt und ist mit nein beantwortet.Punkt 3: Teil 1 hängt an einem offenen Messpunkt von #65
Der Grund, warum die Verteilung überhaupt wirken kann, ist dass die StatefulSet keine
volumeClaimTemplateshat — ein Pod ist nicht durch ein topologie-gebundenes Volume an einen Knoten genagelt.Offener Messpunkt 1 auf #65 lautet: muss unter
/var/lib/stalwartetwas einen Neustart überleben? Wenn ja, braucht es doch eine PVC je Knotengruppe. Fällt diese Antwort auf ja, entsteht eine PVC — und mit einer knoten-lokalen oder topologie-gebundenen Storage-Klasse wird der Pod dorthin gebunden, wo sein Volume zuerst angelegt wurde. Die Verteilung entscheidet sich dann beim ersten Provisionieren, nicht mehr über die Regel, undDoNotSchedulekann einen wiederkehrenden Pod sogar dauerhaftPendinglassen, wenn sein Volume auf einem Knoten liegt, der die Skew-Bedingung verletzt.Keine Blockade für dieses Issue, aber es gehört in die Chart-Dokumentation: diese Verteilungsregel setzt voraus, dass der Chart PVC-frei bleibt.
Punkt 4: die Selektor-Prüfung soll eine CI-Zusicherung werden
Das Abnahmekriterium sagt richtig „am gerenderten Ergebnis abgleichen, nicht nach Augenmaß". Der Fehlerfall kehrt aber zurück, sobald jemand ein Label umbenennt — und er kehrt still zurück: der Selektor trifft null Pods, die Bedingung ist trivial erfüllt, verteilt wird nichts, alles grün. Bei
DoNotScheduleist das noch unangenehmer, weil die Regel dann scheinbar eine Zusage trägt.Vorbild im Repo:
ci/run-quantity-render-test.shist eine Zusicherung auf Render-Ebene. Dieselbe Form hier — denlabelSelectoraus der gerenderten Spread-Bedingung und dem PDB extrahieren und gegen die Labels des gerenderten Pod-Templates halten — macht aus einer einmaligen Prüfung eine dauerhafte. Das halte ich für die wertvollste Ergänzung dieses Durchgangs.Nebenbefund, außerhalb dieses Issues
Beim Nachprüfen fiel zweierlei auf, das die Objektwahl des Charts betrifft und hier nur festgehalten wird, weil es #96 nicht berührt —
topologySpreadConstraintsund PDB wirken auf StatefulSet und Deployment identisch:serviceName:an der StatefulSet; niemand löst<pod>.<gruppe>-headlessauf, weil die Koordination über Valkey-Pub/Sub läuft und nicht über DNS. Er existiert für einen Zweck, den nichts in Anspruch nimmt.keycloakin diesem Repo ist derselbe Fall (zustandslos, externe Datenbank, geclusterter eingebetteter Cache, Knotenname je Pod) und holt ihn genauso ausfieldRef: metadata.name— in einem Deployment. Dort genügt, dass der Name eindeutig ist; stabil muss er nicht sein.Ob Stalwarts Node-ID-Lease einen wechselnden Hostnamen verträgt, ist ungemessen. Leases laufen ab, und Rollen werden über
STALWART_ROLEzugewiesen statt über die Knoten-Identität — eine stehengebliebene Lease verdoppelt also keine Aufgabe. Das klingt tragbar, ist aber nicht belegt, und ein Wechsel wäre ein Major-Bump. Eigenes Issue, falls es verfolgt werden soll.Korrektur meiner Ergänzung, und die offenen Punkte entschieden
Der zweite Preis-Fall meiner Ergänzung war falsch, und zwar in die unangenehme Richtung. Nachgeprüft an der Kubernetes-API-Definition, beide Vorgaben wörtlich:
Daraus folgt die Asymmetrie, die meine Ergänzung nicht nannte:
DoNotSchedule,maxSkew: 1, VorgabennodeSelectorlässt nur einen Knoten zu, Gruppe hat 2 ReplicasPending, kein Ereignis, keine Zusage.PendingMein Satz „jemand setzt
nodeSelectorodertolerations… → dauerhaftPending, der zweite Fall ist der wahrscheinlichere" stimmt also nur fürtolerations. FürnodeSelectorverspricht er ein Symptom, das nicht eintritt, während der Schaden unsichtbar bleibt — genau die stille Klasse, gegen die der ganze Entwurf gebaut ist. Und die CI-Zusicherung fängt es nicht: der Selektor ist dann richtig, die Knotenmenge ist falsch.Entscheidung:
nodeAffinityPolicy: Ignore, ausdrücklich gesetztDie Behebung ist keine der beiden zur Wahl gestellten Varianten — kein Render-Abbruch und nicht nur Dokumentation, sondern ein Feld an der chart-eigenen Bedingung.
Mit
nodeAffinityPolicy: Ignorezählen alle Knoten in die Skew-Rechnung. EinnodeSelector, der auf einen Knoten verengt, ergibt dann2,0,0→ Skew 2 →Pending. Die stille Hälfte wird zur lauten.Durchgerechnet, damit es keine zulässige Konfiguration verbietet (genau der Grund gegen einen Abbruch):
nodeSelector, 3 Knoten, 2 ReplicasnodeSelectorüber 5 Knoten, 2 ReplicasnodeSelectorüber 1 Knoten, 2 ReplicasPending— gewolltEin Render-Abbruch hätte Zeile 2 mitverboten. Das Feld trifft genau Zeile 3.
Entscheidung:
nodeTaintsPolicybleibt auf der VorgabeIgnoreKonsequent zur
DoNotSchedule-Wahl: die Zusage lässt sich nicht durch das Wegfallen von Domänen aufweichen. Zwei Folgen, die ausdrücklich dokumentiert gehören — invalues.yamlam Schalter, inNOTES.txtund auf der Wiki-Seite:maxSkew: 1kann eine Gruppe damit nie mehr als drei Replicas planen — die vierte ergäbe2,1,1,0,0,0, also Skew 2. Für den Zielzustand von zwei je Gruppe ohne Belang, aber es ist eine Grenze, die später überrascht.Pendingstatt beim Überlebenden mitzulaufen — solange dieNode-Objekte noch existieren (NotReadyzählt wegenIgnoreals leere Domäne). Werden die Knoten gelöscht, verschwinden die Domänen und der Pod läuft mit. Das ist derselbe Mechanismus, ein Satz Unterschied, und beide Hälften gehören hin.Entscheidung zu Befund 2: anhängen, bei Kollision abbrechen
Übernommen wie vorgeschlagen. Andere
topologyKeys werden angehängt und wirklich mit UND verknüpft. Bei Kollision auf(topologyKey, whenUnsatisfiable)— alsokubernetes.io/hostnamemit leerem oderDoNotSchedule— Render-Abbruch, dernodes.<gruppe>.spreadAcrossNodesundnodes.<gruppe>.topologySpreadConstraintsbeide namentlich nennt.Die Begründung trägt: bei
createlehnt die API-Validierung zwei Einträge mit gleichem Schlüsselpaar ab, mit einer Meldung auf einen Listenindex statt auf einen Wert; und auf einem Apply-Pfad verschmelzen sie überpatchMergeKey: topologyKeystill zu einem, wobei ein Selektor wegfällt und das Ergebnis grün ist. Beides ist schlechter als ein benannter Abbruch.Befund 3 übernommen
Der 97-%-Knoten gehört an den dritten Replica, nicht an den zweiten: bei je zwei auf drei Workern ist
1,1,0→ Skew 1 → zulässig, die knappe CPU erzeugt dort noch keinPending. Erst ab drei Replicas je Gruppe muss jeder Knoten einen annehmen. Die Begründungskette im Issue-Anlass gehört entsprechend verschoben, sonst wird aus der Vorsorge eine falsche Warnung.Die vorbereiteten Punkte, bestätigt
Vorgaben und Versionssprung:
podDisruptionBudget.enabled: trueundspreadAcrossNodes: true, beide hinter der berechneten Replica-Zahl>= 2gesperrt, damitscaleDownmitgefangen ist. Eine einfache Installation (full: 1,mx: 0) rendert damit dieselben Objekte wie vorher → Minor,1.1.0+upv0.16.24. Zu belegen mit einemhelm template-Diff gegenorigin/main, nicht mit Gefühl. Die Gegenrechnung gehört invalues.yaml: ein Hochskalieren von 1 auf 2 ändert das Pod-Template, der bestehende Pod wird in derselben Rollout-Runde ersetzt.Die CI-Zusicherung generisch über alle Charts, wie vorgeschlagen — Selektoren aus PDB und Spread-Bedingung gegen die Labels jedes gerenderten Pod-Templates desselben Renders. Dass die Runner kein
pyyamlund keinyqhaben, ist gemessen; Helm selbst als YAML-Parser zu benutzen statt einen eigenen zu schreiben ist die richtige Antwort, weil ein selbstgeschriebener Parser den Test still vakuum-grün machen könnte.podAffinity/podAntiAffinitybleiben draußen, Selektoren mit ausschließlichmatchExpressionswerden als „nicht geprüft" gemeldet statt als bestanden gezählt. Und das Overlay, damit die Zusicherung beistalwartnicht vakuum-grün ist, gehört dazu.Schlüsselwort:
Fixes #96. Die Abnahmeliste dieses Issues fordert ausschließlich Render-Aussagen, und die sind belegbar. EinPart ofgegen Kriterien, die das Issue nicht enthält, würde es auf Dauer offen halten — das ist die Buchführung, die hier schon dreimal zu „Issue heißt neuer Chart und wartet auf eine Browser-Prüfung" geführt hat. Dafür gehört in den PR-Text ausdrücklich, was ungemessen bleibt: die tatsächliche Scheduling-Wirkung, das Verhalten des PDB unterkubectl drain, und die Obergrenze ausnodeTaintsPolicy. Der Install-Test fährt eine Replica je Gruppe und rendert PDB und Spread-Bedingung also nicht — das gehört ebenfalls hinein.Drei Präzisierungen zur Entscheidung, eine davon neu
Die Rechnung aus dem Kommentar davor ist unabhängig nachgerechnet und bestätigt — alle vier Fälle, und die Obergrenze. Drei Dinge gehören davon präzisiert in die Dokumentation, und der erste Punkt ist neu.
1.
nodeAffinityPolicykann still verworfen werden — gehört an den WertDas Feld ist seit Kubernetes 1.26 Beta und erst mit dem Gate
NodeInclusionPolicyInPodTopologySpreadGA geworden. Auf einem älteren API-Server wird es stillschweigend verworfen — und dann kehrt genau die stillenodeSelector-Hälfte zurück, gegen die es gesetzt wurde: eine Verengung auf einen Knoten ergibt wieder Skew 0 und damit Ko-Lokation, ohne Meldung.Das ist dieselbe Gattung wie der Fehler in meiner ersten Ergänzung, nur eine Ebene tiefer: der Schutz ist dann weg, ohne dass etwas davon berichtet. Der Satz gehört an den Wert, nicht als bekannt vorausgesetzt — mit der Mindestversion, gegen die der Chart die Zusage überhaupt geben kann.
2. Die Obergrenze ist keine Zahl, sondern eine Regel
Mein Kommentar sprach von „nie mehr als drei Replicas". Das ist der heutige Wert, nicht der Mechanismus. Richtig ist: so viele Replicas je Gruppe, wie es planbare Worker gibt. Die Control-Plane-Domänen halten das Minimum dauerhaft bei 0, und daraus folgt die Grenze — kommt ein Worker hinzu, steigt sie mit; wird einer entfernt, fällt sie.
So gehört es dokumentiert, sonst steht in
values.yamleine Zahl, die beim nächsten Cluster-Umbau falsch ist und niemand nachzieht.3. Die beiden Felder sind kein Paket
Die Obergrenze entsteht aus
nodeTaintsPolicy ≙ Ignoreund bestand damit schon vor diesem Entwurf —nodeAffinityPolicy: Ignorefügt sie nicht hinzu, es schließt nur die stillenodeSelector-Lücke.In
values.yamlsollten die beiden deshalb getrennt begründet stehen und nicht als ein Block erscheinen, sonst liest der nächste die Obergrenze als Preis einer Entscheidung, die sie nicht verursacht hat.Zum Verfahren
Der vorige Durchgang hat nichts implementiert: der isolierte Worktree des Agenten wurde ihm zum fünften Mal mitten in der Arbeit entzogen, und zwar wieder beim Fortsetzen, nicht beim Start. Er hat angehalten statt im Worktree der Hauptsitzung zu arbeiten — richtig, und die Auflage stand so in seinem Auftrag. Kein Branch, kein Commit, keine PR, kein Wiki-Edit; im fremden Worktree ausschließlich Lesevorgänge.
Ein Hinweis, der dabei auffiel und jede künftige Sitzung betrifft: in der gemeinsamen Arbeitskopie steht
mainaufafa1efb,origin/maindagegen auf6e196c2. Der lokalemain-Ref ist also nichtorigin/main— genau die Blindstelle, die CLAUDE.md beschreibt. Jeder Worktree ist vonorigin/mainzu schneiden.