Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Docker-образы в CI: кеш и оптимизация сборки

Сборка Docker-образов в CI: слои, кеш и быстрые пайплайны

Оптимизация docker build: порядок слоёв под кеш, lock-файлы и зависимости, BuildKit и кеш в GitHub Actions, реестры и теги, типичные причины медленных сборок.

Прямой ответ

Скорость сборки Docker-образа определяется почти entirely порядком слоёв относительно кеша: BuildKit кеширует слой, пока неизменны его команда и контекст — отсюда золотое правило Dockerfile: сначала то, что меняется редко (базовый образ, системные пакеты, зависимости из lock-файла), потом то, что меняется часто (код приложения); классическая ошибка — COPY . . до pip install — любой коммит инвалидирует установку зависимостей (минуты переустановки на каждый пуш); правильный паттерн: COPY pyproject.toml uv.lock ./ → RUN uv sync --frozen → COPY . . — зависимости переустанавливаются только при изменении lock-файла (multistage-основа). Второй рычаг — кеш в CI: GitHub Actions-раннеры эфемерны — локальный кеш слоёв не живёт между запусками; решения — registry-кеш (--cache-to type=registry — слои в реестр, следующие сборки тянут) или actions/cache для build-кеша; без этого каждая сборка в CI — с нуля, и лучший Dockerfile не спасает. Третий — теги и реестр: тег по git-sha + latest (иммутабельность деплоя — K8s-роллинги опираются на уникальность тега), и сборка один раз на коммит с переиспользацией образа между окружениями (стейдж и прод — один артефакт, не две сборки — 12-factor build/release).

Слои и кеш: механика и паттерны

Как BuildKit решает: каждый слой — хеш (инструкция + входные файлы); совпал — взят из кеша, дети тоже (пока цепочка цела); первое расхождение — пересборка с этого места вниз. Следствия-паттерны: зависимости отдельно от кода (копия lock + установка — слой живёт между коммитами кода); COPY точечный (COPY src/ ./src/ вместо COPY . . — изменение README не инвалидирует слои кода; .dockerignore обязателен — .git/node_modules/артефакты в контексте замедляют и отправку контекста, и инвалидацию); системные пакеты и Python-зависимости — раздельные слои (apt меняется реже pip); mount=type=cache (BuildKit: RUN --mount=type=cache,target=/root/.cache/uv uv sync — кеш пакетного менеджера переживает пересборки слоя — переустановка зависимостей без сети). Многоэтапность усиливает эффект: builder-стадия (компиляция, dev-зависимости) кешируется независимо от финальной — итоговый образ маленький и его слои стабильны (multistage-детали). Антипаттерны: ARG до зависимостей без нужды (изменение любого аргумента выше — инвалидация вниз), дата/версия в ENV внутри ранних слоёв (каждая сборка уникальна — кеш мёртв; версия — лейблом или на этапе релиза), pip install без lock (нестабильный слой — каждый резолв разный — воспроизводимость и кеш — lock-дисциплина).

Кеш в CI: GitHub Actions специфика

Проблема: раннер живёт один job — локальный кеш BuildKit исчезает; наивная сборка в CI — полная каждый раз. Решения: registry-кеш (docker/build-push-action с cache-from: type=registry,ref=image:buildcache + cache-to: ...mode=max — все слои, включая промежуточные, в отдельный тег реестра; следующие сборки — cache hit по слою; цена — место в реестре и трафик, окупается с первой же минуты экономии); GHA-кеш (cache-from: type=gha — кеш слоёв в кеше Actions — быстрее на малых образах, лимиты 10 ГБ на репо учитываются); выделенный раннер с постоянным локальным кешем (self-hosted — раннер-практика — для больших монорепозиторных сборок). Практика пайплайна: сборка — одна джоба на push (не на каждую матрицу тестов — тесты гоняются на готовом образе: docker run image pytest — тестирование того же артефакта, что уедет в прод — матрица); параллельные ветки — кеш-теги по ветке с fallback на main (scope в cache-to — ветка наследует кеш основной); замер эффекта — время build-шага до/после (типично 6 мин → 40 сек на коммит без изменения зависимостей — окупаемость настройки — час работы).

Теги, реестр, поставки

Схема тегов: image:<git-sha> (иммутабельный артефакт — деплой ссылается на него; откат = предыдущий sha), image:latest — удобство dev (не для прод-ссылок — мутабельность); семантические теги — на релизах (SemVer-процесс — image:1.4.2 = конкретный релиз); buildcache-тег — служебный (не для запуска). Реестр: GHCR/облачный/self-hosted (Gitea-вариант — локальная разработка с реестром) — доступы из CI — token с минимальными правами (секреты); retention-политика на старые sha-теги (реестр не бесконечен — buildcache и старые артефакты чистятся по правилам, релизные — живут). Один образ на окружения: собирается на merge в main, промотируется стейдж→прод перетеговкой/копией (не пересборкой — «на проде работает то, что проверено на стейдже» буквально — различия окружений в конфиге, не в артефакте — 12-factor); сканирование образа в пайплайне до публикации (trivy — критичные CVE блокируют push — hardening-контекст). Манифесты мультиплатформ (buildx --platform linux/amd64,linux/arm64 — локальные arm-ноутбуки и x86-прод из одного пайплайна) — по потребности, с учётом удвоения времени сборки/кеша.

Практический сценарий: CI-сборка с 7 минут до минуты

Исходник: образ FastAPI-сервиса, CI-сборка 7 минут на каждый пуш; разбор слоёв (docker history) — установка зависимостей пересобирается всегда (COPY . . перед pip install), контекст 800 МБ (.git и .venv без dockerignore), кеш в CI отсутствует (эфемерные раннеры). Прогон: (1) Dockerfile — lock-слой раньше кода, --mount=type=cache на uv, точечные COPY, .dockerignore (контекст 800 МБ → 12 МБ — отправка контекста 40 с → 2 с); (2) multistage уже был — проверен порядок (dev-зависимости в builder — финальный слой стабилен); (3) build-push-action: cache-from/to type=gha + fallback registry для больших слоёв; (4) пайплайн: сборка один раз на push в main/PR (тесты — на собранном образе — раньше тесты и сборка дублировали установку зависимостей дважды). Замеры: коммит без изменений зависимостей — 55 секунд (все слои из кеша, пересобирается только код); изменение lock — 3.5 минуты (зависимости, но кеш пакет через mount); полная холодная сборка (новый раннер без кеша) — 6 минут (регресс к худшему случаю редок — fallback-кеш реестра). Побочные находки: тег latest использовался в прод-манифестах — заменён на sha (откат стал возможным без археологии «какой latest был вчера»); скан trivy в пайплайне поймал CVE в базовом слое — переход на свежий slim-тег — аудит supply chain в действии.

FAQ

Почему кеш слоёв «слетает» сам по себе? Причины по частоте: изменился ранний слой (аргумент/базовый тег/lock — всё ниже пересоберётся — порядок слоёв лечит), контекст попал в COPY шире нужного (любой файл инвалидирует — .dockerignore/COPY точечный), CI без персистентного кеша (эфемерный раннер — registry/GHA-кеш), --pull в сборке (свежий базовый образ — новый хеш — осознанная цена актуальности базы).

Как ускорить установку зависимостей без кеша слоёв? mount=type=cache на каталог кеша пакетного менеджера (uv/pip/npm — установка даже «с нуля» без загрузки из сети — секунды), выбор быстрого установщика (uv против pip — порядок величины на резолве и распаковке — окружения), wheel-зависимости против sdist-сборок (нативные пакеты без wheels компилируются минутами — пины версий с wheels).

Нужно ли собирать образ на каждый PR? Да, если PR меняет Dockerfile/зависимости (сборка должна быть проверена — «сломанный Dockerfile» находится в PR, не в main); для чисто кодовых PR достаточно тестов на существующем образе-базе + сборка при изменении docker-контекста (фильтр путей в triggers — экономия минут CI — Actions-настройка).

Один Dockerfile для dev и prod — нормально? Да при multistage: builder/dev-стадия (инструменты, тесты) и финальная рантайм-стадия — один файл, разные targets (--target dev локально, финальный — в CI/прод); два расходящихся Dockerfile'а — дрейф «локально работает, в проде нет» — единство файла с targets дешевле.

Источники: документация Docker build cache, build best practices, BuildKit — ссылки в шапке.