---
title: 'Why Containers'
source: 'https://academia.sh/en/courses/containers/why-containers'
course: Containers
language: en
updated: '2026-08-23T16:55:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
