Надёжность: 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, чтобы жать зомби и проксировать сигналы.