Multi-stage builds: маленькие образы
Образ, в котором лежат компилятор, заголовочные файлы и весь SDK, нужен ровно один раз — в момент сборки. В продакшене он только занимает место, замедляет деплой и расширяет поверхность атаки. Multi-stage builds решают эту проблему элегантно: одна стадия собирает артефакт, другая — кладёт в финальный образ только его.
Multi-stage build — это Dockerfile с несколькими инструкциями
FROM, где промежуточные стадии собирают артефакт, а финальная стадия копирует из них только готовый результат, отбрасывая весь инструментарий сборки.
Представьте типичный образ Go-приложения, собранный «в лоб»: базовый golang тянет за собой компилятор, стандартную библиотеку, систему модулей и кучу утилит — в сумме под гигабайт. А ведь скомпилированный бинарь Go самодостаточен и весит мегабайты. Получается, 99% образа — это балласт, который никогда не выполнится в рантайме. То же с Node: devDependencies, исходники TypeScript, тулчейн сборки нужны, чтобы получить dist, но в продакшене бесполезны.
Чем это плохо на практике? Во-первых, размер: большой образ дольше выкачивается на каждую ноду кластера, дольше пушится в реестр, занимает место в кеше. Во-вторых, безопасность: каждый лишний пакет — это потенциальная уязвимость; компилятор и shell внутри контейнера — подарок атакующему. В-третьих, скорость деплоя: при тысяче перезапусков в день экономия сотен мегабайт на образ складывается в часы. Цель multi-stage — финальный образ, состоящий только из того, что реально нужно для работы.
Стадии сборки
Раньше, до появления multi-stage, разработчики держали два Dockerfile (один «толстый» для сборки, другой «тонкий» для рантайма) и связывали их хрупкими скриптами. Сейчас всё умещается в один файл.
Синтаксис FROM ... AS и COPY --from
Каждая инструкция FROM начинает новую стадию. Стадии можно именовать через AS:
FROM golang:1.22 AS builder
# здесь живут компилятор и все зависимости сборки
FROM alpine:3.20
# а это финальный, «чистый» образ
COPY --from=builder /app/server /usr/local/bin/server
Ключевая инструкция — COPY --from=builder. Она говорит: «не бери файл из контекста сборки, а возьми его из файловой системы стадии builder». Источником может быть имя стадии, её порядковый номер (--from=0) или даже внешний образ. Всё, что осталось в стадии builder и не было скопировано, в финальный образ не попадёт.
Пример: Go
Go компилируется в один статический бинарь — идеальный кандидат для multi-stage. Собираем в полноценном golang, а кладём в scratch (абсолютно пустой образ) или alpine:
FROM golang:1.22 AS builder
WORKDIR /app
# Сначала только манифесты — слой кешируется,
# пока go.mod/go.sum не изменились
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 — статическая линковка, без зависимости от libc
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/server
# --- Финальная стадия ---
FROM scratch
# сертификаты нужны для HTTPS-запросов наружу
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/bin/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
Финальный образ содержит ровно два файла: бинарь и пачку корневых сертификатов. Ни компилятора, ни shell, ни пакетного менеджера — атаковать внутри попросту нечего.
Пример: Node
С Node-приложением (например, на TypeScript или с бандлером) логика та же: одна стадия ставит все зависимости и собирает dist, финальная — берёт node:slim и только продакшен-зависимости.
FROM node:20 AS build
WORKDIR /app
# npm ci ставит зависимости строго по package-lock.json
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Отдельно ставим ТОЛЬКО продакшен-зависимости
RUN npm ci --omit=dev
# --- Финальная стадия ---
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
# из стадии build берём собранный код и prod-модули
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]
Здесь мы дважды вызвали npm ci: первый раз — со всеми зависимостями, чтобы прошла сборка; второй — с флагом --omit=dev, чтобы получить «чистый» node_modules без линтеров, типов и тестовых фреймворков. В финал едет только он.
Как это работает под капотом
Каждая стадия — это отдельный граф слоёв со своим базовым образом. Docker собирает их сверху вниз, но в финальный образ превращается только последняя стадия (или та, что указана в --target). Промежуточные стадии существуют лишь во время сборки: их слои используются как источник для COPY --from, но в итоговый манифест образа не входят. Поэтому весит финальный образ ровно столько, сколько весят его собственная база плюс скопированные файлы.
Иногда нужно собрать не до конца, а остановиться на промежуточной стадии — например, чтобы прогнать тесты в окружении со всеми зависимостями. Для этого есть флаг --target:
# собрать только до стадии builder
docker build --target builder -t myapp:builder .
# собрать полностью и сравнить размеры
docker build -t myapp:slim .
docker images | grep myapp
Вывод:
REPOSITORY TAG IMAGE ID SIZE myapp builder a1b2c3d4e5f6 1.2GB myapp slim f6e5d4c3b2a1 15MB
Разница говорит сама за себя: 1.2 ГБ против 15 МБ — почти в восемьдесят раз меньше, и это при идентичной функциональности.
Ещё одна возможность — копировать не из своей стадии, а из чужого образа прямо из реестра. Так удобно вытащить готовый конфиг или утилиту, не собирая её самому:
# взять дефолтный конфиг прямо из официального образа nginx
COPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/nginx.conf
Частые ошибки
Multi-stage прощает не всё. Вот на чём спотыкаются чаще всего:
- Копируют всё, включая SDK. Если в финале написать
COPY --from=builder /app /appцеликом, туда уедут и исходники, и кеши сборки. Копируйте точечно — только артефакт. - Забывают CGO_ENABLED=0 для scratch. По умолчанию Go может слинковаться динамически и потянуть зависимость от
libc. Вscratchникакой libc нет, и бинарь упадёт с загадочным «no such file or directory». Лечится статической линковкой черезCGO_ENABLED=0. - Тащат node_modules с dev-зависимостями. Скопировать
node_modules, оставшийся после полногоnpm ci, — значит унести в продакшен сотни мегабайт инструментов сборки. Делайте отдельныйnpm ci --omit=devперед копированием. - Не используют AS-имена. Ссылка
COPY --from=0по номеру стадии ломается, стоит вставить новую стадию в начало файла — нумерация съезжает. Имена черезASустойчивы к перестановкам. - Забывают про сертификаты и timezone в scratch. Пустой образ не содержит ничего: ни корневых CA для HTTPS, ни базы часовых поясов. Их нужно явно скопировать из стадии-сборщика, иначе исходящие TLS-соединения будут отваливаться.
Итоги
- Multi-stage build — это один Dockerfile с несколькими
FROM; промежуточные стадии собирают, финальная — только забирает артефакт. - Стадии именуются через
FROM ... AS name, а файлы переносятся инструкциейCOPY --from=name. - Go компилируют в
golang AS builderсCGO_ENABLED=0и кладут бинарь вscratchилиalpine; Node собирают в полном образе, а в финал везутdistиnode_modulesбез dev-зависимостей на базеnode:slim. - В финальный образ попадают только скопированные через
--fromфайлы — промежуточные стадии в нём не остаются, отсюда и разница в десятки раз (1.2 ГБ против 15 МБ). - Флаг
--targetостанавливает сборку на нужной стадии;COPY --fromумеет тянуть файлы и из внешних образов реестра. - Главные грабли — копировать лишнее, забыть статическую линковку для
scratchи тащить dev-зависимости в продакшен.