Lesson 12 / 21
Image Registries
A layer-shared image registry is built with real files and measured from disk: bytes sharing removes, layers that cannot be deleted because of reference counting once a retention rule applies, the drop in pull traffic when a locally present layer is skipped, and the layer count public and private images share are counted.
Contents
The previous lesson tied a tag to a layer list and assumed the counterpart of every row in the list sits somewhere. That place is this lesson’s subject: the image registry. The registry is the layered form of the build artifact repository measured in M22/K01 — it does the same two jobs, storing content and resolving names, and the difference is that what it stores is not a single file but a set of layers. This difference changes every measure: growth, deletion, and download are now computed per layer, not per object.
IM37. The registry in this lesson is built with real files: every layer is a real file named
by its own content digest, every manifest is a real file, and every byte measured is read from
disk. The scale is 1/1000 — the 34.5 MB image from previous lessons is built here with 34.5 KB
real files. IM38. The build history comes from the previous lesson’s generator; the seed
20260604 is visible, and the same 20 builds give the same 17 distinct images. IM39. Part of
the images are placed in a private registry; this split comes from a second generator, seeded
20260812.
What the Registry Stores
In a content-addressed registry, a layer file’s name is its own digest. This one rule determines the whole of storage: when the same content is written a second time, it is written under the same name — that is, it takes up no new space.
// measurement-network/repo.mjs — layer-shared image registry: real files, real bytes import { createHash } from "node:crypto"; import { mkdirSync, rmSync, writeFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; export const ROOT = join(process.env.TMPDIR ?? "/tmp", "measurement-network-repo"); // Scale 1/1000: the 34.5 MB image from previous lessons is built here with 34.5 KB real files. export const LAYER = [["base", 10500, 0.08], ["dependencies", 16000, 0.25], ["application", 7200, null], ["config", 800, 0.30]]; export const generator = (seed) => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; export const digest = (b) => createHash("sha256").update(b).digest("hex").slice(0, 12); export const history = (n = 20, seed = 20260604, hiddenSeed = 20260812) => { const r = generator(seed), g = generator(hiddenSeed); const version = [1, 1, 1, 1], list = []; let day = 0; for (let no = 1; no <= n; no++) { day += 1 + Math.round(r() * 8); if (r() > 0.25) version[2] += 1; LAYER.forEach(([, , p], k) => { if (p !== null && r() < p) version[k] += 1; }); r(); r(); // the previous lesson's two fields: pulled so the schedule stays the same list.push({ no, day, hidden: g() < 0.35, layers: LAYER.map(([name, bytes], k) => ({ name, bytes, version: version[k] })) }); } return list; }; // The registry is content-addressed: a layer file's name is its own digest, the same layer is never written twice. export const setUpRepo = (G) => { rmSync(ROOT, { recursive: true, force: true }); mkdirSync(join(ROOT, "layer"), { recursive: true }); mkdirSync(join(ROOT, "manifest"), { recursive: true }); for (const d of G) { d.digests = d.layers.map(({ name, bytes, version }) => { const content = Buffer.alloc(bytes, `${name}@${version}|`); const h = digest(content); writeFileSync(join(ROOT, "layer", h), content); return h; }); d.image = digest(d.digests.join("|")); writeFileSync(join(ROOT, "manifest", d.image), d.digests.join("\n")); } return G; }; export const dirBytes = (path) => readdirSync(path).reduce((t, f) => t + statSync(join(path, f)).size, 0); export const kb = (b) => (b / 1000).toFixed(1) + " KB"; if (import.meta.url === `file://${process.argv[1]}`) { const G = setUpRepo(history()); const actual = dirBytes(join(ROOT, "layer")); const unshared = G.reduce((t, d) => t + d.layers.reduce((s, k) => s + k.bytes, 0), 0); const distinctLayers = readdirSync(join(ROOT, "layer")).length; const distinctImages = readdirSync(join(ROOT, "manifest")).length; console.log(`${G.length} builds, ${distinctImages} distinct manifests, ${G.length * 4} layer references`); console.log(`distinct layer files: ${distinctLayers}`); console.log(`repo actual (measured from disk): ${kb(actual)}`); console.log(`if unshared : ${kb(unshared)}`); console.log(`what sharing removes : ${kb(unshared - actual)} ` + `(%${(100 * (1 - actual / unshared)).toFixed(1)})`); const count = new Map(); for (const d of G) for (const h of new Set(d.digests)) count.set(h, (count.get(h) ?? 0) + 1); const sorted = [...count.values()].sort((a, b) => b - a); console.log(`references per layer: most ${sorted[0]}, median ` + `${sorted[Math.floor(sorted.length / 2)]}, referenced once ` + `${sorted.filter((v) => v === 1).length}/${distinctLayers}`); }
20 builds, 17 distinct manifests, 80 layer references distinct layer files: 27 repo actual (measured from disk): 176.2 KB if unshared : 690.0 KB what sharing removes : 513.8 KB (%74.5) references per layer: most 16, median 2, referenced once 11/27
Eighty layer references correspond to twenty-seven files on disk. The registry is 176.2 KB; if every image carried its own layers, it would be 690.0 KB. Sharing removes 74.5 percent of the bytes. The 513.8 KB removed is the same size as the unchanged byte total in the previous lesson’s move measurement — the two measurements are two faces of the same fact.
The reference distribution shows the real structure: one layer appears in sixteen images, the median is two, but eleven of twenty-seven layers appear in only a single image. The registry is not homogeneous; at one end sit lower layers that almost never change, at the other sit upper layers renewed on nearly every build. This distribution is also the source of the next measurement.
Deletion Runs Into Reference Counting
M22/K01 scanned the retention rule through bytes, the rollback window, and unreachable deployments; that scan is not repeated. In a layered registry, the rule runs into a new obstacle: deleting an image does not mean deleting its layers. A layer can only be deleted if no manifest references it.
IM40. The retention rule is a count — “the last K images are kept” — and K is counted over distinct manifests. The scan is calculated first, then one rule is really applied: files are deleted and the directory is re-measured.
// measurement-network/delete.mjs — what a retention rule really deletes when it runs into layer references (real files) import { readdirSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { ROOT, history, setUpRepo, dirBytes, kb } from "./repo.mjs"; const G = setUpRepo(history()); const distinctImages = []; // distinct manifests, in build order for (const d of G) if (!distinctImages.some((x) => x.image === d.image)) distinctImages.push(d); const sizeOf = (h) => statSync(join(ROOT, "layer", h)).size; const allLayers = readdirSync(join(ROOT, "layer")); const totalRepo = dirBytes(join(ROOT, "layer")); console.log("rule".padEnd(16) + "kept".padEnd(10) + "referenced".padEnd(13) + "deletable".padEnd(13) + "freed".padEnd(11) + "repo left"); for (const K of [3, 5, 8, 12, 17]) { const kept = distinctImages.slice(-K); const referenced = new Set(kept.flatMap((d) => d.digests)); const deletable = allLayers.filter((h) => !referenced.has(h)); const freed = deletable.reduce((t, h) => t + sizeOf(h), 0); console.log(`last ${K} images`.padEnd(16) + `${K}/${distinctImages.length}`.padEnd(10) + `${referenced.size}/${allLayers.length}`.padEnd(13) + `${deletable.length}/${allLayers.length}`.padEnd(13) + kb(freed).padEnd(11) + kb(totalRepo - freed)); } // The "last 8 images" rule is really applied: files are deleted, the directory is re-measured. const kept = distinctImages.slice(-8); const referenced = new Set(kept.flatMap((d) => d.digests)); const size = new Map(allLayers.map((h) => [h, sizeOf(h)])); const dropped = distinctImages.slice(0, -8); const droppedLayers = new Set(dropped.flatMap((d) => d.digests)); const before = dirBytes(join(ROOT, "layer")); let deleted = 0; for (const h of allLayers) if (!referenced.has(h)) { unlinkSync(join(ROOT, "layer", h)); deleted++; } for (const d of dropped) unlinkSync(join(ROOT, "manifest", d.image)); const after = dirBytes(join(ROOT, "layer")); const surviving = [...droppedLayers].filter((h) => referenced.has(h)); const droppedBytes = [...droppedLayers].reduce((t, h) => t + size.get(h), 0); console.log(`\n"last 8 images" applied: ${dropped.length} manifests and ${deleted} layer files deleted`); console.log(`layer directory ${kb(before)} -> ${kb(after)} (measured from disk): ` + `${dropped.length} of ${distinctImages.length} images dropped, bytes fell %${(100 * (before - after) / before).toFixed(0)}`); console.log(`of the dropped images' ${droppedLayers.size} distinct layers, ${surviving.length} are still ` + `referenced: ${kb(droppedBytes)} of bytes, ${kb(surviving.reduce((t, h) => t + size.get(h), 0))} of it could not be deleted`);
rule kept referenced deletable freed repo left last 3 images 3/17 7/27 20/27 126.5 KB 49.7 KB last 5 images 5/17 10/27 17/27 111.3 KB 64.9 KB last 8 images 8/17 13/27 14/27 102.5 KB 73.7 KB last 12 images 12/17 20/27 7/27 56.1 KB 120.1 KB last 17 images 17/17 27/27 0/27 0.0 KB 176.2 KB "last 8 images" applied: 9 manifests and 14 layer files deleted layer directory 176.2 KB -> 73.7 KB (measured from disk): 9 of 17 images dropped, bytes fell %58 of the dropped images' 17 distinct layers, 3 are still referenced: 129.8 KB of bytes, 27.3 KB of it could not be deleted
The table is not linear. When three of seventeen images are kept, the registry drops to 49.7 KB; when eight are kept, it is 73.7 KB. While the kept image count rises two and a half times, the registry only grows by about half, because most of the newly kept images’ layers are already there.
The applied rule gives the real point. Nine manifests were deleted, but three of the seventeen distinct layers these nine images referenced could not be deleted, because they still appear in kept images: 27.3 KB of 129.8 KB stayed on disk. The retention rule is written over manifests, it does not operate over bytes. The sentence “delete this image” means, in a layered registry, “delete this manifest and collect the layers whose reference count dropped to zero” — and there is a collecting step between these two jobs.
This is also a shortfall in isolation, in the deletion direction. An image being deleted does not mean the bytes inside it have left the registry; for anyone who knows its digest, those layers are still readable.
Pull: When What Is Local Gets Skipped
Sharing in the registry is not only a disk gain; the same arithmetic works on pulls too. A node does not redownload a layer it already has.
IM41. A node pulls seventeen images in sequence, and its local layer cache is never cleared. The copying is real: layer files are copied from the registry to a local directory, and bytes pulled are measured from disk. IM42. Authentication itself is outside the model; what is measured is what level access control can be enforced at.
// measurement-network/pull.mjs — bytes pulled when a locally present layer is skipped (real copying) import { mkdirSync, rmSync, existsSync, copyFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { ROOT, history, setUpRepo, dirBytes, kb } from "./repo.mjs"; const G = setUpRepo(history()); const LOCAL = join(ROOT, "local"); rmSync(LOCAL, { recursive: true, force: true }); mkdirSync(LOCAL, { recursive: true }); const distinct = []; for (const d of G) if (!distinct.some((x) => x.image === d.image)) distinct.push(d); // A node pulls images in order: a layer file already local is not copied. let pulled = 0, full = 0, skipped = 0, firstPull = 0; const rows = []; for (const d of distinct) { let pullBytes = 0, fresh = 0; for (const h of d.digests) { const dest = join(LOCAL, h); if (existsSync(dest)) { skipped += statSync(join(ROOT, "layer", h)).size; continue; } copyFileSync(join(ROOT, "layer", h), dest); pullBytes += statSync(dest).size; fresh += 1; } const imageBytes = d.layers.reduce((t, k) => t + k.bytes, 0); pulled += pullBytes; full += imageBytes; if (rows.length === 0) firstPull = pullBytes; rows.push(`${String(d.no).padStart(3)} ${fresh}/4 ${kb(pullBytes).padStart(8)} ${kb(imageBytes)}`); } console.log("first pull and last three pulls"); console.log(" no fresh pulled full image"); for (const s of [rows[0], ...rows.slice(-3)]) console.log(s); console.log(`\n${distinct.length} pulls: pulled ${kb(pulled)}, full sizes total ${kb(full)}, ` + `skipped ${kb(skipped)} (%${(100 * skipped / full).toFixed(1)})`); console.log(`first (cold) pull ${kb(firstPull)}; average of the next ${distinct.length - 1} pulls is ` + kb((pulled - firstPull) / (distinct.length - 1))); console.log(`local cache on disk ${kb(dirBytes(LOCAL))}, repo ${kb(dirBytes(join(ROOT, "layer")))}`); // Access control: public and private images share a single content-addressed pool. const publicImages = distinct.filter((d) => !d.hidden), privateImages = distinct.filter((d) => d.hidden); const publicLayers = new Set(publicImages.flatMap((d) => d.digests)); const privateLayers = new Set(privateImages.flatMap((d) => d.digests)); const common = [...privateLayers].filter((h) => publicLayers.has(h)); const commonBytes = common.reduce((t, h) => t + statSync(join(ROOT, "layer", h)).size, 0); const privateOnly = [...privateLayers].filter((h) => !publicLayers.has(h)); console.log(`\npublic images ${publicImages.length}, private images ${privateImages.length}`); console.log(`of the private images' ${privateLayers.size} layers, ${common.length} also appear in ` + `public images (${kb(commonBytes)}); private-only layers ${privateOnly.length}`);
first pull and last three pulls no fresh pulled full image 1 4/4 34.5 KB 34.5 KB 17 2/4 8.0 KB 34.5 KB 18 1/4 7.2 KB 34.5 KB 19 2/4 8.0 KB 34.5 KB 17 pulls: pulled 176.2 KB, full sizes total 586.5 KB, skipped 410.3 KB (%70.0) first (cold) pull 34.5 KB; average of the next 16 pulls is 8.9 KB local cache on disk 176.2 KB, repo 176.2 KB public images 10, private images 7 of the private images' 16 layers, 9 also appear in public images (63.4 KB); private-only layers 7
Seventeen pulls download 176.2 KB in total, against the images’ combined size of 586.5 KB. The 410.3 KB skipped is 70 percent of the traffic. The first pull downloads a full image (34.5 KB); the average of the next sixteen pulls is 8.9 KB — about a quarter of an image.
It is not a coincidence that the total downloaded equals the registry’s size: a node that pulls every image downloads the distinct layer set exactly once. This means pull traffic’s upper bound is not the image count but the distinct layer count.
The number behind the cost is the local cache: 176.2 KB is kept on the node, and that is about five times a single image currently running. This is isolation’s counterpart on the pull side — download shrinks, storage grows.
Public Registry, Private Registry
The output’s last two lines measure access control. Ten of seventeen images are public, seven are private. Authentication is enforced at the manifest level: requesting a private image’s manifest requires identity. Layers, though, sit in a single content-addressed pool.
The outcome is measured: nine of the private images’ sixteen layers also appear in public images — 63.4 KB. These nine layers’ digests can be read from a public manifest, and once read, those bytes can be pulled without needing identity. Only seven layers are specific to private images; that is what is actually secret.
This is this lesson’s place where isolation is pierced, and it has two sides. On one hand, the loss is real: the phrase “private registry” suggests all the bytes are secret, and the measurement says it is limited to seven layers. On the other hand, the cost of closing it can be counted: removing sharing and giving private images a separate pool spreads secrecy over every layer, but gives back part of the 513.8 KB the registry gained from sharing. Isolation is a budget, and this item comes out of it too.
Summary
- In a content-addressed registry, 80 layer references correspond to 27 files: the registry is 176.2 KB on disk, 690.0 KB if unshared. Sharing removes 74.5 percent of the bytes.
- The reference distribution is not homogeneous: one layer appears in 16 images, the median is 2, and 11 of 27 layers appear in only one image.
- The retention rule is written over manifests, it does not operate over bytes. When “last 8 images” is applied, 9 manifests are deleted, and because 3 of the dropped images’ 17 distinct layers are still referenced, 27.3 KB of 129.8 KB stays on disk.
- When a locally present layer is skipped, 17 pulls download 176.2 KB instead of 586.5 KB; what is skipped is 70 percent. Pull traffic’s upper bound is the distinct layer count, not the image count. The cost is the 176.2 KB local cache kept on the node.
- Access control is enforced at the manifest level, layers share the pool: of the 16 layers across 7 private images, 9 (63.4 KB) also appear in public images and can be read without identity by knowing the digest. Only 7 layers are actually secret.
Next Step
The registry has been measured, but one question stays open. If a private image’s nine layers are readable in the public pool, what is inside those layers? This lesson’s measurement looked at layers as stacks of bytes and never looked inside them.
The next lesson looks inside and closes the topic: does a file deleted in an upper layer really still sit in the lower layer, and how many bytes were really never deleted; how many does a scan running against known defects catch, and how many does it miss; what does a signing chain verify, and what path does an unsigned pull open?
To keep your progress and take notes, Log in
My notes
Log in to take notes.