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 следит за тем, жив ли процесс операционной системы - существует ли он вообще, не завершился ли с ошибкой, не убил ли его 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 ниже |
Соблазн - завести пустой маршрут вроде `/api/health`, который просто отвечает `200 OK`, ничего не делая. Проблема в том, что такой маршрут ловит только блокировку событийного цикла, а не зависший запрос к конкретной зависимости - если утечка из первой статьи серии как раз про такой случай (зависание на конкретном запросе к базе), пустой health-роут ответит мгновенно, потому что он этот код вообще не трогает, а реальная главная страница в это время не отвечает никому.
Значит, нужно проверять маршрут, который трогает ту же базу или кеш, что и боевой трафик? Не всегда - и здесь есть реальная ловушка. Если такой маршрут зависит от внешней базы, которая упала на 20 минут целиком, - сам процесс и событийный цикл при этом полностью здоровы, а healthcheck честно видит провал за провалом и раз за разом перезапускает Node.js. Рестарт процесса не чинит упавшую базу: приложение поднимется, тут же снова упрётся в мёртвую базу, снова не ответит на проверку - и получится ровно тот самый restart-loop, от которого хотелось защититься.
Разница - между liveness (жив ли сам процесс и способен ли он вообще работать) и readiness (может ли он прямо сейчас полноценно обслуживать пользователя). Для автоматического restart безопаснее liveness-проверка - той, что реагирует на состояние, которое перезапуск действительно способен исправить. Проверка, завязанная на внешнюю зависимость, полезна для мониторинга и алертов, но не должна сама решать вопрос о перезапуске - если только вы заранее не доказали на своём приложении, что конкретно зависает именно пул соединений внутри процесса, а не сама внешняя база, и что рестарт эту конкретную проблему действительно чинит.
Практический компромисс для типичного маленького VPS-приложения: проверяйте маршрут, который делает что-то реальное (не пустышку), но следите за отдельным сигналом, если начали массово падать именно проверки, завязанные на конкретную внешнюю зависимость - это тот случай, где circuit breaker в скрипте ниже (не больше N рестартов подряд, дальше только алерт) особенно важен, а не опция для перестраховки.
Дашборд, алерт в Slack, письмо от системы мониторинга - все они делают одно и то же: сообщают человеку, что что-то не так. Дальше человеку нужно проснуться в три часа ночи, открыть ноутбук, подключиться по SSH и вручную выполнить `pm2 restart`. Пока он это делает, приложение недоступно.
Healthcheck из этой статьи делает тот же restart сам, в течение одной-двух минут после того, как приложение перестало отвечать, - и всё равно оставляет след: строку в journalctl, а если настроено уведомление - сообщение в Telegram. Утром есть что разобрать, но разбирать это можно после кофе, а не вместо сна.
Логика скрипта простая, и это её главное достоинство - в ней почти нечему ломаться.
Порог именно в два провала подряд, а не в один, нужен, чтобы не перезапускать здоровый процесс из-за случайного сетевого затыка на пару секунд - короткий всплеск нагрузки или пауза сборщика мусора не должны считаться падением. Один провал - это шум, два подряд с интервалом в минуту - уже сигнал.
Отдельный вопрос - что именно проверять. Соблазн завести пустой маршрут `/api/health`, который просто отвечает `200 OK`, ничего не делая, - ловушка: если зависание происходит на конкретном запросе к базе (как в примере из статьи про утечку памяти), пустой health-роут ответит мгновенно, потому что он этот код вообще не трогает, а реальная главная страница в это время не отвечает никому. Проверяйте маршрут, который делает то же самое, что и обычный запрос пользователя - в большинстве случаев это просто главная страница. Отдельный лёгкий `/health` имеет смысл, только если он сам дергает то же соединение к базе или тот же кеш, что и боевой трафик, а не существует отдельно от него.
Сохраните скрипт под тем же пользователем, из-под которого запущен 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} неудачных проверок"
Каталог для файлов состояния нужно создать до первого запуска - обычный пользователь не может сам завести новый каталог в `/var/lib`, только писать в уже существующий и принадлежащий ему.
sudo install -d -o deploy -g deploy /var/lib/healthcheck
sudo install -m 755 healthcheck.sh /usr/local/bin/healthcheck.sh
Проверьте руками, прежде чем доверять расписанию. Так вы увидите строку вида 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
Путь от читателя до вашего приложения (по схеме из статьи про деплой Next.js на VPS) идёт через Cloudflare Tunnel, потом через nginx, и только потом - в сам процесс на localhost. Если бить healthcheck-ом в публичный домен, вы проверяете весь этот путь целиком: DNS, туннель, nginx, TLS. Любая заминка на любом из этих звеньев - перезапуск tunnel-демона, обновление конфига nginx, короткий сбой DNS - даст такой же провал проверки, как реально зависший процесс, и скрипт перезапустит совершенно здоровое приложение.
Обращение на `127.0.0.1:3000` бьёт напрямую в то, чем управляет PM2, - в единственную вещь, которую этому скрипту вообще разрешено чинить перезапуском. Если сломался туннель или nginx, это отдельная проблема, не имеющая отношения к процессу приложения, и restart её не решит.
`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
Таймер запускает этот сервис каждую минуту:
[Unit]
Description=Запускает healthcheck.service каждую минуту
[Timer]
OnBootSec=1min
OnUnitInactiveSec=1min
AccuracySec=5s
Unit=healthcheck.service
[Install]
WantedBy=timers.target
Включаем и проверяем:
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:
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-скрипте.
PM2 сам перезапускает процесс только при его фактическом завершении - зависший, но живой процесс он не тронет. Нужен внешний healthcheck: скрипт, который стучится в приложение по HTTP снаружи, считает подряд идущие провалы и по достижении порога сам вызывает `pm2 restart`. Выше в статье - готовый скрипт и systemd timer для его расписания.
Это отдельный небольшой bash-скрипт, который раз в одну-две минуты дёргает `curl` по локальному адресу приложения, хранит счётчик подряд идущих отказов в файле на диске и при достижении порога запускает `pm2 restart <имя>`. Плюс cooldown, чтобы не долбить restart на каждую проверку, если проблема не проходит сама.
PM2 следит за тем, жив ли процесс операционной системы, а не за тем, отвечает ли он на запросы. Причина обычно одна из двух: либо событийный цикл Node.js заблокирован синхронным кодом и не отвечает вообще ничего, либо конкретный запрос завис без таймаута (например, к базе), а событийный цикл при этом свободен и продолжает обслуживать остальное. В обоих случаях процесс продолжает существовать, PM2 видит его как исправный и не перезапускает.
Timer логирует каждый запуск и его результат в journalctl, поэтому историю проверок видно командой `journalctl -u healthcheck.service`, тогда как cron по умолчанию пишет только в почту root, которая на свежем VPS обычно не настроена. Timer также ждёт завершения предыдущего запуска и не теряет тихо упавшую задачу без вывода.
Заведите бота через `@BotFather`, получите токен и chat_id, добавьте в скрипт запрос `curl -X POST` к `https://api.telegram.org/bot<токен>/sendMessage` с текстом сообщения - он сработает сразу после `pm2 restart` в теле скрипта. Это необязательный шаг: healthcheck перезапускает процесс и без Telegram, уведомление лишь избавляет от необходимости самому читать логи по утрам.
Этот healthcheck закрывает конкретную дыру - зависший, но живой процесс. Он ничего не говорит о том, как выкладывать новые версии без секунды простоя, и не защищает сам сервер от чужого трафика на портах, которые вы не открывали. Обе темы - следующие звенья этой серии про сайт на VPS: как деплоить обновления без даунтайма и как закрыть nginx и порты через ufw. Если ещё не читали первую часть про сам процесс деплоя - начните с деплоя Next.js на VPS и общего разбора процесса деплоя сервиса на сервере.