Lesson 46 of 66 · 22 min read · Updated

Node Selectors, Node Affinity and Pod Affinity/Anti-Affinity

Up to this point in the Kubernetes series, you have let the scheduler decide where every Pod runs. You wrote a Deployment, applied it, and the Pods appeared on whichever worker node had room. For most stateless .NET APIs that is exactly what you want. The scheduler is good at its job, and the less you tell it, the more freedom it has to keep the cluster balanced.

Real clusters are rarely that uniform, though. Some nodes have fast SSDs and some do not. Some sit in one availability zone and some in another. Some workloads must never share a node with a second copy of themselves, and some work best when they sit right next to a specific neighbour, such as an API and its local cache. This article shows how to express those wishes to the scheduler, from the bluntest tool to the most precise.

We will cover the following topics:

  1. How the scheduler picks a node: filtering and scoring
  2. nodeSelector, the simplest way to target labelled nodes
  3. nodeName, and why you should almost never use it
  4. Node affinity, with required and preferred rules, operators and weights
  5. What IgnoredDuringExecution really means
  6. Pod affinity and pod anti-affinity, and the role of topologyKey
  7. Spreading replicas of a .NET API across workers, and co-locating a Redis cache with it

Technical Requirements

  1. A multi-node cluster. The examples use Docker Desktop's built-in Kubernetes with the kind provisioner: one control-plane node (desktop-control-plane) and three workers (desktop-worker, desktop-worker2, desktop-worker3), Kubernetes v1.36. A plain kind create cluster with a config file that declares one control-plane and three workers behaves the same; only the node names change to kind-control-plane, kind-worker, and so on.
  2. kubectl configured for that cluster.
  3. Docker and the .NET 10 SDK to build the small sample API.
  4. A single-node cluster such as minikube can run the manifests, but you will not see any of the placement effects this article is about.

Core Concepts

How the Scheduler Chooses a Node

When a new Pod has no node yet, the scheduler runs it through two phases.

PhaseQuestionResult
FilteringWhich nodes are allowed to run this Pod at all?A shortlist. Nodes that fail any hard rule are removed.
ScoringOf the allowed nodes, which one is best?Each remaining node gets a score; the highest wins.

Every feature in this article plugs into one of those two phases. A hard rule such as nodeSelector or a required affinity term is a filter: a node that does not match is simply not a candidate. A soft rule such as a preferred affinity term only changes the score: a node that does not match can still win if nothing better is available.

Keep that distinction in mind. It explains almost every surprise you will meet, including Pods that stay Pending forever and Pods that land somewhere you "told" them not to go.

Labels Are the Raw Material

All of these features work by matching labels on nodes or labels on Pods. Kubernetes adds some node labels for you:

LabelExample valueSet by
kubernetes.io/hostnamedesktop-worker2kubelet
kubernetes.io/oslinuxkubelet
kubernetes.io/archarm64kubelet
topology.kubernetes.io/zonewesteurope-1cloud provider (not on kind)
node.kubernetes.io/instance-typeStandard_D4s_v5cloud provider (not on kind)

On a managed cloud cluster the zone and instance-type labels are already there. On a local kind cluster they are not, so in the hands-on part we will add our own labels under a prefix we own, lesson.dotnetacademy.dev/. Using your own prefix for custom labels is good practice in real clusters too: it makes it obvious which labels your team manages, and it never collides with labels a cloud provider or controller might add later.

nodeSelector

nodeSelector is a map of labels that a node must all have. It is the oldest and simplest placement tool.

spec:
nodeSelector:
lesson.dotnetacademy.dev/disk: ssd

It is a pure filter. If no node carries every listed label, the Pod stays Pending. It cannot express "ssd or nvme", "anything except hdd", or "prefer ssd if possible". For those you need node affinity.

nodeName — Bypassing the Scheduler

Setting spec.nodeName does not ask the scheduler anything. It skips the scheduler entirely and hands the Pod straight to the kubelet on that node.

That sounds convenient, but it has sharp edges:

  1. Taints are not checked. In the hands-on section you will see a Pod land on the control-plane node, which every normal workload is kept off.
  2. Resources are only checked by the kubelet at admission time, not by the scheduler's view of the cluster. If the node is full, the Pod fails with an error such as OutOfcpu instead of waiting politely.
  3. If the node does not exist, the Pod sits there unassigned. Nothing will move it elsewhere.
  4. A Deployment with a hard-coded node name defeats the point of a Deployment. If that node dies, the replacement Pods are pinned to the dead node.

nodeName exists for tools that implement their own scheduling, and it is handy for a one-off debugging Pod. For application workloads, use a selector or affinity instead.

Node Affinity

Node affinity is nodeSelector with a richer language. It lives under spec.affinity.nodeAffinity and comes in two flavours with deliberately long names:

FieldPhaseMeaning
requiredDuringSchedulingIgnoredDuringExecutionFilteringThe node must match, or the Pod is not placed there.
preferredDuringSchedulingIgnoredDuringExecutionScoringThe scheduler tries to find a matching node, weighted by your weight.

A required rule is a list of nodeSelectorTerms. The terms are ORed: the node needs to match at least one of them. Inside a term, the matchExpressions are ANDed: the node must satisfy all of them.

Each expression has a key, an operator and, for most operators, values:

OperatorMatches when the node label...
Inexists and its value is in the list
NotInis missing, or its value is not in the list
Existsexists, any value
DoesNotExistdoes not exist
Gt / Ltexists and its value, read as an integer, is greater / less than the single value given

NotIn and DoesNotExist give you node anti-affinity: "keep me off nodes like these".

A preferred rule is a list of {weight, preference} pairs, where weight is 1–100. For every node that passed filtering, the scheduler adds up the weights of the preferences that node matches. That sum becomes one of several scoring inputs; it is not the only factor, so a preference is a strong hint rather than a promise.

What IgnoredDuringExecution Means

Both names end in IgnoredDuringExecution. This tells you that affinity is evaluated only when the Pod is scheduled. If you later change a node's labels so that a running Pod no longer matches, Kubernetes does nothing to that Pod. It keeps running where it is. Only Pods scheduled from that moment on see the new labels.

This is intentional. Evicting running Pods every time someone edits a label would be dangerous. If you want running Pods to move, you replace them, for example with kubectl rollout restart.

Pod Affinity and Pod Anti-Affinity

Node affinity looks at node labels. Pod affinity and anti-affinity look at the labels of other Pods already running, and they need one more ingredient: a topologyKey.

The topology key is the name of a node label. Nodes that share the same value for that label form one topology domain. The rule then reads like this:

  1. Pod affinity: schedule me into a domain that already runs a Pod matching this labelSelector.
  2. Pod anti-affinity: schedule me into a domain that does not run a Pod matching this labelSelector.
topologyKeyOne domain isTypical use
kubernetes.io/hostnamea single nodeOne replica per node; cache next to API
topology.kubernetes.io/zoneone availability zoneOne replica per zone; survive a zone outage
topology.kubernetes.io/regionone regionRare, multi-region clusters

Both come in the same required and preferred flavours as node affinity. Pod anti-affinity with kubernetes.io/hostname is the classic way to make sure two replicas of the same API never share a node, so that losing one node never takes out both.

Inter-Pod rules are more expensive for the scheduler than node rules, because it has to look at the Pods on every candidate node. On clusters with many thousands of nodes the Kubernetes documentation recommends using them with care. For the cluster sizes most teams run, they are fine.

Choosing the Right Tool

You want to...Use
Run only on nodes with a certain labelnodeSelector
Express OR, NOT, or numeric comparisons on node labelsRequired node affinity
Prefer certain nodes but run anywhere if neededPreferred node affinity
Keep replicas of the same app on different nodes or zonesPod anti-affinity
Place a Pod next to another PodPod affinity
Spread replicas evenly with a defined toleranceTopology spread constraints (next article)
Keep everyone else off a nodeTaints and tolerations (next article)

Hands-On: Kubernetes Commands

Show nodes with chosen labels as columns

-L adds one column per label key, which is the fastest way to see your topology.

kubectl get nodes -L lesson.dotnetacademy.dev/zone,lesson.dotnetacademy.dev/disk

Show every label on a node

Useful when you are looking for the exact key a cloud provider uses.

kubectl get node desktop-worker2 --show-labels

Add and remove a node label

A trailing - removes the label. --overwrite is required to change an existing value.

kubectl label node desktop-worker2 lesson.dotnetacademy.dev/disk=ssd
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/disk=nvme --overwrite
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/disk-

See which node each Pod landed on

-o wide adds the NODE column.

kubectl get pods -n sched-affinity -o wide

Count Pods per node for one app

Handy when you have many replicas and only care about the distribution.

kubectl get pods -n sched-affinity -l app=basket-api -o custom-columns=NODE:.spec.nodeName --no-headers | sort | uniq -c

Find out why a Pod is Pending

The last event from default-scheduler tells you which filter rejected each node.

kubectl describe pod <pod-name> -n sched-affinity

Read the affinity field documentation from the cluster

kubectl explain always matches your cluster version.

kubectl explain pod.spec.affinity.podAntiAffinity.requiredDuringSchedulingIgnoredDuringExecution

Step-by-Step Example

The Scenario

You run several .NET 10 APIs for an online shop on a cluster with three workers. To make the examples realistic, you will pretend the workers are spread across three availability zones and have different disks:

NodeZoneDisk
desktop-workerzone-ahdd
desktop-worker2zone-bssd
desktop-worker3zone-cssd

Then you will place a catalog API only on SSD nodes, keep a reports API inside two zones, nudge a search API towards SSDs, spread a basket API one replica per node, and pin a Redis cache next to each basket API replica.

Step 1 — Build a Small API That Reports Its Node

Every Pod in this article runs the same tiny API. It returns its own Pod name and the node it runs on, read from the Downward API.

dotnet new web -n NodeInfoApi
cd NodeInfoApi

Replace Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string podName = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;
string nodeName = Environment.GetEnvironmentVariable("NODE_NAME") ?? "unknown";
string zone = Environment.GetEnvironmentVariable("ZONE") ?? "unknown";

app.MapGet("/", () => Results.Ok(new { pod = podName, node = nodeName, zone }));
app.MapGet("/healthz", () => Results.Ok("healthy"));

app.Run();

Add a Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish NodeInfoApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "NodeInfoApi.dll"]

Build it:

docker build -t node-info-api:1.0 .

Docker Desktop's kind cluster can use images from your local Docker image store directly, so imagePullPolicy: IfNotPresent is enough. With a plain kind cluster, copy the image into the nodes first with kind load docker-image node-info-api:1.0.

Step 2 — Namespace and Node Labels

apiVersion: v1
kind: Namespace
metadata:
name: sched-affinity
kubectl apply -f 1-namespace.yaml
kubectl label node desktop-worker lesson.dotnetacademy.dev/zone=zone-a lesson.dotnetacademy.dev/disk=hdd
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/zone=zone-b lesson.dotnetacademy.dev/disk=ssd
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/zone=zone-c lesson.dotnetacademy.dev/disk=ssd
kubectl get nodes -L lesson.dotnetacademy.dev/zone,lesson.dotnetacademy.dev/disk
NAME STATUS ROLES AGE VERSION ZONE DISK
desktop-control-plane Ready control-plane 7d13h v1.36.1
desktop-worker Ready <none> 7d13h v1.36.1 zone-a hdd
desktop-worker2 Ready <none> 7d13h v1.36.1 zone-b ssd
desktop-worker3 Ready <none> 7d13h v1.36.1 zone-c ssd

The control-plane node has no zone or disk label. That matters later: any rule that requires these labels automatically excludes it.

Step 3 — nodeSelector: SSD Nodes Only

The catalog API reads a large product index from local disk, so it should only run on SSD nodes.

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
namespace: sched-affinity
spec:
replicas: 4
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
nodeSelector:
lesson.dotnetacademy.dev/disk: ssd
containers:
- name: catalog-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
resources:
requests:
cpu: 50m
memory: 64Mi
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
kubectl apply -f 2-nodeselector.yaml
kubectl rollout status deploy/catalog-api -n sched-affinity
kubectl get pods -n sched-affinity -o wide
NAME READY STATUS RESTARTS AGE IP NODE
catalog-api-69549d9567-hfwpg 1/1 Running 0 54s 10.244.4.9 desktop-worker3
catalog-api-69549d9567-jqngn 1/1 Running 0 54s 10.244.1.9 desktop-worker2
catalog-api-69549d9567-l4q4q 1/1 Running 0 54s 10.244.4.8 desktop-worker3
catalog-api-69549d9567-zpd7z 1/1 Running 0 54s 10.244.1.8 desktop-worker2

All four replicas are on the two SSD nodes. desktop-worker was filtered out before scoring even started.

Step 4 — What Happens When Nothing Matches

Ask for a disk type that no node has:

apiVersion: v1
kind: Pod
metadata:
name: nvme-only
namespace: sched-affinity
spec:
nodeSelector:
lesson.dotnetacademy.dev/disk: nvme
containers:
- name: app
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
kubectl apply -f 3-nvme-pod.yaml
kubectl describe pod nvme-only -n sched-affinity
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 4s default-scheduler 0/4 nodes are available: 1 node(s) had untolerated taint(s), 3 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/4 nodes are available: 4 Preemption is not helpful for scheduling.

Read the message as a tally of the filter phase. The control-plane node was rejected by its taint (the subject of the next article) and the three workers were rejected by the selector. The Pod will stay Pending until a node with that label appears. Note that nodeSelector failures are reported as "node affinity/selector": internally the scheduler treats them as the same filter.

Step 5 — nodeName Skips Every Check

Now pin a Pod to the control-plane node by name:

apiVersion: v1
kind: Pod
metadata:
name: pinned-by-name
namespace: sched-affinity
spec:
nodeName: desktop-control-plane
containers:
- name: app
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
kubectl apply -f 4-nodename-pod.yaml
kubectl get pod pinned-by-name -n sched-affinity -o wide
kubectl describe pod pinned-by-name -n sched-affinity
NAME READY STATUS RESTARTS AGE IP NODE
pinned-by-name 1/1 Running 0 6s 10.244.0.6 desktop-control-plane

Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Pulling 6s kubelet spec.containers{app}: Pulling image "node-info-api:1.0"
Normal Pulled 3s kubelet spec.containers{app}: Successfully pulled image "node-info-api:1.0" in 1.542s (2.147s including waiting). Image size: 92453198 bytes.
Normal Created 3s kubelet spec.containers{app}: Container created
Normal Started 3s kubelet spec.containers{app}: Container started

Two things stand out. The Pod is running on the control-plane node even though that node's taint keeps normal Pods away. And there is no Scheduled event from default-scheduler at all: the first event comes from the kubelet. The scheduler never saw this Pod. That is exactly why nodeName does not belong in application manifests.

Clean up before moving on:

kubectl delete pod nvme-only pinned-by-name -n sched-affinity
kubectl delete deploy catalog-api -n sched-affinity

Step 6 — Required Node Affinity: Two Zones, Any Disk

The reports API must stay inside zone-a and zone-b for data-residency reasons, and it must only run on nodes that declare a disk type at all.

apiVersion: apps/v1
kind: Deployment
metadata:
name: reports-api
namespace: sched-affinity
spec:
replicas: 4
selector:
matchLabels:
app: reports-api
template:
metadata:
labels:
app: reports-api
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: lesson.dotnetacademy.dev/zone
operator: In
values: ["zone-a", "zone-b"]
- key: lesson.dotnetacademy.dev/disk
operator: Exists
containers:
- name: reports-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

The two expressions are in the same term, so a node must satisfy both. nodeSelector cannot express the In list.

kubectl apply -f 5-required-affinity.yaml
kubectl rollout status deploy/reports-api -n sched-affinity
kubectl get pods -n sched-affinity -l app=reports-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
reports-api-786597f58-hk4b2 1/1 Running 0 2m28s 10.244.1.13 desktop-worker2
reports-api-786597f58-n4mhm 1/1 Running 0 2m28s 10.244.2.17 desktop-worker
reports-api-786597f58-pphc7 1/1 Running 0 2m28s 10.244.2.18 desktop-worker
reports-api-786597f58-tsh7n 1/1 Running 0 2m28s 10.244.1.14 desktop-worker2

Nothing on desktop-worker3, which is in zone-c.

Step 7 — Proving IgnoredDuringExecution

Remove the zone label from desktop-worker, which is running two reports Pods:

kubectl label node desktop-worker lesson.dotnetacademy.dev/zone-
kubectl get pods -n sched-affinity -l app=reports-api -o wide

The two Pods on desktop-worker keep running. Kubernetes does not re-check affinity for running Pods. Now scale up:

kubectl scale deploy/reports-api -n sched-affinity --replicas=6
kubectl get pods -n sched-affinity -l app=reports-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
reports-api-786597f58-7v6pw 1/1 Running 0 5s 10.244.1.19 desktop-worker2
reports-api-786597f58-8wqsg 1/1 Running 0 5s 10.244.1.18 desktop-worker2
reports-api-786597f58-hk4b2 1/1 Running 0 2m42s 10.244.1.13 desktop-worker2
reports-api-786597f58-n4mhm 1/1 Running 0 2m42s 10.244.2.17 desktop-worker
reports-api-786597f58-pphc7 1/1 Running 0 2m42s 10.244.2.18 desktop-worker
reports-api-786597f58-tsh7n 1/1 Running 0 2m42s 10.244.1.14 desktop-worker2

The two new Pods both went to desktop-worker2, the only node that still matches. The old Pods on desktop-worker are untouched. In a real cluster this is how a label change "drains" a node slowly: new Pods avoid it while old ones finish their life naturally.

Put the label back:

kubectl label node desktop-worker lesson.dotnetacademy.dev/zone=zone-a

Step 8 — Preferred Node Affinity with Weights

The search API can run anywhere in our three zones, but it is noticeably faster on SSD and slightly better in zone-a, where most users are.

apiVersion: apps/v1
kind: Deployment
metadata:
name: search-api
namespace: sched-affinity
spec:
replicas: 6
selector:
matchLabels:
app: search-api
template:
metadata:
labels:
app: search-api
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: lesson.dotnetacademy.dev/zone
operator: Exists
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: lesson.dotnetacademy.dev/disk
operator: In
values: ["ssd"]
- weight: 20
preference:
matchExpressions:
- key: lesson.dotnetacademy.dev/zone
operator: In
values: ["zone-a"]
containers:
- name: search-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

The required part keeps it on labelled workers. The preferred part gives desktop-worker2 and desktop-worker3 80 points each for SSD, and desktop-worker 20 points for zone-a.

kubectl apply -f 6-preferred-affinity.yaml
kubectl get pods -n sched-affinity -l app=search-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
search-api-787f8f8849-4wwvp 1/1 Running 0 2m 10.244.1.15 desktop-worker2
search-api-787f8f8849-9wq62 1/1 Running 0 2m 10.244.4.14 desktop-worker3
search-api-787f8f8849-jdpwq 1/1 Running 0 2m 10.244.1.17 desktop-worker2
search-api-787f8f8849-n886j 1/1 Running 0 2m 10.244.1.16 desktop-worker2
search-api-787f8f8849-nlkjn 1/1 Running 0 2m 10.244.4.15 desktop-worker3
search-api-787f8f8849-rpbdf 1/1 Running 0 2m 10.244.4.16 desktop-worker3

80 beats 20, so all six went to the SSD nodes. If both SSD nodes were full, the scheduler would still place replicas on desktop-worker instead of leaving them Pending. That is the difference between required and preferred.

Clean up:

kubectl delete deploy reports-api search-api -n sched-affinity

Step 9 — Required Pod Anti-Affinity: One Replica per Node

The basket API holds in-memory session state for a few seconds during checkout. Losing two replicas at once is painful, so no two replicas may share a node.

apiVersion: apps/v1
kind: Deployment
metadata:
name: basket-api
namespace: sched-affinity
spec:
replicas: 3
selector:
matchLabels:
app: basket-api
template:
metadata:
labels:
app: basket-api
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: basket-api
topologyKey: kubernetes.io/hostname
containers:
- name: basket-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName

The rule says: do not schedule me on a node (kubernetes.io/hostname) that already runs a Pod labelled app: basket-api. The Pod's own label matches its own selector, which is what makes the replicas repel each other.

kubectl apply -f 7-anti-affinity.yaml
kubectl get pods -n sched-affinity -l app=basket-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
basket-api-7d8f6bb8ff-l8xwp 1/1 Running 0 1s 10.244.1.20 desktop-worker2
basket-api-7d8f6bb8ff-mlfbk 1/1 Running 0 1s 10.244.4.18 desktop-worker3
basket-api-7d8f6bb8ff-rw98n 1/1 Running 0 1s 10.244.2.20 desktop-worker

One per worker. Now ask for a fourth:

kubectl scale deploy/basket-api -n sched-affinity --replicas=4
kubectl get pods -n sched-affinity -l app=basket-api -o wide
NAME READY STATUS RESTARTS AGE IP NODE
basket-api-7d8f6bb8ff-l8xwp 1/1 Running 0 6s 10.244.1.20 desktop-worker2
basket-api-7d8f6bb8ff-mgq4s 0/1 Pending 0 5s <none> <none>
basket-api-7d8f6bb8ff-mlfbk 1/1 Running 0 6s 10.244.4.18 desktop-worker3
basket-api-7d8f6bb8ff-rw98n 1/1 Running 0 6s 10.244.2.20 desktop-worker
kubectl describe pod basket-api-7d8f6bb8ff-mgq4s -n sched-affinity
Warning FailedScheduling 5s default-scheduler 0/4 nodes are available: 1 node(s) had untolerated taint(s), 3 node(s) didn't match pod anti-affinity rules. no new claims to deallocate, preemption: 0/4 nodes are available: 1 Preemption is not helpful for scheduling, 3 No preemption victims found for incoming pod.

This is the price of a required rule: replicas can never exceed the number of domains. With three workers you get at most three replicas. During a rolling update this can also block progress, because the new Pod needs a free node before an old one goes away. Use required anti-affinity when "two on one node" is genuinely unacceptable, and size the Deployment strategy accordingly (for example maxSurge: 0, maxUnavailable: 1).

Scale back to three:

kubectl scale deploy/basket-api -n sched-affinity --replicas=3

Step 10 — Pod Affinity: A Cache Next to Every API Replica

Each basket API replica talks to a Redis instance for short-lived data. Network hops between nodes add latency, so you want one Redis Pod on every node that runs a basket API, and never two Redis Pods on the same node.

apiVersion: apps/v1
kind: Deployment
metadata:
name: basket-cache
namespace: sched-affinity
spec:
replicas: 3
selector:
matchLabels:
app: basket-cache
template:
metadata:
labels:
app: basket-cache
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: basket-api
topologyKey: kubernetes.io/hostname
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: basket-cache
topologyKey: kubernetes.io/hostname
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
resources:
requests:
cpu: 50m
memory: 32Mi

Two rules work together. Pod affinity attracts each cache Pod to a node that runs app: basket-api. Pod anti-affinity against its own label keeps two caches apart.

kubectl apply -f 8-cache-affinity.yaml
kubectl get pods -n sched-affinity -o wide --sort-by=.spec.nodeName
NAME READY STATUS RESTARTS AGE IP NODE
basket-api-7d8f6bb8ff-rw98n 1/1 Running 0 3m35s 10.244.2.20 desktop-worker
basket-cache-6b44d8f65d-2kx98 1/1 Running 0 3m20s 10.244.2.21 desktop-worker
basket-api-7d8f6bb8ff-l8xwp 1/1 Running 0 3m35s 10.244.1.20 desktop-worker2
basket-cache-6b44d8f65d-sbtln 1/1 Running 0 3m20s 10.244.1.22 desktop-worker2
basket-api-7d8f6bb8ff-mlfbk 1/1 Running 0 3m35s 10.244.4.18 desktop-worker3
basket-cache-6b44d8f65d-k26dz 1/1 Running 0 3m20s 10.244.4.19 desktop-worker3

Sorted by node, the pairs are obvious: every worker runs exactly one API and one cache. If you scaled the cache to four, the fourth would be Pending for the same reason as the fourth basket API in the previous step.

Remember that the affinity is one-directional. The cache follows the API; the API knows nothing about the cache. If the API Pods move, existing cache Pods stay where they are (IgnoredDuringExecution again), so in production you would usually restart both together or run them as two containers of one Pod when they are truly inseparable.

Step 11 — Preferred Anti-Affinity Is Only a Hint

Replace the basket API's hard rule with a soft one and ask for five replicas, more than there are nodes:

apiVersion: apps/v1
kind: Deployment
metadata:
name: basket-api
namespace: sched-affinity
spec:
replicas: 5
selector:
matchLabels:
app: basket-api
template:
metadata:
labels:
app: basket-api
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: basket-api
topologyKey: kubernetes.io/hostname
containers:
- name: basket-api
image: node-info-api:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi

Note the extra podAffinityTerm wrapper that preferred terms need.

kubectl delete deploy basket-api basket-cache -n sched-affinity
kubectl apply -f 9-preferred-anti-affinity.yaml
kubectl get pods -n sched-affinity -l app=basket-api -o wide --sort-by=.spec.nodeName
NAME READY STATUS RESTARTS AGE IP NODE
basket-api-8548fbd765-8wc44 1/1 Running 0 1s 10.244.2.29 desktop-worker
basket-api-8548fbd765-t7grg 1/1 Running 0 1s 10.244.1.32 desktop-worker2
basket-api-8548fbd765-zx9zs 1/1 Running 0 1s 10.244.1.31 desktop-worker2
basket-api-8548fbd765-97jhm 1/1 Running 0 1s 10.244.4.35 desktop-worker3
basket-api-8548fbd765-tshz8 1/1 Running 0 1s 10.244.4.34 desktop-worker3

All five run, spread 1-2-2. Nothing is Pending, which is the whole point of the preferred form.

Be aware that a preference is one scoring input among several. While testing this article on a busy cluster, with Pods from a previous run still terminating, one attempt put all five replicas on a single node. The rule was obeyed in the sense that it was considered; it simply lost to other scores. If an even spread really matters, use a topology spread constraint, covered in the next article, which gives you a hard, measurable limit on imbalance.

Step 12 — Clean Up

Delete the namespace and remove every label you added. The trailing - removes a label.

kubectl delete namespace sched-affinity
kubectl label node desktop-worker lesson.dotnetacademy.dev/zone- lesson.dotnetacademy.dev/disk-
kubectl label node desktop-worker2 lesson.dotnetacademy.dev/zone- lesson.dotnetacademy.dev/disk-
kubectl label node desktop-worker3 lesson.dotnetacademy.dev/zone- lesson.dotnetacademy.dev/disk-

Summary

  1. The scheduler filters nodes with hard rules, then scores the survivors with soft rules. Required rules filter; preferred rules score.
  2. nodeSelector is a simple AND of node labels. If nothing matches, the Pod stays Pending.
  3. nodeName bypasses the scheduler, ignores taints, and pins Pods to a single machine. Keep it out of application manifests.
  4. Node affinity adds In, NotIn, Exists, DoesNotExist, Gt and Lt. Terms are ORed, expressions inside a term are ANDed, and preferred terms carry weights from 1 to 100.
  5. IgnoredDuringExecution means label changes never move running Pods. Only newly scheduled Pods see them.
  6. Pod affinity and anti-affinity match labels of other Pods inside a topology domain chosen by topologyKey. kubernetes.io/hostname means "per node"; a zone label means "per zone".
  7. Required anti-affinity caps replicas at the number of domains. Preferred anti-affinity never blocks scheduling but does not guarantee an even spread.
  8. Use your own label prefix for custom node labels, and on cloud clusters prefer the well-known topology.kubernetes.io/zone label.

In the next article we turn the problem around with taints and tolerations, which let a node keep Pods away, and with topology spread constraints, which give you precise control over how evenly replicas are distributed.