Kubernetes Scheduler — Deep Internals
A pod that comes in without a nodeName doesn't get scheduled by magic — it moves through a strict two-phase pipeline (Filter, then Score), gets bound to whichever node wins, and only then does that node's kubelet take over and actually start it. This guide walks through that whole pipeline, what happens when no node fits, and the taint/toleration/affinity/topology-spread mechanics that feed into it.
How the Scheduler Works
The scheduler has one job: assign a nodeName to a Pod that has none. It runs a two-phase algorithm for every unscheduled pod.
flowchart TD
WATCH["Scheduler watches API Server<br/>for pods with nodeName=''"] --> QUEUE
QUEUE["Priority Queue<br/>(sorted by PriorityClass)"] --> FILTER
FILTER["Phase 1: Filter (Predicates)<br/>eliminate nodes that CANNOT run the pod"] --> SCORE
SCORE["Phase 2: Score (Priorities)<br/>rank remaining nodes 0-100"] --> BEST
BEST["Select highest-scoring node<br/>(tie-break: random)"] --> BIND
BIND["Bind: write nodeName to pod<br/>via API Server"] --> KUBELET
KUBELET["kubelet on that node<br/>watches for its pods, starts container"]
What's the key difference between what the Filter phase does and what the Score phase does?
Phase 1: Filter Plugins
Every filter plugin runs for every node. A node is eliminated if any filter returns false.
graph LR
NODE["Candidate Node"] --> F1["NodeUnschedulable<br/>cordon check"]
F1 --> F2["NodeResourcesFit<br/>enough CPU/mem?"]
F2 --> F3["NodeAffinity<br/>nodeSelector / affinity rules"]
F3 --> F4["TaintToleration<br/>pod tolerates node taints?"]
F4 --> F5["PodTopologySpread<br/>spread constraints"]
F5 --> F6["VolumeBinding<br/>PVC can be bound here?"]
F6 --> PASS["Node passes --> enters Score phase"]
F1 & F2 & F3 & F4 & F5 & F6 -->|"any fail"| REJECT["Node eliminated"]
Key filter plugins:
| Plugin | What it checks |
|---|---|
NodeResourcesFit |
Node has enough Allocatable - requested CPU/memory |
NodeAffinity |
Pod's nodeSelector and affinity.nodeAffinity match node labels |
TaintToleration |
Pod's tolerations cover all node taints with NoSchedule/NoExecute |
PodTopologySpread |
topologySpreadConstraints — spread pods across zones/nodes |
VolumeBinding |
PVC's storageClass zone matches node's zone |
NodeUnschedulable |
Node is not cordoned |
A node passes 5 of the 6 filter plugins above but fails NodeResourcesFit. Does it proceed to the Score phase?
Dynamic Resource Allocation (DRA) doesn't add a row to the filter-plugin table above — it's a newer, separate scheduler extension point, the DynamicResources plugin, that sits alongside Filter/Score rather than inside either phase. That's because it isn't doing a scalar Allocatable - requested check the way NodeResourcesFit does; it resolves ResourceClaim/DeviceClass binding instead, matching structured device attributes rather than comparing a single number against capacity. See ai-infra/gpu-scheduling.md for the full treatment of DeviceClass, ResourceClaim, and the capability gap it closes over the classic GPU extended-resource model.
Phase 2: Score Plugins
Remaining nodes are scored 0-100 by each plugin. Final score = weighted sum.
graph LR
NODES["Filtered nodes<br/>[node-1, node-2, node-3]"] --> S1["LeastAllocated<br/>prefer node with most free CPU/mem"]
S1 --> S2["NodeAffinity<br/>preferred rules add score"]
S2 --> S3["InterPodAffinity<br/>co-locate with preferred pods"]
S3 --> S4["ImageLocality<br/>node already has the image? +score"]
S4 --> FINAL["Final scores:<br/>node-1: 72<br/>node-2: 85 ← winner<br/>node-3: 61"]
| Plugin | Goal |
|---|---|
LeastAllocated |
Spread load — pick the least-used node |
MostAllocated |
Bin-pack — fill nodes before using new ones (saves cost) |
NodeAffinity |
Honour preferred affinity rules |
ImageLocality |
Prefer nodes that already pulled the image (faster start) |
TaintToleration |
Nodes with matching tolerations get higher score |
If both MostAllocated and LeastAllocated were enabled as score plugins at real weight for the same cluster, would they push scheduling decisions in the same direction?
LeastAllocated favors the node with the most free capacity (spread load out); MostAllocated favors the node that's already more full (bin-pack, to free up whole nodes elsewhere). The final score is a weighted sum across every enabled plugin, so running both with meaningful weight would just fight each other rather than push toward one clear strategy.What Happens When No Node Passes Filter
sequenceDiagram
participant SCHED as Scheduler
participant API as API Server
participant POD as Pod
SCHED->>SCHED: Filter: 0 nodes pass
SCHED->>API: add Event to pod:<br/>"FailedScheduling: 0/3 nodes available:<br/>3 Insufficient cpu"
Note over POD: Pod stays Pending indefinitely
Note over POD: Scheduler retries every ~1s (backoff)
Note over SCHED: If Cluster Autoscaler present:
SCHED->>API: Node expander watches Pending pods
API->>CA: new EC2 node provisioned
CA->>API: node registers as Ready
SCHED->>SCHED: retry scheduling --> node passes Filter
SCHED->>API: Bind pod to new node
FailedScheduling events — what they mean
kubectl describe pod <pod> -n <namespace>
# Events:
# Warning FailedScheduling 0/3 nodes available:
# 3 Insufficient cpu → raise CPU requests or add nodes
# 3 node(s) had untolerated taint → add toleration to pod
# 3 node(s) didn't match node affinity → fix nodeSelector/affinity
# 1 pod has unbound PVC → PVC not bound, check StorageClass
# 3 node(s) didn't match topology → spread constraints impossible
A pod shows FailedScheduling due to insufficient CPU on all 3 nodes, and there's no Cluster Autoscaler in the cluster. Does the pod eventually get scheduled on its own?
Full Flow: Pod Scheduled to a Node
sequenceDiagram
participant USER as kubectl apply
participant API as API Server (etcd)
participant SCHED as kube-scheduler
participant KUBELET as kubelet (node)
participant CRI as containerd (CRI)
participant CNI as CNI plugin
participant POD as Pod
USER->>API: POST /pods (Pod spec, nodeName='')
API->>API: store in etcd, status=Pending
SCHED->>API: watch: new pod with no nodeName
SCHED->>SCHED: Filter + Score --> node-2 wins
SCHED->>API: POST /pods/binding --> nodeName=node-2
API->>KUBELET: kubelet on node-2 watches its pods
KUBELET->>CRI: RunPodSandbox (create pause container)
CRI->>CNI: ADD (setup network namespace, assign IP)
CNI-->>CRI: pod IP = 10.0.1.15
KUBELET->>CRI: PullImage (if not cached)
KUBELET->>CRI: CreateContainer + StartContainer
KUBELET->>API: pod status = Running, podIP = 10.0.1.15
API->>ENDPOINT: EndpointSlice updated with new pod IP
API->>KPROXY: kube-proxy updates iptables rules
Note over POD: traffic now routes to this pod
Same sequence, one step at a time:
kubectl apply POSTs the pod spec to the API server with nodeName=''. It lands in etcd with status=Pending — nothing is running yet.
nodeName — and drops it into its priority queue, sorted by PriorityClass.
NodeResourcesFit, NodeAffinity, TaintToleration, PodTopologySpread, VolumeBinding, and NodeUnschedulable all get a vote.
LeastAllocated, NodeAffinity, InterPodAffinity, ImageLocality, ...). A node's final score is the weighted sum across every enabled plugin.
Binding object to the API server, which writes nodeName onto the pod.
RunPodSandbox, the CNI plugin sets up the network namespace and assigns a pod IP, the image is pulled if it isn't cached, and the container starts. Pod status flips to Running.
EndpointSlice with the new pod IP, and kube-proxy rewrites its iptables rules on every node. Traffic doesn't reach the pod until this last step completes.
Once the scheduler binds a pod to node-2, does traffic immediately start routing to it?
nodeName — the kubelet still has to pull/start the container and get its IP from the CNI plugin, and only after the pod is Running does the API server propagate that IP into an EndpointSlice, which kube-proxy then turns into iptables rules. Traffic flows only after that last step.Try It Yourself: Live Filter + Score
Four nodes, 8 CPU each, all empty. Add a pod with a CPU request (leave it blank for a random 1-4) and watch the same two phases from above run for real: Filter drops any node that doesn't have enough free CPU, then Score ranks whatever's left with LeastAllocated — score = (capacity - used) / capacity, highest free-fraction wins, ties broken by lowest node index instead of the real scheduler's random tie-break so this demo stays reproducible. Push a pod too big for every remaining node and it lands in Pending instead of blocking, exactly like the FailedScheduling case above. Removing a bound pod frees its capacity immediately, but a pod already sitting in Pending is not auto-rescheduled — the real scheduler only retries on its own trigger, not the instant capacity opens up.
Taints, Tolerations, and Affinity
Taints — repel pods from nodes
# Taint a node (no GPU pods without toleration)
kubectl taint node gpu-node-1 nvidia.com/gpu=present:NoSchedule
# key=value:effect
# Effects: NoSchedule | PreferNoSchedule | NoExecute
The three effects aren't interchangeable severity levels of the same thing — flip through what each actually does:
tolerationSeconds to delay that eviction instead of tolerating it forever.
Tolerations — allow pods onto tainted nodes
spec:
tolerations:
- key: "nvidia.com/gpu"
operator: "Exists"
effect: "NoSchedule"
Node Affinity — attract pods to nodes
spec:
affinity:
nodeAffinity:
# Hard rule: pod MUST land on a node with this label
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values: ["us-east-1a", "us-east-1b"]
# Soft rule: prefer nodes with this label, but not required
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node.kubernetes.io/instance-type
operator: In
values: ["m5.2xlarge"]
These two blocks behave like the Filter/Score split from earlier — one is a hard eligibility rule, the other is only a scoring hint:
requiredDuringSchedulingIgnoredDuringExecution acts like an extra Filter plugin: the pod must land on a node matching one of the nodeSelectorTerms. No match on any node means the pod stays unscheduled, same as failing any other filter.
preferredDuringSchedulingIgnoredDuringExecution acts like an extra Score plugin: matching nodes get a score boost proportional to weight, but a non-matching node is still eligible — it just ranks lower and can still win if nothing else beats it.
Topology Spread Constraints — spread across zones
spec:
topologySpreadConstraints:
- maxSkew: 1 # max diff in pod count between zones
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # or ScheduleAnyway
labelSelector:
matchLabels:
app: api
A pod has a toleration that matches a node's taint. Does that mean the scheduler will prefer to place the pod on that node?