3. Images and Dockerfiles from first principles
3.1 Layers and the build cache
Each filesystem-changing instruction (RUN, COPY, ADD) creates a layer.
Layers are cached by instruction + inputs. Once one layer's cache is
invalidated, every later layer is rebuilt. Hence the golden rule:
Best practice: order instructions from least to most frequently changing. Copy dependency manifests and install dependencies before copying source code.
# BAD: any source change re-downloads every dependency
COPY . .
RUN go mod download && go build -o /app ./cmd/orders
# GOOD: dependency layer is cached until go.mod/go.sum change
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app ./cmd/orders
Deleting files in a later layer does not shrink the image: the bytes still exist in the earlier layer. This is also how secrets leak:
# LEAKS: the key is in layer N even though layer N+1 deletes it
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:acme/private.git && rm /root/.ssh/id_rsa
docker history --no-trunc or dive (a layer explorer) will show it to
anyone who pulls the image. Use BuildKit secret mounts instead (§4.4).
3.2 Instruction reference with the gotchas
| Instruction | Use | Gotcha |
|---|---|---|
FROM image:tag AS name |
base image, names a stage | pin by tag and digest in prod: FROM golang:1.25@sha256:... |
ARG |
build-time variable | visible in docker history; never put secrets in it |
ENV |
runtime env var | baked into the image; also visible |
WORKDIR |
sets cwd (creates it) | prefer over RUN cd ... |
COPY |
copy from context/stage | prefer over ADD; use --chown, --chmod, --link |
ADD |
COPY + URL fetch + tar auto-extract | surprising; use only for the auto-extract |
RUN |
execute at build | chain and clean in the same layer: apt-get update && apt-get install -y --no-install-recommends x && rm -rf /var/lib/apt/lists/* |
USER |
run as this uid | use numeric 65532:65532 so Kubernetes runAsNonRoot can verify it |
EXPOSE |
documentation only | does not publish anything |
HEALTHCHECK |
Docker-level health | ignored by Kubernetes; use probes there |
ENTRYPOINT |
the executable | use exec form ["..."] |
CMD |
default args to ENTRYPOINT | overridden by docker run image args |
STOPSIGNAL |
signal sent on stop | default SIGTERM; nginx uses SIGQUIT for graceful stop |
3.3 PID 1, signals, and shell form vs exec form
This one bug causes thousands of "my container takes 10 seconds to stop" and "in-flight requests fail on every deploy" tickets.
# shell form: runs `/bin/sh -c "node server.js"`. sh is PID 1.
CMD node server.js
# exec form: node IS PID 1 and receives SIGTERM directly.
CMD ["node", "server.js"]
With shell form, docker stop / Kubernetes sends SIGTERM to sh, which
does not forward it to your app. After the grace period (10 s in Docker,
30 s in Kubernetes) the app is SIGKILLed mid-request.
Also, PID 1 has no default signal handlers in Linux: a process that
doesn't explicitly handle SIGTERM ignores it when it is PID 1. And PID 1
must reap zombie child processes. Options:
- Exec form + an app that handles
SIGTERM(Go:signal.NotifyContext). - If your app spawns children (shell scripts, Puppeteer, legacy apps), use a
tiny init:
docker run --initorENTRYPOINT ["tini", "--", "app"]. - If you need a shell wrapper script, end it with
exec "$@"so the app replaces the shell.
#!/bin/sh
# docker-entrypoint.sh
set -eu
# ... templating, waiting, etc.
exec "$@" # app becomes PID 1, receives signals
3.4 ENTRYPOINT + CMD together
ENTRYPOINT ["/app/orders"]
CMD ["--port=8080"]
# docker run img -> /app/orders --port=8080
# docker run img --port=9090 -> /app/orders --port=9090
In Kubernetes, command: overrides ENTRYPOINT and args: overrides
CMD. Mixing that up is a classic.