14 сентября 2026 г.

Healthcheck, который реально перезапускает процесс

Excerpt: PM2 видит только смерть процесса, а не зависший событийный цикл. Собираем внешний healthcheck на bash и systemd timer, который сам вызывает pm2 restart, когда приложение перестаёт отвечать - с cooldown против restart-loop и опциональным уведомлением в Telegram.

PM2 перезапускает процесс, когда тот падает. Реального healthcheck - проверки, которая стучится в приложение снаружи и сама нажимает restart, если оно не отвечает, - у него из коробки нет. Если вы уже прошли диагностику из статьи про утечку памяти на маленьком VPS и знаете, что процесс не падает, а просто перестаёт отвечать на запросы, разбираться дальше нужно не в мониторинге, а в скрипте, который сам перезапустит зависший процесс, пока вы спите.

У зависания есть три разных причины, и путать их вредно. Процесс может упасть - PM2 сам поднимет его снова. Может встать событийный цикл целиком - тяжёлый синхронный код, бесконечный цикл без await, `fs.*Sync` на большом файле - тогда не ответит вообще ничего на этом процессе, включая пустой `/health`. А может зависнуть конкретный запрос - например, вызов к базе без таймаута, у которой оборвалось соединение: сам событийный цикл жив и продолжает обслуживать остальные запросы, просто этот конкретный путь никогда не завершается. PM2 не видит ни второй, ни третий случай - `max_memory_restart` тоже не поможет, если дело не в памяти. Решение - внешний healthcheck: bash-скрипт стучится в приложение по HTTP на 127.0.0.1, считает подряд идущие провалы в файле состояния и при достижении порога (по умолчанию - два провала подряд) сам вызывает `pm2 restart`. Расписание - systemd timer, который логирует каждый запуск в journalctl. Уведомление в Telegram - опциональная надстройка поверх, не обязательное условие работы healthcheck. Restart сам по себе лечит зависший процесс, но не лечит упавшую снаружи базу - если проверка бьёт по маршруту, который зависит от внешнего сервиса, а не только от самого приложения, рестарт может начать долбить по кругу здоровый процесс, пока внешняя проблема не решится сама. Ниже - как эту ловушку обойти, и отдельный предохранитель в скрипте, который останавливает попытки, если рестарты не помогают.

Почему PM2 показывает «online», хотя сайт не отвечает

PM2 следит за тем, жив ли процесс операционной системы - существует ли он вообще, не завершился ли с ошибкой, не убил ли его OOM killer. Статус `online` означает ровно это: процесс запущен. Он ничего не говорит о том, отвечает ли процесс на запросы.

Стоит сразу развести два разных отказа, которые снаружи выглядят одинаково - сайт не отвечает, - но требуют разного лечения.

Событийный цикл (event loop) целиком заблокирован. Событийный цикл - механизм Node.js, который по очереди разбирает входящие задачи: HTTP-запросы, таймеры, обратные вызовы после сетевых операций. Заблокировать его может только синхронный, блокирующий код - тяжёлый цикл без await, `fs.readFileSync` на большом файле, `crypto.pbkdf2Sync` с большим числом итераций, случайный бесконечный `while` без await внутри. Пока такой код выполняется, процесс не может обработать вообще ничего, включая самый пустой `/health`-роут на том же сервере - потому что обслуживать даже его тоже должен тот же самый событийный цикл.

Событийный цикл жив, но конкретный запрос завис. Обычный вызов к базе или внешнему API в Node.js - асинхронный: пока ждём ответа, событийный цикл свободен и обслуживает остальные запросы. Если у сетевого клиента не выставлен таймаут, а соединение оборвалось молча (не по TCP RST, а зависло, например из-за файрвола или упавшего линка), промис от этого вызова может не разрешиться никогда - но это блокирует только тот конкретный запрос и всё, что от него зависит. Остальной процесс, включая пустой `/health`, отвечает нормально. Именно этот случай незаметен для PM2 дольше всего: процесс жив, CPU не растёт, память не растёт - просто часть запросов виснет навсегда.

`max_memory_restart` в конфиге PM2 закрывает только один конкретный сценарий отказа: рост потребления памяти выше порога, как в случае с утечкой из предыдущей статьи. Ни блокировку событийного цикла, ни зависший запрос к базе он не ловит вообще - память в обоих случаях может стоять на месте.

Симптом

Обычный PM2 restart сработает?

Healthcheck сработает?

Необработанное исключение роняет процесс

Да

Не обязателен, но тоже сработает

Событийный цикл заблокирован синхронным кодом, процесс жив

Нет

Да - не ответит вообще ничего, включая пустой /health

Конкретный запрос завис без таймаута (например, к базе), событийный цикл свободен

Нет

Да, если проверяемый маршрут использует то же соединение/пул, что и зависший запрос

Утечка памяти, RSS дорастает до `max_memory_restart`

Да

Да, дублирует защиту

Медленная утечка держится ниже порога, но GC-паузы срывают ответы

Нет

Да, если паузы валят таймаут запроса

Упал nginx или Cloudflare Tunnel, сам процесс в порядке

Нет

Нет - и это правильно, см. ниже про 127.0.0.1

Упала внешняя база или API, сам процесс и событийный цикл в порядке

Нет

Зависит от того, что проверяем - см. раздел про liveness и readiness ниже

Liveness и readiness - не одно и то же

Соблазн - завести пустой маршрут вроде `/api/health`, который просто отвечает `200 OK`, ничего не делая. Проблема в том, что такой маршрут ловит только блокировку событийного цикла, а не зависший запрос к конкретной зависимости - если утечка из первой статьи серии как раз про такой случай (зависание на конкретном запросе к базе), пустой health-роут ответит мгновенно, потому что он этот код вообще не трогает, а реальная главная страница в это время не отвечает никому.

Значит, нужно проверять маршрут, который трогает ту же базу или кеш, что и боевой трафик? Не всегда - и здесь есть реальная ловушка. Если такой маршрут зависит от внешней базы, которая упала на 20 минут целиком, - сам процесс и событийный цикл при этом полностью здоровы, а healthcheck честно видит провал за провалом и раз за разом перезапускает Node.js. Рестарт процесса не чинит упавшую базу: приложение поднимется, тут же снова упрётся в мёртвую базу, снова не ответит на проверку - и получится ровно тот самый restart-loop, от которого хотелось защититься.

Разница - между liveness (жив ли сам процесс и способен ли он вообще работать) и readiness (может ли он прямо сейчас полноценно обслуживать пользователя). Для автоматического restart безопаснее liveness-проверка - той, что реагирует на состояние, которое перезапуск действительно способен исправить. Проверка, завязанная на внешнюю зависимость, полезна для мониторинга и алертов, но не должна сама решать вопрос о перезапуске - если только вы заранее не доказали на своём приложении, что конкретно зависает именно пул соединений внутри процесса, а не сама внешняя база, и что рестарт эту конкретную проблему действительно чинит.

Практический компромисс для типичного маленького VPS-приложения: проверяйте маршрут, который делает что-то реальное (не пустышку), но следите за отдельным сигналом, если начали массово падать именно проверки, завязанные на конкретную внешнюю зависимость - это тот случай, где circuit breaker в скрипте ниже (не больше N рестартов подряд, дальше только алерт) особенно важен, а не опция для перестраховки.

Зачем внешний healthcheck, если уже есть мониторинг

Дашборд, алерт в Slack, письмо от системы мониторинга - все они делают одно и то же: сообщают человеку, что что-то не так. Дальше человеку нужно проснуться в три часа ночи, открыть ноутбук, подключиться по SSH и вручную выполнить `pm2 restart`. Пока он это делает, приложение недоступно.

Healthcheck из этой статьи делает тот же restart сам, в течение одной-двух минут после того, как приложение перестало отвечать, - и всё равно оставляет след: строку в journalctl, а если настроено уведомление - сообщение в Telegram. Утром есть что разобрать, но разбирать это можно после кофе, а не вместо сна.

Как устроен healthcheck: проба, счётчик отказов, порог, перезапуск процесса

Логика скрипта простая, и это её главное достоинство - в ней почти нечему ломаться.

  • Раз в одну-две минуты скрипт делает HTTP-запрос к приложению с коротким таймаутом.
  • Если ответ пришёл - скрипт обнуляет счётчик отказов в файле на диске и выходит.
  • Если ответа нет - увеличивает счётчик на единицу и логирует провал.
  • Как только счётчик достигает порога (два провала подряд) - скрипт вызывает `pm2 restart <имя процесса>`, сбрасывает счётчик и, если настроено, отправляет уведомление.

Порог именно в два провала подряд, а не в один, нужен, чтобы не перезапускать здоровый процесс из-за случайного сетевого затыка на пару секунд - короткий всплеск нагрузки или пауза сборщика мусора не должны считаться падением. Один провал - это шум, два подряд с интервалом в минуту - уже сигнал.

Отдельный вопрос - что именно проверять. Соблазн завести пустой маршрут `/api/health`, который просто отвечает `200 OK`, ничего не делая, - ловушка: если зависание происходит на конкретном запросе к базе (как в примере из статьи про утечку памяти), пустой health-роут ответит мгновенно, потому что он этот код вообще не трогает, а реальная главная страница в это время не отвечает никому. Проверяйте маршрут, который делает то же самое, что и обычный запрос пользователя - в большинстве случаев это просто главная страница. Отдельный лёгкий `/health` имеет смысл, только если он сам дергает то же соединение к базе или тот же кеш, что и боевой трафик, а не существует отдельно от него.

Скрипт healthcheck.sh

Сохраните скрипт под тем же пользователем, из-под которого запущен PM2 - в этой серии статей это `deploy` (замените на своего). Обязательно поправьте порт и путь, если приложение слушает не 3000, и имя процесса, если оно не `web`.

#!/usr/bin/env bash
set -euo pipefail

# что и как проверяем
APP_NAME="web" # имя процесса в pm2 (см. `pm2 list`)
URL="http://127.0.0.1:3000/" # бьём в локальный порт, не в публичный домен
TIMEOUT=5 # секунд ждём ответа, дальше проверка провалена
FAIL_THRESHOLD=2 # сколько провалов подряд запускают перезапуск
COOLDOWN=300 # не перезапускать чаще, чем раз в 5 минут

# защита от бесконечного restart-loop, если рестарт не помогает
MAX_RESTARTS=3 # не больше стольки перезапусков подряд...
RESTART_WINDOW=1800 # ...за это окно в секундах (30 минут)

STATE_DIR="/var/lib/healthcheck"
FAIL_FILE="$STATE_DIR/failcount"
LAST_RESTART_FILE="$STATE_DIR/last-restart"
RESTART_LOG="$STATE_DIR/restart-log" # метки времени последних рестартов, по одной в строке

mkdir -p "$STATE_DIR"
[ -f "$FAIL_FILE" ] || echo 0 > "$FAIL_FILE"
[ -f "$RESTART_LOG" ] || : > "$RESTART_LOG"

# не даём двум запускам скрипта (например, ручному тесту и тику таймера) толкаться
# в одних и тех же файлах состояния одновременно
exec 9>"$STATE_DIR/lock"
flock -n 9 || { echo "$(date -Is) SKIP: уже выполняется другой запуск"; exit 0; }

PM2_BIN="${PM2_BIN:-pm2}" # если systemd не видит pm2 по PATH - впишите сюда вывод `which pm2`

notify() {
# $1 - текст сообщения; тихо не срабатывает, если TELEGRAM_* не заданы
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
curl -fsS --max-time 5 -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d text="$1" \
> /dev/null || echo "$(date -Is) WARN: не отправилось уведомление в Telegram"
fi
}

if curl -fsS --max-time "$TIMEOUT" "$URL" -o /dev/null; then
echo 0 > "$FAIL_FILE"
echo "$(date -Is) OK $URL"
exit 0
fi

fails=$(cat "$FAIL_FILE")
fails=$((fails + 1))
echo "$fails" > "$FAIL_FILE"
echo "$(date -Is) FAIL $URL (подряд: $fails)"

if [ "$fails" -lt "$FAIL_THRESHOLD" ]; then
exit 0
fi

now=$(date +%s)
last=$(cat "$LAST_RESTART_FILE" 2>/dev/null || echo 0)

if [ $((now - last)) -lt "$COOLDOWN" ]; then
echo "$(date -Is) SKIP: $APP_NAME уже перезапускали недавно, ждём cooldown (${COOLDOWN}s)"
exit 0
fi

# сколько раз уже рестартовали за последнее RESTART_WINDOW секунд - заодно
# обрезаем лог до свежих записей, чтобы он не рос бесконечно
cutoff=$((now - RESTART_WINDOW))
awk -v c="$cutoff" '$1 > c' "$RESTART_LOG" > "$RESTART_LOG.tmp" && mv "$RESTART_LOG.tmp" "$RESTART_LOG"
recent=$(wc -l < "$RESTART_LOG")

if [ "$recent" -ge "$MAX_RESTARTS" ]; then
echo "$(date -Is) STOP: $APP_NAME перезапускали $recent раз за последние $((RESTART_WINDOW / 60)) минут - рестарт не помогает, дальше не трогаем"
notify "healthcheck: ${APP_NAME} на $(hostname) падает снова после каждого рестарта (${recent} попыток за $((RESTART_WINDOW / 60)) мин). Автоперезапуск остановлен, нужен человек."
exit 0
fi

echo "$(date -Is) RESTART $APP_NAME после $fails провалов подряд (рестартов за окно: $recent)"
"$PM2_BIN" restart "$APP_NAME"
echo "$now" > "$LAST_RESTART_FILE"
echo "$now" >> "$RESTART_LOG"
echo 0 > "$FAIL_FILE"
notify "healthcheck: перезапустил ${APP_NAME} на $(hostname) после ${fails} неудачных проверок"

  • `set -euo pipefail` - останавливает скрипт на первой неожиданной ошибке вместо того, чтобы тащить дальше уже сломанное состояние.
  • `curl -fsS --max-time 5 ... -o /dev/null` - флаг `-f` считает провалом не только таймаут и отказ в соединении, но и HTTP-ответ 4xx/5xx; `-s` убирает индикатор прогресса, `-S` всё равно печатает текст ошибки, `--max-time` обрывает зависший запрос через 5 секунд вместо того, чтобы ждать его вечно. Обратная сторона: если проверяемый маршрут иногда законно отвечает 401, 429 или 503 (например, во время обслуживания), скрипт посчитает это провалом наравне с реальным зависанием - учитывайте это при выборе `URL`.
  • `FAIL_FILE`, `LAST_RESTART_FILE` и `RESTART_LOG` в `/var/lib/healthcheck` - каталог нужно создать заранее с нужным владельцем, иначе скрипт упадёт на первом же запуске (см. ниже).
  • `PM2_BIN="${PM2_BIN:-pm2}"` - если systemd не находит `pm2` по имени, замените значение по умолчанию на полный путь из `which pm2`, выполненного тем же пользователем.
  • `flock -n 9` в самом начале - защита от двух параллельных запусков скрипта (например, ручной тест попал на тик таймера): второй запуск сразу выходит, а не портит файлы состояния гонкой чтения-записи.
  • `MAX_RESTARTS`/`RESTART_WINDOW` - настоящий предохранитель от бесконечного restart-loop: если рестартов за последние 30 минут набралось больше трёх, скрипт перестаёт перезапускать процесс и только шлёт уведомление. Это отдельная, более сильная защита, чем `COOLDOWN` - подробнее в разделе ниже. Лог рестартов при каждой проверке обрезается до записей внутри окна, так что файл не растёт бесконечно.
  • Функция `notify` выполняется, только если обе переменные `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` заданы, иначе просто ничего не делает без ошибок.

Каталог для файлов состояния нужно создать до первого запуска - обычный пользователь не может сам завести новый каталог в `/var/lib`, только писать в уже существующий и принадлежащий ему.

sudo install -d -o deploy -g deploy /var/lib/healthcheck
sudo install -m 755 healthcheck.sh /usr/local/bin/healthcheck.sh

  • `install -d -o -g` создаёт каталог и сразу назначает владельца и группу одной командой - без этого шага скрипт упадёт с `Permission denied` на первом же `mkdir`.
  • `install -m 755` копирует файл и сразу выставляет права на исполнение, не нужен отдельный `chmod +x`.

Проверьте руками, прежде чем доверять расписанию. Так вы увидите строку вида 2026-09-14T03:12:01+00:00 OK http://127.0.0.1:3000/, если приложение отвечает нормально:

sudo -u deploy /usr/local/bin/healthcheck.sh

Чтобы проверить ветку с отказом, сначала сбросьте файлы состояния - если скрипт уже где-то запускался, там могут остаться старый счётчик отказов или недавняя метка последнего рестарта, и cooldown из предыдущего теста молча съест вашу проверку:

sudo -u deploy sh -c 'echo 0 > /var/lib/healthcheck/failcount'
sudo rm -f /var/lib/healthcheck/last-restart

Дальше остановите процесс и запустите скрипт дважды подряд руками - второй запуск должен вывести RESTART web после 2 провалов подряд, а `pm2 list` после этого - показать свежий, только что обнулившийся аптайм:

pm2 stop web
sudo -u deploy /usr/local/bin/healthcheck.sh
sudo -u deploy /usr/local/bin/healthcheck.sh
pm2 list

Почему проверяем 127.0.0.1, а не публичный домен

Путь от читателя до вашего приложения (по схеме из статьи про деплой Next.js на VPS) идёт через Cloudflare Tunnel, потом через nginx, и только потом - в сам процесс на localhost. Если бить healthcheck-ом в публичный домен, вы проверяете весь этот путь целиком: DNS, туннель, nginx, TLS. Любая заминка на любом из этих звеньев - перезапуск tunnel-демона, обновление конфига nginx, короткий сбой DNS - даст такой же провал проверки, как реально зависший процесс, и скрипт перезапустит совершенно здоровое приложение.

Обращение на `127.0.0.1:3000` бьёт напрямую в то, чем управляет PM2, - в единственную вещь, которую этому скрипту вообще разрешено чинить перезапуском. Если сломался туннель или nginx, это отдельная проблема, не имеющая отношения к процессу приложения, и restart её не решит.

Расписание: systemd timer вместо cron

`systemd` - штатная система запуска сервисов в Linux; она умеет не только держать процессы поднятыми, но и запускать разовые задачи по расписанию через пару юнитов: `.service` описывает саму задачу, `.timer` - когда её запускать. В отличие от cron, каждый запуск логируется в journalctl вместе с полным выводом скрипта, а не улетает молча в почту root, которая на свежем VPS обычно даже не настроена.

Сервис-юнит `oneshot` - разовая задача, которая один раз отрабатывает и завершается, в отличие от долгоживущего процесса вроде самого приложения:

[Unit]
Description=Проверяет 127.0.0.1:3000 и перезапускает web через pm2 при подряд идущих отказах
After=network.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/home/deploy
Environment=PATH=/home/deploy/.nvm/versions/node/v20.19.0/bin:/usr/local/bin:/usr/bin:/bin
EnvironmentFile=-/etc/healthcheck.env
ExecStart=/usr/local/bin/healthcheck.sh

  • `User=deploy` - сервис должен работать от того же пользователя, что и PM2, иначе `pm2 restart` не увидит нужный процесс (подробнее - в разделе про troubleshooting).
  • `Environment=PATH=...` - systemd не наследует PATH вашего интерактивного шелла, а если Node и PM2 поставлены через nvm, они лежат в версионированном каталоге вроде `~/.nvm/versions/node/v20.19.0/bin`, которого в системном PATH попросту нет. Без этой строки юнит будет падать с ошибкой, что `pm2` не найден, хотя из-под SSH та же команда работает без проблем. Узнать свой путь - выполнить `which pm2`, залогинившись как `deploy`.
  • `EnvironmentFile=-/etc/healthcheck.env` - ведущий дефис делает файл опциональным: если его нет, юнит не упадёт с ошибкой. Здесь при желании лежат переменные для Telegram-уведомления (ниже).

Таймер запускает этот сервис каждую минуту:

[Unit]
Description=Запускает healthcheck.service каждую минуту

[Timer]
OnBootSec=1min
OnUnitInactiveSec=1min
AccuracySec=5s
Unit=healthcheck.service

[Install]
WantedBy=timers.target

  • `OnBootSec=1min` - первый запуск через минуту после загрузки сервера.
  • `OnUnitInactiveSec=1min` - каждый следующий запуск через минуту после того, как юнит снова стал неактивным, то есть фактически после завершения предыдущего запуска (для короткого `oneshot` это почти сразу после его окончания). Это не то же самое, что `OnUnitActiveSec` - тот отсчитывает время от момента запуска юнита, а не от его завершения, и для более долгого скрипта интервалы бы поплыли.
  • `AccuracySec=5s` - допустимый разброс времени запуска; systemd специально не бьёт точно в секунду, чтобы не собирать по всей системе десятки таймеров в один и тот же момент.

Включаем и проверяем:

sudo systemctl daemon-reload
sudo systemctl enable --now healthcheck.timer
systemctl list-timers | grep healthcheck
journalctl -u healthcheck.service -f

В выводе `list-timers` должна появиться строка с колонкой `NEXT`, показывающей время ближайшего запуска в пределах минуты. В `journalctl -f` за пару минут наблюдения должны появиться строки `OK http://127.0.0.1:3000/` - это и есть подтверждение, что таймер реально срабатывает, а не просто числится включённым.

Кому привычнее cron - рабочий вариант тоже есть, но у него та же проблема с окружением, что и у systemd, только решать её приходится сразу в двух местах: сам `PATH` для cron и отдельно - куда вообще можно писать лог. Обычный пользователь `deploy` не может писать в `/var/log` - команда упадёт на перенаправлении раньше, чем скрипт вообще запустится. И одной переменной `PM2_BIN` не хватит: если PM2 стоит через nvm, у него `#!/usr/bin/env node` в шебанге, и если сам nvm-каталог с `node` не попадёт в `PATH`, полный путь до `pm2` всё равно не поможет - процессу нечем будет запуститься. Добавляется командой `crontab -e`, выполненной от пользователя `deploy`:

PATH=/home/deploy/.nvm/versions/node/v20.19.0/bin:/usr/local/bin:/usr/bin:/bin
PM2_BIN=/home/deploy/.nvm/versions/node/v20.19.0/bin/pm2
* * * * * /usr/local/bin/healthcheck.sh >> /home/deploy/healthcheck.log 2>&1

Версию Node в путях подставьте свою (`ls ~/.nvm/versions/node/` покажет, какая стоит). Лог теперь пишется в домашний каталог `deploy`, куда у него точно есть права, а не в `/var/log`. В остальном разница с systemd timer в удобстве разбора логов постфактум: `journalctl -u healthcheck.service` фильтруется по времени и уровню из коробки, а плоский файл лога придётся смотреть через `tail`/`grep` самостоятельно - если это не критично, systemd timer всё равно предсказуемее.

Как это выглядит на живом сервере

Весь цикл целиком - зависшее приложение, которое PM2 честно называет «online», ручной провальный запрос, счётчик отказов, автоматический рестарт таймером без единого нажатия клавиши человеком, и запись об этом в journalctl:

Уведомление о перезапуске в Telegram (опционально)

Healthcheck работает и без этого шага - он перезапускает процесс независимо от того, настроены уведомления или нет. Telegram-уведомление лишь избавляет от необходимости самому идти в journalctl каждое утро и искать, не было ли ночью перезапусков.

Токен бота получаете у `@BotFather` в Telegram - об этом подробно в статье про запуск телеграм-бота на VPS: пишете `/newbot`, получаете строку вида `123456789:AA...`. `chat_id` - идентификатор чата, куда слать сообщение. Самый чистый способ его узнать - без сторонних ботов: напишите своему свежесозданному боту любое сообщение, затем откройте в браузере (или через `curl`) https://api.telegram.org/bot<токен>/getUpdates - в ответе будет JSON с вашим сообщением, и в нём поле message.chat.id - это и есть нужное число.

Значения кладутся в отдельный env-файл, а не в сам скрипт, чтобы токен не разошёлся вместе с копией скрипта:

TELEGRAM_BOT_TOKEN=123456789:AA...
TELEGRAM_CHAT_ID=987654321

sudo install -m 600 -o deploy -g deploy healthcheck.env /etc/healthcheck.env

Права `600` и владелец `deploy` - тот же принцип, что и с токеном бота в предыдущей статье: файл читает только тот пользователь, от которого работает сервис, больше никто.

Чтобы не перезапускать по кругу

Если приложение зависает раз в сутки от случайной причины, healthcheck просто чинит это раз в сутки, и на этом всё. Хуже, когда причина не случайная - сломанный релиз, который вешает процесс через минуту после каждого старта, или внешняя база, которая легла надолго. Без защиты скрипт будет честно перезапускать процесс раз за разом бесконечно, создавая видимость работы вместо реального сигнала о поломке - а если дело не в самом приложении, рестарт вообще ничего не чинит.

Здесь важно не путать две разные вещи. `COOLDOWN=300` в скрипте не ограничивает общее число рестартов - он только не даёт запускать `pm2 restart` чаще, чем раз в пять минут. Если проблема стабильно не проходит, скрипт с одним cooldown будет послушно перезапускать сломанный процесс раз в пять минут бесконечно - это не защита от restart-loop, а просто более редкий restart-loop.

Настоящая защита - счётчик `MAX_RESTARTS`/`RESTART_WINDOW` в скрипте выше: не больше трёх рестартов за последние 30 минут, дальше скрипт останавливается и только шлёт уведомление, не трогая процесс. Это circuit breaker в буквальном смысле - разрывает цепочку «упал -> перезапустили -> снова упал», когда рестарт явно не помогает, и оставляет решение человеку вместо того, чтобы долбить по кругу вслепую.

Отдельно от этого стоят настройки самого PM2 из ecosystem-файла, разобранные в статье про деплой Next.js на VPS: `max_restarts` и `exp_backoff_restart_delay` защищают от другого сценария - когда PM2 сам, своими силами, пытается поднять процесс, который постоянно падает сразу после старта. Это защита PM2 от его собственных crash-restart-попыток, она не имеет отношения к рестартам, которые запускает внешний healthcheck-скрипт, и не остановит цикл «healthcheck перезапустил -> PM2 запустил -> приложение снова зависло (не упало) -> healthcheck перезапустил снова» - именно поэтому circuit breaker нужен отдельно, в самом healthcheck-скрипте.

Что проверить, если healthcheck не перезапускает процесс

  • Скрипт не исполняемый. `ls -l /usr/local/bin/healthcheck.sh` должен показывать `-rwxr-xr-x`. Если прав на исполнение нет - `sudo chmod +x /usr/local/bin/healthcheck.sh`.
  • Неверный путь в unit-файле. Опечатка в `ExecStart` роняет запуск с кодом вроде `status=203/EXEC` в `journalctl -u healthcheck.service` - сверьте путь командой `ls /usr/local/bin/healthcheck.sh`.
  • `pm2` не найден по PATH. Самая частая причина тишины в логах при живом приложении - systemd не наследует PATH интерактивного шелла, особенно если Node и PM2 поставлены через nvm. Пропишите полный путь через `which pm2` (от того же пользователя) прямо в скрипте как значение `PM2_BIN`, либо добавьте строку `Environment=PATH=...` в unit-файл.
  • Файл состояния недоступен на запись. `/var/lib/healthcheck` должен принадлежать тому же пользователю, что указан в `User=` unit-файла - иначе скрипт падает на `mkdir` или на записи в файл с `Permission denied`. Создайте каталог заранее: `sudo install -d -o deploy -g deploy /var/lib/healthcheck`.
  • `pm2 restart` не видит процесс. PM2 хранит список процессов в `~/.pm2` конкретного пользователя. Если healthcheck запущен от root, а сайт крутится под `deploy`, команда `pm2 restart web` от root не найдёт нужный процесс в своём собственном `~/.pm2`. `User=` в unit-файле должен совпадать с тем, под кем вы стартовали PM2.
  • Таймер вообще не сработал ни разу. `systemctl list-timers | grep healthcheck` должен показывать ближайшее время запуска в колонке `NEXT`. Если строки нет вообще - `systemctl enable --now healthcheck.timer` не выполнялся или в блоке `[Install]` таймера опечатка.

Часто спрашивают

Как автоматически перезапустить Node.js, если он завис, а не упал?

PM2 сам перезапускает процесс только при его фактическом завершении - зависший, но живой процесс он не тронет. Нужен внешний healthcheck: скрипт, который стучится в приложение по HTTP снаружи, считает подряд идущие провалы и по достижении порога сам вызывает `pm2 restart`. Выше в статье - готовый скрипт и systemd timer для его расписания.

Что такое healthcheck-скрипт для VPS и из чего он состоит?

Это отдельный небольшой bash-скрипт, который раз в одну-две минуты дёргает `curl` по локальному адресу приложения, хранит счётчик подряд идущих отказов в файле на диске и при достижении порога запускает `pm2 restart <имя>`. Плюс cooldown, чтобы не долбить restart на каждую проверку, если проблема не проходит сама.

Почему PM2 показывает процесс «online», хотя сайт не отвечает?

PM2 следит за тем, жив ли процесс операционной системы, а не за тем, отвечает ли он на запросы. Причина обычно одна из двух: либо событийный цикл Node.js заблокирован синхронным кодом и не отвечает вообще ничего, либо конкретный запрос завис без таймаута (например, к базе), а событийный цикл при этом свободен и продолжает обслуживать остальное. В обоих случаях процесс продолжает существовать, PM2 видит его как исправный и не перезапускает.

Чем systemd timer лучше cron для таких проверок?

Timer логирует каждый запуск и его результат в journalctl, поэтому историю проверок видно командой `journalctl -u healthcheck.service`, тогда как cron по умолчанию пишет только в почту root, которая на свежем VPS обычно не настроена. Timer также ждёт завершения предыдущего запуска и не теряет тихо упавшую задачу без вывода.

Как получать уведомление в Telegram, когда healthcheck перезапустил сервис?

Заведите бота через `@BotFather`, получите токен и chat_id, добавьте в скрипт запрос `curl -X POST` к `https://api.telegram.org/bot<токен>/sendMessage` с текстом сообщения - он сработает сразу после `pm2 restart` в теле скрипта. Это необязательный шаг: healthcheck перезапускает процесс и без Telegram, уведомление лишь избавляет от необходимости самому читать логи по утрам.

Коротко

  • PM2 restart реагирует на смерть процесса, не на зависание. Зависание бывает двух разных видов: заблокирован весь событийный цикл (не ответит вообще ничего) или завис конкретный запрос без таймаута, а событийный цикл свободен - PM2 не ловит ни то, ни другое.
  • Внешний HTTP-healthcheck закрывает эту дыру: проверяет приложение так же, как реальный запрос, и сам вызывает `pm2 restart`, если проверка проваливается два раза подряд.
  • Что именно проверять - тоже вопрос: маршрут, завязанный на внешнюю базу или API, может честно проваливаться из-за них, а не из-за самого приложения - и тогда рестарт Node.js ничего не чинит и превращается в restart-loop на здоровом процессе.
  • Проверять нужно 127.0.0.1, а не публичный домен - иначе на любую заминку nginx, Cloudflare Tunnel или DNS вы будете перезапускать здоровый процесс.
  • Расписание - systemd timer: он логирует каждый запуск в journalctl и не теряет тихо упавшие проверки, как это бывает с cron.
  • Уведомление в Telegram - опциональная надстройка, не условие работы healthcheck.
  • `COOLDOWN` только замедляет повторные рестарты, а не ограничивает их число - настоящая защита от бесконечного restart-loop - отдельный счётчик `MAX_RESTARTS`/`RESTART_WINDOW` в скрипте, который останавливает попытки и зовёт человека, если рестарты не помогают.

Что дальше

Этот healthcheck закрывает конкретную дыру - зависший, но живой процесс. Он ничего не говорит о том, как выкладывать новые версии без секунды простоя, и не защищает сам сервер от чужого трафика на портах, которые вы не открывали. Обе темы - следующие звенья этой серии про сайт на VPS: как деплоить обновления без даунтайма и как закрыть nginx и порты через ufw. Если ещё не читали первую часть про сам процесс деплоя - начните с деплоя Next.js на VPS и общего разбора процесса деплоя сервиса на сервере.

PUBLISHED
14 сентября 2026 г.
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU