Most people learn about PodAffinity the hard way: a latency-sensitive service and its cache end up scheduled on opposite sides of the cluster, adding a network hop to every single request. PodAffinity exists to tell the scheduler “put these Pods near each other” — and its counterpart, PodAntiAffinity, does the reverse. This article focuses on PodAffinity specifically: what it is, how the scheduler evaluates it, and how to use it correctly in production.
Where Affinity Fits in the Scheduler
Kubernetes scheduling happens in two broad phases: filtering (which nodes can even run this Pod) and scoring (which of the remaining nodes is best). Affinity rules participate in both, depending on whether they’re expressed as “required” (hard filter) or “preferred” (soft scoring signal).
There are three affinity mechanisms, easy to conflate:
- NodeAffinity: constrains which nodes a Pod can land on, based on node labels.
- PodAffinity: constrains scheduling based on labels of other Pods already running, pulling Pods together.
- PodAntiAffinity: the inverse — pushing Pods apart.
Why Co-locate Pods At All
Typical PodAffinity use cases:
- A web frontend and a co-located cache (e.g. Redis sidecar pattern spread across a topology, not literally the same Pod) that benefit from being in the same zone to cut cross-zone network cost and latency.
- Batch processing Pods that need to be near a shared data-loading Pod on the same node for local disk I/O.
- Compliance requirements that certain workloads run within the same failure domain.
Basic PodAffinity Syntax
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-frontend
spec:
replicas: 3
selector:
matchLabels:
app: web-frontend
template:
metadata:
labels:
app: web-frontend
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- cache
topologyKey: kubernetes.io/hostname
containers:
- name: web
image: registry.example.com/web-frontend:1.0.0
ports:
- containerPort: 8080
topologyKey: kubernetes.io/hostname means “same node.” Using topology.kubernetes.io/zone instead would mean “same availability zone” — a looser, often more realistic constraint for HA-conscious clusters.
Required vs Preferred
requiredDuringSchedulingIgnoredDuringExecution is a hard constraint — if no node satisfies it, the Pod stays Pending. For most production cases, preferredDuringSchedulingIgnoredDuringExecution is the safer default because it degrades gracefully:
affinity:
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- cache
topologyKey: topology.kubernetes.io/zone
The weight (1–100) lets you combine multiple preferred rules and let the scheduler pick the best overall fit rather than an all-or-nothing constraint.
Verifying Placement
After applying, check where Pods actually landed:
kubectl get pods -o wide -l app=web-frontend
kubectl get pods -o wide -l app=cache
Output:
NAME NODE
web-frontend-7d9f8c9-abcde node-3
web-frontend-7d9f8c9-fghij node-3
cache-6b7d9c8-klmno node-3
If a required affinity rule can’t be satisfied, kubectl describe pod will show it plainly:
kubectl describe pod web-frontend-7d9f8c9-abcde
Events:
Warning FailedScheduling 0/5 nodes are available: 5 node(s) didn't
match pod affinity rules.
Combining PodAffinity with Topology Spread
In real clusters, PodAffinity is often paired with topologySpreadConstraints to balance “stick together” against “don’t put all eggs in one basket”:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: web-frontend
This says: keep web-frontend Pods near the cache (PodAffinity), but still spread web-frontend replicas across zones for resilience (topology spread) — the two mechanisms answer different questions and compose cleanly.
RBAC Consideration
Nothing about affinity rules requires special RBAC beyond normal Deployment create/update permissions, since affinity is just a field in the Pod spec:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: deployment-editor
namespace: production
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
Performance and Scheduling Cost
Every PodAffinity term requires the scheduler to inspect labels of other Pods across the cluster, which is more expensive than NodeAffinity’s simpler node-label lookup. In clusters with thousands of Pods, heavy use of required PodAffinity can measurably slow scheduling throughput. The Kubernetes documentation itself flags this as a reason to prefer NodeAffinity when a node-topology label achieves the same practical result.
Troubleshooting Common Failures
# Confirm target labels actually exist on running pods
kubectl get pods --show-labels -n production
# Check scheduler decisions in events
kubectl get events -n production --field-selector reason=FailedScheduling
# Inspect the exact affinity block being applied
kubectl get deployment web-frontend -o yaml | grep -A 20 affinity
A very common mistake: the labelSelector in the affinity rule references a label key/value that doesn’t match what the target Pods are actually labeled with — a typo in app: cache vs app: redis-cache silently leaves the Pod unschedulable with a required rule.
High Availability Trade-off
Required PodAffinity that pins everything to kubernetes.io/hostname is a single point of failure by construction — if that node goes down, both the Pod and whatever it was co-located with disappear together. For HA-sensitive workloads, prefer zone-level topology keys, or use preferred so the constraint yields when it must instead of blocking scheduling entirely, which is the safer default for anything customer-facing.
A Concrete Production Example: Sidecar-Style Locality
A pattern that comes up often enough to walk through fully: a stateless API layer that benefits from being near a shared in-memory cache cluster, without literally running the cache inside the same Pod. Here’s the cache Deployment and the API Deployment configured to prefer proximity:
apiVersion: apps/v1
kind: Deployment
metadata:
name: cache
spec:
replicas: 3
selector:
matchLabels:
app: cache
template:
metadata:
labels:
app: cache
tier: data
spec:
containers:
- name: redis
image: redis:7
ports:
- containerPort: 6379
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 6
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
affinity:
podAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: tier
operator: In
values: ["data"]
topologyKey: topology.kubernetes.io/zone
containers:
- name: api
image: registry.example.com/api:1.0.0
ports:
- containerPort: 8080
Using tier: data rather than app: cache as the match target is deliberate — it lets any future data-tier service adopt the same label and automatically benefit from the same affinity rule, without needing to update the API Deployment’s affinity block every time a new data dependency is introduced.
NodeAffinity as the Simpler Alternative
Before reaching for PodAffinity, it’s worth asking whether the actual goal can be expressed more cheaply with NodeAffinity instead — since NodeAffinity only needs to check static node labels, not the live positions of other Pods, and is therefore both cheaper for the scheduler and easier to reason about:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-type
operator: In
values:
- data-tier
If “run near the cache” can instead be expressed as “run on nodes labeled for the data tier,” NodeAffinity achieves the same practical outcome without the scheduling overhead PodAffinity introduces. PodAffinity earns its cost specifically when the constraint truly depends on where other Pods currently are, not on any property of the nodes themselves.
Namespace Scope in Affinity Rules
By default, a PodAffinity term only matches Pods within the same namespace as the Pod being scheduled — a detail that’s easy to miss and causes confusing “it’s not working” reports when the target Pods actually live in a different namespace:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- cache
namespaces:
- shared-services
topologyKey: kubernetes.io/hostname
Explicitly setting namespaces (a sibling field to labelSelector, not part of it) is required whenever the target Pods live outside the scheduling Pod’s own namespace. Omitting it doesn’t cause an error — it just silently matches nothing outside the current namespace, which is a much harder failure mode to diagnose than an outright rejection, since the Pod schedules “successfully” just without the intended co-location ever taking effect.
An alternative to listing namespaces explicitly is namespaceSelector, which matches by namespace labels rather than by name — useful when the target namespace naming isn’t fixed or predictable across environments:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- cache
namespaceSelector:
matchLabels:
team: platform
topologyKey: kubernetes.io/hostname
Debugging Affinity That “Should” Be Working
When a required PodAffinity rule seems correctly written but the Pod still won’t schedule, the systematic check order is: confirm the target Pods are actually Running (not Pending themselves, which would mean there’s nothing to affine to yet); confirm the namespace scope matches; confirm the label key/value in the selector matches exactly what’s on the target Pods, including case; and only then suspect a genuine topology capacity problem.
kubectl get pods -n shared-services --show-labels | grep cache
kubectl get pods -n production -o yaml | grep -A 15 podAffinity
Comparing these two outputs side by side catches the overwhelming majority of “affinity isn’t working” cases, which in practice are almost always a mismatch between what the rule is looking for and what’s actually running, rather than a genuine scheduler limitation.
Common Mistakes
- Using
requiredaffinity for things that aren’t actually mandatory, causing unschedulable Pods during node maintenance. - Co-locating with
kubernetes.io/hostnameand accidentally creating a single point of failure for otherwise-replicated services. - Forgetting that PodAffinity references labels on other Pods, not on nodes — a very common mix-up with NodeAffinity syntax, which looks similar but means something different.
- Not testing behavior during a node drain — affinity rules that work fine at steady state can prevent rescheduling during maintenance.
Summary
PodAffinity lets the scheduler treat “Pods that work together” as a placement signal, not just an afterthought of chance. Used with preferred rules and zone-level topology keys, it improves latency and locality without sacrificing resilience; used carelessly with required and hostname-level topology, it can just as easily create new single points of failure. Pair it with topology spread constraints for a placement strategy that’s both efficient and fault-tolerant.
References
- Kubernetes affinity and anti-affinity docs: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity
- Kubernetes scheduler documentation: https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/
- Topology spread constraints: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/
