半路手记 · 踩坑实录
背景
我的站是静态站,部署走 GitHub Actions:push 到 main 触发构建,构建产物传到自己的服务器上。
这套流程写得很朴素,就三步:
# 1. 清空服务器上的旧文件
script: |
rm -rf /var/www/<站点目录>/*
mkdir -p /var/www/<站点目录>
# 2. 把新的 dist 传上去
uses: appleboy/scp-action@master
with:
source: "dist/"
target: "/var/www/<站点目录>/"
# 3. 修权限
script: |
find /var/www/<站点目录> -type d -exec chmod 755 {} \;
find /var/www/<站点目录> -type f -exec chmod 644 {} \;
能跑,也能跑通。我之前一直觉得它挺干净——先清干净,再把新文件放进去,逻辑上没毛病。
现在回头看,这三步里藏着一个我一直没意识到的问题:第 1 步和第 2 步之间,线上是空的。
事故发生的那天
9 月 5 日上午 11:40,我推了一次改动,内容本身没什么特别的。CI 照常启动,构建也过了,然后卡在部署这一步,失败。
失败信息不长,CI 运行日志里就是这么一行:
curl: (35) Recv failure: Connection reset by peer
exit code 35。
意思是上传工具在下载自己的依赖时,连接被重置了。具体是 appleboy/scp-action 内部要拉一个 drone-scp 的二进制,那一下没拉下来。
到这里都还只是一次普通的网络抖动。要命的是它失败的位置:
- 第 1 步
rm -rf已经执行完了 —— 服务器上的目录已经空了 - 第 2 步上传根本没开始 —— 文件一个都没上去
于是我的站变成了一个空目录。Caddy 找不到首页,全站返回 404。
为什么挂了近三天没人知道
这是这次事故里比技术问题更值得说的一段。
从 9 月 5 日 11:40 那次推送失败,到 9 月 8 日上午站点恢复,中间隔了将近三天。
具体多少小时,我给不出精确值——站点恢复的那一刻我当时没记下来。事后只能拿两个有据可查的时刻去卡:推送失败是 9 月 5 日 11:40,部署脚本的修复提交是 9 月 8 日 08:15,两者相差不到 69 小时。所以这篇里一律说「近三天」,不写一个看起来精确、其实是估出来的小时数。
三天里,我一个告警都没收到。
不是因为我没做监控——是因为我当时根本没有监控。三个环节全断了:
- CI 失败没有通知。 GitHub Actions 跑挂了我不会收到任何推送。我不主动去 Actions 页面看,就不知道它挂了。
- 站点没有可用性探测。 没有任何东西定时去访问一下首页、看它是不是 200。站挂了三天,机器那边一点声音都没有。
- 推送后我没验证线上。 这是我自己的问题——那天推完就去干别的了,默认“CI 绿了就等于上线了”。而那次恰好 CI 是红的,我根本没看。
三件事里任何一件补上,都不会拖三天。“没有告警”和“没有监控”其实是一回事:都需要有人主动来看,而我不会天天来。
根因:发布动作不是原子的
把事故剥到最后一层,问题不在网络,也不在 scp-action。
问题在于**「先删后传」这个模式本身**:它把一个发布拆成了两个独立步骤,中间存在一个真实的窗口期。在这个窗口里,任何失败都会让线上停在一个残缺状态——而且是残缺到不能用的那种。
发布这个动作,天然就该是要么全成、要么全不生效。而 rm -rf + scp 给的是第三种结果:旧的全没了,新的没来。
改法:版本目录 + 原子软链
修法不复杂,核心思路一句话:永远不要动线上正在用的那个目录。
改成这样:
第一步:先上传到一个全新的版本目录
每次部署都建一个新目录,目录名带上 commit 前 10 位和构建编号,互不覆盖:
REL="/var/www/<站点目录>-releases/${SHA:0:10}-$GITHUB_RUN_NUMBER"
ssh "$SSH" "sudo mkdir -p '$REL' && sudo chown $DEPLOY_USER:$DEPLOY_USER '$REL'"
tar czf - -C dist . | ssh "$SSH" "tar xzf - -C '$REL'"
注意这里是用 tar 流过去,而不是 scp。少一个中间工具、少一层失败面。
第二步:传完了,再原子地换软链
ln -sfn '$REL' /var/www/<站点目录>-releases/new-link
sudo mv -Tf /var/www/<站点目录>-releases/new-link /var/www/<站点目录>
这里的关键是 mv -Tf。在同一个文件系统内,mv 走的是 rename(2) 系统调用,而 rename 对符号链接的替换是原子的——要么指向旧的,要么指向新的,不存在“指向一半”的中间状态。
所以现在的失败模式变成了这样:上传失败 → 只留下一个没人用的版本目录 → 线上纹丝不动,还指着上一个完整版本。
站点再也不会因为一次网络抖动而消失。
第三步:保留最近 3 个版本
cd /var/www/<站点目录>-releases && ls -1t | tail -n +4 | xargs -r rm -rf
ls -1t 按修改时间从新到旧排,tail -n +4 从第 4 个开始取——也就是把前 3 个留下,后面的删掉。
留 3 版的意义在于:出事之后一条 ln -sfn 就能秒回滚,代价只是几百兆磁盘。这个成本低到没有理由不做。
还有一个一次性动作
旧的 /var/www/<站点目录> 是一个真实目录,不是软链,没法直接被 mv -Tf 覆盖。所以第一次要迁移一下:
if [ -d /var/www/<站点目录> ] && [ ! -L /var/www/<站点目录> ]; then
sudo mv /var/www/<站点目录> /var/www/<站点目录>-legacy-$GITHUB_RUN_NUMBER
fi
判断条件里 [ ! -L ... ] 是必须的:迁移只做一次,之后它已经是软链了,不能再搬。
二次坑:runner 里的 $USER 不可靠
改完之后第一次跑,又炸了,报的是 mkdir 没权限。
原因在这一行:
$SSH "sudo mkdir -p /var/www/<站点目录>-releases && sudo chown $USER:$USER /var/www/<站点目录>-releases"
$USER 是 runner 环境里的变量,不是服务器上的。它取到的值不一定是真正登录服务器的那个用户在服务器上的用户名。结果 chown 静默地没生效(或改错了对象),releases 目录留在 root 名下,下一次部署去 mkdir 就没权限了。
修法就一句话——别问 runner,去问服务器:
DEPLOY_USER=$($SSH "id -un")
id -un 是登录之后在服务器上执行的,拿到的就是真实的部署用户名。
顺带把版本目录的创建也改成 sudo mkdir + chown,不再依赖父目录是否可写:
$SSH "sudo mkdir -p '$REL' && sudo chown $DEPLOY_USER:$DEPLOY_USER '$REL'"
教训:CI 脚本里凡是用到“当前用户”的地方,都要先问一句——这个变量是谁的环境里的?runner 的 $USER、$HOME、$PWD 和服务器上的不是一回事。
验证
改完之后跑了两轮,两轮 CI 都 success。然后做了两件事确认:
- 部署窗口内连续探测 12 次——改造上线那一次,我在部署进行中连着访问首页,12 次全部返回 200。零空窗,这是这次改造真正要买的东西。
- 确认软链和目标:
/var/www/<站点目录>指向<站点目录>-releases/<sha>-<run>;releases 目录里留着 3 个版本——当前正在用的那个,加上往前 2 个随时可回滚的。
如果你也在用 rm -rf 部署
按这个顺序自查一遍:
- 部署脚本里有没有「先清空、再上传」的两步结构?有就是同一个坑。
- CI 失败有没有落到你眼前?邮件、手机推送、群机器人,随便哪种,必须有一个。
- 有没有一个东西每分钟访问一次首页、不是 200 就叫你?没有的话,站挂三天也不会有人知道。
- 推送之后你是不是只看 CI 是绿的、从来不打开线上页面?
- 发布是不是“要么全成、要么全不生效”?只要存在中间态,早晚会遇到。
一句话
不要用“先毁后立”的方式发布——把新版本准备好,再一次性把指针挪过去。