1. Схема БД після app + postgres
Коли ви вперше підняли app + postgres і побачили, що контейнер PostgreSQL має статус healthy, легко виникає хибне відчуття перемоги: «Ну й усе, база є, отже застосунок має працювати». Це приблизно як радіти, що у вас є холодильник, — ще до того, як ви перевірили, чи є в ньому їжа і чи не відключили його від розетки. Для бекенда «база піднялася» — це лише половина історії.
Давайте розмежуємо дві схожі, але принципово різні думки. Перша: PostgreSQL як процес уже запущена і готова приймати TCP-з’єднання на порту 5432. Це про мережу та readiness. Друга: PostgreSQL як база даних містить потрібні таблиці, індекси, обмеження, і все це відповідає очікуванням нашого застосунку. Оце вже про схему та її стан.
У нашому наскрізному проєкті Container-Ready Catalog Service застосунок у профілі postgres використовує JPA і очікує, що в базі є таблиці на кшталт catalog_item і, ймовірно, таблиця для експортних джобів. Якщо цих таблиць немає, то навіть ідеально healthy PostgreSQL перетворюється для застосунку на «дуже самовпевнений порожній блокнот»: з’єднання є, а працювати ні з чим.
І тут з’являється зріла інженерна думка: стан схеми БД має бути частиною відтворюваного запуску, так само як compose.yaml — частина відтворюваного підняття контейнерів. Інакше Docker ніби розв’язує проблему «як запустити сервіс», але залишає прогалину: «а як привести дані до правильного стану».
2. Міграції схеми БД без «DevOps-магії»
Коли люди чують слово «міграції», у новачка часто вмикається режим тривоги: «Ой, це щось зі світу адміністраторів баз даних, мені туди не можна». Насправді міграції в нашому контексті — це максимально розробницька річ. Це спосіб зберігати зміни схеми як код, поруч із вашим застосунком, щоб будь-який розробник міг отримати правильний стан бази однією командою запуску.
Схема БД — це, простими словами, «як улаштовані таблиці»: які вони, які в них поля, які обмеження, які індекси. Якщо в Java-коді ви додали нове поле в сутність або бізнес сказав: «Потрібно зберігати статус товару», — у базі має з’явитися відповідна зміна. І ось міграція — це маленький SQL-скрипт (або інший формат, але в курсі ми тримаємося SQL), який описує одну конкретну зміну: створити таблицю, додати колонку, створити індекс, заповнити мінімальні стартові дані.
Ключовий момент: міграції — це не «разова підготовка бази руками автора курсу». Міграції — це джерело істини про те, якою має бути схема. Вони лежать у репозиторії, подорожують разом із кодом і виконуються автоматично під час старту застосунку (у нашому випадку через Flyway). А отже, новий розробник або ви через тиждень, коли все забудете, не шукатимете в чаті: «А хто пам’ятає, які таблиці треба було створити?».
Так, міграції — це не про «красиву теорію». Це про банальну економію нервів. Бо ручне створення таблиць у локальній базі майже завжди закінчується тим, що в кожного розробника схема трохи своя. А «трохи» для бази — це як «трохи» інший пароль: не працює.
3. Flyway під час старту Spring Boot
Після слова «міграції» зазвичай виникає друге питання: «Добре, міграції є. А хто їх запускає?» І ось тут Flyway виступає в ролі дуже дисциплінованого менеджера: він приходить на роботу раніше за всіх, перевіряє, що вже зроблено, виконує відсутні зміни й лише після цього дозволяє застосунку справді стартувати. Причому робить це не як окрема утиліта десь збоку, а як частина нормального старту застосунку Spring Boot.
У нашому проєкті Flyway — це бібліотека. Ви підключаєте залежність, Spring Boot бачить її на classpath, бачить, що є DataSource, і автоматично вмикає крок міграцій у конвеєрі запуску. Це схоже на автонастроювання, до якого ми вже звикли: ви не пишете вручну «створи мені Flyway і виклич migrate()», а просто вмикаєте потрібну залежність і даєте коректну конфігурацію.
Дуже важливо зафіксувати послідовність. У контейнерному світі застосунок вважається «живим» рівно доти, доки живе основний процес. Для Spring Boot це JVM-процес. Якщо Flyway під час старту не зміг застосувати міграції, Spring Boot зазвичай не доходить до стану «Started … in N seconds», контекст не підіймається, процес завершується, і контейнер застосунку або зупиняється, або переходить у перезапуски, якщо ви ввімкнули restart policy. Але в нашому базовому навчальному стенді ми на цьому не зосереджуємося.
Зручно уявити старт як ланцюжок кроків, де кожен наступний залежить від попереднього:
flowchart TD
%% Важливо: healthcheck Postgres — це про мережу та доступність процесу, а не про готову схему
A["docker compose up"] --> B["Контейнер PostgreSQL стартує"]
B --> C["Healthcheck (pg_isready) = healthy"]
%% Flyway вбудований у старт застосунку і виконується ДО фінального Started
C --> D["Контейнер застосунку стартує"]
D --> E["Spring Boot підіймає ApplicationContext"]
E --> F["Створюється DataSource (підключення до postgres)"]
F --> G["Flyway застосовує міграції"]
G --> H["Застосунок доходить до Started і починає слухати порт"]
Зверніть увагу на тонкість: readiness бази (pg_isready) гарантує лише те, що база готова приймати з’єднання, але не гарантує, що схема в правильному стані. Flyway — це наступний контрольний пункт, який перетворює просто доступну базу на базу, придатну для роботи застосунку.
До цього локально могли траплятися простіші підходи: хтось створює таблиці вручну, хтось залишає spring.jpa.hibernate.ddl-auto, щоб Hibernate сам підлаштовував схему. Для швидкого експерименту це інколи допустимо, але в postgres-режимі курсу власником еволюції схеми стає Flyway. Hibernate тут не має паралельно конкурувати з migration files, інакше в проєкті з’являться два джерела істини: одне живе в SQL-файлах, інше — у runtime-поведінці ORM.
4. Підключення Flyway і профіль postgres
Тепер давайте приземлимо ідею на наш Container-Ready Catalog Service. Тут важливо побачити, що Flyway не «живе в Docker Compose окремим контейнером» за замовчуванням, не є «якоюсь утилітою, яку ви запускаєте вручну», і не вимагає окремого сервісу в compose.yaml. У нашому навчальному baseline Flyway — це частина застосунку, а отже живе там само, де живе код.
На рівні Gradle це виглядає дуже буденно: додали залежність — і все. У build.gradle.kts це може виглядати приблизно так, дуже спрощено й лише з тим, що зараз важливо:
dependencies {
// JPA: у профілі postgres працюємо через ORM і DataSource
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// Flyway: під час старту підтягне схему до очікуваного стану
implementation("org.flywaydb:flyway-core")
// Драйвер Postgres: без нього застосунок просто не зможе підключитися до бази
runtimeOnly("org.postgresql:postgresql")
}
Сенс простий: JPA нам потрібна для роботи з Postgres у профілі postgres, драйвер PostgreSQL потрібен, щоб узагалі було чим підключитися, а Flyway потрібен, щоб привести схему до очікуваного стану на старті. Spring Boot через своє автонастроювання з’єднує це в єдиний механізм.
Далі Flyway має підключатися до тієї самої бази, що й застосунок. Тому в профілі postgres (наприклад, у application-postgres.yml) Flyway стоїть поруч із налаштуваннями datasource, і це дуже логічно. Приблизно так:
spring:
datasource:
# JDBC-URL до Postgres
url: jdbc:postgresql://postgres:5432/catalog
# Обліковий запис, під яким застосунок підключається до БД
username: catalog
password: catalog
flyway:
# У навчальному проєкті міграції — частина старту (за замовчуванням і так true, але явно — спокійніше)
enabled: true
Зверніть увагу, що тут host — це postgres, тобто ім’я сервісу всередині Compose. Це той самий принцип, який ми закріпили раніше: всередині Compose localhost майже завжди означає «сам контейнер», а не «сусідній сервіс».
І нарешті, найконтейнерніше: Flyway починає мати сенс лише тоді, коли застосунок реально запущено в postgres-режимі, тобто коли активовано профіль postgres. У compose.yaml ми робимо це через змінну середовища. У мінімальному вигляді це виглядає так:
services:
app:
environment:
# Увімкнути профіль, у якому є зовнішній DataSource і, відповідно, Flyway
SPRING_PROFILES_ACTIVE: postgres
depends_on:
postgres:
# Чекаємо, поки Postgres буде готовий приймати TCP-з’єднання
condition: service_healthy
Це місце дуже показове: Compose гарантує порядок і мінімальну готовність залежності, Spring Boot-профіль перемикає режим зберігання, а Flyway стає частиною шляху старту застосунку саме в цьому режимі.
5. Ready-сигнали та готовність застосунку
Коли ми вивчали readiness-модель, ми боролися з ефектом «іноді стартує, іноді ні». Ми додали healthcheck, зробили depends_on.condition: service_healthy, і тепер застосунок перестав падати лише тому, що база «не встигла прокинутися». Це був важливий крок. Але він усе ще вирішує тільки мережеву сторону готовності.
З появою Flyway з’являється другий шар готовності — логічний. Застосунок може чудово підключитися до бази, але впасти, тому що міграції не застосувалися. І це не помилка Docker і не «примха Spring», а чесний сигнал: база доступна, але привести її до очікуваного стану не вдалося.
Щоб не плутатися, корисно тримати в голові просту таблицю відмінностей. Вона дуже неакадемічна, зате чудово рятує від хаосу в голові:
| Сигнал | Про що говорить | Чого не гарантує |
|---|---|---|
| postgres = healthy | PostgreSQL готова приймати з’єднання | Що схема вже створена і відповідає очікуванням застосунку |
| контейнер app запущено | JVM-процес стартував | Що застосунок дійшов до робочого стану і почав відповідати на HTTP |
| у логах є Successfully applied ... migrations | Flyway успішно застосував міграції | Що далі не буде проблем, але принаймні схема вже піднялася |
| у логах є Started ... | Spring Boot дійшов до стану «піднявся» | Що конкретний бізнес-ендпоінт працює, але це вже інший шар перевірок |
З практичної точки зору це означає, що, починаючи від сьогодні, слово «стартував» треба вимовляти обережніше. «Стартував Postgres» — одне. «Стартував застосунок» — інше. А «стартував застосунок і міграції пройшли» — це вже третій, доросліший рівень упевненості.
І так, це саме той момент, коли розробник перестає дивитися на контейнер як на «магічну коробку» і починає бачити ланцюжок причин і наслідків. Іноді це трохи сумно, бо тепер у нас більше відповідальності. Але й радісно, бо тепер у нас менше магії та більше контролю.
6. Логи та workflow Flyway
Успішний старт: логи Flyway
Ми вже звикли вважати логи не «шумом», а життєво важливим сигналом. З Flyway це стає ще важливішим, бо міграції — частина старту. Тому успішний старт застосунку в postgres-режимі тепер має характерний малюнок у логах: спочатку Spring Boot починає підійматися, потім з’являється блок Flyway, а потім застосунок доходить до “Started”.
У реальності рядки можуть відрізнятися, залежно від версії, формату логера та деталей, але сенс зазвичай упізнаваний. Приклад того, як логічно виглядає успішний старт:
# Запуск JVM/застосунку
app-1 | Starting CatalogApplication
# Flyway увімкнувся і почав перевірку та застосування міграцій
app-1 | Flyway Community Edition ...
# Ключовий сигнал: міграції успішно застосовано
app-1 | Успішно застосовано 2 міграції
# Фінальний сигнал: Spring Boot дійшов до Started
app-1 | Запущено CatalogApplication
У цьому фрагменті важливо не те, що там «2 міграції» (це число зміниться, коли проєкт розвиватиметься), а те, що послідовність показує: міграції знаходяться між початком старту та фінальним “Started”.
І ось тут у новачків часто ламається старий рефлекс: «Я побачив, що контейнер app у статусі Up — отже, все добре». Тепер це не зовсім так. Контейнер справді може бути у статусі Up перші секунди, але якщо Flyway падає, Spring Boot не підійметься, процес завершиться, і контейнер стане Exited. Тому дивитися в логи — це не «коли все погано», а нормальна частина запуску.
Для Compose це особливо зручно, бо можна читати потік логів одразу по всьому стеку. Наприклад, у якийсь момент ви побачите, що Postgres піднявся, а потім застосунок відпрацював міграції. Команди ми вивчали раніше, а тут просто закріплюємо думку, що вони потрібні щодня.
Flyway у робочому процесі
У якийсь момент хочеться запитати: «А можна просто один раз підняти базу, вручну створити таблиці й далі не мучитися?» Можна. Саме так і робили тисячі проєктів, і саме тому тисячі проєктів потім страждали. Бо ручний стан бази — це як «я точно пам’ятаю пароль, не записував». До першої відпустки, зміни ноутбука або появи нового колеги.
Docker і Compose зробили наш запуск середовища відтворюваним на рівні контейнерів: однакові версії Postgres, однакові порти, однакові env vars, однакові команди. Flyway робить відтворюваним те, що всередині бази: таблиці, їхню структуру, мінімальний стартовий стан і послідовність змін.
Особливо важлива ця думка у зв’язці з named volume PostgreSQL. Ми вже знаємо, що дані бази живуть довше за контейнер. Це означає, що база може пережити десятки up/down, і разом із нею переживе і схема, і історія міграцій. Flyway під час чергового старту не буде щоразу все створювати заново: він уміє розуміти, які міграції вже було застосовано, а які ще ні, і виконує лише відсутні. У підсумку локальне середовище стає не просто «запусканим», а таким, що живе в зрозумілій еволюції.
І ось чому сьогодні ми називаємо Flyway частиною старту застосунку. Бо на рівні звички це має виглядати так: ви підняли стек — Flyway відпрацював — схема відповідає версії коду — ви займаєтеся задачею, а не шаманством навколо бази.
Тут достатньо однієї робочої думки: міграції — такий самий артефакт проєкту, як Dockerfile або compose.yaml, а Flyway — механізм, який робить цей артефакт виконуваним під час старту.
7. Типові помилки під час роботи з Flyway
У цій темі помилки часто трапляються не тому, що «складно», а тому, що в голові змішуються різні рівні готовності: контейнери, мережа, база як процес і база як схема. Додає проблем і звичка «швидко поправити руками», яка на короткій дистанції здається порятунком, а на довгій перетворюється на постійну загадку. Давайте проговоримо найчастіші пастки.
Помилка № 1: сприймати Flyway як окремий ручний крок «збоку».
Новачок іноді думає, що міграції потрібно запускати окремо: окремою командою, окремою утилітою, окремим контейнером. У реальних командах так іноді роблять, але в нашому навчальному baseline Flyway вбудований у Spring Boot-застосунок і працює як частина старту. Це важливо методично: ви маєте бачити міграції в логах поруч зі стартом і розуміти, що без них застосунок не «готовий».
Помилка № 2: вважати, що якщо PostgreSQL healthy, то застосунок зобов’язаний стартувати.
Healthcheck бази вирішує лише readiness бази як сервісу. Але застосунок може впасти, бо міграції не застосувалися або застосувалися не повністю. Це неприємно, але чесно: база доступна, а схема не готова. Відтепер «healthy postgres» — це «можна починати старт застосунку», а не «все вже готово».
Помилка № 3: очікувати міграції в будь-якому профілі, зокрема в standalone.
У standalone-режимі в нас немає зовнішньої бази даних: зберігання in-memory, швидкий запуск, мінімум залежностей. Тому Flyway — історія саме профілю postgres, де є реальний DataSource. Якщо ви запускаєте standalone і «не бачите Flyway у логах», це не поломка, а правильна поведінка.
Помилка № 4: «виправляти» проблему міграцій вимкненням Flyway.
Іноді хочеться поставити spring.flyway.enabled=false, щоб застосунок «хоча б стартував». Це схоже на ідею: «лампочка перегоріла — заклею її скотчем». Застосунок може піти далі, але далі він почне падати вже під час роботи з таблицями, і ви втратите головне джерело правди про схему. Якщо міграції ламаються — це сигнал виправити джерело істини, тобто міграції, а не вимикати механізм.
Помилка № 5: правити схему вручну прямо в базі й забувати оновити міграції.
Ручні зміни в локальній БД створюють «дрейф стану»: у вас схема одна, у сусіда інша, у CI третя, якщо він узагалі є, а у майбутнього вас через тиждень — четверта. Flyway задуманий як спосіб тримати схему версіонованою та відтворюваною. Щойно ви робите «маленьку ручну правку» і не фіксуєте її міграцією, ви знову повертаєтеся у світ хаосу, від якого Docker і Compose нас, власне, і намагалися врятувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ