GitHub Actions 流水线可观测性与 DORA 度量

GitHub Actions 流水线可观测性与 DORA 度量实战:部署频率与变更前置时间与变更失败率与恢复时长的取数口径、用 gh api 与 workflow_run 拉取运行数据、作业级耗时分解与关键路径识别、缓存命中率与排队时间、JUnit 报告归档、Grafana 趋势看板、flaky 测试识别、concurrency 与 timeout-minutes 治理


一、为什么流水线需要度量

1.1 没有度量的三种典型症状

症状 1:所有人都觉得 CI 慢,但没人知道慢在哪
  缺失的数据:作业级耗时、缓存命中率、排队时间

症状 2:优化了三个月,主观感觉变快了,实际没有
  缺失的数据:关键路径识别、作业依赖图

症状 3:稳定性问题反复出现,修了又坏
  缺失的数据:按测试用例维度的失败率统计

1.2 DORA 四指标

部署频率 Deployment Frequency
  定义:单位时间内成功发布到生产的次数
  取数:生产部署 workflow 的成功运行次数 / 时间窗口
  口径陷阱:把预发部署也算进去会让数字虚高

变更前置时间 Lead Time for Changes
  定义:从代码提交到该变更在生产运行的时间
  取数:生产部署运行时间减去对应 commit 的提交时间
  口径陷阱:需要建立「部署 run 对应哪个 commit」的映射

变更失败率 Change Failure Rate
  定义:导致生产降级并需要补救的部署比例
  取数:(回滚次数 + 热修次数) / 总部署次数
  口径陷阱:什么算「失败」要事先定义清楚

恢复时长 MTTR
  定义:从生产故障发生到恢复服务的时间
  口径陷阱:依赖事件记录,需要与告警系统打通

1.3 度量落地的前提

1) 部署必须走流水线,手工部署无法取数
2) 制品与 commit 必须可关联,镜像 tag 用 sha 短哈希或写入 provenance
3) 事件必须被记录,回滚、热修、故障都要在系统里留痕
4) 度量用于改进而非考核,一旦与绩效挂钩,数据必然被美化

二、从 GitHub API 拉取运行数据

2.1 运行级数据

# 列出某 workflow 最近的运行
gh api "/repos/my-org/my-repo/actions/workflows/ci.yml/runs?per_page=100" \
  --jq '.workflow_runs[] | "\(.id) \(.event) \(.conclusion) \(.created_at)"'

# 统计最近 100 次运行的成功率
gh api "/repos/my-org/my-repo/actions/workflows/ci.yml/runs?per_page=100" \
  --jq '[.workflow_runs[].conclusion] | group_by(.) | map({(.[0]): length}) | add'

# 单次运行的总耗时(秒)
gh api "/repos/my-org/my-repo/actions/runs/123456789" \
  --jq '(.updated_at | fromdate) - (.created_at | fromdate)'

2.2 作业级与步骤级耗时

# 汇总某次运行中各作业耗时并排序
gh api "/repos/my-org/my-repo/actions/runs/123456789/jobs?per_page=100" \
  --jq '[.jobs[] | {
          name: .name,
          secs: ((.completed_at|fromdate) - (.started_at|fromdate))
        }] | sort_by(-.secs) | .[] | "\(.secs)s\t\(.name)"'
# 定位单次运行中最慢的步骤
gh api "/repos/my-org/my-repo/actions/runs/123456789/jobs?per_page=100" \
  --jq '[.jobs[].steps[] | {
          name: .name,
          secs: ((.completed_at|fromdate) - (.started_at|fromdate))
        }] | sort_by(-.secs) | .[0:10][] | "\(.secs)s\t\(.name)"'

步骤级耗时能回答:是 checkout 慢还是依赖安装慢,是测试本身慢还是测试环境准备慢,缓存到底有没有生效。

2.3 在 workflow 中采集并归档

name: Metrics collection

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]

permissions:
  actions: read
  contents: read

jobs:
  collect:
    runs-on: ubuntu-latest
    steps:
      - name: Collect run metrics
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RUN_ID: ${{ github.event.workflow_run.id }}
        run: |
          gh api "/repos/${{ github.repository }}/actions/runs/$RUN_ID/jobs?per_page=100" > jobs.json
          jq -r --arg run "$RUN_ID" '
            .jobs[] | [$run, .name, .conclusion, .started_at, .completed_at,
              (((.completed_at|fromdate) - (.started_at|fromdate))|tostring)] | @csv
          ' jobs.json > metrics.csv
          cat metrics.csv

      - name: Push metrics
        run: ./scripts/push-to-prometheus.sh metrics.csv
为什么用 workflow_run 而非在 CI 内部采集
  1) 采集本身不影响被观测流水线的耗时
  2) 能拿到完整结论,包括被取消的运行
  3) 失败时采集逻辑仍然执行(CI 内部失败会中断后续步骤)
  4) 可以统一处理所有 workflow,无需逐个改动

三、作业耗时分解与关键路径

3.1 关键路径的定义

流水线总时长 != 所有作业耗时之和
流水线总时长 == 依赖图上最长的一条路径

示例
  lint        2 min
  test-a      8 min
  test-b      6 min
  build       3 min(依赖 lint 与 test-a 与 test-b)
  deploy      2 min(依赖 build)

关键路径:lint → test-a → build → deploy = 2 + 8 + 3 + 2 = 15 min
优化 test-b 从 6 min 降到 3 min,总时长仍然是 15 min,收益为零

3.2 用 needs 显式表达依赖

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  test-a:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:a

  test-b:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run test:b

  build:
    needs: [lint, test-a, test-b]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/deploy.sh
依赖设计原则
  1) 能并行的一律并行,用矩阵或独立 job
  2) 只有真正需要产物的才用 needs
  3) 避免「漏斗型」依赖:所有 job 都 needs 一个慢 job
  4) 用 needs 与 if 组合做条件跳过

3.3 用矩阵摊平耗时

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test -- --shard=${{ matrix.shard }}/4
收益计算
  单 shard 8 min,4 shard 后约 2.5 min(含启动开销),收益 5.5 min
  代价:4 倍 runner 分钟数(约 10 min 计费)
  结论:对关键路径上的长作业值得,对非关键路径不值得

四、缓存命中率与排队时间

4.1 缓存命中率

- name: Cache dependencies
  id: cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

- name: Report cache result
  run: echo "cache-hit=${{ steps.cache.outputs.cache-hit }}" >> "$GITHUB_STEP_SUMMARY"
gh api "/repos/my-org/my-repo/actions/caches" \
  --jq '.actions_caches[] | "\(.key) \(.size_in_bytes) \(.last_accessed_at)"'
gh api "/repos/my-org/my-repo/actions/caches" --jq '[.actions_caches[].size_in_bytes] | add'
命中率低的常见原因
  1) key 里带了 github.sha 或 run_id,导致每次 key 都不同
  2) hashFiles 指向了频繁变动的文件(如整个 src 目录)
  3) restore-keys 缺失,前缀匹配无法回退
  4) 缓存被 10GB 上限淘汰(仓库级总量限制)
  5) 不同 runner 架构混用(arm64 与 amd64 缓存不通用)

4.2 排队时间

# 排队时间 = started_at - created_at
gh api "/repos/my-org/my-repo/actions/runs/$RUN_ID/jobs?per_page=100" \
  --jq '.jobs[] | {
          name: .name,
          queue: ((.started_at|fromdate) - (.created_at|fromdate))
        } | "\(.queue)s\t\(.name)"'
排队时间长的原因
  1) 自托管 runner 数量不足,job 一直 queued
  2) runner group 可见性配置错误,job 永远拿不到机器
  3) 并发上限被组织级配额限制
  4) 高峰时段大量 PR 同时触发
concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
concurrency 的语义
  group               同一 group 内同时只允许一个运行
  cancel-in-progress  新运行到来时取消组内正在跑的旧运行
  注意:对 main 分支的发布流水线慎用 cancel-in-progress,
        取消一个正在部署的运行可能留下半完成状态

4.3 超时与挂死

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1
      - name: Run tests
        timeout-minutes: 15
        run: npm test

默认 6 小时上限的问题在于,一个挂死的测试会占用 runner 6 小时,既浪费分钟数又阻塞其他 job 的调度。经验值是设为历史 P99 耗时的 1.5 到 2 倍。


五、测试报告归档与 JUnit 解析

5.1 生成并上传 JUnit 报告

- name: Run tests
  run: npm test -- --reporter=jest-junit --outputFile=junit.xml
  continue-on-error: true

- name: Upload test results
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: junit-results
    path: junit.xml
    retention-days: 30

关键点是 if: always():测试失败时若不加它,上传步骤会被跳过,恰恰在失败时丢掉了最需要的报告。

5.2 解析并写入 Summary

xmllint --xpath 'sum(//testsuite/@tests)' junit.xml
xmllint --xpath 'sum(//testsuite/@failures)' junit.xml
xmllint --xpath '//testcase[failure]/@name' junit.xml
- name: Summarize test results
  if: always()
  run: |
    total=$(xmllint --xpath 'sum(//testsuite/@tests)' junit.xml 2>/dev/null || echo 0)
    failures=$(xmllint --xpath 'sum(//testsuite/@failures)' junit.xml 2>/dev/null || echo 0)
    {
      echo "### 测试结果"
      echo "| 指标 | 数量 |"
      echo "| --- | --- |"
      echo "| 总数 | $total |"
      echo "| 失败 | $failures |"
    } >> "$GITHUB_STEP_SUMMARY"
- name: Test report
  uses: dorny/test-reporter@v1
  if: always()
  with:
    name: JUnit Tests
    path: junit.xml
    reporter: java-junit
    fail-on-error: false

覆盖率归档的价值不在绝对值,而在趋势:覆盖率突然下降通常意味着新增代码没写测试,缓慢下降则是技术债累积的早期信号。


六、构建趋势看板

6.1 推送到 Prometheus

#!/usr/bin/env bash
# scripts/push-to-prometheus.sh
set -euo pipefail

PUSHGATEWAY="${PUSHGATEWAY:-http://prometheus-pushgateway.monitoring:9091}"

while IFS=, read -r run_id job_name conclusion started completed secs; do
  job_name_clean=$(echo "$job_name" | tr -d '"')
  cat <<EOF | curl --data-binary @- "${PUSHGATEWAY}/metrics/job/gha/instance/${job_name_clean}"
# TYPE gha_job_duration_seconds gauge
gha_job_duration_seconds{job="${job_name_clean}",conclusion="${conclusion}"} ${secs}
EOF
done < metrics.csv
用 Pushgateway 的注意事项
  1) Pushgateway 不会自动过期指标,需要定期清理或设置 TTL
  2) 更适合短生命周期任务的汇总指标,不适合高频时序
  3) 大规模场景建议改用 OTLP 直推到远端写或 Loki

6.2 推荐的核心指标

ci_run_duration_seconds{workflow,event,conclusion}      运行总时长
ci_job_duration_seconds{workflow,job,conclusion}        作业耗时
ci_job_queue_seconds{workflow,job}                      排队时间
ci_cache_hit_total{workflow,scope,result}               缓存命中计数
ci_run_total{workflow,event,conclusion}                 运行计数
ci_test_total{workflow,suite,result}                    测试用例计数
ci_deploy_total{service,env,result}                     部署计数

6.3 Grafana 面板与告警

面板 1:DORA 四指标(stat 面板)
  部署频率、前置时间中位数与 P90、变更失败率、恢复时长中位数

面板 2:流水线时长趋势(timeseries)
  主线:P50 与 P90 总时长;副线:关键路径各作业耗时堆叠

面板 3:成功率(stat + timeseries)
  按 workflow 分组的成功率,失败原因分类饼图

面板 4:缓存与排队(timeseries)
  缓存命中率曲线、排队时间 P90 曲线

面板 5:Top 慢作业(table)
  按 P90 耗时排序,点击可跳转到对应运行日志
groups:
  - name: github-actions
    rules:
      - alert: CIPipelineSlow
        expr: |
          histogram_quantile(0.9,
            sum(rate(ci_run_duration_seconds_bucket[1h])) by (le, workflow)
          ) > 1800
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.workflow }} 的 P90 时长超过 30 分钟"

      - alert: CIFailureRateHigh
        expr: |
          sum(rate(ci_run_total{conclusion="failure"}[1h]))
          / sum(rate(ci_run_total[1h])) > 0.2
        for: 1h
        labels:
          severity: critical
        annotations:
          summary: "CI 失败率超过 20%"

告警设计原则:阈值基于历史 P90 而非拍脑袋,用 for 持续时间避免抖动误报,告警要指向可行动的原因,且每个告警都要有对应的处理手册。


七、失败率归因与 flaky 测试

7.1 失败分类

分类维度
  1) 代码问题       真实缺陷导致的测试失败
  2) flaky 测试     同一 commit 重跑会通过
  3) 基础设施问题   runner 掉线、网络超时、registry 限流
  4) 配置问题       缓存 key 错误、secret 缺失
  5) 外部依赖       npm registry 抖动、云 API 限流

取数方式
  代码问题      失败在 PR 与 main 上稳定复现
  flaky         同一 commit 上重跑结果不同
  基础设施      失败集中在某类 runner 或某时间段
  配置          首次运行必失败,修配置后消失

7.2 识别 flaky 测试

# 找出同一 commit 上结论不一致的运行
gh api "/repos/my-org/my-repo/actions/runs?head_sha=$SHA" \
  --jq '.workflow_runs[] | "\(.id) \(.conclusion)"'
- name: Detect flaky by rerun
  run: |
    npm test -- --onlyFailures --reporter=junit --outputFile=rerun.xml || true
    if grep -q '<failure' rerun.xml; then
      echo "::error::测试在重跑后仍然失败,判定为真实失败"
      exit 1
    else
      echo "::warning::测试在重跑后通过,判定为 flaky"
    fi
flaky 治理策略
  1) 统计每个用例的历史失败率,形成 flaky 排行榜
  2) 失败率超过阈值的用例强制隔离并创建 issue
  3) 隔离不等于删除:用独立 job 跑,不阻塞主流水线
  4) 禁止用无条件 retry 掩盖 flaky,否则问题永久潜伏
- name: Run integration tests
  uses: nick-fields/retry@v3
  with:
    timeout_minutes: 15
    max_attempts: 3
    retry_on: timeout
    command: npm run test:integration
retry_on 的正确取值
  timeout     仅网络类超时重试(推荐)
  error       任何错误都重试(会掩盖真实缺陷,慎用)

判断标准
  如果重试后能稳定通过 → 说明是不稳定因素,应治理而非重试
  如果重试后仍失败     → 说明是真实问题,重试只是浪费分钟数

八、度量驱动的改进闭环

8.1 闭环的四个步骤

1) 采集  统一采集全部 workflow 的运行与作业数据,落到时序库
2) 分析  识别关键路径上的瓶颈、缓存命中率洼地、flaky 排行榜
3) 改进  每次只改一个变量,并记录改动前后的对比窗口
4) 验证  用同一指标验证收益,写入改进日志,避免重复优化同一处

8.2 一次真实的优化过程

第 0 周基线
  总时长 P50 = 18 min,P90 = 27 min
  关键路径:lint(2) → test(12) → build(3) → deploy(2) = 19 min
  缓存命中率 42%

第 1 周:修缓存 key
  把 key 中的 github.sha 去掉,改为 hashFiles('package-lock.json')
  结果:命中率 42% → 91%,test 从 12 min 降到 7 min,总时长 18 → 13 min

第 2 周:测试分片
  test 拆成 4 个 shard,从 7 min 降到 2.5 min,总时长 13 → 8.5 min

第 3 周:治理 flaky
  隔离 6 个高频 flaky 用例,失败率 23% → 9%

第 4 周:收紧超时
  job.timeout-minutes 从 360 改为 25,排队时间 P90 从 90s 降到 20s

8.3 反模式

反模式 1:只优化非关键路径
  把 lint 从 2 min 优化到 1 min,总时长不变。先画依赖图。

反模式 2:无限增加并行度
  分片从 4 增到 16,收益从 5.5 min 降到 1 min,计费翻倍。

反模式 3:用缓存掩盖依赖安装问题
  缓存命中率 100% 但内容是错误的依赖版本,
  会在某次 cache miss 时突然暴雷。缓存要能随时冷启动成功。

反模式 4:把度量当 KPI
  一旦失败率与绩效挂钩,就会出现大量无意义的空跑与重跑。

反模式 5:只看均值不看分位
  平均 8 分钟,P90 却有 40 分钟。工程体感由 P90 决定。

8.4 度量体系检查清单

采集层
  [ ] 全部 workflow 的运行与作业数据已采集
  [ ] 作业级与步骤级耗时均可查询
  [ ] 排队时间与缓存命中率已采集
  [ ] JUnit 报告已归档并解析出用例级结果

分析层
  [ ] 有 DORA 四指标的看板,口径已书面定义
  [ ] 有关键路径可视化与 Top 慢作业排行
  [ ] 有 flaky 用例排行榜

治理层
  [ ] 所有 job 设置了 timeout-minutes
  [ ] 长流水线配置了 concurrency
  [ ] 缓存 key 与 restore-keys 设计合理
  [ ] flaky 用例有隔离机制与跟踪 issue

闭环层
  [ ] 每次优化记录改动前后的对比数据
  [ ] 定期复盘度量趋势
  [ ] 度量不用于个人考核

总结

流水线可观测性的起点是把「感觉慢」变成「知道慢在哪」。DORA 四指标提供了外部视角,即部署频率、变更前置时间、变更失败率、恢复时长,但每个指标都必须先写清楚取数口径,否则数字只会互相矛盾。内部视角则要靠作业级与步骤级耗时、排队时间、缓存命中率这三组数据,它们能直接定位到具体是哪一步拖慢了关键路径。工程手段上,concurrency 掐掉冗余运行、timeout-minutes 兜住挂死、缓存 key 去掉 github.sha 保住命中率、测试分片摊平长作业,这些都是投入产出比极高的改动。失败率方面要区分真实失败与 flaky,用重跑识别、用隔离治理,绝不用无条件重试掩盖。最后把数据接到 Grafana 与告警规则上,形成采集、分析、改进、验证的闭环,流水线才会随着时间推移持续变快,而不是在一次次局部优化后原地打转。

延伸阅读:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「github-actions」更多文章

  1. 多云部署编排与基础设施漂移检测
  2. 文档站与静态站点发布流水线
  3. AI 代码审查与 PR 助手集成