Skip to content
academia.sh

Lesson 09 / 21

Multi-Stage Build

Separating build tools from the output is measured: the same application is built single-stage and multi-stage, the final image's size, layer count, and count of unneeded files left inside are compared, and multi-stage building's cost is counted too — total bytes produced, unshipped intermediate layers, and cache behavior.

Contents

The previous lesson built the cache that removes repeated work and counted the cost of instruction order. Throughout that measurement, the image’s content was never touched: the stack that emerged at the end of eight builds was identical to what the first build produced. Every file the dependency install left behind, every source file copied, and every tool used during install sat in the final layer.

Yet part of these is only needed during the build. A compiler, a linker, a test runner, and dev dependencies are used to produce the output; once the output is produced, none of them run again. Multi-stage building sets up this separation at the instruction level: the build happens in its own stage, and only the produced files travel to the final image. The question to measure is clear — how far apart are the final image’s size, layer count, and count of unneeded files left inside it, and what is the cost of this separation?

IM19. The interpreter below is an extended form of the previous lessons’ model: a stage instruction is added, and when a copy source is written as <stage>:<path>, it reads from an earlier stage’s view. IM20. The build base adds three build tools on top of the runtime base; the difference between the two bases is written explicitly in the model. IM21. The bundling model merges non-test source files and runtime dependencies into a single file; dev dependencies do not enter the bundle. IM22. The files needed at runtime are defined by hand: the runtime base’s files and the bundled output. The fictional measurement network’s reading collector service is measured.

Build Time Versus Runtime

In the single-stage definition, all instructions write to a single stack, and that stack both does the build and gets run. In the multi-stage definition, the stage instruction splits the stack: each stage starts from its own base, builds its own chain, and the final stage takes only the paths it wants from earlier stages. The final image is the final stage’s layers; earlier stages’ layers never enter the image at all.

The separation has a counterpart on the chain too. Every stage instruction resets the key chain: the second stage’s base layer gets a key independent of whatever happened in the first stage, and it comes from the cache even when the source changes. The only link that crosses between the two stages is the content of the files carried across. The previous lesson’s rule still holds here — a layer’s key covers everything beneath it — but “everything beneath it” is now cut off at the stage boundary. This is the single structural distinction that makes multi-stage building’s cache behavior both better and worse: the second stage’s base comes for free, but the copy layer gets rebuilt every time the carried package changes.

// measurement-network/stage.mjs — staged-build interpreter and layer cache (model)
import { mkdirSync, writeFileSync, readFileSync, readdirSync, appendFileSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";

const ROOT = "project";
const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12);
const text = (name, n) => Array.from({ length: n }, (_, i) => `// ${name} ${i}\n`).join("");

const TREE = { "source/entry.js": text("entry", 22), "source/collect.js": text("collect", 150),
  "source/queue.js": text("queue", 96), "source/test/collect.test.js": text("test", 205) };

const RUNTIME_BASE = { "root/runtime.js": text("runtime", 400), "root/shell.js": text("shell", 55) };
// Build base adds build tools on top of the runtime base.
const BUILD_BASE = { ...RUNTIME_BASE, "tool/compiler.js": text("compiler", 3200),
  "tool/linker.js": text("linker", 1450), "tool/headers.js": text("headers", 900) };

// Bundling: non-test source and runtime dependencies merge into a single file.
const bundle = (view) => [...view].filter(([y]) =>
  (y.startsWith("work/source/") && !y.includes("/test/")) || y.startsWith("dependency/runtime/"))
  .sort().map(([, i]) => i).join("");

const OUTPUT = {
  "build-base": () => BUILD_BASE,
  "runtime-base": () => RUNTIME_BASE,
  "install-deps": () => ({ "dependency/dev/test-runner.js": text("test-runner", 2100),
    "dependency/dev/build-plugin.js": text("build-plugin", 1600),
    "dependency/runtime/csv.js": text("csv", 780), "dependency/runtime/queue.js": text("queue-dep", 640) }),
  "package-build": (view) => ({ "work/dist/package.js": bundle(view) }),
};

export const SINGLE = `stage     single
base      build-base
copy      source  /work/source
run       install-deps
run       package-build
entry     /work/dist/package.js`;

export const MULTI = `stage     builder
base      build-base
copy      source  /work/source
run       install-deps
run       package-build
stage     output
base      runtime-base
copy      builder:/work/dist  /app
entry     /app/package.js`;

// Same multi-stage definition, in the form that moves the whole work directory across.
export const MULTI_WIDE = MULTI.replace("builder:/work/dist  /app", "builder:/work  /app")
  .replace("entry     /app/package.js", "entry     /app/dist/package.js");

const writeFile = (path, content) => {
  mkdirSync(path.split("/").slice(0, -1).join("/"), { recursive: true });
  writeFileSync(path, content);
};
export const buildTree = () => {
  rmSync(ROOT, { recursive: true, force: true });
  rmSync("image", { recursive: true, force: true });
  for (const [y, i] of Object.entries(TREE)) writeFile(`${ROOT}/${y}`, i);
};
export const touch = (path, no) => appendFileSync(`${ROOT}/${path}`, `// fix ${no}\n`);

const walk = (sub = "") => readdirSync(`${ROOT}/${sub}`, { withFileTypes: true }).flatMap((g) =>
  g.isDirectory() ? walk(`${sub}/${g.name}`) : [`${sub}/${g.name}`.replace(/^\//, "")]).sort();

// Pairs to copy: from the tree, or from another stage's view.
const copyPairs = (source, target, stages) => {
  if (!source.includes(":")) return walk(source).map((y) =>
    [`${target.slice(1)}/${y.slice(source.length + 1)}`, readFileSync(`${ROOT}/${y}`).toString()]);
  const [name, path] = source.split(":");
  const prefix = `${path.slice(1)}/`;
  return [...stages.get(name).view].filter(([y]) => y.startsWith(prefix))
    .map(([y, i]) => [`${target.slice(1)}/${y.slice(prefix.length)}`, i]);
};

export const build = (definition, cache = new Map()) => {
  const stages = new Map();
  let current = null, key = "";
  const counter = { builds: 0, hits: 0, bytes: 0 };
  for (const raw of definition.trim().split("\n")) {
    const [instruction, source, target] = raw.trim().split(/\s+/);
    if (instruction === "stage") {
      current = { name: source, view: new Map(), layer: 0 };
      stages.set(source, current); key = ""; continue;
    }
    if (instruction === "entry") continue; // metadata: produces no layer
    const input = instruction === "copy" ? copyPairs(source, target, stages).join() : "";
    key = digest(key + raw + input);
    let pairs;
    if (cache.has(key)) { pairs = cache.get(key); counter.hits++; }
    else {
      pairs = instruction === "copy" ? copyPairs(source, target, stages)
        : Object.entries(OUTPUT[source](current.view));
      for (const [y, i] of pairs) writeFile(`image/${current.name}/layer-${current.layer}/${y}`, i);
      cache.set(key, pairs);
      counter.builds++; counter.bytes += pairs.reduce((t, [, i]) => t + i.length, 0);
    }
    current.layer++;
    for (const [y, i] of pairs) current.view.set(y, i);
  }
  return { stages, counter, last: current };
};

// Needed at runtime: runtime base files and the packaged output.
export const needed = (path) => path.startsWith("root/") || path.endsWith("package.js");

Two Builds, One Application

Both definitions produce the same bundle from the same source. What is measured is what is left sitting alongside the bundle.

// measurement-network/measure-image.mjs — compares single-stage and multi-stage builds' final images
import { buildTree, build, needed, SINGLE, MULTI, MULTI_WIDE } from "./stage.mjs";

const inspect = (label, definition) => {
  buildTree();
  const { stages, counter, last } = build(definition);
  const files = [...last.view];
  const unneeded = files.filter(([y]) => !needed(y));
  const bytes = files.reduce((t, [, i]) => t + i.length, 0);
  const mid = [...stages.values()].filter((a) => a !== last);
  console.log(label.padEnd(20) + String(last.layer).padStart(4) + String(files.length).padStart(7) +
    String(bytes).padStart(9) + String(unneeded.length).padStart(9) +
    String(unneeded.reduce((t, [, i]) => t + i.length, 0)).padStart(9) + String(counter.bytes).padStart(10) +
    String(mid.reduce((t, a) => t + a.layer, 0)).padStart(6));
  return { bytes, files, unneeded };
};

console.log("build".padEnd(20) + " lyr  files     bytes  unneeded     bytes   produced mid-lyr");
const t = inspect("single-stage", SINGLE);
const c = inspect("multi-stage", MULTI);
inspect("multi-stage, wide", MULTI_WIDE);
console.log(`\nfinal image: ${t.bytes} -> ${c.bytes} bytes, ${(t.bytes / c.bytes).toFixed(1)}x smaller`);
console.log(`files in final image: ${t.files.length} -> ${c.files.length}`);
console.log(`unneeded at runtime: ${t.unneeded.length} -> ${c.unneeded.length}`);
console.log("unneeded files in single-stage: " + t.unneeded.map(([y]) => y).join(" "));
build                lyr  files     bytes  unneeded     bytes   produced mid-lyr
single-stage           4     14   215142       11   185826    215142     0
multi-stage            2      3    29316        0        0    244458     4
multi-stage, wide      2      7    35202        4     5886    250344     4

final image: 215142 -> 29316 bytes, 7.3x smaller
files in final image: 14 -> 3
unneeded at runtime: 11 -> 0
unneeded files in single-stage: tool/compiler.js tool/linker.js tool/headers.js work/source/collect.js work/source/entry.js work/source/queue.js work/source/test/collect.test.js dependency/dev/test-runner.js dependency/dev/build-plugin.js dependency/runtime/csv.js dependency/runtime/queue.js

The final image drops from 215,142 bytes to 29,316 bytes, file count from 14 to 3, layer count from 4 to 2. The single-stage image’s 11 files and 185,826 bytes never run at runtime at all — 86.4 percent of the image. Among them are three build tools, four source files (one a test file), two dev dependencies, and two runtime dependencies that have already entered the bundle. The runtime dependencies being carried twice is also counted: they sit both inside the bundle and alongside it.

The drop in layer count needs a separate reading too. The multi-stage image has two layers because the final stage runs only two instructions; this also changes the sharing gain the previous lesson measured. Other services born from the same base can share the first of those two layers; the second is specific to the service. In the single-stage image, the shareable base also carries the build tools, so it is both bigger and invalidates whenever the build tool version changes — the sharing gain depends on how often something changes, and the build base changes more often than the runtime base.

The difference pulled out of the environment is this lesson’s biggest item. The compiler, the linker, and the header files leave the runtime environment entirely; the running container’s filesystem has no build tool in it. At the instruction level, this costs two lines — a second stage and a single copy between stages.

The third row separates where the gain comes from. When the same multi-stage definition carries across the whole /work directory instead of /work/dist, the final image grows to 35,202 bytes and 7 files; four of them are source files not needed at runtime and take up 5,886 bytes. The whole gain does not come from opening a second stage; it comes from the narrowness of the path carried across. A second stage only helps to the extent that a decision is made about what it will not take from the first stage. This is the stage-boundary form of the directory copying problem measured in the previous lesson: copy takes a path, it does not count what sits beneath that path.

Multi-Stage’s Cost

The separation is not free. The same two definitions are run twice with one cache: the first build with a cold cache, the second after a single line is added to one source file. IM23. The change adds one line to source/collect.js. IM24. The stage view is kept in memory, layers are written to disk; the bytes measured are real file bytes.

// measurement-network/cost.mjs — multi-stage build's cost: intermediate layers and cache behavior
import { buildTree, touch, build, SINGLE, MULTI } from "./stage.mjs";

const sequence = (label, definition) => {
  buildTree();
  const cache = new Map();
  const first = build(definition, cache).counter;
  touch("source/collect.js", 1);
  const { stages, counter, last } = build(definition, cache);
  const mid = [...stages.values()].filter((a) => a !== last)
    .reduce((t, a) => t + [...a.view.values()].reduce((u, i) => u + i.length, 0), 0);
  const cacheBytes = [...cache.values()].reduce((t, c) => t + c.reduce((u, [, i]) => u + i.length, 0), 0);
  console.log(label.padEnd(14) + String(first.builds).padStart(4) + String(first.bytes).padStart(9) +
    String(counter.hits).padStart(8) + String(counter.builds).padStart(8) + String(counter.bytes).padStart(9) +
    String(cache.size).padStart(8) + String(cacheBytes).padStart(9) + String(mid).padStart(10));
};

console.log("build".padEnd(14) + " 1st  1st-byt   hits  builds    bytes   cache    bytes  unshipped");
sequence("single-stage", SINGLE);
sequence("multi-stage", MULTI);
build          1st  1st-byt   hits  builds    bytes   cache    bytes  unshipped
single-stage     4   215142       1       3   121300       7   336442         0
multi-stage      6   244458       2       4   144085      10   388543    215160

The cost has four items. First is the first build: the multi-stage layout produces 6 layers and 244,458 bytes, the single-stage one 4 layers and 215,142 bytes — a difference of 29,316 bytes, exactly the size of the second stage’s own base plus the carried bundle. Second is the source change: the single-stage rebuilds 3 layers and 121,300 bytes, while the multi-stage one produces 4 layers and 144,085 bytes, because when the bundle changes, the cross-stage copy invalidates too. Third is the cache: 7 entries and 336,442 bytes against 10 entries and 388,543 bytes.

The fourth item is the least visible. In the multi-stage layout, 215,160 bytes are produced on every build and enter no image at all. The intermediate stage’s layers are not shipped, but they are produced; they sit on the disk of the machine doing the build, take up room in the cache, and get reused on the next build. The image got smaller, the store did not. This is how the isolation budget closes its account in this lesson: 185,826 bytes were pulled out of the runtime environment, and in exchange, a persistent 215,160-byte stack appeared in the build environment, along with about 18.8 percent more write work per build.

How do these four items turn into a decision? Two numbers are set side by side. The extra cost is paid per build and is 29,316 bytes. The gain is paid per copy of the image and is 185,826 bytes: that many bytes are missing every time the image is written to the store, pulled to a machine, and sits on disk. The ratio is 6.3 — even a single image produced by a single build more than covers the extra cost. Considering the fictional measurement network’s four services, the difference multiplies, because the extra cost is paid once per build, while the gain reappears on every store and every pull. The single-stage layout is defensible where the image is never stored and is produced and run on a single machine; outside that, the measurement points one way.

What the stage boundary is not must also be written down. The copy builder:/work/dist line is a copying boundary, not a verification boundary. The final stage takes the bundle the previous stage produced exactly as it is; it does not ask what is inside it. If the bundler had not left test files out, they would have entered the final image too, and the measurement would not have caught it — because the needed definition is written by hand in this measurement. The image itself does not know what is needed; the person who wrote the carried path guarantees it was chosen correctly. This is the here-form of the shortfall measured in the previous lesson: an instruction records not what is needed but what was copied.

Summary

  • Multi-stage building separates files needed at build time from files needed at runtime: the build happens in its own stage, and only the produced files travel to the final image.
  • Built two ways, the same application’s final image dropped from 215,142 bytes to 29,316 bytes (7.3x), file count from 14 to 3, layer count from 4 to 2.
  • The single-stage image’s 11 files and 185,826 bytes never ran at runtime at all — 86.4 percent of the image. Three build tools, four source files, two dev dependencies, and two runtime dependencies that had already entered the bundle.
  • The cost has four items: 29,316 extra bytes produced on the first build, 4 layers instead of 3 and 144,085 instead of 121,300 bytes on a source change, 10 cache entries instead of 7, and 215,160 bytes produced on every build that enter no image. The image got smaller; the store on the building machine did not.
  • The gain comes not from opening a second stage but from the narrowness of the path carried across: when the whole /work was carried instead of /work/dist, the final image grew to 35,202 bytes and 7 files, and 4 unneeded files and 5,886 bytes came back.
  • The stage boundary is a copying boundary, not a verification boundary. The final stage takes what the previous stage produced exactly as it is; the definition of what is needed at runtime lives outside the instruction, in the hands of whoever wrote it.

Next Step

The final image has three files left, and two of them come from the base: the runtime base is 6,540 bytes, the bundle is 22,776 bytes. After everything the build produces has been measured and shrunk, what remains is the image’s unproduced portion — the base placed beneath it. Throughout this lesson the base was a constant; which files the runtime base carries, which shell and which libraries it contains, was never asked. Yet the base makes up about a fifth of the final image, and its content is not something the writer produced — it is something taken from outside. The next lesson measures this choice: how many bytes and how many files separate a small base from a broad one, what does the small base leave out, and under what circumstances is that shortfall paid for?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close