用 Rust 从零实现一个升级服务:我在几个工具里踩过的坑
最近连续做了几个命令行工具。
一开始大家都还能接受手动升级:
- 去 release 页面下载最新包
- 解压
- 覆盖旧二进制
- 再试着跑一下
但工具一多,这套流程马上就会出问题。
最明显的几个现象是:
- 同一个团队里,大家跑着不同版本
- CI 机器和本地机器版本不一致
- 某个 bug 明明修了,还是不断有人反馈
- 每次发布都要再发一遍“大家记得重新装一下”
说白了,工具只要真的有人长期用,升级能力就不是锦上添花,而是基础设施。
这篇文章想聊的,就是我最近在 Rust 项目里怎么把这件事补起来。
不是讲一个开源项目的完整源码复刻。这个项目本身没有开源,所以我不会贴内部实现全文,也不会暴露真实发布地址、存储细节和流水线配置。但核心逻辑、关键取舍、状态流转,这些是完全可以讲清楚的。
如果你也在做 CLI 工具,并且已经开始遇到“发布了,但用户没升级”这个问题,这套思路基本可以直接参考。
先别急着写代码,先把升级这件事拆成 4 个对象
我后来发现,自升级这件事看着复杂,其实核心就 4 个东西:
- manifest:服务端告诉客户端,当前最新版本是什么,有哪些平台包,每个包的校验和是多少
- archive:真正可下载的制品,比如
.tar.gz或.zip - local state:客户端本地记住自己上次检查了什么、有没有已经下载好的新版本
- 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-gnux86_64-apple-darwinaarch64-apple-darwinx86_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_result 和 last_error 让你知道上次到底发生了什么。
第三类:状态衔接
staged_update 能把“上一次下载好的文件”和“这一次启动时要不要应用”串起来。
你可以把它理解成一个很小的状态机。
比如:
up_to_dateavailabledownloadedappliederror
有了这个状态机,升级逻辑就不会是一堆散在各处的 if/else。
目录布局也值得提前想清楚
我一开始也想过,下载下来的文件是不是放临时目录就行。
后来发现不够。
更稳的方式是给升级系统一个自己可管理的目录结构,比如:
app-data/
bin/
downloads/
staged/
updater-state.json各自职责分开:
bin/:受管理的当前二进制downloads/:下载下来的归档包staged/:已经解压、准备下次应用的新二进制updater-state.json:升级状态
为什么要分开?
因为升级链路天然就有阶段性。
- 下载失败,归档可能不完整
- 校验失败,归档不能继续用
- 解压成功,但还没应用
- 应用完成后,staged 状态应该清掉
如果所有东西都堆在一起,出问题时很难判断文件现在处于哪个阶段。
目录分层以后,排查和清理都容易很多。
发布侧其实不复杂,关键是要稳定地产出 manifest
很多人做升级服务时,注意力都放在客户端。
但客户端想简单,前提是发布侧足够稳定。
我这边发布流水线的核心动作其实很朴素:
- 按平台构建二进制
- 打成归档包
- 计算每个归档的 SHA-256
- 上传到对象存储或 CDN
- 生成一个最新稳定版本的 manifest
- 把 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 设计对,后面很多事都会自然顺下来。
别一上来就想着“自动升级”四个字有多大,先把“发现版本、下载、校验、暂存、下次启动应用”这条链路做扎实,它就已经很有用了。