4.3 持续集成
生命周期管理的持续集成(CI)部分是关于将开发人员签入的源代码转换为可部署的 Docker 镜像集。正如前一节所讨论的,主要是对代码运行一组测试,首先测试代码是否准备好集成,然后测试是否成功集成,集成本身完全根据声明性规范执行。这就是微服务架构的价值主张: 每个组件独立开发,打包成容器(Docker),然后由容器管理系统(Kubernetes)根据声明式集成计划(Helm)进行部署和互联。
但以上描述忽略了一些重要细节,接下来需要填充一些特定机制。
4.3.1 代码存储库
代码库(例如 GitHub 和 Gerrit)通常会提供临时提交补丁集的方法,触发一组静态检查(例如,通过 linter、许可证和 CLA 检查),并给代码审核人员检查和评论代码的机会。这种机制还提供了触发接下来讨论的构建-集成-测试过程的方法。一旦所有检查完成,负责受影响模块的工程师感到满意了,就会合并补丁集。这是大家都很了解的软件开发过程的一部分,我们不再讨论。对于我们的目的而言,重要的是在代码存储库和 CI/CD 流水线的后续阶段之间有一个定义良好的接口。
4.3.2 构建(Build)-集成(Integrate)-测试(Test)
CI 流水线的核心是执行一组进程的机制,它(a)构建给定补丁集影响的组件,(b)将生成的可执行镜像(如二进制文件)与其他镜像集成以构建更大的子系统,(c)对这些集成的子系统运行一组测试并发布结果,(d)可选的发布新的部署工件(如 Docker 镜像)到下游镜像库。最后一步只有在补丁集被接受并合并到存储库之后才会发生(这也会触发运行图 18 中的构建阶段)。重要的是,构建和集成镜像用于测试的方式与构建和集成镜像用于部署的方式完全相同。两者设计原则一致,没有特殊情况,只是端到端 CI/CD 流水线的出口不同。
没有什么话题比不同构建工具的优缺点更能引起开发人员的注意了。在 Unix 上长大的老派 C 程序员更喜欢 Make。谷歌开发了 Bazel,并将其开源。Apache 基金会发布了 Maven,演变成了 Gradle。我们不喜欢在这场无法获胜的辩论中选择任何一方,而是承认不同团队可以为各自项目选择不同的构建工具(我们已经在通用术语中称为子系统),我们使用一个简单的第二级工具来集成所有那些复杂的第一级工具的输出,我们选择的第二级机制是 Jenkins,这是一个作业自动化工具,系统管理员已经使用了多年,但最近被改编和扩展以自动化 CI/CD 流水线。
延伸阅读:
在较高层次上来说,Jenkins 只不过是一种执行被称为作业(job) 的脚本、响应某个触发器(trigger) 的机制。与书中介绍的其他工具一样,Jenkins 有图形化仪表板,可以用来创建、执行和查看一组作业的结果,但这主要用于简单的示例。因为 Jenkins 在 CI 流水线中扮演着核心角色,像我们正在构建的所有其他组件一样,通过一组签入存储库的声明性规范文件所管理。问题在于,这具体是什么意思?
Jenkins 提供了一种名为 Groovy 的脚本语言,可用于定义由一系列阶段(Stage) 组成的流水线(Pipeline) ,每个阶段执行一些任务并测试是否成功或失败。原则上可以为整个系统定义单个 CI/CD 流水线,从"构建"阶段开始,接着是"测试"阶段,如果成功,以"交付"阶段结束。但是这种方法没有考虑到构建云的所有组件之间的松耦合。实际上,狭义来说,Jenkins 被用于(1)构建和测试单个组件,包括合并到代码库之前和之后的组件;(2)集成和测试各种组件的组合,例如每天晚上;(3)在特定件下,将刚刚构建的工件(例如 Docker 镜像)推送到镜像存储库。
这是一项艰巨的任务,因此 Jenkins 支持工具来帮助构建作业。具体来说,Jenkins Job Builder (JJB) 处理声明性 YAML 文件,这些文件"参数化"用 Groovy 编写的流水线,生成 Jenkins 随后运行的作业集。除了其他内容外,这些 YAML 文件指定了启动流水线的触发器(例如签入代码存储库的补丁)。
开发人员如何使用 JJB 是工程细节,但是在 Aether 中,采用的方法是让每个主要组件定义三到四个不同的基于 Groovy 的流水线,每一个都对应于图 18 所示的整个 CI/CD 流水线中的一个顶级阶段。也就是说,一个 Groovy 流水线对应于合并前的构建和测试,一个对应于合并后的构建和测试,一个对应于集成和测试,还有一个对应于发布工件。每个主要组件还定义了一组 YAML 文件,这些文件将特定组件的触发器链接到流水线,以及定义该流水线的相关参数集。YAML 文件(以及由此产生的触发器)的数量因组件而异,常见例子是当新的 Docker 镜像发布,触发存储在代码存储库中的VERSION
文件的更改。(在 4.5 节将介绍为什么这么做。)
作为示例,下面是一个定义 Aether API 测试流水线的 Groovy 脚本,正如我们将在下一章中看到的,它是由运行时控制子系统自动生成的。当前我们只对流水线的一般形式感兴趣,因此省略了大部分细节,但是从示例中应该可以清楚看到每个阶段的作用(记住 Docker 里Kind
就是 Kubernetes)。示例中完整呈现的一个阶段调用就是第 4.2.2 节中介绍的 Robot 测试框架,每个调用执行 API 的不同特性。(为了提高可读性,示例不向收集结果的 Robot 显示输出、日志记录和报告参数。)
pipeline { ... stages { stage("Cleanup"){ ... } stage("Install Kind"){ ... } stage("Clone Test Repo"){ ... } stage("Setup Virtual Environment"){ ... } stage("Generate API Test Framework and API Tests"){ ... } stage("Run API Tests"){ steps { sh """ mkdir -p /tmp/robotlogs cd ${WORKSPACE}/api-tests source ast-venv/bin/activate; set -u; robot ${WORKSPACE}/api-tests/ap_list.robot || true robot ${WORKSPACE}/api-tests/application.robot || true robot ${WORKSPACE}/api-tests/connectivity_service.robot || true robot ${WORKSPACE}/api-tests/device_group.robot || true robot ${WORKSPACE}/api-tests/enterprise.robot || true robot ${WORKSPACE}/api-tests/ip_domain.robot || true robot ${WORKSPACE}/api-tests/site.robot || true robot ${WORKSPACE}/api-tests/template.robot || true robot ${WORKSPACE}/api-tests/traffic_class.robot || true robot ${WORKSPACE}/api-tests/upf.robot || true robot ${WORKSPACE}/api-tests/vcs.robot || true """ } } } ... }
需要注意的一点是,这是另一个工具以特定方式使用通用术语的例子,但与我们使用的通用概念不一致。图 18 中的每个阶段(stage) 都由一个或多个 Groovy 定义的流水线(pipeline) 实现,每个流水线由一系列 Groovy 定义的阶段组成。正如我们在示例中看到的,这些 Groovy 阶段都是相当底层的操作。
此流水线是图 18 所示的构建后 QA 测试阶段的一部分,因此由基于时间的触发器调用,下面的 YAML 片段是指定此类触发器的作业模板示例。注意,如果查看 Jenkins 仪表板中的作业集,就会看到name
属性的值。
- job-template: id: aether-api-tests name: 'aether-api-{api-version}-tests-{release-version}' project-type: pipeline pipeline-file: 'aether-api-tests.groovy' ... triggers: - timed: | TZ=America/Los_Angeles H {time} * * * ...
为了展示完整,下面来自另一个 YAML 文件的代码片段展示了如何指定基于存储库的触发器。此示例执行不同的流水线(未显示),并对应于当开发人员提交候选补丁集时运行的合并前测试。
- job-template: id: 'aether-patchset' name: 'aether-verify-{project}{suffix}' project-type: pipeline pipeline-script: 'aether-test.groovy' ... triggers: - gerrit: server-name: '{gerrit-server-name}' dependency-jobs: '{dependency-jobs}' trigger-on: - patchset-created-event: exclude-drafts: true exclude-trivial-rebase: false exclude-no-code-change: true - draft-published-event - comment-added-contains-event: comment-contains-value: '(?i)^.*recheck$' ...
从讨论中得出的重要结论是,没有单一或全局的 CI 作业。每个组件都有许多作业,在达到条件时独立发布可部署的工件。这些条件包括:(1)组件通过了所需的测试,以及(2)组件的版本表明是否需要新的工件。我们已经在 4.2 节讨论了测试策略,并将在 4.5 节介绍版本控制策略,这两个问题是实现持续集成的可靠方法的核心,工具(示例中是 Jenkins)只是达到目的的一种手段。
4.4 持续部署
现在,我们已经准备好对签入配置存储库(Config Repo)的配置规范采取行动了,其中包括一组指定底层基础设施(我们一直称其为云平台)的 Terraform 模板,以及一组在基础设施上的部署微服务(有时称为应用程序)集合的 Helm Charts。我们已经在第三章介绍了 Terraform,它是实际操作基础设施相关表单的代理。在应用程序端,我们使用一个叫做 Fleet 的开源项目。
图 21 显示了我们工作的概要。请注意,Fleet 和 Terraform 都依赖每个后端云提供商导出的配置 API,粗略的说,Terraform 调用这些 API"管理 Kubernetes",而 Fleet 调用这些 API"使用 Kubernetes"。
图 21. CD 主代理(Terraform 和 Fleet)和后端 Kubernetes 集群之间的关系。
图 21 的 Terraform 端负责部署(和配置)最新的平台级软件。例如,如果运维人员想要向给定集群添加服务器(或虚拟机)、升级 Kubernetes 版本或更改 Kubernetes 使用的 CNI 插件,所需配置将在 Terraform 配置文件中指定。(回想一下 Terraform 计算现有状态和期望状态之间的差异,并执行使前者与后者保持一致所需的调用。)每当向现有集群添加新硬件时,相应的 Terraform 文件将被修改并签入配置存储库(Config Repo),从而触发部署作业。我们不再介绍平台部署是如何被触发的机制,因为它使用了在 4.3.2 节中介绍的完全相同的 Jenkins,只是现在被签入配置存储库(Config Repo)的 Terraform 表单更改所触发。
图 21 的 Fleet 端负责安装要在每个集群上运行的微服务集合。这些微服务组织为一个或多个应用程序,由 Helm Charts 指定。如果我们试图在一个 Kubernetes 集群上部署一个 Chart,那么我们用 Helm 就够了。Fleet 的价值在于扩展了该流程,帮助我们管理跨多个集群的多个 Chart 的部署。(Fleet 是 Rancher 的独立衍生产品,可以直接与 Helm 一起使用。)
延伸阅读:
Fleet 定义了三个与我们的讨论相关的概念。第一个是 Bundle,定义了被部署的基本单元。在我们的例子中,一个 Bundle 相当于一个或多个 Helm Chart 的集合。第二个是 Cluster Group,标识了一组 Kubernetes 集群,这些集群将以相同的方式处理。在我们的例子中,标记为Production
的所有集群可以被视为一个这样的集合,标记为Staging
的所有集群可以被视为另一个这样的集合(这里,我们讨论的是在 Terraform 规范中分配给每个集群的env
标签,如 3.2 节示例所示)。第三个是 GitRepo 存储库,用于监控对 Bundle 工件的更改。在我们的例子中,新的 Helm Charts 被签入到配置存储库中(但正如本章开始所指出的,实践中可能有专用的"Helm Repo")。
接下来了解 Fleet 就很简单了,它提供了一种定义 Bundle、Cluster Group 和 GitRepo 之间关联的方法,这样每当更新的 Helm Chart 被签入 GitRepo 时,包含该 Chart 的所有 Bundle 都会(重新)部署到所有关联的 Cluster Group 上。也就是说,Fleet 可以被视为实现图 18 中所示的部署门控(Deployment Gate) 的机制,尽管其他因素也可以考虑在内(例如,不要在周五下午 5 点开始部署)。下一节将介绍一种版本控制策略,可以覆盖在这种机制上,以控制什么时候部署什么特性。
我们关注 Fleet 作为触发 Helm Charts 执行的代理,但不应该忽略 Helm Chart 本身的核心作用,它们是我们指定服务部署方式的核心,确定要部署的互连的微服务集,正如我们将在下一节中看到的,它们是每个微服务版本的最终仲裁者。后面的章节还将介绍这些 Chart 如何指定一个 Kubernetes Operator 在部署微服务时运行,并以某种特定于组件的方式配置新启动的微服务。最后,Helm Charts 可以指定每个微服务允许使用的资源(例如,处理器内核),包括最小阈值和上限。当然,这是因为 Kubernetes 支持相应的 API 调用,并相应的控制资源的使用,才让这一切成为可能。
请注意,关于资源分配的最后一点揭示了我们所关注的边缘/混合云的基本特征: 它们通常是资源受限的,而不是提供看似无限的基于数据中心的弹性云的资源。因此,配置和生命周期管理被用于决定(1)我们想要部署什么服务,(2)这些服务需要多少资源,以及(3)如何在规划好的服务集合之间共享可用资源。
实现细节问题
我们故意不深入研究生命周期管理子系统中的单个工具,但是细节常常很重要,而 Fleet 就为我们提供了很好的例子。细心的读者可能已经注意到,我们可以使用 Jenkins 来触发 Fleet 部署一个升级的应用,就像使用 Terraform 一样。不过,由于 Fleet 的 Bundle 和 Cluster Group 抽象很方便,我们决定使用 Fleet 的内部触发机制。
在 Fleet 作为部署机制上线后,开发人员注意到代码存储库变得非常缓慢。事实上,这是因为 Fleet 轮询指定的 GitRepo 来监控 Bundle 的更改,而轮询太过频繁,导致存储库。修改"轮询频率(polling-frequency)"参数可以改善这种情况,但也让人们想知道为什么 Jenkins 的触发机制没有导致同样的问题。答案是 Jenkins 与存储库集成得更好(特别是在 Git 上运行的 Gerrit),当文件签入发生时,存储库会向 Jenkins 推送事件通知,而不需要轮询。
4.5 版本控制策略
本章介绍的 CI/CD 工具链只有在与端到端版本策略协同应用时才能发挥作用,从而确保正确的源模块组合得到集成,正确的镜像组合得到部署。请记住,高层挑战是管理我们的云支持的特性集,也就是说,一切都取决于我们如何为这些特性设定版本。
我们的起点是采用被广泛接受的语义版本控制实践,每个组件被分配一个由三部分组成的版本号 MAJOR.MINOR.PATCH(例如,3.2.4
),其中 MAJOR 版本在你做出不兼容的 API 更改时递增,MINOR 版本在你以向后兼容的方式添加功能时递增,而 PATCH 对应于向后兼容的 bug 修复。
延伸阅读:
下面概述了版本控制和 CI/CD 工具链之间可能的相互作用,请记住,有不同的方法来解决这个问题。我们将这个顺序分解为软件生命周期的三个主要阶段:
研发期(Development Time)
- 签入源代码存储库的每个补丁都在存储库中的
VERSION
文件中包含一个最新的语义版本号。请注意,每个补丁并不一定等于每个提交,因为对"开发中"的版本(有时标为3.2.4-dev
)进行多次更改是很常见的。这个VERSION
文件被开发人员用来跟踪当前版本号,但正如我们在 4.3.2 节中看到的,也可以作为 Jenkins 作业的触发器,从而发布新的 Docker 或 Helm 工件。 - 与最终补丁相对应的提交也被标记为(在存储库中)对应的语义版本号。在 git 中,这个标签被绑定到一个哈希值,这个哈希值明确标识了提交,使得它成为将版本号绑定到某个特定源代码实例的权威方式。
- 对应于微服务的存储库里有 Dockerfile,提供了从该(以及其他)软件模块构建 Docker 镜像的方式。
集成期(Integration Time)
- CI 工具链对每个组件的版本号进行完整性检查,确保不会退化,当看到微服务的新版本号时,就构建新镜像并将其上传到镜像存储库中。按照惯例,该镜像在分配的唯一名称中包含相应的源代码版本号。
部署期(Deployment Time)
- CD 工具链在一个或多个 Helm Charts 中通过名称指定并实例化一组 Docker 镜像。由于这些镜像名称包括语义版本号,按照约定,我们知道部署的相应软件版本。
- 每个 Helm Chart 也被签入到存储库中,因此也有自己的版本号。每次 Helm Chart 改变时,由于 Docker 镜像的组成部分的版本改变,Chart 的版本号也会改变。
- Helm Charts 可以分层组织,也就是说,一个 Chart 包含一个或多个其他 Chart(每个 Chart 都有自己的版本号),根 Chart 的版本有效标识了整个部署的系统版本。
请注意,根 Helm Chart 的新版本的提交可以被视为触发流水线 CD 部分的信号(如图 18 中的"部署门控"所示),即模块(特性)的组合现在已经可以部署了。当然,也可以考虑其他因素,比如上面提到的时间。
虽然刚才介绍的源代码 -> Docker 镜像 -> Kubernetes 容器的关系可以在工具链中进行编码,但至少在自动健康测试级别上可以捕捉明显的错误,最终责任落在签入源代码的开发人员和签入配置代码的运维人员身上,他们必须正确指定想要的版本。拥有一个简单而清晰的版本控制策略是完成这项工作的先决条件。
最后,因为版本控制本质上与 API 相关,每当 API 以非向后兼容的方式发生变化时,MAJOR 版本号就会增加,因此开发人员有责任确保软件能够正确使用所依赖的任何 API。当涉及到持久化状态时,这样做就会出现问题,这里的持久化状态指的是必须在访问它的软件的多个版本之间保存的状态。这是所有持续运行的操作系统都必须处理的问题,通常需要数据迁移(data migration) 策略。以通用方式解决应用程序级状态的问题超出了本书范围,但是解决云管理系统(它有自己的持久化状态)的问题是我们在下一章讨论的主题。
4.6 管理密钥
截止到现在的讨论忽略了一个重要细节,那就是如何管理密钥。例如,Terraform 需要访问像 GCP 这样的远程服务的凭证,以及用于确保边缘集群内微服务之间通信安全的密钥。这些密钥实际上是混合云配置状态的一部分,意味着它们存储在配置存储库(Config Repo)中,就像所有其他配置即代码(Configuration-as-Code)工件一样,但问题在于,存储库通常不是为安全而设计的。
从高层来说,解决方案很简单。运维安全的系统所需的各种密钥都是加密的,只有加密的版本被签入配置存储库(Config Repo)。这将问题减少到只需要担心一个密钥上,但这就把问题推到了后面。那么,我们如何管理(保护和分发)解密密钥所需的密钥呢?幸运的是,有一些机制可以帮助解决这个问题。例如,Aether 使用两种不同的方法,每种方法都有自己的优缺点。
其中一种方法是git-crypt
工具,它与上面介绍的高层概述非常匹配。在这种情况下,CI/CD 机制的"中央处理回路"(与 Aether 中的 Jenkins 相对应)是负责解密特定组件的密钥并在部署时将其传递给各种组件的可信实体。这个"传递"步骤通常是使用 Kubernetes Secrets 机制实现的,它是一个向微服务发送配置状态的加密通道(也就是说,它类似于 ConfigMaps)。这个机制不应该与 SealedSecrets(接下来将讨论)相混淆,因为它本身并不能解决我们在这里讨论的更大的问题,即如何在运行的集群之外管理密钥。
这种方法的优点是具有通用性,因为它不做特别的假设,适用于所有密钥和组件。但也带来了对 Jenkins 过分信任的负面影响,或者更确切的说,对 DevOps 团队使用 Jenkins 的做法的负面影响。
第二种方法是 Kubernetes 的 SealedSecrets 机制,其思想是信任 Kubernetes 集群中运行的进程(技术上,这个进程被称为 Controller)来代表所有其他 Kubernetes 托管的微服务管理密钥。在运行时,这个进程创建一个私有/公共密钥对,并使公共密钥对 CI/CD 工具链可见。私钥仅限于 SealedSecrets 控制器,被称为密封密钥(sealing key) 。这里不打算详细介绍完整的协议细节,只需要知道可以将公钥与随机生成的对称密钥结合使用来加密需要存储在配置存储库(Config Repo)中的所有密钥,稍后(在部署时),各个微服务请求 SealedSecrets Controller 使用其密封密钥来帮助它们解锁这些密钥。
虽然这种方法不像第一种方法那么通用(也就是说,它专门用于保护 Kubernetes 集群中的密钥),但优点是使处理回路完全避免人工操作,密封密钥是在运行时以编程方式生成的。然而,一个复杂的问题是,通常更可取的做法是将该密钥写入持久化存储,以防止不得不重新启动 SealedSecrets Controller,这可能会造成多一个需要保护的攻击面。
延伸阅读:
git-crypt - transparent file encryption in git.
"Sealed Secrets" for Kubernetes.
4.7 GitOps 呢?
本章介绍的 CI/CD 流水线与 GitOps 是一致的,GitOps 是一种围绕配置即代码(Configuration-as-Code) 的思想设计的 DevOps 方法,使代码成为构建和部署云原生系统的唯一真实来源。该方法的前提是首先使所有配置状态都具有声明性(例如,在 Helm Charts 和 Terraform 模板中指定),然后将此存储库作为构建和部署云原生系统的唯一真实来源。无论是给 Python 文件打补丁还是更新配置文件,存储库都会触发本章所述的 CI/CD 流水线。
虽然本章介绍的方法是基于 GitOps 模型的,但是有三个注意事项意味着 GitOps 并不是故事的结尾。所有这一切都取决于这样一个问题: 操作云原生系统所需的所有状态是否可以完全使用基于存储库的机制进行管理。
首先要考虑的是,我们需要承认开发软件的人和使用软件构建和运维系统的人之间的差异。DevOps(在其最简单的公式中)意味着应该没有区别,而在实践中,开发人员往往远离运维人员,或者更确切的说,他们远离关于其他人最终将如何使用他们的软件的设计决策。例如,软件在实现时通常会考虑一组特定的用例,但随后会与其他软件集成,以构建全新的云应用程序,这些应用程序拥有自己的一组抽象和特性,相应的,有自己的配置状态集合。对于 Aether 来说就是这样,其 SD-Core 子系统最初是为全球蜂窝网络实现的,但现在被重新用于支持企业的私有 4G/5G。
虽然这样的状态确实可以在 Git 存储库中进行管理,但通过 pull request 进行配置管理的想法过于简单。有低级(以实现为中心)和高级(以应用程序为中心)变量,换句话说,在基本软件上运行一个或多个抽象层是很常见的。在这种限制下,甚至可能终端用户(例如,Aether 中的企业用户)也想要改变状态,这意味着可能需要细粒度的访问控制。这些都不影响 GitOps 作为管理这种状态的一种方法,但它确实提出了这样一种可能性,即并非所有状态都是平等创建的,有一系列配置状态变量需要在不同的时间被具有不同技能集的不同人员访问,最重要的是,需要不同的特权级别。
第二个需要考虑的问题与配置状态产生的位置有关。例如,考虑分配给集群中服务器的地址,可能源于某个组织的库存系统。或者在另一个特定于 Aether 的示例中,需要调用远程 Spectrum Access Service (SAS) 来了解如何为已部署的小基站配置无线电设置。你可能天真的认为,可以从 Git 存储库中的 YAML 文件中取出这个变量。通常,系统必须处理多个(有时是外部的)配置状态源,知道哪个副本是权威的,哪个是派生的,这本身就有问题。没有唯一正确的答案,但是像这样的情况可能会导致需要维护配置状态的权威副本,而不是对该状态的任何一次使用。
第三个需要考虑的是这种状态变化的频率,因此可能会触发重新启动甚至是重新部署一组容器。这样做对于"一次设置"的配置参数当然有意义,但是"运行时可设置"的控制变量呢?更新有可能频繁更改的系统参数的最经济有效的方法是什么?这再次提出了一种可能性,即不是所有状态都是平等的,存在连续变化的配置状态。
这三个注意事项指出了构建时配置状态和运行时控制状态之间的区别,这是下一章的主题。然而,我们强调,如何管理这种状态的问题没有唯一的正确答案,在"配置"和"控制"之间划清界限是出了名的困难。GitOps 支持的基于存储库的机制和下一章介绍的运行时控制方案都有其价值,问题是,对于任何需要维护以使云正常运行的给定信息,哪一个更匹配。