Hermes信使代理:从消息路由到多渠道通知分发的工程实践 1. 为什么我会动手写一个叫 Hermes 的信使代理如果你维护过两套以上的监控系统或者同时对接过内部 OA、企业微信、钉钉和邮件告警大概率遇到过这样的场景每接一个系统就要写一套新的通知逻辑。不是在这里拼 JSON就是去那边调 Webhook同一个报警信息要发到三四个渠道每个渠道的格式还完全不一样。我当时的状态就是天天在各个系统的 API 文档里翻来翻去消息没少发效率低得吓人。后来我做了一个决定把这类接收消息、按规则分发、再执行后续动作的事情统一收口到一个独立的代理程序里这就是 hermes-agent 的由来。Hermes 在神话里是传递信息的信使我这个项目的定位也类似它不负责业务逻辑本身只负责把一条消息从 A 点送到 B 点、C 点、D 点并且在途中完成格式转换、策略过滤、失败重试和状态回执。它本质上是一个轻量级的消息路由与任务执行代理站在各业务系统和通知渠道之间做那个传话的人。从适用范围来看hermes-agent 适合以下场景内部系统多、通知渠道杂需要一个统一出口来收敛消息发送逻辑。有定时任务、事件触发型任务需要按规则分发又不想为此引入一套重量级的消息中间件。想把不同系统的 webhook、回调、告警消息统一处理经过格式整理后再投递到企业微信、钉钉、飞书、邮件等渠道。需要一套可以热加载配置、出问题能快速定位的消息处理中间层。如果你正被各种系统各写一套通知代码这件事烦得不行这个项目会给你提供一个干净利落的解决思路。接下来我会从设计思路、核心模块、部署接入和实际踩坑几个维度完整拆解这个项目。2. 核心设计把一切抽象为消息与路由2.1 消息结构的设计考量hermes-agent 的第一个设计决策是统一消息结构。因为所有渠道的差异都集中在消息格式和传输协议上只要把消息格式抽象出来后续每接入一个新渠道就只是增加一个适配器的事而不需要改动上游业务代码。我定义了一个消息对象它的核心字段包括message_id全局唯一的消息 ID用于幂等处理和链路追踪。source消息来源比如monitor-alertorder-service。event_type事件类型比如errorwarninginfo。title消息标题简短明了。content消息正文支持纯文本和模板变量。meta附加信息以键值对形式存在用来携带追踪 ID、业务上下文等。timestamp消息产生时间。这里有个很关键的取舍得说清楚为什么不同意把 content 直接做成富文本或者 Markdown。因为不同渠道对内容的支持程度不一样企业微信支持 Markdown但某些老旧的短信网关只支持纯文本邮件又需要用 HTML。如果上游传递的是结构化数据 模板那么格式转换的职责就落在 hermes-agent 的适配器层上游永远只传数据下游永远只收渲染结果。这样消息在路途中可以随意转换不会被某个渠道的格式限制绑死。做得比较早的一个版本里我把 content 直接设计成了字符串结果每接一个新渠道就要写一遍把内容拼成对应格式的代码。后来重构成了结构化数据 模板渲染整个接入成本下降了一大截。这个教训我也写在项目的设计文档里消息内容与展示格式必须解耦否则代理会变成一坨只会拼字符串的意大利面条。2.2 插件化的执行器机制hermes-agent 里的执行器是一个很核心的概念它是一个可插拔的组件每个执行器负责把一条消息投递到对应的目标渠道或者触发一个具体的动作。举个例子HttpExecutor通过 HTTP POST 把消息推送到目标系统。WorkWechatExecutor渲染成企业微信的 Markdown 消息格式并发送。DingTalkExecutor渲染成钉钉的消息格式并发送。EmailExecutor把消息通过 SMTP 发送到指定收件人列表。CommandExecutor在配置的安全目录里执行一条外部命令。每个执行器实现一个统一的接口接口里只定义三个方法initialize、execute、destroy。这样插件之间是完全隔离的新增一个执行器不需要改动已有的调用链。这里面值得多说一句的是初始化逻辑。很多类似的代理项目会把每个插件的配置放在同一个文件里做成一个大 config 段一开始确实省事但插件多了以后问题就出来了一个插件配置写错会导致整个进程起不来。hermes-agent 的做法是每个插件的配置独立加载且初始化失败只会让该插件标记为不可用不影响其他插件的正常启动。这个决策后来救了我好几次有一次我把邮件插件的 SMTP 密码填错了服务并没有挂掉只是邮件渠道暂时下线其他渠道的告警转发完全不受影响。2.3 路由规则的表达方式消息到了 hermes-agent 之后怎么决定走哪条路这就要靠路由规则。我参照了消息组件里 route 的思路用一套简单的 JSON 规则来描述什么消息发到哪里去。以下是一条实际跑过的规则{ id: route_alert_to_feishu, source: monitor-alert, event_type: error, match: { level: critical }, executors: [ { name: feishu_notice, template: tpl_alert_critical } ] }这条规则的意思很直白凡是来自 monitor-alert、事件类型为 error 且 meta.level 为 critical 的消息就交给名为 feishu_notice 的执行器处理并使用 tpl_alert_critical 这个模板渲染内容。规则匹配的顺序是一件需要反复强调的事情。我目前采用的是先精确后模糊的排序策略优先级最高的是所有字段都精确匹配的规则其次是只匹配 source 和 event_type 的规则最后是仅匹配 source 的兜底规则。如果不做这种区分很容易出现一条消息同时被多条规则命中的情况消息被重复发送或者发错渠道的连锁问题就随之而来了。3. 关键模块拆解任务编排、重试与状态机3.1 任务的流水线编排消息一旦被规则命中后并不是简单丢给执行器就完事它实际上会进入一条流水线。流水线由若干阶段组成默认的阶段包括过滤阶段检查消息是否满足额外的业务条件比如维护窗口内不发送、重复消息自动去重。渲染阶段根据模板把结构化消息渲染成目标渠道需要的格式。发送阶段调用具体的执行器执行投递。回执阶段记录发送结果写入日志和状态存储。这个流水线的设计参考了 Unix 管道的思想每个阶段只做一件事阶段与阶段之间通过消息对象传递数据。好处是调试时能很清楚地定位到是消息没进来还是渲染失败还是执行器没发出去。我在日志里给每个阶段都加了一个耗时统计有时候看一条消息的完整日志就能知道瓶颈在哪不用靠猜。这里要特别说明一下模板渲染的实现选择。我用的不是自研的模板引擎而是直接嵌入了 Python 的标准库 string.Template 和 Jinja2 两种渲染后端。为什么同时保留两个因为 string.Template 足够轻适合渲染那些只有一两个变量的简单消息Jinja2 则适合构造复杂的 Markdown 或 HTML 内容支持条件判断和循环。大多数情况下我只用 Jinja2string.Template 纯粹是为了让那些没有第三方依赖的基础镜像也能跑起来。3.2 重试与幂等设计真实场景中没有哪个外部系统能保证 100% 可用Webhook 偶发超时、邮件服务商限流、网络抖动都是家常便饭。hermes-agent 的重试机制是我花了最多心思的部分因为它直接影响消息的最终送达率。项目里的重试采用指数退避策略基础间隔从 1 秒开始每次失败后翻倍上限是 5 分钟。最大重试次数默认是 5 次但可以根据执行器的配置单独覆盖。实现上我用的是一个内存中的延时队列失败的消息会进入待重试状态由后台调度协程在到期后重新投递。幂等是重试机制能成立的基石。每条消息都携带 message_id执行器发送前会先检查这条消息我是不是已经成功处理过了只要消息 ID 出现在已处理列表中就自动放弃本次投递。这个设计避免了一个很尴尬的情况网络超时后服务端其实已经收到了消息因为客户端重试导致对方收到两遍一模一样的告警。不过说实话分布式系统要做到严格幂等非常难因为已处理列表本身也可能丢失。项目里的处理策略是把已处理列表做成一个可配置的存储后端默认是 SQLite生产环境可以切换到 Redis。Redis 的 key 用 message_id 加执行器名称做组合有效期为 24 小时。这样既保证了重试期间的去重又不会让存储无限膨胀。3.3 状态机与可观测性每条消息在 hermes-agent 内部都会经历一系列状态pending、matched、rendering、rendered、delivering、delivered、failed、dead。这些状态组成了一个简单的状态机每次状态变更都会写一条事件日志。为什么要刻意引入状态机因为这直接关系到问题排查的效率。以前排查一条消息没发出去的原因要在日志文件里翻半天。现在只需要根据 message_id 查询消息的状态流转记录就能一眼看出它在哪个阶段卡住了。如果状态一直停留在 delivering那就是执行器这边出了问题如果状态是 failed那就是外部接口返回了错误码。可观测性方面我做了两个维度的埋点业务维度和基础设施维度。业务维度记录每个规则命中了多少条消息、每个执行器成功和失败的次数基础设施维度记录每条消息的处理耗时、重试次数和延迟队列的长度。这些指标通过 HTTP 接口暴露出来可以直接被 Prometheus 抓取。对于不想接入 Prometheus 的个人开发者项目还内置了一个简单的本地看板展示最近一小时的消息投递情况和失败率趋势。4. 部署接入与生产配置中的硬经验4.1 部署形态选择hermes-agent 既支持以独立进程方式部署也支持以 Docker 容器方式运行同时还封装了一个可以直接被 Python 应用 import 的嵌入模式。三种模式各有各的适用场景我在不同环境里都用过简单对比一下部署方式适用场景优点需要注意的问题独立进程中大型项目团队协作开发职责边界清晰方便统一运维监控消息链路多一跳要认真设计日志追踪Docker 容器容器化环境大规模集群部署环境一致性最佳资源隔离彻底日志必须走 stdout配置要挂载卷管理嵌入模式小工具、脚本项目快速接入无需单独维护服务启动成本为零代理的生命周期与主进程绑定不能独立升级如果你的项目是微服务架构我倾向于建议你选择独立进程模式让 hermes-agent 作为一个基础服务独立运行。因为微服务场景下它的角色就是企业内部的神经系统独立出来做监控、做权限控制、做灾备恢复都要方便得多。嵌入模式适合原型验证但真正上了生产我还是推荐进程隔离。4.2 配置管理的坑配置管理是我在实际使用中踩坑最多的地方也是我觉得有资格写出一堆经验的内容。首先是配置文件格式的选择。项目同时支持 YAML 和 JSON 两种格式但在实际维护中YAML 的使用体验要明显好于 JSON。JSON 的注释问题实在让人头疼规则多了以后没有注释的配置文件可读性直线下降。YAML 支持注释在写执行器说明、路由规则的适用边界时非常有用。其次是配置文件的热加载。hermes-agent 支持监听配置文件变化在文件变更后自动重载不需要重启进程。这个功能在使用时有个容易忽略的坑热加载的热是有条件的修改了执行器的配置比如换了 Webhook 地址可以即时生效但如果你改了消息结构或者新增了一个模板文件旧消息可能还在用旧模板渲染这时最好等一会儿确认流量平稳后再改动大规模配置。我自己的建议是生产环境配置变更遵循灰度原则。先把新配置放到测试实例上跑 10 分钟确认没有异常日志再推到生产。hermes-agent 的热加载机制虽然方便但它不会帮你判断新配置是否合理只会忠实地把错误配置加载进来然后把所有消息都发到错误的地方。别问我是怎么知道的。4.3 性能与并发控制你会不会担心加了这么一层代理消息链路变长了延迟会不会变大答案是会但通常感知不到。从性能测试数据来看一台 2C4G 的虚拟机跑 hermes-agent单条消息从接收到投递完成的平均耗时为 15ms 到 40ms这个延迟主要花在模板渲染和网络请求上代理本身的开销几乎可以忽略。在并发方面项目内部使用 asyncio 事件循环来调度所有任务单进程可以稳定处理每秒 500 条以上的消息投递这已经覆盖绝大多数中小团队的需求了。不过并发控制里隐藏着一个容易犯的错误很多消息代理一遇到并发就直接用线程池但 hermes-agent 用的是协程加异步驱动。为什么这么选因为这里的任务是 I/O 密集型的无论是 HTTP 请求还是 SMTP 发送大头时间都在等待网络响应协程在等待时会让出控制权能同时挂起的任务数量远超线程。用多线程也可以但线程多了以后内存占用和上下文切换成本会明显上升协程则没有这个问题。如果你在未来遇到了单机性能不够的情况hermes-agent 支持水平扩展。因为消息 ID 本身是全局唯一的多实例部署时只要保证每个实例配置相同前端通过负载均衡把消息分发到不同实例就能线性扩展处理能力。但要注意如果启用了 Redis 存储状态必须确保所有实例连接同一个 Redis否则去重和幂等就失效了。5. 生产环境里踩过的坑与最终解法5.1 网络抖动引发的重试风暴这个问题的场景是某天上午办公网络出现了一次短暂故障持续大概五分钟。结果我的告警消息代理在故障恢复后出现了一大波拥塞一分钟内把所有告警渠道全部打满导致其他正常消息都排不上队。排查后发现根因在于重试机制没有做全局并发限制。网络故障期间所有失败的消息都进入了重试队列网络恢复时大量重试任务同时被唤醒全部并行去连外部 Webhook 服务直接把对方接口打出了限流。修复方案是引入了一个信号量机制给每个执行器单独设置最大并发数。比如企业微信执行器的并发上限是 10邮箱执行器是 5。这样任何时刻发往同一渠道的请求数都是可控的重试任务会老老实实排队而不是一拥而上。这个设计最关键的地方在于它把并发控制从全局细化到了每执行器因为不同渠道的承受能力差别很大统一限流会浪费高并发渠道的性能也会保护不了低并发渠道。5.2 死信队列不做早晚会出事用了 hermes-agent 大概两个月后我开始收到用户反馈说有些告警一直没收到。查日志的时候发现一条消息重试了 5 次都是失败状态停留在 failed之后就没有任何后续动作了。消息其实没有丢但也没有人知道它失败了它就像一个无人认领的孤儿一直在数据库里躺着。后来我给项目加了死信队列机制。所有重试耗尽仍然失败的消息会进入一个独立的死信存储区同时在后台发送一条投递失败通知给管理员配置的兜底渠道比如一个专用的运维群。这些死信的消息详情会被保留下来包括原始消息、经过的规则、重试记录、失败原因方便后续人工补偿或者重新投递。我自己的经验是死信队列必须设置一个周期性的巡检提醒每周一早上把上周的死信统计发出来。这样不会出现死信队列里积压了两千条失败消息负责人还不知情的情况。有些失败是暂时性的比如对方服务升级维护可以手动把死信中的消息重新投递有些失败永远无法成功比如 Webhook 地址配置错误这类死信要及时清理掉避免占存储空间。5.3 日志与追踪从 message_id 到全链路开发阶段通常不太在意日志规范但到了生产环境没有全链路追踪的消息代理会让人抓狂。第一次排查一个跨系统问题时我在 hermes-agent 的日志里看到一条报错但不知道这条消息是哪个上游系统发的也不知道最终发没发到用户那里。这直接促使我在消息结构里加上了 trace_id 字段。上游系统调用 hermes-agent 时会在 HTTP Header 里带上 trace_id代理会把 trace_id 和内部生成的 message_id 绑定在一起贯穿消息的整个生命周期。所有日志都同时打印 trace_id 和 message_id这样无论是从上游系统看还是从代理这边看都能通过同一个 ID 串起完整的调用链。这里有个细节值得提醒日志里打印的字段不要只打 message_id一定要把 source 和 event_type 一起带上。因为只有 message_id 一长串编码看起来非常难受而加上 source 和 event_type 后一眼就能分辨出是哪个业务系统的哪类事件。我之前就是没带这两个字段排查问题时每条日志都要去反查消息详情效率极慢。5.4 时间窗口抖动定时任务依赖系统时钟的风险hermes-agent 除了处理外部消息外还内置了定时触发的能力可以按 cron 表达式生成定时消息。比如每天早上九点自动发送一份昨天的告警汇总。这个功能本身很常规但我在使用中踩了一个时钟同步的坑。当我把它部署在一台没有配置 NTP 同步的测试机上时发现定时任务触发的偏差越来越大一周之后晚了将近十分钟。查了半天最后才意识到系统时钟本身就偏了和 hermes-agent 一点关系都没有。这个问题不算复杂但在生产环境务必提前确认所有运行 hermes-agent 的机器必须开启时间同步否则定时任务的可靠性无从谈起。另外还有一点定时任务的触发记录也要做好持久化不能只存在内存里。否则进程一旦重启触发历史就没了容易造成定时消息重复发送。我后来把定时任务的触发状态存到了 Redis 里key 的格式类似于 scheduler:last_run:{task_id}每次触发前先读取上一次的触发时间发现距离现在小于任务间隔的一半就认为是重复触发直接跳过。6. 进阶用法从消息代理走向自动化中枢6.1 与外部系统的事件联动hermes-agent 的路由规则不仅支持消息到执行器这种一对多的投递也支持事件触发异步动作。这意味着它不只是被动地转发消息还可以主动编排一些自动化任务。举个例子我的服务器上配置了一条规则当 monitor-alert 系统发来一条 CPU 使用率超过 90% 的告警时除了把告警发送到运维群之外还自动触发一个诊断脚本收集当前机器的进程列表、负载情况和最近五条系统日志然后把诊断结果作为后续消息再发出来。这个能力在处理线上故障时特别实用不需要人肉登录服务器去查了代理已经在第一时间把现场信息准备好了。实现这个功能的核心在于 CommandExecutor 的安全性设计。项目默认配置了一个可执行命令白名单白名单之外的命令一律拒绝执行。命令的参数支持从消息 meta 字段中注入但必须经过严格的转义处理防止注入攻击。我见过一些人自己做类似东西的时候命令直接拼字符串看得心惊胆战。这类权限一定要收口不要对用户完全放开。6.2 多环境配置隔离与团队协作团队规模大了以后配置管理会变成一个相当棘手的问题。开发环境的 Webhook 地址和生产环境不一样模板内容也要区分开。hermes-agent 的解决方案是引入环境变量的覆盖机制配置文件里的值如果以 ${ENV_VAR} 形式书写那么在启动时会被环境变量覆盖。同一套配置仓库在开发环境跑的是测试 Webhook、测试接收人、Debug 级别日志在生产环境跑的是正式地址、正式接收人和 Info 级别日志。我推荐的做法是把基础配置提交到 Git 仓库环境相关的敏感配置通过部署平台的密钥管理服务注入这样既不影响代码流转也不会让密钥泄露到仓库历史中。6.3 存量系统的迁移路径如果你的团队已经有一套旧的告警通知逻辑要平滑迁移到 hermes-agent我的建议是采用透传模式过渡。具体做法是第一阶段旧的告警系统仍然正常工作但把发送逻辑同时复制一份到 hermes-agent两边并行跑对比两边消息的送达情况和内容格式。第二阶段确认 hermes-agent 的投递结果完全符合预期后逐步把各系统的通知逻辑切换到新的代理上。第三阶段等所有流量都走通后下线旧的逻辑。这套迁移路径不快但非常稳不会出现切换当天消息就丢了的大事故。我见过不少人一上来就想一把梭全部切换结果发现问题排查起来比想象中的复杂得多最后只能回滚。迁移这件事稳比快重要。7. 写在最后真实使用中的边界感我最初写 hermes-agent 时想的很简单就是把通知功能做成一个服务。但在不断使用的过程中我越来越意识到这个项目真正的价值不在于发消息本身而在于它为系统的可观测性和自动化运维提供了一个轻量级的中枢。消息转发只是一个入口真正的想象力在于把它变成联动各个业务系统的纽带。也谈一下它的边界和局限性。对于超大规模的消息流量比如每日千万级消息量或者是需要复杂消息持久化和回溯的场景我仍然建议选择专业的消息队列而不是让 hermes-agent 承担所有职责。任何工具都应该放在它最合适的位置。hermes-agent 定位清晰服务于中长尾的消息路由、通知分发和轻量级任务编排在这个范围内它是足够好用的。最后再分享一个小技巧我习惯在接入新的执行器插件时先用一个 test 规则只把消息发给自己确认格式和内容都符合预期后再把规则发布给团队使用。这个习惯让我避免了好几次把测试消息捅到全员群的尴尬场面。如果你准备在自己的项目里尝试类似的消息代理设计不妨从最简单的一个入口、一个出口、一条规则开始慢慢叠加上去。项目是死的真正让它有价值的是你结合实际场景不断打磨它的过程。