Frontend Labs
⋯

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:

  1. Exec form + an app that handles SIGTERM (Go: signal.NotifyContext).
  2. If your app spawns children (shell scripts, Puppeteer, legacy apps), use a tiny init: docker run --init or ENTRYPOINT ["tini", "--", "app"].
  3. 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.


Generated from real-life-k8s-guide.md · 8 Oct 2026 16:36