跳到正文
神奇海星的Blog

静态站的原子发布与回滚

2 分钟 技术

直接往网站目录里覆盖文件,是部署最直觉的做法,也是最容易出事的做法。

覆盖式发布的问题

假设一次发布要上传 200 个文件。在传到第 120 个的时候网络断了,网站就处于一个半新半旧的状态:HTML 已经指向新版本,但新的 CSS 和 JS 还没传上去。

访客看到的是样式错乱、脚本报错。更糟的是,这个状态不会被自动纠正 —— 你甚至可能过几天才发现。

还有一个更隐蔽的问题:没法回滚。旧文件已经被覆盖掉了,想退回去只能重新构建再传一次。

软链切换

解决办法是把「发布」和「上线」拆成两步:

  1. 新版本解压到一个全新的目录,比如 releases/20260928-143022/
  2. 全部就绪后,把 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,多留几个历史版本的成本可以忽略。这笔交易很划算。