Python DevOps 教程(三)
十一、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是实现这种自动化的一种强有力的方法。