Lesson 06 / 21
Image and Layer Model
An image is measured as an ordered list of layers: how many layers two services sharing a common base share, how many bytes are stored once, and what the gain is over a flat copy are counted; when a lower layer changes, how many upper layers' chain ids invalidate and how many bytes a deleted file still occupies in the store are measured.
Contents
The previous topic defined what isolation is and measured how many bytes the boundary drawn around a process’s surroundings isolates. Throughout those measurements, where the thing being run came from was never asked. An isolated process opens inside a filesystem, and that filesystem comes from somewhere; this lesson measures where it comes from.
The previous course defined the build artifact as a single object; the immutability principle and the content digest were measured there. A container’s output is bound to the same principle — it is produced once, is never changed in place, and its identity is its content’s digest — but it is not a single file. An image is an ordered list of layers, and the reason for this separation can be counted: when two services use the same base, a single-file output stores two full copies, while a layered output stores the shared portion once. The questions are these: how many layers are shared, how many bytes are stored once, and what invalidates above a lower layer when it changes?
IM1. The layer model below is built with node and runs over real directories; no real
container is run. A layer is a directory, an image is a list of layer names. IM2. The content
digest is the first twelve hexadecimal digits of the sha256 value taken over the sorted
path-and-byte pairs; the truncation is for readability and matches the format used in the previous
course. IM3. Two services of the fictional regional measurement network are measured: the
service that collects meter readings and the service that verifies them. The network and the
services are fictional.
Layer, Image, Chain Id
The layer model has three parts. A layer is a set of files and is named by its own content digest. An image is a list of layers ordered bottom to top; the filesystem that will run is formed by stacking this list in order. The chain id names a layer’s position within the image and is derived from the previous chain id and the layer’s content digest — the same layer carries the same content digest in two different images, but it gets a different chain id if the stack beneath it differs. This distinction is what the rest of the measurement rests on.
IM4. Layer contents are generated files scaled by line count; the sizes measured are the real byte counterpart of this generation, not a real product’s actual image size.
// measurement-network/layer.mjs — builds layers as real directories (model) import { mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; const ROOT = "layers"; const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12); // Layer body: line-count-scaled, reproducibly generated file content. export const body = (name, lines) => Array.from({ length: lines }, (_, i) => `// ${name} ${i}\nexport const a${i} = ${i * 7};\n`).join(""); export const LAYER = { "base-root": { "root/shell.js": body("shell", 30), "root/clock.js": body("clock", 14) }, "shared-library": { "lib/metrics.js": body("metrics", 120), "lib/rules.js": body("rules", 46) }, "shared-config": { "config/region.json": '{"region":"north","counter":41820}\n' }, "collector-dependency": { "dependency/queue.js": body("queue", 190), "dependency/csv.js": body("csv", 62) }, "collector-source": { "app/collect.js": body("collect", 34), "app/entry.js": body("entry", 9) }, "verifier-dependency": { "dependency/rule.js": body("rule", 88) }, "verifier-source": { "app/verify.js": body("verify", 27), "app/entry.js": body("ventry", 11) }, }; export const setUp = (definition = LAYER) => { rmSync(ROOT, { recursive: true, force: true }); for (const [name, files] of Object.entries(definition)) for (const [path, content] of Object.entries(files)) { mkdirSync(`${ROOT}/${name}/${path}`.split("/").slice(0, -1).join("/"), { recursive: true }); writeFileSync(`${ROOT}/${name}/${path}`, content); } }; // Walks the real directory on disk; the layer's file list, bytes, and content digest come from here. export const walk = (name, sub = "") => { const root = `${ROOT}/${name}${sub}`; return readdirSync(root, { withFileTypes: true }).flatMap((g) => g.isDirectory() ? walk(name, `${sub}/${g.name}`) : [`${sub}/${g.name}`.slice(1)]).sort(); }; export const layerInfo = (name) => { const paths = walk(name); const bodies = paths.map((p) => readFileSync(`${ROOT}/${name}/${p}`)); return { name, files: paths.length, paths, bytes: bodies.reduce((t, g) => t + g.length, 0), digest: digest(paths.map((p, i) => `${p}\n${bodies[i]}`).join("\n")) }; }; // Chain id: derived from the previous chain's id and the layer's content digest. export const chain = (names) => names.reduce((z, name) => [...z, digest((z.at(-1) ?? "") + layerInfo(name).digest)], []); export const COLLECTOR = ["base-root", "shared-library", "shared-config", "collector-dependency", "collector-source"]; export const VERIFIER = ["base-root", "shared-library", "shared-config", "verifier-dependency", "verifier-source"];
The stack’s order is not arbitrary: the base, which changes least often, sits at the bottom; the application source, which changes most often, sits at the top. What this order buys shows up in the next measurement.
The Measure of a Shared Layer
Two services are built on the same base: the root files, the shared metrics library, and the region config are three layers. Above them sit each service’s own dependency and source layers.
// measurement-network/measure.mjs — counts two images' layer sharing and store gain import { setUp, layerInfo, chain, COLLECTOR, VERIFIER } from "./layer.mjs"; setUp(); const all = [...new Set([...COLLECTOR, ...VERIFIER])].map(layerInfo); console.log("layer".padEnd(24) + "files bytes " + "content digest"); for (const k of all) console.log(k.name.padEnd(24) + String(k.files).padStart(3) + String(k.bytes).padStart(8) + " " + k.digest); const size = (image) => image.reduce((t, name) => t + layerInfo(name).bytes, 0); const shared = COLLECTOR.filter((name) => VERIFIER.includes(name)); const store = all.reduce((t, k) => t + k.bytes, 0); const flat = size(COLLECTOR) + size(VERIFIER); console.log(`\nreading-collector: ${COLLECTOR.length} layers, ${size(COLLECTOR)} bytes`); console.log(`verifier : ${VERIFIER.length} layers, ${size(VERIFIER)} bytes`); console.log(`shared layers ${shared.length}, stored once ${size(shared)} bytes`); console.log(`flat copy ${flat} bytes / layer store ${all.length} layers ${store} bytes`); console.log(`gain ${flat - store} bytes, %${(100 - (store * 100) / flat).toFixed(1)}`); console.log("chain ids (collector): " + chain(COLLECTOR).map((z) => z.slice(0, 8)).join(" "));
layer files bytes content digest base-root 2 1511 7e497ae2520f shared-library 2 6182 012f6ec862a6 shared-config 1 35 0fa5a2fe9601 collector-dependency 2 9101 48d5c4fac6a9 collector-source 2 1550 879ed761eaa7 verifier-dependency 1 3043 7d842f3affdb verifier-source 2 1336 f03b03ea3a2f reading-collector: 5 layers, 18379 bytes verifier : 5 layers, 12107 bytes shared layers 3, stored once 7728 bytes flat copy 30486 bytes / layer store 7 layers 22758 bytes gain 7728 bytes, %25.3 chain ids (collector): ae409503 9f5f4309 14a3ffd1 1c8a83e6 79a72ddd
The two images’ total size is 30,486 bytes; what the layer store keeps is 22,758 bytes: the three shared layers’ 7,728 bytes are stored once for both images, a 25.3 percent gain. What the measurement should be read for is not the ratio but where the ratio comes from: the gain equals the shared base’s share of the total. As each service’s own dependency layer grows, the ratio falls; as the shared base grows, it rises. If twenty services used the same base instead of two, the same 7,728 bytes would be stored once for twenty images, and the saving would multiply not by the layer count but by the number of sharing images.
This is what is gained over the previous course’s single-file build artifact. The difference pulled out of the environment is the same size and sits right here: the shared library’s version and the region config are no longer something installed on the machine that runs them — they are two layers that sit inside the output and are named by their digest. Another version of that library existing in the environment does not change the measurement, because the filesystem being measured is born from the layers.
When a Lower Layer Changes
Layered output’s cost is that layers are bound to one another by a chain. A single line is added to the shared library, and what changes in the two images is counted.
IM5. The change is kept at the smallest scale — a single line is added to one file in the
shared library — so that the invalidated chain’s length comes only from the change’s position, not
its size. IM6. Deletion is modeled with a marker file placed in the upper layer: an empty path
written with a deleted/ prefix removes the same path underneath from the view. Real union
filesystems format this marker differently; the behavior is the same.
// measurement-network/change.mjs — what invalidates when a lower layer changes, where a deleted file stays import { writeFileSync, readFileSync } from "node:fs"; import { setUp, layerInfo, chain, body, LAYER, COLLECTOR, VERIFIER } from "./layer.mjs"; setUp(); const before = { c: chain(COLLECTOR), v: chain(VERIFIER) }; const prevDigest = Object.fromEntries([...COLLECTOR, ...VERIFIER].map((a) => [a, layerInfo(a).digest])); const oldBytes = layerInfo("shared-library").bytes; writeFileSync("layers/shared-library/lib/metrics.js", body("metrics", 121)); // one line added const after = { c: chain(COLLECTOR), v: chain(VERIFIER) }; const digestChanged = [...new Set([...COLLECTOR, ...VERIFIER])].filter((a) => layerInfo(a).digest !== prevDigest[a]); const chainChanged = ["c", "v"].map((i) => before[i].filter((z, s) => z !== after[i][s]).length); console.log(`layer with changed content digest : ${digestChanged.length} (${digestChanged.join(", ")})`); console.log(`invalidated chain ids : ${chainChanged.reduce((a, b) => a + b)} (per image ${chainChanged.join(", ")})`); console.log(`upper layers with unchanged content: ${[...new Set([...COLLECTOR, ...VERIFIER])].length - digestChanged.length - 1}`); console.log(`store growth: ${layerInfo("shared-library").bytes} bytes (old version stays at ${oldBytes} bytes)`); // Upper layer: shadows one path, deletes one path. Deletion changes the view, not the lower layer. setUp({ ...LAYER, "collector-patch": { "config/region.json": '{"region":"north","counter":41820,"type":"patch"}\n', "deleted/lib/rules.js": "" } }); const stack = [...COLLECTOR, "collector-patch"]; const view = new Map(); let shadow = 0, deletedBytes = 0; for (const name of stack) for (const path of layerInfo(name).paths) { if (path.startsWith("deleted/")) { const target = path.slice(8); deletedBytes += readFileSync(`layers/${view.get(target)}/${target}`).length; view.delete(target); } else { if (view.has(path)) { shadow++; deletedBytes += readFileSync(`layers/${view.get(path)}/${path}`).length; } view.set(path, name); } } const stackBytes = stack.reduce((t, name) => t + layerInfo(name).bytes, 0); console.log(`\nlayers in stack ${stack.length}, total bytes in stack ${stackBytes}`); console.log(`files in union view ${view.size}, shadowed paths ${shadow}, deleted paths 1`); console.log(`bytes missing from the view but still in the store: ${deletedBytes}`); console.log("lib/rules.js in view: " + view.has("lib/rules.js"));
layer with changed content digest : 1 (shared-library) invalidated chain ids : 8 (per image 4, 4) upper layers with unchanged content: 5 store growth: 6222 bytes (old version stays at 6182 bytes) layers in stack 6, total bytes in stack 18429 files in union view 8, shadowed paths 1, deleted paths 1 bytes missing from the view but still in the store: 1654 lib/rules.js in view: false
A one-line change alters one layer’s content digest but invalidates eight chain ids: the changed layer in each of the two images and the three layers above it. The five separate upper layers’ content stays bit-identical; what invalidates is not the content but the position. The lower the change sits, the longer the invalidated chain runs — a line’s cost is not the line itself but how many layers sit above it.
The second cost sits in the store. Under the immutability principle, the old layer is not deleted: the store keeps the 6,182-byte version in place and adds the new 6,222-byte one alongside it. The layer store only grows; a one-line difference between two versions does not change this, because a layer is named by its content digest, and a layer whose content changed is a new object. This is the pair of numbers behind putting small, frequently-changing layers on top and large, rarely-changing ones at the bottom.
The same measurement reads in the opposite direction too. Tried on two extremes of the same model, the numbers come out as follows: when the topmost source layer changes, the invalidated chain id count is 1 for the collector image, 0 for the verifier image; when the bottommost root layer changes, it is 5 per image, 10 total. The cost of a change of the same size swings from one to ten depending on where it sits. The stack order is therefore not a matter of taste but a cost decision: each layer’s position determines in advance the price paid when that layer changes. This is the measurement’s decision-bearing form — the layer count alone is neither good nor bad; the product of how often a layer changes and how many layers sit above it is a cost.
What the Union View Hides
As layers stack up, a union filesystem view is born: if the same path exists in two layers, the upper one wins; a deletion marker in an upper layer removes the file underneath from the view. The measurement counts what the view hides. A six-layer stack carries 18,429 bytes; 8 files show up in the union view; one path is shadowed, one path is deleted. But the 1,654 bytes that drop out of the view are still in the store and are still part of the image.
This is where isolation is pierced. The layer model pulls the filesystem out of the environment and takes it into the output; in exchange, it also takes the output’s history into itself. A file deleted in an upper layer is absent from the view but present in the image: because the lower layer sits on disk and can be resolved by its content digest, anyone who gets hold of the image can read that file. Deletion is a view operation, not a store operation.
# verify.sh — are the layers real directories on disk, is the deleted file still there find layers -type f | wc -l ls layers/collector-patch/deleted/lib/ wc -c layers/shared-library/lib/rules.js
14
rules.js
1619 layers/shared-library/lib/rules.js
Viewed from the shell, seven layers’ fourteen files sit on disk in total; lib/rules.js, marked
deleted in the upper layer, is still readable in the lower layer with its real content of 1,619
bytes. The union view does not show it; the filesystem does.
What a Layer Does Not Carry
The measurements so far counted what the layer model takes into the output: filesystem layout,
library version, config file. The other half of the isolation budget is what does not get taken
in. A layer is a set of files — it is not a process, not a kernel, not a clock. Even the
base-root layer at the bottom of the stack is made only of files; when the image runs, the
kernel sitting beneath it is the kernel of the machine it runs on.
This has three concrete counterparts. First, processor architecture: the executable files inside layers are produced for one architecture, and the image does not carry this dependency inside itself — it assumes it from outside. The content digest names the files’ bytes, not which architecture those bytes carry meaning in; the same digest can name a filesystem that does not run on a different architecture. Second, the kernel interface: if a file inside a layer calls a kernel capability that is absent from the environment, the image resolves flawlessly, the chain ids verify, and the process still does not start. Third, the machine manifest: the timestamp and absolute path counted among the previous course’s sources that break reproducibility get mixed into a layer’s content digest the moment they are written into the layer’s content. The layer model does not close off these sources; it only makes visible where they were written, because they now sit inside a measurable object.
The distinction comes down to one sentence: an image carries what will run, not where it will run. The 22,758-byte layer store this lesson measured is the whole of the difference pulled out of the environment; the rest stays as the machine itself. The isolation budget’s account in this lesson has three entries: the difference pulled out is a filesystem, its cost is 22,758 bytes plus eight chain ids per change, and where it is pierced is the 1,654 bytes that drop out of the view and stay in the store.
Summary
- An image is not a single-file build artifact but a list of layers ordered bottom to top; each layer is named by its own content digest, and each position within the image is named by a chain id derived from the previous chain and the layer’s digest.
- Two images built on a shared base shared three layers: the total that was 30,486 bytes under a flat copy fell to 22,758 bytes in the layer store; 7,728 bytes were stored once, a 25.3 percent gain. The gain multiplies with the number of sharing images, not the number of layers.
- A single line added to the shared library changed one layer’s content digest but invalidated eight chain ids; five upper layers whose content stayed bit-identical got a new position id. The lower a change sits in the stack, the longer the invalidated chain runs.
- Under immutability, the old layer is not deleted: the 6,182-byte version stays in place, and the new 6,222-byte one is added alongside it. The layer store only grows.
- The union view looks like it deletes, the store does not. When one path was shadowed and one was deleted, 8 files remained in the view, but the 1,654 bytes that dropped out of the view kept sitting inside the image.
Next Step
This lesson’s layers were defined by hand: which file went into which layer was written in an object constant. A real image is not built this way — layers are born from instructions applied in sequence, and each instruction produces a layer. That opens two new questions. First, where do the files the instructions will copy come from: how many files and how many bytes enter this set, called the build context, and how many of them never reach the image at all? Second, how many files and bytes does the exclusion rule that narrows this set cut? The next lesson defines an image definition file format, writes an interpreter that applies instructions in sequence, and measures the build context’s size.
To keep your progress and take notes, Log in
My notes
Log in to take notes.