depends_on + healthcheck: правильная синхронизация
«У меня иногда работает, иногда нет». На 80% это про race condition при старте: app коннектится к Postgres, но Postgres ещё не готов. Корректное решение — depends_on с condition: service_healthy + правильно написанный healthcheck. В этом уроке разберём всё, что нужно: семантику depends_on, типичные healthcheck’и для Postgres, Redis, Kafka, и как Compose это оркестрирует.
Lifecycle процесса — ready, running, blocked, zombie
depends_on без condition — устаревшая семантика
services:
postgres:
image: postgres:17
environment: { POSTGRES_PASSWORD: secret }
app:
image: myapp
depends_on: [postgres]
Что это значит:
- Compose стартует
postgresдоapp. - Дождётся state
runningконтейнера postgres (= главный процесс запустился). - Запустит
app.
Что НЕ значит:
- Не значит, что Postgres готов принимать соединения.
- Не значит, что в
appможно сразу делатьpsycopg.connect().
Между «контейнер running» и «приложение готово» — секунды (для Postgres до 5-10 секунд при первом запуске на новом volume из-за initdb). Если app сразу пытается подключиться — connection refused.
depends_on без condition: service_healthy — это false sense of security. Junior смотрит в YAML, видит depends_on, успокаивается, потом удивляется почему «у меня иногда не работает». Всегда добавляй condition.
Правильно: condition: service_healthy
services:
postgres:
image: postgres:17
environment: { POSTGRES_PASSWORD: secret }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
app:
image: myapp
depends_on:
postgres:
condition: service_healthy
Теперь compose:
- Стартует
postgres. - Ждёт start_period (10s) грейс.
- Начинает гонять healthcheck
pg_isreadyкаждые 5 секунд. - Когда
pg_isreadyвозвращает exit code 0 -> state = healthy. - Только тогда стартует
app.
app гарантированно поднимается после того, как Postgres готов.
Возможные condition
| condition | Семантика |
|---|---|
service_started | Default. Контейнер в state running |
service_healthy | Healthcheck перешёл в healthy |
service_completed_successfully | Контейнер завершился с exit 0 |
Последний полезен для one-shot init-jobs:
services:
migrate:
image: myapp
command: ["alembic", "upgrade", "head"]
depends_on:
postgres:
condition: service_healthy
app:
image: myapp
depends_on:
migrate:
condition: service_completed_successfully
app запускается только когда migrate отработал успешно. Если миграция упала — app не стартует.
Healthcheck в compose
Полная форма:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
start_interval: 1s
| Параметр | Default | Что значит |
|---|---|---|
test | - | Команда. CMD-SHELL "..." через sh -c, CMD ["a","b"] exec |
interval | 30s | Интервал между проверками после старта |
timeout | 30s | Максимум на одну проверку |
retries | 3 | Сколько fail подряд для unhealthy |
start_period | 0s | Grace period в начале. Fail здесь не считается |
start_interval | 5s | Интервал в start_period (Docker 25+) |
Форматы test:
test: ["CMD-SHELL", "pg_isready -U postgres"] # рекомендую: shell-форма
test: ["CMD", "pg_isready", "-U", "postgres"] # exec-форма, без shell
test: ["NONE"] # отключить healthcheck (даже если есть в образе)
CMD-SHELL запускает в sh -c. Удобно для пайпов, env vars, переменных. CMD запускает напрямую.
Реальные healthcheck’и
Postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d mydb"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
pg_isready встроен в Postgres-образ, проверяет, что демон слушает и готов. Если у тебя нестандартный port или socket — добавь -h localhost -p $PORT.
Более строгая проверка — реальный SQL-запрос:
test: ["CMD-SHELL", "PGPASSWORD=$$POSTGRES_PASSWORD psql -U postgres -d mydb -c 'SELECT 1'"]
$$ экранирует $ в YAML, чтобы compose не пытался интерполировать переменную.
Redis
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
redis-cli ping возвращает PONG и exit 0, если демон отвечает.
Kafka
Сложнее, потому что Kafka стартует долго (Zookeeper/KRaft + контроллер).
healthcheck:
test: ["CMD-SHELL", "kafka-topics --bootstrap-server localhost:9092 --list || exit 1"]
interval: 10s
timeout: 10s
retries: 10
start_period: 30s
Запрос списка топиков работает только когда брокер reachable. start_period 30s — Kafka просто не успевает за меньшее.
HTTP-сервис
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
-f делает curl exit 22 при HTTP-ошибке. Endpoint /health приложение должно реализовать само — это не магия compose.
Если в образе нет curl, не добавляй его ради healthcheck — раздуешь образ. Используй wget (часто в alpine есть), либо nc -z localhost 8000, либо собственный mini-healthcheck-binary в Go (10 МБ vs 30 МБ для curl с зависимостями).
Как compose использует healthcheck
docker compose ps
# NAME STATUS PORTS
# myproj-postgres-1 Up 2 minutes (healthy) 127.0.0.1:5432->5432/tcp
# myproj-app-1 Up 1 minute 0.0.0.0:8000->8000/tcp
(healthy) — состояние после успешных healthcheck’ов. (starting) — пока в start_period или ещё не было retries проверок. (unhealthy) — несколько fail подряд.
docker compose ps --format json | jq '.[] | {Name, Health}'
— programmatic-вариант для CI.
Что если healthcheck падает
docker compose ps
# myproj-postgres-1 Up 30 seconds (unhealthy)
Compose не убивает unhealthy-контейнер сам — это работа restart policy. Но app, который ждал condition: service_healthy, не стартует, пока БД unhealthy. Если так и осталось — compose в какой-то момент сдастся и отменит up:
Error response from daemon: dependency failed to start: container postgres is unhealthy
Дебаг:
docker compose logs postgres
# Видишь сами логи postgres — почему не стартует.
docker inspect <container> | jq '.[0].State.Health'
# История healthcheck-результатов с exit code и output.
start_period: про что чаще всего забывают
Без start_period healthcheck начинается сразу. Если сервис стартует медленно (Kafka, Elasticsearch, тяжёлые JVM-апликейшены), первые retries-проверок упадут, и compose может объявить unhealthy раньше, чем сервис реально готов.
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
start_period: 30s # дай 30 секунд тишины перед первой проверкой
retries * interval + start_period — это максимальное время до признания unhealthy. Для тяжёлых сервисов запас в start_period полезен.
Попробуй сам
mkdir -p ./hc-demo && cd ./hc-demo
cat > compose.yml <<'YAML'
services:
postgres:
image: postgres:17
environment: { POSTGRES_PASSWORD: secret }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 2s
retries: 5
start_period: 5s
client:
image: postgres:17
depends_on:
postgres:
condition: service_healthy
command: |
sh -c "echo Postgres now healthy at $$(date); psql -h postgres -U postgres -c 'SELECT now();' "
environment:
PGPASSWORD: secret
YAML
# 1. Запусти и засеки время.
date && docker compose up
# Видишь, как postgres сначала starting, потом healthy, потом client сразу делает SELECT.
# 2. Сравни с broken-вариантом (без condition).
cat > compose.yml <<'YAML'
services:
postgres:
image: postgres:17
environment: { POSTGRES_PASSWORD: secret }
client:
image: postgres:17
depends_on: [postgres]
command: |
sh -c "echo Trying at $$(date); psql -h postgres -U postgres -c 'SELECT now();' || echo 'FAIL: postgres not ready'"
environment:
PGPASSWORD: secret
YAML
docker compose down -v
docker compose up
# Скорее всего увидишь FAIL: postgres not ready — race condition.
# 3. Cleanup.
docker compose down -v
cd ..
rm -rf hc-demo
Healthcheck можно объявить и в Dockerfile через HEALTHCHECK CMD ... (модуль 8). Compose-уровень имеет приоритет: если есть и там, и там — compose использует своё. Но если в compose не указан — берёт из образа.
В следующем уроке — собираем реальный compose-стенд: Python ETL + Postgres с init.sql и работающим healthy-pattern.