Я Backend разработчик на Python, и в одном из проектов мне понадобилось настроить сборку Docker-образа в GitLab CI/CD. Базовую настройку я сделал без проблем, но я хотел ускорить сборку по максимуму. И здесь я обнаружил Cache mount или кэш-монтирование.
Что такое кэш-монтирование в Docker?
Кэш-монтирование или Cache mount или --mount=type=cache — это специальное монтирование для инструкции RUN. Его основное применение — сохран��ние кэша пакетного менеджера в BuildKit и использование этого кэша в различных сборках. Кэш позволяет повторно использовать загруженные ранее зависимости, что значительно ускоряет процесс сборки.
Кэш-монтирование для Python
Вот так выглядит пример Dockerfile для Python/Pip из документации Docker:
RUN --mount=type=cache,target=/root/.cache/pip
pip install -r requirements.txt
Давайте разберемся, почему target указан как /root/.cache/pip. Значение target должно соответствовать пути, по которому находится кэш пакетного менеджера. В случае с Pip, кэш по умолчанию располагается в директории $HOME/.cache/pip. Точное расположение этого кэша можно узнать с помощью команды pip cache dir. На него влияют несколько значений: текущий пользователь, переменные окружения XDG_CACHE_HOME и PIP_CACHE_DIR, а также аргумент --cache-dir.
Как устроено кэш-монтирование в BuildKit?
Чтобы понять, как работает кэш-монтирование, нужно заглянуть под капот BuildKit. Во время сборки он создаёт различные объекты и помещает их в BuildKit cache, просмотреть их можно, выполнив docker buildx du --verbose. Нас интересуют два типа объектов:
-
regular — это слои, из которых состоит образ
-
exec.cachemount — это кэш-монтирование
В примере выше, regular объект соответствует слою:
RUN pip install -r requirements.txt
А exec.cachemount, это объект который монтируется в /root/.cache/pip.
Это важно понимать, потому что при выполнении docker push в реестр отправляются только слои образа, то есть объекты regular. Состояние кэш-монтирования не имеет значения ни для содержимого образа, ни для системы кэширования слоев Docker.
Кэш-монтирование — это локальный механизм для оптимизации процесса сборки на хосте. Он существует только во время сборки и не сохраняется в итоговом образе.
Для более полного понимания важно упомянуть еще несколько моментов:
-
В файловой системе образа может существовать каталог, указанный в качестве
targetдля кэш-монтирования (в примере выше, это/root/.cache/pip). Однако при выполнении инструкции RUN с кэш-монтированием исходное содержимое этого каталога временно замещается содержимым кэш-монтирования. После завершения инструкции каталог возвращает своё исходное состояние, которое было до выполнения инструкции. -
Флаг
--no-cacheигнорирует кэш-монтирования аналогично тому, как он игнорирует кэш слоев.
Аргументы и повторное использование кэш-монтирования
|
Аргумент |
Назначение |
По умолчанию |
|
|
Путь монтирования внутри контейнера |
(обязательный) |
|
|
Уникальный идентификатор кэш-монтирования |
|
|
|
Монтирование только для чтения |
|
|
|
Политика совместного доступа: |
|
|
|
Источник монтирования: |
Пустой каталог |
|
|
Подкаталог в |
Корень |
|
|
Права доступа (chmod) для |
0755 |
|
|
User ID для владельца |
0 |
|
|
Group ID для владельца |
0 |
В документации хорошо описано поведение аргумента sharing:
-
sharedпозволяет нескольким писателям использовать одно кэш-монтирование -
privateсоздаёт новое кэш-монтирование, если есть несколько писателей -
lockedприостанавливает работу второго писателя до тех пор, пока первый не освободит кэш-монтирование
Новое кэш-монтирование создаётся не только при смене id или из-за политики sharing. Любое изменение следующих аргументов также приведёт к созданию нового кэша, даже если id остался прежним:
-
uid,gid,mode(изменение прав доступа) -
from(изменение источника для монтирования)
Это означает, что для использования кэш-монтирования между сборками нужно следить за согласованностью всех этих аргументов.
Мои тесты поведения кэш-монтирования при различных аргументах можно посмотреть здесь.
Преимущества кэш-монтирования
-
Кэширование зависимостей. Сохранение кэша пакетных менеджеров (pip, apt и др.) позволяет значительно ускорить последующие сборки, избегая повторной загрузки зависимостей.
-
Переиспользование между сборками. Кэш-монтирование может использоваться на разных этапах сборки и между различными сборками благодаря уникальному идентификатору (
id). -
Кумулятивное обновление. Зависимости в кэш-монтировании постепенно накапливаются, что позволяет не создавать его каждый раз заново.
-
Изоляция от образа. Изменения в кэш-монтировании не влияют на слои образа, что предотвращает инвалидацию кэша сборки и не приводит к созданию новых образов.
Кэш-монтирование в GitLab CI/CD
С кэш-монтированием разобрались. Теперь задача состояла в том, чтобы использовать его в GitLab CI/CD. Но параллельно с внедрением кэш-монтирования в сборку, конфигурация GitLab Runner была изменена — произошёл переход с DinD на DooD.
Docker-in-Docker (DinD)
При использовании DinD, контейнер, в котором выполняется сборка, имеет доступ к Docker через контейнер docker:dind (изолированный Docker-демон), который описывается в секции services.
Пример .gitlab-ci.yml:
stages:
- build
dind_job:
stage: build
tags:
- dind_tag # tag нужен, чтобы job выполнял конкретный GitLab Runner
image: docker:29.1.2
services:
- name: docker:29.1.2-dind
alias: docker_dind # через alias хотел показать, что в DOCKER_HOST прописывается именно контейнер из services
variables:
DOCKER_HOST: tcp://docker_dind:2375
DOCKER_TLS_CERTDIR: ""
script:
- docker info
Вот что происходит, когда запускается job:
-
Создание среды. GitLab Runner, используя хостовый Docker, создает два контейнера:
-
Основной контейнер. Основная среда для выполнения скриптов (задаётся ключом
imageв job). -
Сервисный контейнер
docker:dind. Изолированный Docker-демон, работающий как служба (задаётся ключомservicesв job).
-
-
Взаимодействие с Docker. Когда в job используется команда
docker(например, для сборки образа), она через переменную окруженияDOCKER_HOSTобращается к Docker в контейнереdocker:dind. Весь процесс сборки, включая образы и кэш, изолирован внутри этого контейнера. -
Завершение задачи. После выполнения job GitLab Runner останавливает и удаляет оба контейнера. Контейнер
docker:dindи все его данные (образы, кэш, контейнеры) безвозвратно удаляются.
В DinD не сохраняется BuildKit cache, так как он хранится в контейнере docker:dind, который удаляется в конце каждого job. Поэтому содержимое кэш-монтирования необходимо сохранить в другом месте. Для этого я использовал GitLab cache.
Доработаем наш пример Dockerfile:
FROM python:3.12.10-slim-bookworm
RUN --mount=type=bind,source=/requirements.txt,target=/requirements.txt
--mount=type=cache,id=pip,target=/root/.cache/pip
python -m pip install -r requirements.txt
После этой сборки появляется кэш-монтирование, и теперь его содержимое нужно сохранить в GitLab cache. Здесь я использую вспомогательную сборку run-cache.Dockerfile c --target save_cache:
FROM bash:5.2.37 as save_cache
ARG CACHE_DIR_NAME
RUN --mount=type=cache,id=pip,target=/root/.cache/pip
mkdir -p /${CACHE_DIR_NAME}/pip &&
cp -R /root/.cache/pip/* /${CACHE_DIR_NAME}/pip || true
.save-cache:
script:
- >
docker buildx build
--build-arg CACHE_DIR_NAME=${CACHE_DIR_NAME}
--target save_cache
--progress=plain
-t cache_storage_image
- < run-cache.Dockerfile
- docker create -ti --name cache_storage_container cache_storage_image && rm -rf ${CACHE_DIR}/*
- docker cp -L cache_storage_container:/${CACHE_DIR_NAME} ${CACHE_PARENT_DIR}
Мы смогли сохранить содержимое кэш-монтирования. Теперь нужно использовать этот кэш в другом job. Для этого необходимо выгрузить содержимое кэш-монтирования из GitLab cache и загрузить в BuildKit cache. Снова вспомогательная сборка run-cache.Dockerfile только c --target download_cache:
FROM bash:5.2.37 as download_cache
RUN --mount=type=bind,from=external_cache,target=/external_cache
--mount=type=cache,id=pip,target=/root/.cache/pip
cp -R /external_cache/pip/* /root/.cache/pip || true
.download-cache:
script:
- mkdir -p ${CACHE_DIR}
- >
docker buildx build
--build-context external_cache=${CACHE_DIR}
--target download_cache
--progress=plain
-t cache_creator_image
- < run-cache.Dockerfile
Полная конфигурация DinD варианта:
# .gitlab-ci.yml
stages:
- build
.dind:
tags:
- dind_tag
stage: build
image: docker:29.1.2
services:
- name: docker:29.1.2-dind
alias: docker_dind
variables:
DOCKER_HOST: "tcp://docker_dind:2375"
DOCKER_TLS_CERTDIR: ""
.cache:
variables:
CACHE_PARENT_DIR: "${CI_PROJECT_DIR}"
CACHE_DIR_NAME: "cache"
CACHE_DIR: "${CACHE_PARENT_DIR}/${CACHE_DIR_NAME}"
CACHE_POLICY: "pull-push"
CACHE_KEY: "dind_env"
cache:
key: "${CACHE_KEY}"
paths:
- "${CACHE_DIR}"
policy: "${CACHE_POLICY}"
build:
extends:
- .dind
- .cache
script:
- docker images
- !reference [.download-cache, script]
- >
docker buildx build
--progress=plain
-t my_image:1
-f Dockerfile requirements/
# - docker push my_image:1
- !reference [.save-cache, script]
- docker images
.download-cache:
script:
- mkdir -p ${CACHE_DIR}
- >
docker buildx build
--build-context external_cache=${CACHE_DIR}
--target download_cache
--progress=plain
-t cache_creator_image
- < run-cache.Dockerfile
.save-cache:
script:
- >
docker buildx build
--build-arg CACHE_DIR_NAME=${CACHE_DIR_NAME}
--target save_cache
--progress=plain
-t cache_storage_image
- < run-cache.Dockerfile
- docker create -ti --name cache_storage_container cache_storage_image && rm -rf ${CACHE_DIR}/*
- docker cp -L cache_storage_container:/${CACHE_DIR_NAME} ${CACHE_PARENT_DIR}
# run-cache.Dockerfile
FROM bash:5.2.37 as download_cache
RUN --mount=type=bind,from=external_cache,target=/external_cache
--mount=type=cache,id=pip,target=/root/.cache/pip
cp -R /external_cache/pip/* /root/.cache/pip || true
FROM bash:5.2.37 as save_cache
ARG CACHE_DIR_NAME
RUN --mount=type=cache,id=pip,target=/root/.cache/pip
mkdir -p /${CACHE_DIR_NAME}/pip &&
cp -R /root/.cache/pip/* /${CACHE_DIR_NAME}/pip || true
# Dockerfile
FROM python:3.12.10-slim-bookworm
RUN --mount=type=bind,source=/requirements.txt,target=/requirements.txt
--mount=type=cache,id=pip,target=/root/.cache/pip
python -m pip install -r requirements.txt
Преимущества:
-
Возможность прокидывать кэш в любой job.
CACHE_KEYявляется переменной, которая указывает на определённую среду сборки. Например, сборка под разные версии Python или CPU/GPU образы. Если нужно собрать под Python 3.12, задаёмCACHE_KEY: "py312"— и подгружается кэш с пакетами для Python 3.12. -
Контроль политики кэша. С помощью
CACHE_POLICYможно настраивать кэш только на чтение для job. -
Автоматическая очистка BuildKit cache после job. Не нужно думать о том, что происходит вокруг сборки.
Недостатки:
-
Автоматическая очистка BuildKit cache после job. Именно из-за этого механизма приходит��я организовывать "карусель кэша".
-
"Карусель кэша". Необходимо загружать и выгружать данные из BuildKit cache, а также загружать и выгружать их из GitLab cache в каждом job. На это тратится время и ресурсы.
Docker-out-of-Docker (DooD)
При использовании DooD, контейнер, в котором выполняется сборка, имеет доступ к Docker через монтирование docker.sock хоста, то есть использует Docker хоста.
Пример .gitlab-ci.yml:
stages:
- build
dood_job:
stage: build
tags:
- dood_tag
image: docker:29.1.2
script:
- docker info
Снова рассмотрим, что происходит, когда запускается job:
-
Создание среды. GitLab Runner, используя хостовый Docker, создает основной контейнер — среду для выполнения скриптов (задаётся ключом
imageв job). -
Взаимодействие с Docker. Когда в job используется команда
docker, она обращается к Docker хоста. Все данные сборки (образы, кэш, контейнеры) теперь находятся на хосте. -
Завершение задачи. После выполнения job GitLab Runner останавливает и удаляет основной контейнер. Но теперь весь BuildKit cache сохраняется на хосте и доступен в любом job.
DooD v1
Теперь нам не нужно заниматься сохранением и выгрузкой содержимого кэш-монтирования из GitLab cache. BuildKit cache сохраняется и можно использовать его в любом job.
Я удалил run-cache.Dockerfile, потому что он больше не нужен, и добавил sharing=locked для кэш-монтирования, чтобы избежать конфликтов при параллельных сборках.
# .gitlab-ci.yml
stages:
- build
.dood:
tags:
- dood_tag
stage: build
image: docker:29.1.2
build:
extends:
- .dood
script:
- docker images
- >
docker buildx build
--progress=plain
-t my_image:1
-f Dockerfile requirements/
# - docker push my_image:1
- docker rmi my_image:1
- docker images
# Dockerfile
FROM python:3.12.10-slim-bookworm
RUN --mount=type=bind,source=/requirements.txt,target=/requirements.txt
--mount=type=cache,id=dood-v1-pip,target=/root/.cache/pip,sharing=locked
python -m pip install -r requirements.txt
Преимущества:
-
Отсутствие "карусели кэша". Всё работает только на BuildKit cache.
-
Простота конфигурации.
Недостатки:
-
Раздувание BuildKit cache. Разные слои и кэш могут быстро занять всё доступное место.
-
Ограниченный контроль. В варианте с DinD можно было подключать конкретный кэш для пакетного менеджера в job под нужную среду и управлять политикой кэша.
-
Невозможность переиспользования кэша между разными GitLab Runner.
DooD v2
На основе DooD я решил объединить оба предыдущих варианта, чтобы получить все их преимущества и устранить недостатки.
В итоге всё свелось к такой идее: если нужное кэш-монтирование уже есть в BuildKit cache, я его использую, а если нет, то загружаю из GitLab cache. Идея простая, но, как говорится, дьявол в деталях:
-
Параллельное выполнение job
-
Синхронизация между BuildKit cache и GitLab cache
-
Предотвращение раздувания BuildKit cache
# .gitlab-ci.yml
stages:
- build
.dood:
tags:
- dood_tag
stage: build
image: docker:29.1.2
.cache:
variables:
CACHE_PARENT_DIR: "${CI_PROJECT_DIR}"
CACHE_DIR_NAME: "cache"
CACHE_DIR: "${CACHE_PARENT_DIR}/${CACHE_DIR_NAME}"
CACHE_CREATOR_IMAGE: "cache_creator_image:${CI_JOB_ID}"
CACHE_STORAGE_IMAGE: "cache_storage_image:${CI_JOB_ID}"
CACHE_STORAGE_CONTAINER: "cache_storage_container_${CI_JOB_ID}"
CACHE_POLICY: "pull-push"
CACHE_KEY: "dood_v2_env"
CACHE_ID_PREFIX: "dood-v2"
CACHE_BUILDKIT_TTL: "72h"
CACHE_BUILDKIT_MAX_SPACE: "30GB"
cache:
key: "${CACHE_KEY}"
paths:
- "${CACHE_DIR}"
policy: "${CACHE_POLICY}"
build:
extends:
- .dood
- .cache
script:
- docker images
- !reference [.download-cache, script]
- >
docker buildx build
--progress=plain
-t my_image:1
-f Dockerfile requirements/
# - docker push my_image:1
- docker rmi my_image:1
- !reference [.save-cache, script]
- docker images
after_script:
- !reference [.clear-buildkit-cache, script]
.download-cache:
script:
- mkdir -p ${CACHE_DIR}
- >
docker buildx build
--build-context external_cache=${CACHE_DIR}
--build-arg CACHE_ID_PREFIX=${CACHE_ID_PREFIX}
--build-arg CACHE_MARK=${CI_JOB_ID}
--target download_cache
--progress=plain
-t ${CACHE_CREATOR_IMAGE}
- < run-cache.Dockerfile
- docker rmi ${CACHE_CREATOR_IMAGE}
.save-cache:
script:
- >
docker buildx build
--build-arg CACHE_ID_PREFIX=${CACHE_ID_PREFIX}
--build-arg CACHE_DIR_NAME=${CACHE_DIR_NAME}
--build-arg CACHE_MARK=${CI_JOB_ID}
--target save_cache
--progress=plain
-t ${CACHE_STORAGE_IMAGE}
- < run-cache.Dockerfile
- docker rm -f ${CACHE_STORAGE_CONTAINER} || true
- docker create -ti --name ${CACHE_STORAGE_CONTAINER} ${CACHE_STORAGE_IMAGE} && rm -rf ${CACHE_DIR}/*
- docker cp -L ${CACHE_STORAGE_CONTAINER}:/${CACHE_DIR_NAME} ${CACHE_PARENT_DIR}
- docker rm -f ${CACHE_STORAGE_CONTAINER}
- docker rmi ${CACHE_STORAGE_IMAGE}
.clear-buildkit-cache:
script:
- docker buildx prune -f --filter=type=regular --filter=description~=tag_no_cache
- docker buildx prune -f --filter=type=exec.cachemount --filter=description~=tag_no_cache_domain --filter=until=${CACHE_BUILDKIT_TTL}
- docker buildx prune -f --reserved-space=${CACHE_BUILDKIT_MAX_SPACE}
# - docker buildx prune -f --keep-storage=${CACHE_BUILDKIT_MAX_SPACE} (--keep-storage deprecated)
# run-cache.Dockerfile
FROM bash:5.2.37 as download_cache
ARG CACHE_ID_PREFIX
ARG CACHE_MARK
RUN --mount=type=bind,from=external_cache,target=/external_cache
--mount=type=cache,id=${CACHE_ID_PREFIX}-pip,target=/root/.cache/pip,sharing=locked
if [ -f /root/.cache/pip/.cache_warmed ]; then
echo "BuildKit cache already warmed";
else
echo "Warming BuildKit cache from GitLab" &&
cp -R /external_cache/pip/* /root/.cache/pip || true &&
touch /root/.cache/pip/.cache_warmed;
fi
&&
echo ${CACHE_MARK} &&
echo tag_no_cache_domain_pip
FROM bash:5.2.37 as save_cache
ARG CACHE_ID_PREFIX
ARG CACHE_DIR_NAME
ARG CACHE_MARK
RUN --mount=type=cache,id=${CACHE_ID_PREFIX}-pip,target=/root/.cache/pip,sharing=locked
mkdir -p /${CACHE_DIR_NAME}/pip &&
cp -R /root/.cache/pip/* /${CACHE_DIR_NAME}/pip || true &&
rm -f /${CACHE_DIR_NAME}/pip/.cache_warmed &&
echo ${CACHE_MARK} &&
echo tag_no_cache_domain_pip
# Dockerfile
FROM python:3.12.10-slim-bookworm
RUN --mount=type=bind,source=/requirements.txt,target=/requirements.txt
--mount=type=cache,id=dood-v2-pip,target=/root/.cache/pip,sharing=locked
python -m pip install -r requirements.txt
Обсудим детали подробнее:
1. Параллельное выполнение job
Для кэш-монтирования везде используется sharing=locked. Для вспомогательных образов и контейнеров, чтобы избежать конфликтов в Docker, к их именам добавляется CI_JOB_ID. Для сборок run-cache.Dockerfile прокидываем внутрь CI_JOB_ID, чтобы избежать использования кэша слоёв — иначе инструкция не выполнится и копирование будет пропущено.
И была добавлена переменная CACHE_ID_PREFIX, предназначенная для кэш-монтирования. Она служит аналогом CACHE_KEY, но для BuildKit cache. Однако существует важное отличие. Если использовать эту переменную в основной сборке (ARG CACHE_ID_PREFIX), это может привести к инвалидации кэша последующих слоёв, а это противоречит сути кэш-монтирования, так как этот механизм не должен влиять на кэш слоёв и на формирование образа. Поэтому в основной сборке я предпочитаю использовать для id постоянные значения. Хотя, рассматривая CACHE_ID_PREFIX как указатель на среду, я пока не нашёл сценария, в котором это привело бы к критическим проблемам.
2. Синхронизация между BuildKit cache и GitLab cache
Для проверки прогрева кэш-монтирования используется файл .cache_warmed. В конце каждого job содержимое кэш-монтирования сохраняется в GitLab cache.
3. Предотвращение раздувания BuildKit cache
Для этого был написан блок .clear-buildkit-cache, внутри которого выполняется docker buildx prune с фильтрами. Для более точечного удаления слоёв используется метка tag_no_cache_domain_pip. Эту метку можно кастомизировать как угодно, но в данном случае она состоит из трёх частей, что позволяет удалять объекты BuildKit cache на разных уровнях:
-
tag_no_cache— общая часть для полной очистки (--filter=description~=tag_no_cache) -
domain— доменная часть для очистки объектов определённого домена (например, по имени отдела или кодовому имени части приложения) (--filter=description~=tag_no_cache_domain) -
pip— имя пакетного менеджера для очистки его кэша во всех доменах. В примере это не используется, но может быть удобно для очистки (--filter=description~="tag_no_cache_w+_pip")
И вот что делает каждый prune:
-
Первый
pruneудаляет объекты типа regular, которые создаются во время сборокrun-cache.Dockerfile. Они не нужны, поэтому удаляются все. -
Второй
pruneудаляет объекты типа exec.cachemount (кэш-монтирования) по истечении TTL. -
Третий
pruneудаляет всё, но использует--reserved-space(раньше--keep-storage), поэтому удаляет объекты из BuildKit cache по логике LRU и останавливается, когда освобождается достаточно места.
Заключение
Итак, мы рассмотрели кэш-монтирование, разобрались в принципах его работы, изучили его параметры, обсудили возможные подводные камни, а также определили его преимущества.
Далее мы проанализировали несколько подходов к использованию кэш-монтирования в GitLab CI/CD, каждый из которых обладает своими сильными и слабыми сторонами:
-
DinD вариант предоставляет изолированную среду, но требует организации сложной "карусели кэша" между BuildKit cache и GitLab cache, что замедляет процесс сборки.
-
DooD v1 значительно упрощает конфигурацию, используя общий BuildKit cache на хосте, но может привести к его раздуванию и не позволяет делиться кэшем между разными GitLab Runner.
-
DooD v2 представляет собой гибридный подход, который объединяет преимущества обоих методов: автоматическое использование локального BuildKit cache при его наличии с резервным копированием в GitLab cache, а также управление размером кэша через регулярную очистку.
Выбор оптимального подхода зависит от ваших конкретных условий: частоты сборок, доступного дискового пространства, объёма зависимостей и тд.
Развернуть локальный GitLab с разными GitLab Runner и примерами проектов можно с помощью этого репозитория.
Кэш-монтирование является мощным инструментом для оптимизации сборок Docker-образов и его правильное использование в CI/CD может сильно ускорить процесс сборки.
Автор: Arut1995
