Pod blijft Pending: 'didn't match pod topology spread constraints' met niet-getolereerde taints

Een pod met topology spread constraints blijft Pending terwijl het cluster nog vrije capaciteit heeft. De scheduler meldt in hetzelfde FailedScheduling-event zowel niet-getolereerde taints als constraints die niet matchen. De oorzaak is de default nodeTaintsPolicy: Ignore, die niet-bereikbare getainte nodes meetelt in de spread-wiskunde en zo een deadlock veroorzaakt in multi-tenant clusters. De fix is nodeTaintsPolicy: Honor zetten op de constraint.

Het symptoom

Een pod blijft Pending. Het cluster heeft nog ruimte. kubectl describe pod levert een FailedScheduling-event dat twee schijnbaar losse klachten in één bericht combineert:

Warning  FailedScheduling  default-scheduler
0/12 nodes are available:
  3 node(s) had untolerated taint {node-role.kubernetes.io/control-plane},
  3 node(s) had untolerated taint {dedicated: tenant-a},
  6 node(s) didn't match pod topology spread constraints.
preemption: 0/12 nodes are available: 12 Preemption is not helpful for scheduling.

De pod heeft een topologySpreadConstraints-blok op topology.kubernetes.io/region met maxSkew: 1 en whenUnsatisfiable: DoNotSchedule. Twee van de drie replicas draaien al. De derde kan nergens terecht. Worker-nodes bijschalen helpt niet. Meer replicas toevoegen aan de deployment helpt ook niet.

Wat dit betekent

De topology spread plugin van de scheduler berekent pod-distributie over "eligible domains". Default staat nodeTaintsPolicy op Ignore, wat betekent dat de plugin elke node met het topology-label meetelt, ongeacht of de pod daar daadwerkelijk kan landen. In een multi-tenant cluster waar één regio's nodes getaint zijn voor een andere tenant, verschijnt die regio als een domain met nul pods en trekt het globale minimum naar nul. De constraint dwingt scheduling daarna richting de onbereikbare regio. De taint blokkeert het. Deadlock.

Hoe de spread-wiskunde werkt

maxSkew is het maximale toegestane verschil tussen het aantal pods in een eligible domain en het globale minimum. Met whenUnsatisfiable: DoNotSchedule weigert de scheduler een pod te plaatsen als dat het verschil voorbij maxSkew zou duwen. De definitie van "eligible domain" is precies waar de bug in dit artikel zit.

Stel: een cluster van 12 nodes verdeeld over drie regions. De pod tolereert de worker-taints in eu-west-1 en eu-central-1 maar niet de dedicated taint in us-east-1:

Region Worker nodes Pods draaiend Bereikbaar voor deze pod?
eu-west-1 3 1 Ja
eu-central-1 3 1 Ja
us-east-1 3 0 Nee (taint dedicated=tenant-a)

Met de default nodeTaintsPolicy: Ignore zijn alle drie de regions eligible domains. Globaal minimum over die domains is nul (de telling in us-east-1). Plaatsen in eu-west-1 of eu-central-1 zou 2-0=2 opleveren, voorbij maxSkew: 1. Plaatsen in us-east-1 zou de wiskunde wél kloppen (1-0=1), maar de taint weigert de binding. Geen enkele plaatsing werkt.

Met nodeTaintsPolicy: Honor filtert de plugin us-east-1 uit de eligible domain set vóór het tellen. De wiskunde gaat nu over twee domains: eu-west-1 (1) en eu-central-1 (1). De derde pod in een van beide plaatsen geeft 2-1=1, gelijk aan maxSkew: 1. De pod schedulet.

Waarom de default Ignore is

Vóór Kubernetes 1.25 had de topology spread plugin geen taint-bewustzijn. Getainte nodes werden altijd als domain-leden meegeteld. nodeTaintsPolicy standaard op Ignore zetten houdt dat pre-1.25 gedrag intact en voorkomt dat workloads die toevallig werkten onder de oude tellogica stilletjes anders gaan schedulen. Het promotietraject van NodeInclusionPolicyInPodTopologySpread is alpha in 1.25, beta (default aan) in 1.26, en GA via PR #130920 in 1.33.

Dit deadlock-patroon is gerapporteerd als kubernetes/kubernetes#107464 ("Pod Topology Spread takes into account unschedulable tainted nodes") en de cordon-tijdens-upgrade variant als #106127. De fix waren de nieuwe policy-velden, niet een default-wijziging.

Het zustervraagstuk nodeAffinityPolicy defaultet juist naar Honor, het tegenovergestelde. De asymmetrie bestaat omdat de scheduler nodes die niet aan de nodeAffinity voldeden al filterde via een aparte plugin chain, vóór topology spread aan de beurt was. Honor als default behield dat impliciete pre-1.25 gedrag, terwijl taint-awareness echt nieuw was en de conservatieve Ignore als default kreeg.

Diagnose

Bevestig de deadlock met deze volgorde. Voer de stappen op volgorde uit.

1. Lees het FailedScheduling-event. Op clusters met Kubernetes 1.28 of nieuwer is kubectl events het moderne commando (GA in 1.28):

kubectl events --for pod/<pod-name> -n <namespace> --types=Warning

Op oudere clusters val je terug op kubectl describe pod <pod-name> en lees je de Events-sectie. Het samengestelde bericht bevat zowel untolerated taint als didn't match pod topology spread constraints als losse clauses wanneer de deadlock van toepassing is.

2. Map nodes op regions en taints. Dit commando bevestigt de deadlock-structuur:

kubectl get nodes -L topology.kubernetes.io/region \
  -o custom-columns=NAME:.metadata.name,\
REGION:'.metadata.labels.topology\.kubernetes\.io/region',\
TAINTS:.spec.taints

Je ziet één of meerdere regions waar elke node een taint draagt die de pending pod niet tolereert. Die regio is de spookdomain in de wiskunde.

3. Vergelijk de tolerations van de pod tegen die taints.

kubectl get pod <pod-name> -n <namespace> -o jsonpath='{.spec.tolerations}'

Cross-reference elke NoSchedule- en NoExecute-taint uit stap 2 tegen de tolerations van de pod. Als de taints van de getainte regio niet in de toleration-lijst staan, heb je de deadlock te pakken.

4. Reconstrueer wat de scheduler ziet. Tel matchende pods per region voor dezelfde labelSelector als de constraint:

kubectl get pods -n <namespace> \
  -l <label-selector-uit-de-constraint> \
  -o custom-columns=NAME:.metadata.name,\
REGION:'.metadata.labels.topology\.kubernetes\.io/region',\
NODE:.spec.nodeName

Als de telling per region matcht met de structuur in de wiskunde-tabel hierboven (een getainte regio op nul die het globale minimum omlaag trekt), is de deadlock bevestigd.

5. Check de huidige nodeTaintsPolicy-waarde op de constraint.

kubectl get pod <pod-name> -n <namespace> \
  -o jsonpath='{.spec.topologySpreadConstraints[*].nodeTaintsPolicy}'

Een lege uitvoer betekent dat het veld niet gezet is en terugvalt op de default Ignore. Dat bevestigt de fix.

Oplossing: zet nodeTaintsPolicy: Honor

De minimale wijziging is één regel op de constraint:

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/region
    whenUnsatisfiable: DoNotSchedule
    nodeTaintsPolicy: Honor       # nodes met niet-getolereerde taints uitsluiten
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: cache-replica

Na het toepassen valt de getainte regio uit de eligible domain set en wordt de spread-wiskunde herberekend over alleen de bereikbare regions.

Als de Helm chart het veld niet exposed

Veel Helm charts exposen topologySpreadConstraints als values-blok maar stoppen bij maxSkew, topologyKey en whenUnsatisfiable. Per april 2026 is de DandyDeveloper redis-ha chart een voorbeeld: nodeTaintsPolicy staat niet in de values-schema en niet in de StatefulSet template.

In dat geval patch je de gerenderde manifest met Kustomize JSON 6902:

# kustomization.yaml
patches:
  - target:
      kind: StatefulSet
      name: redis-ha-server
    patch: |-
      - op: add
        path: /spec/template/spec/topologySpreadConstraints/0/nodeTaintsPolicy
        value: Honor

De add-operatie maakt het veld aan omdat het nog niet bestaat op het gerenderde object. Het pad-index 0 gaat uit van één constraint; verhoog het voor extra entries.

Verifiëren dat de fix werkt

Check de gerenderde StatefulSet vóór het apply:

kustomize build --enable-helm . | \
  yq 'select(.kind == "StatefulSet" and .metadata.name == "redis-ha-server") | .spec.template.spec.topologySpreadConstraints'

Bevestig na het apply dat de draaiende pod het veld ziet:

kubectl get pod <pod-name> -n <namespace> \
  -o jsonpath='{.spec.topologySpreadConstraints[*].nodeTaintsPolicy}'

Output moet Honor zijn. Trigger een rescheduling voor de nog-pending pod met kubectl delete pod <pending-pod>, zodat de controller hem opnieuw aanmaakt met de gepatchte spec. Je weet dat het werkte als de pod van Pending naar Running gaat en op een node in een bereikbare regio belandt.

Wat dit niet oplost: whenUnsatisfiable: ScheduleAnyway

whenUnsatisfiable van DoNotSchedule naar ScheduleAnyway zetten laat het symptoom verdwijnen, maar is niet equivalent aan de Honor-fix. Het degradeert de constraint tot een zachte voorkeur, met vier reële gevolgen:

  • De scheduler probeert skew te beperken, maar andere scoring-factoren (resource-gebruik, affinity scoring) concurreren mee. Distributie wordt niet-deterministisch.
  • Bestaande imbalances worden niet gecorrigeerd. Pods die suboptimaal landden blijven daar staan tot een descheduler of eviction ze opnieuw schedulet.
  • Karpenter behandelt ScheduleAnyway-constraints als echte constraints tijdens consolidatie. Nodes worden niet geconsolideerd als dat zelfs maar een zachte constraint zou schenden, waardoor onderbenutte nodes langer blijven draaien dan nodig.
  • De oorspronkelijke distributie-intentie gaat verloren. De constraint bestaat juist om een harde verdeelregel af te dwingen, en ScheduleAnyway laat die intentie stilletjes los.

De aanbeveling in community-discussie is: houd DoNotSchedule en zet er nodeTaintsPolicy: Honor bij. Dat behoudt de strikte verdeling en lost de deadlock bij de bron op.

Wanneer dit patroon in productie raakt

Multi-tenant clusters produceren deze deadlock in een handvol terugkerende vormen:

  • Dedicated tenant pools. Node groups getaint met dedicated=<tenant>:NoSchedule. Workloads van andere tenants kunnen daar niet landen, maar de region of zone telt nog wel mee in de spread-wiskunde.
  • GPU node pools. Getaint met nvidia.com/gpu:NoSchedule. Niet-GPU workloads met region-level spread constraints zien GPU-regions als nul-pod domains.
  • Node cordoning tijdens upgrades. Een kubectl cordon plaatst node.kubernetes.io/unschedulable:NoSchedule. Pods die van de cordoned node worden geëvicteerd proberen opnieuw te schedulen, maar de regio van de cordoned node blijft in de spread-telling op nul pods staan. Dit is de meest voorkomende productie-trigger en zit in #106127.
  • virtual-kubelet nodes die een topology-label delen met echte nodes.
  • vCluster, Capsule of andere multi-tenant abstracties die taints gebruiken om tenants te isoleren.

De deadlock treedt eerder op met topology.kubernetes.io/region en topology.kubernetes.io/zone dan met kubernetes.io/hostname. Region- en zone-topologies hebben weinig domains; één spookdomain breekt de wiskunde direct. Hostname-topology heeft honderden domains, dus één getainte host heeft een veel kleiner verstorend effect.

Wanneer escaleren

Als nodeTaintsPolicy: Honor toepassen de Pending-status niet oplost, verzamel dan dit voordat je hulp vraagt:

  • Volledige output van kubectl describe pod <pod-name> -n <namespace>
  • kubectl get nodes -L topology.kubernetes.io/region -L topology.kubernetes.io/zone -o wide
  • kubectl get pod <pod-name> -o yaml (volledige spec inclusief uiteindelijke tolerations en constraints)
  • Alle FailedScheduling-events: kubectl events --for pod/<pod-name> -n <namespace> --types=Warning
  • Pod-distributie per region voor de labelSelector (het commando uit stap 4 hierboven)
  • Kubernetes-versie: kubectl version
  • De Helm chart-naam en -versie, of de manifest-bron, plus de gerenderde spec

Dat is genoeg om elk overgebleven filter dat de pod weigert te diagnosticeren, inclusief gestapelde constraints (meerdere topologySpreadConstraints-entries), onvervulbare nodeAffinity, of pod anti-affinity botsingen.

Hoe voorkom je herhaling

  • Zet in elk multi-tenant cluster nodeTaintsPolicy: Honor als default op elke constraint die region- of zone-topology-keys gebruikt. Tot je control plane op Kubernetes 1.33 of nieuwer draait met de GA-defaults ingebakken, is Ignore voor multi-tenant scheduling effectief verkeerd-by-default.
  • Audit cordon-gedrag tijdens cluster-upgrades. Dezelfde deadlock duikt tijdelijk op wanneer een node gecordoned raakt en de eviction onder de oude default opnieuw probeert te schedulen.
  • Voeg nodeTaintsPolicy: Honor toe aan interne base Helm chart values, zodat engineers het niet per service hoeven te onthouden. Voor charts die je niet zelf onderhoudt, houd een Kustomize-overlay klaar die het inpatcht.
  • Cross-check dit tegen de bredere Pod Pending diagnose-flow wanneer het FailedScheduling-event resource- en constraint-clauses door elkaar mengt. Het deadlock-patroon is zeldzaam in single-tenant clusters maar routine in gedeelde clusters, ook tijdens eviction-getriggerde reschedules.

Terugkerende server- of deploymentproblemen?

Ik help teams productie betrouwbaar maken met CI/CD, Kubernetes en cloud—zodat fixes blijven en deploys geen stress meer zijn.

Bekijk DevOps consultancy

Doorzoek deze site

Begin met typen om te zoeken, of blader door de kennisbank en blog.