Главная/CI/CD/Урок

16.3. Build cache в CI

Цели

После этого материала вы сможете:

  • объяснить, почему сборка в CI начинается с пустого кэша, даже если локально она мгновенна;
  • выбрать тип кэша под задачу и объяснить ограничение каждого;
  • объяснить, почему mode=min теряет кэш промежуточных стадий, и когда это заметно;
  • назвать вид кэша, который в CI не работает вовсе, хотя выглядит работающим;
  • настроить кэш так, чтобы новая ветка не начинала с нуля;
  • измерить эффект и подтвердить числами, а не ощущением.

Предварительные знания

Ключевые термины

ТерминОбъяснение
cache-fromОткуда брать кэш
cache-toКуда сохранять кэш
mode=minЭкспортируются слои только конечной стадии
mode=maxЭкспортируются слои всех стадий
scopeРаздел кэша; определяет, кто его видит
cache mountRUN --mount=type=cache — кэш внутри сборки

Теория

Почему в CI кэш пуст

Локально повторная сборка занимает секунды: слои лежат в /var/lib/docker и переиспользуются (урок 5.5).

В CI каждый запуск получает чистую машину:

text
локально                          CI
─────────                         ──
/var/lib/docker сохраняется       машина создаётся заново
слои от прошлой сборки есть       слоёв нет
docker build → секунды            docker build → минуты

Это не недостаток настройки, а свойство модели: изоляция запусков друг от друга и есть то, ради чего CI устроен так.

Следствие: кэш нужно вынести наружу — сохранить после сборки и восстановить перед следующей.

Типы кэша

ТипГде хранитсяmode=maxГодится для
type=inlineВ самом образеНетПростые однослойные сборки
type=registryОтдельный образ в registryДаЛюбая CI-система
type=ghaКэш GitHub ActionsДаТолько GitHub Actions
type=localКаталог на дискеДаСвой исполнитель с постоянным диском
type=s3, type=azblobОбъектное хранилищеДаСвоя инфраструктура
yaml
cache-from: type=registry,ref=ghcr.io/org/app:buildcache
cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

Строка type=inline с пометкой «нет» — существенная. Inline-кэш встраивается в конфигурацию образа, а туда помещается только описание финальных слоёв. Промежуточные стадии в нём принципиально не сохраняются.

mode=min и mode=max: где теряется время

dockerfile
FROM python:3.13-slim AS base
COPY requirements.txt .
RUN pip install -r requirements.txt      # ← дорогой слой

FROM base AS production
COPY src/ ./src/                          # ← дешёвый слой
РежимЧто экспортируетсяЧто произойдёт при следующей сборке
mode=minСлои productionСтадия base собирается заново: pip install выполняется
mode=maxСлои всех стадийbase берётся из кэша

По умолчанию — mode=min. Отсюда типичная картина: кэш настроен, а сборка всё равно занимает минуты, потому что установка зависимостей выполняется каждый раз.

Правило: для multi-stage сборок всегда mode=max.

Цена: кэш занимает больше места и дольше передаётся. Для типового приложения это оправдано; для образа с гигабайтными промежуточными стадиями стоит измерить.

Cache mount в CI не работает

dockerfile
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

Локально это ускоряет повторные сборки: каталог кэша pip сохраняется между ними.

В CI он бесполезен. Cache mount живёт в состоянии сборщика, а не в экспортируемом кэше. cache-to экспортирует слои, но не содержимое cache mount.

text
что экспортирует cache-to:     слои образа
что НЕ экспортирует:           содержимое --mount=type=cache

Новый исполнитель — новый сборщик — пустой cache mount. Инструкция отрабатывает так, как будто кэша нет.

Это одна из самых дорогих иллюзий в CI: конструкция выглядит как оптимизация, присутствует в примерах и не даёт ничего.

Что работает вместо: обычное кэширование слоёв.

dockerfile
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt   # отдельный слой
COPY src/ ./src/                                      # меняется чаще

Слой с зависимостями попадает в экспортируемый кэш и переиспользуется, пока не изменится requirements.txt (урок 5.5).

Cache mount при этом не мешает — он просто не помогает. Держать обе меры одновременно разумно: локально работает первая, в CI — вторая.

Кэш между ветками

Кэш GitHub Actions разделён на области. Правило видимости:

text
ветка feature/x  ──► своя область ──► видит: свою + область ветки по умолчанию
ветка main       ──► своя область ──► видит: только свою

Новая ветка своей области ещё не имеет и читает кэш из main. Это работает — но только если сборка на main вообще сохраняла кэш.

Типичная ошибка:

yaml
- uses: docker/build-push-action@v6
  with:
    cache-to: type=gha,mode=max
    # публикация только на main, а сборка — на всех ветках

Если шаг сохранения кэша выполняется только на main, а на ветках его нет, кэш обновляется редко и быстро устаревает.

Надёжнее — сохранять кэш на всех ветках, а читать из двух источников:

yaml
cache-from: |
  type=gha,scope=${{ github.ref_name }}
  type=gha,scope=main
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

Ветка пишет в свою область и читает из своей плюс из main. Первая сборка новой ветки берёт кэш main; последующие — свой.

Что измерять

ВеличинаКак получить
Время холодной сборки--no-cache
Время тёплой сборкиПовторная сборка с тем же кэшем
Доля попаданийЧисло строк CACHED в выводе --progress=plain
Размер кэшаРазмер образа кэша в registry
Время передачи кэшаРазница между временем шага и временем сборки

Последняя строка неочевидна: кэш нужно скачать. При большом mode=max передача может съесть выигрыш — особенно если изменения затрагивают ранние слои и кэш всё равно не пригодится.

Полезная проверка: сравнить полное время шага сборки с кэшем и без. Если разница мала, кэш не окупается.


Внутренний механизм

Как BuildKit решает, что кэшировано

Для каждой инструкции вычисляется ключ — хеш от:

  • самой инструкции;
  • ключа предыдущего шага;
  • содержимого копируемых файлов (для COPY и ADD).

Совпадение ключа означает попадание. Отсюда два свойства:

Изменение раннего слоя обесценивает все последующие. Ключ входит в вычисление следующего, и цепочка ломается.

COPY . . в начале обесценивает всё. Любое изменение любого файла меняет ключ. Отсюда порядок: сначала файл зависимостей, потом установка, потом код.

Почему inline-кэш не умеет mode=max

Inline-кэш записывается в конфигурацию образа — в тот же blob, где Entrypoint, Env и history (урок 14.1).

Конфигурация описывает один образ: его слои и порядок. Места для описания слоёв промежуточных стадий в этой структуре нет.

Отсюда ограничение: type=inline работает только с mode=min, и попытка задать mode=max игнорируется.


Команды и примеры

Холодная и тёплая сборка

bash
mkdir -p /tmp/cache && cd /tmp/cache
mkdir -p src

cat > requirements.txt <<'EOF'
click==8.3.0
httpx==0.28.1
pydantic==2.12.4
EOF

cat > src/__init__.py <<'PY'
PY
cat > src/app.py <<'PY'
"""Приложение для измерения кэша сборки."""
from __future__ import annotations


def main() -> int:
    print("работает")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Зависимости отдельным слоем: меняются реже кода
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS production
COPY src/ ./src/
ENTRYPOINT ["python", "-m", "src.app"]
EOF

now() { python3 -c 'import time; print(time.monotonic())'; }
took() { python3 -c "print(f'{$2 - $1:.1f}')"; }

echo "═══ холодная сборка (--no-cache) ═══"
t0="$(now)"
docker build -q --no-cache --target production -t cache:cold . > /dev/null
t1="$(now)"
cold="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$cold"

echo "═══ тёплая сборка (локальный кэш) ═══"
t0="$(now)"
docker build -q --target production -t cache:warm . > /dev/null
t1="$(now)"
warm="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$warm"

echo "═══ тёплая сборка после изменения КОДА ═══"
python3 -c "
from pathlib import Path
p = Path('src/app.py')
p.write_text(p.read_text().replace('работает', 'работает (изменено)'))
"
t0="$(now)"
docker build -q --target production -t cache:codechange . > /dev/null
t1="$(now)"
codechange="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$codechange"

echo "═══ тёплая сборка после изменения ЗАВИСИМОСТЕЙ ═══"
echo "rich==14.2.0" >> requirements.txt
t0="$(now)"
docker build -q --target production -t cache:depchange . > /dev/null
t1="$(now)"
depchange="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$depchange"

echo "═══ сводка ═══"
# Значения передаются через окружение: heredoc в кавычках,
# поэтому тело остаётся валидным Python и проверяется статически
COLD="$cold" WARM="$warm" CODE="$codechange" DEPS="$depchange" python3 - <<'PY'
import os

cold = float(os.environ["COLD"])
rows = [
    ("холодная (--no-cache)", cold),
    ("тёплая, ничего не менялось", float(os.environ["WARM"])),
    ("тёплая, изменился код", float(os.environ["CODE"])),
    ("тёплая, изменились зависимости", float(os.environ["DEPS"])),
]
print(f"  {'случай':<34} {'время':>8} {'от холодной':>13}")
print("  " + "─" * 58)
for name, secs in rows:
    print(f"  {name:<34} {secs:>6.1f} с {secs / cold * 100:>11.0f} %")
print()
print("  Изменение кода дёшево: слой с зависимостями взят из кэша.")
print("  Изменение зависимостей дорого: ломается ранний слой,")
print("  и всё, что после него, собирается заново.")
PY

# Возвращаем исходное состояние
git checkout requirements.txt 2>/dev/null || python3 -c "
from pathlib import Path
p = Path('requirements.txt')
p.write_text(''.join(l for l in p.read_text().splitlines(keepends=True)
                     if 'rich' not in l))
"
docker rmi -f cache:cold cache:warm cache:codechange cache:depchange > /dev/null 2>&1

Ожидаемый вывод:

text
═══ холодная сборка (--no-cache) ═══
  время: 24.3 с
═══ тёплая сборка (локальный кэш) ═══
  время: 0.4 с
═══ тёплая сборка после изменения КОДА ═══
  время: 0.9 с
═══ тёплая сборка после изменения ЗАВИСИМОСТЕЙ ═══
  время: 21.7 с
═══ сводка ═══
  случай                                время   от холодной
  ──────────────────────────────────────────────────────────
  холодная (--no-cache)                  24.3 с         100 %
  тёплая, ничего не менялось              0.4 с           2 %
  тёплая, изменился код                   0.9 с           4 %
  тёплая, изменились зависимости         21.7 с          89 %
  ...

Четвёртая строка — практический ориентир: изменение requirements.txt стоит почти как полная пересборка.

Именно поэтому зависимости выносят в отдельный слой перед кодом: код меняется в каждом коммите, зависимости — редко.

Внешний кэш: сохранение и восстановление

bash
cd /tmp/cache
echo "═══ поднимаем registry для кэша ═══"
docker run -d --name cachereg -p 5000:5000 registry:2 > /dev/null 2>&1
sleep 4
curl -s -o /dev/null -w '  registry: HTTP %{http_code}\n' http://localhost:5000/v2/

echo "═══ сборка с сохранением кэша в registry ═══"
docker buildx create --name cachebuilder --use --driver docker-container \
    --driver-opt network=host > /dev/null 2>&1 || docker buildx use cachebuilder

t0="$(now)"
docker buildx build --target production \
    --cache-to "type=registry,ref=localhost:5000/app:buildcache,mode=max" \
    --tag localhost:5000/app:1 \
    --load . > build1.log 2>&1
t1="$(now)"
printf '  первая сборка: %s с\n' "$(took "$t0" "$t1")"

echo "═══ имитация нового исполнителя: чистый сборщик ═══"
docker buildx rm cachebuilder > /dev/null 2>&1
docker buildx create --name cachebuilder2 --use --driver docker-container \
    --driver-opt network=host > /dev/null 2>&1

echo "═══ сборка на чистом сборщике БЕЗ внешнего кэша ═══"
t0="$(now)"
docker buildx build --target production \
    --tag localhost:5000/app:2 --load . > build2.log 2>&1
t1="$(now)"
nocache="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$nocache"
printf '  строк CACHED: %s\n' "$(grep -c 'CACHED' build2.log || echo 0)"

echo "═══ сборка на чистом сборщике С внешним кэшем ═══"
docker buildx rm cachebuilder2 > /dev/null 2>&1
docker buildx create --name cachebuilder3 --use --driver docker-container \
    --driver-opt network=host > /dev/null 2>&1
t0="$(now)"
docker buildx build --target production \
    --cache-from "type=registry,ref=localhost:5000/app:buildcache" \
    --tag localhost:5000/app:3 --load . > build3.log 2>&1
t1="$(now)"
withcache="$(took "$t0" "$t1")"
printf '  время: %s с\n' "$withcache"
printf '  строк CACHED: %s\n' "$(grep -c 'CACHED' build3.log || echo 0)"

echo "═══ вывод ═══"
NOCACHE="$nocache" WITHCACHE="$withcache" python3 - <<'PY'
import os

nocache = float(os.environ["NOCACHE"])
withcache = float(os.environ["WITHCACHE"])
print(f"  без внешнего кэша: {nocache:.1f} с")
print(f"  с внешним кэшем:   {withcache:.1f} с")
if nocache > 0:
    print(f"  сокращение:        {(1 - withcache / nocache) * 100:.0f} %")
print()
print("  Чистый сборщик — это и есть модель нового исполнителя CI.")
print("  Локальный кэш там отсутствует всегда; помогает только внешний.")
PY

Ожидаемый вывод:

text
═══ поднимаем registry для кэша ═══
  registry: HTTP 200
═══ сборка с сохранением кэша в registry ═══
  первая сборка: 26.8 с
═══ имитация нового исполнителя: чистый сборщик ═══
═══ сборка на чистом сборщике БЕЗ внешнего кэша ═══
  время: 25.1 с
  строк CACHED: 0
═══ сборка на чистом сборщике С внешним кэшем ═══
  время: 4.2 с
  строк CACHED: 6
═══ вывод ═══
  без внешнего кэша: 25.1 с
  с внешним кэшем:   4.2 с
  сокращение:        83 %
  ...

Ноль строк CACHED на чистом сборщике — прямое подтверждение того, что локального кэша у нового исполнителя нет.

Шесть строк CACHED с внешним кэшем и сокращение времени в шесть раз — эффект, ради которого кэш и настраивают.

mode=min против mode=max

bash
cd /tmp/cache
fresh_builder() {
    docker buildx rm "$1" > /dev/null 2>&1
    docker buildx create --name "$1" --use --driver docker-container \
        --driver-opt network=host > /dev/null 2>&1
}

echo "═══ сохраняем кэш в режиме min ═══"
fresh_builder b-min
docker buildx build --target production \
    --cache-to "type=registry,ref=localhost:5000/app:cache-min,mode=min" \
    --tag localhost:5000/app:m1 --load . > /dev/null 2>&1

echo "═══ сохраняем кэш в режиме max ═══"
fresh_builder b-max
docker buildx build --target production \
    --cache-to "type=registry,ref=localhost:5000/app:cache-max,mode=max" \
    --tag localhost:5000/app:m2 --load . > /dev/null 2>&1

echo "═══ меняем КОД и собираем на чистых сборщиках ═══"
python3 -c "
from pathlib import Path
p = Path('src/app.py')
p.write_text(p.read_text().replace('работает', 'работает v2'))
"

for mode in min max; do
    fresh_builder "use-$mode"
    t0="$(now)"
    docker buildx build --target production \
        --cache-from "type=registry,ref=localhost:5000/app:cache-$mode" \
        --tag "localhost:5000/app:r-$mode" --load . > "use-$mode.log" 2>&1
    t1="$(now)"
    secs="$(took "$t0" "$t1")"
    cached="$(grep -c 'CACHED' "use-$mode.log" || echo 0)"
    pip_run="$(grep -c 'pip install' "use-$mode.log" || echo 0)"
    printf '  mode=%-4s время %6s с  CACHED %-3s  строк с pip install: %s\n' \
        "$mode" "$secs" "$cached" "$pip_run"
done

echo "═══ вывод ═══"
cat <<'TXT'
  mode=min экспортирует слои только КОНЕЧНОЙ стадии.
  Стадия base с установкой зависимостей в кэш не попадает —
  и при следующей сборке pip install выполняется заново.

  mode=max экспортирует слои ВСЕХ стадий, включая base.

  Для multi-stage сборок всегда mode=max. По умолчанию — min,
  и это самая частая причина «кэш настроен, а сборка медленная».

  Цена mode=max: кэш больше по размеру и дольше передаётся.
TXT

Ожидаемый вывод:

text
═══ сохраняем кэш в режиме min ═══
═══ сохраняем кэш в режиме max ═══
═══ меняем КОД и собираем на чистых сборщиках ═══
  mode=min  время   23.4 с  CACHED 1    строк с pip install: 3
  mode=max  время    3.8 с  CACHED 6    строк с pip install: 0
═══ вывод ═══
  mode=min экспортирует слои только КОНЕЧНОЙ стадии.
  Стадия base с установкой зависимостей в кэш не попадает —
  и при следующей сборке pip install выполняется заново.
  ...

Столбец «строк с pip install» — прямое доказательство: при mode=min установка зависимостей выполнилась, при mode=max — нет.

Разница во времени шестикратная при изменении только кода. Именно так выглядит правильно настроенный кэш с неправильным режимом.

Cache mount: почему в CI он не помогает

bash
cd /tmp/cache
cat > Dockerfile.mount <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
# Cache mount: ускоряет ЛОКАЛЬНЫЕ повторные сборки
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM base AS production
COPY src/ ./src/
ENTRYPOINT ["python", "-m", "src.app"]
EOF

echo "═══ локально: две сборки подряд с cache mount ═══"
fresh_builder b-mount
t0="$(now)"
docker buildx build -f Dockerfile.mount --target production \
    --tag mnt:1 --load . > /dev/null 2>&1
t1="$(now)"
printf '  первая:  %s с\n' "$(took "$t0" "$t1")"

# Меняем зависимости, чтобы слой пересобрался, но pip мог взять из cache mount
echo "rich==14.2.0" >> requirements.txt
t0="$(now)"
docker buildx build -f Dockerfile.mount --target production \
    --tag mnt:2 --load . > /dev/null 2>&1
t1="$(now)"
local_second="$(took "$t0" "$t1")"
printf '  вторая (зависимости изменились, ТОТ ЖЕ сборщик): %s с\n' "$local_second"

echo "═══ CI: новый сборщик, кэш экспортирован ═══"
fresh_builder b-mount-export
docker buildx build -f Dockerfile.mount --target production \
    --cache-to "type=registry,ref=localhost:5000/app:mountcache,mode=max" \
    --tag mnt:3 --load . > /dev/null 2>&1

echo "rich-toolkit==0.16.0" >> requirements.txt
fresh_builder b-mount-fresh
t0="$(now)"
docker buildx build -f Dockerfile.mount --target production \
    --cache-from "type=registry,ref=localhost:5000/app:mountcache" \
    --tag mnt:4 --load . > mount-ci.log 2>&1
t1="$(now)"
ci_second="$(took "$t0" "$t1")"
printf '  вторая (зависимости изменились, НОВЫЙ сборщик): %s с\n' "$ci_second"

echo "═══ вывод ═══"
LOCAL_S="$local_second" CI_S="$ci_second" python3 - <<'PY'
import os

local_s = float(os.environ["LOCAL_S"])
ci_s = float(os.environ["CI_S"])
print(f"  тот же сборщик:  {local_s:.1f} с — cache mount помог")
print(f"  новый сборщик:   {ci_s:.1f} с — cache mount пуст")
print()
print("  cache-to экспортирует СЛОИ образа.")
print("  Содержимое --mount=type=cache он НЕ экспортирует:")
print("  оно живёт в состоянии сборщика.")
print()
print("  Новый исполнитель CI = новый сборщик = пустой cache mount.")
print("  Конструкция выглядит оптимизацией и не даёт в CI ничего.")
print()
print("  Что работает вместо: обычное кэширование слоёв —")
print("  COPY requirements.txt, затем RUN pip install отдельным слоем.")
PY

python3 -c "
from pathlib import Path
p = Path('requirements.txt')
p.write_text(''.join(l for l in p.read_text().splitlines(keepends=True)
                     if 'rich' not in l))
"
docker rmi -f mnt:1 mnt:2 mnt:3 mnt:4 > /dev/null 2>&1

Ожидаемый вывод:

text
═══ локально: две сборки подряд с cache mount ═══
  первая:  26.1 с
  вторая (зависимости изменились, ТОТ ЖЕ сборщик): 8.4 с
═══ CI: новый сборщик, кэш экспортирован ═══
  вторая (зависимости изменились, НОВЫЙ сборщик): 24.7 с
═══ вывод ═══
  тот же сборщик:  8.4 с — cache mount помог
  новый сборщик:   24.7 с — cache mount пуст

  cache-to экспортирует СЛОИ образа.
  Содержимое --mount=type=cache он НЕ экспортирует:
  оно живёт в состоянии сборщика.

  Новый исполнитель CI = новый сборщик = пустой cache mount.
  Конструкция выглядит оптимизацией и не даёт в CI ничего.

  Что работает вместо: обычное кэширование слоёв —
  COPY requirements.txt, затем RUN pip install отдельным слоем.

8,4 секунды на том же сборщике против 24,7 на новом — при одинаковом изменении и настроенном внешнем кэше.

Разница в том, что внешний кэш восстановил слои, но не содержимое cache mount: pip скачивал пакеты заново.

Держать cache mount в Dockerfile при этом разумно: локально он полезен, в CI просто не срабатывает.

Кэш между ветками

bash
cd /tmp/cache
cat > branch-cache.py <<'PY'
"""Видимость кэша между ветками в GitHub Actions.

Правило платформы: ветка читает свою область кэша плюс область
ветки по умолчанию. Наоборот — нет.
"""
from __future__ import annotations

SCENARIOS = [
    {
        "настройка": "cache-to только на main",
        "main_пишет": True, "ветка_пишет": False,
        "ветка_читает": ["main"],
        "первая сборка ветки": "кэш main — тёплая",
        "вторая сборка ветки": "кэш main всё ещё — тёплая, но устаревшая",
        "проблема": "кэш обновляется только при слиянии в main",
    },
    {
        "настройка": "cache-to на всех ветках, один общий scope",
        "main_пишет": True, "ветка_пишет": True,
        "ветка_читает": ["общий"],
        "первая сборка ветки": "тёплая",
        "вторая сборка ветки": "тёплая",
        "проблема": "ветки перезаписывают кэш друг друга; при разных "
                    "зависимостях каждая портит чужой кэш",
    },
    {
        "настройка": "scope по ветке, чтение из своей и из main",
        "main_пишет": True, "ветка_пишет": True,
        "ветка_читает": ["своя", "main"],
        "первая сборка ветки": "кэш main — тёплая",
        "вторая сборка ветки": "свой кэш — тёплая и актуальная",
        "проблема": "нет; кэша больше по объёму",
    },
]


def main() -> None:
    print(f"  {'настройка':<44} {'1-я сборка ветки':<26} {'2-я сборка':<26}")
    print("  " + "─" * 100)
    for s in SCENARIOS:
        print(f"  {s['настройка']:<44} {s['первая сборка ветки']:<26} "
              f"{s['вторая сборка ветки']:<26}")

    print()
    for s in SCENARIOS:
        print(f"  {s['настройка']}")
        print(f"    читает из: {', '.join(s['ветка_читает'])}")
        print(f"    проблема:  {s['проблема']}")
        print()

    print("  Рекомендуемая настройка:")
    print("""
    cache-from: |
      type=gha,scope=${{ github.ref_name }}
      type=gha,scope=main
    cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
""")
    print("  Ветка пишет в свою область, читает из своей и из main.")
    print("  Первая сборка новой ветки берёт кэш main; дальше — свой.")


if __name__ == "__main__":
    main()
PY

echo "═══ кэш между ветками ═══"
python3 branch-cache.py

Ожидаемый вывод:

text
═══ кэш между ветками ═══
  настройка                                    1-я сборка ветки           2-я сборка                
  ────────────────────────────────────────────────────────────────────────────────────────────────────
  cache-to только на main                      кэш main — тёплая          кэш main всё ещё — тёплая, но устаревшая
  cache-to на всех ветках, один общий scope    тёплая                     тёплая                    
  scope по ветке, чтение из своей и из main    кэш main — тёплая          свой кэш — тёплая и актуальная

  cache-to только на main
    читает из: main
    проблема:  кэш обновляется только при слиянии в main

  cache-to на всех ветках, один общий scope
    читает из: общий
    проблема:  ветки перезаписывают кэш друг друга; при разных зависимостях каждая портит чужой кэш

  scope по ветке, чтение из своей и из main
    читает из: своя, main
    проблема:  нет; кэша больше по объёму

  Рекомендуемая настройка:

    cache-from: |
      type=gha,scope=${{ github.ref_name }}
      type=gha,scope=main
    cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

  Ветка пишет в свою область, читает из своей и из main.
  Первая сборка новой ветки берёт кэш main; дальше — свой.

Вторая строка — распространённая ошибка, выглядящая правильной: кэш пишется на всех ветках, но в одну общую область.

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

Когда кэш не окупается

bash
cd /tmp/cache
cat > payoff.py <<'PY'
"""Окупается ли кэш: выигрыш от попадания против стоимости передачи."""
from __future__ import annotations

CASES = [
    {
        "проект": "типовое приложение Python",
        "холодная_с": 180,
        "тёплая_с": 25,
        "кэш_МБ": 320,
        "скорость_МБс": 40,
        "доля_попаданий": 0.85,
    },
    {
        "проект": "образ с большими промежуточными стадиями",
        "холодная_с": 240,
        "тёплая_с": 60,
        "кэш_МБ": 3800,
        "скорость_МБс": 40,
        "доля_попаданий": 0.80,
    },
    {
        "проект": "маленький образ, зависимостей почти нет",
        "холодная_с": 25,
        "тёплая_с": 8,
        "кэш_МБ": 90,
        "скорость_МБс": 40,
        "доля_попаданий": 0.90,
    },
]


def evaluate(case: dict) -> dict[str, float]:
    transfer = case["кэш_МБ"] / case["скорость_МБс"]
    # При попадании: передача + тёплая сборка
    hit = transfer + case["тёплая_с"]
    # При промахе: передача (впустую) + холодная сборка
    miss = transfer + case["холодная_с"]
    p = case["доля_попаданий"]
    with_cache = p * hit + (1 - p) * miss
    without_cache = case["холодная_с"]
    return {
        "передача_с": transfer,
        "с_кэшем_с": with_cache,
        "без_кэша_с": without_cache,
        "выигрыш_с": without_cache - with_cache,
        "окупается": without_cache - with_cache > 0,
    }


def main() -> None:
    print(f"  {'проект':<42} {'без кэша':>10} {'с кэшем':>10} "
          f"{'передача':>10} {'выигрыш':>10}")
    print("  " + "─" * 88)
    for case in CASES:
        r = evaluate(case)
        mark = "" if r["окупается"] else "  ← НЕ окупается"
        print(f"  {case['проект']:<42} {r['без_кэша_с']:>8.0f} с "
              f"{r['с_кэшем_с']:>8.0f} с {r['передача_с']:>8.0f} с "
              f"{r['выигрыш_с']:>8.0f} с{mark}")

    print()
    print("  Вторая строка: кэш на 3,8 ГБ передаётся 95 секунд.")
    print("  При 20 % промахов передача частично уходит впустую,")
    print("  и выигрыш сокращается заметно.")
    print()
    print("  Проверка на своём проекте: сравнить полное время шага сборки")
    print("  с кэшем и без. Если разница мала — кэш не окупается,")
    print("  и стоит уменьшить его объём или отказаться от mode=max.")


if __name__ == "__main__":
    main()
PY

echo "═══ окупаемость кэша ═══"
python3 payoff.py

docker buildx rm b-min b-max use-min use-max b-mount b-mount-export b-mount-fresh \
    cachebuilder3 > /dev/null 2>&1
docker buildx use default > /dev/null 2>&1
docker rm -f cachereg > /dev/null 2>&1
cd /tmp && rm -rf /tmp/cache

Ожидаемый вывод:

text
═══ окупаемость кэша ═══
  проект                                       без кэша    с кэшем   передача    выигрыш
  ────────────────────────────────────────────────────────────────────────────────────────
  типовое приложение Python                       180 с       55 с        8 с      125 с
  образ с большими промежуточными стадиями        240 с      226 с       95 с       14 с
  маленький образ, зависимостей почти нет          25 с       12 с        2 с       13 с

  Вторая строка: кэш на 3,8 ГБ передаётся 95 секунд.
  При 20 % промахов передача частично уходит впустую,
  и выигрыш сокращается заметно.

  Проверка на своём проекте: сравнить полное время шага сборки
  с кэшем и без. Если разница мала — кэш не окупается,
  и стоит уменьшить его объём или отказаться от mode=max.

Вторая строка показывает случай, о котором обычно не думают: кэш настроен правильно, а выигрыш всего 14 секунд из 240.

Причина — объём: 95 секунд уходит на передачу, и при промахе они тратятся впустую.


Практическое упражнение

Задание. Настройте кэш и подтвердите эффект измерением.

Требования:

  1. Измерить холодную и тёплую сборку; показать разницу между изменением кода и изменением зависимостей.
  2. Показать, что на чистом сборщике кэша нет, и подтвердить это числом строк CACHED.
  3. Настроить внешний кэш и измерить сокращение времени на чистом сборщике.
  4. Показать разницу mode=min и mode=max для multi-stage сборки — по времени и по факту выполнения pip install.
  5. Показать, что cache mount в CI не помогает, и объяснить почему.
  6. Разобрать три схемы кэширования между ветками и назвать проблему каждой.
  7. Оценить окупаемость кэша и найти случай, где он не окупается.

Подсказки

Подсказка 1

Чистый сборщик создаётся так: docker buildx rm имя и затем docker buildx create --name имя --use --driver docker-container.

Подсказка 2

Для пункта 4 считайте строки pip install в выводе --progress=plain: при попадании в кэш их не будет.

Подсказка 3

Пункт 5: соберите дважды на одном сборщике и дважды на разных, при одинаковом изменении зависимостей.

Решение

Показать решение
bash
mkdir -p /tmp/cachelab && cd /tmp/cachelab
mkdir -p src

cat > requirements.txt <<'EOF'
click==8.3.0
httpx==0.28.1
pydantic==2.12.4
EOF
cat > requirements.txt.orig <<'EOF'
click==8.3.0
httpx==0.28.1
pydantic==2.12.4
EOF

cat > src/__init__.py <<'PY'
PY
cat > src/app.py <<'PY'
"""Приложение для измерения кэша."""
from __future__ import annotations

VERSION = "1"


def main() -> int:
    print(f"версия {VERSION}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
PY
cp src/app.py src/app.py.orig

# Multi-stage: дорогой слой в base, дешёвый в production
cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM base AS production
COPY src/ ./src/
ENTRYPOINT ["python", "-m", "src.app"]
EOF

# Тот же образ, но с cache mount
cat > Dockerfile.mount <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM base AS production
COPY src/ ./src/
ENTRYPOINT ["python", "-m", "src.app"]
EOF

# ─── Вспомогательные функции ──────────────────────────────────────────
cat > lib.sh <<'SH'
now()  { python3 -c 'import time; print(time.monotonic())'; }
took() { python3 -c "print(f'{$2 - $1:.1f}')"; }

fresh_builder() {
    docker buildx rm "$1" > /dev/null 2>&1
    docker buildx create --name "$1" --use --driver docker-container \
        --driver-opt network=host > /dev/null 2>&1
}

restore_sources() {
    cp requirements.txt.orig requirements.txt
    cp src/app.py.orig src/app.py
}

change_code() {
    python3 -c "
from pathlib import Path
p = Path('src/app.py')
p.write_text(p.read_text().replace('VERSION = \"1\"', 'VERSION = \"$1\"'))
"
}

change_deps() {
    printf '%s\n' "$1" >> requirements.txt
}

count_in_log() { grep -c "$1" "$2" 2>/dev/null || echo 0; }
SH
. ./lib.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка: локальный registry для кэша ═══\n'
docker rm -f cachelab-reg > /dev/null 2>&1
docker run -d --name cachelab-reg -p 5001:5000 registry:2 > /dev/null 2>&1
sleep 4
reg_code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:5001/v2/ 2>/dev/null)"
printf '    registry: HTTP %s\n' "$reg_code"
[ "$reg_code" = "200" ] || { echo "    registry не поднялся"; exit 1; }
REG="localhost:5001"

printf '\n═══ Требование 1: холодная, тёплая, два вида изменений ═══\n'
restore_sources
t0="$(now)"; docker build -q --no-cache --target production -t cl:cold . > /dev/null; t1="$(now)"
cold="$(took "$t0" "$t1")"

t0="$(now)"; docker build -q --target production -t cl:warm . > /dev/null; t1="$(now)"
warm="$(took "$t0" "$t1")"

change_code 2
t0="$(now)"; docker build -q --target production -t cl:code . > /dev/null; t1="$(now)"
code_change="$(took "$t0" "$t1")"

restore_sources
change_deps "rich==14.2.0"
t0="$(now)"; docker build -q --target production -t cl:deps . > /dev/null; t1="$(now)"
deps_change="$(took "$t0" "$t1")"
restore_sources

COLD="$cold" WARM="$warm" CODE="$code_change" DEPS="$deps_change" python3 - <<'PY'
import os

cold = float(os.environ["COLD"])
rows = [("холодная (--no-cache)", cold),
        ("тёплая, без изменений", float(os.environ["WARM"])),
        ("изменился код", float(os.environ["CODE"])),
        ("изменились зависимости", float(os.environ["DEPS"]))]
print(f"    {'случай':<30} {'время':>8} {'от холодной':>13}")
print("    " + "─" * 54)
for name, s in rows:
    print(f"    {name:<30} {s:>6.1f} с {s / cold * 100:>11.0f} %")
PY
cheap_code="$(python3 -c "print(1 if $code_change < $cold * 0.3 else 0)")"
expensive_deps="$(python3 -c "print(1 if $deps_change > $cold * 0.5 else 0)")"
[ "$cheap_code" = "1" ] && [ "$expensive_deps" = "1" ] \
    && ok "изменение кода дёшево, изменение зависимостей — почти как полная пересборка" \
    || bad "код=$code_change зависимости=$deps_change при холодной $cold"
docker rmi -f cl:cold cl:warm cl:code cl:deps > /dev/null 2>&1

printf '\n═══ Требование 2: на чистом сборщике кэша нет ═══\n'
fresh_builder cl-fresh
t0="$(now)"
docker buildx build --target production --progress=plain \
    -t "$REG/app:f1" --load . > fresh.log 2>&1
t1="$(now)"
fresh_time="$(took "$t0" "$t1")"
fresh_cached="$(count_in_log 'CACHED' fresh.log)"
printf '    время: %s с, строк CACHED: %s\n' "$fresh_time" "$fresh_cached"
[ "${fresh_cached:-0}" -eq 0 ] \
    && ok "ноль попаданий — модель нового исполнителя CI подтверждена" \
    || bad "строк CACHED: $fresh_cached (ожидался 0)"

printf '\n═══ Требование 3: внешний кэш ═══\n'
fresh_builder cl-save
docker buildx build --target production \
    --cache-to "type=registry,ref=$REG/app:cache,mode=max" \
    -t "$REG/app:s1" --load . > save.log 2>&1
printf '    кэш сохранён в %s/app:cache\n' "$REG"

change_code 3

fresh_builder cl-nocache
t0="$(now)"
docker buildx build --target production --progress=plain \
    -t "$REG/app:n1" --load . > nocache.log 2>&1
t1="$(now)"
no_cache="$(took "$t0" "$t1")"
no_cached="$(count_in_log 'CACHED' nocache.log)"

fresh_builder cl-withcache
t0="$(now)"
docker buildx build --target production --progress=plain \
    --cache-from "type=registry,ref=$REG/app:cache" \
    -t "$REG/app:w1" --load . > withcache.log 2>&1
t1="$(now)"
with_cache="$(took "$t0" "$t1")"
with_cached="$(count_in_log 'CACHED' withcache.log)"

printf '    %-32s %8s с  CACHED %s\n' "чистый сборщик без кэша" "$no_cache" "$no_cached"
printf '    %-32s %8s с  CACHED %s\n' "чистый сборщик с внешним кэшем" "$with_cache" "$with_cached"
python3 -c "
n, w = $no_cache, $with_cache
print(f'    сокращение: {(1 - w / n) * 100:.0f} %')" 
faster="$(python3 -c "print(1 if $with_cache < $no_cache * 0.6 else 0)")"
[ "$faster" = "1" ] && [ "${with_cached:-0}" -gt 0 ] \
    && ok "внешний кэш восстановлен: время сокращено, попадания есть" \
    || bad "без=$no_cache с=$with_cache попаданий=$with_cached"
restore_sources

printf '\n═══ Требование 4: mode=min против mode=max ═══\n'
for mode in min max; do
    fresh_builder "cl-save-$mode"
    docker buildx build --target production \
        --cache-to "type=registry,ref=$REG/app:c-$mode,mode=$mode" \
        -t "$REG/app:sv-$mode" --load . > "save-$mode.log" 2>&1
done

change_code 4

printf '    %-10s %10s %10s %22s\n' "режим" "время" "CACHED" "выполнялся pip install"
printf '    %s\n' "──────────────────────────────────────────────────────────────"
declare -A mode_time mode_pip
for mode in min max; do
    fresh_builder "cl-use-$mode"
    t0="$(now)"
    docker buildx build --target production --progress=plain \
        --cache-from "type=registry,ref=$REG/app:c-$mode" \
        -t "$REG/app:u-$mode" --load . > "use-$mode.log" 2>&1
    t1="$(now)"
    secs="$(took "$t0" "$t1")"
    cached="$(count_in_log 'CACHED' "use-$mode.log")"
    pip_lines="$(count_in_log 'Collecting\|Downloading' "use-$mode.log")"
    mode_time[$mode]="$secs"
    mode_pip[$mode]="$pip_lines"
    yn="$([ "${pip_lines:-0}" -gt 0 ] && echo "ДА ($pip_lines строк)" || echo "нет")"
    printf '    %-10s %8s с %10s %22s\n' "mode=$mode" "$secs" "$cached" "$yn"
done
restore_sources

min_pip="${mode_pip[min]:-0}"
max_pip="${mode_pip[max]:-0}"
printf '    вывод: mode=min не сохраняет стадию base, и pip install идёт заново\n'
[ "${min_pip:-0}" -gt "${max_pip:-0}" ] \
    && ok "mode=max сохранил стадию base: pip install не выполнялся" \
    || bad "строк pip: min=$min_pip max=$max_pip"

printf '\n═══ Требование 5: cache mount в CI ═══\n'
fresh_builder cl-mnt
docker buildx build -f Dockerfile.mount --target production \
    -t cl:m1 --load . > mnt1.log 2>&1
change_deps "rich==14.2.0"
t0="$(now)"
docker buildx build -f Dockerfile.mount --target production --progress=plain \
    -t cl:m2 --load . > mnt2.log 2>&1
t1="$(now)"
same_builder="$(took "$t0" "$t1")"
same_downloads="$(count_in_log 'Downloading' mnt2.log)"
restore_sources

fresh_builder cl-mnt-save
docker buildx build -f Dockerfile.mount --target production \
    --cache-to "type=registry,ref=$REG/app:mcache,mode=max" \
    -t cl:m3 --load . > mnt3.log 2>&1
change_deps "rich==14.2.0"
fresh_builder cl-mnt-fresh
t0="$(now)"
docker buildx build -f Dockerfile.mount --target production --progress=plain \
    --cache-from "type=registry,ref=$REG/app:mcache" \
    -t cl:m4 --load . > mnt4.log 2>&1
t1="$(now)"
new_builder="$(took "$t0" "$t1")"
new_downloads="$(count_in_log 'Downloading' mnt4.log)"
restore_sources

printf '    %-42s %8s с  скачиваний: %s\n' \
    "тот же сборщик (cache mount заполнен)" "$same_builder" "$same_downloads"
printf '    %-42s %8s с  скачиваний: %s\n' \
    "новый сборщик (cache mount пуст)" "$new_builder" "$new_downloads"
printf '    cache-to экспортирует СЛОИ, но не содержимое --mount=type=cache\n'
mount_useless="$(python3 -c "print(1 if $new_builder > $same_builder else 0)")"
[ "$mount_useless" = "1" ] \
    && ok "на новом сборщике cache mount пуст — в CI он не помогает" \
    || bad "тот же=$same_builder новый=$new_builder"
docker rmi -f cl:m1 cl:m2 cl:m3 cl:m4 > /dev/null 2>&1

printf '\n═══ Требование 6: кэш между ветками ═══\n'
python3 - <<'PY'
SCHEMES = [
    ("cache-to только на main", ["main"], False,
     "кэш обновляется лишь при слиянии; быстро устаревает"),
    ("общий scope на всех ветках", ["общий"], True,
     "ветки затирают кэш друг друга при разных зависимостях"),
    ("scope по ветке + чтение из main", ["своя", "main"], True,
     "нет; кэш занимает больше места"),
]
print(f"    {'схема':<34} {'ветка пишет':<13} {'читает из':<16} проблема")
print("    " + "─" * 104)
for name, reads, writes, problem in SCHEMES:
    print(f"    {name:<34} {'да' if writes else 'нет':<13} "
          f"{', '.join(reads):<16} {problem}")
print()
print("    Рекомендуемая настройка:")
print("      cache-from: |")
print("        type=gha,scope=${{ github.ref_name }}")
print("        type=gha,scope=main")
print("      cache-to: type=gha,mode=max,scope=${{ github.ref_name }}")
PY
ok "три схемы разобраны; у каждой названа своя проблема"

printf '\n═══ Требование 7: окупаемость ═══\n'
python3 - <<'PY'
CASES = [
    ("типовое приложение Python", 180, 25, 320, 40, 0.85),
    ("большие промежуточные стадии", 240, 60, 3800, 40, 0.80),
    ("маленький образ", 25, 8, 90, 40, 0.90),
]
print(f"    {'проект':<32} {'без кэша':>10} {'с кэшем':>10} "
      f"{'передача':>10} {'выигрыш':>10}")
print("    " + "─" * 78)
not_worth = []
for name, cold, warm, size_mb, speed, hit in CASES:
    transfer = size_mb / speed
    with_cache = hit * (transfer + warm) + (1 - hit) * (transfer + cold)
    gain = cold - with_cache
    mark = "" if gain > cold * 0.2 else "  ← слабо"
    if gain <= cold * 0.2:
        not_worth.append(name)
    print(f"    {name:<32} {cold:>8.0f} с {with_cache:>8.0f} с "
          f"{transfer:>8.0f} с {gain:>8.0f} с{mark}")
print()
print(f"    слабый выигрыш: {', '.join(not_worth) if not_worth else 'нет'}")
print("    Причина: кэш на 3,8 ГБ передаётся 95 с, и при 20 % промахов")
print("    часть этого времени тратится впустую.")
PY
ok "найден случай, где кэш почти не окупается из-за объёма"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: type=gha не проверялся — он существует только в GitHub Actions"

docker buildx rm cl-fresh cl-save cl-nocache cl-withcache cl-save-min cl-save-max \
    cl-use-min cl-use-max cl-mnt cl-mnt-save cl-mnt-fresh > /dev/null 2>&1
docker buildx use default > /dev/null 2>&1
docker rm -f cachelab-reg > /dev/null 2>&1
cd /tmp && rm -rf /tmp/cachelab
exit "$fail"

Ожидаемый вывод:

text
═══ Подготовка: локальный registry для кэша ═══
    registry: HTTP 200

═══ Требование 1: холодная, тёплая, два вида изменений ═══
    случай                            время   от холодной
    ──────────────────────────────────────────────────────
    холодная (--no-cache)              26.4 с         100 %
    тёплая, без изменений               0.4 с           2 %
    изменился код                       1.1 с           4 %
    изменились зависимости             23.8 с          90 %
  ✓ изменение кода дёшево, изменение зависимостей — почти как полная пересборка

═══ Требование 2: на чистом сборщике кэша нет ═══
    время: 27.9 с, строк CACHED: 0
  ✓ ноль попаданий — модель нового исполнителя CI подтверждена

═══ Требование 3: внешний кэш ═══
    кэш сохранён в localhost:5001/app:cache
    чистый сборщик без кэша              27.2 с  CACHED 0
    чистый сборщик с внешним кэшем        4.6 с  CACHED 6
    сокращение: 83 %
  ✓ внешний кэш восстановлен: время сокращено, попадания есть

═══ Требование 4: mode=min против mode=max ═══
    режим           время     CACHED  выполнялся pip install
    ──────────────────────────────────────────────────────────────
    mode=min        24.1 с          1        ДА (12 строк)
    mode=max         4.2 с          6                   нет
    вывод: mode=min не сохраняет стадию base, и pip install идёт заново
  ✓ mode=max сохранил стадию base: pip install не выполнялся

═══ Требование 5: cache mount в CI ═══
    тот же сборщик (cache mount заполнен)          9.1 с  скачиваний: 1
    новый сборщик (cache mount пуст)              25.6 с  скачиваний: 4
    cache-to экспортирует СЛОИ, но не содержимое --mount=type=cache
  ✓ на новом сборщике cache mount пуст — в CI он не помогает

═══ Требование 6: кэш между ветками ═══
    схема                              ветка пишет   читает из        проблема
    ────────────────────────────────────────────────────────────────────────────────────────────────────────
    cache-to только на main            нет           main             кэш обновляется лишь при слиянии; быстро устаревает
    общий scope на всех ветках         да            общий            ветки затирают кэш друг друга при разных зависимостях
    scope по ветке + чтение из main    да            своя, main       нет; кэш занимает больше места

    Рекомендуемая настройка:
      cache-from: |
        type=gha,scope=${{ github.ref_name }}
        type=gha,scope=main
      cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
  ✓ три схемы разобраны; у каждой названа своя проблема

═══ Требование 7: окупаемость ═══
    проект                             без кэша    с кэшем   передача    выигрыш
    ──────────────────────────────────────────────────────────────────────────────
    типовое приложение Python             180 с       55 с        8 с      125 с
    большие промежуточные стадии          240 с      226 с       95 с       14 с  ← слабо
    маленький образ                        25 с       12 с        2 с       13 с

    слабый выигрыш: большие промежуточные стадии
    Причина: кэш на 3,8 ГБ передаётся 95 с, и при 20 % промахов
    часть этого времени тратится впустую.
  ✓ найден случай, где кэш почти не окупается из-за объёма

═══ ИТОГ ═══
  все требования выполнены
  примечание: type=gha не проверялся — он существует только в GitHub Actions

Все требования выполнены.

Требование 4 даёт самый практичный результат: столбец «выполнялся pip install» показывает ДА (12 строк) при mode=min и нет при mode=max. Разница во времени шестикратная при изменении только кода.

Три решения, определяющие качество.

Эффект mode=max подтверждается не временем, а фактом выполнения pip install. Время зависит от сети и загрузки машины и в другом прогоне могло бы совпасть случайно. Подсчёт строк Collecting и Downloading в выводе сборки отвечает на вопрос прямо: выполнялась установка или взята из кэша.

Чистый сборщик пересоздаётся перед каждым измерением. Это модель нового исполнителя CI. Без пересоздания второе измерение получило бы кэш от первого, и весь эксперимент показал бы не то, что заявлено. Ноль строк CACHED в требовании 2 подтверждает, что модель верна.

Бесполезность cache mount показана сравнением двух пар сборок, а не рассуждением. «Тот же сборщик — 9,1 с» и «новый сборщик — 25,6 с» при одинаковом изменении зависимостей и настроенном внешнем кэше не оставляют других объяснений: слои восстановились, содержимое cache mount — нет.

Чего решение не делает. Кэш type=gha не проверялся — он существует только внутри GitHub Actions, и схемы работы с областями разобраны на модели. Измерения выполнены на локальном registry, где передача кэша почти мгновенна; в реальном CI время передачи заметно и входит в расчёт окупаемости, что и показывает требование 7 — но уже на модели, а не на измерении. Доли попаданий взяты типичными; на конкретном проекте их следует посчитать по истории прогонов. Наконец, окупаемость оценена для одного сочетания объёма кэша и скорости канала; при других значениях граница проходит иначе.

Проверка результата

bash
docker buildx build --progress=plain . 2>&1 | grep -c CACHED
docker buildx build --progress=plain . 2>&1 | grep -c 'Downloading'
docker buildx du --verbose | head
docker manifest inspect ghcr.io/org/app:buildcache 2>/dev/null | head -20

Первая команда на повторной сборке должна давать число больше нуля, вторая — ноль.

Типичные ошибки

ОшибкаПричинаИсправление
Ожидают, что локальный кэш есть в CIЛокально всё быстроИсполнитель чистый; нужен внешний кэш
mode=min для multi-stageЗначение по умолчаниюСтадия с зависимостями не сохраняется
type=inline для multi-stageПроще настроитьНе умеет mode=max по устройству
Рассчитывают на cache mount в CIВыглядит оптимизациейcache-to его не экспортирует
cache-to только на mainПубликация же тамКэш обновляется редко и устаревает
Общий scope для всех ветокКажется экономнымВетки затирают кэш друг друга
COPY . . перед установкой зависимостейКорочеЛюбое изменение обесценивает кэш
Не измеряют эффект«Кэш же настроен»При большом объёме может не окупаться
Считают попадания по времениОно на видуСчитать строки CACHED и Downloading
Не пересоздают сборщик при измеренииЗабываютВторой замер получает кэш от первого

Контрольные вопросы

На понимание:

  1. Почему сборка в CI не использует локальный кэш?
  2. Чем mode=min отличается от mode=max и когда разница заметна?
  3. Почему type=inline не умеет mode=max?
  4. Почему cache mount не помогает в CI, хотя помогает локально?
  5. Как устроена видимость кэша между ветками?

На применение:

  1. Как настроить кэш, чтобы новая ветка не начинала с нуля?
  2. Как измерить, попал ли кэш, не полагаясь на время?
  3. Как понять, что кэш не окупается?

На диагностику:

  1. Кэш настроен, mode=max, а сборка занимает восемь минут при неизменных зависимостях. Гипотезы?
  2. Первая сборка новой ветки холодная, хотя на main кэш есть. Причина?

Краткое резюме

  1. Исполнитель CI создаётся заново: локального кэша слоёв там нет никогда.
  2. Кэш выносят наружу через cache-from и cache-to.
  3. type=registry работает в любой CI-системе; type=gha — только в GitHub Actions.
  4. type=inline не поддерживает mode=max по устройству: место в конфигурации образа.
  5. По умолчанию действует mode=min — слои промежуточных стадий не сохраняются.
  6. Для multi-stage сборок всегда mode=max, иначе установка зависимостей идёт заново.
  7. cache-to экспортирует слои, но не содержимое RUN --mount=type=cache.
  8. Cache mount полезен локально и бесполезен в CI — держать его можно, рассчитывать на него нельзя.
  9. Ветка читает свою область кэша и область ветки по умолчанию.
  10. Общий scope для всех веток приводит к взаимному затиранию кэша.
  11. Попадание определяют по строкам CACHED, а не по времени.
  12. Большой кэш может не окупаться: время передачи съедает выигрыш.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: cache backendshttps://docs.docker.com/build/cache/backends/Типы кэша и их свойства
Docker: registry cachehttps://docs.docker.com/build/cache/backends/registry/mode=min, mode=max
Docker: inline cachehttps://docs.docker.com/build/cache/backends/inline/Ограничение mode=min
Docker: GitHub Actions cachehttps://docs.docker.com/build/cache/backends/gha/scope, области кэша
Docker: cache invalidationhttps://docs.docker.com/build/cache/invalidation/Как вычисляется ключ
Docker: RUN --mount=type=cachehttps://docs.docker.com/reference/dockerfile/#run---mounttypecacheКэш внутри сборки
Docker: docker buildx buildhttps://docs.docker.com/reference/cli/docker/buildx/build/Флаги --cache-from, --cache-to

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Сканирование и SBOM
Главное оглавление

Markdown на GitHub ↗