静态站发布有个反直觉的事实:构建成功从来不是目标,「任何时刻打开都拿到完整的一版」才是。这篇拆 site-builder 的两条构建路径——正式发布与预览,重点讲清楚原子切换为什么能成立、构建失败时线上为什么不受影响,以及预览目录的 TTL 到底该由谁负责清。

1. 把 dist 直接写进 current 会怎样

先看最朴素的发布方式:Astro 构建到 dist/,然后 rsync 或 cp -r 覆盖线上目录。它在三种时刻出问题:

  • 构建刚开始,旧文件已被清空,用户拿到没有 CSS 的 HTML;
  • 复制到一半失败,线上是半新半旧的混合体;
  • 想回滚,只能 git checkout 旧提交再构建一次,而这次构建同样有窗口。

根因是「构建」和「切换」被混在同一个目录里。静态站的产物是一组互相引用的文件(HTML、_astro/ 里的带 hash 资源、sitemap),只要读者在写入过程中访问,就可能拿到不一致的组合。

正确的做法只有一条:在全新目录里构建,构建完成后用一次指针替换切换。site-builder 就是围绕这条原则写的。

2. site-builder 的职责边界

它是一个很小的 Node 服务,只做三件事:接收构建请求、执行 Astro 构建、原子切换产物。它不连数据库、不读业务表,所有内容都通过 app 的 API 拉取。

site-builder:
  container_name: limit-blog-site-builder
  image: ${SITE_BUILDER_IMAGE:-limit-blog-site-builder:latest}
  environment:
    BUILDER_HOST: "0.0.0.0"
    BUILDER_PORT: "9000"
    BUILDER_SECRET: ${BUILDER_SECRET}
    BUILDER_API_BASE_URL: http://app:8000
    BUILDER_SITE_URL: ${SITE_URL:-https://localhost}
    BUILDER_DATA_ROOT: /app/data/site
  volumes:
    - ../data:/app/data
  depends_on:
    app:
      condition: service_healthy

边界体现在几个细节上:

  • 不暴露端口。Compose 里没有 ports,只有容器网络内的 site-builder:9000。构建服务能读全站数据,暴露到公网等于开了一个无须登录的发布入口。
  • 共享密钥。每个请求都要带 X-Builder-Secret,值来自 BUILDER_SECRET,必须与 app 配置里的 site.build.secret 一致。/build 和 /status 都校验,不存在「只读接口不用鉴权」的例外。
  • 串行执行。构建期间再来请求不会并发跑,而是记成「排队一次」,当前构建结束后只跑最后一个。Astro 构建吃 CPU 和内存,并发只会让两个构建都变慢。
  • 立即返回。POST /build 返回 202 就结束,不等构建完成。否则 app 的 sitebuild.Client 会在 15 秒超时,把一次正常构建误判成失败。

镜像本身也很薄,只装前端依赖,不含 Go 二进制:

FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@10.34.7 --activate
ENV CI=true
WORKDIR /app/frontend
COPY frontend/ ./
RUN pnpm install --frozen-lockfile
WORKDIR /app
CMD ["node", "frontend/site-builder/server.mjs"]

依赖在镜像构建时安装,运行期不再 pnpm install。好处是构建请求的冷启动稳定,代价是 frontend/package.json 或 lockfile 变更后必须重建镜像,否则新依赖在容器里不存在,构建会以 Cannot find package 失败。

3. 目录:releases、preview、current

构建根目录的核心是三个东西:

data/site/
├── releases/
│   ├── 1761177600000/
│   └── 1761174000000/
├── preview/
│   └── 3f2a9c1d4e5b6a7c8d9e0f1a2b3c4d5e/
└── current -> releases/1761177600000
  • releases/ 存正式产物,目录名是毫秒时间戳;每次构建新建一个,从不原地覆盖。
  • preview/ 存预览产物,目录名是 app 生成的 32 位十六进制 buildId。
  • current 是指向某个 release 的符号链接,Nginx 的 root 就指向它。

current 用的是相对路径链接,不是绝对路径。builder 容器里根目录是 /app/data/site,Nginx 容器里同一份数据挂在 /srv/limit-blog/data/site;绝对路径链接换一个挂载点就指向不存在的位置。相对链接在两种挂载点下都能解析,这是跨容器共享符号链接的必要条件。

4. 正式构建:先写新目录

一次正式构建的完整链路:

  1. app 收到内容变更,经 2 秒防抖后 POST /build,mode=production;
  2. builder 执行 pnpm run build:search,即 astro build 加 pagefind 建站内搜索索引;
  3. 构建产物在 frontend/web/dist;
  4. 删除并重建 releases/<timestamp>/,把 dist 复制进去;
  5. 原子切换 current 到新目录。

这里有个取舍值得说明:预览构建用的是 build:raw,不跑 pagefind。搜索索引对预览没有意义,却会把构建时间拉长一截。两条路径共用同一套 Astro 工程,只在构建命令和输出目录上分叉。

另一个约束是构建目录必须是全新的。rm -rf 加 mkdir 看着粗暴,但它保证了「旧 release 永远不会被写坏」——即使复制到一半进程被杀,坏掉的也只是那个还没被切换上去的新目录。

5. 原子切换靠一次 rename

切换逻辑只有十几行,但每一行都有原因:

async function replaceSymlink(target, linkPath) {
  const temporary = `${linkPath}.tmp-${Date.now()}`;
  const relativeTarget = path.relative(path.dirname(linkPath), target);
  await rm(temporary, { force: true });
  await symlink(relativeTarget, temporary, 'dir');
  await rename(temporary, linkPath);
}

为什么这样就原子:

  • 新链接先以临时名字创建,此时还没人访问它;
  • rename(2) 在同一文件系统内替换目录项是原子的,读者要么看到旧链接,要么看到新链接,不存在「链接不存在」的中间态;
  • 临时链接和 current 在同一个目录,保证同一文件系统,rename 不会退化成跨设备复制。

Nginx 侧只需要把 root 指向这个链接:

root /srv/limit-blog/data/site/current;
index index.html;
client_max_body_size 32m;
error_page 404 /404.html;

location / {
    try_files $uri $uri/ =404;
}

location = /404.html {
    internal;
}

每个请求打开文件时由内核解析符号链接,所以切换对正在运行的 Nginx 立即生效,不需要 reload。已经打开的文件描述符继续指向旧 inode,正在传输的响应不会被腰斩;新请求走新目录。这是「原子」在 HTTP 层面的真实含义。

带 hash 的静态资源可以放心长缓存,因为切换后文件名会变:

location /_astro/ {
    alias /srv/limit-blog/data/site/current/_astro/;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

immutable 让浏览器在一年内连条件请求都不发。前提是资源真的内容寻址——如果哪天把不带 hash 的文件放进这个前缀,回滚后用户会长期看到旧版本。

sitemap 和 robots 是例外,它们每次构建都可能变,路径里又没有 hash,所以要显式降级缓存:

location = /sitemap-index.xml {
    add_header Cache-Control "no-cache";
}

location ~* ^/(sitemap.*\.xml|robots\.txt)$ {
    add_header Cache-Control "no-cache";
}

不带 no-cache 时,搜索引擎可能拿着几天前的 sitemap 反复抓已经不存在的页面,而你在后台看到的抓取错误会完全对不上。

媒体上传目录同理,只是来源换成了 data/uploads:

location /statics/ {
    alias /srv/limit-blog/data/uploads/;
    add_header Cache-Control "public, max-age=31536000, immutable";
    access_log off;
}

6. 构建失败,线上为什么不受影响

失败可能发生在任何一步:Astro 报错、API 拉不到数据、复制中断、磁盘满。处理方式统一:

  • 状态记为 failed,error 里带原因,finishedAt 照常写;
  • current 完全不动,Nginx 继续服务上一个成功的 release;
  • 已经创建的新目录会留在 releases/ 下,方便进容器看产物和日志;
  • 管理端仪表盘可以手动点「重建站点」,走同一个 POST /api/admin/site/rebuild。

状态查询是同步的:

docker compose -f deploy/docker-compose.yml exec app \
  wget -qO- --header="X-Builder-Secret: $BUILDER_SECRET" \
  http://site-builder:9000/status

返回体里 buildId、mode、state、startedAt、finishedAt、previewUrl、error 七个字段都是平铺的。state 只有 idle、running、success、failed 四种。带 buildId 查询时,如果与最近一次构建不匹配会返回 404,避免把旧构建的状态当成新的。

值得注意的是「构建失败」和「发布失败」是两件事。Astro 构建失败不会触碰线上;真正危险的是切换之后的环节,比如静态资源路径写错、CSP 配错,这类问题构建是成功的,只能靠发布后检查。

7. 预览构建:另一条路径

预览面向编辑中的内容,要包含草稿,所以它不能走公开 API。流程是:

  1. POST /api/admin/site/preview 生成 32 位 buildId,签发预览令牌,TTL 取 site.build.previewTTLMinutes(默认 15 分钟);
  2. builder 带 X-Preview-Token 请求 /api/preview/bundle,拿到包含草稿的整站快照;
  3. 用 BASE_PATH=/preview/<buildId>/ 执行 build:raw;
  4. 产物写入 preview/<buildId>/,不切换 current。

Nginx 对预览目录的处理是独立的一段:

location /preview/ {
    alias /srv/limit-blog/data/site/preview/;
    add_header X-Robots-Tag "noindex, nofollow" always;
    add_header Cache-Control "no-store" always;
    add_header Content-Security-Policy "frame-ancestors 'self'" always;
}

noindex 和 no-store 是必须的:预览 URL 虽然带随机 ID,但它没有登录态,任何人拿到链接都能看。不索引、不缓存能减少意外扩散。

TTL 是三个不同的时间,别混:

时间 值 作用
预览令牌 15 分钟 过期后不能再拉 bundle,构建无法开始
预览目录保留 60 分钟 按目录 mtime 清理,从构建完成算起
清理巡检 10 分钟 定时器扫一遍 preview/,删过期目录

清理逻辑按 mtime 判断:

const PREVIEW_RETENTION_MS = 60 * 60 * 1000;

async function prunePreviews(previewRoot) {
  const entries = await readdir(previewRoot, { withFileTypes: true });
  const now = Date.now();
  for (const entry of entries) {
    if (!entry.isDirectory()) continue;
    const fullPath = path.join(previewRoot, entry.name);
    const info = await stat(fullPath);
    if (now - info.mtimeMs > PREVIEW_RETENTION_MS) {
      await rm(fullPath, { recursive: true, force: true });
    }
  }
}

这里有个容易踩的坑:Compose 里声明了 BUILDER_PREVIEW_TTL_MINUTES: "15",但当前 server.mjs 并没有读取这个变量,目录保留时间由常量 PREVIEW_RETENTION_MS 决定(60 分钟),巡检间隔固定 10 分钟。改环境变量不会改变清理行为,要改得改代码。配置和实现对不上,是这类小服务最常见的技术债。

还有一个不显眼的残留:每次预览构建会在 data/site/ 下写一个 preview-bundle-<buildId>.json,而清理逻辑只扫描 preview/ 目录,这个文件不会被自动删除。单个文件不大,但长期运行会累积,定期清理时别只盯着子目录。

8. 触发方式:自动与手动

正式构建有两个入口:

  1. 自动触发。文章、分类、标签、导航、友链、公告、页面、项目、设置、主题、公开媒体的写操作成功后触发。文章走 controller 里的 notifySiteBuild,媒体、设置、主题通过注入的 SiteBuildHook,都丢到后台 goroutine,不阻塞请求。
  2. 手动触发。管理端「重建站点」按钮或 POST /api/admin/site/rebuild,需要 site:rebuild 权限。手动触发会先 Cancel() 掉尚未执行的防抖计时,避免刚点完又被自动构建覆盖。

初始化不会自动触发构建:POST /api/init/data 只写数据库。空站上线时,要么在后台点一次「重建站点」,要么保存一次设置来触发自动构建。

自动触发有 2 秒防抖(site.build.debounceSeconds)。连续保存五篇文章只会构建一次,这是必要的:Astro 全量构建是分钟级操作,逐次触发会把机器打满。

site:
  build:
    enabled: true
    url: http://site-builder:9000
    secret: "<与 BUILDER_SECRET 一致>"
    previewTTLMinutes: 15
    debounceSeconds: 2
    previewRoot: /app/data/site/preview

enabled 和 url 必须同时有效,否则构建直接返回「站点构建未启用」。secret 不一致的典型症状是日志里的 HTTP 401: {"message":"unauthorized"},检查两个环境变量即可。

9. 回滚:链接指回去就行

因为每次发布都是一个独立目录,回滚不需要重新构建,只需要把链接指回上一个 release。

cd data/site
ls -1 releases | sort -n | tail -5
ln -sfn "releases/<buildId>" current
readlink current
curl -skI https://example.com/ | head -1

ln -sfn 里的 -n 不能省。current 本身是指向目录的符号链接,不加 -n 时 ln 会跟进去,在旧 release 里再建一个链接,结果是「命令成功但线上没变」。

严格说 ln -sfn 是「删旧链接再建新链接」,中间有一个极短的窗口。如果要和 builder 一样严格原子,用临时链接加 mv -Tf(GNU coreutils):

rm -f current.tmp
ln -s "releases/<buildId>" current.tmp
mv -Tf current.tmp current

回滚前先确认目标目录完整:ls releases/<buildId>/index.html。回滚后注意两件事:

  • 数据库不会跟着回滚。内容已经更新,回滚的只是渲染结果;下一次保存内容又会触发构建,把线上带回新版本。要维持旧版,得临时关掉 site.build.enabled 或暂停自动触发。
  • releases 没有自动清理。site-builder 只清 preview/,正式产物会一直堆积。一个 release 包含全部页面和图片引用,站点越大增长越快,定期看一眼磁盘,保留最近若干个即可;删之前确认 current 没有指向它。

10. 小结

这条流水线的核心只有一句话:构建与切换分离,切换只做一次 rename。失败时旧版本不动,预览与正式走两条目录,清理和回滚都变成对目录和链接的操作,而不是对构建系统的操作。

代价是磁盘会累积、预览权限依赖随机路径、TTL 配置存在实现漂移。这些都不是致命问题,但需要知道它们在哪。

下一篇换到故障现场:502 和 504 分别是谁返回的、容器 DNS 怎么查、卷权限出问题时日志长什么样。