Знакомая для многих картина: вы написали микросервис, набросали классический Dockerfile из четырёх команд, запустили сборку и получили на выходе Docker-образ 1 ГБ, а иногда и больше. В единичном случае это не кажется проблемой, но этот гигабайт гоняется по сети десятки раз в день. Он качается при каждом коммите в CI, при развёртывании эфемерных preview-окружений и деплое в облачные кластеры. Для разработчиков, которые работают в нелокальных средах, утяжелённые образы — это потерянные минуты команды на ожидание пайплайнов и дополнительные расходы на трафик и хранение. 

Вместе с лишними гигабайтами в финальный образ попадают лишние системные пакеты, инструменты сборки, компиляторы и утилиты, которые могут принести с собой более 150 разных уязвимостей. Обновление пакетов помогает не всегда: часть зависимостей лучше вообще не включать в финальный образ. 

Эта статья — практическое руководство, в котором мы расскажем, как уменьшить размер Docker-образа с помощью multi-stage сборок без переписывания кода приложения.

С подготовкой статьи помог:

Андрей Хомутов

Эксперт-разработчик Центра компетенций по разработке мультимодальных ИИ-решений в Ростелеком ИТ, дипломный руководитель в Нетологии (Python, JS)

TL;DR
  • Проблема: тяжёлые образы замедляют доставку кода, увеличивают расходы на инфраструктуру и могут содержать лишние системные зависимости.

  • Решение: multi-stage builds разделяют этап сборки, где нужны компиляторы и инструменты разработки, и этап запуска, где остаётся только runtime-окружение.

  • Результат: на примере Node.js-приложения покажем, как уменьшается Docker-образ без переписывания кода. 

  • Бонус: шаблон Dockerfile, который можно адаптировать под свой проект.

Почему Docker-образ может весить как операционная система

Разберём типичный Dockerfile для Node.js-приложения и посмотрим, из чего складываются лишние мегабайты. Вот типичный набор слоёв, который оказывается внутри контейнера, если собрать его одной командой COPY . . и обычным npm install:

  • Полновесная ОС в базе (~1 ГБ). Популярный базовый Docker-образ FROM node:22 (или любой другой актуальный LTS) по умолчанию строится на базе полноценного дистрибутива Debian. В нём развёрнуто всё: от утилит вроде curl, wget и git до менеджера пакетов apt и системных библиотек, о существовании которых ваше приложение даже не подозревает. Аналогичная проблема преследует и другие экосистемы, если использовать полные образы вместо специализированных (вроде python:slim).

  • Dev-зависимости (сотни МБ). Для разработки и тестирования вам необходимы typescript, eslint, jest, prettier и nodemon. Но в продакшене, когда код уже скомпилирован в чистый JavaScript, эти инструменты не выполняют ни одной строчки полезной работы. Тем не менее они упаковываются и отправляются на прод внутри папки node_modules.

  • Инструменты сборки. Если в зависимостях проекта есть нативные C++-модули, при установке активируется node-gyp. Ему для работы нужны python3, make и g++. После сборки эти инструменты не используются, но продолжают занимать место.

  • Кэш пакетного менеджера (~100–200 МБ). Команда RUN npm install оставляет за собой огромный локальный кэш пакетов в домашней директории пользователя Docker. Если не оптимизировать кэш Docker-слоёв, он остаётся в истории образа.

  • Лишние файлы из репозитория. Тесты (*.spec.ts), конфигурации линтеров (.eslintrc), файлы документации, README.md и даже скрытая папка .git часто копируются в образ целиком, если не был настроен файл .dockerignore.

Есть три основных причины, по которым лишний вес образов становится проблемой для бизнеса и разработки: 

  1. Замедление деплоя — холодный старт. В современных облачных средах контейнеры динамичны. При автомасштабировании под нагрузкой или при перезапуске упавшего сервиса оркестратор должен вытянуть образ из Registry. Прокачать 1,2 ГБ по сети — это время. Вместо мгновенного поднятия инстанса за 2 секунды команда получает холодный старт на полминуты-минуту.

  2. Перегрузка Registry и скрытые расходы. Каждый коммит в CI/CD генерирует новый тег образа. Хранение десятков версий тяжёлых артефактов стремительно забивает диски приватного Docker Registry и раздувает счета за облачное хранилище.

  3. Риски безопасности — CVE. Часть уязвимостей в контейнерах связана не с кодом приложения, а с пакетами базового образа и системными утилитами. Оставляя в продакшен-контейнере curl, bash, python или старые версии системных утилит, вы можете своими руками расширять поверхность атаки и упрощать эксплуатацию уязвимостей.

Для примера посмотрим на замеры времени, которые мы получили для проекта весом 1,2 ГБ в типичном CI/CD-пайплайне на удалённом сервере (средняя скорость сети ~250 Мбит/с на скачивании и загрузке). Конкретные числа будут зависеть от железа и канала, но порядок величин показателен:

Этап пайплайна

Время выполнения

Что происходит на самом деле

docker build

~150 сек

Скачивание тяжёлой базы, установка всех devDependencies, компиляция нативных модулей без кэша.

docker push

~40 сек

Транспортировка 1,2 ГБ из CI-воркера в удалённый Container Registry.

docker pull

~35 сек

Скачивание огромного образа целевым сервером перед командой запуска.

Итого на деплой

~3,5–4 минуты

Почти четыре минуты ожидания команды на каждый рабочий коммит только из-за веса артефакта.

Идея multi-stage за одну минуту

Чтобы понять, как работает multi-stage Docker, можно представить процесс как конвейер. Суть технологии multi-stage builds проста: вы используете несколько инструкций FROM в одном-единственном Dockerfile. Каждая инструкция FROM обнуляет контекст и запускает новый, чистый этап сборки с нового базового образа.

Так вы можете изолировать тяжёлый процесс компиляции от этапа запуска приложения, перенося между ними только готовый результат.

  • Этап 1 — Builder. Берём тяжёлый образ со всеми компиляторами, исходниками, тестами и пакетными менеджерами. Собираем проект, компилируем бинарник или минифицируем JS-скрипты.

  • Этап 2 — Runtime. Берём пустой или максимально легковесный образ, например, на базе Docker Alpine.

  • Ключевой принцип. С помощью команды COPY --from Docker забирает из первого этапа только скомпилированный продакшен-артефакт. Все лишние системные файлы, кэш и инструменты сборки автоматически выбрасываются и не попадают в финальные Docker-слои.

Вот как выглядит этот каркас в коде:

Dockerfile
# === ЭТАП 1: Сборка (Называем его builder) ===
FROM node:22 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build  # Результат сборки складывается в папку /app/dist

# === ЭТАП 2: Финальный продакшен-образ ===
FROM node:22-alpine AS runtime
WORKDIR /app
# Забираем из этапа builder ТОЛЬКО готовый продакшен-код
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm install --only=production

CMD ["node", "dist/main.js"]

В итоге в ваш Container Registry и на прод уходит только то, что находится в этапе runtime. Всё, что происходило в builder, остаётся на машине сборщика и стирается после завершения пайплайна. Дополнительные приёмы — например, сборка отдельных target-этапов и копирование файлов из внешних образов — описаны в документации Docker по multi-stage builds.

Уменьшаем Node.js-образ с 1,2 ГБ до 50 МБ

Разберём на практике, как уменьшить Docker-образ Node.js, превратив этот процесс в последовательность контролируемых шагов. На каждом этапе мы будем делать замер и смотреть, какая именно техника сработала.

Вот манифест, с которого мы начинаем:

Dockerfile
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main.js"]

Начальный замер: 1,2 ГБ.

Шаг 1. Разделяем сборку и рантайм — Multi-stage. Применим многоэтапную сборку, разделяя окружение компиляции и среду выполнения.

Dockerfile
# Этап 1: Сборка артефакта
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
RUN npm prune --production
# Финальный рантайм
FROM node:22
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./

CMD ["node", "dist/main.js"]

Из финального образа полностью исчезли .ts-файлы, компиляторы и devDependencies (благодаря команде npm prune). Мы перенесли через границу этапов только скомпилированный JS и runtime-зависимости.

Новый замер: ~320 МБ.

Шаг 2. Меняем базовый образ рантайма. В качестве рантайма во втором FROM всё ещё используется тяжёлый базовый образ на Debian. Заменяем его на дистрибутив Alpine Linux, оптимизированный под облачную инфраструктуру (используем образ node:alpine).

Dockerfile
# ... (Этап 1 builder остаётся прежним) ...

# Этап 2: Финальный рантайм на базе Alpine Linux
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./

CMD ["node", "dist/main.js"]

Для понимания масштаба посмотрите на официальные образы Node.js без вашего кода в разрезе Docker Alpine vs Debian slim:

Имя образа

Базовая ОС

Вес «из коробки»

Для чего подходит

node:22

Debian (Full)

~1,1 ГБ

Локальная разработка, сложные нативные сборки.

node:22-slim

Debian (Slim)

~240 МБ

Если критична стопроцентная совместимость с glibc.

node:22-alpine

Alpine Linux

~150 МБ

Если важен небольшой размер образа и зависимости приложения совместимы с musl libc.

Alpine Linux использует лёгкую библиотеку musl libc вместо тяжёлой glibc, а вместо привычного набора GNU-утилит — компактный busybox. Так мы убрали около 900 МБ лишнего системного окружения.

Новый замер: ~110 МБ.

Шаг 3. Чистим зависимости и пользуемся .dockerignore. В пайплайне осталась скрытая проблема: команда COPY . . на первом этапе тащит в контейнер локальный кэш разработчика, логи, локальную папку node_modules и скрытые файлы вроде .git. Настраиваем игнорирование.

Создаём файл .dockerignore в корне проекта:

Plaintext
node_modules
dist
.git
.gitignore
*.log
README.md
tests/

Модифицируем шаг установки в Dockerfile: заменяем npm install на npm ci. Эта команда устанавливает зависимости строго по package-lock.json, не меняет лок-файла, а при любом расхождении версий прерывает сборку ошибкой. Это гарантирует, что в Docker-образ попадут ровно те версии пакетов, которые тестировались локально.

Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
RUN npm prune --production
# ... (далее без изменений) ...

Лишние файлы больше не попадают в контекст сборки, а npm ci жёстко следует package-lock.json и не пытается обновлять пакеты на лету, сокращая объём временного мусора.

Новый замер: ~75 МБ.

Шаг 4. Оптимизируем порядок слоёв и BuildKit cache mounts. При любом изменении хотя бы одного символа в коде Docker сбрасывает кэш и заново скачивает все npm-пакеты. Разделяем копирование файлов, чтобы изолировать слой с зависимостями, и подключаем временный кэш-маунт, который предоставляет Docker BuildKit.

Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app

# Сначала копируем ТОЛЬКО манифесты зависимостей
COPY package*.json ./

# Используем cache mount для кэша npm. Он не попадёт в слои образа!
RUN --mount=type=cache,target=/root/.npm npm ci
(кэш-маунт не работает со старыми версиями Docker (до 18.09) и требует DOCKER_BUILDKIT=1)

# Только теперь копируем исходники. Изменение кода больше не сбросит кэш npm ci!
COPY . .
RUN npm run build
RUN npm prune --production

FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./

CMD ["node", "dist/main.js"]

Инструкция --mount=type=cache подключает кэш пакетного менеджера: он не попадает в слой образа и может переиспользоваться между сборками. А порядок COPY package*.jsonnpm ciCOPY . помогает не пересобирать слой с зависимостями, если изменился только код приложения.

Новый замер: ~52 МБ.

Итоговый production-ready Dockerfile

Dockerfile
# === ЭТАП 1: Сборка и компиляция ===
FROM node:22-alpine AS builder
WORKDIR /app

# Изолируем слой зависимостей для эффективного кэширования Docker
COPY package*.json ./

# Собираем проект без запекания кэша npm в историю слоёв
RUN --mount=type=cache,target=/root/.npm npm ci

# Копируем исходный код и компилируем в JS
COPY . .
RUN npm run build

# Выбрасываем devDependencies перед переносом в продакшен
RUN npm prune --production

# === ЭТАП 2: Чистый продакшен-рантайм ===
FROM node:22-alpine AS runtime
WORKDIR /app

# Переносим строго минимальный набор артефактов
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./

# Безопасность: переключаемся на некорневого пользователя node
USER node

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

Динамика сокращения размера образа одной строкой: > 1,2 ГБ (Базовый) → 320 МБ (Multi-stage) → 110 МБ (Alpine) → 75 МБ (.dockerignore) → 52 МБ (BuildKit Cache). Общее сжатие — в 23 раза.

Для Node.js также полезно сверяться с рекомендациями команды официального Docker-образа Node.js. В руководстве разобраны работа с Alpine, сборка нативных модулей и удаление npm и Yarn из финального образа с помощью multi-stage.

Экстремальный вариант: Node.js на Distroless. Чтобы ужать рантайм ещё сильнее, можно использовать Distroless-образ для Node.js. В нём нет пакетного менеджера и стандартного shell, поэтому в продакшен попадает только необходимое для запуска приложения окружение.

Например:

FROM gcr.io/distroless/nodejs22-debian13 AS runtime
WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["dist/main.js"]

Это уменьшает поверхность атаки, но усложняет интерактивную диагностику контейнера. Для отладки разработчики Distroless предоставляют отдельные дебаг-варианты образов.

Ограничения multi-stage сборок

При всей эффективности многоэтапные сборки — это всегда компромисс:

  • Сложность отладки. Когда вы вычищаете из рантайма bash, curl, ping и системные пакеты, вы практически лишаете команду возможности зайти внутрь упавшего контейнера через docker exec. Если в проде что-то пойдёт не так, диагностировать проблему «на месте» не получится. Решение: использовать эфемерные дебаг-контейнеры в оркестраторе (например, kubectl debug) или временно переключаться на образы формата -slim.

  • Трудности с экспортом артефактов. Если вы запускаете тесты, линтеры или генераторы документации на этапе builder, все их отчёты (например, coverage.xml) остаются запертыми внутри промежуточных слоёв. Чтобы вытащить их на хост-машину для CI-системы, приходится использовать продвинутые возможности BuildKit (флаг --output) или разделять запуск пайплайна через --target.

  • Хрупкость кэша. Многоэтапный Dockerfile требует жёсткой дисциплины. Стоит вам случайно поменять порядок команд или обновить одну незначительную строчку в конфигурации первого этапа (builder), как Docker сбросит кэш для всей цепочки. В результате вместо ускорения пайплайна вы получите долгую сборку с нуля.

  • Раздувание размера Dockerfile. Вместо одного линейного сценария из пяти команд вам придётся поддерживать сложную древовидную структуру. Для крупных монолитов с множеством нативных зависимостей такой Dockerfile быстро превращается в трудночитаемый код на 50+ строк.

Как multi-stage builds работают в Go

Если вам нужен чистый multi-stage Docker-пример для компилируемого языка, то статический бинарник Go в Docker — подходящая демонстрация технологии. Скомпилированному Go-приложению для работы в проде не нужна ни сама платформа Go, ни менеджер пакетов, ни даже операционная система. 

Решаем задачу с помощью многоэтапной сборки и предельно компактного базового образа scratch (встроенный пустой образ Docker, который весит 0 байт):

Dockerfile
# === ЭТАП 1: Компиляция приложения ===
FROM golang:1.26-alpine AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Собираем статический бинарник (отключаем зависимости от системных C-библиотек)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o myservice .

# === ЭТАП 2: Минимальный runtime-образ ===
FROM scratch

# Переносим только один-единственный скомпилированный файл
COPY --from=builder /app/myservice /myservice

EXPOSE 8080
ENTRYPOINT ["/myservice"]

Флаг CGO_ENABLED=0 заставляет компилятор Go собирать строго статический бинарник. Все необходимые функции упаковываются внутрь одного исполняемого файла. Ему больше не требуются динамические системные библиотеки из родительской ОС, а на финальном этапе мы берём пустой образ scratch и кладём туда только один самодостаточный бинарник. В таком образе нет shell, пакетного менеджера и большинства системных утилит, поэтому поверхность атаки сведена к минимуму. 

Полезные побочные эффекты: безопасность и CI/CD

Меньше лишних пакетов в финальном образе → меньше потенциальных уязвимостей. Когда вы убираете из финального продакшен-слоя лишние бинарники, вы лишаете потенциального злоумышленника готового набора инструментов для закрепления в системе. Чтобы контролировать этот процесс на конвейере, регулярно проверяйте финальные артефакты сканерами безопасности — например, популярным Trivy или встроенным инструментом Docker Scout, которые подтягивают свежие базы CVE и показывают отчёт прямо в консоли. Измеряйте и фиксируйте CVE количественно (critical, high), чтобы отслеживать эффективность вносимых изменений. 

Что это даёт CI/CD. Тяжёлые образы — главное препятствие для быстрой доставки кода. Пересылка 1,2 ГБ по сети на каждом этапе превращает процесс в долгое ожидание. Когда благодаря многоэтапной сборке вес артефакта падает до ~50 МБ, нагрузка на сеть становится неощутимой. В типичном сценарии с прогретым кэшем сборка ускоряется в разы (в нашем замере — с 14 минут до 30 секунд), превращая CI/CD-пайплайн в более оперативный инструмент обратной связи. 

Чек-лист и подборка для изучения

Финальный чек-лист для проверки Dockerfile. Перед тем как отправить свой Dockerfile в продакшен, можно пройтись по этому списку:

  • Настроен .dockerignore. Локальные папки node_modules, dist, логи, тесты и .git гарантированно изолированы от контекста сборки.

  • Внедрён Multi-stage. В файле описано как минимум два этапа FROM (разграничены зоны сборки и рантайма).

  • Выбран правильный базовый образ рантайма. Вместо полновесных дистрибутивов используются версии -alpine или -slim.

  • Слои упорядочены по частоте изменений. Сначала копируются манифесты зависимостей (package*.json, go.mod), и только потом — исходный код.

  • Подключены кэш-маунты. Инструкция RUN --mount=type=cache используется для защиты от запекания кэша пакетных менеджеров внутри слоёв.

  • Очищены dev-зависимости. На этапе сборки вызвана команда удаления лишних пакетов (npm prune --production или аналоги для вашего стека).

  • Внедрён Docker-образ без root. В финальном слое явно указан некорневой пользователь (USER node или аналогичный), приложение не работает из-под привилегированного аккаунта.

  • Статическая линковка для компилируемых языков. Бинарники для Go/Rust собраны без динамических системных зависимостей.

  • Инструкции EXPOSE и CMD зафиксированы. Порты и стартовые команды описаны на финальном этапе рантайма, а не в билдере.

А если хотите выжать максимум из оптимизации контейнеров, обратите дополнительное внимание на:

  • Docker Distroless-образы и запуск приложений в окружении, где полностью отсутствует shell (нет даже sh), что радикально снижает системные CVE. Подробнее в репозитории Google Distroless.

  • Глубокий тюнинг BuildKit. Официальная документация Docker BuildKit поможет настроить продвинутые сценарии кэширования, секретов и сборки под разные архитектуры.

  • Специфику других экосистем. Посмотрите, как принципы минимальных образов адаптируются под другие популярные платформы в официальных руководствах по оптимизации контейнеров для Python, Java и Rust.


Хорошие инженерные решения рождаются там, где не боятся заглянуть за пределы привычного стека. Чтобы оставаться в профессии на шаг впереди, стоит регулярно учиться новому. Если пока сомневаетесь, начните с чего-то небольшого и бесплатного:

Или можно сразу сделать решительный шаг к переменам, карьерному росту и повышению дохода с профессиональным обучением:

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы ужимаете Docker-образы?
51.61%Multi-stage сборки16
87.1%Лёгкие базовые образы (Alpine, slim)27
16.13%Distroless или scratch5
58.06%.dockerignore и чистка слоёв18
12.9%Никак — устраивает как есть4
Проголосовал 31 пользователь. Воздержались 7 пользователей.