升级 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 这篇先说结论

我最后记住了几件事:

  1. Gitea Actions 里,弹性拉 runner 要看 workflow_jobqueued 事件,不是 push
  2. push 只能说明代码变了,不能说明有几个 job 在等 runner。
  3. ephemeral runner 要用支持 --ephemeralact_runner,镜像里老二进制不会因为 Gitea 升级自动变新。
  4. webhook 新事件默认不一定勾选,升级以后要主动 audit。
  5. 兜底清理逻辑如果没有 HTTP status 日志,等于没有。

2. 我们原来的 runner 是怎么跑的

环境大概是这样:

  • Git 服务:自建 Gitea。
  • CI:Gitea Actions。
  • runner:跑在 AWS ECS Fargate。
  • 触发方式:Gitea webhook 打到 Runway server,server 调 ECS RunTask 拉一个 runner task。

链路画出来是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
git push
|
v
Gitea webhook
|
v
Runway server
|
| aws ecs run-task
v
Fargate task
|
| act_runner register + daemon
v
Gitea Actions job

旧版本里,runner 不是跑完一个 job 就退出,而是启动以后硬撑 30 分钟。

1
2
timeout --kill-after=60s 1800s \
act_runner daemon || true

为什么这么设计?因为当时没有稳定的 workflow_job 事件可用,server 只能靠 push / create 这种粗粒度事件预热 runner。

一个 workflow 里可能有多个 job:

1
2
3
4
5
6
push
|
v
workflow
|- build-check
`- deploy

如果 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
2
3
4
T+00  runner daemon 启动
T+29 接到 deploy job
T+30 timeout 到点,SIGTERM
T+31 kill-after 到点,SIGKILL

如果这时候跑的是 docker build,最多是浪费时间。如果正在跑 Terraform apply 或部署后的稳定检查,排查起来就很脏。

所以改 ephemeral 不是为了追新,是旧方案真的有边界。


3. ephemeral runner 应该是什么样

Gitea 官方文档里,workflow_job 表示 workflow job 状态变化,action 包括 queuedwaitingin_progresscompleted

对弹性 runner 来说,最有用的是:

1
2
event = workflow_job
action = queued

它的含义是:现在有一个具体 job 在等 runner。

这比 push 准确太多。一个 push 可能不触发 workflow,也可能触发 1 个 job,也可能触发 5 个 job。workflow_job: queued 不猜,它就是要一个 runner。

ephemeral 的目标链路是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
workflow_job:queued
|
v
Runway server 拉一个 Fargate task
|
v
act_runner register --ephemeral
|
v
Gitea 派一个 job
|
v
job 跑完
|
v
runner 注销自己,进程退出,Fargate 释放

这才是“每个 job 一个干净 runner”。


4. 坑一:镜像里的 act_runner 太旧

我第一步改 entrypoint:

1
2
3
4
5
6
7
8
act_runner register \
--no-interactive \
--ephemeral \
--instance "${GITEA_INSTANCE_URL}" \
--token "${RUNNER_TOKEN}" \
--name "${UNIQUE_NAME}" \
--labels "hoxi-go-node-aws:host" \
--config /etc/act_runner/config.yaml

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
2
3
4
5
6
7
8
switch event {
case "workflow_job":
shouldLaunch = payload.Action == "queued"
case "push":
shouldLaunch = payload.Ref == "refs/heads/main" || payload.Ref == "refs/heads/dev"
case "create":
shouldLaunch = payload.RefType == "tag" && strings.HasPrefix(payload.Ref, "v")
}

这里保留 pushcreate,是为了兼容没有 workflow_job 的旧场景,也可以当作预热信号。

更容易被忽略的是后面这个 force

1
2
3
4
force := event == "workflow_job"
if err := s.launchRunner(repo, force); err != nil {
// ...
}

launchRunner 里,如果 force=false,会先查这个 repo 有没有 running task:

1
2
3
4
5
6
7
if !force {
n, err := a.ECSRunningTaskCount(s.cfg.RunnerCluster, startedBy)
if err == nil && n > 0 {
log.Printf("launchRunner: runner already running for %s ... skipping", repo)
return nil
}
}

这在 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
2
3
4
RUNNER_ID=$(grep -o '"id":[0-9]*' /tmp/.runner | grep -o '[0-9]*' | head -1)
curl -X DELETE \
-H "Authorization: token ${ADMIN_TOKEN}" \
"${GITEA_URL}/api/v1/admin/runners/${RUNNER_ID}"

问题是这段兜底本身也坏了。

第一,API path 已经不对,应该走:

1
/api/v1/admin/actions/runners/{id}

第二,token scope 不够时会 403。

第三,也是最要命的,脚本没把 HTTP status 打出来。404 也好,403 也好,最后都沉默了。Fargate task 退出了,Gitea 里 runner 还挂着,下一次再注册一个新的。

现在 entrypoint 至少会打印 status:

1
2
3
4
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${GITEA_ADMIN_TOKEN}" \
"${GITEA_INSTANCE_URL}/api/v1/admin/actions/runners/${RUNNER_ID}")
echo "Deregistered runner ID ${RUNNER_ID} (HTTP ${HTTP_STATUS})."

这还不算彻底。更严谨一点,非 204 应该把响应 body 打出来,并且用更明确的日志说“deregister failed”。但至少现在不会再完全静默。


8. 最后改成了什么样

runner entrypoint 现在是:

1
2
3
4
5
6
7
8
9
10
11
12
13
UNIQUE_NAME="ecs-liuwei-runner-$(date +%s)"

act_runner register \
--no-interactive \
--ephemeral \
--instance "${GITEA_INSTANCE_URL}" \
--token "${RUNNER_TOKEN}" \
--name "${UNIQUE_NAME}" \
--labels "hoxi-go-node-aws:host" \
--config /etc/act_runner/config.yaml

timeout --kill-after=60s 7200s \
act_runner daemon --config /etc/act_runner/config.yaml || true

外层 timeout 还在,但含义变了。它不再控制 runner 寿命,只是防止进程卡死。正常情况下,ephemeral runner 跑完一个 job 会自己退出。

server 侧也不是盲目拉 task。它先做几层检查:

  1. HMAC 签名校验。
  2. X-Gitea-Delivery 去重,避免同一个 webhook 重放。
  3. repo 必须在 token registry 里,相当于 allowlist。
  4. runner ECS 配置必须齐全。
  5. 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
2
3
4
5
Registering runner ...
Starting act_runner daemon (ephemeral, one-job-then-exit)...
act_runner stopped, deregistering...
Deregistered runner ID ... (HTTP 204).
act_runner exiting.

第四,看 Gitea runner 列表。job 跑完以后,ephemeral runner 不应该长期以 offline 状态留在那里。

清僵尸 runner 的脚本可以这样写:

1
2
3
4
5
6
for id in $(curl -s -H "Authorization: token $T" \
"https://git.example.com/api/v1/admin/actions/runners?limit=200" \
| jq '.runners[] | select(.status == "offline") | .id'); do
curl -X DELETE -H "Authorization: token $T" \
"https://git.example.com/api/v1/admin/actions/runners/$id"
done

这一步做完以后,再跑几次 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,不能继续装作主路径的一部分。