1. Compose: Spring падає через YAML
Коли ви запускаєте Container-Ready Catalog Service локально через bootRun, картина знайома й передбачувана: якщо щось упало, значить, проблема в коді або в конфігурації застосунку. Compose дуже мʼяко й водночас підступно ламає цю звичку: ви змінюєте не Java-код, а оточення, і застосунок чесно падає… тож здається, ніби «зламався Spring Boot». Насправді ж Spring у цей момент лише виступає в ролі термометра, який першим показує температуру, але не є причиною застуди.
У Compose поруч із вами зʼявляються сусіди: postgres, redis, rabbitmq. Зʼявляється мережа Compose, зʼявляються volumes, зʼявляється кілька Compose-файлів (compose.yaml і compose.dev.yaml), зʼявляється підстановка змінних із .env. І ось ви вже живете не в «одному застосунку», а в маленькому містечку. Якщо в місті відключили воду — не піднявся postgres — першим страждає той, хто намагався помити руки, тобто ваш застосунок. І він буде нарікати так, ніби зламалася ванна. Тому в Compose дуже легко переплутати місце, де проявляється помилка, з її першопричиною.
Найважливіша практична думка тут проста: у багатоконтейнерному середовищі частину помилок треба діагностувати ззовні застосунку так само уважно, як і всередині нього. Якщо ви бачите stack trace в логах app, це ще не означає, що винен код. Це означає лише одне: app чесно повідомив про те, що йому не дали того, що він очікує від оточення.
З окремими збоями все було відносно чесно: симптом зазвичай був недалеко від причини. У Compose зʼявляється окрема пастка: помилка живе в моделі запуску, а stack trace ви насамперед бачите в app. Тому далі важливо тримати в голові два рівні: що Compose збирається запустити за YAML і що реально є всередині вже створених контейнерів.
2. YAML vs runtime: що запустилося
Одна з причин, чому Compose-помилка так легко маскується під помилку застосунку, — це розрив між тим, що ви думаєте, ніби запускаєте, і тим, що реально вийшло після всіх склеювань. У голові новачка compose.yaml — це істина, а контейнери — лише «його виконання». У світі Compose все більше схоже на компіляцію: у вас є вихідники (compose.yaml, compose.dev.yaml, .env), а на виході — «скомпільована» підсумкова конфігурація. І вже за нею Compose створює контейнери.
Ось невеликий і корисний образ думки: compose.yaml — це як Java‑код, а docker compose config — це як байткод після компіляції. Ви можете хоч тисячу разів дивитися на вихідник і казати: «Ну тут усе правильно», але запускатиметься те, що вийшло після підстановок і злиттів.
У світі Compose дуже часто існує одразу кілька причин, чому «те, що написано», не дорівнює «тому, що вийшло»:
По-перше, кілька Compose-файлів. У нашому курсі це канон: є compose.yaml і є compose.dev.yaml, причому другий файл переозначає перший. Якщо ви дивитеся лише на compose.yaml, ви бачите половину картини — приблизно так само, як читати лише непарні сторінки книги й дивуватися, чому сюжет дивний.
По-друге, підстановка змінних. Compose може замінити ${SOME_VAR} на порожній рядок, якщо змінну не задано, і це створює дуже правдоподібні помилки вже на рівні застосунку. І так, застосунок чесно скаже: «Не можу сконфігурувати datasource», але першопричина буде в тому, що рядок підключення став порожнім через підстановку.
По-третє, старі контейнери. Ви могли змінити environment або volumes у YAML, але запущені контейнери не завжди автоматично пересоздаються так, як ви інтуїтивно очікуєте. У результаті ви виправляєте YAML, а насправді продовжуєте дивитися на поведінку старої конфігурації.
І тут легко переплутати два різні джерела істини. docker compose config показує resolved-модель запуску: що Compose збере з файлів, override і підстановок. docker inspect і, коли контейнер живий, docker compose exec ... printenv показують actual state уже створеного контейнера: що в ньому є просто зараз. Після правки YAML ці рівні можуть розійтися: desired config уже правильний, а контейнер усе ще старий. У такій ситуації спочатку пересоздайте потрібний сервіс, а вже потім повторюйте той самий failing-check.
Тому в Compose-діагностиці у вас зʼявляється окреме обовʼязкове запитання: що саме застосувалося.
3. docker compose config: merged-правда
Якщо ви запамʼятаєте з цієї лекції лише одну команду, нехай це буде docker compose config. Вона потрібна не «для краси» і не для DevOps‑шаманства. Вона показує resolved‑правду про модель запуску: що Compose збирається зібрати з файлів, override і .env. А вже потім ви порівнюєте її з actual state контейнерів, щоб перестати сперечатися зі своєю уявою.
Найважливіший момент: коли у вас два файли, ви повинні перевіряти конфігурацію в тому самому вигляді, в якому реально запускаєте стек. Якщо ви піднімаєте dev‑стенд так:
# Піднімаємо стек рівно з тими самими файлами, які використовуємо в реальному dev-запуску
docker compose -f compose.yaml -f compose.dev.yaml up
то і «правду» ви повинні дивитися так:
# Виводимо підсумкову (merged) конфігурацію після всіх override і підстановок
docker compose -f compose.yaml -f compose.dev.yaml config
Ця команда покаже вам підсумковий YAML після підстановок і обʼєднання. Далі ви вже можете очима шукати ключові речі: які порти реально опубліковані, які env vars реально передаються, які volumes реально підключені, які depends_on умови реально встановлені.
Якщо після правки YAML config уже показує правильні env vars або mounts, а застосунок поводиться по-старому, це не contradiction. Отже, настав час порівняти actual state через docker inspect і пересоздати сервіс, а не переписувати той самий YAML ще раз.
Дуже часта маскувальна проблема — коли обидва фрагменти YAML виглядають логічно, але разом дають несумісну конструкцію. Наприклад, базовий файл задає шлях експорту, а dev-файл монтує інший шлях:
# compose.yaml (фрагмент)
services:
app:
environment:
# Це шлях, куди застосунок намагатиметься записувати файли всередині контейнера
APP_EXPORT_DIR: /app/exports
# compose.dev.yaml (фрагмент)
services:
app:
volumes:
# Ліворуч шлях на хості, праворуч шлях усередині контейнера (і тут легко помилитися в target)
- ./data/exports:/app/export # зверніть увагу: export, не exports
Кожен шматок окремо виглядає правдоподібно. У підсумку ж застосунок пише в /app/exports, а mount підʼєднано до /app/export. З боку Java ви побачите «файл не зʼявився» або «Permission denied», і дуже легко почати копирсатися в коді експорту. Насправді це чиста Compose-проблема, і docker compose config показує її буквально.
Ще одна важлива деталь, яка часто плутає студентів: .env і environment: — це різні рівні. .env у Compose насамперед бере участь у підстановці значень у YAML, а не є «автоматичною передачею всіх змінних усередину контейнера». Якщо ви очікуєте, що «оскільки в .env лежить SPRING_PROFILES_ACTIVE=postgres, то контейнер точно це отримає», ви цілком можете отримати сюрприз. Перевіряти треба тим самим способом: через docker compose config і, за потреби, через docker exec … printenv.
4. Маски Compose у логах Spring Boot
У багатоконтейнерному оточенні застосунок стає вітриною: він першим показує помилку, тому що він першим намагається працювати. Але клас помилки при цьому може бути рівня Compose. Щоб не гадати, корисно тримати в голові кілька типових відповідностей між тим, що ви бачите в логах застосунку, і тим, що насправді може бути зламано в Compose-моделі.
Нижче — невелика таблиця-шпаргалка. Не сприймайте її як довідник на всі випадки життя; це радше спосіб швидко сформулювати першу гіпотезу й обрати дешеву перевірку.
| Що видно в логах app (Spring Boot) | Як це відчувається | Часта першопричина в Compose | Що перевірити насамперед |
|---|---|---|---|
| UnknownHostException: postgres або Name or service not known | «Застосунок не бачить БД» | Помилка в hostname, неправильне service name, не та network | docker compose config (URL), docker compose ps (чи є сервіс) |
| Connection refused на postgres:5432 | «База не приймає зʼєднання» | База ще не готова, немає service_healthy, неправильний порт усередині мережі | docker compose ps (health), логи postgres |
| Failed to configure a DataSource / немає datasource URL | «Spring не може підняти контекст» | Не передали env vars, порожня підстановка з ${…}, неправильний профіль | docker compose config (environment), потім docker exec app printenv |
| Помилка підключення до Redis/RabbitMQ за увімкнених профілів | «Зламався cache/messaging код» | Увімкнули профілі, але підняли частковий стек без залежностей | Що реально піднято (docker compose ps) і які профілі активні |
| AccessDeniedException, Permission denied, «не можу створити файл» | «Зламався експорт» | Mount підʼєднано не туди або немає прав на target dir | docker compose config (volumes + APP_EXPORT_DIR), docker exec app ls -la |
Зверніть увагу на загальну структуру: майже всюди перша перевірка — це не «відкрити IDE і шукати баг», а «подивитися фактичні runtime-параметри». У Docker/Compose особливо небезпечно лікувати симптоми за звичкою: ви бачите stack trace, мозок автоматично перемикається в режим «я Java-розробник, значить, зараз виправлятиму Java». А правильніше на дві хвилини стати «розробником оточення» і підтвердити, що ви взагалі дали застосунку те, що він очікує.
Окремо варто сказати про partial startup, тому що це одне з найправдоподібніших джерел хибних помилок. Ви могли підняти лише app + postgres, але залишити SPRING_PROFILES_ACTIVE=postgres,cache. Застосунок буде чесно намагатися підключитися до Redis і нарікати. У логах це виглядає як «зламалося кешування». Насправді ж проблема в невідповідності: активні профілі вимагають залежність, яку ви не підняли в поточному сценарії.
Точно так само можна випадково увімкнути messaging профіль і отримати помилки RabbitMQ, хоча в цей момент ви налагоджуєте взагалі не повідомлення, а, наприклад, експорт у файл. І знову: помилка в логах застосунку, а першопричина — у моделі запуску.
5. Логи кількох сервісів
В одиночному контейнері все просто: є один контейнер, один процес, один потік логів. У Compose‑стеку так не працює, тому що у вас є щонайменше два процеси, які спілкуються між собою, і часто саме в цьому діалозі схована першопричина. Тому дуже корисно навчитися читати логи як ланцюжок причин і наслідків, а не як набір незалежних текстів.
На практиці це виглядає так: ви бачите, що app пише «не можу підключитися до PostgreSQL». Якщо ви читаєте лише логи app, ви отримаєте гарний stack trace і відчуття, що «все погано». Але якщо ви одночасно читаєте логи postgres, ви часто побачите значно точнішу відповідь: база ще стартує, база не прийняла пароль, база не створила користувача, база впала на міграціях — залежно від сценарію. Застосунок у цей момент лише клієнт, який відобразив симптом.
Найпростіший спосіб зібрати ланцюжок — використати Compose-варіант логів:
# Дивимося логи одразу клієнта (app) і залежності (postgres), щоб побачити причинно-наслідковий ланцюжок
docker compose logs app postgres
Якщо ви хочете дивитися «вживу», додайте follow-режим:
# Follow-режим зручний, коли ви перезапускаєте сервіси й ловите момент падіння або готовності
docker compose logs -f app postgres
А якщо логів занадто багато, що в Spring Boot трапляється навіть по понеділках, то корисний короткий «хвіст»:
# Беремо лише останні рядки, щоб швидко побачити актуальну причину без прокрутки
docker compose logs --tail=50 app postgres
Тут важлива не кількість прапорців, а звичка: щойно у вас є залежність, у голові завжди має бути щонайменше два потоки логів. І коли ви будуєте гіпотезу, ви питаєте не «чому застосунок упав», а «хто першим сказав, що щось не так».
У Compose-діагностиці корисно іноді починати навіть не з логів, а зі стану сервісів:
docker compose ps
Ви отримаєте таблицю, де видно, які контейнери Up, які Exited, які мають статус healthy/starting. Якщо postgres ще starting, а app уже намагається підключитися й падає, це майже підручник із readiness-проблем. І знову: зовні буде здаватися, що «зламався Spring», а насправді ви просто стартуєте стек без реальної готовності залежності.
6. Помилка в resolved YAML
Є особливий клас проблем, який психологічно найскладніший: коли ви дивитеся на compose.yaml, там усе ідеально, і навіть колега поруч каже: «Так, усе нормально». Але стек усе одно ламається. У таких випадках майже завжди винне те, що реальна конфігурація запуску — це не compose.yaml, а результат обʼєднання кількох файлів і підстановок. Тобто проблема живе в resolved YAML.
Найчастіший сценарій — неочікуваний override у compose.dev.yaml. Наприклад, ви хотіли в dev-режимі додати volume для зручності або відкрити debug-порт, але випадково переозначили не ту секцію і зламали базовий режим. Це дуже схоже на ситуацію в Java, коли ви хотіли «трішки змінити конфіг для тестів», але випадково переозначили production-налаштування — тільки тут це відбувається на рівні YAML.
Друга часта історія — конфлікт шляхів. Для mounts у Compose важливий саме target path усередині контейнера. Якщо APP_EXPORT_DIR указує на одне місце, а volume підʼєднано в інше, поведінка буде «магічною» й дратівливою: експорт «працює» — у логах ви бачите успіх, — але файлу «немає» на хості. І ви починаєте підозрювати CSV, код, права доступу, фазу місяця… хоча достатньо було порівняти два шляхи в docker compose config.
Третя історія — readiness і depends_on. Якщо ви бачите в resolved config, що у вас стоїть condition: service_started, це означає лише «контейнер postgres запущено», але не означає «PostgreSQL готова приймати зʼєднання». Застосунок цілком може чесно впасти на старті з помилкою підключення, і це виглядатиме як startup failure застосунку. Першопричина ж — у тому, що модель запуску не дочекалася готовності залежності. Це настільки часта маска, що її корисно впізнавати очима.
Ось приклад, який виглядає майже правильно, але дає нестабільну поведінку:
services:
app:
depends_on:
postgres:
# Контейнер запустився, але сервіс усередині нього може ще не бути готовим
condition: service_started
А ось варіант, який краще виражає реальну залежність:
services:
app:
depends_on:
postgres:
# Очікуємо саме готовність (healthcheck), а не просто факт запуску контейнера
condition: service_healthy
Різниця маленька, а ефект величезний: ви перестаєте грати в «вгадай, чи встигла база прокинутися».
І нарешті — найневидиміша причина: підстановка змінних дала порожнє значення. Наприклад, у YAML написано так:
services:
app:
environment:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
Якщо змінну не задано, підсумкова resolved-конфігурація може містити порожнє значення, а застосунок стартуватиме взагалі не в тому режимі, який ви очікуєте. І ви отримаєте помилку: «Усе працювало вчора, а сьогодні ні», хоча насправді просто змінилося оточення, з якого Compose брав значення. docker compose config покаже вам це «як є», без фантазій.
7. Мінісхема діагностики Compose-маски
Важливо не перетворювати цю лекцію на передчасний «повний playbook» — його ми акуратно зберемо в наступній лекції дня. Але мінімальна схема саме для Compose-маски вам потрібна вже зараз, щоб не робити типовий рефлекс «IDE-first».
Можна тримати в голові такий короткий маршрут: спочатку ви перевіряєте модель запуску, потім стан сервісів, потім будуєте ланцюжок за логами, і тільки потім лізете всередину контейнера. Це звучить занадто раціонально, тому давайте зафіксуємо це у вигляді простої блок-схеми.
flowchart TD
A["Помилка проявилася в застосунку всередині Compose"] --> B["Дивимося resolved-модель: docker compose config"]
B --> C["Дивимося стан: docker compose ps"]
C --> D["Читаємо ланцюжок логів: docker compose logs app + залежності"]
D --> E{"Гіпотеза стала конкретною?"}
E -->|"Так"| F["Точкова перевірка: inspect/exec лише по справі"]
E -->|"Ні"| G["Уточнюємо: яким був перший симптом і де саме?"]
F --> H["Одне виправлення"]
H --> I["Повторюємо вихідну перевірку"]
У цій схемі важливо те, що вона захищає вас від двох дорогих помилок. Перша помилка — виправляти застосунок, не підтвердивши, що оточення взагалі збігається з очікуваним. Друга помилка — робити «сто перевірок поспіль», не сформулювавши гіпотезу. Compose-маска особливо любить хаос: чим більше ви хаотично правите YAML, тим менше розумієте, чому стало краще або гірше.
І ще один маленький, але практичний нюанс: коли ви змінюєте YAML, переконайтеся, що справді застосували зміну. Іноді потрібно пересоздати сервіс, інакше ви продовжуєте дивитися на стару конфігурацію. Це не «магія Docker», а просто механіка: контейнер уже створено з певними env vars і mounts, і доки ви його не пересоздасте, він не стане іншим сам по собі. Тому в діагностиці завжди корисно памʼятати: ви змінюєте не текст, ви змінюєте реальний runtime.
8. Типові помилки діагностики Compose-масок
Помилка №1: читати лише логи застосунку й ігнорувати логи залежностей.
Це виглядає природно: ви Java-розробник, у вас у логах stack trace, отже, треба читати саме його. Але в Compose-стеку stack trace часто лише відображає проблему сусіда. Якщо postgres ще стартує або отримав неправильний пароль, застосунок падатиме першим і найголосніше, але першопричина житиме в іншому контейнері.
Помилка №2: довіряти фрагменту YAML замість resolved-конфігурації.
Коли у вас є compose.yaml і compose.dev.yaml, дивитися лише на один файл — це як діагностувати баг за одним методом без читання коду, який його викликає. У результаті ви виправляєте те, що «і так не використовувалося», а реальне override-значення продовжує ламати запуск. Команда docker compose config тут економить години.
Помилка №3: запускати partial startup і забувати звірити його з активними профілями Spring.
Partial startup — чудовий інструмент, але він вимагає дисципліни. Якщо ви підняли лише app + postgres, а SPRING_PROFILES_ACTIVE містить cache або messaging, застосунок чесно чекатиме Redis/RabbitMQ і нарікатиме. Симптом виглядатиме як «зламався код кешу/повідомлень», хоча ви просто попросили застосунок жити в режимі, якому не дали інфраструктуру.
Помилка №4: плутати service_started і реальну готовність залежності.
depends_on із service_started часто створює відчуття «іноді працює, іноді ні». Новачок сприймає це як випадковість або як «Spring гальмує». Насправді це таймінг: база запущена як контейнер, але ще не готова приймати зʼєднання. Якщо ви хочете стабільності, ви повинні виражати в Compose саме ready-стан, а не просто порядок запуску.
Помилка №5: монтувати один шлях, а налаштовувати застосунок на інший.
Особливо часто це трапляється з експортною директорією: у APP_EXPORT_DIR одне значення, у volumes інший target. Застосунок пише файли «всередину контейнера», ви не бачите їх на хості, починаєте підозрювати код або формат CSV. А це чистий mismatch у Compose-моделі, який перевіряється швидше, ніж читається будь-який stack trace.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ