Знакомая картина: вы написали микросервис, набросали Dockerfile из четырёх команд — и получили образ на 1,2 ГБ. В единичном случае не страшно, но этот гигабайт гоняется по сети десятки раз в день: при каждом коммите в CI, на preview-окружениях, при деплое в кластеры. В итоге — минуты ожидания пайплайнов, счета за трафик и хранение, плюс лишние системные пакеты с десятками CVE. Показываем на Node.js и Go, как ужать образ с 1,2 ГБ до 50 МБ через multi-stage — без единой строчки правок в коде. Раскатать без боли →
Знакомая для многих картина: вы написали микросервис, набросали классический Dockerfile из четырёх команд, запустили сборку и получили на выходе Docker-образ 1 ГБ, а иногда и больше. В единичном случае это не кажется проблемой, но этот гигабайт гоняется по сети десятки раз в день. Он качается при каждом коммите в CI, при развёртывании эфемерных preview-окружений и деплое в облачные кластеры. Для разработчиков, которые работают в нелокальных средах, утяжелённые образы — это потерянные минуты команды на ожидание пайплайнов и дополнительные расходы на трафик и хранение.
Вместе с лишними гигабайтами в финальный образ попадают лишние системные пакеты, инструменты сборки, компиляторы и утилиты, которые могут принести с собой более 150 разных уязвимостей. Обновление пакетов помогает не всегда: часть зависимостей лучше вообще не включать в финальный образ.
Эта статья — практическое руководство, в котором мы расскажем, как уменьшить размер Docker-образа с помощью multi-stage сборок без переписывания кода приложения.
С подготовкой статьи помог:

Эксперт-разработчик Центра компетенций по разработке мультимодальных ИИ-решений в Ростелеком ИТ, дипломный руководитель в Нетологии (Python, JS)
Проблема: тяжёлые образы замедляют доставку кода, увеличивают расходы на инфраструктуру и могут содержать лишние системные зависимости.
Решение: multi-stage builds разделяют этап сборки, где нужны компиляторы и инструменты разработки, и этап запуска, где остаётся только runtime-окружение.
Результат: на примере Node.js-приложения покажем, как уменьшается Docker-образ без переписывания кода.
Бонус: шаблон Dockerfile, который можно адаптировать под свой проект.
Разберём типичный 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 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.
Разберём на практике, как уменьшить 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 МБ.
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 предоставляют отдельные дебаг-варианты образов.
При всей эффективности многоэтапные сборки — это всегда компромисс:
Сложность отладки. Когда вы вычищаете из рантайма bash, curl, ping и системные пакеты, вы практически лишаете команду возможности зайти внутрь упавшего контейнера через docker exec. Если в проде что-то пойдёт не так, диагностировать проблему «на месте» не получится. Решение: использовать эфемерные дебаг-контейнеры в оркестраторе (например, kubectl debug) или временно переключаться на образы формата -slim.
Трудности с экспортом артефактов. Если вы запускаете тесты, линтеры или генераторы документации на этапе builder, все их отчёты (например, coverage.xml) остаются запертыми внутри промежуточных слоёв. Чтобы вытащить их на хост-машину для CI-системы, приходится использовать продвинутые возможности BuildKit (флаг --output) или разделять запуск пайплайна через --target.
Хрупкость кэша. Многоэтапный Dockerfile требует жёсткой дисциплины. Стоит вам случайно поменять порядок команд или обновить одну незначительную строчку в конфигурации первого этапа (builder), как Docker сбросит кэш для всей цепочки. В результате вместо ускорения пайплайна вы получите долгую сборку с нуля.
Раздувание размера Dockerfile. Вместо одного линейного сценария из пяти команд вам придётся поддерживать сложную древовидную структуру. Для крупных монолитов с множеством нативных зависимостей такой Dockerfile быстро превращается в трудночитаемый код на 50+ строк.
Если вам нужен чистый 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, пакетного менеджера и большинства системных утилит, поэтому поверхность атаки сведена к минимуму.
Меньше лишних пакетов в финальном образе → меньше потенциальных уязвимостей. Когда вы убираете из финального продакшен-слоя лишние бинарники, вы лишаете потенциального злоумышленника готового набора инструментов для закрепления в системе. Чтобы контролировать этот процесс на конвейере, регулярно проверяйте финальные артефакты сканерами безопасности — например, популярным 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-архитектор с нуля до профи» с бонусным модулем по безопасной работе с данными при использовании ИИ;
на программе «Специалист по информационной безопасности», которая соответствует ФГОС и включает требования ФСТЭК;
на курсе «Дата-инженер» с изучением ИИ и программой трудоустройства.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
85%Лёгкие базовые образы (Alpine, slim)34
12.5%Distroless или scratch5
52.5%.dockerignore и чистка слоёв21
12.5%Никак — устраивает как есть5
Проголосовали 40 пользователей. Воздержались 9 пользователей.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Оптимизация без AI: как я автоматизировал API-ручки и типы | -2 | 5 | 29-06-2026 |
| 2 | Инвентаризируем контейнеры с помощью Wazuh-агента | 0 | 5 | 26-06-2026 |
| 3 | Технический долг — это не только legacy: как мы уменьшаем разброс решений между Go-сервисами | 0 | 7 | 07-07-2026 |
| 4 | Как я навайбкодил каталог бесплатных парковок Петербурга и России | 5 | 7 | 07-07-2026 |
| 5 | Multi-Service Docker Compose Build in Travis CI with Cross-Container Caching and Service Health Checks | 0 | 7 | 03-06-2025 |
| 6 | GitFlic CI/CD: первый конвейер с нуля — от коммита до деплоя | 0 | 7 | 07-07-2026 |
| 7 | Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов | 5 | 8 | 07-07-2026 |
| 8 | Организовал весь пентест-арсенал в одном месте: всё под рукой, офлайн и на русском | 5 | 7 | 28-06-2026 |
| 9 | ContentCombine: как я сделал мультинишевый контент-комбайн и запустил ежедневный SEO-дайджест | 5 | 7 | 28-06-2026 |