Spread pods with topology constraints
Topology spread constraints keep a workload's replicas apart, across nodes and across zones, so a single node replacement takes at most one of them. On a platform where node replacement is routine, this is the difference between a drain that costs one replica and a drain that costs all of them at once.
maxSkew, and the default you are overriding#
maxSkew is how uneven the spread may get: the busiest topology domain may hold at most maxSkew more matching pods than the emptiest. A pod that carries no spread rule of its own still gets one, because the cluster applies a default. But that default is deliberately loose:
| Axis | Label | Default maxSkew | On unsatisfiable |
|---|---|---|---|
| Nodes | kubernetes.io/hostname | 3 | ScheduleAnyway |
| Zones | topology.kubernetes.io/zone | 5 | ScheduleAnyway |
Read the top row honestly: maxSkew: 3 across nodes means three replicas can pile onto one node, and losing that node takes all three. For anything that has to survive a single node replacement, write your own tighter rule:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
maxSkew: 1 keeps any two nodes within one pod of each other, so replicas land on separate nodes. One caveat that trips people up: your own constraint replaces the loose default for that pod. It does not stack on top of it.
DoNotSchedule or ScheduleAnyway#
whenUnsatisfiable is where you decide what matters more: the placement, or the pod running at all.
DoNotScheduleholds a podPendinguntil it can spread. This is strict high availability: an imbalanced placement is not allowed to happen.ScheduleAnywayplaces the pod even when it cannot spread evenly. Use it when a running-but-lopsided pod beats no pod.
Zones on bare metal#
The CCM gives every node a zone label, bare metal included. The values differ in detail: cloud nodes get a datacenter like fsn1-dc14, Robot nodes get a location like fsn1. So a pool of Robot servers in one location is a single zone.
A single zone does not block scheduling. The skew is the gap between the fullest and the emptiest zone, and with only one zone that gap is always 0. A DoNotSchedule zone constraint is therefore satisfied straight away, and every replica lands.
What does block scheduling is a topologyKey that no node carries. Under DoNotSchedule those nodes are skipped entirely and every replica stays Pending. On cloud, minDomains goes the other way, requiring a minimum number of zones to actually get used before the constraint counts as satisfied.
Verify it survived a drain#
Scheduling three replicas onto three nodes is the easy half. Whether the spread holds when a node goes away, the exact event the page opened on, is the half that matters. Start with three replicas across worker-1, worker-2, and worker-3, then drain the middle one:
$ kubectl drain worker-2 --ignore-daemonsets --delete-emptydir-data
Watch where the evicted replica lands. Under maxSkew: 1 it must reschedule onto a node that does not already hold a matching pod, never doubling up on worker-1 or worker-3. If instead it sits Pending, the spread is working exactly as told: there is no node left that satisfies the rule, and your choices are to add a node or relax that one constraint to ScheduleAnyway.
Spread decides where pods land; it does not cap how many are evicted at once. Pair it with a PodDisruptionBudget to keep enough replicas running through the drain itself.
Set affinity and anti-affinity
Require or prefer certain nodes, and co-locate or separate pods from each other, when a plain node selector is not enough.
Reserve nodes with taints and tolerations
Custom node taints don't survive here, so keep general workloads off dedicated nodes with a dedicated pool, a label, and a nodeSelector instead.