Anyone who uses Docker regularly knows how to write a Dockerfile, run `docker build`, and move on. Few people stop to actually open an image and look at what's inside. That's a shame, because understanding this structure changes how you write Dockerfiles, optimize builds, and debug those weird size or cache issues that don't make sense at first glance. In this post we're going to pop the hood for real — literally extract an image and look at its files — to understand layers, metadata, the OCI format, and why the order of your instructions in a Dockerfile matters just as much as their content. ## <br>An image isn't a single file The first thing that tends to surprise people is that a Docker image isn't a monolithic blob. It's a collection of layers plus a handful of metadata files describing how those layers fit together. Every Dockerfile instruction that modifies the filesystem — `RUN`, `COPY`, `ADD` — creates a new layer. Instructions like `ENV` or `LABEL` don't produce a filesystem layer; they just touch the metadata. This is easy to confirm in practice. Run: ```bash docker history my-image ``` and you'll see a list of lines, each one corresponding to a Dockerfile instruction and the size it added. A heavy `RUN apt-get install` will show up as a fat layer; an `ENV` will show up at 0B. ## <br>Union filesystem: how layers stack up Layers are stacked using a union filesystem — in practice, almost always OverlayFS on modern Linux (older Docker versions used AUFS, and alternative drivers like btrfs or devicemapper exist, but overlay2 is the default today). The core idea is simple: each layer holds only the diff relative to the one before it — files created, changed, or removed — and the union filesystem mounts these layers in sequence to produce a single unified view of the filesystem. This has an interesting practical consequence. If you create a 500MB file in one layer and then delete it in a later layer, that space isn't freed — Docker just writes a "whiteout file" marking that the file shouldn't show up in the final view anymore. The final image still carries those 500MB, just hidden. This is one of the classic reasons an image ends up huge even when the Dockerfile looks clean, and it's why the pattern of combining install and cleanup in the same `RUN` exists — doing both in the same layer keeps the leftover junk from getting trapped in an earlier one. ## <br>Image vs container: the writable layer The image itself is read-only. When you run `docker run`, Docker takes the image's set of layers and adds a writable layer on top, exclusive to that container. Every write the process makes inside the container — logs, temp files, config changes — goes into that extra layer, never into the original image's layers. It's the same copy-on-write principle you'll find in filesystem snapshots: if a process inside the container tries to modify a file that came from a read-only layer, the system first copies that file into the writable layer and only then applies the change. The original image layer stays untouched, which is exactly what lets you run ten containers from the same image without one interfering with another and without duplicating the shared data on disk. ## <br>Opening an image for real The theory gets a lot more concrete once you extract an image manually. You can do this without even needing a running daemon doing anything special: ```bash docker save my-image:latest -o image.tar mkdir extracted && tar -xf image.tar -C extracted ls extracted ``` Inside that folder you'll find something like this: a `manifest.json`, a config file with a long hash for a name, and a folder for each layer containing a `layer.tar` with that layer's actual content. If the image follows the more recent OCI format, the structure is similar but with slightly different names (`index.json`, blobs organized by hashing algorithm). The `manifest.json` is basically the index: it lists which layers make up the image, in what order, and points to the config file. It's worth opening that config with `cat` or `jq` — that's where the entrypoint, default command, environment variables, working directory, and, interestingly, the full history of Dockerfile commands live, including the ones that didn't produce a layer at all. That history is exactly what `docker history` is reading when you run the command above. ## <br>The OCI standard It's worth mentioning that this format isn't Docker-proprietary anymore. Since 2015 there's been the Open Container Initiative, which standardized the image spec (OCI Image Spec) and the runtime spec (OCI Runtime Spec). This means an image built with `docker build` can be run by Podman, containerd, or any compatible runtime, and vice versa. In practice, when you run `docker manifest inspect` on a published image, what you're seeing is exactly this structure — manifest plus config plus layers referenced by digest, a SHA256 hash of each one's content. That digest detail isn't just a nice technical footnote — it's what enables deduplication across images. If two different images share the same base layer (say, the same Ubuntu version), the registry and your local disk store that layer once, referenced by both images. It's also what makes incremental pulls possible — when you pull a new image, Docker checks which layer digests you already have locally and only downloads what's missing. ## <br>Base images and the size trade-off Picking the right base image is where all this layer theory turns into a daily practical decision. `FROM scratch` gives you a completely empty image — no shell, no libc, nothing — typically used for statically compiled binaries in Go or Rust. At the other extreme you have full images like `ubuntu` or `debian`, with an entire operating system underneath. Alpine images sit in between, using musl libc instead of glibc — which makes the image much smaller, but sometimes causes subtle compatibility issues with packages expecting glibc, something that's caught plenty of people off guard in production. Distroless images, popularized by Google, try a middle ground: they include only the runtime you actually need (say, the JVM or Node's runtime) with no shell, no package manager, none of the system tooling that normally sits around in a full image. That cuts down attack surface and size, but it also makes debugging harder — you can't just drop into the container with `docker exec ... sh` because there's no shell in there. ## <br>Build cache and why order matters Back to the Dockerfile: Docker decides whether to reuse a cached layer or rebuild by comparing the current instruction against what already ran before, in the same position, with the same context. For `COPY` and `ADD` instructions, it also looks at the content of the copied files, not just the command itself. That means putting a `COPY . .` early in the Dockerfile, before installing dependencies, invalidates the cache for everything that follows every time any project file changes — even if it's just a comment edited in the README. The most common pattern to avoid this is copying only the dependency file first (`package.json`, `requirements.txt`, `go.mod`), installing dependencies, and only then copying the rest of the code. That way, the dependency-install cache only invalidates when dependencies actually change, not on every commit. ## <br>Wrapping up: commands to explore your own image If you want to reproduce all of this on one of your own images, these commands are the starting point: ```bash docker history --no-trunc my-image docker inspect my-image docker save my-image -o image.tar docker manifest inspect my-image ``` Running these four against an image you already use day to day tends to reveal things that fly under the radar — giant layers nobody remembers the reason for, duplicated layers across images from the same project, or old config left over from past builds. It's a short exercise, but it changes how you look at a Dockerfile afterward.

