静态站发布有个反直觉的事实:构建成功从来不是目标,「任何时刻打开都拿到完整的一版」才是。这篇拆 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. 正式构建:先写新目录
一次正式构建的完整链路:
- app 收到内容变更,经 2 秒防抖后
POST /build,mode=production; - builder 执行
pnpm run build:search,即astro build加pagefind建站内搜索索引; - 构建产物在
frontend/web/dist; - 删除并重建
releases/<timestamp>/,把dist复制进去; - 原子切换
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。流程是:
POST /api/admin/site/preview生成 32 位buildId,签发预览令牌,TTL 取site.build.previewTTLMinutes(默认 15 分钟);- builder 带
X-Preview-Token请求/api/preview/bundle,拿到包含草稿的整站快照; - 用
BASE_PATH=/preview/<buildId>/执行build:raw; - 产物写入
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. 触发方式:自动与手动
正式构建有两个入口:
- 自动触发。文章、分类、标签、导航、友链、公告、页面、项目、设置、主题、公开媒体的写操作成功后触发。文章走 controller 里的
notifySiteBuild,媒体、设置、主题通过注入的SiteBuildHook,都丢到后台 goroutine,不阻塞请求。 - 手动触发。管理端「重建站点」按钮或
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 怎么查、卷权限出问题时日志长什么样。




评论
加载中…