Надёжность: healthcheck, restart policy, graceful shutdown

Контейнер, который «запущен», ещё не значит «работает»; разбираем healthcheck, политики перезапуска и корректное завершение по SIGTERM.

HEALTHCHECK — команда, по которой Docker регулярно проверяет, жив ли сервис внутри контейнера, и отмечает его как healthy или unhealthy, не ограничиваясь фактом «процесс не упал».

Зачем это на практике

Статус контейнера running говорит лишь, что главный процесс не завершился. Но процесс может висеть: веб-сервер запущен, а в ответ на запросы отдаёт 500 из-за оборванного коннекта к базе. Снаружи он «работает», по факту — нет. Healthcheck закрывает этот разрыв: вы описываете, как именно проверить здоровье, и оркестратор (Compose, Swarm, балансировщик) перестаёт слать трафик на нездоровый контейнер.

Вторая половина надёжности — поведение при сбоях. Если контейнер всё же упал или хост перезагрузился, его нужно поднять автоматически. За это отвечает restart policy.

HEALTHCHECK

Проверку задают прямо в Dockerfile. Команда возвращает 0 (здоров) или 1 (болен):

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl --fail http://localhost:8080/health || exit 1

Параметры: --interval — как часто проверять; --timeout — сколько ждать ответа; --retries — сколько подряд провалов до статуса unhealthy; --start-period — «льготное» время на старт, в течение которого провалы не считаются (приложение ещё инициализируется). В Compose то же самое описывается декларативно:

services:
  app:
    image: myapp:latest
    healthcheck:
      test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
      interval: 30s
      timeout: 3s
      retries: 3
      start_period: 10s

Статус виден в docker ps (колонка STATUS показывает (healthy)/(unhealthy)). Зависимые сервисы можно ждать по здоровью: depends_on: { db: { condition: service_healthy } } — приложение стартует только когда база реально готова принимать запросы, а не просто «запущена».

Restart policy

Политика перезапуска говорит демону Docker, что делать, когда контейнер завершился:

docker run --restart unless-stopped myapp:latest
ПолитикаПоведение
noне перезапускать (по умолчанию)
on-failure[:N]перезапуск только при ненулевом коде выхода, не более N раз
alwaysперезапускать всегда, в т.ч. после рестарта демона
unless-stoppedкак always, но не поднимать контейнер, остановленный вручную

Для постоянно работающих сервисов в проде берут unless-stopped: контейнер сам поднимется после перезагрузки сервера, но если вы намеренно остановили его (docker stop), Docker не станет навязчиво воскрешать его при следующем старте демона. В Compose: restart: unless-stopped.

Важно понимать границу применимости. Restart policy спасает от случайных сбоев — упавшего из-за временной ошибки процесса, перезагрузки хоста, краткого недоступности зависимости. Но она же маскирует системные проблемы: если контейнер падает каждые пару секунд из-за бага в конфигурации, политика always будет бесконечно его поднимать (Docker применяет нарастающую задержку между попытками, но не сдаётся). Снаружи это выглядит как «сервис вроде живой, но не отвечает». Поэтому restart policy — не замена мониторингу: всплеск перезапусков должен попадать в алерты, а не тихо крутиться в фоне. Для разовых задач (миграция БД, разовый скрипт) перезапуск вообще вреден — берите no или on-failure с ограничением попыток.

Graceful shutdown и SIGTERM

Когда вы делаете docker stop, Docker шлёт главному процессу сигнал SIGTERM, ждёт grace-period (по умолчанию 10 секунд) и, если процесс не завершился, добивает его SIGKILL. Корректное приложение должно перехватить SIGTERM и завершиться красиво: дослать ответы текущим клиентам, закрыть соединения с БД, сбросить буферы. Иначе вы теряете запросы в полёте при каждом деплое.

import signal
import sys

running = True

def shutdown(signum, frame):
    global running
    print("Получен SIGTERM, завершаюсь корректно")
    running = False

signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)

# имитация рабочего цикла
for step in range(3):
    if not running:
        break
    print(f"обрабатываю задачу {step}")

print("ресурсы закрыты, выход")
sys.exit(0)

Вывод:

обрабатываю задачу 0
обрабатываю задачу 1
обрабатываю задачу 2
ресурсы закрыты, выход

Как это работает под капотом: PID 1 и зомби

Главный процесс контейнера получает PID 1 — а у PID 1 в Linux особая роль. Во-первых, ядро не применяет к нему сигналы по умолчанию: если процесс не повесил обработчик на SIGTERM, сигнал просто игнорируется, и docker stop каждый раз ждёт 10 секунд и бьёт SIGKILL. Во-вторых, PID 1 обязан пожинать (reap) завершившиеся дочерние процессы, иначе они остаются «зомби» и копятся в таблице процессов.

Особенно коварен запуск через shell-форму CMD python main.py: PID 1 получает /bin/sh, который не пересылает сигналы дочернему python и не жнёт зомби. Решения два. Использовать exec-форму, чтобы ваш процесс стал PID 1 напрямую:

CMD ["python", "main.py"]

Либо подложить крошечный init-процесс, который правильно проксирует сигналы и жнёт зомби. Docker умеет это сам по флагу --init (он встраивает tini):

docker run --init myapp:latest

В Compose — init: true. Если приложение само порождает поддочерние процессы (воркеры, форки), tini/--init почти обязателен.

Частые ошибки

  • Считать running синонимом «здоров». Без healthcheck балансировщик льёт трафик на сломанный, но не упавший контейнер.
  • Shell-форма CMD. CMD python main.py ставит PID 1 = sh, сигналы не доходят до приложения. Используйте JSON-форму CMD ["python", "main.py"].
  • Игнорировать SIGTERM. Тогда каждый деплой = жёсткий SIGKILL через 10 с и потеря запросов в полёте.
  • Тяжёлый healthcheck. Проверка, дёргающая базу и внешние API, сама создаёт нагрузку и каскадные сбои. Эндпоинт /health должен быть лёгким.
  • restart: always на разовых задачах. Скрипт-миграция отработает, выйдет с кодом 0 — и Docker запустит его снова по кругу.

Итоги

  • HEALTHCHECK отличает «процесс жив» от «сервис здоров»; настройте interval, retries, start_period.
  • Для долгоживущих сервисов берите --restart unless-stopped; для разовых задач — on-failure или вовсе ничего.
  • Перехватывайте SIGTERM и завершайтесь корректно, иначе теряете запросы при деплое.
  • Используйте exec-форму CMD, чтобы ваш процесс стал PID 1 и получал сигналы.
  • Если есть дочерние процессы — включайте --init/tini, чтобы жать зомби и проксировать сигналы.
Проверьте себя
1. Чем статус healthy от HEALTHCHECK отличается от статуса running?
AНичем, это синонимы
Brunning значит лишь, что процесс не завершился; healthy значит, что проверка реально подтвердила работоспособность сервиса
Chealthy показывает использование памяти
Drunning относится к образу, а healthy — к контейнеру
2. Почему shell-форма CMD python main.py мешает корректному завершению по SIGTERM?
APython не умеет ловить сигналы
BPID 1 становится /bin/sh, который не пересылает SIGTERM дочернему процессу python
CShell-форма запрещена синтаксисом Dockerfile
DSIGTERM в контейнерах не работает вообще
3. Какая restart policy не станет поднимать контейнер, который вы остановили вручную через docker stop?
Aalways
Bon-failure
Cunless-stopped
Dno