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

Почему медленные сборки CI вредят командам
Медленная сборка Docker — это не просто раздражение. Она нарушает ритм проверки, тестирования и релиза. Когда каждый pull request ждёт ту же долгую сборку образа, разработчики перестают сразу проверять результаты и переключаются на другую работу. Такой переключение контекста стоит дороже самой сборки.
Проблема усугубляется в CI, потому что задержка повторяется на каждой ветке, на каждом повторе и при каждой мелкой правке. Сборка в 12 минут вместо 4 может не выглядеть драматично один раз. Но за неделю это превращается в часы работы раннеров и много пустого ожидания. Для команды из шести человек, которая пушит несколько раз в день, эти лишние минуты быстро накапливаются.
Медленная обратная связь меняет поведение. Люди перестают делать небольшие, безопасные коммиты и начинают складывать изменения в пачки. Обзоры растут, отказы сложнее распутать. Поломанная зависимость или пропущенное копирование файла может скрыться в большом наборе изменений, что ещё сильнее замедляет команду.
Проблема особенно заметна на чистых раннерах. Многие CI-задания стартуют на пустых машинах без локального кеша слоёв Docker, поэтому системные пакеты, инструменты и зависимости приложения пересобираются снова и снова. То, что кажется приемлемым на ноутбуке, в хостингованном пайплайне становится дорогим.
Паттерн обычно легко распознать. Деплойы ждут, потому что одна пересборка образа блокирует остальную часть пайплайна. Инженеры перезапускают задания после крошечных правок и снова платят полную стоимость. Параллельные ветки сжигают облачные минуты без особой пользы. Тестирование смещается позже, когда исправления идут медленнее и рискованнее.
Время сборки — это не просто техническая метрика. Оно меняет то, как команда работает, как часто код попадает в прод и сколько в итоге стоит каждая ветка.
Куда на самом деле уходит время сборки
Большинство медленных Docker-сборок вызвано несколькими повторяющимися затратами, а не одной плохой командой. Первая задержка часто — это скачивание базового образа. Если чистому раннеру каждый раз приходится загружать тяжёлый образ, вы теряете время ещё до того, как ваш код попал в картинку.
Следующая большая затрата — установка зависимостей. Здесь многие команды теряют больше всего времени. Небольшая правка в исходнике не должна заставлять снова запускать npm install, pip install или установку системных пакетов, но неправильный порядок слоёв часто делает именно это. Если Dockerfile копирует весь проект слишком рано, Docker считает маленькое изменение поводом перестроить дорогие слои с нуля.
Размер контекста сборки важнее, чем многие ожидают. CI должен отправить файлы для сборки, прежде чем сможет сделать полезную работу. Если в контекст попадают отчёты тестов, локальные кеши, скриншоты или старые артефакты, каждое задание тратит время на перенос данных, которые образу не нужны.
Компиляция ассетов — ещё одна незаметная «поглотительница» времени. Фронтенд-бандлы, сгенерированный код и минифицированные файлы могут занимать времени больше, чем сама компиляция приложения. Если эти шаги перезапускаются после правки README или небольшой правки бэкенда, сборка выполняет работу без отдачи.
Также есть потеря кеша между заданиями. Это часто происходит в хостинговых CI и в self-hosted раннерах, которые слишком агрессивно очищают кеш. Команда, шипящая Next.js через GitLab-раннеры, может наблюдать один быстрый билд, а затем намного медленнее через час, потому что раннер забыл все кешированные слои.
Прежде чем что-то менять, найдите, какая затрата повторяется чаще всего. Обычно именно там находятся самые простые пути экономии времени.
Поставьте слои в лучший порядок
Docker кеширует каждый шаг сборки по очереди. Один «шумный» шаг вверху Dockerfile может заставить выполняться заново все последующие шаги. Решение простое: ставьте медлительную работу, которая редко меняется, выше, а быстро меняющиеся файлы — ближе к концу.
Разумный порядок начинаетcя с базового образа и системных пакетов, которые вы редко трогаете. Затем копируйте файлы зависимостей, такие как package-lock.json, poetry.lock или go.sum, и устанавливайте зависимости до того, как скопируете весь проект. Исходники приложения и сгенерированные ассеты должны идти позже, когда промах кеша будет стоить секунд, а не всего стека зависимостей.
Команды часто поступают наоборот: копируют весь репозиторий первым, а затем устанавливают пакеты. Это работает, но малейшая правка кода может заставить Docker переустановить всё. В CI это добавляет минуты без причины.
То же правило применимо и к системной настройке. Если образу нужны инструменты сборки, SSL-библиотеки или пакеты для обработки изображений, устанавливайте их в ранних слоях. Эти пакеты обычно не меняются при каждом коммите, поэтому Docker сможет переиспользовать слой в нескольких сборках.
Исходники приложения меняются постоянно. Файлы версий, шаблоны, сгенерированный код и фронтенд-ассеты меняются ещё чаще. Держите их ближе к концу, чтобы промах кеша затрагивал секунды, а не весь стек зависимостей.
Многоступенчатые сборки помогают, когда инструменты для сборки и файлы для runtime меняются с разной скоростью. Поместите компиляторы, менеджеры пакетов и тестовые утилиты в build-stage, а затем копируйте только финальный бинарник или бандл в runtime-stage. Это делает runtime-образ меньше и избегает пересборок лишних слоёв, когда меняется только артефакт для деплоя.
Простое правило легко запомнить: если файл меняется почти в каждом коммите, он не должен находиться выше шага, который занимает две минуты.
Отправляйте меньше данных в сборку
Медленная сборка часто начинается до того, как Docker выполнит одну команду. CI сначала упаковывает контекст и отправляет его билдеру. Если туда попадают отчёты тестов, локальные кеши, скриншоты, файлы покрытия или старые логи, каждая сборка платит за лишний вес.
Решение обычно — более строгий .dockerignore. Многие команды игнорируют node_modules и .git, но забывают про dist, build, .next, вывод покрытия, временные папки, файлы редактора и локальные архивы. Эти файлы ничего не дают образу, но стоят времени при каждой отправке.
Сгенерированные файлы требуют дополнительного внимания. Если приложение создаёт скомпилированные ассеты, доки или кодоген-выход во время разработки, держите их вне контекста сборки, если образ действительно их не требует. CI может сгенерировать их отдельным шагом и передать в сборку только финальный результат. Это часто быстрее, чем снова и снова отправлять огромную папку в Docker.
Обычная ошибка — COPY . . из корня репозитория, когда образу нужен только один сервис. В монорепозитории копируйте папку сервиса, его lockfile и только те общие файлы, которые действительно важны. Если изменился фронтенд, бэкенд-образ не должен тянуть весь репозиторий в сборку.
Два простых чекa быстро это выявят. Посмотрите на отчётный размер контекста в логах CI, затем сравните его до и после правок .dockerignore. Проверьте, не попали ли в контекст сгенерированные ассеты и не стоит ли крупные файлы вынести в отдельный шаг артефактов.
Если контекст — сотни мегабайт, начните с этого, прежде чем настраивать кеш или менять базовые образы. Команды, стремящиеся получить более быструю обратную связь, часто экономят больше времени, сокращая размер контекста, чем оптимизируя команды Dockerfile.
Используйте удалённый кеш осознанно
Удалённый кеш полезен только тогда, когда он экономит больше времени, чем тратит на загрузку и выгрузку. В CI эта компенсация важна. Кеш, который скачивается 90 секунд до каждой сборки, может съесть весь выигрыш.
Практичная схема восстанавливает кеш из общего хранилища в начале задания, а затем сохраняет обновлённый кеш только после успешной сборки. С неудачными сборками часто создаются «шумные» слои, которые вы не хотите, чтобы другие задания переиспользовали. Это простое правило делает кеш чище и предсказуемее.
Будьте избирательны в том, что делите между ветками. Стабильные слои, такие как системные пакеты и языковые зависимости, — хорошие кандидаты для общего кеша. Быстро меняющийся код приложения — другое дело. Если каждая ветка пушит свои слои приложения в общий кеш, качество кеша падает, и сборки тратят время, проверяя записи, которые им никогда не пригодятся.
Полезно разделять кеш по назначению. Держите слои зависимостей отдельно от слоёв приложения. Зависимости меняются реже, поэтому дольше остаются полезными. Слои приложения меняются постоянно, им требуется короткий срок жизни и узкий охват.
Не оценивайте удалённый кеш только по общему времени сборки. Измеряйте, где он действительно помогает: время скачивания кеша в начале задания, сэкономленное время на установке зависимостей, процент попаданий для стабильных слоёв, время загрузки после успеха и время пересборки при изменениях зависимостей.
Небольшая команда может протестировать это за день. Если одна ветка обновила только UI-текст, слой зависимостей должен прийти из кеша, и сборка должна пропустить дорогостоящую установку пакетов. Если этого не происходит, кеш слишком широк, слишком «шумный» или сохраняется не в то время.
Выберите базовый образ под задачу
Базовый образ задаёт темп для всей сборки. Если вы тянете огромный образ для маленького приложения, CI платит за это каждый раз. Начинайте с самого маленького образа, который всё ещё содержит нужные инструменты.
Размер — это только часть решения. Предпочитайте образы, которые ваши раннеры тянут часто. Если CI уже часто использует node:20-slim или golang:1.22, эти слои могут уже быть на раннере или в ближайшем registry-кеше. На практике знакомый образ может обойти более мелкий, который CI всё ещё должен скачивать с нуля.
Образы для сборки и для выполнения
Две стадии обычно работают лучше, чем одна. Используйте более крупный builder-образ с компиляторами, менеджерами пакетов и другими инструментами сборки. Затем копируйте только готовое приложение в облегченном runtime-образе. Это делает финальный образ меньше и сокращает время pull в последующих шагах.
Это особенно важно для команд, которые часто пересобирают. Маленький Go-сервис, например, может компилироваться в полном Go-образе, а запускаться из гораздо меньшего runtime-образа. Сборка остаётся простой, а деплои и тесты идут быстрее.
Держите базовый образ стабильным. Если вы меняете его каждые несколько дней, вы сбрасываете кеш и заставляете время сборки прыгать. Выберите версию, зафиксируйте её и обновляйте по графику, в обычное время обслуживания, а не во время срочных фиксов.
Относитесь к базовым образам как к части бюджета сборки. Стандартизируйте небольшой список, держите их в актуальном состоянии и избегайте случайных замещений.
Очищайте в правильном порядке
Когда вы настраиваете Docker-сборки, меняйте по одному параметру за раз и ведите заметки. Если вы потрёте три настройки одновременно, вы не поймёте, что именно помогло.
Начните с базовой точки. Запустите одну сборку без кеша, затем ту же коммит с доступным кешем. Это даст вам два полезных числа: худший случай и нормальный. Сохраняйте логи — одних времён может быть недостаточно, потому что они скрывают, где именно сидит замедление.
Потом исправьте порядок слоёв до всего остального. Поместите редко меняющиеся шаги в начало, а быстро меняющиеся файлы — ниже в Dockerfile. Обычный паттерн: сначала копировать файлы зависимостей, устанавливать пакеты, а затем копировать остальной код приложения. Запустите сборку снова и сравните с базой.
Далее уменьшите размер контекста сборки. Большие контексты тратят время ещё до того, как Docker начнёт полезную работу. Проверьте, что ваш билд отправляет в CI, ужесточите .dockerignore и запустите снова. Вы должны увидеть меньше переданных данных и меньше промахов кеша из-за файлов, которые там не нужны.
После этого добавьте удалённый кеш и протестируйте на свежем раннере. Это важно, потому что локальный кеш может сделать результаты лучше, чем они есть на самом деле. Если новый раннер может быстро подтянуть кеш и пропустить дорогие слои, настройка выдержит нагрузку всей команды.
Базовый образ оставьте на потом. Многие сначала меняют его, потому что это кажется большим выигрышем, но часто таковым не является. Когда порядок слоёв, размер контекста и удалённый кеш под контролем, попробуйте меньший или лучше подходящий базовый образ и измерьте снова.
Простой порядок действий работает хорошо:
- Измерьте чистую сборку и сборку с кешем.
- Переставьте слои и протестируйте снова.
- Обрежьте контекст сборки.
- Добавьте удалённый кеш на свежем раннере.
- Меняйте базовый образ в конце и оставляйте изменение только если числа улучшаются.
Простой пример из небольшой команды
Трёхчленная команда имела Node.js-сервис, который собирался при каждом коммите. Приложение было не очень большим, но CI всё равно казался медленным. Их раннеры стартовали свежими каждый раз, поэтому они теряли локальный кеш Docker между сборками. При многих коммитах пайплайн тратил больше времени на пересборку старых слоёв, чем на проверку нового кода.
Большая часть задержки была из-за одного выбора в Dockerfile. Они копировали весь исходник до npm install. Это звучит безвредно, но означало, что почти любое изменение кода инвалидировало слой зависимостей. Небольшая правка в одном API-файле заставляла CI скачать и установить те же пакеты снова.
Они исправили это несколькими небольшими правками. Сначала скопировали package.json и package-lock.json, запустили npm ci до того, как скопировать остальной исходник, использовали удалённый кеш, чтобы свежие раннеры могли подтянуть предыдущие слои, и переключились на меньший runtime-образ для деплоев.
После этого повторные сборки стали значительно короче. Коммит, который менял только код приложения, больше не запускал полную установку пакетов. Время повторной сборки упало примерно с 7 минут до чуть более 2 минут. Это сократило расходы на раннеры и сделало обзоры менее раздражающими — разработчики получали результаты быстрее.
Меньший runtime-образ помог в другом месте. Задачи деплоя завершались быстрее, потому что серверам приходилось тянуть меньше данных. Это было не так критично, как кеш сборки, но команды замечают это, когда деплоев становится несколько в день.
Такой простой рефакторинг обычно даёт больше пользы, чем экзотичная тонкая настройка. Правильный порядок слоёв, аккуратный удалённый кеш и подходящий базовый образ могут убрать много лишней работы без изменений в самом приложении.
Ошибки, которые держат сборки медленными
Многие медленные пайплайны рождаются из привычек, которые выглядят безобидно при ревью, но стоят времени при каждом запуске.
Одна распространённая ошибка — изменение файлов версий при каждой сборке и их копирование слишком рано в Dockerfile. Отметка времени, номер сборки или файл с коммитом, сгенерированный в процессе, может заставить кеш Docker сбрасываться. Если такой файл попадает выше шага установки зависимостей, задание будет снова скачивать и перестраивать те же пакеты.
Команды также теряют время, когда держат инструменты сборки и runtime-файлы в одной стадии. Одностадийный образ кладёт рядом маленькие изменения приложения и компиляторы, менеджеры пакетов и временные артефакты сборки. Пересборки становятся массивнее, и образ часто получается больше, чем нужно.
Очистка кеша — ещё одна самопричинённая задержка. Некоторые CI-задания стирают локальный и удалённый кеш при каждом запуске, потому что так кажется безопаснее. Обычно это просто превращает обычные сборки в холодные старты. Делайте инвалидирование кеша узконаправленным и обдуманным, иначе вы будете платить цену полной установки и компиляции при каждом запуске.
Монорепозитории добавляют ещё одну проблему: крошечное изменение в одном сервисе может триггерить пересборку всех сервисов. Это трудно оправдать, когда обновление доков или одна мелкая правка API будит полпайплайна. Собирайте только то, что изменилось, и то, что от этого зависит.
Последняя ловушка — погоня за минимальным размером образа любой ценой, даже если это замедляет путь к рабочей сборке. Экономия 40 МБ в финальном образе не стоит минут на настройку пакетов, усложнение отладки или замедление сборки нативных компонентов.
Защищайте кешируемые шаги, чётко разделяйте стадии сборки и исполнения и избегайте пересборок, которых пользователь никогда не увидит.
Быстрые проверки перед дальнейшей настройкой
Многие медленные сборки вызваны одной маленькой ошибкой, а не Docker как таковым. Прежде чем тратить часы на оптимизацию, проведите несколько повторяемых проверок.
Если изменение одного файла приложения заставляет Docker снова запускать apt-get, npm install или pip install, ваш порядок слоёв неверен. Поместите файлы зависимостей первыми, установите один раз, а затем копируйте остальной код.
Проверьте размер контекста сборки. Если node_modules, dist, вывод тестов или локальные кеши попадают в контекст, CI загружает мусор при каждом запуске. Чистый .dockerignore часто экономит больше времени, чем сложная схема кеширования.
Используйте свежий раннер и убедитесь, что он восстанавливает кеш до начала сборки. Удалённый кеш полезен только если раннер может подтянуть метаданные кеша достаточно рано, чтобы повторно использовать старые слои.
Отдельно измерьте время скачивания базового образа. Некоторые образы выглядят удобными, но добавляют 30–60 секунд ещё до того, как начнётся реальная работа. Для маленького сервиса меньший образ может выиграть у «полнофункционального», который CI тянет с нуля.
Наконец, фиксируйте два числа отдельно: чистая сборка и повторная. Если смешивать их, можно обмануть себя и подумать, что изменение исправило весь пайплайн, когда на самом деле оно помогло только тёплым сборкам.
Это не гламурная работа, но она окупается. Как только вы поймёте, откуда идёт задержка — установка пакетов, размер контекста, восстановление кеша или время pull базового образа — следующее исправление обычно очевидно.
Следующие шаги для более дешёвого и быстрого CI-цикла
Сделайте первый проход в одном активном репозитории на этой неделе. Измените не больше трёх вещей: поместите стабильные слои раньше в Dockerfile, отправляйте в контекст меньше файлов и включите удалённый кеш. Этого обычно достаточно, чтобы увидеть реальные экономии.
Не объединяйте десять изменений в одно. Когда всё меняется сразу, никто не поймёт, какое изменение сэкономило время, а какое лишило пайплайн доверия.
Запишите результаты в простую таблицу и сохраните её в заметках репозитория:
| Change | Before | After | Notes |
|---|---|---|---|
| Layer order | 8m 40s | 5m 15s | dependency layer reused |
| Build context | 5m 15s | 4m 25s | fewer files sent |
| Remote cache | 4m 25s | 2m 55s | pull requests got faster |
Это помогает больше, чем ожидают. Команды часто помнят худшую сборку прошлой недели, а не среднее по 20 запускам. Простая таблица упрощает принятие решения.
Оставляйте в репозитории только те изменения, которые реально экономят время и работают стабильно в повседневной работе. Если настройка кеша часто падает, требует ручной чистки или даёт случайные пропуски, уменьшите её масштаб. Немного более медленный, но надёжный пайплайн лучше, чем флапающий, который блокирует слияния.
После этого ревьюйте базовый образ. Если сборка всё ещё медленная, проблема может быть в стартовой точке, а не в деталях Dockerfile.
Если вашей команде нужен внешний аудит, Oleg Sotnikov на oleg.is работает со стартапами и малыми бизнесами как Fractional CTO и может помочь проверить Dockerfile, поток кеша в CI и инфраструктурные решения. Иногда короткий аудит быстрее, чем месяц попыток угадать, почему сборки всё ещё долго идут.
Часто задаваемые вопросы
What should I fix first in a slow Docker build?
Start with measurement, not guesses. Time one clean build on a fresh runner and one repeat build with cache available.
Then fix layer order and build context first. Those two changes often cut more time than swapping base images or adding fancy cache rules.
Why does one small code change rerun npm install?
Docker caches layers in order. If you copy the whole repo before npm install, a small code edit changes that earlier layer and Docker reruns dependency install.
Copy package.json and package-lock.json first, run npm ci, and copy app code after that.
How should I order Dockerfile layers?
Put slow steps that rarely change near the top. Base image, OS packages, and dependency files usually belong there.
Move app source, templates, generated files, and other fast-changing content near the end so cache misses cost less.
What should I put in .dockerignore?
Keep anything out of the build context if the image does not need it. That often includes node_modules, dist, .next, coverage files, logs, screenshots, temp files, and editor folders.
If CI can generate an artifact in a separate step, do that instead of sending a large folder into Docker on every run.
Does remote cache always make CI faster?
No. Remote cache only pays off when pull and push time stay lower than the work it skips.
Test it on a fresh runner. If the job spends too long downloading cache before the build starts, trim the cache scope or drop it.
When does a multi-stage build make sense?
Use multi-stage builds when build tools change at a different pace than runtime files. Compile or bundle in one stage, then copy only the final app into a smaller runtime image.
That setup often cuts final image size and avoids dragging compilers and package managers through every deploy.
How do I choose the right base image?
Pick the smallest image that still fits the job. A tiny runtime image works well for deploys, while a larger builder image works better for compiling or bundling.
Also check what your runners already pull often. A familiar image can finish faster than a smaller one that CI must fetch from scratch each time.
Should I optimize clean builds or repeat builds?
Track both. A clean build shows your worst case on fresh runners, and a repeat build shows daily developer experience.
If you only watch warm builds, you can miss a slow cache restore or a heavy base image pull that hurts real CI runs.
Why do monorepos often make Docker builds slower?
Monorepos slow builds when one service drags unrelated code into the build context or triggers rebuilds across the repo. COPY . . from the root often causes that mess.
Build only the service that changed, copy only the files it needs, and keep shared dependencies narrow.
What numbers should I track after I change the build?
Watch build context size, base image pull time, dependency install time, cache restore time, and total clean versus repeat build time.
Those numbers show where the delay lives. If one app edit still triggers apt-get or pip install, your Dockerfile order still wastes time.