car

image via shipvehicles

使用 GitOps 管理交付内容是一个常见的 DevOps 使用模式。 我们会使用 Git 进行版本管理, 并通过 Git Tag 来跟踪部署软件的版本。 虽然这看上去可以工作,但在云原生技术的推动下,版本的概念远非如此简单。

版本问题

在引入 GitOps 到 DevOps 流程后,我们可以借助 GitOps 的能力进行持续集成和持续交付。 GitOps 解决了三个核心问题:内容版本协作。然而,我们经常将注意力集中在内容上,却经常忽略了版本管理问题。

在 GitOps 过程中,有哪些版本管理问题需要解决呢?

一套完整的 GitOps 解决方案包括内容描述(Manifest)、构建方案(Builder)和生效方案(Applier)。其中,内容描述衍生出多种描述语言,从最传统的 Ansible / Chef,到云计算和云原生流行起来的 TerraformHelmKustomize 等。引入了这么多内容描述方式之后,当我们想要明确一个应用的版本时,变得非常复杂。

当提到版本时,我们是指应用源代码的版本?还是指镜像的版本?或者是指某个基础设施即代码(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 进行描述

还需要注意的是,中文中有两个概念(库 Libray 和仓库 Repository)。 无论是哪种定义,都没有表示一个库一定是一个版本化(Git / SVN)仓库, 这意味着我们并没有假设代码库一定是被版本化管理的。当我们将代码文件打包成一个 zip 文件时(GitHub 的 zip 下载就是这种形式),即使这个 zip 文件失去了所有的 Git 历史,它仍然是一个代码库。

代码的版本实质上是应用的版本,这是作者的意图表达。这个版本往往是 vx.y.z 这种形式,而不是 Git commit hash, 最常见的管理方案是基于语义化版本

我推荐的版本存储方式是使用一个 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 文件中定义的版本。
  • 源代码仓库版本,即 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)的衍生。毕竟, 让每个运行的程序都知道自己来自何处、自己是谁,在大规模集群管理下已经变得相当重要。


原文链接: GitOps 和版本管理 | Log4D

3a1ff193cee606bd1e2ea554a16353ee

欢迎关注我的微信公众号:窥豹

窥豹