---
title: 'Container Standards'
source: 'https://academia.sh/en/courses/containers/container-standards'
course: Containers
language: en
updated: '2026-08-23T16:55:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.
