用 Rust 从零实现一个升级服务:我在几个工具里踩过的坑

  • King
  • 发布于 2天前
  • 阅读 59

最近连续做了几个命令行工具。一开始大家都还能接受手动升级:去release页面下载最新包解压覆盖旧二进制再试着跑一下但工具一多,这套流程马上就会出问题。最明显的几个现象是:同一个团队里,大家跑着不同版本CI机器和本地机器版本不一致某个bug明明修了,还是不断有人反

最近连续做了几个命令行工具。

一开始大家都还能接受手动升级:

  • 去 release 页面下载最新包
  • 解压
  • 覆盖旧二进制
  • 再试着跑一下

但工具一多,这套流程马上就会出问题。

最明显的几个现象是:

  • 同一个团队里,大家跑着不同版本
  • CI 机器和本地机器版本不一致
  • 某个 bug 明明修了,还是不断有人反馈
  • 每次发布都要再发一遍“大家记得重新装一下”

说白了,工具只要真的有人长期用,升级能力就不是锦上添花,而是基础设施

这篇文章想聊的,就是我最近在 Rust 项目里怎么把这件事补起来。

不是讲一个开源项目的完整源码复刻。这个项目本身没有开源,所以我不会贴内部实现全文,也不会暴露真实发布地址、存储细节和流水线配置。但核心逻辑、关键取舍、状态流转,这些是完全可以讲清楚的。

如果你也在做 CLI 工具,并且已经开始遇到“发布了,但用户没升级”这个问题,这套思路基本可以直接参考。


先别急着写代码,先把升级这件事拆成 4 个对象

我后来发现,自升级这件事看着复杂,其实核心就 4 个东西:

  1. manifest:服务端告诉客户端,当前最新版本是什么,有哪些平台包,每个包的校验和是多少
  2. archive:真正可下载的制品,比如 .tar.gz.zip
  3. local state:客户端本地记住自己上次检查了什么、有没有已经下载好的新版本
  4. apply step:在合适的时机,把新二进制替换成当前在用的版本

只要这 4 个东西定义清楚了,升级系统就不会乱。

我自己最后落地的模型,大概就是这样:

发布流程构建多平台二进制
        ↓
生成每个平台的压缩包 + SHA-256
        ↓
发布一个稳定 manifest
        ↓
CLI 启动或手动执行 update 时拉取 manifest
        ↓
比较版本,找到当前平台对应的包
        ↓
下载 -> 校验 -> 解压 -> 暂存
        ↓
下次启动时应用升级

这里最关键的一点是:不要把“检查更新”和“应用更新”混成一件事。

很多人第一次做,会本能地想“发现新版本后,立刻把自己替换掉”。理论上可以,工程上会很难受。后面我会讲为什么我最后选择了“先下载,等下次启动再应用”。


manifest 才是升级服务真正的核心

很多人一说升级服务,第一反应是“搞个下载链接”。

但真正稳定的方案里,下载链接只是结果,manifest 才是客户端和发布系统之间的契约

我这边实际用到的 manifest 信息,核心可以收敛成这样:

{
  "schema_version": 1,
  "product": "your-cli",
  "channel": "stable",
  "version": "1.2.3",
  "release_tag": "v1.2.3",
  "published_at": "2026-03-21T09:00:00Z",
  "files": [
    {
      "archive_name": "your-cli-v1.2.3-aarch64-apple-darwin.tar.gz",
      "target": "aarch64-apple-darwin",
      "archive_format": "tar.gz",
      "url": "https://updates.example.com/releases/v1.2.3/your-cli-v1.2.3-aarch64-apple-darwin.tar.gz",
      "sha256": "..."
    }
  ]
}

这里每个字段都不是摆设。

schema_version

以后 manifest 字段变了,客户端至少能知道自己是不是还能理解这份数据。

product

防止客户端拉错配置。尤其当你后面有多个工具共用一套发布基础设施时,这个字段很有必要。

channel

给以后做 stable / beta / nightly 留口子。哪怕现在只用 stable,也建议先留着。

version

这是客户端做版本比较的依据。

files

这是重点。升级不是“下载最新包”,而是“下载当前平台能用的那个包”。

我在项目里专门把 manifest 校验和目标平台匹配独立出来,就是为了让客户端行为尽量确定:

  • schema 不对,直接拒绝
  • product 不对,直接拒绝
  • files 为空,直接拒绝
  • 当前平台找不到对应制品,直接拒绝

这比“试试看能不能下一个包”靠谱得多。

一句话总结:

升级系统里最应该认真设计的,不是下载逻辑,而是 manifest 契约。


客户端第一件事,不是下载,而是先判断“值不值得动”

升级逻辑如果写得太热情,很容易把自己写成一个烦人的后台任务。

比如:

  • 每次命令执行都请求一次网络
  • 不管有没有新版本都做一堆事情
  • 明明用户只想跑个命令,你先给他来一段升级流程

这体验很差。

所以我后来把客户端判断逻辑收成了三步。

第一步:先看本地设置允不允许自动检查

本地状态里至少要有类似这样的设置:

{
  "auto_check": true,
  "auto_download": true,
  "channel": "stable"
}

这件事很重要,因为自动升级不是所有用户都欢迎。

有的人希望工具静默保持最新。 有的人希望只提醒,不自动下载。 还有的人在离线环境里,根本不想让程序自己探测网络。

所以升级系统从第一天就要有“用户可控”的意识。

第二步:做节流,不要每次都查

我在实际实现里做了检查间隔控制。原因很简单:

命令行工具不是桌面 App,没有必要每执行一次命令就联网问一句“我是不是过时了”。

通常做法就是在本地状态里记一个 last_check_at,然后约定一个间隔,比如 6 小时、12 小时,或者 24 小时。

伪代码大概是这样:

if !state.settings.auto_check {
    return;
}

if now - state.last_check_at < check_interval {
    return;
}

这个小判断非常值钱。

它可以明显减少:

  • 不必要的网络请求
  • 命令执行时的额外延迟
  • 服务端 manifest 接口压力

第三步:只比较版本,不做“猜测升级”

客户端拿到 manifest 后,先做语义化版本比较:

  • 远端版本 <= 当前版本:结束
  • 远端版本 > 当前版本:继续

这里不要做奇怪的字符串比较,也不要自己手搓版本规则。Rust 里直接用 semver 这类成熟库就行。

这一步本身没什么花活,但它决定了后面整条链路是不是稳。


平台匹配这件事,别偷懒

很多内部工具一开始只有 macOS 版本,大家容易误以为升级很简单。

真等到:

  • CI 跑在 Linux
  • 一部分同事用 Intel Mac
  • 一部分同事换成 Apple Silicon
  • 某些环境还需要 Windows

你就会发现,升级服务本质上也是个“多目标制品分发系统”。

所以我比较建议一开始就显式维护 target:

  • x86_64-unknown-linux-gnu
  • x86_64-apple-darwin
  • aarch64-apple-darwin
  • x86_64-pc-windows-msvc

然后客户端运行时根据当前 arch + os 去映射目标 triple,再去 manifest 里找完全匹配的文件。

不要写成“macOS 就下这个包”“大概差不多能跑”。

升级系统里,模糊匹配就是未来的事故来源

尤其是 Apple Silicon 这类场景,x86_64 包有时候不是不能跑,而是“也许能跑,但行为、性能、用户体验都不稳定”。

所以最稳的方式永远是:

  • 发布侧明确产物
  • manifest 明确记录
  • 客户端精确匹配

下载只是开始,真正麻烦的是“下载之后怎么确认它能用”

如果你只是把远端文件拉下来,然后直接替换当前二进制,那这套升级服务其实还没做完。

中间至少还有三件事必须补上。

1)校验完整性

这个几乎是底线。

服务端在发布时生成每个制品的 SHA-256,写进 manifest。客户端下载完以后,对本地文件重新计算一次 hash,和 manifest 里的值比对。

不一致就直接失败。

原因不复杂:

  • 下载可能损坏
  • 中间缓存可能有问题
  • 你不能默认“文件下到了,就一定是对的”

伪代码就是:

let actual = sha256(downloaded_file);
if actual != expected_sha256 {
    return Err("checksum mismatch");
}

这个动作看起来普通,但它把升级从“下载文件”变成了“可信交付”。

2)只解出你真正想要的那个二进制

我在实际实现里,不会把整个压缩包原样解到一个目录里再慢慢挑文件,而是只找目标二进制:

  • Unix 包一般是 tar.gz
  • Windows 包一般是 zip
  • 解压时只认目标文件名

这样做的好处是:

  • 目标明确
  • 少一层不必要的文件布局依赖
  • 避免“压缩包结构改了导致客户端一起炸”

比如你今天的包里是:

apifire

明天为了 release 展示好看,改成:

apifire-v1.2.3/
  apifire
  README.txt

如果客户端依赖的是“必须解到固定目录结构”,就容易出问题。

而如果客户端只关心“里面有没有那个二进制文件”,会稳很多。

3)做平台后处理

这一步特别容易被漏掉。

下载并解压成功,不代表这个文件已经能执行。

我这边至少补了两个动作:

  • Unix 平台补执行权限
  • macOS 清理 quarantine 属性

为什么要做?

Unix 权限

压缩、解压、跨环境传输后,执行权限不一定还在。你不补 0o755,用户下次启动就可能遇到“文件在,但不能执行”。

macOS quarantine

这个坑如果你没踩过,会莫名其妙卡很久。

某些来源下载下来的文件,即便已经落到本地,也可能带着 quarantine 属性。你不处理,系统可能会在执行时额外拦一下。

这类动作看起来很“平台细节”,但升级服务最后往往就死在这些细节上。

所以我的建议是:把“下载后处理”当成升级系统的一等公民,而不是收尾杂活。


为什么我最后选了“暂存 + 下次启动时应用”

这是整套设计里,我觉得最值的一次取舍。

很多人第一次做自升级时,都会想:

既然新版本已经下载好了,为什么不立刻把当前程序替换掉?

答案是:因为你现在就在运行它。

这会带来一堆现实问题:

  • 当前进程正在使用这个文件
  • 平台对可执行文件替换的限制不一样
  • 失败时很难给自己留后路
  • Windows 一类环境里文件锁问题会更明显

所以我最后采用的是两阶段:

阶段一:下载并暂存

检查到新版本后:

  • 下载归档
  • 校验 SHA-256
  • 解出二进制
  • 放到 staged 目录
  • 在本地状态里记录“这个版本已就绪”

阶段二:下次启动时应用

程序下次启动时,最前面做一件事:

  • 读取本地 updater state
  • 看有没有 ready = true 的 staged update
  • 如果有,就用替换逻辑把新二进制切进来
  • 成功后清掉 staged 状态

这个模型有几个特别直接的好处。

好处 1:运行中的进程不用和自己抢文件

你避免了最尴尬的“边跑边换自己”。

好处 2:用户体验更可控

用户会看到一句很明确的话:

新版本已经下载好,下次启动自动生效。

这句话的信息量其实刚刚好。

它不会打断当前任务,也不会让用户再手动跑一套安装步骤。

好处 3:错误边界清晰

下载阶段失败,就是下载失败。 应用阶段失败,就是应用失败。

两者的状态和日志都能分开记录,排查起来简单很多。

好处 4:更适合 CLI 工具

桌面应用有时候会做热更新,是因为它本身就是长驻进程。

但 CLI 工具天然就是短生命周期程序。你完全可以利用这个特点,把应用升级这件事推迟到下一次进程启动,工程复杂度会明显下降。

如果让我给命令行工具的升级策略只留一句建议,那就是:

优先选 staged update,而不是 in-place hot replace。


本地状态文件不是附属品,它其实是升级系统的小脑

很多“能跑”的升级实现,最大的问题是太无状态。

一出问题就很难回答这些问题:

  • 上次什么时候检查过?
  • 上次看到的新版本是什么?
  • 这次为什么没升级?
  • 是没新版本,还是下载失败?
  • 有没有已经下载好的 staged binary?

所以我后来把本地状态专门落成一个文件,核心字段大概是这样:

{
  "schema_version": 1,
  "current_version": "1.1.0",
  "last_check_at": "2026-03-21T09:00:00Z",
  "last_seen_version": "1.2.0",
  "last_check_result": "downloaded",
  "last_error": null,
  "staged_update": {
    "version": "1.2.0",
    "target": "aarch64-apple-darwin",
    "archive_url": "https://updates.example.com/...",
    "archive_sha256": "...",
    "archive_path": "/path/to/archive",
    "unpacked_binary_path": "/path/to/staged/binary",
    "ready": true
  },
  "settings": {
    "auto_check": true,
    "auto_download": true,
    "channel": "stable"
  }
}

这份状态至少解决了三类问题。

第一类:节流

last_check_at 能决定要不要再次联网。

第二类:可观察性

last_check_resultlast_error 让你知道上次到底发生了什么。

第三类:状态衔接

staged_update 能把“上一次下载好的文件”和“这一次启动时要不要应用”串起来。

你可以把它理解成一个很小的状态机。

比如:

  • up_to_date
  • available
  • downloaded
  • applied
  • error

有了这个状态机,升级逻辑就不会是一堆散在各处的 if/else。


目录布局也值得提前想清楚

我一开始也想过,下载下来的文件是不是放临时目录就行。

后来发现不够。

更稳的方式是给升级系统一个自己可管理的目录结构,比如:

app-data/
  bin/
  downloads/
  staged/
  updater-state.json

各自职责分开:

  • bin/:受管理的当前二进制
  • downloads/:下载下来的归档包
  • staged/:已经解压、准备下次应用的新二进制
  • updater-state.json:升级状态

为什么要分开?

因为升级链路天然就有阶段性。

  • 下载失败,归档可能不完整
  • 校验失败,归档不能继续用
  • 解压成功,但还没应用
  • 应用完成后,staged 状态应该清掉

如果所有东西都堆在一起,出问题时很难判断文件现在处于哪个阶段。

目录分层以后,排查和清理都容易很多。


发布侧其实不复杂,关键是要稳定地产出 manifest

很多人做升级服务时,注意力都放在客户端。

但客户端想简单,前提是发布侧足够稳定。

我这边发布流水线的核心动作其实很朴素:

  1. 按平台构建二进制
  2. 打成归档包
  3. 计算每个归档的 SHA-256
  4. 上传到对象存储或 CDN
  5. 生成一个最新稳定版本的 manifest
  6. 把 manifest 发布到固定地址

注意这里的重点是:manifest 地址要稳定,制品地址可以版本化。

也就是说,客户端永远只需要知道一个入口,例如:

https://updates.example.com/stable.json

至于里面指向的是 v1.2.3 还是 v1.2.4,客户端不关心。

它只要每次拉这个稳定入口,就能拿到最新信息。

为什么这个设计简单有效

因为你把“发现最新版”这件事从客户端拿走了。

客户端不需要:

  • 猜 release 页面结构
  • 遍历 tag
  • 解析 HTML
  • 硬编码每个平台下载地址模板

它只需要做一件事:

请求 manifest,然后照 manifest 执行。

这是整个升级系统里最省心的边界划分。


一个最小可工作的发布清单

如果你准备自己搭这套东西,发布侧至少要保证这些事情:

1. 归档命名稳定

像这样:

your-cli-v1.2.3-aarch64-apple-darwin.tar.gz
your-cli-v1.2.3-x86_64-unknown-linux-gnu.tar.gz
your-cli-v1.2.3-x86_64-pc-windows-msvc.zip

命名一稳定,target 解析、问题排查、人工核对都会轻松很多。

2. checksum 在发布时生成,不要事后补

因为 checksum 本来就是制品的一部分元数据,和产物一起生成最合理。

3. manifest 由发布流程生成,不要手写

只要开始支持多个平台,手写 manifest 迟早出错。

4. 失败要尽早暴露

比如:

  • tag 和 Cargo.toml 版本不一致,直接失败
  • 产物命名不符合约定,直接失败
  • 没有找到任何归档,直接失败

升级系统最怕“发布看起来成功了,其实客户端拿不到完整信息”。


项目不开源,文章应该讲到什么边界为止?

这个问题其实挺现实。

很多内部项目都能分享经验,但又不适合把实现全文贴出来。

我自己的标准是:

可以讲的

  • 整体架构
  • manifest 设计
  • 客户端状态机
  • 版本比较、平台匹配、校验、暂存、重启应用这些核心流程
  • 脱敏后的字段示例
  • 精简伪代码
  • 跨平台处理时遇到的工程细节

不该讲的

  • 真实更新域名
  • 对象存储路径规则
  • token、secret、环境名
  • 完整内部工作流脚本
  • 公司内部发布权限模型
  • 任何可能让别人直接复刻你私有基础设施配置的细节

换句话说,讲“设计”,少讲“部署细枝末节”;讲“为什么这样做”,少讲“我们线上具体怎么配”。

这样既能把经验分享出去,也不会把不该公开的东西带出去。


如果让我从零再做一次,我会按这个顺序落地

我不建议一开始就追求“特别完整的升级平台”。

更现实的顺序是:

第一步:先定 manifest 契约

先把这些字段定下来:

  • schema_version
  • product
  • channel
  • version
  • published_at
  • files[target, url, sha256, archive_format]

第二步:把发布流程接上

做到:

  • 按平台打包
  • 自动算 SHA-256
  • 自动生成 manifest
  • 发布固定 manifest 地址

第三步:客户端只做 check 模式

先支持:

your-cli update --check

确认以下事情都对:

  • 能正确拉 manifest
  • 能做版本比较
  • 能正确识别当前平台
  • 能找到对应制品

第四步:加 download-and-stage

确认:

  • 能下载
  • 能校验
  • 能解压
  • 能写入 staged 状态

第五步:最后再加启动时应用升级

把它挂到程序最早启动阶段,优先于主要业务逻辑执行。

这样你调试时会轻松很多。

因为每一步都有明确的输入输出,不会一下把所有问题搅在一起。


最后给一份我自己觉得够用的落地清单

如果你正在给内部 CLI 补升级能力,可以直接按这个 checklist 过一遍:

  • [ ] 有稳定的 manifest 地址
  • [ ] manifest 里有 schema_version 和 product 字段
  • [ ] manifest 能按 target 提供制品信息
  • [ ] 制品发布时自动计算 SHA-256
  • [ ] 客户端会做语义化版本比较
  • [ ] 客户端会显式匹配当前平台,不做模糊猜测
  • [ ] 自动检查有节流机制
  • [ ] 本地有持久化状态文件
  • [ ] 下载后会做完整性校验
  • [ ] 解压时只提取目标二进制
  • [ ] Unix 权限和 macOS 平台细节有处理
  • [ ] 升级采用 staged update,而不是运行时硬替换
  • [ ] 启动时会尝试应用已暂存升级
  • [ ] 升级失败不会影响主命令路径
  • [ ] 发布流程能稳定生成 manifest,而不是靠人工维护

如果这 15 条你都能勾掉,基本上这套升级服务已经不是“能演示”,而是“能长期用”。


结尾

我现在越来越觉得,很多工具能力一开始看起来都像“以后再说”。

升级服务就是典型例子。

项目刚起步时,手动发包完全够用;但只要这个工具真的进入团队日常,升级就会从“可选项”变成“维护成本的分水岭”。

而 Rust 很适合做这件事:

  • 二进制分发天然友好
  • 跨平台构建链路成熟
  • 做版本、校验、文件处理、状态管理都比较顺手
  • 最后交付出来的 CLI 也比较稳

如果你也准备给自己的工具补一套升级能力,我的建议就一句:

先把 manifest 和 staged update 设计对,后面很多事都会自然顺下来。

别一上来就想着“自动升级”四个字有多大,先把“发现版本、下载、校验、暂存、下次启动应用”这条链路做扎实,它就已经很有用了。

点赞 0
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发