image via shipvehicles
如何使用 GitOps 管理交付内容版本是一个常见的问题。一个简单的回答是使用 Git 进行版本管理,并通过 Git Tag 来跟踪仓库内容的版本。虽然这可以解决一些问题,但在云原生技术的推动下,版本的概念远非如此简单。
在引入 GitOps 到 DevOps 流程后,我们可以借助 GitOps 的能力进行持续集成和持续交付。 GitOps 解决了三个核心问题:内容、版本和协作。然而,我们经常将注意力集中在内容上,却经常忽略了版本管理问题。
那么,在 GitOps 过程中,有哪些版本管理问题需要解决呢?
一套完整的 GitOps 解决方案包括内容描述(Manifest)、构建方案(Builder)和生效方案(Applier)。其中,内容描述衍生出多种描述语言,从最传统的 Ansible / Chef,到云计算和云原生流行起来的 Terraform、Helm、Kustomize 等。引入了这么多内容描述方式之后,当我们想要明确一个应用的版本时,变得非常复杂。
当提到版本时,我们是指应用源代码的版本?还是指镜像的版本?或者是指某个基础设施即代码(IaC)仓库的版本?进一步地,如果我们要发布一组相互关联的应用,例如前端和后端,或者由多个后端应用组成的系统,如何清晰地描述它们之间的版本依赖关系?
一旦版本描述不准确,就会引入一系列问题,例如错误的上线版本、混乱的应用依赖关系、无法回滚等。
大多数团队对于这个问题的解决方案比较模糊:发布最新的版本,先发布后端再发布前端。然而,在一个复杂的业务团队或需要同时保留多个稳定版本的团队中,这种粗暴的方案是无法接受的。
版本管理不仅解决了版本定位的问题,还可以用于管理应用之间的依赖关系。因此,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 进行描述
需要注意的是,中文中有两个概念(库和仓库),无论是哪种定义,都没有明确规定一个库一定是一个 Git / SVN 仓库,这意味着我们并没有假设代码库一定是被版本化管理的。当我们将代码文件打包成一个 zip 文件时(GitHub 的 zip 下载就是这种形式),即使这个 zip 文件失去了所有的 Git 历史,它仍然是一个代码库。
代码的版本实质上是作者的意图表达。目前最常见的管理方案是基于语义化版本。
我推荐的版本存储方式是使用一个 VERSION
文件将版本存储在代码目录中。例如,Git 的 Version 文件可以清楚地看到当前 Git 的版本是:
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
文件中定义的版本。引入制品版本管理后,问题变得更加复杂,因为制品带来了更多的问题:
制品的概念非常重要,其中最核心的一个理念是:制品可以通过打包器形成新的制品。
由于制品具有版本,而新的制品将形成新的版本,我们将进入多层嵌套。为了避免最原始的版本信息丢失,我们将 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 | 制品打包时候需要的额外信息,比如容器的操作系统,进程的运行容量,默认环境变量等 |
这些概念共同构成了制品版本管理的核心要素,帮助我们管理和跟踪制品的不同版本,以及它们之间的关联和依赖关系。
打包器是一种工具,通过打包操作(Packaging)将制品组织成特定的格式,形成全新的制品。 打包的过程涉及编译、链接、合并和存档等常见概念。
它通常以上游(Upstream)作为输入,上游可以是源码,也可以是其他系统生成的制品(Artifacts)。
例如,在打包 Docker Compose 时,输入是镜像(Image),而对于 Helm,输入则包括镜像、启动配置文件和 Helm 模板,而输出则是 YAML 文件。
制品是一种数据集合,可以在特定环境中运行。 它由可执行文件和启动配置文件等组成,通常以文件形式存在,并且可以在运行时环境下运行。制品具有嵌套的能力,可以包含其他制品。
最常见的形态是二进制文件(ELF),也可以是适用于特定环境的运行物,如容器镜像。
制品通常以文件形式进行传输。
安装器是一种工具,用于将制品安装到运行时环境中。 它负责将制品部署到目标环境并确保其正常运行。 例如,dpkg、Pacman 是常见的安装器工具,而在 Windows 平台上,我们常见自引导的安装器。
对于特定的环境如 Kubernetes,我们可以使用 kubectl 命令进行安装,而 Helm 则使用helm
命令来进行安装。
当我们理解了这些概念后,我们或许会惊讶地发现,这些概念与 Linux 社区多年来的实践是如此相似。抛开云原生等新概念,Linux 社区早就拥有了完整的解决方案。
自豪地使用 ArchLinux。
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 版本管理本身,让我们重新面对文中的几个问题,通过以上的分析和调研,是否已经解决了这些问题呢?
VERSION
文件来确定软件版本,也就是上游版本(Upstream Version)v1.2.3-afe12c
的形式来追踪 Git 仓库中的版本,使用 v1.2.3-afe12c-b1
来追踪镜像构建物的版本。我们经常提到 GitOps,那么 GitOps 究竟解决了什么问题呢?是打包?发布?还是版本定义?
我认为 GitOps 的核心在于 IaC ,它提供了一种以终态描述的视角来描述我们期望系统处于的状态,并且这种期望是可透明、可持久化和可编程的。在 IaC 出现之前,我们可以使用传统的 Deb、RPM 等方式来完成部署,只是不太关注定制化配置和配置漂移检测的问题。
每一层制品都会引入新的配置(Config)/ 扩展(Extension)/ 值(Values)/ 环境变量(Env)等等,无论如何称呼,我们统一称之为配置。这种不确定性在大规模系统中是不稳定的,因此 GitOps 的版本管理显得尤为重要。而这些新加入的 Packaging Info 的描述在大规模集群管理下也带来了新的问题。Terraform 的 HCL 提供了一套描述抽象的 DSL,Google 的 CUE 也是尝试提供一套抽象的方案,而蚂蚁金服的 KCL 也是一种描述抽象。 Pulumi 则放弃了 DSL 的抽象,转而使用通用编程语言来描述。
就像当年的 RPM / DEB / PKGBUILD 一样。
复杂性是不会消失的,只是以另一种形式存在或被隐藏起来。好的抽象解决问题,而不好的抽象则会带来问题。当我们被新事物迷惑时,也许我们可以回头看看那些老家伙(如 Arch Linux、Debian、CentOS),他们是如何解决各种复杂问题的。
换个领域来描述,程序的依赖管理(Lib Dependencies)也是一个复杂的系统。有些人抱怨 Maven 太复杂,go.mod
难以使用,pip
太简单不够用,而 node_modules
则是个黑洞。
依然是同样的道理,复杂性无法消除,留给用户的只有掌控或随缘了。