Знакомая ночь: оркестратор показывает все контейнеры зелёными, аптайм четыре дня, а пользователи жалуются, что приложение висит. Заходишь внутрь - процесс есть, порт открыт, но за портом мёртвое тело. Пул соединений к базе протух, воркер встал в дедлок, очередь не разгребается. 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+). Позволяет внутри прогрева опрашивать чаще, чтобы быстрее поймать момент готовности.
Код: Выделить всё
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"
}
]
}
Что 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
Проблема 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
Код: Выделить всё
services:
web:
image: myapp:1.0
init: true
restart: unless-stopped
Код: Выделить всё
RUN apk add --no-cache tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
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);
});
Зависимости старта: 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
Грабли: контейнер 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 по здоровью - и получишь контейнер, который честно говорит о своём состоянии, чисто умирает и аккуратно встаёт в строй.