---
title: 'Scheduling Constraints'
source: 'https://academia.sh/en/courses/orchestration/scheduling-constraints'
course: 'Container Orchestration'
language: en
updated: '2026-09-13T05:08:28+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Writes the scheduler as a constraint solver and counts the narrowing of the eligible node set'
    - 'Measures the pods left unplaced as constraints are added and the case where two constraints render placement unsolvable'
    - "Counts topology spread's payoff on a zone loss in terms of surviving replicas"
    - 'Separates the kind of information constraints rest on: a label is a declaration, not a measurement'
---

# Scheduling Constraints

Writing the scheduler as a constraint solver: as label selection, affinity, anti-affinity, and topology spread are added, the eligible node set narrows; unplaceable pods are counted, two constraints render each other unsolvable, and topology spread changes the number of replicas that survive a zone loss.

The previous lesson's scheduler asked a single question: does the remaining request suffice.
Placement has other sides, and none of them concern capacity. Having the verifier run on nodes with
a fast disk, keeping two copies of the same service from piling onto one node, keeping billing on
the same node as the verifier, guaranteeing a surviving copy after a zone is lost — each of these
turns a node with sufficient capacity into an ineligible one.

These constraints turn the scheduler into a constraint solver. The decision stays the same (which
pod goes to which node), but it is now made within a search space: for each pod, the eligible node
set is computed first, and then one node is chosen from that set. If the set empties out, no
decision can be made and the pod does not get placed. This is what this lesson measures: how much
the set narrows as constraints are added, and at what point it empties out.

The model is nine nodes spread across three zones; the workload is three services of the regional
measurement network (the running fiction). No real cluster runs.

## The Constraint Solver and the Eligible Node Set

Four kinds of constraint are added cumulatively. Label selection requires one of the node's labels,
affinity requires the presence of another service, anti-affinity requires the absence of the same
service, and topology spread bounds the replica-count difference across zones. The selection rule
is the same as in the previous lesson: the first node in the eligible set.

The constraints are written with our own small schema. A node declaration carries two labels: zone
and disk type. A pod declaration places a constraint with three fields — the `disk` field requires
the node's disk label, the `antiAffinity` field asks for a node with no copy of the same service,
the `affinity` field asks for a node where the named service is present. Topology spread belongs to
the solver, not the pod, and is tuned with a spread count. The solver reads nothing beyond these
fields; whatever it does not read, however real it is on the node, lies outside the decision.

```js
// scheduling/constraint.mjs — the scheduler as a CONSTRAINT SOLVER. Not a real cluster, a model.
// Constraints are added cumulatively; the shrinking of the eligible node set is measured at each stage.
export const ZONE = ["b1", "b2", "b3"];
// SS7: three zones x three nodes, 1500 cpu units per node
// SS8: a fast disk only on the first node of each zone
export const NODES = ZONE.flatMap((b, i) =>
  [0, 1, 2].map((j) => ({ name: `${b}-d${j}`, zone: b, disk: j === 0 ? "fast" : "standard",
                          capacity: 1500 })));

// SS9: workload — the verifier wants a fast disk, billing wants the verifier's node
export const WORKLOAD = [
  { service: "verifier", replicas: 6, unit: 650, disk: "fast", antiAffinity: true },
  { service: "billing", replicas: 3, unit: 300, affinity: "verifier", antiAffinity: true },
  { service: "reading-collector", replicas: 9, unit: 420 },
];

export const expandPods = (load = WORKLOAD) => load.flatMap((s) =>
  Array.from({ length: s.replicas }, (_, i) => ({ ...s, name: `${s.service}-${i + 1}` })));

// SS10: topology spread — a service's replica-count difference across zones is at most 1
export const SPREAD = 1;

export function solve(constraint, load = WORKLOAD, nodes = NODES) {
  const state = nodes.map((d) => ({ ...d, used: 0, pods: [] }));
  const zoneCounts = new Map();          // service -> zone -> count
  const unplaced = [];
  const eligibleCounts = [];
  const count = (s, b) => (zoneCounts.get(s) ?? {})[b] ?? 0;

  for (const k of expandPods(load)) {
    const eligible = state.filter((d) => {
      if (d.used + k.unit > d.capacity) return false;
      if (constraint.label && k.disk && d.disk !== k.disk) return false;
      if (constraint.antiAffinity && k.antiAffinity && d.pods.some((x) => x.service === k.service)) return false;
      if (constraint.affinity && k.affinity && !d.pods.some((x) => x.service === k.affinity))
        return false;
      if (constraint.topology) {
        const min = Math.min(...ZONE.map((b) => count(k.service, b)));
        if (count(k.service, d.zone) + 1 - min > SPREAD) return false;
      }
      return true;
    });
    eligibleCounts.push(eligible.length);
    if (eligible.length === 0) { unplaced.push(k); continue; }
    const d = eligible[0];               // first-fit node: the rule never looks back
    d.used += k.unit; d.pods.push(k);
    const m = zoneCounts.get(k.service) ?? {};
    m[d.zone] = (m[d.zone] ?? 0) + 1; zoneCounts.set(k.service, m);
  }
  return { state, unplaced, eligibleCounts, zoneCounts };
}

export const distribution = (o, service) =>
  ZONE.map((b) => (o.zoneCounts.get(service) ?? {})[b] ?? 0);

const STAGE = [
  ["capacity only", {}],
  ["+ label selection", { label: 1 }],
  ["+ affinity", { label: 1, affinity: 1 }],
  ["+ anti-affinity", { label: 1, affinity: 1, antiAffinity: 1 }],
  ["+ topology spread", { label: 1, affinity: 1, antiAffinity: 1, topology: 1 }],
];

if (process.argv[1].endsWith("constraint.mjs")) {
  const total = WORKLOAD.reduce((t, s) => t + s.replicas, 0);
  console.log(`model: ${NODES.length} nodes / ${ZONE.length} zones, ${total} pods, ` +
    `fast-disk nodes ${NODES.filter((d) => d.disk === "fast").length}`);
  const s = (x, n = 15) => String(x).padStart(n);
  console.log(`${"stage".padEnd(22)}${s("avg eligible")}${s("min eligible")}${s("placed")}` +
    `${s("unplaced")}${s("busy nodes")}`);
  for (const [ad, constraint] of STAGE) {
    const o = solve(constraint);
    const avg = o.eligibleCounts.reduce((t, x) => t + x, 0) / o.eligibleCounts.length;
    console.log(`${ad.padEnd(22)}${s(avg.toFixed(2))}${s(Math.min(...o.eligibleCounts))}` +
      `${s(total - o.unplaced.length)}${s(o.unplaced.length)}` +
      `${s(o.state.filter((d) => d.pods.length > 0).length)}`);
  }
  console.log(`\nper-service zone distribution (${ZONE.join("/")}):`);
  console.log(`${"stage".padEnd(22)}${s("verifier")}${s("billing")}${s("collector", 16)}`);
  for (const [ad, constraint] of STAGE) {
    const o = solve(constraint);
    console.log(`${ad.padEnd(22)}${s(distribution(o, "verifier").join("/"))}` +
      `${s(distribution(o, "billing").join("/"))}${s(distribution(o, "reading-collector").join("/"), 16)}`);
  }
}
```

```
model: 9 nodes / 3 zones, 18 pods, fast-disk nodes 3
stage                    avg eligible   min eligible         placed       unplaced     busy nodes
capacity only                    5.83              3             18              0              7
+ label selection                3.83              1             18              0              7
+ affinity                       3.17              0             15              3              6
+ anti-affinity                  4.28              0             15              3              6
+ topology spread                3.00              0             15              3              6

per-service zone distribution (b1/b2/b3):
stage                        verifier        billing       collector
capacity only                   6/0/0          0/3/0           0/7/2
+ label selection               2/2/2          3/0/0           4/5/0
+ affinity                      2/2/2          0/0/0           6/3/0
+ anti-affinity                 1/1/1          1/1/1           7/2/0
+ topology spread               1/1/1          1/1/1           3/3/3
```

## The Narrowing Is Not One-Directional

The first table shows the expected narrowing: the average number of eligible nodes per pod drops
from 5.83 to 3.83 with label selection, then to 3.17 with affinity. The minimum-eligible-nodes
column is sharper — label selection shrinks the set to a single node for one pod, and once affinity
is added the set **empties out** and three billing pods fail to place. The reason is in the second
table: once the verifier's copies fill the three fast-disk nodes two apiece, no room is left on
those nodes for billing. The affinity constraint does not ask for room, but it requires room to be
left.

The next row is the lesson's inverted result. Once the anti-affinity constraint is added, the
average eligible-node count does not fall; it rises from 3.17 to **4.28**. The reason the search
space widens even as the constraint count grows is this: anti-affinity limits the verifier to one
copy per node, so three copies become unplaceable, and the capacity they no longer hold enlarges the
eligible set for the pods that follow. The number of placed pods stays at fifteen, but its
composition changes: billing's three pods now get placed, and three of the verifier's copies do not.
**One constraint rescues one service while dropping another**, and the total count does not show it.

In the topology-spread row the placed count is again fifteen, and the average eligible set is 3.00.
The only thing that changes is the last column: the reading collector's zone distribution goes from
7/2/0 to 3/3/3. This constraint drops no pod at all; it only reshapes placement — and its payoff
only becomes visible once a zone is lost.

## Zone Loss, Unsolvability, and a Wrong Label

```js
// scheduling/zone.mjs — topology spread's payoff on a zone loss, constraints that make each
// other unsolvable, and an incorrect label declaration. The model comes from the previous file.
import { ZONE, NODES, WORKLOAD, solve, distribution } from "./constraint.mjs";

const FULL = { label: 1, affinity: 1, antiAffinity: 1, topology: 1 };
const NO_TOPOLOGY = { label: 1, affinity: 1, antiAffinity: 1 };
const s = (x, n = 12) => String(x).padStart(n);
const SERVICES = ["verifier", "billing", "reading-collector"];

// SS12: a zone loss means all three nodes in that zone become unreachable at once.
console.log("replicas surviving a zone loss (placed / after the loss):");
console.log(`${"setting".padEnd(16)}${"loss".padEnd(8)}` +
  ["verifier", "billing", "collector"].map((x) => s(x, 14)).join("") +
  s("total remaining", 18));
for (const [ad, constraint] of [["no topology", NO_TOPOLOGY], ["with topology", FULL]]) {
  const o = solve(constraint);
  for (const loss of ZONE) {
    const remaining = SERVICES.map((sv) => {
      const d = distribution(o, sv), i = ZONE.indexOf(loss);
      return [d.reduce((t, x) => t + x, 0), d.reduce((t, x, j) => t + (j === i ? 0 : x), 0)];
    });
    console.log(`${ad.padEnd(16)}${loss.padEnd(8)}` +
      remaining.map(([t, k]) => s(`${t} / ${k}`, 14)).join("") +
      s(remaining.reduce((t, [, k]) => t + k, 0), 18));
  }
}

// Constraints making each other unsolvable: verifier has 6 replicas, 3 fast-disk nodes, each node
// takes at most one replica. The third row is unsolvable; the last row widens the node label.
const FAST6 = NODES.map((d, i) => ({ ...d, disk: i % 3 < 2 ? "fast" : "standard" }));
const VERIFIER_ONLY = WORKLOAD.filter((x) => x.service === "verifier");
console.log("\nverifier at 6 replicas, constraint combinations:");
console.log(`${"combination".padEnd(38)}${s("fast nodes")}${s("placed")}${s("unsolvable")}`);
for (const [ad, constraint, dgm] of [
  ["unconstrained", {}, NODES],
  ["label selection only", { label: 1 }, NODES],
  ["anti-affinity only", { antiAffinity: 1 }, NODES],
  ["label + anti-affinity", { label: 1, antiAffinity: 1 }, NODES],
  ["label + anti-affinity, 6 fast nodes", { label: 1, antiAffinity: 1 }, FAST6],
]) {
  const o = solve(constraint, VERIFIER_ONLY, dgm);
  console.log(`${ad.padEnd(38)}${s(dgm.filter((d) => d.disk === "fast").length)}` +
    `${s(6 - o.unplaced.length)}${s(o.unplaced.length)}`);
}

// SS11: a label is a declaration, not a measurement. What happens if a node's label is wrong.
console.log("\nwhen a label declaration is corrupted (full constraint set):");
console.log(`${"declaration".padEnd(34)}${s("placed", 14)}${s("unplaced", 14)}${s("busy nodes", 14)}`);
for (const [ad, dgm] of [
  ["correct", NODES],
  ["b2-d0 missing its fast label", NODES.map((d) => d.name === "b2-d0" ? { ...d, disk: "standard" } : d)],
  ["b3-d1 mistakenly labeled fast", NODES.map((d) => d.name === "b3-d1" ? { ...d, disk: "fast" } : d)],
]) {
  const o = solve(FULL, WORKLOAD, dgm);
  console.log(`${ad.padEnd(34)}${s(18 - o.unplaced.length, 14)}${s(o.unplaced.length, 14)}` +
    `${s(o.state.filter((d) => d.pods.length > 0).length, 14)}`);
}
const labelItems = NODES.length * 2;
const podItems = WORKLOAD.reduce((t, x) =>
  t + x.replicas * ((x.disk ? 1 : 0) + (x.antiAffinity ? 1 : 0) + (x.affinity ? 1 : 0)) + 1, 0);
console.log(`\ndeclarations read by the constraint solver: ${labelItems} node labels + ` +
  `${podItems} pod constraints = ${labelItems + podItems}; none of them are measurements`);
```

```
replicas surviving a zone loss (placed / after the loss):
setting         loss          verifier       billing     collector   total remaining
no topology     b1               3 / 2         3 / 2         9 / 2                 6
no topology     b2               3 / 2         3 / 2         9 / 7                11
no topology     b3               3 / 2         3 / 2         9 / 9                13
with topology   b1               3 / 2         3 / 2         9 / 6                10
with topology   b2               3 / 2         3 / 2         9 / 6                10
with topology   b3               3 / 2         3 / 2         9 / 6                10

verifier at 6 replicas, constraint combinations:
combination                             fast nodes      placed  unsolvable
unconstrained                                    3           6           0
label selection only                             3           6           0
anti-affinity only                               3           6           0
label + anti-affinity                            3           3           3
label + anti-affinity, 6 fast nodes              6           6           0

when a label declaration is corrupted (full constraint set):
declaration                               placed      unplaced    busy nodes
correct                                       15             3             6
b2-d0 missing its fast label                  13             5             5
b3-d1 mistakenly labeled fast                 16             2             6

declarations read by the constraint solver: 18 node labels + 21 pod constraints = 39; none of them are measurements
```

## Three Measurements

**Zone loss.** Without topology spread, the outcome depends on which zone is lost: if b1 goes down,
only two of the nine reading collectors remain; if b3 goes down, all nine remain. The total
surviving replica count swings between six and thirteen, and nobody chose which number would come
out. With topology spread, all three rows are identical: whichever zone goes down, six collectors
and ten replicas in total survive. **What the constraint buys is not the average but the worst
case** — the worst case rises from six to ten, the best case falls from thirteen to ten.

**Unsolvability.** The second table shows in numbers that two constraints, harmless alone, are
unsolvable together. Label selection alone places all six replicas, anti-affinity alone places all
six replicas, and the two together place only three, leaving three replicas unsolvable. What
collides is not the constraints themselves but a number: anti-affinity wants as many separate nodes
as there are replicas, while label selection narrows the eligible node count to three. The last row
gives the fix in numbers too — once the fast-disk node count is raised to six, all six of the six
replicas get placed. **The unsolvability is not in the declaration; it is in the cluster's
hardware.**

**A wrong label.** The third table exposes the kind of information the constraints rest on. None of
the thirty-nine declarations is a measurement: eighteen node labels were written by a person, and so
were the twenty-one pod constraints. When a node's fast-disk label goes missing, the placed count
drops from fifteen to thirteen; the error is visible, because something fails to place.

The real problem is in the next row. When a node without a fast disk is mistakenly labeled fast, the
placed count **rises** to sixteen. In the solver's ledger, the situation has improved; in reality, a
verifier copy is running on a slow-disk node, and no counter reports it. The scheduler does not
measure the node's disk; it reads its label. The source of the wrong decision is, once again, not
the information item itself but the fact that **the item is a declaration**.

## Summary

- As constraints are added, the average number of eligible nodes per pod drops from 5.83 to 3.83,
  then to 3.17; under the affinity constraint the set empties out and three billing pods fail to
  place.
- Adding a constraint does not always narrow the search space: the anti-affinity constraint raises
  the average from 3.17 to 4.28, because the capacity the three unplaceable verifier copies no
  longer hold is left over for the pods that follow.
- The same placed count can hide different compositions: while fifteen pods get placed, one
  constraint rescues three of billing's pods while dropping three of the verifier's.
- Topology spread fixes the worst case without dropping a single pod: surviving replicas on a zone
  loss move from the 6–13 range to a fixed ten, and the reading collector's distribution becomes
  3/3/3 instead of 7/2/0.
- Two constraints are harmless alone and unsolvable together: six replicas, three fast-disk nodes,
  and a one-copy-per-node rule yield three unsolvable pods; the unsolvability ends once the
  fast-node count rises to six.
- None of the thirty-nine items the solver reads is a measurement; a mis-written label raises the
  placed count from fifteen to sixteen and makes the error invisible.

## Next Step

The constraints so far write what the pod wants from the node. The reverse direction was never
written: which pod the node will accept. In the regional measurement network, the nightly batch job
consumes the whole disk and all the memory, and a service copy that lands on the same node slows
down for the whole night. The way to set these nodes aside for the batch job is not to write one
more constraint into every pod; it is to place a taint on the node itself and let only the pods that
carry a matching toleration arrive. The next lesson measures this separation and the idle ratio of
the set-aside capacity.
