DevOpsIndex

Kubernetes Scheduler — Deep Internals

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"]

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

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

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

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

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

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"]

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