Kestrel Preview: a Dry-Run Pod Scheduler
HardAI-assisted coding · AI-Assisted Feature Work
Kestrel Preview: a Dry-Run Pod Scheduler
The platform team ships Kestrel Preview, a small simulator that answers "where would the real cluster put these pods?" before a team rolls out a manifest. It is only useful if it agrees with the real scheduler, and lately it hasn't. Four incident reports are open against it (Part 1), and the roadmap has anti-affinity (Part 2), priority preemption (Part 3) and capacity-planning replays of thousands of pods (Part 4) queued behind them.
CPU is in millicores, memory in MiB, everything is an integer. There is no clock: the preview is a pure function of the operations so far.
Codebase
| File | What it does |
|---|---|
kestrel/api.py | run(operations) entry point and the Dispatcher that routes each operation |
kestrel/cluster.py | Cluster: nodes, pods, pending list, event log, place / submit / bind / unbind |
kestrel/filters.py | Filter phase: selector, taints and tolerations, resource fit |
kestrel/scoring.py | Score phase: node_score and pick_best |
kestrel/labels.py | selector_matches, shared by node selectors and pod (anti-)affinity |
kestrel/affinity.py | affinity_ok hook called by the filter phase (placeholder until Part 2) |
kestrel/preemption.py | preempt_for hook called when nothing fits (placeholder until Part 3) |
kestrel/index.py | Placeholder for Part 4 |
kestrel/nodes.py | Node: capacity, labels, taints, bound pods, usage accounting |
kestrel/models.py | Pod and AffinityTerm |
kestrel/specs.py | Manifest validation (parse_node, parse_pod); read its docstrings for the exact fields |
kestrel/generator.py | Deterministic workload generators behind bulk_nodes / bulk_submit (Part 4); do not change them |
kestrel/config.py, kestrel/errors.py | Constants and error codes |
tests/ | Existing unit tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against an empty cluster and returns one result per operation. Every operation is [name, *args]. Errors are returned as strings, never raised.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
add_node | name, spec | true | invalid node, node exists |
submit | name, spec | name of the node the pod was bound to | invalid pod, pod exists, unschedulable |
delete_pod | name | true | pod not found |
where | name | node name, or "pending" | pod not found |
node_status | name | {"cpu_used", "mem_used", "pods"} (pods sorted by name) | node not found |
pending | none | names of pending pods (see Part 3) | none |
retry_pending | none | see Part 3 | none |
events | k | the last k event lines (all of them if fewer), oldest first | invalid arguments unless k is an int >= 0 |
An unknown operation returns "error: unknown operation"; a wrong number of arguments (or an operation that is not a non-empty list starting with a string) returns "error: invalid arguments".
Node spec: {"cpu", "mem", "zone", "labels"?, "taints"?}. A taint is {"key", "value"?, "effect"} with effect NoSchedule or NoExecute (both keep new pods away). Pod spec: {"requests": {"cpu", "mem"}, "limits"?, "priority"?, "selector"?, "tolerations"?, "labels"?, "affinity"?, "preemption"?}; specs.py documents every field and what is invalid.
Scheduling = filter, then score.
- *Filter.* A node is feasible for a pod when all of these hold:
- every
selectorkey/value is present in the node'slabels(an empty selector matches every node); - every taint on the node is covered by at least one of the pod's tolerations;
- the pod's requests fit in what is free:
used + request <= capacity, for CPU and for memory (an exact fit is allowed). A node's usage is the sum of the requests of the pods bound to it. Limits never count; they only have to be valid (limits >= requests). - (Part 2) the pod's affinity terms hold.
- *Score.* For each feasible node compute, with integer division (floor):
cpu_pct = (cpu_capacity - cpu_used - cpu_request) * 100 // cpu_capacity,mem_pct = (mem_capacity - mem_used - mem_request) * 100 // mem_capacity(used = before this pod),score = (cpu_pct + mem_pct) // 2. The highest score wins; equal scores go to the node whose name is smallest (plain string order). No floats anywhere. - Bind the pod, append the event
bind <pod> <node>. If no node is feasible the result is"error: unschedulable"and nothing changes.
Tolerations are {"key", "operator", "value", "effect"} with operator Equal (default) or Exists. A toleration covers a taint when all hold:
effectis empty or equal to the taint's effect;Exists: the toleration'skeyis empty (it then covers every taint) or equals the taint's key; the taint's value is ignored;Equal: key and value equal (a missing value means"", and so does a taint without one).
Worked example
[["add_node", "n-b", {"cpu": 4000, "mem": 8192, "zone": "a"}],
["add_node", "n-a", {"cpu": 4000, "mem": 8192, "zone": "b"}],
["add_node", "gpu-1", {"cpu": 8000, "mem": 16384, "zone": "a", "labels": {"hw": "gpu"},
"taints": [{"key": "hw", "value": "gpu", "effect": "NoSchedule"}]}],
["submit", "api-1", {"requests": {"cpu": 1000, "mem": 1024}, "limits": {"cpu": 2000, "mem": 2048}}],
["submit", "api-2", {"requests": {"cpu": 1000, "mem": 1024}, "limits": {"cpu": 2000, "mem": 2048}}],
["submit", "train", {"requests": {"cpu": 6000, "mem": 4096}, "selector": {"hw": "gpu"},
"tolerations": [{"key": "hw", "operator": "Exists"}]}],
["submit", "train-2", {"requests": {"cpu": 6000, "mem": 4096}, "selector": {"hw": "gpu"}}],
["node_status", "n-a"],
["where", "train"]]returns
[true, true, true, "n-a", "n-b", "gpu-1", "error: unschedulable",
{"cpu_used": 1000, "mem_used": 1024, "pods": ["api-1"]}, "gpu-1"](api-1 scores 81 on both small nodes, a tie, so n-a wins by name; api-2 then prefers the empty n-b; gpu-1 is tainted, so only pods that tolerate it can land there.)
Part 1: Preview disagrees with the real cluster (INC-4412, INC-4419, INC-4427, INC-4431)
Goal: make the filter and score phases follow the rules above. Reports from teams, verbatim:
- INC-4412: "Preview says my 3-core batch pod does not fit on a 4-core node that is empty. In prod it schedules (its *limit* is 6 cores, its request is 3). Afterwards
node_statusshows far more CPU used than I requested." - INC-4419: "My pod tolerates
hwwithExists. Preview still refuses to place it on the tainted GPU nodes. Our platform pods use a toleration with an empty key andExists, and those are refused everywhere." - INC-4427: "A pod with selector
{disk: ssd, zone-class: a}landed on a node that only hasdisk: ssd." - INC-4431: "Two identical empty nodes: preview picks a different one depending on the order I added them, and with nodes of different sizes it sometimes picks a node that, by the numbers, is not the emptiest. Prod always breaks exact ties by node name."
Rules: everything in the section above. tests/test_filters.py has failing tests to start from. specs.py, nodes.py, models.py and generator.py are otherwise trusted.
Part 2: Pod affinity and anti-affinity (PLAT-880)
Goal: teams want replicas spread across zones (or nodes), and caches co-located with their database. Implement affinity_ok so the affinity field of a pod spec is enforced. Terms are already parsed onto the pod (pod.affinity_terms, pod.anti_terms).
A term is {"match": {labels}, "topology": "node" | "zone"}. A *topology domain* is a single node (its name) or a zone (all nodes sharing the zone value of the node). A bound pod is *matched* by a term when selector_matches(term.match, pod.labels) (so an empty match matches every pod). Only bound pods count: pending pods (Part 3) are nowhere.
Rules (all required terms; a node is feasible only if every rule holds for it):
- *Anti-affinity.* For each term in
pod_anti_affinity: no pod bound in the candidate node's domain (same node, or same zone, pertopology) may be matched by the term. - *Symmetry.* If a bound pod has an anti-affinity term that matches the incoming pod's labels, and the candidate node is in that term's domain *of the bound pod* (its node or its zone), the candidate is rejected too. Existing pods can repel newcomers. Affinity is not symmetric.
- *Affinity.* For each term in
pod_affinity: the candidate node's domain must contain at least one bound matched pod. Exception, so the first replica can start: if *no pod bound anywhere in the cluster* is matched by the term and the incoming pod's own labels match the term, the term is satisfied on every node. - A pod never counts against itself; deleting or evicting a pod removes its effect immediately.
- Part 1 scoring and tie-breaking apply unchanged among the nodes that pass.
Example
[["add_node", "a1", {"cpu": 4000, "mem": 8192, "zone": "east"}],
["add_node", "a2", {"cpu": 4000, "mem": 8192, "zone": "east"}],
["add_node", "b1", {"cpu": 4000, "mem": 8192, "zone": "west"}],
["submit", "db-1", {"requests": {"cpu": 500, "mem": 512}, "labels": {"app": "db"},
"affinity": {"pod_anti_affinity": [{"match": {"app": "db"}, "topology": "zone"}]}}],
["submit", "db-2", <same as db-1>],
["submit", "db-3", <same as db-1>],
["submit", "cache-1", {"requests": {"cpu": 500, "mem": 512}, "labels": {"app": "cache"},
"affinity": {"pod_affinity": [{"match": {"app": "db"}, "topology": "zone"}]}}],
["submit", "web-1", {"requests": {"cpu": 500, "mem": 512}, "labels": {"app": "web"},
"affinity": {"pod_affinity": [{"match": {"app": "web"}, "topology": "node"}]}}]]returns [true, true, true, "a1", "b1", "error: unschedulable", "a2", "a1"]. db-1 takes a1 (all scores tie, name order); db-2 must leave zone east, so b1; db-3 has no free zone. cache-1 may use any node (both zones have a db) and a2 is the emptiest. web-1 is the first web anywhere and matches its own term, so the exception applies and it lands on a1 (three nodes tie on score).
Part 3: Priority preemption (PLAT-902)
Goal: when a high-priority pod fits nowhere, the real cluster evicts lower-priority pods to make room. Implement preempt_for and retry_pending so Preview does the same. Real Kubernetes is more elaborate; this is the rule set Preview uses.
This supersedes the Part 1 rule "no feasible node means unschedulable": a pod that fits nowhere now tries preemption first, and is unschedulable only if preemption finds no viable node. When no lower-priority pod exists to evict, results are the same as before.
Rules
- *Who may preempt.* Pods with
preemption: "PreemptLowerPriority"(the default). A pod with"Never"is simplyunschedulable. A pod may only evict pods with strictly lowerpriority. - *Per node.* Skip a node whose
selector/taints (thestatic_okchecks) fail: evicting cannot fix those. Otherwise letcandidatesbe the node's bound pods with lower priority, ordered by(priority ascending, seq descending)(cheapest and newest first;seqis the order of a pod's first successful bind, 1, 2, 3, ...). Remove candidates one at a time in that order until the pod is fully feasible on that node (resources *and* the Part 2 rules, evaluated as if the removed pods were gone). If it is still not feasible with all candidates removed, the node is not viable. - *Give back what was not needed.* Take the removed pods in order
(priority descending, seq ascending); put each one back if the pod is still feasible with it present. What remains removed are the node's victims. - *Choosing the node.* Among viable nodes pick the smallest key
(highest victim priority, number of victims, node name). Scores are not used. - *Eviction.* Evict victims in order
(priority ascending, seq ascending), loggingevict <victim> <node> for <pod>for each; then bind the preemptor to the node (bind <pod> <node>). - *Requeue.* Then re-place each victim, in order
(priority descending, seq ascending), through the normal filter-and-score path without preempting (the preemptor is already bound, so it counts). A victim that fits getsbind <victim> <node>and keeps itsseq. A victim that does not fit becomes pending (pending <victim>event);wherereports"pending"for it. pendingreturns the pending pod names ordered(priority descending, seq ascending).delete_podremoves pending pods too. A pending pod's name stays taken (pod exists).retry_pendingmakes one pass over the pending pods in that same order, each through the normal path without preemption, and returns[[pod, node], ...]for those that were placed, in that order. The rest stay pending.- A failed
submit(unschedulable) leaves no trace: noseqis used, no event is logged.
Example
[["add_node", "n1", {"cpu": 4000, "mem": 8192, "zone": "a"}],
["submit", "old", {"requests": {"cpu": 2500, "mem": 512}}],
["submit", "new", {"requests": {"cpu": 500, "mem": 512}}],
["add_node", "n2", {"cpu": 1000, "mem": 8192, "zone": "b",
"taints": [{"key": "dedicated", "value": "x", "effect": "NoSchedule"}]}],
["submit", "urgent", {"requests": {"cpu": 2000, "mem": 512}, "priority": 100}],
["events", 5], ["pending"], ["where", "old"], ["node_status", "n1"]]returns [true, "n1", "n1", true, "n1", ["bind old n1", "bind new n1", "evict old n1 for urgent", "bind urgent n1", "pending old"], ["old"], "pending", {"cpu_used": 2500, "mem_used": 1024, "pods": ["new", "urgent"]}]. urgent needs 2000 CPU and only 1000 is free on n1. Candidates in order: new (newer), old. Removing new frees too little; removing old as well is enough. Giving back in order old, new: old cannot return (1500 free < 2000 needed) but new can. Victim: old only. It cannot be re-placed (n1 has 1500 free; n2 is tainted), so it is pending.
Part 4: Capacity-planning replays (PLAT-931, stretch)
Goal: make Preview fast enough to replay large clusters. Two operations already exist; they expand compactly-described workloads using generator.py and submit them through the normal path:
["bulk_nodes", {prefix, count, zones, cpu, mem}]adds nodes and returns how many.["bulk_submit", {prefix, count, seed, groups, cpu, mem, priorities}]submits pods one at a time and returns{"bound", "unschedulable", "evictions", "pending", "digest", "final"}.digestfingerprints every submit's result in order;finalfingerprints where every pod ended up (seebulk_submit's docstring). Both must come out exactly as the per-pod rules of Parts 1-3 dictate, so placements must be identical, not merely similar. Invalid arguments return"error: invalid arguments"; colliding names return"error: node exists"/"error: pod exists"before anything is added.
Rules
- Results of every earlier Part are unchanged.
- Hidden tests replay clusters of about a hundred nodes with over a thousand pods, with preemption and zone anti-affinity in play, in one
run; the whole suite has a few seconds. Re-deriving every node's state for every pod is too slow. Keep your fast path consistent with every way a pod can enter or leave a node (submit, delete, eviction, requeue, retry).
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later parts must not break earlier ones; hidden tests re-check earlier behaviour.
- Parts 1-3 is a strong result. Part 4 is a stretch.
Example 1
- Input:
- [["add_node","n-b",{"cpu":4000,"mem":8192,"zone":"a"}],["add_node","n-a",{"cpu":4000,"mem":8192,"zone":"b"}],["submit","api-1",{"requests":{"cpu":1000,"mem":1024},"limits":{"cpu":2000,"mem":2048}}],["submit","api-2",{"requests":{"cpu":1000,"mem":1024},"limits":{"cpu":2000,"mem":2048}}],["node_status","n-a"]]
- Output:
- [true,true,"n-a","n-b",{"cpu_used":1000,"mem_used":1024,"pods":["api-1"]}]
- Explanation
- Pods count by their requests. api-1 scores 81 on both empty nodes, so the smaller name n-a wins; api-2 then prefers the emptier n-b.
Example 2
- Input:
- [...3 nodes in 2 zones..., ["submit","db-1",<zone anti-affinity on app=db>],["submit","db-2",<same>],["submit","db-3",<same>]]
- Output:
- [..., "a1", "b1", "error: unschedulable"]
- Explanation
- Each db pod needs a zone with no other db pod. With two zones the third replica has nowhere to go.
Example 3
- Input:
- [["add_node","n1",{...4000m...}],["submit","old",{cpu 2500}],["submit","new",{cpu 500}],["submit","urgent",{cpu 2000,"priority":100}],["pending"]]
- Output:
- [true,"n1","n1","n1",["old"]]
- Explanation
- urgent evicts old (new is given back because it is not needed); old cannot be re-placed so it becomes pending.
Guidelines
- Python 3 standard library only
- CPU is integer millicores and memory integer MiB; there is no floating-point arithmetic in placement decisions
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Placement is fully deterministic: highest integer score, ties to the smallest node name; every victim, requeue and pending order is stated in the README
- Part 4 replays about a hundred nodes and over a thousand pods per test; the whole suite has a few seconds