尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用Rust自研轻量级任务Agent:解决大规模远程执行与调度痛点

发布时间:2026/9/9 12:57:11

资讯中心
01
ARTICLE

用Rust自研轻量级任务Agent:解决大规模远程执行与调度痛点

用Rust自研轻量级任务Agent:解决大规模远程执行与调度痛点
我是在一次大规模巡检任务中被彻底点醒的。当时手上有近两千台云主机和物理机分布在三个机房上面跑着各种业务脚本、定时任务和健康检查而我最开始用的办法很原始写一个 Shell 脚本用 SSH 循环到每台机器上执行再把结果汇总回本地。听起来没毛病但等到机器数量上来后问题全暴露了——SSH 握手超时、批量执行时密码和密钥混乱、脚本中断后没人重试、结果丢失了也不知道是哪台机器丢的。于是我花了三个晚上用 Rust 写了一个叫Hermes-Agent的轻量级任务执行代理专门解决“把任务安全地送到远端机器上执行、再把结果可靠地拿回来”这件事。这篇文章就把这个项目的全部设计思路、核心实现和我在部署过程中踩过的坑完整拆开讲给你听适合正在自建任务调度系统、或者觉得现有自动化工具太重了一个不落的同学。1. 为什么我要再造 Agent 这个轮子1.1 现成方案要么太重要么太封闭说实话我并不是没考虑过用现成的开源项目。Ansible、SaltStack、Puppet 这些我都试过甚至有一套完整的 SaltStack 部署经验。Ansible 是典型的推模式——控制节点通过 SSH 连到每一台目标机器把任务推送过去执行然后等待结果返回。这个模式在机器少的时候非常舒服因为不需要在被管理机器上装任何常驻程序靠 SSH 就能跑通一切。但一旦规模上千SSH 握手的开销、控制节点本身的并发压力、以及弱网环境下连接经常秒断的问题就会变成硬伤。SaltStack 有常驻 Agent功能确实强大但它的问题在于“重”。安装完一套 Salt 环境你会有 Master、Minion、Syndic、Roster 等一大堆概念配置文件也够你折腾一下午。如果你只想做“把脚本下发到机器上执行完把结果拿回来”这个最基本的动作这些东西都是负担。更让我头疼的是扩展成本。我要的不是偶尔手动执行一条命令而是要对接我们自己内部的工单系统和告警平台工单系统下发一个“清理日志”的请求Agent 执行完后把结果回传给工单系统告警平台可能触发一条“采集进程列表”的指令需要 Agent 在几秒内完成采集。这些现成自动化工具的 API 扩展方式各不相同学一遍的成本极高。我需要的是一个能被我们系统当“零件”嵌入的 Agent而不是反过来把我们的流程迁进它的体系。1.2 动手前定下的五条硬约束每次有人问我“你怎么想到写一个 Agent”的时候我的回答都是先别急着写先把问题边界划清楚。我在设计 Hermes-Agent 之前给自己立了五条硬性约束后面所有技术选型都是围绕这几条来的。内存占用要小目标机器里有相当一部分是低配的 1C1G 云主机Agent 常驻不能吃掉太多资源我定的线是常驻内存不超过 30MB。部署必须是单个二进制不想在被管理机器上装 Python 环境、配 Java 虚拟机或者拉一堆动态链接库。一个静态编译好的二进制扔到/usr/local/bin就能跑是我能接受的部署方式上限。断网不能丢数据Agent 在执行任务期间如果网络抖动甚至机房断网任务结果必须先存在本地等恢复后再上传绝不能因为网络瞬时故障就丢一条结果。协议要简单可调试我不需要太炫酷的传输协议最好能用curl直接模拟调试这样出问题的时候定位非常快。执行的任务必须能限制资源脚本里出现死循环、内存泄漏或者疯狂输出日志的情况Agent 必须有手段把它杀掉而不是让一台机器被脚本拖死。这五条约束看起来简单但实际做起来几乎每一条都在挑战具体的技术选型。比如为了满足“单二进制 低内存”我最终选了 Rust因为它静态编译的特性让部署变得非常干净内存和 CPU 的占用也足够克制为了保证“断网不丢数据”我引入了本地持久化队列而不是简单的内存缓存。这些细节我会在后面的章节里逐个展开。2. 落地前先想清楚的架构和消息模型2.1 四层模块划分推荐、调度、执行、回传Hermes-Agent 的整体结构我把它分成四层接入层、调度层、执行层、回传层。接入层负责和服务端建立连接维护心跳把自己的状态比如当前是否有任务在跑实时上报。调度层是 Agent 的核心决策模块它从接入层拿到任务通知后决定是否接收、怎么排队、超时怎么处理。执行层是最危险的区域——它要把外部传过来的命令或脚本真正运行起来同时做资源限制和输出截断。回传层则负责把执行结果安全地送回服务端并处理各种失败重传。这四个模块我最终做成了单一进程内的四个线程组而不是拆成四个独立进程。原因是独立进程之间的通信本身就要走 IPC无论是 Unix Domain Socket 还是 TCP 回环都会增加故障点和调试难度而单一进程内部用线程池和互斥锁数据流简单得多部署的时候也只需管一个进程systemd 的 unit 文件都省了三个。但单一进程也有单一进程的代价如果某个执行中的脚本阻塞了 IO会不会把心跳也堵住我的做法是给心跳单独开一个线程与任务执行线程池完全隔离。心跳线程只关心心跳包的收发不碰任何任务相关的东西。这就保证了即使在某个任务卡死、甚至 CPU 跑满的情况下Agent 的身份在服务端看来依然“活着”运维人员至少能看到问题出在哪台机器上。2.2 消息格式与交互时序从注册到确认的完整流程Agent 与 Server 的交互我一开始就用 JSON 作为消息格式而不是上手就上 protobuf。原因很实际JSON 的调试成本最低任何语言都能解析而且初期我可以用curl直接给它喂数据手动模拟服务端快速验证 Agent 的行为是否符合预期。等消息量真正大到需要节约带宽和 CPU 的时候再在传输层加一层 protobuf 封装也不迟内部消息模型不变。一条完整的任务消息大致长这样{ task_id: 7f3a0c9e-0d57-4d6e-9e6a-1dbe2a5f1a44, type: shell, payload: { cmd: df -h, timeout: 30, env: {}, workdir: /tmp }, priority: 5, created_at: 2025-03-18T14:22:31Z }服务端下发任务不是直接推送给 Agent而是走“长轮询”模型。Agent 每 10 秒发送一次心跳心跳包里带上自己的状态而服务端如果在心跳响应中发现有等待分发的任务就把它带到响应里。这个方式比起 WebSocket 实时推送好处是穿透性好——不需要维护长连接偶尔断线也不影响整体逻辑。更准确地说我让 Agent 每 10 秒发送心跳其中包含一台机器当前是否空闲、上一条任务是否已经回传成功等关键信息。服务端收到心跳后如果库里存在符合该机器标签的 pending 任务就会在心跳响应里返回一个新的pending_task字段。Agent 收到后进入执行流程任务完成后通过回传接口上传结果。整个交互时序是这样的Agent 启动向服务端注册拿到自己的 Agent ID 和签名密钥。Agent 每 10 秒发一次心跳服务端返回时间戳、版本信息、可能的等待任务。如果拿到任务Agent 更新自己状态为 busy并开始执行。执行结束后Agent 把结果以 POST 方式发给服务端的/api/task/result。服务端返回确认 IDAgent 收到确认后才删除本地任务记录。如果服务端没有返回确认Agent 会在下一个心跳周期重新尝试回传最多重试三次。这个模型的核心是“确认后删除”跟 TCP 的 ACK 机制就很像。服务端没确认就当作客户端没收到这条结果必须留着直到真正被确认。2.3 任务状态机的设计为什么必须有超时和取消任务状态机看似简单但我在设计时是一步步把边界条件补全的。一个任务从服务端下发到最终结果会经历这几个状态pending等待执行、running执行中、success成功、failed失败、timeout超时、canceled被取消。pending到running的转换发生在 Agent 实际创建进程那一刻而不是 Agent 收到任务时——这里要防止 Agent 刚收到一个任务但还没开始跑、结果进程却被系统杀死这条任务明明没执行却被标记成“已接收”的情况。所以我把状态更新的时机放在fork/exec之后。timeout是我特别强调的一个状态。很多 Agent 设计里任务只有成功和失败两个终态但实际生产中你没法区分“脚本运行后返回码是 2”和“脚本跑了两个小时还在死循环”。加入了超时时间之后Agent 用定时器控制一旦超时直接向进程组发送终止信号并记录结果为timeout附带当时的 stdout 尾部内容方便人工排查。canceled这个状态也很关键。运维过程中经常有任务下错了需要立刻取消或者某个任务执行太久为了避免拖垮其他任务而手动中止。如果 Agent 不支持取消就只能等它自然超时这在紧急故障处理时特别让人抓狂。我在心跳协议里加入了cancel_task_id字段服务端可以随时通过心跳响应告诉 Agent“你正在跑的某个任务别跑了。”Agent 收到后会主动终止那个任务的进程组同时把状态置为canceled。3. 核心模块的关键实现本地队列、执行沙箱与心跳并发3.1 为什么任务队列必须落盘而且选了 SQLite你可能觉得Agent 的任务队列在内存里放一个VecDeque不就行了吗当时我也这么想过直到我第一次模拟 Agent 崩溃才发现问题。测试场景是这样的Agent 正在执行一个耗时很长的脚本结果网络断开Agent 进程直接被kill -9杀掉重启之后内存里的任务队列清空了服务端还在苦苦等结果而这条任务的执行结果已经永远丢失。这个问题迫使我引入本地持久化。我用的是 SQLite而不是 LevelDB 或者简单的 JSON 文件。原因有两条第一SQLite 支持事务我可以做到“写入任务记录、执行、更新结果”这些步骤要么全部成功要么全部回滚不会出现执行完了但状态没更新的尴尬情况第二SQLite 的文件格式是明文的出了问题我可以直接打开数据库文件查询任务记录排查效率比二进制存储高得多。任务记录的表结构大概是这样的CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT pending, payload TEXT NOT NULL, result TEXT, retry_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, finished_at INTEGER );执行时的顺序很关键先从 SQLite 里把任务状态置为running再真正去创建子进程。任务执行完成后先把结果写入 SQLite置为success或failed然后才尝试回传服务端。这样哪怕回传失败Agent 重启时也能从数据库里捞到所有已完成但未确认的任务继续补传。3.2 命令执行沙箱看起来简单细节特别多执行层是 Agent 里最容易出事故的地方。外部来的命令要在本机跑起来如果不做任何限制一个写错的脚本就能让机器卡死。我把执行隔离做成了三层防护。第一层是超时控制。每个任务在创建时都会带上timeout参数Agent 用定时器盯着执行线程到点就终止进程组。这里的重点是“进程组”而不是“进程”。很多脚本会再拉起子进程如果你只杀父进程子进程会变成孤儿继续跑。所以我在启动命令时用了setsid把目标进程放进独立会话里杀的时候kill(-pid)整个进程组。第二层是资源限制。在 Linux 环境下我用 cgroup 给每个任务的进程组单独建一个控制组在里面限制 CPU 使用时间和内存上限。配合 systemd 的MemoryLimit和CPUQuota能达到同样的效果但直接操作 cgroup 更灵活。比如我可以给某个任务只分配 0.5 个 CPU 核心和 512MB 内存超出就自动 OOM 杀掉不影响同机其他服务。第三层是输出截断和环境清理。脚本疯狂输出日志也是一种常见的故障。我设定单任务的 stdout 和 stderr 各自最多保留 1MB超过部分直接截断并在结果里标记output_truncated: true。环境变量方面我会主动剥离LD_PRELOAD、LD_LIBRARY_PATH这类可能被用来劫持进程行为的变量防止执行环境中被人塞进恶意库。3.3 心跳、任务拉取与结果回传三条链路的并发协调在设计过程中最容易被忽视的其实是心跳、任务拉取和结果回传这三条链路的时间配合问题。如果全都在同一个线程里串行处理一旦回传一个大的结果包心跳就会被卡住十几秒服务端很容易把 Agent 判定为离线。我的方案是把三者拆开。心跳线程每 10 秒一个独立的异步请求只负责上报状态和拿到可能的任务通知超时 5 秒。任务拉取复用心跳的响应通道不额外发起请求靠服务端在心跳响应里携带任务数据。这里我用的是长轮询的变体——如果服务端当前没有任务会 hold 住这个请求最长 30 秒再返回空结果减少空轮询的频率。结果回传线程独立于心跳线程每次回传用单独的连接。如果回传失败退避重试间隔按照 1 秒、2 秒、4 秒、8 秒指数增长最大退避到 60 秒。另外还有一个并发安全点任务执行期间心跳包里要上报busy: true这样服务端就不会在同一时间给这个 Agent 分配其他的任务。但有一种情况要注意——如果服务端已经下发了新任务而 Agent 还在跑旧任务它一定不要把新任务漏掉。我的做法是 Agent 本地维护一个“接收但未执行队列”心跳收到的任务先入队再由调度线程从队列里取出来执行。这样即使 Agent 在跑长任务新的任务通知也不会因为busy状态而丢失。4. 真实部署里踩过的坑三个典型故障的完整排查链路4.1 发布重启时正在执行的脚本突然被杀掉第一次联调是很快乐的功能全通了但真正丢脸的时刻也很快就来了。我兴冲冲地把 Agent 部署到一台测试机上提交了一个执行 10 分钟的长任务然后为了更新 Agent 版本执行了systemctl restart hermes-agent。结果 10 分钟之后回来一看任务状态是failed而且 stdout 几乎是空的脚本像被凭空腰斩了一样。排查的过程很有意思。我看了一眼日志没有超时没有资源限制触发没有取消指令子进程的退出信号是 SIGTERM — 系数 15。什么信号会在我们重启 Agent 的时候发给脚本我第一反应是 systemd 默认会把同一个 cgroup 里的所有进程都杀掉。确认了一下 systemd 的KillMode默认确实是control-group也就是说systemd 认为脚本也是这个服务的一部分所以重启服务时把整个 cgroup 一次性清理干净了。解决办法也很干脆Agent 在启动脚本之前主动setsid()把自己放进独立的进程组systemd 那边则保持默认的 KillMode 不变但对我们启动的脚本来说它已经不在服务主进程的 cgroup 追踪路径的同一个会话里了。验证办法是执行长任务、重启服务、再观察脚本是否继续运行其退出码应该不再是 SIGTERM。这个坑提醒我凡是 Agent 要启动外部进程的场景进程组隔离都是安全底线不然一次普通升级就会把正在跑的任务全部带崩。4.2 任务回传包太大导致服务端解析失败第二个故障是在一次全量采集任务上爆出来的。我写了一个巡检脚本让它去采集每台机器上所有监听端口、对外开放的进程路径、当前连接数等信息。脚本在单台机器上跑得好好的结果回传也能收到。但当 200 台机器同时回传时服务端日志里开始出现大量JSON parse error几十台机器的结果全部失败。我抓了一条失败回传的原始数据curl -d result.json手动打给服务端发现是小版本的 HTTP 服务端设置的问题默认的client_max_body_size只有 1MB而我们的巡检结果到了 2MB 多直接被拦在入口层。这是一个非常“蠢”的坑但它真实存在。更根本的解决思路是让回传数据量本身降下来。最终我做了三件事。第一回传之前先用 gzip 压缩一遍绝大多数文本型巡检数据压缩率能到 80% 以上2MB 变 200 多 KB。第二凡是单条结果超过 512KB 的超大包走分片上传Agent 把结果拆成 512KB 一片服务端收齐后再拼接。第三在 Agent 侧增加本地预估——如果结果膨胀到超过 10MB说明这个任务输出异常直接截断并打上警告标记避免把下游存储冲垮。这个修改上线之后回传失败率一下降到接近零。教训是任何 Agent 做结果回传时都要在客户端就考虑好压缩、分片和上限而不是把问题留给下游。4.3 机房断网两小时恢复后的“心跳风暴”第三个坑也是最刺激的一个。某次运营商光缆被挖断整个机房断网了快两个小时。断网期间 Agent 自然连不上服务端但本地队列在正常累积任务结果。网络恢复的那一瞬间几百台 Agent 几乎同时发起了心跳、补传和重连服务端的数据库连接瞬间被打满甚至导致了主库的写锁等待整条链路又瘫了快十分钟。这个问题本质是客户端重连没有退避策略。几百个客户端同时重连哪怕每个请求只需要几毫秒后面排队等待的请求也能把数据库连接池拖垮。我修复方式是给 Agent 的心跳间隔加了“指数退避 随机抖动”。所谓抖动就是在退避计算后的基础上加一个随机的小偏移量。比如第一波恢复后不是所有 Agent 都在第 1 秒立刻重连而是随机分布在 0 秒到 30 秒之间之后再逐步恢复正常间隔。同时我在服务端加了一双层保护连接池上限设死拒绝后客户端会走退避重试不会直接排队心跳入口做限流每台 Agent 每分钟最多 6 次心跳多余的直接返回429客户端收到 429 后自动拉长下次心跳间隔。这套组合拳之后我再做过一次断网演练恢复后服务端的负载曲线平滑了很多再也没有尖峰。我真心建议所有做 Agent 的人重连逻辑一定要从第一天就考虑退避和抖动不要等到被真实事故教育。5. 这套 Agent 的适用范围、性能表现和后续扩展方向5.1 实测数据规模、延迟和资源占用跑了大半年之后Hermes-Agent 在我的生产环境里管理着 600 多台机器。最近一次压测中我用它下发了一个hostname date的快速命令从任务在服务端创建到收到 Agent 回传结果平均耗时 2.8 秒其中大头是等待 10 秒心跳周期里“顺带捞取任务”的时间真正执行时间不到 100 毫秒。如果你需要更低延迟的任务下发可以把心跳间隔缩短到 3 秒但代价是心跳请求量和服务端压力会翻三倍。绝大多数运维场景 10 秒延迟完全可以接受没必要为了那几秒增加全链路压力。资源占用方面Agent 进程常驻内存稳定在 12MB 左右执行任务时峰值也就涨到 20MB 上下比我预设的 30MB 低了近一半。这要归功于 Rust 的零开销抽象和没有额外运行时这个特性。600 台机器的连接并发服务端用一台 2C4G 的机器就能撑住CPU 平均使用率不到 15%。5.2 什么样的场景适合自研 Agent什么场景不适合被问得最多的问题就是“我该不该自己也写一个 Agent”。我个人觉得有几个判断标准可以帮助你决定。适合的场景有几个特征对内网的机器有批量操作需求目标的部署位置跨网络或者有防火墙隔离SSH 推模式不方便任务类型偏向脚本执行、巡检、定时任务对 Agent 本身有定制化需求比如要对接内部工单系统。在这些情况下自研一个轻量 Agent 的投入产出比非常可观。但如果你的需求主要是大规模实时文件分发、低延迟交互式运维或者需要秒级任务反馈Agent 这种“心跳间隙拉取”的模式就不是最优解建议看消息 push 或者专用的文件分发系统。另外如果你刚接手一个几十台机器的小环境老老实实写几个 Ansible Playbook 就够了自研 Agent 的维护成本对小规模来说确实没必要。5.3 我计划做的三个扩展方向我现在对 Hermes-Agent 已经比较满意了但后面还有三个方向在计划中。第一个是插件化。当前 Agent 只能执行 Shell 脚本和二进制下一步我打算支持加载 WebAssembly 模块作为任务处理器这样可以在不升级 Agent 本身的情况下扩展执行能力。第二个是 Server 端的 DAG 编排能力。目前任务之间是相互独立的后续我希望支持在服务端定义任务依赖关系比如先拉代码、再构建、最后发布Agent 只需要负责执行编排全放在服务端。第三个是交互式会话。现在 Agent 是纯异步模型执行过程中不能交互我想通过长轮询通道实现一个半双工的交互式会话让运维人员可以从 Web 端实时查看任务的 stdout以及在允许的情况下发送 stdin 指令。经过这半年多的磨练我最大的体会是Agent 类项目真正难的地方从来不在功能怎么实现而在于你对各种边界情况有没有敬畏。断网了怎么办进程被杀怎么办网络抖动时会不会打爆服务端结果包大了要不要拆这些才是 Agent 能否上线跑一年的关键。我后来专门做了一套故障注入测试脚本每隔一段时间就自动kill -9这个 Agent或者把网络断掉几分钟看看它能不能自动恢复。到目前为止Hermes-Agent 在真实的机房故障里已经扛过了三次没有丢过一条任务结果这对一个自己写的工具来说已经是最好的肯定了。如果你打算自己动手做类似的东西建议你先把故障注入测试跑起来再考虑功能加得多不多这个过程会帮你发现至少十个你没有预料到的问题。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。