image via shipvehicles
使用 GitOps 管理交付内容是一个常见的 DevOps 使用模式。 我们会使用 Git 进行版本管理, 并通过 Git Tag 来跟踪部署软件的版本。 虽然这看上去可以工作,但在云原生技术的推动下,版本的概念远非如此简单。
版本问题
在引入 GitOps 到 DevOps 流程后,我们可以借助 GitOps 的能力进行持续集成和持续交付。 GitOps 解决了三个核心问题:内容 、版本 和 协作。然而,我们经常将注意力集中在内容上,却经常忽略了版本管理问题。
在 GitOps 过程中,有哪些版本管理问题需要解决呢?
一套完整的 GitOps 解决方案包括内容描述(Manifest)、构建方案(Builder)和生效方案(Applier)。其中,内容描述衍生出多种描述语言,从最传统的 Ansible / Chef,到云计算和云原生流行起来的 Terraform、Helm、Kustomize 等。引入了这么多内容描述方式之后,当我们想要明确一个应用的版本时,变得非常复杂。
当提到版本时,我们是指应用源代码的版本?还是指镜像的版本?或者是指某个基础设施即代码(IaC)仓库的版本?进一步地,如果我们要发布一组相互关联的应用,例如前端和后端,或者由多个后端应用组成的系统,如何清晰地描述它们之间的版本依赖关系?
一旦版本描述不准确,就会引入一系列问题,例如错误的上线版本、混乱的应用依赖关系、无法回滚等。
大多数团队对于这个问题的解决方案比较模糊:发布最新的版本,先发布后端再发布前端。然而,在一个复杂的业务团队或需要同时保留多个稳定版本的团队中,这种粗暴的方案是无法接受的。
版本管理不仅解决了版本定位的问题,还可以用于管理应用之间的依赖关系。因此,GitOps 版本管理需要解决以下问题:
- 如何构建交付给客户的制品,如何定义这些制品的版本以及如何展示所有版本的制品。
- 如果有一组软件存在版本依赖关系,如何解决这些依赖问题。
- 如果一组软件形成了一个系统,如何描述这个系统。
在所有的交付产品中,版本管理都是一个重要问题。我们将逐步拆分版本管理这个命题,并从原始问题过渡到 GitOps 的版本管理最佳实践。
GitOps 简介
在开始正文之前,我将简要介绍 GitOps,以避免对关键概念的理解出现分歧。
GitOps 最核心的技术是基础设施即代码(IaC),即使用声明式描述来取代命令式描述。 通常,IaC 的内容基于某种范式,用于描述特定目标的期望状态。这个范式可以是 Terraform、Kubernetes YAML、Pulumi,甚至是 Ansible。而特定目标可以是云服务、Kubernetes,甚至是物理机。 直观的说,通过使用 YAML 取代过去的 Bash 命令,我们可以大大提高变更的准确性和可控性。
对于 GitOps 来说,是否使用 Git 并不是最重要的,我们也可以使用 SVN 来实现 GitOps。只是 Git 具有更广泛的适用范围,并可以充分发挥 Git 仓库在团队协作和持续集成/持续部署中的能力。
引入 Git 仓库后,我们还同时拥有了基于 Git Revision / Tag / Branch 的版本管理能力,这体现在业务上就是版本记录、多版本并行管理等方面。
简单地基于 Git Revision 进行描述还不足以满足我们的实际需求。
问题的源头 - 二进制文件和启动配置文件版本
在探索版本的源头时,我们会发现最原始的版本是代码的版本。
代码的版本是什么?是代码仓库的版本还是代码编译出来应用的版本。 这个版本并不是代码所在的版本管理系统(如 Git / Mercurial / SVN 等)的版本。尽管这两者经常相关,但事实上,一份代码本身只是一组代码文件,只要构建成功,就会有一个版本。如果没有定义,版本就是未知的,此时与仓库管理没有关联。
注意:下文我们不再区分 Git / Mercurial / SVN 多种版本管理方案,统一使用 Git 进行描述
还需要注意的是,中文中有两个概念(库 Libray 和仓库 Repository)。 无论是哪种定义,都没有表示一个库一定是一个版本化(Git / SVN)仓库, 这意味着我们并没有假设代码库一定是被版本化管理的。当我们将代码文件打包成一个 zip 文件时(GitHub 的 zip 下载就是这种形式),即使这个 zip 文件失去了所有的 Git 历史,它仍然是一个代码库。
代码的版本实质上是应用的版本,这是作者的意图表达。这个版本往往是 vx.y.z
这种形式,而不是 Git commit hash, 最常见的管理方案是基于语义化版本。
我推荐的版本存储方式是使用一个 VERSION
文件将版本存储在代码目录中。例如,Git 的 Version 文件可以清楚地看到当前 Git 的版本是:
ini
GVF=GIT-VERSION-FILE
DEF_VER=v2.42.GIT
其中的 .GIT
也明确说明了这个代码是一个开发模式下的版本。如果我们切换到一个发布版本的代码,例如 v2.39.3 版本,我们可以看到 DEF_VER=v2.39.3
,这是一个遵循标准的制品(Artifacts)格式。这里还有两个最佳实践:
- 使用文件来保存源代码的版本。
- 源代码中的版本文件始终处于
dev
模式,只有在进行标记封版之后才会成为正式版本号。
源代码的最终产物不仅包括二进制文件、可执行文件和动态库(.dll
/ .so
/ .dylib
),还包括相应的启动配置文件。这些启动配置文件通常与对应的版本一起进行管理。例如,Nginx 的启动文件 nginx.conf
和 Redis 的启动文件 redis.conf
,这些启动配置文件也应该纳入版本管理。
从源代码仓库构建出来的内容就是制品(Artifacts)。制品已经具有两个版本:
- 源代码版本,即使用
VERSION
文件中定义的版本。 - 源代码仓库版本,即 Git Revision
制品版本管理
引入制品版本管理后,问题变得更加复杂,因为制品带来了更多的问题:
- 制品是什么,由什么构成?(上文已经回答)
- 制品如何进行安装,安装程序(Installer)是什么,运行时(Runtime)是什么?
- 制品信息如何进行集中管理,数据如何管理?
- 制品之间是否存在依赖关系,如何处理依赖关系,版本如何约束?
制品的概念非常重要,其中最核心的一个理念是:制品可以通过打包器形成新的制品。
由于制品具有版本,而新的制品将形成新的版本,我们将进入多层嵌套。为了避免最原始的版本信息丢失,我们将 Version 的概念扩展为 Upstream Version,这是软件作者人为指定的版本,是所有版本的源头。
为什么制品可以形成新的制品呢?我举一个 Kubernetes 容器环境下的例子。 容器是一种交付形式,它将可执行文件和启动配置文件写入镜像文件中,并可以在容器环境中运行。形成的镜像文件存在于镜像仓库中,本身也是一种制品。
另外,Helm / Kustomize 也是一种交付形式(打包工具链)。 每个构建层解决其特定问题,并且可以在特定环境(例如容器、Kubernetes、云基础设施)中运行。
每个制品都需要构建,过程中会有自己的额外描述信息(Packaging Info),这些额外的描述信息本身也会发生变化,因此会增加一个版本。在实践中,我们希望制品的版本与其上游版本绑定。每种打包机制可能会包含自己的一些定义配置,但仍然遵循上游的版本。例如,Kubernetes 的 Workload 包含一个镜像,Workload 的描述是附加信息,而镜像仍然受到上游控制。
Artifact + Packaging Info = New Artifact,制品经过打包可以形成新的制品。直到最后的 Installer 放置到相应的环境中生效。
如果这些制品可以通过文件(IaC)进行描述,就形成了各种 IaC 仓库,这些仓库成为了 GitOps 的核心对象。
概念梳理
让我们来理清一下这些略有晦涩的概念:
中文 | 英文 | 解释 |
---|---|---|
源代码 | Source Code | 程序、应用的源文件集合 |
代码仓库 | Source Code Repo | 源代码放到版本管理系统中的管理单元 |
版本 | Version | 源代码对应的应用版本,人为定义,语义化,有些场景会说 Upstream Version |
可执行文件 | Executable File | 源代码构建出来的结果,一般是 ELF 可执行文件,也可以是 Lib 文件 |
启动配置文件 | Configuration File | 配套 ELF / Lib 的启动配置文件,区别于广泛意义上的配置文件(比如 Kubernetes YAML) |
制品 | Artifact | 包含可执行文件和启动配置文件的集合,可以运行在运行时下面,一般是文件形态。制品可以嵌套制品。 |
安装器 | Installer | 将制品安装到运行时的工具 |
运行时 | Runtime | 制品的运行环境,比如特定操作系统,Kubernetes,Docker Engine。 |
打包器 | Packer | 将制品打包成特定格式(新的制品)的工具 |
打包附属信息 | Packaging Info | 制品打包时候需要的额外信息,比如容器的操作系统,进程的运行容量,默认环境变量等 |
这些概念共同构成了制品版本管理的核心要素,帮助我们管理和跟踪制品的不同版本,以及它们之间的关联和依赖关系。
打包器 Packer
打包器是一种工具,通过打包操作(Packaging)将制品组织成特定的格式,形成全新的制品。 打包的过程涉及编译、链接、合并和存档等常见概念。
它通常以上游(Upstream)作为输入,上游可以是源码,也可以是其他系统生成的制品(Artifacts)。
例如,在打包 Docker Compose 时,输入是镜像(Image),而对于 Helm,输入则包括镜像、启动配置文件和 Helm 模板,而输出则是 YAML 文件。
制品 Artifacts
制品是一种数据集合,可以在特定环境中运行。 它由可执行文件和启动配置文件等组成,通常以文件形式存在,并且可以在运行时环境下运行。制品具有嵌套的能力,可以包含其他制品。
最常见的形态是二进制文件(ELF),也可以是适用于特定环境的运行物,如容器镜像。
制品通常以文件形式进行传输。
安装器 Installer
安装器是一种工具,用于将制品安装到运行时环境中。 它负责将制品部署到目标环境并确保其正常运行。 例如,dpkg、Pacman 是常见的安装器工具,而在 Windows 平台上,我们常见自引导的安装器。
对于特定的环境如 Kubernetes,我们可以使用 kubectl 命令进行安装,而 Helm 则使用helm
命令来进行安装。
Linux 社区实践
当我们理解了这些概念后,我们或许会惊讶地发现,这些概念与 Linux 社区多年来的实践是如此相似。抛开云原生等新概念,Linux 社区早就拥有了完整的解决方案。
每一层制品都会引入新的配置(Config)/ 扩展(Extension)/ 值(Values)/ 环境变量(Env)等等,无论如何称呼, 我们统一称之为配置。 这些新加入的 Packaging Info 的描述在大规模集群管理下也带来了新的问题。
自豪地使用 ArchLinux。
Arch Linux 社区的实践
Arch Linux 使用 Pacman 作为包安装器,并且拥有一套完整的构建方案。
在 Arch Linux 中,PKGBUILD
link用于描述包的构建方式,它本身是 Bash 的子集,是描述包的核心文件。
版本管理方面,Arch Linux 提供了清晰明确的方案,并且设计了完整的制品嵌套解决方案。 在 PKGBUILD
中,pkgver
表示上游版本,并经过适当的修正,使用 _
替代 -
,并调整了时间戳的格式。而 pkgrel
则表示发布号,而不是构建号,每次发布都会增加该号码,用于管理 Arch Linux 的发布动作。当大部分 PKGBUILD
发生变化时,发布号都会发生变化。
此外,epoch
是一个强制构建版本的机制,默认为 0 并且隐藏起来。使用 epoch
是一种兜底的解决方案,通过破坏版本对比来强制进行新版本的升级。
另外,在 PKGBUILD
中,使用了版本依赖的方式来优雅地解决模块的问题。 例如,base-devel
包是对 26 个基础软件的依赖,而该包本身并没有具体的内容。这种方案非常优雅,避免了引入一个新的模型(比如叫做 Group / 产品)。
基于 GitOps 的版本管理解决方案
最后让我们回归到 GitOps 版本管理本身,让我们重新面对文中的几个问题,通过以上的分析和调研,是否已经解决了这些问题呢?
- 交付给客户的制品如何构成,如何定义这个制品的版本,以及如何呈现所有版本的制品?
- 使用
VERSION
文件来确定软件版本,也就是上游版本(Upstream Version) - 不同形式的制品有独立的版本号,这些版本号需要与上游版本关联。例如,可以使用
v1.2.3-afe12c
的形式来追踪 Git 仓库中的版本,使用v1.2.3-afe12c-b1
来追踪镜像构建物的版本。
- 使用
- 如果存在一组软件,如何解决这组软件之间的版本依赖问题?
- 这个问题可以交给具体的安装器处理,一般这些元信息会在对应的打包信息(Packaging Info)中定义,并由 Installer 识别和处理。
- 如果一组软件形成了一套体系,如何表达这个体系?
- 创建一个没有上游版本的新制品,其中交付的内容可能为空,但包含相应的打包信息和依赖信息。
- (或者)也可以真正抽象出一个新的概念来进行管理,这取决于打包器和安装器之间的协作。
总结
版本管理的智慧,其实已经体现在当年的 RPM / DEB / PKGBUILD 中。 我们通过明确版本定义权交给应用作者,提出制品嵌套的概念,允许版本的概念进行多层嵌套。
我们希望,最后运行的制品版本仍然是原始应用版本(Upstream Version)的衍生。毕竟, 让每个运行的程序都知道自己来自何处、自己是谁,在大规模集群管理下已经变得相当重要。