1. compose.yaml как описание окружения
Если до этого Docker у вас был как набор команд, то Compose добавляет ровно одну ключевую идею: «пусть окружение живёт в файле, а команды будут короткими». Важно не перепутать: Compose не заменяет Docker Engine и не создаёт “другой Docker”. Он просто даёт вам удобный способ описать контейнеры и их параметры так, чтобы не держать их в голове и не копировать по чатам.
Что делает Docker Compose по сути? Он читает YAML и превращает его в те же самые действия, которые вы бы сделали руками: собрать image (если нужно), создать сеть (если нужно), создать том (если нужно), запустить контейнер(ы) с нужными портами, переменными, mount’ами. То есть Compose — это не «вторая система», а «нормальная оболочка над тем, что вы уже делали руками».
Удобно представлять это так:
flowchart TD A["compose.yaml (описание)"] --> B["docker compose up"] B --> C["Docker Engine: build image (опционально)"] B --> D["Docker Engine: create network (опционально)"] B --> E["Docker Engine: create volumes (опционально)"] B --> F["Docker Engine: run containers"] F --> G["Вы проверяете API /actuator/health и логи"]
В рамках сегодняшнего дня мы сознательно держим окружение простым: один сервис app, без внешней БД и без «зоопарка зависимостей». Это поможет понять механику Compose на чистом примере, а не утонуть в том, что конкретно не стартует: приложение или база.
2. Канонические имена и команда Compose
Когда вы начинаете искать примеры в интернете, мир делится на два лагеря: где-то показывают файл docker-compose.yml, а где-то — compose.yaml. И ещё где-то встречается команда docker-compose, а не docker compose. Это не потому, что люди любят хаос (хотя иногда кажется, что любят). Это потому, что Compose исторически эволюционировал, и сегодня у нас есть канонический современный путь.
В этом курсе мы фиксируем baseline: файл называется compose.yaml, команда — docker compose (через пробел). Это соответствует современному Compose v2, который идёт как часть Docker CLI. С точки зрения вашего будущего «взрослого» опыта это выгодно: меньше путаницы, меньше “а у меня другая версия”, меньше “почему команда не найдена”.
Практически это означает три простых правила.
Первое правило: файл лежит в корне репозитория, рядом с Dockerfile, .dockerignore, README.md. Не прячьте его в docker/compose/compose.yaml, если вы не ведёте отдельный курс по поиску файлов.
Второе правило: вы запускаете Compose из директории проекта (или явно указываете файл). Самый частый сценарий в учебном проекте будет выглядеть так:
cd docker-java-catalog-service # Переходим в директорию проекта (где лежит compose.yaml)
docker compose up # Поднимаем окружение по описанию из compose.yaml
Третье правило: мы не превращаем курс в археологию, но контекст полезен. Старое имя docker-compose.yml и команда docker-compose — это legacy-путь, который вы можете встретить в чужих репозиториях. Уметь прочитать — полезно. Но как основной teaching-path мы его не используем, чтобы не размножать варианты.
3. YAML в Compose: отступы — это ваши новые “скобки”
Compose-файл — это YAML. А YAML — это такой формат, который выглядит дружелюбно… пока вы не сделали лишний пробел. Если в Java вы забыли закрыть фигурную скобку, компилятор скажет: «друг, ты не закрыл блок». В YAML роль “скобок” играют отступы. И да, это тот редкий случай, когда пробелы реально важны, а не просто «чтобы было красиво».
Давайте договоримся о минимальной YAML-грамматике, которая нужна именно для Compose.
Ключ-значение — это “map” (словарь). Пример:
services: # Раздел с сервисами окружения
app: # Имя сервиса (логическое имя в Compose)
build: . # Собирать image из текущей директории (Dockerfile по умолчанию)
Здесь services — ключ верхнего уровня, а значение — вложенный объект. У services есть ключ app, а у app есть ключ build.
Список — это “array” (массив) с -. Пример с портами:
ports: # Список пробросов портов
- "8080:8080" # host:container (слева порт на вашей машине, справа — в контейнере)
ports — список, где каждый элемент — строка. Почему строка в кавычках? Потому что так надёжнее и читабельнее. YAML, конечно, умный, но мы с ним не соревнуемся в IQ — мы хотим, чтобы файл читался одинаково на всех машинах и у всех студентов.
И вот важный практический совет: когда файл ломается отступами, вы обычно получаете ошибку вида mapping values are not allowed here или что-то столь же “поэтичное”. В такие моменты не нужно вспоминать все грехи последних двух недель. Нужно просто прочитать файл сверху вниз и проверить, что уровни вложенности логичны.
Минимальный скелет compose.yaml
Compose-файл почти всегда читается от раздела services. Это точка входа. Можно сказать, что services — это как public static void main в мире окружения: тут начинается “жизнь”.
Минимально возможный Compose-файл, который вообще имеет смысл, выглядит так:
services: # Всегда начинаем с описания сервисов
app: # Имя сервиса (вы будете ссылаться на него в командах: logs, exec и т.д.)
build: . # Источник сборки образа (контекст)
Да, всё. Три строки — и уже есть смысл: “есть сервис app, он собирается из текущей директории”. И вот теперь docker compose up --build сделает то, что вы делали бы руками: docker build ... и docker run ...
Чаще всего в реальном проекте вы добавите ports, потому что иначе сервис запустится, но будет «виден только самому себе» (контейнеру). И получается такой базовый минимум:
services:
app:
build: .
ports:
- "8080:8080" # Публикуем порт наружу: localhost:8080 -> контейнер:8080
Теперь появляется “мост наружу”: host порт 8080 проброшен в контейнер на 8080.
Обратите внимание на то, что Compose-файл — это не место, где вы «придумываете новый способ запуска приложения». Если у вас уже есть корректный ENTRYPOINT в Dockerfile, Compose не обязан его повторять. Compose должен сказать: какой image запустить и какие runtime-параметры добавить.
4. build и image: не путать
Одна из самых частых ранних путаниц: «а мне писать build или image?». Это не вкусовщина. Это два разных сценария.
build означает: “собери image из исходников”. Compose будет использовать build context и Dockerfile (по умолчанию Dockerfile в указанной директории). Простой пример:
services:
app:
build: . # Собираем image локально из Dockerfile и контекста проекта
То есть мы говорим: “бери Dockerfile из текущего проекта и собирай”.
image означает: “возьми уже готовый образ”. Обычно либо вы уже собрали его ранее (docker build -t ...), либо он лежит в registry. Пример:
services:
app:
image: docker-java-catalog-service:latest # Берём готовый image по имени:тегу
И это важно: image не “собирает”, он “использует”. Поэтому если image не существует локально и не может быть вытянут, запуск не состоится.
Есть и третий, очень практичный случай: использовать build и image вместе — например, build: . плюс image: docker-java-catalog-service. Тогда build отвечает за источник сборки, а image — за имя результата. Для локальной разработки это удобно: Compose сам собирает сервис и одновременно сохраняет результат под предсказуемым именем, которое не зависит от имени директории проекта.
В учебном проекте на раннем Compose-дне чаще всего удобно начинать с build: ., потому что это меньше шагов: вам не нужно помнить, под каким тегом вы собрали образ. Compose сам соберёт и запустит. Но полезно понимать оба режима, потому что в реальной жизни вы будете использовать оба: build — для локальной разработки, image — когда вы хотите запускать заранее собранный, фиксированный артефакт.
Если хочется сделать build чуть более явным (и чуть менее магическим), можно записать так:
services:
app:
build:
context: . # Где лежит контекст сборки (что отправляем Docker-демону)
dockerfile: Dockerfile # Явно указываем имя Dockerfile
Выглядит длиннее, зато читается как “контракт”. Но на старте курса это не обязательно — иногда краткость полезнее, чем идеологическая строгость.
5. Настройки сервиса: порты, env, тома
Когда вы переходите с docker run на Compose, самый приятный момент — вы начинаете видеть параметры запуска как структуру. Там, где раньше был длинный one-liner, теперь есть YAML, в котором глазом видно: “порты вот тут, переменные вот тут, тома вот тут”.
Чтобы мозг быстрее привык, можно держать маленькую таблицу соответствий. Это не “полный справочник Compose”, а именно мост от уже знакомых флагов docker run к ключам YAML:
| Что вы делали в docker run | Как это выглядит в compose.yaml | Смысл на человеческом |
|---|---|---|
| -p 8080:8080 | ports: ["8080:8080"] | “Открой порт на host и прокинь в контейнер” |
| -e SPRING_PROFILES_ACTIVE=standalone | environment: { SPRING_PROFILES_ACTIVE: standalone } | “Положи переменную окружения внутрь контейнера” |
| -v ./data/exports:/data/exports | volumes: ["./data/exports:/data/exports"] | “Смонтируй каталог host в контейнер” |
Простейший сервис с портом и одним env var выглядит так:
services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: standalone
Этого уже достаточно, чтобы увидеть структуру: ports отвечает за внешний вход в контейнер, environment — за env vars внутри него. Если сервису нужны bind mount’ы или дополнительные параметры, они просто добавляются в соседние разделы volumes и environment, а каркас файла остаётся тем же.
Здесь хорошо видно два важных момента.
Во-первых, “настройки окружения” разделены по смыслу: порты отдельно, переменные отдельно, тома отдельно. Вы не ловите глазами \ и не ищете в строке, где там -e, а где -v.
Во-вторых, Compose не требует от вас помнить порядок флагов. В docker run можно написать -p до -e или после — Docker поймёт. Но человек читает это тяжело. YAML же по природе структурный: вы группируете по разделам, а не по порядку.
6. Верхний уровень: networks и volumes
Даже если сегодня у нас один сервис, вы уже видите, что у Compose есть сущности “уровня окружения”, а не “уровня контейнера”. Это важная мысль, потому что Compose — не “запуск одного контейнера”, а “описание стенда”.
В файле есть разделы верхнего уровня, которые часто встречаются:
services — кто участвует в окружении (контейнеры);
networks — как они соединяются по сети;
volumes — как хранится состояние/данные через named volumes.
Сегодня нам достаточно понять именно структурный момент: эти разделы пишутся на одном уровне вложенности с services, а не внутри services.app. То есть вот так (правильно):
services:
app:
build: .
volumes:
exports-data: # Объявляем named volume на верхнем уровне (как сущность окружения)
А вот так (неправильно, и YAML/Compose будут не в восторге):
services:
app:
build: .
volumes:
exports-data: # Ошибка: здесь ожидается список подключений, а не объявление volume
Внутри сервиса вы подключаете volume, а объявляете его снаружи. Почему так? Потому что объявление — это “мы заводим сущность окружения”, а подключение — это “мы используем сущность окружения в конкретном сервисе”. Разделение похоже на Java: объявили класс отдельно, а потом используете его в конкретных местах.
Ещё раз подчеркну: мы не углубляемся сегодня в сетевую модель и DNS. Мы просто фиксируем: если вы видите networks: и volumes: на верхнем уровне — это нормально, так и должно быть. Это не “лишняя бюрократия”, а структура, которая потом спасает проект от YAML-хаоса.
7. Минимальный цикл работы: up, logs, down
Файл сам по себе ничего не запускает. Он — как build.gradle.kts: пока вы не вызвали команду, это просто текст. Поэтому важно знать минимальный “рабочий цикл”, который заменяет вам ручные docker build + docker run.
Самый базовый сценарий выглядит так:
docker compose up --build # Поднять окружение и при необходимости пересобрать image
Флаг --build полезен, когда вы используете build: . и хотите гарантировать, что образ будет пересобран, если вы поменяли код или Dockerfile. На старте обучения это делает поведение более предсказуемым: меньше шансов “случайно запустить старый образ”.
Когда стек поднялся, вы почти сразу захотите посмотреть логи:
docker compose logs app # Показать логи сервиса app (имя берётся из compose.yaml)
app — это имя сервиса (не контейнера). В этом и есть кайф Compose: вы думаете “сервисами окружения”, а не “случайными контейнерами”.
Когда нужно остановить окружение, используйте:
docker compose down # Остановить и удалить созданные контейнеры/сети (по умолчанию)
И вот здесь очень приятное ощущение: вы не ищете, как именно назывался контейнер, не вспоминаете docker stop ... и docker rm .... Compose сам знает, что он создавал, и сам это аккуратно убирает.
Если вы любите “держать окно терминала чистым”, можно запускать в фоне (detached):
docker compose up -d --build # Запуск в фоне, чтобы терминал сразу освободился
Но на старте обучения чаще удобнее без -d, чтобы видеть логи прямо в консоли и быстрее связывать действие с симптомом.
8. Типичные ошибки при создании и чтении compose.yaml
Ошибка №1: “Я написал YAML, но Compose ругается непонятными словами”.
Обычно это отступы. YAML не прощает “слегка съехавший уровень”, потому что уровень вложенности и есть смысл. Самый рабочий способ лечения — не пытаться угадать, а открыть файл и прочитать: services → app → ключи сервиса. Если какой-то ключ внезапно оказался на другом уровне, вы это увидите. И да, иногда это буквально один пробел, который стоит вам 20 минут и пары философских вопросов к мирозданию.
Ошибка №2: путаница build и image — “я думал, оно само соберётся”.
Если вы написали image: docker-java-catalog-service, Compose не обязан строить этот image. Он обязан попытаться его использовать. Если образа нет — получите ошибку. Если вы хотите сборку из текущего репозитория, используйте build: .. И наоборот, если вы хотите запускать строго конкретный готовый артефакт (особенно в команде), image становится более честным и предсказуемым.
Ошибка №3: ожидание, что Compose “поймёт всё за меня”, даже если я не описал порты.
Compose не телепат. Если вы не указали ports, приложение может работать внутри контейнера идеально, но снаружи вы его не откроете через localhost:8080. Это не поломка Spring Boot, это просто отсутствие публикации порта. Compose не публикует порты “по умолчанию”, потому что это было бы небезопасно и неожиданно.
Ошибка №4: попытка сделать первый compose.yaml слишком большим.
Очень хочется сразу добавить разделы “на вырост”: networks, volumes, restart-policies, healthchecks, profiles, depends_on, и ещё пять вещей, которые вы видели в чужом репозитории. На практике это убивает обучаемость. Правильный первый файл читается одним взглядом и отвечает на вопрос: “как запустить наш сервис в том режиме, который нам нужен прямо сейчас”. Всё остальное — лишний шум, который потом трудно отличить от реально важного.
Ошибка №5: смешивание “настроек Compose” и “настроек приложения” в одну кашу.
Compose управляет тем, как запускается контейнер: порты, переменные окружения, mount’ы. Spring Boot управляет тем, как ведёт себя приложение внутри контейнера: профиль, порт, datasource и так далее. В Compose-файле вы можете передать значения внутрь контейнера через environment, но если вы начинаете пытаться “настроить Spring Boot ключами Compose” (или наоборот) — вы быстро перестаёте понимать, где именно живёт причина поведения.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ