Python-持续继承和交付教程-全-

Python 持续继承和交付教程(全)

原文:Python Continuous Integration and Delivery

协议:CC BY-NC-SA 4.0

一、自动化测试

在深入研究如何测试 Python 代码的例子之前,必须更详细地讨论测试的本质。为什么我们要进行测试?我们从他们那里得到了什么?缺点是什么?什么是好的测试;什么是糟糕的测试?我们如何对测试进行分类?我们应该写多少种类型的测试?

1.1 我们想从测试中得到什么?

为什么要费心编写测试呢?我们想写或者至少有测试的原因有很多。

一个测试套件中有几个测试是为了响应不同的需求而编写的,这种情况并不少见。

快速反馈

对代码的每一次修改都伴随着引入错误的风险。研究表明,7%到 20%的 bug 修复会引入新的 bug。 1

如果我们能在这些错误找到客户之前找到它们,那不是很好吗?或者甚至在你的同事看到他们之前?这不仅仅是虚荣心的问题。如果您收到快速反馈,说您引入了一个错误,您更有可能记住您刚刚工作的代码库部分的所有细节,因此当您收到快速反馈时,修复错误往往会快得多。

许多测试用例被编写来给出这种快速反馈循环。您通常可以在将您的更改提交到源代码控制系统之前运行它们,它们使您的工作更加高效,并保持您的源代码控制历史清晰。

信心

与前一点相关,但值得单独提及的是,知道测试套件将为您捕捉简单的错误,您可以获得信心的提升。在大多数基于软件的企业中,有一些关键领域存在严重的错误,可能会危及整个企业。想象一下,作为一名开发人员,你不小心弄乱了一个医疗保健数据管理产品的登录系统,现在人们看到了别人的诊断。或者想象一下,自动计费向客户的信用卡收取了错误的金额。

甚至非软件企业也曾因软件错误而遭遇灾难性的失败。由于软件问题,火星气候轨道器 2 和阿丽亚娜 5 号火箭 3首次发射都遭受了各自运载工具的损失。

他们工作的重要性给软件开发人员带来了情绪压力。自动化测试和良好的开发方法可以帮助减轻这种压力。

即使人们正在开发的软件不是任务关键型的,风险逆境也可能导致开发人员或维护人员尽可能地进行最小的更改,并推迟必要的重构以保持代码的可维护性。一个好的测试套件所提供的信心可以让开发人员做必要的事情来防止代码库变成众所周知的大泥巴球4

调试工具

当开发人员更改代码,从而导致测试失败时,他们希望测试有助于找到 bug。如果测试只是简单地说“有问题”,这比不知道错误要好。如果测试能够提供开始调试的提示,那就更有帮助了。

例如,如果一个测试失败表明函数find_shortest_path引发了一个异常,而不是像预期的那样返回一个路径,那么我们知道要么这个函数(或者它调用的一个函数)中断了,要么它接收到了错误的输入。这是一个更好的调试工具。

设计帮助

极限编程(XP) 5 运动主张你要实践测试驱动开发 (TDD)。也就是说,在您编写任何解决问题的代码之前,您首先要编写一个失败的测试。然后你写足够的代码通过测试。要么你做完,要么你写下一个测试。冲洗并重复。

这有明显的好处:你确保你写的所有代码都有测试覆盖,并且你不会写不必要的或不可及的代码。然而,TDD 实践者也报告说测试优先的方法帮助他们写出了更好的代码。一个方面是,编写测试迫使您考虑实现将具有的应用编程接口(API ),因此您开始在头脑中实现一个更好的计划。另一个原因是纯函数(其返回值仅取决于输入,并且不会产生副作用或从数据库读取数据的函数,等等。)很容易测试。因此,测试优先的方法引导开发人员将算法或业务逻辑从支持逻辑中更好地分离出来。这种关注点的分离是好的软件设计的一个方面。

应该注意的是,并不是每个人都同意这些观点,从经验或论据来看,有些代码比编写代码更难测试,这导致了工作的浪费,因为要求对所有事情都进行测试。尽管如此,测试可以提供的设计帮助是开发人员编写代码的原因之一,因此不应该在这里遗漏。

产品规格

软件项目的大的、统一的规范文档的日子已经过去了。大多数项目都遵循某种迭代开发模型,即使有详细的规范文档,也往往是过时的。

当没有详细的和最新的散文规范时,测试套件可以扮演规范的角色。当人们不确定一个程序在某种情况下应该如何表现时,一个测试可能会提供答案。对于编程语言、数据格式、协议和其他东西,提供一个可以用来验证多个实现的测试套件可能更有意义。

1.2 测试的缺点

对考试可能带来的负面影响保持沉默是不诚实的。这些缺点不应该分散你写测试的注意力,但是意识到它们将帮助你决定测试什么,如何写测试,以及,也许,写多少测试。

努力

编写测试需要时间和精力。因此,当你被赋予实现一个特性的任务时,你不仅要实现这个特性,还要为它编写测试,结果是更多的工作和更少的时间去做其他可能给业务带来直接好处的事情。当然,除非测试提供了足够的时间节约(例如,通过不必修复生产环境中的错误和清理被错误破坏的数据)来分摊编写测试所花费的时间。

要维护的额外代码

测试本身就是代码,必须维护,就像被测试的代码一样。一般来说,您希望使用尽可能少的代码来解决您的问题,因为您拥有的代码越少,需要维护的代码就越少。将代码(包括测试代码)视为负债而非资产。

如果您编写测试以及您的特性和错误修复,当需求改变时,您必须改变这些测试。当重构时,一些测试也需要改变,使得代码库更难改变。

脆性

有些测试可能很脆弱,也就是说,它们偶尔会给出错误的结果。即使有问题的代码是正确的,测试仍然失败,这被称为假阳性。这样的测试失败需要花费时间来调试,而不会提供任何价值。一个假阴性是当测试中的代码被破坏时不会失败的测试。假阴性测试也不提供任何价值,但往往比假阳性更难发现,因为大多数工具会引起对失败测试的注意。

脆弱的测试破坏了对测试套件的信任。如果失败测试的产品部署成为常态,因为每个人都认为那些失败的测试是假阳性,那么测试套件的信号价值已经下降到零。您仍然可以使用它来跟踪与上一次运行相比哪些测试失败了,但是这往往会退化成许多没有人愿意做的手工工作。

不幸的是,有些测试很难稳健地进行。图形用户界面(GUI)测试往往对布局或技术变化非常敏感。依赖于您控制之外的组件的测试也可能是脆性的来源。

虚假的安全感

测试套件的完美运行会给你一种错误的安全感。这可能是由于假阴性(应该失败但没有失败的测试)或者遗漏了测试场景。即使一个测试套件实现了测试代码的 100%语句覆盖率,它也可能会错过一些代码路径或场景。因此,您看到一个通过的测试运行,并把它作为您的软件工作正常的指示,一旦真正的客户接触到产品,就会被错误报告淹没。

测试套件无法直接解决过度自信的问题。只有通过对代码库及其测试的体验,您才能感受到绿色(即通过)测试运行所提供的真实置信度。

1.3 优秀测试的特征

一个好的测试是结合了编写测试的几个原因,同时尽可能避免缺点。这意味着测试应该快速运行,易于理解和维护,在失败时给出良好和具体的反馈,并且是健壮的。

可能有点令人惊讶的是,它偶尔也会失败,尽管人们预计测试会失败。从不失败的测试也不会给你反馈,不能帮助你调试。这并不意味着您应该删除一个您从未记录失败的测试。也许它在开发人员的机器上失败了,他或她在检查更改之前修复了 bug。

不是所有的测试都符合好测试的所有标准,所以让我们来看看一些不同种类的测试和它们固有的权衡。

1.4 各种测试

基于测试的范围(覆盖多少代码)和目的,有一个如何对测试进行分类的传统模型。该模型将测试正确性的代码分为单元测试、集成测试和系统测试。它还增加了冒烟测试、性能测试和其他不同目的的测试。

单元测试

单元测试独立地测试一个程序中最小的单元。在过程式或函数式编程语言中,它往往是一个子程序或函数。在 Python 这样的面向对象语言中,它可以是一个方法。根据您对定义的理解,它也可以是一个类或一个模块。

单元测试应该避免在被测试单元之外运行代码。因此,如果您正在测试一个数据库密集型业务应用,那么您的单元测试仍然不应该执行对数据库(访问网络进行 API 调用)或文件系统的调用。有一些方法可以替代这种外部依赖来进行测试,我将在后面讨论,但是如果您可以构建代码来避免这种调用,至少在大多数单元中,那就更好了。

因为对外部依赖的访问是导致大多数代码变慢的原因,所以单元测试通常非常快。这使得它们非常适合测试算法或核心业务逻辑。

例如,如果您的应用是一个导航助手,其中至少有一段算法上具有挑战性的代码:路由器,在给定地图、起点和目标的情况下,它会生成一条路线,或者可能是一列可能的路线,并附带长度和预期到达时间等指标。这个路由器,或者甚至是它的一部分,是你想要尽可能彻底地用单元测试来覆盖的东西,包括可能导致无限循环的奇怪的边缘情况,或者检查从柏林到慕尼黑的旅程没有经过罗马。

对于这样一个单元,您想要的测试用例的绝对数量使得其他类型的测试不切实际。此外,您不希望由于不相关的组件而导致这样的测试失败,因此将它们集中在一个单元上可以提高它们的特异性。

集成测试

如果你用单个部件组装了一个复杂的系统,比如一辆汽车或一艘宇宙飞船,并且每个部件都可以独立工作,那么整体工作的可能性有多大?出问题的方式有很多:一些线路可能有故障,组件想要通过不兼容的协议交谈,或者可能关节无法承受操作期间的振动。

在软件中没有什么不同,所以人们编写集成测试。综合测试一次练习几个单元。这使得单元之间的边界处的不匹配变得明显(通过测试失败),使得这样的错误能够被及早纠正。

系统测试

系统测试将一个软件放到一个环境中,并在那里进行测试。对于经典的三层体系结构,系统测试从用户界面的输入开始,测试所有层直到数据库。

单元测试和集成测试是白盒测试(需要并利用软件如何实现的知识的测试),而系统测试往往是黑盒测试。他们站在用户的角度,不关心系统的内部。

就如何测试软件而言,这使得系统测试最现实,但是它们也有一些缺点。

首先,管理系统测试的依赖关系可能非常困难。例如,如果您正在测试一个 web 应用,您通常首先需要一个可以用来登录的帐户,然后每个测试用例需要一组它可以使用的固定数据。

第二,系统测试经常一次测试如此多的组件,以至于测试失败不能给出关于实际错误的好线索,并且需要开发人员查看每个测试失败,经常发现变化与测试失败无关。

第三,系统测试暴露了您不打算测试的组件中的故障。由于软件使用的 API 中的传输层安全性(TLS)证书配置错误,系统测试可能会失败,这可能完全在您的控制之外。

最后,系统测试通常比单元和集成测试慢得多。白盒测试允许您只测试您想要的组件,因此您可以避免运行不感兴趣的代码。在 web 应用的系统测试中,您可能必须执行登录,导航到您想要测试的页面,输入一些数据,然后最终执行您实际想要执行的测试。系统测试通常比单元测试或集成测试需要更多的设置,增加了它们的运行时间,延长了人们收到代码反馈的时间。

烟雾测试

冒烟测试类似于系统测试,因为它测试技术堆栈中的每一层,尽管它不是对每一层的彻底测试。它通常不是用来测试应用某个部分的正确性,而是用来测试应用在当前上下文中是否能正常工作。

web 应用的冒烟测试可以像登录一样简单,然后调用用户的个人资料页面,验证用户名是否出现在该页面的某个位置。这不会验证任何逻辑,但会检测到诸如配置错误的 web 服务器或数据库服务器,或者无效的配置文件或凭证。

为了从冒烟测试中获得更多好处,您可以向应用添加一个状态页或 API 端点来执行额外的检查,例如检查数据库中是否存在所有必需的表、相关服务的可用性等等。只有满足了所有这些运行时依赖关系,状态才会是“OK”,冒烟测试可以很容易地确定这一点。通常,您只需为每个可部署的组件编写一到两个冒烟测试,但要为您部署的每个实例运行它们。

性能测试

到目前为止,讨论的测试集中在正确性上,但是非功能性的质量,比如性能和安全性,可能同样重要。原则上,运行性能测试相当容易:记录当前时间,运行某个动作,再次记录当前时间。两个时间记录之间的差异是该动作的运行时间。如有必要,根据这些值重复并计算一些统计数据(如中值、平均值、标准差)。

一如既往,细节决定成败。主要的挑战是创建真实可靠的测试环境、真实的测试数据和真实的测试场景。

许多商业应用严重依赖数据库。因此,您的性能测试环境也需要一个数据库。从硬件和许可成本的角度来看,为测试环境复制一个大型生产数据库实例可能非常昂贵。因此,使用缩小的测试数据库是一种诱惑,这带来了结果无效的风险。如果在性能测试中有些东西很慢,开发人员倾向于说“那只是较弱的数据库;prod 可以轻松解决这个问题”——他们可能是对的。或者不是。没有办法知道。

环境设置的另一个潜在的方面是当涉及到性能时有许多活动部件。在虚拟机(VM)上,您通常不知道 VM 从虚拟机管理程序获得了多少 CPU 周期,或者虚拟化环境是否对 VM 内存玩了有趣的把戏(例如将 VM 的部分内存换出到磁盘),从而导致不可预测的性能。

在物理机器(也是每个虚拟机的基础)上,你会遇到现代的电源管理系统,这些系统根据散热因素来控制时钟速度,在某些情况下,甚至根据 CPU中使用的特定指令来控制时钟速度。66

所有这些因素导致性能测量比你可能从像计算机这样的确定性系统中天真地期望的更不确定。

1.5 摘要

作为软件开发人员,我们希望自动化测试为我们提供快速的变更反馈,在回归到达客户之前捕捉它们,并为我们提供足够的信心来重构代码。好的测试是快速、可靠的,并且当它失败时具有高诊断价值。

单元测试往往很快,具有很高的诊断价值,但只覆盖了一小部分代码。一个测试覆盖的代码越多,它就越慢,越脆弱,它的诊断价值就越低。

在下一章,我们将看看如何用 Python 编写和运行单元测试。然后我们将研究如何为每次提交自动运行它们。

二、Python 中的单元测试

许多程序员手动测试他们正在编写的代码,方法是调用他们正在开发的代码,将结果打印到控制台,并直观地扫描输出的正确性。这适用于简单的任务,但存在一些问题:

  • 当输出变大时,发现错误变得更加困难。

  • 当程序员疲劳时,很容易错过微妙的错误输出。

  • 当实现的特性变得更大时,人们往往会错过早期“测试”部分的回归。

  • 因为非正式的测试脚本通常只对编写它们的程序员有用,其他开发人员就看不到它们的效用了。

因此,单元测试被发明出来了,在单元测试中,人们编写对代码片段的样本调用,并将返回值与期望值进行比较。

这种比较通常在测试通过时产生很少或没有输出,否则产生非常明显的输出。一个测试工具可以用来运行来自几个测试脚本的测试,并且只报告错误和通过测试的统计概要。

2.1 消化:Virtualenvs

为了运行我们将要编写的单元测试,我们需要一些额外的工具,这些工具以 Python 包的形式提供。要安装它们,你应该使用一种叫做的工具。这是一个 Python 目录,其中包含 Python 解释器、包管理程序(如pip)以及到基本 Python 包的符号链接,从而为您提供了一个原始的 Python 环境,您可以在这个环境上构建一个定制的、隔离的虚拟环境,其中包含您所需要的库。virtualenv 允许你安装任何你想要的 Python 包;为应用安装依赖项不需要 root 权限。您可以在给定的 shell 会话中激活一个 virtualenv,当您不再需要它时,只需删除该目录。

Virtualenvs 用于将独立的开发环境相互隔离,并与系统 Python 安装隔离。要创建一个,您需要virtualenv工具,它通常随 Python 安装一起提供,或者在 Linux 发行版上,可以通过包管理器安装。在基于 Debian 的系统上,你可以这样安装它:

$ sudo apt-get install virtualenv

要创建名为venv的 virtualenv,运行

$ virtualenv -p python3 venv

这将准备一个名为venv的目录,其中包含必要的文件。下一步应该是激活它,如下所示:

$ source venv/bin/activate

一旦您激活了它,您就可以使用pip将软件包安装到其中,例如:

$ pip install pytest

完成后,使用命令deactivate将其禁用。

2.2 单元测试入门

为了说明单元测试,让我们从单个函数以及如何测试它开始。这里我想实现的功能是一个二分搜索法。给定一个排序的数字列表(我们称之为干草堆),在其中搜索另一个数字(针)。如果它存在,返回找到它的索引。如果没有,引发类型为ValueError的异常。你可以在 https://github.com/python-ci-cd/binary-search 找到这个例子的代码和测试。

我们从草堆的中间部分开始。如果恰好等于针,我们就完了。如果它比针还小,我们可以在干草堆的左半部分重复搜索。如果它比较大,我们可以在草堆的右半部分继续搜索。

为了跟踪我们需要搜索的干草堆内部的区域,我们保留了两个索引,leftright,并且在每次迭代中,将其中一个移动到更靠近另一个,在每一步中将要搜索的空间减半。

这是第一次尝试实现这个函数时的样子:

def search(needle, haystack):
    left = 0
    right = len(haystack) - 1

    while left <= right:
        middle = left + (right - left) // 2
        middle_element = haystack[middle]
        if middle_element == needle:
            return middle
        elif middle_element < needle:
            left = middle
        else:
            right = middle
    raise ValueError("Value not in haystack")

第一次测试

有用吗?谁知道呢?让我们通过写一个测试来找出答案。

def test_search():
    assert search(2, [1, 2, 3, 4]) == 1, \
        'found needle somewhere in the haystack'

这是一个简单的函数,它使用样本输入执行search函数,如果没有达到预期,则使用assert来引发异常。我们没有直接调用这个测试函数,而是使用了pytest,一个由同名 Python 包提供的命令行工具。如果您的开发环境中没有它,您可以使用下面的命令安装它(记住在 virtualenv 中运行它):

pip install pytest

pytest可用时,您可以在包含search函数和测试函数的文件上运行它,如下所示:

$ pytest binary-search.py
==================== test session starts =====================
platform linux -- Python 3.5.2, pytest-3.3.2, py-1.5.2
rootdir: /home/moritz/examples, inifile:
collected 1 item

binary-search.py .                                    [100%]

================== 1 passed in 0.01 seconds ==================

测试运行打印出各种信息:这些信息包括有关平台和所涉及软件版本的细节、工作目录,以及使用了什么pytest配置文件(本例中没有)。

collected 1 item行显示pytest找到了一个测试函数。下一行中文件名后面的点显示了进度,一个点代表一个已经执行的测试。

在终端中,最后一行显示为绿色,表示测试运行通过。如果我们犯了一个错误,比如说,使用 0 而不是 1 作为预期结果,我们会得到一些诊断输出,如下所示:

========================== FAILURES ==========================
_________________________test_search__________________________

     def test_search():
>        assert search(2, [1, 2, 3, 4]) == 0, \
             'found needle somewhere in the haystack'
E        AssertionError: found needle somewhere in the haystack
E        assert 1 == 0
E         + where 1 = search(2, [1, 2, 3, 4])

binary-search.py:17: AssertionError
================== 1 failed in 0.03 seconds ==================

这显示了失败的测试函数,既有源代码,也有在assert调用中的==操作符两边替换的值,显示了到底哪里出错了。在支持彩色的终端中,失败的测试和底部的状态行以红色显示,以使失败的测试更加明显。

编写更多测试

代码中的许多错误在边缘情况下表现出来,以空列表或字符串作为输入,数字为零,访问列表的第一个和最后一个元素,等等。在编写测试时考虑这些情况并覆盖它们是一个好主意。让我们从搜索第一个和最后一个元素开始。

def test_search_first_element():
    assert search(1, [1, 2, 3, 4]) == 0, \
        'search first element'

def test_search_last_element():
    assert search(4, [1, 2, 3, 4]) == 3, \
        'search last element'

查找第一个元素的测试通过了,但是最后一个元素的测试挂起了,也就是说,它无限期地运行而没有终止。您可以通过同时按下 Ctrl 和 C 键来中止 Python 进程。

如果函数search能找到第一个元素却找不到最后一个元素,这里面一定有某种不对称。确实有:确定中间元素使用整数除法运算符//,它将正数四舍五入为零。例如,1 // 2 == 0。这解释了为什么循环会被卡住:当right等于left + 1时,代码将middle设置为left的值。如果执行分支left = middle,函数搜索的干草堆面积没有减少,循环就卡住了。

有一个简单的解决方法。因为代码已经确定索引middle处的元素不是针,所以可以将其从搜索中排除。

def search(needle, haystack):
    left = 0
    right = len(haystack) - 1

    while left <= right:
        middle = left + (right - left) // 2
        middle_element = haystack[middle]
        if middle_element == needle:
            return middle
        elif middle_element < needle:
            left = middle + 1
        else:
            right = middle - 1
    raise ValueError("Value not in haystack")

有了这个修正,所有三个测试都通过了。

测试不愉快的路径

到目前为止,测试主要集中在“快乐路径”上,即找到一个元素并且没有遇到错误的路径。因为异常不是正常控制流中的异常(请原谅双关语),所以它们也应该被测试。

有一些工具可以帮助你验证一个异常是由一段代码引起的,并且是正确的类型。

def test_exception_not_found():
    from pytest import raises

    with raises(ValueError):
        search(-1, [1, 2, 3, 4])

    with raises(ValueError):
        search(5, [1, 2, 3, 4])

    with raises(ValueError):
        search(2, [1, 3, 4])

在这里,我们测试三种场景:一个值小于草堆中的第一个元素,大于最后一个元素,最后,它的大小介于第一个和最后一个元素之间,但不在草堆中。

pytest.raises例程返回一个上下文管理器。上下文管理器是一种将代码(在with ...块中)包装到其他代码中的好方法。在这种情况下,上下文管理器从with块捕获异常,如果类型正确,测试通过。相反,如果没有出现异常或者出现了一个错误的类型,比如KeyError,测试就会失败。

和前面的assert语句一样,您可以给测试加上标签。这对于调试测试失败和记录测试都很有用。使用raises函数,您可以将测试标签作为名为message的命名参数进行传递。

def test_exception_not_found():
    from pytest import raises

    with raises(ValueError, message="left out of bounds"):
        search(-1, [1, 2, 3, 4])

    with raises(ValueError, message="right out of bounds"):
        search(5, [1, 2, 3, 4])

    with raises(ValueError, message="not found in middle"):

        search(2, [1, 3, 4])

2.3 处理依赖性

并非所有代码都像前面章节中的search函数一样易于测试。一些函数调用外部库或与数据库、API 或互联网交互。

在单元测试中,出于几个原因,您应该避免执行那些外部操作。

  • 这些行为可能会产生不必要的副作用,比如向客户或同事发送电子邮件,让他们感到困惑,甚至造成伤害。

  • 您通常无法控制外部服务,这意味着您无法控制一致的响应,这使得编写可靠的测试更加困难。

  • 执行外部操作,如写入或删除文件,会使环境处于不同的状态,这可能会导致无法重现测试结果。

  • 性能受损,对开发反馈周期产生负面影响。

  • 通常,外部服务,如数据库或 API,需要凭证,这是一个管理上的麻烦,并对设置开发环境和运行测试构成了严重的障碍。

那么,如何在单元测试中避免这些外部依赖呢?让我们探索一些选择。

将逻辑与外部依赖分离

许多应用从某个地方(通常是不同的来源)获取数据,然后对其进行一些逻辑处理,最后可能会打印出结果。

让我们考虑一下计算网站中关键词的应用的例子。这方面的代码可能如下所示(它使用了requests库;可以用pip install requests在你的 virtualenv)里安装:

import requests

def most_common_word_in_web_page(words, url):
    """

    finds the most common word from a list of words
    in a web page, identified by its URL
    """

    response = requests.get(url)
    text = response.text
    word_frequency = {w: text.count(w) for w in words}
    return sorted(words, key=word_frequency.get)[-1]

if __name__ == '__main__':
    most_common = most_common_word_in_web_page(
        ['python', 'Python', 'programming'],
        'https://python.org/',
    )
    print(most_common)

在撰写本文时,这段代码将Python打印为答案,尽管这在将来可能会改变,由 python.org 维护者决定。

您可以在 https://github.com/python-ci-cd/python-webcount 找到示例代码和测试。

这段代码使用requests库获取网页内容并访问结果文本(实际上是 HTML)。然后,该函数遍历搜索词,计算每个词在文本中出现的频率(使用string.count方法),并用这些计数构建一个字典。然后,它根据单词的出现频率对单词列表进行排序,并返回最常出现的单词,这是排序列表的最后一个元素。

测试most_common_word_in_web_page变得单调乏味,因为它使用了 HTTP 客户端requests。我们可以做的第一件事是将计数和排序的逻辑从获取网站的机制中分离出来。这不仅使逻辑部分更容易测试,还通过分离不真正属于一起的东西,提高了代码的质量,从而增加了内聚性。

import requests

def most_common_word_in_web_page(words, url):
    """

    finds the most common word from a list of words
    in a web page, identified by its URL
    """

    response = requests.get(url)
    return most_common_word(words, response.text)

def most_common_word(words, text):
    """

    finds the most common word from a list of words
    in a piece of text
    """

    word_frequency = {w: text.count(w) for w in words}
    return sorted(words, key=word_frequency.get)[-1]

if __name__ == '__main__':
    most_common = most_common_word_in_web_page(
        ['python', 'Python', 'programming'],
        'https://python.org/',
    )
    print(most_common)

做逻辑的函数most_common_word,现在是一个函数,也就是返回值只取决于传递给它的自变量,它和外界没有任何交互。这样一个纯函数很容易测试(同样,测试进入test/functions.py)。

def test_most_common_word():
    assert most_common_word(['a', 'b', 'c'], 'abbbcc') \
            == 'b', 'most_common_word with unique answer'

def test_most_common_word_empty_candidate():
    from pytest import raises
    with raises(Exception, message="empty word raises"):
        most_common_word([], 'abc')

def test_most_common_ambiguous_result():
    assert most_common_word(['a', 'b', 'c'], 'ab') \
        in ('a', 'b'), "there might be a tie"

这些测试更多的是单元测试的例子,它们也提出了一些仅仅阅读函数源代码可能不明显的问题。

  • most_common_word实际上并不寻找单词边界,所以它会愉快地在字符串abbbcc中三次计数“单词”b

  • 当使用空的关键字列表调用该函数时,它会引发一个异常,但是我们并没有费心去指定哪种类型的错误。 1

  • 我们还没有指定如果两个或更多单词有相同的出现次数,返回哪个值,因此最后一个测试使用了带有两个有效答案列表的in

根据您的情况,您可能希望将这样的测试作为已知边缘情况的文档,或者细化规范和实现。

回到测试外部依赖函数的话题,我们已经取得了部分成功。有趣的逻辑现在是一个独立的纯函数,可以很容易地测试。最初的函数most_common_word_in_web_page,现在更简单了,但是仍然没有经过测试。

我们已经隐含地建立了这样一个原则,即为了使测试更容易而修改代码是可以接受的,但这值得明确地提出来。我们将来会更多地使用它。

测试的依赖注入

如果我们多想想是什么使得函数most_common_word_in_web_page难以测试,我们可以得出结论,不仅仅是通过 HTTP 用户代理requests与外界的交互,还有全局符号requests的使用。如果我们可以用另一个类来代替它,那么测试就更容易了。我们可以通过对测试中的函数进行简单的修改来实现这一点。(为简洁起见,注释已从示例中删除。)

def most_common_word_in_web_page(words, url,
        user_agent=requests):
    response = user_agent.get(url)
    return most_common_word(words, response.text)

该函数现在接受一个可选参数user_agent,默认为requests,而不是直接使用requests。在功能里面,单独使用的requests已经被user_agent取代。

对于只用两个参数调用函数的调用者来说,什么都没有改变。但是编写测试的开发人员现在可以提供他/她自己的测试 double ,一个以确定性方式运行的用户代理的替代实现。

def test_with_test_double():
    class TestResponse():
        text = 'aa bbb c'

    class TestUserAgent():
        def get(self, url):
            return TestResponse()

    result = most_common_word_in_web_page(
        ['a', 'b', 'c'],
        'https://python.org/',
        user_agent=TestUserAgent()
    )
    assert result == 'b', \
        'most_common_word_in_web_page tested with test double'

这个测试仅仅模拟了被测试函数使用的部分requests API。它忽略了get方法的url参数,所以纯粹从这个测试中,我们不能确定被测试的函数正确地使用了用户代理类。我们可以扩展 test double 来记录传入的参数值,并在以后检查它。

def test_with_test_double():
    class TestResponse():
        text = 'aa bbb c'

    class TestUserAgent():
        def get(self, url):
            self.url = url
            return TestResponse()

    test_ua = TestUserAgent()
    result = most_common_word_in_web_page(
        ['a', 'b', 'c'],
        'https://python.org/',
        user_agent=test_ua
    )
    assert result == 'b', \
        'most_common_word_in_web_page tested with test double'
    assert test_ua.url == 'https://python.org/'

本节演示的技术是 依赖注入 的简单形式。 2 调用者可以选择注入一个函数所依赖的对象或类。

依赖注入不仅对测试有用,而且使软件更具可插拔性。例如,您可能希望您的软件能够在不同的上下文中使用不同的存储引擎,或者不同的 XML 解析器,或者存在多种实现的任何数量的其他软件基础结构。

模拟对象

编写 test double 类很快就会变得单调乏味,因为您通常需要为测试中调用的每个方法编写一个类,并且所有这些类都必须设置为正确地链接它们的响应。如果您编写多个测试场景,您要么必须使测试加倍足够通用以覆盖多个场景,要么再次重复几乎相同的代码。

模拟对象提供了一个更方便的解决方案。您可以轻松地将这些对象配置为以预定义的方式做出响应。

def test_with_test_mock():
    from unittest.mock import Mock
    mock_requests = Mock()
    mock_requests.get.return_value.text = 'aa bbb c'
    result = most_common_word_in_web_page(
        ['a', 'b', 'c'],
        'https://python.org/',
        user_agent=mock_requests
    )
    assert result == 'b', \
        'most_common_word_in_web_page tested with test double'
    assert mock_requests.get.call_count == 1
    assert mock_requests.get.call_args[0][0] \
            == 'https://python.org/', 'called with right URL'

这个测试函数的前两行导入了类Mock并从中创建了一个实例。然后真正的奇迹发生了。

mock_requests.get.return_value.text = 'aa bbb c'

这将在对象mock_requests中安装一个属性get,当它被调用时,将返回另一个模拟对象。第二个模拟对象上的属性text有一个属性text,它保存了字符串'aa bb c'

让我们从一些简单的例子开始。如果你有一个Mock对象m,那么m.a = 1安装一个值为1的属性a。另一方面,m.b.return_value = 2配置m,使得m.b()返回2

可以继续链式,所以m.c.return_value.d.e.return_value = 3使m.c().d.e()返回 3。本质上,赋值中的每个return_value对应于调用链中的一对括号。

除了设置这些准备好的返回值,模拟对象还记录调用。前面的例子检查了一个模拟对象的call_count,它只是记录了这个模拟作为一个函数被调用的频率。

属性包含一组传递给它的最后一次调用的参数。这个元组的第一个元素是位置参数的列表,第二个元素是命名参数的字典。

如果您想检查一个模拟对象的多次调用,call_args_list包含一个这样的元组列表。

Mock类有更多有用的方法。完整列表请参考官方文档 3

修补

有时候,依赖注入是不实际的,或者你不想冒险改变现有的代码来测试它。然后,您可以利用 Python 的动态特性来临时覆盖测试代码中的符号,并用测试 doubles 替换它们——通常是模拟对象。

from unittest.mock import Mock, patch

def test_with_patch():
    mock_requests = Mock()
    mock_requests.get.return_value.text = 'aa bbb c'
    with patch('webcount.functions.requests', mock_requests):
        result = most_common_word_in_web_page(
            ['a', 'b', 'c'],
            'https://python.org/',
        )
    assert result == 'b', \
        'most_common_word_in_web_page tested with test double'
    assert mock_requests.get.call_count == 1
    assert mock_requests.get.call_args[0][0] \
            == 'https://python.org/', 'called with right URL'

patch函数的调用(从unittest.mock导入,这是 Python 附带的一个标准库)指定了要修补(临时替换)的符号和替换它的测试 double。patch函数返回一个上下文管理器。因此,在执行离开发生调用的with块后,临时替换会自动撤销。

修补导入的符号时,重要的是在导入该符号的命名空间中修补该符号,而不是在源库中修补该符号。在我们的例子中,我们修补了webcount.functions.requests,而不是requests.get

打补丁消除了与其他代码(通常是库)的交互。这有利于单独测试代码,但也意味着打了补丁的测试无法检测对已打补丁的库的误用。因此,编写更大范围的测试,如集成测试或验收测试,以涵盖这些库的正确使用是很重要的。

2.4 分离代码和测试

到目前为止,为了方便起见,我们已经将代码和测试放在同一个文件中。然而,代码和测试服务于不同的目的,所以当它们变大时,通常将它们分成不同的文件,通常甚至是不同的目录。我们的测试代码现在也可以自己加载一个模块(pytest),这是一个你不想加在产品代码上的负担。最后,一些测试工具为测试和代码假设了不同的文件,所以我们将遵循这个惯例。

在开发 Python 应用时,通常有一个项目包名和一个同名的顶级目录。测试进入名为tests的第二个顶级目录。例如,Django web 框架有目录djangotest,还有一个README.rst作为初学者的入口点,还有一个setup.py用于安装项目。

每个作为 Python 模块的目录必须包含一个名为__init__.py的文件,这个文件可以是空的,也可以包含一些代码。通常,这段代码只导入其他符号,这样模块的用户就可以从顶级模块名导入它们。

让我们考虑一个小应用,给定一个 URL 和一列关键字,打印出 URL 指向的网页上最常出现的关键字。我们可以称之为webcount,并将逻辑放入文件webcount/functions.py。然后,文件webcount/ __init__ .py会是这样的:

from .functions import most_common_word_in_web_page

在每个测试文件中,我们明确地导入我们测试的函数,例如:

from webcount import most_common_word_in_web_page

我们可以将测试函数放入test/目录下的任何文件中。在本例中,我们将它们放入文件test/test_functions.py,以镜像实现的位置。test_ prefix告诉 pytest 这是一个包含测试的文件。

调整 Python 路径

当您用pytest test/test_functions.py运行这个测试时,您可能会得到这样一个错误:

test/functions.py:3: in <module>
    from webcount import most_common_word_in_web_page
E   ImportError: No module named 'webcount'

Python 找不到测试中的模块webcount,因为它不在 Python 的默认模块加载路径中。

您可以通过在 virtualenv 的site-packages目录中添加项目根目录的绝对路径到一个扩展名为.pth的文件来解决这个问题。例如,如果您使用 Python 3.5,并且您的 virtualenv 位于目录venv/中,您可以将绝对路径放入文件venv/lib/python3.5/site-packages/webcount.pth。在官方 Python 文档中讨论了操纵“Python 路径”的其他方法。 4

一种特定于 pytest 的方法是向项目的根目录添加一个空文件conftest.py。Pytest 查找该名称的文件,并在检测到它们存在时,将包含它们的目录标记为要测试的项目,并在测试运行期间将该目录添加到 Python 路径中。

在调用 pytest 时,您不必指定测试文件。如果您忽略它,pytest 会搜索所有测试文件并运行它们。关于集成 实践 5pytest 文档中有关于这个搜索如何工作的更多信息。

2.5 关于单元测试和 Pytest 的更多内容

在尝试为代码编写测试时,您可能会遇到更多的主题。例如,您可能必须管理fixture,作为您的测试基线的数据片段。或者,您可能需要从运行时加载的代码中修补函数,或者做一些其他没有人让您做好准备的事情。

对于这种情况, pytest 文档 6 是一个很好的起点。如果你想要更全面的介绍,Brian Okken 的书 Python Testing with pytest (务实书架,2017)值得一读。

2.6 在新环境中运行单元测试

开发人员通常有一个开发环境,在这个环境中,他们实现他们的变更,运行自动的,有时是手动的测试,提交他们的变更,并将它们推送到一个中央存储库。这样的开发环境倾向于积累 Python 包,这些包并不明确依赖于正在开发的软件,而且它们倾向于只使用一个 Python 版本。这两个因素也倾向于使测试套件不太具有可重复性,这会导致“在我的机器上工作”的心态。

为了避免这种情况,您需要一种机制来以可重复的方式在几个 Python 版本上轻松执行测试套件。tox 自动化项目 7 提供了一个解决方案:您向它提供一个简短的配置文件tox.ini,它列出了 Python 版本和一个用于安装模块的标准setup.py文件。然后,你可以运行tox命令。

tox命令为每个 Python 版本创建一个新的 virtualenv,在每个环境中运行测试,并报告测试状态。首先,我们需要一个文件setup.py

# file setup.py

from setuptools import setup

setup(
    name = "webcount",
    version = "0.1",
    license = "BSD",
    packages=['webcount', 'test'],
    install_requires=['requests'],
)

这使用 Python 的库setuptools使开发中的代码可安装。通常,您会包括更多的元数据,如作者、电子邮件地址、更详细的描述等。

然后,文件tox.ini告诉 tox 如何运行测试,以及在什么环境中运行。

[tox]

envlist = py35

[testenv]

deps = pytest
       requests
commands = pytest

本例中的envlist只包含 Python 3.5 的py35。如果你也想在 Python 3.6 上运行测试,你可以写envlist = py35,py36。关键字pypy35指的是 3.5 版本中 Python 的替代 pypy 实现。

现在,调用tox在所有环境中运行测试(这里只有一个),最后报告状态。

py35 runtests: PYTHONHASHSEED="3580365323"
py35 runtests: commands[0] | pytest
================== test session starts ==================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4,
    pluggy-0.6.0
rootdir: /home/[...]/02-webcount-patched, inifile:
collected 1 item

test/test_functions.py .                         [100%]

=============== 1 passed in 0.08 seconds ================
_________________________summary_________________________

py35: commands succeeded
congratulations :)

2.7 另一个示例项目:matheval

如今,许多项目都是作为 web 服务实现的,因此可以通过 HTTP 使用它们——或者作为 API,或者通过实际的网站。让我们考虑一个很小的 web 服务,它评估被编码为 JSON 数据结构中的树的数学表达式。(你可以在 https://github.com/python-ci-cd/python-matheval/ 找到这个项目的完整源代码。)作为一个例子,表达式 5 * (4 - 2)将被编码为 JSON 树["*", 5, ["+", 4, 2]]并计算为 10。

应用逻辑

实际的评估逻辑相当紧凑(参见清单 2-1 )。

from functools import reduce

import operator

ops = {
    '+': operator.add,
    '-': operator.add,
    '*': operator.mul,
    '/': operator.truediv,
}

def math_eval(tree):
    if not isinstance(tree, list):
        return tree
    op = ops[tree.pop(0)]
    return reduce(op, map(math_eval, tree))

Listing 2-1File matheval/evaluator.py: Evaluation Logic

使用 Flask 框架,将它公开到 Web 上也不需要太多努力(参见清单 2-2 )。

#!/usr/bin/python3

from flask import Flask, request

from matheval.evaluator import math_eval

app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
    tree = request.get_json(force=True)
    result = math_eval(tree);
    return str(result) + "\n"

if __name__ == '__main__':
    app.run(debug=True)

Listing 2-2File matheval/frontend.py: Web Service Binding

一旦您将项目的根目录添加到当前 virtualenv 的一个.pth文件中,并安装了flask先决条件,您就可以启动一个开发服务器,如下所示:

$ python matheval/frontend.py
 * Serving Flask app "frontend" (lazy loading)
 * Environment: production
   WARNING: Do not use the development server in a production environment.
   Use a production WSGI server instead.
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

对于生产应用,最好安装gunicorn,然后启动应用

$ gunicorn matheval.frontend:app

对应用逻辑进行单元测试非常简单,因为它是一个纯函数(参见清单 2-3 )。

from matheval.evaluator import math_eval

def test_identity():
    assert math_eval(5) == 5, 'identity'

def test_single_element():
    assert math_eval(['+', 5]) == 5, 'single element'

def test_addition():
    assert math_eval(['+', 5, 7]) == 12, 'adding two numbers'

def test_nested():
    assert math_eval(['*', ['+', 5, 4], 2]) == 18

Listing 2-3File test/test_evaluator.py: Unit Tests for Evaluating Expression Trees

index路线并不复杂,不足以保证单独的单元测试,但是在后面的章节中,我们将编写一个冒烟测试,在应用安装后对其进行测试。

我们需要一个小的setup.py文件来通过pytest运行测试(参见清单 2-4 )。

#!/usr/bin/env python

from setuptools import setup

setup(name='matheval',
      version='0.1',
      description='Evaluation of expression trees',
      author='Moritz Lenz',
      author_email='moritz.lenz@gmail.com',
      url='https://deploybook.com/',
      requires=['flask', 'pytest', 'gunicorn'],
      setup_requires=['pytest-runner'],
      packages=['matheval']
     )

Listing 2-4File setup.py for matheval

最后,我们再次需要一个空文件conftest.py,现在可以运行测试了。

$ pytest
==================== test session starts =====================
platform linux -- Python 3.6.5, pytest-3.8.0, py-1.6.0
rootdir: /home/moritz/src/matheval, inifile:
collected 4 items

test/test_evaluator.py ....                            [100%]

================== 4 passed in 0.02 seconds ==================

2.8 摘要

单元测试通过用样本输入调用一段代码,并验证它返回预期的结果还是抛出预期的异常,来孤立地测试这段代码。使用 pytest,测试是一个函数,其名称以test_开头,包含验证返回值的assert语句。您用pytest path/to/file.py运行这些测试文件,它会为您找到并运行测试。它使测试失败变得非常明显,并试图提供尽可能多的上下文来调试它们。

模拟对象提供了一种快速创建测试副本的方法,修补机制提供了一种将它们注入测试代码的便捷方法。

tox命令和项目创建了隔离的测试环境,使得测试套件可重复,并且更方便在多个 Python 版本和实现上进行测试。

三、Jenkins 持续集成

一旦你对你的软件进行了自动化测试,你必须小心保持那些测试的通过。随着代码或基础设施的改变,或者新的库版本,测试可能开始失败。

如果你让它们失败,并且对这种蠕动熵不采取任何措施,测试的诊断价值开始下降,新的回归往往会被一般的噪音所掩盖。保持您的测试通过,并不断地检入新的特性和错误修复,是软件开发团队工程文化的一部分。

有工具可以帮助团队。持续集成 (CI) 服务器监控版本控制库,并在每次新提交时自动运行测试套件,可能在各种平台上运行。当他们导致一些测试失败时,他们可以通知开发人员,给出测试工作的历史概况,并可视化趋势数据,例如测试覆盖率。

当您使用这样的工具时,它通过提供独立的评估,帮助您发现测试何时开始失败,将失败分类到某些提交或平台,并使“它在我的机器上工作”的咒语过时。然而,从事软件工作的工程师仍然需要规程来修复 CI 工具发现的测试失败。

3.1 持续集成服务器

根据其部署模型,有两种 CI 服务器。您在自己的基础设施上安装和运行本地软件,而基于云的软件或软件即服务 (SaaS)软件通常由创建 CI 服务器的供应商运行。

在企业设置中,内部软件往往是首选的解决方案,因为这意味着被测试的源代码不必离开组织的网络。

最流行的开源本地 CI 服务器软件是 Jenkins1 一个基于 Java 的项目,在麻省理工学院的许可下,我们将在本章的后面使用它。这一类的其他例子还有build bot2(用 Python 写的)和 CruiseControl3 流行的封闭源码,内部 CI 软件有 JetBrains 的team city4和 Atlassian 的 Bamboo5

在托管 CI 服务领域,Travis CI6由于其与 GitHub 的出色集成而非常受欢迎。Travis 也是开源的,可以自托管。AppVeyor7常用于基于 Windows 的 CI。Travis 和 AppVeyor 都为开源项目提供免费计划。

大多数 CI 软件都有一个中央服务器组件,可以轮询源代码库的变更,也可以由钩子触发。如果检测到源代码库中有变化,就会触发一个作业。该作业可以在服务器中集中配置,也可以在源代码存储库中配置。例如,Travis 希望在存储库的根目录中有一个名为.travis.yml的文件,该文件指导 Travis 如何准备环境以及执行哪些命令来触发构建和测试。

一旦 CI 服务器知道要执行哪些测试,以及在哪些环境中执行,它通常会将实际的测试运行委派给工作节点。然后,工作节点将它们的结果报告给服务器,服务器负责发送通知,并通过 web 界面提供输出和结果以供检查。

3.2 Jenkins 入门

首先,您需要一个正常工作的 Jenkins 安装。官方网站 8 包含如何在所有常见操作系统上安装和设置 Jenkins 的说明。接下来,你也可以找到快速指导,让一个 Docker 为基础的詹金斯操场跑步。

在 Docker 中运行 Jenkins

通常,在生产环境中,您会在一台机器上运行 Jenkins 服务器,并让几个构建人员在不同的(虚拟)机器上工作。为了便于设置,我们将放弃这种明智的区分,在同一个 docker 容器中运行服务器和所有构建作业,只是为了管理更少的 Docker 容器。

为此,我们使用来自 Jenkins 的官方 Docker 映像,但是添加了tox Python 模块(我们将使用它来创建可再现的构建环境),以及我们想要测试的 Python 版本。

这个定制是通过一个定制Dockerfile来完成的,看起来像这样:

FROM jenkins/jenkins:lts
USER root
RUN apt-get update \
    && apt-get install -y python-pip python3.5 \
    && rm -rf /var/lib/apt/lists/*
RUN pip install tox

要构建定制映像,您必须安装 Docker,并且您的用户必须能够访问 Docker 守护进程,在基于 UNIX 的系统上,它通过将用户添加到docker组并重新登录来工作。建筑看起来像这样:

$ docker build -t jenkins-python .

这首先从 Dockerhub 下载图像jenkins/jenkins:lts,这可能需要几分钟。然后它运行 Dockerfile 的RUN行中的命令,docker file 安装pip,然后安装tox。由此产生的图像被命名为jenkins-python

接下来,通过运行

$ docker run --rm -p 8080:8080 -p 50000:50000 \
    -v jenkins_home:/var/jenkins_home jenkins-python

-v ...参数附加了一个卷,这使得 Jenkins 服务器在容器被终止并重启时不会丢失状态。

在启动过程中,容器在控制台上产生如下输出:

Please use the following password to proceed to installation:

b1792b6c4c324f358a2173bd698c35cd

复制密码,然后将浏览器指向http://127.0.0.1:8080/,并按照设置说明进行操作(第一步需要输入密码)。说到插件,将 Python 插件添加到要安装的插件列表中。

插件安装过程可能需要几分钟时间。之后,您就有了一个正常工作的 Jenkins 服务器。

配置源代码库

Jenkins 基于源代码控制库中的源代码运行作业。对于一个合适的软件开发项目,您可能已经有了一个存储代码的地方。如果没有,可以使用众多云托管服务中的一个,比如 GitHub9git lab10 或者 Atlassian 的 Bitbucket11 你也可以在自己的基础设施上安装 GitLab、 Gitea12Gogs13 或者其他 Git 管理项目。

无论哪种情况,您最终都会得到一个可以通过网络访问的 Git 存储库,这正是 Jenkins 所需要的。为了便于演示,我在 https://github.com/python-ci-cd/python-webcount 创建了一个公共的 GitHub 库。

对于私有存储库,您还需要一个 SSH 密钥对或用户名和密码的组合来访问存储库。

创建第一个詹金斯工作

我们希望 Jenkins 定期运行我们项目的测试。为此,我们需要创建一个作业,它配置 Jenkins 从哪里以及如何获得源代码并运行测试的所有细节。

要创建作业,请单击 Jenkins 起始页左栏中的 New Item 链接。然后,您必须输入一个名称,例如存储库的名称python-webcount,以及一个作业类型,这里是多配置项目。然后单击“确定”继续。

下一个屏幕提供了过多的配置选项。以下是让我们的示例作业运行的基本要素:

  • 在源代码管理部分选择 Git,并输入存储库 URL(例如, https://github.com/python-ci-cd/python-webcount.git )。对于私有存储库,您还必须在 URL 下方输入有效凭证(图 3-1 )。

  • 在 Build Trigger 部分,选择 Poll SCM 并输入字符串H/5 * * * *作为调度,这意味着每五分钟轮询一次。

  • 在配置矩阵下,添加一个名为TOXENV和值为py35的用户定义轴。如果你在 Jenkins 中安装了更多的 Python 版本并在项目的tox.ini文件中定义,你可以在这里添加它们,用空格分开(图 3-2 )。

  • 在构建部分,选择执行 Python 脚本,并将以下简短的 Python 脚本粘贴到脚本区域(图 3-3 )。

img/456760_1_En_3_Fig3_HTML.jpg

图 3-3

Jenkins 配置:构建配置

img/456760_1_En_3_Fig2_HTML.jpg

图 3-2

Jenkins 配置:构建触发器和配置矩阵

img/456760_1_En_3_Fig1_HTML.jpg

图 3-1

Jenkins 配置:源代码管理

import
os, tox

os.chdir(os.getenv("WORKSPACE"))
tox.cmdline()

添加这些信息后,您可以保存页面并拥有第一个工作配置项作业。

每五分钟,Jenkins 将检查 Git 存储库中的新提交,如果有,它将获取它们,通过 tox 运行测试,并在前端提供状态。

当您定义更多的 tox 环境时,Jenkins 会显示每个环境的测试是通过还是失败,并为您提供每个环境的历史记录。

3.3 将更多测试细节导出到 Jenkins

在当前状态下,Jenkins 完全基于运行的脚本的退出代码来检测测试状态,这并没有提供良好的粒度。我们可以通过指示 tox 编写一份机器可读的摘要并让 Jenkins 读取这些数据来提高粒度。

为此,将项目 Git 存储库中的tox.ini文件中的commands = pytest行改为

commands = pytest --junitxml=junit-{envname}.xml

对于环境py35,pytest 然后创建一个文件junit-py35.xml,它更详细地描述了测试运行。

在 Jenkins 的作业配置中,单击 Post-build actions 并添加一个 Publish JUnit test result report 类型。在现场测试报告 XMLs 中,输入模式**/junit-*.xml。(见图 3-4 。)

img/456760_1_En_3_Fig4_HTML.jpg

图 3-4

构建后操作:发布 JUnit 测试结果报告

当作业再次运行时,Jenkins 会获得单个测试函数的状态,甚至会报告每个函数的运行时间。这允许直接从 Jenkins web 界面进行更好的诊断。

3.4 与 Jenkins 合作的模式

现在,用 Jenkins 进行测试的基础已经就绪,是时候考虑如何在日常工作中使用它了。大部分都集中在保持测试绿色上,也就是说,所有的测试都通过。经验表明,如果你不关注保持你的工作绿色,开发人员习惯了失败的测试,然后从仅仅 1%的失败测试下滑到测试运行成为纯粹的噪音,因此失去了他们的价值。

此外,您应该对开发工作流程中的测试部分进行审查,以确保测试准确地反映了需求,即使新特性改变了需求。

责任

如果几个开发人员在同一个代码基础上工作,为通过测试套件定义清晰的职责是很重要的。通常,破坏测试套件的人(根据 Jenkins 中从绿色到红色的变化来衡量)负责再次修复它。

在一个像润滑良好的机器一样工作的团队中,一条规则可能就足够了。如果不是这样,任命一个主要负责绿色测试套件的构建大师是有意义的。

这并不意味着构建大师必须清理所有失败的测试。这更像是一个管理角色,与那些破坏了测试套件的人交谈,并确保他们清理干净。如果事实证明这不可行,那么恢复导致问题的提交,并在通过所有测试后重新包含它。

如果没有人觉得有必要一直做这个工作,构建大师的角色也可以在不同的开发人员之间轮换。

通知

通知可以帮助开发团队保持测试绿色,简单地通过通知成员关于被破坏的测试,从而让他们知道需要一个动作。通知可以通过电子邮件发送到开发人员使用的聊天系统,甚至发送到开发人员办公室中的监视器。Jenkins 丰富的插件生态系统几乎涵盖了所有常用的通知技术。

如果您将 Jenkins 配置为在测试套件中断时发送通知,那么也要将其配置为在测试套件再次通过时发送通知。否则,每个相关人员都会讨厌 Jenkins 只给出负面反馈的通知,这对成功的 CI 流程来说不是一个好位置。

功能分支和拉请求

如果您的开发工作流是基于特性分支的,并且可能是基于合并请求或拉请求的(其中第二个人评审并合并变更),那么在您的 CI 系统中包含这些分支也是有意义的。负责合并分支的开发人员可以在知道所有测试仍然通过特性分支的情况下这样做。

对于正式的合并请求或拉请求,GitHub 和 GitLab 等 Git 托管解决方案甚至支持这样一种模式,即只有在所有测试都通过的情况下,请求才能被合并。在这样的场景中,不仅要测试功能分支,还要测试功能分支和开发分支合并的结果,这是有意义的。这避免了所有测试在开发分支和特性分支中都通过,但是合并中断了一些测试的情况。

詹金斯可以将这些集成作为插件使用。 14

3.5 Jenkins 中的其他指标

一旦一个团队顺利地使用 CI 系统工作,您就可以使用它来收集关于软件的其他度量,并将其引导到一个理想的方向。请注意,仅在有限的实验中引入这样的度量,并且仅当您发现它们为开发过程提供了切实的价值时,才将其扩展到更大的项目。它们都伴随着维护成本和减少开发人员自主权的成本。

代码覆盖率

代码覆盖率衡量测试运行期间执行的一段源代码中的语句或表达式占表达式总数的百分比。代码覆盖率是一个简单的代理,代表了测试套件对代码的测试有多彻底,尽管应该有所保留,因为一段代码中路径号的组合爆炸会导致未检测到的错误,即使是在测试过的代码中。

pytest-cov15项目收集这样的数据,如果测试覆盖率低于某个阈值,您甚至可以使用它来使您的 CI 工作失败。

复杂性

衡量一个代码库的复杂度有各种各样的尝试,比如 复杂度可维护性指数,一个叫做radon16的工具可以为 Python 代码计算出来。虽然这些数字不太可靠,但观察它们的趋势可以让你对代码库的健康状况有所了解。

编程风格

当一个项目定义了一种编码风格时,它可以使用类似于 pylint 17flake 818的工具来检查存储库中的代码是否真正遵守了指导方针,如果检测到违反,甚至会导致构建失败。这两个工具带有一组默认规则,但是可以定制成您自己的规则。

架构约束检查

如果一个项目遵循一个定义良好的架构,那么可能会有可以通过编程方式检查的代码规则。例如,由用户界面(UI)、业务逻辑和存储后端组成的封闭三层系统可能具有如下规则:

  • UI 可能不直接使用存储后端,只使用业务逻辑。

  • 存储后端可能不会直接使用 UI。

如果这些层作为 Python 代码中的模块来处理,您可以编写一个小脚本来分析所有源文件中的导入语句,并检查它们是否违反了这些规则。静态导入分析器,如snakefood19可以使这变得更容易。

当检测到违规时,这样的工具应该使 CI 步骤失败。这允许您跟踪架构的思想是否在代码中实际实现,并防止代码慢慢削弱底层架构原则。

3.6 摘要

Jenkins 是一个 CI 服务器,它会自动为您运行测试套件,通常是针对源存储库中的每个新提交。这为您提供了测试套件状态的客观视图,可能在多个 Python 版本或平台上。

一旦你有了这个观点,你就可以有一个测试套件总是通过的过程,并且你可以从测试套件中获得价值。

当一个成熟的团队能够很好地处理 CI 过程时,您可以在 CI 过程中引入其他度量,比如代码覆盖率或者对架构规则的遵守。

四、持续交付

持续集成(CI)是健壮的现代软件开发的基石,但它不是软件开发方法的顶峰。相反,它是更先进技术的推动者。

当 CI 作业显示所有测试都通过时,您可以合理地确定软件可以独立工作。但是和其他软件配合的好吗?我们如何将它呈现在最终用户面前?这就是连续交付(CD)的用武之地。

当您实践 CD 时,您将软件的部署过程自动化,并在几个环境中重复它。您可以使用其中的一些环境进行自动化测试,例如全系统集成测试、自动化验收测试,甚至性能和渗透测试。当然,这并不排除手工问答,手工问答仍然可以发现一类自动化测试不容易发现的缺陷。最后,您使用相同的自动化在您的生产环境中部署软件,在那里它到达它的最终用户。

建立一个 CD 系统听起来的确是一项令人生畏的任务,事实也确实如此。然而,好处是多方面的,但也许并不是所有的好处都是一目了然的。

本章的其余部分将讨论 CD 的好处,并提供一个粗略的路线图来实现它。本书的其余部分致力于展示 CD 的简单方法和实现它的例子。

4.1 光盘和自动化部署的原因

因为实现 CD 可能需要大量的工作,所以清楚这样做的原因和潜在好处是有好处的。你也可以使用本节中的论点来说服你的管理层投资这种方法。

节约时间

在大中型组织中,应用及其基础设施通常由独立的团队开发和操作。每个部署都必须在这些团队之间进行协调。必须提交变更请求,必须找到适合两个团队的日期,必须传播关于新版本的信息(例如什么新配置是可用的或需要的),开发团队必须使二进制文件可供安装,等等。无论是在开发团队还是在运营团队,所有这些都很容易耗费每个版本几个小时或几天的时间。

然后,实际的部署过程也需要时间,通常伴随着停机。由于通常要在工作时间避免停机,部署必须在晚上或周末进行,这使得运营团队不太愿意执行任务。宝贵的善意也被人工调配消耗殆尽。

自动化部署可以节省大量时间和信誉。例如, Etsy 1 引入了连续(因此是自动化的)交付,将部署时间成本从“部署大军”的6-14 小时减少到单人15 分钟2

更短的发布周期

不言而喻,需要大量努力的任务比那些几乎不需要努力的任务完成得少得多。冒险的努力也是如此:我们倾向于避免经常这样做。

手动发布和部署的公司通常每周发布一次,甚至更少。有些公司每月甚至每季度发布一次。在更保守的行业,甚至每 6 个月或 12 个月发布一次也不是闻所未闻。

不频繁的发布总是导致冗长的开发过程和缓慢的上市时间。如果软件每季度部署一次,从规范到部署的时间很容易被缓慢的发布周期所支配,至少对于小的特性是如此。

这可能意味着,例如,在结账过程中用户体验不佳的在线企业必须等待大约三个月才能改善用户体验,这可能会花费大量金钱。自动化部署使得更频繁的发布更加容易,减轻了这种痛苦。

更短的反馈周期

获得软件反馈的最佳方式是将其部署到生产环境中。在那里,人们将实际使用它,然后你可以听听他们要说什么,甚至连续测量他们与系统不同部分的参与度。

如果您正在开发供公司内部使用的工具,您可能会让一些人在一个试运行环境中试用它们,但这并不容易。这需要他们从实际工作中抽出时间;必须用所有必要的数据(客户数据、库存等)来设置登台环境,甚至使其可用;然后那里的所有更改最终都会丢失。根据我的经验,让用户在非生产环境中进行测试是一项艰苦的工作,只有在重大变更时才值得。

对于手动的,因此也是不频繁的发布,反馈周期很慢,这违背了“敏捷”或“精益”开发过程的整体思想。

img/456760_1_En_4_Figa_HTML.jpg精益软件开发是一种受丰田精益制造流程启发的开发模式,其重点是减少不必要的工作、快速交付软件、学习和相关原则。

因为人类的交流容易产生误解,一个特性的第一次实现很少能满足最初的期望。反馈周期是不可避免的。因此,缓慢的发布周期导致缓慢的开发,使涉众和开发人员都感到沮丧。

但是也有副作用。当改进周期花费很长时间时,许多用户甚至都懒得请求小的改进。这是一个真正的遗憾,因为一个好的用户界面是由数以百计的小便利和锋利的边缘组成的,它们必须是圆形的。所以,从长远来看,缓慢的发布周期会导致更差的可用性和质量。

发布的可靠性

手动释放有一个恶性循环。它们往往是不频繁的,这意味着许多变化都集中在一个版本中。这增加了出错的风险。当一个大版本带来太多麻烦时,经理和工程师会寻找方法来提高下一个版本的可靠性,通过增加更多的验证步骤和过程。

但是更多的过程意味着更多的努力,更多的努力导致更慢的周期,导致每个版本更多的变化。你可以看到事情的发展。

自动化发布过程的步骤,甚至整个过程,是打破这种恶性循环的一种方式。在不折不扣地遵循指令方面,计算机比人强得多,在漫长的软件部署之夜结束时,它们的注意力不会下降。

一旦发布过程变得更可靠,执行起来更快,就很容易推动更频繁的发布,每一次都引入更少的变化。自动化节省的时间释放了资源来进一步改进自动化发布过程。

随着进行更多的部署,也带来了更多的经验,这使您能够更好地进一步改进流程和工具。

较小的增量使训练更容易

当一个部署引入了一个 bug,并且这个部署只引入了一两个特性或者 bug 修复,通常很容易就能找出是哪个变更导致了这个 bug (triaging)。相比之下,当许多变更是同一个部署的一部分时,就很难对新的 bug 进行分类,这意味着浪费了更多的时间,但是这也导致需要更长的时间才能修复缺陷。

更多的建筑自由

软件业当前的趋势是从巨大的、单一的应用转向更多更小组件的分布式系统。这就是微服务模式的全部内容。较小的应用或服务往往更容易维护,可伸缩性需求要求它们都必须能够在不同的机器上运行,并且通常每个服务都要在几台机器上运行。

但是,如果部署一个应用或服务已经很痛苦,那么部署十个甚至一百个更小的应用肯定会更痛苦,并且将微服务与手动部署混合在一起是完全不负责任的。

因此,自动部署打开了可能的软件架构的空间,您可以利用它来解决业务问题。

先进的质量保证技术

一旦你有了必要的基础设施,你就可以采用令人惊讶的 QA 策略。例如,GitHub 使用新老实现 3 的实时并行执行来避免结果和性能参数的倒退。

假设你开发了一个旅游搜索引擎,你想改进搜索算法。您可以同时部署引擎的旧版本和新版本,并针对两者运行传入的查询(或其中的一部分),并定义一些度量标准来评估它们。例如,快速旅行和低成本使航班衔接很好。你可以用它来发现新引擎比旧引擎表现差的情况,并使用这些数据来改进它。你也可以用这些数据来证明新搜索引擎的优越性,从而证明开发它所付出的努力是正确的。

但是,如果每个新版本都必须手动部署,并且部署每个版本是一项很大的工作,那么这样的实验是不实际的。自动部署不会自动给你带来这些好处,但是它是使用这种高级 QA 技术的先决条件。

4.2 光盘计划

我希望现在你已经相信裁谈会是一个好主意。当我到达那个阶段时,实际实施它的前景似乎相当令人生畏。

CD 的过程可以分解成几个步骤,每个步骤都可以单独管理。更好的是,每个步骤的自动化提供了好处,即使整个过程还没有自动化。

让我们来看看一个典型的光盘系统和所涉及的步骤。

管道架构

CD 系统的结构是一个流水线。版本控制系统中的新提交或分支触发流水线的实例化,并开始执行一系列阶段中的第一个。当一个阶段成功运行时,它会触发下一个阶段。如果失败,整个管道实例将停止。

那么手动干预是必要的,通常是通过添加新的提交来修复代码或测试,或者通过修复环境或管道配置。然后,管道的新实例或失败阶段的重新运行就有机会成功。

偏离严格的管道模型是可能的。例如,潜在地并行执行的分支允许在不同的环境中运行不同的测试,并等待下一步,直到两者都成功完成。分支成多个流水线,从而并行执行,称为扇出;在(图 4-1 )中,将管道连接成一个单独的分支称为风扇。

img/456760_1_En_4_Fig1_HTML.png

图 4-1

扇出分支管道;范也加入了他们

典型的阶段是构建、运行单元测试、部署到第一个测试环境、在那里运行集成测试、可能部署到各种测试环境并在其中进行测试,以及最终部署到生产环境(图 4-2 )。

img/456760_1_En_4_Fig2_HTML.png

图 4-2

部署管道的典型推荐阶段

有时候,这些阶段有点模糊。例如,Debian 包的典型构建也运行单元测试,这减少了对单独单元测试阶段的需求。同样,如果部署到一个环境中,对其部署到的每个主机运行冒烟测试,则不需要单独的冒烟测试阶段(图 4-3 )。

img/456760_1_En_4_Fig3_HTML.png

图 4-3

在实际的流水线中,将多个推荐的阶段合并成一个阶段是很方便的,并且可能有理论掩盖的额外阶段

通常,有一个软件控制整个管道的流量。它为一个阶段准备必要的文件,运行与该阶段相关的代码,收集其输出和工件(即该阶段产生的值得保留的文件,如二进制文件或测试输出),确定该阶段是否成功,然后继续下一个阶段。

从架构的角度来看,这使阶段不必知道下一步是什么阶段,甚至不必知道如何到达运行它的机器。它分离了阶段并保持关注点的分离。

反模式:每个环境独立构建

如果您为您的源代码使用一个分支模型,比如 GitFlow 4 ,那么自动将develop分支部署到测试环境是很有诱惑力的。当发布时间到来时,您将开发分支合并到master分支中(可能通过独立发布分支的间接方式),然后您自动构建master分支并将结果部署到生产环境中。

这很诱人,因为它是现有的、经过验证的工作流的直接扩展。不要这样做。

这种方法的一个大问题是,您实际上没有测试将要部署的内容,另一方面,您将一些未经测试的内容部署到生产环境中。即使您在部署到生产环境之前已经有了一个试运行环境,但是如果您没有实际发布在之前的环境中测试过的二进制文件或包,那么您所做的所有测试都是无效的。

如果您从不同的来源(比如不同的分支)构建“测试”和“发布”包,那么产生的二进制文件将会不同。即使您使用完全相同的源代码,构建两次仍然是一个坏主意,因为许多构建是不可重复的。不确定的编译器行为以及环境和依赖关系的差异都可能导致包在一个版本中运行良好,而在另一个版本中失败。最好通过将您在测试环境中测试过的版本部署到生产环境中,来避免这种潜在的差异和错误。

环境之间的行为差异(如果需要的话)应该由不属于构建的配置来实现。同样不言而喻的是,配置必须在版本控制之下并自动部署。有专门用于部署配置的工具,比如 Puppet、Chef 和 Ansible,后面的章节将讨论如何将它们集成到部署过程中。

一切都取决于包装形式

构建可部署的工件是 CD 管道的早期阶段:构建、存储库管理、安装和操作都依赖于包格式的选择。Python 软件通常被打包成源代码 tarball,格式由setuptools包确定,有时也打包成二进制包,由 Python 增强提案(PEP) 427 指定。 5

源 tarballs 和 wheels 都不特别适合部署正在运行的应用。它们在安装时缺少钩子来创建必要的系统资源(如用户帐户),启动或重新启动应用,以及其他特定于操作系统的任务。它们也不支持管理非 Python 依赖项,比如用 c 编写的数据库客户端库。

Python 包由 pip 包管理器安装,默认为系统范围的全局安装,有时与操作系统包管理器安装的 Python 包交互不佳。例如,存在虚拟环境形式的工作区,但是管理这些工作区需要额外的关注和努力。

最后,在开发和操作职责分开的情况下,操作团队通常更熟悉本地操作系统包。尽管如此,源代码 tarballs 作为创建更适合直接部署的格式的包的起点,发挥了非常有用的作用。

在本书中,我们部署到 Debian GNU/Linux 机器上,因此我们使用两步过程构建 Debian 包。首先,我们使用一个由setuptools支持的setup.py文件创建一个源 tarball。然后工具dh-virtualenv创建一个包含 virtualenv 的 Debian 包,该软件及其所有 Python 依赖项都安装在这个包中。

管理 Debian 仓库的技术

部署 Debian(和大多数其他)包的工作方式是将它们上传到一个中。然后,使用该存储库的 URL 配置目标机器。从目标机器的角度来看,这是一个基于拉的模型,允许它们获取尚未安装的依赖项。这些存储库由特定的目录布局组成,其中预定义名称和格式的文件包含元数据并链接到实际的包文件。

这些文件和目录可以通过传输机制公开,例如本地文件访问(可能通过网络文件系统挂载)、HTTP 和 FTP。HTTP 是很好的选择,因为它设置简单,易于调试,而且通常不会成为性能瓶颈,因为它是标准的系统组件。

有各种各样的软件来管理 Debian 仓库,其中大部分都没有很好的文档记录或者很少维护。一些解决方案,如 debarchiver、dak ,提供了通过 SSH 的远程上传,但没有给出上传是否成功的即时反馈。Debarchiver 还批量处理上传的文件,这是由 cron 作业触发的,这会导致延迟,从而降低自动化的乐趣。

我选择了**,* 6 ,这是一个用于管理存储库的命令行工具集。当您向存储库中添加一个新的包时,会以退出代码的形式给出及时的反馈。它没有提供一种方便的方式将文件上传到存储库所在的服务器上,但是这是管道管理器可以做的事情。*

*最后,Aptly 可以在一个存储库中保存同一个包的多个版本,这使得回滚到以前的版本更加容易。

安装软件包的工具

一旦您构建了一个 Debian 包,将它上传到一个存储库中,并配置了目标机器来使用这个存储库,交互式包安装看起来就像这样:

$ apt-get update && apt-get install $package

在自动化安装中有一些微妙之处需要注意。您必须关闭所有形式的交互,可能控制输出的详细程度,配置降级是否可接受,等等。

与其试图找出所有这些细节,不如重用一个现有的工具,该工具的作者已经完成了艰苦的工作。配置管理工具如 Ansible、 7 、Chef、 8 、木偶、 9 、10、雷克斯、 11 、都有安装包的模块

然而,并不是所有的配置管理系统都适合自动化部署。Puppet 通常在基于拉的模型中使用,在这种模型中,每个 Puppet 管理的机器定期联系服务器,并请求其目标配置。这对可伸缩性来说很好,但是集成到工作流中是一个大问题。在基于推的模型中,管理器联系被管理的机器(例如,通过 SSH)然后执行命令,这种模型更适合于部署任务(通常提供更简单、更愉快的开发和调试体验)。

对于这本书,我选择了 Ansible。这主要是因为我喜欢它的声明式语法,它的简单模型,并且到目前为止,谷歌搜索已经为所有实际问题找到了很好的解决方案。

控制管道

即使您从构建、测试、分发和安装软件的角度考虑部署管道,所做的大部分工作实际上是“粘合”,即使整个事情顺利运行的小任务。这些包括轮询版本控制系统,为构建作业准备目录,收集构建的包(或者在失败时中止当前管道实例),以及将工作分配给最适合该任务的机器。

当然,也有完成这些任务的工具。Jenkins 等一般 CI 和构建服务器通常可以完成这项工作。但也有专门做 CD 流水线的工具,比如 Go 连续交付 (GoCD) 12Concourse13

虽然 Jenkins 是一个很好的 CI 工具,但它以工作为中心的世界观使它不太适合 CD 的管道模型。在这里,我们将探索 GoCD,它是 ThoughtWorks,Inc .的开源软件,主要用 Java 编写,可用于大多数操作系统。为了方便基于 Debian 的开发环境,它提供了预构建的 Debian 包。

在接下来章节的例子中,我们将打包一个也运行单元测试的构建。在生产环境中,如果 Jenkins 中的所有测试都已通过,您可能会在 Jenkins 管道中包含一个使用 GoCD API 来触发 CD 步骤的构建后操作。

4.3 总结

CD 支持以小增量部署软件。这缩短了上市时间,缩短了反馈周期,并使得对新引入的 bug 进行分类变得更加容易。

CD 中涉及的步骤包括单元测试、包构建、包分发、安装和已安装包的测试。它由一个管道系统控制,为此我们将使用 GoCD。

*

五、构建包

我们将首先探索创建 Python 源代码 tarball 的基础知识,然后从这些 tarball 创建 Debian 包。

5.1 创建 Python 源代码 Tarball

要创建一个 Python 源代码 tarball,您必须编写一个使用distutilssetuptoolssetup.py脚本。然后python setup.py sdist以正确的格式创建 tarball。

distutils是 Python 标准库的一部分,但是缺少一些常用的特性。setuptools通过扩展distutils增加了这些特性。你使用这些工具中的哪一个主要是个人喜好和环境的问题。

这是一个非常小的setup.py文件,使用setuptools作为第二章中webcount的例子。

from setuptools import setup

setup(
    name = "webcount",
    version = "0.1",
    packages=['webcount', 'test'],
    install_requires=['requests'],
)

这将从setuptools导入setup函数,并使用关于包的元数据调用它——名称、版本、要包含的 Python 包列表和依赖项列表。

setuptools文档 1 列出了可以传递给setup函数的其他参数。最常用的包括

  • Author:用于维护人员的姓名。author_email是联系人的电子邮件地址。

  • 这应该是一个项目网站的链接。

  • package_data:用于向 tarball 添加非 Python 文件。

  • description:这是对软件包用途的一段描述。

  • python_requires:用于指定你的包支持哪些 Python 版本。

  • scripts:它可以保存作为可运行脚本安装的 Python 文件列表,而不仅仅是 Python 包。

setup.py文件就绪后,您可以运行python setup.py sdist,它会在dist目录中创建一个 tarball。该文件的命名类似于setup.py中的name,后跟破折号、版本号,然后是后缀.tar.gz。在我们的例子中,它是dist/webcount-0.1.tar.gz

5.2 用 dh-virtualenv 进行 Debian 打包

官方的 Debian 库有超过 40,000 个软件包,包括用所有通用编程语言编写的软件。为了支持这种规模和多样性,已经开发了一些工具来简化打包,而且还支持许多定制挂钩。

这个工具主要位于devscripts包中,它从debian目录中读取元数据和构建指令。

虽然 debhelper 工具的完整描述对于单独的一本书来说是一个足够大的主题,但我想在这里提供足够的信息来帮助您开始。

打包入门

dh-make包提供了一个创建框架debian目录的工具,其中已经填充了一些元数据和示例文件,您可以基于这些文件创建自己的版本。工具的其余部分然后利用debian包中的文件,从您的源代码构建二进制档案。

如果您在自己的开发环境中遵循这个示例,请确保在继续之前安装了dh-make包。

Debian 开发者的起点通常是另一个项目发布的带有源代码的 tar 存档,Debian 社区称之为上游的。对于上一章的示例项目,我们是自己的上游,使用 Git 存储库而不是 tarball,所以我们必须指示dh_make构建自己的“原始”tarball,如下所示:

$ dh_make --packageclass=s –yes --createorig \
     -p python-webcount_0.1
Maintainer Name     : Moritz Lenz
Email-Address       : moritz@unknown
Date                : Tue, 04 Sep 2018 15:04:35 +0200
Package Name        : python-webcount
Version             : 0.1
License             : blank
Package Type        : single
Currently there is not top level Makefile. This may require additional tuning Done. Please edit the files in the debian/ subdirectory now.

5.3 控制文件

debian/control有关于源包的元数据,并且可能有多个从这个源包构建的二进制包。对于python-webcount项目,经过一些小的编辑,它看起来像清单 5-1 。

Section: unknown
Priority: optional
Maintainer: Moritz Lenz <moritz@unknown>
Build-Depends: debhelper (>= 10), dh-virtualenv
Standards-Version: 4.1.2

Package: python-webcount
Architecture: any
Depends: python3
Description: Count occurrences of words in a web page

Listing 5-1File debian/control: Metadata for the Debian Package

Source: python-webcount

这声明了构建依赖关系dh-virtualenv,您需要安装它,以便构建 Debian 包。

指导构建过程

Debian 维护人员使用命令dpkg-buildpackagedebuild来构建 Debian 包。除此之外,这些工具以当前动作作为参数来调用debian/rules脚本。动作可以是configurebuildtestinstall等。

通常,debian/rules是一个 makefile,带有一个调用 deb helperdh的 catchall 目标%。最小的debian/rules脚本如下所示:

#!/usr/bin/make -f
%:
        dh $@

我们必须扩展它来调用dh-virtualenv并告诉dh-virtualenv使用 Python 3 作为其安装的基础。

%:
        dh $@ --with python-virtualenv

override_dh_virtualenv:
        dh_virtualenv --python=/usr/bin/python3

作为 makefile,这里的缩进必须是实际的制表字符,而不是一系列空格。

声明 Python 依赖项

dh-virtualenv需要一个名为requirements.txt的文件,该文件列出了 Python 的依赖关系,每一个都在单独的一行上(列出了 5-2 )。

flask
pytest
gunicorn

Listing 5-2File requirements.txt

这些行将被传递给命令行上的pip,因此指定版本号的工作就像在 pip 中一样,例如pytest==3.8.0。您可以使用这样一行代码

--index-url=https://...

指定一个指向您自己的 pypi 镜像的 URL,然后dh-virtualenv用它来获取包。

构建包

一旦这些文件准备就绪,您就可以使用以下命令来触发构建:

$ dpkg-buildpackage -b -us -uc

-b选项指示dpkg-buildpackage只构建二进制包(这是我们想要的可部署单元),而-us-uc跳过了 Debian 开发者将他们的包上传到 Debian 镜像的签名过程。

该命令必须在项目的根目录(也就是包含debian目录的目录)中调用,当成功时,它会将生成的.deb文件放入根目录的父目录中。

创建 python-matheval 包

打包matheval为 Debian 包python-matheval的工作方式类似于webcount。主要区别在于matheval是一个应该一直运行的服务。

我们使用 systemd2Debian、Ubuntu 和许多其他 Linux 发行版使用的 init 系统来控制服务进程。这是通过写一个单元文件,存储为debian/python-matheval.service来完成的。

[Unit]
Description=Evaluates mathematical expressions
Requires=network.target
After=network.target

[Service]
Type=simple
SyslogIdentifier=python-matheval
User=nobody
ExecStart=/usr/share/python-custom/python-matheval/bin/\
gunicorn --bind 0.0.0.0:8800 matheval.frontend:app

PrivateTmp=yes
InaccessibleDirectories=/home
ReadOnlyDirectories=/bin /sbin /usr /lib /etc

[Install]
WantedBy=multi-user.target

管理 systemd 单元文件是 Debian 软件包的一项标准任务,因此有一个助手工具可以为我们完成这项任务:dh-systemd。我们必须安装它,并在control文件中将它声明为一个构建依赖项(清单 5-3 )。

Source: python-matheval
Section: main
Priority: optional
Maintainer: Moritz Lenz <moritz.lenz@gmail.com>
Build-Depends: debhelper (>=9), dh-virtualenv,
               dh-systemd, python-setuptools
Standards-Version: 3.9.6

Package: python-matheval
Architecture: any
Depends: python3 (>= 3.4)
Description: Web service that evaluates math expressions.

Listing 5-3debian/control File for the python-matheval Package

debian/rules文件同样需要一个--with systemd参数。

#!/usr/bin/make -f
export DH_VIRTUALENV_INSTALL_ROOT=/usr/share/python-custom

%:
        dh $@ --with python-virtualenv --with systemd

override_dh_virtualenv:
        dh_virtualenv --python=/usr/bin/python3 --setuptools-test

大家熟悉的dpkg-buildpackage调用一起创建了一个 Debian 包,它在安装时会自动启动 web 服务,并在安装了新版本的包时重新启动它。

dh-virtualenv 的权衡

dh-virtualenv工具使得创建包含所有 Python 依赖项的 Debian 包变得非常容易。这对开发人员来说非常方便,因为这意味着他/她可以开始使用 Python 包,而不必从它们创建单独的 Debian 包。

这也意味着您可以依赖安装在同一台机器上的多个应用中的几个不同版本的 Python 包——如果您使用系统范围的 Python 包,这是不容易做到的。

另一方面,这种“胖包装”意味着如果一个 Python 包包含一个安全缺陷,或者一个严重的错误,那么您必须重新构建并部署所有包含有缺陷代码副本的 Debian 包。

最后,dh-virtualenv包被绑定到构建服务器上使用的 Python 版本。因此,例如,如果一个包是为 Python 3.5 构建的,它就不能与 Python 3.6 一起工作。如果您要从一个 Python 版本过渡到下一个版本,您必须并行地为两个版本构建包。

5.4 总结

我们分两步构建包:首先,基于 Python setuptools的 Python 源代码 tarball,然后通过dh-virtualenv构建二进制 Debian 包。这两个步骤都使用了一些文件,主要基于声明性语法。最终结果是一个大部分自包含的 Debian 包,只需要在目标机器上安装一个匹配的 Python 版本。

*

六、分发 Debian 软件包

一旦 Debian 软件包被构建,它必须被分发到将要安装它的服务器上。Debian,以及基本上所有其他的操作系统,都使用拉模型。包及其元数据存储在服务器上,客户端可以与服务器通信并请求元数据和包。

元数据和包的总和被称为一个。为了将包分发到需要它们的服务器,我们必须建立和维护这样一个存储库。

6.1 签名

在 Debian 领域,包是加密签名的,以确保它们不会在存储服务器上或传输过程中被篡改。因此,第一步是创建一个密钥对,用于对这个特定的存储库进行签名。(如果您已经有了用于签名包的 PGP 密钥,可以跳过这一步。)

下面假设您正在与一个没有 GnuPG 密匙环的原始系统用户一起工作,这个密匙环将用于维护 Debian 仓库。它还假设您已经在版本 2 或更高版本中安装了gnupg包。

首先,创建一个名为key-control-file-gpg2的文件,内容如下:

%no-protection
Key-Type: RSA
Key-Length: 1024
Subkey-Type: RSA
Name-Real: Aptly Signing Key
Name-Email: nobody@example.com
Expire-Date: 0
%commit
%echo done

用您自己的电子邮件地址或您所从事项目的电子邮件地址替换 nobody@example.com,然后运行以下命令:

$ gpg --gen-key --batch key-control-file-gpg2

该命令的输出包含如下所示的一行:

gpg: key D163C61A6C25A6B7 marked as ultimately trusted

十六进制数字串D163C...是密钥 ID,每次运行都不同。用它来导出公钥,我们稍后会用到它。

$ gpg --export --armor D163C61A6C25A6B7 > pubkey.asc

6.2 准备存储库

我恰当地使用了1来创建和管理存储库。它是一个没有服务器组件的命令行应用。

要初始化一个存储库,我首先必须想出一个名字。在这里,我称之为myrepo

$ aptly repo create -distribution=stretch \
     -architectures=amd64,i386,all -component=main myrepo

Local repo [myrepo] successfully added.
You can run 'aptly repo add myrepo ...' to add packages
to repository.

$ aptly publish repo -architectures=amd64,i386,all myrepo
Warning: publishing from empty source, architectures list
should be complete, it can't be changed after publishing
(use -architectures flag)
Loading packages...
Generating metadata files and linking package files...
Finalizing metadata files...
Signing file 'Release' with gpg, please enter your
passphrase when prompted:
Clearsigning file 'Release' with gpg, please enter your
passphrase when prompted:

Local repo myrepo has been successfully published.
Please set up your webserver to serve directory
'/home/aptly/.aptly/public' with autoindexing.
Now you can add following line to apt sources:
  deb http://your-server/ stretch main
Don't forget to add your GPG key to apt with apt-key.

You can also use `aptly serve` to publish your repositories over HTTP quickly.

现在已经创建了存储库,您可以通过运行

$ aptly repo add myrepo python_webcount_0.1-1_all.deb
$ aptly publish update myrepo

这将把.aptly/public中的文件更新为一个有效的 Debian 仓库,其中包含了新添加的包。

6.3 自动创建存储库和添加包

为了在部署管道中使用,使用一个命令创建存储库以及向这些存储库中添加包是很方便的。为不同的环境建立单独的存储库也是有意义的。因此,我们需要为测试、试运行和生产环境各准备一个存储库。第二个维度是为其构建包的发行版。

下面是一个小程序(清单 6-1 ),给定一个环境、一个发行版和一个 Debian 包的文件名列表,在路径$HOME/aptly/$environment/$distribution中创建存储库,添加包,然后更新存储库的公共文件:

#!/usr/bin/env python3

import json

import os

import os.path

import subprocess

import sys

assert len(sys.argv) >= 4, \
    'Usage: add-package <env> <distribution> <.deb-file>+'

env, distribution = sys.argv[1:3]
packages = sys.argv[3:]

base_path = os.path.expanduser('~') + '/aptly'
repo_path = '/'.join((base_path, env, distribution))
config_file = '{}/{}-{}.conf'.format(base_path, env,
                                     distribution)

def run_aptly(*args):
    aptly_cmd = ['aptly', '-config=' + config_file]
    subprocess.call(aptly_cmd + list(args))

def init_config():
    os.makedirs(base_path, exist_ok=True)
    contents = {
        'rootDir': repo_path,
        'architectures': ['amd64', 'all'],
    }
    with open(config_file, 'w') as conf:
        json.dump(contents, conf)

def init_repo():
    if os.path.exists(repo_path + '/db'):
        return
    os.makedirs(repo_path, exist_ok=True)
    run_aptly('repo', 'create',
        '-distribution=' + distribution, 'myrepo')
    run_aptly('publish', 'repo', 'myrepo')

def add_packages():
    for pkg in packages:
        run_aptly('repo', 'add', 'myrepo', pkg)

    run_aptly('publish', 'update', distribution)

if __name__ == '__main__':
    init_config();
    init_repo();
    add_packages();

Listing 6-1add-package, a Tool for Creating and Populating Debian Repositories

它可以用作

$ ./add-package testing stretch python-matheval_0.1-1_all.deb

python-matheval_0.1-1_all.deb文件添加到环境测试的扩展存储库中,如果存储库尚不存在,它会自动创建该存储库。

6.4 为存储库提供服务

事实上,这些存储库只能在一台机器上使用。使它们对更多机器可用的最简单的方法是通过 HTTP 将公共目录作为静态文件提供。

如果您使用 Apache 作为 web 服务器,那么为这些文件提供服务的虚拟主机配置可能如清单 6-2 所示。

ServerName apt.example.com
ServerAdmin moritz@example.com

DocumentRoot /home/aptly/aptly/
Alias /debian/testing/stretch/     \
    /home/aptly/aptly/testing/stretch/public/
Alias /debian/production/stretch/  \
    /home/aptly/aptly/production/stretch/public/
# more repositories go here

Options +Indexes +FollowSymLinks
Require all granted

LogLevel notice
CustomLog /var/log/apache2/apt/access.log combined
ErrorLog /var/log/apache2/apt/error.log
ServerSignature On

Listing 6-2Apache 2 Configuration for Serving Debian Repositories

在创建日志目录(mkdir -p /var/log/apache2/apt/)、启用虚拟主机(a2ensite apt.conf)并重启 Apache 之后,Debian 仓库就准备好了。

相反,如果您更喜欢使用 lighttpd2,您可以使用清单 6-3 中的配置片段。

dir-listing.encoding = "utf-8"
server.dir-listing = "enable"
alias.url = (
    "/debian/testing/stretch/"    =>
        "/home/aptly/aptly/testing/stretch/public/",
    "/debian/production/stretch/" =>
        "/home/aptly/aptly/production/stretch/public/",
    # more repositories go here
)

Listing 6-3lighttpd Configuration for Serving Debian Repositories

配置计算机以使用存储库

当机器使用一个新的存储库时,它首先必须信任用来签署存储库的密钥。

将 PGP 公钥(pubkey.asc)复制到将使用该存储库的机器上,并导入它。

$ apt-key add pubkey.asc

然后添加实际的包源。

$ echo "deb http://apt.example.com/ stretch main" \
    > /etc/apt/source.list.d/myrepo.list

在一个apt-get update之后,存储库的内容是可用的,并且一个apt-cache policy python-matheval将存储库显示为这个包的一个可能的源。

$ apt-cache policy python-webcount
python-webcount:
  Installed: (none)
  Candidate: 0.1-1
  Version table:
*** 0.1-1 0
      990 http://apt.example.com/ stretch/main amd64 Packages
      100 /var/lib/dpkg/status

Debian 仓库管理的旋风之旅到此结束,包分发也结束了。

6.5 总结

来自 APT 软件套件的 Debian 软件包安装程序,如apt-getaptitude读取元数据并从存储库中下载软件包。像这样的软件可以很好地管理这些存储库。

加密签名对软件包进行认证,并捕捉修改软件包的中间人攻击和传输错误。您必须创建一个 GPG 密钥,并适当地提供它,并将目标机器配置为信任此密钥。

七、包部署

在前面的章节中,您已经看到了 Debian 软件包是如何构建的,如何插入到一个存储库中,以及如何将这个存储库配置为目标机器上的软件包源。有了这些准备工作,交互式安装实际的包就变得容易了。

要安装python-matheval示例项目,运行

$ apt-get update
$ apt-get install python-matheval

在目标机器上。

如果需要多台计算机来提供服务,那么协调更新会很有好处,例如,一次只更新一台或两台主机,或者在转移到下一台主机后,在每台主机上进行一次小型集成测试。一个很好的工具是一个开源的 IT 自动化和配置管理系统。

7.1 可扩展:一级

Ansible 是一个非常实用和强大的配置管理系统,很容易上手。如果您已经熟悉 Ansible(或者选择使用不同的配置管理和部署系统),您可以安全地跳过这一节。

连接和库存

Ansible 通常用于通过安全外壳(SSH)连接到一个或多个远程机器,并使它们进入所需的状态。连接方法是可插拔的。其他方法包括local,它只是调用本地机器上的命令,以及docker,它通过 Docker 守护进程连接以配置一个正在运行的容器。Ansible 称这些远程机器为主机。

要告诉 Ansible 在哪里以及如何连接,您需要编写一个清单主机文件。在清单文件中,您可以定义主机和主机组,还可以设置控制如何连接到它们的变量(清单 7-1 )。

# example inventory file
[all:vars]
# variables set here apply to all hosts
ansible_ssh_user=root

[web]
# a group of webservers

www01.example.com
www02.example.com

[app]
# a group of 5 application servers,
# all following the same naming scheme:
app[01:05].example.com

[frontend:children]
# a group that combines the two previous groups
app
web

[database]
# here we override ansible_ssh_user for just one host
db01.example.com ansible_ssh_user=postgres

Listing 7-1File myinventory: an Ansible Hosts File

详见库存档案介绍 2

要测试连接,您可以在命令行上使用ping模块。

$ ansible -i myinventory web -m ping
www01.example.com | success >> {
    "changed": false,
    "ping": "pong"
}

www02.example.com | success >> {
    "changed": false,
    "ping": "pong"
}

让我们将命令行分成几个部分。-i myinventory告诉 Ansible 使用myinventory文件作为库存。web告诉 Ansible 在哪个主机上工作。它可以是一个组,如本例所示,也可以是单个主机,或者是几个这样的东西,用冒号隔开。例如,www01.example.com:database将选择一个 web 服务器和所有数据库服务器。

最后,-m ping告诉 Ansible 执行哪个模块。ping大概是最简单的模块。它只是发送响应"pong",而不在远程机器上进行任何更改,它主要用于调试库存文件和凭证。

这些命令在不同的主机上并行运行,因此打印响应的顺序可能会有所不同。如果在连接到主机时出现问题,在命令行中添加选项-vvvv,以获得更多输出,包括来自 SSH 的任何错误消息。

Ansible 隐式地为您提供了组all,您猜对了,它包含清单文件中配置的所有主机。

模块

每当你想通过 Ansible 在主机上做一些事情时,你就调用一个模块来完成它。模块通常接受指定应该发生什么的参数。在命令行上,您可以用ansible -m module –a 'arguments'添加这些参数。例如:

$ ansible -i myinventory database -m shell -a 'echo "hi there"'
db01.example.com | success | rc=0 >>
hi there

Ansible 提供了丰富的内置模块和第三方模块生态系统。大多数模块都是幂等的,这意味着在第一次运行之后,使用相同的参数重复执行不会带来任何变化。例如,不是指示 Ansible 创建一个目录,而是指示它确保该目录存在。第一次运行这样的指令会创建目录,第二次运行它不会做任何事情,但仍然会报告成功。

在这里,我想介绍几个常用的模块。

外壳模块

shell模块 3 在主机上执行一个 shell 命令,并接受一些选项,如chdir,在运行命令之前,切换到另一个工作目录。

$ ansible -i myinventory database -m shell -e 'pwd chdir=/tmp'
db01.example.com | success | rc=0 >>
/tmp

这很普通,但也是最后的选择。如果手头的任务有更具体的模块,您应该更喜欢更具体的模块。例如,您可以使用shell模块来确保系统用户的存在,但是更专业的用户模块 4 更易于使用,并且可能比临时编写的 shell 脚本做得更好。

复制模块

copy5 可以将文件从本地一字不差地复制到远程机器上。

$ ansible -i myinventory database -m copy \
     -a 'src=README.md dest=/etc/motd mode=644 db01.example.com' | success >> {
    "changed": true,
    "dest": "/etc/motd",
    "gid": 0,
    "group": "root",
    "md5sum": "d41d8cd98f00b204e9800998ecf8427e",
    "mode": "0644",
    "owner": "root",
    "size": 0,
    "state": "file",
    "uid": 0

}

模板模块

template 6 的工作方式大多与copy类似,但它在将源文件传输到远程主机之前,会将其解释为 Jinja2 模板7 。这通常用于创建配置文件和合并来自变量的信息(稍后将详细介绍)。

模板不能直接从命令行使用,而是在行动手册中使用,因此这里有一个简单的行动手册示例。

# file motd.j2
This machine is managed by {{team}}.

# file template-example.yml
---
- hosts: all
  vars:
    team: Slackers
  tasks:
   - template: src=motd.j2 dest=/etc/motd mode=0644

稍后将在行动手册中详细介绍,但是您可以看到,这定义了一个变量team,将其设置为值Slackers,并且模板对该变量进行插值。

运行行动手册

$ ansible-playbook -i myinventory \
    --limit database template-example.yml

在数据库服务器上创建一个包含内容的文件/etc/motd

This machine is managed by Slackers.

文件模块

file模块 8 管理着文件名的属性,比如权限,还可以让你创建目录和软硬链接。

$ ansible -i myinventory database -m file \
    -a 'path=/etc/apt/sources.list.d
            state=directory mode=0755'
db01.example.com | success >> {
    "changed": false,
    "gid": 0,
    "group": "root",
    "mode": "0755",
    "owner": "root",
    "path": "/etc/apt/sources.list.d",
    "size": 4096,
    "state": "directory",
    "uid": 0
}

apt 模块

在 Debian 和衍生发行版(比如 Ubuntu)上,安装和移除软件包通常是由来自apt家族的软件包管理器来完成的,比如apt-getaptitude,而在更新的版本中,则直接由apt二进制文件来完成。

apt模块 9 从 Ansible 内部对此进行管理。

$ ansible -i myinventory database -m apt \
    -a 'name=screen state=present update_cache=yes'
db01.example.com | success >> {
    "changed": false
}

这里已经安装了screen包,所以模块没有改变系统的状态。

单独的模块可用于管理 apt-keys 10 ,利用这些模块对存储库进行密码验证,以及管理存储库本身11

yum 和 zypper 模块

对于基于 RPM 的 Linux 发行版,可以使用 yum 12zypper模块 13 (在编写本文时,处于预览状态)。他们通过同名的包管理器来管理包的安装。

包装模块

package模块 14 使用它检测到的任何包管理器。因此,它比aptyum模块更通用,但支持的功能要少得多。例如,在apt的情况下,它不提供在做其他事情之前是否运行apt-get update的任何控制。

特定应用模块

到目前为止,所展示的模块与系统非常接近,但是也有一些模块用于实现常见的特定于应用的任务。例子包括处理 与数据库15网络相关的东西比如代理16 版本控制系统17 集群解决方案比如 Kubernetes18 等等。

剧本

行动手册可以包含以定义的顺序对模块的多次调用,并将其执行限制在单个主机或一组主机上。它们以 YAML 文件格式 19 编写,这是一种为人类可读性而优化的数据序列化文件格式。

这里有一个样例剧本(清单 7-2 )安装了最新版本的go-agent Debian 包,即 Go 连续交付 (GoCD)的工作器。 20

---
 - hosts: go-agent
   vars:
     go_server: go-server.example.com
   tasks:
   - apt: package=apt-transport-https state=present
   - apt_key:
        url: https://download.gocd.org/GOCD-GPG-KEY.asc
        state: present
        validate_certs: no
   - apt_repository:
        repo: 'deb https://download.gocd.org /'
        state: present
   - apt: update_cache=yes package={{item}} state=present
     with_items:
      - go-agent
      - git
      - build-essential
   - lineinfile:
        dest: /etc/default/go-agent
        regexp: ^GO_SERVER=
        line: GO_SERVER={{ go_server }}
   - copy:
       src: files/guid.txt
       dest: /var/lib/go-agent/config/guid.txt
       user: go
       group: go
   - service: name=go-agent enabled=yes state=started

Listing 7-2An Ansible Playbook

for Installing a GoCD Agent on a Debian-Based System

该文件中的顶级元素是一个单元素列表。单个元素以hosts: go-agent开始,这将执行限制到组go-agent中的主机。这是附带的清单文件的相关部分:

[go-agent]

go-worker01.p6c.org
go-worker02.p6c.org

然后,它将变量go_server设置为一个字符串,这里是运行 GoCD 服务器的主机名。

最后是剧本的核心部分:要执行的任务列表。每个任务都是对一个模块的调用,其中一些已经讨论过了。以下是一个快速概述。

  • 首先,apt安装 Debian 包apt-transport-https,以确保系统可以通过 HTTPS 从 Debian 仓库获取元数据和文件。

  • 接下来的两个任务使用 apt_repository 21apt_key22模块来配置将从其中安装实际go-agent包的存储库。

  • 另一个对apt的调用安装了所需的包。此外,一些更多的软件包安装了一个循环结构23

  • lineinfile模块 24 通过 regex(正则表达式)在文本文件中搜索一行,并用预定义的内容替换它找到的行。这里,我们使用它来配置代理连接到的 GoCD 服务器。

  • 最后, service 25 模块启动代理,如果它还没有运行(state=started),并确保它在重新引导时自动启动(enabled=yes)。

使用ansible-playbook命令调用剧本,例如ansible-playbook -i inventory go-agent.yml

一个行动手册中可以有多个任务列表,当它们影响不同的主机组时,这是一个常见的用例。

---
- hosts: go-agent:go-server
  tasks:
    - apt: package=apt-transport-https state=present
    - apt_key:
        url: https://download.gocd.org/GOCD-GPG-KEY.asc
        state: present
        validate_certs: no
    - apt_repository:
        repo: 'deb https://download.gocd.org /'
        state: present

- hosts: go-agent

  tasks:
    - apt: update_cache=yes package={{item}} state=present
      with_items:
       - go-agent
       - git
       - build-essential
     - ...

- hosts: go-server

  tasks:
    - apt: update_cache=yes package={{item}} state=present
    - apt: update_cache=yes package=go-server state=present
    - ...

变量

变量对于控制剧本中的流程和填充模板中的点以生成配置文件都很有用。有几种方法可以设置变量。一种方法是通过vars: ...直接在剧本中设置它们,如前所述。另一种方法是在命令行中指定它们。

ansible-playbook --extra-vars=variable=value theplaybook.yml

第三种非常灵活的方法是使用group_vars特性。对于主机所在的每个组,Ansible 查找文件group_vars/thegroup.yml和匹配group_vars/thegroup/*.yml的文件。一个主机可以同时属于几个组,这为您提供了额外的灵活性。

例如,您可以将每台主机分为两组,一组用于主机所扮演的角色(如 web 服务器、数据库服务器、DNS 服务器等。),另一个用于其所处的环境(测试、试运行、生产)。下面是一个使用这种布局的小例子。

# environments
[prod]
www[01:02].example.com
db01.example.com

[test]
db01.test.example.com
www01.test.example.com

# functional roles
[web]
www[01:02].example.com
www01.test.example.com

[db]
db01.example.com
db01.test.example.com

要仅配置测试主机,您可以运行

ansible-playbook --limit test theplaybook.yml

并将特定于环境的变量放在group_vars/test.ymlgroup_vars/prod.yml中,将特定于 web 服务器的变量放在group_vars/web.yml中,等等。

您可以在变量中使用嵌套的数据结构,如果这样做,您可以配置 Ansible 来合并这些数据结构,如果它们在几个源中被指定的话。您可以通过创建一个名为ansible.cfg的文件进行配置,其内容如下:

[defaults]

hash_behavior=merge

这样,您可以拥有一个设置默认值的文件group_vars/all.yml

# file group_vars/all.yml
myapp:
    domain: example.com
    db:
        host: db.example.com
        username: myappuser
        instance. myapp

然后覆盖该嵌套数据结构的单个元素,例如在group_vars/test.yml中,如下所示:

# file group_vars/test.yml
myapp:
    domain: test.example.com
    db:
        hostname: db.test.example.com

test组变量文件没有触及的键,例如myapp.db.username,是从all.yml文件继承的。

角色

角色是将剧本的各个部分封装成可重用组件的一种方式。让我们考虑一个导致简单角色定义的真实例子。

对于部署软件,您通常希望部署刚刚构建的确切版本,因此行动手册的相关部分是

- apt:
    name: thepackage={{package_version}}
    state: present
    update_cache: yes
    force: yes

但是这需要您在运行剧本时提供package_version变量。当您不运行新构建软件的部署,而是配置一台新机器,并且必须安装几个软件包,每个软件包都有自己的剧本时,这将是不实际的。

因此,我们将代码一般化,以处理缺少版本号的情况。

- apt:
    name: thepackage={{package_version}}
    state: present
    update_cache: yes
    force: yes
  when: package_version is defined
- apt: name=thepackage state=present update_cache=yes
  when: package_version is undefined

如果您将几个这样的行动手册包含在一个中,并在同一台主机上运行它们,您可能会注意到,它大部分时间都在为每个包含的行动手册运行apt-get update

第一次更新apt缓存是必要的,因为在部署之前,您可能刚刚在您的本地 Debian 镜像上上传了一个新的包,但是后续的运行是不必要的。因此,您可以将主机已经为其缓存更新的信息存储在一个 事实 26 中,这是 Ansible 中一种基于主机的变量。

- apt: update_cache=yes
  when: apt_cache_updated is undefined

- set_fact:
    apt_cache_updated: true

正如你所看到的,明智地安装一个包的代码库已经增长了一些,是时候把它分解成一个角色了。

角色是具有预定义名称的 YAML 文件的集合。命令

$ mkdir roles
$ cd roles
$ ansible-galaxy init custom_package_installation

为名为custom_package_installation的角色创建一个空框架。之前放在所有剧本中的任务现在放在角色主目录下的文件tasks/main.yml(清单 7-3 )中。

- apt: update_cache=yes
  when: apt_cache_updated is undefined
- set_fact:
    apt_cache_updated: true

- apt:
    name: {{package}={{package_version}}
    state: present
    update_cache: yes
    force: yes
  when: package_version is defined
- apt: name={{package} state=present update_cache=yes
  when: package_version is undefined

Listing 7-3File roles/custom_package_installation/tasks/main.yml

要使用该角色,请将其包含在行动手册中,如下所示:

---
- hosts: web
  pre_tasks:
     - # tasks that are executed before the role(s)
  roles:
     role: custom_package_installation
     package: python-matheval
  tasks:
    - # tasks that are executed after the role(s)

pre_taskstasks是可选的。只包含角色的剧本就可以了。

Ansible 有更多的功能,如处理程序,允许您在任何更改后只重新启动服务一次,更灵活的服务器环境的动态清单,加密变量的保险库27 ,以及用于管理常见应用和中间件的现有角色的丰富生态系统。

关于 Ansible 的更多内容,我强烈推荐优秀的书籍 Up and Running, 2nd ed。,作者洛林·霍赫斯坦(奥赖利传媒,2017)。

7.2 使用 Ansible 部署

有了上一节的 Ansible 知识,部署就成了一项简单的任务。我们从环境的单独清单文件开始(清单 7-4 )。

[web]
www01.yourorg.com
www02.yourorg.com

[database]
db01.yourorg.com

[all:vars]
ansible_ssh_user=root

Listing 7-4Ansible Inventory File production

也许测试环境只需要一台 web 服务器(清单 7-5 )。

[web]
www01.testing.yourorg.com

[database]
db01.stagingyourorg.com

[all:vars]
ansible_ssh_user=root

Listing 7-5Ansible Inventory File testing

在测试环境中的 web 服务器上安装包python-matheval现在是一行程序。

$ ansible -i testing web -m apt -a 'name=python-matheval update_cache=yes state=latest'

一旦您开始使用 Ansible 进行部署,您可能还想使用它来执行其他配置管理任务,因此为您想要部署的每个包编写一个行动手册是有意义的。这里有一个(清单 7-6 )使用了本章前面“角色”一节中的包安装角色。

---
- hosts: web
  roles:
    role: custom_package_installation
    package: python-matheval

Listing 7-6File deploy-python-matheval.yml: Deployment Playbook for Package python-matheval

然后,您可以像这样调用它

$ ansible-playbook -i testing deploy-python-matheval.yml

7.3 总结

Ansible 可以为您安装软件包,但它还可以做更多的事情。它可以配置操作系统和应用,甚至在几台机器上协调流程。

通过编写一个清单文件,你告诉 Ansible 它控制哪些机器。行动手册指定要做什么,使用模块来完成单个任务,例如创建用户或安装软件。

八、自动化部署的虚拟平台

在接下来的章节中,我们将探索工具 Go continuous delivery (GoCD)以及如何用它来打包、分发和部署软件。如果你想继续下去,用这些章节中描述的东西做实验,你将需要一些你能在上面做的机器。

如果您没有公共云或私有云来运行虚拟机(VM)并尝试这些示例,您可以使用本章介绍的工具在您的笔记本电脑或工作站上创建一个 VM 游乐场。即使您可以访问云解决方案,您也可能想要使用这里提供的一些脚本来设置和配置这些机器。

8.1 要求和资源使用

我们想在虚拟操场上做的事情是

  • 构建 Debian 包。

  • 将它们上传到本地的 Debian 仓库。

  • 在一台或多台服务器上安装软件包。

  • 运行一些精简和简单的 web 服务。

  • 使用 Ansible 运行部署和配置脚本。

  • 通过 GoCD 服务器和代理控制一切。

除了最后一项任务,所有这些任务都需要很少的资源。GoCD 服务器需要最多的资源,最低 1GB 内存,建议 2GB 内存。Go 服务器也是一个保存持久状态(比如配置和管道历史)的系统,您通常不想丢失这些状态。

因此,最简单的方法,也是我在这里采用的方法,是在主机上安装 Go 服务器,这是我通常使用的笔记本电脑或工作站。

然后是一个运行 Go 代理的虚拟机,Debian 包将在其上构建。另外两个虚拟机作为目标机器,新构建的包将在其上安装和测试。其中一个用作测试环境,另一个用作生产环境。

对于这三个虚拟机,工具提供的默认半 GB 内存已经足够了。如果您使用这个平台,并且主机上没有足够的内存,您可以尝试将这些虚拟机的内存使用减半。对于目标机器来说,甚至 200MB 就足够启动了。

8.2 介绍流浪者

layer 是传统虚拟化解决方案的抽象层,如 KVM 和 VirtualBox。它为您的虚拟机提供基础映像(称为),为您管理虚拟机,并为初始配置提供统一的 API。它还创建了一个虚拟专用网,允许主机与虚拟机通信,反之亦然。

要安装游民,可以从 www.vagrantup.com/downloads.html 下载安装程序,或者如果你用的是带包管理器的操作系统,比如 Debian 或者 RedHat,可以通过包管理器安装。在 Debian 和 Ubuntu 上,你可以用apt-get install vagrant来安装它(尽管不要用 2.0 系列,而是从流浪者的网站上安装 2.1 或更新的版本,如果通过软件包管理器只能获得 2.0 的话)。

你也要用同样的方法安装virtualbox,它充当了流浪汉的后端。安装了 After 后,运行以下命令:

$ vagrant plugin install vagrant-vbguest

这将安装一个浮动插件,该插件会自动在浮动框中安装访客工具,从而提高可配置性和可靠性。

要使用 vagger,您需要编写一个名为Vagrantfile的小 Ruby 脚本,它将一个或多个盒子实例化为 VM。您可以配置端口转发、专用或桥接网络,并在主机和来宾虚拟机之间共享目录。

vagrant命令行工具允许您使用vagrant up命令创建和配置虚拟机,使用vagrant ssh连接到虚拟机,使用vagrant status获取状态摘要,使用vagrant destroy再次停止和删除虚拟机。不带任何参数调用vagrant会给出可用选项的摘要。

你可能想知道为什么我们使用流浪者虚拟机而不是 Docker 容器。原因是 Docker 被优化为运行单个进程或进程组。但是对于我们的用例,我们必须运行至少三个进程:GoCD 代理和我们实际上想要在那里运行的应用;恰当地说,管理 Debian 仓库;和 HTTP 服务器,以允许对存储库的远程访问。

网络和流浪者设置

我们将使用地址从 172.28.128.1 到 172.28.128.254 的虚拟专用 IP 网络。当您将此范围内的一个或多个地址分配给虚拟机时,range 会自动将地址 172.28.128.1 分配给主机。

我已经将这几行添加到我的/etc/hosts文件中。这并不是绝对必要的,但它使与虚拟机的对话变得更加容易。

# Vagrant
172.28.128.1 go-server.local
172.28.128.3 testing.local
172.28.128.4 production.local
172.28.128.5 go-agent.local

我还在我的∾/.ssh/config文件中添加了几行。

Host 172.28.128.* *.local
    User root
    StrictHostKeyChecking no
    IdentityFile /dev/null
    LogLevel ERROR

img/456760_1_En_8_Figb_HTML.jpg 不要对生产机器进行此操作。这仅在单台机器上的虚拟网络中是安全的,通过它您可以确保没有攻击者存在,除非他们已经危害了您的机器。

创建和销毁虚拟机在流浪者之地很常见,每次你重新创建它们,它们都会有新的主机密钥。如果没有这样的配置,您将花费大量时间来更新 SSH 密钥指纹。

img/456760_1_En_8_Figc_HTML.jpg这里介绍的Vagrantfile和 Ansible playbook 可以在 GitHub 上的deployment-utils资源库的playground文件夹中找到。为了跟上,你可以这样使用它:

$ git clone https://github.com/python-ci-cd/deployment-utils.git

$ cd deployment-utils/playground

$ vagrant up

$ ansible-playbook setup.yml

清单 8-1 显示了为虚拟操场创建盒子的流浪者文件。

Vagrant.configure(2) do |config|
  config.vm.box = "debian/stretch"

  {
    'testing'    => "172.28.128.3",
    'production' => "172.28.128.4",
    'go-agent'   => "172.28.128.5",
  }.each do |name, ip|
    config.vm.define name do |instance|
        instance.vm.network "private_network", ip: ip,
            auto_config: false
        instance.vm.hostname = name + '.local'
    end
  end

  config.vm.provision "shell" do |s|
    ssh_pub_key = File.readlines("#{Dir.home}/.ssh/id_rsa.pub")
        .first.strip
    s.inline = <<-SHELL
      mkdir -p /root/.ssh
      echo #{ssh_pub_key} >> /root/.ssh/authorized_keys
    SHELL
  end

end

Listing 8-1Vagrantfile for the Playground

这个Vagrantfile假设您有一个 SSH 密钥对,并且公钥位于您的主目录下的.ssh/id_rsa.pub路径中,这是 Linux 上 RSA SSH 密钥的默认位置。它使用流浪者的shell provisioner 将公钥添加到虚拟机内部根用户的authorized_keys文件中,这样您就可以通过 SSH 在客户机上登录。(流浪者提供了一个vagrant ssh命令用于连接,没有这个额外的步骤,但我发现直接使用系统ssh命令更容易,主要是因为它不依赖于当前工作目录中Vagrantfile的存在。)

在带有Vagrantfile的目录中,您可以运行

$ vagrant up

启动和配置三台虚拟机。第一次做的时候要花几分钟,因为流浪汉要先下载基盒。

如果一切顺利,您可以通过调用vagrant status来检查这三个虚拟机是否正在运行,如下所示:

$ vagrant status
Current machine states:

testing                   running (virtualbox)
production                running (virtualbox)
go-agent                  running (virtualbox)

This environment represents multiple VMs. The VMs are all
listed above with their current state. For more information
about a specific VM, run `vagrant status NAME`.

(在基于 Debian 的 Linux 系统上)您应该能够看到新创建的私有网络。

$ ip route | grep vboxnet
172.28.128.0/24 dev vboxnet1 proto kernel scope link
    src 172.28.128.1

现在,您可以使用主机名ssh root@go-agent.localtesting.localproduction.local登录虚拟机。

8.3 配置机器

为了配置虚拟机,我们从一个小的ansible.cfg文件开始(清单 8-2 )。

[defaults]

host_key_checking = False
inventory = hosts
pipelining=True

Listing 8-2ansible.cfg: A Configuration File for the Playground

img/456760_1_En_8_Figd_HTML.jpg禁用主机密钥检查只能在开发系统的可信虚拟网络中进行,而不能在生产环境中进行。

虚拟机及其 IP 列在清单文件中(清单 8-3 )。

[all:vars]

ansible_ssh_user=root

[go-agent]

agent.local ansible_ssh_host=172.28.128.5

[aptly]

go-agent.local

[target]

testing.local ansible_ssh_host=172.28.128.3
production.local ansible_ssh_host=172.28.128.4

[testing]

testing.local

[production]

production.local

Listing 8-3hosts Inventory File for the Playground

接下来是剧本(清单 8-4 ),它完成了运行 GoCD 代理、适当的存储库以及从go-agent虚拟机到目标虚拟机的 SSH 访问所需的所有配置。

---
 - hosts: go-agent
   vars:
     go_server: 172.28.128.1
   tasks:
   - group: name=go system=yes
   - name: Make sure the go user has an SSH key
     user: >
        name=go system=yes group=go generate_ssh_key=yes
        home=/var/go
   - name: Fetch the ssh public key, so we can distribute it.
     fetch:
        src: /var/go/.ssh/id_rsa.pub
        dest: go-rsa.pub
        fail_on_missing: yes
        flat: yes
   - apt: >
        package=apt-transport-https state=present
        update_cache=yes

   - apt_key:
        url: https://download.gocd.org/GOCD-GPG-KEY.asc
        state: present
        validate_certs: no
   - apt_repository:
        repo: 'deb https://download.gocd.org /'
        state: present
   - apt: package={{item}} state=present force=yes
     with_items:
      - openjdk-8-jre-headless
      - go-agent
      - git

   - file:
       path: /var/lib/go-agent/config
       state: directory
       owner: go
       group: go
   - copy:
       src: files/guid.txt

       dest: /var/lib/go-agent/config/guid.txt
       owner: go
       group: go
   - name: Go agent configuration for versions 16.8 and above
     lineinfile:
        dest: /etc/default/go-agent
        regexp: ^GO_SERVER_URL=
        line: GO_SERVER_URL=https://{{ go_server }}:8154/go
   - service: name=go-agent enabled=yes state=started

- hosts: aptly
  tasks:
    - apt: package={{item}} state=present
      with_items:
       - ansible
       - aptly
       - build-essential
       - curl
       - devscripts
       - dh-systemd
       - dh-virtualenv
       - gnupg2
       - libjson-perl
       - python-setuptools
       - lighttpd
       - rng-tools

    - copy:
       src: files/key-control-file-gpg2
       dest: /var/go/key-control-file
    - command: killall rngd
      ignore_errors: yes
      changed_when: False
    - command: rngd -r /dev/urandom
      changed_when: False
    - command: gpg --gen-key --batch /var/go/key-control-file
      args:
        creates: /var/go/.gnupg/pubring.gpg
      become_user: go
      become: true
      changed_when: False
    - shell: gpg --export --armor > /var/go/pubring.asc
      args:
        creates: /var/go/pubring.asc
      become_user: go
      become: true
    - fetch:
        src: /var/go/pubring.asc
        dest: deb-key.asc
        fail_on_missing: yes
        flat: yes
    - name: Bootstrap aptly repos on the `target` machines
      copy:
       src: ../add-package
       dest: /usr/local/bin/add-package
       mode: 0755
    - name: Download an example package to fill the repo with
      get_url:
       url: https://perlgeek.de/static/dummy.deb
       dest: /tmp/dummy.deb
    - command: >
           /usr/local/bin/add-package {{item}}
           stretch /tmp/dummy.deb
      with_items:
        - testing
        - production
      become_user: go
      become: true
    - user: name=www-data groups=go

    - name: Configure lighttpd to serve the aptly directories
      copy:
           src: files/lighttpd.conf
           dest: /etc/lighttpd/conf-enabled/30-aptly.conf
    - service: name=lighttpd state=restarted enabled=yes

- hosts: target

  tasks:
    - authorized_key:
       user: root
       key: "{{ lookup('file', 'go-rsa.pub') }}"
    - apt_key:
           data: "{{ lookup('file', 'deb-key.asc') }}"
           state: present

- hosts: production
  tasks:
    - apt_repository:
        repo: >
           deb http://172.28.128.5/debian/production/stretch
           stretch main
        state: present

- hosts: testing
  tasks:
    - apt_repository:
        repo:
           deb http://172.28.128.5/debian/testing/stretch
           stretch main
        state: present

- hosts: go-agent
  tasks:
    - name: 'Checking SSH connectivity to {{item}}'
      become: True
      become_user: go
      command: >
         ssh -o StrictHostkeyChecking=No
         root@"{{ hostvars[item]['ansible_ssh_host'] }}" true
      changed_when: false
      with_items:
           - testing.local

           - production.local

Listing 8-4File setup.yml: An Ansible Playbook for Configuring the Three VMs

这做了很多事情。它

  • 安装和配置 GoCD 代理

    • 它将一个具有固定 UID 的文件复制到 Go 代理的配置目录中,这样当您拆除机器并重新创建它时,Go 服务器将把它识别为与以前相同的代理。
  • 通过以下方式授予go-agent机器上的go用户对目标主机的 SSH 访问权限

    • 首先确保 Go 用户有一个 SSH 密钥

    • 将公共 SSH 密钥复制到主机

    • 随后使用authorized_key模块将其分发到目标机器

  • 为用户go创建一个 GPG 密钥对

    • 因为 GPG 密钥创建对随机数使用大量熵,而虚拟机通常没有那么多熵,所以它首先安装rng-tools并使用它来说服系统使用较低质量的随机性。同样,这是你永远不应该在生产环境中做的事情。
  • 将所述 GPG 密钥对的公钥复制到主机,并使用apt_key模块将其分发给目标机

  • 通过以下方式在go-agent机器上创建一些合适的基于 Debian 的仓库

    • add-package脚本从同一个存储库复制到go-agent机器上

    • 用一个虚拟包运行它来实际创建存储库

    • 安装和配置 lighttpd 以通过 HTTP 提供这些包

    • 配置目标机器使用这些存储库作为包源

  • 检查go-agent机器上的Go用户确实可以通过 SSH 访问其他虚拟机

在使用ansible-playbook setup.yml运行剧本之后,您有一个 GoCD 代理等待连接到服务器。下一章将介绍 GoCD 服务器的安装。安装 GoCD 服务器后,您必须在 web 配置中激活代理并分配适当的资源(debian-stretchbuildaptly,如果您遵循本书中的示例)。

8.4 总结

通过管理虚拟机和专用网络,帮助你建立一个虚拟的 CD 游乐场。我们已经看到了一个可行的剧本,它配置这些机器来提供在主机上运行 GoCD 服务器所需的所有基础设施。

九、持续交付中的构建

前面的章节已经展示了从源代码到部署的基本步骤的自动化:构建、发布和部署。现在缺少的是将它们结合在一起的粘合剂:轮询源代码存储库,将包从构建服务器发送到存储库服务器并通常控制流程,当一个步骤失败时中止管道实例,等等。

我们将用 ThoughtWorks 的 Go 连续交付 1 (GoCD 或 Go)作为胶水。

9.1 关于 Go 持续交付

GoCD 是一个用 Java 编写的开源项目,它的 web 接口组件是用 Ruby on Rails 编写的。它在 2010 年开始作为专有软件,并在 2014 年开源。

您可以下载适用于 Windows、OSX、Debian 和基于 RPM 的 Linux 发行版以及 Solaris 的 GoCD。ThoughtWorks 提供 GoCD 的商业支持。

它由一个服务器组件组成,该组件保存管道配置、轮询源代码存储库的更改、调度和分发工作、收集工件、提供一个 web 界面来可视化和控制所有这些,并提供一个手动批准步骤的机制。

一个或多个代理连接到服务器并执行构建管道中的实际任务。

管道组织

GoCD 执行的每个构建、部署或测试工作都必须是管道的一部分。流水线由一个或多个线性排列的组成。在一个阶段中,一个或多个作业潜在地并行运行,并被单独分配给代理。任务在一个作业中连续执行。

在任务中,您可以依赖于同一作业中以前的任务生成的文件,而在作业和阶段之间,您必须显式地捕获它们,并在以后将其作为工件进行检索。更多信息请见下文。

最常见的任务是执行外部程序。其他任务包括检索工件或特定于语言的东西,比如运行 Ant 或 Rake 构建。 2

管道可以触发其他管道,让你形成一个非循环的、有向的管道图(图 9-1 )。

img/456760_1_En_9_Fig1_HTML.png

图 9-1

GoCD 管道可以形成一个图。管道由连续的阶段组成,在这些阶段中,几个作业可以并行运行。任务在一个作业中连续执行。

作业与代理的匹配

当代理空闲时,它轮询服务器的工作。如果服务器有作业要运行,它使用两个标准来决定代理是否适合执行作业:环境资源

每个作业都是管道的一部分,如果您选择使用环境,管道就是环境的一部分。另一方面,每个代理被配置为一个或多个环境的一部分。代理仅接受来自其环境之一的管道的作业。

资源是用户定义的标签,描述代理必须提供的内容,在管道配置中,您可以指定作业需要哪些资源。例如,如果您定义一个作业需要使用phantomjs资源来测试一个 web 应用,那么只有您分配了该资源的代理才会执行该作业。将操作系统和版本作为资源添加是一个好主意。在前面的例子中,代理可能拥有phantomjsdebiandebian-stretch资源,为作业的作者提供了指定所需操作系统的粒度选择。

关于环境的一句话

GoCD 使得在特定环境中运行代理成为可能。例如,可以在每台测试机器和每台生产机器上运行 Go 代理,并将管道与代理环境相匹配,以确保安装步骤发生在正确环境中的正确机器上。如果您使用这个模型,您还可以使用 GoCD 将构建工件复制到需要它们的机器上。

我选择不这样做,因为我不想在我想部署的每台机器上安装 GoCD 代理。相反,我使用在 GoCD 代理上执行的 Ansible 来控制环境中的所有机器。这需要管理 Ansible 使用的 SSH 密钥,并通过 Debian 仓库分发包。但是因为 Debian 无论如何都需要一个存储库来解决依赖性,所以这并不是额外的负担。

材料

GoCD 中的一个素材有两个用途:它触发一个管道,它提供管道中的任务可以使用的文件。

我倾向于使用 Git 存储库作为素材,GoCD 可以轮询这些存储库,在新版本可用时触发管道。GoCD 代理还将存储库克隆到代理执行其作业的文件系统中。

有针对各种源代码控制系统的材料插件,比如 Subversion (svn)和 mercurial,还有将 Debian 和 RPM 包存储库视为材料的插件。

最后,管线可以作为其他管线的材料。使用此功能,您可以构建管线图。

史前古器物

GoCD 可以收集工件,这些工件是由一个作业生成的文件或目录。同一管道的后续部分,甚至是其他连接的管道,都可以检索这些工件。工件的检索不限于在同一台代理机器上创建的工件。

您还可以从 web 界面和 GoCD 服务器提供的REST API 中检索工件。 3

当磁盘空间变得不足时,可以将工件存储库配置为丢弃旧版本。

9.2 安装

为了使用 GoCD,您必须在一台机器上安装 GoCD 服务器,并在至少一台机器上安装 GoCD 代理。这可以与服务器在同一台机器上,也可以在不同的机器上,只要它可以通过端口 8153 和 8154 连接到 GoCD 服务器。

当您的基础设施和管道数量增长时,您可能会运行几个 Go 代理。

在 Debian 上安装 GoCD 服务器

要在基于 Debian 的操作系统上安装 GoCD 服务器,首先你必须确保你可以通过 HTTPS 下载 Debian 软件包。

$ apt-get install -y apt-transport-https

然后,您必须配置软件包源。

$ echo 'deb https://download.gocd.org /' \
        > /etc/apt/sources.list.d/gocd.list
$ curl https://download.gocd.org/GOCD-GPG-KEY.asc \
        | apt-key add -

最后安装。

$ apt-get update && apt-get install -y go-server

在 Debian 9 上,codename Stretch ,Java 8 开箱即用。在 Debian 的旧版本中,你可能不得不从其他来源安装 Java 8,比如 Debian Backports4

现在,当您将浏览器指向 HTTPS Go 服务器的端口 8154(忽略 SSL 安全警告)或 HTTP 的端口 8153 时,您应该会看到 GoCD 服务器的 web 界面(图 9-2 )。

img/456760_1_En_9_Fig2_HTML.jpg

图 9-2

GoCD 的初始网络界面

如果你得到一个连接被拒绝的错误,检查/var/log/go-server/下的文件,寻找出错的提示。

为了防止未经认证的访问,您可以安装认证插件,例如基于密码文件的认证 5 或基于 LDAP 或 Active Directory 的认证。 6

在 Debian 上安装 GoCD 代理

在您想要执行自动构建和部署步骤的一台或多台机器上,您必须安装一个 Go 代理,它将连接到服务器并轮询它的工作。

关于 GoCD 代理的自动安装示例,请参见第八章。如果您想改为手动安装,您必须执行与安装 GoCD 服务器时相同的前三个步骤,以确保您可以从 GoCD 软件包资源库安装软件包。然后,当然,你安装 Go 代理。在基于 Debian 的系统上,如下所示:

$ apt-get install -y apt-transport-https
$ echo 'deb https://download.gocd.org /' >
    /etc/apt/sources.list.d/gocd.list
$ curl https://download.gocd.org/GOCD-GPG-KEY.asc \
    | apt-key add -
$ apt-get update && apt-get install -y go-agent

然后编辑文件/etd/default/go-agent。第一行应为

GO_SERVER_URL=https://127.0.0.1:8154/go

将变量更改为指向您的 GoCD 服务器,然后启动代理。

$ service go-agent start

几秒钟后,代理将联系服务器。当您在 GoCD 服务器的 web 界面中单击代理菜单时,您应该会看到代理(图 9-3 )。

img/456760_1_En_9_Fig3_HTML.jpg

图 9-3

GoCD 的代理管理界面截图。(lara是这里代理的主机名。)

第一次接触 GoCD 的 XML 配置

有两种方法可以配置 GoCD 服务器:通过 web 界面和 XML 格式的配置文件。您还可以通过 web 界面编辑 XML 配置。 7

虽然 web 界面是探索 GoCD 功能的好方法,但由于点击次数太多,它很快就变得令人讨厌。使用具有良好 XML 支持的编辑器可以更快地完成工作,而且它更适合于紧凑的解释,所以这就是我在这里选择的路线。您还可以在同一个 GoCD 服务器实例上使用这两种方法。

在管理菜单中,Config XML 项目允许您查看和编辑服务器配置。清单 9-1 是一个原始的 XML 配置,已经注册了一个代理。

<?xml version="1.0" encoding="utf-8"?>

<cruise

    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="cruise-config.xsd"
    schemaVersion="77">
<server artifactsdir="artifacts"
        commandRepositoryLocation="default"
        serverId="b2ce4653-b333-4b74-8ee6-8670be479df9">

</server>

<agents>

    <agent hostname="lara" ipaddress="192.168.2.43"
        uuid="19e70088-927f-49cc-980f-2b1002048e09" />

</agents>

</cruise>

Listing 9-1Baseline GoCD XML Configuration, with One Agent Registered

即使您遵循相同的步骤,代理的serverId和数据在您的安装中也会有所不同。

为了给代理一些资源,您可以将<agents>部分中的<agent .../>标记改为如清单 9-2 所示。

<agent hostname="lara" ipaddress="192.168.2.43"
    uuid="19e70088-927f-49cc-980f-2b1002048e09">
  <resources>
    <resource>debian-stretch</resource>
    <resource>build</resource>
    <resource>aptly</resource>
  </resources>

</agent>

Listing 9-2GoCD XML Configuration for an Agent with Resources

创建 SSH 密钥

对于 GoCD 来说,拥有一个没有密码的 SSH 密钥是很方便的,例如,能够通过 SSH 克隆 Git 存储库。要创建一个,请在服务器上运行以下命令:

$ su - go
$ ssh-keygen -t rsa -b 2048 -N " -f ~/.ssh/id_rsa

要么将生成的.ssh目录和其中的文件复制到每个代理的/var/go目录中(记得在最初创建时设置所有者和权限),要么在每个代理上创建一个新的密钥对。

9.3 管道中的建筑

触发 Debian 包的构建需要从 Git 存储库中获取源代码,将其配置为 GoCD 材料,然后调用带有一些选项的dpkg-buildpackage命令,最后收集结果文件。

这里(清单 9-3 )是构建python-matheval包的第一步,用 GoCD 的 XML 配置表示。

<pipelines group="deployment">
  <pipeline name="python-matheval">
    <materials>
      <git
url="https://github.com/python-ci-cd/python-matheval.git"
        dest="source" />
    </materials>
    <stage name="build" cleanWorkingDir="true">
       <jobs>
         <job name="build-deb" timeout="5">
          <tasks>
            <exec command="/bin/bash" workingdir="source">
              <arg>-c</arg>
              <arg>dpkg-buildpackage -b -us -uc</arg>
            </exec>
          </tasks>
          <artifacts>
            <artifact src="*.deb" dest="debian-packages/"
                type="build" />
          </artifacts>
          <resources>
            <resource>debian-stretch</resource>
            <resource>build</resource>
          </resources>
        </job>
      </jobs>
    </stage>
  </
pipeline>

</pipelines>

Listing 9-3Simple Approach to Building a Debian Package in GoCD

您可以在deployment-utils8存储库的gocd目录中找到这个配置和所有下面的 XML 配置。

最外面的标记是管道组,它有一个名称。它可用于对可用管道进行分类,也可用于管理权限。

第二层是带有名称的<pipeline>,它包含一个材料列表和一个或多个阶段。

目录布局

每次运行某个阶段中的作业时,分配给该作业的 GoCD 代理都会准备一个目录,在该目录中可以使用这些材料。在 Linux 上,这个目录默认为/var/lib/go-agent/pipelines/,后跟管道名。GoCD 配置中的路径相对于此路径。

例如,前面的 material 定义包含属性dest="source",所以这个 Git 存储库的工作副本的绝对路径是/var/lib/go-agent/pipelines/python-matheval/source。省略掉dest="..."会有效,并且会减少一个目录级别,但是这也会阻止我们在将来使用第二个材料。

可用材料类型和选项列表见配置参考 9插件可用于 10 添加更多的材料类型。

阶段、作业、任务和工件

管道中的所有阶段都是串行运行的,并且只有在前一个阶段成功的情况下,每个阶段才会运行。每个阶段都有一个名称,它既用于前端,也用于获取该阶段中产生的工件。

在前面的例子中,我给 stage 赋予了属性cleanWorkingDir="true",这使得 GoCD 删除在之前的构建过程中创建的文件,并放弃对版本控制下的文件的更改。这往往是一个很好的选择;否则,您可能会不知不觉地陷入前一个构建影响当前构建的境地,这对于调试来说确实很痛苦。

作业有可能在一个阶段中并行执行,并且由于与阶段相同的原因而命名。如果有几个代理可以运行这些作业,则它们只能并行运行。

GoCD 代理连续执行作业中的任务。我倾向于主要使用<exec>任务(和<fetchartifact>,你将在下一章看到),它们调用系统命令。他们遵循 UNIX 惯例,将退出状态 0 视为成功,其他都视为失败。

对于更复杂的命令,我在 Git 存储库中创建 shell、Perl 或 Python 脚本,并将存储库作为素材添加到管道中,这使得它们在构建过程中可用,而无需额外的工作。

我们示例中的<exec>任务调用/bin/bash -c 'dpkg-buildpackage -b -us -uc'。这是一个货邪教编程11 的案例,因为直接调用dpkg-buildpackage也可以。啊,好吧,我们可以稍后修改这个…

构建 Debian 包,并在源代码的 Git 检验中执行。它生成一个.deb文件,一个.changes文件,可能还有其他一些带有元数据的文件。它们是在管道的根目录中创建的,比 Git checkout 高一级。

因为这些是我们稍后想要处理的文件,至少是.deb文件,我们让 GoCD 将它们存储在一个名为工件库的内部数据库中。这就是配置中的<artifact>标签指示 GoCD 做的事情。

生成的包文件的名称取决于构建的 Debian 包的版本号(来自 Git 存储库中的debian/changelog文件),所以以后不容易通过名称引用它们。这就是dest="debian-packages/"发挥作用的地方:它让 GoCD 将工件存储在一个有固定名称的目录中。随后的阶段可以通过固定的目录名从这个目录中检索所有工件文件。

行动中的管道

如果没有出错(从来没有出错过,对吗?),图 9-4 大致展示了运行新管道后 web 界面的样子。

img/456760_1_En_9_Fig4_HTML.jpg

图 9-4

成功运行构建阶段后的管道概述

每当 Git 存储库中有新的提交时,GoCD 都会很高兴地构建一个 Debian 包并存储起来以备将来使用。自动化构建,耶!

版本回收被认为有害

当构建 Debian 包时,工具通过查看debian/changelog文件的顶部来确定最终包的版本号。这意味着,每当有人在没有新的 changelog 条目的情况下推送代码或文档变更时,生成的 Debian 包与前一个包具有相同的版本号。

大多数 Debian 工具假设包名、版本和架构的元组唯一地标识了包的修订。将旧版本号的包的新版本塞进一个存储库中肯定会引起麻烦。大多数存储库管理软件只是简单地拒绝接受循环使用某个版本的包的副本。在要安装软件包的目标机器上,如果版本号保持不变,升级软件包不会有任何作用。

构建唯一的版本号

有几个来源可以用来生成唯一的版本号。

  • 随机性(例如,以 UUIDs 的形式)

  • 当前日期和时间

  • Git 存储库本身

  • GoCD 公开的几个有用的环境变量 12

后者大有可为。GO_PIPELINE_COUNTER是一个单调计数器,每次 GoCD 运行管道时它都会增加,所以这是一个很好的版本号来源。GoCD 允许手动重新运行阶段,所以最好与GO_STAGE_COUNTER结合使用。就 shell 脚本而言,使用$GO_PIPELINE_COUNTER.$GO_STAGE_COUNTER作为版本字符串听起来是一种不错的方法。

但是,还有更多。GoCD 允许您使用特定版本的材料触发管道,因此您可以运行新的管道来构建旧版本的软件。如果这样做,使用GO_PIPELINE_COUNTER作为版本字符串的第一部分并不能反映旧代码库的使用。

git describe是一种计算提交次数的既定方法。默认情况下,它打印存储库中的最后一个标记,如果HEAD没有解析为与该标记相同的提交,它会添加自该标记以来的提交次数和以g为前缀的缩写 SHA1 哈希,例如,提交42322042016.04-32-g4232204,这是在标记2016.04之后的 32 次提交。选项--long强制它总是打印提交次数和散列,即使 HEAD 指向一个标签。

我们不需要版本号的提交散列,因此构建合适版本号的 shell 脚本如下所示。

#!/bin/bash

set -e
set -o pipefail
v=$(git describe --long |sed 's/-g[A-Fa-f0-9]*$//')
version="$v.${GO_PIPELINE_COUNTER:-0}.${GO_STAGE_COUNTER:-0}"

Bash 的${VARIABLE:-default}语法是让脚本在 GoCD 代理环境之外工作的好方法。这个脚本需要在 Git 存储库中设置一个标记。如果没有,则失败,并显示来自git describe的消息:

fatal: No names found, cannot describe anything.

围绕构建的其他零碎内容

现在我们有了一个惟一的版本字符串,我们必须指示构建系统使用这个版本字符串。这通过用期望的版本号在debian/changelog中写入一个新条目来实现。debchange工具为我们实现了自动化。要使它可靠地工作,有几个选项是必需的。

export DEBFULLNAME='Go Debian Build Agent'
export DEBEMAIL='go-noreply@example.com'
debchange --newversion=$version --force-distribution -b \
    --distribution="${DISTRIBUTION:-stretch}" 'New Version'

当我们想要在管道的后期引用这个版本号时(是的,将会有更多),在一个文件中提供它是很方便的。在输出中包含它也很方便,所以我们在脚本中还需要两行。

echo $version
echo $version > ../version

当然,必须触发实际的构建,如下所示:

dpkg-buildpackage -b -us -uc

将它插入 GoCD

为了让 GoCD 可以访问这个脚本,并让它处于版本控制之下,我将这个脚本放入一个 Git 存储库中,命名为debian-autobuild,并将这个存储库作为一个素材添加到管道中(清单 9-4 )。

<pipeline name="python-matheval">
  <materials>
    <git
url="https://github.com/python-ci-cd/python-matheval.git"
        dest="source" materialName="python-matheval" />
    <git
url="https://github.com/python-ci-cd/deployment-utils.git"
    dest="deployment-utils" materialName="deployment-utils" />
  </materials>
  <stage name="build" cleanWorkingDir="true">
    <jobs>
      <job name="build-deb" timeout="5">
        <tasks>
          <exec command="../deployment-utils/debian-autobuild"
                workingdir="source" />
        </tasks>
        <artifacts>
          <artifact src="version" type="build"/>
          <artifact src="*.deb" dest="debian-packages/"
            type="build" />
        </artifacts>
        <resources>
          <resource>debian-stretch</resource>
          <resource>build</resource>
        </resources>
      </job>

    </jobs>
  </stage>

</pipeline>

Listing 9-4GoCD Configuration for Building Packages with Distinct Version Numbers

现在,GoCD 在每次提交到 Git 存储库时自动构建 Debian 包,并给每个包一个不同的版本字符串。

9.4 总结

GoCD 是一个开源工具,可以轮询您的 Git 存储库,并通过专用代理触发构建。它是通过 web 界面配置的,可以通过点击助手或提供 XML 配置。

必须注意为每个版本构造有意义的版本号。Git 标记、自最后一个标记以来的提交次数以及 GoCD 公开的计数器是构建这种版本号的有用组件。

十、在管道中分发和部署包

前一章给我们留下了 GoCD 管道的开端。它会在每次新的提交被推送到 Git 时自动构建一个 Debian 包,并为每次构建生成一个唯一的版本号。最后,它捕获构建的包和包含版本号的文件version,作为工件。接下来的任务是将它上传到 Debian 仓库,并部署到目标机器上。

10.1 管道上传

第六章,关于分发软件包,已经介绍了一个小程序,用于创建和填充 Debian 知识库。如果您将它添加到那一章的deployment-utils Git 存储库中,那么您可以自动上传新构建的带有这个额外 GoCD 配置的包(清单 10-1 ),在构建阶段之后插入。

<stage name="upload-testing">
  <jobs>
    <job name="upload-testing">
      <tasks>
        <fetchartifact pipeline="" stage="build"
            job="build-deb" srcdir="debian-packages"
            artifactOrigin="gocd">
          <runif status="passed" />
        </fetchartifact>

        <exec command="/bin/bash">
          <arg>-c</arg>
  <arg>deployment-utils/add-package testing stretch *.deb</arg>
        </exec>
      </tasks>
      <resources>
        <resource>aptly</resource>
      </resources>
    </job>
  </jobs>

</stage>

Listing 10-1GoCD Configuration for Uploading a Freshly Built Package to the testing Repository

您猜对了,fetchartifact任务获取存储在 GoCD 服务器的工件库中的工件。在这里,它获取目录python-matheval,前一阶段将 Debian 包上传到这个目录中。管道名称的空字符串指示 GoCD 使用当前管道。

add-package脚本的调用中,testing指的是环境的名称(可以自由选择,只要一致),而不是 Debian 项目的测试发行版。

最后,aptly资源选择具有相同资源的 GoCD 代理来运行作业(参见图 10-1 )。如果您预计您的设置会有所增长,那么您应该有一台单独的机器来为这些存储库服务。在其上安装 GoCD 代理,并为其分配该资源。您甚至可以为测试和生产存储库准备单独的机器,并给它们更多特定的资源(比如aptly-testingaptly-production)。

img/456760_1_En_10_Fig1_HTML.jpg

图 10-1

Aptly 存储库所在的机器有一个 GoCD 代理,它从 GoCD 服务器中检索 Debian 包作为工件。目标机器将存储库配置为软件包源。

用户帐户和安全性

在前面的示例配置中,add-package脚本以go系统用户的身份运行,默认情况下,该用户在基于 Linux 的系统上的主目录是/var/go。这将在诸如/var/go/aptly/testing/stretch/的目录中创建存储库。

在第六章中,假设 Aptly 在自己的系统用户账户下运行。您仍然需要给予go用户权限来将包添加到存储库中,但是您可以防止go用户修改现有的存储库,更重要的是,防止用户从用来签署包的 GPG 密钥获得访问权。

如果您将存储库置于一个单独的用户之下,您需要一种跨越用户帐户障碍的方法,对于命令行应用来说,传统的方法是允许go用户通过sudo命令调用add-package。但是为了获得实际的安全性好处,您必须将add-package命令复制到一个位置,在那里go用户没有写权限。否则,可以访问go用户帐户的攻击者可以修改这个命令,做他/她认为合适的任何事情。

假设您打算将其复制到/usr/local/bin,您可以添加这一行:

/etc/sudoers

到文件(列表 10-2 )。

go ALL=(aptly) NOPASSWD: /usr/local/bin/add-package

Listing 10-2/etc/sudoers Line That Allows the go User to Execute add-package As User aptly

然后,不是调用add-package <environment> <distribution> <deb package>,而是将它改为

$ sudo -u aptly --set-home /usr/local/bin/add-package \
    <environment> <distribution> <deb package>

--set-home标志告诉sudoHOME环境变量设置为目标用户的主目录,这里是aptly

如果你选择不走sudo路线,你必须调整网络服务器配置,以提供来自/var/go/aptly/而不是/home/aptly/aptly的文件。

10.2 在管道中部署

在第七章中,我们看到了如何通过 Ansible 升级(或安装,如果尚未安装的话)一个包(见图 10-2 ,如下所示:

$ ansible -i testing web -m apt \
    -a 'name=python-matheval state=latest update_cache=yes'

其中,testing是与环境同名的清单文件,web是要部署到的主机组,python-matheval是软件包的名称。

img/456760_1_En_10_Fig2_HTML.jpg

图 10-2

GoCD 代理运行 Ansible,通过 SSH 连接到目标机器,安装所需的软件包

在 GoCD 中,你可以在upload-testing阶段(清单 10-3 )之后,作为一个单独的阶段来完成这项工作。

<stage name="deploy-testing">
  <jobs>
    <job name="deploy-testing">
      <tasks>
        <exec command="ansible" workingdir="deployment-utils/ansible/">
          <arg>--inventory-file=testing</arg>
          <arg>web</arg>
          <arg>-m</arg>
          <arg>apt</arg>
          <arg>-a</arg>
          <arg>name=python-matheval state=latest update_cache=yes</arg>
          <runif status="passed" />
        </exec>
      </tasks>
    </job>
  </jobs>

</stage>

Listing 10-3GoCD Configuration for Automatically Installing a Package

这里假设您将库存文件添加到了deployment-utils Git 仓库的ansible目录中,并且 Debian 仓库已经在目标机器上配置好了,正如第七章中所讨论的。

10.3 结果

要运行新阶段,可以通过点击 web 前端管道概览中的“播放”三角形来触发管道的完整运行,或者在管道历史视图中手动触发该阶段。您可以登录目标机器,检查软件包是否安装成功。

$ dpkg -l python-matheval
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name          Version     Architecture Description
+++-==============-============-============-==============
ii python-matheval  0.1-0.7.1   all         Web service

并验证该服务正在运行

$ systemctl status python-matheval
  python-matheval.service - Package installation informati
   Loaded: loaded (/lib/systemd/system/python-matheval.ser
   Active: active (running) since Sun 2016-03-27 13:15:41
  Process: 4439 ExecStop=/usr/bin/hypnotoad -s /usr/lib/py
 Main PID: 4442 (/usr/lib/packag)
   CGroup: /system.slice/python-matheval.service
           ├─4442 /usr/lib/python-matheval/python-matheval
           ├─4445 /usr/lib/python-matheval/python-matheval
           ├─4446 /usr/lib/python-matheval/python-matheval
           ├─4447 /usr/lib/python-matheval/python-matheval
           └─4448 /usr/lib/python-matheval/python-matheval

您还可以从主机检查服务是否在端口 8080 上响应,正如它应该做的那样。

$ curl --data '["+", 5]' -XPOST http://172.28.128.3:8800
5

10.4 一直到生产

上传和部署到生产环境的工作方式与测试环境相同。因此,所有需要做的就是复制最后两条管道的配置,用production替换每次出现的testing,并添加一个手动批准按钮,这样生产部署仍然是一个有意识的决定(列出 10-4 )。

<stage name="upload-production">
  <approval type="manual" />
  <jobs>
    <job name="upload-production">
      <tasks>
        <fetchartifact pipeline="" stage="build" job="build-deb" srcdir="debian-packages" artifactOrigin="gocd">
          <runif status="passed" />
        </fetchartifact>
        <exec command="/bin/bash">
          <arg>-c</arg>
          <arg>deployment-utils/add-package production \ stretch *.deb</arg>
        </exec>
      </tasks>
      <resources>
        <resource>aptly</resource>
      </resources>
    </job>

  </jobs>

</stage>

<stage name="deploy-production">
  <jobs>
    <job name="deploy-production">
      <tasks>
        <exec command="ansible" workingdir="deployment-utils/ansible/">
          <arg>--inventory-file=production</arg>
          <arg>web</arg>
          <arg>-m</arg>
          <arg>apt</arg>
          <arg>-a</arg>
  <arg>name=python-matheval state=latest update_cache=yes</arg>
          <runif status="passed" />
        </exec>
      </tasks>
    </job>
  </jobs>

</stage>

Listing 10-4GoCD Configuration for Distributing in, and Deploying to, the Production Environment

这里唯一真正的新闻是第二行

<approval type="manual" />

这使得 GoCD 只有在有人单击 web 界面中的批准箭头时才进行到这个阶段。

您还必须用您的一台或多台服务器的列表填写名为production的清单文件。

10.5 成就解锁:基本连续交付

简而言之,管道

  • 由源代码中的提交自动触发

  • 从每次提交自动构建 Debian 包

  • 将其上传到测试环境的存储库中

  • 自动将其安装在测试环境中

  • 在手动批准后,将其上传到生产环境的存储库中

  • 在生产中自动安装新版本

从源代码中的 Git 提交到生产环境中运行的软件的自动化部署的基本框架已经就绪。

十一、管道改进

前一章中的管道已经非常有用,并且比手工构建、分发和安装更好。尽管如此,仍有改进的余地。我将讨论如何将它更改为总是部署在同一管道实例中构建的确切版本,如何在安装后运行冒烟测试,以及如何从 Go continuous delivery (GoCD)配置中提取模板,以便它变得易于重用。

11.1 回滚和安装特定版本

在前几章中开发的部署管道总是安装最新版本的包。因为构建版本号的逻辑通常会产生单调递增的版本号,所以这通常是以前在同一个管道实例中构建的包。

然而,我们真的希望管道部署在管道的同一个实例中构建的确切版本。明显的好处是,它允许您重新运行旧版本的管道,安装旧版本,有效地让您回滚。

或者,您可以为修补程序构建第二个管道,基于同一个 Git 存储库但不同的分支。当您需要修补程序时,您只需暂停常规管道并触发修补程序管道。在这种情况下,如果您总是安装最新版本,为修补程序找到合适的版本字符串几乎是不可能的,因为它必须高于当前安装的版本,但也低于下一个常规版本。哦,请全部自动完成。

安装特定版本的一个不太明显的好处是,它可以检测目标机器的包源代码配置中的错误。如果部署脚本只安装可用的最新版本,并且由于错误没有在目标机器上配置存储库,那么安装过程将变成一个无声的无操作,如果软件包已经安装在一个旧版本中的话。

履行

要做两件事:弄清楚要安装哪个版本的包,然后去做。如何用 Ansible 安装一个特定版本的包(清单 11-1 )已经在第七章解释过了。

- apt: name=foo=1.00 state=present force=yes

Listing 11-1Ansible Playbook Fragment for Installing Version 1.00 of Package foo

更通用的方法是使用同一章中介绍的角色custom_package_installation

- hosts: web roles:
  role: custom_package_installation
  package: python-matheval

您可以用ansible-playbook --extra-vars=package_version=1.00...调用它。

将这个剧本作为文件ansible/deploy-python-matheval.yml添加到deployment-utils Git 存储库中。找到要安装的版本号也有一个简单但可能不明显的解决方案:将版本号写入文件;将此文件作为工件收集到 GoCD 中;然后,当需要安装时,获取工件并从中读取版本号。在撰写本文时,GoCD 没有更直接的方法通过管道传播元数据。

将版本传递给 Ansible playbook 的 GoCD 配置如清单 11-2 所示。

<job name="deploy-testing">
  <tasks>
    <fetchartifact pipeline="" stage="build" job="build-deb" srcfile="version" artifactOrigin="gocd" />
    <exec command="/bin/bash" workingdir="deployment-utils/ansible/">
      <arg>-c</arg>
      <arg>ansible-playbook --inventory-file=testing
--extra-vars="package_version=$(&lt; ../../version)" deploy-python-matheval.yml</arg>
    </exec>
  </tasks>

</job>

Listing 11-2GoCD Configuration for Installing the Version from the version File

(<arg>...</arg> XML 标签必须在一行上,这样 Bash 会将其解释为一个命令。此处多行显示只是为了可读性。)

Bash 的$(...)打开一个子进程,这也是一个 Bash 进程,并将该子进程的输出插入命令行。< ../../version是读取文件的一种简单方式,这是 XML,需要对小于号进行转义。

生产部署配置看起来非常相似,只是使用了--inventory-file=production

试试看!

为了测试特定于版本的包安装,您必须拥有至少两次捕获了version工件的管道运行。如果还没有,可以将提交推送到源存储库,GoCD 会自动获取它们。

可以用dpkg -l python-matheval查询目标机器上安装的版本。在最后一次运行之后,应该安装在该管道实例中构建的版本。

然后,您可以从以前的管道重新运行部署阶段,例如,在管道的历史视图中,将鼠标悬停在阶段上,然后单击上面带有触发重新运行的箭头的圆圈(图 11-1 )。

img/456760_1_En_11_Fig1_HTML.jpg

图 11-1

在管道的历史视图中,将鼠标悬停在一个完整的阶段(通过或失败)上会显示一个重新运行该阶段的图标

当 stage 完成运行时,您可以再次检查目标机器上已安装的软件包版本,以验证旧版本确实已经部署。

11.2 在管道中运行冒烟测试

部署应用时,测试应用的新版本是否能正常工作是非常重要的。通常,这是通过冒烟测试来完成的——这是一个非常简单的测试,但它测试了应用的许多方面:应用进程运行,它绑定到它应该绑定的端口,以及它可以响应请求。通常,这意味着配置和数据库连接也是相同的。

什么时候抽烟?

烟尘测试一次覆盖很多领域。一个单独的测试可能需要一个正常工作的网络、正确配置的防火墙、web 服务器、应用服务器、数据库等等。这是一个优点,因为这意味着它可以检测许多种类的错误,但这也是一个缺点,因为这意味着诊断能力较低。当它出现故障时,您不知道是哪个组件的问题,必须重新调查每个故障。

冒烟测试也比单元测试贵得多。它们往往需要更多的时间来编写,需要更长的时间来执行,并且在面对配置或数据更改时更加脆弱。因此,典型的建议是进行少量的冒烟测试,可能是 1 到 20 次,或者可能是 1%的单元测试。

举个例子,如果你要为 Web 开发一个航班搜索和推荐引擎,你的单元测试将覆盖用户可能遇到的不同场景,并且引擎会产生最好的建议。在冒烟测试中,你只需检查你是否能输入旅行的起点、目的地和日期,以及你是否能得到一个飞行建议列表。如果该网站上有一个成员资格区域,您将测试没有凭据无法访问该区域,并且可以在登录后访问该区域。三次烟雾测试,差不多吧。

白盒烟雾测试

上面提到的例子基本上是黑盒冒烟测试,因为它们不关心应用的内部,并且像用户一样对待应用。这是非常有价值的,因为归根结底,你关心用户的体验。

有时,应用的某些方面不容易进行冒烟测试,但经常出现故障,足以保证自动冒烟测试。例如,应用可能会缓存来自外部服务的响应,因此简单地使用某个功能并不能保证能够使用这个特定的通信通道。

一个实用的解决方案是让应用提供某种自我诊断,比如应用从一个 web 页面测试其自身配置的一致性,检查所有必需的数据库表是否存在,以及外部服务是否可达。然后,单个冒烟测试可以调用状态页,并在状态页不可访问或报告错误时引发错误。这是一个白盒烟雾测试。

白盒冒烟测试的状态页面可以在监控检查中重用,但是在部署过程中显式检查它们仍然是一个好主意。白盒烟雾测试不应该取代黑盒烟雾测试,而是对其进行补充。

样本黑盒烟雾测试

python-matheval应用提供了一个简单的 HTTP 端点,因此任何 HTTP 客户端都可以进行冒烟测试。使用curl命令行 HTTP 客户端,请求可能如下所示:

$ curl --silent -H "Accept: application/json" \
       --data '["+", 37, 5]' \
       -XPOST http://127.0.0.1:8800/
42

检查输出是否符合预期的一个简单方法是通过grep将它传送出去。

$ curl --silent -H "Accept: application/json" \
       --data '["+", 37, 5]' \
       -XPOST http://127.0.0.1:8800/ | grep ⁴²$
42

输出与之前相同,但是如果输出偏离预期,退出状态是非零的。

在管道和滚动发布中增加烟雾测试

一个简单的在交付管道中集成冒烟测试的方法是在每个部署阶段之后添加一个冒烟测试阶段(也就是说,一个在测试部署之后,一个在生产部署之后)。如果应用的某个版本在测试环境中未通过冒烟测试,此设置会阻止该版本进入生产环境。因为冒烟测试只是一个 shell 命令,它以非零退出状态指示失败,所以将它作为命令添加到您的部署系统中是微不足道的。

如果您的应用只有一个实例在运行,这是您能做的最好的事情。但是,如果您有一个机器群,并且有几个应用实例在某种负载平衡器后面运行,则可以在升级期间单独对每个实例进行冒烟测试,如果太多实例未通过冒烟测试,则可以中止升级。

所有成功的大型科技公司都通过检查来保护他们的生产系统,甚至更精细的版本。

这种滚动升级的一个简单方法是为每个包的部署扩展 Ansible playbook,并让它在移动到下一台机器之前运行每台机器的冒烟测试(清单 11-3 和 11-4 )。

---

- hosts: web
  serial: 1
  max_fail_percentage: 1
  tasks:
    - apt:
        update_cache: yes
        package: python-matheval={{package_version}}
        state: present
        force: yes
    - local_action: >
        command ../smoke-tests/python-matheval
        "{{ansible_host}}"
      changed_when: False

Listing 11-4File ansible/deploy-python-matheval.yml: A Rolling Deployment Playbook with Integrated Smoke Test

#!/bin/bash

curl  --silent -H "Accept: application/json" \
      --data '["+", 37, 5]' –XPOST  http://$1:8800/ \
      | grep ⁴²$

Listing 11-3File smoke-tests/python-matheval: A Simple HTTP-Based Smoke Test

随着冒烟测试的数量随着时间的推移而增长,将它们全部塞进 Ansible playbook 是不现实的,这样做还会限制可重用性。在这里,它们位于部署工具库的一个单独文件中。 1 另一个选择是从冒烟测试中构建一个包,并将它们安装在运行 Ansible 的机器上。

虽然在安装了该服务的机器上执行 smoke tests 命令很容易,但作为本地操作(即在启动 Ansible playbook 的控制主机上)运行它也会测试网络和防火墙部分,从而更真实地模拟实际使用场景。

11.3 配置模板

当您有多个软件包要部署时,您需要为每个软件包构建一个管道。只要部署管道在结构上足够相似——通常使用相同的打包格式和相同的安装技术——您就可以重用该结构,方法是从第一个管道中提取一个模板,并对其进行多次实例化,以创建具有相同结构的单独管道。

如果您仔细查看之前开发的管道 XML 配置,您可能会注意到它并不是非常特定于python-matheval项目。除了 Debian 发行版和部署手册的名称,这里的所有内容都可以被 Debian 打包的任何软件重用。

为了使管道更加通用,您可以将参数(简称 params )定义为管道内部的第一件事,在<materials>部分之前(列表 11-5 )。

<params>

  <param name="distribution">stretch</param>
  <param name="deployment_playbook">deploy-python-matheval.yml</param>

</params>

Listing 11-5Parameter Block for the python-matheval Pipeline, to Be Inserted Before the Materials

然后用占位符#{distribution}替换每个阶段定义中所有出现的stretch,用#{deployment_playbook}替换deploy-python-matheval.yml,这样就得到如下 XML 片段

<exec command="/bin/bash">
  <arg>-c</arg>
  <arg>deployment-utils/add-package \
        testing #{distribution} *.deb</arg>

</exec>

<exec command="/bin/bash" workingdir="deployment-utils/ansible/">
  <arg>-c</arg>
  <arg>ansible-playbook --inventory-file=testing
      --extra-vars="package_version=$(&lt; ../../version)"
      #{deployment_playbook}</arg>

</exec>

泛化的下一步是将阶段移动到一个模板。这也可以通过编辑 XML 配置来完成,或者在 web 界面中使用管理➤管道,然后单击名为 python-matheval 的管道旁边的提取模板链接来完成。

如果选择debian-base作为模板名,XML 中的结果看起来如清单 11-6 所示。

<pipelines group="deployment">
  <pipeline name="python-matheval" template="debian-base">
    <materials>
      <git url=
        "https://github.com/python-ci-cd/python-matheval.git"
        dest="source" materialName="python-matheval" />
      <git url=
        "https://github.com/python-ci-cd/deployment-utils.git"
        dest="deployment-utils"
        materialName="deployment-utils" />
    </materials>
    <params>
      <param name="distribution">stretch</param>
      <param name="deployment_playbook">deploy-python-matheval.yml</param>
    </params>

</pipelines>

<templates>

  <pipeline name="debian-base">
      <!-- stages definitions go here -->
  </pipeline>

</templates>

Listing 11-6GoCD Configuration for Pipeline matheval Using a Template

这个软件包特有的一切现在都在管道定义中,可重用的部分在模板中。唯一的例外是deployment-utils存储库,它必须单独添加到每个管道中,因为 GoCD 无法将材料移动到模板中。

为另一个应用添加部署管道现在只需要指定 URL、目标(即 Ansible 清单文件中的组名)和分发。在下一章你会看到一个例子。一旦您习惯了工具,这相当于不到五分钟的工作。

11.4 避免重建踩踏

当您有相当数量的管道时,您会注意到一种不幸的模式。每当您向deployment-utils存储库提交时,都会触发所有管道的重建。这是一种资源浪费,并且会占用一个或多个构建代理,因此基于实际源代码更改的包构建会延迟到所有构建工作完成之后。

GoCD 的材料有一个忽略过滤器,这是为了避免当只有文档发生变化时昂贵的重建(清单 11-7 )。您可以使用它来忽略对存储库中所有文件的更改,从而避免重建混乱。

<git url="https://github.com/python-ci-cd/deployment-utils.git"
      dest="deployment-utils" materialName="deployment-utils">
    <filter>
        <ignore pattern="*" />
        <ignore pattern="**/*" />
    </filter>

</git>

Listing 11-7GoCD Material Definition That Avoids Triggering the Pipeline

*过滤器匹配顶层目录中的所有文件,**/*匹配子目录中的所有文件。

当您将所有管道中的deployment-utils材料的材料配置更改为这些忽略过滤器时,对deployment-utils存储库的新提交不会触发任何管道。启动管道时,GoCD 仍然轮询材料并使用最新版本。与所有管线一样,所有阶段的材料版本都是相同的。

忽略存储库中的所有文件是一种笨拙的工具,需要您手动触发项目的管道,以对部署行动手册进行更改。因此,从 GoCD 版本 16.6 开始,您可以用invertFilter="true"反转过滤条件,以创建白名单(清单 11-8 )。

<git url="https://github.com/python-ci-cd/deployment-utils.git"
        invertFilter="true" dest="deployment-utils"
        materialName="deployment-utils">
  <filter>
    <ignore pattern="ansible/deploy-python-matheval.yml" />
  </filter>

/git>

Listing 11-8Using White Lists in GoCD Materials to Selectively Trigger on Changes to Certain Files

每个管道的这种白名单配置导致对deployment-utils储存库的提交仅触发与变更相关的管道。

11.5 摘要

当您将管道配置为部署在管道的同一实例中构建的完全相同的版本时,您可以使用它来安装旧版本或进行回滚。

管道模板允许您提取管道之间的共性,并且只维护这些共性一次。参数带来了支持不同软件包所需的多样性。

十二、安全性

自动化部署对您的应用和基础架构的安全性有什么影响?事实证明,这既有安全优势,也有需要警惕的事情。

12.1 集权的危险

在部署管道中,控制部署的机器必须能够访问部署软件的目标机器。在最简单的情况下,部署机器上有一个私有的 SSH 密钥,目标机器将访问权授予该密钥的所有者。

这是一个明显的风险,因为获得部署机器(GoCD 代理或控制代理的 GoCD 服务器)访问权限的攻击者可以使用这个密钥连接到所有目标机器,从而获得对它们的完全控制。

一些可能的缓解措施包括:

  • 实现部署机器的强化设置(例如,使用 SELinux 或 grsecurity)。

  • 用密码保护 SSH 密钥,并通过触发部署的同一通道提供密码,比如通过 GoCD 服务器中的加密变量。

  • 使用硬件令牌来存储 SSH 部署密钥。硬件令牌对于基于软件的密钥提取是安全的。

  • 拥有单独的部署和构建主机。构建主机往往需要安装更多的软件,这暴露了更大的攻击面。

  • 您还可以为每个环境配备单独的部署机器,并使用单独的凭证。

  • 在目标机器上,通过上述 SSH 密钥只允许非特权访问,并使用类似于sudo的东西,只允许某些特权操作。

每种缓解措施都有自己的成本和缺点。为了说明这一点,请注意,如果攻击者仅设法获得文件系统的副本,那么密码保护的 SSH 密钥会有所帮助,但如果攻击者获得机器上的 root 权限,从而可以获得包含解密的 SSH 密钥的内存转储,那么就没有帮助了。

基于硬件的秘密存储可以很好地防止密钥被盗,但是它使得虚拟系统的使用更加困难,并且必须购买和配置。

sudo方法在限制攻击传播方面非常有效,但是它需要在目标机器上进行大量的配置,并且您需要一种安全的方式来部署它。所以,你遇到了一个鸡和蛋的问题,需要付出额外的努力。

另一方面,如果您没有交付管道,部署必须手动进行。所以,现在你有同样的问题,必须让人类访问目标机器。大多数组织都提供某种安全的机器来存储操作员的 SSH 密钥,并且您面临着与部署机器相同的风险。

12.2 安全修复上市时间

与手动部署相比,即使相对较慢的部署管道仍然非常快。当发现漏洞时,这种快速且自动化的部署过程可以大大缩短部署修补程序的时间。

同样重要的是,笨拙的手动发布过程诱使操作人员绕过安全修复走捷径,从而跳过了质量保证过程的一些步骤。当这个过程自动化且快速时,坚持这个过程比跳过它更容易,所以即使在有压力的情况下,它实际上也会被执行。

12.3 审计和软件材料清单

一个好的部署管道跟踪软件包的哪个版本是在什么时候构建和部署的。这允许人们回答诸如“我们的这个安全漏洞有多长时间了?”,“报告问题后多久在生产中修补了漏洞?”,甚至可能是“谁批准了引入漏洞的变更?”

如果您还使用基于存储在版本控制系统中的文件的配置管理,那么您甚至可以针对配置来回答这些问题,而不仅仅是针对软件版本。

简而言之,部署管道为审计提供了足够的数据。

一些法规要求您在某些情况下记录软件物料清单 1 ,例如,对于医疗设备软件。这是软件中包含的组件的记录,例如库及其版本的列表。虽然这对于评估许可证违规的影响很重要,但对于找出特定版本的库中哪些应用受到漏洞的影响也很重要。

惠普安全部门 2015 年的一份报告发现,44%的被调查漏洞可能是由至少两年前已知(并可能已修补)的漏洞造成的。这反过来意味着,通过跟踪您在何处使用哪个软件版本、订阅已知漏洞的新闻简报或订阅源,以及定期使用修补版本重建和重新部署您的软件,您可以将安全风险减半。

连续交付系统不会自动为您创建这样的软件物料清单,但它会为您提供一个可以插入这样的系统的地方。

12.4 摘要

连续交付提供了对新发现的漏洞做出快速且可预测的反应的能力。同时,部署管道本身也是一个攻击面,如果没有适当的保护,它可能会成为入侵者的诱人目标。

最后,部署管道可以帮助您收集数据,这些数据可以提供对具有已知漏洞的软件的使用的洞察,从而使您在修补这些安全漏洞时能够彻底。

十三、状态管理

连续交付(CD)对于一个无状态的应用来说很好也很容易,也就是说,对于一个没有持久存储数据的应用来说。安装新的应用版本是一项简单的任务,只需要安装新的二进制文件(或者源代码,如果是未编译的语言),停止旧的实例,并启动新的实例。

一旦有持久状态要考虑,事情就变得更复杂了。在这里,我将考虑带有模式的传统关系数据库。您可以通过使用无模式的“noSQL”数据库来避免一些问题,但是您并不总是拥有这种奢侈。如果您真的采用无模式,您必须在应用代码中处理旧的数据结构,而不是通过部署过程。

随着模式的改变,您可能需要考虑数据迁移,这可能涉及到用默认值填充缺失值或者从不同的数据源导入数据。一般来说,这种数据迁移与模式迁移符合相同的模式,即要么执行一段 SQL 和数据 定义语言(DDL ) 1 ,要么运行直接与数据库对话的外部命令。

13.1 代码和数据库版本之间的同步

状态管理很困难,因为代码通常被绑定到数据库模式的一个版本上。在一些情况下,这可能会导致问题。

  • 数据库更改通常比应用更新慢。如果应用的版本 1 只能处理模式的版本 1,而应用的版本 2 只能处理模式的版本 2,那么您必须停止应用的版本 1,进行数据库升级,并且只有在数据库迁移完成之后才启动应用的版本 2。

  • 回滚到应用的前一个版本,以及它的数据库模式版本,变得非常痛苦。通常,数据库更改或其回滚都可能丢失数据,因此您不能轻松地跨这些界限进行自动释放和回滚。

为了详细说明最后一点,考虑向数据库的表中添加一列的情况。在这种情况下,更改的回滚(再次删除列)会丢失数据。相反,如果最初的更改是删除一列,这一步通常不能逆转。您可以重新创建相同类型的列,但数据会丢失。即使您存档了已删除的列数据,新行也可能已被添加到表中,并且这些新行没有存档的数据。

13.2 分离应用和数据库版本

有一些工具可以帮助您将数据库模式可重复地转换到一个定义的状态,但是它不能为您解决回滚过程中潜在的数据丢失问题。唯一可行的方法是在应用开发人员和数据库管理员之间建立协作,并将有问题的更改分解成多个步骤。

假设您想要的更改是删除一个有NOT NULL约束的列。简单地在一个步骤中删除列会带来上一节中概述的问题。相反,您可以执行以下步骤:

  1. 部署一个能够处理从列中读取NULL值的应用版本,即使还不允许使用NULL值。

  2. 等到您确定不希望回滚到无法处理NULL值的应用值。

  3. 部署数据库更改,使列可为空(或给它一个默认值)。

  4. 等到您确定不想回滚到该列为NOT NULL的模式版本。

  5. 部署不再使用该列的新版本的应用。

  6. 等到您确定不希望回滚到使用该列的应用版本。

  7. 部署完全删除该列的数据库更改。

有些场景允许您跳过其中的一些步骤,或者将多个步骤合并为一个步骤。向表中添加列是一个类似的过程,如下所示:

  1. 部署一个数据库更改,添加带有默认值(或允许NULL值)的新列。

  2. 部署写入新列的应用版本。

  3. 可以选择运行一些迁移来填充旧行的列。

  4. 可选地部署一个数据库变更,添加开始时不可能的约束(如NOT NULL)。

    …在步骤之间有适当的等待。

模式更改的示例

假设您有一个由 PostgreSQL 数据库支持的 web 应用,并且目前该应用将登录尝试记录到数据库中。因此,该模式如下所示:

CREATE TABLE users (
    id          SERIAL,
    email       VARCHAR NOT NULL,
    PRIMARY KEY(id)
);

CREATE TABLE login_attempts (
    id          SERIAL,
    user_id     INTEGER NOT NULL REFERENCES users (id),
    success     BOOLEAN NOT NULL,
    timestamp   TIMESTAMP NOT NULL DEFAULT NOW(),
    source_ip   VARCHAR NOT NULL,
    PRIMARY KEY(id)
);

随着 web 应用负载的增加,您意识到您正在为数据库创建不必要的写负载,并开始记录到外部日志服务。在数据库中,您真正需要的是最后一次成功登录的日期和时间(您的 CEO 坚持要求您在每次登录时显示该日期和时间,因为一名审计员确信这将提高安全性)。

所以,你最终想要的模式是这样的:

CREATE TABLE users (
    id          SERIAL,
    email       VARCHAR NOT NULL,
    last_login  TIMESTAMP NOT NULL,
    PRIMARY KEY(id)
);

一个直接的数据库更改脚本将会有

DROP TABLE login_attempts;

ALTER TABLE users
    ADD COLUMN last_login TIMESTAMP NOT NULL;

但是这存在前面提到的问题,即它将模式版本与应用版本联系在一起,而且在没有默认值和没有为其提供值的情况下,您不能引入一个NOT NULL列。

让我们把它分解成不受这些问题困扰的独立步骤。

创建新列,可空

第一步是添加可选的新列users.last_login(通过允许NULL值)。如果起点是模式的版本 1,这就是版本 2:

CREATE TABLE users (
    id          SERIAL,
    email       VARCHAR NOT NULL,
    last_login  TIMESTAMP,
    PRIMARY KEY(id)
);

-- table login_attempts omitted, because it's unchanged.

运行 apgdiff,另一个 PostgreSQL Diff 工具、 2 、针对这两个方案文件给出了我们:

$ apgdiff schma-1.sql schema-2.sql

ALTER TABLE users
    ADD COLUMN last_login TIMESTAMP;

这是从模式 1 到模式 2 的正向迁移脚本。请注意,我们不一定需要回滚脚本,因为每个可以处理模式版本 1 的应用版本也可以处理模式版本 2(除非应用做了类似于SELECT * FROM users的傻事,并期望得到一定数量或顺序的结果。我会假设应用没有那么愚蠢)。

这个迁移脚本可以在 web 应用运行时应用于数据库,而不会有任何停机时间。

MySQL 有一个不幸的特性,即模式更改不是事务性的,在模式更改期间它们会锁定整个表,这抵消了您从增量数据库更新中获得的一些优势。

为了减轻这种情况,有一些外部工具可以解决这一问题,方法是创建表的修改副本,逐渐将数据从旧表复制到新表,最后进行重命名以替换旧表。GitHub 的 gh-ost 3 就是这样一个工具。

这些工具通常只对外键约束提供有限的支持,所以在使用它们之前要仔细评估。

当模式更改完成后,您可以部署新版本的 web 应用,每当成功登录时,该应用都会写入到users.last_login。请注意,该应用版本必须能够处理从该列读取的NULL值,例如,通过返回到表login_attempts来确定最后一次登录尝试。

该应用版本还可以停止向表login_attempts中插入新条目。更保守的方法是将该步骤推迟一段时间,这样您就可以安全地回滚到较旧的应用版本。

数据迁移

最后,users.last_login应该是NOT NULL,所以你必须为它的位置NULL生成值。这里,表last_login是这种数据的来源。

UPDATE users
  SET last_login = (
         SELECT login_attempts.timestamp
           FROM login_attempts
          WHERE login_attempts.user_id = users.id
            AND login_attempts.success
       ORDER BY login_attempts.timestamp DESC
          LIMIT 1
       )
 WHERE users.last_login IS NULL;

如果NULL值仍然存在,比如说,因为用户从未成功登录,或者因为表last_login没有足够远地返回,您必须有一些回退,这可以是一个固定值。在这里,我选择简单的方法,简单地使用NOW()作为退路。

UPDATE users SET last_login = NOW() WHERE last_login IS NULL;

当应用运行时,这两个更新可以再次在后台运行。此次更新后,users.last_login中不应显示更多的NULL值。等待几天后,验证确实如此,是时候应用必要的约束了。

应用约束,清理

一旦您确信在列last_login中没有丢失值的行,并且您不会回滚到引入丢失值的应用版本,您可以部署停止使用表login_attempts的应用版本,处理表login_attempts,然后应用NOT NULL约束(参见图 13-1 )。

img/456760_1_En_13_Fig1_HTML.png

图 13-1

应用和数据库更新步骤的顺序。每个数据库版本都与之前和之后的应用版本兼容,反之亦然。

DROP TABLE login_attempts;

ALTER TABLE users
    ALTER COLUMN last_login SET NOT NULL;

总的来说,一个逻辑数据库更改已经被分散到三次数据库更新(两次模式更新和一次数据迁移)和两次应用更新中。

这使得应用开发更加费力,但是您获得了操作上的优势。其中一个优势是保持应用在任何时候都可以发布到生产环境中。

先决条件

如果在几个步骤中部署单个逻辑数据库更改,则必须进行几次部署,而不是一次引入代码和模式更改的大型部署。只有当部署(至少大部分)是自动化的,并且组织提供了足够的连续性,您可以实际完成变更过程时,这才是可行的。

如果开发人员不断地灭火,他们很可能从来没有时间添加最终期望的NOT NULL约束,并且一些未被发现的 bug 将导致以后信息的丢失。

您还应该建立某种问题跟踪器,使用它您可以跟踪模式迁移的路径,以确保没有未完成的,例如,在开发人员离开公司的情况下。

工具作业

不幸的是,我不知道有哪种工具能够完全支持我所描述的数据库和应用发布周期。一般来说,有一些工具可以管理模式更改。例如,Sqitch 4 和 Flyway 5 是管理数据库变更和回滚的通用框架。

在较低的层次上,有一些工具,比如 apgdiff,可以比较新旧模式,并使用这种比较来生成 DDL 语句,将您从一个版本带到下一个版本。这种自动生成的 DDL 可以构成 Sqitch 或 Flyway 随后管理的升级脚本的基础。

一些 ORM 还带有框架,承诺为您管理模式迁移。仔细评估它们是否允许在不丢失数据的情况下进行回滚。

结构

如果您将应用部署与模式部署分离,那么您必须拥有至少两个可单独部署的包:一个用于应用,一个用于数据库模式和模式迁移脚本。如果您希望或必须支持数据库模式的回滚,您必须记住,您需要与新模式相关联的元数据才能回滚到旧版本。

模式版本 5 的数据库描述不知道如何从版本 6 回滚到版本 5,因为它对版本 6 一无所知。因此,您应该始终安装最新版本的模式文件包,并将已安装的版本与当前活动的数据库版本分开。控制模式迁移的工具可以独立于应用及其模式,因此应该存在于第三个软件包中。

没有银弹

没有一个解决方案可以在部署期间自动管理您的所有数据迁移。您必须仔细设计应用和数据库的更改,以分离并分别部署它们。这通常是应用开发方面的更多工作,但它为您购买了部署和回滚的能力,而不会受到数据库更改的阻碍。

工具可用于某些部分,但通常不适用于全局。必须有人跟踪应用和模式的版本,或者将它们自动化。

13.3 摘要

数据库中保存的状态会使应用升级变得复杂。

不兼容的数据结构和模式更改可以分解成几个较小的步骤,每个步骤都与前一个步骤兼容。

这允许在不停机的情况下升级应用,代价是必须进行多次应用和模式部署。

十四、总结和展望

读完这本书后,你应该对如何以及为什么为一个 Python 项目实现持续集成(CI)和持续交付(CD)有一个坚实的理解。这不是一个小任务,但是许多例子应该可以让您很快开始,甚至只有某些方面的实现也可以给您带来好处。在一个协作的环境中,展示这些好处更容易让其他人相信,在工具链和过程改进上花费时间是值得的。

14.1 接下来是什么?

在这最后一章中,让我们看看一些概念,它们可以帮助你发展一个更成熟的软件开发过程,并与 CI 和 CD 相结合。

提高质量保证

提高软件质量就像增加应用的单元测试覆盖率一样简单。然而,并不是所有的错误都可以通过这种方式捕获,例如,性能退化或您以前没有想到的错误。

为了捕捉性能回归,您可以创建一个单独的性能测试环境,并在该环境中运行一组预定义的负载和性能测试。您可以将此作为另一个阶段添加到部署管道中。

处理意想不到的案件更难,因为,根据定义,他们会出其不意地抓住你。对于某些类型的应用,自动模糊化可以找到导致应用崩溃的输入,并将这些输入作为示例提供给开发人员。

有一些架构方法可以使您的应用对意外的用户输入和错误场景更加健壮,但是从工具的角度来看,您能做的最好的事情就是使应用对这种错误的反应更加健壮。

专门的错误跟踪器可以帮助您识别这样的错误。它们让开发人员更深入地了解如何重现和诊断这些错误。例如, Sentry 1 是一款开源的集中式错误跟踪器,提供托管解决方案。

韵律学

在更大的系统和组织中,收集和聚合指标是保持系统可管理性的一个要求。甚至出现了将监测建立在时间序列数据基础上的趋势。

在部署系统的上下文中,您可以收集的一些数据点包括每个阶段或任务的开始日期和持续时间、它构建的版本以及该特定版本的特征,包括性能数据、使用数据(如参与率)、生成的工件的大小、发现的缺陷和漏洞等等。

理解收集到的数据并不总是容易的,关于这方面的书籍已经有很多了。尽管如此,建立一种收集各种指标的方法并创建有助于解释它们的仪表板是一个好主意。

基础设施自动化

配置管理对于扩展基础设施至关重要,但是像 Ansible 这样的工具不足以满足所有的需求和规模。

数据库中的配置、机密管理

随着配置数据量的增长,将其保存在纯文本文件中变得不切实际。因此,您必须维护一个配置数据库,并使用 Ansible 的动态库存机制 2 将数据传播到配置管理系统中。

然而,在数据库中存储密码、私钥和其他秘密总是一件棘手的事情。你需要在数据库上安装一个应用,以避免将这些秘密泄露给不应该访问它们的用户。

这样的应用已经存在。专用的秘密管理系统以加密的形式存储秘密,并小心地控制对它们的访问。这类应用的例子有 Square 的 Keywhiz??、??、,或者《流浪者》的作者 HashiCorp 的 Vault4

秘密管理系统通常提供插件来创建服务帐户,如 MySQL 或 PostgreSQL 数据库帐户,并在无人参与的情况下轮换其密码。至关重要的是,这也意味着没有人会看到随机生成的密码。

除了将应用配置推送到运行应用的机器或容器中,您还可以构建您的应用来从中央位置获取配置。这样的中心位置通常被称为服务发现系统。CoreOS 项目的 etcd5和 HashiCorp 的consult6 等工具,让管理大量配置变得更加容易。它们还提供了额外的特性,比如对服务的基本监控,以及只向消费者公开服务端点的工作实例。

举例来说,考虑一个需要大量配置数据的应用可以只被提供一个秘密密钥,用于针对服务发现系统的认证以及关于它在哪个环境中运行的信息。然后,应用从中央服务中读取所有其他配置。如果应用需要访问存储服务,并且有多个实例可以提供该服务,则监控服务会确保应用获得一个工作实例的地址。

这种服务发现方法允许一种称为不可变基础设施的模式。这意味着您只需构建一个容器(比如 Docker 容器,甚至是一个虚拟机映像),然后,您就可以通过各种测试环境传播整个容器,而不是只传播您的应用。集群管理系统提供连接到服务发现系统的凭证;否则,容器保持不变。

基础设施作为代码

如前几章所述,传统的 CD 系统通常局限于源代码控制系统中的一个分支,因为只有一个用于部署代码的测试环境。

云基础设施改变了游戏规则。它允许对整个环境的声明性描述,包括几个数据库、服务和虚拟服务器。实例化一个新环境就变成了执行一个命令的事情,允许您将每个分支部署到一个新的、独立的环境中。

创造新环境的主要工具有地形 7云形成8

14.2 结论

自动化部署使软件开发和操作更加高效和愉快。我已经向您展示了对它的温和而实用的介绍,反过来,使您能够向您的组织介绍 CD。

对于一个开发软件的组织来说,这是一个很大的进步,但它也是自动化基础设施的一小部分,是通往高效且有弹性的软件开发过程的一小部分。

posted @ 2024-08-09 17:41  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报