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: 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:

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.9.2
$ docker build -q --no-cache -f Dockerfile.bloat -t lab-slim:bloat .
sha256:19fbebb25b11e5b0517b7742ab0c97c7a4ca3fcb4d2db6a6c357285fde050d9d
$ docker history lab-slim:bloat
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:

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.9.2
$ docker build -q --no-cache -f Dockerfile.lean -t lab-slim:lean . docker image ls lab-slim
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:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.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: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:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.2
$ DIVE=mirror.gcr.io/wagoodman/dive:v0.13.1@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 docker pull -q $DIVE docker run --rm $DIVE --version
mirror.gcr.io/wagoodman/dive:v0.13.1@sha256:f1886e6c32c094fc41a623c1989f5cb3e48aa766da5f0be233f911fc1d85ce10 dive 0.13.1
$ 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:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.2
$ 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:

app/Dockerfile
FROM python:3.15-slim
WORKDIR /app
COPY . .
USER 10001
CMD ["python", "app.py"]
ubuntu@secopslog-docker:~/lab/slimming/app · Docker 29.9.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: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:

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.2
$ TRIVY=mirror.gcr.io/aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c docker pull -q $TRIVY
mirror.gcr.io/aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c
$ TRIVY=mirror.gcr.io/aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c docker save -o app.tar lab-slim:app docker run --rm -v "$PWD/app.tar:/image.tar:ro" "$TRIVY" \ image --input /image.tar --scanners secret --exit-code 1 --quiet echo "trivy exit status: $?"
Report Summary ┌──────────────────────────────────────────────────────────────────────┬────────────┬─────────┐ │ Target │ Type │ Secrets │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ /image.tar (debian 13.7) │ debian │ - │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ Python │ python-pkg │ - │ ├──────────────────────────────────────────────────────────────────────┼────────────┼─────────┤ │ usr/local/lib/python3.15/site-packages/pip-26.2.2.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 exit status: 1

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):

app.dockerignore (copied to app/.dockerignore)
.git
.env
*.pem
Dockerfile
.dockerignore
ubuntu@secopslog-docker:~/lab/slimming/app · Docker 29.9.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:ff5bba68eb48ee3c9b829d15093232455308ffe495e7e87eeb8c27412d677a58 app.py
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.2
$ TRIVY=mirror.gcr.io/aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c docker save -o app.tar lab-slim:app docker run --rm -v "$PWD/app.tar:/image.tar:ro" "$TRIVY" \ 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 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:

Dockerfile.deleted
FROM alpine:3.24
COPY app/.env /tmp/.env
RUN rm /tmp/.env
ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.2
$ TRIVY=mirror.gcr.io/aquasec/trivy:0.76.1@sha256:790fd66d1697395d8693d1acaa1ea702406a20f109b6fc7bac8db623eca6c06c 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" "$TRIVY" \ image --input /image.tar --scanners secret --exit-code 1 --quiet | grep -E '^Total|added by' echo "trivy exit status: ${PIPESTATUS[0]}"
sha256:7eaaafa4c249446521ee31ed2ad0da0d014a87b7a23902943ef1e064ee658233 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') trivy exit status: 1

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
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.9.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. 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.

ubuntu@secopslog-docker:~/lab/slimming · Docker 29.9.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:19fbebb25b11e5b0517b7742ab0c97c7a4ca3fcb4d2db6a6c357285fde050d9d Untagged: lab-slim:lean Deleted: sha256:2a58df70d4de8265e733ae2089d6ad6ed60f45ee6d2888e854bfcbee2870707f Untagged: lab-slim:squashed Deleted: sha256:d96883be25f419f06f1eff308d30469d75a59ba38afab75450c316e858dcf114 Untagged: lab-slim:app Deleted: sha256:ff5bba68eb48ee3c9b829d15093232455308ffe495e7e87eeb8c27412d677a58 Untagged: lab-slim:deleted Deleted: sha256:7eaaafa4c249446521ee31ed2ad0da0d014a87b7a23902943ef1e064ee658233

Try this

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.

Related