不靠免密 sudo 的静态站发布:目录授权、严格健康检查与重复覆盖
背景
ZGLab Tools 是一个 Astro 静态站点。生产环境只需要 Nginx 读取 dist/,日常发布不会修改 Nginx 配置,也不需要重启服务。
早期部署脚本为了操作 /var/www 和 /var/backups,在远程阶段通过 sudo -n 执行:
mkdir;rsync;chown;chmod;find;nginx -t。
这种写法只有在服务器为这些命令配置免密 sudo 后才能自动运行。为了让一个静态文件发布脚本工作而放宽多条 root 命令,不符合当前个人服务器的最小权限目标。
后续方案改为:
- 管理员首次创建目录并授权部署用户;
- 日常部署完全不使用 sudo;
- 静态文件更新不重复 chown;
- 只有首次创建或修改 Nginx 配置时运行
nginx -t。
环境与目标目录
当前目标环境:
- Ubuntu 24.04;
- Nginx;
- 默认部署用户
ubuntu; - Nginx 用户组
www-data; - 站点目录
/var/www/tools.zglab.fun; - 备份目录
/var/backups/tools.zglab.fun; - 远程临时目录
/tmp/zglab-tools-dist。
首次由管理员执行:
sudo apt update
sudo apt install -y nginx rsync curl
sudo install -d -o ubuntu -g www-data -m 0755 \
/var/www/tools.zglab.fun \
/var/backups/tools.zglab.fun
目录所有者 ubuntu 负责写入,Nginx 只需要遍历 755 目录并读取 644 文件。日常脚本不需要 root 权限。
如果这些目录已经存在且包含文件,chown -R 和权限调整属于一次性迁移操作。执行前必须确认路径,不能把递归权限命令扩大到 /var/www 或 /var/backups 根目录。
日常发布流程
flowchart TD
START[开始] --> PATH[校验 URL、SSH 与路径]
PATH --> CI[npm ci]
CI --> FORMAT[format:check]
FORMAT --> LINT[lint]
LINT --> CHECK[Astro Check]
CHECK --> TEST[Vitest]
TEST --> BUILD[Astro build]
BUILD --> TMP[上传远程 /tmp 临时目录]
TMP --> BACKUP[备份当前站点]
BACKUP --> PUBLISH[rsync --delete 覆盖发布]
PUBLISH --> HEALTH{health.txt 为 200 且正文匹配?}
HEALTH -->|否| ROLLBACK[从本次备份覆盖回滚]
HEALTH -->|是| PRUNE[保留最近 N 个备份]
PRUNE --> DONE[输出 PUBLIC_SITE_URL]
完整本地检查顺序是:
npm ci
npm run format:check
npm run lint
npm run check
npm run test
npm run build
任何一步失败都不会进入远程覆盖阶段。
为什么日常部署不需要 nginx -t
nginx -t 检查的是 Nginx 配置语法和引用文件,不是静态 HTML 是否正确。
日常部署只替换:
/var/www/tools.zglab.fun/
不会修改:
/etc/nginx/nginx.conf
/etc/nginx/sites-available/
/etc/nginx/sites-enabled/
因此,只有首次启用或修改站点配置时需要:
sudo ln -sfn \
/etc/nginx/sites-available/tools.zglab.fun \
/etc/nginx/sites-enabled/tools.zglab.fun
sudo nginx -t
sudo systemctl reload nginx
把 nginx -t 放进每次静态文件发布,不仅没有增加页面内容校验,还迫使部署用户获得额外 sudo 权限。
路径校验为什么必须在 rsync 之前
rsync --delete 会让目标目录与源目录一致。目标多出的文件会被删除,因此部署目录必须是专用静态目录。
当前脚本要求:
DEPLOY_ROOT位于/var/www/的非根子目录;DEPLOY_TMP位于/tmp/的非根子目录;DEPLOY_BACKUP_ROOT位于/var/backups/的非根子目录;- 路径不能为空;
- 路径不能是
/或前缀根目录本身; - 拒绝
.、..、重复斜杠和非安全字符; DEPLOY_SERVER必须使用安全的user@host格式;DEPLOY_BACKUP_KEEP必须是大于 0 的整数。
已经实际执行过的拒绝测试包括:
- 把
DEPLOY_ROOT设置为/var/www; - 把临时目录设置到
/var/tmp; - 在备份路径中加入
..; - 把备份保留数设置为 0;
- 在路径中加入 Shell 元字符;
- 将站点 URL 写成带凭据的形式;
- 显式传入空的部署路径。
这些输入都在 npm 和远程操作之前返回非零状态。
覆盖发布不是真正的原子发布
当前发布使用:
rsync -a --delete --chmod=D755,F644 \
"${deploy_tmp}/" \
"${deploy_root}/"
它会覆盖同名文件并删除新构建中不存在的旧文件。再次执行部署脚本时,会自动用新的 dist/ 覆盖服务器版本,不需要手工删除旧站。
但这不是原子切换。同步过程中,目标目录会逐步变化。即使窗口很短,访问者理论上仍可能看到新旧资源混合状态。
当前方案通过三项措施降低风险:
- 覆盖前完整备份旧站;
- 在 rsync 开始前将
published=true,确保中途失败也进入回滚; - 发布后立即执行严格健康检查。
如果未来需要真正的原子发布,可以采用版本目录加 current 软链接切换,但这会增加 Nginx 路径、软链接权限和备份管理复杂度。对于当前低频更新的个人静态工具站,覆盖发布加失败回滚是可以接受的取舍。
健康检查必须验证状态和正文
只执行:
curl --fail <site-url>
不足以证明新版本已经正确发布。首页可能被缓存,301/302 也可能让检查逻辑产生误判,错误页面还可能返回 200。
项目新增:
public/health.txt
固定内容为:
zglab-tools-ok
构建后它应出现在:
dist/health.txt
发布脚本请求:
${PUBLIC_SITE_URL}/health.txt
并同时要求:
- HTTP 状态码严格等于
200; - 响应正文与构建产物逐字节一致。
脚本不跟随重定向,所以 301 和 302 都会失败。正文不匹配时,即使状态是 200,也会触发回滚。
这条健康检查验证的是“指定静态构建是否已经能够通过公开站点读取”,而不是 Nginx 进程的全部健康状态。
失败回滚为什么要覆盖原目录
发布前创建时间戳备份:
/var/backups/tools.zglab.fun/20260717T120000Z/
失败后执行:
rsync -a --delete --chmod=D755,F644 \
"${backup_dir}/" \
"${deploy_root}/"
回滚也使用 --delete,因为目标可能已经写入只属于失败版本的新哈希资源。如果回滚只复制旧文件而不删除新文件,目录会残留两套资源。
首次部署没有旧站时,脚本创建空备份目录作为回滚源。如果发布失败,空备份可以清除已经写入的部分文件;成功后这个空目录会被移除。
备份清理必须限制作用域
默认配置:
DEPLOY_BACKUP_KEEP=10
成功发布后,脚本只扫描备份根目录的直接子目录:
find "$backup_root" \
-mindepth 1 \
-maxdepth 1 \
-type d \
-name '20??????T??????Z'
随后按名称倒序保留最近 N 个。
这里的安全边界是:
- 不递归搜索其他位置;
- 只处理时间戳格式目录;
- 删除前再次确认候选目录的父目录就是备份根目录;
- 健康检查成功后才开始清理。
备份清理是破坏性操作。即使已有前缀和格式校验,也应定期人工检查目录与磁盘空间。
重复部署时会发生什么
再次运行:
./scripts/deploy.sh
会自动:
- 重新安装锁定依赖并运行全部检查;
- 重新构建
dist/; - 上传远程临时目录;
- 备份当前线上版本;
- 覆盖同名文件;
- 删除不再属于新
dist/的旧文件; - 检查
health.txt; - 失败回滚,成功清理超量备份。
因此,不要直接在服务器站点目录手工修改 HTML、CSS 或图片。这些改动下次部署会被覆盖或删除。本地仓库和构建结果应成为唯一发布源。
当前验证状态
截至 2026-07-17,已经验证:
- 部署脚本通过
bash -n; - 本地完整检查链通过;
- 5 个测试文件共 60 项测试通过;
- Astro 构建成功并生成 8 个页面;
dist/health.txt存在且内容精确匹配;- 多组危险路径和配置被脚本拒绝;
- 脚本中不存在日常
sudo、chown和nginx -t; - 用户已完成一次服务器上传。
最新的顶部导航精简仍处于本地调试阶段,本文不声称该界面版本已经再次部署。
经验总结
静态站点发布的最小权限原则可以概括为:
- 管理员只在首次准备目录和 Nginx 配置时使用 sudo;
- 部署用户只写自己拥有的站点、备份和临时目录;
- 日常发布不修改 Nginx,不重复 chown;
rsync --delete前必须验证路径并完成备份;- 覆盖发布要诚实说明它不是原子发布;
- 健康检查同时验证固定路径、精确状态码和固定正文;
- 回滚与备份清理都要限制作用域。
相关笔记: