What Scheduling Is and Why It's Hard
Summary
- Definition: Kubernetes scheduling is the act of binding an unscheduled Pod to a specific node so the kubelet there can run it.
- Insight: The scheduler does not run your containers. It only writes a node name into the Pod's
spec.nodeName, and the kubelet on that node does the rest. - Key Concepts: the
kube-schedulercontrol loop, the scheduling queue, filter (predicate) and score (priority) phases, binding, and feasibility. - When to Use: every workload you submit goes through scheduling, so this shapes availability, cost, performance, and blast radius.
- Limitations/Trade-offs: scheduling is a one-shot decision at bind time; it does not re-evaluate placement as the cluster changes.
- Related Topics: node affinity, taints and tolerations, topology spread, and priority/preemption.
Foundations
A Pod is created with an empty spec.nodeName.
That empty field is the signal: this Pod needs a home.
The kube-scheduler watches for such Pods and, for each one, chooses exactly one node that can run it.
Think of the cluster as a set of bins (nodes), each with a finite capacity of CPU, memory, ephemeral storage, and a cap on the number of pods.
Each Pod is an item with a size (its resource requests) and a set of placement rules.
Scheduling is bin-packing under constraints: fit every item into some bin without violating any rule.
This is why the problem is genuinely hard.
Bin-packing is NP-hard in the general case, and Kubernetes layers many competing constraints on top: affinity, anti-affinity, taints, topology spread, resource fit, and priority.
The scheduler does not search for a provably optimal packing. It makes a fast, good-enough decision for one Pod at a time.
Mechanics & Interactions
The scheduler runs a control loop with a clear pipeline for each Pod.
First, the Pod enters the scheduling queue, an internal priority queue ordered by Pod priority and arrival.
Second comes the filter phase (historically called predicates). Every node is checked against hard requirements: does it have enough allocatable CPU and memory for the Pod's requests, does it tolerate the node's taints, does it match nodeSelector and required node affinity, are requested volumes attachable there.
Nodes that pass are called feasible. Nodes that fail are eliminated.
Third comes the score phase (priorities). Each feasible node gets a numeric score from scoring plugins: spreading, affinity preferences, least- or most-allocated resource balancing, image locality, and more.
The scheduler picks the highest-scoring node, breaking ties at random to avoid always favoring the same node.
Fourth is binding: the scheduler issues an API call that sets spec.nodeName.
Only then does the kubelet on the chosen node see the Pod and start pulling images and creating containers through the CRI runtime, which is containerd, not Docker Engine.
A key interaction: the scheduler reasons about requests, not usage or limits.
A node with 90% actual CPU utilization but low reserved requests still looks empty to the scheduler.
This decoupling is deliberate but is a frequent source of surprise.
Advanced Considerations & Applications
Scheduling is a single-shot decision.
Once bound, a Pod stays on its node until it is deleted or evicted; the scheduler never migrates a running Pod because the cluster changed shape.
That is why a separate descheduler component exists: to evict pods that have drifted into suboptimal placement so the scheduler can place them again.
At scale, throughput matters.
To avoid scoring thousands of nodes on every decision, the scheduler samples a percentage of feasible nodes (percentageOfNodesToScore) rather than scoring all of them in very large clusters.
The scheduler is also extensible through the scheduling framework, a set of plugin extension points (PreFilter, Filter, Score, Reserve, Permit, Bind, and others).
Custom plugins, or an entirely separate scheduler named in spec.schedulerName, let teams add domain-specific placement logic.
Preemption adds another dimension: if a high-priority Pod cannot fit, the scheduler may evict lower-priority Pods to make room.
Finally, scheduling interacts with cluster autoscaling.
When no node is feasible, tools like Cluster Autoscaler or Karpenter can provision new nodes so the pending Pod becomes schedulable.
Common Misconceptions
"The scheduler runs my containers." It does not. It only selects a node. The kubelet and containerd start the containers.
"The scheduler balances by actual CPU and memory usage." It balances by resource requests. Real-time utilization is invisible to it unless you add a plugin or metrics-based scoring.
"Placement is re-optimized continuously." Placement is decided once at bind time. Rebalancing requires eviction plus rescheduling, typically via the descheduler.
"More constraints always give better placement." Over-constraining (rigid required affinity plus narrow selectors) commonly produces unschedulable Pods stuck in Pending.
"Limits affect scheduling." Only requests affect the scheduling fit. Limits govern runtime enforcement (throttling and OOM kills), not node selection.
FAQs
Where does the scheduler record its decision? It sets spec.nodeName on the Pod through a binding API call. After that the kubelet takes over.
What does Pending mean? No feasible node was found, or none passed filtering. kubectl describe pod shows the per-node failure reasons in the events.
Can I bypass the scheduler entirely? Yes. Set spec.nodeName directly, or use a static Pod. Both skip filtering and scoring, so you lose all safety checks.
Does every Pod use the same scheduler? By default yes, default-scheduler. You can run multiple schedulers and select one per Pod with spec.schedulerName.
Is scheduling per-Pod or per-Deployment? Per-Pod. A Deployment's replicas are individual Pods, each scheduled independently.
Related
- Scheduling Basics
- How the Scheduler Decides
- nodeSelector, Node Affinity & Anti-Affinity
- Pod Priority, Preemption & the Descheduler
- Scheduling Best Practices
Stack versions: This page was written for Kubernetes 1.36.2, Docker Engine 29.6.1 (BuildKit default), containerd (CRI runtime on nodes), Helm 3, Compose v2, Argo CD (latest - verify at build), and Gateway API (GA - verify controller support at build).