Rust 单二进制部署,真没你想的那么“单”

最近写这个 Rust 全栈 CRM 时,有个点我越来越想说清楚:"Rust 单二进制部署"这句话,很多时候只说对了一半。

如果你的项目是纯 API 服务,那确实很好理解:

bash 复制代码
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 圈很容易给人一种印象:

text 复制代码
没有 Node
没有 PM2
没有 Java 那一坨运行时
编完一个文件就能跑

这话没错,但放到 SSR Web 项目,真正有价值的不是"文件数量必须等于 1",而是:

上线时不要拆三套服务,不要记五条命令,不要每次都怀疑自己漏传了什么。

Pico-CRM 现在的发布目标就很朴素:

  1. 构建一次,拿到完整产物
  2. 上传到 VPS
  3. 配环境变量
  4. 启动一个服务进程

只要做到这一点,对我这种自己写前后端、数据库、部署的人来说,就已经比"前端一套、SSR 一套、API 一套、静态资源一套"轻太多了。

二、为什么 Leptos 项目不能只盯着 server 二进制

先看项目里的 cargo-leptos 配置,在 Cargo.toml

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 crate
  • lib-package = "frontend":前端 WASM 入口是单独的 frontend crate
  • site-root = "target/site":前端构建产物会落到这个目录
  • assets-dir = "public"public/ 下的静态资源会一起被复制过去

这意味着什么?

意味着 cargo leptos build --release --split 之后,你得到的不是"一个孤零零的 ELF 文件",而是一整套可以被服务端直接消费的站点产物:

text 复制代码
target/server/...     ← 服务端可执行文件
target/site/          ← SSR 运行时要用到的静态站点目录
  pkg/*.js
  pkg/*.wasm
  pkg/*.css
  icons/*
  vendor/*
  manifest.json

也就是说,二进制负责跑服务,site 目录负责提供浏览器真正要下载的资源。

如果你只拷了 server,没把 site 带上,服务能启动,但页面资源会缺。

三、真实构建链路:不是 cargo build,而是两步

项目 README 里现在写的构建命令是:

bash 复制代码
cargo leptos build --release --split
./scripts/optimize-wasm-release.sh

第一步负责把 SSR 服务端和前端站点资源一起构建出来。

第二步很多人会省掉,但我在这个项目里保留了,因为它是实打实对 WASM 体积再压一轮。脚本在 scripts/optimize-wasm-release.sh

bash 复制代码
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 本身已经开了:

toml 复制代码
[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 只是"理论上应该跑一下",那过几次发版之后就一定有人忘。

所以我宁可把它固化成显式脚本,发布时照着执行:

bash 复制代码
cargo leptos build --release --split
./scripts/optimize-wasm-release.sh

这样整个构建动作就很稳定,不靠临场想起来"要不要再压一遍 wasm"。

四、运行时怎么吃这些产物:服务端直接托管 site_root

再看运行时逻辑。

server/src/main.rs 里,服务端启动会先读取 Leptos 配置:

rust 复制代码
let conf =
    get_configuration(None).unwrap_or_else(|err| panic!("加载 Leptos 配置失败: {}", err));
let leptos_options = conf.leptos_options;

随后路由 fallback 走的是文件服务:

rust 复制代码
.fallback(leptos_axum::file_and_error_handler(shell))

而项目自己也有一个静态文件处理器实现,在 server/src/fileserv.rs

rust 复制代码
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 里的启动写法是:

bash 复制代码
LEPTOS_SITE_ROOT=./site ./server

这个命令本身已经说明问题了。

它不是只运行一个二进制然后什么都不管,而是明确告诉服务:

站点资源目录在 ./site

所以更准确的发布包理解应该是:

text 复制代码
server   ← 主服务进程
site/    ← 前端静态产物目录
.env.*   ← 环境变量配置

再结合 server/src/main.rs 里的启动顺序:

rust 复制代码
let db = Database::new().await;
Migrator::up(db.get_connection(), None).await?;
bootstrap_cqrs(db.connection.clone()).await?;

我现在这套发布的实际体验是:

  1. 服务启动自动连数据库
  2. 自动跑 SeaORM migration
  3. 自动初始化 CQRS 基础设施
  4. 自动提供 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?欢迎在评论区聊聊你的做法。

相关推荐
angerdream1 小时前
Android手把手编写儿童手机远程监控App之webrtc聊天数据通道
前端
SamDeepThinking1 小时前
一个业务场景只需要一个ThreadLocal实例
java·后端·程序员
浩风祭月1 小时前
受够了每次切分支都要重装依赖:一份 Git 工作流优化指南
前端·ai编程
谭光志1 小时前
如何从零开始实现一个 AI Agent CLI
前端·javascript·ai编程
她的男孩2 小时前
从自然语言到数据大屏:Forge Report Studio 的 AI 生成链路
人工智能·后端·架构
半个落月2 小时前
彻底搞懂 JavaScript 变量提升(Hoisting)—— 从现象到底层原理
前端·javascript
她的男孩2 小时前
大屏动态数据接入:从静态 Mock 到真实业务 API
后端·架构
零度晚风2 小时前
React 底层原理 & 新特性
前端
用户61848240219512 小时前
我受够了 Electron 的 IPC 样板代码,于是写了 electron-ipc-auto-import
前端