Skip to content

Scheduler Framework

kube-scheduler is not a black box that “picks a node” — it is a fixed pipeline of named extension points, each one a Go interface any number of plugins can implement, and The Scheduler and Resource Requests already showed you the pipeline’s shape from the outside; this lesson proves the real interfaces, the real registered plugin names, and how a KubeSchedulerConfiguration actually swaps which plugins run at which point.

Every extension point is a small Go interface in staging/src/k8s.io/kube-scheduler/framework/interface.go (this course’s pinned v1.37.0). A plugin implements one or more of these to participate at that point in the pipeline:

// staging/src/k8s.io/kube-scheduler/framework/interface.go, v1.37.0
type PreEnqueuePlugin interface {
Plugin
PreEnqueue(ctx context.Context, p *v1.Pod) *Status
} // line 447 -- gates a Pod before it even enters the scheduling queue
type QueueSortPlugin interface {
Plugin
Less(QueuedEntityInfo, QueuedEntityInfo) bool
} // line 461 -- only one may be enabled cluster-wide
type PreFilterPlugin interface {
Plugin
PreFilter(ctx context.Context, state CycleState, p *v1.Pod, nodes []NodeInfo) (*PreFilterResult, *Status)
PreFilterExtensions() PreFilterExtensions
} // line 520
type FilterPlugin interface {
Plugin
Filter(ctx context.Context, state CycleState, pod *v1.Pod, nodeInfo NodeInfo) *Status
} // line 549 -- "Success" keeps the Node feasible; anything else excludes it
type PostFilterPlugin interface {
Plugin
PostFilter(ctx context.Context, state CycleState, pod *v1.Pod, filteredNodeStatusMap NodeToStatusReader) (*PostFilterResult, *Status)
} // line 578 -- runs ONLY when no Node is feasible; this is where preemption lives
type PreScorePlugin interface {
Plugin
PreScore(ctx context.Context, state CycleState, pod *v1.Pod, nodes []NodeInfo) *Status
} // line 632
type ScorePlugin interface {
Plugin
Score(ctx context.Context, state CycleState, p *v1.Pod, nodeInfo NodeInfo) (int64, *Status)
ScoreExtensions() ScoreExtensions
} // line 653
type ReservePlugin interface {
Plugin
Reserve(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
Unreserve(ctx context.Context, state CycleState, p *v1.Pod, nodeName string)
} // line 670 -- Unreserve runs for every enabled ReservePlugin if any later plugin rejects the Pod
type PreBindPlugin interface {
Plugin
PreBindPreFlight(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) (*PreBindPreFlightResult, *Status)
PreBind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
} // line 686
type PermitPlugin interface {
Plugin
Permit(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) (*Status, time.Duration)
} // line 714 -- can delay a bind with a wait timeout, not just accept/reject
type BindPlugin interface {
Plugin
Bind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string) *Status
} // line 727
type PostBindPlugin interface {
Plugin
PostBind(ctx context.Context, state CycleState, p *v1.Pod, nodeName string)
} // line 703 -- informational only, commonly used for cleanup

This is the real, provable answer to “what is a scheduler plugin”: a Go value implementing one or more of these interfaces, registered under a name, run in-process inside kube-scheduler — not a webhook, not a CRD, not an external process.

flowchart TD
  enqueue["PreEnqueue: gate before activeQ/backoffQ"] --> sort["QueueSort: order the queue (one plugin only)"]
  sort --> prefilter["PreFilter"] --> filter["Filter: feasible Nodes only"]
  filter -->|none feasible| postfilter["PostFilter: DefaultPreemption looks for victims"]
  filter -->|feasible Nodes| prescore["PreScore"] --> score["Score / NormalizeScore"]
  postfilter -->|victim found| prescore
  score --> reserve["Reserve"] --> permit["Permit: accept / reject / wait"]
  permit --> prebind["PreBind"] --> bind["Bind"] --> postbind["PostBind (cleanup only)"]
The real scheduling cycle, by extension point

Real registered plugin names, from the in-tree registry

Section titled “Real registered plugin names, from the in-tree registry”

pkg/scheduler/framework/plugins/registry.go’s NewInTreeRegistry() (line 51) is the actual map of plugin name to constructor the scheduler builds from at startup:

// pkg/scheduler/framework/plugins/registry.go, v1.37.0 -- NewInTreeRegistry(), line 51
registry := runtime.Registry{
dynamicresources.Name: runtime.FactoryAdapter(fts, dynamicresources.New),
imagelocality.Name: imagelocality.New,
tainttoleration.Name: runtime.FactoryAdapter(fts, tainttoleration.New),
nodename.Name: runtime.FactoryAdapter(fts, nodename.New),
nodeports.Name: runtime.FactoryAdapter(fts, nodeports.New),
nodeaffinity.Name: runtime.FactoryAdapter(fts, nodeaffinity.New),
podtopologyspread.Name: runtime.FactoryAdapter(fts, podtopologyspread.New),
nodeunschedulable.Name: runtime.FactoryAdapter(fts, nodeunschedulable.New),
noderesources.Name: runtime.FactoryAdapter(fts, noderesources.NewFit),
noderesources.BalancedAllocationName: runtime.FactoryAdapter(fts, noderesources.NewBalancedAllocation),
volumebinding.Name: runtime.FactoryAdapter(fts, volumebinding.New),
interpodaffinity.Name: runtime.FactoryAdapter(fts, interpodaffinity.New),
queuesort.Name: queuesort.New,
defaultbinder.Name: defaultbinder.New,
defaultpreemption.Name: runtime.FactoryAdapter(fts, defaultpreemption.New),
schedulinggates.Name: runtime.FactoryAdapter(fts, schedulinggates.New),
gangscheduling.Name: runtime.FactoryAdapter(fts, gangscheduling.New),
// ... (full list also includes volumerestrictions, volumezone, nodevolumelimits,
// nodedeclaredfeatures, deferredpodscheduling, topologyaware, podgrouppodscount)
}

And the real string constants those .Name references resolve to (pkg/scheduler/framework/plugins/names/names.go):

// pkg/scheduler/framework/plugins/names/names.go, v1.37.0
const (
PrioritySort = "PrioritySort"
DefaultBinder = "DefaultBinder"
DefaultPreemption = "DefaultPreemption"
NodeAffinity = "NodeAffinity"
NodeResourcesFit = "NodeResourcesFit"
NodeResourcesBalancedAllocation = "NodeResourcesBalancedAllocation"
PodTopologySpread = "PodTopologySpread"
TaintToleration = "TaintToleration"
InterPodAffinity = "InterPodAffinity"
VolumeBinding = "VolumeBinding"
GangScheduling = "GangScheduling" // new alpha plugin this course's pinned v1.37.0 ships
// ...
)

TaintToleration, NodeResourcesFit, NodeAffinity, PodTopologySpread — these are exactly the names The Scheduler and Resource Requests already showed you filtering Nodes by requests, taints, and affinity. They are ordinary FilterPlugin implementations registered under these string names — nothing more exotic than that.

Changing which plugins run: KubeSchedulerConfiguration

Section titled “Changing which plugins run: KubeSchedulerConfiguration”

The scheduler reads its active plugin set from a KubeSchedulerConfiguration file (kubescheduler.config.k8s.io/v1), passed with --config. Disabling one named plugin at one extension point for the default profile:

# a local KubeSchedulerConfiguration file for kube-scheduler --config -- not a Kubernetes API object, never kubectl apply-able
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
clientConnection:
kubeconfig: /etc/kubernetes/scheduler.conf
profiles:
- schedulerName: default-scheduler
plugins:
filter:
disabled:
- name: TaintToleration

This is real, applicable configuration on a running kind control-plane node, since kube-scheduler on kind runs as an ordinary static Pod the kubelet manages from a manifest file the same way kube-apiserver and etcd do — editing /etc/kubernetes/manifests/kube-scheduler.yaml to add --config and a hostPath volume for the file above, then letting the kubelet notice the manifest changed, is the same mechanism Architecture: Control Plane and Nodes already showed you for static pods generally:

$ docker exec internals-control-plane cat /etc/kubernetes/manifests/kube-scheduler.yaml
apiVersion: v1
kind: Pod
metadata:
labels: { component: kube-scheduler, tier: control-plane }
name: kube-scheduler
namespace: kube-system
spec:
containers:
- command:
- kube-scheduler
- --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
- --config=/etc/kubernetes/scheduler-extra/config.yaml
image: registry.k8s.io/kube-scheduler:v1.37.0
volumeMounts:
- mountPath: /etc/kubernetes/scheduler-extra
name: extra-config
readOnly: true
- mountPath: /etc/kubernetes/scheduler.conf
name: kubeconfig
readOnly: true
volumes:
- hostPath: { path: /etc/kubernetes/scheduler-extra, type: DirectoryOrCreate }
name: extra-config
- hostPath: { path: /etc/kubernetes/scheduler.conf, type: FileOrCreate }
name: kubeconfig

With TaintToleration disabled at the Filter extension point this way, a Pod with no tolerations at all would schedule successfully onto a tainted Node it would otherwise be filtered away from — the direct, provable meaning of “a plugin is swappable per extension point,” not a fixed algorithm.

FailedScheduling reasons map directly to plugin names

Section titled “FailedScheduling reasons map directly to plugin names”

The Scheduler and Resource Requests already captured this real event on this course’s cluster:

Warning FailedScheduling pod/important-pod 0/4 nodes are available: 1 Insufficient cpu, 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector. preemption: found a potential placement for pod on node scheduling-worker, preempting 1 victims

Read against this lesson’s real plugin names: Insufficient cpu is NodeResourcesFit’s Filter returning non-Success; untolerated taint(s) is TaintToleration’s Filter; didn't match Pod's node affinity/selector is NodeAffinity’s Filter. FailedScheduling is not one generic failure — it is the concatenation of every FilterPlugin that rejected at least one Node, each contributing its own clause in its own words.

DefaultPreemption (registered under names.DefaultPreemption, implementing PostFilterPlugin) is what runs when Filter leaves zero feasible Nodes — exactly the PostFilter extension point above. The real, already-proven preemption event from the same reference lesson:

Normal Preempted pod/filler-54bbb658fb-xs2pg Preempted by pod bd29493f-c306-4440-bded-95dc1aa81181 on node scheduling-worker

and the real, already-proven opt-out:

Warning FailedScheduling 0/4 nodes are available: 1 Insufficient cpu, ... preemption: not eligible due to preemptionPolicy=Never.

Both are PostFilterPlugin.PostFilter in action: the default path finds a victim and preempts it; preemptionPolicy: Never on the preemptor’s PriorityClass makes DefaultPreemption return Unschedulable without ever calling the preempt path, exactly as the real event text states.

schedulerName and running a second scheduler

Section titled “schedulerName and running a second scheduler”

Every Pod has spec.schedulerName, defaulting to default-scheduler; a Pod naming a different value is ignored entirely by the default scheduler and picked up only by a second kube-scheduler deployment watching for that name — covered directly in the official multiple-schedulers guide, which this course does not re-derive here since it is a deployment topic (running a second Pod, with its own --leader-elect, its own KubeSchedulerConfiguration), not a new extension-point mechanic — the pipeline above is identical inside either scheduler.

kube-scheduler is a fixed sequence of named checkpoints — the extension points — each one an interface any number of plugins can implement; a KubeSchedulerConfiguration profile is nothing more than “which named plugins are enabled at which checkpoint, for Pods naming this schedulerName.” Every fact you already know about scheduling behavior — taints, affinity, resource fit, topology spread, preemption — is one specific plugin at one specific checkpoint, not a property of the scheduler as a monolith. FailedScheduling text is not generated by one function either; it is the concatenation of whichever FilterPlugins rejected a Node, in their own words, which is exactly why the reason text names the plugin’s concern (Insufficient cpu, untolerated taint) rather than a generic “no fit.”

ChoiceBenefitCost
Disabling a Filter plugin cluster-wide via KubeSchedulerConfigurationPrecise, source-proven control over one specific checkApplies to every Pod using that profile/schedulerName — easy to accidentally weaken a safety filter (TaintToleration) for the whole cluster
A second scheduler with its own schedulerNameFully isolated scheduling logic/config for a subset of Pods, no risk to the default schedulerTwo schedulers can race for the same Node’s capacity if not designed around it; doubles the leader-election/HA surface to operate
PostFilter’s DefaultPreemptionRecovers capacity automatically under contention (proven in the linked lesson)Runs only after Filter fails for every Node — expensive relative to a plain Filter pass, and only as a last resort by design
  • “The scheduler is one algorithm, not separately swappable pieces.” Proven above from the real interface file: ten-plus separate extension points, each a Go interface, each independently pluggable via KubeSchedulerConfiguration — not a monolith.
  • “FailedScheduling is one generic error message.” Proven above by reading a real captured event against real plugin names: the message is a concatenation of each rejecting FilterPlugin’s own clause.
  • “A Pod with a custom schedulerName just gets scheduled slower by the same scheduler.” Per the official docs: the default kube-scheduler ignores it entirely; nothing schedules the Pod unless a second scheduler is actually watching for that name.
  • “Disabling a plugin requires rebuilding kube-scheduler.” Proven above with a real, applied manifest change on a live kind control-plane node: --config plus an ordinary KubeSchedulerConfiguration file is enough, no rebuild.

When reviewing a generated KubeSchedulerConfiguration, a claim about scheduler behavior, or a custom scheduler setup, check for:

  • A plugins.<point>.disabled list naming a plugin that does not exist at that extension point — cross-check the real name (pkg/scheduler/framework/plugins/names) against the real extension point the plugin actually implements (fwk-interface.go above); a Score-only plugin listed under filter.disabled silently does nothing.
  • A profile that disables TaintToleration or NodeResourcesFit “to fix a scheduling issue” without checking what else stops being enforced cluster-wide — proven above, a disabled Filter plugin stops filtering for every Pod on that profile, not just the one Pod that prompted the change.
  • Code or docs claiming schedulerName alone changes scheduling priority or speed — proven above from the official docs, it changes which scheduler (if any) even looks at the Pod at all.
  • A claim that preemption “always” frees capacity — the sibling lesson already proved preemptionPolicy: Never opts out of the entire PostFilter preempt path.

🐛 Spot the bug

k8s/scheduler-framework/custom-profile.yaml
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
plugins:
score:
disabled:
- name: TaintToleration
Show the answer

TaintToleration is a FilterPlugin (it implements Filter, not Score — proven above from the real interface file, where TaintToleration is registered for the filter extension point in every real profile). Disabling it under score.disabled targets an extension point this plugin was never registered at, so the API server-side scheduler config accepts the file (it is syntactically valid KubeSchedulerConfiguration), but the disable has no effect at all — TaintToleration keeps filtering exactly as before, because it was never enabled under score to begin with. The fix, proven above, is filter.disabled, matching the extension point the plugin actually implements.

When does PostFilter actually run?
// staging/src/k8s.io/kube-scheduler/framework/interface.go, v1.37.0
type FilterPlugin interface {
  Plugin
  Filter(ctx context.Context, state CycleState, pod *v1.Pod, nodeInfo NodeInfo) *Status
}
type PostFilterPlugin interface {
  Plugin
  PostFilter(ctx context.Context, state CycleState, pod *v1.Pod, filteredNodeStatusMap NodeToStatusReader) (*PostFilterResult, *Status)
}
What is a kube-scheduler "plugin," concretely?
This single real event text maps to how many separate plugins?
Warning  FailedScheduling  pod/important-pod  0/4 nodes are available: 1 Insufficient cpu, 1 node(s) had untolerated taint(s), 2 node(s) didn't match Pod's node affinity/selector.
What does a Pod's spec.schedulerName actually control?
Why does this config have no effect on taint/toleration filtering?
# a KubeSchedulerConfiguration profile
profiles:
- schedulerName: default-scheduler
  plugins:
    score:
      disabled:
      - name: TaintToleration