Skip to content
academia.sh

Lesson 04 / 21

Container Standards

The promise carried by an image format specification is split into three parts and a verifier that checks the digest chain is written; how far portability actually goes is counted: what stays the same and what changes between two runtimes that comply with the same specification.

Contents

The previous lesson measured the three mechanisms that build isolation and showed that all three belong to a single machine’s kernel. The measured layer stack, though, does not belong to a machine: it is a stack made of directories, and it can be copied and moved. Being portable is not a guarantee that it will open the same way wherever it is moved. The order the layers stack in, the configuration it runs with, and that a layer was not corrupted along the way must be written down somewhere.

That writing’s type is a specification. This lesson names no specification, body, or product; specifications are referred to by their common structure, because what is measured is not a document’s name but the promise that document carries. CC20. An image format specification defines three things: a config object, a layer list, and a content-addressed digest chain. A runtime specification, in turn, reads these three and defines the rules for starting a process.

The three parts of the promise each do a separate job. The config object says what will run: entry point, working directory, user, environment variables. The layer list says what parts the output is made of and how many bytes each part is. The digest chain, in turn, ties the pieces’ identity and order together; thanks to the chain, whether a layer has changed, and if so from which point in the stack the output becomes a different output, can be computed.

The boundary between the two specification types is drawn right here too. The image format specification defines the output at rest: which bytes, in which order, under which identity. The runtime specification, in turn, defines the running state: which steps a runtime reading this definition will take, in which order. Where one ends is where the other begins, and the differences measured in this lesson’s second section sit exactly on top of that boundary. A specification’s promise can be read without knowing its name, because the promise always has the same shape: what it binds, what it leaves to interpretation.

The fictional regional measurement network’s output is defined by these three parts. The verifier below checks that definition.

Checking the Chain

CC21. The image definition is fictional; the digests are really computed with node:crypto and truncated to the first twelve hexadecimal digits. CC22. The chain rule is single: z(0) = own(0) and z(n) = digest(z(n-1) + “ “ + own(n)). CC23. The layers’ content sizes are the values the previous lessons measured.

// measurement-network/verifier.mjs — a verifier that checks an image format's digest chain
// The image definition is FICTIONAL; the digests are really computed with node:crypto.
import { createHash } from "node:crypto";

const digest = (v) => "s256:" + createHash("sha256").update(v).digest("hex").slice(0, 12);

// Layer contents (fictional): each layer is represented by a block of bytes.
const layerContent = [
  ["base-root", Buffer.alloc(7_600_000, 48)],
  ["resolver", Buffer.alloc(44_800_000, 49)],
  ["rule-package", Buffer.alloc(31_200_000, 50)],
  ["app", Buffer.alloc(50_787, 51)],
];

// The promise a specification carries has three parts: config object, layer list,
// content-addressed digest chain. Chain: z(0)=own(0), z(n)=digest(z(n-1)+" "+own(n)).
const chain = (owns) =>
  owns.reduce((z, f, i) => [...z, i === 0 ? f : digest(z[i - 1] + " " + f)], []);

function imageDefinition(contents) {
  const layers = contents.map(([name, block]) => ({ name, size: block.length, own: digest(block) }));
  const chainValues = chain(layers.map((k) => k.own));
  const config = { entryPoint: "nightly-job", workingDir: "/app",
    user: "measure", chain: chainValues };
  return { config, configDigest: digest(JSON.stringify(config)), layers };
}

// Verifier: every layer's digest is recomputed, then the chain is rebuilt.
function verify(definition, contents) {
  const broken = [];
  definition.layers.forEach((k, i) => { if (digest(contents[i][1]) !== k.own) broken.push(i); });
  const expected = chain(definition.layers.map((k) => k.own));
  const chainBreak = expected.findIndex((z, i) => z !== definition.config.chain[i]);
  return { broken, chainBreak, brokenCount: chainBreak < 0 ? 0 : expected.length - chainBreak };
}

const DEFINITION = imageDefinition(layerContent);
console.log("layer list (content-addressed)");
DEFINITION.layers.forEach((k, i) => console.log("  " + String(i).padEnd(3) + k.name.padEnd(14) +
  String(k.size).padStart(9) + " bytes  own " + k.own + "  chain " + DEFINITION.config.chain[i]));
console.log("config object digest: " + DEFINITION.configDigest);
console.log("sound definition: " + JSON.stringify(verify(DEFINITION, layerContent)));

// A single byte is changed: where the bond breaks and how many links are affected.
const brokenContent = layerContent.map(([a, b], i) => i === 1
  ? [a, Buffer.concat([b.subarray(0, 5), Buffer.from("X"), b.subarray(6)])] : [a, b]);
const result = verify(DEFINITION, brokenContent);
console.log("\n1 byte changed in layer 1 (one of 44800000 bytes)");
console.log("  layer whose digest does not hold: " +
  result.broken.map((i) => `${i} (${DEFINITION.layers[i].name})`).join(", "));
console.log("  new own digest: " + digest(brokenContent[1][1]) + ", in definition: " + DEFINITION.layers[1].own);

// If the broken layer is republished together with the definition, where does the chain break?
const newDefinition = imageDefinition(brokenContent);
const brokenAt = DEFINITION.config.chain
  .map((z, i) => z === newDefinition.config.chain[i]).indexOf(false);
console.log("  chain breaks at link " + brokenAt + "; " +
  (DEFINITION.config.chain.length - brokenAt) + "/" + DEFINITION.config.chain.length +
  " links change, the " + brokenAt + " links below stay exactly the same");
console.log("  the config digest changes too: " + newDefinition.configDigest);
console.log("  bytes that must move again: " +
  brokenContent.slice(brokenAt).reduce((t, [, b]) => t + b.length, 0) + "/" +
  layerContent.reduce((t, [, b]) => t + b.length, 0));
layer list (content-addressed)
  0  base-root       7600000 bytes  own s256:1048de0c7a68  chain s256:1048de0c7a68
  1  resolver       44800000 bytes  own s256:b0c7e6c28bfa  chain s256:926ac9255c57
  2  rule-package   31200000 bytes  own s256:99a2f404c480  chain s256:08f1f6ea4359
  3  app               50787 bytes  own s256:3fbeb763e66c  chain s256:ebdca65649c3
config object digest: s256:d1f033f46596
sound definition: {"broken":[],"chainBreak":-1,"brokenCount":0}

1 byte changed in layer 1 (one of 44800000 bytes)
  layer whose digest does not hold: 1 (resolver)
  new own digest: s256:9b5c4ad8de20, in definition: s256:b0c7e6c28bfa
  chain breaks at link 1; 3/4 links change, the 1 links below stay exactly the same
  the config digest changes too: s256:169313bf9d63
  bytes that must move again: 76050787/83650787

The verifier finds no fault in the sound definition: the broken-layer list is empty, there is no chain break. Then a single byte of the 44,800,000-byte layer is changed, and that layer’s content digest no longer holds. This is the definition of content addressing — because the name is derived from the content itself, changing the content also changes the name, and the mismatch is caught by a single comparison. It is not necessary to know which byte the change is in, and the number of bytes searched does not change the result either: one byte changed in 44.8 million and a layer with half its bytes changed are reported the same way, with a single inequality. This is the one-sentence equivalent of naming a layer by its content rather than by its name.

The chain’s contribution is one step further. If the broken layer is republished together with the definition, the chain breaks at link 1: three of the four links change, one link below stays exactly the same. So a layer changing carries itself and everything above it to a new identity, and does not carry what is below it. This asymmetry determines the bytes that must move: of the 83,650,787-byte stack, 76,050,787 bytes must move again, and the 7,600,000-byte base stays where it is. This is why the chain also binds the order — if the layers’ positions were swapped, the same digests would produce a different chain, and the two outputs would not count as the same.

The config object’s digest changes too, because the chain is written inside it. This is where the three parts tie into a single identity: a single digest names an output, and that digest covers both what will run and what bytes it is made of.

Verification has a cost too, and this is the line item this lesson adds to the isolation budget: a full verification reads the entire stack — 83,650,787 bytes — and computes four content digests and three chain links. The cost grows linearly with the output’s size and is paid again on every transfer. What is bought with it is that not a single byte having changed becomes provable; a check that looks only at a layer’s name has no such proof.

What the chain does not bind must also be written down. The digest binds the layer’s content, not how that content was produced. There is no promise that building the same layer twice gives the same digest; the chain only says the bytes it holds have not changed, it does not say those bytes are reproducible. This distinction is concrete in the fictional network: the verifier catches a corrupted layer, but if the resolver layer, built twice on two separate machines, comes out with two different digests, it reports this not as corruption but as two separate outputs. The boundary between what the verifier says and what it does not say sits exactly here, and a “verified” line read without knowing that boundary promises more than it delivers.

How Far Does Portability Go

The digest chain checks that the bytes have been carried over. It does not check that the carried bytes will behave the same way. What the specification binds and what it leaves to interpretation must be counted separately.

CC24. Ten properties are split into two sets — five the specification binds, five it leaves to interpretation — and the split is a model. CC25. The portability ratio is computed over the property count; no weighting is applied.

// measurement-network/portability.mjs — two runtimes complying with the same specification are compared (model)
// Properties are split into two sets: what the specification BINDS and what it LEAVES TO INTERPRETATION.

const BOUND = ["root filesystem layout", "layer order and merge rule",
  "content digest algorithm", "entry point and working directory", "environment variable set"];
const LEFT_TO_INTERPRETATION = ["resource limit interpretation", "where log output is written",
  "default network mode", "user mapping", "stop signal and grace period"];

// Two runtimes; they are required to carry the same value in the bound properties.
const RUNTIME = {
  "runtime 1": {
    "root filesystem layout": "layer stack", "layer order and merge rule": "bottom to top",
    "content digest algorithm": "s256", "entry point and working directory": "nightly-job @ /app",
    "environment variable set": "6 variables written in the definition",
    "resource limit interpretation": "process is stopped when the limit is exceeded",
    "where log output is written": "file", "default network mode": "bridge",
    "user mapping": "one-to-one", "stop signal and grace period": "graceful, 10 s",
  },
  "runtime 2": {
    "root filesystem layout": "layer stack", "layer order and merge rule": "bottom to top",
    "content digest algorithm": "s256", "entry point and working directory": "nightly-job @ /app",
    "environment variable set": "6 variables written in the definition",
    "resource limit interpretation": "request is rejected when the limit is exceeded",
    "where log output is written": "file", "default network mode": "host network",
    "user mapping": "shifted", "stop signal and grace period": "graceful, 10 s",
  },
};

const [A, B] = Object.keys(RUNTIME);
const same = (o) => RUNTIME[A][o] === RUNTIME[B][o];
console.log("feature".padEnd(38) + "set".padEnd(10) + "across two runtimes");
for (const o of [...BOUND, ...LEFT_TO_INTERPRETATION])
  console.log(o.padEnd(38) + (BOUND.includes(o) ? "bound" : "open").padEnd(10) +
    (same(o) ? "same" : `DIFF  ${RUNTIME[A][o]} / ${RUNTIME[B][o]}`));

const changed = LEFT_TO_INTERPRETATION.filter((o) => !same(o));
console.log(`\nbound features ${BOUND.length}, left to interpretation ${LEFT_TO_INTERPRETATION.length}`);
console.log(`unchanged ${BOUND.length + (LEFT_TO_INTERPRETATION.length - changed.length)}/10, ` +
  `changed ${changed.length}/10: ${changed.join(", ")}`);
console.log(`portability: the output itself is 100% portable (the digest chain is checked), ` +
  `${(BOUND.length + LEFT_TO_INTERPRETATION.length - changed.length) * 10}% of the behavior is portable`);
console.log(`by the previous lessons' count: 6 differences were pulled out of the environment, ` +
  `${changed.length} new dimensions came back as runtime differences`);
feature                               set       across two runtimes
root filesystem layout                bound     same
layer order and merge rule            bound     same
content digest algorithm              bound     same
entry point and working directory     bound     same
environment variable set              bound     same
resource limit interpretation         open      DIFF  process is stopped when the limit is exceeded / request is rejected when the limit is exceeded
where log output is written           open      same
default network mode                  open      DIFF  bridge / host network
user mapping                          open      DIFF  one-to-one / shifted
stop signal and grace period          open      same

bound features 5, left to interpretation 5
unchanged 7/10, changed 3/10: resource limit interpretation, default network mode, user mapping
portability: the output itself is 100% portable (the digest chain is checked), 70% of the behavior is portable
by the previous lessons' count: 6 differences were pulled out of the environment, 3 new dimensions came back as runtime differences

In the five bound properties, the two runtimes carry the same value and are required to: root filesystem layout, layer order together with the merge rule, content digest algorithm, entry point together with working directory, environment variable set. These are the specification’s promise, and they are fixed in every compliant runtime. In the five properties left to interpretation, though, there is no binding force; in the model, three of the five come out different between the two runtimes.

Which three make up the difference matters. The resource limit interpretation is the exact decision measured in the previous lesson: on one runtime the process that exceeds the limit is stopped, on the other the request is rejected. For the fictional nightly job, this means the same output stopping midway in one place and taking the error and continuing in another. Network mode and user mapping are of the same kind — the output is carried over, the behavior is not.

The two properties that do carry the same value must also be read carefully. Where the log output is written and the stop signal are the same across both runtimes in the model; but both sit in the set left to interpretation, so their being the same is not a guarantee, it is a coincidence. An assumption built on these two — for instance, that the nightly job will wait ten seconds while stopping — can silently break on a third runtime, and the break only becomes visible at runtime. The distinction from the first lesson repeats itself exactly here: a declared difference is a matter for a decision, an undeclared difference is a surprise. All five properties left to interpretation are candidates for an undeclared difference, even while their values happen to match right now.

The way to close it also follows from this count. The five properties left to interpretation move into the bound set the moment they are written explicitly in the runtime’s configuration: the resource limit interpretation is chosen, the network mode is specified, the user mapping is declared. This does not change the specification; it is the side running the output filling in what the specification left blank. The cost is known too — a five-line declaration, and that declaration being verified separately for every runtime. As long as the declaration is not written, portability stays at 70%, and the remaining 30% must be re-measured on every transfer; the moment it is written, the measurement is made once and its result sits in the manifest.

The number is right here too. The output itself is 100% portable, because the digest chain checks that the bytes are the same. The behavior, though, is 70% portable. In this lesson, isolation is pierced in the specification itself: the digest chain binds bytes, it does not bind behavior, and every property it does not bind is a new difference dimension. The first lesson pulled 6 differences out of the environment; 3 dimensions come back as a runtime difference. The difference does not go to zero, it changes address: it moves from the environment manifest to the runtime configuration. This is also why the gain staying measurable depends on a new manifest — this time the runtime’s own manifest.

Summary

  • An image format specification defines three things: a config object, a layer list, and a content-addressed digest chain. The three bind into a single identity.
  • The verifier catches a single-byte change in a 44,800,000-byte layer from its content digest; this is the definition of content addressing.
  • The chain is asymmetric: when a layer changes, it and everything above it get a new identity, what is below does not. In the model, 3 of 4 links change and 76,050,787 of 83,650,787 bytes must move again.
  • Two runtimes complying with the same specification are identical in the five bound properties; three of the five properties left to interpretation differ. The output is 100% portable, the behavior 70%.
  • The specification binds bytes, not behavior: in exchange for the 6 differences pulled out of the environment, 3 dimensions come back as a runtime difference. The difference does not disappear, it changes manifest.

Next Step

This lesson wrote down the output’s identity and the boundary of its portability; all of it was about the output at rest. Once the output is actually run, though, something unmeasured shows up: the running container stops, but what does it leave behind? The previous lesson showed the writable layer growing through copy-up; a network name, a volume, and a process record are also held somewhere. The next lesson builds the lifecycle as a state machine, counts the resource held at every transition, and measures how much a stopped-but-uncleaned container accumulates over a period.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close