Docker-和-Jenkins-持续交付(全)

Docker 和 Jenkins 持续交付(全)

原文:zh.annas-archive.org/md5/7C44824F34694A0D5BA0600DC67F15A8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

多年来,我一直观察软件交付流程。我写了这本书,因为我知道有多少人仍然在发布过程中挣扎,并在日夜奋斗后感到沮丧。尽管多年来已经开发了许多自动化工具和流程,但这一切仍在发生。当我第一次看到持续交付流程是多么简单和有效时,我再也不愿意回到繁琐的传统手动交付周期。这本书是我经验的结果,也是我进行的许多持续交付研讨会的结果。我分享了使用 Jenkins、Docker 和 Ansible 的现代方法;然而,这本书不仅仅是工具。它介绍了持续交付背后的理念和推理,最重要的是,我向所有我遇到的人传达的主要信息:持续交付流程很简单,要使用它!

本书内容

第一章《介绍持续交付》介绍了公司传统的软件交付方式,并解释了使用持续交付方法改进的理念。本章还讨论了引入该流程的先决条件,并介绍了本书将构建的系统。

第二章《介绍 Docker》解释了容器化的概念和 Docker 工具的基础知识。本章还展示了如何使用 Docker 命令,将应用程序打包为 Docker 镜像,发布 Docker 容器的端口,并使用 Docker 卷。

第三章《配置 Jenkins》介绍了如何安装、配置和扩展 Jenkins。本章还展示了如何使用 Docker 简化 Jenkins 配置,并实现动态从节点供应。

第四章《持续集成管道》解释了流水线的概念,并介绍了 Jenkinsfile 语法。本章还展示了如何配置完整的持续集成管道。

第五章《自动验收测试》介绍了验收测试的概念和实施。本章还解释了工件存储库的含义,使用 Docker Compose 进行编排,以及编写面向 BDD 的验收测试的框架。

第六章,使用 Ansible 进行配置管理,介绍了配置管理的概念及其使用 Ansible 的实现。本章还展示了如何将 Ansible 与 Docker 和 Docker Compose 一起使用。

第七章,持续交付流水线,结合了前几章的所有知识,以构建完整的持续交付过程。本章还讨论了各种环境和非功能测试的方面。

第八章,使用 Docker Swarm 进行集群,解释了服务器集群的概念及其使用 Docker Swarm 的实现。本章还比较了替代的集群工具(Kubernetes 和 Apache Mesos),并解释了如何将集群用于动态 Jenkins 代理。

第九章,高级持续交付,介绍了与持续交付过程相关的不同方面的混合:数据库管理、并行流水线步骤、回滚策略、遗留系统和零停机部署。本章还包括持续交付过程的最佳实践。

本书所需内容

Docker 需要 64 位 Linux 操作系统。本书中的所有示例都是使用 Ubuntu 16.04 开发的,但任何其他具有 3.10 或更高内核版本的 Linux 系统都足够。

本书适合对象

本书适用于希望改进其交付流程的开发人员和 DevOps。无需先前知识即可理解本书。

约定

在本书中,您将找到一些区分不同信息种类的文本样式。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:docker info

代码块设置如下:

      pipeline {
           agent any
           stages {
                stage("Hello") {
                     steps {
                          echo 'Hello World'
                     }
                }
           }
      }

当我们希望引起您对代码块的特定部分的注意时,相关行或项目以粗体设置:

 FROM ubuntu:16.04
 RUN apt-get update && \
 apt-get install -y python

任何命令行输入或输出都以以下方式编写:

$ docker images
REPOSITORY              TAG     IMAGE ID         CREATED            SIZE
ubuntu_with_python      latest  d6e85f39f5b7  About a minute ago 202.6 MB
ubuntu_with_git_and_jdk latest  8464dc10abbb  3 minutes ago      610.9 MB

新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中显示为这样:"点击 新项目"。

警告或重要说明以这样的框出现。

如果您的 Docker 守护程序在公司网络内运行,您必须配置 HTTP 代理。详细说明可以在docs.docker.com/engine/admin/systemd/找到。

提示和技巧会显示在这样。

所有支持的操作系统和云平台的安装指南都可以在官方 Docker 页面docs.docker.com/engine/installation/上找到。

第一章:介绍持续交付

大多数开发人员面临的常见问题是如何快速而安全地发布已实施的代码。然而,传统上使用的交付流程是一个陷阱的来源,通常会导致开发人员和客户的失望。本章介绍了持续交付方法的概念,并为本书的其余部分提供了背景。

本章涵盖以下要点:

  • 介绍传统的交付流程及其缺点

  • 描述持续交付的概念及其带来的好处

  • 比较不同公司如何交付其软件

  • 解释自动化部署流水线及其阶段

  • 对不同类型的测试及其在流程中的位置进行分类

  • 指出成功的持续交付流程的先决条件

  • 介绍本书中将使用的工具

  • 展示本书中将构建的完整系统

什么是持续交付?

持续交付的最准确定义由 Jez Humble 提出,如下所述:“持续交付是能够以可持续的方式将各种类型的变更,包括新功能、配置变更、错误修复和实验,安全快速地投入生产或交付给用户的能力。”该定义涵盖了关键点。

为了更好地理解,让我们想象一个场景。你负责产品,比如说电子邮件客户端应用程序。用户向你提出一个新的需求——他们希望按大小对邮件进行排序。你决定开发需要大约一周的时间。用户可以在什么时候期待使用这个功能呢?通常,在开发完成后,你首先将已完成的功能交给质量保证团队,然后再交给运维团队,这需要额外的时间,从几天到几个月不等。因此,即使开发只花了一周的时间,用户也要在几个月后才能收到!持续交付方法通过自动化手动任务来解决这个问题,使用户能够在实施新功能后尽快收到。

为了更好地展示要自动化的内容和方式,让我们从描述目前大多数软件系统使用的交付流程开始。

传统的交付流程

传统的交付流程,顾名思义,已经存在多年,并在大多数 IT 公司中实施。让我们定义一下它的工作原理,并评论其缺点。

介绍传统交付过程

任何交付过程都始于客户定义的需求,并以在生产环境上发布结束。差异在于中间。传统上,它看起来如下发布周期图表所示:

发布周期始于产品负责人提供的需求,他代表客户(利益相关者)。然后有三个阶段,在这些阶段中,工作在不同的团队之间传递:

  • 开发:在这里,开发人员(有时与业务分析师一起)致力于产品。他们经常使用敏捷技术(Scrum 或 Kanban)来提高开发速度并改善与客户的沟通。演示会议被组织起来以获得客户的快速反馈。所有良好的开发技术(如测试驱动开发或极限编程实践)都受到欢迎。实施完成后,代码传递给质量保证团队。

  • 质量保证:这个阶段通常被称为用户验收测试UAT),它需要对主干代码库进行代码冻结,以防止新的开发破坏测试。质量保证团队执行一系列集成测试验收测试非功能测试(性能,恢复,安全等)。检测到的任何错误都会返回给开发团队,因此开发人员通常也有很多工作要做。完成 UAT 阶段后,质量保证团队批准了下一个发布计划的功能。

  • 运营:最后一个阶段,通常是最短的一个阶段,意味着将代码传递给运营团队,以便他们可以执行发布并监控生产。如果出现任何问题,他们会联系开发人员帮助处理生产系统。

发布周期的长度取决于系统和组织,但通常范围从一周到几个月不等。我听说过最长的是一年。我工作过的最长周期是季度为基础,每个部分的时间分配如下:开发-1.5 个月,UAT-1 个月和 3 周,发布(严格的生产监控)-1 周。

传统交付过程在 IT 行业广泛使用,这可能不是你第一次读到这样的方法。尽管如此,它有许多缺点。让我们明确地看一下它们,以了解为什么我们需要努力追求更好的东西。

传统交付过程的缺点

传统交付过程的最显著缺点包括以下内容:

  • 交付速度慢:在这里,客户在需求规定之后很长时间才收到产品。这导致了不满意的上市时间和客户反馈的延迟。

  • 长反馈周期:反馈周期不仅与客户有关,还与开发人员有关。想象一下,你意外地创建了一个错误,而你在 UAT 阶段才得知。修复你两个月前工作的东西需要多长时间?即使是小错误也可能需要几周的时间。

  • 缺乏自动化:稀少的发布不鼓励自动化,这导致了不可预测的发布。

  • 风险的紧急修复:紧急修复通常不能等待完整的 UAT 阶段,因此它们往往会以不同的方式进行测试(UAT 阶段缩短)或者根本不进行测试。

  • 压力:不可预测的发布对运营团队来说是有压力的。而且,发布周期通常安排得很紧,这给开发人员和测试人员增加了额外的压力。

  • 沟通不畅:工作从一个团队传递到另一个团队代表了瀑布式方法,人们开始只关心自己的部分,而不是整个产品。如果出了什么问题,通常会导致责备游戏,而不是合作。

  • 共同责任:没有团队从头到尾对产品负责。对于开发人员来说,“完成”意味着需求已经实现。对于测试人员来说,“完成”意味着代码已经测试过。对于运营人员来说,“完成”意味着代码已经发布。

  • 工作满意度降低:每个阶段对不同的团队来说都很有趣,但其他团队需要支持这个过程。例如,开发阶段对开发人员来说很有趣,但在另外两个阶段,他们仍然需要修复错误并支持发布,这通常对他们来说一点都不有趣。

这些缺点只是传统交付过程相关挑战的冰山一角。你可能已经感觉到一定有更好的方法来开发软件,而这种更好的方法显然就是持续交付的方法。

持续交付的好处

“你的组织需要多长时间来部署只涉及一行代码的更改?你是否能够重复、可靠地做到这一点?”这些是 Mary 和 Tom Poppendieck(《实施精益软件开发》的作者)的著名问题,被 Jez Humble 和其他作者多次引用。实际上,对这些问题的回答是衡量交付流程健康的唯一有效标准。

为了能够持续交付,而不需要花费大量资金雇佣 24/7 工作的运维团队,我们需要自动化。简而言之,持续交付就是将传统交付流程的每个阶段转变为一系列脚本,称为自动化部署管道或持续交付管道。然后,如果不需要手动步骤,我们可以在每次代码更改后运行流程,因此持续向用户交付产品。

持续交付让我们摆脱了繁琐的发布周期,因此带来了以下好处:

  • 快速交付:市场推出时间大大缩短,因为客户可以在开发完成后立即使用产品。请记住,软件在用户手中之前不会产生收入。

  • 快速反馈循环:想象一下,你在代码中创建了一个 bug,当天就进入了生产环境。修复当天工作的东西需要多长时间?可能不多。这与快速回滚策略一起,是保持生产稳定的最佳方式。

  • 低风险发布:如果每天发布,那么流程变得可重复,因此更安全。俗话说,“如果疼,就多做几次。”

  • 灵活的发布选项:如果需要立即发布,一切都已准备就绪,因此发布决策不会带来额外的时间/成本。

不用说,我们可以通过消除所有交付阶段并直接在生产环境上进行开发来实现所有好处。然而,这会导致质量下降。实际上,引入持续交付的整个困难在于担心质量会随着消除手动步骤而下降。在本书中,我们将展示如何以安全的方式处理这个问题,并解释为什么与常见观念相反,持续交付的产品 bug 更少,更适应客户的需求。

成功案例

我最喜欢的持续交付故事是 Rolf Russell 在其中一次演讲中讲述的。故事如下。2005 年,雅虎收购了 Flickr,这是开发者世界中两种文化的冲突。当时的 Flickr 是一家以初创公司方法为主的公司。相反,雅虎是一家拥有严格规定和安全至上态度的大型公司。他们的发布流程有很大不同。雅虎使用传统的交付流程,而 Flickr 每天发布多次。开发人员实施的每个更改都在当天上线。他们甚至在页面底部有一个页脚,显示最后一次发布的时间以及进行更改的开发人员的头像。

雅虎很少部署,每次发布都带来了很多经过充分测试和准备的更改。Flickr 以非常小的块工作,每个功能都被分成小的增量部分,并且每个部分都快速部署到生产环境。差异如下图所示:

你可以想象当两家公司的开发人员相遇时会发生什么。雅虎显然把 Flickr 的同事当作不负责任的初级开发人员,“一群不知道自己在做什么的软件牛仔。”因此,他们想要改变的第一件事是将 QA 团队和 UAT 阶段加入 Flickr 的交付流程。然而,在应用更改之前,Flickr 的开发人员只有一个愿望。他们要求评估整个雅虎公司中最可靠的产品。当发生这种情况时,令人惊讶的是,雅虎所有软件中,Flickr 的停机时间最短。雅虎团队起初不理解,但还是让 Flickr 保持他们当前的流程。毕竟,他们是工程师,所以评估结果是确凿的。只是过了一段时间,他们意识到持续交付流程对雅虎的所有产品都有益处,他们开始逐渐在所有地方引入它。

故事中最重要的问题是-Flickr 如何成为最可靠的系统?实际上,这个事实的原因已经在前面的部分提到过。如果一个发布是少量风险的话:

  • 代码更改的增量很小

  • 这个过程是可重复的。

这就是为什么,即使发布本身是一项困难的活动,但频繁进行发布时要安全得多。

雅虎和 Flickr 的故事只是许多成功公司的一个例子,对于这些公司来说,持续交付流程被证明是正确的。其中一些甚至自豪地分享了他们系统的细节,如下:

  • 亚马逊:2011 年,他们宣布在部署之间平均达到 11.6 秒

  • Facebook:2013 年,他们宣布每天部署代码更改两次

  • HubSpot:2013 年,他们宣布每天部署 300 次

  • Atlassian:2016 年,他们发布了一项调查,称他们 65%的客户实践持续交付

您可以在continuousdelivery.com/evidence-case-studies/阅读有关持续交付流程和个案研究的更多研究。

请记住,统计数据每天都在变得更好。然而,即使没有任何数字,想象一下每行代码您实现都安全地进入生产的世界。客户可以迅速做出反应并调整他们的需求,开发人员很高兴,因为他们不必解决那么多的错误,经理们很满意,因为他们总是知道当前的工作状态。毕竟,记住,唯一真正的进展度量是发布的软件。

自动化部署流水线

我们已经知道持续交付流程是什么,以及为什么我们使用它。在这一部分,我们将描述如何实施它。

让我们首先强调传统交付流程中的每个阶段都很重要。否则,它根本不会被创建。没有人想在没有测试的情况下交付软件!UAT 阶段的作用是检测错误,并确保开发人员创建的内容是客户想要的。运维团队也是如此——软件必须配置、部署到生产环境并进行监控。这是毋庸置疑的。那么,我们如何自动化这个过程,以便保留所有阶段?这就是自动化部署流水线的作用,它由以下图表中呈现的三个阶段组成:

自动化部署流水线是一系列脚本,每次提交到存储库的代码更改后都会执行。如果流程成功,最终会部署到生产环境。

每个步骤对应传统交付流程中的一个阶段,如下所示:

  • 持续集成:这个阶段检查不同开发人员编写的代码是否能够整合在一起

  • 自动验收测试:这取代了手动的 QA 阶段,并检查开发人员实现的功能是否符合客户的要求

  • 配置管理:这取代了手动操作阶段-配置环境并部署软件。

让我们深入了解每个阶段的责任和包括哪些步骤。

持续集成

持续集成阶段为开发人员提供了第一次反馈。它从代码库检出代码,编译代码,运行单元测试,并验证代码质量。如果任何步骤失败,管道执行将停止,开发人员应该做的第一件事是修复持续集成构建。这个阶段的关键是时间;它必须及时执行。例如,如果这个阶段需要一个小时才能完成,那么开发人员会更快地提交代码,这将导致持续失败的管道。

持续集成管道通常是起点。设置它很简单,因为一切都在开发团队内部完成,不需要与 QA 和运维团队达成协议。

自动验收测试

自动验收测试阶段是与客户(和 QA)一起编写的一套测试,旨在取代手动的 UAT 阶段。它作为一个质量门,决定产品是否准备发布。如果任何验收测试失败,那么管道执行将停止,不会运行进一步的步骤。它阻止了进入配置管理阶段,因此也阻止了发布。

自动化验收阶段的整个理念是将质量构建到产品中,而不是在后期进行验证。换句话说,当开发人员完成实现时,软件已经与验收测试一起交付,这些测试验证了软件是否符合客户的要求。这是对测试软件思维的一个重大转变。不再有一个人(或团队)批准发布,一切都取决于通过验收测试套件。这就是为什么创建这个阶段通常是持续交付过程中最困难的部分。它需要与客户的密切合作,并在过程的开始(而不是结束)创建测试。

在遗留系统的情况下,引入自动化验收测试尤其具有挑战性。我们在第九章 高级持续交付中对这个主题进行了更详细的描述。

关于测试类型及其在持续交付过程中的位置通常存在很多混淆。也经常不清楚如何自动化每种类型,应该有多少覆盖范围,以及 QA 团队在整个开发过程中应该扮演什么角色。让我们使用敏捷测试矩阵和测试金字塔来澄清这一点。

敏捷测试矩阵

Brian Marick 在他的一系列博客文章中,以所谓的敏捷测试矩阵的形式对软件测试进行了分类。它将测试放置在两个维度上:业务或技术面向和支持程序员或批评产品。让我们来看看这个分类:

让我们简要评论一下每种类型的测试:

  • 验收测试(自动化):这些测试代表了从业务角度看到的功能需求。它们以故事或示例的形式由客户和开发人员编写,以达成关于软件应该如何工作的一致意见。

  • 单元测试(自动化):这些测试帮助开发人员提供高质量的软件并最小化错误数量。

  • 探索性测试(手动):这是手动的黑盒测试,试图破坏或改进系统。

  • 非功能性测试(自动化):这些测试代表了与性能、可扩展性、安全性等相关的系统属性。

这个分类回答了持续交付过程中最重要的问题之一:QA 在过程中的角色是什么?

手动 QA 执行探索性测试,因此他们与系统一起玩耍,试图破坏它,提出问题,思考改进。自动化 QA 帮助进行非功能性和验收测试,例如,他们编写代码来支持负载测试。总的来说,QA 在交付过程中并没有他们特别的位置,而是在开发团队中扮演着一个角色。

在自动化的持续交付过程中,不再有执行重复任务的手动 QA 的位置。

你可能会看到分类,想知道为什么你在那里看不到集成测试。Brian Marick 在哪里,以及将它们放在持续交付管道的哪里?

为了解释清楚,我们首先需要提到,集成测试的含义取决于上下文。对于(微)服务架构,它们通常意味着与验收测试完全相同,因为服务很小,不需要除单元测试和验收测试之外的其他测试。如果构建了模块化应用程序,那么通过集成测试,我们通常指的是绑定多个模块(但不是整个应用程序)并一起测试它们的组件测试。在这种情况下,集成测试位于验收测试和单元测试之间。它们的编写方式与验收测试类似,但通常更加技术化,并且需要模拟不仅是外部服务,还有内部模块。集成测试与单元测试类似,代表了“代码”视角,而验收测试代表了“用户”视角。关于持续交付流水线,集成测试只是作为流程中的一个单独阶段实施。

测试金字塔

前一节解释了过程中每种测试类型代表的含义,但没有提到我们应该开发多少测试。那么,在单元测试的情况下,代码覆盖率应该是多少呢?验收测试呢?

为了回答这些问题,迈克·科恩在他的书《敏捷成功:使用 Scrum 进行软件开发》中创建了所谓的测试金字塔。让我们看一下图表,以便更好地理解它。

当我们向金字塔顶部移动时,测试变得更慢,创建起来更昂贵。它们通常需要触及用户界面,并雇佣一个单独的测试自动化团队。这就是为什么验收测试不应该以 100%的覆盖率为目标。相反,它们应该以特性为导向,仅验证选定的测试场景。否则,我们将在测试开发和维护上花费巨资,我们的持续交付流水线构建将需要很长时间来执行。

在金字塔底部情况就不同了。单元测试便宜且快速,因此我们应该努力实现 100%的代码覆盖率。它们由开发人员编写,并且为他们提供应该是任何成熟团队的标准程序。

我希望敏捷测试矩阵和测试金字塔澄清了验收测试的角色和重要性。

让我们转向持续交付流程的最后阶段,配置管理。

配置管理

配置管理阶段负责跟踪和控制软件及其环境中的变化。它涉及准备和安装必要的工具,扩展服务实例的数量和分布,基础设施清单,以及与应用部署相关的所有任务。

配置管理是解决手动在生产环境部署和配置应用程序带来的问题的解决方案。这种常见做法导致一个问题,即我们不再知道每个服务在哪里运行以及具有什么属性。配置管理工具(如 Ansible、Chef 或 Puppet)能够在版本控制系统中存储配置文件,并跟踪在生产服务器上所做的每一次更改。

  1. 取代运维团队手动任务的额外努力是负责应用程序监控。通常通过将运行系统的日志和指标流式传输到一个共同的仪表板来完成,该仪表板由开发人员(或者在下一节中解释的 DevOps 团队)监控。

7. 持续交付的先决条件

本书的其余部分致力于如何实施成功的持续交付流水线的技术细节。然而,该过程的成功不仅取决于本书中介绍的工具。在本节中,我们全面审视整个过程,并定义了三个领域的持续交付要求:

    1. 组织结构及其对开发过程的影响
    1. 产品及其技术细节
    1. 开发团队及其使用的实践

2. 组织先决条件

组织的工作方式对引入持续交付流程的成功有很大影响。这有点类似于引入 Scrum。许多组织希望使用敏捷流程,但他们不改变他们的文化。除非组织结构进行了调整,否则你无法在开发团队中使用 Scrum。例如,你需要一个产品负责人、利益相关者和理解在冲刺期间不可能进行任何需求更改的管理层。否则,即使有良好的意愿,你也无法成功。持续交付流程也是如此;它需要调整组织结构。让我们来看看三个方面:DevOps 文化、流程中的客户和业务决策。

5. DevOps 文化

很久以前,当软件是由个人或微型团队编写时,开发、质量保证和运营之间没有明确的分离。一个人开发代码,测试它,然后将其投入生产。如果出了问题,同一个人调查问题,修复它,然后重新部署到生产环境。现在组织开发的方式逐渐改变,当系统变得更大,开发团队增长时。然后,工程师开始专门从事某个领域。这是完全有道理的,因为专业化会导致生产力的提升。然而,副作用是沟通开销。特别是如果开发人员、质量保证和运营在组织中处于不同的部门,坐在不同的建筑物中,或者外包到不同的国家。这种组织结构对持续交付流程不利。我们需要更好的东西,我们需要适应所谓的 DevOps 文化。

在某种意义上,DevOps 文化意味着回归到根本。一个人或一个团队负责所有三个领域,如下图所示:

能够转向 DevOps 模式而不损失生产力的原因是自动化。与质量保证和运营相关的大部分任务都被移至自动化交付流程,因此可以由开发团队管理。

DevOps 团队不一定只需要由开发人员组成。在许多正在转型的组织中,一个常见的情景是创建由四名开发人员、一个质量保证人员和一个运营人员组成的团队。然而,他们需要密切合作(坐在一起,一起开会,共同开发同一个产品)。

小型 DevOps 团队的文化影响软件架构。功能需求必须被很好地分离成(微)服务或模块,以便每个团队可以独立处理一个部分。

组织结构对软件架构的影响已经在 1967 年观察到,并被规定为康威定律:“任何设计系统(广义定义)的组织都将产生一个结构与组织沟通结构相同的设计。”

客户端在流程中

在持续交付采用过程中,客户(或产品负责人)的角色略有变化。传统上,客户参与定义需求,回答开发人员的问题,参加演示,并参与用户验收测试阶段,以确定构建的是否符合他们的意图。

在持续交付中,没有用户验收测试,客户在编写验收测试的过程中至关重要。对于一些已经以可测试的方式编写需求的客户来说,这并不是一个很大的转变。对于其他人来说,这意味着改变思维方式,使需求更加技术导向。

在敏捷环境中,一些团队甚至不接受没有验收测试的用户故事(需求)。即使这些技术可能听起来太严格,但通常会导致更好的开发生产力。

业务决策

在大多数公司中,业务对发布计划有影响。毕竟,决定交付哪些功能以及何时交付与公司的不同部门(例如营销)相关,并且对企业具有战略意义。这就是为什么发布计划必须在业务和开发团队之间重新审视和讨论。

显然,有一些技术,如功能切换或手动流水线步骤,有助于在指定时间发布功能。我们将在书中稍后描述它们。准确地说,持续交付这个术语并不等同于持续部署。前者意味着每次提交到存储库都会自动发布到生产环境。持续交付要求较少严格,意味着每次提交都会产生一个发布候选版本,因此允许最后一步(发布到生产环境)是手动的。

在本书的其余部分,我们将互换使用持续交付和持续部署这两个术语。

技术和开发先决条件

从技术方面来看,有一些要求需要牢记。我们将在整本书中讨论它们,所以在这里只是简单提一下而不详细讨论:

  • 自动构建、测试、打包和部署操作:所有操作都需要能够自动化。如果我们处理的系统无法自动化,例如由于安全原因或其复杂性,那么就不可能创建完全自动化的交付流程。

  • 快速流水线执行:流水线必须及时执行,最好在 5-15 分钟内。如果我们的流水线执行需要几个小时或几天,那么就不可能在每次提交到仓库后运行它。

  • 快速故障恢复:快速回滚或系统恢复的可能性是必须的。否则,由于频繁发布,我们会冒着生产健康的风险。

  • 零停机部署:部署不能有任何停机时间,因为我们每天发布多次。

  • 基于主干的开发:开发人员必须定期签入主分支。否则,如果每个人都在自己的分支上开发,集成很少,因此发布也很少,这恰恰与我们想要实现的相反。

我们将在整本书中更多地讨论这些先决条件以及如何解决它们。记住这一点,让我们转到本章的最后一节,介绍我们计划在本书中构建的系统以及我们将用于此目的的工具。

构建持续交付过程

我们介绍了持续交付过程的理念、好处和先决条件。在本节中,我们描述了将在整本书中使用的工具及其在完整系统中的位置。

如果你对持续交付过程的想法更感兴趣,那么可以看看杰兹·汉布尔和大卫·法利的一本优秀书籍,《持续交付:通过构建、测试和部署自动化实现可靠的软件发布》。

介绍工具

首先,具体的工具总是比理解其在流程中的作用更不重要。换句话说,任何工具都可以用另一个扮演相同角色的工具替换。例如,Jenkins 可以用 Atlassian Bamboo 替换,Chief 可以用 Ansible 替换。这就是为什么每一章都以为什么需要这样的工具以及它在整个流程中的作用的一般描述开始。然后,具体的工具会与其替代品进行比较描述。这种形式给了你选择适合你环境的正确工具的灵活性。

另一种方法可能是在思想层面上描述持续交付过程;然而,我坚信用代码提取的确切示例,读者可以自行运行,会更好地理解这个概念。

有两种阅读本书的方式。第一种是阅读和理解持续交付流程的概念。第二种是创建自己的环境,并在阅读时执行所有脚本,以理解细节。

让我们快速看一下本书中将使用的工具。然而,在本节中,这只是对每种技术的简要介绍,随着本书的进行,会呈现更多细节。

Docker 生态系统

Docker 作为容器化运动的明确领导者,在近年来主导了软件行业。它允许将应用程序打包成与环境无关的镜像,因此将服务器视为资源的集群,而不是必须为每个应用程序配置的机器。Docker 是本书的明确选择,因为它完全适合(微)服务世界和持续交付流程。

随着 Docker 一起出现的还有其他技术,如下所示:

  • Docker Hub:这是 Docker 镜像的注册表

  • Docker Compose:这是一个定义多容器 Docker 应用程序的工具

  • Docker Swarm:这是一个集群和调度工具

Jenkins

Jenkins 绝对是市场上最受欢迎的自动化服务器。它有助于创建持续集成和持续交付流水线,以及一般的任何其他自动化脚本序列。高度插件化,它有一个伟大的社区,不断通过新功能扩展它。更重要的是,它允许将流水线编写为代码,并支持分布式构建环境。

Ansible

Ansible 是一个自动化工具,可帮助进行软件供应、配置管理和应用部署。它的趋势比任何其他配置管理引擎都要快,很快就可以超过它的两个主要竞争对手:Chef 和 Puppet。它使用无代理架构,并与 Docker 无缝集成。

GitHub

GitHub 绝对是所有托管版本控制系统中的第一名。它提供了一个非常稳定的系统,一个出色的基于 Web 的用户界面,以及免费的公共存储库服务。话虽如此,任何源代码控制管理服务或工具都可以与持续交付一起使用,无论是在云端还是自托管,无论是基于 Git、SVN、Mercurial 还是其他任何工具。

Java/Spring Boot/Gradle

多年来,Java 一直是最受欢迎的编程语言。这就是为什么在本书中大多数代码示例都使用 Java。与 Java 一起,大多数公司使用 Spring 框架进行开发,因此我们使用它来创建一个简单的 Web 服务,以解释一些概念。Gradle 用作构建工具。它仍然比 Maven 不那么受欢迎,但发展速度更快。与往常一样,任何编程语言、框架或构建工具都可以替换,持续交付流程将保持不变,所以如果您的技术栈不同,也不用担心。

其他工具

我们随意选择了 Cucumber 作为验收测试框架。其他类似的解决方案有 Fitnesse 和 JBehave。对于数据库迁移,我们使用 Flyway,但任何其他工具也可以,例如 Liquibase。

创建完整的持续交付系统

您可以从两个角度看待本书的组织方式。

第一个角度是基于自动部署流水线的步骤。每一章都让您更接近完整的持续交付流程。如果您看一下章节的名称,其中一些甚至命名为流水线阶段的名称:

  • 持续集成流水线

  • 自动验收测试

  • 使用 Ansible 进行配置管理

其余章节提供了介绍、总结或与流程相关的附加信息。

本书的内容还有第二个视角。每一章描述了环境的一个部分,这个环境又为持续交付流程做好了充分的准备。换句话说,本书逐步展示了如何逐步构建一个完整系统的技术。为了帮助您了解我们计划在整本书中构建的系统,现在让我们来看看每一章中系统将如何发展。

如果您目前不理解概念和术语,不用担心。我们将在相应的章节中从零开始解释一切。

介绍 Docker

在第二章中,介绍 Docker,我们从系统的中心开始构建一个打包为 Docker 镜像的工作应用程序。本章的输出如下图所示:

一个 docker 化的应用程序(Web 服务)作为一个容器在Docker 主机上运行,并且可以像直接在主机上运行一样访问。这得益于端口转发(在 Docker 术语中称为端口发布)。

配置 Jenkins

在第三章中,配置 Jenkins,我们准备了 Jenkins 环境。多个代理(从)节点的支持使其能够处理大量并发负载。结果如下图所示:

Jenkins主节点接受构建请求,但执行是在一个Jenkins 从节点(代理)机器上启动的。这种方法提供了 Jenkins 环境的水平扩展。

持续集成流水线

在第四章中,持续集成流水线,我们展示了如何创建持续交付流水线的第一阶段,即提交阶段。本章的输出是下图所示的系统:

该应用程序是使用 Spring Boot 框架编写的简单的 Java Web 服务。Gradle 用作构建工具,GitHub 用作源代码仓库。对 GitHub 的每次提交都会自动触发 Jenkins 构建,该构建使用 Gradle 编译 Java 代码,运行单元测试,并执行其他检查(代码覆盖率,静态代码分析等)。Jenkins 构建完成后,会向开发人员发送通知。

在这一章之后,您将能够创建一个完整的持续集成流水线。

自动验收测试

在第五章中,自动验收测试,我们最终合并了书名中的两种技术:DockerJenkins。结果如下图所示:

图中的附加元素与自动验收测试阶段有关:

  • Docker Registry:在持续集成阶段之后,应用程序首先被打包成一个 JAR 文件,然后作为一个 Docker 镜像。然后将该镜像推送到Docker Registry,它充当了 docker 化应用程序的存储库。

  • Docker 主机:在执行验收测试套件之前,应用程序必须启动。Jenkins 触发一个Docker 主机机器从Docker Registry拉取 docker 化的应用程序并启动它。

  • Docker Compose:如果完整的应用程序由多个 Docker 容器组成(例如,两个 Web 服务:使用应用程序 2 的应用程序 1),那么Docker Compose有助于将它们一起运行。

  • Cucumber:应用程序在Docker 主机上启动后,Jenkins 运行了一套用Cucumber框架编写的验收测试。

Ansible/持续交付流水线的配置管理

在接下来的两章中,即第六章,使用 Ansible 进行配置管理和第七章,持续交付流水线,我们完成了持续交付流水线。输出是下图所示的环境:

Ansible 负责环境,并使得同一应用程序可以部署到多台机器上。因此,我们将应用程序部署到暂存环境,运行验收测试套件,最后将应用程序发布到生产环境,通常是在多个实例上(在多个 Docker 主机上)。

使用 Docker Swarm 进行集群/高级持续交付

在第八章中,使用 Docker Swarm 进行集群,我们用机器集群替换了每个环境中的单个主机。第九章,高级持续交付,此外还将数据库添加到了持续交付流程中。本书中创建的最终环境如下图所示:

暂存和生产环境配备有 Docker Swarm 集群,因此应用程序的多个实例在集群上运行。我们不再需要考虑我们的应用程序部署在哪台精确的机器上。我们只关心它们的实例数量。Jenkins 从属也是在集群上运行。最后的改进是使用 Flyway 迁移自动管理数据库模式,这已经整合到交付流程中。

我希望你已经对我们在本书中计划构建的内容感到兴奋。我们将逐步进行,解释每一个细节和所有可能的选项,以帮助你理解程序和工具。阅读本书后,你将能够在你的项目中引入或改进持续交付流程。

摘要

在本章中,我们介绍了从想法开始的持续交付过程,讨论了先决条件,并介绍了本书其余部分使用的工具。本章的关键要点如下:

  • 目前大多数公司使用的交付流程存在重大缺陷,可以通过现代自动化工具进行改进

  • 持续交付方法提供了许多好处,其中最重要的是:快速交付、快速反馈周期和低风险发布

  • 持续交付流水线包括三个阶段:持续集成、自动验收测试和配置管理

  • 引入持续交付通常需要组织文化和结构的变革。

  • 在持续交付的背景下,最重要的工具是 Docker、Jenkins 和 Ansible

在下一章中,我们将介绍 Docker,并介绍如何构建一个 Docker 化的应用程序。

第二章:介绍 Docker

我们将讨论现代持续交付过程应该如何看待,引入 Docker,这种改变了 IT 行业和服务器使用方式的技术。

本章涵盖以下内容:

  • 介绍虚拟化和容器化的概念

  • 在不同的本地和服务器环境中安装 Docker

  • 解释 Docker 工具包的架构

  • 使用 Dockerfile 构建 Docker 镜像并提交更改

  • 将应用程序作为 Docker 容器运行

  • 配置 Docker 网络和端口转发

  • 介绍 Docker 卷作为共享存储

什么是 Docker?

Docker 是一个旨在通过软件容器帮助应用程序部署的开源项目。这句话来自官方 Docker 页面:

“Docker 容器将软件包装在一个完整的文件系统中,其中包含运行所需的一切:代码、运行时、系统工具、系统库 - 任何可以安装在服务器上的东西。这保证软件无论在什么环境下都能始终运行相同。”

因此,Docker 与虚拟化类似,允许将应用程序打包成可以在任何地方运行的镜像。

容器化与虚拟化

没有 Docker,可以使用硬件虚拟化来实现隔离和其他好处,通常称为虚拟机。最流行的解决方案是 VirtualBox、VMware 和 Parallels。虚拟机模拟计算机架构,并提供物理计算机的功能。如果每个应用程序都作为单独的虚拟机镜像交付和运行,我们可以实现应用程序的完全隔离。以下图展示了虚拟化的概念:

每个应用程序都作为一个单独的镜像启动,具有所有依赖项和一个客户操作系统。镜像由模拟物理计算机架构的 hypervisor 运行。这种部署方法得到许多工具(如 Vagrant)的广泛支持,并专门用于开发和测试环境。然而,虚拟化有三个重大缺点:

  • 性能低下:虚拟机模拟整个计算机架构来运行客户操作系统,因此每个操作都会带来显着的开销。

  • 高资源消耗:模拟需要大量资源,并且必须针对每个应用程序单独进行。这就是为什么在标准桌面机器上只能同时运行几个应用程序。

  • 大的镜像大小:每个应用程序都随着完整的操作系统交付,因此在服务器上部署意味着发送和存储大量数据。

容器化的概念提出了一个不同的解决方案:

每个应用程序都与其依赖项一起交付,但没有操作系统。应用程序直接与主机操作系统接口,因此没有额外的客户操作系统层。这导致更好的性能和没有资源浪费。此外,交付的 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 的最初版本是基于开源的LXCLinux Containers)系统的,这是一个容器的替代平台。其他已知的解决方案包括 FreeBSD Jails、OpenVZ 和 Solaris Containers。然而,Docker 因其简单性、良好的营销和创业方法而超越了所有其他系统。它适用于大多数操作系统,允许您在不到 15 分钟内做一些有用的事情,具有许多易于使用的功能,良好的教程,一个伟大的社区,可能是 IT 行业中最好的标志。

Docker 安装

Docker 的安装过程快速简单。目前,它在大多数 Linux 操作系统上得到支持,并提供了专门的二进制文件。Mac 和 Windows 也有很好的本地应用支持。然而,重要的是要理解,Docker 在内部基于 Linux 内核及其特定性,这就是为什么在 Mac 和 Windows 的情况下,它使用虚拟机(Mac 的 xhyve 和 Windows 的 Hyper-V)来运行 Docker 引擎环境。

Docker 的先决条件

Docker 的要求针对每个操作系统都是特定的。

Mac

  • 2010 年或更新型号,具有英特尔对内存管理单元MMU)虚拟化的硬件支持

  • macOS 10.10.3 Yosemite 或更新版本

  • 至少 4GB 的 RAM

  • 未安装早于 4.3.30 版本的 VirtualBox

Windows

  • 64 位 Windows 10 专业版

  • 启用了 Hyper-V 包

Linux

  • 64 位架构

  • Linux 内核 3.10 或更高版本

如果您的机器不符合要求,那么解决方案是使用安装了 Ubuntu 操作系统的 VirtualBox。尽管这种解决方法听起来有些复杂,但并不一定是最糟糕的方法,特别是考虑到在 Mac 和 Windows 的情况下 Docker 引擎环境本身就是虚拟化的。此外,Ubuntu 是使用 Docker 的最受支持的系统之一。

本书中的所有示例都在 Ubuntu 16.04 操作系统上进行了测试。

在本地机器上安装

Dockers 的安装过程非常简单,并且在其官方页面上有很好的描述。

Ubuntu 的 Docker

docs.docker.com/engine/installation/linux/ubuntulinux/ 包含了在 Ubuntu 机器上安装 Docker 的指南。

在 Ubuntu 16.04 的情况下,我执行了以下命令:

$ sudo apt-get update
$ sudo apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
$ sudo apt-add-repository 'deb [arch=amd64] https://download.docker.com/linux/ubuntu xenial main stable'
$ sudo apt-get update
$ sudo apt-get install -y docker-ce

所有操作完成后,Docker 应该已安装。然而,目前唯一被允许使用 Docker 命令的用户是root。这意味着每个 Docker 命令前都必须加上sudo关键字。

我们可以通过将他们添加到docker组来使其他用户使用 Docker:

$ sudo usermod -aG docker <username>

成功注销后,一切都设置好了。然而,通过最新的命令,我们需要采取一些预防措施,以免将 Docker 权限赋予不需要的用户,从而在 Docker 引擎中创建漏洞。这在服务器机器上安装时尤为重要。

Linux 的 Docker

docs.docker.com/engine/installation/linux/ 包含了大多数 Linux 发行版的安装指南。

Mac 的 Docker

docs.docker.com/docker-for-mac/ 包含了在 Mac 机器上安装 Docker 的逐步指南。它与一系列 Docker 组件一起提供:

  • 带有 Docker 引擎的虚拟机

  • Docker Machine(用于在虚拟机上创建 Docker 主机的工具)

  • Docker Compose

  • Docker 客户端和服务器

  • Kitematic:一个 GUI 应用程序

Docker Machine 工具有助于在 Mac、Windows、公司网络、数据中心以及 AWS 或 Digital Ocean 等云提供商上安装和管理 Docker 引擎。

Windows 的 Docker

docs.docker.com/docker-for-windows/ 包含了如何在 Windows 机器上安装 Docker 的逐步指南。它与一组类似于 Mac 的 Docker 组件一起提供。

所有支持的操作系统和云平台的安装指南都可以在官方 Docker 页面上找到,docs.docker.com/engine/installation/

测试 Docker 安装

无论您选择了哪种安装方式(Mac、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 套接字运行,只允许本地通信。必须添加监听所选网络接口套接字,以便外部客户端可以连接。docs.docker.com/engine/admin/详细描述了每个 Linux 发行版所需的所有配置步骤。

在 Ubuntu 的情况下,Docker 守护程序由 systemd 配置,因此为了更改它的启动配置,我们需要修改/lib/systemd/system/docker.service文件中的一行:

ExecStart=/usr/bin/dockerd -H <server_ip>:2375

通过更改这一行,我们启用了通过指定的 IP 地址访问 Docker 守护程序。有关 systemd 配置的所有细节可以在docs.docker.com/engine/admin/systemd/找到。

服务器配置的第二步涉及 Docker 安全证书。这使得只有通过证书认证的客户端才能访问服务器。Docker 证书配置的详细描述可以在docs.docker.com/engine/security/https/找到。这一步并不是严格要求的;然而,除非您的 Docker 守护程序服务器位于防火墙网络内,否则是必不可少的。

如果您的 Docker 守护程序在公司网络内运行,您必须配置 HTTP 代理。详细描述可以在docs.docker.com/engine/admin/systemd/找到。

运行 Docker hello world>

Docker 环境已经设置好,所以我们可以开始第一个示例。

在控制台中输入以下命令:

$ docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
78445dd45222: Pull complete
Digest: sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.
...

恭喜,您刚刚运行了您的第一个 Docker 容器。我希望您已经感受到 Docker 是多么简单。让我们逐步检查发生了什么:

  1. 您使用run命令运行了 Docker 客户端。

  2. Docker 客户端联系 Docker 守护程序,要求从名为hello-world的镜像创建一个容器。

  3. Docker 守护程序检查是否在本地包含hello-world镜像,由于没有,它从远程 Docker Hub 注册表请求了hello-world镜像。

  4. Docker Hub 注册表包含了hello-world镜像,因此它被拉入了 Docker 守护程序。

  5. Docker 守护程序从hello-world镜像创建了一个新的容器,启动了产生输出的可执行文件。

  6. Docker 守护程序将此输出流式传输到 Docker 客户端。

  7. Docker 客户端将其发送到您的终端。

预期的流程可以用以下图表表示:

让我们看一下在本节中所示的每个 Docker 组件。

Docker 组件

官方 Docker 页面上说:

“Docker Engine 是一个创建和管理 Docker 对象(如镜像和容器)的客户端-服务器应用程序。”

让我们搞清楚这意味着什么。

Docker 客户端和服务器

让我们看一下展示 Docker Engine 架构的图表:

Docker Engine 由三个组件组成:

  • Docker 守护程序(服务器)在后台运行

  • Docker 客户端作为命令工具运行

  • REST API

安装 Docker Engine 意味着安装所有组件,以便 Docker 守护程序作为服务在我们的计算机上运行。在hello-world示例中,我们使用 Docker 客户端与 Docker 守护程序交互;但是,我们也可以使用 REST API 来做完全相同的事情。同样,在 hello-world 示例中,我们连接到本地 Docker 守护程序;但是,我们也可以使用相同的客户端与远程机器上运行的 Docker 守护程序交互。

要在远程机器上运行 Docker 容器,可以使用-H选项:docker -H <server_ip>:2375 run hello-world

Docker 镜像和容器

在 Docker 世界中,镜像是一个无状态的构建块。您可以将镜像想象为运行应用程序所需的所有文件的集合,以及运行它的方法。镜像是无状态的,因此可以通过网络发送它,将其存储在注册表中,命名它,对其进行版本控制,并将其保存为文件。镜像是分层的,这意味着可以在另一个镜像的基础上构建镜像。

容器是镜像的运行实例。如果我们想要多个相同应用的实例,我们可以从同一个镜像创建多个容器。由于容器是有状态的,我们可以与它们交互并更改它们的状态。

让我们来看一个容器和镜像层结构的例子:

底部始终是基础镜像。在大多数情况下,它代表一个操作系统,我们在现有的基础镜像上构建我们的镜像。从技术上讲,可以创建自己的基础镜像,但这很少需要。

在我们的例子中,ubuntu基础镜像提供了 Ubuntu 操作系统的所有功能。add git镜像添加了 Git 工具包。然后,有一个添加了 JDK 环境的镜像。最后,在顶部,有一个从add JDK镜像创建的容器。这样的容器能够从 GitHub 仓库下载 Java 项目并将其编译为 JAR 文件。因此,我们可以使用这个容器来编译和运行 Java 项目,而无需在我们的操作系统上安装任何工具。

重要的是要注意,分层是一种非常聪明的机制,可以节省带宽和存储空间。想象一下,我们的应用程序也是基于ubuntu

这次我们将使用 Python 解释器。在安装add python镜像时,Docker 守护程序将注意到ubuntu镜像已经安装,并且它需要做的只是添加一个非常小的python层。因此,ubuntu镜像是一个被重复使用的依赖项。如果我们想要在网络中部署我们的镜像,情况也是一样的。当我们部署 Git 和 JDK 应用程序时,我们需要发送整个ubuntu镜像。然而,随后部署python应用程序时,我们只需要发送一个小的add python层。

Docker 应用程序

许多应用程序以 Docker 镜像的形式提供,可以从互联网上下载。如果我们知道镜像名称,那么只需以与 hello world 示例相同的方式运行它就足够了。我们如何在 Docker Hub 上找到所需的应用程序镜像呢?

让我们以 MongoDB 为例。如果我们想在 Docker Hub 上找到它,我们有两个选项:

在第二种情况下,我们可以执行以下操作:

$ docker search mongo
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
mongo MongoDB document databases provide high av... 2821 [OK] 
mongo-express Web-based MongoDB admin interface, written... 106 [OK] 
mvertes/alpine-mongo light MongoDB container 39 [OK]
mongoclient/mongoclient Official docker image for Mongoclient, fea... 19 [OK]
...

有很多有趣的选项。我们如何选择最佳镜像?通常,最吸引人的是没有任何前缀的镜像,因为这意味着它是一个官方的 Docker Hub 镜像,因此应该是稳定和维护的。带有前缀的镜像是非官方的,通常作为开源项目进行维护。在我们的情况下,最好的选择似乎是mongo,因此为了运行 MongoDB 服务器,我们可以运行以下命令:

$ docker run mongo
Unable to find image 'mongo:latest' locally
latest: Pulling from library/mongo
5040bd298390: Pull complete
ef697e8d464e: Pull complete
67d7bf010c40: Pull complete
bb0b4f23ca2d: Pull complete
8efff42d23e5: Pull complete
11dec5aa0089: Pull complete
e76feb0ad656: Pull complete
5e1dcc6263a9: Pull complete
2855a823db09: Pull complete
Digest: sha256:aff0c497cff4f116583b99b21775a8844a17bcf5c69f7f3f6028013bf0d6c00c
Status: Downloaded newer image for mongo:latest
2017-01-28T14:33:59.383+0000 I CONTROL [initandlisten] MongoDB starting : pid=1 port=27017 dbpath=/data/db 64-bit host=0f05d9df0dc2
...

就这样,MongoDB 已经启动了。作为 Docker 容器运行应用程序是如此简单,因为我们不需要考虑任何依赖项;它们都与镜像一起提供。

在 Docker Hub 服务上,你可以找到很多应用程序;它们存储了超过 100,000 个不同的镜像。

构建镜像

Docker 可以被视为一个有用的工具来运行应用程序;然而,真正的力量在于构建自己的 Docker 镜像,将程序与环境一起打包。在本节中,我们将看到如何使用两种不同的方法来做到这一点,即 Docker commit命令和 Dockerfile 自动构建。

Docker commit

让我们从一个例子开始,使用 Git 和 JDK 工具包准备一个镜像。我们将使用 Ubuntu 16.04 作为基础镜像。无需创建它;大多数基础镜像都可以在 Docker Hub 注册表中找到:

  1. ubuntu:16.04运行一个容器,并连接到其命令行:
 $ docker run -i -t ubuntu:16.04 /bin/bash

我们拉取了ubuntu:16.04镜像,并将其作为容器运行,然后以交互方式(-i 标志)调用了/bin/bash命令。您应该看到容器的终端。由于容器是有状态的和可写的,我们可以在其终端中做任何我们想做的事情。

  1. 安装 Git 工具包:
 root@dee2cb192c6c:/# apt-get update
 root@dee2cb192c6c:/# apt-get install -y git
  1. 检查 Git 工具包是否已安装:
 root@dee2cb192c6c:/# which git
 /usr/bin/git
  1. 退出容器:
 root@dee2cb192c6c:/# exit
  1. 检查容器中的更改,将其与ubuntu镜像进行比较:
 $ docker diff dee2cb192c6c

该命令应打印出容器中所有更改的文件列表。

  1. 将容器提交到镜像:
 $ 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 259.7 MB
ubuntu           16.04    f49eec89601e  7 days ago         129.5 MB
mongo            latest   0dffc7177b06  10 days ago        402 MB
hello-world      latest   48b5124b2768  2 weeks ago        1.84 kB

如预期的那样,我们看到了hello-worldmongo(之前安装的),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

使用完全相同的方法,我们可以在ubuntu_with_git镜像的基础上构建ubuntu_with_git_and_jdk

$ docker run -i -t ubuntu_with_git /bin/bash
root@6ee6401ed8b8:/# apt-get install -y openjdk-8-jdk
root@6ee6401ed8b8:/# exit
$ docker commit 6ee6401ed8b8 ubuntu_with_git_and_jdk

Dockerfile

手动创建每个 Docker 镜像并使用 commit 命令可能会很费力,特别是在构建自动化和持续交付过程中。幸运的是,有一种内置语言可以指定构建 Docker 镜像时应执行的所有指令。

让我们从一个类似于 Git 和 JDK 的例子开始。这次,我们将准备ubuntu_with_python镜像。

  1. 创建一个新目录和一个名为Dockerfile的文件,内容如下:
 FROM ubuntu:16.04
 RUN apt-get update && \
 apt-get install -y python
  1. 运行命令以创建ubuntu_with_python镜像:
 $ docker build -t ubuntu_with_python .
  1. 检查镜像是否已创建:
$ docker images
REPOSITORY              TAG     IMAGE ID       CREATED            SIZE
ubuntu_with_python      latest  d6e85f39f5b7  About a minute ago 202.6 MB
ubuntu_with_git_and_jdk latest  8464dc10abbb  3 minutes ago      610.9 MB
ubuntu_with_git         latest  f3d674114fe2  9 minutes ago      259.7 MB
ubuntu                  16.04   f49eec89601e  7 days ago         129.5 MB
mongo                   latest  0dffc7177b06   10 days ago        402 MB
hello-world             latest  48b5124b2768   2 weeks ago        1.84 kB

现在我们可以从镜像创建一个容器,并检查 Python 解释器是否存在,方式与执行docker commit命令后的方式完全相同。请注意,即使ubuntu镜像是ubuntu_with_gitubuntu_with_python的基础镜像,它也只列出一次。

在这个例子中,我们使用了前两个 Dockerfile 指令:

  • FROM定义了新镜像将基于的镜像

  • RUN指定在容器内部运行的命令

所有 Dockerfile 指令都可以在官方 Docker 页面docs.docker.com/engine/reference/builder/上找到。最常用的指令如下:

  • MAINTAINER定义了关于作者的元信息

  • COPY将文件或目录复制到镜像的文件系统中

  • ENTRYPOINT定义了可执行容器中应该运行哪个应用程序

您可以在官方 Docker 页面[https://docs.docker.com/engine/reference/builder/]上找到所有 Dockerfile 指令的完整指南。

完整的 Docker 应用程序

我们已经拥有构建完全可工作的应用程序作为 Docker 镜像所需的所有信息。例如,我们将逐步准备一个简单的 Python hello world 程序。无论我们使用什么环境或编程语言,这些步骤都是相同的。

编写应用程序

创建一个新目录,在这个目录中,创建一个名为hello.py的文件,内容如下:

print "Hello World from Python!"

关闭文件。这是我们应用程序的源代码。

准备环境

我们的环境将在 Dockerfile 中表示。我们需要定义以下指令:

  • 应该使用哪个基础镜像

  • (可选)维护者是谁

  • 如何安装 Python 解释器

  • 如何将hello.py包含在镜像中

  • 如何启动应用程序

在同一目录中,创建 Dockerfile:

FROM ubuntu:16.04
MAINTAINER Rafal Leszko
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 编写的应用程序。这是因为作为镜像打包的应用程序在内部具有所需的所有环境。

Python 解释器的镜像已经存在于 Docker Hub 服务中,因此在实际情况下,使用它就足够了。

环境变量

我们已经运行了我们的第一个自制 Docker 应用程序。但是,如果应用程序的执行应该取决于一些条件呢?

例如,在生产服务器的情况下,我们希望将Hello打印到日志中,而不是控制台,或者我们可能希望在测试阶段和生产阶段有不同的依赖服务。一个解决方案是为每种情况准备一个单独的 Dockerfile;然而,还有一个更好的方法,即环境变量。

让我们将我们的 hello world 应用程序更改为打印Hello World from <name_passed_as_environment_variable> !。为了做到这一点,我们需要按照以下步骤进行:

  1. 更改 Python 脚本以使用环境变量:
        import os
        print "Hello World from %s !" % os.environ['NAME']
  1. 构建镜像:
 $ docker build -t hello_world_python_name .
  1. 运行传递环境变量的容器:
 $ docker run -e NAME=Rafal hello_world_python_name
 Hello World from Rafal !
  1. 或者,我们可以在 Dockerfile 中定义环境变量的值,例如:
        ENV NAME Rafal
  1. 然后,我们可以运行容器而不指定-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:16.04

这个命令启动了 Ubuntu 容器,但没有将控制台附加到它上面。我们可以使用以下命令看到它正在运行:

$ docker ps
CONTAINER ID IMAGE        COMMAND     STATUS PORTS NAMES
95f29bfbaadc ubuntu:16.04 "/bin/bash" Up 5 seconds kickass_stonebraker

这个命令打印出所有处于运行状态的容器。那么我们的旧容器呢,已经退出了?我们可以通过打印所有容器来找到它们:

$ docker ps -a
CONTAINER ID IMAGE        COMMAND        STATUS PORTS  NAMES
95f29bfbaadc ubuntu:16.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
6ee6401ed8b8 ubuntu_with_git "/bin/bash" Exited        grave_nobel
3b0d1ff457d4 ubuntu_with_git "/bin/bash" Exited        desperate_williams
dee2cb192c6c ubuntu:16.04 "/bin/bash"    Exited        small_dubinsky
0f05d9df0dc2 mongo        "/entrypoint.sh mongo" Exited trusting_easley
47ba1c0ba90e hello-world  "/hello"       Exited        tender_bell

请注意,所有旧容器都处于退出状态。我们还没有观察到的状态有两种:暂停和重新启动。

所有状态及其之间的转换都在以下图表中显示:

暂停 Docker 容器非常罕见,从技术上讲,它是通过使用 SIGSTOP 信号冻结进程来实现的。重新启动是一个临时状态,当容器使用--restart选项运行以定义重新启动策略时(Docker 守护程序能够在发生故障时自动重新启动容器)。

该图表还显示了用于将 Docker 容器状态从一个状态更改为另一个状态的 Docker 命令。

例如,我们可以停止正在运行的 Ubuntu 容器:

$ docker stop 95f29bfbaadc

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

我们一直使用docker run命令来创建和启动容器;但是,也可以只创建容器而不启动它。

Docker 网络

如今,大多数应用程序不是独立运行的,而是需要通过网络与其他系统进行通信。如果我们想在 Docker 容器内运行网站、网络服务、数据库或缓存服务器,那么我们需要至少了解 Docker 网络的基础知识。

运行服务

让我们从一个简单的例子开始,直接从 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 使用情况下,这样简单的端口映射命令就足够了。我们能够将(微)服务部署为 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命令查看 Docker 容器内创建的接口:

$ docker inspect 03d1e6dc4d9e

它以 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,并且它与具有 IP 地址172.17.0.1的 Docker 主机进行通信。这意味着在我们之前的示例中,即使没有端口转发,我们也可以访问 Tomcat 服务器,使用地址http://172.17.0.2:8080。然而,在大多数情况下,我们在服务器机器上运行 Docker 容器并希望将其暴露到外部,因此我们需要使用-p选项。

请注意,默认情况下,容器受主机防火墙系统保护,并且不会从外部系统打开任何路由。我们可以通过使用--network标志并将其设置为以下内容来更改此默认行为:

  • bridge(默认):通过默认 Docker 桥接网络

  • none:无网络

  • container:与其他(指定的)容器连接的网络

  • host:主机网络(无防火墙)

不同的网络可以通过docker network命令列出和管理:

$ docker network ls
NETWORK ID   NAME   DRIVER SCOPE
b3326cb44121 bridge bridge local 
84136027df04 host   host   local 
80c26af0351c none   null   local

如果我们将none指定为网络,则将无法连接到容器,反之亦然;容器无法访问外部世界。host选项使容器网络接口与主机相同。它们共享相同的 IP 地址,因此容器上启动的所有内容在外部可见。最常用的选项是默认选项(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 卷使容器的数据持久化和共享。卷还清楚地将处理与数据分开。

让我们从一个示例开始,并使用-v <host_path>:<container_path>选项指定卷并连接到容器:

$ docker run -i -t -v ~/docker_ubuntu:/host_directory ubuntu:16.04 /bin/bash

现在,我们可以在容器中的host_directory中创建一个空文件:

root@01bf73826624:/# touch host_directory/file.txt

让我们检查一下文件是否在 Docker 主机的文件系统中创建:

root@01bf73826624:/# exit
exit

$ ls ~/docker_ubuntu/
file.txt

我们可以看到文件系统被共享,数据因此得以永久保存。现在我们可以停止容器并运行一个新的容器,看到我们的文件仍然在那里:

$ docker stop 01bf73826624

$ docker run -i -t -v ~/docker_ubuntu:/host_directory ubuntu:16.04 /bin/bash
root@a9e0df194f1f:/# ls host_directory/
file.txt

root@a9e0df194f1f:/# exit

不需要使用-v标志来指定卷,可以在 Dockerfile 中将卷指定为指令,例如:

VOLUME /host_directory

在这种情况下,如果我们运行 docker 容器而没有-v标志,那么容器的/host_directory将被映射到主机的默认卷目录/var/lib/docker/vfs/。如果您将应用程序作为镜像交付,并且知道它因某种原因需要永久存储(例如存储应用程序日志),这是一个很好的解决方案。

如果卷在 Dockerfile 中和作为标志定义,那么命令标志优先。

Docker 卷可能会更加复杂,特别是在数据库的情况下。然而,Docker 卷的更复杂的用例超出了本书的范围。

使用 Docker 进行数据管理的一个非常常见的方法是引入一个额外的层,即数据卷容器。数据卷容器是一个唯一目的是声明卷的 Docker 容器。然后,其他容器可以使用它(使用--volumes-from <container>选项)而不是直接声明卷。在docs.docker.com/engine/tutorials/dockervolumes/#creating-and-mounting-a-data-volume-container中了解更多。

在 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:图像的版本,可以是任何形式,例如,16.04,20170310

我们将在第五章中介绍 Docker 注册表,自动验收测试。如果图像保存在官方 Docker Hub 注册表上,那么我们可以跳过注册表地址。这就是为什么我们在没有任何前缀的情况下运行了tomcat图像。最后一个版本总是被标记为最新的,也可以被跳过,所以我们在没有任何后缀的情况下运行了tomcat图像。

图像通常有多个标签,例如,所有四个标签都是相同的图像:ubuntu:16.04ubuntu:xenial-20170119ubuntu:xenialubuntu:latest

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:16.04 "/bin/bash" Exited         jolly_archimedes
01bf73826624 ubuntu:16.04 "/bin/bash" Exited         suspicious_feynman
078e9d12a1c8 tomcat "catalina.sh run" Up 14 minutes 0.0.0.0:32772->8080/tcp nauseous_fermi
0835c95538ae tomcat "catalina.sh run" Created        distracted_heyrovsky
03d1e6dc4d9e tomcat "catalina.sh run" Up 50 minutes 0.0.0.0:8080->8080/tcp drunk_ritchie
d51ad8634fac tomcat "catalina.sh run" Exited         jovial_kare
95f29bfbaadc ubuntu:16.04 "/bin/bash" Exited         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
6ee6401ed8b8 ubuntu_with_git "/bin/bash" Exited      grave_nobel
3b0d1ff457d4 ubuntu_with_git "/bin/bash" Exited      desperate_williams
dee2cb192c6c ubuntu:16.04 "/bin/bash" Exited         small_dubinsky
0f05d9df0dc2 mongo  "/entrypoint.sh mongo" Exited    trusting_easley
47ba1c0ba90e hello-world "/hello"     Exited         tender_bell

为了删除已停止的容器,我们可以使用docker rm命令(如果容器正在运行,我们需要先停止它):

$ docker rm 47ba1c0ba90e

如果我们想要删除所有已停止的容器,我们可以使用以下命令:

$ docker rm $(docker ps --no-trunc -aq)

-aq选项指定仅传递所有容器的 ID(没有额外数据)。另外,--no-trunc要求 Docker 不要截断输出。

我们也可以采用不同的方法,并要求容器在停止时使用--rm标志自行删除,例如:

$ docker run --rm hello-world

在大多数实际场景中,我们不使用已停止的容器,它们只用于调试目的。

清理图像

图像和容器一样重要。它们可能占用大量空间,特别是在持续交付过程中,每次构建都会产生一个新的 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
hello_world_python latest              3e1fa5c29b44 2 hours ago 202.6 MB
ubuntu_with_python latest              d6e85f39f5b7 2 hours ago 202.6 MB
ubuntu_with_git_and_jdk latest         8464dc10abbb 2 hours ago 610.9 MB
ubuntu_with_git latest                 f3d674114fe2 3 hours ago 259.7 MB
tomcat latest                          c822d296d232 2 days ago  355.3 MB
ubuntu 16.04                           f49eec89601e 7 days ago  129.5 MB
mongo latest                           0dffc7177b06 11 days ago 402 MB
hello-world latest                     48b5124b2768 2 weeks ago 1.84 kB

要删除图像,我们可以调用以下命令:

$ docker rmi 48b5124b2768

在图像的情况下,自动清理过程稍微复杂一些。图像没有状态,所以我们不能要求它们在不使用时自行删除。常见的策略是设置 Cron 清理作业,删除所有旧的和未使用的图像。我们可以使用以下命令来做到这一点:

$ docker rmi $(docker images -q)

为了防止删除带有标签的图像(例如,不删除所有最新的图像),非常常见的是使用dangling参数:

$ docker rmi $(docker images -f "dangling=true" -q)

如果我们有使用卷的容器,那么除了图像和容器之外,还值得考虑清理卷。最简单的方法是使用docker volume ls -qf dangling=true | xargs -r docker volume rm命令。

Docker 命令概述

通过执行以下help命令可以找到所有 Docker 命令:

$ docker help

要查看任何特定 Docker 命令的所有选项,我们可以使用docker help <command>,例如:

$ docker help run

在官方 Docker 页面docs.docker.com/engine/reference/commandline/docker/上也有对所有 Docker 命令的很好的解释。真的值得阅读,或者至少浏览一下。

在本章中,我们已经介绍了最有用的命令及其选项。作为一个快速提醒,让我们回顾一下:

命令 解释
docker build 从 Dockerfile 构建图像
docker commit 从容器创建图像
docker diff 显示容器中的更改
docker images 列出图像
docker info 显示 Docker 信息
docker inspect 显示 Docker 镜像/容器的配置
docker logs 显示容器的日志
docker network 管理网络
docker port 显示容器暴露的所有端口
docker ps 列出容器
docker rm 删除容器
docker rmi 删除图像
docker run 从图像运行容器
docker search 在 Docker Hub 中搜索 Docker 镜像
docker start/stop/pause/unpause 管理容器的状态

练习

在本章中,我们涵盖了大量的材料。为了记忆深刻,我们建议进行两个练习。

  1. 运行CouchDB作为一个 Docker 容器并发布它的端口:

您可以使用docker search命令来查找CouchDB镜像。

    • 运行容器
  • 发布CouchDB端口

  • 打开浏览器并检查CouchDB是否可用

  1. 创建一个 Docker 镜像,其中 REST 服务回复Hello World!localhost:8080/hello。使用您喜欢的任何语言和框架:

创建 REST 服务的最简单方法是使用 Python 和 Flask 框架,flask.pocoo.org/。请注意,许多 Web 框架默认只在 localhost 接口上启动应用程序。为了发布端口,有必要在所有接口上启动它(在 Flask 框架的情况下,使用app.run(host='0.0.0.0'))。

    • 创建一个 Web 服务应用程序
  • 创建一个 Dockerfile 来安装依赖和库

  • 构建镜像

  • 运行容器并发布端口

  • 使用浏览器检查它是否正常运行

总结

在本章中,我们已经涵盖了足够构建镜像和运行应用程序作为容器的 Docker 基础知识。本章的关键要点如下:

  • 容器化技术利用 Linux 内核特性解决了隔离和环境依赖的问题。这是基于进程分离机制的,因此没有观察到真正的性能下降。

  • Docker 可以安装在大多数系统上,但只有在 Linux 上才能得到原生支持。

  • Docker 允许从互联网上可用的镜像中运行应用程序,并构建自己的镜像。

  • 镜像是一个打包了所有依赖关系的应用程序。

  • Docker 提供了两种构建镜像的方法:Dockerfile 或提交容器。在大多数情况下,第一种选项被使用。

  • Docker 容器可以通过发布它们暴露的端口进行网络通信。

  • Docker 容器可以使用卷共享持久存储。

  • 为了方便起见,Docker 容器应该被命名,Docker 镜像应该被标记。在 Docker 世界中,有一个特定的约定来标记镜像。

  • Docker 镜像和容器应该定期清理,以节省服务器空间并避免设备上没有空间的错误。

在下一章中,我们将介绍 Jenkins 的配置以及 Jenkins 与 Docker 一起使用的方式。

第三章:配置 Jenkins

我们已经看到如何配置和使用 Docker。在本章中,我们将介绍 Jenkins,它可以单独使用,也可以与 Docker 一起使用。我们将展示这两个工具的结合产生了令人惊讶的好结果:自动配置和灵活的可扩展性。

本章涵盖以下主题:

  • 介绍 Jenkins 及其优势

  • 安装和启动 Jenkins

  • 创建第一个流水线

  • 使用代理扩展 Jenkins

  • 配置基于 Docker 的代理

  • 构建自定义主从 Docker 镜像

  • 配置安全和备份策略

Jenkins 是什么?

Jenkins 是用 Java 编写的开源自动化服务器。凭借非常活跃的基于社区的支持和大量的插件,它是实施持续集成和持续交付流程的最流行工具。以前被称为 Hudson,Oracle 收购 Hudson 并决定将其开发为专有软件后更名为 Jenkins。Jenkins 仍然在 MIT 许可下,并因其简单性、灵活性和多功能性而备受推崇。

Jenkins 优于其他持续集成工具,是最广泛使用的其类软件。这一切都是可能的,因为它的特性和能力。

让我们来看看 Jenkins 特性中最有趣的部分。

  • 语言无关:Jenkins 有很多插件,支持大多数编程语言和框架。此外,由于它可以使用任何 shell 命令和任何安装的软件,因此适用于可以想象的每个自动化流程。

  • 可扩展的插件:Jenkins 拥有一个庞大的社区和大量可用的插件(1000 多个)。它还允许您编写自己的插件,以定制 Jenkins 以满足您的需求。

  • 便携:Jenkins 是用 Java 编写的,因此可以在任何操作系统上运行。为了方便,它还以许多版本提供:Web 应用程序存档(WAR)、Docker 镜像、Windows 二进制、Mac 二进制和 Linux 二进制。

  • 支持大多数 SCM:Jenkins 与几乎所有现有的源代码管理或构建工具集成。再次,由于其广泛的社区和插件,没有其他持续集成工具支持如此多的外部系统。

  • 分布式:Jenkins 具有内置的主/从模式机制,可以将其执行分布在位于多台机器上的多个节点上。它还可以使用异构环境,例如,不同的节点可以安装不同的操作系统。

  • 简单性:安装和配置过程简单。无需配置任何额外的软件,也不需要数据库。可以完全通过 GUI、XML 或 Groovy 脚本进行配置。

  • 面向代码:Jenkins 管道被定义为代码。此外,Jenkins 本身可以使用 XML 文件或 Groovy 脚本进行配置。这允许将配置保存在源代码存储库中,并有助于自动化 Jenkins 配置。

Jenkins 安装

Jenkins 安装过程快速简单。有不同的方法可以做到这一点,但由于我们已经熟悉 Docker 工具及其带来的好处,我们将从基于 Docker 的解决方案开始。这也是最简单、最可预测和最明智的方法。然而,让我们先提到安装要求。

安装要求

最低系统要求相对较低:

  • Java 8

  • 256MB 可用内存

  • 1 GB 以上的可用磁盘空间

然而,需要明白的是,要求严格取决于您打算如何使用 Jenkins。如果 Jenkins 用于为整个团队提供持续集成服务器,即使是小团队,建议具有 1 GB 以上的可用内存和 50 GB 以上的可用磁盘空间。不用说,Jenkins 还执行一些计算并在网络上传输大量数据,因此 CPU 和带宽至关重要。

为了了解在大公司的情况下可能需要的要求,Jenkins 架构部分介绍了 Netflix 的例子。

在 Docker 上安装

让我们看看使用 Docker 安装 Jenkins 的逐步过程。

Jenkins 镜像可在官方 Docker Hub 注册表中找到,因此为了安装它,我们应该执行以下命令:

$ docker run -p <host_port>:8080 -v <host_volume>:/var/jenkins_home jenkins:2.60.1

我们需要指定第一个host_port参数——Jenkins 在容器外可见的端口。第二个参数host_volume指定了 Jenkins 主目录映射的目录。它需要被指定为卷,并因此永久持久化,因为它包含了配置、管道构建和日志。

例如,让我们看看在 Linux/Ubuntu 上 Docker 主机的安装步骤会是什么样子。

  1. 准备卷目录:我们需要一个具有管理员所有权的单独目录来保存 Jenkins 主目录。让我们用以下命令准备一个:
 $ mkdir $HOME/jenkins_home
 $ chown 1000 $HOME/jenkins_home
  1. 运行 Jenkins 容器:让我们将容器作为守护进程运行,并给它一个合适的名称:
 $ docker run -d -p 49001:8080 
        -v $HOME/jenkins_home:/var/jenkins_home --name 
        jenkins jenkins:2.60.1
  1. 检查 Jenkins 是否正在运行:过一会儿,我们可以通过打印日志来检查 Jenkins 是否已经正确启动:
 $ docker logs jenkins
 Running from: /usr/share/jenkins/jenkins.war
 webroot: EnvVars.masterEnvVars.get("JENKINS_HOME")
 Feb 04, 2017 9:01:32 AM Main deleteWinstoneTempContents
 WARNING: Failed to delete the temporary Winstone file 
        /tmp/winstone/jenkins.war
 Feb 04, 2017 9:01:32 AM org.eclipse.jetty.util.log.JavaUtilLog info
 INFO: Logging initialized @888ms
 Feb 04, 2017 9:01:32 AM winstone.Logger logInternal
 ...

在生产环境中,您可能还希望设置反向代理,以隐藏 Jenkins 基础设施在代理服务器后面。如何使用 Nginx 服务器进行设置的简要说明可以在wiki.jenkins-ci.org/display/JENKINS/Installing+Jenkins+with+Docker找到。

完成这几个步骤后,Jenkins 就可以使用了。基于 Docker 的安装有两个主要优点:

  • 故障恢复:如果 Jenkins 崩溃,只需运行一个指定了相同卷的新容器。

  • 自定义镜像:您可以根据自己的需求配置 Jenkins 并将其存储为 Jenkins 镜像。然后可以在您的组织或团队内共享,而无需一遍又一遍地重复相同的配置步骤。

在本书的所有地方,我们使用的是版本 2.60.1 的 Jenkins。

在没有 Docker 的情况下安装

出于前面提到的原因,建议安装 Docker。但是,如果这不是一个选择,或者有其他原因需要采取其他方式进行安装,那么安装过程同样简单。例如,在 Ubuntu 的情况下,只需运行:

$ 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 install jenkins

所有安装指南(Ubuntu、Mac、Windows 等)都可以在官方 Jenkins 页面jenkins.io/doc/book/getting-started/installing/上找到。

初始配置

无论您选择哪种安装方式,Jenkins 的第一次启动都需要进行一些配置步骤。让我们一步一步地走过它们:

  1. 在浏览器中打开 Jenkins:http://localhost:49001(对于二进制安装,默认端口为8080)。

  2. 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

 ...
  1. 接受初始密码后,Jenkins 会询问是否安装建议的插件,这些插件适用于最常见的用例。您的答案当然取决于您的需求。然而,作为第一个 Jenkins 安装,让 Jenkins 安装所有推荐的插件是合理的。

  2. 安装插件后,Jenkins 要求设置用户名、密码和其他基本信息。如果你跳过它,步骤 2 中的令牌将被用作管理员密码。

安装完成后,您应该看到 Jenkins 仪表板:

我们已经准备好使用 Jenkins 并创建第一个管道。

Jenkins 你好世界

整个 IT 世界的一切都始于 Hello World 的例子。

让我们遵循这个规则,看看创建第一个 Jenkins 管道的步骤:

  1. 点击新建项目

  2. hello world输入为项目名称,选择管道,然后点击确定。

  3. 有很多选项。我们现在会跳过它们,直接进入管道部分。

  4. 在脚本文本框中,我们可以输入管道脚本:

      pipeline {
           agent any
           stages {
                stage("Hello") {
                     steps {
                          echo 'Hello World'
                     }
                }
           }
      }
  1. 点击保存

  2. 点击立即构建

我们应该在构建历史下看到#1。如果我们点击它,然后点击控制台输出,我们将看到管道构建的日志。

我们刚刚看到了第一个例子,成功的输出意味着 Jenkins 已经正确安装。现在,让我们转移到稍微更高级的 Jenkins 配置。

我们将在第四章中更详细地描述管道语法,持续集成管道

Jenkins 架构

hello world 作业几乎没有时间执行。然而,管道通常更复杂,需要时间来执行诸如从互联网下载文件、编译源代码或运行测试等任务。一个构建可能需要几分钟到几小时。

在常见情况下,也会有许多并发的管道。通常,整个团队,甚至整个组织,都使用同一个 Jenkins 实例。如何确保构建能够快速顺利地运行?

主节点和从节点

Jenkins 很快就会变得过载。即使是一个小的(微)服务,构建也可能需要几分钟。这意味着一个频繁提交的团队很容易就能够使 Jenkins 实例崩溃。

因此,除非项目非常小,Jenkins 不应该执行构建,而是将它们委托给从节点(代理)实例。准确地说,我们当前运行的 Jenkins 称为 Jenkins 主节点,它可以委托给 Jenkins 代理。

让我们看一下呈现主从交互的图表:

在分布式构建环境中,Jenkins 主节点负责:

  • 接收构建触发器(例如,提交到 GitHub 后)

  • 发送通知(例如,在构建失败后发送电子邮件或 HipChat 消息)

  • 处理 HTTP 请求(与客户端的交互)

  • 管理构建环境(在从节点上编排作业执行)

构建代理是一个负责构建开始后发生的一切的机器。

由于主节点和从节点的责任不同,它们有不同的环境要求:

  • 主节点:这通常是一个专用的机器,内存从小型项目的 200 MB 到大型单主项目的 70GB 以上不等。

  • 从节点:没有一般性要求(除了它应该能够执行单个构建之外,例如,如果项目是一个需要 100GB RAM 的巨型单体,那么从节点机器需要满足这些需求)。

代理也应尽可能通用。例如,如果我们有不同的项目:一个是 Java,一个是 Python,一个是 Ruby,那么每个代理都可以构建任何这些项目将是完美的。在这种情况下,代理可以互换,有助于优化资源的使用。

如果代理不能足够通用以匹配所有项目,那么可以对代理和项目进行标记,以便给定的构建将在给定类型的代理上执行。

可扩展性

我们可以使用 Jenkins 从节点来平衡负载和扩展 Jenkins 基础架构。这个过程称为水平扩展。另一种可能性是只使用一个主节点并增加其机器的资源。这个过程称为垂直扩展。让我们更仔细地看看这两个概念。

垂直扩展

垂直扩展意味着当主机负载增加时,会向主机的机器应用更多资源。因此,当我们的组织中出现新项目时,我们会购买更多的 RAM,增加 CPU 核心,并扩展 HDD 驱动器。这可能听起来像是一个不可行的解决方案;然而,它经常被使用,甚至被知名组织使用。将单个 Jenkins 主设置在超高效的硬件上有一个非常强大的优势:维护。任何升级、脚本、安全设置、角色分配或插件安装都只需在一个地方完成。

水平扩展

水平扩展意味着当组织增长时,会启动更多的主实例。这需要将实例智能分配给团队,并且在极端情况下,每个团队都可以拥有自己的 Jenkins 主实例。在这种情况下,甚至可能不需要从属实例。

缺点是可能难以自动化跨项目集成,并且团队的一部分开发时间花在了 Jenkins 维护上。然而,水平扩展具有一些显著的优势:

  • 主机器在硬件方面不需要特殊。

  • 不同的团队可以有不同的 Jenkins 设置(例如,不同的插件集)

  • 团队通常会感到更好,并且如果实例是他们自己的话,他们会更有效地使用 Jenkins。

  • 如果一个主实例宕机,不会影响整个组织

  • 基础设施可以分为标准和关键任务

  • 一些维护方面可以简化,例如,五人团队可以重用相同的 Jenkins 密码,因此我们可以跳过角色和安全设置(当然,只有在企业网络受到良好防火墙保护的情况下才可能)

测试和生产实例

除了扩展方法,还有一个问题:如何测试 Jenkins 升级、新插件或流水线定义?Jenkins 对整个公司至关重要。它保证了软件的质量,并且(在持续交付的情况下)部署到生产服务器。这就是为什么它需要高可用性,因此绝对不是为了测试的目的。这意味着应该始终存在两个相同的 Jenkins 基础架构实例:测试和生产。

测试环境应该尽可能与生产环境相似,因此也需要相似数量的附加代理。

示例架构

我们已经知道应该有从属者,(可能是多个)主节点,以及一切都应该复制到测试和生产环境中。然而,完整的情况会是什么样子呢?

幸运的是,有很多公司发布了他们如何使用 Jenkins 以及他们创建了什么样的架构。很难衡量更多的公司是偏好垂直扩展还是水平扩展,但从只有一个主节点实例到每个团队都有一个主节点都有。范围很广。

让我们以 Netflix 为例,来完整了解 Jenkins 基础设施的情况(他们在 2012 年旧金山 Jenkins 用户大会上分享了计划中的基础设施):

他们有测试和生产主节点实例,每个实例都拥有一组从属者和额外的临时从属者。总共,它每天提供大约 2000 个构建。还要注意,他们的基础设施部分托管在 AWS 上,部分托管在他们自己的服务器上。

我们应该已经对 Jenkins 基础设施的外观有一个大致的想法,这取决于组织的类型。

现在让我们专注于设置代理的实际方面。

配置代理

我们已经知道代理是什么,以及何时可以使用。但是,如何设置代理并让其与主节点通信呢?让我们从问题的第二部分开始,描述主节点和代理之间的通信协议。

通信协议

为了让主节点和代理进行通信,必须建立双向连接。

有不同的选项可以启动它:

  • SSH:主节点使用标准的 SSH 协议连接到从属者。Jenkins 内置了 SSH 客户端,所以唯一的要求是从属者上配置了 SSHD 服务器。这是最方便和稳定的方法,因为它使用标准的 Unix 机制。

  • Java Web Start:在每个代理机器上启动 Java 应用程序,并在 Jenkins 从属应用程序和主 Java 应用程序之间建立 TCP 连接。如果代理位于防火墙网络内,主节点无法启动连接,通常会使用这种方法。

  • Windows 服务:主节点在远程机器上注册代理作为 Windows 服务。这种方法不鼓励使用,因为设置很棘手,图形界面的使用也有限制。

如果我们知道通信协议,让我们看看如何使用它们来设置代理。

设置代理

在低级别上,代理始终使用上面描述的协议与 Jenkins 主服务器通信。然而,在更高级别上,我们可以以各种方式将从节点附加到主服务器。差异涉及两个方面:

  • 静态与动态:最简单的选项是在 Jenkins 主服务器中永久添加从节点。这种解决方案的缺点是,如果我们需要更多(或更少)的从节点,我们总是需要手动更改一些东西。更好的选择是根据需要动态提供从节点。

  • 特定与通用:代理可以是特定的(例如,基于 Java 7 的项目有不同的代理,基于 Java 8 的项目有不同的代理),也可以是通用的(代理充当 Docker 主机,流水线在 Docker 容器内构建)。

这些差异导致了四种常见的代理配置策略:

  • 永久代理

  • 永久 Docker 代理

  • Jenkins Swarm 代理

  • 动态提供的 Docker 代理

让我们逐个检查每种解决方案。

永久代理

我们从最简单的选项开始,即永久添加特定代理节点。可以完全通过 Jenkins Web 界面完成。

配置永久代理

在 Jenkins 主服务器上,当我们打开“管理 Jenkins”,然后点击“管理节点”,我们可以查看所有已附加的代理。然后,通过点击“新建节点”,给它一个名称,并点击“确定”按钮,最终我们应该看到代理的设置页面:

让我们来看看我们需要填写的参数:

  • 名称:这是代理的唯一名称

  • 描述:这是代理的任何可读描述

  • 执行器数量:这是从节点上可以并行运行的构建数量

  • 远程根目录:这是从节点上的专用目录,代理可以用它来运行构建作业(例如,/var/jenkins);最重要的数据被传输回主服务器,因此目录并不重要

  • 标签:这包括匹配特定构建的标签(相同标记),例如,仅基于 Java 8 的项目

  • 用法:这是决定代理是否仅用于匹配标签(例如,仅用于验收测试构建)还是用于任何构建的选项

  • 启动方法:这包括以下内容:

  • 通过 Java Web Start 启动从属:在这里,代理将建立连接;可以下载 JAR 文件以及在从属机器上运行它的说明

  • 通过在主节点上执行命令启动从属:这是在主节点上运行的自定义命令,大多数情况下它会发送 Java Web Start JAR 应用程序并在从属上启动它(例如,ssh <slave_hostname> java -jar ~/bin/slave.jar

  • 通过 SSH 启动从属代理:在这里,主节点将使用 SSH 协议连接到从属

  • 让 Jenkins 将此 Windows 从属作为 Windows 服务进行控制:在这里,主节点将启动内置于 Windows 中的远程管理设施

  • 可用性:这是决定代理是否应该一直在线或者在某些条件下主节点应该将其离线的选项

当代理正确设置后,可以将主节点离线,这样就不会在其上执行任何构建,它只会作为 Jenkins UI 和构建协调器。

理解永久从属

正如前面提到的,这种解决方案的缺点是我们需要为不同的项目类型维护多个从属类型(标签)。这种情况如下图所示:

在我们的示例中,如果我们有三种类型的项目(java7java8ruby),那么我们需要维护三个分别带有标签的(集合)从属。这与我们在维护多个生产服务器类型时遇到的问题相同,如第二章 引入 Docker中所述。我们通过在生产服务器上安装 Docker Engine 来解决了这个问题。让我们尝试在 Jenkins 从属上做同样的事情。

永久 Docker 从属

这种解决方案的理念是永久添加通用从属。每个从属都配置相同(安装了 Docker Engine),并且每个构建与 Docker 镜像一起定义,构建在其中运行。

配置永久 Docker 从属

配置是静态的,所以它的完成方式与我们为永久从属所做的完全相同。唯一的区别是我们需要在每台将用作从属的机器上安装 Docker。然后,通常我们不需要标签,因为所有从属都可以是相同的。在从属配置完成后,我们在每个流水线脚本中定义 Docker 镜像。

pipeline {
     agent {
          docker {
               image 'openjdk:8-jdk-alpine'
          }
     }
     ...
}

当构建开始时,Jenkins 从服务器会从 Docker 镜像openjdk:8-jdk-alpine启动一个容器,然后在该容器内执行所有流水线步骤。这样,我们始终知道执行环境,并且不必根据特定项目类型单独配置每个从服务器。

理解永久 Docker 代理

看着我们为永久代理所采取的相同场景,图表如下:

每个从服务器都是完全相同的,如果我们想构建一个依赖于 Java 8 的项目,那么我们在流水线脚本中定义适当的 Docker 镜像(而不是指定从服务器标签)。

Jenkins Swarm 代理

到目前为止,我们总是不得不在 Jenkins 主服务器中永久定义每个代理。这样的解决方案,即使在许多情况下都足够好,如果我们需要频繁扩展从服务器的数量,可能会成为负担。Jenkins Swarm 允许您动态添加从服务器,而无需在 Jenkins 主服务器中对其进行配置。

配置 Jenkins Swarm 代理

使用 Jenkins Swarm 的第一步是在 Jenkins 中安装自组织 Swarm 插件模块插件。我们可以通过 Jenkins Web UI 在“管理 Jenkins”和“管理插件”下进行。完成此步骤后,Jenkins 主服务器准备好动态附加 Jenkins 从服务器。

第二步是在每台将充当 Jenkins 从服务器的机器上运行 Jenkins Swarm 从服务器应用程序。我们可以使用swarm-client.jar应用程序来完成。

swarm-client.jar应用程序可以从 Jenkins Swarm 插件页面下载:wiki.jenkins-ci.org/display/JENKINS/Swarm+Plugin。在该页面上,您还可以找到其执行的所有可能选项。

要附加 Jenkins Swarm 从节点,只需运行以下命令:

$ java -jar swarm-client.jar -master <jenkins_master_url> -username <jenkins_master_user> -password <jenkins_master_password> -name jenkins-swarm-slave-1

在撰写本书时,存在一个client-slave.jar无法通过安全的 HTTPS 协议工作的未解决错误,因此需要在命令执行中添加-disableSslVerification选项。

成功执行后,我们应该注意到 Jenkins 主服务器上出现了一个新的从服务器,如屏幕截图所示:

现在,当我们运行构建时,它将在此代理上启动。

添加 Jenkins Swarm 代理的另一种可能性是使用从swarm-client.jar工具构建的 Docker 镜像。Docker Hub 上有一些可用的镜像。我们可以使用csanchez/jenkins-swarm-slave镜像。

了解 Jenkins Swarm 代理

Jenkins Swarm 允许动态添加代理,但它没有说明是否使用特定的或基于 Docker 的从属,所以我们可以同时使用它。乍一看,Jenkins Swarm 可能看起来并不是很有用。毕竟,我们将代理设置从主服务器移到了从属,但仍然需要手动完成。然而,正如我们将在第八章中看到的那样,使用 Docker Swarm 进行集群,Jenkins Swarm 可以在服务器集群上动态扩展从属。

动态配置的 Docker 代理

另一个选项是设置 Jenkins 在每次启动构建时动态创建一个新的代理。这种解决方案显然是最灵活的,因为从属的数量会动态调整到构建的数量。让我们看看如何以这种方式配置 Jenkins。

配置动态配置的 Docker 代理

我们需要首先安装 Docker 插件。与 Jenkins 插件一样,我们可以在“管理 Jenkins”和“管理插件”中进行。安装插件后,我们可以开始以下配置步骤:

  1. 打开“管理 Jenkins”页面。

  2. 单击“配置系统”链接。

  3. 在页面底部,有云部分。

  4. 单击“添加新的云”并选择 Docker。

  5. 填写 Docker 代理的详细信息。

  1. 大多数参数不需要更改;但是,我们需要设置其中两个如下:
    • Docker URL:代理将在其中运行的 Docker 主机机器的地址
  • 凭据:如果 Docker 主机需要身份验证的凭据

如果您计划在运行主服务器的相同 Docker 主机上使用它,则 Docker 守护程序需要在docker0网络接口上进行监听。您可以以与在服务器上安装部分中描述的类似方式进行操作。这与我们在维护多个生产服务器类型时遇到的问题相同,如第二章中所述,介绍 Docker,通过更改/lib/systemd/system/docker.service文件中的一行为ExecStart=/usr/bin/dockerd -H 0.0.0.0:2375 -H fd://

  1. 单击“添加 Docker 模板”并选择 Docker 模板。

  2. 填写有关 Docker 从属镜像的详细信息:

我们可以使用以下参数:

  • Docker 镜像:Jenkins 社区中最受欢迎的从属镜像是evarga/jenkins-slave

  • 凭据:对evarga/jenkins-slave镜像的凭据是:

  • 用户名:jenkins

  • 密码:jenkins

  • 实例容量:这定义了同时运行的代理的最大数量;初始设置可以为 10

除了evarga/jenkins-slave之外,也可以构建和使用自己的从属镜像。当存在特定的环境要求时,例如安装了 Python 解释器时,这是必要的。在本书的所有示例中,我们使用了leszko/jenkins-docker-slave

保存后,一切都设置好了。我们可以运行流水线来观察执行是否真的在 Docker 代理上进行,但首先让我们深入了解一下 Docker 代理的工作原理。

理解动态提供的 Docker 代理

动态提供的 Docker 代理可以被视为标准代理机制的一层。它既不改变通信协议,也不改变代理的创建方式。那么,Jenkins 会如何处理我们提供的 Docker 代理配置呢?

以下图表展示了我们配置的 Docker 主从架构:

让我们逐步描述 Docker 代理机制的使用方式:

  1. 当 Jenkins 作业启动时,主机会在从属 Docker 主机上从jenkins-slave镜像运行一个新的容器。

  2. jenkins-slave 容器实际上是安装了 SSHD 服务器的 ubuntu 镜像。

  3. Jenkins 主机会自动将创建的代理添加到代理列表中(与我们在设置代理部分手动操作的方式相同)。

  4. 代理是通过 SSH 通信协议访问以执行构建的。

  5. 构建完成后,主机会停止并移除从属容器。

将 Jenkins 主机作为 Docker 容器运行与将 Jenkins 代理作为 Docker 容器运行是独立的。两者都是合理的选择,但它们中的任何一个都可以单独工作。

这个解决方案在某种程度上类似于永久的 Docker 代理解决方案,因为最终我们是在 Docker 容器内运行构建。然而,不同之处在于从属节点的配置。在这里,整个从属都是 docker 化的,不仅仅是构建环境。因此,它具有以下两个巨大的优势:

  • 自动代理生命周期:创建、添加和移除代理的过程是自动化的。

  • 可扩展性:实际上,从容器主机可能不是单个机器,而是由多台机器组成的集群(我们将在第八章中介绍使用 Docker Swarm 进行集群化,使用 Docker Swarm 进行集群化)。在这种情况下,添加更多资源就像添加新机器到集群一样简单,并且不需要对 Jenkins 进行任何更改。

Jenkins 构建通常需要下载大量项目依赖项(例如 Gradle/Maven 依赖项),这可能需要很长时间。如果 Docker 代理自动为每个构建进行配置,那么值得为它们设置一个 Docker 卷,以便在构建之间启用缓存。

测试代理

无论选择了哪种代理配置,现在我们应该检查它是否正常工作。

让我们回到 hello world 流水线。通常,构建的持续时间比 hello-world 示例长,所以我们可以通过在流水线脚本中添加睡眠来模拟它:

pipeline {
     agent any
     stages {
          stage("Hello") {
               steps {
                    sleep 300 // 5 minutes
                    echo 'Hello World'
               }
          }
     }
}

点击“立即构建”并转到 Jenkins 主页后,我们应该看到构建是在代理上执行的。现在,如果我们多次点击构建,不同的代理应该执行不同的构建(如下截图所示):

为了防止作业在主节点上执行,记得将主节点设置为离线或在节点管理配置中将执行器数量设置为0

通过观察代理执行我们的构建,我们确认它们已经正确配置。现在,让我们看看为什么以及如何创建我们自己的 Jenkins 镜像。

自定义 Jenkins 镜像

到目前为止,我们使用了从互联网上拉取的 Jenkins 镜像。我们使用jenkins作为主容器,evarga/jenkins-slave作为从容器。然而,我们可能希望构建自己的镜像以满足特定的构建环境要求。在本节中,我们将介绍如何做到这一点。

构建 Jenkins 从容器

让我们从从容器镜像开始,因为它经常被定制。构建执行是在代理上执行的,因此需要调整代理的环境以适应我们想要构建的项目。例如,如果我们的项目是用 Python 编写的,可能需要 Python 解释器。同样的情况也适用于任何库、工具、测试框架或项目所需的任何内容。

您可以通过查看其 Dockerfile 来查看evarga/jenkins-slave镜像中已安装的内容github.com/evarga/docker-images

构建和使用自定义镜像有三个步骤:

  1. 创建一个 Dockerfile。

  2. 构建镜像。

  3. 更改主节点上的代理配置。

举个例子,让我们创建一个为 Python 项目提供服务的从节点。为了简单起见,我们可以基于evarga/jenkins-slave镜像构建它。让我们按照以下三个步骤来做:

  1. Dockerfile:让我们在 Dockerfile 中创建一个新目录,内容如下:
 FROM evarga/jenkins-slave
 RUN apt-get update && \
 apt-get install -y python

基础 Docker 镜像evarga/jenkins-slave适用于动态配置的 Docker 代理解决方案。对于永久性 Docker 代理,只需使用alpineubuntu或任何其他镜像即可,因为 docker 化的不是从节点,而只是构建执行环境。

  1. 构建镜像:我们可以通过执行以下命令来构建镜像:
 $ docker build -t jenkins-slave-python .
  1. 配置主节点:当然,最后一步是在 Jenkins 主节点的配置中设置jenkins-slave-python,而不是evarga/jenkins-slave(如设置 Docker 代理部分所述)。

从节点的 Dockerfile 应该保存在源代码仓库中,并且可以由 Jenkins 自动执行构建。使用旧的 Jenkins 从节点构建新的 Jenkins 从节点镜像没有问题。

如果我们需要 Jenkins 构建两种不同类型的项目,例如一个基于 Python,另一个基于 Ruby,该怎么办?在这种情况下,我们可以准备一个足够通用以支持 Python 和 Ruby 的代理。然而,在 Docker 的情况下,建议创建第二个从节点镜像(通过类比创建jenkins-slave-ruby)。然后,在 Jenkins 配置中,我们需要创建两个 Docker 模板并相应地标记它们。

构建 Jenkins 主节点

我们已经有一个自定义的从节点镜像。为什么我们还想要构建自己的主节点镜像呢?其中一个原因可能是我们根本不想使用从节点,而且由于执行将在主节点上进行,它的环境必须根据项目的需求进行调整。然而,这是非常罕见的情况。更常见的情况是,我们会想要配置主节点本身。

想象一下以下情景,您的组织将 Jenkins 水平扩展,每个团队都有自己的实例。然而,有一些共同的配置,例如:一组基本插件,备份策略或公司标志。然后,为每个团队重复相同的配置是一种浪费时间。因此,我们可以准备共享的主镜像,并让团队使用它。

Jenkins 使用 XML 文件进行配置,并提供基于 Groovy 的 DSL 语言来对其进行操作。这就是为什么我们可以将 Groovy 脚本添加到 Dockerfile 中,以操纵 Jenkins 配置。而且,如果需要比 XML 更多的更改,例如插件安装,还有特殊的脚本来帮助 Jenkins 配置。

Dockerfile 指令的所有可能性都在 GitHub 页面github.com/jenkinsci/docker上有详细描述。

例如,让我们创建一个已经安装了 docker-plugin 并将执行者数量设置为 5 的主镜像。为了做到这一点,我们需要:

  1. 创建 Groovy 脚本以操纵config.xml并将执行者数量设置为5

  2. 创建 Dockerfile 以安装 docker-plugin 并执行 Groovy 脚本。

  3. 构建图像。

让我们使用提到的三个步骤构建 Jenkins 主镜像。

  1. Groovy 脚本:让我们在executors.groovy文件内创建一个新目录,内容如下:
import jenkins.model.*
Jenkins.instance.setNumExecutors(5)

完整的 Jenkins API 可以在官方页面javadoc.jenkins.io/上找到。

  1. Dockerfile:在同一目录下,让我们创建 Dockerfile:
FROM jenkins
COPY executors.groovy 
      /usr/share/jenkins/ref/init.groovy.d/executors.groovy
RUN /usr/local/bin/install-plugins.sh docker-plugin
  1. 构建图像:我们最终可以构建图像:
$ docker build -t jenkins-master .

创建图像后,组织中的每个团队都可以使用它来启动自己的 Jenkins 实例。

拥有自己的主从镜像可以为我们组织中的团队提供配置和构建环境。在接下来的部分,我们将看到 Jenkins 中还有哪些值得配置。

配置和管理

我们已经涵盖了 Jenkins 配置的最关键部分:代理配置。由于 Jenkins 具有高度可配置性,您可以期望有更多的可能性来调整它以满足您的需求。好消息是配置是直观的,并且可以通过 Web 界面访问,因此不需要任何详细的描述。所有内容都可以在“管理 Jenkins”子页面下更改。在本节中,我们只会关注最有可能被更改的一些方面:插件、安全和备份。

插件

Jenkins 是高度面向插件的,这意味着许多功能都是通过插件提供的。它们可以以几乎无限的方式扩展 Jenkins,考虑到庞大的社区,这是 Jenkins 如此成功的原因之一。Jenkins 的开放性带来了风险,最好只从可靠的来源下载插件或检查它们的源代码。

选择插件的数量实际上有很多。其中一些在初始配置过程中已经自动安装了。另一个(Docker 插件)是在设置 Docker 代理时安装的。有用于云集成、源代码控制工具、代码覆盖等的插件。你也可以编写自己的插件,但最好先检查一下你需要的插件是否已经存在。

有一个官方的 Jenkins 页面可以浏览插件plugins.jenkins.io/

安全

您应该如何处理 Jenkins 安全取决于您在组织中选择的 Jenkins 架构。如果您为每个小团队都有一个 Jenkins 主服务器,那么您可能根本不需要它(假设企业网络已设置防火墙)。然而,如果您为整个组织只有一个 Jenkins 主服务器实例,那么最好确保您已经很好地保护了它。

Jenkins 自带自己的用户数据库-我们在初始配置过程中已经创建了一个用户。您可以通过打开“管理用户”设置页面来创建、删除和修改用户。内置数据库可以在小型组织的情况下使用;然而,对于大量用户,您可能希望使用 LDAP。您可以在“配置全局安全”页面上选择它。在那里,您还可以分配角色、组和用户。默认情况下,“已登录用户可以做任何事情”选项被设置,但在大规模组织中,您可能需要考虑更详细的细粒度。

备份

俗话说:“有两种人:那些备份的人,和那些将要备份的人”。信不信由你,备份可能是你想要配置的东西。要备份哪些文件,从哪些机器备份?幸运的是,代理自动将所有相关数据发送回主服务器,所以我们不需要担心它们。如果你在容器中运行 Jenkins,那么容器本身也不重要,因为它不保存任何持久状态。我们唯一感兴趣的地方是 Jenkins 主目录。

我们可以安装一个 Jenkins 插件(帮助我们设置定期备份),或者简单地设置一个 cron 作业将目录存档到一个安全的地方。为了减小大小,我们可以排除那些不感兴趣的子文件夹(这将取决于你的需求;然而,几乎可以肯定的是,你不需要复制:"war","cache","tools"和"workspace")。

有很多插件可以帮助备份过程;最常见的一个叫做备份插件

蓝色海洋 UI

Hudson(Jenkins 的前身)的第一个版本于 2005 年发布。它已经在市场上超过 10 年了。然而,它的外观和感觉并没有改变太多。我们已经使用它一段时间了,很难否认它看起来过时。Blue Ocean 是一个重新定义了 Jenkins 用户体验的插件。如果 Jenkins 在美学上让你不满意,那么值得一试。

您可以在jenkins.io/projects/blueocean/的蓝色海洋页面上阅读更多信息!

练习

在本章中,我们学到了很多关于 Jenkins 配置的知识。为了巩固这些知识,我们建议进行两个练习,准备 Jenkins 镜像并测试 Jenkins 环境。

  1. 创建 Jenkins 主和从属 Docker 镜像,并使用它们来运行能够构建 Ruby 项目的 Jenkins 基础设施:
  • 创建主 Dockerfile,自动安装 Docker 插件。

  • 构建主镜像并运行 Jenkins 实例

  • 创建从属 Dockerfile(适用于动态从属供应),安装 Ruby 解释器

  • 构建从属镜像

  • 在 Jenkins 实例中更改配置以使用从属镜像

  1. 创建一个流水线,运行一个打印Hello World from Ruby的 Ruby 脚本:
  • 创建一个新的流水线

  • 使用以下 shell 命令即时创建hello.rb脚本:

sh "echo "puts 'Hello World from Ruby'" > hello.rb"

  • 添加命令以使用 Ruby 解释器运行hello.rb

  • 运行构建并观察控制台输出

总结

在本章中,我们已经介绍了 Jenkins 环境及其配置。所获得的知识足以建立完整基于 Docker 的 Jenkins 基础设施。本章的关键要点如下:

  • Jenkins 是一种通用的自动化工具,可与任何语言或框架一起使用。

  • Jenkins 可以通过插件进行高度扩展,这些插件可以自行编写或在互联网上找到。

  • Jenkins 是用 Java 编写的,因此可以安装在任何操作系统上。它也作为 Docker 镜像正式提供。

  • Jenkins 可以使用主从架构进行扩展。主实例可以根据组织的需求进行水平或垂直扩展。

  • Jenkins 的代理可以使用 Docker 实现,这有助于自动配置和动态分配从机。

  • 可以为 Jenkins 主和 Jenkins 从创建自定义 Docker 镜像。

  • Jenkins 是高度可配置的,应始终考虑的方面是:安全性和备份。

在下一章中,我们将专注于已经通过“hello world”示例接触过的部分,即管道。我们将描述构建完整持续集成管道的思想和方法。

第四章:持续集成管道

我们已经知道如何配置 Jenkins。在本章中,您将看到如何有效地使用它,重点放在 Jenkins 核心的功能上,即管道。通过从头开始构建完整的持续集成过程,我们将描述现代团队导向的代码开发的所有方面。

本章涵盖以下要点:

  • 解释管道的概念

  • 介绍 Jenkins 管道语法

  • 创建持续集成管道

  • 解释 Jenkinsfile 的概念

  • 创建代码质量检查

  • 添加管道触发器和通知

  • 解释开发工作流程和分支策略

  • 介绍 Jenkins 多分支

介绍管道

管道是一系列自动化操作,通常代表软件交付和质量保证过程的一部分。它可以简单地被看作是一系列脚本,提供以下额外的好处:

  • 操作分组:操作被分组到阶段中(也称为质量门),引入了结构到过程中,并清晰地定义了规则:如果一个阶段失败,就不会执行更多的阶段

  • 可见性:过程的所有方面都被可视化,这有助于快速分析失败,并促进团队协作

  • 反馈:团队成员一旦发现问题,就可以迅速做出反应

管道的概念对于大多数持续集成工具来说是相似的,但命名可能有所不同。在本书中,我们遵循 Jenkins 的术语。

管道结构

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'
               }
          }
     }
}

管道在环境方面没有特殊要求(任何从属代理),并在两个阶段内执行三个步骤。当我们点击“立即构建”时,我们应该看到可视化表示:

管道成功了,我们可以通过单击控制台查看步骤执行详细信息。如果任何步骤失败,处理将停止,不会运行更多的步骤。实际上,管道的整个目的是阻止所有进一步的步骤执行并可视化失败点。

管道语法

我们已经讨论了管道元素,并已经使用了一些管道步骤,例如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 指令。为了回答实验谜题,让我们逐条看看管道的执行指令:

  1. 使用任何可用的代理。

  2. 每分钟自动执行。

  3. 如果执行时间超过 5 分钟,请停止。

  4. 在开始之前要求布尔输入参数。

  5. Rafal设置为环境变量 NAME。

  6. 仅在true输入参数的情况下:

  • 打印来自 Rafal 的问候

  • 打印测试 chrome 浏览器

  • 打印测试 firefox 浏览器

  1. 无论执行过程中是否出现任何错误,都打印我总是会说再见!

让我们描述最重要的 Jenkins 关键字。声明性管道总是在pipeline块内指定,并包含部分、指令和步骤。我们将逐个讨论它们。

完整的管道语法描述可以在官方 Jenkins 页面上找到jenkins.io/doc/book/pipeline/syntax/

部分

部分定义了流水线的结构,通常包含一个或多个指令或步骤。它们使用以下关键字进行定义:

  • 阶段:这定义了一系列一个或多个阶段指令

  • 步骤:这定义了一系列一个或多个步骤指令

  • 后置:这定义了在流水线构建结束时运行的一个或多个步骤指令序列;标有条件(例如 always,success 或 failure),通常用于在流水线构建后发送通知(我们将在触发器和通知部分详细介绍)。

指令

指令表达了流水线或其部分的配置:

  • 代理:这指定执行的位置,并可以定义label以匹配同样标记的代理,或者使用docker来指定动态提供环境以执行流水线的容器

  • 触发器:这定义了触发流水线的自动方式,可以使用cron来设置基于时间的调度,或者使用pollScm来检查仓库的更改(我们将在触发器和通知部分详细介绍)

  • 选项:这指定了特定于流水线的选项,例如timeout(流水线运行的最长时间)或retry(流水线在失败后应重新运行的次数)

  • 环境:这定义了在构建过程中用作环境变量的一组键值

  • 参数:这定义了用户输入参数的列表

  • 阶段:这允许对步骤进行逻辑分组

  • :这确定阶段是否应根据给定条件执行

步骤

步骤是流水线最基本的部分。它们定义了要执行的操作,因此它们实际上告诉 Jenkins要做什么

  • sh:这执行 shell 命令;实际上,几乎可以使用sh来定义任何操作

  • 自定义:Jenkins 提供了许多可用作步骤的操作(例如echo);其中许多只是用于方便的sh命令的包装器;插件也可以定义自己的操作

  • 脚本:这执行基于 Groovy 的代码块,可用于一些需要流程控制的非常规情况

可用步骤的完整规范可以在以下网址找到:jenkins.io/doc/pipeline/steps/

请注意,流水线语法非常通用,从技术上讲,几乎可以用于任何自动化流程。这就是为什么应该将流水线视为一种结构化和可视化的方法。然而,最常见的用例是实现我们将在下一节中看到的持续集成服务器。

提交流水线

最基本的持续集成流程称为提交流水线。这个经典阶段,顾名思义,从主存储库提交(或在 Git 中推送)开始,并导致构建成功或失败的报告。由于它在代码每次更改后运行,构建时间不应超过 5 分钟,并且应消耗合理数量的资源。提交阶段始终是持续交付流程的起点,并且在开发过程中提供了最重要的反馈循环,不断提供代码是否处于健康状态的信息。

提交阶段的工作如下。开发人员将代码提交到存储库,持续集成服务器检测到更改,构建开始。最基本的提交流水线包含三个阶段:

  • 检出:此阶段从存储库下载源代码

  • 编译:此阶段编译源代码

  • 单元测试:此阶段运行一套单元测试

让我们创建一个示例项目,看看如何实现提交流水线。

这是一个使用 Git、Java、Gradle 和 Spring Boot 等技术的项目的流水线示例。然而,相同的原则适用于任何其他技术。

检出

从存储库检出代码始终是任何流水线中的第一个操作。为了看到这一点,我们需要有一个存储库。然后,我们将能够创建一个流水线。

创建 GitHub 存储库

在 GitHub 服务器上创建存储库只需几个步骤:

  1. 转到github.com/页面。

  2. 如果还没有帐户,请创建一个。

  3. 点击“新存储库”。

  4. 给它一个名字,calculator

  5. 选中“使用 README 初始化此存储库”。

  6. 点击“创建存储库”。

现在,您应该看到存储库的地址,例如https://github.com/leszko/calculator.git

创建一个检出阶段

我们可以创建一个名为calculator的新流水线,并将代码放在一个名为 Checkout 的阶段的流水线脚本中:

pipeline {
     agent any
     stages {
          stage("Checkout") {
               steps {
                    git url: 'https://github.com/leszko/calculator.git'
               }
          }
     }
}

流水线可以在任何代理上执行,它的唯一步骤只是从存储库下载代码。我们可以点击“立即构建”并查看是否成功执行。

请注意,Git 工具包需要安装在执行构建的节点上。

当我们完成检出时,我们准备进行第二阶段。

编译

为了编译一个项目,我们需要:

  1. 创建一个带有源代码的项目。

  2. 将其推送到存储库。

  3. 将编译阶段添加到流水线。

创建一个 Java Spring Boot 项目

让我们使用 Gradle 构建的 Spring Boot 框架创建一个非常简单的 Java 项目。

Spring Boot 是一个简化构建企业应用程序的 Java 框架。Gradle 是一个基于 Apache Maven 概念的构建自动化系统。

创建 Spring Boot 项目的最简单方法是执行以下步骤:

  1. 转到start.spring.io/页面。

  2. 选择 Gradle 项目而不是 Maven 项目(如果您更喜欢 Maven,也可以保留 Maven)。

  3. 填写组和 Artifact(例如,com.leszkocalculator)。

  4. 将 Web 添加到依赖项。

  5. 单击生成项目。

  6. 应下载生成的骨架项目(calculator.zip文件)。

以下屏幕截图显示了start.spring.io/页面:

将代码推送到 GitHub

我们将使用 Git 工具执行commitpush操作:

为了运行git命令,您需要安装 Git 工具包(可以从git-scm.com/downloads下载)。

让我们首先将存储库克隆到文件系统:

$ git clone https://github.com/leszko/calculator.git

将从start.spring.io/下载的项目解压到 Git 创建的目录中。

如果您愿意,您可以将项目导入到 IntelliJ、Eclipse 或您喜欢的 IDE 工具中。

结果,calculator目录应该有以下文件:

$ ls -a
. .. build.gradle .git .gitignore gradle gradlew gradlew.bat README.md src

为了在本地执行 Gradle 操作,您需要安装 Java JDK(在 Ubuntu 中,您可以通过执行sudo apt-get install -y default-jdk来完成)。

我们可以使用以下代码在本地编译项目:

$ ./gradlew compileJava

在 Maven 的情况下,您可以运行./mvnw compile。Gradle 和 Maven 都编译src目录中的 Java 类。

您可以在docs.gradle.org/current/userguide/java_plugin.html找到所有可能的 Gradle 指令(用于 Java 项目)。

现在,我们可以将其commitpush到 GitHub 存储库中:

$ git add .
$ git commit -m "Add Spring Boot skeleton"
$ git push -u origin master

运行git push命令后,您将被提示输入 GitHub 凭据(用户名和密码)。

代码已经在 GitHub 存储库中。如果您想检查它,可以转到 GitHub 页面并查看文件。

创建一个编译阶段

我们可以使用以下代码在管道中添加一个编译阶段:

stage("Compile") {
     steps {
          sh "./gradlew compileJava"
     }
}

请注意,我们在本地和 Jenkins 管道中使用了完全相同的命令,这是一个非常好的迹象,因为本地开发过程与持续集成环境保持一致。运行构建后,您应该看到两个绿色的框。您还可以在控制台日志中检查项目是否已正确编译。

单元测试

是时候添加最后一个阶段了,即单元测试,检查我们的代码是否符合预期。我们必须:

  • 添加计算器逻辑的源代码

  • 为代码编写单元测试

  • 添加一个阶段来执行单元测试

创建业务逻辑

计算器的第一个版本将能够添加两个数字。让我们将业务逻辑作为一个类添加到src/main/java/com/leszko/calculator/Calculator.java文件中:

package com.leszko.calculator;
import org.springframework.stereotype.Service;

@Service
public class Calculator {
     int sum(int a, int b) {
          return a + b;
     }
}

为了执行业务逻辑,我们还需要在单独的文件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));
     }
}

这个类将业务逻辑公开为一个网络服务。我们可以运行应用程序并查看它的工作方式:

$ ./gradlew bootRun

它应该启动我们的网络服务,我们可以通过浏览器导航到页面http://localhost:8080/sum?a=1&b=2来检查它是否工作。这应该对两个数字(12)求和,并在浏览器中显示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));
     }
}

我们可以使用./gradlew test命令在本地运行测试。然后,让我们commit代码并将其push到存储库中:

$ git add .
$ git commit -m "Add sum logic, controller and unit test"
$ git push

创建一个单元测试阶段

现在,我们可以在管道中添加一个单元测试阶段:

stage("Unit test") {
     steps {
          sh "./gradlew test"
     }
}

在 Maven 的情况下,我们需要使用./mvnw test

当我们再次构建流水线时,我们应该看到三个框,这意味着我们已经完成了持续集成流水线:

Jenkinsfile

到目前为止,我们一直直接在 Jenkins 中创建流水线代码。然而,这并不是唯一的选择。我们还可以将流水线定义放在一个名为Jenkinsfile的文件中,并将其与源代码一起commit到存储库中。这种方法更加一致,因为流水线的外观与项目本身密切相关。

例如,如果您不需要代码编译,因为您的编程语言是解释性的(而不是编译的),那么您将不会有Compile阶段。您使用的工具也取决于环境。我们使用 Gradle/Maven,因为我们构建了 Java 项目;然而,对于用 Python 编写的项目,您可以使用 PyBuilder。这导致了一个想法,即流水线应该由编写代码的同一人员,即开发人员创建。此外,流水线定义应与代码一起放在存储库中。

这种方法带来了即时的好处,如下所示:

  • 在 Jenkins 失败的情况下,流水线定义不会丢失(因为它存储在代码存储库中,而不是在 Jenkins 中)

  • 流水线更改的历史记录被存储

  • 流水线更改经过标准的代码开发过程(例如,它们要经过代码审查)

  • 对流水线更改的访问受到与对源代码访问完全相同的限制

创建 Jenkinsfile

我们可以创建Jenkinsfile并将其推送到我们的 GitHub 存储库。它的内容几乎与我们编写的提交流水线相同。唯一的区别是,检出阶段变得多余,因为 Jenkins 必须首先检出代码(与Jenkinsfile一起),然后读取流水线结构(从Jenkinsfile)。这就是为什么 Jenkins 在读取Jenkinsfile之前需要知道存储库地址。

让我们在项目的根目录中创建一个名为Jenkinsfile的文件:

pipeline {
     agent any
     stages {
          stage("Compile") {
               steps {
                    sh "./gradlew compileJava"
               }
          }
          stage("Unit test") {
               steps {
                    sh "./gradlew test"
               }
          }
     }
}

我们现在可以commit添加的文件并push到 GitHub 存储库:

$ git add .
$ git commit -m "Add sum Jenkinsfile"
$ git push

从 Jenkinsfile 运行流水线

Jenkinsfile在存储库中时,我们所要做的就是打开流水线配置,在Pipeline部分:

  • 将定义从Pipeline script更改为Pipeline script from SCM

  • 在 SCM 中选择 Git

  • https://github.com/leszko/calculator.git放入存储库 URL

保存后,构建将始终从 Jenkinsfile 的当前版本运行到存储库中。

我们已成功创建了第一个完整的提交流水线。它可以被视为最小可行产品,并且实际上,在许多情况下,它作为持续集成流程是足够的。在接下来的章节中,我们将看到如何改进提交流水线以使其更好。

代码质量阶段

我们可以通过额外的步骤扩展经典的持续集成三个步骤。最常用的是代码覆盖和静态分析。让我们分别看看它们。

代码覆盖

考虑以下情景:您有一个良好配置的持续集成流程;然而,项目中没有人编写单元测试。它通过了所有构建,但这并不意味着代码按预期工作。那么该怎么办?如何确保代码已经测试过了?

解决方案是添加代码覆盖工具,运行所有测试并验证代码的哪些部分已执行。然后,它创建一个报告显示未经测试的部分。此外,当未经测试的代码太多时,我们可以使构建失败。

有很多工具可用于执行测试覆盖分析;对于 Java 来说,最流行的是 JaCoCo、Clover 和 Cobertura。

让我们使用 JaCoCo 并展示覆盖检查在实践中是如何工作的。为了做到这一点,我们需要执行以下步骤:

  1. 将 JaCoCo 添加到 Gradle 配置中。

  2. 将代码覆盖阶段添加到流水线中。

  3. 可选地,在 Jenkins 中发布 JaCoCo 报告。

将 JaCoCo 添加到 Gradle

为了从 Gradle 运行 JaCoCo,我们需要通过在插件部分添加以下行将jacoco插件添加到build.gradle文件中:

apply plugin: "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文件中查看完整的覆盖报告:

添加代码覆盖阶段

将代码覆盖率阶段添加到流水线中与之前的阶段一样简单:

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插件。您可以在jenkins.io/doc/pipeline/steps/htmlpublisher/#publishhtml-publish-html-reports了解有关该插件的更多信息。

我们已经创建了代码覆盖率阶段,显示了未经测试且因此容易出现错误的代码。让我们看看还可以做些什么来提高代码质量。

如果您需要更严格的代码覆盖率,可以检查变异测试的概念,并将 PIT 框架阶段添加到流水线中。在pitest.org/了解更多信息。

静态代码分析

您的代码可能运行得很好,但是代码本身的质量如何呢?我们如何确保它是可维护的并且以良好的风格编写的?

静态代码分析是一种自动检查代码而不实际执行的过程。在大多数情况下,它意味着对源代码检查一系列规则。这些规则可能适用于各种方面;例如,所有公共类都需要有 Javadoc 注释;一行的最大长度是 120 个字符,或者如果一个类定义了equals()方法,它也必须定义hashCode()方法。

对 Java 代码进行静态分析的最流行工具是 Checkstyle、FindBugs 和 PMD。让我们看一个例子,并使用 Checkstyle 添加静态代码分析阶段。我们将分三步完成这个过程:

  1. 添加 Checkstyle 配置。

  2. 添加 Checkstyle 阶段。

  3. 可选地,在 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="JavadocType">
               <property name="scope" value="public"/>
          </module>
     </module>
</module>

配置只包含一个规则:检查公共类、接口和枚举是否用 Javadoc 记录。如果没有,构建将失败。

完整的 Checkstyle 描述可以在checkstyle.sourceforge.net/config.html找到。

我们还需要将checkstyle插件添加到build.gradle文件中:

apply plugin: 'checkstyle'

然后,我们可以运行以下代码来运行checkstyle

$ ./gradlew checkstyleMain

在我们的项目中,这应该会导致失败,因为我们的公共类(Calculator.javaCalculatorApplication.javaCalculatorTest.javaCalculatorApplicationTests.java)都没有 Javadoc 注释。我们需要通过添加文档来修复它,例如,在src/main/java/com/leszko/calculator/CalculatorApplication.java文件中:

/
 * Main Spring Application.
 */
@SpringBootApplication
public class CalculatorApplication {
     public static void main(String[] args) {
          SpringApplication.run(CalculatorApplication.class, args);
     }
}

现在,构建应该成功。

添加静态代码分析阶段

我们可以在流水线中添加一个“静态代码分析”阶段:

stage("Static code analysis") {
     steps {
          sh "./gradlew checkstyleMain"
     }
}

现在,如果有人提交了一个没有 Javadoc 的公共类文件,构建将失败。

发布静态代码分析报告

与 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 界面来配置规则并显示代码漏洞。

您可以在其官方页面www.sonarqube.org/上阅读有关 SonarQube 的更多信息。

触发器和通知

到目前为止,我们一直通过点击“立即构建”按钮手动构建流水线。这样做虽然有效,但不太方便。所有团队成员都需要记住,在提交到存储库后,他们需要打开 Jenkins 并开始构建。流水线监控也是一样;到目前为止,我们手动打开 Jenkins 并检查构建状态。在本节中,我们将看到如何改进流程,使得流水线可以自动启动,并在完成后通知团队成员其状态。

触发器

自动启动构建的操作称为流水线触发器。在 Jenkins 中,有许多选择,但它们都归结为三种类型:

  • 外部

  • 轮询 SCM(源代码管理)

  • 定时构建

让我们来看看每一个。

外部

外部触发器很容易理解。它意味着 Jenkins 在被通知者调用后开始构建,通知者可以是其他流水线构建、SCM 系统(例如 GitHub)或任何远程脚本。

下图展示了通信:

GitHub 在推送到存储库后触发 Jenkins 并开始构建。

要以这种方式配置系统,我们需要以下设置步骤:

  1. 在 Jenkins 中安装 GitHub 插件。

  2. 为 Jenkins 生成一个秘钥。

  3. 设置 GitHub Web 钩子并指定 Jenkins 地址和秘钥。

对于最流行的 SCM 提供商,通常都会提供专门的 Jenkins 插件。

还有一种更通用的方式可以通过对端点<jenkins_url>/job/<job_name>/build?token=<token>进行 REST 调用来触发 Jenkins。出于安全原因,它需要在 Jenkins 中设置token,然后在远程脚本中使用。

Jenkins 必须可以从 SCM 服务器访问。换句话说,如果我们使用公共 GitHub 来触发 Jenkins,那么我们的 Jenkins 服务器也必须是公共的。这也适用于通用解决方案;<jenkins_url>地址必须是可访问的。

轮询 SCM

轮询 SCM 触发器有点不太直观。下图展示了通信:

Jenkins 定期调用 GitHub 并检查存储库是否有任何推送。然后,它开始构建。这可能听起来有些反直觉,但是至少有两种情况可以使用这种方法:

  • Jenkins 位于防火墙网络内(GitHub 无法访问)

  • 提交频繁,构建时间长,因此在每次提交后执行构建会导致过载

轮询 SCM的配置也更简单,因为从 Jenkins 到 GitHub 的连接方式已经设置好了(Jenkins 从 GitHub 检出代码,因此需要访问权限)。对于我们的计算器项目,我们可以通过在流水线中添加triggers声明(在agent之后)来设置自动触发:

triggers {
     pollSCM('* * * * *')
}

第一次手动运行流水线后,自动触发被设置。然后,它每分钟检查 GitHub,对于新的提交,它会开始构建。为了测试它是否按预期工作,您可以提交并推送任何内容到 GitHub 存储库,然后查看构建是否开始。

我们使用神秘的* * * * *作为pollSCM的参数。它指定 Jenkins 应该多久检查新的源更改,并以 cron 样式字符串格式表示。

cron 字符串格式在en.wikipedia.org/wiki/Cron中描述(与 cron 工具一起)。

计划构建

计划触发意味着 Jenkins 定期运行构建,无论存储库是否有任何提交。

如下图所示,不需要与任何系统进行通信:

计划构建的实现与轮询 SCM 完全相同。唯一的区别是使用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关键字;然而,还有不同的选项:

  • 始终:无论完成状态如何都执行

  • 更改:仅在流水线更改其状态时执行

  • 失败:仅在流水线处于失败状态时执行

  • 成功:仅在流水线处于成功状态时执行

  • 不稳定:仅在流水线处于不稳定状态时执行(通常是由测试失败或代码违规引起的)

群聊

如果群聊(例如 Slack 或 HipChat)是团队中的第一种沟通方式,那么考虑在那里添加自动构建通知是值得的。无论使用哪种工具,配置的过程始终是相同的:

  1. 查找并安装群聊工具的插件(例如Slack 通知插件)。

  2. 配置插件(服务器 URL、频道、授权令牌等)。

  3. 将发送指令添加到流水线中。

让我们看一个 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 订阅、短信通知、移动应用程序、桌面通知器等。

团队开发策略

我们已经描述了持续集成管道应该是什么样子的一切。但是,它应该在什么时候运行?当然,它是在提交到存储库后触发的,但是提交到哪个分支?只提交到主干还是每个分支都提交?或者它应该在提交之前而不是之后运行,以便存储库始终保持健康?或者,怎么样采用没有分支的疯狂想法?

对于这些问题并没有单一的最佳答案。实际上,您使用持续集成过程的方式取决于团队的开发工作流程。因此,在我们继续之前,让我们描述一下可能的工作流程是什么。

开发工作流程

开发工作流程是您的团队将代码放入存储库的方式。当然,这取决于许多因素,如源代码控制管理工具、项目特定性或团队规模。

因此,每个团队以稍微不同的方式开发代码。但是,我们可以将它们分类为三种类型:基于主干的工作流程、分支工作流程和分叉工作流程。

所有工作流程都在www.atlassian.com/git/tutorials/comparing-workflows上详细描述,并附有示例。

基于主干的工作流程

基于主干的工作流程是最简单的策略。其概述如下图所示:

有一个中央存储库,所有对项目的更改都有一个单一入口,称为主干或主要。团队的每个成员都克隆中央存储库,以拥有自己的本地副本。更改直接提交到中央存储库。

分支工作流

分支工作流,顾名思义,意味着代码被保存在许多不同的分支中。这个想法如下图所示:

当开发人员开始开发新功能时,他们从主干创建一个专用分支,并在那里提交所有与功能相关的更改。这使得多个开发人员可以轻松地在不破坏主代码库的情况下开发功能。这就是为什么在分支工作流的情况下,保持主干健康是没有问题的。当功能完成时,开发人员会从主干重新设置功能分支,并创建一个包含所有与功能相关代码更改的拉取请求。这会打开代码审查讨论,并留出空间来检查更改是否不会影响主干。当代码被其他开发人员和自动系统检查接受后,它就会合并到主代码库中。然后,在主干上再次运行构建,但几乎不应该失败,因为它在分支上没有失败。

分叉工作流

分叉工作流在开源社区中非常受欢迎。其思想如下图所示:

每个开发人员都有自己的服务器端存储库。它们可能是官方存储库,也可能不是,但从技术上讲,每个存储库都是完全相同的。

分叉字面上意味着从其他存储库创建一个新存储库。开发人员将代码推送到自己的存储库,当他们想要集成代码时,他们会创建一个拉取请求到其他存储库。

分支工作流的主要优势在于集成不一定通过中央存储库。它还有助于所有权,因为它允许接受他人的拉取请求,而不给予他们写入权限。

在面向需求的商业项目中,团队通常只开发一个产品,因此有一个中央存储库,因此这个模型归结为分支工作流,具有良好的所有权分配,例如,只有项目负责人可以将拉取请求合并到中央存储库中。

采用持续集成

我们描述了不同的开发工作流程,但它们如何影响持续集成配置呢?

分支策略

每种开发工作流程都意味着不同的持续集成方法:

  • 基于主干的工作流程:意味着不断与破损的管道作斗争。如果每个人都提交到主代码库,那么管道经常会失败。在这种情况下,旧的持续集成规则是:“如果构建失败,开发团队立即停止正在做的事情并立即解决问题”。

  • 分支工作流程:解决了破损主干的问题,但引入了另一个问题:如果每个人都在自己的分支上开发,那么集成在哪里?一个功能通常需要几周甚至几个月的时间来开发,而在这段时间内,分支没有集成到主代码中,因此不能真正称为“持续”集成;更不用说不断需要合并和解决冲突。

  • 分叉工作流程:意味着每个存储库所有者管理持续集成过程,这通常不是问题。然而,它与分支工作流程存在相同的问题。

没有银弹,不同的组织选择不同的策略。最接近完美的解决方案是使用分支工作流程的技术和基于主干工作流程的哲学。换句话说,我们可以创建非常小的分支,并经常将它们集成到主分支中。这似乎兼具两者的优点,但要求要么有微小的功能,要么使用功能切换。由于功能切换的概念非常适合持续集成和持续交付,让我们花点时间来探讨一下。

功能切换

功能切换是一种替代维护多个源代码分支的技术,以便在功能完成并准备发布之前进行测试。它用于禁用用户的功能,但在测试时为开发人员启用。功能切换本质上是在条件语句中使用的变量。

功能切换的最简单实现是标志和 if 语句。使用功能切换进行开发,而不是使用功能分支开发,看起来如下:

  1. 必须实现一个新功能。

  2. 创建一个新的标志或配置属性feature_toggle(而不是feature分支)。

  3. 每个与功能相关的代码都添加到if语句中(而不是提交到feature分支),例如:

        if (feature_toggle) {
             // do something
        }
  1. 在功能开发期间:
  • 使用feature_toggle = true在主分支上进行编码(而不是在功能分支上进行编码)

  • 从主分支进行发布,使用feature_toggle = false

  1. 当功能开发完成时,所有if语句都被移除,并且从配置中移除了feature_toggle(而不是将feature合并到主分支并删除feature分支)。

功能切换的好处在于所有开发都是在“主干”上进行的,这样可以实现真正的持续集成,并减轻合并代码的问题。

Jenkins 多分支

如果您决定以任何形式使用分支,长期功能分支或推荐的短期分支,那么在将其合并到主分支之前知道代码是否健康是很方便的。这种方法可以确保主代码库始终保持绿色,幸运的是,使用 Jenkins 可以很容易地实现这一点。

为了在我们的计算器项目中使用多分支,让我们按照以下步骤进行:

  1. 打开主 Jenkins 页面。

  2. 点击“新建项目”。

  3. 输入calculator-branches作为项目名称,选择多分支管道,然后点击“确定”。

  4. 在分支来源部分,点击“添加来源”,然后选择 Git。

  5. 将存储库地址输入到项目存储库中。

  1. 如果没有其他运行,则设置 1 分钟为间隔,然后勾选“定期运行”。

  2. 点击“保存”。

每分钟,此配置会检查是否有任何分支被添加(或删除),并创建(或删除)由 Jenkinsfile 定义的专用管道。

我们可以创建一个新的分支并看看它是如何工作的。让我们创建一个名为feature的新分支并将其push到存储库中:

$ git checkout -b feature
$ git push origin feature

一会儿之后,您应该会看到一个新的分支管道被自动创建并运行:

现在,在将功能分支合并到主分支之前,我们可以检查它是否是绿色的。这种方法不应该破坏主构建。

在 GitHub 的情况下,有一种更好的方法,使用“GitHub 组织文件夹”插件。它会自动为所有项目创建具有分支和拉取请求的管道。

一个非常类似的方法是为每个拉取请求构建一个管道,而不是为每个分支构建一个管道,这会产生相同的结果;主代码库始终保持健康。

非技术要求

最后但同样重要的是,持续集成并不全是关于技术。相反,技术排在第二位。詹姆斯·肖尔在他的文章《每日一美元的持续集成》中描述了如何在没有任何额外软件的情况下设置持续集成过程。他所使用的只是一个橡皮鸡和一个铃铛。这个想法是让团队在一个房间里工作,并在一个空椅子上设置一台独立的计算机。把橡皮鸡和铃铛放在那台计算机前。现在,当你计划签入代码时,拿起橡皮鸡,签入代码,去空的计算机,检出最新的代码,在那里运行所有的测试,如果一切顺利,放回橡皮鸡并敲响铃铛,这样每个人都知道有东西被添加到了代码库。

《每日一美元的持续集成》是由詹姆斯·肖尔(James Shore)撰写的,可以在以下网址找到:www.jamesshore.com/Blog/Continuous-Integration-on-a-Dollar-a-Day.html

这个想法有点过于简化,自动化工具很有用;然而,主要信息是,没有每个团队成员的参与,即使是最好的工具也无济于事。杰兹·汉布尔(Jez Humble)在他的著作《持续交付》中提到了持续集成的先决条件,可以用以下几点重新表述:

  • 定期签入:引用迈克·罗伯茨的话,“连续性比你想象的更频繁”,最少每天一次。

  • 创建全面的单元测试:不仅仅是高测试覆盖率,可能没有断言但仍保持 100%的覆盖率。

  • 保持流程迅速:持续集成必须需要很短的时间,最好在 5 分钟以内。10 分钟已经很长了。

  • 监控构建:这可以是一个共同的责任,或者你可以适应每周轮换的构建主管角色。

练习

你已经学到了如何配置持续集成过程。由于“熟能生巧”,我们建议进行以下练习:

  1. 创建一个 Python 程序,用作命令行参数传递的两个数字相乘。添加单元测试并将项目发布到 GitHub 上:
  1. 为 Python 计算器项目构建持续集成流水线:
  • 使用 Jenkinsfile 指定管道

  • 配置触发器,以便在存储库有任何提交时自动运行管道

  • 管道不需要“编译”步骤,因为 Python 是一种可解释语言

  • 运行管道并观察结果

  • 尝试提交破坏管道每个阶段的代码,并观察它在 Jenkins 中的可视化效果

总结

在本章中,我们涵盖了持续集成管道的所有方面,这总是持续交付的第一步。本章的关键要点:

  • 管道提供了组织任何自动化流程的一般机制;然而,最常见的用例是持续集成和持续交付

  • Jenkins 接受不同的管道定义方式,但推荐的是声明性语法

  • 提交管道是最基本的持续集成过程,正如其名称所示,它应该在每次提交到存储库后运行

  • 管道定义应存储在存储库中作为 Jenkinsfile

  • 提交管道可以通过代码质量阶段进行扩展

  • 无论项目构建工具如何,Jenkins 命令应始终与本地开发命令保持一致

  • Jenkins 提供了广泛的触发器和通知

  • 团队或组织内部应谨慎选择开发工作流程,因为它会影响持续集成过程,并定义代码开发的方式

在下一章中,我们将专注于持续交付过程的下一个阶段,自动接受测试。它可以被认为是最重要的,而且在许多情况下,是最难实现的步骤。我们将探讨接受测试的概念,并使用 Docker 进行示例实现。

第五章:自动验收测试

我们已经配置了持续交付过程的提交阶段,现在是时候解决验收测试阶段了,这通常是最具挑战性的部分。通过逐渐扩展流水线,我们将看到验收测试自动化的不同方面。

本章涵盖以下内容:

  • 介绍验收测试过程及其困难

  • 解释工件存储库的概念

  • 在 Docker Hub 上创建 Docker 注册表

  • 安装和保护私有 Docker 注册表

  • 在 Jenkins 流水线中实施验收测试

  • 介绍和探索 Docker Compose

  • 在验收测试过程中使用 Docker Compose

  • 与用户一起编写验收测试

介绍验收测试

验收测试是为了确定业务需求或合同是否得到满足而进行的测试。它涉及对完整系统进行黑盒测试,从用户的角度来看,其积极的结果应意味着软件交付的验收。有时也称为用户验收测试(UAT)、最终用户测试或测试版测试,这是开发过程中软件满足真实世界受众的阶段。

许多项目依赖于由质量保证人员或用户执行的手动步骤来验证功能和非功能要求,但是,以编程可重复操作的方式运行它们要合理得多。

然而,自动验收测试可能被认为是困难的,因为它们具有特定的特点:

  • 面向用户:它们需要与用户一起编写,这需要技术和非技术两个世界之间的理解。

  • 依赖集成:被测试的应用程序应该与其依赖一起运行,以检查整个系统是否正常工作。

  • 环境身份:暂存(测试)和生产环境应该是相同的,以确保在生产环境中运行时,应用程序也能如预期般运行。

  • 应用程序身份:应用程序应该只构建一次,并且相同的二进制文件应该被传输到生产环境。这保证了在测试和发布之间没有代码更改,并消除了不同构建环境的风险。

  • 相关性和后果:如果验收测试通过,应该清楚地表明应用程序从用户角度来看已经准备好发布。

我们在本章的不同部分解决了所有这些困难。通过仅构建一次 Docker 镜像并使用 Docker 注册表进行存储和版本控制,可以实现应用程序身份。Docker Compose 有助于集成依赖项,提供了一种构建一组容器化应用程序共同工作的方式。在“编写验收测试”部分解释了以用户为中心创建测试的方法,而环境身份则由 Docker 工具本身解决,并且还可以通过下一章描述的其他工具进行改进。关于相关性和后果,唯一的好答案是要记住验收测试必须始终具有高质量。

验收测试可能有多重含义;在本书中,我们将验收测试视为从用户角度进行的完整集成测试,不包括性能、负载和恢复等非功能性测试。

Docker 注册表

Docker 注册表是用于存储 Docker 镜像的存储库。确切地说,它是一个无状态的服务器应用程序,允许在需要时发布(推送)和检索(拉取)镜像。我们已经在运行官方 Docker 镜像时看到了注册表的示例,比如jenkins。我们从 Docker Hub 拉取了这些镜像,这是一个官方的基于云的 Docker 注册表。使用单独的服务器来存储、加载和搜索软件包是一个更一般的概念,称为软件存储库,甚至更一般的是构件存储库。让我们更仔细地看看这个想法。

构件存储库

虽然源代码管理存储源代码,但构件存储库专门用于存储软件二进制构件,例如编译后的库或组件,以后用于构建完整的应用程序。为什么我们需要使用单独的工具在单独的服务器上存储二进制文件?

  • 文件大小:构件文件可能很大,因此系统需要针对它们的下载和上传进行优化。

  • 版本:每个上传的构件都需要有一个版本,这样可以方便浏览和使用。然而,并不是所有的版本都需要永久存储;例如,如果发现了一个 bug,我们可能对相关的构件不感兴趣并将其删除。

  • 修订映射:每个构件应该指向源代码的一个确切修订版本,而且二进制创建过程应该是可重复的。

  • :构件以编译和压缩的形式存储,因此这些耗时的步骤不需要重复进行。

  • 访问控制:用户可以以不同方式限制对源代码和构件二进制文件的访问。

  • 客户端:构件存储库的用户可以是团队或组织外的开发人员,他们希望通过其公共 API 使用库。

  • 用例:构件二进制文件用于保证部署到每个环境的确切相同的构建版本,以便在失败情况下简化回滚过程。

最受欢迎的构件存储库是 JFrog Artifactory 和 Sonatype Nexus。

构件存储库在持续交付过程中扮演着特殊的角色,因为它保证了相同的二进制文件在所有流水线步骤中被使用。

让我们看一下下面的图,展示了它是如何工作的:

开发人员将更改推送到源代码存储库,这会触发流水线构建。作为提交阶段的最后一步,会创建一个二进制文件并存储在构件存储库中。之后,在交付过程的所有其他阶段中,都会拉取并使用相同的二进制文件。

构建的二进制文件通常被称为发布候选版本,将二进制文件移动到下一个阶段的过程称为提升

根据编程语言和技术的不同,二进制格式可能会有所不同。

例如,在 Java 的情况下,通常会存储 JAR 文件,在 Ruby 的情况下会存储 gem 文件。我们使用 Docker,因此我们将 Docker 镜像存储为构件,并且用于存储 Docker 镜像的工具称为 Docker 注册表。

一些团队同时维护两个存储库,一个是用于 JAR 文件的构件存储库,另一个是用于 Docker 镜像的 Docker 注册表。虽然在 Docker 引入的第一阶段可能会有用,但没有理由永远维护两者。

安装 Docker 注册表

首先,我们需要安装一个 Docker 注册表。有许多选项可用,但其中两个比其他更常见,一个是基于云的 Docker Hub 注册表,另一个是您自己的私有 Docker 注册表。让我们深入了解一下。

Docker Hub

Docker Hub 是一个提供 Docker 注册表和其他功能的基于云的服务,例如构建镜像、测试它们以及直接从代码存储库中拉取代码。Docker Hub 是云托管的,因此实际上不需要任何安装过程。你需要做的就是创建一个 Docker Hub 账户:

  1. 在浏览器中打开hub.docker.com/

  2. 填写密码、电子邮件地址和 Docker ID。

  3. 收到电子邮件并点击激活链接后,帐户已创建。

Docker Hub 绝对是开始使用的最简单选项,并且允许存储私有和公共图像。

私有 Docker 注册表

Docker Hub 可能并不总是可接受的。对于企业来说,它并不免费,更重要的是,许多公司有政策不在其自己的网络之外存储其软件。在这种情况下,唯一的选择是安装私有 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 需要使用 SSL/TLS 保护注册表。该过程与公共 Web 服务器配置非常相似,并且强烈建议使用 CA(证书颁发机构)签名证书。如果获取 CA 签名的证书不是一个选项,那么我们可以自签名证书或使用--insecure-registry标志。

您可以在docs.docker.com/registry/insecure/#using-self-signed-certificates阅读有关创建和使用自签名证书的信息。

无论证书是由 CA 签名还是自签名,我们都可以将domain.crtdomain.key移动到certs目录并启动注册表。

$ docker run -d -p 5000:5000 --restart=always --name registry -v `pwd`/certs:/certs -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2

在使用自签名证书的情况下,客户端必须明确信任该证书。为了做到这一点,他们可以将domain.crt文件复制到/etc/docker/certs.d/<docker_host_domain>:5000/ca.crt

不建议使用--insecure-registry标志,因为它根本不提供安全性。

添加访问限制

除非我们在一个良好安全的私人网络中使用注册表,否则我们应该配置认证。

这样做的最简单方法是使用registry镜像中的htpasswd工具创建具有密码的用户:

$ mkdir auth
$ docker run --entrypoint htpasswd registry:2 -Bbn <username> <password> > auth/passwords

该命令运行htpasswd工具来创建auth/passwords文件(其中包含一个用户)。然后,我们可以使用一个授权访问它的用户来运行注册表:

$ docker run -d -p 5000:5000 --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/passwords -v `pwd`/certs:/certs -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key registry:2

该命令除了设置证书外,还创建了仅限于auth/passwords文件中指定的用户的访问限制。

因此,在使用注册表之前,客户端需要指定用户名和密码。

--insecure-registry标志的情况下,访问限制不起作用。

其他 Docker 注册表

当涉及基于 Docker 的工件存储库时,Docker Hub 和私有注册表并不是唯一的选择。

其他选项如下:

  • 通用存储库:广泛使用的通用存储库,如 JFrog Artifactory 或 Sonatype Nexus,实现了 Docker 注册表 API。它们的优势在于一个服务器可以存储 Docker 镜像和其他工件(例如 JAR 文件)。这些系统也是成熟的,并提供企业集成。

  • 基于云的注册表:Docker Hub 并不是唯一的云提供商。大多数面向云的服务都在云中提供 Docker 注册表,例如 Google Cloud 或 AWS。

  • 自定义注册表:Docker 注册表 API 是开放的,因此可以实现自定义解决方案。而且,镜像可以导出为文件,因此可以简单地将镜像存储为文件。

使用 Docker 注册表

当注册表配置好后,我们可以展示如何通过三个步骤与其一起工作:

  • 构建镜像

  • 将镜像推送到注册表

  • 从注册表中拉取镜像

构建镜像

让我们使用第二章中的示例,介绍 Docker,并构建一个安装了 Ubuntu 和 Python 解释器的图像。在一个新目录中,我们需要创建一个 Dockerfile:

FROM ubuntu:16.04
RUN apt-get update && \
    apt-get install -y python

现在,我们可以构建图像:

$ docker build -t ubuntu_with_python .

推送图像

为了推送创建的图像,我们需要根据命名约定对其进行标记:

<registry_address>/<image_name>:<tag>

"registry_address"可以是:

  • 在 Docker Hub 的情况下的用户名

  • 私有注册表的域名或 IP 地址和端口(例如,localhost:5000

在大多数情况下,<tag>的形式是图像/应用程序版本。

让我们标记图像以使用 Docker Hub:

$ docker tag ubuntu_with_python leszko/ubuntu_with_python:1

我们也可以在build命令中标记图像:"docker

build -t leszko/ubuntu_with_python:1 . ".

如果存储库配置了访问限制,我们需要首先授权它:

$ docker login --username <username> --password <password>

可以使用docker login命令而不带参数,并且 Docker 会交互式地要求用户名和密码。

现在,我们可以使用push命令将图像存储在注册表中:

$ docker push leszko/ubuntu_with_python:1

请注意,无需指定注册表地址,因为 Docker 使用命名约定来解析它。图像已存储,我们可以使用 Docker Hub Web 界面进行检查,该界面可在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存储库更改为公共。

我们可以使用docker images命令确认图像已经恢复。

当我们配置了注册表并了解了它的工作原理后,我们可以看到如何在持续交付流水线中使用它并构建验收测试阶段。

流水线中的验收测试

我们已经理解了验收测试的概念,并知道如何配置 Docker 注册表,因此我们已经准备好在 Jenkins 流水线中进行第一次实现。

让我们看一下呈现我们将使用的过程的图表:

该过程如下:

  1. 开发人员将代码更改推送到 GitHub。

  2. Jenkins 检测到更改,触发构建并检出当前代码。

  3. Jenkins 执行提交阶段并构建 Docker 图像。

  4. Jenkins 将图像推送到 Docker 注册表。

  5. Jenkins 在暂存环境中运行 Docker 容器。

  6. 部署 Docker 主机需要从 Docker 注册表中拉取镜像。

  7. Jenkins 对运行在暂存环境中的应用程序运行验收测试套件。

为了简单起见,我们将在本地运行 Docker 容器(而不是在单独的暂存服务器上)。为了远程运行它,我们需要使用-H选项或配置DOCKER_HOST环境变量。我们将在下一章中介绍这部分内容。

让我们继续上一章开始的流水线,并添加三个更多的阶段:

  • Docker 构建

  • Docker push

  • 验收测试

请记住,您需要在 Jenkins 执行器(代理从属节点或主节点,在无从属节点配置的情况下)上安装 Docker 工具,以便它能够构建 Docker 镜像。

如果您使用动态配置的 Docker 从属节点,那么目前还没有提供成熟的 Docker 镜像。您可以自行构建,或者使用leszko/jenkins-docker-slave镜像。您还需要在 Docker 代理配置中标记privileged选项。然而,这种解决方案有一些缺点,因此在生产环境中使用之前,请阅读jpetazzo.github.io/2015/09/03/do-not-use-docker-in-docker-for-ci/

Docker 构建阶段

我们希望将计算器项目作为 Docker 容器运行,因此我们需要创建 Dockerfile,并在 Jenkinsfile 中添加"Docker 构建"阶段。

添加 Dockerfile

让我们在计算器项目的根目录中创建 Dockerfile:

FROM frolvlad/alpine-oraclejdk8:slim
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 自动使用 Maven 风格的版本0.0.1-SNAPSHOT对应用程序进行了版本化。

Dockerfile 使用包含 JDK 8 的基础镜像(frolvlad/alpine-oraclejdk8:slim)。它还复制应用程序 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 构建添加到流水线

我们需要的最后一步是在 Jenkinsfile 中添加“Docker 构建”阶段。通常,JAR 打包也被声明为一个单独的Package阶段:

stage("Package") {
     steps {
          sh "./gradlew build"
     }
}

stage("Docker build") {
     steps {
          sh "docker build -t leszko/calculator ."
     }
}

我们没有明确为镜像版本,但每个镜像都有一个唯一的哈希 ID。我们将在下一章中介绍明确的版本控制。

请注意,我们在镜像标签中使用了 Docker 注册表名称。没有必要将镜像标记两次为“calculator”和leszko/calculator

当我们提交并推送 Jenkinsfile 时,流水线构建应该会自动开始,我们应该看到所有的方框都是绿色的。这意味着 Docker 镜像已经成功构建。

还有一个适用于 Docker 的 Gradle 插件,允许在 Gradle 脚本中执行 Docker 操作。您可以在以下链接中看到一个示例:spring.io/guides/gs/spring-boot-docker/

Docker push 阶段

当镜像准备好后,我们可以将其存储在注册表中。Docker push阶段非常简单。只需在 Jenkinsfile 中添加以下代码即可:

stage("Docker push") {
     steps {
          sh "docker push leszko/calculator"
     }
}

如果 Docker 注册表受到访问限制,那么首先我们需要使用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,并在停止时自动删除。

向流水线添加一个验收测试

验收测试通常需要运行一个专门的黑盒测试套件,检查系统的行为。我们将在“编写验收测试”部分进行介绍。目前,为了简单起见,让我们通过使用curl工具调用 Web 服务端点并使用test命令检查结果来执行验收测试。

在项目的根目录中,让我们创建acceptance_test.sh文件:

#!/bin/bash
test $(curl localhost:8765/sum?a=1\&b=2) -eq 3

我们使用参数a=1b=2调用sum端点,并期望收到3的响应。

然后,Acceptance test阶段可以如下所示:

stage("Acceptance test") {
     steps {
          sleep 60
          sh "./acceptance_test.sh"
     }
}

由于docker run -d命令是异步的,我们需要使用sleep操作来确保服务已经在运行。

没有好的方法来检查服务是否已经在运行。睡眠的替代方法可能是一个脚本,每秒检查服务是否已经启动。

添加一个清理阶段环境

作为验收测试的最后一步,我们可以添加分段环境清理。这样做的最佳位置是在post部分,以确保即使失败也会执行:

post {
     always {
          sh "docker stop calculator"
     }
}

这个声明确保calculator容器不再在 Docker 主机上运行。

Docker Compose

没有依赖关系的生活是轻松的。然而,在现实生活中,几乎每个应用程序都链接到数据库、缓存、消息系统或另一个应用程序。在(微)服务架构的情况下,每个服务都需要一堆其他服务来完成其工作。单片架构并没有消除这个问题,一个应用程序通常至少有一些依赖,至少是数据库。

想象一位新人加入你的开发团队;设置整个开发环境并运行带有所有依赖项的应用程序需要多长时间?

当涉及到自动化验收测试时,依赖问题不再仅仅是便利的问题,而是变成了必要性。虽然在单元测试期间,我们可以模拟依赖关系,但验收测试套件需要一个完整的环境。我们如何快速设置并以可重复的方式进行?幸运的是,Docker Compose 是一个可以帮助的工具。

什么是 Docker Compose?

Docker Compose 是一个用于定义、运行和管理多容器 Docker 应用程序的工具。服务在配置文件(YAML 格式)中定义,并可以使用单个命令一起创建和运行。

Docker Compose 使用标准的 Docker 机制来编排容器,并提供了一种方便的方式来指定整个环境。

Docker Compose 具有许多功能,最有趣的是:

  • 构建一组服务

  • 一起启动一组服务

  • 管理单个服务的状态

  • 在运行之间保留卷数据

  • 扩展服务的规模

  • 显示单个服务的日志

  • 在运行之间缓存配置和重新创建更改的容器

有关 Docker Compose 及其功能的详细描述,请参阅官方页面:docs.docker.com/compose/

我们从安装过程开始介绍 Docker Compose 工具,然后介绍 docker-compose.yml 配置文件和docker-compose命令,最后介绍构建和扩展功能。

安装 Docker Compose

安装 Docker Compose 的最简单方法是使用 pip 软件包管理器:

您可以在pip.pypa.io/en/stable/installing/找到 pip 工具的安装指南,或者在 Ubuntu 上使用sudo apt-get install python-pip

$ pip install docker-compose

要检查 Docker Compose 是否已安装,我们可以运行:

$ docker-compose --version

所有操作系统的安装指南都可以在docs.docker.com/compose/install/找到。

定义 docker-compose.yml

docker-compose.yml文件用于定义容器的配置、它们之间的关系和运行时属性。

换句话说,当 Dockerfile 指定如何创建单个 Docker 镜像时,docker-compose.yml指定了如何在 Docker 镜像之外设置整个环境。

docker-compose.yml文件格式有三个版本。在本书中,我们使用的是最新和推荐的版本 3。更多信息请阅读:docs.docker.com/compose/compose-file/compose-versioning/

docker-compose.yml文件具有许多功能,所有这些功能都可以在官方页面找到:docs.docker.com/compose/compose-file/。我们将在持续交付过程的上下文中介绍最重要的功能。

让我们从一个例子开始,假设我们的计算器项目使用 Redis 服务器进行缓存。在这种情况下,我们需要一个包含两个容器calculatorredis的环境。在一个新目录中,让我们创建docker-compose.yml文件。

version: "3"
services:
     calculator:
          image: calculator:latest
          ports:
               - 8080
     redis:
          image: redis:latest

环境配置如下图所示:

让我们来看看这两个容器的定义:

  • redis:来自官方 Docker Hub 最新版本的redis镜像的容器。

  • calculator:来自本地构建的calculator镜像的最新版本的容器。它将8080端口发布到 Docker 主机(这是docker命令的-p选项的替代)。该容器链接到redis容器,这意味着它们共享相同的网络,redis IP 地址在calculator容器内部的redis主机名下可见。

如果我们希望通过不同的主机名来访问服务(例如,通过 redis-cache 而不是 redis),那么我们可以使用链接关键字创建别名。

使用 docker-compose 命令

docker-compose命令读取定义文件并创建环境:

$ docker-compose up -d

该命令在后台启动了两个容器,calculatorredis(使用-d选项)。我们可以检查容器是否在运行:

$ docker-compose ps
 Name                   Command            State          Ports 
---------------------------------------------------------------------------
project_calculator_1   java -jar app.jar    Up     0.0.0.0:8080->8080/tcp
project_redis_1        docker-entrypoint.sh redis ... Up 6379/tcp

容器名称以项目名称project为前缀,该名称取自放置docker-compose.yml文件的目录的名称。我们可以使用-p <project_name>选项手动指定项目名称。由于 Docker Compose 是在 Docker 之上运行的,我们也可以使用docker命令来确认容器是否在运行:

$ docker ps
CONTAINER ID  IMAGE             COMMAND                 PORTS
360518e46bd3  calculator:latest "java -jar app.jar"     0.0.0.0:8080->8080/tcp 
2268b9f1e14b  redis:latest      "docker-entrypoint..."  6379/tcp

完成后,我们可以拆除环境:

$ docker-compose down

这个例子非常简单,但这个工具本身非常强大。通过简短的配置和一堆命令,我们可以控制所有服务的编排。在我们将 Docker Compose 用于验收测试之前,让我们看看另外两个 Docker Compose 的特性:构建镜像和扩展容器。

构建镜像

在前面的例子中,我们首先使用docker build命令构建了calculator镜像,然后可以在 docker-compose.yml 中指定它。还有另一种方法让 Docker Compose 构建镜像。在这种情况下,我们需要在配置中指定build属性而不是image

让我们把docker-compose.yml文件放在计算器项目的目录中。当 Dockerfile 和 Docker Compose 配置在同一个目录中时,前者可以如下所示:

version: "3"
services:
     calculator:
          build: .
          ports:
               - 8080
     redis:
          image: redis:latest

docker-compose build命令构建镜像。我们还可以要求 Docker Compose 在运行容器之前构建镜像,使用docker-compose --build up命令。

扩展服务

Docker Compose 提供了自动创建多个相同容器实例的功能。我们可以在docker-compose.yml中指定replicas: <number>参数,也可以使用docker-compose scale命令。

例如,让我们再次运行环境并复制calculator容器:

$ docker-compose up -d
$ docker-compose scale calculator=5

我们可以检查正在运行的容器:

$ docker-compose ps
 Name                     Command             State Ports 
---------------------------------------------------------------------------
calculator_calculator_1   java -jar app.jar   Up   0.0.0.0:32777->8080/tcp
calculator_calculator_2   java -jar app.jar   Up   0.0.0.0:32778->8080/tcp
calculator_calculator_3   java -jar app.jar   Up   0.0.0.0:32779->8080/tcp
calculator_calculator_4   java -jar app.jar   Up   0.0.0.0:32781->8080/tcp
calculator_calculator_5   java -jar app.jar   Up   0.0.0.0:32780->8080/tcp
calculator_redis_1        docker-entrypoint.sh redis ... Up 6379/tcp

五个calculator容器完全相同,除了容器 ID、容器名称和发布端口号。

它们都使用相同的 Redis 容器实例。现在我们可以停止并删除所有容器:

$ docker-compose down

扩展容器是 Docker Compose 最令人印象深刻的功能之一。通过一个命令,我们可以扩展克隆实例的数量。Docker Compose 负责清理不再使用的容器。

我们已经看到了 Docker Compose 工具最有趣的功能。

在接下来的部分,我们将重点介绍如何在自动验收测试的情境中使用它。

使用 Docker Compose 进行验收测试

Docker Compose 非常适合验收测试流程,因为它可以通过一个命令设置整个环境。更重要的是,在测试完成后,也可以通过一个命令清理环境。如果我们决定在生产环境中使用 Docker Compose,那么另一个好处是验收测试使用的配置、工具和命令与发布的应用程序完全相同。

要了解如何在 Jenkins 验收测试阶段应用 Docker Compose,让我们继续计算器项目示例,并将基于 Redis 的缓存添加到应用程序中。然后,我们将看到两种不同的方法来运行验收测试:先 Jenkins 方法和先 Docker 方法。

使用多容器环境

Docker Compose 提供了容器之间的依赖关系;换句话说,它将一个容器链接到另一个容器。从技术上讲,这意味着容器共享相同的网络,并且一个容器可以从另一个容器中看到。为了继续我们的示例,我们需要在代码中添加这个依赖关系,我们将在几个步骤中完成。

向 Gradle 添加 Redis 客户端库

build.gradle文件中,在dependencies部分添加以下配置:

compile "org.springframework.data:spring-data-redis:1.8.0.RELEASE"
compile "redis.clients:jedis:2.9.0"

它添加了负责与 Redis 通信的 Java 库。

添加 Redis 缓存配置

添加一个新文件src/main/java/com/leszko/calculator/CacheConfig.java

package com.leszko.calculator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;

/** Cache config. */
@Configuration
@EnableCaching
public class CacheConfig extends CachingConfigurerSupport {
    private static final String REDIS_ADDRESS = "redis";

    @Bean
    public JedisConnectionFactory redisConnectionFactory() {
        JedisConnectionFactory redisConnectionFactory = new
          JedisConnectionFactory();
        redisConnectionFactory.setHostName(REDIS_ADDRESS);
        redisConnectionFactory.setPort(6379);
        return redisConnectionFactory;
    }

    @Bean
    public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory cf) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<String, 
          String>();
        redisTemplate.setConnectionFactory(cf);
        return redisTemplate;
    }

    @Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
        return new RedisCacheManager(redisTemplate);
    }
}

这是一个标准的 Spring 缓存配置。请注意,对于 Redis 服务器地址,我们使用redis主机名,这是由于 Docker Compose 链接机制自动可用。

添加 Spring Boot 缓存

当缓存配置好后,我们最终可以将缓存添加到我们的网络服务中。为了做到这一点,我们需要更改src/main/java/com/leszko/calculator/Calculator.java文件如下:

package com.leszko.calculator;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

/** Calculator logic */
@Service
public class Calculator {
    @Cacheable("sum")
    public int sum(int a, int b) {
        return a + b;
    }
}

从现在开始,求和计算将被缓存在 Redis 中,当我们调用calculator网络服务的/sum端点时,它将首先尝试从缓存中检索结果。

检查缓存环境

假设我们的 docker-compose.yml 在计算器项目的目录中,我们现在可以启动容器了:

$ ./gradlew clean build
$ docker-compose up --build -d

我们可以检查计算器服务发布的端口:

$ docker-compose port calculator 8080
0.0.0.0:32783

如果我们在localhost:32783/sum?a=1&b=2上打开浏览器,计算器服务应该回复3,同时访问redis服务并将缓存值存储在那里。为了查看缓存值是否真的存储在 Redis 中,我们可以访问redis容器并查看 Redis 数据库内部:

$ docker-compose exec redis redis-cli

127.0.0.1:6379> keys *
1) "\xac\xed\x00\x05sr\x00/org.springframework.cache.interceptor.SimpleKeyL\nW\x03km\x93\xd8\x02\x00\x02I\x00\bhashCode\x00\x06paramst\x00\x13[Ljava/lang/Object;xp\x00\x00\x03\xe2ur\x00\x13[Ljava.lang.Object;\x90\xceX\x9f\x10s)l\x02\x00\x00xp\x00\x00\x00\x02sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x00\x01sq\x00~\x00\x05\x00\x00\x00\x02"
2) "sum~keys"

docker-compose exec命令在redis容器内执行了redis-cli(Redis 客户端以浏览其数据库内容)命令。然后,我们可以运行keys *来打印 Redis 中存储的所有内容。

您可以通过计算器应用程序进行更多操作,并使用不同的值在浏览器中打开,以查看 Redis 服务内容增加。之后,重要的是使用docker-compose down命令拆除环境。

在接下来的章节中,我们将看到多容器项目的两种验收测试方法。显然,在 Jenkins 上采取任何行动之前,我们需要提交并推送所有更改的文件(包括docker-compose.yml)到 GitHub。

请注意,对于进一步的步骤,Jenkins 执行器上必须安装 Docker Compose。

方法 1 - 首先进行 Jenkins 验收测试

第一种方法是以与单容器应用程序相同的方式执行验收测试。唯一的区别是现在我们有两个容器正在运行,如下图所示:

从用户角度来看,redis容器是不可见的,因此单容器和多容器验收测试之间唯一的区别是我们使用docker-compose up命令而不是docker run

其他 Docker 命令也可以用它们的 Docker Compose 等效命令替换:docker-compose build 替换 docker builddocker-compose push 替换 docker push。然而,如果我们只构建一个镜像,那么保留 Docker 命令也是可以的。

改变暂存部署阶段

让我们改变 部署到暂存 阶段来使用 Docker Compose:

stage("Deploy to staging") {    
     steps {        
          sh "docker-compose up -d"    
}}

我们必须以完全相同的方式改变清理:

post {    
     always {        
          sh "docker-compose down"    
}}

改变验收测试阶段

为了使用 docker-compose scale,我们没有指定我们的 web 服务将发布在哪个端口号下。如果我们这样做了,那么扩展过程将失败,因为所有克隆将尝试在相同的端口号下发布。相反,我们让 Docker 选择端口。因此,我们需要改变 acceptance_test.sh 脚本,首先找出端口号是多少,然后使用正确的端口号运行 curl

#!/bin/bash
CALCULATOR_PORT=$(docker-compose port calculator 8080 | cut -d: -f2)
test $(curl localhost:$CALCULATOR_PORT/sum?a=1\&b=2) -eq 3

让我们找出我们是如何找到端口号的:

  1. docker-compose port calculator 8080 命令检查 web 服务发布在哪个 IP 和端口地址下(例如返回 127.0.0.1:57648)。

  2. cut -d: -f2 选择只有端口(例如,对于 127.0.0.1:57648,它返回 57648)。

我们可以将更改推送到 GitHub 并观察 Jenkins 的结果。这个想法和单容器应用程序的想法是一样的,设置环境,运行验收测试套件,然后拆除环境。尽管这种验收测试方法很好并且运行良好,让我们看看另一种解决方案。

方法 2 – 先 Docker 验收测试

在 Docker-first 方法中,我们创建了一个额外的 test 容器,它从 Docker 主机内部执行测试,如下图所示:

这种方法在检索端口号方面简化了验收测试脚本,并且可以在没有 Jenkins 的情况下轻松运行。它也更符合 Docker 的风格。

缺点是我们需要为测试目的创建一个单独的 Dockerfile 和 Docker Compose 配置。

为验收测试创建一个 Dockerfile

我们将首先为验收测试创建一个单独的 Dockerfile。让我们在计算器项目中创建一个新目录 acceptance 和一个 Dockerfile。

FROM ubuntu:trusty
RUN apt-get update && \
    apt-get install -yq curl
COPY test.sh .
CMD ["bash", "test.sh"]

它创建一个运行验收测试的镜像。

为验收测试创建一个 docker-compose.yml

在同一个目录下,让我们创建 docker-compose-acceptance.yml 来提供测试编排:

version: "3"
services:
    test:
        build: ./acceptance

它创建一个新的容器,链接到被测试的容器:calculator。而且,内部始终是 8080,这就消除了端口查找的麻烦部分。

创建验收测试脚本

最后缺失的部分是测试脚本。在同一目录下,让我们创建代表验收测试的test.sh文件:

#!/bin/bash
sleep 60
test $(curl calculator:8080/sum?a=1\&b=2) -eq 3

它与之前的验收测试脚本非常相似,唯一的区别是我们可以通过calculator主机名来访问计算器服务,端口号始终是8080。此外,在这种情况下,我们在脚本内等待,而不是在 Jenkinsfile 中等待。

运行验收测试

我们可以使用根项目目录下的 Docker Compose 命令在本地运行测试:

$ docker-compose -f docker-compose.yml -f acceptance/docker-compose-acceptance.yml -p acceptance up -d --build

该命令使用两个 Docker Compose 配置来运行acceptance项目。其中一个启动的容器应该被称为acceptance_test_1,并对其结果感兴趣。我们可以使用以下命令检查其日志:

$ docker logs acceptance_test_1
 %   Total %   Received % Xferd Average Speed Time 
 100 1     100 1        0 0     1       0     0:00:01

日志显示curl命令已成功调用。如果我们想要检查测试是成功还是失败,可以检查容器的退出代码:

$ docker wait acceptance_test_1
0

0退出代码表示测试成功。除了0之外的任何代码都意味着测试失败。测试完成后,我们应该像往常一样清理环境:

$ docker-compose -f docker-compose.yml -f acceptance/docker-compose-acceptance.yml -p acceptance down

更改验收测试阶段

最后一步,我们可以将验收测试执行添加到流水线中。让我们用一个新的验收测试阶段替换 Jenkinsfile 中的最后三个阶段:

stage("Acceptance test") {
    steps {
        sh "docker-compose -f docker-compose.yml 
                   -f acceptance/docker-compose-acceptance.yml build test"
        sh "docker-compose -f docker-compose.yml 
                   -f acceptance/docker-compose-acceptance.yml 
                   -p acceptance up -d"
        sh 'test $(docker wait acceptance_test_1) -eq 0'
    }
}

这一次,我们首先构建test服务。不需要构建calculator镜像;它已经在之前的阶段完成了。最后,我们应该清理环境:

post {
    always {
        sh "docker-compose -f docker-compose.yml 
                   -f acceptance/docker-compose-acceptance.yml 
                   -p acceptance down"
    }
}

在 Jenkinsfile 中添加了这个之后,我们就完成了第二种方法。我们可以通过将所有更改推送到 GitHub 来测试这一点。

比较方法 1 和方法 2

总之,让我们比较两种解决方案。第一种方法是从用户角度进行真正的黑盒测试,Jenkins 扮演用户的角色。优点是它非常接近于在生产中将要做的事情;最后,我们将通过其 Docker 主机访问容器。第二种方法是从另一个容器的内部测试应用程序。这种解决方案在某种程度上更加优雅,可以以简单的方式在本地运行;但是,它需要创建更多的文件,并且不像在生产中将来要做的那样通过其 Docker 主机调用应用程序。

在下一节中,我们将远离 Docker 和 Jenkins,更仔细地研究编写验收测试的过程。

编写验收测试

到目前为止,我们使用curl命令执行一系列验收测试。这显然是一个相当简化的过程。从技术上讲,如果我们编写一个 REST Web 服务,那么我们可以将所有黑盒测试写成一个大脚本,其中包含多个curl调用。然而,这种解决方案非常难以阅读、理解和维护。而且,这个脚本对非技术的业务相关用户来说完全无法理解。如何解决这个问题,创建具有良好结构、可读性强的测试,并满足其基本目标:自动检查系统是否符合预期?我将在本节中回答这个问题。

编写面向用户的测试

验收测试是为用户编写的,应该让用户能够理解。这就是为什么编写它们的方法取决于客户是谁。

例如,想象一个纯粹的技术人员。如果你编写了一个优化数据库存储的 Web 服务,而你的系统只被其他系统使用,并且只被其他开发人员读取,那么你的测试可以以与单元测试相同的方式表达。通常情况下,测试是好的,如果开发人员和用户都能理解。

在现实生活中,大多数软件都是为了提供特定的业务价值而编写的,而这个业务价值是由非开发人员定义的。因此,我们需要一种共同的语言来合作。一方面,业务了解需要什么,但不知道如何做;另一方面,开发团队知道如何做,但不知道需要什么。幸运的是,有许多框架可以帮助连接这两个世界,例如 Cucumber、FitNesse、JBehave、Capybara 等等。它们彼此之间有所不同,每一个都可能成为一本单独的书的主题;然而,编写验收测试的一般思想是相同的,并且可以用以下图表来表示:

验收标准由用户(或其代表产品所有者)与开发人员的帮助下编写。它们通常以以下场景的形式编写:

Given I have two numbers: 1 and 2
When the calculator sums them
Then I receive 3 as a result

开发人员编写称为fixtures步骤定义的测试实现,将人性化的 DSL 规范与编程语言集成在一起。因此,我们有了一个可以很好集成到持续交付管道中的自动化测试。

不用说,编写验收测试是一个持续的敏捷过程,而不是瀑布式过程。这需要开发人员和业务方的不断协作,以改进和维护测试规范。

对于具有用户界面的应用程序,直接通过界面执行验收测试可能很诱人(例如,通过记录 Selenium 脚本);然而,如果没有正确执行,这种方法可能导致测试速度慢且与界面层紧密耦合的问题。

让我们看看实践中编写验收测试的样子,以及如何将它们绑定到持续交付管道中。

使用验收测试框架

让我们使用黄瓜框架为计算器项目创建一个验收测试。如前所述,我们将分三步完成这个过程:

  • 创建验收标准

  • 创建步骤定义

  • 运行自动化验收测试

创建验收标准

让我们将业务规范放在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 cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.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);
    }
}

特性规范文件中的每一行(GivenWhenThen)都与 Java 代码中相应的方法匹配。通配符(.*)作为参数传递。请注意,服务器地址作为 Java 属性calculator.url传递。该方法执行以下操作:

  • i_have_two_numbers:将参数保存为字段

  • the_calculator_sums_them:调用远程计算器服务并将结果存储在字段中

  • i_receive_as_a_result:断言结果是否符合预期

运行自动化验收测试

要运行自动化测试,我们需要进行一些配置:

  1. 添加 Java 黄瓜库:在build.gradle文件中,将以下代码添加到dependencies部分:
        testCompile("info.cukes:cucumber-java:1.2.4")
        testCompile("info.cukes:cucumber-junit:1.2.4")
  1. 添加 Gradle 目标:在同一文件中,添加以下代码:
       task acceptanceTest(type: Test) {
            include '**/acceptance/**'
            systemProperties System.getProperties()
       }

       test {
            exclude '**/acceptance/**'
       }

这将测试分为单元测试(使用./gradlew test运行)和验收测试(使用./gradlew acceptanceTest运行)。

  1. 添加 JUnit 运行器:添加一个新文件src/test/java/acceptance/AcceptanceTest.java
        package acceptance;

        import cucumber.api.CucumberOptions;
        import cucumber.api.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:8080

显然,我们可以将此命令添加到我们的acceptance_test.sh中,而不是curl命令。这将使 Cucumber 验收测试在 Jenkins 流水线中运行。

验收测试驱动开发

与持续交付过程的大多数方面一样,验收测试更多地关乎人而不是技术。测试质量当然取决于用户和开发人员的参与,但也取决于测试创建的时间,这可能不太直观。

最后一个问题是,在软件开发生命周期的哪个阶段应准备验收测试?或者换句话说,我们应该在编写代码之前还是之后创建验收测试?

从技术上讲,结果是一样的;代码既有单元测试,也有验收测试覆盖。然而,考虑先编写测试的想法是很诱人的。TDD(测试驱动开发)的理念可以很好地适用于验收测试。如果在编写代码之前编写单元测试,结果代码会更清洁、结构更好。类似地,如果在系统功能之前编写验收测试,结果功能将更符合客户的需求。这个过程,通常称为验收测试驱动开发,如下图所示:

用户与开发人员以人性化的 DSL 格式编写验收标准规范。开发人员编写固定装置,测试失败。然后,使用 TDD 方法进行内部功能开发。功能完成后,验收测试应该通过,这表明功能已完成。

一个非常好的做法是将 Cucumber 功能规范附加到问题跟踪工具(例如 JIRA)中的请求票据上,以便功能总是与其验收测试一起请求。一些开发团队采取了更激进的方法,拒绝在没有准备验收测试的情况下开始开发过程。毕竟,这是有道理的,你怎么能开发客户无法测试的东西呢?

练习

在本章中,我们涵盖了很多新材料,为了更好地理解,我们建议做练习,并创建自己的验收测试项目:

  1. 创建一个基于 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 框架:www.sinatrarb.com/,但您也可以使用 Ruby on Rails)。

  • 书应具有以下属性:名称,作者和 ISBN。

  • Web 服务应具有以下端点:

  • POST/books/以添加书籍

  • GETbooks/<isbn>以检索书籍

  • 数据可以存储在内存中。

  • 最后,检查验收测试是否通过。

  1. 将“book-library”添加为 Docker 注册表中的 Docker 图像:

    • 在 Docker Hub 上创建一个帐户。
  • 为应用程序创建 Dockerfile。

  • 构建 Docker 图像并根据命名约定对其进行标记。

  • 将图像推送到 Docker Hub。

  1. 创建 Jenkins 流水线以构建 Docker 图像,将其推送到 Docker 注册表并执行验收测试:
    • 创建一个“Docker 构建”阶段。
  • 创建“Docker 登录”和“Docker 推送”阶段。

  • 创建一个执行验收测试的“测试”容器,并使用 Docker Compose 执行测试。

  • 在流水线中添加“验收测试”阶段。

  • 运行流水线并观察结果。

摘要

在本章中,您学会了如何构建完整和功能齐全的验收测试阶段,这是持续交付过程的重要组成部分。本章的关键要点:

  • 接受测试可能很难创建,因为它们结合了技术挑战(应用程序依赖关系,环境设置)和个人挑战(开发人员与业务的合作)。

  • 验收测试框架提供了一种以人类友好的语言编写测试的方法,使非技术人员能够理解。

  • Docker 注册表是 Docker 镜像的工件存储库。

  • Docker 注册表与持续交付流程非常匹配,因为它提供了一种在各个阶段和环境中使用完全相同的 Docker 镜像的方式。

  • Docker Compose 编排一组相互交互的 Docker 容器。它还可以构建镜像和扩展容器。

  • Docker Compose 可以帮助在运行一系列验收测试之前设置完整的环境。

  • 验收测试可以编写为 Docker 镜像,Docker Compose 可以运行完整的环境以及测试,并提供结果。

在下一章中,我们将介绍完成持续交付流水线所需的缺失阶段。

第六章:使用 Ansible 进行配置管理

我们已经涵盖了持续交付过程的两个最关键的阶段:提交阶段和自动接受测试。在本章中,我们将专注于配置管理,将虚拟容器化环境与真实服务器基础设施连接起来。

本章涵盖以下要点:

  • 介绍配置管理的概念

  • 解释最流行的配置管理工具

  • 讨论 Ansible 的要求和安装过程

  • 使用 Ansible 进行即时命令

  • 展示 Ansible 自动化的强大力量与 playbooks

  • 解释 Ansible 角色和 Ansible Galaxy

  • 实施部署过程的用例

  • 使用 Ansible 与 Docker 和 Docker Compose 一起

介绍配置管理

配置管理是一种控制配置更改的过程,以使系统随时间保持完整性。即使这个术语并非起源于 IT 行业,但目前它被广泛用来指代软件和硬件。在这个背景下,它涉及以下方面:

  • 应用程序配置:这涉及决定系统如何工作的软件属性,通常以传递给应用程序的标志或属性文件的形式表达,例如数据库地址、文件处理的最大块大小或日志级别。它们可以在不同的开发阶段应用:构建、打包、部署或运行。

  • 基础设施配置:这涉及服务器基础设施和环境配置,负责部署过程。它定义了每台服务器应安装哪些依赖项,并指定了应用程序的编排方式(哪个应用程序在哪个服务器上运行以及有多少个实例)。

举个例子,我们可以想象一个使用 Redis 服务器的计算器 Web 服务。让我们看一下展示配置管理工具如何工作的图表。

配置管理工具读取配置文件并相应地准备环境(安装依赖工具和库,将应用程序部署到多个实例)。

在前面的例子中,基础设施配置指定了计算器服务应该在服务器 1服务器 2上部署两个实例,并且Redis服务应该安装在服务器 3上。计算器应用程序配置指定了Redis服务器的端口和地址,以便服务之间可以通信。

配置可能因环境类型(QA、staging、production)的不同而有所不同,例如,服务器地址可能不同。

配置管理有许多方法,但在我们研究具体解决方案之前,让我们评论一下一个好的配置管理工具应该具备的特征。

良好配置管理的特点

现代配置管理解决方案应该是什么样的?让我们来看看最重要的因素:

  • 自动化:每个环境都应该自动可再现,包括操作系统、网络配置、安装的软件和部署的应用程序。在这种方法中,修复生产问题意味着自动重建环境。更重要的是,这简化了服务器复制,并确保暂存和生产环境完全相同。

  • 版本控制:配置的每个更改都应该被跟踪,这样我们就知道是谁做的,为什么,什么时候。通常,这意味着将配置保存在源代码存储库中,要么与代码一起,要么在一个单独的地方。前者的解决方案是推荐的,因为配置属性的生命周期与应用程序本身不同。版本控制还有助于修复生产问题-配置始终可以回滚到先前的版本,并自动重建环境。唯一的例外是基于版本控制的解决方案是存储凭据和其他敏感信息-这些信息永远不应该被检入。

  • 增量更改:应用配置的更改不应该需要重建整个环境。相反,配置的小改变应该只改变基础设施的相关部分。

  • 服务器配置:通过自动化,添加新服务器应该像将其地址添加到配置(并执行一个命令)一样快。

  • 安全性:对配置管理工具和其控制下的机器的访问应该得到很好的保护。当使用 SSH 协议进行通信时,密钥或凭据的访问需要得到很好的保护。

  • 简单性:团队的每个成员都应该能够阅读配置,进行更改,并将其应用到环境中。属性本身也应尽可能简单,不受更改影响的属性最好保持硬编码。

在创建配置时以及在选择正确的配置管理工具之前,重要的是要牢记这些要点。

配置管理工具概述

最流行的配置管理工具是 Ansible、Puppet 和 Chef。它们每个都是一个不错的选择;它们都是开源产品,有免费的基本版本和付费的企业版本。它们之间最重要的区别是:

  • 配置语言:Chef 使用 Ruby,Puppet 使用其自己的 DSL(基于 Ruby),而 Ansible 使用 YAML。

  • 基于代理:Puppet 和 Chef 使用代理进行通信,这意味着每个受管服务器都需要安装特殊工具。相反,Ansible 是无代理的,使用标准的 SSH 协议进行通信。

无代理的特性是一个重要的优势,因为它意味着不需要在服务器上安装任何东西。此外,Ansible 正在迅速上升,这就是为什么选择它作为本书的原因。然而,其他工具也可以成功地用于持续交付过程。

安装 Ansible

Ansible 是一个开源的、无代理的自动化引擎,用于软件供应、配置管理和应用部署。它于 2012 年首次发布,其基本版本对个人和商业用途都是免费的。企业版称为 Ansible Tower,提供 GUI 管理和仪表板、REST API、基于角色的访问控制等更多功能。

我们介绍了安装过程以及如何单独使用它以及与 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/intro_installation.html

安装过程完成后,我们可以执行 Ansible 命令来检查是否一切都安装成功。

$ ansible --version
ansible 2.3.2.0
    config file = /etc/ansible/ansible.cfg
    configured module search path = Default w/o overrides

基于 Docker 的 Ansible 客户端

还可以将 Ansible 用作 Docker 容器。我们可以通过运行以下命令来实现:

$ docker run williamyeh/ansible:ubuntu14.04
ansible-playbook 2.3.2.0
 config file = /etc/ansible/ansible.cfg
 configured module search path = Default w/o overrides

Ansible Docker 镜像不再得到官方支持,因此唯一的解决方案是使用社区驱动的版本。您可以在 Docker Hub 页面上阅读更多关于其用法的信息。

使用 Ansible

为了使用 Ansible,首先需要定义清单,代表可用资源。然后,我们将能够执行单个命令或使用 Ansible playbook 定义一组任务。

创建清单

清单是由 Ansible 管理的所有服务器的列表。每台服务器只需要安装 Python 解释器和 SSH 服务器。默认情况下,Ansible 假定使用 SSH 密钥进行身份验证;但是,也可以通过在 Ansible 命令中添加--ask-pass选项来使用用户名和密码进行身份验证。

SSH 密钥可以使用ssh-keygen工具生成,并通常存储在~/.ssh目录中。

清单是在/etc/ansible/hosts文件中定义的,它具有以下结构:

[group_name]
<server1_address>
<server2_address>
...

清单语法还接受服务器范围,例如www[01-22].company.com。如果 SSH 端口不是默认的 22 端口,还应该指定。您可以在官方 Ansible 页面上阅读更多信息:docs.ansible.com/ansible/intro_inventory.html

清单文件中可能有 0 个或多个组。例如,让我们在一个服务器组中定义两台机器。

[webservers]
192.168.0.241
192.168.0.242

我们还可以创建带有服务器别名的配置,并指定远程用户:

[webservers]
web1 ansible_host=192.168.0.241 ansible_user=admin
web2 ansible_host=192.168.0.242 ansible_user=admin

前面的文件定义了一个名为webservers的组,其中包括两台服务器。Ansible 客户端将作为用户admin登录到它们两台。当我们创建了清单后,让我们发现如何使用它来在许多服务器上执行相同的命令。

Ansible 提供了从云提供商(例如 Amazon EC2/Eucalyptus)、LDAP 或 Cobbler 动态获取清单的可能性。在docs.ansible.com/ansible/intro_dynamic_inventory.html了解更多关于动态清单的信息。

临时命令

我们可以运行的最简单的命令是对所有服务器进行 ping 测试。

$ ansible all -m ping
web1 | SUCCESS => {
 "changed": false,
 "ping": "pong"
}
web2 | SUCCESS => {
 "changed": false,
 "ping": "pong"
}

我们使用了-m <module_name>选项,允许指定应在远程主机上执行的模块。结果是成功的,这意味着服务器是可达的,并且身份验证已正确配置。

可以在docs.ansible.com/ansible/modules.htm找到 Ansible 可用模块的完整列表。

请注意,我们使用了all,以便可以处理所有服务器,但我们也可以通过组名webservers或单个主机别名来调用它们。作为第二个例子,让我们只在其中一个服务器上执行一个 shell 命令。

$ ansible web1 -a "/bin/echo hello"
web1 | SUCCESS | rc=0 >>
hello

-a <arguments>选项指定传递给 Ansible 模块的参数。在这种情况下,我们没有指定模块,因此参数将作为 shell Unix 命令执行。结果是成功的,并且打印了hello

如果ansible命令第一次连接服务器(或服务器重新安装),那么我们会收到密钥确认消息(当主机不在known_hosts中时的 SSH 消息)。由于这可能会中断自动化脚本,我们可以通过取消注释/etc/ansible/ansible.cfg文件中的host_key_checking = False或设置环境变量ANSIBLE_HOST_KEY_CHECKING=False来禁用提示消息。

在其简单形式中,Ansible 临时命令的语法如下:

ansible <target> -m <module_name> -a <module_arguments>

临时命令的目的是在不必重复时快速执行某些操作。例如,我们可能想要检查服务器是否存活,或者在圣诞假期关闭所有机器。这种机制可以被视为在一组机器上执行命令,并由模块提供的附加语法简化。然而,Ansible 自动化的真正力量在于 playbooks。

Playbooks

Ansible playbook 是一个配置文件,描述了服务器应该如何配置。它提供了一种定义一系列任务的方式,这些任务应该在每台机器上执行。Playbook 使用 YAML 配置语言表示,这使得它易于阅读和理解。让我们从一个示例 playbook 开始,然后看看我们如何使用它。

定义一个 playbook

一个 playbook 由一个或多个 plays 组成。每个 play 包含一个主机组名称,要执行的任务以及配置细节(例如,远程用户名或访问权限)。一个示例 playbook 可能如下所示:

---
- 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

此配置包含一个 play,其中:

  • 仅在主机web1上执行

  • 使用sudo命令获取 root 访问权限

  • 执行两个任务:

  • 安装最新版本的apache2:Ansible 模块apt(使用两个参数name=apache2state=latest)检查服务器上是否安装了apache2软件包,如果没有,则使用apt-get工具安装apache2

  • 运行apache2服务:Ansible 模块service(使用三个参数name=apache2state=startedenabled=yes)检查 Unix 服务apache2是否已启动,如果没有,则使用service命令启动它

在处理主机时,您还可以使用模式,例如,我们可以使用web*来寻址web1web2。您可以在docs.ansible.com/ansible/intro_patterns.html了解更多关于 Ansible 模式的信息。

请注意,每个任务都有一个易于阅读的名称,在控制台输出中使用,例如aptservice是 Ansible 模块,name=apache2state=lateststate=started是模块参数。在使用临时命令时,我们已经看到了 Ansible 模块和参数。在前面的 playbook 中,我们只定义了一个 play,但可以有很多 play,并且每个 play 可以与不同的主机组相关联。

例如,我们可以在清单中定义两组服务器:databasewebservers。然后,在 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 服务器。如何处理这种情况?

例如,假设您将配置文件复制到远程机器,并且只有在配置文件更改时才应重新启动 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 中还有一个发布-订阅机制。使用它意味着将一个主题分配给许多处理程序。然后,一个任务通知主题以执行所有相关的处理程序。您可以在以下网址了解更多信息:docs.ansible.com/ansible/playbooks_intro.html

变量

虽然 Ansible 自动化使多个主机的事物变得相同和可重复,但不可避免地,服务器可能需要一些差异。例如,考虑应用程序端口号。它可能因机器而异。幸运的是,Ansible 提供了变量,这是一个处理服务器差异的良好机制。让我们创建一个新的 playbook 并定义一个变量。

例如,考虑应用程序端口号。它可能因机器而异。幸运的是,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.pocoo.org/

debug模块在执行时打印消息。如果我们运行ansible-playbook命令,就可以看到变量的使用情况。

$ ansible-playbook playbook.yml

...

TASK [print port number] ************************************************
ok: [web1] => {
 "msg": "Port number: 8080"
}  

变量也可以在清单文件中的[group_name:vars]部分中定义。您可以在以下网址了解更多信息:docs.ansible.com/ansible/intro_inventory.html#host-variables

除了用户定义的变量,还有预定义的自动变量。例如,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.0.241  192.168.0.242 "
}

请注意,使用 Jinja2 语言,我们可以在 Ansible 剧本文件中指定流程控制操作。

对于条件和循环,Jinja2 模板语言的替代方案是使用 Ansible 内置关键字:whenwith_items。您可以在以下网址了解更多信息:docs.ansible.com/ansible/playbooks_conditionals.html

角色

我们可以使用 Ansible 剧本在远程服务器上安装任何工具。想象一下,我们想要一个带有 MySQL 的服务器。我们可以轻松地准备一个类似于带有apache2包的 playbook。然而,如果你想一想,带有 MySQL 的服务器是一个相当常见的情况,肯定有人已经为此准备了一个 playbook,所以也许我们可以重用它?这就是 Ansible 角色和 Ansible Galaxy 的用武之地。

理解角色

Ansible 角色是一个精心构建的剧本部分,准备包含在剧本中。角色是独立的单元,始终具有以下目录结构:

templates/
tasks/
handlers/
vars/
defaults/
meta/

您可以在官方 Ansible 页面上阅读有关角色及每个目录含义的更多信息:docs.ansible.com/ansible/playbooks_roles.html

在每个目录中,我们可以定义main.yml文件,其中包含可以包含在playbook.yml文件中的剧本部分。继续 MySQL 案例,GitHub 上定义了一个角色:github.com/geerlingguy/ansible-role-mysql。该存储库包含可以在我们的 playbook 中使用的任务模板。让我们看一下tasks/main.yml文件的一部分,它安装mysql包。

...
- name: Ensure MySQL Python libraries are installed.
  apt: "name=python-mysqldb state=installed"

- name: Ensure MySQL packages are installed.
  apt: "name={{ item }} state=installed"
  with_items: "{{ mysql_packages }}"
  register: deb_mysql_install_packages
...

这只是在tasks/main.yml文件中定义的任务之一。其他任务负责 MySQL 配置。

with_items关键字用于在所有项目上创建循环。when关键字意味着任务仅在特定条件下执行。

如果我们使用这个角色,那么为了在服务器上安装 MySQL,只需创建以下 playbook.yml:

---
- hosts: all
  become: yes
  become_method: sudo
  roles:
  - role: geerlingguy.mysql
    become: yes

这样的配置使用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 文件中后续使用。

如果您需要同时安装许多角色,可以在requirements.yml文件中定义它们,并使用ansible-galaxy install -r requirements.yml。了解更多关于这种方法和 Ansible Galaxy 的信息,请访问:docs.ansible.com/ansible/galaxy.html

使用 Ansible 进行部署

我们已经介绍了 Ansible 的最基本功能。现在,让我们暂时忘记 Docker,使用 Ansible 配置完整的部署步骤。我们将在一个服务器上运行计算器服务,而在第二个服务器上运行 Redis 服务。

安装 Redis

我们可以在新的 playbook 中指定一个 play。让我们创建playbook.yml文件,内容如下:

---
- hosts: web1
  become: yes
  become_method: sudo
  tasks:
  - name: install Redis
    apt:
      name: redis-server
      state: present
  - name: start Redis
    service:
      name: redis-server
      state: started
  - name: copy Redis configuration
    copy:
      src: redis.conf
      dest: /etc/redis/redis.conf
    notify: restart Redis
  handlers:
  - name: restart Redis
    service:
      name: redis-server
      state: restarted

该配置在一个名为web1的服务器上执行。它安装redis-server包,复制 Redis 配置,并启动 Redis。请注意,每次更改redis.conf文件的内容并重新运行ansible-playbook命令时,配置都会更新到服务器上,并且 Redis 服务会重新启动。

我们还需要创建redis.conf文件,内容如下:

daemonize yes
pidfile /var/run/redis/redis-server.pid
port 6379
bind 0.0.0.0

此配置将 Redis 作为守护程序运行,并将其暴露给端口号为 6379 的所有网络接口。现在让我们定义第二个 play,用于设置计算器服务。

部署 Web 服务

我们分三步准备计算器 Web 服务:

  1. 配置项目可执行。

  2. 更改 Redis 主机地址。

  3. 将计算器部署添加到 playbook 中。

配置项目可执行

首先,我们需要使构建的 JAR 文件可执行,以便它可以作为 Unix 服务轻松在服务器上运行。为了做到这一点,只需将以下代码添加到build.gradle文件中:

bootRepackage {
    executable = true
}

更改 Redis 主机地址

以前,我们已将 Redis 主机地址硬编码为redis,所以现在我们应该在src/main/java/com/leszko/calculator/CacheConfig.java文件中将其更改为192.168.0.241

在实际项目中,应用程序属性通常保存在属性文件中。例如,对于 Spring Boot 框架,有一个名为application.propertiesapplication.yml的文件。

将计算器部署添加到 playbook 中

最后,我们可以将部署配置作为playbook.yml文件中的新 play 添加。

- hosts: web2
  become: yes
  become_method: sudo
  tasks:
  - name: ensure Java Runtime Environment is installed
    apt:
      name: default-jre
      state: present
  - name: create directory for Calculator
    file:
      path: /var/calculator
      state: directory
  - name: configure Calculator as a service
    file:
      path: /etc/init.d/calculator
      state: link
      force: yes
      src: /var/calculator/calculator.jar
  - 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

让我们走一遍我们定义的步骤:

  • 准备环境:此任务确保安装了 Java 运行时环境。基本上,它准备了服务器环境,以便计算器应用程序具有所有必要的依赖关系。对于更复杂的应用程序,依赖工具和库的列表可能会更长。

  • 将应用程序配置为服务:我们希望将计算器应用程序作为 Unix 服务运行,以便以标准方式进行管理。在这种情况下,只需在/etc/init.d/目录中创建一个指向我们应用程序的链接即可。

  • 复制新版本:将应用程序的新版本复制到服务器上。请注意,如果源文件没有更改,则文件不会被复制,因此服务不会重新启动。

  • 重新启动服务:作为处理程序,每次复制应用程序的新版本时,服务都会重新启动。

运行部署

与往常一样,我们可以使用ansible-playbook命令执行 playbook。在此之前,我们需要使用 Gradle 构建计算器项目。

$ ./gradlew build
$ ansible-playbook playbook.yml

成功部署后,服务应该可用,并且我们可以在http://192.168.0.242:8080/sum?a=1&b=2上检查它是否正常工作。预期地,它应该返回3作为输出。

请注意,我们通过执行一个命令配置了整个环境。而且,如果我们需要扩展服务,只需将新服务器添加到清单中并重新运行ansible-playbook命令即可。

我们已经展示了如何使用 Ansible 进行环境配置和应用程序部署。下一步是将 Ansible 与 Docker 一起使用。

Ansible 与 Docker

正如您可能已经注意到的,Ansible 和 Docker 解决了类似的软件部署问题:

  • 环境配置:Ansible 和 Docker 都提供了配置环境的方式;然而,它们使用不同的方法。虽然 Ansible 使用脚本(封装在 Ansible 模块中),Docker 将整个环境封装在一个容器中。

  • 依赖性:Ansible 提供了一种在相同或不同的主机上部署不同服务并让它们一起部署的方式。Docker Compose 具有类似的功能,允许同时运行多个容器。

  • 可扩展性:Ansible 有助于扩展服务,提供清单和主机组。Docker Compose 具有类似的功能,可以自动增加或减少运行容器的数量。

  • 配置文件自动化:Docker 和 Ansible 都将整个环境配置和服务依赖关系存储在文件中(存储在源代码控制存储库中)。对于 Ansible,这个文件称为playbook.yml。在 Docker 的情况下,我们有 Dockerfile 用于环境和 docker-compose.yml 用于依赖关系和扩展。

  • 简单性:这两个工具都非常简单易用,并提供了一种通过配置文件和一条命令执行来设置整个运行环境的方式。

如果我们比较这些工具,那么 Docker 做了更多,因为它提供了隔离、可移植性和某种安全性。我们甚至可以想象在没有任何其他配置管理工具的情况下使用 Docker。那么,我们为什么还需要 Ansible 呢?

Ansible 的好处

Ansible 可能看起来多余;然而,它为交付过程带来了额外的好处:

  • Docker 环境:Docker 主机本身必须进行配置和管理。每个容器最终都在 Linux 机器上运行,需要内核打补丁、Docker 引擎更新、网络配置等。而且,可能有不同的服务器机器使用不同的 Linux 发行版,Ansible 的责任是确保 Docker 引擎正常运行。

  • 非 Docker 化应用程序:并非所有东西都在容器内运行。如果基础设施的一部分是容器化的,另一部分以标准方式或在云中部署,那么 Ansible 可以通过 playbook 配置文件管理所有这些。不以容器方式运行应用程序可能有不同的原因,例如性能、安全性、特定的硬件要求、基于 Windows 的软件,或者与旧软件的工作。

  • 清单:Ansible 提供了一种非常友好的方式来使用清单管理物理基础设施,清单存储有关所有服务器的信息。它还可以将物理基础设施分成不同的环境:生产、测试、开发。

  • GUI:Ansible 提供了一个(商业)名为 Ansible Tower 的 GUI 管理器,旨在改进企业的基础设施管理。

  • 改进测试流程:Ansible 可以帮助集成和验收测试,并可以以与 Docker Compose 类似的方式封装测试脚本。

我们可以将 Ansible 视为负责基础设施的工具,而将 Docker 视为负责环境配置的工具。概述如下图所示:

Ansible 管理基础设施:Docker 服务器、Docker 注册表、没有 Docker 的服务器和云提供商。它还关注服务器的物理位置。使用清单主机组,它可以将 Web 服务链接到其地理位置附近的数据库。

Ansible Docker playbook

Ansible 与 Docker 集成得很顺利,因为它提供了一组专门用于 Docker 的模块。如果我们为基于 Docker 的部署创建一个 Ansible playbook,那么第一个任务需要确保 Docker 引擎已安装在每台机器上。然后,它应该使用 Docker 运行一个容器,或者使用 Docker Compose 运行一组交互式容器。

Ansible 提供了一些非常有用的与 Docker 相关的模块:docker_image(构建/管理镜像)、docker_container(运行容器)、docker_image_facts(检查镜像)、docker_login(登录到 Docker 注册表)、docker_network(管理 Docker 网络)和docker_service(管理 Docker Compose)。

安装 Docker

我们可以使用 Ansible playbook 中的以下任务来安装 Docker 引擎。

tasks:
- name: add docker apt keys
  apt_key:
    keyserver: hkp://p80.pool.sks-keyservers.net:80
    id: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
- name: update apt
  apt_repository:
    repo: deb [arch=amd64] https://download.docker.com/linux/ubuntu xenial main stable
    state: present
- name: install Docker
  apt:
    name: docker-ce
    update_cache: yes
    state: present
- name: add admin to docker group
  user:
    name: admin
    groups: docker
    append: yes
- name: install python-pip
  apt:
    name: python-pip
    state: present
- name: install docker-py
  pip:
    name: docker-py
- name: install Docker Compose
  pip:
    name: docker-compose
    version: 1.9.0

每个操作系统的 playbook 看起来略有不同。这里介绍的是针对 Ubuntu 16.04 的。

此配置安装 Docker 引擎,使admin用户能够使用 Docker,并安装了 Docker Compose 及其依赖工具。

或者,您也可以使用docker_ubuntu角色,如此处所述:www.ansible.com/2014/02/12/installing-and-building-docker-with-ansible

安装 Docker 后,我们可以添加一个任务,该任务将运行一个 Docker 容器。

运行 Docker 容器

使用docker_container模块来运行 Docker 容器,它看起来与我们为 Docker Compose 配置所呈现的非常相似。让我们将其添加到playbook.yml文件中。

- name: run Redis container
  docker_container:
    name: redis
    image: redis
    state: started
    exposed_ports:
    - 6379

您可以在官方 Ansible 页面上阅读有关docker_container模块的所有选项的更多信息:docs.ansible.com/ansible/docker_container_module.html

现在我们可以执行 playbook 来观察 Docker 是否已安装并且 Redis 容器已启动。请注意,这是一种非常方便的使用 Docker 的方式,因为我们不需要在每台机器上手动安装 Docker 引擎。

使用 Docker Compose

Ansible playbook 与 Docker Compose 配置非常相似。它们甚至共享相同的 YAML 文件格式。而且,可以直接从 Ansible 使用docker-compose.yml。我们将展示如何做到这一点,但首先让我们定义docker-compose.yml文件。

version: "2"
services:
  calculator:
    image: leszko/calculator:latest
    ports:
    - 8080
  redis:
    image: redis:latest

这几乎与我们在上一章中定义的内容相同。这一次,我们直接从 Docker Hub 注册表获取计算器镜像,并且不在docker-compose.yml中构建它,因为我们希望构建一次镜像,将其推送到注册表,然后在每个部署步骤(在每个环境中)重复使用它,以确保相同的镜像部署在每台 Docker 主机上。当我们有了docker-compose.yml,我们就准备好向playbook.yml添加新任务了。

- name: copy docker-compose.yml
  copy:
    src: ./docker-compose.yml
    dest: ./docker-compose.yml
- name: run docker-compose
  docker_service:
    project_src: .
    state: present

我们首先将 docker-compose.yml 文件复制到服务器,然后执行docker-compose。结果,Ansible 创建了两个容器:计算器和 Redis。

我们已经看到了 Ansible 的最重要特性。在接下来的章节中,我们会稍微介绍一下基础设施和应用程序版本控制。在本章结束时,我们将介绍如何使用 Ansible 来完成持续交付流程。

练习

在本章中,我们已经介绍了 Ansible 的基础知识以及与 Docker 一起使用它的方式。作为练习,我们提出以下任务:

  1. 创建服务器基础设施并使用 Ansible 进行管理。
  • 连接物理机器或运行 VirtualBox 机器来模拟远程服务器

  • 配置 SSH 访问远程机器(SSH 密钥)

  • 在远程机器上安装 Python

  • 创建一个包含远程机器的 Ansible 清单

  • 运行 Ansible 的临时命令(使用ping模块)来检查基础设施是否配置正确

  1. 创建一个基于 Python 的“hello world”网络服务,并使用 Ansible 剧本在远程机器上部署它。
  • 服务可以与本章练习中描述的完全相同

  • 创建一个部署服务到远程机器的剧本

  • 运行ansible-playbook命令并检查服务是否已部署

总结

我们已经介绍了配置管理过程及其与 Docker 的关系。本章的关键要点如下:

  • 配置管理是创建和应用基础设施和应用程序的配置的过程

  • Ansible 是最流行的配置管理工具之一。它是无代理的,因此不需要特殊的服务器配置

  • Ansible 可以与临时命令一起使用,但真正的力量在于 Ansible 剧本

  • Ansible 剧本是环境应该如何配置的定义

  • Ansible 角色的目的是重用剧本的部分。

  • Ansible Galaxy 是一个在线服务,用于共享 Ansible 角色

  • 与仅使用 Docker 和 Docker Compose 相比,Ansible 与 Docker 集成良好并带来额外的好处

在下一章中,我们将结束持续交付过程并完成最终的 Jenkins 流水线。

第七章:持续交付流水线

我们已经涵盖了持续交付过程中最关键的部分:提交阶段、构件存储库、自动验收测试和配置管理。

在本章中,我们将重点关注最终流水线的缺失部分,即环境和基础设施、应用程序版本控制和非功能性测试。

本章涵盖以下要点:

  • 设计不同的软件环境及其基础设施

  • 保护 Jenkins 代理和服务器之间的连接

  • 引入各种非功能性测试

  • 介绍持续交付过程中非功能性测试的挑战

  • 解释不同类型的应用程序版本控制

  • 完成持续交付流水线

  • 介绍烟雾测试的概念并将其添加到最终流水线中

环境和基础设施

到目前为止,我们总是使用一个 Docker 主机来处理一切,并将其视为无尽资源的虚拟化,我们可以在其中部署一切。显然,Docker 主机实际上可以是一组机器,我们将在接下来的章节中展示如何使用 Docker Swarm 创建它。然而,即使 Docker 主机在资源方面是无限的,我们仍然需要考虑底层基础设施,至少有两个原因:

  • 机器的物理位置很重要

  • 不应在生产物理机器上进行测试

考虑到这些事实,在本节中,我们将讨论不同类型的环境,在持续交付过程中的作用以及基础设施安全方面。

环境类型

有四种最常见的环境类型:生产、暂存、QA(测试)和开发。让我们讨论每种环境及其基础设施。

生产

生产环境是最终用户使用的环境。它存在于每家公司中,当然,它是最重要的环境。

让我们看看下面的图表,看看大多数生产环境是如何组织的:

用户通过负载均衡器访问服务,负载均衡器选择确切的机器。如果应用程序在多个物理位置发布,那么(首先)设备通常是基于 DNS 的地理负载均衡器。在每个位置,我们都有一个服务器集群。如果我们使用 Docker,那么这个服务器集群可以隐藏在一个或多个 Docker 主机后面(这些主机在内部由使用 Docker Swarm 的许多机器组成)。

机器的物理位置很重要,因为请求-响应时间可能会因物理距离而有显着差异。此外,数据库和其他依赖服务应该位于靠近部署服务的机器上。更重要的是,数据库应该以一种方式进行分片,以使不同位置之间的复制开销最小化。否则,我们可能会等待数据库在彼此相距很远的实例之间达成共识。有关物理方面的更多细节超出了本书的范围,但重要的是要记住,Docker 并不总是解决问题的灵丹妙药。

容器化和虚拟化使您可以将服务器视为无限资源;然而,一些物理方面,如位置,仍然相关。

暂存

暂存环境是发布候选版本部署的地方,以便在上线之前进行最终测试。理想情况下,这个环境应该是生产环境的镜像。

让我们看看以下内容,以了解在交付过程的背景下,这样的环境应该是什么样子的:

请注意,暂存环境是生产的精确克隆。如果应用程序在多个位置部署,那么暂存环境也应该有多个位置。

在持续交付过程中,所有自动接受功能和非功能测试都针对这个环境运行。虽然大多数功能测试通常不需要相同的类似生产的基础设施,但在非功能(尤其是性能)测试的情况下,这是必须的。

为了节省成本,暂存基础设施与生产环境不同(通常包含较少的机器)并不罕见。然而,这种方法可能导致许多生产问题。 Michael T. Nygard 在他的著作 Release It! 中举了一个真实场景的例子,其中暂存环境使用的机器比生产环境少。

故事是这样的:在某家公司,系统一直很稳定,直到某个代码更改导致生产环境变得极其缓慢,尽管所有压力测试都通过了。这是怎么可能的?事实上,有一个同步点,每个服务器都要与其他服务器通信。在暂存环境中,只有一个服务器,所以实际上没有阻塞。然而,在生产环境中,有许多服务器,导致服务器相互等待。这个例子只是冰山一角,如果暂存环境与生产环境不同,许多生产问题可能无法通过验收测试来测试。

QA

QA 环境(也称为测试环境)旨在供 QA 团队进行探索性测试,以及依赖我们服务的外部应用程序进行集成测试。QA 环境的用例和基础设施如下图所示:

虽然暂存环境不需要稳定(在持续交付的情况下,它在每次提交到存储库的代码更改后都会更改),但 QA 实例需要提供一定的稳定性,并公开与生产环境相同(或向后兼容)的 API。与暂存环境相反,基础设施可以与生产环境不同,因为其目的不是确保发布候选版本正常工作。

一个非常常见的情况是为了 QA 实例的目的分配较少的机器(例如,只来自一个位置)。

部署到 QA 环境通常是在一个单独的流水线中进行的,这样它就可以独立于自动发布流程。这种方法很方便,因为 QA 实例的生命周期与生产环境不同(例如,QA 团队可能希望对从主干分支出来的实验性代码进行测试)。

开发

开发环境可以作为所有开发人员共享的服务器创建,或者每个开发人员可以拥有自己的开发环境。这里呈现了一个简单的图表:

开发环境始终包含代码的最新版本。它用于实现开发人员之间的集成,并且可以像 QA 环境一样对待,但是由开发人员而不是 QA 使用。

持续交付中的环境

对于持续交付过程,暂存环境是必不可少的。在一些非常罕见的情况下,当性能不重要且项目没有太多依赖性时,我们可以在本地(开发)Docker 主机上执行验收测试(就像我们在上一章中所做的那样),但这应该是一个例外,而不是规则。在这种情况下,我们总是面临与环境相关的一些生产问题的风险。

其他环境通常对于持续交付并不重要。如果我们希望在每次提交时部署到 QA 或开发环境,那么我们可以为此创建单独的流水线(小心不要混淆主要发布流水线)。在许多情况下,部署到 QA 环境是手动触发的,因为它可能与生产环境有不同的生命周期。

保护环境

所有环境都需要得到很好的保护。这是明显的。更明显的是,最重要的要求是保持生产环境的安全,因为我们的业务取决于它,任何安全漏洞的后果在那里可能是最严重的。

安全是一个广泛的话题。在本节中,我们只关注与持续交付过程相关的主题。然而,建立完整的服务器基础设施需要更多关于安全的知识。

在持续交付过程中,从属必须能够访问服务器,以便它可以部署应用程序。

提供从属机器与服务器凭据的不同方法:

  • 将 SSH 密钥放入从属中:如果我们不使用动态 Docker 从属配置,那么我们可以配置 Jenkins 从属机器以包含私有 SSH 密钥。

  • 将 SSH 密钥放入从属镜像中:如果我们使用动态 Docker 从属配置,我们可以将 SSH 私钥添加到 Docker 从属镜像中。然而,这会产生可能的安全漏洞,因为任何访问该镜像的人都将可以访问生产服务器。

  • Jenkins 凭据:我们可以配置 Jenkins 来存储凭据并在流程中使用它们。

  • 复制到从属 Jenkins 插件:我们可以在启动 Jenkins 构建时动态地将 SSH 密钥复制到从属系统中。

每种解决方案都有一些优点和缺点。在使用任何一种解决方案时,我们都必须格外小心,因为当一个从属系统可以访问生产环境时,任何人入侵从属系统就等于入侵生产环境。

最危险的解决方案是将 SSH 私钥放入 Jenkins 从属系统镜像中,因为镜像存储的所有地方(Docker 注册表或带有 Jenkins 的 Docker 主机)都需要得到很好的保护。

非功能性测试

在上一章中,我们学到了很多关于功能需求和自动化验收测试。然而,对于非功能性需求,我们应该怎么办呢?甚至更具挑战性的是,如果没有需求怎么办?在持续交付过程中,我们应该完全跳过它们吗?让我们在本节中回答这些问题。

软件的非功能性方面总是重要的,因为它们可能对系统的运行造成重大风险。

例如,许多应用程序失败,是因为它们无法承受用户数量突然增加的负载。在《可用性工程》一书中,Jakob Nielsen 写道,1.0 秒是用户思维流程保持不间断的极限。想象一下,我们的系统在负载增加的情况下开始超过这个极限。用户可能会因为性能问题而停止使用服务。考虑到这一点,非功能性测试与功能性测试一样重要。

长话短说,我们应该始终为非功能性测试采取以下步骤:

  • 决定哪些非功能性方面对我们的业务至关重要

  • 对于每一个:

  • 指定测试的方式与我们为验收测试所做的方式相同

  • 在持续交付流程中添加一个阶段(在验收测试之后,应用程序仍然部署在暂存环境中)

  • 应用程序只有在所有非功能性测试通过后才能进入发布阶段

无论非功能性测试的类型如何,其思想总是相同的。然而,方法可能略有不同。让我们来看看不同的测试类型以及它们带来的挑战。

非功能性测试的类型

功能测试总是与系统行为相关。相反,非功能测试涉及许多不同的方面。让我们讨论最常见的系统属性以及它们如何在持续交付过程中进行测试。

性能测试

性能测试是最广泛使用的非功能测试。它们衡量系统的响应能力和稳定性。我们可以创建的最简单的性能测试是向 Web 服务发送请求并测量其往返时间(RTT)。

性能测试有不同的定义。在许多地方,它们意味着包括负载、压力和可伸缩性测试。有时它们也被描述为白盒测试。在本书中,我们将性能测试定义为衡量系统延迟的最基本的黑盒测试形式。

为了进行性能测试,我们可以使用专用框架(对于 Java 来说,最流行的是 JMeter),或者只是使用我们用于验收测试的相同工具。一个简单的性能测试通常被添加为管道阶段,就在验收测试之后。如果往返时间超过给定限制,这样的测试应该失败,并且它可以检测到明显减慢服务的错误。

Jenkins 的 JMeter 插件可以显示随时间变化的性能趋势。

负载测试

负载测试用于检查系统在有大量并发请求时的功能。虽然系统对单个请求可能非常快,但这并不意味着它在同时处理 1000 个请求时速度足够快。在负载测试期间,我们测量许多并发调用的平均请求-响应时间,通常是从许多机器上执行的。负载测试是发布周期中非常常见的 QA 阶段。为了自动化它,我们可以使用与简单性能测试相同的工具;然而,在较大系统的情况下,我们可能需要一个单独的客户端环境来执行大量并发请求。

压力测试

压力测试,也称为容量测试或吞吐量测试,是一种确定多少并发用户可以访问我们的服务的测试。这听起来与负载测试相同;然而,在负载测试的情况下,我们将并发用户数量(吞吐量)设置为一个给定的数字,检查响应时间(延迟),并且如果超过限制,则使构建失败。然而,在压力测试期间,我们保持延迟恒定,并增加吞吐量以发现系统仍然可操作时的最大并发调用数量。因此,压力测试的结果可能是通知我们的系统可以处理 10,000 个并发用户,这有助于我们为高峰使用时间做好准备。

压力测试不太适合连续交付流程,因为它需要进行长时间的测试,同时并发请求数量不断增加。它应该准备为一个独立的脚本或一个独立的 Jenkins 流水线,并在需要时触发,当我们知道代码更改可能会导致性能问题时。

可扩展性测试

可扩展性测试解释了当我们增加更多服务器或服务时延迟和吞吐量的变化。完美的特征应该是线性的,这意味着如果我们有一个服务器,当有 100 个并行用户使用时,平均请求-响应时间为 500 毫秒,那么添加另一个服务器将保持响应时间不变,并允许我们添加另外 100 个并行用户。然而,在现实中,由于保持服务器之间的数据一致性,通常很难实现这一点。

可扩展性测试应该是自动化的,并且应该提供图表,展示机器数量和并发用户数量之间的关系。这些数据有助于确定系统的限制以及增加更多机器不会有所帮助的点。

可扩展性测试,类似于压力测试,很难放入连续交付流程中,而应该保持独立。

耐久测试

耐久测试,也称为长期测试,长时间运行系统,以查看性能是否在一定时间后下降。它们可以检测内存泄漏和稳定性问题。由于它们需要系统长时间运行,因此在连续交付流程中运行它们是没有意义的。

安全测试

安全测试涉及与安全机制和数据保护相关的不同方面。一些安全方面纯粹是功能需求,例如身份验证、授权或角色分配。这些部分应该与任何其他功能需求一样在验收测试阶段进行检查。还有其他安全方面是非功能性的;例如,系统应该受到 SQL 注入的保护。没有客户可能会明确指定这样的要求,但这是隐含的。

安全测试应该作为连续交付的一个流水线阶段包括在内。它们可以使用与验收测试相同的框架编写,也可以使用专门的安全测试框架,例如 BDD 安全。

安全也应始终成为解释性测试过程的一部分,测试人员和安全专家会发现安全漏洞并添加新的测试场景。

可维护性测试

可维护性测试解释了系统维护的简单程度。换句话说,它们评判了代码质量。我们已经在提交阶段有了相关的阶段,检查测试覆盖率并进行静态代码分析。Sonar 工具也可以提供一些关于代码质量和技术债务的概述。

恢复测试

恢复测试是一种确定系统在因软件或硬件故障而崩溃后能够多快恢复的技术。最好的情况是,即使系统的一部分服务停止,系统也不会完全崩溃。一些公司甚至会故意进行生产故障,以检查他们是否能够在灾难中生存。最著名的例子是 Netflix 和他们的混沌猴工具,该工具会随机终止生产环境的随机实例。这种方法迫使工程师编写能够使系统对故障具有弹性的代码。

恢复测试显然不是连续交付过程的一部分,而是定期事件,用于检查整体健康状况。

您可以在github.com/Netflix/chaosmonkey了解更多关于混沌猴的信息。

还有许多与代码和持续交付过程更接近或更远的非功能测试类型。其中一些与法律相关,如合规性测试;其他与文档或国际化相关。还有可用性测试和容量测试(检查系统在大量数据情况下的表现)。然而,大多数这些测试在持续交付过程中并没有任何作用。

非功能挑战

非功能方面给软件开发和交付带来了新的挑战:

  • 长时间运行测试:测试可能需要很长时间运行,并且可能需要特殊的执行环境。

  • 增量性质:很难设置测试应该在何时失败的限值(除非 SLA 定义得很好)。即使设置了边缘限制,应用程序也可能逐渐接近限制。实际上,在大多数情况下,没有任何代码更改导致测试失败。

  • 模糊的需求:用户通常对非功能需求没有太多的输入。他们可能会提供一些关于请求-响应时间或用户数量的指导,但他们可能不会太了解可维护性、安全性或可扩展性。

  • 多样性:有很多不同的非功能测试,选择应该实施哪些需要做一些妥协。

解决非功能方面的最佳方法是采取以下步骤:

  1. 列出所有非功能测试类型。

  2. 明确划掉您的系统不需要的测试。您可能不需要某种测试的原因有很多,例如:

  • 该服务非常小,简单的性能测试就足够了

  • 该系统仅内部使用,仅供只读,因此可能不需要进行任何安全检查。

  • 该系统仅设计用于一台机器,不需要任何扩展

  • 创建某些测试的成本太高

  1. 将您的测试分为两组:
  • 持续交付:可以将其添加到流水线中

  • 分析:由于执行时间、性质或相关成本,无法将其添加到流水线中

  1. 对于持续交付组,实施相关的流水线阶段。

  2. 对于分析组:

  • 创建自动化测试

  • 安排何时运行它们

  • 安排会议讨论它们的结果并制定行动计划

一个非常好的方法是进行夜间构建,其中包括不适合持续交付流程的长时间测试。然后,可以安排每周一次的会议来监视和分析系统性能的趋势。

正如所述,有许多类型的非功能性测试,它们给交付过程带来了额外的挑战。然而,为了系统的稳定性,这些测试绝不能被简单地跳过。技术实现因测试类型而异,但在大多数情况下,它们可以以类似的方式实现功能验收测试,并应该针对暂存环境运行。

如果您对非功能性测试、系统属性和系统稳定性感兴趣,请阅读 Michael T. Nygard 的书《发布它!》。

应用版本控制

到目前为止,在每次 Jenkins 构建期间,我们都创建了一个新的 Docker 镜像,将其推送到 Docker 注册表,并在整个过程中使用最新版本。然而,这种解决方案至少有三个缺点:

  • 如果在 Jenkins 构建期间,在验收测试之后,有人推送了图像的新版本,那么我们可能会发布未经测试的版本。

  • 我们总是推送以相同方式命名的镜像;因此,在 Docker 注册表中,它被有效地覆盖了。

  • 仅通过哈希样式 ID 来管理没有版本的图像非常困难

管理 Docker 镜像版本与持续交付过程的推荐方式是什么?在本节中,我们将看到不同的版本控制策略,并学习在 Jenkins 流水线中创建版本的不同方法。

版本控制策略

有不同的应用版本控制方式。

让我们讨论这些最流行的解决方案,这些解决方案可以与持续交付过程一起应用(每次提交都创建一个新版本)。

  • 语义化版本控制:最流行的解决方案是使用基于序列的标识符(通常以 x.y.z 的形式)。这种方法需要 Jenkins 在存储库中进行提交,以增加当前版本号,通常存储在构建文件中。这种解决方案得到了 Maven、Gradle 和其他构建工具的良好支持。标识符通常由三个数字组成。

  • x:这是主要版本;当增加此版本时,软件不需要向后兼容

  • y:这是次要版本;当增加版本时,软件需要向后兼容

  • z: 这是构建编号;有时也被认为是向后和向前兼容的更改

  • 时间戳:对于应用程序版本,使用构建的日期和时间比顺序号更简洁,但在持续交付过程中非常方便,因为它不需要 Jenkins 向存储库提交。

  • 哈希:随机生成的哈希版本具有日期时间的好处,并且可能是可能的最简单的解决方案。缺点是无法查看两个版本并告诉哪个是最新的。

  • 混合:有许多先前描述的解决方案的变体,例如,带有日期时间的主要和次要版本。

所有解决方案都可以与持续交付流程一起使用。语义化版本控制要求从构建执行向存储库提交,以便在源代码存储库中增加版本。

Maven(和其他构建工具)推广了版本快照,为未发布的版本添加了后缀 SNAPSHOT,但仅用于开发过程。由于持续交付意味着发布每个更改,因此没有快照。

Jenkins 流水线中的版本控制

正如前面所述,使用软件版本控制时有不同的可能性,每种可能性都可以在 Jenkins 中实现。

举个例子,让我们使用日期时间。

为了使用 Jenkins 中的时间戳信息,您需要安装 Build Timestamp 插件,并在 Jenkins 配置中设置时间戳格式(例如为"yyyyMMdd-HHmm")。

在我们使用 Docker 镜像的每个地方,我们需要添加标签后缀:${BUILD_TIMESTAMP}

例如,Docker 构建阶段应该是这样的:

sh "docker build -t leszko/calculator:${BUILD_TIMESTAMP} ."

更改后,当我们运行 Jenkins 构建时,我们应该在我们的 Docker 注册表中使用时间戳版本标记图像。

请注意,在显式标记图像后,它不再隐式标记为最新版本。

版本控制完成后,我们终于准备好完成持续交付流程。

完成持续交付流程

在讨论了 Ansible、环境、非功能测试和版本控制的所有方面后,我们准备扩展 Jenkins 流水线并完成一个简单但完整的持续交付流程。

我们将分几步来完成:

  • 创建暂存和生产环境清单

  • 更新验收测试以使用远程主机(而不是本地)

  • 将应用程序发布到生产环境

  • 添加一个冒烟测试,确保应用程序已成功发布

清单

在最简单的形式中,我们可以有两个环境:暂存和生产,每个环境都有一个 Docker 主机。在现实生活中,如果我们希望在不同位置拥有服务器或具有不同要求,可能需要为每个环境添加更多的主机组。

让我们创建两个 Ansible 清单文件。从暂存开始,我们可以定义inventory/staging文件。假设暂存地址是192.168.0.241,它将具有以下内容:

[webservers]
web1 ansible_host=192.168.0.241 ansible_user=admin

类比而言,如果生产 IP 地址是192.168.0.242,那么inventory/production应该如下所示:

[webservers]
web2 ansible_host=192.168.0.242 ansible_user=admin

只为每个环境拥有一个机器可能看起来过于简化了;然而,使用 Docker Swarm(我们稍后在本书中展示),一组主机可以隐藏在一个 Docker 主机后面。

有了定义的清单,我们可以更改验收测试以使用暂存环境。

验收测试环境

根据我们的需求,我们可以通过在本地 Docker 主机上运行应用程序(就像我们在上一章中所做的那样)或者使用远程暂存环境来测试应用程序。前一种解决方案更接近于生产中发生的情况,因此可以被认为是更好的解决方案。这与上一章的方法 1:首先使用 Jenkins 验收测试部分非常接近。唯一的区别是现在我们将应用程序部署到远程 Docker 主机上。

为了做到这一点,我们可以使用带有-H参数的docker(或docker-compose命令),该参数指定了远程 Docker 主机地址。这将是一个很好的解决方案,如果您不打算使用 Ansible 或任何其他配置管理工具,那么这就是前进的方式。然而,出于本章已经提到的原因,使用 Ansible 是有益的。在这种情况下,我们可以在持续交付管道中使用ansible-playbook命令。

stage("Deploy to staging") {
    steps {
        sh "ansible-playbook playbook.yml -i inventory/staging"
    }
}

如果playbook.yml和 docker-compose.yml 看起来与使用 Docker 的 Ansible部分中的内容相同,那么将足以将应用程序与依赖项部署到暂存环境中。

“验收测试”阶段与上一章完全相同。唯一的调整可能是暂存环境的主机名(或其负载均衡器)。还可以添加用于对运行在暂存环境上的应用程序进行性能测试或其他非功能测试的阶段。

在所有测试通过后,是时候发布应用程序了。

发布

生产环境应尽可能接近暂存环境。发布的 Jenkins 步骤也应与将应用程序部署到暂存环境的阶段非常相似。

在最简单的情况下,唯一的区别是清单文件和应用程序配置(例如,在 Spring Boot 应用程序的情况下,我们将设置不同的 Spring 配置文件,这将导致使用不同的属性文件)。在我们的情况下,没有应用程序属性,所以唯一的区别是清单文件。

stage("Release") {
    steps {
        sh "ansible-playbook playbook.yml -i inventory/production"
    }
}

实际上,如果我们想要实现零停机部署,发布步骤可能会更加复杂。关于这个主题的更多内容将在接下来的章节中介绍。

发布完成后,我们可能认为一切都已完成;然而,还有一个缺失的阶段,即冒烟测试。

冒烟测试

冒烟测试是验收测试的一个非常小的子集,其唯一目的是检查发布过程是否成功完成。否则,我们可能会出现这样的情况:应用程序完全正常,但发布过程中出现问题,因此我们可能最终得到一个无法工作的生产环境。

冒烟测试通常与验收测试以相同的方式定义。因此,管道中的“冒烟测试”阶段应该如下所示:

stage("Smoke test") {
    steps {
        sleep 60
        sh "./smoke_test.sh"
    }
}

设置完成后,连续交付构建应该自动运行,并且应用程序应该发布到生产环境。通过这一步,我们已经完成了连续交付管道的最简单但完全有效的形式。

完整的 Jenkinsfile

总之,在最近的章节中,我们创建了相当多的阶段,这导致了一个完整的连续交付管道,可以成功地应用于许多项目。

接下来我们看到计算器项目的完整 Jenkins 文件:

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"
      publishHTML (target: [
              reportDir: 'build/reports/jacoco/test/html',
              reportFiles: 'index.html',
              reportName: "JaCoCo Report" ])
      sh "./gradlew jacocoTestCoverageVerification"
    } }

    stage("Static code analysis") { steps {
      sh "./gradlew checkstyleMain"
      publishHTML (target: [
              reportDir: 'build/reports/checkstyle/',
              reportFiles: 'main.html',
              reportName: "Checkstyle Report" ])
    } }

    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("Deploy to staging") { steps {
      sh "ansible-playbook playbook.yml -i inventory/staging"
      sleep 60
    } }

    stage("Acceptance test") { steps { sh "./acceptance_test.sh" } }  

    // Performance test stages

    stage("Release") { steps {
      sh "ansible-playbook playbook.yml -i inventory/production"
      sleep 60
    } }

    stage("Smoke test") { steps { sh "./smoke_test.sh" } }
  }
}

您可以在 GitHub 上找到这个 Jenkinsfile:github.com/leszko/calculator/blob/master/Jenkinsfile

练习

在本章中,我们涵盖了持续交付管道的许多新方面;为了更好地理解这个概念,我们建议您进行以下练习:

  1. 添加一个性能测试,测试“hello world”服务:
  • “hello world”服务可以从上一章中获取

  • 创建一个performance_test.sh脚本,同时进行 100 次调用,并检查平均请求-响应时间是否低于 1 秒

  • 您可以使用 Cucumber 或curl命令来执行脚本

  1. 创建一个 Jenkins 管道,构建“hello world”网络服务作为版本化的 Docker 镜像,并执行性能测试:
  • 创建“Docker 构建”阶段,用于构建带有“hello world”服务的 Docker 镜像,并添加时间戳作为版本标记

  • 创建一个使用 Docker 镜像的 Ansible 剧本

  • 添加“部署到暂存”阶段,将镜像部署到远程机器

  • 添加“性能测试”阶段,执行performance_test.sh

  • 运行管道并观察结果

摘要

在本章中,我们完成了持续交付管道,最终发布了应用程序。以下是本章的要点:

  • 为了持续交付的目的,两个环境是必不可少的:暂存和生产。

  • 非功能测试是持续交付过程的重要组成部分,应始终被视为管道阶段。

  • 不符合持续交付过程的非功能测试应被视为定期任务,以监控整体性能趋势。

  • 应用程序应始终进行版本控制;但是,版本控制策略取决于应用程序的类型。

  • 最小的持续交付管道可以被实现为一系列以发布和冒烟测试为结束的脚本阶段。

  • 冒烟测试应始终作为持续交付管道的最后阶段添加,以检查发布是否成功。

在下一章中,我们将介绍 Docker Swarm 工具,该工具可帮助我们创建 Docker 主机集群。

第八章:使用 Docker Swarm 进行集群化

我们已经涵盖了持续交付流水线的所有基本方面。在本章中,我们将看到如何将 Docker 环境从单个 Docker 主机更改为一组机器,并如何与 Jenkins 一起使用它。

本章涵盖以下内容:

  • 解释服务器集群的概念

  • 介绍 Docker Swarm 及其最重要的功能

  • 介绍如何从多个 Docker 主机构建群集

  • 在集群上运行和扩展 Docker 镜像

  • 探索高级群集功能:滚动更新、排水节点、多个管理节点和调整调度策略

  • 在集群上部署 Docker Compose 配置

  • 介绍 Kubernetes 和 Apache Mesos 作为 Docker Swarm 的替代方案

  • 在集群上动态扩展 Jenkins 代理

服务器集群

到目前为止,我们已经分别与每台机器进行了交互。即使我们使用 Ansible 在多台服务器上重复相同的操作,我们也必须明确指定应在哪台主机上部署给定服务。然而,在大多数情况下,如果服务器共享相同的物理位置,我们并不关心服务部署在哪台特定的机器上。我们所需要的只是让它可访问并在许多实例中复制。我们如何配置一组机器以便它们共同工作,以至于添加新的机器不需要额外的设置?这就是集群的作用。

在本节中,您将介绍服务器集群的概念和 Docker Swarm 工具包。

介绍服务器集群

服务器集群是一组连接的计算机,它们以一种可以类似于单个系统的方式一起工作。服务器通常通过本地网络连接,连接速度足够快,以确保服务分布的影响很小。下图展示了一个简单的服务器集群:

用户通过称为管理器的主机访问集群,其界面应类似于常规 Docker 主机。在集群内,有多个工作节点接收任务,执行它们,并通知管理器它们的当前状态。管理器负责编排过程,包括任务分派、服务发现、负载平衡和工作节点故障检测。

管理者也可以执行任务,这是 Docker Swarm 的默认配置。然而,对于大型集群,管理者应该配置为仅用于管理目的。

介绍 Docker Swarm

Docker Swarm 是 Docker 的本地集群系统,将一组 Docker 主机转换为一个一致的集群,称为 swarm。连接到 swarm 的每个主机都扮演管理者或工作节点的角色(集群中必须至少有一个管理者)。从技术上讲,机器的物理位置并不重要;然而,将所有 Docker 主机放在一个本地网络中是合理的,否则,管理操作(或在多个管理者之间达成共识)可能需要大量时间。

自 Docker 1.12 以来,Docker Swarm 已经作为 swarm 模式被原生集成到 Docker Engine 中。在旧版本中,需要在每个主机上运行 swarm 容器以提供集群功能。

关于术语,在 swarm 模式下,运行的镜像称为服务,而不是在单个 Docker 主机上运行的容器。一个服务运行指定数量的任务。任务是 swarm 的原子调度单元,保存有关容器和应在容器内运行的命令的信息。副本是在节点上运行的每个容器。副本的数量是给定服务的所有容器的预期数量。

让我们看一下展示术语和 Docker Swarm 集群过程的图像:

我们首先指定一个服务,Docker 镜像和副本的数量。管理者会自动将任务分配给工作节点。显然,每个复制的容器都是从相同的 Docker 镜像运行的。在所呈现的流程的上下文中,Docker Swarm 可以被视为 Docker Engine 机制的一层,负责容器编排。

在上面的示例图像中,我们有三个任务,每个任务都在单独的 Docker 主机上运行。然而,也可能所有容器都在同一个 Docker 主机上启动。一切取决于分配任务给工作节点的管理节点使用的调度策略。我们将在后面的单独章节中展示如何配置该策略。

Docker Swarm 功能概述

Docker Swarm 提供了许多有趣的功能。让我们来看看最重要的几个:

  • 负载均衡:Docker Swarm 负责负载均衡和分配唯一的 DNS 名称,使得部署在集群上的应用可以与部署在单个 Docker 主机上的应用一样使用。换句话说,一个集群可以以与 Docker 容器类似的方式发布端口,然后集群管理器在集群中的服务之间分发请求。

  • 动态角色管理:Docker 主机可以在运行时添加到集群中,因此无需重新启动集群。而且,节点的角色(管理器或工作节点)也可以动态更改。

  • 动态服务扩展:每个服务都可以通过 Docker 客户端动态地扩展或缩减。管理节点负责从节点中添加或删除容器。

  • 故障恢复:管理器不断监视节点,如果其中任何一个失败,新任务将在不同的机器上启动,以便声明的副本数量保持不变。还可以创建多个管理节点,以防止其中一个失败时发生故障。

  • 滚动更新:对服务的更新可以逐步应用;例如,如果我们有 10 个副本并且想要进行更改,我们可以定义每个副本部署之间的延迟。在这种情况下,当出现问题时,我们永远不会出现没有副本正常工作的情况。

  • 两种服务模式:可以运行在两种模式下:

  • 复制服务:指定数量的复制容器根据调度策略算法分布在节点之间。

  • 全球服务:集群中的每个可用节点上都运行一个容器

  • 安全性:由于一切都在 Docker 中,Docker Swarm 强制执行 TLS 身份验证和通信加密。还可以使用 CA(或自签名)证书。

让我们看看这在实践中是什么样子。

实际中的 Docker Swarm

Docker Engine 默认包含了 Swarm 模式,因此不需要额外的安装过程。由于 Docker Swarm 是一个本地的 Docker 集群系统,管理集群节点是通过docker命令完成的,因此非常简单和直观。让我们首先创建一个管理节点和两个工作节点。然后,我们将从 Docker 镜像运行和扩展一个服务。

建立一个 Swarm

为了设置一个 Swarm,我们需要初始化管理节点。我们可以在一个即将成为管理节点的机器上使用以下命令来做到这一点:

$ docker swarm init

Swarm initialized: current node (qfqzhk2bumhd2h0ckntrysm8l) is now a manager.

To add a worker to this swarm, run the following command:
docker swarm join \
--token SWMTKN-1-253vezc1pqqgb93c5huc9g3n0hj4p7xik1ziz5c4rsdo3f7iw2-df098e2jpe8uvwe2ohhhcxd6w \
192.168.0.143:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

一个非常常见的做法是使用--advertise-addr <manager_ip>参数,因为如果管理机器有多个潜在的网络接口,那么docker swarm init可能会失败。

在我们的情况下,管理机器的 IP 地址是192.168.0.143,显然,它必须能够从工作节点(反之亦然)访问。请注意,在控制台上打印了要在工作机器上执行的命令。还要注意,已生成了一个特殊的令牌。从现在开始,它将被用来连接机器到集群,并且必须保密。

我们可以使用docker node命令来检查 Swarm 是否已创建:

$ docker node ls
ID                          HOSTNAME       STATUS  AVAILABILITY  MANAGER STATUS
qfqzhk2bumhd2h0ckntrysm8l * ubuntu-manager Ready   Active        Leader

当管理器正常运行时,我们准备将工作节点添加到 Swarm 中。

添加工作节点

为了将一台机器添加到 Swarm 中,我们必须登录到给定的机器并执行以下命令:

$ docker swarm join \
--token SWMTKN-1-253vezc1pqqgb93c5huc9g3n0hj4p7xik1ziz5c4rsdo3f7iw2-df098e2jpe8uvwe2ohhhcxd6w \
192.168.0.143:2377

This node joined a swarm as a worker.

我们可以使用docker node ls命令来检查节点是否已添加到 Swarm 中。假设我们已经添加了两个节点机器,输出应该如下所示:

$ docker node ls
ID                          HOSTNAME        STATUS  AVAILABILITY  MANAGER STATUS
cr7vin5xzu0331fvxkdxla22n   ubuntu-worker2  Ready   Active 
md4wx15t87nn0c3pyv24kewtz   ubuntu-worker1  Ready   Active 
qfqzhk2bumhd2h0ckntrysm8l * ubuntu-manager  Ready   Active        Leader

在这一点上,我们有一个由三个 Docker 主机组成的集群,ubuntu-managerubuntu-worker1ubuntu-worker2。让我们看看如何在这个集群上运行一个服务。

部署一个服务

为了在集群上运行一个镜像,我们不使用docker run,而是使用专门为 Swarm 设计的docker service命令(在管理节点上执行)。让我们启动一个单独的tomcat应用并给它命名为tomcat

$ docker service create --replicas 1 --name tomcat tomcat

该命令创建了服务,因此发送了一个任务来在一个节点上启动一个容器。让我们列出正在运行的服务:

$ docker service ls
ID            NAME    MODE        REPLICAS  IMAGE
x65aeojumj05  tomcat  replicated  1/1       tomcat:latest

日志确认了tomcat服务正在运行,并且有一个副本(一个 Docker 容器正在运行)。我们甚至可以更仔细地检查服务:

$ docker service ps tomcat
ID           NAME      IMAGE          NODE            DESIRED STATE CURRENT STATE 
kjy1udwcnwmi tomcat.1  tomcat:latest  ubuntu-manager  Running     Running about a minute ago

如果您对服务的详细信息感兴趣,可以使用docker service inspect <service_name>命令。

从控制台输出中,我们可以看到容器正在管理节点(ubuntu-manager)上运行。它也可以在任何其他节点上启动;管理器会自动使用调度策略算法选择工作节点。我们可以使用众所周知的docker ps命令来确认容器正在运行:

$ docker ps
CONTAINER ID     IMAGE
COMMAND           CREATED            STATUS              PORTS            NAMES
6718d0bcba98     tomcat@sha256:88483873b279aaea5ced002c98dde04555584b66de29797a4476d5e94874e6de 
"catalina.sh run" About a minute ago Up About a minute   8080/tcp         tomcat.1.kjy1udwcnwmiosiw2qn71nt1r

如果我们不希望任务在管理节点上执行,可以使用--constraint node.role==worker选项来限制服务。另一种可能性是完全禁用管理节点执行任务,使用docker node update --availability drain <manager_name>

扩展服务

当服务运行时,我们可以扩展或缩小它,以便它在许多副本中运行:

$ docker service scale tomcat=5
tomcat scaled to 5

我们可以检查服务是否已扩展:

$ docker service ps tomcat
ID            NAME     IMAGE          NODE            DESIRED STATE  CURRENT STATE 
kjy1udwcnwmi  tomcat.1  tomcat:latest  ubuntu-manager  Running    Running 2 minutes ago 
536p5zc3kaxz  tomcat.2  tomcat:latest  ubuntu-worker2  Running    Preparing 18 seconds ago npt6ui1g9bdp  tomcat.3  tomcat:latest  ubuntu-manager  Running    Running 18 seconds ago zo2kger1rmqc  tomcat.4  tomcat:latest  ubuntu-worker1  Running    Preparing 18 seconds ago 1fb24nf94488  tomcat.5  tomcat:latest  ubuntu-worker2  Running    Preparing 18 seconds ago  

请注意,这次有两个容器在manager节点上运行,一个在ubuntu-worker1节点上,另一个在ubuntu-worker2节点上。我们可以通过在每台机器上执行docker ps来检查它们是否真的在运行。

如果我们想要删除服务,只需执行以下命令即可:

$ docker service rm tomcat

您可以使用docker service ls命令检查服务是否已被删除,因此所有相关的tomcat容器都已停止并从所有节点中删除。

发布端口

Docker 服务,类似于容器,具有端口转发机制。我们可以通过添加-p <host_port>:<container:port>参数来使用它。启动服务可能如下所示:

$ docker service create --replicas 1 --publish 8080:8080 --name tomcat tomcat

现在,我们可以打开浏览器,在地址http://192.168.0.143:8080/下查看 Tomcat 的主页。

该应用程序可在充当负载均衡器并将请求分发到工作节点的管理主机上使用。可能听起来有点不太直观的是,我们可以使用任何工作节点的 IP 地址访问 Tomcat,例如,如果工作节点在192.168.0.166192.168.0.115下可用,我们可以使用http://192.168.0.166:8080/http://192.168.0.115:8080/访问相同的运行容器。这是可能的,因为 Docker Swarm 创建了一个路由网格,其中每个节点都有如何转发已发布端口的信息。

您可以阅读有关 Docker Swarm 如何进行负载平衡和路由的更多信息docs.docker.com/engine/swarm/ingress/

默认情况下,使用内部 Docker Swarm 负载平衡。因此,只需将所有请求发送到管理机器,它将负责在节点之间进行分发。另一种选择是配置外部负载均衡器(例如 HAProxy 或 Traefik)。

我们已经讨论了 Docker Swarm 的基本用法。现在让我们深入了解更具挑战性的功能。

高级 Docker Swarm

Docker Swarm 提供了许多在持续交付过程中有用的有趣功能。在本节中,我们将介绍最重要的功能。

滚动更新

想象一下,您部署了应用程序的新版本。您需要更新集群中的所有副本。一种选择是停止整个 Docker Swarm 服务,并从更新后的 Docker 镜像运行一个新的服务。然而,这种方法会导致服务停止和新服务启动之间的停机时间。在持续交付过程中,停机时间是不可接受的,因为部署可以在每次源代码更改后进行,这通常是经常发生的。那么,在集群中如何实现零停机部署呢?这就是滚动更新的作用。

滚动更新是一种自动替换服务副本的方法,一次替换一个副本,以确保一些副本始终在工作。Docker Swarm 默认使用滚动更新,并且可以通过两个参数进行控制:

  • update-delay:启动一个副本和停止下一个副本之间的延迟(默认为 0 秒)

  • update-parallelism:同时更新的最大副本数量(默认为 1)

Docker Swarm 滚动更新过程如下:

  1. 停止<update-parallelism>数量的任务(副本)。

  2. 在它们的位置上,运行相同数量的更新任务。

  3. 如果一个任务返回RUNNING状态,那么等待<update-delay>时间。

  4. 如果任何时候任何任务返回FAILED状态,则暂停更新。

update-parallelism参数的值应该根据我们运行的副本数量进行调整。如果数量较小,服务启动速度很快,保持默认值 1 是合理的。update-delay参数应设置为比我们应用程序预期的启动时间更长的时间,这样我们就会注意到失败,因此暂停更新。

让我们来看一个例子,将 Tomcat 应用程序从版本 8 更改为版本 9。假设我们有tomcat:8服务,有五个副本:

$ docker service create --replicas 5 --name tomcat --update-delay 10s tomcat:8

我们可以使用docker service ps tomcat命令检查所有副本是否正在运行。另一个有用的命令是docker service inspect命令,可以帮助检查服务:

$ docker service inspect --pretty tomcat

ID:    au1nu396jzdewyq2y8enm0b6i
Name:    tomcat
Service Mode:    Replicated
 Replicas:    5
Placement:
UpdateConfig:
 Parallelism:    1
 Delay:    10s
 On failure:    pause
 Max failure ratio: 0
ContainerSpec:
 Image:    tomcat:8@sha256:835b6501c150de39d2b12569fd8124eaebc53a899e2540549b6b6f8676538484
Resources:
Endpoint Mode:    vip

我们可以看到服务已经创建了五个副本,来自于tomcat:8镜像。命令输出还包括有关并行性和更新之间的延迟时间的信息(由docker service create命令中的选项设置)。

现在,我们可以将服务更新为tomcat:9镜像:

$ docker service update --image tomcat:9 tomcat

让我们看看发生了什么:

$ docker service ps tomcat
ID            NAME      IMAGE     NODE            DESIRED STATE  CURRENT STATE 
4dvh6ytn4lsq  tomcat.1  tomcat:8  ubuntu-manager  Running    Running 4 minutes ago 
2mop96j5q4aj  tomcat.2  tomcat:8  ubuntu-manager  Running    Running 4 minutes ago 
owurmusr1c48  tomcat.3  tomcat:9  ubuntu-manager  Running    Preparing 13 seconds ago 
r9drfjpizuxf   \_ tomcat.3  tomcat:8  ubuntu-manager  Shutdown   Shutdown 12 seconds ago 
0725ha5d8p4v  tomcat.4  tomcat:8  ubuntu-manager  Running    Running 4 minutes ago 
wl25m2vrqgc4  tomcat.5  tomcat:8  ubuntu-manager  Running    Running 4 minutes ago       

请注意,tomcat:8的第一个副本已关闭,第一个tomcat:9已经在运行。如果我们继续检查docker service ps tomcat命令的输出,我们会注意到每隔 10 秒,另一个副本处于关闭状态,新的副本启动。如果我们还监视docker inspect命令,我们会看到值UpdateStatus: State将更改为updating,然后在更新完成后更改为completed

滚动更新是一个非常强大的功能,允许零停机部署,并且应该始终在持续交付过程中使用。

排水节点

当我们需要停止工作节点进行维护,或者我们只是想将其从集群中移除时,我们可以使用 Swarm 排水节点功能。排水节点意味着要求管理器将所有任务移出给定节点,并排除它不接收新任务。结果,所有副本只在活动节点上运行,排水节点处于空闲状态。

让我们看看这在实践中是如何工作的。假设我们有三个集群节点和一个具有五个副本的 Tomcat 服务:

$ docker node ls
ID                          HOSTNAME        STATUS  AVAILABILITY  MANAGER STATUS
4mrrmibdrpa3yethhmy13mwzq   ubuntu-worker2  Ready   Active 
kzgm7erw73tu2rjjninxdb4wp * ubuntu-manager  Ready   Active        Leader
yllusy42jp08w8fmze43rmqqs   ubuntu-worker1  Ready   Active 

$ docker service create --replicas 5 --name tomcat tomcat

让我们检查一下副本正在哪些节点上运行:

$ docker service ps tomcat
ID            NAME      IMAGE          NODE            DESIRED STATE  CURRENT STATE 
zrnawwpupuql  tomcat.1  tomcat:latest  ubuntu-manager  Running    Running 17 minutes ago 
x6rqhyn7mrot  tomcat.2  tomcat:latest  ubuntu-worker1  Running    Running 16 minutes ago 
rspgxcfv3is2  tomcat.3  tomcat:latest  ubuntu-worker2  Running    Running 5 weeks ago 
cf00k61vo7xh  tomcat.4  tomcat:latest  ubuntu-manager  Running    Running 17 minutes ago 
otjo08e06qbx  tomcat.5  tomcat:latest  ubuntu-worker2  Running    Running 5 weeks ago      

有两个副本正在ubuntu-worker2节点上运行。让我们排水该节点:

$ docker node update --availability drain ubuntu-worker2

节点被设置为drain可用性,因此所有副本应该移出该节点:

$ docker service ps tomcat
ID            NAME      IMAGE          NODE            DESIRED STATE  CURRENT STATE
zrnawwpupuql  tomcat.1  tomcat:latest  ubuntu-manager  Running    Running 18 minutes ago 
x6rqhyn7mrot  tomcat.2  tomcat:latest  ubuntu-worker1  Running    Running 17 minutes ago qrptjztd777i  tomcat.3  tomcat:latest  ubuntu-worker1  Running    Running less than a second ago 
rspgxcfv3is2   \_ tomcat.3  tomcat:latest  ubuntu-worker2  Shutdown   Shutdown less than a second ago 
cf00k61vo7xh  tomcat.4  tomcat:latest  ubuntu-manager  Running    Running 18 minutes ago k4c14tyo7leq  tomcat.5  tomcat:latest  ubuntu-worker1  Running    Running less than a second ago 
otjo08e06qbx   \_ tomcat.5  tomcat:latest  ubuntu-worker2  Shutdown   Shutdown less than a second ago   

我们可以看到新任务在ubuntu-worker1节点上启动,并且旧副本已关闭。我们可以检查节点的状态:

$ docker node ls
ID                          HOSTNAME        STATUS  AVAILABILITY  MANAGER STATUS
4mrrmibdrpa3yethhmy13mwzq   ubuntu-worker2  Ready   Drain 
kzgm7erw73tu2rjjninxdb4wp * ubuntu-manager  Ready   Active        Leader
yllusy42jp08w8fmze43rmqqs   ubuntu-worker1  Ready   Active   

如预期的那样,ubuntu-worker2节点可用(状态为Ready),但其可用性设置为排水,这意味着它不托管任何任务。如果我们想要将节点恢复,可以将其可用性检查为active

$ docker node update --availability active ubuntu-worker2

一个非常常见的做法是排水管理节点,结果是它不会接收任何任务,只做管理工作。

排水节点的另一种方法是从工作节点执行docker swarm leave命令。然而,这种方法有两个缺点:

  • 有一段时间,副本比预期少(离开 Swarm 之后,在主节点开始在其他节点上启动新任务之前)

  • 主节点不控制节点是否仍然在集群中

出于这些原因,如果我们计划暂停工作节点一段时间然后再启动它,建议使用排空节点功能。

多个管理节点

拥有单个管理节点是有风险的,因为当管理节点宕机时,整个集群也会宕机。在业务关键系统的情况下,这种情况显然是不可接受的。在本节中,我们将介绍如何管理多个主节点。

为了将新的管理节点添加到系统中,我们需要首先在(当前单一的)管理节点上执行以下命令:

$ docker swarm join-token manager

To add a manager to this swarm, run the following command:

docker swarm join \
--token SWMTKN-1-5blnptt38eh9d3s8lk8po3069vbjmz7k7r3falkm20y9v9hefx-a4v5olovq9mnvy7v8ppp63r23 \
192.168.0.143:2377

输出显示了令牌和需要在即将成为管理节点的机器上执行的整个命令。执行完毕后,我们应该看到添加了一个新的管理节点。

另一种添加管理节点的选项是使用docker node promote <node>命令将其从工作节点角色提升为管理节点。为了将其重新转换为工作节点角色,我们可以使用docker node demote <node>命令。

假设我们已经添加了两个额外的管理节点;我们应该看到以下输出:

$ docker node ls
ID                          HOSTNAME         STATUS  AVAILABILITY  MANAGER STATUS
4mrrmibdrpa3yethhmy13mwzq   ubuntu-manager2  Ready   Active 
kzgm7erw73tu2rjjninxdb4wp * ubuntu-manager   Ready   Active        Leader
pkt4sjjsbxx4ly1lwetieuj2n   ubuntu-manager1  Ready   Active        Reachable

请注意,新的管理节点的管理状态设置为可达(或留空),而旧的管理节点是领导者。其原因是始终有一个主节点负责所有 Swarm 管理和编排决策。领导者是使用 Raft 共识算法从管理节点中选举出来的,当它宕机时,会选举出一个新的领导者。

Raft 是一种共识算法,用于在分布式系统中做出决策。您可以在raft.github.io/上阅读有关其工作原理的更多信息(并查看可视化)。用于相同目的的非常流行的替代算法称为 Paxos。

假设我们关闭了ubuntu-manager机器;让我们看看新领导者是如何选举的:

$ docker node ls
ID                          HOSTNAME         STATUS  AVAILABILITY  MANAGER STATUS
4mrrmibdrpa3yethhmy13mwzq   ubuntu-manager2  Ready   Active        Reachable
kzgm7erw73tu2rjjninxdb4wp   ubuntu-manager   Ready   Active        Unreachable 
pkt4sjjsbxx4ly1lwetieuj2n * ubuntu-manager1  Ready   Active        Leader

请注意,即使其中一个管理节点宕机,Swarm 也可以正常工作。

管理节点的数量没有限制,因此听起来管理节点越多,容错能力就越好。这是真的,然而,拥有大量管理节点会影响性能,因为所有与 Swarm 状态相关的决策(例如,添加新节点或领导者选举)都必须使用 Raft 算法在所有管理节点之间达成一致意见。因此,管理节点的数量始终是容错能力和性能之间的权衡。

Raft 算法本身对管理者的数量有限制。分布式决策必须得到大多数节点的批准,称为法定人数。这一事实意味着建议使用奇数个管理者。

要理解为什么,让我们看看如果我们有两个管理者会发生什么。在这种情况下,法定人数是两个,因此如果任何一个管理者宕机,那么就不可能达到法定人数,因此也无法选举领导者。结果,失去一台机器会使整个集群失效。我们增加了一个管理者,但整个集群变得不太容错。在三个管理者的情况下情况会有所不同。然后,法定人数仍然是两个,因此失去一个管理者不会停止整个集群。这是一个事实,即使从技术上讲并不是被禁止的,但只有奇数个管理者是有意义的。

集群中的管理者越多,就涉及到越多与 Raft 相关的操作。然后,“管理者”节点应该被放入排水可用性,以节省它们的资源。

调度策略

到目前为止,我们已经了解到管理者会自动将工作节点分配给任务。在本节中,我们将深入探讨自动分配的含义。我们介绍 Docker Swarm 调度策略以及根据我们的需求进行配置的方法。

Docker Swarm 使用两个标准来选择合适的工作节点:

  • 资源可用性:调度器知道节点上可用的资源。它使用所谓的扩展策略,试图将任务安排在负载最轻的节点上,前提是它符合标签和约束指定的条件。

  • 标签和约束

  • 标签是节点的属性。有些标签是自动分配的,例如node.idnode.hostname;其他可以由集群管理员定义,例如node.labels.segment

  • 约束是服务创建者应用的限制,例如,仅选择具有特定标签的节点

标签分为两类,node.labelsengine.labels。第一类是由运营团队添加的;第二类是由 Docker Engine 收集的,例如操作系统或硬件特定信息。

例如,如果我们想在具体节点ubuntu-worker1上运行 Tomcat 服务,那么我们需要使用以下命令:

$ docker service create --constraint 'node.hostname == ubuntu-worker1' tomcat

我们还可以向节点添加自定义标签:

$ docker node update --label-add segment=AA ubuntu-worker1

上述命令添加了一个标签node.labels.segment,其值为AA。然后,在运行服务时我们可以使用它:

$ docker service create --constraint 'node.labels.segment == AA' tomcat

这个命令只在标记有给定段AA的节点上运行tomcat副本。

标签和约束使我们能够配置服务副本将在哪些节点上运行。尽管这种方法在许多情况下是有效的,但不应该过度使用,因为最好让副本分布在多个节点上,并让 Docker Swarm 负责正确的调度过程。

Docker Compose 与 Docker Swarm

我们已经描述了如何使用 Docker Swarm 来部署一个服务,该服务又从给定的 Docker 镜像中运行多个容器。另一方面,还有 Docker Compose,它提供了一种定义容器之间依赖关系并实现容器扩展的方法,但所有操作都在一个 Docker 主机内完成。我们如何将这两种技术合并起来,以便我们可以指定docker-compose.yml文件,并自动将容器分布在集群上?幸运的是,有 Docker Stack。

介绍 Docker Stack

Docker Stack 是在 Swarm 集群上运行多个关联容器的方法。为了更好地理解它如何将 Docker Compose 与 Docker Swarm 连接起来,让我们看一下下面的图:

Docker Swarm 编排哪个容器在哪台物理机上运行。然而,容器之间没有任何依赖关系,因此为了它们进行通信,我们需要手动链接它们。相反,Docker Compose 提供了容器之间的链接。在前面图中的例子中,一个 Docker 镜像(部署为三个复制的容器)依赖于另一个 Docker 镜像(部署为一个容器)。然而,所有容器都运行在同一个 Docker 主机上,因此水平扩展受限于一台机器的资源。Docker Stack 连接了这两种技术,并允许使用docker-compose.yml文件在一组 Docker 主机上运行链接容器的完整环境。

使用 Docker Stack

举个例子,让我们使用依赖于redis镜像的calculator镜像。让我们将这个过程分为四个步骤:

  1. 指定docker-compose.yml

  2. 运行 Docker Stack 命令。

  3. 验证服务和容器。

  4. 移除堆栈。

指定 docker-compose.yml

我们已经在前面的章节中定义了docker-compose.yml文件,它看起来类似于以下内容:

version: "3"
services:
    calculator:
        deploy:
            replicas: 3
        image: leszko/calculator:latest
        ports:
        - "8881:8080"
    redis:
        deploy:
            replicas: 1
        image: redis:latest

请注意,所有镜像在运行docker stack命令之前必须推送到注册表,以便它们可以从所有节点访问。因此,不可能在docker-compose.yml中构建镜像。

使用所提供的 docker-compose.yml 配置,我们将运行三个calculator容器和一个redis容器。计算器服务的端点将在端口8881上公开。

运行 docker stack 命令

让我们使用docker stack命令来运行服务,这将在集群上启动容器:

$ docker stack deploy --compose-file docker-compose.yml app
Creating network app_default
Creating service app_redis
Creating service app_calculator

Docker 计划简化语法,以便不需要stack这个词,例如,docker deploy --compose-file docker-compose.yml app。在撰写本文时,这仅在实验版本中可用。

验证服务和容器

服务已经启动。我们可以使用docker service ls命令来检查它们是否正在运行:

$ docker service ls
ID            NAME            MODE        REPLICAS  IMAGE
5jbdzt9wolor  app_calculator  replicated  3/3       leszko/calculator:latest
zrr4pkh3n13f  app_redis       replicated  1/1       redis:latest

我们甚至可以更仔细地查看服务,并检查它们部署在哪些 Docker 主机上:

$ docker service ps app_calculator
ID            NAME              IMAGE                     NODE  DESIRED STATE  CURRENT STATE 
jx0ipdxwdilm  app_calculator.1  leszko/calculator:latest  ubuntu-manager  Running    Running 57 seconds ago 
psweuemtb2wf  app_calculator.2  leszko/calculator:latest  ubuntu-worker1  Running    Running about a minute ago 
iuas0dmi7abn  app_calculator.3  leszko/calculator:latest  ubuntu-worker2  Running    Running 57 seconds ago 

$ docker service ps app_redis
ID            NAME         IMAGE         NODE            DESIRED STATE  CURRENT STATE 
8sg1ybbggx3l  app_redis.1  redis:latest  ubuntu-manager  Running  Running about a minute ago    

我们可以看到,ubuntu-manager机器上启动了一个calculator容器和一个redis容器。另外两个calculator容器分别在ubuntu-worker1ubuntu-worker2机器上运行。

请注意,我们明确指定了calculator web 服务应该发布的端口号。因此,我们可以通过管理者的 IP 地址http://192.168.0.143:8881/sum?a=1&b=2来访问端点。操作返回3作为结果,并将其缓存在 Redis 容器中。

移除 stack

当我们完成了 stack,我们可以使用方便的docker stack rm命令来删除所有内容:

$ docker stack rm app
Removing service app_calculator
Removing service app_redis
Removing network app_default

使用 Docker Stack 允许在 Docker Swarm 集群上运行 Docker Compose 规范。请注意,我们使用了确切的docker-compose.yml格式,这是一个很大的好处,因为对于 Swarm,不需要指定任何额外的内容。

这两种技术的合并使我们能够在 Docker 上部署应用程序的真正力量,因为我们不需要考虑单独的机器。我们只需要指定我们的(微)服务如何相互依赖,用 docker-compose.yml 格式表达出来,然后让 Docker 来处理其他一切。物理机器可以简单地被视为一组资源。

替代集群管理系统

Docker Swarm 不是唯一用于集群 Docker 容器的系统。尽管它是开箱即用的系统,但可能有一些有效的理由安装第三方集群管理器。让我们来看一下最受欢迎的替代方案。

Kubernetes

Kubernetes 是一个由谷歌最初设计的开源集群管理系统。尽管它不是 Docker 原生的,但集成非常顺畅,而且有许多额外的工具可以帮助这个过程;例如,kompose 可以将 docker-compose.yml 文件转换成 Kubernetes 配置文件。

让我们来看一下 Kubernetes 的简化架构:

Kubernetes 和 Docker Swarm 类似,它也有主节点和工作节点。此外,它引入了 pod 的概念,表示一组一起部署和调度的容器。大多数 pod 都有几个容器组成一个服务。Pod 根据不断变化的需求动态构建和移除。

Kubernetes 相对较年轻。它的开发始于 2014 年;然而,它基于谷歌的经验,这是它成为市场上最受欢迎的集群管理系统之一的原因之一。越来越多的组织迁移到 Kubernetes,如 eBay、Wikipedia 和 Pearson。

Apache Mesos

Apache Mesos 是一个在 2009 年由加州大学伯克利分校发起的开源调度和集群系统,早在 Docker 出现之前就开始了。它提供了一个在 CPU、磁盘空间和内存上的抽象层。Mesos 的一个巨大优势是它支持任何 Linux 应用程序,不一定是(Docker)容器。这就是为什么可以创建一个由数千台机器组成的集群,并且用于 Docker 容器和其他程序,例如基于 Hadoop 的计算。

让我们来看一下展示 Mesos 架构的图:

Apache Mesos,类似于其他集群系统,具有主从架构。它使用安装在每个节点上的节点代理进行通信,并提供两种类型的调度器,Chronos - 用于 cron 风格的重复任务和 Marathon - 提供 REST API 来编排服务和容器。

与其他集群系统相比,Apache Mesos 非常成熟,并且已经被许多组织采用,如 Twitter、Uber 和 CERN。

比较功能

Kubernetes、Docker Swarm 和 Mesos 都是集群管理系统的不错选择。它们都是免费且开源的,并且它们都提供重要的集群管理功能,如负载均衡、服务发现、分布式存储、故障恢复、监控、秘钥管理和滚动更新。它们在持续交付过程中也可以使用,没有太大的区别。这是因为,在 Docker 化的基础设施中,它们都解决了同样的问题,即 Docker 容器的集群化。然而,显然,这些系统并不完全相同。让我们看一下表格,展示它们之间的区别:

Docker Swarm Kubernetes Apache Mesos
Docker 支持 本机支持 支持 Docker 作为 Pod 中的容器类型之一 Mesos 代理(从属)可以配置为托管 Docker 容器
应用程序类型 Docker 镜像 容器化应用程序(Docker、rkt 和 hyper) 任何可以在 Linux 上运行的应用程序(也包括容器)
应用程序定义 Docker Compose 配置 Pod 配置,副本集,复制控制器,服务和部署 以树形结构形成的应用程序组
设置过程 非常简单 根据基础设施的不同,可能需要运行一个命令或者进行许多复杂的操作 相当复杂,需要配置 Mesos、Marathon、Chronos、Zookeeper 和 Docker 支持
API Docker REST API REST API Chronos/Marathon REST API
用户界面 Docker 控制台客户端,Shipyard 等第三方 Web 应用 控制台工具,本机 Web UI(Kubernetes 仪表板) Mesos、Marathon 和 Chronos 的官方 Web 界面
云集成 需要手动安装 大多数提供商(Azure、AWS、Google Cloud 等)提供云原生支持 大多数云提供商提供支持
最大集群大小 1,000 个节点 1,000 个节点 50,000 个节点
自动扩展 不可用 根据观察到的 CPU 使用情况提供水平 Pod 自动扩展 Marathon 根据资源(CPU/内存)消耗、每秒请求的数量和队列长度提供自动扩展

显然,除了 Docker Swarm、Kubernetes 和 Apache Mesos 之外,市场上还有其他可用的集群系统。然而,它们并不那么受欢迎,它们的使用量随着时间的推移而减少。

无论选择哪个系统,您都可以将其用于暂存/生产环境,也可以用于扩展 Jenkins 代理。让我们看看如何做到这一点。

扩展 Jenkins

服务器集群的明显用例是暂存和生产环境。在使用时,只需连接物理机即可增加环境的容量。然而,在持续交付的背景下,我们可能还希望通过在集群上运行 Jenkins 代理(从属)节点来改进 Jenkins 基础设施。在本节中,我们将看两种不同的方法来实现这个目标。

动态从属配置

我们在《配置 Jenkins》的第三章中看到了动态从属配置。使用 Docker Swarm,这个想法保持完全一样。当构建开始时,Jenkins 主服务器会从 Jenkins 从属 Docker 镜像中运行一个容器,并在容器内执行 Jenkinsfile 脚本。然而,Docker Swarm 使解决方案更加强大,因为我们不再局限于单个 Docker 主机,而是可以提供真正的水平扩展。向集群添加新的 Docker 主机有效地扩展了 Jenkins 基础设施的容量。

在撰写本文时,Jenkins Docker 插件不支持 Docker Swarm。其中一个解决方案是使用 Kubernetes 或 Mesos 作为集群管理系统。它们每个都有一个专用的 Jenkins 插件:Kubernetes 插件(wiki.jenkins.io/display/JENKINS/Kubernetes+Plugin)和 Mesos 插件(wiki.jenkins.io/display/JENKINS/Mesos+Plugin)。

无论从属是如何配置的,我们总是通过安装适当的插件并在 Manage Jenkins | Configure System 的 Cloud 部分中添加条目来配置它们。

Jenkins Swarm

如果我们不想使用动态从属配置,那么集群化 Jenkins 从属的另一个解决方案是使用 Jenkins Swarm。我们在《配置 Jenkins》的第三章中描述了如何使用它。在这里,我们为 Docker Swarm 添加描述。

首先,让我们看看如何使用从 swarm-client.jar 工具构建的 Docker 镜像来运行 Jenkins Swarm 从属。Docker Hub 上有一些可用的镜像;我们可以使用 csanchez/jenkins-swarm-slave 镜像:

$ docker run csanchez/jenkins-swarm-slave:1.16 -master -username -password -name jenkins-swarm-slave-2

该命令执行应该与第三章中介绍的具有完全相同的效果,配置 Jenkins;它动态地向 Jenkins 主节点添加一个从节点。

然后,为了充分利用 Jenkins Swarm,我们可以在 Docker Swarm 集群上运行从节点容器:

$ docker service create --replicas 5 --name jenkins-swarm-slave csanchez/jenkins-swarm-slave -master -disableSslVerification -username -password -name jenkins-swarm-slave

上述命令在集群上启动了五个从节点,并将它们附加到了 Jenkins 主节点。请注意,通过执行 docker service scale 命令,可以非常简单地通过水平扩展 Jenkins。

动态从节点配置和 Jenkins Swarm 的比较

动态从节点配置和 Jenkins Swarm 都可以在集群上运行,从而产生以下图表中呈现的架构:

Jenkins 从节点在集群上运行,因此非常容易进行水平扩展和缩减。如果我们需要更多的 Jenkins 资源,我们就扩展 Jenkins 从节点。如果我们需要更多的集群资源,我们就向集群添加更多的物理机器。

这两种解决方案之间的区别在于,动态从节点配置会在每次构建之前自动向集群添加一个 Jenkins 从节点。这种方法的好处是,我们甚至不需要考虑此刻应该运行多少 Jenkins 从节点,因为数量会自动适应流水线构建的数量。这就是为什么在大多数情况下,动态从节点配置是首选。然而,Jenkins Swarm 也具有一些显著的优点:

  • 控制从节点数量:使用 Jenkins Swarm,我们明确决定此刻应该运行多少 Jenkins 从节点。

  • 有状态的从节点:许多构建共享相同的 Jenkins 从节点,这可能听起来像一个缺点;然而,当一个构建需要从互联网下载大量依赖库时,这就成为了一个优势。在动态从节点配置的情况下,为了缓存这些依赖,我们需要设置一个共享卷。

  • 控制从节点运行的位置:使用 Jenkins Swarm,我们可以决定不在集群上运行从节点,而是动态选择主机;例如,对于许多初创公司来说,当集群基础设施成本高昂时,从节点可以动态地在开始构建的开发人员的笔记本电脑上运行。

集群化 Jenkins 从属节点带来了许多好处,这就是现代 Jenkins 架构应该看起来的样子。这样,我们可以为持续交付过程提供动态的水平扩展基础设施。

练习

在本章中,我们详细介绍了 Docker Swarm 和集群化过程。为了增强这方面的知识,我们建议进行以下练习:

  1. 建立一个由三个节点组成的 Swarm 集群:
    • 使用一台机器作为管理节点,另外两台机器作为工作节点
  • 您可以使用连接到一个网络的物理机器,来自云提供商的机器,或者具有共享网络的 VirtualBox 机器

  • 使用 docker node 命令检查集群是否正确配置

  1. 在集群上运行/扩展一个 hello world 服务:
    • 服务可以与第二章中描述的完全相同
  • 发布端口,以便可以从集群外部访问

  • 将服务扩展到五个副本

  • 向“hello world”服务发出请求,并检查哪个容器正在提供请求

  1. 使用在 Swarm 集群上部署的从属节点来扩展 Jenkins:
    • 使用 Jenkins Swarm 或动态从属节点供应
  • 运行管道构建并检查它是否在其中一个集群化的从属节点上执行

总结

在本章中,我们看了一下 Docker 环境的集群化方法,这些方法可以实现完整的分段/生产/Jenkins 环境的设置。以下是本章的要点:

  • 聚类是一种配置一组机器的方法,从许多方面来看,它可以被视为一个单一的系统

  • Docker Swarm 是 Docker 的本地集群系统

  • 可以使用内置的 Docker 命令动态配置 Docker Swarm 集群

  • 可以使用 docker service 命令在集群上运行和扩展 Docker 镜像

  • Docker Stack 是在 Swarm 集群上运行 Docker Compose 配置的方法

  • 支持 Docker 的最流行的集群系统是 Docker Swarm、Kubernetes 和 Apache Mesos

  • Jenkins 代理可以使用动态从属节点供应或 Jenkins Swarm 插件在集群上运行

在下一章中,我们将描述持续交付过程的更高级方面,并介绍构建流水线的最佳实践

第九章:高级持续交付

在上一章中,我们介绍了服务器集群的工作原理以及如何与 Docker 和 Jenkins 一起使用。在本章中,我们将看到一系列不同方面的内容,这些内容在持续交付过程中非常重要,但尚未被描述。

本章涵盖以下要点:

  • 解释如何在持续交付的背景下处理数据库更改

  • 介绍数据库迁移及相关工具的概念

  • 探索向后兼容和不向后兼容的数据库更新的不同方法

  • 在 Jenkins 流水线中使用并行步骤

  • 创建 Jenkins 共享库

  • 介绍回滚生产更改的方法

  • 为传统系统引入持续交付

  • 探索如何准备零停机部署

  • 介绍持续交付的最佳实践

管理数据库更改

到目前为止,我们已经专注于应用于 Web 服务的持续交付过程。其中一个简单的部分是 Web 服务本质上是无状态的。这意味着它们可以很容易地更新、重新启动、在许多实例中克隆,并且可以从给定的源代码重新创建。然而,Web 服务通常与其有状态部分——数据库相关联,这给交付过程带来了新的挑战。这些挑战可以分为以下几类:

  • 兼容性:数据库架构和数据本身必须始终与 Web 服务兼容

  • 零停机部署:为了实现零停机部署,我们使用滚动更新,这意味着数据库必须同时与两个不同的 Web 服务版本兼容

  • 回滚:数据库的回滚可能很困难,受限制,有时甚至是不可能的,因为并非所有操作都是可逆的(例如,删除包含数据的列)

  • 测试数据:与数据库相关的更改很难测试,因为我们需要与生产环境非常相似的测试数据

在本节中,我将解释如何解决这些挑战,以便持续交付过程尽可能安全。

理解模式更新

如果你考虑交付过程,实际上并不是数据本身造成了困难,因为当我们部署一个应用程序时,我们通常不会改变数据。数据是在系统在生产环境中运行时收集的;而在部署过程中,我们只是改变了存储和解释这些数据的方式。换句话说,在持续交付过程的背景下,我们对数据库的结构感兴趣,而不是它的内容。这就是为什么这一部分主要涉及关系数据库(及其模式),并且对其他类型的存储,如 NoSQL 数据库,关注较少,因为它们没有结构定义。

为了更好地理解这一点,我们可以想象一下 Redis,我们在本书中已经使用过。它存储了缓存数据,因此实际上它是一个数据库。然而,从持续交付的角度来看,它不需要任何努力,因为它没有任何数据结构。它存储的只是键值条目,这些条目随时间不会发生变化。

NoSQL 数据库通常没有任何限制模式,因此简化了持续交付过程,因为不需要额外的模式更新步骤。这是一个巨大的好处;然而,这并不一定意味着使用 NoSQL 数据库编写应用程序更简单,因为我们在源代码中需要更多的努力来进行数据验证。

关系数据库具有静态模式。如果我们想要更改它,例如向表中添加新列,我们需要编写并执行 SQL DDL(数据定义语言)脚本。为每个更改手动执行这个操作需要大量的工作,并且会导致易出错的解决方案,运维团队必须保持代码和数据库结构同步。一个更好的解决方案是以增量方式自动更新模式。这样的解决方案称为数据库迁移。

引入数据库迁移

数据库模式迁移是对关系数据库结构进行增量更改的过程。让我们看一下以下图表,以更好地理解它:

版本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。

  1. 配置 Flyway 工具与 Gradle 一起工作。

  2. 定义 SQL 迁移脚本以创建计算历史表。

  3. 在 Spring Boot 应用程序代码中使用 SQL 数据库。

配置 Flyway

为了将 Flyway 与 Gradle 一起使用,我们需要将以下内容添加到build.gradle文件中:

buildscript {
   dependencies {
       classpath('com.h2database:h2:1.4.191')
    }
}
…
plugins {
   id "org.flywaydb.flyway" version "4.2.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 文件创建了一个具有四列IDABRESULT的表。ID列是表的自动递增主键。现在,我们准备运行 Flyway 命令来应用迁移:

$ ./gradlew flywayMigrate -i
…
Successfully applied 1 migration to schema "PUBLIC" (execution time 00:00.028s).
:flywayMigrate (Thread[Daemon worker Thread 2,5,main]) completed. Took 1.114 secs.

该命令自动检测到迁移文件并在数据库上执行了它。

迁移文件应始终保存在版本控制系统中,通常与源代码一起。

访问数据库

我们执行了第一个迁移,因此数据库已准备就绪。为了查看完整的示例,我们还应该调整我们的项目,以便它可以访问数据库。

首先,让我们配置 Gradle 依赖项以使用 Spring Boot 项目中的 H2 数据库。我们可以通过将以下行添加到build.gradle文件中来实现这一点:

dependencies {
   compile("org.springframework.boot:spring-boot-starter-data-jpa")
   compile("com.h2database:h2")
}

下一步是在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 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.AUTO)
   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;
   }
}

实体类在 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> {}

最后,我们可以使用CalculationCalculationRepository类来存储计算历史。让我们将以下代码添加到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;
   }
}

现在,当我们启动服务并执行/sum端点时,每个求和操作都会记录到数据库中。

如果您想浏览数据库内容,那么您可以将spring.h2.console.enabled=true添加到application.properties文件中,然后通过/h2-console端点浏览数据库。

我们解释了数据库模式迁移的工作原理以及如何在使用 Gradle 构建的 Spring 项目中使用它。现在,让我们看看它如何在持续交付过程中集成。

在持续交付中更改数据库

在持续交付管道中使用数据库更新的第一种方法可能是在迁移命令执行中添加一个阶段。这个简单的解决方案对许多情况都能正确工作;然而,它有两个重大缺点:

  • 回滚:如前所述,不总是可能回滚数据库更改(Flyway 根本不支持降级)。因此,在服务回滚的情况下,数据库变得不兼容。

  • 停机时间:服务更新和数据库更新并非完全同时执行,这会导致停机时间。

这导致我们需要解决的两个约束:

  • 数据库版本需要始终与服务版本兼容

  • 数据库模式迁移是不可逆的

我们将针对两种不同情况解决这些约束:向后兼容的更新和非向后兼容的更新。

向后兼容的更改

向后兼容的更改更简单。让我们看一下以下图表,看看它们是如何工作的:

假设模式迁移“数据库 v10”是向后兼容的。如果我们需要回滚“服务 v1.2.8”版本,那么我们部署“服务 v1.2.7”,并且不需要对数据库做任何操作(数据库迁移是不可逆的,所以我们保留“数据库 v11”)。由于模式更新是向后兼容的,“服务 v.1.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 版本。在这种情况下,我们如何处理不向后兼容的数据库迁移,以便回滚和零停机部署是可能的呢?

长话短说,我们可以通过将不向后兼容的更改转换为在一定时间内向后兼容的更改来解决这个问题。换句话说,我们需要额外努力并将模式迁移分为两部分:

  • 现在执行的向后兼容更新通常意味着保留一些冗余数据

  • 在回滚期限之后执行的不向后兼容更新定义了我们可以回滚代码的时间范围

为了更好地说明这一点,让我们看一下以下图片:

让我们考虑一个删除列的例子。一个提议的方法包括两个步骤:

  1. 停止在源代码中使用该列(v1.2.5,向后兼容的更新,首先执行)。

  2. 从数据库中删除列(v11,不向后兼容的更新,在回滚期之后执行)。

直到Database v11的所有服务版本都可以回滚到任何以前的版本,从Service v1.2.8开始的服务只能在回滚期内回滚。这种方法可能听起来很琐碎,因为我们所做的一切只是延迟了从数据库中删除列。但是,它解决了回滚问题和零停机部署问题。因此,它减少了与发布相关的风险。如果我们将回滚期调整为合理的时间,例如,每天多次发布到两周,则风险可以忽略不计。我们通常不会回滚很多版本。

删除列是一个非常简单的例子。让我们看一个更困难的情景,并在我们的计算器服务中重命名结果列。我们将在几个步骤中介绍如何做到这一点:

  1. 向数据库添加新列。

  2. 更改代码以使用两列。

  3. 合并两列中的数据。

  4. 从代码中删除旧列。

  5. 从数据库中删除旧列。

向数据库添加新列

假设我们需要将result列重命名为sum。第一步是添加一个将是重复的新列。我们必须创建一个src/main/resources/db/migration/V3__Add_sum_column.sql迁移文件:

alter table CALCULATION
add SUM varchar(100);

因此,在执行迁移后,我们有两列:resultsum

更改代码以使用两列

下一步是在源代码模型中重命名列,并将两个数据库列用于设置和获取操作。我们可以在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()方法中,我们应该比较类似最后修改列日期的内容(不一定总是首先使用新列)。

从现在开始,每当我们向数据库添加一行时,相同的值将被写入resultsum列。在读取sum时,我们首先检查它是否存在于新列中,如果不存在,则从旧列中读取。

可以通过使用数据库触发器来实现相同的结果,触发器会自动将相同的值写入两列。

到目前为止,我们所做的所有更改都是向后兼容的,因此我们可以随时回滚服务,到任何我们想要的版本。

合并两个列中的数据

这一步通常在发布稳定后的一段时间内完成。我们需要将旧的result列中的数据复制到新的sum列中。让我们创建一个名为V4__Copy_result_into_sum_column.sql的迁移文件:

update CALCULATION
set CALCULATION.sum = CALCULATION.result
where CALCULATION.sum is null;

我们仍然没有回滚的限制;然而,如果我们需要部署在第 2 步之前的版本,那么这个数据库迁移需要重复执行。

从代码中删除旧列

此时,我们已经将所有数据存储在新列中,因此我们可以在数据模型中开始使用它,而不需要旧列。为了做到这一点,我们需要删除Calculation类中与result相关的所有代码,使其如下所示:

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;

在这一步之后,我们终于完成了列重命名的过程。请注意,我们所做的一切只是稍微复杂了操作,以便将其延长。这减少了向后不兼容的数据库更改的风险,并允许零停机部署。

将数据库更新与代码更改分开

到目前为止,在所有的图表中,我们都提到数据库迁移是与服务发布一起运行的。换句话说,每个提交(意味着每个发布)都包括数据库更改和代码更改。然而,推荐的方法是明确分离存储库的提交是数据库更新还是代码更改。这种方法在下图中呈现:

数据库服务变更分离的好处是,我们可以免费获得向后兼容性检查。想象一下,更改 v11 和 v1.2.7 涉及到一个逻辑更改,例如,向数据库添加一个新列。然后,我们首先提交数据库 v11,这样持续交付流水线中的测试就会检查数据库 v11 是否与服务 v.1.2.6 正常工作。换句话说,它们检查数据库更新 v11 是否向后兼容。然后,我们提交 v1.2.7 的更改,这样流水线就会检查数据库 v11 是否与服务 v1.2.7 正常工作。

数据库-代码分离并不意味着我们必须有两个单独的 Jenkins 流水线。流水线可以始终同时执行,但我们应该将其作为一个良好的实践,即提交要么是数据库更新,要么是代码更改。

总之,数据库架构的更改不应该手动完成。相反,我们应该始终使用迁移工具自动化它们,作为持续交付流水线的一部分执行。我们还应该避免非向后兼容的数据库更新,确保这一点的最佳方法是将数据库和代码更改分别提交到存储库中。

避免共享数据库

在许多系统中,我们可以发现数据库成为了多个服务之间共享的中心点。在这种情况下,对数据库的任何更新都变得更加具有挑战性,因为我们需要在所有服务之间进行协调。

例如,想象一下我们开发了一个在线商店,我们有一个包含以下列的 Customers 表:名字,姓氏,用户名,密码,电子邮件和折扣。有三个服务对客户数据感兴趣:

  • 个人资料管理器:这使得用户数据可以进行编辑

  • 结账处理器:这个处理结账(读取用户名和电子邮件)

  • 折扣管理器:这个分析客户的订单并设置合适的折扣

让我们看一下下面的图片,展示了这种情况:

它们依赖于相同的数据库架构。这种方法至少存在两个问题:

  • 当我们想要更新架构时,它必须与这三个服务兼容。虽然所有向后兼容的更改都没问题,但任何非向后兼容的更新都变得更加困难甚至不可能。

  • 每个服务都有一个独立的交付周期和独立的持续交付管道。那么,我们应该使用哪个管道进行数据库架构迁移?不幸的是,对于这个问题没有一个好的答案。

基于之前提到的原因,每个服务应该有自己的数据库,并且服务应该通过它们的 API 进行通信。根据我们的例子,我们可以应用以下的重构:

  • 结账处理器应该与档案管理器的 API 通信,以获取客户的数据

  • 折扣列应该被提取到一个单独的数据库(或架构)中,并且折扣管理器应该负责

重构后的版本如下图所示:

这种方法与微服务架构的原则一致,应该始终被应用。通过 API 的通信比直接访问数据库更加灵活。

在单体系统的情况下,数据库通常是集成点。由于这种方法会引起很多问题,被认为是一种反模式。

准备测试数据

我们已经介绍了保持数据库架构一致的数据库迁移,这是一个副作用。这是因为如果我们在开发机器上、在暂存环境中或者在生产环境中运行相同的迁移脚本,我们总是会得到相同的架构结果。然而,表内的数据值是不同的。我们如何准备测试数据,以有效地测试我们的系统?这就是本节的主题。

这个问题的答案取决于测试的类型,对于单元测试、集成/验收测试和性能测试是不同的。让我们分别来看每种情况。

单元测试

在单元测试的情况下,我们不使用真实的数据库。我们要么在持久化机制的层面(仓库、数据访问对象)模拟测试数据,要么用内存数据库(例如 H2 数据库)伪造真实的数据库。由于单元测试是由开发人员创建的,确切的数据值通常是由开发人员虚构的,而且并不重要。

集成/验收测试

集成和验收测试通常使用测试/暂存数据库,该数据库应尽可能与生产环境相似。许多公司采取的一种方法是将生产数据快照到暂存,以确保两者完全相同。然而,出于以下原因,这种方法被视为反模式:

  • 测试隔离:每个测试都在同一个数据库上操作,因此一个测试的结果可能会影响其他测试的输入

  • 数据安全性:生产实例通常存储敏感信息,因此更容易得到保护。

  • 可重现性:每次快照后,测试数据都是不同的,这可能导致测试不稳定

基于上述原因,首选的方法是通过与客户或业务分析师一起手动准备测试数据,选择生产数据的子集。当生产数据库增长时,值得重新审视其内容,看是否有任何应该添加的合理情况。

将数据添加到暂存数据库的最佳方法是使用服务的公共 API。这种方法与通常是黑盒的验收测试一致。而且,使用 API 可以保证数据本身的一致性,并通过限制直接数据库操作简化数据库重构。

性能测试

性能测试的测试数据通常类似于验收测试。一个重要的区别是数据量。为了正确测试性能,我们需要提供足够数量的输入数据,至少与生产环境(在高峰时段)的数据量一样大。为此,我们可以创建数据生成器,通常在验收和性能测试之间共享。

管道模式

我们已经知道启动项目并使用 Jenkins 和 Docker 设置持续交付管道所需的一切。本节旨在通过一些推荐的 Jenkins 管道实践来扩展这些知识。

并行化管道

在整本书中,我们总是按顺序执行流水线,一步一步地进行。这种方法使得很容易理清构建的状态和结果。如果首先是验收测试阶段,然后是发布阶段,这意味着在验收测试成功之前,发布永远不会发生。顺序流水线易于理解,通常不会引起任何意外。这就是为什么解决任何问题的第一种方法是按顺序进行。

然而,在某些情况下,阶段是耗时的,值得并行运行它们。一个很好的例子是性能测试。它们通常需要很长时间,因此假设它们是独立和隔离的,将它们并行运行是有意义的。在 Jenkins 中,我们可以在两个不同的级别上并行化流水线:

  • 并行步骤:在一个阶段内,并行进程在同一代理上运行。这种方法很简单,因为所有与 Jenkins 工作区相关的文件都位于一台物理机器上,但是,与垂直扩展一样,资源仅限于该单一机器。

  • 并行阶段:每个阶段可以在提供资源水平扩展的单独代理机器上并行运行。如果需要在另一台物理机器上使用前一阶段创建的文件,我们需要注意环境之间的文件传输(使用stash Jenkinsfile 关键字)。

在撰写本书时,声明性流水线中并行阶段是不可用的。该功能应该在 Jenkins Blue Ocean v1.3 中添加。与此同时,唯一的可能性是使用基于 Groovy 的脚本流水线中的弃用功能,如此处所述:jenkins.io/doc/book/pipeline/jenkinsfile/#executing-in-parallel

让我们看看实际操作中是什么样子。如果我们想要并行运行两个步骤,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"   
           }
       }
   }
}

阶段 1中,使用parallel关键字,我们执行两个并行步骤,onetwo。请注意,只有在两个并行步骤都完成后,才会执行阶段 2。这就是为什么这样的解决方案非常安全地并行运行测试;我们始终可以确保只有在所有并行测试都已通过后,才会运行部署阶段。

有一个非常有用的插件叫做并行测试执行器,它可以帮助自动拆分测试并并行运行它们。在jenkins.io/doc/pipeline/steps/parallel-test-executor/上阅读更多。

前面的描述涉及到并行步骤级别。另一个解决方案是使用并行阶段,因此在单独的代理机器上运行每个阶段。选择使用哪种类型的并行通常取决于两个因素:

  • 代理机器的强大程度

  • 给定阶段需要多少时间

作为一般建议,单元测试可以并行运行,但性能测试通常最好在单独的机器上运行。

重用管道组件

当 Jenkinsfile 脚本变得越来越复杂时,我们可能希望在相似的管道之间重用其部分。

例如,我们可能希望为不同的环境(开发、QA、生产)拥有单独但相似的管道。在微服务领域的另一个常见例子是,每个服务都有一个非常相似的 Jenkinsfile。那么,我们如何编写 Jenkinsfile 脚本,以便不重复编写相同的代码?为此有两种好的模式,参数化构建和共享库。让我们逐一描述它们。

构建参数

我们已经在第四章中提到,持续集成管道,管道可以有输入参数。我们可以使用它们来为相同的管道代码提供不同的用例。例如,让我们创建一个带有环境类型参数的管道:

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}"   
           }
       }
   }
}

构建需要一个输入参数,环境。然后,在这一步中,我们所做的就是打印参数。我们还可以添加一个条件,以执行不同环境的不同代码。

有了这个配置,当我们开始构建时,我们将看到一个输入参数的提示,如下所示:

参数化构建可以帮助重用管道代码,适用于只有少许不同的情况。然而,不应该过度使用这个功能,因为太多的条件会使 Jenkinsfile 难以理解。

共享库

重用管道的另一个解决方案是将其部分提取到共享库中。

共享库是作为单独的源代码控制项目存储的 Groovy 代码。此代码可以稍后用作许多 Jenkinsfile 脚本的管道步骤。为了明确起见,让我们看一个例子。共享库技术始终需要三个步骤:

  1. 创建一个共享库项目。

  2. 在 Jenkins 中配置共享库。

  3. 在 Jenkins 文件中使用共享库。

创建一个共享库项目

我们首先创建一个新的 Git 项目,在其中放置共享库代码。每个 Jenkins 步骤都表示为位于vars目录中的 Groovy 文件。

让我们创建一个sayHello步骤,它接受name参数并回显一个简单的消息。这应该存储在vars/sayHello.groovy文件中:

/
* Hello world step.
*/
def call(String name) {   
   echo "Hello $name!"
}

共享库步骤的可读性描述可以存储在*.txt文件中。在我们的例子中,我们可以添加带有步骤文档的vars/sayHello.txt文件。

当库代码完成时,我们需要将其推送到存储库,例如,作为一个新的 GitHub 项目。

在 Jenkins 中配置共享库

下一步是在 Jenkins 中注册共享库。我们打开“管理 Jenkins | 配置系统”,找到全局管道库部分。在那里,我们可以添加一个选择的名称的库,如下所示:

我们指定了库注册的名称和库存储库地址。请注意,库的最新版本将在管道构建期间自动下载。

我们介绍了将 Groovy 代码导入为全局共享库,但也有其他替代解决方案。更多信息请阅读jenkins.io/doc/book/pipeline/shared-libraries/

在 Jenkinsfile 中使用共享库

最后,我们可以在 Jenkinsfile 脚本中使用共享库。

让我们看一个例子:

pipeline {
   agent any
   stages {
       stage("Hello stage") {
           steps {
           sayHello 'Rafal'
         }
       }
   }
}

如果在 Jenkins 配置中没有选中“隐式加载”,那么我们需要在 Jenkinsfile 脚本的开头添加"@Library('example') _"。

正如您所看到的,我们可以使用 Groovy 代码作为管道步骤sayHello。显然,在管道构建完成后,在控制台输出中,我们应该看到Hello Rafal!

共享库不限于一个步骤。实际上,借助 Groovy 语言的强大功能,它们甚至可以作为整个 Jenkins 管道的模板。

回滚部署

我记得我的一位资深架构师同事说过:“你不需要更多的质量保证人员,你需要更快的回滚。”尽管这种说法过于简化,而且质量保证团队通常是非常有价值的,但这句话中有很多真理。想想看;如果你在生产环境引入了一个 bug,但在第一个用户报告错误后很快回滚,通常不会发生什么坏事。另一方面,如果生产错误很少,但没有进行回滚,那么通常会导致在长时间的失眠夜晚和一些不满意的用户中调试生产过程。这就是为什么我们在创建 Jenkins 流水线时需要事先考虑回滚策略。

在持续交付的背景下,有两个可能发生失败的时刻:

  • 在发布过程中,在流水线执行中

  • 流水线构建完成后,在生产环境中

第一种情况非常简单且无害。它涉及到应用程序已经部署到生产环境,但接下来的阶段失败,例如,冒烟测试失败。那么,我们需要做的就是在“失败”情况的“后”流水线部分执行一个脚本,将生产服务降级到较旧的 Docker 镜像版本。如果我们使用蓝绿部署(如本章后面描述的),那么任何停机时间的风险都是最小的,因为通常我们会在冒烟测试之后的最后一个流水线阶段执行负载均衡器切换。

第二种情况是,当我们在成功完成流水线后注意到生产 bug 时,情况更加困难,需要一些评论。在这种情况下,规则是我们应该始终使用与标准发布完全相同的流程来发布回滚的服务。否则,如果我们尝试以更快的方式手动操作,那么我们就是在自找麻烦。任何非重复性的任务都是有风险的,尤其是在压力下,当生产环境处于无序状态时。

顺便说一句,如果流水线顺利完成但出现了生产 bug,那么这意味着我们的测试还不够好。因此,在回滚之后的第一件事是扩展单元/验收测试套件,以涵盖相应的场景。

最常见的持续交付流程是一个完全自动化的流水线,从检出代码开始,以发布到生产结束。

以下图表显示了这是如何工作的:

在本书中,我们已经介绍了经典的持续交付管道。如果回滚应该使用完全相同的流程,那么我们需要做的就是从存储库中恢复最新的代码更改。结果,管道会自动构建、测试,最后发布正确的版本。

存储库回滚和紧急修复不应跳过管道中的测试阶段。否则,我们可能会因为其他问题导致发布仍然无法正常工作,使得调试变得更加困难。

解决方案非常简单而优雅。唯一的缺点是我们需要花费时间在完整的管道构建上。如果使用蓝绿部署或金丝雀发布,可以避免这种停机时间,在这种情况下,我们只需更改负载均衡器设置以解决健康环境。

在编排发布的情况下,回滚操作变得更加复杂,因为在此期间许多服务同时部署。这是编排发布被视为反模式的原因之一,特别是在微服务世界中。正确的方法是始终保持向后兼容,至少在一段时间内(就像我们在本章开头为数据库所介绍的那样)。然后,可以独立发布每个服务。

添加手动步骤

一般来说,持续交付管道应该是完全自动化的,由对存储库的提交触发,并在发布后结束。然而,有时我们无法避免出现手动步骤。最常见的例子是发布批准,这意味着流程是完全自动化的,但有一个手动步骤来批准新发布。另一个常见的例子是手动测试。其中一些可能是因为我们在传统系统上操作;另一些可能是因为某些测试根本无法自动化。无论原因是什么,有时除了添加手动步骤别无选择。

Jenkins 语法提供了一个关键字input用于手动步骤:

stage("Release approval") {
   steps {
       input "Do you approve the release?"
   }
}

管道将在“输入”步骤上停止执行,并等待手动批准。

请记住,手动步骤很快就会成为交付过程中的瓶颈,这就是为什么它们应该始终被视为次于完全自动化的解决方案的原因。

有时设置输入的超时时间是有用的,以避免无限等待手动交互。在配置的时间过去后,整个管道将被中止。

发布模式

在上一节中,我们讨论了用于加快构建执行(并行步骤)、帮助代码重用(共享库)、限制生产错误风险(回滚)和处理手动批准(手动步骤)的 Jenkins 流水线模式。本节介绍了下一组模式,这次与发布过程有关。它们旨在减少将生产环境更新到新软件版本的风险。

我们已经在《使用 Docker Swarm 进行集群化》的第八章中描述了一个发布模式,即滚动更新。在这里,我们介绍另外两种:蓝绿部署和金丝雀发布。

蓝绿部署

蓝绿部署是一种减少发布相关停机时间的技术。它涉及拥有两个相同的生产环境,一个称为绿色,另一个称为蓝色,如下图所示:

在图中,当前可访问的环境是蓝色的。如果我们想进行新的发布,那么我们将所有内容部署到绿色环境,并在发布过程结束时将负载均衡器切换到绿色环境。结果,用户突然开始使用新版本。下次我们想进行发布时,我们对蓝色环境进行更改,并在最后将负载均衡器切换到蓝色。每次都是这样进行,从一个环境切换到另一个环境。

蓝绿部署技术在两个假设条件下能够正常工作:环境隔离和无编排发布。

这种解决方案带来了两个重要的好处:

  • 零停机时间:从用户角度来看,所有停机时间都是可以忽略不计的负载平衡切换时刻

  • 回滚:为了回滚一个版本,只需将负载平衡切换回蓝绿部署包括:

  • 数据库:模式迁移在回滚时可能会很棘手,因此值得使用本章开头介绍的模式。

  • 事务:运行数据库事务必须移交给新数据库

  • 冗余基础设施/资源:我们需要双倍的资源

有技术和工具可以克服这些挑战,因此蓝绿部署模式在 IT 行业中被高度推荐和广泛使用。

您可以在优秀的 Martin Fowler 博客martinfowler.com/bliki/BlueGreenDeployment.html中阅读有关蓝绿部署技术的更多信息。

金丝雀发布

金丝雀发布是一种减少引入新软件版本风险的技术。与蓝绿部署类似,它使用两个相同的环境,如下图所示:

与蓝绿部署技术类似,发布过程始于在当前未使用的环境中部署新版本。然而,相似之处到此为止。负载均衡器不是切换到新环境,而是仅将选定的用户组链接到新环境。其余所有用户仍然使用旧版本。这样,一些用户可以测试新版本,如果出现错误,只有一小部分用户受到影响。测试期结束后,所有用户都切换到新版本。

这种方法有一些很大的好处:

  • 验收和性能测试:如果在暂存环境中难以进行验收和性能测试,那么可以在生产环境中进行测试,最大程度地减少对一小群用户的影响。

  • 简单回滚:如果新更改导致失败,那么通过将所有用户切换回旧版本来进行回滚。

  • A/B 测试:如果我们不确定新版本在 UX 或性能方面是否比旧版本更好,那么可以将其与旧版本进行比较。

金丝雀发布与蓝绿部署具有相同的缺点。额外的挑战是我们同时运行两个生产系统。尽管如此,金丝雀发布是大多数公司用来帮助发布和测试的一种优秀技术。

您可以在优秀的 Martin Fowler 博客martinfowler.com/bliki/CanaryRelease.html中阅读有关金丝雀发布技术的更多信息。

与遗留系统一起工作

到目前为止,我们所描述的一切都顺利适用于全新项目,为这些项目设置持续交付流水线相对简单。

然而,遗留系统要困难得多,因为它们通常依赖于手动测试和手动部署步骤。在本节中,我们将逐步介绍将持续交付应用于遗留系统的推荐方案。

作为第零步,我建议阅读 Michael Feathers 的一本优秀书籍Working Effectively with Legacy Code。他关于如何处理测试、重构和添加新功能的想法清楚地解决了如何为传统系统自动化交付流程的大部分问题。

对于许多开发人员来说,完全重写传统系统可能比重构更具诱惑力。虽然这个想法从开发人员的角度来看很有趣,但通常这是一个导致产品失败的不良商业决策。您可以在 Joel Spolsky 的博客文章Things You Should Never Do中阅读更多关于重写 Netscape 浏览器的历史,网址为www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i

应用持续交付流程的方式在很大程度上取决于当前项目的自动化、使用的技术、硬件基础设施和当前发布流程。通常,它可以分为三个步骤:

  1. 自动化构建和部署。

  2. 自动化测试。

  3. 重构和引入新功能。

让我们详细看一下。

自动化构建和部署

第一步包括自动化部署过程。好消息是,在我所使用的大多数传统系统中,已经存在一些自动化,例如以 shell 脚本的形式。

无论如何,自动化部署的活动包括以下内容:

  • 构建和打包:通常已经存在一些自动化,例如 Makefile、Ant、Maven、任何其他构建工具配置或自定义脚本。

  • 数据库迁移:我们需要以增量方式开始管理数据库架构。这需要将当前架构作为初始迁移,并使用 Flyway 或 Liquibase 等工具进行所有进一步的更改,如本章中已经描述的。

  • 部署:即使部署过程完全手动,通常也有一个需要转换为自动化脚本的文本/维基页面描述。

  • 可重复配置:在传统系统中,配置文件通常是手动更改的。我们需要提取配置并使用配置管理工具,如第六章中描述的 Ansible 配置管理。

在前面的步骤之后,我们可以将所有内容放入部署流水线,并将其用作手动 UAT(用户验收测试)周期后的自动化阶段。

从流程的角度来看,已经值得开始更频繁地发布。例如,如果发布是每年一次,尝试将其改为每季度一次,然后每月一次。对这一因素的推动将最终导致更快的自动化交付采用。

自动化测试

下一步,通常更加困难,是为系统准备自动化测试。这需要与 QA 团队沟通,以了解他们目前如何测试软件,以便我们可以将所有内容移入自动验收测试套件。这个阶段需要两个步骤:

  • 验收/理智测试套件:我们需要添加自动化测试,以取代 QA 团队的一些回归活动。根据系统的不同,它们可以作为黑盒 Selenium 测试或 Cucumber 测试提供。

  • (虚拟)测试环境:此时,我们应该已经考虑到我们的测试将在哪些环境中运行。通常,为了节省资源并限制所需机器的数量,最好的解决方案是使用 Vagrant 或 Docker 虚拟化测试环境。

最终目标是拥有一个自动化验收测试套件,它将取代开发周期中的整个 UAT 阶段。然而,我们可以从一个理智的测试开始,它将简要检查系统是否从回归的角度正确。

在添加测试场景时,请记住测试套件应该在合理的时间内执行。对于理智测试,通常不到 10 分钟。

重构和引入新功能

当我们至少拥有了基本的回归测试套件时,我们就可以开始添加新功能并重构旧代码。最好一步一步地进行,因为一次性重构通常会导致混乱,从而导致生产故障(与任何特定更改都没有明显关系)。

这个阶段通常包括以下活动:

  • 重构:重构旧代码的最佳地方是新功能预期的地方。以这种方式开始,我们就为新的功能请求做好了准备。

  • 重写:如果我们计划重写部分旧代码,我们应该从最难测试的代码开始。这样,我们不断增加项目中的代码覆盖率。

  • 引入新功能:在实施新功能时,值得使用功能切换模式。然后,如果发生任何不良情况,我们可以快速关闭新功能。实际上,在重构过程中也应该使用相同的模式。

在这个阶段,值得阅读Martin Fowler的一本优秀书籍,重构:改善现有代码的设计

在触及旧代码时,最好遵循先添加通过的单元测试的规则,然后再更改代码。通过这种方法,我们可以依赖自动化来检查我们不会意外改变业务逻辑。

理解人的因素

在向遗留系统引入自动交付过程时,您可能会感到人的因素比其他任何地方都更重要。为了自动化构建过程,我们需要与运维团队进行良好的沟通,他们必须愿意分享他们的知识。同样的情况也适用于手动 QA 团队;他们需要参与编写自动化测试,因为只有他们知道如何测试软件。如果您仔细想想,运维和 QA 团队都需要为以后自动化他们的工作做出贡献。在某个时候,他们可能会意识到他们在公司的未来不稳定,并变得不那么乐于助人。许多公司在引入持续交付过程时遇到困难,因为团队不愿意投入足够的精力。

在本节中,我们讨论了如何应对遗留系统以及它们带来的挑战。如果您正在将项目和组织转变为持续交付方法,那么您可能希望查看持续交付成熟度模型,该模型旨在为采用自动交付的过程提供结构。

可以在developer.ibm.com/urbancode/docs/continuous-delivery-maturity-model/找到持续交付成熟度模型的良好描述。

练习

在本章中,我们已经涵盖了持续交付过程的各个方面。由于熟能生巧,我们建议进行以下练习:

  1. 使用 Flyway 在 MySQL 数据库中创建一个不向后兼容的更改:
  • 使用官方的 Docker 镜像mysql来启动数据库

  • 使用正确的数据库地址、用户名和密码配置 Flyway

  • 创建一个初始迁移,创建一个包含三列的users表:idemailpassword

  • 向表中添加示例数据

  • password列更改为hashed_password,用于存储散列密码

  • 按照本章的描述,将不向后兼容的更改分成三个迁移

  • 您可以使用 MD5 或 SHA 进行散列

  • 检查结果,确保数据库不以明文存储密码

  1. 创建一个 Jenkins 共享库,其中包含构建和单元测试 Gradle 项目的步骤:
  • 为库创建一个单独的存储库

  • 在库中创建两个文件:gradleBuild.groovygradleTest.groovy

  • 编写适当的call方法

  • 将库添加到 Jenkins

  • 在流水线中使用库中的步骤

总结

本章混合了以前未涉及的各种持续交付方面。本章的主要要点如下:

  • 数据库是大多数应用程序的重要组成部分,因此应包含在持续交付过程中。

  • 数据库模式更改存储在版本控制系统中,并由数据库迁移工具管理。

  • 数据库模式更改有两种类型:向后兼容和向后不兼容。虽然第一种类型很简单,但第二种类型需要一些额外的工作(分成多个迁移,分散在时间上)。

  • 数据库不应成为整个系统的中心点。首选解决方案是为每个服务提供其自己的数据库。

  • 交付过程应始终准备好回滚场景。

  • 始终考虑三种发布模式:滚动更新、蓝绿部署和金丝雀发布

  • 传统系统可以分步骤转换为持续交付过程,而不是一次性全部转换。

最佳实践

感谢阅读本书。我希望您已经准备好将持续交付方法引入您的 IT 项目中。作为本书的最后一部分,我提出了前 10 个持续交付实践清单。祝您愉快!

实践 1 - 在团队内部拥有流程!

团队内部拥有整个流程,从接收需求到监控生产。正如有人曾经说过:在开发者的机器上运行的程序是赚不到钱的。这就是为什么有一个小的 DevOps 团队完全拥有产品是很重要的。实际上,这就是 DevOps 的真正含义 - 从开始到结束的开发和运营:

  • 拥有持续交付管道的每个阶段:如何构建软件,验收测试中的需求以及如何发布产品。

  • 避免拥有管道专家!团队的每个成员都应参与创建管道。

  • 找到一种良好的方式在团队成员之间共享当前管道状态(以及生产监控)。最有效的解决方案是团队空间中的大屏幕。

  • 如果开发人员,质量保证和 IT 运维工程师是独立的专家,那么确保他们在一个敏捷团队中共同工作。基于专业知识的分开团队会导致对产品不负责任。

  • 记住,给团队自主权会带来高度的工作满意度和异常的参与度。这将带来出色的产品!

实践 2-自动化一切!

自动化一切,从业务需求(以验收测试的形式)到部署过程。手动描述,带有指导步骤的 wiki 页面,它们很快就会过时,并导致部落知识,使流程变得缓慢,繁琐和不可靠。这反过来又导致需要发布排练,并使每次部署都变得独特。不要走上这条路!一般来说,如果你做某件事第二次,就自动化它:

  • 消除所有手动步骤;它们是错误的根源!整个过程必须是可重复和可靠的。

  • 绝对不要直接在生产环境中进行任何更改!使用配置管理工具代替。

  • 使用完全相同的机制部署到每个环境。

  • 始终包括自动化的冒烟测试,以检查发布是否成功完成。

  • 使用数据库模式迁移来自动化数据库更改。

  • 使用自动维护脚本进行备份和清理。不要忘记删除未使用的 Docker 镜像!

实践 3-对一切进行版本控制!

对一切进行版本控制:软件源代码,构建脚本,自动化测试,配置管理文件,持续交付管道,监控脚本,二进制文件和文档。简直就是一切。让你的工作基于任务,每个任务都会导致对存储库的提交,无论是与需求收集,架构设计,配置还是软件开发有关。任务从敏捷看板开始,最终进入存储库。这样,你就可以保持一个真实的历史和更改原因的单一真相:

  • 严格控制版本控制。一切都意味着一切!

  • 将源代码和配置存储在代码仓库中,将二进制文件存储在构件仓库中,将任务存储在敏捷问题跟踪工具中。

  • 将持续交付管道作为代码开发。

  • 使用数据库迁移并将其存储在仓库中。

  • 以可版本控制的 markdown 文件形式存储文档。

实践 4 - 使用业务语言进行验收测试!

使用面向业务的语言进行验收测试,以改善双方的沟通和对需求的共同理解。与产品负责人密切合作,创建埃里克·埃文所说的“普遍语言”,即业务和技术之间的共同方言。误解是大多数项目失败的根本原因:

  • 创建一个共同的语言,并在项目内使用它。

  • 使用接受测试框架,如 Cucumber 或 FitNesse,帮助业务团队理解并参与其中。

  • 在验收测试中表达业务价值,并在开发过程中不要忘记它们。很容易在无关的主题上花费太多时间!

  • 改进和维护验收测试,使其始终作为回归测试。

  • 确保每个人都知道,通过验收测试套件意味着业务方同意发布软件。

实践 5 - 准备好回滚!

准备好回滚;迟早你会需要这样做。记住,你不需要更多的质量保证人员,你需要更快的回滚。如果在生产环境出现问题,你想做的第一件事就是确保安全,并回到上一个可用版本:

  • 制定回滚策略以及系统宕机时的处理流程

  • 将不兼容的数据库更改拆分为兼容的更改

  • 始终使用相同的交付流程进行回滚和标准发布

  • 考虑引入蓝绿部署或金丝雀发布

  • 不要害怕错误,如果你能快速反应,用户不会离开你!

实践 6 - 不要低估人的影响力

不要低估人的影响力。他们通常比工具更重要。如果 IT 运维团队不帮助你,你就无法自动化交付。毕竟,他们对当前流程有了解。同样适用于质量保证人员、业务人员和所有相关人员。让他们变得重要并参与其中:

  • 让质量保证人员和 IT 运维成为 DevOps 团队的一部分。你需要他们的知识和技能!

  • 为当前正在进行手动操作的成员提供培训,以便他们可以转向自动化。

  • 更青睐非正式沟通和扁平化的组织结构,而不是等级制度和命令。没有善意,你什么都做不了!

实践 7 - 构建可追溯性!

为交付过程和工作系统构建可追溯性。没有比没有日志消息的失败更糟糕的了。监控请求数量、延迟、生产服务器的负载、持续交付管道的状态,以及您能想到的任何能帮助您分析当前软件的东西。要主动!在某个时候,您将需要检查统计数据和日志:

  • 记录管道活动!在失败的情况下,用信息丰富的消息通知团队。

  • 实施对运行系统的适当记录和监控。

  • 使用 Kibana、Grafana 或 Logmatic.io 等专业工具进行系统监控。

  • 将生产监控集成到您的开发生态系统中。考虑在团队共享空间中放置大屏幕,显示当前生产统计数据。

实践 8 - 经常集成!

经常集成,实际上,一直都在!正如有人所说:“持续性比你想象的更频繁”。没有什么比解决合并冲突更令人沮丧的了。持续集成更多关乎团队实践而非工具。每天至少将代码集成到一个代码库中几次。忘掉持续存在的特性分支和大量的本地更改。基于主干的开发和功能切换才是胜利之道!

  • 使用基于主干的开发和功能切换,而不是特性分支。

  • 如果您需要分支或本地更改,请确保您至少每天与团队集成一次。

  • 始终保持主干的健康;确保在合并到基线之前运行测试。

  • 在每次提交到存储库后运行管道,以获得快速反馈循环。

实践 9 - 仅构建一次二进制文件!

仅构建一次二进制文件,并在每个环境中运行相同的二进制文件。无论它们是 Docker 镜像还是 JAR 包的形式,仅构建一次可以消除各种环境引入的差异风险。这也可以节省时间和资源:

  • 仅构建一次,并在各个环境之间传递相同的二进制文件。

  • 使用工件存储库存储和版本化二进制文件。绝对不要使用源代码存储库来做这个目的。

  • 外部化配置并使用配置管理工具在环境之间引入差异。

实践 10-经常发布!

经常发布,最好是在每次提交到存储库后。俗话说,“如果痛苦,就更频繁地做。”每天发布使过程变得可预测和平静。远离陷入罕见的发布习惯。那只会变得更糟,最终你将以每年一次的频率发布,需要三个月的准备期!

  • 重新定义你的完成标准为完成意味着发布。承担整个过程的责任!

  • 使用功能切换来隐藏(对用户)仍在进行中的功能。

  • 使用金丝雀发布和快速回滚来减少生产中的错误风险。

  • 采用零停机部署策略以实现频繁发布。

posted @ 2024-05-06 18:33  绝不原创的飞龙  阅读(75)  评论(0编辑  收藏  举报