Docker Multi-Stage Builds Shrink Your Image Without Breaking the Deploy
Практическое руководство по многоэтапным сборкам Docker — реальные размеры изображений до и после, рабочие примеры Dockerfile для Node.js и Python, и ошибки, которые встречаются в каждой команде хотя бы один раз.
Your Node.js image is 1.1 GB. You’ve added .dockerignore, pruned dev dependencies, tried node:slim — it barely moved. The actual fix is multi-stage builds. If you haven’t switched yet, you’re shipping your TypeScript compiler to production.
Multi-stage builds have been in Docker since version 17.05 (2017). They’re just underused. Here’s a real walkthrough: what changes, how big the difference is, and the three pitfalls that bite teams on the first migration.
The single-stage problem
Most Dockerfiles start like this:
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
Build that and check with docker images: ~1.1 GB. You’re shipping the full Node 20 image with npm, your TypeScript toolchain, every dev dependency, and your complete source tree. None of that runs in production — the app just needs the compiled dist/ output and a handful of runtime packages.
Multi-stage builds: the fix
Каждый FROM instruction starts a new stage with a clean filesystem. Name stages with AS, then use COPY --from=stagename to pull specific files into the next stage. Intermediate stages don’t make it into the final image — they’re build artifacts, discarded after the COPY is done.
Here’s the same app as a proper multi-stage build:
# ---- Build stage ----
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime stage ----
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
The critical line: COPY --from=builder /app/dist ./dist. That pulls для декодирования любого the compiled output from the builder stage into the Alpine-based runtime image. The TypeScript compiler, source files, and dev dependencies never touch the final layer.
Result: ~160 MB instead of 1.1 GB. That’s roughly an 85% reduction for a typical Node app — and it’s the same built artifact, just without the scaffolding around it.
Adding a test stage
You can add a test stage between build and runtime that runs your test suite. If tests fail, the build stops before the runtime image is created. If tests pass, you skip the test stage entirely when building for production.
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM builder AS tester
RUN npm test
FROM node:20-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
In CI you target the tester stage explicitly: docker build --target tester .. For production images you build without a target and Docker runs all stages in order, stopping at the last FROM. The test stage runs but its filesystem is discarded — tests act as a gate, not as payload.
Python: same idea, slightly different execution
Python multi-stage builds follow the same pattern. The main difference: pip installs packages under /root/.local when you use --user, so you copy that directory into the slim runtime image.
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user -r requirements.txt
COPY . .
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]
python:3.12 base image: ~1 GB. python:3.12-slim with only installed dependencies: ~180–250 MB depending on what’s in requirements.txt. The compiled .pyc files come along for free since they live next to the source.
Three pitfalls that catch everyone at least once
1. Copying the wrong files
The most common mistake: you COPY --from=builder /app ./ вместо COPY --from=builder /app/dist ./dist. You just copied everything — source files, test fixtures, node_modules, the works — into your “minimal” runtime image. It’s now bigger than the single-stage version.
Be explicit about what you’re copying. Copy only the directory or files that your production entry point actually needs. For most Node apps: the compiled output (dist/) and optionally any static assets. For Python: the installed packages and the application code, not requirements.txt, not tests, not notebooks.
2. Build secrets leaking in layers
If you pass secrets as build arguments (e.g., ARG NPM_TOKEN followed by using it in a RUN command), that secret is visible in every layer that follows — even in a multi-stage build. docker history myimage will show it.
The correct approach is Docker BuildKit’s --mount=type=secret:
RUN --mount=type=secret,id=npm_token NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
The secret is mounted at runtime of that layer only — it never gets committed to the image history. Build with: docker build --secret id=npm_token,src=.npmrc .
The cheap workaround people use — delete the secret in the same RUN layer — doesn’t actually help with multi-stage builds, but the BuildKit approach is cleaner regardless.
3. Forgetting .dockerignore
Multi-stage builds shrink your final image, but COPY . . in the build stage still sends your entire context to the Docker daemon. Without a .dockerignore, that includes .git/, node_modules/, test fixtures, local .env files, and any secrets you’re storing in plaintext files. The build stage sees all of it.
Minimum .dockerignore for any Node project:
.git
node_modules
dist
.env
*.log
coverage
.nyc_output
Добавить .dockerignore the same day you add the Dockerfile. The build context size shows up in the first line of docker build output (Sending build context to Docker daemon X MB) — if that number is suspiciously large, check what’s being included.
Useful tools for Dockerfile work
If you want a starting point before writing your own, the Генератор Dockerfile on IO Tools will scaffold a multi-stage Dockerfile for common stacks. Once you have something written, run it through the Линтер и Форматер Dockerfile to catch common mistakes before they hit CI — things like missing WORKDIR, using latest tags, or running as root unnecessarily.
The takeaway
Multi-stage builds are a two-step change: add a named build stage, copy the compiled output into a fresh minimal image. The size reduction is almost always worth it — 80–90% is typical for Node and Python apps. The main gotchas are being too broad with COPY --from, leaking secrets as build args, and skipping .dockerignore. Fix those and you’ve got a production image that’s actually sized for production.
Установите наши расширения
Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска
恵 Табло результатов прибыло!
Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!
Подписаться на новости
все Новые поступления
всеОбновлять: Наш последний инструмент Был добавлен 13 Июня 2026
