Inspect and shrink a Docker image without breaking it
Measure a Docker 29.9.2 image with docker history and dive, shrink it without breaking its contract, and see why --squash and docker import fall short.
The issue opens pre-filled with this page title and URL.
An image weighs 538 MB although its visible content weighs only about 80: this lesson teaches you to explain that gap, to shrink it, then to prove that the application still provides the same service. You measure a small application in several forms with docker image ls, docker history, docker system df -v and dive, you see why a RUN rm gives back no bytes, and you run a test suite, launched from the outside, after each reduction.
All the measurements come from Docker Engine 29.9.2, Buildx 0.38.1 and dive 0.13.1, on a Debian x86_64 VM whose daemon stores its images with containerd (the "containerd image store") and on the Alpine 3.24.3 base image. The sizes are those of this application, on this date: redo your own measurements before quoting a figure.
Several names in this lesson are French because the commands and outputs are the ones that were run: horloge ("clock") is the example application and its image, serveur.py ("server") its script, test-contrat.sh ("contract test") the test script, construction ("build") the build stage, PAQUETS and UTILISATEUR ("packages" and "user") the build arguments, and casse-* ("broken") the names of the broken images. The labels and messages that the script prints (sante repond ok, symptomes, echecs) are French for the same reason. They are identifiers and outputs, so they stay as they are. The comments in the code are in English.
What you will learn
Say what each size displayed by Docker measures: DISK USAGE, CONTENT SIZE, .Size and docker system df -v, then deduce the uncompressed size
Read docker history to find the instruction that makes the image grow
Explain why a layer keeps what a later layer deletes
Run dive in CI, with a pinned version, and know what its efficiency does not tell you
Explain why --squash has no effect with BuildKit and what an export followed by an import destroys
Reject with an external suite the reductions that break the contract of the image
Prerequisites
Having read Test the contract of an image: the suite of this lesson reuses its principle (run the image from the outside, one check per wait, an exit code)
Docker 29.9.2 on a throwaway machine, bash and curl (installing Docker)
Which application do you shrink, and how do you prove it still works?
The application is called horloge. It is a small Python HTTP server on port 8080 that exposes four routes: /sante answers ok, /heure gives the time offset of Europe/Paris (it reads the time zone database), /moi returns the uid of the process, and /ca loads the certificate bundle of the image. Each route relies on an element that somebody will one day want to remove to save space.
Save this code under the name serveur.py, in an empty working folder.
import datetime
import json
import os
import ssl
import zoneinfo
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests
classGestionnaire(BaseHTTPRequestHandler):
defdo_GET(self):
try:
ifself.path =="/sante":
corps = {"etat": "ok", "requests": requests.__version__}
The following script is the external suite, in the spirit of the one in the previous lesson. It starts the image with a HEALTHCHECK verified every 2 seconds, queries the routes from the host, and exits with the number of failed checks. It never looks inside the image: it asks questions the way a user would. Save it under the name test-contrat.sh, in the same folder as serveur.py.
#!/usr/bin/env bash
# External suite: looks at the image only from the outside (HTTP + health state).
The five checks cover the service (/sante), the time zone, the unprivileged user, the certificates and the health state. Keep them in sight: a reduction succeeds only if the five stay at OK.
Which images do you build to compare?
The first image, v1, is deliberately naive. It installs Python, a compiler (build-base), curl, tzdata and ca-certificates, creates a 20 MB archive in /tmp to simulate a build file, installs requests with pip, then deletes the archive and the compiler. Each command has its own RUN. Save this file under the name Dockerfile.v1.
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
Two options deserve an explanation. --break-system-packages allows pip to install into the system Python: Alpine declares its Python "externally managed" (PEP 668), and pip refuses to write into it without this option. It is not a concern here, because this Python serves only this application. As for build-base, it is the compilation toolchain: requests does not need it, it is there to simulate a build tool that you install, use, then delete.
The second image, v2, contains exactly the same thing. Only the way the RUN is written changes: everything that serves only to build is installed, used and deleted in the same instruction. Save this file under the name Dockerfile.v2.
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
RUN apk add --no-cache python3 py3-pip curl tzdata ca-certificates \
v2 wins on the content of each layer, not on their number: adduser and COPY stay in their own instructions, because these light instructions cost almost nothing in size, and keeping them apart helps the build cache (modifying serveur.py does not redo the package installation).
These Dockerfiles pin neither the apk packages nor requests. Hadolint 2.15.1 reports it as a warning on each of these Dockerfiles, v3 included (rules DL3018 for apk add and DL3013 for pip install), and also reports the shell form of the HEALTHCHECK (DL3025). The lesson does not freeze these versions, to keep the reading centered on the layers, and because a precise package version eventually disappears from the Alpine repository, which breaks the build. In a real project, pin them.
The third image, v3, is the multi-stage build that you saw in the lesson on separating build from runtime. It comes further down, when you know how to measure: until you have built it, ignore the v3 column of the tables.
Terminal window
dockerbuild-fDockerfile.v1-thorloge:v1.
dockerbuild-fDockerfile.v2-thorloge:v2.
bashtest-contrat.shhorloge:v1
bashtest-contrat.shhorloge:v2
Each build ends with naming to docker.io/library/horloge:v1 done (then v2) and an exit code 0. Both suites print the same thing, and exit with code 0:
OK sante repond ok (200)
OK heure de Paris (decalage +0100 ou +0200)
OK processus non root (uid 10001)
OK certificats CA presents (au moins 10)
OK HEALTHCHECK passe a healthy (etat: healthy)
echecs: 0
The two images therefore provide the same service, echecs: 0 for both: only their size tells them apart.
What does each size displayed by Docker measure?
The same image displays several sizes depending on the command, and they do not answer the same question. Before reading them, check how your daemon stores images: the relations in this section were measured on the containerd image store.
Terminal window
dockerinfo--format'{{json .DriverStatus}}'
[["driver-type","io.containerd.snapshotter.v1"]]
io.containerd.snapshotter.v1 designates the containerd store (docker info then displays Storage Driver: overlayfs). On another store, the columns may be computed differently: measure on your own machine before applying these relations. On this store, docker image ls gives two sizes per image:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
horloge:v1 ddaa570df489 538MB 150MB
horloge:v2 75014dd369f9 114MB 30.7MB
horloge:v3 6896b18a9ef8 89.8MB 23.7MB
First look at the two columns of horloge:v1: 538 MB and 150 MB. CONTENT SIZE is the compressed size of the layers: the one that travels to a registry and that every pull downloads. DISK USAGE is the space the image occupies on the disk of the machine. No alpine line appears: a BuildKit build keeps the base image in its build cache, not in the image list, whether the FROM is pinned by digest or not. It enters the list only after an explicit docker pull.
A third quantity is missing, the uncompressed size, that is, what the files of the layers weigh once extracted. It is obtained by DISK USAGE minus CONTENT SIZE, and it equals the sum of the layer sizes given by docker history, up to rounding: 388 MB for v1, 83.1 MB for v2, 66.1 MB for v3. On this store, DISK USAGE is therefore the sum of the two: for v1, 150 MB compressed plus 388 MB uncompressed make 538 MB.
Each question calls for its column. To measure a transfer cost, compare CONTENT SIZE before and after. To measure the space taken on the machine, compare DISK USAGE. To compare a layer of docker history with the image, compare it with the uncompressed size, never with DISK USAGE. Mixing columns announces a reduction that nobody obtained: 538 MB before and 30.7 MB after, for example.
The following table brings together all the measurements of the same image.
Measurement
v1
v2
v3
DISK USAGE of docker image ls
538MB
114MB
89.8MB
docker image inspect --format '{{.Size}}' (bytes)
537835566
113839591
89769385
CONTENT SIZE of docker image ls
150MB
30.7MB
23.7MB
Uncompressed size: sum of the SIZE of docker history
388 MB
83.1 MB
66.1 MB
Content visible in the container, du -xsk / (KB)
80860
80860
64172
Swipe to see more
CONTENT SIZE is also the sum of the sizes of the compressed layers that the docker save archive contains: 150238448 bytes for v1 and 23701461 for v3. These layers are tar archives compressed with gzip, hence their small size. The first one, Alpine's, weighs 3853109 bytes in the v1 image, and it is the same layer in v1, v2 and v3, which share it.
Two pitfalls await whoever automates these readings. The first: the template of docker image ls --format has no .DiskUsage or .ContentSize field.
template parsing error: template: :1:45: executing "" at <.DiskUsage>: can't evaluate field DiskUsage in type *formatter.imageContext
The {{.Size}} field of this template returns the value of DISK USAGE (538MB for v1). To read these sizes in a script, docker image inspect --format '{{.Size}}' gives this value in bytes, and the compressed size is read in docker image ls or computed by adding up the layers of the docker save archive.
The second pitfall concerns shared parts: docker image ls --format json displays "SharedSize":"N/A","UniqueSize":"N/A". You have to go through docker system df -v.
Terminal window
dockersystemdf-v
For each image, the command displays its size, what it shares with others and what is its own:
REPOSITORY TAG IMAGE ID CREATED SIZE SHARED SIZE UNIQUE SIZE CONTAINERS
horloge v3 6896b18a9ef8 2 seconds ago 89.8MB 12.94MB 76.83MB 0
horloge v2 75014dd369f9 10 seconds ago 114MB 12.94MB 100.9MB 0
horloge v1 ddaa570df489 35 seconds ago 538MB 12.94MB 524.9MB 0
Read SHARED SIZE: v1, v2 and v3 share the same 12.94 MB, the base layer coming from Alpine. The rest belongs to each image: 524.9 MB for v1, 100.9 MB for v2, 76.83 MB for v3. A registry or a daemon stores this common layer only once, which matters when many images rest on the same base.
What does docker history show?
When an image is too big, docker history points to the guilty instruction. It lists the instructions from the most recent (at the top) to the oldest (at the bottom) with the size that each one adds. A line is not necessarily a layer: v1 displays 13 lines for 8 layers, because instructions that touch no file do not create one.
Terminal window
dockerhistoryhorloge:v1
IMAGE CREATED CREATED BY SIZE COMMENT
ddaa570df489 About a minute ago CMD ["python3" "/srv/serveur.py"] 0B buildkit.dockerfile.v0
<missing> About a minute ago EXPOSE [8080/tcp] 0B buildkit.dockerfile.v0
<missing> About a minute ago HEALTHCHECK {Test:[CMD-SHELL curl -fs http:/… 0B buildkit.dockerfile.v0
<missing> About a minute ago USER service 0B buildkit.dockerfile.v0
<missing> About a minute ago COPY serveur.py /srv/serveur.py # buildkit 12.3kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c adduser -D -u 10001 service #… 41kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c apk del build-base # buildkit 274kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c rm /tmp/archive-sources.bin #… 8.19kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c pip install --break-system-pa… 5.42MB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c dd if=/dev/urandom of=/tmp/ar… 21MB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c apk update && apk add python3… 352MB buildkit.dockerfile.v0
<missing> 12 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 12 days ago ADD alpine-minirootfs-3.24.3-x86_64.tar.gz /… 9.08MB buildkit.dockerfile.v0
Three lines can be read in the SIZE column. The package installation weighs 352 MB, that is 90.8% of the uncompressed size of the image (388 MB), and not a share of the 538 MB of DISK USAGE. The creation of the archive weighs 21 MB. And the two deletions, rm and apk del, weigh 8.19 kB and 274 kB: almost empty layers, whereas what they delete weighs 21 MB and several hundred MB.
The five lines at 0B are metadata (two CMD, EXPOSE, HEALTHCHECK, USER): they contain no file and create no layer. <missing> in the IMAGE column is displayed for every line except the most recent: this is not an error. CREATED BY truncates long commands: add --no-trunc to read them in full, for example the complete HEALTHCHECK.
The same docker history on v2 shows the effect of writing in a single instruction:
IMAGE CREATED CREATED BY SIZE COMMENT
75014dd369f9 About a minute ago CMD ["python3" "/srv/serveur.py"] 0B buildkit.dockerfile.v0
<missing> About a minute ago EXPOSE [8080/tcp] 0B buildkit.dockerfile.v0
<missing> About a minute ago HEALTHCHECK {Test:[CMD-SHELL curl -fs http:/… 0B buildkit.dockerfile.v0
<missing> About a minute ago USER service 0B buildkit.dockerfile.v0
<missing> About a minute ago COPY serveur.py /srv/serveur.py # buildkit 12.3kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c adduser -D -u 10001 service #… 41kB buildkit.dockerfile.v0
<missing> About a minute ago RUN /bin/sh -c apk add --no-cache python3 py… 74MB buildkit.dockerfile.v0
<missing> 12 days ago CMD ["/bin/sh"] 0B buildkit.dockerfile.v0
<missing> 12 days ago ADD alpine-minirootfs-3.24.3-x86_64.tar.gz /… 9.08MB buildkit.dockerfile.v0
Only one big line remains: 74 MB, instead of six lines of which three exceed 5 MB. The archive and the compiler were never written into a layer that survives.
Why does a layer keep what a later layer deletes?
An image layer is frozen as soon as it is created. When a RUN ends, Docker records the files that it added or modified, and this layer no longer changes. An rm in a later RUN removes nothing from the previous layer: it creates a new layer that notes that the file no longer exists. That is why the rm layer of v1 weighs 8.19 kB in docker history whereas the archive it hides weighs 21 MB in the layer before. The section on inspection shows this deletion marker in the image archive.
The diagram below puts the two images one under the other. Look at the highlighted layer of each stack: in v1, it is the one that contains the compiler and that can no longer lose anything; in v2, the same work fits in a layer where the archive and the compiler were deleted before the end of the instruction. The layer sizes are those of docker history; their sum stays below the total in the title, because DISK USAGE adds the compressed content to it (see above).
What the container sees is nevertheless identical. In both images, a du on the root returns the same value, because the deleted files have disappeared from the view:
The message about /root comes from the image running with the service user, who has no right to read it. The result, 80860 blocks of 1 KB, is the same for v1 and for v2 (see the table above). The size of an image is not read in its visible content: it is read in its layers, and a layer keeps everything it received, even if a later layer masks it.
The resulting rule fits in one sentence: a cleanup saves space only if it is in the same RUN instruction as what it cleans. Grouping the instructions is enough to go from 538 MB to 114 MB, with the same application and the same packages.
How do you inspect an image without installing a tool?
Docker alone already answers most size questions. The following table says which command to ask depending on the question, before looking for a third-party tool.
Question
Command
Which instruction makes the image grow?
docker history IMAGE
What are the compressed size and the uncompressed size?
docker image ls: CONTENT SIZE, then DISK USAGE minus CONTENT SIZE
What does this image share with the others?
docker system df -v
Which platforms and which manifests does it contain?
docker image ls --tree, docker buildx imagetools inspect
How much does the content seen from the container weigh?
docker run --rm --entrypoint sh IMAGE -c 'du -xsk /'
How much does the build cache weigh?
docker buildx du
Which files does a layer contain?
docker save, tar -xf, then tar -tzf on a blob, or dive
Swipe to see more
docker image ls --tree details the size per platform. For v1, a single platform exists, and its manifest carries the two sizes:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
horloge:v1 ddaa570df489 538MB 150MB
└─ linux/amd64 588b80874a9c 538MB 150MB
What remains is the question that these commands leave open, the one about files: which files weigh, and which are written in one layer then deleted in another. docker save writes the image into an archive, which you extract to read its layers one by one. The one for v1 weighs 150263296 bytes, the compressed content, like CONTENT SIZE.
Terminal window
dockersavehorloge:v1-ov1.tar
mkdirv1-couches && tar-xfv1.tar-Cv1-couches
catv1-couches/manifest.json
Listing the archive with tar -tf v1.tar shows only blob names (blobs/sha256/<digest>), plus index.json, manifest.json and oci-layout: the files of the image are in the blobs. The manifest.json says which ones are layers, in the order in which they stack:
Eight layers, in the order of the Dockerfile: Alpine, apk add, dd, pip install, rm, apk del, adduser, COPY. Your digests will be different, copy those of your manifest.json. The third is the dd layer. Its blob is a gzip archive, which tar -tzf lists:
The .wh. prefix is a deletion marker (a whiteout): the rm layer erases nothing, it writes next to the file a note that says "this one no longer exists". The 20 MB archive stays intact in the dd layer. This is the proof that a layer keeps what a later layer deletes. dive does this work for all files at once.
What does dive do, and what does it not measure?
dive (in French) opens each layer of an image and compares its files with those of the previous layers. It estimates an efficiency: the share of bytes that is not waste, that is, files written in one layer then modified or deleted in another. The guide dedicated to dive details the interactive interface; here, it is its non-interactive mode that is used, because it returns an exit code usable in CI.
The version is pinned by its digest. With CI=true, dive opens no interface, it displays a report and exits with code 0 or 1.
Here is the beginning of the report for v1, then the end with the verdict. The "Inefficient Files" list contains several hundred lines; only the first ones are reproduced.
Using default CI config
Image Source: docker://horloge:v1
Extracting image from docker-engine... (this can take a while for large images)
Analyzing image...
efficiency: 75.4481 %
wastedBytes: 132609108 bytes (133 MB)
userWastedPercent: 36.3501 %
Inefficient Files:
Count Wasted Space File Path
2 41 MB /usr/bin/lto-dump
2 21 MB /tmp/archive-sources.bin
2 11 MB /usr/share/misc/magic.mgc
2 9.4 MB /usr/lib/libc.a
2 7.0 MB /usr/lib/libstdc++.a
[...]
Results:
FAIL: highestUserWastedPercent: too many bytes wasted, relative to the user bytes added (%-user-wasted-bytes=0.363501103465308 > threshold=0.1)
SKIP: highestWastedBytes: rule disabled
FAIL: lowestEfficiency: image efficiency is too low (efficiency=0.7544809512961126 < threshold=0.9)
Look at the Count column: 2 means that the file is present in two layers. /tmp/archive-sources.bin appears there with 21 MB wasted, exactly the archive that the rm did not give back. The compilers (lto-dump, libc.a, libstdc++.a) are those that apk del build-base masked. dive returns exit code 1, because two default rules fail: the minimum efficiency of 0.9 and the maximum share of 0.1 of wasted bytes relative to the bytes added.
The table gives the verdict for v1, v2 and v3.
Image
Efficiency
Wasted bytes
Wasted share
Verdict (exit code)
v1
75.4481%
132609108 (133 MB)
36.3501%
FAIL (1)
v2
99.4821%
580833 (581 kB)
0.8855%
PASS (0)
v3
99.4531%
522587 (523 kB)
1.0244%
PASS (0)
Swipe to see more
A poor efficiency therefore spots the flaw of v1 and can block a regression in CI. But read the end of the table: v2 and v3 pass with an almost identical efficiency, although they differ by 24 MB on disk. The efficiency of dive counts the bytes written then masked between layers. It measures neither the compressed size, nor the weight of the packages, nor what is useful: a 500 MB image with no duplicates between layers gets an excellent score.
It does not see everything that is masked either. dive counts a file when a later layer rewrites it or places a deletion marker on that very file. It does not count files erased together with their parent folder: apk del build-base removes entire folders such as /usr/libexec/gcc, and the compilers cc1 and cc1plus (45 and 42 MB) do not appear in its list. On v1, its 133 MB cover only about 44% of the 298 MB actually masked, measured by rebuilding the file system layer by layer. Keep it to detect waste between layers, and measure the size with the columns of docker image ls.
The 581 kB wasted by v2 come mostly from two files: the certificate bundle /etc/ssl/certs/ca-certificates.crt (179 kB, so 358 kB counted twice) and the package database /lib/apk/db/installed (216 kB). Each appears in two layers: the Alpine image already contains them, and the RUN that installs the packages rewrites them. Six small apk and account files add about 7 kB.
Without mounting the socket, dive can read an archive produced by docker save, such as the v1.tar of the previous section. The figures are the same as on the image of the daemon: 75.4481% efficiency and 133 MB wasted for v1.
How do you shrink further with a multi-stage build?
The multi-stage build removes from the result everything that serves only to build, without depending on the order of the deletions. The lesson Separate build from runtime: the multi-stage build teaches the technique; here, v3 is its application on horloge. pip runs in a construction stage, and only the installed folder is copied into the final image, which has neither pip nor a compiler. Save this file under the name Dockerfile.v3.
ARG PAQUETS="python3 curl tzdata ca-certificates"
ARG UTILISATEUR=service
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a AS construction
The two ARG change nothing in the default image: they will be used further down to build broken variants without writing a new file. The two declarations before the first FROM are repeated in the final stage, otherwise the values would not be visible there.
v3 weighs 89.8 MB (23.7 MB compressed) against 114 MB (30.7 MB) for v2, and its visible content goes from 80860 to 64172 KB. In docker history horloge:v3, two lines matter:
<missing> 5 seconds ago RUN |2 PAQUETS=python3 curl tzdata ca-certif… 53MB buildkit.dockerfile.v0
The history shows the ARG and nothing of the build stage: neither pip, nor its cache. The suite returns echecs: 0 on v3, and dive returns PASS. This gain is not general: it depends on what the build stage contains and on what the final stage keeps, here the gap between pip and its dependencies. Measure it on your application before announcing a percentage.
Why is --squash no longer useful, and what does an export followed by an import lose?
docker build --squash used to promise to flatten all the layers of an image into one. With BuildKit, which is the default builder of Docker 29, the option is accepted and ignored:
WARNING: experimental flag squash is removed with BuildKit. You should squash inside build using a multi-stage Dockerfile for efficiency.
The build succeeds (exit code 0) but produces the same image: docker image ls displays 538MB and 150MB for horloge:v1-squash, docker history gives the same thirteen lines, and docker image ls --tree shows the same platform manifest for both names:
horloge:v1 ddaa570df489 538MB 150MB
└─ linux/amd64 588b80874a9c 538MB 150MB
horloge:v1-squash a9158c72b8d7 538MB 150MB
└─ linux/amd64 588b80874a9c 538MB 150MB
Only the image identifier changes, a9158c72b8d7 against ddaa570df489. docker buildx build --squash displays the same warning and has no more effect. With the legacy builder, DOCKER_BUILDKIT=0 docker build --squash fails with exit code 1, before any build, whether the Dockerfile is Dockerfile.v1 or Dockerfile.v2:
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.
BuildKit is currently disabled; enable it by removing the DOCKER_BUILDKIT=0
environment-variable.
"--squash" is only supported on a Docker daemon with experimental features enabled
The remedy that the warning recommends is the one of v3: split into stages.
The other way to flatten an image is to create a container, export its file system and import it again. It works, at the price of everything that is not a file.
The resulting image weighs 116 MB on disk: 84.3 MB uncompressed plus 31.6 MB compressed. It fits in a single layer, and its history in one line:
IMAGE CREATED CREATED BY SIZE COMMENT
0f2dca98164c 52 seconds ago 84.3MB Imported from -
This line is all that remains of the history: no more instructions, no more layers to inspect. And the configuration of the image is empty. docker image inspect --format '{{json .Config}}' gives for v1 the user, the port, the command and the health probe:
For v1-import, the same field is {}. The image therefore no longer starts:
docker: Error response from daemon: no command specified
Run 'docker run --help' for more information
The command exits with code 125.
What has disappeared is the CMD, the USER, the EXPOSE, the PATH, the WorkingDir and the HEALTHCHECK. docker import --change restores part of them. Since the source container was deleted, create it again, export it while adding the CMD, USER and EXPOSE instructions, then delete it again and run the suite on the horloge:v1-import2 image:
Terminal window
dockercreate--namesrc-exporthorloge:v1
dockerexportsrc-export|dockerimport\
--change'CMD ["python3","/srv/serveur.py"]'\
--change'USER service'\
--change'EXPOSE 8080'\
-horloge:v1-import2
dockerrmsrc-export
bashtest-contrat.shhorloge:v1-import2
The image starts. The suite answers OK to the first four checks and fails on the last one, because the HEALTHCHECK was not restored:
OK sante repond ok (200)
OK heure de Paris (decalage +0100 ou +0200)
OK processus non root (uid 10001)
OK certificats CA presents (au moins 10)
KO HEALTHCHECK passe a healthy (etat: )
--- symptomes
GET /heure -> {"zone": "Europe/Paris", "decalage": "+0100"} [HTTP 200]
GET /moi -> {"uid": 10001} [HTTP 200]
GET /ca -> {"certificats": 118} [HTTP 200]
template parsing error: template: :1:13: executing "" at <.State.Health.Log>: map has no entry for key "Health"
echecs: 1
The template parsing error of the suite comes from the image having no health probe: there is no health history to read. The three routes do answer.
docker import also accepts --change 'HEALTHCHECK CMD curl -fs http://localhost:8080/sante || exit 1', and the probe then appears in .Config.Healthcheck. But you have to have thought of each piece of metadata: the import keeps nothing by default.
There is a second loss, less visible. In docker system df -v, v1-import displays 0B in SHARED SIZE (and 115.9 MB in UNIQUE SIZE), whereas v2 and v3 share 12.94 MB of base layer: the flattened image no longer shares the base layer with the other Alpine images of the machine or of the registry. Every flattened image is downloaded and stored in full.
The same docker system df -v, run while horloge:v1-squash still existed, confirms that --squash changed nothing. v1 and v1-squash each display 537.8MB of SHARED SIZE and only 2.039kB and 2.046kB of UNIQUE SIZE: the two names designate the same platform manifest, so almost everything is shared between them.
Finally, flattening gained nothing that v2 had not already: 116 MB against 114 MB. The gain came from the cleanup done in the right instruction, not from the flattening.
The multi-stage build (v3, 89.8 MB and 23.7 MB compressed) remains smaller than the import, keeps the metadata and keeps the sharing of the base layer.
Which reductions break the contract without making the probe fail?
Shrinking an image means removing elements. Some serve no purpose, others serve a route that the health probe does not look at. The first four images are built from Dockerfile.v3, changing only the ARG:
The fifth starts from v3 and deletes the certificates in one more layer. Save it under the name Dockerfile.nossl. The USER root gives this RUN the right to delete these folders, and the last USER service restores the account of v3.
The five builds end with exit code 0: these images build without any error, it is their behavior that is broken.
The first three are a little lighter than v3. casse-root and casse-nossl keep its size: the first changes only the account, and the second deletes the certificates in one more layer, which masks them without removing anything, like v1 with its archive. Each one is broken in a different way.
Image
Size (DISK USAGE / CONTENT SIZE)
What breaks
What the suite shows
casse-tz (without tzdata)
88MB / 23.5MB
/heure
ZoneInfoNotFoundError, HTTP 500; /sante stays 200 and the probe healthy
casse-curl (without curl)
81.9MB / 21.2MB
the HEALTHCHECK
unhealthy, curl: not found; the application answers
casse-minimal (python3 and tzdata only)
80.6MB / 21MB
the HEALTHCHECK
unhealthy; the 118 certificates are still present
casse-root (UTILISATEUR=root)
89.8MB / 23.7MB
the uid
/moi returns {"uid": 0}, HTTP 200
casse-nossl (without /etc/ssl or /usr/share/ca-certificates)
89.8MB / 23.7MB
/ca
FileNotFoundError, HTTP 500; /sante ok and probe healthy
Swipe to see more
Here is the suite on casse-tz, the most misleading one:
OK sante repond ok (200)
KO heure de Paris (decalage +0100 ou +0200)
OK processus non root (uid 10001)
OK certificats CA presents (au moins 10)
OK HEALTHCHECK passe a healthy (etat: healthy)
--- symptomes
GET /heure -> {"erreur": "ZoneInfoNotFoundError", "detail": "'No time zone found with key Europe/Paris'"} [HTTP 500]
The probe is green because it tests only /sante, and /sante needs neither the time zone nor the certificates. An orchestrator that trusts it would send traffic to an image whose /heure answers a 500 error. Only the external suite, which queries every route, sees the breakage. It is the same idea as in the previous lesson: a probe that does not judge the real work does not prove the contract.
The opposite case, casse-curl, shows the application healthy and the probe failing, because the probe uses curl. Removing curl forces you to rewrite the HEALTHCHECK with what the image still contains, not to abandon it. Here is what the suite reads in the health history:
"ExitCode":1,"Output":"/bin/sh: curl: not found\n"
And casse-root gains no byte: its only flaw is to run with the administrator account, which neither /sante nor the size reveals. The suite catches it because it checks the uid.
There remains casse-minimal, which also removes ca-certificates: its 118 certificates are nevertheless still there. They come from the ca-certificates-bundle package, which apk-tools requires and which the bare Alpine already contains (apk info -r ca-certificates-bundle answers that apk-tools-3.0.8-r0 requires it). The ca-certificates package mainly brings the update-ca-certificates command: removing it gains 1374174 bytes (81936965 bytes for casse-curl, 80562791 for casse-minimal, about 1.4 MB), and the /ca check would pass anyway.
These removals do have a real gain, which the measurement gives. Removing tzdata takes the image from 89.8 to 88 MB. Removing curl makes nine packages disappear (brotli-libs, c-ares, curl, libcurl, libidn2, libpsl, libunistring, nghttp2-libs and zstd-libs): the image goes from 89.8 to 81.9 MB (81936965 bytes for casse-curl, about 7.8 MB and 8.7% less than v3; the exact bytes vary by a few dozen from one build to another, take your own with docker image inspect --format '{{.Size}}'), and from 23.7 to 21.2 MB compressed (2.5 MB less). This gain is worth it only if the suite proves that nothing breaks: without curl, the HEALTHCHECK probe must be rewritten, and without tzdata, /heure answers a 500 error. Remove an element because a measurement shows that it weighs and the suite proves that it serves no route, not because it looks useless.
How do you clean the machine after the measurements?
The measurements leave images, an archive and an extracted folder. Delete them by name, so as to remove nothing other than what this lesson created: the only rm -r below targets the v1-couches folder, extracted above. The derived images go first, then v1, v2 and v3.
The docker image ls horloge command must no longer list anything, and docker ps -a --filter name=test-contrat no container (the suite deletes its own on exit). Two traces remain: the dive image, downloaded in the section on dive, and the build cache (779 MB after the construction of v1, v2 and v3, more with the broken variants). Remove the first by its name, then empty the cache with docker buildx prune -f, which deletes the whole build cache of the machine: reserve it for the throwaway machine.
docker buildx du must then display Total: 0B. The files serveur.py, test-contrat.sh, the Dockerfile.v1, Dockerfile.v2, Dockerfile.v3 and Dockerfile.nossl stay in your working folder: keep them to replay the lesson, or delete them one by one.
Practice
The lab gives an image that is too heavy for what it does, with its starting Dockerfile. You weigh it layer by layer to find where the megabytes go, you rebuild it lighter, and you keep what it shares with its base. Only the state of the system is checked, with dsoxlab check, never the commands typed: the validation runs the rebuilt image to verify that it still works, measures its size against that of its useful content, and looks at whether its first layers are still those of the base.
Key points
A layer keeps what a later layer deletes. An rm or an apk del in a separate RUN creates an almost empty layer and gives back no byte: v1 and v2 have the same visible content (80860 KB) and weigh 538 MB against 114 MB.
Compare only identical columns.DISK USAGE (and .Size) gives the space on the disk, CONTENT SIZE the compressed size that travels to a registry. On the containerd store, their difference is the uncompressed size, equal to the sum of the layers of docker history.
Start with docker history: the SIZE column points to the instruction to rewrite, and the layers at 0B are mere metadata.
docker system df -v gives SHARED SIZE and UNIQUE SIZE. A flattened image shares 0B, an image that rests on the same base shares a part.
dive in CI runs with CI=true, a version pinned by digest, and a socket mounted on a throwaway workstation, or on a docker save archive. Its efficiency counts a part of the bytes masked between layers (not those of a folder deleted as a whole): it measures neither the compressed size nor the usefulness of the packages.
--squash is accepted and ignored by BuildKit. An export followed by an import does flatten, but destroys CMD, USER, EXPOSE, HEALTHCHECK and the history, and loses the sharing of layers. The multi-stage build (see the dedicated lesson) shrinks without any of this.
A reduction is proven by an external suite that queries every route. A green probe is not enough: casse-tz and casse-nossl keep /sante at 200, and casse-root changes no size.
Knowledge check
Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.
Knowledge check
Check what you have learned with this interactive quiz
8 questions
8 min
70% to pass
How it works
The timer starts when you click Start
Multiple choice, true/false and short answer questions
You can move between questions
Detailed results are shown at the end
Starts the quiz and the timer
⏱️--:--
0 / 8
Review
(0/0)
--:--
0%
0Correct
0To review
0:00Time
-Level
Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.