09 持续集成
Table of Content
当一组开发者共同开发一个项目时,各种冲突似乎不可避免。我们在第 8 章介绍了分支和 gitflow 工作流模型,从而解决了代码冲突的问题。但是,代码合并只能达到表面上的和谐。不同的人开发的代码能否协同工作,最终还得通过测试来检验。
在已经介绍过的开发流程中,开发者在签入代码并推送到远程服务器之前,应该通过 tox 执行的单元测试和代码检查。但是,如果开发者有意忽略这些步骤,不良代码仍然能溜进仓库。此外,尽管我们虚拟化了测试环境,但仍然有可能发生这样的情况:在一名开发者机器上通过的测试,不能在另一个环境里运行。比如,可能引入了新的配置项,这些配置项存在于本地环境,但其他人并不知道;或者更改了本地数据库,但相应的变更脚本并没有集成进来,等等。如果只要有新的代码签入,就能在一台公共机器上自动执行所有测试以确保代码正确性,显然可以更早地发现问题,降低后期维护成本。这就是持续集成的意义所在。
持续集成是一种 DevOps 软件开发实践。采用持续集成时,开发人员会定期将代码变更合并到一个中央存储库中,之后系统会自动运行构建和测试操作。持续集成通常是指软件发布流程的构建或集成阶段,需要用到自动化组件(例如 CI 或构建服务)和文化组件(指组织的流程和规范等,例如学习频繁地集成)。持续集成的主要目标是更快发现并解决缺陷,提高软件质量,并减少验证和发布新软件更新所需的时间。
Info
持续集成强调的文化是:Fail fast, fail often。但实际上,一旦构建起良好的 CI/CD 流水线和文化,最终我们得到的将是 Move fast and don't break things。
1. 盘点 CI 软件和在线服务¶
我们先来认识一下持续集成领域的头部玩家。Jekins 是持续集成领域的老大哥,自诞生之日起,至今已发展了近 20 年,社区、生态最为完善。Gitlab 最初是基于 Git 的代码托管平台,自版本 8.0 起,开始提供持续集成功能。其优点是与代码仓库无缝集成,原生地支持代码仓库各类事件触发流水线。不足之处是,无论 Jekins 还是 Gitlab,都需要自己搭建服务器,这对于开源项目和个人开发者来说,成本太高了。
构建 CI 服务器的成本是昂贵的。在虚拟机没有广泛应用之前,这个成本就更为昂贵。你需要为你的应用将要部署到的每一种操作系统准备至少一台机器。如果你的应用需要部署到从 windows、 Linux 到 Macos 上的各个版本,你可能就需要准备至少十台以上的机器硬件。虚拟化的出现大大降低了 CI 的成本,随后是容器化的出现,进一步降低了 CI 的成本。但是,即便是这样,对开源项目,自行搭建和维护 CI 服务器的成本仍然是昂贵的。比如,如果你的应用需要部署到 MacOs 上,你必须至少有一到多台苹果的服务器,因为其它机器上都无法虚拟化出来 macos 的容器。
这就是为什么我们推荐使用在线 CI 服务的原因。好消息是,对开源项目,有相当多的在线 CI 服务是免费的。这里我们仅仅介绍 Travis CI 和 Github Actions。
Travis CI 是一个基于云的持续集成服务,它可以帮助开发者在 Github 上构建和测试代码,从而减少发布软件的时间。Travis CI 为开源项目提供了一定量的服务时间,对于私有项目,则需要付费--起价是每月 69 美元。这个定价本身也说明了持续集成的价值,以及要实现持续集成,所需要消耗的资源。
Github Actions 是 Github 在 2020 年前后推出的持续集成服务,它的优点同 gitlab CI 一样,也在于与代码托管服务无缝集成。在价格方面,Github Actions 对开源项目也是免费使用的,与 Travis CI 相比,给出的免费 quota 更多,足堪使用。因此,我们本章就略过 Travis CI,直接介绍 Github Actions。
2. GITHUB ACTIONS¶
Github Actions 是一个持续集成与交付的平台,它使得我们可以将构建、测试和部署像流水线一样自动化。您可以创建工作流程来构建和测试存储库的每个拉取请求,或将合并的拉取请求部署到生产环境。
GitHub 通过云服务来提供 Linux、Windows 和 macOS 这些基础设施,但也允许我们在自己的私有云上进行本地化部署。
2.1. Github Actions 的架构和概念¶
Github Actions 由工作流 (workflow)、事件 (Event)、作业 (job)、操作 (Action) 和执行者 (runner) 等组件构成。
工作流由一个 yaml 文件来定义,它位于项目根目录下的.github/workflows 目录。我们也可以简单地将该文件当成脚本来理解。该文件定义了哪些事件可以触发工作流,工作流中应该包含哪些作业,以及作业应该在什么样的执行者(容器)中运行。一个存储库中可以有多个工作流,分别执行不同的任务集。
事件是能引发工作流运行的特定事件,比如当有人创建拉取请求、或者将提交 (commit) 推送到存储库时,这就是一个能触发工作流的事件。
作业 (job) 是工作流中在同一运行器上执行的一组步骤(steps)。 每个步骤 (step) 要么是一个将要执行的 shell 脚本,要么是一个将要运行的操作 (Action)。 步骤按顺序执行,并且相互依赖。 由于每个步骤都在同一运行器上执行,因此您可以将数据从一个步骤共享到另一个步骤。例如,可以在构建应用程序的步骤之后跟一个测试已生成应用程序的步骤。
一个工作流中可以有多个作业。作业之间默认没有依赖关系,并且彼此并行运行。 但我们也可以配置一个作业依赖于另一个作业,此时,它将等待从属作业完成,然后才能运行。 例如,对于没有依赖关系的不同体系结构,您可能有多个测试作业,以及一个依赖于这些作业的打包作业。测试作业将并行运行,但只有它们全部成功完成后,打包作业才会运行。
操作 (Action) 是用于 GitHub Actions 平台的自定义应用程序,它执行复杂但经常重复的任务。 使用操作可帮助减少在工作流程文件中编写的重复代码量。 操作可以从 GitHub 拉取 git 存储库,为您的构建环境设置正确的工具链,或设置对云提供商的身份验证。
您可以编写自己的操作,也可以在 GitHub Marketplace 中找到要在工作流程中使用的操作。
执行者 (runner) 是运行工作流的服务器。每个执行者一次可以运行一个作业。 GitHub 提供 Ubuntu Linux、Microsoft Windows 和 macOS 运行器来运行您的工作流程;每个工作流程运行都在新预配的全新虚拟机(或者容器)中执行。
下面的图展示了 Github Actions 的架构:
2.2. 工作流语法概述¶
在了解了 Github Actions 的架构之后,我们通过一个例子,来讲解如何定义工作流。
我们先看 ppw 生成的一个工作流文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
|
这是一个名为 dev build CI 的作业,它将在任一分支发生提交和 pull request 时触发。它包含三个作业,即 test(执行单元测试)、publish_dev_build(构建并发布开发版本到 test_pypi) 和 notification(在构建成功或失败时发送邮件通知)。三者之间有依赖关系,如果 test 作业失败,publish_dev_build 会取消;但是无论 test/publish_dev_build 作业是否成功,notification 作业都会执行,并根据前两个作业的状态发送内容不同的邮件通知。
这是一个比较简单的作业,但也涉及了我们在 CI 中需要做的几乎所有事情。在代码中我们加入了较多的注释,建议读者结合下面的讲解,仔细阅读代码。
2.2.1. 定义触发条件¶
第 4 行到第 14 行配置了工作流的触发条件。触发条件一节由关键字'on'引起。在它的下一层,我们可以定义多个触发事件,并为每个触发事件,指定类型和过滤器。一个完整的触发条件配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
在事件的下一级,是定义活动类型和筛选器的地方。不是所有的事件都有活动类型,即使有,它们的活动类型也很可能不一样。比如,push
事件就没有活动类型,而label
事件有created
, edited
和deleted
三种活动类型,而issues
事件的活动类型则是opened
和labeled
等。要想知道事件有哪些活动类型,可以参考 触发工作流的事件。
筛选器有 branches 和 tags 两种,分别用于指定分支和标签。从上面的示例可以看出,筛选器的值支持 glob 模式,即我们可以使用、*、 +、 ? 和!等通配符来匹配分支和标签名。除了示例中的正向匹配外,还可以使用 branches-ignore 或者 tags-ignore 来反向匹配。
上面的示例还展示了一种特殊的事件,即 'schedule' 事件。这将导致工作流周期性地运行。这可以用来运行一些安全性扫描、依赖升级扫描等工作。
2.2.2. 定义作业集¶
接下来工作流声明了三个作业,即 test, publish_dev_build 和 notification。作业定义一节由关键字'jobs'引起。在它的下一层,我们可以定义多个作业,并为每个作业,指定运行环境和运行步骤。
每个作业都有自己的 id 和名字。在上述例子中,test, publish_dev_build 和 notification 都是作业 id。作业 id 是必须的,但是作业名字是可选的。如果没有指定作业名字,那么作业名字将默认为作业 id。作业名字可以用来在 GitHub Actions 的界面上显示作业的名称。
在作业 publish_dev_build 中,我们通过关键字needs
来定义了它对作业test
的依赖。我们在第 114 行还看到,作业还可以依赖到一组作业。当我们指定作业依赖时,要注意只能使用作业的 id,而不是作业的名字。
接下来我们为作业定义执行环境。执行环境是通过关键字 'runs-on' 来定义的。有些任务只需要在一台机器上执行就可以了,比如 Python 包的构建和发布;有些任务则需要在所有的机器上、并以多个 python 运行时来运行,比如测试。
比如,publish_dev_build 作业只需要在 ubuntu_latest 和 python 3.9 这个组合上运行,因为在任何一个组合上运行这个作业,它们的结果都应该是一样的。第 78 行和第 84 行都是指定单个执行环境的例子。但对测试任务,我们希望它在所有机器上都运行,并且还要运行在不同的 python 版本上。为了表达简洁起见,Github actions 引入了矩阵的概念。
我们通过 strategy.matrix 来定义测试矩阵。在示例第 20~23 行定义的矩阵中,我们定义了 python 版本和操作系统列表。这个定义随后就被使用了,在第 24 行,我们通过{{matrix.os}}来引用了其中的操作系统定义。第 22 行的 python-versions 是一个特殊的关键字,它用来指示我们要使用的 python 版本。如果我们的开发语言不是 python,这里的指定将没有意义。
接下来我们需要为作业定义具体执行哪些任务,它们被归类在步骤集 (steps) 中。步骤集是一个包含多个步骤的列表。而每一个步骤,要么是一个 shell 命令(或者一组 shell 命令),要么是一个 action。如果要运行 shell 命令,我们使用下面的语法:
1 2 |
|
1 2 3 4 5 |
|
至于 action,我们前面已经讲过了,它是用于 GitHub Actions 平台的自定义应用程序。您可以自己编写 action,也可以在 Github 的应用市场上查找他人开发的应用。
每一个步骤都可以指定 python 运行时。我们在第 48 行看到,这里{{ matrix.python-versions }}
使用的是矩阵中定义的 python 版本。而在第 84 行,我们则直接指定了一个 python 的版本。这里还要注意,版本号是一个字符串,q 我们可以使用'3.10'
,但不能使用3.10
,后者将会被 yaml 解析为3.1
,从而出现找不到 python 版本的问题。
最后,我们介绍一下 job 运行时的条件控制。我们已经介绍过了 job 之间的依赖关系,这可以算作是一种条件。还有一种情况,比如示例中的 notification 作业,我们要求它只能在前两个作业都完成后才运行(并且无论成功与否,都要运行),但会根据前面作业的状态,发出不同内容的通知邮件,此时,我们就需要引入if
条件控制。
if
条件控制可以作用于作业作用域(如第 116 行所示),也可以作用于步骤作用域(如第 123 行所示)。在作业作用域中,我们可以使用success()
、failure()
、cancelled()
、always()
等函数来指定在何种情况下才运行本作业。在步骤作用域中,我们则可以进行简单的条件判断,来指示本步骤是否运行。
在进行条件判断时,我们必然需要使用变量。现在,我们需要全面地介绍一下在工作流中变量的使用。通过变量,结合各种控制条件,我们才能实现一些高级的技巧。
Github 中的变量分为两种,一种是系统变量,一种是作业变量。系统变量是 Github 平台提供的,作业变量是我们自己定义的。无论那一种变量,我们都通过${{ }}
来引用。
系统变量按级别可以定义在组织、存储库或者环境上。比如secrets
就定义在存储库级别上,我们可以通过${{ secrets.BUILD_NOTIFY_MAIL_RCPT }}
来访问它的值,这需要我们在存储库里事先定义BUILD_NOTIFY_MAIL_RCPT
这个变量。GitHub 提供了一些默认变量,比如GITHUB_REPOSITORY
(此变量的值是仓库所有者及仓库名,比如,octocat/Hello-World,示例第 59 行使用了这一变量,并且演示了一个字符串提取技巧)等。
下面的代码演示了作业变量的使用技巧(摘自示例第 122~132 行):
1 2 3 4 5 6 7 8 |
|
{job_id}.outputs.{variable}
来引用的,这并没有什么奇怪的地方。但是要注意它还有一个needs
作用域。这表明,如果我们使用变量的地方,其所属的作业如果没有声明依赖到test
作业,那么这个变量是无法被引用的。
示例中没有展示环境变量的用法,这里我们给一个示例,请读者结合注释自行研究:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
2.2.3. 连接其它服务¶
在示例的第 31 行到第 40 行,有一段被注释的代码,这是用来启用 redis 服务的。Github Actions 通过容器技术来提供这些服务。理论上,只要在 docker hub 上存在某个服务的 image(镜像),我们就可以在 Github Actions 中使用它。
Attention
要注意的是,如果我们要在 workflow 中使用服务,执行者 (runner) 必须是 ubuntu 操作系统,而不是能其它 Linux 系统,或者 windows 和 MacOS。
我们的工作流可以运行在执行者 (runner) 上,也可以运行在容器里(该容器运行在 runner 上)。如果工作流运行在容器里,则需要通过自定义的桥接网络来连接运行在容器里的服务。如果工作流就运行在执行者 (runner) 上,那么我们可以将容器的端口映射到执行者上,这样我们就可以直接访问容器里的服务了。
在工作流中使用服务,一般需要等待容器完全启动被初始化成功。这就是第 35 行(REDIS-CLI PING)的作用。
3. 第三方应用和 Actions¶
我们已经在示例中看到了一些来自应用市场的 action,比如 actions/checkout, actions/setup-python, pypa/gh-action-pypi-publish, dawidd6/action-send-mail, codecove/Codecov 等等。这里'/'之前的是 action 的作者,作者为 actions 的是 GitHub 官方的 action,其他的都是第三方的 action。这些 action,它们的名字就已经说明了其功能,因此这里不再赘述。
下面介绍一些使用较多的第三方 actions,其中有一些我们进行了简要说明并举例了使用方法。如果我们没有进行特别说明,或者您想进一步了解相关信息,可以访问 marketplace 查看相关文档。
3.1. Github pages 部署¶
这个 action 可以用来将静态网站部署到 Github Pages 上。在 ppw 生成的项目中,它与 mkdocs/mike 配合使用。其 ID 是 JamesIves/github-pages-deploy-action,使用示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
3.2. 构建和发布 docker 镜像¶
显然,作为持续部署的一个步骤,docker 镜像的构建也应该通过 CI/CD 服务器来完成并发布。docker 官方提供了一个完成此功能的 action, id 是 docker/build-push-action。
3.3. Github Release¶
一般地,Python 项目的发布都是通过 PyPI 来完成的。但是,我们也可以将其发布到 Github Release 上。这个 action 的 id 是 softprops/action-gh-release。
3.4. 制订发布日志草案¶
编写发布日志(release notes)是一件枯燥乏味的事。relase-drafter/release-drafter 可以帮助我们自动生成发布日志草案。它将自动从代码提交日志中找到有用的信息以组织一份发布日志。
在编写发布日志时,往往只需要记录功能性的变更、修订(bug fix)、性能增强这类影响到外部使用的信息。但在我们进行代码提交时,会存在各种各样的提交,除了上述几类之外,还有文档修订,代码格式化,CI 流程变更等等。Release-drafter 如何自动把这些无用的信息过滤掉呢?这就需要我们在提交日志时,一是严格进行分类,二是要遵循规范的格式。我们在第 2 章的编辑提交信息的扩展这一节中,介绍了一个编辑提交信息的扩展。通过此类工具,就可以保证我们提交的日志都有良好的分类,从而 release-drafter 就可能完成自动信息提取,形成草案。接下来即使还需要人工编辑,工作量也会少不少。
正如我们开头所说的,好的开发习惯不能仅仅靠培养程序员的意识,重要的是,要通过一系列环环相扣的工具,来把我们的流程流水线化,从而得到强制遵循。
还有一些好玩的 action,比如一个生成贪吃蛇游戏的 action,id 是 Platane/snk。它会生成如下的贪吃蛇游戏:
3.5. 通知消息¶
我们已经介绍了邮件通知。在应用市场里,还有各种各样的通知 action,比如 Slack 通知,可以通过 Ilshidur/action-slack 这个 id 来找到它。
3.6. Giscus¶
Giscus 是一个基于 Github Discussion 的评论系统。它的 id 是 giscus/giscus。如果你使用了 gitpages 作为博客和静态站系统,你可以在 Github 上安装它,并在博客和静态站系统中增加评论功能。
4. 通过 Github CI 发布 Python 库¶
我们在前面看到的例子来自于ppw
生成的项目下的.github\workflows\dev.yml 文件。这个文件定义的工作流适用于所有的分支,在每次 push 时都会触发。它的作用是进行集成测试,构建测试包并发布到 testpypi,并发布非正式文档到 Github Pages 上。
正式的版本发布工作交给了 release.yml。这个工作流仅适用于 main 分支,并且只有当 main 分支上有打标签事件发生,并且标签是以'v'字线开头时,才会真正运行。下面是这个工作流的内容:
release.yml | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
|