Skip to content
academia.sh

Lesson 10 / 21

Base Image Selection

Three base image candidates are measured by type designation: bytes, carried binaries and library count, and the presence of a shell and a package manager are counted; how many layers really change when the base updates is calculated, and the small base's hidden cost is measured in extra debugging steps and broken dependency bytes.

Contents

The previous lesson separated build tools from the output: the compiler, development headers, and intermediate files stayed outside the final output, and the output got smaller. But what got smaller were the upper layers. The final stage is still built on top of a base, and that base is the one part of the image that is not written inside it — it comes before the first instruction, someone else compiled its files, and someone else sets its update schedule.

This lesson measures that base. The question is not “which one is good”; it is what the same quantities — bytes, carried binaries, update load — come out to for three candidate types, and which cost the smaller one pushes out of sight.

IM25. Candidates are referred to by type, not by product: full-featured base, slimmed base, single-binary base. IM26. Inventory numbers come from our own generator; the seed 20260518 is visible, and the numbers are not a real measurement but an order of magnitude. IM27. The application layer on top is the same across all three candidates: the regional measurement network’s — the fictional software that collects readings from water meters — reading collector service, 24 MB.

What Is Inside the Base

The base image does most of the work of taking what is installed in the environment into the output: libraries, the certificate store, timezone data, a shell. The difference among candidate types is where this set gets cut. The full-featured base carries a general-purpose operating environment’s package set; a shell, a package manager, and process and network tools come with it. The slimmed base reduces the same set to a single multi-purpose binary and the required libraries, but drops the shell and the package manager. The single-binary base carries only the libraries the application links against, certificates, and timezone data.

// measurement-network/base.mjs — inventory of three base image candidates (model; type, not product)
export const generator = (seed) => () =>
  (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;

// [count, low bytes, high bytes]. Numbers come from the generator; they are an order of magnitude.
export const CANDIDATES = [
  { name: "full-featured", binary: [480, 8e3, 220e3], library: [310, 12e3, 240e3],
    data: [2400, 1e3, 14e3], shell: true, pkg: true, component: 148 },
  { name: "slimmed", binary: [140, 3e3, 60e3], library: [95, 8e3, 120e3],
    data: [120, 1e3, 9e3], shell: true, pkg: true, component: 31 },
  { name: "single-binary", binary: [0, 0, 0], library: [6, 30e3, 900e3],
    data: [40, 1e3, 30e3], shell: false, pkg: false, component: 6 },
];

export const APPLICATION = 24_000_000; // reading collector service; the same across all three candidates

export const inventory = (candidate, seed = 20260518) => {
  const r = generator(seed);
  const group = ([n, low, high]) => {
    let bytes = 0;
    for (let i = 0; i < n; i++) bytes += low + Math.round(r() * (high - low));
    return { n, bytes };
  };
  const i = group(candidate.binary), k = group(candidate.library), v = group(candidate.data);
  return { ...candidate, i, k, v, files: i.n + k.n + v.n, bytes: i.bytes + k.bytes + v.bytes };
};

export const mb = (b) => (b / 1e6).toFixed(1) + " MB";

// Below runs only when this file is executed directly; other files import it as a module.
if (import.meta.url === `file://${process.argv[1]}`) {
  const rows = CANDIDATES.map((a) => inventory(a));
  console.log("candidate".padEnd(15) + "files".padEnd(8) + "binary".padEnd(7) +
    "library".padEnd(11) + "shell".padEnd(7) + "pkg mgr".padEnd(12) +
    "base".padEnd(10) + "image");
  for (const s of rows) {
    console.log(s.name.padEnd(15) + String(s.files).padEnd(8) + String(s.i.n).padEnd(7) +
      String(s.k.n).padEnd(11) + (s.shell ? "yes" : "no").padEnd(7) +
      (s.pkg ? "yes" : "no").padEnd(12) + mb(s.bytes).padEnd(10) +
      mb(s.bytes + APPLICATION));
  }
  const [full, slim, single] = rows;
  console.log(`\nbase's share within the image: ${(100 * full.bytes / (full.bytes + APPLICATION)).toFixed(0)}% / ` +
    `${(100 * slim.bytes / (slim.bytes + APPLICATION)).toFixed(0)}% / ` +
    `${(100 * single.bytes / (single.bytes + APPLICATION)).toFixed(0)}%`);
  console.log(`executable files inside the image: ${full.i.n} / ${slim.i.n} / ${single.i.n}`);
  console.log(`components to track for updates: ${full.component} / ${slim.component} / ${single.component}`);
}
candidate      files   binary library    shell  pkg mgr     base      image
full-featured  3190    480    310        yes    yes         111.1 MB  135.1 MB
slimmed        355     140    95         yes    yes         10.5 MB   34.5 MB
single-binary  46      0      6          no     no          4.1 MB    28.1 MB

base's share within the image: 82% / 30% / 14%
executable files inside the image: 480 / 140 / 0
components to track for updates: 148 / 31 / 6

The same application, built on three bases, produces output ranging from 135.1 MB to 28.1 MB. In the full-featured base, 82 percent of the image is the unwritten portion: the base alone carries 3,190 files, and none of them were written for this job.

The second row is not bytes. The image containing 480 executable files is the count of tools standing ready if a code execution flaw is found — a file downloader, an archiver, a compiler, a network client. The attack surface is not equal to this number, but this number is its upper bound, and it is zero in the single-binary base.

There is one thing none of the three candidates change: the kernel. The base image carries user-space files, not the kernel interface; all three candidates depend on the system call set the kernel underneath provides, and that set is not inside the image. The single-binary base’s 46 files reach the same call surface as the full-featured base’s 3,190 files. The size table therefore does not measure the whole of isolation — it only measures how much of user space was taken into the output.

The difference pulled out of the environment is counted here: installed packages and library versions are no longer in the environment — they are in the output, as 148, 31, or 6 components. The difference has not vanished; it has only changed location and become bound to another publisher’s schedule. The next section counts that schedule’s cost.

How Many Layers Change When an Update Arrives

Component count means not only bytes but event frequency. Every component produces its own updates, and the base’s publisher ships them as a new base version. When the base layer changes, the image needs rebuilding; the real question is whether the layers above it change too.

IM28. An average of 0.9 security updates per component per year falls due, and the number of running machines is 12; both are fictional. IM29. The image is four layers: base, dependencies, application, config. Rebuilding is measured under two disciplines — a deterministic build puts no variable stamp into layers, a stamped build writes the build time into every rebuilt layer.

// measurement-network/update.mjs — how many layers really change when the base updates (model)
import { createHash } from "node:crypto";
import { CANDIDATES, inventory, mb } from "./base.mjs";

const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 10);
const gb = (b) => (b / 1e9).toFixed(1) + " GB";
const NODES = 12;              // number of running machines in the measurement network (fictional)
const ANNUAL_RATE = 0.9;       // annual security updates per component (fictional)

// The image is four layers: base + the application's three layers. The top three layers are the same across all three candidates.
const UPPER = [["dependencies", 16.0e6], ["application", 7.2e6], ["config", 0.8e6]];

// stamp=null: deterministic rebuild. stamp=<value>: a timestamp enters every layer.
const layers = (baseVersion, baseBytes, stamp) => [
  { name: "base", bytes: baseBytes, digest: digest(`base@${baseVersion}`) },
  ...UPPER.map(([name, bytes]) => ({ name, bytes, digest: digest(`${name}@v1${stamp ?? ""}`) })),
];

console.log("candidate".padEnd(15) + "annual updates".padEnd(19) + "changed layers".padEnd(19) +
  "changed bytes".padEnd(16) + "annual pull");
for (const candidate of CANDIDATES) {
  const e = inventory(candidate);
  const old = layers("2026.05", e.bytes, null);
  for (const [label, stamp] of [["deterministic", null], ["stamped", "-b2"]]) {
    const updated = layers("2026.06", e.bytes, stamp);
    const changed = updated.filter((k, i) => k.digest !== old[i].digest);
    const bytes = changed.reduce((t, k) => t + k.bytes, 0);
    const annual = Math.round(candidate.component * ANNUAL_RATE);
    console.log((label === "deterministic" ? candidate.name : "").padEnd(15) +
      (label === "deterministic" ? `${annual} times` : "").padEnd(19) +
      `${changed.length}/4 ${label}`.padEnd(19) + mb(bytes).padEnd(16) +
      gb(annual * bytes * NODES));
  }
}
console.log(`\nannual pull = annual updates x changed bytes x ${NODES} nodes`);
candidate      annual updates     changed layers     changed bytes   annual pull
full-featured  133 times          1/4 deterministic  111.1 MB        177.4 GB
                                  4/4 stamped        135.1 MB        215.7 GB
slimmed        28 times           1/4 deterministic  10.5 MB         3.5 GB
                                  4/4 stamped        34.5 MB         11.6 GB
single-binary  5 times            1/4 deterministic  4.1 MB          0.2 GB
                                  4/4 stamped        28.1 MB         1.7 GB

annual pull = annual updates x changed bytes x 12 nodes

The table’s two columns must be read together. Under deterministic building, a base update changes only one of the four layers: because the upper three layers’ content stays the same, their digests stay the same too, and they get skipped in the pull. Under stamped building, the same update changes four of four, because even when the content is the same, the build time written into the layer shifts the digest. The difference is 38.3 GB a year in the full-featured base, 1.5 GB in the single-binary base. Determinism is not a property of the base but a discipline of the build step — no matter how small the base is, a build that stamps redistributes the whole image on every update.

The annual pull column shows the real gap: 177.4 GB in the full-featured base, 0.2 GB in the single-binary base. The source of the gap in scale is frequency as much as bytes: the full-featured base updates 133 times a year, the single-binary base 5 times, and the latter has only 6 components to track. The small base’s most concrete gain shows up here — not the image’s size, but the size of the update surface.

This also writes down one of the places isolation is pierced: the base image has been taken into the output, but its production stays outside. 133 times a year, a change whose decision was not made here enters the output’s bottom layer. It can be stopped by pinning the version — but then security updates do not enter either.

The Small Base’s Hidden Cost

The small base’s cost does not show up in the size table; it shows up in two places. The first is during an incident: if the shell, a process lister, or a network tool is not inside the image, a question that tool would answer cannot be answered on-site. The second is during the build: a library the base does not carry has to be added to the image by hand.

IM30. The eight event types, the tool set each needs to be diagnosed on-site, and the service’s nine local dependencies are fictional. When a tool is missing, the extra-step count is tied to a fixed rule: 2 if a package manager is present (install, then clean the image afterward), 4 if not (prepare a helper container, share its namespace, copy the output out, rerun). Each dependency only comes ready in the base up to a certain leanness.

// measurement-network/cost.mjs — the small base's hidden cost: extra debugging steps and compatibility (model)
import { CANDIDATES, inventory, APPLICATION, mb } from "./base.mjs";

// The tool set each candidate carries (fictional, by type name).
const TOOLS = {
  "full-featured": ["shell", "pkg", "proc", "net", "file", "search"],
  "slimmed": ["shell", "pkg", "proc", "file", "search"],
  "single-binary": [],
};
// The measurement network's eight event types and the tool needed to diagnose each on-site (fictional).
const EVENTS = [
  ["service not responding", ["proc"]], ["queue backed up", ["proc", "search"]],
  ["certificate not verifying", ["file"]], ["name not resolving", ["net"]],
  ["disk full", ["file"]], ["config read wrong", ["shell", "search"]],
  ["slow response", ["net", "proc"]], ["permission denied", ["file", "shell"]],
];
// The service's nine local dependencies: [name, bytes, rank of the leanest candidate that provides it].
// Candidates are ordered rich to lean; if rank > provider, the dependency is absent from the base.
const DEPENDENCY = [
  ["crypto library", 3.1e6, 2], ["certificate store", 0.3e6, 2],
  ["math library", 1.2e6, 2], ["compression library", 0.6e6, 2],
  ["localization data", 2.4e6, 1], ["timezone database", 0.9e6, 1],
  ["name resolver plugin", 0.9e6, 1], ["version compatibility layer", 0.6e6, 0],
  ["shell script runner", 0.3e6, 0],
];

console.log("candidate".padEnd(15) + "on-site events".padEnd(17) + "steps".padEnd(9) +
  "missing deps".padEnd(18) + "added".padEnd(9) + "real image");
CANDIDATES.forEach((candidate, rank) => {
  const tools = TOOLS[candidate.name];
  let onSite = 0, extraSteps = 0;
  for (const [, need] of EVENTS) {
    if (need.every((g) => tools.includes(g))) onSite += 1;
    else extraSteps += tools.includes("pkg") ? 2 : 4;
  }
  const missing = DEPENDENCY.filter(([, , provider]) => rank > provider);
  const added = missing.reduce((t, [, b]) => t + b, 0);
  const nominal = inventory(candidate).bytes + APPLICATION;
  console.log(candidate.name.padEnd(15) + `${onSite}/8 events`.padEnd(17) +
    String(extraSteps).padEnd(9) + `${missing.length}/9`.padEnd(18) +
    mb(added).padEnd(9) + mb(nominal + added));
});
candidate      on-site events   steps    missing deps      added    real image
full-featured  8/8 events       0        0/9               0.0 MB   135.1 MB
slimmed        6/8 events       4        2/9               0.9 MB   35.4 MB
single-binary  0/8 events       32       5/9               5.1 MB   33.2 MB

In the single-binary base, none of the eight events can be diagnosed on-site, and the eight events produce a total of 32 extra steps. These steps are not only time. Attaching a helper container to the same namespace means bringing back every tool the image removed, right then — that is, isolation is knowingly pierced for the duration of debugging. The 480 executable files the small base closed off get reopened another way at the moment of the incident — the difference is that it is now temporary, not permanent, and when that period began can go on record.

The last two columns correct the size table. The single-binary base is nominally 6.4 MB smaller than the slimmed base; but five of the nine dependencies are absent from the base and get added to the image by hand, and the gap narrows to 2.2 MB. One of the missing dependencies is the shell script runner: on a base with no shell, a startup script does not run, and the service has to be directly executable. This is a small cost in bytes but a real one in how things must be written.

The slimmed base holds the middle value on both sides: 6/8 events on-site, 4 extra steps, 2 missing dependencies. Looked at together, the three measured quantities turn the choice from “the smallest one” into the question of who the extra steps fall on.

Summary

  • The same application produces 135.1 MB, 34.5 MB, and 28.1 MB on the three bases; in the full-featured base, 82 percent of the image is the unwritten portion, and the base alone carries 3,190 files.
  • The count of executable files inside the image is 480 / 140 / 0. This number is not equal to the attack surface, but it is its upper bound: it is the number of tools standing ready if a code execution flaw is found.
  • A base update changes one of four layers under deterministic building, four of four under stamped building. Annual pull is 177.4 GB in the full-featured base, 0.2 GB in the single-binary base; the source of the difference is frequency as much as bytes (133 updates against 5).
  • The small base’s hidden cost is measured in numbers: in the single-binary base, 0 of 8 events can be diagnosed on-site, 32 extra steps are needed, and these steps knowingly pierce isolation for the duration of debugging.
  • The nominal 6.4 MB size gap narrows to 2.2 MB once the 5 missing dependencies are added to the image; everything the base does not carry re-enters the image anyway — just from a different place, and by hand.

Next Step

This lesson’s measurement treated the three candidates as if their content were fixed. But no base is chosen by content: it is pulled by a name, and which content that name brings depends on time. The same holds for the produced image itself — each of the 133 updates produces new content, but what sits in deployment records is the name.

M22/K01’s build artifact repositories measured the distinction between a portable tag and an immutable version; that count is not repeated here. The question here sits one layer lower: an image is not a single object, it is a list of layers. The next lesson counts this — how many names point to the same content digest, how many of the four layers really change when a tag moves, and can a deployment that reaches production be traced back from its digest to its source?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close