Topology Spread Constraints (TSC) are the modern, recommended way to distribute Pods evenly across failure domains — zones, nodes, or any custom topology. They're more flexible and more efficient than Pod anti-affinity for spreading workloads.
1. The Core Idea: maxSkew
A Topology Spread Constraint says: "The difference in Pod count between any two topology domains must not exceed maxSkew."
# The formula: # skew = (max count in any domain) - (min count in any domain) # Constraint: skew ≤ maxSkew # maxSkew=1: perfectly balanced (difference of at most 1) # maxSkew=2: allows some imbalance (difference of at most 2) # maxSkew=3: very relaxed (rarely useful)
Basic Spec
spec:
topologySpreadConstraints:
- maxSkew: 1 # Max allowed imbalance
topologyKey: topology.kubernetes.io/zone # What defines a "domain"
whenUnsatisfiable: DoNotSchedule # Hard or soft?
labelSelector: # Which Pods to count
matchLabels:
app: web
Fields Explained
| Field | Purpose | Values |
|---|---|---|
maxSkew | Maximum allowed difference between domains | Integer ≥ 1 |
topologyKey | Node label defining topology domains | zone, hostname, custom |
whenUnsatisfiable | What to do if constraint can't be met | DoNotSchedule (hard) / ScheduleAnyway (soft) |
labelSelector | Which Pods are counted for skew calculation | Label selector (like Services) |
minDomains | Minimum number of domains to consider (K8s 1.25+) | Integer (prevents over-concentration when few domains exist) |
nodeAffinityPolicy | Whether to honor node affinity when counting (1.26+) | Honor / Ignore |
nodeTaintsPolicy | Whether to honor taints when counting (1.26+) | Honor / Ignore |
labelSelector counts ALL matching Pods in the cluster (not just those from this Deployment). If two Deployments both have app: web Pods, the constraint counts Pods from both. Use unique labels if you want per-Deployment spreading.
2. whenUnsatisfiable — Hard vs Soft
| Value | Behavior | Use Case |
|---|---|---|
DoNotSchedule | Pod stays Pending if placing it would violate maxSkew | Critical HA (must be spread) |
ScheduleAnyway | Scheduler tries to minimize skew but doesn't block scheduling | General spreading (best-effort) |
# Hard: 7 replicas, 3 zones, maxSkew=1 # Zone A: 3, Zone B: 2, Zone C: 2 → skew = 3-2 = 1 ✓ # 8th Pod: would make A:4, skew=4-2=2 → BLOCKED (DoNotSchedule) # must go to B or C: A:3, B:3, C:2 → skew=1 ✓ # Soft (ScheduleAnyway): same scenario # 8th Pod: scheduler prefers B or C, but if they're full/unavailable, # it CAN go to A (skew=2, which exceeds maxSkew, but Pod still schedules)
DoNotSchedule for stateful workloads where zone spread is a hard requirement (databases, consensus systems). Use ScheduleAnyway for stateless services where spreading is desired but availability is more important than perfect balance. You don't want a web server stuck Pending because of a skew violation.
3. Multiple Constraints (Zone + Node Spreading)
The most common production pattern: spread across zones AND across nodes within each zone:
spec:
topologySpreadConstraints:
# Spread across zones (hard):
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
# Also spread across nodes within each zone (soft):
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web
4. Practical Patterns
Pattern: Cluster-Wide Default (K8s 1.24+)
Set default topology spread constraints for ALL Pods in the cluster via scheduler configuration:
# In KubeSchedulerConfiguration:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- pluginConfig:
- name: PodTopologySpread
args:
defaultConstraints:
- maxSkew: 3
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
- maxSkew: 5
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
defaultingType: List # or "System" for K8s built-in defaults
ScheduleAnyway (soft) to avoid blocking Pods. Individual Deployments can override with tighter constraints when needed.
Pattern: Match Affinity and Spread Together
# Restrict to specific zones + spread evenly within those zones:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: [us-east-1a, us-east-1b] # Only these 2 zones
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: web
# Result: Pods only in zones a and b, evenly spread between them
Pattern: minDomains — Prevent Concentration
# Problem: If only 1 node exists with the right label, all Pods go there
# minDomains says "consider at least N domains" even if fewer exist now:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
minDomains: 3 # Expect at least 3 zones
labelSelector:
matchLabels:
app: web
# If only 2 zones exist → Pod stays Pending (waiting for 3rd zone)
# Prevents deploying all replicas in a degraded topology
Debugging Topology Spread
# Check current distribution: kubectl get pods -l app=web -o custom-columns=\ NAME:.metadata.name,\ NODE:.spec.nodeName,\ ZONE:.metadata.labels.topology\.kubernetes\.io/zone # Check scheduler events for spread violations: kubectl describe pod web-pending # Events: # Warning FailedScheduling doesn't satisfy spread constraint: # zone skew (3) > maxSkew (1)
maxSkew, topologyKey, whenUnsatisfiable, labelSelector. A common gotcha: the labelSelector must match the Pod's OWN labels (not some other Pod's). The constraint counts Pods with those labels to calculate skew.
Summary
| Concept | Key Point |
|---|---|
| maxSkew | Maximum allowed difference in Pod count between any two domains |
| topologyKey | Node label defining domains (zone, hostname, custom) |
| DoNotSchedule | Hard — Pod stays Pending if placement violates maxSkew |
| ScheduleAnyway | Soft — scheduler minimizes skew but doesn't block |
| labelSelector | Defines which Pods are counted — must match own labels for self-spreading |
| Multiple constraints | AND'd together — spread across zones AND nodes simultaneously |
| minDomains | Require minimum topology domains to exist before scheduling |
| vs Anti-Affinity | TSC is more efficient, more flexible (tunable skew), recommended for spreading |
| Cluster defaults | Set in scheduler config — every Pod gets basic spreading automatically |
📝 Quiz: Topology Spread Constraints
Q1: You have 5 Pods with maxSkew: 1 across 3 zones. The current distribution is Zone A: 2, Zone B: 2, Zone C: 1. Where can the 6th Pod go?
Q2: What's the difference between DoNotSchedule and ScheduleAnyway?
ScheduleAnyway: Soft constraint — the scheduler tries to minimize skew (scores domains by imbalance) but will place the Pod even if it violates maxSkew. The Pod always schedules.
Q3: A Deployment uses labelSelector: {app: web} in its topology constraint. Another Deployment also has Pods labeled app: web. Are those counted?
labelSelector counts ALL Pods matching those labels across the entire cluster (or namespace). It doesn't distinguish between Deployments. If you want per-Deployment spreading, use a more unique label (e.g., app: web, deployment: frontend-v2) in the labelSelector.Q4: You have two topology spread constraints: zone (DoNotSchedule, maxSkew=1) and node (ScheduleAnyway, maxSkew=1). How are they evaluated?
Q5: You set maxSkew: 1 with DoNotSchedule for zone spreading. You scale to 10 replicas but only 2 zones exist. What happens?
Q6: When would you use minDomains: 3?
minDomains: 3, Pods stay Pending until 3 zones are available, preventing single-zone concentration.