open atlas
↑ К треку
Docker: контейнеры как система DOCK · 06 · 02

Порядок старта — не готовность: depends_on, healthcheck и restart-политики

depends_on без healthcheck упорядочивает старт контейнеров, а не готовность — API гонится с базой, ещё проигрывающей WAL. condition: service_healthy плюс healthcheck (interval, timeout, retries, start_period) гейтит по готовности; restart решает, лечит крах или прячет.

DOCK Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

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. Что произойдёт и какая ручка это чинит?

Вспомните перед уходом
  1. 01
    Почему depends_on без condition вызывает периодические сбои подключения и что именно это чинит?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.