I once ran a “highly available” service with six replicas that, thanks to nothing enforcing distribution, ended up with five of them on a single node. When that node got drained for a routine kernel patch, we lost 83% of capacity in one shot. The Deployment’s replicas: 6 gave a false sense of safety — nothing was actually telling the scheduler to spread them out. That gap is exactly what Pod Topology Spread Constraints close.
The Problem They Solve
Kubernetes’ default scheduler makes reasonably good placement decisions, but “reasonably good” isn’t the same as “evenly distributed across failure domains.” Without explicit constraints, it’s entirely possible — even common under certain load patterns — for most replicas of a Deployment to cluster onto a small number of nodes or a single availability zone.
Pod Topology Spread Constraints let you define, declaratively, how pods should be distributed across a chosen topology domain — node, zone, region, or any custom label you use to group nodes.
Core Concepts
topologyKey— the node label that defines your topology domain (e.g.,kubernetes.io/hostnamefor per-node spread,topology.kubernetes.io/zonefor per-AZ spread).maxSkew— the maximum allowed difference between the pod count in the domain with the most matching pods and the domain with the fewest.whenUnsatisfiable— what the scheduler does if the constraint can’t be met:DoNotSchedule(hard) orScheduleAnyway(soft).labelSelector— which pods count toward the spread calculation (usually matching the Deployment’s own pod template labels).
How maxSkew Actually Works
This is the part that confuses people most. Say you have 3 zones and 9 replicas, with maxSkew: 1:
- Ideal: 3/3/3 — skew is 0.
- Acceptable: 3/3/2 — the difference between max (3) and min (2) is 1, which satisfies
maxSkew: 1. - Rejected: 4/3/2 — skew is 2, which violates
maxSkew: 1, so withDoNotSchedulethe 4th pod in that zone can’t be scheduled there.
Skew is evaluated per new pod placement, not as a one-time check — the scheduler continuously enforces it as pods come and go.
Basic Example: Spread Across Nodes
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
replicas: 6
selector:
matchLabels:
app: api-service
template:
metadata:
labels:
app: api-service
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api-service
containers:
- name: api
image: myregistry.io/api-service:3.1.0
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "1"
memory: "1Gi"
kubectl apply -f api-service.yaml
kubectl get pods -o wide -l app=api-service
NAME NODE
api-service-6c8d7f9b5-abc12 worker-node-1
api-service-6c8d7f9b5-def34 worker-node-2
api-service-6c8d7f9b5-ghi56 worker-node-3
api-service-6c8d7f9b5-jkl78 worker-node-1
api-service-6c8d7f9b5-mno90 worker-node-2
api-service-6c8d7f9b5-pqr12 worker-node-3
Even 2/2/2 spread across three nodes — no node has more than a 1-pod skew from any other.
Zone-Level Spread for High Availability
For genuine AZ-level resilience, spread on the zone topology key instead:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
replicas: 6
selector:
matchLabels:
app: payment-service
template:
metadata:
labels:
app: payment-service
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payment-service
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: payment-service
containers:
- name: payment
image: myregistry.io/payment-service:1.8.0
resources:
requests:
cpu: "500m"
memory: "1Gi"
This combines two constraints: a hard requirement to spread evenly across zones (critical for AZ-level resilience), and a soft preference to also spread across individual nodes within a zone (nice-to-have, but won’t block scheduling if capacity is tight).
whenUnsatisfiable: ScheduleAnyway and minDomains
ScheduleAnyway tells the scheduler to still place the pod even if it violates the skew, just scored lower in preference — useful when strict enforcement would leave pods permanently Pending during legitimate capacity constraints (e.g., a zone temporarily out of capacity during a scale event).
topologySpreadConstraints:
- maxSkew: 2
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
minDomains: 3
labelSelector:
matchLabels:
app: payment-service
minDomains (available in stable Kubernetes releases from 1.30 onward) tells the scheduler how many topology domains it should expect to exist, even if fewer are currently visible — useful for zone autoscaling scenarios where a zone’s node pool might currently be scaled to zero.
Combining with Pod Anti-Affinity (When You’d Use Which)
Topology Spread Constraints largely superseded the older pattern of using podAntiAffinity for spread, because anti-affinity’s requiredDuringScheduling mode can be too rigid (an all-or-nothing filter) and doesn’t have a clean concept of “allowed skew.” Still, pod affinity/anti-affinity remains useful for co-locating or separating specific different workloads (e.g., “never run this cache pod on the same node as this other database pod”) rather than spreading replicas of the same Deployment.
# Anti-affinity: keep cache and database off the same node (different workloads)
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: database
topologyKey: kubernetes.io/hostname
Rule of thumb: use Topology Spread Constraints for distributing replicas of the same workload; use pod anti-affinity for keeping distinct workloads apart.
Multiple Constraints and How They Combine
You can stack multiple topologySpreadConstraints entries, and all must be satisfied simultaneously — they’re ANDed, not ORed. This is powerful but also where misconfiguration commonly causes pods to get permanently stuck Pending:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
With both constraints as DoNotSchedule, if your cluster has fewer nodes than replicas require for both zone and node-level even spread, pods will queue up Pending. Use ScheduleAnyway for the secondary (node-level) constraint in resource-constrained clusters.
Cluster-Level Default Constraints
For cluster admins who want spread behavior applied automatically without every team remembering to add it, Kubernetes supports default topology spread constraints via the scheduler configuration:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- pluginConfig:
- name: PodTopologySpread
args:
defaultConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
defaultingType: List
This applies to any pod that doesn’t define its own topology spread constraints — a good safety net for clusters where teams might forget.
Real-World Use Case: StatefulSet Spread
Databases and other stateful workloads benefit enormously from this — losing an entire replica set to one bad node is far worse than losing a stateless web pod:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cassandra
spec:
serviceName: cassandra
replicas: 3
selector:
matchLabels:
app: cassandra
template:
metadata:
labels:
app: cassandra
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: cassandra
containers:
- name: cassandra
image: cassandra:5.0
resources:
requests:
cpu: "2"
memory: "4Gi"
volumeMounts:
- name: data
mountPath: /var/lib/cassandra
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi
For a 3-replica Cassandra ring across 3 zones, this guarantees each replica lands in its own zone — matching Cassandra’s own replication-factor assumptions about failure independence.
Node Affinity Interaction: Restricting the Eligible Domain Set
Topology Spread Constraints only spread across domains that already contain schedulable nodes matching the pod’s other constraints — they don’t create new domains or force pods into zones that fail affinity rules. Combining spread constraints with node affinity narrows the domain set before spreading is calculated:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- us-east-1a
- us-east-1b
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: restricted-service
Here, even though the cluster might span three zones, the affinity rule limits eligible nodes to two of them, and the spread constraint then enforces even distribution across just those two — useful when a workload has a licensing or data-residency reason to avoid a specific zone while still needing resilience across the zones it is allowed to use.
Weighted Preference vs Strict Spread
It’s worth being explicit about a subtlety: Topology Spread Constraints with whenUnsatisfiable: DoNotSchedule are a hard filtering constraint, not a scoring preference — this is different from preferredDuringSchedulingIgnoredDuringExecution node affinity, which only ever influences scoring. If you want spread to influence scheduling as a soft preference rather than a hard requirement, ScheduleAnyway is the correct setting, and the scheduler incorporates it into its scoring phase alongside other soft preferences like preferred node affinity:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web-frontend
This distinction — hard filter versus soft scoring signal — is the same conceptual split that runs through node affinity’s required/preferred modes and taints’ NoSchedule/PreferNoSchedule effects. Recognizing this shared pattern across Kubernetes’ scheduling primitives makes it much easier to reason about how multiple constraints interact on a single pod spec.
Troubleshooting
kubectl describe pod <pod-name>
Warning FailedScheduling default-scheduler 0/6 nodes are available: 3 node(s) didn't match pod topology spread constraints, 3 Insufficient cpu.
This message distinguishes topology-caused failures from resource-caused ones — read it carefully.
# Check current distribution
kubectl get pods -o wide -l app=api-service | awk '{print $7}' | sort | uniq -c
Common Mistakes
- Stacking multiple hard (
DoNotSchedule) constraints in clusters without enough nodes/zones to satisfy all of them simultaneously — causes stuckPendingpods. - Wrong
labelSelector— if it doesn’t match your pod template’s labels, the constraint effectively counts zero pods and does nothing useful. - Using
maxSkew: 1with an odd replica count across an even number of zones — perfectly fine, but understand the resulting distribution won’t be perfectly even (e.g., 5 replicas across 2 zones is 3/2, which is the best possible undermaxSkew: 1). - Forgetting this doesn’t replace PodDisruptionBudgets — spread controls placement, PDBs control voluntary disruption limits; you need both for real resilience.
- Not testing constraint interactions with cluster autoscaler — scale-up decisions need to be aware of pending pods blocked by topology constraints, or new nodes may not land in the zone that actually needs them.
Summary
Pod Topology Spread Constraints give you precise, declarative control over how replicas distribute across nodes, zones, or any custom topology domain — closing the gap between “I have 6 replicas” and “I have genuine fault tolerance.” Use maxSkew and whenUnsatisfiable deliberately: hard constraints for genuinely critical spread requirements (like AZ distribution for stateful services), soft constraints for nice-to-have secondary preferences. Combined with PodDisruptionBudgets and multi-AZ node pools, this is how you turn a replica count into an actual availability guarantee.