05-scheduling-autoscaling-availability

Controlling Where Pods Run

Node selectors, affinity, taints and topology spread — and why all three replicas landing on one node is a problem you have to ask for explicitly.

September 13, 2026
kubernetesscheduleraffinityanti-affinitytaintstolerationstopology-spread

How the Scheduler Decides

For every unscheduled pod, the scheduler runs two stages:

  1. Filtering — eliminate nodes that cannot host the pod. Not enough allocatable resource (from the requests in the previous guide), an untolerated taint, an unsatisfiable node selector, a volume that can't attach in that zone.
  2. Scoring — rank the survivors and pick the best. By default it favours spreading pods and prefers nodes that already have the image.

If filtering leaves zero nodes, the pod stays Pending — and the reason is written into its events:

bash
kubectl describe pod api-7d4f8b9c5-x2kfp
# Warning  FailedScheduling  ...  0/6 nodes are available:
#   3 Insufficient memory, 2 node(s) had untolerated taint {gpu: true}, 1 node(s) had volume node affinity conflict.

That message is a precise summary of which filter rejected which nodes. It's the first thing to read for anything Pending, and it usually contains the whole answer.

nodeSelector: the Simple Case

yaml
spec:
  nodeSelector:
    disktype: ssd

A hard requirement matched against node labels. No node has the label, the pod never schedules. It's exact-match only — no operators, no preferences.

bash
kubectl get nodes --show-labels
kubectl label node ip-10-0-1-42 disktype=ssd

Nodes carry useful built-in labels already — kubernetes.io/arch, kubernetes.io/os, topology.kubernetes.io/zone, node.kubernetes.io/instance-type.

Node Affinity: Expressive Requirements

Node affinity does what nodeSelector does, with operators and — crucially — preferences:

yaml
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: topology.kubernetes.io/zone
                operator: In
                values: [us-east-1a, us-east-1b]
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          preference:
            matchExpressions:
              - key: node.kubernetes.io/instance-type
                operator: In
                values: [m6i.xlarge]

The field names are long but they parse cleanly:

  • required... — a hard filter. Unsatisfied means Pending.
  • preferred... — a scoring hint with a weight. Unsatisfied means the pod schedules elsewhere anyway.
  • IgnoredDuringExecution — this is evaluated only at scheduling time. If a node's labels change later, a running pod is not evicted.

Operators available: In, NotIn, Exists, DoesNotExist, Gt, Lt.

Pod Affinity and Anti-Affinity

These place a pod relative to other pods rather than to node labels.

The common and important case is anti-affinity — stopping all your replicas from landing on one node:

yaml
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: api
          topologyKey: kubernetes.io/hostname

topologyKey defines what "together" means. kubernetes.io/hostname means one pod per node; topology.kubernetes.io/zone means one per zone.

⚠️

The scheduler's default scoring already spreads pods somewhat, but it's a preference, not a guarantee. On a small or busy cluster, three replicas of a Deployment can absolutely end up on one node — and that node becoming unavailable takes your entire service down. High availability across nodes is something you must ask for explicitly, via anti-affinity or topology spread constraints.

Pod affinity (the positive form) co-locates pods — useful for latency-sensitive pairs, though it's used far less often than anti-affinity.

🚨

required pod anti-affinity with topologyKey: kubernetes.io/hostname means you can never have more replicas than nodes. Scale a 3-node cluster's Deployment to 5 and two pods sit Pending forever. Use preferred unless you genuinely need the hard guarantee, or use topology spread constraints, which handle this more gracefully.

Topology Spread Constraints

The modern, and usually better, tool for distribution:

yaml
spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway
      labelSelector:
        matchLabels:
          app: api
  • maxSkew — the maximum permitted difference in pod count between topology domains. 1 means the most-loaded zone can have at most one more pod than the least.
  • whenUnsatisfiable: DoNotSchedule — hard requirement.
  • whenUnsatisfiable: ScheduleAnyway — best effort; schedule somewhere rather than stay Pending.

Why this beats anti-affinity for most availability work: it expresses even distribution rather than never together, so it degrades sensibly. With ScheduleAnyway, a cluster that can't spread perfectly still runs your pods — instead of leaving them Pending on principle.

Check yourself

A team adds `required` pod anti-affinity with topologyKey kubernetes.io/hostname to guarantee replicas land on different nodes. They later scale from 3 to 6 replicas on a 4-node cluster. What happens?

Taints and Tolerations: the Inverse

Everything so far is the pod choosing a node. Taints let the node repel pods.

bash
kubectl taint nodes gpu-node-1 workload=gpu:NoSchedule

Now nothing schedules on gpu-node-1 unless it explicitly tolerates that taint:

yaml
spec:
  tolerations:
    - key: workload
      operator: Equal
      value: gpu
      effect: NoSchedule

Three effects:

EffectMeaning
NoScheduleDon't schedule new pods here
PreferNoScheduleAvoid if possible — soft
NoExecuteDon't schedule, and evict pods already running that don't tolerate it
💡

A toleration permits but does not attract. A pod tolerating the GPU taint may still be scheduled onto an ordinary node. To both reserve nodes and direct workloads to them, pair a taint (keeps others out) with node affinity (pulls the right workloads in). Using only one is a common half-measure.

Kubernetes uses taints internally too. Control-plane nodes are tainted so general workloads stay off them, and the node controller applies NoExecute taints like node.kubernetes.io/not-ready when a node misbehaves — which is the mechanism that evicts pods from a failing node.

Priority and Preemption

When a cluster is full, priority decides who wins:

yaml
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: critical
value: 1000000
globalDefault: false
description: "Revenue-critical services"
---
spec:
  priorityClassName: critical

If a high-priority pod can't schedule, the scheduler may preempt — evict lower-priority pods to make room. Useful for guaranteeing capacity for critical services; also a good way to cause surprise evictions if priorities are handed out casually. Define a small number of classes deliberately.

Check yourself

A node is tainted `workload=gpu:NoSchedule` and a pod has the matching toleration. Where can that pod be scheduled?

What's Next

You can place pods deliberately. The next guide covers changing how many there are — horizontal and vertical autoscaling, and the tuning that stops a scale-up event from taking down the database it was meant to protect.