1. Частота змін і кеш Docker
Тепер, коли зрозуміло, що COPY треба будувати від стабільного до мінливого, виникає практичне запитання: що саме в Gradle‑проєкті вважати стабільним, а що — тим, що живе в зоні щоденних правок. У Java/Gradle‑проєктів є своя особливість: «вага» збирання часто сидить не у вашому коді, а в залежностях і кроках збирання. Тому грамотний поділ файлів за частотою змін — це не естетика, а реальна економія хвилин життя, а іноді й нервових клітин.
Уявіть, що кеш Docker — це чек‑лист на вході в клуб: якщо ви щоразу змінюєте паспорт, вас перевіряють заново. Наше завдання — зробити так, щоб «паспортні дані» (wrapper і build‑файли) змінювалися рідко й лежали в ранніх кроках, а «сьогоднішній одяг» (src/) можна було змінювати хоч сто разів на день, але так, щоб це зачіпало лише пізні кроки.
Тут важливо не переплутати: ми не намагаємося досягти «0 секунд повторного збирання» і не робимо з Dockerfile змагання мікрооптимізацій. Нам потрібна передбачувана поведінка: змінили код — повторно зібрався тільки той шматок, який логічно має зібратися знову.
2. Карта репозиторію Gradle/Spring Boot
Коли ви дивитеся на корінь репозиторію, він здається одним великим каталогом: «ну там же весь проєкт». Але кеш Docker так не думає. Для нього важливо інше: «до цього шару я копіюю ось ці файли». І якщо у вас немає в голові карти проєкту, ви майже неминуче скотитеся до COPY . ., а далі героїчно мучитиметеся (і писатимете в чат: «чому Docker не кешує???»).
Давайте зафіксуємо мінімальну карту, на яку ми спиратимемося в нашому docker-java-catalog-service:
docker-java-catalog-service/
|-- gradlew
|-- gradle/
|-- build.gradle.kts
|-- settings.gradle.kts
|-- src/
\-- build/ # генерується Gradle, у Git зазвичай не комітиться
У цій картинці вже захована логіка сьогоднішньої лекції. gradlew і папка gradle/ — це «двигун», який дає змогу запускати Gradle однаково в усіх. build.gradle.kts і settings.gradle.kts — «рецепт», за яким Gradle збирає проєкт і вирішує, які залежності підтягнути. src/ — те, що ви змінюєте найчастіше. А build/ — результат, який узагалі не має бути вхідними даними для збирання: це «вихлоп», а не «сировина».
3. Gradle Wrapper
На цьому місці зазвичай відбувається типовий діалог початківця з реальністю: «А навіщо мені цей gradlew, я ж можу просто gradle bootJar». Можете… поки ви працюєте самі, у вас одна машина і ви не змінювали версію Gradle пів року. Щойно з’являється друга людина або ви перевстановлюєте систему, починається лотерея: у когось Gradle 9.4, у когось 8.7, а в когось узагалі «не встановлено».
Gradle Wrapper — це якраз розв’язання цієї лотереї. Він фіксує версію Gradle для проєкту й робить запуск збирання відтворюваним. Для Docker це особливо важливо: усередині контейнера ми не хочемо «вгадувати», який Gradle там стоїть. Ми хочемо принести в образ рівно те, що потрібно для збирання.
Міні‑карта цієї частини виглядає так:
.
|-- gradlew
\-- gradle/
\-- wrapper/
|-- gradle-wrapper.properties
\-- gradle-wrapper.jar
Якщо спростити до однієї фрази, gradlew — це «кнопка запуску Gradle», а gradle/wrapper/* — «вбудована інструкція, який саме Gradle завантажити й запустити». І все це змінюється рідко. Отже, з точки зору кешу Docker це ідеальні кандидати на ранні COPY.
Є ще один практичний нюанс, який обов’язково спливає хоча б у частини людей: gradlew має бути виконуваним (chmod +x). На Linux і macOS це зазвичай «просто так і працює», а на Windows іноді виринає раптове permission denied. Це не привід ненавидіти Docker, а привід пам’ятати, що docker build найчастіше виконується в Linux‑контексті.
4. Build‑файли та залежності
Після wrapperʼа друга опорна плита проєкту — build‑файли. У Kotlin DSL це зазвичай build.gradle.kts і settings.gradle.kts. У них живе все, що Gradle вважає описом збирання: плагіни, залежності, версія проєкту, ім’я проєкту, структура модулів. Ви не змінюєте їх щогодини, але й «раз на рік» теж не завжди: додали бібліотеку, оновили версію, підключили плагін — і ось файл уже змінився.
З точки зору кешу Docker build‑файли — це зона середньої стабільності. Їх потрібно копіювати раніше за src/, тому що зміна бізнес-коду не має ламати кеш там, де ви, умовно, підтягуєте залежності. Але якщо ви справді змінюєте залежності — так, це чесний привід анулювати частину кешу й повторно зібрати важкі шари. Тут Docker поводиться правильно, і сперечатися з ним не треба.
Приклад фрагмента build.gradle.kts у нашому навчальному сервісі може виглядати так:
dependencies {
// Вебшар (контролери, HTTP-обробка). Змінюєте залежності тут — змінюється граф залежностей.
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Кінцева точка /actuator для моніторингу та здоров’я застосунку.
implementation("org.springframework.boot:spring-boot-starter-actuator")
// JPA + транзакції. Це зазвичай «важкий» шар, який тягне пачку залежностей.
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Драйвер БД потрібен тільки під час виконання програми (у коді зазвичай не імпортується напряму).
runtimeOnly("org.postgresql:postgresql")
}
Тут важливо побачити логіку: ви змінили цей блок — отже, проєкт став іншим з точки зору графа залежностей. Нормально, що кеш Docker на цьому місці скаже: «Окей, я повторно зберу шар, де Gradle щось розв’язує, завантажує та повторно збирає». Але якщо ви не змінювали залежності, а просто поправили CatalogItemService, Docker має мати шанс не чіпати цей шар. Саме заради цього ми й розводимо файли по групах.
Ще один часто вживаний файл — gradle.properties. Якщо він є і в ньому лежать налаштування збирання (версії, прапорці), то за змістом він належить до тієї самої категорії, що й build.gradle.kts: це частина «рецепта» збирання, отже, його логічно тримати в тій самій групі.
5. Вихідні файли: src/
Якщо wrapper і build‑файли — це фундамент, то src/ — ваше щоденне будівництво. Тут ви живете: пишете Java‑класи, змінюєте контролери, правите сервіси, додаєте DTO, коригуєте application.yml, змінюєте SQL‑міграції тощо. І так, це змінюватиметься часто. Іноді — десятки разів на день, особливо поки ви вчитеся.
Тому src/ майже завжди має потрапляти в Dockerfile пізніше, ніж wrapper і build‑файли. Це не «оптимізація заради оптимізації», а спосіб зберегти чесний кеш для більш стабільних шарів. Зміна src/ має анулювати шар COPY src ... і все, що нижче по Dockerfile (наприклад, крок збирання bootJar). Це нормально. Ненормально, коли зміна src/ анулює ще й кроки, які взагалі не пов’язані з вихідними файлами, наприклад підготовку залежностей або копіювання wrapperʼа.
Якщо спростити: src/ — це як робочий стіл розробника, а wrapper і build‑файли — як ящик з інструментами та інструкція до них. Ви можете перекладати папірці на столі скільки завгодно, але це не має змушувати вас щоразу купувати новий молоток.
6. build/ і згенеровані артефакти
На цьому місці часто хочеться зробити «просту» річ: «Я зберу bootJar локально, а потім просто скопіюю build/ в образ». На ранньому етапі це справді працює, і ми так уже робили, коли хотіли швидко побачити, що «контейнер запускається». Але якщо ми говоримо про збирання, дружнє до кешу, і про поступовий розвиток Dockerfile, важливо не перетворювати каталог build/ на частину вхідних даних для docker build просто тому, що так коротше писати.
Каталог build/ — це результат роботи Gradle. Усередині нього лежать скомпільовані класи, ресурси, звіти, результати тестів, іноді тимчасові файли, і, так, ваш jar теж. Цей каталог за своєю природою «шумний»: він змінюється навіть тоді, коли ви змінили дрібницю. А ще там легко накопичити сміття від минулих збирань. Якщо Docker побачить, що в build context потрапляє build/, він вважатиме це частиною входу. І далі починається історія «чому у мене кеш завжди промахується».
Тому правило дуже просте й дуже корисне: build/ найчастіше має бути виключений із build context через .dockerignore, а Dockerfile має працювати з вихідними файлами та рецептами збирання, а не з тим, що Gradle вже «накидав» у вашу файлову систему.
Міні‑приклад .dockerignore для Java/Gradle‑проєкту:
# Вихідні дані Gradle: «шумний» output, не має потрапляти в контекст збирання
build
.gradle
# Файли IDE: змінюються самі по собі і ламають кеш «на рівному місці»
.idea
*.iml
out
# Зазвичай не потрібні для збирання JAR усередині образу (якщо ви їх не використовуєте в build-скрипті)
# requests
# scripts
Навіть якщо ви не зробите нічого більше, вже це помітно зменшить кількість випадкових причин, через які кеш Docker стає марним.
7. Локальні кеші та сліди IDE
Є особлива категорія файлів, які живуть поруч із проєктом, але взагалі не є частиною «вихідних файлів застосунку». Це локальні кеші Gradle (.gradle/), налаштування IDE (.idea/, *.iml), службові папки (out/) та інші сліди вашої машини. Вони часто змінюються непомітно для вас: IDE щось перезберегла, Gradle оновив кеш, ви перемкнули гілку.
Якщо такі файли потраплять у build context і ви використовуєте широкий COPY . ., Docker буде змушений реагувати на їхні зміни так, ніби ви змінили важливу частину проєкту. А ви сидітимете й думатимете: «Я ж нічого не змінював, чому кеш зламано?». Спойлер: ви не змінювали бізнес-код, але змінювалися файли в контексті збирання.
Тому .dockerignore тут працює як фільтр здорового глузду: ми явно кажемо Dockerʼу «це не стосується збирання образу, не тягни це в build context». І так, .dockerignore не замінює порядок COPY, але різко знижує рівень випадкового шуму, особливо у початківців.
До речі, в навчальному репозиторії у нас можуть бути requests/ і scripts/ для smoke‑перевірок. Це корисні речі для розробника, але для збирання JAR усередині образу вони зазвичай не потрібні. Якщо копіювати весь проєкт цілком, зміни у файлі requests/catalog.http теж ламатимуть кеш Docker просто тому, що змінився файл у контексті. Тому наступний природний крок — копіювати всередину образу тільки те, що справді потрібно для збирання.
8. Таблиця частоти змін
Після всіх пояснень корисно зафіксувати це не як набір емоцій («здається, це стабільне»), а як інженерну карту. Нижче — та сама таблиця, яку зручно тримати в голові, коли ви пишете COPY у Dockerfile.
| Група файлів | Приклади | Як часто змінюється | Що це означає для кешу Docker | Де зазвичай у Dockerfile |
|---|---|---|---|---|
| Gradle Wrapper | |
рідко | чудовий кандидат на ранній COPY і довгоживучий кеш | на самому початку (після WORKDIR) |
| Опис збирання та залежності | |
іноді | має анулювати кеш, коли ви справді змінюєте залежності (це нормально) | після wrapper, але до src/ |
| Вихідні файли застосунку | |
часто | часто ламатиме кеш нижніх кроків, і це очікувано | ближче до кінця |
| Тести (якщо є) | |
іноді/часто | якщо ви в образі запускаєте тести, це ще один «гарячий» шар | залежить від стратегії, але зазвичай окремо |
| Build output (generated) | |
постійно | ламає кеш «шумом», зазвичай шкідливий як вхід | в образ зазвичай не копіюємо |
| Локальні службові файли | |
постійно і непередбачувано | створює випадкові cache miss | виключаємо через .dockerignore |
Зверніть увагу на важливу думку: кеш Docker — це не «зробити так, щоб усе було cached». Кеш Docker — це «кешувати те, що логічно кешувати, і перебудовувати те, що справді змінилося». Якщо ви додали нову залежність, повторне збирання — правильна ціна. А якщо поправили один метод у сервісі, то перевантажувати весь інтернет заново вже звучить як знущання, і ми це лікуємо структурою Dockerfile.
9. Групуємо COPY у Dockerfile
Зараз нам важливо не дописати Dockerfile до останнього рядка, а перевести карту репозиторію в порядок COPY. На рівні файлової структури логіка зазвичай виглядає так: спочатку wrapper, потім build‑файли, потім src.
Міні‑фрагмент, який відображає саме ідею групування:
WORKDIR /app
# 1) Найрідкісніші зміни: wrapper (дає відтворювану версію Gradle)
COPY gradlew ./
COPY gradle/ ./gradle/
# 2) «Рецепт» збирання: змінюється рідко/іноді, але критично для залежностей
COPY build.gradle.kts settings.gradle.kts ./
# 3) Найчастіші зміни: вихідні файли застосунку
COPY src/ ./src/
Цього фрагмента вже достатньо, щоб побачити правильну межу анулювання. src/ має приходити пізніше за build‑файли, щоб звичайна правка коду не будила верхню частину збирання. А якщо змінюється build.gradle.kts, межа підніметься вище — і це якраз чесна ціна зміни графа залежностей.
З цієї карти вже майже складається Dockerfile цілком: зверху залишаються стабільні входи збирання, нижче йде гаряча зона src/, а важкий bootJar має стояти після цієї межі.
10. Типові помилки під час роботи з кешем Docker
Помилка №1: сприймати весь проєкт як один неподільний каталог і писати COPY . . на початку.
Такий Dockerfile приємно короткий, але він буквально просить Docker вважати «будь-який чих» причиною для повторного збирання: змінили .java файл, оновився .idea, поправили README.md, IDE створила новий файл — і ось у вас знову повторно збирається важка частина. Це особливо болісно в Gradle‑проєкті, де збирання й залежності коштують дорого.
Помилка №2: тягнути build/ у build context і дивуватися, чому кеш ніколи не спрацьовує.
build/ — це output, він змінюється часто й шумно. Коли він бере участь як input, ви фактично кажете Docker: «Вважай, що проєкт змінюється завжди». Після цього кеш стає лотереєю, а повторне збирання — вічним. Правильний шлях — виключити build/ у .dockerignore і копіювати тільки вихідні файли та файли збирання.
Помилка №3: забути про Gradle Wrapper і намагатися «просто поставити Gradle в образ».
Іноді хочеться зробити RUN apt-get install gradle або «взяти Gradle з образу». Але тоді ви втрачаєте відтворюваність і починаєте залежати від того, що опинилося в базовому образі та в менеджері пакетів. Wrapper створено саме для того, щоб цього не було. Для навчального проєкту це особливо важливо: ми хочемо, щоб команди працювали однаково в усіх студентів.
Помилка №4: не розуміти, що build‑файли та вихідні файли — це різні «класи змін».
Якщо ви копіюєте build‑файли та src/ одним кроком, ви змішуєте «рідкісні зміни» (наприклад, оновлення залежностей) із «частими змінами» (правка коду). У результаті рідкісні частини не отримують нормального шансу бути закешованими. Поділ COPY за змістом — одна з найпростіших і найефективніших речей, які можна зробити в Dockerfile.
Помилка №5: залишити в build context .gradle/ і .idea/, а потім ловити випадкові cache miss «на рівному місці».
Ці папки легко змінюються самі по собі. Ви можете навіть не чіпати код, але IDE оновить файли, Gradle оновить кеш, і Docker побачить зміни. У підсумку ви втрачаєте довіру до збирання («воно непередбачуване»). Зазвичай це лікується двома речами: хорошим .dockerignore і вужчим набором файлів у COPY.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ