Я 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. Нас интересуют два типа объектов:

  1. regular — это слои, из которых состоит образ

  2. 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 игнорирует кэш-монтирования аналогично тому, как он игнорирует кэш слоев.

Аргументы и повторное использование кэш-монтирования

Аргумент

Назначение

По умолчанию

target, dst, destination

Путь монтирования внутри контейнера

(обязательный) 

id

Уникальный идентификатор кэш-монтирования

target 

ro, readonly

Монтирование только для чтения

false

sharing

Политика совместного доступа: shared, private, locked

shared

from

Источник монтирования: build stage, build context, image

Пустой каталог

source

Подкаталог в from для монтирования

Корень from

mode

Права доступа (chmod) для target

0755

uid

User ID для владельца target

0

gid

Group ID для владельца target

0

В документации хорошо описано поведение аргумента sharing:

  1. shared позволяет нескольким писателям использовать одно кэш-монтирование

  2. private создаёт новое кэш-монтирование, если есть несколько писателей

  3. locked приостанавливает работу второго писателя до тех пор, пока первый не освободит кэш-монтирование

Новое кэш-монтирование создаётся не только при смене id или из-за политики sharingЛюбое изменение следующих аргументов также приведёт к созданию нового кэша, даже если id остался прежним:

  • uidgidmode (изменение прав доступа)

  • from (изменение источника для монтирования)

Это означает, что для использования кэш-монтирования между сборками нужно следить за согласованностью всех этих аргументов.

Мои тесты поведения кэш-монтирования при различных аргументах можно посмотреть здесь.

Преимущества кэш-монтирования

  1. Кэширование зависимостей. Сохранение кэша пакетных менеджеров (pip, apt и др.) позволяет значительно ускорить последующие сборки, избегая повторной загрузки зависимостей.

  2. Переиспользование между сборками. Кэш-монтирование может использоваться на разных этапах сборки и между различными сборками благодаря уникальному идентификатору (id).

  3. Кумулятивное обновление. Зависимости в кэш-монтировании постепенно накапливаются, что позволяет не создавать его каждый раз заново.

  4. Изоляция от образа. Изменения в кэш-монтировании не влияют на слои образа, что предотвращает инвалидацию кэша сборки и не приводит к созданию новых образов.

Кэш-монтирование в 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:

  1. Создание среды. GitLab Runner, используя хостовый Docker, создает два контейнера:

    • Основной контейнер. Основная среда для выполнения скриптов (задаётся ключом image в job).

    • Сервисный контейнер docker:dind. Изолированный Docker-демон, работающий как служба (задаётся ключом services в job).

  2. Взаимодействие с Docker. Когда в job используется команда docker (например, для сборки образа), она через переменную окружения DOCKER_HOST обращается к Docker в контейнере docker:dind. Весь процесс сборки, включая образы и кэш, изолирован внутри этого контейнера.

  3. Завершение задачи. После выполнения 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

Преимущества:

  1. Возможность прокидывать кэш в любой job. CACHE_KEY является переменной, которая указывает на определённую среду сборки. Например, сборка под разные версии Python или CPU/GPU образы. Если нужно собрать под Python 3.12, задаём CACHE_KEY: "py312" — и подгружается кэш с пакетами для Python 3.12.

  2. Контроль политики кэша. С помощью CACHE_POLICY можно настраивать кэш только на чтение для job.

  3. Автоматическая очистка BuildKit cache после job. Не нужно думать о том, что происходит вокруг сборки.

Недостатки:

  1. Автоматическая очистка BuildKit cache после job. Именно из-за этого механизма приходится организовывать "карусель кэша".

  2. "Карусель кэша". Необходимо загружать и выгружать данные из 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:

  1. Создание среды. GitLab Runner, используя хостовый Docker, создает основной контейнер — среду для выполнения скриптов (задаётся ключом image в job).

  2. Взаимодействие с Docker. Когда в job используется команда docker, она обращается к Docker хоста. Все данные сборки (образы, кэш, контейнеры) теперь находятся на хосте.

  3. Завершение задачи. После выполнения 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

Преимущества:

  1. Отсутствие "карусели кэша". Всё работает только на BuildKit cache.

  2. Простота конфигурации.

Недостатки:

  1. Раздувание BuildKit cache. Разные слои и кэш могут быстро занять всё доступное место.

  2. Ограниченный контроль. В варианте с DinD можно было подключать конкретный кэш для пакетного менеджера в job под нужную среду и управлять политикой кэша.

  3. Невозможность переиспользования кэша между разными GitLab Runner.

DooD v2

На основе DooD я решил объединить оба предыдущих варианта, чтобы получить все их преимущества и устранить недостатки.

В итоге всё свелось к такой идее: если нужное кэш-монтирование уже есть в BuildKit cache, я его использую, а если нет, то загружаю из GitLab cache. Идея простая, но, как говорится, дьявол в деталях:

  1. Параллельное выполнение job

  2. Синхронизация между BuildKit cache и GitLab cache

  3. Предотвращение раздувания 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:

  1. Первый prune удаляет объекты типа regular, которые создаются во время сборок run-cache.Dockerfile. Они не нужны, поэтому удаляются все.

  2. Второй prune удаляет объекты типа exec.cachemount (кэш-монтирования) по истечении TTL.

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