拆解一只机器鸭的"保命系统":updater 更新引擎、18.2k 行代码,讲给普通人听

给路由器刷固件刷到一半断电,设备变砖只能拆机救——这是每个玩过硬件的人的噩梦。现在想象一台卖到人家里、没有屏幕没有键盘、坏了连报错都看不见的双足机器人,它一生要经历几百次自动更新。Microduck 用全仓库最大的 crate(18.2k 行 Rust)回答一个问题:怎么让更新永远不变砖。 本文沿用系列的"生活比喻 + 真实代码 + 设计思想"三件套,把签名验证、原子交换、健康门与四级回滚一层层讲透。

系列文章(精读开源机器鸭 Microduck):


目录


开场:为什么"更新"配得上全仓库最大的 crate?

先讲一个你可能有过的经历:给路由器刷第三方固件,刷到一半断电——设备变砖,只能拆机焊线救。这只鸭子卖到人家里,没有屏幕、没有键盘、坏了连报错都看不见。而它一生中要经历几百次自动更新:每次更新都是一次"刷机"。

所以这个工程做了一个反直觉的决定:更新系统是全队第一个写完的组件,然后靠它递送其它一切——"在失败还免费的时候(没有客户、没有可损坏的东西),前置最大风险。"它也是最大的 crate(18.2k 行),因为里面装的是四层防变砖纵深。

先看"工位表"(14 个模块,比 duck-control 多,但多数很小):

工位(文件)职务一句话比喻
engine.rs(3828 行)总指挥十四道工序的施工状态机
store.rs仓库管理员版本目录 + 门牌(符号链)管理
verify.rs验钞机minisign 签名验证
manifest.rs装箱单版本清单与兼容性声明
journal.rs黑匣子兼会计更新日志、启动计数器、单飞锁
preflight.rs / hooks.rs开工检查 / 装修队前置检查、安装前后钩子
orphan.rs防遗孤降级时检测孤儿服务
transcript.rs庭审记录员每次更新的完整过程档案
faults.rs内鬼故障注入器(测试用)
ipc.rs / main.rs前台 / updaterd 本体对外服务
source/采购渠道GitHub / HF Hub / HTTP / 本地
robot.rs问询台向 robotd 问"你还好吗"的接口

工位 1:engine.rs——总指挥的十四道工序

整个 crate 的灵魂是文件头那七行状态机(engine.rs:3-8):

✅ Healthy / Degraded 健康通过

❌ Unhealthy / 超时 / 不兼容

❌校验失败

❌兼容不通过

❌下载失败

❌哈希错误

❌签名非法

❌前置钩子失败

❌后置钩子失败

🟢 preflight 开工前置检查

📦 获取装箱单manifest

🔐 签名校验

⚙️ 版本兼容性检查

💾 下载更新包

🔍 校验哈希值

🔐 更新包二次签名校验

📂 解包新版本到staging临时目录

🧩 执行前置钩子pre‑hook

⭐【原子交换rename】切换current符号链接门牌

🧩 执行后置钩子post‑hook

🔄 重启相关服务

🚪健康门:轮询查询robotd健康状态
500ms轮询一次

🧹提交更新,清理旧版本

🔁 触发回滚逻辑

跟着这七行,是三条铁律(注释原文,engine.rs:13-18):

  1. 交换点之后的任何失败都回滚。 钩子失败、健康失败、超时——全是同一种结局。不存在"基本装好"。
  2. 签名和哈希都通过之前,不向任何活路径解包一个字节。
  3. 启动计数器在交换之前布防——这样"交换后、健康检查前"之间崩溃也可恢复。反过来做,会留下一个未被记录的坏版本在运行。

💡 普通人版:像航天发射的倒计时检查单——每一步失败都通往同一个按钮(中止返回),而不是"差不多就算成功"。最危险的动作(交换)之前,降落伞(计数器)必须已经背好。

几个刻在常量里的时间预算(engine.rs:38-80),每个都有出处注释:

常量为什么
MAX_BOOT_ATTEMPTS2一个待定更新有 2 次开机自证机会,然后无条件回退
ROBOT_QUERY_TIMEOUT2 秒单次问询 robotd 的上限
HOOK_TIMEOUT120 秒后置钩子上限
PRE_INSTALL_HOOK_TIMEOUT10 分钟见下面专门一段
HEALTH_POLL_INTERVAL500 毫秒健康门轮询间隔

为什么前置钩子敢给 10 分钟? 注释是一段精彩的"位置决定预算"论证:前置钩子跑在交换之前——旧版本还活着还在服务,钩子慢只是"更新慢",不是"机器人危险"。同样的分钟数若花在后置钩子上,就卡在"交换和重启之间,板上两个版本都没在正常跑"的鬼门关里。同一件事,在不同位置就有不同的安全预算。

进度条要节流到每秒 4 条PROGRESS_MIN_GAP = 250ms)——注释记了一次真实事故:下载源每收到一块数据就报一次进度,一次更新几十万条;进度行约 100 字节,而蓝牙通道每次只能塞 20 字节——手机看到的进度条是 12% → 61% → 34% 这样乱跳的。节流到每秒 4 条、每条都是不同的整数百分比,进度条才平滑,20 字节的管道才扛得住。


工位 2:store.rs——仓库管理员与"换门牌"的艺术

核心思想:交换,而非修补

板上磁盘的布局:

/opt/robot/daemon/
├── releases/
│   ├── 0.9.0/     ← 完整的一个版本:二进制+策略+文档
│   ├── 0.10.0/    ← 另一个完整版本
│   └── 0.11.0/
├── current → releases/0.11.0   ← 门牌:指向"现在用哪个"
└── golden  → releases/0.9.0    ← 保底门牌:永远指向"出厂验证版"

💡 普通人版:像剧场换场。新布景整个搭好在新房间,切换只是换门牌;回滚就是把门牌指回旧房间。永远不要在正在演出的房间里边演边改布景。

换门牌的两个讲究(store.rs:165-203)

fn link_to(&self, name: &str, version: &Version) -> Result<(), Error> {
    // ① 先在同目录造一个临时门牌
    std::os::unix::fs::symlink(&target, &tmp)?;
    // ② rename 一步把临时门牌盖到正式名字上
    fs::rename(&tmp, &link)?;
    // ③ fsync 父目录
    crate::fsutil::fsync_parent(&link)
}

讲究一:原子性。 rename 在同一目录内是原子操作——并发的读者(比如正在启动的服务)要么看到旧门牌、要么看到新门牌,永远看不到"没有门牌"。如果先删再建,中间那微秒级的空窗就是灾难。

讲究二:耐久性。 原子 ≠ 断电安全。fsync_parent 注释解释了为什么多这一步:“对 current:启动计数器在交换前布防,如果门牌落盘了而计数器的待定记录没落盘,断电后就是一个坏版本在跑、却没有任何审判记录能回退它——恰恰是设计宣称不可能出现的状态。对 golden:没挺过断电的门牌,等于一次断电后救援程序无靶可指。”

小细节:临时门牌造之前先删一次残留——“上次崩溃留下的临时链接不许挡住这次交换”;门牌用相对路径——“挂载点挪了树还是有效的(而且在终端里读着也通顺)”。

保底房间永不清理

prune(清理旧版本)有一条铁律:golden 无条件豁免(store.rs:205-220)——“这样’永不变砖’的保证才不会随着版本累积而悄悄过期”。普通版本按"保留当前 + 最近 N 个"清理,golden 不占名额、永不被删。

还有一个聪明的小机关:解包中的半成品目录用 .staging- 前缀命名——这个名字永远解析不成合法版本号,所以就算上次解包到一半断电,残留目录也不会被当成一个"已安装的版本",启动时的 clean_staging 一律清掉。


工位 3:verify.rs——验钞机的三条家规

更新包来自网络,等于"收到一笔来路不明的现金"。验钞(minisign 签名验证)的三条家规:

家规一:能验钞的机器,不该会印钞。

选依赖时刻意用了只含公钥验证功能的 minisign-verify,而不是完整的 minisign 库——“这个进程没必要具备签名能力”。物理上消灭"updater 被攻破后自己签恶意包"这条路。

家规二:空钥匙环是致命错误,不是"什么都不信任"。

if keys.is_empty() {
    return Err(Error::Config(
        "no trusted keys in {} — refusing to run with an empty trust anchor"));
}

注释(verify.rs:80-83):“空钥匙环是错误,不是空允许表:静默地’什么都不信任’,和’路径配错了’长得一模一样——这里往哪个方向猜错都是灾难。” 配错路径 → 直接拒跑 → 问题当场暴露,而不是让一台"什么都不验证"的机器人看起来工作正常。

家规三:密钥材料永不落日志。

/// 手写 Debug,让密钥材料永远不会被意外打印——只打印它的 id。
impl std::fmt::Debug for TrustedKey { ... }

程序打日志时常常把整个结构体打印出来(Debug),这里手写实现绕过它——日志系统再怎么配置错误,也不可能把信任锚的密钥材料漏出去

此外验签顺序有讲究:先验装箱单签名 → 再验压缩包哈希 → 再验压缩包签名(流式,不落盘整个校验);解包时用 unpack_in 防路径穿越(压缩包里藏 ../../etc/passwd 这种)+ 档案限额(默认 2 GiB / 5 万个文件)防"zip 炸弹"——一个 1 MB 的压缩包解出 100 GB。注释对穿越的态度很硬:这种文件"等于用我们自己的钥匙签了一个敌意文件"——要大声报出来,而不是悄悄跳过


工位 4:journal.rs——黑匣子与三件随身工具

工具一:黑匣子(update-log.jsonl)

每次更新追加一行 JSON,每行写完立刻 fsync——断电也丢不了。为什么不用系统日志(journald)?因为板子的 /var/logzram(内存盘),断电即失——而"这台机器人装过什么、结果如何"恰恰是断电之后最需要回答的问题。旁边还有 runs/ 目录存完整庭审记录(每次更新保留 20 份、单份 2 MiB 上限:每个阶段边界、验过的清单、钩子输出、重启了哪些单元)——“最需要档案的那些更新,恰恰最容易以断电收场。

工具二:单飞锁——“OS 锁,不要 PID 文件”

防止两个更新同时进行。实现用了 Rust 1.89 刚稳定的 File::try_lock(内核级文件锁):

OS 锁而不是 PID 文件——内核保证持有者被杀后锁自动释放;一个僵死的 PID 文件会让机器人永远无法更新(它以为还有个进程在跑)。”

💡 普通人版:PID 文件像门口贴的"我在里面"纸条——人死了纸条还在,后人永远不敢进。内核锁像图书馆储物柜——人走了钥匙自动作废。

工具三:启动计数器——“预算和执行分家”

pending.json交换之前写好:记录"我启用了版本 X 的审判,允许它开机自证 2 次"。每次 updaterd 启动时核对:次数耗尽还没通过健康检查?回退。按组件分开计数——“模型选择的审判不能覆盖守护进程更新的审判”。

启动时的恢复顺序本身也是设计(recover_on_start,在任何 socket 开始服务之前):清残留 → 记录救援面包屑 → 刷新 golden 门牌 → 核对计数器。注释里那句原则值得抄录:“预算决定何时问,机器人决定是否回退”——计数器只管"到点提醒",回不回退要看 robotd 当下的健康实况。

known_bad:别把坏蛋请回来

回滚目标的选择有一条容易忽略的规则:跳过"最近结局是’已回滚’"的版本。否则出现鬼打墙:新版本坏了 → 回滚到上一版 → 上一版也坏 → 又滚回更上一版时,选择逻辑可能又相中刚才那个坏版本……记上 known_bad,坏版本就永久出局。


工位 5:健康门与回滚的四级阶梯

健康门:交换之后,谁说了算?

换完门牌、重启完服务,引擎开始每 500 毫秒问一次 robotd:“你还好吗?”(robot.health)。规则严格到不近人情:

  • Healthy / Degraded(板子问题)→ 通过
  • Unhealthy / Incompatible → 失败
  • 超时 → 也是失败:“未证明不是健康”

而且注释强调:Incompatible(答了话但版本不合)和 Unreachable(没答话)是两种不同的失败,不许混为一谈——“一个读不懂的答案,也仍然是一个答案”。

回滚的四级阶梯

新版本不健康
  → ① 回滚到上一个版本(跳过 known_bad)
       也失败?
  → ② 回滚到 golden(出厂保底,prune 永不删除)
       开机都起不来(连 updaterd 都没能跑)?
  → ③ 启动计数器耗尽 → boot 前回退(robot-boot-check,
       由 180 秒的 timer 触发,故意不装 [Install] 段防止更新中途误触发)
       连它也没跑成?
  → ④ /usr/local/sbin/robot-rescue —— 放在 release 目录之外的
       手动救援脚本,读 golden 门牌只需一行 readlink,不需要任何解析器

每一级对应一种更深的故障:软件坏 → 保底版也坏 → 新版本让系统起不来 → 全靠壳里留的最后一手。层层往下,总有一层能接住。

结局枚举也很有讲究:Applied / AlreadyCurrent / DryRunPassed / RolledBack / Stuck(没东西可退)/ RollbackFailed(最严重,独立错误码让支持一眼看到)


工位 6:updaterd 永不自杀

一个精妙的执行细节(restart-order.md 里有专页):更新过程中,updaterd 绝不重启自己——它是执行手术的医生,不能术中自杀。也不停留旧版:答案发布 5 秒后,由一个 systemd 临时单元替它重启(用临时单元正是为了这个单元能活得比 updaterd 自己的 cgroup 长)。

接班者上岗前先跑 --self-test 自检。而且"安排了重启"≠"重启发生了"——下一次 updaterd 启动会核对每个单元是否已在新版本上,把没换干净的补齐。两个集合(绝不在术中重启的 / 答案后重启的)由一个测试钉死必须互为镜像——btd 也在"绝不术中重启"名单里,因为更新指令可能正是从蓝牙传来的


工位 7:三个"绝了"的守卫细节

① 降级攻击防线(WouldDowngrade)。 只允许从"最新版"发起更新——防止攻击者重放旧安装包(旧版本可能带着已修复的漏洞)把机器人滚回去。

② 孤儿单元检测(orphan.rs)。 回滚到一个**早于"某个守护进程诞生"**的版本,会发生什么?板上还留着那个守护进程的 systemd 单元文件,但 release 目录里已经没有对应的二进制——单元每次启动都失败,报 203/EXEC。这个故障"确实就是以这个方式被观察到的"(注释原话),于是有了专门的检测和拒绝。

③ 内鬼机制(faults.rs)。 怎么测试"更新到一半断电""回滚时再失败"这些稀有路径?给引擎装一个故障注入开关abort_after_swapfail_rollback……),CI 里真实驱动完整引擎走到故障点。哲学:“回滚是 CI 的断言,不是一种希望”——只在出事后才运行的代码最容易悄悄坏掉,所以要天天故意搞坏它。

配套的还有 ipc.rs 的一个反向设计:周期性检查跑在 updaterd 内部,而不是靠 cron 定时调 robotctl apply——因为后者绕过 known_bad 防线,“就是一个刷砖死循环”。


设计思想总结(人话版)

  1. 交换而非修补——新版本整个搬进新房间,切换只是一次原子的"换门牌";回滚就是换回来。没有"半安装"状态。
  2. 最危险的动作之前,降落伞先背好——签名哈希全过才解包、计数器先于交换布防、fsync 补齐断电耐久性。
  3. 失败只有一种出口——交换后任何失败都通往回滚,回滚有四级阶梯,保底版本永不清删。
  4. 执行者不自戕——updaterd 术中不重启自己,接班者先自检,且有人核查"重启真的发生了"。
  5. 黑匣子活在断电之外——日志每行 fsync、不进内存盘;“最需要档案的更新恰恰最容易以断电收场”。
  6. 能力最小化是安全——验钞机不会印钞、Debug 不打密钥、空信任锚直接拒跑。
  7. 回滚是测试出来的,不是许愿出来的——故障注入让每条稀有路径都天天被人踩一遍。

结语

这个 crate 最值得带走的不是任何一行代码,而是一个顺序决定:先写更新系统,再写其它一切,然后让更新系统天天递送整个团队的作品——在失败还免费的时候,把最致命的风险演练上千遍。

到了自己的项目里,这对应一个朴素的问题:“我的 OTA 出过一次半安装吗?我的回滚路径,上次真机演练是什么时候?”——如果答案让你不安,这只鸭子的四级阶梯值得抄。

下一篇拆外围五服务configd(配网与身份)、btd(蓝牙前门)、padd(手柄)、mediad(摄像头与 WebRTC)、tofd(深度传感器)——一台机器人身上"不拥有机器人"的那一半。觉得有收获欢迎点赞收藏,评论区聊聊你见过的变砖事故。


参考链接

  • Microduck 仓库:github.com/pollen-robotics/microduck
  • 更新系统设计文档:仓库内 docs/design/updater-design.md
  • 重启顺序专页:仓库内 docs/design/restart-order.md
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐