升级 Gitea 之后的半天踩坑
1. 以为是 5 分钟升级,最后花了半天
这次升级 Gitea,我原本只想顺手把自建 runner 改成 ephemeral 模式。
预期很简单:Gitea 升到 1.26,act_runner register 加一个 --ephemeral,runner 跑完一个 job 自动退出。这样就不用再让 Fargate task 空等 30 分钟。
实际不是。
先是 runner 启不来,接着 job 卡在 queued,再接着发现 webhook 没发对事件,最后翻出两百多个 offline runner。问题单个看都不大,串起来就吃掉半天。
1.1 这篇先说结论
我最后记住了几件事:
- Gitea Actions 里,弹性拉 runner 要看
workflow_job的queued事件,不是push。 push只能说明代码变了,不能说明有几个 job 在等 runner。- ephemeral runner 要用支持
--ephemeral的act_runner,镜像里老二进制不会因为 Gitea 升级自动变新。 - webhook 新事件默认不一定勾选,升级以后要主动 audit。
- 兜底清理逻辑如果没有 HTTP status 日志,等于没有。
2. 我们原来的 runner 是怎么跑的
环境大概是这样:
- Git 服务:自建 Gitea。
- CI:Gitea Actions。
- runner:跑在 AWS ECS Fargate。
- 触发方式:Gitea webhook 打到 Runway server,server 调 ECS
RunTask拉一个 runner task。
链路画出来是:
1 | git push |
旧版本里,runner 不是跑完一个 job 就退出,而是启动以后硬撑 30 分钟。
1 | timeout --kill-after=60s 1800s \ |
为什么这么设计?因为当时没有稳定的 workflow_job 事件可用,server 只能靠 push / create 这种粗粒度事件预热 runner。
一个 workflow 里可能有多个 job:
1 | push |
如果 runner 跑完第一个 job 就退出,第二个 job 进入 queued 时,server 未必会收到新的精确信号。于是只能让 runner 多活一阵,赌后续 job 会被同一个 runner 接走。
这个设计能跑,但很粗。
2.1 30 分钟保活有两个问题
第一个问题是浪费钱。
一个 job 跑 5 分钟,Fargate task 还要继续空等 25 分钟。一天几次部署,一年算下来也不是天价,但这笔钱完全没必要花。
第二个问题更烦:timeout 1800s 是总寿命,不是空闲超时。
也就是说,runner 可能在第 29 分钟接到一个 10 分钟的 job,然后第 30 分钟被 timeout 砍掉。
1 | T+00 runner daemon 启动 |
如果这时候跑的是 docker build,最多是浪费时间。如果正在跑 Terraform apply 或部署后的稳定检查,排查起来就很脏。
所以改 ephemeral 不是为了追新,是旧方案真的有边界。
3. ephemeral runner 应该是什么样
Gitea 官方文档里,workflow_job 表示 workflow job 状态变化,action 包括 queued、waiting、in_progress、completed。
对弹性 runner 来说,最有用的是:
1 | event = workflow_job |
它的含义是:现在有一个具体 job 在等 runner。
这比 push 准确太多。一个 push 可能不触发 workflow,也可能触发 1 个 job,也可能触发 5 个 job。workflow_job: queued 不猜,它就是要一个 runner。
ephemeral 的目标链路是:
1 | workflow_job:queued |
这才是“每个 job 一个干净 runner”。
4. 坑一:镜像里的 act_runner 太旧
我第一步改 entrypoint:
1 | act_runner register \ |
rebuild、push、触发 workflow。Fargate task 很快退出。
CloudWatch 里看到:
1 | Error: unknown flag: --ephemeral |
这里的问题很低级:Gitea server 升级了,不代表 runner 镜像里的 act_runner 二进制也升级了。
我们镜像里是之前手动放进去的二进制。它支持哪些 flag,只取决于这个文件本身,不取决于 server 版本。
修法就是换掉镜像里的 act_runner,然后重新 build runner 镜像。这里我后来会加一个习惯:entrypoint 启动时先打印 act_runner --version。不然以后再遇到 flag 不兼容,第一眼还是猜。
5. 坑二:Gitea 没发 workflow_job
runner 镜像修完以后,job 还是卡在 queued。
Gitea UI 上能看到类似这样的提示:
1 | 没有匹配 hoxi-go-node-aws 标签的在线运行器 |
去看 Runway server 的 webhook log,只有 push:
1 | webhook: push to refs/heads/dev (...) - launching act_runner ECS task |
没有我想要的:
1 | webhook: workflow_job queued for ... - launching act_runner ECS task |
最后发现不是代码没处理,而是 Gitea webhook 配置里没勾 “工作流任务”。
Gitea UI 里两个名字很容易看混:
| UI 里的名字 | event | 用途 |
|---|---|---|
| 工作流运行 | workflow_run | 整个 workflow 的状态变化 |
| 工作流任务 | workflow_job | 单个 job 的状态变化 |
弹性 runner 要的是第二个。
这个坑给我的提醒很直接:升级后不要只看 server 版本,也要看 webhook 订阅项。新事件默认没勾,老 handler 写得再对也收不到。
6. 坑三:旧的 push fallback 还在影响判断
Runway 的 webhook handler 现在同时支持三类事件:
1 | switch event { |
这里保留 push 和 create,是为了兼容没有 workflow_job 的旧场景,也可以当作预热信号。
更容易被忽略的是后面这个 force:
1 | force := event == "workflow_job" |
launchRunner 里,如果 force=false,会先查这个 repo 有没有 running task:
1 | if !force { |
这在 push fallback 时代是合理的:连续 push 不要重复拉太多 runner。
但在 workflow_job 时代,“已有 runner 在跑”不能说明这个 queued job 有人接。那个 runner 可能已经被前一个 job 占住了,尤其是 capacity=1 的时候。
所以 workflow_job: queued 必须 force=true,每个 queued job 都拉一个新 runner。
我当时排查时最容易被这个点绕住:看到 server 确实“拉过 runner”,就以为 runner 数够了。其实 push 拉的是预热 runner,不等于每个 job 都有自己的 runner。
7. 坑四:两百多个 offline runner
前面都修完以后,我去 Gitea admin 页面看 runner 列表,发现一堆 offline runner。
原稿里记的是 215 个。看到这个数字的时候很不舒服,因为它说明清理逻辑不是刚坏,是坏了很久。
旧 entrypoint 末尾其实有兜底注销:
1 | RUNNER_ID=$(grep -o '"id":[0-9]*' /tmp/.runner | grep -o '[0-9]*' | head -1) |
问题是这段兜底本身也坏了。
第一,API path 已经不对,应该走:
1 | /api/v1/admin/actions/runners/{id} |
第二,token scope 不够时会 403。
第三,也是最要命的,脚本没把 HTTP status 打出来。404 也好,403 也好,最后都沉默了。Fargate task 退出了,Gitea 里 runner 还挂着,下一次再注册一个新的。
现在 entrypoint 至少会打印 status:
1 | HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \ |
这还不算彻底。更严谨一点,非 204 应该把响应 body 打出来,并且用更明确的日志说“deregister failed”。但至少现在不会再完全静默。
8. 最后改成了什么样
runner entrypoint 现在是:
1 | UNIQUE_NAME="ecs-liuwei-runner-$(date +%s)" |
外层 timeout 还在,但含义变了。它不再控制 runner 寿命,只是防止进程卡死。正常情况下,ephemeral runner 跑完一个 job 会自己退出。
server 侧也不是盲目拉 task。它先做几层检查:
- HMAC 签名校验。
X-Gitea-Delivery去重,避免同一个 webhook 重放。- repo 必须在 token registry 里,相当于 allowlist。
- runner ECS 配置必须齐全。
workflow_job强制拉新 runner,push/create只做预热。
这套链路比旧版复杂一点,但每一步都有明确原因。
9. 我会怎么验证
这类问题不能靠“看起来配置对了”收工。我会按几个信号验证。
第一,看 webhook log。触发一次 workflow 后,必须看到:
1 | webhook: workflow_job queued for owner/repo - launching act_runner ECS task |
只看到 push 不够。
第二,看 ECS task 数。一个 queued job 应该至少对应一个新 task。多 job 并行时,不能因为已有 task running 就全部 skip。
第三,看 runner 日志。应该能看到:
1 | Registering runner ... |
第四,看 Gitea runner 列表。job 跑完以后,ephemeral runner 不应该长期以 offline 状态留在那里。
清僵尸 runner 的脚本可以这样写:
1 | for id in $(curl -s -H "Authorization: token $T" \ |
这一步做完以后,再跑几次 workflow,runner 数量不应该继续涨。
10. 给后来人的检查清单
如果以后还要在 ECS Fargate 或 Kubernetes 上跑 Gitea self-hosted runner,我会先检查这些:
- Gitea webhook 是否勾了“工作流任务”,也就是
workflow_job。 - handler 是否只在
action == "queued"时拉 runner。 workflow_job路径是否强制拉新 runner,不被“已有 runner running”短路。- runner 镜像启动时是否打印
act_runner版本。 - runner 是否用
--ephemeral注册。 - 外层 timeout 是否只是兜底,不要拿它控制 job 生命周期。
- 兜底 deregister 是否使用
/api/v1/admin/actions/runners/{id}。 - 删除 runner 的 token 是否有 admin 权限。
- 所有 curl 是否打印 HTTP status,失败时是否打印响应 body。
- 是否定期检查 offline runner 数量。
这不是通用标准,只是这次踩出来的最小清单。
11. 这次学到的
这次最浪费时间的地方,不是某个 bug 难,而是几个旧假设同时失效。
以前没有 workflow_job,所以用 push 预热 runner。以前 runner 不是 ephemeral,所以做了 30 分钟保活。以前手写了兜底注销,但没人看过它到底返回 204、404 还是 403。
升级以后,主路径变了,旧的兜底、去重、清理逻辑都要重新审一遍。否则最容易出现这种情况:新功能已经打开了,旧逻辑还在旁边悄悄拖后腿。
我以后再做这种升级,会先列一张表:哪些逻辑是为了旧限制存在的。限制消失以后,这些逻辑要么删掉,要么明确降级成 fallback,不能继续装作主路径的一部分。