CoursesKubernetes administrationMultiple schedulers & profiles

Multiple schedulers & profiles

Running and targeting custom scheduling logic.

Advanced10 min · lesson 30 of 65
In plain terms
Running a second scheduler is hiring a specialist host for one section of the restaurant, and telling certain guests to “ask for them by name” when they arrive.

Every node is up, none is full, and your pod sits in Pending with no events at all. Not FailedScheduling. Nothing. Nine times out of ten the pod asked to be placed by a scheduler that isn't running. That is the door into this topic: a pod does not have to be placed by the built-in scheduler. Think of the scheduler as the host at a busy restaurant, the person who decides which table each party gets. The default host is a solid generalist. But you can hire a second one, or hand the same host a different seating card, and send certain guests to whichever you choose.

Point a pod at a different scheduler

Every pod (the smallest thing Kubernetes runs, normally a single container) carries a spec.schedulerName field. Leave it blank and the API server fills in default-scheduler, the built-in one. A scheduler only ever touches pods that name it, and ignores every other pod completely. That one rule is what lets two schedulers share a cluster without a fight: each picks up only the pods addressed to it. So why run a second scheduler at all? Because some jobs need logic the default one cannot express. The classic case is gang scheduling for batch or HPC (high-performance computing) work: a 500-pod training job that has to start all of its pods at once or none of them, so it doesn't grab half the cluster and then sit deadlocked waiting for the other half. The default scheduler places one pod at a time and has no concept of all-or-nothing. You might wonder why node affinity or taints won't cover this. They steer the default scheduler, but they can't change its one-pod-at-a-time habit, so they still can't promise all-or-nothing. That needs different code, and Volcano is the batch scheduler most teams reach for here.

job-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: train-shard-0
namespace: ml
spec:
schedulerName: volcano # place me with Volcano, not the default
containers:
- name: trainer
image: registry.example.com/hpc-train:2.0
terminal
$ kubectl -n kube-system get pods -l app=volcano-scheduler
NAME READY STATUS RESTARTS AGE
volcano-scheduler-6c9d7f8b5-lm4tq 1/1 Running 0 21m
$ kubectl -n ml describe pod train-shard-0 | grep -A3 Events:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 4s volcano Successfully assigned ml/train-shard-0 to node-7

The From column is your proof of who did the work: it reads volcano, not default-scheduler. That second scheduler is just a Deployment running a scheduler image, so it has no special powers by default. It needs a ServiceAccount (the identity a program logs in as) wired to permission to watch pods and nodes and to write the binding that assigns a pod to a node. Volcano ships that RBAC (Role-Based Access Control, the rules for who may do what) in its install. A hand-rolled scheduler that can't create bindings will sit there watching pods it is never allowed to place. One more detail trips people up. If you turn on leader election (you should, so a standby takes over cleanly), the new scheduler needs its own distinct lease name. Two schedulers sharing one lease name fight over the same lock. Only one wins, and the loser sits idle as a standby and never schedules a thing, so pods addressed to it pile up with no explanation. Give it a unique leaderElection resourceName and confirm it actually holds the lease before you route real work to it.

Profiles: one scheduler, many policies

Running a whole second scheduler is a lot of moving parts for what is often a small change in behavior. Most of the time you don't need custom code. You need the same scheduler to follow a different rule for some of your workloads. That is what profiles are for. Same host, two seating cards. One card fills a section completely before opening the next (packs guests in tight, cheaper to staff). The other spreads guests across the room (quieter, and one bad section takes down less). Guests ask for a card by name. In Kubernetes terms, one kube-scheduler binary reads a config file that defines several profiles. Each profile has its own schedulerName and its own plugin settings. The headline example is bin-packing versus spreading. The scoring plugin NodeResourcesFit rates a node by how full it is. Its default strategy, LeastAllocated, prefers emptier nodes, so pods spread out. Flip that profile to MostAllocated and it prefers the fullest node that still fits, so pods pack tight and you can scale empty nodes away to cut cost.

scheduler-config.yaml
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
leaderElection:
leaderElect: true
profiles:
- schedulerName: default-scheduler # untouched: spreads pods (LeastAllocated)
- schedulerName: bin-packing # new profile in the SAME binary
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated # pack onto the fullest node that still fits

Profiles are cheaper than a second scheduler, but they aren't free of rules. Every profile lives in the same process and shares one waiting line for pods, so they must all agree on how that line is ordered. You can vary how nodes get scored per profile, packing on one and spreading on another. You cannot have one profile sort its waiting pods one way while another sorts them a different way, because there is only the single queue. You wire this in by pointing the scheduler at the file with --config=/etc/kubernetes/scheduler-config.yaml in its static pod manifest (the file on the control-plane node that defines the scheduler itself). The scheduler reads that file only at startup, so a change means restarting the scheduler pod, and a malformed config keeps it from coming back up at all. Validate the YAML and watch the scheduler roll before you assume the new profile exists. After that, a workload opts into a profile with the exact same schedulerName field you already used for a separate scheduler. Nothing new to learn on the pod side. Send your cost-sensitive batch pods to bin-packing, leave your latency-sensitive services on default-scheduler, and one scheduler process serves both. A profile's schedulerName must be unique in the config, and default-scheduler is the reserved name for the built-in behavior.

terminal
$ kubectl -n team-a get pod cost-web-7bd -o jsonpath='{.spec.schedulerName}'
bin-packing
$ kubectl -n team-a describe pod cost-web-7bd | grep -A3 Events:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 6s bin-packing Successfully assigned team-a/cost-web-7bd to node-2

Same tell as before: From reads bin-packing, so you know the packing profile placed this pod and not the default one. That From value is the fastest way to confirm a profile is live and actually being used, without reading a single line of scheduler logs.

Which scheduling knob do you actually need?
Placement the default scheduler can't do?
start here
No
default-scheduler
the default profile spreads pods; leave it alone
Different policy, same logic
add a profile
one new schedulerName in the config, e.g. bin-packing
Truly custom logic (gang, HPC)
second scheduler
run Volcano; pods opt in by schedulerName
Almost every cluster stops at the first branch. Reach right only when you can name the placement the default scheduler genuinely cannot express.
terminal
$ kubectl -n ml get pod train-shard-9
NAME READY STATUS RESTARTS AGE
train-shard-9 0/1 Pending 0 11m
$ kubectl -n ml describe pod train-shard-9 | grep -A1 Events:
Events: <none>
Pending with no events is a different bug from FailedScheduling
These two look identical in kubectl get (both say Pending) but they mean opposite things. A FailedScheduling event means the right scheduler picked the pod up and could not find a node that fits: a capacity, taint, or affinity problem you solve by looking at nodes. Events: <none> means no scheduler ever claimed the pod at all, which almost always means schedulerName points at something that isn't running. Think a typo like volcanoo, a profile you never added to the config, or a scheduler pod that's down. Check the name against what's actually running before you go hunting for resource problems that aren't there.

A typo in schedulerName means Pending forever with no default rescue. Validate the name against what actually runs.

Multiple schedulers must not fight over the same pods. Partition by schedulerName cleanly.

Profiles in one binary are often safer than a second process. Prefer the supported extension points your version documents.

Try this

You do not need Volcano installed for this. Point a pod at a scheduler name nobody answers to and watch it sit Pending with an empty event list, then check what is actually running under that name and whether the built-in scheduler was even handed a config file to read profiles from. Delete the pod, recreate it without the override, and read the From column. That is the whole diagnostic, start to finish.

terminal
$ kubectl run ghost --image=nginx:1.27 --overrides='{"apiVersion":"v1","spec":{"schedulerName":"ghost-scheduler"}}'
pod/ghost created
$ kubectl get pod ghost
NAME READY STATUS RESTARTS AGE
ghost 0/1 Pending 0 45s
$ kubectl get pod ghost -o jsonpath='{.spec.schedulerName}'
ghost-scheduler
$ kubectl describe pod ghost | grep -A3 Events:
Events: <none>
$ kubectl -n kube-system get pods -l component=kube-scheduler
NAME READY STATUS RESTARTS AGE
kube-scheduler-cp-1 1/1 Running 0 42d
$ kubectl -n kube-system get pod kube-scheduler-cp-1 -o jsonpath='{.spec.containers[0].command}'
["kube-scheduler","--authorization-kubeconfig=/etc/kubernetes/scheduler.conf","--bind-address=127.0.0.1","--kubeconfig=/etc/kubernetes/scheduler.conf","--leader-elect=true"]
$ kubectl delete pod ghost
pod "ghost" deleted
$ kubectl run ghost --image=nginx:1.27
pod/ghost created
$ kubectl describe pod ghost | grep -A3 Events:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 2s default-scheduler Successfully assigned default/ghost to node-3
$ kubectl delete pod ghost
pod "ghost" deleted

Takeaway

Most clusters need one scheduler. Custom schedulers and profiles exist for specialized placement — target them explicitly per pod.

Quick check
01A pod has schedulerName: fast-batch and has been Pending for 10 minutes. kubectl describe pod shows Events: <none>, and every node has plenty of spare CPU and memory. What's the most likely cause?
Correct — Events: <none> means no scheduler touched the pod. A wrong or missing schedulerName (a typo, a profile never added to the config, or a dead scheduler pod) is the usual reason, and it stays Pending forever because the default scheduler ignores pods addressed to someone else.
Incorrect — If a scheduler had picked it up and failed on capacity, you would see a FailedScheduling event reading 'Insufficient cpu', not an empty event list.
Incorrect — A taint mismatch also produces a FailedScheduling event ('untolerated taint'). An empty event list means no scheduler evaluated the pod at all.
Incorrect — Image pulls happen on the node after scheduling. That shows as ContainerCreating on an already-assigned node, not Pending with no scheduling events.
02A scheduler profile sets NodeResourcesFit's scoringStrategy to MostAllocated. What behavior does that produce, versus the default?
Incorrect — No: scoringStrategy ranks candidate nodes; it does not cap per-pod allocation or reject pods for size.
Correct — MostAllocated is bin-packing for cost, while LeastAllocated is the default spread.
Incorrect — That is LeastAllocated, the default; MostAllocated does the reverse.
Incorrect — No: a profile runs inside the single scheduler binary, so no second process or extra RBAC is involved.
03You deploy a second scheduler with leader election enabled but give it the same leaderElection resourceName (lease) as the built-in scheduler. Pods addressed to it never schedule and show no events. What happened?
Correct — a shared lease name means one leader and one perpetual standby, and the standby schedules nothing, so its pods pile up unexplained.
Incorrect — No: a lease is a single-holder lock, not a load balancer; the loser goes idle rather than sharing work.
Incorrect — No: both start fine; they only contend for the lock at runtime, and the loser stays a standby.
Incorrect — No: that routes them to the built-in scheduler entirely; the real fix is a unique lease so the intended scheduler can win leadership.

Related