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-зависимости в продакшен.
Проверьте себя
1. Что из перечисленного НЕ попадёт в финальный образ multi-stage сборки?
AФайлы, скопированные инструкцией COPY --from из стадии builder
BКомпилятор и SDK, оставшиеся в промежуточной стадии builder и не скопированные через --from
CБаза финальной стадии (например, alpine или node:slim)
DСертификаты, явно скопированные COPY --from=builder /etc/ssl/certs/...
2. Почему Go-бинарь, собранный без CGO_ENABLED=0, может не запуститься в образе на базе scratch?
Ascratch не поддерживает инструкцию ENTRYPOINT, поэтому бинарь некому запустить
BБез CGO_ENABLED=0 бинарь может слинковаться динамически и зависеть от libc, которой в пустом scratch нет
Cscratch автоматически удаляет все бинарные файлы при старте контейнера из соображений безопасности
DCGO_ENABLED=0 нужен только для Node, на компиляцию Go этот флаг не влияет