Lesson 01 / 21
Why Containers
Whether the environment difference can be pulled into the output is counted: the previous course's 18 differences are split into three buckets, the byte cost of what gets pulled in is measured, and the share of what must stay in the environment is written down.
Contents
The previous course counted where the difference hides and stopped at one point: across ten dimensions between three environments and production there were 18 differences, 12 of them written in the environment manifest, 6 visible only while the process was running. What the count actually said was not the size of the difference but where it sat: installed package versions, resource limits, file paths, timezone. The difference was not inside the software; it was in the place the software ran.
This means there are two separate ways to close the difference. The first is to pull the environments closer together: draw every link’s manifest toward production’s, then keep it drawn there. The second is to take the difference out of the environment and put it inside the build artifact. The less the output demands from the place it runs, the less it matters how different that place is.
The name of the second way is the container: placing the pieces of the environment the application needs alongside the application itself, and carrying all of it as a single output. This lesson asks how much of that way it actually closes, and the question has three parts: how many of the 18 differences can be pulled into the output, what pulling them in costs in bytes, and what remains when they cannot be pulled in.
The same fiction runs throughout the course: a municipality’s regional measurement network. The software collects readings from water meters, verifies them, turns them into invoices, and opens work orders for field crews; a nightly batch job processes the whole day’s readings. It is fiction; the numbers below come from that fiction’s model too.
CC1. The previous course’s difference list is this lesson’s input; the ten dimensions and the difference count per environment are carried over from there, not recounted. CC2. The measured output is not a single file but a set of files; how the set is packaged is the next topic’s question.
Splitting the Difference into Three Buckets
There is exactly one condition for a difference to be pulled into the output: the dimension’s value must be writable as a file. A package version can be written; the package itself is placed inside the output and the version installed in the environment stops mattering. Network latency cannot be written; latency is not a file’s content but a property of the path between two machines. There are dimensions in between too, and they need a name of their own.
CC3. The bucket rule is single: if the dimension’s value can be written into the output as a file, included; if the kernel, the hardware, or the processed data itself decides it, environment; if both are true, boundary. CC4. A dimension’s bucket is the same across every environment; the bucket depends on the dimension, not the environment.
// measurement-network/bucket.mjs — splits the previous course's 18 differences into three buckets (model, fictional data) // Input: the dimensions on which the three environments diverge from production in the // regional measurement network fiction; carried over from the previous course, not recounted here. const DIFFERENCES = { development: ["resolverVersion", "memoryMB", "concurrentWorkers", "meterCount", "billingGateway", "workOrderQueue", "timezone", "caseSensitiveFileSystem", "networkLatencyMs"], test: ["resolverVersion", "memoryMB", "concurrentWorkers", "meterCount", "billingGateway", "timezone", "networkLatencyMs"], staging: ["meterCount", "networkLatencyMs"], }; // Bucket rule: if the dimension's value can be written into the output as a file, INCLUDED; // if the kernel, the hardware, or the processed data itself decides it, ENVIRONMENT; if both // are true, BOUNDARY. const BUCKET = { resolverVersion: ["included", "the resolver binary is carried inside the output"], rulePackageVersion: ["included", "the rule package is carried inside the output"], concurrentWorkers: ["included", "the worker count is fixed in the configuration inside the output"], timezone: ["included", "the timezone table and setting are carried inside the output"], memoryMB: ["environment", "the control group enforcing the limit sits outside the output"], meterCount: ["environment", "data volume is a property of the running system, not the output"], networkLatencyMs: ["environment", "latency is a property of the physical path"], billingGateway: ["boundary", "the client is included, the gate itself stays outside"], workOrderQueue: ["boundary", "the client is included, the queue stays outside"], caseSensitiveFileSystem: ["boundary", "the files are included, the name-matching rule comes from below"], }; const ENVIRONMENTS = Object.keys(DIFFERENCES); const DIMENSIONS = Object.keys(BUCKET); const bucketOf = (b) => BUCKET[b][0]; console.log("dimension".padEnd(26) + "bucket".padEnd(13) + "diffs".padEnd(7) + "reason"); for (const b of DIMENSIONS) { const n = ENVIRONMENTS.filter((o) => DIFFERENCES[o].includes(b)).length; console.log(b.padEnd(26) + bucketOf(b).padEnd(13) + String(n).padEnd(7) + BUCKET[b][1]); } const allDifferences = ENVIRONMENTS.flatMap((o) => DIFFERENCES[o]); const countByBucket = (k) => allDifferences.filter((b) => bucketOf(b) === k).length; const dimensionCountByBucket = (k) => DIMENSIONS.filter((b) => bucketOf(b) === k).length; console.log("\ntotal differences: " + allDifferences.length + " (across ten dimensions)"); for (const k of ["included", "environment", "boundary"]) { console.log(`${k.padEnd(13)}dimensions ${dimensionCountByBucket(k)}/10 diffs ${countByBucket(k)}/18 ` + DIMENSIONS.filter((b) => bucketOf(b) === k).join(", ")); } console.log("\ndifference remaining per environment once pulled into the output:"); for (const o of ENVIRONMENTS) { const remaining = DIFFERENCES[o].filter((b) => bucketOf(b) !== "included"); console.log(`${o.padEnd(13)}${DIFFERENCES[o].length} -> ${remaining.length} remaining: ${remaining.join(", ")}`); } const remainingTotal = ENVIRONMENTS.reduce((t, o) => t + DIFFERENCES[o].filter((b) => bucketOf(b) !== "included").length, 0); console.log(`total 18 -> ${remainingTotal}; closing the boundary bucket too gives ${remainingTotal - countByBucket("boundary")}`);
dimension bucket diffs reason resolverVersion included 2 the resolver binary is carried inside the output rulePackageVersion included 0 the rule package is carried inside the output concurrentWorkers included 2 the worker count is fixed in the configuration inside the output timezone included 2 the timezone table and setting are carried inside the output memoryMB environment 2 the control group enforcing the limit sits outside the output meterCount environment 3 data volume is a property of the running system, not the output networkLatencyMs environment 3 latency is a property of the physical path billingGateway boundary 2 the client is included, the gate itself stays outside workOrderQueue boundary 1 the client is included, the queue stays outside caseSensitiveFileSystem boundary 1 the files are included, the name-matching rule comes from below total differences: 18 (across ten dimensions) included dimensions 4/10 diffs 6/18 resolverVersion, rulePackageVersion, concurrentWorkers, timezone environment dimensions 3/10 diffs 8/18 memoryMB, meterCount, networkLatencyMs boundary dimensions 3/10 diffs 4/18 billingGateway, workOrderQueue, caseSensitiveFileSystem difference remaining per environment once pulled into the output: development 9 -> 6 remaining: memoryMB, meterCount, billingGateway, workOrderQueue, caseSensitiveFileSystem, networkLatencyMs test 7 -> 4 remaining: memoryMB, meterCount, billingGateway, networkLatencyMs staging 2 -> 2 remaining: meterCount, networkLatencyMs total 18 -> 12; closing the boundary bucket too gives 8
Buckets diverge between dimension count and difference count. The included bucket has four dimensions but only 6 differences stand there; the rule package version was already the same across all four environments — there was no difference to close. The environment bucket has three dimensions but 8 differences stand there; data volume and network latency diverged from production in all three environments. So inclusion and the size of the difference are lined up in opposite directions: the two dimensions that produce the most difference are the two dimensions that can never be pulled into the output. This is why bucket counting is read through differences rather than dimensions; the dimension count says what can be addressed, the difference count says how much has actually closed.
The per-environment reading is sharper. The development environment drops from 9 differences to 6, test from 7 to 4, staging from 2 to 2 — no change at all. The link closest to production gains nothing, because both of the two dimensions staging diverges on sit in the environment bucket. This result pinpoints exactly where the sentence “the container removes the environment difference” goes wrong: the gain is largest at the link farthest from production and zero at the link closest to it. In total, 18 differences drop to 12; closing the four in the boundary bucket too brings it to 8.
The Byte Cost of Pulling In
Closing the six differences is not free. Every piece that is pulled in is a set of files genuinely written into the output, and the output’s size travels wherever it travels: bytes stored, bytes crossing the network, bytes unpacked to disk.
CC5. The measurement’s source tree is real — the service files are written to disk by a
deterministic generator (seed 20260801) and really measured with node:fs. CC6. The sizes
of the pulled-in pieces, however, are model values — declared figures, not measurements from
an actual setup — and a pulled-in piece replaces the one installed in the environment; the two
copies are not counted together.
// measurement-network/cost.mjs — the byte cost of what is pulled in // The source tree is really written to disk and really measured; the sizes of the pieces // pulled in from the environment are MODEL values, not measurements from an actual setup. import { mkdirSync, writeFileSync, readdirSync, statSync, rmSync } from "node:fs"; import { join } from "node:path"; const ROOT = "/tmp/measurement-network-source"; let seed = 20260801; // generator seed; the same seed gives the same tree const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648); // The fictional services' source tree: deterministic lines are generated for each service. const SERVICES = { collector: 210, verifier: 340, billing: 480, workOrder: 160, nightlyJob: 260 }; rmSync(ROOT, { recursive: true, force: true }); mkdirSync(join(ROOT, "src"), { recursive: true }); for (const [name, lines] of Object.entries(SERVICES)) { const body = Array.from({ length: lines }, (_, i) => `export const ${name}${i} = ${next() % 100000};`).join("\n"); writeFileSync(join(ROOT, "src", `${name}.mjs`), body + "\n"); } writeFileSync(join(ROOT, "config.json"), JSON.stringify({ concurrentWorkers: 8, timezone: "+00:00", rulePackage: "9.1.0" }, null, 2)); const bytes = (path) => statSync(path).isDirectory() ? readdirSync(path).reduce((t, a) => t + bytes(join(path, a)), 0) : statSync(path).size; const sourceBytes = bytes(ROOT); // MODEL: the declared sizes of the pieces pulled out of the environment and into the output. const PULLED_IN = { "resolver runtime": 44_800_000, "rule package and its dependencies": 31_200_000, "timezone table": 460_000, }; const BASE = { "small": 7_600_000, "medium": 31_500_000, "general-purpose": 118_000_000 }; const mb = (n) => (n / 1_000_000).toFixed(2).padStart(8) + " MB"; console.log("measured source tree (real files, seed " + 20260801 + ")"); for (const file of readdirSync(join(ROOT, "src")).sort()) console.log(" src/" + file.padEnd(20) + String(bytes(join(ROOT, "src", file))).padStart(8) + " bytes"); console.log(" config.json".padEnd(24) + String(bytes(join(ROOT, "config.json"))).padStart(8) + " bytes"); console.log(" source total".padEnd(24) + String(sourceBytes).padStart(8) + " bytes = " + mb(sourceBytes)); console.log("\nMODEL: pieces pulled into the output"); let pulledInTotal = 0; for (const [name, b] of Object.entries(PULLED_IN)) { pulledInTotal += b; console.log(" " + name.padEnd(35) + mb(b)); } console.log(" " + "total (base excluded)".padEnd(35) + mb(pulledInTotal)); console.log("\nbase root filesystem type scanned (closed difference count unchanged: 6/18)"); for (const [kind, t] of Object.entries(BASE)) { const output = sourceBytes + pulledInTotal + t; console.log(` ${kind.padEnd(17)}base ${mb(t)} output ${mb(output)} ` + `${Math.round(output / sourceBytes)} times the source per difference ${mb((output - sourceBytes) / 6)}`); } rmSync(ROOT, { recursive: true, force: true });
measured source tree (real files, seed 20260801) src/billing.mjs 15674 bytes src/collector.mjs 7217 bytes src/nightlyJob.mjs 9219 bytes src/verifier.mjs 11409 bytes src/workOrder.mjs 5472 bytes config.json 78 bytes source total 49069 bytes = 0.05 MB MODEL: pieces pulled into the output resolver runtime 44.80 MB rule package and its dependencies 31.20 MB timezone table 0.46 MB total (base excluded) 76.46 MB base root filesystem type scanned (closed difference count unchanged: 6/18) small base 7.60 MB output 84.11 MB 1714 times the source per difference 14.01 MB medium base 31.50 MB output 108.01 MB 2201 times the source per difference 17.99 MB general-purpose base 118.00 MB output 194.51 MB 3964 times the source per difference 32.41 MB
The written code comes to 49,069 bytes. The three pieces pulled out of the environment and into the output add up to 76.46 MB; the underlying root filesystem is added on top of that, and with the smallest base the output comes to 84.11 MB. The ratio is 1,714 times the source. The cost per closed difference across the six is 14.01 MB, and this is the first line item of the isolation budget: one difference has been pulled out of the environment at a cost of fourteen megabytes.
Scanning the base type gives the second line item. The number of closed differences is the same across all three types — 6 — but the output ranges from 84.11 MB to 194.51 MB. The cost paid for the same gain differs by a factor of 2.3, and the only thing producing the difference is which root filesystem the output is placed on top of. This is why the sentence “the container is lightweight” carries no decision: lightness is not a measurement result but the result of a choice, and the choice stands inside the output.
The cost is not only a byte line item. The pulled-in resolver runtime used to be a component that upgraded itself automatically whenever the environment was upgraded; from the moment it is pulled in, upgrading it requires the output to be rebuilt. As the difference is taken out of the environment, the responsibility for closing that difference also passes from whoever operates the environment to whoever produces the output.
This transfer is the budget’s invisible line item. For the team operating the environment, a version upgrade used to be one installation that touched every application on that machine at once. Once pulled into the output, the same upgrade becomes a separate build job for every output that carries that piece. The fictional network has five services; upgrading the resolver version means five rebuilds instead of one installation. The byte line item is visible because it can be measured; this one stays invisible until someone measures it — and the isolation budget’s real total is the sum of both.
What Stays in the Environment and What Sits on the Boundary
The three dimensions in the environment bucket are separated along with the reasons they cannot be pulled into the output. What enforces the resource limit is a control group outside the output; the output can declare how much memory it wants, but it cannot set the limit itself. Data volume is the number of meters being processed, and it is a property of the running system, not of the output. Network latency is a property of the physical path. What the three have in common is this: none of them can be written as a file’s content.
The three dimensions in the boundary bucket, on the other hand, include only part of the difference. For the billing gateway and the work order queue, the client library, the contract definition, and the timeout setting go into the output; the real gate on the other side stays outside. The difference shrinks but does not close to zero — the “contract drift” defect class stays open even after the client is included.
The third boundary dimension is the one that shows most clearly where the isolation is pierced. All the files are inside the output; but the rule that decides whether two file names count as the same name does not come from inside the output — it comes from the file system the output is opened on. The same output fails to find the tariff file on a case-sensitive file system and finds it on a case-insensitive one. The same fact holds for the clock: the timezone table can be pulled in, the clock’s value cannot — it is read from the kernel. The one-sentence summary of container isolation follows from here: the output carries files, not behavior. Part of the behavior always stays tied to the underlying kernel and file system.
Summary
- There are two ways to close the environment difference; this course measures the second one: taking the difference out of the environment and putting it inside the build artifact. The name of the way is the container.
- The previous course’s 18 differences were split into three buckets: 6 that can be included, 8 that must stay in the environment, 4 that sit on the boundary. Inclusion and how often a dimension produces a difference run in opposite directions.
- The gain per environment is not equal: development drops from 9 differences to 6, test from 7 to 4, staging stays at 2. The link closest to production gains nothing.
- The cost of pulling in was measured: 49,069 bytes of source becomes an 84.11 MB output with the smallest base — 1,714 times the source, 14.01 MB per closed difference. When the base type changes, the gain stays the same and the cost rises by a factor of 2.3.
- Isolation is pierced: the resource limit is enforced by a control group outside the output, and the clock’s value and the file-name-matching rule come from the underlying kernel. The output carries files, not behavior.
Next Step
This lesson showed that a difference can be pulled into the output, but it never asked one thing: where, and next to what, will this 76-megabyte piece of environment pulled in actually run? What happens if a second application on the same machine also brings its own resolver, its own rule package, and its own timezone table — do the two see each other’s files, can either exhaust the other’s memory, can one stop the other’s processes? Closing the environment difference and separating two applications that share an environment are separate jobs, and the name of the second one is isolation. The next lesson splits isolation into levels and compares what each level separates, what it duplicates, and how much memory and startup delay it demands in return, all by the same criteria.
To keep your progress and take notes, Log in
My notes
Log in to take notes.