Многоэтапная сборка (multi-stage build) позволяет собирать приложение в одном этапе Dockerfile, а в финальный образ копировать только готовые артефакты. Компиляторы, исходники, dev-зависимости и кэш сборки остаются в промежуточных этапах и в продакшн не попадают. Выигрыш зависит от стека: в одном из примеров Go-приложения финальный образ на scratch весил около 3,12 МБ против 968 МБ у одноэтапной сборки, а для Node.js-приложения в другом примере размер удалось снизить с 1,2 ГБ примерно до 50 МБ. Для Python конкретные цифры зависят от проекта, но подход тот же: wheels собираются на одном этапе и устанавливаются на другом.
Причина разницы видна сразу: обычный Dockerfile тащит в рантайм всё, что понадобилось для сборки, включая gcc, кэш pip и полный node_modules. Многоэтапная сборка разводит роли: этап builder собирает, этап runtime запускает. Инструкции, на которых держится схема, - это FROM ... AS имя_этапа и COPY --from=имя_этапа.
Дальше разбираем синтаксис, готовые фрагменты Dockerfile, типичные ошибки переноса зависимостей и команды, которыми проверяют размер и состав слоёв. Базовые шаги сборки, работу кэша и слоёв разбирает пошаговое руководство по сборке Docker-образа из Dockerfile.
Что такое multi-stage build и зачем он нужен
Multi-stage build - это один Dockerfile с несколькими инструкциями FROM. Каждый FROM открывает новый этап со своим базовым образом и своим набором слоёв. Финальным считается последний этап, если при сборке не передан ключ --target. Промежуточные артефакты и SDK остаются позади и не сохраняются в финальном образе: всё, что происходило в промежуточном этапе, остаётся на машине сборщика и стирается после завершения пайплайна. Именно поэтому multi-stage позволяет заметно уменьшить финальный образ, не пытаясь вручную сокращать число промежуточных слоёв и файлов.
Чем multi-stage отличается от обычной сборки
Обычный Dockerfile содержит один FROM и линейный список инструкций:
FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD ["node", "dist/server.js"]
В прод здесь уезжает всё: dev-зависимости, исходники TypeScript, кэш npm, лишние пакеты базового образа. Многоэтапный вариант отличается структурой, а не набором команд:
FROM node:20 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine AS runtime WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=builder /app/dist ./dist USER node CMD ["node", "dist/server.js"]
Разница в границе между этапами. Всё, что находится выше второго FROM, живёт отдельной жизнью и в финальный образ не попадает. Этапы именуют через AS и ссылаются на них в COPY --from. Синтаксис AS и COPY --from поддерживается и классическим билдером, и BuildKit, поэтому сам синтаксис Dockerfile менять не придётся. При этом поведение билдеров различается: классический билдер Docker Engine обрабатывает все этапы Dockerfile вплоть до выбранного target, тогда как BuildKit собирает только те этапы, от которых зависит target.
Какую проблему решает разделение этапов сборки и рантайма
Одноэтапная сборка оставляет в продакшн-образе четыре категории лишнего:
- инструменты сборки: компиляторы, линтеры, пакетные менеджеры;
- dev-зависимости и кэш их установки;
- исходники, тесты, файлы документации;
- временные файлы, созданные инструкциями RUN.
Каждый пункт бьёт по трём метрикам: размер образа, время передачи по сети при деплое, число пакетов, которые анализирует сканер уязвимостей. Образ на сотни мегабайт скачивается на каждую ноду кластера, тогда как образ в единицы или десятки мегабайт уезжает почти мгновенно и хорошо кэшируется на узле.
| Стек | Одноэтапная сборка | Multi-stage |
|---|---|---|
| Go, статический бинарник | 968 МБ (пример из блога) | 3,12 МБ на scratch; 3,52 МБ на distroless |
| Node.js | 1,2 ГБ (пример из статьи) | около 50 МБ |
| Python | зависит от проекта | wheels собираются на одном этапе и ставятся на другом |
Цифры взяты из отдельных примеров и относятся к конкретным тестовым приложениям: они зависят от версии базового образа, набора системных библиотек и того, что именно вы копируете в рантайм. Измерьте свой образ до и после, команды есть в разделе про проверку слоёв.
Синтаксис COPY --from и именование этапов через AS
Работа с этапами сводится к двум конструкциям: AS в инструкции FROM и --from в COPY. Аргумент --from принимает имя этапа, его индекс или внешний образ.
Именование этапов: зачем нужен AS
Запись FROM golang:1.22 AS builder задаёт алиас этапа. Дальше на этот алиас ссылаются в COPY --from=builder и в ключе сборки --target builder.
Имена давайте осмысленные: builder, runtime, deps, assets. Если алиас не задан, Docker присваивает этапам индексы по порядку, начиная с 0 для первого этапа. COPY --from=0 в таком случае работает, но становится хрупким: вставка нового этапа в начало сдвигает нумерацию, и сборка либо падает, либо молча копирует не тот файл. Имена такой зависимости не создают.
Именованные этапы удобны для отладки: docker build --target builder -t myapp:builder . собирает только промежуточный этап с компилятором и исходниками. Отдельный Dockerfile для тестов при этом не нужен.
COPY --from: копируем только готовые артефакты
COPY --from переносит файлы и каталоги из указанного этапа в текущий, оставляя всё ненужное за пределами финального образа:
COPY --from=builder /out/server /usr/local/bin/server COPY --from=builder /app/dist ./dist COPY --from=builder /app/static ./static
Индекс работает так же: COPY --from=0 /out/server /usr/local/bin/server. Внешний образ тоже допустим: COPY --from=nginx:alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf позволяет взять готовый конфиг, не устанавливая nginx в свой этап сборки.
Две практические детали. Первая: каждая COPY создаёт отдельный слой, поэтому бинарник, статику и конфиги лучше копировать одной инструкцией, где это возможно. Вторая: флаг --chown задаёт владельца файлов на лету и избавляет от отдельного RUN chown, который добавил бы лишний слой.
COPY --from=builder --chown=10001:10001 /out/server /usr/local/bin/server
Копировать целиком /app или node_modules нельзя: вместе с артефактами в рантайм уедут кэш, тесты и dev-зависимости, и весь смысл разделения пропадёт.
Практический пример: собираем приложение в отдельном этапе
Этап сборки: устанавливаем зависимости и собираем
Порядок инструкций в builder-этапе определяет, как работает кэш. Сначала копируем только файлы зависимостей, потом ставим их, и лишь затем копируем исходники:
FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
При правке кода слой с RUN go mod download берётся из кэша, и повторная сборка идёт быстрее. Если поменять порядок и сначала выполнить COPY . ., любое изменение в файле проекта обнулит кэш зависимостей.
Флаги сборки тоже влияют на результат: -s убирает таблицу символов, а -w удаляет отладочную информацию DWARF. Флаг -trimpath также применяют при сборке релизного бинарника, чтобы в него не попадали пути сборочной машины. Для Node.js логика похожая: npm ci на этапе сборки со всеми зависимостями, затем npm ci --omit=dev в рантайме. Перед сборкой настройте .dockerignore: .git, node_modules, dist, .env и логи не должны попадать даже в builder.
Этап рантайма: минимальный образ и только артефакты
Базовый образ рантайма выбирают по типу артефакта:
| Базовый образ | Когда подходит | Что учесть |
|---|---|---|
| scratch | статически слинкованный бинарник, собранный с CGO_ENABLED=0 | нет shell, нет CA-сертификатов и таймзон, их копируют из builder вручную |
| alpine | нужны shell и системные утилиты для отладки | другая libc (musl), проверяйте бинарники через ldd |
| distroless | нужен только рантайм языка без shell и пакетного менеджера | отладка внутри контейнера затруднена, логи смотрите снаружи |
Полный Dockerfile для Go с рантаймом на scratch:
FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server FROM scratch AS runtime COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /out/server /server USER 10001:10001 EXPOSE 8080 ENTRYPOINT ["/server"]
Сборка, отладочный этап и проверка запуска:
docker build -t myapp:multi-stage . docker build --target builder -t myapp:builder . docker run --rm --entrypoint sh myapp:multi-stage
Последняя команда для scratch-образа не сработает: в нём нет sh. Такие образы проверяют через docker history и docker inspect, они работают с любым образом. Для alpine можно запустить which gcc или ls /usr/bin и убедиться, что компилятора в финальном образе нет. Пошаговые примеры сборки для Python, Go и Node.js с замерами размера собраны в практическом руководстве по multi-stage сборке.
Типичные ошибки при переносе зависимостей
Почему нельзя копировать node_modules или venv целиком
В node_modules лежат production-пакеты вместе с dev-зависимостями, кэшем npm, бинарниками под платформу сборочной машины и дублями версий. Копирование каталога целиком возвращает в рантайм сотни мегабайт, от которых вы избавлялись.
Рабочие варианты для интерпретируемых языков:
- Node.js: выполнить в рантайм-этапе npm ci --omit=dev по package-lock.json, то есть ставить строго по lock-файлу;
- Node.js с бандлом: если сборщик укладывает всё в один файл, зависимости в рантайм можно не копировать вовсе;
- Python: собрать wheel на builder и установить его в рантайме либо ставить пакеты из requirements.txt с флагом --no-cache-dir, чтобы pip не оставлял кэш в слое;
- Python с venv: переносить каталог venv между разными путями рискованно, в скриптах активации прописаны абсолютные пути. Копируйте не venv, а установленные пакеты в рантайм-этап.
Отдельная категория проблем - системные библиотеки. Статический бинарник на scratch запустится, а приложение с libssl, libpq или libffi без этих библиотек упадёт на старте. Проверяйте зависимости командой ldd /out/server. Если вывод начинается с not a dynamic executable, бинарник статический и scratch подойдёт. Если в списке есть строки со словом not found, нужную библиотеку копируют из builder или ставят в рантайм-этапе через apk add.
Остальные частые ошибки: перенос в рантайм исходников и конфигов, которые не читаются приложением; одинаковый базовый образ для сборки и запуска; забытый .dockerignore, из-за которого в контекст сборки попадают .git, .env и тестовые данные; ENV с секретами, которые остаются в метаданных образа.
Как проверить, что в финальном образе нет лишнего
Быстрый набор проверок для собранного образа:
- docker history myapp:multi-stage показывает команды слоёв и их размер;
- docker run --rm --entrypoint sh myapp:multi-stage открывает оболочку, в ней ls /app и du -sh /* подскажут, что занимает место;
- docker inspect myapp:multi-stage отдаёт метаданные: пользователя, переменные окружения, список слоёв;
- dive myapp:multi-stage разбирает содержимое каждого слоя.
Как проверить размер и состав слоёв после сборки
Команды для сравнения размера до и после
Соберите одну и ту же версию кода двумя Dockerfile и сравните результат:
docker build -t myapp:single -f Dockerfile.single .
docker build -t myapp:multi-stage .
docker images --format "{{.Repository}}:{{.Tag}} {{.Size}}" | grep myapp
Разница между строками и есть эффект от разделения этапов. Проверку стоит автоматизировать в пайплайне: получить размер и упасть с кодом выхода 1, если превышен заданный порог.
docker image inspect myapp:multi-stage --format '{{.Size}}'
Значение выводится в байтах, поэтому порог удобно держать в тех же единицах.
Анализ слоёв с помощью docker history и dive
docker history myapp:multi-stage
В выводе важны колонки CREATED BY и SIZE. Самые тяжёлые слои обычно создают RUN apt-get install, RUN npm install и COPY . . без .dockerignore. Учтите, что docker history не показывает содержимое слоя, только команду и размер, поэтому для поиска конкретных файлов нужен dive. Подробный разбор анализа слоёв и поиска лишнего веса собран в руководстве про анализ слоёв Docker-образов через docker inspect и dive.
Чек-лист по итогам проверки:
- в финальном образе нет компилятора и пакетного менеджера;
- нет исходников, тестов и файлов документации;
- нет кэша пакетных менеджеров;
- размер совпадает с ожидаемым и не растёт от сборки к сборке;
- все нужные системные библиотеки на месте, приложение стартует.
Как встроить multi-stage build в CI/CD
Переносить пайплайн целиком не нужно: меняется только Dockerfile, а конвейер по-прежнему вызывает docker build. Разделение этапов происходит внутри сборки.
Сборка только нужного этапа через --target
Ключ --target останавливает сборку на указанном этапе и делает его финальным для этой команды. Это удобно для тестов и линтеров, которым нужны исходники и полный набор зависимостей:
docker build --target builder -t myapp:builder . docker run --rm myapp:builder go test ./...
Инструкции установки зависимостей при этом не дублируются в отдельном Dockerfile для CI. Ключ работает и с классическим билдером, и с BuildKit.
Кэширование слоёв в пайплайне
В CI кэш слоёв между запусками обычно не сохраняется, поэтому каждая сборка начинается с нуля. Рабочие варианты:
- BuildKit cache mount для кэша пакетного менеджера: RUN --mount=type=cache,target=/root/.cache/go-build go build -o /out/server ./cmd/server. Cache mount задаёт постоянный кэш пакетов, который ускоряет шаги установки; записи в такой mount отбрасываются после завершения инструкции и не фиксируются в слое образа;
- экспорт и импорт кэша сборки: docker buildx build с ключами --cache-from и --cache-to и типом хранилища registry или gha;
- кэш на уровне раннера: сохранение каталога BuildKit между запусками CI.
Порядок инструкций из builder-этапа важен и здесь: пока файлы зависимостей не менялись, слой с их установкой берётся из кэша, а пересобирается только код. Как выстроить сборки для dev, test и production с фиксацией версий и digest, разобрано в материале про воспроизводимые Docker-образы для разных сред.
Безопасность и актуальность multi-stage образов
Почему меньше компонентов снижает число уязвимостей
Сканеры вроде Trivy, Grype и Clair ищут уязвимости в пакетах, которые находят в образе. Чем меньше пакетов, тем короче отчёт. Инструменты сборки и dev-зависимости регулярно получают CVE, но в продакшн-образе они не нужны: multi-stage их там не оставляет. Рантайм на distroless или scratch содержит приложение, его библиотеки и больше ничего.
Дополнительный эффект: отсутствие shell и пакетного менеджера усложняет закрепление в контейнере после эксплуатации уязвимости приложения. Готовые шаблоны с non-root пользователем и отключением лишних пакетов собраны в подборке про безопасные Dockerfile для Python, Node.js и Go.
Чек-лист перед публикацией образа
- базовый образ обновлён, тег зафиксирован, по возможности по digest;
- финальный образ не содержит компиляторов, исходников и пакетных менеджеров;
- приложение стартует от непривилегированного пользователя, заданного через USER;
- секреты не попали в слои: ни через COPY .env, ни через ENV, ни через аргументы сборки;
- shell отсутствует, если он не нужен для отладки;
- сканер уязвимостей не находит критичных проблем, а проверка запускается в CI автоматически;
- размер образа укладывается в заданный порог.
Источники
- Multi-stage builds | Docker Docs
- Optimize cache usage in builds | Docker Docs
- Dockerfile reference | Docker Docs
- Многоэтапные сборки в Docker: как уменьшить образ
- Smallest Golang Docker Image | Andrew Klotz
- Reducing Docker Image Sizes | Better Stack Community
- How to build a release version binary in Go? | Stack Overflow
- Securing Golang Binaries | DEV Community
- Image Build & Optimization | Ontitansshoulder
Начните с одного сервиса: переведите его Dockerfile на два этапа, замерьте размер до и после, прогоните сканер уязвимостей. Схема переносится в остальные сборки почти без изменений, а выигрыш виден уже на первой выкатке.