An image is just tarballs and a digest

COMMIT536605dHEAD → main
PUBLISHEDFeb 18, 20251y ago
READING14 min1,606 words
#containers#storage·by santiago toscanini
Container internals·Part 1 of 3

I spent years running docker pull before I ever asked what it downloads. The word "image" sounds like a single sealed thing, a disk snapshot you either have or don't. It isn't. An image is a handful of ordinary files: some tarballs holding a filesystem, one JSON describing how to run it, and a naming scheme that ties them together so tightly you can't fake a single byte.

If you read my git series, the naming scheme will feel familiar, because it's the same idea. Git names every object by the sha of its content. Container registries do exactly that with image parts. Once you see it, docker pull stops being a black box and turns into something you can read with curl and sha256sum. This post pulls a real image, busybox:1.38.0-glibc, and takes it apart. Everything below is bytes I actually downloaded, and every digit you see is recomputable in your browser.

01 · The name is the content

Start with the rule everything else rests on. A content address is a name computed from the bytes it names. You run the bytes through sha256, and the resulting 64 hex digits are the thing's identity. Change one byte and you get a different name. There is no way to hold a digest and matching bytes that don't hash to it, which is the whole point: the name is proof of the content.

Type into the box below and watch the digest recompute on every keystroke. Then load the real busybox config, the small JSON that tells the runtime what command to run, and watch its digest turn green when it matches what the registry served me:

That green check is not decoration. The registry handed me a blob and told me its name was sha256:e0e8b3c…. Your browser just hashed the same bytes and got the same name. If a CDN, a proxy, or an attacker had flipped a single byte in transit, the hashes wouldn't line up and every honest client would reject it. Registries lean on this so hard that they can serve blobs from untrusted mirrors: the digest is the trust, not the server.

Git calls these objects and stores them in .git/objects. Registries call them blobs and store them by digest. Different words, one idea, and it's content addressing both times.

02 · A layer is a tar stream

The filesystem inside an image isn't a disk image or a special format. It's a tar archive. When you write FROM debian and then RUN apt-get install ..., the changes that command made to the filesystem get packed into a tar, gzipped, and that tarball is a layer. That's it. A layer is a tar of the files that changed.

Which means you can read one with a plain tar parser, so I wrote a small one and ran it on real layer bytes. Below are two actual layers from an image I built on top of busybox. The parser walks the raw bytes in your browser: tar is just a sequence of 512-byte headers, each followed by that file's data padded to the next 512 boundary. Pick any entry to see its header dissected field by field, at the exact byte offsets the format defines:

Look at the typeflag field. 0 is a regular file, 5 is a directory, 1 is a hardlink, 2 is a symlink. And in the third layer there's an entry named data/.wh.a.txt with type 0 and zero bytes of content. That .wh. prefix is a whiteout, and it's how a layer deletes a file. We'll come back to it; first, notice what you can already do. You have the layer bytes, you have the parser, you can list every file a layer touches without Docker installed. There's no daemon in this figure, just tar.

The OCI image spec makes the tar rule official, and it's honest about the media types: application/vnd.oci.image.layer.v1.tar for a raw tar, +gzip when it's compressed. The registry served me the gzipped form to save bandwidth. Uncompress it and you get exactly the tar my parser reads.

03 · One binary wearing four hundred hats

While the parser was open I pointed it at the busybox base layer itself, and it taught me something about how small a real image can be. Busybox ships hundreds of commands, ls, cat, sh, awk, wget, the works. You'd expect hundreds of binaries. There's one.

The layer has 448 tar entries, but only 16 of them are real files. 410 are hardlinks: the same 1.18 MB binary appearing under 410 names. ls and cat and sh are the literal same inode; busybox looks at which name you called it by and behaves accordingly. The tar format records this directly with typeflag 1, so you saw it in the parser above. This figure is checkable end to end: pull the layer at the digest shown, untar it, count the entries, and you'll get the same numbers I did. Nothing here is illustrative.

04 · Stacking layers, and how deletes work

An image is usually more than one layer, and the layers are ordered. To build the container's filesystem you apply them in sequence, each tar unpacked over the result of the last. Later layers win. This is why docker pull shows you a list of layers downloading in parallel but applying in order.

Additions are easy: a later layer just carries new tar entries. Modifications are the same mechanism, since a file at a path that already exists gets overwritten by the later entry. The interesting case is deletion, because you can't put "absence" in a tar. That's what the whiteout from the parser solves. To delete data/a.txt, a layer includes an empty file named data/.wh.a.txt, and any tool assembling the image treats that marker as "remove a.txt from everything below me." The spec spells it out: the whiteout filename is .wh. plus the basename of the path to delete. There's even an opaque variant, .wh..wh..opq, that hides an entire directory's previous contents at once.

Step through the three layers below and watch the merged filesystem, the thing the container actually sees as /, take shape. The third step is the one to watch: greeting.txt gets rewritten, data/c.txt appears, and data/a.txt vanishes because layer 3 carried its whiteout:

The delete is worth sitting with. a.txt disappears from the merged view, but the base layer that introduced it is never modified. Layers are immutable and shared: the same base layer backs a thousand images, so nothing is allowed to edit it. The whiteout doesn't remove the file, it hides it. The file is still down there, named by its digest, shared with every other image built on the same base. When we get to the runtime in part three, you'll see the Linux kernel do this exact stacking with a filesystem called overlayfs, and use its own kind of whiteout to hide files the same way.

05 · The cache is content addressing, again

Everything so far has been about an image at rest. But the place content addressing does its most visible daily work is when you build one, and it's the same Merkle trick from the git series wearing work clothes.

When Docker builds an image, each instruction in the Dockerfile becomes a layer, and each layer gets a cache key computed from its parent layer plus its own inputs. Rebuild, and Docker walks down the Dockerfile reusing every layer whose key is unchanged, stopping at the first one that differs. Everything below a changed layer rebuilds; everything above it is free. This is why the order of your Dockerfile is a performance decision, not a style one.

The figure below is a real build I captured, then rebuilt twice. The Dockerfile copies a dependency list, runs an expensive step that only reads that list, then copies the application code last. Toggle which file you edited:

Watch the digest on the RUN layer. When you edit the application file, that layer's diff_id stays d2d88d5, byte for byte the one from the previous build: Docker doesn't re-run it, it reuses it, because nothing it depends on changed. When you edit the dependency file instead, the same layer gets a new digest and re-runs, and every layer below it rebuilds too. One byte higher up the stack cascades all the way down.

That cascade is exactly the git Merkle payoff from part one of the git series. There, editing a file rewrote its blob, which changed the tree that held it, which changed the commit, while an untouched directory like bak/ reused its old tree hash for free. A Docker layer is a tree, a build cache hit is a reused tree hash, and putting COPY package.json before COPY . . is you arranging the tree so the expensive subtree keeps its hash. Same structure, same payoff, different day.

06 · What ties it together

So an image is: some layer tarballs, each named by the digest of its bytes, plus a config JSON (also a blob, also named by digest) that lists which layers to stack and what command to run. One more file references all of them by digest and gives the image its shape. That file is the manifest, and getting it out of a registry is its own small adventure: a token dance, some content negotiation, and a "fat manifest" that picks the right build for your CPU.

That's part two. We'll pull this same busybox image over HTTP and read every request and response, the way part four of the git series read every byte of a clone. The digests you verified here are the same ones the registry will name over the wire, so you already know half the conversation.

$git log --oneline public/posts/container-images/
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini