原文:
annas-archive.org/md5/68b28228356df0415ddc83eb0aaea548相关服务:巴西VPS服务器
译者:飞龙
第九章:云计算
云计算是一个术语,它像其他流行的现代术语一样会造成混淆,比如大数据、人工智能和敏捷。当一个术语变得足够流行时,最终会对很多人有很多含义。这里是一个精确的定义。云是按需提供计算服务的交付,您只需支付您使用的量,就像任何其他公用事业一样:天然气、电力或水。
云计算的顶级好处包括成本、速度、全球规模、生产力、性能、可靠性和安全性。让我们逐个解析这些。
成本
没有前期成本,资源可以精确计量以满足需求。
速度
云提供自助服务,因此专业用户可以利用资源快速构建解决方案。
全球规模
所有主要云提供商都具有全球规模,这意味着可以在世界各地提供服务,以满足地理区域的需求。
生产力
许多任务,如架设服务器、配置网络硬件和物理保护数据中心,已经不复存在。公司可以专注于构建核心知识产权,而不是重复造轮子。
性能
与您拥有的硬件不同,云硬件不断升级,这意味着最快和最新的硬件始终按需可用。所有硬件还连接在一起,通过低延迟和高带宽的基础设施,创建了一个理想的高性能环境。
可靠性
云的核心架构在每一步都提供冗余。每个区域都有多个数据中心,每个数据中心都有多个。云原生架构可以围绕这些能力设计,从而实现高可用性架构。此外,许多核心云服务本身也具有高可用性,比如亚马逊 S3,其可靠性为九个“9”,即 99.999999999%。
安全性
您的安全性取决于最薄弱的环节。通过集中到中央化的安全性,可以实现更高级别的安全性。诸如物理访问数据中心或静止加密等问题,在第一天就成为行业标准。
云计算基础
从某些方面来看,很难在不考虑云的情况下思考 DevOps。亚马逊将以下内容描述为 DevOps 的最佳实践:持续集成、持续交付、微服务、基础设施即代码、监控与日志、以及沟通与协作。在这些最佳实践中,可以说所有这些都依赖于云的存在。即使是较难定义的“沟通与协作”实践,也是通过现代化的 SaaS 沟通工具套件实现的:Jira、Trello、Slack、GitHub 等。所有这些 SaaS 沟通工具都运行在云上。
现代云时代有什么独特之处?至少有三个定义性特征:理论上的无限计算资源,按需访问计算资源以及没有前期资本承诺。这些特征内涵了 DevOps 技能的帕累托分布。
在实践中,云在支持云的真正效率方面使用时变得极具成本效益。另一方面,对于使用云计算的不成熟组织来说,可能会非常昂贵,因为他们没有利用云计算的核心功能。可以说,在云计算的早期阶段,80%的总利润来自于未成熟的用户,他们让实例空闲,选择了错误的实例(过大),没有为自动扩展进行架构设计,或者使用了非云原生的软件架构,例如将所有内容都塞进关系数据库中。同样,其余 20%的总利润来自于具有卓越 DevOps 技能的极为节俭的组织。
在云存在之前,有一个永远不会消失的固定成本。这个成本无论是在金钱上还是在开发人员时间上都是固定的。一个数据中心必须由一个团队来维护,这是一份全职工作,而且非常昂贵。随着云的成熟发展,现在只有最优秀的人才才会在数据中心工作,他们为像谷歌、微软和亚马逊这样极为成熟的组织工作。从统计上讲,小公司无法长期拥有那些水平的数据中心工程师硬件技能。
经济学的一个基本法则是比较优势原则。与其看云计算的成本,然后认为自己可以通过自己动手省钱,不如看看不做某些事情的机会成本。大多数组织已经得出结论:
-
他们无法在数据中心专业知识方面与谷歌、亚马逊和微软竞争。
-
支付云服务费用使公司能够专注于其他领域,利用他们独特的技能。
Netflix 决定专注于提供流媒体服务和创作原创内容,而不是运营自己的数据中心。如果你看一下 Netflix 从 2008 年到 2019 年的 11 年股票价格(图 9-1),很难反驳这一策略。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0901.png
图 9-1. Netflix 11 年股票价格
Netflix 的独特之处在于其在云端运营卓越性上的承诺。目前或曾经在 Netflix 工作的员工在各大会议上发表了许多演讲,在 GitHub 上开发和发布了工具,并在 DevOps 和云计算主题上撰写了文章和书籍。这进一步支持了这样一个观点:仅仅意识到云是正确的选择是不够的,这个决定必须以卓越的运营实践为支撑。否则,一个组织可能会像那些注册了一年会员却只去了三周健身房的人一样,那些不去健身房的会员在经济上补贴了那些经常去的会员。
云计算的类型
云计算有几种主要类型:公有云、私有云、混合云和多云。大多数情况下,当我们谈论云时,指的是公有云。但这并不是唯一的云类型。私有云由组织独占使用,可以是物理上位于该组织的数据中心,也可以由另一家公司为该组织托管。一些私有云提供商包括 HPE、VMware、戴尔和甲骨文。一个流行的开源私有云选项是 OpenStack。实际上的一个很好的例子是,Rackspace,一个在托管空间中更为专业的替代品,是 OpenStack 私有云即服务的最大提供商之一。
更加灵活的选择是混合云。混合云结合了私有云和公有云。这种架构的一个例子是在需要可伸缩性和额外容量的情况下使用公有云,而在日常运营中使用私有云。另一个例子可能涉及专用硬件架构,比如在私有云中进行深度学习的 GPU 农场,而连接的公有云则作为核心基础设施。即使是主要的云供应商也进入了这个领域。一个很好的例子是谷歌的 Anthos 平台。这个平台通过在本地数据中心与 GCP 之间建立链接来完成难以置信的工作,允许以无缝的方式运行 Kubernetes 集群。
最后,多云是一种选择,部分由现代 DevOps 技术(如 Docker 容器)和基础设施即代码(IaC)解决方案(如 Terraform)所启用。多云策略涉及同时使用多个云。一个很好的例子是在多个云上同时运行容器中的作业。为什么这样做?首先,你可以决定在 AWS Spot 实例价格适宜以赚取利润时运行作业,但在 AWS 价格过高时切换到 GCP。像 Terraform 这样的工具允许你将云概念抽象为熟悉的配置语言,而容器允许代码和执行环境在能运行容器的任何目标上运行。
云服务的类型
云服务有五种主要类型:基础设施即服务(IaaS),金属即服务(MaaS),平台即服务(PaaS),无服务器,以及软件即服务(SaaS)。这些云服务在不同的抽象层上工作,并各有利弊。让我们来详细了解每一种服务。
基础设施即服务
Infrastructure as a Service(基础设施即服务,IaaS)是一个低级别的类别,包括按分钟租用虚拟机、访问对象存储、提供软件定义网络(SDN)和软件定义存储(SDS),以及竞标可用虚拟机的能力。这种服务水平与 AWS 密切相关,特别是在早期(2006 年)亚马逊推出 S3 云存储、SQS(简单队列服务)和 EC2(虚拟机)时。
这项服务对于在 DevOps 方面拥有强大专业知识的组织来说具有巨大的成本效益和可靠性,只需少数人就能完成。缺点是 IaaS 有陡峭的学习曲线,当管理效率低下时,它在成本和人力上可能会非常昂贵。在 2009 年至 2019 年期间的旧金山湾区,这种情况在许多公司的 AWS 上实时发生。
一个令人铭记的故事是,当诺亚管理一个提供监控和搜索工具的 SaaS 公司的工程部门时发生的。在他上任的第一个月,云端发生了两个关乎任务的严重问题。第一个问题发生在第一周,是 SaaS 计费系统错误配置了存储系统。公司正在删除付费客户的数据!问题的要点是他们没有成功在云端运作所需的 DevOps 基础设施:没有构建服务器,没有测试,没有真正的隔离开发环境,没有代码审查,以及有限的自动部署软件能力。诺亚采取的解决措施是这些 DevOps 实践,就在一场象征性的大火正在燃烧的时候。
注意
一名开发人员曾用烤面包机烤培根,导致办公室起火。诺亚闻到了烟味,于是走进了厨房,发现火焰正顺着墙壁和天花板蔓延。他对情况的讽刺意味感到震惊,所以他坐在那里几秒钟,沉浸在其中。幸运的是,一个反应迅速的同事(产品经理)拿起了灭火器,扑灭了火势。
我们的云架构随后发生了第二个更为严重的问题。公司所有开发人员都必须值班,以确保 24/7 覆盖(除了 CTO/创始人经常编写直接或间接导致故障的代码…稍后再说)。一天晚上,当诺亚值班时,他在凌晨 2 点被 CEO/创始人的手机电话吵醒。他告诉诺亚他们被黑客攻击了,整个 SaaS 系统不复存在。平台上没有任何网页服务器、搜索端点或任何其他虚拟机在运行。诺亚问为什么他没有收到警报,CEO 说监控系统也被删除了。诺亚决定凌晨 2 点驱车去公司解决问题。
随着更多信息浮出水面,问题变得显而易见。CEO 和创始人最初设置了 AWS 账户,并且所有有关服务中断的电子邮件都发送到他的邮箱。几个月来,亚马逊一直向他发送关于我们所在地区北弗吉尼亚的虚拟机需要退役并且即将删除的电子邮件。最终那一天到来了,在深夜,整个公司的服务器都停止存在了。
当诺亚开车去上班时,他发现了这个问题,于是专注于从 GitHub 源代码重新构建一个完整的 SaaS 公司。从这一点开始,诺亚开始理解 AWS 的强大和复杂性。他从凌晨 2 点到下午 8 点之间,将 SaaS 系统恢复正常,能够接收数据、处理支付并提供仪表板服务。又花了 48 小时完全恢复所有备份数据。
导致恢复时间如此之长的原因之一是部署过程集中在先前员工创建但从未提交到版本控制的 Puppet 的分支版本上。幸运的是,诺亚在凌晨 6 点左右找到了存活下来的孤立机器上的那个版本的 Puppet 的副本。如果这台机器不存在,可能会导致公司的灭亡。在没有基础设施即代码(IAC)支撑的情况下,完全重建这种复杂公司可能需要一周时间。
一个非常令人压力巨大的经历,但最终有一个相对幸运的结局,给他带来了很多教训。诺亚意识到这是云计算的一个折衷之处;虽然非常强大,但学习曲线对于旧金山湾区的风投支持的初创公司来说压力巨大。现在回到那位 CTO/创始人,他不在值班,但是在没有使用构建服务器或持续集成系统的情况下将代码推送到生产环境。这个人并不是故事的反派。诺亚本人在职业生涯的某个阶段如果成为一家公司的 CTO/创始人,可能也会犯同样的错误。
真正的问题是权力动态。等级制度并不等同于正确性。很容易沉迷于自己的权力,并认为因为你掌管着,你所做的一切总是有道理的。当诺亚经营一家公司时,他也犯了类似的错误。关键要点是过程必须正确,而不是个人。如果不自动化,那就是有问题的。如果没有经过某种类型的自动化质量控制测试,那也是有问题的。如果部署不可重复,那也是有问题的。
最后要分享的关于这家公司的故事涉及监控。在这两次初始危机之后,症状得到了缓解,但根本疾病仍然是恶性的。该公司存在一个无效的工程过程。另一个故事突显了根本问题。有一个自制的监控系统(再次由创始人最初创建),平均每 3-4 小时生成一次警报,每天 24 小时。
由于除了首席技术官外,所有工程人员都在值班,大部分工程人员总是睡眠不足,因为他们每晚都会接到系统不工作的警报。对警报的“修复”是重新启动服务。诺亚自愿连续一个月值班,以便工程有时间解决问题。这段持续的痛苦和缺觉期导致他意识到几件事情。首先,监控系统不比随机更好。他可能用这个 Python 脚本完全替换整个系统:
from random import choices
hours = list(range(1,25))
status = ["Alert", "No Alert"]
for hour in hours:
print(f"Hour: {hour} -- {choices(status)}"
✗ python random_alert.py
Hour: 1 -- ['No Alert']
Hour: 2 -- ['No Alert']
Hour: 3 -- ['Alert']
Hour: 4 -- ['No Alert']
Hour: 5 -- ['Alert']
Hour: 6 -- ['Alert']
Hour: 7 -- ['Alert']
Hour: 8 -- ['No Alert']
Hour: 9 -- ['Alert']
Hour: 10 -- ['Alert']
Hour: 11 -- ['No Alert']
Hour: 12 -- ['Alert']
Hour: 13 -- ['No Alert']
Hour: 14 -- ['No Alert']
Hour: 15 -- ['No Alert']
Hour: 16 -- ['Alert']
Hour: 17 -- ['Alert']
Hour: 18 -- ['Alert']
Hour: 19 -- ['Alert']
Hour: 20 -- ['No Alert']
Hour: 21 -- ['Alert']
Hour: 22 -- ['Alert']
Hour: 23 -- ['No Alert']
Hour: 24 -- ['Alert']
一旦他意识到这一点,他深入挖掘数据,并按天创建了过去一年每个单独警报的历史图片(注意这些警报旨在可行动并“唤醒你”)。从图 9-2 中可以看出,这些警报不仅毫无意义,而且在事后看来频率还荒谬增长。他们在“货物崇拜”工程最佳实践,并象征性地在一个由稻草建造的泥土跑道上挥舞棕榈树枝。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0902.png
图 9-2. SaaS 公司每日警报
查看数据后,了解到工程师们花费了多年的生命响应页面和夜间被唤醒,却毫无意义。这种痛苦和牺牲一无所获,强化了生活不公的悲哀真相。这种情况的不公非常令人沮丧,并且需要大量说服才能让人们同意关闭警报。人类行为中有一种固有的偏见,继续做你一直做过的事情。此外,由于痛苦如此严重和持久,往往倾向于赋予更深层次的意义。最终,这是一个虚假的神。
对于这家特定公司使用 AWS 云 IaaS 的回顾实际上是 DevOps 的卖点:
-
你必须有交付流水线和反馈循环:构建、测试、发布、监控,然后计划。
-
开发与运维不是独立的。如果首席技术官在编写代码,他们也应该负责值班(多年被惊醒的痛苦和苦难将成为正确的反馈循环)。
-
地位在等级制度中并不比流程更重要。团队成员之间应该有一种强调所有权和责任的合作关系,不论职称、薪水或者经验水平。
-
速度是 DevOps 的基本要求。因此,微服务和持续交付是必需的,因为它们使团队能够快速拥有自己的服务并发布软件。
-
快速交付是 DevOps 的基本要求,但它还需要持续集成、持续交付以及有效和可操作的监控和日志记录。
-
它提供了在规模上管理基础设施和开发过程的能力。自动化和一致性是硬性要求。使用基础设施即代码(IaC)以可重复和自动化的方式管理开发、测试和生产环境是解决方案。
MaaS(Metal as a Service)
MaaS(Metal as a Service)允许你像虚拟机一样处理物理服务器。在管理虚拟机集群时同样便捷的使用体验也适用于物理硬件。MaaS 是由 Canonical 提供的一项服务,Canonical 的所有者马克·舒特尔沃斯特称其为“云语义”进入裸金属世界。MaaS 还可以指的是使用将硬件视为虚拟化硬件的供应商概念。这方面的一个很好的例子是 SoftLayer,这是一家被 IBM 收购的裸金属提供商。
在正面的优势中,对硬件拥有完全控制确实对特定应用具有一定吸引力。这方面的一个很好的例子可以是使用基于 GPU 的数据库。实际上,常规公共云也可以提供类似的服务,因此进行全面的成本效益分析有助于在何时使用 MaaS 时进行合理的辩解。
平台即服务(Platform as a Service)
PaaS(Platform as a Service)是一个完整的开发和部署环境,具备创建云服务所需的所有资源。其例子包括 Heroku 和 Google App Engine。PaaS 与 IaaS 不同之处在于它拥有开发工具、数据库管理工具以及高级服务,提供“点对点”集成。可以捆绑的服务类型的例子包括认证服务、数据库服务或 Web 应用服务。
对 PaaS 的一个合理批评是,长期来看它可能比 IaaS 更昂贵,正如之前讨论的;然而这取决于环境。如果组织无法执行 DevOps 行为,那么成本就成了无关紧要的点。在这种情况下,最好支付更昂贵的服务,提供更多这些能力。对于需要学习管理 IaaS 部署高级功能的组织来说,机会成本可能对初创企业的短期生命周期来说太高。对于一个组织来说,把这些能力外包给 PaaS 提供商可能更明智。
无服务器计算
无服务器是云计算的新类别之一,仍然在积极发展中。无服务器的真正承诺在于能够花更多时间构建应用程序和服务,而不需要或几乎不需要考虑它们的运行方式。每个主要的云平台都有无服务器解决方案。
服务器无关解决方案的构建模块是计算节点或函数即服务(FaaS)。AWS 拥有 Lambda,GCP 拥有 Cloud Functions,Microsoft 拥有 Azure Functions。传统上,这些云函数的底层执行已经被抽象为一个运行时,即 Python 2.7、Python 3.6 或 Python 3.7. 所有这些供应商都支持 Python 运行时,并且在某些情况下,它们还支持通过定制的 Docker 容器来定制底层运行时。这里是一个简单的 AWS Lambda 函数示例,用于获取维基百科的第一页。
有几点需要指出关于这个 Lambda 函数。逻辑本身在 lambda_handler 中,并且它接受两个参数。第一个参数 event 来自于触发它的任何内容。Lambda 可以是从 Amazon Cloud Watch 事件定时器到使用从 AWS Lambda 控制台制定的负载运行。第二个参数 context 具有方法和属性,提供有关调用、函数和执行环境的信息。
import json
import wikipedia
print('Loading function')
def lambda_handler(event, context):
"""Wikipedia Summarizer"""
entity = event["entity"]
res = wikipedia.summary(entity, sentences=1)
print(f"Response from wikipedia API: {res}")
response = {
"statusCode": "200",
"headers": { "Content-type": "application/json" },
"body": json.dumps({"message": res})
}
return response
要使用 Lambda 函数,需要发送一个 JSON 负载:
{"entity":"google"}
Lambda 的输出也是一个 JSON 负载:
Response
{
"statusCode": "200",
"headers": {
"Content-type": "application/json"
},
"body": "{\"message\": \"Google LLC is an American multinational technology"}
}
FaaS 最强大的一点之一是能够编写响应事件而不是持续运行的代码:例如 Ruby on Rails 应用程序。FaaS 是云原生能力,真正利用了云的弹性特性。此外,编写 Lambda 函数的开发环境已经有了很大进步。
AWS 的 Cloud9 是一个基于浏览器的开发环境,与 AWS 深度集成(图 9-3)。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0903.png
图 9-3. 使用 AWS Cloud9
Cloud9 现在是我编写 AWS Lambda 函数和运行需要 AWS API 密钥的代码的首选环境。Cloud9 内置了用于编写 AWS Lambda 函数的工具,使得在本地构建和测试它们,以及部署到 AWS 中变得简单直观。
图 9-4 显示了如何传递 JSON 负载并在 Cloud9 中本地测试 lambda。这种测试方式是这一不断发展平台的显著优势。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0904.png
图 9-4. 在 Cloud9 中运行 Lambda 函数
同样,Google Cloud 在你使用 GCP 云 Shell 环境时开始启动你(参见 图 9-5)。云 Shell 还允许您快速启动开发,访问关键命令行工具和完整的开发环境。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0905.png
图 9-5. GCP 云 Shell
GCP 云 Shell 编辑器(参见 图 9-6)是一个功能齐全的 IDE,具有语法高亮显示、文件浏览器和许多传统 IDE 中通常找到的其他工具。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0906.png
图 9-6. GCP 云 Shell 编辑器
关键要点是,在云端,最好在可能的情况下使用本地开发工具。这样做可以减少安全漏洞,限制由于从笔记本电脑传输数据到云端而导致的减速,并因其与本地环境的深度集成而提高生产力。
软件即服务
SaaS 和云从一开始就被结合在一起。随着云端的功能不断增加,SaaS 产品继续在云端创新的基础上分发创新。SaaS 产品有许多优势,特别是在 DevOps 领域。例如,如果你刚开始时可以租用一个监控解决方案,为什么要自己建造呢?此外,许多核心的 DevOps 原则,如持续集成和持续交付,也可以通过云供应商提供的 SaaS 应用(如 AWS CodePipeline)或第三方 SaaS 解决方案(如 CircleCI)实现。
在许多情况下,能够混合使用 IaaS、PaaS 和 SaaS 允许现代公司以比 10 年前更可靠和高效的方式开发产品。由于云端以及构建在云端之上的 SaaS 公司的快速演变,每年构建软件变得更加容易。
基础设施即代码
IaC 在 第十章 中有更详细的介绍;请参考该章节以获取 IaC 的更详细说明。然而,就云和 DevOps 而言,IaC 是实施真实世界云计算的基本要素。在云上实施 DevOps 实践必须具备 IaC 的能力。
持续交付
持续交付是一个较新的术语,可能会在持续集成和持续部署之间产生混淆。关键区别在于软件被交付到某个环境,例如一个演示环境,可以进行自动化和手动测试。虽然不要求立即部署,但它处于可部署状态。有关构建系统的更详细解释可以在 第十五章 中找到,但同样值得指出的是,这是正确使用云的基本要求之一。
虚拟化和容器
在云中最基本的组成部分莫过于虚拟化了。当 AWS 在 2006 年正式推出时,Amazon 弹性计算云(EC2)是发布的核心服务之一。有几个关键的虚拟化领域需要讨论。
硬件虚拟化
AWS 发布的第一个虚拟化抽象是硬件虚拟化。硬件虚拟化有两种形式:半虚拟化(PV)或硬件虚拟机(HVM)。最佳性能来自于 HVM。性能上的关键差异在于,HVM 能够利用硬件扩展,与主机硬件紧密结合,实质上使虚拟机成为主机硬件的一部分,而不仅仅是一个不知情于主机操作的客人。
硬件虚拟化提供了在一个主机上运行多个操作系统的能力,以及将 CPU、I/O(包括网络和磁盘)和内存分区到客户操作系统的能力。这种方法有许多优点,是现代云计算的基础,但对于 Python 本身也存在一些独特的挑战。一个问题是,通常的粒度对于 Python 来说太大,无法充分利用环境。由于 Python 和线程的限制(它们不能在多核上工作),一个有两个核心的虚拟机可能会浪费一个核心。使用硬件虚拟化和 Python 语言,由于缺乏真正的多线程,可能会造成资源的巨大浪费。对于 Python 应用程序的虚拟机配置往往会导致一个或多个核心处于空闲状态,浪费金钱和能源。幸运的是,云计算提供了新的解决方案,帮助消除 Python 语言中的这些缺陷。特别是,容器和无服务器消除了这个问题,因为它们将云视为一个操作系统,而不是线程,有的是 lambda 或容器。而不是在队列上监听线程,lambda 响应来自云队列(例如 SQS)的事件。
软件定义网络
软件定义网络(SDNs)是云计算的重要组成部分。SDNs 的杀手级特性在于能够动态和程序化地改变网络行为。在此能力出现之前,这通常由网络专家负责管理,类似于使用 F5 负载均衡器。诺亚曾在一家大型电信公司工作过,那里每天都有一个称为“变更管理”的会议,由一个名叫 Bob 的人负责控制每一个被发布的软件。
要成为 Bob,需要有独特的个性。Bob 和公司内的人们经常发生争吵。这是经典的 IT 运维与开发之间的斗争,Bob 乐于说不。云和 DevOps 完全消除了这个角色、硬件和每周的吵架。持续交付流程是使用精确配置、软件和所需数据持续地构建和部署软件,以用于生产环境。Bob 的角色深深地融入了矩阵中的 0 和 1 中,被一些 Terraform 代码所取代。
软件定义存储
软件定义存储(SDS)是一种允许按需配置存储的抽象概念。此存储可以配置有细粒度的磁盘 I/O 和网络 I/O。一个很好的例子是亚马逊的 EBS 卷,您可以在其中配置已配置的磁盘 I/O。通常,云 SDS 会随着卷大小自动增加磁盘 I/O。一个如何在实践中工作的绝佳例子是亚马逊弹性文件系统(EFS)。EFS 随着存储大小的增长增加磁盘 I/O(这是自动发生的),并且设计用于支持同时来自数千个 EC2 实例的请求。它还与亚马逊 EC2 实例深度集成,允许挂起的写入进行缓冲并异步发生。
诺亚在这种情况下使用 EFS 有丰富的经验。在 AWS 批处理可用之前,他设计并编写了一个系统,该系统利用了数千个挂载了 EFS 卷的 spot 实例,它们执行从 Amazon SQS 收集的分布式计算机视觉作业。使用一个始终在线的分布式文件系统对于分布式计算来说是一个巨大的优势,并且它简化了从部署到集群计算的一切。
容器
容器已经存在了几十年,它们指的是操作系统级虚拟化。内核允许存在隔离的用户空间实例。在 2000 年代初期,有许多托管公司使用 Apache 网站的虚拟托管作为操作系统级虚拟化的形式。大型机和经典的 Unix 操作系统,如 AIX、HP-UX 和 Solaris,多年来也拥有先进的容器形式。作为开发人员,诺亚在 2007 年推出的 Solaris LDOM 技术中使用了 Solaris LDOM 技术,并对他如何能够安装允许对 CPU、内存和 I/O 进行细粒度控制的完整操作系统而感到敬畏,所有这些都可以通过远程登录到具有带外管理卡的机器来完成。
容器的现代版本正在快速发展,借鉴了主机时代的优秀特性,并结合了像源代码控制这样的新思想。特别是,容器的一个重大革新是将其视为从版本控制中签出的项目。Docker 容器现在是容器的标准格式,所有主要的云供应商都支持 Dockerfile 容器和 Kubernetes 容器管理软件。有关容器的更多信息,请参阅第十二章,但与云相关的基本内容列于此处:
容器注册表
所有的云服务提供商都有一个容器注册表,用于存储您的容器。
Kubernetes 管理服务
所有的云服务提供商都有 Kubernetes 服务,并且这现在是管理基于容器的部署的标准。
Dockerfile 格式
这是构建容器的标准方法,它是一个简单的文件格式。在构建过程中使用像hadolint这样的代码审查工具是一个最佳实践,以确保简单的错误不会通过。
使用容器进行持续集成
所有的云服务提供商都有基于云的构建系统,允许与容器集成。谷歌有Cloud Build,亚马逊有AWS CodePipeline,Azure 有Azure Pipelines。它们都可以构建容器并将其注册到容器注册表中,同时也可以使用容器构建项目。
深度集成容器到所有云服务中
当你进入云平台上的托管服务时,可以放心它们都有一个共同点——容器!亚马逊的 SageMaker,一个托管的机器学习平台,使用容器。谷歌云 Shell 云开发环境使用容器来允许您定制开发环境。
分布式计算中的挑战和机遇
计算机科学中最具挑战性的领域之一是分布式计算。在云计算的现代时代,有几个根本性的转变彻底改变了一切。最显著的转变之一是多核机器的兴起和摩尔定律的终结。请参见图 9-7。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0907.png
图 9-7. 摩尔定律的终结(来源:John Hennessy 和 David Patterson,《计算机体系结构:定量方法》第 6 版,2018 年)
摩尔定律揭示了云时代表现出来的两个基本问题。第一个问题是,CPU 被设计为多用途处理器。它们并不专门用于运行并行工作负载。如果将其与增加 CPU 速度的终极物理极限相结合,CPU 在云时代变得不那么关键。在 2015 年,摩尔定律实际上结束了,每年的增益率为 3%。
第二个问题是,为了抵消单处理器速度的限制而制造多核机器导致了软件语言的连锁反应。许多语言以前在多核利用方面存在重大问题,因为它们是在多处理器甚至是互联网之前设计的时代。Python 在这里是一个很好的例子。更具挑战性的是,图 9-8 显示,通过为主要非并行问题增加更多核心并不是一种“免费午餐”。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0908.png
图 9-8. 阿姆达尔定律
云和不同的架构的机会,例如应用特定集成电路(ASIC)。这些包括图形处理单元(GPU)、现场可编程门阵列(FPGA)和张量处理单元(TPU)。这些专用芯片越来越多地用于机器学习工作负载,并为使用多种硬件组合解决分布式计算中的复杂问题铺平了道路。
云时代的 Python 并发性、性能和进程管理
想象一下,在旧金山的一个危险街区深夜走在黑暗的街道上。在这种情况下,你是巴西柔术的黑带。你独自一人,注意到有个陌生人似乎在跟踪你。当他们走近时,你的心开始加速,你想到了你多年的武术训练。你会不会不得不在街上和陌生人打斗?你每周在健身房与对手进行活跃的实战。你觉得自己准备好了,如果需要的话可以保护自己。你也知道巴西柔术是一种高效的武术,适用于现实世界的情况。
另一方面,与人打斗仍然是要避免的事情。这是危险的。可能会涉及武器。你可能会赢得这场斗争,但会严重伤害你的对手。你也可能会输掉这场战斗,并且自己也会受重伤。即使是巴西柔术的专家也知道,在街头打斗并不是一个理想的场景,尽管很有可能会赢得比赛。
Python 中的并发性非常类似。有一些方便的模式,如多进程和 asyncio。最好是节制地使用并发性。通常,与通过编程语言自己创建的并发性相比,使用平台的并发性选项(无服务器、批处理处理、竞价实例)更好。
进程管理
Python 中的进程管理是该语言的一个突出能力。当 Python 作为连接其他平台、语言和进程的胶水时,它表现最佳。此外,进程管理的实际实现在多年来已经发生了显著变化,并且继续改进。
用子进程管理进程
使用标准库启动进程最简单和最有效的方法是使用run()函数。只要你安装了 Python 3.7 或更高版本,就从这里开始简化你的代码。一个简单的示例只需要一行代码:
out = subprocess.run(["ls", "-l"], capture_output=True)
这行代码几乎可以满足你所需。它在 Python 子进程中调用 shell 命令并捕获输出。返回值是一个CompletedProcess类型的对象。这个对象包含了启动进程时使用的args:returncode、stdout、stderr和check_returncode。
这个一行代码替换了过于冗长和复杂的调用 shell 命令的方法。对于经常写 Python 代码并夹杂着 shell 命令的开发人员来说,这非常棒。以下是一些可能有用的其他提示。
避免使用 shell=True
最佳实践是将命令作为列表中的项调用:
subprocess.run["ls", "-la"]
最好避免使用字符串:
#AVOID THIS
subprocess.run("ls -la", shell=True)
这样做的原因很简单。如果你接受任意字符串并执行它,很容易意外引入一个安全漏洞。假设你编写了一个允许用户列出目录的简单程序。用户可以植入任何想要的命令并利用你的程序。意外制造后门非常可怕,希望这说明了使用shell=True是多么糟糕的主意!
#This is input by a malicious user and causes permanent data loss
user_input = 'some_dir && rm -rf /some/important/directory'
my_command = "ls -l " + user_input
subprocess.run(my_command, shell=True)
相反,你可以通过不允许使用字符串完全避免这个问题:
#This is input by a malicious user and does nothing
user_input = 'some_dir && rm -rf /some/important/directory'
subprocess.run(["ls", "-l", user_input])
设置适当的超时时间并在适当时处理它们
如果你正在编写一个可能运行一段时间的进程的代码,你应该设置一个明智的默认超时时间。一个测试这个的简单方法是使用 Unix 的sleep命令。下面是一个在 IPython shell 中在超时触发之前完成的sleep命令的示例。它返回一个CompletedProcess对象:
In [1]: subprocess.run(["sleep", "3"], timeout=4)
Out[1]: CompletedProcess(args=['sleep', '3'], returncode=0)
这里是第二个版本,它会抛出一个异常。在大多数情况下,处理这个异常会很明智:
----> 1 subprocess.run(["sleep", "3"], timeout=1)
/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/subprocess.py
in run(input, capture_output, timeout, check, *popenargs, **kwargs)
477 stdout, stderr = process.communicate()
478 raise TimeoutExpired(process.args, timeout, output=stdout,
--> 479 stderr=stderr)
480 except: # Including KeyboardInterrupt, communicate handled that.
481 process.kill()
TimeoutExpired: Command '['sleep', '3']' timed out after 1 seconds
一个合理的做法是捕获这个异常TimeoutExpired,然后记录异常并实现一些清理代码:
import logging
import subprocess
try:
subprocess.run(["sleep", "3"], timeout=4)
except subprocess.TimeoutExpired:
logging.exception("Sleep command timed out")
在构建专业级别的系统时,记录异常至关重要。如果这段代码稍后部署在许多机器上,没有一个可搜索的集中式日志系统,追踪错误可能会变得不可能。对于 DevOps 专业人员来说,遵循这个模式并传播它的用处是至关重要的。
用 Python 线程的问题
你可能在成长过程中有过父母告诉你不要和某个朋友交往的经历。如果是这样,很可能是因为你的父母试图帮助你避免犯错。Python 线程就像你成长过程中那个糟糕的朋友一样。如果你继续和它们联系,事情不会有好结果。
在其他语言中,线程是一个合理的折衷方案。在像 C#这样的语言中,你可以执行与队列连接的线程池,并期望每个生成的线程都可以利用设备上的所有核心。这种已经被证明有效的使用线程与队列的模式减少了在代码中手动设置和移除锁的缺点。
Python 不是这样工作的。如果你生成线程,它不会利用你机器上的所有核心,并且它通常会表现出非确定性的方式,从一个核心跳到另一个核心,甚至“减慢你的代码”。为什么在有替代方案的情况下要使用这样的东西呢?
如果你对学习更多关于 DevOps 感兴趣,那么你很可能专注于实用性。你只想学习和应用实际和有意义的知识。实用性是避免在 Python 中使用线程的另一个理由。理论上,在某些情况下可以使用线程并获得性能提升,如果问题是 I/O 绑定的话。然而,再次问一下,为什么要使用一个不可靠的工具,当有可靠的工具存在时?在 Python 中使用线程就像开车需要推一下然后通过弹跳离合器来启动汽车,因为电池不靠谱。当你没有地方可以推动它或者无法把车停在斜坡上时会发生什么?采用这种策略纯粹是疯狂的!
本章中没有使用线程的示例。为什么展示一些不正确的东西?与其使用线程,不如专注于本章中概述的其他替代方案。
使用多进程解决问题
多进程库是使用 Python 标准库在机器上利用所有核心的唯一统一方式。查看图 9-9 时,操作系统级别有几个选择:多进程和容器。
https://github.com/OpenDocCN/ibooker-python-zh/raw/master/docs/py-dop/img/pydo_0909.png
图 9-9. 运行并行 Python 代码
使用容器作为替代方案是一个重要的区别。如果使用多进程库的目的是在没有进程间通信的情况下多次调用进程,可以有很强的理由使用容器、虚拟机或云原生构造,如函数即服务。一个受欢迎且有效的云原生选项是 AWS Lambda。
同样,与自行分叉进程相比,容器具有许多优势。容器有许多优点。容器定义为代码。容器可以精确地调整到所需的级别:即内存、CPU 或磁盘 I/O。它们是直接竞争对手,通常是自行分叉进程的更好替代品。在实践中,它们也可以更容易地融入 DevOps 思维方式。
从 DevOps 的角度来看,如果你认同这样一个观点,即除非没有其他选择,否则应该避免在 Python 中自己实现并发,那么即使是使用 multiprocessing 模块的场景也是有限的。也许在开发和实验阶段,multiprocessing 最好只作为一种工具,因为在容器和云层面都存在更好的选择。
另一种说法是问你信任哪个进程分叉:你在 Python 中编写的多进程代码,Google 编写的 Kubernetes 开发人员,还是亚马逊编写的 AWS Lambda 开发人员?经验告诉我,当我站在巨人的肩膀上时,我做出了最好的决定。在哲学考虑之后,这里是一些有效使用多进程的方法。
使用 Pool()分叉进程
测试多进程分叉能力并针对其运行函数的一个直接方法是使用 sklearn 机器学习库计算 KMeans 聚类。KMeans 计算密集且时间复杂度为 O(n**2),这意味着随着数据量增加,其增长速度会指数级减慢。这个例子非常适合在宏观或微观级别上并行化处理。在下面的例子中,make_blobs方法创建了一个包含 10 万条记录和 10 个特征的数据集。每个 KMeans 算法的计时以及总计时如下:
from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time
def do_kmeans():
"""KMeans clustering on generated data"""
X,_ = make_blobs(n_samples=100000, centers=3, n_features=10,
random_state=0)
kmeans = KMeans(n_clusters=3)
t0 = time.time()
kmeans.fit(X)
print(f"KMeans cluster fit in {time.time()-t0}")
def main():
"""Run Everything"""
count = 10
t0 = time.time()
for _ in range(count):
do_kmeans()
print(f"Performed {count} KMeans in total time: {time.time()-t0}")
if __name__ == "__main__":
main()
KMeans 算法的运行时显示,其是一个昂贵的操作,运行 10 次迭代需要 3.5 秒:
(.python-devops) ➜ python kmeans_sequential.py
KMeans cluster fit in 0.29854321479797363
KMeans cluster fit in 0.2869119644165039
KMeans cluster fit in 0.2811620235443115
KMeans cluster fit in 0.28687286376953125
KMeans cluster fit in 0.2845759391784668
KMeans cluster fit in 0.2866239547729492
KMeans cluster fit in 0.2843656539916992
KMeans cluster fit in 0.2885470390319824
KMeans cluster fit in 0.2878849506378174
KMeans cluster fit in 0.28443288803100586
Performed 10 KMeans in total time: 3.510640859603882
在下面的例子中,使用multiprocessing.Pool.map方法将 10 个 KMeans 集群操作分配给一个包含 10 个进程的池。这个例子通过将参数100000映射到函数do_kmeans来实现:
from multiprocessing import Pool
from sklearn.datasets.samples_generator import make_blobs
from sklearn.cluster import KMeans
import time
def do_kmeans(n_samples):
"""KMeans clustering on generated data"""
X,_ = make_blobs(n_samples, centers=3, n_features=10,
random_state=0)
kmeans = KMeans(n_clusters=3)
t0 = time.time()
kmeans.fit(X)
print(f"KMeans cluster fit in {time.time()-t0}")
def main():
"""Run Everything"""
count = 10
t0 = time.time()
with Pool(count) as p:
p.map(do_kmeans, [100000,100000,100000,100000,100000,
100000,100000,100000,100000,100000])
print(f"Performed {count} KMeans in total time: {time.time()-t0}")
if __name__ == "__main__":
main()
每个 KMeans 操作的运行时间较慢,但总体加速度翻倍。这是并发框架的常见问题;并行工作分配有开销。并行代码的运行并不是“免费午餐”。每个任务的启动时间约为 1 秒:
(.python-devops) ➜ python kmeans_multiprocessing.py
KMeans cluster fit in 1.3836050033569336
KMeans cluster fit in 1.3868029117584229
KMeans cluster fit in 1.3955950736999512
KMeans cluster fit in 1.3925609588623047
KMeans cluster fit in 1.3877739906311035
KMeans cluster fit in 1.4068050384521484
KMeans cluster fit in 1.41087007522583
KMeans cluster fit in 1.3935530185699463
KMeans cluster fit in 1.4161033630371094
KMeans cluster fit in 1.4132652282714844
Performed 10 KMeans in total time: 1.6691410541534424
这个例子展示了为什么对代码进行性能分析和谨慎立即跳转到并发是至关重要的。如果问题规模较小,那么并行化方法的开销可能会使代码变慢,并且调试起来更加复杂。
从 DevOps 的角度来看,最直接和最可维护的方法始终应该是首选。实际上,这可能意味着这种多进程并行化的风格是一个合理的方法,但在尝试宏观准备水平的并行化方法之前不要轻易采用。一些替代的宏观方法可能包括使用容器,使用 FaaS(如 AWS Lambda 或其他无服务器技术),或者使用一个高性能服务器,Python 运行工人对其进行工作(如 RabbitMQ 或 Redis)。
作为服务的函数和无服务器
现代 AI 时代已经创建了压力,促使新范式的出现。CPU 时钟速度的增加已经停滞不前,这实际上结束了摩尔定律。与此同时,数据爆炸、云计算的兴起以及应用特定集成电路(ASIC)的可用性填补了这一空白。现在,函数作为工作单元已经成为一个重要的概念。
Serverless 和 FaaS 可以在某种程度上互换使用,它们描述了在云平台上作为工作单元运行函数的能力。
使用 Numba 进行高性能 Python
Numba 是一个非常酷的库,用于进行分布式问题解决的实验。使用它就像是用高性能的市场售后部件改装你的汽车一样。它还利用了使用 ASIC 解决特定问题的趋势。
使用 Numba 即时编译器
让我们来看看 官方文档示例 中关于 Numba 即时编译器(JIT)的例子,稍作调整,然后分析发生了什么。
这个示例是一个被 JIT 装饰的 Python 函数。参数 nopython=True 强制代码通过 JIT 并使用 LLVM 编译器进行优化。如果不选择这个选项,意味着如果某些内容无法转换为 LLVM,则会保持常规的 Python 代码:
import numpy as np
from numba import jit
@jit(nopython=True)
def go_fast(a):
"""Expects Numpy Array"""
count = 0
for i in range(a.shape[0]):
count += np.tanh(a[i, i])
return count + trace
接下来,创建一个 numpy 数组,并使用 IPython 的魔术函数来计时它:
x = np.arange(100).reshape(10, 10)
%timeit go_fast(x)
输出显示,运行该代码耗时 855 纳秒:
The slowest run took 33.43 times longer than the fastest. This example could mean
that an intermediate result is cached. 1000000 loops, best of 3: 855 ns per loop
可以使用此技巧运行常规版本以避免装饰器:
%timeit go_fast.py_func(x)
输出显示,没有 JIT,常规 Python 代码运行速度慢了 20 倍:
The slowest run took 4.15 times longer than the fastest. This result could mean
that an intermediate run is cached. 10000 loops, best of 3: 20.5 µs per loop
使用 Numba JIT,for 循环是可以加速的优化对象。它还优化了 numpy 函数和 numpy 数据结构。这里的主要观点是,也许值得查看已运行多年的现有代码,看看 Python 基础架构的关键部分是否可以受益于使用 Numba JIT 进行编译。
使用高性能服务器
自我实现是人类发展中的一个重要概念。自我实现的最简单定义是个体达到他们真实潜力的状态。为了做到这一点,他们必须接受自己的人性,包括其中的所有缺陷。有一种理论认为,不到 1%的人已完全实现了自我。
同样的概念也可以应用于 Python,这种语言。全面接受语言的优势和劣势允许开发人员充分利用它。Python 不是一种高性能语言。Python 不是一种像其他语言(如 Go、Java、C、C++、C# 或 Erlang)那样优化用于编写服务器的语言。相反,Python 是一种在高性能语言或平台上应用高级逻辑的语言。
Python 之所以广受欢迎,是因为它符合人类思维的自然过程。通过足够的语言使用经验,你可以像使用母语一样思考 Python。逻辑可以用许多方式表达:语言、符号表示、代码、图片、声音和艺术。计算机科学构造,如内存管理、类型声明、并发原语和面向对象设计可以从纯逻辑中抽象出来。它们对于表达一个想法是可选的。
类似 Python 这样的语言的强大之处在于它允许用户在逻辑层面工作,而不是计算机科学层面。什么是要点?为任务选择正确的工具,而通常这是云或另一种语言的服务。
结论
DevOps 和数据科学都有一个共同点,那就是它们既是职位名称,又是能力。DevOps 方法的一些好处是速度、自动化、可靠性、规模和安全性通过实用主义实现。在考虑可用框架和解决方案之前使用宏观级别的解决方案来提高并发和进程管理的效率是一种危险的 DevOps 反模式。
Python 在云时代的要点是什么?
-
学会为手头的任务掌握正确的并发技术。
-
学会使用高性能计算库 Numba 为你的代码提速,使用真实线程、JIT 和 GPU。
-
学会使用 FaaS 优雅地解决独特问题。
-
将云视为操作系统,并让它承担并发的繁重工作。
-
拥抱云原生构造,如持续交付、Docker 格式容器和无服务器。
练习
-
什么是 IaaS?
-
什么是 PaaS?
-
弹性是什么意思?
-
可用性是什么意思?
-
什么是块存储?
-
云计算服务的不同类型是什么?
-
什么是无服务器?
-
IaaS 和 PaaS 之间有哪些关键区别?
-
什么是 CAP 定理?
-
什么是阿姆达尔定律?
案例研究问题
-
一家公司犹豫不决地转向云计算,因为它听说可能会更昂贵。有哪些方法可以减轻采用云计算的成本风险?
-
什么是云原生架构的例子?绘制一个云原生系统的架构图,并列出关键特性。
-
spot 或抢先实例有什么作用?它们如何节省金钱?它们适用于什么问题?它们不适用于什么问题?
第十章:基础设施即代码
在我们拥有花哨的 DevOps 标题和工作描述之前,我们是卑微的系统管理员,或者简称为 sysadmins。那些是黑暗的,云计算之前的日子,当时我们不得不将我们的车的行李箱装满裸金属服务器,然后开车到一个机房设施,将服务器安装到机架上,连接它们,连接一个有轮子的监视器/键盘/鼠标,然后逐个设置它们。格里格仍然不敢想象他在机房度过的小时,灯光刺眼,空调冷得刺骨。我们必须成为 Bash 脚本的巫师,然后我们毕业到 Perl,我们中更幸运的人到了 Python。俗话说,2004 年左右的互联网是用胶带和泡泡糖粘在一起的。
在 2006 年到 2007 年期间的某个时候,我们发现了亚马逊 EC2 实例的神奇世界。我们能够通过一个简单的点-and-click 接口或通过命令行工具来创建服务器。不再需要开车去机房,不再需要堆叠和连接裸金属服务器。我们可以疯狂地一次性启动 10 个 EC2 实例。甚至 20!甚至 100!天空是极限。然而,我们很快发现,手动连接到每个 EC2 实例,然后在每个实例上单独设置我们的应用程序不会扩展。创建实例本身相当容易。困难的是安装我们应用程序所需的软件包,添加正确的用户,确保文件权限正确,最后安装和配置我们的应用程序。为了解决这个问题,第一代基础架构自动化软件诞生了,“配置管理”工具代表着。Puppet 是第一个知名的配置管理工具,于 2005 年发布,早于 Amazon EC2 的发布。在 Puppet 推出后不久推出的其他类似工具包括 2008 年的 Chef,2011 年的 SaltStack,以及 2012 年的 Ansible。
到了 2009 年,世界准备迎来一个新术语的到来:DevOps。到今天为止,DevOps 有着竞争激烈的定义。有趣的是,它诞生在基础设施软件自动化的动荡早期。虽然 DevOps 中有重要的人和文化方面,但在这一章中有一件事是突出的:即自动化基础架构和应用程序的配置、部署和部署能力。
到了 2011 年,要跟踪亚马逊网络服务(AWS)套件中所有的服务变得越来越困难。云比起原始计算能力(Amazon EC2)和对象存储(Amazon S3)要复杂得多。应用程序开始依赖于相互交互的多个服务,并且需要工具来帮助自动化这些服务的配置。亚马逊没有等待太久就填补了这个需求,2011 年它开始提供这样的工具:AWS CloudFormation。这是我们真正能够通过代码描述基础设施的一个重要时刻之一。CloudFormation 为基础设施即代码(IaC)工具的新一代打开了大门,这些工具操作的是云基础设施层,低于第一代配置管理工具所提供的层次。
到了 2014 年,AWS 推出了数十项服务。那一年,另一个在 IaC 领域中重要的工具诞生了:HashiCorp 的 Terraform。时至今日,CloudFormation 和 Terraform 仍然是最常用的 IaC 工具。
在 IaC 和 DevOps 领域的另一个重要进展发生在 2013 年末到 2014 年初之间:Docker 的发布,它成为容器技术的代名词。尽管容器技术已经存在多年,但 Docker 为此带来的巨大好处在于,它将 Linux 容器和 cgroups 等技术包装成易于使用的 API 和命令行界面(CLI)工具集,大大降低了希望将其应用程序打包成容器并在任何 Docker 运行的地方部署和运行的人们的准入门槛。容器技术和容器编排平台在第十一章和第十二章中有详细讨论。
Docker 的使用率和影响力急剧上升,损害了第一代配置管理工具(Puppet、Chef、Ansible、SaltStack)的流行度。这些工具背后的公司目前正陷入困境,并都在试图通过重塑自身以适应云环境来保持活力和时效性。在 Docker 出现之前,您会使用诸如 CloudFormation 或 Terraform 等 IaC 工具来配置应用程序的基础设施,然后使用配置管理工具(如 Puppet、Chef、Ansible 或 SaltStack)部署应用程序本身(代码和配置)。Docker 突然间使得这些配置管理工具变得过时,因为它提供了一种方式,您可以将应用程序(代码+配置)打包到一个 Docker 容器中,然后在由 IaC 工具配置的基础设施内运行。
基础设施自动化工具分类
快进到 2020 年,作为一名 DevOps 从业者,在面对众多基础设施自动化工具时很容易感到迷失。
区分 IaC 工具的一种方式是看它们运行的层级。CloudFormation 和 Terraform 等工具运行在云基础设施层。它们允许你提供云资源,如计算、存储和网络,以及各种服务,如数据库、消息队列、数据分析等。配置管理工具如 Puppet、Chef、Ansible 和 SaltStack 通常在应用程序层操作,确保为你的应用程序安装所有所需的包,并确保应用程序本身配置正确(尽管这些工具中许多也有可以提供云资源的模块)。Docker 也在应用程序层操作。
另一种比较 IaC 工具的方法是将它们分为声明式和命令式两类。你可以用声明式方式告诉自动化工具要做什么,描述你想要实现的系统状态。Puppet、CloudFormation 和 Terraform 采用声明式方式操作。或者,你可以使用程序化或命令式方式使用自动化工具,指定工具需要执行的确切步骤来实现所需的系统状态。Chef 和 Ansible 采用命令式方式操作。SaltStack 可以同时采用声明式和命令式方式操作。
让我们把系统的期望状态看作建筑物(比如体育场)的建造蓝图。你可以使用 Chef 和 Ansible 这样的程序化工具,逐节、逐行地在每个部分内部建造体育场。你需要跟踪体育场的状态和建造进度。使用 Puppet、CloudFormation 和 Terraform 这样的声明式工具,你首先组装体育场的蓝图。然后工具确保建造达到蓝图中描述的状态。
鉴于本章的标题,我们将把剩下的讨论集中在 IaC 工具上,可以进一步按多个维度进行分类。
一个维度是指定系统期望状态的方式。在 CloudFormation 中,你可以使用 JSON 或 YAML 语法,而在 Terraform 中,你可以使用 HashiCorp Configuration Language (HCL) 语法。相比之下,Pulumi 和 AWS Cloud Development Kit (CDK) 允许你使用真正的编程语言,包括 Python,来指定系统的期望状态。
另一个维度是每个工具支持的云提供商。由于 CloudFormation 是亚马逊的服务,因此它专注于 AWS(尽管在使用自定义资源功能时可以定义非 AWS 资源)。AWS CDK 也是如此。相比之下,Terraform 支持许多云提供商,Pulumi 也是如此。
由于这是一本关于 Python 的书,我们想提到一个名为 troposphere 的工具,它允许你使用 Python 代码指定 CloudFormation 堆栈模板,然后将其导出为 JSON 或 YAML。Troposphere 只负责生成堆栈模板,这意味着你需要使用 CloudFormation 进行堆栈的配置。另一个同样使用 Python 并值得一提的工具是 stacker,它在底层使用 troposphere,但它还可以配置生成的 CloudFormation 堆栈模板。
本章的其余部分展示了两个自动化工具 Terraform 和 Pulumi 的实际操作,它们分别应用于一个常见的场景,即在 Amazon S3 中部署静态网站,该网站由 Amazon CloudFront CDN 托管,并通过 AWS 证书管理器(ACM)服务提供 SSL 证书保护。
注意
在以下示例中使用的某些命令会生成大量输出。除非这些输出对理解命令至关重要,否则我们将省略大部分输出行以节省资源,并帮助你更好地专注于文本内容。
手动配置
我们首先通过 AWS 的 Web 控制台手动完成了一系列操作。没有什么比亲自体验手动操作的痛苦更能让你更好地享受自动化繁琐工作的成果了!
我们首先按照 AWS S3 托管网站 的文档进行操作。
我们已经在 Namecheap 购买了一个域名:devops4all.dev。我们为该域名在 Amazon Route 53 中创建了托管区域,并将该域名在 Namecheap 中的名称服务器指向处理托管域名的 AWS DNS 服务器。
我们创建了两个 S3 存储桶,一个用于站点的根 URL(devops4all.dev),另一个用于 www URL(www.devops4all.dev)。我们的想法是将对 www 的请求重定向到根 URL。我们还按照指南配置了这些存储桶,使其支持静态网站托管,并设置了适当的权限。我们上传了一个 index.html 文件和一张 JPG 图片到根 S3 存储桶。
接下来的步骤是为处理根域名(devops4all.dev)及其任何子域名(*.devops4all.dev)的 SSL 证书进行配置。我们使用了添加到 Route 53 托管区域的 DNS 记录进行验证。
注意
ACM 证书需要在 us-east-1 AWS 区域进行配置,以便在 CloudFront 中使用。
然后我们创建了一个 AWS CloudFront CDN 分发,指向根 S3 存储桶,并使用了前面配置的 ACM 证书。我们指定 HTTP 请求应重定向到 HTTPS。分发部署完成后(大约需要 15 分钟),我们添加了 Route 53 记录,将根域名和 www 域名作为类型为别名的 A 记录,指向 CloudFront 分发终端节点的 DNS 名称。
在完成本练习时,我们能够访问 http://devops4all.dev,自动重定向到 https://devops4all.dev,并看到我们上传的图片显示在站点首页上。我们还尝试访问 http://www.devops4all.dev,并被重定向到 https://devops4all.dev。
我们手动创建所有提到的 AWS 资源大约花了 30 分钟。此外,我们还花了 15 分钟等待 CloudFront 分发的传播,总共是 45 分钟。请注意,我们之前已经做过这些,所以我们几乎完全知道该怎么做,只需要最少量地参考 AWS 指南。
注意
值得一提的是,如今很容易配置免费的 SSL 证书。早已不再需要等待 SSL 证书提供商审批您的请求,并提交证明您的公司存在的时间,只需使用 AWS ACM 或 Let’s Encrypt,2020 年再也没有不应该在站点的所有页面上启用 SSL 的借口。
使用 Terraform 进行自动化基础设施配置
我们决定使用 Terraform 作为首选的 IaC 工具来自动化这些任务,尽管 Terraform 与 Python 没有直接关系。它有几个优点,如成熟性、强大的生态系统和多云供应商。
编写 Terraform 代码的推荐方式是使用模块,这些模块是 Terraform 配置代码的可重用组件。HashiCorp 托管的 Terraform 模块的 注册表 是一个通用的地方,您可以搜索用于配置所需资源的现成模块。在本示例中,我们将编写自己的模块。
此处使用的 Terraform 版本是 0.12.1,在撰写本文时是最新版本。在 Mac 上通过 brew 安装它:
$ brew install terraform
配置一个 S3 存储桶
创建一个 modules 目录,在其下创建一个 s3 目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。s3 目录中的 main.tf 文件告诉 Terraform 创建一个具有特定策略的 S3 存储桶。它使用一个名为 domain_name 的变量,在 variables.tf 中声明,其值由调用此模块的用户传递给它。它输出 S3 存储桶的 DNS 端点,其他模块将使用它作为输入变量。
这里是 modules/s3 中的三个文件:
$ cat modules/s3/main.tf
resource "aws_s3_bucket" "www" {
bucket = "www.${var.domain_name}"
acl = "public-read"
policy = <<POLICY
{
"Version":"2012-10-17",
"Statement":[
{
"Sid":"AddPerm",
"Effect":"Allow",
"Principal": "*",
"Action":["s3:GetObject"],
"Resource":["arn:aws:s3:::www.${var.domain_name}/*"]
}
]
}
POLICY
website {
index_document = "index.html"
}
}
$ cat modules/s3/variables.tf
variable "domain_name" {}
$ cat modules/s3/outputs.tf
output "s3_www_website_endpoint" {
value = "${aws_s3_bucket.www.website_endpoint}"
}
注意
上述 aws_s3_bucket 资源的 policy 属性是允许公共访问该存储桶的 S3 存储桶策略的一个示例。如果您在 IaC 环境中使用 S3 存储桶,请熟悉 官方 AWS 存储桶和用户策略文档。
将所有模块绑定在一起的主要 Terraform 脚本位于当前目录中的名为 main.tf 的文件中:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
它引用了一个定义在名为 variables.tf 的单独文件中的变量:
$ cat variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "domain_name" {
default = "devops4all.dev"
}
这是当前目录树的情况:
|____main.tf
|____variables.tf
|____modules
| |____s3
| | |____outputs.tf
| | |____main.tf
| | |____variables.tf
运行 Terraform 的第一步是调用 terraform init 命令,它将读取主文件引用的任何模块的内容。
接下来的步骤是运行 terraform plan 命令,它创建了前面讨论中提到的蓝图。
要创建计划中指定的资源,请运行 terraform apply 命令:
$ terraform apply
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# module.s3.aws_s3_bucket.www will be created
+ resource "aws_s3_bucket" "www" {
+ acceleration_status = (known after apply)
+ acl = "public-read"
+ arn = (known after apply)
+ bucket = "www.devops4all.dev"
+ bucket_domain_name = (known after apply)
+ bucket_regional_domain_name = (known after apply)
+ force_destroy = false
+ hosted_zone_id= (known after apply)
+ id= (known after apply)
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = [
+ "s3:GetObject",
]
+ Effect = "Allow"
+ Principal = "*"
+ Resource = [
+ "arn:aws:s3:::www.devops4all.dev/*",
]
+ Sid = "AddPerm"
},
]
+ Version= "2012-10-17"
}
)
+ region = (known after apply)
+ request_payer = (known after apply)
+ website_domain= (known after apply)
+ website_endpoint = (known after apply)
+ versioning {
+ enabled = (known after apply)
+ mfa_delete = (known after apply)
}
+ website {
+ index_document = "index.html"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
module.s3.aws_s3_bucket.www: Creating...
module.s3.aws_s3_bucket.www: Creation complete after 7s [www.devops4all.dev]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
此时,请检查是否使用 AWS Web 控制台 UI 创建了 S3 存储桶。
使用 AWS ACM 配置 SSL 证书
下一个模块是为使用 AWS 证书管理器服务进行 SSL 证书配置而创建的。创建一个名为 modules/acm 的目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。acm 目录中的 main.tf 文件告诉 Terraform 使用 DNS 作为验证方法创建 ACM SSL 证书。它使用一个名为 domain_name 的变量,该变量在 variables.tf 中声明,并由调用此模块的调用者传递其值。它输出证书的 ARN 标识符,该标识符将被其他模块用作输入变量。
$ cat modules/acm/main.tf
resource "aws_acm_certificate" "certificate" {
domain_name = "*.${var.domain_name}"
validation_method = "DNS"
subject_alternative_names = ["*.${var.domain_name}"]
}
$ cat modules/acm/variables.tf
variable "domain_name" {
}
$ cat modules/acm/outputs.tf
output "certificate_arn" {
value = "${aws_acm_certificate.certificate.arn}"
}
在主 Terraform 文件中添加对新的 acm 模块的引用:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
接下来的三个步骤与 S3 存储桶创建序列中的步骤相同:terraform init、terraform plan 和 terraform apply。
使用 AWS 控制台添加必要的 Route 53 记录以进行验证过程。证书通常会在几分钟内验证和发布。
配置 Amazon CloudFront 分发
下一个模块是为创建 Amazon CloudFront 分发而创建的。创建一个名为 modules/cloudfront 的目录,其中包含三个文件:main.tf、variables.tf 和 outputs.tf。cloudfront 目录中的 main.tf 文件告诉 Terraform 创建 CloudFront 分发资源。它使用在 variables.tf 中声明的多个变量,这些变量的值由调用此模块的调用者传递。它输出 CloudFront 端点的 DNS 域名和 CloudFront 分发的托管 Route 53 区域 ID,这些将作为其他模块的输入变量使用:
$ cat modules/cloudfront/main.tf
resource "aws_cloudfront_distribution" "www_distribution" {
origin {
custom_origin_config {
// These are all the defaults.
http_port= "80"
https_port = "443"
origin_protocol_policy = "http-only"
origin_ssl_protocols= ["TLSv1", "TLSv1.1", "TLSv1.2"]
}
domain_name = "${var.s3_www_website_endpoint}"
origin_id= "www.${var.domain_name}"
}
enabled = true
default_root_object = "index.html"
default_cache_behavior {
viewer_protocol_policy = "redirect-to-https"
compress = true
allowed_methods= ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "www.${var.domain_name}"
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
}
aliases = ["www.${var.domain_name}"]
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
acm_certificate_arn = "${var.acm_certificate_arn}"
ssl_support_method = "sni-only"
}
}
$ cat modules/cloudfront/variables.tf
variable "domain_name" {}
variable "acm_certificate_arn" {}
variable "s3_www_website_endpoint" {}
$ cat modules/cloudfront/outputs.tf
output "domain_name" {
value = "${aws_cloudfront_distribution.www_distribution.domain_name}"
}
output "hosted_zone_id" {
value = "${aws_cloudfront_distribution.www_distribution.hosted_zone_id}"
}
在主 Terraform 文件中添加对 cloudfront 模块的引用。将 s3_www_website_endpoint 和 acm_certificate_arn 作为输入变量传递给 cloudfront 模块。它们的值从其他模块 s3 和 acm 的输出中获取。
注意
ARN 代表 Amazon 资源名称。它是一个字符串,用于唯一标识给定的 AWS 资源。当您使用在 AWS 内运行的 IaC 工具时,会看到许多生成的 ARN 值作为变量传递和传递。
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
module "cloudfront" {
source = "./modules/cloudfront"
domain_name = "${var.domain_name}"
s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
acm_certificate_arn = "${module.acm.certificate_arn}"
}
接下来的三个步骤是使用 Terraform 进行资源配置的常规步骤:terraform init、terraform plan 和 terraform apply。
在这种情况下,terraform apply 步骤耗时约 23 分钟。创建 Amazon CloudFront 分发是 AWS 中最耗时的操作之一,因为该分发在幕后由 Amazon 在全球范围内部署。
配置 Route 53 DNS 记录
下一个模块是为站点 www.devops4all.dev 的主域创建 Route 53 DNS 记录。创建一个名为 modules/route53 的目录,并包含两个文件:main.tf 和 variables.tf。route53 目录中的 main.tf 文件告诉 Terraform 创建一个类型为 A 的 Route 53 DNS 记录,作为 CloudFront 终端节点的 DNS 名称的别名。它使用在 variables.tf 中声明的几个变量,并通过调用此模块的调用者传递给它的值:
$ cat modules/route53/main.tf
resource "aws_route53_record" "www" {
zone_id = "${var.zone_id}"
name = "www.${var.domain_name}"
type = "A"
alias {
name = "${var.cloudfront_domain_name}"
zone_id = "${var.cloudfront_zone_id}"
evaluate_target_health = false
}
}
$ cat modules/route53/variables.tf
variable "domain_name" {}
variable "zone_id" {}
variable "cloudfront_domain_name" {}
variable "cloudfront_zone_id" {}
在 main.tf Terraform 文件中添加对 route53 模块的引用。将 zone_id、cloudfront_domain_name 和 cloudfront_zone_id 作为输入变量传递给 route53 模块。zone_id 的值在当前目录的 variables.tf 中声明,而其他值则从 cloudfront 模块的输出中检索:
$ cat main.tf
provider "aws" {
region = "${var.aws_region}"
}
module "s3" {
source = "./modules/s3"
domain_name = "${var.domain_name}"
}
module "acm" {
source = "./modules/acm"
domain_name = "${var.domain_name}"
}
module "cloudfront" {
source = "./modules/cloudfront"
domain_name = "${var.domain_name}"
s3_www_website_endpoint = "${module.s3.s3_www_website_endpoint}"
acm_certificate_arn = "${module.acm.certificate_arn}"
}
module "route53" {
source = "./modules/route53"
domain_name = "${var.domain_name}"
zone_id = "${var.zone_id}"
cloudfront_domain_name = "${module.cloudfront.domain_name}"
cloudfront_zone_id = "${module.cloudfront.hosted_zone_id}"
}
$ cat variables.tf
variable "aws_region" {
default = "us-east-1"
}
variable "domain_name" {
default = "devops4all.dev"
}
variable "zone_id" {
default = "ZWX18ZIVHAA5O"
}
接下来的三个步骤,现在对您来说应该非常熟悉了,是使用 Terraform 配置资源的:terraform init、terraform plan 和 terraform apply。
将静态文件复制到 S3
为了测试从头到尾创建静态网站的配置,创建一个名为 index.html 的简单文件,其中包含一个 JPEG 图像,并将这两个文件复制到之前使用 Terraform 配置的 S3 存储桶。确保 AWS_PROFILE 环境变量已设置为 ~/.aws/credentials 文件中已存在的正确值:
$ echo $AWS_PROFILE
gheorghiu-net
$ aws s3 cp static_files/index.html s3://www.devops4all.dev/index.html
upload: static_files/index.html to s3://www.devops4all.dev/index.html
$ aws s3 cp static_files/devops4all.jpg s3://www.devops4all.dev/devops4all.jpg
upload: static_files/devops4all.jpg to s3://www.devops4all.dev/devops4all.jpg
访问 https://www.devops4all.dev/ 并验证您是否可以看到已上传的 JPG 图像。
使用 Terraform 删除所有已配置的 AWS 资源
每当您配置云资源时,都需要注意与其相关的费用。很容易忘记它们,您可能会在月底收到意外的 AWS 账单。确保删除上面配置的所有资源。通过运行 terraform destroy 命令来删除这些资源。还要注意,在运行 terraform destroy 之前需要删除 S3 存储桶的内容,因为 Terraform 不会删除非空桶的内容。
注意
在运行 terraform destroy 命令之前,请确保您不会删除仍可能在生产环境中使用的资源!
使用 Pulumi 自动化基础设施配置
当涉及到 IaC 工具时,Pulumi 是新秀之一。关键词是 new,这意味着它在某些方面仍然有些粗糙,特别是在 Python 支持方面。
Pulumi 允许您通过告诉它使用真正的编程语言来提供所需的基础设施状态,来指定您的基础设施的期望状态。TypeScript 是 Pulumi 支持的第一种语言,但现在也支持 Go 和 Python。
理解在 Python 中使用 Pulumi 编写基础设施自动化代码与使用 AWS 自动化库(如 Boto)之间的区别至关重要。
使用 Pulumi,您的 Python 代码描述了要部署的资源。实际上,您正在创建本章开头讨论的蓝图或状态。这使得 Pulumi 类似于 Terraform,但其主要区别在于 Pulumi 允许您充分利用像 Python 这样的编程语言的全部功能,例如编写函数、循环、使用变量等。您不受 Terraform 的 HCL 等标记语言的限制。Pulumi 结合了声明式方法的强大之处(描述所需的最终状态)与真正编程语言的强大之处。
使用诸如 Boto 之类的 AWS 自动化库,您可以通过编写的代码描述和提供单个 AWS 资源。没有整体蓝图或状态。您需要自行跟踪已提供的资源,并协调其创建和移除。这是自动化工具的命令式或过程化方法。您仍然可以利用编写 Python 代码的优势。
要开始使用 Pulumi,请在他们的网站 pulumi.io 上创建一个免费帐户。然后,您可以在本地计算机上安装pulumi命令行工具。在 Macintosh 上,请使用 Homebrew 安装pulumi。
本地运行的第一个命令是pulumi login:
$ pulumi login
Logged into pulumi.com as griggheo (https://app.pulumi.com/griggheo)
创建 AWS 的新 Pulumi Python 项目
创建一个名为proj1的目录,在该目录中运行pulumi new,并选择aws-python模板。在项目创建的过程中,pulumi会要求您为堆栈命名。称其为staging:
$ mkdir proj1
$ cd proj1
$ pulumi new
Please choose a template: aws-python A minimal AWS Python Pulumi program
This command will walk you through creating a new Pulumi project.
Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.
project name: (proj1)
project description: (A minimal AWS Python Pulumi program)
Created project 'proj1'
stack name: (dev) staging
Created stack 'staging'
aws:region: The AWS region to deploy into: (us-east-1)
Saved config
Your new project is ready to go!
To perform an initial deployment, run the following commands:
1\. virtualenv -p python3 venv
2\. source venv/bin/activate
3\. pip3 install -r requirements.txt
Then, run 'pulumi up'
重要的是要理解 Pulumi 项目与 Pulumi 堆栈之间的区别。项目是您为指定系统所需状态编写的代码,即您希望 Pulumi 提供的资源。堆栈是项目的特定部署。例如,堆栈可以对应于开发、暂存或生产等环境。在接下来的示例中,我们将创建两个 Pulumi 堆栈,一个称为staging,对应于暂存环境,以及稍后的另一个称为prod,对应于生产环境。
这里是由pulumi new命令自动生成的文件,作为aws-python模板的一部分:
$ ls -la
total 40
drwxr-xr-x 7 ggheo staff 224 Jun 13 21:43 .
drwxr-xr-x 11 ggheo staff 352 Jun 13 21:42 ..
-rw------- 1 ggheo staff 12 Jun 13 21:43 .gitignore
-rw-r--r-- 1 ggheo staff 32 Jun 13 21:43 Pulumi.staging.yaml
-rw------- 1 ggheo staff 77 Jun 13 21:43 Pulumi.yaml
-rw------- 1 ggheo staff 184 Jun 13 21:43 __main__.py
-rw------- 1 ggheo staff 34 Jun 13 21:43 requirements.txt
按照pulumi new的输出指示安装virtualenv,然后创建新的virtualenv环境并安装requirements.txt中指定的库:
$ pip3 install virtualenv
$ virtualenv -p python3 venv
$ source venv/bin/activate
(venv) pip3 install -r requirements.txt
注意
在使用pulumi up之前,确保您正在使用预期目标的 AWS 帐户来配置任何 AWS 资源。指定所需 AWS 帐户的一种方法是在当前 shell 中设置AWS_PROFILE环境变量。在我们的情况下,本地*~/.aws/credentials*文件中已设置了名为gheorghiu-net的 AWS 配置文件。
(venv) export AWS_PROFILE=gheorghiu-net
由 Pulumi 作为aws-python模板的一部分生成的*main.py*文件如下:
$ cat __main__.py
import pulumi
from pulumi_aws import s3
# Create an AWS resource (S3 Bucket)
bucket = s3.Bucket('my-bucket')
# Export the name of the bucket
pulumi.export('bucket_name', bucket.id)
本地克隆Pulumi 示例 GitHub 存储库,然后将__main__.py 和pulumi-examples/aws-py-s3-folder中的 www 目录复制到当前目录中。
这里是当前目录中的新__main__.py 文件:
$ cat __main__.py
import json
import mimetypes
import os
from pulumi import export, FileAsset
from pulumi_aws import s3
web_bucket = s3.Bucket('s3-website-bucket', website={
"index_document": "index.html"
})
content_dir = "www"
for file in os.listdir(content_dir):
filepath = os.path.join(content_dir, file)
mime_type, _ = mimetypes.guess_type(filepath)
obj = s3.BucketObject(file,
bucket=web_bucket.id,
source=FileAsset(filepath),
content_type=mime_type)
def public_read_policy_for_bucket(bucket_name):
return json.dumps({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
f"arn:aws:s3:::{bucket_name}/*",
]
}]
})
bucket_name = web_bucket.id
bucket_policy = s3.BucketPolicy("bucket-policy",
bucket=bucket_name,
policy=bucket_name.apply(public_read_policy_for_bucket))
# Export the name of the bucket
export('bucket_name', web_bucket.id)
export('website_url', web_bucket.website_endpoint)
请注意 Python 变量content_dir和bucket_name的使用,for循环的使用,以及正常 Python 函数public_read_policy_for_bucket的使用。能够在 IaC 程序中使用正常的 Python 结构感到耳目一新!
现在是运行pulumi up以提供在__main__.py 中指定的资源的时候了。该命令将展示将要创建的所有资源。将当前选择移动到yes将启动配置过程:
(venv) pulumi up
Previewing update (staging):
Type Name Plan
+ pulumi:pulumi:Stack proj1-staging create
+ ├─ aws:s3:Bucket s3-website-bucket create
+ ├─ aws:s3:BucketObject favicon.png create
+ ├─ aws:s3:BucketPolicy bucket-policy create
+ ├─ aws:s3:BucketObject python.png create
+ └─ aws:s3:BucketObject index.html create
Resources:
+ 6 to create
Do you want to perform this update? yes
Updating (staging):
Type Name Status
+ pulumi:pulumi:Stack proj1-staging created
+ ├─ aws:s3:Bucket s3-website-bucket created
+ ├─ aws:s3:BucketObject index.html created
+ ├─ aws:s3:BucketObject python.png created
+ ├─ aws:s3:BucketObject favicon.png created
+ └─ aws:s3:BucketPolicy bucket-policy created
Outputs:
bucket_name: "s3-website-bucket-8e08f8f"
website_url: "s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com"
Resources:
+ 6 created
Duration: 14s
检查现有的 Pulumi 堆栈:
(venv) pulumi stack ls
NAME LAST UPDATE RESOURCE COUNT URL
staging* 2 minutes ago 7 https://app.pulumi.com/griggheo/proj1/staging
(venv) pulumi stack
Current stack is staging:
Owner: griggheo
Last updated: 3 minutes ago (2019-06-13 22:05:38.088773 -0700 PDT)
Pulumi version: v0.17.16
Current stack resources (7):
TYPE NAME
pulumi:pulumi:Stack proj1-staging
pulumi:providers:aws default
aws:s3/bucket:Bucket s3-website-bucket
aws:s3/bucketPolicy:BucketPolicy bucket-policy
aws:s3/bucketObject:BucketObject index.html
aws:s3/bucketObject:BucketObject favicon.png
aws:s3/bucketObject:BucketObject python.png
检查当前堆栈的输出:
(venv) pulumi stack output
Current stack outputs (2):
OUTPUT VALUE
bucket_name s3-website-bucket-8e08f8f
website_url s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com
访问website_url输出中指定的 URL(http://s3-website-bucket-8e08f8f.s3-website-us-east-1.amazonaws.com),确保您可以看到静态站点。
在接下来的章节中,将通过指定更多的 AWS 资源来增强 Pulumi 项目。目标是与使用 Terraform 配置的资源保持一致:一个 ACM SSL 证书,一个 CloudFront 分发和一个用于站点 URL 的 Route 53 DNS 记录。
为 Staging 堆栈创建配置值
当前堆栈为staging。将现有的 www 目录重命名为 www-staging,然后使用pulumi config set命令为当前staging堆栈指定两个配置值:domain_name和local_webdir。
提示
有关 Pulumi 如何管理配置值和密钥的详细信息,请参阅Pulumi 参考文档。
(venv) mv www www-staging
(venv) pulumi config set local_webdir www-staging
(venv) pulumi config set domain_name staging.devops4all.dev
要检查当前堆栈的现有配置值,请运行:
(venv) pulumi config
KEY VALUE
aws:region us-east-1
domain_name staging.devops4all.dev
local_webdir www-staging
配置值设置完毕后,将它们用于 Pulumi 代码中:
import pulumi
config = pulumi.Config('proj1') # proj1 is project name defined in Pulumi.yaml
content_dir = config.require('local_webdir')
domain_name = config.require('domain_name')
现在配置值已经就位;接下来我们将使用 AWS 证书管理器服务来提供 SSL 证书。
配置 ACM SSL 证书
大约在这一点上,当涉及到其 Python SDK 时,Pulumi 开始显露出其不足之处。仅仅阅读 Pulumi Python SDK 参考中的acm模块并不足以理解您在 Pulumi 程序中需要做的事情。
幸运的是,有许多 Pulumi TypeScript 示例可供您参考。一个展示我们用例的示例是aws-ts-static-website。
这是创建新 ACM 证书的 TypeScript 代码(来自index.ts):
const certificate = new aws.acm.Certificate("certificate", {
domainName: config.targetDomain,
validationMethod: "DNS",
}, { provider: eastRegion });
这是我们编写的相应 Python 代码:
from pulumi_aws import acm
cert = acm.Certificate('certificate', domain_name=domain_name,
validation_method='DNS')
提示
从 TypeScript 转换 Pulumi 代码到 Python 的一个经验法则是,在 TypeScript 中使用驼峰命名法的参数在 Python 中会变成蛇形命名法。正如您在前面的示例中看到的,domainName变成了domain_name,而validationMethod变成了validation_method。

我们的下一步是为 ACM SSL 证书配置 Route 53 区域,并在该区域中为 DNS 验证记录。
配置 Route 53 区域和 DNS 记录
如果您遵循 Pulumi SDK reference for route53,使用 Pulumi 配置新的 Route 53 区域非常容易。
from pulumi_aws import route53
domain_name = config.require('domain_name')
# Split a domain name into its subdomain and parent domain names.
# e.g. "www.example.com" => "www", "example.com".
def get_domain_and_subdomain(domain):
names = domain.split(".")
if len(names) < 3:
return('', domain)
subdomain = names[0]
parent_domain = ".".join(names[1:])
return (subdomain, parent_domain)
(subdomain, parent_domain) = get_domain_and_subdomain(domain_name)
zone = route53.Zone("route53_zone", name=parent_domain)
前面的代码片段显示了如何使用常规 Python 函数将读取的配置值拆分为 domain_name 变量的两部分。如果 domain_name 是 staging.devops4all.dev,则函数将其拆分为 subdomain (staging) 和 parent_domain (devops4all.dev)。
parent_domain 变量然后作为 zone 对象的构造函数的参数使用,告诉 Pulumi 配置 route53.Zone 资源。
注意
创建 Route 53 区域后,我们必须将 Namecheap 的域名服务器指向新区域的 DNS 记录中指定的域名服务器,以便该区域可以公开访问。
到目前为止,一切都很顺利。接下来的步骤是同时创建 ACM 证书和用于验证证书的 DNS 记录。
我们首先尝试通过将 camelCase 参数名转换为 snake_case 的经验法则来移植示例 TypeScript 代码。
TypeScript:
const certificateValidationDomain = new aws.route53.Record(
`${config.targetDomain}-validation`, {
name: certificate.domainValidationOptions[0].resourceRecordName,
zoneId: hostedZoneId,
type: certificate.domainValidationOptions[0].resourceRecordType,
records: [certificate.domainValidationOptions[0].resourceRecordValue],
ttl: tenMinutes,
});
第一次尝试通过将 camelCase 转换为 snake_case 来将示例移植到 Python:
cert = acm.Certificate('certificate',
domain_name=domain_name, validation_method='DNS')
domain_validation_options = cert.domain_validation_options[0]
cert_validation_record = route53.Record(
'cert-validation-record',
name=domain_validation_options.resource_record_name,
zone_id=zone.id,
type=domain_validation_options.resource_record_type,
records=[domain_validation_options.resource_record_value],
ttl=600)
运气不佳。pulumi up 显示如下错误:
AttributeError: 'dict' object has no attribute 'resource_record_name'
在这一点上,我们陷入困境,因为 Python SDK 文档没有包含这么详细的信息。我们不知道在 domain_validation_options 对象中需要指定哪些属性。
我们只能通过在 Pulumi 的导出列表中添加 domain_validation_options 对象来解决此问题,这些对象由 Pulumi 在 pulumi up 操作结束时打印出来。
export('domain_validation_options', domain_validation_options)
pulumi up 的输出如下:
+ domain_validation_options: {
+ domain_name : "staging.devops4all.dev"
+ resourceRecordName : "_c5f82e0f032d0f4f6c7de17fc2c.staging.devops4all.dev."
+ resourceRecordType : "CNAME"
+ resourceRecordValue: "_08e3d475bf3aeda0c98.ltfvzjuylp.acm-validations.aws."
}
终于找到了!原来 domain_validation_options 对象的属性仍然是驼峰命名法。
这是第二次成功移植到 Python 的尝试:
cert_validation_record = route53.Record(
'cert-validation-record',
name=domain_validation_options['resourceRecordName'],
zone_id=zone.id,
type=domain_validation_options['resourceRecordType'],
records=[domain_validation_options['resourceRecordValue']],
ttl=600)
接下来,指定要配置的新类型资源:证书验证完成资源。这使得 pulumi up 操作等待 ACM 通过检查先前创建的 Route 53 验证记录来验证证书。
cert_validation_completion = acm.CertificateValidation(
'cert-validation-completion',
certificate_arn=cert.arn,
validation_record_fqdns=[cert_validation_dns_record.fqdn])
cert_arn = cert_validation_completion.certificate_arn
到此为止,您已经拥有了通过完全自动化的方式配置 ACM SSL 证书并通过 DNS 进行验证的方法。
接下来的步骤是在托管站点静态文件的 S3 存储桶前面配置 CloudFront 分发。
配置 CloudFront 分发
使用 Pulumi cloudfront 模块 的 SDK 参考来确定传递给 cloudfront.Distribution 的构造函数参数。检查 TypeScript 代码以了解这些参数的正确值。
这里是最终结果:
log_bucket = s3.Bucket('cdn-log-bucket', acl='private')
cloudfront_distro = cloudfront.Distribution ( 'cloudfront-distro',
enabled=True,
aliases=[ domain_name ],
origins=[
{
'originId': web_bucket.arn,
'domainName': web_bucket.website_endpoint,
'customOriginConfig': {
'originProtocolPolicy': "http-only",
'httpPort': 80,
'httpsPort': 443,
'originSslProtocols': ["TLSv1.2"],
},
},
],
default_root_object="index.html",
default_cache_behavior={
'targetOriginId': web_bucket.arn,
'viewerProtocolPolicy': "redirect-to-https",
'allowedMethods': ["GET", "HEAD", "OPTIONS"],
'cachedMethods': ["GET", "HEAD", "OPTIONS"],
'forwardedValues': {
'cookies': { 'forward': "none" },
'queryString': False,
},
'minTtl': 0,
'defaultTtl': 600,
'maxTtl': 600,
},
price_class="PriceClass_100",
custom_error_responses=[
{ 'errorCode': 404, 'responseCode': 404,
'responsePagePath': "/404.html" },
],
restrictions={
'geoRestriction': {
'restrictionType': "none",
},
},
viewer_certificate={
'acmCertificateArn': cert_arn,
'sslSupportMethod': "sni-only",
},
logging_config={
'bucket': log_bucket.bucket_domain_name,
'includeCookies': False,
'prefix': domain_name,
})
运行 pulumi up 以配置 CloudFront 分发。
为站点 URL 配置 Route 53 DNS 记录
在端到端为 staging 栈配置资源的最后一步是将 A 类型的 DNS 记录作为 CloudFront 终端节点的域的别名指定。
site_dns_record = route53.Record(
'site-dns-record',
name=subdomain,
zone_id=zone.id,
type="A",
aliases=[
{
'name': cloudfront_distro.domain_name,
'zoneId': cloudfront_distro.hosted_zone_id,
'evaluateTargetHealth': True
}
])
如常运行 pulumi up。
访问 https://staging.devops4all.dev,查看上传到 S3 的文件。转到 AWS 控制台中的日志桶,并确保 CloudFront 日志在那里。
让我们看看如何将相同的 Pulumi 项目部署到一个新的环境,由新的 Pulumi 栈表示。
创建和部署新栈
我们决定修改 Pulumi 程序,使其不再创建新的 Route 53 区域,而是使用现有区域的区域 ID 作为配置值。
要创建 prod 栈,请使用命令 pulumi stack init 并将其名称指定为 prod:
(venv) pulumi stack init
Please enter your desired stack name: prod
Created stack 'prod'
列出栈现在显示了两个栈,staging 和 prod,prod 栈旁边带有星号,表示 prod 是当前栈:
(venv) pulumi stack ls
NAME LAST UPDATE RESOURCE COUNT URL
prod* n/a n/a https://app.pulumi.com/griggheo/proj1/prod
staging 14 minutes ago 14 https://app.pulumi.com/griggheo/proj1/staging
现在是为 prod 栈设置正确配置值的时候了。使用一个新的 dns_zone_id 配置值,设置为在 Pulumi 配置 staging 栈时已创建的区域的 ID:
(venv) pulumi config set aws:region us-east-1
(venv) pulumi config set local_webdir www-prod
(venv) pulumi config set domain_name www.devops4all.dev
(venv) pulumi config set dns_zone_id Z2FTL2X8M0EBTW
更改代码以从配置中读取 zone_id 并且不创建 Route 53 区域对象。
运行 pulumi up 配置 AWS 资源:
(venv) pulumi up
Previewing update (prod):
Type Name Plan
pulumi:pulumi:Stack proj1-prod
+ ├─ aws:cloudfront:Distribution cloudfront-distro create
+ └─ aws:route53:Record site-dns-record create
Resources:
+ 2 to create
10 unchanged
Do you want to perform this update? yes
Updating (prod):
Type Name Status
pulumi:pulumi:Stack proj1-prod
+ ├─ aws:cloudfront:Distribution cloudfront-distro created
+ └─ aws:route53:Record site-dns-record created
Outputs:
+ cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
+ log_bucket_id : "cdn-log-bucket-53d8ea3"
+ web_bucket_id : "s3-website-bucket-cde"
+ website_url : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"
Resources:
+ 2 created
10 unchanged
Duration: 18m54s
成功!prod 栈已完全部署。
然而,此时 www-prod 目录中包含站点静态文件的内容与 www-staging 目录中的内容相同。
修改 www-prod/index.html,将“Hello, S3!”改为“Hello, S3 production!”,然后再次运行 pulumi up 以检测更改并将修改后的文件上传到 S3:
(venv) pulumi up
Previewing update (prod):
Type Name Plan Info
pulumi:pulumi:Stack proj1-prod
~ └─ aws:s3:BucketObject index.html update [diff: ~source]
Resources:
~ 1 to update
11 unchanged
Do you want to perform this update? yes
Updating (prod):
Type Name Status Info
pulumi:pulumi:Stack proj1-prod
~ └─ aws:s3:BucketObject index.html updated [diff: ~source]
Outputs:
cloudfront_domain: "d3uhgbdw67nmlc.cloudfront.net"
log_bucket_id : "cdn-log-bucket-53d8ea3"
web_bucket_id : "s3-website-bucket-cde"
website_url : "s3-website-bucket-cde.s3-website-us-east-1.amazonaws.com"
Resources:
~ 1 updated
11 unchanged
Duration: 4s
使 CloudFront 分发的缓存失效以查看更改。
访问 https://www.devops4all.dev,查看消息:Hello, S3 production!
关于跟踪系统状态的 IaC 工具有一个注意事项:有时工具看到的状态与实际状态不同。在这种情况下,同步两种状态非常重要;否则,它们会越来越分离,你会陷入不敢再进行更改的境地,因为害怕会破坏生产环境。这就是为什么“Code”一词在“基础设施即代码”中占主导地位的原因。一旦决定使用 IaC 工具,最佳实践建议所有资源都通过代码进行配置,不再手动创建任何资源。保持这种纪律是很难的,但长远来看会带来回报。
练习
-
使用 AWS Cloud Development Kit 配置相同的一组 AWS 资源。
-
使用 Terraform 或 Pulumi 从其他云服务提供商(如 Google Cloud Platform 或 Microsoft Azure)配置云资源。
第十一章:容器技术:Docker 和 Docker Compose
虚拟化技术自 IBM 大型机时代就已存在。大多数人没有机会使用过大型机,但我们相信本书的一些读者还记得他们曾经在惠普或戴尔等制造商那里设置或使用裸金属服务器的日子。这些制造商今天仍然存在,你仍然可以在像互联网泡沫时代那样的机房中使用裸金属服务器。
然而,当大多数人想到虚拟化时,他们不会自动想到大型机。相反,他们很可能想象的是在虚拟化管理程序(如 VMware ESX 或 Citrix/Xen)上运行的虚拟机(VM),并且运行了 Fedora 或 Ubuntu 等客户操作系统(OS)。虚拟机相对于普通裸金属服务器的一大优势是,通过使用虚拟机,你可以通过在几个虚拟机之间分割它们来优化服务器的资源(CPU、内存、磁盘)。你还可以在一个共享的裸金属服务器上运行几个操作系统,每个操作系统在自己的虚拟机中运行,而不是为每个目标操作系统购买专用服务器。像亚马逊 EC2 这样的云计算服务如果没有虚拟化管理程序和虚拟机是不可能的。这种类型的虚拟化可以称为内核级,因为每个虚拟机运行其自己的操作系统内核。
在对资源追求更大回报的不懈努力中,人们意识到虚拟机在资源利用上仍然是浪费的。下一个逻辑步骤是将单个应用程序隔离到自己的虚拟环境中。通过在同一个操作系统内核中运行容器来实现这一目标。在这种情况下,它们在文件系统级别被隔离。Linux 容器(LXC)和 Sun Solaris zones 是这种技术的早期示例。它们的缺点是使用起来很困难,并且与它们运行的操作系统紧密耦合。在容器使用方面的重大突破是当 Docker 开始提供一种简便的方法来管理和运行文件系统级容器。
什么是 Docker 容器?
一个 Docker 容器封装了一个应用程序以及它运行所需的其他软件包和库。人们有时会将 Docker 容器和 Docker 镜像的术语互换使用,但它们之间是有区别的。封装应用程序的文件系统级对象称为 Docker 镜像。当你运行该镜像时,它就成为了一个 Docker 容器。
你可以运行许多 Docker 容器,它们都使用相同的操作系统内核。唯一的要求是你必须在要运行容器的主机上安装一个称为 Docker 引擎或 Docker 守护程序的服务器端组件。通过这种方式,主机资源可以在容器之间以更精细的方式分割和利用,让你的投资得到更大的回报。
Docker 容器提供了比常规 Linux 进程更多的隔离和资源控制,但提供的功能不及完整的虚拟机。为了实现这些隔离和资源控制的特性,Docker 引擎利用了 Linux 内核功能,如命名空间、控制组(或 cgroups)和联合文件系统(UnionFS)。
Docker 容器的主要优势是可移植性。一旦创建了 Docker 镜像,您可以在任何安装有 Docker 服务器端守护程序的主机操作系统上作为 Docker 容器运行它。如今,所有主要操作系统都运行 Docker 守护程序:Linux、Windows 和 macOS。
所有这些可能听起来太理论化,所以现在是一些具体示例的时候了。
创建、构建、运行和删除 Docker 镜像和容器
由于这是一本关于 Python 和 DevOps 的书籍,我们将以经典的 Flask “Hello World” 作为在 Docker 容器中运行的应用程序的第一个示例。本节中显示的示例使用 Docker for Mac 包。后续章节将展示如何在 Linux 上安装 Docker。
这是 Flask 应用程序的主文件:
$ cat app.py
from flask import Flask
app = Flask(__name__)
@app.route('/')
def hello_world():
return 'Hello, World! (from a Docker container)'
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')
我们还需要一个需求文件,其中指定了要与 pip 安装的 Flask 包的版本:
$ cat requirements.txt
Flask==1.0.2
在 macOS 笔记本电脑上直接使用 Python 运行 app.py 文件,而不先安装要求,会导致错误:
$ python app.py
Traceback (most recent call last):
File "app.py", line 1, in <module>
from flask import Flask
ImportError: No module named flask
要解决这个问题的一个明显方法是在本地机器上使用 pip 安装需求。这将使一切都与您本地运行的操作系统具体相关。如果应用程序需要部署到运行不同操作系统的服务器上,该怎么办?众所周知的“在我的机器上能运行”的问题可能会出现,即在 macOS 笔记本电脑上一切运行良好,但由于操作系统特定版本的 Python 库,一切在运行其他操作系统(如 Ubuntu 或 Red Hat Linux)的暂存或生产服务器上却突然崩溃。
Docker 为这个难题提供了一个优雅的解决方案。我们仍然可以在本地进行开发,使用我们喜爱的编辑器和工具链,但是我们将应用程序的依赖项打包到一个可移植的 Docker 容器中。
这是描述将要构建的 Docker 镜像的 Dockerfile:
$ cat Dockerfile
FROM python:3.7.3-alpine
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY requirements.txt .
RUN pip install -r requirements.txt
ENTRYPOINT [ "python" ]
CMD [ "app.py" ]
关于这个 Dockerfile 的一些说明:
-
使用基于 Alpine 发行版的 Python 3.7.3 预构建 Docker 镜像,这样可以生成更轻量的 Docker 镜像;这个 Docker 镜像已经包含了诸如
python和pip等可执行文件。 -
使用
pip安装所需的包。 -
指定一个 ENTRYPOINT 和一个 CMD。两者的区别在于,当 Docker 容器运行从这个 Dockerfile 构建的镜像时,它运行的程序是 ENTRYPOINT,后面跟着在 CMD 中指定的任何参数;在本例中,它将运行
python app.py。
注意
如果您没有在您的 Dockerfile 中指定 ENTRYPOINT,则将使用以下默认值:/bin/sh -c。
要为这个应用程序创建 Docker 镜像,运行 docker build:
$ docker build -t hello-world-docker .
要验证 Docker 镜像是否已保存在本地,请运行docker images,然后输入镜像名称:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
要将 Docker 镜像作为 Docker 容器运行,请使用docker run命令:
$ docker run --rm -d -v `pwd`:/app -p 5000:5000 hello-world-docker
c879295baa26d9dff1473460bab810cbf6071c53183890232971d1b473910602
关于docker run命令参数的几点说明:
-
--rm参数告诉 Docker 服务器在停止运行后删除此容器。这对于防止旧容器堵塞本地文件系统非常有用。 -
-d参数告诉 Docker 服务器在后台运行此容器。 -
-v参数指定当前目录(pwd)映射到 Docker 容器内的*/app*目录。这对于我们想要实现的本地开发工作流至关重要,因为它使我们能够在本地编辑应用程序文件,并通过运行在容器内部的 Flask 开发服务器进行自动重新加载。 -
-p 5000:5000参数将本地的第一个端口(5000)映射到容器内部的第二个端口(5000)。
要列出运行中的容器,请运行docker ps并注意容器 ID,因为它将在其他docker命令中使用:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED
c879295baa26 hello-world-docker:latest "python app.py" 4 seconds ago
STATUS PORTS NAMES
Up 2 seconds 0.0.0.0:5000->5000/tcp flamboyant_germain
要检查特定容器的日志,请运行docker logs并指定容器名称或 ID:
$ docker logs c879295baa26
* Serving Flask app "app" (lazy loading)
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 647-161-014
使用curl命中端点 URL 以验证应用程序是否正常工作。由于使用-p命令行标志将运行在 Docker 容器内部的应用程序的端口 5000 映射到本地机器的端口 5000,因此可以使用本地 IP 地址 127.0.0.1 作为应用程序的端点地址。
$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container)%
现在用你喜欢的编辑器修改app.py中的代码。将问候文本更改为Hello, World! (from a Docker container with modified code)。保存app.py并注意 Docker 容器日志中类似以下行:
* Detected change in '/app/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 647-161-014
这表明运行在容器内部的 Flask 开发服务器已检测到app.py中的更改,并重新加载了应用程序。
使用curl命中应用程序端点将显示修改后的问候语:
$ curl http://127.0.0.1:5000
Hello, World! (from a Docker container with modified code)%
要停止运行中的容器,请运行docker stop或docker kill,并指定容器 ID 作为参数:
$ docker stop c879295baa26
c879295baa26
要从本地磁盘中删除 Docker 镜像,请运行docker rmi:
$ docker rmi hello-world-docker
Untagged: hello-world-docker:latest
Deleted:sha256:dbd84c229002950550334224b4b42aba948ce450320a4d8388fa253348126402
Deleted:sha256:6a8f3db7658520a1654cc6abee8eafb463a72ddc3aa25f35ac0c5b1eccdf75cd
Deleted:sha256:aee7c3304ef6ff620956850e0b6e6b1a5a5828b58334c1b82b1a1c21afa8651f
Deleted:sha256:dca8a433d31fa06ab72af63ae23952ff27b702186de8cbea51cdea579f9221e8
Deleted:sha256:cb9d58c66b63059f39d2e70f05916fe466e5c99af919b425aa602091c943d424
Deleted:sha256:f0534bdca48bfded3c772c67489f139d1cab72d44a19c5972ed2cd09151564c1
此输出显示组成 Docker 镜像的不同文件系统层。当删除镜像时,这些层也将被删除。有关 Docker 如何使用文件系统层构建其镜像的详细信息,请参阅Docker 存储驱动程序文档。
发布 Docker 镜像到 Docker 注册表
一旦在本地构建了 Docker 镜像,就可以将其发布到所谓的 Docker 注册表中。有几个公共注册表可供选择,本示例将使用 Docker Hub。这些注册表的目的是允许个人和组织共享可在不同机器和操作系统上重复使用的预构建 Docker 镜像。
首先,在Docker Hub上创建一个免费帐户,然后创建一个仓库,可以是公共的或私有的。我们在griggheo的 Docker Hub 帐户下创建了一个名为flask-hello-world的私有仓库。
然后,在命令行上运行docker login并指定您帐户的电子邮件和密码。此时,您可以通过docker客户端与 Docker Hub 进行交互。
注意
在向 Docker Hub 发布您本地构建的 Docker 镜像之前,我们要指出的最佳实践是使用唯一标签对镜像进行标记。如果不明确打标签,默认情况下镜像将标记为latest。发布不带标签的新镜像版本将把latest标签移至最新镜像版本。在使用 Docker 镜像时,如果不指定所需的确切标签,将获取latest版本的镜像,其中可能包含可能破坏依赖关系的修改和更新。始终应用最小惊讶原则:在推送镜像到注册表时和在 Dockerfile 中引用镜像时都应使用标签。话虽如此,您也可以将所需版本的镜像标记为latest,以便对最新和最伟大的人感兴趣的人使用而不需要指定标签。
在上一节中构建 Docker 镜像时,它会自动标记为latest,并且仓库被设置为镜像的名称,表示该镜像是本地的:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
要为 Docker 镜像打标签,请运行docker tag:
$ docker tag hello-world-docker hello-world-docker:v1
现在你可以看到hello-world-docker镜像的两个标签:
$ docker images hello-world-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world-docker latest dbd84c229002 2 minutes ago 97.7MB
hello-world-docker v1 89bd38cb198f 42 seconds ago 97.7MB
在将hello-world-docker镜像发布到 Docker Hub 之前,您还需要使用包含您的用户名或组织名称的 Docker Hub 仓库名称对其进行标记。在我们的情况下,这个仓库是griggheo/hello-world-docker:
$ docker tag hello-world-docker:latest griggheo/hello-world-docker:latest
$ docker tag hello-world-docker:v1 griggheo/hello-world-docker:v1
使用docker push将两个镜像标签发布到 Docker Hub:
$ docker push griggheo/hello-world-docker:latest
$ docker push griggheo/hello-world-docker:v1
如果您跟随进行,现在应该能够看到您的 Docker 镜像已发布到您在帐户下创建的 Docker Hub 仓库,并带有两个标签。
在不同主机上使用相同镜像运行 Docker 容器
现在 Docker 镜像已发布到 Docker Hub,我们可以展示 Docker 的可移植性,通过在不同主机上基于已发布镜像运行容器来展示。这里考虑的场景是与一位没有 macOS 但喜欢在运行 Fedora 的笔记本上开发的同事合作。该场景包括检出应用程序代码并进行修改。
在 AWS 上启动基于 Linux 2 AMI 的 EC2 实例,该 AMI 基于 RedHat/CentOS/Fedora,并安装 Docker 引擎。将 EC2 Linux AMI 上的默认用户ec2-user添加到docker组,以便可以运行docker客户端命令。
$ sudo yum update -y
$ sudo amazon-linux-extras install docker
$ sudo service docker start
$ sudo usermod -a -G docker ec2-user
确保在远程 EC2 实例上检出应用程序代码。在这种情况下,代码仅包括app.py文件。
接下来,运行基于发布到 Docker Hub 的镜像的 Docker 容器。唯一的区别是,作为docker run命令参数使用的镜像是griggheo/hello-world-docker:v1,而不仅仅是hello-world-docker。
运行docker login,然后:
$ docker run --rm -d -v `pwd`:/app -p 5000:5000 griggheo/hello-world-docker:v1
Unable to find image 'griggheo/hello-world-docker:v1' locally
v1: Pulling from griggheo/hello-world-docker
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
d997915c3f9c: Pull complete
f1fd8d3cc5a4: Pull complete
10b64b1c3b21: Pull complete
Digest: sha256:af8b74f27a0506a0c4a30255f7ff563c9bf858735baa610fda2a2f638ccfe36d
Status: Downloaded newer image for griggheo/hello-world-docker:v1
9d67dc321ffb49e5e73a455bd80c55c5f09febc4f2d57112303d2b27c4c6da6a
请注意,EC2 实例上的 Docker 引擎会意识到本地没有 Docker 镜像,因此会从 Docker Hub 下载镜像,然后基于新下载的镜像运行容器。
在此时,通过向与 EC2 实例关联的安全组添加规则来授予对端口 5000 的访问权限。访问 http://54.187.189.51:5000¹(其中 54.187.189.51 是 EC2 实例的外部 IP)并查看问候语Hello, World! (from a Docker container with modified code)。
在远程 EC2 实例上修改应用程序代码时,运行在 Docker 容器内部的 Flask 服务器将自动重新加载修改后的代码。将问候语更改为Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance),并注意 Flask 服务器通过检查 Docker 容器的日志重新加载了应用程序:
[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker ps
CONTAINER ID IMAGE COMMAND CREATED
9d67dc321ffb griggheo/hello-world-docker:v1 "python app.py" 3 minutes ago
STATUS PORTS NAMES
Up 3 minutes 0.0.0.0:5000->5000/tcp heuristic_roentgen
[ec2-user@ip-10-0-0-111 hello-world-docker]$ docker logs 9d67dc321ffb
* Serving Flask app "app" (lazy loading)
* Debug mode: on
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 306-476-204
72.203.107.13 - - [19/Aug/2019 04:43:34] "GET / HTTP/1.1" 200 -
72.203.107.13 - - [19/Aug/2019 04:43:35] "GET /favicon.ico HTTP/1.1" 404 -
* Detected change in '/app/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 306-476-204
点击 http://54.187.189.51:5000²现在显示新的问候语Hello, World! (from a Docker container on an EC2 Linux 2 AMI instance)。
值得注意的是,为了使我们的应用程序运行,我们没有必要安装任何与 Python 或 Flask 相关的东西。通过简单地在容器内运行我们的应用程序,我们能够利用 Docker 的可移植性。Docker 选择“容器”作为技术的名字并不是没有原因的,其中一个灵感来源于运输容器如何革命了全球运输行业。
提示
阅读“Production-ready Docker images”一书,由 Itamar Turner-Trauring 编写,涵盖了关于 Python 应用程序 Docker 容器打包的大量文章。
使用 Docker Compose 运行多个 Docker 容器
在本节中,我们将使用“Flask By Example”教程,该教程描述了如何构建一个 Flask 应用程序,根据给定 URL 的文本计算单词频率对。
首先克隆Flask By Example GitHub 存储库:
$ git clone https://github.com/realpython/flask-by-example.git
我们将使用compose来运行代表示例应用程序不同部分的多个 Docker 容器。使用 Compose,您可以使用 YAML 文件定义和配置组成应用程序的服务,然后使用docker-compose命令行实用程序来创建、启动和停止这些将作为 Docker 容器运行的服务。
示例应用程序的第一个依赖项是 PostgreSQL,在教程第二部分中有描述。
这是如何在docker-compose.yaml文件中运行 PostgreSQL Docker 容器的方法:
$ cat docker-compose.yaml
version: "3"
services:
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
关于这个文件的几个注意事项:
-
定义一个名为
db的服务,基于在 Docker Hub 上发布的postgres:11镜像。 -
将本地端口 5432 映射到容器端口 5432。
-
为 PostgreSQL 存储其数据的目录(/var/lib/postgresql/data)指定一个 Docker 卷。这样做是为了确保 PostgreSQL 中存储的数据在容器重新启动后仍然存在。
docker-compose工具不是 Docker 引擎的一部分,因此需要单独安装。请参阅官方文档以获取在各种操作系统上安装的说明。
要启动docker-compose.yaml中定义的db服务,请运行docker-compose up -d db命令,该命令将在后台启动db服务的 Docker 容器(使用了-d标志)。
$ docker-compose up -d db
Creating postgres ... done
使用docker-compose logs db命令检查db服务的日志:
$ docker-compose logs db
Creating volume "flask-by-example_dbdata" with default driver
Pulling db (postgres:11)...
11: Pulling from library/postgres
Creating postgres ... done
Attaching to postgres
postgres | PostgreSQL init process complete; ready for start up.
postgres |
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG: listening on IPv4 address "0.0.0.0", port 5432
postgres | 2019-07-11 21:50:20.987 UTC [1]
LOG: listening on IPv6 address "::", port 5432
postgres | 2019-07-11 21:50:20.993 UTC [1]
LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres | 2019-07-11 21:50:21.009 UTC [51]
LOG: database system was shut down at 2019-07-11 21:50:20 UTC
postgres | 2019-07-11 21:50:21.014 UTC [1]
LOG: database system is ready to accept connections
运行docker ps命令可以显示运行 PostgreSQL 数据库的容器:
$ docker ps
dCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
83b54ab10099 postgres:11 "docker-entrypoint.s…" 3 minutes ago Up 3 minutes
0.0.0.0:5432->5432/tcp postgres
运行docker volume ls命令显示已为 PostgreSQL /var/lib/postgresql/data目录挂载的dbdata Docker 卷:
$ docker volume ls | grep dbdata
local flask-by-example_dbdata
要连接到运行在与db服务相关联的 Docker 容器中的 PostgreSQL 数据库,运行命令docker-compose exec db并传递psql -U postgres命令行:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=#
参照“Flask by Example, Part 2”,创建一个名为wordcount的数据库:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=# create database wordcount;
CREATE DATABASE
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+--------+----------+----------+----------+--------------------
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | |postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | |postgres=CTc/postgres
wordcount| postgres | UTF8| en_US.utf8 | en_US.utf8 |
(4 rows)
postgres=# \q
连接到wordcount数据库并创建一个名为wordcount_dbadmin的角色,该角色将被 Flask 应用程序使用:
$ docker-compose exec db psql -U postgres wordcount
wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
postgres=# \q
下一步是为 Flask 应用程序创建一个 Dockerfile,安装所有的先决条件。
对requirements.txt文件进行以下修改:
-
将
psycopg2包的版本从2.6.1修改为2.7以支持 PostgreSQL 11。 -
将
redis包的版本从2.10.5修改为3.2.1以提供更好的 Python 3.7 支持。 -
将
rq包的版本从0.5.6修改为1.0以提供更好的 Python 3.7 支持。
以下是 Dockerfile 的内容:
$ cat Dockerfile
FROM python:3.7.3-alpine
ENV APP_HOME /app
WORKDIR $APP_HOME
COPY requirements.txt .
RUN \
apk add --no-cache postgresql-libs && \
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev && \
python3 -m pip install -r requirements.txt --no-cache-dir && \
apk --purge del .build-deps
COPY . .
ENTRYPOINT [ "python" ]
CMD ["app.py"]
注意
这个 Dockerfile 与第一个hello-world-docker示例中使用的版本有一个重要的区别。这里将当前目录的内容(包括应用程序文件)复制到 Docker 镜像中。这样做是为了展示与之前开发工作流不同的场景。在这种情况下,我们更关注以最便携的方式运行应用程序,例如在暂存或生产环境中,我们不希望像在开发场景中通过挂载卷来修改应用程序文件。在开发目的上通常可以使用docker-compose与本地挂载卷,但本节重点是讨论 Docker 容器在不同环境(如开发、暂存和生产)中的可移植性。
运行docker build -t flask-by-example:v1 .来构建一个本地 Docker 镜像。由于该命令的输出内容相当长,因此这里不显示。
“Flask By Example”教程的下一步是运行 Flask 迁移。
在docker-compose.yaml文件中,定义一个名为migrations的新服务,并指定其image、command、environment变量以及它依赖于db服务正在运行的事实:
$ cat docker-compose.yaml
version: "3"
services:
migrations:
image: "flask-by-example:v1"
command: "manage.py db upgrade"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
depends_on:
- db
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
volumes:
dbdata:
DATABASE_URL变量使用db作为 PostgreSQL 数据库主机的名称。这是因为在docker-compose.yaml文件中将db定义为服务名称,并且docker-compose知道如何通过创建一个覆盖网络使所有在docker-compose.yaml文件中定义的服务能够通过其名称互相交互。有关更多详细信息,请参阅docker-compose 网络参考。
DATABASE_URL变量定义引用了另一个名为DBPASS的变量,而不是直接硬编码wordcount_dbadmin用户的密码。docker-compose.yaml文件通常提交到源代码控制,最佳实践是不要将诸如数据库凭据之类的机密信息提交到 GitHub。相反,使用诸如sops之类的加密工具管理密钥文件。
这里是使用sops和 PGP 加密创建加密文件的示例。
首先,在 macOS 上通过brew install gpg安装gpg,然后使用空密码生成新的 PGP 密钥:
$ gpg --generate-key
pub rsa2048 2019-07-12 [SC] [expires: 2021-07-11]
E14104A0890994B9AC9C9F6782C1FF5E679EFF32
uid pydevops <my.email@gmail.com>
sub rsa2048 2019-07-12 [E] [expires: 2021-07-11]
接下来,从其发布页面下载sops。
要创建一个名为environment.secrets的新加密文件,例如,运行带有-pgp标志的sops并提供上述生成的密钥的指纹:
$ sops --pgp BBDE7E57E00B98B3F4FBEAF21A1EEF4263996BD0 environment.secrets
这将打开默认编辑器,并允许输入纯文本密钥。在此示例中,environment.secrets文件的内容是:
export DBPASS=MYPASS
保存environment.secrets文件后,请检查文件以确保其已加密,这样可以安全地添加到源代码控制中:
$ cat environment.secrets
{
"data": "ENC[AES256_GCM,data:qlQ5zc7e8KgGmu5goC9WmE7PP8gueBoSsmM=,
iv:xG8BHcRfdfLpH9nUlTijBsYrh4TuSdvDqp5F+2Hqw4I=,
tag:0OIVAm9O/UYGljGCzZerTQ==,type:str]",
"sops": {
"kms": null,
"gcp_kms": null,
"lastmodified": "2019-07-12T05:03:45Z",
"mac": "ENC[AES256_GCM,data:wo+zPVbPbAJt9Nl23nYuWs55f68/DZJWj3pc0
l8T2d/SbuRF6YCuOXHSHIKs1ZBpSlsjmIrPyYTqI+M4Wf7it7fnNS8b7FnclwmxJjptBWgL
T/A1GzIKT1Vrgw9QgJ+prq+Qcrk5dPzhsOTxOoOhGRPsyN8KjkS4sGuXM=,iv:0VvSMgjF6
ypcK+1J54fonRoI7c5whmcu3iNV8xLH02k=,
tag:YaI7DXvvllvpJ3Talzl8lg==,
type:str]",
"pgp": [
{
"created_at": "2019-07-12T05:02:24Z",
"enc": "-----BEGIN PGP MESSAGE-----\n\nhQEMA+3cyc
g5b/Hu0OvU5ONr/F0htZM2MZQSXpxoCiO\nWGB5Czc8FTSlRSwu8/cOx0Ch1FwH+IdLwwL+jd
oXVe55myuu/3OKUy7H1w/W2R\nPI99Biw1m5u3ir3+9tLXmRpLWkz7+nX7FThl9QnOS25
NRUSSxS7hNaZMcYjpXW+w\nM3XeaGStgbJ9OgIp4A8YGigZQVZZFl3fAG3bm2c+TNJcAbl
zDpc40fxlR+7LroJI\njuidzyOEe49k0pq3tzqCnph5wPr3HZ1JeQmsIquf//9D509S5xH
Sa9lkz3Y7V4KC\nefzBiS8pivm55T0s+zPBPB/GWUVlqGaxRhv1TAU=\n=WA4+
\n-----END PGP MESSAGE-----\n",
"fp": "E14104A0890994B9AC9C9F6782C1FF5E679EFF32"
}
],
"unencrypted_suffix": "_unencrypted",
"version": "3.0.5"
}
}%
要解密文件,请运行:
$ sops -d environment.secrets
export DBPASS=MYPASS
注意
在 Macintosh 上使用sops与gpg交互存在问题。在能够使用sops解密文件之前,您需要运行以下命令:
$ GPG_TTY=$(tty)
$ export GPG_TTY
这里的目标是运行先前在docker-compose.yaml文件中定义的migrations服务。为了将sops密钥管理方法集成到docker-compose中,使用sops -d解密environments.secrets文件,将其内容源化到当前 shell 中,然后使用一个命令调用docker-compose up -d migrations,该命令不会将密钥暴露给 shell 历史记录:
$ source <(sops -d environment.secrets); docker-compose up -d migrations
postgres is up-to-date
Recreating flask-by-example_migrations_1 ... done
通过检查数据库并验证是否创建了两个表alembic_version和results来验证迁移是否成功运行:
$ docker-compose exec db psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
wordcount=# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+-------------------
public | alembic_version | table | wordcount_dbadmin
public | results | table | wordcount_dbadmin
(2 rows)
wordcount=# \q
第四部分 在“Flask 实例”教程中是部署一个基于 Python RQ 的 Python 工作进程,该进程与 Redis 实例通信。
首先,需要运行 Redis。将其作为名为redis的服务添加到docker_compose.yaml文件中,并确保其内部端口 6379 映射到本地操作系统的端口 6379:
redis:
image: "redis:alpine"
ports:
- "6379:6379"
通过将其作为参数指定给 docker-compose up -d 单独启动 redis 服务:
$ docker-compose up -d redis
Starting flask-by-example_redis_1 ... done
运行 docker ps 查看基于 redis:alpine 镜像运行的新 Docker 容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a1555cc372d6 redis:alpine "docker-entrypoint.s…" 3 seconds ago Up 1 second
0.0.0.0:6379->6379/tcp flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 22 hours ago Up 16 hours
0.0.0.0:5432->5432/tcp postgres
使用 docker-compose logs 命令检查 redis 服务的日志:
$ docker-compose logs redis
Attaching to flask-by-example_redis_1
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections
下一步是在 docker-compose.yaml 中为 Python RQ 工作进程创建一个名为 worker 的服务:
worker:
image: "flask-by-example:v1"
command: "worker.py"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
运行工作服务,就像redis服务一样,使用docker-compose up -d:
$ docker-compose up -d worker
flask-by-example_redis_1 is up-to-date
Starting flask-by-example_worker_1 ... done
运行 docker ps 将显示工作容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
72327ab33073 flask-by-example "python worker.py" 8 minutes ago
Up 14 seconds flask-by-example_worker_1
b11b03a5bcc3 redis:alpine "docker-entrypoint.s…" 15 minutes ago
Up About a minute 0.0.0.0:6379->6379/tc flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 23 hours ago
Up 17 hours 0.0.0.0:5432->5432/tcp postgres
使用docker-compose logs查看工作容器日志:
$ docker-compose logs worker
Attaching to flask-by-example_worker_1
20:46:34 RQ worker 'rq:worker:a66ca38275a14cac86c9b353e946a72e' started,
version 1.0
20:46:34 *** Listening on default...
20:46:34 Cleaning registries for queue: default
现在在自己的容器中启动主 Flask 应用程序。在 docker-compose.yaml 中创建一个名为 app 的新服务:
app:
image: "flask-by-example:v1"
command: "manage.py runserver --host=0.0.0.0"
ports:
- "5000:5000"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
将应用程序容器中的端口 5000(Flask 应用程序的默认端口)映射到本地机器的端口 5000。在应用程序容器中传递命令 manage.py runserver --host=0.0.0.0,以确保 Flask 应用程序在容器内正确地暴露端口 5000。
使用 docker compose up -d 启动 app 服务,同时在包含 DBPASS 的加密文件上运行 sops -d,然后在调用 docker-compose 之前源化解密文件:
source <(sops -d environment.secrets); docker-compose up -d app
postgres is up-to-date
Recreating flask-by-example_app_1 ... done
注意到新的 Docker 容器正在运行应用程序,列表由 docker ps 返回:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
d99168a152f1 flask-by-example "python app.py" 3 seconds ago
Up 2 seconds 0.0.0.0:5000->5000/tcp flask-by-example_app_1
72327ab33073 flask-by-example "python worker.py" 16 minutes ago
Up 7 minutes flask-by-example_worker_1
b11b03a5bcc3 redis:alpine "docker-entrypoint.s…" 23 minutes ago
Up 9 minutes 0.0.0.0:6379->6379/tcp flask-by-example_redis_1
83b54ab10099 postgres:11 "docker-entrypoint.s…" 23 hours ago
Up 17 hours 0.0.0.0:5432->5432/tcp postgres
使用 docker-compose logs 检查应用程序容器的日志:
$ docker-compose logs app
Attaching to flask-by-example_app_1
app_1 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
运行 docker-compose logs 而不带其他参数允许我们检查 docker-compose.yaml 文件中定义的所有服务的日志:
$ docker-compose logs
Attaching to flask-by-example_app_1,
flask-by-example_worker_1,
flask-by-example_migrations_1,
flask-by-example_redis_1,
postgres
1:C 12 Jul 2019 20:17:12.966 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 12 Jul 2019 20:17:12.966 # Redis version=5.0.5, bits=64, commit=00000000,
modified=0, pid=1, just started
1:C 12 Jul 2019 20:17:12.966 # Warning: no config file specified, using the
default config. In order to specify a config file use
redis-server /path/to/redis.conf
1:M 12 Jul 2019 20:17:12.967 * Running mode=standalone, port=6379.
1:M 12 Jul 2019 20:17:12.967 # WARNING: The TCP backlog setting of 511 cannot
be enforced because /proc/sys/net/core/somaxconn
is set to the lower value of 128.
1:M 12 Jul 2019 20:17:12.967 # Server initialized
1:M 12 Jul 2019 20:17:12.967 * Ready to accept connections
app_1 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
postgres | 2019-07-12 22:15:19.193 UTC [1]
LOG: listening on IPv4 address "0.0.0.0", port 5432
postgres | 2019-07-12 22:15:19.194 UTC [1]
LOG: listening on IPv6 address "::", port 5432
postgres | 2019-07-12 22:15:19.199 UTC [1]
LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
postgres | 2019-07-12 22:15:19.214 UTC [22]
LOG: database system was shut down at 2019-07-12 22:15:09 UTC
postgres | 2019-07-12 22:15:19.225 UTC [1]
LOG: database system is ready to accept connections
migrations_1 | INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
migrations_1 | INFO [alembic.runtime.migration] Will assume transactional DDL.
worker_1 | 22:15:20
RQ worker 'rq:worker:2edb6a54f30a4aae8a8ca2f4a9850303' started, version 1.0
worker_1 | 22:15:20 *** Listening on default...
worker_1 | 22:15:20 Cleaning registries for queue: default
最后一步是测试应用程序。访问 http://127.0.0.1:5000 并在 URL 字段中输入 python.org。此时,应用程序向工作进程发送一个作业,要求其对 python.org 的主页执行函数 count_and_save_words。应用程序定期轮询作业以获取结果,完成后在主页上显示单词频率。
为了使 docker-compose.yaml 文件更具可移植性,将 flask-by-example Docker 镜像推送到 Docker Hub,并在 app 和 worker 服务的容器部分引用 Docker Hub 镜像。
使用 Docker Hub 用户名前缀为现有的本地 Docker 镜像 flask-by-example:v1 打标签,然后将新标记的镜像推送到 Docker Hub:
$ docker tag flask-by-example:v1 griggheo/flask-by-example:v1
$ docker push griggheo/flask-by-example:v1
修改 docker-compose.yaml 以引用新的 Docker Hub 镜像。以下是 docker-compose.yaml 的最终版本:
$ cat docker-compose.yaml
version: "3"
services:
app:
image: "griggheo/flask-by-example:v1"
command: "manage.py runserver --host=0.0.0.0"
ports:
- "5000:5000"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
worker:
image: "griggheo/flask-by-example:v1"
command: "worker.py"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
REDISTOGO_URL: redis://redis:6379
depends_on:
- db
- redis
migrations:
image: "griggheo/flask-by-example:v1"
command: "manage.py db upgrade"
environment:
APP_SETTINGS: config.ProductionConfig
DATABASE_URL: postgresql://wordcount_dbadmin:$DBPASS@db/wordcount
depends_on:
- db
db:
image: "postgres:11"
container_name: "postgres"
ports:
- "5432:5432"
volumes:
- dbdata:/var/lib/postgresql/data
redis:
image: "redis:alpine"
ports:
- "6379:6379"
volumes:
dbdata:
要重新启动本地 Docker 容器,请运行 docker-compose down,然后跟着 docker-compose up -d:
$ docker-compose down
Stopping flask-by-example_worker_1 ... done
Stopping flask-by-example_app_1 ... done
Stopping flask-by-example_redis_1 ... done
Stopping postgres ... done
Removing flask-by-example_worker_1 ... done
Removing flask-by-example_app_1 ... done
Removing flask-by-example_migrations_1 ... done
Removing flask-by-example_redis_1 ... done
Removing postgres ... done
Removing network flask-by-example_default
$ source <(sops -d environment.secrets); docker-compose up -d
Creating network "flask-by-example_default" with the default driver
Creating flask-by-example_redis_1 ... done
Creating postgres ... done
Creating flask-by-example_migrations_1 ... done
Creating flask-by-example_worker_1 ... done
Creating flask-by-example_app_1 ... done
注意使用 docker-compose 轻松启动和关闭一组 Docker 容器。
提示
即使您只想运行单个 Docker 容器,将其包含在 docker-compose.yaml 文件中并使用 docker-compose up -d 命令启动它仍然是个好主意。当您想要添加第二个容器时,这将使您的生活更加轻松,并且还将作为基础设施即代码的一个小例子,docker-compose.yaml 文件反映了您的应用程序的本地 Docker 设置状态。
将 docker-compose 服务迁移到新主机和操作系统
现在我们将展示如何将前一节的 docker-compose 设置迁移到运行 Ubuntu 18.04 的服务器。
启动运行 Ubuntu 18.04 的 Amazon EC2 实例并安装 docker-engine 和 docker-compose:
$ sudo apt-get update
$ sudo apt-get remove docker docker-engine docker.io containerd runc
$ sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg-agent \
software-properties-common
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
$ sudo add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) \
stable"
$ sudo apt-get update
$ sudo apt-get install docker-ce docker-ce-cli containerd.io
$ sudo usermod -a -G docker ubuntu
# download docker-compose
$ sudo curl -L \
"https://github.com/docker/compose/releases/download/1.24.1/docker-compose-\
$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
将 docker-compose.yaml 文件复制到远程 EC2 实例并首先启动 db 服务,以便可以创建应用程序使用的数据库:
$ docker-compose up -d db
Starting postgres ...
Starting postgres ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49fe88efdb45 postgres:11 "docker-entrypoint.s…" 29 seconds ago
Up 3 seconds 0.0.0.0:5432->5432/tcp postgres
使用 docker exec 在正在运行的 Docker 容器中运行 psql -U postgres 命令访问 PostgreSQL 数据库。在 PostgreSQL 提示符下,创建 wordcount 数据库和 wordcount_dbadmin 角色:
$ docker-compose exec db psql -U postgres
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
postgres=# create database wordcount;
CREATE DATABASE
postgres=# \q
$ docker exec -it 49fe88efdb45 psql -U postgres wordcount
psql (11.4 (Debian 11.4-1.pgdg90+1))
Type "help" for help.
wordcount=# CREATE ROLE wordcount_dbadmin;
CREATE ROLE
wordcount=# ALTER ROLE wordcount_dbadmin LOGIN;
ALTER ROLE
wordcount=# ALTER USER wordcount_dbadmin PASSWORD 'MYPASS';
ALTER ROLE
wordcount=# \q
在启动在 docker-compose.yaml 中定义的服务的容器之前,有两件事是必需的:
-
运行
docker login以能够拉取之前推送到 Docker Hub 的 Docker 镜像:$ docker login -
在当前 Shell 中设置
DBPASS环境变量的正确值。在本地 macOS 设置中描述的sops方法可用,但是在本示例中,直接在 Shell 中设置:$ export DOCKER_PASS=MYPASS
现在通过运行 docker-compose up -d 启动应用程序所需的所有服务:
$ docker-compose up -d
Pulling worker (griggheo/flask-by-example:v1)...
v1: Pulling from griggheo/flask-by-example
921b31ab772b: Already exists
1a0c422ed526: Already exists
ec0818a7bbe4: Already exists
b53197ee35ff: Already exists
8b25717b4dbf: Already exists
9be5e85cacbb: Pull complete
bd62f980b08d: Pull complete
9a89f908ad0a: Pull complete
d787e00a01aa: Pull complete
Digest: sha256:4fc554da6157b394b4a012943b649ec66c999b2acccb839562e89e34b7180e3e
Status: Downloaded newer image for griggheo/flask-by-example:v1
Creating fbe_redis_1 ... done
Creating postgres ... done
Creating fbe_migrations_1 ... done
Creating fbe_app_1 ... done
Creating fbe_worker_1 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f65fe9631d44 griggheo/flask-by-example:v1 "python3 manage.py r…" 5 seconds ago
Up 2 seconds 0.0.0.0:5000->5000/tcp fbe_app_1
71fc0b24bce3 griggheo/flask-by-example:v1 "python3 worker.py" 5 seconds ago
Up 2 seconds fbe_worker_1
a66d75a20a2d redis:alpine "docker-entrypoint.s…" 7 seconds ago
Up 5 seconds 0.0.0.0:6379->6379/tcp fbe_redis_1
56ff97067637 postgres:11 "docker-entrypoint.s…" 7 seconds ago
Up 5 seconds 0.0.0.0:5432->5432/tcp postgres
此时,在允许 AWS 安全组中与我们的 Ubuntu EC2 实例关联的端口 5000 的访问后,您可以在该实例的外部 IP 上的 5000 端口访问并使用该应用程序。
再次强调一下 Docker 简化应用部署的重要性。Docker 容器和镜像的可移植性意味着您可以在任何安装了 Docker 引擎的操作系统上运行您的应用程序。在这里展示的示例中,在 Ubuntu 服务器上不需要安装任何先决条件:不需要 Flask,不需要 PostgreSQL,也不需要 Redis。也不需要将应用程序代码从本地开发机器复制到 Ubuntu 服务器上。在 Ubuntu 服务器上唯一需要的文件是 docker-compose.yaml。然后,只需一条命令就可以启动应用程序的整套服务:
$ docker-compose up -d
提示
警惕从公共 Docker 仓库下载和使用 Docker 镜像,因为其中许多镜像存在严重的安全漏洞,其中最严重的可以允许攻击者突破 Docker 容器的隔离性并接管主机操作系统。一个良好的实践是从一个受信任的、预构建的镜像开始,或者从头开始构建你自己的镜像。随时关注最新的安全补丁和软件更新,并在这些补丁或更新可用时重新构建你的镜像。另一个良好的实践是使用众多可用的 Docker 扫描工具之一(其中包括 Clair、Anchore 和 Falco)扫描所有的 Docker 镜像。这样的扫描可以作为持续集成/持续部署流水线的一部分进行,通常在构建 Docker 镜像时执行。
尽管 docker-compose 可以轻松地运行多个容器化服务作为同一应用的一部分,但它只适用于单台机器,这在生产环境中的实用性受到限制。如果你不担心停机时间并愿意在单台机器上运行所有内容,那么只能认为使用 docker-compose 部署的应用程序是“生产就绪”的(尽管如此,格里格看到一些托管提供者使用 docker-compose 在生产环境中运行 Docker 化应用程序)。对于真正的“生产就绪”场景,你需要一个像 Kubernetes 这样的容器编排引擎,这将在下一章讨论。
练习
-
熟悉 Dockerfile 参考。
-
创建一个 AWS KMS 密钥,并在
sops中使用它,而不是本地的PGP密钥。这允许你将 AWS IAM 权限应用到密钥上,并将对密钥的访问限制为仅需要的开发人员。 -
编写一个 shell 脚本,使用
docker exec或docker-compose exec来运行 PostgreSQL 命令,创建数据库和角色。 -
尝试其他容器技术,例如 Podman。
¹ 这是一个示例 URL 地址——你的 IP 地址将会不同。
² 你的 IP 地址将会不同。






