1. FROM у final stage — це ваша «операційна система за замовчуванням»
Коли людина вперше пише Dockerfile, рядок FROM ... сприймається як формальність: «ну треба ж із чогось почати». Але насправді FROM для фінального (runtime) stage — це вибір середовища, у якому ваш застосунок житиме щодня. Це майже як вибирати ОС для сервера, тільки наслідки видно швидше.
У multi-stage Dockerfile у нас є щонайменше два світи: builder stage, де ми збираємо артефакт, і runtime stage, де цей артефакт запускаємо. У builder stage ми частіше миримося з «важкими» речами (Gradle, додаткові утиліти), бо це тимчасова кухня. А runtime stage — це вже «подача страви»: саме він стає вашим фінальним образом, який ви запускатимете знову й знову.
Щоб не плутатися в термінах, зафіксуємо коротку карту понять прямо тут, без спроби перетворити лекцію на словник Dockerʼа:
| Термін | Просте пояснення | Де трапляється |
|---|---|---|
| base image | База, від якої починається stage; «що вже є у файловій системі та середовищі» | FROM ... |
| stage | Логічна частина Dockerfile, що починається з FROM | FROM ... AS builder / |
| runtime stage | Фінальний stage, який реально запускається як контейнер | останній FROM |
| runtime image | Образ, розрахований насамперед на запуск уже зібраного застосунку, а не на збирання | зазвичай використовується в runtime stage |
Тут важливо одне: у runtime stage ми вибираємо base image не тому, що «так заведено», а тому, що він задає ключові властивості середовища. І ці властивості потім проявляються дуже практично: в когось застосунок раптом не працює з HTTPS, у когось «пливе» час у логах, у когось виникають дивні помилки нативних бібліотек. І це не містика, а наслідки вибраної бази.
2. Що змінюється під час зміни base image
Дуже хочеться думати так: «У мене ж один і той самий app.jar, отже і застосунок той самий». І це майже правда, але з важливою поправкою: код не змінюється, а середовище — так. Саме воно може впливати сильніше, ніж очікує новачок, особливо коли застосунок перестає бути “Hello World” і починає робити бодай щось схоже на реальний backend.
Спершу про те, що не змінюється. Ваш Java-код, ваші контролери, ваш сервісний шар — усе це залишається тим самим. Наприклад, CatalogApplication як був, так і залишається звичайним Boot-застосунком:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApplication {
public static void main(String[] args) {
// Точка входу застосунку: саме звідси Spring Boot створює контекст і запускає сервер.
SpringApplication.run(CatalogApplication.class, args);
}
}
Тепер про те, що змінюється. Змінюється все, що оточує java -jar: яка саме JVM усередині образу, які системні бібліотеки поруч, чи є кореневі сертифікати, яка часовa зона за замовчуванням, що відбувається з кодуваннями, чи є мінімальні утиліти для діагностики. І найчастіше це проявляється у вигляді: «чому в одному образі все нормально, а в іншому ніби щось не так».
Цю ідею зручно бачити як схему: один і той самий артефакт, різні бази — і ви отримуєте різні контейнерні світи:
flowchart TD
%% Один і той самий артефакт, зібраний у builder stage
JAR["bootJar: catalog-service.jar
(один і той самий)"]
%% Різні base image можуть відрізнятися ОС, пакетами й налаштуваннями середовища
B1["Базовий образ A
Java 25 runtime
один набір пакетів ОС"]
B2["Базовий образ B
Java 25 runtime
інший набір пакетів ОС"]
%% Підсумок — різна поведінка вже на етапі запуску контейнера
C1["Контейнер A
поведінка, логи, мережеві нюанси"]
C2["Контейнер B
поведінка, логи, мережеві нюанси"]
JAR --> B1 --> C1
JAR --> B2 --> C2
Ключовий висновок на сьогодні звучить просто: «запустилося» — це лише перший фільтр. Нам потрібно вибрати base image так, щоб він був розумним варіантом за замовчуванням для проєкту, а не випадковою удачею конкретної машини.
3. Критерії вибору runtime base image
Якщо підійти до теми чесно, критеріїв може бути десятки, і половина з них звучатиме як «релігійна війна дистрибутивів». Ми так робити не будемо. Нам потрібен набір критеріїв, який справді допомагає Junior-розробнику ухвалювати рішення і потім пояснювати його команді без фрази «ну я так відчуваю».
Почнімо з головної ідеї: runtime base image у нашому курсі потрібен, щоб стабільно запускати Spring Boot 4.0.3 сервіс на Java 25. Не збирати, не компілювати, не «гратися в DevOps», а саме запускати. Тому критерії — практичні, приземлені й перевірні.
Нижче — матриця, яка допомагає думати не «про розмір», а «про придатність»:
| Критерій | Чому це важливо для Spring Boot сервісу | Як проявляється, якщо помилилися |
|---|---|---|
| Сумісність із Java 25 | У runtime stage має бути коректний java потрібної версії; інакше ви або не запуститеся, або отримаєте дивні несумісності | UnsupportedClassVersionError, неочікувані падіння, «у мене локально працювало» |
| Адекватна OS-база та системні залежності | Boot-сервіс — це не лише байткод: це TLS, DNS, файлова система, іноді нативні бібліотеки | Помилки SSL, дивності з мережею, проблеми з нативними бібліотеками |
| Наявність CA certificates | Навіть якщо ваш сервіс зараз не звертається до зовнішніх API, завтра він може почати це робити; сертифікати — базова річ для HTTPS | SSLHandshakeException, “PKIX path building failed” |
| Передбачуваність і супровідність | Вам потрібно, щоб образ був зрозумілим для команди і не перетворювався на «чорну скриньку» | Складно дебажити, складно пояснювати, складно повторити результат |
| Баланс мінімальності та діагностованості | Надто «голий» образ може економити мегабайти, але з’їдати години життя під час будь-якої проблеми | «Не можна зайти всередину», «немає навіть базових утиліт», «усе навмання» |
| Розмір | Розмір впливає на швидкість pull/push і на роботу з кешем, але це не єдина метрика | Ви виграєте 40 МБ і програєте 4 години розслідування |
Зверніть увагу, що в цій таблиці «розмір» стоїть останнім. Не тому, що він неважливий, а тому, що розмір — це метрика, а придатність — це властивість. Метрика без властивості легко перетворюється на спорт: «мій образ на 20 МБ менший, отже я переміг». А потім виявляється, що перемога була над здоровим глуздом.
Сумісність із Java 25
Коли ви вибираєте runtime base image, перше, що треба запитати: «яка там Java і чи саме та?». Звучить очевидно, але це як із парасолею: доки не почався дощ, здається, що вона не потрібна. А потім ви йдете мокрі й філософськи розумієте, що очевидні речі були не такими вже й очевидними.
Мінімальний тест сумісності — не запуск застосунку, а перевірка, що Java справді потрібної лінії:
# Перевіряємо, що всередині образу справді Java 25, а не щось схоже.
docker run --rm your-runtime-image java -version
І ви маєте побачити зрозумілий вивід про 25-ту лінію. Це важливо саме для курсу з фіксованим baseline: ми свідомо тримаємо Java 25 як єдину опорну версію, щоб не роз’їжджалося середовище студентів і приклади.
OS-база та системні «дрібниці»
Друга велика група критеріїв — це все, що зазвичай називають «та це ж просто Linux усередині». На практиці ця фраза часто означає: «Я не знаю, що там усередині, але сподіваюся, що все нормально». Docker, звісно, багато що спрощує, але він не скасовує реальності: Java-застосунок живе в середовищі, де є DNS, сертифікати, файлова система, часовий пояс, іноді системні бібліотеки.
Найчастіший «контейнерний сюрприз» для Java-розробника — TLS. Наприклад, ви робите вихідний HTTPS-запит, навіть банально до якогось внутрішнього сервісу, і раптом ловите помилку. Для демонстрації достатньо крихітного Java-прикладу, який просто звертається до HTTPS:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
public class HttpsPing {
public static void main(String[] args) throws Exception {
// Клієнт спирається на TLS/SSL-налаштування та сертифікати, які є в образі.
var client = HttpClient.newHttpClient();
// Простий HTTPS-запит: якщо в образі немає CA certificates, тут легко зловити SSLHandshakeException.
var request = HttpRequest.newBuilder(URI.create("https://example.com"))
.GET()
.build();
// Надсилаємо запит; тіло нам не потрібне — важливий сам факт успішного TLS-рукостискання.
client.send(request, java.net.http.HttpResponse.BodyHandlers.discarding());
System.out.println("OK"); // OK (якщо сертифікати в порядку)
}
}
Якщо в образі немає нормального набору CA certificates, ви не отримуєте «якусь дрібну проблему», ви отримуєте ситуацію «сервіс не може спілкуватися через HTTPS». І це вже не про красу Dockerfile, а про працездатність.
Діагностованість: щоб «мінімальний» не став «непідтримуваним»
Є одна болюча пастка: можна вибрати образ настільки мінімальний, що він почне заважати вам самим. У теорії «мінімально = добре», але в реальному житті іноді треба хоча б зрозуміти, що відбувається. Не щоб жити в контейнері, як у повноцінній VM — цього ми не робимо, — а щоб мати змогу швидко локалізувати проблему.
Тут корисно тримати в голові просте правило: runtime base image має бути мінімальним рівно настільки, щоб залишатися керованим. Якщо ви вибираєте образ, у якому немає взагалі нічого, крім java, ви виграєте в розмірі, але програєте у супроводі. І ось тут особливо легко стати героєм анекдоту: «контейнер не працює, але зате маленький».
4. Пастка «найменшого» образу
Тема «найменшого» образу зазвичай спливає дуже рано, тому що docker images показує розмір, і мозок радісно вмикає режим змагання. Це як крокомір: спочатку корисний інструмент, а потім — джерело дивних рішень («я пройду кола по кухні, щоб добити до 10k»). З образами так само: можна почати оптимізувати те, що ще не стало проблемою.
У Java-світі є кілька популярних класів «ультрамінімальних» рішень: дуже slim-варіанти дистрибутивів, Alpine-подібні бази, distroless-образи. Усі вони мають право на життя, але погано підходять як перший усвідомлений default для новачка. Не тому, що вони «погані», а тому, що вимагають розуміння компромісів.
Найчастіші проблеми під час погоні за мінімальністю виглядають так. Ви раптом виявляєте, що в образі немає якихось базових речей на кшталт сертифікатів, timezone data або звичних утиліт. Потім приходить наступне відкриття: деякі мінімальні бази використовують іншу стандартну бібліотеку C, наприклад musl замість glibc, і це може спливти через залежності або нативні частини. І нарешті, ви розумієте, що «зменшив розмір» ви за 5 хвилин, а «пояснити команді, чому воно зламалося» не можете за 5 годин.
Важливо: в межах цієї лекції ми не обираємо переможця серед дистрибутивів. Ми фіксуємо думку: мінімальність — це не самоціль, а оптимізація, яка має сенс лише після того, як ви забезпечили працездатність і передбачуваність.
5. Runtime base image у multi-stage Dockerfile
Давайте акуратно прив’яжемо теорію до нашого наскрізного сервісу Container-Ready Catalog Service. Після дня про multi-stage у нас уже є Dockerfile, де збирання відбувається в builder stage, а фінальний образ — це runtime stage. І саме в runtime stage живе рядок FROM ..., який визначає вашу базу для запуску.
Щоб не змішувати вибір base image із уже зібраним cache-friendly baseline, нижче буде спрощений multi-stage фрагмент. Порядок COPY, layout builder stage та інші оптимізації залишаються тими, що вже закріплені в проєкті; тут нам важливе лише місце, де вибирається база для runtime stage.
Схематичний builder stage виглядає так (тут база зазвичай «JDK-образ», тому що ми реально збираємо jar усередині контейнера):
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /src
# Спрощено копіюємо проєкт цілком: у цьому фрагменті нам важлива роль builder stage, а не cache-friendly порядок COPY.
COPY . .
# Збираємо jar усередині builder stage. Тут допустимі "важкі" інструменти, тому що це тимчасова кухня.
RUN ./gradlew --no-daemon bootJar
Зверніть увагу: це не «фінальне пакування», а кухня. Builder stage може бути товстішим, і це нормально — він не потрапить у final image, якщо ви все зробили правильно.
А ось runtime stage — це те, що ви реально запускатимете:
FROM eclipse-temurin:25-jre
WORKDIR /app
# Забираємо лише результат збирання з builder stage — без Gradle та вихідних кодів.
COPY --from=builder /src/build/libs/*.jar app.jar
# У runtime stage нам потрібні лише JVM і наш jar.
ENTRYPOINT ["java", "-jar", "app.jar"]
Тут ім’я образу поки що слугує лише прикладом. І сам фрагмент вище — не новий canonical Dockerfile проєкту, а ізоляція одного рішення: base image вибирається саме у final stage і має бути усвідомленим runtime default.
І ось тут корисно подумки розділити два питання, які новачки часто змішують. Перше питання — «чи запускається?». Друге питання — «чи є це хорошим default?». У контейнерах ці два питання іноді розходяться: образ може запускатися, але бути надто важким, надто крихким, надто непередбачуваним або надто незручним для підтримки.
6. Типові помилки під час вибору runtime base image
Перш ніж рухатися далі за матеріалом дня, корисно спіймати себе на типових автоматичних рішеннях. Вони зазвичай виглядають логічно «за відчуттями», але потім перетворюються на джерело хаосу, особливо коли проєкт опиняється на ноутбуках у різних людей і починає жити довше за пару днів.
Помилка № 1: вибирати образ лише за розміром.
Розмір — це видима метрика, тому мозок чіпляється за неї першим. Але якщо ви вибираєте base image лише за принципом «найменший», ви по суті робите ставку на те, що вам ніколи не знадобляться ані нормальна діагностика, ані передбачувані системні залежності. У реальності це рідко окуповується на початку проєкту: економія мегабайт часто перетворюється на втрату годин.
Помилка № 2: вважати, що «якщо запускається — значить вибрано правильно».
Контейнер міг стартувати, показати красивий банер Spring Boot і навіть відповісти на один endpoint. Це ще не гарантія, що база підходить як default. Щойно з’являться HTTPS, взаємодія з файловою системою, різні локальні середовища або просто трохи складніша експлуатація, слабкі місця base image почнуть проявлятися. Краще заздалегідь мислити критеріями, а не сподіваннями.
Помилка № 3: змінювати одразу все: і Java, і vendor образу, і базовий дистрибутив.
Коли ви одночасно змінюєте кілька параметрів, ви втрачаєте причинно-наслідковий зв’язок. Якщо після зміни образу щось зламалося, ви не знаєте, що саме винне: інша Java, інший Linux, інші пакети чи інші налаштування. Для нормальної інженерії змінюють по одному параметру й перевіряють ефект.
Помилка № 4: плутати роль builder stage і runtime stage.
Дуже часта звичка — взяти один і той самий образ усюди, бо «так простіше». Але multi-stage саме й був потрібен, щоб розділити ролі. Збирання вимагає одного набору інструментів, запуск — іншого. Якщо ви в runtime stage тягнете все підряд «про всяк випадок», ви поступово повертаєтеся до single-stage за змістом, тільки менш чесного.
Помилка № 5: не формулювати критерії словами і не залишати слід у репозиторії.
Поки рішення живе лише в голові автора Dockerfile, воно недовговічне. Через місяць хтось змінить FROM, бо «знайшов менший варіант», і ви отримаєте дрейф середовища. Навіть коротке людське пояснення поруч із FROM («чому вибрано саме цей тип runtime base image») робить проєкт дорослішим і спокійнішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ