Слои и кэширование сборки

Кэш слоёв превращает минутные сборки в секундные — но только если инструкции Dockerfile выстроены в правильном порядке.

Build cache — механизм Docker, который при повторной сборке переиспользует ранее собранные слои, если их вход (команда и затронутые файлы) не изменился.

В базовом разделе мы уже разобрали, что каждая инструкция Dockerfile создаёт отдельный read-only слой, и научились читать их поверхностно. Теперь смотрим глубже: на практике именно кэш слоёв решает, будет ли ваша CI-сборка идти 8 секунд или 8 минут. Типичная боль — поменяли одну строку в коде, а Docker заново качает и компилирует все зависимости, потому что слой с установкой пакетов оказался ниже по Dockerfile, чем изменившийся файл. Один неудачный порядок инструкций — и кэш не помогает вообще. Разберёмся, как этого избежать.

Почему порядок инструкций критичен

Docker собирает образ сверху вниз и кэширует слои последовательно. Ключевое правило: как только один слой инвалидируется (его вход изменился), все слои ниже пересобираются заново, даже если их собственные команды не менялись. Кэш — это цепочка, и она рвётся в первой же точке промаха.

Отсюда стратегия: ставьте редко меняющееся выше, часто меняющееся — ниже. Установка зависимостей меняется редко (только когда правите список пакетов), а исходный код — на каждом коммите. Значит, зависимости должны устанавливаться раньше, чем копируется код.

Плохой порядок: COPY . . в начале

Самая частая ошибка — скопировать весь проект одной инструкцией, а потом ставить зависимости:

FROM node:20-alpine
WORKDIR /app

# Плохо: любой коммит меняет хотя бы один файл,
# слой COPY инвалидируется, а с ним и npm ci ниже
COPY . .
RUN npm ci

RUN npm run build
CMD ["node", "dist/server.js"]

Здесь COPY . . тащит в слой всё содержимое контекста. Поменяли одну букву в README.md или в исходнике — контрольная сумма скопированных файлов изменилась, слой пересобран, и RUN npm ci ниже тоже выполняется заново. Сеть, скачивание сотен пакетов, минуты ожидания — на каждой сборке.

Хороший порядок: манифест зависимостей отдельным шагом

Сначала копируем только файлы со списком зависимостей, ставим их, и лишь потом копируем остальной код:

FROM node:20-alpine
WORKDIR /app

# 1) Сначала только манифест и лок-файл
COPY package.json package-lock.json ./

# 2) Установка зависимостей — кэшируется, пока манифест не менялся
RUN npm ci

# 3) Только теперь копируем исходники
COPY . .
RUN npm run build

CMD ["node", "dist/server.js"]

Теперь правка кода инвалидирует только слои с шага 3 и ниже. Слой RUN npm ci остаётся в кэше, пока вы не тронете package.json или package-lock.json. Сборка после правки одного файла занимает секунды.

Тот же паттерн для Go и Python

Приём языконезависим — меняется только имя манифеста. В Go это go.mod и go.sum:

FROM golang:1.22-alpine
WORKDIR /src

# Манифесты модулей — отдельным слоём
COPY go.mod go.sum ./
RUN go mod download

# Исходники — после, чтобы их правка не сбрасывала кэш модулей
COPY . .
RUN go build -o /app ./cmd/server

CMD ["/app"]

В Python с pip — это requirements.txt:

FROM python:3.12-slim
WORKDIR /app

# Сначала список зависимостей
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Затем код
COPY . .
CMD ["python", "main.py"]

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

Как это работает под капотом

Docker определяет попадание в кэш по ключу кэша каждого слоя. Ключ складывается из ключа предыдущего слоя плюс «отпечатка» текущей инструкции — поэтому цепочка и рвётся целиком после первого промаха.

Как считается отпечаток, зависит от типа инструкции:

  • RUN — берётся буквальный текст команды. Docker не выполняет команду, чтобы проверить кэш; он сравнивает строку. RUN apt-get update сегодня и через месяц — один и тот же текст, значит, попадание в кэш (даже если в репозиториях давно новые версии — это отдельная ловушка, см. ниже).
  • COPY и ADD — считается контрольная сумма содержимого копируемых файлов (а также метаданные вроде прав доступа). Изменилось содержимое хоть одного файла — хеш другой — кэш сброшен. Именно поэтому COPY . . в начале так опасен: его хеш зависит от всего проекта.
  • Остальные инструкции (ENV, WORKDIR, ARG, CMD и т. д.) хешируются по своему тексту и значениям.

Запустим сборку дважды и посмотрим на вывод. Первый прогон собирает всё, второй — берёт слои из кэша:

docker build -t myapp:latest .

# Меняем один файл исходника и собираем снова
docker build -t myapp:latest .

Вывод второй сборки (BuildKit):

=> [1/6] FROM node:20-alpine                          CACHED
=> [2/6] WORKDIR /app                                  CACHED
=> [3/6] COPY package.json package-lock.json ./        CACHED
=> [4/6] RUN npm ci                                    CACHED
=> [5/6] COPY . .                                      0.4s
=> [6/6] RUN npm run build                             6.2s

Слой CACHED означает «взято из кэша, ничего не пересобиралось». Видно, что установка зависимостей (шаг 4) переиспользована, а пересобрались только шаги, зависящие от изменённого кода. В классическом (не BuildKit) выводе тот же смысл несёт строка ---> Using cache.

Посмотреть слои готового образа и их размеры можно через docker history:

docker history myapp:latest

Управление кэшем: ARG для cache busting и --no-cache

Иногда кэш надо сбросить намеренно — например, заставить apt-get update или git clone выполниться заново, хотя текст команды не менялся. Удобный приём — объявить ARG и подставить его в команду: меняя значение аргумента, вы меняете отпечаток слоя.

FROM debian:12

# Меняя значение CACHE_BUST при сборке, инвалидируем слой ниже
ARG CACHE_BUST=0
RUN echo "bust=${CACHE_BUST}" && apt-get update && apt-get install -y curl
# Передаём новое значение — слой пересоберётся, остальной кэш цел
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

# Грубый вариант: пересобрать вообще всё, игнорируя кэш
docker build --no-cache -t myapp .

Разница важна: ARG-busting прицельно сбрасывает один слой и хвост ниже него, а --no-cache отключает кэш для всей сборки — это медленно и нужно редко (обычно при отладке самого Dockerfile).

Частые ошибки

  • COPY . . в самом начале Dockerfile. Привязывает дорогие шаги установки к любому изменению в проекте. Всегда копируйте манифест зависимостей отдельным шагом до их установки, а полный COPY . . ставьте как можно ниже.
  • Меняющийся ARG или таймстамп выше по файлу. Если в начале Dockerfile стоит ARG BUILD_DATE и вы передаёте текущее время на каждой сборке, отпечаток меняется — и весь хвост слоёв ниже пересобирается, кэш бесполезен. Размещайте часто меняющиеся ARG как можно ниже, прямо перед слоем, который они должны инвалидировать.
  • apt-get update отдельным слоём от install. Так как кэш RUN держится по тексту команды, слой RUN apt-get update может застрять в кэше надолго, а следующий RUN apt-get install возьмёт устаревший индекс пакетов и поставит старые или отсутствующие версии. Объединяйте их в одну инструкцию: RUN apt-get update && apt-get install -y ....
  • Забыли .dockerignore. Без него COPY . . тащит в контекст node_modules, .git, логи, артефакты сборки. Этот мусор меняется постоянно, ломая хеш COPY-слоя (а заодно раздувает контекст сборки). Добавьте .dockerignore с node_modules, .git, dist и прочим — кэш COPY станет стабильнее.

Итоги

  • Кэш слоёв — цепочка: первый промах инвалидирует все слои ниже, поэтому порядок инструкций решает скорость сборки.
  • Ставьте редко меняющееся выше, часто меняющееся ниже: манифест зависимостей и их установку — до COPY . . с исходниками.
  • Паттерн «COPY package.json/go.mod/requirements.txt → установка → COPY кода» кэширует дорогой шаг зависимостей при обычной правке кода.
  • Отпечаток слоя для RUN — это текст команды, для COPY/ADD — контрольная сумма содержимого файлов.
  • Сбрасывайте кэш прицельно через ARG-busting; --no-cache отключает кэш целиком и нужен редко.
  • Читайте CACHED / ---> Using cache в выводе сборки и docker history, чтобы видеть, какие слои переиспользованы.
  • .dockerignore — обязателен: без него мусор из контекста ломает хеш COPY-слоя на каждой сборке.
Проверьте себя
1. В Dockerfile для Node-проекта инструкция COPY . . стоит в самом начале, а RUN npm ci — сразу под ней. Разработчик поменял одну строку в исходном коде и пересобрал образ. Что произойдёт?
ADocker возьмёт слой npm ci из кэша, потому что package.json не менялся
BСлой COPY . . инвалидируется из-за изменения файлов, и RUN npm ci ниже выполнится заново
CПересоберётся только слой CMD в конце
DDocker автоматически переставит инструкции в оптимальный порядок
2. Как Docker определяет, можно ли взять из кэша слой инструкции RUN apt-get install -y curl?
AРеально выполняет команду и сравнивает результат с прошлым разом
BСравнивает буквальный текст команды: если строка та же, слой берётся из кэша
CПроверяет, вышли ли новые версии пакетов в репозитории
DСчитает контрольную сумму всех файлов в образе