Microduck更新引擎
拆解一只机器鸭的"保命系统":updater 更新引擎、18.2k 行代码,讲给普通人听
给路由器刷固件刷到一半断电,设备变砖只能拆机救——这是每个玩过硬件的人的噩梦。现在想象一台卖到人家里、没有屏幕没有键盘、坏了连报错都看不见的双足机器人,它一生要经历几百次自动更新。Microduck 用全仓库最大的 crate(18.2k 行 Rust)回答一个问题:怎么让更新永远不变砖。 本文沿用系列的"生活比喻 + 真实代码 + 设计思想"三件套,把签名验证、原子交换、健康门与四级回滚一层层讲透。
系列文章(精读开源机器鸭 Microduck):
- ① 《深度解析 Microduck:8 万行 Rust 写成的开源双足机器鸭,堪称嵌入式软件架构的教科书》
- ② 《嵌入式工程师的 Rust 速成:14 个语法点,读懂 8 万行开源机器人项目》
- ③ 《拆解一只机器鸭的"小脑":duck-control 八个模块、3200 行代码,讲给普通人听》
- ④ 本文:更新引擎
updater(科普精读)
目录
- 开场:为什么"更新"配得上全仓库最大的 crate
- 工位 1:engine.rs——总指挥的十四道工序
- 工位 2:store.rs——仓库管理员与"换门牌"的艺术
- 工位 3:verify.rs——验钞机的三条家规
- 工位 4:journal.rs——黑匣子与三件随身工具
- 工位 5:健康门与回滚的四级阶梯
- 工位 6:updaterd 永不自杀
- 工位 7:三个"绝了"的守卫细节
- 设计思想总结(人话版)
开场:为什么"更新"配得上全仓库最大的 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):
跟着这七行,是三条铁律(注释原文,engine.rs:13-18):
- 交换点之后的任何失败都回滚。 钩子失败、健康失败、超时——全是同一种结局。不存在"基本装好"。
- 签名和哈希都通过之前,不向任何活路径解包一个字节。
- 启动计数器在交换之前布防——这样"交换后、健康检查前"之间崩溃也可恢复。反过来做,会留下一个未被记录的坏版本在运行。
💡 普通人版:像航天发射的倒计时检查单——每一步失败都通往同一个按钮(中止返回),而不是"差不多就算成功"。最危险的动作(交换)之前,降落伞(计数器)必须已经背好。
几个刻在常量里的时间预算(engine.rs:38-80),每个都有出处注释:
| 常量 | 值 | 为什么 |
|---|---|---|
MAX_BOOT_ATTEMPTS | 2 | 一个待定更新有 2 次开机自证机会,然后无条件回退 |
ROBOT_QUERY_TIMEOUT | 2 秒 | 单次问询 robotd 的上限 |
HOOK_TIMEOUT | 120 秒 | 后置钩子上限 |
PRE_INSTALL_HOOK_TIMEOUT | 10 分钟 | 见下面专门一段 |
HEALTH_POLL_INTERVAL | 500 毫秒 | 健康门轮询间隔 |
为什么前置钩子敢给 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/log 是 zram(内存盘),断电即失——而"这台机器人装过什么、结果如何"恰恰是断电之后最需要回答的问题。旁边还有 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_swap、fail_rollback……),CI 里真实驱动完整引擎走到故障点。哲学:“回滚是 CI 的断言,不是一种希望”——只在出事后才运行的代码最容易悄悄坏掉,所以要天天故意搞坏它。
配套的还有 ipc.rs 的一个反向设计:周期性检查跑在 updaterd 内部,而不是靠 cron 定时调 robotctl apply——因为后者绕过 known_bad 防线,“就是一个刷砖死循环”。
设计思想总结(人话版)
- 交换而非修补——新版本整个搬进新房间,切换只是一次原子的"换门牌";回滚就是换回来。没有"半安装"状态。
- 最危险的动作之前,降落伞先背好——签名哈希全过才解包、计数器先于交换布防、fsync 补齐断电耐久性。
- 失败只有一种出口——交换后任何失败都通往回滚,回滚有四级阶梯,保底版本永不清删。
- 执行者不自戕——updaterd 术中不重启自己,接班者先自检,且有人核查"重启真的发生了"。
- 黑匣子活在断电之外——日志每行 fsync、不进内存盘;“最需要档案的更新恰恰最容易以断电收场”。
- 能力最小化是安全——验钞机不会印钞、Debug 不打密钥、空信任锚直接拒跑。
- 回滚是测试出来的,不是许愿出来的——故障注入让每条稀有路径都天天被人踩一遍。
结语
这个 crate 最值得带走的不是任何一行代码,而是一个顺序决定:先写更新系统,再写其它一切,然后让更新系统天天递送整个团队的作品——在失败还免费的时候,把最致命的风险演练上千遍。
到了自己的项目里,这对应一个朴素的问题:“我的 OTA 出过一次半安装吗?我的回滚路径,上次真机演练是什么时候?”——如果答案让你不安,这只鸭子的四级阶梯值得抄。
下一篇拆外围五服务:configd(配网与身份)、btd(蓝牙前门)、padd(手柄)、mediad(摄像头与 WebRTC)、tofd(深度传感器)——一台机器人身上"不拥有机器人"的那一半。觉得有收获欢迎点赞收藏,评论区聊聊你见过的变砖事故。
参考链接
- Microduck 仓库:
github.com/pollen-robotics/microduck - 更新系统设计文档:仓库内
docs/design/updater-design.md - 重启顺序专页:仓库内
docs/design/restart-order.md
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)