Lesson 06 / 30
Pod
Measuring the smallest deployment unit declared to the cluster: what keeping three things together buys, the scale unit of packaging two processes into one pod versus two pods, and the difference in failure propagation, resource accounting, and network round trips.
Published Updated
In this lesson
- Names the three things a pod keeps together: the same network identity, the same local filesystem, the same lifetime
- Compares two packaging decisions by scale unit, failure propagation, resource accounting, and network round trip count
- Counts the information items a packaging decision depends on and measures the cost of a wrong decision when one item is missing
Contents
The cluster is up, and the reconciliation loop is turning: desired state sits in one place, actual state is measured in another, and the difference between them is converted into a correction. This loop was measured — how many rounds it takes to converge, how much work it does per round, how many wrong corrections it produces with delayed information. But the thing the loop operates on was never defined. What is declared to the cluster? Desired state is not a number; it is an object, and that object’s boundaries determine what the reconciler treats as a single unit and what it corrects separately.
This topic’s first decision starts right there: what is the smallest piece that can be declared
to the cluster? The answer is not a single container. The smallest deployment unit is a set of
containers that declares that it will be moved together; this unit is called a pod. This lesson
counts what a pod holds together and compares two packaging decisions across four measurements. What
is measured is a model built with node; no real cluster runs.
CO1 — the regional measurement network runs on a cluster in this course: this is the fictional software that collects, verifies, and bills meter readings. CO2 — the processes inside a pod share three things: the same network identity, the same local filesystem, the same lifetime. CO3 — every exchange between two processes in separate pods adds one name resolution and one network round trip, and inside the same pod this count is zero. CO4 — the reading collector needs six replicas, the log transformer two; the verifier and the local cache need four each, billing and the work order three each. CO5 — if a process inside a pod crashes, the whole pod restarts and goes two rounds without serving. CO6 — the resource request and limit are declared at the pod level; the processes inside it are not accounted separately.
Where the Boundary Is Drawn
A log transformer runs alongside the reading collector: it reads the raw lines the collector produces, formats them, and emits them. Are these two processes packaged in the same pod, or in separate pods? The question looks like a matter of preference; the model below reduces it to four numbers.
// pod.mjs — measuring two packaging decisions (MODEL; no real cluster runs). // CO1-CO6 assumptions are embedded in the code; every number below comes from this file. export const PROCESSES = { 'reading-collector': { replicas: 6, sharedFile: true, exchanges: 120 }, 'log-transformer': { replicas: 2, sharedFile: true, exchanges: 120 }, 'verifier': { replicas: 4, sharedFile: false, exchanges: 400 }, 'local-cache': { replicas: 4, sharedFile: false, exchanges: 400 }, 'billing': { replicas: 3, sharedFile: false, exchanges: 12 }, 'work-order': { replicas: 3, sharedFile: false, exchanges: 12 }, }; export const TRIPS = 2; // CO3: name resolution + network round trip export const SHARED = ['network identity', 'local filesystem', 'lifetime']; // Two packaging decisions keep the same process under the same load; only the boundary changes. export function pack(a, b) { const A = PROCESSES[a], B = PROCESSES[b]; const max = Math.max(A.replicas, B.replicas), total = A.replicas + B.replicas; const exchanges = Math.min(A.exchanges, B.exchanges); return { exchanges, together: { pods: max, procs: 2 * max, extra: 2 * max - total, scaleUnit: 1, failures: 2, accounting: max, procAccounting: 0, trips: 0, extraNotify: 0 }, apart: { pods: total, procs: total, extra: 0, scaleUnit: 2, failures: 1, accounting: total, procAccounting: total, trips: TRIPS * exchanges, extraNotify: SHARED.length }, }; } // Devolved decision: should these two processes travel in the same pod? export const INFO = ['shared file', 'exchange frequency', 'replica ratio']; const THRESHOLD = 100; export function decide(k) { const missing = INFO.filter((x) => k[x] === undefined); if (missing.length) return { result: 'cannot decide', note: `missing: ${missing.join(', ')}` }; const tight = k['shared file'] || k['exchange frequency'] >= THRESHOLD; if (k['replica ratio'] !== 1 && tight) return { result: 'conflict', note: 'two criteria point opposite ways' }; if (k['replica ratio'] !== 1) return { result: 'separate pods', note: 'replica ratio is not 1' }; if (tight) return { result: 'same pod', note: 'tightly coupled, ratio 1' }; return { result: 'separate pods', note: 'sparse exchange' }; } export function record(a, b) { const A = PROCESSES[a], B = PROCESSES[b]; return { 'shared file': A.sharedFile && B.sharedFile, 'exchange frequency': Math.min(A.exchanges, B.exchanges), 'replica ratio': Math.max(A.replicas, B.replicas) / Math.min(A.replicas, B.replicas) }; } export const PAIRS = [ ['reading-collector', 'log-transformer', 'collector+transformer'], ['verifier', 'local-cache', 'verifier+cache'], ['billing', 'work-order', 'billing+work-order'], ]; if (import.meta.url === `file://${process.argv[1]}`) { const s = (x, n) => String(x).padStart(n); console.log(`${'pair'.padEnd(23)}${'boundary'.padEnd(12)}${s('pods', 7)}${s('procs', 6)}` + `${s('extra', 6)}${s('scale', 6)}${s('fail', 6)}${s('acct', 6)}${s('trips', 6)}` + `${s('notify', 9)}`); for (const [a, b, ad] of PAIRS) { const p = pack(a, b); for (const [boundary, r] of [['one pod', p.together], ['two pods', p.apart]]) { console.log(`${ad.padEnd(23)}${boundary.padEnd(12)}` + `${s(r.pods, 7)}${s(r.procs, 6)}${s(r.extra, 6)}${s(r.scaleUnit, 6)}` + `${s(r.failures, 6)}${s(r.procAccounting, 6)}${s(r.trips, 6)}${s(r.extraNotify, 9)}`); } } console.log(`\nshared item (one pod): ${SHARED.join(', ')} = ${SHARED.length}`); console.log(`in two pods each one turns into a separate notification item: ${SHARED.length}`); console.log(`\n${'pair'.padEnd(28)}${'decision'.padEnd(16)}reason`); for (const [a, b, ad] of PAIRS) { const r = decide(record(a, b)); console.log(`${ad.padEnd(28)}${r.result.padEnd(16)}${r.note}`); } const missingK = { ...record('billing', 'work-order') }; delete missingK['replica ratio']; const r = decide(missingK); console.log(`${'billing+work-order (blind)'.padEnd(28)}${r.result.padEnd(16)}${r.note}`); // The cost of information: if 'replica ratio' is unknown, the system still has to pack. let wrong = 0, extraProcs = 0; for (const [a, b] of PAIRS) { const k = record(a, b); const full = decide(k); const blindK = { ...k }; delete blindK['replica ratio']; const blind = blindK['shared file'] || blindK['exchange frequency'] >= THRESHOLD ? 'same pod' : 'separate pods'; const correct = full.result === 'same pod' ? 'same pod' : 'separate pods'; if (blind !== correct) { wrong++; extraProcs += pack(a, b).together.extra; } } console.log(`\nif replica ratio is unknown: ${wrong} of ${PAIRS.length} pairs mispacked,` + ` ${extraProcs} unnecessary process instances`); }
pair boundary pods procs extra scale fail acct trips notify collector+transformer one pod 6 12 4 1 2 0 0 0 collector+transformer two pods 8 8 0 2 1 8 240 3 verifier+cache one pod 4 8 0 1 2 0 0 0 verifier+cache two pods 8 8 0 2 1 8 800 3 billing+work-order one pod 3 6 0 1 2 0 0 0 billing+work-order two pods 6 6 0 2 1 6 24 3 shared item (one pod): network identity, local filesystem, lifetime = 3 in two pods each one turns into a separate notification item: 3 pair decision reason collector+transformer conflict two criteria point opposite ways verifier+cache same pod tightly coupled, ratio 1 billing+work-order separate pods sparse exchange billing+work-order (blind) cannot decide missing: replica ratio if replica ratio is unknown: 1 of 3 pairs mispacked, 4 unnecessary process instances
The first pair shows why the pod boundary is not a matter of preference. The collector wants six replicas, the transformer two. In one pod the scale unit is one: six pods open, twelve process instances run, and four of them serve no load at all. Four extra process instances are the direct cost of the scale unit being one — the transformer has ridden along on the collector’s scale even though it did not need to. In two pods these four instances disappear, and in exchange two hundred forty network round trips appear: each of the hundred twenty exchanges pays one name resolution and one network round trip.
The second pair gives the reverse of the same accounting. The verifier and the local cache have equal replica counts, so in one pod the extra process instances are zero; separating them buys nothing, and eight hundred network round trips are paid instead. Tightly coupled helper processes that scale at the same rate belong inside the pod boundary; this is the reason the boundary exists. The third pair shows the third case: equal scale, but sparse exchange. Twenty-four round trips are cheap; separating is reason enough to bring failure propagation down from two to one and to gain per-process resource accounting.
The resource accounting column is a direct consequence of CO6. In one pod there are six accounting entries but zero per-process accounting: the pod’s request and limit are the sum of the two processes, and which one consumes how much never shows up in the declaration. In two pods there are eight entries and eight per-process accounts. This difference feeds into the scaling decision — a system that does not know what is growing scales both of them at once.
The last row counts the notification cost. In one pod the three shared items are free because the boundary itself provides them. Separating turns all three into a notification item: the network identity into a service object, the local filesystem into a transfer format, and the shared lifetime into a guarantee that no longer exists. The compose file on a single machine also gathers several processes into one file; the difference is that togetherness there means being on the same machine, while in a pod it means moving together, dying together, and sharing a single identity. The compose file’s startup waves and readiness-check measurement were done there; they are not repeated here, they become an input to the comparison.
The Cost of the Same Lifetime
Scale, round-trip, and accounting counts can all be read from a fixed table. The third shared item — the same lifetime — only becomes visible over time. The second run applies the same failure sequence to both packagings.
// lifetime.mjs — the cost of the same lifetime: the same failure sequence applied to two packagings (MODEL). import { PROCESSES } from './pod.mjs'; const SEED = 20260801, ROUNDS = 24, REPAIR = 2, RATE = 0.35; // The generator is hand-written; 32-bit integer operations, seed is visible. const make = (t) => () => { t ^= t << 13; t ^= t >>> 17; t ^= t << 5; return (t >>> 0) / 2 ** 32; }; // Required process instances: 6 collectors + 2 transformers. The failure sequence is generated once. const NEEDED = [ ...Array.from({ length: PROCESSES['reading-collector'].replicas }, (_, i) => ['collector', i]), ...Array.from({ length: PROCESSES['log-transformer'].replicas }, (_, i) => ['transformer', i]), ]; const rnd = make(SEED); const FAILURES = Array.from({ length: ROUNDS }, () => (rnd() < RATE ? NEEDED[Math.floor(rnd() * NEEDED.length)] : null)); // One pod: pod i carries both collector i and transformer i; if one falls, both fall. // Two pods: each process instance is in its own pod; a failure only takes it down. function run(together) { const down = {}; // process instance -> down until when let fell = 0, neighbor = 0; const missingRounds = { collector: 0, transformer: 0 }; for (let t = 0; t < ROUNDS; t++) { if (FAILURES[t]) { const [role, i] = FAILURES[t]; // Pod i, at the one-pod boundary, carries both collector i and transformer i. CO5. for (const [r, k] of together ? [['collector', i], ['transformer', i]] : [[role, i]]) { const key = `${r}#${k}`; if (!NEEDED.some(([x, y]) => x === r && y === k)) continue; if ((down[key] ?? -1) < t) { fell++; if (r !== role) neighbor++; } down[key] = t + REPAIR; } } for (const role2 of ['collector', 'transformer']) { const ad = role2 === 'collector' ? 'reading-collector' : 'log-transformer'; const up = Array.from({ length: PROCESSES[ad].replicas }, (_, k) => `${role2}#${k}`) .filter((a) => (down[a] ?? -1) < t).length; if (up < PROCESSES[ad].replicas) missingRounds[role2]++; } } return { fell, neighbor, ...missingRounds }; } const s = (x, n) => String(x).padStart(n); console.log(`seed ${SEED}, ${ROUNDS} rounds, ${FAILURES.filter(Boolean).length} failures,` + ` repair ${REPAIR} rounds`); console.log(`${'boundary'.padEnd(12)}${s('fell', 7)}${s('from neighbor', 16)}` + `${s('collector short', 17)}${s('transformer short', 19)}`); for (const [ad, b] of [['one pod', true], ['two pods', false]]) { const r = run(b); console.log(`${ad.padEnd(12)}${s(r.fell, 7)}${s(r.neighbor, 16)}${s(`${r.collector} rounds`, 17)}` + `${s(`${r.transformer} rounds`, 19)}`); }
seed 20260801, 24 rounds, 11 failures, repair 2 rounds boundary fell from neighbor collector short transformer short one pod 13 3 22 rounds 7 rounds two pods 10 0 20 rounds 4 rounds
Eleven failures produce thirteen and ten drops across the two packagings. The gap of three and the number three in the third column say the same thing: in one pod, three process instances fell because of a failure that had nothing to do with them. In none of these three drops was there a problem in the transformer’s or the collector’s own code; its neighbor crashed, the pod restarted, and it went down with it. At the two-pod boundary this column is zero — the failure never crosses the boundary drawn by the declaration.
The last column is how this shows up in service. The transformer ran with a missing replica in seven of twenty-four rounds in one pod, and in four when separated. A gap of three rounds looks small; what actually grows is not the ratio but the cause: nearly half of the transformer’s missing rounds come not from its own failure but from its neighbor’s, and this share grows with the number of processes placed in the pod. The same lifetime is declared as a convenience and paid for as a source of outages.
The Devolved Decision and What It Does Not Know
The decision that moves from human to system in this lesson is: which processes travel as a single unit. Once the decision is made, placement, restart, and scaling all operate on this unit; the system never looks inside the pod. The information items the decision depends on are named in the model, and there are three of them: shared file use, exchange frequency, replica ratio. All three have a source — the first two come from measurement, the third from a load declaration.
The cost of information is most visible in the third item. The replica ratio is not a measurement but a guess: will the two processes’ future load grow in the same direction? If this guess is not written down, the system still has to pack, and the model’s last line states what happens: one of three pairs is mispacked, four unnecessary process instances appear. The source of the error is not a bug but a gap; a rule that does not know the replica ratio takes the option that looks cheap and puts the collector and the transformer in the same pod.
The first pair’s decision, though, is not resolved even with information: because the two criteria
point in opposite directions, the result comes out as conflict. The shared file and frequent
exchange argue for togetherness, while the threefold replica ratio argues for separation. This is a
case where devolving does not pay off: the system applies the rule, the rule produces no answer,
and the decision returns to a human. An answer only emerges once one of the inputs changes — either
the four extra process instances are accepted, or the shared file is converted into a transfer and
two hundred forty round trips are paid. Devolving the decision forces a change in one of the
quantities the decision depends on.
Summary
- A pod is the smallest deployment unit that can be declared to the cluster, and it keeps three things together: the same network identity, the same local filesystem, the same lifetime. Separating turns these three into three separate notification items.
- If two processes asking for six and two replicas are packed into one pod, the scale unit drops to one and four unnecessary process instances appear; if they are separated, these four instances disappear and two hundred forty network round trips are added.
- For the pair with an equal replica ratio, separating gains nothing and costs eight hundred round trips; for the pair that scales equally but exchanges rarely, failure propagation drops from two to one in exchange for twenty-four round trips.
- Resource accounting is at the pod level: one pod keeps six entries, per-process accounting is zero, and what is growing never shows up in the declaration.
- On the same failure sequence, one pod produced thirteen drops and two pods produced ten; the entire difference is neighbor failure, and the transformer ran with a missing replica for seven rounds instead of four.
- The devolved decision rests on three information items; when the replica ratio is missing, one of three pairs is mispacked and four unnecessary process instances appear, and when the criteria conflict the rule produces no answer.
Next Step
This lesson drew the boundary of a single pod and took the number of replicas it needs as a given:
six, four, three. Yet that number is itself something declared to the cluster, and the moment it is
declared a new question arises — when the actual replica count drifts from what is desired, who
corrects it, how fast, and in what order? Moreover, while the desired replica count stays fixed,
what the replica contains can change; moving to a new version requires declaring how many extra
and how many missing replicas are allowed at the same time. The next lesson writes both objects with
node and counts the rollout round by round: how many rounds it takes to complete, how many rounds
it runs below capacity, and how many rounds a rollback takes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.