---
title: Крон молчит — мониторинг фоновых задач, бэкапов и выгрузок
description: У фоновой задачи нет способа сообщить о своей смерти. Разбираем мониторинг кронов через сигнал от задачи — боевая обёртка с flock, timeout и сохранением кода возврата, ловушки часовых поясов и CRON_TZ, безопасность heartbeat-адреса.
source: https://pingvera.ru/blog/monitoring-cron-zadach.html
---
# Крон молчит: мониторинг фоновых задач, бэкапов и выгрузок

У упавшего сайта есть голос: он отвечает пятисоткой, таймаутом, сорванным сертификатом — чем-нибудь, что видно снаружи. У фоновой задачи голоса нет. Крон-строка, которую не перенесли при переезде на новый хостинг, не подаёт никаких признаков: она просто отсутствует. Скрипт бэкапа, падающий на второй секунде из-за кончившегося места, ведёт себя ровно так же, как скрипт, который отработал идеально, — снаружи ни то ни другое не отличить. Единственный сигнал, который у вас вообще есть, — это тишина. Весь мониторинг фоновых задач построен на том, чтобы научиться её слышать.

## Направление наблюдения приходится развернуть

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

С крон-задачей ни одного из трёх условий нет. Она живёт внутри сервера, у неё нет адреса, и достучаться до неё можно единственным способом — зайти на машину. Этого мы не делаем и не будем: [наш агент не принимает команд](https://pingvera.ru/blog/monitoring-bez-dostupa-k-serveru.html), у хаба физически нет способа что-либо запустить на клиентском сервере. Это записано в архитектуру как инвариант и не пересматривается.

Способов узнать состояние фоновой задачи вообще-то много: локальный агент или exporter, журнал systemd, почта крона, метрики Prometheus, API планировщика, состояние Kubernetes CronJob. Можно вовсе не смотреть на задачу, а проверять результат — свежесть файла на диске, дату последней записи в таблице, появление объекта в хранилище. У каждого способа своя цена: агент надо ставить и обновлять, метрики — собирать и хранить, проверка результата требует доступа туда, где этот результат лежит.

Но если условие такое: ничего не ставить на сервер, никуда не подключаться и ничем не управлять, — вариант остаётся один. Инициативу отдаём задаче. Она получает короткий персональный адрес и обращается к нему сама, когда отработала. Мониторинг знает, по какому графику ждать сигнал, и поднимает тревогу, если тот не пришёл. Отрасль называет это dead man's switch: срабатывает не действие, а его отсутствие.

Разворот направления меняет и сам вопрос, на который отвечает мониторинг. Внешняя проверка отвечает «сайт открывается?». Сигнал от задачи отвечает «работа сделана?». Второе ближе к делу: сайт может прекрасно открываться, пока база не бэкапится, остатки не обновляются, а отложенные публикации стоят в очереди.

## Как это выглядит на практике

Проверка типа «Задача» выдаёт адрес. Дальше строка crontab выглядит так:

`30 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 https://app.pingvera.ru/hb/hb_XXXX`

 Здесь важен каждый символ, и меньше всего — адрес.

`&&` — договор о том, что считать успехом. Пинг уходит, только если скрипт завершился с нулевым кодом. Поставьте `;` вместо `&&`, и вы будете отслеживать факт запуска крона, а не факт выполнения работы. Разница между этими двумя вещами и есть весь смысл затеи.

`-m 10` — предохранитель. Мониторинг не имеет права стать причиной аварии: если наш сервис недоступен или тормозит, `curl` обязан сдаться через десять секунд и отпустить вашу задачу. `-f` и `-s` — чтобы ошибка не потерялась и чтобы в почту крона не сыпался индикатор прогресса.

Голого пинга хватает для ответа «дошла ли задача до конца». Дальше начинается интересное: у адреса есть три хвоста.

`/start` — задача началась. Сам по себе признаком жизни он не считается, иначе зависший бэкап выглядел бы здоровым. Зато даёт две вещи: длительность каждого запуска и возможность поймать зависание — если после старта завершение не пришло за отведённое время, открывается событие «задача запустилась и не завершилась».

`/fail` — задача сама сообщает, что упала, и событие открывается сразу, не дожидаясь следующего срока. Для ночного бэкапа это разница между «узнали в 3:35» и «узнали через сутки».

`/<код возврата>` — то же самое, но числом: `/0` означает успех, любой другой код — провал. Тело запроса, до 10 КБ, сохраняется как лог запуска и видно в карточке рядом с временем и длительностью. Когда в три часа ночи придёт оповещение, ответ на вопрос «что именно случилось» будет уже там, а не в почте крона, которую никто не читает.

## Две ловушки, на которых мы поймали сами себя

Мы ставили эту схему на настоящий крон на своём сервере — не в тестах, а живьём, с реальной задачей раз в две минуты. За первый час она поймала две ошибки, обе наши, и обе поучительные.

### Ловушка первая: код возврата, который всегда ноль

Обёртка выглядела безобидно:

`out=$( { df -h /mnt/backup | tail -1; echo "готово"; } 2>&1 )
rc=$? # ← всегда 0`

 Задача честно ломалась, монтирования не было, а мониторинг получал `/0` и рисовал зелёное. Причина простая: `$?` — это статус **последней выполненной** команды, а последним тут оказался `echo`, который не падает никогда.

Правило, которое из этого следует: между самой задачей и `rc=$?` не должно быть ничего. С конвейерами тоньше: `set -o pipefail` действует только на тот shell, где конвейер исполняется. Если внутри `backup.sh` есть `pg_dump | gzip`, включение `pipefail` в обёртке этому конвейеру не поможет — строку нужно ставить в сам `backup.sh`. В обёртке она защищает только её собственные конвейеры.

Ошибка коварна тем, что не ломает ничего видимого. Мониторинг работает, письма не приходят, все довольны — и ровно поэтому её стоит проверить у себя прямо сейчас, если обёртка писалась на бегу. Правильный вариант — ниже, в разделе про боевую обёртку: там же выясняется, что одного `rc=$?` для production мало.

### Ловушка вторая: тревога, которая гарантированно ложная

Вторую ошибку допустили уже мы, в самом продукте. Свежесозданная проверка присылала событие «задача не отметилась» через четыре минуты — то есть ровно в тот момент, когда человек ещё копирует адрес и идёт вписывать команду в crontab.

Формально всё верно: сигнала нет, срок вышел, событие открыто. По сути — вредно. Отличить «крон сломан» от «команду ещё не подключили» невозможно в принципе, а оповещение, которое предсказуемо срабатывает не по делу, делает ровно одну вещь: приучает игнорировать оповещения. Один раз промотал — и промотаешь следующее, настоящее.

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

## Обёртка, пригодная для боевого крона

Строка с `&&` хороша, чтобы начать, и годится для задач, где нечего терять. У ночного бэкапа терять есть что, и там нужна обёртка. Вот она целиком, дальше по строкам:

`#!/bin/bash
set -o pipefail # только для конвейеров ЭТОГО скрипта
PING=https://app.pingvera.ru/hb/hb_XXXX
hb() { curl -fsS -m 10 --retry 3 --retry-delay 5 --retry-connrefused "$@" >/dev/null; }

exec 9>/var/lock/backup.lock # два запуска не должны наложиться
flock -n 9 || { hb "$PING/fail" --data-raw "предыдущий запуск ещё идёт"; exit 1; }

hb "$PING/start"
log=$(mktemp); trap 'rm -f "$log"' EXIT
timeout 30m /usr/local/bin/backup.sh >"$log" 2>&1; rc=$?
tail -c 8000 "$log" | hb --data-binary @- "$PING/$rc"
exit "$rc"`

 **`exit "$rc"` в последней строке — не мелочь.** Без него кодом возврата всей обёртки становится код `curl`, и получаются две одинаково неприятные ситуации: бэкап упал, но сигнал доставлен — крон считает запуск успешным; бэкап отработал, а мониторинг был недоступен — крон считает запуск провальным и шлёт вам письмо. Обёртка обязана сохранять семантику задачи, которую оборачивает.

**`--retry`** — потому что моргнувшая сеть не должна выглядеть как пропущенный бэкап. Три попытки с паузой в пять секунд закрывают типичный сетевой чих; при этом общий предел `-m 10` на попытку не даёт обёртке залипнуть, если наш сервис лежит по-настоящему.

**`flock`** — от наложения запусков. Дамп, который сегодня идёт дольше обычного, к следующему сроку встретит второй экземпляр себя, и они подерутся за блокировки в базе. Здесь второй запуск честно сообщает о провале и уходит.

**`timeout 30m`** — от зависания. Наш детект «запустилась и не завершилась» сообщит вам о повисшей задаче, но убить её мы не можем и не станем: это ваш сервер. Ограничение по времени ставит сама задача.

**Лог во временный файл, а не в переменную.** Вывод боевого бэкапа бывает в мегабайты: в переменной shell он целиком поедет в память, потеряет хвостовые переводы строк и споткнётся о нулевые байты, которые bash хранить не умеет. Мы отправляем последние 8 КБ — с запасом влезает в наш предел в 10 КБ. И отдельно стоит посмотреть, что вообще уезжает в этот хвост: строки подключения, пароли в отладочном выводе, персональные данные из выгрузки. Если задача такое печатает — чистите вывод перед отправкой, мониторинг для секретов не место.

## Что будет, если недоступны мы

Heartbeat добавляет в вашу цепочку новое звено: задача → DNS → сеть → TLS → наш хаб → обработчик событий → Telegram или почта. Звено новое, значит и отказать может. Честные ответы на неудобные вопросы:

**Отличаем ли мы свою аварию от вашего пропуска?** Нет. Если наш хаб недоступен, пинги не доходят, и после восстановления мы увидим тишину — ровно такую же, как от не запустившейся задачи. Событие откроется. Именно поэтому в обёртке стоит `--retry`: он закрывает короткие перебои, а на длинные честного ответа у нас нет. Наш аптайм — на [публичной статус-странице](https://app.pingvera.ru/status/pingvera), и разбирать спорный алёрт стоит с ней в соседней вкладке.

**Задерживается ли реакция?** Состояние задач пересчитывается раз в 15 секунд, так что событие открывается в пределах этого шага после того, как истёк допуск. Это же означает, что микроскопические допуски бессмысленны: меньше минуты ставить незачем.

**Следим ли мы за доставкой оповещения?** Отправку в канал мы повторяем до восьми раз с нарастающими паузами — это около двух часов на затяжной сбой провайдера, — и пишем результат каждой попытки в журнал события, так что в карточке видно, ушло оповещение или нет. Но подтверждения, что человек его прочитал, у нас нет и быть не может. Если задача критична, не полагайтесь на один канал: почта и мессенджер отказывают по разным причинам.

## Расписание против интервала

«Раз в сутки» и «каждую ночь в 03:30» звучат похоже, но ведут себя по-разному. Интервал отсчитывается от предыдущего сигнала: задача, отработавшая в 03:31, следующего срока ждёт до 03:31, потом до 03:33 — и постепенно уезжает. Расписание привязано к часам и никуда не едет.

Поэтому у задачи можно задать cron-выражение и часовой пояс отдельно. И вот здесь стоит остановиться, потому что пояс — самое частое место, где ожидания разъезжаются с реальностью.

**Пояс в мониторинге задаёт только наши ожидания.** На то, когда задача реально запустится, он не влияет никак — это решает крон-демон на вашем сервере. Если сервер живёт в UTC, а в проверке выбрана Москва, мы будем ждать сигнал в 03:00 по Москве, а крон запустит задачу в 03:00 по UTC, то есть на три часа позже, — и вы получите ложное событие каждую ночь. Проверьте `timedatectl` на сервере и либо укажите его зону в проверке, либо привяжите крон к нужной зоне явно:

`CRON_TZ=Europe/Moscow
30 3 * * * /usr/local/bin/backup.sh && curl -fsS -m 10 https://app.pingvera.ru/hb/hb_XXXX`

 `CRON_TZ` понимают Vixie cron и cronie — то есть штатный крон в Debian, Ubuntu, RHEL. У systemd-таймеров своя настройка (`OnCalendar` с указанием зоны), у Kubernetes CronJob — поле `timeZone` в спецификации. Общее правило одно: где задаёте расписание, там и задавайте пояс, а в мониторинге ставьте тот же.

**Перевод часов.** Две ночи в году в зонах с переходом ведут себя необычно: один локальный час не существует вовсе, другой случается дважды. Задача, назначенная на исчезнувший час, у разных реализаций крона отработает по-разному — какая-то пропустит, какая-то сдвинет. Мы в этих случаях сдвигаем ожидание вперёд, к ближайшему существующему времени. Если задача важная и живёт в зоне с переходом, надёжнее увести её из окна 02:00–04:00 или держать сервер и расписание в UTC — там переходов нет.

**Пропущенные запуски.** Если сервер был выключен в момент срока, обычный крон запуск не догоняет — он просто не состоялся. Для мониторинга это выглядит как тишина, и событие придёт. Это правильно: работа действительно не сделана. Но если у вас плановые окна обслуживания, имеет смысл заранее заглушить проверку, а не разбирать потом ложную тревогу.

**День месяца и день недели.** В классическом cron эти два поля работают через ИЛИ, а не через И: выражение `0 0 13 * 5` означает «13-го числа ИЛИ в пятницу», а не «в пятницу тринадцатого». Мы следуем этому же правилу и проговариваем его вслух в описании — если увидите в переводе неожиданное «или», значит выражение делает не то, что вы думали.

 Само выражение собирается мышью: частота, время, дни недели, число месяца. Строка crontab пересчитывается на каждое действие и лежит рядом — её копируют к себе на сервер. Обратный разбор тоже работает: существующая задача открывается своими контролами, а не голой строкой.

И отдельно — то, ради чего стоило возиться. Интерфейс проговаривает выражение вслух: «по будням в 09:00», «каждые 15 мин», «1-го числа в 05:00», — и показывает шесть ближайших ожидаемых запусков в выбранном поясе. Причина конкретная: самая частая ошибка в cron — перепутанные местами день месяца и день недели. Выражение при этом остаётся синтаксически безупречным, крон его принимает, а работает оно не тогда. Заметить это можно единственным способом: если система скажет, как она вас поняла, до того как вы нажали «Сохранить».

## Допуск: чем отличается «чуть задержалась» от «не запустилась»

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

Пока срок прошёл, а допуск ещё идёт, задача помечается просроченной — жёлтым в списке. Событие в этот момент не открывается и никого не будит: это состояние для глаз, а не для тревоги. Практически: ночному бэкапу разумно дать полчаса, выгрузке остатков каждые 15 минут — пару минут. Если оставить допуск нулевым, он берётся по умолчанию: у расписания — пять минут, у периодической проверки — сам период.

Дальше всё как с обычными проверками, и это, пожалуй, главный практический довод: не нужно заводить ещё один сервис со своими оповещениями и своим логином. Событие приходит в те же каналы — почта, Telegram, Max, VK, Slack, Discord, вебхук, — попадает в общую ленту и в [отчёт клиенту за месяц](https://pingvera.ru/blog/otchet-klientu-za-podderzhku-sayta.html). Когда задача снова отметится успешно, событие закроется само: ручного «починил» у нас нет по устройству — мониторинг фиксирует факты, а не ведёт тикеты.

## Честно о границах

Такой мониторинг видит ровно то, о чём задача рассказала сама, и ни байтом больше. Список того, чего он не умеет, стоит знать до внедрения, а не после.

**Он не запускает задачи.** Это не планировщик и не замена крону. Если крон-демон на сервере умер целиком, мы сообщим о тишине — но запустить за него ничего не сможем.

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

**Он не поймает зависание у неразмеченной задачи.** «Запустилась и не завершилась» работает только там, где есть сигнал `/start`. Без него зависшая задача выглядит как молчащая — событие придёт, но позже и с другой формулировкой.

**Адрес пинга — это секрет, и обращаться с ним надо соответственно.** Токен в нём — 128 бит случайности, перебрать нельзя. Но защита вся в самом адресе: отдельного пароля нет — иначе его нельзя было бы дёрнуть одной строкой из crontab. Что из этого следует практически:

- Знающий адрес может отметить вашу задачу выполненной — то есть заглушить тревогу. Прочитать что-либо об аккаунте он не может.
- Адрес попадает в места, о которых легко забыть: история shell, логи прокси и балансировщиков по пути, наши собственные access-логи, системы сбора ошибок. Полностью этого не избежать — стоит просто знать.
- Мы принимаем и `GET`, и `POST`. GET удобен и нужен для одной строки в crontab, но у него есть цена: ссылку могут открыть случайно — превью в мессенджере, сканер ссылок в почте, любопытный бот, — и задача отметится выполненной без задачи. Если адрес где-то фигурирует в переписке или тикетах, используйте `POST`: `curl -fsS -m 10 -X POST …`.
- Ротации адреса пока нет: если он утёк, проверку нужно удалить и создать заново — токен сменится. Это осознанный пробел, а не забытая мелочь; отдельная кнопка «сменить адрес» напрашивается, и она в списке.
- В открытых репозиториях, публичных дампах конфигов и скриншотах адресу не место — ровно как токену API.

**История запусков не бесконечна.** Храним последние 50 сигналов на задачу и не дольше 30 дней. Этого хватает, чтобы разобрать инцидент и увидеть, что выгрузка, которая шла две минуты, последнюю неделю идёт двадцать. Для архива логов есть системы логирования, это не они.

## С чего начать

Не стоит вешать проверку на каждую строку crontab. Мониторинг, присылающий десять оповещений в неделю про мелочи, перестают читать — и вместе с мелочами пропускают важное. Начните с того, где тишина стоит дорого:

- **Бэкапы** — базы и файлов. Самое дорогое и самое незаметное: сломанный бэкап обнаруживается ровно в тот день, когда он понадобился.
- **Выгрузки и импорты** — каталог в маркетплейс, остатки из 1С, цены поставщика. Тут тишина превращается в деньги напрямую: [магазин работает, а заказов нет](https://pingvera.ru/blog/magazin-rabotaet-a-zakazov-net.html) — иногда именно поэтому.
- **Продление сертификатов** — `certbot renew` молчит ровно до дня, когда сертификат истёк. Доступность HTTPS мы [проверяем и снаружи](https://pingvera.ru/blog/ne-rabotaet-https.html), но знать о сломавшемся обновлении заранее спокойнее.
- **Очереди и wp-cron** — отложенные публикации, письма, синхронизация. Особенно если `WP_CRON` отключён и вынесен в системный крон.

Каждая такая проверка занимает один слот монитора — на бесплатном тарифе их пять, и этого хватает, чтобы закрыть бэкапы и главную выгрузку.

## Что в сухом остатке

Тихо ломается многое: доставка почты, репликация, очереди, интеграции с чужими API. Фоновые задачи в этом ряду выделяются тем, что у них нет вообще никакого внешнего проявления — ни кода ответа, ни выросшей очереди, ни отлупа от партнёра. Только отсутствие результата, которое замечают в момент, когда результат понадобился.

Первую проверку заводят за пять минут: создать задачу, дописать `&& curl …` в конец строки crontab. Дальше начинается настоящая работа, и она уже не на пять минут: сохранить код возврата задачи, пережить моргнувшую сеть, не дать запускам наложиться, свести пояс мониторинга с поясом крон-демона, решить, что делать с секретами в логе. Разница между «поставил галочку» и «на это можно положиться» — примерно вечер. Мы этот вечер уже потратили и выложили результат готовой обёрткой выше, чтобы вам он не понадобился.

## Частые вопросы

**Почему мониторинг крона устроен «наоборот» — задача сама отмечается?**

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

**Почему код возврата иногда врёт и приходит «успех» при упавшей задаче?**

Потому что `$?` в bash возвращает статус **последней выполненной** команды, а не той, которую вы имели в виду. Если между задачей и чтением `$?` стоит ещё что-нибудь — `echo`, `tail`, закрывающая скобка группы — код возьмётся от неё, и упавшая задача отчитается успехом. Для конвейера нужен `set -o pipefail`, иначе статус берётся от последнего звена, а не от упавшего. Правило: между самой задачей и `rc=$?` не должно быть ничего.

**Часовой пояс в проверке заставит задачу запускаться в это время?**

Нет. Пояс в проверке задаёт только то, когда сигнала ждём мы. Время запуска определяет крон-демон на вашем сервере по своей зоне. Если зоны разные, вы получите ложное событие каждый раз: мы ждём в 03:00 по Москве, крон запускает в 03:00 по UTC. Сверьте `timedatectl` и либо укажите в проверке зону сервера, либо добавьте `CRON_TZ=` первой строкой crontab (понимают Vixie cron и cronie; у systemd-таймеров и Kubernetes CronJob свои поля для зоны).

**Задача запускается не по расписанию, а по событию — это подойдёт?**

Да, если у неё есть ожидаемая частота. Проверка умеет работать не только по cron-выражению, но и по периоду: «сигнал должен приходить не реже, чем раз в N минут или часов». Отсчёт идёт от предыдущего сигнала. Для задач, у которых частоты нет в принципе (запускается когда придёт файл, а придёт он неизвестно когда), мониторинг тишины не подходит — тишина у них законное состояние.
