Virtualization · Containers
From Image Manifest to Mounted Root Filesystem
How a container image is assembled from a manifest, a config blob and ordered layer tarballs, and what the runtime does between pulling and exec.
A container image is not a disk image. It is a small graph of JSON documents and tar archives, plus a set of rules for stacking those archives into something a process can use as /. The gap between “downloaded files” and “running process” is where most of the interesting machinery lives: digest verification, layer unpacking, mount construction, and the final switch of the process’s root directory.
Three blobs, one graph
A typical image is described by a manifest, which is itself a JSON document fetched by tag from a registry and then usually referred to by digest. The manifest contains a reference to a config blob and an ordered array of layer descriptors. Each descriptor carries a media type, a digest, and a size in bytes.
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:9f2a...c41e",
"size": 5821
},
"layers": [
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:1b7c...0d55", "size": 2934881 },
{ "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:77aa...b309", "size": 44119 }
]
}
The config is where the runtime finds the things that are not files: the default environment, the entrypoint and command vectors, the working directory, the user, exposed ports, volume declarations, and a history array recording how the image was built. It also holds rootfs.diff_ids, a list of digests computed over the uncompressed layer content. That second digest list matters because the digest in the manifest covers the bytes as stored (possibly gzip-compressed), while diff_id covers the tar stream itself. A layer that is recompressed with a different gzip level has the same diff_id and a different blob digest. Anything that keys cache or deduplication off layer identity has to decide which of the two it means.
The split exists so that a registry can serve compressed blobs without the client having to trust the compression, and so that two images built from identical filesystem changes can be recognised as sharing content even if their compression settings differ.
Pulling is verification, not download
Every blob is fetched by digest, and the digest is checked before the bytes are admitted to the local content store. The store is a directory keyed by algorithm and hex digest, typically sha256/<first bytes>/<full hex>. Because the key is derived from the content, a blob is immutable once written: a different payload under the same name is a verification failure, not an update.
The manifest’s own digest is what a tag resolves to. A tag such as latest is a mutable pointer in the registry’s index; resolving it yields a manifest digest, and every subsequent fetch is pinned to that digest. This is why pulling by tag and then running later can produce a different filesystem: the tag moved, the digest did not.
Sizes in descriptors are advisory for scheduling but are also checked. A client that receives more or fewer bytes than declared treats the blob as corrupt. Layer sizes are frequently in the low megabytes for base images and in the kilobytes for a final layer that only adds a config file, because each layer is a full tar archive with its own headers and padding: tar pads every file to a 512-byte boundary and terminates the archive with two zero blocks.
Stacking layers: two strategies
Once the blobs are local, the runtime must turn an ordered list of tar archives into a root filesystem. There are two common strategies.
The first applies each layer in sequence onto a single directory, so that later layers overwrite earlier ones. This is what a build tool does when it commits a new layer, and it is also what some runtimes do at container creation. The cost is a full traversal of every layer at start time, proportional to the total uncompressed size of the image, and no sharing between containers except through filesystem-level snapshots.
The second unpacks each layer once into its own directory and composes them at mount time with a union filesystem. With overlayfs, the runtime passes the unpacked layers as lowerdir entries and an empty per-container directory as upperdir, with a workdir on the same filesystem as the upper directory. The kernel then presents a merged view where a name resolves to the highest-priority lower layer that contains it, and writes land in the upper directory. The unpack cost is paid once per layer per host; container creation becomes a mount() call.
/* die() and write_file() are local helpers: perror-and-exit, and a
formatted write to a file opened by path. */
/* Mount namespace already entered; make the merged root the process root. */
if (mount("overlay", "/mnt/root", "overlay", 0,
"lowerdir=/layers/3:/layers/2:/layers/1,"
"upperdir=/containers/abc/upper,"
"workdir=/containers/abc/work") < 0)
die("overlay mount");
mkdir("/mnt/root/oldroot", 0755);
if (syscall(SYS_pivot_root, "/mnt/root", "/mnt/root/oldroot") < 0)
die("pivot_root");
umount2("/oldroot", MNT_DETACH);
pivot_root has requirements that a naive implementation trips over: the new root must be a mount point, it must not be the current root, and the put_old directory must reside underneath it. chroot alone is insufficient because a process holding a directory descriptor outside the new root can escape it; pivot_root changes the mount namespace’s root so that the old tree is no longer reachable by path.
The lower layer count is bounded by the kernel and by practical mount-argument length. Deep images, where every build step adds a layer, hit those bounds and also make every lookup walk more directories. This is the mechanism behind advice to consolidate layers: the union is a linear scan over candidates, and a name present only in the bottom layer is found last.
Where the layer contents come from
Each layer archive contains only the filesystem changes relative to its parent, expressed as ordinary tar entries. Deletions cannot be represented by absence, so the format uses whiteout entries: a file named .wh.<name> marks the removal of <name>, and an opaque marker .wh..wh..opq inside a directory declares that everything inherited in that directory from lower layers is hidden. A runtime unpacking layers sequentially deletes the named path; a runtime using overlayfs converts these markers into character devices with the whiteout device number, which is how overlayfs itself records deletion in an upper directory.
Hard links inside a single layer are preserved as tar hard link entries, but hard links that cross layers cannot be: the earlier layer’s file is a different inode in a different directory. Copy-up (described in Copy-Up, Whiteouts and Rename in Overlay Roots) then breaks the link, and the two paths diverge on first write.
File ownership and mode come from tar headers, which is why unpacking must run with privileges sufficient to chown arbitrary user IDs, or must map them through a user namespace as described in Capability Sets and UID Mapping in Rootless Containers. Extended attributes, including the security labels some systems require, travel in PAX headers; a runtime that drops them produces a root filesystem that looks correct by name but fails policy checks at exec time.
The handoff to the runtime
After the root is mounted, the runtime writes an OCI runtime bundle: a config.json describing the process to start, the namespaces to create, the mounts to perform, the capabilities to retain, and the cgroup resources to apply. The lifecycle then forks a child that unshares its namespaces, sets the root, drops privileges, and calls execve. Everything before that point is setup performed by a privileged parent; everything after is the workload.
That boundary explains a common surprise. Environment variables, the working directory, and the command are not properties of the filesystem; they are fields in the config blob and in the runtime spec, and they can be overridden at creation time without touching a single layer. Conversely, a file added to a running container’s upper directory is not in any image, and disappears when the container is removed. The image is an input to the assembly, not the assembled result.
Limits of the model
The manifest-layer model assumes layers are independent tar streams that can be applied in order. It has no notion of a file being modified in place across layers except as a full replacement, so a 1 GB file changed once in a late layer exists twice in the image, once in each layer’s blob. Sparse files are stored densely in tar unless the archive uses GNU sparse extensions, and runtimes differ in whether they preserve holes. Images therefore tend to be much larger than the apparent size of their final root filesystem, and the difference is entirely duplication across layers rather than anything in the merged view.
The composition also inherits the union filesystem’s semantics rather than a block device’s. There is no per-file block allocation to share, no snapshot at the block level, and no way to atomically replace the whole tree without replacing the mount. Systems that need those properties move to a different snapshotter, where layers are stored as filesystem snapshots and the merged root is a real filesystem rather than an overlay. The manifest format is unchanged; only the thing that materialises it is.
References
- OCI Image Format Specification — Open Container Initiative
- OCI Runtime Specification — Open Container Initiative
- OverlayFS — Linux kernel documentation
- pivot_root(2) — Linux man-pages project