Webhook 到底是什么?从 HTTP POST 到 Zabbix QQ 机器人告警实践

我最开始接触 Webhook 时有一个很直接的疑问:既然最后发消息时执行的明明就是 HTTP POST,为什么 Zabbix 还要单独把这种媒介类型叫做 Webhook?

实际把 Zabbix 的设备上下线告警接到腾讯 QQ 机器人之后,这几个概念才真正串起来。Webhook 并不是一种新的网络协议,它描述的是“事件发生后,系统自动调用外部接口”这套机制;POST 则只是 HTTP 的一种请求方法,描述这一次请求具体怎么发。

简单来说:

HTTP/HTTPS
├─ URL:请求发到哪里
├─ Method:GET、POST、PUT、PATCH、DELETE 等
├─ Header:鉴权、数据类型等附加信息
└─ Body:实际提交的数据
   └─ JSON:常见的数据格式

Webhook
└─ 某个事件发生后,自动发起上述 HTTP 请求

所以我最后做出来的 Zabbix QQ 通知,更准确的描述是:

Zabbix 检测到设备状态变化
    ↓
触发器产生事件
    ↓
执行 Action
    ↓
调用 Webhook
    ↓
Webhook 使用 HTTP POST
    ↓
调用 QQ Bot API
    ↓
QQ群收到通知

这也是 Webhook 和 API 最容易混淆的地方。QQ Bot 提供的是 HTTP API,别人可以调用它发送消息;Zabbix Webhook 则决定“什么时候因为一个事件而自动调用这个 API”。

从一台主机上下线开始

实际需求很简单:Zabbix 已经通过 ICMP Ping 监控一台 Windows 主机 192.168.0.55,我希望它掉线时往 QQ 群发一条消息,恢复后再发一条恢复通知。

这台 Windows 最初连局域网内的 Ping 都不响应,但远程连接正常。这里也顺带确认了一件事:TCP 服务能够访问,并不代表 ICMP 一定允许。Windows 防火墙可以允许远程桌面,同时拒绝 ICMP Echo Request。

最后我只针对内网开放 ICMP:

New-NetFirewallRule `
  -DisplayName "Allow LAN ICMPv4" `
  -Protocol ICMPv4 `
  -IcmpType 8 `
  -Direction Inbound `
  -Action Allow `
  -RemoteAddress 192.168.0.0/24,192.168.10.0/24,192.168.20.0/24

Zabbix 随后可以正常得到:

ICMP ping:Up (1)
ICMP loss:0%
ICMP response time:正常

为了测试掉线,我临时禁用了这条 Windows 防火墙规则。最新数据很快变成:

ICMP ping:Down (0)
ICMP loss:100%

这里第一次出现了一个容易误解的地方:监控项出现 Down (0),不等于告警事件已经立刻产生。

ICMP Ping 模板中的离线触发器还要根据连续多次检查结果判断设备真的不可达,触发器进入 PROBLEM 状态后,后面的 Action 才会执行。也就是说:

监控项失败
≠
触发器已经报警
≠
通知已经发送

它们是三个不同阶段。

QQ 机器人不是一个 Webhook 地址

一开始我以为接聊天软件通知,大概就是拿到一个 Webhook URL 填进 Zabbix。腾讯官方 QQ 机器人实际不是这种模式。

需要先创建 QQ 机器人并取得:

AppID:<AppID>
AppSecret:<AppSecret>

然后使用 AppID + AppSecret 换取 access_token。发送群消息时,再带着这个 Token 调 QQ 的群消息 API。

调用关系实际上是:

AppID + AppSecret
    ↓
POST 获取 access_token
    ↓
Authorization: QQBot <Token>
    ↓
POST /v2/groups/<group_openid>/messages
    ↓
QQ群消息

手工获取 Token 时使用:

TOKEN_JSON=$(curl -sS 
  -X POST 
  'https://bots.qq.com/app/getAppAccessToken' 
  -H 'Content-Type: application/json' 
  -d "{"appId":"${QQ_APPID}","clientSecret":"${QQ_SECRET}"}")

这里的 POST(HTTP 请求方法:向接口提交数据)只是在完成一次请求;整个“Zabbix 告警产生后自动执行这些请求”的过程,才属于 Webhook 的使用场景。

group_openid 不是 QQ 群号

真正配置时又碰到了一个问题:QQ Bot 发群消息不能直接填普通 QQ 群号,需要的是 group_openid

这个值可以在机器人收到群事件时得到。我临时启动了一个 Python Docker 容器,通过腾讯 QQ Bot SDK 监听群里的 @ 消息,然后直接读取事件里的 group_openid

最开始为了图省事,我尝试把安装 SDK、读取 AppID/AppSecret 和运行脚本全部塞进一条 Docker 命令里,结果出现:

EOFError: EOF when reading a line

原因不是 QQ API,而是脚本使用了 heredoc:

python - <<PY

Python 的标准输入已经被 heredoc 用来读取脚本本身,后面的 input() 再读取 AppID 时直接遇到 EOF。

后面改成进入临时容器后再运行脚本,同时因为访问 files.pythonhosted.org 下载依赖发生超时,安装 SDK 时改用了国内 PyPI 镜像:

docker run --rm -it --name qqbot-openid python:3.12-slim bash
pip install 
  -i https://pypi.tuna.tsinghua.edu.cn/simple 
  --default-timeout=120 
  qq-botpy

监听群 @ 消息后成功取得:

========== 找到了 ==========
group_openid = <group_openid>
消息内容 = 测试
============================

这个临时容器只负责取一次 group_openid,拿到之后就可以删除,不需要为了 Zabbix 长期运行一个 QQ SDK 服务。

API 能调用,不代表机器人有权限发消息

拿到 access_tokengroup_openid 后,我先没有急着配置 Zabbix,而是直接调用 QQ API 测试:

curl -sS 
  -X POST 
  "https://api.sgroup.qq.com/v2/groups/${GROUP_OPENID}/messages" 
  -H "Authorization: QQBot ${ACCESS_TOKEN}" 
  -H 'Content-Type: application/json' 
  -d '{
    "content": "Zabbix QQ通知测试:机器人发送链路正常",
    "msg_type": 0
  }'

第一次返回:

{
  "message": "主动消息失败, 无权限",
  "code": 40034105,
  "err_code": 40034105
}

这个结果反而很有用,因为它说明请求已经到达 QQ API,Token 和 group_openid 至少已经进入正确的处理流程,真正卡住的是权限。

最后在 QQ 群里给机器人开启“主动在群聊内发言”权限,再次调用相同接口,返回了消息 id 和时间:

{
  "id": "ROBOT1.0_<消息ID>",
  "timestamp": "<时间>"
}

QQ群也实际收到了测试消息。

修改前的状态是机器人已经进群,但没有主动发言权限,因此 API 返回 40034105;修改后机器人获得群聊主动发言权限,相同 API 请求无需其他改动即可成功。

把 QQ API 封装成 Zabbix Webhook

手工 API 验证完成后,才开始配置 Zabbix。

创建一个 Webhook 类型的媒介,传入:

appid         = <AppID>
secret        = <AppSecret>
group_openid  = <group_openid>
message       = {ALERT.MESSAGE}

Webhook 的 JavaScript 负责做两件事:

  1. AppID + AppSecret 获取 access_token
  2. 使用 Token 调 QQ 群消息 API。

实际脚本核心逻辑如下:

var params = JSON.parse(value);

var tokenRequest = new HttpRequest();
tokenRequest.addHeader('Content-Type: application/json');

var tokenResponse = tokenRequest.post(
    'https://bots.qq.com/app/getAppAccessToken',
    JSON.stringify({
        appId: params.appid,
        clientSecret: params.secret
    })
);

if (tokenRequest.getStatus() < 200 || tokenRequest.getStatus() >= 300) {
    throw '获取QQ access_token失败,HTTP ' +
        tokenRequest.getStatus() + ': ' + tokenResponse;
}

var tokenData = JSON.parse(tokenResponse);

if (!tokenData.access_token) {
    throw 'QQ没有返回access_token: ' + tokenResponse;
}

var messageRequest = new HttpRequest();

messageRequest.addHeader(
    'Authorization: QQBot ' + tokenData.access_token
);

messageRequest.addHeader(
    'Content-Type: application/json'
);

var messageResponse = messageRequest.post(
    'https://api.sgroup.qq.com/v2/groups/' +
        params.group_openid +
        '/messages',
    JSON.stringify({
        content: params.message,
        msg_type: 0
    })
);

if (messageRequest.getStatus() < 200 ||
    messageRequest.getStatus() >= 300) {
    throw '发送QQ消息失败,HTTP ' +
        messageRequest.getStatus() + ': ' + messageResponse;
}

return 'OK';

在“媒介类型”页面直接测试:

Zabbix Webhook 测试成功

QQ群也收到了同样的消息。

到这里只能证明:

Zabbix Webhook
→ QQ API
→ QQ群

这段链路没有问题,还不能证明真实告警一定能发出去。

这个区别后来又实际踩了一次。

Webhook 测试成功,为什么真实告警还是没消息

我给 ICMP Ping 模板建立了一条统一动作,不给每台主机分别配置:

事件名称 包含 Unavailable by ICMP ping
AND
模板 = ICMP Ping

故障操作统一发送:

🔴 {HOST.NAME} 已离线
IP:{HOST.IP}
时间:{EVENT.DATE} {EVENT.TIME}

恢复操作发送:

🟢 {HOST.NAME} 已恢复在线
IP:{HOST.IP}
恢复时间:{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}
离线持续:{EVENT.DURATION}

这样以后任何绑定 ICMP Ping 模板的主机,只要触发同一个离线事件,都可以复用这一条 QQ 通知动作。

实际测试时,设备已经出现 Down (0),后来也恢复上线,但 QQ 一直没有收到通知。由于 Webhook 自带测试明明成功,一开始很容易怀疑 Action 条件或者 ICMP 触发器没有生效。

查看:

报表
→ 动作日志

发现动作实际上已经执行,只是状态为:

已失败

错误信息是:

No media defined for user.

这才发现 Zabbix 里的几个对象也是不同层级:

媒介类型
= 系统知道“QQ机器人这种通知方式怎么发送”

用户媒介
= 某个用户实际拥有哪些通知渠道

Action
= 什么事件发生时,通知哪个用户

我已经创建了“QQ机器人”媒介类型,Action 也指定发送给 Admin,但 Admin 用户自身并没有绑定这个 QQ 媒介。

所以实际执行变成了:

ICMP 事件
↓
Action 命中
↓
准备通知 Admin
↓
Admin 没有 QQ机器人媒介
↓
No media defined for user.

在:

用户
→ 用户
→ Admin
→ 媒介

添加 QQ机器人 后,这条链路才真正完整:

ICMP Ping
↓
Trigger
↓
Action
↓
Admin 用户
↓
QQ机器人媒介
↓
Webhook
↓
QQ Bot API
↓
QQ群

这里也解释了为什么“Webhook 测试成功”和“实际告警发送成功”完全是两件事。前者只测试媒介类型本身能不能调用 QQ;后者还涉及触发器、Action、用户、用户媒介和权限。

最后形成的理解

做完这套配置后,Webhook 对我来说就不再只是“一个 POST”。

POST 描述的是一次 HTTP 请求怎么发送,例如:

POST /v2/groups/<group_openid>/messages

API 是 QQ Bot 对外提供的能力:

你按规定调用这个地址,
我就帮你发送群消息。

Webhook 描述的则是:

设备掉线了
↓
Zabbix 自动产生事件
↓
不需要人工操作
↓
自动调用 QQ API

最终整个实践可以简化成:

设备状态变化
↓
Zabbix ICMP 监控项
↓
Trigger 判断是否真正故障
↓
Action 判断该不该通知
↓
用户媒介确定发到哪里
↓
Webhook 执行 JavaScript
↓
HTTP POST 获取 access_token
↓
HTTP POST 调 QQ Bot API
↓
QQ群收到离线或恢复通知

所以 Webhook 与 POST 从来不存在“二选一”的关系。

Webhook 是事件驱动的调用机制,HTTP/HTTPS 是通信协议,POST 是 HTTP 请求方法,JSON 是常见数据格式,而 QQ Bot API 是最终被调用的接口。

真正把这些东西放进一次实际告警链路后,它们之间的层级才变得清楚。