18. September 2026 · 7 Min. Lesezeit
Die Constraint war erfüllt. Die Zone war trotzdem leer.
Ich startete einen Collector neu, Pod für Pod, um ein zonenbewusstes Routing zu konvergieren, jeweils einen nach dem anderen, damit nichts Kapazität verlor. Nach drei sicheren Löschungen saßen zwei Pods desselben Deployments auf einem einzelnen Node, und seine eigene Zone war leer.
Der Namespace trägt auf jedem dieser Deployments eine harte Spread-Constraint. Ihre einzige Aufgabe ist es, genau den Zustand zu verhindern, den ich gerade hervorgebracht hatte. Und sie war die ganze Zeit erfüllt.
Was die Constraint tatsächlich zusagt
Eine topologySpreadConstraint wird genau einmal evaluiert, wenn ein Pod gescheduled wird, und nie wieder. Kubernetes rebalanciert nicht. Kommt ein Pod hoch, zählt der Scheduler, pro Wert des Topology-Keys, die Pods, die auf den labelSelector der Constraint matchen, berechnet den Skew gegen maxSkew und platziert entsprechend. Nach diesem Moment wertet nichts neu aus; ein Pod bleibt dort, wo er gelandet ist, bis etwas ihn löscht.
Das macht „erfüllt” zu einer Aussage über zwei Dinge zugleich: über eine Zahl und über die Population, über die gezählt wurde. In meinem Fall stimmte die Zahl. Die Population nicht.
Es liegt nahe, nach whenUnsatisfiable: DoNotSchedule zu greifen und die Constraint damit als strenger zu bezeichnen. Das ist nicht das Problem, und es war nicht meins. Die Strenge war nie der Input. Die Population war.
Defekt eins: das Rollout stapelt
Die erste Art, wie die Population lügt, verläuft über die Zeit.
Während eines Rolling Updates existieren kurzzeitig zwei ReplicaSets: das ausgehende, das entleert wird, und das eingehende, das gefüllt wird. Ein nackter labelSelector matcht beide. Der Scheduler platziert neue Pods also gegen eine Population, die Pods enthält, die gleich verschwinden werden, sieht einen Skew, den er nicht erfüllen kann, und packt zwei Replicas auf einen Node. Dort bleiben sie unbegrenzt, weil nichts neu evaluiert, und das nächste Rollout macht es wieder.
Der Fix ist eine Zeile:
matchLabelKeys:
- pod-template-hash
Der Scheduler hängt den eigenen pod-template-hash des Pods an den Selector an, bevor er zählt, was die Population auf das eigene ReplicaSet des Pods begrenzt. Die sterbenden Geschwister werden nicht mehr gezählt, und das Rollout verteilt sich normal. GA seit Kubernetes 1.30, und günstig genug, dass es auf jede Spread-Constraint gehört, deren Selector keine Per-Workload-Identität enthält.
Defekt zwei: das gemeinsame Label poolt drei Deployments
Die zweite Art, wie die Population lügt, verläuft seitwärts, und sie ist die schwere.
Ein Operator verwaltet in diesem Namespace drei Collector-Deployments, mit drei, zwei und zwei Replicas. Er setzt app.kubernetes.io/component: opentelemetry-collector auf allen dreien, denn genau das bedeutet das Label: was ein Ding ist, nicht welches Ding es ist.
Jedes der drei Deployments trägt eine Spread-Constraint, die auf dieses Component-Label selektiert. Also berechnet jedes einzelne seinen Skew über die Vereinigung aller sieben Pods. Drei Constraints, eine gemeinsame Population, keine davon zählt ihre eigenen Replicas.
Während meines Pod-für-Pod-Neustarts war die kombinierte Verteilung über die sieben Pods nach Zonen 3/1/2. Aus Sicht der Vereinigung war die Ein-Pod-Zone das Minimum, also ließ der Skew-Filter dort weiter Pods zu. Aus Sicht des Deployments mit drei Replicas hatte diese Zone bereits einen Pod, und der Pod, den ich gerade gelöscht hatte, gehörte ihm, also wurden seine Ersatzpods von der eigenen, leeren Zone weggesteuert. Zwei seiner Replicas landeten auf einem Node gestapelt.
Das ist der Teil, den man verinnerlichen sollte. Die Constraint war durchgehend erfüllt, und der Scheduler war durchgehend korrekt. Ihm wurde aufgetragen, Pods zu zählen, die er nicht hätte zählen sollen, er hat sie gezählt, und er hat den Skew respektiert, den er berechnet hat. Der Defekt lag nie im Mechanismus. Er lag in der Populationsdeklaration.
Der Fix ist Per-Workload-Identität im Selector. Der Operator setzt ein Instance-Label pro Deployment, sodass die Constraint wird:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/component: opentelemetry-collector
app.kubernetes.io/instance: <die eigene Instanz des Deployments> # begrenzt
matchLabelKeys:
- pod-template-hash # hinzugefügt
Lesen Sie das Instance-Label von einem lebenden Pod ab, statt den Key zu raten. Operatoren labeln nach Art, und ein Label, das eine Art identifiziert, ist exakt der falsche Key, um danach zu verteilen.
Die Reparatur, die auf die falsche Antwort konvergiert
Die Standard-Reparatur für ein gestapeltes Deployment ist, die gedoppelten Pods einzeln zu löschen. Der Skew-Filter lässt nur einen Node zu, dessen Zählung dem aktuellen globalen Minimum entspricht, also wird jeder Ersatz auf den leeresten legalen Node gezwungen. Deterministisch, kein Glück, und ich hatte sie am Vortag bei einer anders geformten Version desselben Problems benutzt.
Mit einem verflochtenen Selector ist diese Reparatur eine Falle. Jede Löschung berechnet dasselbe falsche Minimum neu, über dieselbe falsche Population, und landet den Ersatz am selben falschen Ort. Ich habe denselben Pod zweimal auf demselben Node zurückkommen sehen, bevor ich akzeptierte, dass die Reparatur selbst etwas voraussetzte, das die Constraint stillschweigend gebrochen hatte: dass der Filter das zählt, wovon man ausgeht, dass er es zählt.
Der Ausweg ist, die falsche Wahl für einen Scheduling-Zyklus illegal zu machen. Cordonen Sie den Node, den er immer wieder wählt, löschen Sie den Pod, sodass als einzige legale Plätze die richtigen bleiben, und heben Sie das cordon danach auf. Das ist kein Fix; es ist eine Flucht. Der Fix ist der Selector.
Noch eine Falle aus derselben Familie, weil sie genau während solcher Untersuchungen zuschlägt: Ein Helm-Apply kehrt zurück, bevor das Rollout, das es ausgelöst hat, abgeschlossen ist, weil maxUnavailable: 1 zwei von dreien als verfügbar zählt. Das Apply sagte fertig, während ein Pod noch Pending war. Prüfen Sie Rollouts selbst; erben Sie nicht den Optimismus des Apply.
Der Fix, verifiziert durch den Fall, der scheiterte
Beide Zeilen gingen rein: das Per-Instance-Label in matchLabels, der pod-template-hash in matchLabelKeys. Das gestapelte Deployment habe ich danach nicht von Hand repariert, und das war Absicht.
Das Rolling Update, das den Fix anwandte, führte exakt den Fall erneut aus, der gescheitert war. Dieselbe Constraint-Semantik, derselbe Namespace, derselbe Start-Skew, die eine Situation, die zwei Pods auf einem Node hervorgebracht hatte. Das Deployment kam zurück, ein Pod pro Zone, ohne Hilfe. Wenn das Deployment des Fixes die Reproduktion des Fehlers ist, bekommt man Verifikation und Regressionstest in demselben Ereignis.
Der Rollout brachte nebenbei den Anteil der gleichzonalen Verbindungen des Bestands auf 99 Prozent, weil jeder ersetzte Pod in seine eigene Zone auflöste. Das steht auf dem Spiel: mit einem zonalen Load Balancer, Cross-Zone-Traffic aus, externalTrafficPolicy: Local, ist eine Zone ohne Pod ein Load-Balancer-Node ohne gesundes Ziel, und zonenaffines Routing zahlt nichts für die Clients in dieser Zone. Ein Scheduling-Bug, der kosmetisch aussieht, ist ein Routing-Bug ist ein Kosten-Bug. Ein gewöhnlicher Deploy hat alle drei auf einmal behoben.
Wo dies verallgemeinert
Ein Selector ist eine Populationsdeklaration, kein Name. Wann immer Sie einen schreiben, lautet die Frage nicht „matcht er mein Workload”, sondern „was ist die vollständige Menge der Dinge, die er matcht”, und die zwei Antworten weichen in Weisen voneinander ab, die nie in dem Objekt auftauchen, das Sie gerade bearbeiten.
Die milde Version der Lüge ist der Sidecar: der Single-Replica-Metrics-Pod eines Charts trägt meist dasselbe name-Label wie das, was Sie meinten, also zählt die Zählung ihn mit, und ein schiefer Spread erfüllt maxSkew: 1, während er eine Zone tatsächlich leer lässt. Tragen die Pods, die Sie wollen, kein unterscheidendes eigenes Label, muss der Ausschluss ausgedrückt werden, nicht angedeutet:
labelSelector:
matchLabels:
app.kubernetes.io/name: <the chart>
matchExpressions:
- key: app.kubernetes.io/component
operator: DoesNotExist
Die schwere Version sind verwandte Deployments, die über ein Art-Label gepoolt werden, und keine Strenge behebt sie, weil die Strenge auf eine Zählung über die falsche Menge angewandt wird.
Die diagnostische Gewohnheit, die beide erwischt: Bevor Sie einer Spread-Constraint vertrauen, zählen Sie auf, was der Scheduler tatsächlich zählt, und zählen Sie es von lebenden Pods auf, kubectl get pods --show-labels, nicht aus dem Spec, den Sie geschrieben haben. Der Spec sagt Ihnen, was Sie meinten. Die Pods sagen Ihnen, was Sie bekommen haben, und das sind die einzigen zwei Fakten im System, und sie stimmen nur überein, wenn der Selector stimmt.
Eine erfüllte Constraint beweist eine Zahl. Nur die Absicht entscheidet, ob die Zahl über dem Ding gezählt wurde, das Sie meinten.