16.3. Build cache в CI
Цели
После этого материала вы сможете:
- объяснить, почему сборка в CI начинается с пустого кэша, даже если локально она мгновенна;
- выбрать тип кэша под задачу и объяснить ограничение каждого;
- объяснить, почему
mode=minтеряет кэш промежуточных стадий, и когда это заметно; - назвать вид кэша, который в CI не работает вовсе, хотя выглядит работающим;
- настроить кэш так, чтобы новая ветка не начинала с нуля;
- измерить эффект и подтвердить числами, а не ощущением.
Предварительные знания
- 5.5. Кэш сборки — механика слоёв;
- 5.7. Multi-stage сборка;
- 16.2. GitHub Actions —
cache-fromиcache-to.
Ключевые термины
| Термин | Объяснение |
|---|---|
cache-from | Откуда брать кэш |
cache-to | Куда сохранять кэш |
mode=min | Экспортируются слои только конечной стадии |
mode=max | Экспортируются слои всех стадий |
scope | Раздел кэша; определяет, кто его видит |
cache mount | RUN --mount=type=cache — кэш внутри сборки |
Теория
Почему в CI кэш пуст
Локально повторная сборка занимает секунды: слои лежат в /var/lib/docker и переиспользуются (урок 5.5).
В CI каждый запуск получает чистую машину:
локально 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 | Объектное хранилище | Да | Своя инфраструктура |
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: где теряется время
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 не работает
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Локально это ускоряет повторные сборки: каталог кэша pip сохраняется между ними.
В CI он бесполезен. Cache mount живёт в состоянии сборщика, а не в экспортируемом кэше. cache-to экспортирует слои, но не содержимое cache mount.
что экспортирует cache-to: слои образа
что НЕ экспортирует: содержимое --mount=type=cache
Новый исполнитель — новый сборщик — пустой cache mount. Инструкция отрабатывает так, как будто кэша нет.
Это одна из самых дорогих иллюзий в CI: конструкция выглядит как оптимизация, присутствует в примерах и не даёт ничего.
Что работает вместо: обычное кэширование слоёв.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt # отдельный слой
COPY src/ ./src/ # меняется чаще
Слой с зависимостями попадает в экспортируемый кэш и переиспользуется, пока не изменится requirements.txt (урок 5.5).
Cache mount при этом не мешает — он просто не помогает. Держать обе меры одновременно разумно: локально работает первая, в CI — вторая.
Кэш между ветками
Кэш GitHub Actions разделён на области. Правило видимости:
ветка feature/x ──► своя область ──► видит: свою + область ветки по умолчанию
ветка main ──► своя область ──► видит: только свою
Новая ветка своей области ещё не имеет и читает кэш из main. Это работает — но только если сборка на main вообще сохраняла кэш.
Типичная ошибка:
- uses: docker/build-push-action@v6
with:
cache-to: type=gha,mode=max
# публикация только на 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; последующие — свой.
Что измерять
| Величина | Как получить |
|---|---|
| Время холодной сборки | --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 игнорируется.
Команды и примеры
Холодная и тёплая сборка
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
Ожидаемый вывод:
═══ холодная сборка (--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 стоит почти как полная пересборка.
Именно поэтому зависимости выносят в отдельный слой перед кодом: код меняется в каждом коммите, зависимости — редко.
Внешний кэш: сохранение и восстановление
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
Ожидаемый вывод:
═══ поднимаем 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
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
Ожидаемый вывод:
═══ сохраняем кэш в режиме 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 он не помогает
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
Ожидаемый вывод:
═══ локально: две сборки подряд с 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 просто не срабатывает.
Кэш между ветками
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
Ожидаемый вывод:
═══ кэш между ветками ═══
настройка 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; дальше — свой.
Вторая строка — распространённая ошибка, выглядящая правильной: кэш пишется на всех ветках, но в одну общую область.
Две ветки с разными зависимостями будут по очереди затирать кэш друг друга, и обе получат промах.
Когда кэш не окупается
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
Ожидаемый вывод:
═══ окупаемость кэша ═══
проект без кэша с кэшем передача выигрыш
────────────────────────────────────────────────────────────────────────────────────────
типовое приложение Python 180 с 55 с 8 с 125 с
образ с большими промежуточными стадиями 240 с 226 с 95 с 14 с
маленький образ, зависимостей почти нет 25 с 12 с 2 с 13 с
Вторая строка: кэш на 3,8 ГБ передаётся 95 секунд.
При 20 % промахов передача частично уходит впустую,
и выигрыш сокращается заметно.
Проверка на своём проекте: сравнить полное время шага сборки
с кэшем и без. Если разница мала — кэш не окупается,
и стоит уменьшить его объём или отказаться от mode=max.
Вторая строка показывает случай, о котором обычно не думают: кэш настроен правильно, а выигрыш всего 14 секунд из 240.
Причина — объём: 95 секунд уходит на передачу, и при промахе они тратятся впустую.
Практическое упражнение
Задание. Настройте кэш и подтвердите эффект измерением.
Требования:
- Измерить холодную и тёплую сборку; показать разницу между изменением кода и изменением зависимостей.
- Показать, что на чистом сборщике кэша нет, и подтвердить это числом строк
CACHED. - Настроить внешний кэш и измерить сокращение времени на чистом сборщике.
- Показать разницу
mode=minиmode=maxдля multi-stage сборки — по времени и по факту выполненияpip install. - Показать, что cache mount в CI не помогает, и объяснить почему.
- Разобрать три схемы кэширования между ветками и назвать проблему каждой.
- Оценить окупаемость кэша и найти случай, где он не окупается.
Подсказки
Подсказка 1
Чистый сборщик создаётся так: docker buildx rm имя и затем docker buildx create --name имя --use --driver docker-container.
Подсказка 2
Для пункта 4 считайте строки pip install в выводе --progress=plain: при попадании в кэш их не будет.
Подсказка 3
Пункт 5: соберите дважды на одном сборщике и дважды на разных, при одинаковом изменении зависимостей.
Решение
Показать решение
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"
Ожидаемый вывод:
═══ Подготовка: локальный 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 — но уже на модели, а не на измерении. Доли попаданий взяты типичными; на конкретном проекте их следует посчитать по истории прогонов. Наконец, окупаемость оценена для одного сочетания объёма кэша и скорости канала; при других значениях граница проходит иначе.
Проверка результата
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 |
| Не пересоздают сборщик при измерении | Забывают | Второй замер получает кэш от первого |
Контрольные вопросы
На понимание:
- Почему сборка в CI не использует локальный кэш?
- Чем
mode=minотличается отmode=maxи когда разница заметна? - Почему
type=inlineне умеетmode=max? - Почему cache mount не помогает в CI, хотя помогает локально?
- Как устроена видимость кэша между ветками?
На применение:
- Как настроить кэш, чтобы новая ветка не начинала с нуля?
- Как измерить, попал ли кэш, не полагаясь на время?
- Как понять, что кэш не окупается?
На диагностику:
- Кэш настроен,
mode=max, а сборка занимает восемь минут при неизменных зависимостях. Гипотезы? - Первая сборка новой ветки холодная, хотя на
mainкэш есть. Причина?
Краткое резюме
- Исполнитель CI создаётся заново: локального кэша слоёв там нет никогда.
- Кэш выносят наружу через
cache-fromиcache-to. type=registryработает в любой CI-системе;type=gha— только в GitHub Actions.type=inlineне поддерживаетmode=maxпо устройству: место в конфигурации образа.- По умолчанию действует
mode=min— слои промежуточных стадий не сохраняются. - Для multi-stage сборок всегда
mode=max, иначе установка зависимостей идёт заново. cache-toэкспортирует слои, но не содержимоеRUN --mount=type=cache.- Cache mount полезен локально и бесполезен в CI — держать его можно, рассчитывать на него нельзя.
- Ветка читает свою область кэша и область ветки по умолчанию.
- Общий scope для всех веток приводит к взаимному затиранию кэша.
- Попадание определяют по строкам
CACHED, а не по времени. - Большой кэш может не окупаться: время передачи съедает выигрыш.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: cache backends | https://docs.docker.com/build/cache/backends/ | Типы кэша и их свойства |
| Docker: registry cache | https://docs.docker.com/build/cache/backends/registry/ | mode=min, mode=max |
| Docker: inline cache | https://docs.docker.com/build/cache/backends/inline/ | Ограничение mode=min |
| Docker: GitHub Actions cache | https://docs.docker.com/build/cache/backends/gha/ | scope, области кэша |
| Docker: cache invalidation | https://docs.docker.com/build/cache/invalidation/ | Как вычисляется ключ |
Docker: RUN --mount=type=cache | https://docs.docker.com/reference/dockerfile/#run---mounttypecache | Кэш внутри сборки |
Docker: docker buildx build | https://docs.docker.com/reference/cli/docker/buildx/build/ | Флаги --cache-from, --cache-to |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Сканирование и SBOM
Главное оглавление