Zabbix пишет Sent, а сообщение не дошло: как на самом деле работают повторы webhook
Что на самом деле значит статус Sent у webhook в Zabbix, когда срабатывают повторы, какой скрипт молча теряет оповещения, как отсеивать дубли после таймаута, что поправить в шаблонах из поставки и как читать тексты ошибок.
Знакомая картина: в Zabbix у оповещения статус Sent, а в чате или в тикетной системе пусто. Частая причина в том, что Sent отчитывается о скрипте, а вовсе не о получателе. Ниже разбираем, когда Zabbix повторяет отправку, какой скрипт молча теряет оповещения, что поправить в шаблонах из поставки и как по тексту ошибки понять, где сломалось.
TL;DR: если нужен только итог, переходите сразу к чек-листу и эталонному скрипту для веб-хука Zabbix.
Как проверяли
Мы подняли стенд на Zabbix 7.0 и поставили вместо получателя заглушку, которую можно ломать по заказу. Она эмулировала пять видов отказов: отвечала HTTP 500, не принимала соединение, молчала до таймаута, не резолвилась по имени и обрывала соединение без ответа. Через эти отказы прошли три стиля самописного скрипта и шаблоны из поставки (часть мы запускали на ответах, похожих на ошибки сервисов, остальные читали по коду), а мы смотрели, что Zabbix при этом пишет в журнале и в API.
Sent говорит о скрипте, а не о доставке
Zabbix не знает, дошло ли сообщение. Он знает только одно: завершился скрипт webhook без исключения или нет. Если скрипт вернул значение, оповещение становится Sent, что бы ни ответил получатель (да, вы правильно поняли, даже если он ответил HTTP 5xx). Если выбросил исключение, Zabbix повторяет попытку, а когда попытки кончились, ставит Failed. Надёжность доставки целиком зависит от того, кидает ли ваш скрипт исключение на каждом отказе.
На схеме весь путь от проблемы до статуса. Развилка, которая решает судьбу сообщения, здесь одна: выбросил скрипт исключение или вернул значение.
Каждая отправка через тип оповещения становится отдельной записью, и её видно в Reports → Action log. Пока идут повторы, у записи статус In progress с подписью вида «2 retries left», то есть число оставшихся попыток убывает. В API то же состояние видно по полю retries, которое растёт. В поле ошибки попадает текст последнего исключения, поэтому текст, с которым скрипт выбрасывает исключение, и есть ваша диагностика.
Как отличить повтор от окончательного отказа. Пока у записи в alert.get поле status равно 0 (или 3 у только что созданной), а retries растёт, Zabbix ещё пробует, и получатель в этот момент отказывает. Окончательный отказ выглядит как status=2 (см. лог ниже). Текст ошибки появляется уже после первой неудачной попытки, так что причину видно до окончания повторов. Лог скрипта, который отдаёт исключение HTTP 500, при трёх попытках:
Attempts 3 это одна отправка и два повтора
Поле Attempts задаёт общее число попыток, а не число повторов сверх первого. При значении 3 получатель увидит не больше трёх запросов: первую отправку и два повтора. Повтор случается только тогда, когда скрипт вызвал исключение. Скрипт, который вернул значение, второй раз не запускается. При Attempts 1 повтора нет вовсе, и любой сбой, даже один оборванный запрос, сразу даёт Failed.
Какое значение повторов оптимально? Базовый минимум явно не должен быть меньше 3. При Attempts 3 и интервале 10 секунд последняя попытка уходит примерно через 20 секунд после первой, если получатель отказывает сразу (отказ в соединении, HTTP 500). Если каждая попытка ждёт Timeout, окно растягивается: с Timeout 5 секунд последняя попытка ушла через 29 секунд после первой. Если получатель успел ожить к последней попытке, сообщение он получит. Если ваш получатель перезапускается дольше, увеличивайте Attempt interval или Attempts. Больше попыток значит больше возможных дублей, поэтому получатель должен их отсеивать (об этом ниже, в главе про таймауты).
Тип, созданный через API, по умолчанию выключен. Если вызвать mediatype.create без поля status, тип создаётся выключенным, и каждое оповещение через него сразу получает Failed с текстом Media type disabled., не сделав ни одной попытки. Поэтому передавайте "status": 0 явно. Остальные поля он получает со значениями по умолчанию в Attempts 3, Attempt interval 10s и Timeout 30s.
Повторы есть только у скрипта, который выбрасывает исключение
Из трёх стилей, которые чаще всего встречаются в самописных webhook, правильный только один. Throw проверяет код ответа и выбрасывает исключение, поэтому любой отказ даёт повторы и честный Failed. Return опасен: он теряет событие ровно тогда, когда получатель жив, но отвечает ошибкой, ведь на HTTP 500 он получает обычный ответ, возвращает OK, и оповещение становится Sent без повторов. Swallow хуже всех: он ловит любое исключение и возвращает успех, так что каждый отказ превращается в Sent и теряется всё.
Throw, правильный стиль. Кидает исключение на всё, что не 2xx:
Return, опасный стиль. Отправляет запрос и не смотрит на ответ:
Swallow, худший стиль. Проверяет статус так же, как Throw, но заворачивает всё в try/catch и возвращает успех:
В таблице итог каждого стиля на пяти отказах при Attempts 3. Жирный Sent означает потерянное оповещение: получатель его не принял или ответил ошибкой, а Zabbix считает его отправленным. Строка Throw целиком состоит из Failed с тремя попытками, и именно так выглядит надёжный скрипт.
| Скрипт | HTTP 500 | Отказ в соединении | Таймаут | DNS | Обрыв |
|---|---|---|---|---|---|
| Throw | Failed, 3 попытки | Failed, 3 попытки | Failed, 3 попытки | Failed, 3 попытки | Failed, 3 попытки |
| Return | Sent, 1 запрос | Failed, 3 попытки | Failed, 3 попытки | Failed, 3 попытки | Failed, 3 попытки |
| Swallow | Sent, 1 запрос | Sent | Sent, 1 запрос | Sent | Sent, 1 запрос |
Return выглядит прилично на сетевых отказах лишь потому, что HttpRequest сам выбрасывает исключение, когда запрос не удался на уровне соединения, а ответ 500 для него обычный ответ, и закрывает эту дыру только проверка статуса. От Swallow же на любом отказе остаётся одна строка в журнале сервера, которую скрипт написал сам.
Ловить можно, если пробрасывать дальше. Скрипт, который ловит исключение, пишет его в журнал и делает throw e, на сетевых отказах ведёт себя как Throw. Проверку статуса это не заменяет: без неё HTTP 500 у такого скрипта всё равно становится Sent. Строки Zabbix.log(3, ...) видны в журнале сервера при уровне журнала по умолчанию.
Текст ошибки webhook показывает причину поломки
По тексту в поле ошибки оповещения обычно сразу понятно, где что сломалось. Сетевые ошибки HttpRequest начинаются с Error: cannot get URL:, дальше идёт сообщение HTTP-клиента и стек. Номер в последней строке стека (function:N) это номер строки скрипта, в которой был вызов запроса.
Давайте разберём каждую ошибку.
Could not connect to server
Соединение отвергнуто: например, на адресе никто не слушает порт или межсетевой экран отвечает отказом (если он молча отбрасывает пакеты, будет таймаут). Точная формулировка зависит от версии libcurl на сервере Zabbix. Проверьте адрес и порт в параметрах типа оповещения, а доступность получателя проверяйте именно с сервера Zabbix.
Operation timed out
Получатель не ответил за время Timeout типа оповещения. Запрос при этом мог до него дойти, так что считать сообщение недоставленным рано (подробнее в главе про таймауты). Timeout ограничивает весь скрипт, а не каждый запрос: у первого запроса число миллисекунд в тексте близко к Timeout типа, у следующих оно меньше, потому что им достаётся остаток. Если получатель обычно отвечает дольше, поднимайте Timeout или ускоряйте получателя.
Could not resolve host
Имя не резолвится с сервера Zabbix. Скорее всего, в адресе опечатка или на хосте сервера не настроен DNS.
Empty reply from server
Соединение установлено и закрыто без ответа. Часто так ведёт себя получатель, который упал на запросе, или балансировщик, оборвавший соединение. Загляните в журнал получателя за это время.
RangeError: execution timeout
Сам скрипт не уложился в Timeout, например застрял в цикле. Сеть тут ни при чём: ищите ошибку в логике скрипта по номеру строки в стеке.
Media type disabled.
Тип оповещения выключен, и попыток не было. Включите тип на странице Alerts → Media types. Так бывает с типом, созданным через API без "status": 0, и с шаблонами из поставки, потому что они поставляются выключенными.
HTTP 0 в собственном тексте ошибки
После сетевого исключения req.getStatus() возвращает 0. Если в catch собирать текст как 'HTTP ' + req.getStatus(), в журнале окажется HTTP 0, и причина потеряется. Выбрасывайте исходное исключение: в нём есть текст HTTP-клиента.
Ошибки шаблонов из поставки
Следующие тексты пишут сами шаблоны, а не HttpRequest. В первых трёх кода ответа нет.
Telegram, Slack, PagerDuty и Discord получили ответ не в JSON, например HTML-страницу 502 от прокси. Сервис или прокси ответил ошибкой, но какой именно, из текста не узнать. Код ищите в журнале прокси перед сервисом или повторите запрос с сервера Zabbix через curl с тем же прокси.
У Mattermost та же беда выглядит так:
А этот текст оставляет Telegram, когда получил ответ с ошибкой без поля description в теле (у нас это был HTTP 500). Кода ответа в тексте опять нет.
Для сравнения, MS Teams Workflow проверяет код до разбора ответа и называет его прямо:
После таймаута Zabbix отправит сообщение ещё раз: получатель должен отсеивать дубли
Таймаут говорит только о том, что получатель не ответил вовремя. Сам запрос он при этом мог получить и обработать, просто не успел ответить за время Timeout. Zabbix обрывает соединение, считает попытку неудачной и отправляет сообщение снова.
На стенде получатель записывал запрос на каждой такой попытке. Значит, медленный получатель может получить одно оповещение до Attempts раз: в чате появятся дубли, а в тикетной системе несколько одинаковых заявок.
Чтобы отсеивать такие повторы, передавайте в теле ключ из {EVENT.ID}, {DATE} и {TIME} и считайте повтором запрос с уже виденным ключом. В параметрах типа оповещения для этого хватит трёх строк:
В скрипте Throw, который разбирали выше, из них собирается ключ и уходит в тело запроса:
Получатель увидит ключ вида 164:2026.09.29 21:19:33. Если действие оповещает нескольких получателей, добавьте к ключу {ALERT.SENDTO}.
Ключ работает, потому что Zabbix раскрывает макросы один раз, при создании оповещения, и повтор через 10 секунд несёт то же {TIME}, что и первая попытка. У шагов эскалации, обновлений (подтверждение, комментарий), восстановления и уведомления об отмене эскалации время создания своё, поэтому их ключи различаются. Два разных уведомления одному получателю в одну и ту же секунду мы не воспроизводили.
Напрашивается пара {EVENT.ID} и {EVENT.VALUE}, но она опасна. Шаг 2 эскалации и уведомление об отмене несут те же EVENT.ID и EVENT.VALUE 1, что и первое уведомление о проблеме. Получатель, который отсеивает по этой паре, отбросит шаг 2, то есть как раз то уведомление, которое должно разбудить следующего человека. Номер шага в ключ тоже не добавить: такого макроса в Zabbix нет, и {ESC.STEP} приходит буквальным текстом.
Как выбрать Timeout
Время ожидания задаётся только полем Timeout на основной вкладке Media type типа оповещения, и оно же ограничивает все запросы скрипта вместе. Больше 60 секунд поставить нельзя. Получатель, который отвечает за 45 секунд, при Timeout 30s падает с Operation timed out after 30000 milliseconds, а при Timeout 1m доходит успешно. Ставьте Timeout с запасом над обычным временем ответа получателя, но не больше, чем готовы ждать. Если скрипт делает несколько запросов, запас нужен на их сумму.
Из скрипта таймаут не задать. Метода setTimeout у HttpRequest в 7.0 нет, и вызов req.setTimeout(...) на каждой попытке заканчивается ошибкой TypeError: undefined not callable.
Слишком длинный Timeout держит в очереди чужие оповещения. Девять оповещений упёрлись в получателя, который молчал 45 секунд при Timeout 30 секунд, и Zabbix отправлял их группами по три с шагом 30 секунд: девятое впервые ушло только через минуту после события. Процессов отправки на стенде было три, и, судя по всему, каждый из них всё время ожидания занят одним оповещением (вывод, не проверяли). Внутри одного типа оповещения очередь ещё уже: по умолчанию на вкладке Options стоит Concurrent sessions: One, и на стенде два оповещения одного типа ушли строго друг за другом. Выходит, один зависший получатель тормозит всех, кто стоит за ним в очереди, поэтому держите Timeout близким к реальному времени ответа.
Шаблоны webhook из поставки надо поправить перед включением
Шаблоны из поставки по-разному честны к отказам. Перед включением проверьте в каждом, который собираетесь использовать, три вещи: сколько в нём попыток, выбрасывает ли он исключение на ответ не 2xx и читает ли тело ответа. Все шаблоны поставляются выключенными.
Slack, Mattermost, MantisBT и GLPI поставляются с Attempts 1. Любой сбой у них сразу даёт Failed без повтора. Но учтите: Slack, Mattermost и MantisBT делают за попытку несколько запросов, и если упал не первый из них, повтор отправит сообщение или создаст заявку ещё раз (вывод по коду шаблонов, на стенде не проверяли). Поднимая Attempts, будьте готовы к дублям. Поднимите Attempts до 3 на вкладке Options типа оповещения.
IBM Maximo Service Request на ошибку HTTP возвращает значение вместо исключения. На ответы 500, 502 и 429 оповещение стало Sent без повторов. Если пользуетесь этим шаблоном, поправьте скрипт.
Найдите:
Замените на:
MS Teams Workflow не читает тело ответа. Код ответа он проверяет, но и ответ 200 с отказом в теле, и пустой ответ 200 дали Sent. Если сервис сообщает об ошибке в теле, Zabbix этого не увидит, поэтому после настройки убедитесь, что тестовое сообщение действительно появилось в канале.
Telegram, Slack, PagerDuty, Discord и Mattermost теряют код ответа, если тело не JSON (как выглядят такие ошибки, разбирали выше); судя по исходникам, так же, до проверки кода ответа, разбирают ответ GitHub, Jira, JSM, OTRS CE, GLPI и Zammad.
Проверка TLS по умолчанию полная. Если исходящий трафик идёт через прокси с подменой сертификата, шаблоны из поставки отправлять не будут, пока сервер Zabbix не доверяет этому сертификату (вывод по коду шаблонов, на стенде не проверяли). У всех 32 webhook-шаблонов параметр tls_verify по умолчанию равен {$HTTP.TLS.VERIFY:"<имя шаблона>"}. Такого макроса в свежей базе нет, и нераспознанное значение превращается в full, то есть в полную проверку сертификата. Кроме того, при любой проверке, кроме none, шаблон отвергает адрес не с https://. Уровень проверки задаётся этим же макросом (none, peer или full) или прямо в параметре tls_verify.
Параметра прокси в шаблонах нет, хотя код его читает. Все 32 шаблона читают параметр прокси: одни http_proxy (среди них Telegram, Slack, PagerDuty, Discord, MS Teams Workflow, Jira, GitHub), другие HTTPProxy (среди них Mattermost, Pushover, Opsgenie, ServiceNow). В поставке он есть только у Event-Driven Ansible. Если к API сервиса нет прямого выхода, добавьте параметр вручную в таблицу Parameters на вкладке Media type: на копии Discord запрос после этого пошёл через прокси методом CONNECT.
Ниже сводка по проверенным шаблонам. Смотрите на жирные ячейки: Sent означает, что сервис отказал, а оповещение числится отправленным, то есть потеряно, а Attempts 1 означает, что повтора не будет. Везде, где отказ заканчивается Failed, шаблон ведёт себя честно. Ответы сервисов на стенде имитировала заглушка, поэтому таблица говорит о поведении скриптов, а не настоящих API.
| Шаблон | Attempts | Ответ не 2xx | Отказ в теле при 200 |
|---|---|---|---|
| Telegram | 3 | Failed | Failed |
| Slack | 1 | Failed | Failed |
| Discord | 3 | код не проверяется, успех = в ответе есть id | Failed |
| MS Teams Workflow | 3 | Failed | Sent |
| Mattermost | 1 | Failed, если не 200/201 | тело не проверяется |
| Opsgenie | 3 | Failed | проверяется опросом результата, на стенде не подтвердили |
| PagerDuty | 3 | Failed, если не ровно 202 | Failed |
| Pushover | 3 | Failed, если не 200 | Failed |
| IBM Maximo Service Request | 3 | Sent | Failed, если нет полей заявки |
| iLert (по коду) | 3 | 400 с двумя определёнными кодами считается успехом | не прослеживали |
Чек-лист надёжного webhook
- Выбрасывайте исключение на любой ответ не 2xx. Без этого HTTP-ошибки становятся Sent.
- Не возвращайте успех из
catch. Записали в журнал, пробрасывайте дальше. - Проверяйте код до
JSON.parseи кладите код и тело ответа в текст исключения, иначе HTML-страница прокси превратится в ошибку разбора без кода, а Failed не объяснит себя. - Проверяйте тело, если сервис сообщает об отказе в ответе 200, например
if (data.ok !== true) { throw 'API refused: ' + JSON.stringify(data); }. - Ставьте Attempts не меньше 3: Alerts → Media types → ваш тип → Options → Attempts. В шаблонах Slack, Mattermost, MantisBT и GLPI там 1 (у Slack, Mattermost и MantisBT повтор после частичного успеха даёт дубль).
- Задавайте время ожидания полем Timeout на основной вкладке типа, Alerts → Media types → ваш тип → Media type → Timeout, с запасом над временем ответа получателя: например,
1mдля получателя, который отвечает за 45 секунд. Вызовreq.setTimeoutв скрипте сломает отправку, потому что такого метода нет. - Передавайте ключ для отсеивания дублей в параметрах типа:
event_id={EVENT.ID},alert_date={DATE},alert_time={TIME}(при нескольких получателях ещёsendto={ALERT.SENDTO}). Получатель считает повтором запрос с уже виденным ключом, например164:2026.09.29 21:19:33; параEVENT.IDиEVENT.VALUEне годится, она не отличает шаг 2 от первого уведомления. - Создавая тип через API, включайте его явно:
"status": 0вmediatype.create. - В IBM Maximo Service Request замените
return 'HTTP code: ' + statusнаthrow. - Если к сервису нет прямого выхода, добавьте параметр прокси в таблицу Parameters на вкладке Media type:
http_proxyилиHTTPProxy, в зависимости от того, какое имя читает скрипт шаблона. - Если прокси подменяет сертификат, добавьте сертификат прокси в доверенные на сервере Zabbix. Макрос
{$HTTP.TLS.VERIFY:"Telegram"}со значениемnone(контекст берите из значения параметраtls_verify) отключает проверку сертификата целиком, и токен пойдёт по непроверенному соединению; это крайняя мера (вывод по коду шаблонов, на стенде не проверяли).
Эталонный скрипт закрывает пункты 1, 2, 3 и 7. Параметры типа оповещения: url=<адрес получателя>, event_id={EVENT.ID}, event_value={EVENT.VALUE}, alert_date={DATE}, alert_time={TIME}, subject={ALERT.SUBJECT}, message={ALERT.MESSAGE}. При нескольких получателях добавьте параметр sendto={ALERT.SENDTO} и включите p.sendto в ключ.
Сетевые ошибки этот скрипт не ловит намеренно: HttpRequest сам кинет исключение с понятным текстом, и Zabbix повторит попытку.
Что дальше
С таким скриптом Zabbix честно отчитывается об отказах и повторяет отправку столько раз, сколько задано в Attempts. Остаётся вопрос, кто примет оповещение и доведёт его до человека.
Если поверх Zabbix нужны эскалации и графики дежурств, Incident Garden принимает события Zabbix напрямую, как описано в инструкции по подключению Zabbix. А чтобы дежурный проснулся ночью, можно настроить голосовой звонок: робот позвонит на телефон и назовёт число новых алертов.
Желаем меньше инцидентов и больше спокойных ночей, ну а задать интересующие вопросы как по Incident Garden, так и просто по тематике devops/SRE вы всегда можете в нашем закрытом чате ТГ DevOps, SRE, Infra.