Back to Blog

How kube-scheduler Actually Picks a Node for the CKA & KCNA Exams: The Scheduling Framework, Filter & Score Plugins, NodeResourcesFit Scoring Strategies, percentageOfNodesToScore, Scheduling Gates & Running a Second Scheduler

A practitioner's walkthrough of the engine underneath every Pending Pod: the scheduling queue (active, backoff and unschedulable queues, PrioritySort, queueing hints), the scheduling cycle vs binding cycle, every extension point from PreEnqueue to PostBind, which default plugin produces each fragment of the '0/3 nodes are available' message, how Score plugins and weights combine, the LeastAllocated vs MostAllocated vs RequestedToCapacityRatio scoring strategies with a worked example, percentageOfNodesToScore and the 100-node floor, KubeSchedulerConfiguration profiles and plugin args, scheduling gates, bypassing the scheduler with nodeName and static Pods, and the classic CKA task of running a second scheduler and proving which one placed a Pod.

By Sailor Team , October 26, 2026

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:

QueueWhat is in itHow a Pod leaves
activeQPods ready to be scheduled nowPopped one at a time into a scheduling cycle
backoffQPods whose last attempt failed, waiting out an exponential backoffBackoff expires, Pod moves back to activeQ
unschedulablePodsPods the scheduler could not place and does not expect to be able to place until the cluster changesA 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 PrioritySort plugin (the only default QueueSort plugin) orders activeQ by spec.priority descending, 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. podInitialBackoffSeconds defaults to 1 and podMaxBackoffSeconds to 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 (SchedulerQueueingHints stable) 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.

OrderExtension pointPurposeDefault plugins that implement it
0PreEnqueueGate a Pod before it is even queuedSchedulingGates, DynamicResources
1QueueSortOrder the active queue (exactly one allowed)PrioritySort
2PreFilterPre-compute Pod-level state; abort early if the Pod can never fitNodeResourcesFit, NodePorts, PodTopologySpread, InterPodAffinity, VolumeBinding, DynamicResources
3FilterPer 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
4PostFilterOnly when no node is feasible; try to make oneDefaultPreemption
5PreScoreShared state for scorersTaintToleration, PodTopologySpread, InterPodAffinity
6ScoreEach plugin scores every feasible nodeNodeResourcesFit, NodeResourcesBalancedAllocation, ImageLocality, NodeAffinity, TaintToleration, PodTopologySpread, InterPodAffinity (+ VolumeBinding when StorageCapacityScoring is on, beta in 1.37)
7NormalizeScoreRescale each plugin’s raw scores to 0–100 before weighting(part of the Score plugins)
8ReserveReserve node-side state (volumes, DRA claims) before binding; Unreserve rolls it back on any later failureVolumeBinding, DynamicResources
9PermitApprove, deny, or wait with a timeout (used for gang scheduling by out-of-tree plugins)none by default

Then the binding cycle:

OrderExtension pointPurposeDefault plugins
10PreBindWork that must finish before binding, e.g. provision and bind a PersistentVolumeVolumeBinding, DynamicResources
11BindWrite the Binding. Plugins run in order; the first one that handles the Pod wins and the rest are skippedDefaultBinder
12PostBindInformational cleanup after a successful bindnone 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 fragmentPluginWhat to check
node(s) were unschedulableNodeUnschedulablekubectl uncordon; spec.unschedulable on the Node
node(s) had untolerated taint {key: value}TaintTolerationNode taints vs Pod tolerations
node(s) didn't match Pod's node affinity/selectorNodeAffinitynodeSelector, requiredDuringScheduling... vs node labels
node(s) didn't match the requested node nameNodeNamespec.nodeName points at a node that does not exist or is filtered out
node(s) didn't have free ports for the requested pod portsNodePortshostPort collisions
Insufficient cpu, Insufficient memory, Too many podsNodeResourcesFitrequests vs Allocatable minus existing requests (see the Node Allocatable guide)
node(s) didn't match pod affinity rules / anti-affinity rulesInterPodAffinitypodAffinity / podAntiAffinity and topologyKey
node(s) didn't match pod topology spread constraintsPodTopologySpreadmaxSkew, topologyKey, whenUnsatisfiable: DoNotSchedule
pod has unbound immediate PersistentVolumeClaimsVolumeBindingPVC is Pending with an Immediate StorageClass
node(s) had volume node affinity conflictVolumeBindingPV nodeAffinity (local volumes, zonal disks) vs candidate node
node(s) exceed max volume countNodeVolumeLimitsCSI attach limits on the node
Preemption is not helpful for schedulingDefaultPreemptionEvicting Pods would not make the node fit (taint, affinity, nodeName)
No preemption victims found for incoming podDefaultPreemptionNo 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:

PluginPrefers nodes that…
NodeResourcesFitmatch the configured scoring strategy (next section); default LeastAllocated prefers emptier nodes
NodeResourcesBalancedAllocationwould end up with CPU and memory utilisation similar to each other (penalises a node that becomes 90% CPU / 20% memory)
ImageLocalityalready 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)
NodeAffinitysatisfy more preferredDuringSchedulingIgnoredDuringExecution terms, by their weights
TaintTolerationhave fewer PreferNoSchedule taints the Pod does not tolerate
PodTopologySpreadreduce skew for whenUnsatisfiable: ScheduleAnyway constraints (and the cluster-default constraints)
InterPodAffinitysatisfy 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 of utilization → score points, 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 placementLeastAllocatedMostAllocated
Node A2 of 4(4 − 2) × 100 / 4 = 502 × 100 / 4 = 50
Node B4 of 4(4 − 4) × 100 / 4 = 04 × 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. 0 means “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:

  • profiles is a list. Each profile has a unique schedulerName, its own enabled/disabled plugins and pluginConfig. A Pod chooses a profile with spec.schedulerName. A Pod that does not set it is defaulted by the API server to default-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 QueueSort plugin 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: '*'
  • multiPoint enables or disables a plugin at every extension point it implements in one line (plugins.multiPoint.enabled: [{name: MyPlugin}]). Specific extension-point entries override multiPoint, which overrides defaults. All default plugins are internally enabled through multiPoint, 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 nodeSelector keys, add requiredDuringScheduling node affinity terms or expressions, change preferred terms 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:

MechanismWho assigns the nodeDoes the scheduler run?Caveat
spec.nodeNameYou, at creationNo. The API server stores the Pod already bound; the kubelet on that node admits itNo Filter checks happen; the kubelet can still reject it at admission with OutOfcpu, OutOfmemory or an untolerated NoExecute taint
Static PodThe kubelet reading staticPodPathNo. The kubelet creates a mirror Pod in the API for visibilityCannot be deleted through the API; edit or remove the manifest file
DaemonSetThe DaemonSet controller sets nodeAffinity to each target nodeYes, 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

SymptomLikely causeConfirm with
Pending, FailedScheduling event with reasonsA Filter plugin rejected every nodekubectl describe pod, map fragments with the table in section 4
Pending, no eventsschedulerName points at a scheduler that is not running, or no scheduler is running at allkubectl get pod -o jsonpath='{.spec.schedulerName}'; kubectl -n kube-system get pods -l component=kube-scheduler
SchedulingGatedPod has schedulingGateskubectl get pod -o jsonpath='{.spec.schedulingGates}'; remove them
All Pods Pending after a scheduler config changeScheduler static Pod crashlooping on a bad configcrictl ps -a / crictl logs on the control plane; check apiVersion is v1, YAML indentation, file mounted
Pods all land on one nodeMostAllocated or a reversed RequestedToCapacityRatio shape, or ImageLocality on a tiny cluster with huge imagesInspect pluginConfig; kubectl get pods -o wide distribution
Pods ignore cordonThey set spec.nodeName or are static Podskubectl get pod -o yaml for nodeName; mirror Pod annotation kubernetes.io/config.mirror
Pod scheduled but OutOfcpunodeName bypassed filtering; kubelet admission rejectedEvents on the Pod from the kubelet, not the scheduler
Preemption expected but did not happenPostFilter runs only when zero nodes are feasible; or preemptionPolicy: NeverPriority guide; message Preemption is not helpful
Second scheduler running but Pods not placedRBAC missing (system:volume-scheduler or auth-reader), or profile name differs from schedulerNameScheduler Pod logs for forbidden; compare names

13. Exam Drills

Run each against a clock in a disposable kubeadm cluster:

  1. Create a Pod requesting more CPU than any node has. Read the event and name the plugin that rejected each node. Then add a nodeName to the same Pod and observe the kubelet’s OutOfcpu rejection instead of a scheduler event.
  2. Write a KubeSchedulerConfiguration that switches NodeResourcesFit to MostAllocated, apply it to the kubeadm scheduler through the static Pod manifest, and verify with crictl logs that it loaded. Deploy six small Pods and watch them pack onto one node; switch back and watch them spread.
  3. Deploy the second scheduler from section 11 with the official image. Create the three test Pods and use kubectl get events -o wide to prove which scheduler placed each. Then delete the Deployment, create a fourth Pod with schedulerName: my-scheduler, and confirm it has no events.
  4. Create a Pod with a scheduling gate, observe SchedulingGated, try to add a second gate (it is rejected), tighten its nodeSelector, then remove the gates with kubectl patch and watch it schedule.
  5. Cordon a node, then create a Pod with nodeName set 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 preferred as a constraint. It only moves the score. If the Pod must land somewhere specific, it has to be required, a taint or nodeName.
  • Tuning percentageOfNodesToScore on 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-port and the probes.
  • Forgetting system:volume-scheduler. The second scheduler starts but cannot bind PVCs and fails PreBind for any Pod with a WaitForFirstConsumer volume.
  • Looking for FailedScheduling on a mistyped schedulerName. There are no events; nobody owns that Pod.
  • Believing cordon stops all Pods. Only the scheduler honours spec.unschedulable; nodeName and 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.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now