Every Kubernetes exam, from the KCNA to the CKA, eventually asks a version of the same question: a Pod is Pending, why? Most candidates can read the headline reason (Insufficient cpu, an untolerated taint, a node selector that matches nothing) and fix it. Far fewer can say which component wrote that sentence, in which phase, and what would have happened next if the node had passed. That gap matters in two places. In the CKA it shows up as scheduler configuration tasks: tune a scoring strategy, run a second scheduler, make a Pod use it, prove that it did. In the KCNA it shows up as conceptual questions about filtering, scoring and binding that are trivial once you have watched the machinery run and oddly slippery if you have only memorised the words.
This guide is about the machinery. The placement inputs (nodeSelector, node and Pod affinity, taints and tolerations, topology spread, priority and preemption) each have their own deep dive on this blog and are not re-taught here: see the taints, tolerations and affinity guide, the topology spread guide and the priority and preemption guide. What follows is the engine that consumes those inputs: the queue, the scheduling and binding cycles, the plugins at each extension point, how scores are combined, the configuration file that changes all of it, and the operational tasks the CKA builds on top. Everything is from the current Kubernetes documentation (v1.37); feature stages are stated where they matter.
1. The Shape of the Problem: One Pod, One Node, Many Plugins
kube-scheduler is a control-plane process (on kubeadm clusters, a static Pod in kube-system) that watches for Pods with no spec.nodeName and, for each one, chooses a node and writes a Binding. It never starts containers; that is the kubelet’s job once it sees a Pod bound to its node. The whole decision for one Pod is a scheduling context made of two cycles:
- The scheduling cycle selects a node. It runs serially: one Pod at a time, so decisions never race each other.
- The binding cycle applies the decision to the cluster. Binding cycles may run concurrently, because they mostly wait on API calls and, for volumes, on provisioning.
Each cycle is a sequence of extension points, and at each extension point a configured list of plugins runs in order. Since Kubernetes 1.19 this scheduling framework is the only scheduler implementation; the old “predicates and priorities” vocabulary you may still see in older material maps onto Filter and Score plugins respectively.
The two-step summary the KCNA expects is simply: filtering finds the set of feasible nodes, scoring ranks them, the highest score wins, and ties are broken at random. Everything below is the detail behind those three sentences.
2. Before Scheduling: The Queue
A Pod does not go straight from kubectl apply to a scheduling cycle. It enters the scheduler’s scheduling queue, which has three parts:
| Queue | What is in it | How a Pod leaves |
|---|---|---|
| activeQ | Pods ready to be scheduled now | Popped one at a time into a scheduling cycle |
| backoffQ | Pods whose last attempt failed, waiting out an exponential backoff | Backoff expires, Pod moves back to activeQ |
| unschedulablePods | Pods the scheduler could not place and does not expect to be able to place until the cluster changes | A relevant cluster event (node added, Pod deleted, PVC bound) moves them to backoffQ or activeQ |
Three details are exam-relevant:
- Ordering is by priority, then age. The
PrioritySortplugin (the only defaultQueueSortplugin) orders activeQ byspec.prioritydescending, then by the time the Pod entered the queue. This is the first concrete effect of a PriorityClass, before preemption is even considered. - Backoff is short.
podInitialBackoffSecondsdefaults to 1 andpodMaxBackoffSecondsto 10, doubling between attempts. A Pod that keeps failing is retried roughly every ten seconds, which is why fixing a taint or adding a node makes a Pending Pod schedule “almost immediately”. - Queueing hints are GA. Since 1.34 (
SchedulerQueueingHintsstable) each plugin tells the scheduler which cluster events could make a Pod it rejected schedulable again, so unschedulable Pods are not blindly re-queued on every event. From the outside you only notice that Pending Pods react quickly to the right change and ignore irrelevant ones.SchedulerPopFromBackoffQ(beta, default on since 1.33) lets the scheduler pull from backoffQ when activeQ is empty rather than idle.
The queue is also where the first extension point lives: PreEnqueue. A plugin here can block a Pod from entering activeQ at all. The default SchedulingGates plugin does exactly that for Pods with spec.schedulingGates (section 9).
3. The Scheduling Cycle, Extension Point by Extension Point
Here is the full sequence with the default plugins that participate at each point. Memorise the order; the names of the plugins are worth recognising because they appear verbatim in events and error messages.
| Order | Extension point | Purpose | Default plugins that implement it |
|---|---|---|---|
| 0 | PreEnqueue | Gate a Pod before it is even queued | SchedulingGates, DynamicResources |
| 1 | QueueSort | Order the active queue (exactly one allowed) | PrioritySort |
| 2 | PreFilter | Pre-compute Pod-level state; abort early if the Pod can never fit | NodeResourcesFit, NodePorts, PodTopologySpread, InterPodAffinity, VolumeBinding, DynamicResources |
| 3 | Filter | Per node: feasible or not. Run in order; first failure skips the rest for that node. Nodes evaluated in parallel (parallelism, default 16) | NodeUnschedulable, NodeName, TaintToleration, NodeAffinity, NodePorts, NodeResourcesFit, VolumeRestrictions, VolumeZone, NodeVolumeLimits, EBSLimits, GCEPDLimits, AzureDiskLimits, VolumeBinding, PodTopologySpread, InterPodAffinity, DynamicResources |
| 4 | PostFilter | Only when no node is feasible; try to make one | DefaultPreemption |
| 5 | PreScore | Shared state for scorers | TaintToleration, PodTopologySpread, InterPodAffinity |
| 6 | Score | Each plugin scores every feasible node | NodeResourcesFit, NodeResourcesBalancedAllocation, ImageLocality, NodeAffinity, TaintToleration, PodTopologySpread, InterPodAffinity (+ VolumeBinding when StorageCapacityScoring is on, beta in 1.37) |
| 7 | NormalizeScore | Rescale each plugin’s raw scores to 0–100 before weighting | (part of the Score plugins) |
| 8 | Reserve | Reserve node-side state (volumes, DRA claims) before binding; Unreserve rolls it back on any later failure | VolumeBinding, DynamicResources |
| 9 | Permit | Approve, deny, or wait with a timeout (used for gang scheduling by out-of-tree plugins) | none by default |
Then the binding cycle:
| Order | Extension point | Purpose | Default plugins |
|---|---|---|---|
| 10 | PreBind | Work that must finish before binding, e.g. provision and bind a PersistentVolume | VolumeBinding, DynamicResources |
| 11 | Bind | Write the Binding. Plugins run in order; the first one that handles the Pod wins and the rest are skipped | DefaultBinder |
| 12 | PostBind | Informational cleanup after a successful bind | none by default |
Two consequences are worth stating plainly. First, preemption is a PostFilter plugin, which is why it only ever runs when filtering produced zero nodes and never to “improve” a placement; the preemption guide covers what it does from there. Second, volume binding is spread across five extension points (PreFilter, Filter, Reserve, PreBind, Score), which is why a Pod with a WaitForFirstConsumer StorageClass has its PV provisioned during scheduling and why a scheduling failure after Reserve has to roll that reservation back.
4. Reading “0/3 nodes are available” Like a Scheduler
When filtering leaves no node, the scheduler writes a FailedScheduling event and a PodScheduled=False condition whose message concatenates each node’s first failing Filter plugin reason, followed by the PostFilter (preemption) result. A real example:
Warning FailedScheduling default-scheduler 0/3 nodes are available:
1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
2 Insufficient cpu.
preemption: 0/3 nodes are available:
1 Preemption is not helpful for scheduling,
2 No preemption victims found for incoming pod.
Because Filter plugins stop at the first failure per node, the message tells you one reason per node, not all of them. Fix the taint on the control-plane node and you may discover it also lacks CPU. The table below maps the fragments you will see to the plugin that wrote them and the field to inspect:
| Message fragment | Plugin | What to check |
|---|---|---|
node(s) were unschedulable | NodeUnschedulable | kubectl uncordon; spec.unschedulable on the Node |
node(s) had untolerated taint {key: value} | TaintToleration | Node taints vs Pod tolerations |
node(s) didn't match Pod's node affinity/selector | NodeAffinity | nodeSelector, requiredDuringScheduling... vs node labels |
node(s) didn't match the requested node name | NodeName | spec.nodeName points at a node that does not exist or is filtered out |
node(s) didn't have free ports for the requested pod ports | NodePorts | hostPort collisions |
Insufficient cpu, Insufficient memory, Too many pods | NodeResourcesFit | requests vs Allocatable minus existing requests (see the Node Allocatable guide) |
node(s) didn't match pod affinity rules / anti-affinity rules | InterPodAffinity | podAffinity / podAntiAffinity and topologyKey |
node(s) didn't match pod topology spread constraints | PodTopologySpread | maxSkew, topologyKey, whenUnsatisfiable: DoNotSchedule |
pod has unbound immediate PersistentVolumeClaims | VolumeBinding | PVC is Pending with an Immediate StorageClass |
node(s) had volume node affinity conflict | VolumeBinding | PV nodeAffinity (local volumes, zonal disks) vs candidate node |
node(s) exceed max volume count | NodeVolumeLimits | CSI attach limits on the node |
Preemption is not helpful for scheduling | DefaultPreemption | Evicting Pods would not make the node fit (taint, affinity, nodeName) |
No preemption victims found for incoming pod | DefaultPreemption | No lower-priority Pods to evict, or Pod has no priority |
The exam-speed habit is: kubectl describe pod → read the fragments → map each to the field in the table → fix the one that is actually blocking you. Scoring never appears in these messages; if a Pod is Pending, scoring never ran.
5. Scoring: How Plugins and Weights Combine
Once at least one node survives filtering, each Score plugin assigns every feasible node an integer, NormalizeScore maps it to 0–100, the result is multiplied by the plugin’s weight, and the per-plugin products are summed. Highest sum wins; equal sums are broken randomly. Default weights are 1 for every scorer except PodTopologySpread, which defaults to 2 so that spreading beats mild resource preferences.
What each default scorer rewards:
| Plugin | Prefers nodes that… |
|---|---|
| NodeResourcesFit | match the configured scoring strategy (next section); default LeastAllocated prefers emptier nodes |
| NodeResourcesBalancedAllocation | would end up with CPU and memory utilisation similar to each other (penalises a node that becomes 90% CPU / 20% memory) |
| ImageLocality | already have the Pod’s container images pulled (scaled by image size, discounted when many nodes have it so one node does not become an image magnet) |
| NodeAffinity | satisfy more preferredDuringSchedulingIgnoredDuringExecution terms, by their weights |
| TaintToleration | have fewer PreferNoSchedule taints the Pod does not tolerate |
| PodTopologySpread | reduce skew for whenUnsatisfiable: ScheduleAnyway constraints (and the cluster-default constraints) |
| InterPodAffinity | satisfy preferred Pod affinity and anti-affinity terms |
VolumeBinding (StorageCapacityScoring, beta 1.37) | have more (or, configurably, less) free storage capacity for WaitForFirstConsumer volumes |
The important mental model: filtering is binary and scoring is advisory. A required node affinity term removes nodes; a preferred term only tilts the score. A NoSchedule taint removes nodes; a PreferNoSchedule taint only tilts the score. Every “required vs preferred” question on the KCNA and CKA is really a “Filter vs Score” question.
6. Spreading vs Bin-Packing: NodeResourcesFit Scoring Strategies
NodeResourcesFit is the one scorer whose behaviour you are expected to be able to change, through its scoringStrategy argument. Three strategies exist:
LeastAllocated(default): score rises as the node’s remaining free fraction rises. Spreads Pods across nodes; good for availability and headroom.MostAllocated: score rises as the node’s used fraction rises. Packs Pods onto as few nodes as possible; good for cost (the cluster autoscaler can scale empty nodes away) and for batch clusters.RequestedToCapacityRatio: you supply a shape, a list ofutilization → scorepoints, and the scheduler interpolates. It is how you bin-pack on an extended resource such as GPUs while leaving CPU and memory alone.
The arithmetic for the first two, per resource, with the incoming Pod’s request already added to the node’s existing requests:
LeastAllocated score = (allocatable − requested) × 100 / allocatable
MostAllocated score = requested × 100 / allocatable
The per-resource scores are averaged using the resources[].weight values you configure (CPU and memory, weight 1 each, unless you say otherwise).
Worked example. Two nodes, 4 allocatable CPU each. Node A already has 1 CPU requested, node B has 3. A Pod requesting 1 CPU arrives (ignore memory for the moment).
| Requested after placement | LeastAllocated | MostAllocated | |
|---|---|---|---|
| Node A | 2 of 4 | (4 − 2) × 100 / 4 = 50 | 2 × 100 / 4 = 50 |
| Node B | 4 of 4 | (4 − 4) × 100 / 4 = 0 | 4 × 100 / 4 = 100 |
LeastAllocated sends the Pod to A and leaves both nodes half-used. MostAllocated fills B completely and leaves A nearly empty for the autoscaler to remove. Same cluster, same Pod, opposite decision, and the only thing that changed is one line of scheduler configuration.
That line lives in pluginConfig:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
pluginConfig:
- name: NodeResourcesFit
args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: NodeResourcesFitArgs
scoringStrategy:
type: MostAllocated
resources:
- name: cpu
weight: 1
- name: memory
weight: 1
And the GPU bin-packing shape, which gives a node a score of 0 when the GPU is unused and 10 (before normalisation) when it is full, while CPU and memory still spread:
- name: NodeResourcesFit
args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: NodeResourcesFitArgs
scoringStrategy:
type: RequestedToCapacityRatio
resources:
- name: nvidia.com/gpu
weight: 5
- name: cpu
weight: 1
- name: memory
weight: 1
requestedToCapacityRatio:
shape:
- utilization: 0
score: 0
- utilization: 100
score: 10
Reverse the shape (utilization: 0 → score: 10, utilization: 100 → score: 0) and the same strategy spreads instead of packing. The exam signal words are “consolidate”, “reduce node count”, “allow the autoscaler to scale in” (MostAllocated) versus “maximise headroom”, “spread for resilience” (LeastAllocated) versus “pack GPU workloads but not CPU workloads” (RequestedToCapacityRatio).
NodeResourcesFit also accepts ignoredResources and ignoredResourceGroups, which tell the filter to stop treating a given extended resource as a scheduling constraint. That is the fix when a device plugin is temporarily absent and you want Pods requesting its resource to still land somewhere.
7. percentageOfNodesToScore: Why Big Clusters Do Not Score Every Node
On a 5,000-node cluster, scoring every feasible node for every Pod would make scheduling slow for no benefit, because the best of 500 good nodes is good enough. percentageOfNodesToScore tells the scheduler to stop filtering as soon as it has found that percentage of nodes feasible, and to score only those.
The rules, exactly as documented:
- The value is a whole number 0–100.
0means “use the compiled-in default”; anything above 100 behaves as 100. - If you do not set it, the default is adaptive: a linear formula that yields 50% for a 100-node cluster and 10% for a 5,000-node cluster, with a lower bound of 5%.
- There is a hardcoded minimum of 100 feasible nodes. In a cluster with fewer than 100 feasible nodes the scheduler always checks every node, so on the three-node clusters the CKA gives you the setting has no effect at all.
- To keep things fair, the scheduler iterates nodes round-robin, starting each Pod where the previous one stopped, and when nodes span zones it alternates between zones (Node 1 from zone 1, Node 5 from zone 2, Node 2 from zone 1, and so on) so an early stop does not starve a zone.
- The documentation’s advice: leave it alone below several hundred nodes, and never go below 10% unless throughput matters more than placement quality.
It can be set globally or per profile:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
percentageOfNodesToScore: 50
profiles:
- schedulerName: default-scheduler
The sibling knob parallelism (default 16) sets how many goroutines evaluate nodes concurrently during Filter and Score.
8. KubeSchedulerConfiguration: The File That Changes Everything
All of the above is configured through one object, KubeSchedulerConfiguration in API group kubescheduler.config.k8s.io/v1 (the v1beta3 version was removed in 1.29; if you find an old example, bump the apiVersion). You hand it to the binary with --config /path/to/file. A kubeadm control plane does not ship one by default; its scheduler runs on flags only.
The anatomy:
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
clientConnection:
kubeconfig: /etc/kubernetes/scheduler.conf
leaderElection:
leaderElect: true # defaults: resourceName kube-scheduler, resourceNamespace kube-system
parallelism: 16
percentageOfNodesToScore: 0 # 0 = adaptive default
podInitialBackoffSeconds: 1
podMaxBackoffSeconds: 10
profiles:
- schedulerName: default-scheduler
plugins:
score:
disabled:
- name: ImageLocality # turn one scorer off
enabled:
- name: NodeResourcesFit
weight: 3 # re-weight another
pluginConfig:
- name: PodTopologySpread
args:
apiVersion: kubescheduler.config.k8s.io/v1
kind: PodTopologySpreadArgs
defaultingType: System # or List + defaultConstraints
Rules you need:
profilesis a list. Each profile has a uniqueschedulerName, its own enabled/disabled plugins andpluginConfig. A Pod chooses a profile withspec.schedulerName. A Pod that does not set it is defaulted by the API server todefault-scheduler, so a profile with that name should exist in whatever scheduler is running.- All profiles share one queue, so they must use the same
QueueSortplugin with the same arguments. - Disabling with
name: '*'removes every default plugin at that extension point. The documentation’s “no scoring” profile is the canonical example:
profiles:
- schedulerName: default-scheduler
- schedulerName: no-scoring-scheduler
plugins:
preScore:
disabled:
- name: '*'
score:
disabled:
- name: '*'
multiPointenables or disables a plugin at every extension point it implements in one line (plugins.multiPoint.enabled: [{name: MyPlugin}]). Specific extension-point entries overridemultiPoint, which overrides defaults. All default plugins are internally enabled throughmultiPoint, which is why you can still list one at a specific extension point purely to change its order or weight.- Scheduling events carry the profile name as their
reportingController, which is how you prove later that a particular scheduler placed a Pod.
To apply a configuration to the kubeadm default scheduler, drop the file somewhere the static Pod can see it, mount it, and add the flag:
sudo cp scheduler-config.yaml /etc/kubernetes/scheduler-config.yaml
sudo vim /etc/kubernetes/manifests/kube-scheduler.yaml
command:
- kube-scheduler
- --config=/etc/kubernetes/scheduler-config.yaml # add
- --authentication-kubeconfig=/etc/kubernetes/scheduler.conf
- --authorization-kubeconfig=/etc/kubernetes/scheduler.conf
- --bind-address=127.0.0.1
- --kubeconfig=/etc/kubernetes/scheduler.conf
- --leader-elect=true
volumeMounts:
- mountPath: /etc/kubernetes/scheduler-config.yaml # add
name: schedconfig
readOnly: true
volumes:
- hostPath:
path: /etc/kubernetes/scheduler-config.yaml # add
type: FileOrCreate
name: schedconfig
The kubelet restarts the static Pod on save. Verify with kubectl -n kube-system logs kube-scheduler-<node> and, if it crashloops, crictl ps -a and crictl logs on the control-plane node, because a scheduler that cannot parse its config never becomes a running Pod you can kubectl logs. Our static Pods guide covers that debugging loop.
9. Scheduling Gates: Telling the Scheduler “Not Yet”
Pod scheduling readiness has been GA since 1.30. A Pod created with one or more spec.schedulingGates is held by the SchedulingGates PreEnqueue plugin and never enters activeQ; kubectl get pods shows it as SchedulingGated, not Pending.
apiVersion: v1
kind: Pod
metadata:
name: test-pod
spec:
schedulingGates:
- name: example.com/foo
- name: example.com/bar
containers:
- name: pause
image: registry.k8s.io/pause:3.6
The rules:
- Gates can be set only at creation (by the client or a mutating admission webhook). Afterwards they can be removed in any order but never added. When the list is empty the Pod becomes schedulable.
- While gated you may tighten scheduling directives but not loosen them: add
nodeSelectorkeys, addrequiredDuringSchedulingnode affinity terms or expressions, changepreferredterms freely. You cannot remove or widen an existing required term. - The metric
scheduler_pending_pods{queue="gated"}separates gated Pods from Pods that were tried and found unschedulable.
The intended use is an external controller (a quota or queue manager) that admits Pods only when capacity or budget exists, instead of letting thousands of Pods churn the scheduler and the cluster autoscaler. For the exam, recognise the SchedulingGated status, know that kubectl describe will show the gates and no FailedScheduling events (nothing was tried), and know that the fix is to remove the gates, not to add a node.
10. Bypassing the Scheduler Entirely
Three things place Pods without a scheduling cycle, and the CKA likes to see whether you know which:
| Mechanism | Who assigns the node | Does the scheduler run? | Caveat |
|---|---|---|---|
spec.nodeName | You, at creation | No. The API server stores the Pod already bound; the kubelet on that node admits it | No Filter checks happen; the kubelet can still reject it at admission with OutOfcpu, OutOfmemory or an untolerated NoExecute taint |
| Static Pod | The kubelet reading staticPodPath | No. The kubelet creates a mirror Pod in the API for visibility | Cannot be deleted through the API; edit or remove the manifest file |
| DaemonSet | The DaemonSet controller sets nodeAffinity to each target node | Yes, the default scheduler binds it (since 1.12) | Taints matter; the controller adds tolerations for not-ready, unreachable, disk-pressure, memory-pressure, pid-pressure, unschedulable and (for host-network Pods) network-unavailable automatically |
A nodeName Pod on a cordoned node will run, because cordoning sets spec.unschedulable and only the NodeUnschedulable Filter plugin reads it. That is a reliable exam distractor: “cordon prevents all new Pods” is false; it prevents scheduled Pods.
11. Running a Second Scheduler: The Classic CKA Task
The documented way to run an additional scheduler is a Deployment in kube-system with its own ServiceAccount, RBAC, and a ConfigMap holding a KubeSchedulerConfiguration whose profile name is the name Pods will reference. The pieces, in the order you create them:
RBAC and identity. A ServiceAccount, two ClusterRoleBindings to the built-in ClusterRoles system:kube-scheduler and system:volume-scheduler, and a RoleBinding in kube-system to the Role extension-apiserver-authentication-reader (so the scheduler can read the extension-apiserver-authentication ConfigMap for its secure port):
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-scheduler
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: my-scheduler-as-kube-scheduler
subjects:
- kind: ServiceAccount
name: my-scheduler
namespace: kube-system
roleRef:
kind: ClusterRole
name: system:kube-scheduler
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: my-scheduler-as-volume-scheduler
subjects:
- kind: ServiceAccount
name: my-scheduler
namespace: kube-system
roleRef:
kind: ClusterRole
name: system:volume-scheduler
apiGroup: rbac.authorization.k8s.io
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: my-scheduler-extension-apiserver-authentication-reader
namespace: kube-system
roleRef:
kind: Role
name: extension-apiserver-authentication-reader
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: my-scheduler
namespace: kube-system
Configuration. The profile name is the contract with Pods, and leader election is off because one replica of a differently-named scheduler has nothing to elect against:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-scheduler-config
namespace: kube-system
data:
my-scheduler-config.yaml: |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: my-scheduler
leaderElection:
leaderElect: false
Deployment. The documentation builds its own image from source; in practice the official registry.k8s.io/kube-scheduler:<version> image (match your control-plane minor) is what you use, and it is exactly what the kubeadm static Pod runs:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-scheduler
namespace: kube-system
labels:
component: scheduler
tier: control-plane
spec:
replicas: 1
selector:
matchLabels:
component: scheduler
tier: control-plane
template:
metadata:
labels:
component: scheduler
tier: control-plane
spec:
serviceAccountName: my-scheduler
containers:
- name: kube-second-scheduler
image: registry.k8s.io/kube-scheduler:v1.37.1
command:
- /usr/local/bin/kube-scheduler
- --config=/etc/kubernetes/my-scheduler/my-scheduler-config.yaml
livenessProbe:
httpGet:
path: /healthz
port: 10259
scheme: HTTPS
initialDelaySeconds: 15
readinessProbe:
httpGet:
path: /healthz
port: 10259
scheme: HTTPS
resources:
requests:
cpu: '0.1'
volumeMounts:
- name: config-volume
mountPath: /etc/kubernetes/my-scheduler
hostNetwork: false
hostPID: false
volumes:
- name: config-volume
configMap:
name: my-scheduler-config
Using it and proving it. Create three Pods: one with no schedulerName, one with schedulerName: default-scheduler, one with schedulerName: my-scheduler. Then:
kubectl get pods -o wide
kubectl get events -o wide | grep Scheduled
Each Scheduled event names the scheduler that produced it (default-scheduler or my-scheduler) in its source / reporting-controller column; kubectl describe pod shows the same in the From column of the Events section. That event is the only authoritative proof, and it is what a grader checks.
The trap. If a Pod names a scheduler that is not running (a typo, or the Deployment is not up yet), the Pod sits in Pending forever, with no events at all: no scheduler picked it up, so nobody wrote a FailedScheduling message. The documented behaviour is that it stays Pending until a scheduler with that exact name starts, at which point it is placed. A Pending Pod with zero events is almost always a schedulerName mismatch.
The static-Pod shortcut. On an exam cluster you may be told to run the second scheduler as a static Pod instead. Copy /etc/kubernetes/manifests/kube-scheduler.yaml, change metadata.name, add --config= pointing at a hostPath-mounted config with your profile name and leaderElect: false, set --leader-elect=false on the command line as well (the kubeadm manifest sets it to true explicitly), and move it off port 10259 with --secure-port=10260 (update the probes). Two schedulers on the same host cannot share the port.
Leader election for a replicated second scheduler. If you run more than one replica, set leaderElection.leaderElect: true with a unique resourceName (and resourceNamespace, typically kube-system); the lock is a Lease. With RBAC on, the built-in system:kube-scheduler ClusterRole only lets its holder update the Lease named kube-scheduler, so you must kubectl edit clusterrole system:kube-scheduler and add your scheduler’s name to resourceNames for the leases rule.
12. Troubleshooting Scheduling: A Decision Table
| Symptom | Likely cause | Confirm with |
|---|---|---|
Pending, FailedScheduling event with reasons | A Filter plugin rejected every node | kubectl describe pod, map fragments with the table in section 4 |
Pending, no events | schedulerName points at a scheduler that is not running, or no scheduler is running at all | kubectl get pod -o jsonpath='{.spec.schedulerName}'; kubectl -n kube-system get pods -l component=kube-scheduler |
SchedulingGated | Pod has schedulingGates | kubectl get pod -o jsonpath='{.spec.schedulingGates}'; remove them |
| All Pods Pending after a scheduler config change | Scheduler static Pod crashlooping on a bad config | crictl ps -a / crictl logs on the control plane; check apiVersion is v1, YAML indentation, file mounted |
| Pods all land on one node | MostAllocated or a reversed RequestedToCapacityRatio shape, or ImageLocality on a tiny cluster with huge images | Inspect pluginConfig; kubectl get pods -o wide distribution |
| Pods ignore cordon | They set spec.nodeName or are static Pods | kubectl get pod -o yaml for nodeName; mirror Pod annotation kubernetes.io/config.mirror |
Pod scheduled but OutOfcpu | nodeName bypassed filtering; kubelet admission rejected | Events on the Pod from the kubelet, not the scheduler |
| Preemption expected but did not happen | PostFilter runs only when zero nodes are feasible; or preemptionPolicy: Never | Priority guide; message Preemption is not helpful |
| Second scheduler running but Pods not placed | RBAC missing (system:volume-scheduler or auth-reader), or profile name differs from schedulerName | Scheduler Pod logs for forbidden; compare names |
13. Exam Drills
Run each against a clock in a disposable kubeadm cluster:
- Create a Pod requesting more CPU than any node has. Read the event and name the plugin that rejected each node. Then add a
nodeNameto the same Pod and observe the kubelet’sOutOfcpurejection instead of a scheduler event. - Write a
KubeSchedulerConfigurationthat switchesNodeResourcesFittoMostAllocated, apply it to the kubeadm scheduler through the static Pod manifest, and verify withcrictl logsthat it loaded. Deploy six small Pods and watch them pack onto one node; switch back and watch them spread. - Deploy the second scheduler from section 11 with the official image. Create the three test Pods and use
kubectl get events -o wideto prove which scheduler placed each. Then delete the Deployment, create a fourth Pod withschedulerName: my-scheduler, and confirm it has no events. - Create a Pod with a scheduling gate, observe
SchedulingGated, try to add a second gate (it is rejected), tighten itsnodeSelector, then remove the gates withkubectl patchand watch it schedule. - Cordon a node, then create a Pod with
nodeNameset to it. Explain in one sentence why it runs.
Common Mistakes
- Expecting the message to list every problem per node. Filter stops at the first failing plugin for each node; fix one reason and re-read.
- Treating
preferredas a constraint. It only moves the score. If the Pod must land somewhere specific, it has to berequired, a taint ornodeName. - Tuning
percentageOfNodesToScoreon a small cluster. Below 100 feasible nodes it does nothing. - Using
v1beta3. Removed in 1.29; the scheduler will not start. - Running a second scheduler on port 10259 on the same host as the default one. Change
--secure-portand the probes. - Forgetting
system:volume-scheduler. The second scheduler starts but cannot bind PVCs and fails PreBind for any Pod with aWaitForFirstConsumervolume. - Looking for
FailedSchedulingon a mistypedschedulerName. There are no events; nobody owns that Pod. - Believing cordon stops all Pods. Only the scheduler honours
spec.unschedulable;nodeNameand static Pods do not.
Practising for the CKA and KubeAstronaut Exams
Scheduler tasks reward people who have done them before, because almost every step is a file edit followed by waiting for a static Pod to restart, and the failure mode (a scheduler that will not start) hides behind kubectl. Build the muscle in a cluster you can break: our kubeadm installation guide gets you a two-worker cluster, and the CKA troubleshooting guide covers the control-plane debugging loop you will use when a config change goes wrong. The KubeAstronaut mock exam bundle runs scheduling scenarios across the CKA, CKAD and KCNA question styles in real multi-node clusters under exam timing, so the second-scheduler task, the scoring-strategy change and the Pending-Pod diagnosis become routine rather than novel. If you are targeting a single exam, the CKA bundle and the KCNA bundle cover the same ground at their own depth, and the free CKA practice questions are a good warm-up.
Frequently Asked Questions
What are the two steps the Kubernetes scheduler uses to pick a node?
Filtering, which produces the set of feasible nodes by running Filter plugins against every node, and scoring, which ranks the feasible nodes by running Score plugins, normalising to 0–100, weighting and summing. The highest total wins; ties are random.
What is the difference between the scheduling cycle and the binding cycle?
The scheduling cycle (PreFilter through Permit) chooses a node and runs serially, one Pod at a time. The binding cycle (PreBind, Bind, PostBind) applies the decision and may run concurrently with other binding cycles and with the next scheduling cycle.
Why does my Pending Pod have no events at all?
Almost always because spec.schedulerName names a scheduler that is not running. No scheduler claims the Pod, so no FailedScheduling event is ever written. Fix the name or start the scheduler.
How do I make the scheduler bin-pack instead of spread?
Set the NodeResourcesFit plugin’s scoringStrategy.type to MostAllocated in pluginConfig of a KubeSchedulerConfiguration, and start kube-scheduler with --config. Use RequestedToCapacityRatio with a shape when you want to pack only one resource, such as GPUs.
What does percentageOfNodesToScore do and should I set it?
It lets the scheduler stop filtering once it has found that percentage of nodes feasible and score only those. The default is adaptive (50% at 100 nodes, 10% at 5,000, never below 5%) with a hard minimum of 100 feasible nodes, so it has no effect on small clusters and should be left alone unless you run several hundred nodes or more.
Does a Pod with nodeName go through the scheduler?
No. It is stored already bound to that node and the kubelet admits it directly. Filtering is skipped, so cordon, taints with NoSchedule and node selectors are not checked; the kubelet may still reject it for insufficient resources.
What is SchedulingGated?
The status of a Pod whose spec.schedulingGates list is non-empty. The SchedulingGates PreEnqueue plugin keeps it out of the scheduling queue until every gate is removed. Gates can be removed but never added after creation.
Is this on the CKA and KCNA?
Yes. The KCNA’s Kubernetes Fundamentals domain covers scheduling concepts (filtering, scoring, requests vs limits, which component places Pods). The CKA’s Workloads & Scheduling and Cluster Architecture domains include scheduler configuration, diagnosing Pending Pods, and tasks such as deploying a second scheduler and making a Pod use it.
Conclusion
kube-scheduler is a pipeline: a priority-sorted queue feeds one Pod at a time into a serial scheduling cycle, Filter plugins reduce the cluster to the feasible nodes and write the exact sentence you see in a FailedScheduling event, Score plugins turn the survivors into weighted 0–100 numbers, and a concurrent binding cycle writes the result. Everything configurable about it lives in one file, KubeSchedulerConfiguration, where profiles, enabled plugins, weights and plugin arguments such as the NodeResourcesFit scoring strategy change what “best node” means.
For the exams, the payoff is a fixed set of reflexes. Read a Pending Pod’s message and name the plugin. Know that preferred rules and PreferNoSchedule taints only move scores. Know that a Pod with no events has nobody scheduling it. Know how to put a config file in front of the kubeadm scheduler without taking the control plane down, and how to stand up a second scheduler, point a Pod at it and prove with an event that it did the work. Each of those is a two-minute task once you have done it, and a twenty-minute hole in your exam if you have not.