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 | Что это означает |
|---|---|---|
|
или |
откуда берётся контейнер: готовый image или сборка из Dockerfile |
|
|
проброс порта на host |
|
|
env vars внутри контейнера |
|
|
mount: bind mount или volume |
|
обычно не нужно | в 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:
# Собираем image из Dockerfile в текущей директории
build: .
Эта запись означает: «в окружении есть сервис app, и его image надо собрать из текущей директории (.)». Compose возьмёт ваш Dockerfile, применит .dockerignore, соберёт image и запустит контейнер.
Практический вопрос, который почти всегда всплывает сразу: «А где имя образа?». Если вы не укажете image: ..., Compose сам придумает имя на основе имени папки проекта и имени сервиса. Это рабочий вариант, но для учебного репозитория обычно приятнее иметь предсказуемое имя — чтобы не гадать, что именно вы сейчас смотрите в docker image ls.
Здесь нет конфликта между build и image: первое говорит Compose, откуда собирать образ, второе — под каким именем сохранить результат. Поэтому вполне нормальная «чуть-чуть взрослее» версия выглядит так:
services:
app:
# Сборка всё так же идёт из текущей директории
build: .
# Явно фиксируем имя image, чтобы оно не зависело от названия папки
image: docker-java-catalog-service
То есть Compose всё так же собирает сервис из текущей директории, просто результат получает предсказуемое имя. Очень удобно, когда вы сравниваете запуск через docker build и через Compose: имя одно и то же, меньше путаницы.
Пока что это всё ещё не похоже на запуск реального приложения, потому что мы не указали ни порты, ни профиль, ни экспортную директорию. Но важное уже сделано: мы перенесли факт сборки из терминала в файл.
Небольшая картинка, чтобы почувствовать поток действий:
flowchart TD A[compose.yaml] --> B[docker compose up --build] B --> C[build: Dockerfile -> image] B --> D[create: контейнер сервиса app] D --> E[запуск ENTRYPOINT из Dockerfile] E --> F[Spring Boot стартует]
Compose здесь не «альтернатива Dockerfile». Он просто читает Dockerfile вместо вас и делает одинаковые шаги каждый раз.
4. Runtime-настройки: ports, environment, volumes
Скелет Compose-файла уже есть, но он пока описывает «контейнер сам в себе», без связи с внешним миром. Чтобы это стало полезным запуском нашего Spring Boot сервиса, нам нужно повторить то, что раньше было в docker run: пробросить порт, передать профиль standalone, сообщить приложению путь экспорта и примонтировать папку экспортов с host-машины. Дальше мы будем двигаться ровно по этому списку, чтобы ничего не потерять.
Порт: как пользователю попасть в сервис
Добавим ports. Помним правило чтения: слева — host, справа — контейнер. Нам нужно, чтобы на host был доступен localhost:8080, а внутри контейнера приложение слушало 8080.
services:
app:
build: .
image: docker-java-catalog-service
ports:
# Публикуем порт контейнера 8080 на host:8080
- "8080:8080"
Почему строка в кавычках? Потому что YAML иногда пытается «умничать» с числами и двоеточиями. Кавычки тут — не религия, а привычка, которая экономит нервы.
Переменные окружения: профиль и путь экспорта
Теперь добавим environment. Здесь мы передаём внутрь контейнера то, что Spring Boot прочитает как env vars.
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
# Внутренний порт приложения (это про контейнер, а не про host)
SERVER_PORT: 8080
# Путь для экспорта внутри контейнера
APP_EXPORT_DIR: /data/exports
Это особенно хорошо, когда позже вы будете менять host-порт (например, 8090:8080) и не путаться, какой именно порт меняется: внешний или внутренний.
Том/маунт: экспортная папка должна быть видна на host
Теперь добавим 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:
# Bind mount: папка в репозитории на host -> папка в контейнере
- ./data/exports:/data/exports
Эту строку нужно читать слева направо: ./data/exports на host связывается с /data/exports внутри контейнера. Если приложение создаёт файл /data/exports/catalog-2026-03-21.csv, вы увидите его как ./data/exports/catalog-2026-03-21.csv у себя в проекте.
Если собрать всё в один финальный вариант «первой версии», получится примерно такой compose.yaml (и да, он специально короткий — чтобы читался за один взгляд):
services:
app:
# Собираем image из Dockerfile в текущей директории
build: .
# Фиксируем имя image для предсказуемости
image: docker-java-catalog-service
ports:
# Host:Container
- "8080:8080"
environment:
# Профиль запуска приложения
SPRING_PROFILES_ACTIVE: standalone
# Внутренний порт Spring Boot
SERVER_PORT: 8080
# Куда писать экспорт внутри контейнера
APP_EXPORT_DIR: /data/exports
volumes:
# Экспорты должны появляться в репозитории на host
- ./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:
# Меняем только host-порт (внутри контейнера остаётся 8080)
- "${APP_PORT}:8080"
environment:
# Значение из .env попадает внутрь контейнера только потому, что мы явно передали его здесь
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILE}
# Внутренний порт приложения фиксирован и не зависит от host-порта
SERVER_PORT: 8080
# Путь внутри контейнера остаётся стабильным
APP_EXPORT_DIR: /data/exports
volumes:
# Путь на host берём из .env, путь в контейнере фиксированный
- "${EXPORT_DIR_HOST}:/data/exports"
Здесь APP_PORT управляет только входом с host-машины, SPRING_PROFILE становится env var внутри контейнера через environment, а путь слева у volumes остаётся host-путём. Этого уже достаточно, чтобы не править YAML руками под каждую машину.
6. Запуск и остановка: цикл docker compose
Compose становится реально приятным инструментом не в момент написания YAML, а в момент, когда вы пару раз запускаете и останавливаете сервис, не вспоминая длинные команды. Поэтому сейчас закрепим минимальный «рабочий цикл» — буквально три-четыре команды, которые заменяют десяток ручных действий. И да, это та самая привычка, которая потом экономит часы.
Базовый запуск:
docker compose up --build
Что здесь происходит: Compose читает compose.yaml, при необходимости пересобирает image (из-за --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 на host при этом останется, потому что это bind mount, а не «жизнь внутри контейнера».
И полезно заметить: этот цикл уже не меняется, когда у сервиса появляется сосед. Если рядом будет PostgreSQL, точка входа всё равно останется docker compose up, в logs вы так же будете искать симптомы, а разница будет только в том, что в файле станет больше одного service и приложение перестанет искать зависимость на localhost.
7. Smoke-check: HTTP и экспорт на host
Ниже для простоты считаем, что в .env остались значения по умолчанию: APP_PORT=8080 и EXPORT_DIR_HOST=./data/exports. Если вы меняли их под свою машину, просто подставьте свои значения в URL и путь на host.
После docker compose up очень легко попасть в ловушку: «ну логи вроде красивые, значит работает». Красивые логи — это хорошо, но сервис считается живым тогда, когда он отвечает по HTTP. Поэтому делаем две быстрые проверки: health и один бизнесовый endpoint. А затем — маленький «файловый экзамен»: экспорт должен появиться на host.
Проверка здоровья:
curl http://localhost:8080/actuator/health
# {"status":"UP",...} (примерно так; точный JSON зависит от настроек)
Проверка базового API (конкретный ответ зависит от стартовых данных, но важно, что это 200 OK и JSON):
curl http://localhost:8080/api/catalog/items
# [ ... ] (список элементов каталога)
Теперь самое вкусное: экспорт. В учебном сервисе у нас есть endpoint запуска экспорта. Допустим, он не требует body (или вы используете requests из requests/catalog.http). Минимально это может выглядеть так:
curl -X POST http://localhost:8080/api/catalog/exports
# {"id":1,"status":"COMPLETED",...} (пример)
После этого смотрим папку на host-машине:
ls -la ./data/exports
# тут должен появиться новый .csv (имя зависит от реализации)
Если файл появился в ./data/exports, значит связка «APP_EXPORT_DIR внутри контейнера → bind mount → папка на host» действительно работает. Это важнее, чем кажется: именно на таких «простых» сценариях новички чаще всего понимают, что контейнер — это не магическая коробка, а процесс с файловой системой и чётко заданными границами.
8. Типичные ошибки при работе с Compose
Эта тема кажется простой ровно до первой ситуации «Compose не поднимается, а я ничего не менял (честно-честно)». На практике большинство ошибок не сложные, а именно бытовые: отступ не там, переменная не подставилась, путь примонтировали не туда. Ниже — набор самых частых грабель, которые встречаются именно на первой версии compose.yaml с одним сервисом.
Ошибка №1: сломать YAML отступами и долго искать “что не так”.
YAML — это язык, который искренне верит, что пробелы важнее человеческих чувств. Если ports или environment уехали на один пробел, Compose либо откажется запускаться, либо прочитает структуру не так, как вы ожидали. Самый практичный приём — перечитывать файл как дерево: services → app → ports/environment/volumes. Если вы не можете это прочитать глазами, Compose тоже «не сможет».
Ошибка №2: ожидать, что ${APP_PORT}:8080 меняет порт приложения внутри контейнера.
Подстановка в ports меняет только то, какой порт на host публикуется наружу. Внутренний порт приложения задаётся самим приложением (через SERVER_PORT, application.yml или аргументы), и справа в ports должен стоять именно тот порт, который слушает процесс в контейнере. Поэтому логика “давайте подставим переменную справа” часто заканчивается недоступным сервисом и вопросом «почему localhost молчит».
Ошибка №3: путать .env (подстановка) и environment (env vars внутри контейнера).
.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: хардкодить абсолютный путь host-машины в volumes.
Строка вроде /Users/alex/projects/catalog/data/exports:/data/exports может работать у автора, но превращается в камень в ботинке для всех остальных. На Windows и macOS это ещё веселее из-за различий в форматах путей. Относительный путь ./data/exports почти всегда лучший старт, потому что он “привязан” к репозиторию, а не к вашей конкретной машине.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ