Docker multi-stage build: оптимизация образа
Если ваш образ приложения весит гигабайт с лишним, а сам код — несколько мегабайт, дело почти всегда в том, что в финальный образ попало то, что нужно было только для сборки: компилятор, 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-расширениями (которая требует компилятора) в отдельную стадию и скопировать в финальную только готовые пакеты — компилятор в рантайме не нужен.