Знакомая для многих картина: вы написали микросервис, набросали классический 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.
Есть три основных причины, по которым лишний вес образов становится проблемой для бизнеса и разработки:
Замедление деплоя — холодный старт. В современных облачных средах контейнеры динамичны. При автомасштабировании под нагрузкой или при перезапуске упавшего сервиса оркестратор должен вытянуть образ из Registry. Прокачать 1,2 ГБ по сети — это время. Вместо мгновенного поднятия инстанса за 2 секунды команда получает холодный старт на полминуты-минуту.
Перегрузка Registry и скрытые расходы. Каждый коммит в CI/CD генерирует новый тег образа. Хранение десятков версий тяжёлых артефактов стремительно забивает диски приватного Docker Registry и раздувает счета за облачное хранилище.
Риски безопасности — CVE. Часть уязвимостей в контейнерах связана не с кодом приложения, а с пакетами базового образа и системными утилитами. Оставляя в продакшен-контейнере
curl,bash,pythonили старые версии системных утилит, вы можете своими руками расширять поверхность атаки и упрощать эксплуатацию уязвимостей.
Для примера посмотрим на замеры времени, которые мы получили для проекта весом 1,2 ГБ в типичном CI/CD-пайплайне на удалённом сервере (средняя скорость сети ~250 Мбит/с на скачивании и загрузке). Конкретные числа будут зависеть от железа и канала, но порядок величин показателен:
Этап пайплайна | Время выполнения | Что происходит на самом деле |
| ~150 сек | Скачивание тяжёлой базы, установка всех |
| ~40 сек | Транспортировка 1,2 ГБ из CI-воркера в удалённый Container Registry. |
| ~35 сек | Скачивание огромного образа целевым сервером перед командой запуска. |
Итого на деплой | ~3,5–4 минуты | Почти четыре минуты ожидания команды на каждый рабочий коммит только из-за веса артефакта. |
Идея multi-stage за одну минуту
Чтобы понять, как работает multi-stage Docker, можно представить процесс как конвейер. Суть технологии multi-stage builds проста: вы используете несколько инструкций FROM в одном-единственном Dockerfile. Каждая инструкция FROM обнуляет контекст и запускает новый, чистый этап сборки с нового базового образа.
Так вы можете изолировать тяжёлый процесс компиляции от этапа запуска приложения, перенося между ними только готовый результат.
Этап 1 — Builder. Берём тяжёлый образ со всеми компиляторами, исходниками, тестами и пакетными менеджерами. Собираем проект, компилируем бинарник или минифицируем JS-скрипты.
Этап 2 — Runtime. Берём пустой или максимально легковесный образ, например, на базе Docker Alpine.
Ключевой принцип. С помощью команды
COPY --fromDocker забирает из первого этапа только скомпилированный продакшен-артефакт. Все лишние системные файлы, кэш и инструменты сборки автоматически выбрасываются и не попадают в финальные 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:
Имя образа | Базовая ОС | Вес «из коробки» | Для чего подходит |
| Debian (Full) | ~1,1 ГБ | Локальная разработка, сложные нативные сборки. |
| Debian (Slim) | ~240 МБ | Если критична стопроцентная совместимость с |
| Alpine Linux | ~150 МБ | Если важен небольшой размер образа и зависимости приложения совместимы с |
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*.json → npm ci → COPY . помогает не пересобирать слой с зависимостями, если изменился только код приложения.
Новый замер: ~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.
Хорошие инженерные решения рождаются там, где не боятся заглянуть за пределы привычного стека. Чтобы оставаться в профессии на шаг впереди, стоит регулярно учиться новому. Если пока сомневаетесь, начните с чего-то небольшого и бесплатного:
курса «ИИ в деле: ускорьте свою работу», который научит применять ИИ в работе и автоматизировать рутину;
вебинара «Секретный рецепт DevSecOps, или почему его используют 90% компаний»;
вводного курса магистратуры ВШЭ «Бэкенд-разработка и архитектура программных систем»;
видеолекций и мастер-классов конференции «Карьерное планирование: как достичь своей цели»;
записи открытого занятия «Информационная безопасность: как построить карьеру в цифровом будущем».
Или можно сразу сделать решительный шаг к переменам, карьерному росту и повышению дохода с профессиональным обучением:
на программе профпереподготовки «Безопасная разработка программного обеспечения»;
в онлайн-магистратуре УрФУ «Программная инженерия цифровых решений»;
на курсе «AI-архитектор с нуля до профи» с бонусным модулем по безопасной работе с данными при использовании ИИ;
на программе «Специалист по информационной безопасности», которая соответствует ФГОС и включает требования ФСТЭК;
на курсе «Дата-инженер» с изучением ИИ и программой трудоустройства.
