Working model#

The scheduler packs reservations, not measurements. After placement, the kernel arbitrates real consumption; the gap between those two views creates most surprises.

Translate the resource contract before scheduling it#

A node's hardware capacity is not the amount available to Pods. Kubernetes reports allocatable CPU, memory, and other resources after reserving capacity for the operating system and node components. The scheduler compares Pod requests with that allocatable amount.

A request is reserved demand used for placement and, for some resources, contention policy. A limit is a runtime ceiling or enforcement input. Neither field reports current use. One Kubernetes CPU means one vCPU or physical core's compute capacity as reported by the node; 1000m means one CPU and 250m means one quarter. Memory is bytes, with binary suffixes such as Mi and Gi. Write units explicitly because 400m CPU and 400Mi memory describe unrelated quantities.

CPU, memory, storage bandwidth, network queues, cache, and kernel work remain shared on a node. A noisy neighbor is another workload whose use of one of those shared resources delays or destabilizes its peers. CPU and memory declarations help, but they do not isolate every queue or device.

node capacity
  - operating-system and Kubernetes reservations
  = node allocatable

sum of scheduled requests must fit allocatable capacity
observed use later competes under kernel and cgroup policy

Feasible first, preferable second#

Scheduling starts with unscheduled Pods. Filter plugins remove nodes that lack requested resources or violate hard constraints. Score plugins rank what remains, reserve plugins protect the decision, and bind records the chosen node. A high score cannot rescue a node that failed a filter.

Requests drive the resource-fit calculation even when a container usually consumes less. This makes placement predictable, but inflated requests strand capacity while missing requests let a node accept more work than it can survive.

Namespace policy can reject the object before scheduling begins. A ResourceQuota caps aggregate namespace use such as requested CPU, memory, object counts, or selected storage. A LimitRange can set defaults or minimum and maximum values for individual Pods, containers, or claims. Admission against those policies is separate from whether any node can fit the admitted Pod.

CPU slows down; memory can end the process#

CPU requests influence shares during contention, while a CPU limit can impose a cgroup quota and throttle work. Memory requests guide placement; a memory limit becomes a hard ceiling that can lead to an OOM kill. Neither request nor limit measures current demand.

LL2: CPU scheduling and locality follows runnable delay, cache, and NUMA behavior below this abstraction. LL6: Containers and cgroups follows how the node kernel accounts for and enforces the resource contract.

Kubernetes derives Pod quality-of-service (QoS) classes from request and limit patterns. QoS affects eviction order under node pressure, but it doesn't make a process immune to its own cgroup limit. Inspect throttling, working set, out-of-memory (OOM) events, and node pressure together.

For a Horizontal Pod Autoscaler (HPA) CPU-utilization target, utilization is measured against the relevant CPU request, not the limit. A missing request can leave utilization undefined for that Pod and prevent the metric from producing the expected scale decision. On clusters with Pod-level resources enabled, CPU and memory budgets can also appear at spec.resources; capacity and QoS inspection must include both Pod-level and container-level declarations.

Spread what must survive; pack what can wait#

Node affinity selects labeled hardware or zones. Taints repel Pods unless a matching toleration admits them. Pod affinity and anti-affinity express relationships among workloads, while topology spread constraints distribute replicas across domains. Every hard rule shrinks the feasible set, sometimes to zero.

Priority and preemption can clear lower-priority Pods for urgent work; they don't create nodes. HPA changes replica demand, and a node autoscaler such as Karpenter or Cluster Autoscaler changes supply. Queue age or depth often describes worker pressure better than CPU, provided the metric has a defined aggregation, freshness check, stabilization window, and downstream-capacity limit.

Autoscaling has separate demand and supply loops#

The Horizontal Pod Autoscaler changes a workload's desired replica count from measured demand. The scheduler then tries to place those new Pods. If they do not fit, a node autoscaler may ask the cloud provider for more machines. Node startup, image pulls, initialization, and readiness all happen after the demand spike, so autoscaling cannot create instant capacity.

Every loop needs a metric, target, sampling interval, delay, minimum and maximum, and stop condition. CPU can fit a compute-bound request handler. Queue age or drain time often fits workers better because a deep queue with cheap handlers and a shallow queue with expensive handlers need different responses. Scaling also stops at downstream limits: database connections, API quotas, Pod IP addresses, or Kafka partition concurrency can become the actual ceiling.

Reservations set the fit even when dashboards look empty#

Assume each node has 4 vCPU and 16 GiB of allocatable memory after system reservation. A worker Pod requests 1.5 vCPU and 5 GiB. Two workers fit because they reserve 3 vCPU and 10 GiB. A third would request 4.5 vCPU and fail the CPU filter even though memory would remain. If the first two workers happen to use only 200 millicores each, the scheduler still sees their 3 vCPU of requests; observed idleness does not rewrite the reservation.

Now suppose the same Pod has a 2 vCPU limit and 6 GiB memory limit. Short CPU bursts above its available quota can be throttled without a restart. Crossing the memory limit can end a container with an OOM reason. The node can also experience pressure from page cache, kernel memory, image storage, or Pods without honest requests. Size from measured working sets and service behavior, then keep enough unreserved capacity for daemons, rollout surge, eviction recovery, and a failed node.

Run the same arithmetic for every hard placement domain, including zones and dedicated pools.

node allocatable: 4 vCPU, 16 GiB
worker request: 1.5 vCPU, 5 GiB
2 workers: 3 vCPU, 10 GiB → feasible
3 workers: 4.5 vCPU, 15 GiB → rejected by CPU fit

Separate placement failure from runtime contention#

For Pending Pods, read scheduler events and evaluate each hard condition: requests, node selectors, required affinity, taints and tolerations, topology spread, volume topology, host ports, quotas, and priority. kubectl describe pod <name> usually shows the filter summary. Compare that message with kubectl get nodes labels, taints, and allocatable resources. If a node autoscaler does not add capacity, look for an impossible constraint or provider limit before increasing a pool maximum.

For running Pods, compare CPU usage with throttled periods and throttled time, then compare memory working set, limit, container termination reason, and node pressure. A high CPU percentage with low request may trigger HPA while the process is not throttled; a low average CPU can hide a single-thread limit or long request queue. For noisy neighbors, group latency and resource signals by node and compare an affected node with a healthy peer. Check disk I/O, network, and kernel pressure too, because CPU and memory charts do not account for every shared resource.

Advanced scheduler internals#

The ordinary placement, contention, and autoscaling model above is enough for most workload design and first-line diagnosis. The remaining sections explain kube-scheduler's queues, plugin phases, serialized decision boundary, scaling cost, and custom profiles.

Follow one attempt through queues and plugin phases#

kube-scheduler implements placement as a pipeline of plugin extension points. PreEnqueue decides whether a newly observed Pod may enter the active queue, and QueueSort chooses which active attempt runs next. The scheduling cycle filters Nodes, scores the feasible set, and selects one Node. The binding cycle applies that selection through the Kubernetes API.

After selecting a Node, the scheduler temporarily assumes the Pod on that Node in its in-memory cache. Later serial scheduling cycles can then account for the promised resources without waiting for the API binding request. Reserve plugins may keep related state of their own, and Permit plugins may approve, reject, or briefly hold the choice before binding.

flowchart LR
  accTitle: One Pod through scheduler queues and plugin phases
  accDescr: An unscheduled Pod passes PreEnqueue and ActiveQ before one serial scheduling cycle selects a Node. The scheduler assumes that placement in memory, runs Reserve and Permit, then starts a binding cycle. Rejection or binding failure removes reserved and assumed state before retry.

  Pod["Unscheduled Pod"] --> Pre["PreEnqueue"]
  Pre --> Active["ActiveQ"]
  Active --> Sort["QueueSort picks one attempt"]
  Sort --> Select["Scheduling cycle<br/>PreFilter → Filter → Score → select Node"]
  Select --> Assume["Assume Pod on Node<br/>in scheduler cache"]
  Assume --> Reserve["Finish scheduling cycle<br/>Reserve → Permit"]
  Reserve --> PreBind["Start binding cycle<br/>PreBind"]
  PreBind --> Bind["Bind through API server<br/>persist Node assignment"]
  Bind --> PostBind["PostBind<br/>notification after success"]

  Pre -->|"reject"| Retry["BackoffQ or unschedulable Pod pool"]
  Select -->|"no feasible Node"| Retry
  Reserve -->|"failure after Reserve"| Undo["Unreserve plugin state<br/>and forget assumed Pod"]
  PreBind -->|"failure"| Undo
  Bind -->|"failure"| Undo
  Undo --> Retry
  Retry -->|"backoff expires or relevant state changes"| Active

If Reserve or a later phase fails, the scheduler calls the Reserve plugins' Unreserve methods in reverse Reserve order and forgets the assumed Pod. Unreserve must tolerate repetition and cannot report failure. The attempt then returns to a retry queue; otherwise stale assumptions or plugin allocations could make later Pods appear to consume capacity that no API object owns.

Pending is a Pod phase, not one scheduler queue#

The default scheduler watches for Pods that request its scheduler name and do not yet have a Node. Its scheduling queue is an in-memory control-plane structure, not a durable task queue in etcd.

QueueSort chooses the next Pod from ActiveQ. QueueingHint lets a plugin examine a cluster change and decide whether that change could make a rejected Pod schedulable. A new matching Node label may wake a Pod rejected by node affinity; an unrelated Secret update should not wake every resource-fit failure. This avoids retrying the entire unschedulable population for every event.

Do not infer queue membership from status.phase: Pending. A Pod already bound to a Node can remain Pending while images, volumes, or containers are prepared. A Pod naming another scheduler is outside this scheduler's ordinary queue. Count scheduler_pending_pods by its queue label and scheduling-attempt metrics rather than treating every Pending Pod as waiting in ActiveQ.

One placement decision is serial, while node work and binding can overlap#

The scheduling cycle shown above ends after the Permit extension point. In the ordinary per-Pod path, kube-scheduler runs these cycles serially, so a later Pod does not begin its placement decision until the current Pod finishes that cycle. Binding cycles can overlap because the assumed cache already makes each unfinished selection visible to later scheduling work.

That statement does not mean the process is wholly single-threaded. Filter plugins for different Nodes may run concurrently, Score work is parallelized across Nodes in the current implementation, and binding cycles may overlap later scheduling cycles. A Permit plugin can approve, deny, or wait with a timeout. PreBind performs work required before assignment, Bind records the selected Node, and PostBind receives notification after success. A rejection follows the rollback path in the diagram.

Pod A: [serial scheduling cycle ----------------] [async binding cycle ------]
Pod B:                                          [serial scheduling cycle ---] [binding]

inside one scheduling cycle:
  Nodes can be filtered and scored concurrently

The serialized boundary protects a coherent placement decision against the scheduler's latest assumed state. Binding concurrency hides API latency without letting two later cycles spend the same apparent capacity. A second active scheduler replica normally waits under leader election; adding replicas improves failover, not steady-state scheduling throughput for one profile.

Derive the O(P × N) bound instead of memorizing it#

Let P be scheduling attempts handled by this scheduler during a window and N be Nodes. P is not the count of unique Pods: retries can make it larger, while already-bound Pending Pods and Pods owned by another scheduler do not belong in it.

For attempt i, let M_i be the number of Nodes inspected by Filter and K_i the feasible Nodes sent to Score. Then K_i <= M_i <= N. If F and S represent the cost of the enabled Filter and Score plugin sets, a useful work model is:

W ~= sum over attempts i of (M_i * F + K_i * S) + retry and PostFilter work

If each attempt scans all Nodes and plugin cost is treated as constant, the Pod-Node work is Theta(P * N). That is the defensible meaning of the common O(P × N) statement. It is a worst-case work bound, not elapsed time: node evaluation can run in parallel. It also omits costs that are not constant. Inter-Pod affinity and topology checks can inspect other workload state, volume binding can call storage logic, extenders can add network waits, and preemption can evaluate candidate victims after ordinary filtering finds no feasible Node.

percentageOfNodesToScore stops after enough feasible Nodes#

kube-scheduler does not always send every Node to Score. percentageOfNodesToScore sets a target number of feasible Nodes. Filtering can stop after finding that target, then Score ranks only those candidates. It does not promise that the scheduler inspects only that percentage: when most Nodes are infeasible, it may inspect all N Nodes to find a much smaller feasible set.

With automatic configuration, current documentation gives 50 percent for a 100-Node cluster, 10 percent for 5,000 Nodes, and a lower bound of 5 percent. The implementation also seeks at least 100 feasible Nodes; clusters with fewer than 100 feasible Nodes are fully checked. At 5,000 Nodes, the automatic target is therefore 500 feasible Nodes. If nearly every Node fits, the scheduler can stop after roughly 500 inspections. If only 20 fit, it can inspect all 5,000 and still return only 20 candidates.

Lowering the percentage trades placement quality for search throughput because a Node with a better score may never enter the candidate set. Iteration starts at a rotating point and interleaves zones so the same prefix is not favored for every Pod. Setting 100 requests all feasible Nodes. Keep the default until measurement shows node search is the limiting stage and poorer placement is acceptable.

Kubernetes v1.35 introduced Opportunistic Batching as a beta feature enabled by default. Eligible consecutive Pods with equivalent scheduling constraints can reuse Filter and Score results. That can reduce repeated work for a large homogeneous rollout, but it does not remove the O(P x N) worst case for ineligible, changing, constrained, or non-batched attempts. Confirm the feature state and eligibility rules for the cluster version under study.

Work a 5,000-Node burst from queue to etcd#

Suppose 2,000 Pods become ready for scheduling on a 5,000-Node cluster. First analyze the non-batched path, either because batching is unavailable or because the Pods are not eligible to share results. A full scan offers up to 2,000 * 5,000 = 10,000,000 Pod-Node filter opportunities before plugin multiplicity. With the current automatic 10 percent target and a mostly feasible fleet, each attempt may find 500 candidates quickly, for roughly 1,000,000 successful feasibility checks and 1,000,000 Node candidates sent through Score. If hard topology or resource rules leave few feasible Nodes, inspection moves back toward the 10,000,000 upper bound.

Now suppose profiling under this exact plugin set and object population measures a mean scheduling-cycle time of 5 ms. Because placement cycles are serial, 2,000 attempts need at least about 10 seconds of scheduling-cycle time even if binding overlaps and no retries arrive. If 100 new attempts arrive each second during the drain, the net drain rate is about 100 per second rather than 200, so the original burst takes about 20 seconds. These are illustrative calculations from a measured input, not kube-scheduler performance guarantees.

After each successful choice, the scheduler posts the binding through the API server. The API server conditionally persists the Pod's assignment to etcd, updates its watch cache, and publishes the change. The selected kubelet then observes the bound Pod and begins node-local reconciliation. High churn therefore spends queue and plugin work, API admission and serialization, an etcd mutation, watch fan-out, and kubelet work for each successful placement. CI3: Control planes, etcd, and reconciliation develops that storage path.

Measure the stages separately:

Signal Question it answers
scheduler_pending_pods by queue Are attempts active, backing off, or unschedulable?
scheduler_schedule_attempts_total by result Are attempts succeeding, failing, or returning unschedulable?
scheduler_scheduling_attempt_duration_seconds How long does the complete scheduling attempt, including binding, take?
scheduler_scheduling_algorithm_duration_seconds (alpha) How long does the scheduling algorithm take before binding?
scheduler_plugin_execution_duration_seconds (alpha) Which extension point, plugin, and status own execution time?
API binding request latency and errors Is the selected decision slow or failing to persist?
etcd proposal, WAL, and backend latency Is durable control-plane storage limiting the bind path?
Pod scheduled condition and events Which user-visible placement rule rejected the Pod?

Fictional case. The bookshop keeps latency-sensitive storefront-api Pods on the default scheduler. Disposable image-resize Jobs may opt into a second scheduler profile that scores requested CPU and memory with MostAllocated, which helps empty a node for later scale-down. The profile name and every value below belong only to this fictional example.

Trace a fictional packing profile to Pod opt-in#

The scheduler configuration fragment below defines the teaching profile batch-packer. It tells the NodeResourcesFit scoring plugin to prefer nodes with a larger requested-resource share. The fragment shows only the scoring contract, and one kube-scheduler process can host multiple profiles. If the cluster instead runs batch-packer in a separate scheduler process, that deployment also needs its own arguments, credentials, permissions, health checks, and distinct leader-election identity.

apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
  - schedulerName: batch-packer
    pluginConfig:
      - name: NodeResourcesFit
        args:
          scoringStrategy:
            type: MostAllocated
            resources:
              - name: cpu
                weight: 1
              - name: memory
                weight: 1

An image-resize Job opts in with spec.template.spec.schedulerName: batch-packer; an ordinary Pod without that field uses default-scheduler. The profile name joins the running scheduler to the Pod. Resource requests supply the scoring inputs, while current dashboard use does not, so inspect the Pod requests before trying to explain a placement.

The running scheduler profile and each workload opt-in form one contract.

Summary#

Scheduling and runtime resource control use different inputs. The scheduler admits Pods against declared reservations and hard placement rules; after binding, cgroups and the kernel decide how actual CPU, memory, I/O, and network demand contend.

References#