Docker-和-Jenkins-持续交付第三版-全-
Docker 和 Jenkins 持续交付第三版(全)
原文:
annas-archive.org/md5/d22a94edab5feae6e598a1373cd0c8c5译者:飞龙
序言
使用 Docker 和 Jenkins 进行持续交付 – 第三版 解释了结合 Jenkins 和 Docker 的优势,如何改善应用程序开发的持续集成与交付过程。书中从设置 Docker 服务器和配置 Jenkins 开始,接着概述了如何在 Docker 文件上构建应用程序,并通过持续交付流程(如持续集成、自动化验收测试和配置管理)将其与 Jenkins 集成的步骤。
接下来,你将学习如何通过 Docker 容器确保快速部署应用程序,并了解如何扩展 Jenkins 和使用 Kubernetes。之后,你将学习如何使用 Docker 镜像部署应用并通过 Jenkins 进行测试。最后,本书将涉及持续交付管道中缺失的部分,包括环境和基础设施、应用版本管理以及非功能性测试。
到本书结束时,你将了解如何通过将 Docker 和 Jenkins 的功能集成来增强 DevOps 工作流。
本书的目标读者
本书面向 DevOps 工程师、系统管理员、Docker 专业人士或任何希望探索如何将 Docker 和 Jenkins 结合使用的相关人员。
本书内容
第一章,介绍持续交付,展示了传统交付过程中的陷阱,并描述了包括亚马逊和雅虎在内的成功案例。
第二章,介绍 Docker,简要介绍了 Docker 及其容器化概念,并阐述了使用该平台运行应用程序和服务的好处。此外,本章还会逐步描述如何在本地机器或运行 Linux 的服务器上设置 Docker 社区版,并检查 Docker 是否正常运行。
第三章,配置 Jenkins,介绍了 Jenkins 工具、其架构,以及如何在 Docker 服务器上安装 master/agent 实例,无论是使用 Docker 还是 Kubernetes。接着,我们将探讨如何扩展代理。最后,你将获得一个可用的 Jenkins 实例,准备与源代码存储库服务集成并构建应用程序。
第四章,持续集成管道,描述了经典的持续集成管道包含三个步骤:检出、构建和单元测试。在本章中,你将学习如何使用 Jenkins 构建这个管道,并了解应该考虑的其他步骤(如代码覆盖率和静态代码分析)。
第五章,自动化验收测试,解释了在发布应用程序之前,您需要通过运行自动化验收测试确保整个系统按预期工作。通常,应用程序会连接到数据库、缓存、消息传递和其他需要运行其他服务的工具。这就是为什么在开始测试套件之前,必须设置并准备好整个环境的原因。在本章中,您将学习 Docker Registry 的概念,以及如何构建由不同组件组成的系统,这些组件作为 Docker 容器运行。
第六章,使用 Kubernetes 进行集群管理,解释了如何使用 Docker 工具扩展到多个团队和项目。在本章中,您将接触到 Kubernetes,并学习如何在持续交付过程中使用它。
第七章,使用 Ansible 进行配置管理,描述了当您扩展了服务器之后,如何在生产环境中部署您的应用程序。在本章中,您将学习如何使用配置管理工具(如 Chef 和 Ansible)在 Docker 生产服务器上发布应用程序。此外,您还将学习基础设施即代码的方法以及 Terraform 工具的使用。
第八章,持续交付管道,重点讲解最终管道中缺失的部分,包括环境和基础设施、应用版本控制以及非功能性测试。完成这一章后,完整的持续交付管道将准备就绪。
第九章,高级持续交付,解释了在构建完整的管道后,如何处理更具挑战性的现实场景。从并行化管道任务开始,我们将展示如何回滚到先前的版本,如何运行性能测试,如何处理数据库变更,以及如何处理遗留系统和手动测试。
最佳实践,本章包括贯穿全书的最佳实践。
为了最大程度地利用本书
Docker 需要 64 位 Linux 操作系统。本书中的所有示例都使用了 Ubuntu 20.04 开发,但任何其他内核版本为 3.10 或以上的 Linux 系统都可以使用。

下载彩色图片
我们还提供了一份 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781803237480_ColorImages.pdf。
《Code in Action》
本书的《Code in Action》视频可以在bit.ly/3NSEPNA观看。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件,链接在github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition。如果代码有更新,将会在现有的 GitHub 仓库中进行更新。
我们还提供其他来自我们丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/找到。赶快去看看吧!
使用的约定
本书中使用了一些文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入以及 Twitter 账户名。例如: "我们可以创建一个新的管道,叫做calculator,并将代码放入名为Checkout的阶段作为管道脚本。"
一段代码如下设置:
pipeline {
agent any
stages {
stage("Checkout") {
steps {
git url: 'https://github.com/leszko/calculator.git', branch: 'main'
}
}
}
}
所有命令行输入或输出如下所示:
$ sudo apt-get update
粗体:表示新术语、重要词汇或在屏幕上看到的文字。例如,菜单或对话框中的词汇在文本中会这样显示。以下是一个例子:“选择Gradle 项目而不是Maven 项目(如果您更喜欢 Maven,也可以选择它)。”
小贴士或重要提示
这样显示。
与我们联系
我们始终欢迎读者的反馈。
customercare@packtpub.com。
勘误表:尽管我们已经尽力确保内容的准确性,但错误还是会发生。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问www.packtpub.com/support/errata,选择您的书籍,点击“勘误表提交表格”链接,并输入详细信息。
copyright@packt.com 并附有材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识并且有意撰写或参与编写一本书,请访问authors.packtpub.com。
分享您的想法
阅读完《使用 Docker 和 Jenkins 实现持续交付》后,我们希望听到您的想法!请点击这里直接前往 Amazon 评价页面并分享您的反馈。
您的评价对我们以及技术社区非常重要,将帮助我们确保提供卓越的内容质量。
第一部分 – 设置环境
在本节中,我们将介绍 Docker,并涵盖持续交付及其优势等概念,以及容器化。此外,我们还将介绍 Jenkins 工具,以及在 Docker 服务器上安装主/从实例所需的架构和过程,无论是在没有 Docker 的情况下,还是使用云环境。
本节中涵盖的章节如下:
-
第一章,介绍持续交付
-
第二章,介绍 Docker
-
第三章,配置 Jenkins
第一章:介绍持续交付
大多数开发人员面临的一个常见问题是如何快速且安全地发布实现的代码。传统的交付过程充满了陷阱,通常会导致开发人员和客户的失望。本章将介绍持续交付(CD)方法的理念,并为本书的其余部分提供背景。
在本章中,我们将涵盖以下主题:
-
理解 CD
-
自动化部署流水线
-
CD 的前提条件
-
结合 CD 和微服务
-
构建 CD 过程
理解 CD
CD 的最准确定义由Jez Humble提出,内容如下:
“持续交付是将所有类型的变更——包括新功能、配置更改、错误修复和实验——安全、快速且可持续地推送到生产环境或用户手中的能力。”
这个定义涵盖了关键点。
为了更好地理解这个问题,让我们假设一个场景。你负责一个产品——比如说,一个电子邮件客户端应用。用户向你提出一个新需求:他们希望按大小排序电子邮件。你决定开发大约需要 1 周时间。用户何时能使用这个功能? 通常,在开发完成后,你会将完成的功能交给质量保证(QA)团队,然后再交给运维团队,这会花费额外的时间,从几天到几个月不等。
因此,尽管开发只花了 1 周时间,但用户需要等几个月才能收到!CD 方法通过自动化手动任务解决了这个问题,使用户能够在新功能实现的第一时间就收到它。
为了帮助你理解需要自动化什么以及如何自动化,我们将首先描述当前大多数软件系统所使用的交付过程。
传统交付过程
传统交付过程,如其名称所示,已经实施多年,并且在大多数 IT 公司中得到应用。我们来定义它是如何工作的,并评论它的不足之处。
介绍传统的交付过程
每个交付过程都以客户定义的需求开始,并以产品发布到生产环境结束。这两个阶段之间存在差异。传统上,这个过程是这样的:

图 1.1 – 发布周期图
发布周期从产品负责人提供的需求开始,产品负责人代表客户(利益相关者)。然后,分为三个阶段,在这些阶段中,工作会在不同的团队之间传递:
-
开发:开发人员(有时与业务分析师一起)进行产品开发。他们通常使用敏捷技术(Scrum 或 Kanban)来提高开发速度并改善与客户的沟通。会组织演示会议以获得客户的快速反馈。所有优秀的开发技术(如测试驱动开发(TDD)或极限编程实践)都是欢迎的。一旦实现完成,代码会传给 QA 团队。
-
质量保证:这个阶段通常称为用户验收测试(UAT),需要将代码冻结在主代码库上,以确保没有新的开发会破坏测试。QA 团队会进行一系列的集成测试、验收测试和非功能性分析(性能、恢复、安全等)。任何检测到的缺陷都会返回给开发团队,所以开发人员通常工作非常繁忙。在 UAT 阶段完成后,QA 团队会批准已计划好用于下一个发布的功能。
-
运维:最后阶段,通常也是最短的阶段,涉及将代码交给运维团队,以便他们执行发布并监控生产环境。如果出现任何问题,他们会联系开发人员,帮助解决生产系统的问题。
发布周期的长短取决于系统和组织,但通常从 1 周到几个月不等。我听说过最长的周期是 1 年。我亲自参与过的最长周期是按季度进行的,每个部分如下:
-
开发:1.5 个月
-
UAT:1 个月 3 周
-
发布(和严格的生产监控):1 周
传统交付过程在 IT 行业广泛使用,因此这可能不是你第一次读到这样的方式。然而,它也有几个缺点。让我们明确地看看它们,以便理解为何我们需要追求更好的方法。
传统交付过程的缺点
传统交付过程的最显著缺点如下:
-
慢速交付:客户在需求明确后很久才收到产品。这导致市场响应时间不理想,且客户反馈延迟。
-
长反馈周期:反馈周期不仅与客户有关,也与开发人员相关。想象一下,如果你不小心创建了一个 BUG,并且在 UAT 阶段才得知。修复你两个月前做的事情需要多久? 即便是处理小 BUG,也可能需要几周。
-
缺乏自动化:稀少的发布不鼓励自动化,这导致发布过程不可预测。
-
风险较大的热修复:热修复通常不能等待完整的 UAT 阶段,因此它们通常会以不同的方式进行测试(UAT 阶段会缩短)或根本不进行测试。
-
压力:不可预测的发布对运维团队来说是很有压力的。更糟糕的是,发布周期通常安排得很紧,这给开发人员和测试人员增加了额外的压力。
-
沟通不畅:从一个团队传递到另一个团队的工作代表着瀑布式方法,人们开始只关心他们自己的部分,而不是完整的产品。如果出了什么问题,这通常导致责备游戏,而不是合作。
-
责任共享:没有团队从头到尾对产品负责:
-
对开发人员来说:完成 意味着需求已经被实现。
-
对测试人员来说:完成 意味着代码已经经过测试。
-
对运维人员来说:完成 意味着代码已经发布。
-
-
低工作满意度:每个阶段对不同的团队都很有趣,但其他团队需要支持这个过程。例如,开发阶段对开发人员来说很有趣,但在其他两个阶段,他们仍然需要修复 bug 并支持发布,这通常对他们来说一点都不有趣。
这些缺点仅仅是传统交付过程挑战的冰山一角。你可能已经感受到,开发软件必须有更好的方法,而这种更好的方法显然就是 CD 方法。
CD 的好处
你的组织部署一个涉及单行代码的更改需要多长时间? 你是否可以重复、可靠地做到这一点? 这些是玛丽和汤姆·波彭迪克(《实施精益软件开发》的作者)提出的著名问题,被杰兹·汉布尔和其他人引用了很多次。对这些问题的回答是你交付过程健康状况的唯一有效衡量标准。
为了能够持续交付,并且不需要大量花费在全天候工作的运维团队上,我们需要自动化。简而言之,持续交付(CD)就是将传统交付过程的每个阶段转变为一系列称为自动化部署流水线或CD 流水线的脚本。然后,如果不需要手动步骤,我们可以在每次代码更改后运行这个流程,并持续将产品交付给用户。
CD 让我们摆脱乏味的发布周期,并带来以下好处:
-
快速交付:市场推出时间显著缩短,因为客户可以在开发完成后立即使用产品。记住,软件直到交到用户手中才能产生收益。
-
快速反馈周期:想象一下,你在代码中引入了一个 bug,而这个 bug 当天就进入了生产环境。修复当天工作的问题需要多长时间?可能不会太久。这与快速回滚策略一起,是保持生产稳定的最佳方式。
-
低风险发布:如果你每天发布,这个过程就会变得可重复且安全得多。俗话说,如果它伤害你,那就更频繁地做它。
-
灵活的发布选项:如果你需要立即发布,一切已经准备就绪,因此没有额外的时间/成本与发布决策相关。
不用多说,我们完全可以通过消除所有交付阶段,直接从生产环境开始开发,来实现所有这些好处。然而,这样做会导致质量的下降。引入持续交付的困难在于,大家担心在消除所有手动步骤的同时,质量也会下降。在本书中,我们将向你展示如何安全地进行持续交付,并解释为什么,与普遍的看法相反,持续交付的产品包含的 bug 更少,更能满足客户的需求。
成功案例
我最喜欢的关于持续交付的故事是在 Rolf Russell 的一次演讲中听到的。故事如下。2005 年,Yahoo! 收购了 Flickr,这在开发者的世界中是两种文化的碰撞。那时,Flickr 是一家具有初创公司思维的公司,而 Yahoo! 则是一个庞大的企业,拥有严格的规则和安全至上的态度。他们的发布流程差异巨大。Yahoo! 使用传统的交付流程,而 Flickr 每天发布多次。开发者实施的每个变化都会在当天进入生产环境。他们甚至在页面底部放置了一个页脚,显示上次发布的时间和做出更改的开发者头像。
Yahoo! 很少进行部署,每次发布都会带来很多经过充分测试和准备的变化。Flickr 则采用非常小的增量方式工作;每个功能被划分为小的增量部分,每一部分都会迅速部署到生产环境中。差异如下面的图示所示:

图 1.2 – Yahoo! 和 Flickr 发布周期的比较
你可以想象,当两家公司开发者碰面时发生了什么。Yahoo! 把 Flickr 的同事当作不负责任的初级开发者,一群不懂自己在做什么的软件牛仔。所以,他们首先想做的就是为 Flickr 的交付流程增加一个 QA 团队和 UAT 阶段。然而,在他们进行更改之前,Flickr 的开发者只有一个愿望。他们希望评估整个 Yahoo! 所有产品中最可靠的那些。令他们吃惊的是,即使是 Yahoo! 的所有软件,Flickr 也有着最低的宕机时间。最初,Yahoo! 团队不明白这一点,但他们还是让 Flickr 保持现有流程。毕竟,他们是工程师,因此评估结果是确凿的。过了一段时间,Yahoo! 的开发者才意识到,持续交付流程对 Yahoo! 的所有产品都有好处,并开始逐步在各个产品中推广。
故事中最重要的问题仍然是:为什么 Flickr 是最可靠的系统? 其背后的原因就是我们在前面提到过的内容。如果以下情况成立,则发布的风险较小:
-
代码变更的差异很小
-
这个过程是可重复的
这就是为什么,尽管发布本身是一项复杂的活动,但如果频繁进行发布,它反而更安全。
Yahoo!和 Flickr 的故事只是众多成功公司的一个例子,在这些公司中,CD 过程证明是正确的选择。如今,即使是小型组织,也通常会频繁发布软件,像亚马逊、Facebook、谷歌和 Netflix 这样的行业领袖每天都会执行成千上万次的发布。
信息
你可以在continuousdelivery.com/evidence-case-studies/上阅读更多关于 CD 过程和个案研究的内容。
请记住,统计数据每天都在改善。然而,即使没有任何数据,想象一下这样一个世界:你实现的每一行代码都安全地进入了生产环境。客户能够快速反应并调整需求,开发人员很高兴,因为他们不需要解决那么多的漏洞,而管理者也满意,因为他们总是知道工作进展的当前状态。毕竟,记住唯一真正衡量进展的标准是已发布的软件。
自动化部署管道
我们已经知道什么是 CD 过程以及为什么要使用它。在这一节中,我们将描述如何实施它。
我们首先要强调,传统交付过程中的每一个阶段都非常重要,否则这个过程根本不会被创造出来。没有人愿意在没有测试的情况下交付软件!UAT 阶段的角色是发现漏洞,确保开发人员创建的内容正是客户所需要的。运维团队的角色也是如此——软件必须被配置、部署到生产环境并进行监控。这是不可忽视的。因此,我们如何自动化这个过程,以保持所有阶段的完整性? 这就是自动化部署管道的作用,它由三个阶段组成,如下图所示:

图 1.3 – 自动化部署管道
自动化部署管道是一系列脚本,执行顺序是在每次代码变更提交到仓库后进行的。如果过程成功,它最终会被部署到生产环境。
每个步骤都对应于传统交付过程中的一个阶段,如下所示:
-
持续集成:这会检查不同开发者编写的代码是否已被整合。
-
自动化验收测试:这会检查开发人员实现的功能是否满足客户的需求。这个测试还替代了手动 QA 阶段。
-
配置管理:这取代了手动操作阶段;它配置环境并部署软件。
让我们更深入地了解每个阶段,理解它的责任以及它包括哪些步骤。
持续集成
持续集成(CI)阶段为开发人员提供了第一轮反馈。它从代码库中检出代码,编译它,运行单元测试,并验证代码的质量。如果任何一步失败,管道执行将被停止,开发人员应该做的第一件事是修复 CI 构建。这个阶段的关键是时间;它必须及时执行。例如,如果这个阶段花费了 1 小时才能完成,开发人员就会更快地提交代码,这将导致管道不断失败。
CI 管道通常是起点。设置它很简单,因为一切都在开发团队内部完成,不需要与 QA 和运维团队达成协议。
自动化验收测试
自动化验收测试阶段是一套与客户(和 QA)一起编写的测试,旨在取代手动 UAT 阶段。它作为质量门,决定产品是否准备好发布。如果任何一个验收测试失败,管道执行将被停止,后续步骤将不再运行。它阻止了配置管理阶段的继续,因此也阻止了发布。
自动化验收阶段的整个理念是将质量融入产品,而不是事后验证。换句话说,当开发人员完成实现时,软件会与验收测试一起交付,这些测试验证软件是否符合客户的要求。这是软件测试思维的一大转变。现在不再是由单个人(或团队)批准发布,而是所有的一切都取决于通过验收测试套件。这也是为什么创建这个阶段通常是 CD 过程最困难的部分。它需要与客户的密切合作,并在过程开始时(而不是结束时)创建测试。
注意
在遗留系统中引入自动化验收测试尤其具有挑战性。我们将在第九章《高级持续交付》中详细讨论这个话题。
通常会有很多关于测试类型及其在 CD 过程中的位置的混淆。如何自动化每种类型的测试,覆盖范围应该是什么,以及 QA 团队在开发过程中的角色通常也不清晰。让我们通过敏捷测试矩阵和测试金字塔来澄清这些问题。
敏捷测试矩阵
Brian Marick 在一系列博客文章中,以敏捷测试矩阵的形式对软件测试进行了分类。它将测试分为两个维度——面向业务或技术,并支持程序员或对产品的批评。让我们来看一下这种分类:

图 1.4 – Brian Marick 的测试矩阵
让我们来看看每种测试类型:
-
验收测试(自动化):这些测试代表了从业务角度来看功能性要求。它们以故事或示例的形式由客户和开发人员编写,以便双方能就软件应如何工作达成一致。
-
单元测试(自动化):这些测试帮助开发人员提供高质量的软件,并最小化错误的数量。
-
探索性测试(手动):这是手动的黑盒测试阶段,旨在打破或改进系统。
-
非功能性测试(自动化):这些测试代表了与性能、可扩展性、安全性等相关的系统属性。
这个分类回答了关于 CD 过程中的一个重要问题:质量保证(QA)在过程中的角色是什么?
手动 QA 执行探索性测试,这意味着他们会与系统进行互动,尝试打破它,提问并思考改进之处。自动化 QA 则帮助进行非功能性测试和验收测试;例如,他们编写代码来支持负载测试。通常,QA 在交付过程中没有特殊的位置,而是作为开发团队的一员。
注意
在自动化的 CD 流程中,执行重复任务的手动 QA 已经没有位置了。
你可能会看着这个分类,想知道为什么没有看到集成测试。根据 Brian Marick 的说法,它们在哪里,我们又该如何在 CD 流水线中放置它们?
为了更好地解释这一点,我们需要提到集成测试的含义在不同上下文中的差异。对于(微)服务架构,集成测试通常意味着与验收测试相同,因为服务较小,只需要单元测试和验收测试。如果你构建一个模块化应用程序,那么集成测试通常意味着组件测试,绑定多个模块(但不是整个应用程序)并一起测试。在这种情况下,集成测试介于验收测试和单元测试之间。它们的编写方式类似于验收测试,但通常更加技术性,且需要模拟不仅是外部服务,还有内部模块。集成测试类似于单元测试,代表了代码的视角,而验收测试代表了用户的视角。在 CD 流水线中,集成测试作为一个单独的阶段实现。
测试金字塔
前一部分解释了每种测试类型在过程中的含义,但并未提到我们应该开发多少测试。那么,在单元测试的情况下,代码覆盖率应该是多少?验收测试又该如何?
为了回答这些问题,Mike Cohn在他的书《Succeeding with Agile》中创造了所谓的测试金字塔。下图应能帮助你更好地理解这一点:

图 1.5 – Mike Cohn 的测试金字塔
当我们往金字塔上层移动时,测试变得更慢且更昂贵。它们通常需要触及用户界面,并且需要雇佣一个独立的测试自动化团队。这就是为什么验收测试不应该追求 100%的覆盖率。相反,它们应该以功能为导向,只验证选定的测试场景。否则,我们将花费大量资金用于测试开发和维护,而我们的 CD 管道构建将需要很长时间才能执行完毕。
在金字塔的底部,情况则不同。单元测试既便宜又快速,因此我们应该追求 100%的代码覆盖率。它们由开发人员编写,提供它们应该是任何成熟团队的标准流程。
我希望敏捷测试矩阵和测试金字塔能澄清验收测试的作用和重要性。
现在,让我们来看一下 CD 过程的最后一个阶段:配置管理。
配置管理
配置管理阶段负责追踪和控制软件及其环境中的变化。它包括准备和安装必要工具、扩展服务实例的数量及其分布、基础设施清单以及所有与应用程序部署相关的任务。
配置管理是解决手动部署和配置生产环境中的应用程序所带来问题的一种方案。这个常见做法会导致一个问题,即我们无法再知道每个服务的运行位置及其属性。配置管理工具(如 Ansible、Chef 和 Puppet)使我们能够将配置文件存储在版本控制系统中,并跟踪对生产服务器所做的每一次更改。
替换运营团队手动任务的额外工作包括处理应用监控。这通常通过将运行系统的日志和指标流式传输到一个公共仪表盘来完成,该仪表盘由开发人员(或下节中提到的 DevOps 团队)进行监控。
近期与配置管理相关的另一个术语是基础设施即代码(IaC)。如果你使用云而不是裸机服务器,那么像 Terraform 或 AWS CloudFormation 这样的工具可以让你将基础设施的描述(不仅仅是软件)存储在版本控制系统中。我们将在第七章中讨论配置管理和 IaC,使用 Ansible 进行配置管理。
CD 的前提条件
本书的其余部分将专注于如何实现成功 CD 管道的技术细节。然而,这一过程的成功不仅仅依赖于本书中介绍的工具。在这一部分,我们将从整体上审视整个过程,并在以下三个方面定义 CD 的要求:
-
组织结构及其对开发过程的影响
-
你的产品及其技术细节
-
你的开发团队及你们采用的实践
让我们从组织先决条件开始。
组织先决条件
你们组织的运作方式对引入 CD 过程的成功有很大的影响。这有点类似于引入 Scrum。许多组织想要使用敏捷流程,但他们并没有改变他们的文化。除非组织的结构为此进行了调整,否则你无法在开发团队中使用 Scrum。例如,你需要一个产品负责人、相关利益方和一个管理团队,他们理解在 sprint 过程中需求不可能变更。否则,即使你有好的意图,也无法成功。同样的情况适用于 CD 过程;它要求你调整组织的结构。我们来看三个方面:DevOps 文化、过程中的客户和商业决策。
DevOps 文化
很久以前,当软件由个人或小团队编写时,开发、质量保证和运维之间没有明确的分隔。一个人编写代码,测试它,然后将其投入生产。如果出现问题,同一个人会调查问题,修复它,并重新部署到生产环境中。随着开发过程的组织方式逐渐变化,系统变得更大,开发团队也在不断壮大。于是,工程师们开始在某个领域中变得更加专业化。这是非常有道理的,因为专业化提升了生产力。然而,副作用是沟通成本的增加。如果开发人员、QA 和运维位于组织的不同部门,坐在不同的楼栋,或者外包到不同的国家,这种现象尤为明显。这种组织结构不利于 CD 过程。我们需要更好的方法;我们需要采用 DevOps 文化。
DevOps 文化在某种意义上意味着回归根本。一个人或一个团队负责所有三个领域,如下图所示:

图 1.6 – DevOps 文化
能够在不降低生产力的情况下转向 DevOps 模式的原因是自动化。与 QA 和运维相关的大部分任务被移到自动化交付管道中,因此可以由开发团队来管理。
信息
一个 DevOps 团队不一定需要由开发人员组成。许多转型中的组织中,一个非常常见的场景是创建由四个开发人员、一个 QA 和一个运维人员组成的团队。然而,他们需要紧密合作(坐在同一片区域,一起进行站立会议,共同开发同一个产品)。
小型 DevOps 团队的文化会影响软件架构。功能需求必须被分离为(微)服务或模块,以便每个团队可以处理独立的部分。
信息
组织结构对软件架构的影响早在 1967 年就被观察到,并被公式化为 Conway 定律:“任何设计系统的组织(广义定义)都会产生一个设计,其结构是该组织沟通结构的复制。”
客户在过程中
在持续交付(CD)采纳过程中,客户(或产品负责人)的角色略有变化。传统上,客户参与定义需求、回答开发人员的问题、参加演示并参与用户验收测试(UAT)阶段,以确定所构建的产品是否符合他们的预期。
在 CD 中,没有 UAT,客户在编写验收测试的过程中至关重要。对于一些已经以可测试的方式编写需求的客户来说,这并不是一个很大的变化。而对另一些客户来说,则意味着需要改变他们的思维方式,使需求更加面向技术。
信息
在敏捷环境中,有些团队甚至不接受没有附带验收测试的用户故事(需求)。这些技术,尽管听起来可能过于严格,但往往能带来更高的开发生产力。
业务决策
在大多数公司中,业务对发布计划有一定的影响。毕竟,决定交付哪些功能以及何时交付,涉及到公司内不同部门(例如,市场部)的协调,并且可能对企业的战略至关重要。因此,发布计划必须在业务和开发团队之间重新审视和讨论。
有一些技术手段,比如功能切换(feature toggles)或手动管道步骤,能够帮助按指定时间发布功能。我们将在本书后续部分描述这些技术。准确来说,持续交付(continuous delivery)与持续部署(continuous deployment)并不相同。后者意味着每次提交到代码仓库都会自动发布到生产环境。持续交付则更为宽松,它意味着每次提交都会生成一个发布候选版本,从而允许最后一步(从发布到生产)是手动进行的。
注意
在本书的其余部分,我们将交替使用“持续交付”和“持续部署”这两个术语。
技术和开发前提
从技术角度来看,有一些要求需要牢记。我们将在本书中讨论这些要求,因此这里只简单提及,不再详细说明:
-
自动化构建、测试、打包和部署操作:所有操作都需要能够自动化。如果我们处理的是一个无法自动化的系统,例如由于安全原因或其复杂性,便无法创建完全自动化的交付管道。
-
快速管道执行:管道必须迅速执行,最好在 5 到 15 分钟内完成。如果我们的管道执行需要数小时或数天,那就无法在每次提交代码时都执行。
-
快速故障恢复:需要能够快速回滚或恢复系统的可能性。否则,由于频繁发布,我们会危及生产环境的健康。
-
零停机部署:部署过程中不能有任何停机时间,因为我们每天都会发布多次。
-
基于主干的开发:开发人员必须定期提交代码到一个主分支。如果每个人都在自己的分支中开发,集成就会变得稀少,这意味着发布也会很少,而这正是我们想要避免的。
在本书中,我们将详细了解这些前提条件以及如何解决它们。考虑到这一点,让我们进入本章的最后一节,介绍我们计划在本书中构建的系统,以及我们将为此目的使用的工具。
结合持续交付(CD)和微服务
我们生活在微服务的世界里。如今,每个系统要么是基于微服务的,要么正在变成基于微服务的。在 Sam Newman 的畅销书《构建微服务》首次发布之后,软件世界转向了这种细粒度模块化的系统,所有的通信都是通过网络进行的。一些公司更进一步,意识到他们需要整合一些微服务,因为他们创建了太多的微服务。还有一些公司甚至退回一步,将微服务整合成单体系统。
微服务的话题本身非常广泛,超出了本书的范围,但了解微服务架构如何影响持续交付管道仍然很重要。我们是否应该为每个服务创建单独的管道?如果是,那我们该如何测试各个服务之间以及服务与整个系统之间的交互?
在回答这些问题之前,让我们看看下图,它展示了一个小型基于微服务的系统:

图 1.7 – 示例微服务系统
我们的系统中有三个服务,每个服务都有一个数据库。用户只与服务 1进行交互。作为一个更具体的例子,这个系统可以代表一个在线商店,其中服务 1可能代表结账服务,服务 2可能代表产品目录服务,服务 3可能代表客户服务。
我们可以为整个系统实现一个 CD 流水线,或者为每个微服务实现一个单独的 CD 流水线。哪种方法更合适呢?我们来考虑一下这两种选择。如果我们创建一个 CD 流水线,这意味着自动化验收测试阶段将从最终用户的角度对整个系统进行测试,这似乎是正确的。然而,一个 CD 流水线也意味着我们同时部署所有服务,这与微服务原则完全相悖。记住,在每个基于微服务的系统中,服务是松耦合的,并且应该始终是可独立部署的。
所以,我们需要采取第二种方法,为每个服务创建一个单独的 CD 流水线。然而,在这种情况下,自动化验收测试阶段从未对整个系统进行测试。那么,我们如何确保从最终用户的角度来看,一切都正常呢?为了回答这个问题,我们需要更多关于微服务架构的背景信息。
在微服务架构中,每个服务都是一个独立的单元,通常由一个独立的团队开发和维护。服务是松耦合的,它们通过一个明确定义的 API 进行通信,该 API 应始终保持向后兼容。在这种情况下,每个内部微服务与外部服务没有太大区别。这就是为什么我们应该始终能够在不测试其他服务的情况下部署新服务。请注意,这并不排除为整个系统进行单独验收测试的可能性。它解释的是,整个系统的验收测试不应成为单个服务部署的门槛。
信息
CD 流程适用于单体系统和基于微服务的系统。在前者的情况下,我们应该始终为每个微服务创建一个单独的 CD 流水线。
为了简化起见,本书中的所有示例都呈现了一个由单一服务组成的系统。
构建 CD 流程
到目前为止,我们已经介绍了关于 CD 流程的理念、好处和前提条件。在本节中,我们将描述本书中将使用的工具及其在整个系统中的位置。
信息
如果你对 CD 流程的概念感兴趣,可以看看 Jez Humble 和 David Farley 合著的精彩书籍《Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation》。
介绍工具
首先,工具的重要性总是低于理解它在流程中的作用。换句话说,任何工具都可以被一个执行相同角色的工具替代。例如,Jenkins 可以被 Atlassian Bamboo 替代,Chef 可以用 Ansible 代替。这就是为什么每一章都会以该工具为何必要以及它在整个流程中作用的概述开始。然后,工具会与其替代品进行比较。这将为你提供在你的环境中选择合适工具的灵活性。
另一种方法可能是从理念层面描述 CD 过程;然而,我坚信,提供一个精确的例子,并附上代码片段——这样你自己也能运行的示例——会让你对这个概念有更好的理解。
信息
本书有两种阅读方式。第一种是阅读并理解 CD 过程的概念。第二种是在阅读的同时创建环境并执行所有脚本,以理解细节。
让我们快速了解一下本书中将使用的工具。然而,本节仅是对每项技术的简要介绍——更多的细节将在本书后续章节中提供。
Docker 生态系统
Docker,作为容器化运动的明确领导者,近年来在软件行业占据了主导地位。它允许我们将应用程序打包成环境无关的镜像,并将服务器视为资源的农场,而不是必须为每个应用程序配置的机器。Docker 是本书的明确选择,因为它适应了(微)服务世界和 CD 过程。
Docker 包含以下几项额外技术:
-
Docker Hub:这是一个 Docker 镜像的注册中心
-
Kubernetes:这是一个容器编排工具
信息
在本书的第一版中,Docker Compose 和 Docker Swarm 被介绍为用于集群化和调度多容器应用的工具。然而,从那时起,Kubernetes 已成为市场的领导者,并被取而代之。
Jenkins
Jenkins 是目前市场上最流行的自动化服务器。它有助于创建 CI 和 CD 管道,通常也可用于任何其他自动化脚本序列。Jenkins 高度依赖插件,并且拥有一个活跃的社区,不断通过新功能扩展其功能。更重要的是,它允许我们将管道写成代码,并支持分布式构建环境。
Ansible
Ansible 是一种自动化工具,帮助进行软件配置、配置管理和应用部署。它的流行速度超过了任何其他配置管理引擎,并将很快超越其两大竞争对手:Chef 和 Puppet。它采用无代理架构,并与 Docker 无缝集成。
GitHub
GitHub 是所有托管版本控制系统中最好的。它提供了一个非常稳定的系统,一个出色的基于 Web 的 UI,并且为公共仓库提供免费服务。话虽如此,任何源代码管理服务或工具都可以与 CD(持续交付)配合使用,无论它是基于云的还是自托管的,是否基于 Git、SVN、Mercurial 或任何其他工具。
Java/Spring Boot/Gradle
Java 多年来一直是最流行的编程语言。因此,它将用于本书中的大多数代码示例。与 Java 一起,许多公司使用 Spring 框架进行开发,所以我们也使用它来创建一个简单的 Web 服务,解释一些概念。Gradle 用作构建工具,它虽然不如 Maven 流行,但发展势头更快。像往常一样,任何编程语言、框架或构建工具都可以替换,CD 流程保持不变,所以如果你的技术栈不同,也不必担心。
其他工具
Cucumber 被任意选作验收测试框架。其他类似的解决方案包括 FitNesse 和 Jbehave。对于数据库迁移过程,我们将使用 Flyway,但任何其他工具都可以,如 Liquibase。
创建一个完整的 CD 系统
你可以从两个角度来了解本书的组织方式。
第一个角度是基于自动化部署流水线的步骤。每一章都会将你带得更接近完整的 CD 流程。如果你查看各章的名称,其中有些甚至与流水线的阶段名称相同:
-
CI 流水线
-
自动化验收测试
-
使用 Ansible 进行配置管理
剩余的章节提供了对过程的介绍、总结或补充信息。
本书还有第二个角度的内容。每一章描述了环境中的一个部分,这些部分也为 CD 流程的顺利进行做好了充分准备。换句话说,本书一步一步、技术一项一项地展示了如何构建一个完整的系统。为了帮助你了解我们将在本书中构建的系统,接下来我们来看看每一章中系统如何逐步演变。
注意
如果此时你还不理解这些概念和术语,不用担心。我们将在相应的章节中从头开始学习所有内容。
引入 Docker
在第二章,引入 Docker中,我们将从系统的核心开始,构建一个已打包为 Docker 镜像的工作应用。本章的输出如下图所示:

图 1.8 – 引入 Docker
一个 Docker 化的应用(Web 服务)作为容器在Docker 主机上运行,并且可以访问,因为它将直接在主机机器上运行。这是通过端口转发(在 Docker 术语中是端口发布)实现的。
配置 Jenkins
在第三章,配置 Jenkins中,我们将准备 Jenkins 环境。得益于多个代理(从属)节点的支持,它能够处理繁重的并发负载。结果如下图所示:

图 1.9 – 配置 Jenkins
Jenkins 主机接收构建请求,但执行过程从某个 Jenkins Slave(代理)机器开始。这种方法提供了 Jenkins 环境的水平扩展。
CI 管道
在第四章,《持续集成管道》中,我们将向您展示如何创建 CD 管道的第一阶段:提交阶段。该章节的输出如下图所示:

图 1.10 – CI 管道
该应用程序是一个简单的 Web 服务,使用 Java 和 Spring Boot 框架编写。Gradle 用作构建工具,GitHub 用作源代码仓库。每次提交到 GitHub 都会自动触发 Jenkins 构建,Jenkins 使用 Gradle 编译 Java 代码,运行单元测试并执行其他检查(如代码覆盖率、静态代码分析等)。一旦 Jenkins 构建完成,会发送通知给开发人员。
在本章结束后,您将能够创建一个完整的 CI 管道。
自动化验收测试
在第五章,《自动化验收测试》中,我们将结合本书标题中提到的两项技术:Docker 和 Jenkins。这将导致下图所示的系统:

图 1.11 – 自动化验收测试
上述图表中的额外元素与自动化验收测试阶段相关:
-
Docker 注册库:在 CI 阶段之后,应用程序被打包成 JAR 文件,然后作为 Docker 镜像。该镜像随后被推送到 Docker 注册库,它充当容器化应用程序的存储库。
-
Docker 主机:在执行验收测试套件之前,必须先启动应用程序。Jenkins 触发 Docker 主机 机器从 Docker 注册库 拉取容器化的应用程序并启动它。
-
Cucumber:在应用程序启动在 Docker 主机 上之后,Jenkins 会运行一套使用 Cucumber 框架编写的验收测试。
使用 Kubernetes 集群
在第六章,《使用 Kubernetes 集群》中,我们将用 Kubernetes 集群替换单个 Docker 主机,并将单个独立应用程序替换为两个依赖的容器化应用程序。输出为下图所示的环境:

图 1.12 – 使用 Kubernetes 集群
Kubernetes 提供了一层抽象,管理一组 Docker 主机,并允许依赖应用程序之间的简单通信。我们不再需要考虑应用程序部署在哪台机器上。我们关心的只是实例的数量。
使用 Ansible 进行配置管理
在 第七章,《使用 Ansible 进行配置管理》中,我们将使用 Ansible 创建多个环境。输出结果如下图所示:

图 1.13 – 使用 Ansible 进行配置管理
Ansible 负责管理环境,允许你在多个机器上部署相同的应用程序。结果是,我们拥有一个镜像环境用于测试和生产。
在本章中,我们还将触及 IaC,并展示如果使用云环境,如何使用 Terraform。
CD 流水线/高级 CD
在最后两章——即 第八章,《持续交付流水线》和 第九章,《高级持续交付》——我们将把应用程序部署到预发布环境,运行验收测试套件,并将应用程序发布到生产环境,通常会在多个实例中进行。最终的改进是,我们将能够通过将 Flyway 迁移集成到交付过程中,自动管理数据库架构。此书中将创建的最终环境如下面的图所示:

图 1.14 – CD 流水线/高级 CD
我希望你已经对我们将在本书中构建的内容感到兴奋。我们将逐步进行,解释每个细节和所有可能的选项,帮助你理解这些过程和工具。阅读完本书后,你将能够在你的项目中引入或改进 CD 流程。
总结
在这一章中,我们介绍了 CD 流程,包括其背后的理念、先决条件以及本书中将使用的工具。关键点是,当前大多数公司使用的交付流程存在显著不足,可以通过现代自动化工具进行改进。CD 方法提供了多个优势,其中最显著的包括快速交付、快速反馈周期和低风险发布。CD 流水线包括三个阶段:CI、自动化验收测试和配置管理。引入 CD 通常需要组织改变其文化和结构。在 CD 上下文中,最重要的工具是 Docker、Jenkins 和 Ansible。
在下一章,我们将介绍 Docker,并向你展示如何构建一个 Docker 化的应用程序。
问题
为了测试你对本章内容的理解,请回答以下问题:
-
传统交付过程的三个阶段是什么?
-
CD 流水线的三个主要阶段是什么?
-
至少列出使用 CD 的三个好处。
-
在 CD 流水线中应该自动化哪些类型的测试?
-
我们应该增加更多的集成测试还是单元测试?解释原因。
-
DevOps 这个术语是什么意思?
-
本书将使用哪些软件工具?请列出至少四个。
进一步阅读
要了解更多关于 CD 概念及其背景的信息,请参考以下资源:
-
持续交付,作者:Jez Humble 和 David Farley:
continuousdelivery.com/ -
测试金字塔,作者:Martin Fowler:
martinfowler.com/bliki/TestPyramid.html -
敏捷成功之道:使用 Scrum 进行软件开发,作者:Mike Cohn
-
构建微服务:设计精细化系统,作者:Sam Newman
第二章:介绍 Docker
在本章中,我们将讨论现代 持续交付(CD)过程是如何通过引入 Docker 来实现的,Docker 是改变了 信息技术(IT)行业及服务器使用方式的技术。
本章涵盖以下主题:
-
什么是 Docker?
-
安装 Docker
-
运行 Docker hello-world
-
Docker 组件
-
Docker 应用
-
构建 Docker 镜像
-
Docker 容器状态
-
Docker 网络
-
使用 Docker 卷
-
在 Docker 中使用名称
-
Docker 清理
-
Docker 命令概述
技术要求
完成本章内容,你需要满足以下硬件/软件要求:
-
至少需要 4 千兆字节(GB)的 随机存取存储器(RAM)
-
macOS 10.15+、Windows 10/11 Pro 64 位、Ubuntu 20.04+ 或其他 Linux 操作系统
所有示例和练习的解决方案可以在 github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter02 找到。
本章的《代码实战》视频可以在 bit.ly/3LJv1n6 上观看。
什么是 Docker?
Docker 是一个开源项目,旨在通过使用软件容器来帮助应用程序部署。这种方法意味着将应用程序与完整的环境(文件、代码库、工具等)一起运行。因此,Docker —— 类似于虚拟化 —— 允许将应用程序打包成一个可以在任何地方运行的镜像。
容器化与虚拟化
如果没有 Docker,隔离和其他好处可以通过使用硬件虚拟化来实现,这通常被称为 虚拟机(VMs)。最受欢迎的解决方案有 VirtualBox、VMware 和 Parallels。虚拟机仿真计算机架构,并提供物理计算机的功能。如果将每个应用程序作为独立的虚拟机镜像交付并运行,则可以实现完全的应用隔离。
以下图表展示了虚拟化的概念:

图 2.1 – 虚拟化
每个应用程序都作为一个包含所有依赖项和客操作系统的独立镜像启动。这些镜像由 虚拟机管理程序(hypervisor)运行,虚拟机管理程序仿真物理计算机架构。这种部署方法得到了许多工具(如 Vagrant)的广泛支持,并且专为开发和测试环境而设计。然而,虚拟化有三个显著的缺点,如下所述:
-
低性能:虚拟机仿真整个计算机架构以运行客操作系统,因此执行每个操作时都有显著的开销。
-
高资源消耗:仿真需要大量资源,并且必须为每个应用单独执行。这就是为什么在标准桌面机器上,只有少数应用可以同时运行。
-
大图像大小:每个应用程序都配备了完整的操作系统,因此在服务器上部署意味着需要传输和存储大量数据。
容器化的概念提供了一种不同的解决方案,正如我们在这里看到的:

图 2.2 – 容器化
每个应用程序与其依赖项一起交付,但不包括操作系统。应用程序直接与主机操作系统接口,因此没有额外的来宾操作系统层。这带来了更好的性能,并且没有浪费资源。此外,发布的 Docker 镜像要小得多。
请注意,在容器化的情况下,隔离发生在主机操作系统的进程级别。然而,这并不意味着容器共享它们的依赖项。每个容器都有自己的库,并且版本正确,如果其中任何一个库被更新,其他容器不会受到影响。为了实现这一点,Docker 引擎为容器创建了一组 Linux 名称空间和控制组。这就是 Docker 安全性基于 Linux 内核进程隔离的原因。尽管这种解决方案已经足够成熟,但可能会被认为比虚拟机提供的基于操作系统的完全隔离稍微不那么安全。
Docker 的需求
Docker 容器化解决了传统软件交付中出现的许多问题。让我们仔细看看。
环境
安装和运行软件是复杂的。你需要关于操作系统、资源、库、服务、权限、其他软件以及应用程序所依赖的一切做出决策。然后,你需要知道如何安装它。更重要的是,可能会存在一些冲突的依赖关系。那该怎么办呢? 如果你的软件需要升级某个库,而其他资源不需要怎么办? 在一些公司中,这类问题通过设立应用程序类别来解决,每个类别由专用服务器提供支持,例如,使用 Java 7 的 Web 服务服务器和使用 Java 8 的批处理作业服务器。然而,这种解决方案在资源分配方面并不平衡,并且需要大量 IT 运维团队来管理所有生产和测试服务器。
环境复杂性的另一个问题是,它通常需要专业人员来运行应用程序。一个技术能力较弱的人可能很难设置 MySQL、开放数据库连接(ODBC)或任何其他稍微复杂的工具。对于那些不是以特定操作系统二进制文件形式交付的应用程序,尤其如此,这些应用程序需要源代码编译或其他特定于环境的配置。
隔离
保持工作区整洁。一个应用程序可以改变另一个应用程序的行为。想象一下可能发生什么。应用程序共享一个文件系统,如果应用程序A将数据写入错误的目录,应用程序B就会读取到错误的数据。它们共享资源,因此,如果应用程序A有内存泄漏,它不仅会冻结自己,还可能冻结应用程序B。它们共享网络接口,因此如果应用程序A和B都使用端口8080,其中一个就会崩溃。隔离还涉及到安全性方面。如果运行一个有缺陷的应用程序或恶意软件,可能会对其他应用程序造成损害。这就是为什么将每个应用程序保存在一个独立的沙箱中是一个更安全的做法,它限制了可能对应用程序本身造成的损害范围。
组织应用程序
服务器通常最终看起来很杂乱,有大量正在运行的应用程序,没人知道它们的任何情况。你将如何检查哪些应用程序正在服务器上运行,并且它们各自依赖了哪些库? 它们可能依赖于库、其他应用程序或工具。如果没有详尽的文档,我们能做的就是查看正在运行的进程并开始猜测。Docker 通过将每个应用程序作为独立的容器来保持事物的有序,这样就可以列出、搜索和监控它们。
可移植性
一次编写,处处运行,这是早期 Java 版本广告中的口号。的确,Java 很好地解决了可移植性问题。然而,我仍然能想到一些失败的情况;例如,不兼容的本地依赖项或旧版的 Java 运行时环境。此外,并非所有软件都是用 Java 编写的。
Docker 将可移植性的概念提升了一个层次;如果 Docker 版本兼容,那么无论编程语言、操作系统还是环境配置如何,运送的软件都能正常工作。因此,Docker 可以用以下口号来表达:运送整个环境,而不仅仅是代码。
小猫与牲畜
传统软件部署和基于 Docker 的部署之间的区别,常用小猫与牲畜的类比来表达。每个人都喜欢小猫。小猫是独一无二的。每只小猫都有自己的名字并需要特别照顾。小猫是带有感情的。当它们死去时,我们会哭泣。相反,牲畜的存在仅仅是为了满足我们的需求。甚至“牲畜”这个词是单数的,因为它只是一群被一起对待的动物——没有名字,没有独特性。当然,它们是独一无二的(就像每台服务器都是独特的),但这并不重要。这就是为什么 Docker 背后的思想最直观的解释是:把你的服务器当作牲畜对待,而不是宠物。
替代的容器化技术
Docker 并不是市场上唯一的容器化系统。实际上,Docker 的早期版本基于开源的Linux Containers(LXC)系统,这是一个替代性的容器平台。其他知名的解决方案有Windows Server containers、OpenVZ 和Linux Server。然而,Docker 由于其简便性、良好的市场营销和创业方式,超越了所有其他系统。它能在大多数操作系统上运行,允许你在不到 15 分钟的时间内做一些有用的事情,并且拥有很多易用的功能、优秀的教程、一个强大的社区,可能还有 IT 行业中最棒的 logo!
我们已经理解了 Docker 的基本概念,接下来让我们进入实际部分,从头开始:Docker 安装。
安装 Docker
Docker 的安装过程既快速又简单。目前,它支持大多数 Linux 操作系统,并且为多个操作系统提供了专用的二进制文件。macOS 和 Windows 也通过原生应用得到很好的支持。然而,重要的是要理解 Docker 内部是基于 Linux 内核及其特性,这也是为什么在 macOS 和 Windows 上它使用虚拟机(macOS 使用 HyperKit,Windows 使用 Hyper-V)来运行 Docker Engine 环境。
Docker 的先决条件
Docker Community Edition 的系统要求因操作系统不同而有所不同,具体如下:
-
macOS:
-
macOS 10.15 或更新版本
-
至少 4 GB 内存
-
安装前没有 VirtualBox 版本 4.3.30 或更高版本
-
-
Windows:
-
64 位 Windows 10/11
-
已启用 Hyper-V 包
-
至少 4 GB 内存
-
-
Linux:
-
64 位架构
-
Linux 内核 3.10 或更高版本
-
如果你的机器不满足这些要求,解决方案是使用VirtualBox并安装 Ubuntu 操作系统。这个变通方法虽然听起来很复杂,但不一定是最糟糕的选择,特别是考虑到在 macOS 和 Windows 上 Docker Engine 环境本身就是虚拟化的。而且,Ubuntu 是使用 Docker 的最佳支持系统之一。
信息
本书中的所有示例都已在 Ubuntu 20.04 操作系统上进行过测试。
在本地机器上安装
Docker 安装过程非常简单,详细步骤可以参考官方页面:docs.docker.com/get-docker/。
Docker Desktop
在本地环境中使用 Docker 的最简单方法是安装 Docker Desktop。通过这种方式,只需几分钟,你就可以拥有一个完整的 Docker 开发环境并运行起来。对于 Windows 和 macOS 用户,Docker Desktop 提供了一个本地应用程序,隐藏了所有设置的复杂性。技术上,Docker 引擎安装在一个虚拟机内,因为 Docker 需要 Linux 内核才能运行。然而,作为用户,你根本不需要考虑这一点——只需安装 Docker Desktop,就可以开始使用 docker 命令。你可以在下图中看到 Docker Desktop 的概览:

图 2.3 – Docker Desktop
除了 Docker 引擎外,Docker Desktop 还提供了以下一些附加功能:
-
一个 用户界面 (UI) 用于显示镜像、容器和卷
-
本地 Kubernetes 集群
-
自动 Docker 更新
-
与本地文件系统集成的卷挂载
-
(Windows)支持 Windows 容器
-
(Windows)与 Windows 子系统 Linux (WSL)/WSL 版本 2 (WSL2) 的集成
注意
请访问
docs.docker.com/get-docker/获取 Docker Desktop 安装指南。
Docker for Ubuntu
访问 docs.docker.com/engine/install/ubuntu/ 查找如何在 Ubuntu 机器上安装 Docker 的指南。
在 Ubuntu 20.04 上,我执行了以下命令:
$ sudo apt-get update
$ sudo apt-get -y install ca-certificates curl gnupg lsb-release
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
$ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
$ sudo apt-get update
$ sudo apt-get -y install docker-ce docker-ce-cli containerd.io
完成所有操作后,Docker 应该已经安装。但此时,只有 root 用户被允许使用 Docker 命令。这意味着每个 Docker 命令前都必须加上 sudo 关键字。
我们可以通过将其他用户添加到 docker 组来允许他们使用 Docker,方法如下:
$ sudo usermod -aG docker <username>
成功注销后,一切设置完成。然而,对于最新的命令,我们需要采取一些预防措施,以避免将 Docker 权限赋予不必要的用户,从而在 Docker 引擎环境中创建漏洞。在服务器机器上安装时尤其需要注意这一点。
Docker for 其他 Linux 发行版
Docker 支持大多数 Linux 发行版和架构。详情请查看官方页面 docs.docker.com/engine/install/。
测试 Docker 安装
无论你选择了哪种安装方式(macOS、Windows、Ubuntu、Linux 或其他),Docker 应该已经设置好并准备就绪。测试的最佳方法是运行 docker info 命令。输出信息应该类似于以下内容:
$ docker info
Containers: 0
Running: 0
Paused: 0
Stopped: 0
Images: 0
…
在服务器上安装
为了通过网络使用 Docker,可以利用云平台提供商,或者手动在专用服务器上安装 Docker。
在第一种情况下,Docker 配置在不同平台间有所不同,但通常在专门的教程中都有详细描述。大多数云平台都允许通过用户友好的网页界面创建 Docker 主机,或提供执行命令的具体描述。
第二种情况(手动安装 Docker)确实需要简要说明一下。
专用服务器
手动在服务器上安装 Docker 与本地安装并没有太大区别。
还需要两个额外的步骤,其中包括让 Docker 守护进程监听网络套接字和设置安全证书。更多细节请参见这里:
-
默认情况下,出于安全考虑,Docker 通过一个非网络化的 Unix 套接字运行,只允许本地通信。需要在所选的网络接口套接字上开启监听,以便外部客户端能够连接。在 Ubuntu 系统中,Docker 守护进程是由
systemd配置的,因此,要修改其启动配置,我们需要修改/lib/systemd/system/docker.service文件中的一行,如下所示:ExecStart=/usr/bin/dockerd -H <server_ip>:2375
通过修改这一行,我们启用了通过指定的 systemd 配置访问 Docker 守护进程,相关的配置可以在 docs.docker.com/config/daemon/systemd/ 中找到。
-
这一步是关于服务器配置的 Docker 安全证书。这只允许通过证书认证的客户端访问服务器。Docker 证书配置的详细描述可以在
docs.docker.com/engine/security/protect-access/中找到。这一步不是强制性的;然而,除非你的 Docker 守护进程服务器位于有防火墙的网络中,否则它是必需的。信息
如果你的 Docker 守护进程运行在公司网络中,你需要配置 超文本传输协议 (HTTP) 代理。详细描述可以在
docs.docker.com/config/daemon/systemd/中找到。
Docker 环境已设置完毕并准备好,因此我们可以开始第一个示例。
运行 Docker hello-world
在控制台中输入以下命令:
$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:2557e3c07ed1e38f26e389462d03ed943586f744621577a99efb77324b0fe535
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
...
恭喜!你刚刚运行了第一个 Docker 容器。我希望你已经能看出 Docker 是多么简单。让我们来看看在幕后发生了什么,如下所示:
-
你使用
run命令运行了 Docker 客户端。 -
Docker 客户端联系 Docker 守护进程,并请求从名为
hello-world的镜像创建一个容器。 -
Docker 守护进程检查本地是否存在
hello-world镜像,如果没有,则从远程 Docker Hub 仓库请求hello-world镜像。 -
Docker Hub 仓库包含了
hello-world镜像,因此它被拉取到 Docker 守护进程中。 -
Docker 守护进程从
hello-world镜像创建了一个新容器,启动了可执行文件并产生了输出。 -
Docker 守护进程将此输出流式传输到 Docker 客户端。
-
Docker 客户端将其发送到你的终端。
预计的流程如以下图所示:

图 2.4 – docker run 命令执行步骤
现在让我们来看一下本节中展示的每个 Docker 组件。
Docker 组件
Docker 实际上是一个包含多个组件的生态系统。让我们从仔细了解 Docker 客户端-服务器架构开始,描述所有这些组件。
Docker 客户端和服务器
让我们看一下以下图表,它展示了 Docker 引擎的架构:

图 2.5 – Docker 客户端-服务器架构
Docker 引擎由以下三个组件组成:
-
在后台运行的Docker 守护进程(服务器)
-
作为命令工具运行的Docker 客户端
-
Docker 表述性状态转移(REST)应用程序编程接口(API)
安装 Docker 意味着安装所有组件,以便 Docker 守护进程始终作为服务在我们的计算机上运行。在hello-world示例中,我们使用 Docker 客户端与 Docker 守护进程交互;然而,我们也可以通过 REST API 做完全相同的事情。此外,在hello-world示例中,我们连接到了本地 Docker 守护进程,但我们也可以使用相同的客户端与运行在远程机器上的 Docker 守护进程进行交互。
提示
要在远程机器上运行 Docker 容器,可以使用-H选项:docker -H <server_ip>:2375 run hello-world。
Docker 镜像和容器
镜像是 Docker 世界中的一个无状态构建块。你可以把镜像看作是运行你的应用程序所需的所有文件的集合,以及如何运行它的配方。镜像是无状态的,因此你可以通过网络传输它,存储到注册表中,给它命名,进行版本管理,并将其保存为文件。镜像是分层的,这意味着你可以在另一个镜像的基础上构建镜像。
容器是镜像的一个运行实例。如果我们想要有多个相同应用程序的实例,我们可以从同一个镜像创建多个容器。由于容器是有状态的,这意味着我们可以与它们交互并对其状态进行更改。
让我们来看一个容器和镜像分层结构的示例:

图 2.6 – Docker 镜像的分层结构
在最底部,总是有一个基础镜像。在大多数情况下,这代表了一个操作系统,我们在现有的基础镜像上构建我们的镜像。从技术上讲,可以创建你自己的基础镜像;然而,这种需求很少。
在我们的示例中,ubuntu 基础镜像提供了 Ubuntu 操作系统的所有功能。add git 镜像添加了 Git 工具集。接着,有一个镜像添加了 add JDK 镜像。这样的容器可以,例如,从 GitHub 仓库下载一个 Java 项目并将其编译为 Java ARchive(JAR)文件。结果是,我们可以使用这个容器来编译和运行 Java 项目,而无需在操作系统上安装任何工具。
需要注意的是,分层是一种非常聪明的机制,用于节省带宽和存储空间。假设我们有一个以下基于 Ubuntu 的应用程序:

图 2.7 – 重用 Docker 镜像的层
这一次,我们将使用 Python 解释器。当安装 add python 镜像时,Docker 守护进程会注意到 ubuntu 镜像已经安装,所需要做的只是添加一个非常小的 Python 层。因此,ubuntu 镜像是一个被重用的依赖项。如果我们想在网络中部署我们的镜像,同样适用。当我们部署 Git 和 JDK 应用程序时,需要传输整个 ubuntu 镜像。然而,在随后部署 Python 应用程序时,我们只需要传输小的 add python 层。
现在我们已经了解了 Docker 生态系统的组成,让我们描述一下如何运行打包为 Docker 镜像的应用程序。
Docker 应用程序
很多应用程序以 Docker 镜像的形式提供,可以从互联网上下载。如果我们知道镜像名称,那么就像运行 hello-world 示例一样,只需要运行它就可以了。我们如何在 Docker Hub 上找到所需的应用程序镜像? 以 MongoDB 为例。以下是我们需要遵循的步骤:
-
如果我们想在 Docker Hub 上找到它,我们有两种选择,具体如下:](https://hub.docker.com/search/)
- 在 Docker Hub 上搜索
docker search命令。
- 在 Docker Hub 上搜索
在第二种情况下,我们可以执行以下操作:
$ docker search mongo
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
mongo MongoDB document databases... 8293 [OK]
...
-
有许多有趣的选项。我们如何选择最佳镜像? 通常,最吸引人的是没有任何前缀的镜像,因为这意味着它是官方的 Docker Hub 镜像,因此应该是稳定和维护的。带有前缀的镜像是非官方的,通常作为开源项目维护。在我们的例子中,最佳选择似乎是
mongo,因此为了运行 MongoDB 服务器,我们可以执行以下命令:$ docker run mongo Unable to find image 'mongo:latest' locally latest: Pulling from library/mongo 7b722c1070cd: Pull complete ... Digest: sha256:a7c1784c83536a3c686ec6f0a1c570ad5756b94a1183af88c07df82c5b64663c {"t":{"$date":"2021-11-17T12:23:12.379+00:00"},"s":"I", "c":"CONTROL", "id":23285, "ctx":"-","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"} ...
这就是我们需要做的。MongoDB 已经启动。作为 Docker 容器运行应用程序就是这么简单,因为我们不需要考虑任何依赖项;它们都与镜像一起提供。Docker 可以作为一个运行应用程序的有用工具,但真正的强大之处在于构建你自己的 Docker 镜像,将程序与环境一起打包。
信息
在 Docker Hub 服务上,你可以找到很多应用程序,它们存储了成千上万种不同的镜像。
构建 Docker 镜像
在这一部分,我们将展示如何使用两种不同的方式构建 Docker 镜像:docker的commit命令和 Dockerfile 自动化构建。
docker commit
让我们从一个示例开始,准备一个包含 Git 和 JDK 工具包的镜像。我们将使用 Ubuntu 20.04 作为基础镜像。无需自己创建,绝大多数基础镜像都可以在 Docker Hub 仓库中找到。操作步骤如下:
-
从
ubuntu:20.04运行一个容器,并将其连接到命令行,如下所示:$ docker run -i -t ubuntu:20.04 /bin/bash
我们已经拉取了ubuntu:20.04镜像,将其作为容器运行,并以交互方式(-i标志)调用了/bin/bash命令。你应该能够看到容器的终端。由于容器是有状态且可写的,我们可以在它的终端中做任何我们想做的事情。
-
安装 Git 工具包,如下所示:
root@dee2cb192c6c:/# apt-get update root@dee2cb192c6c:/# apt-get install -y git -
通过运行以下命令检查是否安装了 Git 工具包:
root@dee2cb192c6c:/# which git /usr/bin/git -
退出容器,如下所示:
root@dee2cb192c6c:/# exit -
通过比较容器的唯一
ubuntu镜像,检查容器中发生了什么变化,如下所示:$ docker diff dee2cb192c6c
上述命令应打印容器中所有更改的文件列表。
-
将容器提交为镜像,如下所示:
$ docker commit dee2cb192c6c ubuntu_with_git
我们刚刚创建了第一个 Docker 镜像。现在让我们列出所有 Docker 主机上的镜像,查看该镜像是否存在,如下所示:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu_with_git latest f3d674114fe2 About a minute ago 205 MB
ubuntu 20.04 20bb25d32758 7 days ago 87.5 MB
mongo latest 4a3b93a299a7 10 days ago 394 MB
hello-world latest fce289e99eb9 2 weeks ago 1.84 kB
正如预期的那样,我们看到了hello-world、mongo(之前安装过)、ubuntu(从 Docker Hub 拉取的基础镜像)以及新建的ubuntu_with_git镜像。顺便提一下,我们可以观察到每个镜像的大小与我们在镜像上安装的内容相对应。
现在,如果我们从该镜像创建一个容器,它将安装 Git 工具,如下所示的代码片段所示:
$ docker run -i -t ubuntu_with_git /bin/bash
root@3b0d1ff457d4:/# which git
/usr/bin/git
root@3b0d1ff457d4:/# exit
Dockerfile
使用commit命令手动创建每个 Docker 镜像可能会很繁琐,尤其是在构建自动化和持续交付过程中。幸运的是,Docker 提供了一种内建语言,可以指定构建 Docker 镜像时需要执行的所有指令。
让我们从一个与 Git 类似的示例开始。这次,我们将准备一个ubuntu_with_python镜像,如下所示:
-
创建一个新目录,并在其中创建一个名为
Dockerfile的文件,内容如下:FROM ubuntu:20.04 RUN apt-get update && \ apt-get install -y python -
运行以下命令以创建
ubuntu_with_python镜像:$ docker build -t ubuntu_with_python . -
运行以下命令,检查镜像是否已创建:
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE ubuntu_with_python latest d6e85f39f5b7 About a minute ago 147 MB ubuntu_with_git_and_jdk latest 8464dc10abbb 3 minutes ago 580 MB ubuntu_with_git latest f3d674114fe2 9 minutes ago 205 MB ubuntu 20.04 20bb25d32758 7 days ago 87.5 MB mongo latest 4a3b93a299a7 10 days ago 394 MB hello-world latest fce289e99eb9 2 weeks ago 1.84 kB
现在,我们可以从镜像中创建一个容器,并检查 Python 解释器是否存在,方法与执行docker commit命令后一样。请注意,尽管ubuntu镜像是ubuntu_with_git和ubuntu_with_python的基础镜像,但它只列出了一次。
在本示例中,我们使用了前两个 Dockerfile 指令,如下所示:
-
FROM定义了在其上构建新镜像的基础镜像。 -
RUN指定了在容器内执行的命令。
其他广泛使用的指令详细说明如下:
-
COPY/ADD将文件或目录复制到镜像的文件系统中。 -
ENTRYPOINT定义了在可执行容器中应该运行的应用程序。
所有 Dockerfile 指令的完整指南可以在官方 Docker 页面找到,网址是 docs.docker.com/engine/reference/builder/。
完整的 Docker 应用程序
我们已经拥有构建一个完全可工作的应用程序作为 Docker 镜像所需的所有信息。作为示例,我们将一步一步地准备一个简单的 Python hello-world 程序。无论我们使用哪种环境或编程语言,步骤始终相同。
编写应用程序
创建一个新目录,并在此目录中创建一个名为 hello.py 的文件,内容如下:
print "Hello World from Python!"
关闭文件。这就是我们应用程序的源代码。
准备环境
我们的环境将在 Dockerfile 中表示。我们需要指令来定义以下内容:
-
应使用哪个基础镜像
-
如何安装 Python 解释器
-
如何将
hello.py包含到镜像中 -
如何启动应用程序
在同一目录中创建 Dockerfile,方法如下:
FROM ubuntu:20.04
RUN apt-get update && \
apt-get install -y python
COPY hello.py .
ENTRYPOINT ["python", "hello.py"]
构建镜像
现在,我们可以以与之前完全相同的方式构建镜像,如下所示:
$ docker build -t hello_world_python .
运行应用程序
我们通过运行容器来启动应用程序,方法如下:
$ docker run hello_world_python
你应该会看到一个友好的 Hello World from Python! 消息。这个示例中最有趣的地方是,我们能够运行用 Python 编写的应用程序,而无需在主机系统中安装 Python 解释器。这是因为打包成镜像的应用程序已经包含了所需的环境。
提示
Docker Hub 服务中已经存在带有 Python 解释器的镜像,因此在实际场景中,使用该镜像就足够了。
环境变量
我们已经运行了第一个自制的 Docker 应用程序。然而,如果应用程序的执行依赖于某些条件怎么办?
例如,在生产服务器的情况下,我们希望将 Hello 打印到日志中,而不是打印到控制台,或者我们可能希望在测试阶段和生产阶段有不同的依赖服务。一种解决方案是为每种情况准备一个单独的 Dockerfile;但是,还有一种更好的方法:环境变量。
让我们将 hello-world 应用程序修改为打印 Hello World from <name_passed_as_environment_variable> !。为了实现这一点,我们需要按照以下步骤操作:
-
更改
hello.pyPython 脚本以使用环境变量,如下所示:import os print "Hello World from %s !" % os.environ['NAME'] -
构建镜像,方法如下:
$ docker build -t hello_world_python_name . -
运行容器并传递环境变量,方法如下:
$ docker run -e NAME=Rafal hello_world_python_name Hello World from Rafal ! -
或者,我们可以在 Dockerfile 中定义一个环境变量值,如下所示:
ENV NAME Rafal -
以不指定
-e选项的方式运行容器,如下所示:$ docker build -t hello_world_python_name_default . $ docker run hello_world_python_name_default Hello World from Rafal !
环境变量在我们需要根据 Docker 容器的用途拥有不同版本时特别有用;例如,为生产和测试服务器提供单独的配置文件。
信息
如果环境变量同时在 Dockerfile 和作为标志中定义,则标志优先。
Docker 容器状态
迄今为止,我们运行的每个应用程序都应该执行某些工作然后停止——例如,我们打印了Hello from Docker!并退出。然而,有些应用程序应该持续运行,比如服务。
要在后台运行容器,我们可以使用-d(--detach)选项。我们可以尝试使用ubuntu镜像,如下所示:
$ docker run -d -t ubuntu:20.04
此命令启动了 Ubuntu 容器,但并没有将控制台附加到它。我们可以使用以下命令查看它是否正在运行:
$ docker ps
CONTAINER ID IMAGE COMMAND STATUS PORTS
NAMES
95f29bfbaadc ubuntu:20.04 "/bin/bash" Up 5 seconds kickass_stonebraker
此命令打印所有处于运行中状态的容器。那么我们的旧容器,它们已经退出了呢? 我们可以通过打印所有容器来找到它们,如下所示:
$ docker ps -a
CONTAINER ID IMAGE COMMAND STATUS PORTS
NAMES
95f29bfbaadc ubuntu:20.04 "/bin/bash" Up 33 seconds kickass_stonebraker
34080d914613 hello_world_python_name_default "python hello.py" Exited lonely_newton
7ba49e8ee677 hello_world_python_name "python hello.py" Exited mad_turing
dd5eb1ed81c3 hello_world_python "python hello.py" Exited thirsty_bardeen
...
请注意,所有旧的容器都处于退出状态。我们还有两个状态尚未观察到:暂停和重启。
所有状态及其之间的转换展示在以下图表中:

图 2.8 – Docker 容器状态
暂停 Docker 容器是非常罕见的,技术上它是通过使用SIGSTOP信号冻结进程来实现的。重启是当容器使用--restart选项运行时的一种临时状态,用于定义重启策略(Docker 守护进程能够在失败时自动重启容器)。
上图还显示了用于将 Docker 容器状态从一个状态转换到另一个状态的 Docker 命令。
例如,我们可以停止运行 Ubuntu 容器,如下所示:
$ docker stop 95f29bfbaadc
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
信息
我们一直使用docker run命令来创建并启动容器。然而,也可以仅创建一个容器而不启动它(使用docker create)。
掌握了 Docker 状态的细节后,让我们描述一下 Docker 世界中的网络基础知识。
Docker 网络
现在的大多数应用程序都不是孤立运行的;它们需要通过网络与其他系统进行通信。如果我们想在 Docker 容器内运行网站、Web 服务、数据库或缓存服务器,我们需要首先了解如何运行服务并将其端口暴露给其他应用程序。
运行服务
让我们从一个简单的示例开始,并直接从 Docker Hub 运行一个 Tomcat 服务器,如下所示:
$ docker run -d tomcat
Tomcat 是一个 Web 应用服务器,其用户界面可以通过端口8080访问。因此,如果我们在机器上安装了 Tomcat,我们可以在http://localhost:8080访问它。然而,在我们的案例中,Tomcat 是在 Docker 容器内运行的。
我们以与第一个Hello World示例相同的方式启动它。我们可以看到它正在运行,如下所示:
$ docker ps
CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES
d51ad8634fac tomcat "catalina.sh run" Up About a minute 8080/tcp jovial_kare
由于它是以守护进程方式运行(使用 -d 选项),所以我们不会立即在控制台看到日志。然而,我们可以通过执行以下代码来访问它:
$ docker logs d51ad8634fac
如果没有错误,我们应该看到很多日志,这表明 Tomcat 已经启动并且可以通过端口 8080 访问。我们可以尝试访问 http://localhost:8080,但无法连接。这是因为 Tomcat 已经在容器内启动,我们正在尝试从外部连接。换句话说,只有在通过命令连接到容器的控制台并在那里检查时,我们才能访问它。我们如何使运行中的 Tomcat 从外部可访问呢?
我们需要启动容器,并指定端口映射,使用 -p(--publish)标志,如下所示:
-p, --publish <host_port>:<container_port>
所以,让我们首先停止运行中的容器并启动一个新容器,如下所示:
$ docker stop d51ad8634fac
$ docker run -d -p 8080:8080 tomcat
等待几秒钟后,Tomcat 应该已经启动,我们应该能够打开其页面——http://localhost:8080。
以下截图演示了如何发布 Docker 容器端口:

图 2.9 – 发布 Docker 容器端口
这样一个简单的端口映射命令在大多数常见的 Docker 用例中就足够了。我们能够将(微型)服务作为 Docker 容器部署并暴露它们的端口以促进通信。然而,让我们深入探讨一下背后发生了什么。
信息
Docker 还允许我们使用 -p <ip>:<host_port>:<container_port> 来发布到特定的主机网络接口。
容器网络
我们已经连接到运行在容器内的应用程序。实际上,连接是双向的,因为如果你记得我们之前的示例,我们从内部执行了 apt-get install 命令,包是从互联网下载的。这是怎么可能的?
如果你检查你机器上的网络接口,你会看到其中一个接口叫做 docker0,如下所示:
$ ifconfig docker0
docker0 Link encap:Ethernet HWaddr 02:42:db:d0:47:db
inet addr:172.17.0.1 Bcast:0.0.0.0 Mask:255.255.0.0
...
docker0 接口是 Docker 守护进程创建的,用于与 Docker 容器连接。现在,我们可以看到通过 docker inspect 命令查看到的 Tomcat Docker 容器内创建了哪些接口,如下所示:
$ docker inspect 03d1e6dc4d9e
这会以 JavaScript 对象表示法 (JSON) 格式打印出关于容器配置的所有信息。其中,我们可以找到与网络设置相关的部分,如下所示的代码片段:
"NetworkSettings": {
"Bridge": "",
"Ports": {
"8080/tcp": [
{
"HostIp": "0.0.0.0",
"HostPort": "8080"
}
]
},
"Gateway": "172.17.0.1",
"IPAddress": "172.17.0.2",
"IPPrefixLen": 16,
}
信息
为了过滤 docker inspect 的响应,我们可以使用 --format 选项——例如,docker inspect --format '{{ .NetworkSettings.IPAddress }}' <container_id>。
我们可以观察到 Docker 容器的 IP 地址是 172.17.0.2,它与 Docker 主机的 IP 地址 172.17.0.1 进行通信。这意味着,在我们之前的示例中,即使没有端口转发,使用 http://172.17.0.2:8080 也可以访问 Tomcat 服务器。然而,在大多数情况下,我们是在服务器机器上运行 Docker 容器,并希望将其暴露到外部,因此我们需要使用 -p 选项。
请注意,默认情况下,容器不会从外部系统打开任何路由。我们可以通过调整 --network 标志来更改这种默认行为,并按如下方式设置:
-
bridge(默认):通过默认的 Docker 桥接网络 -
none:无网络 -
container:与其他(指定的)容器共同加入的网络 -
host:主机的网络堆栈 -
NETWORK:用户创建的网络(使用docker network create命令)
可以使用 docker network 命令列出并管理不同的网络,具体如下:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
b3326cb44121 bridge bridge local
84136027df04 host host local
80c26af0351c none null local
如果我们指定 none 作为网络,就无法连接到容器,反之亦然;容器也没有网络访问外部世界的能力。host 选项使得 bridge)成为可用,因为它让我们明确指定应该发布哪些端口,并且既安全又可访问。
暴露容器端口
我们曾多次提到容器暴露端口。事实上,如果我们深入 GitHub 上的 Tomcat 镜像(github.com/docker-library/tomcat),可以看到 Dockerfile 中有以下一行:
EXPOSE 8080
该 Dockerfile 指令规定应该从容器中暴露端口 8080。然而,正如我们已经看到的,这并不意味着端口会自动发布。EXPOSE 指令只是通知用户应该发布哪些端口。
自动端口分配
让我们尝试在不停止第一个 Tomcat 容器的情况下运行第二个 Tomcat 容器,如下所示:
$ docker run -d -p 8080:8080 tomcat
0835c95538aeca79e0305b5f19a5f96cb00c5d1c50bed87584cfca8ec790f241
docker: Error response from daemon: driver failed programming external connectivity on endpoint distracted_heyrovsky (1b1cee9896ed99b9b804e4c944a3d9544adf72f1ef3f9c9f37bc985e9c30f452): Bind for 0.0.0.0:8080 failed: port is already allocated.
这个错误可能是常见的。在这种情况下,我们必须自己处理端口的唯一性,或者让 Docker 自动分配端口,可以使用以下任一版本的 publish 命令:
-
-p <container_port>:将容器端口发布到未使用的主机端口 -
-p(--publish-all):将容器暴露的所有端口发布到未使用的主机端口,具体如下:$ docker run -d -P tomcat 078e9d12a1c8724f8aa27510a6390473c1789aa49e7f8b14ddfaaa328c8f737b $ docker port 078e9d12a1c8 8080/tcp -> 0.0.0.0:32772
我们可以看到第二个 Tomcat 实例已发布到端口 32772,因此可以通过 http://localhost:32772 访问。
了解了 Docker 网络基础知识后,让我们看看如何使用 Docker 卷为 Docker 容器提供持久层。
使用 Docker 卷
假设你想将一个数据库作为容器运行。你可以启动这样的容器并输入数据。数据存储在哪里? 当你停止或移除容器时会发生什么? 你可以重新启动一个新的容器,但数据库会再次为空。除非这是你的测试环境,否则你期望数据能够永久保存。
Docker 卷是 Docker 主机的目录,挂载在容器内。它允许容器像写入自己的文件系统一样写入主机的文件系统。这个机制在下图中展示:

图 2.10 – 使用 Docker 卷
Docker 卷使得容器的数据得以持久化和共享。卷也清晰地将处理过程与数据分开。让我们从以下示例开始:
-
使用
-v <host_path>:<container_path>选项指定卷,然后连接到容器,方法如下:$ docker run -i -t -v ~/docker_ubuntu:/host_directory ubuntu:20.04 /bin/bash -
在容器中的
host_directory创建一个空文件,方法如下:root@01bf73826624:/# touch /host_directory/file.txt -
通过运行以下命令检查文件是否在 Docker 主机的文件系统中创建:
root@01bf73826624:/# exit exit $ ls ~/docker_ubuntu/ file.txt -
我们可以看到文件系统已共享,因此数据得以永久保存。停止容器并运行一个新的容器,看看我们的文件是否还在,方法如下:
$ docker run -i -t -v ~/docker_ubuntu:/host_directory ubuntu:20.04 /bin/bash root@a9e0df194f1f:/# ls /host_directory/ file.txt root@a9e0df194f1f:/# exit -
不使用
-v标志来指定卷,而是可以在 Dockerfile 中将其作为指令指定,如以下示例所示:VOLUME /host_directory
在这种情况下,如果我们不使用-v标志运行 Docker 容器,容器的/host_directory路径将映射到主机的卷的默认目录/var/lib/docker/vfs/。如果你交付一个应用程序作为镜像并且知道它由于某种原因需要持久存储(例如存储应用程序日志),这是一个很好的解决方案。
信息
如果一个卷在 Dockerfile 和作为标志都被定义,则flag命令具有优先权。
Docker 卷可以更复杂,特别是在数据库的情况下。然而,Docker 卷的更复杂用法超出了本书的范围。
信息
在 Docker 中进行数据管理的一个非常常见的方法是引入一个额外的层,即数据卷容器。数据卷容器是一个 Docker 容器,其唯一目的是声明一个卷。然后,其他容器可以使用它(通过--volumes-from <container>选项),而不需要直接声明卷。了解更多请访问docs.docker.com/storage/volumes/。
了解 Docker 卷后,让我们看看如何使用名称使得操作 Docker 镜像/容器更加方便。
在 Docker 中使用名称
到目前为止,当我们操作容器时,我们总是使用自动生成的名称。这种方法有一些优点,例如名称唯一(没有命名冲突)和自动化(无需做任何事)。然而,在许多情况下,给容器或镜像起一个用户友好的名称更好。
命名容器
有两个好理由来给容器命名:方便性和自动化的可能性。让我们看一下原因,具体如下:
-
方便性:用名称来操作容器比检查哈希或自动生成的名称更简单。
-
自动化:有时,我们希望依赖于容器的特定命名。
例如,我们可能希望有相互依赖的容器,并将一个容器链接到另一个容器。因此,我们需要知道它们的名称。
为了给容器命名,我们使用--name参数,如下所示:
$ docker run -d --name tomcat tomcat
我们可以通过 docker ps 来检查容器是否具有有意义的名称。这样,任何操作都可以使用容器的名称进行,例如以下示例:
$ docker logs tomcat
请注意,当容器被命名时,它不会失去其身份。我们仍然可以通过自动生成的哈希 ID 来引用容器,就像我们之前做的那样。
信息
一个容器总是有一个 ID 和一个名称。我们可以通过任意一个来引用它们,而且它们都是唯一的。
镜像打标签
镜像可以打标签。我们在创建自己的镜像时已经做过这件事——例如,在构建 hello_world_python 镜像时,如下所示:
$ docker build -t hello_world_python .
-t 标志描述镜像的标签。如果我们不使用它,镜像将没有标签,因此我们必须通过其 ID(哈希值)来引用它,才能运行容器。
镜像可以有多个标签,它们应该遵循以下命名约定:
<registry_address>/<image_name>:<version>
一个标签由以下部分组成:
-
registry_address:注册表的 IP 和端口或别名 -
image_name:构建的镜像名称,例如ubuntu -
version:镜像的版本,可以是任何形式的版本,例如20.04、20170310
我们将在 第五章 中讨论 Docker 注册表,自动化验收测试。如果镜像保存在官方的 Docker Hub 注册表中,我们可以跳过注册表地址。这就是为什么我们运行 tomcat 镜像时没有任何前缀的原因。最后的版本总是被标记为 latest,并且可以省略,因此我们运行 tomcat 镜像时没有任何后缀。
信息
镜像通常有多个标签;例如,以下三个标签表示同一个镜像:ubuntu:18.04、ubuntu:bionic-20190122 和 ubuntu:bionic。
最后但同样重要的是,我们需要学习如何在玩 Docker 之后进行清理。
Docker 清理
在本章中,我们已经创建了许多容器和镜像。然而,这仅仅是你在实际场景中会看到的一小部分。即使容器未运行,它们也需要存储在 Docker 主机上。这可能会迅速导致存储空间不足并停止机器。我们该如何解决这个问题呢?
清理容器
首先,让我们看一下存储在我们机器上的容器。以下是我们需要遵循的步骤:
-
要打印所有容器(无论其状态如何),我们可以使用
docker ps -a命令,如下所示:$ docker ps -a CONTAINER ID IMAGE COMMAND STATUS PORTS NAMES 95c2d6c4424e tomcat "catalina.sh run" Up 5 minutes 8080/tcp tomcat a9e0df194f1f ubuntu:20.04 "/bin/bash" Exited jolly_archimedes 01bf73826624 ubuntu:20.04 "/bin/bash" Exited suspicious_feynman ... -
为了删除一个停止的容器,我们可以使用
docker rm命令(如果容器正在运行,我们需要先停止它),如下所示:$ docker rm 47ba1c0ba90e -
如果我们想要删除所有停止的容器,可以使用以下命令:
$ docker container prune -
我们还可以采用另一种方法,通过使用
--rm标志,要求容器在停止后自动删除自己,如以下示例所示:$ docker run --rm hello-world
在大多数现实场景中,我们不会使用已停止的容器,它们通常仅用于调试目的。
清理镜像
清理镜像与清理容器一样重要。它们可能占用大量空间,尤其是在 CD 流程中,每次构建都会生成一个新的 Docker 镜像。这可能很快导致磁盘空间不足的错误。具体步骤如下:
-
要查看 Docker 容器中的所有镜像,我们可以使用
docker images命令,如下所示:$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE hello_world_python_name_default latest 9a056ca92841 2 hours ago 202.6 MB hello_world_python_name latest 72c8c50ffa89 2 hours ago 202.6 MB ... -
要删除一个镜像,我们可以使用以下命令:
$ docker rmi 48b5124b2768 -
对于镜像,自动清理过程稍微复杂一些。镜像没有状态,因此我们不能要求它们在不使用时自动删除。一个常见的策略是设置一个定时清理任务,删除所有旧的和未使用的镜像。我们可以使用以下命令来实现:
docker volume prune command.
在清理部分结束后,我们已经完成了主要的 Docker 描述。接下来,让我们简要总结一下并回顾一下最重要的 Docker 命令。
提示
使用docker system prune命令删除所有未使用的容器、镜像和网络。此外,你还可以添加–volumes参数来清理卷。
Docker 命令概览
所有 Docker 命令可以通过执行以下help命令找到:
$ docker help
要查看任何特定 Docker 命令的所有选项,我们可以使用docker help <command>,如下所示:
$ docker help run
官方 Docker 页面上也有非常好的 Docker 命令解释,docs.docker.com/engine/reference/commandline/docker/ 上有详细说明。值得一读,或者至少浏览一下。
在本章中,我们介绍了最有用的命令及其选项。作为快速回顾,以下是它们的总结:

概要
在本章中,我们讲解了 Docker 的基础知识,这足以构建镜像和运行作为容器的应用程序。以下是主要要点总结。
容器化技术通过使用 Linux 内核特性来解决隔离和环境依赖问题。这是基于进程分离机制,因此不会出现真正的性能下降。Docker 可以安装在大多数系统上,但仅在 Linux 上原生支持。Docker 允许我们从互联网上获取可用的镜像并构建我们自己的镜像。镜像是将应用程序与所有依赖项打包在一起的文件。
Docker 提供了两种构建镜像的方法——使用 Dockerfile 或提交容器。在大多数情况下,使用第一种方法。Docker 容器可以通过发布它们暴露的端口进行网络通信。Docker 容器可以通过卷共享持久存储。为了方便,Docker 容器应该命名,Docker 镜像应该打标签。在 Docker 的世界里,关于如何标记镜像有一个特定的约定。为了节省服务器空间并避免 磁盘空间不足 错误,应该定期清理 Docker 镜像和容器。
在下一章中,我们将介绍 Jenkins 配置,并了解如何将 Jenkins 与 Docker 配合使用。
练习
我们在这一章中已经覆盖了很多内容。为了巩固我们所学的知识,推荐以下两个练习:
-
运行
CouchDB作为 Docker 容器并发布其端口,如下所示:提示
你可以使用
docker search命令查找CouchDB镜像。-
运行容器。
-
发布
CouchDB端口。 -
打开浏览器并检查
CouchDB是否可用。
-
-
创建一个包含 REST 服务的 Docker 镜像,回复
Hello World到localhost:8080/hello。使用你喜欢的任何语言和框架。以下是你需要遵循的步骤:提示
创建 REST 服务的最简单方法是使用 Python 和 Flask 框架(
flask.palletsprojects.com/)。请注意,许多 Web 框架默认只在本地主机接口上启动应用程序。为了发布端口,必须在所有接口上启动它(在 Flask 框架中使用app.run(host='0.0.0.0'))。-
创建一个 Web 服务应用程序。
-
创建一个 Dockerfile 来安装依赖和库。
-
构建镜像。
-
运行发布端口的容器。
-
使用浏览器(或
curl)检查它是否正确运行。
-
问题
为了验证你在本章中获得的知识,请回答以下问题,使用 L-编号进行列表。
-
容器化(如 Docker)和虚拟化(如 VirtualBox)之间的主要区别是什么?
-
将应用程序提供为 Docker 镜像的好处是什么?请至少列举两点。
-
Docker 守护进程是否可以在 Windows 和 macOS 上原生运行?
-
Docker 镜像和 Docker 容器之间有什么区别?
-
当说 Docker 镜像有层时,这意味着什么?
-
创建 Docker 镜像的两种方法是什么?
-
哪个命令用于从 Dockerfile 创建 Docker 镜像?
-
哪个命令用于从 Docker 镜像运行 Docker 容器?
-
在 Docker 术语中,发布端口是什么意思?
-
什么是 Docker 卷?
进一步阅读
如果你对深入了解 Docker 和相关技术感兴趣,请查阅以下资源:
-
Docker 文档——入门:
docs.docker.com/get-started/ -
Docker 书籍 由 詹姆斯·特恩布尔 编写:
dockerbook.com/
第三章:配置 Jenkins
要启动任何持续交付过程,我们需要一个自动化服务器,如 Jenkins。然而,配置 Jenkins 可能很困难,尤其是当分配给它的任务随着时间增加时。更重要的是,由于 Docker 允许动态提供 Jenkins 代理,那么值得花时间在前期将所有内容配置正确,以便考虑到可扩展性吗?
在本章中,我们将介绍 Jenkins,它可以单独使用,也可以与 Docker 一起使用。我们将展示这两种工具的结合能够产生令人惊讶的好效果——自动化配置和灵活的可扩展性。
本章将涵盖以下主题:
-
什么是 Jenkins?
-
安装 Jenkins
-
Jenkins – Hello World
-
Jenkins 架构
-
配置代理
-
自定义 Jenkins 镜像
-
配置与管理
技术要求
为了跟随本章的指导,您需要以下硬件/软件:
-
Java 8 及以上版本
-
至少 4 GB 的内存
-
至少 1 GB 的空闲磁盘空间
-
已安装 Docker 引擎
本章中的所有示例和练习解答可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter03。
本章的《Code in Action》视频可以在bit.ly/3DP02TW观看。
什么是 Jenkins?
Jenkins 是一个用 Java 编写的开源自动化服务器。它拥有非常活跃的社区支持和大量插件,是实现持续集成和持续交付流程最流行的工具之一。曾被称为 Hudson,在 Oracle 收购 Hudson 并决定将其开发为专有软件后,Jenkins 被更名。Jenkins 从 Hudson 分叉,但仍然作为 MIT 许可证下的开源软件。由于其简单性、灵活性和多功能性,它备受推崇。
Jenkins 超越了其他持续集成工具,是最广泛使用的同类软件。这一切都得益于它的功能和能力。
让我们深入了解 Jenkins 特性中最有趣的部分:
-
语言无关:Jenkins 有很多插件,支持大多数编程语言和框架。此外,由于它可以使用任何 shell 命令和任何软件,因此适用于所有可想象的自动化流程。
-
通过插件可扩展:Jenkins 拥有一个强大的社区和大量可用的插件(超过千个)。它还允许你编写自己的插件,以便根据你的需求定制 Jenkins。
-
可移植:Jenkins 是用 Java 编写的,因此可以在任何操作系统上运行。为了方便,它也提供了多种版本——Web 应用程序归档(WAR)文件、Docker 镜像、Helm 图表、Kubernetes 操作符、Windows 二进制文件、macOS 二进制文件和 Linux 二进制文件。
-
支持大多数源代码管理(SCM)工具:Jenkins 几乎与所有存在的源代码管理或构建工具集成。由于其庞大的社区和大量插件,没有其他持续集成工具能支持如此多的外部系统。
-
分布式:Jenkins 具有内置的主机/代理模式机制,将其执行分布到多个节点,位于多台机器上。它还可以使用异构环境;例如,不同的节点可以安装不同的操作系统。
-
简便性:安装和配置过程简单。无需配置任何额外的软件或数据库。可以通过 GUI、XML 或 Groovy 脚本完全配置。
-
面向代码:Jenkins 流水线定义为代码。此外,Jenkins 本身可以通过 YAML/XML 文件或 Groovy 脚本进行配置。这使你能够将配置保存在源代码仓库中,并有助于 Jenkins 配置的自动化。
现在你已经对 Jenkins 有了基本的了解,接下来我们来讲解如何安装它。
安装 Jenkins
安装 Jenkins 有不同的方法,你应选择最适合你需求的方法。接下来,我们将介绍所有可选方法,并详细描述最常见的选择:
-
Servlet:Jenkins 使用 Java 编写,并以 WAR 格式作为 Web 应用程序原生分发,专门运行在应用服务器中(如 Apache Tomcat 或 GlassFish);如果你将所有应用程序部署为 servlet,可以考虑此选项。
-
应用程序:Jenkins 的 WAR 文件内嵌了 Jetty 应用服务器,因此可以直接使用 Java 命令运行,因此,Java 运行时环境(JRE)是启动 Jenkins 的唯一要求;如果你使用裸机服务器和/或需要在一台机器上安装多个 Jenkins 实例,可以考虑此选项。
-
专用包:Jenkins 以专用包的形式为大多数操作系统分发(Windows 的 MSI、macOS 的 Homebrew 包、Debian/Ubuntu 的 deb 包等);如果你使用裸机服务器,考虑此选项以便进行最简单的安装和配置。
-
Docker:Jenkins 以 Docker 镜像的形式分发,因此唯一的要求是安装 Docker;如果你在生态系统中使用 Docker,考虑此选项以获得最简单的安装。
-
Kubernetes:Jenkins 提供了 Helm 图表和 Kubernetes 操作符,简化了在 Kubernetes 集群中的安装、管理和扩展;如果你需要最简单的 Jenkins 扩展和管理,考虑使用此选项。
-
云服务:Jenkins 作为软件即服务(SaaS)由多个平台托管;如果你不想考虑服务器维护和 Jenkins 安装,考虑使用此选项。
每种安装方式都有其优缺点。我们将描述最常见的方法,从使用 Jenkins Docker 镜像开始。
信息
您可以在 www.jenkins.io/doc/book/installing/ 上找到每种安装方法的详细描述。
使用 Docker 安装 Jenkins
Jenkins 镜像在 Docker Hub 仓库中可用,因此为了安装其最新版本,我们应该执行以下命令:
$ docker run -p <host_port>:8080 -v <host_volume>:/var/jenkins_home jenkins/jenkins
我们需要指定以下参数:
-
第一个
host_port参数:Jenkins 在容器外部可见的端口。 -
第二个
host_volume参数:它指定了 Jenkins 主目录的映射目录。需要将其指定为卷,以便永久保存,因为它包含了配置、流水线构建和日志。
作为示例,我们按照以下安装步骤进行:
-
准备存储卷目录:我们需要一个单独的目录来存储 Jenkins 数据。让我们使用以下命令来准备它:
$ mkdir $HOME/jenkins_home -
运行 Jenkins 容器:让我们以守护进程的方式运行容器,并使用以下命令为其指定一个合适的名称:
$ docker run -d -p 8080:8080 \ -v $HOME/jenkins_home:/var/jenkins_home \ --name jenkins jenkins/jenkins -
检查 Jenkins 是否正在运行:过一会儿,我们可以通过打印日志来检查 Jenkins 是否已正确启动:
$ docker logs jenkins Running from: /usr/share/jenkins/jenkins.war webroot: EnvVars.masterEnvVars.get("JENKINS_HOME") ...信息
在生产环境中,您可能还需要设置一些额外的参数;有关详细信息,请参考
www.jenkins.io/doc/book/installing/docker/。
完成这些步骤后,您可以通过 http://localhost:8080/ 访问您的 Jenkins 实例。
使用专用安装包安装 Jenkins
如果您的服务器上没有使用 Docker,那么最简单的方法是使用专用的安装包。Jenkins 支持大多数操作系统——例如,Windows 的 MSI、macOS 的 Homebrew 包以及 Debian/Ubuntu 的 deb 包。
作为示例,在 Ubuntu 系统中,只需运行以下命令即可安装 Jenkins(以及所需的 Java 依赖):
$ sudo apt-get update
$ sudo apt-get -y install default-jdk
$ wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | sudo apt-key add –
$ sudo sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'
$ sudo apt-get update
$ sudo apt-get -y install jenkins
安装成功后,可以通过 http://localhost:8080/ 访问 Jenkins 实例。
初始配置
无论您选择哪种安装方式,启动 Jenkins 都需要几个配置步骤。让我们一步步地走过这些步骤:
-
在浏览器中打开 Jenkins,地址为
http://localhost:8080。 -
Jenkins 会要求输入管理员密码。可以在 Jenkins 日志中找到该密码:
$ docker logs jenkins ... Jenkins initial setup is required. An admin user has been created and a password generated. Please use the following password to proceed to installation: c50508effc6843a1a7b06f6491ed0ca6 ... -
在接受初始密码后,Jenkins 会询问是否安装建议的插件,这些插件是针对最常见的用例进行调整的。您的回答当然取决于您的需求。不过,作为第一次安装 Jenkins,允许 Jenkins 安装所有推荐插件是合理的选择。
-
插件安装完成后,Jenkins 会要求您设置用户名、密码以及其他基本信息。如果跳过此步骤,将使用 步骤 2 中的令牌作为管理员密码。
安装完成后,你应该会看到 Jenkins 仪表盘:

图 3.1 – 成功安装 Jenkins
现在,让我们看看如何在 Kubernetes 集群环境中安装 Jenkins。
在 Kubernetes 中安装 Jenkins
在 Kubernetes 中安装 Jenkins 有两种方法——使用 Helm 图表或 Kubernetes 操作员。我们先来看一个更简单的选项,即使用 Helm 工具。
提示
有关 Helm 工具及其安装过程的更多详细信息,请访问 helm.sh/。
使用以下命令安装 Jenkins:
$ helm repo add jenkinsci https://charts.jenkins.io
$ helm repo update
$ helm install jenkins jenkinsci/jenkins
执行前面的命令后,Jenkins 已安装。你可以使用以下命令检查其日志:
$ kubectl logs sts/jenkins jenkins
Running from: /usr/share/jenkins/jenkins.war
...
默认情况下,Jenkins 实例配置了一个管理员账户,并通过随机生成的密码进行保护。要查看此密码,请执行以下命令:
$ kubectl get secret jenkins -o jsonpath="{.data.jenkins-admin-password}" | base64 --decode
nn1Pvq7asHPYz7EUHhc4PH
现在,你将能够使用以下凭据登录 Jenkins:
-
admin -
nn1Pvq7asHPYz7EUHhc4PH
默认情况下,Jenkins 不会暴露在 Kubernetes 集群外部。要使其能够从本地计算机访问,请运行以下命令:
$ kubectl port-forward sts/jenkins 8080:8080
之后,你可以在浏览器中打开 http://localhost:8080/,并使用前述凭据登录。
信息
请访问 www.jenkins.io/doc/book/installing/kubernetes/ 以获取有关在 Kubernetes 中安装 Jenkins 的更多信息。
将 Jenkins 安装在 Kubernetes 集群中的最大好处之一是,它提供了开箱即用的水平扩展功能。Jenkins 代理通过 Jenkins 的 Kubernetes 插件自动配置。
我们将在 Jenkins 架构 部分中讲解如何扩展 Jenkins,并在 第六章 中讲解更多 Kubernetes 相关内容,与 Kubernetes 集群协作。现在,让我们看看如何在云中使用 Jenkins。
云中的 Jenkins
如果你不想自己安装 Jenkins,有一些公司提供托管在云中的 Jenkins 服务。不过需要注意的是,Jenkins 从未采用云优先的方法设计,因此大多数提供的服务实际上是通用的云解决方案,旨在帮助你安装和管理 Jenkins 应用程序。
我推荐的解决方案是 Google Cloud Marketplace,它可以在 Google Kubernetes Engine 中自动部署 Jenkins。详细信息请参见 cloud.google.com/jenkins。其他提供托管 Jenkins 服务的公司包括 Kamatera 和 Servana。
当我们终于启动并运行 Jenkins 后,我们就准备创建我们的第一个 Jenkins 管道。
Jenkins – Hello World
在整个 IT 世界中,一切都从 Hello World 示例开始,来证明基本功能正常。让我们遵循这一规则,并用它来创建第一个 Jenkins 管道:
- 点击 新建项目:

图 3.2 – Jenkins Web 界面中的新项目
- 在“项目名称”字段中输入
hello world,选择管道,然后点击确定:

图 3.3 – Jenkins Web 界面中的新管道
- 有很多选项。我们现在先跳过这些,直接进入管道部分:

图 3.4 – Jenkins Web 界面的管道脚本
-
然后,在脚本文本框中,我们可以输入管道脚本:
pipeline { agent any stages { stage("Hello") { steps { echo 'Hello World' } } } } -
点击保存。
-
点击立即构建:

图 3.5 – Jenkins Web 界面的立即构建
我们应该在构建历史下看到#1。如果点击它,然后点击控制台输出,我们将看到管道构建的日志:

图 3.6 – Jenkins Web 界面的控制台输出
第一个示例中的成功输出意味着 Jenkins 已正确安装。现在,让我们看看可能的 Jenkins 架构。
信息
我们将在第四章中详细描述更多关于管道语法的内容,持续集成管道。
Jenkins 架构
Hello World 几乎立即执行完毕。然而,管道通常更复杂,时间花费在下载文件、编译源代码或运行测试等任务上。一次构建可能需要几分钟到几个小时。
在常见的场景中,也会有许多并发管道。通常,一个团队,甚至一个整个组织,都会使用同一个 Jenkins 实例。我们如何确保构建能够快速且顺利地运行呢?
主服务器和代理
Jenkins 的负载很快就会超载。即使是一个小型(微型)服务,构建也可能需要几分钟。这意味着,一个团队频繁提交代码很容易导致 Jenkins 实例崩溃。
因此,除非项目非常小,否则 Jenkins 不应执行构建任务,而应将它们委派给代理(从)实例。准确来说,我们当前运行的 Jenkins 服务器叫做Jenkins 主服务器,它可以将执行任务委派给Jenkins 代理。
让我们看看一个展示主从交互的图表:

图 3.7 – Jenkins 主从交互
在分布式构建环境中,Jenkins 主服务器负责以下任务:
-
接收构建触发(例如,GitHub 提交后)
-
发送通知(例如,构建失败后发送电子邮件或 Slack 消息)
-
处理 HTTP 请求(与客户端的交互)
-
管理构建环境(协调代理上的作业执行)
构建代理是负责处理构建启动后所有事务的机器。
由于主节点和代理的职责不同,因此它们有不同的环境要求:
-
主节点:通常这是(除非项目非常小)一台专用机器,内存从小项目的 200 MB 到大型单主节点项目的 70+ GB 不等。
-
代理:没有特别的通用要求(除了它应该能够执行单次构建;例如,如果项目是一个庞大的单体项目,需要 100 GB 的内存,那么代理机器需要满足这些需求)。
代理应尽可能通用。例如,如果我们有不同的项目——一个是 Java 项目,一个是 Python 项目,还有一个是 Ruby 项目——那么最好每个代理都能构建这些项目中的任何一个。这样,代理可以互换,有助于优化资源的使用。
提示
如果代理无法通用到所有项目,那么可以对代理和项目进行标记(标签),以确保给定的构建会在指定类型的代理上执行。
可扩展性
正如软件世界中的一切一样,随着使用量的增加,Jenkins 实例可能会迅速变得过载并变得无法响应。因此,我们需要提前考虑如何扩展它。有两种可能的方法——垂直扩展 和 横向扩展。
垂直扩展
垂直扩展意味着,当主节点的负载增加时,会向主节点的机器添加更多资源。所以,当我们的组织中出现新项目时,我们会购买更多的内存、添加更多的 CPU 核心,扩展硬盘驱动器。这可能听起来像是一种不可行的解决方案;然而,许多知名的组织经常使用这种方法。将一个 Jenkins 主节点设置在超高效的硬件上有一个非常强大的优势——维护。任何升级、脚本、安全设置、角色分配或插件安装都必须在一个地方完成。
横向扩展
横向扩展意味着,当组织增长时,会启动更多的主实例。这需要智能地将实例分配给团队,在极端情况下,每个团队可以拥有自己的 Jenkins 主节点。这样可能会出现不需要代理的情况。
缺点是,可能很难实现跨项目的集成自动化,而且团队的一部分开发时间会花在 Jenkins 的维护上。然而,横向扩展有一些显著的优势:
-
主机硬件不需要特殊。
-
不同的团队可以拥有不同的 Jenkins 设置(例如,不同的插件集)。
-
如果实例属于自己的团队,团队通常会感觉更好,使用 Jenkins 的效率也更高。
-
如果一个主节点实例宕机,它不会影响整个组织。
-
基础设施可以分为标准和关键任务两类。
测试与生产实例
除了扩展方法外,还有一个问题——如何测试 Jenkins 的升级、新插件或管道定义。 Jenkins 对整个公司至关重要。它保证软件的质量,并且在持续交付的情况下,将软件部署到生产服务器上。这就是为什么它需要具备高可用性,并且绝对不能用作测试目的。因此,应该始终有两套相同的 Jenkins 基础设施——测试和生产。
示例架构
我们已经知道应该有代理和(可能多个)主节点,并且一切都应该在测试和生产环境中进行复制。然而,完整的架构是什么样的呢?
幸运的是,很多公司已经发布了他们如何使用 Jenkins 以及他们创建了什么样的架构。虽然很难衡量更多的公司倾向于使用垂直扩展还是水平扩展,但从只有一个主节点实例到每个团队都有一个主节点实例都有涉及。
让我们以 Netflix 为例,了解一个完整的 Jenkins 基础设施(Netflix 在 2012 年在旧金山的 Jenkins 用户大会上分享了这个计划中的基础设施):

图 3.8 – Netflix 的 Jenkins 基础设施
他们有测试和生产环境的主节点实例,每个主节点都有一组代理和额外的临时代理。总的来说,它每天处理约 2000 个构建任务。另外,需要注意的是,他们的部分基础设施托管在 AWS 上,另一部分则托管在自有服务器上。
你应该已经对 Jenkins 基础设施的可能样式有了大致的了解,具体取决于组织的类型。
现在,让我们关注设置代理的实际操作部分。
配置代理
你已经了解了代理是什么以及它们何时可以使用。然而,我们如何设置一个代理,并使其与主节点进行通信呢?让我们从问题的第二部分开始,描述主节点和代理之间的通信协议。
通信协议
为了使主节点和代理之间能够通信,必须建立双向连接。
启动 Jenkins 的方式有多种选择:
-
SSH:主节点通过标准的 SSH 协议连接到代理。Jenkins 内置了 SSH 客户端,因此唯一的要求是代理上需要配置SSH 守护进程(sshd)服务器。这是最方便和稳定的方法,因为它使用了标准的 Unix 机制。
-
Java Web 启动:在每个代理机器上启动一个 Java 应用程序,并在 Jenkins 代理应用程序与主节点 Java 应用程序之间建立 TCP 连接。如果代理在防火墙内,而主节点无法发起连接,则通常使用此方法。
一旦我们了解了通信协议,就可以看看如何使用这些协议来设置代理。
设置代理
在较低级别上,代理总是通过前面描述的协议之一与 Jenkins 主节点通信。然而,在更高级别上,我们可以以不同的方式将代理附加到主节点。差异涉及两个方面:
-
静态与动态:最简单的选项是在 Jenkins 主节点中永久添加代理。此解决方案的缺点是,如果需要更多(或更少)代理节点,我们总是需要手动更改一些内容。一个更好的选择是根据需要动态配置代理。
-
特定用途与通用用途:代理可以是特定用途的(例如,为基于 Java 8 和 Java 11 的项目提供不同的代理),也可以是通用用途的(代理作为 Docker 主机,管道在 Docker 容器内构建)。
这些差异导致了四种常见的代理配置策略:
-
永久代理
-
永久 Docker 主机代理
-
Jenkins Swarm 代理
-
动态配置的 Docker 代理
-
动态配置的 Kubernetes 代理
让我们检查每个解决方案。
永久代理
我们将从最简单的选项开始,即永久添加特定的代理节点。可以通过 Jenkins Web 界面完全完成这一操作。
配置永久代理
在 Jenkins 主节点中,当我们打开管理 Jenkins,然后点击管理节点和云时,可以查看所有连接的代理。接着,通过点击新建节点,给它命名,设置其类型为永久代理,并点击创建按钮,最终我们应该能看到代理的设置页面:

图 3.9 – 永久代理配置
让我们逐步了解需要填写的参数:
-
名称:这是代理的唯一名称。
-
描述:这是代理的可读描述。
-
执行器数量:这是可以在代理上并行运行的构建数量。
-
/var/jenkins);最重要的数据会传回到主节点,所以该目录并非关键。 -
标签:这包括匹配特定构建的标签(标签相同)——例如,仅基于 Java 8 的项目。
-
使用情况:这是用来决定代理是否仅用于匹配标签的选项(例如,仅用于验收测试构建),还是用于任何构建。
-
ssh <agent_hostname> java -jar ~/bin/slave.jar)。 -
通过 SSH 启动代理:在这里,主节点将通过 SSH 协议连接到代理。
-
50000用于与 Jenkins 主节点通信;因此,如果使用基于 Docker 的 Jenkins 主节点,则需要发布该端口(-p 50000:50000)。
当代理正确设置时,可以通过0更新主节点的内置配置,这样就不会在其上执行构建,它将仅作为 Jenkins UI 和构建的协调器。
信息
想了解更多详细信息和逐步说明如何配置永久性 Jenkins 代理,请访问 www.jenkins.io/doc/book/using/using-agents/。
理解永久代理
正如我们之前提到的,缺点是我们需要为不同的项目类型维护多个代理类型(标签)。下图展示了这种情况:

图 3.10 – 永久代理
在我们的示例中,如果我们有三种类型的项目(java7、java8 和 ruby),那么我们需要维护三个单独标记的(代理集)。这与我们在维护多个生产服务器类型时遇到的问题相同,正如在第二章《引入 Docker》中所描述的那样。我们通过在生产服务器上安装 Docker 引擎来解决了这个问题。让我们尝试用同样的方式来处理 Jenkins 代理。
永久性 Docker 主机代理
这个解决方案背后的想法是永久地添加通用代理。每个代理的配置是相同的(都安装了 Docker 引擎),并且每次构建都与 Docker 镜像一起定义,构建就在该镜像内运行。
配置永久性 Docker 主机代理
配置是静态的,因此与我们为永久代理所做的完全相同。唯一的区别是,我们需要在每台将用作代理的机器上安装 Docker。然后,通常我们不需要标签,因为所有代理都可以是相同的。代理配置完成后,我们在每个流水线脚本中定义 Docker 镜像:
pipeline {
agent {
docker {
image 'openjdk:8-jdk-alpine'
}
}
...
}
当构建开始时,Jenkins 代理从 Docker 镜像 openjdk:8-jdk-alpine 启动一个容器,然后在该容器内执行所有的流水线步骤。通过这种方式,我们总是能够知道执行环境,并且不需要根据特定的项目类型单独配置每个代理。
理解永久性 Docker 主机代理
看着我们为永久代理使用的相同场景,图示如下:

图 3.11 – 永久性 Docker 主机代理
每个代理完全相同,如果我们想构建一个依赖于 Java 8 的项目,那么我们将在流水线脚本中定义适当的 Docker 镜像(而不是指定代理标签)。
Jenkins Swarm 代理
到目前为止,我们总是需要在 Jenkins 主服务器中永久定义每个代理。这样的解决方案,尽管在许多情况下足够好,但如果我们需要频繁扩展代理机器的数量,可能会成为负担。Jenkins Swarm 允许你动态添加代理,而无需在 Jenkins 主服务器中配置它们。
配置 Jenkins Swarm 代理
使用 Jenkins Swarm 的第一步是在 Jenkins 中安装 Swarm 插件。我们可以通过 Jenkins 网页 UI 完成此操作,进入 管理 Jenkins 和 管理插件。完成此步骤后,Jenkins 主节点已准备好动态附加 Jenkins 代理。
第二步是在每台将作为 Jenkins 代理的机器上运行 Jenkins Swarm 代理应用程序。我们可以使用 swarm-client.jar 应用程序来完成此操作。
信息
swarm-client.jar 应用程序可以从 Jenkins Swarm 插件页面下载,网址为 plugins.jenkins.io/swarm/。在该页面,你还可以找到所有可能的执行选项。
要附加 Jenkins Swarm 代理节点,运行以下命令:
$ java -jar path/to/swarm-client.jar -url ${JENKINS_URL} -username ${USERNAME}
执行成功后,我们应该注意到 Jenkins 主节点上出现了一个新代理,当我们运行构建时,它将在该代理上启动。
了解 Jenkins Swarm 代理
让我们看一下以下图示,展示了 Jenkins Swarm 配置:

图 3.12 – Jenkins Swarm 代理
Jenkins Swarm 允许你动态添加代理,但并未说明是否使用特定的或基于 Docker 的代理,因此我们可以两者都用。乍一看,Jenkins Swarm 似乎没什么用,毕竟我们已将代理设置从主节点移到代理节点,但仍然需要手动操作。然而,借助 Kubernetes 或 Docker Swarm 等集群系统,Jenkins Swarm 显然使得在服务器集群上动态扩展代理成为可能。
动态分配的 Docker 代理
另一种选择是设置 Jenkins,在每次启动构建时动态创建一个新的代理。这种解决方案显然是最灵活的,因为代理的数量会根据构建的数量动态调整。让我们来看看如何以这种方式配置 Jenkins。
配置动态分配的 Docker 代理
首先,我们需要安装 Docker 插件。像往常一样,我们可以在 管理 Jenkins 和 管理插件 中完成此操作。插件安装后,我们可以开始以下配置步骤:
-
打开 管理 Jenkins 页面。
-
点击 管理节点和云 链接。
-
点击 配置云 链接。
-
点击 添加新云 并选择 Docker。
-
填写 Docker 代理的详细信息,如下图所示:

图 3.13 – Docker 代理配置
-
大多数参数无需更改;但是(除了选择
docker0网络接口外,你可以按照在 第二章 介绍 Docker 部分中描述的方法,修改/lib/systemd/system/docker.service文件中的一行,将ExecStart设置为/usr/bin/dockerd -H 0.0.0.0:2375 -H fd://)。 -
点击 Docker 代理模板... 然后选择 添加 Docker 模板。
-
填写有关 Docker 代理镜像的详细信息:

图 3.14 – Docker 代理模板配置
我们可以使用以下参数:
-
jenkins/agent(用于默认连接方式,即附加 Docker 容器)。 -
10。信息
除了
jenkins/agent,还可以构建并使用自己的代理镜像。这在特定环境要求的情况下可能会很有帮助——例如,你需要安装 Golang。还需注意,对于其他代理连接方法(jenkins/ssh-agent或jenkins/inbound-agent),详情请查看plugins.jenkins.io/docker-plugin/。
保存后,所有配置将完成。我们可以运行管道以观察执行是否确实发生在 Docker 代理上,但首先,让我们更深入地了解 Docker 代理是如何工作的。
理解动态提供的 Docker 代理
动态提供的 Docker 代理可以视为标准代理机制之上的一层。它既不改变通信协议,也不改变代理的创建方式。那么,Jenkins 对我们提供的 Docker 代理配置做了什么?
以下图示展示了我们配置的 Docker 主从架构:

图 3.15 – 动态提供的 Docker 代理
让我们一步一步描述如何使用 Docker 代理机制:
-
当 Jenkins 任务启动时,主节点在代理 Docker 主机上从
jenkins/agent镜像运行一个新容器。 -
jenkins/agent容器启动 Jenkins 代理并将其附加到 Jenkins 主节点的节点池中。 -
Jenkins 在
jenkins/agent容器内执行管道。 -
构建完成后,主节点停止并移除代理容器。
信息
将 Jenkins 主节点作为 Docker 容器运行与将 Jenkins 代理作为 Docker 容器运行是相互独立的。两者可以同时进行,但它们各自独立工作。
这个解决方案在某种程度上类似于永久 Docker 代理解决方案,因为最终我们是在 Docker 容器内运行构建。然而,区别在于代理节点配置。这里,整个代理都是 Docker 化的——不仅仅是构建环境。
提示
Jenkins 构建通常需要下载大量的项目依赖(例如,Gradle/Maven 依赖),这可能需要很长时间。如果为每次构建自动提供 Docker 代理,那么为它们设置 Docker 卷以便在构建之间启用缓存可能是值得的。
动态提供的 Kubernetes 代理
我们可以在 Kubernetes 中动态配置代理,类似于在 Docker 主机上所做的那样。这种方法的好处是,Kubernetes 是一个由多台物理机器组成的集群,可以根据需求轻松扩展或缩减。
配置动态配置的 Kubernetes 代理
首先,我们需要安装Kubernetes插件。然后,我们可以按照安装 Docker 代理时的相同步骤进行操作。区别在于,当我们点击添加一个新云时,选择的是Kubernetes而不是Docker,并填写有关 Kubernetes 集群的所有详细信息:

图 3.16 – Kubernetes 代理配置
你需要填写Kubernetes URL,即 Kubernetes 集群的地址。通常,你还需要输入 Kubernetes 集群的凭据。然后,你必须点击添加 Pod 模板,并按类似于为Docker插件中的Docker 模板所做的方式填写Pod 模板。
信息
关于如何设置 Jenkins Kubernetes 插件的更多详细说明,请访问plugins.jenkins.io/kubernetes/。
配置成功后,当你开始一个新的构建时,Jenkins 会自动在 Kubernetes 中配置一个新的代理,并用于管道执行。
提示
如果你按照本章开始时所述的方式使用 Helm 在 Kubernetes 中安装 Jenkins,它会自动配置 Kubernetes 插件,并自动在与 Jenkins 主服务器部署相同的 Kubernetes 集群中配置 Jenkins 代理。通过一个 Helm 命令,我们就能安装一个功能齐全且可扩展的 Jenkins 生态系统!
了解动态配置的 Kubernetes 代理
在 Kubernetes 中动态配置代理的方式与在 Docker 主机中配置代理非常相似。不同之处在于,现在我们与一群机器交互,而不仅仅是与单个 Docker 主机交互。这种方法在下图中有所展示:

图 3.17 – 动态配置的 Kubernetes 代理
Kubernetes 节点可以动态添加和移除,这使得整个主机-代理架构在所需资源方面非常灵活。当 Jenkins 构建任务过多时,我们可以轻松向 Kubernetes 集群中添加一台新机器,从而提高 Jenkins 的容量。
我们已经介绍了许多不同的策略来配置 Jenkins 代理。接下来,让我们进行配置测试。
测试代理
无论你选择了哪种代理配置,现在都可以检查是否一切正常工作。
让我们回到 Hello World 管道。通常,构建过程会比 Hello World 示例持续更长时间,因此我们可以通过在管道脚本中添加sleeping来进行模拟:
pipeline {
agent any
stages {
stage("Hello") {
steps {
sleep 300 // 5 minutes
echo 'Hello World'
}
}
}
}
点击 立即构建 后,进入 Jenkins 主页面,我们应该能看到构建在代理上执行。如果我们多次点击构建,应该会并行启动多个构建(如下图所示):

图 3.18 – Jenkins 在 Jenkins 代理上运行多个构建
提示
为了防止在主节点上执行作业,请记得在 管理节点 配置中将主节点的设置为 0。
既然我们已经看到代理在执行我们的构建,确认它们已经正确配置。接下来,在我们学习如何创建自己的 Jenkins 镜像之前,让我们先澄清一个细节,即 Docker 代理和 Docker 管道构建之间的区别。
比较 Docker 管道构建和 Docker 代理
Jenkins 管道构建在两种情况下会在 Docker 容器中执行——永久的 Docker 主机代理和动态配置的 Docker/Kubernetes 代理。然而,这两种解决方案之间有一个微妙的区别,值得澄清一下。
Docker 管道构建
如果你的代理是 Docker 主机,那么你可以从 Jenkins 用户的角度指定管道的运行时。换句话说,如果你的项目有一些特殊的构建运行时要求,你可以将它们 Docker 化,并按如下方式描述你的管道脚本:
agent {
docker {
image 'custom-docker-image'
}
}
这种方式意味着从用户的角度来看,你可以自由选择用于构建的 Docker 镜像。而且,你甚至可以决定直接在主机上执行构建,而不是在 Docker 容器中执行,这在管道中的步骤需要 Docker 主机而主机在容器内无法访问时尤为有用。我们将在本书的后续章节中看到这种需求的例子。
Docker 代理
如果你的代理本身是一个 Docker 容器,那么你需要从 Jenkins 管理员的角度指定使用的 Docker 镜像。在这种情况下,如果你的项目有一些特定的构建运行时要求,你需要做如下操作:
-
创建一个自定义的 Docker 镜像,使用
jenkins/agent作为基础镜像。 -
请让 Jenkins 管理员将其包含在 Docker/Kubernetes 插件配置中,并为该代理分配一个特殊标签。
-
在管道脚本中使用特定的代理标签。
这意味着,对于具有自定义要求的项目,设置稍微复杂一些。
还有一个开放问题:如果你的管道需要访问 Docker 主机,比如构建 Docker 镜像,该怎么办?是否有办法在 Docker 容器内使用 Docker?Docker-in-Docker 就可以解决这个问题。
Docker-in-Docker
有一个解决方案叫做 Docker-in-Docker (DIND),它允许你在 Docker 容器内使用 Docker。技术上,它要求为 Docker 容器授予特权权限,并且 Jenkins Docker 插件中有一个相关的配置字段。请注意,允许容器访问 Docker 主机可能会成为潜在的安全隐患,因此在应用此配置之前,务必采取额外的安全措施。
我们已经涵盖了关于 Jenkins 代理配置的所有内容。现在,让我们继续看一下,为什么以及如何创建我们自己的 Jenkins 镜像。
自定义 Jenkins 镜像
到目前为止,我们使用的是从互联网拉取的 Jenkins 镜像。我们使用了jenkins/jenkins作为主容器镜像,jenkins/agent(或jenkins/inbound-agent或jenkins/ssh-agent)作为代理容器镜像。然而,你可能想要构建自己的镜像,以满足特定构建环境的要求。在本节中,我们将介绍如何操作。
构建 Jenkins 代理
让我们从代理镜像开始,因为它是最常定制的。构建执行发生在代理上,因此需要根据我们想要构建的项目调整代理环境——例如,如果我们的项目是用 Python 编写的,它可能需要 Python 解释器。同样,任何库、工具、测试框架或项目所需的其他任何东西也适用。
构建和使用自定义镜像的步骤有四个:
-
创建一个 Docker 文件。
-
构建镜像。
-
将镜像推送到注册表。
-
更改主机上的代理配置。
作为一个示例,假设我们要创建一个用于 Python 项目的代理。为了简化操作,我们可以在jenkins/agent镜像的基础上进行构建。我们可以通过以下四个步骤来实现:
-
Dockerfile,内容如下:FROM jenkins/agent USER root RUN apt-get update && \ apt-get install -y python USER jenkins -
构建镜像:我们可以通过执行以下命令来构建镜像:
$ docker build -t leszko/jenkins-agent-python . -
(将
leszko替换为你的 Docker Hub 名称)并确保你已执行过docker login。我们将在第五章中更详细地讨论 Docker 注册表,自动化验收测试。 -
在 Jenkins 主机的配置中使用
leszko/jenkins-agent-python,而不是jenkins/agent(如动态分配 Docker 代理部分所描述)。提示
如果你已将镜像推送到 Docker Hub 并且该注册表是私有的,那么你还需要在 Jenkins 主机配置中配置相应的凭证。
如果我们需要 Jenkins 构建两种不同类型的项目——例如,一个基于 Python,另一个基于 Ruby,怎么办? 在这种情况下,我们可以准备一个通用的代理,支持这两种语言——Python 和 Ruby。然而,在 Docker 的情况下,建议创建第二个代理镜像(例如leszko/jenkins-agent-ruby)。然后,在 Jenkins 配置中,我们需要创建两个 Docker 模板,并相应地标记它们。
信息
我们使用 jenkins/agent 作为基础镜像,但我们也可以以完全相同的方式使用 jenkins/inbound-agent 和 jenkins/ssh-agent。
构建 Jenkins 主机
我们已经有了一个自定义代理镜像。 为什么我们还要构建自己的主镜像呢? 其中一个原因可能是我们完全不想使用代理,并且由于执行将由主机完成,其环境必须根据项目需求进行调整。然而,这种情况非常少见。更常见的是,我们会希望配置主机本身。
想象以下场景:您的组织在水平扩展 Jenkins,每个团队都有自己的实例。然而,某些配置是共享的——例如,一组基础插件、备份策略或公司标志。那么,为每个团队重复相同的配置就是一种时间浪费。因此,我们可以准备一个共享的主镜像,让各团队使用。
Jenkins 本身是通过 XML 文件进行配置的,并且提供了基于 Groovy 的 DSL 语言来操作这些文件。这就是为什么我们可以将 Groovy 脚本添加到 Dockerfile 中,以便操作 Jenkins 配置。此外,如果 Jenkins 配置需要更复杂的操作(如插件安装),也有专门的脚本可以帮助完成这些任务。
信息
Dockerfile 指令的所有可能性在 GitHub 页面 github.com/jenkinsci/docker 上有详细描述。
例如,我们可以创建一个已安装 docker-plugin 并将执行器数量设置为 5 的主镜像。为此,我们需要执行以下操作:
-
创建 Groovy 脚本以操作
config.xml,并将执行器数量设置为5。 -
创建 Dockerfile 以安装
docker-plugin并执行 Groovy 脚本。 -
构建镜像。
让我们按照之前提到的三步来构建 Jenkins 主机镜像:
-
executors.groovy文件,内容如下:import jenkins.model.* Jenkins.instance.setNumExecutors(5)提示
完整的 Jenkins API 可以在官方页面
javadoc.jenkins.io/上找到。 -
Dockerfile:在同一目录下创建一个 Docker 文件:
FROM jenkins/jenkins:lts-jdk11 COPY executors.groovy /usr/share/jenkins/ref/init.groovy.d/executors.groovy RUN jenkins-plugin-cli --plugins docker-plugin github-branch-source:1.8 -
构建镜像:我们可以最终构建镜像:
$ docker build -t jenkins-master .
镜像创建后,组织中的每个团队都可以使用它来启动自己的 Jenkins 实例。
提示
类似于 Jenkins 代理镜像,您可以构建名为 leszko/jenkins-master 的主镜像,并将其推送到您的 Docker Hub 账户中。
拥有我们自己的主镜像和代理镜像,可以为我们组织中的团队提供配置和构建环境。在下一节中,您将看到在 Jenkins 中值得配置的其他内容。
信息
您还可以使用基于 YAML 的配置和 Configuration as Code 插件来配置 Jenkins 主机以及 Jenkins 管道。详细信息请参见 www.jenkins.io/projects/jcasc/。
配置与管理
我们已经覆盖了 Jenkins 配置中最关键的部分 - 代理配置。由于 Jenkins 具有高度可配置性,您可以期望有更多的可能性来根据您的需求进行调整。好消息是,配置直观且可通过 Web 界面访问,因此不需要详细描述。所有内容都可以在 管理 Jenkins 子页面下更改。在本节中,我们将仅关注可能需要更改的几个方面 - 插件、安全性和备份。
插件
Jenkins 高度依赖插件,这意味着许多功能通过插件提供。它们几乎可以无限扩展 Jenkins,考虑到庞大的社区,这是 Jenkins 成功的原因之一。Jenkins 的开放性带来了风险,最好只从可靠来源下载插件或检查其源代码。
有大量插件可供选择。其中一些在初始配置期间已自动安装。其他插件(如 Docker 和 Kubernetes 插件)是在设置 Docker 代理时安装的。有用于云集成、源代码控制工具、代码覆盖等的插件。您也可以编写自己的插件,但最好先检查您需要的插件是否已经存在。
信息
有一个官方的 Jenkins 页面可以浏览插件,网址为 plugins.jenkins.io/。
安全性
您应该如何处理 Jenkins 的安全性取决于您在组织中选择的 Jenkins 架构。如果您为每个小团队都有一个 Jenkins 主节点,那么您可能根本不需要它(假设企业网络已设置防火墙)。然而,如果您为整个组织只有一个 Jenkins 主节点,那么最好确保已经充分保护了它。
Jenkins 带有自己的用户数据库;我们在初始配置过程中已经创建了一个用户。您可以通过打开 管理用户 设置页面来创建、删除和修改用户。内置数据库可以是小型组织的解决方案;但对于大量用户组,您可能希望使用 轻量级目录访问协议(LDAP)。您可以在 配置全局安全 页面上进行选择。在那里,您还可以分配角色、组和用户。默认情况下,设置了 已登录用户可以执行任何操作 的选项,但在大型组织中,您可能应考虑使用更详细的权限粒度。
备份
正如老话所说,有两种人:那些做备份的人,和那些将会做备份的人。信不信由你,备份是你可能想要配置的东西。应该备份哪些文件,来自哪些机器? 幸运的是,代理会自动将所有相关数据发送回主节点,所以我们不需要操心这些。如果你在容器中运行 Jenkins,那么容器本身也不重要,因为它不持久保存状态。我们唯一关心的地方是 Jenkins 主目录。
我们可以安装一个 Jenkins 插件(它将帮助我们设置定期备份),或者简单地设置一个定时任务,将目录归档到安全的位置。为了减小大小,我们可以排除那些不感兴趣的子文件夹(这取决于你的需求;然而,几乎可以肯定的是,你不需要复制以下内容:war、cache、tools 和 workspace)。
信息
如果您自动化了 Jenkins 主节点的设置(通过构建自定义 Docker 镜像或使用 Jenkins 配置即代码插件),那么您可能会考虑跳过 Jenkins 备份配置。
Jenkins 蓝海用户界面
Hudson 的第一个版本(前身为 Jenkins)发布于 2005 年。至今已经有超过 15 年的历史。然而,它的外观和感觉变化不大。我们已经使用它一段时间,难以否认它看起来有些过时。蓝海是重新定义 Jenkins 用户体验的插件。如果 Jenkins 在美学上令你不满意,或者它的工作流感觉不够直观,那么绝对值得尝试一下蓝海插件(如下图所示):

图 3.19 – Jenkins 蓝海用户界面
信息
您可以在蓝海页面阅读更多内容,链接地址为www.jenkins.io/doc/book/blueocean/。
总结
本章我们介绍了 Jenkins 环境及其配置。我们所获得的知识足以设置完整的基于 Docker 的 Jenkins 基础设施。本章的关键要点如下:
-
Jenkins 是一个通用的自动化工具,可以与任何语言或框架一起使用。
-
Jenkins 通过插件具有很强的扩展性,这些插件可以自己编写,也可以在互联网上找到。
-
Jenkins 是用 Java 编写的,因此可以安装在任何操作系统上。它也以 Docker 镜像的形式正式提供。
-
Jenkins 可以通过主从架构进行扩展。主节点实例可以根据组织的需求进行水平或垂直扩展。
-
Jenkins 代理可以通过 Docker 实现,这有助于自动配置和动态分配代理。
-
可以为 Jenkins 主节点和 Jenkins 代理创建自定义 Docker 镜像。
-
Jenkins 是高度可配置的,应该始终考虑的一些方面包括安全性和备份。
在下一章中,我们将重点介绍我们已经在 Hello World 示例中触及过的内容——管道。我们将描述构建一个完整的持续集成管道的理念和方法。
练习
在本章中,你已经学到了很多关于 Jenkins 配置的知识。为了巩固你的知识,我们推荐以下关于准备 Jenkins 镜像和测试 Jenkins 环境的练习:
-
创建 Jenkins 主机和代理 Docker 镜像,并使用它们运行一个能够构建 Ruby 项目的 Jenkins 基础设施:
-
创建 Jenkins 主机 Dockerfile,该文件会自动安装 Docker 插件。
-
构建主机镜像并运行 Jenkins 实例。
-
创建代理 Dockerfile(适用于动态代理配置),该文件安装 Ruby 解释器。
-
构建代理镜像。
-
更改 Jenkins 实例的配置,使用代理镜像。
-
-
创建一个管道,运行一个 Ruby 脚本,打印
Hello World from Ruby:-
创建一个新管道。
-
使用以下 shell 命令动态创建
hello.rb脚本:
-
sh "echo "puts 'Hello World from Ruby'" > hello.rb"
-
添加命令以使用 Ruby 解释器运行
hello.rb。 -
运行构建并观察控制台输出。
问题
为了验证你在本章中的知识,请回答以下问题:
-
Jenkins 是否以 Docker 镜像的形式提供?
-
Jenkins 主机和 Jenkins 代理(从属)有什么区别?
-
垂直扩展和水平扩展有什么区别?
-
启动 Jenkins 代理时,主机与代理之间的两种主要通信方式是什么?
-
设置永久代理和永久 Docker 代理有什么区别?
-
何时需要为 Jenkins 代理构建自定义 Docker 镜像?
-
何时需要为 Jenkins 主机构建自定义 Docker 镜像?
-
什么是 Jenkins Blue Ocean?
进一步阅读
要了解更多关于 Jenkins 的信息,请参考以下资源:
-
Jenkins 手册:
www.jenkins.io/doc/book/ -
Jenkins 基础知识,Mitesh Soni:
www.packtpub.com/virtualization-and-cloud/jenkins-essentials-second-edition -
Jenkins: The Definitive Guide,John Ferguson Smart:
www.oreilly.com/library/view/jenkins-the-definitive/9781449311155/
第二部分 – 架构设计与应用测试
在本节中,我们将介绍持续集成管道步骤和 Docker Hub 注册中心的概念。同时也会介绍 Kubernetes,并学习如何扩展 Docker 服务器池。
本节将涵盖以下章节:
-
第四章,持续集成管道
-
第五章,自动化验收测试
-
第六章,使用 Kubernetes 进行集群管理
第四章:持续集成流水线
我们已经知道如何配置 Jenkins。在本章中,我们将看到如何有效地使用它,重点介绍 Jenkins 的核心特性——流水线。通过从头构建一个完整的持续集成过程,我们将描述现代团队导向的代码开发的各个方面。
本章内容包括以下主题:
-
引入流水线
-
提交流水线
-
代码质量阶段
-
触发器和通知
-
团队开发策略
技术要求
完成本章,你需要以下软件:
-
Jenkins
-
Java JDK 8+
所有示例和练习的解决方案可以在github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter04找到。
本章的代码演示视频可以在bit.ly/3r9lbmG观看。
引入流水线
流水线是由一系列自动化操作组成的,通常表示软件交付和质量保证过程的一部分。它可以看作是一个脚本链,提供以下额外的好处:
-
操作分组:操作被分组为阶段(也称为关卡或质量关卡),这些阶段为流程引入了结构,并清晰地定义了规则——如果一个阶段失败,则后续阶段不会执行。
-
可视性:流程的所有方面都会被可视化,这有助于快速分析故障并促进团队协作。
-
反馈:团队成员会在问题发生时立即了解到,以便快速做出反应。
信息
流水线的概念类似于大多数持续集成工具。然而,命名可能会有所不同。在本书中,我们将坚持使用 Jenkins 的术语。
首先我们描述 Jenkins 流水线结构,然后说明它是如何在实际中运作的。
流水线结构
一个 Jenkins 流水线由两种元素组成——阶段和步骤。下图展示了它们的使用方式:

图 4.1 – Jenkins 流水线结构
以下是基本的流水线元素:
-
步骤:告诉 Jenkins 需要做什么的单一操作——例如,从仓库中检出代码并执行脚本
-
阶段:将步骤进行逻辑分隔,将概念上不同的步骤序列分组——例如,构建、测试和部署,用于可视化 Jenkins 流水线的进度
信息
从技术上讲,可以创建并行步骤;然而,最好将其视为仅用于优化目的的例外情况。
多阶段的 Hello World
作为示例,我们将扩展Hello World流水线,加入两个阶段:
pipeline {
agent any
stages {
stage('First Stage') {
steps {
echo 'Step 1\. Hello World'
}
}
stage('Second Stage') {
steps {
echo 'Step 2\. Second time Hello'
echo 'Step 3\. Third time Hello'
}
}
}
}
该管道在环境方面没有特殊要求,并且在两个阶段内执行了三个步骤。当我们点击立即构建时,我们应该看到一个可视化表示:

图 4.2 – 多阶段管道构建
管道执行成功,我们可以通过点击控制台查看步骤执行的详细信息。如果任何步骤失败,处理将停止,后续步骤将不再执行。实际上,管道的唯一目的是防止后续步骤执行,并可视化失败的发生点。
管道语法
我们已经讨论了管道的元素,并且已经使用了几个管道步骤——例如,echo。我们还能在管道定义中使用哪些其他操作?
信息
本书中使用的是推荐用于所有新项目的声明式语法。其他选项包括基于 Groovy 的 DSL 和(在 Jenkins 2 之前的版本)XML(通过 Web 界面创建)。
声明式语法的设计目的是使理解管道尽可能简单,即使是那些并非每天编写代码的人。这也是为什么语法仅限于最重要的关键字。
让我们做一个实验,但在描述所有细节之前,请阅读以下管道定义并尝试猜测它的作用:
pipeline {
agent any
triggers { cron('* * * * *') }
options { timeout(time: 5) }
parameters {
booleanParam(name: 'DEBUG_BUILD', defaultValue: true,
description: 'Is it the debug build?')
}
stages {
stage('Example') {
environment { NAME = 'Rafal' }
when { expression { return params.DEBUG_BUILD } }
steps {
echo "Hello from $NAME"
script {
def browsers = ['chrome', 'firefox']
for (int i = 0; i < browsers.size(); ++i) {
echo "Testing the ${browsers[i]} browser."
}
}
}
}
}
post { always { echo 'I will always say Hello again!' } }
}
希望管道没有吓到你。它确实很复杂。实际上,它复杂到包含了大多数可用的 Jenkins 指令。为了回答实验谜题,让我们逐条查看管道指令的执行过程:
-
使用任何可用的代理
-
每分钟自动执行
-
如果执行时间超过 5 分钟,则停止执行
-
在开始之前请求布尔类型输入参数
-
将
Rafal设置为NAME环境变量 -
仅在
true输入参数的情况下执行以下操作:-
打印
来自 Rafal 的问候 -
打印
测试 Chrome 浏览器 -
打印
测试 Firefox 浏览器
-
-
打印
我将永远再次说你好!,无论执行过程中是否有任何错误
现在,让我们描述最重要的 Jenkins 关键字。声明式管道始终在pipeline块内指定,并包含部分、指令和步骤。我们将逐一讲解它们。
信息
完整的管道语法描述可以在官方 Jenkins 页面上找到,地址为 jenkins.io/doc/book/pipeline/syntax/。
部分
部分定义了管道的结构,通常包含一个或多个指令或步骤。它们使用以下关键字定义:
-
阶段:这定义了一系列一个或多个阶段指令。
-
步骤:这定义了一系列一个或多个步骤指令。
-
Post:这定义了一系列在管道构建结束时运行的步骤指令;它们带有一个条件(例如,总是、成功或失败),通常用于在管道构建后发送通知(我们将在 触发器和通知 部分详细讲解)。
-
label用于匹配具有相同标签的代理,或者docker用于指定动态提供的容器,以为管道执行提供环境。
指令
指令表达了管道或其部分的配置:
-
cron用于设置基于时间的调度,或者pollSCM用于检查仓库中的更改(我们将在 触发器和通知 部分详细讲解此内容)。 -
timeout(管道运行的最大时间)或retry(在失败后管道应重新运行的次数)。 -
Environment:这定义了一组作为构建过程中环境变量使用的键值。
-
Parameters:这定义了一个用户输入参数的列表。
-
Stage:这允许对步骤进行逻辑分组。
-
When:这决定了是否应该执行该阶段,取决于给定的条件。
-
PATH。 -
Input:这允许我们提示输入参数。
-
Parallel:这允许我们指定并行运行的阶段。
-
Matrix:这允许我们指定一组参数组合,以便在并行中运行给定的阶段。
步骤
步骤是管道中最基本的部分。它们定义了要执行的操作,因此实际上告诉 Jenkins 做什么:
-
sh:这执行 Shell 命令;实际上,几乎可以使用sh定义任何操作。 -
custom:Jenkins 提供了许多可以用作步骤的操作(例如,echo);其中许多只是sh命令的包装器,旨在便捷操作。插件也可以定义自己的操作。 -
script:这执行一块基于 Groovy 的代码,可用于一些需要流程控制的复杂场景。信息
可用步骤的完整规范可以在
jenkins.io/doc/pipeline/steps/找到。
请注意,管道语法非常通用,从技术上讲,可以用于几乎任何自动化过程。这就是为什么管道应被视为一种结构化和可视化的方法。然而,最常见的使用场景是实现持续集成服务器,接下来我们将详细介绍这一部分。
提交管道
最基本的持续集成过程被称为commit(或在 Git 中称为push)到主代码库,并生成关于构建成功或失败的报告。由于它在每次代码更改后运行,构建应不超过 5 分钟,并且应消耗合理的资源。提交阶段始终是持续交付过程的起点,并且提供了开发过程中最重要的反馈循环——如果代码处于健康状态,能够不断获得信息。
提交阶段的工作原理如下:开发者将代码提交到仓库,持续集成服务器检测到变更并启动构建。最基本的提交管道包含三个阶段:
-
检出:此阶段从仓库下载源代码。
-
编译:此阶段编译源代码。
-
单元测试:此阶段运行一套单元测试。
让我们创建一个示例项目,看看如何实现提交管道。
信息
这是一个使用 Git、Java、Gradle 和 Spring Boot 等技术的项目管道示例。然而,相同的原则适用于任何其他技术。
检出
从仓库检出代码始终是任何管道中的第一操作。为了看到这一点,我们需要一个仓库。然后,我们才能创建管道。
创建一个 GitHub 仓库
在 GitHub 服务器上创建一个仓库只需几个步骤:
-
访问
github.com/。 -
如果您还没有账户,请创建一个账户。
-
点击新建,在仓库旁边。
-
给它起个名字——
calculator。 -
勾选用 README 初始化此仓库。
-
点击创建仓库。
现在,您应该能看到仓库的地址——例如,github.com/leszko/calculator.git。
创建检出阶段
我们可以创建一个名为calculator的新管道,并且它是一个检出:
pipeline {
agent any
stages {
stage("Checkout") {
steps {
git url: 'https://github.com/leszko/calculator.git', branch: 'main'
}
}
}
}
这个管道可以在任何代理上执行,其唯一的步骤只是从仓库下载代码。我们可以点击立即构建来查看它是否成功执行。
信息
Git 工具包需要安装在构建执行的节点上。
当我们完成检出后,我们就准备好进入第二阶段。
编译
为了编译一个项目,我们需要执行以下操作:
-
创建一个包含源代码的项目。
-
将其推送到仓库。
-
将
编译阶段添加到管道中。
让我们详细了解这些步骤。
创建一个 Java Spring Boot 项目
让我们使用由 Gradle 构建的 Spring Boot 框架创建一个非常简单的 Java 项目。
信息
Spring Boot 是一个简化企业应用构建的 Java 框架。Gradle 是一个基于 Apache Maven 概念的构建自动化系统。
创建 Spring Boot 项目的最简单方法是执行以下步骤:
-
选择 Gradle Project 而不是 Maven Project(如果你更喜欢 Maven 而不是 Gradle,可以选择 Maven)。
-
填写
com.leszko和calculator。 -
将 Web 添加到 Dependencies。
-
点击 Generate。
-
生成的骨架项目应该已下载(
calculator.zip文件)。
以下截图显示了 start.spring.io/ 页面:


图 4.3 – spring initializr
项目创建后,我们可以将其推送到 GitHub 仓库。
将代码推送到 GitHub
我们将使用 Git 工具执行 commit 和 push 操作。
信息
为了运行 git 命令,你需要安装 Git 工具包(可以从 git-scm.com/downloads 下载)。
首先让我们将仓库克隆到文件系统:
$ git clone https://github.com/leszko/calculator.git
将从 start.spring.io/ 下载的项目解压到 Git 创建的目录中。
提示
如果你喜欢,你可以将项目导入到 IntelliJ、Visual Studio Code、Eclipse 或你最喜欢的 IDE 工具中。
结果是,calculator 目录应该包含以下文件:
$ ls -a
. .. build.gradle .git .gitignore gradle gradlew gradlew.bat HELP.md README.md settings.gradle src
信息
为了在本地执行 Gradle 操作,你需要安装 Java JDK。
我们可以使用以下代码在本地编译项目:
$ ./gradlew compileJava
对于 Maven,你可以运行 ./mvnw compile。Gradle 和 Maven 都会编译位于 src 目录中的 Java 类。
现在,我们可以提交并推送到 GitHub 仓库:
$ git add .
$ git commit -m "Add Spring Boot skeleton"
$ git push -u origin main
代码已经在 GitHub 仓库中了。如果你想检查它,可以访问 GitHub 页面查看文件。
创建一个编译阶段
我们可以使用以下代码将 Compile 阶段添加到流水线中:
stage("Compile") {
steps {
sh "./gradlew compileJava"
}
}
请注意,我们在本地和 Jenkins 流水线中使用的命令完全相同,这是一个非常好的迹象,因为本地开发过程与持续集成环境一致。运行构建后,你应该看到两个绿色的框。你也可以在控制台日志中检查项目是否正确编译。
单元测试
现在是添加最后阶段的时候了,那就是单元测试;它检查我们的代码是否按预期执行。我们需要做以下操作:
-
添加计算器逻辑的源代码。
-
为代码编写单元测试。
-
添加一个 Jenkins 阶段来执行单元测试。
接下来我们将详细讲解这些步骤。
创建业务逻辑
计算器的第一个版本将能够执行两个数字的加法。让我们在 src/main/java/com/leszko/calculator/Calculator.java 文件中添加业务逻辑作为一个类:
package com.leszko.calculator;
import org.springframework.stereotype.Service;
@Service
public class Calculator {
public int sum(int a, int b) {
return a + b;
}
}
为了执行业务逻辑,我们还需要在一个单独的文件中添加 Web 服务控制器:src/main/java/com/leszko/calculator/CalculatorController.java:
package com.leszko.calculator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CalculatorController {
@Autowired
private Calculator calculator;
@RequestMapping("/sum")
String sum(@RequestParam("a") Integer a,
@RequestParam("b") Integer b) {
return String.valueOf(calculator.sum(a, b));
}
}
这个类将业务逻辑暴露为 Web 服务。我们可以运行应用程序,看看它是如何工作的:
$ ./gradlew bootRun
这应该启动我们的 Web 服务,我们可以通过在浏览器中打开 http://localhost:8080/sum?a=1&b=2 来检查它是否正常工作。这个链接应该将两个数字(1 和 2)相加并在浏览器中显示 3。
编写单元测试
我们已经有了可工作的应用程序。我们如何确保逻辑按预期工作?我们尝试过一次,但为了确保它始终如一地工作,我们需要单元测试。在我们的例子中,这将是微不足道的,甚至可能是不必要的;然而,在真实项目中,单元测试可以帮助你避免错误和系统故障。
让我们在 src/test/java/com/leszko/calculator/CalculatorTest.java 文件中创建一个单元测试:
package com.leszko.calculator;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
public class CalculatorTest {
private Calculator calculator = new Calculator();
@Test
public void testSum() {
assertEquals(5, calculator.sum(2, 3));
}
}
我们的测试使用了 JUnit 库,因此我们需要在 build.gradle 文件中将其作为依赖项添加:
dependencies {
...
testImplementation 'junit:junit:4.13'
}
我们可以使用 ./gradlew test 命令在本地运行测试。然后,让我们提交代码并推送到代码库:
$ git add .
$ git commit -m "Add sum logic, controller and unit test"
$ git push
创建单元测试阶段
现在,我们可以在管道中添加一个 单元测试 阶段:
stage("Unit test") {
steps {
sh "./gradlew test"
}
}
提示
在 Maven 的情况下,使用 ./mvnw test 命令代替。
当我们再次构建管道时,我们应该看到三个框,这意味着我们已经完成了持续集成管道:

图 4.4 – 持续集成管道构建
现在我们已经准备好了管道,让我们看看如何使用 Jenkinsfile 达到完全相同的结果。
Jenkinsfile
到目前为止,我们已经直接在 Jenkins 中创建了所有的管道代码。然而,这并不是唯一的选择。我们还可以将管道定义放在一个名为 Jenkinsfile 的文件中,并将其与源代码一起提交到代码库中。这种方法更一致,因为你的管道结构严格与项目本身相关。
例如,如果你不需要代码编译,因为你的编程语言是解释型语言(而不是编译型语言),那么你就不会有 编译 阶段。你使用的工具也会有所不同,取决于环境。我们使用了 Gradle/Maven,因为我们构建的是 Java 项目;然而,在 Python 项目中,你可以使用 PyBuilder。这引出了一个观点,即管道应该由编写代码的人来创建——即开发者。此外,管道定义应该与代码一起放在代码库中。
这种方法带来了即时的好处,如下所示:
-
在 Jenkins 失败的情况下,管道定义不会丢失(因为它存储在代码库中,而不是 Jenkins 中)。
-
管道更改的历史记录已被存储。
-
管道更改经过标准的代码开发流程(例如,代码审查)。
-
对管道更改的访问权限与对源代码的访问权限完全相同。
让我们通过创建一个 Jenkinsfile 文件,看看它在实践中的表现。
创建 Jenkins 文件
我们可以创建一个 Jenkinsfile 文件,并将其推送到我们的 GitHub 仓库。其内容几乎与我们写的提交管道相同。唯一的区别是,checkout 阶段变得多余,因为 Jenkins 必须首先检出代码(连同 Jenkinsfile),然后读取管道结构(来自 Jenkinsfile)。这就是为什么 Jenkins 在读取 Jenkinsfile 之前需要知道仓库地址的原因。
让我们在项目的 root 目录中创建一个名为 Jenkinsfile 的文件:
pipeline {
agent any
stages {
stage("Compile") {
steps {
sh "./gradlew compileJava"
}
}
stage("Unit test") {
steps {
sh "./gradlew test"
}
}
}
}
现在我们可以提交已添加的文件并将它们推送到 GitHub 仓库:
$ git add Jenkinsfile
$ git commit -m "Add Jenkinsfile"
$ git push
从 Jenkinsfile 运行管道
当 Jenkinsfile 存在于仓库中时,我们需要做的就是打开管道配置,并在 管道 部分执行以下操作:
-
将 定义 从 管道脚本 更改为 来自 SCM 的管道脚本。
-
在 SCM 中选择 Git。
-
将
github.com/leszko/calculator.git放入 仓库 URL。 -
使用
*/main作为 分支说明符。

图 4.5 – Jenkinsfile 管道配置
保存后,构建将始终从仓库中的当前版本的 Jenkinsfile 运行。
我们已经成功创建了第一个完整的提交管道。它可以作为最小可行产品来对待,实际上,在许多情况下,这足够作为持续集成过程。在接下来的章节中,我们将看到如何改进,以使提交管道更加完善。
代码质量阶段
我们可以通过增加额外的步骤来扩展持续集成的三个经典步骤。最流行的步骤是代码覆盖和静态分析。让我们来看看每个步骤。
代码覆盖
假设以下场景:你有一个配置良好的持续集成过程,但项目中的任何人都没有编写单元测试。它通过了所有构建,但这并不意味着代码按预期工作。那么我们该怎么办呢? 我们如何确保代码已经经过测试?
解决方案是添加一个代码覆盖工具,它会运行所有测试并验证代码中哪些部分已经执行。然后,它可以生成报告,显示未测试的部分。此外,当未测试的代码过多时,我们可以使构建失败。
有很多工具可以执行测试覆盖率分析;对于 Java,最流行的工具是 JaCoCo、OpenClover 和 Cobertura。
让我们使用 JaCoCo 并展示覆盖率检查如何工作。为此,我们需要执行以下步骤:
-
将 JaCoCo 添加到 Gradle 配置中。
-
将代码覆盖阶段添加到管道中。
-
可选地,在 Jenkins 中发布 JaCoCo 报告。
让我们详细了解这些步骤。
将 JaCoCo 添加到 Gradle 中
为了从 Gradle 运行 JaCoCo,我们需要通过插入以下行将 jacoco 插件添加到 build.gradle 文件中:
plugins {
...
id 'jacoco'
}
接下来,如果我们希望在代码覆盖率较低的情况下让 Gradle 构建失败,可以在build.gradle文件中添加以下配置:
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = 0.2
}
}
}
}
该配置将最小代码覆盖率设置为 20%。我们可以使用以下命令运行它:
$ ./gradlew test jacocoTestCoverageVerification
该命令检查代码覆盖率是否至少为 20%。你可以调整最小值,查看构建在哪个覆盖率水平下失败。我们还可以使用以下命令生成测试覆盖报告:
$ ./gradlew test jacocoTestReport
你可以在build/reports/jacoco/test/html/index.html文件中查看完整的覆盖报告:

图 4.6 – JaCoCo 代码覆盖报告
现在,让我们在管道中添加覆盖阶段。
添加代码覆盖阶段
向管道中添加代码覆盖阶段与之前的阶段一样简单:
stage("Code coverage") {
steps {
sh "./gradlew jacocoTestReport"
sh "./gradlew jacocoTestCoverageVerification"
}
}
添加此阶段后,如果有人提交了测试覆盖不足的代码,构建将会失败。
发布代码覆盖报告
当覆盖率较低且管道失败时,查看代码覆盖报告并找出哪些部分尚未被测试是很有用的。我们可以在本地运行 Gradle 并生成覆盖报告;但是,如果 Jenkins 为我们显示报告,则会更方便。
为了在 Jenkins 中发布代码覆盖报告,我们需要以下阶段定义:
stage("Code coverage") {
steps {
sh "./gradlew jacocoTestReport"
publishHTML (target: [
reportDir: 'build/reports/jacoco/test/html',
reportFiles: 'index.html',
reportName: "JaCoCo Report"
])
sh "./gradlew jacocoTestCoverageVerification"
}
}
此阶段将生成的 JaCoCo 报告复制到 Jenkins 输出中。当我们再次运行构建时,我们应该能在左侧菜单中的立即构建下方看到指向代码覆盖报告的链接。
信息
要执行publishHTML步骤,你需要在 Jenkins 中安装 HTML Publisher 插件。你可以在www.jenkins.io/doc/pipeline/steps/htmlpublisher/查看更多关于该插件的信息。还需要注意的是,如果报告已生成但未正确显示在 Jenkins 中,你可能需要配置 Jenkins 安全设置,具体操作可以参考这里:www.jenkins.io/doc/book/security/configuring-content-security-policy/。
我们已经创建了代码覆盖阶段,显示了未经过测试的代码,因此容易出现漏洞。让我们看看还可以做些什么来提高代码质量。
提示
如果你需要更严格的代码覆盖要求,可以了解突变测试的概念,并将 PIT 框架阶段添加到管道中。阅读更多内容请访问pitest.org/。
静态代码分析
你的代码覆盖率可能非常完美;然而,那代码本身的质量呢? 我们如何确保它是可维护的,并且写得风格良好?
静态代码分析是一个无需实际执行代码的自动检查过程。在大多数情况下,它意味着检查源代码上的多个规则。这些规则可以应用于广泛的方面;例如,所有公共类需要有 Javadoc 注释,一行的最大长度为 120 个字符,或者如果一个类定义了 equals() 方法,它也必须定义 hashCode() 方法。
执行 Java 代码静态分析的最流行工具是 Checkstyle、FindBugs 和 PMD。让我们看一个例子,并使用 Checkstyle 添加静态代码分析阶段。我们将分三步来完成:
-
添加 Checkstyle 配置
-
添加 Checkstyle 阶段
-
可选的,在 Jenkins 中发布 Checkstyle 报告
我们将逐一讲解它们。
添加 Checkstyle 配置
为了添加 Checkstyle 配置,我们需要定义检查代码的规则。我们可以通过指定 config/checkstyle/checkstyle.xml 文件来完成:
<?xml version="1.0"?>
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.2//EN"
"http://www.puppycrawl.com/dtds/configuration_1_2.dtd">
<module name="Checker">
<module name="TreeWalker">
<module name="ConstantName" />
</module>
</module>
配置中只包含一个规则——检查所有 Java 常量是否遵循命名约定,并且仅由大写字母组成。
信息
完整的 Checkstyle 描述可以在 checkstyle.sourceforge.io/config.html 查找。
我们还需要将 checkstyle 插件添加到 build.gradle 文件中:
plugins {
...
id 'checkstyle'
}
然后,我们可以使用以下命令运行 checkstyle:
$ ./gradlew checkstyleMain
对于我们的项目来说,这个命令应该能够成功完成,因为我们到目前为止没有使用任何常量。然而,您可以尝试添加一个命名错误的常量,并检查构建是否失败。例如,如果您将以下常量添加到 src/main/java/com/leszko/calculator/CalculatorApplication.java 文件中,checkstyle 将会失败:
@SpringBootApplication
public class CalculatorApplication {
private static final String constant = "constant";
public static void main(String[] args) {
SpringApplication.run(CalculatorApplication.class, args);
}
}
添加静态代码分析阶段
我们可以在流水线中添加一个 Static code analysis 阶段:
stage("Static code analysis") {
steps {
sh "./gradlew checkstyleMain"
}
}
现在,如果有人提交不遵循 Java 常量命名约定的代码,构建将会失败。
发布静态代码分析报告
与 JaCoCo 非常相似,我们可以将 Checkstyle 报告添加到 Jenkins 中:
publishHTML (target: [
reportDir: 'build/reports/checkstyle/',
reportFiles: 'main.html',
reportName: "Checkstyle Report"
])
这将生成一个指向 Checkstyle 报告的链接。
我们现在已添加静态代码分析阶段,它可以帮助查找错误并标准化团队或组织内部的代码风格。
让我们看看实现代码分析时的另一个选择。
SonarQube
SonarQube 是最广泛使用的源代码质量管理工具。它支持多种编程语言,并且可以作为我们之前提到的代码覆盖率和静态代码分析步骤的替代方案。实际上,它是一个独立的服务器,汇聚了不同的代码分析框架,如 Checkstyle、FindBugs 和 JaCoCo。它拥有自己的仪表盘,并且能够与 Jenkins 良好集成。
我们可以安装 SonarQube,在那里添加插件,并在流水线中添加 sonar 阶段,而不是将代码质量步骤添加到流水线中。此解决方案的优点是,SonarQube 提供了一个用户友好的 Web 界面,用于配置规则并显示代码漏洞。
信息
你可以在其官方网站查看有关 SonarQube 的更多信息:www.sonarqube.org/。
现在我们已经讲解了代码质量阶段,让我们关注触发器和通知。
触发器和通知
到目前为止,我们一直是通过点击 立即构建 按钮手动构建流水线。这完全没问题,但在实际操作中可能不太方便。所有团队成员都需要记住,在提交到仓库后,他们需要打开 Jenkins 并启动构建。流水线监控也是如此;到目前为止,我们一直是手动打开 Jenkins 并检查构建状态。在本节中,我们将看到如何改进这个流程,使得流水线能够自动启动,并在完成时通知团队成员其状态。
触发器
启动构建的自动动作被称为流水线触发器。在 Jenkins 中,有许多选项可以选择;然而,它们都归结为三种类型:
-
外部
-
拉取 源代码管理 (SCM)
-
定时构建
让我们逐一查看它们。
外部
外部触发器易于理解。它们意味着 Jenkins 在被 通知者 调用后启动构建,通知者可以是其他流水线构建、SCM 系统(例如 GitHub)或任何远程脚本。
以下图示展示了通信过程:

图 4.7 – 外部触发器
GitHub 在推送到仓库后触发 Jenkins 并启动构建。
为了以这种方式配置系统,我们需要以下设置步骤:
-
在 Jenkins 中安装 GitHub 插件。
-
为 Jenkins 生成一个密钥。
-
设置 GitHub webhook,并指定 Jenkins 地址和密钥。
对于最流行的 SCM 提供商,总是会提供专用的 Jenkins 插件。
还有一种更通用的方式,通过 REST 调用 <jenkins_url>/job/<job_name>/build?token=<token> 端点来触发 Jenkins。出于安全原因,需要在 Jenkins 中设置 token,然后在远程脚本中使用它。
信息
Jenkins 必须能够从 SCM 服务器访问。换句话说,如果我们使用公共 GitHub 仓库来触发 Jenkins,那么我们的 Jenkins 服务器也必须是公开的。这也适用于 REST 调用方案,在这种情况下,<jenkins_url> 地址必须能够从触发它的脚本访问。
拉取 SCM
拉取 SCM 触发器的直观性稍差。以下图示展示了通信过程:

图 4.8 – 拉取 SCM 触发器
Jenkins 定期调用 GitHub 检查是否有推送到代码库的内容。然后,它启动构建。乍一听这可能有些反直觉,但实际上有至少两种情况适合使用这种方法:
-
Jenkins 位于防火墙内的网络中(GitHub 无法访问该网络)。
-
提交频繁且构建时间较长,因此在每次提交后执行构建会导致系统负载过重。
pollSCM的配置也相对简单,因为 Jenkins 到 GitHub 的连接已经配置好了(Jenkins 从 GitHub 检出代码,所以它知道如何访问)。以我们的计算器项目为例,我们可以通过在管道中添加triggers声明(紧跟agent之后)来设置自动触发:
triggers {
pollSCM('* * * * *')
}
在第一次手动运行管道后,自动触发器已设置好。接下来,它每分钟检查一次 GitHub,如果有新提交,就启动构建。为了测试它是否按预期工作,你可以向 GitHub 仓库提交并推送任意内容,看看构建是否启动。
我们将神秘的* * * * *作为pollSCM的参数。它指定 Jenkins 应该多久检查一次新源代码的更改,采用的是cron风格的字符串格式。
信息
cron字符串格式的说明(以及 cron 工具)可见于en.wikipedia.org/wiki/Cron。
定时构建
定时触发意味着 Jenkins 定期运行构建,不管代码库是否有提交。
如下截图所示,不需要与任何系统进行通信:

图 4.9 – 定时构建触发器
实现cron关键字,而不是pollSCM。这种触发方式通常不会用于提交管道,但适用于夜间构建(例如,晚上执行复杂的集成测试)。
通知
Jenkins 提供了许多方式来宣布其构建状态。更重要的是,像 Jenkins 中的所有其他功能一样,可以通过插件添加新的通知类型。
让我们来了解最流行的几种类型,这样你可以选择最适合自己需求的那种。
邮件
通知用户 Jenkins 构建状态的最经典方式是发送电子邮件。这个方案的优点是每个人都有邮箱,每个人都知道如何使用它,而且每个人都习惯在邮箱中接收信息。缺点是,通常电子邮件太多,而来自 Jenkins 的邮件很快就会被过滤掉,根本没人阅读。
邮件通知的配置非常简单:
-
配置SMTP(简单邮件传输协议)服务器。
-
在 Jenkins 中设置其详细信息(在管理 Jenkins | 配置系统中)。
-
在管道中使用
mail to指令。
管道配置可以如下:
post {
always {
mail to: 'team@company.com',
subject: "Completed Pipeline: ${currentBuild.fullDisplayName}",
body: "Your build completed, please check: ${env.BUILD_URL}"
}
}
请注意,所有通知通常都在管道的post部分调用,该部分在所有步骤执行完毕后执行,无论构建是成功还是失败。我们使用了always关键字;不过,还有其他不同的选项:
-
always:无论完成状态如何,都会执行。 -
changed:仅当管道的状态发生变化时执行。 -
fixed:仅当管道的状态从失败变为成功时执行。 -
regression:仅当管道的状态从成功变为失败、不稳定或中止时执行。 -
aborted:仅当管道被手动中止时执行。 -
failure:仅当管道的状态为failed时执行。 -
success:仅当管道的状态为success时执行。 -
unstable:仅当管道的状态为unstable时执行(通常由测试失败或代码违规引起)。 -
unsuccessful:仅当管道的状态不是success时执行。
群组聊天
如果群组聊天(例如 Slack)是你团队中的首选沟通方式,那么考虑在其中添加自动构建通知是值得的。无论你使用哪种工具,配置它的过程都是一样的:
-
查找并安装适用于你的群组聊天工具的插件(例如,Slack Notification插件)。
-
配置插件(服务器 URL、频道、授权令牌等)。
-
将发送指令添加到管道中。
让我们看一个在构建失败后发送通知的 Slack 管道配置示例:
post {
failure {
slackSend channel: '#dragons-team',
color: 'danger',
message: "The pipeline ${currentBuild.fullDisplayName} failed."
}
}
团队空间
随着敏捷文化的兴起,出现了一个观念:最好所有事情都在团队空间内进行。与其写电子邮件,不如聚在一起;与其在线聊天,不如面对面交流;与其使用任务跟踪工具,不如有一块白板。这个理念同样也影响了持续交付和 Jenkins。目前,在团队空间安装大屏幕(也叫构建散热器)已经非常普遍。因此,当你来到办公室时,首先看到的是管道的当前状态。构建散热器被认为是最有效的通知策略之一。它们确保每个人都意识到构建失败,并且作为一种积极的副作用,它们促进了团队精神并支持面对面的沟通。
由于开发人员是富有创造力的存在,他们发明了很多其他的创意,起着与散热器相同的作用。有些团队挂上了大扬声器,当管道失败时会发出蜂鸣声。其他团队则有玩具,当构建完成时会闪烁。我的一个最喜欢的例子是 Pipeline State UFO,这是一个在 GitHub 上提供的开源项目。在它的页面上,你可以找到如何打印和配置一个悬挂在天花板上的 UFO,来显示管道状态的描述。你可以在github.com/Dynatrace/ufo找到更多信息。
信息
由于 Jenkins 可通过插件扩展,其社区编写了许多不同的方法来通知用户构建状态。其中包括 RSS 源、短信通知、移动应用程序和桌面通知器。
现在我们已经讨论了触发器和通知,让我们关注另一个重要方面——团队开发策略。
团队开发策略
我们已经涵盖了有关持续集成流水线应如何构建的所有内容。然而,究竟什么时候应该运行它? 当然,它是在提交到仓库后触发的,但提交到哪个分支后? 仅仅提交到主干,还是每个分支都提交? 或者,也许应该在提交之前运行,以确保仓库始终处于健康状态? 或者,如果没有分支,那个疯狂的想法怎么样?
对这些问题没有唯一正确的答案。实际上,你如何使用持续集成过程取决于你的团队开发工作流。因此,在我们进一步讨论之前,让我们先描述一下可能的工作流。
开发工作流
开发工作流是指团队如何将代码提交到仓库。它当然取决于多个因素,比如 SCM 工具、项目特点和团队规模。
结果是,每个团队以略有不同的方式开发代码。然而,我们可以将它们分为三种类型:基于主干的工作流、分支工作流和分叉工作流。
信息
所有工作流的详细描述和示例,可以在www.atlassian.com/git/tutorials/comparing-workflows找到。
基于主干的工作流
基于主干的工作流是最简单的策略。该策略在下图中展示:

图 4.10 – 基于主干的工作流
有一个中央仓库,所有项目的更改都通过该仓库进行提交,这个仓库被称为主干或主分支。团队的每个成员都克隆该中央仓库,以便拥有自己的本地副本。更改直接提交到中央仓库。
分支工作流
分支工作流,顾名思义,意味着代码保存在多个不同的分支中。这个概念在下图中展示:

图 4.11 – 分支工作流
当开发者开始开发新功能时,他们会从主干创建一个专用的分支,并在该分支上提交所有与功能相关的更改。这使得多个开发者可以在不破坏主代码库的情况下共同开发一个功能。这也是为什么在分支工作流中,保持主干健康没有问题。当功能完成时,开发者会从主干对功能分支进行 rebase,并创建一个包含所有功能相关代码更改的拉取请求。这样会启动代码审查讨论,并提供检查更改是否破坏主干的空间。当其他开发者和自动化系统检查接受代码后,它会被合并到主代码库中。构建会在主干上重新运行,但几乎不应该失败,因为它在分支上没有失败。
Forking 工作流
Forking 工作流在开源社区中非常受欢迎。它在以下图示中展示:

图 4.12 – Forking 工作流
每个开发者都有自己的服务器端代码仓库。这个仓库可能是官方仓库,也可能不是,但从技术上讲,每个仓库是完全相同的。
Forking 字面意思是从另一个仓库创建一个新仓库。开发者推送到他们自己的仓库,当他们想要集成代码时,他们会创建一个拉取请求到另一个仓库。
Forking 工作流的主要优点是,集成不一定需要通过中央仓库。它还帮助管理所有权,因为它允许接受来自其他人的拉取请求而不授予他们写权限。
对于需求导向的商业项目,一个团队通常会专注于一个产品,因此会有一个中央仓库, 所以这种模型最终归结为具有良好所有权分配的分支工作流;例如,只有项目负责人可以将拉取请求合并到中央仓库中。
采用持续集成
我们已经描述了不同的开发工作流,但 它们如何影响持续集成配置?
分支策略
每种开发工作流都暗示了不同的持续集成方法:
-
基于主干的工作流:这意味着需要不断与破坏的流水线作斗争。如果每个人都提交到主代码库,流水线往往会失败。在这种情况下,旧的持续集成规则是,如果构建失败,开发团队会停止手头的工作,立即修复问题。
-
分支工作流:它解决了破坏主干的问题,但引入了另一个问题:如果每个人都在自己的分支上开发,那么集成在哪里? 一个功能通常需要几周或几个月的时间来开发,在这段时间里,分支并没有被集成到主代码中。因此,这种情况不能真正称为持续集成——更不用说持续需要合并和解决冲突的问题。
-
分叉工作流:这意味着每个仓库所有者都管理持续集成过程,这通常不是问题。然而,它确实与分支工作流面临相同的问题。
没有银弹,不同的组织选择不同的策略。最接近完美的解决方案使用了分支工作流的技术和基于主干的工作流哲学。换句话说,我们可以创建非常小的分支,并频繁地将它们集成到主分支中。这似乎结合了两者的最佳特点。然而,它需要有小功能或者使用特性开关。由于特性开关的概念非常适合持续集成和持续交付,我们可以稍作停顿来探讨它。
特性开关
特性开关是一种替代维护多个源代码分支的技术,使得特性可以在完成并准备发布之前进行测试。它用于禁用用户的特性,但允许开发人员在测试时启用该特性。特性开关本质上是用于条件语句中的变量。
特性开关的最简单实现是标志和 if 语句。使用特性开关进行开发,与使用特性分支的开发方式相比,结果如下:
-
需要实现一个新特性。
-
创建一个新的标志或配置属性 –
feature_toggle(而不是feature分支)。 -
所有与特性相关的代码都添加在
if语句中(而不是提交到feature分支),例如以下内容:if (feature_toggle) { // do something } -
在功能开发过程中,会发生以下情况:
-
代码在主分支中编写,
feature_toggle = true(而不是在功能分支中编写代码)。 -
发布工作从主分支进行,
feature_toggle = false。
-
-
当功能开发完成时,所有的
if语句会被移除,feature_toggle会从配置中删除(而不是将feature合并到主分支并删除feature分支)。
特性开关的好处在于所有的开发工作都在主干中进行,这有助于实现真正的持续集成,并减轻了合并代码时的问题。
Jenkins 多分支
如果你决定使用任何形式的分支,无论是长时间的功能分支,还是推荐的短生命周期分支,了解代码在合并到主分支之前是健康的会很方便。这种方法的结果是始终保持主代码库绿色,幸运的是,可以通过 Jenkins 很容易做到这一点。
为了在我们的计算器项目中使用多分支,我们可以按照以下步骤进行:
-
打开 Jenkins 主页面。
-
点击 New Item。
-
输入
calculator-branches作为项目名称,选择 Multibranch Pipeline,然后点击 OK。 -
在 Branch Sources 部分,点击 Add source,然后选择 Git。
-
在 项目仓库 字段中输入仓库地址:

图 4.13 – 多分支管道配置
-
选中周期性运行(如果没有其他设置)并设置1 分钟作为间隔。
-
点击保存。
每分钟,这个配置会检查是否添加(或删除)了任何分支,并根据 Jenkinsfile 创建(或删除)相应的专用管道。
我们可以创建一个新分支并观察其工作原理。让我们创建一个名为feature的新分支,并将其推送到仓库中:
$ git checkout -b feature
$ git push origin feature
稍等片刻,您应该看到一个新的分支管道自动创建并运行:

图 4.14 – 多分支管道构建
现在,在将功能分支合并到主分支之前,我们可以检查它是否通过测试。这种方式永远不应该破坏主分支构建。
一种非常相似的方法是为每个拉取请求建立管道,而不是为每个分支建立管道,这样也能达到相同的结果——主代码库始终保持健康。
非技术要求
最后但同样重要的是,持续集成并不仅仅是技术问题。相反,技术排在第二位。James Shore 在他的《每天一美元的持续集成》文章中描述了如何在没有任何额外软件的情况下设置持续集成过程。他所使用的仅仅是一只橡胶鸡和一只铃铛。其想法是让团队在一个房间里工作,并设置一台带有空椅子的计算机。把橡胶鸡和铃铛放在那台计算机前面。现在,当你计划提交代码时,拿起橡胶鸡,提交代码,去空的计算机,拉取最新代码,在那里运行所有测试,如果一切通过,就把橡胶鸡放回去,并响铃通知大家已经有新内容被提交到仓库。
信息
《每天一美元的持续集成》 由James Shore 撰写,可以在 www.jamesshore.com/v2/blog/2006/continuous-integration-on-a-dollar-a-day 上找到。
这个想法有点过于简化,自动化工具是有用的;然而,主要的信息是:没有每个团队成员的参与,即使是最好的工具也没有帮助。在他的书中,Jez Humble 概述了持续集成的先决条件:
-
定期提交:引用 Mike Roberts 的话,持续集成比你想象的要更频繁;最少的频率是一天一次。
-
创建全面的单元测试:这不仅仅是关于高测试覆盖率;即使没有任何断言,仍然可以保持 100% 的覆盖率。
-
保持过程快捷:持续集成必须在短时间内完成,最好是在 5 分钟以内,10 分钟已经算长了。
-
监控构建:这可以是共享责任,或者您可以适应每周轮换的构建负责人角色。
总结
本章中,我们涵盖了持续集成管道的各个方面,持续集成是持续交付的第一步。以下是关键要点:
-
管道提供了一个通用机制,用于组织任何自动化过程;然而,最常见的用例是持续集成和持续交付。
-
Jenkins 接受定义管道的不同方式,但推荐使用声明性语法。
-
提交管道是最基本的持续集成过程,顾名思义,它应该在每次提交代码到仓库后运行。
-
管道定义应该作为
Jenkinsfile文件存储在仓库中。 -
提交管道可以通过增加代码质量阶段来扩展。
-
无论项目构建工具是什么,Jenkins 命令都应该与本地开发命令保持一致。
-
Jenkins 提供了广泛的触发器和通知功能。
-
开发工作流应该在团队或组织内部谨慎选择,因为它影响持续集成过程并定义了代码的开发方式。
在下一章中,我们将关注持续交付过程的下一阶段——自动化验收测试。这可以被视为最重要的步骤,并且在许多情况下,是最难实现的步骤。我们将探讨验收测试的概念,并通过 Docker 进行样例实现。
练习
你已经学到了很多关于如何配置持续集成过程的知识。因为 熟能生巧,我建议做以下练习:
-
创建一个 Python 程序,用于乘法运算,接收命令行参数传入的两个数字。添加单元测试并将项目发布到 GitHub 上:
-
创建两个文件:
calculator.py和test_calculator.py。 -
你可以使用
unittest库,参考docs.python.org/3/library/unittest.html。 -
运行程序和单元测试。
-
-
为 Python 计算器项目构建持续集成管道:
-
使用
Jenkinsfile来指定管道。 -
配置触发器,使得在每次提交到仓库时管道自动运行。
-
由于 Python 是一种解释性语言,管道不需要
Compile步骤。 -
运行管道并观察结果。
-
尝试提交破坏管道构建的代码,并观察在 Jenkins 中如何显示。
-
问题
为了验证本章学到的知识,请回答以下问题:
-
什么是管道?
-
管道中的 stage 和 step 有什么区别?
-
post部分在 Jenkins 管道中是什么? -
提交管道的三个最基本阶段是什么?
-
什么是
Jenkinsfile? -
代码覆盖阶段的目的是什么?
-
以下 Jenkins 触发器——外部触发和轮询 SCM——有什么区别?
-
Jenkins 最常见的通知方法有哪些?请至少列举三种。
-
三种最常见的开发工作流是什么?
-
什么是特性开关(Feature Toggle)?
进一步阅读
要了解更多关于持续集成的内容,请参考以下资源:
-
持续交付,杰兹·汉布尔和大卫·法利:
continuousdelivery.com/ -
持续集成:提高软件质量并降低风险,安德鲁·格洛弗、史蒂夫·马提亚斯和保罗·杜瓦尔:
www.oreilly.com/library/view/continuous-integration-improving/9780321336385/
第五章:自动化验收测试
我们已经配置了持续交付(CD)过程中的提交阶段,现在是时候处理验收测试阶段了,这通常是最具挑战性的部分。通过逐步扩展管道,我们将看到一个执行良好的验收测试自动化的不同方面。
本章涵盖以下主题:
-
引入验收测试
-
安装和使用 Docker Registry
-
Jenkins 管道中的验收测试
-
编写验收测试
技术要求
为完成本章内容,您需要以下软件:
-
Jenkins
-
Docker
-
Java 开发工具包(JDK)8+
所有示例和练习的解决方案可以在github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter05找到。
本章的 Code in Action 视频可以通过bit.ly/3Ki1alm观看。
引入验收测试
验收测试是用来确定业务需求或合同是否满足的步骤。它涉及从用户角度对完整系统进行黑盒测试,测试通过的结果意味着软件交付的接受。有时也称为用户验收测试(UAT)或终端用户测试,这是开发过程中的一个阶段,软件面对的是真实世界的受众。
许多项目依赖于质量保证人员(QA)或用户执行的手动步骤来验证功能和非功能要求(FRs和NFRs),但从更合理的角度来看,还是将它们作为程序化的可重复操作来执行。
然而,自动化验收测试可能会因为其特性而变得困难,正如这里所描述的那样:
-
面向用户:这些测试需要与用户一起编写,这需要在技术和非技术两个世界之间达成理解。
-
依赖集成:被测试的应用程序应与其依赖项一起运行,以确保整个系统正常工作。
-
暂存环境:暂存(测试)环境需要与生产环境完全相同,以确保相同的功能和非功能行为。
-
应用程序身份:应用程序应该只构建一次,并将相同的二进制文件传输到生产环境。这消除了不同构建环境的风险。
-
相关性与后果:如果验收测试通过,那么从用户的角度来看,应用程序应该已准备好发布。
我们将在本章的不同部分解决这些问题。通过只构建一次 Docker 镜像并使用 Docker 注册表进行存储和版本控制,可以实现应用程序身份。如何以用户面向的方式创建测试将在《编写验收测试》一节中解释,而环境身份则由 Docker 工具本身解决,并且可以通过下一章中描述的其他工具进一步改进。
信息
验收测试可以有多种含义;在本书中,我们将验收测试视为从用户角度出发的完整集成测试套件,不包括性能、负载和恢复等非功能需求(NFRs)。
既然我们已经理解了验收测试的目标和意义,接下来让我们描述我们需要的第一个方面——Docker 注册表。
安装和使用 Docker 注册表
Docker 注册表是一个存储 Docker 镜像的地方。准确来说,它是一个无状态的服务器应用程序,允许镜像被发布(推送)并随后被检索(拉取)。在第二章,《介绍 Docker》中,我们已经看到过官方 Docker 镜像的例子,例如 hello-world。我们从 Docker Hub 拉取了这些镜像,Docker Hub 是一个官方的基于云的 Docker 注册表。拥有一个独立的服务器来存储、加载和搜索软件包是一个更为通用的概念,叫做软件仓库,或者更广义地称为文物库。让我们更深入地了解这个概念。
文物库
虽然源代码管理存储源代码,但文物库专门用于存储软件二进制文物,如编译后的库或组件,稍后用于构建完整的应用程序。为什么我们需要使用单独的服务器和工具来存储二进制文件? 下面是原因:
-
文件大小:文物文件可能很大,因此系统需要对其下载和上传进行优化。
-
版本:每个上传的文物需要有一个版本,以便轻松浏览和使用。然而,并非所有版本都必须永久保存;例如,如果发现了一个 bug,我们可能对相关文物不感兴趣并将其删除。
-
修订映射:每个文物应指向源代码管理的一个修订版本,并且更重要的是,二进制文件的创建过程应当是可重复的。
-
包:文物以编译和压缩的形式存储,这样就不需要重复这些耗时的步骤。
-
访问控制:用户可以根据访问源代码和文物二进制文件的权限设置不同的限制。
-
客户端:文物库的用户可以是团队或组织外部的开发人员,他们希望通过公共应用程序编程接口(API)使用该库。
-
使用场景:文物二进制文件用于确保每个环境中部署的构建版本完全相同,以便在失败时轻松回滚。
信息
最流行的制品库是JFrog Artifactory和Sonatype Nexus。
制品库在 CD 过程中的作用特殊,因为它保证在所有管道步骤中使用相同的二进制文件。
让我们看一下下面的图表来理解它是如何工作的:

图 5.1 – CD 过程中的制品库
开发人员将变更推送到源代码仓库,触发管道构建。作为提交阶段的最后一步,创建并存储一个二进制文件到制品库中。之后,在交付过程的所有其他阶段,都会使用相同的二进制文件(被拉取并使用)。
信息
二进制文件通常被称为发布候选版,将二进制文件移动到下一阶段的过程称为晋升。
根据不同的编程语言和技术,二进制格式可能有所不同。例如,在 Java 的情况下,通常会存储Java ARchive(JAR)文件,而在 Ruby 的情况下,则是 gem 文件。我们使用 Docker,因此我们会将 Docker 镜像作为制品存储,存储 Docker 镜像的工具被称为Docker Registry。
信息
一些团队同时维护两个制品库,一个用于存储 JAR 文件的制品库,另一个用于存储 Docker 镜像的 Docker Registry。虽然在 Docker 引入的初期阶段,这种做法可能有用,但没有必要一直维护两个库。
安装 Docker Registry
首先,我们需要安装一个 Docker Registry。有很多可用的选项,但它们都属于两个类别:基于云的 Docker Registry 和自托管的 Docker Registry。我们来深入了解一下它们。
基于云的 Docker Registry
使用基于云的服务的好处在于你不需要在自己本地安装或维护任何东西。有很多云服务可供选择,但 Docker Hub 无疑是最受欢迎的。这就是为什么我们在本书中会一直使用它的原因。
Docker Hub
Docker Hub 提供 Docker Registry 服务和其他相关功能,例如构建镜像、测试镜像,以及直接从代码仓库拉取代码。Docker Hub 是基于云的,因此实际上不需要任何安装过程。你需要做的就是创建一个 Docker Hub 账户,步骤如下:
-
在浏览器中打开
hub.docker.com/。 -
在注册中,填写密码、电子邮件地址和 Docker 标识符(ID)。
-
在收到电子邮件并点击激活链接后,会创建一个账户。
Docker Hub 无疑是最简单的选择,适合初学者使用,并允许存储私有和公共镜像。
Docker Hub 替代品
还有更多值得一提的云服务。首先,以下三个主要云平台每个平台都提供自己的 Docker Registry:
-
亚马逊弹性容器注册表(ECR)
-
谷歌制品库
-
Azure 容器注册表
其他广泛使用的解决方案包括:
-
Quay 容器注册表
-
JFrog Artifactory
-
GitLab 容器注册表
所有上述提到的注册表都实现了相同的 Docker 注册表协议,因此好消息是无论选择哪个,使用的命令完全相同。
自托管 Docker 注册表
云解决方案可能并不总是可接受的。它们对企业并非免费的,更重要的是,许多公司有政策要求不将软件存储在自己的网络之外。在这种情况下,唯一的选择是安装自托管 Docker 注册表。
Docker 注册表的安装过程快速且简单,但要确保其安全性并使其对公众可用,需要设置访问限制和域证书。这就是为什么我们将此部分分为三个部分,如下所示:
-
安装 Docker 注册表应用
-
添加域证书
-
添加访问限制
让我们看看每个部分。
安装 Docker 注册表应用
Docker 注册表作为 Docker 镜像提供。要启动它,我们可以运行以下命令:
$ docker run -d -p 5000:5000 --restart=always --name registry registry:2
提示
默认情况下,注册表数据存储为 Docker 卷,位于默认主机文件系统的目录中。要更改它,您可以添加-v <host_directory>:/var/lib/registry。另一个选择是使用卷容器。
此命令启动注册表并通过端口5000使其可访问。registry容器从注册表镜像(版本 2)启动。--restart=always选项使容器在停止时自动重新启动。
提示
考虑设置负载均衡器,并在用户数量较多时启动几个 Docker 注册表容器。请注意,在这种情况下,它们需要共享存储或有一个同步机制。
添加域证书
如果注册表运行在本地主机上,那么一切正常,不需要其他安装步骤。然而,在大多数情况下,我们希望为注册表配置专用服务器,以便图像能够广泛可用。在这种情况下,Docker 需要使用--insecure-registry标志来保护注册表。
信息
您可以阅读有关创建和使用自签名证书的内容:docs.docker.com/registry/insecure/#use-self-signed-certificates。
一旦证书被 CA 签署或自签署,我们可以将domain.crt和domain.key移动到certs目录,并启动注册表,注册表将监听默认的超文本传输安全协议(HTTPS)端口,如下所示:
$ docker run -d -p 443:443 --restart=always --name registry -v `pwd`/certs:/certs -e REGISTRY_HTTP_ADDR=0.0.0.0:443 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2
不推荐使用--insecure-registry标志,因为它没有进行适当的 CA 验证。
信息
详细了解如何在官方 Docker 文档中设置 Docker 注册表并使其安全:docs.docker.com/registry/deploying/。
添加访问限制
除非我们在高度安全的私有网络内使用注册表,否则应该配置身份验证。
最简单的方法是使用registry镜像中的htpasswd工具创建一个带有密码的用户,如下所示:
$ mkdir auth
$ docker run --entrypoint htpasswd httpd:2 -Bbn <username> <password> > auth/htpasswd
该命令运行htpasswd工具来创建一个auth/htpasswd文件(其中包含一个用户)。然后,我们可以使用该文件中的用户来运行注册表,以便授权访问,如下所示:
$ docker run -d -p 443:443 --restart=always --name registry -v `pwd`/auth:/auth -e "REGISTRY_AUTH=htpasswd" -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd -v `pwd`/certs:/certs -e REGISTRY_HTTP_ADDR=0.0.0.0:443 -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2
该命令除了设置证书之外,还会创建一个访问限制,限制仅有auth/passwords文件中指定的用户可以访问。
结果是,在使用注册表之前,客户端需要指定用户名和密码。
重要提示
如果使用了--insecure-registry标志,访问限制将不起作用。
使用 Docker 注册表
当我们的注册表配置好之后,我们可以分三步展示如何使用它,如下所示:
-
构建镜像
-
推送镜像到注册表
-
从注册表拉取镜像
构建镜像
让我们使用第二章中的示例,介绍 Docker,并构建一个安装了 Ubuntu 和 Python 解释器的镜像。在一个新的目录中,我们需要创建一个 Dockerfile,如下所示:
FROM ubuntu:20.04
RUN apt-get update && \
apt-get install -y python
现在,我们可以通过以下命令构建镜像:
$ docker build -t ubuntu_with_python .
在镜像构建完成后,我们可以将其推送到 Docker 注册表。
推送镜像到注册表
为了推送创建的镜像,我们需要根据命名约定对其进行标签,如下所示:
<registry_address>/<image_name>:<tag>
registry_address值可以是以下之一:
-
Docker Hub 中的用户名
-
域名或
localhost:5000信息
在大多数情况下,
<tag>是镜像/应用版本的形式。
让我们给镜像添加标签,以便使用 Docker Hub,如下所示:
$ docker tag ubuntu_with_python leszko/ubuntu_with_python:1
请记得使用您的 Docker Hub 用户名,而不是leszko。
提示
我们也可以在build命令中对镜像进行标签,例如:docker build -t leszko/ubuntu_with_python:1。
如果仓库已配置访问限制,我们需要先进行授权,如下所示:
$ docker login --username <username> --password <password>
信息
如果您使用的是除 Docker Hub 以外的 Docker 注册表,则还需要添加login命令,例如docker login quay.io。
现在,我们可以使用push命令将镜像存储到注册表中,如下所示:
$ docker push leszko/ubuntu_with_python:1
请注意,不需要指定注册表地址,因为 Docker 使用命名约定来解析它。镜像已存储,我们可以通过 Docker Hub 网页界面查看它,地址为hub.docker.com。
从注册表拉取镜像
为了演示注册表的工作原理,我们可以在本地删除镜像并从注册表中拉取它,如下所示:
$ docker rmi ubuntu_with_python leszko/ubuntu_with_python:1
我们可以通过执行docker images命令看到镜像已经被移除。接下来,通过执行以下代码从注册表中取回镜像:
$ docker pull leszko/ubuntu_with_python:1
提示
如果您使用的是免费的 Docker Hub 账户,在拉取ubuntu_with_python仓库之前,可能需要将其更改为public。
我们可以通过docker images命令确认镜像是否已生成。
当我们配置好注册表并理解它的工作原理后,我们可以看到如何在 CD 流水线中使用它,并构建验收测试阶段。
Jenkins 流水线中的验收测试
我们已经理解了验收测试的概念,并且知道如何配置 Docker 注册表,所以我们已经准备好在 Jenkins 流水线中实现它。
让我们看看下面的图示,它展示了我们将要使用的过程:

图 5.2 – Jenkins 流水线中的验收测试
流程如下:
-
开发者将代码更改推送到 GitHub。
-
Jenkins 检测到更改,触发构建,并检出当前代码。
-
Jenkins 执行提交阶段并构建 Docker 镜像。
-
Jenkins 将镜像推送到Docker 注册表。
-
Jenkins 在预生产环境中运行 Docker 容器。
-
预生产环境中的 Docker 主机需要从 Docker 注册表拉取镜像。
-
Jenkins 在预生产环境中运行的应用程序上执行验收测试套件。
信息
为了简化起见,我们将本地运行 Docker 容器(而不是在单独的预生产服务器上运行)。若要远程运行,需要使用
-H选项或配置DOCKER_HOST环境变量。
让我们继续在第四章,持续集成流水线中开始的流水线,并添加以下三个阶段:
-
Docker build -
Docker push -
Acceptance test
请记住,您需要在 Jenkins 执行器(代理或主机,若是无代理配置)上安装 Docker 工具,以便它可以构建 Docker 镜像。
提示
如果您使用动态配置的 Docker 代理,请确保使用leszko/jenkins-docker-slave镜像。记得在 Docker 代理配置中也要标记privileged选项。
Docker 构建阶段
我们希望将计算器项目作为 Docker 容器运行,因此需要创建一个 Dockerfile 并在 Jenkinsfile 中添加Docker build阶段。
添加 Dockerfile
让我们在计算器项目的根目录中创建一个 Dockerfile,如下所示:
FROM openjdk:11-jre
COPY build/libs/calculator-0.0.1-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
信息
Gradle 的默认构建目录是build/libs/,calculator-0.0.1-SNAPSHOT.jar是将应用程序打包成的完整 JAR 文件。请注意,Gradle 使用0.0.1-SNAPSHOT的 Maven 风格版本自动为应用程序进行了版本控制。
Dockerfile 使用了一个包含openjdk:11-jre的基础镜像。它还复制了应用程序 JAR(由 Gradle 创建)并运行它。现在让我们通过执行以下代码来检查应用程序是否构建并运行:
$ ./gradlew build
$ docker build -t calculator .
$ docker run -p 8080:8080 --name calculator calculator
使用前面的命令,我们构建了应用程序,构建了 Docker 镜像,并运行了 Docker 容器。过一会儿,我们应该能够在浏览器中打开 http://localhost:8080/sum?a=1&b=2,并看到结果为 3。
我们可以停止容器并将 Dockerfile 推送到 GitHub 仓库,如下所示:
$ git add Dockerfile
$ git commit -m "Add Dockerfile"
$ git push
将 Docker build 添加到管道中
我们需要执行的最后一步是将 Docker build 阶段添加到 Jenkinsfile 中。通常,JAR 打包也会作为一个单独的 Package 阶段声明,如下代码片段所示:
stage("Package") {
steps {
sh "./gradlew build"
}
}
stage("Docker build") {
steps {
sh "docker build -t leszko/calculator ."
}
}
信息
我们没有明确地为镜像指定版本,但每个镜像都有一个唯一的哈希 ID。我们将在后续章节中讨论显式版本控制。
请注意,我们在镜像标签中使用了 Docker Registry 名称。无需将镜像同时标记为 calculator 和 leszko/calculator。
当我们提交并推送 Jenkinsfile 后,管道构建应该会自动开始,我们应该看到所有的框都变成绿色。这意味着 Docker 镜像已成功构建。
提示
如果在 Docker 构建阶段看到失败,最有可能是你的 Jenkins 执行器无法访问 Docker 守护进程。如果你使用 Jenkins 主机作为执行器,请确保 jenkins 用户已添加到 docker 用户组中。如果你使用 Jenkins 代理,请确保它们能够访问 Docker 守护进程。
Docker push 阶段
当镜像准备好后,我们可以将其存储在注册表中。Docker push 阶段非常简单,只需要在 Jenkinsfile 中添加以下代码:
stage("Docker push") {
steps {
sh "docker push leszko/calculator"
}
}
信息
如果 Docker Registry 限制了访问权限,首先,我们需要使用 docker login 命令登录。不言而喻,凭据必须妥善保管——例如,使用专门的凭据存储,如官方 Docker 页面中所描述的 docs.docker.com/engine/reference/commandline/login/#credentials-store。
一如既往,向 GitHub 仓库推送更改会触发 Jenkins 开始构建,过一会儿,我们应该能看到镜像自动存储到注册表中。
验收测试阶段
为了执行验收测试,我们首先需要将应用程序部署到预发布环境,然后在该环境中运行验收测试套件。
将预发布部署添加到管道中
让我们添加一个阶段来运行 calculator 容器,如下所示:
stage("Deploy to staging") {
steps {
sh "docker run -d --rm -p 8765:8080 --name calculator leszko/calculator"
}
}
运行此阶段后,calculator 容器作为守护进程运行,公开其端口为 8765,并在停止时自动移除。
最后,我们准备将验收测试添加到 Jenkins 管道中。
将验收测试添加到管道中
验收测试通常需要运行一个专门的黑盒测试套件,用以检查系统的行为。我们将在编写验收测试章节中详细介绍这一点。目前,为了简化处理,我们只需通过curl工具调用 web 服务端点,并使用test命令检查结果来进行验收测试。
在项目的根目录下,我们创建一个acceptance_test.sh文件,如下所示:
#!/bin/bash
test $(curl localhost:8765/sum?a=1\&b=2) -eq 3
我们使用a=1和b=2参数调用sum端点,期望收到3的响应。
然后,可以添加一个验收测试阶段,如下所示:
stage("Acceptance test") {
steps {
sleep 60
sh "chmod +x acceptance_test.sh && ./acceptance_test.sh"
}
}
由于docker run -d命令是异步的,我们需要使用sleep操作来等待,以确保服务已经启动。
信息
没有一种好的方法可以检查服务是否已经在运行。睡眠的替代方案可以是一个脚本,每秒检查一次服务是否已启动。
此时,我们的流水线已经执行了自动化验收测试。我们永远不应该忘记的最后一件事是,添加一个清理阶段。
添加清理阶段环境
作为验收测试的最后阶段,我们可以添加暂存环境的清理操作。执行此操作的最佳位置是在post部分,以确保即使在失败的情况下也会执行。以下是我们需要执行的代码:
post {
always {
sh "docker stop calculator"
}
}
该语句确保calculator容器在 Docker 主机上不再运行。
编写验收测试
到目前为止,我们使用curl命令执行了一系列验收测试。显然,这是一种相当简化的做法。从技术上讲,如果我们写了curl调用。然而,这种解决方案将非常难以阅读、理解和维护。更重要的是,脚本对非技术性、与业务相关的用户来说完全无法理解。我们如何解决这个问题,并创建结构良好的、易于用户阅读且满足其根本目标的测试:自动检查系统是否按预期工作? 我将在本节中回答这个问题。
编写面向用户的测试
验收测试是面向用户编写的,应该易于用户理解。这就是编写验收测试的方法选择取决于客户是谁的原因。
举个例子,假设你是一个纯技术人员。如果你编写了一个优化数据库存储的 web 服务,而该系统只被其他系统使用,并且其他开发者只能读取系统数据,你的测试可以像单元测试一样进行表达。一般来说,如果测试既能被开发人员也能被用户理解,那么这个测试就是好的。
在现实生活中,大多数软件是为了交付特定的商业价值而编写的,而这种商业价值是由非开发人员定义的。因此,我们需要一种共同的语言来进行协作。一方面是业务方,了解需要什么,但不清楚如何实现;另一方面是开发团队,知道如何做,但不知道做什么。幸运的是,有许多框架帮助连接这两个世界,例如Cucumber、FitNesse、JBehave 和 Capybara。它们彼此有所不同,每一个都可能是一本独立的书的主题;然而,编写验收测试的基本理念是相同的,如下图所示:

图 5.3 – 面向用户的验收测试
验收标准由用户(或作为其代表的产品负责人)在开发人员的帮助下编写。它们通常以以下场景的形式编写:
Given I have two numbers: 1 and 2
When the calculator sums them
Then I receive 3 as a result
开发人员编写测试实现,称为固定器或步骤定义,将人性化的领域特定语言(DSL)规范与编程语言进行集成。结果是我们拥有了一个可以轻松集成到 CD 管道中的自动化测试。
不必多说,编写验收测试是一个持续的敏捷过程,而非瀑布式过程。它需要不断的协作,在此过程中,测试规范由开发人员和业务人员共同改进和维护。
信息
在具有用户界面(UI)的应用程序中,直接通过界面进行验收测试(例如,通过录制 Selenium 脚本)可能会很有吸引力。然而,如果这种方法没有正确实施,可能会导致测试变得缓慢且与界面层紧密耦合。
让我们看看编写验收测试在实际中的样子,以及如何将其绑定到 CD 管道。
使用验收测试框架
让我们使用 Cucumber 框架,并为计算器项目创建一个验收测试。正如前面所述,我们将分三阶段进行,具体如下:
-
创建验收标准
-
创建步骤定义
-
运行自动化验收测试
创建验收标准
让我们将业务规范放在 src/test/resources/feature/calculator.feature 中,具体如下:
Feature: Calculator
Scenario: Sum two numbers
Given I have two numbers: 1 and 2
When the calculator sums them
Then I receive 3 as a result
这个文件应该由用户在开发人员的帮助下创建。请注意,它是以非技术人员能够理解的方式编写的。
创建步骤定义
下一步是创建 Java 绑定,以便特性规范可以执行。为此,我们创建一个新文件 src/test/java/acceptance/StepDefinitions.java,具体如下:
package acceptance;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import org.springframework.web.client.RestTemplate;
import static org.junit.Assert.assertEquals;
/** Steps definitions for calculator.feature */
public class StepDefinitions {
private String server = System.getProperty("calculator.url");
private RestTemplate restTemplate = new RestTemplate();
private String a;
private String b;
private String result;
@Given("^I have two numbers: (.*) and (.*)$")
public void i_have_two_numbers(String a, String b) throws Throwable {
this.a = a;
this.b = b;
}
@When("^the calculator sums them$")
public void the_calculator_sums_them() throws Throwable {
String url = String.format("%s/sum?a=%s&b=%s", server, a, b);
result = restTemplate.getForObject(url, String.class);
}
@Then("^I receive (.*) as a result$")
public void i_receive_as_a_result(String expectedResult) throws Throwable {
assertEquals(expectedResult, result);
}
}
特性规范文件中的每一行(Given、When 和 Then)由 (.*) 匹配,并作为参数传递。请注意,服务器地址作为 calculator.url Java 属性传递。该方法执行以下操作:
-
i_have_two_numbers:将参数保存为字段 -
the_calculator_sums_them:调用远程计算器服务并将结果存储在一个字段中 -
i_receive_as_a_result:断言结果符合预期
运行自动化验收测试
要运行自动化测试,我们需要做一些配置,如下所示:
-
添加 Java Cucumber 库。在
build.gradle文件中,向dependencies部分添加以下代码:testImplementation("io.cucumber:cucumber-java:7.2.0") testImplementation("io.cucumber:cucumber-junit:7.2.0") -
添加 Gradle 目标。在同一个文件中,向
dependencies部分添加以下代码:tasks.register('acceptanceTest', Test) { include '**/acceptance/**' systemProperties System.getProperties() } test { useJUnitPlatform() exclude '**/acceptance/**' }
这将测试分为单元测试(通过 ./gradlew test 运行)和验收测试(通过 ./gradlew acceptanceTest 运行)。
-
添加 JUnit 测试运行器,添加一个新文件,
src/test/java/acceptance/AcceptanceTest.java,内容如下:package acceptance; import io.cucumber.junit.CucumberOptions; import io.cucumber.junit.Cucumber; import org.junit.runner.RunWith; /** Acceptance Test */ @RunWith(Cucumber.class) @CucumberOptions(features = "classpath:feature") public class AcceptanceTest { }
这是验收测试套件的入口点。
配置完成后,如果服务器在本地主机上运行,我们可以通过执行以下代码进行测试:
$ ./gradlew acceptanceTest \
-Dcalculator.url=http://localhost:8765
显然,我们可以添加此命令来代替 acceptance_test.sh。这样,Cucumber 验收测试将在 Jenkins 流水线中运行。
验收测试驱动开发
验收测试,和 CD 过程中的大多数方面一样,更多的是关于人,而不是技术。当然,测试的质量依赖于用户和开发人员的参与,但也有一个可能不太直观的因素——测试创建的时机。
最后一个问题是:在软件开发生命周期的哪个阶段应该准备验收测试? 或者换句话说:我们应该在编写代码之前还是之后创建验收测试?
从技术上讲,结果是相同的;代码已经通过单元测试和验收测试的覆盖。然而,写测试先行的想法是令人诱惑的。测试驱动开发(TDD)的理念可以很好地应用于验收测试。如果单元测试在代码之前编写,那么最终的代码会更加简洁和结构化。同理,如果在系统功能之前编写验收测试,那么最终的功能会更好地对应客户的需求。
这个过程,通常被称为验收 TDD,在以下图表中展示:

图 5.4 – 验收 TDD
用户(与开发人员)以人类友好的 DSL 格式编写验收标准规范。开发人员编写夹具,测试失败。然后,功能开发开始,在内部使用 TDD 方法论。一旦功能完成,验收测试应该通过,这标志着功能的完成。
一个非常好的做法是将 Cucumber 功能规格附加到问题跟踪工具中的请求票证(例如 JIRA),以便功能总是与其接受测试一起被请求。有些开发团队采取更激进的方式,如果没有准备好接受测试,他们会拒绝开始开发过程。这是很有道理的。毕竟,如何开发出客户无法测试的东西呢?
总结
在本章中,你学习了如何构建一个完整且功能齐全的接受测试阶段,这是 CD 过程中的一个关键部分。以下是关键要点:
-
创建接受测试可能很困难,因为它们结合了技术挑战(应用程序依赖关系、环境设置)和个人挑战(开发人员/业务协作)。
-
接受测试框架提供了一种使用人类友好的语言编写测试的方法,使非技术人员也能理解它们。
-
Docker Registry 是一个用于 Docker 镜像的工件库。
-
Docker Registry 非常适合与 CD 过程配合使用,因为它提供了一种在各个阶段和环境中使用完全相同的 Docker 镜像的方法。
在下一章中,我们将介绍集群和服务依赖关系,这是创建完整 CD 管道的下一步。
练习
我们在本章中涵盖了许多新内容,为了帮助你的理解,我建议做以下练习:
- 创建一个基于 Ruby 的 Web 服务,
book-library,用于存储书籍。
接受标准以以下 Cucumber 功能的形式提供:
Scenario: Store book in the library
Given Book "The Lord of the Rings" by "J.R.R. Tolkien" with ISBN number "0395974682"
When I store the book in library
Then I am able to retrieve the book by the ISBN number
按照以下步骤进行:
-
为 Cucumber 测试编写步骤定义。
-
编写 Web 服务(最简单的方式是使用 Sinatra 框架(http://www.sinatrarb.com/),但也可以使用 Ruby on Rails)。
-
书籍应具有以下属性:
name、author和ISBN)。 -
Web 服务应具有以下端点:
-
POST /books用于添加一本书 -
GET /books/<isbn>用于检索书籍
-
-
数据可以存储在内存中。
-
最后,检查接受测试是否通过(绿色)。
-
通过以下方式将
book-library作为 Docker 镜像添加到 Docker Registry:-
在 Docker Hub 上创建一个帐户。
-
为应用程序创建一个 Dockerfile。
-
构建 Docker 镜像并根据命名惯例对其进行标记。
-
将镜像推送到 Docker Hub。
-
-
创建一个 Jenkins 管道来构建 Docker 镜像,推送到 Docker Registry,并执行接受测试,方法如下:
-
创建一个
Docker build阶段。 -
创建
Docker login和Docker push阶段。 -
在管道中添加
Acceptance test阶段。 -
运行管道并观察结果。
-
问题
为了验证从本章获得的知识,请回答以下问题:
-
什么是 Docker Registry?
-
什么是 Docker Hub?
-
Docker 镜像的命名惯例是什么(稍后推送到 Docker Registry)?
-
什么是暂存环境?
-
你会使用哪些 Docker 命令来构建镜像并将其推送到 Docker Hub?
-
接受测试框架,如 Cucumber 和 FitNesse,主要的目的是什么?
-
Cucumber 测试的三个主要部分是什么?
-
什么是接受 TDD?
深入阅读
要了解更多关于 Docker Registry、接受测试和 Cucumber 的信息,请参考以下资源:
-
Docker Registry 文档:
docs.docker.com/registry/ -
Jez Humble, David Farley—持续交付:
continuousdelivery.com/ -
黄瓜框架:
cucumber.io/
第六章: 使用 Kubernetes 进行集群管理
到目前为止,本书已涵盖了验收测试过程的基础知识。本章中,我们将看到如何将 Docker 环境从单一 Docker 主机转换为一组机器的集群,以及如何将独立的应用程序变为由多个应用程序组成的系统。
本章将涵盖以下主题:
-
服务器集群
-
介绍 Kubernetes
-
Kubernetes 安装
-
使用 Kubernetes
-
高级 Kubernetes
-
应用程序依赖关系
-
替代的集群管理系统
技术要求
本章中,您需要满足以下硬件/软件要求才能跟随本章中的说明进行操作:
-
至少 4 GB 的 RAM
-
至少 1 GB 的可用磁盘空间
-
Java JDK 8+
本章中所有示例和练习的解决方案可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter06。
本章的代码实战视频可以在bit.ly/3rcffcz观看。
服务器集群
到目前为止,我们已经分别与每台机器进行过交互。我们所做的是连接到localhost Docker 守护进程服务器。我们本可以在docker run命令中使用-H选项来指定远程 Docker 的地址,但那仍然意味着将应用程序部署到单一的 Docker 主机上。然而,在实际生活中,如果服务器共享相同的物理位置,我们并不关心服务部署在哪一台具体的机器上。我们需要做的是让它可访问,并在多个实例中进行复制,以支持高可用性。我们如何配置一组机器让它们以这种方式工作? 这就是集群的作用。
在接下来的子章节中,您将了解服务器集群的概念,以及 Kubernetes 环境,这是一个集群管理软件的示例。
介绍服务器集群
服务器集群是一组连接在一起的计算机,它们协同工作,可以像单一系统一样使用。服务器通常通过局域网连接,连接速度足够快,以确保运行的服务可以分布式处理。下图展示了一个简单的服务器集群:

图 6.1 – 服务器集群
用户通过主机访问集群,主机暴露集群 API。集群中有多个节点作为计算资源,负责运行应用程序。而主机则负责其他所有活动,例如编排过程、服务发现、负载均衡和节点故障检测。
介绍 Kubernetes
Kubernetes 是一个开源的集群管理系统,最初由 Google 设计。从流行度的图表来看,它在其他竞争者(如 Docker Swarm 和 Apache Mesos)中遥遥领先。它的流行度增长非常迅速,以至于大多数云平台都直接提供 Kubernetes。它不是 Docker 原生的,但有许多附加工具和集成可以使其与整个 Docker 生态系统顺畅配合;例如,kompose 可以将 Docker Compose 文件转换为 Kubernetes 配置。
信息
在本书的第一版中,我推荐使用 Docker Compose 和 Docker Swarm 来解决应用依赖关系和服务器集群问题。虽然这两个工具都很好,但 Kubernetes 最近的流行度如此之高,以至于我决定将 Kubernetes 作为推荐的方法,并将 Docker 原生工具作为替代方案。
让我们来看看简化版的 Kubernetes 架构:

图 6.2 – 简化版 Kubernetes 架构
Kubernetes 8080) 和控制平面负责实现这一目标。另一方面,Kubernetes 节点是一个工作节点。你可以把它看作是一个(Docker)容器主机,安装了一个特殊的 Kubernetes 进程(称为 kubelet)。
从用户的角度来看,你提供一个声明式的部署配置,形式为 YAML 文件,并通过其 API 将该文件传递给 Kubernetes 控制平面。然后,控制平面读取配置并安装部署。Kubernetes 引入了 Pod 的概念,表示一个单独的部署单元。Pod 包含 Docker 容器,这些容器一起调度。虽然你可以将多个容器放入一个 Pod,但在实际场景中,大多数 Pod 通常只包含一个 Docker 容器。Pods 会根据 YAML 配置文件更新中表达的需求变化动态创建和删除。
在本章后续部分,你将获得更多关于 Kubernetes 的实际知识,但首先,让我们列举出使 Kubernetes 成为如此出色的环境的一些特性。
Kubernetes 特性概述
Kubernetes 提供了许多有趣的特性。让我们来看看其中最重要的一些:
-
容器负载均衡:Kubernetes 负责 Pods 在节点上的负载均衡;你指定应用副本的数量,Kubernetes 会处理其余部分。
-
流量负载均衡:当你有多个应用副本时,Kubernetes 服务可以进行流量负载均衡。换句话说,你创建一个具有单一 IP(或 DNS)的服务,Kubernetes 会处理将流量负载均衡到你的应用副本。
-
动态水平扩展:每个部署可以动态地进行扩展或缩减;你指定应用实例的数量(或自动扩展的规则),Kubernetes 会启动/停止 Pod 副本。
-
故障恢复:Pods(和节点)会持续被监控,如果其中任何一个失败,新的 Pods 会被启动,以确保声明的副本数保持不变。
-
滚动更新:配置的更新可以逐步应用;例如,如果我们有 10 个副本并希望进行更改,可以在每个副本的部署之间定义一个延迟。在这种情况下,当出现问题时,我们永远不会遇到副本无法正常工作的情况。
-
存储编排:Kubernetes 可以将你选择的存储系统挂载到你的应用程序上。Pod 是无状态的,因此 Kubernetes 与多个存储提供商集成,例如 Amazon Elastic Block Storage (EBS)、Google Compute Engine (GCE) 持久磁盘和 Azure 数据存储。
-
服务发现:Kubernetes Pods 本质上是短暂的,它们的 IP 动态分配,但 Kubernetes 提供了基于 DNS 的服务发现机制。
-
随处运行:Kubernetes 是一个开源工具,你可以选择多种方式来运行它:本地部署、云基础设施或混合部署。
既然我们对 Kubernetes 有了一些背景了解,接下来让我们看看它在实践中的表现,从安装过程开始。
Kubernetes 安装
Kubernetes 就像 Docker 一样,由两部分组成:客户端和服务器端。客户端是一个名为kubectl的命令行工具,它通过 Kubernetes API 连接到服务器端。服务器端要复杂得多,正如我们在上一节所描述的那样。显然,要使用 Kubernetes 执行任何操作,你需要这两部分,因此我们将逐一描述它们,从客户端开始。
Kubernetes 客户端
Kubernetes 客户端 kubectl 是一个命令行应用程序,允许你对 Kubernetes 集群执行操作。安装过程取决于你的操作系统。你可以在 Kubernetes 官方网站上查看详细信息:kubernetes.io/docs/tasks/tools/。
在你成功安装了 kubectl 之后,你应该能够执行以下命令:
$ kubectl version --client
Client Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.4", ...
现在你已经配置了 Kubernetes 客户端,我们可以继续设置服务器部分。
Kubernetes 服务器
设置 Kubernetes 服务器有多种方式。你应该使用哪种方式取决于你的需求,但如果你完全是 Kubernetes 的新手,我建议从本地环境开始。
本地环境
尽管 Kubernetes 本身是一个复杂的集群系统,但有一些工具可以简化你的本地开发环境。让我们逐一了解你可以使用的选项,包括 Docker Desktop、kind 和 minikube。
Docker Desktop
Docker Desktop 是一个应用程序,用于在 macOS 或 Windows 上设置本地 Docker 环境。如你从前面的章节中可能记得,Docker 守护进程只能在 Linux 上本地运行,因此在其他操作系统上,你需要在虚拟机上运行它。Docker Desktop 提供了一种超级直观的方式来实现这一点,幸运的是,它还支持创建 Kubernetes 集群。
如果你已安装 Docker Desktop,那么你需要做的就是检查kubectl是否已配置:

图 6.3 – 在 Docker Desktop 中使用 Kubernetes
从这一点开始,你已经准备好使用 Kubernetes 集群了。
kind
如果你使用的是 Linux 操作系统,并且不能或不想使用 Docker Desktop,那么你第二简单的选择是kind(即Kubernetes in Docker的缩写)。它是一个工具,唯一的要求是安装并配置 Docker。
安装完 kind 后,你可以通过以下一条命令启动并配置本地 Kubernetes 集群:
$ kind create cluster
信息
你可以查看 kind 的安装步骤,网址是kind.sigs.k8s.io/docs/user/quick-start/。
minikube
minikube 是一个命令行工具,可以在虚拟机内启动一个功能完备的 Kubernetes 环境。它由虚拟机监控程序支持,因此你需要安装 VirtualBox、Hyper-V、VMware 或类似的工具。安装 minikube 的说明取决于你的操作系统,你可以在minikube.sigs.k8s.io/docs/start/找到每个操作系统的安装指南。
信息
minikube 是一个开源工具,你可以在 GitHub 上找到它,网址是github.com/kubernetes/minikube。
在你成功安装 minikube 后,你可以通过以下命令启动 Kubernetes 集群:
$ minikube start
minikube 启动一个 Kubernetes 集群,并自动配置你的 Kubernetes 客户端,包含集群 URL 和凭证,这样你就可以直接进入验证 Kubernetes 配置部分。
云平台
Kubernetes 已经变得非常流行,几乎所有的云计算平台都将其作为一项服务提供。这里的领导者是Google Cloud Platform(GCP),它允许你在几分钟内创建一个 Kubernetes 集群。其他云平台,如 Microsoft Azure、Amazon Web Services(AWS)和 IBM Cloud,也将 Kubernetes 纳入其产品组合。让我们仔细看看三大最受欢迎的解决方案——GCP、Azure和AWS。
Google Cloud Platform
你可以访问 GCP,网址是cloud.google.com/。创建账户后,你应该能够打开他们的 Web 控制台(console.cloud.google.com)。他们的产品组合中有一个服务叫做Google Kubernetes Engine(GKE)。
你可以通过点击用户界面或使用 GCP 命令行工具gcloud来创建 Kubernetes 集群。
信息
你可以在官方 GCP 网站上查看如何在你的操作系统上安装 gcloud:cloud.google.com/sdk/docs/install。
要使用命令行工具创建 Kubernetes 集群,只需执行以下命令:
$ gcloud container clusters create test-cluster
除了创建 Kubernetes 集群外,它还会自动配置kubectl。
微软 Azure
微软 Azure 还通过Azure Kubernetes 服务(AKS)提供了一个非常快速的 Kubernetes 设置。像 GCP 一样,你可以使用 web 界面或命令行工具来创建集群。
信息
你可以访问 Azure Web 控制台:portal.azure.com/。要安装 Azure 命令行工具,请查看其官方网站的安装指南:docs.microsoft.com/en-us/cli/azure/install-azure-cli。
要使用 Azure 命令行工具创建 Kubernetes 集群,假设你已经创建了一个 Azure 资源组,只需运行以下命令:
$ az aks create -n test-cluster -g test-resource-group
几秒钟后,你的 Kubernetes 集群应该准备就绪。要配置 kubectl,运行以下命令:
$ az aks get-credentials -n test-cluster -g test-resource-group
通过这样做,你将成功设置一个 Kubernetes 集群并配置好 kubectl。
亚马逊网络服务
AWS 提供了一种托管的 Kubernetes 服务,称为 Amazon 弹性 Kubernetes 服务(EKS)。你可以通过访问 AWS Web 控制台:console.aws.amazon.com/eks 或使用 AWS 命令行工具开始使用它。
信息
你可以在 AWS 命令行工具的官方网站查看所有信息(包括安装指南):docs.aws.amazon.com/cli/。
正如你所看到的,使用云中的 Kubernetes 是一个相对简单的选择。然而,有时你可能需要从头开始在自己的服务器上安装本地 Kubernetes 环境。我们将在下一节讨论这个问题。
本地部署
如果你不想依赖云平台,或者你的公司安全政策不允许使用云平台,那么从头开始在自己的服务器上安装 Kubernetes 是有意义的。安装过程相对复杂,超出了本书的范围,但你可以在官方文档中找到所有细节:kubernetes.io/docs/setup/production-environment/。
现在我们已经配置好了 Kubernetes 环境,可以检查 kubectl 是否已正确连接到集群,并准备好开始部署应用程序。
验证 Kubernetes 配置
无论你选择哪种 Kubernetes 服务器安装方式,你应该已经完成所有配置,并且 Kubernetes 客户端应该已经填充了集群的 URL 和凭证。你可以通过以下命令来检查:
$ kubectl cluster-info
Kubernetes control plane is running at https://kubernetes.docker.internal:6443
CoreDNS is running at https://kubernetes.docker.internal:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
这是 Docker Desktop 场景下的输出,正因如此你能看到localhost。你的输出可能略有不同,且可能包含更多条目。如果没有看到错误信息,那么一切都是正确的,我们可以开始使用 Kubernetes 来运行应用程序。
使用 Kubernetes
我们已经准备好了整个 Kubernetes 环境,并配置了kubectl。这意味着是时候展示 Kubernetes 的强大功能,部署我们的第一个应用程序了。我们将使用前几章中构建的leszko/calculator Docker 镜像,并在 Kubernetes 上启动多个副本。
部署应用程序
为了在 Kubernetes 上启动 Docker 容器,我们需要准备一个部署配置文件,格式为 YAML。我们将其命名为deployment.yaml:
apiVersion: apps/v1
kind: Deployment (1)
metadata:
name: calculator-deployment (2)
labels:
app: calculator
spec:
replicas: 3 (3)
selector: (4)
matchLabels:
app: calculator
template: (5)
metadata:
labels: (6)
app: calculator
spec:
containers:
- name: calculator (7)
image: leszko/calculator (8)
ports: (9)
- containerPort: 8080
在这个 YAML 配置中,我们需要确保以下几点:
-
我们已经定义了一个类型为
Deployment的 Kubernetes 资源,使用的是apps/v1Kubernetes API 版本。 -
唯一的部署名称是
calculator-deployment。 -
我们已经定义了应该创建正好
3个相同的 Pod。 -
selector定义了Deployment如何找到需要管理的 Pod,在这个例子中,仅通过标签来进行查找。 -
template定义了每个创建的 Pod 的规格。 -
每个 Pod 都被标记为
app: calculator。 -
每个 Pod 都包含一个名为
calculator的 Docker 容器。 -
从名为
leszko/calculator的镜像创建了一个 Docker 容器。 -
该 Pod 暴露了容器端口
8080。
要安装部署,请运行以下命令:
$ kubectl apply -f deployment.yaml
你可以检查是否已经创建了包含一个 Docker 容器的三个 Pod:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
calculator-deployment-dccdf8756-h2l6c 1/1 Running 0 1m
calculator-deployment-dccdf8756-tgw48 1/1 Running 0 1m
calculator-deployment-dccdf8756-vtwjz 1/1 Running 0 1m
每个 Pod 都运行一个 Docker 容器。我们可以使用以下命令来检查它的日志:
$ kubectl logs pods/calculator-deployment-dccdf8756-h2l6c
你应该能看到熟悉的 Spring logo 和我们 Calculator web 服务的日志。
信息
要查看kubectl命令的概述,请查看官方指南:kubernetes.io/docs/reference/kubectl/overview/。
我们刚刚完成了第一次 Kubernetes 部署,通过几行代码,我们创建了三个副本的 Calculator web 服务应用程序。现在,让我们看看如何使用我们部署的应用程序。为此,我们需要了解 Kubernetes Service 的概念。
部署 Kubernetes Service
每个 Pod 在 Kubernetes 内部网络中都有一个 IP 地址,这意味着你已经可以从同一 Kubernetes 集群中运行的其他 Pod 访问每个 Calculator 实例。但是,我们如何从外部访问我们的应用程序呢? 这正是 Kubernetes Service 的作用。
Pod 和 Service 的概念是,Pod 是有生命的——它们会被终止,然后重新启动。Kubernetes 调度器只关心正确数量的 Pod 副本,而不是 Pod 的身份。这就是为什么,即使每个 Pod 都有一个(内部)IP 地址,我们也不应该依赖它或使用它。而 Service 则充当 Pod 的前端。它们有可供使用的 IP 地址(和 DNS 名称)。让我们看一下以下图示,展示了 Pod 和 Service 的概念:

图 6.4 – Kubernetes Pod 和 Service
Pod 实际上分布在不同的节点上,但您不需要担心这一点,因为 Kubernetes 会处理正确的调度,并引入 Pod 和 Service 的抽象。用户访问 Service,Service 会在 Pod 副本之间进行流量负载均衡。让我们看一个如何为我们的计算器应用创建服务的示例。
就像我们为部署所做的一样,我们从一个 YAML 配置文件开始。我们将其命名为 service.yaml:
apiVersion: v1
kind: Service
metadata:
name: calculator-service
spec:
type: NodePort
selector:
app: calculator
ports:
- port: 8080
这是一个简单的服务配置,它将流量负载均衡到所有符合我们在 selector 中提到的条件的 Pod。要安装该服务,请运行以下命令:
$ kubectl apply -f service.yaml
然后,您可以通过运行以下命令来检查服务是否已正确部署:
$ kubectl get service calculator-service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
calculator-service NodePort 10.19.248.154 <none> 8080:32259/TCP 13m
要检查该服务是否指向我们在上一节中创建的三个 Pod 副本,请运行以下命令:
$ kubectl describe service calculator-service | grep Endpoints
Endpoints: 10.16.1.5:8080,10.16.2.6:8080,10.16.2.7:8080
从我们运行的最后两条命令中,我们可以看到该服务在 10.19.248.154 的 IP 地址下可用,并将流量负载均衡到三个 IP 地址分别为 10.16.1.5、10.16.2.6 和 10.16.2.7 的 Pod。所有这些 IP 地址,无论是服务还是 Pod,都是 Kubernetes 集群网络内部的。
信息
要了解更多关于 Kubernetes 服务的信息,请访问 Kubernetes 官方网站:kubernetes.io/docs/concepts/services-networking/service/。
在下一节中,我们将探讨如何从 Kubernetes 集群外部访问服务。
曝露应用
要了解您的应用如何从外部访问,我们需要从 Kubernetes 服务类型开始。您可以使用四种不同的服务类型,如下所示:
-
ClusterIP(默认):该服务仅具有内部 IP 地址。
-
<NODE-IP>:<NODE-PORT>。 -
LoadBalancer:创建一个外部负载均衡器,并为该服务分配一个单独的外部 IP 地址。您的 Kubernetes 集群必须支持外部负载均衡器,在云平台中通常没有问题,但如果您使用 minikube,可能无法正常工作。
-
externalName在规格说明中)。
如果你使用的是部署在云平台上的 Kubernetes 实例(例如,GKE),那么暴露服务的最简单方法是使用 kubectl get service 命令。如果我们在配置中使用了它,那么你就可以通过 http://<EXTERNAL-IP>:8080 访问 Calculator 服务。
虽然 LoadBalancer 似乎是最简单的解决方案,但它有两个缺点:
-
首先,它并不总是可用的,例如,如果你在本地部署了 Kubernetes 或使用了 minikube。
-
其次,外部公共 IP 通常比较昂贵。另一种解决方案是使用
NodePort服务,正如我们在前面一节中所做的那样。
现在,让我们来看一下如何访问我们的服务。
我们可以重复我们已经执行过的相同命令:
$ kubectl get service calculator-service
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
calculator-service NodePort 10.19.248.154 <none> 8080:32259/TCP 13m
你可以看到端口 32259 被选作节点端口。这意味着我们可以通过该端口和任何 Kubernetes 节点的 IP 来访问我们的 Calculator 服务。
你的 Kubernetes 节点的 IP 地址取决于你的安装方式。如果你使用的是 Docker Desktop,那么你的节点 IP 是 localhost。如果是 minikube,你可以通过 minikube ip 命令来查看它。在云平台或本地安装的情况下,你可以使用以下命令查看 IP 地址:
$ kubectl get nodes -o jsonpath='{ $.items[*].status.addresses[?(@.type=="ExternalIP")].address }'
35.192.180.252 35.232.125.195 104.198.131.248
要检查你是否能从外部访问 Calculator,请运行以下命令:
$ curl <NODE-IP>:32047/sum?a=1\&b=2
3
我们向其中一个 Calculator 容器实例发出了 HTTP 请求,它返回了正确的响应,这意味着我们已经成功地在 Kubernetes 上部署了应用程序。
小提示
kubectl 命令提供了一种快捷方式,可以在不使用 YAML 的情况下创建服务。你可以执行以下命令,而不是使用我们之前的配置:
$ kubectl expose deployment calculator-deployment --type=NodePort --name=calculator-service。
我们刚刚学到的内容为我们提供了 Kubernetes 的必要基础。现在我们可以将其用于暂存和生产环境,从而将其纳入持续交付过程。然而,在我们这么做之前,让我们再看看一些使 Kubernetes 成为一个伟大且有用工具的其他功能。
高级 Kubernetes
Kubernetes 提供了一种在运行时动态修改部署的方法。这一点尤其重要,如果你的应用程序已经在生产环境中运行,并且你需要支持零停机时间的部署。首先,让我们看看如何扩展应用程序,然后介绍 Kubernetes 在任何部署更改中的通用方法。
扩展应用程序
假设我们的 Calculator 应用程序开始流行起来。人们开始使用它,流量大到三个 Pod 副本都超载了。我们现在该怎么办?
幸运的是,kubectl 提供了一种简单的方法来使用 scale 关键字扩展和缩减部署。让我们将我们的 Calculator 部署扩展到 5 个实例:
$ kubectl scale --replicas 5 deployment calculator-deployment
就这样,我们的应用程序现在已经扩展:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
calculator-deployment-dccdf8756-h2l6c 1/1 Running 0 19h
calculator-deployment-dccdf8756-j87kg 1/1 Running 0 36s
calculator-deployment-dccdf8756-tgw48 1/1 Running 0 19h
calculator-deployment-dccdf8756-vtwjz 1/1 Running 0 19h
calculator-deployment-dccdf8756-zw748 1/1 Running 0 36s
请注意,从现在开始,我们创建的服务会将流量负载均衡到所有 5 个 Calculator Pod 上。还要注意,你甚至不需要担心每个 Pod 运行在哪台物理机器上,因为 Kubernetes 编排器已经涵盖了这一点。你只需要考虑应用实例的目标数量。
信息
Kubernetes 还提供了一种根据其指标自动扩展 Pod 的方法。这个功能叫做 HorizontalPodAutoscaler,你可以在kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/了解更多信息。
我们刚刚看到如何扩展应用程序。现在,让我们更广泛地了解如何更新 Kubernetes 部署的任何部分。
更新应用程序
Kubernetes 会处理部署更新。让我们修改 deployment.yaml 文件,并向 Pod 模板中添加一个新标签:
apiVersion: apps/v1
kind: Deployment
metadata:
name: calculator-deployment
labels:
app: calculator
spec:
replicas: 5
selector:
matchLabels:
app: calculator
template:
metadata:
labels:
app: calculator
label: label
spec:
containers:
- name: calculator
image: leszko/calculator
ports:
- containerPort: 8080
现在,如果我们重复这个操作并应用相同的部署,我们可以观察到 Pod 会发生什么:
$ kubectl apply -f deployment.yaml
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pod/calculator-deployment-7cc54cfc58-5rs9g 1/1 Running 0 7s
pod/calculator-deployment-7cc54cfc58-jcqlx 1/1 Running 0 4s
pod/calculator-deployment-7cc54cfc58-lsh7z 1/1 Running 0 4s
pod/calculator-deployment-7cc54cfc58-njbbc 1/1 Running 0 7s
pod/calculator-deployment-7cc54cfc58-pbthv 1/1 Running 0 7s
pod/calculator-deployment-dccdf8756-h2l6c 0/1 Terminating 0 20h
pod/calculator-deployment-dccdf8756-j87kg 0/1 Terminating 0 18m
pod/calculator-deployment-dccdf8756-tgw48 0/1 Terminating 0 20h
pod/calculator-deployment-dccdf8756-vtwjz 0/1 Terminating 0 20h
pod/calculator-deployment-dccdf8756-zw748 0/1 Terminating 0 18m
我们可以看到,Kubernetes 终止了所有旧的 Pod 并启动了新的 Pod。
信息
在我们的示例中,我们修改的是 YAML 配置中的部署,而不是应用程序本身。然而,修改应用程序实际上是一样的。如果我们对应用程序的源代码进行任何更改,我们需要构建一个新的 Docker 镜像并更新 deployment.yaml 中的版本。
每次你更改某些内容并运行 kubectl apply 时,Kubernetes 会检查现有状态与 YAML 配置之间是否存在变化,然后如果需要,它会执行我们之前描述的更新操作。
这一切都很好,但如果 Kubernetes 突然终止所有 Pod,我们可能会陷入一种情况:所有旧的 Pod 都已经被杀死,但新的 Pod 还没有准备好。这将导致我们的应用程序暂时不可用。如何确保零停机时间的部署? 这就是滚动更新的作用。
滚动更新
滚动更新意味着逐步终止旧的实例并启动新的实例。换句话说,工作流程如下:
-
终止一个旧的 Pod。
-
启动一个新的 Pod。
-
等待直到新的 Pod 准备好。
-
重复 第 1 步,直到所有旧实例被替换。
信息
滚动更新的概念只有在新版本的应用程序与旧版本的应用程序向后兼容时才有效。否则,我们可能会面临两个不兼容的版本同时存在的风险。
要配置它,我们需要在部署中添加 RollingUpdate 策略,并指定 readinessProbe,这让 Kubernetes 知道 Pod 何时准备就绪。让我们修改 deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: calculator-deployment
labels:
app: calculator
spec:
replicas: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 25%
maxSurge: 0
selector:
matchLabels:
app: calculator
template:
metadata:
labels:
app: calculator
spec:
containers:
- name: calculator
image: leszko/calculator
ports:
- containerPort: 8080
readinessProbe:
httpGet:
path: /sum?a=1&b=2
port: 8080
让我们解释一下我们在配置中使用的参数:
-
maxUnavailable:在更新过程中可以不可用的最大 Pods 数量;在我们的例子中,Kubernetes 在 Pod 数量大于一个时,不会同时终止多个 Pods(75% ** 5* 所需副本)。 -
maxSurge:可以创建的 Pods 数量,超过所需 Pods 的数量;在我们的例子中,Kubernetes 在终止旧 Pods 之前不会创建新的 Pods。 -
path和port:检查容器是否准备就绪的端点;一个 HTTPGET请求会被发送到<POD-IP>:8080/sum?a=1&b=2,当它最终返回200HTTP 状态码时,Pod 将被标记为 就绪。提示
通过修改
maxUnavailable和maxSurge参数,我们可以决定是 Kubernetes 首先启动新的 Pods 然后终止旧的 Pods,还是像我们做的那样,先终止旧的 Pods 然后再启动新的 Pods。
现在我们可以应用部署并观察 Pods 一一更新:
$ kubectl apply -f deployment.yaml
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
calculator-deployment-78fd7b57b8-npphx 0/1 Running 0 4s
calculator-deployment-7cc54cfc58-5rs9g 1/1 Running 0 3h
calculator-deployment-7cc54cfc58-jcqlx 0/1 Terminating 0 3h
calculator-deployment-7cc54cfc58-lsh7z 1/1 Running 0 3h
calculator-deployment-7cc54cfc58-njbbc 1/1 Running 0 3h
calculator-deployment-7cc54cfc58-pbthv 1/1 Running 0 3h
就是这样,我们已经为我们的计算器部署配置了滚动更新,这意味着我们可以提供零停机时间的发布。
信息
Kubernetes 还提供了一种不同的应用程序运行方式。你可以使用 StatefulSet 代替 Deployment,这样滚动更新将始终启用(即使没有指定任何额外的策略)。
滚动更新在持续交付的背景下尤其重要,因为如果我们频繁部署,就绝对不能承受任何停机时间。
提示
在玩 Kubernetes 后,进行清理以删除我们创建的所有资源是很好的做法。在我们的例子中,我们可以执行以下命令来删除我们创建的服务和部署:
$ kubectl delete -f service.yaml
$ kubectl delete -f deployment.yaml
我们已经展示了所有 Kubernetes 中用于持续交付过程的功能。让我们做一个简短的总结,并简要介绍其他有用的功能。
Kubernetes 对象和工作负载
Kubernetes 中的执行单元始终是 Pod,它包含一个或多个(Docker)容器。有多种不同的资源类型来编排 Pods:
-
Deployment:这是最常见的工作负载,它管理所需副本 Pods 的生命周期。
-
StatefulSet:这是一个专门的 Pod 控制器,它保证 Pods 的顺序和唯一性。它通常与面向数据的应用程序关联(在这种情况下,仅仅说 我需要 3 个副本 并不够,就像 Deployment 中的情况,而是 我想要 3 个副本,始终保持相同的可预测 Pod 名称,并且总是按相同顺序启动)。
-
DaemonSet:这是一个专门的 Pod 控制器,它在每个 Kubernetes 节点上运行一个 Pod 的副本。
-
Job/CronJob:这是一个专门针对任务操作的工作流,其中容器预计能够成功存在。
信息
你可能还会看到一个 Kubernetes 资源叫做 ReplicationController,它已经被弃用,并由 Deployment 取代。
除了 Pod 管理,Kubernetes 还有其他对象。以下是你可能经常遇到的最有用的对象:
-
服务:充当 Pod 内部负载均衡器的组件。
-
ConfigMap:这将配置与镜像内容解耦;它可以是任何与镜像分开定义的数据,然后挂载到容器的文件系统中。
-
Secret:这允许你存储敏感信息,如密码。
-
PersistentVolume/PersistentVolumeClaim:这些允许你将持久卷挂载到(无状态的)容器文件系统中。
事实上,还有更多的对象可用,甚至可以创建自己的资源定义。然而,我们在这里提到的这些是实践中最常用的。
我们已经对 Kubernetes 中的集群有了很好的理解,但 Kubernetes 不仅仅是关于工作负载和扩展。它还可以帮助解决应用程序之间的依赖关系。在接下来的部分,我们将探讨这个话题,并描述在 Kubernetes 和持续交付过程中应用程序的依赖关系。
应用程序依赖项
没有依赖的生活很轻松。然而,在现实生活中,几乎每个应用程序都会连接到数据库、缓存、消息系统或其他应用程序。在(微)服务架构中,每个服务都需要一堆其他服务来完成工作。单体架构并没有消除这个问题——一个应用程序通常至少会有一些依赖项,至少是数据库。
想象一下一个新人加入你的开发团队;设置整个开发环境并运行所有依赖项的应用程序需要多少时间?
当谈到自动化验收测试时,依赖问题不再仅仅是一个便利问题——它变成了一个必要条件。在单元测试中,我们可以模拟依赖项,但验收测试套件需要一个完整的环境。我们如何快速并可重复地设置它? 幸运的是,Kubernetes 可以通过其内建的 DNS 解析功能帮助我们解决这个问题,适用于服务和 Pod。
Kubernetes DNS 解析
让我们通过一个现实场景来展示 Kubernetes DNS 解析。假设我们想将一个缓存服务作为一个独立的应用程序部署,并让它对其他服务可用。最好的内存缓存解决方案之一是 Hazelcast,因此我们在这里使用它。对于计算器应用程序,我们需要 Deployment 和 Service。让我们将它们都定义在一个文件中,hazelcast.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hazelcast
labels:
app: hazelcast
spec:
replicas: 1
selector:
matchLabels:
app: hazelcast
template:
metadata:
labels:
app: hazelcast
spec:
containers:
- name: hazelcast
image: hazelcast/hazelcast:5.0.2
ports:
- containerPort: 5701
---
apiVersion: v1
kind: Service
metadata:
name: hazelcast
spec:
selector:
app: hazelcast
ports:
- port: 5701
类似于我们之前为计算器应用程序所做的,我们现在将定义 Hazelcast 配置。我们以相同的方式开始:
$ kubectl apply -f hazelcast.yaml
几秒钟后,Hazelcast 缓存应用程序应该启动。你可以通过 kubectl logs 命令查看其 Pod 日志。我们还创建了一个默认类型的服务(ClusterIP,仅在同一 Kubernetes 集群内暴露)。
到目前为止,一切顺利——我们与计算器应用程序中的操作没有什么不同。现在进入最有趣的部分。Kubernetes 提供了一种使用服务名称解析服务 IP 的方式。更有意思的是,我们事先就知道了 Service 的名称——在我们的案例中,它始终是 hazelcast。所以,如果我们在应用程序中使用它作为缓存地址,依赖关系将被自动解析。
信息
实际上,Kubernetes 的 DNS 解析功能更强大,它甚至可以解析不同 Kubernetes 命名空间中的服务。详情请阅读 kubernetes.io/docs/concepts/services-networking/dns-pod-service/。
在向你展示如何在计算器应用程序中实现缓存之前,让我们先概览一下我们将构建的系统。
多应用系统概览
我们已经在 Kubernetes 上部署了 Hazelcast 服务器。在修改我们的计算器应用程序之前,以便能够将其用作缓存提供者,让我们先来看一下我们要构建的完整系统的示意图:

图 6.5 – 示例多应用部署
用户使用 hazelcast)。Hazelcast 服务 将重定向到 Hazelcast Pod。
如果你查看图示,可以看到我们刚刚部署了 Hazelcast 部分(Hazelcast 服务 和 Hazelcast Pod)。我们也在上一节中部署了计算器部分(Calculator 服务 和 Calculator Pod)。最后缺失的部分是计算器代码,来使用 Hazelcast。现在让我们来实现它。
多应用系统实现
为了在我们的计算器应用程序中实现缓存功能,我们需要执行以下操作:
-
将 Hazelcast 客户端库添加到 Gradle。
-
添加 Hazelcast 缓存配置。
-
添加 Spring Boot 缓存。
-
构建 Docker 镜像。
让我们一步一步来。
将 Hazelcast 客户端库添加到 Gradle
在 build.gradle 文件中,将以下配置添加到 dependencies 部分:
implementation 'com.hazelcast:hazelcast:5.0.2'
这将添加负责与 Hazelcast 服务器通信的 Java 库。
添加 Hazelcast 缓存配置
将以下部分添加到 src/main/java/com/leszko/calculator/CalculatorApplication.java 文件中:
package com.leszko.calculator;
import com.hazelcast.client.config.ClientConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
@EnableCaching
public class CalculatorApplication {
public static void main(String[] args) {
SpringApplication.run(CalculatorApplication.class, args);
}
@Bean
public ClientConfig hazelcastClientConfig() {
ClientConfig clientConfig = new ClientConfig();
clientConfig.getNetworkConfig().addAddress("hazelcast");
return clientConfig;
}
}
这是一个标准的 Spring 缓存配置。请注意,对于 Hazelcast 服务器地址,我们使用 hazelcast,它由于 Kubernetes DNS 解析功能而自动可用。
提示
在实际应用中,如果你使用 Hazelcast,甚至不需要指定服务名称,因为 Hazelcast 提供了一个专门针对 Kubernetes 环境的自动发现插件。详情请阅读 docs.hazelcast.com/hazelcast/latest/deploy/deploying-in-kubernetes.html。
我们还需要删除 Spring Initializr 自动创建的 Spring 上下文测试,src/test/java/com/leszko/calculator/CalculatorApplicationTests.java。
接下来,让我们为 Spring Boot 服务添加缓存。
添加 Spring Boot 缓存
现在缓存已经配置好,我们终于可以将缓存添加到我们的 Web 服务中了。为此,我们需要修改src/main/java/com/leszko/calculator/Calculator.java文件,使其如下所示:
package com.leszko.calculator;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class Calculator {
@Cacheable("sum")
public int sum(int a, int b) {
try {
Thread.sleep(3000);
}
catch (InterruptedException e) {
e.printStackTrace();
}
return a + b;
}
}
我们添加了@Cacheable注解,使 Spring 自动缓存每次调用sum()方法。我们还添加了 3 秒钟的睡眠,仅用于测试,以便我们能够看到缓存是否正确工作。
从现在开始,求和计算会被缓存到 Hazelcast 中,当我们调用 Calculator Web 服务的/sum端点时,它会首先尝试从缓存中检索结果。现在,让我们构建我们的应用程序。
构建 Docker 镜像
下一步,我们需要删除 Spring 默认的上下文测试,src/test/java/com/leszko/calculator/CalculatorApplicationTests.java(以避免因缺少 Hazelcast 依赖项而导致失败)。
现在,我们可以重新构建 Calculator 应用程序及其 Docker 镜像,并使用新标签。然后,我们会将它再次推送到 Docker Hub:
$ ./gradlew build
$ docker build -t leszko/calculator:caching .
$ docker push leszko/calculator:caching
显然,你应该将leszko更改为你的 Docker Hub 账户。
应用程序已经准备好,所以让我们一起在 Kubernetes 上进行测试。
多应用系统测试
我们应该已经在 Kubernetes 上部署了 Hazelcast 缓存服务器。现在,让我们修改 Calculator 应用程序的部署,使用leszko/calculator:caching Docker 镜像。你需要在deployment.yaml文件中修改image:
image: leszko/calculator:caching
然后,应用 Calculator 的部署和服务:
$ kubectl apply -f deployment.yaml
$ kubectl apply -f service.yaml
让我们重复之前的curl操作:
$ curl <NODE-IP>:<NODE-PORT>/sum?a=1\&b=2
第一次执行时,它应该在 3 秒钟内响应,但所有后续调用应该是即时的,这意味着缓存工作正常。
提示
如果你有兴趣,你还可以查看 Calculator Pod 的日志。你应该能看到一些日志,确认应用程序已连接到 Hazelcast 服务器:
Members [1] {
Member [10.16.2.15]:5701 - 3fca574b-bbdb-4c14-ac9d-73c45f56b300
}
你可能已经能看到我们如何在一个多容器系统上执行验收测试了。我们所需要的只是整个系统的验收测试规范。然后,我们可以将完整的系统部署到 Kubernetes 的暂存环境中,并对其运行一系列的验收测试。我们将在第八章,持续交付管道中详细讨论这个问题。
信息
在我们的示例中,依赖服务与缓存相关,这实际上并没有改变我们在第五章,自动化验收测试中创建的功能验收测试。
这就是我们在持续交付的背景下,如何处理部署在 Kubernetes 集群上的依赖应用程序所需要了解的全部内容。然而,在我们结束这一章节之前,让我们简单谈一谈 Kubernetes 的竞争者,也就是其他流行的集群管理系统。
替代的集群管理系统
Kubernetes 不是唯一可以用来集群 Docker 容器的系统。尽管它目前是最受欢迎的选择,但可能会有一些合理的原因去使用不同的软件。让我们一起来了解一下其他的替代方案。
Docker Swarm
Docker Swarm 是一个原生的 Docker 集群系统,它将一组 Docker 主机转化为一个一致的集群,称为 swarm。每个连接到 swarm 的主机都扮演着管理者或工作者的角色(集群中必须至少有一个管理者)。从技术上讲,机器的物理位置并不重要;然而,将所有 Docker 主机放在同一个本地网络中是合理的,否则管理操作(或在多个管理者之间达成共识)可能会花费大量时间。
信息
从 Docker 1.12 开始,Docker Swarm 原生集成进了 Docker 引擎的 swarm 模式。在旧版本中,需要在每个主机上运行 swarm 容器来提供集群功能。
让我们来看一下以下的图表,它展示了 Docker Swarm 的术语和集群过程:

图 6.6 – Docker Swarm
在 Docker Swarm 模式下,一个运行中的镜像被称为 Service,而与之相对的 container 则是在单个 Docker 主机上运行的。一个服务会运行指定数量的 tasks。任务是 swarm 的一个原子调度单元,它包含关于容器及其内部应该运行的命令的信息。Replica 是在节点上运行的每个容器。副本数量是指给定服务的所有容器的期望数量。
我们通过指定服务、Docker 镜像和副本数量开始。管理器会自动将任务分配给工作节点。显然,每个复制的容器都是从相同的 Docker 镜像运行的。在所展示的流程中,Docker Swarm 可以被看作是 Docker 引擎机制之上的一层,负责容器编排。
信息
在本书的第一版中,所有提供的示例都使用了 Docker Swarm。因此,如果 Docker Swarm 是你选择的集群系统,你可能想阅读第一版。
Kubernetes 的另一个替代方案是 Apache Mesos。现在让我们来谈谈它。
Apache Mesos
Apache Mesos 是一个开源调度和集群系统,始于 2009 年在加利福尼亚大学伯克利分校,它比 Docker 更早诞生。它在 CPU、磁盘空间和内存上提供了一个抽象层。Mesos 的一个大优点是它支持任何 Linux 应用程序,但不一定是(Docker)容器。这就是为什么它可以将成千上万台机器组成一个集群,并用于 Docker 容器和其他程序,比如基于 Hadoop 的计算。
让我们看看下面的图示,展示了 Mesos 的架构:

图 6.7 – Apache Mesos
Apache Mesos 与其他集群系统类似,采用主从架构。它使用已在每个节点上安装的节点代理进行通信,并提供两种类型的调度器:
-
Chronos:用于定时任务风格的重复任务
-
Marathon:提供一个 REST API 来编排服务和容器
相较于其他集群系统,Apache Mesos 已经非常成熟,并且已被大量组织采纳,如 Twitter、Uber 和 CERN。
比较特性
Kubernetes、Docker Swarm 和 Mesos 都是集群管理系统的不错选择。它们都是免费的开源软件,并且提供了重要的集群管理功能,如负载均衡、服务发现、分布式存储、故障恢复、监控、秘密管理和滚动更新。它们也都可以在持续交付过程中使用,且没有太大差异。这是因为,在 Docker 化的基础设施中,它们都解决了同一个问题——Docker 容器的集群化。然而,这些系统并不完全相同。让我们看一下以下的表格,展示了它们之间的差异:

显然,除了 Kubernetes、Docker Swarm 和 Apache Mesos,市场上还有其他集群系统。特别是在云平台时代,存在许多流行的特定平台系统,例如 Amazon 弹性容器服务(ECS)。好消息是,如果你理解了 Docker 容器集群化的思路,那么使用其他系统对你来说也不会太难。
总结
在本章中,我们探讨了 Docker 环境的集群方法,允许你设置完整的预生产和生产环境。让我们回顾一下本章的一些关键要点:
-
集群化是一种将一组机器配置成某种方式的方法,在许多方面可以视作一个单一的系统。
-
Kubernetes 是最受欢迎的 Docker 集群系统。
-
Kubernetes 由 Kubernetes 服务器和 Kubernetes 客户端(
kubectl)组成。 -
Kubernetes 服务器可以本地安装(通过 minikube 或 Docker Desktop)、在云平台上安装(AKS、GKE 或 EKS),或者手动安装在一组服务器上。Kubernetes 使用 YAML 配置来部署应用程序。
-
Kubernetes 提供了开箱即用的功能,如自动扩展和滚动更新。
-
Kubernetes 提供 DNS 解析,这在部署由多个依赖应用程序组成的系统时非常有用。
-
支持 Docker 的最流行集群系统有 Kubernetes、Docker Swarm 和 Apache Mesos。
在下一章中,我们将描述持续交付管道中的配置管理部分。
练习
本章我们详细讲解了 Kubernetes 和集群过程。为了增强这部分知识,我们推荐以下练习:
-
在 Kubernetes 集群上运行一个
hello world应用程序:-
hello world应用程序可以与我们在第二章中描述的完全相同,介绍 Docker。 -
使用三个副本部署应用程序。
-
使用
NodePort服务暴露应用程序。 -
向应用程序发出请求(使用
curl)。
-
-
实现一个新特性,Goodbye World!,并使用滚动更新进行部署:
-
这个特性可以作为一个新的端点
/bye添加,始终返回 Goodbye World!。 -
使用新版本标签重建一个 Docker 镜像。
-
使用
RollingUpdate策略和readinessProbe。 -
观察滚动更新过程。
-
向应用程序发出请求(使用
curl)。
-
问题
为了验证你在本章中的知识,请回答以下问题:
-
什么是服务器集群?
-
Kubernetes 控制平面和 Kubernetes 节点有什么区别?
-
请列举至少三个提供 Kubernetes 环境的云平台。
-
Kubernetes 部署和服务有什么区别?
-
Kubernetes 用于扩展部署的命令是什么?
-
请列举至少两个除了 Kubernetes 之外的集群管理系统。
进一步阅读
要了解更多关于 Kubernetes 的信息,请参考以下资源:
-
Kubernetes 官方文档:
kubernetes.io/docs/home/ -
Nigel Poulton: The Kubernetes Book (
leanpub.com/thekubernetesbook)
第三部分 – 部署应用程序
在本节中,我们将介绍如何使用配置管理工具,如 Chef 和 Ansible,在 Docker 生产服务器上发布应用程序,并探讨持续交付过程中的关键部分。我们还将讨论在构建完整的流水线后,如何应对一些更具挑战性的实际场景。
本节涵盖以下章节:
-
第七章,使用 Ansible 的配置管理
-
第八章,持续交付流水线
-
第九章,高级持续交付
第七章:使用 Ansible 进行配置管理
我们已经覆盖了持续交付过程中的两个最关键的阶段:提交阶段和自动化验收测试。我们还解释了如何为应用程序和 Jenkins 代理集群化你的环境。在本章中,我们将重点讨论配置管理,它将虚拟容器化环境与真实服务器基础设施连接起来。
本章将涵盖以下内容:
-
配置管理简介
-
安装 Ansible
-
使用 Ansible
-
使用 Ansible 部署
-
Ansible 与 Docker 和 Kubernetes
-
基础设施即代码简介
-
Terraform 简介
技术要求
要跟随本章的指引,你需要以下硬件/软件:
-
Java 8+
-
Python
-
安装了 Ubuntu 操作系统和 SSH 服务器的远程机器
-
AWS 账户
所有示例和练习解决方案都可以在 GitHub 上找到:github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter07。
本章的代码实践视频可以在 bit.ly/3JkcGLE 查看。
配置管理简介
配置管理是控制配置变化的过程,以确保系统在一段时间内保持完整性。尽管这个术语并不是起源于 IT 行业,但目前它被广泛应用于软件和硬件的领域。在此背景下,它涉及以下几个方面:
-
应用配置:这涉及决定系统如何运行的软件属性,通常以标志或属性文件的形式传递给应用程序,例如数据库地址、文件处理的最大块大小或日志记录级别。这些配置可以在不同的开发阶段应用:构建、打包、部署或运行。
-
服务器配置:这定义了每个服务器上应安装的依赖项,并指定应用程序的编排方式(哪个应用程序运行在哪个服务器上,以及运行多少个实例)。
-
基础设施配置:这涉及服务器基础设施和环境配置。如果你使用的是本地服务器,那么这部分与手动硬件和网络安装有关;如果使用的是云解决方案,那么这部分可以通过基础设施即代码(IaC)方法自动化。
作为示例,我们可以考虑使用 Hazelcast 服务器的计算器 Web 服务。让我们看一下下面的图表,展示了配置管理是如何工作的:

图 7.1 – 示例配置管理
配置管理工具读取配置文件并准备环境。它安装依赖的工具和库,并将应用程序部署到多个实例上。此外,在云部署的情况下,它还可以提供必要的基础设施。
在前面的例子中,基础设施配置指定了所需的服务器,而服务器配置定义了计算器服务应该部署在服务器 1和服务器 2的两个实例上,并且 Hazelcast 服务应该安装在服务器 3上。计算器应用程序配置指定了 Hazelcast 服务器的端口和地址,以便各个服务能够通信。
信息
配置可以根据环境类型(QA、暂存或生产环境)有所不同;例如,服务器地址可能会不同。
配置管理有很多方法,但在我们深入探讨具体解决方案之前,先评论一下一个好的配置管理工具应该具备哪些特性。
优秀配置管理的特点
现代配置管理解决方案应该是什么样的?让我们看看最重要的几个因素:
-
自动化:每个环境都应该是自动可重现的,包括操作系统、网络配置、已安装的软件和已部署的应用程序。在这种方法中,解决生产环境问题意味着仅仅是自动重建环境。更重要的是,它简化了服务器的复制,并确保暂存和生产环境完全相同。
-
版本控制:每一次配置更改都应该被追踪,这样我们就知道是谁、为什么以及何时做出的更改。通常,这意味着将配置保存在源代码仓库中,可以与代码一起存储,也可以存放在单独的位置。推荐使用前者,因为配置属性的生命周期与应用程序本身不同。版本控制还可以帮助解决生产环境问题;配置可以随时回滚到上一个版本,并自动重建环境。唯一的例外是存储凭证和其他敏感信息;这些信息绝对不能被提交到版本控制中。
-
增量更改:应用配置更改不应该需要重建整个环境。相反,配置中的小更改应该只会影响相关的基础设施部分。
-
服务器配置:得益于自动化,添加新服务器应该像将其地址添加到配置中(并执行一个命令)一样快速。
-
安全性:对配置管理工具及其控制下的机器的访问应该得到充分保护。当使用 SSH 协议进行通信时,访问密钥或凭证需要得到妥善保护。
-
简洁性:团队中的每个成员都应该能够阅读配置,进行修改,并将其应用到环境中。配置项本身也应该尽可能简单,且那些不需要改变的配置最好保持硬编码。
在创建配置时,甚至在选择合适的配置管理工具时,记住这些要点非常重要。
配置管理工具概览
在传统意义上,在云时代之前,配置管理指的是在所有服务器都已经到位时开始的过程。因此,起点是一组可以通过 SSH 访问的 IP 地址。为此,最流行的配置管理工具有 Ansible、Puppet 和 Chef。每个工具都是不错的选择;它们都是开源产品,提供免费的基础版本和付费的企业版。它们之间最重要的区别如下:
-
配置语言:Chef 使用 Ruby,Puppet 使用自己的 DSL(基于 Ruby),而 Ansible 使用 YAML。
-
基于代理:Puppet 和 Chef 使用代理进行通信,这意味着每台被管理的服务器都需要安装一个特殊的工具。而 Ansible 则不需要代理,使用标准的 SSH 协议进行通信。
无代理特性是一个显著的优势,因为它意味着不需要在服务器上安装任何东西。而且,Ansible 正在快速崛起,这也是为什么它被选择用于本书的原因。然而,其他工具也可以在持续交付过程中成功使用。
随着云转型的进行,配置管理的意义也在扩大,开始包括所谓的 IaC(基础设施即代码)。作为输入,你不再需要一组 IP 地址,而只需要提供你喜欢的云服务提供商的凭据。然后,IaC 工具可以为你配置服务器。此外,每个云服务提供商都提供一系列服务,因此在许多情况下,你甚至不需要配置裸机服务器,而是直接使用云服务。虽然你仍然可以使用 Ansible、Puppet 或 Chef 达到这个目的,但有一个专门针对 IaC 用例的工具叫做 Terraform。
我们首先描述使用 Ansible 的经典配置管理方法,然后介绍使用 Terraform 的 IaC 解决方案。
安装 Ansible
Ansible 是一个开源的无代理自动化引擎,用于软件配置、管理和应用部署。它的第一次发布是在 2012 年,基础版本对个人和商业使用都是免费的。企业版叫做 Ansible Tower,它提供了 GUI 管理和仪表盘、REST API、基于角色的访问控制以及其他一些功能。
我们将分别介绍安装过程以及 Ansible 如何单独使用,或与 Docker 一起使用的描述。
Ansible 服务器要求
Ansible 使用 SSH 协议进行通信,并且对它管理的机器没有特殊要求。也没有中央主服务器,因此只需在任何地方安装 Ansible 客户端工具;然后我们可以使用它来管理整个基础设施。
信息
被管理的机器的唯一要求是必须安装 Python 工具(显然,也需要安装 SSH 服务器)。然而,这些工具几乎在任何服务器上默认都能使用。
Ansible 安装
安装说明将根据操作系统有所不同。以 Ubuntu 为例,只需运行以下命令:
$ sudo apt-get install software-properties-common
$ sudo apt-add-repository ppa:ansible/ansible
$ sudo apt-get update
$ sudo apt-get install ansible
信息
你可以在官方 Ansible 页面找到所有操作系统的安装指南,地址为 docs.ansible.com/ansible/latest/installation_guide/intro_installation.html。
安装过程完成后,我们可以执行 ansible 命令来检查是否一切安装成功:
$ ansible –version
ansible [core 2.12.2]
config file = /etc/ansible/ansible.cfg
...
使用 Ansible
为了使用 Ansible,我们首先需要定义库存,它表示可用的资源。然后,我们将能够执行单个命令或使用 Ansible 剧本定义一组任务。
创建库存
库存是 Ansible 管理的所有服务器的列表。每个服务器只需要安装 Python 解释器和 SSH 服务器。默认情况下,Ansible 假定使用 SSH 密钥进行身份验证;然而,也可以通过在 Ansible 命令中添加 --ask-pass 选项来使用用户名和密码。
提示
可以使用 ssh-keygen 工具生成 SSH 密钥,通常它们存储在 ~/.ssh 目录中。
库存默认定义在 /etc/ansible/hosts 文件中(但可以通过 –i 参数定义位置),它的结构如下:
[group_name]
<server1_address>
<server2_address>
...
提示
库存语法还支持服务器的范围,例如 www[01-22].company.com。如果 SSH 端口不是 22(默认端口),还应指定端口号。
库存文件中可以包含多个组。例如,让我们在一个服务器组中定义两台机器:
[webservers]
192.168.64.12
192.168.64.13
我们还可以创建带有服务器别名的配置,并指定远程用户:
[webservers]
web1 ansible_host=192.168.64.12 ansible_user=ubuntu
web2 ansible_host=192.168.64.13 ansible_user=ubuntu
上面的文件定义了一个名为 webservers 的组,该组包含两台服务器。Ansible 客户端将以 ubuntu 用户身份登录这两台服务器。当我们创建好库存后,让我们了解如何使用它在多台服务器上执行相同的命令。
信息
Ansible 提供了从云提供商(例如,Amazon EC2/Eucalyptus)、LDAP 或 Cobbler 动态拉取库存的功能。关于动态库存的更多信息,请参阅 docs.ansible.com/ansible/latest/user_guide/intro_dynamic_inventory.html。
临时命令
我们可以运行的最简单的命令是在所有服务器上进行 ping 测试。假设我们有两台远程机器(192.168.64.12 和 192.168.64.13),它们配置了 SSH 服务器,并且有一个清单文件(如上一节所定义),我们可以执行 ping 命令:
$ ansible all -m ping
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
我们使用了 -m <module_name> 选项,它允许指定应该在远程主机上执行的模块。结果是成功的,这意味着服务器是可达的,且身份验证已正确配置。
注意,我们使用了 all,因此所有服务器都会被处理,但我们也可以通过 webservers 组名或单个主机别名来调用它们。作为第二个例子,我们只在其中一台服务器上执行 shell 命令:
$ ansible web1 -a "/bin/echo hello"
web1 | CHANGED | rc=0 >>
hello
-a <arguments> 选项指定传递给 Ansible 模块的参数。在这个例子中,我们没有指定模块,因此这些参数作为一个 shell Unix 命令执行。结果是成功的,并且打印出了 hello。
提示
如果 ansible 命令是第一次连接到服务器(或者服务器被重新安装),则会出现密钥确认提示信息(即 SSH 消息,当主机不在 known_hosts 文件中时)。由于这可能会中断自动化脚本,我们可以通过在 /etc/ansible/ansible.cfg 文件中取消注释 host_key_checking = False 来禁用提示信息,或者通过设置环境变量 ANSIBLE_HOST_KEY_CHECKING=False 来实现。
在最简单的形式中,Ansible 临时命令的语法如下所示:
$ ansible <target> -m <module_name> -a <module_arguments>
临时命令的目的是在不需要重复执行的情况下快速完成某些任务。例如,我们可能想检查某台服务器是否在线,或者在圣诞假期期间关闭所有机器。这个机制可以看作是在一组机器上执行命令,同时利用模块提供的额外语法简化。然而,Ansible 自动化的真正威力在于剧本。
剧本
Ansible 剧本 是一个配置文件,用来描述如何配置服务器。它提供了一种定义应在每台机器上执行的任务顺序的方式。剧本采用 YAML 配置语言表示,使其既易于阅读又易于理解。我们先从一个示例剧本开始,然后看看如何使用它。
定义一个剧本
一个剧本由一个或多个剧集(play)组成。每个剧集包含一个主机组名称、要执行的任务和配置细节(例如,远程用户名或访问权限)。一个示例剧本可能如下所示:
---
- hosts: web1
become: yes
become_method: sudo
tasks:
- name: ensure apache is at the latest version
apt: name=apache2 state=latest
- name: ensure apache is running
service: name=apache2 state=started enabled=yes
这个配置包含一个剧集,它执行以下操作:
-
仅在
web1主机上执行 -
使用
sudo命令获得 root 权限 -
执行两个任务:
-
aptAnsible 模块(通过两个参数调用,name=apache2和state=latest)检查服务器上是否安装了apache2软件包,如果没有,它将使用apt-get工具进行安装。 -
serviceAnsible 模块(通过三个参数name=apache2、state=started和enabled=yes调用)检查apache2Unix 服务是否已启动,如果未启动,它将使用service命令启动它。
-
请注意,每个任务都有一个人类可读的名称,该名称用于控制台输出,其中 apt 和 service 是 Ansible 模块,而 name=apache2、state=latest 和 state=started 是模块参数。在使用临时命令时,您已经看到了 Ansible 模块和参数。在前面的 playbook 中,我们只定义了一个 play,但可以有多个 play,每个 play 可以与不同的主机组相关联。
信息
请注意,由于我们使用了 apt Ansible 模块,因此该 playbook 仅适用于 Debian/Ubuntu 服务器。
例如,我们可以在清单中定义两个服务器组:database 和 webservers。然后,在 playbook 中,我们可以指定应该在所有数据库托管机器上执行的任务,以及应该在所有 Web 服务器上执行的一些不同任务。通过使用一个命令,我们可以设置整个环境。
执行 playbook
当 playbook.yml 定义好后,我们可以使用 ansible-playbook 命令来执行它:
$ ansible-playbook playbook.yml
PLAY [web1] ***************************************************************
TASK [setup] **************************************************************
ok: [web1]
TASK [ensure apache is at the latest version] *****************************
changed: [web1]
TASK [ensure apache is running] *******************************************
ok: [web1]
PLAY RECAP ****************************************************************
web1: ok=3 changed=1 unreachable=0 failed=0
提示
如果服务器需要输入 sudo 命令的密码,则需要在 ansible-playbook 命令中添加 --ask-sudo-pass 选项。也可以通过设置额外变量 -e ansible_become_pass=<sudo_password> 来传递 sudo 密码(如果需要)。
playbook 配置已执行,因此 apache2 工具已安装并启动。请注意,如果任务已对服务器做出更改,则标记为 changed。相反,如果没有任何更改,任务将标记为 ok。
提示
可以通过使用 -f <num_of_threads> 选项并行运行任务。
playbook 的幂等性
我们可以再次执行该命令,如下所示:
$ ansible-playbook playbook.yml
PLAY [web1] ***************************************************************
TASK [setup] **************************************************************
ok: [web1]
TASK [ensure apache is at the latest version] *****************************
ok: [web1]
TASK [ensure apache is running] *******************************************
ok: [web1]
PLAY RECAP ****************************************************************
web1: ok=3 changed=0 unreachable=0 failed=0
请注意,输出略有不同。这次命令没有更改服务器上的任何内容。这是因为每个 Ansible 模块设计为幂等的。换句话说,多次执行相同的模块,效果应与只执行一次相同。
实现幂等性最简单的方法是首先检查任务是否已执行过,只有在未执行时才执行该任务。幂等性是一个强大的功能,我们应该始终以这种方式编写 Ansible 任务。
如果所有任务都是幂等的,我们可以执行任意次数。在这种情况下,我们可以将 playbook 看作是远程机器所需状态的描述。然后,ansible-playbook 命令负责将机器(或机器组)带入该状态。
处理程序
有些操作应该仅在其他任务发生变化时执行。例如,假设你将配置文件复制到远程机器,并且只有在配置文件发生变化时,Apache 服务器才应该重新启动。我们该如何处理这种情况呢?
Ansible 提供了一个事件驱动机制来通知变化。为了使用它,我们需要知道两个关键字:
-
handlers:指定在通知时执行的任务。 -
notify:指定应该执行的处理器。
让我们看一个示例,说明如何将配置复制到服务器,并且只有在配置发生变化时才重新启动 Apache:
tasks:
- name: copy configuration
copy:
src: foo.conf
dest: /etc/foo.conf
notify:
- restart apache
handlers:
- name: restart apache
service:
name: apache2
state: restarted
现在,我们可以创建foo.conf文件并运行ansible-playbook命令:
$ touch foo.conf
$ ansible-playbook playbook.yml
...
TASK [copy configuration] ************************************************
changed: [web1]
RUNNING HANDLER [restart apache] *****************************************
changed: [web1]
PLAY RECAP ***************************************************************
web1: ok=5 changed=2 unreachable=0 failed=0
信息
处理器总是在 play 的最后执行,并且只会执行一次,即使被多个任务触发。
Ansible 已经复制了文件并重新启动了 Apache 服务器。重要的是要理解,如果我们再次运行命令,什么也不会发生。但是,如果我们更改了foo.conf文件的内容,然后运行ansible-playbook命令,文件会再次被复制(并且 Apache 服务器会被重新启动):
$ echo "something" > foo.conf
$ ansible-playbook playbook.yml
...
TASK [copy configuration] *************************************************
changed: [web1]
RUNNING HANDLER [restart apache] ******************************************
changed: [web1]
PLAY RECAP ****************************************************************
web1: ok=5 changed=2 unreachable=0 failed=0
我们使用了copy模块,它足够智能,可以检测文件是否已更改,然后在服务器上进行相应的修改。
提示
Ansible 中也有发布-订阅机制。使用它意味着将一个主题分配给多个处理器。然后,一个任务通过通知该主题来执行所有相关的处理器。
变量
虽然 Ansible 自动化使得多个主机的操作变得相同且可重复,但不可避免的是,服务器可能需要一些差异。例如,考虑应用端口号。根据机器的不同,它可能会有所不同。幸运的是,Ansible 提供了变量,这是一种很好的机制来处理服务器之间的差异。让我们创建一个新的 playbook 并定义一个变量:
---
- hosts: web1
vars:
http_port: 8080
配置定义了http_port变量,其值为8080。现在,我们可以通过使用Jinja2语法来使用它:
tasks:
- name: print port number
debug:
msg: "Port number: {{ http_port }}"
提示
Jinja2语言不仅仅允许获取变量。我们可以使用它来创建条件、循环等更多功能。你可以在 Jinja 页面找到更多详细信息:jinja.palletsprojects.com/。
debug模块在执行时打印消息。如果我们运行ansible-playbook命令,我们可以看到变量的使用:
$ ansible-playbook playbook.yml
...
TASK [print port number] **************************************************
ok: [web1] => {
"msg": "Port number: 8080"
}
除了用户定义的变量外,还有预定义的自动变量。例如,hostvars变量存储了一个映射,包含了所有主机的相关信息。使用 Jinja2 语法,我们可以迭代并打印所有主机的 IP 地址:
---
- hosts: web1
tasks:
- name: print IP address
debug:
msg: "{% for host in groups['all'] %} {{
hostvars[host]['ansible_host'] }} {% endfor %}"
然后,我们可以执行ansible-playbook命令:
$ ansible-playbook playbook.yml
...
TASK [print IP address] **************************************************
ok: [web1] => {
"msg": " 192.168.64.12 192.168.64.13 "
}
请注意,通过使用 Jinja2 语言,我们可以在 Ansible playbook 文件中指定流程控制操作。
角色
我们可以通过使用 Ansible playbooks 在远程服务器上安装任何工具。假设我们想要在服务器上安装 MySQL。我们可以轻松地准备一个类似于 apache2 包的 playbook。然而,如果仔细想想,MySQL 服务器是一个相当常见的案例,肯定有人已经为此准备了 playbook,那么我们或许可以直接重用它。这就是 Ansible 角色和 Ansible Galaxy 发挥作用的地方。
了解角色
Ansible 角色是一个结构良好的 playbook 部分,准备好可以被包含到 playbook 中。角色是独立的单元,始终具有以下目录结构:
templates/
tasks/
handlers/
vars/
defaults/
meta/
信息
你可以在 Ansible 官方页面上了解更多关于角色以及每个目录含义的内容,网址是 docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html。
在每个目录中,我们可以定义 main.yml 文件,其中包含可以包含到 playbook.yml 文件中的 playbook 部分。以 MySQL 为例,GitHub 上有一个角色定义在 github.com/geerlingguy/ansible-role-mysql 上。这个仓库包含可以在我们的 playbook 中使用的任务模板。让我们来看一下 tasks/setup-Debian.yml 文件的部分内容,它在 Ubuntu/Debian 中安装 mysql 包:
...
- name: Ensure MySQL Python libraries are installed.
apt:
name: "{{ mysql_python_package_debian }}"
state: present
- name: Ensure MySQL packages are installed.
apt:
name: "{{ mysql_packages }}"
state: present
register: deb_mysql_install_packages
...
这只是 tasks/main.yml 文件中定义的任务之一。其他任务负责将 MySQL 安装到其他操作系统上。
如果我们使用这个角色来安装服务器上的 MySQL,只需创建以下 playbook.yml 即可:
---
- hosts: all
become: yes
become_method: sudo
roles:
- role: geerlingguy.mysql
这样的配置会使用 geerlingguy.mysql 角色在所有服务器上安装 MySQL 数据库。
Ansible Galaxy
Ansible Galaxy 对 Ansible 就像 Docker Hub 对 Docker 一样——它存储常见的角色,供他人重用。你可以在 Ansible Galaxy 页面浏览可用的角色,网址是 galaxy.ansible.com/。
要从 Ansible Galaxy 安装一个角色,我们可以使用 ansible-galaxy 命令:
$ ansible-galaxy install username.role_name
该命令会自动下载角色。以 MySQL 为例,我们可以通过执行以下命令下载该角色:
$ ansible-galaxy install geerlingguy.mysql
该命令会下载 mysql 角色,之后可以在 playbook 文件中使用它。如果你按照前面片段中描述的方式定义了 playbook.yml,则以下命令会将 MySQL 安装到所有服务器中:
$ ansible-playbook playbook.yml
现在你已经了解了 Ansible 的基础知识,让我们来看一下如何使用它来部署我们自己的应用程序。
使用 Ansible 部署
我们已经涵盖了 Ansible 的最基本特性。现在,让我们暂时忘记 Docker、Kubernetes 和我们至今学到的大多数内容。让我们仅使用 Ansible 配置一个完整的部署步骤。我们将在一台服务器上运行计算器服务,在另一台服务器上运行 Hazelcast 服务。
安装 Hazelcast
我们可以在新的 playbook 中指定一个任务。让我们创建一个playbook.yml文件,内容如下:
---
- hosts: web1
become: yes
become_method: sudo
tasks:
- name: ensure Java Runtime Environment is installed
apt:
name: default-jre
state: present
update_cache: yes
- name: create Hazelcast directory
file:
path: /var/hazelcast
state: directory
- name: download Hazelcast
get_url:
url: https://repo1.maven.org/maven2/com/hazelcast/hazelcast/5.0.2/hazelcast-5.0.2.jar
dest: /var/hazelcast/hazelcast.jar
mode: a+r
- name: copy Hazelcast starting script
copy:
src: hazelcast.sh
dest: /var/hazelcast/hazelcast.sh
mode: a+x
- name: configure Hazelcast as a service
file:
path: /etc/init.d/hazelcast
state: link
force: yes
src: /var/hazelcast/hazelcast.sh
- name: start Hazelcast
service:
name: hazelcast
enabled: yes
state: started
配置将在web1服务器上执行,并且需要 root 权限。它执行几个步骤,最终将完成 Hazelcast 服务器的安装。让我们逐步查看我们定义的内容:
-
准备环境:此任务确保 Java 运行时环境已安装。基本上,它为服务器环境做准备,以确保 Hazelcast 能够拥有所有必要的依赖项。对于更复杂的应用程序,依赖工具和库的列表可能会更长。
-
下载 Hazelcast 工具:Hazelcast 以 JAR 形式提供,可以从互联网上下载。我们硬编码了版本,但在实际情况中,最好将其提取到一个变量中。
-
/etc/init.d/目录。 -
启动 Hazelcast 服务:当 Hazelcast 被配置为 Unix 服务时,我们可以以标准方式启动它。
在同一目录下,让我们创建hazelcast.sh,这是一个负责将 Hazelcast 作为 Unix 服务运行的脚本(如下所示):
#!/bin/bash
### BEGIN INIT INFO
# Provides: hazelcast
# Required-Start: $remote_fs $syslog
# Required-Stop: $remote_fs $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Hazelcast server
### END INIT INFO
java -cp /var/hazelcast/hazelcast.jar com.hazelcast.core.server.HazelcastMemberStarter &
完成此步骤后,我们可以执行 playbook 并在web1服务器上启动 Hazelcast。然而,我们先创建第二个任务来启动计算器服务,然后一起运行。
部署网页服务
我们将通过两步准备calculator网页服务:
-
更改 Hazelcast 主机地址。
-
将计算器部署添加到 playbook 中。
更改 Hazelcast 主机地址
之前,我们硬编码了 Hazelcast 主机地址为hazelcast,所以现在我们应该将它更改为src/main/java/com/leszko/calculator/CalculatorApplication.java文件中的192.168.64.12(与我们的清单中web1的 IP 地址相同)。
提示
在实际项目中,应用程序的属性通常保存在properties文件中。例如,对于 Spring Boot 框架,它是一个名为application.properties或application.yml的文件。然后,我们可以使用 Ansible 更改它们,从而更加灵活。
将计算器部署添加到 playbook 中
最后,我们可以将部署配置作为新任务添加到playbook.yml文件中。它与我们为 Hazelcast 创建的配置类似:
- hosts: web2
become: yes
become_method: sudo
tasks:
- name: ensure Java Runtime Environment is installed
apt:
name: default-jre
state: present
update_cache: yes
- name: create directory for Calculator
file:
path: /var/calculator
state: directory
- name: copy Calculator starting script
copy:
src: calculator.sh
dest: /var/calculator/calculator.sh
mode: a+x
- name: configure Calculator as a service
file:
path: /etc/init.d/calculator
state: link
force: yes
src: /var/calculator/calculator.sh
- name: copy Calculator
copy:
src: build/libs/calculator-0.0.1-SNAPSHOT.jar
dest: /var/calculator/calculator.jar
mode: a+x
notify:
- restart Calculator
handlers:
- name: restart Calculator
service:
name: calculator
enabled: yes
state: restarted
配置与我们在 Hazelcast 案例中看到的非常相似。唯一的不同之处是,这次我们不会从互联网上下载 JAR 文件,而是从我们的文件系统中复制它。另一个不同之处是,我们使用 Ansible 处理器重新启动服务。这样做是因为我们希望每次复制新版本时重新启动计算器。
在我们一起开始之前,我们还需要定义calculator.sh:
#!/bin/bash
### BEGIN INIT INFO
# Provides: calculator
# Required-Start: $remote_fs $syslog
# Required-Stop: $remote_fs $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Calculator application
### END INIT INFO
java -jar /var/calculator/calculator.jar &
当一切准备就绪时,我们将使用此配置来启动完整的系统。
执行部署
像往常一样,我们可以使用ansible-playbook命令执行 playbook。在此之前,我们需要使用 Gradle 构建计算器项目:
$ ./gradlew build
$ ansible-playbook playbook.yml
部署成功后,服务应该可以使用,我们可以通过http://192.168.64.13:8080/sum?a=1&b=2检查其是否正常工作(IP 地址应该与我们的库存中标记为web2的 IP 地址相同)。如预期,它应该返回3作为输出。
请注意,我们通过执行一条命令配置了整个环境。更重要的是,如果我们需要扩展服务,只需将新服务器添加到库存中并重新运行ansible-playbook命令。另外,注意我们可以将其打包为 Ansible 角色并上传到 GitHub,从此每个人都可以在他们的 Ubuntu 服务器上运行相同的系统。这就是 Ansible 的强大之处!
我们已经展示了如何使用 Ansible 进行环境配置和应用部署。接下来的步骤是将 Ansible 与 Docker 和 Kubernetes 结合使用。
Ansible 与 Docker 和 Kubernetes
正如你可能已经注意到的,Ansible 和 Docker(以及 Kubernetes)都解决了类似的软件部署问题:
-
环境配置:Ansible 和 Docker 都提供了配置环境的方法;然而,它们采用了不同的手段。Ansible 通过脚本(封装在 Ansible 模块内)来实现,而 Docker 则将整个环境封装在一个容器内。
-
依赖关系:Ansible 提供了一种方式,在相同或不同的主机上部署不同的服务,并允许它们一起部署。Kubernetes 具有类似的功能,可以同时运行多个容器。
-
可扩展性:Ansible 通过提供库存和主机组来帮助扩展服务。Kubernetes 具有类似的功能,能够自动增加或减少运行中的容器数量。
-
playbook.yml。在 Docker 和 Kubernetes 的情况下,我们有用于环境的Dockerfile和用于依赖关系与扩展的deployment.yml。 -
简易性:这两种工具都非常简单易用,通过配置文件和仅仅一条命令执行就能设置整个运行环境。
如果我们对比这些工具,Docker 做得更多,因为它提供了隔离性、可移植性以及某种程度的安全性。我们甚至可以设想,使用 Docker/Kubernetes 而不依赖任何其他配置管理工具。那么,我们为什么还需要 Ansible 呢?
Ansible 的优点
Ansible 可能显得有些冗余;然而,它为交付过程带来了额外的好处,具体如下:
-
Docker 环境:Docker/Kubernetes 主机本身需要配置和管理。每个容器最终都在 Linux 机器上运行,这些机器需要进行内核补丁、Docker 引擎更新和网络配置等工作。而且,可能有不同的服务器机器使用不同的 Linux 发行版,Ansible 的责任是确保一切正常运行。
-
非 Docker 化应用:并非所有内容都运行在容器中。如果部分基础设施已经容器化,而部分则采用传统方式或部署在云中,那么 Ansible 可以通过执行剧本配置文件来管理这一切。没有将应用程序作为容器运行可能有不同的原因;例如,性能、安全性、特定的硬件要求或与遗留软件的兼容性。
-
清单:Ansible 提供了一种非常友好的方式来通过清单管理物理基础设施,清单中存储着所有服务器的信息。它还可以将物理基础设施分为不同的环境——生产、测试和开发。
-
云资源配置:Ansible 可以负责配置 Kubernetes 集群或在云中安装 Kubernetes;例如,我们可以设想一种集成测试,其中第一步是在 Google Cloud Platform(GCP)上创建 Kubernetes 集群(只有在此之后,我们才能部署整个应用程序并执行测试过程)。
-
GUI:Ansible 提供了图形化管理工具(商业版 Ansible Tower 和开源版 AWX),旨在提升基础设施管理的体验。
-
改进测试过程:Ansible 可以帮助进行集成测试和验收测试,因为它可以封装测试脚本。
我们可以把 Ansible 看作是负责基础设施的工具,而 Docker 和 Kubernetes 是负责环境配置和集群的工具。下图展示了一个概览:

图 7.2 – Ansible 作为基础设施管理器
Ansible 管理基础设施:Kubernetes 集群、Docker 服务器、Docker 镜像库、没有 Docker 的服务器以及 云服务提供商。它还负责服务器的物理位置。通过使用清单主机组,它可以将 Web 服务与靠近其地理位置的数据库连接。
让我们来看一下如何使用 Ansible 在服务器上安装 Docker 并部署一个示例应用程序。
Ansible Docker 执行剧本
Ansible 与 Docker 平稳集成,因为它提供了一套专门为 Docker 设计的模块。如果我们为基于 Docker 的部署创建 Ansible 执行剧本,那么第一项任务是确保 Docker 引擎已安装在每台机器上。然后,它应该使用 Docker 运行一个容器。
首先,让我们在 Ubuntu 服务器上安装 Docker。
安装 Docker
我们可以通过以下任务在 Ansible 执行剧本中安装 Docker 引擎:
- hosts: web1
become: yes
become_method: sudo
tasks:
- name: Install required packages
apt:
name: "{{ item }}"
state: latest
update_cache: yes
loop:
- apt-transport-https
- ca-certificates
- curl
- software-properties-common
- python3-pip
- virtualenv
- python3-setuptools
- name: Add Docker GPG apt Key
apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker Repository
apt_repository:
repo: deb https://download.docker.com/linux/ubuntu focal stable
state: present
- name: Update apt and install docker-ce
apt:
name: docker-ce
state: latest
update_cache: yes
- name: Install Docker Module for Python
pip:
name: docker
信息
每个操作系统的执行脚本略有不同。这里展示的是用于 Ubuntu 20.04 的版本。
该配置安装了 Docker 和 Docker Python 工具(Ansible 所需)。请注意,我们使用了新的 Ansible 语法 loop,以便让剧本更加简洁。
安装 Docker 后,我们可以添加一个任务来运行 Docker 容器。
运行 Docker 容器
运行 Docker 容器通过使用docker_container模块来完成,具体如下所示:
- hosts: web1
become: yes
become_method: sudo
tasks:
- name: run Hazelcast container
community.docker.docker_container:
name: hazelcast
image: hazelcast/hazelcast
state: started
exposed_ports:
- 5701
信息
您可以阅读更多有关docker_container模块的所有选项,网址为docs.ansible.com/ansible/latest/collections/community/docker/docker_container_module.html。
在前面介绍的两个 playbook 中,我们使用 Docker 配置了 Hazelcast 服务器。请注意,这非常方便,因为我们可以在多个(Ubuntu)服务器上运行相同的 playbook。
现在,让我们来看看 Ansible 如何帮助 Kubernetes。
Ansible Kubernetes Playbook
类似于 Docker,Ansible 也可以帮助管理 Kubernetes。当您的 Kubernetes 集群配置完成后,您可以使用 Ansible 的k8s模块创建 Kubernetes 资源。以下是一个示例 Ansible 任务,用于在 Kubernetes 中创建命名空间:
- name: Create namespace
kubernetes.core.k8s:
name: my-namespace
api_version: v1
kind: Namespace
state: present
这里的配置确保在 Kubernetes 集群中创建一个名为my-namespace的命名空间。
信息
您可以在docs.ansible.com/ansible/latest/collections/kubernetes/core/k8s_module.html找到更多关于 Ansible k8s模块的信息。
我们已经介绍了使用 Ansible 进行配置管理,这在您的部署环境由裸金属服务器构成时是一个完美的方案。您也可以将 Ansible 与云服务提供商一起使用,并且有许多专门为此目的设计的模块。例如,amazon.aws.ec2_instance 让您可以创建和管理 AWS EC2 实例。然而,云计算有更好的解决方案。让我们看看它们是什么,以及如何使用它们。
引入 IaC
IaC(基础设施即代码)是管理和提供计算资源的过程,取代了物理硬件配置。它通常与云计算方法相关,您可以以可编程的方式请求所需的基础设施。
管理计算机基础设施一直是一个艰巨、耗时且容易出错的任务。您必须手动放置硬件、连接网络、安装操作系统并维护其更新。随着云计算的出现,一切变得简单;您只需要写几个命令或在 Web UI 中点击几下即可。IaC 更进一步,它允许您以声明的方式指定所需的基础设施。为了更好地理解它,让我们看看下面的图示:

图 7.3 – IaC
你准备好基础设施的声明性描述,例如,你需要三台服务器,一个 Kubernetes 集群和一个负载均衡器。然后,你将这个配置传递给一个工具,该工具使用云特定的 API(例如,AWS API),以确保基础设施符合要求。请注意,你应将基础设施配置存储在源代码仓库中,并且可以从相同的配置创建多个相同的环境。
你可以看到,IaC 的概念与配置管理非常相似;然而,配置管理确保你的软件按指定方式配置,而 IaC 确保你的基础设施按指定方式配置。
现在,让我们来看看使用 IaC 的好处。
IaC 的好处
基础设施为所有 DevOps 活动带来了许多好处。我们来逐一了解其中最重要的一些:
-
速度:创建整个基础设施不过是运行一个脚本,这大大缩短了我们可以开始部署应用程序的时间。
-
成本降低:自动化基础设施的提供减少了操作服务器环境所需的 DevOps 团队成员数量。
-
一致性:IaC 配置文件成为唯一的真实来源,因此它们确保每个创建的环境都是完全相同的。
-
风险降低:基础设施配置存储在源代码仓库中,并遵循标准的代码审查流程,这降低了出错的概率。
-
协作:多人可以共享代码并在相同的配置文件上工作,从而提高工作效率。
我希望这些要点已经让你相信 IaC 是一种很好的方法。现在,让我们来看看你可以使用的 IaC 工具。
IaC 工具
在谈到 IaC 时,有许多工具可以使用。选择取决于你所使用的云服务提供商和你自己的偏好。我们来看看最流行的解决方案:
-
Terraform:市场上最流行的 IaC 工具。它是开源的,使用基于插件的模块,称为 providers,来支持不同的基础设施 API。目前,已有超过 1,000 个 Terraform providers,包括 AWS、Azure、GCP 和 DigitalOcean。
-
云服务提供商特定:每个主要的云服务提供商都有自己的 IaC 工具:
-
AWS CloudFormation:一项由亚马逊提供的服务,允许你以 YAML 或 JSON 模板文件的形式指定 AWS 资源。
-
Azure 资源管理器(ARM):一项微软 Azure 服务,允许你使用 ARM 模板文件创建和管理 Azure 资源。
-
Google Cloud 部署管理器:一项由谷歌提供的服务,允许你使用 YAML 文件管理 Google Cloud Platform 资源。
-
-
通用配置管理:Ansible、Chef 和 Puppet 都提供专门的模块来为最流行的云解决方案提供基础设施。
-
Pulumi:一个非常灵活的工具,允许你使用通用编程语言(如 JavaScript、Python、Go 或 C#)来指定所需的基础设施。
-
Vagrant:通常与虚拟机管理相关,提供了一些插件,利用 AWS 等云提供商来配置基础设施。
在所有提到的解决方案中,Terraform 是最受欢迎的。因此,我们将花一些时间来理解它是如何工作的。
Terraform 介绍
Terraform 是一个由 HashiCorp 创建并维护的开源工具。它允许你以人类可读的配置文件形式指定你的基础设施。与 Ansible 类似,它以声明式的方式工作,这意味着你指定期望的结果,而 Terraform 确保你的环境按照指定方式创建。
在我们深入具体示例之前,先花点时间了解一下 Terraform 是如何工作的。
理解 Terraform
Terraform 读取配置文件并相应地调整云资源。让我们看一下下面的图示,展示了这个过程:

图 7.4 – Terraform 工作流
用户创建 配置文件 并启动 Terraform 工具。然后,Terraform 检查 Terraform 状态 并使用 Terraform 提供者 将声明式配置文件转换为针对 目标 API 的请求,这个 API 是特定于给定云提供商的。举例来说,我们可以考虑一个定义三个 AWS EC2 实例的配置文件。Terraform 使用 AWS 提供者,执行请求到 AWS API,确保创建了三个 AWS EC2 实例。
信息
有超过 1,000 个 Terraform 提供者可用,你可以通过 Terraform 注册表浏览它们,网址是 registry.terraform.io/。
Terraform 工作流始终包括三个阶段:
-
Write:用户将云资源定义为配置文件。
-
Plan:Terraform 比较配置文件与当前状态,并准备执行计划。
-
Apply:用户批准计划,Terraform 使用云 API 执行计划中的操作。
这种方法非常方便,因为在计划阶段,我们可以始终检查 Terraform 在实际应用更改之前,将会对我们的基础设施进行哪些更改。
现在我们理解了 Terraform 背后的理念,让我们从 Terraform 的安装过程开始,看看它在实际中是如何工作的。
安装 Terraform
安装过程取决于操作系统。以 Ubuntu 为例,你可以执行以下命令:
$ curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
$ sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
$ sudo apt-get update
$ sudo apt-get install terraform
信息
你可以在 Terraform 官方网站上找到所有操作系统的安装指南,网址是 www.terraform.io/downloads。
安装过程完成后,我们可以验证terraform命令是否正常工作:
$ terraform version
Terraform v1.1.5
在 Terraform 配置完成后,我们可以进入 Terraform 示例。
使用 Terraform
作为示例,让我们使用 Terraform 来配置一个 AWS EC2 实例。为此,我们需要首先配置 AWS。
配置 AWS
要从你的机器访问 AWS,你需要以下内容:
-
一个 AWS 账户
-
已安装 AWS CLI
信息
你可以在
aws.amazon.com/free创建一个免费的 AWS 账户。要安装 AWS CLI 工具,请查看以下说明:docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html。
让我们使用以下命令配置 AWS CLI:
$ aws configure
AWS 命令会提示你输入 AWS 访问密钥 ID 和 AWS 秘密访问密钥。
信息
有关如何创建 AWS 访问密钥对的说明,请访问 docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html#cli-configure-quickstart-creds。
完成这些步骤后,你的 AWS 账户访问已配置,我们可以开始使用 Terraform 进行操作。
编写 Terraform 配置
在一个新目录中,让我们创建 main.tf 文件并添加以下内容:
terraform {
required_version = ">= 1.1" (1)
required_providers {
aws = { (2)
source = "hashicorp/aws"
version = "~> 3.74"
}
}
}
provider "aws" {
profile = "default" (3)
region = "us-east-1" (4)
}
resource "aws_instance" "my_instance" { (5)
ami = "ami-04505e74c0741db8d" (6)
instance_type = "t2.micro" (7)
}
在上述配置中,我们定义了以下部分:
-
Terraform 工具版本应至少为
1.1。 -
配置使用
hashicorp/aws提供程序:-
提供程序版本需要至少为
3.74。 -
Terraform 将自动从 Terraform Registry 下载它。
-
-
aws提供程序的凭证存储在 AWS CLI 创建的default位置中。 -
提供程序在
us-east-1区域创建所有资源。 -
提供程序创建一个名为
my_instance的aws_instance(AWS EC2 实例)。 -
一个 EC2 实例是从
ami-04505e74c0741db8d(us-east-1区域中的 Ubuntu 20.04 LTS)创建的。 -
实例类型为
t2.micro。
你可以看到整个配置是声明式的。换句话说,我们定义我们想要的目标,而不是如何实现它的算法。
当配置创建时,我们需要从 Terraform Registry 下载所需的提供程序。
初始化 Terraform 配置
让我们执行以下命令:
$ terraform init
该命令会下载所有必需的提供程序并将它们存储在 .terraform 目录中。现在,让我们最终应用 Terraform 配置。
应用 Terraform 配置
在进行任何 Terraform 更改之前,最好先执行 terraform plan 来检查我们即将面临的更改:
$ terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
...
我们可以看到,通过应用配置,我们将在基础设施中创建一个资源,正如控制台输出所描述的那样。
现在,让我们应用我们的配置:
$ terraform apply
...
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
在确认更改后,你应该会看到大量日志,最后的 Apply complete! 消息,表示我们的基础设施已经创建完成。
现在,让我们验证一切是否如预期。
验证基础设施
从 Terraform 的角度来看,我们可以执行以下命令来查看我们基础设施的状态:
$ terraform show
# aws_instance.my_instance:
resource "aws_instance" "my_instance" {
...
}
这将打印出我们所创建资源的所有信息。
信息
和 Ansible 一样,Terraform 倾向于进行幂等操作。这就是为什么,如果我们再次执行 terraform plan 或 terraform apply,什么也不会改变。你只会看到以下消息:No changes. Your infrastructure matches the configuration。
我们现在可以验证我们的 AWS EC2 实例是否真正创建了。由于我们已经安装了 AWS CLI,我们可以使用以下命令检查:
$ aws ec2 describe-instances --region us-east-1
{
"Reservations": [
{
"Groups": [],
"Instances":
{
"AmiLaunchIndex": 0,
"ImageId": "ami-04505e74c0741db8d",
"InstanceId": "i-053b633c810728a97",
"InstanceType": "t2.micro",
...
如果你愿意,你也可以在 AWS 网络控制台中检查实例是否已创建。
![图 7.5 – 使用 Terraform 创建的 AWS EC2 实例
图 7.5 – 使用 Terraform 创建的 AWS EC2 实例
我们刚刚验证了我们的 Terraform 配置按预期工作。
提示
当与 Ansible 一起使用时,我们可以利用 Ansible 的动态清单,让 Ansible 发现已创建的 EC2 实例。详情请参阅 docs.ansible.com/ansible/latest/user_guide/intro_dynamic_inventory.html。
为了使我们的示例完整,让我们也来看看如何删除已创建的资源。
销毁基础设施
让我们使用以下命令删除我们创建的资源:
$ terraform destroy
aws_instance.my_instance: Refreshing state... [id=i-053b633c810728a97]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- destroy
...
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
...
Destroy complete! Resources: 1 destroyed.
在用户确认之后,Terraform 删除了所有资源。你可以检查我们的 AWS EC2 实例是否已不存在。
作为 Terraform 的最后一步,让我们看看它是如何与 Kubernetes 交互的。
Terraform 和 Kubernetes
当涉及到 Terraform 和 Kubernetes 之间的交互时,有两种不同的用例:
-
部署一个 Kubernetes 集群
-
与 Kubernetes 集群交互
让我们逐一展示它们。
部署一个 Kubernetes 集群
每个主要的云提供商都提供托管的 Kubernetes 集群,我们可以使用 Terraform 来部署它们。以下是可用的 Terraform 提供程序:
-
AWS:这可以在 Amazon Elastic Kubernetes Service (EKS) 中部署集群。
-
Google:这可以在 Google Kubernetes Engine (GKE) 中部署集群。
-
AzureRM:这可以在 Azure Kubernetes Service (AKS) 中部署集群。
使用这些提供程序中的每一个都相对简单,且工作方式类似于我们在 Terraform 示例中描述的方式。
提示
如果你在裸机服务器上安装 Kubernetes,你应该使用配置管理工具,例如 Ansible。要部署云托管的 Kubernetes 集群,你可以使用 Ansible 或 Terraform,但前者更为适合。
让我们也来看看 Terraform 在 Kubernetes 中的第二种使用方式。
与 Kubernetes 集群交互
类似于 Ansible,我们可以使用 Terraform 与 Kubernetes 集群进行交互。换句话说,我们可以使用专门的 Terraform Kubernetes 提供程序,而不是使用 kubectl 命令应用 Kubernetes 配置。
一个示例的 Terraform 配置,用于更改 Kubernetes 资源,示例如下:
resource "kubernetes_namespace" "example" {
metadata {
name = "my-first-namespace"
}
}
上述配置在 Kubernetes 集群中创建了一个名为 my-namespace 的命名空间。
提示
有多种方式可以与 Kubernetes 集群交互:kubectl、Ansible、Terraform 或其他工具。一般来说,我会首先尝试最简单的方法,即 kubectl 命令,只有在有特殊需求时才会使用 Ansible 或 Terraform;例如,当你需要同时管理多个 Kubernetes 集群时。
我们已经讲解了 Terraform 的基础内容,现在让我们通过简短的总结来结束这一章。
总结
我们已经讲解了配置管理和 IaC 方法,以及相关的工具。请注意,是否在你的持续交付管道中使用 Ansible、Terraform 或两者都不使用,取决于你的具体用例。
当你需要管理多个裸金属服务器时,Ansible 的优势尤为明显。因此,如果你的发布意味着在多个服务器上同时进行相同的更改,你很可能会将 Ansible 命令放入管道中。
Terraform 在你使用云时效果最佳。因此,如果你的发布意味着对云基础设施进行更改,那么 Terraform 是最佳选择。
然而,如果你的环境只有一个 Kubernetes 集群,那么在管道中执行 kubectl 命令并没有问题。
本章的其他要点如下:
-
配置管理是创建和应用应用程序配置的过程。
-
Ansible 是最流行的配置管理工具之一。它不需要代理,因此不需要特殊的服务器配置。
-
Ansible 可以与临时命令一起使用,但真正的强大之处在于 Ansible playbook。
-
Ansible playbook 是定义如何配置环境的文件。
-
Ansible 角色的目的是为了重用 playbook 的部分内容。
-
Ansible Galaxy 是一个在线服务,用于共享 Ansible 角色。
-
IaC(基础设施即代码)是管理云资源的过程。
-
Terraform 是最流行的 IaC 工具。
在下一章,我们将总结持续交付过程并完成最终的 Jenkins 管道。
练习
在本章中,我们讲解了 Ansible 的基础知识以及如何将它与 Docker 和 Kubernetes 一起使用。作为练习,请尝试以下任务:
-
创建服务器基础设施并使用 Ansible 管理它:
-
连接一台物理机器或运行 VirtualBox 虚拟机来模拟远程服务器。
-
配置 SSH 访问远程机器(SSH 密钥)。
-
在远程机器上安装 Python。
-
创建一个包含远程机器的 Ansible 清单。
-
运行 Ansible 临时命令(使用
ping模块)检查基础设施是否配置正确。
-
-
创建一个基于 Python 的
hello worldWeb 服务,并使用 Ansible playbook 将其部署到远程机器:-
服务可以与我们在本章练习中描述的完全相同。
-
创建一个剧本,将服务部署到远程机器。
-
运行
ansible-playbook命令并检查服务是否已部署。
-
-
使用 Terraform 配置一个 GCP 虚拟机实例:
-
在 GCP 中创建一个账户。
-
安装
gcloud工具并进行身份验证(gcloud init)。 -
生成凭据并将其导出到
GOOGLE_APPLICATION_CREDENTIALS环境变量中。 -
创建一个 Terraform 配置文件,配置一个虚拟机实例。
-
使用 Terraform 应用配置。
-
验证实例是否已创建。
-
问题
为了验证你对本章内容的理解,请回答以下问题:
-
什么是配置管理?
-
配置管理工具是无代理(agentless)是什么意思?
-
三个最流行的配置管理工具是什么?
-
什么是 Ansible 清单(inventory)?
-
Ansible 的临时命令和剧本有什么区别?
-
什么是 Ansible 角色?
-
什么是 Ansible Galaxy?
-
什么是 IaC?
-
哪些是最流行的 IaC 工具?
进一步阅读
要了解更多关于配置管理和 IaC 的内容,请参考以下资源:
-
官方 Ansible 文档:
docs.ansible.com/ -
官方 Terraform 文档:
www.terraform.io/docs -
Michael T. Nygard,《Release It!》:(
pragprog.com/titles/mnee2/release-it-second-edition/) -
Russ McKendrick,《Learn Ansible》:(
www.packtpub.com/virtualization-and-cloud/learn-ansible)
第八章:持续交付流水线
本章我们将重点讨论最终流水线中缺失的部分——即环境和基础设施、应用程序版本管理以及非功能性测试。
我们将讨论以下主题:
-
环境和基础设施
-
非功能性测试
-
应用程序版本管理
-
完成持续交付流水线
技术要求
要跟随本章内容,您需要以下资源:
-
一个 Jenkins 实例(在 Jenkins 代理上安装了 Java 8+、Docker 和
kubectl) -
Docker 注册表(例如,Docker Hub 上的帐户)
-
两个 Kubernetes 集群
本章中的所有示例和练习解决方案都可以在 GitHub 上找到,链接:github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter08。
本章的 Code in Action 视频可以在以下链接查看:bit.ly/3JeyQ1X。
环境和基础设施
到目前为止,我们已将应用程序部署到一些服务器——也就是 Docker 主机、Kubernetes 集群和纯 Ubuntu 服务器(在 Ansible 的情况下)。然而,当我们深入思考 持续交付(CD)过程(或者一般的软件交付过程)时,我们需要逻辑地将资源进行分组。这一点很重要,主要有两个原因:
-
机器的物理位置很重要
-
不应在生产机器上进行测试
考虑到这些事实,在本节中,我们将讨论不同类型的环境,它们在 CD 过程中的角色,以及我们的基础设施的安全性方面。
环境类型
有四种常见的环境类型——生产、预发布、QA(测试)和 开发。我们将逐一讨论它们。
生产
生产环境是终端用户使用的环境。它存在于每个公司中,并且是最重要的环境。
下图展示了大多数生产环境的组织方式:

图 8.1 – 生产环境
用户通过负载均衡器访问服务,负载均衡器选择机器。如果应用程序发布在多个物理位置,则(第一个)设备通常是基于 DNS 的地理负载均衡器。在每个位置,我们都有一个服务器集群。例如,如果我们使用 Docker 和 Kubernetes,这意味着在每个位置,我们至少有一个 Kubernetes 集群。
机器的物理位置很重要,因为请求-响应时间会因物理距离的不同而有显著差异。此外,数据库和其他依赖服务应部署在接近服务所在机器的地方。更重要的是,数据库应该按分片方式进行划分,以最小化不同地点之间的复制开销;否则,我们可能会陷入等待数据库在不同实例间达成一致的情况,而这些实例可能相距较远。关于物理方面的更多细节超出了本书的范围,但需要记住的是,Docker 和 Kubernetes 本身并不能解决这个问题。
信息
容器化和虚拟化使你可以将服务器视为无限资源;然而,某些物理方面,如位置,仍然是相关的。
测试环境
测试环境是部署发布候选版本的地方,用于在正式上线前进行最终测试。理想情况下,这个环境应该是生产环境的镜像。
以下图表展示了在交付过程中,这种环境应该是什么样子的:
![图 8.2 – 测试环境]
图 8.2 – 测试环境
请注意,测试环境是生产环境的克隆。如果应用部署在多个地点,那么测试环境也应该有多个地点。
在持续交付(CD)过程中,所有自动化的验收测试(包括功能性和非功能性测试)都会在这个环境中运行。虽然大多数功能性测试通常不要求与生产环境完全相同的基础设施,但在非功能性测试(尤其是性能测试)的情况下,这一点是必须的。
为了节省成本,测试基础设施与生产环境不同并不罕见(通常包含较少的机器)。然而,这种做法可能导致许多生产问题。Michael T. Nygard 在《Release It! 设计与部署生产就绪软件》中举了一个真实案例,说明测试环境中使用的机器比生产环境中的要少。
故事是这样的:在某家公司,系统在某次代码变更后变得极其缓慢,尽管所有压力测试都通过了。这是怎么可能的? 这种情况发生的原因是有一个同步点,其中每个服务器都需要与其他服务器通信。在测试环境中,只有一台服务器,因此没有阻塞。但在生产环境中,服务器数量很多,导致服务器之间相互等待。这个例子只是冰山一角,如果测试环境与生产环境不同,很多生产问题可能无法通过验收测试。
QA
QA 环境(也叫做测试环境)是为 QA 团队执行探索性测试以及外部应用程序(依赖我们的服务)进行集成测试而设计的。QA 环境的用例和基础设施如以下图所示:

图 8.3 – QA 环境
虽然暂存环境不需要保持稳定(在 CD 情况下,它会在每次提交到代码仓库后进行更改),但 QA 实例需要提供一定的稳定性,并暴露与生产环境相同(或向后兼容的)API。与暂存环境不同,基础设施可以与生产环境不同,因为它的目的是不保证发布候选版本的正常运行。
一个非常常见的情况是将较少的机器(例如,仅来自一个位置)分配给 QA 实例。
信息
部署到 QA 环境通常是在单独的流水线中完成的,使其独立于自动发布流程。这种方法很方便,因为 QA 实例的生命周期与生产实例不同(例如,QA 团队可能希望在从主干分出的实验代码上进行测试)。
开发
开发环境可以作为所有开发者共享的服务器,或者每个开发者可以拥有自己的开发环境。以下是一个简单的示意图:

图 8.4 – 开发环境
开发环境始终包含最新版本的代码。它用于实现开发者之间的集成,可以与 QA 环境一样对待。然而,它是开发者使用的,而不是 QA 人员使用的。
现在我们已经查看了所有环境,接下来看看它们如何适应 CD 流程。
持续交付中的环境
在 CD 流程中,暂存环境是不可或缺的。在一些极为少见的情况下,当性能不是关键且项目没有太多依赖时,我们可以在本地(开发)Docker 主机上进行验收测试,但这应该是一个例外,而不是常规。在这种情况下,我们始终面临环境相关的生产问题的风险。
其他环境通常在 CD 中不重要。如果我们希望每次提交后都部署到 QA 或开发环境,那么我们可以为此目的创建单独的流水线(小心不要遮蔽主发布流水线)。在许多情况下,部署到 QA 环境是手动触发的,因为它与生产环境的生命周期不同。
环境安全
所有环境都需要得到充分的安全保护——这是显而易见的。更为显而易见的是,最重要的要求是保持生产环境的安全,因为我们的业务依赖于它,任何安全漏洞的后果都可能是最严重的。
信息
安全性是一个广泛的话题。本节我们将专注于与 CD 过程相关的主题。然而,搭建完整的服务器基础设施需要更多的安全知识。
在 CD 过程中,Jenkins 代理必须能够访问服务器,以便它能够部署应用程序。
有不同的方式为代理提供服务器的凭证:
-
将 SSH 密钥放入代理中:如果我们不使用动态 Docker 从节点配置,那么可以配置 Jenkins 代理机器,使其包含私有的 SSH 密钥。
-
将 SSH 密钥放入代理镜像中:如果我们使用动态 Docker 从节点配置,我们可以将 SSH 私钥添加到 Docker 代理镜像中;然而,这样会产生一个可能的安全漏洞,因为任何可以访问该镜像的人都将可以访问生产服务器。
-
使用 Jenkins 凭证:我们可以配置 Jenkins 存储凭证并在流水线中使用它们。
-
复制到从节点 Jenkins 插件:我们可以在启动 Jenkins 构建时将 SSH 密钥动态复制到从节点中。
每种解决方案都有一些优缺点。使用任何一种时,我们都必须格外小心,因为当代理可以访问生产环境时,任何进入该代理的人都可能侵入生产环境。
最具风险的解决方案是将 SSH 私钥放入 Jenkins 代理镜像中,因为镜像存储的每个地方(无论是 Docker 注册表还是 Jenkins 内部的 Docker 主机)都需要得到良好的安全保护。
现在我们已经讲解了基础设施,接下来让我们看一个我们还没有涉及的话题——非功能性测试。
非功能性测试
我们在前几章中学到了很多关于功能性需求和自动化验收测试的知识。但是我们应该如何处理非功能性需求呢? 或者更具挑战性的是,如果没有需求呢? 我们是否应该在 CD 过程中跳过它们? 我们将在本节中回答这些问题。
软件的非功能性方面始终很重要,因为它们可能会对系统的运行造成重大风险。
例如,许多应用程序失败是因为它们无法承受用户数量突然增加的负载。在他的某本书中,Jakob Nielsen 写到关于用户体验的观点,1 秒钟大约是用户思维流畅不被打断的极限。想象一下,如果我们的系统在负载不断增加时,开始超出这个极限,用户可能因为性能问题而停止使用该服务。考虑到这一点,非功能性测试和功能性测试同样重要。
长话短说,我们应该始终采取以下步骤进行非功能性测试:
-
确定哪些非功能方面对我们的业务至关重要。
-
对于每一个测试,我们都需要做以下几点:
-
像我们为验收测试所做的那样指定测试
-
在 CD 管道中添加一个阶段(在验收测试后,应用程序仍部署在预发布环境中)
-
-
应用程序只有在所有非功能测试通过后,才会进入发布阶段。
无论是哪种类型的非功能测试,基本思想总是相同的。然而,方法可能略有不同。我们来看看不同测试类型及其带来的挑战。
非功能测试的类型
功能测试始终与同一方面相关——系统的行为。相反,非功能测试则关注许多不同的方面。我们来讨论一些最常见的系统属性以及如何在 CD 过程中进行测试。
性能测试
性能测试是最常用的非功能测试。它们测量系统的响应性和稳定性。我们可以创建的最简单性能测试是向Web服务发送请求并测量其往返时间(RTT)。
性能测试有不同的定义。它们通常包括负载、压力和可扩展性测试。有时,它们也被描述为白盒测试。在本书中,我们将性能测试定义为一种最基本的黑盒测试形式,用来衡量系统的延迟。
对于性能测试,我们可以使用专用框架(对于 Java,最流行的是 JMeter),或者仅仅使用我们在验收测试中使用的相同工具。简单的性能测试通常作为管道阶段添加,就在验收测试之后。这样的测试应该在 RTT 超出给定限制时失败,并且可以发现导致我们服务变慢的错误。
小贴士
Jenkins 的 JMeter 插件可以显示性能趋势随时间的变化。
负载测试
负载测试用于检查在大量并发请求时系统的表现。虽然系统在处理单个请求时可能非常快速,但这并不意味着它在同时处理 1,000 个请求时依然足够快。在负载测试中,我们会测量多个并发调用的平均请求响应时间,这些调用通常会从多台机器上进行。负载测试是发布周期中非常常见的 QA 阶段。为了自动化这一过程,我们可以使用与简单性能测试时相同的工具;但是在面对更大规模的系统时,我们可能需要一个独立的客户端环境来执行大量的并发请求。
压力测试
压力测试,也叫容量测试或吞吐量测试,是一种测试,旨在确定有多少并发用户能够访问我们的服务。它听起来可能和负载测试相似,但在负载测试中,我们会将并发用户数(吞吐量)设置为给定的数值,检查响应时间(延迟),并在超过这个限制时使构建失败。然而,在压力测试中,我们保持延迟不变,并增加吞吐量,以发现系统仍然可以操作的最大并发调用数。因此,压力测试的结果可能是一个通知,告知我们的系统可以处理 10,000 个并发用户,这有助于我们为高峰使用期做准备。
压力测试不太适合用于 CD 流程,因为它需要长时间的测试,并且并发请求数量不断增加。它应该作为一个单独的脚本,在一个独立的 Jenkins 管道中准备,并在我们知道代码更改可能会引发性能问题时按需触发。
可扩展性测试
可扩展性测试解释了当我们增加更多的服务器或服务时,延迟和吞吐量如何变化。理想的特征是线性的,这意味着如果我们有一台服务器,且在 100 个并发用户使用时,平均请求-响应时间为 500 毫秒,那么增加另一台服务器应该能保持响应时间不变,并且允许我们再增加 100 个并发用户。实际上,由于需要在服务器之间保持数据一致性,这通常很难实现。
可扩展性测试应该实现自动化,并提供一张图表,显示机器数量与并发用户数量之间的关系。这些数据有助于确定系统的极限,以及何时增加更多机器不再有效。
可扩展性测试,与压力测试类似,也很难融入 CD 管道,应该单独保持。
长期测试
压力测试,也称为耐力测试或长期测试,是将系统运行长时间,查看在一定时间后性能是否下降。它们能检测内存泄漏和稳定性问题。由于它们需要系统长时间运行,因此在持续集成(CD)管道内运行并不合理。
安全测试
安全测试处理与安全机制和数据保护相关的不同方面。一些安全方面是纯粹的功能性需求,如认证、授权和角色分配。这些元素应该像其他功能需求一样,在验收测试阶段进行检查。其他安全方面是非功能性的;例如,系统应防止 SQL 注入。客户可能不会明确指定这样的需求,但它是隐含的。
安全测试应该作为持续交付过程中的一个管道阶段。它们可以使用与验收测试相同的框架编写,也可以使用专门的安全测试框架,例如 行为驱动开发(BDD)安全性。
信息
安全性也应该始终是解释性测试过程的一部分,在这个过程中,测试人员和安全专家会发现安全漏洞并添加新的测试场景。
可维护性测试
可维护性测试解释了一个系统的维护简单性。换句话说,它们评估代码质量。我们已经在提交阶段描述了检查测试覆盖率和执行静态代码分析的步骤。Sonar 工具也可以提供代码质量和技术债务的概览。
恢复测试
恢复测试是一种用于确定系统在因软件或硬件故障崩溃后能够多快恢复的技术。最理想的情况是,如果系统根本不崩溃,即使其一部分服务宕机。一些公司甚至故意进行生产故障测试,检查它们是否能在灾难中生还。最著名的例子是 Netflix 和他们的 Chaos Monkey 工具,该工具会随机终止生产环境中的实例。这种方法迫使工程师编写能让系统在故障时具有恢复力的代码。
恢复测试不是持续交付过程的一部分,而是定期检查系统整体健康状况的事件。
提示
你可以在github.com/Netflix/chaosmonkey了解更多关于 Chaos Monkey 的信息。
许多非功能性测试类型与代码和持续交付(CD)过程的关系更近或更远。有些与法律相关,例如合规性测试,而其他则与文档或国际化相关。还有可用性测试和容量测试(它检查系统在处理大量数据时的表现)。然而,这些测试中的大多数与持续交付过程无关。
非功能性挑战
非功能性方面给软件开发和交付带来了新的挑战。让我们现在来看看其中的一些:
-
长时间的测试运行:测试可能需要很长时间才能完成,并可能需要特殊的执行环境。
-
渐进性质:很难设定何时测试应当失败的限制值(除非 SLA 定义得很好)。即使设定了边界值,应用程序也可能会逐步接近该限制。在大多数情况下,代码更改不会导致测试失败。
-
模糊的需求:用户在非功能性需求方面通常没有太多输入。他们可能会提供一些关于请求-响应时间或用户数量的指导,但他们可能不了解可维护性、安全性或可扩展性。
-
多样性:有很多不同的非功能性测试,选择应该实施哪些测试意味着需要做出一些妥协。
解决非功能性方面的最佳方法是执行以下步骤:
-
列出所有非功能性测试类型。
-
明确划掉不需要的测试。你可能不需要某种测试的原因有很多种,例如以下几种:
-
该服务非常小,一个简单的性能测试就足够了。
-
该系统仅限内部使用,且仅供只读,因此可能不需要任何安全检查。
-
该系统仅为单台机器设计,无需进行扩展。
-
创建某些测试的成本太高。
-
-
将你的测试分成两组:
-
持续交付:可以将其添加到流水线中。
-
分析:由于其执行时间、性质或相关成本,无法将其添加到流水线中。
-
-
对于 CD 组,实现相关的流水线阶段。
-
对于分析组,请执行以下操作:
-
创建自动化测试
-
安排它们应该运行的时间
-
安排会议讨论它们的结果并采取行动
提示
一种非常好的方法是进行夜间构建,包含那些不适合 CD 流水线的长时间测试。然后,可以安排每周的会议来监控和分析系统性能的趋势。
-
如我们所见,非功能性测试有很多类型,它们给交付过程带来了额外的挑战。然而,为了系统的稳定性,这些测试绝不应该被跳过。技术实现会根据测试类型有所不同,但在大多数情况下,它们可以像功能性验收测试一样实现,并应在预发布环境中运行。
提示
如果你对非功能性测试、系统属性和系统稳定性感兴趣,可以阅读Michael T. Nygard的书籍《Release It!》。
现在我们已经讨论了非功能性测试,让我们看一下另一个方面——应用程序版本控制,这是我们之前没有详细讨论过的。
应用程序版本控制
到目前为止,在每次 Jenkins 构建过程中,我们都会创建一个新的 Docker 镜像,将其推送到 Docker 注册中心,并在整个过程中使用最新版本。然而,这种解决方案至少有三个缺点:
-
如果在 Jenkins 构建过程中,在验收测试后,有人推送了新的镜像版本,那么我们可能会发布未经测试的版本。
-
我们总是推送一个以相同方式命名的镜像,因此它实际上会在 Docker 注册中心被覆盖。
-
仅使用它们的哈希式 ID 来管理没有版本的镜像非常困难。
管理 Docker 镜像版本与 CD 流程的推荐方法是什么? 在本节中,我们将探讨不同的版本控制策略,并学习如何在 Jenkins 流水线中创建版本。
版本控制策略
有多种方法可以对应用程序进行版本控制。
让我们讨论可以与 CD 流程一起应用的最流行的解决方案(当每次提交都会创建一个新版本时):
-
x.y.z)。此方法要求 Jenkins 向代码库提交更改,以增加当前的版本号,通常该版本号存储在构建文件中。此解决方案得到了 Maven、Gradle 和其他构建工具的广泛支持。标识符通常由三个数字组成:-
x:这是主版本号;当此版本号增加时,软件不需要向后兼容。 -
y:这是次版本号;当此版本号增加时,软件需要向后兼容。 -
z:这是构建号(也称为修补版本);有时也将其视为向后兼容和向前兼容的更改。
-
-
时间戳:使用构建的日期和时间作为应用程序版本,比顺序编号更简洁,但在 CD 过程中非常方便,因为它不需要 Jenkins 将其提交回代码库。
-
哈希:一个随机生成的哈希版本与日期时间的优势相似,并且可能是最简单的解决方案。缺点是无法通过查看两个版本来判断哪个是最新版本。
-
混合:如前所述,有许多解决方案的变体——例如,主版本号和次版本号与日期时间结合使用。
所有这些解决方案都可以与 CD 过程一起使用。然而,语义版本控制需要在构建执行过程中向代码库提交更改,以便在源代码库中增加版本号。
信息
Maven(以及其他构建工具)推广了版本快照,它为尚未发布并且仅用于开发过程的版本添加了SNAPSHOT后缀。由于 CD 意味着发布每一次更改,因此没有快照。
现在,让我们学习如何在 Jenkins 流水线中适配版本控制。
Jenkins 流水线中的版本控制
正如我们之前提到的,使用软件版本控制时有不同的选择,每种方式都可以在 Jenkins 中实现。
举个例子,使用日期和时间。
信息
要使用来自 Jenkins 的时间戳信息,您需要安装 Build Timestamp 插件,并在 Jenkins 配置中设置时间戳格式为yyyyMMdd-HHmm。
在我们使用 Docker 镜像的地方,我们需要添加${BUILD_TIMESTAMP}标签后缀。
例如,Docker 构建阶段应该如下所示:
sh "docker build -t leszko/calculator:${BUILD_TIMESTAMP} ."
在进行这些更改后,当我们运行 Jenkins 构建时,镜像应该会使用时间戳的版本标签,并存储在我们的 Docker 注册表中。
版本控制完成后,我们终于准备好完成 CD 流水线。
完成持续交付流水线
既然我们已经了解了 Ansible、环境、非功能性测试和版本控制,我们就可以扩展 Jenkins 流水线,并完成一个简单但完整的 CD 流水线。
按照以下步骤操作:
-
创建预发布和生产环境的清单。
-
在 Kubernetes 部署中使用版本。
-
使用远程 Kubernetes 集群作为 staging 环境。
-
更新验收测试,以便它们使用 staging Kubernetes 集群。
-
将应用程序发布到生产环境。
-
添加一个冒烟测试,以确保应用程序已经成功发布。
我们从创建一个清单开始。
清单
在上一章中,我们描述了 Ansible 时查看了清单文件。为了概括这个概念,清单包含了描述如何访问环境的列表。在这个例子中,我们将直接使用 Kubernetes,因此 Kubernetes 配置文件,通常存储在 .kube/config 中,将作为清单使用。
信息
正如我们在上一章中解释的,根据你的需求,你可以直接使用 kubectl,或者通过 Ansible 或 Terraform 来使用它。这些方法适用于 CD 流水线。
配置两个 Kubernetes 集群 – staging 和 production。你的 .kube/config 文件应该类似于下面的示例:
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CR...
server: https://35.238.191.252
name: staging
- cluster:
certificate-authority-data: LS0tLS1CR...
server: https://35.232.61.210
name: production
contexts:
- context:
cluster: staging
user: staging
name: staging
- context:
cluster: production
user: production
name: production
users:
- name: staging
user:
token: eyJhbGciOiJSUzI1NiIsImtpZCI6I...
- name: production
user:
token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSU...
Kubernetes 配置为每个集群存储以下信息:
-
cluster:集群的地址(Kubernetes 主节点端点)及其 CA 证书 -
context:集群和用户的绑定 -
user:访问 Kubernetes 集群的授权数据提示
创建两个 Kubernetes 集群的最简单方法是使用
kubectl和gcloud container clusters get-credentials,最后通过kubectl config rename-context <original-context-name> staging重命名集群上下文。注意,你可能还需要创建一个 GCP 防火墙规则,以允许流量进入你的 Kubernetes 节点。
你还需要确保 Kubernetes 配置在 Jenkins 代理节点上可用。正如我们在前面部分提到的,仔细考虑你的安全性,确保没有未经授权的人通过 Jenkins 代理访问你的环境。
现在我们已经定义了清单,可以准备 Kubernetes 部署配置,使其能够与应用程序版本管理一起工作。
版本管理
Kubernetes YAML 文件与我们在前几章中定义的相同。唯一的区别是,我们需要为应用程序版本引入一个模板变量。让我们在 deployment.yaml 文件中做一个修改:
image: leszko/calculator:{{VERSION}}
然后,我们可以在 Jenkinsfile 中填写版本:
stage("Update version") {
steps {
sh "sed -i 's/{{VERSION}}/${BUILD_TIMESTAMP}/g' deployment.yaml"
}
}
现在,我们可以更改验收测试,使用远程的 staging 环境。
远程 staging 环境
根据我们的需求,我们可以通过在本地 Docker 主机上运行应用程序(如前所述)或使用远程(且集群化的)staging 环境来进行测试。前者的解决方案更接近生产环境,因此可以视为更好的选择。
为此,我们需要将使用的命令从 docker 改为 kubectl。让我们修改 Jenkinsfile 中的相关部分:
stage("Deploy to staging") {
steps {
sh "kubectl config use-context staging"
sh "kubectl apply -f hazelcast.yaml"
sh "kubectl apply -f deployment.yaml"
sh "kubectl apply -f service.yaml"
}
}
首先,我们将kubectl切换到staging上下文。然后,我们部署了 Hazelcast 服务器。最后,我们将Calculator部署到 Kubernetes 服务器上。此时,我们在 staging 环境中已经有了一个完全功能的应用程序。接下来,让我们看看如何修改接受测试阶段。
接受测试环境
接受测试阶段与上一章中完全相同。我们需要更改的唯一内容是服务的 IP 和端口,使用远程 Kubernetes 集群中的地址。正如我们在第六章《使用 Kubernetes 进行集群化》中所解释的那样,如何进行此操作取决于你的 Kubernetes 服务类型。我们使用的是NodePort,因此我们需要在Jenkinsfile中做以下更改:
stage("Acceptance test") {
steps {
sleep 60
sh "chmod +x acceptance-test.sh && ./acceptance-test.sh"
}
}
acceptance-test.sh脚本应如下所示:
#!/bin/bash
set -x
NODE_IP=$(kubectl get nodes -o jsonpath='{ $.items[0].status.addresses[?
(@.type=="ExternalIP")].address }')
NODE_PORT=$(kubectl get svc calculator-service -o=jsonpath='{.spec.ports[0].nodePort}')
./gradlew acceptanceTest -Dcalculator.url=http://${NODE_IP}:${NODE_PORT}
首先,我们使用sleep等待应用程序部署完成。然后,使用kubectl获取服务的 IP 地址(NODE_IP)和端口(NODE_PORT)。最后,我们执行了接受测试套件。
提示
如果你使用 Minishift 作为 Kubernetes 集群,那么你可以使用minishift ip获取NODE_IP。如果你使用 Docker for Desktop,那么你的 IP 将是localhost。
现在所有测试都已经就绪,是时候发布应用程序了。
发布
生产环境应该尽可能接近 staging 环境。发布的 Jenkins 阶段也应该尽可能接近部署到 staging步骤。
在最简单的情况下,唯一的区别将是 Kubernetes 配置上下文和应用程序配置(例如,在 Spring Boot 应用程序的情况下,我们将设置不同的 Spring 配置文件,这样就会使用不同的application.properties文件)。在我们的例子中,应用程序没有属性文件,因此唯一的区别是kubectl上下文:
stage("Release") {
steps {
sh "kubectl config use-context production"
sh "kubectl apply -f hazelcast.yaml"
sh "kubectl apply -f deployment.yaml"
sh "kubectl apply -f service.yaml"
}
}
一旦发布完成,我们可能认为一切都已完成;然而,有一个阶段是缺失的——烟雾测试。
烟雾测试
烟雾测试是接受测试的一个非常小的子集,其唯一目的就是检查发布过程是否成功完成;否则,可能会出现应用程序本身没有问题,但发布过程中存在问题,导致生产环境无法正常工作。
烟雾测试通常与接受测试的定义相同。因此,流水线中的烟雾测试阶段应该是这样的:
stage("Smoke test") {
steps {
sleep 60
sh "chmod +x smoke-test.sh && ./smoke-test.sh"
}
}
一旦一切设置完成,CD 构建应该会自动运行,应用程序应该被发布到生产环境。这样,我们就完成了对 CD 流水线的分析,这是最简单但完全实用的形式。
完整的 Jenkinsfile
总结一下,在过去的几章中,我们经历了多个阶段,最终我们创建了一个完整的 CD 流水线,可以用于许多项目。
以下是Calculator项目的完整Jenkinsfile:
pipeline {
agent any
triggers {
pollSCM('* * * * *')
}
stages {
stage("Compile") { steps { sh "./gradlew compileJava" } }
stage("Unit test") { steps { sh "./gradlew test" } }
stage("Code coverage") { steps {
sh "./gradlew jacocoTestReport"
sh "./gradlew jacocoTestCoverageVerification"
} }
stage("Static code analysis") { steps {
sh "./gradlew checkstyleMain"
} }
stage("Build") { steps { sh "./gradlew build" } }
stage("Docker build") { steps {
sh "docker build -t leszko/calculator:${BUILD_TIMESTAMP} ."
} }
stage("Docker push") { steps {
sh "docker push leszko/calculator:${BUILD_TIMESTAMP}"
} }
stage("Update version") { steps {
sh "sed -i 's/{{VERSION}}/${BUILD_TIMESTAMP}/g' deployment.yaml"
} }
stage("Deploy to staging") { steps {
sh "kubectl config use-context staging"
sh "kubectl apply -f hazelcast.yaml"
sh "kubectl apply -f deployment.yaml"
sh "kubectl apply -f service.yaml"
} }
stage("Acceptance test") { steps {
sleep 60
sh "chmod +x acceptance-test.sh && ./acceptance-test.sh"
} }
// Performance test stages
stage("Release") { steps {
sh "kubectl config use-context production"
sh "kubectl apply -f hazelcast.yaml"
sh "kubectl apply -f deployment.yaml"
sh "kubectl apply -f service.yaml"
} }
stage("Smoke test") { steps {
sleep 60
sh "chmod +x smoke-test.sh && ./smoke-test.sh"
} }
}
}
上述代码是对整个 CD 过程的声明性描述,从检出代码开始,到将其发布到生产环境结束。恭喜你,使用这段代码,你已经完成了本书的主要目标,那就是创建一个 CD 管道!
总结
在本章中,我们完成了 CD 管道,这意味着我们终于可以发布应用程序。以下是本章的关键要点:
-
在 CD 过程中,两个环境是不可或缺的:staging 和 production。
-
非功能性测试是 CD 过程的重要组成部分,应该始终作为管道阶段进行考虑。
-
不适合 CD 过程的非功能性测试应作为周期性任务,用于监控整体性能趋势。
-
应用程序应始终进行版本控制;然而,版本控制策略取决于应用程序的类型。
-
一个最小的 CD 管道可以通过一系列脚本实现,最终以两个阶段结束:发布和烟雾测试。
-
烟雾测试应该始终作为 CD 管道的最后一个阶段,以检查发布是否成功。
在下一章,我们将研究 CD 管道的一些高级方面。
练习
在本章中,我们涵盖了 CD 管道的许多新方面。为了帮助你理解这些概念,我们建议你完成以下练习:
-
添加一个性能测试,测试
hello world服务:-
hello world服务可以来自前一章。 -
创建一个
performance-test.sh脚本,进行 100 次调用并检查平均请求响应时间是否小于 1 秒。 -
你可以使用 Cucumber 或
curl命令来执行脚本。
-
-
创建一个 Jenkins 管道,构建
hello worldWeb 服务为一个版本化的 Docker 镜像,并执行性能测试:-
创建一个
Docker build(和Docker push)阶段,构建带有hello world服务的 Docker 镜像,并将时间戳添加为版本标签。 -
使用前几章的 Kubernetes 部署来部署应用程序。
-
添加
Deploy to staging阶段,将镜像部署到远程机器。 -
添加
Performance testing阶段,执行performance-test.sh。 -
运行管道并观察结果。
-
问题
要检查你对本章的知识掌握情况,请回答以下问题:
-
至少列出三种不同类型的软件环境。
-
staging 和 QA 环境之间有什么区别?
-
至少列出五种非功能性测试类型。
-
所有非功能性测试都应该是 CD 管道的一部分吗?
-
至少列出两种应用程序版本控制策略。
-
什么是烟雾测试?
进一步阅读
若要了解更多关于 CD 管道的内容,请参阅以下资源:
-
Sameer Paradkar:精通非功能性需求:
www.packtpub.com/application-development/mastering-non-functional-requirements。 -
Sander Rossel: 持续集成、交付和部署:
www.packtpub.com/application-development/continuous-integration-delivery-and-deployment.
第九章:高级持续交付
在前几章中,我们从零开始,最终构建了一个完整的持续交付流水线。现在,是时候介绍一些持续交付过程中同样非常重要,但尚未描述的不同方面了。
本章涵盖以下内容:
-
管理数据库变更
-
流水线模式
-
发布模式
-
与遗留系统的合作
技术要求
要跟随本章的指导,你需要以下内容:
-
Java 8+
-
一个 Jenkins 实例
所有示例和练习解决方案可以在 GitHub 上的github.com/PacktPublishing/Continuous-Delivery-With-Docker-and-Jenkins-3rd-Edition/tree/main/Chapter09找到。
本章的 Code in Action 视频可以在bit.ly/3NVVOyi查看。
管理数据库变更
到目前为止,我们关注的是应用于 web 服务的持续交付过程。其简单的因素之一是 web 服务本身就是无状态的。这一事实意味着它们可以轻松地更新、重启、克隆多个实例并从给定的源代码中重新创建。然而,web 服务通常与其有状态部分(数据库)关联,而这给交付过程带来了新的挑战。这些挑战可以分为以下几类:
-
兼容性:数据库模式及其数据本身必须始终与 web 服务兼容。
-
零停机部署:为了实现零停机部署,我们使用滚动更新,这意味着数据库必须同时兼容两个不同的 web 服务版本。
-
回滚:数据库的回滚可能是困难的、有限的,甚至有时是不可能的,因为并非所有操作都是可逆的(例如,删除包含数据的列)。
-
测试数据:与数据库相关的变更很难测试,因为我们需要与生产数据非常相似的测试数据。
在本节中,我将解释如何解决这些挑战,以便使持续交付过程尽可能安全。
理解模式更新
如果考虑交付过程,实际上并不是数据本身导致了困难,因为我们在部署应用时通常不会更改数据。数据是在系统上线运行时收集的,而在部署过程中,我们只是更改了存储和解释这些数据的方式。换句话说,在持续交付过程中,我们关注的是数据库的结构,而不是其内容。这也是为什么本节主要讨论关系型数据库(及其模式),而较少涉及其他类型的存储,如 NoSQL 数据库,因为后者没有结构定义。
为了更好地理解这一点,可以考虑我们在本书中已经使用过的 Hazelcast。它存储了缓存数据,因此,实际上它就是一个数据库。然而,从持续交付的角度来看,它不需要任何努力,因为它没有任何数据结构。它存储的只是键值对条目,这些条目不会随时间变化。
信息
NoSQL 数据库通常没有任何限制性的模式,因此,它们简化了持续交付过程,因为不需要额外的模式更新步骤。这是一个巨大的好处;然而,这并不一定意味着使用 NoSQL 数据库编写应用程序更简单,因为我们必须在源代码中投入更多精力进行数据验证。
关系型数据库具有静态模式。如果我们想要更改它(例如,向表中添加一个新列),我们需要编写并执行一个 SQL 数据定义语言 (DDL) 脚本。手动执行每个变更需要大量工作,并且容易出错,导致运维团队必须保持代码和数据库结构的同步。一个更好的解决方案是以增量方式自动更新模式。这样的解决方案称为 数据库迁移。
介绍数据库迁移
数据库模式迁移是对关系型数据库结构进行增量变更的过程。让我们看看下面的图表,以便更好地理解:

图 9.1 – 数据库模式迁移
版本 v1 中的数据库具有由 V1_init.sql 文件定义的模式。此外,它存储了与迁移过程相关的元数据,例如当前的模式版本和迁移变更日志。当我们想要更新模式时,我们提供以 SQL 文件形式的变更,例如 V2_add_table.sql。然后,我们需要运行迁移工具,在数据库上执行给定的 SQL 文件(它还会更新元数据表)。实际上,数据库模式是所有随后的 SQL 迁移脚本执行的结果。接下来,我们将看到一个迁移的示例。
信息
迁移脚本应存储在版本控制系统中,通常与源代码存储在同一个代码库中。
数据库迁移工具及其使用的策略可以分为两类:
-
升级和降级:这种方法(例如 Ruby on Rails 框架中使用的方式)意味着我们可以向上迁移(从v1到v2)和向下迁移(从v2到v1)。它允许数据库模式回滚,但有时这可能会导致数据丢失(如果迁移在逻辑上是不可逆的)。
-
仅升级:这种方法(例如 Flyway 工具中使用的方式)只允许我们向上迁移(从v1到v2)。在许多情况下,数据库更新是不可逆的,例如,当从数据库中删除一个表时。这样的变更无法回滚,因为即使我们重新创建该表,也已经丢失了所有数据。
市面上有许多数据库迁移工具,其中最流行的是Flyway、Liquibase和Rail Migrations(来自 Ruby on Rails 框架)。作为理解这些工具如何工作的下一步,我们将基于 Flyway 工具查看一个示例。
信息
也有一些商业解决方案提供给特定的数据库,例如 Redgate(针对 SQL Server)和 Optim Database Administrator(针对 DB2)。
使用 Flyway
让我们使用 Flyway 为计算器 Web 服务创建一个数据库模式。该数据库将存储所有在服务上执行过的操作历史:第一个参数,第二个参数,以及结果。
我们展示了如何通过三个步骤使用 SQL 数据库和 Flyway:
-
配置 Flyway 工具与 Gradle 一起使用
-
定义 SQL 迁移脚本以创建计算历史表
-
在 Spring Boot 应用程序代码中使用 SQL 数据库
让我们开始吧。
配置 Flyway
为了在 Gradle 中使用 Flyway,我们需要将以下内容添加到build.gradle文件中:
buildscript {
dependencies {
classpath('com.h2database:h2:1.4.200')
}
}
...
plugins {
id "org.flywaydb.flyway" version "8.5.0"
}
...
flyway {
url = 'jdbc:h2:file:/tmp/calculator'
user = 'sa'
}
以下是关于配置的一些简要说明:
-
我们使用了 H2 数据库,它是一个内存型(和基于文件的)数据库。
-
我们将数据库存储在
/tmp/calculator文件中。 -
默认的数据库用户被称为
sa(系统管理员)。提示
对于其他 SQL 数据库(例如 MySQL),配置将非常相似。唯一的区别在于 Gradle 依赖项和 JDBC 连接。
在应用此配置后,我们应该能够通过执行以下命令来运行 Flyway 工具:
$ ./gradlew flywayMigrate -i
该命令在/tmp/calculator.mv.db文件中创建了数据库。显然,它没有任何模式,因为我们还没有定义任何内容。
信息
Flyway 可以作为命令行工具、通过 Java API 或作为流行构建工具 Gradle、Maven 和 Ant 的插件来使用。
定义 SQL 迁移脚本
下一步是定义 SQL 文件,添加计算表到数据库模式中。让我们创建src/main/resources/db/migration/V1__Create_calculation_table.sql文件,内容如下:
create table CALCULATION (
ID int not null auto_increment,
A varchar(100),
B varchar(100),
RESULT varchar(100),
primary key (ID)
);
请注意迁移文件的命名规则,<version>__<change_description>.sql。该 SQL 文件创建了一个包含四列的表,ID、A、B 和 RESULT。ID 列是表的自动递增主键。现在,我们已经准备好运行flyway命令以应用迁移:
$ ./gradlew flywayMigrate -i
...
Migrating schema "PUBLIC" to version "1 - Create calculation table"
Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.018s)
该命令自动检测到迁移文件并在数据库上执行它。
信息
迁移文件应该始终保存在版本控制系统中,通常与源代码一起保存。
访问数据库
我们已经执行了第一次迁移,因此数据库已经准备好。要查看完整的示例,我们还应调整我们的项目,使其能够访问数据库。
首先让我们配置 Gradle 依赖项,以便使用 Spring Boot 项目中的h2database:
-
我们可以通过向
build.gradle文件中添加以下行来实现:dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.h2database:h2:1.4.200' } -
下一步是在
src/main/resources/application.properties文件中设置数据库位置和启动行为:spring.datasource.url=jdbc:h2:file:/tmp/calculator;DB_CLOSE_ON_EXIT=FALSE spring.jpa.hibernate.ddl-auto=validate spring.datasource.username=sa
第二行表示 Spring Boot 不会尝试从源代码模型自动生成数据库模式。相反,它只会验证数据库模式是否与 Java 模型一致。
-
现在,让我们为新的
src/main/java/com/leszko/calculator/Calculation.java文件创建 Java ORM 实体模型:package com.leszko.calculator; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; @Entity public class Calculation { @Id @GeneratedValue(strategy= GenerationType.IDENTITY) private Integer id; private String a; private String b; private String result; protected Calculation() {} public Calculation(String a, String b, String result) { this.a = a; this.b = b; this.result = result; } }
Entity类表示 Java 代码中的数据库映射。一个表通过一个类来表达,每个列作为一个字段。下一步是为加载和存储Calculation实体创建仓库。
-
让我们创建
src/main/java/com/leszko/calculator/CalculationRepository.java文件:package com.leszko.calculator; import org.springframework.data.repository.CrudRepository; public interface CalculationRepository extends CrudRepository<Calculation, Integer> {} -
最后,我们可以使用
Calculation和CalculationRepository类来存储计算历史记录。让我们将以下代码添加到src/main/java/com/leszko/calculator/CalculatorController.java文件中:... class CalculatorController { ... @Autowired private CalculationRepository calculationRepository; @RequestMapping("/sum") String sum(@RequestParam("a") Integer a, @RequestParam("b") Integer b) { String result = String.valueOf(calculator.sum(a, b)); calculationRepository.save(new Calculation(a.toString(), b.toString(), result)); return result; } } -
现在,我们可以最终启动服务,例如,使用以下命令:
$ ./gradlew bootRun
当我们启动服务后,可以向/sum端点发送请求。结果,每个加法操作都会被记录到数据库中。
提示
如果你想浏览数据库内容,可以将spring.h2.console.enabled=true添加到application.properties文件中,然后通过/h2-console端点浏览数据库。
我们已经解释了数据库模式迁移的工作原理以及如何在使用 Gradle 构建的 Spring Boot 项目中使用它。现在,让我们来看一下它是如何与持续交付过程集成的。
在持续交付中更改数据库
使用数据库更新在持续交付管道中的第一种方法是在线程执行过程中添加一个阶段。这个简单的解决方案在很多情况下都能正常工作;然而,它有两个显著的缺点:
-
回滚:如前所述,数据库更改并不总是可以回滚的(Flyway 根本不支持降级)。因此,在服务回滚的情况下,数据库会变得不兼容。
-
停机时间:服务更新和数据库更新并不会同时执行,这会导致停机时间。
这引出了我们需要解决的两个约束:
-
数据库版本需要始终与服务版本兼容。
-
数据库模式迁移是不可逆的。
我们将处理两种不同情况的约束:向后兼容的更新和非向后兼容的更新。
向后兼容的更改
向后兼容的更改较为简单。让我们通过以下图形来查看它们是如何工作的:

图 9.2 – 向后兼容的数据库迁移
假设 数据库 v10 的架构迁移是向后兼容的。如果我们需要回滚 服务 v1.2.8 的发布,那么我们部署 服务 v1.2.7,并且不需要对数据库做任何操作(数据库迁移不可逆,因此我们保持 数据库 v11)。由于架构更新是向后兼容的,服务 v1.2.7 与 数据库 v11 完美兼容。如果我们需要回滚到 服务 v1.2.6,同样可以正常工作,依此类推。现在,假设 数据库 v10 和所有其他迁移都是向后兼容的,那么我们可以回滚到任何服务版本,一切都能正常工作。
停机时间也没有问题。如果数据库迁移本身没有停机时间,那么我们可以先执行它,然后再对服务使用滚动更新。
我们来看一个向后兼容变更的例子。我们将创建一个架构更新,向计算表添加一个 created_at 列。src/main/resources/db/migration/V2__Add_created_at_column.sql 迁移文件如下所示:
alter table CALCULATION
add CREATED_AT timestamp;
除了迁移脚本外,计算器服务还需要在 Calculation 类中添加一个新字段:
...
private Timestamp createdAt;
...
我们还需要调整其构造函数,然后调整它在 CalculatorController 类中的使用:
calculationRepository.save(new Calculation(a.toString(), b.toString(), result, Timestamp.from(Instant.now())));
运行服务后,计算历史记录会存储在 created_at 列中。请注意,这个变更是向后兼容的,因为即使我们还原了 Java 代码并在数据库中保留了 created_at 列,一切仍然可以正常工作(还原的代码根本不涉及新列)。
非向后兼容的变更
非向后兼容的变更要困难得多。从之前的示意图来看,如果 v11 数据库的变更是向后不兼容的,那么就无法将服务回滚到 1.2.7。在这种情况下,我们该如何处理非向后兼容的数据库迁移,以便能够进行回滚并实现零停机部署呢?
简单来说,我们可以通过将非向后兼容的变更转换为在一定时间内向后兼容的变更来解决这个问题。换句话说,我们需要额外付出努力,将架构迁移分为两个部分:
-
当前执行的向后兼容更新,通常意味着保留一些冗余数据
-
在回滚期后执行非向后兼容的更新,定义我们可以回退代码的最大时间
为了更好地说明这一点,我们来看下面的示意图:

图 9.3 – 非向后兼容的数据库迁移
我们考虑一个删除列的例子。一个建议的方法包括两个步骤:
-
停止在源代码中使用该列 (v1.2.5,向后兼容更新,首先执行)。
-
从数据库中删除该列 (v11,非向后兼容更新,回滚期后执行)。
所有版本直到数据库 v11都可以回滚到任何之前的版本;从服务 v1.2.8开始的服务只能在回滚周期内回滚。这个方法可能听起来很简单,因为我们所做的只是延迟了从数据库中删除列的操作。然而,它解决了回滚问题和零停机时间部署问题。因此,它降低了发布过程中相关的风险。如果我们将回滚周期调整为合理的时间(例如,在一天内多次发布时调整为 2 周),那么风险几乎可以忽略不计。我们通常不会回滚很多版本。
删除列是一个非常简单的例子。让我们来看一个更复杂的场景,重命名我们计算器服务中的结果列。我们展示了如何通过几个步骤来完成这项操作:
-
向数据库中添加新列
-
更改代码以使用两列数据
-
合并两列中的数据
-
从代码中删除旧列
-
从数据库中删除旧列
让我们详细看看这些步骤。
向数据库中添加新列
假设我们需要将result列重命名为sum。第一步是添加一个新的列,它将是旧列的副本。我们需要创建一个迁移文件:src/main/resources/db/migration/V3__Add_sum_column.sql:
alter table CALCULATION
add SUM varchar(100);
结果是,在执行迁移后,我们将拥有两列:result和sum。
更改代码以使用两列数据
下一步是在源代码模型中重命名列,并在set和get操作中使用两列数据。我们可以在Calculation类中进行更改:
public class Calculation {
...
private String sum;
...
public Calculation(String a, String b, String sum, Timestamp createdAt) {
this.a = a;
this.b = b;
this.sum = sum;
this.result = sum;
this.createdAt = createdAt;
}
public String getSum() {
return sum != null ? sum : result;
}
}
提示
为了达到 100%的准确性,在getSum()方法中,我们应该比较类似于最后修改日期的列。(并不一定总是需要先使用新列。)
从现在起,每次向数据库中添加一行时,相同的值会写入result和sum两列。在读取sum时,我们首先检查它是否存在于新列中,如果没有,则从旧列中读取。
提示
也可以通过使用数据库触发器来实现相同的结果,触发器会自动将相同的值写入两列中。
到目前为止,我们所做的所有更改都是向后兼容的,因此我们可以随时将服务回滚到任何我们想要的版本。
合并两列中的数据
这一步通常在发布版本稳定后进行。我们需要将旧的result列中的数据复制到新的sum列。让我们创建一个迁移文件,命名为V4__Copy_result_into_sum_column.sql:
update CALCULATION
set CALCULATION.sum = CALCULATION.result
where CALCULATION.sum is null;
我们目前还没有回滚的限制;但是,如果我们需要部署变更前的版本(在步骤 2之前),则需要重复执行此数据库迁移。
从代码中删除旧列
到此为止,我们已经将所有数据迁移到新的列中,因此我们可以在数据模型中开始使用新列,而不再依赖旧列。为了做到这一点,我们需要删除与result相关的所有代码,这些代码在Calculation类中应如下所示:
public class Calculation {
...
private String sum;
...
public Calculation(String a, String b, String sum, Timestamp createdAt) {
this.a = a;
this.b = b;
this.sum = sum;
this.createdAt = createdAt;
}
public String getSum() {
return sum;
}
}
完成此操作后,我们将不再在代码中使用result列。请注意,这一操作仅在步骤 2之前向后兼容。如果我们需要回滚到步骤 1,则可能会丢失此步骤后存储的数据。
从数据库中删除旧的列
最后一步是从数据库中删除旧的列。此迁移操作应在回滚期后进行,当我们确认在步骤 4之前不会需要回滚时。
信息
回滚期可能非常长,因为我们不再使用数据库中的该列。这个任务可以视为清理任务,因此即使它不向后兼容,也没有相关的风险。
让我们添加最终的迁移文件,V5__Drop_result_column.sql:
alter table CALCULATION
drop column RESULT;
完成此步骤后,我们将最终完成列重命名过程。请注意,我们采取的步骤使得操作变得稍微复杂了一些,以便将其延长至更长时间。这减少了数据库不向后兼容更改的风险,并实现了零停机时间部署。
将数据库更新与代码更改分离
到目前为止,在所有图像中,我们展示了数据库迁移是与服务发布一起进行的。换句话说,每次提交(即每次发布)都包括数据库更改和代码更改。然而,推荐的做法是明确区分,提交到代码库的内容是数据库更新还是代码更改。这个方法在下图中展示:

图 9.4 – 将数据库更新与代码更改分离
数据库与服务更改分离的好处是我们能够免费进行向后兼容性检查。假设v11和v1.2.7的更改涉及一个逻辑更改,例如向数据库中添加一个新列。然后,我们首先提交Database v11,这样持续交付流水线中的测试就会检查Database v11是否与Service v1.2.6正确兼容。换句话说,它们检查Database v11更新是否向后兼容。然后,我们提交v1.2.7的更改,流水线会检查Database v11是否与Service v1.2.7兼容。
信息
数据库与代码的分离并不意味着我们必须有两个独立的 Jenkins 流水线。流水线可以始终执行两者,但我们应当将其视为一种良好的实践,确保每次提交要么是数据库更新,要么是代码更改。
总结一下,数据库模式的更改永远不应该手动进行。相反,我们应该始终通过迁移工具来自动化这些更改,并将其作为持续交付管道的一部分执行。我们还应避免进行不向后兼容的数据库更新,而确保这一点的最佳方法是将数据库和代码更改分别提交到仓库中。
避免共享数据库
在许多系统中,我们可以发现数据库成为多个服务共享的中心点。在这种情况下,任何对数据库的更新都会变得更加具有挑战性,因为我们需要在所有服务之间进行协调。
例如,假设我们正在开发一个在线商店,并且我们有一个Customers表,其中包含以下列:first name、last name、username、password、email和discount。有三个服务对客户数据感兴趣:
-
用户资料管理器:用于编辑用户数据。
-
结账处理器:处理结账过程(读取用户名和电子邮件)。
-
折扣管理器:用于分析客户的订单并应用适当的折扣。
让我们看一下以下图示,展示了这种情况:

图 9.5 – 共享数据库反模式
这三个服务依赖于相同的数据库模式。这种方法至少有两个问题:
-
当我们想要更新模式时,它必须与所有三个服务兼容。虽然所有向后兼容的更改都可以,但任何不向后兼容的更新将变得更加困难,甚至不可能实现。
-
每个服务都有单独的交付周期和独立的持续交付管道。那么,我们应该使用哪个管道来进行数据库模式迁移? 不幸的是,这个问题没有一个明确的答案。
基于前面提到的原因,每个服务应该拥有自己的数据库,服务之间应该通过其 API 进行通信。以我们的示例为例,我们可以应用以下重构:
-
结账处理器应该通过调用用户资料管理器的 API 来获取客户数据。
-
折扣列应该提取到一个单独的数据库(或模式),并且折扣管理器应该负责管理。
重构后的版本如下图所示:

图 9.6 – 每服务数据库模式
这种方法与微服务架构的原则一致,并且应该始终应用。通过 API 进行通信比直接访问数据库更加灵活。
信息
对于单体系统来说,数据库通常是集成点。由于这种方式会引发许多问题,因此被视为反模式。
准备测试数据
我们已经展示了数据库迁移,它通过副作用确保环境之间的数据库模式一致。这是因为如果我们在开发机器、预发布环境或生产环境中运行相同的迁移脚本,我们总能得到相同模式的结果。然而,表中的数据值是不同的。那么,我们该如何准备测试数据,以便有效地测试我们的系统呢?这将是下一节的重点。
这个问题的答案取决于测试类型,并且在单元测试、集成/验收测试和性能测试中是不同的。让我们逐个检查每种情况。
单元测试
在单元测试的情况下,我们不会使用真实的数据库。我们要么在持久化机制(如仓库和数据访问对象)层面模拟测试数据,要么用内存数据库(例如 H2 数据库)伪造真实数据库。由于单元测试是由开发人员编写的,因此测试数据的具体值通常是由开发人员自行创设的,且这些数据值并不那么重要。
集成/验收测试
集成和验收测试通常使用测试/预发布数据库,该数据库应该尽可能与生产环境相似。许多公司采用的一种方法是将生产数据快照到预发布环境中,确保数据完全相同。然而,这种方法被视为反模式,原因如下:
-
测试隔离:每个测试都在相同的数据库上进行,因此一个测试的结果可能会影响其他测试的输入。
-
数据安全:生产环境中的实例通常存储敏感信息,因此需要更高的安全保障。
-
可重现性:每次快照后,测试数据都会有所不同,这可能导致不稳定的测试结果。
因此,推荐的做法是通过与客户或业务分析师一起选择一部分生产数据手动准备测试数据。当生产数据库增长时,值得重新审视其内容,看看是否有任何合理的情况应该被加入。
向预发布数据库添加数据的最佳方式是使用服务的公共 API。这种方式与验收测试一致,后者通常是黑盒测试。此外,使用 API 可以确保数据本身的一致性,并通过限制直接操作数据库来简化数据库重构。
性能测试
性能测试的测试数据通常与验收测试相似。一个显著的区别是数据量的大小。为了正确测试性能,我们需要提供足够的输入数据量,最好与生产环境中的数据量相当(尤其是在高峰时段)。为此,我们可以创建数据生成器,通常这些生成器会在验收测试和性能测试之间共享。
我们在持续交付过程中已经涵盖了很多关于数据库的内容。现在,让我们转到一个完全不同的话题。让我们讨论如何使用著名的流水线模式来改进我们的 Jenkins 流水线。
流水线模式
我们已经知道了启动项目和设置 Jenkins、Docker、Kubernetes、Ansible 和 Terraform 的持续交付流水线所需的一切。本节旨在通过一些推荐的 Jenkins 流水线实践来扩展这些知识。
并行化流水线
在本书中,我们始终是按顺序执行流水线的,一阶段接一阶段,一步接一步。这种方法使得构建的状态和结果容易推理。如果先有验收测试阶段,再有发布阶段,那就意味着只有在验收测试成功后,发布才会发生。顺序流水线易于理解,通常不会带来意外情况。因此,解决任何问题的首选方法是按顺序执行。
然而,在某些情况下,阶段执行时间较长,值得将它们并行运行。一个非常好的例子是性能测试。它们通常需要较长时间,因此,如果假设它们是独立且隔离的,选择并行运行是有意义的。在 Jenkins 中,我们可以在两个不同的层面上并行化流水线:
-
并行步骤:在一个阶段内,多个并行进程在同一代理上运行。这种方法很简单,因为所有与 Jenkins 工作区相关的文件都位于同一台物理机器上。然而,与垂直扩展一样,资源限制于这台机器。
-
如果在前一个阶段创建的文件在其他物理机器上需要,可以使用
stash(Jenkinsfile关键字)。
让我们看看实际操作中会是什么样子。如果我们想并行执行两个步骤,Jenkinsfile脚本应如下所示:
pipeline {
agent any
stages {
stage('Stage 1') {
steps {
parallel (
one: { echo "parallel step 1" },
two: { echo "parallel step 2" }
)
}
}
stage('Stage 2') {
steps {
echo "run after both parallel steps are completed"
}
}
}
}
在Stage 1中,使用parallel关键字,我们执行了两个并行步骤,one和two。请注意,Stage 2只有在两个并行步骤都完成后才会执行。这就是为什么这种解决方案在并行运行测试时非常安全;我们始终可以确保部署阶段只有在所有并行测试都通过之后才会执行。
上面的代码示例涉及的是并行步骤层次。另一种解决方案是使用并行阶段,因此每个阶段在不同的代理机器上运行。选择使用哪种并行化方法通常取决于两个因素:
-
代理机器的强大程度
-
给定阶段所需的时间
一般建议是,单元测试可以在并行步骤中运行,但性能测试通常最好在不同的机器上进行。
重用流水线组件
当Jenkinsfile脚本变得更大、更复杂时,我们可能希望在类似的流水线之间重用其部分内容。
例如,我们可能希望为不同的环境(开发、QA 和生产)拥有独立的(但相似的)流水线。在微服务世界中,另一个常见的例子是每个服务都有一个非常相似的 Jenkinsfile。那么,我们如何编写 Jenkinsfile 脚本,以便不重复相同的代码呢?有两个很好的模式可以实现这一目标:参数化构建和共享库。让我们分别来讨论这两种方式。
构建参数
我们之前在 第四章,持续集成流水线 中提到过,流水线可以有输入参数。我们可以使用这些参数为相同的流水线代码提供不同的用例。例如,让我们创建一个带有 environment 类型的流水线参数:
pipeline {
agent any
parameters {
string(name: 'Environment', defaultValue: 'dev', description: 'Which environment (dev, qa, prod)?')
}
stages {
stage('Environment check') {
steps {
echo "Current environment: ${params.Environment}"
}
}
}
}
该构建需要一个输入参数,Environment。然后,我们在这个步骤中所做的就是打印该参数。我们还可以添加条件,以便根据不同的环境执行不同的代码。
使用此配置,当我们启动构建时,我们将看到一个输入参数提示,如下所示:

图 9.7 – Jenkins 参数化构建
参数化构建可以帮助我们在略有不同的场景中重用流水线代码。然而,这个功能不应被过度使用,因为过多的条件可能使得 Jenkinsfile 难以理解。
共享库
另一个重用流水线的解决方案是将其部分提取到共享库中。
共享库是存储为独立的、源代码控制的项目的 Groovy 代码。以后可以在许多 Jenkinsfile 脚本中作为流水线步骤使用这些代码。为了更清楚地说明这一点,让我们看一个例子。使用共享库技术通常需要三个步骤:
-
创建一个共享库项目。
-
在 Jenkins 中配置共享库。
-
在
Jenkinsfile中使用共享库。
创建共享库项目
我们首先创建一个新的 Git 项目,在其中存放共享库代码。每个 Jenkins 步骤都作为一个 Groovy 文件表示,文件位于 vars 目录中。
让我们创建一个 sayHello 步骤,该步骤接受 name 参数并回显一条简单的消息。此代码应存放在 vars/sayHello.groovy 文件中:
/**
* Hello world step.
*/
def call(String name) {
echo "Hello $name!"
}
信息
共享库步骤的可读描述可以存储在 *.txt 文件中。在我们的例子中,我们可以添加 vars/sayHello.txt 文件以记录步骤文档。
当库代码完成后,我们需要将其推送到仓库,例如作为一个新的 GitHub 项目。
在 Jenkins 中配置共享库
下一步是将共享库注册到 Jenkins 中。我们打开 管理 Jenkins | 配置系统,找到 全局流水线库 部分。在这里,我们可以添加库,并为其指定一个选择的名称,如下所示:

图 9.8 – Jenkins 全局流水线库配置
我们指定了库的注册名称和库的仓库地址。请注意,库的最新版本将在管道构建过程中自动下载。
信息
我们展示了将 Groovy 代码作为全局共享库导入,但也有其他解决方案。详情请阅读www.jenkins.io/doc/book/pipeline/shared-libraries/。
在 Jenkinsfile 中使用共享库
最后,我们可以在Jenkinsfile中使用共享库:
pipeline {
agent any
stages {
stage("Hello stage") {
steps {
sayHello 'Rafal'
}
}
}
}
提示
如果@Library('example') _在Jenkinsfile脚本的开头。
如你所见,我们可以将 Groovy 代码用作sayHello管道步骤。显然,在管道构建完成后,我们应该在控制台输出中看到Hello Rafal!。
信息
共享库不限于一个步骤。事实上,凭借 Groovy 语言的强大功能,它们甚至可以作为整个 Jenkins 管道的模板。
在描述如何共享 Jenkins 管道代码之后,让我们再谈一谈在持续交付过程中回滚部署的几句话。
回滚部署
我记得我同事,一位高级架构师的话——你不需要更多的 QA,你需要更快的回滚。虽然这个说法过于简化,且 QA 团队通常非常有价值,但这句话中确实有不少真理。想想看,如果你在生产环境中引入了一个 bug,但在第一个用户报告错误后很快就回滚了,那么通常不会发生什么坏事。另一方面,如果生产环境中的错误很少发生,但没有回滚操作,那么调试生产环境的过程通常会以漫长的失眠之夜和一些不满的用户告终。这就是为什么我们在创建 Jenkins 管道时,需要提前思考回滚策略的原因。
在持续交付的背景下,失败可能发生的时刻有两个:
-
在发布过程中,在管道执行中
-
管道构建完成后,在生产环境中
第一个场景非常简单且无害。它涉及一个应用程序已经部署到生产环境,但下一个阶段失败的情况,例如烟雾测试失败。此时,我们只需要在post管道部分的failure情况下执行一个脚本,该脚本将生产服务降级到较旧的 Docker 镜像版本。如果我们使用蓝绿部署(稍后我们将在本章中描述),任何停机的风险将最小,因为通常我们会在烟雾测试之后的最后一个管道阶段执行负载均衡器切换。
第二种情况是在管道成功完成后,我们发现了一个生产环境的错误,这种情况更为复杂,需要稍作评论。在这种情况下,规则是我们应该总是通过与标准发布完全相同的流程来发布回滚的服务。否则,如果我们尝试以更快的方式手动处理,就会带来麻烦。任何非重复性的任务都是有风险的,特别是在生产环境故障时,压力更大。
信息
顺便提一下,如果管道成功完成,但出现了生产环境的错误,那么说明我们的测试还不够充分。因此,回滚后的第一件事是扩展单元/验收测试套件,加入相应的场景。
最常见的持续交付过程是一个单一的、完全自动化的管道,它从检出代码开始,到发布到生产环境结束。
下图展示了这个流程是如何运作的:

图 9.9 – 持续交付管道
本书中我们已经展示了经典的持续交付管道。如果回滚需要使用完全相同的流程,那么我们只需从代码库中恢复最新的代码更改。这样,管道会自动构建、测试,最终发布正确的版本。
信息
仓库回滚和紧急修复永远不应跳过管道中的测试阶段,否则,我们可能会得到一个仍然无法正常工作的发布版本,且由于其他问题,调试变得更加困难。
解决方案非常简单且优雅。唯一的缺点是我们需要在完整的管道构建中消耗一些停机时间。如果我们使用蓝绿部署或金丝雀发布,就可以避免这些停机时间,在这种情况下,我们只需要修改负载均衡器的设置以指向健康的环境。
在协调发布的情况下,回滚操作变得更加复杂,因为在协调发布过程中,多个服务会同时部署。这也是为什么协调发布被视为反模式,特别是在微服务世界中。正确的方法是始终保持向后兼容性,至少在一段时间内(如本章开始时我们展示的数据库)。然后,就可以独立发布每个服务。
添加手动步骤
通常,持续交付管道应该是完全自动化的,通过提交到代码库触发,并在发布后结束。然而,有时我们无法避免手动步骤。最常见的例子是发布批准,这意味着过程是完全自动化的,但仍然需要手动步骤来批准新的发布。另一个常见的例子是手动测试。有些手动测试是因为我们在操作遗留系统,有些则是因为某些测试无法自动化。不管是什么原因,有时我们别无选择,只能添加手动步骤。
Jenkins 语法提供了一个 input 关键字用于手动步骤:
stage("Release approval") {
steps {
input "Do you approve the release?"
}
}
流水线将在 input 步骤上停止执行,并等待手动批准。
请记住,手动步骤很快就会成为交付过程中的瓶颈,这就是为什么它们应始终被视为完全自动化的次优解的原因。
提示
有时为输入设置超时是有用的,以避免无限期等待手动交互。在配置的时间过去后,整个流水线将被中止。
我们已经涵盖了许多重要的流水线模式;现在让我们专注于不同的部署发布模式。
发布模式
在上一节中,我们讨论了用于加速构建执行(并行步骤)、帮助代码重用(共享库)、限制生产错误风险(回滚)以及处理手动批准(手动步骤)的 Jenkins 流水线模式。本节将专注于下一组模式;这次是与发布过程相关的。它们旨在减少将新软件版本更新到生产环境中的风险。
我们已经在 第六章 中描述了一个发布模式,即滚动更新,这里我们将介绍另外两种:蓝绿部署和金丝雀发布。
信息
在 Kubernetes 中使用发布模式的一个非常方便的方式是使用 Istio 服务网格。详细信息请参阅 istio.io/。
蓝绿部署
蓝绿部署是一种减少与发布相关的停机时间的技术。它涉及拥有两个相同的生产环境——一个称为 绿色,另一个称为 蓝色——如下图所示:

图 9.10 – 蓝绿部署
在图中,当前可访问的环境是蓝色的。如果我们要进行新版本发布,那么我们将所有内容部署到绿色环境,并在发布流程结束时将负载均衡器切换到绿色环境。因此,用户突然开始使用新版本。下次我们想发布时,我们对蓝色环境进行更改,最终将负载均衡器切换到蓝色。我们每次都按相同方式进行,从一个环境切换到另一个环境。
信息
蓝绿部署技术的正确运作需要满足两个假设:环境隔离和无编排的发布。
此解决方案提供以下好处:
-
零停机时间:从用户的角度来看,所有停机时间仅仅是改变负载均衡开关的时刻,这是可以忽略不计的。
-
回滚(Rollback):为了回滚到上一个版本,只需切换回负载均衡开关。
注意,蓝绿部署必须包括以下内容:
-
数据库:在回滚情况下,模式迁移可能会有些棘手,因此使用本章开头讨论的模式是值得的。
-
事务:运行数据库事务必须交给新数据库处理。
-
冗余基础设施/资源:我们需要准备两倍的资源。
有许多技术和工具可以克服这些挑战,因此蓝绿部署模式被广泛推荐,并在 IT 行业中得到广泛应用。
信息
您可以在 Martin Fowler 的博客上进一步阅读有关蓝绿部署技术的内容,网址是 martinfowler.com/bliki/BlueGreenDeployment.html。
金丝雀发布
金丝雀发布是一种减少引入新版本软件时风险的技术。与蓝绿部署类似,它使用两个相同的环境,如下图所示:

图 9.11 – 金丝雀发布
同样,类似于蓝绿部署技术,发布过程从在当前未使用的环境中部署新版本开始。不过,在这里,相似之处就结束了。负载均衡器不是切换到新环境,而是仅将一部分用户链接到新环境,其余用户仍然使用旧版本。通过这种方式,只有一部分用户可以测试新版本,如果出现错误,也仅会影响小范围的用户。测试期结束后,所有用户都将切换到新版本。
这种方法有一些很好的优点:
-
验收与性能测试:如果在暂存环境中难以进行验收与性能测试,则可以在生产环境中进行测试,最小化对小部分用户的影响。
-
简单回滚:如果新变更导致故障,可以通过将所有用户切换回旧版本来实现回滚。
-
A/B 测试:如果我们不确定新版本在用户体验或性能方面是否更好,可以将其与旧版本进行比较。
金丝雀发布与蓝绿部署有相同的缺点。额外的挑战是,我们同时运行了两个生产系统。尽管如此,金丝雀发布仍然是大多数公司广泛使用的优秀技术,帮助进行发布和测试。
信息
您可以在 Martin Fowler 的博客上进一步阅读有关金丝雀发布技术的内容,网址是 martinfowler.com/bliki/CanaryRelease.html。
与遗留系统的协作
目前为止我们所描述的一切适用于绿地项目,对于这些项目,设置持续交付管道相对简单。
然而,遗留系统要复杂得多,因为它们通常依赖手动测试和手动部署步骤。在这一部分中,我们将讲解如何逐步将持续交付应用于遗留系统的推荐场景。
作为第一步,我建议阅读 Michael Feathers 的一本好书,《与遗留代码有效合作》。他关于如何处理测试、重构以及添加新特性的观点,解决了大多数关于如何自动化遗留系统交付过程的疑虑。
信息
对于许多开发人员来说,完全重写遗留系统而不是重构它可能是一个诱人的选择。虽然从开发者的角度来看,这个想法很有趣,但通常这是一个糟糕的商业决策,最终导致产品失败。你可以在 Joel Spolsky 的一篇精彩博客《你永远不该做的事》中了解更多关于重写 Netscape 浏览器的历史,博客地址为 www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i。
应用持续交付过程的方式在很大程度上取决于当前项目的自动化程度、所使用的技术、硬件基础设施以及当前的发布流程。通常,它可以分为三个步骤:
-
自动化构建与部署
-
自动化测试
-
重构与引入新特性
让我们详细看看这些内容。
自动化构建与部署
第一阶段包括自动化部署过程。好消息是,在我曾经接触过的大多数遗留系统中,已经有一些自动化措施(例如,采用 Shell 脚本的形式)。
无论如何,自动化部署的活动包括以下内容:
-
构建与打包:通常,已经存在一些自动化措施,比如 Makefile、Ant、Maven 或任何其他构建工具的配置,或者是自定义脚本。
-
数据库迁移:我们需要开始逐步管理数据库架构。这要求将当前的架构作为初始迁移,并使用 Flyway 或 Liquibase 等工具进行所有进一步的更改,正如本章中已描述的那样。
-
部署:即使部署过程完全是手动的,通常也会有一个需要转化为自动化脚本的文本/维基页面描述。
-
可重复配置:在遗留系统中,配置文件通常是手动更改的。我们需要提取配置并使用配置管理工具,正如在 第七章《使用 Ansible 的配置管理》中所描述的那样。
在前面的步骤完成后,我们可以将所有内容放入部署管道,并在手动用户验收测试(UAT)周期后,作为自动化阶段使用。
从流程的角度来看,现在开始更频繁地发布版本已经是值得的。例如,如果发布周期是每年一次,尝试改为每季度发布一次,再到每月发布一次。推动这一因素将最终促使更快的自动化交付方式的采用。
自动化测试
下一步,通常更加困难,是为系统准备自动化测试。这需要与 QA 团队沟通,了解他们当前如何测试软件,从而将所有内容迁移到自动化验收测试套件中。这个阶段需要两个步骤:
-
验收/健康检查测试套件:我们需要添加自动化测试,替代 QA 团队的部分回归活动。根据系统的不同,可以使用黑盒 Selenium 测试或 Cucumber 测试提供这些测试。
-
(虚拟)测试环境:此时,我们应该已经开始考虑测试将在哪些环境中运行。通常,为了节省资源并限制所需机器的数量,最佳解决方案是使用 Vagrant 或 Docker 来虚拟化测试环境。
最终目标是拥有一个自动化验收测试套件,取代开发周期中的整个 UAT 阶段。然而,我们可以从一个检查系统是否正确的简单测试开始,重点是回归测试。
信息
在添加测试场景时,请记住,测试套件应该在合理的时间内执行。对于健康检查测试,通常要求时间不超过 10 分钟。
重构与引入新功能
当我们拥有基本的回归测试套件(至少)时,就可以开始添加新功能并重构旧代码。最好一步一步地进行小规模的重构,因为一次性重构所有代码通常会导致混乱,进而导致生产环境的故障(与某一特定更改无关)。
这个阶段通常包括以下活动:
-
重构:开始重构旧代码的最佳位置是预期要新增功能的地方。从这里开始,我们为未来的功能请求做好准备。
-
重写:如果我们计划重写旧代码的部分内容,应该从最难测试的代码开始。这样,我们可以不断增加项目中的代码覆盖率。
-
引入新功能:在实现新功能时,使用功能开关模式是值得推荐的。这样,如果出现问题,我们可以迅速关闭新功能。重构时也应使用相同的模式。
信息
对于这个阶段,阅读马丁·福勒的《重构:改善现有代码的设计》这本非常好的书是值得的。
在处理旧代码时,最好遵循一个规则:始终先添加一个通过的单元测试,然后再修改代码。通过这种方法,我们可以依赖自动化来检查我们是否不小心改变了业务逻辑。
理解人的因素
在向旧系统引入自动化交付过程时,可能会比其他地方更能感受到人因素。为了实现构建过程的自动化,我们需要与运维团队良好沟通,而他们必须愿意分享他们的知识。同样的道理也适用于手动 QA 团队;他们需要参与编写自动化测试,因为只有他们知道如何测试软件。如果仔细想想,运维和 QA 团队都需要为后期自动化他们的工作做出贡献。在某些时候,他们可能会意识到自己在公司中的未来不稳定,从而变得不那么热衷提供帮助。许多公司在引入持续交付过程中面临困难,因为团队不愿意充分参与。
在本节中,我们讨论了如何处理旧系统及其带来的挑战。如果您正在将项目和组织转变为持续交付方式,那么您可能希望查看《持续交付成熟度模型》,该模型旨在为采用自动化交付的过程提供一些结构。
总结
本章是对各种持续交付方面的混合介绍,这些方面以前没有涉及。本章的关键要点如下:
-
数据库是大多数应用程序的核心部分,因此应将其纳入持续交付过程。
-
数据库架构更改存储在版本控制系统中,并通过数据库迁移工具进行管理。
-
数据库架构更改有两种类型:向后兼容和向后不兼容。第一种类型比较简单,而第二种类型需要一些额外的开销(需要分多次迁移,分布在一段时间内)。
-
数据库不应是整个系统的核心。首选的解决方案是为每个服务提供其自己的数据库。
-
交付过程应该始终为回滚场景做好准备。
-
在考虑发布模式时,应该始终考虑三种模式:滚动更新、蓝绿部署和金丝雀发布。
-
旧系统可以通过小步快走的方式转变为持续交付过程,而不是一次性完成。
接下来,在书的最后部分,我们将研究持续交付过程的最佳实践。
练习
在本章中,我们介绍了持续交付过程的各个方面。由于实践能带来完美,我们推荐以下练习:
-
使用 Flyway 在 MySQL 数据库中创建一个向后不兼容的更改:
-
使用官方 Docker 镜像
mysql启动数据库。 -
使用正确的数据库地址、用户名和密码配置 Flyway。
-
创建一个初始迁移,该迁移创建一个包含三个列的
USERS表:ID、EMAIL和PASSWORD。 -
向表中添加示例数据。
-
将
PASSWORD列更改为HASHED_PASSWORD,该列将存储哈希后的密码。 -
将向后不兼容的更改分成三个迁移,如本章所述。
-
你可以使用
MD5或SHA进行哈希处理。 -
检查数据库是否未将任何密码以明文形式存储。
-
-
创建一个 Jenkins 共享库,包含构建和单元测试 Gradle 项目的步骤:
-
为库创建一个独立的代码库。
-
在库中创建两个文件:
gradleBuild.groovy和gradleTest.groovy。 -
编写适当的
call方法。 -
将库添加到 Jenkins。
-
在管道中使用库中的步骤。
-
问题
为了验证本章的知识,请回答以下问题:
-
什么是数据库(模式)迁移?
-
你能列举至少三个数据库迁移工具吗?
-
数据库模式的主要两种变更类型是什么?
-
为什么一个数据库不应该在多个服务之间共享?
-
单元测试与集成/验收测试的数据有什么区别?
-
在 Jenkins 管道中使用什么关键字来使步骤并行执行?
-
有哪些不同的方法可以重用 Jenkins 管道组件?
-
在 Jenkins 管道中使用什么关键字来创建手动步骤?
-
本章中提到的三种发布模式是什么?
进一步阅读
要深入了解持续交付过程的高级方面,请参考以下资源:
-
数据库作为持续交付的挑战:
phauer.com/2015/databases-challenge-continuous-delivery/。 -
使用数据库的零停机部署:
spring.io/blog/2016/05/31/zero-downtime-deployment-with-a-database。
第十章:最佳实践
感谢阅读这本书。我希望你已经准备好将持续交付方法引入你的 IT 项目。作为本书的最后一部分,我提出了持续交付的十大最佳实践列表。祝你阅读愉快!
实践 1 – 在团队内掌控过程!
在团队内拥有整个过程的掌控,从接收需求到监控生产。如曾经所说,"开发者机器上的程序不能赚钱。"这就是为什么拥有一个小型的 DevOps 团队并完全负责产品是很重要的。实际上,这正是DevOps的真正含义:开发与运维,从头到尾:
-
掌控持续交付管道的每个阶段:如何构建软件,验收测试中的要求是什么,以及如何发布产品。
-
避免拥有专门的管道专家!团队中的每个成员都应该参与创建管道。
-
找到一种良好的方式与团队成员共享当前的管道状态(以及生产监控)。最有效的解决方案是在团队空间内放置大屏幕。
-
如果开发者、QA 和 IT 运维工程师是不同的专家,那么确保他们在一个敏捷团队中共同工作。基于专长划分的团队会导致没有人对产品负责。
-
记住,赋予团队自主权会带来高水平的工作满意度和卓越的参与感。这将带来伟大的产品!
实践 2 – 自动化一切!
自动化一切,从业务需求(以验收测试的形式)到部署过程。手动描述、包含步骤说明的 Wiki 页面都会迅速过时,导致部落知识的积累,使得过程变得缓慢、繁琐且不可靠。这反过来又导致需要进行发布演练,使得每次部署都变得独一无二。不要走这条路!作为规则,如果你做任何事情已经是第二次,自动化它:
-
消除所有手动步骤;它们是错误的源头!整个过程必须是可重复和可靠的。
-
永远不要直接在生产环境中进行任何更改!请改用配置管理工具。
-
使用完全相同的机制来部署到每个环境中。
-
始终包含自动化冒烟测试,以检查发布是否成功完成。
-
使用数据库模式迁移来自动化数据库变更。
-
使用自动化维护脚本进行备份和清理。别忘了删除未使用的 Docker 镜像!
实践 3 – 版本化所有内容!
版本化所有内容:软件源代码、构建脚本、自动化测试、配置管理文件、持续交付管道、监控脚本、二进制文件以及文档;简而言之,一切都要版本化。将工作任务化,每个任务都会导致一个提交到仓库,无论该任务是与需求收集、架构设计、配置还是软件开发相关。任务从敏捷看板开始,最终结束在仓库中。这样,你就能维护一个单一的真实来源,记录变更的历史和原因:
-
严格遵循版本控制。版本化所有内容意味着一切!
-
将源代码和配置存储在代码仓库中,将二进制文件存储在工件仓库中,并将任务存储在敏捷问题追踪工具中。
-
将持续交付管道开发为代码。
-
使用数据库迁移并将其存储在仓库中。
-
将文档存储为可以版本控制的 markdown 文件形式。
实践 4 – 使用业务语言进行接受测试
在接受测试中使用面向业务的语言,以促进相互沟通,并帮助更好地理解需求。与产品负责人紧密合作,创建埃里克·埃文(Eric Evans)所称的普遍语言,即业务和技术之间的共同语言。误解是大多数项目失败的根本原因:
-
创建一个通用语言,并在项目中使用它。
-
使用接受测试框架,如 Cucumber 或 FitNesse,帮助业务团队理解并使他们参与进来。
-
在接受测试中表达业务价值,并且在开发过程中不要忽视它们。很容易在无关的议题上花费过多时间!
-
改进并维护接受测试,使其始终充当回归测试。
-
确保每个人都意识到,接受测试套件通过意味着业务部门同意发布软件。
实践 5 – 为回滚做好准备
为回滚做好准备;迟早你会需要它。记住,你不需要更多的 QA;你需要一个更快的回滚。如果生产环境出现问题,你首先要做的就是保持安全并回到最后一个正常版本:
-
制定回滚策略,并规划当系统故障时该做的事。
-
将不兼容的数据库变更拆分为兼容的变更。
-
始终使用相同的交付流程进行回滚和标准发布。
-
考虑引入蓝绿部署或金丝雀发布。
-
不要害怕 bug;如果你快速反应,用户是不会离开的!
实践 6 – 不低估人的影响
不要低估人的影响。人们通常比工具更重要。如果 IT 运维团队不愿意帮助你,你将无法实现自动化交付。毕竟,他们了解当前的流程。相同的道理适用于质量保证(QA)人员、业务部门以及所有相关人员。让他们成为重要角色并积极参与:
-
让 QA 和 IT 运维成为 DevOps 团队的一部分。你需要他们的知识和技能!
-
为当前执行手动操作的成员提供培训,使他们能够转向自动化。
-
更倾向于非正式沟通和扁平化的组织结构,而非等级制度和命令。没有善意,你什么都做不成!
实践 7 - 纳入可追溯性
将可追溯性纳入交付过程和工作系统中。没有任何日志信息的失败是最糟糕的。监控请求数量、延迟、生产服务器的负载、持续交付流水线的状态,以及任何你认为能帮助你分析当前软件的内容。要主动!在某些时候,你需要检查统计数据和日志:
-
记录流水线活动!在发生失败时,使用信息丰富的消息通知团队。
-
实现运行系统的适当日志记录和监控。
-
使用专门的系统监控工具,如 Kibana、Grafana 或 Logmatic.io。
-
将生产监控集成到你的开发生态系统中。考虑在公共团队空间中放置大屏幕显示当前的生产统计数据。
实践 8 - 经常集成
经常集成;事实上,应该始终集成!正如某人曾说过,"持续集成比你想象的更频繁。"没有什么比解决合并冲突更让人沮丧的了。持续集成更关乎团队实践,而非工具。每天至少将代码集成到一个代码库几次。忘掉长时间存在的功能分支和大量的本地更改。基于主干的开发和特性开关才是胜利之道!
-
使用基于主干的开发和特性开关,而不是功能分支。
-
如果需要一个分支或本地更改,请确保每天至少与团队其他成员进行一次集成。
-
始终保持主干的健康;在合并到基线之前确保先运行测试。
-
每次提交到代码库后都运行流水线,以获得更快速的反馈周期。
实践 9 - 只构建一次二进制文件
只构建一次二进制文件,并在每个环境中运行相同的文件,无论它们是以 Docker 镜像还是 JAR 包的形式存在;只构建一次可以消除由于不同环境引入的差异风险,同时节省时间和资源:
-
一次构建,跨环境传递相同的二进制文件。
-
使用工件库来存储和版本控制二进制文件。绝不要将源代码库用于此目的。
-
外部化配置并使用配置管理工具来引入环境之间的差异。
实践 10 - 经常发布
经常发布,最好在每次提交到代码仓库后进行发布。正如俗话所说,"如果痛苦,就多做几次"。将发布作为日常例行公事,使得过程变得可预测且平稳。避免陷入稀有发布的习惯。那样只会越来越糟,最终你将只在一年发布一次,并且准备期长达三个月!
-
重新定义完成的标准为完成意味着已发布。对整个过程负责!
-
使用功能开关将仍在进行中的功能对用户隐藏。
-
使用金丝雀发布和快速回滚来降低生产环境中出现错误的风险。
-
采用零停机时间的部署策略,以支持频繁发布。
在本书的最后部分,我们已经覆盖了关于持续交付过程的最重要的理念和工具。我希望你觉得这些内容有价值,并祝你在持续交付的旅程中一切顺利!
第十一章:评估
在接下来的页面中,我们将回顾每一章的所有实践题并提供正确答案。
本书中的章节并提供正确答案。
第一章: 介绍持续交付
-
开发、质量保证、运维。
-
持续集成、自动化验收测试、配置管理。
-
快速交付、快速反馈周期、低风险发布、灵活的发布选项。
-
单元测试、集成测试、验收测试、非功能性测试(性能、安全性、可扩展性等)。
-
单元测试,因为它们创建/维护成本低且执行速度快。
-
DevOps 是将开发、质量保证和运维这三个领域结合成一个团队(或一个人)的理念。得益于自动化,产品可以从头到尾地提供。
-
Docker、Jenkins、Ansible、Terraform、Git、Java、Spring Boot、Gradle、Cucumber、Kubernetes。
第二章: 介绍 Docker
-
容器化并不模拟整个操作系统,而是使用宿主操作系统。
-
提供应用程序作为 Docker 镜像的好处如下:
-
没有依赖问题:应用程序与其依赖项一起提供。
-
隔离性:应用程序与同一台机器上运行的其他应用程序相互隔离。
-
可移植性:无论存在什么环境依赖,应用程序都能在任何地方运行。
-
-
不,Docker 守护进程只能在 Linux 主机上原生运行。然而,Windows 和 Mac 上有很好集成的虚拟环境。
-
Docker 镜像是一个无状态的、序列化的文件集合,并附带了如何使用它们的说明;Docker 容器是 Docker 镜像的运行实例。
-
Docker 镜像是建立在另一个 Docker 镜像之上的,这样就形成了分层结构。这个机制对用户友好,并节省了带宽和存储。
-
Docker Commit 和 Dockerfile。
-
docker build。 -
docker run。 -
发布端口意味着将主机的端口转发到容器的端口。
-
Docker 卷是 Docker 主机的目录,挂载在容器内部。
第三章: 配置 Jenkins
-
是的,镜像名称是
jenkins/jenkins。 -
Jenkins 主节点是调度任务并提供 web 界面的主要实例,而 Jenkins 从节点(代理)是专门执行任务的附加实例。
-
垂直扩展意味着在负载增加时向机器添加更多资源;水平扩展意味着在负载增加时添加更多机器。
-
SSH 和 Java Web Start。
-
永久代理是最简单的解决方案,它意味着创建一个静态服务器,并为执行 Jenkins 作业准备好所有环境。另一方面,永久 Docker 代理更灵活;它提供了 Docker 守护进程,所有作业都在 Docker 容器内执行。
-
如果你使用动态配置的 Docker 代理,并且标准代理(互联网上可用的)无法提供你所需的执行环境。
-
当组织需要不同团队使用某些模板化的 Jenkins 时。
-
Blue Ocean 是一个 Jenkins 插件,提供了更现代化的 Jenkins Web 界面。
第四章:持续集成流水线
-
流水线是一系列自动化操作,通常表示软件交付和质量保证过程的一部分。
-
步骤是单个自动化操作,而阶段是用于可视化 Jenkins 流水线过程的逻辑步骤组合。
-
post部分定义了一系列在流水线构建结束时执行的步骤指令。 -
检出、编译和单元测试。
-
Jenkinsfile 是一个包含 Jenkins 流水线定义的文件(通常与源代码一起存储在仓库中)。
-
代码覆盖阶段负责检查源代码是否有良好的(单元)测试覆盖。
-
外部触发器是外部仓库(如 GitHub)对 Jenkins 主节点的调用,而 SCM 定期轮询是 Jenkins 主节点对外部仓库的周期性调用。
-
电子邮件、群聊、构建仪表板、短信、RSS 订阅。
-
基于 trunk 的工作流、分支工作流和分叉工作流。
-
功能开关是一种技术,用于在测试时禁用用户的某些功能,但仍然允许开发人员使用这些功能。功能开关本质上是用于条件语句中的变量。
第五章:自动化验收测试
-
Docker Registry 是一个无状态的应用服务器,用于存储 Docker 镜像。
-
Docker Hub 是最著名的公共 Docker 仓库。
-
约定是
<registry_address>/<image_name>:<tag>。 -
Staging 环境是专门用于集成和验收测试的预生产环境。
-
以下命令:
docker build、docker login和docker push。 -
它们允许我们以人类可读的格式指定测试,有助于业务和开发人员之间的协作。
-
验收标准(功能场景规范)、步骤定义、测试运行器。
-
验收测试驱动开发是一种开发方法论(被视为 TDD 的扩展),它要求始终从(失败的)验收测试开始开发过程。
第六章:使用 Kubernetes 进行集群化
-
服务器集群是一组相互连接的计算机,它们协同工作,可以在单一系统内以类似的方式使用。
-
Kubernetes 节点只是一个工作节点,也就是运行容器的主机。Kubernetes 控制平面主节点负责其他所有任务(提供 Kubernetes API、Pod 协调等)。
-
Microsoft Azure、Google Cloud Platform 和 Amazon Web Services。
-
部署是一个 Kubernetes 资源,负责 Pod 的编排(创建、终止等)。服务是一个(内部)负载均衡器,提供了一种暴露 Pod 的方式。
-
kubectl scale。 -
Docker Swarm 和 Mesos。
第七章:使用 Ansible 进行配置管理
-
配置管理是控制配置变更的过程,确保系统随时间保持完整性。
-
无代理意味着你不需要在被管理的服务器上安装任何特殊的工具(代理或守护进程)。
-
Ansible、Chef 和 Puppet。
-
库存是一个包含由 Ansible 管理的服务器列表的文件。
-
临时命令是在服务器上执行的单个命令,而剧本是完整的配置(脚本集合),在服务器上执行。
-
Ansible 角色是一个结构良好的剧本,可以包含在其他剧本中。
-
Ansible Galaxy 是一个 Ansible 角色的存储库(仓库)。
-
基础设施即代码是管理和配置计算资源的过程,而不是物理硬件配置。
-
Terraform、AWS CloudFormation、Azure 资源管理器、Google Cloud 部署管理器、Ansible、Chef、Puppet、Pulumi、Vagrant。
第八章:持续交付流水线
-
生产、预生产、质量保证、开发。
-
预生产环境是用来在发布前测试软件的环境;质量保证(QA)是一个由 QA 团队和依赖应用使用的独立环境。
-
性能、负载、压力、可扩展性、耐久性、安全性、可维护性、恢复。
-
不,应该明确哪些是流水线的一部分,哪些不是(对于那些不是的部分,仍然应该有一些自动化和监控)。
-
语义版本控制、基于时间戳、基于哈希。
-
烟雾测试是验收测试的一个非常小的子集,唯一目的是检查发布过程是否成功完成。
第九章:高级持续交付
-
数据库架构迁移是对关系数据库结构进行增量更改的过程。
-
Flyway、Liquibase、Rails 迁移(来自 Ruby on Rails)、Redgate、Optima 数据库管理员。
-
向后兼容和非向后兼容。
-
如果一个数据库被多个服务共享,那么每次数据库变更必须与所有服务兼容,这使得发起变更变得非常困难。
-
单元测试不需要准备任何特殊数据;数据存在内存中,并由开发人员准备;集成/验收测试需要准备类似生产数据的特殊数据。
-
并行。
-
构建参数和共享库。
-
输入。
-
滚动更新、蓝绿部署和金丝雀发布。


浙公网安备 33010602011771号