1. Риски одного большого Compose-файла
Когда вы только начинаете пользоваться Compose, он кажется идеальным: один compose.yaml, одна команда docker compose up — и всё “само” взлетает. Проблемы приходят тихо, как обновления в продакшене: сначала вы добавляете Redis, потом RabbitMQ, потом “всего лишь” debug-порт, а потом обнаруживаете, что в одном файле живут сразу две реальности — канонический запуск и локальный dev-режим. В итоге YAML становится похож на гигантский класс на 2000 строк, где есть и бизнес-логика, и UI, и работа с базой, и ещё “чуть-чуть магии”.
Самая неприятная часть здесь даже не размер файла, а смешение ответственности. В compose.yaml постепенно оказываются настройки, которые нужны только вам “прямо сейчас на ноутбуке”, а рядом — настройки, которые должны быть стабильными и одинаковыми для всей команды. Как в Java: если вы смешали DTO, сервисы, репозитории и ещё Thread.sleep() в одном месте, то код как будто работает, но жить с ним становится грустно.
Особенно быстро Compose-файл деградирует, когда вы начинаете добавлять debug. У Spring Boot сервиса появляется второй порт, появляются JVM-аргументы для JDWP, иногда хочется включить больше логов, иногда — переключить Dockerfile target на development stage. И если вы засунете всё это в базовый файл, то “обычный запуск” перестанет быть обычным. Новый разработчик запустит стек и удивится: почему вообще открыт debug-порт? почему сервис собирается не как runtime, а как development? почему у нас логирование как будто мы расследуем преступление века?
Ещё один эффект — копипаст. Часто люди решают проблему так: делают второй файл, но… копируют туда почти весь сервис app целиком. Сегодня оно совпадает, а завтра вы меняете переменную окружения в одном месте, забываете в другом — и получаете две версии реальности: “normal mode” и “dev mode”, которые ведут себя по-разному, потому что YAML разошёлся.
Чтобы этого не случилось, нам нужно очень простое правило: базовый файл должен быть “скучным, стабильным и каноническим”, а dev-файл — “маленьким и точечным”. И да, в YAML это звучит почти как несбыточная мечта, но мы попробуем.
2. Канонический compose.yaml: скучный и стабильный
Слово “канонический” здесь не для пафоса, а чтобы договориться о смысле. Канонический compose.yaml — это тот, который описывает нормальную модель стека: какие сервисы существуют, как они связаны, какие тома и healthchecks считаются стандартом, какие порты открываются наружу и какие переменные окружения нужны, чтобы сервис работал в “обычном” режиме. Это файл, который вы хотите показать коллеге со словами: “вот так у нас живёт локальное окружение”.
И “скучный” тут действительно похвала. Скучный — значит предсказуемый. Он не пытается угадать, что вы сегодня отлаживаете. Он не включает debug “на всякий случай”. Он не подстраивается под конкретную IDE. Он просто поднимает стек так, как он должен работать в базовом сценарии. Если в Java хороший application.yml по умолчанию не должен быть “для особого случая”, то и compose.yaml — это не место для “особого случая”.
В нашем курсе это ещё важнее методически: compose.yaml — это не только способ запуска, но и документ. По нему студент (и вы через месяц) восстанавливает картину: какие зависимости есть у сервиса, какие у них имена, какие порты наружу нужны, где лежит состояние (volumes), как устроен старт.
Очень полезно мысленно разделять настройки на два типа: те, что отражают архитектуру окружения, и те, что отражают удобства разработки. Архитектурные вещи должны быть в базовом файле. Dev-удобства — выносить отдельно.
Вот компактная табличка, которая помогает держать границу:
| Категория настройки | Пример | Логичное место |
|---|---|---|
| Состав стека | , , , |
compose.yaml |
| Связи и готовность | depends_on, healthcheck, сети | compose.yaml |
| Стабильные порты | 8080:8080 для API, порты management UI | compose.yaml |
| Базовая конфигурация приложения в полном стенде | SPRING_PROFILES_ACTIVE = "postgres,cache,messaging" | compose.yaml |
| Debug-порт и JDWP | 5005:5005, JAVA_TOOL_OPTIONS = ...jdwp... | compose.dev.yaml |
| Development Dockerfile target | build.target = development | compose.dev.yaml |
Для этой границы нам сейчас достаточно компактного full-stack примера с фиксированным SPRING_PROFILES_ACTIVE. Как только вы запускаете не весь стек, этот параметр уже лучше задавать сценарием запуска, а не вшивать как вечную константу.
Заметьте: это не “запрет”, это “точка владения”. Базовый файл — владелец канонического поведения.
3. compose.dev.yaml как оверлей
compose.dev.yaml в нашей модели — это не альтернативный проект и не “второй Compose-стенд”. Это именно второй файл, который содержит только отличия от канонического запуска. Его роль похожа на аккуратный @Profile("dev") слой в Spring: он добавляет то, что нужно именно для разработки, но не переписывает весь мир заново.
Главная идея очень простая: если вы открыли compose.dev.yaml и увидели там почти полный повтор compose.yaml, значит вы уже проиграли. Не потому что “так нельзя”, а потому что вы сами себе заложили мину: файлы неизбежно разойдутся, и вы будете отлаживать не код, а YAML-расхождения.
В хорошем compose.dev.yaml обычно живут вещи вроде “поменять target сборки”, “добавить debug-порт”, “добавить одну переменную окружения”. Вы буквально накладываете тонкую плёнку на базовую конфигурацию.
Например, базовый файл может быть таким (фрагмент сервиса app, специально короткий):
# compose.yaml
services:
app:
build:
# Контекст сборки: Compose будет искать Dockerfile и исходники в корне проекта
context: .
ports:
# Канонический HTTP-порт приложения (должен быть одинаковым у всей команды)
- "8080:8080"
environment:
# Для компактного full-stack варианта фиксируем полный набор профилей:
# здесь важна граница между base и dev, а не сценарное переключение
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
А dev-файл — таким:
# compose.dev.yaml
services:
app:
build:
# Dev-надстройка: в разработке собираемся не в runtime-стейдж, а в development
target: development
ports:
# Dev-надстройка: открываем дополнительный порт для отладчика
- "5005:5005"
Обратите внимание на психологический эффект: dev-файл не пытается заново описать context, не повторяет 8080:8080, не дублирует профили. Он говорит: “в dev-режиме у app есть ещё две особенности: другой target и ещё один порт”.
Теперь пример того, как выглядит “плохой dev-файл” (тоже фрагмент):
# compose.dev.yaml (плохо)
services:
app:
build:
# Плохо: dev-файл повторяет канонический context (это лишний источник правды)
context: .
# Единственная реальная dev-правка тут — target, остальное лучше оставить в базе
target: development
ports:
# Плохо: здесь продублирован канонический порт, и он может разъехаться с compose.yaml
- "8080:8080"
# Ок: это действительно dev-добавка
- "5005:5005"
environment:
# Плохо: дублируем канонические профили и рискуем "забыть обновить" их позже
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
Проблема не в том, что “так не работает”. Оно как раз работает. Проблема в том, что вы создали второй источник правды. Через неделю вы поменяете профиль или порт в compose.yaml, забудете в dev-файле — и у вас появится загадка уровня “почему у меня в debug всё нормально, а в normal нет?”. Удивительно, но люди потом обвиняют Spring Boot.
4. Правило «точки владения» для переопределений
В любой системе конфигурации наступает момент, когда важнее не “как запустить”, а “как понять, почему запустилось именно так”. И вот тут “точка владения” — почти спасательный круг. Смысл правила: у каждой настройки должно быть место, где она живёт как главный источник правды. Иначе вы получите вечный спор “а где это задано?”.
Чтобы правило работало, нужно не так много: разделить настройки на “стабильные” и “локальные” и удерживать эту границу. Стабильные — те, которые должны одинаково работать у каждого студента и у коллеги по команде. Локальные — те, которые включаются по ситуации и не должны менять канонический запуск.
В контексте нашего проекта удобно мыслить так: compose.yaml — это “паспорт” стенда, а compose.dev.yaml — “наклейки на паспорт”. Наклейки полезны, но паспорт от них не должен становиться нечитаемым.
Если хочется более инженерной метафоры, можно представить слои так:
flowchart TD
A["compose.yaml
канонический стек"] --> C["Итоговая конфигурация
которую реально запустит Compose"]
B["compose.dev.yaml
локальные отличия"] --> C
Этот рисунок ещё не объясняет детали merge-правил (это отдельный важный разговор), но он фиксирует главное: compose.dev.yaml не существует сам по себе. Его смысл — менять базовый стек, а не создавать “параллельную вселенную”.
И да, тут очень помогает дисциплина “меньше строк”. Если dev-файл растёт, это почти всегда симптом того, что вы начали дублировать базу. В большинстве случаев, когда dev-файл разрастается, правильная реакция не “ну ладно, пусть будет”, а “а почему мы не можем держать это в базе или наоборот — почему это вообще должно быть частью dev?”.
5. Честные команды запуска
Когда конфигурация разделена на базовую и dev-часть, важно, чтобы и команда запуска была такой же честной. То есть, чтобы по ней было видно: “я запускаю канонический стенд” или “я запускаю стенд с dev-оверлеем”. Это кажется мелочью, но потом экономит часы объяснений.
Канонический запуск должен быть максимально простой командой. В идеале — без -f, потому что базовый файл называется compose.yaml и Compose сам его подхватит:
# Канонический запуск (без dev-надстроек)
docker compose up --build
А dev-запуск должен быть таким, чтобы вы не могли забыть, что вы в dev-режиме. Поэтому мы явно указываем оба файла:
# Запуск с dev-оверлеем: второй файл добавит только отличия от базы
docker compose -f compose.yaml -f compose.dev.yaml up --build
Эта явность — часть “developer workflow”: вы читаете историю терминала и понимаете, почему у вас внезапно открыт порт 5005 или почему сборка идёт через development stage.
Здесь стоит коротко обсудить популярный “скрытый” механизм: compose.override.yaml. Compose действительно умеет автоматически подхватывать override-файл с таким именем. Но как teaching-path и как командная дисциплина это часто превращается в магию: человек запускает docker compose up и даже не осознаёт, что у него подтянулись какие-то переопределения.
В нашем курсе мы сознательно предпочитаем compose.dev.yaml и явный -f ... -f ..., потому что это лучше для понимания и воспроизводимости. Когда команда запуска явно показывает dev-файл, у вас меньше “мистики” и больше инженерного контроля.
6. Применение в Container-Ready Catalog Service
Теперь давайте приземлимся в наш учебный репозиторий. У нас в корне уже есть Dockerfile, есть compose.yaml, и по плану финального шаблона должен появиться compose.dev.yaml. В хорошем варианте структура выглядит очень узнаваемо:
docker-java-catalog-service/
├── Dockerfile
├── compose.yaml
├── compose.dev.yaml
└── .env.example
Базовый compose.yaml хранит “что такое наш стенд”. Для лекции нам не нужен весь файл на 200 строк, достаточно показать ключевой принцип на app. Например, базовый сервис может фиксировать привычный full-stack runtime-режим и HTTP-порт:
# compose.yaml (фрагмент)
services:
app:
build:
# Канонический context: это часть "паспорта" стенда
context: .
ports:
# Канонический HTTP-порт приложения
- "8080:8080"
environment:
# Для компактного full-stack примера фиксируем полный набор профилей:
# здесь важна сама граница между базовым и dev-слоем
SPRING_PROFILES_ACTIVE: "postgres,cache,messaging"
А compose.dev.yaml аккуратно добавляет dev-отличия. Например, development target и debug-порт (без попытки переписать базу):
# compose.dev.yaml (фрагмент)
services:
app:
build:
# Dev-режим: подменяем только target сборки
target: development
ports:
# Dev-режим: добавляем только debug-порт
- "5005:5005"
Важно, что мы сейчас не обсуждаем “как именно настроить debug” или “какие точные JVM-параметры нужны”. Это действительно отдельная тема, и она имеет смысл только после того, как вы научились чисто отделять dev-режим от normal-режима. Но даже без JDWP-строки уже видно главное: dev-файл — это тонкий оверлей, который не разрушает каноническую модель.
И ещё один практический момент, который кажется смешным, пока вы не попали в него лицом: базовый compose.yaml должен оставаться самостоятельным. То есть команда docker compose up --build должна работать без обязательного dev-файла. Иначе вы превращаете development-режим в “норму”, а канонический режим — в что-то экзотическое, что никто не запускает. Это как держать @Profile("prod") выключенным по умолчанию и удивляться, что в проде всё другое.
7. Типичные ошибки
Когда мы впервые разделяем Compose на два файла, мозг часто пытается “сделать как проще”, и из этого вырастают одинаковые ошибки. Они нормальны, потому что YAML сам по себе провоцирует копипаст (он вообще такой, да), но лучше узнавать их по симптомам, а не по ночным кошмарам.
Ошибка №1: dev-файл превращают в копию базового.
Обычно это начинается с благой идеи “ну я просто продублирую app, а потом добавлю пару строк”. Через неделю в базовом файле меняется переменная, и dev-файл “отстаёт”. В результате normal и dev режимы начинают вести себя по-разному не потому, что так задумано, а потому что конфиги разошлись. Лечится это строгим принципом: в dev-файле оставляем только отличия, всё остальное — в базе.
Ошибка №2: debug-настройки кладут в compose.yaml, потому что “мне так удобно”.
Удобно — да, но это превращает dev-режим в дефолт. Потом приходит коллега, запускает стек и получает открытый порт 5005 и JVM, которая слушает отладчик, хотя ему это вообще не надо. Базовый файл должен описывать нормальный запуск; debug — это локальная надстройка, и она должна быть явно включаемой.
Ошибка №3: заводят второй сервис app-debug, а не переопределяют app.
На первый взгляд кажется логичным: “пусть будет app и app-debug”. На практике вы удваиваете wiring: порты, env vars, depends_on, volumes. И дальше начинается гонка: какой сервис сейчас “настоящий”? где менять конфиг? почему один работает, а другой нет? В нашей учебной модели куда проще иметь один сервис app и два режима запуска через оверлей.
Ошибка №4: базовый файл перестаёт быть рабочим без dev-файла.
Иногда в dev-файл уезжают вещи, которые на самом деле являются частью “канонического” стенда, например обязательные переменные окружения или нужные порты. Тогда compose.yaml становится “недокументированным черновиком”, а реальность живёт только в dev-режиме. Это опасно тем, что вы ломаете воспроизводимость: “нормальный запуск” перестаёт быть нормальным.
Ошибка №5: в dev-файл начинают тащить OS-зависимые абсолютные пути.
Это особенно частая история на Windows/macOS. Кто-то прописывает C:\Users\... или /Users/name/... прямо в YAML, и всё “работает у автора”. Потом файл коммитится — и у остальных окружение не поднимается. Даже если dev-файл содержит локальные отличия, он всё равно часть командного workflow, поэтому относительные пути и кроссплатформенность важны. Если уж есть совсем локальная настройка, которая не должна жить в репозитории, лучше подумать о локальном файле вне VCS (но как подход курса мы держим compose.dev.yaml командным и воспроизводимым).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ