1994 字
10 分钟
重放一条旧 GitHub webhook,我把 13 个服务全回滚了

补一次漏掉的部署,我做了件看起来很合理的事:去 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_ops.py(简化)
_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:

Terminal window
systemctl show displayservice.service -p Environment | tr " " "\n" | grep GIT_COMMIT
GIT_COMMIT=5bbdc1d

5bbdc1d前一天的提交。而 main 的最新是 b19ae93。再看 displayservice 那个 AI 额度面板是哪个 commit 加的:

Terminal window
git log --oneline b19ae93 -5 -- apps/displayservice
ed2fcbe feat(displayservice): autonomous OAuth token refresh in AI quota reporter
649a5ed feat(displayservice): AI panel shows live 5h/weekly rate-limit quota
...

649a5ed / ed2fcbe 都在 5bbdc1d 之后。也就是说部署的代码被退回到了功能加进来之前的状态——面板自然就没了。

爆炸半径:13 个服务全是 5bbdc1d#

不会只有它一个吧。把所有 Deployer 托管的服务的 GIT_COMMIT 都打出来:

Terminal window
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>}"
done
registry <none>
auth <none>
deployer <none>
displayservice 5bbdc1d
locationservice 5bbdc1d
timeservice 5bbdc1d
...(全部 13 个业务/平台服务都是 5bbdc1d)

registry / auth / deployer 是 <none>——它们属于「信任根」,走单独的 Actions 部署、不归这个 webhook 管,所以躲过一劫。其余 13 个服务全被打回了 5bbdc1d

好在 5bbdc1db19ae93 之间只有 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 fetchorigin 拉到最新(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:

Terminal window
curl -X POST https://deploy.example.app/api/deployments \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-d '{"service_id":"displayservice","commit_sha":"<HEAD>"}'

几秒后 GIT_COMMIT 变回 b19ae93report_ai_usage.py、面板渲染代码都回来了,健康检查 200。displayservice 救活。

但这只是把症状按回去。真正该做的是:让重放旧投递这件事根本不可能再悄悄回退。

根治:stale-ref gate#

思路很简单:部署前判断目标 commit 是不是严格落后于分支 tip。如果是(说明这是个被重放的旧投递),就拒绝,除非显式 force

判断「A 是不是 B 的祖先」用 git merge-base --is-ancestor,它的退出码语义正好:A 是 B 的祖先退 0,不是退 1,出错是其它非 0 值。

ensure_repocheckout/reset 之前加这道闸:

git_ops.py 新增的 gate
_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)
_is_ancestor
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 返回 409force: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 落到一个能直接查的地方。

自建轮子的好处是出事时你能一路查到底、改到根;坏处是这种「正向正确、重放出错」的边角,得自己踩一遍才知道要补闸。

重放一条旧 GitHub webhook,我把 13 个服务全回滚了
https://blog.lishuyu.app/posts/重放旧webhook导致部署回退/
作者
猫猫魔女
发布于
2026-06-16
许可协议
CC BY-NC-SA 4.0