Lesson 47 of 66 · 18 min read · Updated

Taints, Tolerations and Topology Spread Constraints

In the previous article you attracted Pods to nodes with selectors and affinity, and you pushed replicas apart with pod anti-affinity. All of those rules live on the Pod. The node had no say.

This article covers two tools that complete the picture. Taints let a node push Pods away unless they explicitly tolerate it, which is how you reserve nodes for special workloads. Topology spread constraints let you say exactly how uneven a distribution of replicas is allowed to be, which is what pod anti-affinity was trying to do with a much blunter instrument. Along the way you will hit a real gotcha that makes a perfectly reasonable spread rule leave Pods Pending on almost every kind cluster.

We will cover the following topics:

  1. What a taint and a toleration are, and why a toleration is not an invitation
  2. The three taint effects: NoSchedule, PreferNoSchedule and NoExecute, including tolerationSeconds
  3. The taint every control-plane node carries, and the tolerations Kubernetes adds to every Pod
  4. The dedicated nodes pattern: taint, label, toleration and affinity together
  5. Topology spread constraints: maxSkew, topologyKey, whenUnsatisfiable, minDomains, nodeTaintsPolicy and matchLabelKeys
  6. When to choose spread constraints over pod anti-affinity

Technical Requirements

  1. A multi-node cluster. The examples use Docker Desktop's Kubernetes with the kind provisioner: desktop-control-plane plus the workers desktop-worker, desktop-worker2 and desktop-worker3, Kubernetes v1.36. A plain kind cluster with one control-plane and three workers behaves the same with kind-* node names.
  2. kubectl configured for that cluster.
  3. The node-info-api:1.0 image from the previous article. Any small image that keeps running works too.
  4. Permission to add labels and taints to nodes. Every change in this article is removed again in the last step.

Core Concepts

Taints Repel, Tolerations Permit

A taint is a property of a node with three parts: key=value:effect. It says: "Pods may not come here unless they tolerate this."

A toleration is a property of a Pod that matches a taint. It says: "I accept that taint."

The important word is accept. A toleration does not attract a Pod to the tainted node. It only removes one obstacle. A Pod that tolerates a taint can still be scheduled on any untainted node, and usually will be. To send Pods to a node you still need a selector or node affinity from the previous article. You will see this clearly in the step-by-step example.

MechanismLives onEffect
Node affinity / nodeSelectorPodPulls the Pod towards matching nodes
TaintNodePushes away every Pod that does not tolerate it
TolerationPodAllows (does not require) the Pod onto a tainted node

The Three Effects

EffectNew Pods without a tolerationPods already running without a toleration
NoScheduleNot scheduled on the nodeKeep running
PreferNoScheduleScheduler avoids the node if it canKeep running
NoExecuteNot scheduled on the nodeEvicted

NoSchedule is the hard, everyday choice. PreferNoSchedule is a soft version, useful for "please move off this node soon" during maintenance planning.

NoExecute is different from the other two: it acts on Pods that are already running. When you add a NoExecute taint, every Pod on that node without a matching toleration is evicted immediately. That makes it a powerful but dangerous tool on a shared cluster, which is why this article explains it but does not apply it. A Pod can soften NoExecute with tolerationSeconds:

tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 300

This means: "if the node gets this taint, let me stay for 300 more seconds, then evict me". Without tolerationSeconds a matching toleration lets the Pod stay forever.

Taints Kubernetes Adds for You

Two kinds of taints exist on most clusters before you add any.

The control-plane taint, node-role.kubernetes.io/control-plane:NoSchedule, keeps application Pods off the nodes that run the API server, etcd and the scheduler. That is why the scheduler messages in the previous article always said "1 node(s) had untolerated taint(s)".

The node lifecycle taints are added automatically by the node controller when something goes wrong with a node. The most common ones are:

TaintAdded when
node.kubernetes.io/not-readyThe node's Ready condition is False
node.kubernetes.io/unreachableThe node controller cannot reach the node
node.kubernetes.io/memory-pressureThe kubelet reports memory pressure
node.kubernetes.io/disk-pressureThe kubelet reports disk pressure
node.kubernetes.io/unschedulableThe node is cordoned

The first two use NoExecute. To avoid evicting everything the moment a node blips, Kubernetes adds a toleration for both, with tolerationSeconds: 300, to every Pod that does not declare its own. That is where the familiar "Pods take about five minutes to move off a dead node" behaviour comes from. You can shorten it per workload by declaring those tolerations yourself with a smaller value.

Matching Rules for Tolerations

Toleration fieldMeaning
keyThe taint key to match. Empty key with operator: Exists matches every taint.
operator: EqualKey and value must both match (the default operator).
operator: ExistsOnly the key must match; do not set value.
effectMust match the taint's effect. Empty matches all effects.
tolerationSecondsOnly for NoExecute: how long to stay after the taint appears.

DaemonSets that must run on every node, such as log collectors and CNI agents, typically carry a toleration with operator: Exists and no key, which tolerates everything.

The Dedicated Nodes Pattern

To reserve a node for one kind of workload, you need both directions:

  1. A taint on the node keeps everyone else off.
  2. A label on the node identifies it.
  3. A toleration on the special workload lets it in.
  4. Node affinity (or a nodeSelector) on the special workload makes it go there and nowhere else.

Skip step 4 and your special workload spreads across the whole cluster. Skip step 1 and ordinary workloads fill up your special node. You will try both halves below.

Topology Spread Constraints

Pod anti-affinity can only say "at most one per domain" (required) or "please avoid" (preferred). Topology spread constraints let you specify how much imbalance you allow.

topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: checkout-api
FieldMeaning
topologyKeyNode label that defines the domains, as in pod affinity.
labelSelectorWhich Pods are counted in each domain. Usually your own app label.
maxSkewThe largest allowed difference between the domain with the most matching Pods and the global minimum.
whenUnsatisfiableDoNotSchedule makes it a filter (hard). ScheduleAnyway makes it a score (soft).
minDomainsWith DoNotSchedule, the minimum number of domains you expect. If fewer exist, the global minimum is treated as 0.
nodeAffinityPolicyHonor (default) counts only nodes that match the Pod's node affinity/selector. Ignore counts all nodes.
nodeTaintsPolicyIgnore (default) counts every node, even tainted ones the Pod cannot use. Honor counts only untainted nodes and tainted nodes the Pod tolerates.
matchLabelKeysExtra Pod label keys whose values are taken from the incoming Pod and ANDed with labelSelector. Typically pod-template-hash.

The skew calculation is simple. Count matching Pods in each eligible domain. Find the smallest count, the global minimum. A domain may receive a new Pod only if, after adding it, its count minus the global minimum is not more than maxSkew.

Nodes that do not have the topologyKey label at all are ignored. A Pod can have several constraints; all DoNotSchedule ones must hold at the same time.

Spread Constraints vs Pod Anti-Affinity

NeedPod anti-affinityTopology spread
Never two replicas on one nodeRequired anti-affinitymaxSkew: 1 works only while replicas ≤ nodes
Even spread with more replicas than domainsNot possiblemaxSkew: 1 gives 3/2/2 for 7 replicas
Soft preferencePreferred anti-affinity (no guarantee)ScheduleAnyway (scored by skew)
Spread across zones and nodes at onceTwo separate terms, no balance guaranteeTwo constraints with different keys
Rolling updatesNew and old Pods count togethermatchLabelKeys: [pod-template-hash] separates them

For "spread my replicas evenly", topology spread constraints are the modern answer. Keep required pod anti-affinity for the narrow case where two copies on one node are genuinely forbidden.

Hands-On: Kubernetes Commands

Show the taints of every node

custom-columns turns the taints array into readable columns.

kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS:.spec.taints[*].key,EFFECT:.spec.taints[*].effect'

Add a taint

The format is key=value:effect.

kubectl taint node desktop-worker3 lesson.dotnetacademy.dev/dedicated=batch:NoSchedule

Remove a taint

Same text with a trailing -.

kubectl taint node desktop-worker3 lesson.dotnetacademy.dev/dedicated=batch:NoSchedule-

See the tolerations Kubernetes added to a Pod

Run this on any Pod you did not give tolerations to.

kubectl get pod <pod> -o jsonpath='{range .spec.tolerations[*]}{.key}{" "}{.operator}{" "}{.effect}{" "}{.tolerationSeconds}{"\n"}{end}'

Count replicas per node

The quickest way to check a spread.

kubectl get pods -n sched-taints -l app=checkout-api -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c

Read the documentation for a spread field

Shows the default and whether the field is beta in your cluster version.

kubectl explain pod.spec.topologySpreadConstraints.nodeTaintsPolicy

Step-by-Step Example

The Scenario

Your shop runs nightly invoice generation, a CPU-heavy batch job that should have a worker to itself so it never slows down customer-facing APIs. You will reserve desktop-worker3 for it. Then you will make the checkout and payments APIs spread evenly across your three workers, using the same pretend zones as in the previous article: desktop-worker is zone-a, desktop-worker2 is zone-b, desktop-worker3 is zone-c.

To keep the manifests short, every Deployment below uses the same container block; only the spec.template.spec scheduling fields change:

containers:
- name: app
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

Step 1 — Look at the Existing Taints and Default Tolerations

kubectl create namespace sched-taints
kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS:.spec.taints[*].key,EFFECT:.spec.taints[*].effect'
NAME TAINTS EFFECT
desktop-control-plane node-role.kubernetes.io/control-plane NoSchedule
desktop-worker <none> <none>
desktop-worker2 <none> <none>
desktop-worker3 <none> <none>

Run a throwaway Pod without any tolerations and look at what it got anyway:

kubectl run probe -n sched-taints --image=node-info-api:1.0 --image-pull-policy=IfNotPresent
kubectl get pod probe -n sched-taints -o jsonpath='{range .spec.tolerations[*]}{.key}{" "}{.operator}{" "}{.effect}{" "}{.tolerationSeconds}{"\n"}{end}'
node.kubernetes.io/not-ready Exists NoExecute 300
node.kubernetes.io/unreachable Exists NoExecute 300

Those two lines are the five-minute grace period described earlier, added by an admission plugin.

kubectl delete pod probe -n sched-taints

Step 2 — Taint and Label the Batch Node

kubectl taint node desktop-worker3 lesson.dotnetacademy.dev/dedicated=batch:NoSchedule
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/dedicated=batch
kubectl describe node desktop-worker3 | grep Taints
Taints: lesson.dotnetacademy.dev/dedicated=batch:NoSchedule

Using the same key for the taint and the label is a common convention. It makes the pairing obvious to anyone reading the node.

Step 3 — Three Workloads, Three Behaviours

First an ordinary API with no tolerations, web-api, six replicas:

apiVersion: apps/v1
kind: Deployment
metadata:
name: web-api
namespace: sched-taints
spec:
replicas: 6
selector:
matchLabels:
app: web-api
template:
metadata:
labels:
app: web-api
spec:
containers:
- name: web-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

Second, report-worker, which tolerates the taint but has no affinity:

spec:
tolerations:
- key: lesson.dotnetacademy.dev/dedicated
operator: Equal
value: batch
effect: NoSchedule

Third, invoice-batch, with the toleration and required node affinity to the label:

apiVersion: apps/v1
kind: Deployment
metadata:
name: invoice-batch
namespace: sched-taints
spec:
replicas: 4
selector:
matchLabels:
app: invoice-batch
template:
metadata:
labels:
app: invoice-batch
spec:
tolerations:
- key: lesson.dotnetacademy.dev/dedicated
operator: Equal
value: batch
effect: NoSchedule
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: lesson.dotnetacademy.dev/dedicated
operator: In
values: ["batch"]
containers:
- name: invoice-batch
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

Apply all three and count where the replicas went:

kubectl apply -f web-api.yaml -f report-worker.yaml -f invoice-batch.yaml
for d in web-api report-worker invoice-batch; do
echo "== $d"
kubectl get pods -n sched-taints -l app=$d -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c
done
== web-api
3 desktop-worker
3 desktop-worker2
== report-worker
2 desktop-worker
2 desktop-worker2
2 desktop-worker3
== invoice-batch
4 desktop-worker3

This one output summarises the whole pattern:

  1. web-api has no toleration, so the taint keeps it off desktop-worker3.
  2. report-worker tolerates the taint, so desktop-worker3 is allowed, but nothing pulls it there. It spreads over all three workers. A toleration alone does not make a dedicated node.
  3. invoice-batch tolerates the taint and requires the label, so all four replicas land on desktop-worker3 and nowhere else.
kubectl delete deploy web-api report-worker invoice-batch -n sched-taints

Step 4 — PreferNoSchedule Is a Nudge

Pretend desktop-worker2 is scheduled for maintenance tomorrow. You want new Pods to avoid it, but not at any cost.

kubectl taint node desktop-worker2 lesson.dotnetacademy.dev/maintenance=soon:PreferNoSchedule

Deploy a fresh six-replica API, storefront-api, identical to web-api apart from its name:

kubectl apply -f storefront-api.yaml
kubectl get pods -n sched-taints -l app=storefront-api -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c
6 desktop-worker

desktop-worker3 is still hard-tainted for batch, and desktop-worker2 is softly tainted, so everything went to desktop-worker. If desktop-worker had been full, the scheduler would have used desktop-worker2 rather than leave Pods Pending.

Remove the maintenance taint and the batch reservation:

kubectl taint node desktop-worker2 lesson.dotnetacademy.dev/maintenance=soon:PreferNoSchedule-
kubectl delete deploy storefront-api -n sched-taints
kubectl taint node desktop-worker3 lesson.dotnetacademy.dev/dedicated=batch:NoSchedule-
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/dedicated-

Step 5 — What NoExecute Would Do

This step is reading only. On a shared cluster, a NoExecute taint evicts every Pod on the node that lacks a matching toleration, including other people's workloads, so do not run it on a cluster you share.

# Do not run this on a shared cluster
kubectl taint node desktop-worker2 lesson.dotnetacademy.dev/maintenance=now:NoExecute

Every Pod on desktop-worker2 without a toleration for that taint would be evicted at once. A Pod that should survive briefly, for example to finish in-flight requests, declares:

tolerations:
- key: lesson.dotnetacademy.dev/maintenance
operator: Equal
value: now
effect: NoExecute
tolerationSeconds: 60

It would stay for 60 seconds and then be evicted. For planned maintenance, kubectl drain is usually the better tool because it respects PodDisruptionBudgets, which the next article covers. NoExecute taints do not.

Step 6 — Spread Across Zones with maxSkew

Label the workers with the pretend zones:

kubectl label node desktop-worker lesson.dotnetacademy.dev/zone=zone-a
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/zone=zone-b
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/zone=zone-c

Now the checkout API, seven replicas, with a hard spread rule:

apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-api
namespace: sched-taints
spec:
replicas: 7
selector:
matchLabels:
app: checkout-api
template:
metadata:
labels:
app: checkout-api
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: lesson.dotnetacademy.dev/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: checkout-api
containers:
- name: checkout-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
kubectl apply -f checkout-api.yaml
kubectl get pods -n sched-taints -l app=checkout-api -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c
2 desktop-worker
2 desktop-worker2
3 desktop-worker3

Seven replicas in three zones: 3/2/2, skew exactly 1. The control-plane node has no zone label, so it is not a domain at all. Pod anti-affinity could not have produced this: required anti-affinity stops at three, and preferred anti-affinity gives no guarantee.

kubectl delete deploy checkout-api -n sched-taints

Step 7 — The Control-Plane Gotcha

Zone labels are not always available, so it is tempting to spread across nodes with kubernetes.io/hostname instead. Try it with the payments API, six replicas:

topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payments-api
kubectl apply -f payments-api.yaml
kubectl get pods -n sched-taints -l app=payments-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
payments-api-55d49694d-2f4kh 0/1 Pending 0 8s <none> <none>
payments-api-55d49694d-5prgv 0/1 Pending 0 8s <none> <none>
payments-api-55d49694d-9jjcn 1/1 Running 0 8s 10.244.1.51 desktop-worker2
payments-api-55d49694d-cwqf8 1/1 Running 0 8s 10.244.2.48 desktop-worker
payments-api-55d49694d-fpc9v 0/1 Pending 0 8s <none> <none>
payments-api-55d49694d-kx5wq 1/1 Running 0 8s 10.244.4.51 desktop-worker3

Three Pods run, one per worker, and three are stuck, although each worker has plenty of room.

kubectl describe pod payments-api-55d49694d-2f4kh -n sched-taints
Warning FailedScheduling 8s default-scheduler 0/4 nodes are available: 1 node(s) had untolerated taint(s), 3 node(s) didn't match pod topology spread constraints. no new claims to deallocate, preemption: 0/4 nodes are available: 1 Preemption is not helpful for scheduling, 3 No preemption victims found for incoming pod.

Here is why. Every node has a kubernetes.io/hostname label, including the control-plane node. The default nodeTaintsPolicy is Ignore, so the tainted control-plane node still counts as a domain. It has zero payments Pods, which makes the global minimum 0. With maxSkew: 1, no worker may hold more than one replica. The scheduler cannot put a Pod on the control-plane node because of its taint, so the minimum never rises and the remaining replicas wait forever.

The fix is one line: tell the constraint to honour taints, so only nodes the Pod could actually use count as domains.

topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
nodeTaintsPolicy: Honor
labelSelector:
matchLabels:
app: payments-api
kubectl apply -f payments-api-honor.yaml
kubectl get pods -n sched-taints -l app=payments-api -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c
2 desktop-worker
2 desktop-worker2
2 desktop-worker3

The same trap appears on any cluster with tainted nodes that carry the topology label: GPU pools, dedicated batch nodes like the one from Step 2, or nodes that are cordoned. If a hostname-based spread leaves Pods Pending while nodes look empty, check nodeTaintsPolicy first.

kubectl delete deploy payments-api -n sched-taints

Step 8 — minDomains: Refuse to Pile Up When Domains Are Missing

Suppose your production cluster normally has four zones and you never want more than one ledger replica per zone above the others. If a zone disappears, you would rather have Pods wait than squeeze extra replicas into the surviving zones. minDomains expresses that.

apiVersion: apps/v1
kind: Deployment
metadata:
name: ledger-api
namespace: sched-taints
spec:
replicas: 5
selector:
matchLabels:
app: ledger-api
template:
metadata:
labels:
app: ledger-api
spec:
topologySpreadConstraints:
- maxSkew: 1
minDomains: 4
topologyKey: lesson.dotnetacademy.dev/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: ledger-api
containers:
- name: ledger-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
kubectl apply -f ledger-api.yaml
kubectl get pods -n sched-taints -l app=ledger-api -o custom-columns=NAME:.metadata.name,STATUS:.status.phase,NODE:.spec.nodeName
NAME STATUS NODE
ledger-api-5bd8dfb965-c9jvg Running desktop-worker2
ledger-api-5bd8dfb965-g6sgc Running desktop-worker3
ledger-api-5bd8dfb965-jnwdb Running desktop-worker
ledger-api-5bd8dfb965-k7jdt Pending <none>
ledger-api-5bd8dfb965-smdxh Pending <none>

Only three zones exist, fewer than minDomains: 4, so the scheduler treats the global minimum as 0. With maxSkew: 1 each zone may hold one replica, and the other two wait until a fourth zone appears. Without minDomains you would get 2/2/1 instead. Which behaviour is right depends on whether you prefer availability now or a guaranteed spread later. minDomains only applies with DoNotSchedule.

kubectl delete deploy ledger-api -n sched-taints

Step 9 — Zones and Nodes Together, Safe for Rolling Updates

In real clusters you usually want two constraints: a hard spread across zones and a soft spread across nodes inside each zone. You also want each rollout to spread its own Pods evenly, which is what matchLabelKeys does.

apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: sched-taints
spec:
replicas: 6
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: lesson.dotnetacademy.dev/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: orders-api
matchLabelKeys:
- pod-template-hash
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: orders-api
matchLabelKeys:
- pod-template-hash
containers:
- name: orders-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

pod-template-hash is the label a Deployment puts on Pods of each ReplicaSet. Without matchLabelKeys, during a rolling update the scheduler counts old and new Pods together, so the new ReplicaSet can end up lopsided once the old Pods are gone. With it, only Pods from the same revision are counted.

kubectl apply -f orders-api.yaml
kubectl set env deploy/orders-api -n sched-taints RELEASE=2
kubectl rollout status deploy/orders-api -n sched-taints
kubectl get pods -n sched-taints -l app=orders-api -o custom-columns=HASH:.metadata.labels.pod-template-hash,NODE:.spec.nodeName --no-headers | sort | uniq -c
2 5cf8db6845 desktop-worker
2 5cf8db6845 desktop-worker2
2 5cf8db6845 desktop-worker3

After the rollout the new revision is balanced 2/2/2. You can see how the API server applied matchLabelKeys by reading the stored constraint on a Pod:

kubectl get pod -n sched-taints -l app=orders-api -o jsonpath='{.items[0].spec.topologySpreadConstraints[0].labelSelector}'
{"matchExpressions":[{"key":"pod-template-hash","operator":"In","values":["5cf8db6845"]}],"matchLabels":{"app":"orders-api"}}

The hash value was merged into the selector when the Pod was created. On Kubernetes v1.36, kubectl explain still describes matchLabelKeys as a beta field behind the MatchLabelKeysInPodTopologySpread feature gate, which is enabled by default.

Step 10 — Clean Up

kubectl delete namespace sched-taints
kubectl label node desktop-worker lesson.dotnetacademy.dev/zone-
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/zone-
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/zone-
kubectl get nodes -o custom-columns='NAME:.metadata.name,TAINTS:.spec.taints[*].key'

The last command should show only the control-plane taint.

Summary

  1. A taint on a node repels Pods; a toleration on a Pod permits it onto that node but never attracts it there.
  2. NoSchedule blocks new Pods, PreferNoSchedule only discourages them, and NoExecute also evicts running Pods, optionally after tolerationSeconds.
  3. Every Pod gets default NoExecute tolerations for not-ready and unreachable with 300 seconds, which is why Pods take five minutes to leave a failed node.
  4. A dedicated node needs four pieces: taint and label on the node, toleration and node affinity on the workload.
  5. Topology spread constraints limit imbalance with maxSkew over domains defined by topologyKey. DoNotSchedule is a filter, ScheduleAnyway is a score.
  6. With the default nodeTaintsPolicy: Ignore, tainted nodes such as the control plane still count as domains and can leave Pods Pending. Set nodeTaintsPolicy: Honor for hostname spreads.
  7. minDomains keeps replicas waiting rather than piling up when fewer domains exist than expected.
  8. matchLabelKeys with pod-template-hash keeps each rollout balanced on its own.
  9. Prefer spread constraints over pod anti-affinity for "spread evenly"; keep required anti-affinity for "never together".

In the next article we look at what happens when the cluster is full: PriorityClasses decide which Pods win, preemption decides who has to leave, and PodDisruptionBudgets protect your API during node maintenance.