有一类 GitHub Actions 故障很容易把人带错方向:
- workflow 中的大部分 Jobs 正常;
- 只有某个云环境或某组 matrix Jobs 一直
queued; - 页面显示 “Waiting for a runner to pick up this job”;
- 动态 runner 服务本身还在运行;
- 应用和集群也没有明显异常。
第一反应通常是查 runner label、云资源配额、容器启动日志,甚至直接查 Helm 和 Kubernetes。但这次真正的问题发生得更早:调度器压根没有看见那些 Jobs。
原因很朴素,也很隐蔽:GitHub Actions Jobs API 默认每页只返回 30 条,调度器只读取了第一页。
先说结论:total_count 不等于当前数组长度
GitHub Actions 的 Jobs 列表响应可以简化成:
{
"total_count": 53,
"jobs": ["...当前页的 Jobs..."]
}
这里最危险的误解是:看到 total_count=53,就以为当前 jobs 数组也包含 53 条。
实际上,如果请求没有指定分页参数,当前数组通常只有第一页的 30 条:
服务端总数: 53
当前响应数组: 30
调度器实际可见范围: 第 1-30 个 Job
目标 Job 所在位置: 第 40 多位
于是形成一种非常迷惑的现场:
- GitHub 正确生成了 Job;
- Job 正确携带动态 runner label;
- Dispatcher 轮询没有报错;
- 云平台也没有创建失败记录;
- 但 Job 永远等不到 runner。
不是创建 runner 失败,而是创建流程从未被触发。
为什么只有部分 Jobs 受影响
问题通常在 matrix workflow 扩大后出现。
例如:
strategy:
matrix:
region: [region-a, region-b, region-c]
service: [api, web, worker, scheduler]
environment: [test, stage, production]
Matrix 会在调度前展开成多个独立 Jobs。再加上 lint、test、build、security scan 和多个部署阶段,一个 workflow 很容易超过 30 个 Jobs。
如果动态 runner Jobs 恰好排在第一页,它们会正常运行;当新的 service、region 或检查项把它们推到第 31 位以后,同一套代码突然就“偶发失效”。
这也是为什么问题容易被误判成:
- 某个云环境不稳定;
- 某个 runner label 有问题;
- 某个 matrix 组合不受支持;
- 某个时间段云 API 创建资源失败。
真正决定是否触发的变量,可能只是 Job 在 API 列表中的位置。
max-parallel 为什么救不了这个问题
有人会尝试:
strategy:
max-parallel: 1
matrix:
# ...
它只能限制同时执行多少个 Jobs,不能减少 matrix 展开后的 Jobs 总数。
假设 workflow 仍然生成 53 个 Jobs,那么即使一次只运行 1 个:
- 第 1 至 30 个仍在第一页;
- 第 31 至 53 个仍在第二页;
- 只读第一页的调度器仍然看不到后面的 Jobs。
因此,max-parallel 可以降低资源峰值,但不能修复数据读取不完整。
用三层状态机定位,不要一上来查应用
动态 runner 链路至少有三个独立状态层:
GitHub Actions Job 状态
|
v
Dispatcher 扫描与匹配状态
|
v
临时 runner 资源生命周期
它们回答的是三个不同问题:
| 层次 | 核心问题 | 典型证据 |
|---|---|---|
| GitHub | Job 是否已生成并等待 runner? | status、labels、runner name、steps |
| Dispatcher | 调度器是否读取并匹配了 Job? | run、Job ID、label、分页数量日志 |
| Runner | 临时资源是否创建、注册并领取 Job? | 资源状态、注册日志、runner name |
只有 Job 获得 runner 并开始 steps 后,才应该进入第四层:部署脚本、集群和应用。
一个实用判断是:
runner_name 为空 + steps 为空
= Job 尚未开始执行
= Helm、kubectl 和应用代码不是当前第一排查对象
第一步:完整读取 Jobs
下面的 Bash 示例依赖 GitHub CLI 和 jq。gh api --paginate 会沿 GitHub 返回的 Link header 读取全部页面,--slurp 再把各页响应合并为一个 JSON 数组:
repo='<owner>/<repository>'
run_id='<workflow-run-id>'
pages="$(
gh api --paginate --slurp \
"/repos/${repo}/actions/runs/${run_id}/jobs?per_page=100"
)"
all_jobs="$(jq -c '[.[].jobs[]]' <<<"${pages}")"
total_count="$(jq -r '.[0].total_count // 0' <<<"${pages}")"
collected="$(jq 'length' <<<"${all_jobs}")"
page_count="$(jq 'length' <<<"${pages}")"
printf 'TotalCount=%s\nCollected=%s\nPages=%s\n' \
"${total_count}" "${collected}" "${page_count}"
然后加入一个不能省略的断言:
if (( collected != total_count )); then
printf 'Incomplete Jobs response: collected %s of %s\n' \
"${collected}" "${total_count}" >&2
exit 1
fi
这个断言会把“静默丢数据”变成显式故障。
没有它,调度器可能持续输出“扫描成功”,实际上每次都在处理不完整的数据。
第二步:找到目标 Job 的全局位置
完整收集后,列出等待动态 runner 的 Jobs:
dynamic_label='<dynamic-runner-label>'
jq -r --arg label "${dynamic_label}" '
["POSITION", "ID", "NAME", "RUNNER_NAME", "STEPS"],
(
to_entries[]
| select(.value.status == "queued")
| select((.value.labels // []) | index($label))
| [
(.key + 1),
.value.id,
.value.name,
(.value.runner_name // ""),
((.value.steps // []) | length)
]
)
| @tsv
' <<<"${all_jobs}"
如果所有异常 Jobs 都位于第 31 位以后,而 dispatcher 日志只出现前 30 个,因果链已经很强:
目标 Job 已由 GitHub 生成
+
Job 位于默认分页之后
+
Dispatcher 仅记录第一页
+
没有任何 runner 创建事件
=
分页遗漏导致 Job 未被调度
第三步:让调度器记录数据完整性
仅记录 “Found 53 jobs” 不够。这个数字可能只是 total_count,并不代表代码收到了 53 个对象。
每轮扫描至少应该记录:
repository
run_id
total_count
returned_count
page_count
queued_dynamic_job_count
建议直接建立不变量:
returned_count == total_count
如果不成立:
- 不应该继续把本次结果当成完整扫描;
- 应该输出 request ID 和分页信息;
- 应该产生告警;
- 不应该把读取失败解释成“没有待处理 Job”。
临时修复:per_page=100 有用,但别把它叫完整分页
最小热修复通常是:
/repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100
它能覆盖 31 至 100 个 Jobs 的常见场景,改动也很小,适合快速恢复业务。
但它有明确边界:第 101 个 Job 以后仍然会被遗漏。
因此文档和变更说明应该准确表述为:
短期:把单页上限提高到 100,恢复当前规模
长期:实现完整分页,覆盖任意合理规模
把 per_page=100 描述成“已经解决分页”会给未来留下同样的事故,只是触发阈值从 30 变成 100。
长期修复:遍历 Link,而不是猜最大数量
更稳妥的实现应遵循 GitHub 返回的 Link header,持续读取 rel="next",直到没有下一页。
伪代码如下:
func listAllJobs(ctx context.Context, firstURL string) ([]Job, error) {
jobs := make([]Job, 0)
nextURL := firstURL
totalCount := -1
for nextURL != "" {
response, err := requestJobsPage(ctx, nextURL)
if err != nil {
return nil, err
}
if totalCount < 0 {
totalCount = response.TotalCount
}
jobs = append(jobs, response.Jobs...)
nextURL = response.NextURL
}
if totalCount >= 0 && len(jobs) < totalCount {
return nil, fmt.Errorf(
"incomplete jobs response: collected %d of %d",
len(jobs), totalCount,
)
}
return jobs, nil
}
实现时还要考虑:
- 429 和 5xx 的有界重试;
Retry-After;- context timeout;
- 每页 request ID;
- 响应解码失败;
- workflow 状态在分页期间变化;
- rerun attempt 对 Job 列表语义的影响。
分页不是在 URL 后加一个参数就结束了,它是一份数据完整性契约。
别忘了另一个陷阱:去重必须允许失败后重试
动态 runner 调度器通常每隔几秒轮询一次。为了防止同一个 Job 被重复创建多个 runner,需要并发去重:
key = repository + run_id + job_id
但去重只能解决一半问题。
如果逻辑是:
- 先写入 “正在创建” key;
- 异步创建 runner;
- 创建失败;
- key 永远不删除;
那么一次瞬时失败就会变成永久不重试。
正确的状态至少要区分:
not-started -> creating -> registered -> completed
|
v
failed -> retryable
工程上需要做到:
- 创建函数返回明确的
error; - 创建失败后释放或带 TTL 保留 key;
- 成功注册后按生命周期更新状态;
- 超时任务能够被重新评估;
- 多个轮询并发时仍只能有一个创建者。
只有去重、没有失败恢复,会把“重复创建风险”换成“永久卡住风险”。
临时止血时,最重要的是避免重复部署
修复调度器后,之前 queued 的 Job 可能立刻被扫描并开始执行。
如果你同时手工触发了一个替代部署 workflow,就可能出现两个部署并行执行。
安全的止血顺序是:
- 确认原 run 和受影响 Jobs;
- 取消原 run,或明确保证目标 Jobs 不会继续执行;
- 选择一个恢复路径;
- 仅触发受影响 cloud/environment/service;
- 验证实际部署版本;
- 再部署 dispatcher 修复。
可选恢复路径包括:
- 使用已有的单环境手工 workflow;
- 由有权限的 owner 重新运行特定失败范围;
- 先部署
per_page=100热修复,再创建一个新的 run; - 在批准的变更窗口内临时使用固定 runner。
没有仓库或生产平台写权限时,不要通过替代凭证绕过边界。把证据、最小操作和回滚条件交给有权限的操作者执行。
发布成功,不等于生产正在运行新版本
动态 runner dispatcher 通常还跨越源码、镜像和运行时资源三层:
源码 commit
-> 镜像构建
-> 镜像 tag/digest
-> 部署 workflow
-> 运行中的 dispatcher image
以下证据都不能单独完成验收:
- PR 已合并;
- 镜像构建 workflow 成功;
- 部署 workflow 成功;
- IaC apply 返回成功。
最终必须从运行中的 ECI、Pod、VM 或 service 读取实际 image tag/digest,并确认:
- 版本与批准版本一致;
- 资源处于 Running/Ready;
- 重启次数正常;
- 新日志已经包含分页数量;
- 第 31 位之后的 Job 能获得 runner。
还要检查定时部署任务。一个隐藏的旧版本变量,可能在几小时后把刚修好的 dispatcher 覆盖回旧镜像。
测试应该覆盖哪些边界
分页缺陷最适合使用边界值测试,而不是只测一个常规响应。
建议最小集合:
| Jobs 数 | 目的 |
|---|---|
| 0 | 空 workflow 或过滤后无结果 |
| 1 | 最小正常响应 |
| 30 | 默认页边界 |
| 31 | 必须进入第二页的第一个场景 |
| 100 | 最大单页边界 |
| 101 | per_page=100 热修复失效点 |
| 150+ | 多页聚合与数量断言 |
同时测试:
- 第二页请求失败;
- 第二页 JSON 解码失败;
- GitHub 返回 429;
- 第一次 runner 创建失败,下轮轮询成功;
- 两次扫描同时命中同一 Job;
- workflow 在分页期间被取消;
- 已完成 Job 不再创建 runner。
一套可复用的排障顺序
遇到动态 self-hosted runner Job 长时间 queued,可以按以下顺序:
1. 看 Job 是否开始
检查 runner_name 和 steps。都为空时,暂时不要查部署步骤。
2. 完整分页读取 Jobs
比较 total_count 与收集数量,找到目标 Job 的全局位置。
3. 对照 dispatcher 日志
确认日志是否出现目标 run、Job ID 和 label,而不只是 workflow 总数。
4. 再查 runner 资源
只有 dispatcher 已发起创建,才进入云资源、注册、网络、配额和镜像排障。
5. 控制止血并发
取消原 queued run,避免替代 workflow 与自动恢复同时部署。
6. 验证运行版本
从实际运行资源读取 image tag/digest,并用第 31 位之后的 Job 做回归。
最后:把分页当成可靠性问题
这次问题表面上只是漏了一个 query 参数,背后却是一个更普遍的工程事实:
调度器对外部 API 的读取完整性,和资源创建成功率一样重要。
一个没有报错的 200 响应,不代表数据完整;一个仍在 Running 的 dispatcher,也不代表它看见了所有待处理任务。
真正可靠的实现需要同时具备:
- 完整分页;
- 数量不变量;
- 有界重试;
- 可观测的 request/page 信息;
- 并发去重;
- 失败后可恢复;
- 运行版本验收;
- 超过分页边界的真实回归。
当这些机制都存在时,“等待 runner”才不再是一条模糊提示,而会变成一条可以逐层定位、快速止血、持续验证的工程链路。