在能用的告警系统前面加一根笨管子:hookrelay 的边界是怎么守住的
我有一个自己写的告警控制面(WebhookWise):接 Prometheus / Grafana / Alertmanager 的事件,归一化、去重、AI 分诊、转发,每条通知都能解释自己为什么发出来或者为什么没发。它是能用的。
然后我在它前面又写了一个更笨的东西 —— hookrelay,两千多行代码、五个运行时依赖,故意不理解事件内容。
结论先放前面:一个系统一旦既负责”把消息送到”又负责”判断消息值不值得送”,这两件事会互相污染 —— 送不到的时候你怀疑是不是被判断掉了,判断错的时候你怀疑是不是没送出去。把它们劈成两个进程,各自只留一句可验证的承诺,排查成本立刻降一个数量级。难的不是劈开,是让笨的那半保持笨。
1. 一条判据
拆分之后最现实的问题是:新需求来了往哪边放?
我给自己定了一条判据,写进了仓库:
这是一根好管子应该有的属性,还是对这条告警”值不值”的判断?
管子的属性留在 hookrelay,判断交给后面的大脑(或者干脆不做)。这条判据的价值在于它可以当场回答,不需要开会:
- 「投递失败要重试」——管子的属性。留下。
- 「重试八次还不行就进死信」——管子的属性。留下。
- 「同一分钟内相同内容的只发一条」——这是判断,它假设”重复的没价值”。
- 「每个渠道每分钟最多发 20 条」——管子的属性,这是下游配额,跟内容无关。
第三条是最容易混进来的那种。它长得很像”管子的属性”,因为它不看语义、只看字节是否相同。但它做的是”帮你决定哪条不用看”,这是判断。
2. 四根柱子和两条承诺
划完边界,剩下的东西自己就分成了四组:
| 柱子 | 它负责什么 |
|---|---|
| 接 | 门、签名方言、为路由抽取字段 |
| 路由 | source + 条件 → 渠道,优先级,是否 stop |
| 发 | 重试、退避、限流、死信、各渠道的报文格式 |
| 账本 | 每个事件一条决策记录,每次投递一个终局 |
第四根是核心。整个项目对外只承诺两句话:
- 每个事件都留下且只留下一条决策记录:
routed到了哪些渠道,或者skipped并带一个具名原因(duplicate/silenced/no_route/storm_suppressed),外加按顺序记录的每一道关卡。 - 每次被接受的投递,最终必然落在
sent或dead之一:尝试次数和最后一次错误都摆在外面,不存在”悄悄没了”。
这两句话之所以值钱,是因为它们可以被证伪。你可以写测试断言”任何输入之后,事件表里的记录数恰好加一”。而”降低告警噪音 60%”这种承诺,没法证伪,也就没法防止它慢慢退化。
具体表现是,同步返回的响应体本身就是决策轨迹:
1 | {"event_id": 1, "outcome": "routed", "channels": ["ops-feishu"], |
调试的时候不用去翻日志、不用去查库,curl 的输出就是答案。
3. 边界是靠改名字守住的
写完两周不到,我做了一次”产品边界审计”,查出四处漂移。最刺眼的一处是:状态页的副标题写着「收 → 判 → 发」。
产品自己的标语,宣称了它绝不该做的那件事。
没人是故意写的。「判」字很自然 —— 中间那一段确实在做判定(去重、静默、路由匹配)。但这个字一旦挂在首页,后面每一个”顺手加个小判断”的需求都变得理直气壮。词会反过来塑造代码。
于是那次审计的主要产出不是代码,是改名:
| 原来的叫法 | 改成 | 为什么 |
|---|---|---|
| 去重(降噪能力之一) | 内容保护 | 它挡的是完全相同的载荷,不是”没价值的告警” |
| 静默(降噪能力之二) | 阀门 | 是运维在管子后面那玩意儿要烧起来时拉的总闸,永远带过期时间 |
| 收 → 判 → 发 | 每条消息都有账、每次投递都有终局 | 换成能被证伪的承诺 |
「降噪」这个词本身就是走私犯:它把”判断价值”包装成”技术处理”。改叫「内容保护」之后,”要不要加一个按级别降噪的开关”这种需求,一问就露馅了 —— 那不是保护,那是判断。
还有一个配套动作:明确写下两种姿态。
- 配对姿态(后面挂了大脑):pipeline 只留
[silence, routes]。去重必须关掉 —— 否则大脑那边的噪音统计会失真,它以为世界上就这么多告警。 - 独立姿态(小团队,没有大脑,只想要 webhook → 飞书):判断类能力(
filter、set、内容去重)可以开,它们就是为这个场景存在的。
关键是这些能力在配对姿态下要让位。留了一个 http 处理器当口子:把事件 POST 给外部服务,拿回 pass / drop / set。判断可以被委托出去,但不能被吸收进来。
4. 内容去重结构上挡不住的那种洪水
这是审计查出的第四处漂移,当时只记录、没有立刻做,后来证据到了才补上。
去重挡的是相同的载荷。但真正打垮系统的那种洪水,往往每一条都不一样 —— 一次网络分区,两千个 Pod 各自报一条带自己名字的告警。高基数洪水不重复任何指纹,内容去重从结构上就看不见它。
于是加了一个和内容无关的东西:保险丝(storm fuse),只数数量。
1 | def check(self, source, threshold, window_seconds, now) -> str: |
三个设计决定值得说:
软级仍然记账。 超过阈值之后,事件照样写进账本(skipped · storm_suppressed,轨迹里带上窗口内的计数),只是不走 pipeline、不发任何渠道。理由很简单:风暴恰恰是你最需要知道到底来了些什么的时候。一个”保护”手段如果让你在事故当口失去可见性,那它保护的是自己。
硬级(10 倍)才不写库。 到了这个量级,账本本身就是要被保护的对象了,直接 429,不碰存储。
它放在保险丝盒里,不在业务逻辑里。 具体是:在门口、验签之后、pipeline 之前。
- 验签之后:没签名的洪水本来就是 401,白嫖不到任何预算。
- pipeline 之前:不管路由配成什么样,它都生效。
- 计数在进程内存里。
最后这条我一开始犹豫过,想着要不要持久化。后来想通了:一个需要自带数据库的保险丝,什么也保护不了;而且重启把窗口清零,对保险丝来说本来就是正确行为 —— 你重启了,就是新的一轮。
配对姿态下这个保险丝必须开。因为把去重关掉之后,前面就再没有任何按量的反压了。
5. 重放:只签 body 的 HMAC 会永久有效
原来的签名是 HMAC 覆盖请求体。问题是:任何人抓到一个签过名的请求,就能永远重放它,把同一条消息反复灌进群里。签名验的是”这确实是你发的”,它从来没验过”这是你现在发的”。
改成签 {timestamp}.{body},配一个新鲜度窗口(默认 300 秒,每个门可调 max_skew_seconds)。
真正麻烦的是怎么在不停机的前提下换掉它。发送方和接收方分属不同系统,不可能同一秒切。顺序是这样:
- 门同时接受两种格式(带时间戳的和只签 body 的);
- 发送方先迁移;
- 确认没有旧格式流量之后,用每个门独立的
require_timestamp拒掉旧格式。
先开门再关门,中间任何一刻流量都不断。这个顺序适用于所有协议升级,不只是签名。
还有一条容易做错的:对外发出去的时候,不要替对端猜格式。 我们自己的门收到的是带时间戳的签名;但如果下游是个用自己方言的接收方(比如 WebhookWise 用 X-Webhook-Signature),发给它的就必须是它验的那种 —— 只签 body,不擅自加一个它根本不认识的时间戳。这条专门写了测试钉住,因为替对端猜签名格式,是签名机制出错最常见的方式。
6. 投递账本:限流是延后,不是失败
投递单独一本账,按 (事件 × 渠道) 一行。退避是 30s · 2ⁿ、上限 10 分钟、最多 8 次,然后进入可见的 dead 状态 —— 不是消失,是躺在那里等你点重试。
一个小但重要的区分:每渠道的 max_per_minute 是”延后”,不是”丢弃”,也不消耗尝试次数。
1 | 限流命中 → 重新排期,attempts 不变 |
理由:反压是调度问题,不是失败。如果限流也扣尝试次数,那么一个被限流的高流量渠道会在几分钟内把 8 次配额烧光,然后一堆本来完全正常的消息进死信 —— 而根因只是”发得太快”。这个 bug 很隐蔽,因为它只在流量高峰出现,而那正是你最不想丢消息的时候。
配套的还有断路器:连续失败到阈值就让这个渠道歇一会儿,别的渠道照常并行投递。
7. 顺手记一个工程习惯:用契约测试把 CI 和本地检查钉在一起
这个项目里有 75 个测试,加 CI 之前没有任何人在跑它们(我本地跑 pytest,CI 里压根没配)。
补 CI 的时候顺手加了一条契约测试:它断言本地的 scripts/gate.sh 和 .github/workflows/ci.yml 检查的是同一份清单。往任意一边加检查而不加另一边,这条测试就红。
这解决的是一个很常见的慢性病 —— 本地脚本和 CI 配置各长各的,最后谁也不知道”全套检查”到底是哪几项。另外 CI 会构建镜像并把它启动起来,因为容器才是这东西真正的交付形态;只跑单元测试的 CI 证明不了它能起来。
8. 什么时候你需要这个
值得拆的信号:
- 你已经有一个”会判断”的告警系统,而它同时也是唯一的入口 —— 它一重启,所有告警全丢。
- 排查”这条告警为什么没发出来”时,你需要同时看归一化、判断、投递三处日志才能定位。
- 你想给判断层做灰度或重构,但不敢,因为它挂了就没有告警了。
不值得拆的信号:
- 只有一个上游、一个下游、每天几十条。那直接用 Alertmanager 的 webhook receiver 就行。
- 团队里没人愿意维护”两个服务”。拆分的成本是真实的,多一个部署单元、多一层网络。
真正的收益不在功能,在排查时的提问方式变了:以前问”这条告警怎么了”,现在先问”它有没有进管子” —— 账本里一查就知道,然后才决定去看管子还是看大脑。把一个模糊的问题变成两个二选一的问题,这是拆分唯一值得要的东西。
代码在 github.com/itswl/hookrelay。后面那个大脑是 WebhookWise,两者可以零代码对接:generic 渠道开 raw 转发,把原始载荷原样送进 WebhookWise 自己的 ingest,它的各生态适配器一行都不用改 —— hookrelay 就成了一个透明的、有账本的边缘。