补一次漏掉的部署,我做了件看起来很合理的事:去 GitHub 的 webhook 投递记录里,把原来那条 push 的 delivery 点了「Redeliver」。服务确实起来了。
几个小时后才发现,墨水屏面板(displayservice)上的 AI 额度图不见了——它被回退了。顺着查下去,发现不是它一个,是这台机器上 13 个服务全被打回了隔天的一个旧 commit。
记录一下这个自建部署系统的坑。
这个 Deployer 是怎么工作的
平台是个人 monorepo + 自建的 Deployer,走 webhook 驱动:
GitHub push → webhook → 校验 HMAC → git fetch + reset --hard 到 push 的 commit → 算出本次改动影响哪些服务 → 逐个 rsync 到 /srv/<svc> → 重启 systemd + reload Caddy关键一步在 git_ops.py,每次部署都把工作树同步到本次 push 携带的那个 commit:
_git("fetch", "origin", cwd=target)full_sha = _resolve_ref(target, ref) # ref = webhook payload 里的 head_commit_git("checkout", full_sha, cwd=target)_git("reset", "--hard", full_sha, cwd=target)正常正向部署时这没问题:push 的 commit 就是 main 最新的,reset --hard 把工作树对齐到它,干净利落。每个服务部署时还会把这个 commit 写进 systemd 单元的 GIT_COMMIT 环境变量——这个细节后面救了我。
起因是另一个服务(notificationservice)早先部署失败过(缺 env 文件)。我补好 env,想「重新触发」那次部署,顺手就去 GitHub 把**当时那条 webhook delivery 重投递(Redeliver)**了一下。notificationservice 起来了,看着一切正常。
第一个线索:displayservice 跑在隔天的 commit 上
「display 怎么回退了」——先看它现在部署的是哪个 commit:
systemctl show displayservice.service -p Environment | tr " " "\n" | grep GIT_COMMITGIT_COMMIT=5bbdc1d5bbdc1d 是前一天的提交。而 main 的最新是 b19ae93。再看 displayservice 那个 AI 额度面板是哪个 commit 加的:
git log --oneline b19ae93 -5 -- apps/displayserviceed2fcbe feat(displayservice): autonomous OAuth token refresh in AI quota reporter649a5ed feat(displayservice): AI panel shows live 5h/weekly rate-limit quota...649a5ed / ed2fcbe 都在 5bbdc1d 之后。也就是说部署的代码被退回到了功能加进来之前的状态——面板自然就没了。
爆炸半径:13 个服务全是 5bbdc1d
不会只有它一个吧。把所有 Deployer 托管的服务的 GIT_COMMIT 都打出来:
for s in registry auth deployer displayservice locationservice timeservice \ resume logservice kvservice secretsservice oss messageservice \ notificationservice emailservice commentservice files; do c=$(systemctl show "$s.service" -p Environment | tr " " "\n" | sed -n "s/^GIT_COMMIT=//p") printf "%-20s %s\n" "$s" "${c:-<none>}"doneregistry <none>auth <none>deployer <none>displayservice 5bbdc1dlocationservice 5bbdc1dtimeservice 5bbdc1d...(全部 13 个业务/平台服务都是 5bbdc1d)registry / auth / deployer 是 <none>——它们属于「信任根」,走单独的 Actions 部署、不归这个 webhook 管,所以躲过一劫。其余 13 个服务全被打回了 5bbdc1d。
好在 5bbdc1d 到 b19ae93 之间只有 displayservice 真的改过代码,别的服务那段没动过源码,所以它们只是 GIT_COMMIT 这个 label 旧了、功能没受影响。真正丢功能的只有 displayservice。但「只丢一个」是运气,不是设计。
根因:重投递 + reset —hard 到旧 commit
把两件事拼起来就清楚了。
第一,GitHub 的 Redeliver 重放的是原始 payload。 这点查了官方文档确认:重投递会复用同一个 X-GitHub-Delivery GUID,因为它就是把当初存下来的那条请求原样再发一遍——里面的 head_commit 还是当时那次 push 的 SHA(5bbdc1d),不是一个新生成的 payload。
第二,Deployer 对这个 commit 做 reset --hard。 git fetch 把 origin 拉到最新(b19ae93 都在本地了),但紧接着 reset --hard 5bbdc1d 又把工作树硬退回到那个旧 commit。git 文档对 --hard 的描述很直白:用 <commit> 的版本覆盖所有文件,工作树里不属于该 commit 的文件被删除,让工作树和该 commit 完全一致。退到旧 commit,就是把工作树退回旧状态。
于是链条成立:
重投递旧 delivery(旧 head_commit)→ Deployer
reset --hard到旧 commit → 工作树退回旧代码 → rsync 给那次 push 影响的每个服务 → 静默回退。
5bbdc1d 当初是个大 PR(动了 SDK 等共享代码),「受影响服务」算出来几乎是全部,所以一次重投递回退了一整排。
这个机制不算 bug——fetch + reset --hard 到 push 的 SHA 是公认的 webhook 部署写法(Shopify 的 shipit-engine 之类都这么干)。它对正向部署完全正确。问题出在「重放一个旧事件」这个用法上:旧事件携带的是旧 commit,正向逻辑照单全收,就成了回退。
先救活:在最新 SHA 上手动重部
Deployer 自带一个手动重部署端点,能指定在哪个 commit 上部署。直接让它在最新 HEAD 上重部 displayservice:
curl -X POST https://deploy.example.app/api/deployments \ -H "Authorization: Bearer <ADMIN_TOKEN>" \ -d '{"service_id":"displayservice","commit_sha":"<HEAD>"}'几秒后 GIT_COMMIT 变回 b19ae93,report_ai_usage.py、面板渲染代码都回来了,健康检查 200。displayservice 救活。
但这只是把症状按回去。真正该做的是:让重放旧投递这件事根本不可能再悄悄回退。
根治:stale-ref gate
思路很简单:部署前判断目标 commit 是不是严格落后于分支 tip。如果是(说明这是个被重放的旧投递),就拒绝,除非显式 force。
判断「A 是不是 B 的祖先」用 git merge-base --is-ancestor,它的退出码语义正好:A 是 B 的祖先退 0,不是退 1,出错是其它非 0 值。
在 ensure_repo 里 checkout/reset 之前加这道闸:
_git("fetch", "origin", cwd=target)full_sha = _resolve_ref(target, ref)
if branch is not None and not force: tip_sha = _resolve_ref(target, branch) # origin/<branch> 的 tip if full_sha != tip_sha and _is_ancestor(target, full_sha, tip_sha): raise StaleRefError(ref=ref, target_sha=full_sha, tip_sha=tip_sha, branch=branch)
_git("checkout", full_sha, cwd=target)_git("reset", "--hard", full_sha, cwd=target)def _is_ancestor(target, ancestor_sha, descendant_sha) -> bool: r = subprocess.run( ["git", "-C", str(target), "merge-base", "--is-ancestor", ancestor_sha, descendant_sha], capture_output=True, text=True, ) if r.returncode == 0: return True if r.returncode == 1: return False raise GitOpsError(...) # 其它退出码才是真出错两条路径接上去:
- webhook 路径:
force恒为False(GitHub webhook 没法传 force),所以重放旧投递直接被拒——返回skipped+ 一条告警日志,绝不回退。 - 手动端点:加一个
force字段。落后 tip 的commit_sha返回 409,force:true才放行——这是给「我就是要回滚」留的正门。
只在传了 branch 时启用这道闸,没有 branch 上下文的「显式检出某个 SHA」保持原样,向后兼容。
写法上还顺手修了手动端点一个隐性 bug:它原来「标着 commit_sha 却实际部署工作树 HEAD」,现在改成真正 checkout 请求的那个 SHA。
按 TDD 补了 7 个测试(gate 的拒绝/force/tip/无branch、handle_push 重放旧 push 不回退、手动端点 409/force),全套 197 个测试通过。
上线后做了次活体验证——拿酿成这次事故的旧 SHA 5bbdc1d 去调手动端点,不带 force:
HTTP 409{"detail":"refusing stale deploy: 5bbdc1da ... is behind main tip 5a4e3337 — hard-resetting onto it would revert the work tree (and every service rsynced from it). Pass force=True to roll back intentionally."}拒了,零回退。当初有这道闸,displayservice 根本不会被退回去。
经验总结
- 别用「重投递旧 webhook」来补部署。 Redeliver 重放的是原始 payload,里面是旧 commit。只要你的部署逻辑会
reset --hard到 push 的 commit,重放旧投递就等于回退。要补一条漏掉的部署,重投递的应该是最新那条 delivery(它的 commit 就是 tip),或者干脆用「部署指定/最新 SHA」的主动接口。 - 「恢复部署」和「重放历史事件」是两回事。 恢复应该是个幂等的「部署到某个明确的 commit」动作,而不是「把过去某个时刻的事件再放一遍」。后者把时间维度也一起重放了。
reset --hard到 push 的 SHA,对正向部署正确,对重放是 footgun。 加个方向判断(目标不能落后于分支 tip,除非显式 force)成本极低,git merge-base --is-ancestor一行就够。- 每个服务记一个
GIT_COMMIT太值了。 这次能五分钟定位,全靠systemctl show ... GIT_COMMIT一眼看出「跑在哪个 commit 上」。自建部署系统,务必把部署的 commit 落到一个能直接查的地方。
自建轮子的好处是出事时你能一路查到底、改到根;坏处是这种「正向正确、重放出错」的边角,得自己踩一遍才知道要补闸。