从一条 Release 流水线说起

GreptimeDB 自开源的第一天起就用 GitHub Actions 开启了自动化构建软件制品的模式,自此诞生了第一条 Release 流水线。对于一个开源项目来说,拥有一个成熟稳定的 Release 流水线至关重要:

  1. 提供可直接使用的软件制品:作为软件供应链的上游生产方,为下游不同场景的使用者提供安全可靠且可直接使用的软件制品(比如二进制、镜像等)是很重要的事情;
  2. 极大地提升开发者体验:用户无需太多配置就能马上拥有一个对应平台可直接运行的软件制品,更不必从头开始搭建环境进行编译;
  3. 围绕 Release 流程做各种自动化测试:结合自动化 Release 的流程做各种不同种类的回归测试(比如性能测试、稳定性测试、集成测试等等),从而提升软件质量;

之所以选用 GitHub Actions(同类产品其实还有 Circle CITravis CIGitLab CI 等等,甚至可基于类似于 Tekton 或者 Argo Workflow 这类开源项目进行自建),原因很简单:GitHub Actions 结合 GitHub 生态足够简单易用且有一个丰富的软件市场生态。但是,简单易用并不代表好用且好维护,相反,GitHub Actions 是非常容易 “腐烂” 的。GreptimeDB 开源的第一个版本release.yml 只有很短的 183 行,后经过 N 次 N 人改动后,小小的 YAML 先后加入了:

  • 更多不同平台的制品构建;
  • 开启不同特性开关的软件制品的构建;
  • 构建之前会先跑集成测试;
  • 推送到不同的软件制品仓库(DockerHub、ACR、S3 等);
  • 各种 Release 条件下的 Conditions 控制(比如手动触发、错误容忍等);

等等。除此之外,全局又因其他原因(调试发布、每日构建等)分叉出了功能只有细微差异的同类流水线在不同的内部仓库中,加剧了维护成本。

在这么多杂乱无章且琐碎的构建需求下,release.yml 逐渐膨胀且难以维护,充斥着冗余啰嗦的配置代码,再不赶紧重构,Release 流水线估计就将要彻底腐烂。

想说爱你不容易

回看release.yml,之所以会极速腐化,其原因有:

  1. 语言层面:GitHub Actions 基于 YAML 的弱 DSL 较之通用编程语言表达力很弱,稍不小心就容易写出冗余且不好维护的代码;
  2. 可调试性较低:GitHub Actions 是出了名的不好调试。加之项目本身使用 Rust,其高昂的编译成本进一步延长了调试周期。尽管有类似于 act 这类本地化运行 GitHub Actions 的工具,但由于我们最终还是得真实地运行,因此并不能彻底缩短 编写-运行-调试 周期;
  3. 没有考虑 actions 间的模块解耦:GitHub Actions 采用 Composite 的方式来组合不同的 actions。由于经验的欠缺,我们并没有根据不同的功能来划分不同的 actions,而是将所有逻辑都堆在一个 YAML 文件,这当然会让人难以维护;
  4. 没有考虑 Reproducible Build:由于 GitHub 目前还没有 ARM64 类型的虚拟机实例,所以为了更好的编译性能,我们选择在 GitHub x86_64 的虚拟机实例中分别构建 AMD64 和 ARM64 类型(交叉编译)的软件制品。尽管我们可以使用 Docker Buildx 启动 QEMU 来模拟 ARM64 平台的构建,但其构建性能较之 Native 平台非常糟糕。由于我们依赖于 GitHub Runner 的 Host 环境且没有使用 Dockerfile,我们很难真正获得统一的 Reproducible Build;

综上,GitHub Actions 非常顽皮,想说爱你不容易,能一次把 GitHub Actions 就写对的人请收下我的膝盖。重构之初,我们坚持的原则是:可维护性 » 性能(构建速度)。因为像这种 Release 流水线,注定是会随着项目演进而不断增加各种构建需求。如果可维护性没有跟上,那么后续就只能渐渐腐化从而降低研发效能。只有可维护性跟上了,我们才能有动力去提高性能(其实针对编译构建的场景,在不考虑各种缓存机制的前提下,一般用更好的构建机器就能很快提升性能)。

重构计划

不同于常见的编程项目,重构一个 YAML 文件本质上是对各种配置过程进行一次完整的梳理,逻辑性不强,但是具有极大的偶然复杂度(Accidental Complexity),是一个不断踩坑又不断挣扎地从坑里爬出来的过程。

  1. 尽可能用 Dockerfile 来标准化构建:基于 Dockerfile 的构建是会带来一些性能的损失,但是增加了可维护性,标准化了所有平台的构建流程,从而具备 Reproducible Build;
  2. 统一命令入口:基于 1,尽量将各种构建命令提炼成一个单独的 make 命令,避免把过于复杂的编译上下文带入到 yaml 中,尽量不让 Release 环节隐藏过多的编译细节,而是让细节暴露在研发态的 Makefile 或脚本中(或者其他工具中)。用户可以通过使用 Makefile 就获得与 Release 环节一致的构建体验,从而提高研发效能;
  3. 使用 AWS EC2:上文曾经提及,由于 GitHub Actions 暂时还没有 ARM64 类型的虚拟机实例,因此我们不得已使用交叉编译。为了实现用一份 Dockerfile 来标准化所有平台的构建流程,我们采用 AWS EC2 的 ARM64 实例来构建 ARM64 平台的软件制品;
  4. 模块解耦:分拆 release.yml,尽量保证 release.yml 是一个相对 “简单干净” 的 Job 的集合(因为 GitHub Actions 没有 Group Job 的机制,否则可以提炼得更加干净)。每一个 actions/ 下的 action.yml 尽量保持简短纯粹,这样容易去基于相同的 actions 去定制不同的流水线;
  5. 尽可能保证 Job 的简单性:一个 Job 尽量干一件纯粹的事情,这样可幂等程度更高,出现错误更容易重试。基于 Job 也能更好地提炼出一些上层控制变量放到手动触发控制中;
  6. 尽量避免在 Actions 中的 Shell Run 中加入过多命令:如果在 GitHub Actions 的某个 Step 中引入过多的 Shell 命令,看似简单直接但其可维护性相当糟糕。如果实在有很多命令,建议变成一个外部的 Scripts 并提炼传入的参数接口。这么做好的好处就是方便对应的 scripts 可被独立执行和验证;
  7. 提炼出一个 Allocate Runners 的前置 Job:Allocate Runners 这个 Job 是第一个被执行的 Job,它将为后续的 Job 分配 Runner 和创建全局的 Version 标记。比如,如果我们选择使用 EC2,Allocate Runners Job 将会通过调用 EC2 API(这是通过 ec2-github-runner 这个 Action 实现的)来分配对应平台的 EC2 实例。更进一步,我们未来可以在 Allocate Runners 增加更复杂的选择算法(比如使用 Self-Hosted Runner)来优化 Runners 分配的成本;
  8. 全局统一流水线:如无必要,尽量不要分叉功能极为相近的 GitHub Actions,这同样会导致维护成本的上升。为了更加公开透明的开源研发流程,我们选择将之前内部使用的构建流水线统一到 GreptimeDB 主仓库中。只要代码是开源的,那么就保证软件制品和构建过程同样是开源的;
  9. 正确使用 GitHub 仓库中 Variables 和 Secrets 配置:之前的 CI 将大多数外部参数都当作是 Secrets,这其实并不正确。有些非 Secrets 类型的外部参数应该被配置为 GitHub Variables,这样更易于后期快速调整。对于一些可能存在不断调整的变量,也不要硬编码到 YAML 之中,而是要从 YAML 中提取出来变成 Variables。这样也可以减少一些低信息量的配置修改 PR。

未来演进

这次 Release 流水线重构仅仅是 GreptimeDB 走向成熟的一小步而已,未来我们将构建一套更高质量的 CI:

  1. 更多的平台运行生态:比如我们即将 Release Windows 平台的软件制品,欢迎届时试用体验;
  2. 更多的自动化测试:我们未来将进一步在 CI 中引入各种类型的测试(比如混沌测试、性能测试等),从而提升软件质量;
  3. 更低的 CI 综合使用成本:根据不同的使用场景选择分配不同类型的 Runner,从而让 CI 综合使用成本较优;
  4. 更高的构建性能:此次 Release 流水线重构其实降低了构建性能(#2113)。也许我们可以通过更加智能的构建缓存来进一步提升构建性能;
  5. 更安全的软件供应链:在现代的软件制品管理中,安全的软件供应链的重要性与日俱增。作为一个开源项目,我们必须保障上游分发的软件制品是安全可信且透明的。为此,我们需要在现有 Release 流程中引入一些必要的安全机制,比如一些 SBOM 管理和软件制品签名验签机制的实践就很值得参考;

GitHub Actions,想说爱你并不容易,欢迎有兴趣的朋友一起来社区讨论!