1. Docker cache и скорость docker build
Рабочий Dockerfile у нас уже есть: контейнер стартует, порт проброшен, всё выглядит прилично. Но маленький проект обманчив: на нём почти любой Dockerfile кажется «нормальным». docker build отработал, контейнер запустился — победа. Настоящая боль начинается на следующий день, когда вы меняете одну строчку в коде, снова запускаете сборку… а она опять тянется как сериал на 12 сезонов. В Java это ощущается особенно остро: зависимостей много, сборка тяжёлая, а терпение у человека — infinite resource.
Кэш Docker нужен не для того, чтобы «всегда собирать мгновенно». Он нужен, чтобы Docker мог честно сказать: «Вот эти шаги точно не изменились, я их уже делал, повторять не буду». И чем лучше мы понимаем, как Docker принимает это решение, тем легче нам спроектировать Dockerfile так, чтобы ежедневные правки в src/ не превращались в «пересобираем весь мир заново».
2. Шаги Dockerfile и слои образа
Dockerfile часто воспринимают как «просто список команд». Для Docker это скорее рецепт сборки: он идёт по нему шаг за шагом, сверху вниз. Каждый шаг даёт промежуточный результат, который Docker может запомнить и потом переиспользовать. Именно этот «запомненный результат шага» в нашей упрощённой учебной модели мы и будем называть слоем (layer).
Представьте образ как слоёный пирог: внизу база (например, Linux + JDK), сверху — ваши добавления (рабочая директория, jar, переменные окружения, entrypoint). Docker «запекает» каждый слой отдельно. И если нижние слои не поменялись, он не обязан снова их «выпекать».
Для наглядности можно представить сборку вот так (упрощённо):
flowchart TD
%% Упрощённая модель: один шаг Dockerfile = один слой
A["Dockerfile: шаг 1"] --> B[Layer 1]
B --> C["Dockerfile: шаг 2"]
C --> D[Layer 2]
D --> E["Dockerfile: шаг 3"]
E --> F[Layer 3]
F --> G["Итоговый image = слои 1+2+3"]
В реальности там чуть больше нюансов: часть инструкций влияет на файловую систему, часть — на метаданные. Но для понимания кэша нам достаточно одной идеи: Docker строит образ как цепочку шагов, а кэш работает по шагам.
3. Layer, image, container
Очень типичная ранняя путаница: человек слышит слово «слой», вспоминает «слой контейнера», «слой файловой системы» — и всё слипается в один ком. Давайте это аккуратно разлепим.
Image — это результат сборки. Он хранится на диске как набор слоёв (плюс конфиг). Его можно запускать много раз, но сам по себе он не «работает» и не слушает порт.
Container — это запущенный экземпляр image. Контейнер живёт, пока живёт основной процесс (для нас это обычно java -jar ...). У контейнера появляется writable layer (записываемый слой поверх образа), куда процесс может писать файлы во время работы.
А слои образа — это те самые «куски» собранного image, которые Docker пытается переиспользовать на следующем docker build.
Сведём это в короткую таблицу, чтобы мозг не держал всё в виде тумана:
| Термин | Простыми словами | Когда появляется | Почему важно для нас сегодня |
|---|---|---|---|
| Layer | Результат шага сборки Dockerfile, который можно переиспользовать | Во время docker build | От слоёв зависит скорость пересборки |
| Image | Готовый «шаблон» приложения (слои + конфиг) | После docker build | Его мы запускаем и передаём другим |
| Container | Запущенный процесс из image | После docker run | Он живёт, пока живёт Java-процесс |
Если упростить до одного предложения, то слой — это про сборку, а контейнер — про запуск. Сегодня мы целимся в скорость сборки, поэтому всё крутится вокруг слоёв.
4. Docker cache: hit, miss, инвалидация
Теперь самое вкусное: что такое Docker cache не как «магия», а как понятный механизм. Во время docker build Docker идёт по Dockerfile сверху вниз и на каждом шаге спрашивает себя: «У меня уже есть результат точно такого же шага при точно таких же входных данных?» Если да — происходит cache hit, и шаг не выполняется заново. Если нет — cache miss, шаг выполняется, и Docker записывает новый результат в кэш.
В выводе сборки вы обычно увидите это как пометки вида CACHED (в BuildKit-выводе) или как Using cache (в более старых форматах). Суть одна: Docker не делает работу второй раз.
Возьмём совсем минимальный Dockerfile для нашего учебного сервиса (пока без «взрослой» оптимизации — нам важен принцип). Считаем, что в build/libs уже лежит один готовый bootJar, и внутри образа мы кладём его под стабильным именем app.jar:
# Базовый образ с JDK — меняется редко, поэтому это хороший «нижний слой» для кэша
FROM eclipse-temurin:25-jdk
# Рабочая директория внутри образа (метаданные образа + создание каталога при необходимости)
WORKDIR /app
# Копируем готовый bootJar в стабильное имя внутри образа
COPY build/libs/*.jar app.jar
# Точка входа контейнера: именно этот процесс будет «главным» (PID 1)
ENTRYPOINT ["java", "-jar", "app.jar"]
На первой сборке Docker всё выполнит честно. На второй (если jar не менялся) он скажет примерно так:
[1/4] FROM eclipse-temurin:25-jdk
CACHED [2/4] WORKDIR /app
CACHED [3/4] COPY build/libs/*.jar app.jar
CACHED [4/4] ENTRYPOINT ["java", "-jar", "app.jar"]
Важно понимать одну простую вещь: кэш не «ускоряет всё подряд». Он ускоряет только то, что не изменилось.
И тут появляется ещё один полезный термин — инвалидация кэша. Это ситуация, когда Docker не может использовать кэш для шага, потому что изменилось что-то важное: сама инструкция Dockerfile или входные файлы, которые эта инструкция использует. Например, если поменялся jar, то слой с COPY ... app.jar должен быть пересобран, потому что фактически мы копируем другой файл. И это нормально: мы же хотим получить образ с новой версией приложения, а не «красивый кэш».
5. Каскадная инвалидация кэша
Сейчас тот самый момент, где многие впервые ловят «ага!». Dockerfile читается сверху вниз, и каждый шаг строится поверх предыдущего результата. Поэтому если у вас изменился шаг №2, то шаг №3 уже не может быть «точно таким же»: его базовый слой теперь другой, даже если команда в шаге №3 формально не менялась.
Это и есть каскадная инвалидация: изменение раннего шага часто заставляет перестраивать всё ниже, как домино.
Посмотрим на простую цепочку:
# Базовый образ: если поменять его (тег/версию), сломается кэш всех шагов ниже
FROM eclipse-temurin:25-jdk
# Метаданные/подготовка директории: обычно кэшируется стабильно
WORKDIR /app
# Этот слой меняется, когда меняется jar (а значит, и ваш код)
COPY build/libs/*.jar app.jar
# Любой шаг ниже COPY будет пересчитан, если COPY стал cache miss (каскад)
ENTRYPOINT ["java", "-jar", "app.jar"]
Если меняется только app.jar, Docker перестраивает слой COPY, а всё, что ниже, тоже считает заново (в нашем примере это только ENTRYPOINT). А FROM и WORKDIR останутся закэшированными.
А теперь представьте другую картину: «мне лень думать, копирую всё»:
FROM eclipse-temurin:25-jdk
WORKDIR /app
# Широкая команда: входом шага становится почти весь репозиторий
COPY . .
# Любое изменение в проекте (включая «мелочи») теперь заставит заново выполнять сборку
RUN ./gradlew bootJar
# Важно: в exec-form wildcard не разворачивается оболочкой, так что `*.jar` сам по себе не подставится
ENTRYPOINT ["java", "-jar", "build/libs/*.jar"]
Здесь один «невинный» шаг COPY . . зависит почти от любого изменения в проекте: вы поправили .md файл, добавили строчку в src/, IDE создала какой-нибудь служебный файл (если вы забыли .dockerignore) — всё, слой изменился. А значит, следующий шаг RUN ./gradlew bootJar будет выполнен заново. И вот тут Java-разработчик начинает грустить, потому что bootJar — шаг тяжёлый.
Сегодняшняя главная мысль: кэш — это не «включили галочку». Кэш — это инженерная награда за то, что вы структурировали шаги так, чтобы они редко инвалидировались.
6. Java/Gradle и проблемы кэша
У Java-проектов есть неприятная особенность: даже маленькое приложение тащит приличный набор зависимостей, а сборка часто включает компиляцию, тесты (иногда), упаковку jar и ещё пару радостей жизни. Плюс у Gradle своя логика загрузки зависимостей, свой кэш и свои первые «прогревы». В итоге docker build становится либо приятным ежедневным инструментом, либо «временным налогом» за каждую строчку кода.
Чтобы почувствовать, почему порядок шагов так важен, нужно увидеть простую вещь: в Gradle-проекте разные файлы меняются с разной частотой. Код в src/ вы трогаете постоянно. build.gradle.kts и settings.gradle.kts — заметно реже. А сам список зависимостей (особенно в учебном проекте) может не меняться неделями. Вот тут кэш и становится союзником: если вы построите Dockerfile так, чтобы редко меняющиеся части попадали в ранние шаги и кэшировались, то ежедневные правки в коде будут трогать только поздние шаги.
Сейчас мы ещё не рефакторим Dockerfile. Но диагноз уже важен: для Java/Gradle плохая структура Dockerfile обычно означает не «минус 5 секунд», а «всё пересобирается по 3–10 минут, и вы начинаете подозревать, что Docker вас не любит». Docker вас любит. Просто он не телепат.
7. Где увидеть кэш
Теория про кэш звучит красиво, пока не увидишь её глазами. Хорошая новость: Docker довольно честно показывает в build output, что он взял из кэша, а что пересобрал. И это видно даже без специальных команд анализа — достаточно внимательно смотреть на сборку.
Представим, что мы работаем в корне репозитория docker-java-catalog-service/ и используем наш простой Dockerfile, который копирует уже собранный jar. Последовательность действий выглядит так:
# Собираем jar локально (это отдельный кэш — gradle cache, не docker cache)
./gradlew bootJar
# Первая сборка образа: слоёв в кэше ещё почти нет, шаги выполнятся реально
docker build -t docker-java-catalog-service:day5 .
# Вторая сборка без изменений: ожидаем увидеть много CACHED
docker build -t docker-java-catalog-service:day5 .
Первая сборка почти наверняка выполнит шаги реально. Вторая должна показать много CACHED. И это логично: вы ничего не меняли, Docker не обязан страдать второй раз.
Теперь ключевой момент: если вы меняете код, то jar меняется, и шаг COPY build/libs/*.jar app.jar будет cache miss. В выводе вы увидите, что именно этот шаг выполняется заново, а предыдущие остаются CACHED. Это и есть то самое разумное поведение: мы пересобираем только то, что реально стало другим.
И наоборот, если вы случайно меняете что-то раннее (например, FROM или WORKDIR, или делаете широкий COPY . .), то вы запускаете каскад: Docker не сможет использовать кэш ниже по цепочке, потому что базовый слой изменился. Это выглядит как «почему вообще всё пересобралось, я же почти ничего не сделал». На самом деле сделали — вы изменили то, от чего зависит всё остальное.
8. Типичные ошибки при работе со слоями
Ошибки вокруг кэша чаще всего происходят не из-за «сложного Docker», а из-за наших ожиданий. Мы ждём, что система догадается: «ну это же мелочь», а система упрямая и честная — она сравнивает шаги и входные данные. Ни чувств, ни эмпатии, только слои. Даже Java-код иногда проявляет больше сочувствия, кидая понятное исключение.
Ошибка №1: путать слой образа и контейнер, а потом пытаться «почистить кэш» через удаление контейнера.
Контейнер — это запуск, слои — это сборка. Удалив контейнер (docker rm), вы не удаляете автоматически слои образа и build cache. И наоборот: можно иметь идеальный кэш сборки и при этом запускать новые контейнеры сколько угодно раз. Это разные части мира.
Ошибка №2: считать, что cache hit — это «ускорение вообще», и не замечать, что входные файлы шага постоянно меняются.
Docker кэширует шаг, только если совпадают инструкция и её входы. Если вы копируете весь проект одной командой COPY . ., то входом становится «почти всё», и любой чих инвалидирует кэш. Потом кажется, что «кэш не работает», хотя на самом деле он работает… просто ему почти нечего переиспользовать.
Ошибка №3: ожидать, что если изменился один файл, то пересоберётся только один шаг.
Изменение часто вызывает каскад: поменялся ранний слой — перестроились все нижние. Это нормально и логично, если помнить, что Docker строит образ как цепочку «поверх предыдущего результата». И это как раз причина, почему порядок шагов в Dockerfile — не косметика, а инструмент управления скоростью.
Ошибка №4: забыть, что кэш живёт локально, и удивляться, что на другой машине сборка «вдруг медленная».
Кэш — это не магия облака (по умолчанию). Если вы собрали образ на своём ноутбуке, а потом коллега собирает его на чистой машине, у него нет ваших закэшированных слоёв. У него будет «первая сборка» со всеми тяжёлыми шагами. Это не поломка — это честная реальность.
Ошибка №5: специально или случайно отключать кэш, а потом ругать Docker за медлительность.
Если вы запускаете сборку с флагом --no-cache, Docker буквально выполняет вашу просьбу: «не используй кэш». Иногда это полезно для проверки, но если делать так постоянно, вы добровольно превращаете каждую сборку в первую. Это как каждый раз переустанавливать IDE, чтобы «точно было чисто».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ