凌晨一点三十八分,我盯着脚本输出的最后一行日志发呆。那行日志告诉我“全部主机已下发完成”,但我知道这不是真的——那批 320 台机器里,有 174 台根本没有执行任何命令。它们沉默地待在机房里,像集体断线了一样,而没有任何一个监控系统站出来吼一声。这次事故之后我花了大概两周时间,把一套名为 Agent-Reach 的自研观测与触达系统从零搭了起来。这篇文章就是那次事故、那套系统,以及上线三个月以来所有费尽心思踩坑的完整复盘。
如果你也在维护一批分散部署的自动化 Agent,或者正在做发布系统、定时任务调度、配置分发这类依赖远程执行的基建,Agent-Reach 的完整思路可以直接抄走:从心跳判活、任务回执,到状态机设计、告警降噪,再到我踩过的几个足以让全网任务误判的深坑,一次讲清楚。
1. 一次静默失联事故,逼出了 Agent-Reach
1.1 事故复盘:日志说“已完成”,机器说“没收到”
事情发生在一次常规的配置批量更新。我们的发布系统通过自研 Agent 向 320 台 nginx 节点下发一份新的 upstream 配置,触发 reload。整个链路很简单:调度平台下发命令到 Agent,Agent 执行本地脚本,执行完把 stdout、exit_code 回传。从平台界面看,所有任务都返回了 success,日志里清一色的“全部主机已完成”。
第二天早上,线上开始出现零星 5xx 错误,排查发现 174 台机器的 upstream 还是旧配置。也就是说:Agent 告诉平台“我执行成功了”,但实际上什么都没发生。更讽刺的是,这批 Agent 当晚根本没收到任何任务——下发请求在某个环节被丢弃,而 Agent 端用了什么策略来兜底呢?没有策略。它只是在下一轮心跳时把“上次任务没收到”这个事实吞掉了。
这是我第一次意识到一个问题:我们把大量精力花在了“任务怎么发下去”上,却几乎没有花精力回答“任务到底有没有被执行”“Agent 当前是不是真的还能被触达”。前者是调度问题,后者是可达性问题。如果可达性没有保障,调度做再多也是对着空气发牌。
1.2 现有监控体系的盲区:能看主机,看不到 Agent
出事后我第一反应是去翻监控系统。我们有基于主机的存活监控、有进程监控、有端口监控,理论上足够判断“一台机器是否健康”。但真正对上去的时候才发现,这些监控回答的是完全不同的一个问题:
- 主机监控回答的是:操作系统是否在线,CPU、内存、磁盘是否异常。
- 端口监控回答的是:某个端口是否在监听。
- Agent 监控回答的是:Agent 进程是否存活,是否能正常接收任务,上次回执是什么时候。
这三个答案之间,隔着一条巨大的鸿沟。Agent 进程活着,不代表它和调度端之间的链路是通的;端口在监听,不代表应用层消息能正常收发。更常见的情况是:Agent 进程被 OOM Killer 干掉、所在机器网络策略变更、进程进入死锁状态无法处理新任务——这些情况里,主机监控全部正常,端口可能也正常,但它就是“够不着”。
那 174 台机器事后排查,原因非常分散:有 60 多台是 Agent 进程被系统 OOM 杀死但 systemd 没有自动拉起;有 40 多台是所在地机房的防火墙策略变更,导致 Agent 无法连回调度端的心跳通道;剩下的则是 Agent 内部一个依赖库崩溃导致线程池全部阻塞。没有一台是主机本身宕机的。这就是为什么传统监控体系解决不了 Agent 可达性问题——它压根没站在 Agent 的视角看世界。
1.3 Agent-Reach 的设计目标与术语定义
这次事故之后,我把目标定得很明确:我们要建立一套围绕 Agent 本身的“感知-触达-控制”体系。我给它起了个名字叫 Agent-Reach,取的是“Reach”的双关:既表示 Agent 的可达范围,也表示调度端能否真正伸手触达每一个 Agent。
三个设计目标,从优先级往下排:
第一,感知:任何时候都能回答“这个 Agent 现在处于什么状态”,不依赖人工登录机器确认。 第二,触达:任务下发要有完整回执链路,不出现“日志说成功、机器说没收到”的情况。 第三,控制:当感知到不可达节点时,变更系统能够主动把它们剔除出批次,而不是强行硬发然后自己骗自己。
这套系统上线三个月,目前管理着 800 多个 Agent 节点,覆盖配置分发、定时采集、变更执行三类场景。后续我会把完整技术方案拆开讲,包括心跳节奏怎么定、状态机怎么设计、回执链路怎么防抖,以及那几次差点把系统搞垮的事故。
2. 感知层:心跳不能只回答“活着”,还得回答“活多久”
2.1 心跳上报的节奏设计:30 秒心跳,90 秒判失联
Agent-Reach 的感知层核心是一个极简的心跳协议。Agent 每隔固定时间向服务端上报一次状态,服务端记录last_seen时间戳,超过阈值没上报就标记失联。这个机制本身不新鲜,新鲜的是节奏怎么定。
我们最初拍脑袋定了 10 秒一跳、30 秒判死,结果上线第一天就被打脸了。800 个节点,10 秒一跳意味着每秒大概 80 个请求,峰值翻几倍到 300 左右,服务端本身扛得住,但代价是每个 Agent 多开了一条常驻连接,部分低配机器的 Agent 进程占用的句柄数和内存明显上升,甚至影响了主业务的执行性能。
后来我把节奏调成了 30 秒心跳、90 秒判失联。计算很简单:800 节点 × 每天 2880 次心跳 ≈ 每天 230 万次上报,平均每秒约 27 次,峰值也能压在 100 以内。这个压力对于任何一台后端服务器都是毛毛雨,同时 90 秒的判定窗口也足够在发布前做快速健康检查。
| 配置项 | 初始值 | 调整后 | 调整原因 |
|---|---|---|---|
| 心跳间隔 | 10 秒 | 30 秒 | 降低 Agent 资源占用和网络开销 |
| 失联判定阈值 | 30 秒 | 90 秒 | 避免瞬时网络抖动导致误判 |
| 上报数据量 | 全量状态 | 增量+全量每5分钟 | 减少序列化开销 |
提示:心跳间隔不是一个可以随便拍的数字。如果你有 N 个 Agent,每秒心跳请求数大约是 N ÷ 心跳间隔秒数。别为了一时的“实时性”把基础设施压垮,30 到 60 秒的判定粒度对绝大多数运维场景已经足够。
2.2 三态判活:正常、可疑、失联的判定逻辑
最初我们的状态只有在线和离线二值,但很快发现二值状态会把人逼疯。Agent 偶发一次心跳超时,服务端立刻标记离线,告警追着人跑;等下一秒心跳恢复了,状态又跳回在线。一晚上告警几百条,全是狼来了。
后来我把判活逻辑改成了三态:正常、可疑、失联。判定规则如下:
- 正常:最近一次心跳在 90 秒内,且上一次任务回执成功。
- 可疑:心跳超时但对端 TCP 连接还活着,或者心跳正常但最近一次任务执行失败。可疑状态代表“没确认死,但需要留意”。
- 失联:连续两个心跳窗口(90 秒 × 2 = 180 秒)没有任何上报,且服务端主动探测端口也失败。
这个设计的关键不是多了一个状态,而是把“不确定”显式地建模了。真实世界里,分布式系统里几乎不存在“100% 确信某个节点死了”这种判断,所有结论都带有概率。可疑就是那个灰色的中间地带,它允许告警系统做延迟处理,也允许变更系统做二次确认。
判活的另一层细节是主动探测。心跳是被动等 Agent 上门,但 Agent 可能因为网络故障连不过来,这时候服务端如果干等,就只能等到 180 秒后才能发现。所以我在服务端加了一个主动探测任务:对状态为可疑的节点,每 30 秒尝试一次反向探测(探测 Agent 暴露的 HTTP 健康端口),如果探测成功就暂时不降级为失联,这也避免了不少由于 Agent 代码崩溃但端口还活着导致的误杀。
2.3 last_seen 的时间戳陷阱:时钟漂移是如何骗过判活的
这个坑我很想重点说,因为它特别隐蔽。我们的 Agent 上报时会带一个本机时间戳,服务端最开始直接用这个时间戳作为last_seen。某个机房的机器系统时间配置有问题,比真实时间快了 4 分 59 秒。结果就是:Agent 每次上报,服务端记录的last_seen都在“未来”。
这个问题会导致什么?Agent 状态在服务端显示为永远在线,即使它已经死了十几分钟,因为它的last_seen已经“预支”到了未来。更离谱的是,失联判定逻辑依赖now - last_seen > 90s,而如果last_seen在未来,这个差值永远不大于 90 秒。一台物理上已经宕机的机器,在 Agent-Reach 里显示状态健康。
排查过程也是一波三折。最初发现某个机房所有节点永远在线,我觉得不对,手动去机房看了两台机器,系统时间确实快了接近 5 分钟。修好 NTP 之后,我做的第一件事就是改协议:服务端永远只信任自己的接收时间,Agent 上报的timestamp只用于计算网络往返延迟和本地执行耗时,不参与任何在线状态判定。last_seen一律以服务端收到请求的时刻为准。
这个修改上线之后,再也没有出现过“未来时间”导致的误判。数据库里agent_status表的核心字段也变成了这样:
CREATE TABLE agent_status ( agent_id VARCHAR(64) PRIMARY KEY, last_seen_at TIMESTAMP NOT NULL, -- 服务端接收时间 reported_at TIMESTAMP, -- Agent 上报时间,仅用于延迟计算 status VARCHAR(16) NOT NULL, -- normal / suspicious / lost last_task_id VARCHAR(64), -- 最近一次任务 version VARCHAR(32), -- Agent 版本 updated_at TIMESTAMP NOT NULL );3. 触达层:任务下发与回执要形成闭环
3.1 execution_id 与执行状态机:给每一次任务下发建立档案
感知层解决的是“Agent 是否活着”,但活着不代表任务能执行成功——那种“日志说成功、机器说没收到”的事故,要依靠触达层的回执闭环来根治。
我们的设计方案是:每次任务下发,调度端生成一个全局唯一的execution_id,并把它和任务内容一起下发。Agent 收到任务后,每一步动作都要回执:收到(ack)、开始执行(running)、执行成功(success)、执行失败(failed)、超时无回执(unknown)。每次状态变更都记录完整的时间线,最终写入一张task_execution表:
CREATE TABLE task_execution ( execution_id VARCHAR(64) PRIMARY KEY, task_id VARCHAR(64) NOT NULL, agent_id VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL, -- queued/dispatching/running/success/failed/unknown retry_count INT DEFAULT 0, payload_digest VARCHAR(64), created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL );这里有一个非常重要的设计决策:unknown 状态。在传统设计里,任务结果不是成功就是失败。但分布式系统里存在第三种情况——服务端发出去的任务,Agent 可能收到了但回执丢失,也可能压根没收到。这时候如果标记为 failed,调度端可能对这个任务进行重试,而 Agent 实际上已经执行过了,再执行一次就可能导致重复操作。如果标记为 success,又可能掩盖真正的问题。
我们的做法是:超过重试窗口还没有回执的任务,先标记为 unknown,等待 Agent 的“任务幂等查询”来主动确认。Agent 每次心跳时会带上本机已执行过的最近 N 个execution_id,服务端据此对账。对账确认已执行的,把状态修正为 success;确认没有执行过的,标记为 failed 并允许重发。这个机制从根上消灭了“日志说成功、机器没执行”的情况,因为它允许系统承认“我不知道”。
3.2 双通道下发与幂等控制:为什么不能只靠 HTTP
任务下发通道我们做了双路冗余:HTTP 长连接为主,MQ 持久化队列兜底。之所以没有只保留 HTTP,是因为单通道存在一个天然盲区——当 Agent 进程崩溃重启或者网络断连恢复的时候,HTTP 连接恰好处于半开状态,任务发不进去,Agent 自己感知不到。
双通道的动作逻辑是这样的:
- 正常情况:调度端通过 Agent 的 HTTP 接口直接下发任务。
- HTTP 失败或超时:调度端把任务投递到 MQ 的 agent 专属队列,Agent 在下一轮心跳时主动消费。
- 双通道都失败:任务进入待重试队列,按指数退避重试,最多 3 次,每次间隔 10 秒、30 秒、90 秒。三次都失败就把 agent 标记为不可达,返回给调用方明确的错误信息“该任务未送达”,而不是假装成功。
幂等控制是双通道设计里最容易被忽略的一环。双通道意味着同一个任务可能被 Agent 收到两次(HTTP 到了一次、MQ 又消费了一次)。我们在 Agent 端内置了一个执行去重表:以execution_id为唯一键,在真正执行任务前先查这个表,如果已经执行过就直接返回上一次的结果,做了典型的“幂等消费”。
一张去重表就能解决大部分问题,但有一个 edge case 需要注意:Agent 执行到一半崩溃,去重表还没有写入完成,重启后任务会被再次执行。这种极端场景下,我们要求所有任务本身具备幂等性或者在执行前先做一次预检查,比如配置更新任务会先比对配置文件 hash,相同就直接跳过。分布式系统最后一道防线永远是业务本身的幂等设计,平台只能降低重复概率,不能消灭所有概率。
3.3 回执处理:别让回调接口成为新的单点
Agent 执行完任务后,需要把结果回传给调度端。第一版设计里,Agent 直接调用调度端的回执接口(callback API),同步等待 200。这个设计在测试环境一切正常,到了线上 200 节点并发回执的时候直接翻车:回调接口所在服务的 Tomcat 线程池被打满,大量回执请求排队超时,Agent 端拿不到 200 就开始重试,重试又加剧了线程池的拥塞,最后形成回执风暴。
事后复盘,根本原因只有一个:把回执处理做成了同步链路,而回执这件事本质上是最适合异步化的——它不需要给 Agent 返回任何实时计算结果。修复方案也直接粗暴:回调接口改为只做两件事,一是简单校验 Agent 身份和 execution_id 格式,二是把回执内容直接写入队列后立刻返回 202。真正的状态更新和落库逻辑全部放到消费端异步处理。
队列消费端还加了分桶逻辑:按execution_id的哈希值分成 16 个桶并发消费,避免单线程消费造成吞吐瓶颈。实测下来,处理 1000 个并发回执从原来的 40 秒缩短到 3 秒以内,而且服务端的响应时间曲线从毛刺状变得平直。这个改动让我意识到:很多所谓的“高并发问题”,本质都是同步等待问题。
4. 消费层:把 Reach 数据变成变更系统的决策输入
4.1 发布前自动剔除不可达节点
Agent-Reach 感知层的产出是一堆状态数据,但如果这些数据只停留在仪表盘上,那它的价值就打了一半折扣。真正的价值在于:把这些状态数据变成其他系统的决策输入。Agent-Reach 上线后的第一个集成对象,就是我们的变更发布平台。
联动逻辑很简单:发布平台在生成变更批次时,会先调用 Agent-Reach 的接口拉取“当前可达 Agent 列表”(最近 90 秒有正常心跳、状态非可疑非失联的节点)。不可达的节点会被自动剔除出本次变更批次,并单独生成一个“待处理”列表交给值班人员。整个处理不再像事故当晚那样,对 174 台沉默的机器疯狂重试然后再假装成功。
有一个设计细节我觉得很值得参考:自动剔除不等于不发布,而是把“不可达”变成显式的例外。变更平台会生成一份差异报告——哪些机器因为 Agent 失联被跳过、这些机器占了多大比例。如果跳过比例超过 10%,平台会直接中止整个变更任务并报警,因为 10% 以上的失联率大概率不是单点问题,而是网络或者版本的大面积故障,这时候继续对剩余 90% 发布是危险的。
4.2 告警分级:别让每个 Agent 失联都打扰人
Agent-Reach 的告警策略经历了两个阶段。第一阶段是“坚守必报”:只要有一个 Agent 状态变成失联,就发一条告警。结果上线当晚告警拉满,值班电话一晚上响了 30 多次。大部分告警其实是 Agent 升级、机房网络割接导致的短时失联,几十分钟后自己恢复。
第二阶段我改为分级告警:
- P2 告警:失联 Agent 数量超过总数量的 1%,持续超过 5 分钟。这代表大面积故障,需要立刻处理。
- P3 告警:单个 Agent 失联超过 30 分钟。这代表可能存在单点问题,上班时间处理即可。
- P4 通知:单 Agent 失联少于 30 分钟,仅记录事件,不通知。
这里的关键是“占比”的概念。单点失联大多不影响整体服务,大面积失联才是真正需要立刻响应的事件。把告警阈值从绝对值改为占比之后,告警量下降了 80% 以上,并且每一通告警都值得接。
4.3 在线率趋势与容量规划:从 Reach 数据里挖出的意外信息
Reach 数据的第二个消费方向是趋势分析。我们每周跑一张报表,统计每个 Agent 的在线率,公式定义为:有效在线时长 ÷ 应在线时长。上线三周后,这张报表暴露了一个之前完全没注意到的问题:有 12 台机器的在线率只有 82%,看起来“大部分时间在线”,但每一周都有三到四次的短时失联记录。
排查发现这 12 台机器都跑着一个重度 cron 任务,每小时会抽走 80% 的 CPU,直接把 Agent 进程的主线程饿死,导致心跳上报超时被标记失联。cron 任务跑完,Agent 恢复心跳,状态回到正常。如果没有在线率趋势分析,只会看到零星的失联告警,根本意识不到这是同一个规律性原因导致的。后来给这些机器的 Agent 进程调整了优先级和 OOM 保护参数,在线率回到了 99.5% 以上。
这让我意识到,Agent-Reach 不只是故障发生时用来救火的工具,它的长期数据本身就是基础设施质量的晴雨表。一台机器的在线率持续偏低,即使每次失联都短暂恢复,也说明它身上有某种慢性问题——可能是资源竞争、可能是网络不稳定、可能是 Agent 本身的bug,值得去深挖。
4.4 一个意外的价值:资产拓扑动态化
还有一个小彩蛋值得一说。Agent-Reach 的 Agent 注册信息里包含了主机名、IP、机房、所属业务模块等标签,而这些信息会和每次心跳一起上报。三个月之后,这个数据库自己就长成了一本动态资产台账。
以前我们有一个号称很准的 CMDB,实际上里面 20% 的 IP 已经过期,扩容缩容全靠人工维护。Agent-Reach 给出了一个更准确的答案:凡是 30 天内没有任何心跳的 Agent 记录,标记为“僵尸节点”,运维可以一键确认下线。拿着一本静态台账做变更,和拿着一张动态活点地图做变更,体感是完全不同的。Agent-Reach 这件事做得不露声色,但确实是最值钱的副产品。
5. 上线三个月,我踩过的四个坑
5.1 心跳风暴把数据库连接池打满
上线第一周,800 个 Agent 全部接入后,某个下午数据库的连接池突然被打满,应用日志里全是连接获取超时。一开始我怀疑是慢 SQL 太多,结果看监控发现连接数从早上的 40 个一路涨到下午的 200 多个,没有回落。
排查链路是这样的:第一步看慢查询日志,发现大量SELECT * FROM agent_status WHERE agent_id = ?出现慢查询,单条超过 3 秒。第二步看表结构,发现agent_status表只有主键,没有对last_seen_at建索引,导致每次状态更新时的条件查询全表扫描。第三步看连接池配置,HikariCP 的 maximumPoolSize 是 20,远小于实际需要的连接数,大量请求排队等待连接。
修复方案分两层:一是连接池适当调大,二是最关键的一步——把心跳更新从逐条 UPDATE 改为批量 UPSERT。Agent 心跳语义上就是“上报即覆盖”,完全没必要逐条执行。我们用 JDBC 的 batch 机制,每 500 条心跳合并成一个批量提交,数据库写入次数直接降到原来的 1/50,连接占用问题当场消失。这个问题的根因不是数据库不够强,而是写入模式设计得过于生猛——对心跳这种高并发写场景,批处理是基本素养。
5.2 回调接口超时引发全网 unknown 误判
有次上线新功能后,调度端回调接口出现间歇性 5 秒超时。Agent 端重试逻辑是等待 2 秒后重试,重试 3 次都失败就放弃本次回执,下轮心跳再补。结果就是:所有任务在超时重试期间都没有写回执,服务端判定逻辑看到有任务超过 5 分钟没回执,把状态批量标成了 unknown,看起来就像全网任务集体异常。
处理过程有两个教训。第一,回执接口的调用方不应该是脆弱的同步请求,所以我们在 Agent 端已经把回执改成了“先写本地回执缓存,再异步批量上报”的模式,避免单次接口抖动影响任务状态。第二,服务端不能仅凭“回执超时”就立刻翻状态,应该等一个完整的对账周期。后来我们把 unknown 状态的确认窗口拉长到 24 小时,24 小时内通过心跳对账完成状态修正,超过 24 小时才允许最终标记为 failed。
还有一个小改动:unknown 不参与任何告警计算,因为 unknown 本身就是“证据不足”的状态,不能拿来当故障依据。这个改动之后,全网误判的场景再也没有发生过。
5.3 延迟重发的时间轮丢包
调度端的待重试队列最初用的是一个简单的内存优先队列,里面塞了所有需要延迟重发的任务。但问题在于:内存队列没有持久化,服务端一重启,队列里的重试任务全部丢失。这种丢包特别危险,因为它会让一批任务永远无法被送达,而且没有任何日志记录。
换成 Redis 的 ZSET 做延迟队列后,任务被写入持久化存储,即使服务端崩溃重启,也能把所有未完成任务捞回来重新投递。这里有个细节值得提:Agent-Reach 的任何状态变化最终都要能追溯到数据库里的某一条记录,任何放内存里可能会丢的东西都是不可接受的。
5.4 批量自升级把 150 台 Agent 一次性带走
最后一个坑是最痛的。Agent-Reach 本身也要靠 Agent 来运行,所以 Agent 升级也是个远程变更。有一次我们发布新版本 Agent,升级脚本里有个依赖冲突没测出来,新版本启动时直接抛异常退出。当时升级策略是分两批灰度,但因为是深夜上线,我偷懒把第二批次全部放开了。结果第一批 150 台升级完成后进程秒退,全部失联。那些机器上的 Agent 能力随之瘫痪,变更平台无法触达其中任何一台。
这个事故提醒了我两件事。第一,Agent 自升级必须做启动自检:新版本进程启动后,先完成自检,然后主动向服务端上报 ready 信号,服务端 120 秒内没收到 ready 就自动触发回滚,切回旧版本。第二,灰度批次之间的观察窗口不能省,至少完整跑一个业务周期的任务,确认无异常再放大范围。后来 Agent-Reach 的升级流程里嵌入了一个自检中间态:新版本进程起来后不立即接管任务,先以“旁路模式”运行 10 分钟,确认心脏没问题再切换。这套流程已经跑了 5 次升级,再没出过事故。
6. 最后聊两句实在的
写到最后我不想做什么总结,就说一点实际的体会。Agent-Reach 这套系统,技术难度其实不大,心跳、回执、状态机,都是分布式系统里的老概念。它真正难的地方在于对“不确定性”的管理:承认自己不知道 Agent 到底有没有执行成功,承认网络会断、时间会漂移、进程会死,然后围绕这些不确定性设计状态、设计重试、设计告警。
这个过程里我最深的感受是:一套系统让所有状态都“可解释”,比让所有状态都“好看”重要得多。宁可让一个任务显示为 unknown 让人类去看一眼,也不要让它显示为 success 然后自欺欺人。如果你也在搭类似的可达性观测体系,我建议从一个小切面开始:先把 Agent 的心跳和三态判活做扎实,再逐步接入任务回执、联动变更平台,不要一开始就想做一个大而全的控制台。系统小的时候好改,等 800 个节点都接上之后再回头改协议,就不是一个周末能搞定的事了。