---
title: 'Image and Layer Model'
source: 'https://academia.sh/en/courses/containers/image-and-layer-model'
course: Containers
language: en
updated: '2026-08-23T16:55:07+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

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

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

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

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