GitHub Actions 真香?我踩了 3 个坑才搞懂它的调度算法

我盯着屏幕上红色的流水线失败标记,已经是凌晨两点了。就为了一个环境变量,我整个晚上都在和 GitHub Actions 死磕。你可能会说——不就是个 CI 吗,至于吗? 至于。 很至于。

GitHub Actions 的设计,说实话,有些地方真的反直觉。但一旦你摸清它的脾气,它比家里那只猫还乖。这篇文章不扯概念,直接从底层撕开,看看它是怎么跑起来的,以及我摔得鼻青脸肿的三个坑。

事件驱动的多米诺骨牌:Workflow 执行的真正内核

很多人以为 GitHub Actions 就是按顺序跑脚本。不是的。它本质上是一个高度并发的有向无环图(DAG)调度器。每一次 push,每一个 issue 评论,都会在 GitHub 的后端生成一个事件。这个事件就像推倒了第一块多米诺骨牌。

GitHub Actions event-driven workflow trigger mechanism
GitHub Actions event-driven workflow trigger mechanism

你的 workflow 文件(那坨 YAML)会被实时解析成一个 Job Graph。解析器会检查 needs 字段,确定依赖关系。然后调度器开始工作:为每个 Job 寻找空闲的 Runner。这里的 Runner 不是神话——就是一台台跑在 Azure 上的虚拟机,或者你自己挂上去的机器。GitHub 的调度算法极其贪婪:它会在所有依赖就绪的瞬间立刻尝试分配 Runner,几乎零延迟。我做过一次压测,同时触发 50 个 workflow 运行,它们在 3.2 秒内全部进入了执行状态。这种并发调度能力,传统的 Jenkins 用 Master-Agent 架构根本做不到,除非你重写它的调度核心。

每个 Job 内部又是一个小型的 DAG。Steps 按顺序执行?通常是的,但你可以用 if 条件跳过,也可以矩阵策略并行出多个组合。矩阵策略是 GitHub Actions 的杀手锏:你定义一个维度,比如 os: [ubuntu, macos, windows],它就会为你fork出三个并行的 Job。底层其实是把矩阵笛卡尔积展开,然后作为独立的 Job 分别调度。这玩意儿要是自己写 Jenkins pipeline,得写一大堆 Groovy 循环。

数据不撒谎:我们为什么要从 Jenkins 全面迁移

老派的 Jenkins 用户总说:“Actions 不成熟。” 我们团队之前也是这么觉得的。直到我们被 Jenkins 的维护成本逼疯。那个 Master 节点动不动就 OOM,插件版本冲突比调色盘还花。后来我们下决心迁移一个核心微服务,中型 Java 项目,大概 20 万行代码。

测试环境完全一样:构建 + 单元测试 + 打包 Docker 镜像。Jenkins 那边,配置了 4 个 Slave 节点,每次跑完平均23 分钟。迁移到 GitHub Actions 后,我们使用了矩阵策略并行测试,并且开了缓存。第一次运行,冷缓存,12 分钟。往后有了缓存,稳定在 7 分钟左右。足足快了将近 70%。

GitHub Actions build time comparison chart vs Jenkins
GitHub Actions build time comparison chart vs Jenkins

成本呢?GitHub Actions 为公开仓库免费,私仓每月有 2000 分钟免费额度。我们计算了一下,按照我们私仓的工作量,每月大约 12000 分钟。超出部分按 $0.008/分钟计,一个月额外花费不到 $80。而维护 Jenkins 集群,光是那几台 EC2 实例就月均 $200+,还不算我要花半天时间处理插件故障。说实话,从成本到效率,Actions 完胜。

但这里必须强调一个压测时发现的性能瓶颈:GitHub Actions 的网络 I/O。在拉取大型 Docker 镜像或者往 GitHub Packages 推送时,速度有时会抽风。官方 Runner 的带宽不是独占的。我们遇到过最离谱的一次,一个 2GB 的镜像推送了 18 分钟。解决方案是:使用 actions/cache 把镜像层缓存下来,并且尽量在 runner 上做本地操作。别指望网络一直稳定。

三个血淋淋的坑,以及我是怎么爬出来的

坑一:环境变量的幽灵作用域

我在 workflow 顶层用 env 定义了一个变量,然后在某个 step 里修改它,指望后续 step 能读到新值。结果——不行。GitHub Actions 的环境变量在不同的 context 里是隔离的。顶层 env 注入到每个 step 的环境,但如果你在 step 里用 export 或者 ::set-env(旧语法),它们并不会回传到 workflow 级别。更恶心的是,::set-env 现在已经被废弃了,因为安全漏洞。新的写法是用 GITHUB_ENV 文件:echo “MY_VAR=value” >> $GITHUB_ENV。我一开始没注意这个变更,整整调了三个小时。解决之道:永远记住环境变量是单向写入的,用 GITHUB_ENV 传递到后续 step,或者使用 job outputs 在 job 之间传递。

坑二:缓存键的陷阱

actions/cache 用起来爽,但缓存键设计不好就是废物。我第一次配置时,直接用了一个固定字符串作为键。结果每次构建都命中同一个缓存,代码更新了,依赖根本没变,导致构建出了旧的二进制。第二次我走了另一个极端,把 hash 值当做键的一部分,结果每次代码有微小改动,缓存就失效,构建速度倒退。正确的做法是:利用 restore-keys 做多级回退。比如用 hashFiles(‘**/package-lock.json’) 作为主键的一部分,并设置 restore-keys 为更模糊的前缀,这样即使 lock 文件有变,也能恢复到最近的一次缓存,再增量安装。细节决定成败,真是一点不假。

坑三:Secrets 的暴露风险

GitHub Actions 的 secrets 藏在仓库设置里,官方说在日志中会自动遮盖。但遮盖机制是字符串匹配,不是上下文感知。如果你把一个 secret 的值 base64 编码后打印,它不会被遮盖!我就犯过这个蠢:调试时把 token 打印了出来,虽然是 base64,但解码很简单。更要命的是,如果你在 fork 来的仓库上触发 workflow,secrets 不会被传入,但 GITHUB_TOKEN 却有只读权限。这意味着恶意 PR 可能读取你的仓库信息。最佳实践:对来自 fork 的 workflow 严格控制权限,在 workflow 里用 if: github.event.pull_request.head.repo.full_name == github.repository 来判断是否来自本仓。同时,永远不要 echo 任何 secret,哪怕你觉得编码了就安全。

这一路踩坑下来,我对 GitHub Actions 的感情很复杂。它像是一把极度锋利却容易割伤手的刀。但一旦你掌握它的脾气,CI/CD 这件事真的不再那么痛苦。现在要我回 Jenkins?除非把我开了。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:GitHub Actions 真香?我踩了 3 个坑才搞懂它的调度算法
文章链接:https://www.lfdjt.com/info_23_7681.html