JavaRush /Курси /Docker for Spring /JDK-, runtime- і debug-friendly образи

JDK-, runtime- і debug-friendly образи

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

1. «Один jar, три ролі»: навіщо образам різні ролі

Якщо ви тільки починаєте, дуже хочеться мати один «найправильніший» образ і більше ніколи про нього не думати. Розумію. Мозок любить стабільність, а Docker іноді поводиться як кіт: поки дивитеся — усе нормально, відвернулися — щось упало. Але в реальному Java-застосунку в Docker є різні завдання: зібрати артефакт, запустити його, а іноді й розібратися з проблемою. І під кожне завдання зручніше мати «образ за роллю», а не «образ за настроєм».

У multi-stage Dockerfile ролі особливо помітні. Builder stage — це «цех», де гамірно, пилюка, інструменти розкладені всюди, і взагалі туди в капцях не заходять. Runtime stage — це «вітрина», куди ми віддаємо лише те, що потрібно користувачу: готовий jar і мінімально достатнє середовище. А debug-friendly образ — це «аптечка і набір викруток»: він може бути важчим, зате допомагає, коли щось пішло не так і вам потрібно зрозуміти чому, а не просто «перезібрати й сподіватися».

Щоб мозок не сприймав це як магію, зручно тримати в голові просту картинку:

flowchart TD
    A[Вхідні файли проєкту + Gradle] --> B[Етап збірки]
    B -->|bootJar| C[Готовий app.jar]
    C -->|COPY --from| D[Етап запуску]
    D --> E[Запущений контейнер]
    B --> F[Debug-friendly шлях за потреби]

Важливо: ми не змінюємо код Container-Ready Catalog Service через те, який образ обрали. Ми змінюємо лише те, «в яких черевиках» цей код ходить. А черевики бувають будівельні, повсякденні та «на випадок дощу».

2. JDK-образ: збирання bootJar

Коли йдеться про JDK-образ, багато новачків думають так: «JDK більше → значить “краще” → значить треба всюди». Це приблизно як тримати на кухні пожежну сокиру на випадок, якщо не вдасться нарізати огірок. Іноді сокира справді корисна, особливо якщо огірок — це legacy-код, але в повсякденному житті вона заважає. У JDK-образу є своя роль: збирання та розширена діагностика.

JDK-образ (у контейнерному сенсі) зазвичай містить повноцінний Java toolchain. Там є java, а також часто javac (компілятор) і утиліти, які допомагають дивитися всередину JVM. І ось це — ключовий момент: builder stage справді компілює та збирає артефакт, отже він логічно живе саме на JDK-образі. Ба більше, builder stage може бути «товстим» — він однаково не потрапить у фінальний runtime image, якщо ви правильно робите multi-stage.

Нижче — навмисно спрощений фрагмент builder stage: зараз нам важливо побачити саме роль JDK-образу. Кеш-дружній порядок COPY та інші деталі компонування базового проєкту залишаються тими самими, що й раніше.

Нижче — мініфрагмент builder stage, максимально схожий на те, що у вас уже є після дня, присвяченого multi-stage. Я використовую eclipse-temurin як зрозумілий приклад; у вашому репозиторії постачальник може відрізнятися, але зміст не змінюється.


# syntax=docker/dockerfile:1

# Етап збірки: тут ми саме збираємо артефакт, тому потрібен JDK
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace

# Копіюємо Gradle-обгортку та вихідні файли (спрощено, без оптимізацій кешу)
COPY gradlew build.gradle.kts settings.gradle.kts ./
COPY gradle gradle
COPY src src

# Збираємо bootJar всередині контейнера
RUN ./gradlew --no-daemon bootJar

Зверніть увагу: ми взагалі не обговорюємо зараз «як прискорити Gradle» або «як правильно розкладати COPY для кешу» — це ви вже проходили раніше. Тут головна думка інша: збірка потребує інструментів, а JDK-образ — природне місце для них. Ви ніби говорите Docker: «У цьому stage ми працюємо як збірний цех, нам потрібен увесь набір».

Перевірити наочно, що ви справді в JDK-світі, можна простою командою: якщо образ містить javac, значить це точно не «runtime-only».


# Перевіряємо, що в образі є компілятор (ознака JDK)
docker run --rm eclipse-temurin:25-jdk javac -version
# javac 25

Так, це виглядає занадто просто — і саме тому добре: менше шаманства, більше спостережуваності.

3. Runtime-образ: фінальний stage для запуску

У runtime stage наша мета нудна й прекрасна: запустити вже зібраний jar. Не компілювати, не збирати, не «гратися з Gradle», а просто виконати java -jar. І щойно ви це приймаєте, стає очевидно: фінальному образу найчастіше не потрібен увесь JDK. Йому потрібен Java runtime (JRE-like/runtime image) і мінімум системного середовища, щоб JVM могла працювати нормально.

Чому це важливо, окрім естетики? Тому що фінальний образ — це те, що ви реально переноситимете між машинами, надсилатимете, завантажуватимете, зберігатимете й оновлюватимете. Він впливає на швидкість «підняти сервіс», на розмір локального середовища і, якщо говорити зовсім приземлено, просто економить вам час і місце на диску. А ще: чим менше в образі зайвого, тим менше «випадкових деталей», які можуть виявитися діркою, конфліктом або джерелом непередбачуваності.

З погляду Dockerfile runtime stage виглядає майже банально — і це добрий знак. Банальність тут означає «зрозуміло навіть у понеділок зранку без кави»:


# Фінальний runtime stage: лише запуск, без інструментів збирання
FROM eclipse-temurin:25-jre
WORKDIR /app

# Забираємо готовий jar з builder stage
COPY --from=builder /workspace/build/libs/*.jar app.jar

# Запускаємо застосунок
ENTRYPOINT ["java", "-jar", "app.jar"]

Якщо ви спробуєте виконати в такому контейнері javac, з великою ймовірністю отримаєте помилку (і це нормально):


# У runtime-образі компілятора зазвичай немає — і це очікувано
docker run --rm eclipse-temurin:25-jre javac -version
# exec: "javac": executable file not found in $PATH

Для новачка це звучить як «ой, чогось не вистачає», але для інженера це звучить як «чудово, я точно не тягну в runtime те, що йому не потрібно». Runtime-образ — саме про це: мінімально достатнє.

Ще один важливий момент: версії мають збігатися за змістом. Якщо ви збираєте bootJar на Java 25 і запускаєте на Java 25 runtime — ви живете спокійно. Якщо ви зібрали на 25, а запустили на 21, то іноді все працюватиме, іноді — ні, а іноді все буде працювати доти, доки ви не додасте одну залежність. У цьому курсі baseline фіксований (Java 25), і це не «суворість заради суворості», а спосіб не влаштовувати собі квест «вгадай несумісність».

4. Debug-friendly образ: інструмент для діагностики

Зараз може виникнути небезпечна думка: «Гаразд, runtime-образ маленький і гарний, але як же мені дебажити, якщо щось зламається? Може, простіше завжди використовувати JDK у фінальному stage і не мучитися?». Це як носити в рюкзаку домкрат щодня лише тому, що колись у вас спустило колесо. Домкрат корисний, але тягати його постійно — сумнівне задоволення. У Docker-світі саме в цьому і полягає сенс debug-friendly образу: він потрібен не завжди, а тоді, коли ви свідомо хочете зручніше подивитися, що відбувається.

Debug-friendly образ — це не окрема релігія і не обов’язковий стандарт. Це домовленість: «у цьому варіанті ми готові заплатити розміром і “шириною” образу, щоб отримати більше інструментів для діагностики». Часто найпростіший debug-friendly варіант для Java — це якраз запуск застосунку на JDK-образі (або тимчасова заміна runtime base image на JDK), тому що там є додаткові JVM-утиліти. Так, це «товстіше», але в момент розслідування проблеми товщина — не головний KPI.

Мініідея виглядає так: Dockerfile залишається multi-stage, але runtime stage у debug-friendly варіанті отримує JDK замість runtime-only. Це легко показати навіть найкоротшим фрагментом:


# Debug-friendly runtime: тимчасово використовуємо JDK заради інструментів
FROM eclipse-temurin:25-jdk
WORKDIR /app

# За вмістом це той самий runtime stage: jar уже зібрано в builder
COPY --from=builder /workspace/build/libs/*.jar app.jar

# Запуск той самий — відмінність саме в базовому образі та доступних утилітах
ENTRYPOINT ["java", "-jar", "app.jar"]

Зверніть увагу: ми не додавали сюди «мільйон пакетів», не вмикали окремі режими, не перетворювали образ на швейцарський ніж. Ми просто чесно сказали: «Зараз мені важливіше мати інструменти, ніж мінімальний runtime». Це корисно, наприклад, коли ви хочете швидко перевірити щось усередині JVM-світу або хоча б мати більше стандартних утиліт під рукою.

Важлива дисципліна тут дуже проста: debug-friendly образ не повинен непомітно стати вашим «дефолтом назавжди». Він добрий як інструмент розслідування, як тимчасова заміна на час діагностики, як спосіб відтворити проблему в зрозумілому середовищі. Але якщо ви щодня запускаєте «debug-friendly як базовий варіант», то це вже не debug-friendly, а просто змішані ролі.

На цьому місці вже видно, що без спільного baseline проєкт швидко скотиться в ситуацію «у кожного свій улюблений образ». Отже, далі потрібне не ще одне локальне рішення, а одне правило для всього проєкту.

Таблиця: JDK vs runtime vs debug-friendly

Дуже легко загубитися в словах і почати сперечатися про смаки. Щоб цього не було, давайте порівняємо три ролі в максимально приземленій таблиці. Вона не про «хто крутіший», а про «хто для чого».

Роль образу Що це зазвичай означає Типове середовище всередині Де в Dockerfile живе Що ви отримуєте За що платите
JDK-образ Повний toolchain для Java java, часто javac, JVM-утиліти У builder stage Можна збирати bootJar всередині Docker, простіша діагностика в процесі збирання Розмір і «ширина» середовища (але це часто неважливо, бо stage одноразовий)
Runtime-образ (JRE-like) Лише те, що потрібно для запуску java (зазвичай без компілятора) У final runtime stage Менший образ, швидше переносити, менше зайвого Менше інструментів «на місці», діагностика іноді складніша
Debug-friendly образ Свідомо «більший», заради зручності Часто JDK як runtime або образ із додатковими утилітами Використовується тимчасово або як окрема стратегія Простіше розслідувати проблеми, більше інструментів у контейнері Розмір, потенційно більше «зайвих» компонентів, не можна робити тихим базовим варіантом

Як цим користуватися в житті? Дуже просто: перед тим як писати FROM у конкретному stage, поставте собі одне запитання. У цьому stage ми будуємо артефакт, запускаємо артефакт чи розбираємо проблему? Якщо ви чесно відповіли, вибір типу образу стає майже механічним. І це, чесно кажучи, одне з найприємніших відчуттів у Docker: коли замість «чаклунства» з’являється «інженерна звичка».

5. Container-Ready Catalog Service: перевіряємо ролі образів

Зараз ми зробимо невелику перевірку, яка сильно підвищує впевненість новачка: побачимо, що різні образи справді відрізняються за роллю, а не лише назвою. Це не про написання нового коду, а про спостережуваність. Наш сервіс залишається тим самим Spring Boot застосунком, але середовище навколо нього змінюється — і ми повинні вміти це побачити.

Щоб не змішувати роль образу з улаштуванням усього Dockerfile, нижче — схематичний multi-stage фрагмент лише для перевірки ролей. Він не замінює вже зібрану базову конфігурацію проєкту і не перевизначає порядок COPY.

Припустімо, ваш Dockerfile після дня, присвяченого multi-stage, виглядає приблизно так (спрощено, щоб фокус був на образах, а не на оптимізаціях):


# Stage 1: збірка (потрібні JDK і Gradle)
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /workspace

# Копіюємо проєкт і збираємо jar (спрощено, без деталізації cache-friendly layout)
COPY . .
RUN ./gradlew --no-daemon bootJar

# Stage 2: запуск (достатньо runtime)
FROM eclipse-temurin:25-jre
WORKDIR /app

# Забираємо результат збирання з першого stage
COPY --from=builder /workspace/build/libs/*.jar app.jar

# Запускаємо jar
ENTRYPOINT ["java", "-jar", "app.jar"]

Тепер два швидкі «тести здорового глузду».

Перший тест: runtime-образ запускає застосунок і показує версію Java. Це корисно, щоб переконатися, що ви справді на Java 25, а не на «незрозуміло чому».


# Збираємо фінальний runtime-образ
docker build -t catalog-service:runtime .

# Перевіряємо версію Java вже всередині фінального контейнера
docker run --rm catalog-service:runtime java -version
# openjdk version "25" ...

Другий тест: runtime-образ не зобов’язаний містити компілятор. Якщо javac відсутній, значить фінальний stage не тягне JDK «за звичкою».


# Перевіряємо, що в runtime-образі немає javac (і це норма)
docker run --rm catalog-service:runtime javac -version
# exec: "javac": executable file not found in $PATH

Якщо ви хочете тимчасово отримати debug-friendly варіант — наприклад, щоб порівняти, що взагалі є всередині, — можна дуже просто замінити FROM eclipse-temurin:25-jre на FROM eclipse-temurin:25-jdk у runtime stage, зібрати ще один образ і подивитися різницю тією самою командою.

Так, це виглядає як «замінив один рядок», але методично це важливо: ви бачите, як роль виражається в дуже конкретному, спостережуваному факті. Це і є та дисципліна, яку ми закріплюємо сьогодні: не сперечатися словами, а перевіряти середовище руками за допомогою маленьких спостережуваних дій.

6. Типові помилки під час вибору образів

Помилка № 1: «Раз воно запускається на JDK, значить JDK — ідеальний runtime».
Це найчастіший логічний стрибок. Запуск на JDK справді спрацьовує і створює хибне відчуття «універсально правильного» рішення. Але multi-stage існує саме для того, щоб runtime stage виражав роль запуску, а не роль збирання. Якщо ви тримаєте JDK у фінальному образі без причини, ви просто змішали ролі.

Помилка № 2: «Спробую зібрати проєкт у runtime-образі, адже там теж є java».
На перший погляд здається, що раз є java, то Gradle теж якось упорається. На практиці ви впираєтеся у відсутність інструментів і в непередбачувані помилки збирання. Builder stage — місце, де інструменти збирання доречні, runtime stage — місце, де вони не потрібні й часто шкідливі.

Помилка № 3: очікувати, що в runtime-образі завжди буде «нормальна консоль» і всі звичні команди.
Деякі образи мінімальні: у них може не бути звичних утиліт, а іноді — навіть shell у звичному вигляді. Для новачка це виглядає як «контейнер зламаний». Насправді це просто ціна мінімальності. Якщо вам потрібно спокійно розслідувати проблему, це якраз привід тимчасово перейти на debug-friendly варіант.

Помилка № 4: плутати «debug-friendly» із «так треба завжди».
Debug-friendly шлях добрий, коли у вас є конкретна мета: зрозуміти проблему. Але якщо він стає вічним базовим варіантом, ви втрачаєте сенс розділення ролей і перетворюєте проєкт на набір компромісів «про всяк випадок». Наступного разу вам буде важче пояснити, що саме у вас вважається baseline і чому.

Помилка № 5: зібрати в builder Java 25, а в runtime випадково запустити на іншій версії.
Іноді це трапляється непомітно, особливо якщо ви змінюєте base image «в пошуках кращого» і паралельно експериментуєте з тегами. Симптоми можуть бути дивними й неочевидними. Тому сьогодні ми тримаємо фокус на ролі образу та на узгодженості версій: runtime має бути сумісним із тим, як ви зібрали артефакт.

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