Самый простой вариант автоматического восстановления выглядит убедительно:
if (monitor.status === "down") { await fetch(restartHook, { method: "POST" }); }
На практике этот код почти ничего не знает. Сервис всё ещё лежит или уже поднялся сам? Запрос к hook действительно выполнил действие? Если endpoint вернул 202 Accepted, изменилось ли состояние самого сервиса? Что произойдёт, если worker повторит задачу после timeout и перезапустит процесс второй раз? Наконец, не относится ли задача к прошлому инциденту, который оператор уже закрыл?
С этими вопросами я столкнулся при разработке Vigil, self-hosted системы мониторинга и работы с инцидентами. В открытой редакции Core сейчас 40 типов проверок, в полной — 42. Vigil хранит наблюдения, инциденты и действия в PostgreSQL; приложение и worker запускаются отдельно. Автоматическое восстановление появилось позже обычных проверок и уведомлений, когда стало ясно, что один restart webhook без протокола создаёт больше неопределённости, чем убирает.
В результате recovery в Vigil стало цепочкой с двумя проверками: перед действием и после него. У цепочки есть предел попыток, cooldown, дневной лимит и срок, после которого инцидент обязательно передаётся человеку. Успешный HTTP-запрос к hook ничего не закрывает. Инцидент завершается только после новой проверки наблюдаемого сервиса.

Инцидент хранит не только итоговый статус. На одной временной шкале остаются обнаружение сбоя, попытки recovery, результаты проверок и действия оператора.
Сначала нужно доказать, что действие всё ещё требуется
Допустим, checkout-сервис не отвечает 90 секунд и пересекает заданное failure window. Vigil открывает инцидент и ставит recovery-задачу в pg-boss. Между этими событиями и фактическим выполнением может пройти несколько секунд. За это время сервис способен подняться сам, оператор может закрыть инцидент вручную, а монитор — успеть начать уже другой outage.
Поэтому первая операция recovery worker — обычная проверка того же target. Она использует тот же registry типов, те же credentials, timeout, expected status, TLS-настройки и правила проверки ответа, что плановый мониторинг. Отдельного упрощённого «recovery ping» нет.
Это не теоретическая придирка. Раньше плановый worker и recovery собирали спецификацию монитора в двух разных местах. Вторая копия не включала часть TLS-параметров. Получалось, что падение обнаруживалось строгой проверкой, а восстановление подтверждалось более мягкой. Сейчас строка монитора преобразуется в CheckSpec одной функцией toCheckSpec(), которую используют оба пути.
Если предварительная проверка уже вернула UP, trigger не вызывается. Инцидент разрешается по фактически наблюдаемому состоянию. При неопределённом результате, например когда remote probes не собрали quorum, система также не получает оснований менять инфраструктуру.
Перед началом цепочки задача запоминает status_revision инцидента. Это номер поколения, а не просто его идентификатор:
interface RecoveryFence { incidentRevision?: number; } interface RecoveryExecuteJob extends RecoveryFence { incidentId: string; monitorId: string; attemptNumber: number; }
Каждая существенная смена состояния увеличивает revision. Перед записью результата worker сравнивает значение из job с текущей строкой. Старый процесс не сможет продолжить recovery после ручного resolution или применить действие к новому падению того же монитора. Проверки одного status !== "resolved" для этого недостаточно: после короткого восстановления монитор может снова упасть, и условие опять станет истинным, хотя это уже другой outage.
Recovery endpoint принадлежит оператору
Vigil не выдаёт worker произвольный shell-доступ к инфраструктуре. На мониторе указывается endpoint, который контролирует оператор. За ним может находиться небольшой сервис для systemctl restart, rollout в Kubernetes, перезапуск Docker-контейнера или запуск существующего внутреннего runbook.
Так граница полномочий остаётся явной. Vigil разрешено вызвать конкретный адрес; какие действия допустимы после этого вызова, решает код на стороне оператора. Endpoint можно разместить во внутренней сети. Для recovery это предусмотрено отдельно: запрет private address, подходящий обычному исходящему webhook, сделал бы restart-hook внутри собственной инфраструктуры бесполезным.
Запрос подписывается HMAC-SHA-256 по исходному body. Receiver проверяет подпись до разбора payload. В событии recovery.execute передаются идентификаторы монитора, инцидента и попытки, поэтому принимающая сторона может вести свой журнал или дополнительно дедуплицировать команды.
У receiver нет причины доверять только IP отправителя. Worker может переехать на другую машину, пройти через reverse proxy или работать в Docker-сети. Секрет подписи остаётся общим доказательством того, что запрос создал Vigil. Отдельное событие recovery.test проверяет соединение и подпись; корректный receiver не должен выполнять реальное действие по тестовому payload.
Ответ hook ещё не является восстановлением
После trigger worker записывает результат попытки и ждёт verify after, заданный для монитора. Эта пауза нужна сервисам, которым требуется время на запуск, прогрев или регистрацию в service discovery. Слишком ранняя проверка породила бы лишнюю вторую попытку именно в тот момент, когда первая уже работает.
Затем Vigil снова запускает нормальную проверку монитора. Ответ recovery endpoint и ответ целевого сервиса хранятся раздельно. Возможны, например, такие сочетания:
Trigger |
Проверка сервиса |
Результат |
|---|---|---|
|
|
recovery подтверждено |
|
|
действие выполнено, сбой остался |
timeout |
|
ответ потерян, но сервис восстановлен |
timeout |
|
исход trigger неизвестен, инцидент продолжается |
Последние две строки особенно показательны. Timeout не сообщает, дошёл ли запрос. Повторять такую задачу на уровне очереди опасно: первый restart мог уже начаться. Поэтому transport-задачи recovery не получают слепых автоматических retries от pg-boss. Следующую попытку создаёт сама recovery-цепочка, после проверки состояния и cooldown. У неё новый номер и отдельная запись в истории.
Инцидент закрывается через общий incident service. Параллельная плановая проверка и recovery verify иногда одновременно видят, что сервис поднялся. Conditional update позволяет разрешить инцидент одному победителю; второй путь не создаёт дублирующее событие и не отправляет второе уведомление о восстановлении.
Ограничения задаются на каждом мониторе
Универсального безопасного числа перезапусков нет. Stateless HTTP-worker можно перезапустить дважды с коротким cooldown. Для базы данных или stateful workload даже одна автоматическая команда может быть слишком рискованной. Поэтому recovery выключено, пока оператор явно не включит его на конкретном мониторе.

Границы задаются вместе с monitor: число попыток на инцидент, cooldown и задержка перед повторной проверкой.
Цепочку ограничивают четыре значения:
максимальное число попыток в одном инциденте;
пауза между попытками;
задержка от trigger до verification probe;
общий дневной предел для монитора.
Последний предел защищает от restart loop, растянутого на несколько коротких инцидентов. Если приложение после каждого запуска работает пять минут, лимита только внутри одного outage недостаточно: система будет снова получать разрешение при каждом новом инциденте.
После исчерпания попыток Vigil прекращает automation и запускает обычную маршрутизацию инцидента: уведомления, on-call или escalation policy. В timeline остаётся причина остановки, номера попыток, ответы endpoint и результаты verification probes. Оператор видит, что команда выполнялась, но не исправила наблюдаемый симптом. Например, restart процесса не восстановит отсутствующую DNS-запись; повторять его десять раз бессмысленно.
Когда допустимо задержать уведомление
Для некоторых сервисов первая автоматическая попытка занимает меньше времени, чем человек открывает сообщение. На таких мониторах можно включить hold alerts while recovering. Vigil открывает инцидент сразу, но ненадолго удерживает page. Если recovery подтверждено, человек не получает алерт о сбое, который уже завершён и полностью записан в истории.
Hold не может быть бессрочным. При его создании сохраняется deadline. Отдельная failsafe-задача recovery-escalate должна уведомить операторов, если основная цепочка зависла или не завершилась вовремя. Ошибка recovery handler тоже не блокирует открытие инцидента: граница между incident service и automation устроена так, что исключение возвращает безопасный результат «не удерживать, отправить обычный page».
Эта часть оказалась сложнее самого restart-hook. Любая автоматизация, которая способна случайно подавить уведомление, опаснее автоматизации, которая просто не запустилась. Поэтому handler отвечает incident service только на два вопроса: можно ли удержать alert и запланировал ли кто-то путь до человека. Универсальную event bus на этой границе я не добавлял; слишком легко спрятать в ней третий вариант, при котором ни recovery, ни page не имеют чёткой ответственности.
Внутренние шаги recovery не публикуются на status page. Пользователю сервиса нужны подтверждённые изменения состояния и публичные обновления команды, а не сообщения «попытка 2 запланирована через 300 секунд». Внутри Vigil эти события остаются доступными для разбора.
Что проверяют интеграционные тесты
Recovery зависит от транзакций, блокировок строк, очереди и реального поведения мониторов. Основные сценарии запускаются с PostgreSQL, а не с mock-хранилищем.
Тесты воспроизводят несколько гонок, которые трудно заметить при ручной проверке:
оператор закрывает инцидент, пока preflight probe ещё выполняется;
старый recovery job просыпается после нового падения того же монитора;
плановая проверка и verification probe одновременно подтверждают
UP;два пути пытаются записать resolution и уведомить каналы;
alert удержан, а worker останавливается до завершения цепочки;
recovery endpoint ответил, но процесс исчез до сохранения результата;
новый параметр monitor spec учитывается плановой проверкой и забывается в verification.
Последний случай уже находил настоящий дефект с TLS-параметрами. Тест на incident revision появился по той же причине: повторное чтение строки не различало старый и новый outage. Здесь полезнее проверять не конкретную последовательность вызовов, а инварианты: один инцидент разрешается один раз, устаревшая job ничего не меняет, удержанный alert обязательно получает deadline, а успешным recovery считается только наблюдаемый UP.
Сейчас полный цикл выглядит так:
incident opened → verify still down → check incident revision → send signed trigger → wait for startup → probe the original target → UP: resolve incident → DOWN: cooldown and next bounded attempt → attempts exhausted or overdue: page a human
Vigil Core остаётся бесплатной Apache-2.0 редакцией с мониторингом, incident evidence, уведомлениями и status pages. Проверяемое автоматическое восстановление, on-call и escalation входят в полную редакцию, но используют тот же monitor registry, incident service и PostgreSQL. Техническая документация и интерактивная демонстрация: vigil-uptime.com. Открытая основа проекта: github.com/sikurdev/vigil-core.