Steer workload placement
Most workloads need no placement rules at all: the cluster already spreads a controller's pods across nodes and zones on its own. So before you write a nodeSelector or a spread constraint, ensure you need one.
Normally you need one when a pod requires a specific kind of node it would otherwise miss, when replicas must survive the loss of a single machine, or when a workload requires dedicated hardware that nothing else should share. Everything else, the scheduler already handles. When one of those reasons applies, find your case in the table below and follow its link. Most controls have their own page; the two simplest, node selectors and placement groups, are covered later on this page.
Which control to use
| You want | Use |
|---|---|
| Pods only on a certain kind of node (GPU, bare metal, one zone) | A node selector (below) |
| Prefer a kind of node, but run elsewhere when it is full | Node affinity, soft rule. See Affinity and anti-affinity |
| Replicas on separate nodes, so one drain takes at most one | Topology spread constraints. See Spread pods with topology constraints |
| A pod near or away from specific other pods | Pod affinity / anti-affinity. See Affinity and anti-affinity |
| Nodes dedicated to one workload | A dedicated node pool with a label. See Reserve nodes with taints and tolerations |
| Cloud VMs on separate physical hosts | Placement groups (below) |
Almost everything in that table lives on the pod and pulls it toward nodes; placement groups are the outlier, acting on the cloud VMs themselves rather than on any pod. Note what none of these offer: there is no reliable way to push other pods away from a node, because a custom node taint does not survive node replacement. If you need to keep all other pods off a node, none of these controls do it; see Reserve nodes with taints and tolerations for what to use instead.
How the default spread already works
Any pod that defines no spread of its own inherits a default one, applied across two axes:
| Axis | Label | maxSkew | When it cannot spread |
|---|---|---|---|
| Nodes | kubernetes.io/hostname | 3 | Schedule anyway |
| Zones | topology.kubernetes.io/zone | 5 | Schedule anyway |
Both axes are schedule-anyway, which is the important part: a pod is never left Pending by the default. That safety has a cost in strictness. The rule is loose enough that three replicas can still land on one node, so anything that must survive a single node loss needs a tighter constraint you set yourself (Spread pods with topology constraints ). The zone label is published by the CCM on every node, though the value on bare-metal (Robot) nodes is less specific than on cloud nodes.
Pin a pod with a node selector
A nodeSelector is the bluntest and simplest steering control. It restricts a pod to nodes that carry every label you list, and nothing more:
spec:
nodeSelector:
instance.hetzner.cloud/is-root-server: "true" # bare-metal workers
spec:
nodeSelector:
autopilot.syself.com/gpu: "true" # GPU nodes
If no node matches, the pod stays Pending rather than landing somewhere unsuitable, which is usually what you want from a hard requirement. For the full label set, see Node labels and annotations .
Spread cloud VMs across physical hosts
Topology spread keeps pods on separate nodes. It says nothing about where those nodes physically live, so two of them can still sit on the same Hetzner host, and one host failure would take both. Placement groups close that gap by keeping the cloud VMs themselves on separate physical servers. Set them through topology variables:
spec:
topology:
variables:
- name: hcloudPlacementGroups
value:
- name: control-plane-spread
type: spread
- name: controlPlanePlacementGroupNameHcloud
value: control-plane-spread
See Spread nodes with placement groups and the cluster variables reference .
Tip
Placement decides where a pod lands, not how many replicas can leave at once when a node drains. Pair any rule here with a PodDisruptionBudget , which controls that behavior.