Python-DevOps-教程-全-
Python DevOps 教程(全)
一、安装 Python
在我们开始使用 Python 之前,我们需要安装它。一些操作系统,如 Mac OS X 和一些 Linux 变种,已经预装了 Python。这些版本的 Python,俗称“系统 Python”,对于想用 Python 开发的人来说,通常都是很差的默认设置。
首先,安装的 Python 版本通常落后于最新的实践。另一方面,系统集成商通常会以可能导致意外的方式修补 Python。例如,基于 Debian 的 Python 经常缺少像venv
和ensurepip
这样的模块。Mac OS X Python 链接到其本机 SSL 库周围的 Mac shim。这些事情意味着,尤其是在开始使用 FAQ 和 web 资源时,最好从头开始安装 Python。
我们将介绍几种方法以及每种方法的优缺点。
1.1 操作系统包
对于一些更受欢迎的操作系统,志愿者已经建立了现成的安装包。
其中最著名的是“死蛇”PPA(个人包档案)。名称中的“死”指的是那些包已经被构建的事实——比喻源代码是“活的”那些包是为 Ubuntu 构建的,通常会支持上游仍然支持的所有版本的 Ubuntu。获取这些包很简单:
$ sudo add-apt-repository ppa:deadsnakes/ppa
$ sudo apt update
在 Mac OS 上,homebrew
第三方包管理器将拥有最新的 Python 包。家酿啤酒的介绍超出了本书的范围。由于家酿是一个滚动版本,Python 的版本会不时升级。虽然这意味着这是一种获得最新 Python 的有用方法,但对于可靠地分发工具来说,这是一个糟糕的目标。
对于日常开发来说,这也是一个有一些缺点的选择。因为它在新的 Python 版本发布后会快速升级,这意味着开发环境会很快中断,并且没有任何警告。这也意味着有时代码可能会停止工作:即使您小心地观察即将到来的重大变化,也不是所有的包都会这样。当需要一个构建良好的、最新的 Python 解释器来完成一次性任务时,自制 Python 是一个很好的选择。编写一个快速的脚本来分析数据,或者自动化一些 API,是对 Homebrew Python 的一个很好的利用。
最后,对于 Windows,可以从 Python.org 下载任何版本 Python 的安装程序。
1.2 使用 Pyenv
Pyenv 往往是为本地开发安装 Python 的最高投资回报。初始设置确实有一些微妙之处。然而,它允许根据需要并排安装任意多的 Python 版本。它允许管理一个将被访问的方式:基于每个用户的默认或每个目录的默认。
安装 pyenv 本身依赖于操作系统。在 Mac OS X 上,最简单的方法是通过自制软件安装。注意,在这种情况下,pyenv 本身可能需要升级以安装新版本的 Python。
在基于 UNIX 的操作系统上,比如 Linux 或 FreeBSD,安装 pyenv 最简单的方法是使用curl|bash
命令:
$ PROJECT=https://github.com/pyenv/pyenv-installer \
PATH=raw/master/bin/pyenv-installer \
curl -L $PROJECT/PATH | bash
当然,这也带来了自身的安全问题,可以用两个步骤来代替:
$ git clone https://github.com/pyenv/pyenv-installer
$ cd pyenv-installer
$ bash pyenv-installer
用户可以在运行之前检查 shell 脚本,甚至可以使用git checkout
锁定特定的修订版本。
遗憾的是,pyenv 不能在 Windows 上运行。
安装 pyenv 后,将其与运行的 shell 集成在一起是很有用的。我们通过向 shell 初始化文件(例如,.bash_profile
)添加以下内容来实现这一点:
export PATH="~/.pyenv/bin:$PATH"
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
这将允许 pyenv 正确拦截所有需要的命令。
Pyenv 将安装的解释器和可用的解释器的概念分开。为了安装一个版本,
$ pyenv install <version>
对于 CPython 来说,<version>
只是版本号,比如3.6.6
或者3.7.0rc1
。
已安装的版本不同于可用版本。通过使用,版本可以“全局地”(对于用户)可用
$ pyenv global 3.7.0
或者在本地使用
$ pyenv local 3.7.0
本地意味着它们将在给定的目录中可用。这是通过在这个目录中放置一个文件python-version.txt
来完成的。这对版本控制的存储库很重要,但是有一些不同的策略来管理它们。一种是将该文件添加到“忽略”列表中。这对开源项目的异质团队很有用。另一种方法是签入这个文件,以便在这个存储库中使用相同版本的 Python。
注意, pyenv ,因为它被设计成并排安装 Python 的版本,所以没有“升级”Python 的概念。为了使用更新的 Python,需要安装 pyenv ,然后设置为默认。
默认情况下, pyenv 安装 Python 的非优化版本。如果需要优化版本,
env PYTHON_CONFIGURE_OPTS="--enable-shared
--enable-optimizations
--with-computed-gotos
--with-lto
--enable-ipv6" pyenv install
将构建一个与来自python.org
的二进制版本非常相似的版本。
1.3 从源代码构建 Python
从源代码构建 Python 的主要挑战是,在某种意义上,它太宽容了。禁用一个内置模块来构建它太容易了,因为没有检测到它的依赖关系。这就是为什么知道哪些依赖关系是脆弱的,以及如何确保本地安装是好的非常重要。
第一个脆弱的依赖是ssl
。默认禁用,必须在Modules/Setup.dist
中启用。仔细遵循那里关于 OpenSSL 库位置的说明。如果你已经通过系统包安装了 OpenSSL,它通常会在/usr/
中。如果您已经从源代码安装了它,它通常会在/usr/local
中。
最重要的是知道如何测试它。当 Python 完成构建后,运行./python.exe -c 'import _ssl'
。这个.exe
不是一个错误——这是构建过程调用刚刚构建好的可执行文件的方式,该文件在安装过程中被重命名为python
。如果成功了,那么ssl
模块就被正确构建了。
另一个可能构建失败的扩展是sqlite
。因为它是内置的,所以很多第三方的包都依赖于它,即使你自己没有使用它。这意味着没有sqlite
模块的 Python 安装是相当糟糕的。通过运行./python.exe -c 'import sqlite3'
进行测试。
在基于 Debian 的系统(比如 Ubuntu)中,需要使用libsqlite3-dev
才能成功。在基于 Red Hat 的系统(比如 Fedora 或 CentOS)中,需要使用libsqlite3-dev
才能成功。
接下来,用./python.exe -c 'import _ctypes'
检查_ctypes
。如果失败,很可能没有安装libffi
割台。
最后,记得在从源代码构建之后运行内置的回归测试套件。这是为了确保在构建包时没有愚蠢的错误。
1.4 皮比
Python 的“通常”实现有时被称为“CPython”,以区别于语言本身。最流行的替代实现是 PyPy。PyPy 是 Python 在 Python 中基于 Python 的 JIT 实现。因为它有一个动态的 JIT(即时)编译到汇编,它有时可以获得比普通 Python 显著的速度提升(3 倍甚至 10 倍)。
使用 PyPy 有时会有挑战。许多工具和软件包只能用 CPython 进行测试。然而,如果性能很重要,有时花精力检查 PyPy 是否与环境兼容是值得的。
从源代码安装 Python 有一些微妙之处。虽然理论上使用 CPython 进行“翻译”是可能的,但实际上 PyPy 中的优化意味着使用 PyPy 进行翻译可以在更合理的机器上工作。即使从源代码安装,也最好先安装一个二进制版本的引导程序。
引导版本应该是 PyPy,而不是 PyPy3。PyPy 是用 Python 2 方言编写的。这是唯一一种不用担心贬值的情况,因为 PyPy 是 Python 2 方言解释器。PyPy3 是 Python 3 方言的实现,通常更适合在生产中使用,因为大多数包都在慢慢放弃对 Python 2 的支持。
最新的 PyPy3 支持 Python 的 3.5 特性,以及 f 字符串。然而,Python 3.6 中添加的最新异步特性并不工作。
1.5 蟒蛇
Anaconda Python 是最接近“系统 Python”的一种,仍然可以合理地用作开发平台。Anaconda 是一种所谓的“元分发”本质上,它是操作系统之上的一个操作系统。Anaconda 植根于科学计算社区,因此它的 Python 为许多科学应用提供了易于安装的模块。从 PyPI 安装许多这样的模块并不容易,需要复杂的构建环境。
可以在同一台机器上安装多个 Anaconda 环境。当需要不同的 Python 版本或不同版本的 PyPI 模块时,这很方便。
为了引导 Anaconda,我们可以使用bash
安装程序,可以从 https://conda.io/miniconda.html
获得。安装程序还会修改~/.bash_profile
来添加安装程序conda
的路径。
Conda 环境使用conda create --name <name>
创建,使用source conda activate <name>
激活。没有简单的方法来使用未激活的环境。可以在安装软件包的同时创建一个 conda 环境:conda create --name some-name python
。我们可以使用= – conda create --name some-name python=3.5
来指定版本。在环境被激活后,也可以使用conda install package[=version]
在 conda 环境中安装更多的包。Conda 有很多预构建的 Python 包,尤其是那些在本地构建的包。如果这些包对您的用例很重要,这是一个很好的选择。
1.6 摘要
运行 Python 程序需要在系统上安装解释器。根据操作系统和所需的版本,有几种不同的方法来安装 Python。使用系统 Python 是一个有问题的选择。在 Mac 和 UNIX 系统上,使用pyenv
几乎总是首选。在 Windows 上,使用 python.org 的预打包安装程序通常是个好主意。
二、包
在现实世界中,处理 Python 的大部分工作都是处理第三方包。很长一段时间,情况都不好。然而,事情已经有了显著的改善。重要的是要理解哪些“最佳实践”是过时的惯例,哪些是基于错误的假设,但有一些优点,哪些实际上是好主意。
在处理包时,有两种交互方式。一种是成为“消费者”,希望使用软件包的功能。另一种是成为“制作人”,发布一个包。这些通常描述不同的开发任务,而不是不同的人。
在转向“生产”之前,对包的“消费者”方面有一个坚实的理解是很重要的如果包发布者的目标是对包用户有用,那么在开始写一行代码之前想象“最后一英里”是至关重要的。
2.1 点
Python 的基本打包工具是pip
。默认情况下,Python 的安装不附带pip
。这使得pip
可以比核心 Python 运行得更快——并且可以与 PyPy 等替代 Python 实现一起工作。然而,它们确实带有有用的ensurepip
模块。这允许通过python -m ensurepip
获得 pip。这通常是引导pip
的最简单方式。
一些 Python 安装,尤其是系统安装,会禁用ensurepip
。当缺少ensurepip
时,有一种手动获取的方式:get-pip.py
。这是一个可下载的单个文件,当执行时,它将解包pip
。
幸运的是,pip
是唯一需要这些奇怪的旋转来安装的包。所有其他的包都可以并且应该使用pip
来安装。这包括升级pip
本身,可以用pip install --upgrade pip
完成。
根据 Python 的安装方式,它的“真实环境”可能会也可能不会被我们的用户修改。各种自述文件和博客中的许多说明可能会鼓励进行 sudo pip 安装。这几乎总是错误的做法:它将在全局环境中安装软件包。
在虚拟环境中安装几乎总是更好,这些将在后面介绍。作为一种临时措施,也许是为了安装创建虚拟环境所需的东西,我们可以安装到我们的用户区域。这是用pip install --user
完成的。
pip install
命令将下载并安装所有依赖项。但是,它可能无法降级不兼容的软件包。总是可以安装显式版本:pip install package-name==<version>
将安装这个精确的版本。这也是一个获得明确的非通用包的好方法,比如发布候选包、测试包或类似的包,用于本地测试。
如果安装了wheel
,那么pip
将为包构建轮子,通常是缓存轮子。这在处理高虚拟环境变动时特别有用,因为安装缓存轮是一个快速操作。这在处理所谓的“本机”或“二进制”包时也特别有用——那些需要用 C 编译器编译的包。轮缓存将消除再次构建轮缓存的需要。
pip
确实允许卸载,带pip uninstall
。默认情况下,该命令需要手动确认。除特殊情况外,不使用该命令。如果一个意想不到的包溜了进来,通常的反应是破坏环境并重建它。出于类似的原因,pip install --ugprade
并不经常需要:常见的反应是破坏和重建环境。有一种情况是个好主意:pip install --upgrade pip
。这是获得新版本pip
的最好方法,它有错误修正和新特性。
pip install
支持一个“需求文件”,pip install --requirements
或pip install -r
。一个需求文件每行只有一个包。这与在命令行上指定包没有什么不同。然而,需求文件经常指定“严格依赖”一个需求文件可以从pip freeze
的环境中生成。获取需求文件的通常方法是,在虚拟环境中,执行以下操作:
$ pip install -e .
$ pip freeze > requirements.txt
这意味着需求文件将拥有当前的包,以及它所有的递归依赖项,并有严格的版本。
2.2 虚拟环境
虚拟环境经常被误解,因为“环境”的概念并不清楚。Python 环境是指 Python 安装的根目录。它之所以重要是因为子目录lib/site-packages
。lib/site-packages
目录是安装第三方软件包的地方。在现代,它们经常被pip
安装。虽然过去有其他工具可以做到这一点,但甚至自举pip
和virtualenv
都可以用pip
来完成,更不用说日常的包管理了。
对于系统 Python 来说,唯一常见的选择是系统包。在 Anaconda 环境中,一些包可能作为 Anaconda 的一部分安装。事实上,这是 Anaconda 的一大好处:许多 Python 包都是定制的,尤其是那些构建起来不简单的包。
“真实的”环境是基于 Python 安装的环境。这意味着要获得一个新的真实环境,我们必须重新安装(通常是重建)Python。这有时是一个昂贵的提议。例如,如果任何参数不同,tox
将从头开始重建环境。为此,虚拟环境存在。
虚拟环境从真实环境中复制最少的必要内容,误导 Python 认为它有了一个新的根。确切的细节并不重要,重要的是这是一个简单的命令,只是复制文件(有时使用符号链接)。
使用虚拟环境有两种方式:激活的和未激活的。为了使用在脚本和自动化过程中最常见的未激活的虚拟环境,我们从虚拟环境中显式调用 Python。
这意味着如果我们在/home/name/venvs/my-special-env,
中创建了一个虚拟环境,我们可以调用/home/name/venvs/my-special-env/bin/python
在这个环境中工作。例如,/home/name/venvs/my-special-env/bin/python -m pip
将运行pip
,但安装在虚拟环境中。注意,对于基于入口点的脚本,它们将与 Python 一起安装,所以我们可以运行/home/name/venvs/my-special-env/bin/pip
在虚拟环境中安装包。
使用虚拟环境的另一种方法是“激活”它。在 bash-like shell 中激活虚拟环境意味着为提供激活脚本:
$ source /home/name/venvs/my-special-env/bin/activate
sourcing 设置了几个环境变量,其中只有一个实际上是重要的。重要的变量是PATH
,它以/home/name/venvs/my-special-env/bin
为前缀。这意味着像python
或pip
这样的命令会首先出现在那里。有两个修饰性的变量被设定:VIRTUAL_ENV
将指向环境的根源。这在希望了解虚拟环境的管理脚本中非常有用。
PS1
将以(my-special-env),
为前缀,这对于在控制台中交互工作时虚拟环境的视觉指示非常有用。
一般来说,在虚拟环境中只安装第三方软件包是一种好的做法。结合虚拟环境“廉价”的事实,这意味着如果一个人进入糟糕的状态,很容易删除整个目录并从头开始。例如,想象一个错误的包安装导致 Python 启动失败。即使运行pip uninstall
也是不可能的,因为pip
在启动时会失败。然而,“便宜”意味着我们可以移除整个虚拟环境,并用一组好的包重新创建它。
事实上,现代实践越来越倾向于将虚拟环境视为半不可变的:在创建它们之后,有一个“安装所有必需的包”的单一阶段如果需要升级,我们不是修改它,而是破坏环境,重新创建和重新安装。
创建虚拟环境有两种方式。一种方法是在 Python 2 和 Python 3 之间移植。这需要以某种方式引导,因为 Python 没有预装virtualenv
。有几种方法可以实现这一点。如果 Python 是使用打包系统安装的,比如系统打包程序、Anaconda 或 Homebrew,那么通常同一个系统会打包virtualenv
。如果 Python 是在用户目录中使用pyenv
安装的,有时直接在“原始环境”中使用pip install
是一个不错的选择,尽管这是“只安装到虚拟环境”的一个例外最后,这是pip install --user
可能是个好主意的情况之一:这将把包安装到特殊的“用户区域”注意,这意味着有时它不在$PATH
中,运行它的最佳方式是使用python-m virtualenv.
如果不需要可移植性,venv
是一种创建虚拟环境的 Python 3 专用方法。它作为python -m venv
被访问,因为没有专用的入口点。这解决了如何安装virtualenv
的“引导”问题,尤其是在使用非系统 Python 时。
无论使用哪个命令来创建虚拟环境,它都会为该环境创建目录。最好是在此之前该目录不存在。最佳实践是在创建环境之前将其删除。还有关于如何创建环境的选项:使用哪个解释器和安装什么初始包。例如,有时完全跳过pip
安装是有益的。然后我们可以通过使用get-pip.py
在虚拟环境中引导pip
。这是一种避免安装在真实环境中的pip
的坏版本的方法——因为如果它足够坏,它甚至不能用于升级pip
。
2.3 设置和车轮
术语“第三方”(如“第三方包”)是指除 Python 核心开发人员(“第一方”)或本地开发人员(“第二方”)之外的人。我们已经在安装部分介绍了如何安装“第一方”包。我们使用了pip
和virtualenv
来安装“第三方”包。是时候最终将我们的注意力转向缺失的环节了:本地开发和安装本地包,或者“第二方”包。
这是一个有很多新成员的领域,比如pyproject.toml
和flit
。然而,理解经典的做事方式是很重要的。首先,新的最佳实践需要一段时间来适应。另一方面,现有的实践是基于setup.py
,因此这种方式在一段时间内仍将是主要方式——甚至可能是在可预见的未来。
文件用代码描述了我们的“发行版”请注意,“分发”不同于“打包”包是 Python 可以导入的(通常是)__init__.py
的目录。一个发行版可以包含几个包,甚至不包含任何包!但是,保持 1-1-1 的关系是一个好主意:一个发行版,一个包,名称相同。
通常,setup.py
会以导入setuptools
或distutils
开始。distutils
是内置的,setuptools
不是。然而,由于它非常受欢迎,它几乎总是首先安装在虚拟环境中。不推荐 Distutils:它已经很久没有更新了。请注意,setup.py
不能有意义地、显式地声明它需要setuptools
也不能显式地请求一个特定的版本:当它被读取时,它将已经试图导入setuptools
。这种非声明性是包替代品的部分动机。
绝对有效的最低限度setup.py
如下:
import setuptools
setuptools.setup(
packages=setuptools.find_packages(),
)
出于某种原因,官方文档称许多其他字段为“必需的”,尽管即使这些字段缺失,包也会被构建。对于一些人来说,这个将会导致产生难看的缺省值,比如包名是UNKNOWN
。
当然,这些领域中有很多都是值得拥有的。但是这个框架setup.py
足以创建一个包含目录中的本地 Python 包的发行版。
现在,当然,几乎总是会有其他领域需要添加。如果要将这个包上传到打包索引,即使它是一个私有索引,也必须添加其他字段。
添加至少一个“名称”字段是一个好主意。这将为发行版命名。如前所述,在发行版中以单个顶级包命名几乎总是一个好主意。
那么,典型的源层次结构将如下所示:
setup.py
import setuptools
setuptools.setup(
name='my_special_package',
packages=setuptools.find_packages(),
)
my_special_package/
__init__.py
another_module.py
tests/
test_another_module.py
另一个几乎总是好主意的领域是版本。软件版本化总是很难。尽管如此,即使是一个连续的数字,也是一个很好的方法来回答这个永恒的问题:“这是运行一个新的还是旧的版本?”
有一些工具可以帮助管理版本号,特别是假设我们希望在运行时 Python 也可以使用它。尤其是在进行日历版本管理时,incremental
是一个强大的软件包,可以自动完成一些繁琐的工作。bumpversion
是一个有用的工具,尤其是在选择语义版本化的时候。最后,versioneer
支持与 git 版本控制系统的简单集成,所以发布只需要一个标签。
setup.py
中的另一个流行字段是install_requires
,它在文档中没有被标记为“required ”,但在几乎每个包中都存在。这就是我们如何标记代码使用的其他发行版。将“松散的”依赖关系放在setup.py
中是一个很好的做法。这与指定特定版本的精确依赖相反。松散的依赖看起来像Twisted>=17.5
——指定了最低版本,但没有最高版本。精确的依赖,像Twisted==18.1
,在setup.py
中通常是个坏主意。它们应该只在极端情况下使用:例如,当使用一个包的私有 API 的重要部分时。
最后,给find_packages
一个白名单,列出要包含的内容,以避免虚假文件,这是一个好主意。例如,
setuptools.find_packages(include=["my_package∗"])
一旦我们有了setup.py
和一些 Python 代码,我们想把它做成一个发行版。一个发行版可以有几种格式,但是我们这里要介绍的是轮。如果my-directory
是有setup.py
的那个,运行pip wheel my-directory
,将产生一个轮子,以及它的所有递归依赖的轮子。
默认情况下,将轮子放在当前目录中,这很少是我们想要的行为。使用--wheel-dir<output-directory>
将把轮子放到目录中——以及它所依赖的任何发行版的轮子。
我们可以用滚轮做几件事,但重要的是要注意我们可以做的一件事是pip install <wheel file>
。如果我们添加了pip install <wheel file> --wheel-dir <output directory>
,那么pip
将使用目录中的轮子,而不会使用 PyPI。这对于可重复安装或支持气隙模式非常有用。
2.4 毒性
Tox 是一种自动管理虚拟环境的工具,通常用于测试和构建。它用于确保那些在定义良好的环境中运行,并智能地缓存它们以减少流失。Tox 作为一个测试运行工具,是按照测试环境配置的。
它使用一种独特的基于 ini 的配置格式。这使得编写配置变得困难,因为记住文件格式的微妙之处可能很难。然而,反过来,虽然很难利用,但是有很多能力可以帮助配置测试和构建清晰简洁的运行。
Tox 缺少的一点是构建步骤之间的依赖关系。这意味着这些通常是从外部管理的,通过在其他测试运行之后运行特定的测试运行,并以某种特别的方式共享工件。
Tox 环境或多或少对应于配置文件中的一个部分。
[testenv:some-name]
.
.
.
注意,如果名称包含pyNM
(例如py36
),那么 Tox 将默认使用 CPython N.M
(本例中为 3.6)作为该测试环境的 Python。如果名称包含pypyNM,
,Tox 将默认使用 PyPy N.M
作为该版本——其中这些代表“CPython 兼容性版本”,而不是 PyPy 自己的版本化方案。
如果名称不包含pyNM
或pypyNM
,或者如果需要覆盖缺省值,可以使用该部分中的basepython
字段来表示特定的 Python 版本。默认情况下,Tox 将在路径中寻找这些蟒蛇。然而,如果安装了插件tox-pyenv
,Tox 将查询pyenv
是否在路径上找不到正确的 Python。
例如,我们将分析一个简单的 Tox 文件和一个更复杂的文件。
[tox]
envlist = py36,pypy3.5,py36-flake8
tox
部分是一个全局配置。在本例中,我们仅有的全局配置是环境列表。
[testenv:py36-flake8]
这个部分配置py36-flake8
测试环境。
deps =
flake8
deps
小节详细说明了哪些包应该安装在测试环境的虚拟环境中。这里我们选择用一个松散的依赖关系来指定flake8
。另一个选项是指定一个严格的依赖项(如flake8==1.0.0.)
)。这有助于可重复的测试运行。我们也可以指定-r <requirements file>
并单独管理需求。如果我们有接受需求文件的其他工具,这是很有用的。
commands =
flake8 useful
在这种情况下,唯一的命令是在目录useful
上运行flake8
。默认情况下,如果所有命令都返回成功的状态代码,Tox 测试运行将会成功。作为一个从命令行运行的程序,flake8
尊重这个惯例,只有在代码没有问题的情况下,才会以一个成功的状态代码退出。
[testenv]
缺少特定配置的其他两个环境将退回到通用环境。注意,由于它们的名字,它们将使用两种不同的解释器:兼容 Python 3.5 的 CPython 3.6 和 PyPy。
deps =
pytest
在这个环境中,我们安装了pytest
转轮。注意,通过这种方式,我们的tox.ini
记录了运行测试所需工具的假设。例如,如果我们的测试使用了Hypothesis
或者PyHamcrest
,这就是我们记录它的地方。
commands =
pytest useful
同样,命令运行很简单。再次注意,pytest
遵守约定,只有在没有测试失败的情况下才会成功退出。
作为一个更现实的例子,我们转向ncolony:
的tox.ini
[tox]
envlist = {py36,py27,pypy}-{unit,func},py27-lint,py27-wheel,docs
toxworkdir = {toxinidir}/build/.tox
我们有更多的环境。注意,我们可以使用{}
语法来创建一个环境矩阵。这意味着{py36,py27,pypy}-{unit,func}
创造了3*2=6
环境。请注意,如果我们有一个进行了“大跳跃”的依赖项(例如,Django 1 和 2),并且我们想要对两者进行测试,我们可以对总共的3*2*2=12
环境进行{py36,py27, pypy}-{unit,func}-{django1,django2}
。请注意,像这样的矩阵测试的数字攀升很快——当使用自动化测试环境时,这意味着事情要么需要更长的时间,要么需要更高的并行性。
这是测试的全面性和资源使用之间的正常权衡。除了仔细考虑官方支持多少变种,没有什么神奇的解决方法。
[testenv]
不是每个变体都有一个testenv
,我们选择使用一个测试环境,但是通过匹配来特例化变体。这是创建许多测试环境变体的相当有效的方法。
deps =
{py36,py27,pypy}-unit: coverage
{py27,pypy}-lint: pylint==1.8.1
{py27,pypy}-lint: flake8
{py27,pypy}-lint: incremental
{py36,py27,pypy}-{func,unit}: Twisted
我们只在单元测试中需要coverage
,而单元测试和功能测试都需要Twisted
。pylint
严格依赖确保了随着pylint
添加更多的规则,我们的代码不会获得新的测试失败。这确实意味着我们需要不时地手动更新pylint
。
commands =
{py36,py27,pypy}-unit: python -Wall \
-Wignore::DeprecationWarning \
-m coverage \
run -m twisted.trial \
--temp-directory build/_trial_temp \
{posargs:ncolony}
{py36,py27,pypy}-unit: coverage report --include ncolony∗ \
--omit ∗/tests/∗,∗/interfaces∗,∗/_version∗ \
↪ --show-missing --fail-under=100
py27-lint: pylint --rcfile admin/pylintrc ncolony
py27-lint: python -m ncolony tests.nitpicker
py27-lint: flake8 ncolony
{py36,py27,pypy}-func: python -Werror -W ignore::DeprecationWarning \
-W ignore::ImportWarning \
-m ncolony tests.functional_test
配置“一个大的测试环境”意味着我们需要将所有的命令混合在一个包中,并基于模式进行选择。这也是一个更现实的测试运行命令——我们希望在启用警告的情况下运行,但是禁用我们不担心的警告,并且还启用代码覆盖测试。虽然具体的复杂性会有所不同,但我们几乎总是需要足够多的东西,以便命令能够增长到合适的大小。
[testenv:py27-wheel]
skip_install = True
deps =
coverage
Twisted
wheel
gather
commands =
mkdir -p {envtmpdir}/dist
pip wheel . --no-deps --wheel-dir {envtmpdir}/dist
sh -c "pip install --no-index {envtmpdir}/dist/∗.whl"
coverage run {envbindir}/trial \
--temp-directory build/_trial_temp {posargs:ncolony}
coverage report --include ∗/site-packages/ncolony∗ \
--omit ∗/tests/∗,∗/interfaces∗,∗/_version∗ \
--show-missing --fail-under=100
测试运行确保我们可以制造和测试一个轮子。作为一个副作用,这意味着一个完整的测试运行将建立一个轮子。这允许我们在发布的时候上传一个测试过的轮到 PyPI。
[testenv:docs]
changedir = docs
deps =
sphinx
Twisted
commands =
sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
basepython = python2.7
文档构建是 Tox 大放异彩的原因之一。它只在虚拟环境中安装sphinx
用于构建文档。这意味着对sphinx
未声明的依赖会使单元测试失败,因为sphinx
没有安装在那里。
2.5 管道与诗歌
Pipenv 和 poem 是产生 Python 项目的两种新方法。它们分别受到 JavaScript 和 Ruby 的工具yarn
和bundler
的启发,这些工具旨在编码更完整的开发流程。就其本身而言,它们并不能替代 Tox——它们不能编码与多个 Python 解释器一起运行的能力,也不能完全覆盖依赖性。然而,可以将它们与 CI 系统配置文件(如Jenkinsfile
或.circleci/config.yml
)一起使用,以构建多种环境。
然而,它们的主要优势在于允许更容易的交互开发。有时,这对于更具探索性的编程很有用。
2.5.1 诗歌
安装诗歌最简单的方法就是使用pip install --user poetry
。然而,这会将它的所有依赖项安装到您的用户环境中,这有可能把事情搞得一团糟。一种干净的方法是创建一个专用的虚拟环境。
$ python3 -m venv ~/.venvs/poetry
$ ~/.venvs/poetry/bin/pip install poetry
$ alias poetry=~/.venvs/poetry/bin/poetry
这是一个使用未激活的虚拟环境的例子。
使用诗歌的最好方法是为项目创建一个专用的虚拟环境。我们将构建一个小型演示项目。我们称之为“有用”
$ mkdir useful
$ cd useful
$ python3 -m venv build/useful
$ source build/useful/bin/activate
(useful)$ poetry init
(useful)$ poetry add termcolor
(useful)$ mkdir useful
(useful)$ touch useful/__init__.py
(useful)$ cat > useful/__main__.py
import termcolor
print(termcolor.colored("Hello", "red"))
如果我们做到了这一切,在虚拟环境中运行 python -m 有用的就会打印出红色的Hello
。在我们交互式地尝试了各种颜色,并可能决定将文本加粗后,我们准备发布:
(useful)$ poetry build
(useful)$ ls dist/
useful-0.1.0-py2.py3-none-any.whl useful-0.1.0.tar.gz
管道 nv
Pipenv 是一个创建符合规范的虚拟环境的工具,此外还有改进规范的方法。它依赖于两个文件:Pipfile
和Pipfile.lock
。我们可以像安装poetry
一样安装pipenv
,在定制的虚拟环境中添加一个别名。
为了开始使用它,我们希望确保没有虚拟环境被激活。然后,
$ mkdir useful
$ cd useful
$ pipenv add termcolor
$ mkdir useful
$ touch useful/__init__.py
$ cat > useful/__main__.py
import termcolor
print(termcolor.colored("Hello", "red"))
$ pipenv shell
(useful-hwA3o_b5)$ python -m useful
这将在它后面留下一个看起来像这样的Pipfile
:
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
termcolor = "∗"
[dev-packages]
[requires]
python_version = "3.6"
注意,为了封装useful
,我们还是要写一个setup.py
。Pipenv 将自己局限于管理虚拟环境,它确实考虑构建和发布单独的任务。
2.6 DevPI
DevPI 是一个 PyPI 兼容的服务器,可以在本地运行。虽然它不能扩展到类似 PyPI 的级别,但在许多情况下,它是一个强大的工具。
DevPI 由三部分组成。最重要的是devpi-server
。对于许多用例来说,这是唯一需要运行的部分。服务器首先充当 PyPI 的缓存代理。它利用了 PyPI 上的包是不可变的的事实:一旦我们有了一个包,它就永远不会改变。
还有一个 web 服务器允许我们在本地包目录中进行搜索。由于许多用例甚至不涉及在 PyPI 网站上搜索,这肯定是可选的。最后,有一个客户机命令行工具,它允许在运行的实例上配置各种参数。客户端在更深奥的用例中最有用。
安装和运行 DevPI 非常简单。在虚拟环境中,只需运行:
(devpi)$ pip install devpi-server
(devpi)$ devpi-server --start --init
默认情况下,pip
工具转到pypi.org
。对于 DevPI 的一些基本测试,我们可以创建一个新的虚拟环境playground
,并运行:
(playground)$ pip install \
-i http://localhost:3141/root/pypi/+simple/ \
httpie glom
(playground)$ http --body https://httpbin.org/get | glom '{"url":"url"}'
{
"url": "https://httpbin.org/get"
}
每次都必须为pip
指定-i ...
参数会很烦人。在检查了一切都正常工作后,我们可以将配置放在一个环境变量中:
$ export PIP_INDEX_URL=http://localhost:3141/root/pypi/+simple/
或者让事情变得更持久:
$ mkdir -p ~/.pip && cat > ~/.pip/pip.conf << EOF
[global]
index-url = http://localhost:3141/root/pypi/+simple/
[search]
index = http://localhost:3141/root/pypi/
上述文件位置适用于 UNIX 操作系统。在 Mac OS X 上,配置文件是$HOME/Library/Application Support/pip/pip.conf
。在 Windows 上,配置文件是%APPDATA%\pip\pip.ini
。
DevPI 对于断开连接的操作很有用。如果我们需要在没有网络的情况下安装包,可以用 DevPI 来缓存。如前所述,虚拟环境是一次性的,通常被视为不可改变的。这意味着,如果没有网络,拥有正确软件包的虚拟环境就不是有用的东西。很有可能某种情况会要求或建议从头开始创建它。
然而,缓存服务器是另一回事。如果所有的包检索都是通过缓存代理完成的,那么破坏一个虚拟环境并重新构建它是没问题的,因为事实的来源是包缓存。这对于将笔记本电脑带进森林进行离线开发非常有用,对于维护适当的防火墙边界和拥有所有已安装软件的一致记录也非常有用。
为了“预热”DevPI 缓存,也就是确保它包含所有需要的包,我们需要使用pip
来安装它们。一种方法是,在配置 DevPI 和pip
之后,对正在开发的软件的源库运行tox
。由于tox
经历了所有的测试环境,它下载了所有需要的包。
在一次性虚拟环境中预装任何相关的requirements.txt
,这绝对是一个好的做法。
然而,DevPI 的效用并不局限于断开连接的操作。在您的构建集群中配置一个,并将构建集群指向它,完全避免了“leftpad 事件”的风险,即您所依赖的包被作者从 PyPI 中删除。它也可能使构建更快,而且它肯定会减少大量的输出流量。
DevPI 的另一个用途是在上传到 PyPI 之前测试上传。假设devpi-server
已经在默认端口上运行,我们可以:
(devpi)$ pip install devpi-client twine
(devpi)$ devpi use http://localhost:3141
(devpi)$ devpi user -c testuser password=123
(devpi)$ devpi login testuser --password=123
(devpi)$ devpi index -c dev bases=root/pypi
(devpi)$ devpi use testuser/dev
(devpi)$ twine upload --repository http://localhost:3141/testuser/dev \
-u testuser -p 123 my-package-18.6.0.tar.gz
(devpi)$ pip install -i http://localhost:3141/testuser/dev my-package
注意,这允许我们上传到一个我们只显式使用的索引,所以我们不会对所有没有显式使用它的环境隐藏my-package
。
一个更高级的用例,我们可以这样做:
(devpi)$ devpi index root/pypi mirror_url=https://ourdevpi.local
这将使我们的 DevPI 服务器成为本地“上游”DevPI 服务器的镜像。这允许我们将私有包上传到“中央”DevPI 服务器,以便与我们的团队共享。在这些情况下,上游 DevPI 服务器通常需要在代理服务器后面运行——我们需要一些工具来正确管理用户访问。
在一个要求用户名和密码的简单代理后面运行一个“集中式”DevPI 允许一个有效的私有存储库。为此,我们首先要删除root/pypi
索引:
$ devpi index --delete root/pypi
然后重新创建它
$ devpi index --create root/pypi
这意味着根索引将不再镜像pypi
。我们现在可以直接向它上传软件包。这种类型的服务器通常与参数--extra-index-url
到pip
一起使用,以允许pip
从私有存储库和外部存储库中进行检索。然而,有时拥有一个只服务于特定包的DevPI
实例是有用的。这允许在使用任何包之前执行关于审计的规则。每当需要一个新的包时,它就会被下载、审计,然后添加到私有存储库中。
2.7 Pex 和 Shiv
虽然目前将一个 Python 程序编译成一个自包含的可执行文件并不容易,但是我们可以做一些几乎一样好的事情。我们可以将一个 Python 程序编译成一个文件,只需要安装一个解释器就可以运行。这利用了 Python 处理启动的特殊方式。
运行python /path/to/filename
时,Python 做两件事:
-
将目录
/path/to
添加到模块路径中。 -
执行
/path/to/filename
中的代码。
当运行python/path/to/directory/
时,Python 的行为就像我们输入python/path/to/directory/__main__.py
一样。
换句话说,Python 会做以下两件事:
-
将目录
/path/to/directory/
添加到模块路径中。 -
执行
/path/to/directory/__main__.py
中的代码。
运行python /path/to/filename.zip
时,Python 会把文件当做一个目录。
换句话说,Python 会做以下两件事:
-
将【目录】添加到模块路径中。
-
执行从
/path/to/filename.zip
中提取的__main__.py
中的代码。
Zip 是一种面向端的格式:元数据和指向数据的指针都在末尾。这意味着向 zip 文件添加前缀不会改变其内容。
因此,如果我们获取一个 zip 文件,并给它加上前缀#!/usr/bin/python<newline>
,并将其标记为可执行,那么当运行它时,Python 将会运行一个 zip 文件。如果我们在__main__.py
中放入正确的引导代码,并在 zip 文件中放入正确的模块,我们可以在一个大文件中获得我们所有的第三方依赖项。
Pex 和 Shiv 是生成这种文件的工具,但是它们都依赖于 Python 和 zip 文件的相同底层行为。
2.7.1 Pex
Pex 既可以用作命令行工具,也可以用作库。当使用它作为命令行工具时,防止它试图对 PyPI 进行依赖解析是一个好主意。所有的依赖关系解析算法在某些方面都有缺陷。然而,由于pip
的流行,软件包将明确地解决其算法中的缺陷。Pex 不太受欢迎,不能保证软件包会明确地尝试使用它。
最安全的做法是使用pip wheel
在一个目录中构建所有的轮子,然后告诉 Pex 只使用这个目录。
例如,
$ pip wheel --wheel-dir my-wheels -r requirements.txt
$ pex -o my-file.pex --find-links my-wheels --no-index \
-m some_package
Pex 有几种方法可以找到切入点。最受欢迎的两个是-m some_package
,它会表现得像python -m some_package
;或者是-c console-script,
,它将找到作为console-script
安装的脚本,并调用相关的入口点。
也可以使用 Pex 作为库。
from pex import pex_builder
构建 Pex 文件的大部分逻辑都在pex_builder
模块中。
builder = pex_builder.PEXBuilder()
我们创建一个构建器对象。
builder.set_entry_point('some_package')
我们设置了入口。这相当于命令行上的-m some_package
参数。
builder.set_shebang(sys.executable)
Pex 二进制有一个复杂的参数来确定正确的线。这有时是特定于预期的部署环境的,所以最好考虑一下正确的部署路线。一个选项是/usr/bin/env python
,会找到当前 shell 调用的python
。有时在这里指定一个版本是一个好主意,例如/usr/local/bin/python3.6
。
subprocess.check_call([sys.executable, '-m', 'pip', 'wheel',
'--wheel-dir', 'my-wheels',
'--requirements', 'requirements.txt'])
我们再次用pip
创建轮子。尽管很诱人,pip
不能作为库使用,所以 shelling out 是唯一支持的接口。
for dist in os.listdir('my-wheels'):
dist = os.path.join('my-wheels', dist)
builder.add_dist_location(dist)
我们添加了pip
构建的所有包。
builder.build('my-file.pex')
最后,我们让构建器生成一个 Pex 文件。
2.7.2 刀
Shiv 是 Pex 背后相同理念的现代体现。但是,由于它直接使用了pip
,它自己需要做的事情就少了很多。
$ shiv -o my-file.shiv -e some_package -r requirements.txt
因为 shiv 只是卸载到 pip 实际依赖解析,所以直接调用它是安全的。Shiv 是 Pex 的年轻替代产品。这意味着很多糟粕已经被去除,但它仍然不够成熟。
例如,关于命令行参数的文档很少。目前也没有办法将它用作库。
2.8 XAR
XAR(可执行文件归档)是一种用于发布自包含可执行文件的通用格式。虽然不是特定于 Python 的,但它首先被设计为 Python。例如,它可以通过 PyPI 进行本地安装。
XAR 的缺点是,它假定了对fuse
(用户空间中的文件系统)的某种程度的系统支持,而这种支持还不是通用的。如果所有设计用于运行 XAR、Linux 或 Mac OS X 的机器都在您的控制之下,这不是问题。关于如何安装适当的 FUSE 支持的说明并不复杂,但是它们需要管理权限。请注意,XAR 也没有 Pex 成熟。
然而,假设有适当的 SquashFS 支持,许多其他问题都会消失:包括,最重要的,与pex
或shiv
相比,本地 Python 版本。这使得 XAR 成为交付开发人员工具或本地系统管理脚本的有趣选择。
为了构建一个 XAR,如果安装了xar
,我们可以用bdist_xar
调用setup.py
。
python setup.py bdist_xar --console-scripts=my-script
在本例中,my-script
是控制台脚本入口点的名称,在setup.py
中用以下内容指定:
entry_points=dict(
console_scripts=["my-script = package.module:function"],
)
在某些情况下,--console-scripts
参数是不必要的。如上例所示,如果只有一个控制台脚本入口点,那么它就是隐式的。否则,如果有一个与包同名的控制台脚本,则使用该脚本。这占了相当多的情况,也就是说这个论证往往是多余的。
2.9 摘要
Python 的强大之处很大程度上来自其强大的第三方生态系统:无论是对于数据科学还是网络代码,都有许多好的选择。理解如何安装、使用和更新第三方包对于用好 Python 至关重要。
对于私有包存储库,对内部库使用 Python 包,并以与开源库兼容的方式分发它们通常是个好主意。它允许使用相同的机制进行内部分发、版本控制和依赖性管理。
三、交互式使用
Python 通常用于探索性编程。通常,最终的结果不是程序,而是一个问题的答案。对科学家来说,问题可能是“医学干预起作用的可能性有多大?”对于排除计算机故障的人来说,问题可能是“哪个日志文件有我需要的消息?”
然而,不管这个问题是什么,Python 通常是回答这个问题的强大工具。更重要的是,在探索性编程中,我们期望遇到更多的问题,基于答案。
Python 中的交互模型来自最初的 Lisp 环境的“读取-评估-打印循环”(简称 REPL)。该环境读取一个 Python 表达式,在内存中持久化的环境中对其求值,打印结果,然后循环返回。
Python 本地的 REPL 环境很受欢迎,因为它是内置的。然而,一些第三方 REPL 工具甚至更强大,可以做本地工具不能或不愿做的事情。这些工具提供了一种与操作系统交互的强大方式,探索和塑造,直到达到期望的状态。
3.1 本机控制台
不带任何参数启动python
将打开“交互式控制台”。使用pyenv
或虚拟环境来确保 Python 版本是最新的是一个好主意。
无需安装任何其他东西就能立即获得交互式控制台,这是 Python 适合于探索性编程的一个原因。我们可以立即提问。
这些问题可能很琐碎:
>>> 2 + 2
4
它们可用于计算海湾地区的销售税:
>>> rate = 9.25
>>> price = 5.99
>>> after_tax = price ∗ (1 + rate / 100.)
>>> after_tax
6.544075
或者他们可以回答关于操作环境的重要问题:
>>> import os
>>> os.path.isfile(os.path.expanduser("~/.bashrc"))
True
使用没有readline
的 Python 原生控制台是不愉快的。用readline
支持重新构建 Python 是个好主意,这样原生控制台将会很有用。如果这不是一个选项,建议使用备用控制台之一。例如,带有特定选项的本地构建的 Python 可能不包括readline
,并且向整个团队重新分发新的 Python 可能会有问题。
如果安装了 readline 支持,Python 将使用它来支持行编辑和历史。也可以使用readline.write_history_file
保存历史记录。这通常是在使用控制台一段时间后,为了参考已经完成的工作,或者将任何想法复制到更永久的形式中。
当使用控制台时,_
变量将计算最后一个表达式语句的值。请注意,异常、非表达式语句和 stat 语句是计算结果为None
的表达式,它们不会改变_
的值。这在交互式会话中很有用,只有在看到值的表示后,我们才意识到我们需要它作为一个对象。
>>> import requests
>>> requests.get("http://en.wikipedia.org")
<Response [200]>
>>> a=_
>>> a.text[:50]
'<!DOCTYPE html>\n<html class="client-nojs" lang="en'
只有在使用了.get
函数之后,我们才意识到我们真正想要的是文本。幸运的是,Response
对象保存在变量_
中。我们立即将变量的值放入a
, _ 被快速替换。我们一评估a.text[:50]
,_
现在就是一个 50 个字符的字符串。如果我们没有将_
保存在变量中,除了前 50 个字符之外的所有字符都将丢失。
注意,每个好的 Python REPL 都遵守这个_
约定,因此“将返回值保存在单字母变量中”的技巧在进行探索时通常很有用。
3.2 代码模块
模块允许我们运行自己的交互循环。这种方法有用的一个例子是,当运行带有特殊标志的命令时,我们可以在特定的点进入提示符状态。这允许我们在以某种方式设置好事情之后,拥有一个 REPL 环境。这适用于解释器内部,用有用的东西建立名称空间;在外部环境中,可能初始化文件或设置外部服务。
code
的最高级用法是interact
函数。
>>> import code
>>> code.interact(banner="Welcome to the special interpreter",
... local=dict(special=[1, 2, 3]))
Welcome to the special interpreter
>>> special
[1, 2, 3]
>>> ^D
now exiting InteractiveConsole...
>>> special
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'special' is not defined
这显示了一个使用变量special
运行 REPL 循环的例子,该变量设置为一个短列表。
对于code
的最底层使用,如果你想自己拥有 UI,code.compile_command(source, [filename="<input>"], symbol="single")
会返回一个 code 对象(可以传递给exec
),如果命令不完整则返回None
,如果命令有问题则抛出SyntaxError
、OverflowError
或ValueError
。
symbol
参数应该总是为"single
。"
例外情况是,如果提示用户输入将计算为表达式的代码(例如,如果值将由底层系统使用)。在这种情况下,symbol
应该设置为"eval
。"
这允许我们自己管理与用户的交互。它可以与 UI 或远程网络接口集成,以允许在任何环境中进行交互。
3.3 ptpython
ptpython
工具是“prompt toolkit Python”的缩写,是内置 REPL 的替代工具。它使用 prompt toolkit 进行控制台交互,而不是使用readline
。
它的主要优点是安装简单。一个简单的pip install ptpython
在虚拟环境中,不考虑 readline 构建问题,一个高质量的 Python REPL 出现了。
ptpython
支持完成建议、多行编辑和语法高亮显示。
启动时,它将读取~/.ptpython/config.py
。这意味着可以以任意方式在本地定制 ptpython。配置的方法是实现一个函数configure
,它接受一个对象(类型为PythonRepl
)并对其进行变异。
有很多种可能性,遗憾的是唯一真正的文档是源代码。相关参考号__init__
为ptpython.python_input.PythonInput
。注意,config.py
实际上是一个任意的 Python 文件。因此,如果你想在内部发布修改,可以发布一个本地的 PyPI 包,让人们从中导入一个configure
函数。
3.4 IPython
IPython 也是 Jupyter 的基础,这将在后面介绍,它是一个交互式环境,其根源在于科学计算社区。IPython 是一个交互式命令提示符,类似于ptpython
工具或 Python 的原生 REPL。
然而,它旨在提供一个复杂的环境。它做的事情之一是对解释器的每个输入和输出进行编号。以后能够参考这些数字是很有用的。IPython 将所有输入放入In
数组,输出放入Out
数组。这允许很好的对称:例如,如果 IPython 说In[4]
,这是如何访问该值。
In [1]: print("hello")
hello
In [2]: In[1]
Out[2]: 'print("hello")'
In [3]: 5 + 4.0
Out[3]: 9.0
In [4]: Out[3] == 9.0
Out[4]: True
它还支持现成的制表符补全。IPython 使用自己的完成和jedi
库进行静态完成。
它还支持内置帮助。键入var_name?
将试图找到变量中对象的最佳上下文相关帮助,并显示它。这适用于函数、类、内置对象等等。
In [1]: list?
Init signature: list(self, /, ∗args, ∗∗kwargs)
Docstring:
list() -> new empty list
list(iterable) -> new list initialized from iterable's items
Type: type
IPython 还支持一种叫做“magic”的东西,在一行前面加上%
将执行一个神奇的功能。例如,%run
将在当前名称空间内运行一个 Python 脚本。又如,%edit
将推出一个编辑器。如果在使用过程中,语句需要更复杂的编辑,这将非常有用。
此外,在一行前面加上!将运行系统命令。利用这一点的一个有用方法是!pip install something
。这就是为什么在用于交互式开发的虚拟环境中安装 IPython 是有用的。
IPython 可以通过多种方式进行定制。在交互式会话中,%config
魔术命令可用于更改任何选项。例如,%config InteractiveShell.autocall = True
将设置 autocall 选项,这意味着调用可调用的表达式,即使没有括号。对于任何只影响启动的选项来说,这都是没有意义的。我们可以使用命令行更改这些选项以及任何其他选项。例如,ipython --InteractiveShell.autocall=True
,将启动一个自动调用解释器。
如果我们希望自定义逻辑决定配置,我们可以从专门的 Python 脚本运行 IPython。
from traitlets import config
import IPython
my_config = config.Config()
my_config.InteractiveShell.autocall = True
IPython.start_ipython(config=my_config)
如果我们将它打包在一个专用的 Python 包中,我们可以使用 PyPI 或私有的包存储库将它分发给一个团队。这允许开发团队拥有同质的定制 IPython 配置。
最后,配置也可以编码在概要文件中,默认情况下,概要文件是位于~/.ipython
下的 Python 片段。概要文件目录可以通过一个显式的命令行参数--ipython-dir
或一个环境变量IPYTHONDIR
来修改。
3.5 Jupyter Lab
Jupyter 是一个使用基于 web 的交互来允许复杂的探索性编程的项目。它并不局限于 Python,尽管它确实起源于 Python。这个名字代表“Julia/Python/R”,这三种语言在探索性编程中最受欢迎,尤其是在数据科学中。
Jupyter Lab 是 Jupyter 的最新进化,最初基于 IPython。它现在拥有一个全功能的网络界面和一种远程编辑文件的方式。Jupyter 的主要用户往往是科学家。他们利用查看结果如何得出的能力来增加可重复性和同行评审。
再现性和同行评审对 DevOps 工作也很重要。例如,显示导致决定重启哪个主机列表的步骤的能力是非常有用的,以便在环境发生变化时可以重新生成该列表。将笔记本附加到事后分析有助于了解发生了什么,以及如何在将来避免问题或更有效地从问题中恢复,笔记本详细描述了停机期间采取的步骤以及这些步骤的输出。
这里需要注意的是,笔记本是而不是可审计性工具:它们可以不按顺序执行,并且可以修改和重新执行程序块。然而,如果使用得当,它们可以让我们记录下做过的事情。
Jupyter 允许真正的探索性编程。这对科学家来说很有用,他们可能事先不了解问题的真实范围。
这里需要注意的是,笔记本是而不是可审计性工具:它们可以不按顺序执行,并且可以修改和重新执行程序块。然而,如果使用得当,它们可以让我们记录下做过的事情。这对于面临复杂系统的系统集成商也很有用,因为在探索之前很难预测问题出在哪里。
在虚拟环境中安装 Jupyter Lab 是一件简单的事情。当启动jupyter lab
时,默认情况下,它将在从8888
开始的开放端口上启动一个 web 服务器,并尝试启动一个 web 浏览器来观看它。如果在一个“太有趣”的环境中工作(例如,默认的 web 浏览器没有正确配置),标准输出将包含一个预先授权的 URL 来访问服务器。如果所有其他方法都失败,可以在 web 浏览器中手动输入 URL 后,将打印到标准输出的令牌复制粘贴到浏览器中。还可以使用jupyter notebook
列表访问令牌,该列表将列出所有当前正在运行的服务器。
一旦进入 Jupyter 实验室,我们可以发射五个东西:
-
安慰
-
末端的
-
文字编辑器
-
笔记本
-
电子表格编辑器
Console
是 IPython 的一个基于 web 的接口。之前关于 IPython 的所有内容(例如,In
和Out
数组)。Terminal
是浏览器中成熟的终端模拟器。这对于 VPN 内部的远程终端很有用:它所需要的只是一个开放的 web 端口,它也可以用 web 端口的常规保护方式来保护:TLS、客户端证书等等。文本编辑器对于编辑远程文件很有用。这是运行远程 shell 的一种替代方式,其中有一个编辑器,比如vi
。它的优点是避免了 UI 延迟,同时仍然具有完整的文件编辑功能。
不过,最有趣的是笔记本:事实上,很多会议除了笔记本什么都不用。笔记本是记录会话的 JSON 文件。随着会话的展开,Jupyter 将保存笔记本的“快照”以及最新版本。笔记本是由一系列细胞组成的。两种最流行的单元格类型是“代码”和“降价”“代码”单元类型将包含一个 Python 代码片段。它将在会话的名称空间的上下文中执行它。名称空间从一个单元执行到另一个单元执行是持久的,对应于一个“内核”运行。内核使用自定义协议接受单元内容,将它们解释为 Python,执行它们,并返回代码片段返回的内容和输出。
在启动 Jupyter 服务器时,默认情况下,它将使用本地 IPython 内核作为唯一可能的内核。这意味着,举例来说,服务器将只能使用相同的 Python 版本和相同的包集。但是,可以将不同环境中的内核连接到该服务器。唯一的要求是环境已经安装了ipykernel
包。从环境中,运行:
python -m ipykernel install \
--name my-special-env \
--display-name "My Env"
--prefix=$DIRECTORY
然后,在 Jupyter 服务器环境中,运行:
jupyter kernelspec install $DIRECTORY/jupyter/kernels/my-special-env
这将导致该环境中的 Jupyter 服务器支持来自特殊环境的内核。这允许运行一个半永久性的 Jupyter 服务器,并连接来自任何“有趣”环境的内核:安装特定的模块,运行特定版本的 Python,或任何其他差异。替代内核的另一个用途是替代语言,这里不详细介绍。Julia 和 R 内核是上游支持的,但是许多语言都有第三方内核——甚至bash
!
Jupyter 支持来自 IPython 的所有魔法命令。特别有用的是在虚拟环境中安装新软件包的!pip install ...
命令。特别是如果小心谨慎,并安装精确的依赖关系,这使得笔记本成为如何以可重复的方式实现结果的高质量文档。
由于 Jupyter 是内核的一个间接层,我们可以直接从 Jupyter 重启内核。这意味着整个 Python 进程重新启动,所有内存中的结果都消失了。我们可以以任何顺序重新执行单元格,但有一种单键方式可以按顺序执行所有单元格。重新启动内核,并按顺序执行所有单元,这是“测试”笔记本工作条件的一种很好的方式——当然,对外部世界的任何影响都不会被重置。
Jupyter 笔记本作为票据和事后分析的附件非常有用,既可以记录具体的补救措施,也可以通过运行查询 API 并在笔记本中收集结果来记录“事情的状态”。通常,当以这种方式附加笔记本时,将其导出为更易于阅读的格式(如 HTML 或 PDF)并附加也是有用的。然而,越来越多的工具集成了直接笔记本查看,使得这一步变得多余。比如 GitHub 项目和 Gists 已经直接渲染笔记本了。
除了笔记本电脑,Jupyter 实验室还拥有一个基本但实用的基于浏览器的远程开发环境。第一部分是远程文件管理器。其中,这允许上传和下载文件。它的一个用途是能够从本地计算机上传笔记本,然后再下载回来。有更好的方法来管理笔记本,但在紧要关头,能够检索笔记本是非常有用的。类似地,还可以下载 Jupyter 的任何持久输出,比如经过处理的数据文件、图像或图表。
接下来,笔记本旁边是一个远程 IPython 控制台。虽然在笔记本旁边的使用有限,但仍然有一些情况下使用控制台更容易。通过使用 IPython 控制台,需要大量短命令的会话可以更加以键盘为中心,从而更加高效。
还有一个文件编辑器。虽然它与一个成熟的开发人员编辑器相差甚远,缺乏彻底的代码理解和完成,但在紧要关头它经常是有用的。它允许在远程 Jupyter 主机上直接编辑文件。一个用例是直接修复笔记本正在使用的库代码,然后重启内核。虽然将它集成到开发流程中需要一些小心,但是作为修复和继续的紧急措施,这是无价的。
最后,还有一个基于浏览器的远程终端。在终端、文件编辑器和文件管理器之间,运行的 Jupyter 服务器允许完全基于浏览器的远程访问和管理,甚至在考虑笔记本之前。记住这一点对安全性的影响很重要,但它也是一个强大的工具,我们将在后面探讨它的各种用途。现在,可以说,使用 Jupyter 笔记本给远程系统管理任务带来的能力是难以估量的。
3.6 摘要
反馈周期越快,我们就能越快地部署新的、经过测试的解决方案。交互式地使用 Python 可以获得最快的反馈:即时。
这通常有助于澄清一个库的文档,一个关于运行系统的假设,或者仅仅是你对 Python 的理解。
交互控制台也是一个强大的控制面板,当最终结果不能很好理解时,可以通过它启动计算:例如,调试软件系统的状态。
四、操作系统自动化
Python 最初是为了自动化一个叫做“变形虫”的分布式操作系统而构建的。尽管阿米巴操作系统几乎被遗忘了,但 Python 在自动化类 UNIX 操作系统任务方面找到了一个家。
Python 轻松地包装了传统的 UNIX C API,在使它们使用起来稍微安全一点的同时,给予运行 UNIX 的系统调用完全的访问权:这种方法被称为“带有泡沫填充物的 C”这种包装低级操作系统 API 的意愿使得它成为 UNIX shell 擅长的程序和 C 编程语言擅长的程序之间的一个很好的选择。
俗话说,权力越大,责任越大。为了考虑到程序员的能力和灵活性,Python 并不阻止程序员肆虐。小心翼翼地使用 Python 来编写工作的程序,更重要的是,以可预测的、安全的方式编写程序,这是一项值得掌握的技能。
4.1 文件
“一切都是文件”在 UNIX 上已经不是一个准确的咒语了。然而,许多东西都是文件,甚至更多的东西就像文件一样,用基于文件的系统调用来操作它们就足够了。
当处理文件内容时,Python 程序可以走两条路线中的一条。他们可以以“文本”或“二进制”格式打开它们尽管文件本身既不是文本也不是二进制文件,只是一个字节块,但打开模式很重要。
当以二进制格式打开文件时,字节以字节字符串的形式被读写。这对于非文本文件(如图片文件)非常有用。
当以文本形式打开文件时,必须使用编码。可以显式指定,但在某些情况下,会应用默认值。从文件中读取的所有字节都被解码,代码接收一个字符字符串。写入文件的所有字符串都被编码为字节。这意味着与文件的接口是字符串——字符序列。
二进制文件的一个简单例子是 GIMP“XCF”内部格式。GIMP 是一个图像处理程序,它以内部 XCF 格式保存文件,比图像有更多的细节。例如,为了便于编辑,XCF 中的图层将是独立的。
>>> with open("Untitled.xcf", "rb") as fp:
... header = fp.read(100)
这里我们打开一个文件。rb
参数代表“读取,二进制”我们读取前一百个字节。我们将需要更少,但这通常是一个有用的策略。许多文件在开头都有一些元数据。
>>> header[:9].decode('ascii')
'gimp xcf '
前九个字符实际上可以解码成 ASCII 文本,并且恰好是格式的名称。
>>> header[9:9+4].decode('ascii')
'v011'
接下来的四个字符是版本。这个文件是 XCF 的第 11 个版本。
>>> header[9+4]
0
0 字节结束“这是什么文件”元数据。这有各种好处。
>>> struct.unpack('>I', header[9+4+1:9+4+1+4])
(1920,)
接下来的四个字节是宽度,以大端格式表示。struct
模块知道如何解析这些。>
表示它是大端字节序,I
表示它是一个无符号的 4 字节整数。
>>> struct.unpack('>I', header[9+4+1+4:9+4+1+4+4])
(1080,)
接下来的四个字节是宽度。这个简单的代码给了我们高层次的数据:它确认了这是 XCF,它显示了格式的版本,我们可以看到图像的尺寸。
以文本形式打开文件时,默认编码是 UTF-8。UTF-8 的一个优点是,它被设计成在某些东西不是 UTF-8 的情况下很快失败:它被精心设计成在先于 Unicode 的 ISO-8859-[1-9]上失败,以及在大多数二进制文件上失败。它还向后兼容 ASCII,这意味着纯 ASCII 文件仍然是有效的 UTF-8。
解析文本文件最流行的方式是逐行,Python 支持这种方式,让一个打开的文本文件成为一个迭代器,按顺序产生各行。
>>> fp = open("things.txt", "w")
>>> fp.write("""\
... one line
... two lines
... red line
... blue line
... """)
39
>>> fp.close()
>>> fpin = open("things.txt")
>>> next(fpin)
'one line\n'
>>> next(fpin)
'two lines\n'
>>> next(fpin)
'red line\n'
>>> next(fpin)
'blue line\n'
>>> next(fpin)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration
通常我们不会直接调用next
,而是使用for
。此外,通常我们使用文件作为上下文管理器,以确保它们在一个很好理解的点关闭。然而,特别是在 REPL 场景中,有一个权衡:在没有上下文管理器的情况下打开文件允许我们探索阅读零碎的内容。
unix 系统上的文件不仅仅是数据块。它们附有各种元数据,可以查询,有时还可以更改。
rename
系统调用被包装在os.rename
Python 函数中。由于rename
是原子的,这可以帮助实现需要特定状态的操作。
一般来说,注意到os
模块倾向于是操作系统调用的一个薄薄的包装器。这里的讨论与类 UNIX 系统相关:Linux、基于 BSD 的系统,以及大多数情况下的 Mac OS X。这一点值得记住,但不值得指出我们做出特定于 UNIX 的假设的每个地方。
例如,
with open("important.tmp", "w") as fout:
fout.write("The horse raced past the barn")
fout.write("fell.\n")
os.rename("important.tmp", "important")
这确保了在读取important
文件时,我们不会意外地误解句子。如果代码中途崩溃,我们不会相信马跑过了谷仓,而是从important
中一无所获。我们只在最后将important.tmp
重命名为important
,在最后一个字被写入文件之后。
在 UNIX 中,不是 blob 的文件的最重要的例子是目录。os.makedirs
函数允许我们确保一个目录容易存在
os.makedirs(some_path, exists_ok=True)
这与来自os.path
的路径操作有力地结合起来,允许安全地创建嵌套文件:
def open_for_write(fname, mode=""):
os.makedirs(os.path.dirname(fname), exists_ok=True)
return open(fname, "w" + mode)
with open_for_write("some/deep/nested/name/of/file.txt") as fp:
fp.write("hello world")
例如,在镜像现有文件布局时,这可能会很有用。
os.path
模块主要有字符串操作函数,这些函数假设字符串是文件名。dirname
函数返回目录名,因此os.path.dirname("a/b/c")
将返回a/b
。类似地,函数basename
返回“文件名”,因此os.path.basename("a/b/c")
将返回c
。两者的逆函数是os.path.join
函数,它连接路径:os.path.join("some", "long/and/winding", "path")
将返回some/long/and/finding/path
。
os.path
模块中的另一组函数对获取文件元数据进行了更高级别的抽象。值得注意的是,这些函数通常是操作系统功能的轻量级包装,并且不会试图隐藏操作系统的怪癖。这意味着操作系统的怪癖可以通过抽象“泄漏”。
最大的元数据是os.path.exists
:文件存在吗?这有时会派上用场,尽管通常以不可知文件存在的方式编写代码会更好:文件存在可能会有竞争。更微妙的是os.path.is...
函数:isdir
、isfile
、islink
,更多的可以决定一个文件名是否指向我们所期望的。
os.path.get...
函数获取非布尔元数据:访问时间、修改时间、c-time(有时简称为“创建时间”,但在一些微妙的情况下,这可能会引起误解,而不是实际的创建时间,更准确的说法是“i-node 修改时间”),以及getsize
获取文件的大小。
shutil
模块(“shell 工具”)包含一些高级操作。shutil.copy
将复制文件的内容和元数据。shutil.copyfile
将只复制内容。shutil.rmtree
相当于rm -r
,而shutil.copytree
相当于cp -r
。
最后,临时文件通常很有用。Python 的tempfile
模块产生安全且抗泄漏的临时文件。最有用的功能是NamedTemporaryFile
,可以作为上下文使用。
典型的用法如下:
with NamedTemporaryFile() as fp:
fp.write("line 1\n")
fp.write("line 2\n")
fp.flush()
function_taking_file_name(fp.name)
注意这里的fp.flush
很重要。文件对象缓存写操作,直到关闭。但是,NamedTemporaryFile
一关闭就会消失。在调用将重新打开文件进行读取的函数之前,显式刷新它是很重要的。
4.2 流程
Python 中处理运行子流程的主要模块是subprocess
。它包含一个高级抽象,与大多数人想到“运行命令”时的直观模型相匹配,而不是在 UNIX 中使用exec
和fork
实现的低级模型。
它也是调用os.system
函数的强大替代方法,后者在几个方面都有问题。例如,os.system
产生了一个额外的进程,外壳。这意味着它依赖于 shell,在一些更奇怪的安装中,shell 可能与更“奇特”的系统 shell 不同,如ash
或fish
。最后,这意味着 shell 将解析字符串,这意味着字符串必须被正确序列化。这是一项艰巨的任务,因为 shell 解析器的正式规范很长。不幸的是,要写出在大多数情况下都能正常工作的东西并不困难,所以大多数错误都很微妙,并在最糟糕的时候出现。这有时甚至表现为安全缺陷。
虽然subprocess
没有完全灵活,但是对于大多数需求,这个模块已经足够了。
subprocess
本身也分为高层次的功能和低层次的实现层次。大多数情况下应该使用的高级函数是check_call
和check_output
。除了其他好处之外,它们的行为就像用-e
或set err
运行一个 shell 如果一个命令返回一个非零值,它们会立即引发一个异常。
稍微低一级的是Popen
,它创建流程并允许对其输入和输出进行细粒度配置。check_call
和check_output
都是在Popen
之上实现的。正因为如此,它们共享一些语义和论点。最重要的论点是shell=True
,最重要的是,使用它几乎总是一个坏主意。当给定参数时,需要一个字符串,并将其传递给 shell 进行解析。
Shell 解析规则很微妙,充满了死角。如果它是一个常量命令,没有任何好处:我们可以将命令翻译成代码中的独立参数。如果它包含一些输入,那么几乎不可能以一种不引入注入问题的方式可靠地避开它。另一方面,如果没有这一点,即使面对潜在的恶意输入,即时创建命令也是可靠的。
例如,下面将把一个用户添加到 docker 组。
subprocess.check_call(["usermod", "-G", "docker", "some-user"])
使用check_call
意味着如果命令由于某种原因失败,比如用户不存在,这将自动引发一个异常。这避免了常见的失败模式,在这种模式下,脚本不会报告准确的状态。
如果我们想让它成为一个接受用户名的函数,这很简单:
def add_to_docker(username):
subprocess.check_call(["usermod", "-G", "docker", username])
请注意,即使参数包含空格、#
或其他具有特殊含义的字符,调用它也是安全的。
为了判断当前用户当前在哪个组中,我们可以运行groups
。
groups = subprocess.check_output(["groups"]).split()
同样,如果命令失败,这将自动引发一个异常。如果成功,我们将得到字符串形式的输出:不需要手动读取和确定结束条件。
这两个函数有共同的参数。cwd
允许在给定目录内运行命令。这对于在当前目录中查找的命令很重要。
sha = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd="src/some-project").decode("ascii").strip()
这将获得项目的当前git
散列,假设项目是 git 目录。如果不是这样,git rev-parse HEAD
将返回非零值并引发一个异常。
注意,我们必须decode
输出,因为subprocess.check_output
像subprocess
中的大多数函数一样,返回一个字节字符串,而不是一个 Unicode 字符串。在这种情况下,rev-parse HEAD
总是返回一个十六进制字符串,所以我们使用了ascii
编解码器。这对于任何非 ASCII 字符都将失败。
在某些情况下,使用高级抽象是不可能的。例如,用它们不可能发送标准输入或成块读取输出。
Popen
运行子流程,允许对输入和输出进行精细控制。虽然所有的事情都有可能,但大多数事情都不容易做对。编写长管道的 shell 模式实现起来令人不快;更令人不快的是,要确保没有挥之不去的死锁条件;最重要的是,没有必要。
如果需要将短消息转换成标准输入,最好的方法是使用communicate
方法。
proc = Popen(["docker", "login", "--password-stdin"], stdin=PIPE)
out, err = proc.communicate(my_password + "\n")
如果需要更长的输入,让communicate
在内存中缓冲可能会有问题。虽然可以以块的形式写入进程,但是在没有潜在死锁的情况下这样做是很重要的。最好的选择通常是使用临时文件:
with tempfile.TemporaryFile() as fp:
fp.write(contents)
fp.write(of)
fp.write(email)
fp.flush()
fp.seek(0)
proc = Popen(["sendmail"], stdin=fp)
result = proc.poll()
事实上,在这种情况下,我们甚至可以使用check_call
函数:
with tempfile.TemporaryFile() as fp:
fp.write(contents)
fp.write(of)
fp.write(email)
fp.flush()
fp.seek(0)
check_call(["sendmail"], stdin=fp)
如果您习惯于在 shell 中运行进程,那么您可能习惯于长管道:
$ ls -l | sort | head -3 | awk '{print $3}'
如上所述,在 Python 中避免真正的命令并行性是一个最佳实践:在所有情况下,我们都试图在从下一个阶段读取之前完成一个阶段。在 Python 中,一般来说,使用subprocess
只用于调用外部命令。对于输入的预处理和输出的后处理,我们通常使用 Python 的内置处理能力:在上面的例子中,我们将使用sorted
切片和字符串操作来模拟逻辑。
用于文本和数字处理的命令在 Python 中很少有用,Python 有一个很好的内存模型来进行这种处理。在脚本中调用命令的一般情况是,操作数据的方式要么只记录为可由命令访问——例如,通过ps -ef
查询过程,要么命令的替代方式是一个微妙的库,有时需要二进制绑定,例如在docker
或git
的情况下。
这是一个将 shell 脚本翻译成 Python 必须仔细考虑的地方。原始代码有一个依赖于通过awk
或sed
的特殊字符串操作的长管道,Python 代码可能不那么并行,而且更明显。需要注意的是,在这些情况下,翻译中会丢失一些东西:最初的低内存需求和透明并行。然而,作为回报,我们得到了更易维护和调试的代码。
4.3 联网
Python 有大量的网络支持。它从最底层开始:支持基于socket
的系统调用到高层协议支持。解决问题的一些最佳方法是使用内置库。对于其他问题,最好的解决方案是第三方库。
底层网络 API 最直接的翻译在socket
模块中。这个模块公开了socket
对象。
HTTP 协议非常简单,因此我们可以直接从 Python 交互式命令提示符下实现一个简单的客户端。
>>> import socket, json, pprint
>>> s = socket.socket()
>>> s.connect(('httpbin.org', 80))
>>> s.send(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
40
>>> res = s.recv(1024)
>>> pprint.pprint(json.loads(
... res.decode('ascii').split('\r\n\r\n', 1)[1]))
{'args': {},
'headers': {'Connection': 'close', 'Host': 'httpbin.org'},
'origin': '73.162.254.113',
'url': 'http://httpbin.org/get'}
第s = socket.socket()
行创建一个新的套接字对象。我们可以用套接字对象做很多事情。其中之一是将它们连接到一个端点:在本例中,连接到服务器 httpbin.org
,端口 80。默认的套接字类型是一个流,互联网类型:这是 UNIX 引用 TCP 套接字的方式。
套接字连接后,我们可以向它发送字节。注意——在套接字上,只能发送字节串。我们读回结果并做一些特别的 HTTP 响应解析——并将实际内容解析为 JSON。
虽然一般来说,使用真正的 HTTP 客户端更好,但本文展示了如何编写低级套接字代码。这可能是有用的,例如,如果我们想通过重放确切的消息来诊断问题。
socket
API 很微妙,上面的例子中有一些不正确的假设。在大多数情况下,这段代码可以工作,但是在遇到极端情况时会以奇怪的方式失败。
如果内部内核级发送缓冲区容纳不下所有数据,那么send
方法可以不发送所有数据。这意味着它可以进行“部分发送”它返回上面的40
,这是字节串的整个长度。正确的代码会检查返回值并发送剩余的块,直到什么都没有了。幸运的是,Python 已经有了一个方法:sendall
。
然而,recv
出现了一个更微妙的问题。它将返回与内核级缓冲区一样多的数据,因为它不知道另一端打算发送多少数据。同样,在大多数情况下,尤其是对于短消息,这将工作得很好。对于像 HTTP 1.0 这样的协议,正确的行为是一直读取,直到连接关闭。
下面是代码的一个固定版本:
>>> import socket, json, pprint
>>> s = socket.socket()
>>> s.connect(('httpbin.org', 80))
>>> s.sendall(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
>>> resp = b''
>>> while True:
... more = s.recv(1024)
... if more == b'':
... break
... resp += more
...
>>> pprint.pprint(json.loads(resp.decode('ascii').split('\r\n\r\n')[1]))
{'args': {},
'headers': {'Connection': 'close', 'Host': 'httpbin.org'},
'origin': '73.162.254.113',
'url': 'http://httpbin.org/get'}
这是网络代码中的一个常见问题,在使用更高级别的抽象时也会发生。在简单的情况下,这些东西看起来可以工作,但在更极端的情况下,如高负载或网络拥塞时,它们就不能工作了。
有很多方法可以测试这些东西。其中之一是使用表现出极端行为的代理。编写或定制这些内容将需要使用socket
的底层网络编码。
Python 也有更高层次的网络抽象。虽然urllib
和urllib2
模块是标准库的一部分,但是 web 上的最佳实践发展很快,一般来说,对于更高级别的抽象,第三方库通常更好。
其中最流行的是第三方库,requests
。有了requests
,获取一个简单的 HTTP 页面就简单多了。
>>> import requests, pprint
>>> res=requests.get('http://httpbin.org/get')
>>> pprint.pprint(res.json())
{'args': {},
'headers': {'Accept': '∗/∗',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'close',
'Host': 'httpbin.org',
'User-Agent': 'python-requests/2.19.1'},
'origin': '73.162.254.113',
'url': 'http://httpbin.org/get'}
我们需要做的不是用原始字节制作自己的 HTTP 请求,而是给出一个 URL,类似于我们在浏览器中输入的 URL。请求解析它以找到要连接的主机( httpbin.org
)端口(80,默认为 HTTP)和路径(/get
)。一旦收到响应,它会自动将其解析为头和内容,并允许我们以 JSON 的形式直接访问内容。
虽然requests
很容易使用,但是,更好的方法是多花一点力气使用Session
对象。否则,将使用默认会话。这导致代码具有非本地副作用:调用请求的一个子库改变了一些会话状态,这导致另一个子库的调用行为不同。例如,HTTP cookies 是在一个会话中共享的。
上面的代码最好写成:
>>> import requests, pprint
>>> session = requests.Session()
>>> res = session.get('http://httpbin.org/get')
>>> pprint.pprint(res.json())
{'args': {},
'headers': {'Accept': '∗/∗',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'close',
'Host': 'httpbin.org',
'User-Agent': 'python-requests/2.19.1'},
'origin': '73.162.254.113',
'url': 'http://httpbin.org/get'}
在本例中,请求很简单,会话状态无关紧要。然而,这是一个需要养成的好习惯:即使在交互式解释器中,也要避免直接使用get
、put
和其他函数,而只使用会话接口。
使用交互式环境来构建代码原型是很自然的,这将在以后使其成为生产程序。通过保持这样的好习惯,我们可以轻松过渡。
4.4 总结
Python 是自动化操作系统操作的强大工具。这来自于拥有作为本机操作系统调用的瘦包装器的库和强大的第三方库的组合。
这允许我们接近操作系统,而没有任何介入的抽象,以及编写不关心这些无关紧要的细节的高级代码。
这种组合通常使 Python 成为编写脚本的更好选择,而不是使用 UNIX shell。这确实需要一种不同的思维方式:Python 并不适合文本转换器的长管道方法,但是在实践中,那些文本转换器的长管道结果是 shell 限制的产物。
使用现代的内存管理语言,将整个文本流读入内存通常更容易,然后操纵它,而不仅限于那些可以指定为管道的转换。
五、测试
用于自动化系统的代码通常不会像应用代码那样关注测试。DevOps 团队通常很小,而且时间紧迫。这样的代码也很难测试,因为它意味着自动化大型系统,并且适当的测试隔离是很重要的。
然而,测试是提高代码质量的最好方法之一。它在许多方面有助于使代码更易于维护。它还降低了缺陷率。对于缺陷经常意味着整个系统中断的代码,因为它经常触及系统的所有部分,这很重要。
5.1 单元测试
单元测试有几个不同的目的。记住这些目的是很重要的,因为单元测试所产生的压力有时是不一致的。
第一个目的是作为 API 使用示例。这有时被概括为有点不准确的术语“测试驱动开发”,有时被概括为另一个有点不准确的术语,“单元测试就是文档”
测试驱动开发意味着在逻辑之前编写单元测试,但是它通常对包含单元测试和逻辑的最终源代码提交没有什么影响,除非注意保存原始的分支提交历史。
然而,在提交中出现的是“作为运用 API 的方法的单元测试”理想情况下,而不是它是 API 的唯一文档。然而,它确实是一个有用的参考:至少,我们知道单元测试正确地调用了 API,并得到了它们期望的结果。
另一个原因是获得信心,相信代码中表达的逻辑做了正确的事情。同样,这经常被误称为“回归测试”,在最常见的这类测试之后:一种测试,以确保某人检测到的 bug 被真正修复。然而,由于代码开发人员知道潜在的边缘情况和更棘手的流程,他们通常能够在之前添加测试,这样的 bug 会出现在外部观察到的代码更改中:然而,这样一个增加信心的测试看起来就像一个“回归测试”
最后一个原因是为了避免不正确的未来变化。这与上面的“回归测试”不同,因为通常被测试的情况对于代码来说是直接的,并且所涉及的流程已经被其他测试所覆盖。然而,似乎一些潜在的优化或其他自然变化可能会打破这种情况,所以包括它有助于未来的维护程序员。
当编写一个测试时,重要的是要考虑它要完成这些目标中的哪一个。
一个好的测试将完成不止一个。所有测试都有两个潜在影响:
-
通过帮助未来的维护工作使代码变得更好。
-
让未来的维护工作变得更加困难,从而使代码变得更糟糕。
每项测试都会做到这两点。一个好的测试做更多的第一个,一个坏的测试做更多的第二个。减少不良影响的一个方法是考虑这样一个问题:“这个测试是在测试代码承诺要做的事情吗?”如果答案是“否”,这意味着以某种方式更改代码是有效的,这将破坏测试,但不会导致任何错误。这意味着测试必须被改变或丢弃。
在编写测试时,尽可能多地测试代码的实际契约是很重要的。
这里有一个例子:
def write_numbers(fout):
fout.write("1\n")
fout.write("2\n")
fout.write("3\n")
这个函数将几个数字写入一个文件。
一个糟糕的测试可能是这样的:
class DummyFile:
def __init__(self):
self.written = []
def write(self, thing):
self.written.append(thing)
def test_write_numbers():
fout = DummyFile()
write_numbers(fout)
assert_that(fout.written, is_(["1\n", "2\n", "3\n"]))
这是一个糟糕的测试的原因是因为它检查了一个write_numbers
从未做过的承诺:每次写只写一行。
未来的重构可能是这样的:
def write_numbers(fout):
fout.write("1\n2\n3\n")
这将保持代码正确—write_numbers
的所有用户仍然拥有正确的文件——但是会导致测试中的变化。
一种稍微复杂一点的方法是将编写的字符串连接起来。
class DummyFile:
def __init__(self):
self.written = []
def write(self, thing):
self.written.append(thing)
def test_write_numbers():
fout = DummyFile()
write_numbers(fout)
assert_that("".join(fout.written), is_("1\n2\n3\n"))
请注意,这个测试在我们上面建议的假设“优化”之前和之后都有效。但是,这还是比write_numbers
的默示合同更考验人。毕竟,该函数应该对文件进行操作:它可能使用另一种方法来编写。
如果我们将write_numbers
修改为:
def write_numbers(fout):
fout.writelines(["1\n",
"2\n",
"3\n"]
一个好的测试只有在代码中有错误的时候才会失败。然而,这段代码仍然适用于write_numbers
的用户,这意味着现在的维护涉及到不中断测试,纯粹是开销。
因为契约是为了能够写入文件对象,所以最好提供一个文件对象。在这种情况下,Python 有一个现成的:
def test_write_numbers():
fout = io.StringIO()
write_numbers(fout)
assert_that(fout.getvalue(), is_("1\n2\n3\n"))
在某些情况下,这将需要编写一个自定义的假。稍后,我们将讨论假货的概念,以及如何书写它们。
我们谈到了write_numbers
的隐性契约。因为它没有文档,我们无法知道最初的程序员的意图是什么。不幸的是,这很常见——尤其是在内部代码中,只被项目的其他部分使用。当然,最好清楚地记录程序员的意图。然而,在缺乏清晰文档的情况下,重要的是对隐含契约做出合理的假设。
上面,我们使用了函数assert_that
和is_
来验证这些值是我们所期望的。这些函数来自hamcrest
库。这个库移植自 Java,允许指定结构的属性并检查它们是否满足要求。
当使用pytest
测试运行器运行单元测试时,可以使用带有assert
关键字的常规 Python 操作符并获得有用的测试失败。然而,这将测试绑定到一个特定的运行者,并且有一组特别针对有用的错误消息的特定断言。
Hamcrest 是一个开放的库:虽然它内置了一些常见的断言(相等、比较、序列操作等等),但它也允许定义特定的断言。当处理复杂的数据结构时,例如从 API 返回的数据结构,或者当契约只能保证特定的断言时(例如,前三个字符可以是任意的,但必须在字符串的其余部分中的某个地方重复),这些就很方便了。
这允许测试函数的确切的约定。特别是,这是避免测试“过多”的另一个工具:测试可以改变的实现细节,当没有真正的用户被破坏时,需要改变测试。这一点至关重要,原因有三。
一个是简单明了的:花在更新本可以避免的测试上的时间是浪费时间。DevOps 团队通常很小,浪费资源的空间很小。
第二,习惯于在测试失败时改变测试是一个坏习惯。这意味着当行为因为一个错误而改变时,人们会认为正确的做法是更新测试。
最后,也是最重要的,这两者的结合将降低单元测试的投资回报,甚至更糟的是,感知的投资回报。因此,将会有组织上的压力,要求花更少的时间编写测试。测试实现细节的糟糕测试是“不值得为 DevOps 代码编写单元测试”的最大原因
作为一个例子,让我们假设我们有一个函数,在这个函数中,我们可以肯定地断言,结果必须能被其中一个自变量整除。
class DivisibleBy(hamcrest.core.base_matcher.BaseMatcher):
def __init__(self, factor):
self.factor = factor
def _matches(self, item):
return (item % self.factor) == 0
def describe_to(self, description):
description.append_text('number divisible by')
description.append_text(repr(self.factor))
def divisible_by(num):
return DivisibleBy(num)
按照惯例,我们将构造函数包装在一个函数中。如果我们想将参数转换成匹配器,这通常是有用的,在这种情况下没有意义。
def test_scale():
result = scale_one(3, 7)
assert_that(result,
any_of(divisible_by(3),
divisible_by(7)))
我们将得到如下所示的错误:
Expected: (number divisible by 3 or number divisible by 7)
but: was <17>
它让我们测试scale_one
的契约到底承诺了什么:在这种情况下,它将一个参数放大一个整数倍。
强调检验精确契约的重要性并不是偶然的。这种强调是一种可以学习的技能,并且具有可以教授的原则,使得单元测试成为加速编写代码过程的东西,而不是使它变慢。
人们厌恶单元测试的大部分原因是这种误解,因为单元测试“浪费了 DevOps 工程师的时间”,并导致大量测试不佳的代码,而这些代码是软件部署等业务流程的基础。正确地应用高质量单元测试的原则会为操作代码带来更可靠的基础。
5.2 仿制品、存根和赝品
典型的 DevOps 代码对操作环境有巨大的影响。事实上,这几乎是好的 DevOps 代码的定义:它取代了大量的手工工作。这意味着测试 DevOps 代码需要仔细完成:我们不能简单地为每次测试运行启动几百个虚拟机。
自动化操作意味着编写代码,随意运行会对生产系统产生重大影响。在测试代码时,尽可能减少这些副作用是值得的。即使我们有高质量的登台系统,每次在操作代码中出现错误时牺牲一个也会导致大量时间的浪费。重要的是要记住,单元测试是在产生的最差的代码上运行的:运行它们并修复 bug 的行为意味着,即使是提交到特性分支的代码也很可能处于更好的状态。
正因为如此,我们经常试图对一个“假”系统运行单元测试。对我们所说的“假”进行分类,以及它如何影响单元测试和代码设计是很重要的:在开始编写代码之前,考虑如何测试好代码是值得的。
替代未被测试系统的事物的中性术语是“测试加倍”Fakes、mocks 和 stubs 通常有更精确的含义,尽管在非正式谈话中它们会互换使用。
最“真实”的测试替身是“验证过的假货”。一个经过验证的伪造品完全实现了未被测试的系统的接口,尽管经常被简化:可能实现效率较低,经常不涉及任何外部操作系统。“已验证”指的是这样一个事实,即 fake 有它自己的测试,验证它确实实现了接口。
验证假的一个例子是在测试中使用仅存储的 SQLite 数据库,而不是基于文件的数据库。由于 SQLite 有自己的测试,这是一个经过验证的赝品:我们可以确信它的行为就像一个真正的 SQLite 数据库。
验证过的假货下面就是假货。fake 实现了一个接口,但通常是以一种简单的形式实现的,不值得花力气去测试。
例如,可以创建一个与subprocess.Popen
具有相同接口的对象,但它实际上从不运行流程:相反,它模拟一个消耗所有标准输入的流程,并将一些预定的内容输出到标准输出中,并以预定的代码退出。
如果足够简单,这个对象可能是一个存根。存根是一个简单的对象,它用预定的数据来回答,总是相同的,几乎不包含任何逻辑。这使得编写起来更容易,但也使它在能做的测试方面受到限制。
一个检查员,或者一个间谍,是一个附着在测试替身上并监控通话的对象。通常,函数契约的一部分是它将调用一些具有特定值的方法。检查器记录调用,并可以在断言中使用,以确保正确的调用得到了正确的参数。
如果我们将 inspect or 与存根或赝品结合起来,我们会得到一个模拟。因为这意味着 stub/fake 将比原来的有更多的功能(至少是检查录音所需的功能),这可能会导致一些副作用。然而,创建模拟的简单性和直接性通常通过使测试代码更简单来弥补。
5.3 测试文件
在许多方面,文件系统是 UNIX 系统中最重要的东西。虽然“一切都是文件”的口号不足以描述现代系统,但文件系统仍然是大多数操作的核心。
在考虑测试文件操作代码时,文件系统有几个值得考虑的属性。
首先,文件系统往往是健壮的。虽然文件系统中的错误并不是未知的,但它们很少发生,而且通常只由极端条件或不太可能的条件组合触发。
其次,文件系统往往很快。考虑这样一个事实,解压缩一个源 tarball,一个常规操作,将会快速连续地创建许多小文件(大约几千字节)。这是读写文件时快速系统调用机制与复杂缓存语义的结合。
文件系统也有一个奇怪的分形属性:除了一些深奥的操作,子-子-子目录支持与根目录相同的语义。
最后,文件系统有一个非常厚的接口。其中一些将内置于 Python 中,甚至——考虑到模块系统直接读取文件。也有第三方 C 库将使用它们自己的内部包装器来访问文件系统,以及几种打开文件的方法,甚至在 Python 中也是如此:内置的file
对象以及os.open
低级操作。
这结合了以下结论:对于大多数文件操作代码来说,伪造或模仿文件系统是低投资回报的。为了确保我们只测试一个功能的契约,投资是相当大的;由于该函数可以切换到低级文件操作,我们需要重新实现 Unix 文件语义的重要部分。回报低;直接使用文件系统是快速、可靠的,而且只要代码仅仅允许我们传递一个替代的“根路径”,几乎没有副作用。
设计文件操作代码的最佳方式是允许传入这样一个“根路径”参数,即使默认为/
。对于这样的设计,最好的测试方法是创建一个临时目录,适当地填充它,调用代码,然后对目录进行垃圾收集。
如果我们使用 Python 的内置tempfile
模块创建临时目录,那么我们可以配置 Tox runner 将临时文件放在 Tox 的内置临时目录中,从而保持一般文件系统的整洁,并且通常与已经忽略 Tox 工件的版本控制ignore
文件兼容。
setenv =
TMPDIR = {envtmpdir}
commands =
python -m 'import os;os.makedirs(sys.argv[1])' {envtmpdir}
# rest of test commands
创建临时目录很重要,因为 Python 的tempfile
只有在指向真实目录时才会使用环境变量。
作为一个例子,我们将为一个寻找.js
文件并将它们重命名为.py
的函数编写测试。
def javascript_to_python_1(dirname):
for fname in os.listdir(dirname):
if fname.endswith('.js'):
os.rename(fname, fname[:3] + '.py')
这个函数使用os.listdir
调用来查找文件名,然后用os.rename
重命名它们。
def javascript_to_python_2(dirname):
for fname in glob.glob(os.path.join(dirname, "∗.js")):
os.rename(fname, fname[:3] + '.py')
这个函数使用glob.glob
函数通过通配符过滤所有匹配*.js
模式的文件。
def javascript_to_python_3(dirname):
for path in pathlib.Path(dirname).iterdir():
if path.suffix == '.js':
path.rename(path.parent.joinpath(path.stem + '.py'))
该函数使用内置模块pathlib
(Python 3 中的新功能)迭代目录并找到其子节点。
测试中的真实函数不确定使用哪个实现:
def javascript_to_python(dirname):
return random.choice([javascript_to_python_1,
javascript_to_python_2,
javascript_to_python_3])(dirname)
由于我们不能确定函数将使用哪个实现,我们只剩下一个选择:测试实际的契约。
为了编写一个测试,我们将定义一些助手代码。在真实的项目中,这些代码将存在于一个专用的模块中,可能被命名为类似于helpers_for_tests
的东西。这个模块将通过自己的单元测试进行测试。
我们首先为临时目录创建一个上下文管理器。这将尽可能确保临时目录被清理。
@contextlib.contextmanager
def get_temp_dir():
temp_dir = tempfile.mkdtemp()
try:
yield temp_dir
finally:
shutil.rmtree(temp_dir)
因为这个测试需要创建很多文件,并且我们不太关心它们的内容,所以我们为此定义了一个助手方法。
def touch(fname, content=''):
with open(fname, 'a') as fpin:
fpin.write(content)
现在在这些函数的帮助下,我们终于可以编写一个测试了:
def test_javascript_to_python_simple():
with get_temp_dir() as temp_dir:
touch(os.path.join(temp_dir, 'foo.js'))
touch(os.path.join(temp_dir, 'bar.py'))
touch(os.path.join(temp_dir, 'baz.txt'))
javascript_to_python(temp_dir)
assert_that(set(os.listdir(temp_dir)),
is_({'foo.py', 'bar.py', 'baz.txt'}))
对于一个真实的项目,我们将编写更多的测试,其中许多可能使用我们上面的get_temp_dir
和touch
助手。
如果我们有一个应该检查特定路径的函数,我们可以让它接受一个参数来“相对化”它的路径。
例如,假设我们想要一个函数来分析我们的 Debian 安装路径,并给出我们下载软件包的所有域的列表。
def _analyze_debian_paths_from_file(fpin):
for line in fpin:
line = line.strip()
if not line:
continue
line = line.split('#', 1)[0]
parts = line.split()
if parts[0] != 'deb':
continue
if parts[1][0] == '[':
del parts[1]
parsed = hyperlink.URL.from_text(parts[1].decode('ascii'))
yield parsed.host
一个简单的方法是测试_analyze_debian_paths_from_file
。然而,它是一个内部功能,并且没有?? 合同。实现可以改变,也许读取文件,然后扫描所有字符串,或者可能分解这个函数,让顶层处理行循环。
相反,我们想测试公共 API:
def analyze_debian_paths():
for fname in os.listdir('/etc/apt/sources.list.d'):
with open(os.path.join('/etc/apt/sources.list.d', fname)) as fpin:
yield from _analyze_debian_paths_from_file(fpin)
然而,如果没有 root 权限,我们无法控制目录/etc/apt/sources.list.d
,即使有 root 权限,这也是一个风险:让每个测试运行控制这样一个敏感的目录。此外,许多持续集成系统并不是为使用 root 特权运行测试而设计的,这是有充分理由的,使得这种方法成为一个问题。
相反,我们可以稍微推广一下这个函数。这意味着有意扩展该函数的官方、公共 API 以允许测试。这绝对是一种取舍。
然而,扩展是最小的:我们需要的只是一个明确的工作目录。作为回报,我们可以简化我们的测试需求,同时避免任何类型的“修补”,这不可避免地会涉及到私有的实现细节。
def analyze_debian_paths(relative_to='/'):
sources_dir = os.path.join(relative_to, 'etc/apt/sources.list.d')
for fname in os.listdir(sources_dir):
with open(os.path.join(sources_dir, fname)) as fpin:
yield from _analyze_debian_paths_from_file(fpin)
现在,使用与之前相同的助手,我们可以为此编写一个简单的测试:
def test_analyze_debian_paths():
with get_temp_dir() as root:
touch(os.path.join(root, 'foo.list'),
content='deb http://foo.example.com\n')
ret = list(analyze_debian_paths(relative_to=root))
assert(ret, equals_to(['foo.example.com']))
同样,在一个真实的项目中,我们会编写不止一个测试,并试图确保覆盖更多的案例。这些可以用同样的技术建造。
给任何访问特定路径的函数添加一个relative_to
参数是一个好习惯。
5.4 测试流程
测试流程操作代码通常是一项微妙的工作,充满了权衡。理论上,进程运行代码与操作系统有一个很厚的接口;我们讨论了subprocess
模块,但是也可以直接使用os.spawn*
函数,甚至使用代码os.fork
和os.exec*
函数。同样,标准的输出/输入通信机制可以用许多方式实现,包括使用Popen
抽象或者用os.pipe
和os.dup.
直接操作文件描述符
流程操作代码也可能是最脆弱的。作为起点,运行外部命令取决于这些命令的行为。进程间通信意味着流本质上是并发的。让测试依赖于并不总是正确的假设,这是很容易犯的错误。这些错误会导致“古怪”的测试:在大多数情况下通过,但在看似随机的情况下失败。
那些排序假设有时在开发机器或未加载的机器上可能更经常是正确的,这意味着 bug 将只在生产中暴露,或者可能只在极端情况下才在生产中暴露。
这就是为什么关于使用进程的章节集中在减少并发性和使事情更有序的方法上。出于这个原因,精心设计可靠的可测试过程代码也是值得的。这种设计本身通常会给代码带来压力,要求代码简单可靠。
如果代码仅仅使用了subprocess.check_call
和subprocess.check_output
,而没有利用外来参数,我们通常可以使用一种叫做“依赖注入”的模式的简化形式来使其可测试。在这种情况下,“依赖注入”只是“向函数传递参数”的一种花哨说法
考虑以下函数:
def error_lines(container_name):
logs = subprocess.check_output(["docker", "logs", container_name])
for line in logs:
if 'error' in line:
return line
这个函数不容易测试。我们可以使用高级补丁来替换subprocess.check_output
,但是这很容易出错,并且依赖于实现细节。相反,我们可以明确地将实现细节提升为契约的一部分:
def error_lines(container_name, runner=subprocess.check_output):
logs = runner(["docker", "logs", container_name])
for line in logs:
if 'error' in line:
yield line.strip()
现在runner
是官方接口的一部分,测试变得容易多了。这可能看起来是一个微不足道的变化,但它比看起来更深刻;在某种意义上,error_lines
现在已经主动将其接口约束到流程运行中。
我们可能想用类似下面的东西来测试它:
def test_error_lines():
container_name = 'foo'
def runner(args):
if args[0] != 'docker':
raise ValueError("Can only run docker", args)
if args[1] != 'logs':
raise ValueError("Can only run docker logs", args)
if args[2] != container_name:
raise ValueError("No such container", args[2])
return iter(["hello\n", "error: 5 is not 6\n", "goodbye\n"])
ret = error_lines(container_name, runner=runner)
assert_that(list(ret), is_(["error: 5 is not 6"))
请注意,在这种情况下,我们并没有将自己限制为只让检查契约:error_lines
可能已经运行,例如,docker logs -- <container_name>
。然而,我们方法的一个优点是,我们可以慢慢提高我们的保真度,只有和可以提高测试。
例如,我们可以给跑步者添加:
def runner(args):
if args[0] != 'docker':
raise ValueError("Can only run docker", args)
if args[1] != 'logs':
raise ValueError("Can only run docker logs", args)
if args[2] == '--':
arg_container_name = args[3]
else:
arg_container_name = args[2]
if args_container_name != container_name:
raise ValueError("No such container", args[2])
return iter(["hello\n", "error: 5 is not 6\n", "goodbye\n"])
这将仍然与旧版本的代码一起工作,也将与修改后的代码一起工作。完全模仿 docker 是不现实的,也是不值得的。然而,这种方法会慢慢地提高测试的准确性,而且没有缺点。
如果我们的大量代码接口,例如 docker,我们最终可以将这样一个迷你 docker 仿真器分解到它自己的测试助手库中。
对流程运行使用更高级别的抽象有助于这种方法。例如,seashore
库将计算命令的部分与底层运行程序分开,只允许替换底层运行程序。
def error_lines(container_name, executor):
logs, _ignored = executor.docker.logs(container_name).batch()
for line in logs.splitlines():
if 'error' in line:
yield line.strip()
当在生产环境中运行时,在顶部的某个地方,将使用类似如下的代码创建一个executor
对象:
executor = seashore.Executor(seashore.Shell())
该对象将被传递给调用error_lines
的对象,并在那里使用。通常,当使用seashore
时,我们将执行器的创建留给顶层功能。
在测试中,我们创建了自己的外壳:
@attr.s
class DummyShell:
_container_name = attr.ib()
def batch(self, ∗args, ∗∗kwargs):
if (args == ['docker', 'logs', self._container_name] and
kwargs == {}):
return "hello\nerror: 5 is not 6\ngoodbye\n", ""
raise ValueError("unknown command", self, args, kwargs)
def test_error_lines():
container_name = 'foo'
executor = seashore.Executor(DummyShell(container_name))
ret = error_lines(container_name, executor)
assert_that(list(ret), is_(["error: 5 is not 6"]))
使用attrs
库,尤其是在写各种假货的时候,往往是个好主意。赝品往往是有意制造的简单物品。因为断言和异常中会涉及到它们,所以对它们进行高质量的表示是很有用的。这正是attrs
帮助减少的那种样板文件。
同样,我们可能需要慢慢提升我们的保真度。
因为流程很难测试,所以只在必要时使用流程运行是好的。尤其是在将 shell 脚本移植到 Python 时——当它们变得越来越复杂时,这通常是个好主意——用内存中的数据处理代替长管道是个好主意。
特别是如果我们以正确的方式分解代码,将数据处理作为一个简单的纯函数,接受一个参数并返回一个值,那么测试大部分代码就成了一种乐趣。
想象一下,例如,管道,
ps aux | grep conky | grep -v grep | awk '{print $2}' | xargs kill
这将杀死所有名字中有conky
的进程。
下面是一种重构代码以使其更易于测试的方法:
def get_pids(lines):
for line in lines:
if 'conky' not in line:
continue
parts = line.split()
pid_part = parts[1]
pid = int(pid_part)
yield pid
def ps_aux(runner=subprocess.check_output):
return runner(["ps", "aux"])
def kill(pids, killer=os.kill):
for pid in pids:
killer(pid, signal.SIGTERM)
def main():
kill(get_pid(ps_aux()))
注意最复杂的代码现在是如何在一个纯函数中:get_pids
。希望这意味着大多数 bug 都会存在,我们可以针对它们进行单元测试。
更难进行单元测试的代码get_pids
,我们不得不进行特别的依赖注入,现在是在简单的函数中,有更少的失败模式。
主要的逻辑在进行数据处理的函数中。测试这些只需要提供简单的数据结构并观察返回值。将潜在的 bug 从需要单元测试更多精力的系统相关代码,移到更容易单元测试的纯逻辑,意味着减少bug;单元测试会发现更多的错误。
5.5 测试网络
在requests
库文档中,使用Session
对象属于“高级”部分。这是不幸的。除了一次性脚本或交互式 REPL 用法,使用Session
对象是最好的选择。到目前为止,测试是最不重要的原因——但是一旦使用了Session
,测试就变得容易多了。
使用requests
的简单示例代码可能如下所示:
def get_files(gist_id):
gist = requests.get(f"https://api.github.com/gists/{gist_id}").json()
result = {}
for name, details in gist["files"].items():
result[name] = requests.get(details["raw_url"]).content
return result
这很难单独测试。相反,我们重写它以获取一个显式会话对象:
def get_files(gist_id, session=None):
if session is None:
session = requests.Session()
gist = session.get(f"https://api.github.com/gists/{gist_id}").json()
result = {}
for name, details in gist["files"].items():
result[name] = session.get(details["raw_url"]).content
return result
代码几乎相同。然而,现在测试变成了用get
方法编写一个对象的简单事情。
@attr.s(frozen=True)
class Gist:
files = attr.ib()
@attr.s(frozen=True):
class Response:
content = attr.ib()
def json(self):
return json.loads(content)
@attr.s(frozen=True)
class FakeSession:
_gists = attr.ib()
def get(self, url):
parsed = hyperlink.URL.from_text(url)
if parsed.host == 'api.github.com':
tail = path.rsplit('/', 1)[-1]
gist = self._gists[tail]
res = dict(files={name: f'http://example.com/{tail}/{name}'
for name in gist.files})
return Repsonse(json.dumps(res))
if parsed.host == 'example.com':
_ignored, gist, name = path.split('/')
return Response(self.gists[gist][name])
这个有点啰嗦。有时,如果这个功能是本地化的,并且不值得编写整个助手库,我们可以使用unittest.mock
库。
def make_mock():
gist_name = 'some_name'
files = {'some_file': 'some_content'}
session = mock.Mock()
session.get.content.return_value = 'some_content'
session.get.json.return_value = json.dumps({'files': 'some_file'})
return session
这是一个“快速和肮脏”的黑客,依靠的是这样一个事实(即合同中的而不是)使用content
检索文件内容,使用json
检索要点的逻辑结构。然而,使用稍微依赖于实现细节的模拟来编写快速测试通常比根本不编写测试要好。
将这样的测试视为“技术债务”并在某个时候对其进行改进以更多地依赖于合同而不是实现细节是很重要的。一个好的方法是在代码中添加注释,并将其链接到问题跟踪器。这也让测试代码的读者明白这仍然是一项正在进行的工作。
另一件重要的事情是,如果一个新的实现破坏了测试,通常正确的解决方法是而不是针对新的实现编写另一个测试。解决这个问题的正确方法是将更多的测试转移到基于契约的测试中。这可以通过首先改进测试来完成,但是要确保它运行在旧代码之上。然后开始重构代码,看到测试仍然通过。
当编写处理较低级概念(如套接字)的网络代码时,类似的思想仍然适用。因为 socket 对象的创建与它的任何使用是分开的,所以编写接受 socket 对象的函数并在外部创建它们会有很多好处。
为了模拟极端条件,看看我们的代码是否能在极端条件下工作,我们可能需要使用类似下面这样的东西作为 socket fake:
@attr.s
class FakeSimpleSocket:
_chunk_size = attr.ib()
_received = attr.ib(init=False, factory=list)
_to_send = attr.ib()
def connect(self, addr):
pass
def send(self, blob):
actually_sent = blob[:chunk_size]
self._received.append(actually_sent)
return len(actually_sent)
def recv(self, max_size):
chunk_size = min(max_size, self._chunk_size)
received, self._to_send = (self._to_send[:chunk_size],
self._to_send[chunk_size:])
return received
这允许我们控制“块”的大小一个极端的测试是使用 1 的chunk_size
。这意味着字节一次输出一个,一次接收一个。没有真正的网络会这么糟糕,但是单元测试允许我们模拟比任何合理的网络更极端的条件。
这个伪造品对测试网络代码很有用。例如,这段代码执行一些特定的 HTTP 来获得一个结果:
def get_get(sock):
sock.connect(('httpbin.org', 80))
sock.send(b'GET /get HTTP/1.0\r\nHost: httpbin.org\r\n\r\n')
res = sock.recv(1024)
return json.loads(res.decode('ascii').split('\r\n\r\n', 1)[1]))
它有一个微妙的缺陷。我们可以通过一个简单的单元测试来发现这个错误,使用 socket fake。
def test_get_get():
result = dict(url='http://httpbin.org/get')
headers = 'HTTP/1.0 200 OK\r\nContent-Type: application/json\r\n\r\n'
output = headers + json.dumps(result)
fake_sock = FakeSimpleSocket(to_send=output, chunk_size=1)
value = get_get(fake_sock)
assert_that(value, is_(result))
这个测试会失败:我们的get_get
假设网络连接质量很好,而这模拟的是一个很差的网络连接。如果我们把chunk_size
改成1024
就会成功。
我们可以循环运行测试,测试从 1 到 1024 的块大小。在真正的测试中,我们还会检查发送的数据,也可能发送无效的结果来查看响应。然而,重要的是,这些事情都不需要设置客户端或服务器,或者试图真实地模拟糟糕的网络。
5.6 总结
团队依靠 DevOps 代码来保持系统的功能性和可观察性。DevOps 代码的正确性至关重要。编写适当的测试将有助于提高代码的正确性。在对代码进行正确的修改时,考虑适当的测试编写原则将有助于减少修改测试的负担。
六、文本操作
基于 UNIX 的系统的自动化通常涉及文本操作。许多程序是用文本配置文件配置的。文本是许多系统的输出格式和输入格式。虽然像sed
、grep
和awk
这样的工具有它们的位置,但是 Python 是复杂文本操作的强大工具。
6.1 字节、字符串和 Unicode
当操作文本或类似文本的流时,很容易编写代码,当遇到外国名字或表情符号时会以有趣的方式失败。这些不再仅仅是理论上的担忧:你将拥有来自全世界的用户,他们坚持要求他们的用户名反映他们如何拼写他们的名字。有人会用表情符号写下git
承诺。为了确保写出健壮的代码,公平地说,当他们处理一个凌晨 3 点的页面时,不会失败,看起来不那么有趣,理解“文本”是一件微妙的事情是很重要的。
你可以理解其中的区别,或者当有人试图用表情符号用户名登录时,你可以在凌晨 3 点醒来。
Python 3 有两种不同的类型,它们都代表了 UNIX“文本”文件中常见的内容:字节和字符串。字节对应于 RFC 通常所说的“八位字节流”这是一个适合 8 位的值序列,或者换句话说,是一个范围在 0 到 256(包括 0 但不包括 256)之间的数字序列。当所有这些值都小于 128 时,我们称这个序列为“ASCII”(美国信息交换标准代码),并赋予这些数字 ASCII 赋予它们的含义。当所有这些值都在 32 和 128 之间(包括 32 但不包括 128)时,我们称该序列为“可打印的 ASCII”或“ASCII 文本”前 32 个字符有时被称为“控制字符”键盘上的“Ctrl”键就是一个参考——它最初的目的是能够输入这些字符。
ASCII 仅包含在“美国”使用的英语字母表为了用(几乎)任何语言表示文本,我们使用了 Unicode。Unicode 码位是介于 0 和2∗∗32
(包括 0 和不包括2∗∗32
)之间的(部分)数字。每个 Unicode 码位都被赋予一个含义。标准的后续版本保留了指定的含义,但增加了更多数字的含义。一个例子是增加了更多的表情符号。国际标准化组织 ISO 在其 10464 标准中批准了 Unicode 的版本。因此,Unicode 有时被称为 ISO-10464。
同样是 ASCII 的 Unicode 点具有相同的含义——如果 ASCII 赋予一个数字“大写 A”,那么 Unicode 也是如此。
正确地说,只有 Unicode 才是“文本”这就是 Python 字符串所代表的。用一个编码完成字节到字符串的转换,反之亦然。目前最流行的编码是 UTF 8。令人困惑的是,将字节转换成文本就是“解码”将文本转换成字节就是“编码”
为了处理文本数据,记住编码和解码之间的区别是至关重要的。记住它的一种方法是,由于 UTF-8 是编码,从字符串移动到 UTF-8 编码的数据是“编码”,而从 UTF-8 编码的数据移动到字符串是“解码”
UTF-8 有一个有趣的特性:当给定一个恰好是 ASCII 的 Unicode 字符串时,它将产生带有码位值的字节。这意味着“在视觉上”,编码和解码的形式看起来是一样的。
>>> "hello".encode("utf-8")
b'hello'
>>> "hello".encode("utf-16")
b'\xff\xfeh\x00e\x00l\x00l\x00o\x00'
我们展示了 UTF-16 的例子,以表明这不是编码的一个无关紧要的属性。UTF-8 的另一个特性是,如果字节是而不是 ASCII,并且字节的 UTF-8 解码成功,那么它们不太可能是用不同的编码进行编码的。这是因为 UTF-8 被设计成自同步:从一个随机字节开始,有可能与被检查的有限字节数的字符串同步。自同步旨在允许从截断和损坏中恢复,但作为一个附带的好处,它允许可靠地检测无效字符,从而检测字符串是否是 UTF-8 开始。
这意味着“用 UTF-8 尝试解码”是安全的操作;它将对纯 ASCII 文本做正确的事情,当然,它将对 UTF-8 编码的文本起作用,而对既不是 ASCII 也不是 UTF-8 编码的文本——无论是不同编码的文本还是 JPEG 等二进制格式的文本——将彻底失败。
对于 Python 来说,失败意味着“抛出异常”
>>> snowman = '\N{snowman}'
>>> snowman.encode('utf-16').decode('utf-8')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
对于随机数据,这也容易失败:
>>> struct.pack('B'∗12,
∗(random.randrange(0, 256)
for i in range(12))
).decode('utf-8')
误差是随机的,因为输入是随机的。一些错误示例可能是:
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2 in position 4: invalid continuation byte
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x98 in position 2: invalid start byte
这是一个很好的练习,试着跑几次;几乎永远不会成功。
6.2 弦
Python 字符串对象很微妙。从一个角度来看,它似乎是一个字符序列:一个字符是一个长度为 1 的字符串。
>>> a="hello"
>>> for i, x in enumerate(a):
... print(i, x, len(x))
...
0 h 1
1 e 1
2 l 1
3 l 1
4 o 1
字符串“hello”有五个元素,每个元素都是长度为 1 的字符串。由于字符串是一个序列,通常的序列操作对它进行操作。
我们可以通过指定两个端点来创建切片:
>>> a[2:4]
'll'
或者只是结尾:
>>> a[:2]
'he'
或者仅仅是开始:
>>> a[3:]
'lo'
我们也可以使用负指数从末尾开始计数:
>>> a[:-3]
'he'
当然,我们可以通过指定一个负步长的扩展片段来反转字符串:
>>> a[::-1]
'olleh'
然而,字符串也有相当多的方法是通用序列接口的而不是部分,在分析文本时非常有用。
startswith
和endswith
方法是有用的,因为文本分析通常在末端。
>>> "hello world".endswith("world")
True
一个鲜为人知的特性是endswith
允许一个字符串元组,并将检查它是否以这些字符串中的任何一个结尾:
>>> "hello world".endswith(("universe", "world"))
True
一个有用的例子是测试一些常见的结尾:
>>> filename.endswith((".tgz", ".tar.gz"))
我们可以很容易地在这里测试一个文件是否有一个 gzipped tarball 的通用后缀:或者是tgz
或者是tar.gz
后缀。
strip
和split
方法对于解析许多 UNIX 文件或工具中出现的特定格式非常有用。例如,文件/etc/fstab
包含静态挂载。
with open("/etc/fstab") as fpin:
for line in fpin:
line = line.rstrip('\n')
line = line.split('#', 1)[0]
if not line:
continue
device, path, fstype, options, freq, passno = line.split()
print(f"Mounting {device} on {path}")
这将解析文件并打印摘要。循环中的第一行去掉了换行符。rstrip
方法从字符串的右边(末尾)开始剥离。
请注意,rstrip
和strip
都接受要删除的字符序列。这意味着将一个字符串传递给rstrip
意味着“该字符串中的任何字符”,而不是“移除该字符串的出现”这并不影响rstrip
的单字符参数,但是这意味着更长的字符串几乎总是一种错误的用法。
然后我们删除评论,如果有的话。我们跳过空行。任何不为空的行,我们使用不带参数的split
来分割任何空白序列。方便的是,这种约定对几种格式都是通用的,正确的处理内置于split
的规范中。
最后,我们使用一个格式字符串来格式化输出,以便于使用。
这是字符串解析的一种典型用法,也是替代 shell 中长管道的那种代码。
最后,字符串上的join
方法将它用作“粘合剂”,将字符串的 iterable 粘合在一起。
简单的例子' '.join(["hello", "world"])
将返回"hello world
、"
,但这只是对join
的皮毛。因为它接受一个 iterable,所以我们有能力向它传递任何支持迭代的东西。
>>> names=dict(hello=1,world=2)
>>> ' '.join(names)
'hello world'
由于对字典对象的迭代产生了键的列表,将它传递给 join 意味着我们得到了一个包含键列表的字符串,它们连接在一起。
我们也可以传入一个生成器:
>>> '-∗-'.join(str(x) for x in range(3))
'0-∗-1-∗-2'
这允许在运行中计算序列并连接它们,而不需要序列的中间存储。
关于join
的常见问题是,为什么它是“粘合”字符串上的方法,而不是序列上的方法。原因正是这样:我们可以传入任何 iterable,粘合字符串将粘合其中的位。
注意join
对单元素的可重复项没有任何作用:
>>> '-∗-'.join(str(x) for x in range(0))
'0'
6.3 正则表达式
正则表达式是用于指定字符串属性的特殊 DSL,也称为“模式”它们在许多工具中很常见,尽管每个实现都有自己的特点。在 Python 中,正则表达式是由re
模块实现的。它基本上允许两种交互模式——一种是在文本分析时自动解析正则表达式,另一种是预先解析正则表达式。
一般来说,后一种风格是首选。自动解析正则表达式只适用于交互式循环,在这种情况下,它们会很快被使用并被遗忘。出于这个原因,我们在这里不会真正涉及这种用法。
为了编译一个正则表达式,我们使用re.compile
。该函数返回一个正则表达式对象,该对象将查找与表达式匹配的字符串。该对象可以用来做几件事:例如,查找一个匹配,查找所有匹配,甚至替换匹配。
正则表达式迷你语言有很多微妙之处。在这里,我们将只讨论说明如何有效使用正则表达式所需的基础知识。
大多数角色代表他们自己。例如,正则表达式hello
与hello
完全匹配。.
代表任何字符。所以hell
。将匹配hello
和hella
,但不匹配hell
——因为后者没有任何对应于..
方括号定界“字符类”的字符:例如,wom[ae]n
匹配women
和woman
。字符类中也可以有范围——[0-9]
匹配任何数字,[a-z]
匹配任何小写字符,[0-9a-fA-F]
匹配任何十六进制数字(十六进制数字和数字在很多地方都会出现,因为两个十六进制数字正好对应一个标准字节)。
我们还有“重复修饰语”,用来修饰它们前面的表达式。例如,ba?b
同时匹配bb
和bab
—?
代表“零或一”∗
代表任意数字:所以ba∗b
代表bb
、bab
、baab
、baaab
等等。If
我们想要“至少一个”,ba+b
将匹配ba∗b
匹配的几乎所有内容,除了bb
。最后,我们有确切的计数器:ba{3}b
匹配baaab
,而ba{1,2}b
匹配bab
和baab
,除此之外别无其他。
为了让一个特殊字符(比如.
或∗
)匹配它自己,我们在它前面加了一个反斜杠。因为在 Python 字符串中,反斜杠有其他含义,所以 Python 支持“原始”字符串。虽然我们可以使用任何字符串来表示正则表达式,但原始字符串通常更容易。
例如,我们想要一个类似 DOS 的文件名正则表达式:r"[^.]{1,8}\.[^.]{0,3}."
这将匹配,比如说,readme.txt
但不匹配archive.tar.gz
。请注意,要匹配文字。我们用反斜杠对它进行了转义。还要注意,我们使用了一个有趣的角色类:[^.]
。这意味着“除了.
之外的任何东西”:^
意味着“排除”一个字符类内部。
正则表达式也支持分组。分组做两件事:它允许对表达式的一部分进行寻址,它允许将表达式的一部分作为单个对象来处理,以便对它应用重复操作之一。如果只需要后者,这是一个“非捕获”组,用(?:....)
表示。
例如,(?:[a-z]{2,5}-){1,4}[0-9]
将匹配hello-3
或hello-world-5
,但不匹配a-hello-2
(因为第一部分不是两个字符长)或hello-world-this-is-too-long-7
,因为它由内部模式的六个重复组成,我们指定最多四个。
这允许任意嵌套;例如,(?:(?:[a-z]{2,5}-){1,4}[0-9];)+
允许前面模式的任何分号结束的、分隔的序列:例如,az-2;hello-world-5;
将匹配,但this-is-3;not-good-match-6
不匹配,因为它在末尾缺少了;
。
这是一个很好的例子,说明正则表达式有多复杂。很容易在 Python 中使用这种密集的迷你语言来指定难以理解的字符串约束。
一旦我们有了一个正则表达式对象,上面主要有两个方法:match
和search
。match
方法将在字符串的开头寻找匹配,而search
将寻找第一个匹配,无论它可能从哪里开始。当他们找到一个匹配时,他们返回一个匹配对象。
>>> reobj = re.compile('ab+a')
>>> m = reobj.search('hello abba world')
>>> m
<_sre.SRE_Match object; span=(6, 10), match="abba">
>>> m.group()
'abba'
经常使用的第一种方法是.group()
,它返回字符串匹配的部分。如果正则表达式包含捕获组的,这个方法可以得到部分匹配。一个捕获组通常标有()
。
>>> reobj = re.compile('(a)(b+)(a)')
>>> m = reobj.search('hello abba world')
>>> m.group()
'abba'
>>> m.group(1)
'a'
>>> m.group(2)
'bb'
>>> m.group(3)
'a'
当组的数量很大时,或者当修改组时,管理组的索引可能是一个挑战。如果需要进行分组分析,也可以将分组。
>>> reobj = re.compile('(?P<prefix>a)(?P<body>b+)(?P<suffix>a)')
>>> m = reobj.search('hello abba world')
>>> m.group('prefix')
'a'
>>> m.group('body')
'bb'
>>> m.group('suffix')
'a'
由于正则表达式可能会变得很密集,有一种方法可以使它们变得更容易阅读:详细模式。
>>> reobj = re.compile(r"""
... (?P<prefix>a) # The beginning -- always an a
... (?P<body>b+) # The middle -- any numbers of b, for emphasis
... (?P<suffix>a) # An a at the end to properly anchor
... """, re.VERBOSE)
>>> m = reobj.search("hello abba world")
>>> m.groups()
('a', 'bb', 'a')
>>> m.group('prefix'), m.group('body'), m.group('suffix')
('a', 'bb', 'a')
当编译带有标志re.VERBOSE
的正则表达式时,空白被忽略,类似 Python 的注释:
#
到行尾,也被忽略。为了匹配空格或#
,它们需要被反斜杠转义。
这允许编写长正则表达式,同时通过明智的换行符、空格和注释使它们更容易理解。
正则表达式松散地基于有限自动机的数学理论。虽然它们确实超越了有限自动机所能匹配的约束,但它们并不完全通用。除此之外,它们不太适合嵌套模式;无论是匹配括号还是 HTML 元素,都不太适合正则表达式。
6.4 JSON
JSON 是一种分层的文件格式,它的优点是易于解析,并且相当容易手动读写。它起源于网络:这个名字代表“JavaScript 对象符号”的确,在网上还是比较流行的;关注 JSON 的一个原因是许多 web APIs 使用 JSON 作为传输格式。
然而,它在其他地方也是有用的。例如,在 JavaScript 项目中,package.json
包括这个项目的依赖项。例如,对其进行解析通常有助于确定安全性或合规性审计的第三方依赖性。
理论上,JSON 是用 Unicode 定义的格式,而不是用字节定义的格式。序列化时,它接受数据结构并将其转换为 Unicode 字符串,反序列化时,它接受 Unicode 字符串并返回数据结构。然而,最近该标准被修改以指定一个首选编码:utf-8
。有了这个增加,现在格式也被定义为一个字节流。
但是,请注意,在某些用例中,编码仍然与格式分离。特别是,当通过 HTTP 发送或接收 JSON 时,HTTP 编码是最终的真理。尽管如此,当没有明确指定编码时,应该采用 UTF-8。
JSON 是一种简单的序列化格式,仅支持几种类型:
-
用线串
-
民数记
-
布尔运算
-
一种
null
类型 -
JSON 值的数组
-
“对象”:将字符串映射到 JSON 值的字典
请注意,JSON 没有完全指定数值范围或精度。如果需要精确的整数,通常可以假设范围-2∗∗53
到2∗∗53
是可以精确表示的。
尽管 Python json
库能够直接读写文件,但实际上我们几乎总是将任务分开;我们根据需要读取尽可能多的数据,并将字符串直接传递给 JSON。
在json
模块中最重要的两个功能是loads
和dumps
。末尾的s
代表“字符串”,这是这些函数接受和返回的内容。
>>> thing = [{"hello": 1, "world": 2}, None, True]
>>> json.dumps(thing)
'[{"hello": 1, "world": 2}, null, true]'
>>> json.loads(_)
[{'hello': 1, 'world': 2}, None, True]
Python 中的None
对象映射到 JSON null
对象,Python 中的布尔映射到 JSON 中的布尔,数字和字符串映射到数字和字符串。请注意,Python JSON 解析库根据所使用的符号来决定一个数字应该映射到整数还是浮点数:
>>> json.loads("1")
1
>>> json.loads("1.0")
1.0
重要的是要记住,不是所有的 JSON 加载库都做出相同的决定,在某些情况下,这可能会导致互操作性问题。
出于调试的原因,能够“漂亮地打印”JSON 通常是有用的。通过一些额外的参数,dumps
函数可以做到这一点。支持漂亮印刷的通常论据如下:
json.dumps(thing, sort_keys=True, indent=4)
如果我们想往返到一个等价的,但漂亮的版本,我们甚至可以这样做:
json.dumps(json.loads(encoded_string), sort_keys=True, indent=4)
最后,在命令行中,模块: json.tool 将自动执行以下操作:
$ python -m json.tool < somefile.json | less
这是浏览转储的 JSON 并寻找感兴趣的信息的一种简单方法。
注意,在 Python 3.7 及以上版本中,sort_keys
要慎用;由于所有的字典都是按插入排序的,而不是使用sort_keys
将保持字典中的原始顺序。
JSON 中一个经常遗漏的类型是日期-时间类型。通常这用字符串来表示,这是解析 JSON 的“模式”最常见的需求,以便知道哪些字符串要转换成datetime
对象。
6.5 CSV
CSV 格式有几个优点。它是受约束的:它总是在二维数组中表示标量类型。正因如此,能进去的惊喜并不多。此外,它还是一种可以导入到 Microsoft Excel 或 Google Sheets 等电子表格应用中的格式。这在准备报告时很方便。
此类报告的示例包括为财务部门支付第三方服务的费用明细,或者为管理层提供的关于管理的事件和恢复时间的报告。在所有这些情况下,拥有一个易于生成和导入到电子表格应用中的格式可以轻松实现任务的自动化。
用csv.writer
写 CSV 文件。一个典型的例子是序列化一个同质数组,一个相同类型的数组。
@attr.s(frozen=True, auto_attribs=True)
class LoginAttempt:
username: str
time_stamp: int
success: bool
该类表示某个用户在给定时间的登录尝试,并记录了该尝试的成功。对于安全审计,我们需要向审计员发送一个 Excel 文件,记录登录尝试。
def write_attempts(attempts, fname):
with open(fname, 'w') as fpout:
writer = csv.writer(fpout)
writer.writerow(['Username', 'Timestamp', 'Success'])
for attempt in attempts:
writer.writerow([
attempt.username,
attempt.time_stamp,
str(attempt.success),
])
注意,按照惯例,第一行应该是“标题行”尽管 Python API 并不强制要求这样做,但强烈建议遵循这一约定。在这个例子中,我们首先写了一个带有字段名称的“标题行”。
然后我们循环尝试。请注意,CSV 只能表示字符串和数字,因此我们没有依赖于关于如何写出布尔值的简单标准,而是显式地这样做了。
这样,如果审计员要求该字段为“是/否”,我们可以改变我们的显式序列化步骤来匹配。
在读取 CSV 文件时,有两种主要方法。
使用csv.reader
将返回一个迭代器,该迭代器以列表的形式逐行解析。然而,假设遵循了第一行是字段名称的约定,csv.DictReader
将不会为第一行产生任何内容,并为随后的每一行产生一个字典,使用字段名称作为键。这使得在面对终端用户添加字段或改变它们的顺序时能够进行更健壮的解析。
>>> reader = csv.DictReader(fileobj)
>>> list(reader)
[OrderedDict([('Username', 'alice'),
('Timestamp', '1514793600.0'),
('Success', 'False')]),
OrderedDict([('Username', 'bob'),
('Timestamp', '1539154800.0'),
('Success', 'True')])]
读取我们在前面的例子中编写的相同的 CSV 将产生合理的结果。字典将字段名映射到值。需要注意的是,所有类型都被遗忘了,所有内容都以字符串的形式返回。不幸的是,CSV 不保存类型信息。
有时候,用.split
来“即兴”解析 CSV 文件是很诱人的。然而,CSV 有相当多的不明显的极限情况。
例如,
1,"Miami, FL","he""llo"
被正确地解析为
('1', 'Miami, FL', 'he"llo')
出于同样的原因,避免使用除了csv.writer
.
之外的任何东西来编写 CSV 文件是一个好主意
6.6 总结
许多 DevOps 任务所需的大部分内容以文本形式出现:日志、数据结构的 JSON 转储或付费许可证的 CSV 文件。理解什么是“文本”,以及如何在 Python 中操作它,可以实现作为 DevOps 基石的许多自动化,无论是通过构建自动化、监控结果分析,还是仅仅准备摘要以方便他人使用。
七、requests
许多系统都公开了基于 web 的 API。有了requests
库,自动化基于 web 的 API 很容易。它被设计成易于使用,同时仍然公开了许多强大的功能。使用requests
几乎总是比使用 Python 的标准库 HTTP 客户端设施要好。
7.1 会议
如前所述,最好在requests
中使用显式会话。重要的是要记住,没有请求中的会话,就没有所谓的工作;当使用“函数”时,它使用全局会话对象。
这是有问题的,原因有几个。首先,这正是那种“全局可变共享状态”,这会导致很难诊断错误。例如,当连接到使用 cookie 的网站时,请求连接到同一网站的另一个用户可以覆盖 cookie。这导致了可能相距甚远的代码片段之间微妙的交互。
它有问题的另一个原因是,这使得代码对 unittest 来说很重要。必须显式模拟request.get/request.post
函数,而不是提供一个假的Session
对象。
最后但同样重要的是,有些功能只有在使用显式的Session
对象时才可访问。如果以后需要使用它,例如,因为我们希望为所有请求添加一个跟踪头或一个自定义用户代理,那么重构所有代码以使用显式会话可能会很微妙。
对于任何期望长寿的代码来说,使用显式会话对象要好得多。出于类似的原因,最好不要让大部分代码构造自己的Session
对象,而是将它作为参数获取。
这允许在离主代码更近的地方初始化会话。这很有用,因为这意味着关于使用哪个代理以及何时使用代理的决定可以在更接近最终用户需求的时候做出,而不是在抽象的库代码中。
用requests.Session()
构造一个会话对象。之后,唯一的互动应该是与对象。session 对象拥有所有的 HTTP 方法:s.get
、s.put
、s.post
、s.patch
和s.options
。
会话可用作上下文:
with requests.Session() as s:
s.get(...)
在上下文结束时,所有挂起的连接都将被清除。这有时很重要,尤其是如果一个 web 服务器有严格的使用限制,我们无论如何都不能超过。
注意,依靠 Python 的引用计数来关闭连接可能是危险的。这不仅没有得到语言的保证(例如,在 PyPy 中也不会是真的),而且一些小事情可以很容易地阻止这种情况的发生。例如,会话可以被捕获为堆栈跟踪中的局部变量,并且该堆栈跟踪可以包含在循环数据结构中。这意味着连接在很长一段时间内都不会关闭:直到 Python 执行了一个循环垃圾收集周期。
会话支持一些变量,我们可以对这些变量进行修改,以便以特定的方式发送所有请求。最常见的需要编辑的是s.auth
。我们将在后面详细介绍requests
的认证功能。
另一个对变异有用的变量是session.headers
。这些是随每个请求一起发送的默认标头。这有时对User-Agent
变量很有用。特别是当使用requests
来测试我们自己的 web APIs 时,在代理中拥有一个标识字符串是非常有用的。这将允许我们检查服务器日志,并区分哪些请求来自测试,哪些来自真实用户。
session.headers = {'User-Agent': 'Python/MySoftware ' + __version__ }
这将允许检查哪个版本的测试代码导致了问题。特别是如果测试代码使服务器崩溃,并且我们想要禁用它,这在诊断中是非常宝贵的。
该会话还在cookies
成员中包含一个CookieJar
。如果我们想要显式刷新或检查 cookies,这是很有用的。如果我们想要有可重启的会话,我们还可以用它将 cookies 持久化到磁盘上并恢复它们。
我们可以改变 cookie jar 或者大规模替换它:任何兼容cookielib.CookieJar
的对象都可以工作。
最后,会话中可以有一个客户端证书,用于需要这种身份验证的情况。这可以是一个pem
文件(密钥和证书连接在一起),也可以是一个包含证书和密钥文件路径的元组。
7.2 休息
REST 名称代表“代表性状态转移”它是一个松散的、应用松散的 web 信息表示标准。它通常用于将面向行的数据库结构几乎直接映射到 web 上,允许编辑操作;当以这种方式使用时,它通常也被称为“CRUD”模型:创建、检索、更新和删除。
当对 CRUD 使用 REST 时,经常会用到一些 web 操作。
第一个是创建到POST
的地图,通过session.post
访问。从某种意义上来说,尽管它是名单上的第一个,但却是四个中最不“宁静”的。这是因为它的语义不是“重放”安全的。
这意味着如果session.post
引发了一个网络级错误,例如socket.error
,那么如何处理就不明显了;这个对象真的被创建了吗?如果对象中的一个字段必须是惟一的,例如,用户的电子邮件地址,那么重放是安全的:如果创建操作较早成功,重放将会失败。
然而,这取决于应用语义,这意味着一般情况下不可能重放。
幸运的是,通常用于其他操作的 HTTP 方法是“重放安全”这个性质也被称为幂等性,它受到幂等函数的数学概念的启发(尽管并不完全相同)。这意味着如果发生网络故障,再次发送操作是安全的。
如果服务器遵循正确的 HTTP 语义,随后的所有操作都是重放安全的。
更新操作通常用PUT
(对于整体对象更新)或PATCH
(当改变特定字段时)来实现。
删除操作用 HTTP DELETE
实现。这里的重放安全性是微妙的;不管一个重放是成功还是失败,最后我们都处于一个已知的状态。
用 HTTP GET
实现的 Retrieve 几乎总是只读操作,replay safe 也是如此:在网络故障后重试是安全的。
如今,大多数 REST 服务都使用 JSON 作为状态表示。requests
库对 JSON 有特殊的支持。
>>> pprint(s.get("https://httpbin.org/json").json())
{'slideshow': {'author': 'Yours Truly',
'date': 'date of publication',
'slides': [{'title': 'Wake up to WonderWidgets!', 'type': 'all'},
{'items': ['Why <em>WonderWidgets</em> are great',
'Who <em>buys</em> WonderWidgets'],
'title': 'Overview',
'type': 'all'}],
'title': 'Sample Slide Show'}}
请求的返回值Response
有一个.json()
方法,该方法假设返回值是 JSON 并解析它。虽然这仅节省了一个步骤,但在多阶段流程中,这是一个非常有用的步骤,在这种流程中,我们得到一些 JSON 编码的响应,只是为了在下一个请求中使用它。
也可以将请求体自动编码为 JSON:
>>> resp = s.put("https://httpbin.org/put", json=dict(hello=5,world=2))
>>> resp.json()['json']
{'hello': 5, 'world': 2}
将这两者结合起来,通过多步骤的过程,通常是有用的。
>>> res = s.get("https://api.github.com/repos/python/cpython/pulls")
>>> commits_url = res.json()[0]['commits_url']
>>> commits = s.get(commits_url).json()
>>> print(commits[0]['commit']['message'])
这个从 CPython 项目的第一个 pull 请求中获取 commit 消息的例子是一个使用好的 REST API 的典型例子。一个好的 REST API 包括作为资源标识符的 URL。我们可以将这些 URL 传递给进一步的请求,以获取更多信息。
7.3 安全性
HTTP 安全模型依赖于认证机构,通常简称为“CAs”。证书颁发机构对属于特定域(或不太常见的 IP)的公钥进行加密签名。为了启用密钥轮换和撤销,认证机构不使用他们的根密钥(浏览器信任的那个)来签署公钥。相反,他们签署一个“签名密钥”,它签署公钥。在这些“链”中,每个密钥签署下一个密钥,直到最终的密钥是服务器正在使用的那个密钥,这些链可以变得很长:通常有三级或四级深链。
由于证书对域进行签名,并且域通常托管在同一个 IP 上,因此请求证书的协议包括“服务器名称指示”,即 SNI。SNI 发送客户端想要连接的服务器名称,但不加密。然后,服务器使用适当的证书进行响应,并使用加密技术证明它拥有与签名的公钥相对应的私钥。
最后,可选地,客户端可以参与对其自身身份的加密证明。这是通过有点名不副实的“客户端证书”来实现的客户端必须使用证书和私有密钥进行初始化。然后,客户端发送证书,如果服务器信任认证机构,则证明它拥有相应的私钥。
客户端证书很少在浏览器中使用,但有时会被程序使用。对于一个程序来说,它们通常是更容易部署的秘密:包括requests
在内的大多数客户端已经支持从文件中读取它们。这使得使用通过文件提供秘密的系统来部署它们成为可能,比如 Kubernetes。这也意味着通过普通的 UNIX 系统权限来管理它们的权限更加容易。
*请注意,客户端证书通常不属于公共 CA。更确切地说,服务器所有者操作一个本地 CA,它通过一些本地确定的程序为客户端签署证书。这可以是从 IT 人员手动签名到自动签名证书的单点登录门户。
为了认证服务器端证书,requests
需要有一个客户端根 ca 源,以便能够成功完成安全连接。根据ssl
构建过程的细微之处,它可能会也可能不会访问系统证书库。
确保有一组好的根 ca 的最好方法是安装包certifi
。这个包有 Mozilla 兼容的证书,requests
将原生使用它。
这在连接到互联网时很有用;几乎所有的网站都经过测试,可以与 Firefox 兼容,因此都有兼容的证书链。如果证书验证失败,就会抛出错误CERTIFICATE VALIDATE FAILED
。互联网上有很多不幸的建议,包括在requests
文档中,关于传入标志verify=False
的“解决方案”。虽然在极少数情况下这个标志有意义,但它几乎从来没有意义。它的使用违反了 TLS 的核心假设:连接是加密和防篡改的。
例如,请求上有一个verify=False
意味着任何 cookies 或认证凭证现在都可以被任何有能力修改流内数据包的人截获。不幸的是,这种情况很普遍:ISP 和开放接入点通常都有动机不良的运营商。
更好的替代方法是确保文件系统中存在正确的证书,并通过verify='/full/path'
将路径传递给verify
参数。至少,这允许我们有一种“第一次使用时信任”的形式:手动从服务中获取证书,并将其写入代码。更好的做法是尝试一些带外验证,例如,让某人登录服务器并验证证书。
选择允许什么样的 SSL 版本,或者允许什么样的密码,稍微有点微妙。同样,这么做的理由很少:requests
设置了良好、安全的缺省值。然而,有时有一些最重要的问题:例如,出于监管原因避免使用特定的 SSL 密码。
要知道的第一件重要的事情是,requests
是围绕urllib3
库的包装器。为了改变底层参数,我们需要编写一个定制的HTTPAdapter
,并设置我们正在使用的会话对象来使用定制适配器。
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.poolmanager import PoolManager
class MyAdapter(HTTPAdapter):
pass
s = requests.Session()
s.mount('https://', MyAdapter())
当然,这没有业务逻辑影响:MyAdapter
类与HTTPAdapter
类没有什么不同。但是现在我们有了定制适配器的机制,我们可以更改 SSL 版本:
class MyAdapter(HTTPAdater)
def init_poolmanager(self, connections, maxsize, block=False):
self.poolmanager = PoolManager(num_pools=connections,
maxsize=maxsize,
block=block,
ssl_version=ssl.PROTOCOL_TLS)
与ssl_version
非常相似,我们也可以使用ciphers=
关键字参数来微调密码列表。这个关键字参数应该是一个字符串,它具有用 ?? 分隔的密码名称。
Requests 还支持所谓的“客户端”证书。很少用于用户到服务的通信,但有时用于微服务架构,客户端证书使用与服务器标识自己相同的机制来标识客户端:使用加密签名的证明。客户端需要一个私钥和一个相应的证书。这些证书通常由私有 CA 签署,它是本地基础设施的一部分。
证书和密钥可以连接到同一个文件中,通常称为“PEM”文件。在这种情况下,初始化会话以标识它是通过以下方式完成的:
s = requests.Session()
s.cert = "/path/to/pem/file.pem"
如果证书和私钥在不同的文件中,则它们以元组的形式给出:
s = requests.Session()
s.cert = ("/path/to/client.cert", "/path/to/client.key")
此类关键文件必须小心管理;任何对它们有读取权限的人都可以假装是客户。
7.4 认证
这将是与请求一起发送的默认身份验证。包含在requests
本身中,最常用的认证是基本认证。
对于基本 auth,这个参数可以只是一个元组,(username, password)
。然而,更好的做法是使用一个HTTPBasicAuth
实例。这更好地记录了意图,如果我们想切换到其他身份验证形式,这很有用。
还有一些第三方包实现了身份验证接口并提供了定制的身份验证类。该接口非常简单:它希望对象是可调用的,并将使用Request
对象调用该对象。预计调用将使Requests
变异,通常是通过添加头。
官方文档推荐子类化AuthBase
,它只是一个实现了引发NotImplementedError
的__call__
的对象。这几乎没有必要。
例如,下面是一个有用的对象,它将使用 V4 签名协议对 AWS 请求进行签名。
我们做的第一件事是使 URL“规范”规范化是许多签名协议的第一步。由于在签名检查器开始查看内容时,软件的更高级别通常已经解析了内容,所以我们将签名的数据转换成唯一对应于解析版本的标准形式。
最微妙的部分是查询部分。我们解析它,并使用urlparse
内置库对它进行重新编码。
def canonical_query_string(query):
if not query:
return ""
parsed = parse_qs(url.query, keep_blank_values=True)
return "?" + urlencode(parsed, doseq=True)
我们在 URL 规范化函数中使用这个函数:
def to_canonical_url(url):
url = urlparse(raw_url)
path = url.path or "/"
query = canonical_query_string(url.query)
return (url.scheme +
"://" +
url.netloc +
path +
querystring)
这里我们确保路径是规范的:我们将一个空路径转换为/
。
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
def sign(request, ∗, aws_session, region, service):
aws_request = AWSRequest(
method=request.method.upper(),
url=to_canonical_url(request.url),
data=request.body,
)
credentials = aws_session.get_credentials()
SigV4Auth(credentials, service, region).add_auth(request)
request.headers.update(∗∗aws_request.headers.items())
我们创建了一个使用botocore
(AWS Python SDK)来签署请求的函数。我们通过用规范的 URL 和相同的数据“伪造”一个AWSRequest
对象,要求签名,然后从“伪造的”请求中获取头。
我们的用法如下:
requests_session = requests.Session()
requests_session.auth = functools.partial(sign,
aws_session=boto3.Session(),
region='us-east-1',
service='es',
)
functools.partial
是一种从原始函数中获得简单可调用函数的简单方法。注意,在这种情况下,区域和服务是 auth“对象”的一部分一种更复杂的方法是从请求的 URL 中推断出地区和服务,并使用它。这超出了这个简单示例的范围。然而,这应该能让我们很好地了解定制认证方案是如何工作的:我们编写代码来修改请求,使其具有正确的认证头,然后将它作为会话的auth
属性放入。
7.5 总结
说“HTTP 流行”感觉是轻描淡写。它无处不在:从用户可访问的服务,通过面向 web 的 API,甚至在许多微服务架构的内部。
有助于所有这些:它可以帮助监控用户可访问的健康服务的一部分,它可以帮助我们访问程序中的 API 来分析数据,它可以帮助我们调试内部服务以了解它们的状态。
它是一个强大的库,有许多方法可以对它进行微调,以发送完全正确的请求,并获得完全正确的功能。*
八、密码系统
密码术是安全架构的许多部分中的必要组件。然而,仅仅在代码中添加加密技术并不能使其更加安全;必须注意诸如秘密生成、秘密存储和明文管理等主题。正确设计安全软件是一件复杂的事情,当涉及到加密技术时更是如此。
安全性设计超出了我们的范围:这一章只讲述 Python 用于加密的基本工具,以及如何使用它们。
8.1 费内特
cryptography
模块支持fernet
加密标准。它是以一种意大利酒命名的,而不是法国酒。发音的一个很好的近似是“公平网”
fernet
为对称密码术工作。它不支持部分或流解密:它期望读入整个密文并返回整个明文。这使得它适用于名称、文本文档,甚至图片。然而,视频和磁盘映像并不适合 Fernet。
Fernet 的加密参数是由领域专家选择的,他们研究了可用的加密方法,以及已知的针对这些方法的最佳攻击。使用 Fernet 的一个好处是,它避免了您自己成为专家的需要。然而,为了完整性,我们注意到 Fernet 标准在 CBC 中使用 AES-128 填充 PKCS7,而 HMAC 使用 SHA256 进行认证。
Go、Ruby 和 Erlang 也支持 Fernet 标准,因此有时适用于与其他语言的数据交换。它被特别设计成不安全地使用它比正确地使用它更难。
>>> k = fernet.Fernet.generate_key()
>>> type(k)
<class 'bytes'>
密钥是一个很短的字节串。安全地管理密钥是很重要的:加密技术的好坏取决于它的密钥。例如,如果它保存在一个文件中,那么该文件应该具有最低限度的权限,并且理想情况下托管在一个加密的文件系统上。
generate_key
类方法使用操作系统级别的随机字节源来安全地生成密钥。然而,它仍然容易受到操作系统级缺陷的攻击:例如,在克隆虚拟机时,必须注意在开始克隆时,它会刷新随机性的来源。不可否认,这是一个深奥的案例,无论使用什么虚拟化系统,都应该有关于如何刷新其虚拟机中的随机性来源的文档。
>>> frn = fernet.Fernet(k)
fernet 类用一个键初始化。它将确保密钥是有效的。
>>> encrypted = frn.encrypt(b"x marks the spot")
>>> encrypted[:10]
b'gAAAAABb1'
加密很简单。它接受一个字节字符串并返回一个加密的字符串。注意,加密的字符串比源字符串长。原因是它也是用密钥签名的。这意味着对加密字符串的篡改是可以检测到的,Fernet API 通过拒绝解密字符串来处理这个问题。这意味着从解密得到的值是可信的;它确实是由有权使用密钥的人加密的。
>>> frn.decrypt(encrypted)
b'x marks the spot'
解密的方式与加密相同。Fernet 确实包含一个版本标记,所以如果发现其中的漏洞,就有可能将标准转移到不同的加密和散列系统。
Fernet encryption 总是将当前日期添加到签名的加密信息中。因此,在解密前限制消息的年龄是可能的。
>>> frn.decrypt(encrypted, ttl=5)
如果加密信息(有时称为“令牌”)的时间超过五秒,这将失败。这有助于防止重放攻击:捕获并重放先前加密的令牌,而不是新的有效令牌。例如,如果加密的令牌具有允许某些访问的用户名列表,并且使用可破坏的介质来检索,则不再被允许进入的用户可以替换旧令牌。
确保令牌的新鲜将意味着没有这样的列表会被解码,每个人都将被拒绝——这并不比媒体在没有先前有效的令牌的情况下被篡改更糟糕。
这也可以用来确保良好的秘密轮换卫生。通过拒绝解密任何超过一周的内容,我们可以确保,如果秘密轮换基础设施崩溃,我们将大声失败,而不是静静地成功,从而修复它。
为了支持无缝的密钥轮换,Fernet 模块还有一个MultiFernet
类。MultiFernet
取秘密列表。它用第一个秘密加密,但会尝试用任何秘密解密。
这意味着如果我们在末尾添加一个新的密钥,第一,它不会被用于加密。在添加到末尾是同步的之后,我们可以删除第一个键。现在所有的加密都将通过第二个密钥来完成;并且即使在那些还没有同步的情况下,解密密钥也是可用的。
这一两步过程旨在实现零“无效解密”错误,同时仍允许密钥轮换,这是一项重要的预防措施——经过充分测试的轮换程序意味着,如果密钥泄露,轮换程序可以将它们造成的危害降至最低。
8.2 氯化钠
PyNaCl 是一个包装了 C 库的库。libsodium
是丹尼尔·j·伯恩斯坦(Daniel J. Bernstein)的libnacl,
的一个分支,这就是为什么PyNaCl
被这样命名的原因。(氯化钠是 Salt 的化学分子式。fork 采用第一个元素的名称。)
PyNaCl 支持对称和非对称加密。然而,由于加密支持 Fernet 的对称加密,PyNaCl 的主要用途是用于非对称加密。
不对称加密的思想是有一个私钥和一个公钥。公钥可以很容易地从私钥中计算出来,但反之则不然;也就是它所指的“不对称”。公钥是公开的,而私钥必须保密。
一般来说,公钥加密支持两种基本操作。我们可以用公钥加密,但只能用私钥解密。我们还可以用私钥对 T1 进行签名,这种签名方式可以用公钥进行验证。
正如我们之前所讨论的,现代密码实践对认证和 T2 保密的重视程度不相上下。这是因为如果传输秘密的介质容易被窃听,它通常也容易被修改。秘密修改攻击在这个领域已经产生了足够的影响,如果一个密码系统不能保证真实性和保密性,它就不能被认为是完整的。
正因为如此,libsodium
以及进一步的PyNaCl
不支持没有签名的加密,或者没有签名验证的解密。
为了生成私钥,我们只需使用 class 方法:
>>> from nacl.public import PrivateKey
>>> k = PrivateKey.generate()
k
的类型是PrivateKey
。然而,在某些时候,我们通常会希望保存私钥。
>>> type(k.encode())
<class 'bytes'>
encode
方法将密钥编码为字节流。
>>> kk = PrivateKey(k.encode())
>>> kk == k
True
我们可以从字节流中生成一个私钥,它将是相同的。这意味着我们可以再次以一种我们认为足够安全的方式保存私钥:例如,一个秘密管理器。
为了加密,我们需要一个公钥。公钥可以由私钥生成。
>>> from nacl.public import PublicKey
>>> target = PrivateKey.generate()
>>> public_key = target.public_key
当然,在更现实的场景中,公钥需要存储在某个地方:文件中、数据库中,或者只是通过网络发送。为此,我们需要将公钥转换成字节。
>>> encoded = public_key.encode()
>>> encoded[:4]
b'\xb91>\x95'
当我们得到字节时,我们可以重新生成公钥。它与原始公钥相同。
>>> public_key_2 = PublicKey(key_bytes)
>>> public_key_2 == public_key
True
PyNaCl Box
代表一对密钥:第一个私有,第二个公共。Box
用私钥签名,然后用公钥加密。我们加密的每条消息都会被签名。
>>> from nacl.public import PrivateKey, PublicKey, Box
>>> source = PrivateKey.generate()
>>> with open("target.pubkey", "rb") as fpin:
... target_public_key = PublicKey(fpin.read())
>>> enc_box = Box(source, target_public_key)
>>> result = enc_box.encrypt(b"x marks the spot")
>>> result[:4]
b'\xe2\x1c0\xa4'
这个使用source
私钥签名,使用target
的公钥加密。
当我们解密时,我们需要建立逆盒。这发生在不同的计算机上:一台有target
私钥但只有源的公钥 .
的计算机
>>> from nacl.public import PrivateKey, PublicKey, Box
>>> with open("source.pubkey", "rb") as fpin:
... source_public_key = PublicKey(fpin.read())
>>> with open("target.private_key", "rb") as fpin:
... target = PrivateKey(fpin.read())
>>> dec_box = Box(target, source_public_key)
>>> dec_box.decrypt(result)
b'x marks the spot'
解密盒使用target
私钥解密,并使用source
的公钥验证签名。如果信息被篡改,解密操作将自动失败。这意味着不可能访问没有正确签名的纯文本信息。
PyNaCl 中另一个有用的功能是加密签名。在没有加密的情况下签名有时是有用的:例如,我们可以通过签名来确保只使用被认可的二进制文件。这允许存储二进制文件的权限是宽松的,只要我们相信保持签名密钥安全的权限足够强。
签名还涉及非对称加密。私钥用于签名,公钥用于验证签名。这意味着,例如,我们可以将公钥登记到源代码控制中,并避免需要对验证部分进行任何进一步的配置。
我们首先必须生成私有签名密钥。这类似于生成用于解密的密钥。
>>> from nacl.signing import SigningKey
>>> key = SigningKey.generate()
我们通常需要将这个密钥(安全地)存放在某个地方,以便重复使用。同样,值得记住的是,能够访问签名密钥的任何人都可以对他们想要的任何数据进行签名。为此,我们可以使用编码:
>>> encoded = key.encode()
>>> type(encoded)
<class 'bytes'>
可以从编码版本中重构密钥。产生一把相同的钥匙。
>>> key_2 = SigningKey(encoded)
>>> key_2 == key
True
为了验证,我们需要验证密钥。由于这是非对称加密,验证密钥可以从签名密钥中计算出来,但反之则不行。
>>> verify_key = key.verify_key
我们通常需要将验证密钥存储在某个地方,所以我们需要能够将其编码为字节。
>>> verify_encoded = verify_key.encode()
>>> verify_encoded[:4]
b'\x08\xb1\x9e\xf4'
我们可以重建验证密钥。给出了一把相同的钥匙。像所有的...Key
类一样,它支持一个接受编码键并返回 key 对象的构造函数。
>>> from nacl.signing import VerifyKey
>>> verify_key_2 = VerifyKey(verify_encoded)
>>> verify_key == verify_key_2
True
当我们签署一条消息时,我们得到一个有趣的对象:
>>> message = b"The number you shall count is three"
>>> result = key.sign(message)
>>> result
b'\x1a\xd38[....'
它以字节显示。但它不是字节:
>>> type(result)
<class 'nacl.signing.SignedMessage'>
我们可以分别从中提取消息和签名:
>>> result.message
b'The number you shall count is three'
>>> result.signature
b'\x1a\xd38[...'
如果我们想把签名保存在一个单独的地方,这是很有用的。例如,如果原始文件在对象存储中,那么出于各种原因,对其进行变更可能是不可取的。在这种情况下,我们可以将签名“放在一边”另一个原因是为了不同的目的维护不同的签名,或者允许密钥轮换。
如果我们确实想写完整的签名消息,最好将结果显式转换为字节。
>>> encoded = bytes(result)
验证返回验证过的消息。这是使用签名的最佳方式;这样,代码就不可能处理未经验证的消息。
>>> verify_key.verify(encoded)
b'The number you shall count is three'
但是,如果有必要从其他地方读取对象本身,然后将它传递给验证器,这也很容易做到。
>>> verify_key.verify(b'The number you shall count is three',
... result.signature)
b'The number you shall count is three'
最后,我们可以直接使用结果对象进行验证。
>>> verify_key.verify(result)
b'The number you shall count is three'
8.3 密码库
密码的安全存储是一件微妙的事情。它如此微妙的最大原因是它必须与不使用密码最佳实践的人打交道。如果所有的密码都是强密码,并且人们从不在不同的站点之间重复使用密码,那么密码存储将会非常简单。
然而,人们通常选择熵值很小的密码(123456
仍然不合理地流行,password
也是如此),他们有一个用于所有网站的“标准密码”,他们经常容易受到网络钓鱼攻击和社会工程攻击,他们会将密码泄露给未经授权的第三方。
并非所有这些威胁都可以通过正确存储密码来阻止,但至少可以减轻和削弱其中的许多威胁。
这个库是由精通软件安全的人编写的,并且试图至少消除保存密码时最明显的错误。密码从不以纯文本格式保存,而是经过哈希处理。
请注意,密码的哈希算法针对不同的用例进行了优化,而不是针对其他原因使用的哈希算法:例如,他们试图否认的事情之一是暴力源映射攻击。
Passlib 使用针对密码存储优化的最新审核算法对密码进行哈希处理,旨在避免任何可能的旁路攻击。此外,“salt”总是用于散列密码。
虽然不理解这些东西也可以使用passlib
,但是为了避免在使用passlib.
时出现错误,理解这些东西是值得的
哈希意味着获取用户的密码,并通过一个相当容易计算但很难逆转的函数来运行。这意味着,即使攻击者能够访问密码数据库,他们也无法恢复用户的密码并冒充他们。
攻击者试图获取原始密码的一种方法是尝试他们能想到的所有密码组合,对它们进行哈希运算,并查看它们是否等于一个密码。为了避免这种情况,使用了计算困难的特殊算法。这意味着攻击者必须使用大量资源来尝试许多密码,因此即使只尝试了几百万个密码,也需要很长时间来进行比较。最后,攻击者可以使用一种叫做“彩虹表”的东西来预先计算许多常见密码的哈希值,并一次性将它们与密码数据库进行比较。为了避免这种情况,密码在被散列之前被“加 Salt”:添加一个随机前缀(“Salt”),密码被散列,Salt 作为散列值的前缀。当从用户处接收到密码时,在对其进行散列比较之前,从散列值的开始处检索 salt。
从头开始做这一切很难,甚至更难做好。“正确”并不仅仅意味着让用户登录,还意味着对密码数据库被盗具有弹性。因为没有关于那方面的反馈,所以最好使用经过良好测试的库。
该库是存储不可知的:它不关心密码存储在哪里。然而,它关心的是有可能更新散列密码。这样,哈希密码可以根据需要更新为新的哈希方案。虽然passlib
确实支持各种低级接口,但是最好使用CryptContext
的高级接口。这个名字有误导性,因为它不加密;它是对 Unix 内置的类似功能的引用。
要做的第一件事是决定支持的散列列表。注意,并不是所有的散列都必须是好的 T2 散列;如果我们过去支持坏散列,它们仍然必须在列表中。在这个例子中,我们选择argon2
作为我们的首选散列,但是允许更多的选项。
>>> hashes = ["argon2", "pbkdf2_sha256", "md5_crypt", "des_crypt"]
注意md5
和des
有严重的漏洞,不适合在实际应用中使用。我们添加它们是因为可能有旧的散列在使用它们。相比之下,即使pbkdf2_sha256
很可能比argon2
差,也没有更新的迫切需要。我们想将md5
和des
标记为不推荐使用。
>>> deprecated = ["md5_crypt", "des_crypt"]
最后,做出决定后,我们构建加密上下文:
>>> from passlib.context import CryptContext
>>> ctx = CryptContext(schemes=hashes, deprecated=deprecated)
可以配置其他细节,如回合数。这几乎总是不必要的,因为缺省值应该足够好。
有时我们会希望将这些信息保存在某个配置中(例如,一个环境变量或文件)并加载它;这样,我们可以在不修改代码的情况下更新哈希表。
>>> serialized = ctx.to_string()
>>> new_ctx = CryptContext.from_string(serialized)
保存字符串时,注意它确实包含换行符;这可能会影响它的保存位置。如果需要,总是可以将其转换为 base64。
在用户创建或更改密码时,我们需要在存储密码之前对其进行哈希运算。这是通过上下文中的hash
方法完成的。
>>> res = ctx.hash("good password")
登录时,第一步是从存储中检索散列。在检索散列值并从用户界面获得用户的密码后,我们需要检查它们是否匹配,如果散列值使用的是不推荐的协议,可能还需要更新散列值。
>>> ctx.verify_and_update("good password", res)
(True, None)
如果第二个元素为真,我们需要用结果更新散列。一般来说,指定一个特定的哈希算法并不是一个好主意,但是要信任上下文默认值。然而,为了展示更新,我们可以用弱算法强制上下文散列。
>>> res = ctx.hash("good password", scheme="md5_crypt")
在这种情况下,verify_and_update
会告诉我们应该更新哈希:
>>> ctx.verify_and_update("good password", res)
(True, '$5$...')
在这种情况下,我们需要在密码散列存储中存储第二个元素。
8.4 TLS 证书
传输层安全性(TLS)是一种保护传输中数据的加密方法。因为一种潜在的攻击是中间人,所以能够验证端点是否正确是很重要的。由于这个原因,公钥由认证机构签名。有时,拥有一个本地证书颁发机构是很有用的。
这在微服务架构中非常有用,在这种架构中,验证每个服务是否正确可以实现更安全的安装。另一个有用的例子是构建一个内部测试环境,在这种情况下,使用真正的认证机构有时是不值得的;很容易将本地证书颁发机构安装为本地信任的,并用它签署相关证书。
另一个有用的地方是运行测试。当运行集成测试时,我们希望建立一个真实的集成环境。理想情况下,这些测试中的一些会检查这一点;事实上,使用的是 TLS 而不是纯文本。如果为了测试的目的,我们降级到纯文本通信,这是不可能测试的。事实上,许多生产安全漏洞的根本原因是,为了支持纯文本通信而插入测试的代码在生产中被意外启用(或可能被恶意启用);此外,测试不存在这样的错误是不可能的,因为测试环境确实有明文通信。
出于同样的原因,在测试环境中允许未经验证的 TLS 连接是危险的。这意味着代码有一个非验证流,它可能在生产中被意外打开或恶意打开,并且无法通过测试来防止。
手动创建证书需要访问cryptography
中的hazmat
层。这样命名是因为这是危险的;我们必须明智地选择加密算法和参数,错误的选择会导致不安全的模式。
为了执行加密,我们需要一个“后端”这是因为最初它旨在支持多个后端。这种设计大部分都被否决了,但是我们仍然需要创建它并传播它。
>>> from cryptography.hazmat.backends import default_backend
最后,我们准备好生成我们的私钥。对于这个例子,我们将使用2048
位,这被认为是截至 2019 年的“合理安全”。关于哪些尺寸提供了多少安全性的完整讨论超出了本章的范围。
>>> from cryptography.hazmat.primitives.asymmetric import rsa
>>> private_key = rsa.generate_private_key(
... public_exponent=65537,
... key_size=2048,
... backend=default_backend()
... )
在非对称加密中,从私钥计算公钥是可能的(也是快速的)。
>>> public_key = private_key.public_key()
这一点很重要,因为证书只涉及公共密钥。因为私钥从不共享,所以对它做出任何断言都是不值得的,而且是非常危险的。
下一步是创建一个证书构建器。证书生成器将用于添加关于公钥的“断言”。在这种情况下,我们将通过自签名证书来完成,因为 CA 证书是自签名的。
>>> from cryptography import x509
>>> builder = x509.CertificateBuilder()
然后我们添加名字。有些名称是必需的,尽管其中包含特定内容并不重要。
>>> from cryptography.x509.oid import NameOID
>>> builder = builder.subject_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'Simple Test CA'),
... ]))
>>> builder = builder.issuer_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'Simple Test CA'),
... ]))
我们需要确定一个有效范围。为此,能够有一个“天”间隔以便于计算是有用的。
>>> import datetime
>>> one_day = datetime.timedelta(days=1)
我们想让有效范围“稍微早一点”开始这样,它将对具有一定偏斜量的时钟有效。
>>> today = datetime.date.today()
>>> yesterday = today - one_day
>>> builder = builder.not_valid_before(yesterday)
由于该证书将用于测试,我们不需要它长期有效。我们将使它在 30 天内有效。
>>> next_month = today + (30 ∗ day)
>>> builder = builder.not_valid_after(next_month)
序列号需要唯一地标识证书。因为保存足够的信息来记住我们使用的序列号是复杂的,所以我们选择了不同的途径:选择一个随机的序列号。同一个序列号被选择两次的概率极低。
>>> builder = builder.serial_number(x509.random_serial_number())
然后,我们添加我们生成的公钥。这个证书由关于这个公钥的断言组成。
>>> builder = builder.public_key(public_key)
由于这是一个 CA 证书,我们需要将其标记为 CA 证书。
>>> builder = builder.add_extension(
... x509.BasicConstraints(ca=True, path_length=None),
... critical=True)
最后,在我们将所有断言添加到构建器中之后,我们需要生成散列并对其进行签名。
>>> from cryptography.hazmat.primitives import hashes
>>> certificate = builder.sign(
... private_key=private_key, algorithm=hashes.SHA256(),
... backend=default_backend()
... )
就是这个!我们现在有了一个私钥和一个自称是 CA 的自签名证书。但是,我们需要将它们存储在文件中。
PEM 文件格式有利于简单的连接。事实上,通常证书就是这样存储的:与私钥在同一个文件中(因为没有私钥它们就没有用)。
>>> from cryptography.hazmat.primitives import serialization
>>> private_bytes = private_key.private_bytes(
... encoding=serialization.Encoding.PEM,
... format=serialization.PrivateFormat.TraditionalOpenSSL,
... encryption_algorithm=serialization.NoEncrption())
>>> public_bytes = certificate.public_bytes(
... encoding=serialization.Encoding.PEM)
>>> with open("ca.pem", "wb") as fout:
... fout.write(private_bytes + public_bytes)
>>> with open("ca.crt", "wb") as fout:
... fout.write(public_bytes)
这使我们现在能够成为 CA。
一般来说,对于真正的证书颁发机构,我们需要生成一个证书签名请求(CSR ),以证明私钥的所有者确实想要那个证书。然而,由于我们是证书颁发机构,我们可以直接创建证书。
为证书颁发机构创建私钥和为服务创建私钥没有区别。
>>> service_private_key = rsa.generate_private_key(
... public_exponent=65537,
... key_size=2048,
... backend=default_backend()
... )
因为我们需要对公钥进行签名,所以我们需要再次从私钥计算它:
>>> service_public_key = service_private_key.public_key()
我们为服务证书创建一个新的生成器:
>>> builder = x509.CertificateBuilder()
对于服务来说,COMMON_NAME
很重要;这是客户端验证域名的依据。
>>> builder = builder.subject_name(x509.Name([
... x509.NameAttribute(NameOID.COMMON_NAME, 'service.test.local')
... ]))
我们假设服务将作为service.test.local
被访问,使用一些本地测试解析。我们再一次将我们的证书有效期限制在一个月左右。
>>> builder = builder.not_valid_before(yesterday)
>>> builder = builder.not_valid_after(next_month)
这一次,我们对服务的公钥进行签名:
>>> builder = builder.public_key(public_key)
然而,我们用 CA 的私钥签署;我们不希望这个证书是自签名的。
>>> certificate = builder.sign(
... private_key=private_key, algorithm=hashes.SHA256(),
... backend=default_backend()
... )
同样,我们用密钥和证书编写一个 PEM 文件:
>>> private_bytes = service_private_key.private_bytes(
... encoding=serialization.Encoding.PEM,
... format=serialization.PrivateFormat.TraditionalOpenSSL,
... encryption_algorithm=serialization.NoEncrption())
>>> public_bytes = certificate.public_bytes(
... encoding=serialization.Encoding.PEM)
>>> with open("service.pem", "wb") as fout:
... fout.write(private_bytes + public_bytes)
service.pem
文件的格式可以被大多数流行的 web 服务器使用:Apache、Nginx、HAProxy 等等。通过使用txsni
扩展,Twisted web 服务器也可以直接使用它。
如果我们将ca.crt
文件添加到信任根,并在我们的客户端将从service.test.local
解析的 IP 上运行一个 Nginx 服务器,那么当我们将客户端连接到 https://service.test.local
时,它们将验证证书确实有效。
8.5 总结
密码术是一种强大的工具,但是容易被误用。通过使用众所周知的高级函数,我们降低了使用加密技术的许多风险。虽然这并不能替代适当的风险分析和建模,但确实使这项工作变得更加容易。
Python 有几个第三方库,里面有经过严格审查的代码,使用它们是个好主意。
九、Paramiko
Paramiko 是一个实现 SSH 协议的库,通常用于远程管理 UNIX 系统。SSH 最初是作为“telnet”命令的安全替代物而发明的,但很快成为事实上的远程管理工具。即使是使用定制代理来管理服务器群的系统,比如Salt
,通常也会使用 SSH 引导来安装定制代理。当一个系统被描述为的“无代理”,例如 Ansible,通常意味着它使用 SSH 作为底层管理协议。
Paramiko 包装了协议,允许高级和低级抽象。在这一章中,我们将主要关注高层次的抽象。
在深入研究细节之前,值得注意的是 Paramiko 与 Jupyter 的协同作用。使用 Jupyter 笔记本,并在其中运行 Paramiko,可以实现强大的自动记录远程控制控制台。将多个浏览器连接到同一台笔记本电脑的能力意味着它具有为远程服务器共享故障排除会话的固有能力,而无需繁琐的屏幕共享。
9.1 SSH 安全性
“SSH”中的“S”代表“安全”使用 SSH 的原因是因为我们相信它允许我们安全地控制和配置远程主机。然而,安全是一个微妙的话题。即使底层的加密原语和协议使用它们的方式是安全的,我们也必须正确使用它们,以防止误用造成为成功攻击打开大门的问题。
为了安全地使用 SSH,理解 SSH 如何考虑安全性是很重要的。不幸的是,它是在“安全性的可承受性”没有被认为是高度优先的时候建造的。SSH 很容易使用,这就否定了它带来的所有安全性好处。
SSH 协议建立了相互信任——客户端确信服务器是可信的,服务器也确信客户端是可信的。有几种方法可以建立这种信任,但是在这个解释中,我们将介绍公钥方法。这是最常见的一种。
服务器的公钥由一个指纹识别。该指纹以两种方式之一确认服务器的身份。一种方式是通过先前建立的安全通道进行通信,并保存在文件中。
例如,当 AWS EC2 服务器启动时,它将指纹打印到它的虚拟控制台。控制台的内容可以使用 AWS API 调用(使用 web 的 TLS 模型保护)来检索,并解析以检索指纹。
可悲的是,另一种更受欢迎的方式是豆腐模式——“第一次使用时的信任”。这意味着在初始连接中,指纹被认为是可信的,并存储在本地的安全位置。在任何后续尝试中,将对照存储的指纹检查指纹,不同的指纹将被标记为错误。
指纹是服务器公钥的散列。如果指纹相同,则意味着公钥相同。服务器可以证明它知道对应于给定公钥的私钥。换句话说,服务器可以说“这是我的指纹”,并证明它确实是具有该指纹的服务器。因此,如果指纹得到确认,我们就与服务器建立了加密信任。
另一方面,用户可以向服务器表明他们信任哪些公钥。同样,这通常是通过一些带外机制实现的:供系统管理员放入公钥的 web API、共享文件系统或从网络读取信息的引导脚本。不管是如何做到的,用户的目录可以包含一个文件,意思是“请授权连接,该连接可以证明它们具有与来自我的这个公钥相对应的私钥。”
当 SSH 连接建立后,客户机将如上所述验证服务器的身份,然后提供证据证明它拥有一个与服务器上的某个公钥相对应的私钥。如果这两个步骤都成功,则可以双向验证连接,并且可以用于运行命令和修改文件。
9.2 客户端密钥
客户端私钥和公钥保存在相邻的文件中。通常用户已经有一个现有的密钥,但如果没有,这很容易补救。
从paramiko
开始,生成密钥本身很容易。我们选择一个ECDSA
键。EC
代表“椭圆曲线”对于相同的密钥长度,椭圆曲线非对称加密比基于素数的加密具有更好的抗攻击性。在 EC 加密的“部分解决方案”方面也有很少的进展,因此加密社区的共识是它们可能对“非公开”参与者更安全。
>>> from paramiko import ecdsakey
>>> k = ecdsakey.ECDSAKey.generate()
与非对称加密技术一样,从私钥部分计算公钥部分既快速又简单。
>>> public_key = k.get_base64()
因为这是公开的,所以我们不必太担心把它写到文件中。
>>> with open("key.pub", "w") as fp:
... fp.write(public_key)
然而,当我们写出密钥的私有部分时,我们希望确保文件权限是安全的。我们这样做的方法是,在打开文件之后,但在写入任何敏感数据之前,我们改变模式。
请注意,这并不完全安全;如果写入错误的目录,文件可能会有错误的用户,并且由于一些文件系统会单独同步数据和元数据,因此在错误的时间崩溃可能会导致数据在文件中,但附加了错误的文件模式。这仅仅是我们安全地写一个文件需要做的最少的事情。
>>> import os
>>> with open("key.priv", "w") as fp:
... os.chmod(0o600, "key.priv")
... k.write_private_key(fp)
我们选择模式0o600
,是八进制600
。如果我们写与这个八进制代码相对应的位,它们是110000000,
,翻译成rw-------:
所有者的读写权限,非所有者组成员没有权限,其他任何人都没有权限。
现在通过一些带外机制,我们需要将公钥推送到相关的服务器。
例如,根据云服务的不同,代码如下:
set_user_data(machine_id,
f"""
ssh_authorized_keys:
- ssh-ecdsa {public_key}
""")
其中set_user_data
是使用云 API 实现的,将在任何使用 cloudinit 的服务器上工作。
有时做的另一件事是使用 Docker 容器作为堡垒。这意味着我们希望用户通过 SSH 进入容器,并从容器进入他们需要运行命令的特定机器。
在这种情况下,构建时的一条简单的COPY
指令(或者运行时的一条docker cp
,视情况而定)就可以实现目标。请注意,向 Docker 注册中心发布包含公钥的图像是完全可以的——事实上,这是一个安全操作的要求是公钥定义的一部分。
9.3 主机身份
如前所述,SSH 中针对中间人攻击的最常见的第一道防线是所谓的豆腐原则——“第一次使用时的信任”为此,在连接到主机后,其指纹必须保存在缓存中。
该缓存的位置过去很简单——用户主目录中的一个文件。然而,更现代的不可变的、一次性的环境、多用户机器和其他复杂因素使这变得更加复杂。
很难提出比“与尽可能多的可信来源分享”更普遍的建议然而,为了实现该准则,Paramiko 确实提供了一些设施:
-
客户端可以设置一个
MissingHostKeyPolicy
,它是支持接口的任何实例。这意味着我们可以用逻辑来记录密钥,或者从外部数据库中查询它。 -
UNIX 系统上最常见格式的抽象,
known_hosts
文件。这允许 Paramiko 与常规 SSH 客户端共享使用密钥的经验——通过读取密钥和记录新条目。
9.4 连接
虽然有较低级别的连接方式,但推荐的高级接口是SSHClient
。
因为需要关闭SSHClient
实例,所以使用contextlib.closing
作为上下文管理器是一个好主意(如果可能的话):
with contextlib.context(SSHClient()) as client:
client.connect(some_host)
## Do things with client
在接近顶层的地方这样做允许我们使用client
作为函数的参数,同时保证它将在最后关闭。
有时,在连接之前,我们希望在客户机上配置各种策略。在返回准备好连接的客户端的函数中这样做有时很有用。
以下是与验证真实性的方式相关的一些有用的连接准备方法:
-
load_system_host_keys
将从由其他系统管理的源加载密钥。这意味着它们将用于验证主机,但如果我们选择保存密钥,它们将不会被保存。 -
load_host_keys
将从我们管理的来源加载密钥。这意味着,如果我们选择保存密钥,这些密钥也会被一起保存。例如,我们可能有一个包含连续文件的目录,keys.1
,keys.2
,。。。并从最新版本加载。我们可以在保存时保存到较新的文件,从而有一个安全的方法来从问题中恢复(只需加载以前的版本)。 -
set_missing_host_policy(policy)
需要一个方法为missing_host_key
的对象。这个方法会用client, hostname, key
调用,它的行为会决定做什么;异常将停止连接,而成功的返回将允许连接继续进行。例如,这可以将主机的密钥放在一个“临时”文件中,并引发异常。用户可以查看临时文件,遵循验证过程,并将密钥添加到下一次迭代加载的文件中。
connect
方法有相当多的参数。除了hostname
都是可选的。比较重要的有以下几条:
-
hostname
–要连接的服务器。 -
如果我们在 22 以外的特殊端口上运行,则需要。这有时是作为安全协议的一部分来完成的;尝试连接到端口 22 会自动拒绝来自该 IP 的所有进一步连接,而真正的服务器运行在
5022
或只能通过 API 发现的端口上。 -
username
–虽然默认是本地用户,但这种情况越来越少。云虚拟机映像通常有一个默认的“系统”用户。 -
pkey
–用于认证的私钥。如果我们想通过某种编程方式获得私钥(例如,从秘密管理器中检索),这是很有用的。 -
allow_agent
–默认情况下这是True
,原因很简单。这通常是一个好的选择,因为这意味着私钥本身将永远不会被我们的进程加载,并且通过扩展,不会被我们的进程泄露:没有意外的日志记录、调试控制台或内存转储是易受攻击的。 -
look_for_keys
–设置为False
,不给出其他按键选项,强制使用代理。
9.5 运行命令
SSH 中的“SH”代表 shell。最初的 SSH 是作为 telnet 的替代品发明的,它的主要工作仍然是在远程机器上运行命令。请注意,“远程”是一个比喻,并不总是字面意思。SSH 有时用于控制虚拟机,有时甚至是容器,它们可能就在附近运行。
Paramiko 客户端连接后,它可以在远程主机上运行命令。这是使用客户端方法exec_command
完成的。注意,这个方法把要执行的命令作为字符串,而不是列表。这意味着在命令中插入用户值时必须格外小心,以确保用户没有完全的执行权限。
返回值是命令的标准输入、输出和错误。这意味着小心地与命令通信以避免死锁的责任牢牢地掌握在最终用户手中。
客户端还有一个方法invoke_shell
,它将创建一个远程 shell 并允许对它进行编程访问。它返回一个Channel
对象,直接连接到 shell。在通道上使用send
方法会将数据发送到 shell——就像一个人在终端上打字一样。类似地,recv
方法允许检索输出。
请注意,这可能很难做到,尤其是在时机方面。一般来说,使用exec_command
要安全得多。很少需要打开显式 shell,除非我们需要在终端中正确运行命令。例如,远程运行visudo
将需要一个真正的类似 shell 的访问。
9.6 模拟外壳
我们已经提到客户端有一个invoke_shell
,它将创建一个远程 shell 并允许对它进行编程访问。
虽然我们可以在返回的Channel
上使用send
和recv
方法,但有时将它作为文件使用更容易。
>>> channel = client.invoke_shell()
>>> input = channel.makefile("wb")
>>> output = channel.makefile("rb")
现在可以用input.write
写命令的输入,用output.write
读。请注意,这仍然很微妙:计时和缓冲效应仍然会导致不确定性问题。
注意,不需要重新连接,总是可以channel.close
并创建一个新的 shell。因此,确保 shell 的使用是幂等的是一个好主意。在这种情况下,简单的超时可以帮助从流被“阻塞”、关闭和重试的情况中恢复。
9.7 远程文件
为了启动文件管理,我们调用客户端的open_sftp
方法。这将返回一个SFTPClient
对象。我们将在这个对象上使用方法来进行所有的远程文件操作。
在内部,这将在同一个 TCP 连接上启动一个新的 SSH 通道。这意味着,即使来回传输文件,该连接仍可用于向远程主机发送命令。SSH 没有“当前目录”的概念虽然SFTPClient
模拟了它,但是最好避免依赖它,而是对所有文件操作使用完全限定的路径。这将使代码更容易重构,并且不会对操作顺序有微妙的依赖。
元数据管理
有时我们不想改变数据,而只是改变文件系统的属性。SFTPClient
对象允许我们进行我们期望的正常操作。
chmod
方法对应于os.chmod
——它采用相同的参数。由于chmod
的第二个参数是一个被解释为许可位域的整数,所以最好用八进制表示法来表达。因此,将文件设置为“常规”权限(所有者读/写,全局读取)的最佳方式是:
client.chmod("/etc/some_config", 0o644)
注意,从 C 中借用的0644
符号在 Python 3 中不工作(在 Python 2 中已被否决)。0o644
符号更加明确和 Pythonic 化。
可悲的是,没有什么能保护我们不去理会这样的废话:
client.chmod("/etc/some_config", 644)
(这将对应于目录列表中的-w----r--
,这并不安全——但是非常混乱!)
更多的元数据操作方法有:
-
chown
-
listdir_iter
–用于检索文件名和元数据 -
stat, lstat
–用于检索文件元数据 -
posix_rename
–用于自动更改文件的名称(不要使用rename
–它有令人困惑的不同语义,在这一点上是为了向后兼容) -
mkdir
、rmdir
–创建和删除目录 -
utime
–设置文件的访问和修改次数
上传
用 Paramiko 上传文件到远程主机主要有两种方式。一种是简单用put
。这绝对是最简单的方法——给它一个本地路径和一个远程路径,它就会复制文件。该函数还接受其他参数——主要是一个回调函数,用于调用中间进度。然而,在实践中,如果需要这种复杂性,最好以不同的方式上传。
SFTPClient
上的open
方法返回一个打开的类似文件的对象。编写一个远程逐块或逐行复制的循环相当简单。在这种情况下,进度逻辑可以嵌入到循环本身中,而不是必须提供回调函数,并仔细维护调用之间的状态。
9.7.3 下载
与上传非常相似,有两种方法可以从远程主机检索文件。一种是通过get
方法,它获取远程和本地文件的名称,并管理复制。
另一种方法是再次使用open
方法,这次是以读模式而不是写模式,逐块或逐行复制。同样,如果需要进度指示器,或者需要来自用户的反馈,这是更好的方法。
9.8 摘要
大多数基于 UNIX 的服务器可以使用 SSH 协议进行远程管理。Paramiko 是 Python 中自动化管理任务的一种强大方法,同时对任何服务器做了最少的假设:它运行一个 SSH 服务器,并且我们有登录的权限。
十、Salt
Salt 属于一类称为“配置管理系统”的系统,旨在使管理大量机器更加容易。它通过对不同的机器应用相同的规则来做到这一点,确保它们配置中的任何差异都是有意的。
它是用 Python 编写的,更重要的是,可以用 Python 扩展。例如,在使用YAML
文件的地方,salt
将允许定义字典的 Python 文件。
10.1 Salt 的基本知识
salt
(有时也称为“SaltStack”)系统是一个系统配置管理框架。它旨在将操作系统带入特定的配置。它基于“收敛环”的概念。当运行salt
时,它做三件事:
-
计算所需的配置,
-
计算系统与所需配置的差异,
-
发出命令,使系统达到所需的配置。
Salt 的一些扩展超越了“操作系统”的概念,将一些 SaaS 产品配置成所需的配置:例如,支持 Amazon Web Services、PagerDuty 或一些 DNS 服务(那些由 libcloud 支持的服务)。
由于在典型环境中,并非所有操作系统都需要以完全相同的方式进行配置,因此 Salt 允许检测系统的属性,并指定哪些配置适用于哪些系统。在运行时,Salt 使用这些来决定什么是完整的期望状态,并执行它。
有几种方法可以使用salt
:
-
本地:运行一个本地命令,该命令将执行所需的步骤。
-
SSH:服务器将
ssh
进入客户端并运行命令,这些命令将采取所需的步骤。 -
本地协议:客户端将连接到服务器,并采取服务器指示的任何步骤。
使用ssh
模式消除了在远程主机上安装专用客户机的需要,因为在大多数配置中,已经安装了 SSH 服务器。然而,Salt 管理远程主机的本地协议有几个优点。
首先,它允许客户端连接到服务器,从而简化了发现过程——我们需要的只是客户端到服务器的发现过程。它的伸缩性也更好。最后,它允许我们控制在远程客户机中安装哪些 Python 模块,这对于 Salt 扩展有时是必不可少的。
在某些 Salt 配置需要一个需要定制模块的扩展的情况下,我们可以采用一种混合方法:使用基于 SSH 的配置让主机知道服务器在哪里,以及如何连接到服务器;然后指定如何使主机达到所需配置。
这意味着服务器将有两个部分:一部分使用 SSH 将系统带入基本配置,其中包括一个salt
客户端;第二部分等待客户端连接,以便将其发送到配置的其余部分。
这具有解决“秘密自举”问题的优点。我们使用不同的机制验证客户端主机的 SSH 密钥,当通过 Salt 连接到它时,注入 Salt 秘密以允许主机连接到它。
当我们选择混合方法时,需要找到所有机器的方法。当使用一些云基础设施时,可以使用 API 查询来实现。无论我们如何获得这些信息,我们都需要让 Salt 能够访问这些信息。
这是使用花名册完成的。花名册是 YAML 的档案。顶层是“逻辑机器名”这很重要,因为这将是使用 Salt 对机器进行寻址的方式。
file_server: # logical name of machine
user: moshe # username
sudo: True # boolean
priv: /usr/local/key # path to private key
print_server: # logical name of machine
user: moshe # username
sudo: True # boolean
priv: /usr/local/key2 # path to private key
在理想情况下,机器的所有参数都是相同的。user
是 SSH 的用户身份。布尔值是是否需要 sudo:这几乎总是正确的。唯一的例外是我们作为管理用户(通常是 root)使用 SSH。因为避免 SSH 作为 root 用户是一个最佳实践,所以在大多数环境中它被设置为True
。
priv
字段是私钥的路径。或者也可以agent-forwarding
使用 SSH 代理。这通常是个好主意,因为它为密钥泄漏提供了额外的屏障。
花名册可以放在任何地方,但默认情况下 Salt 会在/etc/salt/roster
中寻找它。将这个文件放在不同的位置是很微妙的:salt-ssh
将默认从/etc/salt/master
中找到它的配置。因为将花名册放在别处的通常原因是为了避免接触到/etc/salt
目录,这意味着我们通常需要使用-c
选项配置一个显式的主配置文件。
或者,可以使用Saltfile
。salt-ssh
将在当前目录中的Saltfile
中寻找选项。
salt-ssh:
config_dir: some/directory
如果我们在config_dir
中输入值.
,它将在当前目录中查找一个master
文件。我们可以将master
文件中的roster_file
字段设置为本地路径(例如roster
),以确保整个配置是本地的,并且可以在本地访问。如果事情是由版本控制系统管理的,这可能会有所帮助。
在定义了花名册之后,开始检查 Salt 系统是否在运行是很有用的。
命令
$ salt '∗' test.ping
将向名册上的所有机器(或者,稍后当我们使用 minions 时,所有连接的 minions)发送 ping 命令。它们都应该返回 True。如果机器不可访问、SSH 凭证错误或存在其他常见配置问题,此命令将失败。
因为这个命令对远程机器没有任何影响,所以在开始执行任何更改之前,最好先运行它。这将确保系统配置正确。
还有其他几个test
功能,用于对系统进行更复杂的检查。
test.false
命令将故意失败。这有助于了解失败的样子。例如,当通过更高层次的抽象(如连续部署系统)运行 Salt 时,这对于看到故障是可见的(例如,发送适当的通知)是有用的。
test.collatz
和test.fib
函数执行繁重的计算,并返回计算时间和结果。这用于测试性能。例如,如果机器根据可用功率或外部温度动态调整 CPU 速度,这可能是有用的,并且我们想要测试这是否是性能问题的原因。
在salt
命令行上,很多东西被解析成 Python 对象。shell 解析规则和salt-
解析规则的交互有时很难预测。在检查事物是如何被解析的时候,test.kwarg
命令会很有用。它返回字典作为关键字参数传入的值。举个例子,
$ salt '∗' test.kwarg word="hello" number=5
simple_dict='{thing: 1, other_thing: 2}'
我将归还字典
{'word': 'hello', 'number': 5,
'simple_dict': {'thing': 1, 'other_thing': 2}}
因为 shell 解析规则和 Salt 解析规则的组合有时很难预测,所以这是一个有用的命令,可以调试这些组合,并找出哪些内容被过度引用或引用不足。
我们可以通过逻辑名来定位特定的机器,而不是'∗'
。当看到特定机器的问题时,这通常是有用的;在尝试各种修复(例如,更改防火墙设置或 SSH 私有密钥)时,它允许快速反馈机制。
虽然测试连接是否工作良好很重要,但是使用 Salt 的原因是为了远程控制机器。虽然 Salt 的主要用途是同步到一个已知的状态,但是它也可以用于运行特定的命令。
$ salt '∗' cmd.run 'mkdir /src'
这将导致所有连接的机器创建一个目录/src
。更复杂的命令是可能的,并且也可能只针对特定的机器。
Salt 中理想状态的专业术语是“高状态”这个名字经常引起混淆,因为它似乎是“低状态”的反义词,而“低状态”几乎没有被描述过。然而,“highstate”这个名字代表“high- level state”:它描述了状态的目标。
“低”状态,即低水平状态,是 Salt 达到目标的步骤。由于将目标编译成低级状态是在内部完成的,所以在面向用户的文档中没有提到“低级”状态,从而导致混乱。
应用所需状态的方法如下:
$ salt '∗' state.highstate
由于“highstate”这个名称有太多的混淆,为了减少混淆,创建了一个别名:
$ salt '∗' state.apply
同样,这两者做完全相同的事情:它们计算出所有机器的期望状态,然后发出命令来达到它。
状态在sls
文件中描述。这些文件通常是 YAML 格式的,描述了期望的状态。
通常的配置方式是用一个文件top.sls
来描述哪些文件适用于哪些机器。top.sls
名称是默认情况下salt
将用作顶层文件的名称。
一个简单的同构环境可能是:
# top.sls
base:
'∗':
- core
- monitoring
- kubelet
这个例子将让所有的机器应用来自core.sls
的配置(假设,确保安装了基本的包,配置了正确的用户,等等)。);从monitoring.sls
(假设,确保监控机器的工具已安装并运行);和kubelet.sls
,定义如何安装和配置kubelet
。
事实上,Salt 的大部分时间将用于为工作负载编排工具(如 Kubernetes 或 Docker Swarm)配置机器。
10.2 Salt 的概念
Salt 引入了相当多的术语和概念。
一个宠臣就是 Salt 代理。即使在“无代理”的基于 SSH 的通信中,仍然有一个 minionSalt 做的第一件事是发送一个 minion 的代码,然后启动它。
一个 Salt 主人给奴才们发命令。
Salt 状态是一个扩展名为.sls
的文件,包含状态声明:
name_of_state:
state.identifier:
- parameters
- to
- state
例如:
process_tools:
pkg.installed:
- pkgs:
- procps
这将确保软件包procps
(其中包括ps
命令)将被安装。
大多数 Salt 状态被写成幂等:如果它们已经生效,就不再生效。例如,如果已经安装了软件包,Salt 将什么也不做。
Salt 模块不同于 Python 模块。在内部,它们确实对应于模块,但只是一些模块。
不像状态,模块运行事情。这意味着没有幂等性的保证,甚至没有尝试。
通常,Salt 状态会用一些逻辑来包装一个模块,以决定它是否需要运行该模块:例如,在安装一个包之前,pkg.installed
会检查该包是否已经安装。
一个支柱是一种将参数附加到特定的爪牙上的方式,它可以被不同的状态重用。
如果一个支柱过滤掉一些爪牙,那么这些爪牙保证永远不会接触到支柱中的价值观。这意味着柱子是储存秘密的理想选择,因为它们不会被送到错误的爪牙那里。
为了更好地保护秘密,可以使用gpg
来加密柱子中的秘密。因为gpg
是基于非对称加密的,所以可以公布公钥,例如,在保存状态和支柱的同一个源代码控制存储库中。
这意味着任何人都可以向配置中添加秘密,但是在主服务器上需要私钥来应用这些配置。
由于 GPG 是灵活的,它是可能的目标加密到几个密钥。作为一个最佳实践,最好将密钥加载到一个gpg-agent
中。这意味着当主人需要秘密时,它将使用gpg
,T1 将与gpg-agent
通信。
这意味着私钥永远不会直接暴露给 Salt 主机。
一般来说,Salt 按顺序处理状态中的指令。但是,一个状态总是可以指定require
。指定依赖关系时,最好让依赖状态有一个自定义的、可读的名称。这使得依赖关系更具可读性。
Extract archive:
archive.extracted:
- name: /src/some-files
- source: /src/some-files.tgz
- archive_format: tar
- require:
- file: Copy archive
Copy archive:
file.managed:
- name: /src/some-files.tgz
- source: salt://some-files.tgz
拥有显式可读的名称有助于我们确保依赖于正确的状态。注意,即使Extract
在Copy
之前,它仍然会等待复制完成。
也可以颠倒关系:
Extract archive:
archive.extracted:
- name: /src/some-files
- source: /src/some-files.tgz
- archive_format: tar
Copy archive:
file.managed:
- name: /src/some-files.tgz
- source: salt://some-files.tgz
- require_in:
- archive: Extract archive.
一般来说,就像这个例子一样,颠倒关系并不能改善事情。但是,这有时可以用来最小化或本地化对共享存储库中文件的更改。
还有其他可能的关系,都有被倒置的能力;onchanges
指定只有当另一个状态引起实际变化时,才应该重新应用该状态,而onfail
指定只有当另一个状态应用失败时,才应该重新应用该状态。这对于设置警报或确保系统返回到已知状态非常有用。
可能还有一些更深奥的关系,比如watch
和prereq
,它们更加专门化。
当使用内置的 Salt 通信而不是 SSH 方法时,minions 将生成密钥。这些密钥需要被接受或拒绝。一种方法是使用salt-key
命令。
正如我们前面提到的,引导信任的一种方式是使用 SSH。在这种情况下,使用 Salt 将解析后的输出从运行salt-key -F master
转移到 minion,然后在 minion 的配置中的master_finger
字段下设置它。
类似地,在 minion 上远程运行salt-call key.finger --local
(例如,用salt 'minion' cmd.run
)并在接受之前将其与待定密钥进行比较。这可以是自动化的,并导致一个验证链。
根据可用的原语,还有其他方法来引导信任。例如,如果硬件密钥管理(HKM)设备可用,它们可以用来签署奴才的和主的密钥。
可信平台模块(TPM)也可以用于相互确保信任。这两种机制都超出了我们目前的范围。
颗粒(如“一粒 Salt”)将系统参数化。它们与柱子的不同之处在于仆从决定颗粒;该配置在 minions 上存储和修改。
有些颗粒,比如fqdn
,是在小黄人身上自动检测出来的。也可以在 minion 配置文件中定义其他粒度。
有可能从母版中推出颗粒。在引导小兵的时候也可以从其他地方获取谷物。例如,在 AWS 上,可以将UserData
设置为一个颗粒。
Salt 环境是目录层次结构,每个层次结构定义一个单独的 topfile。可以将 Minions 分配给一个环境,或者在使用salt '∗' state.highstate saltenv=...
应用 highstate 时选择一个环境。
Salt file_roots
是一个目录列表,其功能类似于一个路径;当寻找一个文件时,Salt 将按顺序在它们中搜索,直到它找到它。它们可以基于每个环境进行配置,是区分环境的主要因素。
10.3 Salt 格式
到目前为止,我们的示例SLS
文件是 YAML 文件。然而,Salt 将 YAML 文件解释为 YAML 文件的 Jinja 模板。当我们想要基于颗粒或柱子定制字段时,这是很有用的。
例如,包含我们构建 Python 包所需的东西的包的名称在CentOS
和Debian
之间是不同的。
下面的SLS
片段展示了如何在异构环境中将不同的包定位到不同的机器上。
{% if grains['os'] == 'CentOs' %}
python-devel:
{% elif grains['os'] == 'Debian' %}
python-dev:
{% endif %}
pkg:
- installed
需要注意的是,Jinja 处理步骤完全忽略了 YAML 格式。它将文件视为纯文本,进行格式化,然后 Salt 对结果使用 YAML 解析器。
这意味着 Jinja 只有在某些情况下才可能生成无效文件。事实上,我们在上面的例子中嵌入了这样一个 bug 如果操作系统既不是CentOS
也不是Debian
,结果将是一个不正确缩进的 YAML 文件,它将以奇怪的方式解析失败。
为了解决这个问题,我们想提出一个明确的异常:
{% if grains['os'] == 'CentOs' %}
python-devel:
{% elif grains['os'] == 'Debian' %}
python-dev:
{% else %}
{{ raise('Unrecognized operating system', grains['os']) }}
{% endif %}
pkg:
- installed
这在适当的时候引发了一个异常,万一一台机器被添加到我们的花名册中,而不是 Salt 抱怨 YAML 的一个解析错误。
每当用 Jinja 做一些重要的事情时,这种小心是很重要的,因为 Jinja 插值和 YAML 解析这两个层并不知道彼此。Jinja 不知道它应该生成 YAML,YAML 解析器也不知道 Jinja 源代码是什么样子。
Jinja 支持过滤以便处理值。Jinja 内置了一些过滤器,但是 Salt 用一个自定义列表扩展了它。
有趣的滤镜中有YAML_ENCODE
。有时我们需要在我们的.sls
文件中有一个值,它就是 YAML 本身:例如,我们需要复制的 YAML 配置文件的内容。
将 YAML 植入 YAML 通常是不愉快的;必须特别注意正确的逃生。使用YAML_ENCODE
,可以对以 YAML 本地语言书写的值进行编码。
出于类似的原因,JSON_ENCODE_DICT
和JSON_ENCODE_LIST
对于将 JSON 作为输入的系统很有用。
定制过滤器的列表很长,这是一个版本之间经常变化的事情。规范文档将位于 Salt 文档网站docs.saltstack.com
上的“Jinja - >过滤器”下
尽管到目前为止我们称SLS
文件为由 Jinja 和 YAML 处理的文件,但这是不准确的。这是默认的处理,但也可以用特殊指令覆盖该处理。
Salt 本身只关心最终结果是一个类似 YAML(或者,在我们的例子中,等同于类似 JSON)的数据结构:一个包含递归字典、列表、字符串和数字的字典。
将文本转换成这种数据结构的过程在 Salt 的说法中称为“呈现”。这与常见的用法相反,在常见用法中,呈现意味着将文本转换为文本,解析意味着将文本转换为,因此在阅读 Salt 文档时需要注意这一点。
能做渲染的东西就是渲染器。可以编写自定义渲染器,但是在内置渲染器中,最有趣的是py
渲染器。
我们指出应该用顶部带有#!py
的py
渲染器来解析文件。
在这种情况下,该文件被解释为 Python 文件。Salt 查找函数run
,运行它,并将返回值视为状态。
运行时,__grains__
和__pillar__
包含纹理和支柱数据。
作为一个例子,我们可以用一个py
渲染器实现相同的逻辑。
#!py
def run():
if __grains__['os'] == 'CentOS':
package_name = 'python-devel'
elif __grains__['os'] == 'Debian':
package_name = 'python-dev'
else:
raise ValueError("Unrecognized operating system",
__grains__['os'])
return { package_name: dict(pkg='installed') }
由于py
渲染器不是两个不相关的解析器的组合,错误有时更容易诊断。如果我们重新引入第一个版本中的错误,我们会得到:
#!py
def run():
if __grains__['os'] == 'CentOS':
package_name = 'python-devel'
elif __grains__['os'] == 'Debian':
package_name = 'python-dev'
return { package_name: dict(pkg='installed') }
在这种情况下,结果将是一个NameError
指出错误的行和丢失的名称。
权衡的结果是,如果配置很大,而且大部分是静态的,那么以 YAML 的形式阅读它会更简单。
10.4 Salt 延伸
由于 Salt 是用 Python 写的,所以在 Python 中是完全可扩展的。一般来说,为新的事物扩展 Salt 的最简单的方法是将文件放在 Salt 主机上的file_roots
目录中。不幸的是,还没有针对 Salt 扩展的包管理器。当运行state.apply
或显式运行saltutil.sync_state
时,这些文件会自动同步到附属程序。如果我们想要测试,例如,在不引起任何变化的情况下进行状态的模拟运行,但是用修改模块的,后者是有用的。
国家
状态模块位于环境的根目录下。如果我们想要在环境之间共享状态模块,可以创建一个定制的根并在正确的环境之间共享这个根。
下面是一个模块示例,该模块确保特定目录下没有名称为“mean”的文件。这可能不是很有用,尽管一般来说,确保不需要的文件不在那里可能很重要。例如,我们可能想强制不使用.git
目录。
def enforce_no_mean_files(name):
mean_files = __salt__'files.find'
# ...continues below...
函数名映射到SLS
状态文件中的状态名。如果我们把这个代码放在mean.py
中,处理这个状态的合适方式应该是mean.enforce_no_mean_files
。
找到文件的正确方法,或者说,在 Salt 状态扩展中做任何事情的正确方法,是调用 Salt executors。在大多数非玩具示例中,这将意味着编写一个匹配对:一个 Salt 执行器扩展和一个 Salt 状态扩展。
因为我们想一次处理一件事,所以我们使用一个预先编写的 Salt 执行器:file
模块,它有find
功能。
def enforce_no_mean_files(name):
# ...continued...
if mean_files = []:
return dict(
name=name,
result=True,
comment='No mean files detected',
changes=[],
)
# ...continues below...
状态模块负责的事情之一,实际上通常是最重要的事情,是如果状态已经达到,则什么也不做。这就是收敛循环的意义所在:针对已经实现收敛的情况进行优化。
def enforce_no_mean_files(name):
# ...continued...
changes = dict(
old=mean_files,
new=[],
)
# ...continues below...
我们现在知道将会发生什么变化。在这里计算它意味着我们可以保证测试和非测试模式下响应的一致性。
def enforce_no_mean_files(name):
# ...continued...
changes = dict(
if __opts__['test']:
return dict(
name=name,
result=None,
comment=f"The state of {name} will be changed",
changes=changes,
)
# ...continues below...
下一个重要职责是支持test
模式。在应用状态之前总是进行测试被认为是一种最佳实践。我们希望清楚地阐明如果激活该模块将会产生的变化。
def enforce_no_mean_files(name):
# ...continued...
changes = dict(
for fname in mean_files:
__salt__'file.remove'
# ...continues below...
一般来说,我们应该只调用一个函数:执行模块中与状态模块匹配的函数。因为在这个例子中我们使用file
作为我们的执行模块,我们在一个循环中调用remove
函数。
def enforce_no_mean_files(name):
# ...continued...
changes = dict(
return dict(
name=name,
changes=changes,
result=True,
comment=f"The state of {name} was changed",
)
# ...continues below...
最后,我们返回一个字典,它具有与测试模式中记录的相同的更改,但是带有一个注释,表明这些更改已经运行。
这是状态模块的典型结构:一个(或多个)函数,接受一个名称(可能还有更多参数),然后返回一个结果。“检查是否需要更改”,“检查我们是否处于测试模式”,然后“实际执行更改”的结构也是典型的。
执行
由于历史原因,执行模块放在文件根目录的_modules
子目录中。类似于执行模块,当应用state.highstate
时,它们也同步,当通过saltutil.sync_all
显式同步时也同步。
作为一个例子,我们编写一个执行模块来删除几个文件,以便简化我们上面的状态模块。
def multiremove(files):
for fname in files:
__salt__'file.remove'
注意__salt__
在执行模块中也是可用的。然而,虽然它可以交叉调用其他执行模块(在本例中,file)
,但它不能交叉调用状态模块。
我们将这段代码放在_modules/multifile
中,我们可以将我们的状态模块改为:
__salt__'multifile.mutiremove'
代替
for fname in mean_files:
__salt__'file.remove'
执行模块通常比状态模块简单,如下例所示。在这个玩具示例中,执行模块几乎不做任何事情,只是协调对其他执行模块的调用。
然而,这并非完全不典型。Salt 有如此多的管理机器的逻辑,以至于一个执行模块所要做的通常只是协调对其他执行模块的调用。
公用事业
当编写几个执行或状态模块时,有时会有一些公共代码可以被分解出来。
这些代码可以放在所谓的“实用模块”中工具模块位于文件根目录的_utils
目录下,将作为__utils__
字典使用。
例如,我们可以在状态模块中排除返回值的计算:
def return_value(name, old_files):
if len(old_files) == 0:
comment = "No changes made"
result = True
elif __opts__['test']:
comment = f"{name} will be changed"
result = None
else:
comment = f"{name} has been changed"
result = True
changes = dict(old=old_files, new=[])
return dict(
name=name,
comment=comment,
result=result,
changes=changes,
)
如果我们使用执行模块和实用模块,我们会得到一个更简单的状态模块:
def enforce_no_mean_files(name):
mean_files = __salt__'files.find'
if len(mean_files) == 0 or __opts__['test']:
return __utils__'removal.return_value'
__salt__'multifile.mutiremove'
return __utils__'removal.return_value'
在这种情况下,我们可以把函数作为一个常规函数放在模块中;将它放在一个工具模块中是用来展示如何在工具模块中调用函数。
10.4.4 额外的第三方依赖性
有时候拥有第三方依赖是很有用的,尤其是在编写新的状态和执行模块的时候。这在安装一个插件时很容易做到;我们只是确保在具有这些第三方依赖项的虚拟环境中安装该插件。
当在 SSH 中使用 Salt 时,这一点就明显不那么微不足道了。在这种情况下,有时最好从 SSH 引导到一个真正的 minion。实现这一点的一种方法是在 SSH“minion”目录中有一个持久状态,并让安装的 minion 在 SSH minion 中设置一个“completely_disable”。这将确保 SSH 配置不会与常规的 minion 配置发生冲突。
10.5 摘要
Salt 是一个基于 Python 的配置管理系统。对于重要的配置,可以使用 Python 来表达期望的系统配置,这有时比模板化 YAML 文件更有效。也可以用 Python 对进行扩展,以定义新的原语。
十一、Ansible
和 Salt 一样,Ansible 是另一个配置管理系统。然而,Ansible 没有定制代理:它总是与 SSH 一起工作。与 Salt 使用 SSH 的方式不同,在 SSH 中,Salt 启动一个特设的迷你程序并向其发送命令,Ansible 在服务器上计算命令,并通过 SSH 连接发送简单的命令和文件。
默认情况下,Ansible 将尝试使用本地 SSH 命令作为控制机器。如果本地命令由于某种原因不合适,Ansible 将回退到使用 Paramiko 库。
11.1 Ansible 基础知识
Ansible 可以在虚拟环境中使用pip install ansible
安装。安装之后,最简单的事情就是 ping 本地主机:
$ ansible localhost -m ping
这很有用,因为如果这样做了,就意味着很多事情都配置正确了:运行 SSH 命令、配置 SSH 密钥和 SSH 主机密钥。
一般来说,使用 Ansible 的最佳方式,就像使用 SSH 通信时一样,是使用本地加密的私有密钥,该密钥被加载到 SSH 代理中。由于默认情况下ansible
将使用本地 SSH 命令,如果ssh localhost
工作正常(不要求输入密码),那么 Ansible 将正常工作。如果 localhost 没有运行 SSH 守护进程,那么用一个单独的 Linux 主机替换下面的例子:可能是一个本地运行的虚拟机。
稍微复杂一些,但仍然不需要复杂的设置,就是运行一个特定的命令:
$ ansible localhost -a "/bin/echo hello world"
我们也可以给出一个明确的地址:
$ ansible 10.40.32.195 -m ping
会尝试 SSH 到10.40.42.195
。
默认情况下,Ansible 将尝试访问的主机集称为“清单”清单可以在 INI 或 YAML 文件中静态指定。然而,更常见的选择是编写一个“清单脚本”,生成机器列表。
清单脚本只是一个 Python 文件,可以使用参数--list
和–host <hostname>
运行。默认情况下,Ansible 将使用用来运行它的 Python 来运行清单脚本。通过添加 shebang 行,可以使清单脚本成为一个“真正的脚本”,可以在任何解释器上运行,就像不同版本的 Python 一样。传统上,文件不以.py
命名。其中,这避免了文件的意外导入。
当使用--list
运行时,应该以 JSON 格式输出库存。当使用--host
运行时,应该为主机打印变量。一般来说,在这种情况下总是打印一个空字典是完全可以接受的。
下面是一个简单的清单脚本:
import sys
if '--host' in sys.argv[1:]:
print(json.dumps({}))
print(json.dumps(dict(all='localhost')))
这个清单脚本不是很动态;它总是打印同样的东西。但是,它是一个有效的清单脚本。
我们可以用它来
$ ansible -i simple.inv all -m ping
这将再次 ping(使用 SSH)本地主机。
Ansible 主要不是用于对主机运行临时命令。它旨在运行“剧本”剧本是描述“任务”的 YAML 文件
---
- hosts: all
tasks:
- name: hello printer
shell: echo "hello world"
该行动手册将在所有连接的主机上运行echo "hello world"
。
为了运行它,使用我们创建的清单脚本,
$ ansible-playbook -i simple.inv echo.yml
一般来说,这将是日常运行 Ansible 时最常用的命令。其他命令主要用于调试和故障排除,但在正常情况下,流程是“大量”重新运行剧本
所谓“很多”,我们的意思是,一般来说,剧本应该被写成安全幂等的;在相同的情况下再次执行相同的行动手册应该不会有任何效果。注意,在 Ansible 中,幂等性是剧本的属性,而不是基本构建模块的属性。
例如,下面的剧本不是等幂的:
---
- hosts: all
tasks:
- name: hello printer
shell: echo "hello world" >> /etc/hello
使其幂等的一种方法是让它注意到文件已经存在:
---
- hosts: all
tasks:
- name: hello printer
shell: echo "hello world" >> /etc/hello
creates: /etc/hello
这将注意到文件存在,如果存在,将跳过该命令。
一般来说,在更复杂的设置中,不是在行动手册中列出任务,而是将这些任务委派给角色。
角色是一种分离关注点并根据主机灵活组合它们的方式。
---
- hosts: all
roles:
- common
然后,在roles/common/tasks/main.yml
下
---
- hosts: all
tasks:
- name: hello printer
shell: echo "hello world" >> /etc/hello
creates: /etc/hello
这将做同样的事情,但现在它是通过更多的文件间接。好处是,如果我们有许多不同的主机,我们需要为其中一些主机组合指令,这是一个方便的平台来定义更复杂的设置的一部分。
11.2 可行的概念
当 Ansible 需要使用秘密时,它有自己的内部“保险库”。保险库有加密的秘密,用密码解密。有时这个密码会在一个文件中(理想情况下在一个加密的卷上)。
可选择的角色和剧本是 jinja2 YAML 文件。这意味着它们可以使用插值,并且支持许多 Jinja2 滤波器。
一些有用的是from/to_json/yaml
,它允许数据被来回解析和序列化。map
过滤器是一个元过滤器,它将过滤器逐项应用到可迭代对象。
在过滤器内部,定义了一组变量。变量可以来自多个来源:金库(用于秘密),直接在剧本或角色中,或者在其中包含的文件中。变量也可以来自库存(如果不同的库存用于相同的剧本,这可能是有用的)。ansible_facts
变量是一个字典,包含当前主机的事实:操作系统、IP 等等。
它们也可以直接在命令行上定义。虽然这是危险的,但是对于快速迭代来说是有用的。
在剧本中,通常我们需要定义哪个用户以哪个用户的身份登录,以及哪个用户(通常是根用户)以哪个用户的身份执行任务。
所有这些都可以在剧本上配置,并在每个任务级别上覆盖。
我们登录的用户是remote_user
。如果become
是False
,我们执行的用户身份是remote_user
,如果become
是True
,我们执行的用户身份是become_user
。如果become
为True
,用户切换将由become_method
完成。
默认值为:
-
remote_user
–与本地用户相同 -
become_user – root
-
become – False
-
become_method – sudo
这些默认值通常是正确的,除了become,
经常需要被覆盖为True
。一般来说,最好配置机器,这样无论我们选择什么样的become_method
,用户切换的过程都不需要密码。
例如,以下内容将适用于常见的云提供商版本的 Ubuntu:
- hosts: databases
remote_user: ubuntu
become: True
tasks:
- name: ensure that postgresql is started
service:
name: postgresql
state: started
如果这不可能,我们需要给出参数--ask-become-pass
让 Ansible 在运行时请求凭证。请注意,虽然这样做可行,但这会妨碍自动化的尝试,最好避免这样做。
Ansible 支持“模式”来指示要更新哪些主机。在ansible-playbook
中,这是通过--limit
完成的。可以对组进行集合运算::
表示“并”,:!
表示“集合差”,:&
表示“交集”在这种情况下,基本器械包就是清单中定义的器械包。例如,databases:!mysql
会将命令限制为仅针对非mysql
的databases
主机。
模式可以是匹配主机名或 IP 的正则表达式。
11.3 可行的扩展
我们已经看到了一种使用定制 Python 代码扩展 ansible 的方法:动态库存。在动态库存示例中,我们编写了一个临时脚本。然而,该脚本是作为一个单独的进程运行的。扩展 Ansible 的一个更好的方法,也是一个超越库存的方法,是使用插件。
一个库存插件是一个 Python 文件。这个文件有几个位置,以便 Ansible 可以找到它:通常最容易的是与剧本和角色在同一个目录中的plugins/inventory_plugins
。
这个文件应该定义一个继承自BaseInventoryPlugin
的名为InventoryModule
的类。该类应该定义两个方法:verify_file
和parse
。verify_file
函数主要是一种优化;这意味着如果文件不适合插件,可以快速跳过解析。这是一种优化,因为如果文件由于任何原因无法解析,parse
可以(并且应该)引发AnsibleParserError
。Ansible 将尝试其他库存插件。
parse
函数签名是
def parse(self, inventory, loader, path, cache=True):
pass
解析 JSON 的一个简单例子:
def parse(self, inventory, loader, path, cache=True):
super(InventoryModule, self).parse(inventory, loader, path, cache)
try:
with open(path) as fpin:
data = json.loads(fpin.read())
except ValueError as exc:
raise AnsibleParseError(exc)
for host in data:
self.inventory.add_host(server['name'])
inventory
对象是如何管理库存;它有add_group
的方法;add_child
;而set_variable,
就是库存如何扩展。
loader
是一个灵活的加载器,可以猜测文件的格式并加载它。path
是包含插件参数的文件的路径。注意,在某些情况下,如果插件足够具体,可能不需要参数和加载器。
另一个要编写的常见插件是“查找”插件。可以从 Ansible 中的 Jinja2 模板调用查找插件,以便进行任意计算。当模板开始变得有点太复杂时,这通常是一个不错的选择。Jinja2 不能很好地适应复杂的算法,也不能很容易地调用第三方库。
查找插件有时用于复杂的计算,有时用于调用库来计算角色中的参数。例如,它可以获取一个环境的名称,并计算(基于本地约定)相关的对象。
class LookupModule(LookupBase):
def run(self, terms, variables=None, ∗∗kwargs):
pass
例如,我们可以编写一个查找插件来计算几个路径中最大的公共路径:
class LookupModule(LookupBase):
def run(self, terms, variables=None, ∗∗kwargs):
return os.path.commonpath(terms)
注意当使用查找模块时,lookup
和query
都可以从 Jinja2 中使用。默认情况下,lookup
会将返回值转换成字符串。如果返回值是列表,可以发送参数wantslist
以避免转换。即使在这种情况下,只返回一个“简单”的对象也是很重要的:只由整数、浮点数和字符串,或者列表和字典组成的对象。自定义类会以各种令人惊讶的方式被强制转换成字符串。
11.4 摘要
Ansible 是一个简单的配置管理,易于设置,只需要 SSH 访问。编写新的库存插件和查找插件允许以很少的开销实现定制处理。
十二、Docker
Docker 是一个应用级虚拟化的系统。虽然不同的 Docker 容器共享一个内核,但它们通常很少共享其他东西:文件、进程等等都可以是独立的。它通常用于测试软件系统和在生产中运行它们。
Docker 自动化主要有两种方式。可以使用subprocess
库和使用docker
命令行。这是一种流行的方式,并且确实有一些优点。
然而,另一种方法是使用dockerpy
库。这允许做一些用docker
命令完全不可能做的事情,以及一些用该命令根本不可能或令人讨厌的事情。
优点之一是安装;在虚拟环境中安装 DockerPy,或者安装 Python 包的其他方式。安装 Docker 二进制客户端通常更复杂。虽然它是在安装 Docker 守护进程时出现的,但只需要一个客户机,而服务器运行在不同的主机上的情况并不少见。
当使用docker
Python 包时,通常的方法是使用
import docker
client = docker.from_env()
默认情况下,这将连接到本地 Docker 守护进程。然而,例如,在已经使用docker-machine env
准备好的环境中,它将连接到相关的远程 Docker 守护进程。
一般来说,from_env
将使用一种与docker
命令行客户端兼容的算法,因此在插入式替换中非常有用。
例如,这在为每个 CI 会话分配 Docker 主机的持续集成环境中非常有用。因为他们将设置本地环境与docker
命令兼容,from_env
将做正确的事情。
也可以使用主机的详细信息直接连接。DockerClient
构造函数将会这样做。
12.1 形象建设
客户端的images
属性的build
方法接受一些参数,这些参数允许从命令行完成一些困难的事情。该方法只接受关键字参数。没有必需的参数,也必须至少传入path
和fileobj
中的一个。
fileobj
参数可以指向一个类似文件的对象,它是一个 tarball(或者一个 gzipped tarball,在这种情况下encoding
参数需要设置为gzip
)。这将最终成为构建上下文,而dockerfile
参数将被用来表示上下文内部的路径。这允许显式地创建构建上下文。
使用常见的docker build
命令,上下文是一个目录的内容,通过一个.dockerignore
文件进一步包含/排除。使用BytesIO
和tarfile
库在内存中生成 tarball 意味着内容是显式的。注意,这意味着 Python 也可以在内存中生成Dockerfile
。
这样就不需要创建外部文件;整个构建系统是用 Python 指定的,并直接传递给 Docker。
例如,这里有一个简单的程序来创建一个 Docker 图像,它除了一个带有简单问候语的文件/hello
之外什么也没有:
fpout = io.BytesIO()
tfout = tarfile.open(fpout, "w|")
info = tarfile.TarInfo(name="Dockerfile")
dockerfile = io.Bytes("FROM scratch\nCOPY hello /hello".encode("ascii"))
tfout.addfile(tarinfo=info, fileobj=dockerfile)
hello = io.Bytes("This is a file saying 'hello'".encode("ascii"))
info = tarfile.TarInfo(name="hello")
tfout.addfile(tarinfo=info, fileobj=hello)
fpout.seek(0)
client.build(fileobj=fpout, tag="hello")
注意,这个图像自然不是很有用。我们不能从它创建一个运行的容器,因为它没有可执行文件。然而,这个简单的例子表明我们可以在没有任何外部文件的情况下创建 Docker 映像。
这可以派上用场,例如,当创建一个轮子的形象;我们可以将轮子下载到内存缓冲区,创建容器,标记它,并推送它,所有这些都不需要任何临时文件。
运行
客户机上的containers
属性允许管理正在运行的容器。
方法将运行一个容器。这些参数与docker run
命令行非常相似,但是在使用它们的最佳方式上有一些不同。
从 Python 来看,使用detach=True
选项几乎总是一个好主意。这将导致run()
返回一个Container
对象。如果出于某种原因,您需要等到它退出,请在容器对象上显式调用.wait
。
这允许超时,这对终止失控的进程很有用。容器对象的返回值还允许检索日志,或者检查容器内部的进程列表。
像docker create
一样,containers.create
方法将创建一个容器,但不运行它。
不管容器是否正在运行,都可以与其文件系统进行交互。方法将从容器中获取一个文件或者递归地获取一个目录。它将返回一个元组。第一个元素是产生原始字节对象的迭代器。这些可以被解析为一个 tar 存档。第二个元素是一个字典,包含关于文件或目录的元数据。
put_archive
命令将文件注入容器。这有时在create
和start
之间对微调容器很有用:例如,注入服务器的配置文件。
甚至可以用它来代替 build 命令;container.put_archive
和container.commit
与containers.run
和containers.create
的组合允许增量构建容器,无需 Dockerfile 文件。这种方法的一个优点是层的划分与步骤的数量是正交的:同一层可以有几个逻辑步骤。
但是,请注意,在这种情况下,决定缓存哪些“层”就成了我们的责任。此外,在这种情况下,“中间层”是完全成熟的图像。这有它的好处:例如,清理变得更加简单。
12.3 图像管理
客户端的images
属性允许操作容器图像。属性上的list
方法返回图像列表。这些是图像对象,不仅仅是名字。图像可以用tag
方法重新标记。例如,这允许将特定图像标记为:latest
。
pull()
和push()
方法对应于 docker 客户端拉和推。remove()
命令允许删除图像。注意,参数是一个名称,而不是一个Image
对象。
例如,下面是一个简单的例子,它将最新的图像重新标记为latest
:
images = client.list(name="myusername/myrepo")
sofar = None
for image in images:
maxtag = max(tag for tag in image.tags if tag.startswith("date-"))
if sofar is None or maxtag > sofar:
sofar = maxtag
latest = image
latest.tag("myusername/myrepo", tag="latest")
client.push("myusername/myrepo", tag="latest")
12.4 摘要
在自动化 Docker 时,使用dockerpy
是 Docker 客户机的一个强大的选择。它允许我们使用 Python 的全部功能,包括字符串操作和缓冲区操作,来构建容器映像、运行容器和管理正在运行的容器。
十三、亚马逊网络服务
亚马逊网络服务,AWS,是一个云平台。它允许使用数据中心的计算和存储资源,按使用付费。AWS 的一个核心原则是,与它的所有交互都应该可以通过 API 实现:可以操纵计算资源的 web 控制台只是 API 的另一个前端。这允许基础设施的自动化配置:所谓的“作为代码的基础设施”,其中计算基础设施是以编程方式保留和操作的。
Amazon Web Services 团队支持 PyPI 上的一个包,boto3
,用于自动化 AWS 操作。一般来说,这是与 AWS 交互的最佳方式之一。
虽然 AWS 支持控制台 UI,但通常最好将其用作 AWS 服务的只读窗口。当通过控制台 UI 进行更改时,没有可重复的记录。虽然可以记录操作,但这无助于重现它们。
正如我们在前面的章节中所讨论的,将 Jupyter 与boto3
结合起来,就可以得到一个强大的 AWS 操作控制台。使用boto3
API 通过 Jupyter 采取的动作可以根据需要重复、自动化和参数化。
当对 AWS 设置进行特别更改以解决问题时,可以将笔记本附加到跟踪问题的票据上,以便清楚地记录为解决问题所做的工作。这既有助于理解在这导致一些不可预见的问题的情况下做了什么,也有助于在再次需要这种解决方案的情况下容易地重复这种干预。
一如既往,笔记本不是一个审计解决方案;首先,当允许通过boto3
访问时,动作不必通过笔记本来执行。AWS 有内部方法生成审计日志。笔记本是用来记录意图和允许重复性的。
13.1 安全
对于自动化操作,AWS 需要访问键。可以为 root 帐户配置访问密钥,但这不是一个好主意。对 root 帐户没有任何限制,所以这意味着这些访问键可以做任何事情。
用于角色和权限的 AWS 平台称为“身份和访问管理”,简称 IAM。IAM 服务负责用户、角色和策略。
一般来说,最好为每个人类用户以及每个需要执行的自动化任务都配备一个单独的 IAM 用户。即使他们都共享一个访问策略,拥有不同的用户意味着更容易进行密钥管理,以及拥有关于谁(或什么)做了什么的准确审计日志。
配置访问密钥
通过正确的安全策略,用户可以控制自己的访问密钥。单个“访问密钥”由访问密钥 ID 和访问密钥秘密组成。 ID 不需要保密,它将在生成后通过 IAM 用户界面保持可访问性。例如,这允许通过 ID 禁用或删除访问密钥。
用户最多可以配置两个访问键。拥有两个密钥允许进行 0-停机时间密钥轮换。第一步是生成一个新的密钥。然后到处更换旧钥匙。之后,禁用旧密钥。禁用旧密钥会使任何试图使用它的行为失败。如果检测到这种故障,很容易重新启用旧密钥,直到使用该密钥的任务可以升级到新密钥。
经过一段时间后,如果没有观察到故障,删除旧密钥应该是安全的。
一般来说,本地安全策略决定了密钥轮换的频率,但这通常至少应该是每年一次的惯例。一般来说,这应该遵循组织中使用的其他 API 秘密的实践。
注意,在 AWS 中,不同的计算任务可以有自己的 IAM 凭证。
例如,可以为 EC2 机器分配一个 IAM 角色。其他更高级别的计算任务也可以被分配一个角色。例如,运行一个或多个 Docker 容器的弹性容器服务(ECS)任务可以被分配一个 IAM 角色。所谓的“无服务器”Lambda 函数运行在按需分配的基础设施上,也可以被分配 IAM 角色。
如果从这样的任务运行,boto3
客户机将自动使用这些凭证。这消除了显式管理凭据的需要,并且通常是更安全的替代方法。
创建短期代币
AWS 支持所谓的“短期令牌”或 STS。短期令牌可以用于多种用途。它们可用于将替代的身份验证方法转换成可用于任何基于boto3
的程序的令牌,例如,通过将它们放入环境变量中。
例如,在配置了基于 SAML 的基于 SSO 的身份验证的帐户中,可以调用boto3.client('sts').assume_role_with_saml
来生成短期安全令牌。这可以在boto3.Session
中使用,以便获得具有这些权限的会话。
import boto3
response = boto3.client('sts').assume_role_with_saml(
RoleArn=role_arn,
PrincipalArn=principle_arn,
SAMLAssertion=saml_assertion,
DurationSeconds=120
)
credentials = response['Credentials']
session = boto3.Session(
aws_access_key_id=credentials['AccessKeyId'],
aws_secret_access_key=credentials['SecretAccessKey'],
aws_session_token=credentials['SessionToken'],
)
print(session.client('ec2').describe_instances())
一个更现实的用例是在一个定制的 web 门户中,该门户被认证到一个 SSO 门户。它可以代表用户执行操作,而本身对 AWS 没有任何特殊的访问权限。
在配置了跨帐户访问的帐户上,assume_token
可以返回授权帐户的凭证。
即使使用单一帐户,有时创建短期令牌也很有用。例如,这可以用于限制权限;可以创建一个具有有限安全策略的 STS。在一段更容易受到攻击的代码中使用这些限制标记,例如,由于直接的用户交互,允许限制攻击面。
13.2 弹性计算云(EC2)
弹性计算云(EC2)是 AWS 中访问计算(CPU 和内存)资源的最基本方式。EC2 运行各种类型的“机器”。其中大多数是“虚拟机”(VM),与其他 VM 一起运行在物理主机上。AWS 基础设施负责以公平的方式在虚拟机之间分配资源。
EC2 服务还处理机器正常工作所需的资源:操作系统映像、附加存储和网络配置等。
区域
EC2 机器在“区域”中运行区域通常有一个友好的名称(如“俄勒冈州”)和一个用于程序的标识符(如“us-west-2”)。
美国有几个地区:在撰写本文时,北弗吉尼亚(“美国东部-1”)、俄亥俄州(“美国东部-2”)、北加利福尼亚(“美国西部-1”)和俄勒冈州(“美国西部-2”)。欧洲、亚太地区等也有几个地区。
当我们连接到 AWS 时,我们连接到我们需要操作的区域:boto3.client("ec2", region_name="us-west-2")
返回一个连接到俄勒冈州 AWS 数据中心的客户端。
可以在环境变量和配置文件中指定默认区域,但最好是在代码中显式指定(或者从更高级别的应用配置数据中检索)。
EC2 机器也在可用性区域中运行。请注意,虽然区域是“客观的”(每个客户都认为区域是相同的),但可用性区域不是:一个客户的“us-west-2a”可能是另一个客户的“us-west-2c”
亚马逊将所有 EC2 机器放入某个虚拟私有云(VPC)专用网络。对于简单的情况,一个帐户在每个地区有一个 VPC,所有属于该帐户的 EC2 机器都在那个 VPC。
子网是 VPC 与可用性区域相交的方式。子网中的所有计算机都属于同一个区域。一个 VPC 可以有一个或多个安全组。安全组可以设置关于允许哪些网络连接的各种防火墙规则。
亚马逊机器图像
为了启动 EC2 机器,我们需要一个“操作系统映像”虽然可以构建定制的 Amazon 机器映像(AMIs ),但通常情况下我们可以使用现成的映像。
所有主要的 Linux 发行版都有 ami。正确分布的 AMI ID 取决于我们想要运行机器的 AWS 区域。一旦我们决定了地区和发行版本,我们需要找到 AMI ID。
ID 有时很难找到。如果你有的产品代码,比如aw0evgkw8e5c1q413zgy5pjce
,我们可以用describe_images
。
client = boto3.client(region_name='us-west-2')
description = client.describe_images(Filters=[{
'Name': 'product-code',
'Values': ['aw0evgkw8e5c1q413zgy5pjce']
}])
print(description)
CentOS wiki 包含所有相关 CentOS 版本的产品代码。
Debian 镜像的 AMI IDs 可以在 Debian wiki 上找到。Ubuntu 网站有一个工具可以根据地区和版本找到各种 Ubuntu 映像的 AMI IDs。不幸的是,没有集中的自动化注册。可以用 UI 搜索 ami,但这是有风险的;保证阿美族真实性的最好方法是查看创建者的网站。
SSH 密钥
对于临时管理和故障排除,能够 SSH 到 EC2 机器是很有用的。这可能是手动 SSH,使用 Paramiko、Ansible 或引导 Salt。
构建 ami 的最佳实践是使用cloud-init
来初始化机器,其默认映像的所有主要发行版都遵循这一实践。cloud-init
要做的事情之一是允许预先配置的用户通过 SSH 公钥登录,该公钥是从机器的所谓“用户数据”中检索的。
公共 SSH 密钥按地区和帐户存储。添加 SSH 密钥有两种方法:让 AWS 生成一个密钥对,并检索私钥,或者我们自己生成一个密钥对,并将公钥推送给 AWS。
第一种方式通过以下方式完成:
key = boto3.client("ec2").create_key_pair(KeyName="high-security")
fname = os.path.expanduser("~/.ssh/high-security")
with open(fname, "w") as fpout:
os.chmod(fname, 0o600)
fpout.write(key["KeyMaterial"])
注意,这些键是 ASCII 编码的,所以使用字符串(而不是字节)函数是安全的。
请注意,在输入敏感数据之前,更改文件的权限是一个好主意。我们还将它存储在一个具有保守访问权限的目录中。
如果我们想将一个公钥导入 AWS,我们可以这样做:
fname = os.path.expanduser("~/.ssh/id_rsa.pub")
with open(fname, "rb") as fpin:
pubkey = fpin.read()
encoded = base64.encodebytes(pubkey)
key = boto3.client("ec2").import_key_pair(
KeyName="high-security",
PublicKeyMaterial=encoded,
)
正如在密码学一章中所解释的,在尽可能少的机器上拥有私钥是最好的。
总的来说,这是一个比较好的办法。如果我们在本地生成密钥并对它们进行加密,那么未加密的私钥泄漏的地方就更少了。
启动机器
EC2 客户机上的run_instances
方法可以启动新的实例。
client = boto3.client("ec2")
client.run_instances(
ImageId='ami-d2c924b2',
MinCount=1,
MaxCount=1,
InstanceType='t2.micro',
KeyName=ssh_key_name,
SecurityGroupIds=['sg-03eb2567']
)
API 有点违反直觉——在几乎所有情况下,MinCount
和MaxCount
都需要为 1。对于运行几台相同的机器,使用自动缩放组(ASG)要好得多,这超出了本章的范围。总的来说,值得记住的是,作为 AWS 的第一个服务,EC2 拥有最老的 API,在设计良好的云自动化 API 方面学到的经验最少。
虽然通常 API 允许运行多个实例,但这并不常见。SecurityGroupIds
表示机器在哪个 VPC。当从 AWS 控制台运行机器时,会自动创建一个相当自由的安全组。出于调试目的,使用该安全组是一种有用的快捷方式,尽管通常创建自定义安全组更好。
这里选择的 AMI 是 CentOS AMI。虽然KeyName
不是强制性的,但是强烈建议创建一个密钥对,或者导入一个密钥对,并使用名称。
InstanceType
表示分配给实例的计算资源量。t2.micro
顾名思义,是一台相当微型的机器。它主要用于原型开发,但通常不能支持除了最少量的生产工作负载之外的所有工作负载。
安全登录
当通过 SSH 登录时,最好事先知道我们期望的公钥是什么。否则,中介可以劫持连接。尤其是在云环境中,“首次使用时信任”的方法是有问题的;每当我们制造一台新机器时,都会有许多“第一次使用”。由于虚拟机最好被视为一次性的,豆腐原则没有什么帮助。
检索密钥的主要技术是在实例启动时将密钥写入“控制台”。AWS 为我们提供了一种检索控制台输出的方法:
client = boto3.client('ec2')
output = client.get_console_output(InstanceId=sys.argv[1])
result = output['Output']
不幸的是,引导时诊断消息的结构并不好,所以解析必须是临时的。
rsa = next(line
for line in result.splitlines()
if line.startswith('ssh-rsa'))
我们寻找以ssh-rsa
开始的第一行。现在我们有了公钥,我们可以用它做几件事。如果我们只是想运行一个 SSH 命令行,并且机器不是只允许 VPN 访问的,我们将想把公共 IP 存储在known_hosts
中。
这避免了第一次使用时信任(Trust-on-First-Use,豆腐渣)的情况:boto3
使用认证机构安全地连接到 AWS,因此 SSH 密钥的完整性得到了保证。尤其对于云平台来说,豆腐是一个很差的安全模型。既然创造和摧毁机器如此容易,机器的寿命有时以周甚至天来衡量。
resource = boto3.resource('ec2')
instance = resource.Instance(sys.argv[1])
known_hosts = (f'{instance.public_dns_name},'
f'{instance.public_ip_address} {rsa}')
with open(os.path.expanduser('~/.ssh/known_hosts'), 'a') as fp:
fp.write(known_hosts)
建筑形象
建立自己的形象会很有用。这样做的一个原因是为了加速启动。不需要引导一个普通的 Linux 发行版,然后安装所需的包、设置配置等等,只需要做一次,存储 AMI,然后从这个 AMI 启动实例。
这样做的另一个原因是知道升级时间;运行apt-get update && apt-get upgrade
意味着在升级时获得最新的包。相反,在 AMI 构建中这样做可以知道所有的机器都是从同一个 AMI 运行的。升级可以通过首先用具有新 AMI 的机器替换一些机器,检查状态,然后替换剩余的机器来完成。网飞等人使用的这种技术被称为“不可变图像”虽然还有其他实现不变性的方法,但这是在生产中成功部署的第一种方法。
准备机器的一种方法是使用配置管理系统。Ansible 和 Salt 都有一个“本地”模式,在本地运行命令,而不是通过服务器/客户端连接。
步骤如下:
-
使用正确的基础映像启动 EC2 机器(例如 vanilla CentOS)。
-
检索安全连接的主机密钥。
-
复制 Salt 代码。
-
复制 Salt 配置。
-
通过 SSH,在 EC2 机器上运行 Salt。
-
最后,调用
client("ec2").create_image
将当前磁盘内容保存为 AMI。
$ pex -o salt-call -c salt-call salt-ssh
$ scp -r salt-call salt-files $USER@$IP:/
$ ssh $USER@$IP /salt-call --local --file-root /salt-files
(botovenv)$ python
...
>>> client.create_image(....)
这种方法意味着运行在本地机器或 CI 环境中的简单脚本可以从源代码生成 AMI。
13.3 简单存储服务(S3)
简单存储服务(S3)是一种对象存储服务。对象是字节流,可以存储和检索。这可以用来存储备份,压缩日志文件,视频文件,以及类似的东西。
S3 通过键(一个字符串)将对象存储在桶中。可以存储、检索或删除对象。但是,不能就地修改对象。
S3 存储桶名称必须是全球唯一的,而不仅仅是每个帐户。这种唯一性通常是通过添加账户持有人的域名来实现的,例如large-videos.production.example.com
。
可以将存储桶设置为公开可用,在这种情况下,可以通过访问由存储桶名称和对象名称组成的 URL 来检索对象。这使得 S3 桶,正确配置,是静态网站。
管理存储桶
一般来说,创建存储桶是一个相当罕见的操作。新桶对应新代码流,而不是代码运行。这部分是因为存储桶需要有唯一的名称。然而,有时自动创建存储桶是有用的,也许对于许多并行测试环境来说。
response = client("s3").create_bucket(
ACL='private',
Bucket='my.unique.name.example.com',
)
还有其他选择,但通常不需要。其中一些与授予 bucket 权限有关。一般来说,管理 bucket 权限的更好方法是管理所有权限的方式:通过将策略附加到角色或 IAM 用户。
为了列出可能的键,我们可以使用:
response = client("s3").list_objects(
Bucket=bucket,
MaxKeys=10,
Marker=marker,
Prefix=prefix,
)
前两个论点很重要;有必要指定存储桶,最好确保响应具有已知的最大大小。
Prefix
参数非常有用,尤其是当我们使用 S3 桶来模拟“文件系统”时例如,这就是作为网站的 S3 桶通常的样子。将 CloudWatch 日志导出到 S3 时,可以指定一个前缀,准确地模拟一个“文件系统”虽然桶内部仍然是平的,但我们可以使用类似于Prefix="2018/12/04/"
的东西来只获取 2018 年 12 月 4 日的日志。
当符合条件的对象多于MaxKeys
时,响应将被截断。在这种情况下,响应中的IsTruncated
字段将是True
,并且NextMarker
字段将被设置。发送另一个Marker
设置为返回的NextMarker
的list_objects
将检索下一个MaxKeys
对象。这允许通过响应进行分页,即使面对变化的桶也是一致的,在有限的意义上,我们将至少获得所有在分页时没有变化的对象。
为了检索单个对象,我们使用get_object
:
response = boto3.client("s3").get_object(
Bucket='string',
Key='string',
)
value = response["Body"].read()
value
将是一个字节的对象。
特别是对于小到中等大小的对象,比如几兆字节,这是一种允许简单检索所有数据的方法。
为了将此类物体推入桶中,我们可以使用:
response = boto3.client("s3").put_object(
Bucket=BUCKET,
Key=some_key,
Body=b'some content',
)
同样,这适用于身体都适合内存的情况。
正如我们前面提到的,当上传或下载较大的文件(例如,视频或数据库转储)时,我们希望能够增量上传,而不是一次将整个文件保存在内存中。
boto3
库使用∗_fileobj
方法公开了此类功能的高级接口。
例如,我们可以使用以下方式传输大型视频文件:
client = boto3.client('s3')
with open("meeting-recording.mp4", "rb") as fpin:
client.upload_fileobj(
fpin,
my_bucket,
"meeting-recording.mp4"
)
我们还可以使用类似的功能来下载大型视频文件:
client = boto3.client('s3')
with open("meeting-recording.mp4", "wb") as fpout:
client.upload_fileobj(
fpin,
my_bucket,
"meeting-recording.mp4"
)
最后,通常情况下,我们希望对象直接从 S3 转移到 S3,而不通过我们的自定义代码传输数据——但我们不希望允许未经验证的访问。
例如,一个持续集成作业可能会将其工件上传到 S3。我们希望能够通过 CI web 界面下载它们,但是让数据通过 CI 服务器是令人不快的——这意味着该服务器现在需要处理可能更大的文件,而人们会关心传输速度。
S3 允许我们生成“预签名”的网址。这些 URL 可以作为来自另一个 web 应用的链接给出,或者通过电子邮件或任何其他方法发送,并允许对 S3 资源进行有时间限制的访问。
url = s3.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': my_bucket,
'Key': 'meeting-recording.avi'
}
)
这个网址现在可以通过电子邮件发送给需要观看录像的人,他们将能够下载视频并观看。在这种情况下,我们不用运行 web 服务器。
一个更有趣的用例是允许预先签名的上传。这尤其有趣,因为上传文件有时需要 web 服务器和 web 应用服务器之间微妙的交互,以允许发送大型请求。
相反,直接从客户端上传到 S3 允许我们删除所有的中介。例如,这对于使用一些文档共享应用的用户非常有用。
post = boto3.client("s3").generate_presigned_post(
Bucket=my_bucket,
Key='meeting-recording.avi',
)
post_url = post["url"]
post_fields = post["fields"]
我们可以从代码中使用这个 URL,比如:
with open("meeting-recording.avi", "rb"):
requests.post(post_url,
post_fields,
files=dict(file=file_contents))
这使我们能够在本地上传会议记录,即使会议记录设备没有 S3 访问凭据。还可以通过generate_presigned_post
限制文件的最大大小,以限制上传这些文件的未知设备的潜在危害。
请注意,预签名的 URL 可以多次使用。可以使预先签名的 URL 仅在有限的时间内有效,以减少上传后可能改变对象的任何风险。例如,如果持续时间是一秒,我们可以避免检查上传的对象,直到第二秒完成。
13.4 摘要
AWS 是一个流行的基础设施即服务平台,通常以按需付费的方式使用。它适用于基础设施管理任务的自动化,而由 AWS 自己维护的boto3
是实现这种自动化的一种强有力的方法。