最近写这个 Rust 全栈 CRM 时,有个点我越来越想说清楚:“Rust 单二进制部署”这句话,很多时候只说对了一半。
如果你的项目是纯 API 服务,那确实很好理解:
cargo build --release
scp target/release/app user@vps:/opt/app/
./app
但 Pico-CRM 不是纯后端。它是 Leptos SSR + WASM hydration,也就是说:
- 服务端要跑 Axum + Leptos SSR
- 前端要产出 JS / WASM / CSS
public/里的静态资源也要跟着发
所以我后来不再把它描述成“只有一个文件”,而是更愿意说:
它是一个主服务二进制,加一份站点产物目录。
这篇就结合项目里的真实实现,把这条部署链路拆开讲讲。
提前声明:本文是个人项目实践,不构成通用部署标准。你要上容器、k8s、CDN、对象存储,完全可以。但对独立开发者或轻量 SaaS 来说,先把发布链路收敛到最少,收益非常直接。
一、先说结论:我追求的不是“绝对单文件”,而是“部署动作足够少”
我一开始也被“单二进制”这个词打动过。
Rust 圈很容易给人一种印象:
没有 Node
没有 PM2
没有 Java 那一坨运行时
编完一个文件就能跑
这话没错,但放到 SSR Web 项目,真正有价值的不是“文件数量必须等于 1”,而是:
上线时不要拆三套服务,不要记五条命令,不要每次都怀疑自己漏传了什么。
Pico-CRM 现在的发布目标就很朴素:
- 构建一次,拿到完整产物
- 上传到 VPS
- 配环境变量
- 启动一个服务进程
只要做到这一点,对我这种自己写前后端、数据库、部署的人来说,就已经比“前端一套、SSR 一套、API 一套、静态资源一套”轻太多了。
二、为什么 Leptos 项目不能只盯着 server 二进制
先看项目里的 cargo-leptos 配置,在 Cargo.toml:
[[workspace.metadata.leptos]]
bin-package = "server"
lib-package = "frontend"
site-root = "target/site"
site-pkg-dir = "pkg"
style-file = "style/main.scss"
assets-dir = "public"
tailwind-input-file = "style/tailwind.css"
这里面有几个信息很关键:
bin-package = "server":服务端入口是server这个 bin cratelib-package = "frontend":前端 WASM 入口是单独的frontendcratesite-root = "target/site":前端构建产物会落到这个目录assets-dir = "public":public/下的静态资源会一起被复制过去
这意味着什么?
意味着 cargo leptos build --release --split 之后,你得到的不是“一个孤零零的 ELF 文件”,而是一整套可以被服务端直接消费的站点产物:
target/server/... ← 服务端可执行文件
target/site/ ← SSR 运行时要用到的静态站点目录
pkg/*.js
pkg/*.wasm
pkg/*.css
icons/*
vendor/*
manifest.json
也就是说,二进制负责跑服务,site 目录负责提供浏览器真正要下载的资源。
如果你只拷了 server,没把 site 带上,服务能启动,但页面资源会缺。
三、真实构建链路:不是 cargo build,而是两步
项目 README 里现在写的构建命令是:
cargo leptos build --release --split
./scripts/optimize-wasm-release.sh
第一步负责把 SSR 服务端和前端站点资源一起构建出来。
第二步很多人会省掉,但我在这个项目里保留了,因为它是实打实对 WASM 体积再压一轮。脚本在 scripts/optimize-wasm-release.sh:
mapfile -t WASM_FILES < <(find "$PKG_DIR" -maxdepth 1 -type f -name '*.wasm' | sort)
for wasm_file in "${WASM_FILES[@]}"; do
wasm-opt -Oz \
--enable-bulk-memory \
--enable-nontrapping-float-to-int \
--enable-sign-ext \
--enable-mutable-globals \
"$wasm_file" \
-o "$tmp_file"
done
这里我比较在意两点。
3.1 -Oz 是明确的体积优先
工作区 release profile 本身已经开了:
[profile.release]
codegen-units = 1
lto = true
opt-level = 'z'
strip = "symbols"
这说明整个项目从编译阶段开始,就是朝“产物尽量小”去的。
但即便如此,我还是额外保留了 wasm-opt -Oz。原因很现实:WASM 是要下发给浏览器的,体积优化不是玄学,是首屏体验。
尤其 Leptos 这种 SSR + hydration 模式,SSR 首屏虽然先出来了,但交互真正可用还是要等浏览器把对应资源拉完、初始化完。WASM 体积越大,这个等待就越明显。
3.2 我不想把“优化”寄托在记忆力上
如果 wasm-opt 只是“理论上应该跑一下”,那过几次发版之后就一定有人忘。
所以我宁可把它固化成显式脚本,发布时照着执行:
cargo leptos build --release --split
./scripts/optimize-wasm-release.sh
这样整个构建动作就很稳定,不靠临场想起来“要不要再压一遍 wasm”。
四、运行时怎么吃这些产物:服务端直接托管 site_root
再看运行时逻辑。
在 server/src/main.rs 里,服务端启动会先读取 Leptos 配置:
let conf =
get_configuration(None).unwrap_or_else(|err| panic!("加载 Leptos 配置失败: {}", err));
let leptos_options = conf.leptos_options;
随后路由 fallback 走的是文件服务:
.fallback(leptos_axum::file_and_error_handler(shell))
而项目自己也有一个静态文件处理器实现,在 server/src/fileserv.rs:
let root = options.site_root.clone();
match ServeDir::new(root).oneshot(req).await {
Ok(res) => Ok(res.map(Body::new)),
Err(err) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("Something went wrong: {err}"),
)),
}
这里的意思很直接:
site_root指向站点产物目录ServeDir直接托管这个目录- 找得到静态资源就直接返回
- 找不到再回退到 SSR 渲染
也就是说,这个项目根本没有额外再拆一个 Nginx 专门发静态资源,而是服务进程自己把站点目录托起来。
这也是我说它“很轻”的原因之一。
你不需要再维护:
- Node SSR 进程
- 独立静态站
- API 网关转发规则
一个 Axum 进程,把 SSR、API、静态资源全接住了。
五、真正上线时,我现在认的发布形态是什么
README 里的启动写法是:
LEPTOS_SITE_ROOT=./site ./server
这个命令本身已经说明问题了。
它不是只运行一个二进制然后什么都不管,而是明确告诉服务:
站点资源目录在 ./site。
所以更准确的发布包理解应该是:
server ← 主服务进程
site/ ← 前端静态产物目录
.env.* ← 环境变量配置
再结合 server/src/main.rs 里的启动顺序:
let db = Database::new().await;
Migrator::up(db.get_connection(), None).await?;
bootstrap_cqrs(db.connection.clone()).await?;
我现在这套发布的实际体验是:
- 服务启动自动连数据库
- 自动跑 SeaORM migration
- 自动初始化 CQRS 基础设施
- 自动提供 SSR 页面、API 和静态资源
这才是我真正想要的“轻部署”。
不是文件数必须等于 1,而是线上机器拿到产物后,不需要额外拼装很多运行部件。
六、这套方案的优点很实在,但边界也别装没看见
6.1 它确实省心
对个人项目来说,这套方式有三个很实在的好处:
- 构建链路统一:
cargo-leptos负责把 SSR 和前端资源一起产出来 - 运行形态统一:一个服务进程接管页面、API、静态资源
- 部署动作统一:上传服务文件和站点目录,配置环境后直接启动
尤其 Pico-CRM 这种项目还有:
- 自动 migration
- 自动 CQRS bootstrap
public/资源自动同步
整个上线链路的心智负担非常低。
6.2 但它不是“宇宙最简”
有两个边界我觉得必须说清楚。
第一,它不是严格意义上的单文件发布。
如果有人把“单二进制部署”理解成“目标机上只有一个可执行文件”,那 Leptos SSR 这类项目通常不算。
因为浏览器需要的 JS / WASM / CSS,总得有地方放。
第二,它把静态资源托管责任也放进了应用进程。
这在轻量项目里很好用,但如果你后面要上 CDN、对象存储、边缘缓存,那部署形态肯定会继续演化。
所以我的态度一直是:
先接受一个足够轻、足够稳、足够好发版的形态,而不是执着于口号绝对正确。
七、总结
如果只用一句话总结这篇文章,我会这么说:
Rust Web 项目的部署优势,不在于神化“只有一个文件”,而在于你可以把运行部件压到非常少。
在 Pico-CRM 里,这个“少”具体长这样:
- 用
cargo leptos build --release --split统一构建 - 用
wasm-opt -Oz把浏览器侧产物继续压小 - 用
ServeDir让服务端直接托管site_root - 用一个
server进程把 SSR、API、静态资源和启动初始化都串起来
对独立开发者来说,这种“1 个服务进程 + 1 份站点目录”的发布形态,我是完全接受的,而且越用越顺手。
你会把这种形态也算进“单二进制部署”吗?还是你更倾向于把静态资源拆去 CDN / Nginx?欢迎在评论区聊聊你的做法。