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: ```yaml 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. - **`DoNotSchedule`** holds a pod `Pending` until it can spread. This is strict high availability: an imbalanced placement is not allowed to happen. - **`ScheduleAnyway`** places 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: ```console $ 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](/docs/hetzner/apalla/workloads/production/pod-disruption-budgets) to keep enough replicas running through the drain itself.