1. Користь рефакторингу Dockerfile
Мапа Gradle-проєкту вже готова: wrapper і build-файли ми віднесли до стабільної частини, а src/ — до гарячої зони щоденних змін. Тепер залишилося перетворити це на один Dockerfile, у якому межа інвалідації проходить саме там, де нам потрібно.
Розробка — це цикл: змінили код, перезібрали, перевірили. Коли повторне збирання щоразу забирає вічність, контейнер швидко перетворюється на рідкісний ритуал «перед комітом», а не на нормальний робочий інструмент. Нам потрібно, щоб контейнер не був «десь поруч», а справді вписувався у звичайний ритм проєкту.
Наша мета — отримати Dockerfile, який справді використовує кеш. Коли ви змінюєте один Java-клас у src/, вам не треба заново «перетравлювати» всю збірку, завантажувати залежності та виконувати важкі кроки, яких ви взагалі не чіпали. Ми хочемо, щоб Docker розумів: змінився код — перебудуй усе, що пов’язано з кодом; змінилися залежності — перебудуй усе, що пов’язано із залежностями. Звучить майже як поведінка розумної людини. Саме так ми його й будемо «виховувати».
2. Наївний Dockerfile і поламаний кеш
Щоб зрозуміти, що саме ми покращуємо, корисно зафіксувати «поганий, але чесний» варіант. Він часто з’являється сам по собі, бо здається найпростішим: копіюємо весь проєкт, запускаємо Gradle, отримуємо jar — готово. Ось типова версія:
FROM eclipse-temurin:25-jdk
# Робочий каталог усередині контейнера: сюди будемо копіювати проєкт
WORKDIR /app
# Наївний крок: копіюємо ВЕСЬ проєкт одним шаром — це легко ламає кеш
COPY . .
# Збирання jar усередині контейнера
RUN ./gradlew bootJar --no-daemon
Шлях запуску тут свідомо опущено: для розмови про кеш нам важливий саме широкий COPY . . перед важким RUN.
У чому проблема? COPY . . робить цей шар залежним буквально від усього, що потрапило в build context. Ви виправили src/main/java/... — це очікувано. Але ви також можете змінити README.md, файл у requests/, скрипт у scripts/ або навіть випадково зачепити якийсь конфіг. І Docker скаже: «Ага, вміст, який я копіював, змінився. Отже, шар інший. Отже, кеш не підходить. Отже, усе, що нижче, доводиться робити заново».
А нижче в нас RUN ./gradlew bootJar. Це зазвичай найважчий крок: Gradle налаштовує проєкт, розв’язує залежності, можливо, завантажує їх, компілює код і збирає jar. І ось він починає повторюватися навіть тоді, коли ви не змінювали нічого, пов’язаного із залежностями або збиранням. Виходить класична ситуація: «я змінив один рядок — а збирання поводиться так, ніби я переписав увесь інтернет».
Щоб побачити це не «на віру», а власними очима, достатньо один раз зібрати, а потім повторити:
# Перше збирання: шари кешу ще не прогріті
docker build -t docker-java-catalog-service:naive .
# Друге збирання: можна побачити, які кроки стали CACHED, а які запускаються знову
docker build -t docker-java-catalog-service:naive .
На другому збиранні ви можете побачити у виводі, що частина кроків позначається як CACHED (cache hit), а частина виконується заново. Наївний Dockerfile часто дає неприємний ефект: ви змінюєте будь-який файл, який потрапляє під COPY . ., і RUN ./gradlew ... перестає бути cached.
Тут важливо не впадати в крайнощі. Наївний Dockerfile — не «ганьба», а нормальний початковий крок. Просто далі ми починаємо ставитися до образу як до інженерного артефакту, а не як до архіву «запхни все і сподівайся».
3. Базовий варіант, дружній до кешу: критерії
Щоб рефакторинг був осмисленим, нам потрібен критерій «стало краще». Він має бути спостережуваним, а не відчуттям. Давайте сформулюємо цільову поведінку так, щоб її можна було перевірити за build output.
Ми хочемо, щоб Dockerfile поводився так:
Якщо змінився лише код застосунку (src/...), то кроки, пов’язані з wrapper і залежностями, мають залишатися CACHED, а перебудовуватися має лише пізня частина: копіювання src і збирання jar.
Якщо змінилися build-скрипти (наприклад, build.gradle.kts), то це чесно має інвалідовувати шар із залежностями, тому що залежності могли змінитися.
Якщо змінився файл, який узагалі не впливає на збирання jar (наприклад, README.md), ми б хотіли, щоб він не ламав кеш збирання. Саме цього ми й досягаємо, коли перестаємо копіювати «все підряд» одним махом.
Зручно тримати це в голові у вигляді маленької таблиці:
| Що ви змінили | Що має перебудуватися | Чому це нормально |
|---|---|---|
| src/main/java/... | COPY src ... і |
код змінився — jar має перебудуватися |
| build.gradle.kts | крок залежностей і все, що нижче | залежності могли змінитися |
| README.md | в ідеалі — нічого в ланцюжку збирання | документація не має ламати збирання |
Фраза «в ідеалі» тут важлива. Ми все ще працюємо в межах одного Dockerfile без додаткових «підсилювачів». Наше завдання — різко зменшити радіус ураження від звичайних змін коду, а не досягти абсолютної ідеальності.
4. Збирання cache-friendly Dockerfile
Нижче зберемо єдиний single-stage baseline. Він ще не розділяє build і runtime по різних stages, зате вже дає передбачувану поведінку кешу й добре показує, де саме проходить межа інвалідації.
Головна ідея рефакторингу проста й навіть трохи кумедна: ми не змінюємо, що робимо, ми змінюємо порядок. Docker схожий на дуже педантичного бухгалтера: йому подобається, коли ви спочатку приносите стабільні папірці, а потім — ті, які переписуєте кожні п’ять хвилин.
Стабільні «входи збирання»
Починаємо з того, що Gradle взагалі має вміти запускатися всередині контейнера. Для цього нам потрібні Gradle wrapper (gradlew і папка gradle/) та файли збирання (build.gradle.kts, settings.gradle.kts). Іноді ще трапляються gradle.properties і version catalog, але базовий набір уже дає нам правильну структуру.
Ось перший блок нашого майбутнього Dockerfile:
FROM eclipse-temurin:25-jdk
WORKDIR /app
# Gradle Wrapper: скрипт запуску
COPY gradlew ./
# Gradle Wrapper: сама "обгортка" (версія Gradle, jar, налаштування wrapper)
COPY gradle ./gradle
# Build-скрипти: змінюються рідше, ніж вихідний код, тому тримаємо їх вище в Dockerfile
COPY build.gradle.kts settings.gradle.kts ./
Чому це працює на користь кешу? Тому що ці файли змінюються порівняно рідко. Так, ви будете додавати залежності й змінювати плагіни, але не по десять разів на годину. А ось src/ будете чіпати постійно. Тому src/ має бути нижче.
Якщо у вас є gradle.properties, version catalog або інші build-only файли, за змістом вони належать до тієї самої групи: стабільні входи збирання кладемо вгору, а не змішуємо їх із кодом застосунку.
Невеликий кросплатформений нюанс, особливо якщо ви працюєте на Windows: іноді всередині контейнера Gradle wrapper не має права на виконання, і ./gradlew завершується помилкою “Permission denied”. У такому разі додають один крок:
# Даємо право на виконання wrapper-скрипту (часто потрібно після копіювання з Windows)
RUN chmod +x gradlew
Краще сприймати це як маленьке страхування від ситуації «чому в автора на Linux працює, а в мене ні». Якщо у вас усе запускається без chmod, можна не додавати.
Окремий шар для залежностей
Тепер ключовий трюк дня: робимо важкий крок, який залежить лише від build-скриптів, а не від вихідного коду. Нам потрібно змусити Gradle сходити в мережу, розв’язати конфігурації та завантажити те, що знадобиться для збирання.
Простий і наочний варіант — команда dependencies. Вона не збирає jar, але змушує Gradle пройти важливу частину шляху й наповнити кеш залежностей.
# Прогріваємо кеш Gradle: завантажуємо залежності до копіювання вихідного коду
RUN ./gradlew dependencies --no-daemon
Цього кроку часто вистачає, щоб винести значну частину роботи із залежностями вище COPY src. Але це не магічна гарантія, що bootJar потім узагалі нічого не зробить: реальне збирання все одно може завантажити або перерахувати ще частину роботи.
І ось тут починається «магія без магії»: результат цього кроку, включно із завантаженими залежностями та кешем Gradle всередині файлової системи образу, потрапляє в шар Docker. А отже, під час наступного збирання, якщо build-скрипти не змінювалися, Docker може сказати: «Цей шар уже є, тримайте CACHED», і ви не завантажуватимете пів інтернету заново.
Важливо розуміти: зараз ми використовуємо Docker-кеш як грубий, але дуже ефективний «знімок стану». Це не тонка оптимізація, а базова інженерія: не повторювати те, що не змінювалося.
Вихідний код копіюємо пізніше
Тепер, коли «рідко змінюване» вже вгорі, ми додаємо «часто змінюване»:
# Вихідний код змінюється часто — тому копіюємо його після шарів із wrapper і залежностями
COPY src ./src
# Збираємо jar (цей крок буде перебудовуватися під час змін у src)
RUN ./gradlew bootJar --no-daemon
Цей COPY src буде інвалідовуватися під час кожної зміни коду — і це нормально. Зате він не тягнутиме за собою шари, де ми завантажували залежності й готували wrapper. Тобто ви змінили один клас — перебудувався лише хвіст ланцюжка.
Стабільна назва jar
Після bootJar у точці запуску нам потрібен один зрозумілий виконуваний jar. Зараз для нас важливий не конкретний version suffix, а сама дисципліна: ENTRYPOINT має посилатися на стабільну назву, інакше тема кешу швидко змішується з вгадуванням шляху до файлу.
У навчальному baseline просто приводимо виконуваний bootJar до app.jar. Якщо у build/libs у вас виявляється кілька jar-файлів, орієнтуйтеся саме на bootJar, а не на будь-який артефакт, який трапився під руку.
# Приводимо назву виконуваного jar до стабільної, щоб ENTRYPOINT не залежав від версії/суфіксів
RUN cp build/libs/*.jar app.jar
# Запускаємо саме app.jar — передбачувано і без "вгадування" назви
ENTRYPOINT ["java", "-jar", "app.jar"]
Так, це маленький крок, але він перетворює запуск на передбачувану річ. Dockerfile має бути не лише швидким, а й зрозумілим.
Фінальна версія Dockerfile
Зберемо все в один читабельний baseline. Постараємося зробити його коротким і «по-навчальному чесним»: зараз нас цікавлять не розміри образу, а швидкість повторного збирання і передбачуваність шарів.
FROM eclipse-temurin:25-jdk
WORKDIR /app
# 1) Спочатку — усе, що змінюється рідко
COPY gradlew ./
COPY gradle ./gradle
COPY build.gradle.kts settings.gradle.kts ./
# (необов'язково) якщо wrapper не виконується в контейнері
# RUN chmod +x gradlew
# 2) Прогріваємо залежності окремим шаром, щоб зміни в src не ламали цей кеш
RUN ./gradlew dependencies --no-daemon
# 3) Потім — вихідний код (змінюється постійно)
COPY src ./src
# 4) І лише тепер — збирання застосунку
RUN ./gradlew bootJar --no-daemon
# 5) Приводимо виконуваний bootJar до стабільної назви для точки запуску
RUN cp build/libs/*.jar app.jar
# 6) Запуск контейнера
ENTRYPOINT ["java", "-jar", "app.jar"]
Якщо у вас Windows і gradlew не виконується, вставте після COPY gradlew рядок RUN chmod +x gradlew. Це додасть один шар, зате прибере головний біль.
Щоб відчути, що Dockerfile став «дружнім до кешу», корисно навіть візуально уявити його як багатошаровий пиріг:
flowchart TD
A["FROM + WORKDIR"] --> B["COPY wrapper + build scripts"]
B --> C["RUN gradle dependencies"]
C --> D["COPY src"]
D --> E["RUN gradle bootJar"]
E --> F["cp jar -> app.jar + ENTRYPOINT"]
Сенс цієї схеми один: зміни в src мають починати перебудову з блоку COPY src, а не з самого верху.
Це вже той single-stage baseline, на якому зручно спостерігати cache hit і cache miss: верх майже не рухається, а звичайні правки коду б’ють лише по хвосту збирання.
5. Перевірка кешу: два сценарії
Тепер найприємніше: ми не просто «віримо», що стало краще, а перевіряємо це на двох типових змінах. У цьому й полягає інженерний підхід: дивитися на симптоми та робити висновки, а не сподіватися на вдачу.
Сценарій №1: змінюємо лише код у src/
Уявімо, що ви змінили, наприклад, повідомлення в логах у якомусь сервісі каталогу. І перезбираєте образ:
# Змінювали лише src/ — очікуємо, що залежності залишаться CACHED
docker build -t docker-java-catalog-service:day5 .
В ідеалі ви побачите приблизно таку поведінку у виводі:
CACHED ... COPY gradlew ./
CACHED ... COPY gradle ./gradle
CACHED ... COPY build.gradle.kts settings.gradle.kts ./
CACHED ... RUN ./gradlew dependencies --no-daemon
... COPY src ./src
... RUN ./gradlew bootJar --no-daemon
Тобто все, що пов’язане з wrapper і залежностями, залишається cached. Перебудовується лише хвіст — і це саме те, чого ми добивалися. Крок, який приводить jar до app.jar, теж виконається заново — але це вже короткий хвіст, а не повторний прогрів залежностей.
Сценарій №2: змінюємо залежність у build.gradle.kts
Тепер додамо новий стартер Spring Boot або змінимо версію якоїсь бібліотеки (у нашому проєкті версії зазвичай контролюються BOM-ом, але сам факт зміни build-скрипта залишається). І знову збираємо:
# Змінювали build.gradle.kts — очікуємо, що шар із залежностями перебудується
docker build -t docker-java-catalog-service:day5 .
Тепер логічно очікувати, що шар залежностей перебудується:
... COPY build.gradle.kts settings.gradle.kts ./
... RUN ./gradlew dependencies --no-daemon
... COPY src ./src
... RUN ./gradlew bootJar --no-daemon
І це правильно. Якщо ви змінили залежності, Docker зобов’язаний перебудувати той крок, який від них залежить. Cache-friendly Dockerfile не намагається «обдурити реальність». Він просто робить так, щоб повторне збирання відбувалося саме там, де зміни справді зачіпають результат.
6. Пояснення без магії
Іноді після такого рефакторингу виникає відчуття: «ми наче нічого напряму не прискорювали, ми просто переставили рядки». І це чудове відчуття, бо так і є. Docker-кеш улаштований просто: він повторно використовує шар, якщо інструкція та її вхідні дані збіглися. Тому ми й почали ставитися до Dockerfile як до сценарію, де важливо, які вхідні дані з’являються на кожному кроці.
Чому окремий крок ./gradlew dependencies взагалі має сенс? Тому що для Java-проєктів «важке» — це не лише компіляція коду, а й завантаження та розв’язання залежностей. І ці залежності майже завжди змінюються рідше, ніж код. Ми відокремили «рідко змінюване» (опис збирання) від «часто змінюваного» (src) і поставили залежнісний крок туди, де він найчастіше потраплятиме в cache hit.
Чому ми не залишили ENTRYPOINT на build/libs/...jar? Тому що назва jar майже завжди містить версію. Версія — це те, що легко змінюється або принаймні виглядає таким, що змінюється. А Dockerfile має бути простим і передбачуваним. Перейменування в app.jar — це маленька дисципліна, яка різко зменшує кількість ситуацій «а чому контейнер не стартує».
І так, можна було б заглибитися в десятки тонкощів Gradle, його кешів і режимів збирання. Але тут достатньо одного робочого принципу: build-скрипти — вище, src — нижче, прогрів залежностей — до src, точка запуску — через зрозумілий jar. Це вже чудовий робочий baseline.
Навіть якщо build і runtime рознести по різних stages, логіка не зміниться: stable inputs і далі залишаться зверху, а hot zone коду — нижче. Зараз нам важливо зафіксувати саме цю межу кешу.
7. Типові помилки під час рефакторингу Dockerfile
Помилка №1: залишити COPY . . «тому що коротше».
Короткий Dockerfile не завжди означає добрий. COPY . . у верхній частині файлу перетворює кеш на випадковість: змінюється будь-який файл — і Docker змушений перебудувати все, що нижче. У підсумку найважча команда (RUN ./gradlew ...) починає виконуватися навіть через зміни, які ніяк не впливають на jar.
Помилка №2: сподіватися, що .dockerignore замінює правильний порядок COPY.
.dockerignore справді зменшує build context і захищає вас від зайвого сміття, але він не вміє розводити часто і рідко змінювані файли по шарах. Якщо ви все одно робите один великий COPY, то просто копіюєте «менше всього», але все одно одним шматком. Кеш страждатиме менше, але все ще страждатиме.
Помилка №3: змішати generated output (build/) і входи збирання.
Якщо ви випадково копіюєте build/ всередину образу або тримаєте його в build context, ви створюєте ідеально безглузду інвалідацію: build/ змінюється постійно й узагалі не є вихідним кодом. У результаті Docker думає, що «проєкт змінився», хоча змінився лише результат минулого збирання. Це схоже на ситуацію, коли ви приносите викладачеві на перевірку не вихідник твору, а роздруківку з правками червоною ручкою і кажете: «Дивіться, це мій вихідник».
Помилка №4: забути про Gradle wrapper і намагатися запускати gradle так, ніби він «усюди є».
У контейнері немає вашого локального середовища. Там немає «встановленого Gradle», немає ваших глобальних налаштувань, немає того, що «і так працює на моєму ноутбуці». Якщо ви не копіюєте gradlew і gradle/, то RUN ./gradlew ... просто не спрацює. Wrapper — це частина відтворюваності проєкту, і Dockerfile має це поважати.
Помилка №5: не зробити стабільну назву jar і отримати Error: Unable to access jarfile ....
Коли ENTRYPOINT вказує на файл, який насправді називається інакше (наприклад, тому що в jar-імені з’явилася версія), контейнер стартує і одразу падає. Ви дивитеся docker logs, бачите помилку, і починається «танець підозр» навколо Java, шляхів і прав. Хоча проблема насправді в тому, що Dockerfile надто оптимістично «вгадав» назву файлу. Копіювання в app.jar вирішує це просто й надійно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ