Здоровье и автозапуск: HEALTHCHECK, init и сигналы

Рейтинг: 64.6% · 7 голосов
Практический курс по Docker: образы, контейнеры, тома, сети, Compose и продакшен. Уроки по главам с обсуждением.
Ответить
Аватара пользователя
Marina_DevOps
Сообщения: 54
Зарегистрирован: 11 май 2026, 05:31

Здоровье и автозапуск: HEALTHCHECK, init и сигналы

Сообщение Marina_DevOps »

Оглавление курса (46)
  1. Что такое Docker и какие задачи он решает
  2. Установка Docker и запуск первого контейнера
  3. Образы: слои, теги и реестр Docker Hub
  4. Пишем свой Dockerfile
  5. Тома и хранение данных: volumes и bind mounts
  6. Сети в Docker: связываем контейнеры между собой
  7. Переменные окружения и конфигурация контейнеров
  8. Docker Compose: поднимаем многоконтейнерное приложение
  9. Оптимизация образов: multi-stage сборка, размер и кэш слоёв
  10. Логи, отладка и мониторинг контейнеров
  11. Базовая безопасность контейнеров
  12. Подготовка к продакшену: что важно учесть
  13. Dockerfile глубже: ENTRYPOINT и CMD, HEALTHCHECK, .dockerignore, запуск не от root
  14. Реестры образов: приватные registry, push и pull, теги и digest, imagePullSecrets
  15. BuildKit и buildx: multi-arch сборки, секреты сборки, экспорт кэша
  16. Docker в CI/CD: автосборка, сканирование образов (Trivy, Docker Scout), публикация
  17. Итоговый проект и куда расти: от Dockerfile до прода, обзор оркестрации (Kubernetes, Podman, OCI)
  18. Архитектура Docker: dockerd, containerd, runc и OCI
  19. Как работает изоляция: namespaces и cgroups
  20. Образы изнутри: слои, OverlayFS и storage driver
  21. Жизненный цикл контейнера и политики перезапуска
  22. Отладка контейнеров: exec, attach, nsenter, debug
  23. Сети Docker глубже: bridge, overlay, macvlan и iptables
  24. Docker Compose глубже: profiles, healthcheck-зависимости, override
  25. Данные и тома глубже: драйверы, бэкап, права
  26. Секреты и конфигурация: как не светить пароли
  27. Ограничение ресурсов: CPU, память, OOM и cgroups
  28. Здоровье и автозапуск: HEALTHCHECK, init и сигналы (вы здесь)
  29. Логирование контейнеров: драйверы, ротация, сбор
  30. Мониторинг контейнеров: stats, cAdvisor, Prometheus
  31. Безопасность глубже: rootless, user namespaces, seccomp, capabilities
  32. Supply chain: сканирование, SBOM и подпись образов
  33. Глубокая оптимизация образов: distroless, scratch, кэш
  34. Multi-arch и сборка под ARM: buildx и эмуляция
  35. Реестры в продакшене: Harbor и облачные registry
  36. Docker без Docker: Podman, containerd, nerdctl, Buildah
  37. От Compose к оркестрации: когда контейнеров становится много
  38. Docker Swarm: встроенная оркестрация
  39. Сборка образов в CI без демона: kaniko, BuildKit, Buildah
  40. Траблшутинг Docker: типичные ошибки и как чинить
  41. Производительность и эксплуатация Docker-хоста
  42. Docker Desktop, WSL2 и альтернативы на Windows и Mac
  43. Контейнеризация приложений: бэкенд, фронтенд, БД
  44. Docker и Kubernetes: containerd, миграция, kompose
  45. Лучшие практики и антипаттерны Docker
  46. Сквозной проект: production-ready стек и путь дальше
Контейнер запустился - это не значит, что он работает

Знакомая ночь: оркестратор показывает все контейнеры зелёными, аптайм четыре дня, а пользователи жалуются, что приложение висит. Заходишь внутрь - процесс есть, порт открыт, но за портом мёртвое тело. Пул соединений к базе протух, воркер встал в дедлок, очередь не разгребается. Docker считает контейнер живым только потому, что главный процесс не упал. А упасть он и не собирается - он просто перестал делать работу.

Вот ровно эту дыру закрывают три механизма, про которые этот урок. docker healthcheck учит Docker отличать "процесс жив" от "сервис исправен". Init-процесс и правильная обработка сигналов решают проблему docker pid 1, из-за которой контейнеры не умирают по docker stop и копят зомби. А restart policy связывает всё это в самовосстанавливающуюся систему. Разберём механику до дна, потому что слабое место тут - не команды, а понимание, ЧТО именно проверяется и КТО кому шлёт сигнал.

Изображение

HEALTHCHECK: как Docker проверяет здоровье изнутри

HEALTHCHECK - это команда, которую демон Docker периодически запускает ВНУТРИ контейнера в его namespace. Не снаружи, не по сети с хоста - именно внутри, через тот же runtime, что и основной процесс. Если команда вернула exit code 0 - контейнер healthy. Код 1 - unhealthy. Код 2 зарезервирован, его использовать нельзя.

В Dockerfile это выглядит так:

Код: Выделить всё

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD curl -fsS http://localhost:8080/healthz || exit 1
Разберём каждый флаг, потому что дефолты тут коварные:
  • --interval=30s - как часто запускать проверку. Дефолт 30 секунд. Отсчёт идёт от завершения предыдущей проверки, а не от старта.
  • --timeout=3s - сколько ждать ответа. Если команда не уложилась, проверка считается провалом. Дефолт 30 секунд - это очень много, при висящем сервисе ты полминуты будешь ждать вердикта. Ставь реальный SLA эндпоинта.
  • --start-period=40s - окно прогрева. Внутри него ПРОВАЛЫ не считаются. Это ключевой флаг для медленных стартов: JVM, миграции БД, прогрев кеша. Без него контейнер с долгим стартом отлетит в unhealthy и попадёт под рестарт ещё до того, как успеет подняться.
  • --retries=3 - сколько провалов подряд переводят в unhealthy. До исчерпания retries статус остаётся прежним. Защита от единичного флапа.
  • --start-interval - частота проверок внутри start-period, относительно свежая опция (демон 25.0+). Позволяет внутри прогрева опрашивать чаще, чтобы быстрее поймать момент готовности.
Статусы у контейнера три. starting - стартовое состояние; пока идёт первая проверка или мы внутри start-period и ещё не было ни одного успеха. healthy - последняя проверка вернула 0. unhealthy - провалов накопилось retries подряд. Посмотреть текущий статус и историю:

Код: Выделить всё

docker inspect --format '{{json .State.Health}}' web | jq

Код: Выделить всё

{
  "Status": "unhealthy",
  "FailingStreak": 5,
  "Log": [
    {
      "Start": "2026-06-15T09:41:12Z",
      "End": "2026-06-15T09:41:15Z",
      "ExitCode": 1,
      "Output": "curl: (28) Operation timed out after 3001 ms"
    }
  ]
}
Тут видно главное для разбора инцидента: FailingStreak - сколько провалов подряд, и Log - последние проверки с их выводом и exit code. Docker хранит до пяти последних записей с урезанным выводом (около 4 КБ). Это первое, куда смотришь, когда контейнер краснеет.

Что healthcheck делает, а чего не делает

Здесь живёт главное заблуждение. Голый Docker по статусу healthcheck сам контейнер не перезапускает. Никогда. Restart policy реагирует на ВЫХОД процесса (exit code контейнера), а не на статус здоровья. То есть контейнер может вечно висеть unhealthy и при этом крутиться - демон его трогать не будет.

Тогда зачем оно вообще? Healthcheck в первую очередь нужен оркестратору и зависимостям. В Swarm unhealthy таск убивается и пересоздаётся планировщиком. В Compose другой контейнер может ждать, пока этот станет healthy, через condition. В Kubernetes, кстати, инструкция HEALTHCHECK из образа вообще игнорируется - там свои liveness/readiness пробы, и это правильно: оркестратор должен сам решать, как проверять. Но локально и в одиночном Docker healthcheck - это твой индикатор и точка зацепления для зависимостей старта.

Если ты хочешь именно автоперезапуск по нездоровью на голом Docker - связку делают так: healthcheck-команда при провале не просто возвращает 1, а валит главный процесс (например, приложение само ловит несколько провалов внутренней самодиагностики и завершается), после чего срабатывает restart policy on-failure. Либо внешний sidecar-демон (autoheal и аналоги) слушает события и дёргает docker restart. Голым HEALTHCHECK + restart это не связывается напрямую - запомни это, на собеседованиях валят именно тут.

Restart policy: что и когда перезапускается

Restart policy задаёт реакцию демона на завершение контейнера. Четыре варианта:
  • no - не перезапускать. Дефолт.
  • on-failure[:N] - перезапуск только если exit code не ноль. Опционально ограничить N попытками. Идеально для батч-задач, которые должны досчитать.
  • always - перезапускать всегда, при любом выходе, и поднимать при старте демона. Минус: даже если ты руками остановил контейнер, после рестарта Docker он снова поднимется.
  • unless-stopped - как always, но если ты явно остановил контейнер через docker stop, он не воскреснет при перезапуске демона. На практике для долгоживущих сервисов это самый вменяемый выбор.

Код: Выделить всё

docker run -d --restart=unless-stopped --name web myapp:1.0
Важная деталь про backoff: Docker не лупит рестартами в бесконечном цикле без передышки. Задержка растёт - демон удваивает интервал между попытками (100 мс, 200, 400 и так далее), чтобы крашащийся контейнер не сжёг CPU restart-штормом. Увидеть счётчик можно в docker inspect в поле RestartCount.

Проблема PID 1: почему контейнер не умирает от docker stop

Теперь второй большой блок. В контейнере главный процесс получает PID 1. И ядро Linux относится к PID 1 особо - это исторически init, родитель всех процессов. На PID 1 навешаны два неявных обязательства, которые обычное приложение не выполняет.

Первое - сигналы. Для PID 1 ядро НЕ ставит дефолтные обработчики сигналов. Для обычного процесса SIGTERM по умолчанию означает "завершись". Для PID 1 - не означает ничего: если процесс сам не установил хендлер на SIGTERM, сигнал просто игнорируется. Вот почему docker stop на наивном контейнере выглядит так: ты шлёшь команду, ждёшь 10 секунд, ничего не происходит, и Docker добивает контейнер через SIGKILL. Те самые десять секунд паузы при каждом docker stop - это и есть симптом. SIGKILL нельзя перехватить, поэтому приложение умирает мгновенно и грязно: открытые транзакции брошены, файлы не дописаны, соединения оборваны.

Второе - зомби. Когда любой процесс в системе умирает, а его родитель уже мёртв, он переусыновляется к PID 1. Настоящий init обязан вызвать wait() и "пожать" такого сироту, освободив запись в таблице процессов. Зомби - это процесс, который завершился, но его статус никто не забрал; он не ест память, но занимает PID. Обычное приложение в роли PID 1 про сирот ничего не знает и wait() не зовёт. В контейнере, который форкает дочерние процессы (шеллы, хуки, sidecar-команды), за дни работы PID-таблица забивается зомби, и в худшем случае ты упираешься в лимит PID и не можешь создать новый процесс. Симптом - growing список процессов в состоянии Z в docker top.

docker init и tini: правильный PID 1

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

Код: Выделить всё

docker run --init -d --name web myapp:1.0
Флаг docker init (точнее, --init) подсовывает tini как PID 1; теперь docker stop работает мгновенно и чисто, сигналы доходят до приложения, зомби пожинаются. В Compose то же самое одним полем:

Код: Выделить всё

services:
  web:
    image: myapp:1.0
    init: true
    restart: unless-stopped
Когда явно тащить docker tini внутрь образа через ENTRYPOINT, а когда хватит флага? Флаг --init - решение на уровне запуска, удобно и достаточно в большинстве случаев. Но если образ должен быть надёжным сам по себе (его запускают разные люди, в разных средах, забывая про флаг), зашивай init в ENTRYPOINT:

Код: Выделить всё

RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
Двойное тире после tini обязательно - оно отделяет аргументы tini от команды. Альтернативы tini существуют (dumb-init от Yelp, s6-overlay для нескольких процессов), но для одного приложения tini - канонический выбор.

Graceful shutdown: trap SIGTERM в самом приложении

Init доставит сигнал, но красиво завершиться должно само приложение. docker graceful shutdown - это договор: по SIGTERM приложение перестаёт брать новую работу, доделывает текущую и выходит само, не дожидаясь SIGKILL. Жизненный цикл остановки такой: docker stop шлёт SIGTERM, ждёт grace-период (по умолчанию 10 секунд, меняется через --time у stop или stop_grace_period в Compose), и только потом SIGKILL.

Минимальный обработчик в Node.js:

Код: Выделить всё

process.on('SIGTERM', async () => {
  server.close();          // перестаём принимать новые соединения
  await pool.end();        // закрываем пул к БД
  await queue.shutdown();  // дорабатываем текущие задачи
  process.exit(0);
});
Три типичные ошибки тут. Первая - ловить только SIGINT (Ctrl+C), но не SIGTERM; от Docker прилетает именно SIGTERM. Вторая - запускать приложение через шелл в shell-форме (CMD node server.js без скобок): тогда PID 1 - это /bin/sh, и сигнал застрянет на нём, не дойдя до node; пиши exec-форму CMD ["node","server.js"] или явно exec-ай в ENTRYPOINT-скрипте. Третья - не уложиться в grace-период: если дренаж дольше 10 секунд, увеличивай stop_grace_period, иначе SIGKILL прервёт тебя на середине.

Зависимости старта: depends_on по здоровью

Классическая боль: web стартует раньше, чем поднялась postgres, ловит connection refused и падает. Наивный depends_on в Compose ждёт только ЗАПУСКА контейнера-зависимости, а не его готовности - а между "контейнер стартовал" и "база принимает запросы" секунды или минуты. Решение - привязать зависимость к healthcheck:

Код: Выделить всё

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s
  web:
    image: myapp:1.0
    depends_on:
      db:
        condition: service_healthy
Теперь web не стартует, пока db не станет healthy. Старый подход через скрипты wait-for-it.sh или wait-for-it (busy-loop, который долбит порт до ответа) всё ещё встречается и полезен там, где нет оркестратора зависимостей, но в современном Compose v2 condition: service_healthy чище и не требует тащить скрипт в образ. Учти: depends_on работает только в рамках одного compose-проекта и только при старте, он не перезапустит web, если db упадёт позже.

Грабли: контейнер healthy, а приложение нет
  • Проверка не того. healthcheck бьёт в /healthz, а тот отвечает 200, ничего реально не проверяя. Это liveness, а тебе для готовности нужна readiness: эндпоинт должен щупать зависимости - пинг к БД, доступность очереди. Иначе зелёный статус врёт.
  • curl, которого нет. В slim/distroless-образах нет curl и wget. healthcheck с curl будет вечно возвращать 127 (command not found) и контейнер навсегда unhealthy. Используй встроенную проверку приложения или клади легковесный бинарь (grpc_health_probe, или флаг самого сервиса).
  • Тяжёлый healthcheck. Проверка, дёргающая сложный SQL-запрос каждые 5 секунд, сама грузит сервис. Держи её дешёвой.
  • Забыли start-period. Без него медленный старт = ранние провалы = в unhealthy ещё до готовности.
  • restart=always на упавшем по логике. Контейнер, который крашится мгновенно из-за бага конфигурации, в паре с always уйдёт в restart-цикл и зальёт логи. Используй on-failure с лимитом или чини причину.
Мини-лаба: собери надёжный контейнер руками
  • Напиши крошечный сервис (любой язык), который слушает порт и имеет эндпоинт /healthz. Добавь обработчик SIGTERM, который печатает "graceful shutdown" и выходит с кодом 0.
  • Собери два образа: один с CMD в shell-форме без tini, второй с ENTRYPOINT ["tini","--"] и exec-формой CMD.
  • Запусти оба и сделай docker stop. Засеки время. Первый виснет на 10 секунд и не печатает graceful shutdown (сигнал застрял на sh), второй останавливается мгновенно и печатает.
  • Добавь в Dockerfile HEALTHCHECK с проверкой /healthz, --start-period и --retries. Запусти, посмотри docker ps - дождись перехода starting -> healthy. Сломай эндпоинт изнутри и проверь docker inspect ... .State.Health: увидишь рост FailingStreak и переход в unhealthy.
  • В compose.yaml свяжи web и db через depends_on: condition: service_healthy и pg_isready. Останови db, запусти проект заново и убедись, что web ждёт готовности базы, а не падает на connection refused.
Контрольные вопросы
  • Перезапустит ли голый Docker контейнер, который ушёл в unhealthy, при restart=always? Почему?
  • Что конкретно делает init-процесс (tini) как PID 1 и от каких двух проблем он спасает?
  • Почему docker stop на контейнере без обработчика SIGTERM висит 10 секунд, и что происходит после?
  • Чем depends_on: condition: service_healthy лучше наивного depends_on и скрипта wait-for-it?
Итог

Надёжность контейнера стоит на трёх ногах. HEALTHCHECK отвечает на вопрос "сервис реально работает?" и кормит ответом оркестратор и зависимости - но сам по себе ничего не перезапускает. Правильный PID 1 (--init или зашитый tini) доставляет сигналы и пожинает зомби, без него docker stop грязный и медленный. Обработчик SIGTERM в приложении превращает остановку в управляемый дренаж. Свяжи это с restart policy и depends_on по здоровью - и получишь контейнер, который честно говорит о своём состоянии, чисто умирает и аккуратно встаёт в строй.
👍3 ❤️2 🔥1 😄 🤔3
Аватара пользователя
postgres13
Сообщения: 1
Зарегистрирован: 15 май 2026, 14:18

Re: Здоровье и автозапуск: HEALTHCHECK, init и сигналы

Сообщение postgres13 »

О, так вот почему у меня docker stop всегда тупит ровно 10 секунд. Сидел и думал что это сеть тормозит, а это sh сигнал жрёт. Спасибо, сейчас перепишу CMD в exec-форму
👍2 ❤️1 🔥 😄 🤔1
Аватара пользователя
joh0elway
Сообщения: 1
Зарегистрирован: 12 май 2026, 05:24

Re: Здоровье и автозапуск: HEALTHCHECK, init и сигналы

Сообщение joh0elway »

Запутался в одном месте: если restart по unhealthy не срабатывает на голом докере, то получается healthcheck без оркестратора почти бесполезен? Или autoheal-сайдкар это нормальная прод-практика, или костыль?
👍2 ❤️ 🔥1 😄 🤔
Ответить
← Предыдущая глава
Ограничение ресурсов: CPU, память, OOM и cgroups
Следующая глава →
Логирование контейнеров: драйверы, ротация, сбор

Все главы курса «Docker: контейнеризация от основ до продакшена»

Поделиться темой: ✈ Telegram VK

Вернуться в «Docker с нуля»

Кто сейчас на конференции

Сейчас этот форум просматривают: нет зарегистрированных пользователей и 1 гость