Знакомая картина: пайплайн собирает сервис восемь минут, артефакт весит 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
- Есть исходные замеры: размер, холодная и тёплая сборки, число HIGH и CRITICAL.
- Манифесты зависимостей копируются раньше исходников, установка идёт по lock-файлу.
- В репозитории лежит
.dockerignoreбез.git,node_modulesи.env*. - Сборочные инструменты остались на промежуточных этапах, в финальный слой переезжают только нужные каталоги.
- База зафиксирована по версии, а лучше и по дайджесту, libc совпадает на всех этапах.
- Меняющиеся ARG (дата, SHA) стоят в конце файла.
- Процесс запускается от пользователя с числовым UID, пишет только в явно выделенные каталоги.
- В CI настроен экспорт кэша слоёв в реестр или другой внешний бэкенд.
- Trivy роняет билд на HIGH и CRITICAL с исправлением, исключения описаны и имеют срок пересмотра.
- Плановый ночной скан проверяет то, что уже работает в проде.