Слои и кэширование сборки
Кэш слоёв превращает минутные сборки в секундные — но только если инструкции 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-слоя на каждой сборке.