MAATRIX / Блог / Docker multi-stage build: оптимизация образа

Docker multi-stage build: оптимизация образа

Docker multi-stage build: оптимизация образа

MAATRIX

Если ваш образ приложения весит гигабайт с лишним, а сам код — несколько мегабайт, дело почти всегда в том, что в финальный образ попало то, что нужно было только для сборки: компилятор, dev-зависимости, кэш пакетного менеджера, исходники тестов. Multi-stage build — стандартный способ Docker разделить «где собираем» и «что запускаем», и получить в разы более лёгкий и безопасный образ без единого лишнего файла. Ниже — как это устроено и конкретные примеры до/после.

Проблема: почему обычный образ получается тяжёлым

Классический однослойный Dockerfile обычно выглядит так: берём базовый образ языка, ставим зависимости, собираем приложение, запускаем его же командой. Проблема в том, что базовый образ для сборки — это далеко не то же самое, что нужно для запуска.

Для сборки Node.js-приложения нужен npm, все devDependencies (сборщики, линтеры, тестовые фреймворки), кэш пакетов и, часто, сами исходники до компиляции. Для сборки Go-бинарника нужен весь тулчейн компилятора — это тяжёлая штука сама по себе. А для *запуска* готового приложения из всего этого не нужно почти ничего: Node.js нужен только рантайм и продакшн-зависимости, а Go-бинарнику после статической линковки не нужен вообще никакой тулчейн — только сам исполняемый файл.

Если собирать и запускать в одном и том же образе, все эти сборочные артефакты остаются в финальных слоях навсегда — даже если вы их потом rm -rf. Слои Docker иммутабельны: файл, добавленный на одном слое и удалённый на следующем, физически всё равно лежит в образе, просто помечен как удалённый. Единственный надёжный способ не тащить лишнее — не давать ему попасть в финальный образ вообще. Именно для этого и придумали multi-stage build.

Как устроен multi-stage build

Идея простая: в одном Dockerfile может быть несколько инструкций FROM, и каждая начинает новую независимую стадию. У стадии может быть имя (AS build), и на неё можно ссылаться из последующих стадий. Ключевая инструкция — COPY --from=<стадия>, которая копирует конкретные файлы (не весь образ, а только нужные артефакты) из одной стадии в другую.

FROM golang:1.22 AS build
# здесь всё тяжёлое: компилятор, зависимости, исходники

FROM alpine:3.20
COPY --from=build /app/app /app/app
# здесь только готовый бинарник

Docker собирает каждую стадию независимо, но в финальный образ попадает только содержимое последней стадии (той, что указана docker build по умолчанию, или через --target). Промежуточные стадии в финальный образ не входят — они существуют только на время сборки и участвуют в кэшировании слоёв. Это значит, что весь компилятор, весь node_modules с dev-зависимостями, весь кэш пакетного менеджера можно спокойно использовать на промежуточной стадии — они просто не доедут до конечного образа.

Стадий может быть сколько угодно: например, отдельная стадия под тесты, отдельная под сборку фронтенда, отдельная под сборку бэкенда, и одна финальная, которая собирает всё вместе через несколько COPY --from.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Пример: Node.js-приложение до и после

Обычный (однослойный) Dockerfile для типичного Node.js-сервера с шагом сборки (TypeScript, бандлер и т.д.):

FROM node:20

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

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

Здесь в финальном образе остаются: полный node:20 (включает npm, инструменты сборки нативных модулей, компиляторы для C++-аддонов), все devDependencies из npm install (сборщик, линтер, тестовый фреймворк — они нужны были только на шаге npm run build), кэш npm и полные исходники до компиляции вдобавок к собранному dist/. Приложению из всего этого для работы нужен только сам dist/ и продакшн-зависимости.

Версия с multi-stage:

# Стадия 1: сборка
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Стадия 2: финальный образ
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist

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

Что изменилось: сборка (npm ci, npm run build) идёт в тяжёлом node:20, а в рантайм-стадию переезжает только dist/ — уже скомпилированный код — и заново ставятся исключительно продакшн-зависимости (npm ci --omit=dev) поверх лёгкого node:20-alpine. Исходники, dev-зависимости, кэш сборки и весь тулчейн остаются в промежуточной стадии и не попадают в конечный образ. На практике разница между таким «до» и «после» обычно измеряется сотнями мегабайт — конкретная цифра зависит от вашего проекта и набора зависимостей, но направление всегда одно: заметно легче.

Пример: Go-приложение — разница ещё нагляднее

Для компилируемых языков эффект multi-stage build особенно заметен, потому что готовому бинарнику вообще не нужен тулчейн сборки. Обычный вариант:

FROM golang:1.22

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app .

CMD ["./app"]

Образ golang:1.22 сам по себе тяжёлый — в нём полный набор компилятора и инструментов Go, плюс в финальном образе остаются исходники и скачанные модули. Всё это ради того, чтобы в итоге запускать один исполняемый файл.

Multi-stage версия, причём для статически собранного Go-бинарника можно взять почти пустой финальный образ:

FROM golang:1.22 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o app .

FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=build /app/app .

CMD ["./app"]

При CGO_ENABLED=0 бинарник получается статическим, без зависимости от системных библиотек, и его можно скопировать даже в FROM scratch — образ без единого пакета вообще, только сам файл:

FROM scratch
COPY --from=build /app/app /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]

Разница между «весь тулчейн Go плюс исходники» и «один бинарник в пустом образе» — это уже не десятки, а сотни мегабайт, а иногда и больше гигабайта, в зависимости от того, сколько зависимостей у проекта. Точные цифры у вас будут свои — но направление и порядок величины именно такие.

Продвинутые приёмы

--target для сборки конкретной стадии. Если в Dockerfile несколько стадий (например, build, test, runtime), можно собрать только нужную:

docker build --target test -t myapp:test .
docker build --target runtime -t myapp:latest .

Это удобно для CI: одна стадия гоняет тесты в окружении с полным тулчейном, другая собирает лёгкий продакшн-образ, и обе описаны в одном файле без дублирования.

Кэш зависимостей отдельным слоем. В обоих примерах выше файлы зависимостей (package.json, go.mod) копируются и устанавливаются *до* копирования остального кода. Это не про размер образа, а про скорость пересборки: пока зависимости не менялись, Docker переиспользует закэшированный слой с npm ci / go mod download, даже если поменялся только код приложения.

Сборка из внешнего образа как источника файлов. COPY --from работает не только между своими стадиями, но и с любым готовым образом — полезно, чтобы забрать бинарник утилиты без установки её через пакетный менеджер:

COPY --from=golang:1.22 /usr/local/go/bin/gofmt /usr/local/bin/gofmt

Несколько параллельных стадий сборки. Если бэкенд на Go, а фронтенд на Node.js, можно собрать оба независимо и объединить в одной финальной стадии — например, отдать статику фронтенда через nginx, а бэкенд запустить рядом или в соседнем сервисе docker-compose.

Частые ошибки при multi-stage build

  • Копирование лишнего из стадии сборки. COPY --from=build /app /app вместо COPY --from=build /app/dist ./dist — самая частая ошибка, из-за которой весь смысл multi-stage теряется: в финальный образ снова уезжают исходники и node_modules.
  • npm install вместо npm ci --omit=dev в финальной стадии. Если после копирования собранного кода вы снова ставите зависимости без --omit=dev (или без флага --production в старых версиях npm), в образ опять попадают dev-зависимости.
  • Отсутствие .dockerignore. Без него в контекст сборки (а иногда и в промежуточные слои) попадают node_modules, .git, локальные .env — это раздувает и время сборки, и риск случайно утащить секреты в образ.
  • Забытые системные зависимости в рантайм-образе. Если бинарник или приложение обращается к системным библиотекам, сертификатам (ca-certificates) или временной зоне, а финальная стадия — alpine или scratch без них, приложение упадёт при обращении к HTTPS или при работе с датами. Добавляйте только то, что реально нужно рантайму, но не забывайте про это «нужное».
  • Секреты в аргументах RUN. ARG и RUN с токенами или паролями всё равно остаются в истории слоёв промежуточной стадии и потенциально извлекаемы через docker history, даже если стадия не попала в финальный образ. Для секретов на сборке используйте RUN --mount=type=secret (BuildKit) вместо ARG.
  • Один тег latest для базовых образов. Использование FROM node:latest или FROM golang:latest без фиксированной версии делает сборки невоспроизводимыми — образ через месяц может собраться из другой версии рантайма. Указывайте конкретную мажорную версию, как в примерах выше.

Если после чистки Dockerfile образы всё равно постепенно съедают место на сервере, это уже не про Dockerfile, а про накопленный мусор — старые образы, тома, логи; про это отдельно есть разбор Docker: занимает всё место на диске.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать VPS

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Обязательно ли называть стадии через AS?

Нет, стадии можно адресовать и по номеру (COPY --from=0), но именование через AS build, AS runtime делает Dockerfile читаемым и устойчивым к добавлению новых стадий — номера при вставке новой стадии посередине сдвигаются, а имена нет.

Можно ли использовать multi-stage build с docker-compose?

Да, docker-compose.yml просто указывает build.context и опционально build.target, если нужна конкретная стадия. Сама многостадийность целиком описана в Dockerfile, compose её не ограничивает — подробнее про продакшн-настройку есть в статье Docker Compose для продакшена.

Уменьшает ли multi-stage build количество слоёв в принципе?

Косвенно да, потому что в финальный образ попадают только слои последней стадии, но сама по себе многостадийность — это про то, *какие* файлы окажутся в образе, а не про их количество. Число слоёв в финальной стадии по-прежнему зависит от числа инструкций RUN/COPY в ней.

Как проверить, что получилось легче на самом деле?

docker images покажет итоговый размер, а docker history <образ> — размер каждого слоя. Сравните тот же образ, собранный до и после перехода на multi-stage, — разница будет видна сразу в колонке SIZE.

Нужен ли multi-stage build для интерпретируемых языков без шага сборки, например простого Python-скрипта?

Эффект меньше, но не нулевой: даже без компиляции можно вынести установку зависимостей с C-расширениями (которая требует компилятора) в отдельную стадию и скопировать в финальную только готовые пакеты — компилятор в рантайме не нужен.