Lesson 30 / 30
Multi-Cluster Management
Splitting by region and environment is tied to numbers: for every split axis, cluster count, fixed operating hours, upgrade window, and blast radius are all measured together. When the desired state is pushed from one place to many clusters, the propagation window, partial success, and drifted-cluster count are counted; an upper layer that assigns workloads to clusters is modeled, and which wrong decision it makes because of what it does not know is shown.
Published Updated
In this lesson
- Compares the split axis by two numbers: blast radius and fixed cost per cluster
- Counts the propagation window, partial success, and drifted clusters in cross-cluster convergence
- Measures the upper layer's missing information in three classes: correct decision, wrong decision, undecided
Contents
Every number in the previous lesson belonged to a single cluster. A cluster is a single failure unit — one control plane, one state store, one upgrade calendar — and this topic’s second lesson measured that once the entire plane goes down, the cluster keeps running but becomes unable to decide.
Multi-cluster operation splits a workload across more than one cluster; its axis is
environment, region, or tenant. The delegated decision moves up a level: which cluster a
workload runs on gets decided not by a human but by an upper layer. The measurement is again
over the regional measurement network and is fictional; no real cluster is run, and what
is counted is a model built with node.
- OO55. The workload splits into eighteen cells: three environments, two regions, three tenants.
- OO56. Operating hours are an input from the
cluster-architecturetopic: 67 hours per cluster monthly, 39 hours belonging to the workload, 3 windows per cluster over thirty-six months. - OO57. The desired state lives in a single store; each cluster pulls it on its own 15-round period. The pull moment and network lag are hand-written.
- OO58. One of six clusters’ admission control rejects the new declaration; that cluster stays drifted.
- OO59. The upper-layer model carries 24 workloads and 6 clusters; seed
20260901is visible in the output. - OO60. The outcome splits into three classes: correct decision, wrong decision, undecided.
Split Axis, Propagation, and Failure Class
// split.mjs — 18 workload cells split along different axes; fixed cost, propagation, failure class. const ENV = 3, REGION = 2, CELL = ENV * REGION * 3; const CLUSTER_HOURS = 67, WORKLOAD_HOURS = 39, UPGRADE = 3; // 33+34 per cluster, 39 belongs to the workload const AXIS = [["single cluster", 1], ["region", REGION], ["environment", ENV], ["env x region", ENV * REGION], ["env x region x tenant", CELL]]; const pad = (x, n) => String(x).padStart(n); console.log(`${CELL} cells = ${ENV} environments x ${REGION} regions x 3 tenants`); console.log("axis clusters cells blast hrs/mo windows/36mo"); for (const [name, n] of AXIS) { const h = Math.ceil(CELL / n); console.log(name.padEnd(24) + pad(n, 4) + pad(h, 7) + pad(`${((100 * h) / CELL).toFixed(1)}%`, 9) + pad(WORKLOAD_HOURS + n * CLUSTER_HOURS, 12) + pad(n * UPGRADE, 17)); } const ROUND = 120, PERIOD = 15, CHANGE = 10, READS = 26; // 26: reads of an empty verification round const offset = (i) => (i * 4) % PERIOD, lag = (i) => [0, 1, 2, 1, 2, 0][i % 6]; // hand-written const REJECT = 3; // this cluster's admission control rejects function propagate(n) { const pull = [...Array(n).keys()].map((i) => { if (i % 6 === REJECT) return null; // drifted cluster: cannot apply at all let t = CHANGE; while ((t - offset(i)) % PERIOD !== 0) t++; return t + lag(i); }); const arrived = pull.filter((x) => x !== null); const partial = [...Array(ROUND - CHANGE).keys()] .filter((k) => { const y = arrived.filter((x) => x <= CHANGE + k).length; return y > 0 && y < n; }).length; const convRounds = n * Math.floor(ROUND / PERIOD); return [Math.min(...arrived), Math.max(...arrived), partial, n - arrived.length, convRounds, convRounds * READS]; } console.log("\ndesired state in a single store; change at round 10, period 15 rounds"); console.log("clus first last window partial rounds drifted conv rounds reads"); for (const [, n] of AXIS) { const [i, s, k, sp, t, o] = propagate(n); console.log(pad(n, 4) + pad(i, 5) + pad(s, 5) + pad(s - i, 9) + pad(k, 19) + pad(sp, 7) + pad(t, 14) + pad(o, 16)); } const FAILURE = [["control plane", 1], ["node loss", 1], ["version upgrade", 1], ["admission rule change", 1], ["bad declaration", 0], ["shared data store", 0]]; const isolated = FAILURE.filter((a) => a[1]); console.log(`\nfailure classes ${FAILURE.length}: isolated by splitting ${isolated.length} (affected cells ${CELL}` + ` -> ${CELL / 2}); not isolated ${FAILURE.length - isolated.length} — ` + FAILURE.filter((a) => !a[1]).map((a) => a[0]).join(", ") + ` — ${CELL}/${CELL} in both clusters`);
18 cells = 3 environments x 2 regions x 3 tenants axis clusters cells blast hrs/mo windows/36mo single cluster 1 18 100.0% 106 3 region 2 9 50.0% 173 6 environment 3 6 33.3% 240 9 env x region 6 3 16.7% 441 18 env x region x tenant 18 1 5.6% 1245 54 desired state in a single store; change at round 10, period 15 rounds clus first last window partial rounds drifted conv rounds reads 1 15 15 0 0 0 8 208 2 15 20 5 5 0 16 416 3 15 25 10 10 0 24 624 6 15 25 10 105 1 48 1248 18 12 25 13 108 3 144 3744 failure classes 6: isolated by splitting 4 (affected cells 18 -> 9); not isolated 2 — bad declaration, shared data store — 18/18 in both clusters
The first table’s two columns move in opposite directions. Blast radius — the share of cells affected by losing one cluster — falls from a hundred percent to 5.6 percent; monthly fixed hours climb from 106 to 1,245, and the upgrade window from three to fifty-four. Splitting by region carries the radius to fifty percent and the load to 173 hours, splitting by environment to 33.3 percent and 240 hours: the only difference is the axis — the region axis buys failure isolation, the environment axis buys change isolation.
In the second table, the propagation window is zero for a single cluster, ten rounds for six clusters, and across those ten rounds the set of clusters is partly new, partly old. The real number is in the partial-success column: for six clusters it is not ten but a hundred five, because one cluster’s admission control rejects the declaration. A single drifted cluster turns a temporary window into a permanent split. The convergence workload gets multiplied too: 208 item reads climb to 3,744.
The last row gives the place multi-cluster does not pay off: four of six failure classes get isolated, two do not. A shared data store, and a bad declaration that leaves the single store and spreads to both clusters at once, keep stopping eighteen of eighteen cells. Splitting divides the failure unit; it does not divide the data layer.
Delegating the Decision to an Upper Layer
// upper-layer.mjs — the upper layer deciding which cluster a workload lands on, and what it does not know. const ALL = ["measurement", "field", "reporting"]; const CLUSTER = [ // name, environment, region, capacity, tenants accepted ["dev-r1", "development", "r1", 34, ALL], ["dev-r2", "development", "r2", 34, ALL], ["stg-r1", "staging", "r1", 36, ALL], ["stg-r2", "staging", "r2", 36, ALL], ["prod-r1", "production", "r1", 50, ["measurement", "field"]], ["prod-r2", "production", "r2", 26, ["reporting"]]]; const r = ((t) => () => (t = (t * 48271) % 2147483647) / 2147483647)(20260901); // seed visible const ENV = ["development", "staging", "production"]; const WORKLOAD = Array.from({ length: 24 }, (_, i) => ({ env: ENV[i % 3], region: r() < 0.5 ? "r1" : "r2", tenant: ALL[Math.floor(r() * 3)], units: 4 + Math.floor(r() * 9) })); function run({ age = 0, regionKnown = true, tenantKnown = true }) { const filled = Object.fromEntries(CLUSTER.map(([a]) => [a, 0])); let ledger = { ...filled }; const s = { correct: 0, full: 0, distant: 0, violation: 0, undecided: 0 }; WORKLOAD.forEach((y, i) => { if (i % (age + 1) === 0) ledger = { ...filled }; // the ledger goes stale by `age` steps let candidate = CLUSTER.filter(([, e]) => e === y.env); if (tenantKnown) candidate = candidate.filter(([, , , , k]) => k.includes(y.tenant)); if (regionKnown) candidate = [...candidate].sort((a, b) => (b[2] === y.region) - (a[2] === y.region)); const chosen = candidate.find(([a, , , k]) => ledger[a] + y.units <= k); if (!chosen) { s.undecided++; return; } // no decision could be made const [name, , region, cap, tenants] = chosen; if (filled[name] + y.units > cap) { s.full++; return; } // real capacity was not enough filled[name] += y.units; if (!tenants.includes(y.tenant)) s.violation++; else if (region !== y.region) s.distant++; else s.correct++; }); return s; } const SCENARIO = [["full information", {}], ["capacity stale by 8 placements", { age: 8 }], ["region not reported", { regionKnown: false }], ["tenant constraint not reported", { tenantKnown: false }], ["all three missing", { age: 8, regionKnown: false, tenantKnown: false }]]; const pad = (x, n) => String(x).padStart(n); console.log(`${WORKLOAD.length} workloads, ${CLUSTER.length} clusters (3 env x 2 regions); seed 20260901`); console.log("scenario correct wrong undecided full distant violation"); for (const [name, se] of SCENARIO) { const o = run(se); console.log(name.padEnd(31) + pad(o.correct, 5) + pad(o.full + o.distant + o.violation, 8) + pad(o.undecided, 12) + pad(o.full, 6) + pad(o.distant, 6) + pad(o.violation, 7)); } console.log(`items: ${WORKLOAD.length} workloads x 3 + ${CLUSTER.length} clusters x 2 = ${WORKLOAD.length * 3 + CLUSTER.length * 2}` + `; cluster capacity is the only one that is measured`);
24 workloads, 6 clusters (3 env x 2 regions); seed 20260901 scenario correct wrong undecided full distant violation full information 18 3 3 0 3 0 capacity stale by 8 placements 18 6 0 3 3 0 region not reported 13 10 1 0 10 0 tenant constraint not reported 18 4 2 0 0 4 all three missing 13 11 0 0 8 3 items: 24 workloads x 3 + 6 clusters x 2 = 84; cluster capacity is the only one that is measured
The full-information row is not flawless: eighteen of twenty-four workloads place correctly, three land in a distant region, and three go undecided — the first from capacity, the second from the tenant constraint.
The capacity ledger going stale produces a familiar shift. Once the ledger is eight placements stale, wrong decisions rise from three to six and undecided drops from three to zero: the aging ledger shows the pool as emptier than it is, so the upper layer stops saying “no room” and picks a full cluster instead.
Two reported items are measured separately. When region is not reported, correct decisions drop to thirteen and distant placement rises to ten; none get rejected, the workloads run and only exceed the latency limit — a silent class. When the tenant constraint is not reported, four workloads go to a cluster that does not accept them, and this wrong decision is noisy, because the cluster’s admission control catches it. With all three missing at once, undecided drops to zero: an upper layer that knows nothing never hesitates. Only six of the eighty-four items are measured.
Summary
- As 18 cells split from 1 cluster to 18, blast radius falls from 100% to 5.6%; monthly operation climbs from 106 to 1,245 hours, and the upgrade window from 3 to 54.
- The propagation window is 0 for a single cluster, 10 rounds for six; one rejecting drifted cluster pushes partial success to 105 rounds. Convergence load multiplies with cluster count: 208 item reads climb to 3,744.
- 4 of 6 failure classes get isolated by splitting; a shared data store and a bad declaration spreading from the single store affect 18 of 18 cells in both clusters.
- With full information, the upper layer places 18 of 24 workloads correctly; once capacity is stale by 8 placements, wrong decisions go from 3 to 6 and undecided from 3 to 0, and when region goes unreported, 10 workloads silently land in a distant region.
Course Wrap-Up
| Lesson | Delegated decision | Required information and its cost | Source of the wrong decision |
|---|---|---|---|
| The Need for Orchestration | placement | 6 items, 4 measured | unwritten constraint, 4 wrong spots |
| Control Plane Components | request becoming a decision | 50 reads, 7 writes | unrecorded death, 3 presumed |
| Node Components | is the node alive | 19 items in 10 seconds | threshold, 26 wrongly dead |
| The Reconciliation Loop | closing the gap | delayed report, 0.13 actions/round | unrecorded intent, 32 unnecessary copies |
| Local and Managed Clusters | operating the plane | 21 items, 106 hours | lost access, 5 settings missing |
| Pod | packaging boundary | 3 items, one guessed | no replica ratio, 4 excess instances |
| Replica Sets and Version Objects | replica count, rollout speed | 1 number, 2 settings | closed inside the pod, 6 wasted |
| Service Objects | which pod the request goes to | 3 items, 1-round resolution | list age, 216 stale requests |
| External Access | the outside request’s inside service | 4 declarations, 14–36 surface | uncoverage unwritten, 6 wrong |
| Configuration and Secret Objects | the setting reaching the pod | 3 declarations, 3-round propagation | read moment, 36 stale |
| Storage and Volumes | matching demand to supply | 76 items, 72 declarations/round | inventory age, 134 wrong |
| Stateful Applications | which replica, which data | 3 per replica, 15 items | name not persistent, 7 orphaned volumes |
| Jobs and Scheduled Jobs | finished or retry | 4 per night, 120 items | duration unknown, 5 skipped nights |
| Namespaces and Quotas | admitting the request | 3 per request, 180 items | usage invisible, 22 rejections |
| Resource Requests and Limits | placement | 42 items, 36 declared | no usage, 5,503 conflicts |
| Scheduling Constraints | eligible node set | 39 items, no measurement | wrong label, placed 15 → 16 |
| Taints and Tolerations | which node admits whom | 27 items, no verifier | excess toleration, 3 foreign pods |
| Eviction and Priorities | who leaves under pressure | 17 priorities, criticality 0 | value unmeasured, the bloater survives |
| Horizontal Scaling | replica count | 120 metrics, 3 rounds stale | own decision invisible, 510 replica-rounds |
| Vertical and Cluster Scaling | size, node count | 60 samples per round | cause absent from the metric, 12 excess events |
| Deployment Patterns | advance or stop | 4 items, version label | sparse metric, 851 requests |
| Health Checks | restart, open to traffic | 3 probes, 3 thresholds | startup inseparable, 113 kills |
| Role-Based Access Control | who can make the request | 43 of 1344 cells | trace ages, 28 escalation paths |
| Network Policies | is the connection legitimate | 18 rules or 6 | absent from trace, 119 unnecessary open |
| Security Context | should the definition be admitted | one item, the definition itself | looks at declaration, 13 silently exposed |
| Packaging and Templating | who writes the object set | 7 values, 3 branches | commonality unwritten, 3/5 diverged |
| Custom Resource Definitions | is the declaration valid | 10 fields, 120 fields of burden | schema too narrow, 4 silently wrong |
| Operator Pattern | what to do about the event | 5 items, 240 reads | maintenance invisible, 12 restarts |
| Cluster Observability | which question gets answered | 10,131 series, 26.1 GB | uncollected detail, 5 of 12 |
| Multi-Cluster Management | which cluster the workload runs on | 84 items, 1,245 hours | stale capacity, 6 wrong |
Thirty rows are one rule’s worked examples: delegating a decision requires producing the information that decision rests on, continuously and on time; every item the system does not know produces a class of wrong decision. Four sources repeat in the last column. Aging information appears in ten rows: the number at decision time lags behind reality, and as the lag grows, undecided turns into wrong. A condition left out of scope appears in eight rows: the system behaves correctly where it looks, and oscillates or drifts silently where it does not. An unreported item appears in seven rows, an unverified declaration in five — and a wrongly written declaration usually looks like an improvement. The system does not make the wrong decision because it lacks information; it makes it because there was no item it could have known. This is the delegation cost that has been this course’s unit of measure: the gain shows up in step count, the cost accumulates in information items.
One question is left, and M22/K04, Continuous Integration and Delivery, takes it up. The cluster applies the desired state: it reads the declaration, closes the gap, runs it through admission control, checks privilege. But who changes that desired state, looking at what, and passed through which check? A human writes every one of the thirty rows’ declaration items; for all the cluster’s rigor, nowhere does it test the correctness of the declaration it was handed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.