Microduck更新引擎

Microduck更新引擎 拆解一只机器鸭的保命系统updater 更新引擎、18.2k 行代码讲给普通人听给路由器刷固件刷到一半断电设备变砖只能拆机救——这是每个玩过硬件的人的噩梦。现在想象一台卖到人家里、没有屏幕没有键盘、坏了连报错都看不见的双足机器人它一生要经历几百次自动更新。Microduck 用全仓库最大的 crate18.2k 行 Rust回答一个问题怎么让更新永远不变砖。本文沿用系列的生活比喻 真实代码 设计思想三件套把签名验证、原子交换、健康门与四级回滚一层层讲透。系列文章精读开源机器鸭 Microduck① 《深度解析 Microduck8 万行 Rust 写成的开源双足机器鸭堪称嵌入式软件架构的教科书》② 《嵌入式工程师的 Rust 速成14 个语法点读懂 8 万行开源机器人项目》③ 《拆解一只机器鸭的小脑duck-control 八个模块、3200 行代码讲给普通人听》④ 本文更新引擎updater科普精读目录开场为什么更新配得上全仓库最大的 crate工位 1engine.rs——总指挥的十四道工序工位 2store.rs——仓库管理员与换门牌的艺术工位 3verify.rs——验钞机的三条家规工位 4journal.rs——黑匣子与三件随身工具工位 5健康门与回滚的四级阶梯工位 6updaterd 永不自杀工位 7三个绝了的守卫细节设计思想总结人话版开场为什么更新配得上全仓库最大的 crate先讲一个你可能有过的经历给路由器刷第三方固件刷到一半断电——设备变砖只能拆机焊线救。这只鸭子卖到人家里没有屏幕、没有键盘、坏了连报错都看不见。而它一生中要经历几百次自动更新每次更新都是一次刷机。所以这个工程做了一个反直觉的决定更新系统是全队第一个写完的组件然后靠它递送其它一切——在失败还免费的时候没有客户、没有可损坏的东西前置最大风险。它也是最大的 crate18.2k 行因为里面装的是四层防变砖纵深。先看工位表14 个模块比 duck-control 多但多数很小工位文件职务一句话比喻engine.rs3828 行总指挥十四道工序的施工状态机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 问你还好吗的接口工位 1engine.rs——总指挥的十四道工序整个 crate 的灵魂是文件头那七行状态机engine.rs:3-8✅ Healthy / Degraded 健康通过❌ Unhealthy / 超时 / 不兼容❌校验失败❌兼容不通过❌下载失败❌哈希错误❌签名非法❌前置钩子失败❌后置钩子失败 preflight 开工前置检查 获取装箱单manifest 签名校验⚙️ 版本兼容性检查 下载更新包 校验哈希值 更新包二次签名校验 解包新版本到staging临时目录 执行前置钩子pre‑hook⭐【原子交换rename】切换current符号链接门牌 执行后置钩子post‑hook 重启相关服务健康门轮询查询robotd健康状态500ms轮询一次提交更新清理旧版本 触发回滚逻辑跟着这七行是三条铁律注释原文engine.rs:13-18交换点之后的任何失败都回滚。钩子失败、健康失败、超时——全是同一种结局。不存在基本装好。签名和哈希都通过之前不向任何活路径解包一个字节。启动计数器在交换之前布防——这样交换后、健康检查前之间崩溃也可恢复。反过来做会留下一个未被记录的坏版本在运行。 普通人版像航天发射的倒计时检查单——每一步失败都通往同一个按钮中止返回而不是差不多就算成功。最危险的动作交换之前降落伞计数器必须已经背好。几个刻在常量里的时间预算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 字节的管道才扛得住。工位 2store.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-203fnlink_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一律清掉。工位 3verify.rs——验钞机的三条家规更新包来自网络等于收到一笔来路不明的现金。验钞minisign 签名验证的三条家规家规一能验钞的机器不该会印钞。选依赖时刻意用了只含公钥验证功能的minisign-verify而不是完整的 minisign 库——“这个进程没必要具备签名能力”。物理上消灭updater 被攻破后自己签恶意包这条路。家规二空钥匙环是致命错误不是什么都不信任。ifkeys.is_empty(){returnErr(Error::Config(no trusted keys in {} — refusing to run with an empty trust anchor));}注释verify.rs:80-83“空钥匙环是错误不是空允许表静默地’什么都不信任’和’路径配错了’长得一模一样——这里往哪个方向猜错都是灾难。” 配错路径 → 直接拒跑 → 问题当场暴露而不是让一台什么都不验证的机器人看起来工作正常。家规三密钥材料永不落日志。/// 手写 Debug让密钥材料永远不会被意外打印——只打印它的 id。implstd::fmt::DebugforTrustedKey{...}程序打日志时常常把整个结构体打印出来Debug这里手写实现绕过它——日志系统再怎么配置错误也不可能把信任锚的密钥材料漏出去。此外验签顺序有讲究先验装箱单签名 → 再验压缩包哈希 → 再验压缩包签名流式不落盘整个校验解包时用unpack_in防路径穿越压缩包里藏../../etc/passwd这种档案限额默认 2 GiB / 5 万个文件防zip 炸弹——一个 1 MB 的压缩包解出 100 GB。注释对穿越的态度很硬这种文件等于用我们自己的钥匙签了一个敌意文件——要大声报出来而不是悄悄跳过。工位 4journal.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最严重独立错误码让支持一眼看到。工位 6updaterd 永不自杀一个精妙的执行细节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