Итоги для разработки:
- Java-сборка ускорилась в 4 раза
- повторные загрузки зависимостей сократились
- кэш сохраняется между запусками
- частые обновления зависимостей не обнуляют весь кэш
- решение не усложняет Docker-сборку
Ускорение сборки не всегда требует менять сам процесс сборки. Иногда достаточно перестать заново скачивать и собирать то, что уже было получено на предыдущем запуске. Вопрос в том, что именно стоит сохранять между запусками и средствами какого уровня это лучше делать.
Разбираем два варианта кэширования зависимостей в GitLab CI и показываем, как выбрать подход с учетом того, как устроена сборка.
С чего начали
У нас около десятка бэкенд-сервисов: один большой Python-монолит на Django и группа Java-микросервисов на Spring Boot. Все они собираются в GitLab CI внутри Docker-in-Docker, каждый со своим Dockerfile.
Первой мы оптимизировали Python-сборку, которая занимала 15–20 минут. В ее Dockerfile возможность использовать кэш уже была заложена: зависимости устанавливались до копирования исходного кода. Но самого кэша между сборками не было. Каждая новая сборка попадала на билдер, который ничего не знал о предыдущей, и проходила все шаги заново.
Мы добавили кэш Docker-слоев из реестра, и сборка сократилась до 1–5 минут. Через несколько месяцев решили применить тот же подход к Java-сервисам. Здесь выяснилось, что устроить кэш таким же способом не получится.
Как кэш Docker-слоев ускорил Python-сборку
Docker собирает образ последовательно, выполняя инструкции из Dockerfile. Результат отдельных шагов сохраняется в слоях. Если содержимое слоя не изменилось, его можно не собирать заново, а взять из кэша.
В нашем Python-сервисе сначала устанавливаются системные и Python-зависимости, а уже после этого копируются исходники:
FROM python:3.14-slim-bookworm
WORKDIR /app
# Системные пакеты: тулчейн для сборки Python-пакетов
RUN apt-get update \
&& apt-get install -y build-essential curl libpq-dev gettext \
libxrender1 libjpeg62-turbo fontconfig libxtst6 \
xfonts-75dpi xfonts-base xz-utils \
&& fc-cache -fv \
&& apt-get purge -y --auto-remove && rm -rf /var/lib/apt/lists/*
# Пакетный менеджер и зависимости — до копирования исходников
RUN pip install uv
COPY pyproject.toml pyproject.toml
COPY uv.lock uv.lock
RUN uv sync --frozen
ENV PATH="/app/.venv/bin:$PATH"
# И только теперь — исходники
COPY ./python_code python_code
COPY ./config config
COPY ./manage.py manage.py
Такая последовательность и создает возможность использовать кэш: изменения в исходном коде происходят гораздо чаще, чем изменения зависимостей. Но чтобы использовать готовые слои в следующей сборке, новый билдер должен получить информацию о предыдущей.
Сохранили кэш вместе с Docker-образом
До изменений пайплайн выглядел так:
script:
- docker build -t $REGISTRY/$SERVICE-$ENV:$CI_PIPELINE_ID -f $BUILD_DOCKERFILE .
- docker push $REGISTRY/$SERVICE-$ENV:$CI_PIPELINE_ID
Мы добавили две настройки:
script:
- docker build
--build-arg BUILDKIT_INLINE_CACHE=1
--cache-from $REGISTRY/$SERVICE-$ENV:latest
-t $REGISTRY/$SERVICE-$ENV:$CI_PIPELINE_ID
-t $REGISTRY/$SERVICE-$ENV:latest
-f $BUILD_DOCKERFILE .
- docker push $REGISTRY/$SERVICE-$ENV:$CI_PIPELINE_ID
- docker push $REGISTRY/$SERVICE-$ENV:latest
BUILDKIT_INLINE_CACHE=1 говорит BuildKit сохранить метаданные кэша в манифесте Docker-образа. Сам BuildKit — это механизм Docker, который отвечает за сборку образа.
При следующем запуске --cache-from …:latest указывает BuildKit на предыдущий образ. Он сравнивает слои и, если нужный слой уже есть в кэше, использует его повторно.
Таким образом, кэш не нужно хранить отдельно: необходимая для его использования информация находится вместе с образом в registry.
Этот вариант работает при включенном BuildKit. Классический сборщик игнорирует BUILDKIT_INLINE_CACHE без ошибки или предупреждения, а для --cache-from требует предварительно скачать образ через docker pull. Начиная с Docker 23.0 BuildKit включен по умолчанию. На более старых версиях для раннеров нужно добавить DOCKER_BUILDKIT=1 в окружение job.
Отдельная инфраструктура для кэша или buildx-билдеры нам не понадобились. В результате сборка стала занимать около минуты, если менялись только исходники, и до пяти минут при изменении зависимостей вместо прежних 15–20 минут.
Почему с Java все оказалось сложнее
Java-сервисы тоже собираются внутри Docker, но сама сборка устроена иначе.
Она состоит из нескольких стадий. Сначала Maven собирает приложение в JDK-образе. Затем готовые артефакты Spring Boot раскладываются по слоям и переносятся в отдельный образ, в котором уже будет запускаться приложение. Это называется multi-stage сборкой: промежуточная стадия нужна для сборки, но целиком в финальный образ не попадает.
Исходный Dockerfile выглядел так:
# ---------- стадия сборки ----------
FROM eclipse-temurin:25 AS build
WORKDIR /workspace/app
COPY .mvn .mvn
COPY . .
RUN --mount=type=cache,target=/root/.m2 \
./mvnw install -Dmaven.test.skip=true
# Раскладываем fat jar на слои Spring Boot: зависимости, лоадер,
# snapshot-зависимости и собственно приложение
RUN java -Djarmode=tools -jar core/target/application.jar \
extract --layers --launcher --destination target/extracted
# ---------- рантайм ----------
FROM eclipse-temurin:25
WORKDIR /workspace/app
ARG EXTRACTED=/workspace/app/target/extracted
COPY --from=build ${EXTRACTED}/dependencies/ ./
COPY --from=build ${EXTRACTED}/spring-boot-loader/ ./
COPY --from=build ${EXTRACTED}/snapshot-dependencies/ ./
COPY --from=build ${EXTRACTED}/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Для Maven здесь уже использовался отдельный механизм BuildKit:
RUN --mount=type=cache,target=/root/.m2 \
./mvnw install -Dmaven.test.skip=true
Maven хранит загруженные зависимости в локальном репозитории .m2. cache mount предоставляет для него отдельное хранилище во время Docker-сборки, чтобы уже загруженные файлы можно было использовать повторно.
Мы хотели перенести этот кэш между сборками через registry так же, как сделали для Python. Но напрямую это сделать нельзя.
Первая проблема: Maven-кэш не является Docker-слоем
--mount=type=cache создает локальный том билдера. Его содержимое не становится частью Docker-образа, поэтому такой кэш нельзя экспортировать в registry через используемую нами схему. Чтобы зависимости можно было сохранить там, их пришлось превратить в обычный слой Docker-образа.
Для этого сначала копируются только файлы, которые описывают Maven-зависимости, затем Maven загружает сами зависимости, и только после этого копируется исходный код:
# Ключ этого слоя — только pom-файлы и wrapper.
COPY .mvn .mvn
COPY mvnw pom.xml ./
COPY api/pom.xml api/pom.xml
COPY core/pom.xml core/pom.xml
RUN ./mvnw -B dependency:go-offline
# И только теперь — исходники.
COPY . .
RUN ./mvnw install -Dmaven.test.skip=true
dependency:go-offline заранее загружает зависимости, необходимые Maven. Благодаря этому они становятся частью отдельного Docker-слоя, который уже можно сохранить и использовать при следующей сборке.
По сути, мы повторили принцип Python-сборки: сначала зависимости, потом исходники. В исходном Dockerfile этот блок заменил COPY . . и RUN --mount=type=cache …, остальные стадии не менялись. Но здесь возникла еще одна проблема.
Вторая проблема: нужный слой не попадает в финальный образ
Слой с Maven-зависимостями создается на промежуточной стадии build. А используемый нами inline cache сохраняет кэш финальной стадии.
Поэтому просто собрать и отправить готовый образ недостаточно: нужная нам промежуточная стадия в его inline cache не сохранялась.
Пришлось сначала отдельно собрать стадию build и опубликовать ее как самостоятельный образ с тегом :build-cache. Затем запускалась полная сборка, которая использовала этот образ как источник кэша:
# Проход 1: только стадия build, публикуем отдельным тегом.
docker build --target build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $REGISTRY/my-service:build-cache \
-t $REGISTRY/my-service:build-cache -f Dockerfile .
docker push $REGISTRY/my-service:build-cache
# Проход 2: полный образ, стадию build берём из :build-cache, рантайм — из :latest.
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $REGISTRY/my-service:build-cache \
--cache-from $REGISTRY/my-service:latest \
-t $REGISTRY/my-service:$CI_PIPELINE_ID \
-t $REGISTRY/my-service:latest \
-f Dockerfile .
docker push $REGISTRY/my-service:$CI_PIPELINE_ID
docker push $REGISTRY/my-service:latest
В результате ради кэша Maven-зависимостей получили отдельный образ и два прохода Docker-сборки.
Мы все же проверили эту схему на Java-сервисах. Ожидаемого результата она не дала.
Почему сборка не ускорилась
Изменения применили ко всем Java-сервисам, но уже через день откатили. Чистого замера получить не успели: в разных пайплайнах результат колебался от ускорения примерно на 30 секунд до увеличения времени сборки в два раза.
Позже мы подробнее разобрали логи и увидели две причины.
1) Кэш слишком часто становился неактуальным
Docker может повторно использовать слой с Maven-зависимостями, пока не изменились pom-файлы. У нас они меняются довольно часто: работает Renovate, а внутренняя общая библиотека выпускает новые версии примерно раз в три дня.
В зависимости от Java-сервиса обновление зависимостей затрагивает от шестой части до половины коммитов. В Python-монолите для сравнения зависимости меняются примерно в одном коммите из двадцати.
Поэтому в Python готовый слой удавалось использовать почти всегда. В Java изменения регулярно приводили к промаху кэша и слой приходилось создавать заново.
При этом кэш-образ нужно было скачивать и загружать при каждой сборке. Эти операции выполнялись постоянно, а воспользоваться сохраненным слоем удавалось не всегда.
2) Maven-зависимости все равно скачивались заново
Вторая проблема стала понятна из логов. Мы рассчитывали, что cache mount поможет передавать Maven-зависимости между сборками и повторно придется скачивать только -SNAPSHOT-зависимости или плагины, с которыми не справился mvn dependency:go-offline.
Но кэш при каждой сборке приезжал пустым. Поэтому Maven снова скачивал зависимости с зеркала. То есть мы усложнили Docker-сборку, но одна из основных повторяющихся операций никуда не исчезла.
Проверили другой механизм BuildKit
У BuildKit есть еще один способ сохранять Docker-кэш — registry cache с mode=max.
В отличие от inline cache, он умеет сохранять метаданные слоев не только финальной, но и промежуточных стадий:
docker buildx build \
--cache-to type=registry,ref=registry.example.com/my-service:cache,mode=max \
--cache-from type=registry,ref=registry.example.com/my-service:cache \
-t registry.example.com/my-service:latest .
Для нашей сборки это означало, что слой с Maven-зависимостями на стадии build можно было бы кэшировать без отдельной сборки через --target build, второго прохода и тега :build-cache. Продуктовый образ при этом оставался бы отдельным от кэша.
Но этот вариант не подошел из-за нашей инфраструктуры. Для type=registry требовалась включенная функция containerd image store. На момент внедрения она была экспериментальной и на наших GitLab-раннерах не использовалась.
Мы попробовали другой режим buildx — docker-container. В этом случае терялись настройки Docker daemon хоста, в том числе зеркала и корпоративные CA. Поэтому этот вариант тоже не стали использовать.
После этого мы перестали искать способ сохранить Maven-зависимости именно через Docker-кэш и вернулись к самой исходной задаче: как передать уже скачанные зависимости из одной сборки в следующую.
Перенесли Maven-репозиторий в кэш GitLab
Maven уже хранит загруженные зависимости в локальном репозитории .m2. Поэтому вместо кэширования Docker-слоя мы решили сохранять сам этот каталог средствами GitLab CI и затем передавать его внутрь Docker-сборки.
По умолчанию локальный Maven-репозиторий находится в:
~/.m2/repository
Но GitLab может кэшировать только каталоги внутри рабочей директории проекта — $CI_PROJECT_DIR. Поэтому стандартный путь /root/.m2 нам не подходил.
Maven позволяет указать другое расположение локального репозитория:
-Dmaven.repo.local=/куда/угодно
Мы перенесли его в:
$CI_PROJECT_DIR/.m2/repository
Этот путь решает сразу две задачи.
Во-первых, он находится внутри $CI_PROJECT_DIR, поэтому GitLab может сохранить каталог после одной job и восстановить перед следующей.
Во-вторых, $CI_PROJECT_DIR используется как контекст docker build. Значит, восстановленный каталог .m2/repository вместе с остальными файлами проекта становится доступен внутри Docker-сборки. Отдельно передавать или монтировать его не нужно.
В итоге одна и та же директория связывает кэш GitLab, Maven на раннере и Maven внутри Docker-сборки.
Как настроили кэш
Конфигурация состоит из трех частей.
1. GitLab сохраняет .m2
.maven-repo-cache:
cache:
key: "maven-$CI_COMMIT_REF_SLUG"
fallback_keys:
- "maven-master"
paths:
- .m2/repository
Ключом кэша используем название ветки (CI_COMMIT_REF_SLUG, для master — master), а не pom.xml. Это важно из-за частых обновлений зависимостей. Если использовать pom.xml в ключе, каждое такое изменение будет создавать новый кэш. С ключом по ветке уже загруженные зависимости сохраняются, а недостающие Maven может добавить в существующий репозиторий.
2. Maven-job используют тот же репозиторий
Job, которые запускают Maven непосредственно на раннере, например тесты и публикация артефактов, получают тот же путь:
.maven-cache:
extends: .maven-repo-cache
variables:
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress"
Эти job используют уже сохраненные зависимости и добавляют в репозиторий новые. Поэтому к моменту Docker-сборки .m2 уже заполнен.
3. Docker-сборка получает заполненный .m2
На стадии build Maven указываем путь к тому же репозиторию уже внутри контейнера:
FROM eclipse-temurin:25 AS build
WORKDIR /workspace/app
COPY .mvn .mvn
COPY . .
RUN ./mvnw --batch-mode --no-transfer-progress -T1C install \
-Dmaven.repo.local=/workspace/app/.m2/repository \
-Dmaven.test.skip=true
К этому моменту .m2/repository уже находится в контексте сборки и попадает внутрь контейнера через COPY . ..
При этом Maven-репозиторий остается только на промежуточной стадии build. В финальный образ переносятся только нужные слои Spring Boot, поэтому .m2 не увеличивает размер продуктового образа.
Как выглядит итоговая сборка
После перехода на GitLab cache отдельный слой dependency:go-offline, который мы добавляли во время эксперимента с Docker-кэшем, больше не нужен.
Раньше он был нужен, чтобы превратить Maven-зависимости в слой образа и сохранить его в registry. Теперь зависимости приезжают в .m2 еще до начала Docker-сборки.
Поэтому Dockerfile снова остается достаточно простым:
# ---------- стадия сборки ----------
FROM eclipse-temurin:25 AS build
WORKDIR /workspace/app
COPY .mvn .mvn
COPY . .
RUN ./mvnw --batch-mode --no-transfer-progress -T1C install \
-Dmaven.repo.local=/workspace/app/.m2/repository \
-Dmaven.test.skip=true
RUN java -Djarmode=tools -jar core/target/application.jar \
extract --layers --launcher --destination target/extracted
# ---------- рантайм ----------
FROM eclipse-temurin:25
WORKDIR /workspace/app
ARG EXTRACTED=/workspace/app/target/extracted
COPY --from=build ${EXTRACTED}/dependencies/ ./
COPY --from=build ${EXTRACTED}/spring-boot-loader/ ./
COPY --from=build ${EXTRACTED}/snapshot-dependencies/ ./
COPY --from=build ${EXTRACTED}/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Перед запуском GitLab восстанавливает .m2/repository. Поэтому сборка начинает работу с уже заполненным локальным репозиторием вместо повторного скачивания примерно 260 МБ из Nexus. Если после обновления зависимостей Renovate каких-то файлов в кэше еще нет, Maven докачивает их как обычно.
Сам .m2 остается в промежуточной стадии и в финальный образ не попадает.
Здесь же стало понятно, почему исходный --mount=type=cache не сохранял зависимости между нашими сборками. В Docker-in-Docker демон живет столько же, сколько job, поэтому при следующей job его cache mount уже недоступен. Более того, если оставить такой mount после перехода на GitLab cache, он будет перекрывать .m2, который приехал вместе с контекстом сборки.
Сократили Java-сборку с 10–12 до 2–3 минут
После перехода на GitLab cache зависимости перестали скачиваться заново при каждой сборке: .m2 восстанавливается уже заполненным.
В результате время Java-сборки сократилось с 10–12 до 2–3 минут. Большая часть оставшегося времени теперь уходит на запуск раннера и отправку итогового образа в registry.
На Python кэш Docker-слоев дал стабильный результат, потому что зависимости там меняются редко. В Java они обновляются гораздо чаще, а нужный Maven-кэш изначально жил только внутри конкретной Docker-сборки. Поэтому переносить Python-схему один в один оказалось неэффективно.
В нашем случае решение нашлось на другом уровне: вместо попытки сохранить Maven-зависимости внутри Docker-кэша мы сохранили сам локальный репозиторий средствами GitLab CI. Это позволило не скачивать один и тот же набор зависимостей при каждом запуске и при этом не усложнять Docker-сборку дополнительными образами и проходами.