Lesson 11 / 21
Tagging Scheme
A tag is measured in a layered image registry: how many distinct content digests twenty builds produce and how many tags point to the same digest are counted, how many of the four layers really change when a tag moves is calculated, and the rate at which a deployment can be traced back to its source from its digest is measured under three separate record disciplines.
Contents
The previous lesson treated the three base candidates as if their content were fixed. But no base is chosen by content: it is pulled by a name. The same holds for the produced image — a build produces new content, but what sits in a deployment record 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. Does a tag point to this list’s entirety, how many names can point to the same list at once, and when a name moves, how many of the list’s rows really change?
IM31. The registry model keeps two ledgers: image digest → manifest, and tag → image digest.
Access control and the network layer are outside the model. IM32. The build history comes from
our own generator; the seed 20260604 is visible, 20 builds are made, and one build in four is a
rebuild from the same source version. IM33. The image is four layers — dependencies,
application, and config on top of the previous lesson’s slimmed base — and totals 34.5 MB.
What a Tag Points To
A tag does not point directly to bytes. The chain has three levels: a tag names an image digest, an image digest addresses a manifest, and a manifest lists layer digests in order. These three levels are what makes the same name bring back the same set of layers.
IM34. The tagging scheme is this: every build gets an immutable version tag (v-1, v-2, …);
latest moves on every build, stable moves only to builds where the nightly batch job runs
clean, branch-ready moves on every fourth build. IM35. Three-quarters of builds write the
source version field into the config object; builds done by hand outside the pipeline do not write
it.
// measurement-network/image.mjs — build history and tags in a layered image registry (model) import { createHash } from "node:crypto"; export const generator = (seed) => () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; export const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 12); // [name, bytes, probability of changing independent of source]. The application layer depends on the source. export const LAYER = [["base", 10.5e6, 0.08], ["dependencies", 16.0e6, 0.25], ["application", 7.2e6, null], ["config", 0.8e6, 0.30]]; export const history = (n = 20, seed = 20260604) => { const r = generator(seed); const version = [1, 1, 1, 1]; const list = []; let source = 1, day = 0; for (let no = 1; no <= n; no++) { day += 1 + Math.round(r() * 8); const newSource = r() > 0.25; // one in four is a rebuild from the same source if (newSource) { source += 1; version[2] += 1; } LAYER.forEach(([, , p], k) => { if (p !== null && r() < p) version[k] += 1; }); const layers = LAYER.map(([name, bytes], k) => ({ name, bytes, digest: digest(`${name}@${version[k]}`) })); list.push({ no, day, source: `s-${String(source).padStart(3, "0")}`, layers, image: digest("manifest:" + layers.map((k) => k.digest).join("|")), sourceWritten: r() > 0.25, // builds run by hand outside the pipeline do not write this field cleanRun: r() > 0.40, // did the nightly batch job run clean }); } return list; }; // Tag ledger: every build gets an immutable version tag, movable tags are conditional. export const tag = (H) => { const ledger = new Map(); // tag -> image digest for (const d of H) { ledger.set(`v-${d.no}`, d.image); ledger.set("latest", d.image); if (d.cleanRun) ledger.set("stable", d.image); if (d.no % 4 === 0) ledger.set("branch-ready", d.image); } return ledger; }; if (import.meta.url === `file://${process.argv[1]}`) { const H = history(); const seen = new Set(); console.log("no".padEnd(4) + "day".padEnd(5) + "source".padEnd(8) + "image digest".padEnd(15) + "status"); for (const d of H) { const repeat = seen.has(d.image); seen.add(d.image); console.log(String(d.no).padEnd(4) + String(d.day).padEnd(5) + d.source.padEnd(8) + d.image.padEnd(15) + (repeat ? "repeat" : "new")); } const ledger = tag(H); const fanIn = new Map(); for (const [t, image] of ledger) fanIn.set(image, [...(fanIn.get(image) ?? []), t]); const top = [...fanIn.entries()].sort((a, b) => b[1].length - a[1].length); console.log(`\n${H.length} builds produced ${seen.size} distinct image digests; ` + `${H.length - seen.size} builds reproduced a previous digest`); console.log(`${ledger.size} tags point to ${fanIn.size} distinct digests`); for (const [image, tags] of top.slice(0, 3)) { console.log(` ${image}: ${tags.length} tags -> ${tags.join(", ")}`); } console.log(`builds with the source field written: ${H.filter((d) => d.sourceWritten).length}/${H.length}`); }
no day source image digest status 1 7 s-002 2747388d03c9 new 2 11 s-002 acff51e2a712 new 3 18 s-002 acff51e2a712 repeat 4 25 s-003 4bd4e48c8ee9 new 5 29 s-004 81139748f001 new 6 33 s-005 cd88604b9280 new 7 41 s-006 91eb7d898a53 new 8 45 s-007 cd261660f128 new 9 54 s-008 4d5ddd536da3 new 10 60 s-009 ac698a72632e new 11 66 s-010 13c70bced338 new 12 73 s-010 13c70bced338 repeat 13 78 s-010 3027786a6a54 new 14 83 s-011 a9e045075303 new 15 88 s-011 081c32254a6d new 16 91 s-012 b3d1ed4a0430 new 17 95 s-013 385c204d516c new 18 101 s-014 5ef86296e112 new 19 105 s-015 a246d27c5542 new 20 111 s-015 a246d27c5542 repeat 20 builds produced 17 distinct image digests; 3 builds reproduced a previous digest 23 tags point to 17 distinct digests a246d27c5542: 5 tags -> latest, stable, branch-ready, v-19, v-20 acff51e2a712: 2 tags -> v-2, v-3 13c70bced338: 2 tags -> v-11, v-12 builds with the source field written: 14/20
Twenty builds produced seventeen distinct image digests. Three builds reproduced a previous build’s digest: because neither the source nor the layers changed, the manifest came out the same. This is visible proof of the deterministic build discipline from the previous lesson — under a build that stamps, twenty builds would give twenty distinct digests.
The second number runs the other way: 23 tags point to 17 digests. One digest has five names —
latest, stable, branch-ready, v-19, v-20. Two of them are immutable, three will point to
a different digest on the next build. Being able to look at the same object through five names is
not a flaw, it is the registry’s design; the flaw is whether which of these names gets written
to the deployment record determines the object.
What Changes When a Tag Moves
When a tag moves, a new image is addressed, but the image is not entirely new. A manifest is a layer list, and two manifests can share some of the list’s rows.
// measurement-network/move.mjs — how many layers really change when a tag moves (model) import { history, LAYER } from "./image.mjs"; const G = history(); const FULL = LAYER.reduce((t, [, b]) => t + b, 0); const mb = (b) => (b / 1e6).toFixed(1) + " MB"; // Builds each tag targets, in order. const targets = { latest: G, stable: G.filter((d) => d.cleanRun), "branch-ready": G.filter((d) => d.no % 4 === 0), }; console.log("tag".padEnd(15) + "moves".padEnd(8) + "0 layers".padEnd(10) + "1 layer".padEnd(10) + "2+ layers".padEnd(11) + "new bytes".padEnd(11) + "full bytes"); const detail = []; for (const [tag, sequence] of Object.entries(targets)) { const distribution = [0, 0, 0]; let newTotal = 0; for (let i = 1; i < sequence.length; i++) { const old = sequence[i - 1].layers, updated = sequence[i].layers; const changed = updated.filter((k, j) => k.digest !== old[j].digest); const bytes = changed.reduce((t, k) => t + k.bytes, 0); newTotal += bytes; distribution[Math.min(changed.length, 2)] += 1; if (tag === "stable" && changed.length !== 1) { // 1 layer is the ordinary case; it is in the summary detail.push(`s-${sequence[i - 1].no} -> s-${sequence[i].no}`.padEnd(16) + `${changed.length}/4`.padEnd(8) + (changed.map((k) => k.name).join(", ") || "-").padEnd(39) + mb(bytes)); } } const moves = sequence.length - 1; console.log(tag.padEnd(15) + String(moves).padEnd(8) + String(distribution[0]).padEnd(10) + String(distribution[1]).padEnd(10) + String(distribution[2]).padEnd(11) + mb(newTotal).padEnd(11) + mb(moves * FULL)); } console.log('\n"stable" moves with a layer count other than one:'); console.log("move".padEnd(16) + "layer".padEnd(8) + "changed".padEnd(39) + "new bytes"); for (const s of detail) console.log(s); // Per layer: how many of the 19 "latest" moves changed it? const count = {}; for (let i = 1; i < G.length; i++) G[i].layers.forEach((k, j) => { if (k.digest !== G[i - 1].layers[j].digest) count[k.name] = (count[k.name] ?? 0) + 1; }); console.log("\nchange per layer (across 19 moves): " + LAYER.map(([name]) => `${name} ${count[name] ?? 0}`).join(", ")); console.log(`full image ${mb(FULL)}; immutable version tags ${G.length}, movable tags ` + `${Object.keys(targets).length}`);
tag moves 0 layers 1 layer 2+ layers new bytes full bytes latest 19 3 10 6 141.7 MB 655.5 MB stable 14 1 8 5 120.1 MB 483.0 MB branch-ready 4 0 0 4 58.5 MB 138.0 MB "stable" moves with a layer count other than one: move layer changed new bytes s-2 -> s-3 0/4 - 0.0 MB s-3 -> s-5 3/4 base, application, config 18.5 MB s-6 -> s-8 2/4 application, config 8.0 MB s-8 -> s-10 3/4 dependencies, application, config 24.0 MB s-16 -> s-17 2/4 application, config 8.0 MB s-18 -> s-20 2/4 application, config 8.0 MB change per layer (across 19 moves): base 1, dependencies 2, application 13, config 7 full image 34.5 MB; immutable version tags 20, movable tags 3
The latest tag has moved nineteen times. Of these nineteen moves, ten changed only one layer,
three changed no layer at all. A zero-layer move is a case where the tag moved but the content
stayed the same: the name points to a new digest, the digest addresses the same manifest, and
there are zero bytes to pull. From the outside, though, it looks like “a new image was published,”
and a deployment can get triggered.
The bytes columns give the moves’ real weight: across nineteen moves, the total that changed is
141.7 MB, while the same nineteen images’ total size is 655.5 MB. Layer sharing removes 78 percent
of the moves’ load. The branch-ready tag sits at the opposite end — in all four of its four
moves, two or more layers changed, because four builds pile up between them. The less often a
tag moves, the more layers change per move.
The last row shows where the change comes from: across nineteen moves, the application layer changed 13 times, config 7, dependencies 2, the base only 1. This looks the same direction as the previous lesson’s update measurement — lower layers change rarely, upper layers change on almost every build.
Back From Digest to Source
Everything measured so far ran forward: from source to build, from build to digest, from digest to tag. The question asked during an incident runs backward — which source did this object running in production come from?
IM36. The 24 deployments are fictional; the seed 20260707 is visible, and the kind of
requested name — portable tag, immutable version tag, or direct digest — comes from the generator.
The chain has two links: from the deployment record to the image digest, from the image digest to
the source version.
// measurement-network/traceability.mjs — can a deployment's source be traced back from its digest (model) import { history, generator } from "./image.mjs"; const G = history(); const r = generator(20260707); const FIELD_BYTES = 96; // source version + build id + timestamp (fictional) const FULL = 34.5e6; // 24 deployments: day and requested name. The name is either a portable tag, a version tag, or a direct digest. const deployments = []; let day = 3; while (deployments.length < 24) { day += 1 + Math.round(r() * 7); const p = r(); deployments.push({ day, kind: p < 0.60 ? "portable" : p < 0.85 ? "version" : "digest" }); } // The tag ledger advances by day: what resolves at deployment time is that moment's target. const ledger = new Map(); let pos = 0; for (const d of deployments) { while (pos < G.length && G[pos].day <= d.day) { const b = G[pos++]; ledger.set("latest", b); ledger.set(`s-${b.no}`, b); if (b.cleanRun) ledger.set("stable", b); } const versionTags = [...ledger.keys()].filter((k) => k.startsWith("s-")); d.name = d.kind === "portable" ? (r() < 0.6 ? "stable" : "latest") : d.kind === "version" ? versionTags[versionTags.length - 1] : "digest:direct"; d.target = d.kind === "digest" ? ledger.get("latest") : ledger.get(d.name); } // An immutable version tag determines the digest as long as the ledger refuses to move it; a // portable tag does not. Three disciplines: (1) the record writes only the requested name, (2) it // also writes the resolved digest, (3) every build additionally writes the source field. const digestKnown = (d) => d.kind === "digest" || d.kind === "version"; const traceable = (allDigest, allSource) => deployments.filter((d) => (allDigest || digestKnown(d)) && (allSource || d.target.sourceWritten)).length; const N = deployments.length; console.log("discipline".padEnd(34) + "digest known".padEnd(16) + "source readable"); console.log("name only in record".padEnd(34) + `${deployments.filter(digestKnown).length}/${N}`.padEnd(16) + `${traceable(false, false)}/${N}`); console.log("record also has digest".padEnd(34) + `${N}/${N}`.padEnd(16) + `${traceable(true, false)}/${N}`); console.log("every build also writes source".padEnd(34) + `${N}/${N}`.padEnd(16) + `${traceable(true, true)}/${N}`); // The other direction: how many distinct image digests did the same source version produce? const fromSource = new Map(); for (const b of G) fromSource.set(b.source, new Set([...(fromSource.get(b.source) ?? []), b.image])); const multi = [...fromSource.entries()].filter(([, s]) => s.size > 1); console.log(`\ndeployment kind: portable ${deployments.filter((d) => d.kind === "portable").length}, ` + `version tag ${deployments.filter((d) => d.kind === "version").length}, ` + `direct digest ${deployments.filter((d) => d.kind === "digest").length}`); console.log(`${fromSource.size} source versions, ${multi.length} of them produced more than one image digest: ` + multi.map(([k, s]) => `${k} -> ${s.size}`).join(", ")); console.log(`traceability's cost: ${FIELD_BYTES} bytes per image, 1 in ` + `${Math.round(FULL / FIELD_BYTES)} of the full image; ${N} x 1 field in the deployment record`);
discipline digest known source readable name only in record 14/24 9/24 record also has digest 24/24 17/24 every build also writes source 24/24 24/24 deployment kind: portable 10, version tag 10, direct digest 4 14 source versions, 3 of them produced more than one image digest: s-002 -> 2, s-010 -> 2, s-011 -> 2 traceability's cost: 96 bytes per image, 1 in 359375 of the full image; 24 x 1 field in the deployment record
The three rows form a ladder. Under the current setup, nine of twenty-four deployments’ sources can be read. The loss is in two places: ten deployments were made with a portable name, so which digest they resolved to is not in the record, and part of the deployments with a known digest also targeted a build whose source field was not written. Also writing the resolved digest to the record raises the rate to 17/24; writing the source field on every build raises it to 24/24.
The ten deployments made with an immutable version tag determining the digest rests on one
assumption: the ledger refuses to move the v-N tag. This refusal is not a feature, it is a
rule the registry enforces — the counterpart here of the immutability check measured in M22/K01.
If the rule were not enforced, the 14 in the first row would drop too, and the ladder’s first step
would fall to four.
The second result runs the other way. Three of fourteen source versions produced more than one image digest: the dependency or config layer changed without the source changing. Tracing back leads all the way to the source; it does not lead forward from the source to a single object.
This isolation’s cost is small in number. The source field is 96 bytes per image — 1 in 359,375 of the full image — and a single field added to the deployment record. The gain measured is a rate that climbs from 9/24 to 24/24. Where isolation is pierced is the tag ledger itself: the ledger is not inside the image, it is inside the registry. The image is a portable output; its name is not — an image copied to another registry does not carry its tags along, only its digest.
Summary
- A tag is a three-level chain: a tag names an image digest, an image digest names a manifest, and a manifest names layer digests. A tag points to a list, not to bytes.
- 20 builds produced 17 distinct image digests; 3 builds reproduced a previous digest. This is visible proof of deterministic building — under stamped building, the count would be 20.
- 23 tags point to 17 digests; one digest has five names. The flaw is not having many names — it is whether the name written to the record determines the object.
- 10 of the
latesttag’s 19 moves changed a single layer, 3 changed none. The changed total is 141.7 MB, the same images’ total is 655.5 MB: sharing removes 78 percent of the load. The less often a tag moves, the more layers change per move. - The rate a deployment can be traced back to its source depends on record discipline: 9/24; 17/24 once the resolved digest is also written; 24/24 once the source field is written on every build. The cost is 96 bytes per image and a single field in the record.
Next Step
This lesson’s measurement silently assumed one thing: that the counterpart of every row in the manifest sits somewhere. That place was never measured. Against the 141.7 MB that changed across nineteen moves, there are 513.8 MB that did not, and how many copies of those bytes exist depends on whether the layers are shared.
The next lesson builds that place and counts it: when multiple images reference the same layer, how much does the store really grow; when an image is deleted, how many layers can really be deleted; what happens when a retention rule runs into layer references; and how much does downloaded traffic drop when a layer already present locally gets skipped?
To keep your progress and take notes, Log in
My notes
Log in to take notes.