Docker Compose

Docker for Spring
Рівень 15 , Лекція 0
Відкрита

1. docker run: добре для одного контейнера

Після перших кроків із Docker легко закохатися в docker run: одна команда — і сервіс уже працює. Це схоже на «я зараз швидко щось накину в терміналі» — а за пів року цей «накину» уже живе в продакшені, але давайте не будемо про це. Для одного контейнера docker run справді виглядає прийнятно, особливо якщо ви запускаєте все самі, на своїй машині й у знайомому середовищі.

Коли у вас є один сервіс і в нього майже немає налаштувань, docker run — цілком робочий інструмент. Він чесний: ви явно бачите, який образ запускаєте, які порти публікуєте й які змінні середовища передаєте всередину. Для першого контейнерного досвіду це навіть методично корисно: ви відчуваєте на практиці, що контейнер — це процес, що порт усередині контейнера і порт на хості — різні сутності, і що «параметри запуску» — не якась магія, а конкретні прапорці.

Але щойно цей «один контейнер» починає обростати реальністю (а реальність зазвичай не питає дозволу), команда перестає бути командою і перетворюється на маленький роман. І, що ще гірше, роман без змісту: усе в одному рядку, а сенс читається гірше, ніж стек-трейс на три екрани.

Щоб бути зовсім чесними: проблема не в тому, що docker run «поганий». Проблема в тому, що docker run — це спосіб запуску, а не спосіб зберігати опис середовища. Він відповідає на запитання «як мені стартувати зараз», але погано відповідає на запитання «як мені гарантовано стартувати так само завтра» — і тим більше «як стартує інша людина на іншій машині».

2. Як розростається команда docker run

У якийсь момент ви помічаєте, що запуск контейнера перестав бути «просто запустити». Зʼявляються порт, профіль застосунку, змінні середовища, каталоги, які треба змонтувати, і раптом виникає потреба повторювати один і той самий запуск знову і знову. І ось у цей момент команда починає розростатися.

Погляньмо на абсолютно нормальний, життєвий запуск нашого навчального сервісу. Нічого «просунутого» — просто те, що ви вже вмієте:

# Запуск контейнера з явними параметрами середовища.
# Важливо: усе це тримається на тому, що ви не забудете жодного прапорця.
docker run --name catalog-app -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=standalone \
  -e APP_EXPORT_DIR=/data/exports \
  -v ./data/exports:/data/exports \
  docker-java-catalog-service

Якщо дивитися на це очима людини, яка розуміє, що відбувається, тут усе логічно. Ми задаємо імʼя контейнера, щоб до нього було зручно звертатися, публікуємо порт, вмикаємо профіль standalone, кажемо застосунку, куди писати експорт, і монтуємо локальний каталог, щоб файли експорту були видимі на локальній машині.

Проблема в тому, що це вже не «одна команда». Це набір домовленостей, які тримаються на вашій увазі. Варто забути один параметр — і застосунок або змінює поведінку, або «працює, але не так». А найнеприємніше: він може не впасти, а тихо поводитися інакше. У бекенд-розробці це один із найдорожчих класів помилок: усе зелене, але дані «чомусь не там».

Тепер уявіть, що ви вирішили «швидко» змінити порт (наприклад, 8080 зайнятий). Начебто дрібниця:

# Так, порт змінили.
# Але зверніть увагу: тут легко випадково втратити важливі параметри (env/volumes),
# і контейнер при цьому цілком запуститься.
docker run --name catalog-app -p 8090:8080 \
  -e SPRING_PROFILES_ACTIVE=standalone \
  docker-java-catalog-service

Команда стала коротшою… і ось тут починається магія навпаки. Ви легко можете випадково викинути APP_EXPORT_DIR і монтування, тому що «ну я ж тільки порт змінив». Контейнер запуститься, API відкриється, /actuator/health скаже, що все добре. А потім ви запустите експорт і почнете шукати файл на хості — і не знайдете. Тому що він опиниться всередині контейнера, у writable layer, і житиме рівно до першого повторного створення контейнера.

У новачків це викликає відчуття «Docker непередбачуваний». Насправді Docker дуже передбачуваний. Непередбачувані ми, коли тримаємо середовище в голові та в історії термінала.

Можна уявити це як запуск застосунку «по памʼяті». Поки система маленька — терпимо. Щойно параметрів стає багато — памʼять перетворюється на джерело випадкових багів. У цей момент ми починаємо хотіти не новий прапорець, а новий підхід: зберігати опис запуску як документ, а не як «промовте заклинання в терміналі й не помилися».

3. Дрейф запуску: один забутий прапорець

Є особливий тип болю, який люблять усі розробники: «в мене працює». У контексті Docker це часто звучить так: «я запускаю контейнер, усе ок», а колега запускає «майже так само», і в нього «чомусь» не ок. І ви обоє чесно маєте рацію — тому що запускаєте не одне й те саме.

Дрейф починається дуже рано. Ось ви в README написали команду. Потім додали до проєкту експорт. Потім зрозуміли, що шлях експорту має бути параметром. Потім додали монтування. Потім помітили, що порт може конфліктувати. Потім вам знадобився інший профіль. І ось у README вже не одна команда, а чотири варіації, кожна «майже як попередня, але трішки інша».

Найпідступніший момент: docker run не допомагає вам зрозуміти, наскільки «схожа» одна команда на іншу. Це просто рядки. Людина очима порівнює 6–8 прапорців — і легко пропускає один. У підсумку ви отримуєте ситуацію, де контейнер називається однаково (catalog-app), образ той самий (docker-java-catalog-service), а реальне середовище — різне.

Щоб відчути, наскільки це небезпечно, достатньо пригадати, що ми вже вміємо налаштовувати застосунок через зовнішню конфігурацію: профілі, змінні середовища, параметри. Це правильно й потрібно. Але саме через цей правильний підхід стає ще важливіше не губити частину параметрів під час запуску.

У живій розробці це виглядає дуже «по-людськи». Наприклад, ви оновили гілку проєкту, пересклали образ, запускаєте контейнер… і забули передати SPRING_PROFILES_ACTIVE. Застосунок стартує в режимі за замовчуванням, може використовувати не той репозиторій (in-memory замість очікуваного), не ті налаштування, інший порт. І ви витрачаєте час не на розробку, а на зʼясування «чому воно раптом стало іншим, хоча я нічого не змінював».

Тут і зʼявляється основна думка сьогоднішнього дня: конфігурація середовища не має бути «в голові» та «в історії команд». Її потрібно зробити артефактом проєкту — як Dockerfile, як build.gradle.kts, як application.yml. Тобто файлом, який можна читати, перевіряти, ревʼювати й змінювати усвідомлено.

4. Термінал і README як сховище середовища

На цьому місці зазвичай виникає контраргумент: «Ну окей, я просто напишу в README правильну команду, і все». Це звучить розумно… рівно доти, доки ви не спробуєте підтримувати це хоча б тиждень.

README — це документація. Він хороший для пояснення: що за проєкт, як зібрати, як запустити. Але README погано працює як джерело правди для середовища. Причина проста: README — це текст, який ніхто не зобов’язаний виконувати буквально. Ви його читаєте, руками копіюєте шматок, щось правите, запускаєте… а далі в кожної людини в команді зʼявляється «свій трохи виправлений варіант». І починається розповзання.

Історія термінала ще гірша. Вона взагалі не призначена бути конфігурацією. У ній можна випадково знайти «правильну» команду, але її дуже важко відрізнити від «майже правильного» варіанту, і майже неможливо зрозуміти контекст: чому саме ці значення, чому саме цей порт, чому цей параметр зʼявився і що він означає.

Є ще одна важлива річ: команда в терміналі — це одноразовий акт. Файл — це об’єкт, який живе в репозиторії та розвивається разом із проєктом. У файлу є diff. У файлу є історія змін. У файлу є рев’ю. Якщо команда змінилася — ви це помітите, бо змінився файл. Якщо команда «змінилася в голові» — ви не помітите, доки не отримаєте помилку.

Це особливо помітно, коли ви не один. А бекенд-проєкт майже завжди «не один»: сьогодні ви, завтра колега, післязавтра нова людина, яка проходить onboarding. І ось тут відбувається найнеприємніша річ: замість того щоб довіряти проєкту, люди починають довіряти «тому, хто вже запускав». Зʼявляється залежність від носія знань: «спитай у Петі, як стартувати». Петя, звісно, герой. Але Петя іноді у відпустці. А ще в Петі може бути не той порт.

Docker узагалі-то розв’язує проблему відтворюваності. Але якщо запуск середовища живе в командах, які щоразу набираються руками, ми самі повертаємо собі ту саму проблему — тільки тепер вона виглядає «контейнерно».

5. Файл як єдиний опис середовища

Якщо відкинути красиві слова, нам потрібна дуже проста річ: один артефакт, де описано «як запускати» — так само, як Dockerfile описує «як збирати». І саме це дає Docker Compose: опис середовища у файлі.

Важливо: на цьому кроці нам не потрібен повний список YAML-ключів. Нам важливо побачити саму зміну ролі — запуск перестає бути рядком і стає структурою.

Погляньте, як та сама ідея виглядає у файлі:

services:
  app:
    image: docker-java-catalog-service
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: standalone

Навіть на такому короткому фрагменті видно головне: запуск перестав бути рядком із прапорцями та став деревом параметрів. Порти описані окремо, змінні середовища — окремо, і якщо сервісу потрібні монтування або додаткові налаштування, вони додаються в той самий каркас, а не губляться в історії термінала.

Тут зʼявляється дуже корисна інженерна характеристика: по файлу видно, що ви забули. У командному рядку легко загубити прапорець. У YAML-структурі легше помітити, що в сервісу взагалі немає volumes, хоча експорт має виходити назовні, або що забули профіль, хоча застосунок має стартувати в конкретному режимі.

Ще одна річ, яка раптом стає можливою: ви можете робити зміни так, як ви робите зміни в коді. Наприклад, «давайте винесемо порт у змінну», «давайте додамо ще один параметр», «давайте перейменуємо сервіс». Ви робите це diffʼом. Це не «в мене тепер інша команда», це «у проєкту тепер інша конфігурація середовища». І це якісно інший рівень дисципліни.

Якщо вам близька аналогія: docker run — це як запускати застосунок через вікно «Run» і щоразу руками набирати аргументи. Compose — це як тримати конфігурацію запуску поруч із проєктом, а запускати однією короткою командою. Тільки без кнопки — ми все одно залишаємося CLI-людьми, і це прекрасно.

6. Compose: файл + короткі команди

Головна думка тут проста: Compose потрібен не тому, що в нас уже є база даних і половина датацентру. Він починає виправдовувати себе раніше — у той момент, коли ви хочете повторювано підіймати те саме середовище.

Compose дає модель, у якій «опис середовища» лежить у файлі, а команда стає короткою:

# Запустити середовище, яке вже описано в compose-файлі.
docker compose up --build

Команда більше не несе весь тягар параметрів. Вона просто каже: «підійми середовище, описане у файлі». Це дуже здорова зміна ролей: замість того щоб кожну деталь тримати в одному рядку, ми переносимо деталі туди, де їм місце — у конфігураційний документ.

Щоб закріпити це як картинку, ось маленька схема, чим відрізняється «командний підхід» від «файлового»:

flowchart TD
  A["Довга команда docker run
у терміналі/README"] --> B["Копіювання та ручні правки"] B --> C["Запуск на різних машинах"] C --> D["Дрейф середовища
і «в мене працює»"] E["compose.yaml як артефакт проєкту"] --> F["Коротка команда docker compose up"] F --> G["Однаковий запуск для всіх"] G --> H["Передбачуване середовище"]

Compose тут не «вмикає суперсили». Він просто переносить конфігурацію запуску з рядка у файл. Це рівно те, що ми робили раніше на інших рівнях. Ми не пишемо Gradle-команди на памʼять — у нас є build.gradle.kts. Ми не запускаємо застосунок «бо так сказав Петя» — у нас є application.yml. Тепер те саме відбувається з контейнерним середовищем: замість «набору команд» зʼявляється «опис середовища».

Поки сервіс один, цього вже достатньо. А коли поруч зʼявиться сусідній контейнер, ви не вигадуватимете новий спосіб старту — ви просто розширите той самий опис середовища.

7. Типові помилки під час роботи з Docker Compose

Помилка № 1: думати, що Compose потрібен лише тоді, коли зʼявиться база даних.
Це дуже поширена логіка, і вона здається розумною: «один контейнер — руками, два контейнери — Compose». На практиці Compose починає виправдовувати себе раніше: у той момент, коли один і той самий запуск треба повторювати, пояснювати, підтримувати й не ламати випадковою правкою команди. Якщо ви запускаєте сервіс щодня, вам потрібен не другий контейнер, а відтворюваність.

Помилка № 2: вважати довгу команду в README повноцінною заміною конфігурації середовища.
README важливий, але він не є «джерелом правди». Люди копіюють команду, редагують її «трохи», забувають один прапорець, і зʼявляється нова «локальна версія реальності». Потім ви дивитеся на баг і не розумієте, це проблема коду чи просто різні параметри запуску. Файл середовища вирішує саме це: він фіксує параметри як проєктний артефакт, а не як переказану з уст в уста традицію.

Помилка № 3: зберігати кілька схожих docker run команд і вручну підтримувати їхню синхронність.
Спочатку у вас «звичайний запуск», потім «запуск на іншому порту», потім «запуск з експортом», потім «запуск без експорту» — і кожна команда починає жити своїм життям. У підсумку ви витрачаєте час на підтримку набору рядків замість одного опису середовища. Саме для цього й потрібен підхід Compose: повернути контроль і зробити запуск єдиним та передбачуваним.

Помилка № 4: сприймати перехід до Compose як перехід до “магії”, а не як до дисципліни.
Якщо ставитися до Compose як до «ще одного способу щось запустити», він не дасть головної користі. Його сенс у тому, що середовище стає читабельним, версіонованим і перевірюваним. Це не магія — це просто перенесення конфігурації в правильне місце. Саме тому ми починаємо з мотивації й болю, а не з переліку YAML-ключів.

Помилка № 5: не розуміти, що команда стала короткою, а відповідальність нікуди не зникла.
Коли ви переходите на Compose, легко розслабитися: «ну тепер усе у файлі». Але якщо ви руками правите compose.yaml, робите локальні «тимчасові» зміни й не зафіксовуєте їх як частину проєкту, ви отримуєте той самий дрейф — тільки тепер він живе не в терміналі, а у файлі, який не зафіксували в проєкті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ