Docker Multi-Stage Builds: компактные и безопасные образы

Автор: Обновлено

Что такое Docker multi-stage build?

Docker multi-stage build использует несколько стадий FROM в одном Dockerfile и копирует в финальный runtime только необходимые артефакты. Компиляторы, исходники, тестовые tools и package caches остаются за пределами публикуемого образа.

TL;DR

  • -Измерьте исходный образ до установки лимита: результат зависит от base image, native libraries и runtime assets
  • -Собирайте приложение в build stage, а в runtime переносите только файлы и зависимости, необходимые для запуска
  • -Расположите COPY вокруг lockfiles и используйте BuildKit cache mounts, чтобы правка кода не заставляла скачивать зависимости заново
  • -Не передавайте build secrets через ARG или ENV: используйте BuildKit secret или SSH mounts
  • -Тестируйте готовый образ под non-root, сканируйте его и публикуйте SBOM и provenance вместе с релизом

Полезный результат Docker multi-stage build — не эффектный процент в заголовке. Это финальный образ, состав которого команда может объяснить.

Builder нужны компилятор, headers, исходники, тесты и package caches. Запущенному сервису обычно нет. Разделите среды, перенесите в последнюю stage узкий набор артефактов и проверьте поведение контейнера в production-условиях.

Сначала измерьте исходный образ

Соберите текущий образ из чистого checkout и запишите baseline:

docker build --pull -t example-api:baseline .
docker image inspect example-api:baseline \
  --format '{{.Size}} {{.Config.User}} {{json .Config.Entrypoint}} {{json .Config.Cmd}}'
docker history --human --no-trunc example-api:baseline

Запустите сервис и smoke test. Одного размера мало. Зафиксируйте:

  • compressed registry size и локальный unpacked size;
  • cold pull time в среде деплоя;
  • время сборки с пустым и тёплым cache;
  • startup и health check;
  • user, entrypoint, архитектуру и обязательные runtime-файлы;
  • результаты vulnerability scan для OS и application packages.

Не копируйте чужую цель «меньше 100 MB». У сервиса с браузером, media library, CA bundle или native runtime другой минимум, чем у статического Go binary.

Что именно убирает multi-stage build

Каждый FROM начинает новую stage. Финальный образ содержит layers последней stage и только файлы, явно скопированные из предыдущих.

# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev

FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app

COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist

USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Используйте версию языка, поддерживаемую приложением, и pin через обычный процесс обновления зависимостей. Плавающий tag сам по себе не обеспечивает ни воспроизводимость, ни своевременные патчи.

В примере TypeScript, исходники, npm cache и build tools остаются в build stage. Один base family у build и runtime снижает риск проблем с native Node modules.

Перед копированием проверьте предположения:

  • Нужны ли сборке schema, assets или workspace files, которых нет в примере?
  • Читает ли приложение package.json во время работы?
  • Компилируют ли post-install scripts native modules?
  • Выполняются ли migrations отдельной deployment job, а не при каждом старте?
  • Пишет ли сервис за пределами volume или /tmp?

Оптимизированный Dockerfile без нужных runtime assets остаётся сломанным.

Добавьте test stage, но не публикуйте тесты

Multi-stage build держит проверку рядом со сборкой и не переносит test tools в финальный образ:

FROM build AS test
COPY test ./test
RUN npm test

FROM runtime AS production

В CI сначала соберите test, затем production:

docker build --target test -t example-api:test .
docker build --target production -t example-api:${GIT_SHA} .

Не передавайте credentials или production data в test stage. Intermediate stages и build cache могут экспортироваться или оставаться доступными для инспекции.

Управляйте инвалидацией cache

Docker повторно использует layer, если инструкция и зависимые от неё файлы не изменились. Частая ошибка — volatile source tree до дорогой установки:

COPY . .
RUN npm ci

Любая правка исходника может инвалидировать install. Сначала скопируйте manifests, установите зависимости, затем добавьте файлы для компиляции:

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

Cache mount сохраняет загруженные packages, даже когда install layer приходится выполнить снова. Этот cache не попадает в финальный образ.

Для чистых CI workers экспортируйте cache в registry или поддерживаемый CI backend. Ограничьте write access: poisoned shared cache — supply-chain risk, а не просто медленная сборка.

Сузьте build context

.dockerignore определяет, какие локальные файлы вообще могут войти в context. Начните с крупных или чувствительных путей:

.git
node_modules
dist
coverage
.env
.env.*
*.log

Не вставляйте агрессивный шаблон вслепую. Исключённые tsconfig.json, migrations, licenses или static assets способны сделать build неполным. Чистая сборка в CI не даст случайному файлу на машине разработчика скрыть проблему.

В монорепозитории named contexts и bind mounts могут дополнительно сузить input. Правило одно: у каждого COPY должна быть причина.

Выберите runtime base по совместимости

Меньше — хорошо только после того, как приложение запускается и остаётся поддерживаемым.

Runtime baseКогда подходитЦена эксплуатации
Distribution slimNative packages, привычная отладкаБольше OS packages для patching
AlpineНагрузка проверена с muslСовместимость native dependencies
DistrolessФиксированный runtime, внешняя отладкаНет package manager и обычного shell
scratchSelf-contained static binaryВсе нужные файлы копируются явно

Проверьте DNS, TLS certificates, time zones, fonts, native extensions, process signals и архитектуру на той же платформе, что production. Не переносите glibc артефакты в musl runtime или amd64 binary в arm64 в надежде, что контейнер это исправит.

Если для инцидентов нужны tools, сделайте отдельный debug target. Shell, curl и compiler во всех production containers сводят на нет разделение runtime.

Запускайте процесс не от root

USER не решает все проблемы безопасности контейнера, но убирает лишний опасный default.

Права файлов должны соответствовать runtime user. COPY --chown предпочтительнее последующего recursive chown, создающего ещё один крупный layer. Где возможно, тестируйте read-only root filesystem и монтируйте только обязательные writable paths.

За пределами Dockerfile всё ещё нужны platform controls:

  • удалить лишние Linux capabilities;
  • запретить privilege escalation;
  • установить CPU и memory limits;
  • включить read-only root filesystem;
  • монтировать secrets во время запуска, а не встраивать в image;
  • применить network и admission policy.

Не оставляйте secrets в layers и metadata

ARG и ENV не подходят для build credentials. Secret способен остаться в metadata, history или cache, даже если следующий layer удалил файл.

Используйте BuildKit secret mounts:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
    --mount=type=cache,target=/root/.npm \
    npm ci
docker build --secret id=npmrc,src="$HOME/.npmrc" -t example-api .

Для private Git dependencies используйте SSH mount вместо копирования private key. Открывайте secret на одну RUN instruction и проверяйте, что его нет в артефакте.

Проверяйте artifact, а не внешний вид Dockerfile

Аккуратный Dockerfile всё равно может публиковать лишние или неверные файлы. Проверяйте реальный image:

docker buildx build --check .
docker run --rm --read-only --tmpfs /tmp \
  --cap-drop=ALL example-api:${GIT_SHA} node dist/healthcheck.js
docker scout cves example-api:${GIT_SHA}

Trivy, Grype или registry scanner тоже подходят. Определите, какие severity, exploitability, наличие fix и возраст exception блокируют релиз. «Ноль findings» редко бывает устойчивой policy; нужен документированный triage.

При публикации создавайте supply-chain metadata:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --provenance=mode=max \
  --sbom=true \
  --tag registry.example.com/example-api:${GIT_SHA} \
  --push .

SBOM перечисляет компоненты образа. Provenance описывает, как он собран. Подпись и verification должны соответствовать deployment policy; attestations дают доказательства, но сами ничего не запрещают.

Не допускайте незаметного роста

CI должен сравнивать image с принятым baseline, а не с универсальным потолком. Следите за:

  • compressed image size и изменением к прошлому релизу;
  • добавленными и удалёнными packages;
  • digest базового образа;
  • набором архитектур;
  • runtime user и entrypoint;
  • результатом vulnerability и license policy;
  • smoke test под production-like restrictions.

Блокируйте необъяснимую регрессию. Если увеличение оправдано, обновляйте baseline отдельным reviewed change. Новый набор fonts может быть нужен. Случайно опубликованный build cache — нет.

Checklist

  • Build и runtime stages отвечают за разные задачи.
  • В финальную stage переходят только необходимые artifacts.
  • Builder и runtime совместимы для native dependencies.
  • Lockfiles копируются раньше часто меняющихся исходников.
  • Build caches ускоряют сборку, но не входят в image.
  • .dockerignore исключает secrets и локальный build output.
  • Build credentials используют secret или SSH mounts, не ARG и ENV.
  • Процесс работает под non-root, writable paths объявлены.
  • CI собирает из чистого checkout и тестирует готовый image.
  • У релиза есть scan result, SBOM, provenance и reviewed exception path.

Первоисточники

Multi-stage build работает, когда финальный image содержит известный runtime, известные артефакты приложения и ничего, что потребовалось только для их сборки.

Часто задаваемые вопросы

Каждому production-образу нужен Alpine?
Нет. Alpine использует musl libc, тогда как многие готовые native-зависимости рассчитаны на glibc. Debian slim, distroless или специализированный runtime могут быть безопаснее. Сравнивайте совместимость, обновления безопасности и измеренный размер, а не выбирайте автоматически самый маленький base.
Уменьшает ли образ удаление файла в следующем Docker layer?
Не обязательно. Файл, созданный в раннем layer, остаётся в нём даже после удаления следующей инструкцией. Удаляйте временные package lists в том же RUN или выполняйте сборку в отдельной stage, которая не попадёт в финальный образ.
Можно ли скопировать node_modules из Alpine builder в Debian runtime?
Не рассчитывайте на это. Native modules могут быть собраны под другую libc или архитектуру. Используйте совместимые builder и runtime либо устанавливайте production-зависимости в stage на том же base image, что и runtime.