1. Сенс канонічного шляху пакування
Коли порівняння вже зроблено, залишається дуже побутове запитання: який шлях вважати за замовчуванням у репозиторії. Якщо в проєкті є два робочі способи пакування, він живе спокійно рівно до першого запитання «як це запускати?». А потім раптом виявляється, що два будильники теж не гарантують пробудження.
Проблема не в тому, що існує кілька шляхів. Проблема в тому, що без канону команда починає жити в режимі «кожен робить так, як звик». Один збирає docker build, другий — ./gradlew bootBuildImage, третій узагалі випадково запускає старий тег, бо він у нього лишився в кеші. І коли щось ламається, ви навіть не впевнені, який саме образ запустили, яким способом його зібрали й які «приховані» налаштування в ньому опинилися.
Для навчального сервісу це теж критично: без шляху за замовчуванням будь-яка діагностика перетворюється на вгадування. Ви можете бачити один і той самий контейнерний симптом, але при цьому порівнювати два різні образи, зібрані різними способами.
2. Канон у репозиторії та теги
Слово «канонічний» звучить так, ніби ми зараз обиратимемо «єдину істинну релігію пакування». Насправді все простіше й приземленіше. Канонічний шлях — це той, який ви вважаєте базовим за замовчуванням: він описаний у README першим, за ним зроблено всі приклади в документації проєкту, на нього спираються smoke-скрипти, і саме його ви називаєте, коли хтось запитує: «як зібрати й запустити?».
Важливо: канонічний шлях не зобовʼязаний бути єдиним. Він зобовʼязаний бути першим і однозначним. Другий шлях у цьому разі стає «підтримуваною альтернативою»: він теж робочий, теж описаний, але має свою роль. Наприклад: «якщо вам потрібно швидко отримати стандартний образ без ручного Dockerfile — використовуйте buildpacks». Або навпаки: «якщо вам потрібен повний контроль над кроками збирання — використовуйте Dockerfile».
Якщо сформулювати це максимально коротко, вийде корисне правило: у проєкті може бути два шляхи, але не повинно бути двох стандартів.
Щоб це відчувалося не як філософія, а як інженерія, зручно тримати в голові схему «один сервіс → два конвеєри пакування → два образи → один і той самий запуск».
flowchart TD
A["docker-java-catalog-service (один Spring Boot-сервіс)"] --> B["Шлях через Dockerfile `docker build`"]
A --> C["Шлях через buildpacks `./gradlew bootBuildImage`"]
B --> D["Образ: `:dockerfile`"]
C --> E["Образ: `:buildpacks`"]
D --> F["docker run (контейнер)"]
E --> F
Імена образів і теги
Один із найдешевших способів влаштувати собі хаос — зібрати різними способами один і той самий тег. Умовно: вчора ви зібрали docker-java-catalog-service:latest через Dockerfile, сьогодні — через buildpacks, і в обох випадках задоволені. А потім запускаєте контейнер і не розумієте, чому він поводиться «як учора, але не зовсім».
Тому ми запроваджуємо просту, майже нудну дисципліну: один шлях пакування — один тег. У навчальному проєкті це можна оформити прямо так:
- docker-java-catalog-service:dockerfile
- docker-java-catalog-service:buildpacks
Тоді навіть у розмові ви не плутатиметеся. Фраза «запусти :dockerfile» набагато краща, ніж «запусти latest, але той, що через Dockerfile, не переплутай».
Мінімальний сценарій шляху Dockerfile виглядає так:
# Збираємо образ через Dockerfile і фіксуємо тег, щоб його неможливо було переплутати
docker build -t docker-java-catalog-service:dockerfile .
# Запускаємо контейнер саме з цього образу
docker run --rm -p 8080:8080 docker-java-catalog-service:dockerfile
А шлях buildpacks (після того, як ми зафіксували imageName, про це за хвилину) — ось так:
# Збираємо образ через buildpacks (імʼя/тег образу фіксуємо в Gradle-конфігурації)
./gradlew bootBuildImage
# Запускаємо контейнер із тега buildpacks
docker run --rm -p 8080:8080 docker-java-catalog-service:buildpacks
До речі, корисно памʼятати, що сам по собі факт «образ зібрано buildpacks» не робить його якимось «особливим». Це все одно звичайний Docker-образ, який ви запускаєте звичайною командою, так само як і будь-який інший. І Spring Boot загалом прямо підтримує такий шлях: bootBuildImage створює образ, після чого ви запускаєте його docker run.
Фіксуємо обидва шляхи в репозиторії
Якщо ви хочете, щоб обидва шляхи були не «випадково робочими», а підтримуваними, їм потрібно дати зрозумілі точки входу. У нашому проєкті точок входу рівно дві: Dockerfile і Gradle-завдання bootBuildImage. Наша мета — не ускладнити репозиторій, а зробити його читабельним. Тому ми не заводимо десять файлів і не починаємо будувати міні-CI в себе на ноутбуці. Ми просто акуратно фіксуємо назви та порядок.
Фіксуємо imageName для buildpacks
За замовчуванням bootBuildImage може автоматично обирати імʼя образу, і це «нормально», доки ви працюєте лише з одним шляхом. Але щойно шляхів два, імʼя мусить стати явним.
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage // Тип завдання Spring Boot, що збирає образ через buildpacks
tasks.named<BootBuildImage>("bootBuildImage") {
// Явно задаємо імʼя/тег образу, щоб результат збирання був передбачуваним і відтворюваним
imageName.set("docker-java-catalog-service:buildpacks")
}
Тут важливий не сам Kotlin-код (він простий), а ідея: ви робите результат buildpacks передбачуваним. І тоді в README можна чесно написати: «команда створює ось такий тег».
Документуємо канон і альтернативу в README
README має бути таким, щоб людина без телепатії могла:
1) зібрати образ,
2) запустити контейнер,
3) перевірити, що сервіс живий.
Якщо в нас два шляхи пакування, README зобовʼязаний сказати, який з них основний. Приклад того, як це може виглядати:
## Образ контейнера
Канонічний шлях: Dockerfile
```bash
docker build -t docker-java-catalog-service:dockerfile .
```
Підтримувана альтернатива: Buildpacks (Spring Boot `bootBuildImage`)
```bash
./gradlew bootBuildImage
```
Зверніть увагу: ми не сперечаємося, який шлях кращий. Ми просто фіксуємо, який шлях є основним у цьому репозиторії. Це економить неймовірну кількість часу на комунікацію. І так, це той випадок, коли «зайві два рядки в README» дешевші, ніж одна розмова в стилі «а в тебе як зібрано?».
Не плодимо зайві Dockerfile
Дуже популярна пастка: «давайте зробимо Dockerfile.dockerfile, Dockerfile.buildpacks, Dockerfile.v2, Dockerfile.final…». У підсумку репозиторій перетворюється на музей археології, де кожен експонат «важливий», але ніхто не впевнений, який із них живий.
Buildpacks-шлях за визначенням не потребує Dockerfile. Тому не потрібно намагатися «наблизити» його до Dockerfile-шляху, створюючи окремі Dockerfile для кожної стратегії. У нашому проєкті один Dockerfile лишається головним ручним сценарієм, а buildpacks живуть у Gradle-світі. Це різні рівні керування, і саме в цьому їхній сенс.
4. Критерії вибору: Dockerfile vs buildpacks
Після порівняння правила зазвичай дуже прості:
- якщо важливіші прозорість кроків збирання, ручний контроль і зрозумілість пакування, шлях за замовчуванням частіше стає Dockerfile;
- якщо важливіше стандартизувати десяток схожих Boot-сервісів і прибрати зайвий boilerplate, шлях за замовчуванням цілком може стати buildpacks;
- підтримувана альтернатива від цього не зникає: вона просто перестає вдавати другий стандарт.
Для нашого навчального репозиторію Dockerfile лишається каноном за замовчуванням, бо на ньому краще видно механіку контейнеризації, пошарову структуру, базовий образ і точку запуску. Buildpacks лишаються чесною підтримуваною альтернативою: образ збирається швидко, стандартно й без ручного Dockerfile.
І тут важливе одне розмежування. Шлях за замовчуванням стосується звичайного runtime-збирання сервісу. Це не означає, що будь-який дружній до розробника варіант запуску має виглядати точно так само: головне, щоб він був явно підписаний і не підміняв собою основний образ проєкту.
5. Одна smoke-перевірка для обох шляхів
Коли в репозиторії є шлях за замовчуванням і підтримувана альтернатива, важливо не плодити дві різні перевірки «живості». Після будь-якого пакування питання одне й те саме: контейнер стартує і сервіс відповідає на той самий ендпойнт.
# Замість <tag> підставте dockerfile або buildpacks
docker run -d -p 8080:8080 --name catalog \
docker-java-catalog-service:<tag>
curl -s http://localhost:8080/actuator/health # {"status":"UP"}
docker rm -f catalog
Така перевірка дисциплінує краще за будь-яку суперечку. Якщо smoke-check однаковий, значить ви дійсно порівнюєте два шляхи пакування одного й того самого сервісу, а не два випадково різні стани проєкту.
6. Типові помилки під час вибору канонічного шляху
Помилка №1: у README вказано два шляхи без канону.
Помилка №1 — залишити в README дві команди без пояснення, яка з них основна. Тоді на будь-яке запитання «як зібрати?» ви отримуєте дві відповіді, і команда починає жити в режимі вічного контексту: хто з ким говорив, той так і робить.
Помилка №2: один і той самий тег використовується для різних шляхів пакування.
Використовувати один і той самий тег для різних шляхів — дрібниця лише на перший погляд, доки ви не зловите ситуацію, коли docker run docker-java-catalog-service:latest запускає не те, чого ви очікували. Docker дуже чесний: він запустить те, що в нього є. А от ви можете бути не такими чесними із собою, якщо не фіксуєте теги на кшталт :dockerfile і :buildpacks.
Помилка №3: очікування, що bootBuildImage «прочитає Dockerfile».
У голові це звучить логічно («я ж про Docker!»), але на практиці це різні механізми. Dockerfile — це ваш явний сценарій збирання. Buildpacks — це стандартизований сценарій, що живе в buildpack-логіці. Якщо ви змішуєте ці світи в голові, ви починаєте «налаштовувати не там»: правите Dockerfile й дивуєтеся, чому образ buildpacks не змінився.
Помилка №4: порівняння Dockerfile і buildpacks у різних умовах.
Наприклад, Dockerfile-образ ви збираєте зі свіжого коду, а buildpacks — із кешу, або навпаки. Потім ви робите висновок «цей шлях швидший» або «цей менший», але порівняння нечесне. Інженерне порівняння вимагає однакових умов: той самий код, той самий запуск, та сама перевірка.
Помилка №5: документуються команди, але не документується зміст.
Фраза «ось дві команди» — не документація. Документація — це коли поруч написано, яка команда для чого. І тут усе дивовижно просто: Dockerfile — коли потрібні явний контроль і прозорість, buildpacks — коли потрібен стандартний шлях без ручного опису кроків. Коли ви пишете це в README, проєкт перестає бути загадкою й починає бути інструментом.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ