OnCallReady

Lesson 10.33 · Images & Builds · 12 min read

Multi-stage for Node, Python and Go

In plain words

Every kind of cooking needs a different workshop, but the idea is always the same: make the meal in the messy kitchen, then serve only the plate. For a baked cake you leave the oven behind; for a sandwich there is hardly any kitchen at all.

For Node the build stage runs npm ci with all dev dependencies and compiles TypeScript, and the runtime stage installs only production dependencies with npm ci --omit=dev. For Python you build a virtualenv in /opt/venv and copy the whole directory. For Go you compile one static binary with CGO_ENABLED=0 and ship it on distroless, about 12MB. The four questions repeat for every language: what does only the build need, where does the cache go, which user, and who is PID 1.

Why this matters

A platform team never supports just one language. The Java pattern generalises: a fat stage that builds, a thin stage that runs. What changes per language is what "build" leaves behind and what the runtime really needs - and each language has one trap that shows up as a confusing error.

What you need to know already: multi-stage builds (two lessons ago), cache ordering and lockfiles (the cache lesson), PID 1 and SIGTERM (previous lesson).

Node

Node.js runs JavaScript on a server. npm installs its packages into node_modules, reading package.json (what you depend on) and package-lock.json (the lockfile: exact versions). devDependencies are packages only needed to build and test (TypeScript, test runners), not to run. TypeScript is JavaScript with types; tsc compiles it to plain JavaScript in dist/.

FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                              # all deps: typescript, jest, eslint...
COPY . .
RUN npm run build                       # tsc -> dist/

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev                   # runtime deps only
COPY --from=build /app/dist ./dist
USER node
CMD ["node","dist/server.js"]
node:22        1.1GB   buildpack-deps: compilers, git, python - build stage only
node:22-slim   226MB   Debian slim + node
node:22-alpine 153MB   musl

Details that decide the result:

Python

pip installs Python packages, listed in requirements.txt. A virtualenv (venv) is a self-contained folder with its own Python and packages - which makes it trivially copyable between stages.

FROM python:3.12-slim AS build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
RUN useradd -r -u 1001 app
USER app
CMD ["gunicorn","-b","0.0.0.0:8000","app:app"]

Go: the best case

Go compiles a program into one binary (a single executable file). The runtime stage can be almost nothing:

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/pinger .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/pinger /pinger
ENTRYPOINT ["/pinger"]
$ docker images pinger
REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
pinger       latest   4c1d2e3f4a5b   5 seconds ago    11.7MB

go.mod/go.sum are Go's dependency list and lockfile. go build -o names the output file; -ldflags="-s -w" strips the symbol table and debug info. distroless/static is 2MB: CA certificates (Ch 9, TLS), an /etc/passwd with a nonroot user, and timezone data. Plus a 9.7MB binary.

The CGO trap. Most programs rely on the system's C library - glibc on Ubuntu and Debian - loaded when the program starts. A statically linked binary has everything it needs built in; a dynamically linked one needs the library and a small dynamic loader (/lib/ld-linux-aarch64.so.1) to be on disk. CGO is Go's way of calling C code; in the golang image it is on by default, and using Go's net or net/http then produces a dynamically linked binary. Copy that into an image without glibc and it cannot start:

# pinger:cgo = the cgo build of the mission below
docker run --rm pinger:cgo
exec /pinger: no such file or directory

The file exists; the loader it names does not. CGO_ENABLED=0 builds a static binary. (If you genuinely need cgo, use gcr.io/distroless/base-debian12, which has glibc.)

scratch vs distroless/static. FROM scratch is literally empty: no CA certificates (every HTTPS call fails with x509: certificate signed by unknown authority - the certificate chain from Ch 9 cannot be checked), no /etc/passwd (so no named user), no timezone data. distroless static adds exactly those for 2MB. Prefer it.

Same four questions, every language

  1. What does the build need that the runtime does not? That is the build stage.
  2. Where does the package manager put its cache? Keep it out of layers (--no-cache-dir, --no-cache, a cache mount).
  3. Which user does the runtime base provide? Use it.
  4. Who is PID 1, and does it handle SIGTERM?

What you can now do

Why it helps

A platform team never supports just one language. In the same week you might review a Node service with CMD ["npm","start"] (npm as PID 1, signals lost), a Python image where print output never reaches docker logs (missing PYTHONUNBUFFERED=1), and a Go image on scratch where every HTTPS call fails with x509: certificate signed by unknown authority (no CA certificates). You will also meet the CGO trap: a Go binary that works in the golang image and fails in distroless with exec /pinger: no such file or directory, because it is dynamically linked against a glibc that is not there. Recognising these from the error text saves hours, and 'write a production Dockerfile for this Node or Python app' is a common take-home task.

Commands in this lesson

docker

FAQ

Why npm ci instead of npm install?

npm ci installs exactly what the lockfile says, fails if package.json and package-lock.json disagree, and refuses to run without a lockfile. npm install may resolve newer versions and rewrite the lockfile. For reproducible images you want npm ci, with the lockfile committed. Add --omit=dev in the runtime stage to skip devDependencies.

Why shouldn't I use CMD ["npm","start"]?

It makes npm PID 1, and npm does not forward signals reliably to the node process it starts, so graceful shutdown breaks. It also adds an extra process and memory. Run node directly, as in CMD ["node","dist/server.js"], and add a SIGTERM handler in the app, or put tini in front.

Why does my Go binary say no such file or directory when the file is there?

The binary is dynamically linked, and the missing file is its dynamic loader, such as /lib/ld-linux-aarch64.so.1. In the golang image CGO is on by default, and packages like net link against glibc. Scratch, distroless/static and alpine do not have glibc. Build with CGO_ENABLED=0 for a static binary, or use distroless/base, which includes glibc.

Why is PYTHONUNBUFFERED=1 so common in Python images?

When stdout is not a terminal, Python buffers output in blocks. In a container stdout is a pipe, so print and some logging output sit in a buffer and appear late or are lost if the process is killed. PYTHONUNBUFFERED=1 makes stdout and stderr unbuffered, so docker logs shows output immediately.

Scratch or distroless static for a Go binary?

Usually distroless static. Scratch is completely empty: no CA certificates, so HTTPS fails; no /etc/passwd, so no named user; no timezone data. gcr.io/distroless/static-debian12:nonroot adds exactly those for about 2MB and runs as a non-root user by default. Scratch only makes sense for binaries that need none of them.

In an interview Junior

Outline a production Dockerfile for a Node.js API.

Two stages - a fat one that builds, a thin one that runs:

FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node","dist/server.js"]

The points to say out loud: manifests first for the cache; npm ci (exactly the lockfile) not npm install; --omit=dev in the runtime stage drops devDependencies (TypeScript, test tools); a -slim runtime base; the image's node user; and run node directly in exec form, not npm start (npm as PID 1 does not forward signals). Node has no default SIGTERM handler, so add process.on('SIGTERM', ...) or use --init.

Also asked: How do you keep gcc out of the final image of a Python service that needs it to install a package? · What is the CGO trap in Go container images? · Why does npm ci beat npm install in a Dockerfile?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.