跳过正文
  1. 文章/

不重新部署 API 也能发布 Web 前端

· loading · loading ·
仁才德
作者
仁才德
居住在韩国首尔的领导者和软件工程师

直到不久前,Maru Web 应用的每一次改动,走的都是和 Django API 一样的路:构建 Docker 镜像,推上去,在 Fly.io 的每台机器上滚动部署。Web 前端(Expo 的 Web 导出,加上 Hugo 生成的营销页面)和 API 一起被复制进了镜像,所以没有别的办法把它发出去。改一个 CSS 大约要七分钟,重启 11 台机器。八月有一天我这么干了六次。

这篇按顺序过一遍我用什么替换了它:一个部署脚本构建两棵前端树,按版本前缀上传到对象存储,改一个指针对象,等到每台机器都在提供新版本,再通过 API 清掉 Cloudflare 的缓存。另一侧,一个小小的 Django 模块在每个请求里读指针,提供它指向的那个版本。API 完全不用重启。

老办法的问题在哪
#

最大的成本是重启。11 台机器滚动就是 11 次冷启动,在共享 CPU 上 Django 起来大约要 13 秒,其中 7 秒只是在 import Django。我在部署脚本末尾加了一步预热,把每台机器都请求一遍,免得让真实访客来付这笔账,结果就是每次 Web 发布都以等最慢那台机器收尾。 这些冷启动在 Fly.io 上有多贵,我在缩容到零,于是每次重启都是一次 30 秒的故障里写过。简单说,每次重启都是一个访客可能拿到 504 的窗口,所以一条什么都不重启的部署路径,就把 Web 发布的这个窗口整个关掉了。

第二个问题是 Hugo 的输出是拆开的。HTML 进了镜像,但 RSS feed 和搜索索引直接上了存储桶。它们来自同一份源码,只是不在同一时刻构建,所以可能互相对不上,而且对不上之前没人会发现。

第三个更像是习惯问题。当一次发布要七分钟外加一次重启,你就不做小发布了。我会攒三四个修复再一起发,而不是一个一个发,而这正是一行改动和某个坏掉的东西一起上线的原因。

存储桶里的布局
#

所有东西都在 Tigris 的静态存储桶里,web/ 前缀之下:

web/current                              ← 一个文本文件,内容是一个版本字符串
web/a1b2c3d-20260914021500/manifest.json ← 这个版本的所有文件清单
web/a1b2c3d-20260914021500/dist/...      ← Expo 的 Web 导出
web/a1b2c3d-20260914021500/hugo/...      ← Hugo 的输出
web/9f8e7d6-20260912093012/...           ← 上一个版本,还在

版本是短 git SHA 加一个 UTC 时间戳,一眼就能看出是哪个提交、什么时候传上去的。发布上线时唯一变化的东西就是 web/current

部署脚本做什么
#

整件事就是一个脚本 scripts/deploy_web.py,在仓库根目录运行。按顺序:

1. 拒绝不干净的工作树。 它做的第一件事是 git status --porcelain,有任何输出就退出。磁盘上是什么就上传什么,以前的 Hugo 部署脚本就这样发过一次未提交的文案。有个 --allow-dirty 标志给知道自己在干什么的人用,我还没用过。然后它用 git rev-parse --short HEAD 和当前时间算出版本字符串。

2. 构建两棵树。 应用这边跑 npm run build:web:prod,也就是把生产环境的 API 和 WebSocket 地址写死进去的 expo export --platform web,后面接一遍 Brotli 预压缩和后台块编辑器的 bundle。预压缩值得解释一下:Cloudflare 会在线做 Brotli 压缩,但质量等级很低,在这个 bundle 上测出来比 gzip 还差(2.04MB 对 1.66MB)。构建时用质量 11 压一次,能压到 1.28MB,所以脚本给每个超过 1KB 的 JS、CSS 和 JSON 文件旁边都写一个 .br 孪生文件。营销站这边在 hugo-site/ 里跑 hugo --gc --minify,输出到 public/

3. 上传。 它遍历 mobile-app/distpublic/,用 16 个线程把每个文件上传到 web/<version>/dist/...web/<version>/hugo/...。每个对象的 Content-Type 根据文件名猜(先去掉 .br),Brotli 孪生文件再加上 Content-Encoding: br。等所有文件都传完,才写 manifest.json,一个带两个相对路径列表的 JSON 对象。顺序很重要:Django 那一侧把没有清单的版本当作不存在,所以传到一半的版本永远不可能被设为当前版本。现在是 314 个文件,上传大约六秒。

4. 改指针。 一次 put_object 把版本字符串写进 web/current。这就是发布。

5. 等机器。 每台 API 机器把指针缓存五秒,所以改完之后的一小会儿,有些机器还在旧版本上。脚本每半秒轮询一次 /api/web-version/,数带着新版本的连续回答。Fly 会在所有机器之间做负载均衡,所以连续 16 次匹配的回答(比机器数多)就说明每台都重新读过指针了。一个旧回答就把计数清零。90 秒后放弃,让你手动检查;第一次真正的切换花了 30 秒出头,所以上限没定得更低。

6. 清 Cloudflare 缓存。 一次 API 调用,下面讲。失败的话脚本打条警告继续走。

7. 发到 Discord。 用运行时错误告警的同一个 webhook 发一行,带版本和是谁跑的,这样一波错误可以对照着刚才改了什么来看。回滚用琥珀色发。

从头到尾一次发布一分钟出头,大部分时间是 Expo 导出。

Django 这一侧
#

提供前端的本来就是 Django。所有非 API 的 URL 都由一个视图处理,它返回 Expo 导出里对应该路由的预渲染页面;营销、隐私政策和支持页面则来自 Hugo 的输出。这些视图以前是直接打开磁盘上的文件。现在它们都经过一个小模块 web_bundle.py,里面有一个 WebFiles 类,每个视图改成调用它:

body = web_files.read("dist", "tabs/home.html")

每个请求它解析三样东西:当前版本(从 web/current 读,缓存五秒)、该版本的清单(在该版本为当前版本期间一直缓存)、以及文件本身(一个每进程约 48MB 的 LRU,所以页面的 HTML 和主 bundle 会留在内存里)。清单是 404 便宜的原因:路径不在列表里,就直接答没有,不用去问存储桶。如果客户端接受 Brotli,它会改读 .br 的孪生文件并设置编码头。静态资源带着一年的 immutable 缓存头发出去,因为文件名里有内容哈希。

同一个模块还负责构建 Expo 的路由表,把 /tabs/home 映射到 tabs/home.html,而且按版本缓存,所以新部署之后会在第一个请求时重建,之前不会。

然后是部署脚本轮询的那个接口:

def web_version(request):
    response = JsonResponse({"source": web_files.source(), "version": web_files.version()})
    response["Cache-Control"] = "no-store"
    return response

/api/web-version/ 在生产环境返回 bucket:<version>,从镜像提供时返回 filesystem。每台 Fly 机器只回答自己的情况,所以打十几次就知道整个集群有没有收敛。当哪里看着不对劲时,这也是查某台机器认为线上是什么最快的办法。

清 Cloudflare 缓存
#

marucommunity.com 的 HTML 在 Cloudflare 边缘缓存五分钟。不清的话,部署已经在机器上生效,但大多数访客最长五分钟内拿到的还是旧页面,这就失去意义了。

清缓存就是一次 API 调用:

urllib.request.Request(
    f"https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
    data=json.dumps({"hosts": ["marucommunity.com", "www.marucommunity.com"]}).encode(),
    headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)

我按主机而不是按 URL 清,这样就不用把每个页面列一遍。静态资源根本不需要清:文件名带内容哈希,新构建指向新文件名,旧的只是不再被引用。需要刷新的只有引用这些文件名的 HTML。

脚本在清缓存之前等机器,是有意的。如果先清,Cloudflare 可能从一台还在旧版本的机器上把缓存填回去,那个页面就会在边缘再待五分钟。

如果清缓存失败,脚本打一条警告然后照样结束。缓存五分钟内会自己过期,所以最坏情况是发布慢一点,而不是发布坏了。我宁愿这样,也不要部署在最后一步中止。

上线前先检查一个构建
#

--no-flip 做到第 3 步就停。版本在存储桶里,但没有任何东西在提供它。要看的话,在一台机器上设 WEB_BUNDLE_VERSION=<version>,不管 web/current 说什么,那台机器就固定在那个构建上。看着没问题,--rollback <version> 把它设为当前版本。这个标志叫回滚,其实就是"把 current 指向这个版本",往前走和往回退是同一个操作。

回滚
#

旧版本留在存储桶里,所以回滚就是拿一个更早的版本跑 --rollback <version>--list 按从新到旧列出有哪些。它和往前部署一样会等机器、清缓存。我还没用到过。目前没有任何东西会清理旧版本;它们很小,我宁愿留着。

没变的部分
#

API 仍然通过 deploy-all.sh 按老办法部署,也应该如此:它有状态,迁移就跑在它上面。Dockerfile 也仍然把 mobile-app/distpublic/ 复制进镜像。这是有意的。没有配置存储桶时(本地开发、测试套件)或者存储桶读不了时,WebFiles 会退回到磁盘上的那些目录,所以存储故障时访客拿到的是随上一个镜像发出的前端,而不是错误页。如果哪天需要把存储桶从链路里摘掉,WEB_BUNDLE_FROM_BUCKET=0 可以在生产环境强制这个行为。

前端的改动比 API 频繁得多,现在每次发布大约一分钟,而不是七分钟。我没刻意去改,攒着发的习惯就没了。