Рекламный баннер: ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ "ЦЕНТР НАЦИОНАЛЬНЫХ ИНТЕЛЛЕКТУАЛЬНЫХ СИСТЕМ"

Оптимизация Docker образов: как уменьшить размер и ускорить сборку с multi-stage build и кэшем слоёв

Знакомая картина: пайплайн собирает сервис восемь минут, артефакт весит 1,4 ГБ, каждая нода после выкладки минуту тянет его из реестра, а сканер показывает сотню уязвимостей в пакетах, которыми приложение даже не пользуется. Сам Dockerfile при этом выглядит безобидно: пять строк, всё работает, ревьюер не придирается.

Ниже разберём оптимизацию Docker образов на одном гипотетическом Node.js API: от исходного файла до компактного артефакта без root и с проверкой в CI. Для каждого шага покажем, что именно меняется в размере, времени пересборки и числе уязвимостей. Те же приёмы работают для Go, Java и Python, меняются только команды сборки и выбор базы.

Цифры в статье иллюстративные. Они показывают порядок величин для типичного сервиса, а не результат конкретного замера. У вас будут другие значения, поэтому первым делом мы научимся мерить своё.

Коротко:

  • Сначала замер: размер, время сборки с кэшем и без, число HIGH и CRITICAL по сканеру. Без исходной точки любые улучшения остаются на веру.
  • Скорость пересборки почти целиком определяется порядком инструкций: то, что меняется редко, должно стоять выше того, что меняется каждый коммит.
  • Самый заметный выигрыш в размере даёт связка из multi-stage build и более тесной базы (slim или distroless), а не мелкие приёмы вроде объединения RUN.
  • Кэш-маунты BuildKit экономят время на скачивании зависимостей, но на одноразовых CI-раннерах без явного экспорта кэша ничего не дают.
  • Запуск от непривилегированного пользователя и сканирование на каждый билд закрывают большую часть типовых замечаний безопасности.

Сначала замерьте исходное состояние

Оптимизировать вслепую легко: уменьшили на 50 МБ, обрадовались, а настоящий вес сидел в слое с кэшем пакетного менеджера. Четыре команды дают достаточно данных, чтобы понять, куда идти.

docker build --no-cache -t myapp:base .
time docker build -t myapp:base .   # повторно, с кэшем
docker images myapp:base
docker history myapp:base
trivy image --severity HIGH,CRITICAL myapp:base

Холодная сборка без кэша показывает худший случай, а повторная после правки одного файла в исходниках показывает то, что разработчик ощущает ежедневно. docker history выводит размер каждого слоя: сразу видно, где лежит гигабайт. Если нужен интерактивный разбор содержимого слоёв, поможет утилита dive.

Запишите все четыре числа в таблицу. Мы вернёмся к ней в конце.

Порядок слоёв: почему одна правка пересобирает всё

Вот исходный файл нашего гипотетического сервиса:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]

Каждая инструкция создаёт слой, а кэш работает по цепочке: если слой изменился, все следующие за ним пересобираются заново. Здесь COPY . . стоит до установки зависимостей. Правка одной строки в контроллере меняет содержимое слоя, и npm install качает пакеты заново, хотя package.json не трогали.

Исправление занимает три строки: сначала копируем только манифесты, ставим зависимости, и лишь потом тащим исходники.

FROM node:20
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Заодно мы заменили npm install на npm ci: он ставит ровно то, что записано в lock-файле, и падает при расхождении. Для сборки образа воспроизводимость важнее удобства. Аналоги в других экосистемах: pip install --require-hashes, go mod download, mvn dependency:go-offline.

Принцип простой: сверху то, что меняется раз в месяц (системные пакеты, манифесты зависимостей), снизу то, что меняется каждый коммит (исходники, метаданные сборки).

.dockerignore: что попадает в контекст сборки

Команда docker build . отправляет демону весь каталог целиком, и COPY . . затем переносит его в слой. Если рядом лежат .git, локальный node_modules, логи и тестовые данные, то образ раздувается, а кэш инвалидируется от любого чиха: поменялся файл в .git после коммита, и слой с исходниками уже другой.

.git
node_modules
dist
coverage
*.log
.env*
Dockerfile
docker-compose*.yml
README.md

Отдельно о .env*. Попавший в слой файл с секретами остаётся в истории образа, даже если следующей инструкцией его удалить. Это не оптимизация, а гигиена, и проверить её проще всего именно здесь.

Multi-stage build: собираем в одном месте, запускаем в другом

Для запуска Node-приложения нужны рантайм и production-зависимости. Компилятор TypeScript, dev-пакеты, исходники и кэш менеджера нужны только на этапе сборки. Multi-stage build разделяет эти роли: у каждого этапа свой FROM, а в финальный слой переезжает только то, что скопировано явно через COPY --from.

FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM deps AS build
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Побочная польза: этапы можно собирать по отдельности через --target. Например, этап build удобно гонять в CI для тестов, не создавая финальный артефакт. А BuildKit умеет выполнять независимые этапы параллельно, если они не зависят друг от друга.

Сам по себе переход на multi-stage на той же толстой базе даёт скромный результат: уходят dev-зависимости и исходники, но сотни мегабайт полного дистрибутива остаются. Главный выигрыш приходит от следующего шага.

Какую базу выбрать: slim, alpine или distroless

База определяет и размер, и число уязвимостей, и то, чем вы сможете отлаживать сервис в проде. Универсального победителя нет, поэтому сравним по критериям, которые реально болят.

ВариантЧто получаетеПодводные камниКогда брать
Полный (node:20)Компиляторы, git, curl, отладочные утилитыГигабайт лишнего и сотни пакетов для сканераТолько как этап сборки
slimУрезанный Debian с glibcНет компилятора: нативные модули придётся собирать на предыдущем этапеБезопасный выбор по умолчанию
alpineМинимальный размер, musl вместо glibcБинарные пакеты под glibc не заведутся, возможны отличия в DNS и скоростиGo-бинарники, простые скрипты, команды, готовые разбираться с musl
distrolessТолько рантайм и сертификаты, без shell и пакетного менеджераНельзя зайти через sh, сложнее первая отладкаЗрелые сервисы с хорошими логами и метриками

У alpine главная ловушка в musl. Нативные модули (sharp, bcrypt, драйверы баз данных) часто поставляются как готовые бинарники под glibc, и на alpine пакетный менеджер либо ищет альтернативную сборку, либо компилирует из исходников, что тянет за собой тулчейн и съедает всю экономию. Мелкие расхождения в поведении резолвера и аллокатора тоже случаются, и находятся они обычно в проде под нагрузкой.

Distroless образы от Google оставляют в контейнере только рантайм языка и минимальный набор системных файлов. Нет shell, нет apt, нет curl: атакующему после взлома приложения почти нечем пользоваться, а сканеру почти нечего находить. Цена вопроса в отладке. Для Kubernetes выручает kubectl debug с эфемерным контейнером, для локальной работы есть теги :debug с busybox. Договоритесь об этом процессе заранее, а не в три часа ночи.

Есть ещё одно правило, из-за которого ломаются первые попытки. Этап сборки и финальный этап должны совпадать по libc и версии ОС. Node 20 slim построен на Debian 12, и под него подходит distroless-вариант nodejs20-debian12. Если собирать на alpine, а запускать на Debian, нативные зависимости не загрузятся.

Ещё одна деталь, которую часто пропускают: фиксируйте не только тег, но и дайджест базы (FROM node:20-slim@sha256:...). Плавающий тег сегодня и через месяц указывает на разные слои, и воспроизводимость сборки пропадает. Обновлять дайджест можно автоматически через Renovate или Dependabot.

BuildKit кэш: cache mounts и работа в CI

Кэш слоёв спасает, пока манифест зависимостей не менялся. Когда в package.json добавили один пакет, слой с npm ci пересобирается целиком, и все триста пакетов скачиваются заново. Для таких случаев в BuildKit есть кэш-маунты: каталог, который живёт между сборками и не попадает в финальный слой.

# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

Теперь менеджер пакетов берёт уже скачанные архивы из локального кэша и качает только новое. На других стеках монтируют /root/.cache/pip, /go/pkg/mod, /root/.m2. Начиная с Docker Engine 23.0 BuildKit включён по умолчанию, отдельные переменные окружения больше не нужны.

Теперь про главную проблему CI. Кэш-маунт лежит на диске конкретного билдера. На эфемерных раннерах, которые создаются на каждую задачу, этот диск пропадает вместе с раннером, и ускорения нет. Сам кэш слоёв тоже по умолчанию локальный. Чтобы он пережил раннер, его экспортируют во внешнее хранилище:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/myapp:buildcache \
  --cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
  -t registry.example.com/myapp:$GIT_SHA --push .

Режим mode=max сохраняет слои промежуточных этапов, а не только финального. Без него multi-stage сборка при следующем запуске заново проходит самые дорогие этапы. В GitHub Actions есть бэкенд type=gha. Учтите, что содержимое кэш-маунтов в такой экспорт не входит: для него нужны отдельные решения, либо надо смириться и полагаться на кэш слоёв.

Проверьте и то, что незаметно ломает кэш в CI:

  • Аргумент вроде ARG BUILD_DATE или GIT_SHA, объявленный выше тяжёлой инструкции, меняется на каждом коммите и инвалидирует всё ниже. Метаданные кладите в самый конец или в LABEL на последнем этапе.
  • Отдельный RUN apt-get update без установки пакетов в том же слое закэшируется надолго, и через месяц install упадёт на устаревшем индексе. Пишите apt-get update && apt-get install -y --no-install-recommends ... && rm -rf /var/lib/apt/lists/* одной инструкцией.
  • Смена базового образа по плавающему тегу сбрасывает кэш у всей команды в непредсказуемый момент.

Запуск без root

По умолчанию процесс в контейнере работает от root. Это не значит, что он root на хосте, но любой выход из контейнера или ошибка в монтировании даёт атакующему лишние возможности. Исправление стоит пары строк, а сканеры конфигураций ругаются на его отсутствие в первую очередь.

FROM gcr.io/distroless/nodejs20-debian12:nonroot
WORKDIR /app
COPY --from=build --chown=65532:65532 /app/node_modules ./node_modules
COPY --from=build --chown=65532:65532 /app/dist ./dist
EXPOSE 3000
CMD ["dist/server.js"]

Тег :nonroot в distroless уже запускает процесс от пользователя с UID 65532. На slim придётся создать пользователя самостоятельно и указать USER 10001 числом. Именно число, а не имя: проверка runAsNonRoot в Kubernetes не может сопоставить имя с UID и отклонит под.

Что ломается после перехода на непривилегированного пользователя:

  • Порты ниже 1024. Слушайте 3000 или 8080, а внешний порт задавайте на уровне Service или балансировщика.
  • Запись на диск. Приложение, которое пишет в /app/tmp или кэш, получит EACCES. Выделите явный каталог и выдайте права через --chown либо смонтируйте emptyDir.
  • Файлы, скопированные без --chown, остаются у root: читать их можно, менять нельзя, и это обычно ожидаемо.

Бонус: после этого можно включать readOnlyRootFilesystem на уровне оркестратора и проверять, что приложение не пытается писать куда попало.

Сканирование образов Trivy в CI

Уменьшение размера уже снижает число находок, потому что исчезают пакеты, которыми вы не пользуетесь. Но проверку надо превратить в автоматический барьер, иначе через квартал всё вернётся. Trivy умеет сканировать образ, файловую систему, а заодно и сам Dockerfile на неудачные настройки.

trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 registry.example.com/myapp:$GIT_SHA
trivy config .

Первая команда роняет пайплайн, если нашлись серьёзные уязвимости с доступным исправлением. Флаг --ignore-unfixed убирает шум от проблем, которые пока нечем чинить: блокировать из-за них релиз бессмысленно. Вторая команда проверяет конфигурацию, в том числе отсутствие USER.

Несколько практических правил:

  • Сканируйте тот же артефакт, который уйдёт в реестр, по тегу с хэшем коммита, а не локальную сборку.
  • Базы уязвимостей обновляются ежедневно. Образ, прошедший проверку в понедельник, в пятницу может её не пройти, поэтому добавьте плановый ночной прогон по тому, что сейчас работает в проде. Обновления самих зависимостей тоже стоит держать под контролем, об этом наша статья про управление зависимостями в CI/CD.
  • Исключения записывайте в файл игнорирования с комментарием, почему, и датой пересмотра. Бессрочный игнор через полгода никто не вспомнит.
  • Число находок не равно риску. Уязвимость в библиотеке, которая не вызывается из вашего кода, срочной не является, но решение об этом принимает человек, а не сканер.

Оптимизация Docker образов в цифрах: что дал каждый шаг

Вернёмся к гипотетическому API и соберём результаты по шагам. Значения ориентировочные, как и обещали в начале.

ШагРазмерПересборка после правки кодаПересборка после смены зависимостиHIGH и CRITICAL
Исходный файл на node:20около 1,4 ГБоколо 2 мин 50 соколо 3 миноколо 140
Порядок слоёв и .dockerignoreоколо 1,3 ГБоколо 20 соколо 3 миноколо 140
Multi-stage на той же базеоколо 1,15 ГБоколо 20 соколо 3 миноколо 135
Переход на slimоколо 250 МБоколо 20 соколо 2,5 миноколо 40
Distroless, non-rootоколо 170 МБоколо 20 соколо 2,5 минединицы
Кэш-маунт для npmоколо 170 МБоколо 20 соколо 50 сединицы

Картина показательная. Время ежедневной пересборки обвалилось на втором шаге почти бесплатно, одной перестановкой строк. Размер и число находок упали на шагах с базой, а multi-stage сам по себе почти ничего не дал, пока оставалась толстая основа. Кэш-маунт оживил только самый редкий, но самый неприятный сценарий: смену зависимостей.

Если у вас Go, картина получится ещё более резкой. Статический бинарник можно положить в scratch или distroless static, и итоговый артефакт будет весить десятки мегабайт, а не сотни.

Типичные ошибки и их цена

ОшибкаПоследствиеКак исправить
COPY . . перед установкой зависимостейЛюбая правка кода скачивает пакеты зановоСначала копировать манифесты и lock-файл
Удалять файлы отдельной инструкцией RUN rmРазмер не уменьшается: слой с файлами остаётся в историиНе класть лишнее в слой вообще, использовать multi-stage
Тег latest или плавающая версия базыНепредсказуемые сборки и внезапные поломкиФиксировать версию и дайджест
Секрет передан через ARG или ENVЗначение читается из docker historyИспользовать RUN --mount=type=secret
Alpine ради размера при нативных модуляхДолгая сборка и странности в рантаймеВзять slim или собирать на совместимом этапе

Про секреты стоит помнить отдельно: даже удалённый в следующем слое токен остаётся доступен любому, кто получит образ. Если на этапе сборки нужен приватный реестр, передавайте доступ через секретный маунт BuildKit, он не попадает ни в слой, ни в историю.

Когда оптимизировать не нужно или рано

Погоня за каждым мегабайтом быстро превращается в самоцель. Несколько ситуаций, где лучше остановиться.

Если образ весит 300 МБ и выкатывается раз в день, а сборка укладывается в две минуты, переход на distroless принесёт больше проблем с отладкой, чем пользы. Уменьшение размера окупается там, где образы тянутся на сотни нод, масштабируются автоматически и стартуют под нагрузкой: тогда время загрузки слоёв напрямую бьёт по времени запуска пода.

Команде без практики отладки через эфемерные контейнеры стоит сначала остановиться на slim. Дистрибутив без shell не вредит, пока есть наблюдаемость: структурированные логи, метрики и трейсы. Без неё первая же авария превратится в слепые догадки. Как её выстроить, мы разобрали в статьях про observability в backend и про логирование в микросервисах.

И ещё одно ограничение. Малое число находок в сканере не делает образ безопасным: оно говорит лишь о том, что известных уязвимостей в установленных пакетах мало. Ошибки в вашем собственном коде, избыточные права в кластере и утёкшие секреты сканер образа не увидит. Про права в Kubernetes подробно рассказали в статье о RBAC на практике.

Чеклист перед мерджем Dockerfile

  1. Есть исходные замеры: размер, холодная и тёплая сборки, число HIGH и CRITICAL.
  2. Манифесты зависимостей копируются раньше исходников, установка идёт по lock-файлу.
  3. В репозитории лежит .dockerignore без .git, node_modules и .env*.
  4. Сборочные инструменты остались на промежуточных этапах, в финальный слой переезжают только нужные каталоги.
  5. База зафиксирована по версии, а лучше и по дайджесту, libc совпадает на всех этапах.
  6. Меняющиеся ARG (дата, SHA) стоят в конце файла.
  7. Процесс запускается от пользователя с числовым UID, пишет только в явно выделенные каталоги.
  8. В CI настроен экспорт кэша слоёв в реестр или другой внешний бэкенд.
  9. Trivy роняет билд на HIGH и CRITICAL с исправлением, исключения описаны и имеют срок пересмотра.
  10. Плановый ночной скан проверяет то, что уже работает в проде.

часто задаваемые вопросы

Выберите более тесную базу и вынесите сборку на отдельный этап. Переход с полного дистрибутива на slim или distroless обычно даёт минус 70-85 процентов размера, остальные приёмы добавляют проценты. Начните с docker history , чтобы увидеть самые тяжёлые слои.

В обычной сборке всё, что вы скачали и скомпилировали, остаётся в итоговых слоях. В multi-stage каждый этап начинается с чистого FROM , и в финальный артефакт попадает только то, что вы явно скопировали через COPY --from . Компиляторы и dev-зависимости остаются за бортом.

Alpine проще отлаживать, потому что в нём есть shell, но он на musl и требует внимания к нативным модулям. Distroless строже по безопасности и обычно даёт меньше находок в сканере, зато требует готового процесса отладки. Для Go и статических бинарников подходят оба, для Node и Python чаще выбирают slim или distroless на glibc.

Чаще всего раннер одноразовый и локальный кэш не переживает задачу. Настройте --cache-from и --cache-to с внешним хранилищем и режимом mode=max . Второй типичный виновник: меняющийся аргумент вроде SHA коммита, объявленный выше тяжёлых инструкций.

Да, потому что проверка ловит не только базу, но и ваши зависимости, которые тоже устаревают. Уязвимости публикуются каждый день, и образ, чистый на момент сборки, через неделю может перестать быть таким. Поэтому нужен и барьер в CI, и ночной скан работающих версий.

В Kubernetes подключают эфемерный контейнер командой kubectl debug с образом, где есть нужные утилиты, и общим пространством процессов. Локально используют тег distroless с суффиксом :debug . Главное, чтобы команда один раз отрепетировала этот путь до реальной аварии.

Частично: включить BuildKit (уже по умолчанию в новых версиях Docker), подключить внешний кэш слоёв и добавить .dockerignore . Но самый большой эффект всё равно даёт порядок инструкций, и тут без правки файла не обойтись.

Итог

Большая часть пользы от оптимизации приходит не от хитростей, а от трёх решений: правильного порядка слоёв, разделения сборки и запуска и разумного выбора базы. Всё остальное, включая кэш-маунты и сканирование, превращает разовую победу в стабильное свойство пайплайна.

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

Оптимизацию образов и CI/CD часто обсуждают на собеседованиях: примеры вопросов есть в подборке для DevOps-инженера, а ориентиры по доходу в обзоре зарплат DevOps-инженеров. Свежие вакансии для DevOps-инженеров собраны на HireHi.