不改 GitHub Actions 工作流,用分布式追踪看清排队、慢任务与不稳定构建

2026-09-08 29 预计阅读时间: 1 分钟
来源: cncf.io AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

GitHub Actions 在组织里铺开后,账单通常比可观测性增长得更快。团队能看到某次构建失败,却很难立即回答:时间消耗在排队还是执行?哪个 job 长期拖慢流水线?失败后重跑成功的概率有多高?

解决这类问题不一定要给每个仓库修改 workflow YAML。更低侵入的做法,是从 GitHub 的控制面采集 workflow_run Webhook,再通过 Actions API 补齐 job 和 step 数据,最后把一次流水线转换成一条分布式追踪。

把一次流水线映射成一条 Trace

可以采用下面的模型:

CI 对象 追踪对象 关键属性
Workflow run Trace 根 Span repository、workflow、run ID、commit、attempt
Job 子 Span job name、runner labels、conclusion
Queue time Job 下的子 Span created_at 到 started_at
Step Job 下的子 Span step name、number、conclusion

这种映射保留了时间轴和父子关系。打开一条 trace 时,可以直接看到多个 job 是串行还是并行,以及真正拖慢流水线的是排队、环境准备,还是某个具体步骤。

采集服务位于 workflow 之外:

GitHub Webhook
      │ workflow_run.completed
      ▼
CI Trace Receiver ──调用 Actions Jobs API──▶ GitHub API
      │
      │ OTLP traces
      ▼
OpenTelemetry Collector / Trace Backend

因此,不需要向数百个仓库复制 action,也不会因为业务团队重构 YAML 而丢失埋点。生产环境通常会把接收器实现成组织级 GitHub App;原型阶段也可以使用只读、细粒度访问令牌。

一个可运行的 Webhook 到 OTLP 示例

下面是一个最小实现。它监听已完成的 workflow_run 事件,获取该 run 的全部 jobs,然后生成 workflow、job、排队和 step spans。

运行前需要准备:

  • 一个 GitHub Webhook secret;
  • 可读取目标仓库 Actions 数据的令牌,生产环境建议换成 GitHub App installation token;
  • 一个支持 OTLP/HTTP 的 Collector 或追踪后端;
  • 在 GitHub Webhook 中订阅 workflow_run 事件,并把接收地址指向公开的 HTTPS /github/webhook

安装依赖:

python -m venv .venv
. .venv/bin/activate
pip install flask requests opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp-proto-http

保存为 app.py

import hashlib
import hmac
import os
from datetime import datetime

import requests
from flask import Flask, abort, request
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.trace import Status, StatusCode

app = Flask(__name__)

provider = TracerProvider(
    resource=Resource.create({"service.name": "github-actions-trace-receiver"})
)
provider.add_span_processor(
    SimpleSpanProcessor(
        OTLPSpanExporter(
            endpoint=os.environ.get(
                "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
                "http://localhost:4318/v1/traces",
            )
        )
    )
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("ci.github-actions")


def timestamp_ns(value):
    return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1e9)


def verify_signature(body, signature):
    secret = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
    expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")


def apply_status(span, conclusion):
    if conclusion == "success":
        span.set_status(Status(StatusCode.OK))
    elif conclusion in {"failure", "timed_out", "startup_failure"}:
        span.set_status(Status(StatusCode.ERROR, conclusion))


def list_jobs(repository, run_id):
    token = os.environ["GITHUB_TOKEN"]
    url = f"https://api.github.com/repos/{repository}/actions/runs/{run_id}/jobs"
    headers = {
        "Accept": "application/vnd.github+json",
        "Authorization": f"Bearer {token}",
        "X-GitHub-Api-Version": "2022-11-28",
    }
    jobs = []
    params = {"per_page": 100}

    while url:
        response = requests.get(url, headers=headers, params=params, timeout=20)
        response.raise_for_status()
        jobs.extend(response.json()["jobs"])
        url = response.links.get("next", {}).get("url")
        params = None

    return jobs


@app.post("/github/webhook")
def github_webhook():
    body = request.get_data()
    if not verify_signature(body, request.headers.get("X-Hub-Signature-256")):
        abort(401)

    if request.headers.get("X-GitHub-Event") != "workflow_run":
        return {"ignored": True}

    payload = request.get_json()
    if payload.get("action") != "completed":
        return {"ignored": True}

    run = payload["workflow_run"]
    repository = payload["repository"]["full_name"]
    jobs = list_jobs(repository, run["id"])

    run_span = tracer.start_span(
        "github.workflow_run",
        start_time=timestamp_ns(run["created_at"]),
        attributes={
            "github.repository": repository,
            "github.workflow.name": run["name"],
            "github.run.id": run["id"],
            "github.run.attempt": run.get("run_attempt", 1),
            "github.head.sha": run["head_sha"],
            "github.event": run["event"],
            "github.conclusion": run["conclusion"],
        },
    )
    run_context = trace.set_span_in_context(run_span)

    for job in jobs:
        job_span = tracer.start_span(
            f"github.job.{job['name']}",
            context=run_context,
            start_time=timestamp_ns(job["created_at"]),
            attributes={
                "github.job.id": job["id"],
                "github.job.name": job["name"],
                "github.job.conclusion": job.get("conclusion") or "unknown",
                "github.runner.labels": ",".join(job.get("labels", [])),
            },
        )
        job_context = trace.set_span_in_context(job_span)

        if job.get("started_at"):
            queue_span = tracer.start_span(
                "github.job.queue",
                context=job_context,
                start_time=timestamp_ns(job["created_at"]),
            )
            queue_span.end(end_time=timestamp_ns(job["started_at"]))

        for step in job.get("steps", []):
            if not step.get("started_at") or not step.get("completed_at"):
                continue
            step_span = tracer.start_span(
                f"github.step.{step['name']}",
                context=job_context,
                start_time=timestamp_ns(step["started_at"]),
                attributes={
                    "github.step.number": step["number"],
                    "github.step.conclusion": step.get("conclusion") or "unknown",
                },
            )
            apply_status(step_span, step.get("conclusion"))
            step_span.end(end_time=timestamp_ns(step["completed_at"]))

        apply_status(job_span, job.get("conclusion"))
        job_span.end(end_time=timestamp_ns(job["completed_at"]))

    apply_status(run_span, run.get("conclusion"))
    run_span.end(end_time=timestamp_ns(run["updated_at"]))
    return {"accepted": True, "jobs": len(jobs)}


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

配置并启动:

export GITHUB_WEBHOOK_SECRET='replace-with-your-webhook-secret'
export GITHUB_TOKEN='replace-with-a-read-only-token'
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://localhost:4318/v1/traces'
python app.py

示例使用 SimpleSpanProcessor,便于在 Webhook 请求结束前完成导出。吞吐量较大时,应改用 BatchSpanProcessor,并将接收器部署成持续运行的服务。

从 Trace 回答运营问题

有了 spans,还需要把问题转换成稳定的查询口径。

流水线为什么慢:比较 workflow 总时长、各 job 时长和 github.job.queue 时长。如果执行时间稳定而 queue span 持续变长,瓶颈更可能在 runner 容量,而不是测试代码。

哪些 job 不稳定:按 github.repositorygithub.job.namegithub.head.sha 聚合结果。相同提交在较高 github.run.attempt 中从失败转为成功,可以作为 flaky 信号,但不能直接等同于测试本身不稳定——网络、缓存、runner 故障也会造成同样现象。

并发是否合理:trace 时间轴会展示 jobs 的重叠程度。理论上可并行却长期串行的任务,可能受 needs 依赖、并发组、环境审批或 runner 标签约束。

排队时间是否掩盖了优化收益:单看 job 的开始到结束会漏掉排队成本。将 created_at → started_at 单独建 span,才能区分“代码执行慢”和“资源没排上”。

上线前需要补齐的边界

这个方案避免修改 workflow,但并不意味着没有工程成本。正式采用前建议检查:

  • 去重:GitHub 会重投 Webhook。按 X-GitHub-Delivery 保存幂等记录,否则会产生重复 traces。
  • 权限最小化:令牌只开放 Actions 元数据读取权限,不要把长期 PAT 硬编码进镜像。
  • 组织级覆盖:优先使用 GitHub App 管理仓库安装范围和短期 installation token。
  • 数据基数:run ID、job ID 和 commit SHA 适合作为 trace 属性,但不一定适合作为指标标签,否则可能造成高基数成本。
  • 采样策略:如果只保留失败 traces,就无法计算可信的成功率和延迟分位数。可以全量保留指标,同时对成功 traces 采样。
  • 时间限制:这里生成的是“延迟到达”的历史 spans。需要确认后端接受显式历史时间戳,并允许对应的时间偏差。
  • 可见性边界:外部采集可以看到 workflow、job 和 step,却看不到测试进程内部的数据库调用或 HTTP 请求。要把 CI trace 与应用级 trace 连起来,仍需在测试命令或应用中加入 OpenTelemetry 上下文传播。

适合的落地顺序是:先覆盖少量高消耗仓库,验证排队时间、失败率和 P95 job 时长是否可信;再增加去重、GitHub App 鉴权和批量导出;最后才建立组织级告警与成本报表。这样既能保持 workflow 文件不变,也能避免一开始就建设过重的 CI 可观测平台。


相关推荐