深入浅出边缘云 | 4. 生命周期管理

随着技术的发展以及应用对时延、带宽、安全的追求,一个明显的技术趋势是越来越多的应用组件将会被部署到企业所管理的网络边缘。本系列是开源电子书Edge Cloud Operations: A Systems Approach的中文版,详细介绍了基于开源组件构建的边缘云的架构、功能及具体实现。

第4章 生命周期管理

生命周期管理关心的是随着时间的推移更新和发展一个正在运行的系统。我们已经完成了硬件配置和安装基本软件平台的引导步骤(第3章),所以现在将注意力转向持续升级在该平台上运行的软件。作为提醒,我们假设基础平台包括运行在每个服务器和交换机上的Linux,加上Docker、Kubernetes和Helm,以及SD-Fabric控制网络。

虽然可以狭隘的看待生命周期管理,并假设我们想要推出的软件已经经历了离线集成和测试过程(这是厂商发布其新版本产品的传统模式),但我们采取了一种更广泛的方法,从开发过程开始创建新特性和功能。包括"创新"步骤在内形成了如图17所示的闭环,云行业告诉我们,这将帮助我们以更快的速度推出新特性。

图17. 以提高特性发布速度为目标的良性循环。

当然,并不是每个企业都拥有和云提供商一样的开发人员大军,但并不意味着企业就失去了这个机会。创新可以来自许多来源,包括开源,所以真正的目标是使集成和部署民主化,这正是本章介绍的生命周期管理子系统的目标。

4.1 设计概述

图18提供了流水线/工具链的概述,这组成了生命周期管理的两个部分,持续集成(CI)和持续部署(CD),并扩展了在第2章中的概要介绍。需要关注的关键是中间的镜像和配置存储库,代表了两个部分之间的"接口": CI生成Docker镜像和Helm Charts,并存储在各自的存储库中,而CD消费Docker镜像和Helm Charts,从各自的存储库中取出。

图18. CI/CD流水线概述。

配置存储库(Config Repo)还包含由资源配置(Resource Provisioning)生成的基础架构构件的声明性规范,特别是Terraform模板和变量文件[1]。虽然3.1节介绍的资源配置"手动"和"数据输入"步骤发生在CI/CD流水线之外,但配置的最终输出是签入配置存储库(Config Repo)的"基础设施即代码(Infrastructure-as-Code)"。这些文件是生命周期管理的输入,意味着每当这些文件发生变化时,Terraform就会作为CI/CD的一部分被调用。换句话说,CI/CD使底层云平台中的软件相关组件和运行在该平台上的微服务工作负载保持最新。

[1] 我们通常使用术语"Config Repo"表示一个或多个存储库,存储所有与配置相关的文件。在实践中,可能有一个存储库存储Helm Charts,另一个存储Terraform模板。

这个概要介绍中有三个要点。首先,通过在CI和CD(以及资源配置和CD)之间传递定义良好的工件,所有三个子系统都是松散耦合的,并且能够独立执行各自的任务。其次,成功构建和部署系统所需的所有可靠状态都包含在流水线中,特别是作为配置存储库(Config Repo)中的声明性规范。这是"配置即代码(Configuration-as-Code)"(有时也被称为GitOps)的基石,也是本书实现CI/CD的云原生方法。最后,运营商有机会对流水线进行灵活配置,如图中的"部署门控(Deployment Gate)"所示,控制什么功能在何时部署。这个主题在侧边栏和本章的其他地方都有讨论。

持续交付 vs 持续部署(Continuous Delivery vs Deployment)

你还会听到CD指的是"持续交付(Continuous Delivery)"而不是"持续部署(Continuous Deployment)",但我们感兴趣的是完整的端到端过程,所以本书中CD总是暗示后者。但请记住,"持续(continuous)"并不一定意味着"立即(instantaneous)",可以在CI/CD流水线中注入各种门控功能,以控制何时以及如何推出升级。重要的是,流水线中所有阶段都是自动化的。

那么,"持续交付(Continuous Delivery)"到底是什么意思呢?可以说,当与"持续集成(Continuous Integration)"结合在一起时,它是多余的,因为由流水线的CI部分产生的工件集(例如Docker镜像)正是正在交付的东西。除非需要部署这些工件,否则没有"下一步"。这很棘手,但有些人会认为CI仅限于测试新代码,而持续交付对应于最后的"发布工件"步骤。出于我们的目的,我们将"发布工件"归入流水线的CI部分。

延伸阅读:
Weaveworks. Guide to GitOps.

图18最左边显示的第三个存储库是代码存储库(Code Repo)。虽然没有明确指出,但开发人员正在将新特性和Bug修复不断签入这个存储库,然后触发CI/CD流水线。针对这些签入代码运行一组测试和代码评审,并将这些测试/评审的输出报告发送给开发人员,开发人员据此修改。(图18中的虚线暗示了这些开发测试反馈循环。)

图18最右边显示了部署目标集,其中StagingProduction是两个示例。我们的想法是,首先将新版本软件部署到一组预发(Staging)集群中,在一段时间内,它将承受实际的工作负载,然后在预发(Staging)部署让我们确信升级是可靠的之后,再将其部署到生产(Production)集群中。

这是对实际情况的简化描述。通常,在任何给定时间都可以部署两个以上不同版本的云软件。发生这种情况的一个原因是升级通常是渐进式的(例如,在一段较长的时间内每次只部署几个站点),这意味着即使是生产系统也在"Staging"新版本中扮演着角色。例如,一个新版本可能首先部署在10%的生产机器上,只有被认为是可靠的,才被推广到下一个25%,以此类推。具体的发布策略体现为可配置参数,详见4.4节。

最后,图18所示的两个CI阶段定义了测试(Testing) 组件。一个是针对签入代码存储库(Code Repo)的每个补丁集运行的一组组件级测试,这些测试作为集成的门禁,只有先通过这一轮初步测试,才能将补丁完全合并到代码存储库(Code Repo)中。一旦合并,流水线将跨所有组件运行构建,然后在质量保证(QA, Quality Assurance) 集群上进行第二轮测试。通过这些测试将决定是否部署,但是请注意,测试也发生在预发(Staging)集群中,作为流水线CD端的一部分。人们可能很自然的想知道在生产(Production)集群中运行软件后,如何继续测试?当然,这种情况也会发生,但我们倾向于称之为监控和遥测(以及随后的诊断),而不是测试,这是第6章的主题。

我们将在接下来的章节中更详细的探讨图18中的每个阶段,但在深入研究各个机制时,在头脑中保持高层次的、以特性为中心的视角是有帮助的。毕竟,CI/CD流水线只是一种精细的机制,帮助我们管理希望云支持的特性集。每个特性都从开发中开始,这与图18中集成门控(Integration Gate)剩下的所有内容相对应。一旦候选特性成熟到可以正式被代码存储库的主分支所接受(例如,合并),就进入了集成阶段,在此期间,该特性将与所有其他候选特性(包括新特性和旧特性)结合起来进行评估。最后,只要某个特定的特性子集被认为是稳定的,并且被证明是有价值的,就会被部署并最终在生产中运行。因为测试在一组特性的整个生命周期中处于中心位置,所以我们从这里开始。

4.2 测试策略

我们生命周期管理的目标是提高特性发布速度,但必须与交付高质量(可靠、可伸缩性和满足性能需求)的代码相平衡。确保代码质量需要经受一系列测试,但"快速"做到这一点的关键是有效使用自动化。本节介绍了一种测试自动化的方法,但是我们首先讨论的是整体的测试策略。

在Cloud/DevOps环境中进行测试的最佳实践是采用左移(Shift Left) 策略,该策略在开发周期的早期引入测试,也就是在图18所示的流水线左侧。要应用这一原则,首先必须了解需要什么类型的测试,然后可以设置自动化这些测试所需的基础设施。

4.2.1 测试类别

关于测试类型,有很多关于QA的词汇,但不幸的是,这些定义通常是模糊、重叠的,并且并不总是被一致使用。下面给出了满足我们目的的简单分类,根据CI/CD流水线中发生的三个阶段(相对于图18)组织了不同类别的测试:

  • 集成门控(Integration Gate): 这些测试针对每次签入的补丁集运行,因此必须快速完成,并意味着它们的范围有限。合并前测试有两类:
    • 单元测试(Unit Tests): 由开发人员编写的测试,专门测试单个模块。目标是通过对模块的公共接口执行"测试调用"来覆盖尽可能多的代码路径。
    • 冒烟测试(Smoke Tests): 功能测试的一种形式,通常针对一组相关模块运行,但通过简单/粗略的方式(这样它们可以运行得更快)运行。"冒烟测试"一词的词源据说来自于硬件测试,比如,"当你打开盒子时,烟雾会从盒子里冒出来吗?"
  • QA集群: 这些测试定期运行(例如,一天一次,一周一次),因此可以覆盖范围更广。它们通常测试整个子系统,或者在某些情况下,测试整个系统。有两类合并后/部署前测试:
    • 集成测试(Integration Tests): 确保一个或多个子系统正确运行,并遵循已知的不变性。除了端到端(跨模块)功能之外,这些测试还使用了集成机制。
    • 性能测试(Performance Tests): 类似于一定范围内(例如,在子系统级别)的功能测试,但是测量可量化的性能参数,包括扩展工作负载的能力,而不是正确性。
  • 预发集群(Staging Cluster): 在推出到生产环境之前,候选版本要在预发集群上运行很长一段时间(例如,几天)。这些测试在一个完整且完全集成的系统上运行,通常用于发现内存泄漏以及其他随时间和工作负载变化的问题。此阶段只运行一种类型的测试:
    • 浸泡测试(Soak Tests): 有时被称为金丝雀测试(Canary Tests) ,这些测试通过人工生成流量以及来自真实用户的请求相结合,在完整系统上处理真实工作负载。因为集成和部署了整个系统,所以这些测试也用于验证CI/CD机制,例如,签入配置存储库(Config Repo)的规格定义等。

图19总结了测试的顺序,突出了它们之间跨生命周期时间线的关系。注意,最左边的测试通常作为开发过程的一部分重复进行,而最右边的测试则是生产部署持续监控的一部分。为了简单起见,图中显示了在部署之前运行的浸泡测试,但是在实践中,系统的新版本可能会持续不断的推出。

图19. 沿着特性发布时间轴的测试顺序,由CI/CD流水线实现。

制定测试策略的挑战之一是决定给定测试是否属于决定合并补丁的冒烟测试集,还是补丁合并到代码存储库后,但在部署之前发生的集成测试集。并没有严格的规则,这是一种权衡。我们都希望尽可能早的测试新软件,但是完全集成需要时间和资源(即运行候选软件的真实平台)。

与这种权衡相关的,是测试基础设施需要的虚拟资源(例如,预先配置了很多底层平台的VM)和物理资源(例如,忠实代表最终目标硬件的小集群)的组合。同样,这并不是硬性的规则,但早期(Smoke)测试倾向于使用预先配置的虚拟资源,而后期(Integration)测试倾向于在具有代表性的硬件或干净的VM上运行,使用从头构建的软件。

你也会注意到,在这个简单的分类中,没有提到回归测试,但我们的观点是,回归测试的设计是为了确保Bug一旦被识别和修复,就不会再次引入代码中,这意味着它是新测试的常见来源,可以添加到Unit、Smoke、Integration、Performance或Soak测试中。实际上,大多数测试都是回归测试,与它们在CI/CD流水线中运行的位置无关。

4.2.2 测试框架

关于测试框架,图20显示了来自Aether的示例。具体细节会有很大不同,取决于需要测试的功能类型。在Aether中,相关组件显示在右边,但重新排列以突出子系统之间自顶向下的依赖关系,相应的测试自动化工具显示在左边,可以把它们看作特定领域测试类的框架(例如,NG40将5G工作负载发送到SD-Core和SD-RAN上,而TestVectors将数据包注入交换机)。

图20. Aether测试框架示例。

图20显示的某些框架是与相应的软件组件共同开发的。TestVectors和TestON就是这样,它们分别把定制的工作负载发送到Stratum (SwitchOS)和ONOS (NetworkOS)上,两者都是开源的,因此可以深入了解构建测试框架的挑战。相比之下,NG40是用于模拟符合3GPP标准的蜂窝网络流量的专有框架,由于其复杂性及其遵循3GPP标准的价值,是一个封闭的商业产品。

Selenium和Robot是五个例子中最常见的,都是开源项目,拥有活跃的开发人员社区。Selenium是用于自动化web应用测试的工具,而Robot则是用于向任何定义良好的接口生成请求的更通用的工具。开发人员可以编写扩展、库、驱动和插件来分别测试用户门户和运行时API的特定特性的意义上说,这两个系统都是框架[2]。它们都说明了测试框架的目的,即提供一种方法(1)自动化执行一系列测试;(2)收集、归档测试结果;(3)对测试结果进行评价和分析。此外,当这些框架被用于测试具有可伸缩性的系统(如云服务)时,是否有必要使它们也具有可伸缩性?

[2] Selenium实际上可以作为库使用,可以在Robot框架内调用它,如果考虑在Web GUI上的一组HTML定义的元素(如文本框、按钮、下拉菜单等)上调用HTTP操作,这就比较有用。

最后,如前一小节所讨论的,每个测试框架都需要一组资源,用于运行测试套件(生成工作负载)和正在测试的子系统。对于后者,理想状态是为每个开发团队生成目标集群的完整副本,但在云中按需实例化虚拟环境更划算。幸运的是,由于正在开发的软件是容器化的,而且Kubernetes可以在VM中运行,因此可以直接支持虚拟测试环境,也就意味着可以为不频繁(例如每日)的集成测试预留专用硬件。

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.

在较高层次上来说,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: GitOps at Scale.

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修复。

延伸阅读:
Semantic Versioning 2.0.0.

下面概述了版本控制和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支持的基于存储库的机制和下一章介绍的运行时控制方案都有其价值,问题是,对于任何需要维护以使云正常运行的给定信息,哪一个更匹配。

你好,我是俞凡,在Motorola做过研发,现在在Mavenir做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。
微信公众号:DeepNoMind

你可能感兴趣的:(深入浅出边缘云 | 4. 生命周期管理)