Docker

docker

Docker rarely fails loudly. It ships an image that is ten times too big, or one that is missing the dependency the build was supposed to install, and says nothing. Almost none of that is a bug: it follows from two rules. A layer only ever adds, and the cache keys on the instruction plus what it copied.

Everything below was run against Docker Engine 29.9.0 (buildx v0.38.0, BuildKit is the default builder, containerd image store) on alpine:3.22 and python:3.12-alpine. Sizes are the DISK USAGE column of docker images unless stated otherwise.

Cookbook

Order the file so the slow step stays cached

Copy the dependency manifest, install, then copy the source. Source changes every hour, the manifest changes every few weeks.

FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/

The wrong order is COPY . . before the install: any edit to any file busts the cache for everything that follows. Measured on a 5 second install step, changing one source file and rebuilding:

COPY . . before the install : 12.4 s
manifest first              :  0.9 s

Keep the build toolchain out of the final image

Build in one stage, copy the artifact into a clean one.

FROM alpine:3.22 AS build
RUN apk add --no-cache build-base
COPY hello.c .
RUN gcc -static -o /hello hello.c

FROM alpine:3.22
COPY --from=build /hello /hello
CMD ["/hello"]

Same program, 353 MB as a single stage against 12.9 MB multi-stage, and command -v gcc finds nothing in the final image. Use docker build --target build to stop at an intermediate stage and open a shell in it when something goes wrong.

Alpine: build deps in one layer

When a second stage is overkill, install the toolchain under a named virtual package and drop the group in the same RUN.

RUN apk add --no-cache --virtual .builddeps build-base \
 && ... build steps that need those deps ... \
 && apk del .builddeps

--virtual .builddeps tags everything installed in that call under one name, so apk del .builddeps drops the whole group. --no-cache fetches a fresh package index on the fly and keeps nothing in /var/cache/apk, which replaces the old --update plus rm -rf /var/cache/apk/* dance; passing --update as well is redundant. The single RUN is the whole point: the same three commands split into three RUN instructions give 353 MB, against 13 MB in one, barely above the 12.8 MB of the base image.

Keep the build context small

# .dockerignore
node_modules
.git

BuildKit is lazy: with a targeted COPY app/ /app/ it transferred a few dozen bytes whether or not .dockerignore existed, because that is all the build asked for. With COPY . /app/ the difference is the whole repository:

without .dockerignore : 62.93 MB of context, 139 MB image, .git and node_modules inside it
with .dockerignore    :    267 B of context, 12.8 MB image

Pass a secret to the build without baking it in

RUN --mount=type=secret,id=api_token \
    pip install --extra-index-url "https://$(cat /run/secrets/api_token)@pypi.example.com/simple" -r requirements.txt
docker build --secret id=api_token,src=token.txt -t app .

The file exists only while that RUN executes. Grepping the whole exported image for the value (docker save app | grep -c 'the-value') returns 0, docker history --no-trunc does not carry it, and /run/secrets/ does not exist at runtime.

See what is actually inside an image

docker history --no-trunc app          # what each layer added, and the command that added it
docker image inspect app --format '{{json .Config.Env}}'
docker save app | wc -c                # the real byte count, no accounting subtleties
docker run --rm -it app sh             # or: --entrypoint sh, when the image sets one

docker history is the first place to look when an image is bigger than it should be: the layer that added the weight is right there with the instruction responsible.

Gotchas

Each of these was reproduced on the versions above.

Removing a file in a later layer does not shrink the image

A layer only adds. Deleting a file in a later instruction records a whiteout entry, the bytes stay in the layer below, and anyone who pulls the image pulls them.

RUN dd if=/dev/urandom of=/blob bs=1M count=50
RUN rm /blob
two RUN instructions : 56.2 MB of content   (docker save: 56 254 464 bytes)
one RUN, dd && rm    :  3.8 MB              (docker save:  3 808 256 bytes)

The base image is 3.88 MB, so the second form adds nothing at all. docker history shows the rm layer weighing 4.1 kB next to the 52.4 MB it was supposed to remove, and ls /blob inside the container says the file does not exist. Deleted, invisible, still shipped.

A pipe in RUN swallows the failure

RUN runs /bin/sh -c, and a shell pipeline exits with the status of its last command.

RUN pip install -r requirements.txt | tail -2

With the install failing, the build succeeded, the image was tagged, and import requests inside it raised ModuleNotFoundError. Set the option once at the top of the Dockerfile:

SHELL ["/bin/sh", "-o", "pipefail", "-c"]

RUN false | tail -1 builds with return code 0 without it, and fails the build with it.

Build args and ENV end up in the image

--build-arg SECRET=... is visible in docker history --no-trunc forever, on every machine that pulls the image. ENV SECRET=... is worse: it sits in .Config.Env and every process in the container inherits it, so a crash reporter or a subprocess dump carries it out. Both were read back from a freshly built image. Use --mount=type=secret for build-time secrets and a runtime mechanism for runtime ones.

Signals, PID 1, and the ten second wait

The kernel gives PID 1 no default signal handlers, so a process that does not explicitly handle SIGTERM ignores it when it runs as PID 1. This has nothing to do with CMD form:

CMD ["sleep", "600"]            docker stop -> 10.17 s, then SIGKILL
same sleep, docker run --init   docker stop ->  0.15 s

With an application that does handle SIGTERM, what matters is whether the shell got out of the way. BusyBox ash execs the final command of a simple CMD, so the shell form is often fine, and it breaks as soon as the shell has to survive the process:

CMD ["python", "/app.py"]        PID 1 = python   stop =  0.13 s   handler ran
CMD python /app.py               PID 1 = python   stop =  0.15 s   handler ran
CMD python /app.py ; echo bye    PID 1 = sh       stop = 10.15 s   handler never ran
CMD python /app.py | cat         PID 1 = sh       stop = 10.16 s   handler never ran

Use the exec form, handle SIGTERM in the application, and add --init when the entrypoint really has to be a script. Ten seconds is the default docker stop grace period, after which the container is killed and whatever it was flushing is lost.

EXPOSE publishes nothing

EXPOSE 8080 is documentation. docker ps dutifully prints 8080/tcp in the PORTS column and nothing on the host answers. Only -p 8080:8080 (PORTS becomes 0.0.0.0:8080->8080/tcp) makes the port reachable, and -P publishes every exposed port on random host ports.

A bind mount replaces a directory, it does not merge

An image with /app/config.txt and /app/other.txt, mounted with -v ./host:/app where the host directory holds only config.txt, shows exactly one file: other.txt is gone for as long as the mount is there. The image content is intact, it is just covered. This is how a container works locally and fails in production, where nothing is mounted.

docker images reports two sizes

With the containerd image store, the default on fresh installs since Engine 29.0, the single SIZE column is gone. DISK USAGE is what the image occupies on this machine (content blobs plus the unpacked snapshot), CONTENT SIZE is what gets pushed and pulled. They differ by roughly a factor of three on small images: alpine:3.22 is 12.8 MB of disk usage for 3.88 MB of content. A daemon upgraded from an older release keeps the legacy store: the same two columns appear, but CONTENT SIZE reads 0B. Quote the one you mean.

The build cache grows where you are not looking

docker system df          # images, containers, volumes and build cache, separately
docker builder prune      # the build cache only
docker system prune -a    # everything unused, including tagged images with no container

docker system df reported 1.06 GB of build cache of which 13.4 MB was reclaimable, because the rest was still referenced by tagged images. docker system prune and docker builder prune both clear only that reclaimable part; docker builder prune -a is the one that drops the whole cache, and --filter until=168h keeps it bounded without throwing away this week’s work.

See also

  • Kubernetes - running containers in production.
  • Alpine Linux - the distribution the Alpine recipe builds on.
  • Shell script - the shell habits the RUN gotchas above depend on.

Related

Keyboard shortcuts

/Open search
CtrlKOpen search (Cmd+K on macOS)
?Show this cheat sheet
Ctrl/Show this cheat sheet (Cmd+/ on macOS)
EscClose search or this cheat sheet

Turns off the single-key shortcuts (/, ?). Ctrl/Cmd+K and Ctrl/Cmd+/ stay.