Guide · EKS Pod Placement
Node affinity and pod placement for MCP servers on EKS
Without explicit placement rules, Kubernetes packs all your MCP server replicas onto the same node or same availability zone — meaning a single EC2 failure or AZ outage takes your entire MCP fleet offline.
TL;DR
Use topologySpreadConstraints with topologyKey: topology.kubernetes.io/zone to spread MCP replicas across AZs, and podAntiAffinity with topologyKey: kubernetes.io/hostname to keep replicas on different nodes. For dedicated MCP node pools, add a taint to the node group and a matching toleration to the pod spec. Avoid requiredDuringSchedulingIgnoredDuringExecution pod anti-affinity if you ever need to scale past your node count — it will deadlock the scheduler.
Node affinity: controlling which nodes MCP pods land on
Node affinity is the structured replacement for nodeSelector. It lives at spec.affinity.nodeAffinity in your pod or Deployment spec and supports two scheduling modes:
- requiredDuringSchedulingIgnoredDuringExecution — hard rule. The pod will not schedule if no node matches. "IgnoredDuringExecution" means existing pods are not evicted if a node's labels change after scheduling.
- preferredDuringSchedulingIgnoredDuringExecution — soft preference with a weight (1–100). The scheduler prefers matching nodes but will fall back to non-matching nodes if necessary.
Common MCP server use case: require a specific instance family (memory-optimized r7g nodes for large tool context windows) while preferring a particular AZ for latency reasons:
# deployment-mcp-affinity.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: mcp
spec:
replicas: 3
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values:
- r7g.large
- r7g.xlarge
- r7g.2xlarge
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 70
preference:
matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- us-east-1a
- weight: 30
preference:
matchExpressions:
- key: eks.amazonaws.com/nodegroup
operator: In
values:
- mcp-primary-ng
containers:
- name: mcp-server
image: your-ecr-repo/mcp-server:latest
resources:
requests:
cpu: "500m"
memory: "1Gi"
Node labels EKS sets automatically: topology.kubernetes.io/zone (the AZ), node.kubernetes.io/instance-type (EC2 instance type), and eks.amazonaws.com/nodegroup (managed node group name). You don't need to add these — they're present on every EKS node.
Pod anti-affinity: keeping replicas on different nodes
Pod anti-affinity keeps your replicas spread by repelling pods that share the same label selector. The topologyKey defines what "same" means — kubernetes.io/hostname means "same physical node", topology.kubernetes.io/zone means "same AZ".
# pod-anti-affinity fragment — add to spec.template.spec.affinity
affinity:
podAntiAffinity:
# Hard rule: no two mcp-server pods on the same node
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- mcp-server
topologyKey: kubernetes.io/hostname
# Soft preference: prefer different AZs
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- mcp-server
topologyKey: topology.kubernetes.io/zone
Critical limitation of hard pod anti-affinity: if you have 3 nodes and set requiredDuringSchedulingIgnoredDuringExecution on hostname, you can never scale beyond 3 replicas. The 4th pod will be Pending indefinitely because every node already has one mcp-server pod. Use preferredDuringSchedulingIgnoredDuringExecution if you need to scale beyond node count, or pair with topology spread constraints instead.
Topology spread constraints: flexible cross-AZ distribution
Topology spread constraints are a newer, more flexible replacement for pod anti-affinity for distribution use cases. Instead of "no two pods here", they express "the difference between the most-loaded and least-loaded topology domain should not exceed N".
# topology-spread-mcp.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: mcp
spec:
replicas: 6
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
topologySpreadConstraints:
# Cross-AZ spread: no AZ should have more than 1 extra pod vs others
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: mcp-server
# Cross-node spread within each AZ
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # soft — don't block if nodes are unequal
labelSelector:
matchLabels:
app: mcp-server
containers:
- name: mcp-server
image: your-ecr-repo/mcp-server:latest
The maxSkew: 1 rule with 3 AZs means pod counts across AZs can differ by at most 1. For 6 pods across 3 AZs you get 2-2-2. For 7 pods you get 3-2-2. This works cleanly.
The unsatisfiable case: 2 replicas, 3 AZs, maxSkew: 1, whenUnsatisfiable: DoNotSchedule. The only valid distributions are 1-1-0, but the constraint requires the max difference to be ≤ 1 — that means max is 1 and min is 0, difference is 1, which is actually satisfiable. But for maxSkew: 0 with 2 pods and 3 AZs it is impossible — keep this in mind when setting very tight skew values at low replica counts.
Dedicated node pools with taints and tolerations
If your MCP servers have special hardware requirements (GPUs, high memory, specific instance families) or you want to prevent noisy-neighbor interference from other workloads, create a dedicated node group and taint it so only MCP pods land there.
Add the taint to the EKS managed node group (eksctl example):
# eksctl cluster config fragment
managedNodeGroups:
- name: mcp-dedicated
instanceType: r7g.xlarge
minSize: 2
maxSize: 10
taints:
- key: dedicated
value: mcp
effect: NoSchedule # pods without matching toleration won't schedule here
labels:
workload: mcp-server
tags:
k8s.io/cluster-autoscaler/enabled: "true"
k8s.io/cluster-autoscaler/my-cluster: "owned"
Add the matching toleration to the MCP pod spec:
# pod spec fragment — tolerations allow scheduling on the tainted node group
spec:
tolerations:
- key: dedicated
operator: Equal
value: mcp
effect: NoSchedule
# Also add nodeAffinity to *require* landing on these nodes
# (toleration alone only permits — it doesn't enforce)
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: workload
operator: In
values:
- mcp-server
Important: a toleration is permission, not direction. Adding only a toleration lets the pod schedule on the tainted nodes but doesn't force it there — the scheduler may still place it on untainted nodes. Add a requiredDuringScheduling nodeAffinity for the node group label to enforce exclusive placement.
Spot instance pattern: use NoSchedule taint on spot node groups and add tolerations only to fault-tolerant MCP workloads. Keep critical MCP servers on on-demand nodes via preferredDuringScheduling nodeAffinity for the on-demand node group label with a high weight.
Failure modes reference
| Symptom | Root cause | Fix |
|---|---|---|
| Pod stuck in Pending with "didn't match node selector" | requiredDuringSchedulingIgnoredDuringExecution nodeAffinity has no matching nodes |
Run kubectl describe pod <name> to see which expression fails; check node labels with kubectl get nodes --show-labels |
| Can't scale Deployment beyond node count | Hard pod anti-affinity on kubernetes.io/hostname — each node can hold only one pod |
Switch to preferredDuringSchedulingIgnoredDuringExecution or use topologySpreadConstraints with whenUnsatisfiable: ScheduleAnyway |
| topologySpreadConstraints causes Pending pods | maxSkew: 1 with DoNotSchedule and an impossible distribution (e.g., one AZ has no nodes) |
Set whenUnsatisfiable: ScheduleAnyway for the node-level spread; keep DoNotSchedule only for AZ-level spread; ensure all AZs in the topology have at least one node |
| Node label changed but running pods not moved | "IgnoredDuringExecution" — existing pods are never evicted when node labels change | Rolling restart the Deployment to reschedule pods: kubectl rollout restart deployment/mcp-server -n mcp |
| MCP pods evicted from spot node unexpectedly | Spot interruption adds NoExecute taint; pod lacks matching toleration with NoExecute effect |
Add toleration for node.kubernetes.io/unreachable:NoExecute and node.kubernetes.io/not-ready:NoExecute with appropriate tolerationSeconds; use PDB to limit simultaneous evictions |
| Dedicated node group stays empty — other workloads don't go there | Taint only prevents scheduling without toleration; Cluster Autoscaler may scale down these nodes | Expected behavior — the taint is working. Ensure CA is aware of the node group and has k8s.io/cluster-autoscaler/enabled tag set |
| All pods land in one AZ despite spread constraints | Topology spread counts only pods matching the labelSelector; existing pods may have different labels |
Verify labelSelector in topologySpreadConstraints matches the pod labels exactly; check with kubectl get pods -n mcp --show-labels |