Многоэтапная сборка в Dockerfile: как уменьшить размер образа и убрать лишнее | AdminWiki

Многоэтапная сборка в Dockerfile: как уменьшить размер образа и убрать лишнее

26 сентября 2026 12 мин. чтения
Содержание статьи

Многоэтапная сборка (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.js1,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 автоматически;
  • размер образа укладывается в заданный порог.

Источники

Начните с одного сервиса: переведите его Dockerfile на два этапа, замерьте размер до и после, прогоните сканер уязвимостей. Схема переносится в остальные сборки почти без изменений, а выигрыш виден уже на первой выкатке.

Поделиться: