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: 121bf16f97e5cf515c1f2528f273382561b222ee66cf1f35e6e1eb3e2f0278b0
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 you can measure both and gate them in CI. You'll do that here with docker history, dive, Trivy and a small size budget.
Here's 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. If you delete a file in a later layer, that 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:
IMAGE CREATED CREATED BY SIZE COMMENT
19fbebb25b11 1 second ago RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #… 20.5kB buildkit.dockerfile.v0
<missing> 1 second ago RUN /bin/sh -c apt-get install -y curl # bui… 22.7MB buildkit.dockerfile.v0
<missing> 25 seconds ago RUN /bin/sh -c apt-get update # buildkit 21.5MB buildkit.dockerfile.v0
<missing> 5 days ago # debian.sh --arch 'arm64' out/ 'trixie' '@1… 110MB debuerreotype 0.17
Read the history from the bottom up. The Debian base is 110MB. apt-get update downloaded 21.5MB of package lists into its own layer, and apt-get install added 22.7MB. The rm layer is 20.5kB: the whiteout entries plus a little metadata. The lists are hidden in the final filesystem, but they're still 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. It adds back the one recommendation that matters for curl, the CA certificates:
sha256:2a58df70d4de8265e733ae2089d6ad6ed60f45ee6d2888e854bfcbee2870707f
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
lab-slim:bloat 19fbebb25b11 207MB 53.5MB
lab-slim:lean 2a58df70d4de 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
You get one layer of 18.8MB instead of 44MB. The lists are deleted before the layer is committed, and --no-install-recommends leaves out packages curl doesn't need. DISK USAGE drops from 207MB to 165MB. CONTENT SIZE, roughly what a pull transfers, drops 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 goes further (RUN --mount=type=cache, covered in "BuildKit builds: stages, cache and mounts" in Docker in depth). The download cache never enters a layer, and it 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's still accepted, and ignored:
$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:d96883be25f419f06f1eff308d30469d75a59ba38afab75450c316e858dcf114
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. That way, nothing you've deleted in an earlier stage is ever part of the image.
Score an image with dive
dive reads an image layer by layer. It 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. That 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:
$DIVE=mirror.gcr.io/wagoodman/dive:v0.13.1@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
echo "dive exit status: $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.5165 %
wastedBytes: 24978501 bytes (25 MB)
userWastedPercent: 61.4103 %
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 543 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 865 B /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.6141031876228311 > threshold=0.1)
SKIP: highestWastedBytes: rule disabled
FAIL: lowestEfficiency: image efficiency is too low (efficiency=0.8351651922468605 < threshold=0.95)
Result:FAIL [Total:3] [Passed:0] [Failed:2] [Warn:0] [Skipped:1]
dive exit status: 1
The colour codes dive prints are stripped with sed for this page. The last line prints dive's exit status, 1 here. In a CI job that status is the gate, so end the step with it instead of printing it. dive found 25MB of waste. That's the package lists (written in one layer, hidden by the next), plus 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:
$DIVE=mirror.gcr.io/wagoodman/dive:v0.13.1@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.4255 %
wastedBytes: 3538026 bytes (3.5 MB)
userWastedPercent: 20.7259 %
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.20725852933169925 > 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. They exist in the Debian base, and any apt-get install rewrites them, so 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 own images really look like, here 25%. Keep the efficiency floor, since that's what catches lists and caches left in layers.
What the build context brings in
Size is the half you can see. 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:
$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:646db2ac920c902ef041d2135b36a9a7f49e22c042924e569fb184de922069dd
.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 isn't a real repository. A secret scanner run on the saved image finds the token. The Trivy image is pinned by digest like dive. In March 2026 attackers published malicious Trivy images and force-pushed the trivy-action tags (CVE-2026-33634). References by digest weren't affected. TRIVY holds the pinned reference in each step:
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 makes Trivy exit 1 on any finding, as the last line shows. In CI that fails the step. Trivy reads the archive from docker save, so it doesn't need the Docker socket either. Now add a .dockerignore (its basics are in "Writing a Dockerfile" in Docker for beginners):
$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:ff5bba68eb48ee3c9b829d15093232455308ffe495e7e87eeb8c27412d677a58
app.py
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 it's the stronger of the two. A scanner is still worth your time, because it checks the result, including layers the final filesystem hides:
The file was deleted one step after it was copied. Trivy still reports it, attributed to the COPY layer, because that layer ships. Credentials you need 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 with a limit you pass. It reads the .Size field of docker image inspect, in bytes:
check-size.sh
#!/bin/sh
# check-size.sh IMAGE MAX_MB: fail when the image's unpacked size is over budget
Set the budget a little above the current size. 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's 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. Then 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 don't need them any more.
Before the clean-up step, on the main lab VM in ~/lab/slimming, tighten the budget: run ./check-size.sh lab-slim:lean 160 and ./check-size.sh lab-slim:lean 170. Predict which of the two passes from the 164 MB the budget step printed for the lean image, then check. Pick the number you'd actually commit as the budget, and note why a value right at the current size would fail the next small package bump.
Takeaway
A delete in a later layer hides a file without removing it. The 20.5kB rm layer sat on top of 21.5MB of package lists, and Trivy still found the token deleted one step after its COPY. Clean up in the same RUN that made the mess, copy named files instead of COPY . ., and scan the saved archive, since that's what you ship. A common review slip is trusting --squash, which Docker 29 accepts and ignores.
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.