Я 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 может сильно ускорить процесс сборки.