基础设施即代码初学者指南-全-
基础设施即代码初学者指南(全)
原文:
annas-archive.org/md5/aa6e809d34512fcc23974e3f2a132ac6译者:飞龙
前言
欢迎来到 基础设施即代码入门,这是一本帮助你通过代码管理和部署基础设施的指南。本书将为你提供在这个不断变化的领域中成功所需的核心概念、工具和技术的坚实基础。
在各个章节中,你将获得与流行的基础设施即代码工具(如 Terraform 和 Ansible)的实践经验,学习如何规划和部署资源到领先的公共云供应商(如 Microsoft Azure 和 Amazon Web Services),并深入探讨最佳实践和故障排除策略,帮助你克服挑战并优化你的部署。
随着你深入阅读本书,你将探讨持续集成和持续部署(CI/CD)在使用 GitHub Actions 自动化基础设施即代码项目中的作用。你将学习如何利用 CI/CD 创建一致且可靠的部署,并实施安全实践来保护你的部署。
此外,你将通过探索 Pulumi、Azure Bicep 和 AWS CloudFormation 等替代工具,扩展你的基础设施即代码工具集,从而增强对特定供应商和云中立选项的理解。
在这段学习旅程结束时,你将获得规划、构建、部署和管理你的基础设施即代码项目的知识和信心,使你能够创建高效、可扩展和可靠的基础设施解决方案,支持你的项目和职业生涯多年。
本书适合谁阅读
本书专为那些有手动部署资源经验以托管应用程序的开发人员和系统管理员量身定制,他们现在希望通过自动化创建和管理基础设施及应用程序,提升自己的技能。
本书将赋能那些有大量手动部署和配置资源经验的读者,他们现在希望简化流程,提高效率和一致性,并将基础设施即代码集成到他们的日常工作流程中。
本书涵盖的内容
在 第一章**,《选择正确的方法—声明式还是命令式》 中,我们介绍了基础设施即代码的基本概念,讨论了手动基础设施管理的挑战、声明式与命令式方法的区别,以及 宠物与牲畜 的类比,为理解其在现代部署中的重要性奠定了基础。
在 第二章**,《超越文档的 Ansible 和 Terraform》 中,我们深入探讨 HashiCorp 提供的基础设施即代码工具 Terraform 和 Red Hat 提供的配置管理工具 Ansible。我们将讨论工具选择标准,介绍 Terraform 和 Ansible,并提供一些关于使用 Visual Studio Code 作为编写代码的 IDE 的指南,包括一些推荐的扩展。
在第三章**,部署规划,我们 强调在基础设施即代码(Infrastructure-as-Code)部署中规划的重要性。你将了解将要部署的工作负载,学习部署方法,并探索一个逐步指南,以实现高效、无错误的执行。我们还将审视高层次的基础设施架构,为即将到来的 Azure 和 AWS 部署做准备。
在第四章**,部署到微软 Azure,我们 探讨如何将我们的项目部署到微软 Azure,这是本书中介绍的两个主要公共云提供商之一。主题包括介绍 Azure、准备云环境、创建低级设计,并使用 Terraform 和 Ansible 编写和部署基础设施代码。
在第五章**,部署到亚马逊 Web 服务,我们 继续将项目部署到亚马逊 Web 服务(AWS),并突出 Azure 和 AWS 之间的主要区别。我们将深入探讨 Ansible 的部署,并了解如何使用 Ansible 和 Terraform 来管理 AWS 资源。到本章结束时,你将了解如何调整你的部署方法,以适应不同的云提供商。
在第六章**,构建基础,深入探讨 如何使用像 Terraform 和 Ansible 这样的云无关工具,在不同的公共云提供商之间部署高层设计的细节。我们将分享我在解决提供商之间差异时的经验,探索创建可重复部署过程的实际方法,并着眼于模块化代码的重要性,以实现简化部署工作和代码重用。
在 第七章**,在云中利用 CI/CD,我们 将专注于使用 CI/CD 来自动化基础设施部署。我们将探索流行的 CI/CD 工具 GitHub Actions,并学习如何使用它来运行 Azure 和 AWS 的 Terraform 和 Ansible 代码。
在 第八章**,常见的故障排除技巧和最佳实践,我们 将学习规划、编写和故障排除基础设施即代码项目的基本策略。本章涵盖了基础设施即代码的一般最佳实践和故障排除技巧,并为 Terraform 和 Ansible 提供了具体指导。通过了解每个工具的独特挑战,你将更好地准备应对在基础设施即代码旅程中可能遇到的障碍。
在 第九章**,《探索替代基础设施即代码工具》中,我们将通过探索三个额外的工具:Pulumi、Azure Bicep 和 AWS CloudFormation,来扩展我们的基础设施即代码工具集。本章旨在提供这些工具的实践理解和知识,突出展示云中立工具与供应商特定工具之间的差异,并展示 Pulumi 使用熟悉的编程语言的独特方法。
为了从本书中获得最大收获
在开始之前,你应该了解如何接近并部署基础设施,以支持你现有的一个或多个应用程序。理想情况下,这些基础设施应托管在 Microsoft Azure 或 Amazon Web Services 上。
虽然这是可选的,但本书假设你对 Microsoft Azure 或 Amazon Web Services 以及一些核心服务的基本概念(如网络或虚拟机)有基本了解。虽然这不是必需的,但在一些更具实践性的章节中,你可能需要进一步阅读我们为何以某种方式处理任务。
我们还假设你希望跟随一些更具实践性的章节;因此,拥有或访问一个不用于托管生产资源的 Microsoft Azure 或 Amazon Web Services 账户将是一个加分项。
| 书中涉及的软件/硬件 | 操作系统要求 | 
|---|---|
| Terraform | Windows、macOS 或 Linux | 
| Ansible | Windows、macOS 或 Linux | 
| Microsoft Azure CLI 和门户 | Windows、macOS 或 Linux | 
| Amazon Web Services CLI 和门户 | Windows、macOS 或 Linux | 
| Pulumi | Windows、macOS 或 Linux | 
| Visual Studio Code | Windows、macOS 或 Linux | 
每个工具的安装说明链接都可以在各章节的进一步阅读部分找到。
如果你使用的是本书的数字版本,我们建议你自己输入代码或从书籍的 GitHub 仓库访问代码(链接在下一部分提供)。这样做可以帮助你避免与代码复制粘贴相关的潜在错误。
最后,随意尝试;查看本书附带的代码并进行修改。然而,注意控制开支,并始终确保在完成后终止任何已部署的资源,以避免任何意外和不必要的费用。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,地址为 github.com/PacktPublishing/Infrastructure-as-Code-for-Beginners。如果代码有更新,它将会在 GitHub 仓库中进行更新。
我们还提供了其他代码包,来自我们丰富的书籍和视频目录,地址为 github.com/PacktPublishing/。快去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的截图和图表的彩色版本。你可以在此下载:packt.link/uvP61。
使用的约定
本书中使用了许多文本约定。
文本中的代码:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。以下是一个例子:“前三行,name、runtime和description,定义了关于我们的部署的一些基本元信息。”
一段代码格式如下所示:
name: pulumi-yaml
runtime: yaml
description: A minimal Azure Native Pulumi YAML program
outputs:
  primaryStorageKey: ${storageAccountKeys.keys[0].value}
任何命令行输入或输出格式如下所示:
$ pulumi up -c Pulumi.dev.yaml
粗体:表示新术语、重要词汇或在屏幕上出现的词汇。例如,在菜单或对话框中的词汇会以粗体显示。以下是一个例子:“最后一步是查看你的堆栈,点击提交按钮,从而触发堆栈创建。”
提示或重要说明
显示格式如下:
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表:尽管我们已尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现错误,我们将非常感激你向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版:如果你在互联网上发现任何非法复制的我们作品的形式,我们将非常感激你向我们提供位置地址或网站名称。请通过版权邮箱联系我们,并附上相关内容的链接。
如果你有兴趣成为作者:如果你在某个领域具有专业知识,并且有兴趣编写或参与书籍的创作,请访问authors.packtpub.com。
分享你的想法
阅读完《基础设施即代码入门》后,我们很想听听你的想法!请点击这里直接进入亚马逊评论页面,分享你的反馈。
你的反馈对我们以及技术社区都很重要,将帮助我们确保提供优质的内容。
下载本书的免费 PDF 副本
感谢购买本书!
你喜欢随时阅读但又无法随身携带纸质书籍吗?你购买的电子书是否无法在你选择的设备上使用?
不用担心,现在购买任何 Packt 书籍,你都会免费获得该书的无 DRM 保护 PDF 版本。
随时随地,任何设备上都可以阅读。你可以搜索、复制并将代码从你喜欢的技术书籍直接粘贴到你的应用程序中。
好处不仅如此,您还可以独家享受折扣、新闻通讯以及每日送达的优质免费内容
按照以下简单步骤获得福利:
- 扫描二维码或访问以下链接

packt.link/free-ebook/978-1-83763-163-6
- 
提交您的购买凭证 
- 
就这样!我们将直接把您的免费 PDF 和其他福利发送到您的电子邮件 
第一部分:基础 – 基础设施即代码简介
在本部分中,我们将讨论如何使用我自己的经历作为起点来开始您的基础设施即代码之旅,然后探讨基础设施即代码的一些核心原则。
我们还将查看我们用于部署示例工作负载的工具,并规划工作负载本身的部署。
本部分包含以下章节:
- 
第一章,选择正确的方法 – 声明式还是命令式 
- 
第二章,超越文档的 Ansible 和 Terraform 
- 
第三章,规划部署 
第一章:选择正确的方法——声明式还是命令式
欢迎来到基础设施即代码入门的第一章。在本书中,我们将进行一次旅程,带你完成第一次基础设施即代码的部署,非常荣幸能与您同行。
在深入讨论我们在本书中将使用的工具之前,我们首先要讨论一些关键概念,尝试理解通过将基础设施即代码引入您的部署,您可能希望解决的一些问题。
我们将涵盖以下主题:
- 
手动管理基础设施的挑战 
- 
声明式和命令式是什么意思? 
- 
宠物与牲畜 
- 
所有这些对于我们的基础设施即代码部署意味着什么? 
手动管理基础设施的挑战
在我们看看你可能面临的一些挑战之前,我想快速带你回顾一下我与基础设施即代码的历程,虽然那时它还不是现在我们所知道的基础设施即代码。
当我谈到基础设施即代码时,我指的是以下内容:
基础设施即代码是一种基础设施管理方法,通过代码和自动化工具来提供和管理基础设施,而不是通过用户界面手动配置资源。
这使得你可以像管理应用代码一样对基础设施进行版本控制、追踪和管理,并且在很多情况下,使用你已经拥有的相同工具、流程和程序来完成这项工作。
基础设施即代码可以通过在部署中引入一致性,减少与传统 手动部署相比的部署时间,帮助提高基础设施操作的效率、可靠性和可重复性。
我的历程
我从事服务器相关工作已经超过了我能记住的时间;当我第一次接触服务器时,几乎所有的工作都是完全手动的。
裸机时代
在虚拟化成为一种常见实践之前,我记得曾经需要花上一整天时间来构建客户的服务器。这个过程通常从确保我手头的硬件符合正确规格开始——如果由于某种原因硬件不符合要求,而这在当时是很常见的,我通常需要更换内存、硬盘等等。
这是为了确保我不会在配置服务器时深入到一定程度,结果发现必须拆掉重做;一旦确认硬件无误,就可以开始实际构建了。
通常,在构建服务器时,我坐在一个狭小、闷热且嘈杂的机房里,周围是各种设备、电脑零件,还有感觉像是成堆的纸张,这些纸张不仅包含了如何手动安装我们支持的各种操作系统的指令,还包括了构建表格,里面记录了客户所需软件堆栈的配置信息。
构建完成后,服务器被重新打包,放回某人的车里,送到数据中心。从那里,服务器被安装到机架上,并连接电力和网络,然后启动服务器——如果一切配置正确,它将启动并在网络上可用。
经过一些快速测试后,回到办公室舒适的环境中完成构建步骤,最后将服务器交给客户,让他们在上面部署他们的软件。
虽然这种流程在偶尔只有一两次部署时还算可以,但随着事情变得更加繁忙,它很快变得难以管理。
接下来的逻辑步骤是拥有一个构建服务器,其中包含所有支持的操作系统和基础软件堆栈配置的驱动镜像,并且当服务器首次启动时运行一些自定义脚本,以自定义基础配置,并在服务器安装到数据中心机架时将其接入网络。
进入虚拟化
一旦我们开始从为客户提供裸机服务器转向虚拟化服务器,事情变得更容易了——首先,因为不需要物理连接 RAM、CPU 或硬盘到服务器上,只要你构建服务器的集群中有足够的资源,这就大大改变了部署时间,同时也减少了在构建室和数据中心的时间。
在这一点上,我们已经积累了一系列自定义脚本,这些脚本连接到虚拟化管理程序和虚拟机——它们在我们的子版本控制库中与团队成员共享,并记录在我们的内部 Wiki 中。
这是我第一次接触“基础设施即代码”,按今天的标准来看非常基础。
虚拟机配置
接下来的逻辑步骤是通过使用工具(如Puppet或Chef)将远程配置加入其中;我们可以使用自定义脚本部署虚拟机,然后让服务器回调到我们的主管理服务器,按照客户的需求配置状态自启动。
将一切整合在一起
这是拼图的最后一块,它将我们的部署时间从每台服务器几天缩短到大约一个小时,其中大部分时间都在等待自动化任务完成——尽管部署的初期阶段大多由我们的内部自制脚本启动,但我们仍然需要仔细关注进度。
之所以这样,是因为在部署过程中并没有太多逻辑来处理错误或其他意外问题,这在某些情况下导致了许多挑战性的部署后问题——不过关于这些问题,越少提越好。
今天的挑战
那么,今天的挑战与我自己的经验有什么不同呢?在我的日常工作中,我与许多内部和外部团队合作,这些团队几乎都是技术人员,并且非常注重自己应用程序的日常管理和开发。
一切都有文档
在与团队讨论基础设施即代码时,我最常听到的答案是:
“我们已经将基础设施部署过程文档化,团队中的任何人都可以按照文档快速部署 资源。”
虽然有文档并且所有团队成员都可以访问它是件好事,但即便是有了全面且易于遵循的文档,你也会惊讶于当人们实际执行时,结果差异有多大。他们并没有完全理解文档,因为它只是一个任务清单,缺乏任务为何要执行的背景信息。
另一个潜在的问题是,团队成员如此频繁地遵循这个过程,以至于他们只是照做,错过了文档中已添加的任何更新或步骤。
更糟糕的是——这比你想象的更常见——给三个技术人员相同的任务,有时会导致三种截然不同的结果,因为每个人的经验不同,而这些经验通常会影响我们做事的方式——例如,上次我尝试做 A、B 和 C 时,发生了 X,所以现在我按 C、B 和 A 的顺序做,或者我认为按 B、A 然后 C 做会更好——但现在没有时间更新 文档。
所有这些都可能在你的部署中引入不一致,而这些问题可能不会被察觉,因为每个人都认为自己做得正确,因为他们都在遵循相同的文档。
下一步,下一步,下一步
我通常听到的下一个(非常有意的双关)答案是:
“我们不需要经常做这个,每次做时,只需在界面中点击‘下一步,下一步,下一步’——任何人都可以 做。”
当我听到这个答案时,实际上我听到的是,部署资源的过程太简单了,以至于我们没有费心去记录它。虽然对于团队中的某些成员来说可能确实是这样,但并不是每个人都有经验或信心仅凭点击下一步,下一步,下一步就能部署和配置资源,而不需要指导。
正如你能想象的那样,如果在每个人都在遵循相同文档的情况下还可能出现不一致,那么在没有文档所提供的防护措施的情况下进行部署,将会在后续过程中引入更多潜在的问题。
仅仅因为资源已部署且没有错误并且能正常工作,并不意味着它已安全地部署并能够支持你的生产工作负载。
我们拥有所需的一切
在讨论基础设施即代码时,最后一个最常见的答案是:
“我们已经部署了所需的一切,不需要任何 额外的资源。”
再次,当我得到这个回答时,我通常听到的是略微不同的说法——根据我的经验,这通常意味着,早些时候,有人部署了某个完全能够完成任务的东西,并且现在已经离开,要么转向了其他项目或部门,要么完全离开了公司。
虽然资源运行良好很棒,但这种方法可能会导致问题,特别是当你需要重新部署或,情况更糟时,解决生产环境中的问题时,因为很多关于底层配置的知识都缺失了。
因此,虽然你知道存在什么,但你不一定知道为什么。
结论
还有很多其他例子,但前面这些是我在与那些可能未将基础设施即代码视为其部署管道基础的团队合作时最常见的例子,如果你正在阅读这篇文章,可能已经遇到了一些这样的例子,并且希望继续迈向下一步。
那么,为什么你要采用基础设施即代码的方法来进行部署呢?其实有几个原因,包括以下几点:
- 
文档:虽然我们已经提到过文档,但需要注意的是,如果你使用基础设施即代码(Infrastructure as Code),你的部署将作为代码的一部分被文档化,因为它以人类可读的格式定义了基础设施的期望状态。 
- 
可重复和一致性:你应该能够反复使用你的代码进行部署——当然,你可能会对资源的 SKU 和名称等进行一些修改,但这应该只是更新一些在执行时读取的变量,而不是重写整个代码库。 
- 
节省时间:正如我提到的,在我自己的经验中,部署资源有时需要几天——最终,部署时间缩短到几小时,而使用现代的云资源,甚至可以缩短到几分钟。 
- 
安全性:因为你的基础设施在代码中定义,你知道你将拥有一个文档完备的端到端配置,随时可以查看。由于它可以轻松部署,你可以快速创建一个环境来查看或部署你的最新修复,并且放心,它与你的生产配置一致,因为你不依赖于某人手动执行逐步的文档,其中可能会遗漏或误解某些步骤。 
- 
节省成本:我认为你不应以节省成本为首要目标来进行基础设施即代码的部署——但它无疑是一个非常受欢迎的“锦上添花”。根据你的方法,节省成本可以是前面各点的副产品。例如,你是否需要全天候运行你的开发或测试基础设施,而你的开发人员最多只在一周内的几天内需要它? 
好吧,这些基础设施可以作为你构建管道的一部分,几乎无需或只需很少的努力就能部署。在这种情况下,你可能会发现自己处于一个令人羡慕的境地,只在需要时支付资源费用,而不是为了 24/7 可用性支付费用。
现在我们已经讨论了我个人在基础设施即代码中的历程,并且了解了基础设施即代码在不同场景下可能的用途,以及你可能希望将其融入日常工作流的潜在原因,接下来我们来讨论一些在开始讲解本书剩余部分工具之前你需要了解的基本概念。
声明式和命令式是什么意思?
在编程中,有多种方式向计算机下达指令以实现程序员期望的结果。这些向计算机指示操作的方式被称为编程范式。
一般来说,它们指的是我们如何从逻辑概念(如if语句或循环)构建程序。还有其他分类:函数式编程、结构化编程、面向对象编程等等。每种方式描述了程序员在编写代码或思考代码时可能执行的不同任务。
命令式和声明式编程是程序员在定义任务时最基本的思考方式,也是我们在编写和构建基础设施代码时需要思考的两种主要方式。
在我们讨论每种方式之前,让我们先定义一个简单的基础设施即代码项目。
基本的基础设施即代码项目
以下图示展示了在 Microsoft Azure 中部署单个虚拟机的基本基础设施:

图 1.1 – 基本的基础设施即代码项目示意图
如你所见,项目由以下组件组成:
- 
rg-iac-example-uks-001) – 这是一个在 Azure 中存储资源的逻辑容器。
- 
vnet-iac-example-uks-001) – 这是一个虚拟网络,将托管我们的示例虚拟机。
- 
snet-iac-example-uks-001) – 这一部分在图中没有显示,但虚拟网络包含一个子网。
- 
nsg-iac-example-uks-001) – 由于我们不希望像3389(RDP)或22(SSH)这样的管理端口对任何互联网用户开放,这将添加一些基本规则,仅允许来自可信源的流量通过这些端口。此规则将附加到子网,因此适用于部署在该子网中的所有资源。
- 
vm-iac-example-uks-001) – 如你所料,这就是虚拟机本身。
- 
nic-iac-example-uks-001) – 这里我们有网络接口,它将连接到虚拟机和虚拟网络中的子网。
- 
pip-iac-example-uks-001) – 最后,我们部署了公共 IP 地址;它将附加到网络接口上,从而允许我们从网络安全组中定义的受信任位置路由到虚拟机。
虽然这是一个基础的基础设施示例,但部署中涉及了相当多的不同资源。此外,由于我们将从非常高层次讨论如何部署,因此目前不会详细讨论 Azure 本身,Azure 的具体内容将在第四章中讨论,部署到 Microsoft Azure。
声明式方法
当谈到我自己的经验时,我提到我使用了一个配置工具;在我的案例中,这个工具是 Puppet。Puppet 使用声明性语言来定义目标配置——无论是软件堆栈还是基础设施——但这到底意味着什么呢?
与其试图给出一个解释,不如直接跳进来,描述一下一个声明性工具如何部署我们的基础设施。
在最基本的配置中,声明性工具只关心最终状态,而不一定关心如何到达这个状态。这意味着,除非明确要求,否则工具并不关心资源的具体情况,即当工具执行时,它决定资源的部署顺序。
对于我们的示例,假设工具使用以下顺序来部署我们的资源:
- 
虚拟网络 
- 
资源组 
- 
网络安全组 
- 
子网 
- 
公共 IP 地址 
- 
虚拟机 
- 
网络接口 
从表面上看,这似乎没什么问题;让我们探讨一下这种顺序如何影响我们在 Azure 中的资源部署。
以下图示展示了部署结果:

图 1.2 – 使用声明性工具部署我们的基础设施的结果
如你所见,所有资源成功部署花了三次部署,那么为什么会这样呢?
- 
部署 1:虚拟网络未能成功部署,因为它需要与资源组一起部署,而资源组还没有被部署。由于所有剩余资源都依赖于虚拟网络,它们也都失败了,这意味着在第一次执行过程中唯一成功部署的资源是资源组,因为它没有任何依赖。 
- 
部署 2:由于我们已经有了部署 1中的资源组,因此这次虚拟网络和子网都成功部署;然而,因为在子网成功部署之前就尝试部署网络安全组,导致网络安全组部署失败。剩余失败的资源——公共 IP 地址和虚拟机——都失败了,因为网络接口还没有被创建。 
- 
部署 3:在部署 2的最后一组依赖项到位后,其余资源——网络安全组、公共 IP 地址和虚拟机——都成功启动,最终让我们达到了预期的最终状态。 
这个术语叫做最终一致性,因为我们期望的最终状态是在经过几次执行后最终部署完成。
在某些情况下,资源初始部署时的失败并不太重要,因为我们期望的最终状态最终会达到——然而,对于基础设施来说,根据你的目标云环境,这种情况并不总是成立。
在基础设施即代码的早期,这确实是一个大问题,因为你需要构建逻辑来考虑你部署的资源之间的依赖关系——这不仅意味着你需要知道依赖关系是什么,而且你的部署规模越大,效率就越低。
这是因为你在代码中添加的逻辑越多,就越是与工具的声明性特性背道而驰,这也带来了在执行代码时引入竞争条件的风险。例如,如果你有一个资源需要五分钟才能部署——你怎么知道它已经准备好?这意味着需要更多的逻辑,而如果你搞错了或者发生了意外情况,你可能会一直等着执行最终超时。
别担心;随着工具的不断发展,情况已经有了显著改善,工具变得更加注重资源。很多你曾经需要手动处理的逻辑现在已经不再必要,但仍然有一些需要注意的事项,我们将在第二章《超越文档的 Ansible 和 Terraform》中详细讨论。
命令式方法
正如你可能已经猜到的,使用命令式方法时,任务按你定义的顺序执行——而我们知道需要按照什么顺序运行任务来部署我们的资源,顺序如下:
- 
资源组 
- 
虚拟网络 
- 
子网 
- 
网络安全组 
- 
网络接口 
- 
公共 IP 地址 
- 
虚拟机 
这意味着我们第一次部署的结果将如下所示:

图 1.3 – 使用命令式工具部署基础设施的结果
很好,你可能会想,这次第一次就成功了!嗯,某种程度上是这样;不过这里有一个很大的假设,那就是你知道你的资源需要按什么顺序部署,而且你需要以考虑到这一点的方式来结构化代码。
因此,尽管这种方法通常在执行时能够有效,但可能需要稍微多一点前期工作,通过一些试错来将脚本调整到正确的顺序——然而,一旦它们按正确的顺序排列,你可以放心,每次执行时,它们都会成功。
现在我们已经讨论了声明式和命令式在基础设施即代码中的关键区别,接下来让我们讨论另一种部署方式——宠物与牲畜的区别。
宠物与牲畜
传统上,宠物与牲畜一直是定义数据中心资源的一种方式。这个类比描述了一组硬件或虚拟化资源是作为宠物还是作为牲畜。
宠物
宠物是由个人用户/团队拥有或单独管理的资源。
通常,它们被视为任何应用架构中重要的固定点,就像宠物一样,你会做以下事情:
- 
backendapplication.server.domain.com,这样它就很容易被识别。
- 
你喂养和照顾它们:例如,你会定期进行备份并进行审查。你密切关注资源的利用情况,并根据需要增加更多的 RAM 和磁盘空间。 
- 
如果它们生病了,你会照顾它们:它们已安装监控代理,意味着如果出现问题,你会收到警报——有时是 24 小时全天候——如果出现问题,你会尽力恢复服务,确保有故障排除流程。 
- 
你希望它们活得很长时间:鉴于它们在你的应用架构中的重要性以及你对它们的照料,你希望它们能存在很长时间。 
现在被视为宠物的资源通常已经存在一段时间,它们的配置随着时间的推移根据使用情况有机地发展,成为每个独特的部署,这就是为什么你像照顾宠物一样照顾它们——一个很好的例子就是长时间运行的服务器。
牲畜
对于那些被部署并当作牲畜处理的资源,你只关心整体健康状况,而不是个别资源的健康:
- 
beapp001.server.domain.com到beapp015.server.domain.com;你只是不断增加数字,而不是分配一个独特的名称来让它们容易识别。
- 
你远远地观察它们:考虑到资源的数量,你只关心整体的可用性,这意味着你可能只会从资源中获取性能统计数据和日志,而不需要备份它们,因为它们的替换速度很快。 
- 
如果它们生病了,你会替换它们:如前所述,如果资源出现问题,你不会对问题进行故障排除,而是立即终止它并用另一个资源替换。通常这个过程是自动化的,这样资源可以迅速下线并被替换。 
- 
您不期望它们长时间存活:鉴于它们的数量,它们的生命周期可能相当短——在某些情况下,资源可能只存在短时间,用来应对工作负载的增加。一旦额外资源的需求消失,一些资源就会被终止。 
结论
宠物与牲畜 主要适用于应用部署策略,而不仅仅是基础设施本身。毕竟,假设您的应用出于某种原因需要作为一个固定点运行——例如,您的应用执行以下操作:
- 
写入本地磁盘的重要文件,如果实例被终止,这些文件不能丢失。 
- 
在应用实例部署后,需要手动步骤才能让其上线。 
- 
是根据主机的 MAC 地址或 CPU ID 授权的。 
在这种情况下,您可能无法将您的部署视为“牲畜”,但您可以将您的基础设施作为代码来编写,从而使大部分部署尽可能地自动化。
这些是技术原因,但从商业角度来看也有一些考虑因素。
最能吸引大多数企业注意的是成本效益。您选择“宠物”还是“牲畜”方法,可能会对您的托管成本产生重大影响。
“牲畜”方法将服务器视为短暂的资源,它允许更好的资源利用和自动扩展,可能减少成本。另一方面,部署“宠物”方法则强调对单个服务器的维护,可能导致更高的维护和管理成本,但对于需要特别关注的关键任务应用,可能是可以接受的。
采取“牲畜”方法可以更快地部署和扩展您的工作负载;这使得企业能够更迅速地应对市场变化和客户需求。部署“宠物”方法可能导致更长的部署时间,从而影响公司的竞争力。
合规性和安全要求也可能会影响选择“宠物”与“牲畜”部署之间的决策。专注于管理单一资源的“宠物”方法,可能更适合有严格合规或安全要求的企业,因为它允许对服务器配置进行更细粒度的控制和审计。然而,强调自动化和快速扩展的“牲畜”方法,可能无法提供同样程度的控制,并且可能需要额外的努力来确保合规性和安全性。
现在我们已经大致了解了您可能处理的部署类型,我们来谈谈这对基础设施即代码部署意味着什么。
这对我们的基础设施即代码部署意味着什么?
到目前为止,我们讨论了人们在考虑使用基础设施即代码之前所采用的一些方法和路径,那么在我们深入探讨第二章,“Ansible 和 Terraform 超越文档”中的一些工具之前,让我们先谈谈一些实际的使用案例。
在我看来,使用基础设施代码的最大优势是一致性——如果你需要重复执行某个过程或部署多次,那么将部署定义为基础设施代码。
这将确保每次资源部署时的结果都相同,无论是谁在进行部署;如果每个人都在使用相同的代码集,那么合理推断,输出结果会是相同的(除了你允许覆盖的变量值,例如 SKU、资源名称等)。
基础设施代码方法不仅能确保团队成员在部署代码时的一致性,还能确保不同环境之间的一致性。在我开始将我的部署定义为基础设施代码之前,环境之间的配置漂移是一个相当严重的问题——环境上线的时间太长,以至于调整被应用却没有被同步,因此当代码在开发、测试,最后是生产环境之间迁移时,意外的事情开始发生。
接下来是协作;因为你的基础设施是用代码定义的,你可以使用你为应用程序所用的相同开发工作流程。我相信你们大多数人都会使用版本控制系统来管理代码,可能会通过 GitHub、GitLab、BitBucket 或 Azure DevOps 等托管服务使用 Git——如果是这样,那么你已经具备了一切,能够跟踪更改并协作进行基础设施配置。
你还可以通过引入分支和拉取请求,基于现有流程来促进变更和测试,从而进一步扩展此功能,使你的基础设施代码项目的持续维护和开发真正变得协作性。
一旦你将基础设施代码托管在版本控制中,你还可以利用自动化,同样使用你用于构建应用程序的相同流程和管道——使用 GitHub Actions 或 Azure DevOps Pipelines 等服务。
使用这些服务,你可以从一个由服务角色基础访问控制(RBAC)保护的单一位置执行任务,而不需要依赖团队中的每个成员在本地下载并运行基础设施代码的部署。
如果某个团队成员需要在本地运行这项操作,那么这意味着每个需要访问并进行部署的团队成员,也需要拥有相当高的目标资源访问权限——例如你部署到的公共云。
使用自动化解决方案,例如前面提到的那些,意味着你可以让人们在他们的管道中使用凭据,而不需要他们知道凭据的具体内容。这意味着你可以为个人授予较低级别的访问权限——例如只读——因为他们只需要查看资源,而不是管理它们。
这种方法的一个显著副作用是,由于人们没有超出自动化的访问权限,他们就不会轻易跳进门户手动修复某个问题,而是需要更新代码并进行部署,这意味着更改得到了跟踪,执行过程被记录,因此你可以知道是谁、何时、为何做了什么。
最后,我们已经提到过的一点——节省成本。如果你的基础设施即代码部署在版本控制中并且自动化,那么部署你的基础设施按需进行,而不是 24/7 全天候运行,这一点并不困难。
例如,如果你有一个用于构建应用程序的管道,一旦管道成功执行,它就可以触发构建基础设施——一旦基础设施构建完成,它又会触发一个部署,接着,你的测试可以在这个部署和新部署的资源上运行。测试结果可以被存储,而基础设施随后会被拆除,因为它不再需要。
这个端到端的过程可能需要半小时——但这是那半小时的资源成本,而不是支付全天候资源成本——我相信你会同意,这实际上是相当省钱的。
总结
在本章中,我们讨论并介绍了一些我们将在书中余下部分中跟随的核心概念。我们谈到了我自己在基础设施即代码方面的旅程,这部分内容将在后续章节继续展开。
我们讨论了一些在讨论基础设施即代码项目时常见的问题,以及你可能会得到的一些正面和负面反馈。然后,我们继续讨论了两种部署方法之间的区别。
第一个概念是声明式和命令式,它们决定了你的部署代码如何执行以及执行的顺序。
我们讨论的第二种方法,宠物与牲畜,虽然严格来说不是基础设施即代码的方法,但它与编写基础设施即代码脚本时采用的方法是相关的。
随着我们进入更多的实操环节,我将分享我自己在基础设施即代码方面的一些挑战和成功经验。
说到更多实操内容,在下一章,第二章,超越文档的 Ansible 和 Terraform,我们将介绍两种最常见的基础设施即代码工具,并开始查看一些实际的基础设施即代码示例,同时了解声明式和命令式等概念如何应用于这些工具。此外,我们还将基于我自己使用这两个工具的经验,分享一些技巧和窍门。
延伸阅读
这里是一些关于本章中我们讨论的主题、工具和服务的更多信息链接:
- 
Puppet: www.puppet.com/
- 
Chef: www.chef.io/
- 
微软 Azure: azure.microsoft.com/
- 
GitHub: github.com/about
- 
GitLab: about.gitlab.com
- 
BitBucket: bitbucket.org/product
- 
Azure DevOps Repos: azure.microsoft.com/en-us/products/devops/repos/
- 
GitHub Actions: github.com/features/actions
- 
Azure DevOps Pipelines: protect-eu.mimecast.com/s/8fwmCjvkghnR9PJIRuMq_?domain=azure.microsoft.com/
第二章:超越文档的 Ansible 和 Terraform
在我们迈向基础设施即代码(IaC)的下一阶段时,我们将看看Terraform,这是 HashiCorp 提供的 IaC 工具,以及Ansible,这是Red Hat提供的 IaC 和配置管理工具。
我们还将比较使用这些工具的优缺点,分别在 macOS、Windows 11 和 Ubuntu Linux 上进行设置,并使用 Visual Studio Code 作为集成开发环境(IDE)编写我们的代码,同时还会查看推荐安装的扩展。
在本章中,我们将探讨以下主题:
- 
选择工具时需要考虑哪些重要因素? 
- 
介绍 Terraform 
- 
介绍 Ansible 
- 
介绍 Visual Studio Code,来自微软的开源 IDE 
在我们开始查看将用于本书中的工具之前,让我们快速讨论一下我用来选择项目工具的检查清单。
选择工具时需要考虑哪些重要因素?
所以,您有一个新项目——您已经知道将使用哪个云服务提供商,并且您的开发团队已向您提供了应用程序概览——这意味着您已经对要部署和管理的资源有了清晰的了解。您被允许自由选择使用哪种 IaC 工具——那么,您该如何选择呢?
就个人而言,我的做法始终是选择最适合任务的工具,而不是试图将任务与工具相匹配——根据我的经验,这样做总会在代码部署和部署后的管理过程中导致问题。
让我们讨论一些您需要考虑的关键事项。
部署类型
我遇到的主要有两种部署类型,第一种是使用 IaC 以可预测和一致的方式反复部署相同的资源。
这种方法最常见的使用场景是用于开发、测试和其他低环境,而非生产环境。
目标是与开发者的构建、发布和测试管道集成,这样当他们将代码更改推送到前述的某个环境分支时,以下情况将发生:
- 
推送操作触发使用您的 IaC 脚本部署资源 
- 
一旦资源部署完成,您的 IaC 管道会将控制权交还给开发者的管道,供他们构建代码并将其部署到刚刚启动的资源中 
- 
一旦应用程序代码部署完成,运行开发者的自动化测试,或者通知团队中的某个人,告知新推送的代码已经准备好进行手动测试 
- 
最后,经过测试并完成结果存储后,在管道中的自动或手动决策门控作用下,流程开始时部署的资源会被终止。 
上述过程会为每次推送重复执行——有时多个部署会并行执行。
采用这种方法的优势在于,你不仅能节省成本,还可以仅在需要时运行资源。此外,由于每次部署时都是从零启动资源,因此避免了配置漂移。
配置漂移发生在某个人本着最好意图,快速手动调整某个设置以使某个功能正常工作,却没有在任何地方记录。此时,需要将这些临时修复纳入你的代码中,以便它们在下一次部署中得以持续。
下一种部署类型是使用你的 IaC 脚本启动和管理资源。正如你可能猜到的那样,这种方法通常用于长时间运行的环境,如生产环境。
当你首次考虑这种类型的部署时,很容易认为它与第一种部署类型相似——然而,实际上,第一种部署类型每次部署仅执行一次,而这种类型则是多次执行相同的部署,这可能会带来一些有趣的挑战,例如:
- 
根据资源类型,IaC 脚本配置和管理的内容与应用程序部署之间的界限在哪里? 
- 
在处理长时间运行的资源时,你需要在 IaC 脚本中加入哪些额外的逻辑或错误检查,以确保终止的是你的代码执行而不是正在运行的资源?毕竟,无论基础设施重建多么简单,你都不希望导致服务中断! 
- 
你是如何管理基础设施的状态的?正如我们在本章的下一节中将要学习的,保持一致的状态对于我们将在本书中介绍的工具之一至关重要——那么,它是长期存储在哪里的? 
基础设施与配置
尽管本书中我们将大量讨论 IaC(基础设施即代码),如果你已经读到这里,应该不会感到惊讶,但 IaC 脚本与应用程序的部署/配置之间的界限在哪里?
一个很好的例子是,当你的项目涉及部署基础设施即服务(IaaS)资源,如虚拟机时。假设你需要部署两台 Linux 服务器,并在其上安装 NGINX 和 PHP 等脚本语言;你如何实现这一目标?
大多数公共云提供商允许你在启动虚拟机时通过类似 cloud-init 的服务附加并执行脚本——虽然这种方式应该可以覆盖大多数基本用例,但这种方法也会增加一定的抽象层次,可能会引发一些问题——例如,云服务提供商是否提供有关脚本执行的详细信息——而你的 IaC 执行是否能知道该脚本是否失败?
如果你需要对部署过程中执行的命令有更细粒度的控制或可视化,那么这将决定你选择哪种工具,因为纯粹的 IaC 工具可能不足以满足你的需求。
这也会影响下一步的决策。
外部交互与机密
正如上一节末尾所提到的,如果你的 IaC 脚本需要与使用公共 API 之外的服务交互——例如安全外壳(SSH)或Windows 远程管理(WinRM),用于在虚拟机上运行脚本,或者像 vSphere API 这样用于管理 VMware 环境中托管资源的内部 API,那么你就需要仔细选择执行 IaC 的地方,因为你需要直接访问与你交互的资源。
同样,取决于你如何在 IaC 脚本中管理诸如密码或服务证书等机密内容,你还需要确保能直接访问你的机密存储,或者有一种安全的方式将其注入脚本中,因为将它们作为明文硬编码的值存储在你的 IaC 中绝对不是一个可选的办法!
这意味着你需要评估在哪里以及如何执行你的脚本,考虑到诸如防火墙、资源和凭证的访问等问题——而且要做到不暴露任何机密。
当我们卷起袖子开始在后续章节中构建我们的部署时,我们会覆盖所有这些内容。
易用性
最后的考虑因素就是工具的易用性。
很容易被最新的炫酷技术所吸引,但如果你是团队中唯一有相关经验的人,你会增加复杂性,因为不仅需要提升其他团队成员的技能,让他们也能使用这些代码,还需要处理作为早期采用者可能遇到的各种问题。
摘要
在处理任何 IaC 项目时,本节中讨论的所有内容都应该放在你脑海的最前沿。在本书结束时,你将拥有解决本节中提出的所有问题和考虑因素的答案和经验,从而能够选择合适的工具,而不是将项目强行适配工具,或者说有时只能形容为将方钉塞进圆孔。
现在是我希望你一直期待的时刻;我们将看看我们的两个主要工具。
介绍 Terraform
我们将要介绍的两个工具中的第一个是 HashiCorp 的 Terraform。
HashiCorp 的 Terraform 是一个企业级的云和虚拟化管理工具。它帮助你轻松管理资源并部署新的实例。Terraform 是一个开源工具,用于管理云基础设施,既可以高效地配置和部署资源,还能帮助你维护和不断发展你的基础设施。
Terraform 有一个独特的架构,它使用状态机来管理资源,并且完全模块化,你可以根据需求扩展服务。最后,它还与许多第三方工具和服务集成。
Terraform 使用HashiCorp 配置语言(HCL)。乍一看,你可能会误以为它是为 JSON 或 YAML 设计的,但它是 HashiCorp 为构建结构化配置格式而设计的语法和 API,而 YAML 和 JSON 只是分别定义人类和机器可读格式的数据结构的格式。
不再深入讨论 HCL——让我们来看一个 HCL 示例。
一个 HCL 示例——创建资源组
我个人经常在日常工作中使用 Microsoft Azure,所以我将以此为示例。
信息
随时跟着做;如果你需要帮助安装 Terraform,可以在本章结尾的进一步阅读部分找到相关文档的链接。
Azure 有一个资源组的概念,它作为你资源的逻辑容器,因此让我们从创建一个资源组开始:
- 
我们的 Terraform 配置需要三个主要部分,第一部分告诉 Terraform 我们的代码兼容哪个版本的 Terraform,以及我们需要使用哪些外部提供程序。在创建资源组的情况下,它看起来像下面这样: terraform { required_version = ">=1.0" required_providers { azurerm = { source = "hashicorp/azurerm" version = "~>3.0" } } }
Terraform 的最大卖点之一是它既适合机器读取,也适合人类阅读——从前面的这小段代码来看,我相信你也会同意它很容易理解。
这里我们指定了required_version的 Terraform 版本应该大于或等于1.0。接下来是required_providers;提供程序是一个扩展功能的外部库——在这个示例中,我们告诉 Terraform 下载并使用来自hashicorp/azurerm的azurerm提供程序的最新版本3.0,这是官方提供程序发布的来源。
- 
下一个部分配置了提供程序。对于我们的示例,我们不做任何额外的配置,因此这看起来就像下面这样: provider "azurerm" { features {} }
- 
接下来是我们示例的最后一个部分;这里是我们配置资源组的地方: resource "azurerm_resource_group" "example" { name = "rg-example-uks" location = "UK South" }
如你所见,没什么复杂的——我们只是通过提供name来定义我们想要的资源名称,并且使用location指定我们希望资源组所在的 Azure 区域。
所有前面的代码都放在一个名为terraform.tf的文件中,文件位于一个空文件夹里。在我们创建资源组之前,我们需要初始化 Terraform;这将下载azurerm提供程序并创建一些支持文件,比如locks,这些文件在执行代码时是必需的。
- 
要部署资源组,我们首先需要运行以下命令来准备本地环境: $ terraform init
- 
这将产生类似以下的输出: Initializing the backend... Initializing provider plugins... - Finding hashicorp/azurerm versions matching "~> 3.0"... - Installing hashicorp/azurerm v3.32.0... - Installed hashicorp/azurerm v3.32.0 (signed by HashiCorp) Terraform has created a lock file .terraform.lock.hcl to record the provider selections it made above. Include this file in your version control repository so that Terraform can guarantee to make the same selections by default when you run "terraform init" in the future. Terraform has been successfully initialized!
- 
既然 Terraform 已经准备好,我们可以运行它——首先,我们需要运行一个计划: $ terraform plan
这应该能让我们了解当我们应用配置时,Terraform 会做些什么;在我的情况下,得到了以下输出:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # azurerm_resource_group.example will be created
  + resource "azurerm_resource_group" "example" {
      + id       = (known after apply)
      + location = "uksouth"
      + name     = "rg-example-uks"
    }
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform 在这里所做的是一些基本的飞行检查,发现它不知道在uksouth区域有一个名为rg-example-uks的资源组,因此它需要将其添加进去,并且由于我们只创建了一个资源,需要添加的是1个资源。
- 
要创建资源组,我们需要运行以下命令: $ terraform apply
这样做时,它会给我们与运行terraform plan时相同的输出,但这次,像往常一样,如果我们希望继续,输入yes会部署该资源:
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
azurerm_resource_group.example: Creating...
azurerm_resource_group.example: Creation complete after 0s [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/rg-example-uks]
- 
这样,我们的资源组就创建好了。再次运行 terraform apply命令会显示以下输出:azurerm_resource_group.example: Refreshing state... [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /resourceGroups/rg-example-uks] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
所以,不需要做任何更改——现在让我们添加另一个资源——比如存储账户怎么样?
添加更多资源
按照以下步骤添加存储账户:
- 
为此,我们只需要在 terraform.tf文件末尾添加以下资源:resource "azurerm_storage_account" "example" { name = "saiacforbeg2022111534" resource_group_name = "rg-example-uks" location = "UK South" account_tier = "Standard" account_replication_type = "GRS" }
现在运行terraform apply会显示以下输出,我已将总行数从 13 行截断为 166 行:
azurerm_resource_group.example: Refreshing state... [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /resourceGroups/rg-example-uks]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # azurerm_storage_account.example will be created
  + resource "azurerm_storage_account" "example" {
      + account_kind                      = "StorageV2"
      + account_replication_type          = "GRS"
      + account_tier                      = "Standard"
      + location                          = "uksouth"
      + name                              = "saiacforbeg2022111534"
      + resource_group_name               = "rg-example-uks
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
- 
输入 yes,将得到以下输出:azurerm_storage_account.example: Creating... azurerm_storage_account.example: Still creating... [10s elapsed] azurerm_storage_account.example: Still creating... [20s elapsed] azurerm_storage_account.example: Creation complete after 25s [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /resourceGroups/rg-example-uks/providers/Microsoft.Storage/storageAccounts/saiacforbeg2022111534]
- 
现在,我们已经有了存储账户——太好了,让我们销毁它并重新运行: $ terraform destroy
这个命令的输出会告诉我们将要删除的内容(输出已被截断):
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy
Terraform will perform the following actions:
  # azurerm_resource_group.example will be destroyed
  - resource "azurerm_resource_group" "example" {}
  # azurerm_storage_account.example will be destroyed
  - resource "azurerm_storage_account" "example" {}
Plan: 0 to add, 0 to change, 2 to destroy.
Do you really want to destroy all resources?
- 
输入 yes,正如你可能猜到的那样,这将销毁资源:azurerm_resource_group.example: Destroying... [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /resourceGroups/rg-example-uks] azurerm_storage_account.example: Destroying... [id=/subscriptions/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx /resourceGroups/rg-example-uks/providers/Microsoft.Storage/storageAccounts/saiacforbeg2022111534] azurerm_storage_account.example: Destruction complete after 3s azurerm_resource_group.example: Destruction complete after 46s Destroy complete! Resources: 2 destroyed.
- 
现在再次运行脚本,使用 terraform apply告诉我们将添加两个资源:Plan: 2 to add, 0 to change, 0 to destroy.
- 
然而,当你输入 yes并尝试继续时,它会报错:│ Error: creating Azure Storage Account "saiacforbeg2022111534": storage.AccountsClient#Create: Failure sending request: StatusCode=404 -- Original Error: Code="ResourceGroupNotFound" Message="Resource group 'rg-example-uks' could not be found." │ with azurerm_storage_account.example, │ on terraform.tf line 21, in resource "azurerm_storage_account" "example": │ 21: resource "azurerm_storage_account" "example" {
为什么会报错?让我们看看错误并弄清楚发生了什么。
修复错误
首先,为什么会出现错误?
如果你还记得,在上一章中,我们讨论了命令式和声明式的区别;这就是如果你使用命令式工具而没有正确规划部署时会发生的情况。
由于存储账户附属于资源组,而在执行时资源组并不存在,因此存储账户无法被创建。
然而,在 Terraform 运行过程中,资源组没有依赖失败,这意味着如果你再次运行terraform apply,存储账户将被创建——那么我们如何解决这个问题,以便第一次运行terraform apply时就能成功呢?
你可能注意到 Terraform 将我们创建的两个资源称为azurerm_resource_group.example和azurerm_storage_account.example;这些是我们可以在自己的代码中使用的内部引用。此外,对于这些引用中的大多数,某些输出只有在资源创建后才会填充。某些引用只有在资源创建后才能知道,因为它是 Azure 中创建资源时返回的值,例如唯一 ID,而其他引用则是我们定义的——但只有在资源启动后才会填充。在azurerm_resource_group的情况下,名称和位置会在资源组创建后作为输出值填充。
我们可以在azurerm_storage_account块中引用这些资源,通过引用资源来实现;如下所示:
resource "azurerm_storage_account" "example" {
  name                     = "saiacforbeg2022111534"
  resource_group_name      = azurerm_resource_group.example.name
  location                 = azurerm_resource_group.example.location
  account_tier             = "Standard"
  account_replication_type = "GRS"
}
这将等待资源组部署完成后,Terraform 才会尝试创建存储帐户——而不是在执行时就尝试创建两个资源并失败。
虽然我不会将这种情况描述为错误或故障,但它们更像是一些怪癖,直到你尝试某个操作时才能发现。因此,在我们继续深入本书时,我会指出一些这样的例子和其他类似的做法,因为你的部署代码越复杂,编写时需要考虑的因素就越多。
以下截图展示了在 Azure 门户中部署的资源:

图 2.1 – 在 Azure 门户中部署的资源
你可以通过运行以下命令清理已启动的资源:
$ terraform destroy
这将永久删除资源组和存储帐户,因此在选择Yes之前,请确保你已准备好继续操作。
现在我们已经了解了一些关于 Terraform 的知识,接下来让我们看看本书中将要使用的另一个工具——Ansible。
介绍 Ansible
本书中详细介绍的第二个工具是 Red Hat 的 Ansible。
Ansible 是一个流行的配置管理工具,使用户能够自动化部署和管理他们的应用程序。
它采用中心-辐射模型,其中一个控制机器指示其他机器执行任务。你可以用它来管理你的服务器、部署应用程序或配置网络设备。与其他无需代理的设备相比,它的最大优势之一是你无需在目标设备上安装任何东西。
它支持 YAML 和 JSON 来编写 playbook,即主配置文件,这意味着在管理远程系统及其状态时,它是语言无关的。
在你的 IaC(基础设施即代码)解决方案中没有“一刀切”的解决方案,Ansible 允许你选择不同的模块来实现你想要的结果,在管理基础设施时提供了极大的灵活性。
Ansible 示例
让我们以 Terraform 中使用的相同示例为基础,在 Ansible 中重新创建它,创建一个 Azure 资源组,并将一个 Azure 存储帐户放入其中:
信息
再次,随时跟着操作;如果你需要安装 Ansible 的帮助,可以在本章末尾的 进一步阅读 部分找到相关链接。
- 
将以下代码放入本地计算机的空白文件中,命名为 playbook.yml:--- - name: Ansible Infrastructure as Code example hosts: localhost tasks: - name: Create an example resource group azure.azcollection.azure_rm_resourcegroup: name: "rg-example-uks" location: "UK South" - name: Create an example storage account azure.azcollection.azure_rm_storageaccount: resource_group: "rg-example-uks" name: "saiacforbeg2022111534" account_type: "Standard_GRS"
提示
由于这是一个 YAML 文件,缩进非常重要——在尝试执行 playbook 之前,我建议使用类似 www.yamllint.com/ 的在线工具快速验证文件。
- 
当你准备好运行 playbook 时,可以运行以下命令: $ ansible-playbook playbook.yml
在第一次运行时,会出现以下错误:
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
PLAY [Ansible Infrastructure as Code example] ******************************************************
TASK [Gathering Facts] *****************************************************************************
ok: [localhost]
TASK [Create an example resource group] ************************************************************
An exception occurred during task execution. To see the full traceback, use -vvv. The error was: ModuleNotFoundError: No module named 'msrest'
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Failed to import the required Python library (msrestazure) on Russs-Laptop.local's Python /opt/homebrew/Cellar/ansible/6.6.0/libexec/bin/python3.10\. Please read the module documentation and install it in the appropriate location. If the required library is installed, but Ansible is using the wrong Python interpreter, please consult the documentation on ansible_python_interpreter"}
PLAY RECAP *****************************************************************************************
localhost: ok=1    changed=0    unreachable=0    failed=1     skipped=0    rescued=0    ignored=0
前两条警告可以忽略;然而,错误是我们在运行 playbook 之前需要处理的问题。
- 如本节介绍中所提到的,Ansible 是模块化的——这些模块被称为集合。从代码中可以看到,我们正在使用 azure.azcollection集合。
要安装它,我们需要运行两个命令;第一个下载集合本身,第二个安装集合所需的 Python 依赖项:
$ ansible-galaxy collection install azure.azcollection
$ pip3 install -r ~/.ansible/collections/ansible_collections/azure/azcollection/requirements-azure.txt
- 
安装完成后,重新运行以下命令: $ ansible-playbook playbook.yml
这应该会产生以下输出(这次我已去掉警告;如前所述,警告目前可以忽略):
PLAY [Ansible Infrastructure as Code example] *************************************************************
TASK [Gathering Facts] ************************************************************************************
ok: [localhost]
TASK [Create an example resource group] *******************************************************************
changed: [localhost]
TASK [Create an example storage account] ******************************************************************
changed: [localhost]
PLAY RECAP ************************************************************************************************
localhost: ok=3    changed=2    unreachable=0    failed=0     skipped=0    rescued=0    ignored=0
如你所见,这一次一切按计划进行,执行的三个任务中(第一个是对 localhost 的检查),有两个显示了更改。
- 
再次运行命令会显示三个 OK 的执行回顾: PLAY RECAP ************************************************************************************************ localhost: ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
你可能也注意到,它只在第一次运行时执行——如果忽略了安装先决条件的话。
与 Terraform 不同,当以这种方式执行时,Ansible 是声明式的。这意味着它按顺序运行任务,并在每个任务完成后再执行下一个任务。这意味着我们不会遇到 Ansible 尝试启动与其他尚未存在的资源关联的资源的情况。
Ansible 和 Terraform 之间的另一个关键区别是,Ansible 是无状态的——这意味着 Ansible 不会跟踪或存储资源的状态,而是在执行时查看每个资源。
就我个人而言,我认为这是 Terraform 和 Ansible 之间的一个关键区别,因为我已经数不清有多少次需要调试问题,因为某人或某些事物在 Terraform 外部更改了资源,导致 Terraform 在实际存在的资源和它认为存在的资源之间难以协调。
如果你不小心,进入这种情况是非常危险的。
你可能会发现,Terraform 唯一能够恢复其状态到它认为应该部署的方式的方法,就是开始终止并重新部署资源 —— 如果你在生产环境中,这将导致各种混乱。
另一方面,由于 Ansible 不跟踪它管理的资源的状态,它在执行 playbook 之前无法了解资源的状态或配置。
99%的情况下,运行 Ansible playbook 会执行启动或更新现有资源的任务,因此 Ansible 不跟踪状态并不是问题 —— 事实上,它可能是一个好处,因为它不会强制执行自己所知道的状态。
唯一的缺点是,由于它不知道哪些资源是存在的,因此没有 Ansible 等效的 terraform destroy 命令。当你在 Terraform 中运行这个命令时,它只会删除状态文件中存在的资源,提供了一种便捷的方式来删除 Terraform 管理的所有内容。
为了解决这个问题,我通常提供第二个 playbook,将所有或部分资源的状态设置为 absent —— 由于大多数资源的默认状态是 present,这将删除列出的资源。
在我们刚才讲解的示例中,删除资源的 playbook 如下所示:
---
- name: Ansible Infrastructure as Code example
  hosts: localhost
  tasks:
    - name: Terminate the example resource group
      azure.azcollection.azure_rm_resourcegroup:
        name: "rg-example-uks"
        location: "UK South"
        state: absent
        force_delete_nonempty: true
你可能已经注意到代码块末尾有一行空行(---);这些空行是必须存在的。
警告
你可能已经注意到,我们在前面的代码片段中将 force_delete_nonempty 标志设置为 true。使用这个标志时请小心,因为系统不会询问你是否确认,并且它会覆盖默认的操作(即资源组中有资源时会失败)。
将前面的代码放入一个名为 destroy.yml 的文件中,并运行以下命令:
$ ansible-playbook destroy.yml
这将删除资源组。由于存储账户是资源组中的子资源,并且我们已指示 Ansible 即使资源组不为空也删除资源组,因此它也将被删除。
现在我们已经了解了 Ansible,接下来让我们看看一个可以用来编写代码的工具。
介绍 Visual Studio Code
我将要介绍的最后一个工具不是一个 IaC 工具,而是一个 IDE,用于编写代码本身。
Visual Studio Code 是一个强大的代码编辑器,适用于大多数开发语言,包括你的 IaC 项目。它功能丰富、速度快且高度可定制,无论你选择哪种工具,它都是理想的选择。
最棒的部分是,Visual Studio Code 完全免费且开源。无论你是专业的网页开发人员、系统管理员,还是 DevOps 从业者,Visual Studio Code 都提供了你需要的一切,帮助你编写结构良好的代码。
这是我每天使用的工具 —— 正如你从以下截图中看到的,通过使用扩展,你可以获得诸如语法高亮等功能:

图 2.2 – 我们在 Visual Studio Code 中打开的 Terraform 示例
除了语法高亮,使用扩展后,你还可以获得如下强大的功能:
- 
内联错误检查:这是指你的代码会被检查语法错误和一般性问题,例如引用了不存在的变量或输出,并提醒你。 
- 
自动完成:这个功能在不同的扩展中有所不同,但它们可以在你输入时自动填写详细信息,建议使用哪些标志/关键字和值。 
- 
格式化:如前所述,格式化在 HCL 和 YAML 中都非常重要;这两种语言都有扩展,可以在你输入时检查格式,并在发现问题时自动修正,应该可以帮你省去使用如 Ansible 部分中链接的在线工具的麻烦。 
- 
版本控制与持续集成/持续交付(CI/CD):内置了 Git 集成,并且有适用于 GitHub、Azure DevOps 以及其他流行版本控制和 CI/CD 工具和服务的扩展。 
虽然使用像 Visual Studio Code 这样的 IDE 并非必须,但我认为如果不使用,你将错失很多功能和故障排除的帮助。
关于如何获得 Visual Studio Code 以及本书中会用到的推荐扩展的详细信息,请参见进一步阅读部分。
总结
在本章中,我们快速了解了选择适合你项目的 IaC 工具时所需要考虑的方法和因素。
我们还查看了 Terraform 和 Ansible,以及这两种工具之间的一些小差异,然后再讨论 Visual Studio Code,我希望你能安装并使用它。
在下一章,也就是本书第一部分的最后一章,我们将查看示例项目,并在书中剩余部分中使用它,同时深入学习 Terraform 和 Ansible 在两个主要公共云服务商上的应用。
进一步阅读
下面是一些帮助你深入了解 Terraform 的资源:
- 
Azure 资源管理器提供程序: registry.terraform.io/providers/hashicorp/azurerm/latest
下面是一些帮助你深入了解 Ansible 的资源:
以下是一些资源,帮助你深入了解 Visual Studio Code:
- 
官方网站: code.visualstudio.com/
- 
HashiCorp Terraform 扩展: marketplace.visualstudio.com/items?itemName=HashiCorp.terraform
- 
Red Hat Ansible 扩展: marketplace.visualstudio.com/items?itemName=redhat.ansible
- 
GitHub 仓库扩展: marketplace.visualstudio.com/items?itemName=GitHub.remotehub
第三章:部署规划
在本章中,我们将深入探讨部署我们基础设施即代码工作负载的关键阶段。在我们部署基础设施之前,必须了解我们要部署的内容以及我们希望如何进行部署。这将确保我们的部署高效、简洁且没有错误。
我们将首先介绍在接下来的两章中要部署的工作负载。这将使我们清楚地了解我们想要实现的目标以及部署所需的资源。
接下来,我们将讨论如何进行基础设施的部署。这将包括一个逐步指南,帮助我们以平稳和高效的方式规划和执行部署过程。我们还将讨论一些最佳实践和技巧,以确保成功的部署。
最后,我们将审视我们基础设施的高层架构。这将为我们提供一个关于基础设施各个组件如何结合并相互作用的概览。
在了解了我们的工作负载和部署方法后,我们可以自信地继续前进,进入第四章《部署到 Microsoft Azure》和第五章《部署到 Amazon Web Services》,在这两章中我们将深入探讨低级设计和部署代码。
工作负载的部署规划
在为项目设定样本工作负载时,有时很难找到既不太复杂,又不至于简单到只是按照步骤 1 到 10 完成就可以的例子。为了确保我们将要涵盖的项目既令人兴奋,又能涵盖你们在项目中需要做出的考虑,同时又是大多数人都曾有过一定经验的内容,我决定选择WordPress作为示例。
WordPress 是一个开源的内容管理系统(CMS),基于 PHP 和 MySQL 进行托管,允许用户建立网站和博客。它于 2003 年开发,并已成为世界上最流行的 CMS 平台之一,支撑着数百万个网站。WordPress 因其简便性和灵活性而闻名,使其成为所有技能水平用户的理想选择。我知道你们在想什么:但 WordPress 有著名的五分钟安装,只需要按几个简单的步骤进行就行了!然而,在我们的案例中,我们将着眼于如何在多个主机上部署 WordPress,并使用每个公共云提供的原生服务来管理数据库、存储和网络层。
WordPress 的一个关键特点是其主题的使用,允许用户轻松地改变网站的外观和风格,而无需修改底层代码。这使得用户可以轻松制作专业外观的网站,而不需要具备网页设计或编程知识。除了 WordPress 拥有庞大且活跃的用户和开发者社区外,平台上不断推出新的功能和更新。这与其开源性质相结合,使其成为任何想要开发网站或博客的人的绝佳选择。
在我们继续之前,先提醒一句。
信息
尽管我们将把 WordPress 部署到多个实例上,但这样做是为了给出一个关于如何处理自己的基础设施即代码(Infrastructure as Code)项目的示例;请不要将其作为部署和管理自己高可用性 WordPress 安装的指南。
现在我们大致了解了将要部署的内容,让我们来解答一些可能出现的问题,以便更好地理解我们将如何进行部署。
如何部署我们的基础设施
首先,正如我们之前提到的,WordPress 基于 PHP 和 MySQL 运行;更具体地说,它有以下要求:
- 
一款 Web 服务器软件,如 Apache 或 NGINX 
- 
PHP 版本 7.4 或更高版本 
- 
MySQL 版本 5.7 或更高版本,或 MariaDB 版本 10.3 或更高版本 
信息
请注意,在写作时,PHP 8 仅在 WordPress 6.1 版本中提供 beta 支持;因此,在我们的示例部署中,我们将安装 PHP 7。
部署考虑
因此,结合我们的需求,我们知道我们需要在某个地方安装 Web 服务器和 PHP,而在接下来的两章中我们将查看的所有云服务都提供某种形式的应用托管服务。对于我们的项目,我们将使用运行 Ubuntu 的虚拟机实例。
与其启动一个单一的主机(这将是单点故障),不如考虑启动至少两个虚拟机实例来运行 WordPress。此方法引入了一些复杂性,因为 WordPress 更倾向于作为一个固定点运行。那么,在跨多个虚拟机实例运行 WordPress 时,我们需要考虑哪些问题呢?
- 
跨主机共享存储——所有的 WordPress 代码和文件应该存储在一个所有虚拟机实例都可以访问的文件系统中。由于我们使用的是 Ubuntu Linux,因此应选择 NFS,而不是 Samba 或 Windows 文件共享——这应该是云服务提供商提供的平台即服务(PaaS)。 
- 
在使用基础设施即代码脚本安装 WordPress 时,我们应该只从单一虚拟机实例进行操作,且仅执行一次——我们称之为 管理主机。所有其他主机或 Web 主机都应该安装并配置好运行 WordPress 所需的所有软件包,待 WordPress 成功启动后,再挂载 NFS 共享。 
- 
除了需要一个分配流量到我们多个虚拟机实例的方式外,我们还需要考虑如何为我们网站的 WordPress 管理部分提供流量。 
数据库怎么办?由于我们将使用的云服务提供 MySQL 作为服务,我们将在部署中利用这些服务。很好,你可能会想——是的,这样就少了一个我们需要在虚拟机实例上管理的资源——但是我们也需要在这里做一些考虑,还是有一些需要注意的关键点:
- 
在初步启动 WordPress 之前,我们需要知道数据库主机的端点以及访问它所需的凭据。 
- 
我们希望将数据库端点锁定为仅允许我们的虚拟机实例访问,因为只有这些实例需要访问数据库。 
- 
我们还应该设置数据库备份! 
和数据库即服务一样,正如前面提到的,我们应该为我们的 共享存储 使用 NFS 作为服务;这里也有一些考虑事项:
- 
我们需要知道 NFS 端点,以便在启动 WordPress 之前将其挂载,因为在启动额外的虚拟机实例之前,我们需要确保 WordPress 已经正确安装。 
- 
同样地,像数据库一样,NFS 服务需要被限制为仅允许受信任的虚拟机实例连接——我们不希望任何人都能够随意连接并浏览/下载我们的文件系统内容。 
在部署过程中,我们还需要考虑其他方面:
- 
私有网络:由于我们希望对资源进行限制,我们将需要某种内部网络来启动我们的资源。 
- 
负载均衡:我们需要一个第七层负载均衡服务,将流量分配到我们的后端服务。 
- 
启动:我们需要在虚拟机实例上启动软件栈和 WordPress 本身。 
现在我们已经了解了关键的考虑事项,让我们来看一下需要执行的具体部署任务。
执行部署任务
基于上一节的信息,我们大致了解了需要发生的事情以及执行的顺序。这一切都从在我们首选的云服务提供商中启动资源开始。为了使用基础设施即代码部署我们的工作负载,我们需要执行以下任务:
- 
启动并配置我们私有网络所需的资源。 
- 
启动并配置数据库即服务。 
- 
启动并配置 NFS 文件系统作为服务。 
- 
启动并配置负载均衡器服务。 
现在我们在云服务提供商那边已经有了核心资源,我们可以执行以下任务:
- 
收集我们在云服务提供商中已启动的服务和资源的信息。 
- 
动态生成启动管理员虚拟机实例所需的脚本。 
- 
动态生成启动 Web 虚拟机实例所需的脚本。 
一旦我们有了脚本,就可以继续启动我们的工作负载。
- 
启动管理员虚拟机实例,附加我们生成的脚本;在实例首次启动时执行后,应该执行以下操作: - 
执行操作系统更新。 
- 
下载、安装并配置 Apache、PHP、MySQL 客户端和 NFS 客户端。 
- 
通过创建挂载点、设置开机挂载,并确保在继续之前 NFS 共享已挂载,来配置远程 NFS 共享。 
- 
下载 WordPress,初始化数据库,并配置网站。 
- 
启动 Web 服务器并确保我们安装和配置的所有服务都已配置为在重启后自动启动。 
 
- 
现在管理员虚拟机实例已经启动,我们希望在我们的 NFS 共享上有一个工作中的 WordPress 副本,这意味着我们可以继续配置其余的 Web 虚拟机实例:
- 
启动 Web 虚拟机实例,附加我们生成的脚本;在实例启动时执行后,应该执行以下操作: - 
执行操作系统更新。 
- 
下载、安装并配置 Apache、PHP、MySQL 客户端和 NFS 客户端。 
- 
通过创建挂载点、设置开机挂载并确保 NFS 共享在继续之前已挂载,来配置远程 NFS 共享。 
- 
启动 Web 服务器并确保我们安装和配置的所有服务在重启后会自动启动。 
 
- 
如果一切按计划进行,我们现在应该有一个工作中的 WordPress 安装,分布在少数几个虚拟机实例上,这也只剩下最后一个任务。
- 将所有虚拟机实例注册到负载均衡器,以便它们开始接收流量。
还有一些云服务提供商特定的任务,我们将在接下来的几章中讨论,当我们深入设计并开始编写基础设施即代码时;这总结了我们需要按大致顺序完成的任务。
在第二章**《Ansible 和 Terraform 超越文档》中,我们提到过一个观点,那就是 Terraform 并不完全设计用于部署和配置软件——它并不那么简单,不能直接 SSH 进入虚拟机主机来安装和配置软件栈,那么我们该如何做到呢?
让我们现在回答这个问题,使用一个叫做cloud-init的工具。
引入 cloud-init。
在上一节中列出的第 6 步和第 7 步中,我们讨论了生成一个脚本——这将是一个 cloud-init 脚本。这是一个与云和 Linux 操作系统无关的工具,用于在实例启动时进行引导。
它在 Microsoft Azure 和 Amazon Web Services 上都受支持,我们将使用基础设施即代码工具根据已启动资源(如 SQL 和 NFS 端点)收集的信息填充一个基础模板,然后在启动虚拟机实例时将输出附加到这些实例。
以下是一个示例 cloud-init 脚本,部署到虚拟机实例时将执行以下任务:
- 
更新虚拟机上已安装的所有软件包,以确保我们已完全打上补丁。 
- 
安装 NGINX。 
- 
创建一个默认的 NGINX 站点。 
- 
创建一个示例 index.html文件,并将其放置在我们在第 2 步中配置的默认 NGINX 站点根目录中。
- 
重启 NGINX 服务以加载新的配置。 
执行这些步骤的脚本如下所示:
#cloud-config
package_upgrade: true
packages:
  - nginx
write_files:
  - owner: www-data:www-data
    path: /etc/nginx/sites-available/default
    content: |
      server {
          listen         80 default_server;
          root           /var/www/site;
          index          index.html;
          try_files $uri /index.html;
      }
  - owner: www-data:www-data
    path: /var/www/site/index.html
    content: |
      <!DOCTYPE html>
      <html>
          <head>
              <title>Example</title>
          </head>
          <body>
              <p>This is an example of a simple HTML page.</p>
          </body>
      </html>
runcmd:
  - service nginx restart
正如你所看到的,阅读和跟随发生的事情相对简单;我们将用于部署 WordPress 的方法要复杂一些,因为它将执行比我们刚刚给出的示例更多的操作——但更多内容会在第四章,“部署到 Microsoft Azure” 和 第五章,“部署到 Amazon Web Services”中介绍。
信息
请注意,虽然上面的示例使用了 NGINX 作为 Web 服务器,但我们将在 WordPress 部署中使用 Apache 作为 Web 服务器。
这意味着我们仍然可以使用 Terraform 的程序化部分配置虚拟机实例,而无需通过 SSH 进入它们。对于 Ansible,在第六章,“在基础之上构建”中,我们将采取稍微不同的方法,通过 SSH 登录到虚拟机,以便对软件堆栈及其配置进行更改。
现在我们知道了要采取的部署工作负载的步骤,让我们直观地了解它将如何呈现。
探索高层次架构
现在我们知道了要部署的内容,我们应该对高层次架构有一个清晰的了解。以下图表展示了我们将在接下来两章中部署的资源如何结合在一起的云无关概览:

图 3.1 – 我们高层次云架构的概述
从软件堆栈的角度来看,每个虚拟机实例将如下所示:

图 3.2 – 我们高层次软件架构的概述
虽然这并不是最详尽的高层设计,但我们已经大致了解了为部署基于 WordPress 的工作负载需要编写哪些代码。
总结
在本章中,我们讨论了即将在接下来的两章中,使用 Terraform 和 Ansible 将示例项目部署到微软 Azure 和亚马逊 Web 服务的情况。尽管我们保持了较高的讨论层次,并尽可能地做到云平台中立,但我们已经清楚需要执行的任务,并大致了解如何绕过 Terraform 作为工具在应用程序部署管理上的局限性。
现在我们已经知道了云和软件架构的总体情况,并且对资源部署的顺序有了一定的了解,我们可以开始进行低层设计和实际的部署工作。在下一章中,我们将开始部署到微软 Azure 上讨论的工作负载。
深入阅读
你可以在以下网址找到更多我们在本章提到的软件的详细信息:
- 
WordPress: wordpress.org/
- 
PHP: www.php.net/
- 
MySQL: www.mysql.com/
- 
NGINX: nginx.org/
- 
cloud-init:cloud-init.io/
第二部分:动手部署
现在我们已经了解了将要使用的工具,并且对需要执行的任务有了初步的认识,接下来是时候卷起袖子,开始编写代码并进行部署了。
在本部分中,我们将使用 Terraform 和 Ansible 在微软 Azure 和亚马逊 Web 服务中部署工作负载,并讨论如何在脚本上进行扩展。
本部分包含以下章节:
- 
第四章**,部署到微软 Azure 
- 
第五章**,部署到亚马逊 Web 服务 
- 
第六章**,在基础上构建 
第四章:部署到微软 Azure
在第四章中,我们将学习如何使用我们将在本书中覆盖的两个主要公共云提供商之一——微软 Azure,来部署我们的项目。
我们将涵盖以下主题:
- 
介绍微软 Azure 
- 
准备我们的云环境以进行部署 
- 
生成低级设计 
- 
Terraform – 编写代码并部署我们的基础设施 
- 
Ansible – 审查代码并部署我们的基础设施 
我们将深入了解微软 Azure,首先介绍平台、其关键功能,以及它为基于云的应用程序部署所带来的优势。我们还将探索 Azure 中的不同服务及其如何融入我们为 WordPress 工作负载设计的架构中。
接下来,我们将基于 Terraform 知识,学习所需的代码来配置和管理我们的 Azure 云基础设施。最后,我们将探索 Ansible,这是另一个用于自动化基础设施部署和配置管理的关键工具。
到本章结束时,你将了解微软 Azure 及其各种组件,并掌握使用 Terraform 和 Ansible 在该云平台上部署和管理应用程序所需的技能。
技术要求
由于部署项目所需的代码量较大,关于本章的 Terraform 和 Ansible 部分,我们不会覆盖所有部署项目所需的代码。随本书附带的代码仓库将包含完整的可执行代码。
引入并准备我们的云环境
2008 年,微软推出了 Windows Azure,这是一项基于云的数据中心服务,开发时的内部项目代号为 Project Red Dog。该服务包括五个核心组件:
- 
微软 SQL 数据服务,SQL 数据库的云版本,旨在简化托管过程。 
- 
微软 .NET 服务,一种平台即服务(PaaS),允许开发人员将基于 .NET 的应用程序部署到微软管理的运行时环境中。 
- 
微软 SharePoint 和 Dynamics,公司内部网和客户关系管理产品的软件即服务(SaaS)版本。 
- 
Windows Azure 是一种基础设施即服务(IaaS)产品,使用户能够为计算工作负载创建虚拟机、存储和网络服务。 
微软在 Windows Azure 中提供的所有服务都建立在 Red Dog 操作系统上,这是他们 Windows NT 操作系统的一个专门版本,专门设计了一个云层来支持数据中心服务的交付。
2014 年,公司决定将该服务重新命名为 Microsoft Azure;随着他们添加更多服务,取消 Windows 品牌化是有意义的,尤其是因为平台上托管的 Linux 工作负载数量不断增加。这个趋势在接下来的几年中持续,到了 2020 年,据报道,超过一半的 Azure 虚拟机核心和大量的 Azure Marketplace 镜像是基于 Linux 的,这表明 Microsoft 对 Linux 和开源技术的接受程度越来越高,作为他们一些核心服务的构建基石。
现在我们对 Microsoft Azure 有了一些背景知识,让我们开始准备部署的云环境。
为我们的云环境做部署准备
在本章中,我们将在本地机器上运行 Terraform 和 Ansible 脚本——这样部署就会稍微容易一些,因为我们可以利用已登录的会话使用 Azure 命令行界面(CLI)。有关如何安装的详细信息,请参阅官方文档:learn.microsoft.com/en-us/cli/azure/install-azure-cli。
安装完成后,确保您已登录到希望部署资源的帐户;您可以通过运行以下命令来完成此操作:
$ az login
然后,按照屏幕上的提示操作;如果您已经登录,您可以通过运行以下命令来再次确认当前登录的详细信息:
$ az account show
现在我们已经准备好了环境,接下来可以查看我们将要部署的服务。
制作低层次设计
基于我们在第三章中讨论的部署,部署规划,我们知道在 Microsoft Azure 上运行工作负载需要以下资源:

图 4.1 – 我们将在 Azure 中启动的资源概览
我们将使用以下服务:
- 
Azure 负载均衡器:这是一种作为服务提供的 TCP 负载均衡器——虽然我更希望使用 Azure 应用程序网关 来终止我们的 HTTP/HTTPS 连接,但这会在本书的这个阶段为我们的构建增加一些复杂性。 
- 
虚拟网络:我们的服务将被部署到的核心网络服务,或者配置为从中访问。 
- 
虚拟机:我们将使用单个Linux 虚拟机作为我们的 WordPress 管理实例——它将负责应用程序的初步引导。 
- 
虚拟机规模集:这类似于 Linux 虚拟机,但该服务旨在从单一资源管理多个虚拟机,允许我们在需要时进行扩展。 
- 
Azure 存储账户/Azure 文件:我们的 WordPress 文件将存储在一个 NFS 共享中,该共享只能由我们虚拟网络中的受信 IP 地址访问,这些 IP 地址运行着我们的虚拟机和虚拟机规模集实例。 
- 
Azure Database for MySQL - Flexible Server:我们的 WordPress 安装需要一个数据库服务器;由于我们运行在公共云中,选择数据库即服务(DBaaS)是合理的。该服务将使 MySQL 服务器和数据库在我们的虚拟网络内可访问。 
解决方案中还包含其他服务,如Azure 私有 DNS、私有终端、网络安全组和公共 IP,以支持安全地访问我们将在虚拟网络内启动的核心服务。
现在我们已经知道要启动哪些服务,接下来可以开始编写我们的代码。
Terraform – 编写代码并部署我们的基础设施
既然我们已经知道要部署哪些服务,我们可以开始进行 Terraform 部署了。为了使事情更易于管理,我会将我们的代码拆分成多个文件,它们将命名为:
- 
001-setup.tf
- 
002-resource-group.tf
- 
003-networking.tf
- 
004-storage.tf
- 
005-database.tf
- 
006-vm-admin.tf
- 
007-vmss-web.tf
- 
098-outputs.tf
- 
099-variables.tf
- 
vm-cloud-init-admin.yml.tftpl
- 
vmss-cloud-init-web.tftpl
我这样做是为了更有逻辑地将与部署代码某一部分相关的所有功能组合在一起;例如,所有网络相关的元素都可以在 003-networking.tf 文件中找到,而在 099-variables.tf 文件中则定义了使用的变量。
信息
正如本章开头提到的,接下来展示的并不是每个文件中包含的 100% 代码,我将在文中参考来自 variables 文件的代码块,同时也会参考其他文件中的代码块。
不再浪费时间,让我们直接看看 Terraform 代码,首先从设置任务开始。
设置 Terraform 环境
我们需要做的第一件事是为部署设置 Terraform 环境。为此,我们需要确认使用哪个版本的 Terraform,并下载哪些提供商:
terraform {
  required_version = ">=1.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3.0"
    }
    azurecaf = {
      source = "aztfmod/azurecaf"
    }
    random = {
      source = "hashicorp/random"
    }
    http = {
      source = "hashicorp/http"
    }
  }
}
现在,我们需要为其中一个提供商添加配置块:
provider "azurerm" {
  features {}
}
虽然我们没有在其中进行任何自定义配置,但它必须存在才能继续进行部署。
最后,我们来到了第一个任务,它使用来自Terraform 注册表的模块,来列出 Microsoft 定义的地区名称的所有变体,包括全名、简称等。
要调用模块并传递区域变量,请使用以下代码:
module "azure_region" {
  source       = "claranet/regions/azurerm"
  azure_region = var.location
}
我们使用的 var.location 变量在 099-variables.tf 文件中定义如下:
variable "location" {
  description = "Which region in Azure are we launching the resources"
  default     = "West Europe"
}
如你所见,我们将 default 设置为 West Europe;别担心,如果你不想在该地区启动资源,我们将在第六章中讲解如何覆盖变量来执行部署,在基础上构建。
这就涵盖了 001-setup.tf 文件,既然我们已经完成了所有基础设置,接下来可以开始创建 资源组。
创建资源组
正如前面章节所提到的,我的日常工作中有很多与 Microsoft Azure 相关的内容,其中一项是我始终遵循的 云采用框架。这是一个关于如何在 Microsoft Azure 中部署资源的合理文档化推荐集,其中包括命名方案。因此,我们使用的一个提供程序帮助根据我们传递的信息动态创建 Azure 资源名称;在整个部署过程中我们都会使用它,因为该提供程序的目标之一是为我们将要部署的几乎所有资源引入命名一致性。用于生成资源组名称的代码如下:
resource "azurecaf_name" "resource_group" {
  name          = var.name
  resource_type = "azurerm_resource_group"
  suffixes      = [var.environment_type, module.azure_region.location_short]
  clean_input   = true
}
如你所见,我们传递了几项信息——三个变量和动态生成的信息,具体包括以下两个变量:
variable "name" {
  description = "Base name for resources"
  default     = "iac-wordpress"
}
variable "environment_type" {
  description = "type of the environment we are building"
  default     = "prod"
}
同时,我们还在使用输出的 Azure 区域模块,该模块会提供我们在 099-variables.tf 中定义的区域的短名称。它的引用方式如下:
module.azure_region.location_short
我们传递的另一个重要信息是 resource_type,在我们这里是 azurerm_resource_group。这将输出如下图所示的结果,然后我们可以将其传递给下一个资源块:

图 4.2 – 资源组命名
现在我们已经有了资源名称,接下来可以定义资源组块:
resource "azurerm_resource_group" "resource_group" {
  name     = azurecaf_name.resource_group.result
  location = module.azure_region.location_cli
  tags     = var.default_tags
}
如你所见,我们引用 azurecaf_name.resource_group.result 作为资源名称,同时通过 module.azure_region.location_cli 使用区域名称的另一种变体,它会将输出结果显示为 westeurope,而不是 West Europe 或 euw。
我们传递的最后一个变量是 map 类型,而非 string 类型。它看起来如下:
variable "default_tags" {
  description = "The default tags to use across all of our resources"
  type        = map(any)
  default = {
    project     = "iac-wordpress"
    environment = "prod"
    deployed_by = "terraform"
  }
}
这在整个部署过程中都会使用,并且会为每个使用它们的资源添加三个不同的标签,project、environment 和 deployed_by。这是我们使用的最简单的映射形式,仅仅是一个键值对列表。
随着我们进入下一部分——网络,事情会变得稍微复杂一些,因为我们开始使用映射来为部署引入一些逻辑。
网络
除了使用 azurecaf_name 提供程序命名所有资源外,我们还将在这一部分配置并启动以下资源:
- 
Azure 虚拟网络,在这里我们将配置主要的网络资源,并且设置三个子网——这也是映射变得复杂的地方。 
- 
Azure 负载均衡器,以及资源本身,我们将配置一个公共 IP 地址、后端池、健康探针和两种类型的规则——负载均衡和 NAT;这一部分稍后会详细介绍。 
- 
网络安全组,包含两条规则,用于允许安全访问我们的服务 
让我们深入了解一些更令人兴奋的内容,看看 Azure 虚拟网络。
Azure 虚拟网络
配置我们底层网络的第一部分是虚拟网络资源本身。为此,我们将使用两个主要变量;第一个很简单:
variable "vnet_address_space" {
  description = "The address space of vnet"
  type        = list(any)
  default     = ["10.0.0.0/24"]
}
如你所见,这定义了我们将用于虚拟网络的地址空间,它是一个包含单一值的列表;然后在以下代码块中调用它:
resource "azurerm_virtual_network" "vnet" {
  resource_group_name = azurerm_resource_group.resource_group.name
  location            = azurerm_resource_group.resource_group.location
  name                = azurecaf_name.vnet.result
  address_space       = var.vnet_address_space
  tags                = var.default_tags
}
表面上没有什么特别的。为了确保虚拟网络在资源组创建之后创建,通过传递动态生成的名称、默认标签和地址空间列表(在我们的例子中仅包含一个项目),我们使用以下引用:
- 
azurerm_resource_group.resource_group.name
- 
azurerm_resource_group.resource_group.location
第二个变量是我们定义子网的地方,也是让事情变得有趣的地方:
variable "vnet_subnets" {
  description = "The subnets to deploy in the vnet"
  type = map(object({
    subnet_name = string
    address_prefix = string
    private_endpoint_network_policies_enabled = bool
    service_endpoints = list(string)
    service_delegations  = map(map(list(string)))
  }))
上面的代码定义了每个子网所需的变量,而下面的代码设置了我们将在部署中使用的默认设置,从虚拟机的子网开始:
  default = {
    virtual_network_subnets_001 = {
      subnet_name = "vms"
      address_prefix = "10.0.0.0/27"
      private_endpoint_network_policies_enabled = true
      service_endpoints  = ["Microsoft.Storage"]
      service_delegations = {}
    },
第二个子网将用于我们将要部署的私有端点:
    virtual_network_subnets_002 = {
      subnet_name  = "endpoints"
      address_prefix = "10.0.0.32/27"
      private_endpoint_network_policies_enabled = true
      service_endpoints = ["Microsoft.Storage"]
      service_delegations = {}
    },
我们需要的第三个也是最后一个子网是用于 database 服务的:
    virtual_network_subnets_003 = {
      subnet_name = "database"
      address_prefix = "10.0.0.64/27"
      private_endpoint_network_policies_enabled = true
      service_endpoints = ["Microsoft.Storage"]
      service_delegations = {
        fs = {
          "Microsoft.DBforMySQL/flexibleServers" = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
        }
      }
    },
  }
}
正如你所看到的,这里发生了很多事情,让我们稍微分解一下。
我们在这里定义的是一个包含多个对象的映射;这些对象是字符串、布尔值、列表,最后是由映射组成的映射,其中包含一个字符串列表!
让我们从简单的开始,看看我们如何为子网命名。为此,我们使用 for_each 循环:
resource "azurecaf_name" "virtual_network_subnets" {
  for_each      = var.vnet_subnets
  name          = each.value.subnet_name
  resource_type = "azurerm_subnet"
  suffixes      = [var.name, var.environment_type, module.azure_region.location_short]
  clean_input   = true
}
这会从我们的三个映射中的每一个获取 subnet_name 值,并创建三个资源名称;将生成以下名称:
- 
snet-endpoints-iac-wordpress-prod-euw
- 
snet-vms-iac-wordpress-prod-euw
- 
snet-database-iac-wordpress-prod-euw
我们将采用类似的方法,通过使用 for_each 循环来创建子网,但这次会更多地使用映射对象中的信息。创建子网的代码块如下所示:
resource "azurerm_subnet" "vnet_subnets" {
  for_each             = var.vnet_subnets
  name                 = azurecaf_name.virtual_network_subnets[each.key].result
  resource_group_name  = azurerm_resource_group.resource_group.name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = [each.value.address_prefix]
  service_endpoints = try(each.value.service_endpoints, [])
  private_endpoint_network_policies_enabled = try(each.value.private_endpoint_network_policies_enabled, [])
  dynamic "delegation" {
    for_each = each.value.service_delegations
    content {
      name = delegation.key
      dynamic "service_delegation" {
        for_each = delegation.value
        iterator = item
        content {
          name = item.key
          actions = item.value
        }
      }
    }
  }
}
如前所述,我们使用 for_each 参数来遍历 vnet_subnets 映射中的元素。我们使用 azurecaf_name 循环的结果设置 name 属性;每三个名称通过键来引用,在映射中如下所示:
- 
virtual_network_subnets_001
- 
virtual_network_subnets_002
- 
virtual_network_subnets_003
尽管在这个实例中,我们不需要为每个值硬编码,因此我们可以使用 azurecaf_name.virtual_network_subnets[each.key].result。resource_group_name 属性使用 azurerm_resource_group 的输出进行设置。address_prefixes 属性被设置为一个包含当前子网的 vnet_subnets 键的 address_prefix 值的列表。
service_endpoints 属性会设置为当前子网的对应值(如果提供了该值)。如果未提供该值,则使用空列表。
类似地,private_endpoint_network_policies_enabled 属性会设置为当前子网的对应值(如果提供了该值)。如果未提供该值,则使用空列表。最后,代码包含对当前子网的 service_delegations 属性进行的嵌套循环。
动态块 是一个在运行时评估的设置块,允许你根据输入变量的值以及其他任务的输出动态添加块。动态块对于根据运行时才能知道的数据创建可变数量的块非常有帮助。
动态块为 service_delegations 映射中的每个元素创建一个 delegation 块。iterator 参数被设置为 item,表示当前正在处理的元素,content 块创建一个 service_delegation 块,其中键为名称,值为动作。
为了让你了解这是什么样子的,如果我们手动定义 virtual_network_subnets_002 和 virtual_network_subnets_003 映射对象,它们会像以下这样:
resource "azurerm_subnet" "virtual_network_subnets_002" {
  name                 = "snet-endpoints-iac-wordpress-prod-euw"
  resource_group_name  = "rg-iac-wordpress-prod-euw"
  virtual_network_name = "vnet-iac-wordpress-prod-euw"
  address_prefixes     = ["10.0.0.32/27"]
  service_endpoints    = ["Microsoft.Storage"]
  private_endpoint_network_policies_enabled = true
}
resource "azurerm_subnet" "virtual_network_subnets_003" {
  name                 = "snet-database-iac-wordpress-prod-euw"
  resource_group_name  = "rg-iac-wordpress-prod-euw"
  virtual_network_name = "vnet-iac-wordpress-prod-euw"
  address_prefixes     = ["10.0.0.64/27"]
  service_endpoints    = ["Microsoft.Storage"]
  private_endpoint_network_policies_enabled = true
  delegation {
      name = "fs"
      service_delegation {
          name = "Microsoft.DBforMySQL/flexibleServers"
          actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
      }
   }
}
如你所见,虽然它看起来复杂,但它是一种极好的方式,可以用更少的硬编码值编写代码,这使得该块更容易重用。
当我们添加网络安全组时,我们将引用 for_each 循环的输出,并且在我们启动资源并将它们放入刚刚定义的子网时,也会开始将资源附加到我们的子网。
接下来的块启动并配置 Azure 负载均衡服务;这里没有太多新的内容,但以下是每个块的简要概述:
- 
"azurerm_public_ip" "load_balancer"创建一个公共 IP 地址
- 
"azurerm_lb" "load_balancer"启动负载均衡器本身,并附加我们刚刚启动的公共 IP 地址
- 
"azurerm_lb_backend_address_pool" "load_balancer"创建一个后端池;当我们启动虚拟机和虚拟机规模集实例时,我们会将它们附加到这个后端池
- 
"azurerm_lb_probe" "http_load_balancer_probe"添加一个健康探针来检查端口80是否开放,并通过简单的 TCP 测试进行检查
- 
"azurerm_lb_rule" "http_load_balancer_rule"创建一个规则,均匀分配端口80(即 HTTP 请求)的传入请求到后端池中的实例,前提是它们显示为健康状态
- 
"azurerm_lb_nat_rule" "sshAccess"创建一个规则,将2222>2232映射到后端实例上的端口22,从而为我们提供 SSH 访问后端池中每个实例的权限
网络的最后几个任务是创建并配置网络安全组;前几个任务没什么复杂的内容:
- 
"azurerm_network_security_group" "nsg"创建了网络安全组
- 
"azurerm_network_security_rule" "AllowHTTP"向我们刚刚创建的网络安全组添加了一条规则,允许通过端口80访问 HTTP;这对所有人开放
接下来,我们需要添加一条规则来允许 SSH 访问我们的主机,但 SSH 不是您希望暴露给整个互联网的服务——即使我们打算使用非标准端口访问实例(记住我们在 Azure 负载均衡器上使用 NAT 规则将端口 2222 > 2232 映射到实例的端口 22)。因此,我们将使用一个数据源来获取当前运行 Terraform 的主机的公共 IP 地址。
获取当前 IP 地址的代码块使用了 HTTP Terraform 提供程序,格式如下:
data "http" "current_ip" {
  url = "https://api.ipify.org?format=json"
}
如您所见,我们正在调用 api.ipify.org/?format=json URL,它返回一个包含您当前公共 IP 地址的 JSON Blob。
然后我们可以将这个 Blob 与默认为空的network_trusted_ips变量结合起来,该变量包含其他受信任 IP 地址的列表:
variable "network_trusted_ips" {
  description = "Optional list if IP addresses which need access, your current IP will be added automatically"
  type        = list(any)
  default = [
  ]
}
现在我们有了包含 IP 地址的 JSON 以及我们想允许的其他 IP 地址的可选列表,我们可以创建规则本身:
resource "azurerm_network_security_rule" "AllowSSH" {
  name        = "AllowSSH"
  description = "Allow SSH"
  priority    = 150
  direction   = "Inbound"
  access      = "Allow"
  protocol    = "Tcp"
  source_address_prefixes     = setunion(var.network_trusted_ips, ["${jsondecode(data.http.current_ip.response_body).ip}"])
  source_port_range           = "*"
  destination_port_range      = "22"
  destination_address_prefix  = "*"
  resource_group_name         = azurerm_resource_group.resource_group.name
  network_security_group_name = azurerm_network_security_group.nsg.name
}
如您所见,一切都发生在 source_address_prefixes 条目中;在这里,我们使用内置的 setunion 函数,它合并了 var.network_trusted_ips 的内容——在我们的案例中是一个空列表——以及我们使用 HTTP 提供程序发出的请求返回的 JSON 响应体中的内容。
我稍微更新了一下相关代码,使其更易于阅读:
setunion(
  var.network_trusted_ips,
 ["${jsondecode(data.http.current_ip.response_body).ip}"]
)
我们使用 var.network_trusted_ips 是因为它已经定义为一个列表;然而,我们的 IP 地址还没有,所以我们创建一个使用 [ ] 的列表,并在 Terraform 中添加一个内联变量;这使用 ${ something here } 来定义。在我们的例子中,something here 使用内置的 jsondecode 函数,该函数获取 data.http.current_ip.response_body 中保存的响应主体,并提取 ip 键的值,即我们的公共 IP 地址。
现在我们有了网络安全组,我们需要将它附加到子网,该子网将托管虚拟机和虚拟机规模集实例。为了做到这一点,我们需要子网的 ID。为了简化,我创建了三个变量,分别是每个子网映射对象的名称:
variable "subnet_for_vms" {
  description = "Reference to put the virtual machines in"
  default     = "virtual_network_subnets_001"
}
variable "subnet_for_endpoints" {
  description = "Reference to put the private endpoints in"
  default     = "virtual_network_subnets_002"
}
variable "subnet_for_database" {
  description = "Reference to put the database in"
  default     = "virtual_network_subnets_003"
}
现在我们知道了对象的名称,即键名称,我们可以在将网络安全组与托管实例的子网关联的代码块中使用它:
resource "azurerm_subnet_network_security_group_association" "nsg_association" {
  subnet_id = azurerm_subnet.vnet_subnets["${var.subnet_for_vms}"].id
  network_security_group_id = azurerm_network_security_group.nsg.id
}
这就完成了部署的网络部分,我们现在有了基础来开始部署资源,首先是存储。
存储
我们需要创建一个启用了 NFS 的存储账户;这些任务大多都非常直接:
- 
"azurecaf_name" "sa"生成存储帐户的名称;有一个小小的区别,就是我们告诉它添加一个随机字符串——我们这么做是因为存储帐户名称必须在 Azure 中唯一,所以如果我们不添加这个随机字符串,代码可能会因为已有人执行过相同的操作而报错。
- 
"azurecaf_name" "sa_endpoint"使用之前的结果,然后生成私有端点的名称,私有端点将被放置在虚拟网络中
- 
"azurerm_storage_account" "sa"创建存储帐户本身
我们现在需要将存储帐户锁定,仅允许我们虚拟网络中的三个子网和受信任的 IP 地址进行访问——为此,我们使用如下的代码块:
resource "azurerm_storage_account_network_rules" "sa" {
  storage_account_id = azurerm_storage_account.sa.id
  default_action     = var.sa_network_default_action
  ip_rules           = setunion(var.network_trusted_ips, ["${jsondecode(data.http.current_ip.response_body).ip}"])
  bypass             = var.sa_network_bypass
  virtual_network_subnet_ids = [
    for subnet_id in azurerm_subnet.vnet_subnets :
    subnet_id.id
  ]
}
如您所见,IP 规则使用了我们在将公共 IP 地址添加到网络安全组规则时所采用的相同逻辑,以便通过受信任的 IP 地址使用 SSH 访问实例。
获取子网 ID 的代码块部分使用 for 循环遍历 azurerm_subnet.vnet_subnets 列表,对于列表中的每个子网,它提取子网的 id 属性并将其添加到 virtual_network_subnet_ids 列表中。
一旦我们为存储帐户配置了网络规则,就可以添加 NFS 共享:
resource "azurerm_storage_share" "nfs_share" {
  name                 = replace(var.name, "-", "")
  storage_account_name = azurerm_storage_account.sa.name
  quota                = var.nfs_share_quota
  enabled_protocol     = var.nfs_enbled_protocol
  depends_on = [
    azurerm_storage_account_network_rules.sa
  ]
}
如您所见,我们使用 depends_on 来确保网络规则已经配置。我们必须声明 depends_on,因为在执行 "azurerm_storage_share" "nfs_share" 时,"azurerm_storage_account_network_rules" "sa" 并没有输出可以引用。
提示
我们在部署中会使用少量的 depends_on。然而,最佳实践是尽量减少 depends_on 的使用,让 Terraform 尽可能自行推断依赖关系,因为过度使用 depends_on 会降低执行效率。
眼尖的朋友可能也注意到了,在命名这个资源时我们做了些不同的事情;我们使用了内置的 replace 函数,将 var.name 变量中的内容并去除其中的连字符。
剩下的任务都比较基础:
- 
"azurerm_private_dns_zone" "storage_share_private_zone"为privatelink.file.core.windows.net创建一个私有 DNS 区域
- 
"azurerm_private_dns_zone_virtual_network_link" "storage_share_private_zone"将我们创建的私有 DNS 区域附加到虚拟网络
- 
"azurerm_private_endpoint" "storage_share_endpoint"创建私有端点,将其放入var.subnet_for_endpoints变量中定义的子网,并将其注册到私有 DNS 区域
这就是存储部分的结束;接下来是数据库服务。
数据库
随着我们开始启动数据库服务,现在已经进入了正轨:
- 
"azurecaf_name" "mysql_flexible_server"生成 Azure MySQL 弹性服务器的名称
- 
"azurecaf_name" "database"生成我们将用于 WordPress 的数据库名称
- 
"azurerm_private_dns_zone" "mysql_flexible_server"添加我们将为 Azure MySQL 灵活服务器使用的私有 DNS 区域
- 
"azurerm_private_dns_zone_virtual_network_link" "mysql_flexible_server"将私有 DNS 注册到我们的虚拟网络
在我们启动Azure MySQL 灵活服务器之前,还有一件事需要做,那就是创建密码。与其使用变量传递,不如使用随机提供者,根据我们提供的参数程序化地生成一个密码。
以下代码块将生成一个 16 位的随机密码,并且不使用任何特殊字符:
resource "random_password" "database_admin_password" {
  length  = 16
  special = false
}
剩下的代码块用于启动和配置我们的 Azure MySQL 灵活服务器实例,内容并没有太多不同,下面是每个代码块作用的简要总结:
- 
"azurerm_mysql_flexible_server" "mysql_flexible_server"启动灵活服务器;我们在这里使用depends_on,以确保 DNS 区域已经与虚拟网络注册;否则,当创建资源时可能会遇到错误
- 
"azurerm_mysql_flexible_server_configuration" "require_secure_transport"更改 Azure MySQL 灵活服务器参数,允许非 TLS 连接
- 
"azurerm_mysql_flexible_database" "wordpress_database"在 Azure MySQL 灵活服务器上创建一个数据库;更新参数后,我们使用depends_on来实现这一点
现在我们已经配置并将 Azure MySQL 灵活服务器实例连接到虚拟网络,我们可以启动管理员虚拟机实例了。
管理员虚拟机
管理员虚拟机将是一个单独的 Linux 虚拟机实例,用于引导我们的 WordPress 安装。首先,这里没有使用新技术,所以与其详细说明,不如概述一下每个代码块的作用:
- 
"azurecaf_name" "admin_vm"生成虚拟机的名称
- 
"azurecaf_name" "admin_vm_nic"生成网络接口的名称
- 
"azurerm_network_interface" "admin_vm"创建网络接口资源,并将其附加到在var.subnet_for_vms变量中定义的子网
- 
"random_password" "wordpress_admin_password"为 WordPress 管理区域生成一个随机密码——这次,使用了除_%@之外的特殊字符
- 
"random_password" "vm_admin_password"为虚拟机实例生成密码;这次密码稍微复杂一些,因为虚拟机有密码强度要求,所以我们将生成一个 16 位密码,其中至少包含两个大写字母、两个小写字母、两个特殊字符(不包括!@#$%&),还包括数字
下一个任务,"azurerm_linux_virtual_machine" "admin_vm",启动虚拟机本身,并且除了我们传递 user_data 的部分之外,大部分内容都没有太多有趣的内容,这是生成 cloud-init 脚本并在虚拟机启动时传递到 Azure 的部分。该块的这部分看起来如下:
  user_data = base64encode(templatefile("vm-cloud-init-admin.yml.tftpl", {
    tmpl_database_username = "${var.database_administrator_login}"
    tmpl_database_password = "${random_password.database_admin_password.result}"
    tmpl_database_hostname = "${azurecaf_name.mysql_flexible_server.result}.${replace(var.name, "-", "")}.mysql.database.azure.com"
    tmpl_database_name     = "${azurerm_mysql_flexible_database.wordpress_database.name}"
    tmpl_file_share        = "${azurerm_storage_account.sa.name}.file.core.windows.net:/${azurerm_storage_account.sa.name}/${azurerm_storage_share.nfs_share.name}"
    tmpl_wordpress_url     = "http://${azurerm_public_ip.load_balancer.ip_address}"
    tmpl_wp_title          = "${var.wp_title}"
    tmpl_wp_admin_user     = "${var.wp_admin_user}"
    tmpl_wp_admin_password = "${random_password.wordpress_admin_password.result}"
    tmpl_wp_admin_email    = "${var.wp_admin_email}"
  }))
}
让我们更深入地了解这里发生的事情。首先,我们需要将 cloud-init 脚本作为 Base64 编码传递;幸运的是,Terraform 提供了 base64encode 函数,我们可以用来做这件事。
信息
Base64 是一种将数据编码为连续 ASCII 文本字符串的方法;它有助于将多行脚本或二进制数据发布到 API。它不是一种安全的数据编码方式,因为可以轻松解码,并且不提供任何形式的加密。如果我们要对 Hello, world! 进行编码,它将被编码为 SGVsbG8sIHdvcmxkIQ==。末尾的 == 是为了填充字符串,使其成为四个字符的倍数,因为 Base64 编码使用四个字符的块。
接下来的部分使用 Terraform 的本机 templatefile 函数读取一个文件,在我们的案例中,文件名为 vm-cloud-init-admin.yml.tftpl。一旦定义了这个文件,我们传递一个变量列表,用于在模板中使用的变量及其值 - 在这里,我们传递以下细节:
- 
Azure MySQL 弹性服务器 
- 
Azure Files 托管的 NFS 共享 
- 
URL 由 Azure 负载均衡器的公共 IP 地址组成 
- 
我们的 WordPress 安装信息,在我们主要的 variables文件中定义为变量
为避免混淆,我在我们传递到模板文件中的每个变量前面加上了 tmpl 前缀;这不是必需的,但我发现这有助于区分我可以在主要 Terraform 块中使用的变量和在模板中使用的变量。
下面的代码块给出了 cloud-init 模板文件的缩略版本;它包含挂载 NFS 共享的部分:
#cloud-config
package_update: true
package_upgrade: true
packages:
  - nfs-common
runcmd:
  - sudo mount -t nfs ${tmpl_file_share} /var/www/html -o vers=4,minorversion=1,sec=sys
  - echo "${tmpl_file_share} /var/www/html nfs vers=4,minorversion=1,sec=sys" | sudo tee --append /etc/fstab
如您所见,引用变量的语法与主要 Terraform 块略有不同,我们不必像 ${var.something} 那样引用它们,而是可以直接使用 ${something}
完全渲染的文件然后传递给虚拟机,并且脚本在虚拟机启动时执行。完整的 cloud-init 文件执行以下任务:
- 
更新所有已安装的软件包 
- 
安装我们运行 WordPress 所需的软件包,例如 Apache2、PHP 以及 NFS 和 MySQL 客户端软件 
- 
挂载 NFS 共享,并在 /etc/fstab中添加一行,以便在实例重启后自动挂载
- 
安装 WordPress 命令行客户端 
- 
设置我们将安装 WordPress 的文件夹的正确权限 
- 
下载最新版本的 WordPress 
- 
创建一个 wp-config.php文件,填写我们的 Azure MySQL 弹性服务器的详细信息
- 
使用我们传入的变量安装 WordPress 本身 
- 
调整 Apache 配置并重启服务 
一旦这些步骤完成,我们应该就能拥有一个正常工作的 WordPress 安装。
一个 Web 虚拟机规模集
现在我们已经启动了管理员虚拟机实例,我们可以启动一个虚拟机规模集来充当 Web 服务器。由于我们已经有了一个运行中的 WordPress 安装,并且所有需要的文件都在 NFS 共享中,这些实例只需要配置基本的软件堆栈。
此外,由于大部分繁重工作已经完成,这应该是直截了当的:
- 
"azurecaf_name" "web_vmss"生成虚拟机规模集的名称。
- 
"azurecaf_name" "web_vmss_nic"生成虚拟机规模集使用的网络接口的名称。
- 
"azurerm_linux_virtual_machine_scale_set" "web"创建了虚拟机规模集本身;这与我们启动的管理员虚拟机一致,我们重新使用了许多相同的变量,值得注意的是添加了var.number_of_web_servers,它定义了要启动的服务器实例数量。我们还使用了一个简化版的cloud-init脚本,名为vmss-cloud-init-web.tftpl。
这就结束了我们对 Azure 资源的启动和配置;在完成之前,只剩下少数几个步骤。
输出
最终文件输出了一些关于我们部署的有用信息。由于输出中包含一些信息,例如 random_password 的结果,我们需要将这一部分输出标记为 sensitive,因为我们不希望随机生成的密码在打印输出时出现在屏幕上。例如:
output "wp_password" {
  value     = "Wordpress Admin Password: ${random_password.wordpress_admin_password.result}"
  sensitive = true
}
output "wp_url" {
  value     = "Wordpress URL: http://${azurerm_public_ip.load_balancer.ip_address}/"
  sensitive = false
}
现在我们已经理解了 Terraform 代码将要做什么,我们可以运行它了。
部署环境
要部署环境,我们只需运行以下命令:
$ terraform init
$ terraform plan
当你运行 terraform plan 命令时,它会概述将要部署的资源,并进行一些非常基本的错误检查,以确保一切都井然有序。
如果你已经得到了计划的输出,那么可以通过运行以下命令来继续进行部署:
$ terraform apply
完成后,你应该看到如下的屏幕:

图 4.3 – 完成的 Azure 部署
如你所见,两个密码输出已被标记为 sensitive,但我们有了 WordPress 安装的 URL。现在,让我们直接进入 WordPress 管理门户,打开你在浏览器中得到的 URL,并在 URL 后面添加 wp-admin。例如,对于我来说,URL 是 http://20.23.249.255/wp-admin。
注意
本章中使用的所有 URL 和密码早已失效并且无效;请使用你自己部署的详细信息。
这应该会给你一个登录页面,看起来像下面这样:

图 4.4 – WordPress 登录界面
我们知道 WordPress 的用户名是admin,因为我们已经将其设置为变量,但密码怎么办呢?好吧,默认情况下,当你运行terraform output命令时,Terraform 会始终显示sensitive;然而,在命令末尾加上-json,你就能看到完整的输出。
你可以在以下屏幕截图中看到我运行terraform output -json命令的输出:

图 4.5 – 访问密码
输入用户名和密码后,你应该会看到 WordPress 控制面板:

图 4.6 – WordPress 控制面板
你也可以访问 Azure 门户并查看那里的资源;你应该能够在rg-iac-wordpress-prod-euw资源组中找到它们(假设你保持了默认变量并未更新它们;如果你有更新过,那么你需要找到与你的更新匹配的资源组)。
完成后,请不要忘记运行以下命令:
$ terraform destroy
否则,如果你按照步骤操作并在你自己的账户中启动了环境,你将会产生资源运行的费用。
Ansible – 审查代码并部署我们的基础设施
虽然我们在本章详细讲解了 Terraform,但在这里我们将快速回顾 Ansible 代码,因为我们将在下一章第五章中深入探讨 Ansible 部署的更多细节,部署到亚马逊 Web 服务。
像 Terraform 一样,Ansible 代码也被拆分为多个角色;这使得我们的site.yml文件看起来如下所示:
- name: Deploy and configure the Azure Environment
  hosts: localhost
  connection: local
  gather_facts: true
  vars_files:
    - group_vars/azure.yml
    - group_vars/common.yml
  roles:
    - roles/create-randoms
    - roles/azure-rg
    - roles/azure-virtualnetwork
    - roles/azure-storage
    - roles/azure-mysql
    - roles/azure-vm-admin
    - roles/azure-vmss-web
    - roles/output
在这里,我们从group_vars文件夹加载了两个变量文件,并调用了八个不同的角色。正如我们之前讨论的那样,Ansible 会按我们调用它们的顺序执行任务,因此角色的顺序非常重要。
Ansible Playbook 角色概览
让我们直接深入,查看在site.yml文件中首先调用的角色。randoms 角色有一个单一的功能:随机生成我们部署所需的所有变量。然而,这也是我们首次遇到 Ansible 和 Terraform 之间的一个重要区别。由于 Ansible 是无状态的,一旦生成了随机值并且 Ansible 执行完成,它会立即忘记这些值。这意味着当我们下次执行 Playbook 时,它会重新生成随机值,而由于我们在资源定义中使用了这些值,这可能导致新的资源被启动。
我们需要做的是创建一个包含所有随机值的文件,但如果已经有文件存在,则继续操作而不更新它们——我们需要遵循的流程在以下工作流中进行了可视化:

图 4.7 – 我们需要创建一个 secrets.yml 变量文件吗?
那么,作为 Ansible 任务,这看起来是什么样的?
- name: Check if the file secrets.yml exists
  ansible.builtin.stat:
    path: "group_vars/secrets.yml"
  register: secrets_file
如你所见,我们使用内置的stat模块检查名为secrets.yml的文件是否存在于group_vars目录中。然后我们将此任务的结果注册为一个名为secrets_file的变量。
当secrets.yml文件在你的文件系统中不存在时,我们注册的secrets_file变量的内容如下所示(我们即将创建该文件):
TASK [roles/create-randoms : print the secrets_file variable] **************************************
ok: [localhost] => {
    "msg": {
        "changed": false,
        "failed": false,
        "stat": {
            "exists": false
        }
    }
}
如你所见,输出中有一个名为exists的项,值为false。
所以,我们的下一个任务将在exists为false时生成secrets.yml文件,内容如下所示:
- name: Generate the secrets.yml file using a template file if not exists
  ansible.builtin.template:
    src: "secrets.yml.j2"
    dest: "group_vars/secrets.yml"
  when: secrets_file.stat.exists == false
如最后一行所示,任务只有在secrets_file.stat.exists等于false时才会执行;如果返回true,任务将被跳过,因为我们不需要重新生成secrets.yml文件。
该任务本身使用template函数来获取源模板,模板如下所示,处理后将渲染结果输出到group_vars/secrets.yml文件:
short_random_hash: "{{ lookup('community.general.random_string', length=5, upper=false, special=false, numbers=false) }}"
db_password: "{{ lookup('community.general.random_string', length=20, upper=true, special=true, override_special="@-&*", min_special=2, numbers=true) }}"
vm_password: "{{ lookup('community.general.random_string', length=20, upper=true, special=true, override_special="@-&*", min_special=2, numbers=true) }}"
wp_password: "{{ lookup('community.general.random_string', length=20, upper=true, special=true, override_special="@-&*", min_special=2, numbers=true) }}"
模板使用lookup函数调用random_string模块来为资源名称和各种密码生成一个简短的随机哈希值。
注意
虽然这种方法适用于我们的部署,但你应该考虑在生产环境中使用更安全的方法——例如,从远程密钥管理存储动态加载密钥值。
既然我们已经生成了文件,并且知道文件存在,我们可以将文件内容作为变量加载:
- name: Load the variables defined in the secrets.yml file
  ansible.builtin.include_vars:
    file: "group_vars/secrets.yml"
现在我们的变量已加载,我们可以继续执行其余的 playbook 任务。
资源组角色
我们在 Terraform 中使用的azurecaf_name提供程序。因此,我们在group_var/azure.yml文件中使用各种变量和硬编码值定义了所有资源名称。
虚拟网络角色
该角色中有多个任务,许多任务使用了一些我们尚未覆盖的概念,但将在第五章中详细介绍,部署到亚马逊 Web Services:
- 
azure.azcollection.azure_rm_virtualnetwork创建虚拟网络
- 
azure.azcollection.azure_rm_subnet循环遍历并创建名称中不含有database的子网
- 
azure.azcollection.azure_rm_subnet循环遍历并创建名称中含有database的子网
- 
community.general.ipify_facts获取当前 IP 地址
- 
ansible.builtin.set_fact接受前面的输出并将其注册为事实
- 
ansible.builtin.tempfile创建一个空的临时文件
- 
ansible.builtin.template从模板文件动态生成网络安全组任务
- 
ansible.builtin.include_tasks加载并执行我们刚刚生成的网络安全组任务
- 
azure.azcollection.azure_rm_publicipaddress创建一个公共 IP 地址,以供负载均衡器使用
- 
azure.azcollection.azure_rm_loadbalancer创建负载均衡器
现在我们已经完成了所有基础网络设置,可以启动其余资源了。
存储角色
该角色执行的任务如下:
- 
azure.azcollection.azure_rm_resourcegroup_info获取我们已创建的资源组信息
- 
ansible.builtin.set_fact使用一些正则表达式从资源组 ID 中提取订阅 ID 并设置一个事实
- 
ansible.builtin.tempfile生成临时文件,该文件将用于存储帐户规则的变量
- 
ansible.builtin.template动态生成包含存储帐户网络规则的变量
- 
ansible.builtin.include_vars加载我们刚刚生成的变量
- 
azure.azcollection.azure_rm_storageaccount创建存储帐户
- 
azure.azcollection.azure_rm_resource创建 NFS 共享
- 
azure.azcollection.azure_rm_privatednszone创建私有 DNS 区域
- 
azure.azcollection.azure_rm_privatednszonelink将私有 DNS 区域连接到我们的虚拟网络
- 
azure.azcollection.azure_rm_subnet_info获取终端子网信息
- 
azure.azcollection.azure_rm_storageaccount_info获取我们刚刚创建的存储帐户信息
- 
azure.azcollection.azure_rm_privateendpoint使用我们刚刚收集的所有信息创建私有终端
- 
azure.azcollection.azure_rm_privateendpointdnszonegroup将私有终端连接到私有 DNS 区域
虽然这一切看起来很简单,但眼尖的你可能注意到有些地方似乎不太对劲。
在创建 NFS 共享时,我们使用的任务是 azure.azcollection.azure_rm_resource,尽管有一个名为 azure.azcollection.azure_rm_storageshare 的模块;这是为什么呢?
在写作时,azure.azcollection.azure_rm_storageshare 模块不支持在 Azure 存储帐户上创建 NFS 文件共享。因此,我们动态生成有效负载并将其发送到 Azure 资源管理器 REST API 来创建该资源。我们将在下一个角色中详细介绍这一点。
MySQL 角色
Azure MySQL 弹性服务器是另一项 Azure 服务,尚未拥有原生的 Ansible 模块,因此我们必须使用 REST API 来不仅创建服务器,还要设置 require_secure_transport 参数并创建我们的 WordPress 数据库。
不过,在做任何事情之前,我们需要创建 DNS 资源并收集一些我们已启动的网络资源信息;以下任务执行了这一操作:
- 
azure.azcollection.azure_rm_privatednszone为数据库创建私有 DNS 区域
- 
azure.azcollection.azure_rm_privatednszonelink将我们刚刚创建的 DNS 区域附加到虚拟网络
- 
azure.azcollection.azure_rm_privatednszone_info获取我们刚刚创建的私有 DNS 信息
- 
azure.azcollection.azure_rm_subnet_info获取我们为 Azure MySQL 弹性服务器专门创建的子网信息
现在我们拥有了创建 Azure MySQL 弹性服务器所需的所有资源和信息。完成这项任务的步骤如下:
- name: Create an Azure Flexible Server for MySQL using the REST API
  azure.azcollection.azure_rm_resource:
    api_version: "2021-05-01"
    resource_group: "{{ resource_group_name }}"
    provider: "DBforMySQL"
    resource_type: "flexibleServers"
    resource_name: "{{ database_server_name }}"
    body:
      location: "{{ location }}"
      properties:
        administratorLogin: "{{ database_config.admin_username }}"
        administratorLoginPassword: "{{ db_password }}"
        Sku:
          name: "{{ database_config.sku.name }}"
          tier: "{{ database_config.sku.tier }}"
        Network:
          delegatedSubnetResourceId: "{{ database_subnet_output.subnets[0].id }}"
          privateDnsZoneResourceId: "{{ database_private_dns_zone_output.privatednszones[0].id }}"
        tags: "{{ common_tags }}"
任务的第一部分,从 api_version 到 body,用于构造我们将调用的 URL。body 下列出的键是将被发布到我们动态创建的 API 端点 URL 的参数和选项。我们将发布到的 URL 类似于 https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.DBforMySQL/flexibleServers/{serverName}?api-version=2021-05-01。
看起来很简单,但实际上有一个较大的逻辑块,通常由 Ansible 处理,而我们现在必须自己考虑。
由于 Ansible 只是向 REST API 发送请求,只要请求有效,它就会收到 200 的响应,表示请求成功;它不会等到资源启动完成才返回 200,这意味着 Ansible 会迅速进入下一个任务,但在我们的部署中,由于我们立即对资源进行更改,资源的状态会是 Creating,从而导致下一个任务失败。为了避免这个问题,我们有如下任务:
- name: Wait for Azure Flexible Server for MySQL to be ready
  azure.azcollection.azure_rm_resource_info:
    api_version: "2021-05-01"
    resource_group: "{{ resource_group_name }}"
    provider: "DBforMySQL"
    resource_type: "flexibleServers"
    resource_name: "{{ database_server_name }}"
  register: database_wait_output
  delay: 15
  retries: 50
  until: database_wait_output.response[0] is defined and database_wait_output.response[0].properties is defined and database_wait_output.response[0].properties.state == "Ready"
这个任务将每 15 秒轮询一次 REST API,最多 50 次,直到 REST API 报告服务器的 state 为 Ready,然后它将继续执行下一个任务:
- 
azure.azcollection.azure_rm_resource——现在服务器已准备好,我们可以更新require_secure_transport参数
- 
azure.azcollection.azure_rm_resource——最后,我们可以创建 WordPress 数据库
现在我们已经启动并配置了数据库资源,可以启动一些虚拟机——从管理员虚拟机开始。
管理员虚拟机角色
与 Azure MySQL 弹性服务器角色相比,这个任务相当直接,使用了所有 Ansible 原生的 Azure 模块:
- 
azure.azcollection.azure_rm_networkinterface创建用于虚拟机的网络接口
- 
azure.azcollection.azure_rm_publicipaddress_info获取附加到我们启动的负载均衡器的公网 IP 地址信息——我们需要此信息来执行cloud-init脚本
- 
ansible.builtin.tempfile创建一个临时文件,用于存储cloud-init脚本
- 
ansible.builtin.template生成管理员服务器的cloud-init脚本;像我们的 Terraform 部署一样,这将安装包并引导 WordPress
- 
azure.azcollection.azure_rm_virtualmachine使用之前创建和配置的资源启动虚拟机
现在我们已经有了管理员虚拟机,让我们来看看 Web 服务器。
Web 虚拟机规模集角色
该角色包含三个任务:
- 
ansible.builtin.tempfile创建临时文件,用于存储cloud-init脚本
- 
ansible.builtin.template生成用于管理员服务器的cloud-init脚本,类似于我们的 Terraform 部署;这将安装所需的软件包,但不会触及 WordPress,因为它已经安装在我们的管理员虚拟机上。
- 
azure.azcollection.azure_rm_virtualmachinescaleset创建虚拟机规模集
输出角色
这个角色仅输出我们需要访问 WordPress 安装的详细信息;与 Terraform 不同,运行时这些信息是可见的。
这个简单的角色在运行我们的 playbook 时只显示一些文本,是最终的角色,我们现在已经准备好运行 playbook。
运行 Ansible Playbook
现在我们已经了解了每个部分的功能,你可以通过运行以下命令来运行 Ansible Playbook:
$ ansible-playbook site.yml
完成后,你应该会看到类似以下输出:

图 4.8 – 完成的 playbook 执行
一旦查看了 WordPress 和 Azure 资源,你可以运行以下命令来删除资源组及其中的所有内容:
$ ansible-playbook destroy.yml
请记住,通过检查它们是否仍然列在 Azure 门户中,确保所有资源已经被删除,因为如果 playbook 因任何原因失败,可能会产生意外费用。
总结
在本章中,我们深入探讨了使用 Terraform 在 Microsoft Azure 部署 WordPress 环境。我们讨论了 Terraform 提供程序并逐步执行了 Terraform 代码。
此外,在本次演示中,我们讨论了在循环资源时需要考虑的一些事项,何时适合使用 depends_on,以及如何使用模板生成内容。
接下来,我们讲解了 Ansible 代码,部署相同的资源集。这次,我们没有进行深入探讨,而是详细讨论了 Azure 特定的细节,因为我们将在第五章中对 Ansible 进行更深入的分析,部署到 Amazon Web Services。
到目前为止我们涵盖的内容,希望能让你开始思考如何将我们讲解的主题应用到你自己的基础设施即代码部署中,并且你应该已经开始对这两种工具的偏好有所感知。
在下一章中,我们将讨论如何将我们的 WordPress 安装部署到 Amazon Web Services,并深入了解 Ansible。
进一步阅读
你可以在以下网址找到我们在本章中提到的服务和文档的更多细节:链接
- 
Microsoft Azure: azure.microsoft.com/
- 
Azure REST 文档: learn.microsoft.com/en-us/rest/api/azure/
Terraform 提供者和模块:
- 
azurerm: registry.terraform.io/providers/hashicorp/azurerm/latest
- 
azurecaf: registry.terraform.io/providers/aztfmod/azurecaf/latest
- 
random: registry.terraform.io/providers/hashicorp/random/latest
- 
Claranet Azure 区域模块: registry.terraform.io/modules/claranet/regions/azurerm/latest
Ansible 集合的参考如下:
- Azure 集合: galaxy.ansible.com/azure/azcollection
第五章:部署到亚马逊云服务
在将我们的 WordPress 基础设施部署到 Microsoft Azure 后,我们现在准备探索如何将相同的基础设施部署到亚马逊云服务(AWS)。然而,尽管基础设施的高层设计保持不变,但 Azure 和 AWS 之间存在一些关键区别,要求我们以不同的方式进行部署。
在第四章,《部署到 Microsoft Azure》中,我们重点使用 Terraform 部署到 Azure。本章中,我们将深入探索 Ansible,这是另一个流行的基础设施即代码工具,用于将我们的工作负载部署到 AWS。Ansible 使我们能够以声明的方式定义我们基础设施的期望状态,并管理我们 AWS 资源的配置和编排。
在本章结束时,你将对如何使用 Ansible 和 Terraform 在 AWS 上部署 WordPress 工作负载有一个深入的了解。你还将熟悉 Azure 和 AWS 之间的关键区别,以及如何根据你的基础设施即代码部署来调整部署方法。
我们将涵盖以下主题:
- 
介绍亚马逊云服务 
- 
准备我们的云环境进行部署 
- 
生成低级设计 
- 
Ansible – 编写代码并部署我们的基础设施 
- 
Terraform – 审查代码并部署我们的基础设施 
技术要求
像上一章一样,由于部署我们项目所需的代码量,在本章涉及 Terraform 和 Ansible 的部分时,我们将只涵盖一些部署项目所需的代码片段。本书随附的代码库将包含完整的可执行代码。
介绍亚马逊云服务
AWS 是由电子商务巨头亚马逊拥有和运营的云基础设施平台,鉴于其名称,你可能已经猜到了这一点。
该公司从 2000 年开始试验云服务,为其内部和外部零售合作伙伴开发和部署应用程序接口(API)。随着越来越多的亚马逊零售合作伙伴消费更多的软件服务并以指数级速度增长,他们意识到需要构建一个更好、更标准化的基础设施平台,不仅能够托管他们开发的服务,还能确保他们能够快速扩展。
基于这一需求,亚马逊工程师 Chris Pinkham 和 Benjamin Black 撰写了一篇白皮书,该白皮书在 2004 年初得到了 Jeff Bezos 的亲自批准。白皮书描述了一个基础设施平台,计算和存储元素可以通过编程方式进行部署。
AWS 的首次公开承认发生在 2004 年底。尽管那时该术语用于描述一组工具和 API,允许第一方和第三方与亚马逊的零售产品目录进行交互,而不是今天这个完整的公共云服务。直到 2006 年,重新品牌的 AWS 才正式推出,主要是因为服务开始扩展,不仅提供 API 用于亚马逊的零售服务,还开始提供允许用户为其应用程序使用的服务。
简单存储服务(S3)是这些新服务中的第一个;这项服务,尽管现在功能更丰富,允许开发人员使用 Web API 来写入和提供单个文件,而不必像传统的本地文件系统那样进行读写。
下一项即将推出的服务仍然存在,即 Amazon 简单队列服务(SQS)。它最初是 AWS 原始 API 端点集合的一部分。它是一个分布式消息系统,同样可以通过 API 进行控制和消费,由开发人员使用。
最后一项服务是在 2006 年推出的 Amazon 弹性计算云(EC2)服务的测试版,它仅限于现有的 AWS 客户——同样,你可以使用亚马逊开发的 API 启动和管理资源。
这是亚马逊的最后一块拼图。他们现在拥有了一个公共云平台的基础设施,这个平台最初是在 Chris Pinkham 和 Benjamin Black 几年前发布的白皮书中构思的。他们不仅可以将这个新服务用于自己的零售平台,还可以将空间卖给其他公司和公众,比如你和我。额外的好处是,这项新服务可以有一个持续的收入流,不仅可以支付最初的开发费用,还可以让亚马逊通过租赁其闲置的计算资源来最大化硬件投资。
从 2006 年提到的 3 项服务,到 2023 年的 200 多项服务,AWS 取得了巨大的增长。所有这些 200 多项服务都与原始白皮书中阐述的核心原则一致。每项服务都是软件定义的,这意味着开发人员只需要发出 API 请求来启动、配置、有时消费以及终止该服务。
与 API 交互正是我们在本章中将要做的事情,因为白皮书中阐述的许多原则也是基础设施即代码的核心内容。
为我们的云环境做好部署准备
正如我们在 第四章 中讨论的那样,部署到 Microsoft Azure,我们将在本地机器上运行 Ansible 和 Terraform,这意味着我们可以安装并配置 AWS 命令行界面(CLI)。
Ansible 和 Terraform 将使用在 AWS CLI 中配置的凭证进行身份验证,访问 AWS API。有关如何安装 AWS CLI 的详细信息,请参阅 docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html。
安装完成后,您需要生成并输入您的凭证。此过程的文档说明可以在 docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html 中找到。
配置完成后,您应该能够运行以下命令:
$ aws --version
$ aws ec2 describe-regions
当我在我自己的机器上运行这些命令时,我得到以下输出:

图 5.1 – 运行 AWS 版本命令以检查版本的输出
对于第二个命令,输出内容相当多,应该类似于以下内容:

图 5.2 – 运行 AWS 版本命令以描述区域的输出
现在我们已经配置并连接了 AWS CLI 到我们的 AWS 账户,我们可以讨论将在 AWS 内部署和配置的服务。
生成低级设计
从架构的角度来看,我们将要部署的服务与我们在第四章中讨论的服务并无太大不同,部署到 Microsoft Azure:

图 5.3 – 我们将在 AWS 中部署的服务概览
我们将要部署的核心服务如下:
- 
Amazon Elastic Load Balancing (ELB) 是我们将要部署的服务中的第一个差异。Azure Load Balancer 仅在我们的 WordPress 实例之间分发 TCP 请求。然而,在 AWS 中,我们将启动配置为应用程序负载均衡器的 ELB,它将终止我们的 HTTP 请求并将其分发到我们的 WordPress 实例中。 
- 
Amazon EC2 是计算服务。对于我们的 WordPress 部署,我们将部署一个 Amazon EC2 实例,该实例将用于引导 WordPress,之后其余的 Amazon EC2 实例将进行自动扩展。 
- 
我们将使用 Amazon EC2 自动扩展组 (ASGs) 和启动配置的组合来管理 Amazon EC2 实例的部署,这些实例将托管 Web 实例。 
- 
Amazon Elastic File System (EFS) 是提供 WordPress 安装的 NFS 共享托管服务,该服务将在我们所有的实例之间共享。 
- 
Amazon Relational Database Service (RDS) 将用于托管我们将用于 WordPress 的 MySQL 数据库。 
- 
亚马逊虚拟私有云(VPC)是承载本章将要部署服务的底层网络服务。这个服务下涵盖了几种不同的服务,具体内容将在下一节深入讲解 Ansible 代码时详细介绍。 
现在我们已经了解了将要使用的服务,以及它们在 WordPress 部署中所承担的角色,我们可以开始着手使用 Ansible 来进行项目部署——这一次我们会比在第四章《部署到Microsoft Azure》中讲解的更为详细。
Ansible – 编写代码并部署我们的基础设施
在第四章《部署到 Microsoft Azure》中,我们简要介绍了用 Ansible 部署 Azure 环境的代码。现在,让我们回顾一下我们跳过的一些基础知识。
虽然我们可以将所有 playbook 代码写在一个大的 YAML 文件中,但我倾向于将其拆分成更易管理的块,使用角色进行分割。角色有多种用途。在某些情况下,它们可以是独立的可分发任务,可以在多个项目中重复使用;在我们这里,它们则用于管理更复杂的 playbook。
以下是将用于在 AWS 中部署我们的 WordPress 环境的site.yml文件示例:
- name: Deploy and configure the AWS Environment
  hosts: localhost
  connection: local
  gather_facts: true
  vars_files:
    - group_vars/aws.yml
    - group_vars/common.yml
  roles:
    - roles/create-randoms
    - roles/aws-network
    - roles/aws-storage
    - roles/aws-database
    - roles/aws-vm-admin
    - roles/aws-asg-web
    - roles/output
如你所见,这里有多个角色,我已经按我们需要部署资源的逻辑顺序将它们分组。
如果你查看其中一个角色文件夹,例如roles/create-randoms,你会注意到有几个文件夹和文件:
- 
defaults:这是存放角色默认变量的地方。这些变量可以被vars文件夹中同名的变量覆盖。
- 
files:这个文件夹包含我们希望使用copy模块复制到目标主机上的静态文件。
- 
handlers:这些是执行完 playbook 后运行的任务,例如,当配置文件发生更改时,重新启动目标主机上的服务。
- 
meta:这个文件夹包含有关角色本身的信息。如果该角色曾被发布到 Ansible Galaxy,这些信息将被使用。
- 
tasks:这里包含了将在目标主机上执行的一组主要指令或操作。这些指令通常以 YAML 文件的形式定义,包括安装软件包、创建用户、复制文件等。任务可以根据功能或特定操作的不同组织成不同的文件。它们还可以包含变量和条件语句,使其更加动态和灵活。
- 
templates:这个文件夹包含template模块使用的 Jinja2 模板。
- 
tests:如果你打算将你的角色发布到 Ansible Galaxy,那么设置一些测试是一个不错的主意。这些测试会存放在这里。
- 
vars:你可以使用这里定义的变量覆盖default文件夹中定义的任何变量。这里定义的变量也可以被从 playbook 顶部的group_vars文件夹加载的任何变量覆盖。反过来,这些变量又可以在运行时通过ansible-playbook命令传入的变量进行覆盖。
- 
README.md:这是用于创建关于角色的文档文件,当角色被检查并推送到像 GitHub 这样的服务时,这个文件会用到。发布角色到 Ansible Galaxy 时,这个文件非常有用。
现在,当你想添加一个角色时,确实需要创建很多文件夹和文件。幸运的是,ansible-galaxy 命令可以快速引导角色创建。为此,只需在 playbook 文件夹的顶级运行以下命令,确保将 role-name 替换为你希望的角色名称:
$ ansible-galaxy init roles/role-name
这将创建我们刚才讨论的文件夹和文件结构,是一个很好的起点。
在我们深入讨论角色之前,让我们快速讨论一下变量。在 group_vars/aws.yml 文件的顶部,我们定义了一些基本变量。它们如下:
app:
  name: "iac-wordpress"
  location: "us-east-1"
  env: "prod"
wordpress:
  wp_title: "IAC WordPress"
  wp_admin_user: "admin"
  wp_admin_email: "test@test.com"
如你所见,我们正在定义一个顶级变量,并且附加了多个键值对。因此,在我们的代码中,甚至在其他顶级变量中,我们可以简单地使用类似{{ app.name }}的内容,当我们的 playbook 运行时,它将被替换为 iac-wordpress。
这可以在定义资源名称时看到,因为这些名称大多数由在其他地方定义的变量组构成。请看以下示例:
vpc_name: "{{ app.name }}-{{ app.env }}-{{ dict.vpc }}"
vpc_subnet_web01_name: "{{ app.name }}-{{ app.env }}-web01-{{ dict.subnet }}"
vpc_subnet_web02_name: "{{ app.name }}-{{ app.env }}-web02-{{ dict.subnet }}"
现在,让我们详细看看 playbook 角色。
Ansible playbook 角色
让我们直接深入,看看第一个角色。
创建 Randoms 角色
这个角色,我们在 第四章《部署到微软 Azure》中已经详细讲解过,执行的任务与该章节中所述相同。该角色直接从微软 Azure 部署文件夹复制而来。
AWS 网络角色
我们在 group_vars/aws.yml 中为这个角色定义的主要变量比我们为 Azure 部署定义的要简单得多。它仅包含我们希望用于 VPC 网络的 CIDR 范围,没有其他内容:
vpc:
  address_space: "10.0.0.0/24"
我们在角色中运行的任务通过一些 Ansible 内建函数处理其余的信息。第一个任务是相对简单的:
- name: Create VPC
  amazon.aws.ec2_vpc_net:
    name: "{{ vpc_name }}"
    region: "{{ region }}"
    cidr_block: "{{ vpc.address_space }}"
    dns_hostnames: true
    dns_support: true
    state: present
  register: vpc
如你所见,它使用了来自 Ansible Galaxy 上 Amazon 集合的 amazon.aws.ec2_vpc_net 模块来创建 VPC——所以没有什么特别或复杂的内容。任务的输出被注册为 vpc;我们将在 playbook 运行的剩余部分使用这个输出注册。
下一个任务收集了我们将要部署工作负载的区域信息:
- name: get some information on the available zones
  amazon.aws.aws_az_info:
    region: "{{ region }}"
  register: zones
现在我们已经注册了几个输出,我们可以添加子网并开始做一些更有趣的事情。
作为我们部署的一部分,我们需要添加四个子网——两个用于 Web 服务,两个用于数据库服务。与我们的 Azure 部署一样,子网将是/27网段,并且我们将每个子网部署在不同的可用区中。
信息
当我们部署 Azure 版本的 WordPress 工作负载时,我们不必担心子网如何分布在可用区之间(可用区是一个区域内的不同数据中心),因为 Azure 中的虚拟网络可以跨多个可用区。然而,AWS 则不同;子网需要与可用区绑定,这意味着每个服务器角色或服务功能至少需要有一个子网。
添加第一个子网的任务如下所示:
- name: Create Subnet Web01
  amazon.aws.ec2_vpc_subnet:
    vpc_id: "{{ vpc.vpc.id }}"
    cidr: "{{ vpc.vpc.cidr_block | ansible.utils.ipsubnet(27, 0) }}"
    az: "{{ zones.availability_zones[0].zone_name }}"
    region: "{{ region }}"
    tags:
      Name: "{{ vpc_subnet_web01_name }}"
      Description: "{{ dict.ansible_warning }}"
      Project: "{{ app.name }}"
      Environment: "{{ app.env }}"
      Deployed_by: "Ansible"
  register: subnet_web01
事情从简单开始,我们使用在创建 VPC 时的输出寄存器,通过"{{ vpc.vpc.id }}"获取 VPC 的 ID,以便将子网附加到该 VPC。
接下来,我们再次使用输出寄存器从 VPC 输出寄存器获取 CIDR 范围;但是,我们将该值传递给ansible.utils.ipsubnet函数,计算出 CIDR 范围中的第一个/27网段。
由于我们传入了10.0.0.0/24,运行ansible.utils.ipsubnet(27, 0)应该返回10.0.0.0/27。细心的你可能已经注意到我们传入了0而不是1。Ansible 的计数从 0 开始,所以如果我们使用1,我们将得到10.0.0.32/27,那才是我们需要用于第二个子网的地址。
我们正在做的第二件令人兴奋的事情是获取zone寄存器的输出,该寄存器包含我们正在使用的区域信息,包括可用区列表。因此,当我们使用{{ zones.availability_zones[0].zone_name }}时,它是获取第一个结果的可用区名称,即0。
这种为子网填充 CIDR 和可用区信息的方法的优点在于,我们不需要将这些细节作为变量硬编码。如果我们更改 CIDR 范围或区域,信息将根据这些更改自动生成。
当你编写 Ansible 剧本时,任何能够使剧本根据用户输入或动态变化来调整的操作,都是最佳实践。这不仅简化了用户需要了解的信息,而且使代码具有可重用性。
任务的其余部分主要使用静态变量,因此比我们刚才讲解的内容要简单得多。
接着,这个过程将针对第二个 Web 子网和两个用于 Amazon RDS 的子网重复进行——我们所做的只是递增传递给ansible.utils.ipsubnet和zones.availability_zones的数字。
一旦定义了子网,我们通过amazon.aws.ec2_vpc_igw创建一个互联网网关。接着,我们创建一个路由表来利用该网关,使用amazon.aws.ec2_vpc_route_table。
这个子网连接到两个 Web 子网,并将所有出站流量转发到我们的互联网网关。
接下来的任务批次使用amazon.aws.ec2_security_group模块创建了三个安全组。
三个安全组中的第一个将分配给管理/Web EC2 实例和弹性负载均衡器。它将端口80和22开放给全世界,使其公开可访问。
信息
为了方便使用,我将端口22开放给全世界。在你的生产部署中,不应该这么做,应该将访问限制为一个或多个可信 IP 地址。
接下来的两个安全组将附加到 Amazon RDS 和 EFS 服务。
然而,和定义源 IP 范围不同,我们传入的是我们创建的第一个安全组的 ID,这意味着端口3306(MySQL)和2049(NFS)将仅允许访问资源,在我们的案例中,这些资源将是管理和 Web EC2 实例。附加的第一个安全组将能够访问这些服务。
最后的两个任务是配置并启动应用负载均衡器。下面的两个任务中的第一个在这里展示,它创建了一个空的 ELB 目标组:
- name: Create an ELB target group
  community.aws.elb_target_group:
    name: "{{ alb_target_group_name }}"
    protocol: "HTTP"
    port: "80"
    vpc_id: "{{ vpc.vpc.id }}"
    region: "{{ region }}"
    state: "present"
    modify_targets: false
    tags:
      Name: "{{ alb_target_group_name }}"
      Description: "{{ dict.ansible_warning }}"
      Project: "{{ app.name }}"
      Environment: "{{ app.env }}"
      Deployed_by: "Ansible"
  register: alb_target_group
从表面上看,似乎没有什么特别之处,那么为什么我特别提到这一点呢?
在撰写本文时,amazon.aws集合中没有创建 ELB 目标组的模块;因此,我们改用了community.aws集合。开发人员使用此集合作为新特性的试验场,我们将在整个剧本中在这两个集合之间切换。
信息
由于模块可能会从community.aws集合提升到amazon.aws集合,请参考本书附带的 GitHub 仓库中的代码,以获取最新的更新。
该角色的最终任务是创建应用负载均衡器。在这里,我们使用amazon.aws.elb_application_lb模块以及我们在剧本运行过程中创建的几个输出寄存器。
这就完成了我们部署基础网络和支持服务所需运行的所有任务。现在这些资源已经到位,我们可以开始部署 WordPress 安装的存储。
AWS 存储角色
这是一个简单的角色,只包含一个任务:
- name: Create the EFS resource
  community.aws.efs:
    name: "{{ efs_name }}"
    state: present
    region: "{{ region }}"
    targets:
      - subnet_id: "{{ subnet_web01.subnet.id }}"
        security_groups: ["{{ security_group_efs.group_id }}"]
      - subnet_id: "{{ subnet_web02.subnet.id }}"
        security_groups: ["{{ security_group_efs.group_id }}"]
    tags:
      Name: "{{ efs_name }}"
      Description: "{{ dict.ansible_warning }}"
      Project: "{{ app.name }}"
      Environment: "{{ app.env }}"
      Deployed_by: "Ansible"
  register: efs
如你所见,它使用community.aws.efs模块来创建 Amazon EFS 共享,在我们的两个 Web 子网中创建一个目标端点。这个步骤很重要,因为 EFS 在每个可用区有不同的 DNS 端点,如果没有这个步骤,我们将无法连接到我们两个 Web 子网中的 NFS 共享。
AWS 数据库角色
在启动 EC2 实例之前,我们需要准备好 MySQL Amazon RDS 实例。这个角色包含两个任务——第一个任务使用amazon.aws.rds_subnet_group模块创建一个 RDS 子网组。一旦我们有了子网组,amazon.aws.rds_instance模块将被用来创建 RDS 实例本身。
这个角色的内容并不复杂,但我们现在已经有了一个 Amazon RDS 实例,可以开始部署我们的 EC2 实例了。
AWS VM 管理员角色
就像在第四章中提到的,部署到 Microsoft Azure,我们将使用cloud-init脚本部署单个实例来引导我们的 WordPress 安装。
我们将用于当前角色和下一个角色(配置 ASG 管理的 EC2 实例)的变量如下:
ec2:
  instance_type: "t2.micro"
  public_ip: true
  asg:
    min_size: 1
    max_size: 3
    desired_capacity: 2
  ami:
    owners: "099720109477"
    filters:
      name: "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
      virtualization_type: "hvm"
我们将执行的第一个任务是生成一个临时文件:
- name: Generate temp admin cloud-init file
  ansible.builtin.tempfile:
  register: tmp_file_create_cloud_init_admin_task
我们将渲染templates/vm-cloud-init-admin.yml.j2中的模板文件,并将渲染后的内容放入我们刚刚创建的临时文件中:
- name: Create the admin cloud-init file from a template file
  ansible.builtin.template:
    src: "vm-cloud-init-admin.yml.j2"
    dest: "{{ tmp_file_create_cloud_init_admin_task.path }}"
准备好cloud-init文件后,我们可以进入下一步:找出需要使用的Amazon Machine Image(AMI)的 ID:
- name: gather information about AMIs with the specified filters
  amazon.aws.ec2_ami_info:
    region: "{{ region }}"
    owners: "{{ ec2.ami.owners }}"
    filters:
      name: "{{ ec2.ami.filters.name }}"
      virtualization-type: "{{ ec2.ami.filters.virtualization_type }}"
  register: ubuntu_ami_info
作为 AMI 的维护者,Canonical(也开发 Ubuntu)会保持 AMI 的更新,修补漏洞等,因此会返回一长串 AMI,因为有多个版本。
我们接下来的任务是对该列表进行排序,并获取最后一项:
- name: filter the list of AMIs to find the latest one
  set_fact:
    ami: "{{ ubuntu_ami_info.images | sort(attribute='creation_date') | last }}"
如你在前面的代码片段中看到的,我们使用sort函数根据creation_date属性对列表(这是 JSON 格式)进行排序,然后获取last结果。这将给我们带来 Canonical 发布的最新 AMI 的详细信息。
现在我们拥有启动管理员 EC2 实例所需的一切:
- name: create the admin ec2 instance
  amazon.aws.ec2_instance:
    name: "{{ ec2_instance_name_admin }}"
    region: "{{ region }}"
    vpc_subnet_id: "{{ subnet_web01.subnet.id }}"
    instance_type: "{{ ec2.instance_type }}"
    security_group: "{{ security_group_web.group_name }}"
    network:
      assign_public_ip: "{{ ec2.public_ip }}"
    image_id: "{{ ami.image_id }}"
    user_data: "{{ lookup('file', tmp_file_create_cloud_init_admin_task.path) }}"
    tags:
      Name: "{{ ec2_instance_name_admin }}"
      Description: "{{ dict.ansible_warning }}"
      Project: "{{ app.name }}"
      Environment: "{{ app.env }}"
      Deployed_by: "Ansible"
  register: ec2_instance_admin
如你所见,部署实例所需的大部分信息都是变量,要么是硬编码的,例如instance_type,要么是输出变量,如我们刚才收集的信息中的image_id。
对于user_data,我们使用lookup函数读取我们用cloud-init脚本填充的临时文件的内容,稍后我们会讨论这个文件。
现在我们已经有了 EC2 实例,我们需要将其注册到我们在网络角色中创建的 ELB 目标组,但只有在实例状态为running时才能进行注册。
我们的 Ansible 剧本可能执行得太快,实例可能还未达到目标状态,因此我们需要创建一些逻辑,使剧本暂停执行,并等待实例达到正确状态后再继续。
我们可以使用以下任务来实现这一点:
- name: Get information about the admin EC2 instance to see if its running
  amazon.aws.ec2_instance_info:
    region: "{{ region }}"
    filters:
      instance-id: "{{ ec2_instance_admin.instances[0].instance_id }}"
  register: admin_ec2_instance_state
  delay: 5
  retries: 50
  until: admin_ec2_instance_state.instances[0].state.name == "running"
任务本身非常简单;它使用amazon.aws.ec2_instance_info模块收集我们刚刚启动的 EC2 实例的信息。
单独来看,这个任务几乎没有用,因为它只会收集一次信息,然后继续执行。最后三行代码是增加所需逻辑的部分。
使用until函数,我们获取输出寄存器admin_ec2_instance_state,并检查state.name是否等于running:
until: admin_ec2_instance_state.instances[0].state.name == "running"
如果state.name变量不等于running,则每隔 5 秒重试 50 次:
retries: 50
delay: 5
继续直到state.name等于running。
一旦满足此条件,我们就知道可以安全地进行下一任务,并且不会因为实例状态不正确而出现错误:
- name: Update the ELB target group
  community.aws.elb_target_group:
    name: "{{ alb_target_group_name }}"
    protocol: "HTTP"
    port: "80"
    vpc_id: "{{ vpc.vpc.id }}"
    region: "{{ region }}"
    state: "present"
    modify_targets: true
    targets:
      - Id: "{{ ec2_instance_admin.instances[0].instance_id }}"
        Port: 80
现在,我们的管理员 EC2 实例已启动并注册到 ELB 目标组,并且 cloud-init 脚本正在运行。嗯,算是吧——我们需要对 cloud-init 脚本进行一些调整,从我们最后一次查看它时在 第四章,部署到 Microsoft Azure 时做的修改。大部分内容与我们在部署到 Azure 时使用的相同,只有一个我们需要添加的逻辑:
  # Mount the NFS share and add it to fstab
  - until nc -vzw 2 {{ efs.efs.filesystem_address | regex_replace("[^A-Za-z0-9.-]", "") }} 2049; do sleep 2; done; mount -t nfs4 {{ efs.efs.filesystem_address }} /var/www/html -o vers=4,minorversion=1,sec=sys
  - echo "{{ efs.efs.filesystem_address }} /var/www/html nfs4 vers=4,minorversion=1,sec=sys" | sudo tee --append /etc/fstab
从前面的代码片段可以看到,我们对挂载 Amazon EFS 服务提供的 NFS 共享的那一行做了更改——我们为什么需要做这个更改?
如果你还记得,在我们启动 Amazon EFS 服务时,我们提到过每个子网中自动注册的唯一 DNS 端点。为了避免在部署中构建逻辑来找出我们正在运行实例的可用区,从而使用正确的 DNS 名称来访问我们的 Amazon EFS 端点,我们创建了一个通用的端点 CNAME,它解析为子网的适当端点。
很好,你可能在想,这样我们就不用编写代码来考虑这个问题了——你是对的,但这个 DNS 别名的传播可能需要一段时间。
由于 cloud-init 脚本与我们的 Ansible playbook 运行完全独立,因此我们不能像刚才讨论的那样使用条件来等待某个正确的状态,然后再继续进行。
因此,为了绕过这个问题,我们正在添加以下内容:
until nc -vzw 2 somedns.domain.com 2049; do sleep 2;
done;
这是我们之前添加的条件的 Bash 等价物。它会运行 netcat (nc) 命令,检查 somedns.domain.com 是否在端口 2049 上响应。如果没有,它会使用 sleep 命令等待两秒钟,然后重复直到得到正确的响应。
你可能还注意到,我们正在使用另一个 Ansible 函数来从输出注册表中获取 Amazon EFS 端点的详细信息。
默认情况下,如果我们仅使用 {{ efs.efs.filesystem_address }},它将返回我们 Amazon EFS 端点的完全限定域名,并将文件系统路径附加到末尾,在我们的例子中就是 :/。
这不是 nc 命令可用的有效地址,因此我们需要从地址中移除 :/。为此,我们可以使用 Ansible 的 regex_replace 函数,因为我们要移除所有不是常规字符、点或连字符的部分。这样做后,看起来会是这样的:
{{ efs.efs.filesystem_address | regex_replace("[^A-Za-z0-9.-]", "") }}
这段代码应该会让我们得到类似 somedns.domain.com 而不是 somedns.domain.com:/ 的结果。
剩下的脚本保持不变。对于通过 ASG 部署的 Web EC2 实例使用的简化版 cloud-init 脚本,我们也必须使用与之前相同的逻辑,接下来我们会详细查看。
AWS ASG 角色
这个角色遵循与 AWS VM 管理员角色类似的模式,首先生成 cloud-init 脚本:
- name: Generate temp web cloud-init file
  ansible.builtin.tempfile:
  register: tmp_file_create_cloud_init_web_task
- name: Create the web cloud-init file from a template file
  ansible.builtin.template:
    src: "vm-cloud-init-web.yml.j2"
    dest: "{{ tmp_file_create_cloud_init_web_task.path }}"
然后我们需要创建一个启动配置:
- name: Create launch config
  community.aws.autoscaling_launch_config:
    name: "{{ lauch_configuration_name }}"
    image_id: "{{ ami.image_id }}"
    region: "{{ region }}"
    security_groups: "{{ security_group_web.group_name }}"
    instance_type: "{{ ec2.instance_type }}"
    assign_public_ip: "{{ ec2.public_ip }}"
    user_data: "{{ lookup('file', tmp_file_create_cloud_init_web_task.path) }}"
如你所见,这里使用了community.aws.autoscaling_launch_config模块,因为目前amazon.aws集合中没有官方支持用于创建启动配置。
角色中的最后一个任务,也是我们直接针对 AWS 的最后一个任务如下所示:
- name: Create the Auto Scaling Group
  amazon.aws.autoscaling_group:
    name: "{{ asg_name }}"
    region: "{{ region }}"
    target_group_arns:
      - "{{ alb_target_group.target_group_arn }}"
    availability_zones:
      - "{{ zones.availability_zones[0].zone_name }}"
      - "{{ zones.availability_zones[1].zone_name }}"
    launch_config_name: "{{ lauch_configuration_name }}"
    min_size: "{{ ec2.asg.min_size }}"
    max_size: "{{ ec2.asg.max_size }}"
    desired_capacity: "{{ ec2.asg.desired_capacity }}"
    vpc_zone_identifier:
      - "{{ subnet_web01.subnet.id }}"
      - "{{ subnet_web02.subnet.id }}"
    tags:
      - Name: "{{ asg_name }}"
      - Description: "{{ dict.ansible_warning }}"
      - Project: "{{ app.name }}"
      - Environment: "{{ app.env }}"
      - Deployed_by: "Ansible"
这将创建 ASG,它将立即开始启动我们在{{ ec2.asg.desired_capacity }}变量中定义的实例数量。
所有值再次使用硬编码变量填充,比如我们刚才提到的变量或输出寄存器。
输出角色
现在剩下的就是在终端打印一些信息,其中包含我们需要打开的 URL,以访问我们的 WordPress 安装以及登录所需的凭据:
- name: Output details on the deployment
  ansible.builtin.debug:
    msg:
      - "Wordpress Admin Username: {{ wordpress.wp_admin_user }}"
      - "Wordpress Admin Password: {{ wp_password }}"
      - "Wordpress URL: http://{{ alb.dns_name }}/"
这就是我们的 Ansible playbook 的结尾,接下来我们来看一下如何执行它。
运行 Ansible playbook
运行 playbook 的命令与我们在第四章中用于部署到 Microsoft Azure时的相同:
$ ansible-playbook site.yml
完成后,你应该能看到如下输出:

图 5.4 – playbook 运行输出的最后几行
如果你在查看输出时,可能已经注意到我们放置的逻辑,用来等待 admin EC2 实例进入running状态。那些行可以在以下截图中找到:

图 5.5 – 等待实例进入运行状态
现在,你可以跟随输出中的 URL,查看你的 WordPress 安装。它应该看起来像第四章中 Azure 的安装,并且在 AWS 管理控制台中的 AWS 资源可以在console.aws.amazon.com查看。
一旦你浏览完毕,可以通过运行以下命令来终止 playbook 启动的所有资源:
$ ansible-playbook destory.yml
你可能会注意到,发生的事情更多了,正如以下输出所示:

图 5.6 – 删除所有资源
实际上,与我们在第四章中运行同一个 playbook 时相比,这里几乎有 20 个任务,而不是几个任务;为什么会这样呢?
这是 Microsoft Azure 和 AWS 之间的另一个区别。当我们在 Microsoft Azure 中部署资源时,我们将它们部署到一个单独的资源组中,该资源组充当你的工作负载的逻辑容器,将所有资源集中在一起。
当我们终止 Microsoft Azure 部署时,我们必须在一个任务中删除资源组及其包含的所有资源。
然而,AWS 非常不同,我们需要构建一个 playbook,以相反的顺序终止我们部署的资源。
在destroy.yml文件中使用的一些任务重用了我们在部署资源时在角色中使用的一些逻辑,因此在我们开始使用 Terraform 在 AWS 中操作之前,让我们快速讨论一下destroy.yml剧本,首先是自动扩展组,它将移除我们已经启动的实例。
自动扩展组
有三个任务涉及移除 ASG;第一个任务使用amazon.aws.autoscaling_group_info模块获取 ASG 的信息。
第二个任务使用amazon.aws.autoscaling_group模块,并进行了足够的配置,使我们能够将state设置为absent——但仅在前一个任务返回的结果大于 0 时才会执行。为了实现这一点,我们使用了以下这一行:
when: asgs.results | length > 0
这意味着,如果 ASG 已经被移除,任务将被跳过,但由于另一个任务失败,我们需要重新运行剧本。我们将在整个剧本中使用这种逻辑。
三个任务中的最后一个使用community.aws.autoscaling_launch_config模块移除启动配置。
EC2 实例
这里只需要完成两个任务,第一个任务使用amazon.aws.ec2_instance_info获取我们 EC2 实例的信息,第二个任务使用amazon.aws.ec2_instance将state设置为absent,当第一个任务返回结果时执行。
RDS 实例
这里有三个任务,第一个获取信息,第二个终止 RDS 实例,第三个移除 RDS 子网组。
EFS 实例
这里只需要一个任务;它使用community.aws.efs确保在{{ region }}定义的区域中,任何与{{ efs_name }}匹配的资源都不存在。
弹性负载均衡器
这里有两个简单的任务,分别使用amazon.aws.elb_application_lb和community.aws.elb_target_group将我们的state资源设置为absent。
安全组
如果你记得,在我们添加安全组时,我们使用了 Web 安全组的 ID 来允许访问 RDS 和 EFS 资源。同时,正如我们在启动 EC2 实例时讨论的那样,Ansible 剧本有时会比 AWS API 更快完成任务,也就是说,Ansible 可能会在 AWS 完成先前任务之前就开始执行下一个任务。
因此,存在这样的风险:在剧本尝试移除 Web 安全组之前,RDS 或 EFS 安全组可能没有完全移除,这将导致依赖错误。
为了避免这种情况,我们在任务中内置了一些检查:
    - name: Delete the security groups
      amazon.aws.ec2_security_group:
        name: "{{ item }}"
        region: "{{ region }}"
        state: absent
      with_items:
        - "{{ vpc_security_group_name_efs }}"
        - "{{ vpc_security_group_name_rds }}"
        - "{{ vpc_security_group_name_web }}"
      register: delelte_security_groups
      until: "delelte_security_groups is not failed"
      retries: 25
      delay: 10
如你所见,我们使用了with_items来循环遍历我们的三个安全组,并将它们的state设置为absent。我们还设置了until,该参数将重复执行失败的循环部分,直到成功移除安全组:

图 5.7 – 将安全组的状态设置为 absent
它将允许 25 次失败,并每 10 秒尝试一次。如前面的截图所示,它应该只会失败一两次,然后继续执行。
虚拟私有云
剩余的任务都按照我们之前定义的相同模式进行,除了路由表。像其他资源一样,我们使用一个模块,在这个案例中是amazon.aws.ec2_vpc_route_table_info,来获取路由表的信息。然而,这里的不同之处在于它将返回在首次启动 VPC 时创建的默认路由表。如果我们尝试删除它,将会报错。
为了解决这个问题,我们必须在任务中的when子句进行扩展:
    - name: Delete the Route Table
      amazon.aws.ec2_vpc_route_table:
        route_table_id: "{{ item.route_table_id }}"
        vpc_id: "{{ the_vpc.vpcs[0].id }}"
        region: "{{ region }}"
        lookup: id
        state: absent
      when: the_vpc.vpcs | length > 0 and item.associations[0].main != true
      with_items: "{{ the_route_tables.route_tables }}"
如你所见,如果列出了多个路由表并且它不是main关联,它将删除路由表。运行时看起来像下面这样:

图 5.8 – 移除自定义路由表但跳过主路由表
剩余的任务遵循我们在本章其他地方启动资源时使用的相同模式。
信息
记得确保通过检查 AWS 管理控制台中的资源是否仍然列出,来确认所有资源已被移除。访问console.aws.amazon.com,如果前面的剧本由于任何原因失败,可能会产生意外费用。
这就是剧本的结尾,它移除了资源并结束了我们对在 AWS 上运行 Ansible 的深入探讨。
现在是时候继续进行 Terraform 了。
Terraform – 审查代码并部署我们的基础设施
如我们在第四章《部署到微软 Azure》中深入探讨 Terraform 时所做的那样,我们不会深入讨论代码,而是将重点介绍在针对 AWS 时需要考虑的事项,或者在将工作负载部署到微软 Azure 时未使用的功能。
Terraform 文件的逐步解析
接下来是对每个 Terraform 文件的逐步解析。正如我们对微软 Azure 所做的那样,我将每组逻辑资源放在一个单独的.tf文件中。
设置
这与我们为 Azure 定义的差别不大。有一些明显的区别——其中最大的区别是我们使用了 AWS 提供程序:
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
此外,我们将区域硬编码为要启动资源的提供程序配置选项:
provider "aws" {
  region = "us-east-1"
}
有一些遗漏,因为我们没有加载任何辅助提供程序或模块来帮助我们进行资源命名;这将由我们在启动资源时定义。
网络
这里有几个任务:
- 
resource "aws_vpc" "vpc",该代码启动 VPC
- 
resource "aws_subnet" "web01",该代码将添加web01子网
- 
resource "aws_subnet" "web02",该代码将添加web02子网
- 
resource "aws_subnet" "rds01",该代码将添加rds01子网
- 
resource "aws_subnet" "rds02",该代码将添加rds02子网
四个子网任务都很相似:
resource "aws_subnet" "web01" {
  vpc_id            = aws_vpc.vpc.id
  cidr_block        = cidrsubnet("${aws_vpc.vpc.cidr_block}", 3, 0)
  availability_zone = var.zones[0]
  tags              = merge(var.default_tags, tomap({ Name = "${var.name}-${var.environment_type}-web01-subnet" }))
}
如你所见,在定义每个子网的 CIDR 范围时略有不同;与我们在 Microsoft Azure 上硬编码 CIDR 范围的做法不同,我们遵循与使用 Ansible 时类似的模式,使用 Terraform 函数 cidrsubnet 来生成正确的 CIDR 范围。
唯一需要注意的是,我们将定义在 tfvars 文件中的 default_tags 列表与我们通过 tomap 函数动态创建的映射合并。该映射名称包含 Name 标签。我们将在剩余的部署过程中重复使用这种方法。
剩余的任务与我们使用 Ansible 部署时执行的任务非常相似:
- 
resource "aws_internet_gateway" "vpc_igw",用于部署互联网网关。
- 
resource "aws_route_table" "vpc_igw_route",用于添加路由表,将所有外向流量路由到互联网网关。
- 
resource "aws_route_table_association" "rta_subnet_public01",用于将我们刚创建的路由表与web01子网关联。
- 
resource "aws_route_table_association" "rta_subnet_public02",用于将我们刚创建的路由表与web02子网关联。
- 
resource "aws_security_group" "sg_vms",用于创建一个安全组,开放端口80和22给所有人,也就是0.0.0.0/0。
- 
resource "aws_security_group" "sg_efs",用于添加 EFS 安全组,打开端口2049,以允许任何附加了 Web 安全组的资源访问。
- 
resource "aws_security_group" "sg_rds",用于创建 RDS 安全组,打开端口3306,允许任何附加了 Web 安全组的资源访问。
- 
resource "aws_lb" "lb",用于创建类型为application的弹性负载均衡器。
- 
resource "aws_lb_target_group" "front_end",用于创建目标组,我们将注册我们的 EC2 实例。
- 
resource "aws_lb_listener" "front_end",用于配置弹性负载均衡器上端口80的前端监听器。当我们使用 Ansible 启动工作负载时,这一项是在线定义的。
这些就是我们启动并配置以支持工作负载其余服务所需的所有网络资源。现在我们可以开始定义这些资源本身。
存储
这个文件中有三个任务,分别如下:
- 
resource "aws_efs_file_system" "efs",用于创建 Amazon EFS 卷
- 
resource "aws_efs_mount_target" "efs_mount_targets01",用于在web01子网中创建挂载目标
- 
resource "aws_efs_mount_target" "efs_mount_targets02",用于在web02子网中创建挂载目标
现在我们的存储已经到位,我们可以进入 Amazon RDS 实例。
数据库
同样,我们在此只定义了三个任务来配置并启动我们的 Amazon RDS 实例。它们如下:
- 
resource "aws_db_subnet_group" "database",用于创建子网组,使我们的 Amazon RDS 实例可以从我们的 VPC 内部访问。
- 
resource "random_password" "database_password",它会随机生成我们在启动 Amazon RDS 服务时使用的密码
- 
resource "aws_db_instance" "database",它将 Amazon RDS 实例部署到我们定义的子网组中,并按照variables文件中定义的变量进行配置
正如你已经知道的,既然我们正在按照使用 Ansible 时启动工作负载的步骤,现在是时候启动 Admin EC2 实例并引导 WordPress 了。
虚拟机(管理员)
首先,我们需要找到合适的 AMI 来使用。这与使用 Ansible 时有所不同,因为 Terraform 可以在任务执行时为我们选择最新的 AMI:
data "aws_ami" "ubuntu_admin" {
  most_recent = var.ami_most_recent
  owners      = [var.ami_owners]
  filter {
    name   = "name"
    values = [var.ami_filter_name]
  }
  filter {
    name   = "virtualization-type"
    values = [var.ami_filter_virtualization_type]
  }
}
如你所见,我们将 most_recent 键的值设置为 var.ami_most_recent 变量的值,默认情况下它被设置为 true。
在启动 EC2 实例之前,我们还需要做最后一项准备工作,那就是创建 WordPress 管理员密码:
resource "random_password" "wordpress_admin_password" {
  length           = 16
  special          = true
  override_special = "_%@"
}
现在我们拥有了启动 EC2 实例所需的一切。首先,我们定义启动实例所需的基本信息:
resource "aws_instance" "admin" {
  ami  = data.aws_ami.ubuntu_admin.id
  instance_type = var.instance_type
  subnet_id = aws_subnet.web01.id
  associate_public_ip_address = true
  availability_zone = var.zones[0]
  vpc_security_group_ids = [aws_security_group.sg_vms.id]
任务的下一部分是定义用户数据。稍后会详细介绍:
  user_data = templatefile("vm-cloud-init-admin.yml.tftpl", {
    tmpl_database_username = "${var.database_username}"
    tmpl_database_password = "${random_password.database_password.result}"
    tmpl_database_hostname = "${aws_db_instance.database.address}"
    tmpl_database_name     = "${var.database_name}"
    tmpl_file_share        = "${aws_efs_file_system.efs.dns_name}"
    tmpl_wordpress_url     = "http://${aws_lb.lb.dns_name}/"
    tmpl_wp_title          = "${var.wp_title}"
    tmpl_wp_admin_user     = "${var.wp_admin_user}"
    tmpl_wp_admin_password = "${random_password.wordpress_admin_password.result}"
    tmpl_wp_admin_email    = "${var.wp_admin_email}"
  })
最后,我们定义标签,其中包括资源名称:
  tags = merge(var.default_tags, tomap({ Name = "${var.name}-${var.environment_type}-ec2-admin" }))
}
如你所见,我们使用了与在 Microsoft Azure 中启动工作负载时类似的逻辑,通过使用 templatefile 函数注入 user_data。不过这次我们不需要对其进行 Base64 编码。
cloud-init 文件的模板包含我们在使用 Ansible 启动工作负载时所做的相同更改,再次使用 nc 检查 NFS 共享的 DNS 端点是否在 2048 端口上响应,然后才挂载卷。唯一的其他差异与两者之间的模板化功能有关。
最后的任务,像 Ansible 一样,是将新启动的 EC2 实例注册到我们的 ELB 目标组中:
resource "aws_lb_target_group_attachment" "admin" {
  target_group_arn = aws_lb_target_group.front_end.arn
  target_id        = aws_instance.admin.id
  port             = 80
}
使用 Terraform 和 Ansible 之间的最终区别在于,我们不需要在代码中构建等待 EC2 实例达到 running 状态的逻辑,因为 Terraform 会继续轮询 EC2 实例的状态,直到其达到期望状态——默认情况下是 running。
任何依赖于 EC2 任务的操作,比如我们的 "aws_lb_target_group_attachment" "admin" 任务,都不会像 Ansible 部署时那样报错,因为部署不会在满足条件之前继续推进。
自动扩展组(web)
与 Ansible 一样,我们将启动的最后一组 AWS 资源是 web 服务器的 ASG。
同样,我们从启动配置开始:
resource "aws_launch_configuration" "web_launch_configuration" {
  name_prefix                 = "${var.name}-${var.environment_type}-alc-web-"
  image_id                    = data.aws_ami.ubuntu_admin.id
  instance_type               = var.instance_type
  associate_public_ip_address = true
  security_groups             = [aws_security_group.sg_vms.id]
  user_data = templatefile("vm-cloud-init-web.yml.tftpl", {
    tmpl_file_share = "${aws_efs_file_system.efs.dns_name}"
  })
}
就像我们在 Microsoft Azure 中部署时一样,我们只需要传递一个变量——Amazon EFS 端点的 DNS 端点——到模板文件中。
现在启动配置已到位,我们可以创建 ASG,它将立即启动我们在 var.min_number_of_web_servers 变量中定义的 EC2 实例数量:
resource "aws_autoscaling_group" "web_autoscaling_group" {
  name                 = "${var.name}-${var.environment_type}-asg-web"
  min_size             = var.min_number_of_web_servers
  max_size             = var.max_number_of_web_servers
  launch_configuration = aws_launch_configuration.web_launch_configuration.name
  target_group_arns    = [aws_lb_target_group.front_end.arn]
  vpc_zone_identifier  = [aws_subnet.web01.id, aws_subnet.web02.id]
  lifecycle {
    create_before_destroy = true
  }
}
完成此任务后,我们已经拥有了启动工作负载所需的一切,除了输出,它告诉我们如何访问 WordPress。
输出
这里定义了三个输出,其中一个被标记为sensitive:
output "wp_user" {
  value     = "Wordpress Admin Username: ${var.wp_admin_user}"
  sensitive = false
}
output "wp_password" {
  value     = "Wordpress Admin Password: ${random_password.wordpress_admin_password.result}"
  sensitive = true
}
output "wp_url" {
  value     = "Wordpress URL: http://${aws_lb.lb.dns_name}/"
  sensitive = false
}
这将给出您可以用来访问 WordPress 网站的 URL,以及用户名和密码。现在我们可以运行我们的 Terraform 脚本了。
部署环境
要部署环境,我们只需运行以下命令:
$ terraform init
$ terraform apply
在运行terraform apply后,提示时回答yes将继续部署,完成后,您应该会看到类似以下的屏幕:

图 5.9 – 使用 Terraform 部署环境
再次提醒,就像我们在部署到 Microsoft Azure 时做的那样,运行terraform output -json将显示sensitive值的内容,这意味着您可以浏览并登录到 WordPress,并在 AWS 管理控制台中查看资源。
完成后,只需运行以下命令:
$ terraform destroy
这将删除我们使用terraform apply命令创建的所有资源。和往常一样,请在 AWS 管理控制台中再次检查,确保所有资源都已正确删除,以避免产生意外费用。
总结
在本章中,我们深入探讨了如何使用 Ansible 在 AWS 中部署我们的 WordPress 环境。
在讨论了我们的部署情况后,我们回顾了 Ansible playbook,并扩展了我们在第四章中对部署到 Microsoft Azure的快速概述。我们讨论了 Ansible 角色以及如何使用ansible-galaxy init命令引导一个角色。
我们讨论了一些内置函数和工具,如ipsubnet、sort和regex_replace,这些我们用来操作硬编码和输出变量。我们还介绍了几种不同的方法,通过使用until等函数将逻辑构建到我们的 playbook 任务中,以确保我们的 playbook 在启动资源时不出错,并且同样重要的是,在终止资源时也不会出错。毕竟,我们不希望留下无用资源而浪费金钱。
然后我们简要查看了如何使用 Terraform 部署相同的资源,因为我们已经深入探讨过 Terraform,突出了在部署资源时可以采用的一些额外方法。
在这两个演练中,我们还讨论了在 AWS 和 Microsoft Azure 中部署工作负载时所需采取的方法差异。
随意修改 Ansible 和 Terraform 代码;例如,尝试更新启动的服务器数量、更新各种 SKU、更改网络地址等,看看您的更改对部署的影响。
在下一章中,我们将扩展本章内容,并通过进一步探讨我们一直在查看的两个云中立工具的工作原理以及在接触云服务提供商时需要考虑的因素,来深入了解第四章,部署到微软 Azure。
我们还将探讨如何使我们的 Ansible 和 Terraform 代码更具可重用性。
进一步阅读
您可以通过以下网址找到本章中提到的服务和文档的更多细节:
- 
Amazon 服务: - 
Amazon ELB: aws.amazon.com/elasticloadbalancing/
- 
Amazon EC2: aws.amazon.com/ec2/
- 
Amazon EFS: aws.amazon.com/efs/
- 
Amazon RDS: https://aws.amazon.com/rds/ 
- 
Amazon VPC: aws.amazon.com/vpc/
- 
Amazon EC2 ASGs: docs.aws.amazon.com/autoscaling/ec2/userguide/auto-scaling-groups.html
 
- 
- 
Ansible 集合: - 
Amazon AWS 集合: galaxy.ansible.com/amazon/aws
- 
社区 AWS 集合: galaxy.ansible.com/community/aws
 
- 
- 
Terraform 提供者: - HashiCorp AWS: registry.terraform.io/providers/hashicorp/aws/
 
- HashiCorp AWS: 
第六章:基础建设的基础
随着我们继续在不断变化的云计算领域中前行,理解如何在不同的公共云提供商之间部署高级设计的细微差别变得至关重要。在本章中,我们将研究使用诸如 Terraform 和 Ansible 等云平台无关工具时出现的差异。
我发现,尽管我们竭力保持一致性,但在不同提供商之间部署设计时,总会出现一些变数。在本章中,我将分享一些我自己在处理这些变异时的经验,并提供一些实际的方法,用于构建可重复的部署流程,适用于各种应用和环境。
我们还将讨论创建模块化代码的重要性,这对于简化部署工作和避免代码重复至关重要。通过实现这些技术,我们可以高效且有效地在不同的公共云提供商之间部署我们的设计。
本章将涵盖以下主题:
- 
理解云平台无关工具 
- 
理解我们两个云部署之间的差异 
- 
理解 Terraform 和 Ansible 部署之间的差异 
- 
引入更多变量 
- 
使代码更具可复用性 
让我们先讨论一下我们所使用的工具有多么云平台无关。
理解云平台无关工具
在第四章,部署到微软 Azure,以及第五章,部署到亚马逊 Web 服务中,我们使用了 Terraform 和 Ansible 来定位这些云平台——因此我们知道它们与这两个云提供商兼容,但我们复用了多少代码呢?
诚实的回答是:几乎没有。
我们为每个云提供商使用了不同的提供商/集合。因此,我们需要做出很多调整。虽然从概念上看,云提供商在高层次上提供了相似的服务,但它们在实现相同任务时的方式有很大的差异。
例如,启动一个简单的虚拟机需要两种不同的方式:部署诸如网络等服务时,需要考虑和配置不同的因素,因为它们的工作方式本质上不同。
那么,为什么我们称我们一直在使用的这两个工具为云平台无关工具呢?这难道不意味着它们 可以直接工作 吗?
在理想的世界里,是的,应该是这样的。根据 2023 年初写本书时的 AI 工具趋势,我们可能已经足够接近,用自然语言定义我们的基础设施即代码(IaC)部署,带有一些约束和规则,并能够针对我们选择的云平台进行部署。
虽然那可能接近理想,但现在还不存在。
那么回到现在,结合我们一直在使用的两个工具,我们能做出哪些改变,使得我们的工作尽可能做到云平台无关(cloud agnostic)呢?
正如我们在 第四章《部署到 Microsoft Azure》和 第五章《部署到 Amazon Web Services》中发现的那样,Terraform 和 Ansible 提供了一些有用的辅助函数、工具和实用程序,因此我们在部署中越多地利用这些工具,效果就会越好。
在本章的其余部分,我们将探讨在我们的部署中,无论目标云是什么,我们都可以一致使用的内容。
为此,我们需要查看我们部署中的一致性,然后找出如何通过制定更标准化的方法来编写、管理和执行代码,来在部署中最好地考虑这些一致性。
理解 Microsoft Azure 和 Amazon Web Services 部署之间的差异
让我们总结一下我们的部署,因为在前两章中,我们已经在四组独立的代码中讨论了部署。
一般
这里只有一个服务,正如你所看到的——它仅在我们的一个目标云提供商中可用:
| 服务/功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 资源容器 | 资源组 | 不可用 | 
在 Amazon Web Services 中没有与资源组相等的概念,尽管有人可能会认为标签起到了相同的作用。然而,标签更多的是作为查找和报告资源的方式,而不是将所有资源聚集在一个容器中。正如我们所见,这些容器可以被删除或应用权限。
网络
接下来,我们有网络资源;任何标记为***的资源,虽然可用,但在我们的 WordPress 部署中并未使用:
| 服务/ 功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 网络 | 虚拟网络 | 虚拟私有 云(VPC) | 
| 子网 | 子网 | 子网 | 
| 网关 | NAT 网关 * | Internet 网关 | 
| 路由表 | 路由表 * | 路由表 | 
| 安全 | 网络安全组 | 安全组 | 
| 负载均衡器 | 负载均衡器/应用程序网关 * | 弹性负载均衡器*/应用程序负载均衡器 | 
从服务的角度来看,我们的服务覆盖面广泛。同时,它们在两个云服务之间的配置略有不同:
- 
资源名称:所有 Azure 资源都需要一个名称。 
- 
资源区域和可用区:两个云都有区域的概念——在大多数区域中,有多个可用区,但值得注意的是,Microsoft Azure 的一些次要区域——例如英国西部,没有可用区。 
- 
10.0.0.0/24。
- 
子网地址:我们在两个云中部署的子网之间存在一些关键差异;例如,在 Microsoft Azure 中,我们需要将特定服务委派给它们,而在 AWS 中,则不需要委派服务。尽管如此,我们仍然将子网绑定到目标区域内的可用区。不过,除此之外,每个云所需的信息大致相同。 
在我们部署的网络服务中,有一些会受益于使用循环和传递变量进行配置——尽管这可能会有些复杂,因为我们需要为 Azure 服务的 Terraform 和 Ansible 编写一些逻辑,这可能需要将服务委派到子网。
存储
表面上看,这应该很简单,因为我们只需要启动并配置一些存储;然而,正如你可能从我们的脚本中记得的那样,两大云服务商在存储方面存在相当大的差异:
| 服务/功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 存储(网络文件 系统(NFS)) | 启用 Azure 文件的存储帐户 | 亚马逊弹性文件服务 | 
| 私有 域名 系统(DNS) | 私有 DNS 区域 | 挂载目标 | 
| 网络集成 | 私有端点 | 不需要 | 
如你所见,微软在 Azure 中处理服务网络集成的方式存在一些差异——这里的关键词是 集成。
两大云服务商之间最显著且一致的区别是它们如何在其 平台即服务(PaaS)服务中处理网络。
我通常解释,亚马逊从零开始构建其 PaaS 服务,以便在 Amazon VPC 网络中部署。
相比之下,微软将其 PaaS 服务构建为允许你将其连接到虚拟网络。在某些情况下,这种连接并非始终是双向的,因此某些 PaaS 服务只能访问虚拟网络内的资源,而不能在虚拟网络内被消费——尽管在我们示例中的 WordPress 部署中不存在这种情况,但在规划部署时你需要考虑这一点。
启动和配置服务所需的信息相似,即使在之前描述的差异下也是如此。
数据库
按照惯例,在解释完 Microsoft Azure 中大多数 PaaS 服务具有一定程度的虚拟网络集成,而不是直接部署到网络中之后,我们启动一个托管在虚拟网络中的 Azure 网络服务:
| 服务/功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 数据库 | Azure MySQL 数据库 – 灵活服务器 | 亚马逊关系数据库服务 | 
| 私有 DNS | 私有 DNS 区域 | 子网组 | 
虽然我们在部署 Azure Database for MySQL – Flexible Server 时不需要添加私有端点,但我们确实需要将整个子网委派给该服务,因此在规划部署时,仍需考虑一些因素。
同样,启动服务所需的大部分信息在两个云提供商之间是相似的。
虚拟机(管理员)
在部署管理员虚拟机实例时,我们需要做一些考虑;然而,所需的信息对于我们的两个云提供商来说是相似的:
| 服务/功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 镜像 | 来自 Azure 市场的 Azure 镜像 | 来自 AWS 市场的 Amazon 机器镜像 (AMI) | 
| 计算 | Azure 虚拟机 | Amazon 弹性计算 云 (EC2) | 
| 负载均衡器附加 | 必需 | 必需 | 
如你所记得,当我们在 Amazon Web Services 中启动 WordPress 工作负载时,我们需要稍微调整我们的 cloud-init 脚本,以考虑一些服务使用上的差异。我们需要做的只是添加一些逻辑来检查,必要时等待资源可用。
虚拟机与扩展(Web)
我们在部署管理员虚拟机实例时提到的所有内容在这里也适用;实际上,两个提供商之间只有一个主要的区别:
| 服务/功能 | Microsoft Azure | Amazon Web Services | 
|---|---|---|
| 镜像 | 来自 Azure 市场的 Azure 镜像 | 来自 AWS 市场的 AMI | 
| 配置 | 不需要 | 启动配置 | 
| 计算 | Azure 虚拟机规模集 | Amazon EC2 自动扩展组 | 
| 负载均衡器附加 | 内联 | 内联 | 
如你所见,所有 Azure 配置都是内联的;然而,相比之下,Amazon Web Services 中的 Auto Scaling 组需要一个启动配置作为我们部署的基础。
看看它的实际操作
如你所见,虽然两个云提供商的工作方式略有不同,但它们的功能足够接近,至少在高层次上,你可以采取类似的部署方式。
那么,使用单一工具进行云无关性到底意味着什么呢?
嗯,正如我们在高层次上已经讨论过的那样,方法是相似的,尽管模块/任务可能有所不同,但在部署时你可以使用一些相同的逻辑。
让我们来看一下如何使用 Terraform 代码实现这一点;代码将创建一个主网络,然后使用循环在 Microsoft Azure 和 Amazon Web Services 中创建四个子网:
- 
首先,让我们看看我们将要使用的变量来实现这一目标 – 从一开始,我们有 name、region和default标签:variable "name" { description = "Base name for resources" type = string default = "iac-wordpress" } variable "region" { description = "The region to deploy to" type = string default = "uksouth" } variable "tags" { description = "The default tags to use across all of our resources" type = map(any) default = { project = "iac-wordpress" environment = "example" deployed_by = "terraform" } }
在我们两个云提供商之间唯一会变化的变量是 region,因为每个提供商的区域名称不同。
- 
接下来,我们定义地址空间: variable "address_space" { description = "The address space of the network" type = string default = "10.0.0.0/24" }
- 
这里没有什么特别的地方; 但是对于子网,我们定义了以下内容,虽然相当冗长,但您应该能够快速了解发生了什么: variable "subnets" { description = "The subnets to deploy the network" type = map(object({ name = string address_prefix_size = number address_prefix_number = number })) default = { subnet_001 = { name = "subnet001" address_prefix_size = "3" address_prefix_number = "0" }, subnet_002 = { name = "subnet002" address_prefix_size = "3" address_prefix_number = "1" }, subnet_003 = { name = "subnet003" address_prefix_size = "3" address_prefix_number = "2" }, subnet_004 = { name = "subnet004" address_prefix_size = "3" address_prefix_number = "3" }, } }
如您所见,我们在这里定义了一个映射,因为这样可以让我们得到可以循环遍历的内容。现在让我们继续 main.tf 文件。
信息
请注意,这不是完整的代码 - 请查看附带本标题的 GitHub 仓库以获取完整的可执行代码。
- 
我们首先创建网络本身;以下是为 AWS 创建 VPC 的代码: resource "aws_vpc" "network" { cidr_block = var.address_space tags = merge(var.tags, tomap({ Name = "${var.name}-vpc" })) }
- 
现在我们有了相同的任务,但这次是为了 Azure,它将创建一个虚拟网络: resource "azurerm_virtual_network" "network" { resource_group_name = azurerm_resource_group.resource_group.name location = azurerm_resource_group.resource_group.location name = "vnet-${var.name}-${var.region}" address_space = [var.address_space] tags = merge(var.tags, tomap({ Name = "vnet-${var.name}-${var.region}" })) }
如您所见,它们并不完全不同,我们正在应用相同的逻辑,使用 merge 函数来添加资源名称到标签列表中。
- 
现在我们有了网络,是时候循环遍历 subnets变量并添加这些内容了,首先从 AWS 开始:resource "aws_subnet" "subnets" { for_each = var.subnets vpc_id = aws_vpc.network.id cidr_block = cidrsubnet("${aws_vpc.network.cidr_block}", each.value.address_prefix_size, each.value.address_prefix_number) tags = merge(var.tags, tomap({ Name = "${var.name}-${each.value.name}" })) }
- 
然后再来一次,这次是为了 Azure: resource "azurerm_subnet" "subnets" { for_each = var.subnets name = each.value.name resource_group_name = azurerm_resource_group.resource_group.name virtual_network_name = azurerm_virtual_network.network.name address_prefixes = [cidrsubnet("${azurerm_virtual_network.network.address_space[0]}", each.value.address_prefix_size, each.value.address_prefix_number)] }
如您所见,我们在两者中都使用了相同的方法,即通过 for_each 循环遍历 var.subnets 变量。
然后我们使用 each.value.name 来命名资源,在 Azure 的情况下,使用 name 键,在 AWS 的情况下,通过创建一个 Name 标签。
对于两者,我们都使用创建网络的输出来引用它; 对于 AWS,我们使用 aws_vpc.network.id;在 Azure 中,我们使用 azurerm_virtual_network.network.name。
这将确保 Terraform 只在创建它们将要存在的网络后才尝试创建子网。
然后我们可以使用 cidrsubnet 函数来获取我们的地址空间,这里再次引用了我们使用的网络资源创建的内容,使用 "${aws_vpc.network.cidr_block}" 用于 AWS 和 "${azurerm_virtual_network.network.address_space[0]}" 用于 Azure。
然后我们使用 each.value.address_prefix_size 来定义每个子网的 CIDR 大小,在我们的情况下是 /27,并使用 each.value.address_prefix_number 来定义 /27 在地址空间中的位置。
如您所见,尽管在 Amazon Web Services 和 Microsoft Azure 之间的变量和函数应用有些许不同,但我们可以使用相同的逻辑来生成并循环遍历子网。
我们也可以使用 Ansible 应用相同的逻辑 – 如我们将在下一节讨论的那样。
理解我们的 Terraform 和 Ansible 部署之间的差异
我们已经讨论了当使用 Terraform 或 Ansible 时如何采用云不可知的方法来部署,因为每个工具都具有用于操作变量和运行任务输出的内置函数和逻辑。
在第四章、部署到 Microsoft Azure 和第五章、部署到 Amazon Web Services 的代码漫步中,应该已经显现出了一些明显的差异。我也相信您已经开始倾向于喜欢其中的哪一个工具。
这两款工具在方法上有很大的不同,这也是预期中的,因为它们被设计来完成两项不同的任务。
Terraform 主要是用于管理基础设施,而 Ansible 则用于管理服务器和状态配置,也包括一定程度的基础设施管理。
在我的日常工作中,我一直在使用这两款工具,并且继续使用它们——那么,选择使用其中一个工具的决定应该如何做呢?
如果一个项目需要在任何云平台上可重复地部署和配置多个 PaaS 服务——特别是如果这些资源需要被启动、消费然后终止,那么我建议使用 Terraform;原因如下:
- 
首先,它将所有内容存储在其状态文件中,使得终止任何工作负载变得更加简便。正如我们在使用 Ansible 终止 AWS 部署时所发现的,我们需要构建大量的逻辑来确保工作负载正确终止并被移除。 
- 
其次,它很好地与持续集成/持续交付(CI/CD)服务,如 GitHub Actions 配合使用,我们将在第七章中更详细地讨论,在云中利用 CI/CD。 
- 
最后,我发现它在云服务提供商推出的一些新服务和新功能方面有更多的覆盖和支持。这并不是要贬低 Ansible 开发团队的工作;只是 Ansible 在大多数情况下,相较于 Terraform,似乎在新功能上有所滞后,这取决于你所针对的云平台。 
使用 Terraform 的一些原因,实际上也是选择使用 Ansible 的一些促成因素——例如,由于 Ansible 不使用状态文件并且动态发现资源,它在管理生命周期中的变化时要更加直接。例如,一旦资源被部署并且服务进入生产状态,就不会出现工具强行强制执行它已知的状态的风险。
此外,如果我需要在资源级别与主机交互,例如,我需要安全外壳(SSH)连接到刚启动的服务器,或使用 WinRM 针对 Windows 服务器配置主机以设置 Apache 或 互联网信息服务(IIS),那么也可以使用 Ansible。
它非常适合与固定点一起使用,意味着假设你已经使用 Ansible 来管理工作负载的状态,这些工作负载曾在本地运行虚拟机,而你很可能可以重用很多这些代码,来针对云环境进行配置。
在这些情况下,Ansible 将是首选工具。
还有另一种选择——同时使用这两者!没错,你可以使用 Ansible 来运行你的 Terraform 代码,使用 community.general.terraform 任务。
在本书附带的代码仓库中,你会找到一个名为ansible-terraform-azure的文件夹。这个文件夹包含一个 Ansible 剧本,它将使用 Terraform 来启动一个 Azure 托管的虚拟机,然后通过 Ansible 连接到该虚拟机并安装index.html文件。
执行此操作的任务如下:
- name: Launch an Azure Virtal Machine instance and supporting resources using Terraform
  community.general.terraform:
    project_path: "./terraform"
    state: "present"
    complex_vars: true
    variables:
      name: "{{ app.name }}"
      region: "{{ azure.region }}"
      address_space: "{{ azure.vnet_address_space }}"
      vm_admin_username: "{{ azure.vm_admin_username }}"
      vm_ssh_public_key: "{{ lookup('file', '{{ ssh.public_key_path }}') }}"
      tags:
        app: "{{ app.name }}"
        env: "{{ app.env }}"
        deployed_by: "{{ app.deployed_by }}"
    force_init: true
  register: terraform_output
如你所见,我们告诉任务 Terraform 代码的位置;在本例中,它位于terrform文件夹中。然后我们传递了几个变量,这些变量会覆盖在terraform文件夹中的variables.tf文件中定义的默认值。
作为 Terraform 执行的一部分,我们正在输出公共 IP 地址和虚拟机的名称,然后通过以下任务将它们添加到主机组中:
- name: Add the Virtual Machine to the vmgroup group
  ansible.builtin.add_host:
    groups: "{{ host_group_name }}"
    hostname: "{{ terraform_output.outputs.vm_name.value }}"
    ansible_host: "{{ terraform_output.outputs.public_ip.value }}"
    ansible_port: "{{ ssh.port_number }}"
在最终使用ansible.builtin.set_fact模块设置一些事实之前:
- name: set some facts based on the virtual machine we just launched using Terraform
  ansible.builtin.set_fact:
    ansible_ssh_private_key_file: "{{ ssh.private_key_path }}"
    ansible_ssh_user: "{{ azure.vm_admin_username }}"
    the_public_ip: "{{ terraform_output.outputs.public_ip.value }}"
    the_vm_name: "{{ terraform_output.outputs.vm_name.value }}"
如果你运行该剧本,可以通过以下命令执行:
$ ansible-playbook -i inv site.yml
你应该会看到类似以下的输出:

图 6.1 – 让 Ansible 运行 Terraform
如果你跟随输出中提供的链接(前面截图中的链接已经失效),你应该会看到一个网页,类似下面的屏幕:

图 6.2 – 让 Ansible 运行 Terraform
你可以使用以下剧本移除所有内容:
$ ansible-playbook -i inv destroy.yml
正如你可能想象的那样,由于我们使用 Terraform 来管理 Azure 资源,前面的剧本使用 Ansible 来运行terraform destroy,而不是像以前的 Ansible 剧本那样手动将每个资源设置为absent。
现在我们已经讨论了如何将 Ansible 和 Terraform 结合使用,以充分发挥两者的优势,接下来我们需要讨论变量。正如你所注意到的,我们在所有的 Ansible 和 Terraform 代码中使用了许多变量,所以现在让我们来讨论如何最好地使用它们。
引入更多变量
就我个人而言,我尽量做到使用变量而不是将值硬编码到代码中——尽管这在编写代码时可能需要更多时间,但我强烈推荐这样做,因为我们所讨论的这两个工具都允许你通过命令行在运行时覆盖变量。
在 Terraform 中执行此操作时,可以在运行apply命令时使用以下标志:
$ terraform apply -var region="eu-west-1"
当运行 Terraform 代码时,正如我们在本章前面讨论的那样,我们在 Amazon Web Services 中启动了一个网络,将区域从代码中默认设置的us-east-1更改为eu-west-1。
你可以添加多个变量;以下示例在前面的基础上扩展,添加了一个新的地址空间:
$ terraform apply -var region="eu-west-1" -var address_space="172.16.0.0/24"
当运行 Ansible 代码时,例如,在我们上一节执行的剧本中,你可以运行以下命令来覆盖顶级变量:
$ ansible-playbook -i inv site.yml --extra-vars 'host_group_name: "azure_vms "'
在这里,我们将主机组名称更改为azure_vms;你可能已经注意到我说的是顶层变量,因为我们在 Ansible playbook 中使用的许多变量都是多级子变量——这些看起来像以下内容:
page:
  title: "Infrastructure as Code for Beginners"
  body: "This Virtual Machine was launched using Terraform and Ansible"
要覆盖这些,我们需要将多个变量作为 JSON 传递;这将把我们的命令转变为以下内容:
$ ansible-playbook -i inv site.yml --extra-vars '{"page": { "title": "Changing a variable at runtime", "body": "Redeploying using a different variable" }}'
如果我尝试覆盖单个变量(例如,title),需要传入所有内容,因为它会替换整个块,这意味着不再有body变量,我们的 playbook 运行将出错。
我们将在下一章第七章《在云中利用 CI/CD》中使用一些这些技术以及更多内容。
如本节开始时所提到的,尝试使用尽可能多的变量有时会增加编写代码的时间——尤其是当你快速原型化代码以部署某些内容,并为方便使用而硬编码了一些值时。
可以使用像ChatGPT这样的工具以及其他 AI 服务来帮助添加变量,将代码中的硬编码值快速替换。
使用以下提示请求 ChatGPT 将一些基础的 Terraform 代码转化为包含变量的代码:
Add variables to this Terraform code using the current values as the defaults, making sure to add a description and the type of variable.
resource "azurerm_resource_group" "example" {
name = "example"
location = "West Europe"
}
它给出了以下响应:
variable "resource_group_name" {
  description = "The name of the Azure resource group."
  type        = string
  default     = "example"
}
variable "location" {
  description = "The location of the Azure resource group."
  type        = string
  default     = "West Europe"
}
resource "azurerm_resource_group" "example" {
  name     = var.resource_group_name
  location = var.location
}
如你所见,我们已经拥有将其复制并粘贴到代码中的一切——在撰写时,像 ChatGPT 这样的工具的响应需要再次核对,但正如本章前面提到的——随着像这样的工具变得更强大,完全可以预期你将在日常工作流中更多地使用它们。
使用变量的一个优点是它使我们的代码更具可重用性,接下来我们将更详细地讨论这一点。
让代码更具可重用性
除了使用变量,我们还可以重用代码块——当我们在第五章《部署到亚马逊 Web 服务》中讨论 Ansible 时,我们讨论了角色。在 Ansible 中,角色是为了重复调用而设计的,因此虽然我们使用它们将项目逻辑上分成更易管理的部分,我们还可以更进一步,使其只执行单一功能。
我们也可以在 Terraform 中做同样的事情。到目前为止,我们的大多数 Azure 部署都在使用从 Terraform 注册表下载的模块来管理区域设置。
Claranet,该模块的发布者,还有其他模块——让我们看看如何仅使用模块在 Azure 中创建虚拟网络(完整的可执行代码可以在本书的 GitHub 仓库中找到):
- 
首先,我们需要初始化区域模块,就像我们在其他 Terraform 代码中所做的那样: module "azure_region" { source = "claranet/regions/azurerm" azure_region = var.region }
- 
一旦我们锁定了区域,就可以使用该模块的输出创建资源组: module "rg" { source = "claranet/rg/azurerm" location = module.azure_region.location client_name = var.name environment = var.environment stack = var.project_name }
如你所见,我们使用 module.azure_region.location 来定义位置。然后,我们传入一些关于我们项目的细节 —— 由于 Claranet 是一家托管服务提供商,它在其模块中使用 client_name 和 stack。
- 
接下来,我们需要创建一个虚拟网络: module "azure_virtual_network" { source = "claranet/vnet/azurerm" environment = var.environment location = module.azure_region.location location_short = module.azure_region.location_short client_name = var.name stack = var.project_name resource_group_name = module.rg.resource_group_name vnet_cidr = var.address_space }
再次,我们可以看到更多相同的信息以及我们要使用的 CIDR 空间。
- 
最后一步是创建子网: module "azure_network_subnet" { for_each = var.subnets source = "claranet/subnet/azurerm" environment = var.environment location_short = module.azure_region.location_short custom_subnet_name = each.value.name client_name = var.name stack = var.project_name resource_group_name = module.rg.resource_group_name virtual_network_name = module.azure_virtual_network.virtual_network_name subnet_cidr_list = [cidrsubnet("${module.azure_virtual_network.virtual_network_space[0]}", each.value.address_prefix_size, each.value.address_prefix_number)] }
如你所见,我正在使用本章前面创建子网时使用的相同逻辑,使用 cidrsubnet 函数在 for_each 循环中遍历 subnets 变量。
那么,为什么你想要这样做呢?
正如我们在第四章中所看到的,部署到 Microsoft Azure,我们需要在使用 Terraform 部署我们的 WordPress 工作负载时,建立处理子网设置更改的逻辑 —— 在我们的案例中,这是为了将子网委托给 Azure Database for MySQL – Flexible Server 服务使用。
Claranet 提供的模块已经内置了此逻辑;例如,添加此逻辑的代码如下所示:
module "azure_network_subnet_001" {
  for_each             = var.subnets
  source               = "claranet/subnet/azurerm"
  environment          = var.environment
  location_short       = module.azure_region.location_short
  custom_subnet_name   = each.value.name
  client_name          = var.name
  stack                = var.project_name
  resource_group_name  = module.rg.resource_group_name
  virtual_network_name = module.azure_virtual_network.virtual_network_name
  subnet_cidr_list     = ["10.0.0.0/27"]
  subnet_delegation = {
    flexibleServers = [
      {
        name    = "Microsoft.DBforMySQL/flexibleServers"
        actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
      }
    ]
  }
}
Claranet 在 Terraform 注册中心拥有超过 80 个其他的 Microsoft Azure 和 Amazon Web Services 模块,而且它们并不是唯一发布模块的提供商 —— 其他提供商和个人也在该平台上发布了模块,并且所有模块都可以免费使用。
你也可以在 Terraform 注册中心发布自己的模块,甚至将其托管在 GitHub 上,作为公开或私有的代码库;使用模块并采取这种方法的好处是,它使你能够快速开发具有一致可重用组件的 IaC 部署。
那么,Ansible 呢?
如前所述,你可以使用角色,角色通过 Ansible Galaxy 分发 —— 与 Terraform 中可用的模块相比,这里的角色少得多 —— 但是你可以发布自己的角色或在本地重用它们。
快速测验
在我们结束本章之前,来做一个快速的小测验:
- 
在 Terraform 中,我们使用什么函数来处理 CIDR 范围? 
- 
在运行时传入变量时,哪些工具使用 --extra-vars标志,哪些使用-var?
- 
在 Terraform 任务或模块中,哪个关键字可以用来循环遍历变量的列表或映射? 
- 
在使用 NFS 时,哪两个公共云平台之一需要配置挂载目标? 
- 
Azure Database for MySQL – Flexible Server 要求我们对子网做什么? 
你可以在总结之后找到答案。
总结
利用变量、模块或角色,你可以以一致的方式快速构建 IaC 部署,并将其与团队的其他成员共享,允许每个人使用共享构建块来构建自己的环境。
这种方法的另一个优势是,由于你有多个环境或多个客户,你正在为项目反复部署相同类型的基础设施。
每个部署有一组变量,改变诸如库存单位 (SKUs) 或资源名称等内容,其它部分保持不变,这样可以节省时间并允许你集中管理所有部署。我们将在下一章 第七章,在云中利用 CI/CD 中查看如何集中管理我们的部署。
在我们继续之前,让我们快速总结一下本章讨论的内容。我们从澄清云无关工具的定义开始,然后查看了我们的 Amazon Web Services 和 Microsoft Azure 部署之间的区别。
接着我们讨论了选择使用 Terraform 或 Ansible 时所需采取的不同方法;我们还深入探讨了如何将这两个工具结合使用,并利用 Ansible 管理我们的 Terraform 部署。
进一步阅读
你可以在以下网址找到我们在本章中提到的服务和文档的更多详细信息:
- 
Terraform 注册中心: registry.terraform.io
- 
Claranet Terraform 模块和提供者: registry.terraform.io/namespaces/claranet
- 
Ansible Galaxy: galaxy.ansible.com
- 
Ansible Terraform 模块: docs.ansible.com/ansible/latest/collections/community/general/terraform_module.html
- 
ChatGPT: openai.com/blog/chatgpt/
答案
以下是小测验的答案:
- 
在 Terraform 中处理 CIDR 范围时,我们使用的函数名称是什么?答案是 cidrsubnet。
- 
在运行时传递变量时,哪些工具使用 --extra-vars标志,哪些使用-var?--extra-vars标志用于 Ansible,-var用于 Terraform。
- 
在 Terraform 任务或模块中,哪个键可以用来遍历变量的列表或映射?答案是 for_each,其值是你希望遍历的变量。
- 
在使用 NFS 时,哪一个公共云需要配置挂载目标?答案是 Amazon Web Services。 
- 
Azure Database for MySQL – Flexible Server 要求我们对子网做什么?Azure Database for MySQL – Flexible Server 必须通过 delegate键将整个子网委派给它。
第三部分:CI/CD 和最佳实践
在本部分中,我们将探讨如何在云中使用持续集成/持续部署 (CI/CD) 。我们将使用 GitHub Actions 来执行我们的 Terraform 和 Ansible 部署。
然后我们将讨论最佳实践和一些常见的故障排除技巧,最后回顾一些 Terraform 和 Ansible 的替代工具。
本部分包含以下章节:
- 
第七章**, 在云中利用 CI/CD 
- 
第八章**, 常见故障排除技巧与最佳实践 
- 
第九章**, 探索替代基础设施即代码工具 
第七章:在云中利用 CI/CD
我们已经发现 基础设施即代码(IaC)已成为现代开发的必备实践,使得开发者能够通过代码管理基础设施,而不需要手动配置它。
然而,从本地机器部署我们的基础设施(直到现在我们一直在做的事情)对于大规模系统来说已经不再足够。
这时 持续集成/持续部署(CI/CD)发挥作用,它自动化了部署过程,并提供一致且可靠的基础设施部署。
本章将探讨如何利用云中的 CI/CD 部署我们的基础设施即代码(IaC)。我们将重点介绍流行的 CI/CD 工具 GitHub Actions,该工具可以运行由不同事件触发的工作流,如拉取请求或代码提交。我们将探讨如何使用 GitHub Actions 在我们在 第四章《部署到 Microsoft Azure》和 第五章《部署到亚马逊 Web 服务》中涉及的公共云中运行 Terraform 和 Ansible 代码。
我们还将覆盖一些关键的安全实践,如在 GitHub Actions 中管理机密信息以及在部署完成后如何监控和维护部署。通过本章学习后,您将了解如何在云中利用 CI/CD 进行您的 IaC 项目部署。
本章将覆盖以下主题:
- 
介绍 GitHub Actions 
- 
使用 GitHub Actions 运行 Terraform 
- 
使用 GitHub Actions 运行 Ansible 
- 
安全最佳实践 
在我们动手开始编写代码之前,我们应该先讨论一下我们将用来部署基础设施的 CI/CD 工具。
技术要求
本章的源代码可在这里获取:github.com/PacktPublishing/Infrastructure-as-Code-for-Beginners/tree/main/Chapter07
介绍 GitHub Actions
那么,什么是 GitHub Actions?GitHub Actions 是一个自动化平台,允许开发者创建工作流来自动化软件开发任务,在我们这种情况下,意味着管理和部署我们的基础设施即代码工作负载。
GitHub Actions 的 beta 版本首次发布于 2019 年中期。GitHub Actions 的初始版本允许一小部分开发者创建和分享可以用于自动化开发流程中重复任务的操作。它作为 Jenkins、Travis CI 和 CircleCI 等其他流行自动化平台的竞争者发布。
GitHub Actions 基于几个概念,本章将详细介绍其中的以下几个:
- 
工作流:这些是使用 GitHub Actions 自动化的一系列任务。工作流在 YAML 文件中定义,这些文件存储在代码仓库中。工作流可以通过多种事件触发,例如向仓库推送代码、创建拉取请求或调度任务。 
- 
作业:这些是工作流中执行的单独工作单元。一个工作流可以有多个作业,每个作业可以在不同的平台或环境上运行。作业可以并行运行,也可以按顺序运行,具体取决于工作流的要求。 
- 
步骤:这些是构成作业的单独任务。每个步骤可以是一个 shell 命令、脚本或操作。操作是预构建的工作单元,可用于自动化日常开发任务,如构建和测试代码、部署应用程序和发送通知。 
- 
事件:这些是触发工作流的事件类型,GitHub Actions 支持多种事件类型,包括推送到代码库、拉取请求、定时事件和手动触发事件。 
GitHub Actions 是一个强大的自动化平台,允许开发人员在开发工作流中自动化许多任务。凭借其灵活和可定制的工作流、对各种事件的支持以及预构建的操作,GitHub Actions 已成为许多团队的必备工具。
随着其不断发展和新功能的推出,GitHub Actions 有望成为领先的 CI/CD 自动化平台。
与其继续谈论 GitHub Actions,不如我们动手实际操作,看看如何使用它运行 Terraform。
使用 GitHub Actions 运行 Terraform
在过去的四章中,我们谈到了很多关于 Terraform 的内容——然而,我们还没有解决一个重要问题——状态文件。
由于我们一直在本地运行 Terraform,因此在此之前并没有深入讨论状态文件的问题,接下来我们将详细了解它们,再讨论如何使用 GitHub Actions 运行 Terraform。
Terraform 状态文件
每次运行 Terraform 时,都会创建、更新或读取一个名为terraform.tfstate的文件。它是一个 JSON 格式的文件,包含 Terraform 创建或修改的资源信息。它包括与每个资源相关的 ID、IP 地址和其他元数据等详细信息。
Terraform 使用此文件跟踪基础设施的当前状态,以便在你修改基础设施代码时确定必须进行哪些更改。
状态文件对于 Terraform 的正确操作至关重要。它确保 Terraform 在运行terraform apply命令时,能够准确地判断需要对基础设施进行哪些更改。
没有状态文件,Terraform 将无法确定应该对你的基础设施做出哪些更改,这可能导致错误或意外行为——例如,终止和重新部署资源。
同时需要注意的是,Terraform 状态文件应被视为敏感信息;它包含有关你的基础设施资源的详细信息,也可能包含敏感信息,例如如果你使用 Terraform 生成了密码,它们也会包含在内。
这意味着我们必须确保状态文件被安全存储,并且只能授权用户访问。
那为什么我们现在才讨论这个呢?
好吧,像 GitHub Actions 这样的服务旨在提供短时间的计算资源以执行工作流,因此它们是临时的,这意味着没有固定的底层存储,所以一旦工作流完成,计算资源就会被终止,所有内容都会丢失。
为了支持这一点,Terraform 允许您使用后端来存储状态文件;正如您可能已经猜到的,默认的存储选项是本地存储,它会将文件存储在与您正在执行的 Terraform 代码相同的文件夹中。您还可以使用外部 Blob 存储,如 Amazon s3 或 Azure 存储账户(azurerm)。
以下示例展示了如何在名为 rg-terraform-state-uks 的资源组中使用名为 satfbeiac1234 的 Azure 存储账户:
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state-uks"
    storage_account_name = "satfbeiac1234"
    container_name       = "tfstate"
    key                  = "prod.terraform.tfstate"
  }
}
在 Azure 存储账户的情况下,container_name 参数是 Blob 容器,如果你把 Azure 存储账户当作文件系统来看,那么它就是文件夹名称,而 key 则是文件的名称。
Amazon S3 的配置并不复杂,您可以从以下示例中看到:
terraform {
  backend "s3" {
    bucket = "tfbeiac1234"
    key    = "tfstate/prod.terraform.tfstate"
    region = "us-east-1"
  }
}
在这里,我们告诉 Terraform 存储桶名称、文件路径以及托管 Amazon S3 存储桶的区域。
需要添加到前面的代码中的一件事是如何首先创建 Azure 存储账户或 Amazon S3 存储桶,以及 Terraform 如何与云服务提供商进行身份验证,以便能够读取和写入后端。
与其在这里讨论,不如深入到一个示例 GitHub Action 中看看。
GitHub Actions
在本章中,我将专注于 Microsoft Azure。因此,由于我们没有使用本地安装的 Azure 命令行界面(CLI),我们需要生成一些凭证,以便使用并授权访问我们的 Azure 订阅。
信息
请注意,以下列表中的通用唯一标识符(UUIDs)仅为示例;请确保在提示时用您自己的 UUID 替换它们。
为此,我们将使用以下命令创建一个服务主体。运行时,请确保用您自己的订阅 ID 替换命令中的订阅 ID,您可以在 Azure 门户的 订阅 部分找到该 ID:
$ az ad sp create-for-rbac --scopes /subscriptions/3a52ef17-7e42-4f89-9a43-9a23c517cf1a --role Contributor
这将生成类似以下的输出,其中以一条重要消息开头:
Creating 'Contributor' role assignment under scope '/subscriptions/3a52ef17-7e42-4f89-9a43-9a23c517cf1a
The output includes credentials that you must protect. Be sure that you do not include these credentials in your code or check the credentials into your source control. For more information, see https://aka.ms/azadsp-cli
根据前面的消息,确保将输出结果记录在安全的地方,因为这是唯一一次可以获得生成的密码的机会:
{
  "appId": "019f16d2-552b-43ff-8eb8-6c87b13d47f9",
  "displayName": "azure-cli-2023-03-18-14-28-04",
  "password": "6t3Rq~vT.cL9y7zN_apCvGANvAg7_v6wiBb1eboQ",
  "tenant": "8a7e32c4-5732-4e57-8d8c-dfca4b1e4d4a"
}
现在我们已经获得了详细信息,并已将新创建的 Contributor 服务主体授予对 Azure 订阅的访问权限,我们可以继续进行 GitHub 设置。
我们首先需要在 GitHub 仓库中输入一些密钥和变量,以配置 GitHub action。
我从一个名为 Terraform-github-actions-example 的空 GitHub 仓库开始;如果你在跟随这个教程,我建议你创建一个测试仓库,并将附带本教程的仓库代码复制到你的仓库中。
如前所述,首先我们需要做的是添加密钥和变量。为此,请转到你的仓库并点击设置。当设置页面打开后,你应该能在左侧菜单中看到密钥和变量;点击它后,它会展开一个子菜单,其中列出了Actions、Codespaces和Dependabot选项。
正如你可能已经猜到的,你需要点击Actions。这应该会呈现出类似如下的内容:

图 7.1 – 密钥和变量设置页面中的 Actions 选项
如果你点击新建仓库密钥按钮并输入下表中详细列出的密钥,请确保你输入的名称与表中所写完全一致,因为 GitHub 的动作工作流代码在执行时会引用这些名称:
| 名称 | 密钥内容 | 
|---|---|
| ARM_CLIENT_ID | 这是我们运行添加服务主体命令时输出的 appId值。根据示例输出,它将是019f16d2-552b-43ff-8eb8-6c87b13d47f9。 | 
| ARM_CLIENT_SECRET | 这是我们运行添加服务主体命令时输出的密码。从示例输出来看,它将是 6t3Rq~vT.cL9y7zN_apCvGANvAg7_v6wiBb1eboQ。 | 
| ARM_SUBSCRIPTION_ID | 这是你用作作用域来添加服务主体的订阅 ID。从示例输出来看,它将是 3a52ef17-7e42-4f89-9a43-9a23c517cf1a。 | 
| ARM_TENANT_ID | 这是我们运行添加服务主体命令时输出的租户。从示例输出来看,它将是 8a7e32c4-5732-4e57-8d8c-dfca4b1e4d4a。 | 
一旦你输入了前面表格中详细列出的四个密钥,你将使用这些凭证来验证你的 Azure 账户并进行更改。现在我们可以输入变量;这些变量详细描述了存储账户,并且不需要作为密钥存储:
| 名称 | 值内容 | 
|---|---|
| BACKEND_AZURE_RESOURCE_GROUP_NAME | 这是将创建的资源组的名称,用于托管我们将用于 Terraform 状态文件的存储账户,例如: rg-terraform-state-uks。 | 
| BACKEND_AZURE_LOCATION | 资源将要启动的区域。例如, uksouth。 | 
| BACKEND_AZURE_STORAGE_ACCOUNT | 你创建的存储账户名称必须在所有 Azure 中是唯一的;否则你将得到一个错误。例如, satfstate180323。 | 
| BACKEND_AZURE_CONTAINER_NAME | 文件将存储的容器名称,例如 tfstate。 | 
| BACKEND_AZURE_STATE_FILE_NAME | Terraform 状态文件本身的名称,例如 ghact.tfstate。 | 
现在,既然我们已经在 GitHub 仓库中准备好了所有需要的机密和变量,我们可以查看工作流本身。
GitHub Action 工作流是 YAML Ain’t Markup Language 或 Yet Another Markup Language(YAML)文件(具体取决于你读到的解释)。
信息
YAML 是一种易于人类阅读的数据序列化格式,使用缩进来表达结构,广泛应用于配置文件、数据交换以及需要简单数据表示的应用程序。
首先,我们有一些基本配置;在这里,我们使用 name 来命名工作流,并定义 on 来指定工作流应该在什么操作下触发:
name: "Terraform Plan/Apply"
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
如你所见,工作流将根据 YAML 文件中的 on 部分定义,在对 main 分支执行 push 或 pull_request 时触发。现在我们已经定义了工作流的触发时机,接下来我们可以定义构成工作流的三个任务,从检查我们将用于后端 Terraform 状态文件的存储帐户是否存在开始——如果不存在,则创建一个。
请注意
缩进在 YAML 文件的结构中非常重要;然而,在接下来的页面中,我们会去除一些缩进,以提高可读性——请参考附带本书的 GitHub 仓库中的代码,以查看正确的格式和缩进。
首先,我们定义任务和一些基本配置:
jobs:
  check_storage_account:
    name: "Check for Azure storage account"
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
在这里,我们为任务提供了一个内部引用 check_storage_account,并告诉它在最新版本的 Ubuntu 上运行,并使用 bash 作为默认的 shell。
check_storage_account 任务由两个步骤组成,第一步如下:
steps:
    - name: Login to Azure using a service principal
      uses: Azure/login@v1
      with:
        creds: '{"clientId":"${{ secrets.ARM_CLIENT_ID }}","clientSecret":"${{ secrets.ARM_CLIENT_SECRET }}","subscriptionId":"${{ secrets.ARM_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.ARM_TENANT_ID }}"}'
在这里,我们使用 Azure/login@v1 任务通过 GitHub 中定义的机密登录到 Azure 帐户。这些机密通过 ${{ secrets.ARM_CLIENT_ID }} 引用。下一步使用我们在 GitHub 中添加的变量以及 Azure/CLI@v1 任务来检查存储帐户是否存在。
如果它不存在,将会被创建;如果资源已存在,则任务将继续执行到下一步:
- name: Create Azure storage account
  uses: Azure/CLI@v1
  with:
    inlineScript: |
      az group create --name ${{ vars.BACKEND_AZURE_RESOURCE_GROUP_NAME }} --location ${{ vars.BACKEND_AZURE_LOCATION }}
      az storage account create --name ${{ vars.BACKEND_AZURE_STORAGE_ACCOUNT }} --resource-group ${{ vars.BACKEND_AZURE_RESOURCE_GROUP_NAME }} --location ${{ vars.BACKEND_AZURE_LOCATION }} --sku Standard_LRS
      az storage container create --name ${{ vars.BACKEND_AZURE_CONTAINER_NAME }} --account-name ${{ vars.BACKEND_AZURE_STORAGE_ACCOUNT }}
由于此步骤在与第一步相同的 Ubuntu 实例上运行,并且该实例已经登录到 Azure,我们无需再次进行身份验证——相反,我们只需运行所需的 Azure CLI 命令:
- 
使用 azgroup create创建或检查存储帐户所在的资源组是否存在。
- 
使用 az storageaccount create创建或检查存储帐户是否存在。
- 
使用 az storagecontainer create创建或检查存储帐户中的容器是否存在。
现在我们知道已经为 Terraform 后端状态文件存储设置好了存储账户,我们可以继续执行下一个任务,运行 terraform_plan 命令并将输出存储在工作流运行中。
跟上一个任务一样,我们需要设置一些基本配置,例如任务名称和引用、使用的操作系统以及其他一些附加项:
terraform_plan:
  name: "Terraform Plan"
  needs: check_storage_account
  runs-on: ubuntu-latest
  env:
    ARM_CLIENT_ID: "${{ secrets.ARM_CLIENT_ID }}"
    ARM_CLIENT_SECRET: "${{ secrets.ARM_CLIENT_SECRET }}"
    ARM_SUBSCRIPTION_ID: "${{ secrets.ARM_SUBSCRIPTION_ID }}"
    ARM_TENANT_ID: "${{ secrets.ARM_TENANT_ID }}"
  defaults:
    run:
      shell: bash
如你所见,我们设置了一些包含用于登录 Azure 凭据的环境变量;既然我们在上一个任务中已经完成了认证,为什么还要再做一次?
原因是,一旦上一个任务结束,运行该任务的计算资源就被终止了,而当此任务开始时,又启动了一个新的资源,这意味着上一个任务的所有数据都丢失了。
现在我们已经定义了 terraform_plan 任务的基本内容,我们可以逐步完成操作:
steps:
  - name: Checkout the code
    id: checkout
    uses: actions/checkout@v3
这个简单的步骤检查出我们正在运行动作的仓库;该仓库包含了我们将在工作流中执行的 Terraform。
现在我们拥有了安装 Terraform 所需的代码。为此,我们使用 hashicorp/setup-terraform@v2 任务:
- name: Setup Terraform
  id: setup
  uses: hashicorp/setup-terraform@v2
  with:
    terraform_wrapper: false
到目前为止,一切顺利;就像我们在本地机器上运行 Terraform 一样,现在我们需要运行 terraform init 命令:
- name: Terraform Init
  id: init
  run: terraform init -backend-config="resource_group_name=${{ vars.BACKEND_AZURE_RESOURCE_GROUP_NAME }}" -backend-config="storage_account_name=${{ vars.BACKEND_AZURE_STORAGE_ACCOUNT }}" -backend-config="container_name=${{ vars.BACKEND_AZURE_CONTAINER_NAME }}" -backend-config="key=${{ vars.BACKEND_AZURE_STATE_FILE_NAME }}"
如你所见,我们在 terraform init 命令的末尾添加了不少内容——这会根据我们在 GitHub 中定义的变量设置后端,意味着 Terraform 将使用远程后端,而非本地后端。
接下来,我们需要运行 terraform plan 命令,以确定在工作流执行过程中需要发生的操作:
- name: Terraform Plan
  id: tf-plan
  run: |
    export exitcode=0
    terraform plan -detailed-exitcode -no-color -out tfplan || export exitcode=$?
    echo "exitcode=$exitcode" >> $GITHUB_OUTPUT
你会注意到,我们在命令周围加入了一些逻辑,用来判断退出代码。我们需要这样做,因为我们需要知道是否在出现错误时停止工作流的执行,这也是步骤中代码最后一部分的作用:
    if [ $exitcode -eq 1 ]; then
      echo Terraform Plan Failed!
      exit 1
    else
      exit 0
    fi
现在我们知道是否存在明显的错误,或者一切正常,并且我们已经拥有了 Terraform 计划文件的副本;接下来是什么?
如我们之前提到的,当我们运行下一个任务时,我们将从头开始,并且由于需要一个 Terraform 计划文件的副本,因此我们应当从计算资源中复制它:
- name: Publish Terraform Plan
  uses: actions/upload-artifact@v3
  with:
    name: tfplan
    path: tfplan
我们使用 actions/upload-artifact@v3 任务将名为 tfplan 的文件复制到工作流执行中作为工件;在后续的任务和工作中,我们可以下载该文件并使用它,而无需将其提交到代码库中。
下一任务乍一看可能显得有些多余:
- name: Create String Output
  id: tf-plan-string
  run: |
    TERRAFORM_PLAN=$(terraform show -no-color tfplan)
    delimiter="$(openssl rand -hex 8)"
    echo "summary<<${delimiter}" >> $GITHUB_OUTPUT
    echo "## Terraform Plan Output" >> $GITHUB_OUTPUT
    echo "<details><summary>Click to expand</summary>" >> $GITHUB_OUTPUT
    echo "" >> $GITHUB_OUTPUT
    echo '```terraform' >> $GITHUB_OUTPUT
echo "$TERRAFORM_PLAN" >> $GITHUB_OUTPUT
echo '```' >> $GITHUB_OUTPUT
    echo "</details>" >> $GITHUB_OUTPUT
    echo "${delimiter}" >> $GITHUB_OUTPUT
这个任务似乎在处理 Terraform 计划文件,那么它究竟在做什么呢?
使用 GitHub Actions 等系统的优势之一是你可以发布工件以及其他输出——在这种情况下,我们从计划文件中提取了变更列表,并将其格式化为工作流摘要。
此任务中的下一个也是最后一个任务是将我们刚刚生成的摘要发布回 GitHub:
- name: Publish Terraform Plan to Task Summary
  env:
    SUMMARY: ${{ steps.tf-plan-string.outputs.summary }}
  run: |
    echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY
现在,我们剩下的就是运行terraform apply命令——这是我们工作流的最后一个任务,它与前一个任务共享许多步骤。
然而,我们需要强调一些配置更改:
terraform-apply:
  name: "Terraform Apply"
  if: github.ref == 'refs/heads/main'
  runs-on: ubuntu-latest
  env:
    ARM_CLIENT_ID: "${{ secrets.ARM_CLIENT_ID }}"
    ARM_CLIENT_SECRET: "${{ secrets.ARM_CLIENT_SECRET }}"
    ARM_SUBSCRIPTION_ID: "${{ secrets.ARM_SUBSCRIPTION_ID }}"
    ARM_TENANT_ID: "${{ secrets.ARM_TENANT_ID }}"
  needs: [terraform_plan]
如您所见,我们已经添加了if和needs语句。if语句验证我们是否 100% 使用正确的分支,而needs语句确保terraform_plan任务已成功执行,这意味着我们将会有 Terraform 计划文件。
前三步是我们已经涵盖过的,具体如下:
- 
查看代码 
- 
设置 Terraform 
- 
terraform init
接下来,我们需要下载 Terraform 计划文件:
- name: Download Terraform Plan
  uses: actions/download-artifact@v3
  with:
    name: tfplan
下载计划文件后,我们现在可以执行工作流的最后一个任务,即运行terraform apply命令并部署计划文件中详细的更改(如果有的话)。
考虑到我们已经完成的任务数量,最后的任务非常简单:
- name: Terraform Apply
  run: terraform apply -auto-approve tfplan
如您所见,我们以-auto-approve标志运行terraform apply;如果不这样做,Terraform 会愉快地在那里等待一个小时,等待有人输入Yes,但这永远不会发生,因为这不是交互式终端。
然后我们告诉它加载名为tfplan的文件,这意味着我们无需再次运行terraform plan命令,因为我们已经知道在执行过程中会有哪些变化/应用。
那么,为了使其工作,我们的 Terraform 代码需要进行哪些更改?
就是我们需要调整代码以使用azurerm后端;这使得我们main.tf文件的顶部如下所示:
terraform {
  required_version = ">=1.0"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~>3.0"
    }
  }
  backend "azurerm" {}
}
其余代码保持不变。然后我们需要创建一个工作流 YAML 文件,并将其放置在仓库顶级的.github/workflows文件夹中。我将文件命名为action.yml。
请注意
在随本书附带的仓库中,文件夹名称故意移除了“.”,因此 GitHub Action 无法注册。当你将其复制到自己的仓库时,请确保将github文件夹重命名为.github;否则,Action 将无法注册,工作流将无法运行。
因此,第一次检查action.yml文件时让我们运行它。它将创建该 Action 并运行——可以通过提交 ID 旁边的点来确认这一点,在以下示例屏幕中,这个点被标记为b4900e8:

图 7.2 – 检查并运行工作流
如果一切按预期运行,点击仓库页面顶部的Actions标签应该会显示类似以下内容:

图 7.3 – 查看工作流运行情况
点击工作流运行将带你到执行概览页面;对我来说,页面看起来如下所示:

图 7.4 – 查看工作流执行情况
如你所见,我们列出了三个作业,以及从terraform plan命令发布的工件和自定义概览:

图 7.5 – 查看 Terraform Plan 的输出
此外,如果你点击任何一个作业名称,它会显示每个任务的输出:

图 7.6 – 查看 Terraform Plan 的输出
我建议点击四周,仔细查看 GitHub action 工作流执行了什么,因为它提供了相当详细的信息。
最后,如果你查看 Azure 门户,应该可以看到资源组、存储帐户和容器,其中应该有一个名为ghact.tfstate的文件:

图 7.7 – 在 Azure 门户中查看 Terraform 状态文件
这就结束了使用 GitHub actions 运行 Terraform;在我们结束这一章之前,让我们来看一个运行 Ansible 的工作流。
使用 GitHub Actions 运行 Ansible
Ansible 没有状态文件的概念,因此这将简化我们的 GitHub action 工作流。由于我们再次使用 Microsoft Azure,你必须在 GitHub 仓库中设置ARM_CLIENT_ID、ARM_CLIENT_SECRET、ARM_SUBSCRIPTION_ID 和ARM_TENANT_ID密钥,正如我们在上一节中所做的那样。
一旦它们在那里,我们就可以继续进行工作流本身的操作;与 Terraform 工作流一样,我们从设置一些基本配置开始:
name: "Ansible Playbook Run"
on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
然后我们定义作业;没错,这个工作流只有一个作业:
jobs:
  run_ansible_playbook:
    name: "Run Ansible Playbook"
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash
到目前为止,差别不大,我们继续进行下一步。首先,我们检出代码:
steps:
  - name: Checkout the code
    id: checkout
    uses: actions/checkout@v3
在这里我们遇到了第一个差异;由于 Ansible 是用 Python 编写的,我们需要确保 Python 已安装并且版本较为现代。为此,我们将使用actions/setup-python@v4任务:
- name: Ensure that Python 3.10 is installed
  uses: actions/setup-python@v4
  with:
    python-version: "3.10"
下一步是登录到 Azure,这与本章上一节 Terraform 工作流中的使用服务主体登录到 Azure步骤完全相同,因此我不再重复代码。
接下来,我们需要安装 Ansible 本身——我们使用pip命令进行安装;这一步看起来像如下所示:
- name: Install Ansible
  run: pip install ansible
一旦安装了 Ansible,我们就可以运行ansible-galaxy命令来安装 Azure Collection——这一步与我们本地安装时并没有太大区别:
- name: Install Azure Collection
  run: ansible-galaxy collection install azure.azcollection
如你所猜测的,一旦安装了 Azure Collection,我们需要安装该集合运行所需的 Python 模块:
- name: Install Azure Requirements
  run: pip install -r ~/.ansible/collections/ansible_collections/azure/azcollection/requirements-azure.txt
一旦我们安装了运行 Playbook 所需的一切,我们就可以运行任务;这一步看起来像是 Terraform 工作流中的 创建字符串输出 步骤,因为我们要捕获 ansible-playbook 命令的输出并将其存储在工作流总结中:
- name: Run the playbook (with ansible-playbook)
  id: ansible-playbook-run
  run: |
    ANSIBLE_OUTPUT=$(ansible-playbook site.yml)
    delimiter="$(openssl rand -hex 8)"
    echo "summary<<${delimiter}" >> $GITHUB_OUTPUT
    echo "## Ansible Playbook Output" >> $GITHUB_OUTPUT
    echo "<details><summary>Click to expand</summary>" >> $GITHUB_OUTPUT
    echo "" >> $GITHUB_OUTPUT
    echo '```' >> $GITHUB_OUTPUT
echo "$ANSIBLE_OUTPUT" >> $GITHUB_OUTPUT
echo '```' >> $GITHUB_OUTPUT
    echo "</details>" >> $GITHUB_OUTPUT
    echo "${delimiter}" >> $GITHUB_OUTPUT
工作流的最后一步是发布总结:
- name: Publish Ansible Playbook run to Task Summary
  env:
    SUMMARY: ${{ steps.ansible-playbook-run.outputs.summary }}
  run: |
    echo "$SUMMARY" >> $GITHUB_STEP_SUMMARY
就是这样;正如你所看到的,工作流的作业和步骤较少,因为我们不需要像 Terraform 工作流中那样考虑后端存储或将发布计划文件作为工件进行发布。
运行工作流时,应该会得到如下输出:

图 7.8 – 使用 GitHub Actions 运行 Ansible Playbook
再次提醒,本标题所附的仓库文件夹名称故意将文件夹名称开头的 . 字符去掉,以便 GitHub Action 不会注册。如果你在自己的仓库中跟着操作,根据 Terraform GitHub Action 工作流,在提交到仓库时,必须将该文件夹重命名为 .github,以便注册该 Action。
既然我们已经使用 Terraform 和 Ansible 运行了工作流,让我们快速讨论一些最佳实践。
安全最佳实践
当我们讨论 Terraform 和 Ansible 工作流时,我们讨论了如何将仓库的机密添加到 GitHub 仓库中。所有敏感信息应存储在机密中,而不是使用外部来源(如 Azure Key Vault、AWS Secrets Manager 或 HashiCorp Vault)来存储你的机密信息。
这样做的好处是,机密信息会保持隐藏,但代码仍然能够使用它们。你可能会想,这样很好。
但是,任何被授予仓库写权限的人也能够使用这些机密(尽管不能查看内容),所以在授予对 IaC CI/CD 流水线的访问权限时要小心,因为他们将通过工作流拥有对你的云资源的高度访问权限,因此请确保只授予可信团队成员访问权限。
抢答题
在本章结束之前,让我们进行一个快速的抢答题:
- 
编写 YAML 时,必须注意哪些事项? 
- 
关于凭据,你绝对不应该做什么? 
- 
GitHub Action 应该存储在哪个文件夹中? 
总结
尽管我们在本章初期花了很多时间讨论 Terraform 的工作原理,但一旦我们开始使用 GitHub Actions 工作流,我相信你已经开始意识到从一个中心位置而非本地机器运行我们的 IaC 所带来的好处。
一旦我们讨论了 Terraform 的要求,我们在 GitHub 中配置了仓库的机密和变量。接着我们处理了各种作业和步骤,组成了管理存储账户的工作流,在该存储账户中存储了 Terraform 状态,并执行了 Terraform 部署。
我们随后将所有在 Terraform 中学到的内容应用到 Ansible 中,最后讨论了一个重要的安全问题——小心赋予你的 IaC GitHub Actions 过多的访问权限!
有些内容我们需要更多时间来讲解,比如监控;例如,将 GitHub Actions 集成到消息服务(如 Microsoft Teams 或 Slack)中,以便获得工作流运行的实时反馈是相对简单的——如果你想尝试将工作流连接到你喜欢的消息服务,可以查看进一步阅读部分中关于 Microsoft Teams 和 Slack 的 GitHub Actions 市场链接。
这不仅是将 IaC 部署扩展到其他团队成员的好方法,而且它还能作为一个系统,跟踪工作流运行时的变化,记录每次执行的摘要,并在一段时间内存储这些数据。
在接下来的章节和倒数第二个章节中,我们将探讨一些常见的故障排除技巧和窍门。
进一步阅读
你可以在以下网址找到我们在本章节中使用的任务的更多详细信息:
答案
以下是关于突击测试的答案:
- 
编写 YAML 时,必须特别注意什么?缩进!你的 YAML 文件结构至关重要——即使一个字符错误,也会导致错误。 
- 
当涉及到凭证时,你绝对不应该做什么?将其嵌入到代码中!你需要使用外部的密钥管理系统。 
- 
GitHub Actions 应该存储在哪个文件夹中?你的 YAML 文件应该存储在 .github/workflows文件夹中。
第八章:常见故障排除技巧和最佳实践
迄今为止,我们主要讨论了已经编写好的代码示例,这些代码示例已通过本书附带的 GitHub 仓库共享。随着您在基础设施即代码(IaC)的旅程中不断进步,必须理解编写和规划您的 IaC 项目将涉及学习曲线,并且需要进行一些不可避免的调试。
在本章——倒数第二章中,我们将探讨一些关键方面,帮助您更好地规划、编写和调试您的 IaC 项目。
我们将涵盖三个关键领域,以确保您能充分准备好应对过程中的任何挑战:
- 
基础设施即代码 – 最佳实践和故障排除 
- 
Terraform – 最佳实践和故障排除 
- 
Ansible – 最佳实践和故障排除 
在本章节中,您会注意到一些适用于 Terraform 和 Ansible 的共同主题和建议,因为它们都是 IaC 工具。然而,每个工具与您的资源的交互方式各不相同,这导致在故障排除时采取的方式和技术有所不同。
到本章结束时,您将为使用这些强大工具实施 IaC 项目中的挑战做好充分准备。
技术要求
本章节的源代码可以在这里获取:
github.com/PacktPublishing/Infrastructure-as-Code-for-Beginners/tree/main/Chapter08
基础设施即代码 – 最佳实践和故障排除
让我们先讨论一些可以应用于各种工具和平台的一般 IaC 最佳实践。
一般的 IaC 最佳实践
有些常见的主题我们已经提到过,但它们非常重要,值得再次强调:
- 版本控制:确保使用版本控制系统,如 Git,或其他可用的系统,如 Mercurial、Subversion 或 Azure DevOps Server(以前称为团队基础服务器(TFS)等较为常见的工具)来存储和管理您的基础设施代码。
如果您正在采取步骤来定义并以代码形式部署基础设施,那么您个人或在公司内部已经在其他项目中使用版本控制的可能性极高。这意味着您已经具备版本控制的经验,并且能够访问使用版本控制维护代码所需的工具、流程和程序。
使用版本控制能够促进协作、跟踪变更,并在需要时轻松回滚到之前的版本。
- 文档:您可以通过多种方式来处理文档,重要的是无论采取哪种方式,只要完成文档编写就好!
我个人的 IaC 部署文档记录方法是尽量将文档内容保留在代码中,使用注释并确保各个部分、任务、功能或变量的命名尽可能清晰且具有描述性,同时遵守我所使用工具的任何约束。
同时,根据复杂性,我将总结代码的功能,将其附加为README文件,并提交到版本控制系统中。
我这么做的原因是,虽然在项目进行时很容易跟踪正在发生的事情,但当其他人接手项目时——或者即使你自己在离开项目几个月后重新回顾时——有时他们需要一点时间来适应。
你的做法可能有所不同,这也自然引出了下一个最佳实践。
- 代码审查:我建议定期进行代码审查,以保持代码质量,确保遵守最佳实践,并在团队成员之间共享知识。
你可能已经有了强制执行这一过程的机制,适用于业务中的其他类型开发,例如你的应用程序。同样,确保相同的原则适用于你的 IaC 项目也同样重要,因为你可能需要证明你的代码遵循了应用程序必须遵守的任何合规性指南。毕竟,你的 IaC 项目将会部署并维护你的应用程序运行所需的资源。
- 
模块化:编写基础设施代码时,将其拆分为较小的、可重用的模块。这样有利于代码的重用性、可维护性以及更好的代码组织。我们在第六章《在基础上构建》中已经讨论过这一点。 
- 
持续集成与持续部署(CI/CD):我们在前一章第七章《在云中利用 CI/CD》中详细讨论了这一点。即使是为了开发目的,如果你的代码在源代码管理中,理想情况下你也应该利用 CI/CD。 
- 
测试:在理想的世界里,你应该为你的基础设施代码实现自动化测试,以验证其正确性、及早发现问题并提高整体可靠性。如果你正在使用版本控制和 CI/CD,你已经拥有了大部分工具,使这一过程变得简单。例如,在第七章《在云中利用 CI/CD》中,我们在运行 Terraform 计划时遇到了一些断点,以便捕捉潜在问题。 
- 
监控与日志记录:实施监控和日志记录解决方案,以跟踪执行情况并检测问题,从而使你能够及时排除故障。在第七章《在云中利用 CI/CD》中,我们的 CI/CD 管道记录了执行过程中发生的所有事件。在 Terraform 的案例中,我们生成并附加了计划文件的快照——这种信息层级在试图理解意外情况时非常有价值。 
- 
最小权限原则:通过为你的基础设施代码执行操作时,只授予与它们所需组件交互所必需的最小权限,来限制对资源和创建的访问。 
根据你的目标基础设施,这种方法并非总是可行,但大多数云服务提供商允许你在权限方面做到非常精细。另外,具体的部署操作可能需要一些反复试验,但从长远来看,值得投入时间从安全角度去审视。
- 不可变基础设施:与其更新现有基础设施,不如创建新的基础设施来替代旧的,并将请求路由到新基础设施。这可以减少由于配置漂移而引发的错误风险,并强制执行更可预测的部署。
这个方法取决于你的应用程序,可能并不总是实际可行的,但你能够使更多的基础设施组件不可变时,扩展和收缩将变得更加容易。
- 从设计开始即保障安全(SBD):在编写基础设施代码时,从一开始就将安全最佳实践和工具结合其中,例如加密、身份管理和网络分段等(如果可能的话),并且如前所述,尽量使代码的这些部分模块化,以便在多个项目中轻松复用。
既然我们已经建立了一些通用的最佳实践,让我们继续讨论一些常见的故障排除技巧。
一般的 IaC 故障排除技巧
以下是一些通用的故障排除技巧、窍门和方法。因为我们谈论的是通用的 IaC 技巧,许多技巧更多的是预防性措施,而不是用来调试问题的步骤:
- 避免硬编码敏感信息:使用如 Azure Key Vault、HashiCorp Vault 或 AWS Secrets Manager 等秘密管理工具,在运行时安全地存储和检索敏感信息,或者使用你的基础设施代码配置资源,直接利用秘密管理工具。
虽然不言而喻,你不应将敏感信息如密码、私人信息或密钥直接硬编码到代码中(Ansible 可能是个例外,但关于这一点会在 Ansible – 最佳实践与故障排除 部分中提及),使用秘密管理工具有其优势——其中最大的一点是用于证书管理等任务。
假设你的 SSL 证书即将到期,距离到期还有一两天,你正在赶紧更新所有引用它的资源。如果你使用目标平台的密钥存储,可能只需要更新证书,然后所有使用该证书的资源会自动更新。
- 保持依赖项更新:在第四章《部署到 Microsoft Azure》和第五章《部署到 Amazon Web Services》中,你会注意到我们的基础设施代码使用了许多不同的任务和模块。
定期更新依赖项有助于避免安全漏洞和兼容性问题。随着目标云 API 的更新,你可能会发现你的代码出现问题或不再工作。
- 不要过度复杂化你的基础设施代码:保持基础设施代码尽可能简单,避免引入不必要的复杂性,这些复杂性可能会在出现问题时难以维护和排查。
让你的基础设施代码里充满大量逻辑或循环看起来可能很“酷”,但只需要工具或依赖项的一个小变动,它就会崩溃——编写的代码越复杂,遇到问题时,你所需要花费的调试和重构的时间和精力就越多。
相信我,凭经验,你未来的自己会感谢你这样做的。
- 保持代码库的整洁和良好的组织结构:始终使用命名约定,遵循目录结构,并删除过时的代码。
团队中的任何人都应该能够接手你的代码,并了解发生了什么,即使他们之前没有看到过它;你不可能永远是唯一一个处理代码问题的人。
你要避免为接手你工作的人增加更多负担,因为他们可能已经因为有人报告了问题而承受压力。
- 不要忽视错误或警告信息:及时处理任何信息,尤其是基础设施代码中的非致命警告信息,以防止未来出现问题。
大多数工具在出现错误时会停止执行。然而,大多数工具也会打印警告——这些可能只是一些小问题,例如提醒你正在使用的功能将在未来的版本中弃用或发生变化,而警告不会停止执行。但这些警告仍然需要解决,就像你收到的任何错误一样;并不是每天都有机会避免未来的错误,所以抓住这个机会。
最后,不言而喻,与团队沟通。定期与团队沟通基础设施的变化、潜在问题和最佳实践,确保每个人在处理 IaC 时都在同一页面上。你不想成为单点故障,也不希望在出现问题时让团队陷入困境。
现在我们已经了解了通用的最佳实践和故障排除技巧,让我们来看看在使用本书中讨论的两种工具时,你需要考虑的一些事项,从 Terraform 开始。
Terraform – 最佳实践和故障排除
我们将覆盖在通用 IaC 最佳实践部分中已经提到的一些建议。但正如本章开头所述,我们将更详细地探讨这些建议如何应用于 Terraform。
Terraform – 最佳实践
以下是处理 Terraform 部署时的一些最佳实践:
- 采用模块化方法:将基础设施拆分为可重用的模块,从而简化代码维护并支持在不同环境中的重用。
正如我们在第六章中讨论的,在基础上构建,Terraform 模块可以托管在 Terraform 注册表中,或者(我没有提到过)私下托管在你自己的 Git 仓库中。以下示例代码通过安全外壳 (SSH)从 GitHub 下载模块:
module "somefunction" {
  source = "git@github.com:someuser/tfmodule.git"
}
假设你正在从可以访问仓库的地方执行 Terraform 代码,它将下载并使用该代码。
这使得你可以构建一个可重用模块的库,供所有项目使用,同时也可以与其他团队共享这些模块。
- 保持一致的命名约定:为资源和模块使用一致的命名约定可以提高可读性和可维护性。
根据团队规模,你应该为开发和维护 Terraform 基础设施代码制定样式指南和准则。
- 安全管理状态文件:我们已经讨论了将状态文件远程存储在后端,如 Azure 存储账户或 AWS 简单存储服务 (S3)中,在第七章中,利用 CI/CD 在 云中的实践。
大多数支持的后端服务允许你启用版本控制和强制加密,以确保数据的完整性和安全性——确保启用了这些功能。大多数服务默认启用,但最好再次确认。
另外,还有一个应该提到的服务:Terraform Cloud。Terraform 的开发者 HashiCorp 提供了一项云服务,可以安全地存储你的状态文件,并且作为 Terraform 运行的远程执行环境。该服务提供免费和付费选项,如果可以使用,我建议你了解一下。
- 
使用 terraform plan命令来可视化代码运行的潜在影响。使用代码审查和自动化测试来验证更改,最小化错误或意外情况的风险。
- 
使用提供者和资源版本固定:尽管 Terraform 的开发速度因提供者而异,但你可能会发现会引入破坏性更改。 
您应该在基础设施代码中锁定提供程序的版本,并在注册所用的提供程序时定义明确的版本号,以确保基础设施的一致性和稳定性。
- lookup、- count和- for_each用来减少复杂性并提高灵活性。我们在第四章《部署到微软 Azure》和第五章《部署到亚马逊 Web 服务》中也讨论过一些函数,您将计算无类域间路由(CIDR)范围,并对输入和输出变量进行转换—这一切都有助于减少您需要定义的变量数量。
Terraform – 故障排除
以下是一些最佳实践,用于部署您的 Terraform:
- 
避免硬编码敏感信息:正如您可能已经猜到的,这是一个常见但重要的反复主题;请不要这样做! 相反,使用 Terraform,您可以使用环境变量或密钥管理工具,避免在代码中暴露敏感数据。 
- 
必要时使用 depends_on参数,以避免资源顺序相关的问题。
我们在第二章《Ansible 和 Terraform 超越文档》中,在《修复错误》部分讨论过这个问题。
- 使用 prevent_destroy并确保正确的访问控制。
以下是如何使用 prevent_destroy 来防止意外删除 Azure 存储账户的示例:
resource "azurerm_storage_account" "example" {
  name                     = "saiacforbeg2022111534"
  resource_group_name      = azurerm_resource_group.example.name
  location                 = azurerm_resource_group.example.location
  account_tier             = "Standard"
  account_replication_type = "GRS"
  lifecycle {
   prevent_destroy = true
 }
}
如果您尝试对资源运行 terraform destroy,系统会报错,这比意外删除资源要好得多。
请注意,这并不是云提供商级别的资源锁定;您只是指示 Terraform 在执行时不能销毁该资源。
- 监控资源限制:了解提供商特定的限制和配额,如果达到这些限制,可能会导致资源配置失败。
因为限额或配额问题在资源配置时出现的错误,可能导致状态文件损坏,这取决于您要操作的资源,恢复起来可能并不容易。
- 
terraform refresh和terraform plan。您可以通过 CI/CD 完成这项工作,并根据输出进行报警。
- 
注意状态文件冲突:如果多个团队成员在同一基础设施上工作,使用具有锁定机制的远程状态后端来防止冲突更改。大多数后端默认支持此功能,但为了避免生产资源的状态文件损坏,我建议您进行三重检查。 
- 
如果可能,避免使用多个配置工具:将 Terraform 与其他配置工具(例如 CloudFormation 或Azure 资源管理器(ARM)模板)混合使用,可能会导致冲突并在后续执行时出现意外行为。为了保持一致性和可预测性,最好坚持使用一个配置工具,并且如果可能,尝试为你需要使用多个工具进行部署的原因找到一种变通方法。这与我们在第六章《在基础之上构建》中讨论的情况略有不同,在那一章中我们使用 Ansible 来触发 Terraform;而这里是使用 Terraform 来运行其他基础设施即代码工具——这是一些提供商所支持的。 
由于该功能内置于每个提供商中,并且每个提供商都是与核心 Terraform 开发分离的独立项目,因此你可能会发现不同提供商之间的功能差异非常大。
如果你不得不走这条路,请查阅你所使用提供商的文档,并在必要时检查其 GitHub 仓库中记录的问题,看看是否有功能相关的问题被报告。
正如你在本节中看到的,大部分建议与我们在本章开头讨论的通用建议相似。让我们看看这种趋势是否会在 Ansible 中继续存在。
Ansible – 最佳实践与故障排除
在本章的这一部分,你已经知道了流程:我们将从讨论最佳实践开始,但这次我们将加入 Ansible 的元素。
Ansible – 最佳实践
以下是处理你的 Ansible Playbooks 时的一些最佳实践:
- 使用角色组织 Playbook:使用角色将相关任务、变量、文件和模板进行分组,使你的 Playbook 更易于理解和维护。
欲了解更多信息,请参见第六章《在基础之上构建》,我们在那一章中更详细地讨论了角色和 Ansible Galaxy——这也引出了我们接下来的建议。
- 保持 Playbook 模块化和可重用:编写模块化的 Playbook 和任务,可以在不同的场景中重用,以最小化重复工作并提高可维护性。
这里我们与 Terraform 有所不同,因为 Ansible 也可以用于访问 Linux 或 Windows 主机并在其上执行命令,因此,用于日常任务(如安装 Apache、启用Internet 信息服务(IIS)或仅仅是修补你目标操作系统的代码)的可重用代码将非常有用。
- 
使用版本控制:将你的 Ansible Playbook 和配置保存在版本控制系统中(如 Git),以便跟踪更改并促进/支持团队成员之间的协作。 
- 
采用一致的命名规范:为任务、文件、模板,特别是变量,采用清晰一致的命名规范,使其他团队成员更容易理解和跟进你的 Playbook。 
- 
使用动态库存:这是我们到目前为止还没有涉及的内容,但当 Ansible 目标指向主机的操作系统时,它会使用库存文件,这是一个列出要交互的主机的清单。 
不需要在库存文件中硬编码主机详情,你可以使用动态库存脚本自动发现并管理环境中的资源。大多数提供商都有脚本,通常是通过标签来发现需要定位的资源。
假设你的 Ansible playbook 在你选择的云平台上启动了半打虚拟机。如果你将它们标记为 Role:Web,那么你可以使用动态库存脚本在云提供商中搜索所有标记为 Role 为 Web 的虚拟机,并建立一个包含 IP 地址的清单,以便运行 playbook。
- 实现幂等性:确保你的任务是幂等的,这意味着它们可以多次执行而不会产生意外结果或副作用。
如果你的 Ansible playbook 仅处理基础设施代码,那么这应该是直接的,因为大部分逻辑是由 API 处理的,模块将与这些 API 交互。
然而,如果你要操作操作系统,这一点变得重要,因为你需要避免在多个主机之间发生任何意外情况。
- 使用 Ansible Vault 保护敏感数据:我将这一点留到最后。Ansible 具有内建的秘密管理系统,叫做 Ansible Vault,它允许你加密敏感数据,如密码和 API 密钥,以保护它们免受未经授权的访问。
除了像 ansible-playbook 和 ansible-galaxy 这样的命令外,Ansible 还附带了 ansible-vault。
该命令可以加密和解密整个文件以及简单的字符串。在以下示例中,我们将演示如何加密一个字符串:
$ ansible-vault encrypt_string --vault-id @prompt HelloWorld
运行命令时会提示输入新密码并确认密码。输入后,它将加密指定的文本,这里是超级机密的 HelloWorld,并给出如下内容:
New vault password (default):
Confirm new vault password (default):
Encryption successful
!vault |
$ANSIBLE_VAULT;1.1;AES256
35373665396163313561373336306261346264323638616664383766316464643964353266656632
6365373333383734376137656339623165663537633965630a396133336536353036346133393437
37383534653362306438653034383266383132393966383063666330313964396338326462373532
3332653564633839390a636538626261393630323733643135643339303333346638353039396439
3736
现在你有了加密的字符串,你可以在 playbook 文件中使用它,如以下示例所示(请注意,空格已被移除,以便更易阅读):
---
- name: Ansible Vault Example
  hosts: localhost
  gather_facts: false
  vars:
    some_secret: !vault |
          $ANSIBLE_VAULT;1.1;AES256
64346261636562356365326638303365316335343031666439616236663336316361336466353461
6239333166326634636337333133303939306465373130390a626563373834326163313133313039
37353763613539363837636237343631393365323763393235626334323561373434303531653831
3464393938653831370a666565626265666432353039363334623562613363626532623666333565
3062
  tasks:
    - name: print the secure variable
      debug:
        var: some_secret
该标题的 GitHub 仓库中有一份前面代码的副本。要运行 playbook,我们需要稍微调整一下 ansible-playbook 命令:
$ ansible-playbook --vault-id @prompt site.yml
假设你输入了正确的 Vault 密码,应该会得到如下输出:

图 8.1 – 运行 playbook 并查看秘密
如果你承诺不告诉任何人,那么 playbook 的密码是 password,因此你可以自行运行仓库中的 playbook。
Ansible Vault 还可以加密整个文件,这意味着你可以包含如私钥这样的文件,使用base64对二进制文件进行编码为文本,然后使用 Vault 加密编码后的内容,因为 Ansible 有内置函数用于解码base64。
那么,这比使用秘密管理工具好在哪里呢?好吧,它可能更简单——你可以使用秘密管理工具存储 Ansible Vault 的密码,然后将其他的秘密嵌入到你的代码库中。
现在我们已经讨论了一些最佳实践,让我们谈谈故障排除。
Ansible – 故障排除
以下是一些 Ansible 的故障排除提示,很多通用的故障排除技巧也适用:
- 使用debug模块显示变量、消息或任务输出,帮助你识别代码中的问题。
这在你尝试找出变量的内容或任务的输出时非常有用;以下示例剧本使用debug模块输出ansible_facts变量的内容:
---
- hosts: localhost
  gather_facts: yes
  tasks:
    - name: Print all the facts
      debug:
        var: ansible_facts
使用ansible-playbook site.yml运行剧本时,应该会显示关于主机的信息。
- 
ansible-playbook;你可以添加-v、-vv或-vvv选项,以增加输出的详细程度,提供更多关于执行过程中的信息。
- 
检查你的 YAML 语法:我曾浪费无数小时在问题上,结果发现只是因为我在剧本中的 YAML 格式不正确。节省时间,使用代码检查器或在线验证器验证你的 YAML 文件,以捕获任何格式或语法错误。 
- 
查看失败和跳过的任务总结:检查剧本执行结束时的失败和跳过任务总结,识别未按预期执行的任务;Ansible 在任务失败时可能不会完全停止执行,因此请注意你的剧本运行,因为你可能会遇到问题却没有立刻察觉。 
- 
验证文件和目录权限:确保为你的 Ansible 文件和目标主机设置了适当的文件和目录权限,以便执行时具有所需的访问权限。 
例如,如果你在主机启动后使用 SSH 访问它,请确保本地机器上的 SSH 密钥等权限正确,否则你的 Ansible 剧本执行可能会失败。
如你所见,随着在目标主机内管理工作负载的增加,而不仅仅是管理基础设施,相比像 Terraform 这样的工具,Ansible 需要更多的考虑因素。
总结
我们在这一章讨论了很多内容;我们讨论了几个相似的概念,但根据我们选择的工具,采取了稍微不同的方法。
对我来说,本章的最大收获如下:
- 
版本控制:使用版本控制跟踪更改,并轻松与团队和同事进行协作。 
- 
文档和一致性:确保您的基础设施代码有良好的文档,并且符合您的样式指南或其他 IaC 项目——在危机期间没有人愿意接手混乱或未经记录的代码。 
- 
关注内容:确保不要通过将其检入您的版本控制系统来暴露密码、密钥或其他敏感内容。我们讨论的大部分 IaC 设计为人类可读,而这是您对敏感信息不希望发生的最后一件事情。 
- 
请保持简单:相信我,走进兔子洞并创建一些非常复杂的、有些人会说是过度的基础架构即代码(IaC)项目是非常容易的。从经验来看,这些类型的项目总是比解决的问题更多。它们很难维护,如果其他团队成员继承了它们,那么他们将很难上手和使用——保持简单,并遵循先前列出的要点。 
在我们接下来并且是最后一章中,我们将看看包括两个来自云服务提供商 Microsoft Azure 和 Amazon Web Services 的本地工具在内的另外三个 IaC 工具,然后讨论您在 IaC 方面的下一步。
第九章:探索替代的基础设施即代码工具
欢迎来到我们基础设施即代码(IaC)之旅的最后一章!到目前为止,你应该已经掌握了 IaC 的基础知识,并且获得了使用 Terraform 和 Ansible 的实践经验。
随着你职业生涯的进展,了解并熟练使用市场上的工具至关重要。本章旨在通过介绍另外三种工具来扩展你的 IaC 工具集:Pulumi、Azure Bicep和AWS CloudFormation。
虽然我们之前探讨的工具都是云平台无关的,但 Azure Bicep 和 AWS CloudFormation 是针对各自云服务提供商的工具。另一方面,Pulumi 通过允许你使用熟悉的编程语言(如 Python)来定义和管理基础设施,将自己与众不同,所有这些都通过实际的代码来实现。
在本章中,我们将涵盖以下主题:
- 
实践了解 Pulumi 
- 
实践掌握 Azure Bicep 
- 
实践掌握 AWS CloudFormation 
在讨论你 IaC 之旅的下一步之前,由于本章内容较多,我们先深入讨论 Pulumi,它是目前(截至本文写作时)在 IaC 工具中出现的新成员。
技术要求
本章的源代码可以在这里找到:github.com/PacktPublishing/Infrastructure-as-Code-for-Beginners-/tree/main/Chapter09。
与 Pulumi 进行实践操作
那么,Pulumi 是什么,它为何直到现在才被提及?
Pulumi 是一个开源的 IaC 平台,允许开发者定义、配置和管理云基础设施;然而,它并不像使用 YAML(Ansible)或 HCL(Terraform)那样使用描述性语言,而是允许你使用流行的编程语言,如 JavaScript、TypeScript、Python、Go 和 C#,以及非程序员使用的 YAML。
使用 Pulumi,你可以更熟悉和直观地构建、管理和部署 IaC,从而更容易理解复杂的云架构。
Pulumi 支持流行的云服务提供商,如 AWS、Azure 和 Google Cloud。它还支持诸如 Kubernetes 等工具,所有这些都使你能够通过一个工具在多个平台上定义和管理资源。
很好,你可能在心里想——但是为什么直到现在它才被提及?
答案是,它不应该被认为是一个初学者工具——鉴于你可以与之交互的方式有很多种,它可能会非常复杂。仅仅触及表面就需要一本专门的书籍。
信息
如果你想跟着本章内容一起操作,可以在本章末尾的进一步阅读部分找到安装 Pulumi 的相关说明链接。
为了给你一个使用 Pulumi 的概念,我们来看看如何在 Microsoft Azure 中启动一些资源,正如我们在早期示例中使用 Terraform 和 Ansible 所做的那样;我们将创建一个资源组和一个存储账户。
我们将首先使用 YAML,然后再看用 Python 实现同样的部署。
使用 Pulumi 和 YAML
我们有两个文件,这两个文件都可以在伴随本书的 GitHub 仓库中找到。第一个文件定义了一些特定环境的配置,名为Pulumi.dev.yaml,对于我们的示例,它包含以下代码:
config:
  azure-native:location: UKSouth
如你所见,我们所做的就是定义默认的location,以供 Azure Native 提供者使用。
这两个文件中的第二个文件名为Pulumi.yaml,它开始时定义了一些关于我们项目的信息和设置:
name: pulumi-yaml
runtime: yaml
description: A minimal Azure Native Pulumi YAML program
outputs:
  primaryStorageKey: ${storageAccountKeys.keys[0].value}
前三行,name、runtime和description,都定义了我们部署的一些基本元信息。
以下两行定义了输出,在我们的例子中,输出将是将要创建的存储账户的主密钥。
在这里,我们定义了一个primaryStorageKey的输出变量,它的值来自我们将在最后定义的一个变量;这个变量将包含我们在创建存储账户后运行的一个函数的输出。
现在我们已经有了基础设置,让我们通过一个资源块来定义资源,首先是 Azure 资源组:
resources:
  resourceGroup:
    type: azure-native:resources:ResourceGroup
    properties:
      resourceGroupName: rg-pulumi-yaml
如你所见,从结构上看,这与 Terraform 和 Ansible 并没有太大不同——在这里,我们定义了一个将被称为resourceGroup的资源,它的类型为azure-native:resources:ResourceGroup,然后最后设置一个包含resourceGroupName键的单一属性。
现在资源组已经定义,我们可以添加存储账户资源,我们将其称为sa:
  sa:
   type: azure-native:storage:StorageAccount
   properties:
      kind: StorageV2
      resourceGroupName: ${resourceGroup.name}
      sku:
        name: Standard_LRS
同样,它遵循之前的相同模式;我们设置资源引用和我们想要创建的资源类型,然后定义我们的properties。
在这种情况下,代替由 Pulumi 为我们创建的资源名称,我们传入的是sku名称、我们想要创建的存储账户类型(通过kind),以及用于添加资源的resourceGroupName键。
为了做到这一点,我们必须使用${resourceGroup.name}变量,该变量取的是我们作为resourceGroup引用的资源组的名称。像 Terraform 一样,这可以确保在创建存储账户之前,资源组已被创建。
Pulumi.yaml文件的最后部分设置了storageAccountKeys变量,它将被我们在文件开始时定义的输出部分所使用。
为了做到这一点,我们需要定义一个variables部分:
variables:
  storageAccountKeys:
    fn::azure-native:storage:listStorageAccountKeys:
      accountName: ${sa.name}
      resourceGroupName: ${resourceGroup.name}
在这里,我们设置了一个函数(fn),它是一个 azure-native 函数,用于处理 storage,并且名为 listStorageAccountKeys。它需要两个输入——accountName,我们通过 ${sa.name} 传递它,以及在 Azure 中大多数情况下需要的 resourceGroupName 键。所以,和之前一样,我们通过使用 ${``resourceGroup.name} 变量以编程方式传递这个值。
现在我们已经拥有所有代码,接下来让我们启动这些资源。为此,我们需要执行以下命令:
$ pulumi up -c Pulumi.dev.yaml
这里的过程与 Terraform 和 Ansible 有些不同;首先发生的事情是,你会被要求登录,如以下截图所示:

图 9.1 – 第一次运行 Pulumi
跟随屏幕上的提示,按下 Enter 键进入登录页面。在这里,你可以使用支持的身份提供者之一进行注册或登录;我使用了 GitHub。登录或注册后,你应该会看到创建堆栈的选项:

图 9.2 – 创建堆栈的时间
一旦你创建了堆栈,Pulumi 将对你的代码进行检查,并为你提供部署更新的选项。在这种情况下,这将创建三个资源——两个在 Azure 中,另一个是我们的输出:

图 9.3 – 我们是否要运行更新?
如果你使用箭头键选择是,然后按下 Enter 键,Pulumi 将部署资源:

图 9.4 – 部署已完成
如前面的输出所示,我们有输出(我已模糊了其中的值)和部署概览。眼尖的你们可能已经注意到一个 URL——点击它会在浏览器中打开部署概览。对我来说,它看起来是这样的:

图 9.5 – 在浏览器中查看部署
我建议你在浏览器中查看一下你的堆栈。完成后,你可以通过运行以下命令来删除资源:
$ pulumi destroy
这将删除 Azure 资源,但不会删除 Pulumi 网站上的堆栈。
现在,让我们再次看看如何部署相同的资源,不过这次我们将使用 Python,而不是 YAML。
使用 Pulumi 和 Python
如你所猜测的,这也是事情开始变得稍微复杂的地方。
在本书随附的仓库中,你会找到几个文件,具体如下:
- 
.gitignore:此文件包含venv和__pycache__文件夹的条目,我们不需要将这些内容检查到版本控制中
- 
__main__.py:这是主要的 Python 代码,我们稍后会详细讲解
- 
Pulumi.dev.yaml:此文件包含环境配置,并且其内容与我们使用 YAML 而非 Python 时的内容相同
- 
Pulumi.yaml:它包含我们部署的基本元数据。
- 
requirements.txt:像大多数 Python 脚本一样,这里有外部依赖项;此文件列出了这些依赖项,以便通过pip安装。
让我们从查看 requirements.txt 文件开始。如前所述,它包含了运行 Python 代码所需的依赖项:
pulumi>=3.0.0,<4.0.0
pulumi-azure-native>=1.0.0,<2.0.0
如你所见,只有两个依赖项——Pulumi 和 Azure Native 提供程序。
如前所述,我们有 Pulumi.yaml 文件。尽管我们使用的是 Python,但它包含了项目的基本信息和设置:
name: pulumi-python
runtime:
  name: python
  options:
    virtualenv: venv
description: A minimal Azure Native Python Pulumi program
如你所见,runtime 现在是 python,一些设置定义了 Python 虚拟环境(virtualenv)的存储文件夹。在我们的案例中,它是 venv,并且与项目的其他文件位于同一文件夹中。
最终的文件是 __main__.py 文件,它定义了我们的资源。文件的第一部分导入了部署资源所需的 Python 库:
"""An Azure RM Python Pulumi program"""
import pulumi
from pulumi_azure_native import storage
from pulumi_azure_native import resources
如你所见,在 requirements.txt 文件中定义的两个依赖项中,我们导入了整个 pulumi 库;然而,来自 pulumi_azure_native 库的 storage 和 resources 作为资源组和存储帐户,是我们启动的唯一两个资源。因此,我们不需要加载整个库。
接下来,我们必须定义资源组:
resource_group = resources.ResourceGroup(
    "resource_group",
    resource_group_name="rg-pulumi-python",
)
我不会称自己是 Python 程序员——我知道足够的知识来做一些简单的事情——但我相信你会同意代码看起来足够简单。
现在,让我们定义存储帐户:
account = storage.StorageAccount(
    "sa",
    resource_group_name=resource_group.name,
    sku=storage.SkuArgs(
        name=storage.SkuName.STANDARD_LRS,
    ),
    kind=storage.Kind.STORAGE_V2,
)
再次说明,它与我们在定义基础设施时使用 YAML 或 HCL 时略有不同。
但再次说明,跟随代码的逻辑还是相当简单的,主要是因为我们已经使用 Pulumi 和 YAML 部署了相同的项目。
这也意味着你应该对接下来要做的事情有一个大致的了解——即获取存储帐户密钥的函数:
primary_key = (
    pulumi.Output.all(resource_group.name, account.name)
    .apply(
        lambda args: storage.list_storage_account_keys(
            resource_group_name=args[0], account_name=args[1]
        )
    )
    .apply(lambda accountKeys: accountKeys.keys[0].value)
)
pulumi.export("primary_storage_key", primary_key)
这是事情变得有点像传统 Python 脚本的地方;虽然跟随代码的逻辑相对直接,但如果你像我一样不是 Python 开发者,可能会觉得从零开始编写前面的代码有点挑战。
让我们尝试部署代码。为此,我们只需要使用与之前相同的命令:
$ pulumi up -c Pulumi.dev.yaml
你会注意到第一次运行命令时有一些不同:

图 9.6 – 安装依赖项
正如你可能已经猜到的,首先,必须安装在 requirements.txt 文件中定义的依赖项。
一旦我们的依赖项安装完成,我们就回到与之前部署 YAML 版本项目时相同的选项:

图 9.7 – 回到熟悉的领域
再次,你将获得一个 URL 来查看你的堆栈,并且可以通过运行 pulumi destroy 命令来终止这些资源。
那么,为什么要这样做呢?
我想本书的大部分读者可能来自运维或系统管理的背景,而非编程背景——这意味着你更熟悉处理各种配置文件,并了解部署基础设施时需要采取的步骤。
Pulumi 旨在通过提供一种使用开发人员熟悉的语言定义基础设施的方式,吸引那些有相关背景的人以及开发人员;正如你从本节开始时可能记得的,JavaScript、TypeScript、Python、Go 和 C# 都受支持。
另一个优势是,你可以将 IaC 纳入现有的构建和部署流水线中。例如,假设你有一个成熟的 C# 构建、测试和部署工作流。如果你使用 Pulumi,应该能够快速将 IaC 引入到这个流程中。
正如本节开始时提到的,我们还没有开始揭开 Pulumi 的强大功能——但我相信你会同意,当你开始处理 IaC 部署时,它为你提供了许多可能性。
现在我们已经看过最后一个云无关的工具,让我们在结束之前看一看这两个云原生工具。
亲手体验 Azure Bicep
Azure Bicep 是我们将在本章中讨论的两个云特定 IaC 工具中的第一个。很长一段时间,如果你想使用微软提供的本地工具,你需要编写 ARM 模板。
当我们在 第四章 中讨论 Microsoft Azure 时,部署到 Microsoft Azure,我们提到 ARM 是 Azure Resource Manager 的缩写——也就是说,它是驱动所有 Azure 功能的 API。当你使用 Azure 门户、命令行工具、PowerShell 或我们已经讨论过的任何 IaC 工具来启动或管理 Microsoft Azure 资源时,你其实已经在使用 ARM 了。
我认为描述 ARM 模板的最佳方式是,它们是发送到 API 的 JSON 负载——我不会包括 ARM 模板的示例,因为内容很多,但我已经在与 Bicep 文件同一文件夹中的附带仓库里提供了一个名为 arm-template-example.json 的示例文件。如你所见,它的内容非常多;文件将近 120 行代码——而这些代码的作用只是定义一个存储帐户。
既然我们已经快速解释了 ARM 模板,那就让我们来看看 Bicep。
Bicep 是一个 main.bicep 文件。
处理 Bicep 文件
我们的 Bicep 代码的第一部分设置了参数,其中我们将设置三个参数,第一个是我们将要启动的存储帐户类型:
@description('Storage Account type')
@allowed([
  'Premium_LRS'
  'Premium_ZRS'
  'Standard_GRS'
  'Standard_GZRS'
  'Standard_LRS'
  'Standard_RAGRS'
  'Standard_RAGZRS'
  'Standard_ZRS'
])
param storageAccountType string = 'Standard_LRS'
如你所见,在这里,我们在定义一个名为 storageAccountType 的参数(其字符串值为 Standard_LRS)之前,提供了一个 allowed 的可能值数组。这意味着如果我们在运行时覆盖默认参数,它只会接受一个允许的参数,而不是任何一个随便的字符串。
第二个和第三个参数如下:
@description('The storage account location.')
param location string = resourceGroup().location
前者通过继承资源组的位置来设置 location 参数;我们还使用资源组的 ID 来生成存储帐户名称的唯一字符串:
@description('The name of the storage account')
param storageAccountName string = 'sa${uniqueString(resourceGroup().id)}'
uniqueString 函数接受资源组 ID,它本身对于你的部署是唯一的,并利用这个 ID 生成一个字符串。这意味着你知道每次执行 Bicep 代码时生成的是一致的字符串,而不是随机字符串。然而,由于它是基于你部署的唯一资源组 ID,所以你永远不可能有两个相同的字符串。
现在我们已经定义了三个参数,我们可以添加代码来创建存储帐户资源。
执行此操作的代码块如下所示:
resource sa 'Microsoft.Storage/storageAccounts@2022-09-01' = {
  name: storageAccountName
  location: location
  sku: {
    name: storageAccountType
  }
  kind: 'StorageV2'
  properties: {}
}
在这里,我们创建了一个名为 sa 的 resource 块,它将使用 Microsoft.Storage/storageAccounts@2022-09-01 API 端点。我们还传入了 name、location 和 sku 的参数。
最后的两行代码设置了一些输出内容,包括存储帐户名称和 ID:
output storageAccountName string = storageAccountName
output storageAccountId string = sa.id
你可能注意到少了点什么……你猜到是什么了吗?
部署 Bicep 文件
如果你猜到以下内容,那么你是对的:
“等一下,我们引用了一个资源组,但我们没有定义一个 用于该资源组的代码块。”
默认情况下,Bicep 和 ARM 模板都期望你将资源部署到现有资源中,而不是在 Bicep 文件中定义资源。
你可能还注意到,我没有给出如何安装 Azure Bicep 的说明。
这样做的原因是 Bicep 已内置在 Azure CLI 中,我们也将使用它来创建资源组。通过运行以下命令来执行此操作:
$ az group create -l uksouth -n rg-bicep-example
当我运行该命令时,得到了以下输出:
{
  "id": "/subscriptions/3e3c9f50-1a27-4e7e-af2e-e0d3f3e4a8f4/resourceGroups/rg-bicep-example",
  "location": "uksouth",
  "managedBy": null,
  "name": "rg-bicep-example",
  "properties": {
    "provisioningState": "Succeeded"
  },
  "tags": null,
  "type": "Microsoft.Resources/resourceGroups"
}
该命令将在英国南部区域创建一个名为 rg-bicep-example 的资源组,我们现在可以通过运行以下代码将 Bicep 文件部署到该资源组中:
$ az deployment group create --resource-group rg-bicep-example --template-file main.bicep
这将输出大量信息,但我们感兴趣的两个主要部分是输出结果。对我来说,这些看起来是这样的:
"outputs": {
  "storageAccountId": {
    "type": "String",
    "value": "/subscriptions/ce7aa0b9-3545-4104-99dc-d4d082339a05/resourceGroups/rg-bicep-example/providers/Microsoft.Storage/storageAccounts/saljkmvlrqknl2y"
  },
  "storageAccountName": {
    "type": "String",
    "value": "saljkmvlrqknl2y"
  }
},
如你所见,storageAccountId 和 storageAccountName 是可见的。
重要
以下命令将删除整个资源组及其内的所有内容,所以请小心,只有在你确实想删除所有内容时再执行。
你可以通过运行以下命令删除我们使用 Bicep 启动的资源:
$ az group delete -n rg-bicep-example
再次强调,本节并不是为了深入探讨 Bicep;我想给出一个基本示例,向你展示基础设施即代码(IaC)工具不仅仅局限于 Terraform 和 Ansible 这“两大工具”。我们甚至还没有触及到 Bicep 所能实现的所有可能性。
自从微软在 2020 年 8 月发布 Bicep 的 alpha 版本以来,它迅速发展并成为 Azure 生态系统中的一等公民;例如,所有官方的 Azure 文档现在都包含了有关 ARM 模板和 Bicep 代码的引用和示例,用于启动和与 Azure 资源交互。
此外,正如我们所经历的,它直接内置在 Azure CLI 中,这意味着如果你已经在使用 Microsoft Azure,你已经可以使用它了。
在我们讨论为什么你应该使用 Bicep 而不是其他工具之前,让我们先看看另一个云原生选项——AWS CloudFormation。
亲自操作 AWS CloudFormation
AWS CloudFormation 是 Amazon Web Services 提供的一项服务,允许你使用模板管理和配置 AWS 资源。
在我们本书中看到的所有工具中,AWS CloudFormation 是最老的,最初在 2010 年 5 月发布。此外,在描述中,我提到它是一个使用模板的服务——这使得它与我们所讨论的其他工具略有不同。
CloudFormation 使用 JSON 或 YAML 模板来描述你期望的 AWS 资源及其配置。这些模板定义了一个堆栈,堆栈是一个相关资源的集合,这些资源可以一起创建、更新或删除。
它提供了自动回滚和漂移检测功能,帮助你保持基础设施的期望状态。如果堆栈更新失败,CloudFormation 可以自动恢复到之前的工作状态。漂移检测允许你识别并修正实际基础设施与模板中定义的期望状态之间的差异。
此外,在部署堆栈之前,你可以估算模板中定义的资源的成本。此外,你还可以使用标签对与特定资源、项目或环境相关的成本进行分类和追踪。
我们将通过 AWS 命令行和 AWS 管理控制台来部署一个单独的 Amazon S3 存储桶。
AWS CloudFormation 模板
首先,我们来看一下我们将使用的模板文件。我更喜欢使用 YAML 而不是 JSON,因为它更容易阅读并理解发生了什么。
我们将使用的模板分为四个小部分。模板是一个包含 20 行代码的小文件;我见过的模板有几百行代码,所以这是我们可以使用的最基本的示例。
模板的开始部分包含一些基本信息,包括模板的功能描述和使用的格式:
AWSTemplateFormatVersion: "2010-09-09"
Description: Creates a basic S3 bucket using CloudFormation
接下来,我们必须设置一些 Parameters;在我们的例子中,这只会是 BucketName:
Parameters:
  BucketName: { Type: String, Default: "my-example-bucket-name" }
接下来是资源部分,我们在这里定义我们的 S3 存储桶:
Resources:
  ExampleBucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: !Ref BucketName
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
最后,我们必须设置一个输出,返回我们创建的存储桶的名称:
Outputs:
  ExampleBucketName:
    Description: Name of the example bucket
    Value: !Ref ExampleBucket
如您所见,参数引用的方式有所不同;我不太喜欢使用像!Ref BucketName这样的语法,因为其他后来的工具使用了类似的参数/变量引用方式。
现在我们有了模板,让我们看看如何使用 AWS CLI 部署堆栈。
使用 AWS CLI 进行部署
AWS CLI 使得部署我们的模板变得容易。要部署 S3 存储桶,请运行以下命令,确保在命令末尾更新存储桶的名称为您自己的名称。这是因为存储桶名称需要是唯一的:
$ aws cloudformation create-stack --stack-name iaccloudform --template-body file://cftemplate.yaml --parameters ParameterKey=BucketName,ParameterValue=iac230404
部署完成后,您应该会看到类似于以下的输出:
{
    "StackId": "arn:aws:cloudformation:us-west-2:687011238589:stack/iaccloudform/ca605040-d2fa-11ed-84fd-027270021b81"
}
我们刚刚部署了堆栈,但没有部署资源——堆栈作为 AWS 服务,会在后台为您部署这些资源。
要删除刚刚启动的堆栈及其管理的资源,请运行以下命令:
$ aws cloudformation delete-stack --stack-name iaccloudform
让我们看看在 AWS 管理控制台中创建堆栈是什么样的。
使用 AWS 管理控制台进行部署
登录到 AWS 管理控制台后,进入 CloudFormation 并点击创建 堆栈按钮。
创建堆栈的第一步是定义您的模板。由于您已经有了模板,请确保选中了模板已准备好。然后,选择上传模板文件选项,并点击选择文件按钮上传您的文件:

图 9.8 – 完成第一步
第二步是提供有关堆栈的一些详细信息,并更新任何参数:

图 9.9 – 输入堆栈的详细信息
第三步是配置堆栈的选项;在这里,您可以定义标签和权限,并控制如果部署失败时采取的措施。
对于我们的部署,您可以保持所有选项为默认值——不过,我建议您在点击下一步按钮进行最后一步之前,先审查一下这些选项:

图 9.10 – 审查堆栈选项
最后一步是审查您的堆栈,然后点击提交按钮,触发堆栈的创建:

图 9.11 – 审查部署
在这里,您可以查看您的资源。完成后,点击删除将删除该堆栈。
您会注意到有一些示例模板,以及一个模板设计器。将其中一个示例加载到设计器中,您可以看到模板的图形视图,并且可以使用拖放界面设计您的模板:

图 9.12 – 设计您的模板
如你所见,有选项可以将完成的模板导出为 JSON 或 YAML;在我们的示例中,YAML 文件中有超过 700 行代码。
这是你想要使用设计器和 AWS 管理控制台的最大原因。
AWS CloudFormation 很容易变得非常复杂,它不太适合坐在空白文件前开始编写代码——我觉得这非常让人不知所措。
总结
在本章的最后,我们介绍了三种额外的 IaC 工具,它们与我们在前几章中讨论的两个主要工具有所不同。那么,为什么你会选择这些工具而不是 Terraform 或 Ansible 呢?
在第二章《超越文档的 Ansible 和 Terraform》中,我们得出结论,应该为任务选择最合适的工具,而不是试图将项目适配到工具上;这同样适用于本章中我们讨论的工具。
在规划你的 IaC 项目时,熟练掌握多种工具总是一个加分项;在本书中曾多次出现过 Terraform 或 Ansible 无法支持我们尝试执行的任务的情况,因此我们不得不使用提供目标云 API 支持的内建工具。
例如,如果你有一个 Azure 项目,而这些工具对最新服务的支持可能会滞后几个月,那么使用 Azure Bicep 可能是最佳选择,因为你知道自己仅针对 Azure;Bicep 对 99.9% 的所有新 Azure 服务提供第一天支持。
同样,你可能需要与开发人员一起工作,他们希望将你的部署集成到他们现有的流程和程序中;因此,使用 Pulumi 可能比引入其他工具更合适。
那么,你接下来的步骤应该是什么?
假设你有一个实验室或免费的云账户。那时,我建议选择一个典型的部署,并通过前面三章中的步骤来定义你的项目,然后执行你的 IaC 项目。
在开始之前,确保你知道最终部署将是什么样子,以及它需要如何配置。从那里,你应该能够将其分解为任务,这将帮助你了解依赖关系。
一旦你了解了任务和依赖关系,这将帮助你确定任务需要执行的顺序——在这一步你应该选择使用哪个工具。然而,你不应该在这之前做出选择,因为你需要知道自己是需要一个声明式工具还是命令式工具,并且你必须了解每个工具在部署成功所需的兼容性和服务支持。
一旦你知道自己要部署什么、以何种顺序部署以及使用哪个工具,你就可以打开一个空白文件开始编写代码。
我建议编写一些代码并进行测试部署——以解决任何问题——然后在解决问题后终止资源。
不要等到最后才尝试调试你的代码。同时,确保在进行测试部署时删除它们——否则,你可能会在部署过程中引入依赖问题,因为资源可能已经存在,因此代码中的任何问题或错误可能不会显现出来。
预计会有很多反复试验,特别是如果你是首次进行 IaC 部署的话。如果你习惯通过 Azure 门户或 AWS 管理控制台部署资源,许多考虑因素可能不太明显,因为这些接口已经为你做了大量工作,在后台处理了许多任务,目的是使资源启动过程尽可能平滑。
最后,一旦你有了运行中的系统,确保让尽可能多的人看到,适当时给予他们访问你的代码的权限——展示部署过程,尝试向他们推销将 IaC 方法应用到他们项目中的好处,并尽量提供支持。
感谢你让我陪伴你度过这个过程;祝你在项目中取得成功。
进一步阅读
Pulumi:
- 
开始使用 Azure: www.pulumi.com/docs/get-started/azure/
- 
开始使用 AWS: www.pulumi.com/docs/get-started/aws/
- 
导入你的基础设施并转换现有的基础设施即代码(IaC): www.pulumi.com/docs/guides/adopting/
Azure Bicep:
- 
Bicep 概述: learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/overview?tabs=bicep
- 
下载并安装: learn.microsoft.com/en-us/azure/azure-resource-manager/bicep/install
- 
学习 Bicep 实时课程: learn.microsoft.com/en-us/events/learn-events/learnlive-iac-and-bicep/
AWS CloudFormation:

 
                     
                    
                 
                    
                
 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号