Многоэтапная сборка (multi-stage)
| Появление | Docker 17.05 (май 2017) |
|---|---|
| Где описывается | Dockerfile |
| Ключевые инструкции | FROM ... AS name, COPY --from=name |
| Сборщик | BuildKit (по умолчанию с Docker 23.0) |
| Поддержка | Docker, Podman/Buildah, kaniko |
| Цель | маленькие и безопасные образы |
Многоэтапная сборка (англ. multi-stage build) — способ описать в одном Dockerfile несколько последовательных этапов, каждый со своим базовым образом. На первых этапах ставятся компиляторы, менеджеры пакетов и зависимости для сборки, а в финальный образ копируются только готовые файлы — бинарник, собранная статика или установленные библиотеки. В результате образ получается в разы меньше, быстрее скачивается и содержит меньше программ, в которых могут найтись уязвимости.
Зачем это нужно
До появления многоэтапной сборки в 2017 году (Docker 17.05) разработчики либо тащили в продакшен образ с полным набором инструментов, либо держали два Dockerfile и скрипт, который копировал артефакты из одного контейнера в другой («builder pattern»).[1] Каждая инструкция Dockerfile создаёт слой, и удаление файла в следующем слое не уменьшает образ — файл остаётся в предыдущем. Многоэтапная сборка решает это: в финальный образ попадают только слои последнего этапа.
Типичный выигрыш:
| Стек | Один этап | Многоэтапная |
|---|---|---|
| Go | golang ~800 МБ |
scratch/distroless 10–20 МБ |
| Node.js (фронтенд) | node ~1 ГБ |
nginx:alpine ~50 МБ |
| Python | python ~1 ГБ |
python:slim 150–250 МБ |
| Java | JDK + Maven ~700 МБ | JRE ~200 МБ |
Синтаксис
Этапу дают имя через AS, а файлы из него копируют COPY --from=имя:[2]
# этап 1: сборка
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# этап 2: запуск
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot
ENTRYPOINT ["/app"]
Go особенно хорошо подходит для этого: статически слинкованный бинарник может работать даже в пустом образе scratch. Образы distroless от Google содержат только библиотеки времени выполнения — без shell и менеджера пакетов.[5]
Фронтенд-пример:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
Здесь Node.js нужен только для сборки, а раздаёт статику nginx.
Python
Для интерпретируемых языков переносят не бинарник, а установленное окружение:
FROM python:3.12 AS build
RUN pip install uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /app/.venv /app/.venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "app"]
Компиляторы для пакетов с C-расширениями остаются на этапе сборки. Важно, чтобы базовые образы этапов совпадали по версии Python и libc.
Возможности BuildKit
С Docker 23.0 сборку по умолчанию выполняет BuildKit, который:[3]
- строит независимые этапы параллельно и пропускает этапы, не нужные для цели;
- позволяет собрать конкретный этап:
docker build --target test .— удобно для прогона тестов в CI/CD; - поддерживает кэш-монтирование:
RUN --mount=type=cache,target=/root/.cache/pip pip install ...— кэш менеджера пакетов сохраняется между сборками, но не попадает в образ; - передаёт секреты без записи в слои:
RUN --mount=type=secret,id=npmrc ...; - копирует файлы из любого образа:
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv.
Практические советы
- Копируйте сначала файлы зависимостей (
go.mod,package.json,uv.lock), затем исходники — так слой с зависимостями кэшируется, пока они не меняются.[4] - Фиксируйте версии базовых образов (
node:22.11, а лучше по дайджесту), чтобы сборка была воспроизводимой. - Запускайте финальный образ не от root (
USER). - Добавьте
.dockerignore—node_modules,.gitи локальные.envне должны попадать в контекст сборки. - Отдельный этап
testилиlintможно запускать в CI, не включая инструменты тестирования в продакшен-образ.
Многоэтапные Dockerfile понимают и альтернативные сборщики: Podman и Buildah, kaniko в Kubernetes.