JavaRush /Курси /Docker for Spring /Перша версія compose.yaml...

Перша версія compose.yaml

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

1. Мета: перенести docker run у Compose

На цьому етапі всі фрагменти вже на своїх місцях: зрозуміла структура compose.yaml, зрозуміло, як у ньому живуть services, volumes і мережеві імена, а також чим .env відрізняється від environment усередині контейнера. Тепер не потрібно знову обговорювати Compose як ідею — потрібно просто зібрати все це в один робочий запуск нашого сервісу.

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

Якщо у вас була команда такого вигляду:

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

то Compose-файл — це просто той самий набір смислів, тільки не одним рядком, а структурою.

Щоб було простіше зіставити, ось невеличка таблиця-словничок. Це не новий матеріал, а шпаргалка для очей:

Що було в docker run Що стане в compose.yaml Що це означає
docker run ... IMAGE
services.app.image
або
services.app.build
звідки береться контейнер: готовий образ або збірка з Dockerfile
-p 8080:8080
ports: ["8080:8080"]
публікація порту на хості
-e NAME=VALUE
environment: { NAME: VALUE }
змінні середовища всередині контейнера
-v host:container
volumes: ["host:container"]
mount: bind mount або volume
--name ...
зазвичай не потрібно у Compose частіше живуть іменами сервісів, а не контейнерів

2. Репозиторій і шляхи для Compose

Перед тим як писати YAML, важливо на секунду зупинитися й подивитися на репозиторій як на «папку проєкту», а не як на набір файлів. Compose працює найпростіше, коли все лежить у корені поруч: Dockerfile, .dockerignore, compose.yaml, папка даних для експортів і, за потреби, .env. Тоді відносні шляхи на кшталт ./data/exports не перетворюються на квест «з якого каталогу я запускав команду».

Уявіть, що структура у нас приблизно така. Це саме орієнтир для розуміння, а не вимога до кожного символу:

docker-java-catalog-service/
|-- Dockerfile
|-- compose.yaml
|-- .env.example
|-- data/
|   `-- exports/
|       `-- .gitkeep
`-- src/...

Ключова ідея сьогоднішньої практики: bind mount для експортів має бути відносним (./data/exports), бо абсолютні шляхи швидко ламаються під час передавання проєкту між машинами. А шлях усередині контейнера ми тримаємо стабільним, наприклад /data/exports, щоб застосунок завжди писав в одне й те саме місце, а змінювалася лише зовнішня привʼязка.

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

3. Мінімальний compose.yaml і збірка

Compose-файл зручно складати як конструктор: спочатку «скелет», а потім поступово додавати важливі деталі. Це знижує шанс, що ви одразу отримаєте великий YAML, який не запускається, а ви ще не розумієте, який саме рядок винен. Ми почнемо з мінімального опису одного сервісу app, який збирається з поточного каталогу через уже знайомий Dockerfile.

Найменший робочий старт має такий вигляд:

services:
  app:
    # Збираємо образ з Dockerfile у поточному каталозі
    build: .

Цей запис означає: «в оточенні є сервіс app, і його образ треба зібрати з поточного каталогу (.)». Compose візьме ваш Dockerfile, застосує .dockerignore, збере образ і запустить контейнер.

Практичне питання, яке майже завжди спливає одразу: «А де назва образу?». Якщо ви не вкажете image: ..., Compose сам придумає назву на основі імені папки проєкту та імені сервісу. Це робочий варіант, але для навчального репозиторію зазвичай приємніше мати передбачувану назву — щоб не гадати, що саме ви зараз дивитеся в docker image ls.

Тут немає конфлікту між build і image: перше говорить Compose, звідки збирати образ, друге — під якою назвою зберегти результат. Тому цілком нормальна «трохи доросліша» версія має такий вигляд:

services:
  app:
    # Збірка так само йде з поточного каталогу
    build: .
    # Явно фіксуємо назву образу, щоб вона не залежала від назви папки
    image: docker-java-catalog-service

Тобто Compose все так само збирає сервіс із поточного каталогу, просто результат отримує передбачувану назву. Дуже зручно, коли ви порівнюєте запуск через docker build і через Compose: назва одна й та сама, менше плутанини.

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

Невелика схема, щоб відчути потік дій:

flowchart TD
  A[compose.yaml] --> B[docker compose up --build]
  B --> C[збірка: Dockerfile -> image]
  B --> D[створення контейнера сервісу app]
  D --> E[запуск ENTRYPOINT з Dockerfile]
  E --> F[запуск Spring Boot]

Compose тут не «альтернатива Dockerfile». Він просто читає Dockerfile замість вас і виконує однакові кроки щоразу.

4. Runtime-налаштування: ports, environment, volumes

Скелет Compose-файлу вже є, але поки що він описує «контейнер сам у собі», без зв’язку із зовнішнім світом. Щоб це стало корисним запуском нашого Spring Boot сервісу, нам потрібно повторити те, що раніше було в docker run: опублікувати порт, передати профіль standalone, повідомити застосунку шлях експорту й під’єднати папку експортів із хост-машини. Далі ми рухатимемося рівно за цим списком, щоб нічого не втратити.

Порт: як користувачу потрапити до сервісу

Додамо ports. Пам’ятаємо правило читання: ліворуч — хост, праворуч — контейнер. Нам потрібно, щоб на хості був доступний localhost:8080, а всередині контейнера застосунок слухав 8080.

services:
  app:
    build: .
    image: docker-java-catalog-service
    ports:
      # Публікуємо порт контейнера 8080 на хості:8080
      - "8080:8080"

Чому рядок у лапках? Бо YAML іноді намагається «розумничати» з числами та двокрапками. Лапки тут — не релігія, а звичка, яка економить нерви.

Змінні середовища: профіль і шлях експорту

Тепер додамо environment. Тут ми передаємо всередину контейнера те, що Spring Boot прочитає як змінні середовища.

services:
  app:
    build: .
    image: docker-java-catalog-service
    ports:
      - "8080:8080"
    environment:
      # Обираємо профіль запуску (наприклад, без зовнішніх сервісів)
      SPRING_PROFILES_ACTIVE: standalone
      # Куди застосунок писатиме експорт усередині контейнера
      APP_EXPORT_DIR: /data/exports

Зверніть увагу на два різні світи:

Ми вказуємо SPRING_PROFILES_ACTIVE: standalone, щоб застосунок працював у режимі без PostgreSQL/Redis/RabbitMQ, тобто без зовнішніх залежностей. Це ідеально для першого Compose-кроку: ми вивчаємо Compose, а не будуємо стенд.

Ми вказуємо APP_EXPORT_DIR: /data/exports, бо всередині контейнера у нас буде під’єднано каталог /data/exports. Ідея проста: застосунок пише «в контейнерний шлях», але цей шлях виявляється пов’язаним із папкою на вашій машині.

Іноді хочеться додати ще SERVER_PORT: 8080. Якщо у вашому проєкті типовий порт і так 8080, технічно це необов’язково, але методично корисно: ви явно фіксуєте, що застосунок слухає той самий порт, який ви опублікували.

services:
  app:
    environment:
      # Профіль Spring (впливає на конфіги, біни тощо)
      SPRING_PROFILES_ACTIVE: standalone
      # Внутрішній порт застосунку (це про контейнер, а не про хост)
      SERVER_PORT: 8080
      # Шлях для експорту всередині контейнера
      APP_EXPORT_DIR: /data/exports

Це особливо добре, коли пізніше ви будете змінювати хост-порт (наприклад, 8090:8080) і не плутати, який саме порт змінюється: зовнішній чи внутрішній.

Том/маунт: експортна папка має бути видима на хості

Тепер додамо volumes. Для експортів нам потрібен саме bind mount, бо файл результату ми хочемо бачити на своїй машині, у репозиторії.

services:
  app:
    build: .
    image: docker-java-catalog-service
    ports:
      - "8080:8080"
    environment:
      SPRING_PROFILES_ACTIVE: standalone
      SERVER_PORT: 8080
      APP_EXPORT_DIR: /data/exports
    volumes:
      # Каталог у репозиторії на хості -> каталог у контейнері
      - ./data/exports:/data/exports

Цей рядок потрібно читати зліва направо: ./data/exports на хості зв’язується з /data/exports усередині контейнера. Якщо застосунок створює файл /data/exports/catalog-2026-03-21.csv, ви побачите його як ./data/exports/catalog-2026-03-21.csv у себе в проєкті.

Якщо зібрати все в один фінальний варіант «першої версії», вийде приблизно такий compose.yaml. І так, він спеціально короткий — щоб читався з першого погляду:

services:
  app:
    # Збираємо образ з Dockerfile у поточному каталозі
    build: .
    # Фіксуємо назву образу для передбачуваності
    image: docker-java-catalog-service
    ports:
      # Хост:контейнер
      - "8080:8080"
    environment:
      # Профіль запуску застосунку
      SPRING_PROFILES_ACTIVE: standalone
      # Внутрішній порт Spring Boot
      SERVER_PORT: 8080
      # Куди писати експорт усередині контейнера
      APP_EXPORT_DIR: /data/exports
    volumes:
      # Експорти мають зʼявлятися в репозиторії на хості
      - ./data/exports:/data/exports

Це і є те, заради чого ми сьогодні все пояснювали: один файл, який повністю описує запуск.

5. Параметризація: .env і .env.example

Коли перша версія файлу запрацювала, зазвичай хочеться додати трохи гнучкості: на одній машині 8080 вільний, на іншій зайнятий, а шлях до експортів може відрізнятися. Для цього зручно винести змінні значення у .env.example, а в compose.yaml підставляти їх через ${...}. Усередину контейнера при цьому потрапить лише те, що ви явно перелічите в environment.

Почнемо з простого: зробимо .env.example — файл-документацію, який можна комітити. У ньому ми перелічимо змінні, які очікуємо:

APP_PORT=8080
SPRING_PROFILE=standalone
EXPORT_DIR_HOST=./data/exports

Зазвичай цей шаблон копіюють у локальний .env і вже там змінюють значення під свою машину:

cp .env.example .env

Тепер використаємо ті самі змінні в compose.yaml:

services:
  app:
    build: .
    image: docker-java-catalog-service
    ports:
      # Змінюємо лише хост-порт (усередині контейнера залишається 8080)
      - "${APP_PORT}:8080"
    environment:
      # Значення з .env потрапляє всередину контейнера лише тому, що ми явно передали його тут
      SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE}
      # Внутрішній порт застосунку фіксований і не залежить від хост-порту
      SERVER_PORT: 8080
      # Шлях усередині контейнера залишається стабільним
      APP_EXPORT_DIR: /data/exports
    volumes:
      # Шлях на хості беремо з .env, шлях у контейнері фіксований
      - "${EXPORT_DIR_HOST}:/data/exports"

Тут APP_PORT керує лише входом із хост-машини, SPRING_PROFILE стає змінною середовища всередині контейнера через environment, а шлях ліворуч у volumes залишається шляхом на хості. Цього вже достатньо, щоб не правити YAML вручну під кожну машину.

6. Запуск і зупинка: цикл docker compose

Compose стає справді приємним інструментом не в момент написання YAML, а тоді, коли ви кілька разів запускаєте й зупиняєте сервіс, не згадуючи довгі команди. Тому зараз закріпимо мінімальний робочий цикл — буквально три-чотири команди, які замінюють десяток ручних дій. І так, це та сама звичка, яка потім економить години.

Базовий запуск:

docker compose up --build

Що тут відбувається: Compose читає compose.yaml, за потреби перебудовує образ (через --build), створює контейнер і показує логи в терміналі. Для навчання це чудовий режим: ви очима бачите старт Spring Boot і не забуваєте, що «контейнер працює» — це насамперед лог і HTTP-відповідь, а не факт того, що команда не поскаржилася.

Якщо ви хочете запуск у від’єднаному режимі, використовуйте -d (detached mode):

docker compose up --build -d
docker compose ps

docker compose ps покаже, що контейнер живий, який порт опубліковано і, якщо в образі є HEALTHCHECK, який health-статус.

Логи можна дивитися так:

docker compose logs app
docker compose logs -f app

-f означає follow, тобто «як tail -f», тільки не треба пам’ятати, де лежить файл логів. Логи у нас ідуть у stdout/stderr, як і має бути для контейнерного життя.

Зупинити оточення й прибрати контейнери:

docker compose down

down — це «зупини й прибери створене оточення»: контейнери, мережу оточення Compose. Папка ./data/exports на хості при цьому залишиться, бо це bind mount, а не «життя всередині контейнера».

І корисно зауважити: цей цикл не змінюється, коли в сервісу з’являється сусід. Якщо поруч буде PostgreSQL, точка входу все одно залишиться docker compose up, у logs ви так само шукатимете симптоми, а різниця буде лише в тому, що у файлі стане більше ніж один service і застосунок перестане шукати залежність на localhost.

7. Швидка перевірка: HTTP та експорт на хост

Нижче для простоти вважаємо, що в .env залишилися значення за замовчуванням: APP_PORT=8080 і EXPORT_DIR_HOST=./data/exports. Якщо ви змінювали їх під свою машину, просто підставте свої значення в URL і шлях на хості.

Після docker compose up дуже легко потрапити в пастку: «ну логи наче красиві, отже працює». Красиві логи — це добре, але сервіс вважається живим тоді, коли він відповідає по HTTP. Тому робимо дві швидкі перевірки: health і одну бізнесову кінцеву точку. А потім — маленький файловий іспит: експорт має зʼявитися на хості.

Перевірка стану:

curl http://localhost:8080/actuator/health
# {"status":"UP",...}  (приблизно так; точний JSON залежить від налаштувань)

Перевірка базового API (конкретна відповідь залежить від стартових даних, але важливо, що це 200 OK і JSON):

curl http://localhost:8080/api/catalog/items
# [ ... ]  (список елементів каталогу)

Тепер найсмачніше: експорт. У навчальному сервісі у нас є кінцева точка для запуску експорту. Припустімо, вона не вимагає body (або ви використовуєте requests із requests/catalog.http). Мінімально це може мати такий вигляд:

curl -X POST http://localhost:8080/api/catalog/exports
# {"id":1,"status":"COMPLETED",...}  (приклад)

Після цього дивимося папку на хості:

ls -la ./data/exports
# тут має зʼявитися новий .csv (назва залежить від реалізації)

Якщо файл зʼявився в ./data/exports, отже зв’язка «APP_EXPORT_DIR всередині контейнера → bind mount → папка на хості» справді працює. Це важливіше, ніж здається: саме на таких простих сценаріях новачки найчастіше розуміють, що контейнер — це не магічна коробка, а процес із файловою системою та чітко заданими межами.

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

Ця тема здається простою рівно до першої ситуації «Compose не підіймається, а я нічого не змінював (чесно-чесно)». На практиці більшість помилок не складні, а саме побутові: відступ не там, змінна не підставилася, шлях під’єднали не туди. Нижче — набір найчастіших граблів, які трапляються саме на першій версії compose.yaml з одним сервісом.

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

Помилка № 2: очікувати, що ${APP_PORT}:8080 змінює порт застосунку всередині контейнера.
Підстановка в ports змінює лише те, який порт на хості публікується назовні. Внутрішній порт застосунку задається самим застосунком (через SERVER_PORT, application.yml або аргументи), і праворуч у ports має стояти саме той порт, який слухає процес у контейнері. Тому логіка «давайте підставимо змінну праворуч» часто закінчується недоступним сервісом і питанням «чому localhost мовчить».

Помилка № 3: плутати .env (підстановка) і environment (змінні середовища всередині контейнера).
.env допомагає Compose підставити значення в YAML, але це не означає, що Spring Boot автоматично побачить кожну змінну з .env. Щоб застосунок побачив змінну, вона має потрапити в environment: (або ви маєте явно передати її іншим способом). Якщо ви написали SPRING_PROFILE=standalone у .env, але забули SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE} у environment, застосунок запуститься з типовими профілями, і ви будете дивуватися: «чому не той режим».

Помилка № 4: забути bind mount для експортів і потім «шукати файл всередині контейнера».
Якщо ви не під’єднали ./data/exports:/data/exports, файл справді буде створено всередині контейнера (у writable layer), і ви його не побачите в репозиторії. Формально «експорт працює», практично — результат зникає після повторного створення контейнера, а ви отримуєте ілюзію, що «фіча нестабільна». У навчальному проєкті експорт спеціально вибрано як сценарій, де bind mount дає миттєво відчутну користь.

Помилка № 5: хардкодити абсолютний шлях хост-машини в volumes.
Рядок на кшталт /Users/alex/projects/catalog/data/exports:/data/exports може працювати в автора, але перетворюється на камінь у черевику для всіх інших. На Windows і macOS це ще веселіше через відмінності у форматах шляхів. Відносний шлях ./data/exports майже завжди найкращий старт, бо він прив’язаний до репозиторію, а не до вашої конкретної машини.

1
Опитування
Docker Compose, рівень 15, лекція 4
Недоступний
Docker Compose
Збирання та запуск середовища
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ