Мониторинг node-cron и других планировщиков Node.js
С библиотеками вроде node-cron, node-schedule, croner или @nestjs/schedule так и хочется держать задачи по расписанию прямо в приложении на Node.js: ни crontab, ни отдельного контейнера, задача использует код и настройки приложения. Подвох в том, что расписание живёт ровно столько, сколько процесс, а ошибка внутри колбэка в лучшем случае превращается в строку в логе.
Что ломается в планировщиках внутри процесса
- В нужное время процесс не запущен. Цикл падений, деплой или платформа, которая гасит приложение без трафика, — и запускать задачу некому. Позже пропущенный запуск не наверстается.
- Задачу запускает каждая реплика. Три пода или кластер PM2 из четырёх воркеров отправят одно и то же письмо четыре раза.
- Ошибки пропадают. Отклонённый промис в async-колбэке превращается в unhandled rejection: предупреждение в логе или падение всего приложения, в зависимости от версии Node и настроек.
- Запуски накладываются. Задача, которая иногда идёт дольше своего интервала, стартует снова, пока предыдущий запуск ещё не закончился.
- Часовой пояс сервера. Без опции часового пояса расписание идёт по часам сервера, а на большинстве хостингов это UTC.
Оберните каждую задачу
Этот помощник работает с любым планировщиком. Ему нужен Node.js 18 или новее ради встроенного fetch. Он сообщает о старте, результате и ошибке, пропускает запуск, пока идёт предыдущий, и не даёт неудачному пингу сломать задачу:
// monitored.js async function ping(url, body) { try { await fetch(url, { method: body ? "POST" : "GET", body, signal: AbortSignal.timeout(10_000) }); } catch { // мониторинг не должен ломать задачу } } export function monitored(url, job) { let running = false; return async () => { if (running) return; // предыдущий запуск ещё идёт running = true; await ping(`${url}/start`); try { await job(); await ping(url); } catch (err) { await ping(`${url}/fail`, String(err?.stack ?? err).slice(0, 100_000)); } finally { running = false; } }; }
Ошибки перехватываются внутри обёртки, поэтому unhandled rejection не уронит приложение, а текст ошибки появится в журнале событий Heartcron.
node-cron
import cron from "node-cron";
import { monitored } from "./monitored.js";
cron.schedule(
"0 3 * * *",
monitored("https://ping.heartcron.com/<uuid>", sendDailyDigest),
{ timezone: "Europe/Moscow" },
);
NestJS
import { Injectable } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { monitored } from "./monitored";
@Injectable()
export class ReportsService {
private readonly digest = monitored("https://ping.heartcron.com/<uuid>", () => this.sendDailyDigest());
@Cron("0 3 * * *", { timeZone: "Europe/Moscow" })
run() {
return this.digest();
}
}
В Heartcron создайте проверку с тем же выражением 0 3 * * * и тем же часовым поясом. Запас времени сделайте чуть больше обычной длительности запуска. Если в 3:00 приложение лежало, пинг не придёт, и вы об этом узнаете.
Один планировщик, а не по одному на реплику
- Кластерный режим PM2 задаёт каждому воркеру
NODE_APP_INSTANCE. Запускайте расписание только там, где он равен"0". - Kubernetes или несколько контейнеров: вынесите планировщик в отдельный деплоймент с одной репликой, защитите каждую задачу блокировкой в Postgres (
pg_try_advisory_lock) или Redis либо перенесите задачу в CronJob в Kubernetes.
Проверить это помогает журнал событий Heartcron: если один запуск по расписанию приходит несколькими пингами с разницей в миллисекунды, его запускает больше одного процесса.
Или вынесите задачу из процесса
Задачу, которая должна выполняться даже при упавшем приложении, лучше сделать отдельным скриптом, который запускает системный cron, планировщик контейнеров или cron платформы. Тогда открытая утилита heartcron обернёт её без помощника выше:
0 3 * * * heartcron run daily-digest -- node /srv/app/scripts/digest.js
Она отправит старт, код завершения, длительность и последние строки вывода. Что значит выражение, можно проверить в расшифровщике cron-выражений.