JavaRush /Курсы /Docker for Spring /Слои образа и Docker cache

Слои образа и Docker cache

Docker for Spring
5 уровень , 0 лекция
Открыта

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, чтобы «точно было чисто».

1
Задача
Docker for Spring, 5 уровень, 0 лекция
Недоступна
Повторная сборка без изменений
Повторная сборка без изменений
1
Задача
Docker for Spring, 5 уровень, 0 лекция
Недоступна
Cache miss после изменения jar
Cache miss после изменения jar
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ