先说一下这个项目的由来。我做运维和可观测性建设那几年,最头疼的不是“没有告警”,反而是“告警太多太散”。Prometheus 的告警发到钉钉群、Zabbix 的告警发到邮件、CI 构建失败又打到企业微信,再加上云厂商的监控通知,每个平台一套 webhook、一套消息格式、一套联系人配置。系统一多,消息乱得像菜市场,真正重要的告警反而被淹没在“信息洪流”里。我当时最想做的一件事,就是有一个轻量的、能统一接收事件、按规则过滤和路由、再送到正确的人或者群里的中间层——这就是 hermes-agent 最开始的原型。它不是一个重型的监控平台,也不负责采集指标,它的角色更像是一个“消息快递站”:收件、拆包、贴标签、转运,每一步都可以由你自己用配置来控制。这篇文章我会把完整的设计思路、核心实现、部署方式和踩坑记录都梳理一遍,适合正在被多监控源通知折磨的运维同学,也适合想在项目里加一个事件路由层、又不想引入重型消息中间件的开发朋友参考。
1. 整体设计与思路拆解
1.1 这个项目要解决的本质问题
先说清楚它不是什么。hermes-agent 不做数据采集,不存储指标,也不帮你画 dashboard。在可观测性体系里,它位于“监控系统”和“通知对象”之间,角色非常明确:把来自不同监控源的事件,转换成统一格式,按照你的规则做过滤和路由,再推送到正确的地方。
如果你画一张数据流转图,大概是这样的:
Prometheus / Zabbix / Grafana / 云监控 / 自定义脚本 ↓ 各类 webhook hermes-agent 接收层 ↓ 解析 + 标准化 事件统一模型 ↓ 规则引擎(过滤 / 聚合 / 路由) Redis 缓冲队列(可选) ↓ 按规则投递 钉钉 / 企业微信 / 邮件 / Slack / 自定义 webhook我把这种设计叫做“事件总线里的最后一公里”。为什么是最后一公里?因为前面采集、存储、告警规则都有人做了,真正混乱的恰恰是告警产生之后,消息怎么组织、怎么分流、怎么送达。现实里,一个公司可能有几套监控工具,它们各自为政地发通知,大家要么被吵死,要么把群消息静音了事。问题的本质不是缺少监控能力,而是缺少事件治理和分发的控制点。
hermes-agent 就是把“监控产生事件”和“人收到通知”解耦开。接入新的监控源,你只需要加一个解析器;通知方式变了,你只需要改一条路由规则。这个解耦带来的好处是长期的——后面我接入一个新的内部系统告警,只用了半个小时,而过去可能要在每个监控平台和管理者之间反复拉扯。
1.2 为什么最终选定了轻量事件路由器方案
当时我的备选方案其实有好几个:直接用现成的开源工具 Alertmanager 统一告警?但 Alertmanager 跟 Prometheus 绑定很深,非 Prometheus 的事件接入很别扭;上 Kafka + 自研消费者?对于告警通知这个场景来说太重了,光维护集群就得不偿失;用云厂商的 SNS/SQS?又牵扯到云服务绑定和上云迁移成本。所以我的判断是:这个场景需要一个独立的、体量小的、部署和配置都够简单的服务,而且最好能支持 YAML 配置开启即用。
“轻量”是我非常坚持的原则。告警通知系统的核心逻辑并不复杂,复杂的往往是各种接入源的格式差异和投递渠道的 API 差异。如果把大量精力放在高可用集群、分布式事务上,项目就会变得重,重的东西就不好维护。hermes-agent 在架构上是一个无状态的服务:默认情况下,它只靠消息队列做缓冲,不依赖数据库,配置是本地 YAML 文件,支持通过 HTTP 接口热加载。这个优势在部署时才体现出来——一台 1C2G 的小机器就能跑,Docker 单容器启动,连 Redis 都不是强依赖。
再一个考虑是规则的可编程性。很多通知工具是简单的“转发器”,收到什么就转什么,完全没有中间处理环节。但真实的告警场景里,你一定会遇到“白天的告警发给一线值班,夜间的告警发给二线备份”之类需求。如果用传统“转发器”,你只能在监控源那边做配置,一个源一个源去改,非常痛苦。所以 hermes-agent 内置了一个基于 JSONPath 和条件表达式的规则引擎,可以直接在事件字段上做逻辑判断,然后映射到不同渠道。这也是它跟普通 webhook 中转工具最大的区别。
1.3 技术栈选型与理由
项目最开始我用 Python 写,因为解析各种 json 格式、处理字符串逻辑,Python 确实快。但后来在并发和多线程场景下踩了一些坑,又赶上 Go 写后台服务成本变低,我最终把核心服务重写成了 Go。这里不是制造语言之争,而是说下我自己实际选型的理由。
- Go 的并发模型更匹配场景:事件路由本质是 IO 密集 + 并发转发。Go 的 goroutine 天然适合每个投递任务一个协程处理,代码写出来也比多线程的回调简单很多,不会轻易出现 Python 全局解释器锁影响吞吐的情况。
- 部署成本几乎是零:编译出来就是一个二进制文件,量级通常在十几 MB,复制到任何 Linux 机器上直接能跑。这一点对运维场景来说太重要了。
- 标准库和生态够用:服务端 HTTP、JSON 解析、协程调度在 Go 里都是“开箱即用”,不依赖大而全的框架。外部依赖我只用了 Redis 客户端和一个 YAML 解析库,其他全是标准库。
当然这不是说 Python 或 Node.js 做不了这种事,而是结合我自己的长期维护和部署场景,Go 更合适。如果你是个人项目或者公司环境统一,选你熟悉的技术栈也完全没问题,架构模式本身是通用的。
2. 核心模块与关键实现解析
2.1 事件标准化:这是整个系统最关键的一步
我见过很多类似项目,上来就先写投递逻辑,结果每一路对接都越写越复杂。为什么?因为每个消息源给的数据结构差异很大,Prometheus Alertmanager 的告警是一个 alert 数组,Zabbix 的 webhook 是一个嵌套很深的 JSON,自定义脚本可能只是一个字符串。如果你不先做“归一化”,后面的路由规则就无从谈起。
我做了一个标准事件模型,所有的接入源最终都会转换成这个结构:
{ "id": "evt_20250120153000_prom_xxx", "source": "prometheus", "uid": "alert_rule_name", "title": "CPU 使用率超过 90%", "message": "host=web-01 cpu_usage=94.5%", "severity": "critical", "status": "firing", "timestamp": "2025-01-20T15:30:00+08:00", "labels": { "service": "checkout", "env": "prod" }, "raw": {} }这里每个字段都是有意设计的:
id是全系统的事件唯一标识,用于去重和幂等。生成规则是源 + 时间戳 + 原始事件 key做哈希。severity是路由判断最重要的字段。我统一成debug / info / warning / error / critical五级,不直接保留原始监控系统的等级文本,因为不同系统的等级叫法太乱了。status用来区分firing(触发中)和resolved(恢复),告警恢复的时候可以走一套更轻的通知文案。labels保留原始事件的所有标签,路由规则里可以直接引用这个 map,扩展性更好。
为什么要费这么大劲做标准化?因为只有数据模型统一了,路由规则才能统一。否则你给 Prometheus 写一条规则,给 Zabbix 又要写一条,等于把问题往后挪,没解决。
2.2 路由规则:用 YAML 配置控制事件走向
标准化之后,真正的核心功能就是“什么时候、把什么消息、发给谁”。我用 YAML 写规则,采取“先匹配、先生效”的策略。每一条规则包含三块:匹配条件、动作、目标渠道。
下面是我项目里一个典型的配置文件片段:
routes: - name: "critical-notify-all" when: severity: "critical" actions: - type: "webhook" channel: "dingtalk-oncall" template: "critical-tpl" - name: "warning-daily-skip" when: severity: "warning" labels.env: "dev" actions: [] - name: "prom-resolved-notify" when: source: "prometheus" status: "resolved" actions: - type: "webhook" channel: "wecom-group" template: "resolved-tpl" - name: "default-log-only" when: {} actions: - type: "log"这里的when支持精确匹配,也支持 JSONPath 表达式。比如你可以写labels.service.startsWith("pay"),甚至组合多个条件。匹配的顺序很重要,我规定从上往下执行,命中第一条就停止,所以“宽泛规则”要放在后面,“精确规则”放在前面。
路由规则里我还加了**动作静默(rate limit)**功能。比如某条规则如果命中的事件一分钟内超过了 20 条,它就自动进入静默状态,只记录日志而不发送。这个设计在告警风暴里救了我很多次。
2.3 投递渠道适配层:统一接口,灵活扩展
投递渠道是另一个容易让人崩溃的地方。钉钉的 webhook 要拼特定 JSON、企业微信支持的 markdown 语法跟钉钉不完全一样、邮件要处理 SMTP 链接和模板。如果每个渠道都直接写在主逻辑里,代码会越来越乱。所以我把投递抽象成一个接口,每个渠道只实现两个方法:Send(ctx, event)和Validate()。
目前项目内置的渠道我写好了这些:
| 渠道 | 特点 | 主要场景 |
|---|---|---|
| 钉钉群机器人 | 支持 markdown、关键字校验 | 值班告警群 |
| 企业微信应用消息 | 可指定成员或部门,支持 markdown | 内部系统通知 |
| 飞书群机器人 | 富文本卡片支持好 | 跨职能协作 |
| 邮件 SMTP | 稳定、可视化好 | 日报汇总、面向管理侧通知 |
| Slack Webhook | 海外团队常用 | 国际化团队 |
| Webhook 通用模板 | 任意 POST JSON 回调 | 对接公司内部 OA 或工单系统 |
如何快速加一个渠道?只要看现有代码的Sender接口,实现一个Send方法,然后注册到工厂就行。这个抽象带来的价值在后期才越发明显——有同事提需求要对接内部的“值班管家”应用,我当天就实现了一个自定义 webhook 渠道,主流程一行没改。
2.4 可靠性设计:重复不可怕,丢失才可怕
通知系统最怕的不是重复,而是丢失。一旦告警没发出去,监控体系再完整也没有意义。在可靠性上我做了三件事。
第一,缓冲队列。事件进入 hermes-agent 后,会先写进一个 Redis List,然后由消费者异步拉取处理。这样即使某个渠道 Webhook 临时抖动,消息也能在队列里等着,不会直接丢在进程里。Redis 挂掉的情况下,系统会走降级策略:所有事件转为同步处理,同时本地记日志,确保“有据可查”。
第二,失败重试 + 退避。每个渠道投递失败,会按1s - 5s - 30s - 5m的退避策略重试三次,三次仍然失败,进入死信队列,等待人工干预。这个处理方式借鉴了消息队列里死信的设计思路,虽然叫“死信”,但并不是放弃,而是换到一条独立队列里,方便之后从管理后台看失败原因。
第三,幂等去重。因为外部 webhook 可能因为网络超时被重复投递,或者监控系统自己重试导致同一条告警发两次,我必须保证同一条事件不会在短时间内被重复推送。实现很简单:Redis 里存一份事件ID -> 投递结果的 key,设置 10 分钟过期,每次投递前检查。判断标准是事件 id 和状态都相同,就跳过重复发送。
3. 实操演示:从零部署一条完整的事件通知链路
3.1 环境准备与容器化部署
我推荐用 Docker 跑 hermes-agent,因为依赖干净、升级也方便。这里我先展示下目录结构和启动过程。
$ tree hermes-agent . ├── agent ├── docker-compose.yml ├── config │ └── agent.yaml ├── rules │ └── routes.yaml ├── templates │ ├── critical-tpl.tmpl │ └── resolved-tpl.tmpl └── storage └── (redis 数据目录,按需挂载)我习惯把配置、规则、模板三部分分开,这样不同职责的配置变更互不影响。下面是docker-compose.yml的简化版本:
version: "3" services: hermes-agent: image: hermes/agent:1.2.0 container_name: hermes-agent restart: always ports: - "8080:8080" volumes: - ./config:/app/config - ./rules:/app/rules - ./templates:/app/templates environment: - REDIS_ADDR=redis:6379 - CONFIG_PATH=/app/config/agent.yaml depends_on: - redis redis: image: redis:7-alpine restart: always volumes: - ./storage:/data启动只需要一行命令:
docker-compose up -d然后检查健康接口:
curl http://127.0.0.1:8080/healthz如果返回{"status":"ok"},说明服务起来了。这一步的核心思路是:配置和镜像分离,数据卷独立,升级镜像不会带丢配置和缓冲数据。
3.2 接入 Prometheus Alertmanager 告警的实操记录
Prometheus 的 Alertmanager 是我们最常用的告警源之一。它默认支持配置 webhook receiver,推送到给定 URL 的 JSON 结构是固定的。下面是我接入时的配置。
先在 Alertmanager 里加一个路由:
receivers: - name: hermes-webhook webhook_configs: - url: 'http://hermes-agent:8080/webhook/prometheus' send_resolved: true然后在 hermes-agent 的agent.yaml里声明接入源:
sources: prometheus: type: prometheus path: /webhook/prometheus enabled: true这里type决定了用哪个解析器。每来一条事件,hermes-agent 会把 Alertmanager 原始的 JSON 结构读进来,然后转换成前面说的标准事件模型。最关键的一个转换点在于:Alertmanager 的alerts[].labels会变成标准模型的labels;status字段如果是firing,标准模型的status就写成firing,如果是resolved,就转成resolved。
配置好后,可以在 Alertmanager 侧模拟触发一条测试告警:
curl -X POST http://127.0.0.1:9093/-/reload # 触发一个自定义告警后, 查看 hermes-agent 的日志日志会打印成这样的信息:
[INFO] event received: source=prometheus title="CPU 使用率超过 90%" severity=critical [INFO] route matched: critical-notify-all [INFO] delivery ok: channel=dingtalk-oncall cost=213ms能看到事件被接收、规则被命中、渠道投递成功,整条链路就通了。
3.3 命令行自测与链路验证方法
平时改动规则或者模板之后,我一般不会直接依赖真实监控去触发,而是用 curl 直接向 hermes-agent 的接收接口灌一条模拟事件,这样排查问题最快。
curl -X POST http://127.0.0.1:8080/webhook/custom \ -H "Content-Type: application/json" \ -d '{ "title": "(自测)支付服务延迟升高", "message": "service=pay p99_latency=1200ms", "severity": "critical", "labels": { "service": "pay", "env": "prod" } }'比如我这边的自定义 webhook 入口,会接收任意 JSON 并尝试映射到标准事件模型,然后交给规则引擎处理。这样验证critical级别是否正常推送给钉钉群,不需要真的等一次线上故障。
我还写了一个小脚本,用来批量灌测试事件、验证静默和去重逻辑。这个脚本虽然很简单,但每次调整规则后跑一遍,心里就踏实很多。
3.4 性能和并发表现的实测数据
最后说说性能。我拿测试机(4C8G)做了个简单压测,用 Python 脚本并发灌 webhook 事件,主要看两个指标:接收吞吐和投递成功率。
| 并发数 | 总事件数 | 平均处理耗时 | 投递成功率 |
|---|---|---|---|
| 50 | 1000 | 14ms/事件 | 99.9% |
| 200 | 5000 | 18ms/事件 | 99.9% |
| 500 | 10000 | 32ms/事件 | 99.8% |
瓶颈主要还是在对端 webhook 的接口响应速度,hermes-agent 本身处理开销很低。当然压测数据仅供参考,生产环境还得看你对接的 IM 群机器人的限流策略。但整体上,对于一个做通知路由的服务来说,这个表现是完全够用的。
4. 踩坑记录与问题排查实录
4.1 告警风暴导致的消息积压和渠道限流
上线后遇到最典型的问题是告警风暴。某次上游数据库抖动,一瞬间所有服务都报了连接错误,事件数量暴涨,钉钉机器人直接被限流,大量消息积压在 Redis 队列里。恢复后队列流量突然涌入,把钉钉群又塞爆了,大家全在刷消息。
解决思路我分了几层:
- 在“接收层”增加批量聚合窗口。同一规则、同一事件 key 在一分钟窗口内只保留第一条和最后一条,中间的全部丢到统计里,不参与投递。
- 在“投递层”增加速率限制。每个渠道可以配置
max_per_minute,超出部分直接丢弃但记录日志。 - 在“队列消费层”增加堆积告警。如果 Redis 队列长度超过阈值,就触发一条低优先级的通知给管理员,提示需要介入。
这几个措施叠加以后,告警风暴虽然还是比较吵,但至少在可控范围内,不会把渠道打到限流。
4.2 重复通知与去重策略的细节
重复通知的坑比想象中多。一次是 Prometheus Alertmanager 在重启后会重新发送一遍未恢复的告警;一次是某个自定义系统把自己的 webhook 接口写了双重调用。刚开始我做的是“完全事件 ID 去重”,结果发现规则不生效,因为同一条告警第二次到达时时间戳变了。
后来我调整去重策略:取事件的指纹(source + uid + status),而不是完整 id。这样同一规则的firing状态在 10 分钟内只通知一次,避免“时间戳变化导致去重失效”的问题。指纹去重虽然牺牲了一点“时间点上动作可追溯性”,但在通知场景下更实用。
4.3 时区、时间戳与告警静默引发的混乱
还有一个容易忽略的细节,就是时间戳时区。我们公司的告警系统有些用 UTC、有些用 UTC+8,如果不做统一,通知时间会差八个小时,特别是排班、交接班的时候很容易造成误解。我在标准事件模型里统一采用RFC3339格式并带+08:00偏移,在解析器里做时区转换,模板渲染时再转成本地时区。这个设计在后续写日报汇总时非常有用,因为没有因为时间字段不统一去折腾数据清洗。
告警静默这块,我也踩过一次坑。原以为静默规则是针对“某一条规则”的,结果客户那边给的反馈是“同一条主机的一个指标在五分钟内恢复又触发,重复告警很烦”。静默本来是用来防风暴的,但我觉得它更准确的功能应该是“相同段窗口内,相同告警不重复打扰”,所以我在静默逻辑里把labels.host和labels.alertname拼进 key,实现了基于对象维度的告警合并,效果好了很多。
4.4 热加载配置与平滑升级的正确姿势
hermes-agent 支持通过 HTTP 接口热加载配置,但刚开始我犯了一个错误:改完规则直接POST /reload,结果线上有大量正在处理的请求,导致部分事件丢失。原因是旧配置的消费者还没有优雅退出,新配置一应用,旧的投递流程就中断了。
所以后来我把重载流程改成两段式:
POST /reload/prepare:校验新配置,解析规则和模板,如果格式错误就拒绝重载。POST /reload/commit:确认无报错后开始应用,先停止旧的消费协程,等待队列内现有事件处理完毕,再启动新配置的消费者。
升级版本也一样,用 Docker 时我会先拉新镜像,再用docker-compose up -d --no-deps hermes-agent逐步替换,并配合健康检查确认服务正常后再继续。这套流程虽然多了一点步骤,但换来的是线上通知链路不断流。
5. 再分享两个实用的小技巧
第一个技巧是关于模板的。别把通知文案写死在代码里,用模板文件管理,而且每个渠道最好都配一个专属模板。钉钉的机器人比较适合短平快的关键字告警,企业微信适合带链接跳转的富文本,邮件适合有上下文的表格汇总。分开模板之后,每个渠道的阅读体验都会好很多。
第二个技巧是给所有事件增加一个内部的trace_id。这个 id 在整个系统里会贯穿标准事件、日志、投递记录,排查问题时只要拿这个 id 搜日志,就能完整还原一条告警从进来到出去的所有动作。我们的监控群里经常会出现“这条告警怎么没收到”的疑问,如果没有 trace_id,排查至少要多花十分钟。
6. 我的几点体会
做 hermes-agent 这个项目,最大的体会是:工具的价值不在代码量,而在它能不能成为团队工作流里稳定的支点。这个东西写起来不难,难的是想清楚边界——什么该做、什么不该做。我不断提醒自己不要把它做成一个“万能监控平台”,而是专心做好“事件路由”这一件事。
如果你也想搭一个类似的系统,我的建议是:先把数据模型设计好,再写路由规则,最后再处理渠道对接。顺序千万不要反了,否则后面只会越改越乱。项目维护到现在,它已经成了我们监控体系里默认的一环,新接入一个监控源、新加一个通知方式都很快,这种“一次性投入、长期省心”的感觉,是支撑我持续维护它的最大动力。