静态站的原子发布与回滚
直接往网站目录里覆盖文件,是部署最直觉的做法,也是最容易出事的做法。
覆盖式发布的问题
假设一次发布要上传 200 个文件。在传到第 120 个的时候网络断了,网站就处于一个半新半旧的状态:HTML 已经指向新版本,但新的 CSS 和 JS 还没传上去。
访客看到的是样式错乱、脚本报错。更糟的是,这个状态不会被自动纠正 —— 你甚至可能过几天才发现。
还有一个更隐蔽的问题:没法回滚。旧文件已经被覆盖掉了,想退回去只能重新构建再传一次。
软链切换
解决办法是把「发布」和「上线」拆成两步:
- 新版本解压到一个全新的目录,比如
releases/20260928-143022/ - 全部就绪后,把
current这个软链指向新目录
因为软链的替换在 Linux 上是原子操作(底层是 rename 系统调用),所以对 Nginx 来说不存在「指向一半」的中间状态。在这一刻之前,访问的还是旧目录;在这一刻之后,访问的是新目录。
# 先建一个临时软链,再用 mv -T 一步覆盖
# 直接 ln -sfn 会有极短的窗口期软链不存在,mv -T 没有这个问题
ln -sfn "$REL_DIR" "$BASE/.current.new"
mv -Tf "$BASE/.current.new" "$BASE/current"
Nginx 那边只需要:
root /var/www/blog/current;
为什么不用重启 Nginx
因为 root 指向的是软链,Nginx 每次处理请求时都会重新解析这个路径。软链换了,它下次请求就自然读到新目录 —— 整个过程对它完全透明。
只有改了 Nginx 自己的配置文件时才需要 reload。
回滚就是再切一次软链
既然上线只是切换软链,回滚就是把它切回上一个版本:
# 保留最近 5 个版本,就是为了这一刻
ln -sfn "$BASE/releases/$PREV" "$BASE/.current.new"
mv -Tf "$BASE/.current.new" "$BASE/current"
这也顺带解释了为什么每篇文章的发布都能做到秒级回滚:旧的版本目录原封不动地留在磁盘上,回滚不涉及任何重新构建或文件传输。
资源缓存不会冲突
静态站的构建产物文件名里带内容哈希,例如:
_astro/index.C3xK9pQz.css
_astro/hoisted.B7d2mNvR.js
不同版本的同名文件,哈希不同、文件名就不同。所以旧版本目录里残留的资源和新版本互不干扰,浏览器也不会因为缓存而拿到错误的版本 —— 这也正是可以给它们设置「一年不过期」的底气。
发布前先自检
切软链之前,脚本会检查几个关键文件是否真的存在:
for f in index.html 404.html pagefind/pagefind.js; do
if [ ! -e "$REL_DIR/$f" ]; then
echo "发布包缺少 $f,中止发布"
rm -rf "$REL_DIR"
exit 1
fi
done
这几个文件任一缺失,说明构建本身有问题。这时候宁可保持旧版本在线,也不要把一个坏掉的版本推上去 —— 让旧版本多跑一会儿,远比让访客看到错误页面要好。
小结
| 做法 | 覆盖式 | 软链切换 |
|---|---|---|
| 发布失败的影响 | 网站半新半旧 | 完全无影响 |
| 回滚 | 重新构建 + 上传 | 切一次软链,秒级 |
| 需要的磁盘 | 一份 | 保留几份历史版本 |
| 实现复杂度 | 低 | 略高,但一次性 |
静态站的体积通常只有几十 MB,多留几个历史版本的成本可以忽略。这笔交易很划算。