JavaRush /Курси /Docker for Spring /Шари образу та кеш у Docker

Шари образу та кеш у Docker

Docker for Spring
Рівень 5 , Лекція 0
Відкрита

1. Docker cache і швидкість docker build

Робочий Dockerfile у нас уже є: контейнер запускається, порт проброшено, усе виглядає пристойно. Але маленький проєкт оманливий: на ньому майже будь-який Dockerfile здається «нормальним». docker build відпрацював, контейнер запустився — і вже перемога. Справжній біль починається наступного дня, коли ви змінюєте один рядок у коді, знову запускаєте збірку… а вона знову тягнеться, як серіал на 12 сезонів. У Java це відчувається особливо гостро: залежностей багато, збірка важка, а терпіння в людини — безкінечний ресурс.

Кеш Docker потрібен не для того, щоб «завжди збирати миттєво». Він потрібен для того, щоб Docker міг чесно сказати: «Ось ці кроки точно не змінилися, я вже їх виконував, повторювати не буду». І чим краще ми розуміємо, як Docker ухвалює це рішення, тим легше нам спроєктувати Dockerfile так, щоб щоденні правки в src/ не перетворювалися на «перезбираємо весь світ заново».

2. Кроки Dockerfile і шари образу

Dockerfile часто сприймають як «просто список команд». Для Docker це радше рецепт збирання: він іде по ньому крок за кроком, згори донизу. Кожен крок дає проміжний результат, який Docker може запам’ятати й потім повторно використати. Саме цей «запам’ятаний результат кроку» в нашій спрощеній навчальній моделі ми й будемо називати шаром (layer).

Уявіть образ як листковий пиріг: внизу база (наприклад, Linux + JDK), зверху — ваші додавання (робочий каталог, jar, змінні середовища, entrypoint). Docker «випікає» кожен шар окремо. І якщо нижні шари не змінилися, йому не треба знову їх «випікати».

Для наочності можна уявити збирання ось так (спрощено):

flowchart TD
    %% Спрощена модель: один крок Dockerfile = один шар
    A["Dockerfile: крок 1"] --> B[Шар 1]
    B --> C["Dockerfile: крок 2"]
    C --> D[Шар 2]
    D --> E["Dockerfile: крок 3"]
    E --> F[Шар 3]
    F --> G["Підсумковий образ = шари 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 досить чесно показує у виводі збірки, що він узяв із кешу, а що перебудував. І це видно навіть без спеціальних команд аналізу — достатньо уважно дивитися на збірку.

Уявімо, що ми працюємо в корені репозиторію docker-java-catalog-service/ і використовуємо наш простий Dockerfile, який копіює вже зібраний jar. Послідовність дій виглядає так:

# Збираємо jar локально (це окремий кеш — кеш Gradle, не кеш Docker)
./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, щоб «точно було чисто».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ