Python-DevOps-指南-全-

Python DevOps 指南(全)

原文:annas-archive.org/md5/68b28228356df0415ddc83eb0aaea548

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

有一次,诺亚在海里,一浪砸在他身上,把他的呼吸夺走,同时把他拖得更深入海中。就在他开始恢复呼吸时,又一浪猛然砸下来。它消耗了他剩余的大部分能量,把他拉得更深。正当他开始恢复时,又有一浪猛地打来。他越是与浪和海抗争,消耗的能量就越多。他严重怀疑他会在那一刻死去。他无法呼吸,全身疼痛,他害怕自己会溺水而亡。濒临死亡让他专注于唯一能救他的事情,那就是保存自己的能量,利用浪潮而非与之对抗。

身处不实践 DevOps 的初创企业就像是在海滩的那一天。有些生产问题燃烧了数月;一切都是手工操作,警报连续几天不断地将你吵醒,损害你的健康。唯一摆脱这种死亡螺旋的途径就是 DevOps 方法。

做一件正确的事情,然后再做另一件,直到找到清晰。首先,建立一个构建服务器,开始测试你的代码,并自动化手动任务。做点什么,可以是任何事情,但要有“偏向行动”的态度。先做对第一件事情,并确保它是自动化的。

初创企业或任何公司的一个常见陷阱是寻找超级英雄。“我们需要一个性能工程师”,因为他们将解决我们的性能问题。“我们需要一位首席营收官”,因为他们将解决所有销售问题。“我们需要 DevOps 工程师”,因为他们将解决我们的部署流程。

在一家公司,诺亚负责的一个项目已经延迟超过一年,而且这个 Web 应用已经重写了三次,使用了多种编程语言。这个即将发布的版本只需要一个“性能工程师”来完成。我记得当时只有我一个人足够勇敢或者愚蠢,问道:“什么是性能工程师?” 这位工程师使得所有事情在规模上运行良好。他在那一刻意识到他们正在寻找一个超级英雄来拯救他们。招募超级英雄综合症是发现新产品或新创企业出现非常严重问题的最佳方式。除非员工首先拯救了自己,否则不会有人能拯救一个公司。

在其他公司,诺亚听到类似的话:“如果我们只能雇一个资深 Erlang 工程师”,或者“如果我们只能雇一个为我们创造收入的人”,或者“如果我们只能雇一个教会我们财务纪律的人”,或者“如果我们只能雇一个 Swift 开发者”,等等。这种雇佣是你的初创企业或新产品最不需要的东西——它需要理解自己在做错了什么,只有超级英雄才能拯救一天。

对于那家想要聘请性能工程师的公司来说,最终问题是技术监督不足。错误的人掌管着事务(并且口头上打击那些可以修复问题的人)。通过移除一位表现不佳的员工,听取一位一直知道如何解决问题的现有团队成员的建议,删除那份工作列表,一次做对一件事情,然后加入合格的工程管理人员,问题就解决了。

在创业公司,没有人会拯救你;你和你的团队必须通过创建出色的团队合作、优秀的流程并信任你的组织来保护自己。问题的解决方案不是新的雇员,而是诚实和关注你所处的情况,如何到达那里,并一次做对一件事情直到摆脱困境。除非是你,否则没有超级英雄。

就像在风暴中在海洋中慢慢淹没,没有人会拯救你或公司,除非是你。你是公司需要的超级英雄,你可能会发现你的同事也是。

有一条走出混乱的路,而这本书可以成为你的指南。让我们开始吧。

作者们认为 DevOps 的含义是什么?

软件行业中许多抽象概念很难精确定义。云计算、敏捷和大数据都是可以根据与其讨论的人不同而有多种定义的示例。与其严格定义 DevOps 是什么,不如使用一些表明 DevOps 正在发生的短语:

  • 开发和运维团队之间的双向协作。

  • 运维任务的周转时间是几分钟到几小时,而不是几天到几周。

  • 开发人员的强烈参与是必要的;否则,就会回到开发人员与运维人员的对立。

  • 运维人员需要开发技能——至少需要熟练使用 Bash 和 Python。

  • 开发人员需要操作技能——他们的责任不仅是编写代码,而是将系统部署到生产环境并监控警报。

  • 自动化,自动化,自动化:你不能准确地自动化而没有开发技能,也不能正确地自动化而没有运维技能。

  • 理想情况下:开发人员可以自助地进行代码部署。

  • 可以通过 CI/CD 流水线实现。

  • GitOps。

  • 开发和运维之间的双向 everything(工具、知识等)交流。

  • 设计、实施、部署以及是的,自动化的持续协作如果没有合作,就不可能成功。

  • 如果不自动化,就会出问题。

  • 文化层次结构 < 过程。

  • 微服务 > 单块式架构。

  • 软件团队的核心是持续部署系统。

  • 没有超级英雄。

  • 持续交付不是选择,而是必须。

如何使用本书

本书可以按任意顺序阅读。您可以随意打开任何您喜欢的章节,应该能够找到有助于工作的有用内容。如果您是一位经验丰富的 Python 程序员,您可能想要浏览第一章。同样,如果您对战争故事、案例研究和访谈感兴趣,您可能想先阅读第十六章。

概念主题

内容分为几个概念主题。第一组是 Python 基础,涵盖了语言的简要介绍,以及自动化文本、编写命令行工具和自动化文件系统。

接下来是运维,包括有用的 Linux 工具、软件包管理、构建系统、监控与仪表、以及自动化测试。这些都是成为称职的 DevOps 从业者必须掌握的关键主题。

云基础将在下一节详细介绍,包括云计算、基础设施即代码、Kubernetes 和无服务器的章节。当前软件行业在云计算领域缺乏足够的人才,掌握本节将立即提升您的薪资和职业发展。

数据部分接下来。机器学习运营和数据工程都从 DevOps 的视角进行了探讨。此外,还有一个全面的机器学习项目演示,介绍如何使用 Flask、Sklearn、Docker 和 Kubernetes 构建、部署和运营化机器学习模型。

最后一部分是关于案例研究、访谈和 DevOps 战争故事的第十六章。这章节非常适合晚上阅读。

Python 基础

  • 第一章,DevOps 的 Python 基础

  • 第二章,自动化文件和文件系统

  • 第三章,使用命令行

运维

  • 第四章,有用的 Linux 工具

  • 第五章,软件包管理

  • 第六章,持续集成与持续部署

  • 第七章,监控和日志

  • 第八章,DevOps 的 Pytest

云基础

  • 第九章,云计算

  • 第十章,基础设施即代码

  • 第十一章,容器技术:Docker 和 Docker Compose

  • 第十二章,容器编排:Kubernetes

  • 第十三章,无服务器技术

数据

  • 第十四章,MLOps 和机器学习工程

  • 第十五章,数据工程

案例研究

  • 第十六章,DevOps 战争故事和访谈

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序列表,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或由上下文确定值的文本。

提示

此元素表示提示或建议。

注释

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

可下载附加材料(代码示例、练习等)位于 https://pythondevops.com。您也可以在 Pragmatic AI Labs YouTube 频道 上查看与本书代码相关的 DevOps 内容。

如果您对作者有技术问题或使用代码示例遇到问题,请发送电子邮件至 technical@pythondevops.com

本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书中几个代码片段的程序不需要许可。销售或分发来自 O’Reilly 图书的示例则需要许可。引用本书并引用示例代码来回答问题则无需许可。将本书中大量示例代码整合到产品文档中则需要许可。

我们感谢您的使用,但通常不要求署名。署名通常包括书名、作者、出版社和 ISBN。例如:“Python for DevOps 由 Noah Gift、Kennedy Behrman、Alfredo Deza 和 Grig Gheorghiu 编写。 (O’Reilly). 版权 2020 Noah Gift、Kennedy Behrman、Alfredo Deza、Grig Gheorghiu,978-1-492-05769-7。”

如果您认为您对代码示例的使用超出了合理使用或上述许可的范围,请随时通过 permissions@oreilly.com 联系我们。

O’Reilly 在线学习

注释

40 多年来,O’Reilly Media 提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章、会议和在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频资源。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版社:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,其中列出了勘误、示例和任何额外信息。你可以访问oreil.ly/python-for-devops获取相关信息。

发送电子邮件至bookquestions@oreilly.com以提出关于本书的评论或技术问题。

欲了解更多有关我们的书籍、课程、会议和新闻的信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

观看我们的 YouTube 频道:http://www.youtube.com/oreillymedia

致谢

首先,作者要感谢本书的两位主要技术审阅者:

Wes Novack 是一位专注于公共云系统和 Web 规模 SaaS 应用的架构师和工程师。他设计、构建和管理复杂系统,支持高可用基础设施、持续交付管道,并在 AWS 和 GCP 上托管大型、多语言微服务生态系统中进行快速发布。Wes 广泛使用各种语言、框架和工具来定义基础设施即代码,推动自动化,消除重复劳动。他通过导师制、研讨会和会议积极参与技术社区,还是 Pluralsight 视频课程的作者。Wes 支持 DevOps 的 CALMS 理念:文化(Culture)、自动化(Automation)、精益(Lean)、度量(Measurement)和分享(Sharing)。你可以在 Twitter 上找到他,账号是@WesleyTech,或者访问他的个人博客

Brad Andersen 是一位软件工程师和架构师。他已经在专业领域设计和开发软件 30 年。他致力于推动变革和创新,在从企业组织到初创公司的各个领域担任领导和开发角色。目前,Brad 正在加利福尼亚大学伯克利分校攻读数据科学硕士学位。你可以在Brad 的 LinkedIn 资料找到更多信息。

我们还要感谢 Jeremy Yabrow 和 Colin B. Erdman 为本书提供了许多优秀的想法和反馈。

Noah

我要感谢这本书的合著者们:Grig、Kennedy 和 Alfredo。能和如此高效的团队合作真是一种不可思议的体验。

Kennedy

感谢我的合著者们,能和你们一起工作真是一种乐趣。还要感谢家人对我的耐心和理解。

Alfredo

在撰写本文九年前的 2010 年,我踏入了我的第一份软件工程工作。当时我 31 岁,没有大学学历,也没有任何工程经验。这份工作意味着我接受了较低的薪水和没有健康保险。通过不懈的努力,我学到了很多,结识了很多了不起的人,并通过这些年获得了专业知识。在这些年里,如果没有人们为我提供的机会和指引我走向正确方向,我是无法走到今天的。

感谢 Chris Benson,他看到了我渴望学习的心,一直找机会让我参与。

感谢 Alejandro Cadavid,他意识到我能解决其他人不愿意解决的问题。在大家(包括我自己)认为我毫无用处时,你帮助我找到了工作。

Carlos Coll 让我开始编程,即使我请求退出他也不让我放弃。学习编程改变了我的生活,Carlos 有耐心地推动我学习,并帮助我完成了第一个投入生产的程序。

特别感谢 Joni Benton,因为她相信我,并帮助我找到了我的第一份全职工作。

感谢 Jonathan LaCour,你是一个鼓舞人心的老板,一直帮助我走向更好的地方。你的建议对我来说是无价的。

Noah,感谢你的友谊和指导,你对我来说是一种巨大的动力源。我们一起工作总是让我很愉快,就像那次我们从头开始重建基础设施的经历。当我对 Python 毫无头绪时,你的耐心和指导改变了我的生活。

最后,要特别感谢我的家人。我的妻子克劳迪娅从不怀疑我的学习和进步能力,她对我花在这本书上的时间如此慷慨理解。我的孩子们,埃弗雷恩、伊格纳西奥和阿兰娜:我爱你们。

Grig

我要感谢所有开源软件的创作者。没有他们,我们的工作将会更加单调乏味。还要感谢所有无私分享知识的博主。最后,我也要感谢这本书的合著者们。这真是一段愉快的旅程。

第一章:DevOps 的 Python 基础知识

DevOps 是软件开发与信息技术运营相结合的领域,在过去的十年中非常热门。传统的软件开发、部署、维护和质量保证之间的界限已经被打破,使得团队更加整合。Python 在传统 IT 运营和 DevOps 中都非常流行,因为它兼具灵活性、强大性和易用性。

Python 编程语言于 1990 年代初公开发布,用于系统管理。在这一领域取得了巨大成功,并广泛应用。Python 是一种通用编程语言,几乎在所有领域都有使用。视觉效果和电影行业都采用了它。最近,它已成为数据科学和机器学习(ML)的事实标准语言。它已经被应用于从航空到生物信息学的各个行业。Python 拥有丰富的工具库,以满足用户广泛的需求。学习整个 Python 标准库(任何 Python 安装都带有的功能)将是一项艰巨的任务。试图学习所有为 Python 生态系统注入活力的第三方包将是一项巨大的工程。好消息是,您不需要做这些事情。通过学习 Python 的一个小子集,您可以成为强大的 DevOps 实践者。

在本章中,我们利用几十年的 Python DevOps 经验,仅教授您需要的语言元素。这些是日常使用的 Python DevOps 部分。它们构成了完成工作的基本工具箱。一旦掌握了这些核心概念,您可以添加更复杂的工具,正如您将在后续章节中看到的那样。

安装和运行 Python

如果您想尝试本概述中的代码,则需要安装 Python 3.7 或更高版本(截至本文撰写时的最新版本为 3.8.0),并且可以访问一个 shell。在 macOS X、Windows 和大多数 Linux 发行版中,您可以打开终端应用程序以访问 shell。要查看正在使用的 Python 版本,请打开 shell 并键入 python --version

$ python --version
Python 3.8.0

Python 安装程序可以直接从 Python.org 网站 下载。或者,您可以使用像 Apt、RPM、MacPorts、Homebrew、Chocolatey 或其他许多包管理器。

Python Shell

运行 Python 的最简单方式是使用内置的交互式解释器。只需在 shell 中键入 python。然后可以交互地运行 Python 语句。输入 exit() 来退出 shell。

$ python
Python 3.8.0 (default, Sep 23 2018, 09:47:03)
[Clang 9.0.0 (clang-900.0.38)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 1 + 2
3
>>> exit()

Python 脚本

Python 代码通过 .py 扩展名的文件运行:

# This is my first Python script
print('Hello world!')

将此代码保存到名为hello.py的文件中。要调用脚本,在 shell 中运行python,然后是文件名:

$ python hello.py
Hello world!

Python 脚本是大多数生产 Python 代码的运行方式。

IPython

除了内置的交互式 shell 外,还有几个第三方交互式 shell 可以运行 Python 代码。其中最受欢迎的之一是 IPython。IPython 提供内省(动态获取对象信息的能力)、语法高亮、特殊的魔术命令(我们稍后在本章介绍),以及许多其他功能,使其成为探索 Python 的乐趣所在。要安装 IPython,请使用 Python 包管理器 pip

$ pip install ipython

运行类似于在上一节中描述的内置交互式 shell 运行:

$ ipython
Python 3.8.0 (default, Sep 23 2018, 09:47:03)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.5.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]: print('Hello')
Hello

In [2]: exit()

Jupyter 笔记本

iPython 项目的一个分支,Jupyter 项目允许包含文本、代码和可视化的文档。这些文档是结合运行代码、输出和格式化文本的强大工具。Jupyter 使得可以将文档与代码一起交付。它在数据科学领域尤其广受欢迎。以下是安装和运行 Jupyter 笔记本的方法:

$ pip install jupyter
$ jupyter notebook

此命令打开一个网页浏览器标签,显示当前工作目录。从这里,您可以打开当前项目中的现有笔记本或创建新的笔记本。

过程式编程

如果您对编程有所了解,可能已经听说过面向对象编程(OOP)和函数式编程等术语。这些是用于组织程序的不同架构范式。其中最基本的范式之一,过程式编程,是一个很好的起点。过程式编程是按顺序向计算机发出指令:

>>> i = 3
>>> j = i +1
>>> i + j
7

正如您在此示例中看到的那样,有三个语句按顺序执行,从第一行到最后一行。每个语句都使用前面语句产生的状态。在本例中,第一个语句将值 3 分配给名为 i 的变量。在第二个语句中,使用此变量的值将一个值分配给名为 j 的变量,在第三个语句中,从两个变量中的值相加。暂时不用担心这些语句的细节;请注意它们按顺序执行并依赖于前面语句创建的状态。

变量

变量是指向某个值的名称。在前面的例子中,变量是 ij。Python 中的变量可以分配新值:

>>> dog_name = 'spot'
>>> dog_name
'spot'
>>> dog_name = 'rex'
>>> dog_name
'rex'
>>> dog_name = 't-' + dog_name
>>> dog_name
't-rex'
>>>

Python 变量使用动态类型。实际上,这意味着它们可以被重新分配给不同类型或类的值:

>>> big = 'large'
>>> big
'large'
>>> big = 1000*1000
>>> big
1000000
>>> big = {}
>>> big
{}
>>>

在这里,同一个变量分别设置为字符串、数字和字典。变量可以重新分配为任何类型的值。

基本数学

可使用内置数学运算符执行基本的数学运算,如加法、减法、乘法和除法:

>>> 1 + 1
2
>>> 3 - 4
–1
>>> 2*5
10
>>> 2/3
0.6666666666666666

请注意,// 符号用于整数除法。符号 ** 表示指数运算,% 是取模运算符:

>>> 5/2
2.5
>>> 5//2
2
>>> 3**2
9
>>> 5%2
1

注释

注释是 Python 解释器忽略的文本。它们对代码的文档化很有用,可以被某些服务用来提供独立的文档。单行注释以#开头。单行注释可以从行的开头开始,或者之后的任何地方开始。#之后的所有内容都是注释,直到新的换行符出现为止。

 # This is a comment
 1 + 1 # This comment follows a statement

多行注释本身被封闭在以"""'''开头和结尾的块中:

"""
This statement is a block comment.
It can run for multiple lines
"""

'''
This statement is also a block comment
'''

内置函数

函数是作为一个单元分组的语句。通过键入函数名,后跟括号来调用函数。如果函数带有参数,则参数出现在括号内。Python 有许多内置函数。其中两个最常用的内置函数是printrange

打印

print函数生成用户程序可以查看的输出。在交互式环境中它不太相关,但在编写 Python 脚本时是一种基本工具。在前面的示例中,print函数的参数在脚本运行时作为输出写入:

# This is my first Python script
print("Hello world!")

$ python hello.py
Hello world!

print可以用于查看变量的值或提供程序状态的反馈。print通常将标准输出流输出,并在 shell 中作为程序输出可见。

范围

虽然range是一个内置函数,但技术上它根本不是一个函数。它是表示数字序列的类型。调用range()构造函数时,会返回一个表示数字序列的对象。范围对象逐个数字计数。range函数最多接受三个整数参数。如果只有一个参数出现,则序列由从零到该数字(但不包括该数字)的数字表示。如果出现第二个参数,则表示起始点,而不是从 0 开始的默认值。第三个参数可用于指定步长距离,默认为 1。

>>> range(10)
range(0, 10)
>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(range(5, 10))
[5, 6, 7, 8, 9]
>>> list(range(5, 10, 3))
[5, 8]
>>>

range维护一个小的内存占用,即使在长序列中也是如此,因为它只存储起始、停止和步长值。range函数可以迭代长序列的数字而不受性能约束。

执行控制

Python 有许多构造来控制语句执行的流程。你可以将希望一起运行的语句组成一个代码块。这些代码块可以使用forwhile循环多次运行,或者仅在某些条件下运行,例如if语句、while循环或try-except块。使用这些构造是利用编程力量的第一步。不同的语言使用不同的约定来标记代码块。许多类似 C 语言(Unix 中使用的一种非常有影响力的语言)的语法的语言,使用花括号来定义一组语句的块。在 Python 中,缩进用来表示一个代码块。语句通过缩进分组成为一个执行单元的块。

注意

Python 解释器不在乎您是使用制表符还是空格缩进,只要您保持一致即可。然而,Python 样式指南PEP-8建议每级缩进使用四个空格。

if/elif/else

if/elif/else语句是在代码中做出决策分支的常见方式。直接跟在if语句后面的代码块会在该语句评估为True时运行:

>>> i = 45
>>> if i == 45:
...     print('i is 45')
...
...
i is 45
>>>

在这里,我们使用了==运算符,如果项目相等则返回True,否则返回False。可选地,此代码块可以在伴随着elifelse语句的情况下跟随。对于elif语句,只有在elif评估为True时才会执行此代码块:

>>> i = 35
>>> if i == 45:
...     print('i is 45')
... elif i == 35:
...     print('i is 35')
...
...
i is 35
>>>

多个elif循环可以连接在一起。如果您熟悉其他语言中的switch语句,则这模拟了从多个选择中选择的相同行为。在末尾添加else语句会在没有其他条件评估为True时运行一个代码块:

>>> i = 0
>>> if i == 45:
...     print('i is 45')
... elif i == 35:
...     print('i is 35')
... elif i > 10:
...     print('i is greater than 10')
... elif i%3 == 0:
...     print('i is a multiple of 3')
... else:
...     print('I don't know much about i...')
...
...
i is a multiple of 3
>>>

您可以嵌套if语句,创建包含仅在外部if语句为True时才执行的if语句的代码块:

>>> cat = 'spot'
>>> if 's' in cat:
...     print("Found an 's' in a cat")
...     if cat == 'Sheba':
...         print("I found Sheba")
...     else:
...         print("Some other cat")
... else:
...     print(" a cat without 's'")
...
...
Found an 's' in a cat
Some other cat
>>>

for 循环

for循环允许您重复执行一组语句(代码块),每个成员的序列(有序的项目组)一次。当您迭代序列时,当前项目可以通过代码块访问。循环的最常见用途之一是通过range对象迭代来执行固定次数的任务:

>>> for i in range(10):
...     x = i*2
...     print(x)
...
...
0
2
4
6
8
10
12
14
16
18
>>>

在本示例中,我们的代码块如下所示:

...     x = i*2
...     print(x)

我们重复这段代码 10 次,每次将变量i分配给从 0 到 9 的整数序列中的下一个数字。for循环可用于迭代 Python 中的任何序列类型。您将在本章后面看到这些内容。

continue

continue语句跳过循环中的一步,直接跳到序列中的下一个项目:

>>> for i in range(6):
...     if i == 3:
...         continue
...     print(i)
...
...
0
1
2
4
5
>>>

while 循环

while循环会在条件评估为True时重复执行一个代码块:

>>> count = 0
>>> while count < 3:
...     print(f"The count is {count}")
...     count += 1
...
...
The count is 0
The count is 1
The count is 2
>>>

定义循环结束的方法至关重要。否则,您将陷入循环直到程序崩溃。处理这种情况的一种方法是定义条件语句,使其最终评估为False。另一种模式使用break语句来使用嵌套条件退出循环:

>>> count = 0
>>> while True:
...     print(f"The count is {count}")
...     if count > 5:
...         break
...     count += 1
...
...
The count is 0
The count is 1
The count is 2
The count is 3
The count is 4
The count is 5
The count is 6
>>>

处理异常

异常是一种导致程序崩溃的错误类型,如果不进行处理(捕获),程序将崩溃。使用try-except块捕获它们允许程序继续运行。通过在可能引发异常的块中缩进,放置try语句在其前面并在其后放置except语句,后跟应在错误发生时运行的代码块创建这些块:

>>> thinkers = ['Plato', 'PlayDo', 'Gumby']
>>> while True:
...     try:
...         thinker = thinkers.pop()
...         print(thinker)
...     except IndexError as e:
...         print("We tried to pop too many thinkers")
...         print(e)
...         break
...
...
...
Gumby
PlayDo
Plato
We tried to pop too many thinkers
pop from empty list
>>>

有许多内置异常,如IOErrorKeyErrorImportError。许多第三方包还定义了它们自己的异常类。它们指示出现了严重问题,因此只有在确信问题对软件不会致命时才值得捕获它们。您可以明确指定将捕获的异常类型。理想情况下,应捕获确切的异常类型(在我们的示例中,这是异常IndexError)。

内置对象

在此概述中,我们不会涉及面向对象编程。然而,Python 语言提供了许多内置类。

什么是对象?

在面向对象编程中,数据或状态与功能一起出现。在使用对象时需要理解的基本概念包括类实例化(从类创建对象)和点语法(访问对象属性和方法的语法)。类定义了其对象共享的属性和方法,可以将其视为汽车模型的技术图纸。然后可以实例化类以创建实例。实例或对象是基于这些图纸构建的单个汽车。

>>> # Define a class for fancy defining fancy cars
>>> class FancyCar():
...     pass
...
>>> type(FancyCar)
<class 'type'>
>>> # Instantiate a fancy car
>>> my_car = FancyCar()
>>> type(my_car)
<class '__main__.FancyCar'>

您在这一点上不需要担心创建自己的类。只需理解每个对象都是类的实例化。

对象方法和属性

对象将数据存储在属性中。这些属性是附加到对象或对象类的变量。对象使用对象方法(为类中所有对象定义的方法)和类方法(附加到类并由类中所有对象共享的方法)定义功能,这些方法是附加到对象的函数。

注意

在 Python 文档中,附加到对象和类的函数称为方法。

这些函数可以访问对象的属性并修改和使用对象的数据。要调用对象的方法或访问其属性之一,使用点语法:

>>> # Define a class for fancy defining fancy cars
>>> class FancyCar():
...     # Add a class variable
...     wheels = 4
...     # Add a method
...     def driveFast(self):
...         print("Driving so fast")
...
...
...
>>> # Instantiate a fancy car
>>> my_car = FancyCar()
>>> # Access the class attribute
>>> my_car.wheels
4
>>> # Invoke the method
>>> my_car.driveFast()
Driving so fast
>>>

因此,在我们的FancyCar类中定义了一个名为driveFast的方法和一个名为wheels的属性。当您实例化名为my_carFancyCar实例时,可以使用点语法访问属性并调用方法。

序列

序列是一组内置类型,包括列表元组范围字符串二进制类型。序列表示有序且有限的项目集合。

序列操作

有许多操作适用于所有类型的序列。我们在此处介绍了一些最常用的操作。

使用innot in运算符可以测试序列中是否存在某个项:

>>> 2 in [1,2,3]
True
>>> 'a' not in 'cat'
False
>>> 10 in range(12)
True
>>> 10 not in range(2, 4)
True

您可以通过使用其索引号引用序列的内容。要访问某个索引处的项,请使用带有索引号的方括号作为参数。第一个索引的项在位置 0,第二个在 1,依此类推,直到比项数少一个的数字:

>>> my_sequence = 'Bill Cheatham'
>>> my_sequence[0]
'B'
>>> my_sequence[2]
'l'
>>> my_sequence[12]
'm'

可以使用负数从序列的末尾而不是从前面进行索引。最后一项的索引为-1,倒数第二项的索引为-2,依此类推:

>>> my_sequence = "Bill Cheatham"
>>> my_sequence[–1]
'm'
>>> my_sequence[–2]
'a'
>>> my_sequence[–13]
'B'

项目的索引来自index方法。默认情况下,它返回项目的第一次出现的索引,但可选参数可以定义要搜索的子范围:

>>> my_sequence = "Bill Cheatham"
>>> my_sequence.index('C')
5
>>> my_sequence.index('a')
8
>>> my_sequence.index('a',9, 12)
11
>>> my_sequence[11]
'a'
>>>

您可以使用切片从序列生成新序列。切片通过在方括号中调用带有可选的startstopstep参数来显示:

my_sequence[start:stop:step]

start是新序列中要使用的第一项的索引,stop是超出该点的第一个索引,step是项之间的距离。这些参数都是可选的,如果省略则替换为默认值。此语句生成原始序列的副本。start的默认值为 0,stop的默认值为序列的长度,step的默认值为 1。注意,如果步骤未显示,则相应的:也可以省略:

>>> my_sequence = ['a', 'b', 'c', 'd', 'e', 'f', 'g']
>>> my_sequence[2:5]
['c', 'd', 'e']
>>> my_sequence[:5]
['a', 'b', 'c', 'd', 'e']
>>> my_sequence[3:]
['d', 'e', 'f', 'g']
>>>

负数可用于向后索引:

>>> my_sequence[–6:]
['b', 'c', 'd', 'e', 'f', 'g']
>>> my_sequence[3:–1]
['d', 'e', 'f']
>>>

序列共享许多操作以获取有关它们及其内容的信息。len返回序列的长度,min返回最小成员,max返回最大成员,count返回特定项的数量。minmax仅适用于具有可比较项的序列。请记住,这些操作适用于任何序列类型:

>>> my_sequence = [0, 1, 2, 0, 1, 2, 3, 0, 1, 2, 3, 4]
>>> len(my_sequence)
12
>>> min(my_sequence)
0
>>> max(my_sequence)
4
>>> my_sequence.count(1)
3
>>>

列表

列表是 Python 中最常用的数据结构之一,表示任何类型的有序集合。使用方括号表示列表语法。

函数list()可用于创建空列表或基于另一个有限可迭代对象(如另一个序列)的列表:

>>> list()
[]
>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list("Henry Miller")
['H', 'e', 'n', 'r', 'y', ' ', 'M', 'i', 'l', 'l', 'e', 'r']
>>>

直接使用方括号创建的列表是最常见的形式。在这种情况下,列表中的项需要显式枚举。请记住,列表中的项可以是不同类型的:

>>> empty = []
>>> empty
[]
>>> nine = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> nine
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> mixed = [0, 'a', empty, 'WheelHoss']
>>> mixed
[0, 'a', [], 'WheelHoss']
>>>

向列表中添加单个项目的最有效方法是将项目append到列表的末尾。一种效率较低的方法insert允许您在选择的索引位置插入项目:

>>> pies = ['cherry', 'apple']
>>> pies
['cherry', 'apple']
>>> pies.append('rhubarb')
>>> pies
['cherry', 'apple', 'rhubarb']
>>> pies.insert(1, 'cream')
>>> pies
['cherry', 'cream', 'apple', 'rhubarb']
>>>

一个列表的内容可以使用extend方法添加到另一个列表中:

>>> pies
['cherry', 'cream', 'apple', 'rhubarb']
>>> desserts = ['cookies', 'paste']
>>> desserts
['cookies', 'paste']
>>> desserts.extend(pies)
>>> desserts
['cookies', 'paste', 'cherry', 'cream', 'apple', 'rhubarb']
>>>

从列表中删除最后一项并返回其值的最有效和常见方法是将其pop出来。此方法可以提供一个索引参数,从而删除并返回该索引处的项目。这种技术效率较低,因为需要重新索引列表:

>>> pies
['cherry', 'cream', 'apple', 'rhubarb']
>>> pies.pop()
'rhubarb'
>>> pies
['cherry', 'cream', 'apple']
>>> pies.pop(1)
'cream'
>>> pies
['cherry', 'apple']

还有一个remove方法,用于删除一个项目的第一次出现。

>>> pies.remove('apple')
>>> pies
['cherry']
>>>

最有效和成语化的 Python 功能之一,列表推导允许您在一行中使用for循环的功能。让我们看一个简单的例子,从一个for循环开始,将 0-9 的所有数字平方,并将它们附加到列表中:

>>> squares = []
>>> for i in range(10):
...     squared = i*i
...     squares.append(squared)
...
...
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
>>>

为了用列表推导式替换它,我们做以下操作:

>>> squares = [i*i for i in range(10)]
>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
>>>

注意内部代码块的功能先被描述,接着是 for 语句。你也可以在列表推导式中加入条件语句,过滤结果:

>>> squares = [i*i for i in range(10) if i%2==0]
>>> squares
[0, 4, 16, 36, 64]
>>>

列表推导式的其他技巧包括嵌套和使用多个变量,但这里展示的更为简单的形式是最常见的。

字符串

字符串序列类型是一组由引号包围的有序字符集。Python 3 默认使用 UTF-8 编码。

可以使用字符串构造方法 str() 或直接用引号将文本包裹来创建字符串:

>>> str()
''
>>> "some new string!"
'some new string!'
>>> 'or with single quotes'
'or with single quotes'

字符串构造方法可以从其他对象创建字符串:

>>> my_list = list()
>>> str(my_list)
'[]'

可以通过在内容周围使用三重引号来创建多行字符串:

>>> multi_line = """This is a
... multi-line string,
... which includes linebreaks.
... """
>>> print(multi_line)
This is a
multi-line string,
which includes linebreaks.
>>>

除了所有序列共享的方法之外,字符串还有一些特定于其类别的方法。

用户文本通常会有末尾或开头的空白字符。如果有人在表单中输入 " yes " 而不是 “yes”,通常希望将它们视为相同。Python 字符串提供了专门的 strip 方法来处理这种情况。它返回一个去掉开头和结尾空白的字符串。还有方法仅移除字符串左侧或右侧的空白:

>>> input = "  I want more  "
>>> input.strip()
'I want more'
>>> input.rstrip()
'  I want more'
>>> input.lstrip()
'I want more  '

另一方面,如果你想要给字符串添加填充,可以使用 ljustrjust 方法。默认情况下,它们使用空格进行填充,也可以指定一个字符作为填充:

>>> output = 'Barry'
>>> output.ljust(10)
'Barry     '
>>> output.rjust(10, '*')
'*****Barry'

有时候你想要将一个字符串分割成子字符串列表。也许你有一个句子想要变成单词列表,或者有一串用逗号分隔的单词字符串。split 方法可以将一个字符串分割成多个子字符串的列表。默认情况下,它使用空白作为分隔符。还可以用一个可选的参数来指定其他字符作为分隔符:

>>> text = "Mary had a little lamb"
>>> text.split()
['Mary', 'had', 'a', 'little', 'lamb']
>>> url = "gt.motomomo.io/v2/api/asset/143"
>>> url.split('/')
['gt.motomomo.io', 'v2', 'api', 'asset', '143']

可以轻松地从一组字符串中创建新的字符串,并使用 join 方法将它们连接成一个单独的字符串。这个方法将一个字符串作为分隔符插入到其他字符串列表之间:

>>> items = ['cow', 'milk', 'bread', 'butter']
>>> " and ".join(items)
'cow and milk and bread and butter'

改变文本大小写是常见的操作,无论是为了进行比较而统一大小写,还是为了准备用户使用。Python 字符串有几种方法可以轻松完成这个过程:

>>> name = "bill monroe"
>>> name.capitalize()
'Bill monroe'
>>> name.upper()
'BILL MONROE'
>>> name.title()
'Bill Monroe'
>>> name.swapcase()
'BILL MONROE'
>>> name = "BILL MONROE"
>>> name.lower()
'bill monroe'

Python 还提供了方法来了解字符串的内容。无论是检查文本的大小写,还是查看它是否表示一个数字,都有相当多的内置方法可供查询。以下是一些最常用的方法:

>>> "William".startswith('W')
True
>>> "William".startswith('Bill')
False
>>> "Molly".endswith('olly')
True
>>> "abc123".isalnum()
True
>>> "abc123".isalpha()
False
>>> "abc".isalnum()
True
>>> "123".isnumeric()
True
>>> "Sandy".istitle()
True
>>> "Sandy".islower()
False
>>> "SANDY".isupper()
True

可以在运行时向字符串中插入内容并控制其格式。你的程序可以在字符串中使用变量的值或其他计算出的内容。这种方法既用于创建用户可用的文本,也用于编写软件日志。

Python 中的旧式字符串格式化形式来自 C 语言的printf函数。你可以使用模数运算符%将格式化值插入到字符串中。这种技术适用于形式string % values,其中 values 可以是单个非元组或多个值的元组。字符串本身必须为每个值都有一个转换说明符。转换说明符至少以%开头,后跟表示插入值类型的字符:

>>> "%s + %s = %s" % (1, 2, "Three")
'1 + 2 = Three'
>>>

其他格式参数包括转换说明符。例如,你可以控制浮点数%f打印的位数:

>>> "%.3f" % 1.234567
'1.235'

这种字符串格式化机制多年来一直是 Python 中的主流,你会在遗留代码中遇到它。这种方法具有一些引人注目的特性,例如与其他语言共享语法。但也存在一些缺陷。特别是由于使用序列来保存参数,与显示tupledict对象相关的错误很常见。我们建议采用更新的格式化选项,如字符串format方法、模板字符串和 f-strings,既可以避免这些错误,又可以增加代码的简洁性和可读性。

Python 3 引入了一种使用字符串方法format格式化字符串的新方法。这种格式化方式已经回溯到 Python 2。此规范使用字符串中的花括号来表示替换字段,而不是旧式格式化的基于模数的转换说明符。插入值变为字符串format方法的参数。参数的顺序决定它们在目标字符串中的放置顺序:

>>> '{} comes before {}'.format('first', 'second')
'first comes before second'
>>>

你可以在括号中指定索引号,以按不同于参数列表顺序插入值。你还可以通过在多个替换字段中指定相同的索引号来重复值:

>>> '{1} comes after {0}, but {1} comes before {2}'.format('first',
                                                           'second',
                                                           'third')
'second comes after first, but second comes before third'
>>>

更强大的功能是可以按名称指定插入值:

>>> '''{country} is an island.
... {country} is off of the coast of
... {continent} in the {ocean}'''.format(ocean='Indian Ocean',
...                                      continent='Africa',
...                                      country='Madagascar')
'Madagascar is an island.
Madagascar is off of the coast of
Africa in the Indian Ocean'

这里一个dict用于提供基于名称的替换字段的键值:

>>> values = {'first': 'Bill', 'last': 'Bailey'}
>>> "Won't you come home {first} {last}?".format(**values)
"Won't you come home Bill Bailey?"

你也可以指定格式规范参数。在这里它们使用><添加左右填充。在第二个示例中,我们指定了用于填充的字符:

>>> text = "|{0:>22}||{0:<22}|"
>>> text.format('O','O')
'|                     O||O                     |'
>>> text = "|{0:<>22}||{0:><22}|"
>>> text.format('O','O')
'|<<<<<<<<<<<<<<<<<<<<<O||O>>>>>>>>>>>>>>>>>>>>>|'

使用格式规范迷你语言来进行格式规范。我们的主题还使用了另一种称为f-strings的语言类型。

Python 的 f-strings 使用与format方法相同的格式化语言,但提供了一个更简单直观的机制来使用它们。f-strings 在第一个引号之前用fF标记。与前述的format字符串类似,f-strings 使用大括号来标识替换字段。然而,在 f-string 中,替换字段的内容是一个表达式。这种方法意味着它可以引用当前范围内定义的变量或涉及计算:

>>> a = 1
>>> b = 2
>>> f"a is {a}, b is {b}. Adding them results in {a + b}"
'a is 1, b is 2\. Adding them results in 3'

format字符串一样,f-字符串中的格式规范位于值表达式后的大括号内,并以:开头:

>>> count = 43
>>> f"|{count:5d}"
'|   43'

值表达式可以包含嵌套表达式,在父表达式的构造中引用变量和表达式:

>>> padding = 10
>>> f"|{count:{padding}d}"
'|        43'
提示

我们强烈建议您在大多数字符串格式化时使用 f-字符串。它们结合了规范迷你语言的强大功能与简单直观的语法。

模板字符串旨在提供简单的字符串替换机制。这些内置方法适用于国际化等任务,其中需要简单的单词替换。它们使用$作为替换字符,并在其周围可选地使用大括号。紧跟在$后面的字符标识要插入的值。当字符串模板的substitute方法执行时,这些名称用于分配值。

注意

当您运行 Python 代码时,内置类型和函数是可用的,但要访问 Python 生态系统中提供的更广泛的功能,您需要使用import语句。此方法允许您将 Python 标准库或第三方服务的功能添加到您的环境中。您可以使用from关键字选择性地从包中导入部分功能:

>>> from string import Template
>>> greeting = Template("$hello Mark Anthony")
>>> greeting.substitute(hello="Bonjour")
'Bonjour Mark Anthony'
>>> greeting.substitute(hello="Zdravstvuyte")
'Zdravstvuyte Mark Anthony'
>>> greeting.substitute(hello="Nǐn hǎo")
'Nǐn hǎo Mark Anthony'

字典

除了字符串和列表外,字典可能是 Python 内置类中使用最多的。Dict是键到值的映射。使用键查找任何特定值的操作非常高效和快速。键可以是字符串、数字、自定义对象或任何其他不可变类型。

注意

可变对象是指其内容可以就地更改的对象。列表是一个主要的例子;列表的内容可以更改而列表的身份不会改变。字符串是不可变的。每次更改现有字符串的内容时,都会创建一个新字符串。

字典被表示为由大括号包围的逗号分隔的键/值对。键/值对包括键、冒号(:)和值。

您可以使用dict()构造函数创建一个字典对象。如果没有参数,则创建一个空字典。它还可以接受一系列键/值对作为参数:

>>> map = dict()
>>> type(map)
<class 'dict'>
>>> map
{}
>>> kv_list = [['key-1', 'value-1'], ['key-2', 'value-2']]
>>> dict(kv_list)
{'key-1': 'value-1', 'key-2': 'value-2'}

您也可以直接使用大括号创建dict

>>> map = {'key-1': 'value-1', 'key-2': 'value-2'}
>>> map
{'key-1': 'value-1', 'key-2': 'value-2'}

您可以使用方括号语法访问与键相关联的值:

>>> map['key-1']
'value-1'
>>> map['key-2']
'value-2'

您可以使用相同的语法设置一个值。如果键不在字典中,则将其添加为新条目。如果已存在,则该值将更改为新值:

>>> map
{'key-1': 'value-1', 'key-2': 'value-2'}
>>> map['key-3'] = 'value-3'
>>> map
{'key-1': 'value-1', 'key-2': 'value-2', 'key-3': 'value-3'}
>>> map['key-1'] = 13
>>> map
{'key-1': 13, 'key-2': 'value-2', 'key-3': 'value-3'}

如果尝试访问在字典中未定义的键,则会引发KeyError异常:

>>> map['key-4']
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    map['key-4']
KeyError: 'key-4'

您可以使用我们在序列中看到的in语法检查字典中键是否存在。对于字典,它检查键的存在:

>>> if 'key-4' in map:
...     print(map['key-4'])
... else:
...     print('key-4 not there')
...
...
key-4 not there

更直观的解决方案是使用get()方法。如果在字典中未定义键,则返回提供的默认值。如果未提供默认值,则返回None

>>> map.get('key-4', 'default-value')
'default-value'

使用del从字典中删除一个键值对:

>>> del(map['key-1'])
>>> map
{'key-2': 'value-2', 'key-3': 'value-3'}

keys()方法返回一个带有字典键的dict_keys对象。values()方法返回一个带有字典值的dict_values对象,而items()方法返回键值对。这个最后的方法对于迭代字典内容非常有用:

>>> map.keys()
dict_keys(['key-1', 'key-2'])
>>> map.values()
dict_values(['value-1', 'value-2'])
>>> for key, value in map.items():
...     print(f"{key}: {value}")
...
...
key-1: value-1
key-2: value-2

与列表推导式类似,字典推导式是通过迭代一个序列返回一个字典的一行语句:

>>> letters = 'abcde'
>>> # mapping individual letters to their upper-case representations
>>> cap_map = {x: x.upper() for x in letters}
>>> cap_map['b']
'B'

函数

您已经看到了一些 Python 内置函数。现在开始编写您自己的函数。记住,函数是一种封装代码块的机制。您可以在多个位置重复此代码块的行为,而无需重复代码。您的代码将更有组织性,更易于测试、维护和理解。

函数的解剖

函数定义的第一行以关键字def开头,后面跟着函数名、括号括起来的函数参数,然后是:。函数的其余部分是一个缩进的代码块:

def <FUNCTION NAME>(<PARAMETERS>):
    <CODE BLOCK>

如果在缩进块中首先提供使用多行语法的字符串,则它作为文档。使用这些描述函数的作用、参数如何工作以及可以预期返回什么。您会发现这些文档字符串对与代码的未来用户进行交流非常宝贵。各种程序和服务也使用它们来创建文档。提供文档字符串被认为是最佳实践,并强烈推荐使用:

>>> def my_function():
...    '''This is a doc string.
...
...    It should describe what the function does,
...    what parameters work, and what the
...    function returns.
...    '''

函数参数出现在函数名后面的括号中。它们可以是位置参数或关键字参数。位置参数使用参数的顺序来分配值:

>>> def positioned(first, second):
...     """Assignment based on order."""
...     print(f"first: {first}")
...     print(f"second: {second}")
...
...
>>> positioned(1, 2)
first: 1
second: 2
>>>

使用关键字参数,为每个参数分配一个默认值:

>>> def keywords(first=1, second=2):
...     '''Default values assigned'''
...     print(f"first: {first}")
...     print(f"second: {second}")
...
...

当在函数调用时没有传递值时,将使用默认值。关键字参数在函数调用时可以通过名称调用,此时顺序将无关紧要:

>>> keywords(0)
first: 0
second: 2
>>> keywords(3,4)
first: 3
second: 4
>>> keywords(second='one', first='two')
first: two
second: one

在使用关键字参数时,所有在关键字参数之后定义的参数必须也是关键字参数。所有函数都返回一个值。使用return关键字来设置这个值。如果从函数定义中未设置,则函数返回None

>>> def no_return():
...     '''No return defined'''
...     pass
...
>>> result = no_return()
>>> print(result)
None
>>> def return_one():
...     '''Returns 1'''
...     return 1
...
>>> result = return_one()
>>> print(result)
1

函数作为对象

函数是对象。它们可以被传递,或者存储在数据结构中。您可以定义两个函数,将它们放入列表中,然后遍历列表以调用它们:

>>> def double(input):
...     '''double input'''
...     return input*2
...
>>> double
<function double at 0x107d34ae8>
>>> type(double)
<class 'function'>
>>> def triple(input):
...     '''Triple input'''
...     return input*3
...
>>> functions = [double, triple]
>>> for function in functions:
...     print(function(3))
...
...
6
9

匿名函数

当需要创建一个非常有限的函数时,可以使用lambda关键字创建一个未命名(匿名)函数。一般情况下,应将它们的使用限制在函数期望小函数作为参数的情况下。在这个例子中,您接受一个列表的列表并对其进行排序。默认的排序机制基于每个子列表的第一个项目进行比较:

>>> items = [[0, 'a', 2], [5, 'b', 0], [2, 'c', 1]]
>>> sorted(items)
[[0, 'a', 2], [2, 'c', 1], [5, 'b', 0]]

若要根据除第一个条目之外的其他内容进行排序,可以定义一个返回项目第二个条目的方法,并将其传递给排序函数的key参数:

>>> def second(item):
...     '''return second entry'''
...     return item[1]
...
>>> sorted(items, key=second)
[[0, 'a', 2], [5, 'b', 0], [2, 'c', 1]]

使用lambda关键字,您可以在没有完整函数定义的情况下执行相同的操作。Lambda 使用lambda关键字,后跟参数名称、冒号和返回值:

lambda <PARAM>: <RETURN EXPRESSION>

使用 lambda 进行排序,首先使用第二个条目,然后使用第三个:

>>> sorted(items, key=lambda item: item[1])
[[0, 'a', 2], [5, 'b', 0], [2, 'c', 1]]
>>> sorted(items, key=lambda item: item[2])
[[5, 'b', 0], [2, 'c', 1], [0, 'a', 2]]

在更一般地使用 lambda 时要谨慎,因为如果用作一般函数的替代品,它们可能会创建文档不足且难以阅读的代码。

使用正则表达式

反复出现需要在字符串中匹配模式的情况。您可能正在查找日志文件中的标识符,或者检查用户输入的关键字或其他许多情况。您已经看到了使用in操作符进行简单模式匹配,或者字符串.endswith.startswith方法。要进行更复杂的匹配,您需要更强大的工具。正则表达式,通常称为 regex,是答案。正则表达式使用一系列字符来定义搜索模式。Python 的re包提供了类似于 Perl 中的正则表达式操作。re模块使用反斜杠(\)来标示匹配中使用的特殊字符。为了避免与常规字符串转义序列混淆,在定义正则表达式模式时建议使用原始字符串。原始字符串在第一个引号前加上r

注意

Python 字符串具有几个转义序列。其中最常见的是换行符\n和制表符\t

搜索

假设您有来自电子邮件的抄送列表作为文本,并且您想进一步了解谁在此列表中:

In [1]: cc_list = '''Ezra Koenig <ekoenig@vpwk.com>,
 ...: Rostam Batmanglij <rostam@vpwk.com>,
 ...: Chris Tomson <ctomson@vpwk.com,
 ...: Bobbi Baio <bbaio@vpwk.com'''

如果您想知道这段文本中是否有一个名称,您可以使用in序列成员语法:

In [2]: 'Rostam' in cc_list
Out[2]: True

要获得类似的行为,您可以使用re.search函数,仅在有匹配时返回re.Match对象:

In [3]: import re

In [4]: re.search(r'Rostam', cc_list)
Out[4]: <re.Match object; span=(32, 38), match='Rostam'>

您可以将此用作测试成员资格的条件:

>>> if re.search(r'Rostam', cc_list):
...     print('Found Rostam')
...
...
Found Rostam

字符集

到目前为止,re还没有给您使用in运算符获得的任何内容。但是,如果您在文本中寻找一个人,但无法记住名字是Bobbi还是Robby,该怎么办?

使用正则表达式,您可以使用一组字符,其中任何一个都可以出现在某个位置。这些称为字符集。在正则表达式定义中,匹配应选择的字符由方括号括起来。您可以匹配BR,接着是obb,然后是iy

In [5]: re.search(r'[R,B]obb[i,y]', cc_list)
Out[5]: <re.Match object; span=(101, 106), match='Bobbi'>

您可以将逗号分隔的单个字符放入字符集中,也可以使用范围。范围A–Z包括所有大写字母;范围0–9包括从零到九的数字:

In [6]: re.search(r'Chr[a-z][a-z]', cc_list)
Out [6]: <re.Match object; span=(69, 74), match='Chris'>

在正则表达式中,+匹配一个或多个项目。括号中的数字匹配精确数量的字符:

In [7]: re.search(r'[A-Za-z]+', cc_list)
Out [7]: <re.Match object; span=(0, 4), match='Ezra'>
In [8]: re.search(r'[A-Za-z]{6}', cc_list)
Out [8]: <re.Match object; span=(5, 11), match='Koenig'>

我们可以使用字符集和其他字符的组合构建匹配以匹配电子邮件地址的原始匹配器。.字符具有特殊含义。它是一个通配符,匹配任何字符。要匹配实际的.字符,您必须使用反斜杠进行转义:

In [9]: re.search(r'[A-Za-z]+@[a-z]+\.[a-z]+', cc_list)
Out[9]: <re.Match object; span=(13, 29), match='ekoenig@vpwk.com'>

此示例仅演示了字符集。它并不代表用于电子邮件的生产就绪正则表达式的全部复杂性。

字符类

除了字符集外,Python 的re还提供字符类。这些是预定义的字符集。一些常用的是\w,它等效于[a-zA-Z0-9_],以及\d,它等效于[0-9]。您可以使用+修饰符匹配多个字符:

>>> re.search(r'\w+', cc_list)
<re.Match object; span=(0, 4), match='Ezra'>

您还可以用\w替换我们原始的电子邮件匹配器:

>>> re.search(r'\w+\@\w+\.\w+', cc_list)
<re.Match object; span=(13, 29), match='ekoenig@vpwk.com'>

您可以使用括号在匹配中定义分组。可以从匹配对象访问这些组。它们按它们出现的顺序编号,零号组为完整匹配:

>>> re.search(r'(\w+)\@(\w+)\.(\w+)', cc_list)
<re.Match object; span=(13, 29), match='ekoenig@vpwk.com'>
>>> matched = re.search(r'(\w+)\@(\w+)\.(\w+)', cc_list)
>>> matched.group(0)
'ekoenig@vpwk.com'
>>> matched.group(1)
'ekoenig'
>>> matched.group(2)
'vpwk'
>>> matched.group(3)
'com'

命名分组

您还可以通过在组定义中添加?P<NAME>为组添加名称。然后可以按名称而不是编号访问组:

>>> matched = re.search(r'(?P<name>\w+)\@(?P<SLD>\w+)\.(?P<TLD>\w+)', cc_list)
>>> matched.group('name')
'ekoenig'
>>> print(f'''name: {matched.group("name")}
... Secondary Level Domain: {matched.group("SLD")}
... Top Level Domain: {matched.group("TLD")}''')
name: ekoenig
Secondary Level Domain: vpwk
Top Level Domain: com

查找全部

到目前为止,我们演示了只返回找到的第一个匹配项。我们也可以使用findall将所有匹配项作为字符串列表返回:

>>> matched = re.findall(r'\w+\@\w+\.\w+', cc_list)
>>> matched
['ekoenig@vpwk.com', 'rostam@vpwk.com', 'ctomson@vpwk.com', 'cbaio@vpwk.com']
>>> matched = re.findall(r'(\w+)\@(\w+)\.(\w+)', cc_list)
>>> matched
[('ekoenig', 'vpwk', 'com'), ('rostam', 'vpwk', 'com'),
 ('ctomson', 'vpwk', 'com'), ('cbaio', 'vpwk', 'com')]
>>> names = [x[0] for x in matched]
>>> names
['ekoenig', 'rostam', 'ctomson', 'cbaio']

查找迭代器

处理大文本(如日志)时,最好不要一次性处理文本。您可以使用finditer方法生成一个迭代器对象。此对象处理文本直到找到匹配项然后停止。将其传递给next函数返回当前匹配并继续处理直到找到下一个匹配。通过这种方式,您可以单独处理每个匹配项,而无需一次性处理所有输入以节省资源:

>>> matched = re.finditer(r'\w+\@\w+\.\w+', cc_list)
>>> matched
<callable_iterator object at 0x108e68748>
>>> next(matched)
<re.Match object; span=(13, 29), match='ekoenig@vpwk.com'>
>>> next(matched)
<re.Match object; span=(51, 66), match='rostam@vpwk.com'>
>>> next(matched)
<re.Match object; span=(83, 99), match='ctomson@vpwk.com'>

迭代器对象matched也可以在for循环中使用:

>>> matched = re.finditer("(?P<name>\w+)\@(?P<SLD>\w+)\.(?P<TLD>\w+)", cc_list)
>>> for m in matched:
...     print(m.groupdict())
...
...
{'name': 'ekoenig', 'SLD': 'vpwk', 'TLD': 'com'}
{'name': 'rostam', 'SLD': 'vpwk', 'TLD': 'com'}
{'name': 'ctomson', 'SLD': 'vpwk', 'TLD': 'com'}
{'name': 'cbaio', 'SLD': 'vpwk', 'TLD': 'com'}

替换

除了搜索和匹配外,正则表达式还可用于替换字符串的一部分或全部:

>>> re.sub("\d", "#", "The passcode you entered was  09876")
'The passcode you entered was  #####'
>>> users = re.sub("(?P<name>\w+)\@(?P<SLD>\w+)\.(?P<TLD>\w+)",
                   "\g<TLD>.\g<SLD>.\g<name>", cc_list)
>>> print(users)
Ezra Koenig <com.vpwk.ekoenig>,
Rostam Batmanglij <com.vpwk.rostam>,
Chris Tomson <com.vpwk.ctomson,
Chris Baio <com.vpwk.cbaio

编译

到目前为止,所有示例都直接在re模块上调用方法。这对许多情况是足够的,但如果同一匹配会发生多次,则通过将正则表达式编译成对象可以获得性能增益。这个对象可以重复用于匹配而不需要重新编译:

>>> regex = re.compile(r'\w+\@\w+\.\w+')
>>> regex.search(cc_list)
<re.Match object; span=(13, 29), match='ekoenig@vpwk.com'>

正则表达式提供的功能远远超出我们在这里讨论的范围。实际上,有许多书籍专门讨论它们的使用,但是现在您应该准备好处理大多数基本情况了。

惰性评估

惰性评估是一个概念,特别是在处理大量数据时,您不希望在使用结果之前处理所有数据。您已经在range类型中看到了这一点,在其中,即使表示大量数字的一个range对象的内存占用量也是相同的。

生成器

您可以像使用range对象一样使用生成器。它们按需对数据执行一些操作,并在调用之间暂停其状态。这意味着您可以存储需要计算输出的变量,并且每次调用生成器时都会访问它们。

要编写生成器函数,使用yield关键字而不是返回语句。每次调用生成器时,它都返回yield指定的值,然后暂停其状态,直到下次调用。让我们编写一个简单地计数的生成器:

>>> def count():
...     n = 0
...     while True:
...         n += 1
...         yield n
...
...
>>> counter = count()
>>> counter
<generator object count at 0x10e8509a8>
>>> next(counter)
1
>>> next(counter)
2
>>> next(counter)
3

请注意,生成器会跟踪其状态,因此每次调用生成器时,变量n都反映了先前设置的值。让我们实现一个 Fibonacci 生成器:

>>> def fib():
...     first = 0
...     last = 1
...     while True:
...         first, last = last, first + last
...         yield first
...
>>> f = fib()
>>> next(f)
1
>>> next(f)
1
>>> next(f)
2
>>> next(f)
3

我们也可以在for循环中使用生成器进行迭代:

>>> f = fib()
>>> for x in f:
...     print(x)
...     if x > 12:
...         break
...
1
1
2
3
5
8
13

生成器推导式

我们可以使用生成器推导式创建单行生成器。它们使用类似于列表推导式的语法,但使用圆括号而不是方括号:

>>> list_o_nums = [x for x in range(100)]
>>> gen_o_nums = (x for x in range(100))
>>> list_o_nums
[0, 1, 2, 3, ...  97, 98, 99]
>>> gen_o_nums
<generator object <genexpr> at 0x10ea14408>

即使是这个小例子,我们也可以看到使用sys.getsizeof方法来查看对象的内存使用情况的差异,该方法以字节为单位返回对象的大小:

>>> import sys
>>> sys.getsizeof(list_o_nums)
912
>>> sys.getsizeof(gen_o_nums)
120

更多 IPython 特性

您在本章开头看到了一些 IPython 的特性。现在让我们看一些更高级的功能,例如从 IPython 解释器内部运行 shell 命令和使用魔术函数。

使用 IPython 运行 Unix Shell 命令

你可以使用 IPython 运行 shell 命令。这是在 IPython shell 中执行 DevOps 操作的最有说服力的原因之一。让我们看一个非常简单的例子,其中!字符用于标识 IPython 中的 shell 命令,并放在ls命令的前面:

In [3]: var_ls = !ls -l
In [4]: type(var_ls)
Out[4]: IPython.utils.text.SList

命令的输出被分配给一个 Python 变量var_ls。此变量的typeIPython.utils.text.SListSList类型将常规 shell 命令转换为具有三个主要方法的对象:fieldsgrepsort。以下是使用 Unix 的df命令进行排序的示例,sort方法可以解释此 Unix 命令中的空格,并按大小对第三列进行排序:

In [6]: df = !df
In [7]: df.sort(3, nums = True)

接下来让我们来看看SList.grep。以下是一个示例,搜索安装在/usr/bin目录中名称包含kill的命令:

In [10]: ls = !ls -l /usr/bin
In [11]: ls.grep("kill")
Out[11]:
['-rwxr-xr-x   1 root   wheel      1621 Aug 20  2018 kill.d',
 '-rwxr-xr-x   1 root   wheel     23984 Mar 20 23:10 killall',
 '-rwxr-xr-x   1 root   wheel     30512 Mar 20 23:10 pkill']

这里的关键是,IPython 是一个非常适合用来玩弄小型 shell 脚本的理想环境。

使用 IPython 的魔术命令

如果你习惯使用 IPython,也应该养成使用内置魔术命令的习惯。它们本质上是一些强大的快捷方式。魔术命令通过在其前面加上%%来表示。这里有一个在 IPython 中写内联 Bash 的例子。注意,这只是一个小命令,但它可以是一个完整的 Bash 脚本:

In [13]: %%bash
    ...: uname -a
    ...:
    ...:
Darwin nogibjj.local 18.5.0 Darwin Kernel Version 18.5.0: Mon Mar ...

%%writefile 很棘手,因为你可以即兴编写和测试 Python 或 Bash 脚本,使用 IPython 执行它们。这绝对不是一个坏点子:

In [16]: %%writefile print_time.py
    ...: #!/usr/bin/env python
    ...: import datetime
    ...: print(datetime.datetime.now().time())
    ...:
    ...:
    ...:
Writing print_time.py

In [17]: cat print_time.py
#!/usr/bin/env python
import datetime
print(datetime.datetime.now().time())

In [18]: !python print_time.py
19:06:00.594914

另一个非常有用的命令 %who,将显示加载到内存中的内容。当你在一个长时间运行的终端中工作时,它非常方便:

In [20]: %who
df     ls     var_ls

练习

  • 编写一个 Python 函数,接受一个名称作为参数并打印该名称。

  • 编写一个 Python 函数,接受一个字符串作为参数并打印它是大写还是小写。

  • 编写一个列表推导,将单词 smogtether 中的每个字母都大写。

  • 编写一个生成器,交替返回 EvenOdd

第二章:自动化文件和文件系统

Python 最强大的功能之一是其处理文本和文件的能力。在 DevOps 的世界中,您不断地解析、搜索和更改文件中的文本,无论是搜索应用程序日志还是传播配置文件。文件是持久化数据、代码和配置状态的手段;它们是您查看日志发生的情况和控制配置发生的方式。使用 Python,您可以在代码中创建、读取和更改文件和文本,以便重复使用。自动化这些任务确实是现代 DevOps 的一个方面,它使其与传统系统管理有所区别。与手动跟随一套指令不同,您可以编写代码。这样可以减少错过步骤或按顺序执行它们的机会。如果您确信每次运行系统时都使用相同的步骤,那么您对过程的理解和信心将会更高。

读取和写入文件

使用open函数可以创建一个文件对象,该对象可以读取和写入文件。它接受两个参数,文件路径和模式(模式默认为读取)。您可以使用模式指示是否要读取或写入文件,以及文件是文本还是二进制数据等。您可以使用模式 r 打开文本文件以读取其内容。文件对象具有一个read方法,该方法将文件内容作为字符串返回:

In [1]: file_path = 'bookofdreams.txt'
In [2]: open_file = open(file_path, 'r')
In [3]: text = open_file.read()
In [4]: len(text)
Out[4]: 476909

In [5]: text[56]
Out[5]: 's'

In [6]: open_file
Out[6]: <_io.TextIOWrapper name='bookofdreams.txt' mode='r' encoding='UTF-8'>

In [7]: open_file.close()
注意

当您完成文件操作时关闭文件是一个良好的实践。Python 在文件超出范围时会关闭文件,但在此之前文件会消耗资源,并可能阻止其他进程打开它。

您还可以使用 readlines 方法读取文件。此方法读取文件并根据换行符拆分其内容。它返回一个字符串列表。每个字符串都是原始文本的一行:

In [8]: open_file = open(file_path, 'r')
In [9]: text = open_file.readlines()
In [10]: len(text)
Out[10]: 8796

In [11]: text[100]
Out[11]: 'science, when it admits the possibility of occasional hallucinations\n'

In [12]: open_file.close()

使用 with 语句打开文件的一个便捷方法。在这种情况下,您不需要显式关闭文件。Python 在缩进块结束时关闭文件并释放文件资源:

In [13]: with open(file_path, 'r') as open_file:
    ...:     text = open_file.readlines()
    ...:

In [14]: text[101]
Out[14]: 'in the sane and healthy, also admits, of course, the existence of\n'

In [15]: open_file.closed
Out[15]: True

不同的操作系统使用不同的转义字符表示换行符。Unix 系统使用 \n,而 Windows 系统使用 \r\n。Python 在将文件作为文本打开时会将这些转换为 \n。如果您以文本方式打开二进制文件,例如 .jpeg 图像,则可能会通过此转换损坏数据。但是,您可以通过在模式后附加 b 来读取二进制文件:

In [15]: file_path = 'bookofdreamsghos00lang.pdf'
In [16]: with open(file_path, 'rb') as open_file:
    ...:     btext = open_file.read()
    ...:

In [17]: btext[0]
Out[17]: 37

In [18]: btext[:25]
Out[18]: b'%PDF-1.5\n%\xec\xf5\xf2\xe1\xe4\xef\xe3\xf5\xed\xe5\xee\xf4\n18'

添加此项将不对行结束进行任何转换。

要写入文件,使用写入模式,表示为参数w。工具direnv用于自动设置一些开发环境。您可以在名为.envrc的文件中定义环境变量和应用程序运行时;direnv在进入带有该文件的目录时使用它来设置这些内容。您可以在 Python 中使用带有写入标志的open来将环境变量STAGE设置为PRODTABLE_ID设置为token-storage-1234

In [19]: text = '''export STAGE=PROD
 ...: export TABLE_ID=token-storage-1234'''

In [20]: with open('.envrc', 'w') as opened_file:
    ...:     opened_file.write(text)
    ...:

In [21]: !cat .envrc
export STAGE=PROD
export TABLE_ID=token-storage-1234
警告

警告,如果文件已经存在,pathlibwrite方法将覆盖该文件。

open函数如果文件不存在将创建文件,并且如果存在则覆盖它。如果您想保留现有内容并仅追加文件,请使用追加标志a。此标志将新文本追加到文件末尾,同时保留原始内容。如果您正在写入非文本内容,例如.jpeg文件的内容,如果使用wa标志,可能会导致其损坏。当 Python 写入文本数据时,它会将行结束符转换为特定于平台的结束符。要写入二进制数据,您可以安全地使用wbab

第三章深入介绍了pathlib。两个有用的功能是便捷函数用于读取和写入文件。pathlib在幕后处理文件对象。以下示例允许您从文件中读取文本:

In [35]: import pathlib

In [36]: path = pathlib.Path(
           "/Users/kbehrman/projects/autoscaler/check_pending.py")

In [37]: path.read_text()

要读取二进制数据,请使用path.read_bytes方法。

当您想要覆盖文件或写入新文件时,有写入文本和写入二进制数据的方法:

In [38]: path = pathlib.Path("/Users/kbehrman/sp.config")

In [39]: path.write_text("LOG:DEBUG")
Out[39]: 9

In [40]: path = pathlib.Path("/Users/kbehrman/sp")
Out[41]: 8

使用文件对象的readwrite函数进行读写通常对于非结构化文本已经足够,但是如果你要处理更复杂的数据怎么办?JavaScript 对象表示法(JSON)格式被广泛用于在现代 Web 服务中存储简单的结构化数据。它使用两种数据结构:类似于 Python dict的键-值对映射和类似于 Python list的项目列表。它定义了数字、字符串、布尔(保存 true/false 值)和nulls(空值)的数据类型。AWS 身份和访问管理(IAM)Web 服务允许您控制对 AWS 资源的访问。它使用 JSON 文件来定义访问策略,例如以下示例文件:

{
    "Version": "2012-10-17",
    "Statement": {
        "Effect": "Allow",
        "Action": "service-prefix:action-name",
        "Resource": "*",
        "Condition": {
            "DateGreaterThan": {"aws:CurrentTime": "2017-07-01T00:00:00Z"},
            "DateLessThan": {"aws:CurrentTime": "2017-12-31T23:59:59Z"}
        }
    }
}

您可以使用标准文件对象的readreadlines方法从此类文件中获取数据:

In [8]: with open('service-policy.json', 'r') as opened_file:
   ...:     policy = opened_file.readlines()
   ...:
   ...:

结果可能不能立即使用,因为它将是一个字符串或字符串列表,具体取决于您选择的读取方法:

In [9]: print(policy)
['{\n',
 '    "Version": "2012-10-17",
\n',
 '    "Statement": {\n',
 '        "Effect": "Allow",
\n',
 '        "Action": "service-prefix:action-name",
\n',
 '        "Resource": "*",
\n',
 '        "Condition": {\n',
 '            "DateGreaterThan": {"aws:CurrentTime": "2017-07-01T00:00:00Z"},
\n',
 '            "DateLessThan": {"aws:CurrentTime": "2017-12-31T23:59:59Z"}\n',
 '        }\n',
 '    }\n',
 '}\n']

然后,您需要将此字符串(或字符串)解析为与原始数据结构和类型匹配的数据。这可能是一项相当大的工作。更好的方法是使用json模块:

In [10]: import json

In [11]: with open('service-policy.json', 'r') as opened_file:
    ...:     policy = json.load(opened_file)
    ...:
    ...:
    ...:

此模块会为您解析 JSON 格式,将数据返回为适当的 Python 数据结构:

In [13]: from pprint import pprint

In [14]: pprint(policy)
{'Statement': {'Action': 'service-prefix:action-name',
               'Condition': {'DateGreaterThan':
                                  {'aws:CurrentTime': '2017-07-01T00:00:00Z'},
                             'DateLessThan':
                                  {'aws:CurrentTime': '2017-12-31T23:59:59Z'}},
               'Effect': 'Allow',
               'Resource': '*'},
 'Version': '2012-10-17'}
注意

pprint模块会自动格式化 Python 对象以便打印。其输出通常更易读,是查看嵌套数据结构的便捷方式。

现在你可以使用原始文件结构中的数据。例如,这里是如何将该策略控制访问的资源更改为 S3

In [15]: policy['Statement']['Resource'] = 'S3'

In [16]: pprint(policy)
{'Statement': {'Action': 'service-prefix:action-name',
               'Condition': {'DateGreaterThan':
                                {'aws:CurrentTime': '2017-07-01T00:00:00Z'},
                             'DateLessThan':
                                {'aws:CurrentTime': '2017-12-31T23:59:59Z'}},
               'Effect': 'Allow',
               'Resource': 'S3'},
 'Version': '2012-10-17'}

你可以通过使用 json.dump 方法将 Python 字典写入 JSON 文件。这就是你更新刚修改的策略文件的方式:

In [17]: with open('service-policy.json', 'w') as opened_file:
    ...:     policy = json.dump(policy, opened_file)
    ...:
    ...:
    ...:

另一种常用于配置文件的语言是 YAML(“YAML Ain’t Markup Language”)。它是 JSON 的超集,但有更紧凑的格式,使用与 Python 类似的空白。

Ansible 是一个用于自动化软件配置、管理和部署的工具。Ansible 使用称为 playbooks 的文件来定义你想要自动化的操作。这些 playbooks 使用 YAML 格式:

---
- hosts: webservers
  vars:
    http_port: 80
    max_clients: 200
  remote_user: root
  tasks:
  - name: ensure apache is at the latest version
    yum:
      name: httpd
      state: latest
  ...

Python 中最常用的解析 YAML 文件的库是 Py 在 Python 中解析 YAML 文件最常用的库是 PyYAML。它不在 Python 标准库中,但你可以使用 pip 安装它:

$ pip install PyYAML

安装完成后,你可以像处理 JSON 数据那样使用 PyYAML 导入和导出 YAML 数据:

In [18]: import yaml

In [19]: with open('verify-apache.yml', 'r') as opened_file:
    ...:     verify_apache = yaml.safe_load(opened_file)
    ...:

数据加载为熟悉的 Python 数据结构(一个包含 dictlist):

In [20]: pprint(verify_apache)
[{'handlers': [{'name': 'restart apache',
                'service': {'name': 'httpd', 'state': 'restarted'}}],
  'hosts': 'webservers',
  'remote_user': 'root',
  'tasks': [{'name': 'ensure apache is at the latest version',
             'yum': {'name': 'httpd', 'state': 'latest'}},
            {'name': 'write the apache config file',
             'notify': ['restart apache'],
             'template': {'dest': '/etc/httpd.conf', 'src': '/srv/httpd.j2'}},
            {'name': 'ensure apache is running',
             'service': {'name': 'httpd', 'state': 'started'}}],
  'vars': {'http_port': 80, 'max_clients': 200}}]

你还可以将 Python 数据保存到 YAML 格式的文件中:

In [22]: with open('verify-apache.yml', 'w') as opened_file:
    ...:     yaml.dump(verify_apache, opened_file)
    ...:
    ...:
    ...:

另一种广泛用于表示结构化数据的语言是可扩展标记语言(XML)。它由带标签的元素的层次文档组成。历史上,许多 web 系统使用 XML 传输数据。其中一种用途是用于实时简单聚合(RSS)订阅源。RSS 订阅源用于跟踪和通知用户网站更新,并已被用于跟踪来自各种来源的文章的发布。RSS 订阅源使用 XML 格式的页面。Python 提供了 xml 库来处理 XML 文档。它将 XML 文档的层次结构映射到类似树状的数据结构。树的节点是元素,使用父子关系来建模层次结构。最顶层的父节点称为根元素。要解析一个 RSS XML 文档并获取其根节点:

In [1]: import xml.etree.ElementTree as ET
In [2]: tree = ET.parse('http_feeds.feedburner.com_oreilly_radar_atom.xml')

In [3]: root = tree.getroot()

In [4]: root
Out[4]: <Element '{http://www.w3.org/2005/Atom}feed' at 0x11292c958>

你可以通过迭代子节点来遍历树:

In [5]: for child in root:
   ...:     print(child.tag, child.attrib)
   ...:
{http://www.w3.org/2005/Atom}title {}
{http://www.w3.org/2005/Atom}id {}
{http://www.w3.org/2005/Atom}updated {}
{http://www.w3.org/2005/Atom}subtitle {}
{http://www.w3.org/2005/Atom}link {'href': 'https://www.oreilly.com'}
{http://www.w3.org/2005/Atom}link {'rel': 'hub',
                                   'href': 'http://pubsubhubbub.appspot.com/'}
{http://www.w3.org/2003/01/geo/wgs84_pos#}long {}
{http://rssnamespace.org/feedburner/ext/1.0}emailServiceId {}
...

XML 允许进行 命名空间(使用标签分组数据)。XML 在标签前加上用括号括起来的命名空间。如果你知道层次结构的结构,可以使用路径搜索元素。你可以提供一个定义命名空间的字典,方便使用:

In [108]: ns = {'default':'http://www.w3.org/2005/Atom'}
In [106]: authors = root.findall("default:entry/default:author/default:name", ns)

In [107]: for author in authors:
     ...:     print(author.text)
     ...:
Nat Torkington
VM Brasseur
Adam Jacob
Roger Magoulas
Pete Skomoroch
Adrian Cockcroft
Ben Lorica
Nat Torkington
Alison McCauley
Tiffani Bell
Arun Gupta

你可能会遇到需要处理逗号分隔值(CSV)格式的数据。这个格式常用于电子表格数据。你可以使用 Python 的 csv 模块轻松读取这些数据:

In [16]: import csv
In [17]: file_path = '/Users/kbehrman/Downloads/registered_user_count_ytd.csv'

In [18]: with open(file_path, newline='') as csv_file:
    ...:     off_reader = csv.reader(csv_file, delimiter=',')
    ...:     for _ in range(5):
    ...:         print(next(off_reader))
    ...:
['Date', 'PreviousUserCount', 'UserCountTotal', 'UserCountDay']
['2014-01-02', '61', '5336', '5275']
['2014-01-03', '42', '5378', '5336']
['2014-01-04', '26', '5404', '5378']
['2014-01-05', '65', '5469', '5404']

csv 读取器对象逐行遍历 .csv 文件,让你可以逐行处理数据。以这种方式处理文件对于不希望一次性读取到内存的大 .csv 文件特别有用。当然,如果你需要进行跨列的多行计算且文件不太大,你应该一次性加载所有数据。

Pandas 包是数据科学界的主要工具。它包括一个数据结构,pandas.DataFrame,它的作用类似于非常强大的电子表格。如果您有类似表格的数据,想要进行统计分析或者按行和列进行操作,DataFrame 是您的工具。它是一个第三方库,因此您需要使用pip安装它。您可以使用各种方法将数据加载到 DataFrame 中;其中最常见的方法之一是从.csv文件中加载:

In [54]: import pandas as pd

In [55]: df = pd.read_csv('sample-data.csv')

In [56]: type(df)
Out[56]: pandas.core.frame.DataFrame

您可以使用head方法查看 DataFrame 的前几行:

In [57]: df.head(3)
Out[57]:
   Attributes     open       high        low      close     volume
0     Symbols        F          F          F          F          F
1        date      NaN        NaN        NaN        NaN        NaN
2  2018-01-02  11.3007    11.4271    11.2827    11.4271   20773320

您可以使用describe方法获得统计洞见:

In [58]: df.describe()
Out[58]:
        Attributes    open      high    low     close     volume
count          357     356       356    356       356        356
unique         357     290       288    297       288        356
top     2018-10-18  10.402    8.3363   10.2    9.8111   36298597
freq             1       5         4      3         4          1

或者,您可以使用方括号中的名称查看单列数据:

In [59]: df['close']
Out[59]:
0            F
1          NaN
2      11.4271
3      11.5174
4      11.7159
        ...
352       9.83
353       9.78
354       9.71
355       9.74
356       9.52
Name: close, Length: 357, dtype: object

Pandas 有更多用于分析和操作类似表格的数据的方法,也有许多关于其使用的书籍。如果您需要进行数据分析,那么这是您应该了解的工具。

使用正则表达式搜索文本

Apache HTTP 服务器是一个广泛用于提供 Web 内容的开源 Web 服务器。Web 服务器可以配置为以不同的格式保存日志文件。一个广泛使用的格式是通用日志格式(CLF)。各种日志分析工具都能理解这种格式。以下是此格式的布局:

<IP Address> <Client Id> <User Id> <Time> <Request> <Status> <Size>

下面是这种格式的日志的示例行:

127.0.0.1 - swills [13/Nov/2019:14:43:30 -0800] "GET /assets/234 HTTP/1.0" 200 2326

第一章向您介绍了正则表达式和 Python 的re模块,所以让我们使用它从常见日志格式中提取信息。构建正则表达式的一个技巧是分段进行。这样做可以使您让每个子表达式都能正常工作,而不必调试整个表达式。您可以使用命名组创建正则表达式来从一行中提取 IP 地址:

In [1]: line = '127.0.0.1 - rj [13/Nov/2019:14:43:30] "GET HTTP/1.0" 200'

In [2]: re.search(r'(?P<IP>\d+\.\d+\.\d+\.\d+)', line)
Out[2]: <re.Match object; span=(0, 9), match='127.0.0.1'>

In [3]: m = re.search(r'(?P<IP>\d+\.\d+\.\d+\.\d+)', line)

In [4]: m.group('IP')
Out[4]: '127.0.0.1'

您还可以创建一个正则表达式来获取时间:

In [5]: r = r'\[(?P<Time>\d\d/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})\]'

In [6]: m = re.search(r, line)

In [7]: m.group('Time')
Out[7]: '13/Nov/2019:14:43:30'

您可以像这样获取多个元素:IP、用户、时间和请求:

In [8]:  r = r'(?P<IP>\d+\.\d+\.\d+\.\d+)'

In [9]: r += r' - (?P<User>\w+) '

In [10]: r += r'\[(?P<Time>\d\d/\w{3}/\d{4}:\d{2}:\d{2}:\d{2})\]'

In [11]: r += r' (?P<Request>".+")'

In [12]:  m = re.search(r, line)

In [13]: m.group('IP')
Out[13]: '127.0.0.1'

In [14]: m.group('User')
Out[14]: 'rj'

In [15]: m.group('Time')
Out[15]: '13/Nov/2019:14:43:30'

In [16]: m.group('Request')
Out[16]: '"GET HTTP/1.0"'

解析日志的单行很有趣,但不是非常有用。但是,您可以使用这个正则表达式作为从整个日志中提取信息的基础。假设您想要提取 2019 年 11 月 8 日发生的所有GET请求的所有 IP 地址。使用前面的表达式,根据您请求的具体情况进行修改:

In [62]: r = r'(?P<IP>\d+\.\d+\.\d+\.\d+)'
In [63]: r += r'- (?P<User>\w+)'
In [64]: r += r'\[(?P<Time>08/Nov/\d{4}:\d{2}:\d{2}:\d{2} [-+]\d{4})\]'
In [65]: r += r' (?P<Request>"GET .+")'

使用finditer方法处理日志,打印匹配行的 IP 地址:

In [66]: matched = re.finditer(r, access_log)

In [67]: for m in matched:
    ...:     print(m.group('IP'))
    ...:
127.0.0.1
342.3.2.33

有很多可以用正则表达式和各种文本做的事情。如果它们不使你畏惧,你会发现它们是处理文本最强大的工具之一。

处理大文件

有时需要处理非常大的文件。如果文件包含可以一次处理一行的数据,则使用 Python 很容易。与其像您到目前为止所做的那样将整个文件加载到内存中,您可以一次读取一行,处理该行,然后继续下一个。Python 的垃圾收集器会自动从内存中删除这些行,释放内存。

注意

Python 会自动分配和释放内存。垃圾收集是一种方法。Python 的垃圾收集器可以使用gc包进行控制,尽管这很少需要。

操作系统使用替代行结束符时,读取在不同操作系统上创建的文件可能会很麻烦。Windows 创建的文件除了\n外还有\r字符。这些字符在 Linux 系统中显示为文本的一部分。如果您有一个大文件,并且希望校正行结束符以适应当前操作系统,您可以打开文件,一次读取一行,并将其保存到新文件中。Python 会为您处理行结束符的转换:

In [23]: with open('big-data.txt', 'r') as source_file:
    ...:     with open('big-data-corrected.txt', 'w') as target_file:
    ...:         for line in source_file:
    ...:             target_file.write(line)
    ...:

注意,您可以嵌套with语句以同时打开两个文件,并逐行处理源文件对象。如果需要一次处理多个文件的单行,可以定义生成器函数来处理:

In [46]: def line_reader(file_path):
    ...:     with open(file_path, 'r') as source_file:
    ...:         for line in source_file:
    ...:             yield line
    ...:

In [47]: reader = line_reader('big-data.txt')

In [48]: with open('big-data-corrected.txt', 'w') as target_file:
    ...:     for line in reader:
    ...:         target_file.write(line)
    ...:

如果您不使用行结束符来分隔数据,如大型二进制文件的情况,则可以按块读取数据。您将每个块中读取的字节数传递给文件对象的read方法。当没有剩余内容可读时,表达式将返回空字符串:

In [27]: with open('bb141548a754113e.jpg', 'rb') as source_file:
    ...:     while True:
    ...:         chunk = source_file.read(1024)
    ...:         if chunk:
    ...:             process_data(chunk)
    ...:         else:
    ...:             break
    ...:

加密文本

您有许多时候需要加密文本以确保安全性。除了 Python 的内置包hashlib外,还有一个广泛使用的第三方包称为cryptography。让我们来看看它们。

使用 Hashlib 进行哈希处理

为了安全起见,用户密码必须加密存储。处理这一常见方法是使用单向函数将密码加密成位串,这样很难进行逆向工程。执行此操作的函数称为哈希函数。除了遮蔽密码外,哈希函数还确保在传输过程中未更改的文档。您对文档运行哈希函数并将结果与文档一起发送。接收者可以通过对文档进行哈希验证值是否相同。hashlib包括用于执行此操作的安全算法,包括SHA1SHA224SHA384SHA512和 RSA 的MD5。这是使用 MD5 算法对密码进行哈希的方法:

In [62]: import hashlib

In [63]: secret = "This is the password or document text"

In [64]: bsecret = secret.encode()

In [65]: m = hashlib.md5()

In [66]: m.update(bsecret)

In [67]: m.digest()
Out[67]: b' \xf5\x06\xe6\xfc\x1c\xbe\x86\xddj\x96C\x10\x0f5E'

注意,如果您的密码或文档是字符串,则需要使用encode方法将其转换为二进制字符串。

使用密码学进行加密

cryptography库是 Python 中处理加密问题的热门选择。它是一个第三方包,所以你必须使用pip安装它。对称密钥加密是一组基于共享密钥的加密算法。这些算法包括高级加密标准(AES)、Blowfish、数据加密标准(DES)、Serpent 和 Twofish。共享密钥类似于用于加密和解密文本的密码。与稍后我们将讨论的非对称密钥加密相比,创建者和读者都需要共享密钥这一事实是其缺点。然而,对称密钥加密更快更简单,因此适合加密大文件。Fernet 是流行 AES 算法的实现。你首先需要生成一个密钥:

In [1]: from cryptography.fernet import Fernet

In [2]: key = Fernet.generate_key()

In [3]: key
Out[3]: b'q-fEOs2JIRINDR8toMG7zhQvVhvf5BRPx3mj5Atk5B8='

你需要安全存储这个密钥,因为你需要它来解密。请记住,任何有权访问它的人也能解密你的文件。如果选择将密钥保存到文件中,请使用二进制数据类型。下一步是使用Fernet对象加密数据:

In [4]: f = Fernet(key)

In [5]: message = b"Secrets go here"

In [6]: encrypted = f.encrypt(message)

In [7]: encrypted
Out[7]: b'gAAAAABdPyg4 ... plhkpVkC8ezOHaOLIA=='

可以使用使用相同密钥创建的Fernet对象解密数据:

In [1]: f = Fernet(key)

In [2]: f.decrypt(encrypted)
Out[2]: b'Secrets go here'

非对称加密使用一对密钥,一个是公钥,一个是私钥。公钥设计为广泛共享,而单个用户持有私钥。只有使用私钥才能解密使用你的公钥加密的消息。这种加密方式被广泛用于在本地网络和互联网上保密传递信息。一个非常流行的非对称密钥算法是 Rivest-Shamir-Adleman(RSA),它被广泛用于网络通信。加密库提供了创建公钥/私钥对的能力:

In [1]: from cryptography.hazmat.backends import default_backend

In [2]: from cryptography.hazmat.primitives.asymmetric import rsa

In [3]: private_key = rsa.generate_private_key(public_exponent=65537,
                                               key_size=4096,
                                               backend=default_backend())

In [4]: private_key
Out[4]: <cryptography.hazmat.backends.openssl.rsa._RSAPrivateKey at 0x10d377c18>

In [5]: public_key = private_key.public_key

In [6]: public_key = private_key.public_key()

In [7]: public_key
Out[7]: <cryptography.hazmat.backends.openssl.rsa._RSAPublicKey at 0x10da642b0>

然后可以使用公钥进行加密:

In [8]: message = b"More secrets go here"

In [9]: from cryptography.hazmat.primitives.asymmetric import padding
In [11]: from cryptography.hazmat.primitives import hashes

In [12]: encrypted = public_key.encrypt(message,
    ...:    padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
    ...:    algorithm=hashes.SHA256(),
    ...:    label=None))

可以使用私钥解密消息:

In [13]: decrypted = private_key.decrypt(encrypted,
    ...:    padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
    ...:    algorithm=hashes.SHA256(),
    ...:    label=None))

In [14]: decrypted
Out[14]: b'More secrets go here'

os 模块

os模块是 Python 中最常用的模块之一。这个模块处理许多低级操作系统调用,并尝试在多个操作系统之间提供一致的接口,如果你的应用程序可能在 Windows 和 Unix-based 系统上运行,这点非常重要。它确实提供了一些特定于操作系统的特性(在 Windows 上为os.O_TEXT,在 Linux 上为os.O_CLOEXEC),这些特性跨平台不可用。只有在确信你的应用程序不需要在操作系统之间可移植时才使用这些特性。示例 2-1 展示了os模块中一些最有用的附加方法。

示例 2-1. 更多 os 方法
In [1]: os.listdir('.') ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
Out[1]: ['__init__.py', 'os_path_example.py']

In [2]: os.rename('_crud_handler', 'crud_handler') ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)

In [3]: os.chmod('my_script.py', 0o777) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)

In [4]: os.mkdir('/tmp/holding') ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

In [5]: os.makedirs('/Users/kbehrman/tmp/scripts/devops') ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)

In [6]: os.remove('my_script.py') ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)

In [7]: os.rmdir('/tmp/holding') ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)

In [8]: os.removedirs('/Users/kbehrman/tmp/scripts/devops') ![8](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/8.png)

In [9]: os.stat('crud_handler') ![9](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/9.png)
Out[9]: os.stat_result(st_mode=16877,
                       st_ino=4359290300,
                       st_dev=16777220,
                       st_nlink=18,
                       st_uid=501,
                       st_gid=20,
                       st_size=576,
                       st_atime=1544115987,
                       st_mtime=1541955837,
                       st_ctime=1567266289)

1

列出目录的内容。

2

重命名文件或目录。

3

更改文件或目录的权限设置。

4

创建一个目录。

5

递归创建目录路径。

6

删除文件。

7

删除单个目录。

8

删除一棵目录树,从叶子目录开始向上遍历树。该操作会在遇到第一个非空目录时停止。

9

获取文件或目录的统计信息。这些统计信息包括 st_mode,文件类型和权限,以及 st_atime,项目上次访问的时间。

使用 os.path 管理文件和目录

在 Python 中,您可以使用字符串(无论是二进制还是其他)来表示路径。os.path 模块提供了大量用于创建和操作路径的方法。如前所述,os 模块尝试提供跨平台行为,os.path 子模块也不例外。该模块根据当前操作系统解释路径,Unix-like 系统中使用斜杠分隔目录,Windows 中使用反斜杠。您的程序可以动态构造适合当前系统的路径。轻松拆分和连接路径可能是 os.path 最常用的功能之一。用于拆分路径的三个方法是 splitbasenamedirname

In [1]: import os

In [2]: cur_dir = os.getcwd() ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)

In [3]: cur_dir
Out[3]: '/Users/kbehrman/Google-Drive/projects/python-devops/samples/chapter4'

In [4]: os.path.split(cur_dir) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
Out[4]: ('/Users/kbehrman/Google-Drive/projects/python-devops/samples',
         'chapter4')

In [5]: os.path.dirname(cur_dir) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
Out[5]: '/Users/kbehrman/Google-Drive/projects/python-devops/samples'

In [6]: os.path.basename(cur_dir) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)
Out[6]: 'chapter4'

1

获取当前工作目录。

2

os.path.split 将路径的叶级与父路径分开。

3

os.path.dirname 返回父路径。

4

os.path.basename 返回叶子名称。

您可以轻松使用 os.path.dirname 来向上遍历目录树:

In [7]: while os.path.basename(cur_dir):
   ...:     cur_dir = os.path.dirname(cur_dir)
   ...:     print(cur_dir)
   ...:
/Users/kbehrman/projects/python-devops/samples
/Users/kbehrman/projects/python-devops
/Users/kbehrman/projects
/Users/kbehrman
/Users
/

在运行时使用文件配置应用程序是常见的做法;Unix-like 系统中的文件通常按照以rc结尾的约定命名。Vim 的.vimrc文件和 Bash shell 的.bashrc是两个常见的示例。您可以将这些文件存储在不同的位置。通常,程序会定义一个层次结构来检查这些位置。例如,您的工具可能首先查找一个定义了要使用的rc文件的环境变量,如果没有找到,则检查工作目录,然后是用户主目录。在示例 2-2 中,我们尝试在这些位置中找到一个rc文件。我们使用 Python 自动设置的*file*变量,该变量是相对于当前工作目录的路径,而不是绝对路径或完整路径。Python 不会自动展开路径,这在类 Unix 系统中很常见,因此在使用它构建要检查的rc文件路径之前,我们必须将此路径展开。同样,Python 不会自动展开路径中的环境变量,因此我们必须显式展开这些变量。

示例 2-2. find_rc 方法
def find_rc(rc_name=".examplerc"):

    # Check for Env variable
    var_name = "EXAMPLERC_DIR"
    if var_name in os.environ: ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
        var_path = os.path.join(f"${var_name}", rc_name) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
        config_path = os.path.expandvars(var_path) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
        print(f"Checking {config_path}")
        if os.path.exists(config_path): ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)
            return config_path

    # Check the current working directory
    config_path = os.path.join(os.getcwd(), rc_name)  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
    print(f"Checking {config_path}")
    if os.path.exists(config_path):
        return config_path

    # Check user home directory
    home_dir = os.path.expanduser("~/")  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
    config_path = os.path.join(home_dir, rc_name)
    print(f"Checking {config_path}")
    if os.path.exists(config_path):
        return config_path

    # Check Directory of This File
    file_path = os.path.abspath(__file__) ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)
    parent_path = os.path.dirname(file_path) ![8](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/8.png)
    config_path = os.path.join(parent_path, rc_name)
    print(f"Checking {config_path}")
    if os.path.exists(config_path):
        return config_path

    print(f"File {rc_name} has not been found")

1

检查当前环境中是否存在环境变量。

2

使用join结合环境变量名构建路径。这将看起来像是$EXAMPLERC_DIR/.examplerc

3

展开环境变量以将其值插入路径中。

4

检查文件是否存在。

5

使用当前工作目录构建路径。

6

使用expanduser函数获取用户主目录的路径。

7

将存储在*file*中的相对路径扩展为绝对路径。

8

使用dirname获取当前文件所在目录的路径。

path子模块还提供了查询路径统计信息的方法。您可以确定路径是文件、目录、链接还是挂载点。您可以获取大小、最后访问时间或修改时间等统计信息。在示例 2-3 中,我们使用path遍历目录树,并报告其中所有文件的大小和最后访问时间。

示例 2-3. os_path_walk.py
#!/usr/bin/env python

import fire
import os

def walk_path(parent_path):
    print(f"Checking: {parent_path}")
    childs = os.listdir(parent_path) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)

    for child in childs:
        child_path = os.path.join(parent_path, child) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
        if os.path.isfile(child_path): ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
            last_access = os.path.getatime(child_path) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)
            size = os.path.getsize(child_path) ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
            print(f"File: {child_path}")
            print(f"\tlast accessed: {last_access}")
            print(f"\tsize: {size}")
        elif os.path.isdir(child_path): ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
            walk_path(child_path) ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)

if __name__ == '__main__':
    fire.Fire()

1

os.listdir返回目录的内容。

2

构建父目录中项目的完整路径。

3

检查路径是否表示一个文件。

4

获取文件上次访问的时间。

5

获取文件的大小。

6

检查路径是否表示目录。

7

从此目录向下检查树。

您可以使用类似于此的脚本来识别大文件或未访问的文件,然后报告、移动或删除它们。

使用 os.walk 遍历目录树

os 模块提供了一个便捷的函数用于遍历目录树,称为 os.walk。该函数返回一个生成器,依次返回每个迭代的元组。该元组包括当前路径、目录列表和文件列表。在 示例 2-4 中,我们重新编写了来自 示例 2-3 的 walk_path 函数,以使用 os.walk。正如您在此示例中看到的那样,使用 os.walk,您无需测试哪些路径是文件,也无需在每个子目录中重新调用函数。

示例 2-4. 重写 walk_path
def walk_path(parent_path):
    for parent_path, directories, files in os.walk(parent_path):
        print(f"Checking: {parent_path}")
        for file_name in files:
            file_path = os.path.join(parent_path, file_name)
            last_access = os.path.getatime(file_path)
            size = os.path.getsize(file_path)
            print(f"File: {file_path}")
            print(f"\tlast accessed: {last_access}")
            print(f"\tsize: {size}")

使用 Pathlib 作为对象的路径

pathlib 库将路径表示为对象而不是字符串。在 示例 2-5 中,我们使用 pathlib 而不是 os.path 重写了 示例 2-2。

示例 2-5. 重写 find_rc
def find_rc(rc_name=".examplerc"):

    # Check for Env variable
    var_name = "EXAMPLERC_DIR"
    example_dir = os.environ.get(var_name) ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
    if example_dir:
        dir_path = pathlib.Path(example_dir) ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
        config_path = dir_path / rc_name ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
        print(f"Checking {config_path}")
        if config_path.exists(): ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)
            return config_path.as_postix() ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)

    # Check the current working directory
    config_path = pathlib.Path.cwd() / rc_name ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
    print(f"Checking {config_path}")
    if config_path.exists():
        return config_path.as_postix()

    # Check user home directory
    config_path = pathlib.Path.home() / rc_name ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)
    print(f"Checking {config_path}")
    if config_path.exists():
        return config_path.as_postix()

    # Check Directory of This File
    file_path = pathlib.Path(__file__).resolve() ![8](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/8.png)
    parent_path = file_path.parent ![9](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/9.png)
    config_path = parent_path / rc_name
    print(f"Checking {config_path}")
    if config_path.exists():
        return config_path.as_postix()

    print(f"File {rc_name} has not been found")

1

在撰写本文时,pathlib 不会展开环境变量。相反,您可以从 os.environ 中获取变量的值。

2

这将创建适用于当前运行操作系统的 pathlib.Path 对象。

3

您可以通过在父路径后跟正斜杠和字符串来构建新的 pathlib.Path 对象。

4

pathlib.Path 对象本身具有 exists 方法。

5

调用 as_postix 以将路径作为字符串返回。根据您的用例,您可以返回 pathlib.Path 对象本身。

6

类方法 pathlib.Path.cwd 返回当前工作目录的 pathlib.Path 对象。此对象在此处立即用于通过与字符串 rc_name 连接来创建 config_path

7

类方法 pathlib.Path.home 返回当前用户的主目录的 pathlib.Path 对象。

8

使用存储在 *file* 中的相对路径创建 pathlib.Path 对象,然后调用其 resolve 方法以获取绝对路径。

9

这将直接从对象本身返回一个父级pathlib.Path对象。

第三章:使用命令行

命令行是实际操作的地方。尽管有许多强大的图形界面工具,但命令行仍然是 DevOps 工作的主场。从 Python 中与你的 shell 环境交互,并创建 Python 命令行工具,在使用 Python 进行 DevOps 时都是必要的。

使用 Shell

Python 提供了与系统和 shell 交互的工具。你应该熟悉 sysossubprocess 模块,因为它们都是必不可少的工具。

使用 sys 模块与解释器交互

sys 模块提供了访问与 Python 解释器紧密相关的变量和方法。

注意

在读取时有两种主流的字节解释方式。第一种 little endian,将每个后续字节解释为具有更高重要性(表示更大数字)。另一种 big endian,假设第一个字节具有最大重要性,并从那里开始移动。

你可以使用 sys.byteorder 属性来查看当前架构的字节顺序:

In [1]: import sys

In [2]: sys.byteorder
Out[2]: 'little'

你可以使用 sys.getsizeof 来查看 Python 对象的大小。如果你处理有限内存,这将非常有用:

In [3]: sys.getsizeof(1)
Out[3]: 28

如果你想要根据底层操作系统执行不同的行为,可以使用 sys.platform 进行检查:

In [5]: sys.platform
Out[5]: 'darwin'

更常见的情况是你想要使用仅在特定版本的 Python 中可用的语言特性或模块。你可以使用 sys.version_info 来根据正在运行的 Python 解释器控制行为。在这里,我们为 Python 3.7 打印不同的消息,低于 3.7 的 Python 版本,以及低于 3 的 Python 版本:

if sys.version_info.major < 3:
    print("You need to update your Python version")
elif sys.version_info.minor < 7:
    print("You are not running the latest version of Python")
else:
    print("All is good.")

当我们编写命令行工具时,我们稍后会更多地涵盖 sys 的使用。

使用 os 模块处理操作系统

你在 第二章 中看到了 os 模块用于处理文件系统。它还有一堆与操作系统相关的各种属性和函数。在 示例 3-1 中,我们展示了其中一些。

示例 3-1. os 模块示例
In [1]: import os

In [2]: os.getcwd() ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
Out[2]: '/Users/kbehrman/Google-Drive/projects/python-devops'

In [3]: os.chdir('/tmp') ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)

In [4]: os.getcwd()
Out[4]: '/private/tmp'

In [5]: os.environ.get('LOGLEVEL') ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)

In [6]: os.environ['LOGLEVEL'] = 'DEBUG' ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

In [7]: os.environ.get('LOGLEVEL')
Out[7]: 'DEBUG'

In [8]: os.getlogin() ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
Out[8]: 'kbehrman'

1

获取当前工作目录。

2

更改当前工作目录。

3

os.environ 保存了加载 os 模块时设置的环境变量。

4

这是设置和环境变量。该设置适用于从此代码生成的子进程。

5

这是在终端中生成此进程的用户登录信息。

os 模块的最常见用法是从环境变量中获取设置。这些设置可能是设置日志级别或诸如 API 密钥之类的秘密。

使用 subprocess 模块生成进程

在许多情况下,你需要从你的 Python 代码中运行应用程序外部的应用程序。这可能是内置的 shell 命令、Bash 脚本或任何其他命令行应用程序。为此,你可以生成一个新的进程(应用程序的实例)。当你想在 Python 中生成进程并在其中运行命令时,subprocess 模块是正确的选择。使用subprocess,你可以在 Python 中运行你喜欢的 shell 命令或其他命令行软件,并从中收集其输出。对于大多数用例,你应该使用subprocess.run函数来生成进程:

In [1]: cp = subprocess.run(['ls','-l'],
                            capture_output=True,
                            universal_newlines=True)

In [2]: cp.stdout
Out[2]: 'total 96
         -rw-r--r--  1 kbehrman  staff     0 Apr 12 08:48 __init__.py
         drwxr-xr-x  5 kbehrman  staff   160 Aug 18 15:47 __pycache__
         -rw-r--r--  1 kbehrman  staff   123 Aug 13 12:13 always_say_it.py
         -rwxr-xr-x  1 kbehrman  staff  1409 Aug  8 15:36 argparse_example.py
         -rwxr-xr-x  1 kbehrman  staff   734 Aug 12 09:36 click_example.py
         -rwxr-xr-x  1 kbehrman  staff   538 Aug 13 10:41 fire_example.py
         -rw-r--r--  1 kbehrman  staff    41 Aug 18 15:17 foo_plugin_a.py
         -rw-r--r--  1 kbehrman  staff    41 Aug 18 15:47 foo_plugin_b.py
         -rwxr-xr-x  1 kbehrman  staff   335 Aug 10 12:36 simple_click.py
         -rwxr-xr-x  1 kbehrman  staff   256 Aug 13 09:21 simple_fire.py
         -rwxr-xr-x  1 kbehrman  staff   509 Aug  8 10:27 simple_parse.py
         -rwxr-xr-x  1 kbehrman  staff   502 Aug 18 15:11 simple_plugins.py
         -rwxr-xr-x  1 kbehrman  staff   850 Aug  6 14:44 sys_argv.py
         -rw-r--r--  1 kbehrman  staff   182 Aug 18 16:24 sys_example.py
'

subprocess.run函数在进程完成后返回CompletedProcess实例。在这种情况下,我们使用参数-l运行ls shell 命令来查看当前目录的内容。我们设置它来捕获stdoutstderr的输出,然后使用cp.stdout访问结果。如果我们在不存在的目录上运行我们的ls命令,导致返回错误,我们可以在cp.stderr中看到输出:

In [3]: cp = subprocess.run(['ls','/doesnotexist'],
                            capture_output=True,
                            universal_newlines=True)

In [3]: cp.stderr
Out[3]: 'ls: /doesnotexist: No such file or directory\n'

通过使用check参数,你可以更好地集成错误处理。如果子进程报告错误,这将引发异常:

In [23]: cp = subprocess.run(['ls', '/doesnotexist'],
                             capture_output=True,
                             universal_newlines=True,
                             check=True)
---------------------------------------------------------------------------
CalledProcessError                        Traceback (most recent call last)
<ipython-input-23-c0ac49c40fee> in <module>
----> 1 cp = subprocess.run(['ls', '/doesnotexist'],
                            capture_output=True,
                            universal_newlines=True,
                            check=True)

~/.pyenv/versions/3.7.0/lib/python3.7/subprocess.py ...
    466         if check and retcode:
    467             raise CalledProcessError(retcode, process.args,
--> 468                                      output=stdout, stderr=stderr)
    469     return CompletedProcess(process.args, retcode, stdout, stderr)
    470

CalledProcessError: Command '['ls', '/doesnotexist']' returned non-zero exit

这样,你就不必检查stderr以查找失败。你可以像处理其他 Python 异常一样处理来自子进程的错误。

创建命令行工具

在命令行上调用 Python 脚本的最简单方法是使用 Python 调用它。当你构建一个 Python 脚本时,顶级语句(不嵌套在代码块中)会在脚本被调用或导入时运行。如果你有一个希望在代码加载时运行的函数,你可以在顶级调用它:

def say_it():
    greeting = 'Hello'
    target = 'Joe'
    message = f'{greeting} {target}'
    print(message)

say_it()

此函数在脚本在命令行上运行时运行:

$ python always_say_it.py

Hello Joe

此外,当文件被导入时:

In [1]: import always_say_it
Hello Joe

这种方法只适用于最简单的脚本。然而,这种方法的一个显著缺点是,如果你想将你的模块导入到其他 Python 模块中,代码会在导入时运行,而不是等待调用模块。导入你的模块的人通常希望控制其内容在何时被调用。你可以通过使用全局变量*name*来添加仅在从命令行调用时发生的功能。你已经看到,这个变量在导入时报告模块的名称。如果模块直接在命令行上调用,它将被设置为字符串**main**。命令行上运行的模块的约定是在最后一个块中测试这一点,并从该块运行特定于命令行的代码。要修改脚本,仅在命令行调用时自动运行函数,而不在导入时运行,请将函数调用放入测试之后的块中:

def say_it():
    greeting = 'Hello'
    target = 'Joe'
    message = f'{greeting} {target}'
    print(message)

if __name__ == '__main__':
    say_it()

当你导入此函数时,此块不会运行,因为__name__变量反映了导入时的模块路径。然而,当直接运行模块时,它会运行:

$ python say_it.py

Hello Joe

创建命令行工具的第一步是分离只有在命令行调用时才应运行的代码。下一步是接受命令行参数。除非您的工具只做一件事,否则需要接受命令以知道该做什么。此外,执行更复杂任务的命令行工具接受用于配置其工作方式的可选标志。请记住,这些命令和标志是任何使用您工具的人的用户界面(UI)。您需要考虑它们的易用性和可理解性。提供文档是使您的代码易于理解的重要部分。

使用 sys.argv

处理命令行参数的最简单和最基本的方法是使用 sys 模块的 argv 属性。该属性是在运行时传递给 Python 脚本的参数列表。如果脚本在命令行上运行,第一个参数是脚本的名称。列表中的其余项目是剩余的命令行参数,表示为字符串:

#!/usr/bin/env python
"""
Simple command-line tool using sys.argv
"""
import sys

if __name__ == '__main__':
    print(f"The first argument:  '{sys.argv[0]}'")
    print(f"The second argument: '{sys.argv[1]}'")
    print(f"The third argument:  '{sys.argv[2]}'")
    print(f"The fourth argument: '{sys.argv[3]}'")

在命令行上运行并查看参数:

$ ./sys_argv.py --a-flag some-value 13

The first argument:  './sys_argv.py'
The second argument: '--a-flag'
The third argument:  'some-value'
The fourth argument: '13'

您可以使用这些参数来编写自己的参数解析器。要查看这可能是什么样子,请查看 示例 3-2。

示例 3-2. 使用 sys.argv 进行解析
#!/usr/bin/env python
"""
Simple command-line tool using sys.argv
"""
import sys

def say_it(greeting, target):
    message = f'{greeting} {target}'
    print(message)

if __name__ == '__main__': ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
    greeting = 'Hello'  ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
    target = 'Joe'

    if '--help' in sys.argv:  ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
        help_message = f"Usage: {sys.argv[0]} --name <NAME> --greeting <GREETING>"
        print(help_message)
        sys.exit()  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

    if '--name' in sys.argv:
        # Get position after name flag
        name_index = sys.argv.index('--name') + 1 ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
        if name_index < len(sys.argv): ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
            name = sys.argv[name_index]

    if '--greeting' in sys.argv:
        # Get position after greeting flag
        greeting_index = sys.argv.index('--greeting') + 1
        if greeting_index < len(sys.argv):
            greeting = sys.argv[greeting_index]

    say_it(greeting, name) ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)

1

在这里,我们测试是否从命令行运行。

2

默认值设置在以下两行。

3

检查字符串 --help 是否在参数列表中。

4

在打印帮助消息后退出程序。

5

我们需要标志后面的值的位置,这应该是关联的值。

6

测试参数列表的长度是否足够。如果提供了标志但没有值,则长度不够。

7

调用函数时,使用参数修改的值。

示例 3-2 已经足够打印出一个简单的帮助消息并接受函数的参数:

$ ./sys_argv.py --help
Usage: ./sys_argv.py --name <NAME> --greeting <GREETING>

$ ./sys_argv.py --name Sally --greeting Bonjour
Bonjour Sally

这种方法充满了复杂性和潜在的错误。 示例 3-2 无法处理许多情况。 如果用户拼错或者大小写不正确的标志,标志将被忽略且没有有用的反馈。 如果他们使用不受支持的命令或尝试使用多个值与标志,则再次忽略错误。 您应该了解argv解析方法,但除非您专门编写参数解析器,否则不要在任何生产代码中使用它。 幸运的是,有为创建命令行工具设计用户界面的模块和包。 这些包提供了在 shell 中运行时设计模块用户界面的框架。 三种流行的解决方案是argparseclickpython-fire。 这三者都包括设计必需参数,可选标志和显示帮助文档的方法。 第一个argparse是 Python 标准库的一部分,其他两个是需要单独安装的第三方包(使用pip安装)。

使用 argparse

argparse抽象了解析参数的许多细节。 使用它,您可以详细设计命令行用户界面,定义命令和标志以及它们的帮助消息。 它使用解析器对象的概念,您可以将命令和标志附加到其中。 然后解析器解析参数,您可以使用结果调用您的代码。 您使用ArgumentParser对象构建您的接口,这些对象为您解析用户输入:

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Maritime control')

您可以使用add_argument方法向解析器添加基于位置的命令或可选标志(参见示例 3-3)。 此方法的第一个参数是新参数(命令或标志)的名称。 如果名称以短横线开头,则将其视为可选标志参数; 否则将其视为位置依赖命令。 解析器创建一个解析后的参数对象,该对象具有您可以使用的属性来访问输入。 示例 3-3 是一个简单的程序,用于回显用户输入并展示argparse的基本工作原理。

示例 3-3. simple_parse.py
#!/usr/bin/env python
"""
Command-line tool using argparse
"""
import argparse

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Echo your input') ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
    parser.add_argument('message',               ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
                        help='Message to echo')

    parser.add_argument('--twice', '-t',         ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
                        help='Do it twice',
                        action='store_true')     ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

    args = parser.parse_args()  ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)

    print(args.message)    ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
    if args.twice:
        print(args.message)

1

创建解析器对象,并附上其文档消息。

2

添加一个基于位置的命令及其帮助消息。

3

添加一个可选参数。

4

将可选参数存储为布尔值。

5

使用解析器解析参数。

6

按名称访问参数值。 可选参数的名称已删除--

当您使用--twice标志运行它时,输入消息会打印两次:

$ ./simple_parse.py hello --twice
hello
hello

argparse根据您提供的帮助和描述文本自动设置帮助和使用消息:

$ ./simple_parse.py  --help
usage: simple_parse.py [-h] [--twice] message

Echo your input

positional arguments:
  message      Message to echo

optional arguments:
  -h, --help   show this help message and exit
  --twice, -t  Do it twice

许多命令行工具使用嵌套的命令级别来分组控制命令区域。想想 git。它有顶层命令,比如 git stash,下面有单独的命令,比如 git stash pop。使用 argparse,你可以通过在主解析器下创建子解析器来创建子命令。你可以使用子解析器创建命令的层次结构。在 示例 3-4 中,我们实现了一个海事应用程序,其中有用于船只和水手的命令。我们向主解析器添加了两个子解析器;每个子解析器都有自己的命令。

示例 3-4\. argparse_example.py
#!/usr/bin/env python
"""
Command-line tool using argparse
"""
import argparse

def sail():
    ship_name = 'Your ship'
    print(f"{ship_name} is setting sail")

def list_ships():
    ships = ['John B', 'Yankee Clipper', 'Pequod']
    print(f"Ships: {','.join(ships)}")

def greet(greeting, name):
    message = f'{greeting} {name}'
    print(message)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Maritime control') ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)

    parser.add_argument('--twice', '-t',   ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
                        help='Do it twice',
                        action='store_true')

    subparsers = parser.add_subparsers(dest='func') ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)

    ship_parser =  subparsers.add_parser('ships',  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)
                                         help='Ship related commands')
    ship_parser.add_argument('command', ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
                             choices=['list', 'sail'])

    sailor_parser = subparsers.add_parser('sailors', ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
                                          help='Talk to a sailor')
    sailor_parser.add_argument('name', ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)
                               help='Sailors name')
    sailor_parser.add_argument('--greeting', '-g',
                               help='Greeting',
                               default='Ahoy there')

    args = parser.parse_args()
    if args.func == 'sailors': ![8](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/8.png)
        greet(args.greeting, args.name)
    elif args.command == 'list':
        list_ships()
    else:
        sail()

1

创建顶层解析器。

2

添加一个顶级参数,可以与这个解析器层次结构下的任何命令一起使用。

3

创建一个子解析器对象来保存子解析器。dest 是选择子解析器时使用的属性名称。

4

ships 添加一个子解析器。

5

ships 子解析器添加一个命令。choices 参数提供了命令的可能选择列表。

6

sailors 添加一个子解析器。

7

sailors 子解析器添加一个必需的位置参数。

8

通过检查 func 值来检查使用了哪个子解析器。

示例 3-4 有一个顶级可选参数 (twice) 和两个子解析器。每个子解析器都有自己的命令和标志。argparse 自动创建一个帮助消息的层次结构,并使用 --help 标志显示它们。顶级帮助命令包括子解析器和顶级 twice 参数都有文档记录:

$ ./argparse_example.py --help
usage: argparse_example.py [-h] [--twice] {ships,sailors} ...

Maritime control

positional arguments:
  {ships,sailors}
    ships          Ship related commands
    sailors        Talk to a sailor

optional arguments:
  -h, --help       show this help message and exit
  --twice, -t      Do it twice

你可以使用命令后的 help 标志来深入探讨子命令(子解析器)。

$ ./argparse_example.py ships --help
usage: argparse_example.py ships [-h] {list,sail}

positional arguments:
  {list,sail}

optional arguments:
  -h, --help   show this help message and exit

正如你所看到的,argparse 可以很好地控制你的命令行界面。你可以设计一个多层次的界面,并内置文档,有很多选项来微调你的设计。不过,这需要你付出很多工作,所以让我们看一些更简单的选项。

使用 click

click 包最初是为与 web 框架 flask 配合而开发的。它使用 Python 函数装饰器 将命令行接口直接与你的函数绑定在一起。与 argparse 不同,click 直接将你的接口决策与代码的其余部分交织在一起。

这意味着你将你的标志和选项直接与它们公开的函数的参数绑定在一起。你可以使用 click 的 commandoption 函数作为你的函数之前的装饰器,从你的函数中创建一个简单的命令行工具:

#!/usr/bin/env python
"""
Simple Click example
"""
import click

@click.command()
@click.option('--greeting', default='Hiya', help='How do you want to greet?')
@click.option('--name', default='Tammy', help='Who do you want to greet?')
def greet(greeting, name):
    print(f"{greeting} {name}")

if __name__ == '__main__':
    greet()

click.command 表示应将函数公开给命令行访问。click.option 将参数添加到命令行,自动将其链接到同名函数参数(--greeting 对应 greet--name 对应 name)。click 在后台做一些工作,使我们可以在主块中调用我们的 greet 方法,而不需要被 options 装饰器所覆盖的参数。

这些装饰器处理解析命令行参数,并自动生成帮助消息:

$ ./simple_click.py --greeting Privet --name Peggy
Privet Peggy

$ ./simple_click.py --help
Usage: simple_click.py [OPTIONS]

Options:
  --greeting TEXT  How do you want to greet?
  --name TEXT      Who do you want to greet?
  --help           Show this message and exit.

您可以看到,使用 click 可以以比 argparse 更少的代码将函数暴露为命令行使用。您可以集中精力于代码的业务逻辑,而不是设计界面。

现在让我们看一个更复杂的示例,其中包含嵌套命令。使用 click.group 创建表示组的函数来嵌套命令。在 Example 3-5 中,我们使用 argparse 嵌套命令,其接口与 Example 3-4 中的接口非常相似。

示例 3-5. click_example.py
#!/usr/bin/env python
"""
Command-line tool using argparse
"""
import click

@click.group() ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
def cli(): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
    pass

@click.group(help='Ship related commands') ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
def ships():
    pass

cli.add_command(ships) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

@ships.command(help='Sail a ship') ![5](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/5.png)
def sail():
    ship_name = 'Your ship'
    print(f"{ship_name} is setting sail")

@ships.command(help='List all of the ships')
def list_ships():
    ships = ['John B', 'Yankee Clipper', 'Pequod']
    print(f"Ships: {','.join(ships)}")

@cli.command(help='Talk to a sailor')  ![6](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/6.png)
@click.option('--greeting', default='Ahoy there', help='Greeting for sailor')
@click.argument('name')
def sailors(greeting, name):
    message = f'{greeting} {name}'
    print(message)

if __name__ == '__main__':
    cli()  ![7](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/7.png)

1

创建一个顶级组,其他组和命令将驻留在其中。

2

创建一个函数作为顶级组的操作。click.group 方法将该函数转换为一个组。

3

创建一个用于容纳 ships 命令的组。

4

ships 组作为顶级组的一个命令添加。请注意,cli 函数现在是一个具有 add_command 方法的组。

5

ships 组添加一个命令。注意,使用了 ships.command 而不是 click.command

6

cli 组添加一个命令。

7

调用顶级组。

click 生成的顶级帮助消息如下所示:

./click_example.py --help
Usage: click_example.py [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  sailors  Talk to a sailor
  ships    Ship related commands

您可以像这样查看子组的帮助:

$ ./click_example.py ships --help
Usage: click_example.py ships [OPTIONS] COMMAND [ARGS]...

  Ship related commands

Options:
  --help  Show this message and exit.

Commands:
  list-ships  List all of the ships
  sail        Sail a ship

如果您比较 Example 3-4 和 Example 3-5,您会看到使用 argparseclick 的一些区别。在这些示例中,click 方法确实需要的代码量少得多,几乎只有一半。用户界面(UI)代码穿插在整个程序中;这在创建纯粹作为组的函数时尤为重要。如果您有一个复杂的程序和复杂的界面,应尽可能将不同的功能隔离开来。通过这样做,您可以更轻松地测试和调试单独的组件。在这种情况下,您可能会选择使用 argparse 来保持界面代码的独立性。

fire

现在,让我们更进一步,用尽量少的 UI 代码制作一个命令行工具。fire包使用你的代码的内省来自动创建接口。如果你有一个简单的函数想要暴露,可以将其作为参数调用fire.Fire

#!/usr/bin/env python
"""
Simple fire example
"""
import fire

def greet(greeting='Hiya', name='Tammy'):
    print(f"{greeting} {name}")

if __name__ == '__main__':
    fire.Fire(greet)

然后,fire根据方法的名称和参数创建 UI:

$ ./simple_fire.py --help

NAME
    simple_fire.py

SYNOPSIS
    simple_fire.py <flags>

FLAGS
    --greeting=GREETING
    --name=NAME

在简单情况下,通过不带参数调用fire可以自动暴露多个方法:

#!/usr/bin/env python
"""
Simple fire example
"""
import fire

def greet(greeting='Hiya', name='Tammy'):
    print(f"{greeting} {name}")

def goodbye(goodbye='Bye', name='Tammy'):
    print(f"{goodbye} {name}")

if __name__ == '__main__':
    fire.Fire()

fire从每个函数创建命令并自动记录文档:

$ ./simple_fire.py --help
INFO: Showing help with the command 'simple_fire.py -- --help'.

NAME
    simple_fire.py

SYNOPSIS
    simple_fire.py GROUP | COMMAND

GROUPS
    GROUP is one of the following:

     fire
       The Python fire module.

COMMANDS
    COMMAND is one of the following:

     greet

     goodbye
(END)

如果你试图理解别人的代码或调试自己的代码,这真的非常方便。通过额外的一行代码,你就可以与模块的所有函数进行命令行交互。这非常强大。因为fire使用程序本身的结构来确定接口,它甚至比argparseclick更与你的非接口代码紧密相关。要模仿我们的巢状命令接口,你需要定义具有要暴露的接口结构的类。要了解一种方法,请查看示例 3-6。

示例 3-6. fire_example.py
#!/usr/bin/env python
"""
Command-line tool using fire
"""
import fire

class Ships(): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
    def sail(self):
        ship_name = 'Your ship'
        print(f"{ship_name} is setting sail")

    def list(self):
        ships = ['John B', 'Yankee Clipper', 'Pequod']
        print(f"Ships: {','.join(ships)}")

def sailors(greeting, name): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
    message = f'{greeting} {name}'
    print(message)

class Cli(): ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)

    def __init__(self):
        self.sailors = sailors
        self.ships = Ships()

if __name__ == '__main__':
    fire.Fire(Cli) ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

1

为船只命令定义一个类。

2

sailors没有子命令,因此可以定义为一个函数。

3

定义一个充当顶级组的类。将sailors函数和Ships作为类的属性添加。

4

在充当顶层组的类上调用fire.Fire

自动生成的顶层文档代表Ships类作为一个组,sailors命令作为一个命令:

$ ./fire_example.py

NAME
    fire_example.py

SYNOPSIS
    fire_example.py GROUP | COMMAND

GROUPS
    GROUP is one of the following:

     ships

COMMANDS
    COMMAND is one of the following:

     sailors
(END)

展示代表附加到Ships类的方法的命令的ships组文档:

$ ./fire_example.py ships --help
INFO: Showing help with the command 'fire_example.py ships -- --help'.

NAME
    fire_example.py ships

SYNOPSIS
    fire_example.py ships COMMAND

COMMANDS
    COMMAND is one of the following:

     list

     sail
(END)

sailors函数的参数转换为位置参数:

$ ./fire_example.py sailors --help
INFO: Showing help with the command 'fire_example.py sailors -- --help'.

NAME
    fire_example.py sailors

SYNOPSIS
    fire_example.py sailors GREETING NAME

POSITIONAL ARGUMENTS
    GREETING
    NAME

NOTES
    You can also use flags syntax for POSITIONAL ARGUMENTS
(END)

按预期调用命令和子命令:

$ ./fire_example.py ships sail
Your ship is setting sail
chapter3$ ./fire_example.py ships list
Ships: John B,Yankee Clipper,Pequod
chapter3$ ./fire_example.py sailors Hiya Karl
Hiya Karl

fire的一个令人兴奋的特性是能够轻松进入交互模式。通过使用--interactive标志,fire打开一个带有你脚本中对象和函数的 IPython shell:

$ ./fire_example.py sailors Hiya Karl -- --interactive
Hiya Karl
Fire is starting a Python REPL with the following objects:
Modules: fire
Objects: Cli, Ships, component, fire_example.py, result, sailors, self, trace

Python 3.7.0 (default, Sep 23 2018, 09:47:03)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.5.0 -- An enhanced Interactive Python. Type '?' for help.
 ---------------------------------------------------------------------------
In [1]: sailors
Out[1]: <function __main__.sailors(greeting, name)>

In [2]: sailors('hello', 'fred')
hello fred

在这里,我们在交互模式下运行海事程序的sailors命令。一个 IPython shell 打开,你可以访问sailors函数。这种交互模式与使用fire暴露对象的简便性结合使用,使其成为调试和介绍新代码的合适工具。

现在你已经使用命令行工具构建库的全套,从非常实用的argparse,到不那么冗长的click,最后到极简的fire。那么你应该使用哪个?对于大多数用例,我们推荐使用click。它平衡了简易性和控制性。在需要将 UI 代码与业务逻辑分离的复杂界面情况下,argparse是不二之选。此外,如果需要快速访问没有命令行接口的代码,fire适合你。

实现插件

一旦实现了应用程序的命令行用户界面,你可能想考虑一个插件系统。插件是由程序用户提供的代码片段,用于扩展功能。插件系统被用于各种应用程序中,从像 Autodesk 的 Maya 这样的大型应用到像 Flask 这样的最小的 Web 框架。你可以编写一个工具,处理文件系统的遍历,并允许用户提供插件来操作其内容。任何插件系统的关键部分都是插件发现。你的程序需要知道哪些插件是可用的,以加载和运行。

示例 3-7. simple_plugins.py
#!/usr/bin/env python
import fire
import pkgutil
import importlib

def find_and_run_plugins(plugin_prefix):
    plugins = {}

    # Discover and Load Plugins
    print(f"Discovering plugins with prefix: {plugin_prefix}")
    for _, name, _ in  pkgutil.iter_modules(): ![1](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/1.png)
        if name.startswith(plugin_prefix): ![2](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/2.png)
            module = importlib.import_module(name) ![3](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/3.png)
            plugins[name] = module

    # Run Plugins
    for name, module in plugins.items():
        print(f"Running plugin {name}")
        module.run()  ![4](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/4.png)

if __name__ == '__main__':
    fire.Fire()

1

pkgutil.iter_modules 返回当前sys.path中所有可用的模块。

2

检查模块是否使用了我们的插件前缀。

3

使用importlib加载模块,将其保存在后续使用的dict中。

4

调用插件上的run方法。

编写供应用于示例 3-7 的插件就像提供模块一样简单,这些模块的名称使用共享前缀,并且功能通过名为run的方法访问。如果你编写两个文件,使用前缀foo_plugin并且具有各自的运行方法:

def run():
    print("Running plugin A")
def run():
    print("Running plugin B")

你可以通过我们的插件应用程序发现并运行它们:

$ ./simple_plugins.py find_and_run_plugins foo_plugin
Running plugin foo_plugin_a
Running plugin A
Running plugin foo_plugin_b
Running plugin B

你可以轻松扩展这个简单的示例,为你的应用程序创建插件系统。

案例研究:用命令行工具为 Python 添加涡轮增压

现在是编写代码的绝佳时机;一点点代码可以做很多事情。单个函数就能实现令人难以置信的功能。由于 GPU、机器学习、云计算和 Python 的推广,创建“涡轮增压”命令行工具变得轻而易举。将其视为从基本内燃发动机升级到喷气发动机的代码升级基本配方。升级的基本配方是什么?一个函数,一些强大的逻辑,最后,一个装饰器将其路由到命令行。

编写和维护传统的 GUI 应用程序 —— 无论是 Web 还是桌面 —— 都是一项枯燥无味的任务。一切都始于最好的意图,但很快可能变成一个压垮灵魂、耗时紧张的折磨过程,最后你会问自己,为什么最初会认为成为程序员是个好主意呢?为什么你要运行那个基本上将 1970 年代技术 —— 关系数据库 —— 自动化为一系列 Python 文件的 Web 框架设置实用程序呢?老式的 Ford Pinto 汽车尾部爆炸的汽油箱比你的 Web 框架还有新技术。一定有更好的方式来谋生。

答案很简单:停止编写 Web 应用程序,转而编写具有喷气动力的命令行工具。以下各节讨论的增压命令行工具专注于快速结果与最少代码行。它们可以做到像从数据中学习(机器学习)、使你的代码运行快两千倍,最重要的是,生成彩色的终端输出。

这里是将用于制作几个解决方案的原始成分:

  • Click 框架

  • Python CUDA 框架

  • Numba 框架

  • Scikit-learn 机器学习框架

使用 Numba 即时编译器(JIT)

Python 因其本质上是脚本语言而声名狼藉,因其性能慢而著称。解决这个问题的一种方法是使用 Numba 即时编译器(JIT)。让我们看看这段代码是什么样子的。

首先,使用一个定时装饰器来掌握函数的运行时:

def timing(f):
    @wraps(f)
    def wrap(*args, **kwargs):
        ts = time()
        result = f(*args, **kwargs)
        te = time()
        print(f"fun: {f.__name__}, args: [{args}, {kwargs}] took: {te-ts} sec")
        return result
    return wrap

接下来,添加一个 numba.jit 装饰器,使用 nopython 关键字参数并将其设置为 True。这将确保代码将由 JIT 而不是常规 Python 运行。

@timing
@numba.jit(nopython=True)
def expmean_jit(rea):
    """Perform multiple mean calculations"""

    val = rea.mean() ** 2
    return val

当你运行它时,可以看到通过命令行工具同时运行了 jit 和常规版本:

$ python nuclearcli.py jit-test
Running NO JIT
func:'expmean' args:[(array([[1.0000e+00, 4.2080e+05, 2350e+05, ...,
                                  1.0543e+06, 1.0485e+06, 1.0444e+06],
       [2.0000e+00, 5.4240e+05, 5.4670e+05, ...,
              1.5158e+06, 1.5199e+06, 1.5253e+06],
       [3.0000e+00, 7.0900e+04, 7.1200e+04, ...,
              1.1380e+05, 1.1350e+05, 1.1330e+05],
       ...,
       [1.5277e+04, 9.8900e+04, 9.8100e+04, ...,
              2.1980e+05, 2.2000e+05, 2.2040e+05],
       [1.5280e+04, 8.6700e+04, 8.7500e+04, ...,
              1.9070e+05, 1.9230e+05, 1.9360e+05],
       [1.5281e+04, 2.5350e+05, 2.5400e+05, ..., 7.8360e+05, 7.7950e+05,
        7.7420e+05]], dtype=float32),), {}] took: 0.0007 sec
$ python nuclearcli.py jit-test --jit
Running with JIT
func:'expmean_jit' args:[(array([[1.0000e+00, 4.2080e+05, 4.2350e+05, ...,
                                     0543e+06, 1.0485e+06, 1.0444e+06],
       [2.0000e+00, 5.4240e+05, 5.4670e+05, ..., 1.5158e+06, 1.5199e+06,
        1.5253e+06],
       [3.0000e+00, 7.0900e+04, 7.1200e+04, ..., 1.1380e+05, 1.1350e+05,
        1.1330e+05],
       ...,
       [1.5277e+04, 9.8900e+04, 9.8100e+04, ..., 2.1980e+05, 2.2000e+05,
        2.2040e+05],
       [1.5280e+04, 8.6700e+04, 8.7500e+04, ..., 1.9070e+05, 1.9230e+05,
        1.9360e+05],
       [1.5281e+04, 2.5350e+05, 2.5400e+05, ..., 7.8360e+05, 7.7950e+05,
@click.option('--jit/--no-jit', default=False)
        7.7420e+05]], dtype=float32),), {}] took: 0.2180 sec

这是如何工作的?只需几行代码就可以进行这个简单的切换:

@cli.command()
def jit_test(jit):
    rea = real_estate_array()
    if jit:
        click.echo(click.style('Running with JIT', fg='green'))
        expmean_jit(rea)
    else:
        click.echo(click.style('Running NO JIT', fg='red'))
        expmean(rea)

在某些情况下,JIT 版本可以使代码运行速度提高数千倍,但基准测试非常关键。另一个需要指出的是这一行:

click.echo(click.style('Running with JIT', fg='green'))

这个脚本允许生成彩色的终端输出,当创建复杂工具时非常有用。

使用 CUDA Python 在 GPU 上运行

另一种加速代码的方式是直接在 GPU 上运行它。这个示例需要在支持 CUDA 的机器上运行。这是代码的样子:

@cli.command()
def cuda_operation():
    """Performs Vectorized Operations on GPU"""

    x = real_estate_array()
    y = real_estate_array()

    print("Moving calculations to GPU memory")
    x_device = cuda.to_device(x)
    y_device = cuda.to_device(y)
    out_device = cuda.device_array(
        shape=(x_device.shape[0],x_device.shape[1]), dtype=np.float32)
    print(x_device)
    print(x_device.shape)
    print(x_device.dtype)

    print("Calculating on GPU")
    add_ufunc(x_device,y_device, out=out_device)

    out_host = out_device.copy_to_host()
    print(f"Calculations from GPU {out_host}")

需要指出的是,如果先将 Numpy 数组移动到 GPU,然后在 GPU 上执行向量化函数,完成工作后再将数据从 GPU 移回。通过使用 GPU,代码可能会有显著的改进,这取决于它正在运行的内容。命令行工具的输出如下所示:

$ python nuclearcli.py cuda-operation
Moving calculations to GPU memory
<numba.cuda.cudadrv.devicearray.DeviceNDArray object at 0x7f01bf6ccac8>
(10015, 259)
float32
Calculating on GPU
Calculcations from GPU [
 [2.0000e+00 8.4160e+05 8.4700e+05 ... 2.1086e+06 2.0970e+06 2.0888e+06]
 [4.0000e+00 1.0848e+06 1.0934e+06 ... 3.0316e+06 3.0398e+06 3.0506e+06]
 [6.0000e+00 1.4180e+05 1.4240e+05 ... 2.2760e+05 2.2700e+05 2.2660e+05]
 ...
 [3.0554e+04 1.9780e+05 1.9620e+05 ... 4.3960e+05 4.4000e+05 4.4080e+05]
 [3.0560e+04 1.7340e+05 1.7500e+05 ... 3.8140e+05 3.8460e+05 3.8720e+05]
 [3.0562e+04 5.0700e+05 5.0800e+05 ... 1.5672e+06 1.5590e+06 1.5484e+06]
]

使用 Numba 运行真正的多核多线程 Python

Python 的一个常见性能问题是缺乏真正的多线程性能。这也可以通过 Numba 来解决。以下是一些基本操作的示例:

@timing
@numba.jit(parallel=True)
def add_sum_threaded(rea):
    """Use all the cores"""

    x,_ = rea.shape
    total = 0
    for _ in numba.prange(x):
        total += rea.sum()
        print(total)

@timing
def add_sum(rea):
    """traditional for loop"""

    x,_ = rea.shape
    total = 0
    for _ in numba.prange(x):
        total += rea.sum()
        print(total)

@cli.command()
@click.option('--threads/--no-jit', default=False)
def thread_test(threads):
    rea = real_estate_array()
    if threads:
        click.echo(click.style('Running with multicore threads', fg='green'))
        add_sum_threaded(rea)
    else:
        click.echo(click.style('Running NO THREADS', fg='red'))
        add_sum(rea)

注意,并行版本的关键区别在于它使用 @numba.jit(parallel=True)numba.prange 来为迭代生成线程。正如你在图 3-1 中所看到的,机器上的所有 CPU 都被充分利用,但当几乎相同的代码运行时没有并行化,它只使用一个核心。

pydo 0301

图 3-1。使用所有核心
$ python nuclearcli.py thread-test
$ python nuclearcli.py thread-test --threads

KMeans 聚类

另一个可以通过命令行工具完成的强大功能是机器学习。在下面的示例中,仅使用几行代码就创建了一个 KMeans 聚类函数。这将一个 Pandas DataFrame 聚类成默认的三个集群:

def kmeans_cluster_housing(clusters=3):
    """Kmeans cluster a dataframe"""
    url = "https://raw.githubusercontent.com/noahgift/\
 socialpowernba/master/data/nba_2017_att_val_elo_win_housing.csv"
    val_housing_win_df =pd.read_csv(url)
    numerical_df =(
        val_housing_win_df.loc[:,["TOTAL_ATTENDANCE_MILLIONS", "ELO",
        "VALUE_MILLIONS", "MEDIAN_HOME_PRICE_COUNTY_MILLIONS"]]
    )
    #scale data
    scaler = MinMaxScaler()
    scaler.fit(numerical_df)
    scaler.transform(numerical_df)
    #cluster data
    k_means = KMeans(n_clusters=clusters)
    kmeans = k_means.fit(scaler.transform(numerical_df))
    val_housing_win_df['cluster'] = kmeans.labels_
    return val_housing_win_df

通过传入另一个数字(如下所示),可以更改集群数量,使用 click

@cli.command()
@click.option("--num", default=3, help="number of clusters")
def cluster(num):
    df = kmeans_cluster_housing(clusters=num)
    click.echo("Clustered DataFrame")
    click.echo(df.head())

最后,下面显示了带有集群分配的 Pandas DataFrame 的输出。注意它现在有了一个列作为集群分配:

$ python -W nuclearcli.py cluster
Clustered DataFrame
               TEAM  GMS    ...         COUNTY   cluster
0     Chicago Bulls   41    ...           Cook         0
1  Dallas Mavericks   41    ...         Dallas         0
2  Sacramento Kings   41    ...     Sacremento         1
3        Miami Heat   41    ...     Miami-Dade         0
4   Toronto Raptors   41    ...    York-County         0

[5 rows x 12 columns]
$ python -W nuclearcli.py cluster --num 2
Clustered DataFrame
               TEAM  GMS     ...         COUNTY   cluster
0     Chicago Bulls   41     ...           Cook         1
1  Dallas Mavericks   41     ...         Dallas         1
2  Sacramento Kings   41     ...     Sacremento         0
3        Miami Heat   41     ...     Miami-Dade         1
4   Toronto Raptors   41     ...    York-County         1

[5 rows x 12 columns]

练习

  • 使用 sys 编写一个脚本,仅在从命令行运行时才打印命令行

  • 使用 click 创建一个命令行工具,它接受一个名称作为参数,并在名称不以 p 开头时打印它。

  • 使用 fire 从命令行访问现有 Python 脚本中的方法。

第四章:有用的 Linux 实用工具

当他开始职业生涯时,命令行及其工具是 Alfredo 感到亲密的原因之一。在一家中型公司担任系统管理员时,他的第一份工作涉及管理一切与 Linux 相关的事务。小型 IT 部门专注于 Windows 服务器和桌面系统,他们非常不喜欢使用命令行。有一次,IT 管理员告诉他,他了解图形用户界面(GUI)、安装实用工具以及一般工具来解决问题:“我不是程序员,如果没有 GUI,我就无法使用它。”

Alfredo 被聘为承包商,帮助公司管理几台 Linux 服务器。当时,Subversion (SVN) 是版本控制的热门工具,开发人员依赖这一单一的 SVN 服务器来推动他们的工作。与使用中心化身份服务器不同,由两个域控制器提供,它使用了一个基于文本的认证系统,将用户映射到代表密码的哈希值。这意味着用户名不一定映射到域控制器中的用户名,并且密码可以是任意的。通常情况下,开发人员会要求重置密码,然后有人必须编辑这个文本文件来更新哈希值。项目经理要求 Alfredo 将 SVN 的认证整合到域控制器(微软的 Active Directory)中。他第一个问题是为什么 IT 部门之前没有做这件事?“他们说这是不可能的,但是 Alfredo,这是谎言,SVN 可以与 Active Directory 整合。”

他从未使用过像 Active Directory 这样的认证服务,对 SVN 的理解也很浅薄,但他决心要让这个工作成功。Alfredo 开始阅读关于 SVN 和 Active Directory 的所有信息,利用一个运行 SVN 服务器的虚拟机进行尝试,并努力让这种认证机制工作起来。他花了大约两周时间研究所有相关的部分,并最终成功让它投入生产。这感觉非常强大;他掌握了独特的知识,现在完全可以负责这个系统。IT 管理员以及整个部门都非常激动。Alfredo 尝试与他人分享这些新获得的知识,但总是遇到借口:“没时间”,“太忙了”,“其他优先事项”,以及“也许下周吧。”

技术人员的一个恰当描述是:“知识工作者”。你的好奇心和对知识的不断追求将继续提升你以及你所工作的环境。永远不要让同事(或像阿尔弗雷多那样整个 IT 部门)成为改进系统的障碍。如果有学习新东西的机会,一定要抓住!最糟糕的情况是,你获得了也许不经常使用但却可能改变你职业生涯的知识。

Linux 确实有桌面环境,但它真正的力量来自理解和使用命令行,最终,通过扩展它。当没有现成的工具解决问题时,经验丰富的 DevOps 人员将制定自己的解决方案。通过组合核心部件来提出解决方案的这种能力非常强大,最终在那个工作中感到有效率是可以完成任务而不必安装现成软件来修复问题。

本章将介绍 Shell 中的一些常见模式,并包括一些有用的 Python 命令,这些命令应该增强与机器交互的能力。我们发现创建别名和“一行代码”是工作中最有趣的事情之一,有时它们非常有用,最终成为插件或独立的软件。

磁盘工具

系统中有几种不同的工具可用于获取设备信息。其中许多具有功能重叠,一些具有交互式会话以处理磁盘操作,如fdiskparted

精通磁盘工具非常重要,不仅可以检索信息和操作分区,还能准确地衡量性能。特别是性能衡量是一个难点。对于“如何衡量设备性能?”这个问题,最好的答案是“取决于”,因为很难为特定的度量标准做到这一点。

性能测量

如果我们不得不在一个无法访问互联网或我们无法控制并因此无法安装软件包的服务器上工作,那么我们将不得不说dd工具(应该在所有主要的 Linux 发行版上都可以找到)将帮助提供一些答案。如果可能的话,可以与iostat配合使用,以区分是哪个命令在占用设备,哪个在获取报告。

正如一位经验丰富的性能工程师所说,这取决于测量的对象和方式。例如,dd是单线程的并且有其限制,比如不能进行多个随机读写;它还测量吞吐量而不是每秒的输入/输出操作数(IOPS)。你在测量什么?吞吐量还是 IOPS?

注意

关于这些示例,需要提个醒。它们可能会破坏你的系统,请不要盲目跟随,并确保使用可以被清除的设备。

这个简单的单行命令将运行 dd 来获取一个全新设备(在本例中为 /dev/sdc)的一些数据:

$ dd if=/dev/zero of=/dev/sdc count=10 bs=100M
10+0 records in
10+0 records out
1048576000 bytes (1.0 GB, 1000 MiB) copied, 1.01127 s, 1.0 GB/s

它以 1GB/s 的速率写入 100 兆字节的 10 条记录。这是吞吐量。通过 dd 获得 IOPS 的简单方法是使用 iostat。在此示例中,iostat 仅在受 dd 猛烈攻击的设备上运行,使用 -d 标志仅提供设备信息,并以一秒的间隔运行:

$ iostat -d /dev/sdc 1

Device             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn
sdc            6813.00         0.00   1498640.00          0    1498640

Device             tps    kB_read/s    kB_wrtn/s    kB_read    kB_wrtn
sdc            6711.00         0.00   1476420.00          0    1476420

iostat 输出将每秒重复一次,直到发出 Ctrl-C 取消操作。输出中的第二列是 tps,代表每秒事务数,等同于 IOPS。一个更好的可视化输出的方法是在每次运行时清除终端:

$ while true; do clear && iostat -d /dev/sdc && sleep 1; done

使用 fio 进行准确的测试

如果 ddiostat 不足够,性能测试中最常用的工具是 fio。它可以帮助澄清设备在读取或写入密集环境中的性能行为(甚至可以调整读取与写入的百分比)。

fio 的输出非常详细。下面的示例修剪了输出以突出显示读写操作的 IOPS:

$ fio --name=sdc-performance --filename=/dev/sdc --ioengine=libaio \
  --iodepth=1 --rw=randrw --bs=32k --direct=0 --size=64m
sdc-performance: (g=0): rw=randwrite, bs=(R) 32.0KiB-32.0KiB,
(W) 32.0KiB-32.0KiB, (T) 32.0KiB-32.0KiB, ioengine=libaio, iodepth=1
fio-3.1
Starting 1 process

sdc-performance: (groupid=0, jobs=1): err= 0: pid=2879:
   read: IOPS=1753, BW=54.8MiB/s (57.4MB/s)(31.1MiB/567msec)
...
   iops        : min= 1718, max= 1718, avg=1718.00, stdev= 0.00, samples=1
  write: IOPS=1858, BW=58.1MiB/s (60.9MB/s)(32.9MiB/567msec)
...
   iops        : min= 1824, max= 1824, avg=1824.00, stdev= 0.00, samples=1

示例中使用的标志命名了作业 sdc-performance,直接指向 /dev/sdc 设备(将需要超级用户权限),使用本地 Linux 异步 I/O 库,将 iodepth 设置为 1(一次发送的顺序 I/O 请求数),并定义了 32 千字节的缓冲区大小的随机读写操作,使用了带缓冲的 I/O(可以设置为 1 以使用无缓冲 I/O),作用于 64 兆字节文件。这是一个非常长的命令!

fio 工具有大量额外选项,可以帮助几乎所有需要准确 IOPS 测量的情况。例如,它可以跨多个设备进行测试,进行一些I/O 预热,甚至为测试设置 I/O 阈值,以防超出定义的限制。最后,命令行中的许多选项可以使用 INI 风格的文件进行配置,从而可以很好地脚本化作业执行。

分区

我们倾向于使用交互式会话的 fdisk 来创建分区,但在某些情况下,如大分区(两 TB 或更大),fdisk 可能不太适用。在这些情况下,您应该使用 parted 作为备选方案。

一个快速的交互式会话展示如何使用 fdisk 创建一个带有默认起始值和四吉比字节大小的主分区。最后使用 w 键来写入更改:

$ sudo fdisk /dev/sds

Command (m for help): n
Partition type:
   p   primary (0 primary, 0 extended, 4 free)
   e   extended
Select (default p): p
Partition number (1-4, default 1):
First sector (2048-22527999, default 2048):
Using default value 2048
Last sector, +sectors or +size{K,M,G} (2048-22527999, default 22527999): +4G
Partition 1 of type Linux and of size 4 GiB is set

Command (m for help): w
The partition table has been altered!

Calling ioctl() to re-read partition table.
Syncing disks.

parted 以不同的界面实现相同功能:

$ sudo parted /dev/sdaa
GNU Parted 3.1
Using /dev/sdaa
Welcome to GNU Parted! Type 'help' to view a list of commands.
(parted) mklabel
New disk label type? gpt
(parted) mkpart
Partition name?  []?
File system type?  [ext2]?
Start? 0
End? 40%

最后,您可以使用几个命令实现在命令行上以编程方式创建分区,而无需任何交互提示:

$ parted --script /dev/sdaa mklabel gpt
$ parted --script /dev/sdaa mkpart primary 1 40%
$ parted --script /dev/sdaa print
Disk /dev/sdaa: 11.5GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags:

Number  Start   End     Size    File system  Name     Flags
 1      1049kB  4614MB  4613MB

检索特定设备信息

有时候需要设备的特定信息时,要么使用lsblk要么使用blkid更合适。fdisk在没有超级用户权限时无法正常工作。在这里,fdisk列出了有关/dev/sda设备的信息:

$ fdisk -l /dev/sda
fdisk: cannot open /dev/sda: Permission denied

$ sudo fdisk -l /dev/sda

Disk /dev/sda: 42.9 GB, 42949672960 bytes, 83886080 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk label type: dos
Disk identifier: 0x0009d9ce

   Device Boot      Start         End      Blocks   Id  System
/dev/sda1   *        2048    83886079    41942016   83  Linux

blkid类似,它也需要超级用户权限:

$ blkid /dev/sda

$ sudo blkid /dev/sda
/dev/sda: PTTYPE="dos"

lsblk允许获取信息而无需更高的权限,并提供相同的信息输出:

$ lsblk /dev/sda
NAME   MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
sda      8:0    0  40G  0 disk
└─sda1   8:1    0  40G  0 part /
$ sudo lsblk /dev/sda
NAME   MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
sda      8:0    0  40G  0 disk
└─sda1   8:1    0  40G  0 part /

此命令使用-p标志进行低级设备探测,非常彻底,应该会为设备提供足够好的信息:

$ blkid -p /dev/sda1
UUID="8e4622c4-1066-4ea8-ab6c-9a19f626755c" TYPE="xfs" USAGE="filesystem"
PART_ENTRY_SCHEME="dos" PART_ENTRY_TYPE="0x83" PART_ENTRY_FLAGS="0x80"
PART_ENTRY_NUMBER="1" PART_ENTRY_OFFSET="2048" PART_ENTRY_SIZE="83884032"

lsblk有一些默认属性要查找:

$ lsblk -P /dev/nvme0n1p1
NAME="nvme0n1p1" MAJ:MIN="259:1" RM="0" SIZE="512M" RO="0" TYPE="part"

但它还允许您设置特定的标志以请求特定的属性:

lsblk -P -o SIZE /dev/nvme0n1p1
SIZE="512M"

以这种方式访问属性使得编写脚本和从 Python 端访问变得容易。

网络工具

随着越来越多的服务器需要互联,网络工具不断改进。本节中的许多实用程序涵盖了像安全外壳(SSH)隧道这样的有用单行命令,但有些则深入到测试网络性能的细节,例如使用 Apache Bench 工具。

SSH 隧道

您是否尝试过访问在远程服务器上运行但除了通过 SSH 无法访问的 HTTP 服务?当 HTTP 服务已启用但不需要公开访问时会出现这种情况。我们上次看到这种情况发生是在生产实例的RabbitMQ上启用了管理插件,它在端口 15672 上启动了 HTTP 服务。服务没有暴露出来是有道理的;没有必要公开它,因为它很少被使用,而且可以使用 SSH 的隧道功能。

这通过创建与远程服务器的 SSH 连接,然后将远程端口(在我的情况下是 15672)转发到源机器上的本地端口来工作。远程机器具有自定义的 SSH 端口,这略微复杂化了命令。命令如下所示:

$ ssh -L 9998:localhost:15672 -p 2223 adeza@prod1.rabbitmq.ceph.internal -N

这里有三个标志,三个数字和两个地址。让我们解析一下命令,以便更清楚地理解正在发生的事情。-L标志表明我们要启用转发并绑定到一个远程端口(RabbitMQ 的默认端口 15672)的本地端口(9998)。接下来的-p标志指示远程服务器的自定义 SSH 端口为 2223,然后指定用户名和地址。最后,-N表示不应该进入远程 shell 并进行转发。

当正确执行时,命令会似乎挂起,但允许您进入http://localhost:9998/并查看远程 RabbitMQ 实例的登录页面。在进行隧道时了解的一个有用标志是-f:它会将进程发送到后台,这对于这种非临时连接很有帮助,使终端准备好并保持干净,可以继续工作。

使用 Apache Benchmark(ab)进行 HTTP 基准测试

我们确实喜欢测试我们使用的服务器,以确保它们能够正确处理负载,特别是在它们被提升到生产环境之前。有时,甚至会尝试触发某些在重负载下可能发生的奇怪竞态条件。Apache Benchmark 工具(命令行中的 ab)是其中一个可以仅用几个标志迅速启动的小工具。

此命令将在运行 Nginx 的本地实例上一次创建 100 个请求,总共 10,000 个请求:

$ ab -c 100 -n 10000 http://localhost/

这对于系统来说是相当残酷的处理方式,但这是一个本地服务器,请求只是一个 HTTP GET。来自 ab 的详细输出非常全面,如下所示(已削减以保持简洁):

Benchmarking localhost (be patient)
...
Completed 10000 requests
Finished 10000 requests

Server Software:        nginx/1.15.9
Server Hostname:        localhost
Server Port:            80

Document Path:          /
Document Length:        612 bytes

Concurrency Level:      100
Time taken for tests:   0.624 seconds
Complete requests:      10000
Failed requests:        0
Total transferred:      8540000 bytes
HTML transferred:       6120000 bytes
Requests per second:    16015.37 [#/sec] (mean)
Time per request:       6.244 [ms] (mean)
Time per request:       0.062 [ms] (mean, across all concurrent requests)
Transfer rate:          13356.57 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    3   0.6      3       5
Processing:     0    4   0.8      3       8
Waiting:        0    3   0.8      3       6
Total:          0    6   1.0      6       9

这种信息及其呈现方式非常了不起。一眼就可以快速判断生产服务器是否断开连接(在 Failed requests 字段中),以及平均值是多少。使用 GET 请求,但 ab 允许您使用其他 HTTP 动词,如 POST,甚至进行 HEAD 请求。您需要小心使用这种类型的工具,因为它很容易使服务器超载。以下是来自生产环境中 HTTP 服务的更为真实的数据(已削减以保持简洁):

...
Benchmarking prod1.ceph.internal (be patient)

Server Software:        nginx
Server Hostname:        prod1.ceph.internal
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES256-GCM-SHA384,2048,256
Server Temp Key:        ECDH P-256 256 bits
TLS Server Name:        prod1.ceph.internal

Complete requests:      200
Failed requests:        0
Total transferred:      212600 bytes
HTML transferred:       175000 bytes
Requests per second:    83.94 [#/sec] (mean)
Time per request:       1191.324 [ms] (mean)
Time per request:       11.913 [ms] (mean, across all concurrent requests)
Transfer rate:          87.14 [Kbytes/sec] received
....

现在数字看起来不同,它命中了一个启用了 SSL 的服务,并且 ab 列出了协议。每秒 83 次请求,我们认为它可以做得更好,但这是一个生成 JSON 的 API 服务器,并且通常不会一次性承载太多负载,就像刚生成的那样。

使用 molotov 进行负载测试

Molotov 项目是一个有趣的负载测试项目。它的一些特点类似于 Apache Benchmark,但作为一个 Python 项目,它提供了一种使用 Python 和 asyncio 模块编写场景的方法。

这是 molotov 最简单示例的样子:

import molotov

@molotov.scenario(100)
async def scenario_one(session):
    async with session.get("http://localhost:5000") as resp:
        assert resp.status == 200

将文件保存为 load_test.py,创建一个小型 Flask 应用程序,该应用程序在其主 URL 处处理 POSTGET 请求,并将其保存为 small.py

from flask import Flask, redirect, request

app = Flask('basic app')

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        redirect('https://www.google.com/search?q=%s' % request.args['q'])
    else:
        return '<h1>GET request from Flask!</h1>'

使用 FLASK_APP=small.py flask run 启动 Flask 应用程序,然后使用先前创建的 load_test.py 文件运行 molotov

$ molotov -v -r 100 load_test.py
**** Molotov v1.6\. Happy breaking! ****
Preparing 1 worker...
OK
SUCCESSES: 100 | FAILURES: 0 WORKERS: 0
*** Bye ***

单个工作程序上的一百个请求运行到本地 Flask 实例。当负载测试扩展到每个请求更多时,这个工具确实表现出色。它具有类似单元测试的概念,如设置、拆卸,甚至可以对某些事件做出反应的代码。由于小型 Flask 应用程序可以处理重定向到 Google 搜索的 POST,因此在 load_test.py 文件中添加另一个场景到 load_test。这次更改权重,使得 100% 的请求执行 POST

@molotov.scenario(100)
async def scenario_post(session):
    resp = await session.post("http://localhost:5000", params={'q': 'devops'})
    redirect_status = resp.history[0].status
    error = "unexpected redirect status: %s" % redirect_status
    assert redirect_status == 301, error

运行这个新场景以展示以下内容:

$ molotov -v -r 1 --processes 1 load_test.py
**** Molotov v1.6\. Happy breaking! ****
Preparing 1 worker...
OK
AssertionError('unexpected redirect status: 302',)
  File ".venv/lib/python3.6/site-packages/molotov/worker.py", line 206, in step
    **scenario['kw'])
  File "load_test.py", line 12, in scenario_two
    assert redirect_status == 301, error
SUCCESSES: 0 | FAILURES: 1
*** Bye ***

一个请求(使用 -r 1)就足以导致失败。断言需要更新为检查 302 而不是 301。一旦状态更新,将 POST 场景的权重更改为 80,以便其他请求(使用 GET)发送到 Flask 应用程序。最终文件如下:

import molotov

@molotov.scenario()
async def scenario_one(session):
    async with session.get("http://localhost:5000/") as resp:
        assert resp.status == 200

@molotov.scenario(80)
async def scenario_two(session):
    resp = await session.post("http://localhost:5000", params={'q': 'devops'})
    redirect_status = resp.history[0].status
    error = "unexpected redirect status: %s" % redirect_status
    assert redirect_status == 301, error

运行 load_test.py 进行 10 次请求以分发请求,其中两次为 GET 请求,其余为 POST 请求:

127.0.0.1 - - [04/Sep/2019 12:10:54] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:10:56] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:10:57] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:10:58] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [04/Sep/2019 12:10:58] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:10:59] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:11:00] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:11:01] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [04/Sep/2019 12:11:01] "POST /?q=devops HTTP/1.1" 302 -
127.0.0.1 - - [04/Sep/2019 12:11:02] "POST /?q=devops HTTP/1.1" 302 -

正如你所看到的,molotov 可以通过纯 Python 很容易地扩展,并且可以修改以适应其他更复杂的需求。这些例子只是展示了该工具可以做什么的冰山一角。

CPU 实用程序

有两个重要的 CPU 实用程序:tophtop。你可以在今天的大多数 Linux 发行版中找到预安装的 top,但是如果你能够安装软件包,那么 htop 是非常好用的,我们更喜欢它的可自定义界面而不是 top。还有一些其他工具可以提供 CPU 可视化,甚至可能是监控,但是没有一个像 tophtop 那样完整且广泛可用。例如,完全可以从 ps 命令获取 CPU 利用率:

$ ps -eo pcpu,pid,user,args | sort -r | head -10
%CPU   PID USER     COMMAND
 0.3   719 vagrant  -bash
 0.1   718 vagrant  sshd: vagrant@pts/0
 0.1   668 vagrant  /lib/systemd/systemd --user
 0.0     9 root     [rcu_bh]
 0.0    95 root     [ipv6_addrconf]
 0.0    91 root     [kworker/u4:3]
 0.0     8 root     [rcu_sched]
 0.0    89 root     [scsi_tmf_1]

ps 命令需要一些自定义字段。第一个是 pcpu,它给出了 CPU 使用情况,然后是进程 ID、用户,最后是命令。这将通过排序的反向管道处理,因为默认情况下它从较少的 CPU 使用情况到更多的 CPU 使用情况,并且你需要将最高的 CPU 使用情况显示在顶部。最后,由于该命令为每个进程显示此信息,因此使用 head 命令过滤前 10 个结果。

但是这个命令相当冗长,记忆起来很有挑战性,并且不能实时更新。即使使用别名,你最好还是使用 tophtop。正如你将看到的,两者都有丰富的功能。

使用 htop 查看进程

htop 工具就像 top(一个交互式进程查看器),但完全跨平台(适用于 OS X、FreeBSD、OpenBSD 和 Linux),提供更好的可视化支持(参见图 4-1),使用起来非常愉快。访问 https://hisham.hm/htop 查看在服务器上运行 htop 的截图。htop 的一个主要缺点是,你可能知道的所有 top 的快捷键都不兼容,因此你需要重新调整你的大脑来理解并使用它们来使用 htop

pydo 0401

图 4-1. 在服务器上运行的 htop

立即看到在 图 4-1 中显示的信息的外观和感觉是不同的。CPU、内存和交换空间在左上角清晰显示,并随着系统的变化而变化。箭头键可以向上或向下滚动,甚至可以左右滚动,提供对进程的整个命令的视图。

想要终止一个进程吗?使用箭头键移动到该进程,或者按 / 逐步搜索(和过滤)该进程,然后按 k。一个新菜单将显示可以发送给进程的所有信号,例如 SIGTERM 而不是 SIGKILL。可以“标记”多个要终止的进程。按空格键标记所选进程,用不同颜色突出显示。犯了一个错误想要取消标记吗?再次按空格键。这一切都感觉非常直观。

htop的一个问题是它将许多操作映射到F键,而您可能没有。例如,F1是帮助。替代方法是在可能的情况下使用等效的映射。要访问帮助菜单,请使用h键;要访问设置,请使用Shift s,而不是 F2。

t键(再次,多么直观!)将进程列表切换为树形式。可能最常用的功能是排序。按>键会弹出菜单,以选择您想要的排序类型:PID、用户、内存、优先级和 CPU 百分比仅为其中几个。还有直接排序的快捷方式(跳过菜单选择):按内存(Shift i)、CPU(Shift p)和时间(Shift t)。

最后,两个令人难以置信的功能:只要这些已安装并对用户可用,您可以直接在选择的进程中运行stracelsof。如果进程需要超级用户权限,htop将报告,并且需要使用sudo以特权用户身份运行。要在所选进程上运行strace,请使用s键;对于lsof,请使用l键。

如果使用stracelsof,可以使用/字符进行搜索和过滤选项。多么令人难以置信的实用工具!希望将来能实现其他非F键映射,尽管大多数工作可以通过替代映射完成。

提示

如果通过其交互会话定制了htop,更改将保存在通常位于~/.config/htop/htoprc的配置文件中。如果在会话中定义了配置,然后在会话中后来更改了它们,则会话将覆盖先前在htoprc文件中定义的内容。

使用 Bash 和 ZSH

一切都从定制开始。Bash 和 ZSH 通常都会有一个“点文件”,即以点开头的文件,用于保存配置,默认情况下在目录内容列表时是隐藏的,存放在用户的主目录中。对于 Bash 来说,这是.bashrc,对于 ZSH 来说,是.zshrc。这两个 shell 都支持几层按预定义顺序加载的位置,最终会加载用户的配置文件。

当安装 ZSH 时,通常不会创建.zshrc文件。这是它在 CentOS 发行版中的最小版本(为简洁起见,所有注释已删除):

$ cat /etc/skel/.zshrc
autoload -U compinit
compinit

setopt COMPLETE_IN_WORD

Bash 中有一些附加项,但没有什么令人意外的。毫无疑问,您会对某些行为或在其他服务器上看到的事物感到极度恼火,并希望复制它们。我们无法在终端中没有颜色,所以无论是哪个 shell,都必须启用颜色。在您知晓之前,您已深入到配置中,并希望添加一堆有用的别名和函数。

此后,文本编辑器配置就出现了,在不同的机器上或者添加新机器时,这一切都显得难以管理,所有这些有用的别名都没有设置,这简直是令人难以置信,但是没有在任何地方启用颜色支持。每个人都有自己解决这个问题的方式,完全是一种无法转移的,特设的方式:阿尔弗雷多在某些时候使用Makefile,而他的同事们要么什么都不用,要么用一个 Bash 脚本。一个名为Dotdrop的新项目有很多功能,可以使所有这些点文件有条不紊地工作起来,包括复制、符号链接和为开发和其他机器保留独立的配置文件——当你从一台机器移动到另一台机器时非常有用。

您可以在 Python 项目中使用 Dotdrop,虽然您可以通过常规的virtualenvpip工具安装它,但建议将其包含为点文件存储库的子模块。如果您还没有这样做,将所有点文件放入版本控制非常方便,以便跟踪更改。阿尔弗雷多的dotfiles是公开可用的,并且他尽可能使其保持最新。

独立于所使用的内容,通过版本控制跟踪更改,并确保一切始终保持更新,是一个很好的策略。

自定义 Python Shell

您可以通过将有用的模块导入 Python 文件并将其导出为环境变量来自定义 Python shell。我将我的配置文件保存在一个名为dotfiles的仓库中,因此在我的 shell 配置文件(对我而言是$HOME/.zshrc)中定义如下导出:

export PYTHONSTARTUP=$HOME/dotfiles/pythonstartup.py

若要尝试此功能,请创建一个名为pythonstartup.py的新 Python 文件(虽然可以命名为任何名称),内容如下:

import types
import uuid

helpers = types.ModuleType('helpers')
helpers.uuid4 = uuid.uuid4()

现在打开一个新的 Python shell,并指定新创建的pythonstartup.py

$ PYTHONSTARTUP=pythonstartup.py python
Python 3.7.3 (default, Apr  3 2019, 06:39:12)
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> helpers
<module 'helpers'>
>>> helpers.uuid4()
UUID('966d7dbe-7835-4ac7-bbbf-06bf33db5302')

helpers对象立即可用。由于我们添加了uuid4属性,我们可以将其作为helpers.uuid4()访问。正如您可能已经注意到的那样,所有导入和定义将在 Python shell 中可用。这是一种方便的扩展行为的方式,可以与默认 shell 一起使用。

递归全局搜索

在 ZSH 中,默认启用递归全局搜索,但 Bash(4 及更高版本)需要使用shopt来设置。递归全局搜索是一种很酷的设置,允许您以以下语法遍历路径:

$ ls **/*.py

该片段将递归地遍历每个文件和目录,并列出以.py结尾的每个文件。这是如何在 Bash 4 中启用它的:

$ shopt -s globstar

搜索和替换时,需要确认提示。

Vim 在其搜索和替换引擎中有一个很好的功能,提示是否执行替换或跳过。当你不能准确地匹配所需的正则表达式,但又想忽略一些其他接近的匹配时,这特别有用。我们了解正则表达式,但尽量避免成为专家,因为对于一切都使用它们是非常诱人的。大多数情况下,你会希望执行简单的搜索和替换,而不是为了找到完美的正则表达式而苦恼。

要在 Vim 命令的末尾添加c标志以启用确认提示:

:%s/original term/replacement term/gc

上述内容翻译为:在整个文件中搜索原始术语并用替换术语替换,但在每次匹配时提示,以便可以决定是否更改或跳过。如果找到匹配项,Vim 将显示如下消息:

replace with replacement term (y/n/a/q/l/^E/^Y)?

整个确认工作流可能看起来很傻,但它允许你放松对正则表达式的约束,甚至在不使用正则表达式进行更简单的匹配和替换时也可以使用。一个快速的例子是生产工具中最近的 API 更改,更改了对象属性以进行调用。代码返回TrueFalse以通知是否需要超级用户权限。在单个文件中的实际替换如下所示:

:%s/needs_root/needs_root()/gc

增加的难度在于needs_root还散落在注释和文档字符串中,因此不容易想出一个允许在注释块内或文档字符串的一部分时跳过替换的正则表达式。使用c标志,你只需按下YN即可继续。根本不需要正则表达式!

使用启用了递归通配符(shopt -s globstar在 Bash 4 中),这个强大的单行命令将遍历所有匹配的文件,执行搜索并根据需要的情况进行替换:

vim -c "bufdo! set eventignore-=Syntax | %s/needs_root/needs_root()/gce" **/*.py

这里有很多内容要处理,但上面的例子将递归遍历查找所有以.py结尾的文件,加载到 Vim 中,并仅在有匹配时执行带有确认的搜索和替换。如果没有匹配,则跳过该文件。使用set eventignore-=Syntax是因为否则在这种执行方式下 Vim 不会加载语法文件;我们喜欢语法高亮,并期望在使用这种替换时它能正常工作。|字符后的下一部分是带有确认标志和e标志的替换,后者有助于忽略可能会导致平稳工作流中断的任何错误。

提示

还有许多其他标志和变体可用于增强替换命令。要了解有关 Vim 搜索和替换的特殊标志的更多信息,请查看:help substitute,特别是s_flags部分。

使用一个接受两个参数(搜索和替换术语)和路径的函数,使复杂的单行命令更容易记住。

vsed() {
  search=$1
  replace=$2
  shift
 shift
  vim -c "bufdo! set eventignore-=Syntax| %s/$search/$replace/gce" $*
}

将其命名为vsed,结合了 Vim 和sed工具,使其更容易记忆。在终端中看起来很简单,可以让你轻松自信地对多个文件进行更改,因为你可以接受或拒绝每次替换:

$ vsed needs_root needs_root() **/*.py

删除临时 Python 文件

Python 的pyc,以及最近的*pycache*目录,有时可能会妨碍工作。这个简单的一行命令别名为pyclean,使用find命令删除pyc,然后继续查找*pycache*目录,并使用工具的内置删除标志递归删除它们:

alias pyclean='find . \
 \( -type f -name "*.py[co]" -o -type d -name "__pycache__" \) -delete &&
 echo "Removed pycs and __pycache__"'

进程清单和过滤

进程列表用于查看机器上正在运行的内容,然后进行过滤以检查特定应用程序是你每天至少会做几次的事情之一。毫不奇怪,每个人都会对ps工具(我们通常使用aux)的标志或标志的顺序有所变化。你每天都会做这么多次,以至于这些标志和顺序会深深印在你的脑海中,很难用其他方式做。

作为列出进程和一些信息的良好起点,比如进程 ID,试试这个:

$ ps auxw

此命令列出所有进程,使用BSD 风格标志(不带破折号-的标志),无论它们是否具有终端(tty),并包括拥有进程的用户。最后,它为输出提供更多空间(w标志)。

大多数情况下,你会使用grep进行过滤,以获取关于特定进程的信息。例如,如果你想检查 Nginx 是否正在运行,你可以将输出通过管道传递到 grep,并将nginx作为参数传递:

$ ps auxw | grep nginx
root     29640  1536 ?        Ss   10:11   0:00 nginx: master process
www-data 29648  5440 ?        S    10:11   0:00 nginx: worker process
alfredo  30024   924 pts/14   S+   10:12   0:00 grep nginx

这很棒,但包含grep命令有些让人讨厌。特别是当除了grep之外没有结果时,这尤其让人恼火:

$ ps auxw | grep apache
alfredo  31351  0.0  0.0   8856   912 pts/13   S+   10:15   0:00 grep apache

没有找到apache进程,但是视觉上可能会让你误以为有,双重检查确实只是因为参数而导致grep被包含在内,这可能会很快令人疲倦。解决方法是向grep添加另一个管道来从输出中过滤自身:

$ ps auxw | grep apache | grep -v grep

必须始终记住添加额外的grep同样令人恼火,因此别名来拯救:

alias pg='ps aux | grep -v grep | grep $1'

新的别名将过滤掉第一个grep行,仅留下有趣的输出(如果有的话):

$ pg vim
alfredo  31585  77836 20624 pts/3    S+   18:39   0:00 vim /home/alfredo/.zshrc

Unix 时间戳

要在 Python 中获取广泛使用的 Unix 时间戳非常容易:

In [1]: import time

In [2]: int(time.time())
Out[2]: 1566168361

但在 shell 中,可能会更复杂一些。这个别名适用于 OS X,它具有 BSD 风格的date工具:

alias timestamp='date -j -f "%a %b %d %T %Z %Y" "`date`" "+%s"'

OS X 的工具可能有些古怪,导致你总是搞不清楚为什么特定实用程序(比如这种情况下的date)的行为完全不同。在 Linux 版本的date中,一个更简单的方法可以达到同样的效果:

alias timestamp='date "+%s"'

将 Python 与 Bash 和 ZSH 混合使用

我们从未想过尝试将 Python 与像 ZSH 或 Bash 这样的 shell 混合使用。这感觉违反常识,但这里有几个很好的例子,你几乎可以每天都使用。总的来说,我们的经验法则是 shell 脚本的上限是 10 行;超过这个限制的任何内容都可能是一个 bug,会让你浪费时间,因为错误报告并不能帮助你解决问题。

随机密码生成器

每周你需要的帐号和密码数量只会增加,即使是一次性帐号,你也可以使用 Python 生成强大的密码。创建一个有用的随机密码生成器,将内容发送到剪贴板以便轻松粘贴:

In [1]: import os

In [2]: import base64

In [3]: print(base64.b64encode(os.urandom(64)).decode('utf-8'))
gHHlGXnqnbsALbAZrGaw+LmvipTeFi3tA/9uBltNf9g2S9qTQ8hTpBYrXStp+i/o5TseeVo6wcX2A==

将其移植到一个可以接受任意长度的 shell 函数(在站点限制长度为某个特定数字时非常有用)看起来像这样:

mpass() {
    if [ $1 ]; then
        length=$1
    else
        length=12
    fi
    _hash=`python3 -c "
import os,base64
exec('print(base64.b64encode(os.urandom(64))[:${length}].decode(\'utf-8\'))')
 "`
    echo $_hash | xclip -selection clipboard
    echo "new password copied to the system clipboard"
}

现在 mpass 函数默认生成 12 个字符的密码,然后将生成的字符串内容发送到 xclip,以便将其复制到剪贴板方便粘贴。

注意

在许多发行版中默认未安装 xclip,因此您需要确保它已安装以使函数正常工作。如果没有 xclip,任何其他可以帮助管理系统剪贴板的工具都可以正常工作。

模块是否存在?

查找模块是否存在,如果存在则获取该模块的路径。这在被其他函数重用时非常有用,可以接受该输出进行处理:

try() {
    python -c "
exec('''
try:
 import ${1} as _
 print(_.__file__)
except Exception as e:
 print(e)
''')"
}

切换到模块路径

在调试库和依赖项或者探索模块源码时经常会被问到“这个模块在哪?”。Python 安装和分发模块的方式并不直观,在不同的 Linux 发行版中路径完全不同,并且有各自的约定。如果导入模块然后使用 print,你可以找出模块的路径:

In [1]: import os

In [2]: print(os)
<module 'os' from '.virtualenvs/python-devops/lib/python3.6/os.py'>

获取路径以便于切换目录并查看模块并不方便。这个函数将尝试导入作为参数的模块,打印出来(这是 shell,所以 return 对我们没用),然后切换到它:

cdp() {
    MODULE_DIRECTORY=`python -c "
exec('''
try:
 import os.path as _, ${module}
 print(_.dirname(_.realpath(${module}.__file__)))
except Exception as e:
 print(e)
''')"`
    if  [[ -d $MODULE_DIRECTORY ]]; then
        cd $MODULE_DIRECTORY
    else
        echo "Module ${1} not found or is not importable: $MODULE_DIRECTORY"
    fi
}

让它更加健壮,以防包名中有破折号并且模块使用下划线,添加:

    module=$(sed 's/-/_/g' <<< $1)

如果输入有破折号,这个小函数可以实时解决并帮助我们到达目标位置:

$ cdp pkg-resources
$ pwd
/usr/lib/python2.7/dist-packages/pkg_resources

将 CSV 文件转换为 JSON

Python 自带一些内置功能,如果你从未接触过它们,可能会感到惊讶。它可以原生处理 JSON 和 CSV 文件。只需几行代码即可加载 CSV 文件,然后“转储”其内容为 JSON。使用以下 CSV 文件(addresses.csv)在 Python shell 中查看转储的内容:

John,Doe,120 Main St.,Riverside, NJ, 08075
Jack,Jhonson,220 St. Vernardeen Av.,Phila, PA,09119
John,Howards,120 Monroe St.,Riverside, NJ,08075
Alfred, Reynolds, 271 Terrell Trace Dr., Marietta, GA, 30068
Jim, Harrison, 100 Sandy Plains Plc., Houston, TX, 77005
>>> import csv
>>> import json
>>> contents = open("addresses.csv").readlines()
>>> json.dumps(list(csv.reader(contents)))
'[["John", "Doe", "120 Main St.", "Riverside", " NJ", " 08075"],
["Jack", "Jhonson", "220 St. Vernardeen Av.", "Phila", " PA", "09119"],
["John", "Howards", "120 Monroe St.", "Riverside", " NJ", "08075"],
["Alfred", " Reynolds", " 271 Terrell Trace Dr.", " Marietta", " GA", " 30068"],
["Jim", " Harrison", " 100 Sandy Plains Plc.", " Houston", " TX", " 77005"]]'

将交互式会话移植为可以在命令行上执行的函数:

csv2json () {
	python3 -c "
exec('''
import csv,json
print(json.dumps(list(csv.reader(open(\'${1}\')))))
''')
"
}

在 shell 中使用它,这比记住所有调用和模块要简单得多:

$ csv2json addresses.csv
[["John", "Doe", "120 Main St.", "Riverside", " NJ", " 08075"],
["Jack", "Jhonson", "220 St. Vernardeen Av.", "Phila", " PA", "09119"],
["John", "Howards", "120 Monroe St.", "Riverside", " NJ", "08075"],
["Alfred", " Reynolds", " 271 Terrell Trace Dr.", " Marietta", " GA", " 30068"],
["Jim", " Harrison", " 100 Sandy Plains Plc.", " Houston", " TX", " 77005"]]

Python 单行代码

一般来说,编写长的单行 Python 代码并不被视为良好的实践。PEP 8指南甚至不赞成使用分号来合并语句(在 Python 中可以使用分号!)。但是,快速调试语句和调试器的调用是可以的。它们毕竟是临时的。

调试器

一些程序员发誓将print()语句作为调试运行代码的最佳策略。在某些情况下,这可能效果不错,但大多数时候我们使用 Python 调试器(使用pdb模块)或ipdb,它使用 IPython 作为后端。通过创建断点,您可以查看变量并在堆栈上下移动。这些单行语句非常重要,您应该记住它们:

设置一个断点并进入 Python 调试器(pdb):

import pdb;pdb.set_trace()

设置一个断点,并进入基于 IPython(ipdb)的 Python 调试器:

import ipdb;ipdb.set_trace()

尽管在技术上不是调试器(您无法在堆栈中前进或后退),但这个一行命令允许您在执行到它时启动一个 IPython 会话:

import IPython; IPython.embed()
注意

每个人似乎都有自己喜欢的调试器工具。我们发现pdb太粗糙(没有自动完成,没有语法高亮),所以我们更喜欢ipdb。如果有人使用不同的调试器,不要感到惊讶!最后,了解pdb的工作原理是有用的,因为它是无论调试器如何都需要熟练掌握的基础。在无法控制的系统中,直接使用pdb,因为您无法安装依赖项;您可能不喜欢它,但您仍然可以处理。

这段代码有多快?

Python 有一个模块可以多次运行一段代码,并从中获取一些性能指标。许多用户喜欢问是否有高效的方法来处理循环或更新字典,而有很多了解的人都喜欢timeit模块来证明性能。

正如您可能已经看到的,我们是IPython的粉丝,其交互式 shell 配备了timeit模块的“魔术”特殊功能。 “魔术”函数以%字符为前缀,并在 shell 中执行不同的操作。关于性能的一项永久性喜爱是列表推导是否比仅附加到列表更快。以下两个示例使用timeit模块进行测试:

In [1]: def f(x):
   ...:     return x*x
   ...:

In [2]: %timeit for x in range(100): f(x)
100000 loops, best of 3: 20.3 us per loop

在标准的 Python shell(或解释器)中,你需要导入模块并直接访问它。在这种情况下,调用看起来有点不同:

>>> array = []
>>> def appending():
...     for i in range(100):
...         array.append(i)
...
>>> timeit.repeat("appending()", "from __main__ import appending")
[5.298534262983594, 5.32031941099558, 5.359099322988186]
>>> timeit.repeat("[i for i in range(100)]")
[2.2052824340062216, 2.1648171059787273, 2.1733458579983562]

输出有点奇怪,但这是因为它是为另一个模块或库处理而设计的,而不是为了人类可读性。平均值倾向于列表推导。这是在 IPython 中的样子:

In [1]: def appending():
   ...:     array = []
   ...:     for i in range(100):
   ...:         array.append(i)
   ...:

In [2]: %timeit appending()
5.39 µs ± 95.1 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In [3]: %timeit [i for i in range(100)]
2.1 µs ± 15.2 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

因为 IPython 将timeit公开为特殊命令(注意前缀为%),所以输出是人类可读的,并且更有助于查看,并且不需要像在标准的 Python shell 中那样奇怪的导入。

strace

当应用程序没有记录感兴趣的部分或根本没有记录时,能够了解程序如何与操作系统交互变得至关重要。strace 的输出可能有些粗糙,但掌握一些基本知识后,理解问题应用程序的运行情况就会变得更容易。有一次,Alfredo 尝试理解为什么拒绝访问文件的权限。这个文件位于一个符号链接中,这个链接似乎具有所有正确的权限。到底发生了什么?仅仅通过查看日志很难说清楚,因为这些日志在显示访问文件时的权限时并不特别有用。

strace 在输出中包含了这两行:

stat("/var/lib/ceph/osd/block.db", 0x7fd) = -1 EACCES (Permission denied)
lstat("/var/lib/ceph/osd/block.db", {st_mode=S_IFLNK|0777, st_size=22}) = 0

程序正在设置父目录的所有权,这个目录恰好是一个链接,以及 block.db,在这种情况下也是指向一个块设备的链接。块设备本身具有正确的权限,那么问题出在哪里呢?原来,目录中的链接有一个 粘性位,阻止其他链接改变路径,包括块设备在内。chown 工具有一个特殊标志(-h--no-dereference),表示所有权的更改也应影响这些链接。

没有像 strace 这样的工具,这种类型的调试将会很困难(甚至是不可能的)。要尝试它,请创建一个名为 follow.py 的文件,内容如下:

import subprocess

subprocess.call(['ls', '-alh'])

它导入 subprocess 模块来执行系统调用。它将会将系统调用的内容输出到 ls。不要直接使用 Python 进行调用,而是使用 strace 前缀来看看发生了什么:

$ strace python follow.py

终端应该已经填满了大量输出,其中大部分看起来可能非常陌生。不管你是否理解正在发生的事情,强迫自己逐行查看。有些行比其他行更容易区分。你会看到很多 readfstat 调用;你将看到实际的系统调用以及进程在每个步骤中的操作。还有一些文件上的 openclose 操作,特别是应该显示出几个 stat 调用的特定部分:

stat("/home/alfredo/go/bin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/local/go/bin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/local/bin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/home/alfredo/bin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/local/sbin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/local/bin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/sbin/python", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/bin/python", {st_mode=S_IFREG|0755, st_size=3691008, ...}) = 0
readlink("/usr/bin/python", "python2", 4096) = 7
readlink("/usr/bin/python2", "python2.7", 4096) = 9
readlink("/usr/bin/python2.7", 0x7ff, 4096) = -1 EINVAL (Invalid argument)
stat("/usr/bin/Modules/Setup", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/bin/lib/python2.7/os.py", 0x7ffd) = -1 ENOENT (No such file)
stat("/usr/bin/lib/python2.7/os.pyc", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/lib/python2.7/os.py", {st_mode=S_IFREG|0644, ...}) = 0
stat("/usr/bin/pybuilddir.txt", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/bin/lib/python2.7/lib-dynload", 0x7ff) = -1 ENOENT (No such file)
stat("/usr/lib/python2.7/lib-dynload", {st_mode=S_IFDIR|0755, ...}) = 0

这个系统相当老旧,输出中的 python 意味着 python2.7,因此它在文件系统中穿行以找到正确的可执行文件。它经过几个步骤直到达到 /usr/bin/python,这是一个指向 /usr/bin/python2 的链接,进而又将进程发送到 /usr/bin/python2.7。然后它在 /usr/bin/Modules/Setup 上调用 stat,对于我们作为 Python 开发者来说,这是一个闻所未闻的地方,仅仅是继续到 os 模块。

它继续到 pybuilddir.txtlib-dynload。真是一段旅程。没有 strace,我们可能会尝试阅读执行此操作的代码,以尝试弄清楚接下来会发生什么。但 strace 让这变得极为容易,包括沿途的所有有趣步骤,每个调用都有有用的信息。

该工具有许多值得研究的标志;例如,它可以 附加到一个 PID。如果您知道进程的 PID,可以告诉 strace 生成关于它正在进行的操作的输出。

其中一个有用的标志是 -f;它将随着初始程序创建的子进程而跟踪它们。在示例 Python 文件中,调用了 subprocess,它调用了 ls;如果修改 strace 的命令以使用 -f,输出将更加丰富,包含有关该调用的详细信息。

follow.py 在主目录中运行时,与 -f 标志有很多不同之处。您可以看到对点文件(其中一些是符号链接)的 lstatreadlink 调用:

[pid 30127] lstat(".vimrc", {st_mode=S_IFLNK|0777, st_size=29, ...}) = 0
[pid 30127] lgetxattr(".vimrc", "security.selinux", 0x55c5a36f4720, 255)
[pid 30127] readlink(".vimrc", "/home/alfredo/dotfiles/.vimrc", 30) = 29
[pid 30127] lstat(".config", {st_mode=S_IFDIR|0700, st_size=4096, ...}) = 0

不仅显示对这些文件的调用,而且 PID 在输出中有前缀,这有助于识别哪个(子)进程在做什么。例如,不带 -f 标志的 strace 调用不会显示 PID。

最后,要详细分析输出,将其保存到文件中会很有帮助。这可以通过 -o 标志实现:

$ strace -o output.txt python follow.py

练习

  • 定义 IOPS 是什么。

  • 解释吞吐量和 IOPS 之间的区别是什么。

  • 列出 fdisk 创建分区的一个 parted 没有的限制。

  • 列出可以提供磁盘信息的三个工具。

  • SSH 隧道可以做什么?何时有用?

案例研究问题

  • 使用 molotov 工具创建一个负载测试,测试一个具有 200 HTTP 状态的服务器的 JSON 响应。

第五章:包管理

常常,小脚本在有用性和重要性上不断增长,这造成了需要分享和分发其内容的需求。Python 库以及其他代码项目都需要打包。没有打包,分发代码将变得困难且脆弱。

过了概念验证阶段后,跟踪变更是很有帮助的,广告化变更类型(例如,在引入不兼容更新时),并提供一种让用户依赖特定版本的方式。即使在最简单的使用情况下,遵循一些(打包)指南也是有益的。至少,这应该包括跟踪变更日志和确定版本。

有几种跟随的包管理策略,了解其中一些最常用的可以让您采用最佳选项来解决问题。例如,通过 Python 包索引(PyPI)分发 Python 库可能比将其制作为 Debian 和 RPM 的系统包更容易。如果 Python 脚本需要在特定间隔运行,或者它是长时间运行的进程,那么与systemd一起工作的系统打包可能更好。

尽管systemd不是一个打包工具,但在依赖它管理进程和服务器启动顺序的系统上表现良好。学习如何通过几个systemd配置设置和一些打包处理来处理进程,是进一步增强 Python 项目能力的好方法。

原生 Python 打包工具在包公共托管实例(PyPI)上有一个。然而,对于 Debian 和 RPM 包,需要一些努力提供本地仓库。本章涵盖了几个工具,这些工具使创建和管理包仓库更加容易,包括 PyPI 的本地替代方案。

充分了解不同的打包策略以及健康实践,如适当的版本化和保持变更日志,可以在分发软件时提供稳定一致的体验。

打包为什么重要?

几个因素使软件打包成为项目中的重要特性(无论大小如何!)。通过变更日志跟踪版本和变更是提供新功能和错误修复洞察的好方法。版本化允许其他人更好地确定在项目内可能有效的工作内容。

在尝试识别问题和错误时,一个准确描述变更的变更日志是帮助确定系统故障潜在原因的无价工具。

版本化项目,描述变更在变更日志中,提供其他人安装和使用项目的方式,需要纪律和辛勤工作。然而,在分发、调试、升级甚至卸载时,好处显著。

当不需要打包时

有时候根本不需要将项目分发给其他系统。Ansible Playbooks 通常从一个服务器运行,以管理网络中的其他系统。在像 Ansible 这样的情况下,遵循版本控制并保持变更日志可能就足够了。

像 Git 这样的版本控制系统通过使用标签使这一切变得容易。在 Git 中标记仍然是有用的,如果项目确实需要进行打包,因为大多数工具可以使用标签(只要标签表示一个版本)来生成包。

最近,进行了一个长时间的调试会话,以确定为什么一个大型软件项目的安装程序停止工作。突然间,依赖于安装程序完成部署的一个小 Python 工具的所有功能测试都失败了。安装程序确实有版本,并且保持这些版本与版本控制同步,但却完全没有任何变更日志来解释最近的更改将会破坏现有的 API。为了找出问题,我们不得不浏览所有最近的提交,以确定可能的问题所在。

浏览几个提交应该不难,但尝试在有四千多个提交的项目中做到这一点!找到问题后,开了两个票:一个解释了错误,另一个要求变更日志。

打包指南

在打包之前,有几件事情是值得考虑的,以使整个过程尽可能顺利。即使你不打算打包产品,这些指南也有助于改进项目的整体情况。

注意

版本控制系统始终准备好进行打包。

描述性版本控制

有许多版本软件的方法,但遵循一个知名模式是一个好主意。Python 开发者指南对版本控制有一个清晰的定义。

版本控制模式应该非常灵活,同时要保持一致性,以便安装工具可以理解并相应地优先考虑(例如,稳定版本优于测试版本)。在其最纯粹的形式下,以及大多数 Python 包中,通常使用以下两种变体:major.minormajor.minor.micro

有效的版本看起来像:

  • 0.0.1

  • 1.0

  • 2.1.1

注意

尽管 Python 开发者指南描述了许多变体,但集中在较简单的形式上(如上所列)。它们足以生成包,同时也遵循大多数系统和本地 Python 包的指南。

发布的一个通常被接受的格式是 major.minor.micro(也被 语义化版本控制 方案使用):

  • major 用于不兼容的更改。

  • minor 添加了向后兼容的功能。

  • micro 添加了向后兼容的错误修复。

根据上述列出的版本,您可以推断出对版本为 1.0.0 的应用程序的依赖性可能会在版本 2.0.0 中中断。

一旦做出发布决定,确定版本号就变得很容易。假设当前开发中项目的已发布版本为 1.0.0,这意味着可能出现以下结果:

  • 如果发布具有向后不兼容的更改,则版本号为:2.0.0

  • 如果发布添加的功能不会破坏兼容性,则版本号为 1.1.0

  • 如果发布用于修复不会破坏兼容性的问题,则版本号为 1.0.1

一旦遵循了某个模式,发布过程立即变得清晰明了。尽管希望所有软件都遵循类似的模式,但一些项目有完全不同的自己的模式。例如,Ceph 项目使用以下模式:major.[0|1|2].minor

  • major 表示一个主要发布版本,尽管不一定会破坏向后兼容性。

  • 012 分别表示开发版本、发布候选版或稳定版本。

  • minor 仅用于修复错误,永远不用于功能添加。

该模式意味着 14.0.0 是一个开发版本,而 14.2.1 是主要发布版本(本例中为 14)的稳定版本修复版本。

变更日志

正如我们已经提到的,重要的是要跟踪发布和它们在版本号上的含义。一旦选择了版本控制方案,保持变更日志并不难。虽然它可以是一个单一的文件,但大型项目倾向于将其拆分为一个目录中的多个小文件。最佳实践是使用简单且描述性强、易于维护的格式。

下面的例子是生产中一个 Python 工具的变更日志文件的一个实际部分:

1.1.3
^^^^^
22-Mar-2019

* No code changes - adding packaging files for Debian

1.1.2
^^^^^
13-Mar-2019

* Try a few different executables (not only ``python``) to check for a working
  one, in order of preference, starting with ``python3`` and ultimately falling
  back to the connection interpreter

该示例提供了四个重要的信息:

  1. 最新发布的版本号

  2. 最新版本是否向后兼容

  3. 上一个版本的发布日期

  4. 包含在发布中的变更

该文件不需要特定的格式,只要保持一致性和信息性即可。一个合适的变更日志可以在很少的工作量下提供多个信息。尝试自动化写每个发布的变更日志的任务虽然诱人,但我们建议不要完全自动化处理:没有什么能比一个精心编写的关于修复错误或添加功能的条目更好。

一个质量不佳的自动化变更日志是使用所有版本控制提交的变更。这不是一个好的做法,因为你可以通过列出提交来获得相同的信息。

选择策略

理解所需的分发类型和可用的基础设施服务有助于确定使用何种类型的打包。为其他 Python 项目扩展功能的纯 Python 库适合作为本地 Python 包,托管在 Python 软件包索引(PyPI)或本地索引上。

独立脚本和长时间运行的进程是系统软件包(如 RPM 或 Debian)的良好候选者,但最终取决于可用的系统类型以及是否可能托管(和管理)存储库。对于长时间运行的进程,打包可以有规则来配置systemd单元,使其作为可控制的进程可用。systemd允许优雅地处理启动、停止或重启操作。这些是使用本地 Python 打包无法实现的功能。

一般来说,脚本或进程需要与系统交互得越多,就越适合系统软件包或容器。在编写仅限 Python 的脚本时,传统的 Python 打包是正确的选择。

注意

没有硬性要求选择哪种策略。这取决于情况!选择最适合分发的环境(例如,如果服务器是 CentOS,则选择 RPM)。不同类型的打包并不是互斥的;一个项目可以同时提供多种打包格式。

打包解决方案

本节介绍如何创建软件包并进行托管的详细信息。

为简化代码示例,假设一个名为hello-world的小型 Python 项目具有以下结构:

hello-world
└── hello_world
    ├── __init__.py
    └── main.py

1 directory, 2 files

项目有一个名为hello-world的顶层目录和一个子目录(hello_world),其中包含两个文件。根据打包选择的不同,需要不同的文件来创建软件包。

本地 Python 打包

到目前为止,使用本地 Python 打包工具和托管(通过 PyPI)是最简单的解决方案。与其他打包策略一样,该项目需要一些setuptools使用的文件。

提示

一个简单的方法来获取虚拟环境是创建一个bashzsh的别名,它会 cd 到目录并激活环境,就像这样:alias sugar="source ~/.sugar/bin/activate && cd ~/src/sugar"

要继续,请创建一个新的虚拟环境,然后激活:

$ python3 -m venv /tmp/packaging
$ source /tmp/packaging/bin/activate
注意

setuptools是生成本地 Python 软件包的要求。它是一组工具和助手,用于创建和分发 Python 软件包。

一旦虚拟环境激活,存在以下依赖关系:

setuptools

一组用于打包的实用工具

twine

一个用于注册和上传软件包的工具

通过运行以下命令来安装它们:

$ pip install setuptools twine
注意

要查找已安装内容的一个非常简单的方法是使用IPython和以下代码片段,将所有 Python 软件包列出为JSON数据结构:

In [1]: !pip list --format=json

[{"name": "appnope", "version": "0.1.0"},
 {"name": "astroid", "version": "2.2.5"},
 {"name": "atomicwrites", "version": "1.3.0"},
 {"name": "attrs", "version": "19.1.0"}]

软件包文件

要生成本地 Python 软件包,我们必须添加一些文件。为了保持简单,专注于生成软件包所需的最少文件。描述软件包给setuptools的文件名为setup.py,位于顶层目录。对于示例项目,文件看起来是这样的:

from setuptools import setup, find_packages

setup(
    name="hello-world",
    version="0.0.1",
    author="Example Author",
    author_email="author@example.com",
    url="example.com",
    description="A hello-world example package",
    packages=find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
)

setup.py文件将从setuptools模块导入两个助手:setupfind_packagessetup函数需要关于软件包的详细描述。find_packages函数是一个实用工具,用于自动检测 Python 文件的位置。此外,该文件导入了描述软件包特定方面的classifiers,如许可证、支持的操作系统和 Python 版本。这些classifiers称为trove classifiers,在Python 包索引上有关于其他可用分类器的详细描述。详细描述有助于软件包上传到 PyPI 后被发现。

只需添加这一个文件,我们就能够生成一个软件包,这种情况下是源分发软件包。如果没有README文件,在运行命令时会出现警告。为了防止这种情况,请使用以下命令在顶级目录中添加一个空的README文件:touch README

项目目录的内容应该如下所示:

hello-world
├── hello_world
│   ├── __init__.py
│   └── main.py
└── README
└── setup.py

1 directory, 4 files

要从中生成源分发,请运行以下命令:

python3 setup sdist

输出应该类似于以下内容:

$ python3 setup.py sdist
running sdist
running egg_info
writing hello_world.egg-info/PKG-INFO
writing top-level names to hello_world.egg-info/top_level.txt
writing dependency_links to hello_world.egg-info/dependency_links.txt
reading manifest file 'hello_world.egg-info/SOURCES.txt'
writing manifest file 'hello_world.egg-info/SOURCES.txt'
running check
creating hello-world-0.0.1
creating hello-world-0.0.1/hello_world
creating hello-world-0.0.1/hello_world.egg-info
copying files to hello-world-0.0.1...
copying README -> hello-world-0.0.1
copying setup.py -> hello-world-0.0.1
copying hello_world/__init__.py -> hello-world-0.0.1/hello_world
copying hello_world/main.py -> hello-world-0.0.1/hello_world
Writing hello-world-0.0.1/setup.cfg
Creating tar archive
removing 'hello-world-0.0.1' (and everything under it)

在项目的顶级目录,有一个名为dist的新目录;它包含源分发:一个名为hello-world-0.0.1.tar.gz的文件。如果我们检查目录的内容,它已经再次改变:

hello-world
├── dist
│   └── hello-world-0.0.1.tar.gz
├── hello_world
│   ├── __init__.py
│   └── main.py
├── hello_world.egg-info
│   ├── dependency_links.txt
│   ├── PKG-INFO
│   ├── SOURCES.txt
│   └── top_level.txt
├── README
└── setup.py

3 directories, 9 files

新创建的tar.gz文件是一个可安装的软件包!现在可以将此软件包上传到 PyPI,以便其他人直接从中安装。通过遵循版本模式,安装程序可以请求特定版本(在本例中为0.0.1),并且通过传递给setup()函数的额外元数据,其他工具可以发现它并显示关于它的信息,如作者、描述和版本。

Python 安装工具pip可以直接用于安装tar.gz文件。要试试,请使用文件路径作为参数:

$ pip install dist/hello-world-0.0.1.tar.gz
Processing ./dist/hello-world-0.0.1.tar.gz
Building wheels for collected packages: hello-world
  Building wheel for hello-world (setup.py) ... done
Successfully built hello-world
Installing collected packages: hello-world
Successfully installed hello-world-0.0.1

Python 包索引

Python 包索引(PyPI)是一个 Python 软件的仓库,允许用户托管 Python 软件包并从中安装。它由社区维护,并在Python 软件基金会的赞助和捐款下运行。

注意

此部分需要为 PyPI 的测试实例注册。确保您已经有了账户或者在线注册。您需要账户的用户名和密码来上传软件包。

在示例的setup.py文件中,示例电子邮件地址包含一个占位符。如果要将软件包发布到索引,需要更新为与 PyPI 项目所有者相同的电子邮件地址。更新其他字段,如authorurldescription,以更准确地反映正在构建的项目。

为了确保一切正常运作,并且避免推向生产,通过将包上传到 PyPI 的测试实例来测试包。这个测试实例的行为与生产环境相同,并验证包的正确功能。

setuptoolssetup.py 文件是将包上传到 PyPI 的传统方法。一种新方法叫做 twine,可以简化操作。

在本节开始时,twine 已安装在虚拟环境中。接下来,可以使用它将包上传到 PyPI 的测试实例。以下命令上传 tar.gz 文件,并提示输入用户名和密码:

$ twine upload --repository-url https://test.pypi.org/legacy/ \
  dist/hello-world-0.0.1.tar.gz
Uploading distributions to https://test.pypi.org/legacy/
Enter your username:
Enter your password:

要测试包是否安装成功,我们可以尝试使用 pip 安装它:

$ python3 -m pip install --index-url https://test.pypi.org/simple/ hello-world

命令看起来在 PyPI URL 中有一个空格,但索引 URL 以 /simple/ 结尾,并且 hello-world 是另一个参数,指示要安装的 Python 包的名称。

对于实际的生产发布,需要存在一个帐户或创建一个帐户。与上传到测试实例相同的步骤,包括验证,也适用于真实的 PyPI。

较旧的 Python 打包指南可能会提到如下命令:

 $ python setup.py register
 $ python setup.py upload

这些方法仍然有效,并且是 setuptools 一套工具中的一部分,用于打包并上传项目到包索引中。然而,twine 提供了通过 HTTPS 进行安全认证,并支持使用 gpg 签名。Twine 可以工作,即使 python setup.py upload 无法工作,并最终提供了在上传到索引之前测试包的方法。

最后要指出的一点是,创建一个 Makefile 并在其中放入一个 make 命令可能会有所帮助,自动部署项目并为您构建文档。以下是一个示例:

deploy-pypi:
  pandoc --from=markdown --to=rst README.md -o README.rst
  python setup.py check --restructuredtext --strict --metadata
  rm -rf dist
  python setup.py sdist
  twine upload dist/*
  rm -f README.rst

托管内部包索引

在某些情况下,托管内部 PyPI 可能更可取。

Alfredo 曾经工作的公司拥有不应公开的私有库,因此必须托管一个 PyPI 实例是一个要求。然而,托管也有其注意事项。所有依赖项及其版本都必须存在于实例中;否则,安装可能会失败。安装程序无法同时从不同来源获取依赖项!不止一次,新版本缺少组件,因此必须上传该包以完成正确的安装。

如果包 A 托管在内部,并且依赖于包 BC,那么所有这三个包(及其所需版本)都需要在同一个实例中存在。

内部 PyPI 可以加快安装速度,可以保持包的私密性,并且从本质上来说,并不难实现。

注意

一个强烈推荐的用于托管内部 PyPI 的功能全面的工具是 devpi。它具有镜像、分段、复制和 Jenkins 集成等功能。项目文档提供了很好的示例和详细信息。

首先,创建一个名为pypi的新目录,以便可以创建一个适合托管包的正确结构,然后创建一个名为我们示例包(hello-world)的子目录。子目录的名称即是包的名称:

$ mkdir -p pypi/hello-world
$ tree pypi
pypi
└── hello-world

1 directory, 0 files

现在将tar.gz文件复制到hello-world目录中。该目录结构的最终版本应如下所示:

$ tree pypi
pypi
└── hello-world
    └── hello-world-0.0.1.tar.gz

1 directory, 1 file

下一步是创建一个启用了自动索引的 Web 服务器。Python 自带一个内置的 Web 服务器,足以尝试这一功能,并且默认情况下已启用自动索引!切换到包含hello-world包的pypi目录,并启动内置的 Web 服务器:

$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

在新的终端会话中,创建一个临时虚拟环境,以尝试从本地 PyPI 实例安装hello-world包。激活它,最后通过将pip指向自定义本地 URL 来尝试安装它:

$ python3 -m venv /tmp/local-pypi
$ source /tmp/local-pypi/bin/activate
(local-pypi) $ pip install -i http://localhost:8000/ hello-world
Looking in indexes: http://localhost:8000/
Collecting hello-world
  Downloading http://localhost:8000/hello-world/hello-world-0.0.1.tar.gz
Building wheels for collected packages: hello-world
  Building wheel for hello-world (setup.py) ... done
Successfully built hello-world
Installing collected packages: hello-world
Successfully installed hello-world-0.0.1

在运行http.server模块的会话中,应该有一些日志,展示安装程序为检索hello-world包所做的所有请求:

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
127.0.0.1 [09:58:37] "GET / HTTP/1.1" 200 -
127.0.0.1 [09:59:39] "GET /hello-world/ HTTP/1.1" 200 -
127.0.0.1 [09:59:39] "GET /hello-world/hello-world-0.0.1.tar.gz HTTP/1.1" 200

生产环境需要一个性能更好的 Web 服务器。在这个例子中,为了简单起见,使用http.server模块,但它不能处理同时的多个请求或扩展。

提示

在没有像devpi这样的工具的情况下构建本地索引时,有一个定义明确的规范,其中包括规范化名称的目录结构描述。此规范可以在PEP 503中找到。

Debian 打包

如果目标是 Debian(或基于 Debian 的发行版,如 Ubuntu)来分发项目,则需要额外的文件。了解这些文件是什么,以及 Debian 打包工具如何使用它们,可以改善生成可安装的.deb软件包和解决问题的过程。

一些这些纯文本文件需要非常严格的格式,如果格式稍有不正确,打包将无法安装。

注意

本节假设打包是在 Debian 或基于 Debian 的发行版中进行的,因此更容易安装和使用所需的打包工具。

包文件

Debian 打包需要一个包含几个文件的debian目录。为了缩小生成软件包所需内容的范围,大部分可用选项被跳过,例如在完成构建之前运行测试套件或声明多个 Python 版本。

创建debian目录,其中包含所有必需的文件。最终,hello-world项目结构应如下所示:

$ tree
.
├── debian
├── hello_world
│   ├── __init__.py
│   └── main.py
├── README
└── setup.py

2 directories, 4 files
注意

注意该目录包括本地 Python 打包部分的setup.pyREADME文件。这是必需的,因为 Debian 工具使用它们来生成.deb软件包。

变更日志文件

如果手动操作这个文件会很复杂。当文件格式不正确时产生的错误不容易调试。大多数 Debian 打包工作流依赖于dch工具来增强调试能力。

我之前忽略了我的建议,试图手动创建这个文件。最后我浪费了时间,因为错误报告不是很好,而且很难发现问题。以下是在 changelog 文件中导致问题的条目示例:

--Alfredo Deza <alfredo@example.com> Sat, 11 May 2013 2:12:00 -0800

出现以下错误:

parsechangelog/debian: warning: debian/changelog(l7): found start of entry where
  expected more change data or trailer

你能找到解决方法吗?

-- Alfredo Deza <alfredo@example.com> Sat, 11 May 2013 2:12:00 -0800

在破折号和我的名字之间有一个空格是问题的根源。避免自己的麻烦,使用dch。该工具是devscripts软件包的一部分:

$ sudo apt-get install devscripts

dch命令行工具有很多选项,通过阅读其文档(主页内容详尽)会很有帮助。我们将运行它来首次创建变更日志(这需要一次性使用--create标志)。在运行之前,请导出您的全名和电子邮件,以便它们出现在生成的文件中:

$ export DEBEMAIL="alfredo@example.com"
$ export DEBFULLNAME="Alfredo Deza"

现在运行dch以生成变更日志:

$ dch --package "hello-world" --create -v "0.0.1" \
  -D stable "New upstream release"

新创建的文件应该类似于这样:

hello-world (0.0.1) stable; urgency=medium

  * New upstream release

 -- Alfredo Deza <alfredo@example.com>  Thu, 11 Apr 2019 20:28:08 -0400
注意

Debian 变更日志是专用于 Debian 打包的。当格式不符或需要更新其他信息时,项目可以单独保留一个变更日志。许多项目将 Debian 的 changelog 文件作为单独的 Debian 专用文件。

控制文件

这个文件定义了软件包的名称、描述以及构建和运行项目所需的任何依赖项。它也有严格的格式,但不需要经常更改(不像 changelog)。该文件确保需要 Python 3 并遵循 Debian 的 Python 命名指南。

注意

在从 Python 2 迁移到 Python 3 的过渡中,大多数发行版都采用了以下用于 Python 3 软件包的模式:python3-{package name}

添加依赖项、命名约定和简短描述后,文件应如下所示:

Source: hello-world
Maintainer: Alfredo Deza <alfredo@example.com>
Section: python
Priority: optional
Build-Depends:
 debhelper (>= 11~),
 dh-python,
 python3-all
 python3-setuptools
Standards-Version: 4.3.0

Package: python3-hello-world
Architecture: all
Depends: ${misc:Depends}, ${python3:Depends}
Description: An example hello-world package built with Python 3

其他必需的文件

还需要一些其他文件来生成 Debian 软件包。它们大多数只有几行,并且不经常更改。

rules文件是一个可执行文件,告诉 Debian 如何运行以生成软件包;在这种情况下,应如下所示:

#!/usr/bin/make -f

export DH_VERBOSE=1

export PYBUILD_NAME=remoto

%:
    dh $@ --with python3 --buildsystem=pybuild

compat 文件设置对应的 debhelper(另一个打包工具)兼容性,推荐在这里设置为 10。如果出现错误消息指出需要更高的值,您可能需要检查是否需要更高版本:

$ cat compat
10

缺少许可证可能会导致构建过程无法工作,明确声明许可证是个好主意。此特定示例使用 MIT 许可证,应在 debian/copyright 中如下所示:

Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
Upstream-Name: hello-world
Source: https://example.com/hello-world

Files: *
Copyright: 2019 Alfredo Deza
License: Expat

License: Expat
  Permission is hereby granted, free of charge, to any person obtaining a
  copy of this software and associated documentation files (the "Software"),
  to deal in the Software without restriction, including without limitation
  the rights to use, copy, modify, merge, publish, distribute, sublicense,
  and/or sell copies of the Software, and to permit persons to whom the
  Software is furnished to do so, subject to the following conditions:
  .
  The above copyright notice and this permission notice shall be included in
  all copies or substantial portions of the Software.
  .
  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
  THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
  DEALINGS IN THE SOFTWARE.

最后,在将所有这些新文件添加到debian目录之后,hello-world项目如下所示:

.
├── debian
│   ├── changelog
│   ├── compat
│   ├── control
│   ├── copyright
│   └── rules
├── hello_world
│   ├── __init__.py
│   └── main.py
├── README
└── setup.py

2 directories, 9 files

生成二进制文件

要生成二进制文件,请使用 debuild 命令行工具。对于此示例项目,包仍未签名(签名过程需要 GPG 密钥),并且 debuild 文档使用的示例允许跳过签名。从源代码树内部运行该脚本以仅构建二进制包。此命令适用于 hello-world 项目(此处显示了截断版本):

$ debuild -i -us -uc -b
...
dpkg-deb: building package 'python3-hello-world'
in '../python3-hello-world_0.0.1_all.deb'.
...
 dpkg-genbuildinfo --build=binary
 dpkg-genchanges --build=binary >../hello-world_0.0.1_amd64.changes
dpkg-genchanges: info: binary-only upload (no source code included)
 dpkg-source -i --after-build hello-world-debian
dpkg-buildpackage: info: binary-only upload (no source included)
Now running lintian hello-world_0.0.1_amd64.changes ...
E: hello-world changes: bad-distribution-in-changes-file stable
Finished running lintian.

现在上层目录应该存在一个名为 python3-hello-world_0.0.1_all.deb 的文件。lintian 调用(一个 Debian 打包工具)在最后报告 changelog 文件的发行版本无效,这没关系,因为我们不是针对特定的单个发行版(例如 Debian Buster)。相反,我们正在构建一个包,它很可能在任何符合依赖关系(仅 Python 3,在本例中)的 Debian 基础发行版上安装。

Debian 仓库

有许多工具可以自动化管理 Debian 仓库,但了解如何创建一个是很有用的(阿尔弗雷多甚至帮助开发了一个,适用于 RPM 和 Debian!)。继续之前,请确保之前创建的二进制包在已知位置可用:

$ mkdir /opt/binaries
$ cp python3-hello-world_0.0.1_all.deb /opt/binaries/

对于这一部分,需要安装 reprepro 工具:

$ sudo apt-get install reprepro

在系统的某个位置创建一个新目录以保存包。此示例使用 /opt/repo。仓库的基本配置需要一个名为 distributions 的文件,描述其内容并如下所示:

Codename: sid
Origin: example.com
Label: example.com
Architectures: amd64 source
DscIndices: Sources Release .gz .bz2
DebIndices: Packages Release . .gz .bz2
Components: main
Suite: stable
Description: example repo for hello-world package
Contents: .gz .bz2

将此文件保存在 /opt/repo/conf/distributions。创建另一个目录来保存实际的仓库:

$ mkdir /opt/repo/debian/sid

要创建仓库,请指示 reprepro 使用先前创建的 distributions 文件,并指定基本目录为 /opt/repo/debian/sid。最后,将之前创建的二进制文件添加为 Debian sid 发行版的目标:

$ reprepro --confdir /opt/repo/conf/distributions -b /opt/repo/debian/sid \
  -C main includedeb sid /opt/binaries/python3-hello-world_0.0.1_all.deb
Exporting indices...

此命令为 Debian sid 发行版创建了仓库!这个命令可以用于不同的基于 Debian 的发行版,如 Ubuntu Bionic。要做到这一点,只需将 sid 替换为 bionic

现在仓库已创建,下一步是确保其按预期工作。在生产环境中,像 Apache 或 Nginx 这样的稳健 Web 服务器是一个不错的选择,但为了测试,请使用 Python 的 http.server 模块。切换到包含仓库的目录,并启动服务器:

$ cd /opt/repo/debian/sid
$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

Aptitude(或 apt,Debian 包管理器)需要一些配置来知道包的新位置。这个配置是一个简单的文件,其中只有一行指向我们仓库的 URL 和组件。在 /etc/apt/sources.lists.d/hello-world.list 创建一个文件,应如下所示:

$ cat /etc/apt/sources.list.d/hello-world.list
deb [trusted=yes] http://localhost:8000/ sid main

[trusted=yes] 配置告诉 apt 不强制要求已签名的包。在正确签名的仓库上,这一步骤是不必要的。

添加文件后,请更新 apt 以使其识别新位置,并查找(并安装)hello-world 包:

$ sudo apt-get update
Ign:1 http://localhost:8000 sid InRelease
Get:2 http://localhost:8000 sid Release [2,699 B]
Ign:3 http://localhost:8000 sid Release.gpg
Get:4 http://localhost:8000 sid/main amd64 Packages [381 B]
Get:5 http://localhost:8000 sid/main amd64 Contents (deb) [265 B]
Fetched 3,345 B in 1s (6,382 B/s)
Reading package lists... Done

搜索 python3-hello-world 软件包提供在配置 reprepro 时添加到 distributions 文件的描述:

$ apt-cache search python3-hello-world
python3-hello-world - An example hello-world package built with Python 3

安装和删除软件包应该没有问题:

$ sudo apt-get install python3-hello-world
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following NEW packages will be installed:
  python3-hello-world
0 upgraded, 1 newly installed, 0 to remove and 48 not upgraded.
Need to get 2,796 B of archives.
Fetched 2,796 B in 0s (129 kB/s)
Selecting previously unselected package python3-hello-world.
(Reading database ... 242590 files and directories currently installed.)
Preparing to unpack .../python3-hello-world_0.0.1_all.deb ...
Unpacking python3-hello-world (0.0.1) ...
Setting up python3-hello-world (0.0.1) ...
$ sudo apt-get remove --purge python3-hello-world
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be REMOVED:
  python3-hello-world*
0 upgraded, 0 newly installed, 1 to remove and 48 not upgraded.
After this operation, 19.5 kB disk space will be freed.
Do you want to continue? [Y/n] Y
(Reading database ... 242599 files and directories currently installed.)
Removing python3-hello-world (0.0.1) ...

RPM 打包

就像在 Debian 打包时一样,在 RPM 中工作时,必须已经完成本地 Python 打包。应该可以使用 setup.py 文件生成 Python 软件包。然而,与 Debian 不同的是,RPM 打包只需一个文件即可:spec 文件。如果目标是像 CentOS 或 Fedora 这样的发行版,那么 RPM 软件包管理器(前身是 Red Hat 软件包管理器)是最佳选择。

规范文件

在其最简单的形式中,spec 文件(本示例中命名为 hello-world.spec)并不难理解,大多数部分都是不言自明的。甚至可以通过使用 setuptools 来生成它:

$ python3 setup.py bdist_rpm --spec-only
running bdist_rpm
running egg_info
writing hello_world.egg-info/PKG-INFO
writing dependency_links to hello_world.egg-info/dependency_links.txt
writing top-level names to hello_world.egg-info/top_level.txt
reading manifest file 'hello_world.egg-info/SOURCES.txt'
writing manifest file 'hello_world.egg-info/SOURCES.txt'
writing 'dist/hello-world.spec'

dist/hello-world.spec 输出文件应该类似于这样:

%define name hello-world
%define version 0.0.1
%define unmangled_version 0.0.1
%define release 1

Summary: A hello-world example pacakge
Name: %{name}
Version: %{version}
Release: %{release}
Source0: %{name}-%{unmangled_version}.tar.gz
License: MIT
Group: Development/Libraries
BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-buildroot
Prefix: %{_prefix}
BuildArch: noarch
Vendor: Example Author <author@example.com>
Url: example.com

%description
A Python3 hello-world package

%prep
%setup -n %{name}-%{unmangled_version} -n %{name}-%{unmangled_version}

%build
python3 setup.py build

%install
python3 setup.py install --single-version-externally-managed -O1 \
--root=$RPM_BUILD_ROOT --record=INSTALLED_FILES

%clean
rm -rf $RPM_BUILD_ROOT

%files -f INSTALLED_FILES
%defattr(-,root,root)

尽管看起来很简单,但已经存在潜在问题:版本是

set setuptools的集成非常有利,允许进一步修改该文件(如果需要),并将其复制到项目的根目录以便永久存放。一些项目使用基础模板,在构建过程中用填充方式生成规范文件。如果遵循严格的发布工作流程,这个过程非常有用。在 [Ceph 项目](https://ceph.com) 的情况下,通过版本控制(Git)标记发布,并使用标签在Makefile` 中应用于模板。值得注意的是,还存在其他方法可以进一步自动化这个过程。

提示

生成 spec 文件并不总是有用,因为某些部分可能需要硬编码以遵循某些发行规则或特定依赖项,这些依赖项不在生成的文件中。在这种情况下,最好生成一次,进一步配置并最终保存它,使 spec 文件成为项目的正式一部分。

生成二进制文件

有几种不同的工具可以生成 RPM 二进制文件;其中一种特别的工具是 rpmbuild 命令行工具:

$ sudo yum install rpm-build
注意

命令行工具是 rpmbuild,但软件包名是 rpm-build,因此请确保在终端中可用 rpmbuild(命令行工具)。

rpmbuild 需要一个目录结构来创建二进制文件。在创建这些目录之后,source 文件(由 setuptools 生成的 tar.gz 文件)需要存在于 SOURCES 目录中。这就是应该创建的结构以及完成后的样子:

$ mkdir -p /opt/repo/centos/{SOURCES,SRPMS,SPECS,RPMS,BUILD}
$ cp dist/hello-world-0.0.1.tar.gz /opt/repo/centos/SOURCES/
$ tree /opt/repo/centos
/opt/repo/centos
├── BUILD
├── BUILDROOT
├── RPMS
├── SOURCES
│   └── hello-world-0.0.1.tar.gz
├── SPECS
└── SRPMS

6 directories, 1 file

目录结构始终是必需的,默认情况下,rpmbuild需要在主目录中。为了保持隔离,使用不同位置(在/opt/repo/centos中)。这个过程意味着配置rpmbuild使用此目录代替。此过程使用-ba标志生成二进制和软件包(输出已缩写):

$ rpmbuild -ba --define "_topdir /opt/repo/centos"  dist/hello-world.spec
...
Executing(%build): /bin/sh -e /var/tmp/rpm-tmp.CmGOdp
running build
running build_py
creating build
creating build/lib
creating build/lib/hello_world
copying hello_world/main.py -> build/lib/hello_world
copying hello_world/__init__.py -> build/lib/hello_world
Executing(%install): /bin/sh -e /var/tmp/rpm-tmp.CQgOKD
+ python3 setup.py install --single-version-externally-managed \
-O1 --root=/opt/repo/centos/BUILDROOT/hello-world-0.0.1-1.x86_64
running install
writing hello_world.egg-info/PKG-INFO
writing dependency_links to hello_world.egg-info/dependency_links.txt
writing top-level names to hello_world.egg-info/top_level.txt
reading manifest file 'hello_world.egg-info/SOURCES.txt'
writing manifest file 'hello_world.egg-info/SOURCES.txt'
running install_scripts
writing list of installed files to 'INSTALLED_FILES'
Processing files: hello-world-0.0.1-1.noarch
Provides: hello-world = 0.0.1-1
Wrote: /opt/repo/centos/SRPMS/hello-world-0.0.1-1.src.rpm
Wrote: /opt/repo/centos/RPMS/noarch/hello-world-0.0.1-1.noarch.rpm
Executing(%clean): /bin/sh -e /var/tmp/rpm-tmp.gcIJgT
+ umask 022
+ cd /opt/repo/centos//BUILD
+ cd hello-world-0.0.1
+ rm -rf /opt/repo/centos/BUILDROOT/hello-world-0.0.1-1.x86_64
+ exit 0

/opt/repo/centos的目录结构将有许多新文件,但我们只对具有noarch RPM 的文件感兴趣:

$ tree /opt/repo/centos/RPMS
/opt/repo/centos/RPMS
└── noarch
    └── hello-world-0.0.1-1.noarch.rpm

1 directory, 1 file

noarch RPM 是一个可安装的 RPM 包!该工具还生成了其他有用的可以发布的软件包(例如查看/opt/repo/centos/SRPMS)。

RPM 存储库

要创建 RPM 存储库,请使用createrepo命令行工具。它从给定目录中找到的二进制文件生成存储库元数据(基于 XML 的 RPM 元数据)。在此部分中,创建(并托管)noarch二进制文件:

$ sudo yum install createrepo

在生成noarch包的相同位置创建存储库,或者使用新的(干净的)目录。如有必要,创建新的二进制文件。一旦完成,包括如下内容:

$ mkdir -p /var/www/repos/centos
$ cp -r /opt/repo/centos/RPMS/noarch /var/www/repos/centos

要创建元数据,请运行createrepo工具:

$ createrepo -v /var/www/repos/centos/noarch
Spawning worker 0 with 1 pkgs
Worker 0: reading hello-world-0.0.1-1.noarch.rpm
Workers Finished
Saving Primary metadata
Saving file lists metadata
Saving other metadata
Generating sqlite DBs
Starting other db creation: Thu Apr 18 09:13:35 2019
Ending other db creation: Thu Apr 18 09:13:35 2019
Starting filelists db creation: Thu Apr 18 09:13:35 2019
Ending filelists db creation: Thu Apr 18 09:13:35 2019
Starting primary db creation: Thu Apr 18 09:13:35 2019
Ending primary db creation: Thu Apr 18 09:13:35 2019
Sqlite DBs complete

虽然不存在x86_64包,但为了避免yum后续投诉,重复对此新目录调用createrepo

$ mkdir /var/www/repos/centos/x86_64
$ createrepo -v /var/www/repos/centos/x86_64

我们将使用http.server模块通过 HTTP 提供此目录的服务:

$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

要访问此存储库,yum需要配置repo 文件。在/etc/yum.repos.d/hello-world.repo创建一个。它应该如下所示:

[hello-world]
name=hello-world example repo for noarch packages
baseurl=http://0.0.0.0:8000/$basearch
enabled=1
gpgcheck=0
type=rpm-md
priority=1

[hello-world-noarch]
name=hello-world example repo for noarch packages
baseurl=http://0.0.0.0:8000/noarch
enabled=1
gpgcheck=0
type=rpm-md
priority=1

注意gpgcheck值为0。这表示我们尚未签署任何软件包,yum不应尝试验证签名,以防止此示例中的故障。现在应该可以搜索软件包,并在输出的一部分中获取描述:

$ yum --enablerepo=hello-world search hello-world
Loaded plugins: fastestmirror, priorities
Loading mirror speeds from cached hostfile
 * base: reflector.westga.edu
 * epel: mirror.vcu.edu
 * extras: mirror.steadfastnet.com
 * updates: mirror.mobap.edu
base                                                                   | 3.6 kB
extras                                                                 | 3.4 kB
hello- world                                                           | 2.9 kB
hello-world-noarch                                                     | 2.9 kB
updates                                                                | 3.4 kB
8 packages excluded due to repository priority protections
===============================================================================
matched: hello-world
===============================================================================
hello-world.noarch : A hello-world example pacakge

搜索功能正常工作;安装软件包也应正常工作:

$ yum --enablerepo=hello-world install hello-world
Loaded plugins: fastestmirror, priorities
Loading mirror speeds from cached hostfile
 * base: reflector.westga.edu
 * epel: mirror.vcu.edu
 * extras: mirror.steadfastnet.com
 * updates: mirror.mobap.edu
8 packages excluded due to repository priority protections
Resolving Dependencies
--> Running transaction check
---> Package hello-world.noarch 0:0.0.1-1 will be installed
--> Finished Dependency Resolution

Dependencies Resolved
Installing:
 hello-world          noarch          0.0.1-1            hello-world-noarch

Transaction Summary
Install  1 Package

Total download size: 8.1 k
Installed size: 1.3 k
Downloading packages:
hello-world-0.0.1-1.noarch.rpm                                         | 8.1 kB
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : hello-world-0.0.1-1.noarch
  Verifying  : hello-world-0.0.1-1.noarch

Installed:
  hello-world.noarch 0:0.0.1-1

Complete!

移除也必须正常工作:

$ yum remove hello-world
Loaded plugins: fastestmirror, priorities
Resolving Dependencies
--> Running transaction check
---> Package hello-world.noarch 0:0.0.1-1 will be erased
--> Finished Dependency Resolution

Dependencies Resolved
Removing:
 hello-world          noarch          0.0.1-1           @hello-world-noarch

Transaction Summary
Remove  1 Package

Installed size: 1.3 k
Is this ok [y/N]: y
Downloading packages:
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Erasing    : hello-world-0.0.1-1.noarch
  Verifying  : hello-world-0.0.1-1.noarch
Removed:
  hello-world.noarch 0:0.0.1-1
Complete!

http.server模块应显示一些活动,证明yum正在获取hello-world包:

[18/Apr/2019 03:37:24] "GET /x86_64/repodata/repomd.xml HTTP/1.1"
[18/Apr/2019 03:37:24] "GET /noarch/repodata/repomd.xml HTTP/1.1"
[18/Apr/2019 03:37:25] "GET /x86_64/repodata/primary.sqlite.bz2 HTTP/1.1"
[18/Apr/2019 03:37:25] "GET /noarch/repodata/primary.sqlite.bz2 HTTP/1.1"
[18/Apr/2019 03:56:49] "GET /noarch/hello-world-0.0.1-1.noarch.rpm HTTP/1.1"

使用 systemd 进行管理

systemd是 Linux 的系统和服务管理器(也称为init 系统)。它是许多发行版的默认 init 系统,如 Debian 和 Red Hat。以下是systemd提供的众多功能之一:

  • 简单并行化

  • 钩子和触发器用于按需行为

  • 日志集成

  • 能够依赖于其他单元以编排复杂的启动

还有许多其他激动人心的systemd功能,例如网络、DNS,甚至是设备挂载。在 Python 中轻松处理进程的想法一直是具有挑战性的;曾经有几个类似init的 Python 项目供选择,都有各自的配置和处理 API。使用systemd允许可移植性,并使与他人协作变得容易,因为它广泛可用。

提示

Python 中两个知名的进程处理程序是 supervisordcircus

不久前,Alfredo 写了一个小的 Python HTTP API,需要投入生产使用。该项目已从 supervisord 迁移到 circus,一切都运行正常。不幸的是,生产环境的限制意味着需要将 systemd 与操作系统集成。由于 systemd 相对较新,过渡过程很粗糙,但一旦一切就绪,我们就从中受益,可以在开发周期的早期阶段进行类似生产环境的处理,并更早地捕获集成问题。当 API 进入发布时,我们已经对 systemd 感到满意,可以排除问题,甚至调整配置以应对外部问题。(你有没有见过由于网络未运行而导致 init 脚本失败的情况?)

在本节中,我们构建了一个小型 HTTP 服务,它在系统启动时需要可用,并且可以在任何时刻重新启动。单元配置处理日志记录,并确保在尝试启动之前可用特定的系统资源。

长期运行的进程

那些应该一直运行的进程非常适合使用 systemd 进行处理。想想 DNS 或邮件服务器的工作原理;这些都是一直在运行的程序,它们需要一些处理来捕获日志或在配置更改时重新启动。

我们将使用一个基于 Pecan web framework 的小型 HTTP API 服务器。

注意

本节对 Pecan 的工作方式没有特别说明,因此示例可以用于其他框架或长期运行的服务。

设置

选择一个永久位置用于项目,在 /opt/http 创建一个目录,然后创建一个新的虚拟环境并安装 Pecan 框架:

$ mkdir -p /opt/http
$ cd /opt/http
$ python3 -m venv .
$ source bin/activate
(http) $ pip install "pecan==1.3.3"

Pecan 有一些内置的辅助工具,可以为示例项目创建必要的文件和目录。Pecan 可以用于创建一个基本的“香草”HTTP API 项目,将其连接到 systemd。版本1.3.3 有两个选项:baserest-api

$ pecan create api rest-api
Creating /opt/http/api
Recursing into +package+
  Creating /opt/http/api/api
...
Copying scaffolds/rest-api/config.py_tmpl to /opt/http/api/config.py
Copying scaffolds/rest-api/setup.cfg_tmpl to /opt/http/api/setup.cfg
Copying scaffolds/rest-api/setup.py_tmpl to /opt/http/api/setup.py
小贴士

使用一致的路径非常重要,因为稍后在使用 systemd 配置服务时会用到它。

通过包含项目脚手架,我们现在可以毫不费力地拥有一个完全功能的项目。它甚至有一个setup.py文件,里面包含了所有内容,可以立即成为一个原生的 Python 包!让我们安装该项目,以便运行它:

(http) $ python setup.py install
running install
running bdist_egg
running egg_info
creating api.egg-info
...
creating dist
creating 'dist/api-0.1-py3.6.egg' and adding 'build/bdist.linux-x86_64/egg'
removing 'build/bdist.linux-x86_64/egg' (and everything under it)
Processing api-0.1-py3.6.egg
creating /opt/http/lib/python3.6/site-packages/api-0.1-py3.6.egg
Extracting api-0.1-py3.6.egg to /opt/http/lib/python3.6/site-packages
...
Installed /opt/http/lib/python3.6/site-packages/api-0.1-py3.6.egg
Processing dependencies for api==0.1
Finished processing dependencies for api==0.1

pecan 命令行工具需要一个配置文件。配置文件已经由脚手架为您创建,并保存在顶级目录中。使用 config.py 文件启动服务器:

(http) $ pecan serve config.py
Starting server in PID 17517
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080

在浏览器上进行测试应该会产生一个纯文本消息。这是使用 curl 命令显示的方式:

(http) $ curl localhost:8080
Hello, World!

长时间运行的进程从 pecan serve config.py 开始。唯一停止此进程的方法是使用 Control-C 发送 KeyboardInterrupt。重新启动需要激活虚拟环境,再次运行相同的 pecan serve 命令。

systemd 单元文件

与旧的初始化系统不同,它使用可执行脚本,systemd 使用纯文本文件。单元文件的最终版本如下所示:

[Unit]
Description=hello world pecan service
After=network.target

[Service]
Type=simple
ExecStart=/opt/http/bin/pecan serve /opt/http/api/config.py
WorkingDirectory=/opt/http/api
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

将此文件保存为 hello-world.service。稍后会将其复制到最终目标位置。

确保获取所有部分名称和配置指令的准确性非常重要,因为它们都区分大小写。如果名称不完全匹配,事情将无法正常工作。让我们详细讨论 HTTP 服务的每个部分:

单元

提供了描述并包括一个 After 指令,告诉 systemd 此服务单元在启动之前需要具备操作网络环境。其他单元可能有更复杂的要求,不仅仅是启动服务,甚至是启动后ConditionWants 是其他非常有用的指令。

服务

当配置 service 单元时才需要此部分。默认为 Type=simple。此类服务不应分叉,它们必须保持在前台,以便 systemd 可以处理它们的操作。 ExecStart 行解释了命令应如何运行以启动服务。使用绝对路径是至关重要的,以避免找不到正确文件的问题。

虽然不是必需的,我已包含 WorkingDirectory 指令,以确保进程在应用程序所在的相同目录中。如果以后有任何更新,它可能会因为已经处于与应用程序相关的位置而受益。

StandardOutputStandardError 指令非常好用,并展示了 systemd 在这方面的强大功能。它会通过 systemd 机制处理所有通过 stdoutstderr 发出的日志。在我们解释如何与服务交互时,我们将进一步演示这一点。

安装

WantedBy 指令解释了一旦启用,此单元如何处理。 multi-user.target 相当于 runlevel 3(服务器启动到终端的正常运行级别)。这种类型的配置允许系统确定启用后的行为方式。一旦启用,会在 multi-user.target.wants 目录中创建一个符号链接。

安装单元

配置文件本身必须放置在特定位置,以便 systemd 能够找到并加载它。支持多种位置,但 /etc/systemd/system 是由管理员创建或管理的单元所用的位置。

提示

确保 ExecStart 指令与这些路径配合正常非常有用。使用绝对路径可以减少引入拼写错误的机会。要验证,请在终端中运行整行命令,并查找类似于以下输出:

$ /opt/http/bin/pecan serve /opt/http/api/config.py
Starting server in PID 20621
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080

验证命令是否有效后,使用hello-world.service作为名称将单元文件复制到此目录:

$ cp hello-world.service /etc/systemd/system/

放置后,需要重新加载systemd以使其意识到这个新单元:

$ systemctl daemon-reload

服务现在完全可用并可以启动和停止。通过使用status子命令进行验证。让我们看看systemd是否识别它。这是其应有的行为以及输出的样子:

$ systemctl status hello-world
● hello-world.service - hello world pecan service
   Loaded: loaded (/etc/systemd/system/hello-world.service; disabled; )
   Active: inactive (dead)

由于服务未运行,看到它被报告为dead并不奇怪。接下来启动服务,并再次检查状态(curl应该报告端口8080上没有运行任何内容):

$ curl localhost:8080
curl: (7) Failed to connect to localhost port 8080: Connection refused
$ systemctl start hello-world
$ systemctl status hello-world
● hello-world.service - hello world pecan service
   Loaded: loaded (/etc/systemd/system/hello-world.service; disabled; )
   Active: active (running) since Tue 2019-04-23 13:44:20 EDT; 5s ago
 Main PID: 23980 (pecan)
    Tasks: 1 (limit: 4915)
   Memory: 20.1M
   CGroup: /system.slice/hello-world.service
           └─23980 /opt/http/bin/python /opt/http/bin/pecan serve config.py

Apr 23 13:44:20 huando systemd[1]: Started hello world pecan service.

服务正在运行且完全可操作。再次验证端口8080上的服务,确保框架正在运行并响应请求:

$ curl localhost:8080
Hello, World!

如果使用systemctl stop hello-world停止服务,那么curl命令将再次报告连接失败。

到目前为止,我们已经创建并安装了该单元,通过启动和停止服务验证了其工作,并检查了 Pecan 框架是否在其默认端口上响应请求。如果服务器在任何时候重新启动,您希望此服务处于运行状态,这就是Install部分发挥作用的地方。让我们enable该服务:

$ systemctl enable hello-world
Created symlink hello-world.service → /etc/systemd/system/hello-world.service.

当服务器重新启动时,小型 HTTP API 服务将会启动并运行。

日志处理

由于这是一个配置了日志记录配置(所有stdoutstderr都直接进入systemd)的服务,处理工作是免费的。无需配置基于文件的日志记录、旋转或甚至过期。systemd提供了一些有趣且非常好用的功能,允许您与日志交互,例如限制时间范围和按单元或进程 ID 过滤。

注意

与单元日志交互的命令通过journalctl命令行工具完成。如果期望systemd提供另一个子命令来提供日志助手,则这个过程可能会有所不同。

由于我们在前一节中启动了服务并通过curl发送了一些请求给它,让我们看看日志显示了什么:

$ journalctl -u hello-world
-- Logs begin at Mon 2019-04-15 09:05:11 EDT, end at Tue 2019-04-23
Apr 23 13:44:20 srv1 systemd[1]: Started hello world pecan service.
Apr 23 13:44:44 srv1 pecan[23980] [INFO    ] [pecan.commands.serve] GET / 200
Apr 23 13:44:55 srv1 systemd[1]: Stopping hello world pecan service...
Apr 23 13:44:55 srv1 systemd[1]: hello-world.service: Main process exited
Apr 23 13:44:55 srv1 systemd[1]: hello-world.service: Succeeded.
Apr 23 13:44:55 srv1 systemd[1]: Stopped hello world pecan service.

-u标志指定了单元,在本例中是hello-world,但您也可以使用模式或甚至指定多个单元。

跟踪生成条目的日志的常见方法是使用tail命令。具体来说,如下所示:

$ tail -f pecan-access.log

使用journalctl执行相同操作的命令看起来略有不同,但它的工作方式相同

$ journalctl -fu hello-world
Apr 23 13:44:44 srv1 pecan[23980][INFO][pecan.commands.serve] GET / 200
Apr 23 13:44:44 srv1 pecan[23980][INFO][pecan.commands.serve] GET / 200
Apr 23 13:44:44 srv1 pecan[23980][INFO][pecan.commands.serve] GET / 200
提示

如果systemd包使用了pcre2引擎可用,它允许您使用--grep。这进一步基于模式过滤日志条目。

-f 标志意味着 跟随 日志,并从最近的条目开始,并继续显示它们的进展,就像 tail -f 一样。在生产环境中,日志可能太多,并且可能已经出现了错误 today。在这些情况下,您可以结合使用 --since--until。这两个标志都接受几种不同类型的参数:

  • today

  • yesterday

  • "3 hours ago"

  • -1h

  • -15min

  • -1h35min

在我们的小例子中,journalctl 在过去的 15 分钟内无法找到任何内容。在输出的开头,它通知我们范围,并生成条目(如果有的话):

$  journalctl -u hello-world --since "-15min"
-- Logs begin at Mon 2019-04-15 09:05:11 EDT, end at Tue 2019-04-23
-- No entries --

练习

  • 使用三个不同的命令通过 journalctl 获取 systemd 的日志输出。

  • 解释 systemd 单元的 WorkingDirectory 配置选项是用来做什么的。

  • 为什么 changelog 很重要?

  • setup.py 文件的作用是什么?

  • 列出 Debian 和 RPM 包之间的三个不同之处。

案例研究问题

  • 使用 devpi 创建 PyPI 的本地实例,上传一个 Python 包,然后尝试从本地的 devpi 实例安装该 Python 包。

第六章:连续集成和连续部署

作者:Noah

连续集成(CI)和连续部署(CD)的实践对现代软件开发生命周期过程至关重要。CI 系统从诸如 GitHub 这样的源代码控制系统中克隆软件的代码库,将软件构建为可以是二进制、tar 归档或 Docker 镜像的构件,并且非常重要的是,还运行软件的单元测试和/或集成测试。CD 系统将由 CI 系统构建的构件部署到目标环境中。这种部署可以自动化处理非生产环境,但通常在生产环境中包括手动批准步骤。此类系统的更高级别是连续交付平台,该平台自动化生产部署步骤,并且能够基于从监控和日志平台获取的指标回滚部署。

真实案例研究:将维护不善的 WordPress 网站转换为 Hugo

不久之前,一个朋友请求帮忙修复他们公司的网站。该公司销售价格昂贵的二手科学设备,其库存通过一个经常被黑客攻击、性能糟糕或者经常宕机的 WordPress 站点提供。通常我会避免卷入这样的项目,但因为是朋友,我决定帮忙。你可以在这个Git 仓库中参考转换项目的代码。

GitHub 仓库中覆盖了转换过程的每一个步骤。这些步骤包括:

  1. 备份

  2. 转换

  3. 升级

  4. 部署

注意

故事有个有趣的结局。在创建了一个坚不可摧的、性能惊人的、安全的、自动部署的、以及 SEO 无敌的“坦克”式网站后,它在多年间零漏洞、零宕机地运行着。在我早已忘记这个项目的时候,我收到了朋友的一条短信。我已经有几年没和他联系了。他说网站挂了,需要我的帮助。

我回复他问如何可能的时候发了个短信。这是在 Amazon S3 上运行的,有 99.999999999%的正常运行时间。他回复说最近又把它转回 WordPress 了,因为“更容易”修改。我笑了,并告诉他我不适合他的项目。俗话说,善有善报。

我考虑的一些需求包括:

  • 它需要进行持续部署。

  • 它需要快速运行和开发!

  • 它应该是由云提供商托管的静态站点。

  • 应该有一个合理的工作流程,可以从 WordPress 进行转换。

  • 应该可以使用 Python 创建一个合理的搜索界面。

最终,我决定使用HugoAWS,以及Algolia。整体架构看起来像图 6-1。

pydo 0601

图 6-1 连续部署与 Hugo

设置 Hugo

开始使用 Hugo 非常简单(参见Hugo 入门指南)。首先,安装软件。在我的 OS X 机器上,我是这样做的:

brew install hugo

如果您已经安装了 Hugo,您可能需要升级:

Error: hugo 0.40.3 is already installed
To upgrade to 0.57.2, run brew upgrade hugo.

如果您使用另一个平台,可以在这里的说明中进行跟随。要验证一切是否正常工作,请运行hugo version

(.python-devops) ➜  ~ hugo version
Hugo Static Site Generator v0.57.2/extended darwin/amd64 BuildDate: unknown

唯一剩下的事情是初始化一个骨架 Hugo 应用程序并安装一个主题:

hugo new site quickstart

这将创建一个名为quickstart的新网站。您可以通过运行hugo非常快地再次构建此站点。这会将 Markdown 文件编译为 HTML 和 CSS。

将 WordPress 转换为 Hugo 文章

接下来,我通过原始转储将 WordPress 数据库转换为 JSON。然后,我编写了一个 Python 脚本,将这些数据转换为 Markdown 格式的 Hugo 文章。以下是该代码:

"""Conversion code of old database fields into markdown example.

If you did a database dump of WordPress and then converted it to JSON, you could
tweak this."""

import os
import shutil
from category import CAT
from new_picture_products import PICTURES

def check_all_category():
  ares = {}
  REC = []
  for pic in PICTURES:
    res  = check_category(pic)
    if not res:
      pic["categories"] = "Other"
      REC.append(pic)
      continue

    title,key = res
    if key:
      print("FOUND MATCH: TITLE--[%s], CATEGORY--[%s]" %\
        (title, key))
      ares[title]= key
      pic["categories"] = key
      REC.append(pic)
  return ares, REC

def check_category(rec):

  title = str(rec['title'])
  for key, values in CAT.items():
    print("KEY: %s, VALUE: %s" % (key, values))
    if title in key:
      return title,key
    for val in values:
      if title in val:
        return title,key

def move_image(val):
  """Creates a new copy of the uploaded images to img dir"""

  source_picture = "static/uploads/%s" % val["picture"]
  destination_dir = "static/img/"
  shutil.copy(source_picture, destination_dir)

def new_image_metadata(vals):
  new_paths = []
  for val in vals:
    pic = val['picture'].split("/")[-1:].pop()
    destination_dir = "static/img/%s" % pic
    val['picture'] = destination_dir
    new_paths.append(val)
  return new_paths

CAT_LOOKUP = {'2100': 'Foo',
 'a': 'Biz',
 'b': 'Bam',
 'c': 'Bar',
 '1': 'Foobar',
 '2': 'bizbar',
 '3': 'bam'}

def write_post(val):

    tags = val["tags"]
    date = val["date"]
    title = val["title"]
    picture = val["picture"]
    categories = val["categories"]
    out = """
+++
tags = ["%s"]
categories = ["%s"]
date = "%s"
title = "%s"
banner = "%s"
+++
![%s](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/%s)
 **Product Name**: %s""" %\
 (tags, categories, date, title, picture.lstrip("/"),
   title, picture, picture, title)

    filename = "../content/blog/%s.md" % title
    if os.path.exists(filename):
        print("Removing: %s" % filename)
        os.unlink(filename)

    with open(filename, 'a') as the_file:
        the_file.write(out)

if __name__ == '__main__':
    from new_pic_category import PRODUCT
    for product in PRODUCT:
        write_post(product)

创建 Algolia 索引并更新它

将数据库产品转换为 Markdown 文章后,下一步是编写一些 Python 代码来创建 Algolia 索引并将其同步。Algolia是一个很好的工具,因为它可以快速解决搜索引擎问题,并且还具有很好的 Python 支持。

此脚本遍历所有 Markdown 文件并生成一个可上传到 Algolia 的搜索索引:

"""
Creates a very simple JSON index for Hugo to import into Algolia. Easy to extend.

#might be useful to run this on content directory to remove spaces
for f in *\ *; do mv "$f" "${f// /_}"; done

"""
import os
import json

CONTENT_ROOT = "../content/products"
CONFIG = "../config.toml"
INDEX_PATH = "../index.json"

def get_base_url():
    for line in open(CONFIG):
        if line.startswith("baseurl"):
            url = line.split("=")[-1].strip().strip('""')
            return url

def build_url(base_url, title):

    url = "<a href='%sproducts/%s'>%s</a>" %\
         (base_url.strip(), title.lower(), title)
    return url

def clean_title(title):
    title_one = title.replace("_", " ")
    title_two = title_one.replace("-", " ")
    title_three = title_two.capitalize()
    return title_three

def build_index():
    baseurl = get_base_url()
    index =[]
    posts = os.listdir(CONTENT_ROOT)
    for line in posts:
        print("FILE NAME: %s" % line)
        record = {}
        title = line.strip(".md")
        record['url'] = build_url(baseurl, title)
        record['title'] = clean_title(title)
        print("INDEX RECORD: %s" % record)
        index.append(record)
    return index

def write_index():
    index = build_index()
    with open(INDEX_PATH, 'w') as outfile:
        json.dump(index,outfile)

if __name__ == '__main__':
    write_index()

最后,可以使用以下片段将索引发送到 Algolia:

import json
from algoliasearch import algoliasearch

def update_index():
    """Deletes index, then updates it"""
    print("Starting Updating Index")
    client = algoliasearch.Client("YOUR_KEY", "YOUR_VALUE")
    index = client.init_index("your_INDEX")
    print("Clearing index")
    index.clear_index()
    print("Loading index")
    batch = json.load(open('../index.json'))
    index.add_objects(batch)

if __name__ == '__main__':
    update_index()

使用 Makefile 进行编排

使用Makefile可以复制后续部署过程中使用的步骤。我通常设置一个Makefile在本地编排这些步骤。以下是整个构建和部署过程的样子:

build:
  rm -rf public
  hugo

watch: clean
  hugo server -w

create-index:
  cd algolia;python make_algolia_index.py;cd ..

update-index:
  cd algolia;python sync_algolia_index.py;cd ..

make-index: create-index update-index

clean:
  -rm -rf public

sync:
  aws s3 --profile <yourawsprofile> sync --acl \
    "public-read" public/ s3://example.com

build-deploy-local: build sync

all: build-deploy-local

使用 AWS CodePipeline 部署

Amazon Web Services(AWS)是通过 Amazon S3、Amazon Route 53 和 Amazon CloudFront 托管静态网站的常见部署目标。他们的构建服务器服务 AWS CodePipeline 非常适合这些站点的部署机制。您可以登录 AWS CodePipeline,设置一个新的构建项目,并告诉它使用一个buildspec.yml文件。代码可以定制,模板化的部分可以替换为实际值。

一旦 GitHub 接收到更改事件,CodePipeline 会在容器中运行安装。首先,它获取指定版本的特定版本的 Hugo。接下来,它构建 Hugo 页面。由于 Go 语言的速度非常快,可以在几秒钟内渲染数千个 Hugo 页面。

最后,HTML 页面被同步到 Amazon S3。因为这在 AWS 内部运行并且被同步,所以速度非常快。最后一步是使 CloudFront 失效:

version: 0.1

environment_variables:
  plaintext:
    HUGO_VERSION: "0.42"

phases:
  install:
    commands:
      - cd /tmp
      - wget https://github.com/gohugoio/hugo/releases/\
      download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz
      - tar -xzf hugo_${HUGO_VERSION}_Linux-64bit.tar.gz
      - mv hugo /usr/bin/hugo
      - cd -
      - rm -rf /tmp/*
  build:
    commands:
      - rm -rf public
      - hugo
  post_build:
    commands:
      - aws s3 sync public/ s3://<yourwebsite>.com/ --region us-west-2 --delete
      - aws s3 cp s3://<yourwebsite>.com/\
      s3://<yourwebsite>.com/ --metadata-directive REPLACE \
        --cache-control 'max-age=604800' --recursive
      - aws cloudfront create-invalidation --distribution-id=<YOURID> --paths '/*'
      - echo Build completed on `date`

实际案例研究:使用 Google Cloud Build 部署 Python 应用引擎应用程序

回到 2008 年,我写了关于使用 Google App Engine 的第一篇文章。您需要使用 Wayback Machine 从O’Reilly 博客获取它。

现代时代的重启。这是 Google App Engine 的另一个版本,但这次使用 Google Cloud Build。Google Cloud 平台(GCP)Cloud Build 的工作方式与 AWS CodePipeline 非常相似。这里有一个配置文件,它已经检入 GitHub 仓库。配置文件名为 cloudbuild.yaml。你可以在此项目的 Git 仓库中查看所有源代码

steps:
- name: python:3.7
  id: INSTALL
  entrypoint: python3
  args:
  - '-m'
  - 'pip'
  - 'install'
  - '-t'
  - '.'
  - '-r'
  - 'requirements.txt'
- name: python:3.7
  entrypoint: ./pylint_runner
  id: LINT
  waitFor:
  - INSTALL
- name: "gcr.io/cloud-builders/gcloud"
  args: ["app", "deploy"]
timeout: "1600s"
images: ['gcr.io/$PROJECT_ID/pylint']

注意 cloudbuild.yaml 文件安装了在 requirements.txt 文件中看到的包,并运行 gcloud app deploy,这在 GitHub 上检入时部署 App Engine 应用程序:

Flask==1.0.2
gunicorn==19.9.0
pylint==2.3.1

这里是如何设置整个项目的步骤走-through:

  1. 创建项目。

  2. 激活云 Shell。

  3. 参考 Python 3 App Engine 的 hello world 文档

  4. 运行 describe:

    verify project is working
    ```bash
    
    gcloud projects describe $GOOGLE_CLOUD_PROJECT
    
    ```py
    output of command:
    ```bash
    
    createTime: '2019-05-29T21:21:10.187Z'
    
    生命周期状态:活动
    
    名称:helloml
    
    projectId: helloml-xxxxx
    
    projectNumber: '881692383648'
    
    ```py
    
  5. 你可能想验证是否选择了正确的项目。如果没有,请执行以下操作进行切换:

    gcloud config set project $GOOGLE_CLOUD_PROJECT
    
  6. 创建 App Engine 应用程序:

    gcloud app create
    

    这将询问区域。继续选择 us-central [12]

    Creating App Engine application in project [helloml-xxx]
    and region [us-central]....done.
    Success! The app is now created.
    Please use `gcloud app deploy` to deploy your first app.
    
  7. 克隆 hello world 示例应用程序仓库:

    git clone https://github.com/GoogleCloudPlatform/python-docs-samples
    
  8. cd 进入 repo:

    cd python-docs-samples/appengine/standard_python37/hello_world
    
  9. 更新 Cloudshell 镜像(请注意,这是可选的):

    git clone https://github.com/noahgift/gcp-hello-ml.git
    # Update .cloudshellcustomimagerepo.json with project and image name
    # TIP: enable "Boost Mode" in in Cloudshell
    cloudshell env build-local
    cloudshell env push
    cloudshell env update-default-image
    # Restart Cloudshell VM
    
  10. 创建并启用虚拟环境:

    virtualenv --python $(which python) venv
    source venv/bin/activate
    

    双重检查是否有效:

    which python
    /home/noah_gift/python-docs-samples/appengine/\
      standard_python37/hello_world/venv/bin/python
    
  11. 激活云 Shell 编辑器。

  12. 安装包:

    pip install -r requirements.txt
    

    这应该安装 Flask:

    Flask==1.0.2
    
  13. 本地运行 Flask。这将在 GCP Shell 中本地运行 Flask:

    python main.py
    
  14. 使用网页预览(见 Figure 6-2)。

    pydo 0602

    Figure 6-2. 网页预览
  15. 更新 main.py:

    from flask import Flask
    from flask import jsonify
    
    app = Flask(__name__)
    
    @app.route('/')
    def hello():
        """Return a friendly HTTP greeting."""
        return 'Hello I like to make AI Apps'
    
    @app.route('/name/<value>')
    def name(value):
        val = {"value": value}
        return jsonify(val)
    
    if __name__ == '__main__':
        app.run(host='127.0.0.1', port=8080, debug=True)
    
  16. 测试传入参数以执行此功能:

    @app.route('/name/<value>')
    def name(value):
        val = {"value": value}
        return jsonify(val)
    

    例如,调用此路由将会将单词 lion 传递到 Flask 中的 name 函数中:

    https://8080-dot-3104625-dot-devshell.appspot.com/name/lion
    

    在 web 浏览器中返回一个值:

    {
    value: "lion"
    }
    
  17. 现在部署应用程序:

    gcloud app deploy
    

    警告!第一次部署可能需要大约 10 分钟。您可能还需要启用 Cloud Build API。

    Do you want to continue (Y/n)?  y
    Beginning deployment of service [default]...
    ╔════════════════════════════════════════════════════════════╗
    ╠═ Uploading 934 files to Google Cloud Storage              ═╣
    
  18. 现在流式传输日志文件:

    gcloud app logs tail -s default
    
  19. 生产应用已部署,应如此:

    Setting traffic split for service [default]...done.
    Deployed service [default] to [https://helloml-xxx.appspot.com]
    You can stream logs from the command line by running:
      $ gcloud app logs tail -s default
    
      $ gcloud app browse
    (venv) noah_gift@cloudshell:~/python-docs-samples/appengine/\
      standard_python37/hello_world (helloml-242121)$ gcloud app
     logs tail -s default
    Waiting for new log entries...
    2019-05-29 22:45:02 default[2019]  [2019-05-29 22:45:02 +0000] [8]
    2019-05-29 22:45:02 default[2019]  [2019-05-29 22:45:02 +0000] [8]
     (8)
    2019-05-29 22:45:02 default[2019]  [2019-05-29 22:45:02 +0000] [8]
    2019-05-29 22:45:02 default[2019]  [2019-05-29 22:45:02 +0000] [25]
    2019-05-29 22:45:02 default[2019]  [2019-05-29 22:45:02 +0000] [27]
    2019-05-29 22:45:04 default[2019]  "GET /favicon.ico HTTP/1.1" 404
    2019-05-29 22:46:25 default[2019]  "GET /name/usf HTTP/1.1" 200
    
  20. 添加新路由并测试:

    @app.route('/html')
    def html():
        """Returns some custom HTML"""
        return """
     <title>This is a Hello World World Page</title>
     <p>Hello</p>
     <p><b>World</b></p>
     """
    
  21. 安装 Pandas 并返回 JSON 结果。此时,您可能希望考虑创建一个 Makefile 并执行以下操作:

    touch Makefile
    #this goes inside that file
    install:
      pip install -r requirements.txt
    

    您可能还想设置 lint:

    pylint --disable=R,C main.py
    ------------------------------------
    Your code has been rated at 10.00/10
    

    Web 路由语法看起来像以下代码块。在顶部添加 Pandas 导入:

    import pandas as pd
    
    @app.route('/pandas')
    def pandas_sugar():
        df = pd.read_csv(
          "https://raw.githubusercontent.com/noahgift/sugar/\
     master/data/education_sugar_cdc_2003.csv")
        return jsonify(df.to_dict())
    

    当您调用路由 https://.appspot.com/pandas 时,您应该看到类似 Figure 6-3 的东西。

    pydo 0603

    Figure 6-3. JSON 输出示例
  22. 添加此 Wikipedia 路由:

    import wikipedia
    @app.route('/wikipedia/<company>')
    def wikipedia_route(company):
        result = wikipedia.summary(company, sentences=10)
        return result
    
  23. 向应用添加 NLP:

    1. 运行 IPython Notebook

    2. 启用云自然语言 API。

    3. 运行 pip install google-cloud-language:

      In [1]: from google.cloud import language
         ...: from google.cloud.language import enums
         ...:
         ...: from google.cloud.language import types
      In [2]:
      In [2]: text = "LeBron James plays for the Cleveland Cavaliers."
         ...: client = language.LanguageServiceClient()
         ...: document = types.Document(
         ...:         content=text,
         ...:         type=enums.Document.Type.PLAIN_TEXT)
         ...: entities = client.analyze_entities(document).entities
      In [3]: entities
      
  24. 这是一个端到端的 AI API 示例:

    from flask import Flask
    from flask import jsonify
    import pandas as pd
    import wikipedia
    
    app = Flask(__name__)
    
    @app.route('/')
    def hello():
        """Return a friendly HTTP greeting."""
        return 'Hello I like to make AI Apps'
    
    @app.route('/name/<value>')
    def name(value):
        val = {"value": value}
        return jsonify(val)
    
    @app.route('/html')
    def html():
        """Returns some custom HTML"""
        return """
     <title>This is a Hello World World Page</title>
     <p>Hello</p>
     <p><b>World</b></p>
     """
    @app.route('/pandas')
    def pandas_sugar():
        df = pd.read_csv(
          "https://raw.githubusercontent.com/noahgift/sugar/\
     master/data/education_sugar_cdc_2003.csv")
        return jsonify(df.to_dict())
    
    @app.route('/wikipedia/<company>')
    def wikipedia_route(company):
    
        # Imports the Google Cloud client library
        from google.cloud import language
        from google.cloud.language import enums
        from google.cloud.language import types
        result = wikipedia.summary(company, sentences=10)
    
        client = language.LanguageServiceClient()
        document = types.Document(
            content=result,
            type=enums.Document.Type.PLAIN_TEXT)
        entities = client.analyze_entities(document).entities
        return str(entities)
    
    if __name__ == '__main__':
        app.run(host='127.0.0.1', port=8080, debug=True)
    

本节展示了如何在 Google Cloud Shell 中从头开始设置 App Engine 应用程序,以及如何使用 GCP Cloud Build 进行持续交付。

实际案例研究:NFSOPS

NFSOPS 是一种操作技术,利用 NFS(网络文件系统)挂载点来管理计算机集群。听起来像是一种新技术,但实际上自 Unix 存在以来就一直存在。Noah 在 2000 年就曾在加州理工学院的 Unix 系统上使用 NFS 挂载点来管理和维护软件。新的东西又重新回到了老的循环。

作为旧金山虚拟现实创业公司的兼职顾问,我面临的一个问题是如何快速构建一个作业框架,可以将工作分派给成千上万个 AWS Spot Instances。

最终解决方案是使用 NFSOPS(图 6-4)在亚秒内部署 Python 代码到成千上万的计算机视觉 Spot Instances。

pydo 0604

图 6-4. NFSOPS

NFSOPS 的工作方式是利用构建服务器,例如 Jenkins,在几个亚马逊弹性文件系统(EFS)挂载点(DEV、STAGE、PROD)上挂载。当进行持续集成构建时,最后一步是对相应挂载点进行 rsync

#Jenkins deploy build step
rsync -az --delete * /dev-efs/code/

“部署”然后在亚秒内完成到挂载点。当成千上万的 Spot Instances 被启动时,它们预配置为挂载 EFS(NFS 挂载点)并使用源代码。这是一种优化简单和速度的便捷部署模式。它还可以与 IAC、Amazon Machine Image(AMI)或 Ansible 很好地配合使用。

第七章:监控和日志记录

当诺亚在旧金山的初创公司工作时,他利用午餐时间锻炼。他会打篮球,跑到科伊特塔上,或者练巴西柔术。诺亚工作过的大多数初创公司都提供午餐。

他发现一个非常不寻常的模式,在午饭后回来。从来没有剩下任何不健康的东西可吃。剩下的东西通常是完整的沙拉,水果,蔬菜或健康的瘦肉。一群群的初创公司工作人员在他锻炼时吃光了所有不健康的选择,不留一丝诱惑吃坏的食物。不随波逐流确实有一番道理。

同样,当开发机器学习模型,移动应用程序和 Web 应用程序时,忽视操作是一条容易的路。忽视操作是如此典型,就像在提供午餐时吃薯片,苏打水和冰淇淋一样。虽然成为正常人并不一定是首选。在本章中,描述了软件开发的“沙拉和瘦肉”方法。

构建可靠系统的关键概念

经过一段时间的公司建设,看看在软件工程部分起作用和不起作用的东西是有趣的。其中一个最好的反模式是“相信我”。任何理智的 DevOps 专业人员都不会相信人类。他们是有缺陷的,会犯情感上的错误,并且可以随心所欲地摧毁整个公司。特别是如果他们是公司的创始人。

不是基于完全胡言乱语的层次结构,构建可靠系统的更好方法是逐步构建它们。此外,在创建平台时,应该经常预期失败。唯一会影响这个真理的事情是如果有一个有权势的人参与建立架构。在那种情况下,这个真理将呈指数增长。

你可能听说过 Netflix 的混沌猴,但为什么要费心呢?相反,让你公司的创始人,首席技术官或工程副总裁做随意编码并对你的架构和代码库提出质疑。人类混沌猴将在 Netflix 周围打转。更好的是,让他们在生产中断期间编译 jar 文件,并逐个将它们放在节点上,通过 SSH,同时喊着,“这会奏效的!”通过这种方式,混沌和自我之间的谐波均值被实现。

对于理智的 DevOps 专业人员来说,行动项目是什么?自动化大于层次结构。解决初创公司混乱的唯一方法是自动化,怀疑,谦卑和不可变的 DevOps 原则。

不可变的 DevOps 原则

很难想象有比这个不可改变的原则更好的地方来开始构建可靠的系统。如果首席技术官正在从笔记本电脑构建 Java 的.jar文件来解决生产问题,你应该辞职。没有什么能够拯救你的公司。我们应该知道——我们曾经在那里!

无论一个人有多聪明/强大/有魅力/有创意/有钱,如果他们在软件平台危机中手动应用关键更改,你已经死了。只是你还不知道。摆脱这种可怕状态的替代方案是自动化。

长期来看,人类不能参与软件部署。这是软件行业存在的头号反模式。它本质上是暴徒对你的平台造成严重破坏的后门。相反,部署软件、测试软件和构建软件需要 100%自动化。

在公司中,你可以产生最显著的初始影响是建立持续集成和持续交付。其他所有事情都相形见绌。

集中日志记录

在自动化之后,日志记录在重要性上紧随其后。在大规模分布式系统中,日志记录不是可选的。必须特别关注应用程序级别和环境级别的日志记录。

例如,异常应始终发送到集中日志记录系统。另一方面,在开发软件时,通常创建调试日志而不是打印语句是一个好主意。为什么这样做?花费了很多时间开发调试源代码的启发式算法。为什么不捕获它,以便在生产中再次出现问题时可以打开它?

这里的诀窍在于日志记录级别。通过创建仅出现在非生产环境中的调试日志级别,可以将调试逻辑保留在源代码中。同样,不要让过于冗长的日志在生产中出现并引起混乱,可以随时开启或关闭它们。

在大规模分布式系统中,日志记录的一个例子是Ceph的使用:守护程序可以拥有高达 20 个调试级别!所有这些级别都在代码中处理,允许系统精细调节日志记录量。Ceph 通过能够限制每个守护程序的日志记录量进一步推动了这一策略。系统有几个守护程序,可以增加一个或所有守护程序的日志记录量。

案例研究:生产数据库损坏硬盘

日志记录的另一个关键策略是解决可扩展性问题。一旦应用程序足够庞大,可能不再可行将所有日志存储在文件中。阿尔弗雷多曾经被指派解决一个广泛网络应用的主数据库问题,这个应用托管大约一百家报纸、广播电台和电视台的站点。这些站点产生了大量流量并生成了大量日志。产生了如此多的日志输出,以至于 PostgreSQL 的日志记录被设置到最低,他无法调试问题,因为需要提高日志级别。如果提高日志级别,应用程序将因所产生的强烈 I/O 而停止工作。每天早上五点左右,数据库负载就会急剧上升。情况越来越糟。

数据库管理员反对提高日志级别以查看最昂贵的查询(PostgreSQL 可以在日志中包含查询信息)整整一天,因此我们妥协了:每天早晨五点左右十五分钟。一旦能够获取这些日志,阿尔弗雷多立即开始评估最慢的查询及其运行频率。有一个明显的优胜者:一个耗时如此之长的SELECT *查询,以至于十五分钟的时间窗口无法捕获其运行时间。应用程序并未对任何表进行全表查询;那么问题出在哪里呢?

经过多次劝说,我们终于获得了对数据库服务器的访问权限。如果每天清晨五点左右出现负载激增,是否可能存在某种定期脚本?我们调查了crontab(用于记录定时运行任务的程序),发现了一个可疑的脚本:backup.sh。脚本内容包含几条 SQL 语句,其中包括多个SELECT *。数据库管理员使用此脚本备份主数据库,随着数据库规模的增长,负载也随之增加,直至无法忍受。解决方案?停止使用此脚本,开始备份四个次要(副本)数据库中的一个。

这解决了备份问题,但并没有解决无法访问日志的问题。提前考虑分发日志输出是正确的做法。像rsyslog这样的工具就是为解决这类问题而设计的,如果从一开始就添加,可以避免在生产中遇到问题时束手无策。

是否自建还是购买?

令人难以置信的是供应商锁定如何备受关注。然而,供应商锁定是因人而异的。在旧金山市中心,你几乎可以随处一掷石头就能碰到有人大声疾呼供应商锁定的罪恶。然而,深入挖掘之后,你会想知道他们的替代方案是什么。

在经济学中,有一个叫做比较优势的原则。简而言之,它意味着将精力集中在自己最擅长的事务上,并将其他任务外包给其他人是经济上有利的。尤其是在云计算领域,不断的改进使最终用户从中受益,而且通常比以前更少复杂。

除非你的公司运营规模达到技术巨头之一,否则几乎不可能实施、维护和改进私有云,并能在同时节省成本和提升业务。例如,2017 年,亚马逊发布了跨多个可用区具有自动故障切换功能的多主数据库部署能力。作为曾尝试过这一点的人,我可以说这几乎是不可能的,而在这种情况下添加自动故障切换功能非常困难。在考虑外包时要考虑的一个关键问题是:“这是否是业务的核心竞争力?”一个运营自己邮件服务器但核心竞争力是销售汽车零件的公司,正在玩火并且可能已经在亏钱。

容错性

容错性是一个迷人的主题,但它可能非常令人困惑。容错性的含义是什么,如何实现?学习更多关于容错性的信息的好地方是阅读尽可能多的来自 AWS 的白皮书

在设计容错系统时,最好从尝试回答以下问题开始:当此服务宕机时,我可以实施什么来消除(或减少)手动交互?没有人喜欢收到关键系统宕机的通知,尤其是这意味着需要多个步骤来恢复,更不用说还需要与其他服务进行沟通,确保一切恢复正常。需要注意的是,这个问题并不是将宕机视为不太可能发生的事件,而是明确承认该服务会宕机,并且需要进行一些工作来使其恢复运行。

不久之前,计划对复杂的构建系统进行全面重新设计。该构建系统完成了多项任务,其中大部分与软件打包和发布相关:必须检查依赖关系,使用make和其他工具构建二进制文件,生成 RPM 和 Debian 软件包,并创建和托管不同 Linux 发行版(如 CentOS、Debian 和 Ubuntu)的软件库。这个构建系统的主要要求是速度快。

尽管速度是主要目标之一,但在设计涉及多个步骤和不同组件的系统时,解决已知的痛点并努力防止新问题的出现非常有用。大型系统中总会存在未知因素,但使用适当的日志记录(以及日志聚合)、监控和恢复策略至关重要。

现在回到构建系统,其中一个问题是创建仓库的机器有些复杂:一个 HTTP API 接收特定项目特定版本的软件包,并自动生成仓库。 这个过程涉及数据库、RabbitMQ 服务用于异步任务处理,以及大量的存储空间来保存仓库,由 Nginx 提供服务。 最后,一些状态报告将发送到中央仪表板,以便开发人员可以查看其分支在构建过程中的位置。 设计所有东西都围绕这个服务可能出现故障的可能性是至关重要的。

在白板上添加了一张大字条,上面写着:“错误:仓库服务因磁盘已满而停止。” 任务并不是要防止磁盘满,而是要创建一个能在磁盘满的情况下继续工作,并在问题解决后几乎不费力就能重新将其纳入系统的系统。 “磁盘已满”错误是一个虚构的错误,可能是任何事情,比如 RabbitMQ 没有运行或者 DNS 问题,但它完美地说明了当前的任务。

直到某个关键部分失效时,理解监控、日志记录和良好设计模式的重要性才变得困难,并且几乎无法确定为什么以及如何。 你需要知道为什么它会崩溃,以便可以采取预防措施(警报、监控和自我恢复)来避免将来出现类似问题。

为了使这个系统能够继续工作,我们将负载分成了五台机器,它们都是相同的,做着相同的工作:创建和托管仓库。 生成二进制文件的节点将查询一个健康的仓库机器的 API,然后该机器会发送一个 HTTP 请求来查询其列表中下一个构建服务器的 /health/ 终端。 如果服务器报告健康,则二进制文件会发送到那里;否则,API 将选择列表中的下一个服务器。 如果一个节点连续三次未通过健康检查,它将被移出轮换。 系统管理员只需在修复后重新启动仓库服务即可将其重新纳入轮换。(仓库服务有一个自我健康评估,如果成功将通知 API 准备好进行工作。)

虽然实现并非完全可靠(仍需努力使服务器恢复运行,并且通知并不总是准确),但在需要恢复服务时对服务维护产生了巨大影响,并保持所有内容在降级状态下继续运行。 这就是容错性的全部意义所在!

监控

监控是这样一种事情,你几乎什么都不做也可以声称已经建立了一个监控系统(作为实习生,Alfredo 曾经使用一个curl定时任务来检查一个生产网站),但是它可能变得非常复杂,以至于生产环境看起来像是灵活的。当监控和报告做得正确时,通常可以帮助回答生产生命周期中最困难的问题。拥有它是至关重要的,但要做到这一点却很困难。这就是为什么有许多公司专门从事监控、警报和指标可视化的原因。

在其核心,大多数服务遵循两种范式:拉取和推送。本章将涵盖 Prometheus(拉取)和 Graphite 与 StatsD(推送)。了解在何时选择其中一种更为合适以及其中的注意事项,在为环境添加监控时是非常实用的。更重要的是,了解两者并具备根据特定场景选择最佳服务的能力,这一点非常实际。

可靠的时间序列软件必须能够承受极高的事务信息输入速率,能够存储这些信息,与时间相关联,支持查询,并提供一个可以根据过滤器和查询自定义的图形界面。本质上,它几乎必须像一个高性能数据库,但专门用于时间、数据操作和可视化。

Graphite

Graphite 是一个用于数字时间数据的数据存储:它保存与捕获时间相关的数值信息,并根据可自定义的规则保存这些信息。它提供了一个非常强大的 API,可以查询有关其数据的信息,包括时间范围,并且还可以应用函数对数据进行转换或计算。

Graphite的一个重要方面是它不会收集数据;相反,它集中精力于其 API 和处理大量数据的能力。这迫使用户考虑与 Graphite 部署的收集软件。有很多选择可供将指标发送到 Graphite;在本章中,我们将介绍其中一种选择,StatsD。

Graphite 的另一个有趣之处在于,虽然它自带了一个可以按需呈现图形的 Web 应用程序,但通常会部署一个不同的服务,该服务可以直接使用 Graphite 作为图形的后端。一个很好的例子就是优秀的Grafana 项目,它提供了一个功能齐全的 Web 应用程序用于呈现指标。

StatsD

Graphite 允许您通过 TCP 或 UDP 将指标推送到它,但是使用类似 StatsD 的工具特别方便,因为 Python 中有仪表化选项,允许通过 UDP 聚合指标然后将其发送到 Graphite。这种设置对于不应阻塞发送数据的 Python 应用程序很合理(TCP 连接会阻塞,直到收到响应;UDP 则不会)。如果捕获的非常耗时的 Python 循环用于指标,那么将其通信时间加入到捕获指标的服务中是没有意义的。

简而言之,将指标发送到 StatsD 服务感觉就像没有成本一样(应该是这样!)。利用可用的 Python 仪器,测量一切非常直接。一旦 StatsD 服务有足够的指标要发送到 Graphite,它就会开始发送指标的过程。所有这些都是完全异步进行的,有助于应用程序的继续运行。指标、监控和日志记录绝不能以任何方式影响生产应用程序!

使用 StatsD 时,推送到它的数据在给定间隔(默认为 10 秒)内聚合并刷新到可配置的后端(如 Graphite)。在几个生产环境中部署了 Graphite 和 StatsD 的组合后,发现在每个应用程序服务器上使用一个 StatsD 实例比为所有应用程序使用单个实例更容易。这种部署方式允许更简单的配置和更紧密的安全性:所有应用服务器上的配置将指向 localhost 的 StatsD 服务,不需要打开外部端口。最后,StatsD 将通过出站 UDP 连接将指标发送到 Graphite。这也通过将可扩展性进一步推向 Graphite 的管道下游来分担负载。

注意

StatsD 是一个 Node.js 守护程序,因此安装它意味着引入 Node.js 依赖。它绝对 不是 一个 Python 项目!

Prometheus

在许多方面,Prometheus 与 Graphite 非常相似(强大的查询和可视化)。主要区别在于它从源头 拉取 信息,并且通过 HTTP 进行。这需要服务公开 HTTP 端点以允许 Prometheus 收集指标数据。与 Graphite 的另一个显著区别是,它内置了警报功能,可以配置规则来触发警报或使用 Alertmanager:一个负责处理警报、静音、聚合并将其中继到不同系统(如电子邮件、聊天和值班平台)的组件。

一些项目(比如 Ceph)已经具有可配置选项,以便让 Prometheus 在特定间隔内抓取信息。当这种类型的集成被提供时,这是很好的;否则,它需要在某个地方运行一个可以公开服务的 HTTP 实例来暴露度量数据。例如,在 PostgreSQL 数据库 的情况下,Prometheus 导出器是运行一个公开数据的 HTTP 服务的容器。在许多情况下,这可能是fine的,但是如果已经有集成可以使用诸如collectd之类的东西收集数据,那么运行 HTTP 服务可能效果不是那么好。

Prometheus 是短期数据或频繁变化的时间数据的绝佳选择,而 Graphite 更适合长期历史信息。两者都提供非常先进的查询语言,但 Prometheus 更为强大。

对于 Python 来说,prometheus_client是一个很好的实用工具,可以开始将指标发送到 Prometheus;如果应用程序已经是基于 Web 的,该客户端还具有许多不同 Python Web 服务器的集成,例如 Twisted、WSGI、Flask,甚至 Gunicorn。除此之外,它还可以导出所有数据以直接在定义的端点公开(而不是在单独的 HTTP 实例上执行)。如果你想让你的 Web 应用程序在 /metrics/ 暴露出来,那么添加一个调用 prometheus_client.generate_latest() 的处理程序将以 Prometheus 解析器理解的格式返回内容。

创建一个小的 Flask 应用程序(保存到web.py中),以了解generate_latest()的使用是多么简单,并确保安装了prometheus_client包:

from flask import Response, Flask
import prometheus_client

app = Flask('prometheus-app')

@app.route('/metrics/')
def metrics():
    return Response(
        prometheus_client.generate_latest(),
        mimetype='text/plain; version=0.0.4; charset=utf-8'
    )

使用开发服务器运行应用程序:

$ FLASK_APP=web.py flask run
 * Serving Flask app "web.py"
 * Environment: production
   WARNING: This is a development server.
   Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
127.0.0.1 - - [07/Jul/2019 10:16:20] "GET /metrics HTTP/1.1" 308 -
127.0.0.1 - - [07/Jul/2019 10:16:20] "GET /metrics/ HTTP/1.1" 200 -

在应用程序运行时,打开一个 Web 浏览器,输入网址[*http://localhost:5000/metrics*](http://localhost:5000/metrics)。它开始生成 Prometheus 可以收集的输出,即使没有什么真正重要的东西:

...
# HELP process_cpu_seconds_total Total user and system CPU time in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 0.27
# HELP process_open_fds Number of open file descriptors.
# TYPE process_open_fds gauge
process_open_fds 6.0
# HELP process_max_fds Maximum number of open file descriptors.
# TYPE process_max_fds gauge
process_max_fds 1024.0

大多数生产级 Web 服务器(如 Nginx 和 Apache)可以生成有关响应时间和延迟的详尽指标。例如,如果向 Flask 应用程序添加这种类型的度量数据,那么中间件,其中所有请求都可以记录,将是一个很好的选择。应用程序通常会在请求中执行其他有趣的操作,所以让我们添加另外两个端点——一个带有计数器,另一个带有计时器。这两个新端点将生成度量数据,该数据将由 prometheus_client 库处理,并在通过 HTTP 请求 /metrics/ 端点时报告。

向我们的小应用程序添加一个计数器涉及一些小的更改。创建一个新的索引端点:

@app.route('/')
def index():
    return '<h1>Development Prometheus-backed Flask App</h1>'

现在定义 Counter 对象。添加计数器的名称(requests)、一个简短的描述(Application Request Count)和至少一个有用的标签(比如 endpoint)。这个标签将帮助识别这个计数器来自哪里:

from prometheus_client import Counter

REQUESTS = Counter(
    'requests', 'Application Request Count',
    ['endpoint']
)

@app.route('/')
def index():
    REQUESTS.labels(endpoint='/').inc()
    return '<h1>Development Prometheus-backed Flask App</h1>'

定义了 REQUESTS 计数器后,将其包含在 index() 函数中,重新启动应用程序并进行几次请求。然后如果请求 /metrics/,输出应显示我们创建的一些新活动:

...
# HELP requests_total Application Request Count
# TYPE requests_total counter
requests_total{endpoint="/"} 3.0
# TYPE requests_created gauge
requests_created{endpoint="/"} 1.562512871203272e+09

现在添加一个 Histogram 对象来捕获一个端点的详细信息,有时回复时间较长。代码通过随机休眠一段时间来模拟这一情况。与 index 函数一样,还需要一个新的端点,其中使用了 Histogram 对象:

from prometheus_client import Histogram

TIMER = Histogram(
    'slow', 'Slow Requests',
    ['endpoint']
)

模拟的昂贵操作将使用一个函数来跟踪开始时间和结束时间,然后将这些信息传递给直方图对象:

import time
import random

@app.route('/database/')
def database():
    with TIMER.labels('/database').time():
        # simulated database response time
        sleep(random.uniform(1, 3))
    return '<h1>Completed expensive database operation</h1>'

需要两个新模块:timerandom。它们将帮助计算传递给直方图的时间,并模拟在数据库中执行的昂贵操作。再次运行应用程序并请求 /database/ 端点将在 /metrics/ 被轮询时开始生成内容。现在应该能看到几个测量我们模拟时间的项目:

# HELP slow Slow Requests
# TYPE slow histogram
slow_bucket{endpoint="/database",le="0.005"} 0.0
slow_bucket{endpoint="/database",le="0.01"} 0.0
slow_bucket{endpoint="/database",le="0.025"} 0.0
slow_bucket{endpoint="/database",le="0.05"} 0.0
slow_bucket{endpoint="/database",le="0.075"} 0.0
slow_bucket{endpoint="/database",le="0.1"} 0.0
slow_bucket{endpoint="/database",le="0.25"} 0.0
slow_bucket{endpoint="/database",le="0.5"} 0.0
slow_bucket{endpoint="/database",le="0.75"} 0.0
slow_bucket{endpoint="/database",le="1.0"} 0.0
slow_bucket{endpoint="/database",le="2.5"} 2.0
slow_bucket{endpoint="/database",le="5.0"} 2.0
slow_bucket{endpoint="/database",le="7.5"} 2.0
slow_bucket{endpoint="/database",le="10.0"} 2.0
slow_bucket{endpoint="/database",le="+Inf"} 2.0
slow_count{endpoint="/database"} 2.0
slow_sum{endpoint="/database"} 2.0021886825561523

Histogram 对象非常灵活,可以作为上下文管理器、装饰器或直接接收值。拥有这种灵活性非常强大,有助于在大多数环境中轻松生成可用的仪器设备。

仪器设备

在我们熟悉的某家公司,有一个由多家不同报纸使用的大型应用程序——一个巨大的单体网络应用程序,没有运行时监控。运维团队在监视系统资源如内存和 CPU 使用率方面做得很好,但没有任何方式来检查每秒向第三方视频供应商发出多少 API 调用,以及这些调用有多昂贵。可以说,通过日志记录可以实现这种类型的测量,这并不是错误,但再次强调,这是一个有着荒谬数量日志记录的大型单体应用程序。

这里的问题是如何引入具有简单可视化和查询的健壮指标,而不需要开发人员进行三天的实施培训,并使其像在代码中添加日志语句一样简单。运行时的任何技术仪器设备必须尽可能接近前述声明。任何偏离这一前提的解决方案都将难以成功。如果查询和可视化困难,那么很少有人会关心或注意。如果实施(和维护!)困难,那么它可能会被放弃。如果开发人员在运行时添加这些内容复杂,那么无论基础设施和服务是否准备好接收指标,都不会发货(或至少不会是有意义的)。

python-statsd是一个出色的(而且很小)库,用于将指标推送到 StatsD(稍后可以中继到 Graphite),可以帮助您轻松地进行指标仪表化。在应用程序中有一个专用模块包装此库非常有用,因为您需要添加定制化内容,如果到处重复将会很繁琐。

提示

StatsD 的 Python 客户端在 PyPI 上有几个可用的包。为了这些示例的目的,请使用python-statsd包。在虚拟环境中安装它,命令为pip install python-statsd。如果未使用正确的客户端可能会导致导入错误!

最简单的用例之一是计数器,python-statsd的示例显示类似于以下内容:

>>> import statsd
>>>
>>> counter = statsd.Counter('app')
>>> counter += 1

本示例假定本地正在运行 StatsD。因此无需创建连接;在这里默认设置非常有效。但是Counter类的调用传递了一个在生产环境中不起作用的名称(*app*)。如“命名约定”所述,具有帮助识别统计信息的环境和位置的良好方案至关重要,但如果到处都这样做将会非常重复。在某些 Graphite 环境中,作为认证手段,所有发送的指标必须在命名空间前加上一个secret。这增加了另一个需要抽象化的层次,以便在仪表化指标时不需要它。

命名空间的某些部分(如密钥)必须是可配置的,而其他部分可以以编程方式分配。假设有一种方法可以选择性地使用函数get_prefix()作为命名空间前缀,那么Counter将被包装以在单独的模块中提供平滑交互。为使示例生效,请创建新模块,命名为metrics.py,并添加以下内容:

import statsd
import get_prefix

def Counter(name):
    return statsd.Counter("%s.%s" % (get_prefix(), name))

遵循“命名约定”中使用的同一示例,用于调用 Amazon S3 API 的小型 Python 应用程序的某个路径,例如web/api/aws.pyCounter可以这样实例化:

from metrics import Counter

counter = Counter(__name__)

counter += 1

通过使用__name__Counter对象将以模块的完整 Python 命名空间(在接收端显示为web.api.aws.Counter)创建。这很好地实现了功能,但如果我们需要在不同位置的循环中有多个计数器,这种方式就不够灵活了。我们必须修改包装器,以允许使用后缀:

import statsd
import get_prefix

def Counter(name, suffix=None):
    if suffix:
        name_parts = name.split('.')
        name_parts.append(suffix)
        name =  '.'.join(name_parts)
    return statsd.Counter("%s.%s" % (get_prefix(), name))

如果aws.py文件包含两个需要计数器的地方,例如用于 S3 读取和写入功能,则可以轻松添加后缀:

from metrics import Counter
import boto

def s3_write(bucket, filename):
    counter = Counter(__name__, 's3.write')
    conn = boto.connect_s3()
    bucket = conn.get_bucket(bucket)
    key = boto.s3.key.Key(bucket, filename)
    with open(filename) as f:
        key.send_file(f)
    counter += 1

def s3_read(bucket, filename):
    counter = Counter(__name__, 's3.read')
    conn = boto.connect_s3()
    bucket = conn.get_bucket(bucket)
    k = Key(bucket)
    k.key = filename
    counter += 1
    return k

这两个辅助工具现在从同一个包装中具有独特的计数器,如果在生产环境中配置,则度量数据将显示在类似于 secret.app1.web.api.aws.s3.write.Counter 的命名空间中。在尝试识别每个操作的指标时,这种粒度是有帮助的。即使有些情况下不需要粒度,拥有而不使用总比需要而没有要好。大多数度量仪表板允许自定义分组指标。

在函数名称(或类方法)后添加后缀对于几乎不代表它们是什么或它们所做的功能的函数名称来说是有用的,因此通过使用有意义的内容来改进命名是增加灵活性的另一个好处:

    def helper_for_expensive_operations_on_large_files():
        counter = Counter(__name__, suffix='large_file_operations')
        while slow_operation:
            ...
            counter +=1
注意

计数器和其他指标类型,比如计量表,添加起来可能很容易,可以诱人地将它们包含在循环中,但对于每秒运行数千次的性能关键代码块来说,添加这些类型的仪表可能会产生影响。考虑限制发送的指标或稍后发送它们是不错的选择。

本节展示了如何为本地的 StatsD 服务添加仪表指标。此实例最终将其指标数据中继到像 Graphite 这样的配置后端,但这些简单的示例并不意味着仅适用于 StatsD。相反,它们表明添加帮助程序和实用工具来包装常见用法是必不可少的,当存在简单的仪表时,开发人员将希望在各处添加它们。拥有过多的度量数据的问题,比完全没有度量数据要好。

命名惯例

在大多数监控和度量服务中,如 Graphite、Grafana、Prometheus,甚至 StatsD 中,都有命名空间的概念。命名空间非常重要,值得仔细考虑一个约定,以便轻松识别系统组件,同时足够灵活以适应增长甚至变化。这些命名空间类似于 Python 的使用方式:每个名称之间用点分隔,每个分隔的部分表示从左到右层次结构中的一步。从左边开始的第一项是父级,每个后续部分都是子级。

例如,假设我们有一个灵活的 Python 应用程序,通过 AWS 进行一些 API 调用,在网站上提供图像服务。我们要关注的 Python 模块路径如下:web/api/aws.py。此路径的自然命名空间选择可能是:web.api.aws,但如果我们有多个生产应用服务器怎么办?一旦指标使用了一个命名空间,要改成其他方案就很困难(几乎不可能!)。我们改进命名空间以帮助识别生产服务器:{server_name}.web.api.aws

好多了! 但是你能看到另一个问题吗? 在发送指标时,会发送一个尾随名称。 在计数器示例中,名称将类似于:{server_name}.web.api.aws.counter。 这是一个问题,因为我们的小应用程序对 AWS 进行了几次调用,比如 S3,而且我们将来可能还想与其他 AWS 服务交互。 修复子命名比修复父命名更容易,因此在这种情况下,它只需要开发人员尽可能精确地匹配所测量的指标。 例如,如果我们在 aws.py 文件中有一个 S3 模块,那么将其包含进去以区分它与其他部分是有意义的。 该指标的子部分将类似于 aws.s3,计数器指标最终将看起来像 aws.s3.counter

有这么多命名空间变量可能会感到繁琐,但是大多数已建立的度量服务都允许轻松组合,例如“显示上周所有来自东海岸生产服务器的 S3 调用的平均计数”。 很强大,对吧?

这里还有另一个潜在问题。 我们在生产和分级环境中该怎么办? 如果我在某个虚拟机中进行开发和测试怎么办? 如果每个人都将他们的开发机器命名为 srv1,那么{server_name}部分可能不会太有用。 如果部署到不同的地区,甚至如果计划超越单个地区或国家进行扩展,则额外添加到命名空间也是有意义的。 有许多方法可以扩展命名空间以更好地适应环境,但类似这样的适当前缀是合适的:{region}.{prod|staging|dev}.{server_name}

日志记录

配置 Python 日志记录可能会让人望而生畏。 日志记录模块性能很高,有几种不同的输出方式可以接收其输出。 一旦掌握了其初始配置,向其中添加内容就不那么复杂了。 由于不喜欢正确配置日志记录模块而曾经重写了另一种日志记录方式,我们曾经犯过错误。 这是一个错误,因为它几乎从未考虑过标准库模块擅长的所有事情:多线程环境、Unicode 和支持除 STDOUT 之外的多个目的地,仅举几例。

Python 的日志记录模块非常庞大,可以用来适应许多不同的用途(就像本章中所述的几乎所有软件一样),甚至一个完整的章节都不足以涵盖所有内容。 本节将为最简单的用例提供简短的示例,然后逐渐向更复杂的用法发展。 一旦几个场景被充分理解,将日志记录扩展到其他配置就不难了。

即使它很复杂,可能需要一些时间才能完全理解,但它是 DevOps 的 重要支柱 之一。 没有它,你就不能成为成功的 DevOps 人员。

为什么这么难?

Python 应用程序,如命令行工具和一次性工具,通常采用自顶向下的设计,非常程序化。当你开始用像 Python(或者可能是 Bash)这样的东西学习开发时,习惯于这种流程是合理的。即使转向更面向对象的编程并使用更多的类和模块,仍然存在声明所需内容、实例化对象以使用它们等这种感觉。模块和对象通常不会在导入时预先配置,并且在实例化之前全局配置某些导入的模块并不常见。

有这种感觉“不知何故已经配置了,如果我甚至还没有调用它,这怎么可能。” 日志记录就像这样;一旦在运行时配置,模块会在任何地方导入和使用之前持久化这种配置,并且在创建记录器之前。这一切都非常方便,但是当几乎没有其他东西以这种方式在 Python 标准库中运作时,要习惯这种方式是困难的!

基本配置

摆脱日志配置困扰的最简单方法就是简单地使用basicconfig。这是一个直接的方法来让日志记录工作,具有许多默认值,大约三行的工作量:

>>> import logging
>>> logging.basicConfig()
>>> logger = logging.getLogger()
>>> logger.critical("this can't be that easy")
CRITICAL:root:this can't be that easy

几乎不需要理解任何关于日志记录的东西,消息就会出现,模块似乎配置正确。这也很好,它可以支持更多的定制选项,并且适合于不需要高度定制化日志接口的小型应用程序。日志消息的格式和设置详细程度都可以轻松实现:

>>> import logging
>>> FORMAT = '%(asctime)s %(name)s %(levelname)s %(message)s'
>>> logging.basicConfig(format=FORMAT, level=logging.INFO)
>>> logger = logging.getLogger()
>>> logger.debug('this will probably not show up')
>>> logger.warning('warning is above info, should appear')
2019-07-08 08:31:08,493 root WARNING warning is above info, should appear

此示例配置为将最低级别设置为INFO,这就是为什么调试消息没有发出任何内容。格式化信息传递到basicConfig调用中以设置时间、日志名称(本节后面会详述)、级别名称和最后消息。对于大多数应用程序来说,这已经足够了,并且了解到日志记录的简单入门已经可以做这么多事情。

这种类型的配置问题在于它不足以应对更复杂的场景。这种配置有很多默认值,可能不被接受,并且更改起来很麻烦。如果应用程序有可能需要更复杂的内容,建议完全配置日志记录并努力理解如何做到这一点。

更深层次的配置

日志模块有几种不同的loggers;这些记录器可以独立配置,也可以从parent记录器继承配置。最顶层的记录器是root记录器,所有其他记录器都是子记录器(root是父记录器)。当配置root记录器时,实际上是为全局设置了配置。在不同应用程序或单个应用程序的不同部分需要不同类型的日志记录接口和设置时,这种组织日志记录的方式是有意义的。

如果一个 web 应用程序想要将 WSGI 服务器错误发送到电子邮件,但将其他所有内容记录到文件中,如果配置了单个根级别记录器,则这将是不可能的。这与“命名约定”类似,名称之间用点分隔,每个点表示一个新的子级别。这意味着可以配置app.wsgi以通过电子邮件发送错误日志,而app.requests可以单独设置为基于文件的记录。

提示

处理这个命名空间的一个好方法是使用与 Python 相同的命名空间,而不是使用自定义的命名空间。通过在模块中使用*name*来创建记录器来实现这一点。对项目和日志记录使用相同的命名空间可以防止混淆。

日志记录的配置应尽可能早地设置。如果应用程序是一个命令行工具,那么正确的位置是在主入口点,可能甚至在解析参数之前。对于 web 应用程序,日志记录配置通常是通过框架的辅助工具进行的。今天最流行的 web 框架都提供了日志记录配置的接口:Django、Flask、Pecan 和 Pyramid 都提供了早期日志记录配置的接口。利用它!

此示例显示了如何配置一个命令行工具;你可以看到与basicConfig有一些相似之处:

import logging
import os

BASE_FORMAT = "[%(name)s][%(levelname)-6s] %(message)s"
FILE_FORMAT = "[%(asctime)s]" + BASE_FORMAT

root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)

try:
    file_logger = logging.FileHandler('application.log')
except (OSError, IOError):
    file_logger = logging.FileHandler('/tmp/application.log')

file_logger.setLevel(logging.INFO)
console_logger.setFormatter(logging.Formatter(BASE_FORMAT))
root_logger.addHandler(file_logger)

这里发生了很多事情。通过调用getLogger()而不带任何参数来请求root记录器,并将级别设置为DEBUG。这是一个很好的默认设置,因为其他子记录器可以修改级别。接下来,配置文件记录器。在这种情况下,它尝试创建文件记录器,如果无法写入文件,则会回退到临时位置。然后将其设置为INFO级别,并更改其消息格式以包含时间戳(对于基于文件的日志文件很有用)。

请注意,在最后,文件记录器被添加到root_logger中。这感觉有些违反直觉,但在这种情况下,根配置被设置为处理所有内容。向根记录器添加stream handler将使应用程序同时将日志发送到文件和标准错误输出:

console_logger = logging.StreamHandler()
console_logger.setFormatter(BASE_FORMAT)
console_logger.setLevel(logging.WARNING)
root_logger.addHandler(console_logger)

在这种情况下,使用BASE_FORMAT,因为它要去到终端,而时间戳可能会引起过多的噪音。正如你所看到的,它需要相当多的配置和设置,一旦我们开始处理不同的记录器,情况就会变得非常复杂。为了最小化这一点,最好使用一个带有设置所有这些选项的辅助程序的单独模块。作为这种类型配置的替代方案,logging模块提供了一种基于字典的配置,其中设置以键值接口设置。下面的示例显示了相同示例的配置会是什么样子。

要看它如何运作,请在文件末尾添加几个日志调用,直接用 Python 执行,并将其保存在名为log_test.py的文件中:

# root logger
logger = logging.getLogger()
logger.warning('this is an info message from the root logger')

app_logger = logging.getLogger('my-app')
app_logger.warning('an info message from my-app')

根记录器是父记录器,并引入了一个名为my-app的新记录器。直接执行文件将在终端以及名为application.log的文件中输出:

$ python log_test.py
[root][WARNING] this is an info message from the root logger
[my-app][WARNING] an info message from my-app
$ cat application.log
[2019-09-08 12:28:25,190][root][WARNING] this is an info message from the root
logger
[2019-09-08 12:28:25,190][my-app][WARNING] an info message from my-app

输出重复了,因为我们都配置了,但这并不意味着它们必须这样。文件日志记录器的格式已更改,允许在控制台中获得更清晰的视图:

from logging.config import dictConfig

dictConfig({
    'version': 1,
    'formatters': {
        'BASE_FORMAT': {
            'format': '[%(name)s][%(levelname)-6s] %(message)s',
        },
        'FILE_FORMAT': {
            'format': '[%(asctime)s] [%(name)s][%(levelname)-6s] %(message)s',
        },
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'INFO',
            'formatter': 'BASE_FORMAT'
        },
        'file': {
            'class': 'logging.FileHandler',
            'level': 'DEBUG',
            'formatter': 'FILE_FORMAT'
        }

    },
    'root': {
        'level': 'INFO',
        'handlers': ['console', 'file']
    }
})

使用dictConfig有助于更好地可视化事物的走向以及它们如何相互联系,相对于早期更手动的示例。对于需要多个记录器的复杂设置,dictConfig方法更好。大多数 Web 框架都专门使用基于字典的配置。

有时候,日志格式被忽视了。它通常被视为为人阅读日志提供视觉吸引力的一种表面装饰。虽然这在某种程度上是正确的,但加上一些方括号来指定日志级别(例如,[CRITICAL])也是很好的,但在其他环境细节(例如,生产、暂存或开发)需要分离时也可以起到一定作用。对于开发人员来说,日志来自开发版本可能会立即清晰,但如果它们被转发或集中收集,识别它们则非常重要。动态应用此操作是通过环境变量和在dictConfig中使用logging.Filter完成的:

import os
from logging.config import dictConfig

import logging

class EnvironFilter(logging.Filter):
    def filter(self, record):
        record.app_environment = os.environ.get('APP_ENVIRON', 'DEVEL')
        return True

dictConfig({
    'version': 1,
    'filters' : {
        'environ_filter' : {
          '()': EnvironFilter
        }
    },
    'formatters': {
        'BASE_FORMAT': {
            'format':
                '[%(app_environment)s][%(name)s][%(levelname)-6s] %(message)s',
        }
    },
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
            'level': 'INFO',
            'formatter': 'BASE_FORMAT',
            'filters': ['environ_filter'],
        }
    },
    'root': {
        'level': 'INFO',
        'handlers': ['console']
    }
})

在这个示例中发生了很多事情。可能会容易忽略一些已更新的内容。首先,添加了一个名为EnvironFilter的新类,它将logging.Filter作为基类,并定义了一个名为filter的方法,该方法接受一个record参数。这是基类要求定义此方法的方式。record参数被扩展以包括默认为*DEVEL*APP_ENVIRON环境变量。

然后,在dictConfig中,添加了一个新的键(filters),将这个过滤器命名为environ_filter,指向EnvironFilter类。最后,在handlers键中,我们添加了filters键,它接受一个列表,在这种情况下,它只会添加一个单独的过滤器:environ_filter

定义和命名过滤器感觉很笨重,但这是因为我们的例子太简单了。在更复杂的环境中,它允许您扩展和配置,而无需填充字典中的样板文件,使得更新或进一步扩展变得更加容易。

在命令行中进行快速测试表明新过滤器如何显示环境。在这个例子中,使用了基本的Pecan应用程序:

$ pecan serve config.py
Starting server in PID 25585
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080
2019-08-12 07:57:28,157 [DEVEL][INFO    ] [pecan.commands.serve] GET / 200

DEVEL的默认环境有效,将其更改为生产环境只需一个环境变量:

$ APP_ENVIRON='PRODUCTION' pecan serve config.py
Starting server in PID 2832
serving on 0.0.0.0:8080, view at http://127.0.0.1:8080
2019-08-12 08:15:46,552 [PRODUCTION][INFO    ] [pecan.commands.serve] GET / 200

常见模式

日志模块提供了一些非常好用但并非立即显而易见的模式。其中一个模式是使用logging.exception助手。常见的工作流程如下:

try:
    return expensive_operation()
except TypeError as error:
    logging.error("Running expensive_operation caused error: %s" % str(error))

这种工作流程在几个方面存在问题:它主要是吞噬异常,并仅报告其字符串表示。如果异常不明显或发生在不明显的位置,则报告TypeError是无用的。当字符串替换失败时,可能会得到一个 ValueError,但如果代码模糊了回溯,则该错误毫无帮助:

[ERROR] Running expensive_operation caused an error:
    TypeError: not all arguments converted during string formatting

发生在哪里?我们知道在调用expensive_operation()时会发生,但是在哪里?在哪个函数、类或文件中?这种记录方式不仅无益,而且令人恼火!日志模块可以帮助我们记录完整的异常回溯:

try:
    return expensive_operation()
except TypeError:
    logging.exception("Running expensive_operation caused error")

使用logging.exception助手会神奇地将完整的回溯推送到日志输出。实现不需要再担心像以前那样捕获error,甚至尝试从异常中检索有用信息。日志模块会处理一切。

另一种有用的模式是利用日志模块的内置能力进行字符串插值。以这段代码为例:

>>> logging.error(
"An error was produced when calling: expensive_operation, \
with arguments: %s, %s" % (arguments))

该语句需要两个字符串替换,并且假设arguments将有两个项目。如果arguments没有两个参数,上述语句将破坏生产代码。您绝对不希望因为日志记录而破坏生产代码。该模块有一个助手来捕获这种情况,将其报告为问题,并允许程序继续运行:

>>> logging.error("An error was produced when calling: expensive_operation, \
with arguments: %s, %s", arguments)

这是安全的,并且是向语句传递项目的推荐方式。

ELK 堆栈

就像 Linux、Apache、MySQL 和 PHP 被称为LAMP一样,您经常会听到ELK堆栈:Elasticsearch、Logstash 和 Kibana。该堆栈允许您从日志中提取信息,捕获有用的元数据,并将其发送到文档存储(Elasticsearch),然后使用强大的仪表板(Kibana)显示信息。了解每个部分对于有效地消费日志至关重要。该堆栈的每个组件同样重要,尽管您可能会发现每个组件都有类似的应用,但本节重点介绍它们在示例应用程序中的实际角色。

大多数生产系统已经运行了一段时间,你很少有机会从头开始重新设计基础架构。即使你有幸能够从头开始设计基础架构,也可能会忽视日志结构的重要性。适当的日志结构和捕获有用信息一样重要,但当结构不完整时,Logstash 可以提供帮助。在安装 Nginx 时,默认的日志输出类似于这样:

192.168.111.1 - - [03/Aug/2019:07:28:41 +0000] "GET / HTTP/1.1" 200 3700 "-" \
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0"

日志语句的某些部分很简单,比如 HTTP 方法(一个GET)和时间戳。如果你可以控制信息,丢弃无意义的内容或包含所需的数据,只要你清楚所有这些组件的含义即可。HTTP 服务器的配置在/etc/nginx/nginx.conf中包含了这些细节:

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
...

当你第一次看输出时,可能会认为破折号是表示缺失信息的,但这并不完全正确。在日志输出示例中,两个破折号跟在 IP 后面;一个只是装饰用的,第二个用于缺失信息。配置告诉我们,IP 后面跟着一个破折号,然后是$remote_user,在涉及认证时很有用,以便捕获已认证的用户。如果这是一个未启用认证的 HTTP 服务器,可以在有权限和访问权限的情况下从nginx.conf文件中删除$remote_user,或者可以使用从日志中提取元数据的规则来忽略它。让我们在下一节看看 Logstash 如何利用其大量的输入插件来帮助。

提示

Elasticsearch、Logstash 和 Kibana 通常在 Linux 发行版中可用。根据发行版的特性,需要导入正确的签名密钥,并配置包管理器以从正确的存储库获取。请参阅官方文档上的安装部分。确保也安装了 Filebeat 包。这是一个轻量级(但功能强大)的日志转发工具。稍后将用它将日志发送到 Logstash。

Logstash

决定采用 ELK 堆栈后的第一步是对一些 Logstash 规则进行修改,以从给定源中提取信息,过滤它,然后将其发送到一个服务(比如这种情况下的 Elasticsearch)。安装 Logstash 后,路径/etc/logstash/将可用,并且有一个有用的conf.d目录,我们可以在其中为不同的服务添加多个配置。我们的用例是捕获 Nginx 信息,对其进行过滤,然后将其发送到本地已安装并运行的 Elasticsearch 服务。

要消费日志,需要安装 filebeat 实用程序。这可以从安装 Elasticsearch、Kibana 和 Logstash 的相同软件仓库中获取。在配置 Logstash 之前,我们需要确保 Filebeat 已为 Nginx 日志文件和 Logstash 的位置进行了配置。

安装 Filebeat 后,在 /etc/filebeat/filebeat.yml 文件中定义(或取消注释)以下行,添加 Nginx 的日志路径以及 localhost (5044) 的默认 Logstash 端口:

filebeat.inputs:

- type: log
  enabled: true

  paths:
    - /var/log/nginx/*.log

output.logstash:
  hosts: ["localhost:5044"]

这使得 Filebeat 可以查看 /var/log/nginx/ 中的每个路径,并将其转发到 Logstash 的 localhost 实例。如果需要为另一个 Nginx 应用程序添加单独的日志文件,则在此处添加。配置文件中可能存在其他默认值,这些值应该保持不变。现在启动服务:

$ systemctl start filebeat

现在在 Logstash 配置目录(在 /etc/logstash/conf.d/ 中)创建一个新文件,命名为 nginx.conf。首先要添加的部分是处理输入:

input {
  beats {
    port => "5044"
  }
}

input 部分表明信息来源将通过 Filebeat 服务,使用 5044 端口。由于所有文件路径配置都在 Filebeat 配置中完成,这里不需要其他配置。

接下来,我们需要提取信息并映射到键(或字段)。需要设置一些解析规则以理解我们处理的非结构化数据。对于这种类型的解析,请使用 grok 插件;将以下配置追加到同一文件中:

filter {
  grok {
    match => { "message" => "%{COMBINEDAPACHELOG}"}
  }
}

filter 部分现在定义了使用 grok 插件来接收传入行并应用强大的 COMBINEDAPACHELOG,这是一组正则表达式,可以准确地查找并映射来自 Nginx 的 Web 服务器日志的所有组件。

最后,输出部分需要设置新结构化数据的目标位置:

output {
  elasticsearch {
    hosts => ["localhost:9200"]
  }
}

这意味着所有结构化数据都会发送到 Elasticsearch 的本地实例。正如你所看到的,对于 Logstash(和 Filebeat 服务),配置非常简单。可以添加多个插件和配置选项来进一步微调日志收集和解析。这种“一应俱全”的方法非常适合初学者,无需探索扩展或插件。如果你感兴趣,可以浏览 Logstash 源代码,并搜索包含 COMBINEDAPACHELOGgrok-patterns 文件,其中包含一组相当不错的正则表达式。

Elasticsearch 和 Kibana

安装完 elasticsearch 包后,你几乎不需要做其他操作就可以在本地搭建一个可以接收来自 Logstash 的结构化数据的运行环境。确保服务已启动并正常运行:

$ systemctl start elasticsearch

类似地,安装 kibana 包并启动服务:

$ systemctl start kibana

第一次启动 Kibana 后,在浏览日志输出时,它立即开始寻找运行在主机上的 Elasticsearch 实例。这是其自身 Elasticsearch 插件的默认行为,无需额外配置。行为是透明的,消息告诉你它已经成功初始化插件并连接到 Elasticsearch:

{"type":"log","@timestamp":"2019-08-09T12:34:43Z",
"tags":["status","plugin:elasticsearch@7.3.0","info"],"pid":7885,
"state":"yellow",
"message":"Status changed from uninitialized to yellow",
"prevState":"uninitialized","prevMsg":"uninitialized"}

{"type":"log","@timestamp":"2019-08-09T12:34:45Z",
"tags":["status","plugin:elasticsearch@7.3.0","info"],"pid":7885,
"state":"green","message":"Status changed from yellow to green - Ready",
"prevState":"yellow","prevMsg":"Waiting for Elasticsearch"}

在将配置更改为不正确的端口后,日志非常清楚地表明自动行为并未完全起作用:

{"type":"log","@timestamp":"2019-08-09T12:59:27Z",
"tags":["error","elasticsearch","data"],"pid":8022,
"message":"Request error, retrying
  GET http://localhost:9199/_xpack => connect ECONNREFUSED 127.0.0.1:9199"}

{"type":"log","@timestamp":"2019-08-09T12:59:27Z",
"tags":["warning","elasticsearch","data"],"pid":8022,
"message":"Unable to revive connection: http://localhost:9199/"}

一旦 Kibana 正常运行,以及 Elasticsearch(在正确的端口上!)、Filebeat 和 Logstash,你将看到一个功能齐全的仪表板,并有很多选项可供开始使用,就像 图 7-1 中展示的那样。

pydo 0701

图 7-1. Kibana 的起始仪表板页面

访问本地的 Nginx 实例以在日志中生成一些活动并启动数据处理。在这个例子中,使用了 Apache 基准测试工具 (ab),但你也可以用你的浏览器或直接使用 curl 测试:

$ ab -c 8 -n 50 http://localhost/
This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient).....done

要打开 Kibana 的默认 URL 和端口(运行在 [*http://localhost:5601*](http://localhost:5601)),无需做任何特定的配置。默认视图提供了大量添加选项。在 discover 部分,你将看到所有请求的结构化信息。这是 Logstash 处理的示例 JSON 片段,可以在 Kibana 中使用(Kibana 从 Elasticsearch 获取数据):

...
    "input": {
      "type": "log"
    },
    "auth": "-",
    "ident": "-",
    "request": "/",
    "response": "200",
    "@timestamp": "2019-08-08T21:03:46.513Z",
    "verb": "GET",
    "@version": "1",
    "referrer": "\"-\"",
    "httpversion": "1.1",
    "message": "::1 - - [08/Aug/2019:21:03:45 +0000] \"GET / HTTP/1.1\" 200",
    "clientip": "::1",
    "geoip": {},
    "ecs": {
      "version": "1.0.1"
    },
    "host": {
      "os": {
        "codename": "Core",
        "name": "CentOS Linux",
        "version": "7 (Core)",
        "platform": "centos",
        "kernel": "3.10.0-957.1.3.el7.x86_64",
        "family": "redhat"
      },
      "id": "0a75ccb95b4644df88f159c41fdc7cfa",
      "hostname": "node2",
      "name": "node2",
      "architecture": "x86_64",
      "containerized": false
    },
    "bytes": "3700"
  },
  "fields": {
    "@timestamp": [
      "2019-08-08T21:03:46.513Z"
    ]
  }
...

重要的键,如 verbtimestamprequestresponse,已被 Logstash 解析并捕获。在这个初始设置中有很多工作要做,以将其转换为更有用和实用的内容。捕获的元数据可以帮助呈现流量(包括地理位置),而且 Kibana 甚至可以为数据设置阈值警报,用于在特定指标超出或低于设定值时进行警报。

在仪表板中,可以拆解这些结构化数据并用于创建有意义的图表和展示,如 图 7-2 所示。

即使 Kibana 是一个仪表板,并且 ELK 堆栈并非构建于 Python 上,这些服务集成得非常好,展示了出色的平台设计和架构。

图 7-2. Kibana 中的结构化数据

正如我们所见,ELK 堆栈可以让您开始捕获和解析日志,几乎不需要配置和努力。这些示例可能很简单,但已经演示了其组件的巨大能力。我们往往会面对一些基础设施,其中有一个 cron 记录正在 tail 日志并 grep 某些模式以发送电子邮件或向 Nagios 提交警报。使用功能强大的软件组件,并理解它们即使在最简单的形式下也能为您做多少,对于更好的基础设施至关重要,而在这种情况下,能够更好地看到基础设施正在做什么也非常重要。

练习

  • 什么是容错性,它如何帮助基础设施系统?

  • 针对产生大量日志的系统可以采取哪些措施?

  • 解释为什么在将指标推送到其他系统时可能更喜欢 UDP。为什么 TCP 可能会有问题?

  • 描述 拉取推送 系统之间的区别。在什么情况下哪种更好?

  • 制定一个命名规范,用于存储适用于生产环境、Web 和数据库服务器以及不同应用程序名称的指标。

案例研究问题

  • 创建一个 Flask 应用程序,完全实现不同级别(info、debug、warning 和 error)的日志记录,并在产生异常时通过 StatsD 服务将指标(如计数器)发送到远程 Graphite 实例。

第八章:DevOps 的 Pytest

持续集成、持续交付、部署以及一般的管道工作流,只要稍加思考,就会充满验证。这种验证可以在每一步和达到重要目标时发生。

例如,在生成部署的一长串步骤中,如果调用 curl 命令获取一个非常重要的文件,如果失败了,你认为构建应该继续吗?可能不应该!curl 有一个标志可以用来产生非零的退出状态(--fail),如果发生 HTTP 错误。这个简单的标志用法是一种验证:确保请求成功,否则失败构建步骤。关键词确保某事成功,这正是本章的核心:可以帮助您构建更好基础设施的验证和测试策略。

考虑到 Python 混合其中时,思考验证变得更加令人满意,利用像 pytest 这样的测试框架来处理系统的验证。

本章回顾了使用出色的 pytest 框架进行 Python 测试的一些基础知识,然后深入探讨了框架的一些高级特性,最后详细介绍了 TestInfra 项目,这是 pytest 的一个插件,可进行系统验证。

使用 pytest 进行测试超能力

我们对 pytest 框架赞不绝口。由 Holger Krekel 创建,现在由一些人维护,他们出色地生产出一个高质量的软件,通常是我们日常工作的一部分。作为一个功能齐全的框架,很难将范围缩小到足以提供有用的介绍,而不重复项目的完整文档。

小贴士

pytest 项目在其文档中有大量信息、示例和特性细节值得查看。随着项目持续提供新版本和改进测试的不同方法,总是有新东西可以学习。

当 Alfredo 首次接触这个框架时,他在尝试编写测试时遇到了困难,并发现要遵循 Python 的内置测试方式与 unittest 相比有些麻烦(本章稍后将详细讨论这些差异)。他花了几分钟时间就迷上了 pytest 的神奇报告功能。它不强迫他改变他编写测试的方式,而且可以立即使用,无需修改!这种灵活性贯穿整个项目,即使今天可能无法做到的事情,也可以通过插件或配置文件扩展其功能。

通过了解如何编写更简单的测试用例,并利用命令行工具、报告引擎、插件可扩展性和框架实用程序,您将希望编写更多无疑更好的测试。

开始使用 pytest

在其最简单的形式中,pytest是一个命令行工具,用于发现 Python 测试并执行它们。它不强迫用户理解其内部机制,这使得入门变得容易。本节演示了一些最基本的功能,从编写测试到布置文件(以便它们被自动发现),最后看看它与 Python 内置测试框架unittest的主要区别。

提示

大多数集成开发环境(IDE),如 PyCharm 和 Visual Studio Code,都内置了对运行pytest的支持。如果使用像 Vim 这样的文本编辑器,则可以通过pytest.vim插件进行支持。从编辑器中使用pytest可以节省时间,使调试失败变得更容易,但要注意,并非每个选项或插件都受支持。

使用 pytest 进行测试

确保您已安装并可以在命令行中使用pytest

$ python3 -m venv testing
$ source testing/bin/activate

创建一个名为test_basic.py的文件;它应该看起来像这样:

def test_simple():
    assert True

def test_fails():
    assert False

如果pytest在没有任何参数的情况下运行,它应该显示一个通过和一个失败:

$ (testing) pytest
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
rootdir: /home/alfredo/python/testing
collected 2 items

test_basic.py .F                                                        [100%]

================================== FAILURES ===================================
_________________________________ test_fails __________________________________

    def test_fails():
>       assert False
E       assert False

test_basic.py:6: AssertionError
===================== 1 failed, 1 passed in 0.02 seconds ======================

输出从一开始就非常有益;它显示了收集了多少个测试、通过了多少个测试以及失败了哪一个测试(包括其行号)。

提示

pytest的默认输出非常方便,但可能过于冗长。您可以通过配置控制输出量,使用-q标志来减少输出量。

不需要创建一个包含测试的类;函数被发现并正确运行。测试套件可以同时包含两者的混合,而框架在这种环境下也能正常工作。

布局和约定

在 Python 中进行测试时,pytest隐含遵循一些约定。这些约定大多数是关于命名和结构的。例如,尝试将test_basic.py文件重命名为basic.py,然后运行pytest看看会发生什么:

$ (testing) pytest -q

no tests ran in 0.00 seconds

由于将测试文件前缀为test_的约定,没有运行任何测试。如果将文件重命名回test_basic.py,它应该能够被自动发现并运行测试。

注意

布局和约定对于自动发现测试非常有帮助。可以配置框架以使用其他命名约定或直接测试具有唯一名称的文件。但是,遵循基本预期有助于避免测试不运行时的混淆。

这些都是将工具用于发现测试的约定:

  • 测试目录需要命名为tests

  • 测试文件需要以test作为前缀;例如,test_basic.py,或者以test.py作为后缀。

  • 测试函数需要以test_作为前缀;例如,def test_simple():

  • 测试类需要以Test作为前缀;例如,class TestSimple

  • 测试方法遵循与函数相同的约定,以test_作为前缀;例如,def test_method(self):

因为在自动发现和执行测试时需要前缀test_,所以可以引入带有不同名称的帮助函数和其他非测试代码,以便自动排除它们。

与 unittest 的差异

Python 已经提供了一套用于测试的实用工具和辅助程序,它们是unittest模块的一部分。了解pytest与其不同之处以及为什么强烈推荐使用它是很有用的。

unittest模块强制使用类和类继承。对于了解面向对象编程和类继承的经验丰富的开发人员,这不应该是一个问题,但对于初学者来说,这是一个障碍。写基本测试不应该要求使用类和继承!

强制用户从unittest.TestCase继承的部分是,您必须理解(并记住)用于验证结果的大多数断言方法。使用pytest时,有一个可以完成所有工作的单一断言助手:assert

这些是在使用unittest编写测试时可以使用的几个断言方法。其中一些很容易理解,而另一些则非常令人困惑:

  • self.assertEqual(a, b)

  • self.assertNotEqual(a, b)

  • self.assertTrue(x)

  • self.assertFalse(x)

  • self.assertIs(a, b)

  • self.assertIsNot(a, b)

  • self.assertIsNone(x)

  • self.assertIsNotNone(x)

  • self.assertIn(a, b)

  • self.assertNotIn(a, b)

  • self.assertIsInstance(a, b)

  • self.assertNotIsInstance(a, b)

  • self.assertRaises(exc, fun, *args, **kwds)

  • self.assertRaisesRegex(exc, r, fun, *args, **kwds)

  • self.assertWarns(warn, fun, *args, **kwds)

  • self.assertWarnsRegex(warn, r, fun, *args, **kwds)

  • self.assertLogs(logger, level)

  • self.assertMultiLineEqual(a, b)

  • self.assertSequenceEqual(a, b)

  • self.assertListEqual(a, b)

  • self.assertTupleEqual(a, b)

  • self.assertSetEqual(a, b)

  • self.assertDictEqual(a, b)

  • self.assertAlmostEqual(a, b)

  • self.assertNotAlmostEqual(a, b)

  • self.assertGreater(a, b)

  • self.assertGreaterEqual(a, b)

  • self.assertLess(a, b)

  • self.assertLessEqual(a, b)

  • self.assertRegex(s, r)

  • self.assertNotRegex(s, r)

  • self.assertCountEqual(a, b)

pytest允许您专门使用assert,并且不强制您使用上述任何一种方法。此外,它确实允许您使用unittest编写测试,并且甚至执行这些测试。我们强烈建议不要这样做,并建议您专注于只使用普通的 assert 语句。

不仅使用普通的 assert 更容易,而且pytest在失败时还提供了丰富的比较引擎(下一节将详细介绍)。

pytest 功能

除了使编写测试和执行测试更容易外,该框架还提供了许多可扩展的选项,例如钩子。钩子允许您在运行时的不同点与框架内部进行交互。例如,如果要修改测试的收集,可以添加一个收集引擎的钩子。另一个有用的示例是,如果要在测试失败时实现更好的报告。

在开发 HTTP API 时,我们发现有时测试中使用 HTTP 请求针对应用程序的失败并不有益:断言失败会被报告,因为预期的响应(HTTP 200)是 HTTP 500 错误。我们想要了解更多关于请求的信息:到哪个 URL 端点?如果是 POST 请求,是否有数据?它是什么样子的?这些信息已经包含在 HTTP 响应对象中,因此我们编写了一个钩子来查看这个对象,并将所有这些项目包含在失败报告中。

钩子是pytest的高级功能,您可能根本不需要,但了解框架可以灵活适应不同需求是有用的。接下来的章节涵盖如何扩展框架,为什么使用assert如此宝贵,如何参数化测试以减少重复,如何使用fixtures制作帮助工具,以及如何使用内置工具。

conftest.py

大多数软件都允许您通过插件扩展功能(例如,Web 浏览器称其为扩展);同样地,pytest有一个丰富的 API 用于开发插件。这里没有涵盖完整的 API,但其更简单的方法是:conftest.py文件。在这个文件中,工具可以像插件一样扩展。无需完全理解如何创建单独的插件、打包它并安装它。如果存在conftest.py文件,框架将加载它并消费其中的任何特定指令。这一切都是自动进行的!

通常,您会发现conftest.py文件用于保存钩子、fixtures 和这些 fixtures 的 helpers。如果声明为参数,这些fixtures可以在测试中使用(该过程稍后在 fixture 部分描述)。

当多个测试模块将使用它时,将 fixtures 和 helpers 添加到此文件是有意义的。如果只有一个单独的测试文件,或者只有一个文件将使用 fixture 或 hook,那么无需创建或使用conftest.py文件。Fixtures 和 helpers 可以在与测试相同的文件中定义并表现相同的行为。

加载conftest.py文件的唯一条件是存在于tests目录中并正确匹配名称。此外,尽管此名称是可配置的,但我们建议不要更改它,并鼓励您遵循默认命名约定以避免潜在问题。

令人惊叹的assert

当我们不得不描述pytest工具的强大之处时,我们首先描述assert语句的重要用途。幕后,框架检查对象并提供丰富的比较引擎以更好地描述错误。通常会遇到抵制,因为 Python 中的裸assert很糟糕地描述错误。以比较两个长字符串为例:

>>> assert "using assert for errors" == "using asert for errors"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

差异在哪里?如果不花时间仔细查看这两行长字符串,很难说清楚。这会导致人们建议不要这样做。一个小测试展示了pytest在报告失败时如何增强:

$ (testing) pytest test_long_lines.py
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
collected 1 item

test_long_lines.py F                                                    [100%]

================================== FAILURES ===================================
_______________________________ test_long_lines _______________________________

    def test_long_lines():
>      assert "using assert for errors" == "using asert for errors"
E      AssertionError: assert '...rt for errors' == '...rt for errors'
E        - using assert for errors
E        ?        -
E        + using asert for errors

test_long_lines.py:2: AssertionError
========================== 1 failed in 0.04 seconds ===========================

你能说出错误在哪里吗?这极大地简化了。它不仅告诉你失败了,还指出失败发生的确切位置。例如,一个简单的断言与一个长字符串,但是这个框架可以处理其他数据结构,如列表和字典,毫无问题。你有没有在测试中比较过非常长的列表?很难轻松地分辨出哪些项目不同。这里是一个有长列表的小片段:

    assert ['a', 'very', 'long', 'list', 'of', 'items'] == [
            'a', 'very', 'long', 'list', 'items']
E   AssertionError: assert [...'of', 'items'] == [...ist', 'items']
E     At index 4 diff: 'of' != 'items'
E     Left contains more items, first extra item: 'items'
E     Use -v to get the full diff

在通知用户测试失败后,它精确指向索引号(第四个或第五个项目),最后说一个列表有一个额外的项目。没有这种深入的反思,调试失败将需要很长时间。报告中的额外奖励是,默认情况下,在进行比较时省略非常长的项目,因此输出中只显示相关部分。毕竟,你想知道的不仅是列表(或任何其他数据结构)不同之处,而且确切地在哪里不同。

参数化

参数化是一个需要一些时间来理解的特性,因为它在unittest模块中不存在,是 pytest 框架独有的特性。一旦你发现自己编写非常相似的测试,只是输入稍有不同,但测试的是同一个东西时,它就会变得很清晰。举个例子,这个类正在测试一个函数,如果一个字符串暗示一个真实的值,则返回Truestring_to_bool是测试的函数:

from my_module import string_to_bool

class TestStringToBool(object):

    def test_it_detects_lowercase_yes(self):
        assert string_to_bool('yes')

    def test_it_detects_odd_case_yes(self):
        assert string_to_bool('YeS')

    def test_it_detects_uppercase_yes(self):
        assert string_to_bool('YES')

    def test_it_detects_positive_str_integers(self):
        assert string_to_bool('1')

    def test_it_detects_true(self):
        assert string_to_bool('true')

    def test_it_detects_true_with_trailing_spaces(self):
        assert string_to_bool('true ')

    def test_it_detects_true_with_leading_spaces(self):
        assert string_to_bool(' true')

看看所有这些测试如何从相似的输入评估相同的结果?这就是参数化发挥作用的地方,因为它可以将所有这些值分组并传递给测试;它可以有效地将它们减少到单个测试中:

import pytest
from my_module import string_to_bool

true_values = ['yes', '1', 'Yes', 'TRUE', 'TruE', 'True', 'true']

class TestStrToBool(object):

    @pytest.mark.parametrize('value', true_values)
    def test_it_detects_truish_strings(self, value)
        assert string_to_bool(value)

这里发生了几件事情。首先导入了pytest(框架)以使用pytest.mark.parametrize模块,然后将true_values定义为应评估相同的所有值的(列表)变量,并最后将所有测试方法替换为单一方法。测试方法使用parametrize装饰器,定义了两个参数。第一个是字符串*value*,第二个是先前定义的列表的名称。这可能看起来有点奇怪,但它告诉框架*value*是在测试方法中使用的参数名称。这就是value参数的来源!

如果在运行时增加了详细输出,输出将显示确切传入的值。它几乎看起来像单个测试被复制到每次迭代中传入的值中:

test_long_lines.py::TestLongLines::test_detects_truish_strings[yes] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[1] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[Yes] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[TRUE] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[TruE] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[True] PASSED
test_long_lines.py::TestLongLines::test_detects_truish_strings[true] PASSED

输出包括 单个测试 中每次迭代中使用的值,用方括号括起来。它将非常冗长的测试类简化为单个测试方法,多亏了 parametrize。下次您发现自己编写非常相似的测试并且使用不同的输入来断言相同的结果时,您将知道可以通过 parametrize 装饰器简化它。

Fixture

我们把 pytest Fixture 想象成可以注入到测试中的小助手。无论您是编写单个测试函数还是一堆测试方法,Fixture 都可以以相同的方式使用。如果它们不会被其他测试文件共享,那么可以在同一个测试文件中定义它们;否则它们可以放在 conftest.py 文件中。Fixture 就像帮助函数一样,可以是任何您需要的测试用例,从预创建的简单数据结构到为 Web 应用程序设置数据库等更复杂的功能。

这些助手还可以有定义的 scope。它们可以有特定的代码,为每个测试方法、类和模块进行清理,或者甚至允许为整个测试会话设置它们一次。通过在测试方法(或测试函数)中定义它们,您实际上是在运行时获取 Fixture 的注入。如果这听起来有点混乱,通过下几节中的示例将变得清晰起来。

入门

用来定义和使用的 Fixture 如此简单,以至于它们经常被滥用。我们知道我们创建了一些本来可以简化为简单帮助方法的 Fixture!正如我们已经提到的,Fixture 有许多不同的用例——从简单的数据结构到更复杂的用例,例如为单个测试设置整个数据库。

最近,Alfredo 不得不测试一个解析特定文件内容的小应用程序,该文件称为 keyring file。它具有类似 INI 文件的结构,某些值必须是唯一的,并且遵循特定的格式。在每次测试中重新创建文件结构可能非常繁琐,因此创建了一个 Fixture 来帮助。这就是 keyring file 的外观:

[mon.]
    key = AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==
    caps mon = "allow *"

Fixture 是一个返回 keyring file 内容的函数。让我们创建一个名为 test_keyring.py 的新文件,其中包含 Fixture 的内容,以及验证默认键的小测试函数:

import pytest
import random

@pytest.fixture
def mon_keyring():
    def make_keyring(default=False):
        if default:
            key = "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A=="
        else:
            key = "%032x==" % random.getrandbits(128)

        return """
 [mon.]
 key = %s
 caps mon = "allow *"
 """ % key
    return make_keyring

def test_default_key(mon_keyring):
    contents = mon_keyring(default=True)
    assert "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==" in contents

Fixture 使用一个执行繁重工作的嵌套函数,允许使用一个 default 键值,并在调用者希望有随机键时返回嵌套函数。在测试中,它通过声明为测试函数的参数的一部分接收 Fixture(在本例中为 mon_keyring),并使用 default=True 调用 Fixture,以便使用默认键,然后验证它是否按预期生成。

注意

在真实场景中,生成的内容将被传递给解析器,确保解析后的行为符合预期,并且没有错误发生。

使用此固件的生产代码最终发展到执行其他类型的测试,并且在某些时候,测试希望验证解析器能够处理不同条件下的文件。该固件返回一个字符串,因此需要扩展它。现有测试已经使用了 mon_keyring 固件,因此为了在不更改当前固件的情况下扩展功能,创建了一个新的固件,该固件使用了框架的一个特性。固件可以 请求 其他固件!您将所需的固件定义为参数(就像测试函数或测试方法一样),因此在执行时框架会注入它。

这是创建(并返回)文件的新固件的方式:

@pytest.fixture
def keyring_file(mon_keyring, tmpdir):
    def generate_file(default=False):
        keyring = tmpdir.join('keyring')
        keyring.write_text(mon_keyring(default=default))
        return keyring.strpath
    return generate_file

按行解释,pytest.fixture装饰器告诉框架这个函数是一个固件,然后定义了固件,请求 两个固件 作为参数:mon_keyringtmpdir。第一个是前面在 test_keyring.py 文件中创建的,第二个是框架提供的内置固件(关于内置固件的更多内容将在下一节讨论)。tmpdir 固件允许您使用一个临时目录,在测试完成后将其删除,然后创建 keyring 文件,并写入由 mon_keyring 固件生成的文本,传递 default 参数。最后,它返回新创建文件的绝对路径,以便测试可以使用它。

这是测试函数如何使用它的方式:

def test_keyring_file_contents(keyring_file):
    keyring_path = keyring_file(default=True)
    with open(keyring_path) as fp:
        contents = fp.read()
    assert "AQBvaBFZAAAAABAA9VHgwCg3rWn8fMaX8KL01A==" in contents

您现在应该对固件是什么,您可以在哪里定义它们以及如何在测试中使用它们有了一个很好的理解。下一部分将介绍一些最有用的内置固件,这些固件是框架的一部分。

内置固件

前一节简要介绍了 pytest 提供的众多内置固件之一:tmpdir 固件。框架提供了更多固件。要验证可用固件的完整列表,请运行以下命令:

$ (testing) pytest  -q --fixtures

我们经常使用的两个固件是 monkeypatchcapsys,当运行上述命令时,它们都在生成的列表中。这是您将在终端看到的简要描述:

capsys
    enables capturing of writes to sys.stdout/sys.stderr and makes
    captured output available via ``capsys.readouterr()`` method calls
    which return a ``(out, err)`` tuple.
monkeypatch
    The returned ``monkeypatch`` funcarg provides these
    helper methods to modify objects, dictionaries or os.environ::

    monkeypatch.setattr(obj, name, value, raising=True)
    monkeypatch.delattr(obj, name, raising=True)
    monkeypatch.setitem(mapping, name, value)
    monkeypatch.delitem(obj, name, raising=True)
    monkeypatch.setenv(name, value, prepend=False)
    monkeypatch.delenv(name, value, raising=True)
    monkeypatch.syspath_prepend(path)
    monkeypatch.chdir(path)

    All modifications will be undone after the requesting
    test function has finished. The ``raising``
    parameter determines if a KeyError or AttributeError
    will be raised if the set/deletion operation has no target.

capsys 捕获测试中产生的任何 stdoutstderr。您是否尝试过验证某些命令输出或日志记录在单元测试中的输出?这很难做到,并且需要一个单独的插件或库来 patch Python 的内部,然后检查其内容。

这是验证分别在 stderrstdout 上产生的输出的两个测试函数:

import sys

def stderr_logging():
    sys.stderr.write('stderr output being produced')

def stdout_logging():
    sys.stdout.write('stdout output being produced')

def test_verify_stderr(capsys):
    stderr_logging()
    out, err = capsys.readouterr()
    assert out == ''
    assert err == 'stderr output being produced'

def test_verify_stdout(capsys):
    stdout_logging()
    out, err = capsys.readouterr()
    assert out == 'stdout output being produced'
    assert err == ''

capsys 固件处理所有补丁,设置和助手,以检索测试中生成的 stderrstdout。每次测试都会重置内容,这确保变量填充了正确的输出。

monkeypatch可能是我们最常使用的装置。在测试时,有些情况下测试的代码不在我们的控制之下,修补就需要发生来覆盖模块或函数以具有特定的行为。Python 中有相当多的修补模拟库(模拟是帮助设置修补对象行为的助手),但monkeypatch足够好,你可能不需要安装额外的库来帮忙。

以下函数运行系统命令以捕获设备的详细信息,然后解析输出,并返回一个属性(由blkid报告的ID_PART_ENTRY_TYPE):

import subprocess

def get_part_entry_type(device):
    """
 Parses the ``ID_PART_ENTRY_TYPE`` from the "low level" (bypasses the cache)
 output that uses the ``udev`` type of output.
 """
    stdout = subprocess.check_output(['blkid', '-p', '-o', 'udev', device])
    for line in stdout.split('\n'):
        if 'ID_PART_ENTRY_TYPE=' in line:
            return line.split('=')[-1].strip()
    return ''

要进行测试,设置所需的行为在subprocess模块的check_output属性上。这是使用monkeypatch装置的测试函数的外观:

def test_parses_id_entry_type(monkeypatch):
    monkeypatch.setattr(
        'subprocess.check_output',
        lambda cmd: '\nID_PART_ENTRY_TYPE=aaaaa')
    assert get_part_entry_type('/dev/sda') == 'aaaa'

setattr调用设置修补过的可调用对象(在本例中为check_output)。补丁它的是一个返回感兴趣行的 lambda 函数。由于subprocess.check_output函数不在我们的直接控制之下,并且get_part_entry_type函数不允许任何其他方式来注入值,修补是唯一的方法。

我们倾向于使用其他技术,如在尝试修补之前注入值(称为依赖注入),但有时没有其他方法。提供一个可以修补和处理所有测试清理工作的库,这是pytest是一种愉悦的工作方式的更多原因之一。

基础设施测试

本节解释了如何使用Testinfra 项目进行基础设施测试和验证。这是一个依赖于装置的pytest插件,允许您编写 Python 测试,就像测试代码一样。

前几节详细讨论了pytest的使用和示例,本章以系统级验证的概念开始。我们解释基础设施测试的方式是通过问一个问题:如何确定部署成功?大多数情况下,这意味着一些手动检查,如加载网站或查看进程,这是不够的;这是错误的,并且如果系统很重要的话可能会变得乏味。

虽然最初可以将pytest视为编写和运行 Python 单元测试的工具,但将其重新用于基础设施测试可能是有利的。几年前,阿尔弗雷多被委托制作一个安装程序,通过 HTTP API 公开其功能。该安装程序旨在创建一个 Ceph 集群,涉及多台机器。在启动 API 的质量保证阶段,常常会收到集群未按预期工作的报告,因此他会获取凭据以登录这些机器并进行检查。一旦必须调试包含多台机器的分布式系统时,就会产生乘数效应:多个配置文件、不同的硬盘、网络设置,任何和所有的东西都可能不同,即使它们看起来很相似。

每当阿尔弗雷多需要调试这些系统时,他都会有一个日益增长的检查清单。服务器上的配置是否相同?权限是否符合预期?特定用户是否存在?最终他会忘记某些事情,并花时间试图弄清楚自己漏掉了什么。这是一个不可持续的过程。如果我能写一些简单的测试用例来针对集群? 阿尔弗雷多编写了几个简单的测试来验证清单上的项目,并执行它们以检查构成集群的机器。在他意识到之前,他已经拥有了一套很好的测试,只需几秒钟即可运行,可以识别各种问题。

这对于改进交付流程是一个令人难以置信的启示。他甚至可以在开发安装程序时执行这些(功能)测试,并发现不正确的地方。如果 QA 团队发现任何问题,他可以针对其设置运行相同的测试。有时测试会捕捉到环境问题:一个硬盘脏了并导致部署失败;来自不同集群的配置文件遗留下来并引发问题。自动化、精细化测试以及频繁运行它们使工作变得更好,并减轻了 QA 团队需要处理的工作量。

TestInfra 项目具有各种夹具,可高效测试系统,并包含一整套用于连接服务器的后端,无论其部署类型如何:Ansible、Docker、SSH 和 Kubernetes 是一些支持的连接方式。通过支持多种不同的连接后端,可以执行相同的一组测试,而不受基础设施更改的影响。

接下来的章节将介绍不同的后端,并展示一个生产项目的示例。

什么是系统验证?

系统验证可以在不同级别(使用监控和警报系统)和应用程序生命周期的不同阶段进行,例如在预部署阶段、运行时或部署期间。最近由 Alfredo 投入生产的应用程序需要在重新启动时优雅地处理客户端连接,即使有任何中断也不受影响。为了维持流量,应用程序进行了负载均衡:在系统负载较重时,新的连接会被发送到负载较轻的其他服务器。

当部署新版本时,应用程序必须重新启动。重新启动意味着客户端在最佳情况下会遇到奇怪的行为,最坏的情况下会导致非常糟糕的体验。为了避免这种情况,重新启动过程等待所有客户端连接终止,系统拒绝新的连接,允许其完成来自现有客户端的工作,其余系统继续工作。当没有活动连接时,部署继续并停止服务以获取更新的代码。

沿途的每一步都进行了验证:在部署之前通知负载均衡器停止发送新的客户端,并且后来验证没有新的客户端处于活动状态。如果该工作流程转换为测试,标题可能类似于:确保当前没有客户端在运行。一旦新代码就位,另一个验证步骤检查负载均衡器是否已确认服务器再次准备好生成工作。这里的另一个测试可能是:负载均衡器已将服务器标记为活动。最后,确保服务器正在接收新的客户端连接——又一个要编写的测试!

在这些步骤中,验证已经到位,可以编写测试来验证这种类型的工作流程。

系统验证也可以与监控服务器的整体健康状况(或集群环境中的多个服务器)相关联,或者作为开发应用程序和功能测试的持续集成的一部分。验证的基础知识适用于这些情况以及可能从状态验证中受益的任何其他情况。它不应仅用于测试,尽管这是一个很好的开始!

Testinfra 简介

针对基础设施编写单元测试是一个强大的概念,使用 Testinfra 一年多以来,我们可以说它提高了我们必须交付的生产应用程序的质量。以下部分详细介绍了如何连接到不同节点并执行验证测试,并探讨了可用的固定装置类型。

要创建新的虚拟环境,请安装pytest

$ python3 -m venv validation
$ source testing/bin/activate
(validation) $ pip install pytest

安装testinfra,确保使用版本2.1.0

(validation) $ pip install "testinfra==2.1.0"
注意

pytest固定装置提供了 Testinfra 项目提供的所有测试功能。要利用本节,您需要了解它们是如何工作的。

连接到远程节点

因为存在不同的后端连接类型,当未直接指定连接时,Testinfra 默认到某些类型。最好明确指定连接类型并在命令行中定义它。

这些是 Testinfra 支持的所有连接类型:

  • 本地

  • Paramiko(Python 中的 SSH 实现)

  • Docker

  • SSH

  • Salt

  • Ansible

  • Kubernetes(通过 kubectl)

  • WinRM

  • LXC

在帮助菜单中会出现一个 testinfra 部分,提供一些关于提供的标志的上下文。这是来自 pytest 与其与 Testinfra 集成的一个不错的特性。这两个项目的帮助来自相同的命令:

(validation) $ pytest --help
...

testinfra:
  --connection=CONNECTION
                        Remote connection backend (paramiko, ssh, safe-ssh,
                        salt, docker, ansible)
  --hosts=HOSTS         Hosts list (comma separated)
  --ssh-config=SSH_CONFIG
                        SSH config file
  --ssh-identity-file=SSH_IDENTITY_FILE
                        SSH identify file
  --sudo                Use sudo
  --sudo-user=SUDO_USER
                        sudo user
  --ansible-inventory=ANSIBLE_INVENTORY
                        Ansible inventory file
  --nagios              Nagios plugin

有两台服务器正在运行。为了演示连接选项,让我们检查它们是否在运行 CentOS 7,查看 /etc/os-release 文件的内容。这是测试函数的外观(保存为 test_remote.py):

def test_release_file(host):
    release_file = host.file("/etc/os-release")
    assert release_file.contains('CentOS')
    assert release_file.contains('VERSION="7 (Core)"')

它是一个单一的测试函数,接受 host fixture,并针对所有指定的节点运行。

--hosts 标志接受一个主机列表,并使用连接方案(例如 SSH 将使用 *ssh://hostname*),还允许使用通配符进行一些其他变体。如果一次测试多个远程服务器,则在命令行中传递它们变得很麻烦。以下是使用 SSH 测试两台服务器的方式:

(validation) $ pytest -v --hosts='ssh://node1,ssh://node2' test_remote.py
============================= test session starts =============================
platform linux -- Python 3.6.8, pytest-4.4.1, py-1.8.0, pluggy-0.9.0
cachedir: .pytest_cache
rootdir: /home/alfredo/python/python-devops/samples/chapter16
plugins: testinfra-3.0.0, xdist-1.28.0, forked-1.0.2
collected 2 items

test_remote.py::test_release_file[ssh://node1] PASSED                   [ 50%]
test_remote.py::test_release_file[ssh://node2] PASSED                   [100%]

========================== 2 passed in 3.82 seconds ===========================

使用增加冗余信息(使用 -v 标志)显示 Testinfra 在调用中指定的两台远程服务器中执行一个测试函数。

注意

在设置主机时,具有无密码连接是重要的。不应该有任何密码提示,如果使用 SSH,则应该使用基于密钥的配置。

当自动化这些类型的测试(例如作为 CI 系统中的作业的一部分)时,您可以从生成主机、确定它们如何连接以及任何其他特殊指令中受益。Testinfra 可以使用 SSH 配置文件确定要连接的主机。在上一次测试运行中,使用了 Vagrant,它创建了具有特殊密钥和连接设置的这些服务器。Vagrant 可以为其创建的服务器生成临时 SSH 配置文件:

(validation) $ vagrant ssh-config

Host node1
  HostName 127.0.0.1
  User vagrant
  Port 2200
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /home/alfredo/.vagrant.d/insecure_private_key
  IdentitiesOnly yes
  LogLevel FATAL

Host node2
  HostName 127.0.0.1
  User vagrant
  Port 2222
  UserKnownHostsFile /dev/null
  StrictHostKeyChecking no
  PasswordAuthentication no
  IdentityFile /home/alfredo/.vagrant.d/insecure_private_key
  IdentitiesOnly yes
  LogLevel FATAL

将输出内容导出到文件,然后传递给 Testinfra 可以提供更大的灵活性,特别是在使用多个主机时:

(validation) $ vagrant ssh-config > ssh-config
(validation) $ pytest --hosts=default --ssh-config=ssh-config test_remote.py

使用 --hosts=default 避免在命令行中直接指定它们,并且引擎从 SSH 配置中获取。即使没有 Vagrant,SSH 配置提示仍然对连接到具有特定指令的多个主机有用。

Ansible 是另一种选择,如果节点是本地、SSH 或 Docker 容器。测试设置可以从使用主机清单(类似于 SSH 配置)中受益,它可以将主机分组到不同的部分。主机组也可以指定,以便您可以单独针对主机进行测试,而不是针对所有主机执行。

对于在先前示例中使用的 node1node2,清单文件的定义如下(保存为 hosts):

[all]
node1
node2

如果对所有这些执行,命令将更改为:

$ pytest --connection=ansible --ansible-inventory=hosts test_remote.py

如果在清单中定义了需要排除的其他主机,则还可以指定一个组。假设两个节点都是 Web 服务器,并且属于 nginx 组,则此命令将仅在该组上运行测试:

$ pytest --hosts='ansible://nginx' --connection=ansible \
  --ansible-inventory=hosts test_remote.py
提示

许多系统命令需要超级用户权限。为了允许特权升级,Testinfra 允许指定 --sudo--sudo-user--sudo 标志使引擎在执行命令时使用 sudo,而 --sudo-user 命令允许以不同用户的更高权限运行。这个 fixture 也可以直接使用。

功能和特殊的 fixtures。

到目前为止,在示例中,仅使用 host fixture 来检查文件及其内容。然而,这是具有误导性的。host fixture 是一个 全包含 的 fixture;它包含了 Testinfra 提供的所有其他强大 fixtures。这意味着示例已经使用了 host.file,其中包含了大量额外的功能。也可以直接使用该 fixture:

In [1]: import testinfra

In [2]: host = testinfra.get_host('local://')

In [3]: node_file = host.file('/tmp')

In [4]: node_file.is_directory
Out[4]: True

In [5]: node_file.user
Out[5]: 'root'

全功能的 host fixture 利用了 Testinfra 的广泛 API,它为连接到的每个主机加载了所有内容。其想法是编写单个测试,针对从同一 host fixture 访问的不同节点执行。

这些是一些可用的 几十个 属性。以下是其中一些最常用的:

host.ansible

在运行时提供对任何 Ansible 属性的完全访问,例如主机、清单和变量。

host.addr

网络工具,如检查 IPV4 和 IPV6,主机是否可达,主机是否可解析。

host.docker

代理到 Docker API,允许与容器交互,并检查它们是否在运行。

host.interface

用于检查给定接口地址的辅助工具。

host.iptables

用于验证防火墙规则(如 host.iptables 所见)的辅助工具。

host.mount_point

检查挂载点、文件系统类型在路径中的存在以及挂载选项。

host.package

非常有用 以查询包是否已安装及其版本。

host.process

检查运行中的进程。

host.sudo

允许您使用 host.sudo 执行命令,或作为不同用户执行。

host.system_info

各种系统元数据,如发行版版本、发布和代号。

host.check_output

运行系统命令,检查其输出(如果成功运行),可以与 host.sudo 结合使用。

host.run

运行命令,允许检查返回代码,host.stderrhost.stdout

host.run_expect

验证返回代码是否符合预期。

示例

无摩擦地开始开发系统验证测试的方法是在创建实际部署时执行。与测试驱动开发(TDD)有些类似,任何进展都需要一个新的测试。在本节中,需要安装并配置 Web 服务器在端口 80 上运行以提供静态着陆页面。在取得进展的同时,将添加测试。编写测试的一部分是理解失败,因此将引入一些问题来帮助我们确定要修复的内容。

干净的 Ubuntu 服务器上,首先安装 Nginx 包:

$ apt install nginx

在取得进展后创建一个名为test_webserver.py的新测试文件。Nginx 安装后,让我们再创建一个测试:

def test_nginx_is_installed(host):
    assert host.package('nginx').is_installed

使用-q标志减少pytest输出的冗长以集中处理失败。远程服务器称为node4,使用 SSH 连接到它。这是运行第一个测试的命令:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
.
1 passed in 1.44 seconds

进展!Web 服务器需要运行,因此添加了一个新的测试来验证其行为:

def test_nginx_is_running(host):
    assert host.service('nginx').is_running

再次运行应该再次成功:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
.F
================================== FAILURES ===================================
_____________________ test_nginx_is_running[ssh://node4] ______________________

host = <testinfra.host.Host object at 0x7f629bf1d668>

    def test_nginx_is_running(host):
>       assert host.service('nginx').is_running
E       AssertionError: assert False
E        +  where False = <service nginx>.is_running
E        +    where <service nginx> = <class 'SystemdService'>('nginx')

test_webserver.py:7: AssertionError
1 failed, 1 passed in 2.45 seconds

一些 Linux 发行版不允许在安装时启动包服务。此外,测试捕获到systemd(默认单元服务)报告 Nginx 服务未运行。手动启动 Nginx 并运行测试应该再次使一切顺利通过:

(validate) $ systemctl start nginx
(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
..
2 passed in 2.38 seconds

如本节开头所述,Web 服务器应在端口 80 上提供静态着陆页面。添加另一个测试(在test_webserver.py中)以验证端口是下一步:

def test_nginx_listens_on_port_80(host):
    assert host.socket("tcp://0.0.0.0:80").is_listening

此测试更为复杂,需要关注一些细节。它选择检查服务器中任何 IP 上端口80的 TCP 连接。虽然对于此测试来说这没问题,但如果服务器有多个接口并配置为绑定到特定地址,则必须添加新的测试。添加另一个检查端口80是否在给定地址上监听的测试可能看起来有些多余,但如果考虑到报告,它有助于解释发生了什么:

  1. 测试nginx是否在端口80上监听:PASS

  2. 测试nginx是否在地址192.168.0.2和端口80上监听:FAIL

以上告诉我们 Nginx 绑定到端口80只是没有绑定到正确的接口。额外的测试是提供细粒度的好方法(以牺牲额外的冗长)。

再次运行新添加的测试:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
..F
================================== FAILURES ===================================
_________________ test_nginx_listens_on_port_80[ssh://node4] __________________

host = <testinfra.host.Host object at 0x7fbaa64f26a0>

    def test_nginx_listens_on_port_80(host):
>       assert host.socket("tcp://0.0.0.0:80").is_listening
E       AssertionError: assert False
E        +  where False = <socket tcp://0.0.0.0:80>.is_listening
E        +    where <socket tcp://0.0.0.0:80> = <class 'LinuxSocketSS'>

test_webserver.py:11: AssertionError
1 failed, 2 passed in 2.98 seconds

没有任何地址在端口80上有监听。查看 Nginx 的配置发现,它设置为使用默认站点中的指令在端口8080上进行监听:

(validate) $ grep "listen 8080" /etc/nginx/sites-available/default
    listen 8080 default_server;

将其改回到端口80并重新启动nginx服务,测试再次通过:

(validate) $ grep "listen 80" /etc/nginx/sites-available/default
    listen 80 default_server;
(validate) $ systemctl restart nginx
(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
...
3 passed in 2.92 seconds

由于没有内置的夹具来处理向地址的 HTTP 请求,最后一个测试使用wget实用程序检索正在运行的网站的内容,并对输出进行断言以确保静态站点渲染:

def test_get_content_from_site(host):
    output = host.check_output('wget -qO- 0.0.0.0:80')
    assert 'Welcome to nginx' in output

再次运行test_webserver.py以验证所有我们的假设是正确的:

(validate) $ pytest -q --hosts='ssh://node4' test_webserver.py
....
4 passed in 3.29 seconds

理解 Python 中测试概念,并将其重新用于系统验证,具有非常强大的功能。在开发应用程序时自动化测试运行,甚至在现有基础设施上编写和运行测试,都是简化日常操作的极佳方式,因为这些操作往往容易出错。pytest 和 Testinfra 是可以帮助你入门的优秀项目,并且在需要扩展时使用起来非常方便。测试是技能的升级

使用 pytest 测试 Jupyter Notebooks

在数据科学和机器学习中,如果忘记应用软件工程最佳实践,很容易在公司引入大问题。解决这个问题的一种方法是使用 pytest 的nbval插件,它允许你测试你的笔记本。看看这个Makefile

setup:
    python3 -m venv ~/.myrepo

install:
    pip install -r requirements.txt

test:
    python -m pytest -vv --cov=myrepolib tests/*.py
    python -m pytest --nbval notebook.ipynb

lint:
    pylint --disable=R,C myrepolib cli web

all: install lint test

关键项目是--nbval标志,它还允许建立服务器测试仓库中的笔记本。

练习

  • 至少列出三个约定,以便pytest可以发现测试。

  • conftest.py文件的作用是什么?

  • 解释测试参数化。

  • 什么是 fixture,如何在测试中使用它?它方便吗?为什么?

  • 解释如何使用monkeypatch fixture。

案例研究问题

  • 创建一个测试模块,使用testinfra连接到远程服务器。测试 Nginx 是否安装,在systemd下运行,并且服务器是否绑定到端口 80。当所有测试通过时,尝试通过配置 Nginx 监听不同的端口来使它们失败。

第九章:云计算

云计算是一个术语,它像其他流行的现代术语一样会造成混淆,比如大数据、人工智能和敏捷。当一个术语变得足够流行时,最终会对很多人有很多含义。这里是一个精确的定义。云是按需提供计算服务的交付,您只需支付您使用的量,就像任何其他公用事业一样:天然气、电力或水。

云计算的顶级好处包括成本、速度、全球规模、生产力、性能、可靠性和安全性。让我们逐个解析这些。

成本

没有前期成本,资源可以精确计量以满足需求。

速度

云提供自助服务,因此专业用户可以利用资源快速构建解决方案。

全球规模

所有主要云提供商都具有全球规模,这意味着可以在世界各地提供服务,以满足地理区域的需求。

生产力

许多任务,如架设服务器、配置网络硬件和物理保护数据中心,已经不复存在。公司可以专注于构建核心知识产权,而不是重复造轮子。

性能

与您拥有的硬件不同,云硬件不断升级,这意味着最快和最新的硬件始终按需可用。所有硬件还连接在一起,通过低延迟和高带宽的基础设施,创建了一个理想的高性能环境。

可靠性

云的核心架构在每一步都提供冗余。每个区域都有多个数据中心,每个数据中心都有多个。云原生架构可以围绕这些能力设计,从而实现高可用性架构。此外,许多核心云服务本身也具有高可用性,比如亚马逊 S3,其可靠性为九个“9”,即 99.999999999%。

安全性

您的安全性取决于最薄弱的环节。通过集中到中央化的安全性,可以实现更高级别的安全性。诸如物理访问数据中心或静止加密等问题,在第一天就成为行业标准。

云计算基础

从某些方面来看,很难在不考虑云的情况下思考 DevOps。亚马逊将以下内容描述为 DevOps 的最佳实践:持续集成、持续交付、微服务、基础设施即代码、监控与日志、以及沟通与协作。在这些最佳实践中,可以说所有这些都依赖于云的存在。即使是较难定义的“沟通与协作”实践,也是通过现代化的 SaaS 沟通工具套件实现的:Jira、Trello、Slack、GitHub 等。所有这些 SaaS 沟通工具都运行在云上。

现代云时代有什么独特之处?至少有三个定义性特征:理论上的无限计算资源,按需访问计算资源以及没有前期资本承诺。这些特征内涵了 DevOps 技能的帕累托分布。

在实践中,云在支持云的真正效率方面使用时变得极具成本效益。另一方面,对于使用云计算的不成熟组织来说,可能会非常昂贵,因为他们没有利用云计算的核心功能。可以说,在云计算的早期阶段,80%的总利润来自于未成熟的用户,他们让实例空闲,选择了错误的实例(过大),没有为自动扩展进行架构设计,或者使用了非云原生的软件架构,例如将所有内容都塞进关系数据库中。同样,其余 20%的总利润来自于具有卓越 DevOps 技能的极为节俭的组织。

在云存在之前,有一个永远不会消失的固定成本。这个成本无论是在金钱上还是在开发人员时间上都是固定的。一个数据中心必须由一个团队来维护,这是一份全职工作,而且非常昂贵。随着云的成熟发展,现在只有最优秀的人才才会在数据中心工作,他们为像谷歌、微软和亚马逊这样极为成熟的组织工作。从统计上讲,小公司无法长期拥有那些水平的数据中心工程师硬件技能。

经济学的一个基本法则是比较优势原则。与其看云计算的成本,然后认为自己可以通过自己动手省钱,不如看看不做某些事情的机会成本。大多数组织已经得出结论:

  1. 他们无法在数据中心专业知识方面与谷歌、亚马逊和微软竞争。

  2. 支付云服务费用使公司能够专注于其他领域,利用他们独特的技能。

Netflix 决定专注于提供流媒体服务和创作原创内容,而不是运营自己的数据中心。如果你看一下 Netflix 从 2008 年到 2019 年的 11 年股票价格(图 9-1),很难反驳这一策略。

Netflix 11 年股票价格

图 9-1. Netflix 11 年股票价格

Netflix 的独特之处在于其在云端运营卓越性上的承诺。目前或曾经在 Netflix 工作的员工在各大会议上发表了许多演讲,在 GitHub 上开发和发布了工具,并在 DevOps 和云计算主题上撰写了文章和书籍。这进一步支持了这样一个观点:仅仅意识到云是正确的选择是不够的,这个决定必须以卓越的运营实践为支撑。否则,一个组织可能会像那些注册了一年会员却只去了三周健身房的人一样,那些不去健身房的会员在经济上补贴了那些经常去的会员。

云计算的类型

云计算有几种主要类型:公有云、私有云、混合云和多云。大多数情况下,当我们谈论云时,指的是公有云。但这并不是唯一的云类型。私有云由组织独占使用,可以是物理上位于该组织的数据中心,也可以由另一家公司为该组织托管。一些私有云提供商包括 HPE、VMware、戴尔和甲骨文。一个流行的开源私有云选项是 OpenStack。实际上的一个很好的例子是,Rackspace,一个在托管空间中更为专业的替代品,是 OpenStack 私有云即服务的最大提供商之一。

更加灵活的选择是混合云。混合云结合了私有云和公有云。这种架构的一个例子是在需要可伸缩性和额外容量的情况下使用公有云,而在日常运营中使用私有云。另一个例子可能涉及专用硬件架构,比如在私有云中进行深度学习的 GPU 农场,而连接的公有云则作为核心基础设施。即使是主要的云供应商也进入了这个领域。一个很好的例子是谷歌的 Anthos 平台。这个平台通过在本地数据中心与 GCP 之间建立链接来完成难以置信的工作,允许以无缝的方式运行 Kubernetes 集群。

最后,多云是一种选择,部分由现代 DevOps 技术(如 Docker 容器)和基础设施即代码(IaC)解决方案(如 Terraform)所启用。多云策略涉及同时使用多个云。一个很好的例子是在多个云上同时运行容器中的作业。为什么这样做?首先,你可以决定在 AWS Spot 实例价格适宜以赚取利润时运行作业,但在 AWS 价格过高时切换到 GCP。像 Terraform 这样的工具允许你将云概念抽象为熟悉的配置语言,而容器允许代码和执行环境在能运行容器的任何目标上运行。

云服务的类型

云服务有五种主要类型:基础设施即服务(IaaS),金属即服务(MaaS),平台即服务(PaaS),无服务器,以及软件即服务(SaaS)。这些云服务在不同的抽象层上工作,并各有利弊。让我们来详细了解每一种服务。

基础设施即服务

Infrastructure as a Service(基础设施即服务,IaaS)是一个低级别的类别,包括按分钟租用虚拟机、访问对象存储、提供软件定义网络(SDN)和软件定义存储(SDS),以及竞标可用虚拟机的能力。这种服务水平与 AWS 密切相关,特别是在早期(2006 年)亚马逊推出 S3 云存储、SQS(简单队列服务)和 EC2(虚拟机)时。

这项服务对于在 DevOps 方面拥有强大专业知识的组织来说具有巨大的成本效益和可靠性,只需少数人就能完成。缺点是 IaaS 有陡峭的学习曲线,当管理效率低下时,它在成本和人力上可能会非常昂贵。在 2009 年至 2019 年期间的旧金山湾区,这种情况在许多公司的 AWS 上实时发生。

一个令人铭记的故事是,当诺亚管理一个提供监控和搜索工具的 SaaS 公司的工程部门时发生的。在他上任的第一个月,云端发生了两个关乎任务的严重问题。第一个问题发生在第一周,是 SaaS 计费系统错误配置了存储系统。公司正在删除付费客户的数据!问题的要点是他们没有成功在云端运作所需的 DevOps 基础设施:没有构建服务器,没有测试,没有真正的隔离开发环境,没有代码审查,以及有限的自动部署软件能力。诺亚采取的解决措施是这些 DevOps 实践,就在一场象征性的大火正在燃烧的时候。

注意

一名开发人员曾用烤面包机烤培根,导致办公室起火。诺亚闻到了烟味,于是走进了厨房,发现火焰正顺着墙壁和天花板蔓延。他对情况的讽刺意味感到震惊,所以他坐在那里几秒钟,沉浸在其中。幸运的是,一个反应迅速的同事(产品经理)拿起了灭火器,扑灭了火势。

我们的云架构随后发生了第二个更为严重的问题。公司所有开发人员都必须值班,以确保 24/7 覆盖(除了 CTO/创始人经常编写直接或间接导致故障的代码......稍后再说)。一天晚上,当诺亚值班时,他在凌晨 2 点被 CEO/创始人的手机电话吵醒。他告诉诺亚他们被黑客攻击了,整个 SaaS 系统不复存在。平台上没有任何网页服务器、搜索端点或任何其他虚拟机在运行。诺亚问为什么他没有收到警报,CEO 说监控系统也被删除了。诺亚决定凌晨 2 点驱车去公司解决问题。

随着更多信息浮出水面,问题变得显而易见。CEO 和创始人最初设置了 AWS 账户,并且所有有关服务中断的电子邮件都发送到他的邮箱。几个月来,亚马逊一直向他发送关于我们所在地区北弗吉尼亚的虚拟机需要退役并且即将删除的电子邮件。最终那一天到来了,在深夜,整个公司的服务器都停止存在了。

当诺亚开车去上班时,他发现了这个问题,于是专注于从 GitHub 源代码重新构建一个完整的 SaaS 公司。从这一点开始,诺亚开始理解 AWS 的强大和复杂性。他从凌晨 2 点到下午 8 点之间,将 SaaS 系统恢复正常,能够接收数据、处理支付并提供仪表板服务。又花了 48 小时完全恢复所有备份数据。

导致恢复时间如此之长的原因之一是部署过程集中在先前员工创建但从未提交到版本控制的 Puppet 的分支版本上。幸运的是,诺亚在凌晨 6 点左右找到了存活下来的孤立机器上的那个版本的 Puppet 的副本。如果这台机器不存在,可能会导致公司的灭亡。在没有基础设施即代码(IAC)支撑的情况下,完全重建这种复杂公司可能需要一周时间。

一个非常令人压力巨大的经历,但最终有一个相对幸运的结局,给他带来了很多教训。诺亚意识到这是云计算的一个折衷之处;虽然非常强大,但学习曲线对于旧金山湾区的风投支持的初创公司来说压力巨大。现在回到那位 CTO/创始人,他不在值班,但是在没有使用构建服务器或持续集成系统的情况下将代码推送到生产环境。这个人并不是故事的反派。诺亚本人在职业生涯的某个阶段如果成为一家公司的 CTO/创始人,可能也会犯同样的错误。

真正的问题是权力动态。等级制度并不等同于正确性。很容易沉迷于自己的权力,并认为因为你掌管着,你所做的一切总是有道理的。当诺亚经营一家公司时,他也犯了类似的错误。关键要点是过程必须正确,而不是个人。如果不自动化,那就是有问题的。如果没有经过某种类型的自动化质量控制测试,那也是有问题的。如果部署不可重复,那也是有问题的。

最后要分享的关于这家公司的故事涉及监控。在这两次初始危机之后,症状得到了缓解,但根本疾病仍然是恶性的。该公司存在一个无效的工程过程。另一个故事突显了根本问题。有一个自制的监控系统(再次由创始人最初创建),平均每 3-4 小时生成一次警报,每天 24 小时。

由于除了首席技术官外,所有工程人员都在值班,大部分工程人员总是睡眠不足,因为他们每晚都会接到系统不工作的警报。对警报的“修复”是重新启动服务。诺亚自愿连续一个月值班,以便工程有时间解决问题。这段持续的痛苦和缺觉期导致他意识到几件事情。首先,监控系统不比随机更好。他可能用这个 Python 脚本完全替换整个系统:

from  random import choices

hours = list(range(1,25))
status = ["Alert", "No Alert"]
for hour in hours:
    print(f"Hour: {hour} -- {choices(status)}"
✗ python random_alert.py
Hour: 1 -- ['No Alert']
Hour: 2 -- ['No Alert']
Hour: 3 -- ['Alert']
Hour: 4 -- ['No Alert']
Hour: 5 -- ['Alert']
Hour: 6 -- ['Alert']
Hour: 7 -- ['Alert']
Hour: 8 -- ['No Alert']
Hour: 9 -- ['Alert']
Hour: 10 -- ['Alert']
Hour: 11 -- ['No Alert']
Hour: 12 -- ['Alert']
Hour: 13 -- ['No Alert']
Hour: 14 -- ['No Alert']
Hour: 15 -- ['No Alert']
Hour: 16 -- ['Alert']
Hour: 17 -- ['Alert']
Hour: 18 -- ['Alert']
Hour: 19 -- ['Alert']
Hour: 20 -- ['No Alert']
Hour: 21 -- ['Alert']
Hour: 22 -- ['Alert']
Hour: 23 -- ['No Alert']
Hour: 24 -- ['Alert']

一旦他意识到这一点,他深入挖掘数据,并按天创建了过去一年每个单独警报的历史图片(注意这些警报旨在可行动并“唤醒你”)。从图 9-2 中可以看出,这些警报不仅毫无意义,而且在事后看来频率还荒谬增长。他们在“货物崇拜”工程最佳实践,并象征性地在一个由稻草建造的泥土跑道上挥舞棕榈树枝。

pydo 0902

图 9-2. SaaS 公司每日警报

查看数据后,了解到工程师们花费了多年的生命响应页面和夜间被唤醒,却毫无意义。这种痛苦和牺牲一无所获,强化了生活不公的悲哀真相。这种情况的不公非常令人沮丧,并且需要大量说服才能让人们同意关闭警报。人类行为中有一种固有的偏见,继续做你一直做过的事情。此外,由于痛苦如此严重和持久,往往倾向于赋予更深层次的意义。最终,这是一个虚假的神。

对于这家特定公司使用 AWS 云 IaaS 的回顾实际上是 DevOps 的卖点:

  1. 你必须有交付流水线和反馈循环:构建、测试、发布、监控,然后计划。

  2. 开发与运维不是独立的。如果首席技术官在编写代码,他们也应该负责值班(多年被惊醒的痛苦和苦难将成为正确的反馈循环)。

  3. 地位在等级制度中并不比流程更重要。团队成员之间应该有一种强调所有权和责任的合作关系,不论职称、薪水或者经验水平。

  4. 速度是 DevOps 的基本要求。因此,微服务和持续交付是必需的,因为它们使团队能够快速拥有自己的服务并发布软件。

  5. 快速交付是 DevOps 的基本要求,但它还需要持续集成、持续交付以及有效和可操作的监控和日志记录。

  6. 它提供了在规模上管理基础设施和开发过程的能力。自动化和一致性是硬性要求。使用基础设施即代码(IaC)以可重复和自动化的方式管理开发、测试和生产环境是解决方案。

MaaS(Metal as a Service)

MaaS(Metal as a Service)允许你像虚拟机一样处理物理服务器。在管理虚拟机集群时同样便捷的使用体验也适用于物理硬件。MaaS 是由 Canonical 提供的一项服务,Canonical 的所有者马克·舒特尔沃斯特称其为“云语义”进入裸金属世界。MaaS 还可以指的是使用将硬件视为虚拟化硬件的供应商概念。这方面的一个很好的例子是 SoftLayer,这是一家被 IBM 收购的裸金属提供商。

在正面的优势中,对硬件拥有完全控制确实对特定应用具有一定吸引力。这方面的一个很好的例子可以是使用基于 GPU 的数据库。实际上,常规公共云也可以提供类似的服务,因此进行全面的成本效益分析有助于在何时使用 MaaS 时进行合理的辩解。

平台即服务(Platform as a Service)

PaaS(Platform as a Service)是一个完整的开发和部署环境,具备创建云服务所需的所有资源。其例子包括 Heroku 和 Google App Engine。PaaS 与 IaaS 不同之处在于它拥有开发工具、数据库管理工具以及高级服务,提供“点对点”集成。可以捆绑的服务类型的例子包括认证服务、数据库服务或 Web 应用服务。

对 PaaS 的一个合理批评是,长期来看它可能比 IaaS 更昂贵,正如之前讨论的;然而这取决于环境。如果组织无法执行 DevOps 行为,那么成本就成了无关紧要的点。在这种情况下,最好支付更昂贵的服务,提供更多这些能力。对于需要学习管理 IaaS 部署高级功能的组织来说,机会成本可能对初创企业的短期生命周期来说太高。对于一个组织来说,把这些能力外包给 PaaS 提供商可能更明智。

无服务器计算

无服务器是云计算的新类别之一,仍然在积极发展中。无服务器的真正承诺在于能够花更多时间构建应用程序和服务,而不需要或几乎不需要考虑它们的运行方式。每个主要的云平台都有无服务器解决方案。

服务器无关解决方案的构建模块是计算节点或函数即服务(FaaS)。AWS 拥有 Lambda,GCP 拥有 Cloud Functions,Microsoft 拥有 Azure Functions。传统上,这些云函数的底层执行已经被抽象为一个运行时,即 Python 2.7、Python 3.6 或 Python 3.7. 所有这些供应商都支持 Python 运行时,并且在某些情况下,它们还支持通过定制的 Docker 容器来定制底层运行时。这里是一个简单的 AWS Lambda 函数示例,用于获取维基百科的第一页。

有几点需要指出关于这个 Lambda 函数。逻辑本身在 lambda_handler 中,并且它接受两个参数。第一个参数 event 来自于触发它的任何内容。Lambda 可以是从 Amazon Cloud Watch 事件定时器到使用从 AWS Lambda 控制台制定的负载运行。第二个参数 context 具有方法和属性,提供有关调用、函数和执行环境的信息。

import json
import wikipedia

print('Loading function')

def lambda_handler(event, context):
    """Wikipedia Summarizer"""

    entity = event["entity"]
    res = wikipedia.summary(entity, sentences=1)
    print(f"Response from wikipedia API: {res}")
    response = {
    "statusCode": "200",
    "headers": { "Content-type": "application/json" },
    "body": json.dumps({"message": res})
    }
    return response

要使用 Lambda 函数,需要发送一个 JSON 负载:

{"entity":"google"}

Lambda 的输出也是一个 JSON 负载:

Response
{
    "statusCode": "200",
    "headers": {
        "Content-type": "application/json"
    },
    "body": "{\"message\": \"Google LLC is an American multinational technology"}
}

FaaS 最强大的一点之一是能够编写响应事件而不是持续运行的代码:例如 Ruby on Rails 应用程序。FaaS 是云原生能力,真正利用了云的弹性特性。此外,编写 Lambda 函数的开发环境已经有了很大进步。

AWS 的 Cloud9 是一个基于浏览器的开发环境,与 AWS 深度集成(图 9-3)。

pydo 0903

图 9-3. 使用 AWS Cloud9

Cloud9 现在是我编写 AWS Lambda 函数和运行需要 AWS API 密钥的代码的首选环境。Cloud9 内置了用于编写 AWS Lambda 函数的工具,使得在本地构建和测试它们,以及部署到 AWS 中变得简单直观。

图 9-4 显示了如何传递 JSON 负载并在 Cloud9 中本地测试 lambda。这种测试方式是这一不断发展平台的显著优势。

pydo 0904

图 9-4. 在 Cloud9 中运行 Lambda 函数

同样,Google Cloud 在你使用 GCP 云 Shell 环境时开始启动你(参见 图 9-5)。云 Shell 还允许您快速启动开发,访问关键命令行工具和完整的开发环境。

pydo 0905

图 9-5. GCP 云 Shell

GCP 云 Shell 编辑器(参见 图 9-6)是一个功能齐全的 IDE,具有语法高亮显示、文件浏览器和许多传统 IDE 中通常找到的其他工具。

pydo 0906

图 9-6. GCP 云 Shell 编辑器

关键要点是,在云端,最好在可能的情况下使用本地开发工具。这样做可以减少安全漏洞,限制由于从笔记本电脑传输数据到云端而导致的减速,并因其与本地环境的深度集成而提高生产力。

软件即服务

SaaS 和云从一开始就被结合在一起。随着云端的功能不断增加,SaaS 产品继续在云端创新的基础上分发创新。SaaS 产品有许多优势,特别是在 DevOps 领域。例如,如果你刚开始时可以租用一个监控解决方案,为什么要自己建造呢?此外,许多核心的 DevOps 原则,如持续集成和持续交付,也可以通过云供应商提供的 SaaS 应用(如 AWS CodePipeline)或第三方 SaaS 解决方案(如 CircleCI)实现。

在许多情况下,能够混合使用 IaaS、PaaS 和 SaaS 允许现代公司以比 10 年前更可靠和高效的方式开发产品。由于云端以及构建在云端之上的 SaaS 公司的快速演变,每年构建软件变得更加容易。

基础设施即代码

IaC 在 第十章 中有更详细的介绍;请参考该章节以获取 IaC 的更详细说明。然而,就云和 DevOps 而言,IaC 是实施真实世界云计算的基本要素。在云上实施 DevOps 实践必须具备 IaC 的能力。

持续交付

持续交付是一个较新的术语,可能会在持续集成和持续部署之间产生混淆。关键区别在于软件被交付到某个环境,例如一个演示环境,可以进行自动化和手动测试。虽然不要求立即部署,但它处于可部署状态。有关构建系统的更详细解释可以在 第十五章 中找到,但同样值得指出的是,这是正确使用云的基本要求之一。

虚拟化和容器

在云中最基本的组成部分莫过于虚拟化了。当 AWS 在 2006 年正式推出时,Amazon 弹性计算云(EC2)是发布的核心服务之一。有几个关键的虚拟化领域需要讨论。

硬件虚拟化

AWS 发布的第一个虚拟化抽象是硬件虚拟化。硬件虚拟化有两种形式:半虚拟化(PV)或硬件虚拟机(HVM)。最佳性能来自于 HVM。性能上的关键差异在于,HVM 能够利用硬件扩展,与主机硬件紧密结合,实质上使虚拟机成为主机硬件的一部分,而不仅仅是一个不知情于主机操作的客人。

硬件虚拟化提供了在一个主机上运行多个操作系统的能力,以及将 CPU、I/O(包括网络和磁盘)和内存分区到客户操作系统的能力。这种方法有许多优点,是现代云计算的基础,但对于 Python 本身也存在一些独特的挑战。一个问题是,通常的粒度对于 Python 来说太大,无法充分利用环境。由于 Python 和线程的限制(它们不能在多核上工作),一个有两个核心的虚拟机可能会浪费一个核心。使用硬件虚拟化和 Python 语言,由于缺乏真正的多线程,可能会造成资源的巨大浪费。对于 Python 应用程序的虚拟机配置往往会导致一个或多个核心处于空闲状态,浪费金钱和能源。幸运的是,云计算提供了新的解决方案,帮助消除 Python 语言中的这些缺陷。特别是,容器和无服务器消除了这个问题,因为它们将云视为一个操作系统,而不是线程,有的是 lambda 或容器。而不是在队列上监听线程,lambda 响应来自云队列(例如 SQS)的事件。

软件定义网络

软件定义网络(SDNs)是云计算的重要组成部分。SDNs 的杀手级特性在于能够动态和程序化地改变网络行为。在此能力出现之前,这通常由网络专家负责管理,类似于使用 F5 负载均衡器。诺亚曾在一家大型电信公司工作过,那里每天都有一个称为“变更管理”的会议,由一个名叫 Bob 的人负责控制每一个被发布的软件。

要成为 Bob,需要有独特的个性。Bob 和公司内的人们经常发生争吵。这是经典的 IT 运维与开发之间的斗争,Bob 乐于说不。云和 DevOps 完全消除了这个角色、硬件和每周的吵架。持续交付流程是使用精确配置、软件和所需数据持续地构建和部署软件,以用于生产环境。Bob 的角色深深地融入了矩阵中的 0 和 1 中,被一些 Terraform 代码所取代。

软件定义存储

软件定义存储(SDS)是一种允许按需配置存储的抽象概念。此存储可以配置有细粒度的磁盘 I/O 和网络 I/O。一个很好的例子是亚马逊的 EBS 卷,您可以在其中配置已配置的磁盘 I/O。通常,云 SDS 会随着卷大小自动增加磁盘 I/O。一个如何在实践中工作的绝佳例子是亚马逊弹性文件系统(EFS)。EFS 随着存储大小的增长增加磁盘 I/O(这是自动发生的),并且设计用于支持同时来自数千个 EC2 实例的请求。它还与亚马逊 EC2 实例深度集成,允许挂起的写入进行缓冲并异步发生。

诺亚在这种情况下使用 EFS 有丰富的经验。在 AWS 批处理可用之前,他设计并编写了一个系统,该系统利用了数千个挂载了 EFS 卷的 spot 实例,它们执行从 Amazon SQS 收集的分布式计算机视觉作业。使用一个始终在线的分布式文件系统对于分布式计算来说是一个巨大的优势,并且它简化了从部署到集群计算的一切。

容器

容器已经存在了几十年,它们指的是操作系统级虚拟化。内核允许存在隔离的用户空间实例。在 2000 年代初期,有许多托管公司使用 Apache 网站的虚拟托管作为操作系统级虚拟化的形式。大型机和经典的 Unix 操作系统,如 AIX、HP-UX 和 Solaris,多年来也拥有先进的容器形式。作为开发人员,诺亚在 2007 年推出的 Solaris LDOM 技术中使用了 Solaris LDOM 技术,并对他如何能够安装允许对 CPU、内存和 I/O 进行细粒度控制的完整操作系统而感到敬畏,所有这些都可以通过远程登录到具有带外管理卡的机器来完成。

容器的现代版本正在快速发展,借鉴了主机时代的优秀特性,并结合了像源代码控制这样的新思想。特别是,容器的一个重大革新是将其视为从版本控制中签出的项目。Docker 容器现在是容器的标准格式,所有主要的云供应商都支持 Dockerfile 容器和 Kubernetes 容器管理软件。有关容器的更多信息,请参阅第十二章,但与云相关的基本内容列于此处:

容器注册表

所有的云服务提供商都有一个容器注册表,用于存储您的容器。

Kubernetes 管理服务

所有的云服务提供商都有 Kubernetes 服务,并且这现在是管理基于容器的部署的标准。

Dockerfile 格式

这是构建容器的标准方法,它是一个简单的文件格式。在构建过程中使用像hadolint这样的代码审查工具是一个最佳实践,以确保简单的错误不会通过。

使用容器进行持续集成

所有的云服务提供商都有基于云的构建系统,允许与容器集成。谷歌有Cloud Build,亚马逊有AWS CodePipeline,Azure 有Azure Pipelines。它们都可以构建容器并将其注册到容器注册表中,同时也可以使用容器构建项目。

深度集成容器到所有云服务中

当你进入云平台上的托管服务时,可以放心它们都有一个共同点——容器!亚马逊的 SageMaker,一个托管的机器学习平台,使用容器。谷歌云 Shell 云开发环境使用容器来允许您定制开发环境。

分布式计算中的挑战和机遇

计算机科学中最具挑战性的领域之一是分布式计算。在云计算的现代时代,有几个根本性的转变彻底改变了一切。最显著的转变之一是多核机器的兴起和摩尔定律的终结。请参见图 9-7。

pydo 0907

图 9-7. 摩尔定律的终结(来源:John Hennessy 和 David Patterson,《计算机体系结构:定量方法》第 6 版,2018 年)

摩尔定律揭示了云时代表现出来的两个基本问题。第一个问题是,CPU 被设计为多用途处理器。它们并不专门用于运行并行工作负载。如果将其与增加 CPU 速度的终极物理极限相结合,CPU 在云时代变得不那么关键。在 2015 年,摩尔定律实际上结束了,每年的增益率为 3%。

第二个问题是,为了抵消单处理器速度的限制而制造多核机器导致了软件语言的连锁反应。许多语言以前在多核利用方面存在重大问题,因为它们是在多处理器甚至是互联网之前设计的时代。Python 在这里是一个很好的例子。更具挑战性的是,图 9-8 显示,通过为主要非并行问题增加更多核心并不是一种“免费午餐”。

pydo 0908

图 9-8. 阿姆达尔定律

云和不同的架构的机会,例如应用特定集成电路(ASIC)。这些包括图形处理单元(GPU)、现场可编程门阵列(FPGA)和张量处理单元(TPU)。这些专用芯片越来越多地用于机器学习工作负载,并为使用多种硬件组合解决分布式计算中的复杂问题铺平了道路。

云时代的 Python 并发性、性能和进程管理

想象一下,在旧金山的一个危险街区深夜走在黑暗的街道上。在这种情况下,你是巴西柔术的黑带。你独自一人,注意到有个陌生人似乎在跟踪你。当他们走近时,你的心开始加速,你想到了你多年的武术训练。你会不会不得不在街上和陌生人打斗?你每周在健身房与对手进行活跃的实战。你觉得自己准备好了,如果需要的话可以保护自己。你也知道巴西柔术是一种高效的武术,适用于现实世界的情况。

另一方面,与人打斗仍然是要避免的事情。这是危险的。可能会涉及武器。你可能会赢得这场斗争,但会严重伤害你的对手。你也可能会输掉这场战斗,并且自己也会受重伤。即使是巴西柔术的专家也知道,在街头打斗并不是一个理想的场景,尽管很有可能会赢得比赛。

Python 中的并发性非常类似。有一些方便的模式,如多进程和 asyncio。最好是节制地使用并发性。通常,与通过编程语言自己创建的并发性相比,使用平台的并发性选项(无服务器、批处理处理、竞价实例)更好。

进程管理

Python 中的进程管理是该语言的一个突出能力。当 Python 作为连接其他平台、语言和进程的胶水时,它表现最佳。此外,进程管理的实际实现在多年来已经发生了显著变化,并且继续改进。

用子进程管理进程

使用标准库启动进程最简单和最有效的方法是使用run()函数。只要你安装了 Python 3.7 或更高版本,就从这里开始简化你的代码。一个简单的示例只需要一行代码:

out = subprocess.run(["ls", "-l"], capture_output=True)

这行代码几乎可以满足你所需。它在 Python 子进程中调用 shell 命令并捕获输出。返回值是一个CompletedProcess类型的对象。这个对象包含了启动进程时使用的argsreturncodestdoutstderrcheck_returncode

这个一行代码替换了过于冗长和复杂的调用 shell 命令的方法。对于经常写 Python 代码并夹杂着 shell 命令的开发人员来说,这非常棒。以下是一些可能有用的其他提示。

避免使用 shell=True

最佳实践是将命令作为列表中的项调用:

subprocess.run["ls", "-la"]

最好避免使用字符串:

#AVOID THIS
subprocess.run("ls -la", shell=True)

这样做的原因很简单。如果你接受任意字符串并执行它,很容易意外引入一个安全漏洞。假设你编写了一个允许用户列出目录的简单程序。用户可以植入任何想要的命令并利用你的程序。意外制造后门非常可怕,希望这说明了使用shell=True是多么糟糕的主意!

#This is input by a malicious user and causes permanent data loss
user_input = 'some_dir && rm -rf /some/important/directory'
my_command = "ls -l " + user_input
subprocess.run(my_command, shell=True)

相反,你可以通过不允许使用字符串完全避免这个问题:

#This is input by a malicious user and does nothing
user_input = 'some_dir && rm -rf /some/important/directory'
subprocess.run(["ls", "-l", user_input])

设置适当的超时时间并在适当时处理它们

如果你正在编写一个可能运行一段时间的进程的代码,你应该设置一个明智的默认超时时间。一个测试这个的简单方法是使用 Unix 的sleep命令。下面是一个在 IPython shell 中在超时触发之前完成的sleep命令的示例。它返回一个CompletedProcess对象:

In [1]: subprocess.run(["sleep", "3"], timeout=4)
Out[1]: CompletedProcess(args=['sleep', '3'], returncode=0)

这里是第二个版本,它会抛出一个异常。在大多数情况下,处理这个异常会很明智:

----> 1 subprocess.run(["sleep", "3"], timeout=1)

/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/subprocess.py
 in run(input, capture_output, timeout, check, *popenargs, **kwargs)
    477             stdout, stderr = process.communicate()
    478             raise TimeoutExpired(process.args, timeout, output=stdout,
--> 479                                  stderr=stderr)
    480       except:  # Including KeyboardInterrupt, communicate handled that.
    481             process.kill()

TimeoutExpired: Command '['sleep', '3']' timed out after 1 seconds

一个合理的做法是捕获这个异常TimeoutExpired,然后记录异常并实现一些清理代码:

import logging
import subprocess

try:
    subprocess.run(["sleep", "3"], timeout=4)
except subprocess.TimeoutExpired:
    logging.exception("Sleep command timed out")

在构建专业级别的系统时,记录异常至关重要。如果这段代码稍后部署在许多机器上,没有一个可搜索的集中式日志系统,追踪错误可能会变得不可能。对于 DevOps 专业人员来说,遵循这个模式并传播它的用处是至关重要的。

用 Python 线程的问题

你可能在成长过程中有过父母告诉你不要和某个朋友交往的经历。如果是这样,很可能是因为你的父母试图帮助你避免犯错。Python 线程就像你成长过程中那个糟糕的朋友一样。如果你继续和它们联系,事情不会有好结果。

在其他语言中,线程是一个合理的折衷方案。在像 C#这样的语言中,你可以执行与队列连接的线程池,并期望每个生成的线程都可以利用设备上的所有核心。这种已经被证明有效的使用线程与队列的模式减少了在代码中手动设置和移除锁的缺点。

Python 不是这样工作的。如果你生成线程,它不会利用你机器上的所有核心,并且它通常会表现出非确定性的方式,从一个核心跳到另一个核心,甚至“减慢你的代码”。为什么在有替代方案的情况下要使用这样的东西呢?

如果你对学习更多关于 DevOps 感兴趣,那么你很可能专注于实用性。你只想学习和应用实际和有意义的知识。实用性是避免在 Python 中使用线程的另一个理由。理论上,在某些情况下可以使用线程并获得性能提升,如果问题是 I/O 绑定的话。然而,再次问一下,为什么要使用一个不可靠的工具,当有可靠的工具存在时?在 Python 中使用线程就像开车需要推一下然后通过弹跳离合器来启动汽车,因为电池不靠谱。当你没有地方可以推动它或者无法把车停在斜坡上时会发生什么?采用这种策略纯粹是疯狂的!

本章中没有使用线程的示例。为什么展示一些不正确的东西?与其使用线程,不如专注于本章中概述的其他替代方案。

使用多进程解决问题

多进程库是使用 Python 标准库在机器上利用所有核心的唯一统一方式。查看图 9-9 时,操作系统级别有几个选择:多进程和容器。

pydo 0909

图 9-9. 运行并行 Python 代码

使用容器作为替代方案是一个重要的区别。如果使用多进程库的目的是在没有进程间通信的情况下多次调用进程,可以有很强的理由使用容器、虚拟机或云原生构造,如函数即服务。一个受欢迎且有效的云原生选项是 AWS Lambda。

同样,与自行分叉进程相比,容器具有许多优势。容器有许多优点。容器定义为代码。容器可以精确地调整到所需的级别:即内存、CPU 或磁盘 I/O。它们是直接竞争对手,通常是自行分叉进程的更好替代品。在实践中,它们也可以更容易地融入 DevOps 思维方式。

从 DevOps 的角度来看,如果你认同这样一个观点,即除非没有其他选择,否则应该避免在 Python 中自己实现并发,那么即使是使用 multiprocessing 模块的场景也是有限的。也许在开发和实验阶段,multiprocessing 最好只作为一种工具,因为在容器和云层面都存在更好的选择。

另一种说法是问你信任哪个进程分叉:你在 Python 中编写的多进程代码,Google 编写的 Kubernetes 开发人员,还是亚马逊编写的 AWS Lambda 开发人员?经验告诉我,当我站在巨人的肩膀上时,我做出了最好的决定。在哲学考虑之后,这里是一些有效使用多进程的方法。

使用 Pool()分叉进程

测试多进程分叉能力并针对其运行函数的一个直接方法是使用 sklearn 机器学习库计算 KMeans 聚类。KMeans 计算密集且时间复杂度为 O(n**2),这意味着随着数据量增加,其增长速度会指数级减慢。这个例子非常适合在宏观或微观级别上并行化处理。在下面的例子中,make_blobs方法创建了一个包含 10 万条记录和 10 个特征的数据集。每个 KMeans 算法的计时以及总计时如下:

from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time

def do_kmeans():
    """KMeans clustering on generated data"""

    X,_ = make_blobs(n_samples=100000, centers=3, n_features=10,
                random_state=0)
    kmeans = KMeans(n_clusters=3)
    t0 = time.time()
    kmeans.fit(X)
    print(f"KMeans cluster fit in {time.time()-t0}")

def main():
    """Run Everything"""

    count = 10
    t0 = time.time()
    for _ in range(count):
        do_kmeans()
    print(f"Performed {count} KMeans in total time: {time.time()-t0}")

if __name__ == "__main__":
    main()

KMeans 算法的运行时显示,其是一个昂贵的操作,运行 10 次迭代需要 3.5 秒:

(.python-devops) ➜  python kmeans_sequential.py
KMeans cluster fit in 0.29854321479797363
KMeans cluster fit in 0.2869119644165039
KMeans cluster fit in 0.2811620235443115
KMeans cluster fit in 0.28687286376953125
KMeans cluster fit in 0.2845759391784668
KMeans cluster fit in 0.2866239547729492
KMeans cluster fit in 0.2843656539916992
KMeans cluster fit in 0.2885470390319824
KMeans cluster fit in 0.2878849506378174
KMeans cluster fit in 0.28443288803100586
Performed 10 KMeans in total time: 3.510640859603882

在下面的例子中,使用multiprocessing.Pool.map方法将 10 个 KMeans 集群操作分配给一个包含 10 个进程的池。这个例子通过将参数100000映射到函数do_kmeans来实现:

from multiprocessing import Pool
from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time

def do_kmeans(n_samples):
    """KMeans clustering on generated data"""

    X,_ = make_blobs(n_samples, centers=3, n_features=10,
                random_state=0)
    kmeans = KMeans(n_clusters=3)
    t0 = time.time()
    kmeans.fit(X)
    print(f"KMeans cluster fit in {time.time()-t0}")

def main():
    """Run Everything"""

    count = 10
    t0 = time.time()
    with Pool(count) as p:
        p.map(do_kmeans, [100000,100000,100000,100000,100000,
                    100000,100000,100000,100000,100000])

    print(f"Performed {count} KMeans in total time: {time.time()-t0}")

if __name__ == "__main__":
    main()

每个 KMeans 操作的运行时间较慢,但总体加速度翻倍。这是并发框架的常见问题;并行工作分配有开销。并行代码的运行并不是“免费午餐”。每个任务的启动时间约为 1 秒:

(.python-devops) ➜ python kmeans_multiprocessing.py
KMeans cluster fit in 1.3836050033569336
KMeans cluster fit in 1.3868029117584229
KMeans cluster fit in 1.3955950736999512
KMeans cluster fit in 1.3925609588623047
KMeans cluster fit in 1.3877739906311035
KMeans cluster fit in 1.4068050384521484
KMeans cluster fit in 1.41087007522583
KMeans cluster fit in 1.3935530185699463
KMeans cluster fit in 1.4161033630371094
KMeans cluster fit in 1.4132652282714844
Performed 10 KMeans in total time: 1.6691410541534424

这个例子展示了为什么对代码进行性能分析和谨慎立即跳转到并发是至关重要的。如果问题规模较小,那么并行化方法的开销可能会使代码变慢,并且调试起来更加复杂。

从 DevOps 的角度来看,最直接和最可维护的方法始终应该是首选。实际上,这可能意味着这种多进程并行化的风格是一个合理的方法,但在尝试宏观准备水平的并行化方法之前不要轻易采用。一些替代的宏观方法可能包括使用容器,使用 FaaS(如 AWS Lambda 或其他无服务器技术),或者使用一个高性能服务器,Python 运行工人对其进行工作(如 RabbitMQ 或 Redis)。

作为服务的函数和无服务器

现代 AI 时代已经创建了压力,促使新范式的出现。CPU 时钟速度的增加已经停滞不前,这实际上结束了摩尔定律。与此同时,数据爆炸、云计算的兴起以及应用特定集成电路(ASIC)的可用性填补了这一空白。现在,函数作为工作单元已经成为一个重要的概念。

Serverless 和 FaaS 可以在某种程度上互换使用,它们描述了在云平台上作为工作单元运行函数的能力。

使用 Numba 进行高性能 Python

Numba 是一个非常酷的库,用于进行分布式问题解决的实验。使用它就像是用高性能的市场售后部件改装你的汽车一样。它还利用了使用 ASIC 解决特定问题的趋势。

使用 Numba 即时编译器

让我们来看看 官方文档示例 中关于 Numba 即时编译器(JIT)的例子,稍作调整,然后分析发生了什么。

这个示例是一个被 JIT 装饰的 Python 函数。参数 nopython=True 强制代码通过 JIT 并使用 LLVM 编译器进行优化。如果不选择这个选项,意味着如果某些内容无法转换为 LLVM,则会保持常规的 Python 代码:

import numpy as np
from numba import jit

@jit(nopython=True)
def go_fast(a):
    """Expects Numpy Array"""

    count = 0
    for i in range(a.shape[0]):
        count += np.tanh(a[i, i])
    return count + trace

接下来,创建一个 numpy 数组,并使用 IPython 的魔术函数来计时它:

x = np.arange(100).reshape(10, 10)
%timeit go_fast(x)

输出显示,运行该代码耗时 855 纳秒:

The slowest run took 33.43 times longer than the fastest. This example could mean
that an intermediate result is cached. 1000000 loops, best of 3: 855 ns per loop

可以使用此技巧运行常规版本以避免装饰器:

%timeit go_fast.py_func(x)

输出显示,没有 JIT,常规 Python 代码运行速度慢了 20 倍:

The slowest run took 4.15 times longer than the fastest. This result could mean
that an intermediate run is cached. 10000 loops, best of 3: 20.5 µs per loop

使用 Numba JIT,for 循环是可以加速的优化对象。它还优化了 numpy 函数和 numpy 数据结构。这里的主要观点是,也许值得查看已运行多年的现有代码,看看 Python 基础架构的关键部分是否可以受益于使用 Numba JIT 进行编译。

使用高性能服务器

自我实现是人类发展中的一个重要概念。自我实现的最简单定义是个体达到他们真实潜力的状态。为了做到这一点,他们必须接受自己的人性,包括其中的所有缺陷。有一种理论认为,不到 1%的人已完全实现了自我。

同样的概念也可以应用于 Python,这种语言。全面接受语言的优势和劣势允许开发人员充分利用它。Python 不是一种高性能语言。Python 不是一种像其他语言(如 Go、Java、C、C++、C# 或 Erlang)那样优化用于编写服务器的语言。相反,Python 是一种在高性能语言或平台上应用高级逻辑的语言。

Python 之所以广受欢迎,是因为它符合人类思维的自然过程。通过足够的语言使用经验,你可以像使用母语一样思考 Python。逻辑可以用许多方式表达:语言、符号表示、代码、图片、声音和艺术。计算机科学构造,如内存管理、类型声明、并发原语和面向对象设计可以从纯逻辑中抽象出来。它们对于表达一个想法是可选的。

类似 Python 这样的语言的强大之处在于它允许用户在逻辑层面工作,而不是计算机科学层面。什么是要点?为任务选择正确的工具,而通常这是云或另一种语言的服务。

结论

DevOps 和数据科学都有一个共同点,那就是它们既是职位名称,又是能力。DevOps 方法的一些好处是速度、自动化、可靠性、规模和安全性通过实用主义实现。在考虑可用框架和解决方案之前使用宏观级别的解决方案来提高并发和进程管理的效率是一种危险的 DevOps 反模式。

Python 在云时代的要点是什么?

  • 学会为手头的任务掌握正确的并发技术。

  • 学会使用高性能计算库 Numba 为你的代码提速,使用真实线程、JIT 和 GPU。

  • 学会使用 FaaS 优雅地解决独特问题。

  • 将云视为操作系统,并让它承担并发的繁重工作。

  • 拥抱云原生构造,如持续交付、Docker 格式容器和无服务器。

练习

  • 什么是 IaaS?

  • 什么是 PaaS?

  • 弹性是什么意思?

  • 可用性是什么意思?

  • 什么是块存储?

  • 云计算服务的不同类型是什么?

  • 什么是无服务器?

  • IaaS 和 PaaS 之间有哪些关键区别?

  • 什么是 CAP 定理?

  • 什么是阿姆达尔定律?

案例研究问题

  • 一家公司犹豫不决地转向云计算,因为它听说可能会更昂贵。有哪些方法可以减轻采用云计算的成本风险?

  • 什么是云原生架构的例子?绘制一个云原生系统的架构图,并列出关键特性。

  • spot 或抢先实例有什么作用?它们如何节省金钱?它们适用于什么问题?它们不适用于什么问题?

第十章:基础设施即代码

在我们拥有花哨的 DevOps 标题和工作描述之前,我们是卑微的系统管理员,或者简称为 sysadmins。那些是黑暗的,云计算之前的日子,当时我们不得不将我们的车的行李箱装满裸金属服务器,然后开车到一个机房设施,将服务器安装到机架上,连接它们,连接一个有轮子的监视器/键盘/鼠标,然后逐个设置它们。格里格仍然不敢想象他在机房度过的小时,灯光刺眼,空调冷得刺骨。我们必须成为 Bash 脚本的巫师,然后我们毕业到 Perl,我们中更幸运的人到了 Python。俗话说,2004 年左右的互联网是用胶带和泡泡糖粘在一起的。

在 2006 年到 2007 年期间的某个时候,我们发现了亚马逊 EC2 实例的神奇世界。我们能够通过一个简单的点-and-click 接口或通过命令行工具来创建服务器。不再需要开车去机房,不再需要堆叠和连接裸金属服务器。我们可以疯狂地一次性启动 10 个 EC2 实例。甚至 20!甚至 100!天空是极限。然而,我们很快发现,手动连接到每个 EC2 实例,然后在每个实例上单独设置我们的应用程序不会扩展。创建实例本身相当容易。困难的是安装我们应用程序所需的软件包,添加正确的用户,确保文件权限正确,最后安装和配置我们的应用程序。为了解决这个问题,第一代基础架构自动化软件诞生了,“配置管理”工具代表着。Puppet 是第一个知名的配置管理工具,于 2005 年发布,早于 Amazon EC2 的发布。在 Puppet 推出后不久推出的其他类似工具包括 2008 年的 Chef,2011 年的 SaltStack,以及 2012 年的 Ansible。

到了 2009 年,世界准备迎来一个新术语的到来:DevOps。到今天为止,DevOps 有着竞争激烈的定义。有趣的是,它诞生在基础设施软件自动化的动荡早期。虽然 DevOps 中有重要的人和文化方面,但在这一章中有一件事是突出的:即自动化基础架构和应用程序的配置、部署和部署能力。

到了 2011 年,要跟踪亚马逊网络服务(AWS)套件中所有的服务变得越来越困难。云比起原始计算能力(Amazon EC2)和对象存储(Amazon S3)要复杂得多。应用程序开始依赖于相互交互的多个服务,并且需要工具来帮助自动化这些服务的配置。亚马逊没有等待太久就填补了这个需求,2011 年它开始提供这样的工具:AWS CloudFormation。这是我们真正能够通过代码描述基础设施的一个重要时刻之一。CloudFormation 为基础设施即代码(IaC)工具的新一代打开了大门,这些工具操作的是云基础设施层,低于第一代配置管理工具所提供的层次。

到了 2014 年,AWS 推出了数十项服务。那一年,另一个在 IaC 领域中重要的工具诞生了:HashiCorp 的 Terraform。时至今日,CloudFormation 和 Terraform 仍然是最常用的 IaC 工具。

在 IaC 和 DevOps 领域的另一个重要进展发生在 2013 年末到 2014 年初之间:Docker 的发布,它成为容器技术的代名词。尽管容器技术已经存在多年,但 Docker 为此带来的巨大好处在于,它将 Linux 容器和 cgroups 等技术包装成易于使用的 API 和命令行界面(CLI)工具集,大大降低了希望将其应用程序打包成容器并在任何 Docker 运行的地方部署和运行的人们的准入门槛。容器技术和容器编排平台在第十一章和第十二章中有详细讨论。

Docker 的使用率和影响力急剧上升,损害了第一代配置管理工具(Puppet、Chef、Ansible、SaltStack)的流行度。这些工具背后的公司目前正陷入困境,并都在试图通过重塑自身以适应云环境来保持活力和时效性。在 Docker 出现之前,您会使用诸如 CloudFormation 或 Terraform 等 IaC 工具来配置应用程序的基础设施,然后使用配置管理工具(如 Puppet、Chef、Ansible 或 SaltStack)部署应用程序本身(代码和配置)。Docker 突然间使得这些配置管理工具变得过时,因为它提供了一种方式,您可以将应用程序(代码+配置)打包到一个 Docker 容器中,然后在由 IaC 工具配置的基础设施内运行。

基础设施自动化工具分类

快进到 2020 年,作为一名 DevOps 从业者,在面对众多基础设施自动化工具时很容易感到迷失。

区分 IaC 工具的一种方式是看它们运行的层级。CloudFormation 和 Terraform 等工具运行在云基础设施层。它们允许你提供云资源,如计算、存储和网络,以及各种服务,如数据库、消息队列、数据分析等。配置管理工具如 Puppet、Chef、Ansible 和 SaltStack 通常在应用程序层操作,确保为你的应用程序安装所有所需的包,并确保应用程序本身配置正确(尽管这些工具中许多也有可以提供云资源的模块)。Docker 也在应用程序层操作。

另一种比较 IaC 工具的方法是将它们分为声明式和命令式两类。你可以用声明式方式告诉自动化工具要做什么,描述你想要实现的系统状态。Puppet、CloudFormation 和 Terraform 采用声明式方式操作。或者,你可以使用程序化或命令式方式使用自动化工具,指定工具需要执行的确切步骤来实现所需的系统状态。Chef 和 Ansible 采用命令式方式操作。SaltStack 可以同时采用声明式和命令式方式操作。

让我们把系统的期望状态看作建筑物(比如体育场)的建造蓝图。你可以使用 Chef 和 Ansible 这样的程序化工具,逐节、逐行地在每个部分内部建造体育场。你需要跟踪体育场的状态和建造进度。使用 Puppet、CloudFormation 和 Terraform 这样的声明式工具,你首先组装体育场的蓝图。然后工具确保建造达到蓝图中描述的状态。

鉴于本章的标题,我们将把剩下的讨论集中在 IaC 工具上,可以进一步按多个维度进行分类。

一个维度是指定系统期望状态的方式。在 CloudFormation 中,你可以使用 JSON 或 YAML 语法,而在 Terraform 中,你可以使用 HashiCorp Configuration Language (HCL) 语法。相比之下,Pulumi 和 AWS Cloud Development Kit (CDK) 允许你使用真正的编程语言,包括 Python,来指定系统的期望状态。

另一个维度是每个工具支持的云提供商。由于 CloudFormation 是亚马逊的服务,因此它专注于 AWS(尽管在使用自定义资源功能时可以定义非 AWS 资源)。AWS CDK 也是如此。相比之下,Terraform 支持许多云提供商,Pulumi 也是如此。

由于这是一本关于 Python 的书,我们想提到一个名为 troposphere 的工具,它允许你使用 Python 代码指定 CloudFormation 堆栈模板,然后将其导出为 JSON 或 YAML。Troposphere 只负责生成堆栈模板,这意味着你需要使用 CloudFormation 进行堆栈的配置。另一个同样使用 Python 并值得一提的工具是 stacker,它在底层使用 troposphere,但它还可以配置生成的 CloudFormation 堆栈模板。

本章的其余部分展示了两个自动化工具 Terraform 和 Pulumi 的实际操作,它们分别应用于一个常见的场景,即在 Amazon S3 中部署静态网站,该网站由 Amazon CloudFront CDN 托管,并通过 AWS 证书管理器(ACM)服务提供 SSL 证书保护。

注意

在以下示例中使用的某些命令会生成大量输出。除非这些输出对理解命令至关重要,否则我们将省略大部分输出行以节省资源,并帮助你更好地专注于文本内容。

手动配置

我们首先通过 AWS 的 Web 控制台手动完成了一系列操作。没有什么比亲自体验手动操作的痛苦更能让你更好地享受自动化繁琐工作的成果了!

我们首先按照 AWS S3 托管网站 的文档进行操作。

我们已经在 Namecheap 购买了一个域名:devops4all.dev。我们为该域名在 Amazon Route 53 中创建了托管区域,并将该域名在 Namecheap 中的名称服务器指向处理托管域名的 AWS DNS 服务器。

我们创建了两个 S3 存储桶,一个用于站点的根 URL(devops4all.dev),另一个用于 www URL(www.devops4all.dev)。我们的想法是将对 www 的请求重定向到根 URL。我们还按照指南配置了这些存储桶,使其支持静态网站托管,并设置了适当的权限。我们上传了一个 index.html 文件和一张 JPG 图片到根 S3 存储桶。

接下来的步骤是为处理根域名(devops4all.dev)及其任何子域名(*.devops4all.dev)的 SSL 证书进行配置。我们使用了添加到 Route 53 托管区域的 DNS 记录进行验证。

注意

ACM 证书需要在 us-east-1 AWS 区域进行配置,以便在 CloudFront 中使用。

然后我们创建了一个 AWS CloudFront CDN 分发,指向根 S3 存储桶,并使用了前面配置的 ACM 证书。我们指定 HTTP 请求应重定向到 HTTPS。分发部署完成后(大约需要 15 分钟),我们添加了 Route 53 记录,将根域名和 www 域名作为类型为别名的 A 记录,指向 CloudFront 分发终端节点的 DNS 名称。

在完成本练习时,我们能够访问 http://devops4all.dev,自动重定向到 https://devops4all.dev,并看到我们上传的图片显示在站点首页上。我们还尝试访问 http://www.devops4all.dev,并被重定向到 https://devops4all.dev

我们手动创建所有提到的 AWS 资源大约花了 30 分钟。此外,我们还花了 15 分钟等待 CloudFront 分发的传播,总共是 45 分钟。请注意,我们之前已经做过这些,所以我们几乎完全知道该怎么做,只需要最少量地参考 AWS 指南。

注意

值得一提的是,如今很容易配置免费的 SSL 证书。早已不再需要等待 SSL 证书提供商审批您的请求,并提交证明您的公司存在的时间,只需使用 AWS ACM 或 Let’s Encrypt,2020 年再也没有不应该在站点的所有页面上启用 SSL 的借口。

使用 Terraform 进行自动化基础设施配置

我们决定使用 Terraform 作为首选的 IaC 工具来自动化这些任务,尽管 Terraform 与 Python 没有直接关系。它有几个优点,如成熟性、强大的生态系统和多云供应商。

编写 Terraform 代码的推荐方式是使用模块,这些模块是 Terraform 配置代码的可重用组件。HashiCorp 托管的 Terraform 模块的 注册表 是一个通用的地方,您可以搜索用于配置所需资源的现成模块。在本示例中,我们将编写自己的模块。

此处使用的 Terraform 版本是 0.12.1,在撰写本文时是最新版本。在 Mac 上通过 brew 安装它:

$ brew install terraform

配置一个 S3 存储桶

创建一个 modules 目录,在其下创建一个 s3 目录,其中包含三个文件:main.tfvariables.tfoutputs.tfs3 目录中的 main.tf 文件告诉 Terraform 创建一个具有特定策略的 S3 存储桶。它使用一个名为 domain_name 的变量,在 variables.tf 中声明,其值由调用此模块的用户传递给它。它输出 S3 存储桶的 DNS 端点,其他模块将使用它作为输入变量。

这里是 modules/s3 中的三个文件:

$ cat modules/s3/main.tf
resource "aws_s3_bucket" "www" {
  bucket = "www.${var.domain_name}"
  acl = "public-read"
  policy = <<POLICY
{
  "Version":"2012-10-17",
  "Statement":[
    {
      "Sid":"AddPerm",
      "Effect":"Allow",
      "Principal": "*",
      "Action":["s3:GetObject"],
      "Resource":["arn:aws:s3:::www.${var.domain_name}/*"]
    }
  ]
}
POLICY

  website {
    index_document = "index.html"
  }
}

$ cat modules/s3/variables.tf
variable "domain_name" {}

$ cat modules/s3/outputs.tf
output "s3_www_website_endpoint" {
  value = "${aws_s3_bucket.www.website_endpoint}"
}
注意

上述 aws_s3_bucket 资源的 policy 属性是允许公共访问该存储桶的 S3 存储桶策略的一个示例。如果您在 IaC 环境中使用 S3 存储桶,请熟悉 官方 AWS 存储桶和用户策略文档

将所有模块绑定在一起的主要 Terraform 脚本位于当前目录中的名为 main.tf 的文件中:

$ cat main.tf
provider "aws" {
  region = "${var.aws_region}"
}

module "s3" {
  source = "./modules/s3"
  domain_name = "${var.domain_name}"
}

它引用了一个定义在名为 variables.tf 的单独文件中的变量:

$ cat variables.tf
variable "aws_region" {
  default = "us-east-1"
}

variable "domain_name" {
  default = "devops4all.dev"
}

这是当前目录树的情况:

|____main.tf
|____variables.tf
|____modules
| |____s3
| | |____outputs.tf
| | |____main.tf
| | |____variables.tf

运行 Terraform 的第一步是调用 terraform init 命令,它将读取主文件引用的任何模块的内容。

接下来的步骤是运行 terraform plan 命令,它创建了前面讨论中提到的蓝图。

要创建计划中指定的资源,请运行 terraform apply 命令:

$ terraform apply

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.s3.aws_s3_bucket.www will be created
  + resource "aws_s3_bucket" "www" {
      + acceleration_status = (known after apply)
      + acl  = "public-read"
      + arn  = (known after apply)
      + bucket  = "www.devops4all.dev"
      + bucket_domain_name  = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy = false
      + hosted_zone_id= (known after apply)
      + id= (known after apply)
      + policy  = jsonencode(
            {
              + Statement = [
                  + {
                      + Action = [
                          + "s3:GetObject",
                        ]
                      + Effect = "Allow"
                      + Principal = "*"
                      + Resource  = [
                          + "arn:aws:s3:::www.devops4all.dev/*",
                        ]
                      + Sid = "AddPerm"
                    },
                ]
              + Version= "2012-10-17"
            }
        )
      + region  = (known after apply)
      + request_payer = (known after apply)
      + website_domain= (known after apply)
      + website_endpoint = (known after apply)

      + versioning {
          + enabled = (known after apply)
          + mfa_delete = (known after apply)
        }

      + website {
          + index_document = "index.html"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.s3.aws_s3_bucket.www: Creating...
module.s3.aws_s3_bucket.www: Creation complete after 7s [www.devops4all.dev]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

此时,请检查是否使用 AWS Web 控制台 UI 创建了 S3 存储桶。

使用 AWS ACM 配置 SSL 证书

下一个模块是为使用 AWS 证书管理器服务进行 SSL 证书配置而创建的。创建一个名为 modules/acm 的目录,其中包含三个文件:main.tfvariables.tfoutputs.tfacm 目录中的 main.tf 文件告诉 Terraform 使用 DNS 作为验证方法创建 ACM SSL 证书。它使用一个名为 domain_name 的变量,该变量在 variables.tf 中声明,并由调用此模块的调用者传递其值。它输出证书的 ARN 标识符,该标识符将被其他模块用作输入变量。

$ cat modules/acm/main.tf
resource "aws_acm_certificate" "certificate" {
  domain_name = "*.${var.domain_name}"
  validation_method = "DNS"
  subject_alternative_names = ["*.${var.domain_name}"]
}

$ cat modules/acm/variables.tf
variable "domain_name" {
}

$ cat modules/acm/outputs.tf
output "certificate_arn" {
  value = "${aws_acm_certificate.certificate.arn}"
}

在主 Terraform 文件中添加对新的 acm 模块的引用:

$ cat main.tf
provider "aws" {
  region = "${var.aws_region}"
}

module "s3" {
  source = "./modules/s3"
  domain_name = "${var.domain_name}"
}

module "acm" {
  source = "./modules/acm"
  domain_name = "${var.domain_name}"
}

接下来的三个步骤与 S3 存储桶创建序列中的步骤相同:terraform initterraform planterraform apply

使用 AWS 控制台添加必要的 Route 53 记录以进行验证过程。证书通常会在几分钟内验证和发布。

配置 Amazon CloudFront 分发

下一个模块是为创建 Amazon CloudFront 分发而创建的。创建一个名为 modules/cloudfront 的目录,其中包含三个文件:main.tfvariables.tfoutputs.tfcloudfront 目录中的 main.tf 文件告诉 Terraform 创建 CloudFront 分发资源。它使用在 variables.tf 中声明的多个变量,这些变量的值由调用此模块的调用者传递。它输出 CloudFront 端点的 DNS 域名和 CloudFront 分发的托管 Route 53 区域 ID,这些将作为其他模块的输入变量使用:

$ cat modules/cloudfront/main.tf
resource "aws_cloudfront_distribution" "www_distribution" {
  origin {
    custom_origin_config {
      // These are all the defaults.
      http_port= "80"
      https_port  = "443"
      origin_protocol_policy = "http-only"
      origin_ssl_protocols= ["TLSv1", "TLSv1.1", "TLSv1.2"]
    }

    domain_name = "${var.s3_www_website_endpoint}"
    origin_id= "www.${var.domain_name}"
  }

  enabled  = true
  default_root_object = "index.html"

  default_cache_behavior {
    viewer_protocol_policy = "redirect-to-https"
    compress = true
    allowed_methods= ["GET", "HEAD"]
    cached_methods = ["GET", "HEAD"]
    target_origin_id = "www.${var.domain_name}"
    min_ttl  = 0
    default_ttl = 86400
    max_ttl  = 31536000

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  aliases = ["www.${var.domain_name}"]

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn = "${var.acm_certificate_arn}"
    ssl_support_method  = "sni-only"
  }
}

$ cat modules/cloudfront/variables.tf
variable "domain_name" {}
variable "acm_certificate_arn" {}
variable "s3_www_website_endpoint" {}

$ cat modules/cloudfront/outputs.tf
output "domain_name" {
  value = "${aws_cloudfront_distribution.www_distribution.domain_name}"
}

output "hosted_zone_id" {
  value = "${aws_cloudfront_distribution.www_distribution.hosted_zone_id}"
}

在主 Terraform 文件中添加对 cloudfront 模块的引用。将 s3_www_website_endpointacm_certificate_arn 作为输入变量传递给 cloudfront 模块。它们的值从其他模块 s3acm 的输出中获取。

注意

ARN 代表 Amazon 资源名称。它是一个字符串,用于唯一标识给定的 AWS 资源。当您使用在 AWS 内运行的 IaC 工具时,会看到许多生成的 ARN 值作为变量传递和传递。

$ cat main.tf
provider "aws" {
  region = "${var.aws_region}"
}

module "s3" {
  source = "./modules/s3"
  domain_name = "${var.domain_name}"
}

module "acm" {
  source = "./modules/acm"
  domain_name = "${var.domain_name}"
}

module "cloudfront" {
  source = "./modules/cloudfront"
  domain_name = "${var.domain_name}"
  s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

接下来的三个步骤是使用 Terraform 进行资源配置的常规步骤:terraform initterraform planterraform apply

在这种情况下,terraform apply 步骤耗时约 23 分钟。创建 Amazon CloudFront 分发是 AWS 中最耗时的操作之一,因为该分发在幕后由 Amazon 在全球范围内部署。

配置 Route 53 DNS 记录

下一个模块是为站点 www.devops4all.dev 的主域创建 Route 53 DNS 记录。创建一个名为 modules/route53 的目录,并包含两个文件:main.tfvariables.tfroute53 目录中的 main.tf 文件告诉 Terraform 创建一个类型为 A 的 Route 53 DNS 记录,作为 CloudFront 终端节点的 DNS 名称的别名。它使用在 variables.tf 中声明的几个变量,并通过调用此模块的调用者传递给它的值:

$ cat modules/route53/main.tf
resource "aws_route53_record" "www" {
  zone_id = "${var.zone_id}"
  name = "www.${var.domain_name}"
  type = "A"

  alias {
    name  = "${var.cloudfront_domain_name}"
    zone_id  = "${var.cloudfront_zone_id}"
    evaluate_target_health = false
  }
}

$ cat modules/route53/variables.tf
variable "domain_name" {}
variable "zone_id" {}
variable "cloudfront_domain_name" {}
variable "cloudfront_zone_id" {}

main.tf Terraform 文件中添加对 route53 模块的引用。将 zone_idcloudfront_domain_namecloudfront_zone_id 作为输入变量传递给 route53 模块。zone_id 的值在当前目录的 variables.tf 中声明,而其他值则从 cloudfront 模块的输出中检索:

$ cat main.tf
provider "aws" {
  region = "${var.aws_region}"
}

module "s3" {
  source = "./modules/s3"
  domain_name = "${var.domain_name}"
}

module "acm" {
  source = "./modules/acm"
  domain_name = "${var.domain_name}"
}

module "cloudfront" {
  source = "./modules/cloudfront"
  domain_name = "${var.domain_name}"
  s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
  acm_certificate_arn = "${module.acm.certificate_arn}"
}

module "route53" {
  source = "./modules/route53"
  domain_name = "${var.domain_name}"
  zone_id = "${var.zone_id}"
  cloudfront_domain_name = "${module.cloudfront.domain_name}"
  cloudfront_zone_id = "${module.cloudfront.hosted_zone_id}"
}

$ cat variables.tf
variable "aws_region" {
  default = "us-east-1"
}

variable "domain_name" {
  default = "devops4all.dev"
}

variable "zone_id" {
  default = "ZWX18ZIVHAA5O"
}

接下来的三个步骤,现在对您来说应该非常熟悉了,是使用 Terraform 配置资源的:terraform initterraform planterraform apply

将静态文件复制到 S3

为了测试从头到尾创建静态网站的配置,创建一个名为 index.html 的简单文件,其中包含一个 JPEG 图像,并将这两个文件复制到之前使用 Terraform 配置的 S3 存储桶。确保 AWS_PROFILE 环境变量已设置为 ~/.aws/credentials 文件中已存在的正确值:

$ echo $AWS_PROFILE
gheorghiu-net
$ aws s3 cp static_files/index.html s3://www.devops4all.dev/index.html
upload: static_files/index.html to s3://www.devops4all.dev/index.html
$ aws s3 cp static_files/devops4all.jpg s3://www.devops4all.dev/devops4all.jpg
upload: static_files/devops4all.jpg to s3://www.devops4all.dev/devops4all.jpg

访问 https://www.devops4all.dev/ 并验证您是否可以看到已上传的 JPG 图像。

使用 Terraform 删除所有已配置的 AWS 资源

每当您配置云资源时,都需要注意与其相关的费用。很容易忘记它们,您可能会在月底收到意外的 AWS 账单。确保删除上面配置的所有资源。通过运行 terraform destroy 命令来删除这些资源。还要注意,在运行 terraform destroy 之前需要删除 S3 存储桶的内容,因为 Terraform 不会删除非空桶的内容。

注意

在运行 terraform destroy 命令之前,请确保您不会删除仍可能在生产环境中使用的资源!

使用 Pulumi 自动化基础设施配置

当涉及到 IaC 工具时,Pulumi 是新秀之一。关键词是 new,这意味着它在某些方面仍然有些粗糙,特别是在 Python 支持方面。

Pulumi 允许您通过告诉它使用真正的编程语言来提供所需的基础设施状态,来指定您的基础设施的期望状态。TypeScript 是 Pulumi 支持的第一种语言,但现在也支持 Go 和 Python。

理解在 Python 中使用 Pulumi 编写基础设施自动化代码与使用 AWS 自动化库(如 Boto)之间的区别至关重要。

使用 Pulumi,您的 Python 代码描述了要部署的资源。实际上,您正在创建本章开头讨论的蓝图或状态。这使得 Pulumi 类似于 Terraform,但其主要区别在于 Pulumi 允许您充分利用像 Python 这样的编程语言的全部功能,例如编写函数、循环、使用变量等。您不受 Terraform 的 HCL 等标记语言的限制。Pulumi 结合了声明式方法的强大之处(描述所需的最终状态)与真正编程语言的强大之处。

使用诸如 Boto 之类的 AWS 自动化库,您可以通过编写的代码描述和提供单个 AWS 资源。没有整体蓝图或状态。您需要自行跟踪已提供的资源,并协调其创建和移除。这是自动化工具的命令式或过程化方法。您仍然可以利用编写 Python 代码的优势。

要开始使用 Pulumi,请在他们的网站 pulumi.io 上创建一个免费帐户。然后,您可以在本地计算机上安装pulumi命令行工具。在 Macintosh 上,请使用 Homebrew 安装pulumi

本地运行的第一个命令是pulumi login

$ pulumi login
Logged into pulumi.com as griggheo (https://app.pulumi.com/griggheo)

创建 AWS 的新 Pulumi Python 项目

创建一个名为proj1的目录,在该目录中运行pulumi new,并选择aws-python模板。在项目创建的过程中,pulumi会要求您为堆栈命名。称其为staging

$ mkdir proj1
$ cd proj1
$ pulumi new
Please choose a template: aws-python        A minimal AWS Python Pulumi program
This command will walk you through creating a new Pulumi project.

Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.

project name: (proj1)
project description: (A minimal AWS Python Pulumi program)
Created project 'proj1'

stack name: (dev) staging
Created stack 'staging'

aws:region: The AWS region to deploy into: (us-east-1)
Saved config

Your new project is ready to go!
To perform an initial deployment, run the following commands:

   1\. virtualenv -p python3 venv
   2\. source venv/bin/activate
   3\. pip3 install -r requirements.txt

Then, run 'pulumi up'

重要的是要理解 Pulumi 项目与 Pulumi 堆栈之间的区别。项目是您为指定系统所需状态编写的代码,即您希望 Pulumi 提供的资源。堆栈是项目的特定部署。例如,堆栈可以对应于开发、暂存或生产等环境。在接下来的示例中,我们将创建两个 Pulumi 堆栈,一个称为staging,对应于暂存环境,以及稍后的另一个称为prod,对应于生产环境。

这里是由pulumi new命令自动生成的文件,作为aws-python模板的一部分:

$ ls -la
total 40
drwxr-xr-x   7 ggheo  staff  224 Jun 13 21:43 .
drwxr-xr-x  11 ggheo  staff  352 Jun 13 21:42 ..
-rw-------   1 ggheo  staff   12 Jun 13 21:43 .gitignore
-rw-r--r--   1 ggheo  staff   32 Jun 13 21:43 Pulumi.staging.yaml
-rw-------   1 ggheo  staff   77 Jun 13 21:43 Pulumi.yaml
-rw-------   1 ggheo  staff  184 Jun 13 21:43 __main__.py
-rw-------   1 ggheo  staff   34 Jun 13 21:43 requirements.txt

按照pulumi new的输出指示安装virtualenv,然后创建新的virtualenv环境并安装requirements.txt中指定的库:

$ pip3 install virtualenv
$ virtualenv -p python3 venv
$ source venv/bin/activate
(venv) pip3 install -r requirements.txt
注意

在使用pulumi up之前,确保您正在使用预期目标的 AWS 帐户来配置任何 AWS 资源。指定所需 AWS 帐户的一种方法是在当前 shell 中设置AWS_PROFILE环境变量。在我们的情况下,本地~/.aws/credentials文件中已设置了名为gheorghiu-net的 AWS 配置文件。

(venv) export AWS_PROFILE=gheorghiu-net

由 Pulumi 作为aws-python模板的一部分生成的main.py文件如下:

$ cat __main__.py
import pulumi
from pulumi_aws import s3

# Create an AWS resource (S3 Bucket)
bucket = s3.Bucket('my-bucket')

# Export the name of the bucket
pulumi.export('bucket_name',  bucket.id)

本地克隆Pulumi 示例 GitHub 存储库,然后将__main__.py 和pulumi-examples/aws-py-s3-folder中的 www 目录复制到当前目录中。

这里是当前目录中的新__main__.py 文件:

$ cat __main__.py
import json
import mimetypes
import os

from pulumi import export, FileAsset
from pulumi_aws import s3

web_bucket = s3.Bucket('s3-website-bucket', website={
    "index_document": "index.html"
})

content_dir = "www"
for file in os.listdir(content_dir):
    filepath = os.path.join(content_dir, file)
    mime_type, _ = mimetypes.guess_type(filepath)
    obj = s3.BucketObject(file,
        bucket=web_bucket.id,
        source=FileAsset(filepath),
        content_type=mime_type)

def public_read_policy_for_bucket(bucket_name):
    return json.dumps({
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                f"arn:aws:s3:::{bucket_name}/*",
            ]
        }]
    })

bucket_name = web_bucket.id
bucket_policy = s3.BucketPolicy("bucket-policy",
    bucket=bucket_name,
    policy=bucket_name.apply(public_read_policy_for_bucket))

# Export the name of the bucket
export('bucket_name',  web_bucket.id)
export('website_url', web_bucket.website_endpoint)

请注意 Python 变量content_dirbucket_name的使用,for循环的使用,以及正常 Python 函数public_read_policy_for_bucket的使用。能够在 IaC 程序中使用正常的 Python 结构感到耳目一新!

现在是运行pulumi up以提供在__main__.py 中指定的资源的时候了。该命令将展示将要创建的所有资源。将当前选择移动到yes将启动配置过程:

(venv) pulumi up
Previewing update (staging):

     Type                    Name               Plan
 +   pulumi:pulumi:Stack     proj1-staging      create
 +   ├─ aws:s3:Bucket        s3-website-bucket  create
 +   ├─ aws:s3:BucketObject  favicon.png        create
 +   ├─ aws:s3:BucketPolicy  bucket-policy      create
 +   ├─ aws:s3:BucketObject  python.png         create
 +   └─ aws:s3:BucketObject  index.html         create

Resources:
    + 6 to create

Do you want to perform this update? yes
Updating (staging):

     Type                    Name               Status
 +   pulumi:pulumi:Stack     proj1-staging      created
 +   ├─ aws:s3:Bucket        s3-website-bucket  created
 +   ├─ aws:s3:BucketObject  index.html         created
 +   ├─ aws:s3:BucketObject  python.png         created
 +   ├─ aws:s3:BucketObject  favicon.png        created
 +   └─ aws:s3:BucketPolicy  bucket-policy      created

Outputs:
    bucket_name: "s3-website-bucket-8e08f8f"
    website_url: "s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com"

Resources:
    + 6 created

Duration: 14s

检查现有的 Pulumi 堆栈:

(venv) pulumi stack ls
NAME      LAST UPDATE    RESOURCE COUNT  URL
staging*  2 minutes ago  7        https://app.pulumi.com/griggheo/proj1/staging

(venv) pulumi stack
Current stack is staging:
    Owner: griggheo
    Last updated: 3 minutes ago (2019-06-13 22:05:38.088773 -0700 PDT)
    Pulumi version: v0.17.16
Current stack resources (7):
    TYPE                              NAME
    pulumi:pulumi:Stack               proj1-staging
    pulumi:providers:aws              default
    aws:s3/bucket:Bucket              s3-website-bucket
    aws:s3/bucketPolicy:BucketPolicy  bucket-policy
    aws:s3/bucketObject:BucketObject  index.html
    aws:s3/bucketObject:BucketObject  favicon.png
    aws:s3/bucketObject:BucketObject  python.png

检查当前堆栈的输出:

(venv) pulumi stack output
Current stack outputs (2):
    OUTPUT       VALUE
    bucket_name  s3-website-bucket-8e08f8f
    website_url  s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com

访问website_url输出中指定的 URL(http://s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com),确保您可以看到静态站点。

在接下来的章节中,将通过指定更多的 AWS 资源来增强 Pulumi 项目。目标是与使用 Terraform 配置的资源保持一致:一个 ACM SSL 证书,一个 CloudFront 分发和一个用于站点 URL 的 Route 53 DNS 记录。

为 Staging 堆栈创建配置值

当前堆栈为staging。将现有的 www 目录重命名为 www-staging,然后使用pulumi config set命令为当前staging堆栈指定两个配置值:domain_namelocal_webdir

提示

有关 Pulumi 如何管理配置值和密钥的详细信息,请参阅Pulumi 参考文档

(venv) mv www www-staging
(venv) pulumi config set local_webdir www-staging
(venv) pulumi config set domain_name staging.devops4all.dev

要检查当前堆栈的现有配置值,请运行:

(venv) pulumi config
KEY           VALUE
aws:region    us-east-1
domain_name   staging.devops4all.dev
local_webdir  www-staging

配置值设置完毕后,将它们用于 Pulumi 代码中:

import pulumi

config = pulumi.Config('proj1')  # proj1 is project name defined in Pulumi.yaml

content_dir = config.require('local_webdir')
domain_name = config.require('domain_name')

现在配置值已经就位;接下来我们将使用 AWS 证书管理器服务来提供 SSL 证书。

配置 ACM SSL 证书

大约在这一点上,当涉及到其 Python SDK 时,Pulumi 开始显露出其不足之处。仅仅阅读 Pulumi Python SDK 参考中的acm模块并不足以理解您在 Pulumi 程序中需要做的事情。

幸运的是,有许多 Pulumi TypeScript 示例可供您参考。一个展示我们用例的示例是aws-ts-static-website

这是创建新 ACM 证书的 TypeScript 代码(来自index.ts):

const certificate = new aws.acm.Certificate("certificate", {
    domainName: config.targetDomain,
    validationMethod: "DNS",
}, { provider: eastRegion });

这是我们编写的相应 Python 代码:

from pulumi_aws import acm

cert = acm.Certificate('certificate', domain_name=domain_name,
    validation_method='DNS')
提示

从 TypeScript 转换 Pulumi 代码到 Python 的一个经验法则是,在 TypeScript 中使用驼峰命名法的参数在 Python 中会变成蛇形命名法。正如您在前面的示例中看到的,domainName变成了domain_name,而validationMethod变成了validation_method

我们的下一步是为 ACM SSL 证书配置 Route 53 区域,并在该区域中为 DNS 验证记录。

配置 Route 53 区域和 DNS 记录

如果您遵循 Pulumi SDK reference for route53,使用 Pulumi 配置新的 Route 53 区域非常容易。

from pulumi_aws import route53

domain_name = config.require('domain_name')

# Split a domain name into its subdomain and parent domain names.
# e.g. "www.example.com" => "www", "example.com".
def get_domain_and_subdomain(domain):
  names = domain.split(".")
  if len(names) < 3:
    return('', domain)
  subdomain = names[0]
  parent_domain = ".".join(names[1:])
  return (subdomain, parent_domain)

(subdomain, parent_domain) = get_domain_and_subdomain(domain_name)
zone = route53.Zone("route53_zone", name=parent_domain)

前面的代码片段显示了如何使用常规 Python 函数将读取的配置值拆分为 domain_name 变量的两部分。如果 domain_namestaging.devops4all.dev,则函数将其拆分为 subdomain (staging) 和 parent_domain (devops4all.dev)。

parent_domain 变量然后作为 zone 对象的构造函数的参数使用,告诉 Pulumi 配置 route53.Zone 资源。

注意

创建 Route 53 区域后,我们必须将 Namecheap 的域名服务器指向新区域的 DNS 记录中指定的域名服务器,以便该区域可以公开访问。

到目前为止,一切都很顺利。接下来的步骤是同时创建 ACM 证书和用于验证证书的 DNS 记录。

我们首先尝试通过将 camelCase 参数名转换为 snake_case 的经验法则来移植示例 TypeScript 代码。

TypeScript:

    const certificateValidationDomain = new aws.route53.Record(
        `${config.targetDomain}-validation`, {
        name: certificate.domainValidationOptions[0].resourceRecordName,
        zoneId: hostedZoneId,
        type: certificate.domainValidationOptions[0].resourceRecordType,
        records: [certificate.domainValidationOptions[0].resourceRecordValue],
        ttl: tenMinutes,
    });

第一次尝试通过将 camelCase 转换为 snake_case 来将示例移植到 Python:

cert = acm.Certificate('certificate',
    domain_name=domain_name, validation_method='DNS')

domain_validation_options = cert.domain_validation_options[0]

cert_validation_record = route53.Record(
  'cert-validation-record',
  name=domain_validation_options.resource_record_name,
  zone_id=zone.id,
  type=domain_validation_options.resource_record_type,
  records=[domain_validation_options.resource_record_value],
  ttl=600)

运气不佳。pulumi up 显示如下错误:

AttributeError: 'dict' object has no attribute 'resource_record_name'

在这一点上,我们陷入困境,因为 Python SDK 文档没有包含这么详细的信息。我们不知道在 domain_validation_options 对象中需要指定哪些属性。

我们只能通过在 Pulumi 的导出列表中添加 domain_validation_options 对象来解决此问题,这些对象由 Pulumi 在 pulumi up 操作结束时打印出来。

export('domain_validation_options', domain_validation_options)

pulumi up 的输出如下:

+ domain_validation_options: {
  + domain_name        : "staging.devops4all.dev"
  + resourceRecordName : "_c5f82e0f032d0f4f6c7de17fc2c.staging.devops4all.dev."
  + resourceRecordType : "CNAME"
  + resourceRecordValue: "_08e3d475bf3aeda0c98.ltfvzjuylp.acm-validations.aws."
    }

终于找到了!原来 domain_validation_options 对象的属性仍然是驼峰命名法。

这是第二次成功移植到 Python 的尝试:

cert_validation_record = route53.Record(
  'cert-validation-record',
  name=domain_validation_options['resourceRecordName'],
  zone_id=zone.id,
  type=domain_validation_options['resourceRecordType'],
  records=[domain_validation_options['resourceRecordValue']],
  ttl=600)

接下来,指定要配置的新类型资源:证书验证完成资源。这使得 pulumi up 操作等待 ACM 通过检查先前创建的 Route 53 验证记录来验证证书。

cert_validation_completion = acm.CertificateValidation(
        'cert-validation-completion',
        certificate_arn=cert.arn,
        validation_record_fqdns=[cert_validation_dns_record.fqdn])

cert_arn = cert_validation_completion.certificate_arn

到此为止,您已经拥有了通过完全自动化的方式配置 ACM SSL 证书并通过 DNS 进行验证的方法。

接下来的步骤是在托管站点静态文件的 S3 存储桶前面配置 CloudFront 分发。

配置 CloudFront 分发

使用 Pulumi cloudfront 模块 的 SDK 参考来确定传递给 cloudfront.Distribution 的构造函数参数。检查 TypeScript 代码以了解这些参数的正确值。

这里是最终结果:

log_bucket = s3.Bucket('cdn-log-bucket', acl='private')

cloudfront_distro = cloudfront.Distribution ( 'cloudfront-distro',
    enabled=True,
    aliases=[ domain_name ],
    origins=[
        {
          'originId': web_bucket.arn,
          'domainName': web_bucket.website_endpoint,
          'customOriginConfig': {
              'originProtocolPolicy': "http-only",
              'httpPort': 80,
              'httpsPort': 443,
              'originSslProtocols': ["TLSv1.2"],
            },
        },
    ],

    default_root_object="index.html",
    default_cache_behavior={
        'targetOriginId': web_bucket.arn,

        'viewerProtocolPolicy': "redirect-to-https",
        'allowedMethods': ["GET", "HEAD", "OPTIONS"],
        'cachedMethods': ["GET", "HEAD", "OPTIONS"],

        'forwardedValues': {
            'cookies': { 'forward': "none" },
            'queryString': False,
        },

        'minTtl': 0,
        'defaultTtl': 600,
        'maxTtl': 600,
    },
    price_class="PriceClass_100",
    custom_error_responses=[
        { 'errorCode': 404, 'responseCode': 404,
          'responsePagePath': "/404.html" },
    ],

    restrictions={
        'geoRestriction': {
            'restrictionType': "none",
        },
    },
    viewer_certificate={
        'acmCertificateArn': cert_arn,
        'sslSupportMethod': "sni-only",
    },
    logging_config={
        'bucket': log_bucket.bucket_domain_name,
        'includeCookies': False,
        'prefix': domain_name,
    })

运行 pulumi up 以配置 CloudFront 分发。

为站点 URL 配置 Route 53 DNS 记录

在端到端为 staging 栈配置资源的最后一步是将 A 类型的 DNS 记录作为 CloudFront 终端节点的域的别名指定。

site_dns_record = route53.Record(
        'site-dns-record',
        name=subdomain,
        zone_id=zone.id,
        type="A",
        aliases=[
        {
            'name': cloudfront_distro.domain_name,
            'zoneId': cloudfront_distro.hosted_zone_id,
            'evaluateTargetHealth': True
        }
    ])

如常运行 pulumi up

访问 https://staging.devops4all.dev,查看上传到 S3 的文件。转到 AWS 控制台中的日志桶,并确保 CloudFront 日志在那里。

让我们看看如何将相同的 Pulumi 项目部署到一个新的环境,由新的 Pulumi 栈表示。

创建和部署新栈

我们决定修改 Pulumi 程序,使其不再创建新的 Route 53 区域,而是使用现有区域的区域 ID 作为配置值。

要创建 prod 栈,请使用命令 pulumi stack init 并将其名称指定为 prod

(venv) pulumi stack init
Please enter your desired stack name: prod
Created stack 'prod'

列出栈现在显示了两个栈,stagingprodprod 栈旁边带有星号,表示 prod 是当前栈:

(venv) pulumi stack ls
NAME     LAST UPDATE     RESOURCE COUNT  URL
prod*    n/a             n/a      https://app.pulumi.com/griggheo/proj1/prod
staging  14 minutes ago  14       https://app.pulumi.com/griggheo/proj1/staging

现在是为 prod 栈设置正确配置值的时候了。使用一个新的 dns_zone_id 配置值,设置为在 Pulumi 配置 staging 栈时已创建的区域的 ID:

(venv) pulumi config set aws:region us-east-1
(venv) pulumi config set local_webdir www-prod
(venv) pulumi config set domain_name www.devops4all.dev
(venv) pulumi config set dns_zone_id Z2FTL2X8M0EBTW

更改代码以从配置中读取 zone_id 并且不创建 Route 53 区域对象。

运行 pulumi up 配置 AWS 资源:

(venv) pulumi up
Previewing update (prod):

     Type                            Name               Plan
     pulumi:pulumi:Stack             proj1-prod
 +   ├─ aws:cloudfront:Distribution  cloudfront-distro  create
 +   └─ aws:route53:Record           site-dns-record    create

Resources:
    + 2 to create
    10 unchanged

Do you want to perform this update? yes
Updating (prod):

     Type                            Name               Status
     pulumi:pulumi:Stack             proj1-prod
 +   ├─ aws:cloudfront:Distribution  cloudfront-distro  created
 +   └─ aws:route53:Record           site-dns-record    created

Outputs:
+ cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
+ log_bucket_id    : "cdn-log-bucket-53d8ea3"
+ web_bucket_id    : "s3-website-bucket-cde"
+ website_url      : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"

Resources:
    + 2 created
    10 unchanged

Duration: 18m54s

成功!prod 栈已完全部署。

然而,此时 www-prod 目录中包含站点静态文件的内容与 www-staging 目录中的内容相同。

修改 www-prod/index.html,将“Hello, S3!”改为“Hello, S3 production!”,然后再次运行 pulumi up 以检测更改并将修改后的文件上传到 S3:

(venv) pulumi up
Previewing update (prod):

     Type                    Name        Plan       Info
     pulumi:pulumi:Stack     proj1-prod
 ~   └─ aws:s3:BucketObject  index.html  update     [diff: ~source]

Resources:
    ~ 1 to update
    11 unchanged

Do you want to perform this update? yes
Updating (prod):

     Type                    Name        Status      Info
     pulumi:pulumi:Stack     proj1-prod
 ~   └─ aws:s3:BucketObject  index.html  updated     [diff: ~source]

Outputs:
cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
log_bucket_id    : "cdn-log-bucket-53d8ea3"
web_bucket_id    : "s3-website-bucket-cde"
website_url      : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"

Resources:
    ~ 1 updated
    11 unchanged

Duration: 4s

使 CloudFront 分发的缓存失效以查看更改。

访问 https://www.devops4all.dev,查看消息:Hello, S3 production!

关于跟踪系统状态的 IaC 工具有一个注意事项:有时工具看到的状态与实际状态不同。在这种情况下,同步两种状态非常重要;否则,它们会越来越分离,你会陷入不敢再进行更改的境地,因为害怕会破坏生产环境。这就是为什么“Code”一词在“基础设施即代码”中占主导地位的原因。一旦决定使用 IaC 工具,最佳实践建议所有资源都通过代码进行配置,不再手动创建任何资源。保持这种纪律是很难的,但长远来看会带来回报。

练习

  • 使用 AWS Cloud Development Kit 配置相同的一组 AWS 资源。

  • 使用 Terraform 或 Pulumi 从其他云服务提供商(如 Google Cloud Platform 或 Microsoft Azure)配置云资源。

第十一章:容器技术:Docker 和 Docker Compose

虚拟化技术自 IBM 大型机时代就已存在。大多数人没有机会使用过大型机,但我们相信本书的一些读者还记得他们曾经在惠普或戴尔等制造商那里设置或使用裸金属服务器的日子。这些制造商今天仍然存在,你仍然可以在像互联网泡沫时代那样的机房中使用裸金属服务器。

然而,当大多数人想到虚拟化时,他们不会自动想到大型机。相反,他们很可能想象的是在虚拟化管理程序(如 VMware ESX 或 Citrix/Xen)上运行的虚拟机(VM),并且运行了 Fedora 或 Ubuntu 等客户操作系统(OS)。虚拟机相对于普通裸金属服务器的一大优势是,通过使用虚拟机,你可以通过在几个虚拟机之间分割它们来优化服务器的资源(CPU、内存、磁盘)。你还可以在一个共享的裸金属服务器上运行几个操作系统,每个操作系统在自己的虚拟机中运行,而不是为每个目标操作系统购买专用服务器。像亚马逊 EC2 这样的云计算服务如果没有虚拟化管理程序和虚拟机是不可能的。这种类型的虚拟化可以称为内核级,因为每个虚拟机运行其自己的操作系统内核。

在对资源追求更大回报的不懈努力中,人们意识到虚拟机在资源利用上仍然是浪费的。下一个逻辑步骤是将单个应用程序隔离到自己的虚拟环境中。通过在同一个操作系统内核中运行容器来实现这一目标。在这种情况下,它们在文件系统级别被隔离。Linux 容器(LXC)和 Sun Solaris zones 是这种技术的早期示例。它们的缺点是使用起来很困难,并且与它们运行的操作系统紧密耦合。在容器使用方面的重大突破是当 Docker 开始提供一种简便的方法来管理和运行文件系统级容器。

什么是 Docker 容器?

一个 Docker 容器封装了一个应用程序以及它运行所需的其他软件包和库。人们有时会将 Docker 容器和 Docker 镜像的术语互换使用,但它们之间是有区别的。封装应用程序的文件系统级对象称为 Docker 镜像。当你运行该镜像时,它就成为了一个 Docker 容器。

你可以运行许多 Docker 容器,它们都使用相同的操作系统内核。唯一的要求是你必须在要运行容器的主机上安装一个称为 Docker 引擎或 Docker 守护程序的服务器端组件。通过这种方式,主机资源可以在容器之间以更精细的方式分割和利用,让你的投资得到更大的回报。

Docker 容器提供了比常规 Linux 进程更多的隔离和资源控制,但提供的功能不及完整的虚拟机。为了实现这些隔离和资源控制的特性,Docker 引擎利用了 Linux 内核功能,如命名空间、控制组(或 cgroups)和联合文件系统(UnionFS)。

Docker 容器的主要优势是可移植性。一旦创建了 Docker 镜像,您可以在任何安装有 Docker 服务器端守护程序的主机操作系统上作为 Docker 容器运行它。如今,所有主要操作系统都运行 Docker 守护程序:Linux、Windows 和 macOS。

所有这些可能听起来太理论化,所以现在是一些具体示例的时候了。

创建、构建、运行和删除 Docker 镜像和容器

由于这是一本关于 Python 和 DevOps 的书籍,我们将以经典的 Flask “Hello World” 作为在 Docker 容器中运行的应用程序的第一个示例。本节中显示的示例使用 Docker for Mac 包。后续章节将展示如何在 Linux 上安装 Docker。

这是 Flask 应用程序的主文件:

$ cat app.py
from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World! (from a Docker container)'

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

我们还需要一个需求文件,其中指定了要与 pip 安装的 Flask 包的版本:

$ cat requirements.txt
Flask==1.0.2

在 macOS 笔记本电脑上直接使用 Python 运行 app.py 文件,而不先安装要求,会导致错误:

$ python app.py
Traceback (most recent call last):
  File "app.py", line 1, in <module>
    from flask import Flask
ImportError: No module named flask

要解决这个问题的一个明显方法是在本地机器上使用 pip 安装需求。这将使一切都与您本地运行的操作系统具体相关。如果应用程序需要部署到运行不同操作系统的服务器上,该怎么办?众所周知的“在我的机器上能运行”的问题可能会出现,即在 macOS 笔记本电脑上一切运行良好,但由于操作系统特定版本的 Python 库,一切在运行其他操作系统(如 Ubuntu 或 Red Hat Linux)的暂存或生产服务器上却突然崩溃。

Docker 为这个难题提供了一个优雅的解决方案。我们仍然可以在本地进行开发,使用我们喜爱的编辑器和工具链,但是我们将应用程序的依赖项打包到一个可移植的 Docker 容器中。

这是描述将要构建的 Docker 镜像的 Dockerfile:

$ cat Dockerfile
FROM python:3.7.3-alpine

ENV APP_HOME /app
WORKDIR $APP_HOME

COPY requirements.txt .

RUN pip install -r requirements.txt

ENTRYPOINT [ "python" ]
CMD [ "app.py" ]

关于这个 Dockerfile 的一些说明:

  • 使用基于 Alpine 发行版的 Python 3.7.3 预构建 Docker 镜像,这样可以生成更轻量的 Docker 镜像;这个 Docker 镜像已经包含了诸如 pythonpip 等可执行文件。

  • 使用 pip 安装所需的包。

  • 指定一个 ENTRYPOINT 和一个 CMD。两者的区别在于,当 Docker 容器运行从这个 Dockerfile 构建的镜像时,它运行的程序是 ENTRYPOINT,后面跟着在 CMD 中指定的任何参数;在本例中,它将运行 python app.py

注意

如果您没有在您的 Dockerfile 中指定 ENTRYPOINT,则将使用以下默认值:/bin/sh -c

要为这个应用程序创建 Docker 镜像,运行 docker build

$ docker build -t hello-world-docker .

要验证 Docker 镜像是否已保存在本地,请运行docker images,然后输入镜像名称:

$ docker images hello-world-docker
REPOSITORY               TAG       IMAGE ID            CREATED          SIZE
hello-world-docker       latest    dbd84c229002        2 minutes ago    97.7MB

要将 Docker 镜像作为 Docker 容器运行,请使用docker run命令:

$ docker run --rm -d -v `pwd`:/app -p 5000:5000 hello-world-docker
c879295baa26d9dff1473460bab810cbf6071c53183890232971d1b473910602

关于docker run命令参数的几点说明:

  • --rm参数告诉 Docker 服务器在停止运行后删除此容器。这对于防止旧容器堵塞本地文件系统非常有用。

  • -d参数告诉 Docker 服务器在后台运行此容器。

  • -v参数指定当前目录(pwd)映射到 Docker 容器内的/app目录。这对于我们想要实现的本地开发工作流至关重要,因为它使我们能够在本地编辑应用程序文件,并通过运行在容器内部的 Flask 开发服务器进行自动重新加载。

  • -p 5000:5000参数将本地的第一个端口(5000)映射到容器内部的第二个端口(5000)。

要列出运行中的容器,请运行docker ps并注意容器 ID,因为它将在其他docker命令中使用:

$ docker ps
CONTAINER ID  IMAGE                      COMMAND         CREATED
c879295baa26  hello-world-docker:latest  "python app.py" 4 seconds ago
STATUS          PORTS                    NAMES
Up 2 seconds    0.0.0.0:5000->5000/tcp   flamboyant_germain

要检查特定容器的日志,请运行docker logs并指定容器名称或 ID:

$ docker logs c879295baa26
 * Serving Flask app "app" (lazy loading)
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 647-161-014

使用curl命中端点 URL 以验证应用程序是否正常工作。由于使用-p命令行标志将运行在 Docker 容器内部的应用程序的端口 5000 映射到本地机器的端口 5000,因此可以使用本地 IP 地址 127.0.0.1 作为应用程序的端点地址。

$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container)%

现在用你喜欢的编辑器修改app.py中的代码。将问候文本更改为Hello, World! (from a Docker container with modified code)。保存app.py并注意 Docker 容器日志中类似以下行:

 * Detected change in '/app/app.py', reloading
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 647-161-014

这表明运行在容器内部的 Flask 开发服务器已检测到app.py中的更改,并重新加载了应用程序。

使用curl命中应用程序端点将显示修改后的问候语:

$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container with modified code)%

要停止运行中的容器,请运行docker stopdocker kill,并指定容器 ID 作为参数:

$ docker stop c879295baa26
c879295baa26

要从本地磁盘中删除 Docker 镜像,请运行docker rmi

$ docker rmi hello-world-docker
Untagged: hello-world-docker:latest
Deleted:sha256:dbd84c229002950550334224b4b42aba948ce450320a4d8388fa253348126402
Deleted:sha256:6a8f3db7658520a1654cc6abee8eafb463a72ddc3aa25f35ac0c5b1eccdf75cd
Deleted:sha256:aee7c3304ef6ff620956850e0b6e6b1a5a5828b58334c1b82b1a1c21afa8651f
Deleted:sha256:dca8a433d31fa06ab72af63ae23952ff27b702186de8cbea51cdea579f9221e8
Deleted:sha256:cb9d58c66b63059f39d2e70f05916fe466e5c99af919b425aa602091c943d424
Deleted:sha256:f0534bdca48bfded3c772c67489f139d1cab72d44a19c5972ed2cd09151564c1

此输出显示组成 Docker 镜像的不同文件系统层。当删除镜像时,这些层也将被删除。有关 Docker 如何使用文件系统层构建其镜像的详细信息,请参阅Docker 存储驱动程序文档。

发布 Docker 镜像到 Docker 注册表

一旦在本地构建了 Docker 镜像,就可以将其发布到所谓的 Docker 注册表中。有几个公共注册表可供选择,本示例将使用 Docker Hub。这些注册表的目的是允许个人和组织共享可在不同机器和操作系统上重复使用的预构建 Docker 镜像。

首先,在Docker Hub上创建一个免费帐户,然后创建一个仓库,可以是公共的或私有的。我们在griggheo的 Docker Hub 帐户下创建了一个名为flask-hello-world的私有仓库。

然后,在命令行上运行docker login并指定您帐户的电子邮件和密码。此时,您可以通过docker客户端与 Docker Hub 进行交互。

注意

在向 Docker Hub 发布您本地构建的 Docker 镜像之前,我们要指出的最佳实践是使用唯一标签对镜像进行标记。如果不明确打标签,默认情况下镜像将标记为latest。发布不带标签的新镜像版本将把latest标签移至最新镜像版本。在使用 Docker 镜像时,如果不指定所需的确切标签,将获取latest版本的镜像,其中可能包含可能破坏依赖关系的修改和更新。始终应用最小惊讶原则:在推送镜像到注册表时和在 Dockerfile 中引用镜像时都应使用标签。话虽如此,您也可以将所需版本的镜像标记为latest,以便对最新和最伟大的人感兴趣的人使用而不需要指定标签。

在上一节中构建 Docker 镜像时,它会自动标记为latest,并且仓库被设置为镜像的名称,表示该镜像是本地的:

$ docker images hello-world-docker
REPOSITORY               TAG       IMAGE ID            CREATED          SIZE
hello-world-docker       latest    dbd84c229002        2 minutes ago    97.7MB

要为 Docker 镜像打标签,请运行docker tag

$ docker tag hello-world-docker hello-world-docker:v1

现在你可以看到hello-world-docker镜像的两个标签:

$ docker images hello-world-docker
REPOSITORY               TAG      IMAGE ID           CREATED          SIZE
hello-world-docker       latest   dbd84c229002       2 minutes ago    97.7MB
hello-world-docker       v1       89bd38cb198f       42 seconds ago   97.7MB

在将hello-world-docker镜像发布到 Docker Hub 之前,您还需要使用包含您的用户名或组织名称的 Docker Hub 仓库名称对其进行标记。在我们的情况下,这个仓库是griggheo/hello-world-docker

$ docker tag hello-world-docker:latest griggheo/hello-world-docker:latest
$ docker tag hello-world-docker:v1 griggheo/hello-world-docker:v1

使用docker push将两个镜像标签发布到 Docker Hub:

$ docker push griggheo/hello-world-docker:latest
$ docker push griggheo/hello-world-docker:v1

如果您跟随进行,现在应该能够看到您的 Docker 镜像已发布到您在帐户下创建的 Docker Hub 仓库,并带有两个标签。

在不同主机上使用相同镜像运行 Docker 容器

现在 Docker 镜像已发布到 Docker Hub,我们可以展示 Docker 的可移植性,通过在不同主机上基于已发布镜像运行容器来展示。这里考虑的场景是与一位没有 macOS 但喜欢在运行 Fedora 的笔记本上开发的同事合作。该场景包括检出应用程序代码并进行修改。

在 AWS 上启动基于 Linux 2 AMI 的 EC2 实例,该 AMI 基于 RedHat/CentOS/Fedora,并安装 Docker 引擎。将 EC2 Linux AMI 上的默认用户ec2-user添加到docker组,以便可以运行docker客户端命令。

$ sudo yum update -y
$ sudo amazon-linux-extras install docker
$ sudo service docker start
$ sudo usermod -a -G docker ec2-user

确保在远程 EC2 实例上检出应用程序代码。在这种情况下,代码仅包括app.py文件。

接下来,运行基于发布到 Docker Hub 的镜像的 Docker 容器。唯一的区别是,作为docker run命令参数使用的镜像是griggheo/hello-world-docker:v1,而不仅仅是hello-world-docker

运行docker login,然后:

$ docker run --rm -d -v `pwd`:/app -p 5000:5000 griggheo/hello-world-docker:v1

Unable to find image 'griggheo/hello-world-docker:v1' locally
v1: Pulling from griggheo/hello-world-docker
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
d997915c3f9c: Pull complete
f1fd8d3cc5a4: Pull complete
10b64b1c3b21: Pull complete
Digest: sha256:af8b74f27a0506a0c4a30255f7ff563c9bf858735baa610fda2a2f638ccfe36d
Status: Downloaded newer image for griggheo/hello-world-docker:v1
9d67dc321ffb49e5e73a455bd80c55c5f09febc4f2d57112303d2b27c4c6da6a

请注意,EC2 实例上的 Docker 引擎会意识到本地没有 Docker 镜像,因此会从 Docker Hub 下载镜像,然后基于新下载的镜像运行容器。

在此时,通过向与 EC2 实例关联的安全组添加规则来授予对端口 5000 的访问权限。访问 http://54.187.189.51:5000¹(其中 54.187.189.51 是 EC2 实例的外部 IP)并查看问候语Hello, World! (from a Docker container with modified code)

在远程 EC2 实例上修改应用程序代码时,运行在 Docker 容器内部的 Flask 服务器将自动重新加载修改后的代码。将问候语更改为Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance),并注意 Flask 服务器通过检查 Docker 容器的日志重新加载了应用程序:

[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker ps
CONTAINER ID  IMAGE                           COMMAND         CREATED
9d67dc321ffb  griggheo/hello-world-docker:v1  "python app.py" 3 minutes ago
STATUS        PORTS                    NAMES
Up 3 minutes  0.0.0.0:5000->5000/tcp   heuristic_roentgen

[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker logs 9d67dc321ffb
 * Serving Flask app "app" (lazy loading)
 * Debug mode: on
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 306-476-204
72.203.107.13 - - [19/Aug/2019 04:43:34] "GET / HTTP/1.1" 200 -
72.203.107.13 - - [19/Aug/2019 04:43:35] "GET /favicon.ico HTTP/1.1" 404 -
 * Detected change in '/app/app.py', reloading
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 306-476-204

点击 http://54.187.189.51:5000²现在显示新的问候语Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance)

值得注意的是,为了使我们的应用程序运行,我们没有必要安装任何与 Python 或 Flask 相关的东西。通过简单地在容器内运行我们的应用程序,我们能够利用 Docker 的可移植性。Docker 选择“容器”作为技术的名字并不是没有原因的,其中一个灵感来源于运输容器如何革命了全球运输行业。

提示

阅读“Production-ready Docker images”一书,由 Itamar Turner-Trauring 编写,涵盖了关于 Python 应用程序 Docker 容器打包的大量文章。

使用 Docker Compose 运行多个 Docker 容器

在本节中,我们将使用“Flask By Example”教程,该教程描述了如何构建一个 Flask 应用程序,根据给定 URL 的文本计算单词频率对。

首先克隆Flask By Example GitHub 存储库

$ git clone https://github.com/realpython/flask-by-example.git

我们将使用compose来运行代表示例应用程序不同部分的多个 Docker 容器。使用 Compose,您可以使用 YAML 文件定义和配置组成应用程序的服务,然后使用docker-compose命令行实用程序来创建、启动和停止这些将作为 Docker 容器运行的服务。

示例应用程序的第一个依赖项是 PostgreSQL,在教程第二部分中有描述。

这是如何在docker-compose.yaml文件中运行 PostgreSQL Docker 容器的方法:

$ cat docker-compose.yaml
version: "3"
services:
  db:
    image: "postgres:11"
    container_name: "postgres"
    ports:
      - "5432:5432"
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:

关于这个文件的几个注意事项:

  • 定义一个名为db的服务,基于在 Docker Hub 上发布的postgres:11镜像。

  • 将本地端口 5432 映射到容器端口 5432。

  • 为 PostgreSQL 存储其数据的目录(/var/lib/postgresql/data)指定一个 Docker 卷。这样做是为了确保 PostgreSQL 中存储的数据在容器重新启动后仍然存在。

docker-compose工具不是 Docker 引擎的一部分,因此需要单独安装。请参阅官方文档以获取在各种操作系统上安装的说明。

要启动docker-compose.yaml中定义的db服务,请运行docker-compose up -d db命令,该命令将在后台启动db服务的 Docker 容器(使用了-d标志)。

$ docker-compose up -d db
Creating postgres ... done

使用docker-compose logs db命令检查db服务的日志:

$ docker-compose logs db
Creating volume "flask-by-example_dbdata" with default driver
Pulling db (postgres:11)...
11: Pulling from library/postgres
Creating postgres ... done
Attaching to postgres
postgres | PostgreSQL init process complete; ready for start up.
postgres |
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG:  listening on IPv4 address "0.0.0.0", port 5432
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG:  listening on IPv6 address "::", port 5432
postgres | 2019-07-11 21:50:20.993 UTC [1]
LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres | 2019-07-11 21:50:21.009 UTC [51]
LOG:  database system was shut down at 2019-07-11 21:50:20 UTC
postgres | 2019-07-11 21:50:21.014 UTC [1]
LOG:  database system is ready to accept connections

运行docker ps命令可以显示运行 PostgreSQL 数据库的容器:

$ docker ps
dCONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
83b54ab10099 postgres:11 "docker-entrypoint.s…"  3 minutes ago  Up 3 minutes
        0.0.0.0:5432->5432/tcp   postgres

运行docker volume ls命令显示已为 PostgreSQL /var/lib/postgresql/data目录挂载的dbdata Docker 卷:

$ docker volume ls | grep dbdata
local               flask-by-example_dbdata

要连接到运行在与db服务相关联的 Docker 容器中的 PostgreSQL 数据库,运行命令docker-compose exec db并传递psql -U postgres命令行:

$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

postgres=#

参照“Flask by Example, Part 2”,创建一个名为wordcount的数据库:

$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

postgres=# create database wordcount;
CREATE DATABASE

postgres=# \l
                            List of databases
     Name  |  Owner | Encoding |  Collate |   Ctype  |   Access privileges
-----------+--------+----------+----------+----------+--------------------
 postgres  | postgres | UTF8   | en_US.utf8 | en_US.utf8 |
 template0 | postgres | UTF8   | en_US.utf8 | en_US.utf8 | =c/postgres +
           |          |        |            |            |postgres=CTc/postgres
 template1 | postgres | UTF8   | en_US.utf8 | en_US.utf8 | =c/postgres +
           |          |        |            |            |postgres=CTc/postgres
 wordcount| postgres | UTF8| en_US.utf8 | en_US.utf8 |
(4 rows)
postgres=# \q

连接到wordcount数据库并创建一个名为wordcount_dbadmin的角色,该角色将被 Flask 应用程序使用:

$ docker-compose exec db psql -U postgres wordcount
wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
postgres=# \q

下一步是为 Flask 应用程序创建一个 Dockerfile,安装所有的先决条件。

requirements.txt文件进行以下修改:

  • psycopg2包的版本从2.6.1修改为2.7以支持 PostgreSQL 11。

  • redis包的版本从2.10.5修改为3.2.1以提供更好的 Python 3.7 支持。

  • rq包的版本从0.5.6修改为1.0以提供更好的 Python 3.7 支持。

以下是 Dockerfile 的内容:

$ cat Dockerfile
FROM python:3.7.3-alpine

ENV APP_HOME /app
WORKDIR $APP_HOME

COPY requirements.txt .

RUN \
 apk add --no-cache postgresql-libs && \
 apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev && \
 python3 -m pip install -r requirements.txt --no-cache-dir && \
 apk --purge del .build-deps

COPY . .

ENTRYPOINT [ "python" ]
CMD ["app.py"]
注意

这个 Dockerfile 与第一个hello-world-docker示例中使用的版本有一个重要的区别。这里将当前目录的内容(包括应用程序文件)复制到 Docker 镜像中。这样做是为了展示与之前开发工作流不同的场景。在这种情况下,我们更关注以最便携的方式运行应用程序,例如在暂存或生产环境中,我们不希望像在开发场景中通过挂载卷来修改应用程序文件。在开发目的上通常可以使用docker-compose与本地挂载卷,但本节重点是讨论 Docker 容器在不同环境(如开发、暂存和生产)中的可移植性。

运行docker build -t flask-by-example:v1 .来构建一个本地 Docker 镜像。由于该命令的输出内容相当长,因此这里不显示。

“Flask By Example”教程的下一步是运行 Flask 迁移。

docker-compose.yaml文件中,定义一个名为migrations的新服务,并指定其imagecommandenvironment变量以及它依赖于db服务正在运行的事实:

$ cat docker-compose.yaml
version: "3"
services:
  migrations:
    image: "flask-by-example:v1"
    command: "manage.py db upgrade"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
    depends_on:
      - db
  db:
    image: "postgres:11"
    container_name: "postgres"
    ports:
      - "5432:5432"
    volumes:
      - dbdata:/var/lib/postgresql/data
volumes:
  dbdata:

DATABASE_URL变量使用db作为 PostgreSQL 数据库主机的名称。这是因为在docker-compose.yaml文件中将db定义为服务名称,并且docker-compose知道如何通过创建一个覆盖网络使所有在docker-compose.yaml文件中定义的服务能够通过其名称互相交互。有关更多详细信息,请参阅docker-compose 网络参考

DATABASE_URL变量定义引用了另一个名为DBPASS的变量,而不是直接硬编码wordcount_dbadmin用户的密码。docker-compose.yaml文件通常提交到源代码控制,最佳实践是不要将诸如数据库凭据之类的机密信息提交到 GitHub。相反,使用诸如sops之类的加密工具管理密钥文件。

这里是使用sops和 PGP 加密创建加密文件的示例。

首先,在 macOS 上通过brew install gpg安装gpg,然后使用空密码生成新的 PGP 密钥:

$ gpg --generate-key
pub   rsa2048 2019-07-12 [SC] [expires: 2021-07-11]
      E14104A0890994B9AC9C9F6782C1FF5E679EFF32
uid                      pydevops <my.email@gmail.com>
sub   rsa2048 2019-07-12 [E] [expires: 2021-07-11]

接下来,从其发布页面下载sops

要创建一个名为environment.secrets的新加密文件,例如,运行带有-pgp标志的sops并提供上述生成的密钥的指纹:

$ sops --pgp BBDE7E57E00B98B3F4FBEAF21A1EEF4263996BD0 environment.secrets

这将打开默认编辑器,并允许输入纯文本密钥。在此示例中,environment.secrets文件的内容是:

export DBPASS=MYPASS

保存environment.secrets文件后,请检查文件以确保其已加密,这样可以安全地添加到源代码控制中:

$ cat environment.secrets
{
	"data": "ENC[AES256_GCM,data:qlQ5zc7e8KgGmu5goC9WmE7PP8gueBoSsmM=,
  iv:xG8BHcRfdfLpH9nUlTijBsYrh4TuSdvDqp5F+2Hqw4I=,
  tag:0OIVAm9O/UYGljGCzZerTQ==,type:str]",
	"sops": {
		"kms": null,
		"gcp_kms": null,
		"lastmodified": "2019-07-12T05:03:45Z",
		"mac": "ENC[AES256_GCM,data:wo+zPVbPbAJt9Nl23nYuWs55f68/DZJWj3pc0
    l8T2d/SbuRF6YCuOXHSHIKs1ZBpSlsjmIrPyYTqI+M4Wf7it7fnNS8b7FnclwmxJjptBWgL
    T/A1GzIKT1Vrgw9QgJ+prq+Qcrk5dPzhsOTxOoOhGRPsyN8KjkS4sGuXM=,iv:0VvSMgjF6
    ypcK+1J54fonRoI7c5whmcu3iNV8xLH02k=,
    tag:YaI7DXvvllvpJ3Talzl8lg==,
    type:str]",
		"pgp": [
			{
				"created_at": "2019-07-12T05:02:24Z",
				"enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMA+3cyc
        g5b/Hu0OvU5ONr/F0htZM2MZQSXpxoCiO\nWGB5Czc8FTSlRSwu8/cOx0Ch1FwH+IdLwwL+jd
        oXVe55myuu/3OKUy7H1w/W2R\nPI99Biw1m5u3ir3+9tLXmRpLWkz7+nX7FThl9QnOS25
        NRUSSxS7hNaZMcYjpXW+w\nM3XeaGStgbJ9OgIp4A8YGigZQVZZFl3fAG3bm2c+TNJcAbl
        zDpc40fxlR+7LroJI\njuidzyOEe49k0pq3tzqCnph5wPr3HZ1JeQmsIquf//9D509S5xH
        Sa9lkz3Y7V4KC\nefzBiS8pivm55T0s+zPBPB/GWUVlqGaxRhv1TAU=\n=WA4+
        \n-----END PGP MESSAGE-----\n",
				"fp": "E14104A0890994B9AC9C9F6782C1FF5E679EFF32"
			}
		],
		"unencrypted_suffix": "_unencrypted",
		"version": "3.0.5"
	}
}%

要解密文件,请运行:

$ sops -d environment.secrets
export DBPASS=MYPASS
注意

在 Macintosh 上使用sopsgpg交互存在问题。在能够使用sops解密文件之前,您需要运行以下命令:

$ GPG_TTY=$(tty)
$ export GPG_TTY

这里的目标是运行先前在docker-compose.yaml文件中定义的migrations服务。为了将sops密钥管理方法集成到docker-compose中,使用sops -d解密environments.secrets文件,将其内容源化到当前 shell 中,然后使用一个命令调用docker-compose up -d migrations,该命令不会将密钥暴露给 shell 历史记录:

$ source <(sops -d environment.secrets); docker-compose up -d migrations
postgres is up-to-date
Recreating flask-by-example_migrations_1 ... done

通过检查数据库并验证是否创建了两个表alembic_versionresults来验证迁移是否成功运行:

$ docker-compose exec db psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

wordcount=# \dt
                  List of relations
 Schema |      Name       | Type  |       Owner
--------+-----------------+-------+-------------------
 public | alembic_version | table | wordcount_dbadmin
 public | results         | table | wordcount_dbadmin
(2 rows)

wordcount=# \q

第四部分 在“Flask 实例”教程中是部署一个基于 Python RQ 的 Python 工作进程,该进程与 Redis 实例通信。

首先,需要运行 Redis。将其作为名为redis的服务添加到docker_compose.yaml文件中,并确保其内部端口 6379 映射到本地操作系统的端口 6379:

  redis:
    image: "redis:alpine"
    ports:
      - "6379:6379"

通过将其作为参数指定给 docker-compose up -d 单独启动 redis 服务:

$ docker-compose up -d redis
Starting flask-by-example_redis_1 ... done

运行 docker ps 查看基于 redis:alpine 镜像运行的新 Docker 容器:

$ docker ps
CONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
a1555cc372d6   redis:alpine "docker-entrypoint.s…" 3 seconds ago  Up 1 second
0.0.0.0:6379->6379/tcp   flask-by-example_redis_1
83b54ab10099   postgres:11  "docker-entrypoint.s…" 22 hours ago   Up 16 hours
0.0.0.0:5432->5432/tcp   postgres

使用 docker-compose logs 命令检查 redis 服务的日志:

$ docker-compose logs redis
Attaching to flask-by-example_redis_1
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections

下一步是在 docker-compose.yaml 中为 Python RQ 工作进程创建一个名为 worker 的服务:

  worker:
    image: "flask-by-example:v1"
    command: "worker.py"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis

运行工作服务,就像redis服务一样,使用docker-compose up -d

$ docker-compose up -d worker
flask-by-example_redis_1 is up-to-date
Starting flask-by-example_worker_1 ... done

运行 docker ps 将显示工作容器:

$ docker ps
CONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
72327ab33073  flask-by-example "python worker.py"     8 minutes ago
Up 14 seconds                             flask-by-example_worker_1
b11b03a5bcc3  redis:alpine     "docker-entrypoint.s…" 15 minutes ago
Up About a minute  0.0.0.0:6379->6379/tc  flask-by-example_redis_1
83b54ab10099  postgres:11      "docker-entrypoint.s…"  23 hours ago
Up 17 hours        0.0.0.0:5432->5432/tcp postgres

使用docker-compose logs查看工作容器日志:

$ docker-compose logs worker
Attaching to flask-by-example_worker_1
20:46:34 RQ worker 'rq:worker:a66ca38275a14cac86c9b353e946a72e' started,
version 1.0
20:46:34 *** Listening on default...
20:46:34 Cleaning registries for queue: default

现在在自己的容器中启动主 Flask 应用程序。在 docker-compose.yaml 中创建一个名为 app 的新服务:

  app:
    image: "flask-by-example:v1"
    command: "manage.py runserver --host=0.0.0.0"
    ports:
      - "5000:5000"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis

将应用程序容器中的端口 5000(Flask 应用程序的默认端口)映射到本地机器的端口 5000。在应用程序容器中传递命令 manage.py runserver --host=0.0.0.0,以确保 Flask 应用程序在容器内正确地暴露端口 5000。

使用 docker compose up -d 启动 app 服务,同时在包含 DBPASS 的加密文件上运行 sops -d,然后在调用 docker-compose 之前源化解密文件:

source <(sops -d environment.secrets); docker-compose up -d app
postgres is up-to-date
Recreating flask-by-example_app_1 ... done

注意到新的 Docker 容器正在运行应用程序,列表由 docker ps 返回:

$ docker ps
CONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
d99168a152f1   flask-by-example "python app.py"  3 seconds ago
Up 2 seconds    0.0.0.0:5000->5000/tcp   flask-by-example_app_1
72327ab33073   flask-by-example "python worker.py" 16 minutes ago
Up 7 minutes                             flask-by-example_worker_1
b11b03a5bcc3   redis:alpine     "docker-entrypoint.s…" 23 minutes ago
Up 9 minutes    0.0.0.0:6379->6379/tcp   flask-by-example_redis_1
83b54ab10099   postgres:11      "docker-entrypoint.s…"  23 hours ago
Up 17 hours     0.0.0.0:5432->5432/tcp   postgres

使用 docker-compose logs 检查应用程序容器的日志:

$ docker-compose logs app
Attaching to flask-by-example_app_1
app_1         |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

运行 docker-compose logs 而不带其他参数允许我们检查 docker-compose.yaml 文件中定义的所有服务的日志:

$ docker-compose logs
Attaching to flask-by-example_app_1,
flask-by-example_worker_1,
flask-by-example_migrations_1,
flask-by-example_redis_1,
postgres
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections
app_1         |  * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
postgres      | 2019-07-12 22:15:19.193 UTC [1]
LOG:  listening on IPv4 address "0.0.0.0", port 5432
postgres      | 2019-07-12 22:15:19.194 UTC [1]
LOG:  listening on IPv6 address "::", port 5432
postgres      | 2019-07-12 22:15:19.199 UTC [1]
LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres      | 2019-07-12 22:15:19.214 UTC [22]
LOG:  database system was shut down at 2019-07-12 22:15:09 UTC
postgres      | 2019-07-12 22:15:19.225 UTC [1]
LOG:  database system is ready to accept connections
migrations_1  | INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
migrations_1  | INFO [alembic.runtime.migration] Will assume transactional DDL.
worker_1      | 22:15:20
RQ worker 'rq:worker:2edb6a54f30a4aae8a8ca2f4a9850303' started, version 1.0
worker_1      | 22:15:20 *** Listening on default...
worker_1      | 22:15:20 Cleaning registries for queue: default

最后一步是测试应用程序。访问 http://127.0.0.1:5000 并在 URL 字段中输入 python.org。此时,应用程序向工作进程发送一个作业,要求其对 python.org 的主页执行函数 count_and_save_words。应用程序定期轮询作业以获取结果,完成后在主页上显示单词频率。

为了使 docker-compose.yaml 文件更具可移植性,将 flask-by-example Docker 镜像推送到 Docker Hub,并在 appworker 服务的容器部分引用 Docker Hub 镜像。

使用 Docker Hub 用户名前缀为现有的本地 Docker 镜像 flask-by-example:v1 打标签,然后将新标记的镜像推送到 Docker Hub:

$ docker tag flask-by-example:v1 griggheo/flask-by-example:v1
$ docker push griggheo/flask-by-example:v1

修改 docker-compose.yaml 以引用新的 Docker Hub 镜像。以下是 docker-compose.yaml 的最终版本:

$ cat docker-compose.yaml
version: "3"
services:
  app:
    image: "griggheo/flask-by-example:v1"
    command: "manage.py runserver --host=0.0.0.0"
    ports:
      - "5000:5000"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis
  worker:
    image: "griggheo/flask-by-example:v1"
    command: "worker.py"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis
  migrations:
    image: "griggheo/flask-by-example:v1"
    command: "manage.py db upgrade"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
    depends_on:
      - db
  db:
    image: "postgres:11"
    container_name: "postgres"
    ports:
      - "5432:5432"
    volumes:
      - dbdata:/var/lib/postgresql/data
  redis:
    image: "redis:alpine"
    ports:
      - "6379:6379"
volumes:
  dbdata:

要重新启动本地 Docker 容器,请运行 docker-compose down,然后跟着 docker-compose up -d

$ docker-compose down
Stopping flask-by-example_worker_1 ... done
Stopping flask-by-example_app_1    ... done
Stopping flask-by-example_redis_1  ... done
Stopping postgres                  ... done
Removing flask-by-example_worker_1     ... done
Removing flask-by-example_app_1        ... done
Removing flask-by-example_migrations_1 ... done
Removing flask-by-example_redis_1      ... done
Removing postgres                      ... done
Removing network flask-by-example_default

$ source <(sops -d environment.secrets); docker-compose up -d
Creating network "flask-by-example_default" with the default driver
Creating flask-by-example_redis_1      ... done
Creating postgres                 ... done
Creating flask-by-example_migrations_1 ... done
Creating flask-by-example_worker_1     ... done
Creating flask-by-example_app_1        ... done

注意使用 docker-compose 轻松启动和关闭一组 Docker 容器。

提示

即使您只想运行单个 Docker 容器,将其包含在 docker-compose.yaml 文件中并使用 docker-compose up -d 命令启动它仍然是个好主意。当您想要添加第二个容器时,这将使您的生活更加轻松,并且还将作为基础设施即代码的一个小例子,docker-compose.yaml 文件反映了您的应用程序的本地 Docker 设置状态。

将 docker-compose 服务迁移到新主机和操作系统

现在我们将展示如何将前一节的 docker-compose 设置迁移到运行 Ubuntu 18.04 的服务器。

启动运行 Ubuntu 18.04 的 Amazon EC2 实例并安装 docker-enginedocker-compose

$ sudo apt-get update
$ sudo apt-get remove docker docker-engine docker.io containerd runc
$ sudo apt-get install \
  apt-transport-https \
  ca-certificates \
  curl \
  gnupg-agent \
  software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository \
  "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) \
  stable"
$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io
$ sudo usermod -a -G docker ubuntu

# download docker-compose
$ sudo curl -L \
"https://github.com/docker/compose/releases/download/1.24.1/docker-compose-\
$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose

docker-compose.yaml 文件复制到远程 EC2 实例并首先启动 db 服务,以便可以创建应用程序使用的数据库:

$ docker-compose up -d db
Starting postgres ...
Starting postgres ... done

$ docker ps
CONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
49fe88efdb45 postgres:11 "docker-entrypoint.s…" 29 seconds ago
  Up 3 seconds        0.0.0.0:5432->5432/tcp   postgres

使用 docker exec 在正在运行的 Docker 容器中运行 psql -U postgres 命令访问 PostgreSQL 数据库。在 PostgreSQL 提示符下,创建 wordcount 数据库和 wordcount_dbadmin 角色:

$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

postgres=# create database wordcount;
CREATE DATABASE
postgres=# \q

$ docker exec -it 49fe88efdb45 psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
wordcount=# \q

在启动在 docker-compose.yaml 中定义的服务的容器之前,有两件事是必需的:

  1. 运行 docker login 以能够拉取之前推送到 Docker Hub 的 Docker 镜像:

    $ docker login
    
  2. 在当前 Shell 中设置DBPASS环境变量的正确值。在本地 macOS 设置中描述的sops方法可用,但是在本示例中,直接在 Shell 中设置:

    $ export DOCKER_PASS=MYPASS
    

现在通过运行 docker-compose up -d 启动应用程序所需的所有服务:

$ docker-compose up -d
Pulling worker (griggheo/flask-by-example:v1)...
v1: Pulling from griggheo/flask-by-example
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
9be5e85cacbb: Pull complete
bd62f980b08d: Pull complete
9a89f908ad0a: Pull complete
d787e00a01aa: Pull complete
Digest: sha256:4fc554da6157b394b4a012943b649ec66c999b2acccb839562e89e34b7180e3e
Status: Downloaded newer image for griggheo/flask-by-example:v1
Creating fbe_redis_1      ... done
Creating postgres    ... done
Creating fbe_migrations_1 ... done
Creating fbe_app_1        ... done
Creating fbe_worker_1     ... done

$ docker ps
CONTAINER ID   IMAGE   COMMAND    CREATED   STATUS   PORTS   NAMES
f65fe9631d44  griggheo/flask-by-example:v1 "python3 manage.py r…" 5 seconds ago
Up 2 seconds        0.0.0.0:5000->5000/tcp   fbe_app_1
71fc0b24bce3  griggheo/flask-by-example:v1 "python3 worker.py"    5 seconds ago
Up 2 seconds                                 fbe_worker_1
a66d75a20a2d  redis:alpine     "docker-entrypoint.s…"   7 seconds ago
Up 5 seconds        0.0.0.0:6379->6379/tcp   fbe_redis_1
56ff97067637  postgres:11      "docker-entrypoint.s…"   7 seconds ago
Up 5 seconds        0.0.0.0:5432->5432/tcp   postgres

此时,在允许 AWS 安全组中与我们的 Ubuntu EC2 实例关联的端口 5000 的访问后,您可以在该实例的外部 IP 上的 5000 端口访问并使用该应用程序。

再次强调一下 Docker 简化应用部署的重要性。Docker 容器和镜像的可移植性意味着您可以在任何安装了 Docker 引擎的操作系统上运行您的应用程序。在这里展示的示例中,在 Ubuntu 服务器上不需要安装任何先决条件:不需要 Flask,不需要 PostgreSQL,也不需要 Redis。也不需要将应用程序代码从本地开发机器复制到 Ubuntu 服务器上。在 Ubuntu 服务器上唯一需要的文件是 docker-compose.yaml。然后,只需一条命令就可以启动应用程序的整套服务:

$ docker-compose up -d

提示

警惕从公共 Docker 仓库下载和使用 Docker 镜像,因为其中许多镜像存在严重的安全漏洞,其中最严重的可以允许攻击者突破 Docker 容器的隔离性并接管主机操作系统。一个良好的实践是从一个受信任的、预构建的镜像开始,或者从头开始构建你自己的镜像。随时关注最新的安全补丁和软件更新,并在这些补丁或更新可用时重新构建你的镜像。另一个良好的实践是使用众多可用的 Docker 扫描工具之一(其中包括 ClairAnchoreFalco)扫描所有的 Docker 镜像。这样的扫描可以作为持续集成/持续部署流水线的一部分进行,通常在构建 Docker 镜像时执行。

尽管 docker-compose 可以轻松地运行多个容器化服务作为同一应用的一部分,但它只适用于单台机器,这在生产环境中的实用性受到限制。如果你不担心停机时间并愿意在单台机器上运行所有内容,那么只能认为使用 docker-compose 部署的应用程序是“生产就绪”的(尽管如此,格里格看到一些托管提供者使用 docker-compose 在生产环境中运行 Docker 化应用程序)。对于真正的“生产就绪”场景,你需要一个像 Kubernetes 这样的容器编排引擎,这将在下一章讨论。

练习

  • 熟悉 Dockerfile 参考

  • 熟悉 Docker Compose 配置参考

  • 创建一个 AWS KMS 密钥,并在 sops 中使用它,而不是本地的 PGP 密钥。这允许你将 AWS IAM 权限应用到密钥上,并将对密钥的访问限制为仅需要的开发人员。

  • 编写一个 shell 脚本,使用 docker execdocker-compose exec 来运行 PostgreSQL 命令,创建数据库和角色。

  • 尝试其他容器技术,例如 Podman

¹ 这是一个示例 URL 地址——你的 IP 地址将会不同。

² 你的 IP 地址将会不同。

第十二章:容器编排:Kubernetes

如果您正在尝试使用 Docker,或者在单台机器上运行一组 Docker 容器就是您的全部需求,那么 Docker 和 Docker Compose 就足够满足您的需求。然而,一旦您从数字1(单台机器)转移到数字2(多台机器),您就需要开始考虑如何在网络中编排这些容器。对于生产场景来说,这是必须的。您至少需要两台机器来实现容错和高可用性。

在我们这个云计算时代,扩展基础设施的推荐方法是“外部”(也称为“水平扩展”),通过向整个系统添加更多实例来实现,而不是通过“上升”(或“垂直扩展”)的旧方法,即向单个实例添加更多 CPU 和内存。一个 Docker 编排平台使用这些许多实例或节点作为原始资源(CPU、内存、网络),然后将这些资源分配给平台内运行的各个容器。这与我们在第十一章中提到的使用容器而不是经典虚拟机(VMs)的优势相关联:您可以更精细地为容器分配这些资源,因此您的基础设施投入将得到更好的利用率。

在为特定目的配置服务器并在每个实例上运行特定软件包(如 Web 服务器软件、缓存软件、数据库软件)的模式已经发生了转变,现在将它们配置为资源分配的通用单元,并在其上运行 Docker 容器,由 Docker 编排平台协调管理。您可能熟悉将服务器视为“宠物”与将它们视为“牲畜”的区别。在基础架构设计的早期阶段,每个服务器都有明确的功能(如邮件服务器),许多时候每个特定功能仅有一个服务器。这些服务器有命名方案(格里格还记得在点博时代使用行星系统的命名方案),花费大量时间进行管理和维护,因此被称为“宠物”。当 Puppet、Chef 和 Ansible 等配置管理工具出现时,通过在每台服务器上使用相同的安装程序,同时轻松配置多个相同类型的服务器(例如 Web 服务器农场)变得更加容易。这与云计算的兴起同时发生,前面提到的水平扩展的概念以及对容错和高可用性的更多关注,这些都是良好设计的系统基础设施的关键属性。这些服务器或云实例被视为牲畜,是可以丢弃的单位,它们在集合中有价值。

容器和无服务器计算时代也带来了另一个称谓,“昆虫”。确实,人们可以将容器的出现和消失视为一种短暂存在,就像是一个短暂的昆虫一样。函数即服务比 Docker 容器更短暂,其存在期间短暂而强烈,与其调用的持续时间相一致。

在容器的情况下,它们的短暂性使得在大规模上实现它们的编排和互操作性变得困难。这正是容器编排平台填补的需求。以前有多个 Docker 编排平台可供选择,如 Mesosphere 和 Docker Swarm,但如今我们可以安全地说 Kubernetes 已经赢得了这场比赛。本章剩余部分将简要概述 Kubernetes,并示例演示如何在 Kubernetes 中运行相同的应用程序,从docker-compose迁移到 Kubernetes。我们还将展示如何使用 Helm,一个 Kubernetes 包管理器,来安装名为 charts 的包,用于监控和仪表盘工具 Prometheus 和 Grafana,并如何自定义这些 charts。

Kubernetes 概念简介

理解构成 Kubernetes 集群的许多部分的最佳起点是官方 Kubernetes 文档

在高层次上,Kubernetes 集群由节点组成,可以等同于运行在云中的裸金属或虚拟机的服务器。节点运行 pod,即 Docker 容器的集合。Pod 是 Kubernetes 中的部署单位。一个 pod 中的所有容器共享同一网络,并且可以像在同一主机上运行一样互相引用。有许多情况下,运行多个容器在一个 pod 中是有利的。通常情况下,您的应用程序容器作为 pod 中的主容器运行,如果需要,您可以运行一个或多个所谓的“sidecar”容器,用于功能,例如日志记录或监视。一个特殊的 sidecar 容器案例是“init 容器”,它保证首先运行,并可用于诸如运行数据库迁移等的日常管理任务。我们将在本章后面进一步探讨这个问题。

应用程序通常会为了容错性和性能而使用多个 pod。负责启动和维护所需 pod 数量的 Kubernetes 对象称为部署(deployment)。为了让 pod 能够与其他 pod 通信,Kubernetes 提供了另一种对象,称为服务(service)。服务通过选择器(selectors)与部署绑定。服务也可以向外部客户端暴露,可以通过在每个 Kubernetes 节点上暴露一个 NodePort 作为静态端口,或者创建对应实际负载均衡器的 LoadBalancer 对象来实现,如果云提供商支持的话。

对于管理诸如密码、API 密钥和其他凭据等敏感信息,Kubernetes 提供了 Secret 对象。我们将看到一个示例,使用 Secret 存储数据库密码。

使用 Kompose 从 docker-compose.yaml 创建 Kubernetes Manifests

让我们再次查看讨论 第十一章 中的 Flask 示例应用程序的 docker_compose.yaml 文件:

$ cat docker-compose.yaml
version: "3"
services:
  app:
    image: "griggheo/flask-by-example:v1"
    command: "manage.py runserver --host=0.0.0.0"
    ports:
      - "5000:5000"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis
  worker:
    image: "griggheo/flask-by-example:v1"
    command: "worker.py"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
      REDISTOGO_URL: redis://redis:6379
    depends_on:
      - db
      - redis
  migrations:
    image: "griggheo/flask-by-example:v1"
    command: "manage.py db upgrade"
    environment:
      APP_SETTINGS: config.ProductionConfig
      DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
    depends_on:
      - db
  db:
    image: "postgres:11"
    container_name: "postgres"
    ports:
      - "5432:5432"
    volumes:
      - dbdata:/var/lib/postgresql/data
  redis:
    image: "redis:alpine"
    ports:
      - "6379:6379"
volumes:
  dbdata:

我们将使用一个名为 Kompose 的工具来将此 YAML 文件翻译成一组 Kubernetes Manifests。

要在 macOS 机器上获取新版本的 Kompose,首先从 Git 仓库 下载它,然后将其移动到 /usr/local/bin/kompose,并使其可执行。请注意,如果您依赖操作系统的软件包管理系统(例如 Ubuntu 系统上的 apt 或 Red Hat 系统上的 yum)来安装 Kompose,可能会获得一个较旧且不兼容这些说明的版本。

运行 kompose convert 命令从现有的 docker-compose.yaml 文件创建 Kubernetes manifest 文件:

$ kompose convert
INFO Kubernetes file "app-service.yaml" created
INFO Kubernetes file "db-service.yaml" created
INFO Kubernetes file "redis-service.yaml" created
INFO Kubernetes file "app-deployment.yaml" created
INFO Kubernetes file "db-deployment.yaml" created
INFO Kubernetes file "dbdata-persistentvolumeclaim.yaml" created
INFO Kubernetes file "migrations-deployment.yaml" created
INFO Kubernetes file "redis-deployment.yaml" created
INFO Kubernetes file "worker-deployment.yaml" created

此时,请删除 docker-compose.yaml 文件:

$ rm docker-compose.yaml

将 Kubernetes Manifests 部署到基于 minikube 的本地 Kubernetes 集群

我们的下一步是将 Kubernetes Manifests 部署到基于 minikube 的本地 Kubernetes 集群。

在 macOS 上运行 minikube 的先决条件是安装 VirtualBox。从其 下载页面 下载 macOS 版本的 VirtualBox 包,并安装它,然后将其移动到 /usr/local/bin/minikube 以使其可执行。请注意,此时写作本文时,minikube 安装了 Kubernetes 版本为 1.15. 如果要按照这些示例进行操作,请指定要使用 minikube 安装的 Kubernetes 版本:

$ minikube start --kubernetes-version v1.15.0
 minikube v1.2.0 on darwin (amd64)
 Creating virtualbox VM (CPUs=2, Memory=2048MB, Disk=20000MB) ...
 Configuring environment for Kubernetes v1.15.0 on Docker 18.09.6
 Downloading kubeadm v1.15.0
 Downloading kubelet v1.15.0
 Pulling images ...
 Launching Kubernetes ...
 Verifying: apiserver proxy etcd scheduler controller dns
 Done! kubectl is now configured to use "minikube"

与 Kubernetes 集群交互的主要命令是 kubectl

通过从 发布页面 下载并移动到 /usr/local/bin/kubectl 并使其可执行,来在 macOS 机器上安装 kubectl

在运行 kubectl 命令时,您将使用的一个主要概念是 context,它表示您希望与之交互的 Kubernetes 集群。minikube 的安装过程已经为我们创建了一个称为 minikube 的上下文。指定 kubectl 指向特定上下文的一种方法是使用以下命令:

$ kubectl config use-context minikube
Switched to context "minikube".

另一种更方便的方法是从 Git 仓库 安装 kubectx 实用程序,然后运行:

$ kubectx minikube
Switched to context "minikube".
提示

另一个在 Kubernetes 工作中很实用的客户端实用程序是 kube-ps1。对于基于 Zsh 的 macOS 设置,请将以下片段添加到文件 ~/.zshrc 中:

source "/usr/local/opt/kube-ps1/share/kube-ps1.sh"
PS1='$(kube_ps1)'$PS1

这些行将 shell 提示符更改为显示当前 Kubernetes 上下文和命名空间。当您开始与多个 Kubernetes 集群进行交互时,这将帮助您区分生产环境和暂存环境。

现在在本地 minikube 集群上运行 kubectl 命令。例如,kubectl get nodes 命令显示集群中的节点。在本例中,只有一个带有 master 角色的节点:

$ kubectl get nodes
NAME       STATUS   ROLES    AGE     VERSION
minikube   Ready    master   2m14s   v1.15.0

首先,从 dbdata-persistentvolumeclaim.yaml 文件创建持久卷声明(PVC)对象,该文件由 Kompose 创建,对应于在使用 docker-compose 运行时为 PostgreSQL 数据库容器分配的本地卷:

$ cat dbdata-persistentvolumeclaim.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  creationTimestamp: null
  labels:
    io.kompose.service: dbdata
  name: dbdata
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi
status: {}

要在 Kubernetes 中创建此对象,请使用 kubectl create 命令,并使用 -f 标志指定清单文件名:

$ kubectl create -f dbdata-persistentvolumeclaim.yaml
persistentvolumeclaim/dbdata created

使用 kubectl get pvc 命令列出所有 PVC,验证我们的 PVC 是否存在:

$ kubectl get pvc
NAME     STATUS   VOLUME                                     CAPACITY
ACCESS MODES   STORAGECLASS   AGE
dbdata   Bound    pvc-39914723-4455-439b-a0f5-82a5f7421475   100Mi
RWO            standard       1m

下一步是为 PostgreSQL 创建 Deployment 对象。使用之前由 Kompose 工具创建的清单文件 db-deployment.yaml

$ cat db-deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: db
  name: db
spec:
  replicas: 1
  strategy:
    type: Recreate
  template:
    metadata:
      creationTimestamp: null
      labels:
        io.kompose.service: db
    spec:
      containers:
      - image: postgres:11
        name: postgres
        ports:
        - containerPort: 5432
        resources: {}
        volumeMounts:
        - mountPath: /var/lib/postgresql/data
          name: dbdata
      restartPolicy: Always
      volumes:
      - name: dbdata
        persistentVolumeClaim:
          claimName: dbdata
status: {}

要创建部署,请使用 kubectl create -f 命令,并指向清单文件:

$ kubectl create -f db-deployment.yaml
deployment.extensions/db created

要验证是否已创建部署,请列出集群中的所有部署,并列出作为部署一部分创建的 pod:

$ kubectl get deployments
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
db       1/1     1            1           1m

$ kubectl get pods
NAME                  READY   STATUS    RESTARTS   AGE
db-67659d85bf-vrnw7   1/1     Running   0          1m

接下来,为示例 Flask 应用程序创建数据库。使用类似于 docker exec 的命令在运行中的 Docker 容器内运行 psql 命令。在 Kubernetes 集群中,命令的形式是 kubectl exec

$ kubectl exec -it db-67659d85bf-vrnw7 -- psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

postgres=# create database wordcount;
CREATE DATABASE
postgres=# \q

$ kubectl exec -it db-67659d85bf-vrnw7 -- psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
wordcount=# \q

下一步是创建与 db 部署相对应的 Service 对象,将部署暴露给运行在集群内的其他服务,例如 Redis worker 服务和主应用程序服务。这是 db 服务的清单文件:

$ cat db-service.yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: db
  name: db
spec:
  ports:
  - name: "5432"
    port: 5432
    targetPort: 5432
  selector:
    io.kompose.service: db
status:
  loadBalancer: {}

需要注意的一点是以下部分:

  labels:
    io.kompose.service: db

此部分同时出现在部署清单和服务清单中,并确实是将两者联系在一起的方法。服务将与具有相同标签的任何部署关联。

使用 kubectl create -f 命令创建 Service 对象:

$ kubectl create -f db-service.yaml
service/db created

列出所有服务,并注意已创建的 db 服务:

$ kubectl get services
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
db           ClusterIP   10.110.108.96   <none>        5432/TCP   6s
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP    4h45m

下一个要部署的服务是 Redis。基于由 Kompose 生成的清单文件创建 Deployment 和 Service 对象:

$ cat redis-deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: redis
  name: redis
spec:
  replicas: 1
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        io.kompose.service: redis
    spec:
      containers:
      - image: redis:alpine
        name: redis
        ports:
        - containerPort: 6379
        resources: {}
      restartPolicy: Always
status: {}

$ kubectl create -f redis-deployment.yaml
deployment.extensions/redis created

$ kubectl get pods
NAME                    READY   STATUS    RESTARTS   AGE
db-67659d85bf-vrnw7     1/1     Running   0          37m
redis-c6476fbff-8kpqz   1/1     Running   0          11s

$ kubectl create -f redis-service.yaml
service/redis created

$ cat redis-service.yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: redis
  name: redis
spec:
  ports:
  - name: "6379"
    port: 6379
    targetPort: 6379
  selector:
    io.kompose.service: redis
status:
  loadBalancer: {}

$ kubectl get services
NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
db           ClusterIP   10.110.108.96   <none>        5432/TCP   84s
kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP    4h46m
redis        ClusterIP   10.106.44.183   <none>        6379/TCP   10s

到目前为止,已部署的两个服务,dbredis,互不影响。应用程序的下一部分是工作进程,需要与 PostgreSQL 和 Redis 进行通信。这就是使用 Kubernetes 服务的优势所在。工作部署可以通过服务名称引用 PostgreSQL 和 Redis 的端点。Kubernetes 知道如何将来自客户端(作为工作部署中的 pod 的一部分运行的容器)的请求路由到服务器(作为 dbredis 部署中的 pod 的一部分运行的 PostgreSQL 和 Redis 容器)。

工作节点部署中使用的环境变量之一是DATABASE_URL。 它包含应用程序使用的数据库密码。 不应在部署清单文件中明文显示密码,因为该文件需要检入版本控制。 取而代之,创建一个 Kubernetes Secret 对象。

首先,将密码字符串编码为base64

$ echo MYPASS | base64
MYPASSBASE64

然后,创建一个描述要创建的 Kubernetes Secret 对象的清单文件。 由于密码的base64编码不安全,请使用sops来编辑和保存加密的清单文件secrets.yaml.enc

$ sops --pgp E14104A0890994B9AC9C9F6782C1FF5E679EFF32 secrets.yaml.enc

在编辑器中添加这些行:

apiVersion: v1
kind: Secret
metadata:
  name: fbe-secret
type: Opaque
data:
  dbpass: MYPASSBASE64

secrets.yaml.enc 文件现在可以检入,因为它包含密码的base64值的加密版本。

要解密加密文件,请使用sops -d命令:

$ sops -d secrets.yaml.enc
apiVersion: v1
kind: Secret
metadata:
  name: fbe-secret
type: Opaque
data:
  dbpass: MYPASSBASE64

sops -d的输出导向kubectl create -f以创建 Kubernetes Secret 对象:

$ sops -d secrets.yaml.enc | kubectl create -f -
secret/fbe-secret created

检查 Kubernetes Secrets 并描述已创建的 Secret:

$ kubectl get secrets
NAME                  TYPE                                  DATA   AGE
default-token-k7652   kubernetes.io/service-account-token   3      3h19m
fbe-secret            Opaque                                1      45s

$ kubectl describe secret fbe-secret
Name:         fbe-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
dbpass:  12 bytes

要获取base64编码的 Secret,请使用:

$ kubectl get secrets fbe-secret -ojson | jq -r ".data.dbpass"
MYPASSBASE64

要在 macOS 机器上获取纯文本密码,请使用以下命令:

$ kubectl get secrets fbe-secret -ojson | jq -r ".data.dbpass" | base64 -D
MYPASS

在 Linux 机器上,base64解码的正确标志是-d,因此正确的命令将是:

$ kubectl get secrets fbe-secret -ojson | jq -r ".data.dbpass" | base64 -d
MYPASS

现在可以在工作节点的部署清单中使用该秘密。 修改由Kompose实用程序生成的worker-deployment.yaml文件,并添加两个环境变量:

  • DBPASS是从fbe-secret Secret 对象中检索的数据库密码。

  • DATABASE_URL是 PostgreSQL 的完整数据库连接字符串,包括数据库密码,并将其引用为${DBPASS}

这是修改后的worker-deployment.yaml的版本:

$ cat worker-deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: worker
  name: worker
spec:
  replicas: 1
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        io.kompose.service: worker
    spec:
      containers:
      - args:
        - worker.py
        env:
        - name: APP_SETTINGS
          value: config.ProductionConfig
        - name: DBPASS
          valueFrom:
            secretKeyRef:
              name: fbe-secret
              key: dbpass
        - name: DATABASE_URL
          value: postgresql://wordcount_dbadmin:${DBPASS}@db/wordcount
        - name: REDISTOGO_URL
          value: redis://redis:6379
        image: griggheo/flask-by-example:v1
        name: worker
        resources: {}
      restartPolicy: Always
status: {}

通过调用kubectl create -f创建工作节点部署对象的方式与其他部署相同:

$ kubectl create -f worker-deployment.yaml
deployment.extensions/worker created

列出 pods:

$ kubectl get pods
NAME                      READY   STATUS              RESTARTS   AGE
db-67659d85bf-vrnw7       1/1     Running             1          21h
redis-c6476fbff-8kpqz     1/1     Running             1          21h
worker-7dbf5ff56c-vgs42   0/1     Init:ErrImagePull   0          7s

注意,工作节点显示为状态Init:ErrImagePull。 要查看有关此状态的详细信息,请运行kubectl describe

$ kubectl describe pod worker-7dbf5ff56c-vgs42 | tail -10
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type     Reason     Age                  From               Message
  ----     ------     ----                 ----               -------
  Normal   Scheduled  2m51s                default-scheduler
  Successfully assigned default/worker-7dbf5ff56c-vgs42 to minikube

  Normal   Pulling    76s (x4 over 2m50s)  kubelet, minikube
  Pulling image "griggheo/flask-by-example:v1"

  Warning  Failed     75s (x4 over 2m49s)  kubelet, minikube
  Failed to pull image "griggheo/flask-by-example:v1": rpc error:
  code = Unknown desc = Error response from daemon: pull access denied for
  griggheo/flask-by-example, repository does not exist or may require
  'docker login'

  Warning  Failed     75s (x4 over 2m49s)  kubelet, minikube
  Error: ErrImagePull

  Warning  Failed     62s (x6 over 2m48s)  kubelet, minikube
  Error: ImagePullBackOff

  Normal   BackOff    51s (x7 over 2m48s)  kubelet, minikube
  Back-off pulling image "griggheo/flask-by-example:v1"

部署尝试从 Docker Hub 拉取griggheo/flask-by-example:v1私有 Docker 镜像,并且缺少访问私有 Docker 注册表所需的适当凭据。 Kubernetes 包括一种特殊类型的对象,用于处理这种情况,称为imagePullSecret

使用sops创建包含 Docker Hub 凭据和kubectl create secret调用的加密文件:

$ sops --pgp E14104A0890994B9AC9C9F6782C1FF5E679EFF32 \
create_docker_credentials_secret.sh.enc

文件的内容是:

DOCKER_REGISTRY_SERVER=docker.io
DOCKER_USER=Type your dockerhub username, same as when you `docker login`
DOCKER_EMAIL=Type your dockerhub email, same as when you `docker login`
DOCKER_PASSWORD=Type your dockerhub pw, same as when you `docker login`

kubectl create secret docker-registry myregistrykey \
--docker-server=$DOCKER_REGISTRY_SERVER \
--docker-username=$DOCKER_USER \
--docker-password=$DOCKER_PASSWORD \
--docker-email=$DOCKER_EMAIL

使用sops解密加密文件并通过bash运行它:

$ sops -d create_docker_credentials_secret.sh.enc | bash -
secret/myregistrykey created

检查秘密:

$ kubectl get secrets myregistrykey -oyaml
apiVersion: v1
data:
  .dockerconfigjson: eyJhdXRocyI6eyJkb2NrZXIuaW8iO
kind: Secret
metadata:
  creationTimestamp: "2019-07-17T22:11:56Z"
  name: myregistrykey
  namespace: default
  resourceVersion: "16062"
  selfLink: /api/v1/namespaces/default/secrets/myregistrykey
  uid: 47d29ffc-69e4-41df-a237-1138cd9e8971
type: kubernetes.io/dockerconfigjson

对工作节点部署清单的唯一更改是添加这些行:

      imagePullSecrets:
      - name: myregistrykey

在此行后包含它:

     restartPolicy: Always

删除工作节点部署并重新创建:

$ kubectl delete -f worker-deployment.yaml
deployment.extensions "worker" deleted

$ kubectl create -f worker-deployment.yaml
deployment.extensions/worker created

现在工作节点处于运行状态,并且没有错误:

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
db-67659d85bf-vrnw7       1/1     Running   1          22h
redis-c6476fbff-8kpqz     1/1     Running   1          21h
worker-7dbf5ff56c-hga37   1/1     Running   0          4m53s

使用kubectl logs命令检查工作节点的日志:

$ kubectl logs worker-7dbf5ff56c-hga37
20:43:13 RQ worker 'rq:worker:040640781edd4055a990b798ac2eb52d'
started, version 1.0
20:43:13 *** Listening on default...
20:43:13 Cleaning registries for queue: default

接下来是解决应用程序部署的步骤。当应用程序在第十一章中以docker-compose设置部署时,使用单独的 Docker 容器来运行更新 Flask 数据库所需的迁移。这种任务很适合作为同一 Pod 中主应用程序容器的侧车容器运行。侧车容器将在应用程序部署清单中定义为 Kubernetes 的initContainer。此类容器保证在属于其所属的 Pod 内的其他容器启动之前运行。

将此部分添加到由Kompose实用程序生成的app-deployment.yaml清单文件中,并删除migrations-deployment.yaml文件:

      initContainers:
      - args:
        - manage.py
        - db
        - upgrade
        env:
        - name: APP_SETTINGS
          value: config.ProductionConfig
        - name: DATABASE_URL
          value: postgresql://wordcount_dbadmin:@db/wordcount
        image: griggheo/flask-by-example:v1
        name: migrations
        resources: {}

$ rm migrations-deployment.yaml

在应用程序部署清单中复用为工作程序部署创建的fbe-secret Secret 对象:

$ cat app-deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: app
  name: app
spec:
  replicas: 1
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        io.kompose.service: app
    spec:
      initContainers:
      - args:
        - manage.py
        - db
        - upgrade
        env:
        - name: APP_SETTINGS
          value: config.ProductionConfig
        - name: DBPASS
          valueFrom:
            secretKeyRef:
              name: fbe-secret
              key: dbpass
        - name: DATABASE_URL
          value: postgresql://wordcount_dbadmin:${DBPASS}@db/wordcount
        image: griggheo/flask-by-example:v1
        name: migrations
        resources: {}
      containers:
      - args:
        - manage.py
        - runserver
        - --host=0.0.0.0
        env:
        - name: APP_SETTINGS
          value: config.ProductionConfig
        - name: DBPASS
          valueFrom:
            secretKeyRef:
              name: fbe-secret
              key: dbpass
        - name: DATABASE_URL
          value: postgresql://wordcount_dbadmin:${DBPASS}@db/wordcount
        - name: REDISTOGO_URL
          value: redis://redis:6379
        image: griggheo/flask-by-example:v1
        name: app
        ports:
        - containerPort: 5000
        resources: {}
      restartPolicy: Always
status: {}

使用kubectl create -f创建应用程序部署,然后列出 Pod 并描述应用程序 Pod:

$ kubectl create -f app-deployment.yaml
deployment.extensions/app created

$ kubectl get pods
NAME                      READY   STATUS    RESTARTS   AGE
app-c845d8969-l8nhg       1/1     Running   0          7s
db-67659d85bf-vrnw7       1/1     Running   1          22h
redis-c6476fbff-8kpqz     1/1     Running   1          21h
worker-7dbf5ff56c-vgs42   1/1     Running   0          4m53s

将应用程序部署到minikube的最后一步是确保为应用程序创建 Kubernetes 服务,并将其声明为类型LoadBalancer,以便从集群外访问:

$ cat app-service.yaml
apiVersion: v1
kind: Service
metadata:
  annotations:
    kompose.cmd: kompose convert
    kompose.version: 1.16.0 (0c01309)
  creationTimestamp: null
  labels:
    io.kompose.service: app
  name: app
spec:
  ports:
  - name: "5000"
    port: 5000
    targetPort: 5000
  type: LoadBalancer
  selector:
    io.kompose.service: app
status:
  loadBalancer: {}
注意

db服务类似,app服务通过在应用程序部署和服务清单中存在的标签声明与app部署关联:

  labels:
    io.kompose.service: app

使用kubectl create创建服务:

$ kubectl create -f app-service.yaml
service/app created

$ kubectl get services
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
app          LoadBalancer   10.99.55.191    <pending>     5000:30097/TCP   2s
db           ClusterIP      10.110.108.96   <none>        5432/TCP         21h
kubernetes   ClusterIP      10.96.0.1       <none>        443/TCP          26h
redis        ClusterIP      10.106.44.183   <none>        6379/TCP         21h

接下来运行:

$ minikube service app

此命令将使用 URLhttp://192.168.99.100:30097/打开默认浏览器,并显示 Flask 站点的主页。

在下一节中,我们将使用相同的 Kubernetes 清单文件部署我们的应用程序到一个将由 Pulumi 在 Google Cloud Platform(GCP)中配置的 Kubernetes 集群中。

使用 Pulumi 在 Google Cloud Platform(GCP)中启动 GKE Kubernetes 集群

在本节中,我们将使用Pulumi GKE 示例以及GCP 设置文档,因此在继续之前,请使用这些链接获取所需的文档。

首先创建一个新目录:

$ mkdir pulumi_gke
$ cd pulumi_gke

使用macOS 说明设置 Google Cloud SDK。

使用gcloud init命令初始化 GCP 环境。创建一个新的配置和一个名为pythonfordevops-gke-pulumi的新项目:

$ gcloud init
Welcome! This command will take you through the configuration of gcloud.

Settings from your current configuration [default] are:
core:
  account: grig.gheorghiu@gmail.com
  disable_usage_reporting: 'True'
  project: pulumi-gke-testing

Pick configuration to use:
 [1] Re-initialize this configuration [default] with new settings
 [2] Create a new configuration
Please enter your numeric choice:  2

Enter configuration name. Names start with a lower case letter and
contain only lower case letters a-z, digits 0-9, and hyphens '-':
pythonfordevops-gke-pulumi
Your current configuration has been set to: [pythonfordevops-gke-pulumi]

Pick cloud project to use:
 [1] pulumi-gke-testing
 [2] Create a new project
Please enter numeric choice or text value (must exactly match list
item):  2

Enter a Project ID. pythonfordevops-gke-pulumi
Your current project has been set to: [pythonfordevops-gke-pulumi].

登录到 GCP 帐户:

$ gcloud auth login

登录到默认应用程序pythonfordevops-gke-pulumi

$ gcloud auth application-default login

运行pulumi new命令创建一个新的 Pulumi 项目,指定gcp-python作为模板,pythonfordevops-gke-pulumi作为项目名称:

$ pulumi new
Please choose a template: gcp-python
A minimal Google Cloud Python Pulumi program
This command will walk you through creating a new Pulumi project.

Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.

project name: (pulumi_gke_py) pythonfordevops-gke-pulumi
project description: (A minimal Google Cloud Python Pulumi program)
Created project 'pythonfordevops-gke-pulumi'

stack name: (dev)
Created stack 'dev'

gcp:project: The Google Cloud project to deploy into: pythonfordevops-gke-pulumi
Saved config

Your new project is ready to go! 

To perform an initial deployment, run the following commands:

   1\. virtualenv -p python3 venv
   2\. source venv/bin/activate
   3\. pip3 install -r requirements.txt

Then, run 'pulumi up'.

以下文件由pulumi new命令创建:

$ ls -la
ls -la
total 40
drwxr-xr-x  7 ggheo  staff  224 Jul 16 15:08 .
drwxr-xr-x  6 ggheo  staff  192 Jul 16 15:06 ..
-rw-------  1 ggheo  staff   12 Jul 16 15:07 .gitignore
-rw-r--r--  1 ggheo  staff   50 Jul 16 15:08 Pulumi.dev.yaml
-rw-------  1 ggheo  staff  107 Jul 16 15:07 Pulumi.yaml
-rw-------  1 ggheo  staff  203 Jul 16 15:07 __main__.py
-rw-------  1 ggheo  staff   34 Jul 16 15:07 requirements.txt

我们将使用Pulumi 示例GitHub 存储库中的gcp-py-gke示例。

examples/gcp-py-gke复制**.pyrequirements.txt*到当前目录:

$ cp ~/pulumi-examples/gcp-py-gke/*.py .
$ cp ~/pulumi-examples/gcp-py-gke/requirements.txt .

配置与 Pulumi 在 GCP 中操作所需的与 GCP 相关的变量:

$ pulumi config set gcp:project pythonfordevops-gke-pulumi
$ pulumi config set gcp:zone us-west1-a
$ pulumi config set password --secret PASS_FOR_KUBE_CLUSTER

创建并使用 Python virtualenv,安装requirements.txt中声明的依赖项,然后通过运行pulumi up命令启动在mainpy中定义的 GKE 集群:

$ virtualenv -p python3 venv
$ source venv/bin/activate
$ pip3 install -r requirements.txt
$ pulumi up
提示

确保通过在 GCP Web 控制台中将其与 Google 计费账户关联来启用 Kubernetes Engine API。

可以在GCP 控制台中看到 GKE 集群。

生成适当的kubectl配置以及使用它与新配置的 GKE 集群进行交互。通过 Pulumi 程序将kubectl配置方便地导出为output

$ pulumi stack output kubeconfig > kubeconfig.yaml
$ export KUBECONFIG=./kubeconfig.yaml

列出组成 GKE 集群的节点:

$ kubectl get nodes
NAME                                                 STATUS   ROLES    AGE
   VERSION
gke-gke-cluster-ea17e87-default-pool-fd130152-30p3   Ready    <none>   4m29s
   v1.13.7-gke.8
gke-gke-cluster-ea17e87-default-pool-fd130152-kf9k   Ready    <none>   4m29s
   v1.13.7-gke.8
gke-gke-cluster-ea17e87-default-pool-fd130152-x9dx   Ready    <none>   4m27s
   v1.13.7-gke.8

将 Flask 示例应用程序部署到 GKE

使用相同的 Kubernetes 清单文件在 GKE 集群中部署minikube示例,通过kubectl命令。首先创建redis部署和服务:

$ kubectl create -f redis-deployment.yaml
deployment.extensions/redis created

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running   0          5m57s
redis-9946db5cc-8g6zz            1/1     Running   0          20s

$ kubectl create -f redis-service.yaml
service/redis created

$ kubectl get service redis
NAME    TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
redis   ClusterIP   10.59.245.221   <none>        6379/TCP   18s

创建一个 PersistentVolumeClaim,用作 PostgreSQL 数据库的数据卷:

$ kubectl create -f dbdata-persistentvolumeclaim.yaml
persistentvolumeclaim/dbdata created

$ kubectl get pvc
NAME     STATUS   VOLUME                                     CAPACITY
dbdata   Bound    pvc-00c8156c-b618-11e9-9e84-42010a8a006f   1Gi
   ACCESS MODES   STORAGECLASS   AGE
   RWO            standard       12s

创建db部署:

$ kubectl create -f db-deployment.yaml
deployment.extensions/db created

$ kubectl get pods
NAME                             READY   STATUS             RESTARTS  AGE
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running            0         8m52s
db-6b4fbb57d9-cjjxx              0/1     CrashLoopBackOff   1         38s
redis-9946db5cc-8g6zz            1/1     Running            0         3m15s

$ kubectl logs db-6b4fbb57d9-cjjxx

initdb: directory "/var/lib/postgresql/data" exists but is not empty
It contains a lost+found directory, perhaps due to it being a mount point.
Using a mount point directly as the data directory is not recommended.
Create a subdirectory under the mount point.

当尝试创建db部署时遇到了问题。GKE 提供了一个已挂载为/var/lib/postgresql/data的持久卷,并且根据上述错误消息,该目录并非空。

删除失败的db部署:

$ kubectl delete -f db-deployment.yaml
deployment.extensions "db" deleted

创建一个新的临时 Pod,用于挂载与 Pod 内部的/data中相同的dbdata PersistentVolumeClaim,以便检查其文件系统。为了故障排除目的,启动这种类型的临时 Pod 是一个有用的技术手段:

$ cat pvc-inspect.yaml
kind: Pod
apiVersion: v1
metadata:
  name: pvc-inspect
spec:
  volumes:
    - name: dbdata
      persistentVolumeClaim:
        claimName: dbdata
  containers:
    - name: debugger
      image: busybox
      command: ['sleep', '3600']
      volumeMounts:
        - mountPath: "/data"
          name: dbdata

$ kubectl create -f pvc-inspect.yaml
pod/pvc-inspect created

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running   0          20m
pvc-inspect                      1/1     Running   0          35s
redis-9946db5cc-8g6zz            1/1     Running   0          14m

使用kubectl exec打开 Pod 内部的 shell,以便检查/data

$ kubectl exec -it pvc-inspect -- sh
/ # cd /data
/data # ls -la
total 24
drwx------    3 999      root          4096 Aug  3 17:57 .
drwxr-xr-x    1 root     root          4096 Aug  3 18:08 ..
drwx------    2 999      root         16384 Aug  3 17:57 lost+found
/data # rm -rf lost\+found/
/data # exit

注意/data中包含一个需要移除的名为lost+found的目录。

删除临时 Pod:

$ kubectl delete pod pvc-inspect
pod "pvc-inspect" deleted

再次创建db部署,在这次操作中成功完成:

$ kubectl create -f db-deployment.yaml
deployment.extensions/db created

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running   0          23m
db-6b4fbb57d9-8h978              1/1     Running   0          19s
redis-9946db5cc-8g6zz            1/1     Running   0          17m

$ kubectl logs db-6b4fbb57d9-8h978
PostgreSQL init process complete; ready for start up.

2019-08-03 18:12:01.108 UTC [1]
LOG:  listening on IPv4 address "0.0.0.0", port 5432
2019-08-03 18:12:01.108 UTC [1]
LOG:  listening on IPv6 address "::", port 5432
2019-08-03 18:12:01.114 UTC [1]
LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2019-08-03 18:12:01.135 UTC [50]
LOG:  database system was shut down at 2019-08-03 18:12:01 UTC
2019-08-03 18:12:01.141 UTC [1]
LOG:  database system is ready to accept connections

创建wordcount数据库和角色:

$ kubectl exec -it db-6b4fbb57d9-8h978 -- psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

postgres=# create database wordcount;
CREATE DATABASE
postgres=# \q

$ kubectl exec -it db-6b4fbb57d9-8h978 -- psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.

wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYNEWPASS';
ALTER ROLE
wordcount=# \q

创建db服务:

$ kubectl create -f db-service.yaml
service/db created
$ kubectl describe service db
Name:              db
Namespace:         default
Labels:            io.kompose.service=db
Annotations:       kompose.cmd: kompose convert
                   kompose.version: 1.16.0 (0c01309)
Selector:          io.kompose.service=db
Type:              ClusterIP
IP:                10.59.241.181
Port:              5432  5432/TCP
TargetPort:        5432/TCP
Endpoints:         10.56.2.5:5432
Session Affinity:  None
Events:            <none>

根据数据库密码的base64值创建 Secret 对象。密码的明文值存储在使用sops加密的文件中:

$ echo MYNEWPASS | base64
MYNEWPASSBASE64

$ sops secrets.yaml.enc

apiVersion: v1
kind: Secret
metadata:
  name: fbe-secret
type: Opaque
data:
  dbpass: MYNEWPASSBASE64

$ sops -d secrets.yaml.enc | kubectl create -f -
secret/fbe-secret created

kubectl describe secret fbe-secret
Name:         fbe-secret
Namespace:    default
Labels:       <none>
Annotations:  <none>

Type:  Opaque

Data
===
dbpass:  21 bytes

创建另一个表示 Docker Hub 凭据的 Secret 对象:

$ sops -d create_docker_credentials_secret.sh.enc | bash -
secret/myregistrykey created

考虑到正在考虑的情景是将应用程序部署到 GKE 的生产类型部署,将worker-deployment.yaml中的replicas设置为3以确保始终运行三个 worker Pod:

$ kubectl create -f worker-deployment.yaml
deployment.extensions/worker created

确保有三个 worker Pod 正在运行:

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running   0          39m
db-6b4fbb57d9-8h978              1/1     Running   0          16m
redis-9946db5cc-8g6zz            1/1     Running   0          34m
worker-8cf5dc699-98z99           1/1     Running   0          35s
worker-8cf5dc699-9s26v           1/1     Running   0          35s
worker-8cf5dc699-v6ckr           1/1     Running   0          35s

$ kubectl logs worker-8cf5dc699-98z99
18:28:08 RQ worker 'rq:worker:1355d2cad49646e4953c6b4d978571f1' started,
 version 1.0
18:28:08 *** Listening on default...

类似地,在app-deployment.yaml中将replicas设置为两个:

$ kubectl create -f app-deployment.yaml
deployment.extensions/app created

确保有两个应用程序 Pod 正在运行:

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
app-7964cff98f-5bx4s             1/1     Running   0          54s
app-7964cff98f-8n8hk             1/1     Running   0          54s
canary-aqw8jtfo-f54b9749-q5wqj   1/1     Running   0          41m
db-6b4fbb57d9-8h978              1/1     Running   0          19m
redis-9946db5cc-8g6zz            1/1     Running   0          36m
worker-8cf5dc699-98z99           1/1     Running   0          2m44s
worker-8cf5dc699-9s26v           1/1     Running   0          2m44s
worker-8cf5dc699-v6ckr           1/1     Running   0          2m44s

创建app服务:

$ kubectl create -f app-service.yaml
service/app created

注意到创建了一个类型为 LoadBalancer 的服务:

$ kubectl describe service app
Name:                     app
Namespace:                default
Labels:                   io.kompose.service=app
Annotations:              kompose.cmd: kompose convert
                          kompose.version: 1.16.0 (0c01309)
Selector:                 io.kompose.service=app
Type:                     LoadBalancer
IP:                       10.59.255.31
LoadBalancer Ingress:     34.83.242.171
Port:                     5000  5000/TCP
TargetPort:               5000/TCP
NodePort:                 5000  31305/TCP
Endpoints:                10.56.1.6:5000,10.56.2.12:5000
Session Affinity:         None
External Traffic Policy:  Cluster
Events:
Type    Reason                Age   From                Message
----    ------                ----  ----                -------
Normal  EnsuringLoadBalancer  72s   service-controller  Ensuring load balancer
Normal  EnsuredLoadBalancer   33s   service-controller  Ensured load balancer

测试应用程序,访问基于LoadBalancer Ingress对应的 IP 地址的端点 URL:http://34.83.242.171:5000

我们演示了如何从原始 Kubernetes 清单文件创建 Kubernetes 对象(如 Deployments、Services 和 Secrets)。随着应用程序变得更加复杂,此方法的局限性开始显现,因为定制这些文件以适应不同环境(例如,分阶段、集成和生产环境)将变得更加困难。每个环境都将有其自己的环境值和秘密,您需要跟踪这些内容。一般而言,跟踪在特定时间安装了哪些清单将变得越来越复杂。Kubernetes 生态系统中存在许多解决此问题的方案,其中最常见的之一是使用Helm包管理器。把 Helm 视为 yumapt 包管理器的 Kubernetes 等价物。

下一节将展示如何使用 Helm 在 GKE 集群内安装和自定义 Prometheus 和 Grafana。

安装 Prometheus 和 Grafana Helm Charts

在当前版本(截至本文撰写时为 v2),Helm 具有一个名为 Tiller 的服务器端组件,需要在 Kubernetes 集群内具有特定权限。

为 Tiller 创建一个新的 Kubernetes Service Account,并赋予适当的权限:

$ kubectl -n kube-system create sa tiller

$ kubectl create clusterrolebinding tiller \
  --clusterrole cluster-admin \
  --serviceaccount=kube-system:tiller

$ kubectl patch deploy --namespace kube-system \
tiller-deploy -p  '{"spec":{"template":{"spec":{"serviceAccount":"tiller"}}}}'

从官方Helm 发布页面下载并安装适用于您操作系统的 Helm 二进制文件,然后使用helm init命令安装 Tiller:

$ helm init

创建一个名为 monitoring 的命名空间:

$ kubectl create namespace monitoring
namespace/monitoring created

monitoring 命名空间中安装 Prometheus Helm chart

$ helm install --name prometheus --namespace monitoring stable/prometheus
NAME:   prometheus
LAST DEPLOYED: Tue Aug 27 12:59:40 2019
NAMESPACE: monitoring
STATUS: DEPLOYED

列出 monitoring 命名空间中的 pods、services 和 configmaps:

$ kubectl get pods -nmonitoring
NAME                                             READY   STATUS    RESTARTS AGE
prometheus-alertmanager-df57f6df6-4b8lv          2/2     Running   0        3m
prometheus-kube-state-metrics-564564f799-t6qdm   1/1     Running   0        3m
prometheus-node-exporter-b4sb9                   1/1     Running   0        3m
prometheus-node-exporter-n4z2g                   1/1     Running   0        3m
prometheus-node-exporter-w7hn7                   1/1     Running   0        3m
prometheus-pushgateway-56b65bcf5f-whx5t          1/1     Running   0        3m
prometheus-server-7555945646-d86gn               2/2     Running   0        3m

$ kubectl get services -nmonitoring
NAME                            TYPE        CLUSTER-IP    EXTERNAL-IP  PORT(S)
   AGE
prometheus-alertmanager         ClusterIP   10.0.6.98     <none>       80/TCP
   3m51s
prometheus-kube-state-metrics   ClusterIP   None          <none>       80/TCP
   3m51s
prometheus-node-exporter        ClusterIP   None          <none>       9100/TCP
   3m51s
prometheus-pushgateway          ClusterIP   10.0.13.216   <none>       9091/TCP
   3m51s
prometheus-server               ClusterIP   10.0.4.74     <none>       80/TCP
   3m51s

$ kubectl get configmaps -nmonitoring
NAME                      DATA   AGE
prometheus-alertmanager   1      3m58s
prometheus-server         3      3m58s

通过kubectl port-forward命令连接到 Prometheus UI:

$ export PROMETHEUS_POD_NAME=$(kubectl get pods --namespace monitoring \
-l "app=prometheus,component=server" -o jsonpath="{.items[0].metadata.name}")

$ echo $PROMETHEUS_POD_NAME
prometheus-server-7555945646-d86gn

$ kubectl --namespace monitoring port-forward $PROMETHEUS_POD_NAME 9090
Forwarding from 127.0.0.1:9090 -> 9090
Forwarding from [::1]:9090 -> 9090
Handling connection for 9090

在浏览器中转到 localhost:9090 并查看 Prometheus UI。

monitoring 命名空间中安装 Grafana Helm chart

$ helm install --name grafana --namespace monitoring stable/grafana
NAME:   grafana
LAST DEPLOYED: Tue Aug 27 13:10:02 2019
NAMESPACE: monitoring
STATUS: DEPLOYED

列出 monitoring 命名空间中与 Grafana 相关的 pods、services、configmaps 和 secrets:

$ kubectl get pods -nmonitoring | grep grafana
grafana-84b887cf4d-wplcr                         1/1     Running   0

$ kubectl get services -nmonitoring | grep grafana
grafana                         ClusterIP   10.0.5.154    <none>        80/TCP

$ kubectl get configmaps -nmonitoring | grep grafana
grafana                   1      99s
grafana-test              1      99s

$ kubectl get secrets -nmonitoring | grep grafana
grafana                                     Opaque
grafana-test-token-85x4x                    kubernetes.io/service-account-token
grafana-token-jw2qg                         kubernetes.io/service-account-token

检索 Grafana Web UI 中 admin 用户的密码:

$ kubectl get secret --namespace monitoring grafana \
-o jsonpath="{.data.admin-password}" | base64 --decode ; echo

SOMESECRETTEXT

通过kubectl port-forward命令连接到 Grafana UI:

$ export GRAFANA_POD_NAME=$(kubectl get pods --namespace monitoring \
-l "app=grafana,release=grafana" -o jsonpath="{.items[0].metadata.name}")

$ kubectl --namespace monitoring port-forward $GRAFANA_POD_NAME 3000
Forwarding from 127.0.0.1:3000 -> 3000
Forwarding from [::1]:3000 -> 3000

在浏览器中转到 localhost:3000 并查看 Grafana UI。使用上述检索到的密码以 admin 用户身份登录。

使用 helm list 列出当前已安装的 charts。安装 chart 后,当前的安装称为 “Helm release”:

$ helm list
NAME        REVISION  UPDATED                   STATUS    CHART
    APP VERSION NAMESPACE
grafana     1         Tue Aug 27 13:10:02 2019  DEPLOYED  grafana-3.8.3
    6.2.5       monitoring
prometheus. 1         Tue Aug 27 12:59:40 2019  DEPLOYED  prometheus-9.1.0
    2.11.1      monitoring

大多数情况下,您需要自定义一个 Helm chart。如果您从本地文件系统下载 chart 并使用 helm 安装,则会更容易进行此操作。

使用 helm fetch 命令获取最新稳定版本的 Prometheus 和 Grafana Helm charts,该命令将下载这些 chart 的 tgz 归档文件:

$ mkdir charts
$ cd charts
$ helm fetch stable/prometheus
$ helm fetch stable/grafana
$ ls -la
total 80
drwxr-xr-x   4 ggheo  staff    128 Aug 27 13:59 .
drwxr-xr-x  15 ggheo  staff    480 Aug 27 13:55 ..
-rw-r--r--   1 ggheo  staff  16195 Aug 27 13:55 grafana-3.8.3.tgz
-rw-r--r--   1 ggheo  staff  23481 Aug 27 13:54 prometheus-9.1.0.tgz

解压 tgz 文件,然后删除它们:

$ tar xfz prometheus-9.1.0.tgz; rm prometheus-9.1.0.tgz
$ tar xfz grafana-3.8.3.tgz; rm grafana-3.8.3.tgz

默认情况下,模板化的 Kubernetes 清单存储在 chart 目录下名为 templates 的目录中,因此在此例中,这些位置将分别是 prometheus/templatesgrafana/templates。给定 chart 的配置值在 chart 目录下的 values.yaml 文件中声明。

作为 Helm 图表定制的示例,让我们向 Grafana 添加一个持久卷,这样当重启 Grafana pods 时就不会丢失数据。

修改文件 grafana/values.yaml,并在该部分将 persistence 父键下的 enabled 子键的值设置为 true(默认为 false)。

## Enable persistence using Persistent Volume Claims
## ref: http://kubernetes.io/docs/user-guide/persistent-volumes/
##
persistence:
  enabled: true
  # storageClassName: default
  accessModes:
    - ReadWriteOnce
  size: 10Gi
  # annotations: {}
  finalizers:
    - kubernetes.io/pvc-protection
  # subPath: ""
  # existingClaim:

使用 helm upgrade 命令来升级现有的 grafana Helm 发布。命令的最后一个参数是包含图表的本地目录的名称。在 grafana 图表目录的父目录中运行此命令:

$ helm upgrade grafana grafana/
Release "grafana" has been upgraded. Happy Helming!

验证在 monitoring 命名空间中为 Grafana 创建了 PVC:

kubectl describe pvc grafana -nmonitoring
Name:        grafana
Namespace:   monitoring
StorageClass:standard
Status:      Bound
Volume:      pvc-31d47393-c910-11e9-87c5-42010a8a0021
Labels:      app=grafana
             chart=grafana-3.8.3
             heritage=Tiller
             release=grafana
Annotations: pv.kubernetes.io/bind-completed: yes
             pv.kubernetes.io/bound-by-controller: yes
             volume.beta.kubernetes.io/storage-provisioner:kubernetes.io/gce-pd
Finalizers:  [kubernetes.io/pvc-protection]
Capacity:    10Gi
Access Modes:RWO
Mounted By:  grafana-84f79d5c45-zlqz8
Events:
Type    Reason                 Age   From                         Message
----    ------                 ----  ----                         -------
Normal  ProvisioningSucceeded  88s   persistentvolume-controller  Successfully
provisioned volume pvc-31d47393-c910-11e9-87c5-42010a8a0021
using kubernetes.io/gce-pd

另一个 Helm 图表定制的示例是修改 Prometheus 中存储数据的默认保留期,从 15 天改为其他。

prometheus/values.yaml 文件中将 retention 值更改为 30 天:

  ## Prometheus data retention period (default if not specified is 15 days)
  ##
  retention: "30d"

通过运行 helm upgrade 命令来升级现有的 Prometheus Helm 发布。在 prometheus 图表目录的父目录中运行此命令:

$ helm upgrade prometheus prometheus
Release "prometheus" has been upgraded. Happy Helming!

验证保留期已更改为 30 天。运行 kubectl describe 命令针对 monitoring 命名空间中运行的 Prometheus pod,并查看输出的 Args 部分:

$ kubectl get pods -nmonitoring
NAME                                            READY   STATUS   RESTARTS   AGE
grafana-84f79d5c45-zlqz8                        1/1     Running  0          9m
prometheus-alertmanager-df57f6df6-4b8lv         2/2     Running  0          87m
prometheus-kube-state-metrics-564564f799-t6qdm  1/1     Running  0          87m
prometheus-node-exporter-b4sb9                  1/1     Running  0          87m
prometheus-node-exporter-n4z2g                  1/1     Running  0          87m
prometheus-node-exporter-w7hn7                  1/1     Running  0          87m
prometheus-pushgateway-56b65bcf5f-whx5t         1/1     Running  0          87m
prometheus-server-779ffd445f-4llqr              2/2     Running  0          3m

$ kubectl describe pod prometheus-server-779ffd445f-4llqr -nmonitoring
OUTPUT OMITTED
      Args:
      --storage.tsdb.retention.time=30d
      --config.file=/etc/config/prometheus.yml
      --storage.tsdb.path=/data
      --web.console.libraries=/etc/prometheus/console_libraries
      --web.console.templates=/etc/prometheus/consoles
      --web.enable-lifecycle

销毁 GKE 集群

如果不再需要,记得删除用于测试目的的任何云资源,因为这真的很“贵”。否则,月底收到云服务提供商的账单时,可能会有不愉快的惊喜。

通过 pulumi destroy 销毁 GKE 集群:

$ pulumi destroy

Previewing destroy (dev):

     Type                            Name                            Plan
 -   pulumi:pulumi:Stack             pythonfordevops-gke-pulumi-dev  delete
 -   ├─ kubernetes:core:Service      ingress                         delete
 -   ├─ kubernetes:apps:Deployment   canary                          delete
 -   ├─ pulumi:providers:kubernetes  gke_k8s                         delete
 -   ├─ gcp:container:Cluster        gke-cluster                     delete
 -   └─ random:index:RandomString    password                        delete

Resources:
    - 6 to delete

Do you want to perform this destroy? yes
Destroying (dev):

     Type                            Name                            Status
 -   pulumi:pulumi:Stack             pythonfordevops-gke-pulumi-dev  deleted
 -   ├─ kubernetes:core:Service      ingress                         deleted
 -   ├─ kubernetes:apps:Deployment   canary                          deleted
 -   ├─ pulumi:providers:kubernetes  gke_k8s                         deleted
 -   ├─ gcp:container:Cluster        gke-cluster                     deleted
 -   └─ random:index:RandomString    password                        deleted

Resources:
    - 6 deleted

Duration: 3m18s

练习

  • 在 GKE 中不再运行 PostgreSQL 的 Docker 容器,而是使用 Google Cloud SQL for PostgreSQL。

  • 使用 AWS 云开发工具包 来启动 Amazon EKS 集群,并将示例应用部署到该集群中。

  • 在 EKS 中不再运行 PostgreSQL 的 Docker 容器,而是使用 Amazon RDS PostgreSQL。

  • 尝试使用 Kustomize 作为管理 Kubernetes 清单 YAML 文件的 Helm 替代方案。

第十三章:无服务器技术

无服务器是当今 IT 行业中引起很多关注的一个词汇。像这样的词汇经常会导致人们对它们的实际含义有不同的看法。表面上看,无服务器意味着一个你不再需要担心管理服务器的世界。在某种程度上,这是正确的,但只适用于使用无服务器技术提供的功能的开发人员。本章展示了在幕后需要发生很多工作才能实现这个无服务器的神奇世界。

许多人将术语无服务器等同于函数即服务(FaaS)。这在某种程度上是正确的,这主要是因为 AWS 在 2015 年推出了 Lambda 服务。AWS Lambdas 是可以在云中运行的函数,而无需部署传统服务器来托管这些函数。因此,无服务器这个词就诞生了。

然而,FaaS 并不是唯一可以称为无服务器的服务。如今,三大公共云提供商(亚马逊、微软和谷歌)都提供容器即服务(CaaS),允许您在它们的云中部署完整的 Docker 容器,而无需提供托管这些容器的服务器。这些服务也可以称为无服务器。这些服务的示例包括 AWS Fargate、Microsoft Azure 容器实例和 Google Cloud Run。

无服务器技术的一些用例是什么?对于像 AWS Lambda 这样的 FaaS 技术,特别是由于 Lambda 函数可以被其他云服务触发的事件驱动方式,用例包括:

  • 提取-转换-加载(ETL)数据处理,其中,例如,将文件上传到 S3,触发 Lambda 函数对数据进行 ETL 处理,并将其发送到队列或后端数据库

  • 对其他服务发送到 CloudWatch 的日志进行 ETL 处理

  • 基于 CloudWatch Events 触发 Lambda 函数以类似 cron 的方式调度任务

  • 基于 Amazon SNS 触发 Lambda 函数的实时通知

  • 使用 Lambda 和 Amazon SES 处理电子邮件

  • 无服务器网站托管,静态 Web 资源(如 Javascript、CSS 和 HTML)存储在 S3 中,并由 CloudFront CDN 服务前端处理,以及由 API Gateway 处理的 REST API 将 API 请求路由到 Lambda 函数,后者与后端(如 Amazon RDS 或 Amazon DynamoDB)通信

每个云服务提供商的在线文档中都可以找到许多无服务器使用案例。例如,在 Google Cloud 无服务器生态系统中,Web 应用程序最适合由 Google AppEngine 处理,API 最适合由 Google Functions 处理,而 CloudRun 则适合在 Docker 容器中运行进程。举个具体的例子,考虑一个需要执行 TensorFlow 框架的机器学习任务(如对象检测)的服务。由于 FaaS 的计算、内存和磁盘资源限制,再加上 FaaS 设置中库的有限可用性,可能更适合使用 Google Cloud Run 这样的 CaaS 服务来运行这样的服务,而不是使用 Google Cloud Functions 这样的 FaaS 服务。

三大云服务提供商还围绕其 FaaS 平台提供丰富的 DevOps 工具链。例如,当你使用 AWS Lambda 时,你可以轻松地添加 AWS 的这些服务:

  • AWS X-Ray 用于追踪/可观测性。

  • Amazon CloudWatch 用于日志记录、警报和事件调度。

  • AWS Step Functions 用于无服务器工作流协调。

  • AWS Cloud9 用于基于浏览器的开发环境。

如何选择 FaaS 和 CaaS?在一个维度上,这取决于部署的单位。如果你只关心短暂的函数,少量依赖和少量数据处理,那么 FaaS 可以适合你。另一方面,如果你有长时间运行的进程,有大量依赖和重的计算需求,那么使用 CaaS 可能更好。大多数 FaaS 服务对运行时间(Lambda 的最长时间为 15 分钟)、计算能力、内存大小、磁盘空间、HTTP 请求和响应限制都有严格的限制。FaaS 短执行时间的优势在于你只需支付函数运行的时长。

如果你还记得第 第十二章 开头关于宠物与牲畜与昆虫的讨论,函数可以真正被视为短暂存在、执行一些处理然后消失的短命昆虫。由于它们的短暂特性,FaaS 中的函数也是无状态的,这是在设计应用程序时需要牢记的重要事实。

选择 FaaS 和 CaaS 的另一个维度是你的服务与其他服务之间的交互次数和类型。例如,AWS Lambda 函数可以由其他至少八个 AWS 服务异步触发,包括 S3、简单通知服务(SNS)、简单电子邮件服务(SES)和 CloudWatch。这种丰富的交互使得编写响应事件的函数更加容易,因此在这种情况下 FaaS 胜出。

正如您在本章中将看到的,许多 FaaS 服务实际上基于 Kubernetes,这些天是事实上的容器编排标准。尽管您的部署单元是一个函数,但在幕后,FaaS 工具会创建和推送 Docker 容器到一个您可能管理或可能不管理的 Kubernetes 集群中。OpenFaas 和 OpenWhisk 是这种基于 Kubernetes 的 FaaS 技术的示例。当您自托管这些 FaaS 平台时,您很快就会意识到服务器构成了大部分无服务器的单词。突然间,您不得不非常关心如何照料和喂养您的 Kubernetes 集群。

当我们将 DevOps 一词分为其部分 Dev 和 Ops 时,无服务器技术更多地针对 Dev 方面。它们帮助开发人员在部署其代码时感觉少一些摩擦。特别是在自托管场景中,负担在 Ops 身上,以提供将支持 FaaS 或 CaaS 平台的基础设施(有时非常复杂)。然而,即使 Dev 方面在使用无服务器时可能觉得 Ops 需求较少(这确实会发生,尽管根据定义这种分割使其成为非 DevOps 情况),在使用无服务器平台时仍然有很多与 Ops 相关的问题需要担心:安全性、可伸缩性、资源限制和容量规划、监视、日志记录和可观察性。传统上,这些被视为 Ops 的领域,但在我们讨论的新兴 DevOps 世界中,它们需要由 Dev 和 Ops 共同解决并合作。一个 Dev 团队在完成编写代码后不应该觉得任务已经完成。相反,它应该承担责任,并且是的,以在生产环境中全程完成服务,并内置良好的监控、日志记录和跟踪。

我们从这一章开始,展示如何使用它们的 FaaS 提供的相同 Python 函数(表示简单 HTTP 端点),将其部署到“三巨头”云提供商。

以下示例中使用的某些命令会产生大量输出。除了在理解命令时必要的情况下,我们将省略大部分输出行,以节省树木并使读者能够更好地专注于文本。

将相同的 Python 函数部署到“三巨头”云提供商

对于 AWS 和 Google,我们使用无服务器平台,通过抽象出参与 FaaS 运行时环境的云资源的创建来简化这些部署。无服务器平台目前尚不支持 Microsoft Azure 的 Python 函数,因此在这种情况下,我们展示如何使用 Azure 特定的 CLI 工具。

安装无服务器框架

无服务器平台基于 nodejs。要安装它,请使用npm

$ npm install -g serverless

将 Python 函数部署到 AWS Lambda

首先克隆无服务器平台示例 GitHub 存储库:

$ git clone https://github.com/serverless/examples.git
$ cd aws-python-simple-http-endpoint
$ export AWS_PROFILE=gheorghiu-net

Python HTTP 端点定义在文件handler.py中:

$ cat handler.py
import json
import datetime

def endpoint(event, context):
    current_time = datetime.datetime.now().time()
    body = {
        "message": "Hello, the current time is " + str(current_time)
    }

    response = {
        "statusCode": 200,
        "body": json.dumps(body)
    }

    return response

无服务器平台使用声明性方法指定需要创建的资源,该方法使用一个名为serverless.yaml的 YAML 文件。以下是声明一个名为currentTime的函数的文件,对应于之前定义的handler模块中的 Python 函数endpoint

$ cat serverless.yml
service: aws-python-simple-http-endpoint

frameworkVersion: ">=1.2.0 <2.0.0"

provider:
  name: aws
  runtime: python2.7 # or python3.7, supported as of November 2018

functions:
  currentTime:
    handler: handler.endpoint
    events:
      - http:
          path: ping
          method: get

serverless.yaml中修改 Python 版本为 3.7:

provider:
  name: aws
  runtime: python3.7

通过运行serverless deploy命令将函数部署到 AWS Lambda:

$ serverless deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Uploading CloudFormation file to S3...
Serverless: Uploading artifacts...
Serverless:
Uploading service aws-python-simple-http-endpoint.zip file to S3 (1.95 KB)...
Serverless: Validating template...
Serverless: Updating Stack...
Serverless: Checking Stack update progress...
..............
Serverless: Stack update finished...
Service Information
service: aws-python-simple-http-endpoint
stage: dev
region: us-east-1
stack: aws-python-simple-http-endpoint-dev
resources: 10
api keys:
  None
endpoints:
  GET - https://3a88jzlxm0.execute-api.us-east-1.amazonaws.com/dev/ping
functions:
  currentTime: aws-python-simple-http-endpoint-dev-currentTime
layers:
  None
Serverless:
Run the "serverless" command to setup monitoring, troubleshooting and testing.

通过使用curl命中其端点来测试部署的 AWS Lambda 函数:

$ curl https://3a88jzlxm0.execute-api.us-east-1.amazonaws.com/dev/ping
{"message": "Hello, the current time is 23:16:30.479690"}%

使用serverless invoke命令直接调用 Lambda 函数:

$ serverless invoke --function currentTime
{
    "statusCode": 200,
    "body": "{\"message\": \"Hello, the current time is 23:18:38.101006\"}"
}

直接调用 Lambda 函数并同时检查日志(发送到 AWS CloudWatch Logs):

$ serverless invoke --function currentTime --log
{
    "statusCode": 200,
    "body": "{\"message\": \"Hello, the current time is 23:17:11.182463\"}"
}
--------------------------------------------------------------------
START RequestId: 5ac3c9c8-f8ca-4029-84fa-fcf5157b1404 Version: $LATEST
END RequestId: 5ac3c9c8-f8ca-4029-84fa-fcf5157b1404
REPORT RequestId: 5ac3c9c8-f8ca-4029-84fa-fcf5157b1404
Duration: 1.68 ms Billed Duration: 100 ms   Memory Size: 1024 MB
Max Memory Used: 56 MB

请注意,前述输出中的Billed Duration为 100 毫秒。这显示了使用 FaaS 的一个优势——以非常短的时间段计费。

还有一件事我们想要引起您的注意,即无服务器平台在创建作为 Lambda 设置的一部分的 AWS 资源时在幕后进行了大量工作。无服务器平台创建了一个称为 CloudFormation 堆栈的栈,此案例中称为aws-python-simple-http-endpoint-dev。您可以使用aws CLI 工具检查它:

$ aws cloudformation describe-stack-resources \
  --stack-name aws-python-simple-http-endpoint-dev
  --region us-east-1 | jq '.StackResources[].ResourceType'
"AWS::ApiGateway::Deployment"
"AWS::ApiGateway::Method"
"AWS::ApiGateway::Resource"
"AWS::ApiGateway::RestApi"
"AWS::Lambda::Function"
"AWS::Lambda::Permission"
"AWS::Lambda::Version"
"AWS::Logs::LogGroup"
"AWS::IAM::Role"
"AWS::S3::Bucket"

请注意,这个 CloudFormation 堆栈包含不少于 10 种 AWS 资源类型,否则您将不得不手动创建或手动关联这些资源。

将 Python 函数部署到 Google Cloud Functions

在本节中,我们将以服务器平台示例 GitHub 存储库中google-python-simple-http-endpoint目录中的代码为例:

$ gcloud projects list
PROJECT_ID                  NAME                        PROJECT_NUMBER
pulumi-gke-testing          Pulumi GKE Testing          705973980178
pythonfordevops-gke-pulumi  pythonfordevops-gke-pulumi  787934032650

创建一个新的 GCP 项目:

$ gcloud projects create pythonfordevops-cloudfunction

初始化本地的gcloud环境:

$ gcloud init
Welcome! This command will take you through the configuration of gcloud.

Settings from your current configuration [pythonfordevops-gke-pulumi] are:
compute:
  region: us-west1
  zone: us-west1-c
core:
  account: grig.gheorghiu@gmail.com
  disable_usage_reporting: 'True'
  project: pythonfordevops-gke-pulumi

Pick configuration to use:
[1] Re-initialize this configuration with new settings
[2] Create a new configuration
[3] Switch to and re-initialize existing configuration: [default]
Please enter your numeric choice:  2

Enter configuration name. Names start with a lower case letter and
contain only lower case letters a-z, digits 0-9, and hyphens '-':
pythonfordevops-cloudfunction
Your current configuration has been set to: [pythonfordevops-cloudfunction]

Choose the account you would like to use to perform operations for
this configuration:
 [1] grig.gheorghiu@gmail.com
 [2] Log in with a new account
Please enter your numeric choice:  1

You are logged in as: [grig.gheorghiu@gmail.com].

Pick cloud project to use:
 [1] pulumi-gke-testing
 [2] pythonfordevops-cloudfunction
 [3] pythonfordevops-gke-pulumi
 [4] Create a new project
Please enter numeric choice or text value (must exactly match list
item):  2

Your current project has been set to: [pythonfordevops-cloudfunction].

授权本地 Shell 与 GCP:

$ gcloud auth login

使用无服务器框架部署与 AWS Lambda 示例相同的 Python HTTP 端点,但这次作为 Google Cloud Function:

$ serverless deploy

  Serverless Error ---------------------------------------

  Serverless plugin "serverless-google-cloudfunctions"
  initialization errored: Cannot find module 'serverless-google-cloudfunctions'
Require stack:
- /usr/local/lib/node_modules/serverless/lib/classes/PluginManager.js
- /usr/local/lib/node_modules/serverless/lib/Serverless.js
- /usr/local/lib/node_modules/serverless/lib/utils/autocomplete.js
- /usr/local/lib/node_modules/serverless/bin/serverless.js

  Get Support --------------------------------------------
     Docs:          docs.serverless.com
     Bugs:          github.com/serverless/serverless/issues
     Issues:        forum.serverless.com

  Your Environment Information ---------------------------
     Operating System:          darwin
     Node Version:              12.9.0
     Framework Version:         1.50.0
     Plugin Version:            1.3.8
     SDK Version:               2.1.0

我们刚遇到的错误是由于尚未安装package.json中指定的依赖项:

$ cat package.json
{
  "name": "google-python-simple-http-endpoint",
  "version": "0.0.1",
  "description":
  "Example demonstrates how to setup a simple HTTP GET endpoint with python",
  "author": "Sebastian Borza <sebito91@gmail.com>",
  "license": "MIT",
  "main": "handler.py",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "serverless-google-cloudfunctions": "².1.0"
  }
}

无服务器平台是用 node.js 编写的,因此它的包需要使用npm install安装:

$ npm install

再次尝试部署:

$ serverless deploy

  Error --------------------------------------------------

  Error: ENOENT: no such file or directory,
  open '/Users/ggheo/.gcloud/keyfile.json'

要生成凭据密钥,请在 GCP IAM 服务帐户页面上创建一个名为sa的新服务帐户。在此案例中,新服务帐户的电子邮件设置为sa-255@pythonfordevops-cloudfunction.iam.gserviceaccount.com

创建一个凭据密钥,并将其下载为~/.gcloud/pythonfordevops-cloudfunction.json

指定项目和serverless.yml中密钥的路径:

$ cat serverless.yml

service: python-simple-http-endpoint

frameworkVersion: ">=1.2.0 <2.0.0"

package:
  exclude:
    - node_modules/**
    - .gitignore
    - .git/**

plugins:
  - serverless-google-cloudfunctions

provider:
  name: google
  runtime: python37
  project: pythonfordevops-cloudfunction
  credentials: ~/.gcloud/pythonfordevops-cloudfunction.json

functions:
  currentTime:
    handler: endpoint
    events:
      - http: path

转到 GCP 部署管理器页面并启用 Cloud Deployment Manager API;然后还为 Google Cloud Storage 启用计费。

再次尝试部署:

$ serverless deploy
Serverless: Packaging service...
Serverless: Excluding development dependencies...
Serverless: Compiling function "currentTime"...
Serverless: Uploading artifacts...

  Error --------------------------------------------------

  Error: Not Found
  at createError
  (/Users/ggheo/code/mycode/examples/google-python-simple-http-endpoint/
  node_modules/axios/lib/core/createError.js:16:15)
  at settle (/Users/ggheo/code/mycode/examples/
  google-python-simple-http-endpoint/node_modules/axios/lib/
  core/settle.js:18:12)
  at IncomingMessage.handleStreamEnd
  (/Users/ggheo/code/mycode/examples/google-python-simple-http-endpoint/
  node_modules/axios/lib/adapters/http.js:202:11)
  at IncomingMessage.emit (events.js:214:15)
  at IncomingMessage.EventEmitter.emit (domain.js:476:20)
  at endReadableNT (_stream_readable.js:1178:12)
  at processTicksAndRejections (internal/process/task_queues.js:77:11)

  For debugging logs, run again after setting the "SLS_DEBUG=*"
  environment variable.

阅读关于 GCP 凭据和角色的无服务器平台文档

需要分配给部署所使用的服务帐户的以下角色:

  • 部署管理器编辑器

  • 存储管理员

  • 日志管理员

  • 云函数开发者角色

还要阅读有关需启用的 GCP API 的 Serverless 平台文档文档

在 GCP 控制台中需要启用以下 API:

  • Google Cloud Functions

  • Google Cloud Deployment Manager

  • Google Cloud Storage

  • Stackdriver Logging

转到 GCP 控制台中的部署管理器,并检查错误消息:

sls-python-simple-http-endpoint-dev failed to deploy

sls-python-simple-http-endpoint-dev has resource warnings
sls-python-simple-http-endpoint-dev-1566510445295:
{"ResourceType":"storage.v1.bucket",
"ResourceErrorCode":"403",
"ResourceErrorMessage":{"code":403,
"errors":[{"domain":"global","location":"Authorization",
"locationType":"header",
"message":"The project to be billed is associated
with an absent billing account.",
"reason":"accountDisabled"}],
"message":"The project to be billed is associated
 with an absent billing account.",
 "statusMessage":"Forbidden",
 "requestPath":"https://www.googleapis.com/storage/v1/b",
 "httpMethod":"POST"}}

在 GCP 控制台中删除sls-python-simple-http-endpoint-dev部署,并再次运行serverless deploy

$ serverless deploy

Deployed functions
first
  https://us-central1-pythonfordevops-cloudfunction.cloudfunctions.net/http

最初,由于我们没有为 Google Cloud Storage 启用计费,serverless deploy命令一直失败。在serverless.yml中指定的服务的部署标记为失败,并且即使启用了 Cloud Storage 计费后,后续的serverless deploy命令仍然失败。一旦在 GCP 控制台中删除了失败的部署,serverless deploy命令就开始工作了。

直接调用部署的 Google Cloud Function:

$ serverless invoke --function currentTime
Serverless: v1os7ptg9o48 {
    "statusCode": 200,
    "body": {
        "message": "Received a POST request at 03:46:39.027230"
    }
}

使用serverless logs命令检查日志:

$ serverless logs --function currentTime
Serverless: Displaying the 4 most recent log(s):

2019-08-23T03:35:12.419846316Z: Function execution took 20 ms,
finished with status code: 200
2019-08-23T03:35:12.400499207Z: Function execution started
2019-08-23T03:34:27.133107221Z: Function execution took 11 ms,
finished with status code: 200
2019-08-23T03:34:27.122244864Z: Function execution started

使用curl测试函数端点:

$ curl \
https://undefined-pythonfordevops-cloudfunction.cloudfunctions.net/endpoint
<!DOCTYPE html>
<html lang=en>
  <p><b>404.</b> <ins>That’s an error.</ins>
  <p>The requested URL was not found on this server.
  <ins>That’s all we know.</ins>

由于我们没有在serverless.yml中定义区域,端点 URL 以undefined开头并返回错误。

serverless.yml中将区域设置为us-central1

provider:
  name: google
  runtime: python37
  region: us-central1
  project: pythonfordevops-cloudfunction
  credentials: /Users/ggheo/.gcloud/pythonfordevops-cloudfunction.json

使用serverless deploy部署新版本,并使用curl测试函数端点:

$ curl \
https://us-central1-pythonfordevops-cloudfunction.cloudfunctions.net/endpoint
{
    "statusCode": 200,
    "body": {
        "message": "Received a GET request at 03:51:02.560756"
    }
}%

将 Python 函数部署到 Azure

Serverless 平台尚不支持基于 Python 的Azure Functions。我们将演示如何使用 Azure 本地工具部署 Azure Python 函数。

根据您特定操作系统的官方 Microsoft 文档注册 Microsoft Azure 账户并安装 Azure Functions 运行时。如果使用 macOS,使用brew

$ brew tap azure/functions
$ brew install azure-functions-core-tools

为 Python 函数代码创建新目录:

$ mkdir azure-functions-python
$ cd azure-functions-python

安装 Python 3.6,因为 Azure Functions 不支持 3.7。创建并激活virtualenv

$ brew unlink python
$ brew install \
https://raw.githubusercontent.com/Homebrew/homebrew-core/
f2a764ef944b1080be64bd88dca9a1d80130c558/Formula/python.rb \
--ignore-dependencies

$ python3 -V
Python 3.6.5

$ python3 -m venv .venv
$ source .venv/bin/activate

使用 Azure func实用程序创建名为python-simple-http-endpoint的本地函数项目:

$ func init python-simple-http-endpoint
Select a worker runtime:
1\. dotnet
2\. node
3\. python
4\. powershell (preview)
Choose option: 3

切换到新创建的python-simple-http-endpoint目录,并使用func new命令创建 Azure HTTP 触发器函数:

$ cd python-simple-http-endpoint
$ func new
Select a template:
1\. Azure Blob Storage trigger
2\. Azure Cosmos DB trigger
3\. Azure Event Grid trigger
4\. Azure Event Hub trigger
5\. HTTP trigger
6\. Azure Queue Storage trigger
7\. Azure Service Bus Queue trigger
8\. Azure Service Bus Topic trigger
9\. Timer trigger
Choose option: 5
HTTP trigger
Function name: [HttpTrigger] currentTime
Writing python-simple-http-endpoint/currentTime/__init__.py
Writing python-simple-http-endpoint/currentTime/function.json
The function "currentTime" was created successfully
from the "HTTP trigger" template.

检查创建的 Python 代码:

$ cat currentTime/__init__.py
import logging

import azure.functions as func

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    name = req.params.get('name')
    if not name:
        try:
            req_body = req.get_json()
        except ValueError:
            pass
        else:
            name = req_body.get('name')

    if name:
        return func.HttpResponse(f"Hello {name}!")
    else:
        return func.HttpResponse(
             "Please pass a name on the query string or in the request body",
             status_code=400
        )

在本地运行函数:

$ func host start

[8/24/19 12:21:35 AM] Host initialized (299ms)
[8/24/19 12:21:35 AM] Host started (329ms)
[8/24/19 12:21:35 AM] Job host started
[8/24/19 12:21:35 AM]  INFO: Starting Azure Functions Python Worker.
[8/24/19 12:21:35 AM]  INFO: Worker ID: e49c429d-9486-4167-9165-9ecd1757a2b5,
Request ID: 2842271e-a8fe-4643-ab1a-f52381098ae6, Host Address: 127.0.0.1:53952
Hosting environment: Production
Content root path: python-simple-http-endpoint
Now listening on: http://0.0.0.0:7071
Application started. Press Ctrl+C to shut down.
[8/24/19 12:21:35 AM] INFO: Successfully opened gRPC channel to 127.0.0.1:53952

Http Functions:

  currentTime: [GET,POST] http://localhost:7071/api/currentTime

在另一个终端中测试:

$ curl http://127.0.0.1:7071/api/currentTime\?name\=joe
Hello joe!%

currentTime/init.py中更改 HTTP 处理程序,以在其响应中包含当前时间:

import datetime

def main(req: func.HttpRequest) -> func.HttpResponse:
    logging.info('Python HTTP trigger function processed a request.')

    name = req.params.get('name')
    if not name:
        try:
            req_body = req.get_json()
        except ValueError:
            pass
        else:
            name = req_body.get('name')

    current_time = datetime.datetime.now().time()
    if name:
        return func.HttpResponse(f"Hello {name},
        the current time is {current_time}!")
    else:
        return func.HttpResponse(
             "Please pass a name on the query string or in the request body",
             status_code=400
        )

使用curl测试新函数:

$ curl http://127.0.0.1:7071/api/currentTime\?name\=joe
Hello joe, the current time is 17:26:54.256060!%

使用pip安装 Azure CLI:

$ pip install azure.cli

使用azCLI 实用程序在交互模式下创建 Azure 资源组、存储帐户和函数应用。此模式提供自动完成、命令描述和示例的交互式 shell。请注意,如果要跟随操作,需要指定一个不同且唯一的functionapp名称。您可能还需要指定支持免费试用帐户的其他 Azure 区域,如eastus

$ az interactive
az>> login
az>> az group create --name myResourceGroup --location westus2
az>> az storage account create --name griggheorghiustorage --location westus2 \
--resource-group myResourceGroup --sku Standard_LRS
az>> az functionapp create --resource-group myResourceGroup --os-type Linux \
--consumption-plan-location westus2 --runtime python \
--name pyazure-devops4all \
--storage-account griggheorghiustorage
az>> exit

使用func实用程序将functionapp项目部署到 Azure:

$ func azure functionapp publish pyazure-devops4all --build remote
Getting site publishing info...
Creating archive for current directory...
Perform remote build for functions project (--build remote).
Uploading 2.12 KB

OUTPUT OMITTED

Running post deployment command(s)...
Deployment successful.
App container will begin restart within 10 seconds.
Remote build succeeded!
Syncing triggers...
Functions in pyazure-devops4all:
    currentTime - [httpTrigger]
      Invoke url:
      https://pyazure-devops4all.azurewebsites.net/api/
      currenttime?code=b0rN93O04cGPcGFKyX7n9HgITTPnHZiGCmjJN/SRsPX7taM7axJbbw==

使用curl命中其端点测试在 Azure 上部署的函数:

$ curl "https://pyazure-devops4all.azurewebsites.net/api/currenttime\
?code\=b0rN93O04cGPcGFKyX7n9HgITTPnHZiGCmjJN/SRsPX7taM7axJbbw\=\=\&name\=joe"
Hello joe, the current time is 01:20:32.036097!%

删除不再需要的任何云资源始终是个好主意。在这种情况下,您可以运行:

$ az group delete --name myResourceGroup

将 Python 函数部署到自托管 FaaS 平台

正如本章前面提到的,许多 FaaS 平台正在运行在 Kubernetes 集群之上。这种方法的一个优点是您部署的函数作为常规 Docker 容器在 Kubernetes 内部运行,因此您可以使用现有的 Kubernetes 工具,特别是在可观察性方面(监视、日志记录和跟踪)。另一个优点是潜在的成本节约。通过将您的无服务器函数作为容器运行在现有的 Kubernetes 集群中,您可以使用集群的现有容量,并且不像将您的函数部署到第三方 FaaS 平台时那样按函数调用付费。

在本节中,我们考虑其中一个平台:OpenFaaS。一些运行在 Kubernetes 上的类似 FaaS 平台的其他示例包括以下内容:

部署 Python 函数到 OpenFaaS

对于本示例,我们使用 Rancher 的“Kubernetes-lite”分发称为 k3s。我们使用 k3s 而不是 minikube 来展示 Kubernetes 生态系统中可用的各种工具。

通过运行k3sup 实用程序,在 Ubuntu EC2 实例上设置 k3s Kubernetes 集群。

下载并安装 k3sup

$ curl -sLS https://get.k3sup.dev | sh
$ sudo cp k3sup-darwin /usr/local/bin/k3sup

验证远程 EC2 实例的 SSH 连通性:

$ ssh ubuntu@35.167.68.86 date
Sat Aug 24 21:38:57 UTC 2019

通过 k3sup install 安装 k3s

$ k3sup install --ip 35.167.68.86 --user ubuntu
OUTPUT OMITTED
Saving file to: kubeconfig

检查 kubeconfig 文件:

$ cat kubeconfig
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: BASE64_FIELD
    server: https://35.167.68.86:6443
  name: default
contexts:
- context:
    cluster: default
    user: default
  name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
  user:
    password: OBFUSCATED
    username: admin

KUBECONFIG 环境变量指向本地的 kubeconfig 文件,并针对远程 k3s 集群测试 kubectl 命令:

$ export KUBECONFIG=./kubeconfig

$ kubectl cluster-info
Kubernetes master is running at https://35.167.68.86:6443
CoreDNS is running at
https://35.167.68.86:6443/api/v1/namespaces/kube-system/
services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use
'kubectl cluster-info dump'.

$ kubectl get nodes
NAME            STATUS   ROLES    AGE   VERSION
ip-10-0-0-185   Ready    master   10m   v1.14.6-k3s.1

下一步是在 k3s Kubernetes 集群上安装 OpenFaas 无服务器平台。

在本地的 macOS 上安装 faas-cli

$ brew install faas-cli

为 Tiller 创建 RBAC 权限,Tiller 是 Helm 的服务器组件:

$ kubectl -n kube-system create sa tiller \
  && kubectl create clusterrolebinding tiller \
  --clusterrole cluster-admin \
  --serviceaccount=kube-system:tiller
serviceaccount/tiller created
clusterrolebinding.rbac.authorization.k8s.io/tiller created

通过 helm init 安装 Tiller:

$ helm init --skip-refresh --upgrade --service-account tiller

下载、配置并安装 OpenFaaS 的 Helm chart:

$ wget \
https://raw.githubusercontent.com/openfaas/faas-netes/master/namespaces.yml

$ cat namespaces.yml
apiVersion: v1
kind: Namespace
metadata:
  name: openfaas
  labels:
    role: openfaas-system
    access: openfaas-system
    istio-injection: enabled
---
apiVersion: v1
kind: Namespace
metadata:
  name: openfaas-fn
  labels:
    istio-injection: enabled
    role: openfaas-fn

$ kubectl apply -f namespaces.yml
namespace/openfaas created
namespace/openfaas-fn created

$ helm repo add openfaas https://openfaas.github.io/faas-netes/
"openfaas" has been added to your repositories

为连接到 OpenFaaS 网关的基本身份验证生成随机密码:

$ PASSWORD=$(head -c 12 /dev/urandom | shasum| cut -d' ' -f1)

$ kubectl -n openfaas create secret generic basic-auth \
--from-literal=basic-auth-user=admin \
--from-literal=basic-auth-password="$PASSWORD"
secret/basic-auth created

通过安装 Helm chart 部署 OpenFaaS:

$ helm repo update \
 && helm upgrade openfaas --install openfaas/openfaas \
    --namespace openfaas  \
    --set basic_auth=true \
    --set serviceType=LoadBalancer \
    --set functionNamespace=openfaas-fn

OUTPUT OMITTED

NOTES:
To verify that openfaas has started, run:
kubectl --namespace=openfaas get deployments -l "release=openfaas,app=openfaas"
注意

此处使用的没有 TLS 的 basic_auth 设置仅用于实验/学习。任何重要环境都应配置为确保凭据通过安全的 TLS 连接传递。

验证在 openfaas 命名空间中运行的服务:

$ kubectl get service -nopenfaas
NAME                TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
alertmanager        ClusterIP      10.43.193.61    <none>        9093/TCP
basic-auth-plugin   ClusterIP      10.43.83.12     <none>        8080/TCP
gateway             ClusterIP      10.43.7.46      <none>        8080/TCP
gateway-external    LoadBalancer   10.43.91.91     10.0.0.185    8080:31408/TCP
nats                ClusterIP      10.43.33.153    <none>        4222/TCP
prometheus          ClusterIP      10.43.122.184   <none>        9090/TCP

将远程实例的端口 8080 转发到本地端口 8080

$ kubectl port-forward -n openfaas svc/gateway 8080:8080 &
[1] 29183
Forwarding from 127.0.0.1:8080 -> 8080

转到 OpenFaaS Web UI,网址为http://localhost:8080,使用用户名 admin 和密码 $PASSWORD 登录。

继续通过创建一个 OpenFaaS Python 函数。使用 faas-cli 工具创建一个名为 hello-python 的新 OpenFaaS 函数:

$ faas-cli new --lang python hello-python
Folder: hello-python created.
Function created in folder: hello-python
Stack file written: hello-python.yml

检查 hello-python 函数的配置文件:

$ cat hello-python.yml
version: 1.0
provider:
  name: openfaas
  gateway: http://127.0.0.1:8080
functions:
  hello-python:
    lang: python
    handler: ./hello-python
    image: hello-python:latest

检查自动创建的目录 hello-python

$ ls -la hello-python
total 8
drwx------  4 ggheo  staff  128 Aug 24 15:16 .
drwxr-xr-x  8 ggheo  staff  256 Aug 24 15:16 ..
-rw-r--r--  1 ggheo  staff  123 Aug 24 15:16 handler.py
-rw-r--r--  1 ggheo  staff    0 Aug 24 15:16 requirements.txt

$ cat hello-python/handler.py
def handle(req):
    """handle a request to the function
    Args:
        req (str): request body
    """

    return req

编辑 handler.py 并将从 Serverless 平台的 simple-http-example 打印当前时间的代码复制过来:

$ cat hello-python/handler.py
import json
import datetime

def handle(req):
    """handle a request to the function
 Args:
 req (str): request body
 """

    current_time = datetime.datetime.now().time()
    body = {
        "message": "Received a {} at {}".format(req, str(current_time))
    }

    response = {
        "statusCode": 200,
        "body": body
    }
    return json.dumps(response, indent=4)

下一步是构建 OpenFaaS Python 函数。 使用 faas-cli build 命令将根据自动生成的 Dockerfile 构建 Docker 镜像:

$ faas-cli build -f ./hello-python.yml
[0] > Building hello-python.
Clearing temporary build folder: ./build/hello-python/
Preparing ./hello-python/ ./build/hello-python//function
Building: hello-python:latest with python template. Please wait..
Sending build context to Docker daemon  8.192kB
Step 1/29 : FROM openfaas/classic-watchdog:0.15.4 as watchdog

DOCKER BUILD OUTPUT OMITTED

Successfully tagged hello-python:latest
Image: hello-python:latest built.
[0] < Building hello-python done.
[0] worker done.

检查本地是否存在 Docker 镜像:

$ docker images | grep hello-python
hello-python                          latest
05b2c37407e1        29 seconds ago      75.5MB

将 Docker 镜像打标签并推送到 Docker Hub 注册表,以便在远程 Kubernetes 群集上使用:

$ docker tag hello-python:latest griggheo/hello-python:latest

编辑 hello-python.yml 并更改:

image: griggheo/hello-python:latest

使用 faas-cli push 命令将镜像推送到 Docker Hub:

$ faas-cli push -f ./hello-python.yml
[0] > Pushing hello-python [griggheo/hello-python:latest].
The push refers to repository [docker.io/griggheo/hello-python]
latest: digest:
sha256:27e1fbb7f68bb920a6ff8d3baf1fa3599ae92e0b3c607daac3f8e276aa7f3ae3
size: 4074
[0] < Pushing hello-python [griggheo/hello-python:latest] done.
[0] worker done.

现在,将 OpenFaaS Python 函数部署到远程 k3s 群集。 使用 faas-cli deploy 命令部署函数:

$ faas-cli deploy -f ./hello-python.yml
Deploying: hello-python.
WARNING! Communication is not secure, please consider using HTTPS.
Letsencrypt.org offers free SSL/TLS certificates.
Handling connection for 8080

unauthorized access, run "faas-cli login"
to setup authentication for this server

Function 'hello-python' failed to deploy with status code: 401

使用 faas-cli login 命令获取认证凭据:

$ echo -n $PASSWORD | faas-cli login -g http://localhost:8080 \
-u admin --password-stdin
Calling the OpenFaaS server to validate the credentials...
Handling connection for 8080
WARNING! Communication is not secure, please consider using HTTPS.
Letsencrypt.org offers free SSL/TLS certificates.
credentials saved for admin http://localhost:8080

编辑 hello-python.yml 并更改:

gateway: http://localhost:8080

因为我们从处理程序返回 JSON,请将这些行添加到 hello-python.yml

    environment:
      content_type: application/json

hello-python.yml 的内容:

$ cat hello-python.yml
version: 1.0
provider:
  name: openfaas
  gateway: http://localhost:8080
functions:
  hello-python:
    lang: python
    handler: ./hello-python
    image: griggheo/hello-python:latest
    environment:
      content_type: application/json

再次运行 faas-cli deploy 命令:

$ faas-cli deploy -f ./hello-python.yml
Deploying: hello-python.
WARNING! Communication is not secure, please consider using HTTPS.
Letsencrypt.org offers free SSL/TLS certificates.
Handling connection for 8080
Handling connection for 8080

Deployed. 202 Accepted.
URL: http://localhost:8080/function/hello-python

如果需要进行代码更改,请使用以下命令重新构建和重新部署函数。 请注意, faas-cli remove 命令将删除函数的当前版本:

$ faas-cli build -f ./hello-python.yml
$ faas-cli push -f ./hello-python.yml
$ faas-cli remove -f ./hello-python.yml
$ faas-cli deploy -f ./hello-python.yml

现在使用 curl 测试已部署的函数:

$ curl localhost:8080/function/hello-python --data-binary 'hello'
Handling connection for 8080
{
    "body": {
        "message": "Received a hello at 22:55:05.225295"
    },
    "statusCode": 200
}

使用 faas-cli 直接调用函数进行测试:

$ echo -n "hello" | faas-cli invoke hello-python
Handling connection for 8080
{
    "body": {
        "message": "Received a hello at 22:56:23.549509"
    },
    "statusCode": 200
}

下一个示例将更加全面。 我们将演示如何使用 AWS CDK 为存储在 DynamoDB 表中的todo项目提供创建/读取/更新/删除(CRUD)REST 访问的 API 网关后面的几个 Lambda 函数。 我们还将展示如何使用在 AWS Fargate 中部署的容器运行 Locust 负载测试工具来负载测试我们的 REST API。 Fargate 容器也将由 AWS CDK 提供。

使用 AWS CDK 来配置 DynamoDB 表,Lambda 函数和 API Gateway 方法:

我们在 第十章 简要提到了 AWS CDK。 AWS CDK 是一款允许您使用真实代码(当前支持的语言为 TypeScript 和 Python)定义基础架构期望状态的产品,与使用 YAML 定义文件(如 Serverless 平台所做的方式)不同。

在全局级别使用 npm 安装 CDK CLI(根据您的操作系统,您可能需要使用 sudo 来运行以下命令):

$ npm install cdk -g

为 CDK 应用程序创建一个目录:

$ mkdir cdk-lambda-dynamodb-fargate
$ cd cdk-lambda-dynamodb-fargate

使用 cdk init 创建一个示例 Python 应用程序:

$ cdk init app --language=python
Applying project template app for python
Executing Creating virtualenv...

# Welcome to your CDK Python project!

This is a blank project for Python development with CDK.
The `cdk.json` file tells the CDK Toolkit how to execute your app.

列出创建的文件:

$ ls -la
total 40
drwxr-xr-x   9 ggheo  staff   288 Sep  2 10:10 .
drwxr-xr-x  12 ggheo  staff   384 Sep  2 10:10 ..
drwxr-xr-x   6 ggheo  staff   192 Sep  2 10:10 .env
-rw-r--r--   1 ggheo  staff  1651 Sep  2 10:10 README.md
-rw-r--r--   1 ggheo  staff   252 Sep  2 10:10 app.py
-rw-r--r--   1 ggheo  staff    32 Sep  2 10:10 cdk.json
drwxr-xr-x   4 ggheo  staff   128 Sep  2 10:10 cdk_lambda_dynamodb_fargate
-rw-r--r--   1 ggheo  staff     5 Sep  2 10:10 requirements.txt
-rw-r--r--   1 ggheo  staff  1080 Sep  2 10:10 setup.py

检查主文件 app.py

$ cat app.py
#!/usr/bin/env python3

from aws_cdk import core

from cdk_lambda_dynamodb_fargate.cdk_lambda_dynamodb_fargate_stack \
import CdkLambdaDynamodbFargateStack

app = core.App()
CdkLambdaDynamodbFargateStack(app, "cdk-lambda-dynamodb-fargate")

app.synth()

CDK 程序由一个包含一个或多个堆栈的 app 组成。堆栈对应于 CloudFormation 堆栈对象。

检查定义 CDK 堆栈的模块:

$ cat cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_fargate_stack.py
from aws_cdk import core

class CdkLambdaDynamodbFargateStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # The code that defines your stack goes here

因为我们将有两个堆栈,一个用于 DynamoDB/Lambda/API Gateway 资源,另一个用于 Fargate 资源,请重命名:

cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_fargate_stack.py

cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_stack.py

将类 CdkLambdaDynamodbFargateStack 改为 CdkLambdaDynamodbStack

同时更改app.py以引用更改后的模块和类名:

from cdk_lambda_dynamodb_fargate.cdk_lambda_dynamodb_stack \
import CdkLambdaDynamodbStack

CdkLambdaDynamodbStack(app, "cdk-lambda-dynamodb")

激活virtualenv

$ source .env/bin/activate

我们将采用URL 缩短器 CDK 示例,并使用无服务器平台 AWS Python REST API 示例中的代码修改它,以构建用于创建、列出、获取、更新和删除todo项目的 REST API。使用 Amazon DynamoDB 来存储数据。

检查examples/aws-python-rest-api-with-dynamodb中的serverless.yml文件,并使用serverless命令部署它,以查看创建的 AWS 资源:

$ pwd
~/code/examples/aws-python-rest-api-with-dynamodb

$ serverless deploy
Serverless: Stack update finished...
Service Information
service: serverless-rest-api-with-dynamodb
stage: dev
region: us-east-1
stack: serverless-rest-api-with-dynamodb-dev
resources: 34
api keys:
  None
endpoints:
POST - https://tbst34m2b7.execute-api.us-east-1.amazonaws.com/dev/todos
GET - https://tbst34m2b7.execute-api.us-east-1.amazonaws.com/dev/todos
GET - https://tbst34m2b7.execute-api.us-east-1.amazonaws.com/dev/todos/{id}
PUT - https://tbst34m2b7.execute-api.us-east-1.amazonaws.com/dev/todos/{id}
DELETE - https://tbst34m2b7.execute-api.us-east-1.amazonaws.com/dev/todos/{id}
functions:
  create: serverless-rest-api-with-dynamodb-dev-create
  list: serverless-rest-api-with-dynamodb-dev-list
  get: serverless-rest-api-with-dynamodb-dev-get
  update: serverless-rest-api-with-dynamodb-dev-update
  delete: serverless-rest-api-with-dynamodb-dev-delete
layers:
  None
Serverless: Run the "serverless" command to setup monitoring, troubleshooting and
            testing.

上一条命令创建了五个 Lambda 函数、一个 API Gateway 和一个 DynamoDB 表。

在我们正在构建的堆栈中的 CDK 目录中,添加一个 DynamoDB 表:

$ pwd
~/code/devops/serverless/cdk-lambda-dynamodb-fargate

$ cat cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_stack.py
from aws_cdk import core
from aws_cdk import aws_dynamodb

class CdkLambdaDynamodbStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # define the table stores Todo items
        table = aws_dynamodb.Table(self, "Table",
                                    partition_key=aws_dynamodb.Attribute(
                                      name="id",
                                      type=aws_dynamodb.AttributeType.STRING),
                                    read_capacity=10,
                                    write_capacity=5)

安装所需的 Python 模块:

$ cat requirements.txt
-e .
aws-cdk.core
aws-cdk.aws-dynamodb

$ pip install -r requirements.txt

通过运行cdk synth来检查将要创建的 CloudFormation 堆栈:

$ export AWS_PROFILE=gheorghiu-net
$ cdk synth

app.py中将包含区域值的名为variable的变量传递给构造函数CdkLambdaDynamodbStack

app_env = {"region": "us-east-2"}
CdkLambdaDynamodbStack(app, "cdk-lambda-dynamodb", env=app_env)

再次运行cdk synth

$ cdk synth
Resources:
  TableCD117FA1:
    Type: AWS::DynamoDB::Table
    Properties:
      KeySchema:
        - AttributeName: id
          KeyType: HASH
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
      ProvisionedThroughput:
        ReadCapacityUnits: 10
        WriteCapacityUnits: 5
    UpdateReplacePolicy: Retain
    DeletionPolicy: Retain
    Metadata:
      aws:cdk:path: cdk-lambda-dynamodb-fargate/Table/Resource
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Modules: aws-cdk=1.6.1,
      @aws-cdk/aws-applicationautoscaling=1.6.1,
      @aws-cdk/aws-autoscaling-common=1.6.1,
      @aws-cdk/aws-cloudwatch=1.6.1,
      @aws-cdk/aws-dynamodb=1.6.1,
      @aws-cdk/aws-iam=1.6.1,
      @aws-cdk/core=1.6.1,
      @aws-cdk/cx-api=1.6.1,@aws-cdk/region-info=1.6.1,
      jsii-runtime=Python/3.7.4

通过运行cdk deploy部署 CDK 堆栈:

$ cdk deploy
cdk-lambda-dynamodb-fargate: deploying...
cdk-lambda-dynamodb-fargate: creating CloudFormation changeset...
 0/3 | 11:12:25 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table |
 Table (TableCD117FA1)
 0/3 | 11:12:25 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   |
 CDKMetadata
 0/3 | 11:12:25 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table |
 Table (TableCD117FA1) Resource creation Initiated
 0/3 | 11:12:27 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   |
 CDKMetadata Resource creation Initiated
 1/3 | 11:12:27 AM | CREATE_COMPLETE      | AWS::CDK::Metadata   |
 CDKMetadata
 2/3 | 11:12:56 AM | CREATE_COMPLETE      | AWS::DynamoDB::Table |
 Table (TableCD117FA1)
 3/3 | 11:12:57 AM | CREATE_COMPLETE      | AWS::CloudFormation::Stack |
 cdk-lambda-dynamodb-fargate

Stack ARN:
arn:aws:cloudformation:us-east-2:200562098309:stack/
cdk-lambda-dynamodb/3236a8b0-cdad-11e9-934b-0a7dfa8cb208

下一步是向堆栈添加 Lambda 函数和 API Gateway 资源。

在 CDK 代码目录中,创建一个lambda目录,并从无服务器平台 AWS Python REST API 示例复制 Python 模块:

$ pwd
~/code/devops/serverless/cdk-lambda-dynamodb-fargate

$ mkdir lambda
$ cp ~/code/examples/aws-python-rest-api-with-dynamodb/todos/* lambda
$ ls -la lambda
total 48
drwxr-xr-x   9 ggheo  staff   288 Sep  2 10:41 .
drwxr-xr-x  10 ggheo  staff   320 Sep  2 10:19 ..
-rw-r--r--   1 ggheo  staff     0 Sep  2 10:41 __init__.py
-rw-r--r--   1 ggheo  staff   822 Sep  2 10:41 create.py
-rw-r--r--   1 ggheo  staff   288 Sep  2 10:41 decimalencoder.py
-rw-r--r--   1 ggheo  staff   386 Sep  2 10:41 delete.py
-rw-r--r--   1 ggheo  staff   535 Sep  2 10:41 get.py
-rw-r--r--   1 ggheo  staff   434 Sep  2 10:41 list.py
-rw-r--r--   1 ggheo  staff  1240 Sep  2 10:41 update.py

将所需模块添加到requirements.txt中,并使用pip安装它们:

$ cat requirements.txt
-e .
aws-cdk.core
aws-cdk.aws-dynamodb
aws-cdk.aws-lambda
aws-cdk.aws-apigateway

$ pip install -r requirements.txt

在堆栈模块中创建 Lambda 和 API Gateway 构造:

$ cat cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_stack.py
from aws_cdk import core
from aws_cdk.core import App, Construct, Duration
from aws_cdk import aws_dynamodb, aws_lambda, aws_apigateway

class CdkLambdaDynamodbStack(core.Stack):

    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        # define the table stores Todo todos
        table = aws_dynamodb.Table(self, "Table",
            partition_key=aws_dynamodb.Attribute(
                name="id",
                type=aws_dynamodb.AttributeType.STRING),
            read_capacity=10,
            write_capacity=5)

        # define the Lambda functions
        list_handler = aws_lambda.Function(self, "TodoListFunction",
            code=aws_lambda.Code.asset("./lambda"),
            handler="list.list",
            timeout=Duration.minutes(5),
            runtime=aws_lambda.Runtime.PYTHON_3_7)

        create_handler = aws_lambda.Function(self, "TodoCreateFunction",
            code=aws_lambda.Code.asset("./lambda"),
            handler="create.create",
            timeout=Duration.minutes(5),
            runtime=aws_lambda.Runtime.PYTHON_3_7)

        get_handler = aws_lambda.Function(self, "TodoGetFunction",
            code=aws_lambda.Code.asset("./lambda"),
            handler="get.get",
            timeout=Duration.minutes(5),
            runtime=aws_lambda.Runtime.PYTHON_3_7)

        update_handler = aws_lambda.Function(self, "TodoUpdateFunction",
            code=aws_lambda.Code.asset("./lambda"),
            handler="update.update",
            timeout=Duration.minutes(5),
            runtime=aws_lambda.Runtime.PYTHON_3_7)

        delete_handler = aws_lambda.Function(self, "TodoDeleteFunction",
            code=aws_lambda.Code.asset("./lambda"),
            handler="delete.delete",
            timeout=Duration.minutes(5),
            runtime=aws_lambda.Runtime.PYTHON_3_7)

        # pass the table name to each handler through an environment variable
        # and grant the handler read/write permissions on the table.
        handler_list = [
            list_handler,
            create_handler,
            get_handler,
            update_handler,
            delete_handler
        ]
        for handler in handler_list:
            handler.add_environment('DYNAMODB_TABLE', table.table_name)
            table.grant_read_write_data(handler)

        # define the API endpoint
        api = aws_apigateway.LambdaRestApi(self, "TodoApi",
            handler=list_handler,
            proxy=False)

        # define LambdaIntegrations
        list_lambda_integration = \
            aws_apigateway.LambdaIntegration(list_handler)
        create_lambda_integration = \
            aws_apigateway.LambdaIntegration(create_handler)
        get_lambda_integration = \
            aws_apigateway.LambdaIntegration(get_handler)
        update_lambda_integration = \
            aws_apigateway.LambdaIntegration(update_handler)
        delete_lambda_integration = \
            aws_apigateway.LambdaIntegration(delete_handler)

        # define REST API model and associate methods with LambdaIntegrations
        api.root.add_method('ANY')

        todos = api.root.add_resource('todos')
        todos.add_method('GET', list_lambda_integration)
        todos.add_method('POST', create_lambda_integration)

        todo = todos.add_resource('{id}')
        todo.add_method('GET', get_lambda_integration)
        todo.add_method('PUT', update_lambda_integration)
        todo.add_method('DELETE', delete_lambda_integration)

值得注意的是我们刚刚审查的代码的几个特点:

  • 我们能够在每个handler对象上使用add_environment方法,将在 Python 代码中用于 Lambda 函数的环境变量DYNAMODB_TABLE传递,并将其设置为table.table_name。在构建时不知道 DynamoDB 表的名称,因此 CDK 将其替换为令牌,并在部署堆栈时将令牌设置为表的正确名称(有关更多详细信息,请参阅Tokens文档)。

  • 当我们遍历所有 Lambda 处理程序列表时,我们充分利用了一个简单的编程语言结构,即for循环。尽管这看起来很自然,但仍值得指出,因为循环和变量传递是基于 YAML 的基础设施即代码工具(如 Terraform)中尴尬实现的功能,如果有的话。

  • 我们定义了与 API Gateway 的各个端点相关联的 HTTP 方法(GET、POST、PUT、DELETE),并将正确的 Lambda 函数与每个端点关联。

使用cdk deploy部署堆栈:

$ cdk deploy
cdk-lambda-dynamodb-fargate failed: Error:
This stack uses assets, so the toolkit stack must be deployed
to the environment
(Run "cdk bootstrap aws://unknown-account/us-east-2")

通过运行cdk bootstrap来修复:

$ cdk bootstrap
Bootstrapping environment aws://ACCOUNTID/us-east-2...
CDKToolkit: creating CloudFormation changeset...
Environment aws://ACCOUNTID/us-east-2 bootstrapped.

再次部署 CDK 堆栈:

$ cdk deploy
OUTPUT OMITTED

Outputs:
cdk-lambda-dynamodb.TodoApiEndpointC1E16B6C =
https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/

Stack ARN:
arn:aws:cloudformation:us-east-2:ACCOUNTID:stack/cdk-lambda-dynamodb/
15a66bb0-cdba-11e9-aef9-0ab95d3a5528

下一步是使用curl测试 REST API。

首先创建一个新的todo项目:

$ curl -X \
POST https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos \
--data '{ "text": "Learn CDK" }'
{"id": "19d55d5a-cdb4-11e9-9a8f-9ed29c44196e", "text": "Learn CDK",
"checked": false,
"createdAt": "1567450902.262834",
"updatedAt": "1567450902.262834"}%

创建第二个todo项目:

$ curl -X \
POST https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos \
--data '{ "text": "Learn CDK with Python" }'
{"id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e", "text": "Learn CDK with Python",
"checked": false,
"createdAt": "1567451007.680936",
"updatedAt": "1567451007.680936"}%

通过指定其 ID 来尝试获取刚创建项目的详细信息:

$ curl \
https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/
prod/todos/58a992c6-cdb4-11e9-9a8f-9ed29c44196e
{"message": "Internal server error"}%

通过检查 Lambda 函数TodoGetFunction的 CloudWatch 日志来进行调查:

[ERROR] Runtime.ImportModuleError:
Unable to import module 'get': No module named 'todos'

要修复,请更改lambda/get.py中的行:

from todos import decimalencoder

到:

import decimalencoder

使用cdk deploy重新部署堆栈。

试着再次使用curl获取todo项目的详细信息:

$ curl \
https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/
prod/todos/58a992c6-cdb4-11e9-9a8f-9ed29c44196e
{"checked": false, "createdAt": "1567451007.680936",
"text": "Learn CDK with Python",
"id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e",
"updatedAt": "1567451007.680936"}

对需要 decimalencoder 模块的 lambda 目录中的所有模块进行 import decimalencoder 更改,并使用 cdk deploy 重新部署。

列出所有 todos 并使用 jq 工具格式化输出:

$ curl \
https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos | jq
[
  {
    "checked": false,
    "createdAt": "1567450902.262834",
    "text": "Learn CDK",
    "id": "19d55d5a-cdb4-11e9-9a8f-9ed29c44196e",
    "updatedAt": "1567450902.262834"
  },
  {
    "checked": false,
    "createdAt": "1567451007.680936",
    "text": "Learn CDK with Python",
    "id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e",
    "updatedAt": "1567451007.680936"
  }
]

删除一个 todo 并验证列表不再包含它:

$ curl -X DELETE \
https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos/
19d55d5a-cdb4-11e9-9a8f-9ed29c44196e

$ curl https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos | jq
[
  {
    "checked": false,
    "createdAt": "1567451007.680936",
    "text": "Learn CDK with Python",
    "id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e",
    "updatedAt": "1567451007.680936"
  }
]

现在测试使用 curl 更新现有的 todo 项目:

$ curl -X \
PUT https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos/
58a992c6-cdb4-11e9-9a8f-9ed29c44196e \
--data '{ "text": "Learn CDK with Python by reading the PyForDevOps book" }'
{"message": "Internal server error"}%

检查与此端点相关联的 Lambda 函数的 CloudWatch 日志显示:

[ERROR] Exception: Couldn't update the todo item.
Traceback (most recent call last):
  File "/var/task/update.py", line 15, in update
    raise Exception("Couldn't update the todo item.")

更改 lambda/update.py 中的验证测试为:

    data = json.loads(event['body'])
    if 'text' not in data:
        logging.error("Validation Failed")
        raise Exception("Couldn't update the todo item.")

同样将 checked 的值更改为 True,因为我们已经看到了一个我们正在尝试更新的帖子:

ExpressionAttributeValues={
         ':text': data['text'],
         ':checked': True,
         ':updatedAt': timestamp,
       },

使用 cdk deploy 重新部署堆栈。

使用 curl 测试更新 todo 项目:

$ curl -X \
PUT https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos/
58a992c6-cdb4-11e9-9a8f-9ed29c44196e \
--data '{ "text": "Learn CDK with Python by reading the PyForDevOps book"}'
{"checked": true, "createdAt": "1567451007.680936",
"text": "Learn CDK with Python by reading the PyForDevOps book",
"id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e", "updatedAt": 1567453288764}%

列出 todo 项目以验证更新:

$ curl https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/todos | jq
[
  {
    "checked": true,
    "createdAt": "1567451007.680936",
    "text": "Learn CDK with Python by reading the PyForDevOps book",
    "id": "58a992c6-cdb4-11e9-9a8f-9ed29c44196e",
    "updatedAt": 1567453288764
  }
]

下一步是为我们刚刚部署的 REST API 运行负载测试的 AWS Fargate 容器。每个容器将运行一个 Docker 镜像,该镜像使用 Taurus 测试自动化框架 运行 Molotov 负载测试工具。我们在 第五章 中介绍了 Molotov 作为一个简单而非常有用的基于 Python 的负载测试工具。

首先在名为 loadtest 的目录中创建一个运行 Taurus 和 Molotov 的 Dockerfile:

$ mkdir loadtest; cd loadtest
$ cat Dockerfile
FROM blazemeter/taurus

COPY scripts /scripts
COPY taurus.yaml /bzt-configs/

WORKDIR /bzt-configs
ENTRYPOINT ["sh", "-c", "bzt -l /tmp/artifacts/bzt.log /bzt-configs/taurus.yaml"]

Dockerfile 使用 taurus.yaml 配置文件运行 Taurus 的 bzt 命令行:

$ cat taurus.yaml
execution:
- executor: molotov
  concurrency: 10  # number of Molotov workers
  iterations: 5  # iteration limit for the test
  ramp-up: 30s
  hold-for: 5m
  scenario:
    script: /scripts/loadtest.py  # has to be valid Molotov script

在这个配置文件中,concurrency 的值设置为 10,这意味着我们正在模拟 10 个并发用户或虚拟用户(VUs)。executor 被定义为一个基于名为 loadtest.py 的脚本的 molotov 测试。以下是这个作为 Python 模块的脚本:

$ cat scripts/loadtest.py
import os
import json
import random
import molotov
from molotov import global_setup, scenario

@global_setup()
def init_test(args):
    BASE_URL=os.getenv('BASE_URL', '')
    molotov.set_var('base_url', BASE_URL)

@scenario(weight=50)
async def _test_list_todos(session):
    base_url= molotov.get_var('base_url')
    async with session.get(base_url + '/todos') as resp:
        assert resp.status == 200, resp.status

@scenario(weight=30)
async def _test_create_todo(session):
    base_url= molotov.get_var('base_url')
    todo_data = json.dumps({'text':
      'Created new todo during Taurus/molotov load test'})
    async with session.post(base_url + '/todos',
      data=todo_data) as resp:
        assert resp.status == 200

@scenario(weight=10)
async def _test_update_todo(session):
    base_url= molotov.get_var('base_url')
    # list all todos
    async with session.get(base_url + '/todos') as resp:
        res = await resp.json()
        assert resp.status == 200, resp.status
        # choose random todo and update it with PUT request
        todo_id = random.choice(res)['id']
        todo_data = json.dumps({'text':
          'Updated existing todo during Taurus/molotov load test'})
        async with session.put(base_url + '/todos/' + todo_id,
          data=todo_data) as resp:
            assert resp.status == 200

@scenario(weight=10)
async def _test_delete_todo(session):
    base_url= molotov.get_var('base_url')
    # list all todos
    async with session.get(base_url + '/todos') as resp:
        res = await resp.json()
        assert resp.status == 200, resp.status
        # choose random todo and delete it with DELETE request
        todo_id = random.choice(res)['id']
        async with session.delete(base_url + '/todos/' + todo_id) as resp:
            assert resp.status == 200

脚本有四个装饰为 scenarios 的函数,由 Molotov 运行。它们会测试 CRUD REST API 的各种端点。权重指示每个场景在整体测试持续时间中被调用的大致百分比。例如,在这个例子中,函数 _test_list_todos 将大约 50% 的时间被调用,_test_create_todo 将大约 30% 的时间被调用,而 _test_update_todo_test_delete_todo 每次将各自运行约 10% 的时间。

构建本地 Docker 镜像:

$ docker build -t cdk-loadtest .

创建本地的 artifacts 目录:

$ mkdir artifacts

运行本地 Docker 镜像,并将本地 artifacts 目录挂载为 Docker 容器内的 /tmp/artifacts

$ docker run --rm -d \
--env BASE_URL=https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod \
-v `pwd`/artifacts:/tmp/artifacts cdk-loadtest

通过检查 artifacts/molotov.out 文件来调试 Molotov 脚本。

Taurus 的结果可以通过 docker logs CONTAINER_ID 或检查文件 artifacts/bzt.log 进行检查。

通过检查 Docker 日志获得的结果:

$ docker logs -f a228f8f9a2bc
19:26:26 INFO: Taurus CLI Tool v1.13.8
19:26:26 INFO: Starting with configs: ['/bzt-configs/taurus.yaml']
19:26:26 INFO: Configuring...
19:26:26 INFO: Artifacts dir: /tmp/artifacts
19:26:26 INFO: Preparing...
19:26:27 INFO: Starting...
19:26:27 INFO: Waiting for results...
19:26:32 INFO: Changed data analysis delay to 3s
19:26:32 INFO: Current: 0 vu  1 succ  0 fail  0.546 avg rt  /
Cumulative: 0.546 avg rt, 0% failures
19:26:39 INFO: Current: 1 vu  1 succ  0 fail  1.357 avg rt  /
Cumulative: 0.904 avg rt, 0% failures
ETC
19:41:00 WARNING: Please wait for graceful shutdown...
19:41:00 INFO: Shutting down...
19:41:00 INFO: Post-processing...
19:41:03 INFO: Test duration: 0:14:33
19:41:03 INFO: Samples count: 1857, 0.00% failures
19:41:03 INFO: Average times: total 6.465, latency 0.000, connect 0.000
19:41:03 INFO: Percentiles:
+---------------+---------------+
| Percentile, % | Resp. Time, s |
+---------------+---------------+
|           0.0 |          0.13 |
|          50.0 |          1.66 |
|          90.0 |        14.384 |
|          95.0 |         26.88 |
|          99.0 |        27.168 |
|          99.9 |        27.584 |
|         100.0 |        27.792 |
+---------------+---------------+

为 Lambda 持续时间创建 CloudWatch 仪表板(Figure 13-1)以及 DynamoDB 配置和消耗的读写能力单位(Figure 13-2):

pydo 1301

Figure 13-1. Lambda 持续时间

pydo 1302

Figure 13-2. DynamoDB 配置和消耗的读写能力单位

DynamoDB 的指标显示我们的 DynamoDB 读取容量单位配置不足。这导致了延迟,特别是在 List 函数中(Lambda 执行时长图中显示为红线,达到 14.7 秒),该函数从 DynamoDB 表中检索所有 todo 项目,因此读取操作较重。我们在创建 DynamoDB 表时将配置的读取容量单位设置为 10,CloudWatch 图表显示其上升至 25。

让我们将 DynamoDB 表类型从 PROVISIONED 更改为 PAY_PER_REQUEST。在 cdk_lambda_dynamodb_fargate/cdk_lambda_dynamodb_stack.py 中进行更改:

        table = aws_dynamodb.Table(self, "Table",
            partition_key=aws_dynamodb.Attribute(
                name="id",
                type=aws_dynamodb.AttributeType.STRING),
            billing_mode = aws_dynamodb.BillingMode.PAY_PER_REQUEST)

运行 cdk deploy,然后运行本地 Docker 负载测试容器。

这次结果好多了:

+---------------+---------------+
| Percentile, % | Resp. Time, s |
+---------------+---------------+
|           0.0 |         0.136 |
|          50.0 |         0.505 |
|          90.0 |         1.296 |
|          95.0 |         1.444 |
|          99.0 |         1.806 |
|          99.9 |         2.226 |
|         100.0 |          2.86 |
+---------------+---------------+

Lambda 执行时长的图表(图 13-3)和 DynamoDB 消耗的读取和写入容量单位的图表(图 13-4)看起来也好多了。

pydo 1303

图 13-3. Lambda 执行时长

pydo 1304

图 13-4. DynamoDB 消耗的读取和写入容量单位

注意,DynamoDB 消耗的读取容量单位是由 DynamoDB 自动按需分配的,并且正在扩展以满足 Lambda 函数的增加读取请求。对读取请求贡献最大的函数是 List 函数,在 Molotov loadtest.py 脚本中通过 session.get(base_url + /todos) 在列出、更新和删除场景中调用。

接下来,我们将创建一个 Fargate CDK 栈,该栈将根据先前创建的 Docker 镜像运行容器:

$ cat cdk_lambda_dynamodb_fargate/cdk_fargate_stack.py
from aws_cdk import core
from aws_cdk import aws_ecs, aws_ec2

class FargateStack(core.Stack):
    def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
        super().__init__(scope, id, **kwargs)

        vpc = aws_ec2.Vpc(
            self, "MyVpc",
            cidr= "10.0.0.0/16",
            max_azs=3
        )
        # define an ECS cluster hosted within the requested VPC
        cluster = aws_ecs.Cluster(self, 'cluster', vpc=vpc)

        # define our task definition with a single container
        # the image is built & published from a local asset directory
        task_definition = aws_ecs.FargateTaskDefinition(self, 'LoadTestTask')
        task_definition.add_container('TaurusLoadTest',
            image=aws_ecs.ContainerImage.from_asset("loadtest"),
            environment={'BASE_URL':
            "https://k6ygy4xw24.execute-api.us-east-2.amazonaws.com/prod/"})

        # define our fargate service. TPS determines how many instances we
        # want from our task (each task produces a single TPS)
        aws_ecs.FargateService(self, 'service',
            cluster=cluster,
            task_definition=task_definition,
            desired_count=1)

FargateStack 类的代码中需要注意的几点:

  • 使用 aws_ec2.Vpc CDK 构造函数创建了一个新的 VPC。

  • 在新 VPC 中创建了一个 ECS 集群。

  • 基于 loadtest 目录中的 Dockerfile 创建了一个 Fargate 任务定义;CDK 聪明地根据此 Dockerfile 构建 Docker 镜像,然后推送到 ECR Docker 注册表。

  • 创建了一个 ECS 服务来运行基于推送到 ECR 的镜像的 Fargate 容器;desired_count 参数指定我们要运行多少个容器。

app.py 中调用 FargateStack 构造函数:

$ cat app.py
#!/usr/bin/env python3

from aws_cdk import core

from cdk_lambda_dynamodb_fargate.cdk_lambda_dynamodb_stack \
import CdkLambdaDynamodbStack
from cdk_lambda_dynamodb_fargate.cdk_fargate_stack import FargateStack

app = core.App()
app_env = {
    "region": "us-east-2",
}

CdkLambdaDynamodbStack(app, "cdk-lambda-dynamodb", env=app_env)
FargateStack(app, "cdk-fargate", env=app_env)

app.synth()

部署 cdk-fargate 栈:

$ cdk deploy cdk-fargate

转到 AWS 控制台,检查带有运行 Fargate 容器的 ECS 集群(图 13-5)。

pydo 1305

图 13-5. 带有运行中 Fargate 容器的 ECS 集群

检查 CloudWatch 仪表板,查看 Lambda 执行时长(图 13-6)和 DynamoDB 消耗的读取和写入容量单位(图 13-7),注意延迟看起来不错。

pydo 1306

图 13-6. Lambda 执行时长

pydo 1307

图 13-7. DynamoDB 消耗的读取和写入容量单位

cdk_lambda_dynamodb_fargate/cdk_fargate_stack.py 中的 Fargate 容器数量增加到 5:

       aws_ecs.FargateService(self, 'service',
           cluster=cluster,
           task_definition=task_definition,
           desired_count=5)

重新部署 cdk-fargate 栈:

$ cdk deploy cdk-fargate

检查 CloudWatch 仪表板以查看 Lambda 持续时间(图 13-8)和 DynamoDB 消耗的读写容量单位(图 13-9)。

pydo 1308

图 13-8. Lambda 持续时间

pydo 1309

图 13-9. DynamoDB 消耗的读写容量单位

由于我们现在模拟了 5 × 10 = 50 个并发用户,DynamoDB 读取容量单位和 Lambda 持续时间指标均按预期增加。

为了模拟更多用户,我们可以同时增加 taurus.yaml 配置文件中的concurrency值,并增加 Fargate 容器的desired_count。在这两个值之间,我们可以轻松增加对 REST API 端点的负载。

删除 CDK 堆栈:

$ cdk destroy cdk-fargate
$ cdk destroy cdk-lambda-dynamodb

值得注意的是,我们部署的无服务器架构(API Gateway + 五个 Lambda 函数 + DynamoDB 表)非常适合我们的 CRUD REST API 应用程序。我们还遵循了最佳实践,并通过 AWS CDK 使用 Python 代码定义了所有基础设施。

练习

  • 在 Google 的 CaaS 平台上运行一个简单的 HTTP 端点:Cloud Run

  • 在我们提到的基于 Kubernetes 的其他 FaaS 平台上运行一个简单的 HTTP 端点:Kubeless, Fn ProjectFission

  • 在生产级 Kubernetes 集群(如 Amazon EKS、Google GKE 或 Azure AKS)中安装和配置Apache OpenWhisk

  • 将 AWS 的 REST API 示例移植到 GCP 和 Azure。GCP 提供 Cloud Endpoints 来管理多个 API。类似地,Azure 提供 API Management

第十四章:机器学习运维与机器学习工程

2020 年最热门的职位之一是机器学习工程师。其他热门职位包括数据工程师、数据科学家和机器学习科学家。尽管您可以成为 DevOps 专家,但 DevOps 是一种行为,DevOps 的原则可以应用于任何软件项目,包括机器学习。让我们看看一些核心的 DevOps 最佳实践:持续集成、持续交付、微服务、基础设施即代码、监控与日志记录、以及沟通与协作。这些中哪个不适用于机器学习?

软件工程项目越复杂,而机器学习就更复杂,您就越需要 DevOps 原则。有比 API 做机器学习预测更好的微服务示例吗?在本章中,让我们深入探讨如何使用 DevOps 思维以专业且可重复的方式进行机器学习。

什么是机器学习?

机器学习是一种利用算法自动从数据中学习的方法。主要有四种类型:监督学习、半监督学习、无监督学习和强化学习。

监督学习

在监督学习中,已知并标记了正确答案。例如,如果您想要从体重预测身高,可以收集人们身高和体重的示例。身高将是目标,体重将是特征。

让我们看一个监督学习的示例:

摄入

**输入[0]:**

import pandas as pd

**输入[7]:**

df = pd.read_csv(
  "https://raw.githubusercontent.com/noahgift/\
 regression-concepts/master/\
 height-weight-25k.csv")
df.head()

**输出[7]:**

索引 身高-英寸 体重-磅
0 1 65.78331 112.9925
1 2 71.51521 136.4873
2 3 69.39874 153.0269
3 4 68.21660 142.3354
4 5 67.78781 144.2971

探索性数据分析(EDA)

让我们看看数据,看看可以探索什么。

散点图

在这个示例中,使用了 Python 中流行的绘图库 seaborn 来可视化数据集。如果需要安装它,您可以在笔记本中使用!pip install seaborn进行安装。您还可以在部分中使用!pip install <包名称>安装任何其他库。如果您使用的是 Colab 笔记本,则这些库已经为您安装好。查看身高/体重 lm 图(图 14-1)。

**输入[0]:**

import seaborn as sns
import numpy as np

**输入[9]:**

sns.lmplot("Height-Inches", "Weight-Pounds", data=df)

pydo 1401

图 14-1 身高/体重 lm 图

描述性统计

接下来,可以生成一些描述性统计。

**输入[10]:**

df.describe()

**输出[10]:**

索引 身高-英寸 体重-磅
数量 25000.000000 25000.000000 25000.000000
均值 12500.500000 67.993114 127.079421
标准差 7217.022701 1.901679 11.660898
最小值 1.000000 60.278360 78.014760
25% 6250.750000 66.704397 119.308675
50% 12500.500000 67.995700 127.157750
75% 18750.250000 69.272958 134.892850
max 25000.000000 75.152800 170.924000

核密度分布

密度图的分布(图 14-2)显示了两个变量之间的关系。

**In[11]:**

sns.jointplot("Height-Inches", "Weight-Pounds", data=df, kind="kde")

**Out[11]:**

pydo 1402

图 14-2. 密度图

建模

现在让我们来审查建模过程。机器学习建模是指算法从数据中学习的过程。总体思路是利用历史数据来预测未来数据。

Sklearn 回归模型

首先,数据被提取为特征和目标,然后被分割为训练集和测试集。这允许测试集被保留以测试训练模型的准确性。

**In[0]:**

from sklearn.model_selection import train_test_split

提取并检查特征和目标

建议明确提取目标和特征变量,并在一个单元格中重塑它们。然后,您会想要检查形状,以确保它是适合使用 Sklearn 进行机器学习的正确维度。

**In[0]:**

y = df['Weight-Pounds'].values #Target
y = y.reshape(-1, 1)
X = df['Height-Inches'].values #Feature(s)
X = X.reshape(-1, 1)

**In[14]:**

y.shape

**Out[14]:**

(25000, 1)

分割数据

数据被分为 80%/20% 的比例。

**In[15]:**

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
print(X_train.shape, y_train.shape)
print(X_test.shape, y_test.shape)

**Out[15]:**

(20000, 1) (20000, 1)
(5000, 1) (5000, 1)

拟合模型

现在模型使用从 Sklearn 导入的 LinearRegression 算法进行拟合。

**In[0]:**

from sklearn.linear_model import LinearRegression
lm = LinearRegression()
model = lm.fit(X_train, y_train)
y_predicted = lm.predict(X_test)

打印线性回归模型的准确性

现在训练好的模型可以展示在预测新数据时的准确性。这是通过计算预测数据与测试数据的 RMSE 或均方根误差来完成的。

**In[18]:**

from sklearn.metrics import mean_squared_error
from math import sqrt

#RMSE Root Mean Squared Error
rms = sqrt(mean_squared_error(y_predicted, y_test))
rms

**Out[18]:**

10.282608230082417

绘制预测的身高与实际身高

现在让我们绘制预测的身高与实际身高(图 14-3)的图表,以查看该模型在预测中的表现如何。

**In[19]:**

import matplotlib.pyplot as plt
_, ax = plt.subplots()

ax.scatter(x = range(0, y_test.size), y=y_test, c = 'blue', label = 'Actual',
  alpha = 0.5)
ax.scatter(x = range(0, y_predicted.size), y=y_predicted, c = 'red',
  label = 'Predicted', alpha = 0.5)

plt.title('Actual Height vs Predicted Height')
plt.xlabel('Weight')
plt.ylabel('Height')
plt.legend()
plt.show()

pydo 1403

图 14-3. 预测的身高与实际身高

这是一个创建机器学习模型的非常简单但又强大的实际工作流示例。

Python 机器学习生态系统

让我们快速了解一下 Python 机器学习生态系统(图 14-4)。

主要有四个领域:深度学习、sklearn、AutoML 和 Spark。在深度学习领域,最流行的框架依次是 TensorFlow/Keras、PyTorch 和 MXNet。Google 赞助 TensorFlow,Facebook 赞助 PyTorch,而 MXNet 来自亚马逊。您将会看到亚马逊 SageMaker 经常提到 MXNet。需要注意的是,这些深度学习框架针对 GPU 进行优化,使其性能比 CPU 目标提升多达 50 倍。

pydo 1404

图 14-4. Python 机器学习生态系统

Sklearn 生态系统通常在同一个项目中同时使用 Pandas 和 Numpy。Sklearn 故意不针对 GPU,但是有一个名为 Numba 的项目专门针对 GPU(包括 NVIDIA 和 AMD)。

在 AutoML 中,Uber 的 Ludwig 和 H20 的 H20 AutoML 是两个领先的工具。两者都可以节省开发机器学习模型的时间,并且还可以优化现有的机器学习模型。

最后,有 Spark 生态系统,它建立在 Hadoop 的基础上。Spark 可以针对 GPU 和 CPU 进行优化,并通过多个平台实现:Amazon EMR、Databricks、GCP Dataproc 等。

使用 PyTorch 进行深度学习

现在定义了使用 Python 进行机器学习的生态系统,让我们看看如何将简单线性回归示例移植到 PyTorch,并在 CUDA GPU 上运行它。获取 NVIDIA GPU 的一个简单方法是使用 Colab 笔记本。Colab 笔记本是由 Google 托管的与 Jupyter 兼容的笔记本,用户可以免费访问 GPU 和张量处理单元(TPU)。您可以在GPU 中运行此代码

使用 PyTorch 进行回归

首先,将数据转换为float32

**In[0]:**

# Training Data
x_train = np.array(X_train, dtype=np.float32)
x_train = x_train.reshape(-1, 1)
y_train = np.array(y_train, dtype=np.float32)
y_train = y_train.reshape(-1, 1)

# Test Data
x_test = np.array(X_test, dtype=np.float32)
x_test = x_test.reshape(-1, 1)
y_test = np.array(y_test, dtype=np.float32)
y_test = y_test.reshape(-1, 1)

请注意,如果您不使用 Colab 笔记本,可能需要安装 PyTorch。此外,如果您使用 Colab 笔记本,您可以拥有 NVIDIA GPU 并运行此代码。如果您不使用 Colab,则需要在具有 GPU 的平台上运行。

**In[0]:**

import torch
from torch.autograd import Variable

class linearRegression(torch.nn.Module):
    def __init__(self, inputSize, outputSize):
        super(linearRegression, self).__init__()
        self.linear = torch.nn.Linear(inputSize, outputSize)

    def forward(self, x):
        out = self.linear(x)
        return out

现在创建一个启用 CUDA 的模型(假设您在 Colab 上或者有 NVIDIA GPU 的机器上运行)。

**In[0]:**

inputDim = 1        # takes variable 'x'
outputDim = 1       # takes variable 'y'
learningRate = 0.0001
epochs = 1000

model = linearRegression(inputDim, outputDim)
model.cuda()

**Out[0]:**

linearRegression(
  (linear): Linear(in_features=1, out_features=1, bias=True)
)

创建随机梯度下降和损失函数。

**In[0]:**

criterion = torch.nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=learningRate)

现在训练模型。

**In[0]:**

for epoch in range(epochs):
    inputs = Variable(torch.from_numpy(x_train).cuda())
    labels = Variable(torch.from_numpy(y_train).cuda())
    optimizer.zero_grad()
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    print(loss)
    # get gradients w.r.t to parameters
    loss.backward()
    # update parameters
    optimizer.step()
    print('epoch {}, loss {}'.format(epoch, loss.item()))

为了节省空间,输出被抑制了超过 1000 次运行。

**Out[0]:**

tensor(29221.6543, device='cuda:0', grad_fn=<MseLossBackward>)
epoch 0, loss 29221.654296875
tensor(266.7252, device='cuda:0', grad_fn=<MseLossBackward>)
epoch 1, loss 266.72515869140625
tensor(106.6842, device='cuda:0', grad_fn=<MseLossBackward>)
epoch 2, loss 106.6842269897461
....output suppressed....
epoch 998, loss 105.7930908203125
tensor(105.7931, device='cuda:0', grad_fn=<MseLossBackward>)
epoch 999, loss 105.7930908203125

绘制预测高度与实际高度

现在让我们绘制预测高度与实际高度的图示(图 14-5),就像简单模型一样。

**In[0]:**

with torch.no_grad():
    predicted = model(Variable(torch.from_numpy(x_test).cuda())).cpu().\
      data.numpy()
    print(predicted)

plt.clf()
plt.plot(x_test, y_test, 'go', label='Actual Height', alpha=0.5)
plt.plot(x_test, predicted, '--', label='Predicted Height', alpha=0.5)
plt.legend(loc='best')
plt.show()

pydo 1405

图 14-5. 预测高度与实际高度

打印 RMSE

最后,让我们打印出 RMSE 并进行比较。

**In[0]:**

#RMSE Root Mean Squared Error
rms = sqrt(mean_squared_error(x_test, predicted))
rms

**Out[0]:**

59.19054613663507

深度学习确实需要更多的代码,但其概念与 sklearn 模型相同。这里的重要一点是 GPU 正成为生产流水线中不可或缺的一部分。即使您自己不进行深度学习,了解构建基于 GPU 的机器学习模型的过程也是有帮助的。

云机器学习平台

机器学习中一个普遍存在的方面是基于云的机器学习平台。Google 提供了 GCP AI 平台(图 14-6)。

pydo 1406

图 14-6. GCP AI 平台

GCP 平台具有许多高级自动化组件,从数据准备到数据标记。AWS 平台提供 Amazon SageMaker(图 14-7)。

pydo 1407

图 14-7. Amazon SageMaker

SageMaker 还具有许多高级组件,包括在 spot 实例上训练和弹性预测端点。

机器学习成熟度模型

现在面临的一个重大挑战之一是意识到希望进行机器学习的公司需要进行变革。机器学习成熟度模型图表(图 14-8)展示了一些挑战和机会。

pydo 1408

图 14-8. 机器学习成熟度模型

机器学习关键术语

让我们定义一些关键的机器学习术语,这将在本章的其余部分中非常有帮助:

机器学习

基于样本或训练数据建立数学模型的一种方法。

模型

这是机器学习应用中的产品。一个简单的例子是线性方程,即预测 X 和 Y 之间关系的直线。

特征

特征是电子表格中用作信号以创建机器学习模型的列。一个很好的例子是 NBA 球队每场比赛得分。

目标

目标是电子表格中你试图预测的列。一个很好的例子是一支 NBA 球队赛季获胜场次。

超大规模机器学习

这是根据已知的正确历史值预测未来值的机器学习类型。一个很好的例子是使用每场比赛得分特征来预测 NBA 赛季中的胜利次数。

无监督机器学习

这是一种处理未标记数据的机器学习类型。它不是预测未来值,而是通过工具如聚类来发现隐藏的模式,进而可以用作标签。一个很好的例子是创建具有类似得分、篮板、盖帽和助攻的 NBA 球员集群。一个集群可以称为“高个子顶级球员”,另一个集群可以称为“得分后卫”。

深度学习

这是一种使用人工神经网络进行监督或无监督机器学习的方法。深度学习最流行的框架是 Google 的 TensorFlow。

Scikit-learn

这是 Python 中最流行的机器学习框架之一。

熊猫

这是用于进行数据处理和分析的最流行的库之一。它与 scikit-learn 和 Numpy 配合良好。

Numpy

这是进行低级科学计算的主要 Python 库。它支持大型多维数组,并拥有大量的高级数学函数。它广泛地与 scikit-learn、Pandas 和 TensorFlow 一起使用。

第 1 级:构架、范围识别和问题定义

让我们先来看看第一层。在公司实施机器学习时,重要的是考虑需要解决的问题以及如何构架这些问题。机器学习项目失败的一个关键原因是组织在开始之前没有先提出问题。

一个很好的类比是为旧金山的一家餐馆连锁店建立移动应用程序。一个天真的方法是立即开始构建本地 iOS 和本地 Android 应用程序(使用两个开发团队)。一个典型的移动团队可能是每个应用程序三名全职开发人员。所以这意味着每个开发人员大约 20 万美元,需要聘请六名开发人员。项目的运行成本现在约为每年 120 万美元。移动应用程序每年能带来超过 120 万美元的收入吗?如果不能,是否有更简单的替代方案?也许使用现有公司的网络开发人员的移动优化 Web 应用程序会是一个更好的选择。

那么,与一家专门从事食品配送的公司合作并完全外包这项任务呢?这种方法的利弊是什么?同样的思维过程可以和应该应用于机器学习和数据科学倡议。例如,你的公司是否需要雇佣六名年薪 50 万美元的博士级机器学习研究员?还有什么替代方案?对于机器学习来说,一点点范围界定和问题定义可以大大增加成功的机会。

Level 2:数据的持续交付

文明的基础之一是运行水。罗马的高架渠道早在公元前 312 年就为城市提供了数英里的水源。运行水使得城市成功所必需的基础设施得以实现。据联合国儿童基金会估计,全球 2018 年,妇女和女童每天花费约 2 亿小时来取水。这是一个巨大的机会成本;时间不足以学习、照顾孩子、工作或放松。

一个流行的说法是“软件正在吞噬世界”。与此相关的是,未来每家公司都需要有一个机器学习和人工智能战略。其中一部分战略是更加认真地思考数据的持续交付。就像运行水一样,“运行数据”每天为您节省数小时的时间。数据湖的一个潜在解决方案如图 14-9 所示。

pydo 1409

图 14-9. AWS 数据湖

乍看之下,数据湖可能看起来像是在寻找问题的解决方案,或者过于简单而无法做任何有用的事情。但是让我们来看看它解决的一些问题:

  • 您可以在不移动数据的情况下处理数据。

  • 存储数据是便宜的。

  • 创建存档数据的生命周期策略是直接的。

  • 创建安全并审核数据的生命周期策略是直接的。

  • 生产系统与数据处理分离。

  • 它可以拥有几乎无限和弹性的存储和磁盘 I/O。

这种架构的替代方案通常是一种临时混乱的等同于步行四小时到井边取水然后返回的混乱状态。安全性在数据湖架构中也是一个主要因素,就像在供水中一样。通过集中数据存储和交付的架构,防止和监控数据违规变得更加简单明了。以下是一些可能有助于防止未来数据违规的想法:

  • 你的数据是否处于静态加密状态?如果是,谁拥有解密密钥?解密事件是否被记录和审计?

  • 你的数据是否在离开你的网络时被记录和审计?例如,将整个客户数据库移出网络何时是一个好主意?为什么这不受监控和审计?

  • 你进行定期数据安全审计吗?为什么不?

  • 你是否存储个人身份信息(PII)?为什么?

  • 你是否监控关键生产事件的监控?你为什么不监控数据安全事件?

我们为什么要让数据在内部网络之外流动?如果我们设计关键数据为字面上无法在主机网络之外传输的方形销钉,例如核发射代码,会怎样?让数据无法移出环境似乎是防止这些违规行为的一种可行方式。如果外部网络本身只能传输“圆销钉”数据包会怎样?此外,这可能是提供此类安全数据湖云的一个很好的“锁定”功能。

Level 3: 持续交付干净的数据

希望你能接受持续交付数据背后的理念,以及对公司的机器学习计划的成功有多么重要。持续交付数据的一个巨大改进是持续交付干净的数据。为什么要费力交付一团糟的数据?弗林特市密歇根州最近出现的污水问题让人联想起。大约在 2014 年,弗林特将水源从休伦湖和底特律河改为弗林特河。官员未能施用腐蚀抑制剂,导致老化管道中的铅渗漏到饮水供应中。这也可能导致外部水源变更引发的瘟疫,导致 12 人死亡,另有 87 人患病。

最早的数据科学成功故事之一涉及 1849-1854 年的脏水。约翰·斯诺能够使用数据可视化来识别霍乱病例的聚类(图 14-10)。这导致发现了爆发的根本原因。污水直接被泵入饮用水供应中!

pydo 1410

图 14-10. 霍乱病例聚类

考虑以下观察:

  • 为什么数据不会自动处理以“清洁”它?

  • 你能够可视化具有“污水”的数据管道的部分吗?

  • 公司花费了多少时间在可以完全自动化的数据清洗任务上?

Level 4: 持续交付探索性数据分析

如果你对数据科学的唯一看法是 Kaggle 项目,可能会觉得数据科学的主要目标是生成尽可能精确的预测。然而,数据科学和机器学习远不止于此。数据科学是一个多学科领域,有几种不同的视角。其中一种视角是关注因果关系。是什么潜在特征推动了模型?你能解释模型如何得出其预测吗?几个 Python 库在这方面提供了帮助:ELI5、SHAP 和 LIME。它们都致力于帮助解释机器学习模型的真实运行方式。

预测的世界观不太关注如何达到答案,而更关注预测是否准确。在一个云原生、大数据的世界中,这种方法具有优点。某些机器学习问题使用大量数据表现良好,例如使用深度学习进行图像识别。你拥有的数据和计算能力越多,你的预测准确性就会越好。

你的产品已经投入使用了吗?为什么没有?如果你建立机器学习模型,而它们没有被使用,那你建模的目的是什么?

你不知道什么?通过观察数据,你能学到什么?通常,数据科学更感兴趣的是过程而不是结果。如果你只关注预测,那么你可能会错过数据的完全不同的视角。

Level 5:传统机器学习和 AutoML 的持续交付

抗拒自动化如同人类历史的常态。卢德运动是英国纺织工人的秘密组织,他们在 1811 年到 1816 年间摧毁纺织机器,作为抗议的形式。最终,抗议者被击毙,反叛被法律和军事力量镇压,进步的道路依然前行。

如果你审视人类的历史,那些自动化取代曾由人类执行的任务的工具正在不断发展。在技术性失业中,低技能工人被取代,而高技能工人的薪水增加。一个例子是系统管理员与 DevOps 专业人员之间的对比。是的,一些系统管理员失去了工作,例如那些专注于数据中心更换硬盘等任务的工人,但是新的、薪水更高的工作,比如云架构师,也随之出现。

在机器学习和数据科学的职位招聘中,年薪通常在三十万到一百万美元之间,并不少包含着许多基本的商业规则:调整超参数,删除空值,以及将作业分发至集群。我提出的自动化定律(automator’s law)说:“如果你谈论自动化某事,它最终将会被自动化。” 关于自动机器学习(AutoML)有很多讨论,因此机器学习的大部分内容将不可避免地被自动化。

这意味着,就像其他自动化示例一样,工作的性质将发生变化。一些工作将变得更加熟练(想象一下能够每天训练数千个机器学习模型的人),而一些工作将因为机器能够做得更好而自动化(比如调整 JSON 数据结构中数值的工作,即调优超参数)。

第 6 级别:ML 操作反馈循环

为什么要开发移动应用?想必是为了让移动设备上的用户使用你的应用。那么机器学习呢?特别是与数据科学或统计学相比,机器学习的目的是创建模型并预测某些事物。如果模型没有投入生产,那它到底在做什么呢?

此外,将模型推送到生产环境是学习更多的机会。当模型真正部署到生产环境时,它是否能够准确预测新数据的情况?模型是否如预期地对用户产生影响,比如增加购买或在网站上停留的时间?只有在模型实际部署到生产环境时,才能获得这些宝贵的见解。

另一个重要的问题是可伸缩性和可重复性。一个真正技术成熟的组织可以按需部署软件,包括机器学习模型。在这里,机器学习模型的 DevOps 最佳实践同样重要:持续部署、微服务、监控和仪表化。

将更多这种技术成熟性注入到您的组织中的一个简单方法是,应用与选择云计算而不是物理数据中心相同的逻辑。租用他人的专业知识并利用规模经济。

Sklearn Flask 与 Kubernetes 和 Docker

让我们通过 Docker 和 Kubernetes 实现基于 sklearn 的机器学习模型的真实部署。

这是一个 Dockerfile。注意,它提供了一个 Flask 应用程序。Flask 应用程序将托管 sklearn 应用程序。请注意,您可能希望安装 Hadolint,这允许您对 Dockerfile 进行代码检查:https://github.com/hadolint/hadolint

FROM python:3.7.3-stretch

# Working Directory
WORKDIR /app

# Copy source code to working directory
COPY . app.py /app/

# Install packages from requirements.txt
# hadolint ignore=DL3013
RUN pip install --upgrade pip &&\
    pip install --trusted-host pypi.python.org -r requirements.txt

# Expose port 80
EXPOSE 80

# Run app.py at container launch
CMD ["python", "app.py"]

这是 Makefile,它作为应用程序运行时的中心点:

setup:
  python3 -m venv ~/.python-devops

install:
  pip install --upgrade pip &&\
    pip install -r requirements.txt

test:
  #python -m pytest -vv --cov=myrepolib tests/*.py
  #python -m pytest --nbval notebook.ipynb

lint:
  hadolint Dockerfile
  pylint --disable=R,C,W1203 app.py

all: install lint test

这是 requirements.txt 文件:

Flask==1.0.2
pandas==0.24.2
scikit-learn==0.20.3

这是 app.py 文件:

from flask import Flask, request, jsonify
from flask.logging import create_logger
import logging

import pandas as pd
from sklearn.externals import joblib
from sklearn.preprocessing import StandardScaler

app = Flask(__name__)
LOG = create_logger(app)
LOG.setLevel(logging.INFO)

def scale(payload):
    """Scales Payload"""

    LOG.info(f"Scaling Payload: {payload}")
    scaler = StandardScaler().fit(payload)
    scaled_adhoc_predict = scaler.transform(payload)
    return scaled_adhoc_predict

@app.route("/")
def home():
    html = "<h3>Sklearn Prediction Home</h3>"
    return html.format(format)

# TO DO:  Log out the prediction value
@app.route("/predict", methods=['POST'])
def predict():
    """Performs an sklearn prediction

 input looks like:
 {
 "CHAS":{
 "0":0
 },
 "RM":{
 "0":6.575
 },
 "TAX":{
 "0":296.0
 },
 "PTRATIO":{
 "0":15.3
 },
 "B":{
 "0":396.9
 },
 "LSTAT":{
 "0":4.98
 }

 result looks like:
 { "prediction": [ 20.35373177134412 ] }

 """

    json_payload = request.json
    LOG.info(f"JSON payload: {json_payload}")
    inference_payload = pd.DataFrame(json_payload)
    LOG.info(f"inference payload DataFrame: {inference_payload}")
    scaled_payload = scale(inference_payload)
    prediction = list(clf.predict(scaled_payload))
    return jsonify({'prediction': prediction})

if __name__ == "__main__":
    clf = joblib.load("boston_housing_prediction.joblib")
    app.run(host='0.0.0.0', port=80, debug=True)

这是 run_docker.sh 文件:

#!/usr/bin/env bash

# Build image
docker build --tag=flasksklearn .

# List docker images
docker image ls

# Run flask app
docker run -p 8000:80 flasksklearn

这是 run_kubernetes.sh 文件:

#!/usr/bin/env bash

dockerpath="noahgift/flasksklearn"

# Run in Docker Hub container with kubernetes
kubectl run flaskskearlndemo\
    --generator=run-pod/v1\
    --image=$dockerpath\
    --port=80 --labels app=flaskskearlndemo

# List kubernetes pods
kubectl get pods

# Forward the container port to host
kubectl port-forward flaskskearlndemo 8000:80
#!/usr/bin/env bash
# This tags and uploads an image to Docker Hub

#Assumes this is built
#docker build --tag=flasksklearn .

dockerpath="noahgift/flasksklearn"

# Authenticate & Tag
echo "Docker ID and Image: $dockerpath"
docker login &&\
    docker image tag flasksklearn $dockerpath

# Push Image
docker image push $dockerpath

Sklearn Flask 与 Kubernetes 和 Docker

您可能会问自己模型是如何创建然后“泡菜”出来的。您可以在此处查看 整个笔记本

首先,导入一些机器学习的库:

import numpy
from numpy import arange
from matplotlib import pyplot
import seaborn as sns
import pandas as pd
from pandas import read_csv
from pandas import set_option
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_val_score
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import LinearRegression
from sklearn.linear_model import Lasso
from sklearn.linear_model import ElasticNet
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.svm import SVR
from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestRegressor
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.ensemble import ExtraTreesRegressor
from sklearn.ensemble import AdaBoostRegressor
from sklearn.metrics import mean_squared_error

**In[0]:**

boston_housing = "https://raw.githubusercontent.com/\
noahgift/boston_housing_pickle/master/housing.csv"
names = ['CRIM', 'ZN', 'INDUS', 'CHAS',
'NOX', 'RM', 'AGE', 'DIS', 'RAD', 'TAX',
 'PTRATIO', 'B', 'LSTAT', 'MEDV']
df = read_csv(boston_housing,
  delim_whitespace=True, names=names)

**In[0]:**

df.head()

**Out[0]:**

CRIM ZN INDUS CHAS NOX RM AGE
0 0.00632 18.0 2.31 0 0.538 6.575 65.2
1 0.02731 0.0 7.07 0 0.469 6.421 78.9
2 0.02729 0.0 7.07 0 0.469 7.185 61.1
3 0.03237 0.0 2.18 0 0.458 6.998 45.8
4 0.06905 0.0 2.18 0 0.458 7.147 54.2
DIS RAD TAX PTRATIO B LSTAT MEDV
--- --- --- --- --- --- --- ---
0 4.0900 1 296.0 15.3 396.90 4.98 24.0
1 4.9671 2 242.0 17.8 396.90 9.14 21.6
2 4.9671 2 242.0 17.8 392.83 4.03 34.7
3 6.0622 3 222.0 18.7 394.63 2.94 33.4
4 6.0622 3 222.0 18.7 396.90 5.33 36.2

EDA

这些是模型的特征:

房价中位数

查尔斯河虚拟变量(1 表示地段与河流接壤;0 表示否)

RM

每个住宅的平均房间数

TAX

完整价值的房产税率每$10,000

PTRATIO

每个住宅的平均房间数

Bk

镇上的师生比

LSTAT

% 人口低地位

MEDV

住户的房屋中位数价格(以千美元为单位)

**In[0]:**

prices = df['MEDV']
df = df.drop(['CRIM','ZN','INDUS','NOX','AGE','DIS','RAD'], axis = 1)
features = df.drop('MEDV', axis = 1)
df.head()

**Out[0]:**

CHAS RM TAX PTRATIO B LSTAT MEDV
0 0 6.575 296.0 15.3 396.90 4.98 24.0
1 0 6.421 242.0 17.8 396.90 9.14 21.6
2 0 7.185 242.0 17.8 392.83 4.03 34.7
3 0 6.998 222.0 18.7 394.63 2.94 33.4
4 0 7.147 222.0 18.7 396.90 5.33 36.2

建模

这是笔记本中建模的地方。一个有用的策略是始终创建四个主要部分的笔记本:

  • 摄取

  • EDA

  • 建模

  • 结论

在这个建模部分,数据从 DataFrame 中提取并传递到 sklearn 的train_test_split模块中,该模块用于将数据拆分为训练和验证数据。

拆分数据

**In[0]:**

# Split-out validation dataset
array = df.values
X = array[:,0:6]
Y = array[:,6]
validation_size = 0.20
seed = 7
X_train, X_validation, Y_train, Y_validation = train_test_split(X, Y,
  test_size=validation_size, random_state=seed)

**In[0]:**

for sample in list(X_validation)[0:2]:
    print(f"X_validation {sample}")

**Out[0]:**

X_validation [  1.      6.395 666.     20.2   391.34   13.27 ]
X_validation [  0.      5.895 224.     20.2   394.81   10.56 ]

调整缩放的 GBM

这个模型使用了几种在许多成功的 Kaggle 项目中可以参考的高级技术。这些技术包括 GridSearch,可以帮助找到最佳的超参数。注意,数据的缩放也是必要的,大多数机器学习算法都期望进行某种类型的缩放以生成准确的预测。

**In[0]:**

# Test options and evaluation metric using Root Mean Square error method
num_folds = 10
seed = 7
RMS = 'neg_mean_squared_error'
scaler = StandardScaler().fit(X_train)
rescaledX = scaler.transform(X_train)
param_grid = dict(n_estimators=numpy.array([50,100,150,200,250,300,350,400]))
model = GradientBoostingRegressor(random_state=seed)
kfold = KFold(n_splits=num_folds, random_state=seed)
grid = GridSearchCV(estimator=model, param_grid=param_grid, scoring=RMS, cv=kfold)
grid_result = grid.fit(rescaledX, Y_train)

print("Best: %f using %s" % (grid_result.best_score_, grid_result.best_params_))
means = grid_result.cv_results_['mean_test_score']
stds = grid_result.cv_results_['std_test_score']
params = grid_result.cv_results_['params']
for mean, stdev, param in zip(means, stds, params):
    print("%f (%f) with: %r" % (mean, stdev, param))

**Out[0]:**

Best: -11.830068 using {'n_estimators': 200}
-12.479635 (6.348297) with: {'n_estimators': 50}
-12.102737 (6.441597) with: {'n_estimators': 100}
-11.843649 (6.631569) with: {'n_estimators': 150}
-11.830068 (6.559724) with: {'n_estimators': 200}
-11.879805 (6.512414) with: {'n_estimators': 250}
-11.895362 (6.487726) with: {'n_estimators': 300}
-12.008611 (6.468623) with: {'n_estimators': 350}
-12.053759 (6.453899) with: {'n_estimators': 400}

/usr/local/lib/python3.6/dist-packages/sklearn/model_selection/_search.py:841:
DeprecationWarning:
DeprecationWarning)

拟合模型

此模型使用 GradientBoostingRegressor 进行拟合。培训模型后的最后一步是拟合模型并使用设置的数据检查错误。这些数据被缩放并传递到模型中,使用“均方误差”指标评估准确性。

**In[0]:**

# prepare the model
scaler = StandardScaler().fit(X_train)
rescaledX = scaler.transform(X_train)
model = GradientBoostingRegressor(random_state=seed, n_estimators=400)
model.fit(rescaledX, Y_train)
# transform the validation dataset
rescaledValidationX = scaler.transform(X_validation)
predictions = model.predict(rescaledValidationX)
print("Mean Squared Error: \n")
print(mean_squared_error(Y_validation, predictions))

**Out[0]:**

Mean Squared Error:

26.326748591395717

评估

机器学习中较为棘手的一个方面是模型评估。这个示例展示了如何将预测和原始房屋价格添加到同一个 DataFrame 中。该 DataFrame 可用于计算差异。

**In[0]:**

predictions=predictions.astype(int)
evaluate = pd.DataFrame({
        "Org House Price": Y_validation,
        "Pred House Price": predictions
    })
evaluate["difference"] = evaluate["Org House Price"]-evaluate["Pred House Price"]
evaluate.head()

差异显示在这里。

**Out[0]:**

原房价 预测房价 差异
0 21.7 21 0.7
1 18.5 19 -0.5
2 22.2 20 2.2
3 20.4 19 1.4
4 8.8 9 -0.2

使用 Pandas 的 describe 方法是查看数据分布的好方法。

**In[0]:**

evaluate.describe()

**Out[0]:**

原始房价 预测房价 差异
count 102.000000 102.000000 102.000000
mean 22.573529 22.117647 0.455882
std 9.033622 8.758921 5.154438
min 6.300000 8.000000 -34.100000
25% 17.350000 17.000000 -0.800000
50% 21.800000 20.500000 0.600000
75% 24.800000 25.000000 2.200000
max 50.000000 56.000000 22.000000

adhoc_predict

让我们测试这个预测模型,看看在反序列化后的工作流程会是什么样子。当为机器学习模型开发 Web API 时,在笔记本中测试 API 将会很有帮助。在实际笔记本中调试和创建函数比在 Web 应用中努力创建正确的函数要容易得多。

**In[0]:**

actual_sample = df.head(1)
actual_sample

**Out[0]:**

CHAS RM TAX PTRATIO B LSTAT MEDV
0 0 6.575 296.0 15.3 396.9 4.98 24.0

**In[0]:**

adhoc_predict = actual_sample[["CHAS", "RM", "TAX", "PTRATIO", "B", "LSTAT"]]
adhoc_predict.head()

**Out[0]:**

CHAS RM TAX PTRATIO B LSTAT
0 0 6.575 296.0 15.3 396.9 4.98

JSON 工作流

这是笔记本中对调试 Flask 应用程序有用的一节。正如前面提到的,开发 API 代码在机器学习项目中更加直接,确保其有效,然后将该代码传输到脚本中。另一种方法是尝试在没有 Jupyter 提供的相同交互式工具的软件项目中获取准确的代码语法。

**In[0]:**

json_payload = adhoc_predict.to_json()
json_payload

**Out[0]:**

{"CHAS":{"0":0},"RM":
{"0":6.575},"TAX":
{"0":296.0},"PTRATIO":
{"0":15.3},"B":{"0":396.9},"LSTAT":
{"0":4.98}}

尺度输入

需要对数据进行缩放才能进行预测。这个工作流需要在笔记本中详细说明,而不是在网页应用中苦苦挣扎,这样调试将会更加困难。下面的部分展示了解决机器学习预测流程中这一部分的代码。然后可以将其用于在 Flask 应用程序中创建函数。

**In[0]:**

scaler = StandardScaler().fit(adhoc_predict)
scaled_adhoc_predict = scaler.transform(adhoc_predict)
scaled_adhoc_predict

**Out[0]:**

array([[0., 0., 0., 0., 0., 0.]])

**In[0]:**

list(model.predict(scaled_adhoc_predict))

**Out[0]:**

[20.35373177134412]

Pickling sklearn

接下来,我们来导出这个模型。

**In[0]:**

from sklearn.externals import joblib

**In[0]:**

joblib.dump(model, 'boston_housing_prediction.joblib')

**Out[0]:**

['boston_housing_prediction.joblib']

**In[0]:**

!ls -l

**Out[0]:**

total 672
-rw-r--r-- 1 root root 681425 May  5 00:35 boston_housing_prediction.joblib
drwxr-xr-x 1 root root   4096 Apr 29 16:32 sample_data

解析并预测

**In[0]:**

clf = joblib.load('boston_housing_prediction.joblib')

从 Pickle 中 adhoc_predict

**In[0]:**

actual_sample2 = df.head(5)
actual_sample2

**Out[0]:**

CHAS RM TAX PTRATIO B LSTAT MEDV
0 0 6.575 296.0 15.3 396.90 4.98 24.0
1 0 6.421 242.0 17.8 396.90 9.14 21.6
2 0 7.185 242.0 17.8 392.83 4.03 34.7
3 0 6.998 222.0 18.7 394.63 2.94 33.4
4 0 7.147 222.0 18.7 396.90 5.33 36.2

**In[0]:**

adhoc_predict2 = actual_sample[["CHAS", "RM", "TAX", "PTRATIO", "B", "LSTAT"]]
adhoc_predict2.head()

**Out[0]:**

CHAS RM TAX PTRATIO B LSTAT
0 0 6.575 296.0 15.3 396.9 4.98

缩放输入

**In[0]:**

scaler = StandardScaler().fit(adhoc_predict2)
scaled_adhoc_predict2 = scaler.transform(adhoc_predict2)
scaled_adhoc_predict2

**Out[0]:**

array([[0., 0., 0., 0., 0., 0.]])

**In[0]:**

# Use pickle loaded model
list(clf.predict(scaled_adhoc_predict2))

**Out[0]:**

[20.35373177134412]

最后,将被 Pickle 的模型加载回来,并针对真实数据集进行测试。

练习

  • scikit-learn 和 PyTorch 之间的一些关键区别是什么?

  • 什么是 AutoML 以及为什么要使用它?

  • 更改 scikit-learn 模型以使用身高来预测体重。

  • 在 Google Colab 笔记本中运行 PyTorch 示例,并在 CPU 和 GPU 运行时之间切换。如果有性能差异,请解释其原因。

  • 什么是 EDA 以及在数据科学项目中为什么如此重要?

案例研究问题

  • 前往 Kaggle 网站,选择一个流行的 Python 笔记本,并将其转换为容器化的 Flask 应用程序,该应用程序使用本章中所示的示例作为指南提供预测。现在通过托管的 Kubernetes 服务(例如 Amazon EKS)将其部署到云环境中。

学习评估

  • 解释不同类型的机器学习框架和生态系统。

  • 运行和调试现有的 scikit-learn 和 PyTorch 机器学习项目。

  • 将 Flask scikit-learn 模型进行容器化。

  • 了解生产机器学习成熟度模型。

第十五章:数据工程

数据科学可能是 21 世纪最性感的工作,但这个领域正在迅速演变成不同的职位头衔。数据科学家已经过于粗糙地描述了一系列任务。截至 2020 年,两个可以支付相同或更高工资的工作是数据工程师和机器学习工程师。

更令人惊讶的是,支持传统数据科学家所需的大量数据工程师角色。大约需要三到五名数据工程师才能支持一个数据科学家。

发生了什么?让我们从另一个角度来看待。假设我们正在为一家报纸写头条新闻,想要说一些吸引眼球的事情。我们可以说,“CEO 是富人最性感的工作。”CEO 很少,就像 NBA 明星很少,像是靠演艺为生的职业演员很少一样。每个 CEO 背后,有多少人在努力使他们成功?这个陈述与内容空洞、毫无意义,就像“水是湿的”一样。

这个陈述并不是说你不能以数据科学家的身份谋生;这更多是对这种说法背后逻辑的批评。在数据技能方面存在巨大的需求,从 DevOps 到机器学习再到沟通,都有涉及。数据科学家这个术语是模糊的。它是一种工作还是一种行为?在某种程度上,它很像 DevOps 这个词。DevOps 是一种工作,还是一种行为?

在查看职位发布数据和薪资数据时,似乎市场正在表明对数据工程和机器学习工程实际角色存在显著需求。这是因为这些角色执行可识别的任务。数据工程师的任务可能是在云中创建收集批处理和流处理数据的管道,然后创建 API 以访问该数据并安排这些作业。这项工作不是模糊的任务。它要么有效,要么无效。

同样,机器学习工程师建立机器学习模型并以可维护的方式部署它们。这个工作也不含糊。一个工程师可以做数据工程或机器学习工程,但仍然表现出与数据科学和 DevOps 相关的行为。现在是参与数据领域的激动人心时刻,因为有一些重要的机会可以建立复杂而强大的数据管道,这些管道供给其他复杂而强大的预测系统。有一句话说,“你永远不能太富有或太瘦。”同样,对于数据来说,你永远不能拥有太多的 DevOps 或数据科学技能。让我们深入一些 DevOps 风格的数据工程思想。

小数据

工具包是一个令人兴奋的概念。如果你叫水管工来你家,他们会带着工具,帮助他们比你更有效地完成任务。如果你雇用木工在你家建造东西,他们也会有一套独特的工具,帮助他们在比你更短的时间内完成任务。工具对专业人士来说至关重要,DevOps 也不例外。

在这一节中,数据工程的工具概述了它们自己。这些工具包括读写文件、使用pickle、使用JSON、写入和读取YAML文件等小数据任务。掌握这些格式对于成为能够处理任何任务并将其转化为脚本的自动化人员是至关重要的。后面还涵盖了大数据任务的工具。它讨论了与小数据使用不同的工具明显不同的工具。

什么是大数据,什么是小数据?一个简单的区分方法是笔记本电脑测试。它在你的笔记本上能运行吗?如果不能,那么它就是大数据。一个很好的例子是 Pandas。Pandas 需要的 RAM 量是数据集的 5 到 10 倍。如果你有一个 2 GB 的文件,并且你正在使用 Pandas,很可能你的笔记本无法运行。

处理小数据文件

如果 Python 有一个单一的定义特性,那将是语言中效率的不懈追求。一个典型的 Python 程序员希望编写足够的代码来完成任务,但在代码变得难以理解或简洁时停止。此外,一个典型的 Python 程序员也不愿意编写样板代码。这种环境促使有用模式的持续演变。

使用with语句来读写文件的一个活跃模式的例子。with语句处理了烦人的样板部分,即在工作完成后关闭文件句柄。with语句还在 Python 语言的其他部分中使用,使烦琐的任务不那么令人讨厌。

写一个文件

这个例子展示了使用with语句写入文件时,执行代码块后会自动关闭文件句柄。这种语法可以防止因为意外未关闭句柄而导致的 bug:

with open("containers.txt", "w") as file_to_write:
  file_to_write.write("Pod/n")
  file_to_write.write("Service/n")
  file_to_write.write("Volume/n")
  file_to_write.write("Namespace/n")

文件的输出如下所示:

cat containers.txt

Pod
Service
Volume
Namespace

读一个文件

with上下文也是推荐的读取文件的方式。注意,使用readlines()方法使用换行符返回一个惰性评估的迭代器:

with open("containers.txt") as file_to_read:
  lines = file_to_read.readlines()
  print(lines)

输出:

['Pod\n', 'Service\n', 'Volume\n', 'Namespace\n']

在实践中,这意味着你可以通过使用生成器表达式处理大型日志文件,而不必担心消耗机器上的所有内存。

生成器管道用于读取和处理行

这段代码是一个生成器函数,打开一个文件并返回一个生成器:

def process_file_lazily():
  """Uses generator to lazily process file"""

  with open("containers.txt") as file_to_read:
    for line in file_to_read.readlines():
      yield line

接下来,这个生成器被用来创建一个管道,逐行执行操作。在这个例子中,将行转换为小写字符串。这里可以链式连接许多其他操作,而且非常高效,因为只使用了处理一行数据所需的内存:

# Create generator object
pipeline = process_file_lazily()
# convert to lowercase
lowercase = (line.lower() for line in pipeline)
# print first processed line
print(next(lowercase))

这是管道的输出:

pod

在实践中,这意味着那些因为太大而实际上是无限的文件,如果代码能够在找到条件时退出,则仍然可以处理。例如,也许你需要在数千兆字节的日志数据中找到一个客户 ID。生成器管道可以寻找这个客户 ID,然后在第一次出现时退出处理。在大数据的世界中,这不再是一个理论性的问题。

使用 YAML

YAML 正在成为与 DevOps 相关的配置文件的新兴标准。它是一种人类可读的数据序列化格式,是 JSON 的超集。它代表“YAML 不是一种标记语言。” 你经常会在像 AWS CodePipelineCircleCI 这样的构建系统,或者像 Google App Engine 这样的 PaaS 提供中看到 YAML。

YAML 如此经常被使用是有原因的。需要一种配置语言,允许在与高度自动化的系统交互时进行快速迭代。无论是非程序员还是程序员都可以直观地了解如何编辑这些文件。以下是一个例子:

import yaml

kubernetes_components = {
    "Pod": "Basic building block of Kubernetes.",
    "Service": "An abstraction for dealing with Pods.",
    "Volume": "A directory accessible to containers in a Pod.",
    "Namespaces": "A way to divide cluster resources between users."
}

with open("kubernetes_info.yaml", "w") as yaml_to_write:
  yaml.safe_dump(kubernetes_components, yaml_to_write, default_flow_style=False)

写入磁盘的输出如下所示:

cat kubernetes_info.yaml

Namespaces: A way to divide cluster resources between users.
Pod: Basic building block of Kubernetes.
Service: An abstraction for dealing with Pods.
Volume: A directory accessible to containers in a Pod.

结论是,它使得将 Python 数据结构序列化为易于编辑和迭代的格式变得非常简单。将此文件读回的代码仅需两行。

import yaml

with open("kubernetes_info.yaml", "rb") as yaml_to_read:
  result = yaml.safe_load(yaml_to_read)

然后可以对输出进行漂亮的打印:

import pprint
pp = pprint.PrettyPrinter(indent=4)
pp.pprint(result)
{   'Namespaces': 'A way to divide cluster resources between users.',
    'Pod': 'Basic building block of Kubernetes.',
    'Service': 'An abstraction for dealing with Pods.',
    'Volume': 'A directory accessible to containers in a Pod.'}

大数据

数据的增长速度比计算机处理能力的增长速度更快。更有趣的是,摩尔定律认为计算机的速度和能力每两年可以翻一番,但根据加州大学伯克利分校的 David Patterson 博士的说法,这种增长在 2015 年左右就停止适用了。CPU 速度现在每年只增长约 3%。

处理大数据的新方法是必需的。一些新方法包括使用像 GPU、张量处理单元(TPU)等 ASICs,以及云供应商提供的 AI 和数据平台。在芯片级别上,这意味着 GPU 可能是复杂 IT 过程的理想目标,而不是 CPU。通常,这个 GPU 与能够提供分布式存储机制的系统配对,该机制允许分布式计算和分布式磁盘 I/O。一个很好的例子是 Apache Spark,Amazon SageMaker 或 Google AI 平台。它们都可以利用 ASICs(GPU、TPU 等),以及分布式存储和管理系统。另一个更低级别的例子是 Amazon Spot 实例深度学习 AMIs,配有 Amazon 弹性文件系统(EFS)挂载点。

对于一个 DevOps 专业人员,这意味着几件事情。首先,这意味着在将软件交付到这些系统时需要特别注意。例如,目标平台是否有正确的 GPU 驱动程序?你是通过容器部署吗?这个系统是否将使用分布式 GPU 处理?数据主要是批处理还是流处理?提前考虑这些问题可以确保选择正确的架构。

像 AI、大数据、云或数据科学家这样的流行词存在一个问题,即它们对不同的人有不同的含义。以数据科学家为例。在一家公司,它可能意味着为销售团队生成业务智能仪表板的人,而在另一家公司,它可能意味着正在开发自动驾驶汽车软件的人。大数据也存在类似的语境问题;它可以根据你遇到的人的不同而有许多不同的含义。这里有一个考虑的定义。你是否需要不同的软件包来处理你笔记本电脑上的数据和生产环境中的数据?

一个很好的“小数据”工具的典范是 Pandas 包。根据 Pandas 包的作者,它可能需要比使用的文件大小多 5 到 10 倍的 RAM。实际上,如果你的笔记本电脑有 16 GB 的 RAM,并且打开了一个 2 GB 的 CSV 文件,那么现在就变成了一个大数据问题,因为你的笔记本电脑可能没有足够的 RAM(20 GB)来处理这个文件。相反,你可能需要重新思考如何处理这个问题。也许你可以打开数据的样本,或者截断数据以首先解决问题。

这里有一个确切问题及其解决方法的例子。假设你正在支持数据科学家,他们因为使用了对于 Pandas 来说太大的文件而经常遇到内存不足的错误。其中一个例子是来自 Kaggle 的 Open Food Facts数据集。解压后,数据集超过 1 GB。这个问题正好符合 Pandas 可能难以处理的情况。你可以做的一件事是使用 Unix 的shuf命令创建一个打乱的样本:

time shuf -n 100000 en.openfoodfacts.org.products.tsv\
    > 10k.sample.en.openfoodfacts.org.products.tsv
    1.89s user 0.80s system 97% cpu 2.748 total

不到两秒钟,文件就可以被削减到一个可以处理的大小。这种方法比简单地使用头或尾部更可取,因为样本是随机选择的。这对数据科学工作流程非常重要。此外,你可以检查文件的行以先了解你要处理的内容:

wc -l en.openfoodfacts.org.products.tsv
  356002 en.openfoodfacts.org.products.tsv

源文件大约有 350,000 行,因此获取 100,000 个打乱的行大约占据了数据的三分之一。这个任务可以通过查看转换后的文件来确认。它显示 272 MB,大约是原始 1 GB 文件大小的三分之一:

du -sh 10k.sample.en.openfoodfacts.org.products.tsv
272M    10k.sample.en.openfoodfacts.org.products.tsv

这种大小对 Pandas 来说更容易管理,并且这个过程可以转化为一个自动化工作流程,为大数据源创建随机样本文件。这种类型的过程只是大数据要求的许多特定工作流程之一。

另一个关于大数据的定义来自麦肯锡,他们在 2011 年将大数据定义为“数据集,其大小超出典型数据库软件工具捕捉、存储、管理和分析的能力”。这个定义也是合理的,稍作修改,它不仅仅是数据库软件工具,而是任何接触数据的工具。当适用于笔记本电脑的工具(如 Pandas、Python、MySQL、深度学习/机器学习、Bash 等)因数据的规模或速度(变化率)而无法传统方式运行时,它现在是一个大数据问题。大数据问题需要专门的工具,下一节将深入探讨这个需求。

大数据工具、组件和平台

另一种讨论大数据的方式是将其分解为工具和平台。图 15-1 显示了典型的大数据架构生命周期。

pydo 1501

图 15-1. 大数据架构

让我们讨论几个关键组件。

数据来源

一些熟悉的大数据来源包括社交网络和数字交易。随着人们将更多的对话和业务交易迁移到在线平台,数据爆炸性增长。此外,诸如平板电脑、手机和笔记本电脑等移动技术记录音频和视频,进一步创造了数据源。

其他数据来源包括物联网(IoT),包括传感器、轻量级芯片和设备。所有这些导致数据不可阻挡地增加,需要在某处进行存储。涉及数据来源的工具可能从物联网客户端/服务器系统(如 AWS IoT Greengrass)、到对象存储系统(如 Amazon S3 或 Google Cloud Storage)等广泛应用。

文件系统

文件系统在计算中发挥了重要作用。它们的实现不断演变。在处理大数据时,一个问题是有足够的磁盘 I/O 来处理分布式操作。

处理这一问题的现代工具之一是 Hadoop 分布式文件系统(HDFS)。它通过将许多服务器集群在一起来工作,允许聚合的 CPU、磁盘 I/O 和存储。实际上,这使得 HDFS 成为处理大数据的基础技术。它可以迁移大量数据或文件系统用于分布式计算作业。它还是 Spark 的支柱,可以进行流式和批量机器学习。

其他类型的文件系统包括对象存储文件系统,如 Amazon S3 文件系统和 Google Cloud 平台存储。它们允许将大文件以分布式和高可用的方式存储,或者更精确地说是 99.999999999%的可靠性。有 Python API 和命令行工具可用于与这些文件系统通信,实现简单的自动化。这些云 API 将在第十章中详细介绍。

最后,另一种需要注意的文件系统是传统的网络文件系统,或者 NFS,作为托管云服务提供。Amazon Elastic File System(Amazon EFS)是这方面的一个很好的例子。对于 DevOps 专业人员来说,一个高可用和弹性的 NFS 文件系统可以是一个非常多才多艺的工具,特别是与容器技术结合使用。图 15-2 展示了在容器中挂载 EFS 的一个示例。

pydo 1502

图 15-2. 将 EFS 挂载在容器中

一个强大的自动化工作流程是通过构建系统(例如 AWS CodePipeline 或 Google Cloud Build)通过编程方式创建 Docker 容器。然后,这些容器被注册到云容器注册表,例如 Amazon ECR。接下来,一个容器管理系统,比如 Kubernetes,会生成挂载 NFS 的容器。这样一来,既可以享受到产生迅速的不可变容器镜像的强大功能,又可以访问到集中的源代码库和数据。这种类型的工作流程对于希望优化机器学习操作的组织来说可能是理想的。

数据存储

最终,数据需要存放在某个地方,这带来了一些令人兴奋的机会和挑战。一个新兴的趋势是利用数据湖的概念。你为什么关心数据湖?数据湖允许在存储的同一位置进行数据处理。因此,许多数据湖需要具有无限存储和提供无限计算(即在云上)。Amazon S3 通常是数据湖的常见选择。

以这种方式构建的数据湖也可以被机器学习流水线利用,该流水线可能依赖于存储在湖中的训练数据,以及训练模型。然后,可以始终对训练模型进行 A/B 测试,以确保最新模型正在改善生产预测(推断)系统,如图 15-3 所示。

pydo 1503

图 15-3. 数据湖

其他形式的存储对于传统软件开发人员来说可能非常熟悉。这些存储系统包括关系型数据库、键/值数据库、像 Elasticsearch 这样的搜索引擎以及图数据库。在大数据架构中,每种类型的存储系统可能会发挥更具体的作用。在小规模系统中,关系型数据库可能是一个万能工具,但在大数据架构中,对于存储系统的不匹配容忍度较小。

在存储选择中出现的一个极好的不匹配例子是使用关系数据库作为搜索引擎,通过启用全文搜索功能,而不是使用专门的解决方案,比如 Elasticsearch。Elasticsearch 旨在创建可扩展的搜索解决方案,而关系数据库旨在提供引用完整性和事务。亚马逊的 CTO Werner Vogel 非常明确地指出“一个数据库规模并不适合所有人”。这个问题在图 15-4 中有所说明,该图显示每种类型的数据库都有特定的用途。

pydo 1504

图 15-4. Amazon 数据库

选择正确的存储解决方案,包括使用哪种组合的数据库,对于任何类型的数据架构师来说都是一项关键技能,以确保系统以最佳效率运行。在考虑设计一个完全自动化和高效的系统时,应该考虑维护成本。如果滥用特定的技术选择,比如使用关系数据库作为高可用消息队列,那么维护成本可能会激增,从而带来更多的自动化工作。因此,另一个需要考虑的组成部分是维护解决方案所需的自动化工作量。

实时流式传输摄取

实时流式数据是一种特别棘手的数据类型。流本身增加了处理数据的复杂性,可能需要将流路由到系统的另一部分,该部分意图以流式处理数据。一个云端流式摄取解决方案的示例是 Amazon Kinesis Data Firehose。见图 15-5。

pydo 1505

图 15-5. Kinesis 日志文件

下面是一个执行此操作的代码示例。请注意,Python 的asyncio模块允许高度并发的单线程网络操作。节点可以在作业农场中发出这些操作,这可能是指标或错误日志:

import asyncio

def send_async_firehose_events(count=100):
    """Async sends events to firehose"""

    start = time.time()
    client = firehose_client()
    extra_msg = {"aws_service": "firehose"}
    loop = asyncio.get_event_loop()
    tasks = []
    LOG.info(f"sending aysnc events TOTAL {count}",extra=extra_msg)
    num = 0
    for _ in range(count):
        tasks.append(asyncio.ensure_future(put_record(gen_uuid_events(),
                                                      client)))
        LOG.info(f"sending aysnc events: COUNT {num}/{count}")
        num +=1
    loop.run_until_complete(asyncio.wait(tasks))
    loop.close()
    end = time.time()
    LOG.info("Total time: {}".format(end - start))

Kinesis Data Firehose 通过接受捕获的数据并将其持续路由到多个目的地来工作:Amazon S3、Amazon Redshift、Amazon Elasticsearch 服务,或者像 Splunk 这样的第三方服务。使用类似 Kinesis 这样的托管服务的一个开源替代方案是使用 Apache Kafka。Apache Kafka 具有类似的原则,它作为发布/订阅架构工作。

案例研究:构建自制数据管道

在诺亚(Noah)早期担任 CTO 兼总经理的初创企业的 2000 年初期,出现了一个问题,即如何构建公司的第一个机器学习管道和数据管道。下图显示了 Jenkins 数据管道的草图(图 15-6)。

pydo 1506

图 15-6. Jenkins 数据管道

数据管道的输入是任何需要用于业务分析或机器学习预测的数据源。这些来源包括关系数据库、Google Analytics 和社交媒体指标等等。收集作业每小时运行一次,并生成 CSV 文件,这些文件可以通过 Apache web 服务在内部使用。这个解决方案是一个引人注目且简单的过程。

这些作业本身是 Jenkins 作业,是运行的 Python 脚本。如果需要更改某些内容,更改特定作业的 Python 脚本相当简单。这个系统的另一个好处是它很容易调试。如果作业失败了,作业就会显示为失败,查看作业的输出并查看发生了什么是很简单的。

管道的最终阶段然后创建了机器学习预测和分析仪表板,通过基于 R 的 Shiny 应用程序提供仪表板服务。这种方法的简单性是这种架构的最有影响力的因素,而且作为一个额外的奖励,它利用了现有的 DevOps 技能。

无服务器数据工程

另一个新兴的模式是无服务器数据工程。图 15-7 是一个无服务器数据管道的高层架构图。

pydo 1507

图 15-7. 无服务器数据管道

接下来,让我们看看定时 lambda 做了什么。

使用 AWS Lambda 与 CloudWatch 事件

你可以在 AWS Lambda 控制台上创建一个 CloudWatch 计时器来调用 lambda,并设置触发器,如图 15-8 所示。

pydo 1508

图 15-8. CloudWatch Lambda 计时器

使用 Amazon CloudWatch Logging 与 AWS Lambda

使用 CloudWatch 日志记录是 Lambda 开发的一个重要步骤。图 15-9 是 CloudWatch 事件日志的一个示例。

pydo 1509

图 15-9. CloudWatch 事件日志

使用 AWS Lambda 填充 Amazon Simple Queue Service

接下来,您希望在 AWS Cloud9 中本地执行以下操作:

  1. 使用 Serverless Wizard 创建一个新的 Lambda。

  2. cd 进入 lambda 并在上一级安装包。

pip3 install boto3 --target ../
pip3 install python-json-logger --target ../

接下来,你可以在本地测试并部署这段代码:

'''
Dynamo to SQS
'''

import boto3
import json
import sys
import os

DYNAMODB = boto3.resource('dynamodb')
TABLE = "fang"
QUEUE = "producer"
SQS = boto3.client("sqs")

#SETUP LOGGING
import logging
from pythonjsonlogger import jsonlogger

LOG = logging.getLogger()
LOG.setLevel(logging.INFO)
logHandler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter()
logHandler.setFormatter(formatter)
LOG.addHandler(logHandler)

def scan_table(table):
    '''Scans table and return results'''

    LOG.info(f"Scanning Table {table}")
    producer_table = DYNAMODB.Table(table)
    response = producer_table.scan()
    items = response['Items']
    LOG.info(f"Found {len(items)} Items")
    return items

def send_sqs_msg(msg, queue_name, delay=0):
    '''Send SQS Message

 Expects an SQS queue_name and msg in a dictionary format.
 Returns a response dictionary.
 '''

    queue_url = SQS.get_queue_url(QueueName=queue_name)["QueueUrl"]
    queue_send_log_msg = "Send message to queue url: %s, with body: %s" %\
        (queue_url, msg)
    LOG.info(queue_send_log_msg)
    json_msg = json.dumps(msg)
    response = SQS.send_message(
        QueueUrl=queue_url,
        MessageBody=json_msg,
        DelaySeconds=delay)
    queue_send_log_msg_resp = "Message Response: %s for queue url: %s" %\
        (response, queue_url)
    LOG.info(queue_send_log_msg_resp)
    return response

def send_emissions(table, queue_name):
    '''Send Emissions'''

    items = scan_table(table=table)
    for item in items:
        LOG.info(f"Sending item {item} to queue: {queue_name}")
        response = send_sqs_msg(item, queue_name=queue_name)
        LOG.debug(response)

def lambda_handler(event, context):
    '''
 Lambda entrypoint
 '''

    extra_logging = {"table": TABLE, "queue": QUEUE}
    LOG.info(f"event {event}, context {context}", extra=extra_logging)
    send_emissions(table=TABLE, queue_name=QUEUE)


This code does the following:

1.  Grabs company names from Amazon DynamoDB.

2.  Puts the names into Amazon SQS.

To test it, you can do a local test in Cloud9 (Figure 15-10).

![pydo 1510](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1510.png)

###### Figure 15-10\. Local test in Cloud9

Next you can verify messages in SQS, as shown in Figure 15-11.

![pydo 1511](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1511.png)

###### Figure 15-11\. SQS verification

Don’t forget to set the correct IAM role! You need to assign the lambda an IAM role that can write messages to SQS, as shown in Figure 15-12.

![pydo 1512](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1512.png)

###### Figure 15-12\. Permission error

## Wiring Up CloudWatch Event Trigger

The final step to enable CloudWatch trigger does the following: enable timed execution of producer, and verify that messages flow into SQS, as shown in Figure 15-13.

![pydo 1513](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1513.png)

###### Figure 15-13\. Configure timer

You can now see messages in the SQS queue (Figure 15-14).

![pydo 1514](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1514.png)

###### Figure 15-14\. SQS queue

## Creating Event-Driven Lambdas

With the producer lambda out of the way, next up is to create an event-driven lambda that fires asynchronously upon every message in SQS (the consumer). The Lambda function can now fire in response to every SQS message (Figure 15-15).

![pydo 1515](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1515.png)

###### Figure 15-15\. Fire on SQS event

## Reading Amazon SQS Events from AWS Lambda

The only task left is to write the code to consume the messages from SQS, process them using our API, and then write the results to S3:

import json

import boto3

import botocore

import pandas as pd

import pandas as pd

import wikipedia

import boto3

from io import StringIO

设置日志记录

import logging

from pythonjsonlogger import jsonlogger

LOG = logging.getLogger()

LOG.setLevel(logging.DEBUG)

logHandler = logging.StreamHandler()

formatter = jsonlogger.JsonFormatter()

logHandler.setFormatter(formatter)

LOG.addHandler(logHandler)

S3 存储桶

REGION = "us-east-1"

SQS 工具###

def sqs_queue_resource(queue_name):

"""返回一个 SQS 队列资源连接

使用示例:

在 [2] 中:queue = sqs_queue_resource("dev-job-24910")

在 [4] 中:queue.attributes

Out[4]:

{'ApproximateNumberOfMessages': '0',

'ApproximateNumberOfMessagesDelayed': '0',

'ApproximateNumberOfMessagesNotVisible': '0',

'CreatedTimestamp': '1476240132',

'DelaySeconds': '0',

'LastModifiedTimestamp': '1476240132',

'MaximumMessageSize': '262144',

'MessageRetentionPeriod': '345600',

'QueueArn': 'arn:aws:sqs:us-west-2:414930948375:dev-job-24910',

'ReceiveMessageWaitTimeSeconds': '0',

'VisibilityTimeout': '120'}

"""

sqs_resource = boto3.resource('sqs', region_name=REGION)

log_sqs_resource_msg =\

"Creating SQS resource conn with qname: [%s] in region: [%s]" %\

(queue_name, REGION)

LOG.info(log_sqs_resource_msg)

queue = sqs_resource.get_queue_by_name(QueueName=queue_name)

return queue

def sqs_connection():

"""Creates an SQS Connection which defaults to global var REGION"""

sqs_client = boto3.client("sqs", region_name=REGION)

log_sqs_client_msg = "Creating SQS connection in Region: [%s]" % REGION

LOG.info(log_sqs_client_msg)

return sqs_client

def sqs_approximate_count(queue_name):

"""Return an approximate count of messages left in queue"""

queue = sqs_queue_resource(queue_name)

attr = queue.attributes

num_message = int(attr['ApproximateNumberOfMessages'])

num_message_not_visible = int(attr['ApproximateNumberOfMessagesNotVisible'])

queue_value = sum([num_message, num_message_not_visible])

sum_msg = """'ApproximateNumberOfMessages' and\

'ApproximateNumberOfMessagesNotVisible' =\

*** [%s] *** for QUEUE NAME: [%s]""" %\

    (queue_value, queue_name)

LOG.info(sum_msg)

return queue_value

def delete_sqs_msg(queue_name, receipt_handle):

sqs_client = sqs_connection()

try:

    queue_url = sqs_client.get_queue_url(QueueName=queue_name)["QueueUrl"]

    delete_log_msg = "Deleting msg with ReceiptHandle %s" % receipt_handle

    LOG.info(delete_log_msg)

    response = sqs_client.delete_message(QueueUrl=queue_url,

    ReceiptHandle=receipt_handle)

except botocore.exceptions.ClientError as error:

    exception_msg =\

    "FAILURE TO DELETE SQS MSG: Queue Name [%s] with error: [%s]" %\

        (queue_name, error)

    LOG.exception(exception_msg)

    return None

delete_log_msg_resp = "Response from delete from queue: %s" % response

LOG.info(delete_log_msg_resp)

return response

def names_to_wikipedia(names):

wikipedia_snippit = []

for name in names:

    wikipedia_snippit.append(wikipedia.summary(name, sentences=1))

df = pd.DataFrame(

    {

        'names':names,

        'wikipedia_snippit': wikipedia_snippit

    }

)

return df

def create_sentiment(row):

"""Uses AWS Comprehend to Create Sentiments on a DataFrame"""

LOG.info(f"Processing {row}")

comprehend = boto3.client(service_name='comprehend')

payload = comprehend.detect_sentiment(Text=row, LanguageCode='en')

LOG.debug(f"Found Sentiment: {payload}")

sentiment = payload['Sentiment']

return sentiment

def apply_sentiment(df, column="wikipedia_snippit"):

"""Uses Pandas Apply to Create Sentiment Analysis"""

df['Sentiment'] = df[column].apply(create_sentiment)

return df

S3

def write_s3(df, bucket):

"""Write S3 Bucket"""

csv_buffer = StringIO()

df.to_csv(csv_buffer)

s3_resource = boto3.resource('s3')

res = s3_resource.Object(bucket, 'fang_sentiment.csv').\

    put(Body=csv_buffer.getvalue())

LOG.info(f"result of write to bucket: {bucket} with:\n {res}")

def lambda_handler(event, context):

"""Lambda 的入口点"""

LOG.info(f"SURVEYJOB LAMBDA,事件 {event},上下文 {context}")

receipt_handle  = event['Records'][0]['receiptHandle'] # sqs 消息

#'eventSourceARN': 'arn:aws:sqs:us-east-1:561744971673:producer'

event_source_arn = event['Records'][0]['eventSourceARN']

names = [] # 从队列中捕获

# 处理队列

for record in event['Records']:

    body = json.loads(record['body'])

    company_name = body['name']

    # 用于处理的捕获

    names.append(company_name)

    extra_logging = {"body": body, "company_name":company_name}

    LOG.info(f"SQS 消费者 LAMBDA,分割 arn: {event_source_arn}",

    extra=extra_logging)

    qname = event_source_arn.split(":")[-1]

    extra_logging["queue"] = qname

    LOG.info(f"尝试删除 SQS {receipt_handle} {qname}",

    extra=extra_logging)

    res = delete_sqs_msg(queue_name=qname, receipt_handle=receipt_handle)

    LOG.info(f"删除 SQS receipt_handle {receipt_handle} 的结果为 {res}",

    extra=extra_logging)

# 使用 Pandas 构建带有维基百科片段的数据框架

LOG.info(f"使用以下值创建数据框架:{names}")

df = names_to_wikipedia(names)

# 执行情感分析

df = apply_sentiment(df)

LOG.info(f"FANG 公司情感分析结果:{df.to_dict()}")

# 将结果写入 S3

write_s3(df=df, bucket="fangsentiment")

You can see that one easy way to download the files is to use the AWS CLI:

noah:/tmp $ aws s3 cp --recursive s3://fangsentiment/ .

download: s3://fangsentiment/netflix_sentiment.csv to ./netflix_sentiment.csv

download: s3://fangsentiment/google_sentiment.csv to ./google_sentiment.csv

download: s3://fangsentiment/facebook_sentiment.csv to ./facebook_sentiment.csv


好了,我们完成了什么?图 15-16 展示了我们的无服务器 AI 数据工程流水线。

![pydo 1516](https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_1516.png)

###### 图 15-16\. 无服务器 AI 数据工程流水线

# 结论

数据工程是一个不断发展的职业头衔,它极大地受益于强大的 DevOps 技能。微服务、持续交付、基础设施即代码和监控日志记录等 DevOps 最佳实践在这一类别中发挥了重要作用。通过利用云原生技术,使得复杂问题成为可能,简单问题变得无需费力。

这里有一些继续掌握数据工程技能旅程的下一步。学习无服务器技术。无论云环境是什么,都要学习!这种环境是未来,特别是数据工程非常适合抓住这一趋势。

# 练习

+   解释 Big Data 是什么及其主要特征。

+   使用 Python 中的小数据工具解决常见问题。

+   解释数据湖是什么及其用途。

+   解释不同类型用途专用数据库的适当使用案例。

+   在 Python 中构建无服务器数据工程流水线。

# 案例研究问题

+   使用本章展示的相同架构,构建一个端到端的无服务器数据工程管道,使用 Scrapy、Beautiful Soup 或类似的库来爬取网站,并将图像文件发送到 Amazon Rekognition 进行分析。将 Rekognition API 调用的结果存储在 Amazon DynamoDB 中。每天定时运行此作业。


# 第十六章:DevOps 战斗故事和面试

*作者:Noah*

当我在加利福尼亚州圣路易斯奥比斯波的最后一年大学时,我需要在夏季修完有机化学才能按时毕业。不幸的是,夏季没有财政援助,所以我不得不租房并找份全职工作。我能在图书馆找到一份时薪工作,但这还不够钱。我搜遍了招聘广告,唯一出现的工作是一个大型乡村西部夜总会的门卫。

在我的面试中,面试我的经理大约有六英尺高,近三百磅的肌肉大部分。他还有一个巨大的黑眼圈。他告诉我,上个周末一大群人殴打了所有门卫,包括他在内。他告诉我,这是我和田径队的铅球手之间的竞争。为了帮助决定我和铅球手之间的竞争,他问我是否会在类似的斗殴中逃跑。我告诉他我不会逃避打架,然后我得到了这份工作。

后来我开始意识到,我在评估我的勇气和能力时可能有些天真。一些大力士和橄榄球运动员经常来这里打架,他们是可怕的人物。在一场音乐会上,他们叫来了后援,因为他们预料到会有麻烦。一个有着鹰嘴式发型和头上刺着中文的门卫是那个事件的我的同事。几年后,我在电视上看到他赢得 UFC 重量级冠军,并把名字和面孔对应起来,那人就是查克·利德尔。保镖是一份危险的工作。这一事实在有一天变得明显,当我试图制止一场醉酒的 250 磅重的橄榄球运动员在殴打一个受害者的脸时。我想拉他开,但结果被他轻而易举地甩开了好几英尺,就像他在轻松地打一个靠在房间对面的枕头一样。在那一刻,我意识到我并不无敌,而我的武术技能根本不存在。我从未忘记那个教训。

描述这种过度自信的一种方式是邓宁-克鲁格效应。邓宁-克鲁格效应是一种认知偏差,人们错误地评估自己的认知能力比实际更重要。你可以在每年的 StackOverflow 调查中看到这种效应。2019 年,70%的开发者认为自己高于平均水平,而 10%的人认为自己低于平均水平。这里的教训是什么?不要相信人类的认知!相信自动化!“相信我”,“我是老板”,“我已经做了 X 年”,以及其他自信的陈述与正确执行的自动化的残酷效率相比,都是无稽之谈。相信自动化优于等级制度,这就是 DevOps 的全部意义。

本章通过使用真实人物和真实案例研究,将书中关于自动化的一切联系起来,探索了 DevOps 的最佳实践:

+   连续集成

+   持续交付

+   微服务

+   基础设施即代码

+   **监控和日志**

+   **沟通和协作**

# **电影工作室无法制作电影**

在新西兰为电影《阿凡达》工作了一年后,我心境非常平静。我住在北岛一个名叫米拉马的小镇,那里是一个令人惊叹的美丽半岛。每天早上,我会从家门口走出去沿着海滩跑上 14 公里。最终,合同到期了,我不得不找新工作。我接受了湾区一个大型电影工作室的职位,那里有数百名员工,工作场所占地十多万平方英尺。这家公司投资了数亿美元,看起来是一个很酷的工作地方。我周末飞来,星期天抵达(工作日前的一天)。

我工作的第一天相当震惊。我从天堂般的心境中猛然醒来。整个工作室瘫痪了,数百名员工因为核心软件系统——资产管理系统——无法工作。在恐慌和绝望中,我被带进了秘密的战斗室,并展示了问题的严重程度。我可以感觉到我在沿着海滩悠闲奔跑的平静日子结束了。我进入了一个战区。天哪!

随着我对这场危机的了解加深,显而易见,这已经是一个慢慢燃烧的大火已有一段时间了。整天的停机和严重的技术问题成为常态。问题清单如下:

+   系统是在没有代码审查的情况下独立开发的。

+   没有版本控制。

+   没有构建系统。

+   没有测试。

+   有超过一千行的函数。

+   有时很难联系到负责项目的关键人员。

+   停机很昂贵,因为高薪人员无法工作。

+   电影鼓励不负责任的软件开发,因为他们“不是一个软件公司”。

+   远程地点存在神秘的连接问题。

+   没有真正的监控。

+   许多部门为问题实施了临时解决方案和补丁。

这一系列问题的唯一解决办法就是一次做一件正确的事情。新组建的团队正是这样做的。解决这个问题的第一步之一是在演练环境中设置持续集成和自动化负载测试。令人惊讶的是,像这样简单的行动可以极大地增进理解。

###### 注意

我们解决的最后一个更有趣的问题之一出现在我们的监控系统中。在系统性能稳定并应用软件工程最佳实践之后,我们发现了一个令人惊讶的错误。我们让系统每天检查资产作为基本健康检查。几天后,我们开始每天遇到严重的性能问题。当我们查看数据库上 CPU 使用率的波动时,它们与基本健康检查相关。进一步挖掘后,显而易见的是,签入代码(自制的 ORM,对象关系映射器)正在指数级地生成 SQL 查询。大多数工作流程只涉及资产的两三个版本,我们的健康检查监控发现了一个关键缺陷。自动健康检查的另一个原因。

当我们运行负载测试时,我们发现了一系列问题。我们立即发现的一个问题是,在少量并发流量之后,MySQL 数据库会变成一条直线。我们发现我们正在使用的 MySQL 版本具有严重的性能问题。切换到最新版本极大地提高了性能。通过解决了这个性能问题,并找到了一种自动测试是否正在解决问题的方法,我们迅速进行了重大修复。

接下来,我们将源代码放入版本控制中,创建了基于分支的部署策略,然后在每次签入时运行了代码检查和测试,以及代码审查。这个举措也极大地提高了我们对性能问题的可见性,并呈现了不言自明的解决方案。在工业危机中,自动化和卓越标准是您可以部署的两个基本工具。

我们最后需要解决的一个问题是,我们的远程电影工作室位置存在严重的可靠性问题。远程位置的工作人员确信问题与我们的 API 相关的性能问题有关。危机紧急到公司的高层执行官让我和另一位工程师飞到现场排查问题。当我们到达那里时,我们打电话给我们的中央办公室,并让他们仅查看该 IP 范围的请求。当我们启动他们的应用程序时,没有观察到任何网络流量。

我们检查了远程位置的网络基础设施,并验证了客户机与中央办公室之间可以发送流量。出于直觉,我们决定使用专门的诊断软件检查 Windows 机器上的本地和网络性能。然后我们观察到在 2 到 3 秒内启动了成千上万个套接字连接。经过进一步研究,我们发现 Windows 操作系统会暂时关闭整个网络堆栈。如果在短时间内生成太多的网络连接,操作系统会保护自己。客户端应用程序试图在一个`for`循环中启动成千上万个网络连接,最终关闭了它的网络堆栈。我们进入代码并限制了机器只能进行一个网络连接,突然之间,一切正常运转起来。

后来,我们对客户端软件的源代码运行了 pylint,并发现大约三分之一的系统无法执行。关键问题不是性能问题,而是缺乏软件工程和 DevOps 最佳实践。通过一些简单的工作流程修改,如持续集成、监控和自动化负载测试,本可以在不到一周内解决这个问题。

# 游戏工作室无法推出游戏

当我第一次加入一家成熟的游戏公司时,它们正在经历一场变革。旧产品在首次推出时非常创新,但在我加入时,他们决定需要投资新产品。公司当前的文化非常注重数据中心,每一步都有大规模的变革管理。许多开发的工具都是对维持一个高度成功但即将消亡的游戏的可用性的逻辑扩展。引入新人、新部门和新产品导致了不可避免的持续冲突和危机。

我很早就意识到危机的初步深度,尽管我一直在处理老产品。在我去参加另一个会议的路上,经过新产品团队时,我听到了一个有趣的对话。在敏捷团队会议上,一位西班牙开发者在谈论旗舰新产品时引起了我的注意,他说:“It no worketh…” 单这个声明就相当震惊,但更令我震惊的是听到的回应:“Luigi,这是*技术术语*;这不是讨论这个的场合。”

从那一刻起,我知道确实出了问题。后来,项目上的许多人都离开了,我接手了一个延迟一年多、第三次重写第三种语言的项目。其中一个关键发现是这位“煤矿里的金丝雀”开发者完全正确。什么都不起作用!在项目的第一天,我在笔记本电脑上检查了源代码,并尝试运行 Web 应用程序。几次刷新 Chrome 后,我的电脑完全死机。噢哦,我又碰上了这种情况。

在深入研究了项目后,我意识到有几个关键问题。首先是需要解决的核心技术危机。有一个“木乃伊崇拜”的敏捷过程,非常擅长创建和关闭工单,但却构建了一个功能不完善的系统。我做的第一件事是将核心工程师从这个项目管理流程中隔离出来,我们为核心解决方案设计了一个修复方案,而不带有“敏捷”额外开销。接下来,当核心引擎问题解决后,我们创建了自动化部署和定制的负载测试流程。

因为这是公司的头等大事,我们重新调整了一些其他团队成员在核心产品上的工作,以构建定制的负载测试和定制的仪器。这个举措也遭遇了相当大的阻力,因为这意味着与这些资源合作的产品经理们将被迫闲置。这是项目中的一个重要节点,因为它迫使管理层决定是否将这个全新产品的首次发布作为公司的首要任务。

推动产品发布的最后一个重大障碍是创建持续交付系统。平均而言,即使是像更改 HTML 这样的小改动,也需要大约一周的时间。对于拥有数十万付费客户的 C++ 游戏而言,工作相当顺利的部署过程对于现代网络应用程序并不适用。传统游戏是在传统数据中心中运行的,这与在云中创建基于网络的游戏的理想条件大不相同。

云计算还揭示了自动化的缺乏。云计算的本质要求更高级的 DevOps 技能和自动化。如果需要人工介入来扩展和缩小服务器,事情就不具有弹性。持续交付意味着软件持续运行,并且可以快速部署到环境中作为最后一步。参与一个需要一周时间的部署过程的“发布经理”与 DevOps 目标直接相悖。

# Python 脚本需要 60 秒才能启动。

在世界顶级电影工作室之一,并拥有世界上最大的超级计算机之一,是了解大规模运作的好方法。开源软件的一个关键问题是,它可能是在孤立的笔记本电脑上构建的,与大公司的需求无关。开发者试图解决一个特定的问题。一方面,解决方案很优雅,但另一方面,它又带来了问题。

这个电影工作室遇到的 Python 问题之一是,它们不得不在集中文件服务器上处理宠字节的数据。Python 脚本是公司的主要工具,它们几乎在所有地方运行。不幸的是,它们需要大约 60 秒才能启动。我们几个人聚在一起解决这个问题,我们使用了我们最喜欢的工具,`strace`:

```py
root@f1bfc615a58e:/app# strace -c -e stat64,open python -c 'import click'
% time seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0        97         4 open
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                    97         4 total

Python 2 的模块查找是 O(nlogn)或者称为“超线性时间”。路径中目录的数量至少会线性增加启动脚本的时间。这种性能惩罚在电影工作室成为了一个真正的问题,因为这经常意味着超过十万次的文件系统调用来启动 Python 脚本。这个过程不仅速度慢,而且逐步破坏了文件服务器的性能。最终,这开始完全击垮中央化的数百万美元文件服务器。

解决方案是深入使用strace进行详细分析,即使用适合工作的正确工具,同时通过修改 Python 停止使用路径查找导入。Python 的后续版本通过缓存查找已解决了这个问题,但学会使用能够深入分析性能的工具是非常重要的。最后一个要点是始终将性能分析作为连续集成过程的一部分,以捕捉这些类型的性能错误。

注意

想起了电影工作室中另外两种与糟糕的用户体验设计和糟糕的架构结合的情况。一天,一名动画师来到主工程部门,寻求解决设置的 Filemaker Pro 数据库问题的建议。动画部门跟踪镜头的 Filemaker Pro 数据库一直被删除。当我要求查看数据库时,发现了一个 UI 界面,其中有两个相邻的按钮。一个是中等大小的绿色按钮,上面写着“保存条目”,另一个是大红色按钮,上面写着“删除数据库”。

在另一家完全不同的公司,我们注意到一个特定 IP 地址向生产 MySQL 数据库发送了大量负载。当我们追踪开发人员时,他们似乎有些犹豫与我们交谈。我们询问他们的部门是否在做任何特殊的事情。他们说他们有一个 PyQt GUI 执行自动化任务。当我们查看 GUI 时,发现了几个正常大小的按钮,然后是一个标有“GO”的大按钮。我们问“GO”按钮的作用,开发人员羞怯地说每个人都知道不要按下那个按钮。我打开了一个 SSH 连接到我们的数据库,并在 MySQL 服务器上运行了 top 命令。尽管他坚决反对,我还是按下了那个按钮。果然,数据库立即持续 100%的 CPU 使用率数分钟。

使用缓存和智能仪器灭火

在我担任 CTO 的体育社交网络中,我们的关系型数据库性能出现了严重问题。我们开始接近垂直扩展的极限。我们使用了 Amazon RDS 提供的最大版本的 SQL Server。更糟糕的是,由于当时 SQL Server 没有将读从服务器整合到 RDS 中,我们无法轻松切换到水平扩展。

解决这类危机的方法有很多种。一种方法可能涉及重写关键查询,但我们正面临大量流量和工程师短缺的情况,所以我们必须寻找创造性的解决方案。我们其中一位以 DevOps 为中心的工程师提出了一个关键性的解决方案,做了以下几步:

  • 添加更多的仪表盘,通过一个应用性能管理工具(APM),跟踪 SQL 调用所花费的时间,并将其映射到路由。

  • 将 Nginx 作为缓存添加到只读路由。

  • 在专门的测试环境中对这个解决方案进行负载测试。

这位工程师拯救了我们的后路,他通过对应用程序进行最小修改来实施解决方案。这显著提高了我们的性能,并最终让我们能够扩展到每月数百万用户,成为世界上最大的体育网站之一。DevOps 原则不仅仅是抽象的重要性;它们可以象征性地帮助你避免在技术债务的海洋中陷入困境。

你将自动化自己的工作!

20 多岁时,我在世界顶级电影工作室之一找到了工作,并且非常兴奋能够运用我在视频、电影、编程和信息技术方面的综合技能。这也是一个工会工作,这在我之前从事技术工作时从未有过的经历。工会工作的好处是,它拥有极好的福利和薪酬,但后来我发现,其中涉及到自动化方面的一些缺点。

在那里工作了几个月后,我意识到我的一个任务相当愚蠢。我会在星期六(加班工资)在电影工作室里走动,把 CD 放入高端编辑系统中,并“进行维护”。总体的想法是好的;每周进行预防性维护,以确保这些昂贵的设备在工作日几乎没有停机时间。然而,实施方式有缺陷。如果一切都可以自动化,我为什么还要手动操作呢?毕竟,这是一台电脑。

在第二个“维护”星期六之后,我构思了一个秘密计划来自动化我的工作。因为这是一个工会工作,所以我必须小心谨慎,并保密直到验证其可行性。如果我请求批准,那就别想了。我首先写下了一系列必要的步骤来实现自动化:

  1. 连接 OS X 机器到公司的 LDAP 服务器。这一步将允许多个用户,并允许我挂载 NFS 主目录。

  2. 反向工程编辑软件,以允许多个用户访问该软件。我对几个列表应用了组级权限进行了修改,以此来“黑客”它,允许各种用户使用同一台机器。

  3. 创建软件的镜像,使其处于我想要安装的状态。

  4. 编写一个脚本来进行“NetBoot”,即从网络操作系统引导计算机,然后重新映像这些计算机。

一旦我解决了这个问题,我就能够走到任何一台机器旁边,重新启动它,并按住“N”键。然后完全重新安装软件(并且保留了用户数据,因为它在网络上)。由于系统速度快,并且我在做块级复制,所以整个机器的重新安装只需 3.5 到 5 分钟。

在我的第一次测试中,我能够在 30 分钟内完成我的“维护”工作。唯一的瓶颈是走到机器旁边,按住“N”键重启它们。此外,我告诉了电影编辑们首先尝试通过重置并按住“N”键来恢复他们的机器,这显著减少了支持电话。天哪,我的工作以及整个部门的工作都变得更加容易处理了。这种自动化在工会工厂并非完全适用。

不久,一位老工会工人突然把我拉到与我的老板的一次意外会议中。他对我所做的不满意。会议结束时,他对我大声吼叫,并指着我说:“你这个小子会把自己脚本化而失去工作的!”老板的上司也不高兴。几个月来,他一直向管理层倡导批准一个维护团队,然后我写了一个脚本,消除了我们部门大部分工作。他也对我大声斥责。

消息传开了,每个人都喜欢这个新的自动化流程,包括明星和电影编辑。因此,我没有因此被解雇。后来,关于我的做法传开了,我因此把自己脚本化而从一份工作中脱颖而出。我被招募到索尼影像工作室工作,并被聘用来做我几乎因为这个而被解雇的事情。这也是一份有趣的工作。我经常在午餐时和亚当·桑德勒以及他电影的演员们一起打篮球。所以,是的,你可以通过脚本化把自己从一份工作中解脱出来,而且正好转移到一个更好的工作中!

DevOps 反模式

让我们深入一些明确的例子,说明不应该做的事情。通常,从错误中学习要比追求完美容易得多。本节详细介绍了许多可怕的故事和应避免的反模式。

无自动化构建服务器反模式

令我惊讶的是,有多少麻烦项目或公司根本没有构建服务器。这一事实可能是软件公司存在的最重要的警示标志。如果你的软件没有经过构建服务器的检验,你几乎可以肯定几乎没有其他形式的自动化正在进行。这个问题就像煤矿中的金丝雀一样。构建服务器是确保您能够可靠交付软件的基础组成部分。通常,在危机中我会立即设置一个构建服务器。仅仅通过 pylint 运行代码就能迅速改善情况。

与此有些相关的是“几乎工作”的构建服务器。有些组织竟然对待 DevOps 也是这样做,“这不是我的工作…那是构建工程师的工作。” 这种不屑一顾的态度,就像“这不是我的工作;这是 DevOps 的工作”一样,是癌症。如果你在软件公司工作,那么自动化的每一个工作都是你的工作。没有比确保事情自动化更关键或更高尚的任务了。说自动化任务不是你的工作,简直荒谬。任何说这种话的人都应该受到谴责。

飞行盲目

你有记录你的代码吗?如果没有,为什么?你开车时也不开车灯吗?应用程序可见性是另一个很容易解决的问题。对于生产软件,技术上有可能记录过多,但在困扰项目中,往往是没有!对于分布式系统,有记录是至关重要的。无论开发人员有多么熟练,问题多么简单,运维团队有多么优秀,你都需要记录。如果你的应用程序不包括记录,你的项目注定会失败。

协调困难作为一项持续的成就

在 DevOps 团队中工作的一个困难是创始人/CTO、创始人/CEO 和团队其他成员之间的地位差异。这种冲突导致在获取更可靠的基础设施、更好的仪器设备、适当的备份、测试和质量保证以及最终解决任何持续稳定危机方面存在协调困难。

另一个影响整合条件并导致协调失败的普通组织动态是团体中的状态差异,因为高地位的团体可能觉得没有必要承认低地位团体成员的任务贡献。例如,Metiu 在软件开发中展示了高地位程序员拒绝阅读低地位程序员提供的注释和进度文档的情况。因为问责要求承认相互责任,阻碍这种承认的状态差异限制了责任的发展。

在存在显著状态差异的情况下,团体成员可能无法彼此信任。在互相依赖的工作中,这些情况下的低地位个体会少问问题,少提反馈意见,因为担心冒犯他人和可能的后果。这种情况导致知识分享减少,限制了团体的共同理解。

在组织行为学中,有一个概念叫做“封闭”。封闭被定义为基于地位垄断商品或机会的行为。根据 Metiu,典型的高地位软件开发团体会使用以下技术来实行封闭:

  • 缺乏互动

  • 利用地理距离或接近程度(办公室的情况)

  • 不使用工作成果

  • 批评

  • 代码所有权转移

在公司内部的互动观察中,我认为高管经常会为他们与员工合作的项目做出决策。例如,即使 CTO 要求 DevOps 工程师参与一个仪表化任务,但 CTO 后来可能会拒绝使用它。通过不使用,可以推断出 DevOps 工程师永远无法进入与 CTO 同一高地位组的情况。这种行为根据 Metiu 在软件开发团队研究中被称为“封闭”[²]。

这种行为是组织中解决工程普遍问题中最大的挑战之一。当一个高地位的个人“拥有”一个组件时,历史上直到几个“低地位”的团队成员介入并共同承担责任后,它才能正常运作。这些项目包括 UI 界面、日志记录、数据中心迁移、基础设施等等。诚然,这是一个复杂的问题,并非唯一因素,但它是一个具有一定未知但显著权重的因素。

如果组织中的领导比其他人“更优”,你将永远无法实施真正的 DevOps 原则。你将只会应用最高薪人士的观点(HIPO 原则)。尽管 DevOps 可以直接挽救生命并拯救你的公司,但 HIPOs 是凶猛的动物,它们可以并且确实会摧毁它们路径上的一切。

缺乏团队合作

在武术馆中,让学生帮忙拖地是司空见惯的。这样做有很多明显的原因。这显示了对教练的尊重,教给了学生自律。然而,还有一些更微妙的原因。

这里涉及一个博弈论问题。接触到葡萄球菌感染可能会导致严重的健康问题。如果你被提供在你训练的健身房里拖地的机会,务必认真考虑你如何回应。人们会观察你清洁地板的效果,如果你做得好,他们也会因为尊重你而做得好。如果你把这个任务看作“低人一等”的事情,不认真执行,你可能会造成两个问题。首先,你没有清洁好地板,这可能会导致健身房的其他成员生病。其次,你“感染”了其他人的思维方式,他们反过来也不会认真清洁地板。你的行为在现在和未来都会有后果。

因此,通过“赢得”而没有正确清洁地板,实际上是“输掉”了,因为你在鼓励可能会危及生命的不卫生条件中扮演了角色。这个故事的寓意是什么?如果你经常在武术馆训练,并被要求拖地,请确保你以愉快的面孔做出出色的工作。你的生命可能取决于此。

让我们来看看软件公司的同样情况。许多关键任务都符合相同的特征:添加适当的日志记录、创建项目的持续部署、负载测试项目、linting 你的代码,或进行代码审查。如果你态度消极或者不完成这些任务,你的公司可能会得到一种像葡萄球菌一样的致命疾病。方法和完成都很重要。你传达给同事的信息是什么?

有一本由拉森和拉法斯特³撰写的关于团队合作的优秀书籍,涵盖了对团队进行全面和科学研究的详细内容。他们确定了八个特征,解释了有效团队如何以及为什么发展:

  • 一个明确的、激励人心的目标

  • 一个以结果为导向的结构

  • 有能力的团队成员

  • 统一的承诺

  • 一个协作的氛围

  • 卓越的标准

  • 外部支持与认可

  • 有原则的领导力

让我们回顾一些这些在组织中是如何有效或无效的例子。

一个明确的、激励人心的目标

如果你的组织没有一个明确的、激励人心的目标,那就麻烦了,完全停下来!作为一名工程师,我希望目标是制作优秀、可靠的软件。然而,在问题多多的公司里,我听到了许多目标:追逐大鱼,让亚马逊“烧毁”,我们搬到数据中心,将公司卖给“X”或“Y”。

一个以结果为导向的结构

你的组织是否采用了结果导向的工作体验(R.O.W.E)?许多公司使用的工具和流程如果不能直接归因于结果,则其可取性存疑:Skype、电子邮件、非常长的会议、“加班”。但最终,这些都无法单独帮助公司。更多地关注结果,而不是“面对面的时间”或者“Skype 上的快速响应”或电子邮件,可能会在组织中引发突破性变化。“假敏捷”呢?你的公司是否在做仪式主义的敏捷?这种流程除了在会议中消耗开发人员的时间讨论燃尽图、故事点以及使用大量流程术语外,实际上一无所获。

有能力的团队成员

毫无疑问,要想组织成功,你需要有能力的团队成员。能力并不意味着“精英”学校或者“leet code”,而是指作为团队一部分执行任务的能力和愿望。

统一的承诺

你的团队中有自私的人吗?他们只关心自己吗?他们在最后一刻对数据库进行了更改,然后在下班前没有测试就走了,因为已经是下午 4 点 35 分?他们需要赶公交车(不在乎他们是否烧毁了生产)。这种行为比任何其他事情都更快地摧毁了你的团队。在高效团队中,你不能有自私的人;他们会毁掉它。

一个协作的氛围

任务冲突的适当水平是多少?每个人不能仅仅同意彼此,因为你不会发现错误。同时,也不能让人们彼此大声争吵。这需要一个尊重的环境,人们在这样的环境中开放并期待反馈。如果天平倾向任何一方,你注定会失败。实现这种平衡说起来容易做起来难。

另一个例子是招聘流程。许多公司抱怨不能雇用、为多样性而雇用,并且普遍得到优秀的候选人。真正的问题是他们的招聘流程是“fugly”:

  1. 首先,公司 FLATTERs 候选人申请。

  2. 接下来,他们 WASTE 时间进行定制的无关紧要的测试。

  3. 然后他们通过一轮面试 HAZE 他们,其预测价值比随机还差。

  4. 然后他们 GHOST 候选人,并且不给他们任何反馈。

  5. 他们 LIE 并且说他们正在尝试雇佣人员,而实际上他们的流程已经损坏。

  6. 然后他们在社交媒体上 YELL 关于多样化,或任何候选人难以参与的问题。

你不能雇用的原因是你的流程 FUGLY!尊重他人,你将得到尊重。联系将体现在能够留住许多优秀员工身上,他们被一个优化错误事项的招聘实践毫无必要地忽视了。

卓越标准

这一步对组织来说是一个重大挑战。大多数 IT 专业人员工作非常努力,但可以提高卓越和工艺水平。另一种说法是需要更高程度的自律。需要更高的软件编写、测试和部署标准。在部署新技术之前,需要对文档阅读有更严格的要求。

一个例子是软件生命周期。在每个阶段,需要更高的标准。在工作开始之前,撰写技术概述并创建图表是很重要的。绝不能发布未经过适当 DevOps 生命周期的代码。

在基础设施方面,需要在许多步骤上遵循最佳实践,无论是动物管理员配置、EC2 存储配置,还是 mongo 或 serverless。技术堆栈中的每个组件都需要重新审视并符合最佳实践。许多情况下,文档中规定了正确配置元素的方法,但从未被阅读过!可以安全地假设,在许多公司,超过 50%的技术堆栈仍然配置不正确,尽管技术有了显著改进。

请注意,我在“长时间工作、夜晚和周末工作”与工作高度纪律性及遵循卓越标准之间作了明确区分。在软件行业,通常有太多的夜晚和周末工作,却缺乏纪律性,这相差一个数量级。低估缺乏标准和控制的重要性,仅仅告诉某人工作更长时间更努力将是一个严重错误。

最后,对于许多公司在推荐战略方向时收集定量数据,需要设定更高的标准。许多管理人员在“迁移到新数据中心”或“追求大客户”方面缺乏任何真正的定量分析,这反映了许多管理层的缺乏纪律和流程。仅凭意见来作为事实陈述的管理团队成员,而不具备数据支持,这是不够的。管理层需要高标准。公司中的每个人都可以看到,决策使用数据而非意见、等级制度、攻击性或对佣金的渴望。

外部支持和认可

历史上,DevOps 专业人士在外部支持和认可方面确实存在一些真实问题。一个显而易见的例子是值班。在科技界,情况有了显著改善。但即使在今天,许多值班人员仍然没有得到应有的认可,他们工作的辛苦和值班的挑战性并未受到重视。

在许多组织中,似乎没有为努力工作(比如主动承担值班)提供明显的奖励。如果有的话,明显的先例表明逃避责任可能会让你得到晋升,因为你足够狡猾地摆脱了低地位的工作。在我一起工作的某位员工的案例中,他说同意值班“不明智”(他的话)。他在工程部门时拒绝值班。逃避责任随后导致了晋升。当领导展示低于平均水平的承诺和诚信时,要求非凡贡献是具有挑战性的。

另一个缺乏外部支持的例子是,当一个部门将艰难的任务转嫁给另一个部门时。他们常说,“这是 DevOps 的事情,不是我的工作。” 我见过销售工程团队设置了许多环境:数据中心环境、Rackspace 环境、AWS 环境。他们不断地呼叫值班人员,尽管他们并没有设置这些环境。当销售工程师面对这个问题时,他提到自己是销售部门的,这“不是他的工作”。工程部门无法访问他设置的机器。这些机器配置错误,并不断呼叫人员。这里传达的明确信息是“不要当冤大头”,把值班的工作推卸给地位较低的“冤大头们”。

另一个缺乏外部支持的例子是,我曾在一家公司工作,在那里客户数据被意外删除了。一名销售工程师最初错误地配置了机器,存储空间不足以支持客户期望的保留期。然而,持续清理数据的责任却落在了 DevOps 的手中,成了“替罪羊”。

机器的维护需要每天多次运行危险的 Unix 命令,而且通常是在深夜。毫不奇怪,DevOps 团队的一名成员误输入了其中一个命令,导致客户数据被删除。销售工程师生气了,拒绝让客户知道;相反,他试图强迫 DevOps 工程师致电客户并进行“自我承认”。公司外部支持薄弱,管理层允许个人行为不支持的情况是有问题的。这种行为清楚地传达了管理层不会解决“棘手”问题的信息,比如解决不成熟或不道德的行为,而是将其转嫁给 DevOps。

原则领导力

在我工作过的公司里,也有一些展现原则领导力的极好例子,也有一些不幸的情况。Larson 和 LaFasto 提到,一位转型领导者通过定位来建立信任——确保领导者的行为体现了愿景的理想和方向。例如,某位首席技术官在危机期间连续几个月值班,以示与所有人的团结。这种情况是不要求别人做自己不愿意做的事情的一个例子。当个人付出了个人牺牲并且不方便时,责任感就会产生。

另一个展现原则领导力的优秀例子是与产品经理和前端团队的合作。她“要求”前端团队使用工单系统,并以身作则,积极与队列合作并处理其中的问题。结果,用户体验工程师学会了这项技能,以及规划中的重要性。她本可以只是说:“使用系统”,但她选择亲自使用。这种情况导致了一个可以量化衡量的真正成功。产品经理密切监控的工单周转率有所改善。

另一方面,一些初创公司的 CEO 提倡的做法是不道德的。有些人经常发送电子邮件,称需要“加班加点”,然后在下午 4 点就回家了。团队会注意到这种行为,其中一些后果将永远留下。另一种表述方式是称这种行为为“不真实的领导力”。

我曾见过一些情况,其中 DevOps 团队受到了骚扰,这对他们非常有害。这种骚扰是通过说这个团队工作不努力或者无能来制造的。如果这种言论来自于一个经常早退并且拒绝做具有挑战性工程任务的人,那么情况就变得更加难以忍受。骚扰本身已经够糟糕了,但如果这种骚扰来自于一个合法的懒汉,而且他们被允许恐吓人们,那就更加难以忍受。

拉森和拉法斯托还提到,任何自认为在这三个类别中表现低劣的团队最终都不会长久存在:

  • 明确而高远的目标

  • 有能力的团队成员

  • 卓越的标准

面试

Glenn Solomon

你能给 Python 和 DevOps 从业者提供一些简短的智慧吗?

所有公司都将成为软件公司。在这种增长中,将会有四到五家公司起到基础性作用。DevOps 是这种演进的关键方面。变化的速度很重要。将会创造出新的不同的工作。

个人网站

https://goinglongblog.com

公司网站

https://www.ggvc.com

公共联系信息

https://www.linkedin.com/in/glennsolomon

Andrew Nguyen

你在哪里工作,以及你在那里做什么工作?

我是旧金山大学健康信息学项目主任和健康专业系主任。我的研究兴趣包括将机器/深度学习应用于医疗数据,特别关注非结构化数据。这包括使用自然语言处理处理文本以及使用信号处理和分析处理传感器数据,这两者都极大地受益于深度学习的进展。我还是 qlaro, Inc.的创始人兼首席技术官,这是一家专注于利用机器学习和自然语言处理来帮助癌症患者从诊断到存活的数字健康创业公司。我们帮助患者优先考虑下一步需要做什么以及如何最好地向他们的医生和护理团队提问。

你最喜欢的云是什么,为什么?

虽然我最初是从 AWS 的基础设施即服务的角度探索云服务的,但最近我主要在我的工作中使用 GCP。我早期的转换纯粹是因为在部署符合 HIPAA 法规的解决方案时节省了成本。从那时起,我一直在使用 GCP,因为这是我最有经验的平台。然而,尽可能地,我会使用平台无关的工具来减少影响,以防需要进行更改。

从机器学习的角度来看,我更加中立,愿意根据具体的机器学习项目使用 AWS 或者 GCP。话虽如此,对于我接下来的项目(将涉及收集、存储和处理大量数据),我打算使用 GCP,因为在各种执行器上(包括 Google Dataflow)开发和运行 Apache Beam 作业非常方便。

你是什么时候开始使用 Python 的?

我大约 15 年前开始使用 Python 作为 Web 开发语言,当时 Django 刚刚发布。自那时起,我把它用作通用编程/脚本语言,也用作数据科学语言。

你最喜欢 Python 的哪一点?

我最喜欢的是它是一种无处不在的、可解释的、面向对象的语言。它几乎可以在任何系统上运行,并提供面向对象编程的强大性能,同时保持解释脚本语言的简洁性。

你最不喜欢 Python 的哪一点?

空白符。我理解 Python 使用空白符的理由。然而,当试图确定一个函数的作用域跨越屏幕无法显示的范围时,这种方式变得很烦人。

10 年后软件行业会是什么样子?

我认为我们会看到越来越多的人在不写太多代码的情况下进行“软件开发”。类似于 Word 和 Google Docs 如何使任何人能够轻松格式化文档而无需手动文字处理,我认为人们将能够编写小函数或使用 GUI 处理简单的业务逻辑。在某种意义上,随着 AWS Lambda 和 Google Cloud Functions 等工具成为常态,我们将看到越来越多的即插即用的函数,无需正式的计算机科学培训即可有效使用。

你会短什么技术?

我认为机器学习即服务(MLaaS)公司会被短期化——也就是说,那些专注于机器学习算法的公司。正如我们不会看到提供文字处理服务的公司一样,自动 ML 工具或平台如 AutoML 或 SageMaker 将使大多数公司能够轻松地将 ML 能力内部化。虽然我们不能使用这些工具解决所有 ML 问题,但我们可能能解决 80 到 90%的问题。因此,仍然会有公司创建新的 ML 方法或提供 ML 作为服务,但我们将看到围绕主要云提供商的巨大整合(而不是今天看到的无数“做机器学习”的公司)。

你会推荐对 Python DevOps 感兴趣的人学习什么最重要的技能?

学习概念,而不仅仅是工具和工具链。新的范式会来去,但对于每一个范式,我们会看到数十种竞争的工具和库。如果你只学习特定的工具或库,当新的范式开始显现并接管时,你很快就会落后。

你会推荐别人学习什么最重要的技能?

学会如何学习。弄清楚你如何学习以及如何快速学习。与摩尔定律类似,我们看到每一代处理器速度翻倍,我们正在看到 DevOps 工具的加速增长。有些构建在现有方法的基础上,而其他人试图取而代之。无论如何,你需要知道如何学习,以便快速有效地了解越来越多的工具,然后迅速决定是否值得追求。

告诉读者一些关于你的酷事情

我喜欢徒步旅行,背包旅行,总之喜欢待在户外。空闲时间里,我也在当地警长办公室的搜救队做志愿者,通常在森林中寻找失踪的人,也会在像加利福尼亚天堂镇的“野火”等灾难期间参与搜救工作。

Gabriella Roman

你叫什么名字,目前从事什么职业?

你好!我的名字是 Gabriella Roman,我目前是波士顿大学的计算机科学本科生。

你在哪里工作,以及你在那里做什么?

我是红帽公司的实习生,主要在 Ceph 团队工作。我主要与 ceph-medic 这个 Python 工具合作,用于对 Ceph 集群进行检查,无论是修复旧检查中的错误还是解决新检查中的问题。我还与 DocUBetter 团队合作更新 Ceph 的文档。

你最喜欢的云是什么,为什么?

尽管我只用过 Google Cloud Storage,但我实在找不出它成为我最喜欢的理由。我只是碰巧试用了它,而且没有理由讨厌它,所以过去 10 年来我一直忠于它。我喜欢它简洁的界面,作为一个不喜欢保留太多数字杂乱的人,它的 15GB 限制并不困扰我。

你是什么时候开始使用 Python 的?

我第一次学习 Python 是在大二下学期的一门计算机科学导论课上。

你最喜欢 Python 的什么地方?

它的可读性。Python 的语法是所有编程语言中最简单的之一,这使得它成为初学者的绝佳选择。

你最不喜欢 Python 的什么地方?

我对其他编程语言的经验还不足以进行比较。

软件行业未来 10 年会是什么样子?

几乎不可能预知未来会带来什么,尤其是在这个不断变化的领域。我唯一能说的是,我希望软件行业能继续朝着积极的方向发展,并且软件不被不当使用。

你会向有兴趣学习 Python 的人推荐最重要的技能是什么?

遵循良好的代码风格,特别是在团队协作时,有助于避免很多不必要的头疼。作为一个 Python 新手,我发现当我阅读整理有序并有详细文档的代码时特别有帮助。

你会向别人推荐学习的最重要的技能是什么?

这不完全是一种技能,更像是一种心态:要愿意学习新东西!我们一直在学习,即使在最意想不到的时候,所以保持开放的心态,允许他人与你分享知识!

告诉读者一些关于你的酷事情

我非常喜欢玩视频游戏!我最喜欢的一些游戏包括《最后的生还者》、《空洞骑士》和《英雄联盟》。

专业网站

https://www.linkedin.com/in/gabriellasroman

Rigoberto Roche

你在哪里工作,以及你在那里做什么?

我在 NASA Glenn Research Center 担任机器学习和智能算法团队的首席工程师。我的工作是开发决策算法来控制空间通信和导航的所有方面。

你最喜欢的云平台是什么,为什么?

由于它在我的工作流程中的可用性,我最熟悉的是 Amazon Web Services。

你什么时候开始使用 Python?

2014

你最喜欢 Python 的什么?

易于阅读的代码和快速的开发时间。

你最不喜欢 Python 的什么?

空白分隔。

软件行业未来 10 年会是什么样子?

很难说。似乎有一股推动云计算和分散式编程的潮流,将开发者推向作为一切的独立承包商。这将成为一个零工经济,而不是一个大型商业产业。最大的转变将是使用自动编码工具来分离创造性开发与语法学习任务。这可以为更多的创意专业人士开发新事物和新系统打开大门。

你会短暂什么技术?

Uber 和 Lyft。任何可以由狭义 AI 自动化的体力劳动:驾驶、仓储、法律助理工作。可以通过深度学习解决的问题。

你会建议对 Python DevOps 感兴趣的人学习的最重要的技能是什么?

快速学习的能力,标准是“一个月内你能成为危险人物吗?”另一个是理解和从基本原理构建的能力,“像一名物理学家”,通过自己的实际工作理解比理论更多。

你会建议某人学习的最重要的技能是什么?

脑钩(记忆宫殿)、番茄工作法和间隔回忆自测用于内容吸收。

告诉读者一些有趣的事情关于你。

我喜欢像 Rickson Gracie 的柔术风格和 Mussad Krav Maga(不是运动类的)。我在这个世界上的激情是建立一个真正思考的机器。

个人网站

只需谷歌我的名字。

个人博客

没有一个。

公司网站

www.nasa.gov

公开联系信息

rigo.j.roche@gmail.com

Jonathan LaCour

你在哪里工作,做什么工作?

我是 Mission 的 CTO,一家专注于 AWS 的云咨询和托管服务提供商。在 Mission,我负责制定和定义我们的服务提供和领导我们的平台团队,专注于通过自动化推动效率和质量。

你最喜欢的云平台是什么,为什么?

我在公共云领域有深厚的根基,既是公共云服务的消费者,也是构建者。这些经验让我深知 AWS 提供了最深、最广泛、最广泛的公共云服务。因为 AWS 是市场的领导者,他们也拥有最大的开源工具、框架和项目社区。

你什么时候开始使用 Python?

我在 1996 年底第一次开始使用 Python,当时是在 Python 1.4 发布时。那时候,我还在高中,但在空闲时间里为一家企业医疗保健公司工作作为程序员。Python 立即让我感到“如鱼得水”,从那时起,我一直把 Python 作为我的首选语言。

Python 中你最喜欢的是什么?

Python 是一种非常低摩擦的语言,愉快地淡化到背景中,使开发者能够专注于解决问题,而不是与不必要的复杂性作斗争。正因为如此,使用 Python 简直太有趣了!

Python 中你最不喜欢的是什么?

Python 应用程序的部署和分发比我想象中更困难。像 Go 这样的语言可以构建成便于分发的便携式二进制文件,而 Python 程序则需要更多的努力。

十年后的软件行业会是什么样子?

过去的十年见证了公共云服务的兴起,专注于基础设施即代码和基础设施自动化。我相信接下来的十年将会见证无服务器架构和托管服务的兴起。应用程序将不再围绕“服务器”的概念构建,而是围绕服务和函数构建。许多人将从服务器转向容器编排平台,如 Kubernetes,而其他人将直接跨越到无服务器。

你会推荐对技术感兴趣的人做什么?

区块链。虽然是一种有趣的技术,但其适用性的过度扩展令人震惊,该领域充斥着忽悠者和兜售区块链作为解决所有问题的解决方案的推销员。

你会推荐对 Python DevOps 感兴趣的人学习哪些最重要的技能?

自从 1996 年开始使用 Python 以来,我发现学习的最重要动力是好奇心和自动化的驱动力。Python 是一种极好的自动化工具,一个好奇的头脑可以不断找到新的方法来自动化从我们的业务系统到我们的家庭的一切。我鼓励任何刚开始学习 Python 的人寻找机会通过用 Python 解决真正的问题来“解决你自己的问题”。

你会推荐有兴趣学习的人学习哪些最重要的技能?

同理心。技术人员往往会在不考虑其对人类和彼此影响的情况下接受技术。同理心对我来说是一种核心价值观,它帮助我成为更好的技术人员、经理、领导和人类。

向读者介绍一些关于你自己的酷炫事情。

在过去的三年里,我一直在重建我的个人网站,汇集了从 2002 年以来的内容。现在,我的网站成为了我个人的记忆、照片、文章等的档案馆。

个人网站

https://cleverdevil.io

个人博客

https://cleverdevil.io

公司网站

https://www.missioncloud.com

公开联系信息

https://cleverdevil.io

Ville Tuulos

你在哪里工作,做什么工作?

我在 Netflix 工作,负责领导我们的机器学习基础设施团队。我们的工作是为数据科学家提供一个平台,使他们能够快速原型化端到端的机器学习工作流,并有信心将其部署到生产环境中。

你最喜欢的云是什么,为什么?

我是 AWS 的铁杆粉丝。我从 2006 年 EC2 beta 版本开始使用 AWS。AWS 技术上和商业上仍然给我留下深刻印象。他们的核心基础设施,如 S3,具有极好的扩展性和性能,并且非常稳定。从商业角度来看,他们做对了两件事情。他们拥抱开源技术,在许多情况下使采纳变得更容易,并且他们非常重视客户的反馈。

你什么时候开始使用 Python?

我大约在 2001 年开始使用 Python。我记得当时我刚开始使用 Python 不久,就对生成器和生成器表达式的发布感到非常兴奋。

你对 Python 最喜欢的是什么?

我对编程语言非常着迷。不仅在技术上,而且作为人类沟通的媒介和文化。Python 是一种非常平衡的语言。在许多方面,它是一种简单且易于上手的语言,但同时又足够表达复杂的应用程序。它并不是高性能语言,但在大多数情况下性能足够好,特别是在涉及 I/O 时。许多其他语言更适合特定的使用场景优化,但只有少数语言像 Python 一样全面。

此外,CPython 实现是一段简单直接的 C 代码,比起 JVM、V8 或 Go 运行时要简单得多,这使得在需要时易于调试和扩展。

你对 Python 最不喜欢的是什么?

Python 作为一个全面的通用语言的另一面是,它并不是任何特定用例的最佳语言。在处理任何性能关键的工作时,我会想念 C 语言。在构建需要并发性的任何项目时,我会想念 Erlang。在进行算法编程时,我会想念 OCaml 的类型推断。讽刺的是,当使用这些语言中的任何一种时,我会想念 Python 的通用性、务实性和社区。

软件行业 10 年后会是什么样子?

如果你看过过去 50 年的计算机发展,趋势是显而易见的。软件正在改变世界,软件行业在技术栈中不断向上移动。相对而言,我们关注硬件、操作系统和低级编程的人越来越少。相应地,我们有越来越多的写软件的人缺乏底层技术栈的经验或知识,这是可以接受的。我认为这一趋势在很大程度上促成了 Python 的成功至今。我预计我们将看到越来越多像 Python 这样以人为本的解决方案,这样我们就能赋予越来越多的人构建软件的能力。

你会缩短哪种技术?

我倾向于对那些认为技术因素胜过人文因素的技术进行缩短。历史上有许多技术在技术上很出色,但未能体察到用户实际需求。采取这种立场并不总是容易的,因为工程师的本能是认为技术上优雅的解决方案应该胜出。

您会推荐有兴趣学习 Python DevOps 的人学习哪个最重要的技能?

我建议任何严肃对待 Python 的人,特别是 DevOps 领域的人,了解一些关于函数式编程的知识。没有什么非常深入的内容,只是关于幂等性、函数组合以及不可变性的思维方式。我认为函数式思维对于大规模 DevOps 非常有用:如何考虑不可变基础设施、打包等等。

您会推荐有兴趣学习的人学习哪个最重要的技能?

学会识别哪些问题值得解决是至关重要的。我经常观察到有些软件项目几乎投入了无限的资源来解决最终并不重要的问题。我发现 Python 是一个很好的工具来磨练这种技能,因为它可以让您快速原型化完全功能的解决方案,帮助您看清什么是相关的。

向读者介绍一些有趣的事情。

我和一个朋友一起破解了一个在纽约市进行的城市游戏。玩家用手机拍摄的照片实时投射到时代广场的巨大广告牌上。关于这个游戏的一个酷炫的事情是,整个游戏,包括手机上运行的客户端,都是用 Python 编写的。更酷的是,这个游戏是在 2006 年举行的,在智能手机的侏罗纪时代,早于 iPhone。

个人网站

https://www.linkedin.com/in/villetuulos

公司网站

https://research.netflix.com

公共联系信息

@vtuulos 在 Twitter 上

约瑟夫·赖斯

您在哪里工作以及您在那里做什么?

我是 Ternary Data 的联合创始人。我主要从事销售、市场营销和产品开发。

您最喜欢的云平台是什么?为什么?

在 AWS 和 Google Cloud 之间取舍很难。我发现 AWS 更适合应用程序,但 Google Cloud 在数据和 ML/AI 方面更为优越。

你何时开始使用 Python?

2009 年

你最喜欢 Python 的什么?

通常只有一种方法可以解决问题,因此在减少解决问题最佳解决方案所需的心理负担方面有所帮助。按照 Python 的方式去做,然后继续前进。

你最不喜欢 Python 的什么?

GIL 是我最不喜欢的东西。不过幸运的是,世界似乎正在朝着解决 GIL 的方向发展。

10 年后,软件行业会是什么样子?

可能很像现在这样,尽管采用了更快的迭代周期、最佳实践和新工具。新的就是旧的,旧的就是新的。不变的是人。

你会卖空哪种技术?

我会短期做空 AI,但在未来几十年内会非常看好 AI。围绕 AI 的大量炒作可能会导致短期内一些人心碎。

你会推荐对 Python DevOps 感兴趣的人学习什么技能?

尽可能自动化一切。Python 是一个极好的语言,可以简化你的生活和公司的流程。一定要充分利用这种强大的力量。

你会推荐别人学习什么最重要的技能?

学会保持成长的心态。灵活、适应性强和能够学习新事物将使您在很长一段时间内保持相关性。随着技术和世界的超快变化,学习的机会将是无穷无尽的...主要是因为你必须要学习 😃.

告诉读者关于你的一些酷炫事情。

我曾经是一个攀岩狂人、俱乐部 DJ 和冒险家。现在虽然我有了一份攀岩工作,依然当 DJ,并尽可能多地冒险。所以,没有太多改变,但探索和做危险的事情的冲动仍在持续。

个人网站和博客

https://josephreis.com

公司网站

https://ternarydata.com

公共联系信息

josephreis@gmail.com

Teijo Holzer

你在哪里工作,你在那里做什么?

我在新西兰 Weta Digital 担任高级软件工程师已有 12 年。我的职责包括软件开发(主要是 Python 和 C ++),但偶尔也会执行系统工程和 DevOps 任务。

你最喜欢的云是什么,为什么?

一定是 AWS。

他们提供的主要特性之一是支持持续集成和交付。在软件工程中,您希望自动化尽可能多的单调任务,这样您就可以集中精力进行创新软件开发中有趣的部分。

通常你不想考虑的事情是代码构建、运行现有的自动化测试、发布和部署新版本、重新启动服务等。因此,你希望依赖像 Ansible、Puppet、Jenkins 等工具,在特定的定义点(例如,当你将新功能分支合并到主分支时)自动执行这些任务。

另一个重要的优点是在线论坛如 Stack Overflow 等提供的大量支持。作为云平台领域的当前市场领导者,自然会拥有更大的用户群体提出问题和解决问题。

你是什么时候开始使用 Python 的?

我开始使用 Python 超过 15 年,并且有超过 12 年的专业 Python 经验。

关于 Python,你最喜欢的是什么?

永远不需要重新格式化你的源代码。选择让空白符号具有语法/语法意义意味着其他人的 Python 代码立即具有非常高的可读性评分。我也喜欢 Python 许可证,这导致 Python 作为脚本语言在许多第三方商业应用程序中广泛使用。

关于 Python,你最不喜欢的是什么?

在复杂环境中执行高度并发任务的困难程度。在 Python 中,实现高效可靠的线程和多进程仍然是困难的。

未来 10 年软件行业会是什么样子?

在我看来,更加重视在现有基础设施和工具基础上,能够在紧迫时间框架内集成和提供以客户为中心的解决方案。没有必要不断重新发明轮子。因此,在软件行业中,系统工程和 DevOps 技能将变得更加重要。如果需要的话,你需要能够快速扩展。

你会对什么技术感到不安?

任何有单点故障的系统。构建健壮的系统需要你承认所有系统最终都会失败,因此你需要在每个层次上为此做好准备。首先,不要在你的代码中使用断言语句,然后提供高可用性、多主数据库服务器。当有许多用户全天候依赖你的系统时,构建容错系统尤为重要。即使 AWS 也只提供 99.95%的正常运行时间。

你会推荐对 Python DevOps 感兴趣的人学习的最重要的技术是什么?

快速的自动化。每当你发现自己一遍又一遍地重复相同的任务,或者你再次发现自己在等待长时间运行的任务完成时,问问自己:我怎么能自动化并加快这些任务?快速的周转时间对于有效的 DevOps 工作至关重要。

你会推荐别人学习的最重要的技能是什么?

如上所述,快速的自动化。

告诉读者一些有趣的关于你的事情。

我喜欢在 Python 会议上做演讲。请关注我在 Kiwi PyCon X 关于 Python、线程和 Qt 的最新演讲。

最近的演讲

https://python.nz/kiwipycon.talk.teijoholzer

公司网站

http://www.wetafx.co.nz

Matt Harrison

你在哪里工作?你在那里做什么?

我在一家名为 MetaSnake 的公司工作,这是我创建的公司。它提供 Python 和数据科学的企业培训和咨询服务。我大约一半的时间用来教工程师如何提高 Python 的生产力或如何进行数据科学。另一半时间用于咨询和帮助公司利用这些技术。

你最喜欢的云平台是什么?为什么?

我过去使用过 Google 和 AWS。它们(及其他公司)都有出色的 Python 支持,这点我很喜欢。我不知道我有没有最喜欢的,但我很高兴有多个云平台,因为我相信竞争会带给我们更好的产品。

你何时开始使用 Python?

我是在 2000 年开始使用 Python 的,当时在一家小型初创公司做搜索。我和一个同事需要建立一个小型原型。我倾向于使用 Perl,他想使用 TCL。Python 是一个妥协,因为我们都不想使用对方喜欢的技术。我相信我们当时都迅速忘记了之前使用的是什么,此后我一直在使用 Python。

你最喜欢 Python 的什么?

Python 非常适合我。从简单的东西开始构建 MVP,然后再将其投入生产。我非常喜欢使用 Jupyter 和 Colab 这样的笔记本环境。它们使数据分析变得非常互动。

你最不喜欢 Python 的什么?

类的内置文档字符串,如列表和字典,需要进行一些清理。它们对新手来说很难理解。

软件行业 10 年后会是什么样子?

我没有水晶球。对我来说,现在和 10 年前的主要区别是利用云。否则,我使用许多相同的工具。我预计未来 10 年的编程将非常相似,仍然会出现一些错位错误,CSS 仍然很难,也许部署会稍微容易些。

你会短线哪种技术?

我想象专有的数据分析工具将会像恐龙一样灭绝。也许会有努力将其开源以拯救它们,但为时已晚。

你会推荐对 Python DevOps 感兴趣的人学习的最重要的技能是什么?

我认为好奇心和学习意愿非常重要,特别是因为许多工具都在快速发展。似乎总是有新的产品或新的软件出现。

你会推荐别人学习的最重要的技能是什么?

我有两个答案。首先,学习如何学习。人们有不同的学习方式。找到适合自己的方式。

另一项技能不是技术性的。那就是学习如何建立人际网络。这不必是个贬义词,对科技行业的人非常有用。我大部分的工作和工作机会都来自于人际网络。这将带来巨大的回报。

向读者介绍一些关于你的酷事。

我喜欢外出活动。这可能是跑步、究极徒步旅行或滑雪。

个人网站/博客

https://hairysun.com

公司网站

https://www.metasnake.com

公开联系信息

matt@metasnake.com

迈克尔·福德

你在哪里工作,做什么工作?

我过去的两份工作都是在 DevOps 工具上工作,这导致我对这个主题产生了不情愿的热情。不情愿是因为我曾长期怀疑 DevOps 运动,认为这主要是管理者希望开发人员也做系统管理员的工作。我现在看到,我真正关心的 DevOps 部分是系统级的思维,以及开发过程完全意识到这一点。

我在 Canonical 为 Juju 工作了三年,在那里我尝试了使用 Go 进行编程,然后在 Red Hat 为 Ansible Tower 构建了一个测试自动化系统。从那以后,我一直是自由职业者,同时进行培训、团队指导和合同工作,包括我现在正在进行的一个 AI 项目。

在我丰富的空闲时间里,我作为 Python 核心开发团队的一员工作在 Python 本身上。

你最喜欢的云是什么,为什么?

我要稍微侧面回答这个问题。我的最爱云是所有云,或者至少不用太担心我使用的是哪个云。

Juju 模型以与后端无关的方式描述您的系统。它提供了一种描述您的服务及其之间关系的建模语言,然后可以部署到任何云。

这使您可以从 AWS 或 Azure 等平台开始,并出于成本或数据安全原因迁移到像 Kubernetes 或 OpenStack 这样的本地私有云,而无需更改工具。

我喜欢控制我的主要依赖项,因此我更喜欢使用类似 OpenStack 的东西而不是公共云。我也是 Canonical 的 MaaS(金属即服务)的粉丝,这是一个裸金属提供程序。我相信它最初是作为 Cobbler 的一个分支启动的。您可以直接使用它,或者作为管理硬件的私有云的基础。我编写了连接到 MaaS 2 API 的 Juju 代码,并对其印象深刻。

我更喜欢 LXC/LXD 或 KVM 虚拟化,而不是 Docker(在今天几乎是异端邪说),因此 Kubernetes 或 OpenShift 不会是我的首选。

对于商业项目,我有时会推荐 VMware 的云解决方案,主要是因为这些系统的管理员使用情况。

你什么时候开始使用 Python?

我大约在 2002 年开始以 Python 作为业余爱好进行编程。我非常喜欢它,以至于在 2006 年左右全职从事编程工作。我很幸运地在伦敦的一家金融科技初创公司找到了一份工作,那里我真正学到了软件工程的技艺。

你最喜欢 Python 的哪个方面?

这是实用主义。Python 非常实用,这使得它在处理实际任务时非常有用。这种实用性延伸到了力求使理论与实践相匹配的对象系统。

这就是为什么我喜欢教 Python。大部分情况下,理论与实践是一致的,所以你可以同时教授它们。

你对 Python 最不喜欢的是什么?

Python 已经很老了,如果包括标准库的话,就更加庞大了。Python 有一些缺陷,比如描述符协议中的对称性不足,这意味着你不能为类描述符编写设置器。但这些大多数都是小问题。

对我来说,最大的问题是缺乏真正的自由线程。在多核世界中,这变得越来越重要,而 Python 社区多年来一直对此视而不见。幸运的是,我们现在看到核心开发人员正在采取实际步骤来解决这个问题。子解释器支持已经有几个 PEP 在积极地工作中。也有人正在考虑[可能]放弃引用计数的垃圾回收,这将使得自由线程变得更加容易。事实上,Larry Hastings 在他的 Gilectomy 实验中已经完成了大部分工作,但仍受到引用计数的限制。

软件行业未来十年会是什么样子?

我认为我们现在正处于人工智能的早期热潮中。这将会产生成千上万个短命和无用的产品,但也将彻底改变整个行业。人工智能将成为大多数大型系统的标准组成部分。

此外,DevOps 正在为我们提供一种思考系统开发、部署和维护的方式。我们已经在微服务和多语言环境中看到了其影响。我认为我们将会看到新一代 DevOps 工具的兴起,这些工具将民主化系统级思维,使得构建和维护大规模系统变得更加容易。这些问题将变得更加“解决”,而前沿领域将扩展到尚未描述的新挑战。

你会对哪种技术进行空头交易?

哦,这是一个具有挑战性的问题。我要说的是当前一代的 DevOps 工具。

DevOps 的天才在于将复杂的部署和配置知识编码化;例如,使用 Ansible 编写的 playbooks 和 Juju 的 Charms。

理想的 DevOps 工具将允许您以一种与后端无关的方式描述和编排系统,并且还将整合监控和对系统状态的感知。这将使得部署、测试、重新配置、扩展和自我修复变得简单且成为标准功能。

或许我们需要一个面向云的应用商店。我认为很多人都在努力朝这个方向发展。

你会推荐对 Python DevOps 有兴趣的人学习什么技能?

我倾向于通过实践学习,所以我反感被告知学习什么。我肯定是为了学习新技能而接受工作的。我加入 Canonical 的初衷是因为我想学习 Web 开发。

因此,实际经验胜过学习。话虽如此,虚拟机和容器很可能仍然是系统设计和部署的基本单位。熟练地操作容器具有非常强大的能力。

网络技能很难,也很重要,是一项非常宝贵的技能。结合软件定义网络层中的容器,这将是一个强大的组合。

你推荐别人学习的最重要的技能是什么?

你永远不会知道足够多,所以最重要的技能是学习和改变的能力。如果你能够改变,你永远不会被困住。被困住是世界上最糟糕的事情。

告诉读者关于你的一些酷炫事情。

我从剑桥大学辍学,曾经无家可归,多年来生活在社区中,我卖过砖十年,自学编程。现在我是 Python 核心开发团队的一员,并有幸游历世界演讲和教授 Python。

个人网站/博客

http://www.voidspace.org.uk

公开联系信息

michael@voidspace.org.uk

推荐

“所有模型都是错误的……但有些是有用的”,这当然适用于任何关于 DevOps 的一般建议。我的分析中有些部分肯定是错误的,但有些部分是有用的。我个人的偏见不可避免地在我的分析中起作用。尽管在一些分析中可能是错误的并且存在极大的偏见,但大多数公司管理中显然存在一些紧迫的问题需要解决。其中一些最优先的问题包括:

  1. 地位差异导致了责任问题,软件稳定性是一个非常显著的例子。工程经理(特别是创业公司的创始人)特别需要承认非正式地位封闭如何影响软件质量,并加以修复。

  2. 许多组织存在一种无意义风险的文化(发射银子弹对比修复破窗),这是一个问题。

  3. 许多组织在卓越标准上存在无效或无意义,并且在工程领域普遍缺乏纪律。

  4. 文化上,数据并未被用来做出决策。最高薪水者的意见(HIPO),地位、攻击性、直觉,甚至是掷骰子可能都是决策的原因。

  5. 高管对“机会成本”概念的真正理解依然难以达成。这种理解的缺乏随后影响了各个层级。

  6. 需要增加对“精英主义”而非“蛇油和胡扯”的关注,正如 Kaggle 的高级数据科学家杰里米·霍华德所言。

几个月内在工程领域可以落实的“正确”事情:工单系统、代码审查、测试、规划、调度等。公司的高级领导团队可以同意这些是正确的事情,但是他们的行动必须与他们的话语一致。而不是专注于执行、一致性和问责制,高级领导层通常专注于用高性能象枪射银子弹。不幸的是,他们经常错过每头大象。高级管理团队应该从这些错误中吸取教训,避免围绕这些错误产生负面文化。

练习

  • 一个高效团队所需的核心组成部分是什么?

  • 描述你作为团队成员可以改进的三个领域。

  • 描述你作为团队成员擅长的三个领域。

  • 未来所有公司都正确的是什么?

  • 为什么 DevOps 需要外部支持和认可?

挑战

  • 使用 Larson 和 LaFasto 的团队合作框架,对你当前的团队进行详细分析。

  • 让团队的每个人填写匿名索引卡,上面列出每个小组成员的三个积极和三个有价值的反馈项目(必须包括积极和负面问题)。让每个人在房间里读出来来自队友的索引卡。(是的,这确实有效,并且可能是团队成员的一次改变人生的经历。

毕业项目

现在你已经读完这本书,可以完成一个毕业项目,展示你掌握的概念:

  • 使用书中探讨的思想,创建一个通过 Flask 提供预测的 scikit-learn、PyTorch 或 TensorFlow 应用程序。在完成所有这些任务的同时将该项目部署到主要的云提供商:

    • 终端和健康检查监控

    • 连续交付到多个环境

    • 记录到像 Amazon CloudWatch 这样的云服务

    • 进行性能负载测试并创建可伸缩性计划

¹ Anca Metiu,《掌控代码:分布式团队中的状态封闭》,《组织科学》,(2006 年 7-8 月)。

² Anca Metiu,《掌控代码:分布式团队中的状态封闭》,《组织科学》,(2006 年 7-8 月)。

³ Larson, C. E., & LaFasto, F. M. J.(1989)。《人际交流中的智慧系列,第 10 卷:团队合作:什么必须做对/什么可能出错》。千橡市,加利福尼亚州,美国:Sage 出版公司。

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(19)  评论(0编辑  收藏  举报