Kubernetes Scheduler — Deep Internals

A pod that comes in without a nodeName doesn't get scheduled by magic — it moves through a strict two-phase pipeline (Filter, then Score), gets bound to whichever node wins, and only then does that node's kubelet take over and actually start it. This guide walks through that whole pipeline, what happens when no node fits, and the taint/toleration/affinity/topology-spread mechanics that feed into it.

0/0 checks

How the Scheduler Works

The scheduler has one job: assign a nodeName to a Pod that has none. It runs a two-phase algorithm for every unscheduled pod.

flowchart TD
    WATCH["Scheduler watches API Server<br/>for pods with nodeName=''"] --> QUEUE
    QUEUE["Priority Queue<br/>(sorted by PriorityClass)"] --> FILTER
    FILTER["Phase 1: Filter (Predicates)<br/>eliminate nodes that CANNOT run the pod"] --> SCORE
    SCORE["Phase 2: Score (Priorities)<br/>rank remaining nodes 0-100"] --> BEST
    BEST["Select highest-scoring node<br/>(tie-break: random)"] --> BIND
    BIND["Bind: write nodeName to pod<br/>via API Server"] --> KUBELET
    KUBELET["kubelet on that node<br/>watches for its pods, starts container"]

What's the key difference between what the Filter phase does and what the Score phase does?


Phase 1: Filter Plugins

Every filter plugin runs for every node. A node is eliminated if any filter returns false.

graph LR
    NODE["Candidate Node"] --> F1["NodeUnschedulable<br/>cordon check"]
    F1 --> F2["NodeResourcesFit<br/>enough CPU/mem?"]
    F2 --> F3["NodeAffinity<br/>nodeSelector / affinity rules"]
    F3 --> F4["TaintToleration<br/>pod tolerates node taints?"]
    F4 --> F5["PodTopologySpread<br/>spread constraints"]
    F5 --> F6["VolumeBinding<br/>PVC can be bound here?"]
    F6 --> PASS["Node passes --> enters Score phase"]
    F1 & F2 & F3 & F4 & F5 & F6 -->|"any fail"| REJECT["Node eliminated"]

Key filter plugins:

Plugin What it checks
NodeResourcesFit Node has enough Allocatable - requested CPU/memory
NodeAffinity Pod's nodeSelector and affinity.nodeAffinity match node labels
TaintToleration Pod's tolerations cover all node taints with NoSchedule/NoExecute
PodTopologySpread topologySpreadConstraints — spread pods across zones/nodes
VolumeBinding PVC's storageClass zone matches node's zone
NodeUnschedulable Node is not cordoned

A node passes 5 of the 6 filter plugins above but fails NodeResourcesFit. Does it proceed to the Score phase?

Dynamic Resource Allocation (DRA) doesn't add a row to the filter-plugin table above — it's a newer, separate scheduler extension point, the DynamicResources plugin, that sits alongside Filter/Score rather than inside either phase. That's because it isn't doing a scalar Allocatable - requested check the way NodeResourcesFit does; it resolves ResourceClaim/DeviceClass binding instead, matching structured device attributes rather than comparing a single number against capacity. See ai-infra/gpu-scheduling.md for the full treatment of DeviceClass, ResourceClaim, and the capability gap it closes over the classic GPU extended-resource model.


Phase 2: Score Plugins

Remaining nodes are scored 0-100 by each plugin. Final score = weighted sum.

graph LR
    NODES["Filtered nodes<br/>[node-1, node-2, node-3]"] --> S1["LeastAllocated<br/>prefer node with most free CPU/mem"]
    S1 --> S2["NodeAffinity<br/>preferred rules add score"]
    S2 --> S3["InterPodAffinity<br/>co-locate with preferred pods"]
    S3 --> S4["ImageLocality<br/>node already has the image? +score"]
    S4 --> FINAL["Final scores:<br/>node-1: 72<br/>node-2: 85 ← winner<br/>node-3: 61"]
Plugin Goal
LeastAllocated Spread load — pick the least-used node
MostAllocated Bin-pack — fill nodes before using new ones (saves cost)
NodeAffinity Honour preferred affinity rules
ImageLocality Prefer nodes that already pulled the image (faster start)
TaintToleration Nodes with matching tolerations get higher score

If both MostAllocated and LeastAllocated were enabled as score plugins at real weight for the same cluster, would they push scheduling decisions in the same direction?


What Happens When No Node Passes Filter

sequenceDiagram
    participant SCHED as Scheduler
    participant API as API Server
    participant POD as Pod

    SCHED->>SCHED: Filter: 0 nodes pass
    SCHED->>API: add Event to pod:<br/>"FailedScheduling: 0/3 nodes available:<br/>3 Insufficient cpu"
    Note over POD: Pod stays Pending indefinitely
    Note over POD: Scheduler retries every ~1s (backoff)

    Note over SCHED: If Cluster Autoscaler present:
    SCHED->>API: Node expander watches Pending pods
    API->>CA: new EC2 node provisioned
    CA->>API: node registers as Ready
    SCHED->>SCHED: retry scheduling --> node passes Filter
    SCHED->>API: Bind pod to new node

FailedScheduling events — what they mean

kubectl describe pod <pod> -n <namespace>
# Events:
#   Warning  FailedScheduling  0/3 nodes available:
#     3 Insufficient cpu                    → raise CPU requests or add nodes
#     3 node(s) had untolerated taint       → add toleration to pod
#     3 node(s) didn't match node affinity  → fix nodeSelector/affinity
#     1 pod has unbound PVC                 → PVC not bound, check StorageClass
#     3 node(s) didn't match topology       → spread constraints impossible

A pod shows FailedScheduling due to insufficient CPU on all 3 nodes, and there's no Cluster Autoscaler in the cluster. Does the pod eventually get scheduled on its own?


Full Flow: Pod Scheduled to a Node

sequenceDiagram
    participant USER as kubectl apply
    participant API as API Server (etcd)
    participant SCHED as kube-scheduler
    participant KUBELET as kubelet (node)
    participant CRI as containerd (CRI)
    participant CNI as CNI plugin
    participant POD as Pod

    USER->>API: POST /pods (Pod spec, nodeName='')
    API->>API: store in etcd, status=Pending

    SCHED->>API: watch: new pod with no nodeName
    SCHED->>SCHED: Filter + Score --> node-2 wins
    SCHED->>API: POST /pods/binding --> nodeName=node-2

    API->>KUBELET: kubelet on node-2 watches its pods
    KUBELET->>CRI: RunPodSandbox (create pause container)
    CRI->>CNI: ADD (setup network namespace, assign IP)
    CNI-->>CRI: pod IP = 10.0.1.15
    KUBELET->>CRI: PullImage (if not cached)
    KUBELET->>CRI: CreateContainer + StartContainer
    KUBELET->>API: pod status = Running, podIP = 10.0.1.15

    API->>ENDPOINT: EndpointSlice updated with new pod IP
    API->>KPROXY: kube-proxy updates iptables rules
    Note over POD: traffic now routes to this pod

Same sequence, one step at a time:

1. Pod submitted. kubectl apply POSTs the pod spec to the API server with nodeName=''. It lands in etcd with status=Pending — nothing is running yet.
2. Scheduler picks it up. kube-scheduler is watching the API server for exactly this — pods with no nodeName — and drops it into its priority queue, sorted by PriorityClass.
3. Filter phase. Every filter plugin runs against every node. A node is eliminated the moment any single filter returns false — NodeResourcesFit, NodeAffinity, TaintToleration, PodTopologySpread, VolumeBinding, and NodeUnschedulable all get a vote.
4. Score phase. Only nodes that survived Filter get scored, 0-100, by each score plugin (LeastAllocated, NodeAffinity, InterPodAffinity, ImageLocality, ...). A node's final score is the weighted sum across every enabled plugin.
5. Bind. The highest-scoring node wins, ties broken randomly. The scheduler doesn't contact the node directly — it POSTs a Binding object to the API server, which writes nodeName onto the pod.
6. kubelet takes over. The kubelet on the winning node is watching for pods assigned to it. It calls containerd's RunPodSandbox, the CNI plugin sets up the network namespace and assigns a pod IP, the image is pulled if it isn't cached, and the container starts. Pod status flips to Running.
7. Service wiring catches up. Only now does the API server update the relevant EndpointSlice with the new pod IP, and kube-proxy rewrites its iptables rules on every node. Traffic doesn't reach the pod until this last step completes.

Once the scheduler binds a pod to node-2, does traffic immediately start routing to it?

Try It Yourself: Live Filter + Score

Four nodes, 8 CPU each, all empty. Add a pod with a CPU request (leave it blank for a random 1-4) and watch the same two phases from above run for real: Filter drops any node that doesn't have enough free CPU, then Score ranks whatever's left with LeastAllocatedscore = (capacity - used) / capacity, highest free-fraction wins, ties broken by lowest node index instead of the real scheduler's random tie-break so this demo stays reproducible. Push a pod too big for every remaining node and it lands in Pending instead of blocking, exactly like the FailedScheduling case above. Removing a bound pod frees its capacity immediately, but a pod already sitting in Pending is not auto-rescheduled — the real scheduler only retries on its own trigger, not the instant capacity opens up.

bound capacity just scheduled pending (unschedulable)

Taints, Tolerations, and Affinity

Taints — repel pods from nodes

# Taint a node (no GPU pods without toleration)
kubectl taint node gpu-node-1 nvidia.com/gpu=present:NoSchedule
#                             key=value:effect
# Effects: NoSchedule | PreferNoSchedule | NoExecute

The three effects aren't interchangeable severity levels of the same thing — flip through what each actually does:

Soft block, new pods only. The scheduler tries to avoid placing untolerated pods here, but it's not a hard rule — if nothing else fits, the pod can still land on this node.
Hard block, new pods only. A pod without a matching toleration will not be scheduled onto this node, full stop — but pods already running here before the taint was added are left alone.
Evicts, doesn't just block. Untolerated pods already running on this node get evicted, not merely kept off it going forward. A toleration can add tolerationSeconds to delay that eviction instead of tolerating it forever.

Tolerations — allow pods onto tainted nodes

spec:
  tolerations:
  - key: "nvidia.com/gpu"
    operator: "Exists"
    effect: "NoSchedule"

Node Affinity — attract pods to nodes

spec:
  affinity:
    nodeAffinity:
      # Hard rule: pod MUST land on a node with this label
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: topology.kubernetes.io/zone
            operator: In
            values: ["us-east-1a", "us-east-1b"]

      # Soft rule: prefer nodes with this label, but not required
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        preference:
          matchExpressions:
          - key: node.kubernetes.io/instance-type
            operator: In
            values: ["m5.2xlarge"]

These two blocks behave like the Filter/Score split from earlier — one is a hard eligibility rule, the other is only a scoring hint:

requiredDuringSchedulingIgnoredDuringExecution acts like an extra Filter plugin: the pod must land on a node matching one of the nodeSelectorTerms. No match on any node means the pod stays unscheduled, same as failing any other filter.
preferredDuringSchedulingIgnoredDuringExecution acts like an extra Score plugin: matching nodes get a score boost proportional to weight, but a non-matching node is still eligible — it just ranks lower and can still win if nothing else beats it.

Topology Spread Constraints — spread across zones

spec:
  topologySpreadConstraints:
  - maxSkew: 1                          # max diff in pod count between zones
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule   # or ScheduleAnyway
    labelSelector:
      matchLabels:
        app: api

A pod has a toleration that matches a node's taint. Does that mean the scheduler will prefer to place the pod on that node?