Kubernetes Administration — Level by Level›14 · Scheduling in depth

Lesson 14 of 32 · Level 2 — Operator

Scheduling in depth

Control where pods land: node labels and affinity, taints and tolerations, spreading across zones, and priority with preemption. Plus how to read a Pending pod's events.

Practitioner
Key wordsfilter & scorenodeSelectornode affinitypod anti-affinitytaintstolerationstopology spreadPriorityClass

How the scheduler decides

For every pod without a node, the scheduler runs two phases:

  1. Filter: remove nodes that can't run the pod: not enough CPU or memory for its requests, a taint it doesn't tolerate, a nodeSelector or required affinity that doesn't match, a port conflict, a volume in another zone…
  2. Score: rank the nodes that are left, using spreading, preferred affinity and resource balance. The highest score wins, and the pod is bound to that node.

If the filter phase leaves zero nodes, the pod stays Pending and an event explains why.

Picture seating guests at a wedding. First you cross out tables that can't work: full tables, the kids' table for adults, the family table for strangers (filter). Then, from the tables left, you pick the best: near friends, not all cousins at one table (score). A taint is a "Reserved" sign on a table. Only guests with a matching invitation (toleration) can sit there.

Steering pods towards nodes

nodeSelector: the simple, hard rule.

spec:
  nodeSelector:
    disktype: ssd

Node affinity: more expressive, with required (hard) and preferred (soft) rules.

spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: kubernetes.io/arch
                operator: In
                values: ["amd64"]
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 50
          preference:
            matchExpressions:
              - key: disktype
                operator: In
                values: ["ssd"]

"IgnoredDuringExecution" means that if a node's labels change later, running pods are not moved.

Keeping pods away from nodes: taints and tolerations

A taint on a node repels pods. A toleration on a pod lets it ignore that taint.

$ kubectl taint node w1 dedicated=gpu:NoSchedule
node/w1 tainted
spec:
  tolerations:
    - key: dedicated
      operator: Equal
      value: gpu
      effect: NoSchedule
Effect Meaning
NoSchedule New pods without the toleration won't be scheduled here
PreferNoSchedule Try to avoid, but allowed if needed
NoExecute Also evicts running pods that don't tolerate it

Your control-plane nodes already carry node-role.kubernetes.io/control-plane:NoSchedule, which is why normal pods never land there.

A toleration is permission, not a destination

Tolerating the GPU taint lets a pod use GPU nodes, but doesn't send it there. For truly dedicated nodes, combine a taint (keep others out) with node affinity (send these pods in).

Pods and other pods: affinity and anti-affinity

Keep replicas of the same app on different nodes, so one node failure doesn't take them all:

spec:
  affinity:
    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            topologyKey: kubernetes.io/hostname
            labelSelector:
              matchLabels:
                app: web

topologyKey defines what counts as "the same place": a hostname, a zone, a rack label. Pod affinity (the opposite) co-locates pods, such as a cache next to its app.

Spreading evenly: topology spread constraints

The modern, more precise way to spread replicas:

spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway     # or DoNotSchedule for a hard rule
      labelSelector:
        matchLabels:
          app: web

maxSkew: 1 means the busiest zone may have at most one more matching pod than the emptiest.

Priority and preemption

When the cluster is full, what matters most? A PriorityClass answers that:

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: business-critical
value: 100000
globalDefault: false
description: "Customer-facing services"

Pods with priorityClassName: business-critical are scheduled first, and if they can't fit, the scheduler may preempt (evict) lower-priority pods. Kubernetes' own critical add-ons use the built-in system-cluster-critical and system-node-critical classes.

Reading a Pending pod

$ kubectl describe pod web-7c9d8b6f5-x2kqz
...
Events:
  Type     Reason            Message
  ----     ------            -------
  Warning  FailedScheduling  0/3 nodes are available: 1 node(s) had untolerated taint
           {node-role.kubernetes.io/control-plane: }, 2 Insufficient memory.

Read it as a list: every node was filtered out, and each clause explains why. Here the control-plane node is tainted, and both workers lack the requested memory. The fixes are smaller requests, more nodes, or evicting something less important. Not removing the control-plane taint.

Try it: see each rule in action (kind lab)

  1. Label your workers as two zones: kubectl label node lab-worker topology.kubernetes.io/zone=zone-a and kubectl label node lab-worker2 topology.kubernetes.io/zone=zone-b
  2. Deploy web with 4 replicas and the topology spread constraint above. Check kubectl get pods -o wide: 2 per zone.
  3. Taint lab-worker2 with dedicated=gpu:NoExecute. Watch its pods get evicted and rescheduled onto lab-worker.
  4. Remove the taint, then give a pod nodeSelector: disktype: ssd. Read its FailedScheduling event, then label a node to fix it.

Going deeper: scheduling at scale

  • Requests drive scheduling, not usage. A node at 20% real CPU can still be "full" if requests are over-generous. Right-sizing requests (lesson 23) is the biggest scheduling win.
  • Prefer topology spread over pod anti-affinity for large Deployments. Required anti-affinity can make big rollouts unschedulable.
  • Default cluster-wide spreading can be configured in the scheduler profile. Multiple scheduler profiles, or entirely custom schedulers, exist for special workloads (batch, GPU).
  • Preemption respects PodDisruptionBudgets only on a best-effort basis. Keep priorities few and meaningful (for example critical / standard / batch), or everything becomes "critical".

Recap

  • Scheduling = filter (can it run here?) then score (where is best?).
  • nodeSelector / node affinity attract pods to nodes; taints / tolerations let nodes repel pods; use both for dedicated nodes.
  • Anti-affinity and topology spread protect you from single-node and single-zone failures.
  • PriorityClasses decide who wins when capacity runs out.
  • Pending → read the FailedScheduling event; it lists every reason.

This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.