单一静态二进制:go:embed 与零依赖部署
把前端、内置主题、数据库迁移和时区数据全部嵌进一个可执行文件,换来的是「拷过去就能跑」。这篇讲这个选择怎么做成,以及它贵在哪。
Lumo 的发布产物是一个可执行文件。这个决定的表述只有一句,但它把后面几十个选择都锁死了。
这篇讲它怎么落成,以及贵在哪。
嵌了什么进去
四样东西。
后台前端。 一个完整的 React 单页应用,经 go:embed 嵌进二进制:
//go:embed all:dist
var dist embed.FS
all: 前缀不是装饰。默认规则会静默跳过以 . 和 _ 开头的文件——静默跳过是最糟的失败方式:构建成功,部署成功,直到某个资源在运行时 404 才发现。加上前缀就没有这个可能。
内置主题。 模板、样式、脚本,以及按需分包的思源宋体,一共一百多个文件,用 //go:embed all:builtin 嵌进去。它是每个第三方主题的回退目标,所以不能是「需要另外下载的东西」。
迁移。 每个模块带自己的 migrations/*.sql,//go:embed migrations/*.sql。启动时自动跑,不用先准备一个 SQL 目录。
时区数据。 导入 time/tzdata。不导的话,一个没装 tzdata 的精简镜像会让你在设置时区时撞上一个莫名其妙的报错,而这类报错只在别人的机器上出现。
静态的代价与做法
cgo 一律关掉。 CGO_ENABLED=0,-trimpath,-s -w 去掉符号表与调试信息。去掉符号表后,完整嵌入了前端与主题的二进制大约 40 MB。
WebP 是真的花了力气。 缩略图要生成 WebP,而标准做法是链接系统 libwebp——那是动态依赖,装完这 40 MB 还得在目标机器上装一个 .so,本末倒置。
解法是 -tags nodynamic。这个标签由 gen2brain/webp 识别,含义是不使用动态库,只用转译成 Go 的编码器。C 实现的 WebP 编码器被转译成了纯 Go,所以缩略图能生成、产物仍然自足。
因此全链路固定 -tags nodynamic,而且它是构建的一部分,不是可选优化——漏了它,产物在你自己机器上跑得好好的(因为系统里碰巧有那个库),换台机器就起不来。
六个平台。 linux / windows / darwin × amd64 / arm64,goreleaser 一次出全。这也是自足的收益:交叉编译不需要目标平台的环境,只要 CGO_ENABLED=0。
三处对「嵌进去」的让步
完全嵌进去有一个明显代价:改一行前端也要重编译后端。 对使用者无所谓(他拿到的是成品),对开发是致命的。所以有三处例外:
一、开发模式另走一套。 task console:dev 起带 HMR 的 Vite 开发服务器,接口代理到后端;后端用 task run -- serve 单跑。前端产物没构建时后端照样启动,/console/ 返回一句构建提示而不是报错——不影响接口开发,也不至于让人对着一页空白猜原因。
二、内置主题落盘。 主题本来完全可以只在内存里渲染。但那样一来,站长想改模板里的一行文案就必须重编译二进制,而主题系统的全部意义就在于改模板要足够便宜。
所以启动时把内置主题解压一份到 data/themes/ink,此后以磁盘那份为准:改模板即时生效,可以「重新加载」,可以点「恢复出厂」,但不能删除。
三、构建顺序不能反。 前端必须先于 Go 编译产出到 internal/console/dist,否则 go:embed 嵌进去的是一个空目录。task build 只构建后端,task all 才是全量——这两个命令的区别不是习惯问题,搞反了会得到一个「能启动但后台是空白」的二进制。
internal/console/dist 不进版本库(只留一个 .gitkeep 占位,因为 go:embed 要求目录存在),这也是为什么全新克隆之后不能直接 go build。
换来了什么
一条命令,一个文件,一个数据库:
tar -xzf lumo_0.1.0_linux_amd64.tar.gz && cd lumo_0.1.0_linux_amd64 && ./lumo serve
没有 Node 版本问题,没有前端构建缓存,没有「本地能跑服务器不行」的依赖差异,没有容器镜像的层缓存策略要理解,没有构建流水线要维护。升级是替换一个文件,回滚是换回来,备份是拷一个目录加一次 pg_dump。
省的不是 CPU,是运维的心。 这句话是我对这一整篇的总结。
评论
还没有评论,来说两句。