Порядок старта — не готовность: depends_on, healthcheck и restart-политики
depends_on без healthcheck упорядочивает старт контейнеров, а не готовность — API гонится с базой, ещё проигрывающей WAL. condition: service_healthy плюс healthcheck (interval, timeout, retries, start_period) гейтит по готовности; restart решает, лечит крах или прячет.
CI-пайплайн флачил до бешенства: набор интеграционных тестов проходил локально и проходил на повторе, но падал примерно один прогон из четырёх с первой попытки, всегда с одной ошибкой — API не мог подключиться к Postgres. Compose-файл выглядел корректно; там прямо стояло depends_on: [db]. Кто-то наконец посмотрел логи контейнеров в том порядке, в котором они реально происходили: контейнер db стартовал, а через несколько миллисекунд стартовал контейнер api и тут же выстрелил первым запросом — пока Postgres ещё проигрывал свой write-ahead log и не открыл слушающий сокет. depends_on сделал ровно то, что обещал, и это было не тем, что думала команда: он ждал старта контейнера базы, а не готовности базы принимать соединения. Старт контейнера и готовность сервиса разделены где-то от 200 миллисекунд до нескольких секунд инициализации, и на медленном CI-раннере этот зазор был широк настолько, чтобы проигрывать гонку в четверти случаев. Фикс — четыре строки: healthcheck на db, который реально гоняет pg_isready, и condition: service_healthy на зависимости. Флакать перестало, потому что API теперь ждал готовности, а не существования процесса.
depends_on упорядочивает старт, а не готовность
Обычный depends_on: [db] заставляет Compose стартовать db перед api и останавливать после — это вся гарантия. Он не ждёт, пока процесс внутри db будет готов; в момент, когда контейнер создан и его entrypoint запущен, зависимость считается удовлетворённой, и api стартует. Для всего stateful это гонка, потому что старт контейнера и готовность сервиса — разные события, разделённые реальным временем инициализации: Postgres проигрывает WAL (write-ahead log — журнал упреждающей записи) и открывает сокет, Kafka выбирает контроллер, JVM-приложение прогревается — где-то от пары сотен миллисекунд до многих секунд. Зависимый выстреливает первый запрос в этот зазор и получает connection-refused. Корректная форма делает зависимость условной по здоровью:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s # как часто пробить после старта
timeout: 3s # проба медленнее этого = провал
retries: 5 # подряд провалов до "unhealthy"
start_period: 30s # окно благодати: провалы тут не идут в retries
api:
build: .
depends_on:
db:
condition: service_healthy # ждать, пока healthcheck db пройдётТеперь api держится, пока healthcheck db не сообщит healthy, то есть pg_isready реально успешно отработал против слушающего, принимающего Postgres. Доступные условия: service_started (слабый эквивалент дефолта — контейнер поднят), service_healthy (healthcheck проходит — то, что нужно для stateful-зависимостей) и service_completed_successfully (зависимость отработала до exit 0, для одноразовых init/seed/migration-задач).
Ручки healthcheck — это реальный протокол
Healthcheck — это команда, которую Docker гоняет внутри контейнера по расписанию, и её код возврата — вердикт: 0 healthy, 1 unhealthy. Четыре ручки тайминга — не украшение:
interval(дефолт 30s) — как часто проба бежит после поднятия контейнера. Более тесные интервалы (5s) быстрее ловят готовность и сбой, но стоят большего числа исполнений пробы.timeout(дефолт 30s) — одна проба медленнее этого = провал. Ставь его нижеinterval, чтобы зависшая проба не наложилась на следующую.retries(дефолт 3) — сколько подряд провалов переключают статус вunhealthy. Один временный провал не помечает сервис как unhealthy.start_period(дефолт 0s) — стартовое окно благодати, в течение которого провальные пробы не идут в зачётretriesи не помечают контейнер unhealthy. Это ручка, которую упускают: база, которой нужно 25 секунд на инициализацию, провалит первые несколько проб; безstart_period, достаточного покрыть инициализацию, эти провалы прожигаютretries, и контейнер объявляетсяunhealthyи никогда не становится удовлетворённой зависимостью — стек встаёт в дедлок на сервисе, который всего лишь ещё грузится. Задавайstart_periodпод худший холодный старт, щедро.
Все четыре ручки вместе образуют двухфазный протокол: start_period + interval/timeout отвечают на «сколько может занять старт и как часто пробить», а retries — на «сколько подряд провалов после старта означают поломку». Когда встретишь стек, намертво зависший при старте или сервис, который никогда не доходит до healthy, сначала проверь, какая ручка настроена неверно — это быстрее, чем добавлять sleep.
Самый частый сломанный healthcheck — тот, что проверяет не то — test: ["CMD", "true"] или пинг собственного TCP-порта контейнера — который рапортует healthy, пока приложение реально не обслуживает. Реальный healthcheck проходит путь готовности, который используют зависимые: pg_isready для Postgres, HTTP GET /healthz для веб-сервиса, redis-cli ping для Redis.
Интеграционный тест периодически падает, потому что API запрашивает Postgres до того, как тот принимает соединения, хотя в compose-файле есть depends_on: [db]. Какой минимальный корректный фикс?
▸Почему это работает
Почему start_period — ручка, тихо ставящая стек в дедлок? Потому что сбой невидим до медленного пути. На прогретой машине база инициализируется за 2 секунды, первая проба проходит, и отсутствие start_period никогда не кусается. На холодном CI-раннере или ноутбуке под нагрузкой та же база грузится 25 секунд; с interval: 5s и retries: 5 это пять провальных проб в первые 25 секунд — ровно достаточно исчерпать retries и пометить контейнер unhealthy до того, как он догрузился. Зависимый тогда ждёт вечно healthy, который не придёт. Урок — разделить два вопроса, на которые отвечают ручки тайминга: «сколько старт может законно занять?» — это start_period; «сколько провалов после старта значат поломку?» — это retries. Их смешение — баг.
Restart-политика: лечить или прятать
restart: решает, что происходит при выходе контейнера. Варианты: no (дефолт — никогда не рестартить), on-failure (рестарт только на ненулевом выходе, опционально с лимитом: on-failure:5), unless-stopped (рестарт при любом выходе, если ты явно не остановил) и always (рестарт при любом выходе, даже после ручной остановки, как только демон перезапустится). Senior-ловушка в том, что агрессивная restart-политика прячет крахи: сервис, падающий на реальном баге конфигурации, но с restart: always, входит в тесный crash-restart-цикл, и снаружи стек «выглядит поднятым» — контейнер существует и постоянно пересоздаётся — пока ничего не обслуживает и заливает логи. Docker гасит это экспоненциальным backoff между рестартами (от ~100ms с удвоением), но backoff замедляет цикл, а не вскрывает баг. Взаимодействие с healthcheck тоже важно: контейнер может быть запущен и перезапускаться, но никогда healthy, поэтому зависимый, гейтнутый на service_healthy, корректно отказывается стартовать против циклящего зависимого, а не гонится с ним. Используй on-failure с лимитом в dev, чтобы реально сломанный сервис остановился и показал ошибку, а always/unless-stopped оставь для сервисов, которым доверяешь быть временно нестабильными, а не хронически сломанными.
Контейнер базы грузится ~25с на медленном раннере. Его healthcheck использует interval: 5s, retries: 5 и без start_period. Что произойдёт и какая ручка это чинит?
- 01Почему depends_on без condition вызывает периодические сбои подключения и что именно это чинит?
- 02Объясни каждую ручку тайминга healthcheck и дедлок, который вызывает отсутствие start_period.
Главная ошибка — читать depends_on как «жди готовности», когда он значит лишь «стартуй в этом порядке». Compose стартует контейнер зависимости перед зависимым и считает зависимость удовлетворённой в момент запуска этого контейнера, а не когда процесс внутри открыл сокет — поэтому stateful-зависимость вроде Postgres, ещё проигрывающая WAL, проигрывает гонку нетерпеливому зависимому и периодически возвращает connection-refused, хуже на медленном CI. Фикс — гейтить по готовности: добавить healthcheck, проходящий настоящий путь готовности (pg_isready, GET /healthz, redis-cli ping), и зависеть с condition: service_healthy, который держит зависимого, пока проба реально не пройдёт; service_completed_successfully покрывает одноразовые init- и migration-задачи. Четыре ручки тайминга — это протокол, а не украшение: interval и timeout задают каденс пробы, retries считает подряд идущие провалы после старта, а start_period — окно благодати, которое должно покрыть худший холодный старт; опусти его — и медленный, но здоровый старт прожжёт retries, переключит контейнер в unhealthy и поставит в дедлок каждого зависимого, ждущего его. Наконец, restart-политика решает, лечит крах или прячет: always/unless-stopped рестартят сквозь временную нестабильность, но превращают реальный баг конфига в гашённый backoff crash loop, который выглядит поднятым, ничего не обслуживая, поэтому в dev стоит предпочесть on-failure с лимитом, дающим реально сломанному сервису остановиться и показать ошибку. Теперь, когда видишь интеграционный тест, который проходит локально, но флачит на CI — первым делом проверь, есть ли у depends_on condition и покрывает ли start_period холодный старт на медленном раннере.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.