CoursesKubernetes administrationHow the scheduler places pods

How the scheduler places pods

Filter then score: how a pod is assigned a node.

Intermediate12 min · lesson 20 of 65
In plain terms
The scheduler is a host seating diners. First it crosses off tables that can’t fit the party (filter), then picks the best table that’s left (score). If no table fits, the party waits at the door — that’s a Pending pod.

A Pod you just created has an empty box on its record where the node name should go. Nothing is running it yet. It's just an entry in etcd, the cluster's key-value database, and nothing more. The scheduler exists to fill in that one blank. It reads the Pod, picks a machine, and writes the machine's name back through the API server (the cluster's front desk for every read and write). The kubelet, the agent running on that machine, only notices the Pod once the name is written. So the whole story of placement is one field getting filled. Learn how the scheduler chooses and you'll know why a Pod landed where it did, or why it's stuck at the door.

Think of a gate agent finding you a seat on a full flight. First they cross off every seat that won't work: no exit row for the passenger with a lap infant, no window if you asked for aisle, nothing already taken. Then, from whatever's left, they pick the best one against a few preferences. The scheduler runs those exact two phases, in that order, and it has names for them. Filter throws out every node that can't run the Pod. Score ranks the survivors. The highest-scoring node wins.

Filter, then score

The scheduler watches the API server for Pods with no nodeName, and works through them one at a time. The filter phase runs the Pod against every node and asks a yes/no question at each one. Does this node have enough allocatable CPU and memory for the Pod's requests? Does the Pod tolerate whatever taints the node carries (a taint is a 'keep off unless invited' mark on a node)? Do the Pod's nodeSelector and node affinity match the node's labels? Are the ports it wants free? Can the node attach its volumes? Each check is a separate plugin, and a node has to pass all of them to survive. What's left is the set of feasible nodes.

If exactly one node survives, it's chosen. If several do, the score phase gives each a number from 0 to 100 by running another set of plugins, adds them up with weights, and takes the top. By default the scheduler prefers the emptier node, so Pods spread out instead of piling onto one machine. It also leans toward nodes that already have the container image cached, and honors any 'preferred' affinity you asked for. Once a winner is picked the scheduler binds the Pod: it writes the node name back (technically a small Binding request to the API server), and its job is done. Now the kubelet on that node sees a Pod addressed to it and starts pulling the image.

terminal
kubectl run web --image=nginx:1.27
kubectl get pod web -o wide
output
pod/web created
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
web 1/1 Running 0 9s 10.244.2.7 node-2 <none> <none>
terminal
kubectl get events --field-selector involvedObject.name=web,reason=Scheduled
output
LAST SEEN TYPE REASON OBJECT MESSAGE
9s Normal Scheduled pod/web Successfully assigned default/web to node-2

That single Scheduled event is the whole filter-and-score run, compressed to one line. The scheduler picked node-2 and bound the Pod there. Everything after it in the Pod's event list (Pulling, Started) belongs to the kubelet, not the scheduler.

The filter measures requests, not what's actually running

Here's the part that trips people up. When the filter checks whether a node has room, it does not look at how busy the machine really is. It looks at the sum of the CPU and memory requests of the Pods already placed there, and compares that to the node's allocatable amount. Requests are the reservation you wrote in the Pod spec. Allocatable is the node's total capacity minus what's held back for the kubelet and the operating system. So a node can sit nearly idle and still refuse a Pod, because its requests are already reserved to the ceiling. The reverse bites harder: Pods with no requests set look free to the scheduler, so it happily stacks a dozen of them on one node, which then falls over under real load.

terminal
kubectl describe node node-2 | grep -A6 "Allocated resources"
output
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 3800m (95%) 6 (150%)
memory 6144Mi (37%) 8Gi (50%)
terminal
kubectl top node node-2
output
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
node-2 840m 21% 5210Mi 32%

Read the two together. The scheduler sees CPU at 95% reserved and treats node-2 as almost full. The real box is running at 21%. Both numbers are correct. They just measure different things, and the scheduler only cares about the first one. That gap is where most surprising placement decisions come from.

Reading a Pending pod

A Pending Pod almost always means the filter phase rejected every node, and the scheduler writes down exactly why. It fires a FailedScheduling event on the Pod, and the message names the count and the reason. This is the single most useful scheduling skill you can build. Don't guess. Read the event.

big.yaml
apiVersion: v1
kind: Pod
metadata:
name: big
spec:
containers:
- name: app
image: nginx:1.27
resources:
requests:
cpu: "8"
terminal
kubectl apply -f big.yaml
kubectl describe pod big | grep -A5 Events:
output
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 30s default-scheduler 0/3 nodes are available: 3 Insufficient cpu. preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.

Read the message left to right. Zero of three nodes are available, and the reason is Insufficient cpu on all three, because no single node has 8 whole cores free to reserve. The second half, the preemption line, is the scheduler telling you it also tried to make room by evicting lower-priority Pods and found none worth removing. The fix falls straight out of the reason. Lower the request, add a node big enough, or give the Pod a priority that lets it preempt. If the reason had said 'untolerated taint' you'd add a toleration. If it said 'didn't match node selector' you'd fix the label the Pod is asking for. The event points at the exact filter that rejected everything, so you never have to guess your way to the fix.

Is this node feasible for the Pod?
Filter: check one node against the Pod
the same checks run on every node
requests exceed free allocatable
Rejected
tallied as Insufficient cpu / memory
node has a taint the Pod won't tolerate
Rejected
tallied as untolerated taint
labels don't match nodeSelector / affinity
Rejected
tallied as didn't match node selector
passes every filter check
Feasible
moves to scoring; highest-scored feasible node is bound
Every node runs this gauntlet. If no node reaches Feasible, the Pod stays Pending and the FailedScheduling event tallies how many nodes each reason knocked out.
Adding nodes won't rescue a Pod that's too big for any one node
The filter checks nodes one at a time, and a Pod has to fit entirely on a single node's allocatable resources. Cluster-wide free capacity means nothing to it. A Pod that requests 8 cores will sit Pending forever on a cluster of ten 4-core nodes, even with 40 cores free in total, because none of those cores live on one machine. People see the Pending Pod, panic, and scale the cluster out, and the Pod still doesn't move. The tell is a FailedScheduling event reading Insufficient cpu on every node while kubectl top nodes shows plenty of idle capacity spread around. The real fix is to shrink the Pod's request or add one node large enough to hold it, not more small ones.

nodeName short-circuits the scheduler. Use it for emergencies and labs, not as a placement strategy.

Priorities and preemption change who wins under pressure. Understand QoS before you invent priority classes.

Scheduler profiles and plugins matter on advanced clusters. Defaults are enough until you run specialized hardware pools. nodeName short-circuits the scheduler.

Try this

Create a pod, watch it bind, then create one that cannot schedule (huge memory request). Read the Pending events for the filter that rejected every node.

terminal
$ kubectl run web --image=nginx:1.27
$ kubectl get pod web -o wide
$ kubectl get events --field-selector involvedObject.name=web,reason=Scheduled
$ kubectl describe node node-2 | grep -A6 "Allocated resources"
$ kubectl top node node-2
$ kubectl apply -f big.yaml
$ kubectl describe pod big | grep -A5 Events:

Takeaway

Filter then score. Pending with no node is a scheduling failure — describe the pod and read the predicates.

Quick check
01kubectl top node shows node-2 sitting at 20% CPU with plenty of headroom. Yet a new Pod that requests 500m of CPU won't schedule onto node-2 and lands in Pending with 'Insufficient cpu'. What's going on?
Correct — Filtering runs on the sum of Pod requests, not on what the node is really burning. node-2 has reserved almost all its CPU to existing Pods that aren't spending it, so there's no room left to reserve, and top's 20% never enters the decision.
Incorrect — No. top reads live numbers from the metrics-server and is generally current. Even if it were exactly right at 20%, the scheduler still wouldn't care, because it schedules against requests, not measured usage.
Incorrect — No. A missing nodeSelector makes a Pod eligible for all nodes, not none. And that failure would show a selector or affinity reason, not Insufficient cpu.
Incorrect — No. A cordoned (unschedulable) node is filtered out with its own reason about being unschedulable. Insufficient cpu specifically means reserved requests left no room.
02Three nodes all pass the filter phase for a new Pod. How does the default scheduler decide which of the three actually gets the Pod?
Incorrect — the scheduler doesn't order alphabetically; it runs the scoring phase and takes the highest score.
Incorrect — raw capacity isn't the tiebreaker; scoring favors the least-committed node and things like a cached image.
Correct — after filtering, scoring ranks survivors and by default prefers the emptier node.
Incorrect — multiple feasible nodes is the normal case; scoring resolves it automatically without human input.
03A Pod requesting 8 CPU cores sits Pending on a cluster of ten nodes, each with 4 allocatable cores and largely idle. Its FailedScheduling event reads 'Insufficient cpu' on all ten nodes. What actually gets it scheduled?
Incorrect — the filter checks one node at a time and the Pod must fit entirely on a single node, so more 4-core nodes never help.
Correct — the Pod has to fit on one node, so either shrink the request or provide a single big-enough node.
Incorrect — the scheduler never sums capacity across nodes for one Pod, so a restart changes nothing.
Incorrect — scheduling filters on requests, not measured usage, so live metrics don't affect the decision.

Related