Dash0 Raises $110M Series B at $1B Valuation

  • 14 min read

How to View the Contents of a Docker Image

A Docker image is a stack of read-only layers, each one a tar archive of filesystem changes. To see what's actually in one (config, environment variables, files inside each layer, secrets that might have been accidentally baked in), you don't run the image, you crack it open.

This matters for security audits, debugging mystery image bloat, and verifying what a base image contains before you build on top of it. There are five practical ways to do it, ranging from a one-liner that takes a second to an interactive layer browser. Pick based on how deep you need to go.

Inspect image metadata with docker image inspect

Start with docker image inspect when you need the image's configuration: entrypoint, command, environment variables, exposed ports, labels, layer digests, and architecture. This is metadata only, not filesystem contents.

bash
1
docker image inspect nginx:alpine

That returns a hefty JSON blob. Filter it with --format to get just what you need. To see the environment variables baked into the image:

bash
12
docker image inspect nginx:alpine \
--format '{{json .Config.Env}}' | jq .
json
123456
[
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.27.5",
"PKG_RELEASE=1",
"DYNPKG_RELEASE=1"
]

Version numbers will differ depending on when you pull the image, since nginx:alpine tracks the mainline branch.

To list layer digests in order (oldest first):

bash
12
docker image inspect nginx:alpine \
--format '{{json .RootFS.Layers}}' | jq .
json
12345
[
"sha256:08000c18d16d...",
"sha256:9534c1fe0a4f...",
"sha256:2dc4d2c0d8c2..."
]

These digests are content-addressable: the same layer in two different images has the same hash. That's how Docker shares storage between images that use the same base, and it's useful for spotting common ancestry across your fleet. What this command won't show you is what files are inside each layer.

View build steps with docker history

docker history shows the layer-by-layer construction of the image as a reconstructed Dockerfile with sizes. It's the fastest way to spot the layer that ballooned your image.

bash
1
docker history nginx:alpine
text
123456789
IMAGE CREATED CREATED BY SIZE
9aa9b6c8f3e1 2 weeks ago CMD ["nginx" "-g" "daemon off;"] 0B
<missing> 2 weeks ago STOPSIGNAL SIGQUIT 0B
<missing> 2 weeks ago EXPOSE 80 0B
<missing> 2 weeks ago ENTRYPOINT ["/docker-entrypoint.sh"] 0B
<missing> 2 weeks ago COPY 30-tune-worker-processes.sh /docker-en… 4.62kB
<missing> 2 weeks ago RUN /bin/sh -c set -x && ... 47.8MB
<missing> 2 weeks ago ENV NGINX_VERSION=1.27.5 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:9d7c... in / 8.31MB

Read the rows top-to-bottom as newest-to-oldest. The <missing> entries aren't actually missing layers. Docker just doesn't have local IDs for intermediate layers it didn't build itself, and BuildKit moves layer history to the registry manifest anyway. More on that in the pitfalls below.

The "CREATED BY" column is truncated by default. To see the full commands (useful for spotting suspicious RUN curl ... | sh lines):

bash
1
docker history nginx:alpine --no-trunc

Combine with --format for a clean view of just the layers that have weight:

bash
12
docker history nginx:alpine \
--format "table {{.Size}}\t{{.CreatedBy}}" --no-trunc

This is also the right place to start when auditing a third-party image you didn't build. Each RUN and COPY is a chance for something unexpected to land in the filesystem.

Extract the flat filesystem with docker create and export

When you want to see the files, the cleanest approach is to create a container from the image without starting it, then export its filesystem. docker create builds the container's root filesystem on disk but doesn't run anything, so the export reflects exactly what your image would expose to a running process, with no runtime modifications mixed in.

bash
123
container_id=$(docker create nginx:alpine)
docker export "$container_id" | tar -xC ./nginx-fs
docker rm "$container_id"

That gives you the entire flattened filesystem in ./nginx-fs:

bash
1
ls ./nginx-fs
text
12
bin docker-entrypoint.d etc lib mnt proc run srv tmp var
boot docker-entrypoint.sh home media opt root sbin sys usr

Now you can grep for secrets, find large files, or diff against a known-good baseline:

bash
1
find ./nginx-fs -type f -size +1M -exec ls -lh {} \;

The catch: this is the merged filesystem, with all layers collapsed into one tree. You can't tell which file came from which layer. If that distinction matters, and for layer optimization it usually does, use docker save or dive instead.

One more thing about docker export: it captures the filesystem only, not the image config. ENTRYPOINT, ENV, and CMD are dropped. If you docker import the resulting tar to make a new image, you'll need to specify those again with --change.

Examine raw layers with docker save

docker save exports an image as a tar archive that preserves the layer structure. This is the format Docker itself uses to ship images between hosts, and it's the most faithful representation of what's stored on disk.

bash
1234
docker save nginx:alpine -o nginx-alpine.tar
mkdir nginx-extracted
tar -xf nginx-alpine.tar -C nginx-extracted/
ls nginx-extracted
text
1
blobs/ index.json manifest.json oci-layout repositories

That's the OCI image layout, which is what Docker v25 and later produces by default. index.json and oci-layout are the OCI entry points; manifest.json and repositories stick around for backward compatibility with tools that predate the OCI format. Layer tarballs live inside blobs/sha256/ as content-addressable files named by their digest. Older Docker versions (without containerd-snapshotter, on v24 or earlier) produce a different layout with one directory per layer hash containing a layer.tar, plus manifest.json and repositories at the top level.

bash
1
cat nginx-extracted/manifest.json | jq .

Either way, manifest.json is the simplest entry point. It lists the layer paths in order, so you can extract a single layer to look at its contents without writing format-specific code:

bash
12345
# Get the second layer's path from the manifest
layer=$(jq -r '.[0].Layers[1]' nginx-extracted/manifest.json)
mkdir layer-1
tar -xf "nginx-extracted/$layer" -C layer-1/
ls layer-1

This is where you'll see Docker's whiteout files (.wh.filename) that mark deletions from previous layers. Knowing they're there explains the first pitfall below.

Use docker save when you need to inspect layers programmatically, ship an image to an air-gapped environment, or feed the archive into a vulnerability scanner.

Browse layers interactively with dive

Dive is the tool I reach for whenever I need to understand an unfamiliar image. It's an open-source terminal UI that puts image layers in one pane and the file tree of the selected layer in another, with files color-coded by whether they were added, modified, or removed.

Install it on macOS:

bash
1
brew install dive

On Debian or Ubuntu:

bash
12345
DIVE_VERSION=$(curl -sL \
"https://api.github.com/repos/wagoodman/dive/releases/latest" \
| grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
curl -fOL "https://github.com/wagoodman/dive/releases/download/v${DIVE_VERSION}/dive_${DIVE_VERSION}_linux_amd64.deb"
sudo apt install ./dive_${DIVE_VERSION}_linux_amd64.deb

Or run it from a container without installing anything locally:

bash
123
docker run --rm -it \
-v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest nginx:alpine

Then point it at any image:

bash
1
dive nginx:alpine

The UI shows layers on the left, the file tree on the right, and an efficiency score at the bottom. That score is the percentage of bytes in the final image that aren't wasted by deletions or duplications in earlier layers. Dive's default fail threshold is 95%, and anything below that is worth investigating.

Dive also has a CI mode that fails a build when efficiency drops below a threshold:

bash
1
CI=true dive --lowestEfficiency=0.95 --highestUserWastedPercent=0.10 nginx:alpine

Drop that into a pipeline step and you'll catch image bloat regressions before they ship.

Common pitfalls

Deleted files don't actually leave the image. If a Dockerfile installs a secret, copies a key, or downloads a credential and then deletes it in the same or a later RUN step, the file is still in an earlier layer. The merged view from docker export shows it as gone, but docker save and dive surface it immediately. This is why any image that ever touched a secret should be considered compromised, even if the secret was "removed" in the next instruction. Rebuild from scratch with proper build-time secrets instead.

BuildKit images show <missing> in docker history. This is not a bug. BuildKit moves the CreatedBy history into the registry manifest, and the local Docker daemon can't reconstruct it for layers it didn't build. Use docker buildx imagetools inspect against the registry to get the full history for BuildKit-built images, but you need --format to actually surface the per-layer entries:

bash
12
docker buildx imagetools inspect nginx:alpine \
--format '{{json .Image}}' | jq '."linux/amd64".history'
json
1234567891011
[
{
"created": "2026-05-08T13:42:11.123456Z",
"created_by": "/bin/sh -c #(nop) ADD file:9d7c... in / "
},
{
"created": "2026-05-08T13:42:14.789012Z",
"created_by": "ENV NGINX_VERSION=1.27.5",
"empty_layer": true
}
]

Without --format, the bare command only returns a multi-platform manifest summary, not the per-layer history. The index .Image "linux/amd64" step is needed because nginx:alpine is a multi-arch image; for a single-platform image, .Image.history works directly.

Layer sizes from docker history don't add up to docker images size. Layers shared with other images on your host count toward on-disk image size only once, but docker history reports each layer's logical contribution. Use docker image inspect --format '{{.Size}}' for the actual stored size.

docker export flattens, docker save doesn't. If you import the result of docker export back as an image, you've collapsed all layers into one. That's sometimes useful for shrinking an image, but you throw away the build cache and the audit trail along with it. For exact image transfers, use docker save and docker load.

Final thoughts

Inspecting an image is a one-time investigation. The harder question is what those images are doing once they're deployed: which containers are restarting, which ones are leaking memory, and which version is actually running where. A clean image audit doesn't help if a stale tag rolls out three weeks later.

Dash0's infrastructure monitoring tracks container resource metrics alongside real-time logs and distributed traces, all OpenTelemetry-native, so container metadata flows through to every signal without a proprietary translation layer. If you're auditing images today, you'll likely want to monitor what they're doing in production tomorrow. Start a free trial to see your container metrics, logs, and traces in one view. No credit card required.