Разберём механику: depends_on с условием готовности, profiles, слияние нескольких файлов и override, YAML-якоря для DRY, интерполяцию переменных, именованные сети и тома, лимиты ресурсов и Compose Watch. И обязательно - где у Compose заканчиваются полномочия, и начинается территория оркестратора.
Почему depends_on врёт, и при чём тут healthcheck
Самая частая боль новичка: в compose.yaml написано, что app зависит от db, контейнеры стартуют в правильном порядке, а приложение всё равно падает с "connection refused". Дело в том, что обычный depends_on в форме списка гарантирует только порядок запуска контейнеров, а не готовность процесса внутри. Compose запустил контейнер с PostgreSQL - depends_on считает задачу выполненной. Но postgres внутри ещё инициализирует кластер, накатывает initdb, открывает сокет - это секунды, иногда десятки. Твой app в этот момент уже ломится за коннектом и получает отказ.
Решение - связка docker compose depends_on и docker compose healthcheck. Сначала описываешь healthcheck у зависимости: команда, которая возвращает 0, когда сервис реально готов принимать работу. Затем в depends_on задаёшь не список, а map с условием condition: service_healthy. Тогда Compose не просто запустит db первой, а будет ждать, пока healthcheck не перейдёт в состояние healthy, и только потом стартанёт app.
Код: Выделить всё
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
cache:
image: redis:7
Условия depends_on бывают трёх видов. service_started - старое поведение, ждём только запуска контейнера. service_healthy - ждём healthy от healthcheck, то самое, что нам нужно для баз и брокеров. service_completed_successfully - ждём, пока контейнер отработает и выйдет с кодом 0; незаменимо для one-shot сервисов вроде миграций: app зависит от migrate с этим условием, и не стартует, пока схема не накатится.
Посмотрим, что Compose показывает при ожидании:
Код: Выделить всё
$ docker compose up
[+] Running 3/3
- Container proj-db-1 Healthy 8.4s
- Container proj-cache-1 Started 0.6s
- Container proj-app-1 Started 8.7s
Важная оговорка: depends_on - инструкция уровня Compose, она работает при docker compose up. В голом Docker Swarm и тем более в Kubernetes её нет; там готовность решают через readiness-пробы и retry в самом приложении. Поэтому хорошая практика - даже с healthcheck закладывать в код переподключение к базе. Compose сглаживает старт, но не отменяет необходимость устойчивого приложения.

Profiles: включаем сервисы по требованию
Типичная ситуация: в одном compose-файле живут и боевые сервисы, и вспомогательные - админка БД, отладочный прокси, нагрузочный генератор, сборщик метрик. Поднимать всё это сразу каждый раз не нужно и дорого. Тут на сцену выходит docker compose profiles.
Профиль - это метка на сервисе. Сервис без профилей запускается всегда. Сервис с профилем запускается только если этот профиль явно активирован. Активируешь через флаг --profile или переменную COMPOSE_PROFILES.
Код: Выделить всё
services:
app:
build: .
db:
image: postgres:16
adminer:
image: adminer
profiles: ["debug"]
ports:
- "8080:8080"
loadtest:
image: grafana/k6
profiles: ["perf"]
Код: Выделить всё
docker compose up # только app и db
docker compose --profile debug up # app, db и adminer
COMPOSE_PROFILES=debug,perf docker compose up # все четыре
Несколько файлов, override и DRY: docker compose override
Главный антипаттерн - держать compose.prod.yaml и compose.dev.yaml как две полные копии, которые расходятся через неделю. Профессиональный подход - база плюс наложения. Тут работает docker compose override.
Compose по умолчанию ищет в рабочей директории два файла: compose.yaml и compose.override.yaml. Если оба есть, он автоматически сливает их в одну конфигурацию, причём значения из override имеют приоритет. Идея: в compose.yaml лежит общая правда, а в compose.override.yaml - локальные удобства разработчика (проброс портов, монтирование исходников, debug-переменные). override-файл обычно даже не коммитят или коммитят как пример - на проде его просто нет, и поднимается чистая база.
Когда сред больше двух, переходишь на явное слияние через -f. Файлы применяются слева направо, каждый следующий накладывается поверх предыдущего:
Код: Выделить всё
docker compose -f compose.yaml -f compose.prod.yaml up -d
Для борьбы с повторами внутри одного файла есть два инструмента. YAML-якоря (anchors) - чистый синтаксис YAML: помечаешь блок &именем, переиспользуешь *именем, при необходимости расширяешь через <<: для слияния маппингов.
Код: Выделить всё
x-app-base: &app-base
build: .
environment: &app-env
APP_ENV: production
LOG_LEVEL: info
restart: unless-stopped
services:
web:
<<: *app-base
ports:
- "8000:8000"
worker:
<<: *app-base
command: ["python", "worker.py"]
Переменные, сети, тома и лимиты
Compose интерполирует переменные окружения прямо в YAML. Источник - окружение шелла и файл .env в директории проекта (не путать с env_file внутри сервиса - тот задаёт переменные внутри контейнера, а .env подставляет значения в сам compose-файл на этапе разбора). Поддерживается синтаксис со значениями по умолчанию: ${VAR:-default} подставит default, если VAR пуста или не задана; ${VAR-default} - только если не задана вообще; ${VAR:?error} - упадёт с сообщением, если переменной нет. Это спасает от молчаливых дыр в конфиге.
Код: Выделить всё
services:
app:
image: registry.example.ru/app:${APP_TAG:-latest}
ports:
- "${APP_PORT:?APP_PORT is required}:8000"
Код: Выделить всё
services:
app:
networks: [frontend, backend]
db:
networks: [backend]
networks:
frontend:
backend:
monitoring:
external: true
volumes:
pgdata:
Масштабирование без правки файла - docker compose up --scale worker=4 поднимет четыре экземпляра worker. Работает только если у сервиса не зафиксирован контейнерный hostname или статический порт (иначе конфликт), поэтому масштабируемые сервисы делают за балансировщиком. Лимиты ресурсов задаются через deploy.resources - и вот важная деталь: блок deploy исторически был про Swarm, но docker compose up читает из него limits и reservations и применяет их к контейнеру, а вот replicas из deploy в обычном up игнорирует (для этого --scale).
Код: Выделить всё
services:
app:
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
restart: unless-stopped
Compose Watch и границы Compose в проде
Свежий инструмент для локальной разработки - Compose Watch, секция develop.watch. Он умнее, чем монтирование исходников томом, потому что различает типы изменений и реагирует разными действиями. Action sync копирует изменённые файлы в контейнер (горячая перезагрузка для интерпретируемых языков с hot reload). sync+restart синхронизирует и перезапускает контейнер - для случаев, когда правишь конфиг, который читается только на старте. rebuild пересобирает образ - когда меняется то, что влияет на сборку, например зависимости.
Код: Выделить всё
services:
web:
build: .
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: ./requirements.txt
- action: sync+restart
path: ./config
target: /app/config
Теперь честно про границы. docker compose - превосходный инструмент для локальной разработки, CI и небольших одно-хостовых деплоев. Но Compose работает в пределах одной машины. У него нет планировщика по кластеру, нет автоматического восстановления упавшего узла, нет rolling-update с проверкой готовности и автооткатом, нет горизонтального автоскейла. depends_on решает порядок старта, но не оркеструет жизнь сервисов в долгую. Поэтому в серьёзном проде Compose уступает место Kubernetes (или nomad, или managed-платформам). Это не недостаток Compose - это разделение труда: Compose для dev-цикла и простых стендов, оркестратор для отказоустойчивого продакшена. Понимать эту границу - признак зрелого инженера: не натягивать Compose на кластер и не тащить k8s туда, где хватает одного хоста.
Мини-лаба: собери всё вместе
Повтори руками, это закрепит механику.
- Создай проект с тремя сервисами: db (postgres с healthcheck через pg_isready и start_period 30s), app (build, depends_on db с condition: service_healthy), и adminer под profiles: ["debug"].
- Добавь one-shot сервис migrate, а в app пропиши depends_on migrate с condition: service_completed_successfully. Проверь, что app ждёт миграции.
- Вынеси проброс портов и монтирование исходников в compose.override.yaml. Запусти без override (имитация прода) и с ним, сравни docker compose config - он покажет итоговую слитую конфигурацию.
- Подними стек обычно и с --profile debug, убедись, что adminer появляется только во втором случае.
- Добавь develop.watch с action sync на папку с кодом, запусти docker compose watch и поправь файл - проследи синхронизацию в логах.
- Поиграй с ${APP_PORT:?...} - убери переменную из .env и посмотри, как Compose откажется стартовать с внятной ошибкой.
- Чем depends_on в форме списка отличается от формы с condition: service_healthy, и зачем нужен start_period в healthcheck?
- Как Compose сливает списки (ports) и маппинги (environment) при наложении нескольких -f файлов, и как принудительно заменить значение, а не дополнить?
- Что произойдёт, если сервис без профиля зависит через depends_on от сервиса с профилем, который не активирован?
- Когда выбрать action rebuild, а когда sync в Compose Watch, и почему монтирование тома их не заменяет?
Профессиональный docker compose - это управление готовностью, средами и сборкой, а не просто up. depends_on с service_healthy и грамотным healthcheck лечит гонки старта; profiles дают срезы стека из одного файла; override и -f-слияние держат среды в DRY без копипасты; якоря и extends убирают повторы; интерполяция с ${VAR:-default} делает конфиг гибким и явным; deploy.resources и restart готовят сервис к нагрузке; Compose Watch ускоряет dev-цикл. И главное - помни границу: Compose царит на одном хосте, а отказоустойчивый прод - территория оркестратора.