Pod affinity & anti-affinity
Placing pods relative to other pods.
Two of your three web replicas landed on the same node. Nobody noticed until that node drained for a kernel patch at 2am, and two-thirds of your frontend went dark at once. The scheduler didn't do anything wrong. You never told it to keep those copies apart. That's the job pod affinity and anti-affinity do. They place a Pod (Kubernetes' smallest deployable unit, one or more containers that share an IP address) relative to other pods, not relative to node labels. Two flavors. Affinity pulls a pod toward where certain pods already run. Anti-affinity pushes it away. One buys you performance. The other buys you survival.
Attract, repel, and what "same" means
Think about seating at a big wedding. The planner works with two kinds of rules. "Put the band next to the dance floor" is a togetherness rule. "Never seat the two feuding uncles at the same table" is a keep-apart rule. Kubernetes has both. Pod affinity is the band-and-dance-floor rule: schedule this pod where pods matching a label already run, like a cache sitting close to the app that keeps hitting it. Pod anti-affinity is the feuding-uncles rule: keep this pod off any node that already runs a matching pod, so a service's replicas fan out instead of piling onto one machine. The word doing the quiet heavy lifting is topologyKey, and it answers a single question: same what? A table at the wedding could mean one physical table, or the whole west wing of the hall. In a cluster, kubernetes.io/hostname means "the same node," and topology.kubernetes.io/zone means "the same availability zone" (a separate failure domain in your cloud region, with its own power and network). topologyKey has to be a label that actually exists on your nodes. The scheduler groups nodes by that label's value, then applies your rule inside each group. Pick the wrong domain and you'll spread across nodes when you meant to survive a whole zone going down, or the reverse.
apiVersion: apps/v1kind: Deploymentmetadata:name: webspec:replicas: 3selector:matchLabels:app: webtemplate:metadata:labels:app: webspec:affinity:podAntiAffinity:requiredDuringSchedulingIgnoredDuringExecution:- labelSelector:matchLabels:app: webtopologyKey: kubernetes.io/hostnamecontainers:- name: webimage: nginx:1.27
$ kubectl apply -f web-deploy.yamldeployment.apps/web created$ kubectl get pods -l app=web -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-6f9c8b7d5f-4kq2p 1/1 Running 0 15s 10.244.1.23 node-a <none> <none>web-6f9c8b7d5f-lm8xn 1/1 Running 0 15s 10.244.2.31 node-b <none> <none>web-6f9c8b7d5f-t7v6c 1/1 Running 0 15s 10.244.3.19 node-c <none> <none>
How the scheduler actually reads the rule
When you submit a pod, the scheduler makes two passes over every node. The first pass filters: it drops nodes that can't work, meaning not enough CPU or memory, taints the pod won't tolerate, and yes, anti-affinity violations. The second pass scores the survivors and ranks them, and preferred rules add their points here. Hard anti-affinity lives in that first pass. For each candidate node, the scheduler looks at the pods already scheduled in that node's topology group, and if any of them match your labelSelector, the node is out. This is why the feature costs more than node affinity. Node affinity compares a node against fixed labels. Pod affinity compares a node against the live placement of every other matching pod, so the work grows with how many pods you're checking against. On big clusters the docs warn against these once you get past a few hundred nodes, because the extra processing slows scheduling down, so keep the rules for workloads that truly need them. The long field name is trying to tell you something too. requiredDuringSchedulingIgnoredDuringExecution means the rule is enforced when the pod is placed and ignored once it's running. If a matching pod shows up in the same group later, or someone relabels things, Kubernetes won't evict your already-running pod to fix it. It's a scheduling-time promise, nothing more. One more detail people miss: labelSelector only matches pods in the same namespace by default. To reach across namespaces you set namespaceSelector, or list the namespaces explicitly.
Here's the failure everyone trips over once. Scale that deployment to four replicas on a three-node cluster, and the fourth pod has nowhere legal to land.
$ kubectl scale deployment web --replicas=4deployment.apps/web scaled$ kubectl get pods -l app=webNAME READY STATUS RESTARTS AGEweb-6f9c8b7d5f-4kq2p 1/1 Running 0 3mweb-6f9c8b7d5f-lm8xn 1/1 Running 0 3mweb-6f9c8b7d5f-t7v6c 1/1 Running 0 3mweb-6f9c8b7d5f-zp4rd 0/1 Pending 0 22s$ kubectl describe pod web-6f9c8b7d5f-zp4rd | sed -n '/Events/,$p'Events:Type Reason Age From Message---- ------ ---- ---- -------Warning FailedScheduling 24s default-scheduler 0/3 nodes are available: 3 node(s)didn't match pod anti-affinity rules. preemption: 0/3 nodes are available: 3 Nopreemption victims found for incoming pod.
Read that event closely, because it's doing your debugging for you. "0/3 nodes are available: 3 node(s) didn't match pod anti-affinity rules" tells you the filter pass rejected every node for one reason, and the reason is your own rule. Not resources. Not taints. When a pod is stuck Pending, kubectl describe pod always names the filter that killed it, and here it points straight back at the hostname anti-affinity. The second you see "didn't match pod anti-affinity rules," count your schedulable nodes against your replica count. If replicas beat nodes and the rule is hard, that's the whole story, and no amount of waiting will fix it.
Soft rules, and pulling pods together
The fix isn't to give up on spreading. You just tell the scheduler you'd strongly prefer it instead of demanding it. The preferred form turns the rule from a filter into a score. Each term carries a weight from 1 to 100, and the scheduler adds that weight to the nodes that satisfy the term, then still places the pod somewhere even when nothing satisfies it. So four replicas on three nodes spread as evenly as they can, and the fourth doubles up on the least-loaded node instead of sitting Pending. You get good spread on a normal day and you keep running on a bad one. For pure availability spreading, a lot of teams now reach for topology spread constraints (the next lesson), which were built for exactly this, but preferred anti-affinity is the older, widely used tool and still works fine.
affinity:podAntiAffinity:preferredDuringSchedulingIgnoredDuringExecution:- weight: 100podAffinityTerm:labelSelector:matchLabels:app: webtopologyKey: kubernetes.io/hostname
$ kubectl apply -f web-deploy.yamldeployment.apps/web configured$ kubectl get pods -l app=web -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-7c4d9f6b88-2wq5r 1/1 Running 0 40s 10.244.1.24 node-a <none> <none>web-7c4d9f6b88-6nkt8 1/1 Running 0 40s 10.244.2.33 node-b <none> <none>web-7c4d9f6b88-h9zpl 1/1 Running 0 40s 10.244.3.21 node-c <none> <none>web-7c4d9f6b88-x4m7d 1/1 Running 0 38s 10.244.1.25 node-a <none> <none>
The attract side, podAffinity, uses the same shape with the selector pointed at the pods you want to sit near. Say you run a small cache next to each web pod so lookups stay on the same machine and skip a network hop. You require the cache onto nodes already running app: web, over the hostname domain, and it snaps into place beside them.
apiVersion: apps/v1kind: Deploymentmetadata:name: web-cachespec:replicas: 3selector:matchLabels:app: web-cachetemplate:metadata:labels:app: web-cachespec:affinity:podAffinity:requiredDuringSchedulingIgnoredDuringExecution:- labelSelector:matchLabels:app: webtopologyKey: kubernetes.io/hostnamecontainers:- name: cacheimage: redis:7
$ kubectl apply -f web-cache.yamldeployment.apps/web-cache created$ kubectl get pods -o wide -l 'app in (web,web-cache)' --sort-by=.spec.nodeNameNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-7c4d9f6b88-2wq5r 1/1 Running 0 5m 10.244.1.24 node-a <none> <none>web-cache-5d8f7c9b4-qm2rt 1/1 Running 0 25s 10.244.1.28 node-a <none> <none>web-7c4d9f6b88-6nkt8 1/1 Running 0 5m 10.244.2.33 node-b <none> <none>web-cache-5d8f7c9b4-lx9vp 1/1 Running 0 25s 10.244.2.37 node-b <none> <none>web-7c4d9f6b88-h9zpl 1/1 Running 0 5m 10.244.3.21 node-c <none> <none>web-cache-5d8f7c9b4-w7kd2 1/1 Running 0 25s 10.244.3.24 node-c <none> <none>
Hard anti-affinity with too few domains leaves pods Pending. Soft preferred rules often fit real capacity better.
Co-locating chatty pods can cut latency and also create correlated failure. Pick consciously.
Label the targets you affinity toward. Orphan labels make rules silently match nothing.
Try this
Run a datastore pod with a role label, then schedule an app with pod affinity to that label on the same topology key. Add anti-affinity for the app replicas.
$ kubectl apply -f web-deploy.yamldeployment.apps/web created$ kubectl get pods -l app=web -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-6f9c8b7d5f-4kq2p 1/1 Running 0 15s 10.244.1.23 node-a <none> <none>web-6f9c8b7d5f-lm8xn 1/1 Running 0 15s 10.244.2.31 node-b <none> <none>web-6f9c8b7d5f-t7v6c 1/1 Running 0 15s 10.244.3.19 node-c <none> <none>$ kubectl scale deployment web --replicas=4deployment.apps/web scaled$ kubectl get pods -l app=webNAME READY STATUS RESTARTS AGEweb-6f9c8b7d5f-4kq2p 1/1 Running 0 3mweb-6f9c8b7d5f-lm8xn 1/1 Running 0 3mweb-6f9c8b7d5f-t7v6c 1/1 Running 0 3mweb-6f9c8b7d5f-zp4rd 0/1 Pending 0 22s$ kubectl describe pod web-6f9c8b7d5f-zp4rd | sed -n '/Events/,$p'Events:Type Reason Age From Message---- ------ ---- ---- -------Warning FailedScheduling 24s default-scheduler 0/3 nodes are available: 3 node(s)didn't match pod anti-affinity rules. preemption: 0/3 nodes are available: 3 Nopreemption victims found for incoming pod.$ kubectl apply -f web-deploy.yamldeployment.apps/web configured$ kubectl get pods -l app=web -o wideNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-7c4d9f6b88-2wq5r 1/1 Running 0 40s 10.244.1.24 node-a <none> <none>web-7c4d9f6b88-6nkt8 1/1 Running 0 40s 10.244.2.33 node-b <none> <none>web-7c4d9f6b88-h9zpl 1/1 Running 0 40s 10.244.3.21 node-c <none> <none>web-7c4d9f6b88-x4m7d 1/1 Running 0 38s 10.244.1.25 node-a <none> <none>$ kubectl apply -f web-cache.yamldeployment.apps/web-cache created$ kubectl get pods -o wide -l 'app in (web,web-cache)' --sort-by=.spec.nodeNameNAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATESweb-7c4d9f6b88-2wq5r 1/1 Running 0 5m 10.244.1.24 node-a <none> <none>web-cache-5d8f7c9b4-qm2rt 1/1 Running 0 25s 10.244.1.28 node-a <none> <none>web-7c4d9f6b88-6nkt8 1/1 Running 0 5m 10.244.2.33 node-b <none> <none>web-cache-5d8f7c9b4-lx9vp 1/1 Running 0 25s 10.244.2.37 node-b <none> <none>web-7c4d9f6b88-h9zpl 1/1 Running 0 5m 10.244.3.21 node-c <none> <none>web-cache-5d8f7c9b4-w7kd2 1/1 Running 0 25s 10.244.3.24 node-c <none> <none>
Takeaway
Pod affinity places relative to other pods; anti-affinity spreads or isolates. Topology keys decide the domain — node, zone, or region.