1. Builder stage: «будівельний майданчик»
Якщо дивитися на multi-stage Dockerfile очима початківця, легко подумати: «Ну добре, ми просто додали другий FROM, і все стало сучасним». На практиці multi-stage — це не про моду, а про розподіл відповідальності. Builder stage — це та частина Dockerfile, у якій ми дозволяємо собі працювати без зайвої охайності, бо наше завдання — не запускати сервіс, а зібрати артефакт. Як на будівництві: пил, інструменти, упаковки від матеріалів — це нормально, доки все це не переїхало у фінальну квартиру.
Головна думка builder stage дуже проста і навіть трохи нудна, а отже, правильна: у builder stage живе все, що потрібно, щоб отримати виконуваний Spring Boot jar, і більше нічого. Ми не налаштовуємо порт, не думаємо про healthcheck, не проєктуємо runtime-поведінку контейнера. Builder stage відповідає на одне запитання: «Як усередині Docker-збирання зробити так, щоб на виході зʼявився bootJar нашого Container-Ready Catalog Service?»
Важливий психологічний момент: раніше ми часто будували образ за схемою «зібрати jar на своїй машині → скопіювати jar в образ». Це швидкий навчальний старт, але він привʼязаний до середовища розробника. Builder stage дозволяє перенести збирання всередину Docker: якщо у вас є Docker, ви можете зібрати jar у передбачуваному середовищі, навіть якщо на хості Gradle не встановлено (а IDE сьогодні вирішила оновитися і все зламати — буває, це не привід для паніки).
2. Вміст builder stage у Gradle/Spring Boot
Коли ми говоримо «в builder stage мають жити Gradle і вихідні тексти», це звучить очевидно, але на практиці новачки часто плутаються: копіюють або занадто мало — і збирання падає, або занадто багато — і знову ламають кеш, тягнуть сміття та секрети. Тут корисно тримати дуже просту класифікацію: builder stage повинен уміти запустити Gradle Wrapper і побачити проєкт цілком так, щоб команда bootJar відпрацювала.
У проєкті docker-java-catalog-service типовий набір файлів, потрібних для збирання, виглядає так: Gradle wrapper (gradlew і папка gradle/), build scripts (build.gradle.kts, settings.gradle.kts, іноді gradle.properties) і, звичайно, вихідні тексти (src/). Ще туди може входити щось на кшталт src/main/resources/ (воно зазвичай усередині src, але я окремо наголошую: ресурси — частина застосунку; без них jar може зібратися, але потім дивно запускатися).
А ось що builder stage не повинен тягнути з контексту збирання як обовʼязкову частину «за замовчуванням»: build/ з вашої локальної машини, .idea/, .git/, локальні .env файли та будь-які артефакти, які не потрібні для компіляції. Якщо ви це копіюєте, то фактично повертаєтеся до стану «Dockerfile збирає не проєкт, а вашу конкретну робочу теку з усіма її тарганами».
Для наочності — невелика таблиця. Вона не замінює розуміння, але добре ловить момент, коли рука тягнеться зробити COPY . . «бо так простіше».
| Сутність проєкту | Потрібна в builder stage? | Чому |
|---|---|---|
| gradlew, gradle/ | так | Це ваш відтворюваний спосіб запустити Gradle без «а в мене інший Gradle». |
| build.gradle.kts, settings.gradle.kts | так | Без них Gradle не розуміє, що збирати і які залежності підтягувати. |
| src/ | так | Це ваш код і ресурси; без них bootJar або не збереться, або вийде порожня оболонка. |
| .dockerignore | не копіюємо всередину, але обов’язковий у репозиторії | Він впливає на контекст збирання: що взагалі потрапляє в Docker build. |
| build/ (локальна тека) | ні | Це результат роботи вашої машини; він не має бути джерелом істини для контейнерного збирання. |
| .git/, .idea/ | ні | Це не частина збирання артефакта. Для Docker це лише зайва вага і проблеми з кешем. |
3. Каркас builder stage
У цій точці хочеться написати «ну гаразд, давайте Dockerfile», але спочатку трохи сповільнимося: builder stage майже завжди починається з FROM від образу, який уміє компілювати Java. Для компіляції потрібен JDK, але сьогодні ми не робимо окрему лекцію про вибір базового образу, тому використовуємо нейтральну назву java-build-image. У репозиторії курсу буде зафіксований конкретний перевірений базовий варіант, але це вже окрема тема.
Ось мінімальний каркас builder stage, який уже дає нам правильну структуру думок:
FROM java-build-image AS builder
# Робоча тека для збирання всередині контейнера
WORKDIR /build
# Спочатку копіюємо wrapper і скрипти збирання — так краще працює кеш шарів
COPY gradlew build.gradle.kts settings.gradle.kts ./
# Копіюємо каталог Gradle Wrapper
COPY gradle gradle
# Копіюємо вихідні тексти застосунку
COPY src src
Тут поки немає RUN, бо ми лише «зібрали сцену»: поклали в контейнер усе, що потрібно для збирання. Зверніть увагу: ми не копіюємо весь проєкт цілком, а беремо лише потрібні частини. Це продовження дисципліни з дня про кеш: чим точніше ви копіюєте, тим менше «випадкових» причин зруйнувати кеш.
Якщо вам хочеться в цей момент написати COPY . . — це нормальне бажання. Воно приблизно з тієї самої категорії, що й «давайте зробимо один величезний клас, так швидше». Так, швидше… до першого рефакторингу. Ми вчимося робити Dockerfile, який не соромно показати колезі.
4. COPY і кеш у builder stage
Дуже часта помилка в голові: «Кеш — це про runtime-образ, а builder stage — тимчасовий, там неважливо». Насправді важливо. Builder stage хоч і не потрапить у фінальний runtime image, але впливає на дві речі: швидкість збирання і нервову систему людини, яка це збирання запускає (тобто вашу).
Docker кешує шари за кроками, і якщо ви спочатку копіюєте весь src, а потім запускаєте Gradle, то будь-яка правка в одному Java-файлі робить недійсним шар COPY src src, а отже — і наступний шар RUN ./gradlew .... Це очікувано: код змінився, потрібна компіляція. Але завантажувати залежності заново щоразу — уже неприємно. Тому builder stage часто будують так, щоб спочатку «закешувати» частину, повʼязану із залежностями, а потім уже додавати вихідні тексти.
Нижче — один із найзрозуміліших варіантів для навчального проєкту. Спочатку ми копіюємо wrapper і build scripts, потім виконуємо легку команду, яка прогріває розвʼязування залежностей (у простому проєкті це зазвичай працює), і лише після цього копіюємо вихідні тексти.
FROM java-build-image AS builder
WORKDIR /build
# Копіюємо лише те, що впливає на залежності та конфігурацію збирання
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle gradle
# Прогріваємо розвʼязування залежностей, щоб прискорити наступні збирання
RUN ./gradlew --no-daemon dependencies
А вже після цього додаємо код і збираємо bootJar:
# Тепер додаємо вихідні тексти: зміни тут робитимуть недійсними шари нижче
COPY src src
# Збираємо виконуваний jar (Spring Boot)
RUN ./gradlew --no-daemon bootJar
Тут є тонкість: задача dependencies не збирає застосунок, вона лише запускає конфігурацію Gradle і розвʼязує залежності. У більшості навчальних і багатьох реальних проєктів це дає відчутну користь: під час правки коду Gradle вже має кеш залежностей усередині шару образу, і повторне збирання не починається з «скачай половину інтернету ще раз».
Якщо у вашому проєкті dependencies з якихось причин падає (наприклад, збирання залежить від додаткових тек або buildSrc), не треба героїчно страждати. У такому разі можна спростити все й запускати лише bootJar після копіювання src. Builder stage все одно буде корисний як межа між build і runtime — просто кеш буде трохи менш ідеальним. Ми йдемо від простого й стійкого до більш оптимального, а не навпаки.
5. Gradle Wrapper у builder stage
Коли Gradle запускається локально, ми рідко думаємо про такі деталі, як «чи має файл gradlew право бути виконуваним». У Docker-збиранні ці речі раптово стають реальними. І це нормально: контейнер — чесніше середовище, він не вдає, що розуміє ваші наміри, він розуміє лише права доступу й команди.
Найканонічніший запуск збирання всередині builder stage для нашого Spring Boot сервісу виглядає так:
# Збираємо виконуваний jar всередині builder stage
RUN ./gradlew --no-daemon bootJar
Прапорець --no-daemon тут не обовʼязковий «щоб працювало», але він робить збирання більш передбачуваним у Docker. Gradle daemon корисний, коли ви багато разів запускаєте Gradle в одній і тій самій довгоживучій системі. Docker build — це серія коротких кроків, і daemon там не завжди дає користь, а іноді просто додає шуму. Тому в навчальному базовому варіанті я люблю --no-daemon: менше магії, більше відтворюваності.
Тепер про те, чому раптом не запускається gradlew. На Linux це зазвичай не проблема, але на Windows і в деяких сценаріях із git-налаштуваннями файл може прийти без executable bit або з CRLF. Тому типовий «страхувальний» крок, який робить Dockerfile більш переносимим між машинами, виглядає так:
# Копіюємо Gradle Wrapper
COPY gradlew ./
# Про всяк випадок додаємо право на виконання (часто рятує під час перенесення між ОС)
RUN chmod +x gradlew
Так, це ще один рядок. Зате ви економите години на пошуку загадки «чому в мене в контейнері Permission denied, а в сусіда працює». Dockerfile не має бути крихким.
6. Кінцевий jar і app.jar
Після ./gradlew bootJar у нас зʼявляється jar десь у build/libs/. І ось тут у новачків трапляється класична «дрібниця, яка ламає все»: імʼя файла зазвичай містить версію та/або суфікс SNAPSHOT. Сьогодні це docker-java-catalog-service-0.0.1-SNAPSHOT.jar, завтра ви змінили версію — і раптом наступний крок Dockerfile, який «копіює конкретне імʼя», перестав працювати.
Builder stage повинен видавати передбачуваний результат. Тому добра практика — після збирання привести jar до зрозумілого стабільного імені, наприклад app.jar. Це не про красу. Це про те, щоб наступний етап (runtime stage) міг забрати артефакт без вгадування.
Ось як це зазвичай виглядає:
# Збираємо виконуваний jar
RUN ./gradlew --no-daemon bootJar
# Нормалізуємо імʼя артефакта, щоб наступний stage не залежав від версії/SNAPSHOT
RUN cp build/libs/*.jar app.jar
Тут *.jar — свідоме спрощення лише для випадку, де після bootJar у вас у build/libs лежить один виконуваний jar. Якщо проєкт одночасно продукує ще й звичайний plain.jar, цей крок треба звузити до потрібного файла або вимкнути plain-jar, інакше wildcard перестає бути чесним.
Це навмисно просте рішення. Ми не намагаємося витягнути точне імʼя через bash-магію, не пишемо складні find з регулярними виразами. Ми просто кажемо: «У теці build/libs після bootJar лежить наш виконуваний jar, скопіюй його в app.jar».
Якщо вам не подобається *.jar, можна зробити суворіший варіант і копіювати за шаблоном імені проєкту, але тоді ви знову привʼязуєтеся до домовленості про імʼя. Для навчального проєкту це не завжди потрібно.
Іноді студенти запитують: «А чому ми не залишаємо jar як є і не копіюємо build/libs/... далі?» Можна, але тоді наступний крок (перенесення в runtime stage) стає менш читабельним: вам доведеться памʼятати точне імʼя або знову використовувати wildcard. Коли ви нормалізуєте jar у app.jar, ви робите Dockerfile самодокументованим: артефакт збирання — це app.jar, крапка.
7. Межі builder stage
Builder stage — місце, де допустимі тимчасові файли та кеші. Gradle створить свою папку .gradle, завантажить залежності в ~/.gradle, створить проміжні результати компіляції. Це нормально, бо builder stage не є фінальним образом, який ми запускатимемо в контейнері.
Але тут важливо не переплутати «допустимо» з «так і треба». Builder stage не повинен перетворюватися на «другий runtime». Ми не додаємо туди ENTRYPOINT, не запускаємо застосунок після збирання «просто перевірити», не починаємо налаштовувати змінні середовища для Spring-профілів. Усе це належить runtime stage і запуску контейнера, а не збиранню.
Ще одна типова спокуса — «а давайте після збирання підчистимо rm -rf половину тек». Це здебільшого безглуздо саме в builder stage. Чому? Бо builder stage і так не потрапить у фінальний runtime image. Якщо ви хочете зменшити фінальний образ — ви зменшуєте те, що копіюєте в runtime stage (а ми це зробимо в наступній лекції). Чистити builder stage заради розміру фінального образу — це як виносити сміття з будівельного вагончика, щоб квартира стала чистішою. Квартира стане чистішою не від цього, а від того, що ви не переносите вагончик у вітальню.
8. Типові помилки в builder stage
Помилка №1: builder stage намагається «і зібрати, і запустити».
Це виглядає так: ви додаєте ENTRYPOINT прямо в builder stage або запускаєте java -jar наприкінці першої стадії. У підсумку ви знову змішали build-time і runtime, тільки тепер у вас два FROM, а логіка все одно розмазана. Правильна картина простіша: builder stage завершується на появі app.jar і на цьому сходить зі сцени.
Помилка №2: ви забули Gradle Wrapper і сподіваєтеся на gradle «якось».
У контейнері «якось» зазвичай означає «ніяк». Якщо ви пишете RUN gradle bootJar, тоді Gradle має бути встановлений в образі. Це або ускладнює builder stage зайвим встановленням, або ламає відтворюваність. У навчальних і більшості робочих Java-проєктів найкращий базовий варіант — ./gradlew ..., бо wrapper фіксує версію Gradle і поведінку збирання.
Помилка №3: gradlew не виконується (Permission denied) або падає з ^M.
Ця помилка особливо часто трапляється у студентів на Windows. Симптоми зазвичай такі: Docker build падає на RUN ./gradlew ... з повідомленням, що файл не запускається. Лікується це не шаманством, а дисципліною: додати RUN chmod +x gradlew і стежити за кінцевими переведеннями рядків (CRLF у shell-скриптах — часте джерело /bin/sh^M: bad interpreter). Dockerfile має бути переносимим між машинами, інакше вся ідея «відтворюваності» перетворюється на мем.
Помилка №4: ви копіюєте весь проєкт (COPY . .) і тягнете в builder stage локальний build/.
Збирання може навіть пройти, але ви отримуєте два неприємні ефекти. По-перше, кеш буде ламатися занадто часто, бо «змінилося щось десь» — і Docker чесно перебудовує шар. По-друге, ви ризикуєте притягнути те, що не має брати участі в збиранні: локальні файли, тимчасові дані, іноді навіть секрети (наприклад, якщо хтось поклав .env поруч «на хвилинку»). Builder stage має бачити проєкт, а не ваш домашній безлад на робочому столі.
Помилка №5: ви не знаєте, де лежить кінцевий jar.
Це виглядає кумедно: збирання пройшло, але далі ви намагаєтеся копіювати файл, якого немає, бо шлях або імʼя не збіглися. Найспокійніший спосіб уникнути цього — нормалізувати вихід builder stage: після bootJar скопіювати результат у app.jar у відомому місці. Тоді наступна стадія не «вгадує», а працює за домовленістю.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ