How to Implement PodAffinity in Kubernetes

How to Implement PodAffinity in Kubernetes

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 required affinity for things that aren’t actually mandatory, causing unschedulable Pods during node maintenance.
  • Co-locating with kubernetes.io/hostname and 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/
Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Kubernetes Monitoring with Grafana

How to Set Up Kubernetes Monitoring with Grafana

Next Post
How to Set Up Pod Security Policies in Kubernetes

How to Set Up Pod Security Policies in Kubernetes

Related Posts