Многостадийные сборки: жирный билдер, тонкий runtime и как COPY --from схлопывает 1.2 ГБ в 28 МБ
Многостадийная сборка компилирует в жирной стадии-билдере, затем тонкая runtime-стадия копирует только артефакт. BuildKit собирает лишь финальную цель и её зависимости, отбрасывает неиспользуемые стадии и шлёт образ в 28 МБ вместо 1.2 ГБ — без тулчейнов и исходников в слоях.
Команда безопасности заблокировала релиз: сканер нашёл 312 CVE в образе Go-сервиса, большинство — в gcc, git, make и куче -dev-заголовков, которых работающий бинарь не использует. Образ весил 1.2 ГБ. Разработчик был в недоумении: Go-программа — единственный статически слинкованный бинарь на 18 МБ. Откуда 1.2 ГБ? От Dockerfile, который тащил весь тулчейн сборки в прод: FROM golang:1.23, затем COPY . ., RUN go build — и это финальный образ: компилятор, кеш модулей, весь Debian-userland и исходники, запечённые в поставляемых слоях. Каждая из этих CVE сидела в софте, существующем лишь чтобы произвести бинарь, а не запускать его. Переписка заняла четыре строки: собрать в одной стадии, затем FROM gcr.io/distroless/static, COPY --from=build /app /app. Образ упал до 28 МБ, число CVE — до однозначных, а исходники перестали уезжать клиентам. В самой программе ничего не изменилось. Сборка просто перестала путать «что мне нужно, чтобы скомпилировать» с «что мне нужно, чтобы запустить».
Две задачи, две стадии
Спроси себя: нужен ли работающему контейнеру компилятор, которым он был собран? Почти никогда — но одностадийные сборки шлют обе среды в одних слоях. Одностадийная сборка смешивает две разные файловые системы: ту, что нужна чтобы собрать артефакт (компиляторы, пакетные менеджеры, заголовки, исходники, кеши модулей), и ту, что нужна чтобы запустить его (бинарь плюс его реальные runtime-зависимости). Многостадийность их разделяет. Вы пишете несколько стадий FROM в одном Dockerfile; последняя — образ, который вы шлёте, а ранние стадии существуют лишь чтобы произвести входы, которые финальная стадия копирует.
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build # ~840 МБ: компилятор, git, кеш модулей
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:nonroot AS runtime # база ~2 МБ
COPY --from=build /app /app # копируем ТОЛЬКО бинарь
USER nonroot
ENTRYPOINT ["/app"]COPY --from=build /app /app — это шарнир. Он заходит в финальную ФС стадии build и копирует лишь /app в runtime-стадию. Компилятор, 400-МБ кеш модулей, дерево исходников, git — ничто не пересекает границу. Поставляемый образ — это distroless-база плюс один слой бинаря на 18 МБ: 28 МБ всего, против 1.2-ГБ одностадийного. Тот же бинарь, в 40 раз меньше, и весь тулчейн сборки — каждый CVE-несущий dev-пакет — просто отсутствует в слоях, которые тянет клиент.
Собирается только целевая стадия (и её зависимости)
Второе свойство, которое упускают: docker build собирает финальную целевую стадию и только стадии, от которых она транзитивно зависит. Неиспользуемые стадии вырезаются из графа LLB и не выполняются. Так что можно держать стадию test и стадию lint в том же Dockerfile, а обычный docker build (с целью runtime-стадии) пропустит их полностью, если только что-то не сделает COPY --from их выхода. Можно также собрать конкретную стадию через --target build, чтобы получить debug-образ с полным тулчейном, или --target test в CI, чтобы прогнать тесты внутри графа. Один Dockerfile, несколько выходов, и прод-сборка не платит ни за одну стадию, которую не ссылает.
В Dockerfile стадия build (компилятор + исходники, ~840 МБ) и runtime-стадия, делающая COPY --from=build /app /app на 2-МБ distroless-базу. Насколько велик поставляемый образ и куда делся компилятор?
▸Почему это работает
Почему меньший образ важен сверх цифры на диске? Три накапливающихся причины. Поверхность атаки: каждый бинарь в образе — это то, что флагует сканер и может эксплуатировать атакующий; distroless-образ без шелла, пакетного менеджера и компилятора убирает целые классы пост-эксплуатации (нельзя curl | sh то, где нет ни curl, ни sh). Латентность пула: образ в 28 МБ холодно тянется на свежую ноду за доли секунды, тогда как 1.2 ГБ — за десятки секунд; умножьте на событие автоскейлинга, поднимающее 50 подов, — это разница между быстро и заметно медленно. И гигиена цепочки поставок: отправка исходников и .git в прод утекает интеллектуальную собственность и креды, которым не место за пределами сборки. Размер — прокси; на деле вы минимизируете всё, что не является программой.
Ловушка кеша при COPY из стадии
Многостадийность взаимодействует с кешированием так, что кусает людей. Вершина COPY --from=build /app runtime-стадии ключуется на дайджесте /app, как его произвела стадия build. Так что если компиляция в стадии build — попадание в кеш и даёт идентичный бинарь, то COPY --from тоже попадает, и runtime-слой переиспользуется. Но если сборка недетерминирована — встраивает таймстамп, случайный build ID, или Go-сборка варьируется — дайджест /app меняется каждую сборку, COPY --from промахивается всегда, и вы пушите новый runtime-слой в реестр каждый раз, даже когда ничего значимого не менялось. Та же контентная адресация, что делает кеш рабочим, делает невоспроизводимые сборки дорогими: воспроизводимая компиляция даёт финальному слою стабильное попадание — ровно об этом следующий урок о воспроизводимых сборках. Дисциплина: упорядочьте стадию build как любую другую (manifest до исходников) и держите артефакт детерминированным, чтобы межстадийное копирование могло кешироваться.
Dockerfile содержит стадии build, test и lint плюс runtime-стадию. CI запускает обычный docker build с целью runtime, который делает только COPY --from=build. Что происходит со стадиями test и lint?
- 01Пройди, как многостадийная сборка превращает 1.2-ГБ одностадийный Go-образ в ~28 МБ и что конкретно перестаёт шлаться.
- 02Объясни, какие стадии BuildKit реально собирает, и ловушку межстадийного кеша COPY --from.
Одностадийная сборка шлёт одну ФС, смешивающую сборку и запуск — компилятор, пакетный менеджер, кеш модулей, исходники и артефакт все в финальном образе, — поэтому единственный статически слинкованный 18-МБ Go-бинарь приезжает образом в 1.2 ГБ, несущим 300+ CVE в софте, который работающая программа никогда не трогает. Многостадийная сборка пишет несколько стадий FROM в одном Dockerfile: жирная стадия build компилирует артефакт, а отдельная тонкая runtime-стадия на минимальной базе (distroless, ~2 МБ) делает COPY —from=build /app /app и больше ничего. COPY —from копирует лишь названный путь из ФС ранней стадии; её слои никогда не дописываются к финальному образу, так что тулчейн, кеш модулей, git, dev-заголовки и исходники не пересекают границу. Результат — ~28 МБ вместо 1.2 ГБ — тот же бинарь, в ~40 раз меньше — с числом CVE в однозначных и исходниками, больше не утекающими клиентам, а меньшая поверхность вдобавок убирает шелл и пакетный менеджер, к которым потянулся бы атакующий, и режет латентность холодного пула при автоскейлинге. BuildKit это подкрепляет, собирая лишь целевую стадию и её транзитивные зависимости: стадия test или lint в том же файле вырезается, пока вы не нацелитесь на неё явно через —target, так что прод платит лишь за то, что ссылает. Единственная ловушка — кеш: runtime-стадия COPY —from ключуется на дайджесте артефакта, поэтому невоспроизводимая сборка, чей дайджест бинаря пляшет каждый прогон, промахивается мимо этого копирования каждый раз и пушит свежий слой реестра за нулевое реальное изменение — что и есть мост к удержанию сборок воспроизводимыми. Теперь, когда видишь Dockerfile — посчитай стадии FROM: одна стадия на прод почти всегда красный флаг; спроси, что попало в поставляемые слои.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.