Slimming images and layer hygiene

Find what makes an image big or leaky, and gate it in CI.

Advanced13 min · lesson 7 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (7 files, 1 KB): slimming.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/slimming.tar.gz && tar -xzf slimming.tar.gz, which creates ~/lab/slimming/. SHA-256: ced63648cc58f2ef18242c18f03bf34ec49b3c23f640789bd6907be724172f1d

docker history of a 207 MB image shows a 20.5kB layer for rm -rf /var/lib/apt/lists/*, right above a 21.5MB layer for apt-get update. The cleanup step added bytes instead of removing them, and the package lists still ship to every host that pulls the image. Big images are slow to pull, slow to scan and carry more to patch; leaky images carry files nobody meant to publish. Both problems come from the same few habits, and both can be measured and gated in CI. This lesson does that with docker history, dive, Trivy and a small size budget.

A short recap of the mechanics, which "Layers and the build cache" in Docker for beginners teaches in full: each RUN, COPY and ADD adds a read-only layer, and deleting a file in a later layer only records a whiteout that hides it. The bytes stay in the layer that added them. Use the main lab VM; the lesson files go in ~/lab/slimming.

Find the big layers

Dockerfile.bloat installs curl the way many Dockerfiles still do, one command per instruction:

Dockerfile.bloat
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker build -q --no-cache -f Dockerfile.bloat -t lab-slim:bloat .
sha256:3e3f42bbd5f5d4477a0302c486668fea7f394be48aa3baa37b5571349ecbc26f
$ docker history lab-slim:bloat
IMAGE CREATED CREATED BY SIZE COMMENT 3e3f42bbd5f5 9 seconds ago RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #… 20.5kB buildkit.dockerfile.v0 <missing> 11 seconds ago RUN /bin/sh -c apt-get install -y curl # bui… 22.7MB buildkit.dockerfile.v0 <missing> 22 seconds ago RUN /bin/sh -c apt-get update # buildkit 21.5MB buildkit.dockerfile.v0 <missing> 2 days ago # debian.sh --arch 'arm64' out/ 'trixie' '@1… 110MB debuerreotype 0.17

Read history bottom up. The Debian base is 110MB. apt-get update downloaded 21.5MB of package lists into its own layer, apt-get install added 22.7MB, and the rm layer is 20.5kB: the whiteout entries plus a little metadata. The lists are hidden in the final filesystem and still present in the image. --no-cache forces apt to run here; without it a rebuild would reuse the cached layers.

Dockerfile.lean does the same job in one instruction and skips recommended packages, adding back the one recommendation that matters for curl, the CA certificates:

Dockerfile.lean
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker build -q --no-cache -f Dockerfile.lean -t lab-slim:lean . docker image ls lab-slim
sha256:69f5fd1660296e27f43b894adb4f9ce310d9298556c14c4051cc6364a9c9fe2a IMAGE ID DISK USAGE CONTENT SIZE EXTRA lab-slim:bloat 3e3f42bbd5f5 207MB 53.5MB lab-slim:lean 69f5fd166029 165MB 36.4MB
$ docker history --format 'table {{.CreatedBy}}\t{{.Size}}' lab-slim:lean
CREATED BY SIZE RUN /bin/sh -c apt-get update && apt-get in… 18.8MB # debian.sh --arch 'arm64' out/ 'trixie' '@1… 110MB

One layer of 18.8MB instead of 44MB, because the lists are deleted before the layer is committed and --no-install-recommends leaves out packages curl does not need. DISK USAGE drops from 207MB to 165MB, and CONTENT SIZE, roughly what a pull transfers, from 53.5MB to 36.4MB. Your numbers will differ with the current Debian package versions. The same rule holds for every package manager: apk add --no-cache, pip install --no-cache-dir, npm ci followed by npm cache clean --force in the same RUN. A cache mount (RUN --mount=type=cache, covered in "BuildKit builds: stages, cache and mounts" in Docker in depth) goes further: the download cache never enters a layer and still speeds up the next build.

--squash is not the answer

Older guides fix the bloated file by flattening it with docker build --squash. On a default Docker 29 install the flag is still accepted, and ignored:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker build --squash -q -f Dockerfile.bloat -t lab-slim:squashed . 2>&1 docker history --format '{{.Size}} {{.CreatedBy}}' lab-slim:squashed | head -4
WARNING: experimental flag squash is removed with BuildKit. You should squash inside build using a multi-stage Dockerfile for efficiency. sha256:a49b6de768580fde17e1e34885919a5be9e452fa193e2424e6800729040dcdd8 20.5kB RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #… 22.7MB RUN /bin/sh -c apt-get install -y curl # bui… 21.5MB RUN /bin/sh -c apt-get update # buildkit 110MB # debian.sh --arch 'arm64' out/ 'trixie' '@1…

BuildKit prints a warning and builds the same four layers. Squashing belonged to the legacy builder with an experimental daemon. The warning names the replacement: a multi-stage build whose final stage copies only the result, so nothing deleted in an earlier stage is ever part of the image.

Score an image with dive

dive reads an image layer by layer and reports files that were added and then replaced or deleted later, the bytes that ship without being visible. In CI mode it applies thresholds and exits non-zero on failure. It normally talks to the Docker daemon through the socket, which would give the dive container root-equivalent access to the host ("The Docker socket and daemon hardening"); reading a docker save archive with --source docker-archive needs no socket. The image is pinned by digest and pulled through mirror.gcr.io:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ DIVE=mirror.gcr.io/wagoodman/dive:latest@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 docker pull -q $DIVE docker run --rm $DIVE --version
mirror.gcr.io/wagoodman/dive:latest@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 dive 0.13.1
$ DIVE=mirror.gcr.io/wagoodman/dive:latest@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 docker save -o bloat.tar lab-slim:bloat docker run --rm -e CI=true -v "$PWD/bloat.tar:/image.tar:ro" "$DIVE" \ --source docker-archive /image.tar --lowestEfficiency=0.95 > dive.txt rc=$? sed 's/\x1b\[[0-9;]*m//g' dive.txt exit $rc
Using default CI config Image Source: docker-archive:///image.tar Extracting image from docker-archive... (this can take a while for large images) Analyzing image... efficiency: 83.5205 % wastedBytes: 24972814 bytes (25 MB) userWastedPercent: 61.3971 % Inefficient Files: Count Wasted Space File Path 2 21 MB /var/lib/apt/lists/deb.debian.org_debian_dists_trixie_main_binary-arm64_Packages.lz4 2 1.6 MB /var/cache/debconf/templates.dat 2 1.6 MB /var/cache/debconf/templates.dat-old 2 531 kB /var/lib/apt/lists/deb.debian.org_debian-security_dists_trixie-security_main_binary-arm64_Packages.lz4 2 165 kB /var/lib/dpkg/status 2 165 kB /var/lib/dpkg/status-old 2 140 kB /var/lib/apt/lists/deb.debian.org_debian_dists_trixie_InRelease 2 47 kB /var/lib/apt/lists/deb.debian.org_debian_dists_trixie-updates_InRelease 2 43 kB /var/lib/apt/lists/deb.debian.org_debian-security_dists_trixie-security_InRelease 2 21 kB /var/cache/debconf/config.dat 2 21 kB /var/cache/debconf/config.dat-old 2 11 kB /var/lib/apt/extended_states 2 9.9 kB /etc/ld.so.cache 2 9.1 kB /var/log/apt/eipp.log.xz 2 7.3 kB /var/lib/apt/lists/deb.debian.org_debian_dists_trixie-updates_main_binary-arm64_Packages.lz4 2 0 B /var/lib/dpkg/triggers/Unincorp 2 0 B /var/lib/dpkg/lock 2 0 B /var/lib/apt/lists/auxfiles 2 0 B /var/lib/apt/lists/lock 2 0 B /var/lib/apt/lists/partial 2 0 B /var/lib/dpkg/triggers/Lock Results: FAIL: highestUserWastedPercent: too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.6139711147463349 > threshold=0.1) SKIP: highestWastedBytes: rule disabled FAIL: lowestEfficiency: image efficiency is too low (efficiency=0.8352048370506737 < threshold=0.95) Result:FAIL [Total:3] [Passed:0] [Failed:2] [Warn:0] [Skipped:1]

The colour codes dive prints are stripped with sed for this page, and exit $rc hands dive's exit status, 1 here, to the CI job as the gate. Typed into your own shell, that exit closes the session, so paste this block (and the Trivy block below that ends with exit) inside ( ... ), where it ends only the subshell. dive found 25MB of waste: the package lists (written in one layer, hidden by the next), and debconf and dpkg files that the install rewrote. Efficiency 83.5% fails the 95% floor, and the 61% user-wasted ratio fails the default limit of 10%. Now the lean image:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ DIVE=mirror.gcr.io/wagoodman/dive:latest@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 docker save -o lean.tar lab-slim:lean docker run --rm -e CI=true -v "$PWD/lean.tar:/image.tar:ro" "$DIVE" \ --source docker-archive /image.tar --lowestEfficiency=0.95 | sed 's/\x1b\[[0-9;]*m//g' | sed -n '/efficiency:/,$p' echo "--- the same gate with a 25% allowance for rewritten files" docker run --rm -e CI=true -v "$PWD/lean.tar:/image.tar:ro" "$DIVE" \ --source docker-archive /image.tar --lowestEfficiency=0.95 --highestUserWastedPercent=0.25 | sed 's/\x1b\[[0-9;]*m//g' | tail -4 echo "dive exit status: ${PIPESTATUS[0]}"
efficiency: 98.4253 % wastedBytes: 3538324 bytes (3.5 MB) userWastedPercent: 20.7210 % Inefficient Files: Count Wasted Space File Path 2 1.6 MB /var/cache/debconf/templates.dat 2 1.6 MB /var/cache/debconf/templates.dat-old 2 161 kB /var/lib/dpkg/status-old 2 161 kB /var/lib/dpkg/status 2 21 kB /var/cache/debconf/config.dat 2 21 kB /var/cache/debconf/config.dat-old 2 10 kB /var/lib/apt/extended_states 2 9.9 kB /etc/ld.so.cache 2 9.0 kB /var/log/apt/eipp.log.xz 2 0 B /var/lib/dpkg/triggers/Unincorp 2 0 B /var/lib/dpkg/triggers/Lock 2 0 B /var/lib/dpkg/lock Results: FAIL: highestUserWastedPercent: too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.20720956496710455 > threshold=0.1) SKIP: highestWastedBytes: rule disabled PASS: lowestEfficiency Result:FAIL [Total:3] [Passed:1] [Failed:1] [Warn:0] [Skipped:1] --- the same gate with a 25% allowance for rewritten files SKIP: highestWastedBytes: rule disabled PASS: lowestEfficiency Result:PASS [Total:3] [Passed:2] [Failed:0] [Warn:0] [Skipped:1] dive exit status: 0

Efficiency is 98.4%, and it still fails the default highestUserWastedPercent of 10%. The files listed are dpkg's status database and debconf's caches, which exist in the Debian base and are rewritten by any apt-get install; the old copies in the base layer count as waste. Every image that installs a package on a Debian base hits this. Set the threshold from what your images really look like, here 25%, and keep the efficiency floor, which is what catches lists and caches left in layers.

What the build context brings in

Size is the visible half. The other half is files that reach a layer by accident, and COPY . . is the usual way in. The app directory holds a five-line Dockerfile and a one-line app.py; give it the junk a real working copy has, a .env with a (fake) token and a .git directory:

app/Dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY . .
USER 10001
CMD ["python", "app.py"]
ubuntu@secopslog-docker:~/lab/slimming/app · Docker 29.8.2
$ printf 'GITHUB_TOKEN=ghp_%s\n' FAKE0lab0token0not0a0real0secret0000 > .env mkdir -p .git && head -c 2000000 /dev/urandom > .git/pack-1.pack docker build -q -t lab-slim:app . docker run --rm lab-slim:app ls -A /app
WARNING: current commit information was not captured by the build: failed to read current commit information with git rev-parse --is-inside-work-tree sha256:99e474d0148e695948de95767098c3520cebd8be05f20e6d2128b48b062e894d .env .git Dockerfile app.py

Everything in the directory is in /app, including the token file and 2MB of git objects. The warning on the first line comes from Buildx: it tries to record the current commit in the image provenance when the context has a .git directory, and this one is not a real repository. A secret scanner run on the saved image finds the token.:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker pull -q mirror.gcr.io/aquasec/trivy:0.75.0
mirror.gcr.io/aquasec/trivy:0.75.0
$ docker save -o app.tar lab-slim:app docker run --rm -v "$PWD/app.tar:/image.tar:ro" mirror.gcr.io/aquasec/trivy:0.75.0 \ image --input /image.tar --scanners secret --exit-code 1 --quiet
Report Summary ┌──────────────────────────────────────────────────────────────────────┬────────────┬─────────┐ │ Target │ Type │ Secrets │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ /image.tar (debian 13.7) │ debian │ - │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ Python │ python-pkg │ - │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ usr/local/lib/python3.14/site-packages/pip-26.2.1.dist-info/METADATA │ python-pkg │ - │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ /app/.env │ text │ 1 │ └──────────────────────────────────────────────────────────────────────┴────────────┴─────────┘ Legend: - '-': Not scanned - '0': Clean (no security findings detected) /app/.env (secrets) =================== Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 1) CRITICAL: GitHub (github-pat) ════════════════════════════════════════ GitHub Personal Access Token ──────────────────────────────────────── /app/.env:1 (offset: 13 bytes) (added by 'COPY . . # buildkit') ──────────────────────────────────────── 1 [ GITHUB_TOKEN=**************************************** 2 ────────────────────────────────────────

Trivy's secret scanner matched the GitHub token format, masked the value in its report and named the instruction that added the file. --exit-code 1 turns any finding into a failed CI step. Trivy reads the archive from docker save, so it needs no access to the Docker socket either. Now add a .dockerignore (its basics are in "Writing a Dockerfile" in Docker for beginners):

app.dockerignore (copied to app/.dockerignore)
.git
.env
*.pem
Dockerfile
.dockerignore
ubuntu@secopslog-docker:~/lab/slimming/app · Docker 29.8.2
$ cp ../app.dockerignore .dockerignore docker build -q -t lab-slim:app . docker run --rm lab-slim:app ls -A /app
WARNING: current commit information was not captured by the build: failed to read current commit information with git rev-parse --is-inside-work-tree sha256:e44657d5400cdb51e12caf02f1a1ba739ef1bd2666bbb869b8c0dd77da291917 app.py
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker save -o app.tar lab-slim:app docker run --rm -v "$PWD/app.tar:/image.tar:ro" mirror.gcr.io/aquasec/trivy:0.75.0 \ image --input /image.tar --scanners secret --exit-code 1 --quiet --format json | jq -r '"secrets found: \([.Results[]? | .Secrets // [] | length] | add // 0)"' echo "trivy exit status: ${PIPESTATUS[0]}"
secrets found: 0 trivy exit status: 0

Only app.py reaches the image and the scan exits 0. A .dockerignore limits what the context sends; copying named files (COPY app.py ./) limits what a layer receives, and is the stronger of the two. A scanner is still worth running, because it checks the result, including layers the final filesystem hides:

Dockerfile.deleted
FROM alpine:3.22
COPY app/.env /tmp/.env
RUN rm /tmp/.env
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker build -q -f Dockerfile.deleted -t lab-slim:deleted . docker save -o deleted.tar lab-slim:deleted docker run --rm -v "$PWD/deleted.tar:/image.tar:ro" mirror.gcr.io/aquasec/trivy:0.75.0 \ image --input /image.tar --scanners secret --exit-code 1 --quiet | grep -E '^Total|added by' exit ${PIPESTATUS[0]}
sha256:baa6d71661fa348e4386b4855b4119e446190da81591289af563dce7128fd64b Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 1) /tmp/.env:1 (offset: 13 bytes) (added by 'COPY app/.env /tmp/.env # buildkit')

The file was deleted one step after it was copied, and Trivy still reports it, attributed to the COPY layer, because that layer ships. Credentials needed during a build belong in a secret mount, never in the context; "Build-time secrets and how images leak them" measures every place they can otherwise end up. If a scan like this finds a real credential in an image that was pushed, rotate it first.

A size budget

dive measures waste, not size. A budget catches the other regression: a new dependency, a base image that grew, a debug tool someone left in. check-size.sh compares the image's disk usage (the .Size field of docker image inspect, in bytes) with a limit:

check-size.sh
#!/bin/sh
# check-size.sh IMAGE MAX_MB: fail when the image's unpacked size is over budget
img=$1 max=$2
bytes=$(docker image inspect --format '{{.Size}}' "$img") || exit 2
mb=$((bytes / 1000000))
if [ "$mb" -gt "$max" ]; then
echo "FAIL $img: $mb MB, budget $max MB"
exit 1
fi
echo "ok $img: $mb MB, budget $max MB"
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ ./check-size.sh lab-slim:lean 180 ./check-size.sh lab-slim:bloat 180
ok lab-slim:lean: 164 MB, budget 180 MB FAIL lab-slim:bloat: 207 MB, budget 180 MB

Set the budget a little above the current size and raise it on purpose when a change justifies it, in the same review. Compressed size is the better number when pull time is what you care about; it is the CONTENT SIZE column of docker image ls, or the sum of layer sizes in the pushed manifest.

Deterministic dependencies

Gates only mean something when two builds of the same commit produce the same contents. Install from lockfiles (npm ci with package-lock.json, pip install --require-hashes -r requirements.txt, go.sum), pin the base image by digest as described in "Tags, digests and promotion" in Docker in depth, and pin package versions where an unexpected upgrade would hurt. Debian's snapshot archive makes an apt-get install repeatable when that matters. BuildKit can also normalise file timestamps from SOURCE_DATE_EPOCH, which removes one more source of differences between rebuilds. In a pipeline, run the secret scan, the waste check and the budget against the built image, before the push, so a failing image never reaches a registry; "Docker in CI/CD: build to promote" in Docker in depth puts them in order with tests, signing and promotion.

Clean up

The dive and Trivy images stay in the image store; remove them with docker image rm when you no longer need them. In your own pipelines, pin the Trivy image by digest the way dive is pinned here: in March 2026 attackers published malicious Trivy images and force-pushed the trivy-action tags (CVE-2026-33634), and references by digest were not affected.

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.8.2
$ docker image rm lab-slim:bloat lab-slim:lean lab-slim:squashed lab-slim:app lab-slim:deleted rm -rf bloat.tar lean.tar app.tar deleted.tar dive.txt app/.env app/.git app/.dockerignore
Untagged: lab-slim:bloat Deleted: sha256:3e3f42bbd5f5d4477a0302c486668fea7f394be48aa3baa37b5571349ecbc26f Untagged: lab-slim:lean Deleted: sha256:69f5fd1660296e27f43b894adb4f9ce310d9298556c14c4051cc6364a9c9fe2a Untagged: lab-slim:squashed Deleted: sha256:a49b6de768580fde17e1e34885919a5be9e452fa193e2424e6800729040dcdd8 Untagged: lab-slim:app Deleted: sha256:e44657d5400cdb51e12caf02f1a1ba739ef1bd2666bbb869b8c0dd77da291917 Untagged: lab-slim:deleted Deleted: sha256:baa6d71661fa348e4386b4855b4119e446190da81591289af563dce7128fd64b
Quick check
01A Dockerfile has RUN apt-get update, then RUN apt-get install -y curl, then RUN rm -rf /var/lib/apt/lists/*. Which change makes the package lists stop shipping in the image?
Incorrect — Another later layer can only hide more files; the lists stay in the update layer.
Incorrect — BuildKit ignores the flag with a warning; the lab built the same four layers.
Incorrect — .dockerignore filters the build context sent from your machine, not files created by RUN.
Correct — The lists are removed before that single layer is committed, so no layer contains them.
02Trivy reports a token in /tmp/.env, "added by COPY", in an image whose next step runs rm /tmp/.env. A teammate calls it a false positive. Are they right?
Incorrect — The final view hides it, but the image still contains the COPY layer.
Correct — Anyone who pulls the image can extract that layer; the lab's scan found it the same way.
Incorrect — Trivy attributed the finding to the COPY layer, which shows it scans layers.
Incorrect — A private registry has many readers too, and pushed layers get cached and mirrored.
03dive with its default CI rules fails an image whose only own layer is one RUN apt-get update && apt-get install ... && rm -rf /var/lib/apt/lists/*. The listed waste is /var/lib/dpkg/status and debconf caches. What is going on?
Incorrect — The lists are not among the wasted files; only dpkg and debconf files are.
Incorrect — dive analysed the saved archive and produced real numbers for this image.
Correct — Any apt install does this, so the user-wasted threshold needs an allowance.
Incorrect — Nothing was squashed here; BuildKit does not squash.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from slimming images and layer hygiene, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related