Scheduling & Resource Management
Kubernetes Architecture named kube-scheduler as one box on a diagram: it filters, it scores, it writes a Binding. This page opens that box, and goes past what Workloads & Scheduling's CKA-level treatment of affinity, taints, and requests requires into territory the exam doesn't test but production clusters live or die on. The scheduler is a pluggable framework with named extension points, not a monolith; a Pod's resource requests and limits aren't abstract policy but numbers translated directly into Linux cgroup settings the kernel enforces; QoS class determines the exact order the kubelet kills processes under memory pressure, computed as a real number rather than a label; and affinity, taints, topology spread, and priority all act as independent, sometimes competing, placement signals the scheduler has to reconcile at once, every time. If Workloads & Scheduling taught you the vocabulary, this page teaches you the mechanism underneath each word.
Picture assigning seats on a crowded lifeboat. Before anyone gets a seat, the assigner throws out every boat that plainly can't take them — wrong knot they can't tie, no room left at all. Of the boats still in the running, the assigner scores each one: which is least crowded, which keeps a family together. Only then does a name go on the seating chart. Beforehand, everyone wrote down how much space they truly need, not how much they'd grab if nobody stopped them — and if a boat gets dangerously full, the crew doesn't guess who goes over the side first; that order was decided in advance by whose promise was loosest. A latecomer wearing a red priority flag can bump someone with no flag at all — politely, giving them a moment to gather their things — but only if there's truly nowhere else left to put the flag-wearer.
The scheduler's two-phase algorithm, as a plugin pipeline
☺ Like you're 10: "Filter, then score" is the headline, but underneath it's really a checklist of small named steps running one after another, and any one of them can be swapped out or added to.
Modern kube-scheduler isn't a single filter-then-score function — it's the scheduling framework, a pipeline of named extension points that built-in plugins (and, if you write one, your own) hook into. For one Pod pulled off the scheduling queue, the scheduling cycle runs serially, one Pod at a time: PreFilter does cheap up-front checks and can reject the whole cycle early (a Pod requesting a hostPort already claimed cluster-wide, say); Filter is where plugins like NodeResourcesFit, NodeAffinity, TaintToleration, and PodTopologySpread each get a veto over every node — a single "no" from any plugin removes that node from consideration; PostFilter only runs if zero nodes survive filtering, and its default implementation is what triggers preemption; PreScore and Score then rank the nodes that did survive, and Reserve/Permit provisionally hold that node for the Pod before anything is written back to the API. Only after Permit does the binding cycle run — PreBind, Bind (which is what actually writes the Binding object), PostBind — and this second cycle is allowed to run asynchronously, in parallel across several Pods, precisely because the scheduler already made its irreversible node decision back in the (serial) scheduling cycle.
Which node wins the Score phase is itself configurable. The NodeResourcesFit plugin, which scores nodes on how well the Pod's requests fit their allocatable capacity, supports two opposite strategies: LeastAllocated (the default) favors the least-loaded node, spreading Pods out and leaving headroom on every node — good for absorbing spikes, worse for bin-packing efficiency; MostAllocated favors the most-loaded node that can still fit, packing Pods tightly onto as few nodes as possible so the rest can scale to zero — the strategy a cost-conscious cluster running the Cluster Autoscaler or Karpenter usually wants, since an empty node is a node that can be removed. Both are set per-cluster in a KubeSchedulerConfiguration, not per-Pod.
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated # pack tight; default is LeastAllocated
resources:
- name: cpu
weight: 1
- name: memory
weight: 1Requests and limits: what actually enforces them
☺ Like you're 10: A request is the honest promise the seating chart is built from; a limit is a hard wall the kernel itself builds around you once you're seated.
Requests and limits aren't Kubernetes-level policy enforced by some Kubernetes process watching over your shoulder — they're translated straight into Linux cgroup settings on the node, the same primitive that gives every container its isolation in the first place (see DevOps' containers & orchestration page for cgroups and namespaces from the container side). And CPU and memory are enforced completely differently, because one is compressible and the other isn't. A CPU request becomes a proportional cpu.weight (cgroup v2; cpu.shares on the older v1 hierarchy) — under contention, the kernel's CFS scheduler divides spare CPU time proportionally to weight, but a container can still burst past its request any time the node has slack. A CPU limit becomes a hard ceiling, cpu.max (v2) or cpu.cfs_quota_us/cpu.cfs_period_us (v1) — cross it and the kernel simply throttles the container's processes for the rest of that period; nothing dies, it just gets slower, which is exactly why a CPU-throttled Pod can look "fine" in kubectl get pods while p99 latency quietly falls apart.
Memory has no such compressible middle ground. A memory request sets no cgroup limit at all by default — it exists purely for the scheduler's bin-packing math and, as the next section covers, for computing how aggressively the kubelet will kill this Pod under pressure. A memory limit becomes a real, hard cap, memory.max (v2) or memory.limit_in_bytes (v1). Cross it and the kernel's own OOM killer fires inside that cgroup, sending SIGKILL to the container's process — the container goes OOMKilled, and the kubelet restarts it per the Pod's restartPolicy, same as any other crash. There's no throttling equivalent for memory, because you can't "slow down" an allocation that's already happened.
# On a cgroup v2 node, every container gets its own leaf cgroup —
# these are the actual files kubelet's translation of requests/limits lands in
CGROUP=/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod.slice/cri-containerd-.scope
cat $CGROUP/cpu.max # "50000 100000" → 500m CPU limit (50ms compute per 100ms period)
cat $CGROUP/cpu.weight # derived from the request — a relative share, not a ceiling
cat $CGROUP/memory.max # the container's memory limit in bytes, or "max" if unset
# When something in this cgroup gets OOM-killed, the kernel logs it here first
dmesg -T | grep -i "oom-kill\|out of memory" CPU is compressible; memory isn't. That single fact is why a CPU limit only ever throttles — the kernel can always hand out less CPU time and let a process wait its turn — while a memory limit can only ever kill, because there's no way to partially un-allocate memory a process is actively using. It's also why setting a memory limit without a matching request is far more dangerous than doing the same for CPU: a CPU-starved Pod degrades, a memory-starved one dies.
QoS classes and the kubelet's eviction order, precisely
☺ Like you're 10: Every process on the node gets a "how much do I mind being sacrificed" number, and under real pressure the kernel just kills whoever volunteered the most.
Workloads & Scheduling already covers what determines a Pod's QoS class — Guaranteed when every container's requests equal its limits, Burstable when at least one doesn't, BestEffort when none are set at all. What that page doesn't need to go into is how the kubelet turns that class into an actual kill decision: each container's Linux oom_score_adj value, which the kernel's OOM killer consults directly whenever it needs to pick a victim under real memory pressure. BestEffort containers get oom_score_adj pinned to 1000, the maximum — first in line, always. Guaranteed containers get -997, deep in negative territory — last in line, effectively protected unless nothing else is left. Burstable sits in between, scaled by how large the Pod's memory request is relative to the node's allocatable memory: a Burstable Pod that requested almost nothing gets a score near 999 (barely better than BestEffort); one that requested most of the node's capacity gets a score near 2 (barely worse than Guaranteed). Requesting more, even without raising your limit, buys real protection.
This only fires once the kubelet detects actual node pressure — it polls a fixed set of eviction signals (memory.available, nodefs.available, nodefs.inodesFree, imagefs.available, imagefs.inodesFree, pid.available) against configured thresholds, hard or soft. A hard threshold evicts immediately, no grace period, best-effort graceful termination only. A soft threshold instead starts a grace-period timer — if the signal is still past threshold when the timer expires, eviction proceeds; if it recovers first, nothing happens. Crossing a hard threshold doesn't reach for the OOM killer directly, either — the kubelet's own eviction manager tries to reclaim first (deleting dead containers, unused images) and, failing that, evicts whole Pods proactively, ranked by the same QoS-driven logic: BestEffort Pods over their requests go first, then Burstable Pods exceeding their requests, then Guaranteed only as an absolute last resort. The raw kernel OOM killer, using the oom_score_adj values above, is the backstop for the case the kubelet doesn't catch in time — a sudden memory spike inside a single container, rather than gradual node-wide pressure.
# Typical kubelet eviction flags — hard thresholds are on by default;
# soft thresholds are opt-in and need a paired grace period
--eviction-hard=memory.available<100Mi,nodefs.available<10%,imagefs.available<15%
--eviction-soft=memory.available<300Mi
--eviction-soft-grace-period=memory.available=1m30s
--eviction-max-pod-grace-period=30An eviction is not a Deployment shrinking on purpose — it's the kubelet's emergency valve, and it shows up as a Pod moving to Failed with reason Evicted, not as a normal termination. A cluster that evicts routinely almost always means requests are set too low across the board relative to what's actually running — the fix is right-sizing requests (or handing that job to a VerticalPodAutoscaler), not raising the eviction thresholds to make the symptom quieter.
Affinity and anti-affinity, past required vs. preferred
☺ Like you're 10: "Preferred" isn't a coin flip — every preference carries a weight, and the scheduler adds all of them up like a scorecard before picking a winner.
The mechanical difference between requiredDuringSchedulingIgnoredDuringExecution and preferredDuringSchedulingIgnoredDuringExecution is filter versus score, not a stronger versus weaker version of the same check. A required rule runs in the Filter extension point — fail it and the node is gone, full stop. A preferred rule runs in Score: each term carries a weight from 1 to 100, and for every node that passes filtering, the scheduler sums the weights of every preferred term that node happens to satisfy, then adds that sum into the node's overall score alongside NodeResourcesFit and the rest. A node can fail every single preferred term and still win, if its resource-fit score is high enough — "preferred" is a vote, not a gate. The IgnoredDuringExecution half of both names is doing real work too: neither kind of rule is re-checked once the Pod is running, so a node's labels changing out from under an already-placed Pod is silently ignored — Kubernetes will not evict a Pod just because its affinity stopped matching after the fact.
Inter-pod affinity and anti-affinity (podAffinity/podAntiAffinity) cost more than node affinity for a structural reason: a node-affinity check only reads one node's own labels, an O(1) lookup, while a pod-affinity check has to find and count other Pods matching a label selector across the whole topology domain named by topologyKey — genuinely more expensive at scheduler scale, which is exactly why the CKA blueprint's canonical anti-affinity example (spread replicas one-per-node with topologyKey: kubernetes.io/hostname) is a required rule kept deliberately small in scope. A newer field, matchLabelKeys, fixes a real rolling-update bug: without it, a Deployment's anti-affinity rule matching on app: checkout sees both the old and new ReplicaSet's Pods as "the same app" during a rollout and can refuse to schedule the new revision anywhere the old one still runs. Adding pod-template-hash to matchLabelKeys makes the affinity comparison implicitly scope itself to Pods from the same ReplicaSet revision only.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80 # summed into node score if satisfied
podAffinityTerm:
labelSelector:
matchLabels: {app: checkout}
matchLabelKeys: [pod-template-hash] # don't compare across rollout revisions
topologyKey: topology.kubernetes.io/zone
- weight: 20
podAffinityTerm:
labelSelector:
matchLabels: {app: checkout}
topologyKey: kubernetes.io/hostnameTaints and tolerations the cluster writes for you
☺ Like you're 10: Not every taint comes from a human typing kubectl taint — the cluster itself slaps a taint on a node the moment something's visibly wrong with it.
Workloads & Scheduling covers the three effects — NoSchedule, PreferNoSchedule, NoExecute — from the perspective of a taint you set on purpose. In practice, most of the taints doing real work in a running cluster are ones nobody typed: the node controller and kubelet apply a fixed set of node.kubernetes.io/-prefixed taints automatically the instant a condition is detected — not-ready and unreachable (both NoExecute, applied when a Node's heartbeat Lease stops renewing), disk-pressure, memory-pressure, pid-pressure, and network-unavailable (all NoSchedule), plus unschedulable the moment you run kubectl cordon. A newer one, node.kubernetes.io/out-of-service, exists specifically for a node that was shut down ungracefully — hardware failure, force-deleted VM — so StatefulSet Pods bound to it via PersistentVolumeClaims can be forcibly detached and rescheduled instead of waiting forever for a node that's never coming back.
The NoExecute effect carries one more knob most required-rule reading skips: tolerationSeconds. A Pod can tolerate not-ready or unreachable for a bounded window rather than forever — long enough to survive a brief network blip without every Pod on that node being evicted and rescheduled, but not so long that a genuinely dead node holds workloads hostage.
tolerations:
- key: node.kubernetes.io/not-ready
operator: Exists
effect: NoExecute
tolerationSeconds: 60 # ride out a short blip; evict after 60s if still not-ready
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 60Every kubelet-managed Pod, and every DaemonSet Pod created through the default admission plugin, already carries built-in tolerations for most of these with a short tolerationSeconds — which is precisely how a log-collector or CNI DaemonSet keeps running on a node that just went NotReady, riding it out instead of getting evicted the instant the taint lands. Writing your own NoExecute toleration on an ordinary Deployment Pod without thinking hard about it is a genuine anti-pattern: it's the one place a small YAML addition can silently turn "this node is unreachable" into "this Pod just keeps not restarting anywhere else."
Topology spread constraints: skew, not just presence
☺ Like you're 10: Anti-affinity asks "is there already one of me here, yes or no" — topology spread asks "how lopsided would placing me here make things," and answers with a number.
topologySpreadConstraints solves a problem pod anti-affinity answers only crudely: keeping replicas evenly spread across a topology domain, not just merely present in more than one. Each constraint names a topologyKey (commonly topology.kubernetes.io/zone or kubernetes.io/hostname), a maxSkew — the maximum allowed difference between the domain with the most matching Pods and the domain with the fewest — and a whenUnsatisfiable policy: DoNotSchedule makes the constraint a hard filter, same weight as a required affinity rule; ScheduleAnyway demotes it to a scoring preference, same weight-based logic as preferred affinity. Unlike anti-affinity's binary "matches or doesn't," skew is recalculated for every candidate placement — the scheduler is directly answering "if I put this Pod in zone B, does max(counts) - min(counts) across all zones stay within maxSkew?"
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels: {app: checkout}
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway # nice-to-have within a zone; not worth blocking on
labelSelector:
matchLabels: {app: checkout}Priority and preemption
☺ Like you're 10: A higher-priority latecomer can bump a lower-priority Pod out of its seat — but only politely, by asking it to leave, never by throwing it overboard instantly.
Every Pod carries a priority, an int32 resolved at admission time from its priorityClassName — a vocabulary Kubernetes inherited straight from Google's internal Borg system, mentioned back in What Is Kubernetes, and Why as one of the ideas Borg battle-tested first. A PriorityClass object's value can be anything up to 1,000,000,000 for ordinary workloads; the range above that is reserved for Kubernetes' own two built-in classes, system-cluster-critical (2,000,000,000) and system-node-critical (2,000,001,000), which only core cluster add-ons should ever use. At most one PriorityClass in the cluster may set globalDefault: true — every Pod that doesn't name one explicitly gets that value, and if none is marked default, unset Pods fall back to priority 0.
Preemption only activates as the PostFilter fallback from the pipeline earlier on this page — the scheduler always tries to place a Pod on genuinely free capacity first, and only reaches for preemption once zero nodes survive ordinary filtering. When it does, the DefaultPreemption plugin evaluates each infeasible node by asking "if I removed some lower-priority Pods here, would this Pod then fit?", and picks victims to minimize disruption — fewest Pods removed, lowest priority first, and it will not choose a set of victims that would violate a PodDisruptionBudget if any non-violating option exists. The chosen node is recorded on the pending Pod's status.nominatedNodeName, and victims are terminated gracefully, respecting their own terminationGracePeriodSeconds — which means preemption is not instant. Nothing stops a different, equal-or-higher-priority Pod from grabbing that freed capacity in the gap between eviction and rebind; the nomination is a strong hint to the scheduler on retry, not a lock.
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: checkout-critical
value: 100000
globalDefault: false
preemptionPolicy: PreemptLowerPriority # set to "Never" to keep the priority
description: "Customer-facing checkout path — may preempt lower-priority batch work."Preemption fixes a full node; it does nothing for a full cluster. If every node is genuinely out of capacity, evicting lower-priority Pods just makes them Pending instead — nothing gets scheduled, work just moves down the priority ladder, and if the freed capacity keeps getting immediately reclaimed by more incoming high-priority Pods, low-priority workloads can starve indefinitely. Priority is a placement tool, not a capacity-creation tool; that's the Cluster Autoscaler's or Karpenter's job.
Beyond the default scheduler
☺ Like you're 10: When one-Pod-at-a-time placement isn't the right shape for the job, you don't fight the built-in scheduler — you run a second one, side by side with it, just for that job.
Every Pod's spec.schedulerName defaults to default-scheduler, but nothing requires that — you can deploy a second scheduler binary into the cluster under a different name, and any Pod naming it in schedulerName is picked up by that scheduler instead, completely independently of the default one. This is how batch and ML workload schedulers like Volcano and Kueue plug in: the default scheduler's one-Pod-at-a-time model has no concept of gang scheduling — placing all N Pods of a distributed training job together, or none at all, so you never end up with 7 of 8 workers running and burning GPU-hours while the eighth sits Pending forever. Kueue in particular layers job-level queueing and quota management on top of the existing scheduler rather than replacing it outright, and belongs in the same conversation as everything else CNCF's ecosystem builds around Kubernetes' extension points — see The CNCF Project Landscape for where projects like it sit relative to the core project.
On a kind cluster, create a Namespace-scoped ResourceQuota, deploy three replicas of a Deployment with no resource requests set at all, and watch what happens: they schedule instantly (BestEffort has nothing to check against a quota that only limits requested resources), but kubectl describe node shows nothing reserved for them. Now add a conservative CPU and memory request to the Pod spec and roll the Deployment. Compare kubectl describe node | grep -A5 "Allocated resources" before and after — that's the scheduler's own bin-packing math becoming visible, not a metrics-server number, and it's exactly what NodeResourcesFit reads to decide where each replica lands.
"I used to think requests were a formality — round the average usage up a bit, ship it, move on. Then a node's memory pressure evicted three of my Burstable Pods in the same minute a completely unrelated Guaranteed Pod on the same node didn't so much as flinch, and I finally sat down and worked out what oom_score_adj actually does with the numbers I'd been typing without thinking. The request isn't a suggestion to the scheduler. It's a number the kernel itself keeps, and consults, long after the scheduler has moved on to the next Pod entirely."
Gizmo the Gremlin: Batch job keeps losing its spot to the API Pods. Easy fix — bump its PriorityClass value to a billion. Let it preempt anything. Problem solved, forever.
Sol the Sloth: Slow down. A billion is the ceiling for ordinary workloads — one step from there and you're squatting in the range Kubernetes reserves for its own system-cluster-critical add-ons. That's not "high priority," that's "outranks the CNI plugin."
Timmy the Turtle: And it doesn't even fix the actual problem. If the cluster's genuinely out of capacity, all you've done is move who's Pending — the API Pods get evicted instead, and if this batch job runs constantly, it just starves everything under it permanently.
Benny the Beaver: What actually happened is the batch job has no resource requests, so it's BestEffort, and the scheduler's just placing it wherever looks empty at that instant — which happens to be wherever the API Pods aren't, until they scale up and it gets bumped.
Sol the Sloth: Give the batch job an honest request instead — even a modest one. That alone moves its oom_score_adj off the BestEffort ceiling and lets the scheduler actually reason about where it fits, instead of it landing by accident and getting swept aside the moment something real needs the space.
Foxy: So: a modest, honest priority for "don't starve this," plus a real request so it's not accidental in the first place. Not one giant number pretending to be both.
Timmy the Turtle: Exactly. Priority answers "who goes first when it's tight." Requests answer "how much room do you actually need." Confusing the two is how a batch job quietly becomes the most privileged thing in the cluster.
1. What's the structural difference between a required affinity rule and a preferred one, in terms of which scheduler extension point each runs in? 2. Why does a CPU limit only ever throttle a container, while a memory limit can only ever kill it? 3. A Burstable Pod requested nearly all of a node's allocatable memory. Is its oom_score_adj closer to a BestEffort Pod's or a Guaranteed Pod's, and why? 4. What's the actual difference between what pod anti-affinity checks and what a topology spread constraint's maxSkew checks? 5. Why can a preempted node's freed capacity still end up going to a Pod other than the one that triggered the preemption? 6. What problem do gang-scheduling systems like Kueue solve that the default scheduler's one-Pod-at-a-time model structurally can't?
Check your answers
- A required rule runs in the
Filterextension point and can eliminate a node outright — a single failure removes it from consideration entirely. A preferred rule runs inScore: each term has a weight (1–100), and the scheduler sums the weights of satisfied terms into the node's overall score. A node can fail every preferred term and still be chosen if its other scores are high enough. - CPU is compressible — the kernel can hand a process less CPU time and let it wait, so a limit becomes a throttle via
cpu.max/cpu.cfs_quota_us. Memory is incompressible — there's no way to partially un-allocate memory already in use, so a limit becomes a hard cap (memory.max) enforced by the cgroup OOM killer sending SIGKILL. - Closer to a Guaranteed Pod's. Burstable's
oom_score_adjscales between roughly 2 and 999 based on how large the memory request is relative to the node's allocatable memory — a request close to the node's full capacity pushes the score down near 2, close to Guaranteed's -997, not up near BestEffort's 1000. - Pod anti-affinity checks presence — is there already a matching Pod in this topology domain, a binary yes/no. A topology spread constraint's
maxSkewchecks balance — the numeric difference between the domain with the most matching Pods and the one with the fewest, recalculated for each candidate placement, so it can enforce genuinely even distribution rather than merely "more than one domain." - Because preemption only nominates a node and gracefully terminates the victims — it doesn't reserve the freed capacity with a lock. Graceful termination respects each victim's
terminationGracePeriodSeconds, and in that window any other equal-or-higher-priority Pod the scheduler places can claim the space first;status.nominatedNodeNameis a strong hint on retry, not a guarantee. - The default scheduler places one Pod at a time with no awareness that several Pods belong to the same job. A distributed training job needs all of its worker Pods scheduled together or not at all — otherwise you can end up with most workers running and burning resources while the rest sit Pending indefinitely. Gang schedulers like Kueue or Volcano add that all-or-nothing, job-level placement on top of the existing scheduler.