Kubernetes · Core

Kubernetes Scheduling

Taints as the node's veto and the NoExecute effect that evicts what is already running, affinity as the pod's request, the topologyKey that gives anti-affinity its meaning, and why Pending is always a filtering result you can read verbatim.

22 min read Level: core → advanced Kubernetes 03 / 05
The model

HOW A POD PICKS A NODE

Filter, then score. Every Pending pod is a filter that emptied the list, and the scheduler tells you which one.

POD CREATEDspec.nodeName emptyFILTERresource fitnodeSelectortaints vs tolerationsnode/pod affinity(required)volume topologyFEASIBLE NODESsurvivors, or none → PendingSCOREaffinity (preferred)topology spread skewleast/most allocatedimage localityBINDhighest score winsAFTER BINDINGtaint added later → eviction, if NoExecutepreemption by higher priority

Scoring never rescues a pod that failed filtering — it only ranks the survivors. That is why "add more nodes" does not fix an affinity rule no node can satisfy.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is inside one scheduling cycle?
KUBE-SCHEDULERPod with nonodeNamenode + PV stateBinding objectFailedSchedulingeventQUEUEactiveQready to tryunschedulableQfailed, waiting for a cluster changebackoffQexponential retryFILTER — CAN IT FITNodeResourcesFitrequests, not limitsTaintTolerationthe node's vetoNodeAffinitythe pod's requirementVolumeBindingzone of the PVSCORE — WHERE IS BESTLeastAllocatedPodTopologySpreadeven across zonesInterPodAffinitytopologyKeyImageLocalityimage already thereCOMMITReserve → PermitBind: set spec.nodeNameone API write
Filter is a yes/no over every node and score only ranks the survivors. A Pending pod means filter returned an empty set, and the event tells you which predicate rejected how many nodes — read it verbatim.
ConnectionWhy is my pod Pending?
Pod createdactiveQno nodeNamefilter over N nodeseach plugin votes0 nodes surviveFailedSchedulingunschedulableQparked, not retriedcluster changesnode added, pod deletedrequeued → boundspec.nodeName set
A pod in unschedulableQ is not being retried on a timer — it waits for a cluster event that could plausibly change the answer. That is why adding a node fixes it instantly and waiting does not.
Core

THE TWO PLACEMENT MECHANISMS

One belongs to the node and one to the pod; they are routinely confused for each other.

A taint is on the node and repels pods. A toleration is on the pod and says "this one is allowed anyway". Note the direction: a toleration does not attract a pod to a node — it only removes an objection. Wanting a pod to land on specific nodes needs nodeSelector or affinity as well.

EffectNew podsAlready-running pods
NoScheduleRejected unless toleratingLeft alone
PreferNoScheduleAvoided if possibleLeft alone
NoExecuteRejected unless toleratingEvicted unless tolerating

That last row is the one that causes surprise outages: adding a NoExecute taint to a node evicts everything on it that does not tolerate it, immediately.

The taints the cluster adds by itself

Kubernetes taints nodes automatically on conditions — not-ready, unreachable, memory-pressure, disk-pressure, pid-pressure, unschedulable. The first two are NoExecute with a default 300-second toleration injected into every pod, which is why a node going unreachable takes five minutes to shed its pods. Shortening that tolerationSeconds for latency-sensitive workloads is a real tuning knob, and one of the few places where a default is too conservative rather than too aggressive.

why the pod will not schedule, or just got evicted
$ kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints
NAME TAINTS
gpu-01 [map[effect:NoSchedule key:nvidia.com/gpu value:true]]
$ kubectl describe pod trainer | grep -A4 Events
0/12 nodes are available: 11 node(s) had untolerated taint
{nvidia.com/gpu: true}, 1 Insufficient nvidia.com/gpu.
toleration alone does not PULL it there — pair with a selector:
tolerations: [{key: nvidia.com/gpu, operator: Exists, effect: NoSchedule}]
nodeSelector: { accelerator: nvidia }
faster failover than the 300s default, per pod:
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 30

Where taints are the node's veto, affinity is the pod's request. Two families:

  • nodeAffinity — about node labels. A richer nodeSelector, with operators (In, NotIn, Exists, Gt, Lt).
  • podAffinity / podAntiAffinity — about other pods. "Near the cache" or, far more commonly, "not on the same node as my other replicas".

Each comes in two strengths, and the names are long enough that people copy them without reading: requiredDuringSchedulingIgnoredDuringExecution is a hard filter — unmet means Pending forever. preferredDuringSchedulingIgnoredDuringExecution is a scoring hint — unmet just means a lower score. IgnoredDuringExecution in both names is a promise: once bound, a pod is never moved because the rule stopped holding.

topologyKey is the whole meaning of anti-affinity

Anti-affinity says "not co-located", and topologyKey defines co-located. kubernetes.io/hostname means one per node. topology.kubernetes.io/zone means one per zone — which, with three replicas and three zones, is exactly what you want, and with four replicas leaves one Pending forever if the rule is required.

Prefer topologySpreadConstraints for the common case

"Spread my replicas evenly" is better expressed with topologySpreadConstraints than with anti-affinity: it takes a maxSkew, so it degrades gracefully instead of wedging, and whenUnsatisfiable: ScheduleAnyway gives you best-effort spreading that cannot cause a Pending pod.

spread that degrades instead of wedging
brittle: 4 replicas, 3 zones, required -> one Pending forever
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- topologyKey: topology.kubernetes.io/zone
labelSelector: { matchLabels: { app: api } }
better: even spread, tolerates imbalance rather than failing
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector: { matchLabels: { app: api } }
where did they actually land?
$ kubectl get pods -l app=api -o custom-columns=\
POD:.metadata.name,NODE:.spec.nodeName --no-headers | sort -k2
$ kubectl get pods -l app=api -o json | jq -r '.items[].spec.nodeName' \
| xargs -I{} kubectl get node {} -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}' | sort | uniq -c
IgnoredDuringExecution means drift is permanent

Scheduling rules are evaluated once, at binding. If all three replicas end up in one zone because the other two were briefly full, they stay there after capacity returns — nothing rebalances them. A rolling restart is the rebalance, and descheduler is the tool if you want it continuous.

In practice

ADVANCED TROUBLESHOOTING

Pending always means the same thing: filtering produced an empty list. The scheduler says which predicate failed and on how many nodes, and that sentence is the entire diagnosis — it is just easy to skim past.

Message fragmentCauseFix
Insufficient cpu / memoryNo node has that much free — requests, not usageLower requests, or add capacity
had untolerated taintNode repels itAdd the toleration, and a selector to attract it
didn't match Pod's node affinity/selectorNo node carries the labelkubectl get nodes --show-labels
didn't match pod anti-affinity rulesIts own replicas are in the wayRelax to preferred, or use topology spread
node(s) had volume node affinity conflictThe PV is in another zoneWaitForFirstConsumer on the StorageClass
node(s) were unschedulableCordonedkubectl uncordon
exceeded quotaResourceQuota refused it before schedulingkubectl describe quota -n <ns>

Requests are what scheduling uses — not usage

A node showing 30% CPU in top can be 100% allocated and refuse new pods, because the scheduler adds up requests, not actual consumption. That gap is the most common "we have plenty of capacity, why is it Pending" confusion, and kubectl describe node shows both numbers side by side.

allocated versus used, which are different numbers
$ kubectl describe node worker-04 | grep -A8 'Allocated resources'
Resource Requests Limits
cpu 7800m (97%) 14 (175%)
memory 29Gi (92%) 48Gi (152%)
97% ALLOCATED. Meanwhile actual usage:
$ kubectl top node worker-04
NAME CPU(cores) CPU% MEMORY MEMORY%
worker-04 2410m 30% 11Gi 34%
-> over-requested workloads, not a capacity problem. Right-size the requests.
$ kubectl get pods -A -o json | jq -r '.items[]
| select(.spec.nodeName=="worker-04")
| "\(.spec.containers[].resources.requests.cpu // "-")\t\(.metadata.name)"' | sort -rn | head
The Events tail is the answer, verbatim

kubectl describe pod ends with a line of the form 0/12 nodes are available: 8 Insufficient cpu, 4 node(s) had untolerated taint. That accounts for every node in the cluster and why each one was excluded. There is rarely anything to deduce beyond reading it.

Reference

CHEATSHEET

CommandWhat it answers
kubectl describe pod <p>Which predicate failed, on how many nodes
kubectl get nodes --show-labelsWhat affinity rules can actually match
kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taintsEvery taint in one view
kubectl describe node <n> | grep -A8 AllocatedRequested versus capacity — what scheduling uses
kubectl top nodeActual usage, for contrast
kubectl taint node <n> key=value:NoScheduleRepel new pods
kubectl taint node <n> key-Remove a taint (trailing dash)
kubectl cordon / uncordonStop or resume scheduling
kubectl get pods -o wide --sort-by=.spec.nodeNameWhere everything landed
kubectl get pods --field-selector status.phase=Pending -AEverything stuck, cluster-wide
kubectl get priorityclassWhat can preempt what