1. Multi-stage: сенс і користь
Якщо ви вперше чуєте multi-stage, мозок може спробувати перекласти це як «ну добре, ще одна техніка оптимізації для тих, у кого занадто багато вільного часу». Насправді все значно простіше: multi-stage — це спосіб зробити Dockerfile читабельним і чесним, коли в нас є два різні процеси: «зібрати артефакт» і «запустити артефакт». І ці процеси вимагають різних речей.
Уявіть, що ви готуєте вечерю. Збирання — це кухня: там борошно, дошки, ножі, миски, брудний посуд, шум і хаос. Але цей хаос корисний. Runtime — це вже тарілка на столі: на ній має бути їжа, а не ополоник, упаковка від масла та інструкція «як увімкнути духовку». Single-stage Dockerfile — це спроба принести користувачеві і кухню, і тарілку в одному пакеті. Multi-stage — це спосіб залишити кухню на кухні, а на стіл принести лише результат.
У контексті нашого курсу це особливо важливо, тому що ми контейнеризуємо не «будь-який jar», а нормальний Spring Boot-сервіс (Container-Ready Catalog Service) на Java 25 і Gradle. У нього є зрозумілий результат збирання (bootJar) і зрозумілий спосіб запуску (java -jar ...). Питання лише в тому, де і як ці кроки мають жити в Dockerfile, щоб підсумковий образ був чистим і підтримуваним.
2. Стадія в Dockerfile
Слово stage (стадія) — це не філософія і не маркетинг. У Dockerfile це дуже конкретна річ: кожен новий FROM починає нову стадію збирання. Кожну стадію можна уявляти як міні-Dockerfile всередині великого Dockerfile: у неї є своя файлова система, свій ланцюжок інструкцій, свій WORKDIR, свої ENV і так далі. Це не «продовження попередньої стадії», а новий старт.
Наймінімальніший приклад виглядає так:
# Стадія 1: builder — тут живуть інструменти збирання та вихідні тексти
FROM java-build-image AS builder
WORKDIR /workspace
# тут пізніше з'явиться збирання (компіляція, складання jar)
# Стадія 2: runtime — тут залишається лише те, що потрібно для запуску
FROM java-run-image AS runtime
WORKDIR /app
# тут пізніше з'явиться запуск (копіювання jar та ENTRYPOINT)
Зараз у цьому прикладі майже немає конкретики — і це нормально. Нам поки що важливо зрозуміти механіку: два FROM означають дві стадії. Перша стадія може бути «брудною» (там відбувається збирання), друга — «чистою» (там відбувається запуск).
І тут часто виникає плутанина: «А хіба Docker не будує шари? Навіщо ще якісь стадії?». Docker справді будує layers (шари) всередині кожної стадії, тому що майже кожна інструкція (COPY, RUN тощо) створює новий шар. Але stage — це рівень вище: логічний блок, що складається з багатьох шарів і починається з нового базового образу (FROM).
Щоб зафіксувати різницю, зручно один раз поглянути на мінітаблицю:
| Термін | Що це | На що схоже | Де «живе» |
|---|---|---|---|
| Layer (шар) | результат однієї інструкції Dockerfile (наприклад, COPY, RUN) | один «крок» в історії | усередині однієї стадії |
| Stage (стадія) | окремий ланцюжок інструкцій, що починається з FROM | окремий «світ» / контекст | Dockerfile може містити кілька стадій |
Стадії потрібні не замість шарів, а для того, щоб розвести ролі: збирання і запуск, тести й runtime, підготовку артефакта та фінальне пакування.
3. Multi-stage build: кілька FROM
Термін multi-stage build означає просту річ: в одному Dockerfile використовується кілька стадій (тобто кілька FROM). Це все ще один файл, один сценарій збирання, один артефакт у репозиторії — просто структура стає дорослішою.
Важливий момент: Docker збирає стадії зверху вниз, але фінальним образом вважається результат останньої стадії Dockerfile. Тобто якщо у вас дві стадії, фінальним буде образ, описаний у другій; якщо три — фінальним буде третій. Це правило корисно запамʼятати просто зараз, щоб потім не дивуватися: «Чому я збираю Dockerfile, а отримую образ без Gradle та вихідних текстів?». Тому що ви свідомо зробили фінальною стадію, де їх немає.
З погляду команди збирання все виглядає так само звично:
# Збираємо фінальний образ: результатом буде остання стадія Dockerfile
docker build -t docker-java-catalog-service .
Команда не говорить «зібери мені builder stage» або «зібери мені runtime stage» (про такі варіанти буде пізніше, і ми зараз туди не ліземо). У стандартному режимі Docker іде по Dockerfile і наприкінці віддає вам образ, який відповідає останній стадії. Саме його ви потім запускаєте через docker run.
Ідея multi-stage, якщо сформулювати її однією фразою, така: ми можемо дозволити собі всередині Dockerfile «брудну» стадію, тому що вона не зобовʼязана потрапляти в підсумковий runtime-образ.
Це справді змінює мислення. У single-stage ви постійно відчуваєте себе «в одній кімнаті»: якщо ви додали щось для збирання, воно ніби назавжди залишилося у фінальному образі, і ви починаєте прибирати його костилями. У multi-stage ви починаєте думати так: «Зараз я в кімнаті збирання; мені потрібно зібрати jar; після цього я вийду і залишу цю кімнату, а користувачеві віддам іншу кімнату — кімнату запуску».
4. Межа build-time і runtime
Якщо ви памʼятатимете тільки команди (FROM, COPY, RUN) і не триматимете в голові межу між build-time і runtime, multi-stage дуже швидко перетвориться на «дві стадії заради двох стадій». Тому нам потрібно проговорити терміни по-людськи.
Build-time — це все, що потрібно, щоб отримати артефакт, який можна запустити. Для Java/Spring Boot це зазвичай означає: вихідні тексти, Gradle Wrapper, build scripts, завантаження залежностей, компіляція, збирання bootJar. Це «виробничий цех»: шумно, багато тимчасових файлів, інколи великий обсяг — і це нормально, тому що цех має не бути красивим, а має видати результат.
Runtime — це все, що потрібно, щоб просто запустити цей артефакт. У runtime-світі нам потрібні сам jar і зрозумілий запуск через java -jar. Усе інше там або зайве, або небезпечне, або просто погіршує супровід. Runtime — це як сцена в театрі: глядачеві не обовʼязково бачити, де лежать молотки і як будували декорації.
Щоб межа не залишалася абстрактною, давайте прикладемо її до реальних речей, які є в нашому репозиторії docker-java-catalog-service.
| Артефакт / сутність | Потрібен для build-time? | Потрібен для runtime? | Коментар простими словами |
|---|---|---|---|
| src/ (вихідні тексти Java) | так | ні | вихідні тексти потрібні, щоб зібрати jar, але для запуску jar вони не допомагають |
| gradlew, gradle/ | так | ні | wrapper — інструмент збирання, runtime-образу він не потрібен |
| build.gradle.kts, settings.gradle.kts | так | ні | без них збирання не злетить, але runtime-образу байдуже |
| build/ (тимчасовий output) | так (у процесі) | ні | у builder stage це нормально, у runtime stage — сміття |
| *.jar (результат bootJar) | так (як результат) | так | це міст між стадіями: те, що «народилося» під час збирання і потрібне на запуску |
| ENTRYPOINT ["java", "-jar", "..."] | ні | так | запуск — це вже відповідальність runtime |
Зверніть увагу, що тут ми не обговорюємо, який саме base image вибрати, і тему JDK vs JRE — це окрема розмова, і вона зʼявиться в правильний момент. Нам зараз важливо інше: у будь-якій ситуації ви маєте вміти поставити собі запитання: «Це потрібно, щоб зібрати артефакт, чи це потрібно, щоб запустити артефакт?». Якщо відповідь — «потрібно тільки для збирання», то цьому місце в builder stage, а не у фінальному runtime-образі.
5. Як Docker збирає стадії
На словах усе звучить красиво: «дві стадії, дві ролі». Але студент зазвичай починає довіряти темі, коли вона стає спостережуваною. Тому давайте проговоримо, що саме Docker робить під час збирання multi-stage Dockerfile, не заходячи в нетрі реалізації.
Коли Docker виконує першу стадію, він збирає проміжний результат. Можна думати про нього як про проміжний образ. Цей результат потрібен не користувачеві, а самому збиранню: там лежить усе, що допомогло зібрати артефакт. Потім починається наступна стадія з новим FROM, тобто з новою «чистою» файловою системою. І фінальний образ будується тільки з інструкцій останньої стадії, тому що саме вона вважається результатом збирання.
Схематично це зручно побачити так:
flowchart TD
A["Dockerfile"] --> B["Стадія 1: builder (build-time)"]
B --> C["Проміжний результат (не фінальний образ)"]
C --> D["Стадія 2: runtime (runtime)"]
D --> E["Фінальний образ (те, що ми запускаємо)"]
Що важливо в цій картинці: Stage 1 не «вливається» автоматично в Stage 2. Це не «шари зверху». Це окремі простори, і щоб перенести результат, пізніше ми будемо використовувати явний міст (так, той самий COPY --from, але деталі — в окремій лекції).
Для контролю себе (і щоб не вірити на слово) ви можете після збирання подивитися історію фінального образу:
# Дивимося історію шарів саме фінального образу (останньої стадії)
docker image history docker-java-catalog-service
У варіанті multi-stage ви зазвичай побачите, що фінальна історія стала «чистішою»: там менше кроків, повʼязаних із збиранням. Збирання відбувалося, але залишилося в builder stage і не стало частиною runtime-образу. Це і є головна магія multi-stage, тільки без магії: просто інший спосіб організувати файл.
6. Імена стадій (AS name)
Поки multi-stage залишається «дві секції з двома FROM», новачок часто думає: «Ну гаразд, я зрозумів, що FROM може бути двічі. Але як це допомагає мені?». Допомагає тим, що Dockerfile починає читатися як документ: у нього зʼявляються логічні блоки, і ви можете давати цим блокам імена. Це особливо важливо, коли файл росте і ви перестаєте памʼятати, що знаходиться в кожній стадії.
Іменована стадія задається так само просто, як і виглядає:
# Builder stage: тут «живе» збирання (вихідні тексти, Gradle, кеш залежностей)
FROM java-build-image AS builder
WORKDIR /workspace
# тут буде збирання
# Runtime stage: тут залишається лише результат збирання і команда запуску
FROM java-run-image AS runtime
WORKDIR /app
# тут буде запуск
Слово після AS — це просто мітка. Вона потрібна не Docker «для краси», а вам і вашій команді для ясності. Замість «перша стадія» і «друга стадія» ви отримуєте builder і runtime. І це одразу знижує когнітивне навантаження: читаючи файл, ви не згадуєте порядок, а розумієте роль.
Плюс, маленький спойлер рівно на один крок, без переходу до наступної лекції: коли ми переноситимемо результат збирання з builder у runtime, ми посилатимемося на стадію за імʼям. Це значно приємніше, ніж писати щось на кшталт «скопіюй із stage 0» або «зі stage 1» і потім плутатися, що де.
Якщо ви зараз думаєте: «Можна ж назвати stage1 і stage2», — технічно можна. Але тоді ви втрачаєте половину користі. Імена мають відображати роль, інакше вони перетворюються на шум. У нашому курсі канонічні імена прості: builder для збирання і runtime для запуску. Виглядає нудно, зате працює й не змушує майбутнього вас плакати в git blame.
7. Модель для Container-Ready Catalog Service
Зараз ми ще не пишемо повний multi-stage Dockerfile руками «до останньої крапки» — це буде підсумок дня. Але важливо, щоб ви вже могли повʼязати слова stage, build-time, runtime з нашим конкретним сервісом, а не з абстрактним «якимось застосунком».
Для Container-Ready Catalog Service модель дуже прямолінійна. У builder stage ми зрештою зберемо виконуваний bootJar — один jar Spring Boot-застосунку. У runtime stage ми візьмемо лише цей jar, покладемо його в робочий каталог і задамо ENTRYPOINT виду java -jar .... Тобто на запуску в нас залишиться тільки те, що реально потрібно застосунку, а не те, що колись було потрібно збиранню.
Якщо тримати це в голові, подальші лекції дня сприйматимуться не як «ще один рецепт Dockerfile», а як логічне заповнення двох заздалегідь зрозумілих коробок. У наступній лекції ми наповнимо коробку builder конкретними файлами та командою збирання. Після неї — наповненням коробки runtime і перенесенням результату.
8. Типові помилки під час роботи з multi-stage
Помилка № 1: плутати stage і layer.
Це дуже часта історія: студент вивчив, що Docker будує шари, і намагається думати, ніби стадія — це «просто ще один шар». У підсумку він очікує, що друга стадія автоматично успадковує файли першої, ніби це продовження тієї самої файлової системи. Правильна картинка інша: шари живуть усередині стадії, а стадія починається з нового FROM, тобто з нової бази та нового файлового світу.
Помилка № 2: вважати, що multi-stage — це кілька Dockerfile.
Іноді з назви здається, що multi-stage означає «кілька файлів». Особливо якщо ви бачили проєкти, де є Dockerfile і Dockerfile.build. У нашому курсі ми фіксуємо канонічний підхід: один Dockerfile, кілька FROM. Це зручніше для супроводу і простіше для читання, тому що вся історія пакування знаходиться в одному місці.
Помилка № 3: очікувати, що файли з builder stage доступні в runtime stage «самі по собі».
Логіка «я ж уже скопіював вихідні тексти, чому в другій стадії вони зникли?» — абсолютно нормальна для новачка. Але це і є головний сенс stage: новий FROM — новий старт. Якщо вам потрібно перенести результат збирання, це робиться явно. Інакше ви знову отримаєте той самий single-stage хаос, тільки поділений на дві частини на папері.
Помилка № 4: робити multi-stage Dockerfile, але продовжувати збирати і запускати в одній стадії «за звичкою».
Буває так: людина додала другий FROM, але в першій стадії все одно пише ENTRYPOINT і намагається запускати застосунок там само, де його збирає. У підсумку вона не отримує ні чистого образу, ні зрозумілої структури. Модель має бути суворою: builder stage закінчується артефактом, runtime stage починається з перенесення артефакта та його запуску.
Помилка № 5: давати стадіям імена на кшталт stage1, stage2 і потім губитися.
Технічно Docker зрозуміє будь-які імена (і навіть узагалі без імен обійдеться), але ви самі собі ускладнюєте життя. Імена стадій — це не формальність, а спосіб читати Dockerfile як документ. Для новачка це взагалі рятівне коло: builder і runtime одразу підказують, що відбувається, навіть якщо ви забули половину деталей.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ