1. Порівняння Dockerfile і buildpacks: що змінюється
Коли вже видно, що buildpacks теж дають багатошаровий і придатний до інспектування образ, порівняння з ручним шляхом стає чесним. До цього моменту ручний шлях у нас уже не зводиться до наївного «скопіював jar і запустив». Це нормальна базова схема: multi-stage збірка, багатошаровість, продуманий runtime-образ. Тепер можна спокійно порівняти її з buildpacks без ідеології та без суперечок про те, хто краще виглядає в README.
Головна думка: і Dockerfile, і buildpacks приводять до одного й того самого типу результату — контейнерного образу, який можна запустити через docker run.... Різниця не в тому, що один шлях «справжній», а інший «несправжній». Різниця в тому, де саме виражене керування і хто ухвалює рішення.
Для орієнтиру можна тримати в голові просту схему:
flowchart LR
A[Один і той самий Spring Boot-застосунок] --> B1[Dockerfile: явні кроки]
A --> B2[Buildpacks: стандартизовані кроки]
B1 --> C[OCI/Docker-образ]
B2 --> C
C --> D[Контейнер: docker run]
2. Де живе керування: Dockerfile і buildpacks
У ручному шляху керування зосереджене в поточній базовій версії Dockerfile проєкту. Важливо не скочуватися до наївного варіанта «скопіював один jar і запустив». Порівнюємо саме з тією базовою ручною схемою, яку вже побудували: multi-stage-збірка, layered Boot jar і runtime-образ під Java 25.
Нижче не новий альтернативний Dockerfile, а лише короткий фрагмент того самого ручного шляху — просто щоб побачити, де саме в ньому живе керування:
FROM eclipse-temurin:25-jdk AS builder
# ... Gradle build + витягування шарів через jarmode=tools
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# ... COPY --from=builder шарів dependencies / spring-boot-loader / application
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Тут керування видно наочно: ви самі обираєте stages, самі вирішуєте, як витягати шари, що копіювати в runtime і чим запускати контейнер. Ми вже вміємо робити це акуратно; важлива сама точка контролю — керування живе в Dockerfile.
Gradle-конфігурація bootBuildImage — єдине джерело істини на шляху buildpacks. Наприклад, мінімальне корисне налаштування — зафіксувати імʼя образу, щоб потім не шукати його навмання:
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
tasks.named<BootBuildImage>("bootBuildImage") {
// Явно задаємо імʼя та тег образу, щоб потім не шукати «куди воно зібралося»
imageName.set("docker-java-catalog-service:buildpacks")
}
Зверніть увагу на відчуття: у Dockerfile ви описуєте «спочатку зроби A, потім B, потім C». У buildpacks ви радше кажете: «зібери мені коректний образ для цього Spring Boot-проєкту, а кінцевий результат назви ось так». І це нормально — просто інший стиль контролю.
3. Порівняння за критеріями: плюси й ціна
Порівнювати ці підходи лише за однією ознакою — зазвичай за розміром образу — майже гарантовано означає помилитися, тому що ви вимірюєте одну цифру, а обираєте цілу стратегію. Набагато корисніше порівнювати за кількома критеріями одночасно: де важливіший контроль, де — стандартизація, де — зрозумілість для команди, а де — швидкість і менше ручних рішень.
Нижче — таблиця, яку можна тримати як памʼятку. Вона не є «істина назавжди», але для рівня курсу й типового Spring Boot-сервісу працює чудово.
| Критерій | Dockerfile | Buildpacks (bootBuildImage) | Як це відчувається на практиці |
|---|---|---|---|
| Прозорість кроків збірки | Максимальна: кроки видно в Dockerfile | Кроки сховані всередині buildpacks, їх не прочитаєш як звичайний текст | Dockerfile легше пояснити новачку як сценарій, buildpacks — як процес за правилами |
| Швидкість старту (у новому проєкті) | Часто повільніше: потрібно написати й налагодити Dockerfile | Часто швидше: одне завдання, мінімум ручного службового коду | Buildpacks дають швидкий «перший повноцінний образ» без зайвих рухів |
| Стандартизація між сервісами | Потрібно дисциплінувати вручну (шаблони, рев'ю) | За замовчуванням вища: однакові правила пакування | Особливо помітно, коли в компанії 10+ Spring Boot-сервісів |
| Контроль над базою і шарами | Повний, аж до дрібниць | Обмежений, але є точки налаштування (builder/run image, env) | Якщо вам потрібно «ось саме так і ніяк інакше» — Dockerfile простіший |
| Кеш і багатошаровість | Цілком ваша відповідальність | Часто «з коробки» добре для Spring Boot | Важливий момент: результат усе одно багатошаровий і кешований |
| Супровід і дебаг збірки | Ви налагоджуєте свій Dockerfile | Ви налагоджуєте buildpacks-процес | У Dockerfile ви лагодите рецепт, у buildpacks — розбираєтеся, що саме обрав автомат |
| Відтворюваність | Досяжна, якщо фіксувати базові образи та версії | Досяжна, якщо фіксувати builder і домовитися про правила | В обох випадках без дисципліни буде лотерея |
| Ціна навчання команди | Потрібно вчити Dockerfile і мислення шарами | Потрібно вчити, що робить bootBuildImage, і як читати результат | Buildpacks не скасовують Docker-грамотність, просто змінюють місце контролю |
Поки що не робимо остаточного висновку, хто кращий. Ми просто фіксуємо: підходи розв’язують одну задачу, але оптимізують різні проблемні місця.
4. Як досліджувати зібраний образ
На цьому місці часто народжується міф: якщо збірка через buildpacks — це чорна скринька, отже образ не можна аналізувати. На практиці це неправда. Так, кроки збірки ви не читаєте як текст у репозиторії, але результат — усе одно звичайний Docker-образ, і ви можете досліджувати його звичними командами. Просто замість читання Dockerfile ви частіше читатимете вивід docker image inspect і docker image history.
Почнімо з простого. Після збірки через buildpacks ви можете подивитися, чим запускається контейнер, тобто Entrypoint:
# Дивимося, чим насправді запускається контейнер (Entrypoint/Cmd) всередині образу
docker image inspect docker-java-catalog-service:buildpacks \
--format 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}'
# Конкретне значення залежить від builder/stack/version.
# Часто побачите щось на кшталт:
# Entrypoint=["/cnb/lifecycle/launcher"] Cmd=[]
# або:
# Entrypoint=["/cnb/process/web"] Cmd=[]
Навіть якщо ви раніше не бачили цих значень, уже корисно, що вони не ховаються. Buildpacks поклали в образ свій runtime launcher/process wrapper, і це можна побачити звичайною Docker-командою. На шляху buildpacks прозорість зміщується в бік результату.
Так само ви можете подивитися змінні середовища, які образ несе всередині себе:
# Дивимося оточення всередині образу: часто там лежать підказки щодо JVM-прапорців і PATH
docker image inspect docker-java-catalog-service:buildpacks \
--format '{{json .Config.Env}}'
# ["PATH=...","JAVA_TOOL_OPTIONS=...","..."]
Це хороший спосіб зрозуміти, чому, наприклад, JVM стартує з певними параметрами. Не потрібно вгадувати — можна подивитися.
А якщо хочеться побачити багатошаровість, тобто з яких шматків образ зібрано, підійде docker image history:
# Дивимося «історію» образу: шари, їхній розмір і порядок
docker image history docker-java-catalog-service:buildpacks
# (побачите список шарів і їхній розмір)
Важливо зловити правильне відчуття: Dockerfile-шлях прозорий за кроками, buildpacks-шлях — за результатом. У житті ви все одно дивитиметеся і на те, і на інше, просто з різним акцентом.
5. Супровід: команда і рутина
Коли проєкт невеликий і навчальний, хочеться все зробити вручну, «щоб розуміти». Але щойно зʼявляються команда, кілька сервісів і реальна рутина, виникає прагматичне питання: скільки це коштує в супроводі? І ось тут Dockerfile і buildpacks починають поводитися дуже по-різному — не тому, що один поганий, а тому, що вони розраховані на різні звички керування.
Dockerfile — це як особистий шеф-кухар: ви отримуєте страву саме такою, як хочете, але вам треба вміти готувати й пояснювати рецепт іншим. Якщо ви зробили чудовий Dockerfile і закріпили його як канон, команда живе добре. Якщо ж у кожного Dockerfile «трохи свій», починається цирк: хтось додав зайвий RUN, хтось випадково зламав кеш, хтось змінив базовий образ на «бо бачив у статті».
Buildpacks — це ближче до «стандартизованої кухні»: правила пакування спільні, частину рішень ухвалюють за вас, і в середньому ви менше часу витрачаєте на підтримку десятка схожих Dockerfile. Особливо це корисно, коли у вас багато типових Spring Boot-сервісів і ви хочете, щоб вони збиралися однаково без вічного копіпасту.
Але є й зворотний бік. Коли вам знадобиться зробити щось не надто типове, Dockerfile зазвичай «чесніший». Не тому що buildpacks «не вміють», а тому, що ви впираєтеся у філософію: buildpacks оптимізують стандартний шлях і не хочуть перетворюватися на безкінечний конструктор «а давайте ще ось цей хитрий крок». У Dockerfile ви можете явно висловити будь-які незвичні вимоги до збірки, файлової структури та запуску процесу. У buildpacks ви шукатимете, як вмонтувати свою вимогу в доступні точки налаштування, і іноді це стає окремим мініквестом.
Найпрактичніше формулювання для рівня Junior звучить так: Dockerfile вимагає більше дисципліни й знань, але дає максимальний контроль; buildpacks вимагають менше ручної рутини, але зменшують видимість конкретних кроків пакування.
6. Чесне порівняння на Container-Ready Catalog Service
Порівняння «за відчуттями» зазвичай закінчується тим, що перемагає той, хто голосніше зітхає. Щоб порівняння було інженерним, потрібно тримати однаковими три речі: один і той самий сервіс, один і той самий стан коду та одну й ту саму перевірку запуску. І ще один важливий момент: тег :dockerfile тут означає саме поточну базову версію проєкту, а не якийсь новий спрощений Dockerfile «для порівняння».
Уявімо, що в нашому репозиторії docker-java-catalog-service налаштовано імʼя buildpacks-образу як у прикладах вище, наприклад docker-java-catalog-service:buildpacks. Тоді чесний сценарій виглядає приблизно так.
Збірка Dockerfile-шляху (ми явно тегуємо, щоб не плутатися):
# Збираємо образ за Dockerfile і одразу ставимо зрозумілий тег
docker build -t docker-java-catalog-service:dockerfile .
Збірка buildpacks-шляху:
# Збираємо образ через buildpacks (Spring Boot Gradle Plugin)
./gradlew bootBuildImage
Далі запускаємо обидва образи однаково, змінюючи лише порт на хості, щоб не було конфлікту. Наприклад, Dockerfile-образ — на 8080:
# Запускаємо контейнер із Dockerfile-образу
docker run --rm -p 8080:8080 docker-java-catalog-service:dockerfile
А buildpacks-образ — на 8081:
# Запускаємо контейнер із buildpacks-образу
docker run --rm -p 8081:8080 docker-java-catalog-service:buildpacks
І тепер перевірка має бути однаковою за змістом. Ми не вигадуємо «особливі перевірки» для buildpacks. Ми робимо те саме, що вже робили на ранніх етапах курсу: викликаємо той самий endpoint.
# Перевіряємо, що сервіс живий (actuator health)
curl -s http://localhost:8080/actuator/health
# {"status":"UP",...}
curl -s http://localhost:8081/actuator/health
# {"status":"UP",...}
Далі ви можете порівняти шари й метадані:
# Порівнюємо, з чого складаються образи, і чим вони відрізняються за шарами
docker image history docker-java-catalog-service:dockerfile
docker image history docker-java-catalog-service:buildpacks
І ще раз зафіксувати головну думку дня: обидва шляхи дають контейнерний образ, який можна запустити, і різниця — у тому, де описана логіка пакування та хто ухвалює рішення.
7. Типові помилки під час вибору підходу
На цьому етапі студенти часто починають обирати «що красивіше», а потім дивуються, чому збірка стала непередбачуваною, а команда сперечається про смак замість того, щоб випускати фічі. Помилки тут зазвичай не технічні, а методологічні: неправильно поставили запитання, а потім героїчно перемогли не ту проблему. Давайте проговоримо найчастіші граблі, щоб ви наступили на них хоча б у навчальному середовищі, а не на роботі.
Помилка № 1: обирати підхід за принципом “один раз сподобалося — отже, завжди так”.
Іноді людина спробувала bootBuildImage, побачила, що образ зібрався, і вирішила: «Ну все, Dockerfile більше не потрібен». Або навпаки: написала Dockerfile і вирішила: «Усе, buildpacks — для слабаків». На практиці підхід обирають не за емоцією, а за вимогами: чи потрібен вам повний контроль над кроками пакування, чи ви хочете стандартний шлях із меншою кількістю ручних рішень.
Помилка № 2: порівнювати Dockerfile і buildpacks на різних вхідних умовах.
Дуже легко випадково порівняти «Dockerfile-образ після вчорашніх оптимізацій» з «buildpacks-образом, зібраним на іншому стані проєкту», а потім зробити висновки про швидкість і розмір. Такий експеримент схожий на порівняння двох машин, де в однієї повний бак і нова гума, а в іншої — порожній бак і проколоте колесо. В інженерному порівнянні важливо тримати однаковими версію коду, налаштування запуску та спосіб перевірки результату.
Помилка № 3: очікувати, що bootBuildImage «врахує» ваш Dockerfile.
Це типова плутанина: студент думає, що buildpacks — це «просто інший спосіб викликати Docker build», і чекає, що Gradle-завдання прочитає Dockerfile. Воно не читає. Dockerfile — це окремий шлях. Buildpacks — окремий шлях. Якщо ви хочете змінити buildpacks-збірку, ви змінюєте налаштування bootBuildImage, а не Dockerfile.
Помилка № 4: не фіксувати імʼя образу й потім “шукати свій образ очима”.
Якщо не задати imageName, ви легко отримаєте ситуацію «я щось зібрав, але що саме і як воно називається — незрозуміло». У навчальному проєкті це перетворюється на плутанину тегів, а в реальному — на хаос, коли команда не може домовитися, який образ запускати. Звичка явно задавати imageName у Gradle — це маленька річ, яка економить багато нервів.
Помилка № 5: думати, що buildpacks скасовують потребу розуміти Docker.
Buildpacks прибирають частину рутини, але результат усе одно живе у Docker-світі: образ, шари, контейнер, логи, порти. Якщо контейнер не стартує, ви все одно дивитиметеся docker logs і docker inspect. Тому buildpacks — це не «магія замість знань», а «інший рівень автоматизації зі збереженням того самого результату».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ