1. Кілька Compose-файлів: один стек
Коли у проєкті з’являється compose.dev.yaml, у початківця в голові легко народжується дуже людська думка: «Ага, це ніби другий окремий стек: один для звичайного режиму, другий — для dev». І тут Compose доволі безжально ламає очікування: насправді це не два стеки, а одна модель застосунку, зібрана з кількох файлів, які накладаються один на один, наче прозорі плівки. Проблема не у вашій логіці — проблема в тому, що мозок любить «два файли = два запуски», а Compose любить «два файли = один підсумок».
Під капотом Compose бере перший файл за базу, потім читає другий файл і вносить зміни до того, що вже прочитав. Коли в обох файлах описано сервіс з однаковою назвою (скажімо, app), це не два різні сервіси, а один і той самий сервіс app, просто з донастроюванням. Якщо у другому файлі з’являється новий сервіс, якого не було в базі, він додасться до підсумкової моделі.
Дуже корисна аналогія для тих, хто вже працював із налаштуванням Spring Boot: це схоже на те, як application.yml доповнюється й перевизначається application-postgres.yml або як значення зі змінних середовища перекривають значення з файла. Тільки тут ми накладаємо одне на одне не властивості Spring, а Compose YAML.
Невелика схема, щоб зафіксувати ментальну модель:
flowchart TD A["база compose.yaml"] --> C["docker compose config: злиття"] B["перекриття compose.dev.yaml"] --> C C --> D["об’єднана конфігурація: кінцева модель"] D --> E["docker compose up: запуск"]
Головний висновок цього розділу: ви не зможете «на око» зрозуміти, що реально запуститься, коли читаєте лише один файл. Реальність міститься в об’єднаній конфігурації.
2. Порядок -f: база й override
Щойно ви приймаєте, що Compose «склеює» файли, з’являється наступний рівень: порядок має значення. Це не той випадок, коли команда просто бере список файлів, а далі якось розбереться. Compose читає файли зліва направо, і пізніші файли мають право перевизначати те, що було оголошено раніше. Тому базова команда дня виглядає просто, але принципово:
# Зліва — база, справа — override: правий файл «патчить» лівий
docker compose -f compose.yaml -f compose.dev.yaml config
Тут compose.yaml — база, а compose.dev.yaml — патч поверх бази. Саме тому ми й називаємо другий файл «dev override», а не «другий основний».
Коли ви випадково переплутаєте порядок, Compose чесно з’єднає все в інший бік, і ви отримаєте конфігурацію, яка логічно суперечить ідеї розділення ролей. Найнебезпечніший варіант — коли команда формально працює, але результат стає неочікуваним: начебто ви «увімкнули dev-файл», а фактично його переважив базовий.
Для демонстрації різниці (і щоб один раз побачити, а потім більше так не робити) порівняймо:
# правильно: база -> override (dev-налаштування можуть перекрити базові)
docker compose -f compose.yaml -f compose.dev.yaml config
# майже завжди неправильно: override -> база (база перетре dev-налаштування)
docker compose -f compose.dev.yaml -f compose.yaml config
Друга команда не «зламається» автоматично. Вона просто зробить так, що базовий файл почне перевизначати dev-налаштування. У підсумку ви можете витратити 20 хвилин на спроби зрозуміти, чому dev-порт не підхопився, хоча він є в dev-файлі. Спойлер: тому що ви самі його затерли порядком файлів.
Ще один дуже практичний момент: коли ви використовуєте набір -f ... -f ... для up, розумно використовувати той самий набір для config. Інакше ви дивитеся на одну конфігурацію, а запускаєте іншу — а потім дивуєтеся, що реальність не збігається з очікуваннями. Це приблизно як читати один application.yml, а запускати застосунок з іншим профілем.
3. Правила merge у Compose
Коли кажуть «Compose об’єднує файли», це звучить занадто абстрактно, доки не зрозуміло, які типи налаштувань він уміє об’єднувати й як. YAML усередині Compose — це не просто «текст». Це структура: десь лежать карти (map) у форматі ключ: значення, а десь лежать списки (list) у вигляді елементів через дефіс. І ці типи поводяться по-різному під час злиття. Хороша новина: для щоденної роботи Java-розробнику вистачає кількох правил, і не треба ставати фахівцем із YAML-алхімії.
Нижче — практична таблиця «чого очікувати» (спрощено, але чесно для більшості сценаріїв курсу):
| Де в Compose | Як виглядає в YAML | Що зазвичай робить злиття | Як це виглядає на практиці |
|---|---|---|---|
| environment (map-форма) | KEY: value | об’єднує за ключами, пізніший файл може перекрити значення | «додали одну змінну, не зламавши решту» |
| labels, annotations | k: v | те саме: об’єднання за ключем | «наклеїли ще один ярлик» |
| ports (списки) | - "8080:8080" | часто додає елементи з другого файла | «до базового порту додався ще один» |
| build.target, image, command (скалярні значення) | target: ... | пізніший файл замінює значення | «перемкнули режим» |
Але не всі list-like поля варто запам’ятовувати як такі, що «просто дописуються». З ports це часто виглядає саме так, а mount/volume-сценарії безпечніше перевіряти через підсумковий docker compose config.
І ще одна важлива обмовка про знімок прикладів: нижче буде той самий спрощений фрагмент повного стеку з фіксованим SPRING_PROFILES_ACTIVE. Він потрібен лише для того, щоб побачити механіку злиття. Для сценарію часткового запуску це значення вже краще перевизначати, а не вважати його вічною константою.
Погляньмо на простий і дуже життєвий приклад: у базовому compose.yaml ми хочемо тримати звичайні налаштування app (порти, профілі, підключення до сервісів), а в compose.dev.yaml додати лише одну змінну середовища для dev.
# compose.yaml (фрагмент)
services:
app:
environment:
# Для компактного прикладу повного стеку фіксуємо повний набір профілів
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
# compose.dev.yaml (фрагмент)
services:
app:
environment:
# Налаштування лише для dev: додасться до environment, якщо ключ унікальний
JAVA_TOOL_OPTIONS: "-Dexample.flag=true"
Інтуїтивно ви очікуєте, що в підсумку будуть обидві змінні. Так і буде: environment «склеїться» за ключами, а не заміниться цілком.
Тепер приклад, коли override справді перекриває: якщо ви задасте той самий ключ у другому файлі, спрацює правило «останнє значення перемагає».
# compose.dev.yaml (фрагмент)
services:
app:
environment:
# Той самий ключ, що й у базі: скажімо, звузили сценарій до app + postgres
SPRING_PROFILES_ACTIVE: "postgres"
В об’єднаній конфігурації залишиться postgres, тому що dev-файл прийшов пізніше. Іноді це саме те, що потрібно. Іноді — випадкова поломка. І тут ми плавно підходимо до героя нашої лекції — docker compose config, який дозволяє побачити, що вийшло, а не вгадувати.
Ще один частий приклад — ports. У базі в нас є HTTP, а в другому файлі додається ще один порт (скажімо, будь-який додатковий локальний порт). Compose, як правило, додасть ще один елемент до списку портів.
# compose.yaml (фрагмент)
services:
app:
ports:
# Базовий HTTP-порт застосунку
- "8080:8080"
# compose.dev.yaml (фрагмент)
services:
app:
ports:
# Додатковий порт для dev-сценаріїв (наприклад, налагоджувач)
- "5005:5005"
Підсумком стане наявність двох опублікованих портів. Це зручно, але в таких місцях легко отримати дублікати, коли ви не помітили, що десь повторили той самий порт. А дублікат порту — це вже не філософія, а помилка запуску.
4. Відносні шляхи в Compose
Відносні шляхи — це маленька тема, яка ламає нерви непропорційно своїм розмірам. Ви бачите в compose.dev.yaml щось на кшталт ./local-debug:/tmp/debug і мозок автоматично думає: «Ну, це шлях відносно dev-файла». А Compose такий: «Ха. Симпатично. Але ні». На практиці Compose трактує відносні шляхи в об’єднаній конфігурації відносно першого (базового) Compose-файла, тобто відносно compose.yaml, коли він стоїть першим у -f.
Це правило особливо помітне в монтуванні томів і в build.context. Наприклад, ви додали volume лише для dev у compose.dev.yaml:
# compose.dev.yaml (фрагмент)
services:
app:
volumes:
# Важливо: ./local-debug рахуватиметься відносно ПЕРШОГО файла в -f
- ./local-debug:/tmp/debug
Коли compose.yaml лежить у корені репозиторію (як у нас за каноном курсу), то ./local-debug — це repo-root/local-debug. Навіть коли compose.dev.yaml теж лежить у корені, усе добре й майже непомітно. Але коли ви починаєте рухати файли по папках або запускати команди з неочікуваних каталогів, можна зловити класичне «шлях існує в моїй голові, але не на диску».
Тому в навчальному проєкті ми тримаємо обидва файли в корені й припускаємо, що команди запускаються з кореня репозиторію. Це не «жорсткість заради жорсткості», а спосіб не перетворити курс на квест «звідки Compose взяв цей шлях».
Гарний спосіб перевірити реальність: попросити Compose вивести об’єднаний конфіг і подивитися, як він інтерпретував шляхи. Іноді docker compose config прямо показує вже нормалізований шлях, і ви миттєво бачите: «О, він шукав ./local-debug зовсім не там, де я думав».
5. docker compose config: підсумковий YAML
У світі Docker і Compose легко потрапити в режим «ну, давай просто up, а коли не злетить — подивимося логи». Це непоганий інстинкт, але він починає підводити, коли у вас з’являється кілька Compose-файлів, змінні середовища, підстановка ${...}, умовні значення та середовище, що розростається до чотирьох сервісів. У цей момент хочеться мати кнопку «покажи, що саме ти збираєшся запускати». Така кнопка існує — це `docker compose config`.
Команда робить дві важливі речі. По-перше, вона зливає Compose-файли так само, як це зробить up. По-друге, вона друкує результат у вигляді «нормалізованого» YAML, де вже видно, які значення реально вийшли після merge та підстановки. Це і є те саме єдине джерело істини, особливо коли ви перестаєте довіряти власній пам’яті: «Я точно це перевизначив, чи мені здається?»
Базовий приклад для нашої схеми «база + dev override»:
# Використовуємо той самий набір -f, що й під час реального запуску up
docker compose -f compose.yaml -f compose.dev.yaml config
Що корисного ви побачите у виведенні? Наприклад, об’єднання environment для сервісу app (фрагмент, спрощено):
services:
app:
environment:
SPRING_PROFILES_ACTIVE: postgres,cache,messaging
JAVA_TOOL_OPTIONS: -Dexample.flag=true
Навіть коли ви писали environment у різних файлах, у підсумковій картині ви бачите, як воно буде для контейнера. Це особливо цінно, коли ви не впевнені, хто кого перекрив.
Важливий нюанс: docker compose config часто виводить конфігурацію в більш розгорнутому вигляді, ніж ви писали. Наприклад, порти можуть стати «довгою формою», а environment може бути вирівняний і переставлений. Це не означає, що Compose щось «перевидумав». Це означає, що він показав ту саму конфігурацію, але в канонічній формі. Вважайте це не «інший YAML», а «відформатований результат».
І ще один нюанс, який краще знати заздалегідь: команда виконує підстановку змінних (interpolation). Тобто коли у вас у compose.yaml було щось на кшталт:
environment:
# Значення береться зі змінної середовища, а за відсутності використовується значення за замовчуванням після :-
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-catalog}
то docker compose config покаже вже підсумкове значення, наприклад:
environment:
# Тут уже «обчислений» результат після підстановки (це може бути секретом)
SPRING_DATASOURCE_PASSWORD: catalog
Це вкрай корисно для перевірки значень за замовчуванням, але водночас це причина бути обережним: коли ви підставляєте секрети через змінні середовища, config може їх надрукувати. Не тому, що Compose поганий, а тому, що він чесний.
6. Чек-лист для Spring Boot
Вивід docker compose config може бути довгим, і перша реакція новачка часто така: «Ого, тут багато букв. Я просто хотів перевірити один порт». Це нормально. Секрет у тому, щоб не намагатися читати все як роман, а дивитися на конфіг як на чек-лист реальності. У контексті нашого Spring Boot-сервісу ми майже завжди починаємо з сервісу app — тому що саме він має піднятися й правильно підключитися до решти.
Спочатку корисно знайти у виведенні services: app і переконатися, що Compose справді зібрав вам той варіант, який ви хотіли. Коли ви очікували, що dev-файл додасть щось до збірки, у конфігу це має бути видно: наприклад, у build можуть з’явитися додаткові деталі, а в ports — новий опублікований порт. Не треба вгадувати за двома YAML окремо, краще побачити об’єднаний.
Потім має сенс подивитися на environment у app. Для Spring Boot це майже завжди «пульс» застосунку: активні профілі (SPRING_PROFILES_ACTIVE), хости інфраструктури (SPRING_DATA_REDIS_HOST, SPRING_RABBITMQ_HOST), datasource URL на postgres за service name. Коли після злиття у вас раптом зникла якась змінна середовища або змінилися профілі, ви ловите проблему ще до запуску.
Далі варто коротко перевірити ports. У звичайному режимі вам зазвичай достатньо HTTP-порту, наприклад 8080:8080. У dev-режимі може додаватися ще один порт. Важливо не переплутати ситуацію «порт є в dev-файлі» з ситуацією «порт реально потрапив в об’єднану конфігурацію». config показує друге.
Коли в проєкті є прив’язане монтування томів, особливо наш навчальний сценарій з каталогом експорту (./data/exports), погляд на volumes в об’єднаному конфігу допомагає швидко зрозуміти, чи не з’їхав шлях не туди. Це типова поломка, яка виглядає як проблема застосунку («чому я не бачу файли експорту?»), хоча насправді це проблема шляху й монтування.
І нарешті, у нашому стеку є залежності з моделлю readiness. Тому корисно переконатися, що в підсумковому конфігу не загубилися healthcheck і depends_on.condition: service_healthy (коли ви їх використовуєте в базі). Це особливо важливо, коли другий файл випадково перевизначив шматок сервісу, і ви ненароком зачепили важливу частину.
Коли вивід занадто великий, абсолютно нормальна практика — зберегти об’єднаний конфіг у файл і відкрити його як звичайний YAML:
# Зручний прийом: зберегти об’єднаний конфіг і дивитися/шукати по ньому як по звичайному YAML
docker compose -f compose.yaml -f compose.dev.yaml config > merged.compose.yaml
Цей файл зазвичай не хочеться комітити, але його зручно «перегортати очима» або шукати в ньому конкретні рядки.
7. Типові помилки під час роботи з Compose
Коли середовище вже доросле, помилки стають не «страшнішими», а просто хитрішими: часто ламається не сам сервіс, а ваше уявлення про те, що ви запускаєте. Нижче — найчастіші граблі, які трапляються саме у зв’язці «кілька файлів + merge».
Помилка №1: передали файли в неправильному порядку.
Це виглядає безневинно: ви просто поміняли місцями -f compose.yaml і -f compose.dev.yaml. Але для Compose це змінює сенс: базовий файл починає перекривати override-файл, і dev-налаштування «зникають», хоча фізично вони записані. Звичка тримати в голові правило «спочатку база, потім override» і перевіряти docker compose config зазвичай вирішує проблему миттєво.
Помилка №2: дивляться лише на compose.dev.yaml і «по ньому» намагаються вгадати підсумок.
Dev-файл майже завжди містить лише шматочки (як і має бути), тому він за визначенням не розповідає всю правду. У підсумку людина бачить: «ага, тут додали змінну», а потім дивується, що не працює підключення до Redis. Тому що підключення до Redis було задано в базовому файлі, а об’єднану картину вона навіть не подивилася. Щойно ви привчаєте себе «мерджимо → дивимося config → запускаємо», таких сюрпризів стає набагато менше.
Помилка №3: думають, що шляхи в dev-файлі рахуються відносно dev-файла.
Найприкріша поломка: ви монтуєте ./something, у вас на диску це існує, але контейнер каже, що файла або каталогу не існує. Причина — точка відліку відносного шляху. У нашому курсі ми дисциплінуємо структуру (обидва файли в корені), але все одно корисно пам’ятати правило і в спірних ситуаціях перевіряти об’єднаний конфіг.
Помилка №4: не використовують один і той самий набір -f для config і для up.
Команда docker compose config без -f за замовчуванням читає лише compose.yaml. Коли ви потім запускаєте docker compose -f compose.yaml -f compose.dev.yaml up, то ви спочатку подивилися на одну реальність, а потім запустили іншу. Це не «помилка Docker», це просто розсинхронізація в голові. Лікується дисципліною: однаковий набір -f в межах одного сценарію.
Помилка №5: друкують docker compose config і випадково діляться секретами.
Навіть коли сьогодні в навчальному проєкті паролі умовні, у реальній роботі ця команда легко може вивести справжні значення з .env або з оточення shell. Це не причина не користуватися config, це причина пам’ятати: вивід команди — це майже готовий «паспорт запуску», і його краще не копіювати в публічні чати без фільтрації.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ