Multi-stage сборка в Docker: как разделить сборку и запуск
Практика3 минРедакция Innovialabs
Коротко
В одном Dockerfile вы описываете несколько этапов: тяжёлый этап со всеми инструментами собирает приложение, а в финальный лёгкий образ копируется только готовый результат — без компиляторов, исходников и dev-зависимостей.
Коротко: что такое multi-stage сборка
Multi-stage build — это Dockerfile с несколькими инструкциями FROM. Каждая FROM начинает новый этап (stage) со своим базовым образом. В финальный образ попадает только последний этап, а из предыдущих вы явно забираете нужные файлы через COPY --from=<этап>.
Что это даёт:
- Меньший размер — в продакшен не едут компилятор, менеджер пакетов, исходники и тестовые зависимости.
- Меньше поверхность атаки — чем меньше утилит в образе, тем меньше уязвимостей находят сканеры.
- Один Dockerfile вместо связки «build-скрипт + отдельный runtime Dockerfile».
- Кэширование по этапам — Docker переиспользует слои каждого этапа, если входные файлы не менялись.
Go: из компилятора в почти пустой образ
Go собирается в статический бинарник, поэтому финальный образ может быть минимальным.
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]
Ключевые моменты: CGO_ENABLED=0 даёт бинарник без зависимости от libc, а go.mod копируется до исходников, чтобы слой с зависимостями кэшировался.
Node.js: отдельно зависимости для сборки и для запуска
У Node.js-приложения нет одного бинарника, но разделение всё равно полезно: TypeScript, сборщики и тестовые пакеты нужны только на этапе сборки.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
В финальном этапе ставятся только production-зависимости, а из этапа сборки копируется готовая папка dist.
Фронтенд: собрать в Node, отдать через Nginx
Для SPA (React, Vue, Svelte) Node нужен только чтобы собрать статику. Отдавать её лучше лёгким веб-сервером.
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
В итоговом образе нет ни Node.js, ни node_modules — только Nginx и файлы сборки. Путь dist зависит от сборщика: у некоторых это build.
Как измерить выигрыш
Не доверяйте чужим цифрам — размер зависит от ваших зависимостей. Сравните сами:
- Соберите «наивную» версию в один этап:
docker build -t app:single -f Dockerfile.single . - Соберите multi-stage:
docker build -t app:multi . - Сравните:
docker images app - Посмотрите, какие слои занимают место:
docker history app:multi
Обычно самая большая разница у компилируемых языков (Go, Rust), где в финале остаётся один бинарник. У Node.js выигрыш скромнее, но заметен за счёт отказа от dev-зависимостей и тяжёлого базового образа.
Частые ошибки
- Копирование всего контекста без
.dockerignore— в образ и кэш попадаютnode_modules,.git, локальные.env. COPY . .до установки зависимостей — любое изменение кода сбрасывает кэшnpm ciилиgo mod download.- Динамически слинкованный бинарник в distroless или scratch — контейнер падает с ошибкой «not found».
- Запуск от root в финальном этапе, хотя образ это позволяет изменить одной строкой
USER. - Безымянные этапы —
COPY --from=0ломается при добавлении нового этапа. Давайте имена черезAS.