精通-Terraform-全-
精通 Terraform(全)
原文:
annas-archive.org/md5/021c0b033313a7216d181a97e53800ef
译者:飞龙
前言
基础设施即代码(Infrastructure as Code)已成为配置和维护云基础设施的事实标准方法。尽管这种方法已经存在一段时间,但它在多年的发展中已经成熟并不断完善。最初,它主要遵循命令式编程模型,并通过代理来促进变更。然而,随着基于 API 的云服务的出现,它转变为主要通过声明式配置所需状态,并将其转化为云服务及其配置——而 Terraform 就是这个领域的黄金标准。
Terraform 对于应用程序开发团队(那些创建令终端用户满意的应用和服务)以及平台团队(那些赋能企业和组织实现顺畅高效运作的团队)至关重要。因此,Terraform 已成为这些团队构建和管理支持其目标的环境的首选方式。
本书《掌握 Terraform》认识到,要真正掌握 Terraform,首先必须深入理解将通过 Terraform 和基础设施即代码来自动化的云服务和架构。本书将重点解决现实世界中的问题——无论是通过 Terraform 构建新环境,还是管理现有环境。
我们生活在一个多云的世界中。这就是为什么书中会平等地对待 AWS、Azure 和 Google Cloud 三大主流超大规模云平台。无论您计划使用其中一个、两个,还是全部三个云平台,本书都会将您视为优先用户。
本书中接受的另一个现实是,Terraform 并不是工具箱中唯一的工具。实践者通常需要集成多个工具以实现他们的目标。因此,我们将探索三种不同的云计算范式:虚拟机、容器和无服务器。每种范式都有其独特的设计特性、部署机制和工具链动态,您需要在使用它们时有所了解。
本书适合人群
本书面向云计算、DevOps、平台和基础设施工程师、SRE、开发人员以及希望使用 Terraform 自动化云基础设施并简化软件交付的云架构师。您将从对基础设施即代码(如 Terraform、Ansible 和 AWS CDK)、云架构、开发工具和平台的基本理解中受益。这一基础将帮助您通过本书中涉及的概念和实践提升您的技能。
本书内容
第一章,理解 Terraform 架构,深入探讨了 Terraform 架构的核心要素,重点介绍了 Terraform 状态、模块化、命令行工具以及构成 Terraform 的配置语言。
第二章, 使用 HashiCorp 配置语言,深入探讨了 Terraform 的功能语言 HashiCorp Configuration Language (HCL) 的关键语言结构。它分享了常见情景的最佳实践和实际用例。
第三章, 利用 HashiCorp 实用程序提供者,深入探讨了扩展 Terraform 核心和您选择的云提供商的实用程序提供者。它分享了帮助您简化常见云无关操作的最佳实践和常见用例。
第四章, 云架构基础 – 虚拟机和基础设施即服务,提供了开始设计和构建基于 Terraform 的基础设施即代码解决方案所需的核心概念概述。这些关键概念超越了云平台,将帮助您准备自动化这类解决方案,无论您选择的云是什么。
第五章, 超越虚拟机 – 容器和 Kubernetes 的核心概念,提供了开始设计和构建与 Docker 和 Kubernetes 集成的基础设施即代码解决方案所需的核心概念概述——这是一个在当今日益流行的情景。本章探讨了使用原生客户端工具和适用于 Kubernetes 和 Helm 的相关 Terraform 提供者的 Docker 和 Kubernetes 集成策略。
第六章, 连接所有内容 – GitFlow、GitOps 和 CI/CD,提供了使用 GitFlow 进行软件开发流程概述以及这种方法对基础设施即代码操作的影响。最后,我们探讨使用 GitHub Actions 为虚拟机、Kubernetes 和无服务器工作负载实施 CI/CD 流水线。
第七章, AWS 入门 – 使用 AWS EC2 构建解决方案,提供了一种为 AWS 开发的端到端解决方案,使用由 EC2 提供动力的虚拟机。本章探讨了使用 Packer 自动化操作系统级配置、使用 Terraform 部署基础设施和工作负载,并最终使用 GitHub Actions 自动化整个过程。
第八章, AWS 上的容器化 – 使用 AWS EKS 构建解决方案,提供了一种为 AWS 开发的端到端解决方案,使用由 EKS 提供动力的 Kubernetes。本章探讨了使用 Docker 自动化操作系统级配置、使用 Terraform 部署基础设施和工作负载,并最终使用 GitHub Actions 自动化整个过程。
第九章,使用 AWS 无服务器架构 – 使用 AWS Lambda 构建解决方案,提供了一个为 AWS Lambda 开发的端到端无服务器解决方案。本章探讨了必要的应用程序代码更改,以适应 AWS Lambda 的框架,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十章,在 Azure 上入门 – 使用 Azure 虚拟机构建解决方案,提供了一个使用 Azure 虚拟机开发的端到端解决方案。本章探讨了使用 Packer 来自动化操作系统级配置,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十一章,在 Azure 上容器化 – 使用 Azure Kubernetes 服务构建解决方案,提供了一个为 Azure 开发的端到端解决方案,使用由 AKS 提供支持的 Kubernetes。本章探讨了使用 Docker 来自动化操作系统级配置,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十二章,在 Azure 上使用无服务器架构 – 使用 Azure Functions 构建解决方案,提供了一个为 Azure Functions 开发的端到端无服务器解决方案。本章探讨了必要的应用程序代码更改,以适应 Azure Functions 的框架,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十三章,在 Google Cloud 上入门 – 使用 GCE 构建解决方案,提供了一个为 GCP 开发的端到端解决方案,使用由 GCE 提供支持的虚拟机。本章探讨了使用 Packer 来自动化操作系统级配置,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十四章,在 Google Cloud 上容器化 – 使用 GKE 构建解决方案,提供了一个为 GCP 开发的端到端解决方案,使用由 GKE 提供支持的 Kubernetes。本章探讨了使用 Docker 来自动化操作系统级配置,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十五章,在 Google Cloud 上使用无服务器架构 – 使用 Google Cloud Functions 构建解决方案,提供了一个为 Google Cloud Functions 开发的端到端无服务器解决方案。本章探讨了必要的应用程序代码更改,以适应 Google Cloud Functions 的框架,使用 Terraform 来配置基础设施并部署工作负载,最后使用 GitHub Actions 来自动化整个过程。
第十六章,已经预配置?导入现有环境的策略,深入探讨了将现有资源和环境纳入 Terraform 管理的不同方法。本章探讨了内置导入功能的使用以及使用第三方工具的策略,并提供了关于何时以及如何使用这些技术的实际建议,以及它们的权衡。
第十七章,使用 Terraform 管理生产环境,提供了如何使用基础设施即代码(IaC)通过 Terraform 管理长期存在的环境的深入指导。本章探讨了不同的现实世界操作模型,帮助组织更好地协调大规模的基础设施管理。然后,深入探讨了第 2 天操作,提供了管理变更和故障修复的最佳实践。
第十八章,展望未来 – 认证、 emerging trends 及后续步骤,提供了如何准备并参加 Terraform 认证考试的实用指南。本章还探讨了 emerging trends 以及那些希望将 Terraform 技能提升到更高水平的人的潜在后续步骤。
如何最大化本书的价值
你应该熟悉在 Windows 或 Linux 上使用命令行界面、使用代码编辑器(如 Visual Studio Code)以及使用 Git 源代码仓库(如 GitHub)。
虽然本书提供了云计算概念的概述,例如虚拟网络、虚拟机、Docker 和 Kubernetes,但熟悉这些技术将有助于填补空白,提升你的学习体验。
此外,了解基本的命令式和面向对象的编程语言(如 Java、C# 或 Python)将对无服务器章节的学习有所帮助。
本书中涵盖的软件/硬件 | 操作系统要求 |
---|---|
Terraform v1.8.4 | Windows, macOS 或 Linux |
Packer v1.10.3 | Windows, macOS 或 Linux |
Kubectl v1.26.2 | Windows, macOS 或 Linux |
Helm v3.13.2 | Windows, macOS 或 Linux |
.NET 6 | Windows, macOS 或 Linux |
AWS CLI v2.15.58 | |
Azure CLI v2.58.0 | |
Google Cloud SDK v469.0.0 |
下载示例代码文件
本书提供了三个全面的端到端解决方案,分别适用于每个主要云平台:AWS、Azure 和 Google Cloud。每个解决方案都附带一个专用的 GitHub 仓库,包含所有必要的代码,帮助你快速启动并运行各个平台:
-
亚马逊网络服务:
-
第七章 - 虚拟机解决方案:
github.com/markti/aws-vm-demo
-
第八章 - Kubernetes 解决方案:
github.com/markti/aws-k8s-demo
-
第九章 - 无服务器解决方案:
github.com/markti/aws-serverless-demo
-
-
Microsoft Azure:
-
第十章 - 虚拟机解决方案:
github.com/markti/azure-vm-demo
-
第十一章 - Kubernetes 解决方案:
github.com/markti/azure-k8s-demo
-
第十二章 - 无服务器解决方案:
github.com/markti/azure-serverless-demo
-
-
Google Cloud:
-
第十三章 - 虚拟机解决方案:
github.com/markti/gcp-vm-demo
-
第十四章 - Kubernetes 解决方案:
github.com/markti/gcp-k8s-demo
-
第十五章 - 无服务器解决方案:
github.com/markti/gcp-serverless-demo
-
您可以从 GitHub 下载本书的附加示例代码文件,地址为 github.com/PacktPublishing/Mastering-Terraform
。如果代码有更新,GitHub 仓库中的内容也会随之更新。
我们还有其他来自丰富书籍和视频目录的代码包,您可以在github.com/PacktPublishing/
找到。赶快查看吧!
使用的约定
本书中使用了多种文本约定。
文本中的代码
:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter/X 账号。以下是一个示例:“当你运行terraform init
时,Terraform 会将当前工作目录转变为工作区的根模块。”
代码块设置如下:
app_settings = {
"SCM_DO_BUILD_DURING_DEPLOYMENT" = "false"
"WEBSITE_RUN_FROM_PACKAGE" = "1"
当我们希望引起你对代码块中特定部分的注意时,相关行或项目会设置为粗体:
app_settings = {
"SCM_DO_BUILD_DURING_DEPLOYMENT" = "false"
"WEBSITE_RUN_FROM_PACKAGE" = "1"
任何命令行输入或输出如下所示:
terraform import 'ADDRESS["key"]' ID
粗体:表示新术语、重要词汇或在屏幕上看到的词汇。例如,菜单或对话框中的词汇会以粗体显示。以下是一个示例:“两个主要的云平台,亚马逊 Web 服务(AWS)和微软 Azure,已经在各自的 IaC 解决方案中采用了资源类型。”
提示或重要说明
以这种方式显示。
联系我们
我们始终欢迎读者的反馈。
常规反馈:如果您对本书的任何方面有疑问,请通过电子邮件联系 customercare@packtpub.com,并在邮件主题中注明书名。
勘误表:虽然我们已尽力确保内容的准确性,但错误总是难以避免。如果你发现本书中有错误,我们非常感谢你向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。
盗版:如果你在互联网上发现我们作品的任何非法版本,恳请你告知我们其位置或网站名称。请通过 copyright@packt.com 联系我们,并提供相关链接。
如果你有兴趣成为作者:如果你在某个领域具有专业知识,并且有兴趣编写或为书籍做出贡献,请访问 authors.packtpub.com。
分享你的想法
一旦你阅读了《掌握 Terraform》,我们很想听听你的想法!请点击这里直接进入亚马逊评论页面并分享你的反馈。
你的评论对我们和技术社区都很重要,它将帮助我们确保提供优质的内容。
下载本书的免费 PDF 版本
感谢购买本书!
你喜欢随时阅读但又无法随身携带印刷版书籍吗?
你的电子书购买与所选设备不兼容?
别担心,现在每本 Packt 书籍,你都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。可以搜索、复制并将你最喜欢的技术书籍中的代码直接粘贴到你的应用程序中。
好处不止于此,你还可以独家获得折扣、新闻通讯以及每天发送到你邮箱的精彩免费内容。
按照这些简单的步骤,你将能获得以下好处:
- 扫描二维码或访问以下链接
packt.link/free-ebook/9781835086018
-
提交你的购买证明
-
就这样!我们会将你的免费 PDF 和其他福利直接发送到你的邮箱。
第一部分:Terraform 基础
在我们开始旅程之前,我们需要建立 Terraform 的概念模型、架构和能力,并了解如何利用它来开发和维护你的云架构。
本部分包括以下章节:
-
第一章**,理解 Terraform 架构
-
第二章**,使用 HashiCorp 配置语言
-
第三章**,利用 HashiCorp 工具提供商
第一章:1
理解 Terraform 架构
从本质上讲,Terraform 是一个简单的命令行程序,它评估源代码,该源代码描述了期望状态应该是什么样的,随后将其与实际状态进行比较,构建一个计划,将实际状态转变为期望状态,并能够执行该计划。但不要让它表面上的简单性欺骗了你。Terraform 的内部复杂性体现在它的外部简单性中。
Terraform 是一个由 Go 编写的开源项目,维护着命令行可执行文件。它提供了诸如HashiCorp 配置语言(HCL)解析、状态管理、计划创建和执行等基础功能。
Terraform 极其强大,但具有讽刺意味的是,它本身做的很少。但有一个令人兴奋的部分:Terraform 的超能力来自于其可扩展性,这种能力不仅限于其创造者。实际的 Terraform 可执行文件本身不能做太多事情,但当与其插件——称为提供者——捆绑在一起时,Terraform 就能做很多事情!这种可扩展性证明了 Terraform 社区的协作性质,每个人都可以为它的成长和能力做出贡献。
本章将涵盖以下主题:
-
理解 Terraform 架构
-
理解 Terraform 状态
-
理解如何构建和使用模块
-
理解如何有效使用命令行界面(CLI)
Terraform 有四个超能力,使其与其他工具区分开来:规划、可扩展性、配置语言和模块化。一些工具可能共享其中的一些功能,但它们并不具备所有这些功能。拥有这些能力的组合,Terraform 在云自动化中是一款改变游戏规则的工具。
理解 Terraform 架构
Terraform 最显著的差异化特点是,它能够提前规划。让我们详细了解 Terraform 如何处理规划。
计划
在使用 Terraform 时,你将遵循一个过程,Terraform 用于分析现有环境。在进行此分析时,Terraform 确定代码中需要应用的(如果有的话)变更,以使实际环境保持最新。Terraform 将这些变更作为操作项列出在计划中。尽管 Terraform 代表我们执行这项分析,生成计划,并完全能够在环境中执行该计划,但我们仍然有责任审查计划,确定计划中的变更是否是我们所期望的:
图 1.1 – Terraform 资源是具有输入和输出的简单机器
Terraform 在这个分析中将环境中的每个组件表示为一个资源。资源是极其简单的机器。它们接受输入并产生输出。它们还可以连接在一起,从而在环境中的组件之间创建明确的关系。这些关系为 Terraform 对环境的分析和计划中列出的行动顺序提供了信息。
一旦我们决定这个计划是我们所期望的,我们会请求 Terraform 执行它。然后,Terraform 会将该计划应用到我们的实际环境中。这个过程的结果是,Terraform 会将我们的环境更新为代码中的描述。
Terraform 的设计鼓励开发者重复这一过程。因此,随着开发者更新代码,每次将代码应用到环境中时,我们将不断评估当前状态,并确定未来状态,以匹配代码所描述的环境。每次运行 Terraform 来评估环境时,它都会生成一个计划。这个计划是在评估实际环境与代码库之间差异时生成的。
在第 1 天,由于环境尚不存在,Terraform 必须创建开发者在代码中描述的所有内容。然而,到了第 2 天,事情变得更复杂了。在第 1 天,我们开始了清理工作。但在第 2 天,我们仍然需要确定我们的起点,因为 Terraform 已经提前配置过一次环境。自第 1 天以来,许多事情可能发生了变化。我们可能故意修改了代码库来更改环境。同样,gremlins 可能在夜间改变了我们的环境,从而引入了漂移,迫使我们回滚这些变化。
要分析现有环境,Terraform 会参考两个信息来源:Terraform 状态和环境本身——通过提供者(该提供者同样受 Terraform 状态的影响)。如果 Terraform 状态为空,Terraform 会假定环境不存在,并创建一个计划以创建所有内容:
图 1.2 – 资源计划,第 1 天:所有内容都需要被创建
如果 Terraform 状态已存在,事情就变得有趣了,Terraform 将不得不证明其价值。Terraform 会使用 Terraform 状态来分析环境,向提供者查询每个声明资源的健康状态和配置。根据这些结果,Terraform 会构建一组指令。一旦 Terraform 执行了这些指令,当前环境将与代码中描述的期望环境一致。然而,在 Terraform 第一次成功执行您的计划之后,如果您再次要求 Terraform 创建一个计划,它会查询 Terraform 状态并使用提供者来检查实际环境,看到不需要进行任何更改:
图 1.3 – 资源计划,第二天:环境中没有变化
为了生成这样的指令集,Terraform 必须生成一个完整的资源依赖关系图,以确定必须按什么顺序执行指令。资源之间的关系推断出这些依赖关系。如果一个资源将另一个资源的输出变量作为输入变量,Terraform 会判断这两个资源之间存在依赖关系:
图 1.4 – 依赖关系:一个资源的输入是另一个资源的输出
有时,Terraform 只有在执行完指令后才能知道其结果。因此,必然会有一个警告信息 apply 后才知道
。然而,这个依赖关系图和随后的计划是 Terraform 机器的关键所在。
这个过程使得 Terraform 成为一个幂等工具,意味着它可以多次应用,而不会改变初次应用后的结果。幂等性并不仅仅是 Terraform 独有的,许多自动化工具也具有类似的功能。Ansible 就是一个很好的例子,它也确保重复操作不会改变状态,除非必要时进行更改。
执行阶段
Terraform 的核心工作流遵循三个阶段的过程:初始化、规划 和 应用:
图 1.5 – Terraform 执行阶段
让我们逐一检查每个阶段,看看我们的代码库中哪些部分正在被使用,以及 Terraform 正在执行哪些操作。
初始化
首先,使用 terraform init
命令初始化 Terraform 工作空间,它会加载并配置所有引用的提供者和模块:
图 1.6 – Terraform 初始化加载提供者和模块依赖关系,并验证后台连接性
规划
一旦 Terraform 初始化了其工作区,它可以使用 terraform plan
命令生成计划。尽管这个命令看起来简单,但它是一个非常复杂的过程。
首先,使用隐式(有时是显式)关系建立所有资源的依赖图。然后,Terraform 检查状态文件,确定是否已经配置了该资源。假设该资源存在于状态文件中,在这种情况下,Terraform 将通过相应的提供者与资源进行通信,并将所需状态与存储在状态文件中的预期状态以及提供者报告的实际状态进行比较。Terraform 会记录任何差异,并为每个资源创建操作计划。操作可以是创建、更新或销毁:
图 1.7 – terraform plan 使用一组输入变量评估当前代码库,并与工作区的 Terraform 状态进行比较。
应用
一旦 Terraform 生成了计划,它可以选择使用 terraform apply
命令在实际环境中执行该计划。通过依赖图,Terraform 将按顺序执行每个资源操作。如果资源操作之间没有相互依赖,则 Terraform 会并行执行它们。在此阶段,Terraform 会不断与每个提供者进行通信,发出命令并检查相关提供者的状态。随着 Terraform 完成资源操作,它会不断更新 Terraform 状态:
图 1.8 – terraform apply 通过与提供者的通信执行计划,更新 Terraform 状态,并返回输出变量。
资源操作
当 Terraform 生成计划时,它会评估每个资源,确定是否需要更改以实现基础设施的期望状态。Terraform 会根据多种不同的情况判断是否需要对特定资源执行操作。
创建
创建操作可以在三种情况下发生:
-
该资源是全新的。
-
某些非 Terraform 的操作删除了资源。
-
开发者以一种方式更新了资源的代码,以至于提供者要求销毁并重新创建该资源。
下面是添加新资源的示例:
图 1.9 – 添加新资源
当一个资源是全新的,它并不存在于 Terraform 状态文件中。例如,我们想创建一个 vm001
。在这种情况下,Terraform 不会使用提供者来检查该资源是否存在。因此,您可能会遇到这种情况:计划生成成功,但当 Terraform 执行计划时,它会失败。这种情况通常是因为资源命名冲突,当另一个用户已为与 Terraform 计划创建的资源无关的资源分配了相同的名称(即某人已经创建了名为 vm001
的虚拟机)。这种情况可能发生在某人手动创建资源,或者即使资源是通过 Terraform 创建的,但位于不同的 Terraform 工作区,从而导致使用不同的 Terraform 状态文件。
漂移概念的一个典型例子是,当某人手动删除了 Terraform 之外的资源时:
图 1.10 – 漂移
当开发者更改资源时,有时提供者要求将其销毁并重新创建。例如,我们想将虚拟机的硬件配置从 4 核 CPU 和 16 GB 内存更改为 8 核 CPU 和 32 GB 内存。这种逻辑存在于提供者的资源级代码库中。您应仔细查看所使用资源的文档,以确保了解在更新过程中强制销毁并重新创建资源时可能出现的任何中断或数据丢失。
更改
变更操作可以发生在两种情况下:
-
该资源已在代码中发生变化
-
该资源已在 Terraform 之外被修改
以下是更改现有资源的情况:
图 1.11 – 更新现有资源
这种更改不需要销毁并重新创建资源。它可能是一些简单的操作,比如更改资源的标签。这类更改也可能是由漂移引起的。例如,某人使用云平台的管理门户手动添加了一个新标签,但没有更新 Terraform 代码库。
销毁
销毁操作可以发生在两种情况下:
-
开发者已从代码中删除了该资源
-
开发者更新了资源的代码,以至于提供者要求将其销毁并重新创建
以下是移除现有资源的情况:
图 1.12 – 移除现有资源
这可能是一个简单的操作,比如移除一个不再使用的资源——或者更可能的是,已经不再使用的资源。例如,移除对整个互联网的多余的 22
端口——这可能是个好主意!
资源操作计划可能会产生级联效应。自然地,如果一个资源是全新的,那么其依赖的资源也会变成新的。然而,最好在资源需要销毁并重新创建时保持谨慎。这个操作被称为丢弃重建(drop-create)操作。当资源在环境中扮演关键角色时,当发生丢弃重建操作时,通常会有一大片资源图也会被销毁并重新创建——通常是任何依赖于被丢弃重建资源的资源。
配置语言
当 Terraform 最初只是在 Armon Dadgar 和 Mitchell Hashimoto 的脑海中闪现时,业界已经有两种基础设施即代码(IaC)的范式:命令式,该范式通过 Chef 和 Puppet 等工具占主导地位,使用传统的编程语言,如 Ruby 和 Python。然而,也有声明式的方法,但大多数方法都只是编写大型复杂 JSON 文档的练习。
两大云平台,亚马逊网络服务(AWS)和微软 Azure,已经在各自的 IaC 解决方案中采用了资源类型化。AWS CloudFormation 和Azure 资源管理器(ARM)模板采用了一致的架构来描述各种类型的资源。每种资源类型都有一套标准属性,帮助平台定位适当的资源提供商来处理请求。同样,每种资源类型也有自己特定的属性和架构来配置其独特性。但是,这些解决方案仍然局限于各自的云平台内。
因此,在许多方面,业界已经为一个采用资源类型化的方法的解决方案做好了准备,从而打破了云服务提供商之间的壁垒,至少提供了一个能够在同一上下文中描述多个云平台上资源的工具。命令式和声明式方法都有各自的挑战。
命令式方法导致代码过于复杂、嵌套结构过多,并且复杂的状态检查逻辑使得代码库难以维护,快速变成“意大利面条代码”。此外,编程语言和平台的遗产可能会激起开发者阵营之间的宗教性竞争。
声明式解决方案则依赖于行业标准的文档格式,如JSON和YAML。这些格式鼓励简单的自上而下的方式,并由于其中立性避免了部落主义。然而,它们使得表示复杂表达式以及实现简单的迭代和循环变得困难,甚至像给代码添加注释这样的简单操作也无法实现,或者做起来非常繁琐。
Terraform 通过引入命令式语言的元素,如表达式和循环,并将其与声明式模型的优势相结合,从而带来了两者的最佳结合。声明式模型鼓励以简单的自上而下的方式定义环境中的资源。
HCL 使用简单的块定义,相比其他声明式解决方案,它能够更简洁地表示资源,但语法更像代码,所有块之间的链接都体现了云计算本质上基于资源类型的特点:
resource "random_string" "foo" {
length = 4
upper = false
special = false
}
一个块的定义有三个部分:resource
、资源类型是random_string
,以及引用名称是foo
。为了在资源之间创建依赖关系,我们使用引用名称和类型来访问资源的输出值:
resource "azurerm_resource_group" "bar" {
name = "rg-${random_string.foo.result}"
location = var.location
}
在前面的代码中,我们通过引用名为foo
的随机字符串的result
输出值来创建一个 Azure 资源组。
这个简单的模式描述了我们如何将几十个,有时是上百个资源组合在一起,构建复杂的云架构:
图 1.13 – Terraform 资源的链式连接,其中一个资源的输出作为另一个资源的输入
使用这种模式在 HCL 中,Terraform 可以确定资源之间的关系,并构建一个计划来配置它们。最有趣和最聪明的地方在于,这其实只是一个连接点的精妙游戏。
模块化
一切都存在于模块中。当你创建第一个 Terraform 项目时,你无意中创建了第一个 Terraform 模块。这是因为每个 Terraform 项目都是一个根模块。在根模块中,你声明提供者,
Terraform 中的一个普遍模式是,当你编写资源、模块或数据源时,你会处理输入和输出。每个 Terraform 资源和数据源都以这种方式工作,你的整个 Terraform 工作区也是如此,这使得 Terraform 可以被整齐地嵌入到管道中的工具链中,以配置环境。
根模块并不一定是你编写的唯一模块。你可以创建可重用的模块,设计用来封装解决方案中可重复使用的部分,并可以跨根模块共享。根模块和可重用模块之间的区别在于,根模块是为部署一个或多个环境而设计的入口点。可重用模块只是定义有用模式或最佳实践的组件,它们可以帮助你节省每次创建新环境或类似解决方案时重复创建它们的时间。
现在我们已经高层次地了解了 Terraform 的架构,并理解了其核心技术,我们知道它包括 Terraform 命令行应用程序和 HCL 函数语言。我们还知道,Terraform 的超能力在于,核心技术的设计通过利用提供者来扩展,非常适应不同的平台和技术,并且内置的模块化使得实践者能够轻松创建简单或复杂的 IaC 解决方案,这些解决方案可以被打包并在不同团队和组织之间重用。
接下来,我们将深入探讨一个关键子系统,使 Terraform 能够在各种平台和技术上实现一致的、幂等的基础设施即代码(IaC)操作。
理解 Terraform 状态
Terraform 使用状态来记住在给定工作区中之前已配置的内容。一些 Terraform 的批评者在将其与 AWS CloudFormation 或 ARM 模板进行比较时指出,这些技术并不依赖于将状态保存在外部文件中的概念。当然,这仅仅是因为这些工具只支持单一的目标平台,并且可以与这些平台维护状态的专有方式紧密耦合。然而,Terraform 通过其灵活的插件架构,不能假设任何关于平台及其为每个目标平台配置的资源。因此,Terraform 需要回到最基本的层面,并确保它以统一和一致的方式知道自己之前配置了什么。
这种维护状态的方法提供了几个好处。首先,它统一地记录了 Terraform 在那些维护内部状态的以及那些不维护状态的平台上的配置内容。其次,它允许 Terraform 定义受管理和未受管理资源之间的边界。
这个问题就是经典的侏罗纪公园问题。在侏罗纪公园中,他们基因工程化了所有这些恐龙。他们在设计时考虑了种群控制——这样它们就无法交配——或者他们以为如此。在公园里,他们有所有这些复杂的系统来追踪恐龙的位置和数量。然而,他们设计的一个大缺陷是,他们只让系统查找他们基因工程化的恐龙。因此,他们的系统运行得完美无缺,能告诉他们所有他们创造的恐龙在哪里。你猜怎么着?恐龙的数量总是和他们预计的数量相符。这对侏罗纪公园来说是坏事,因为由于这个缺陷,他们没有意识到基因工程中的问题,使得恐龙能够交配。侏罗纪公园的恐龙数量过多,事情变得——嗯——有点失控:
f
图 1.14 – 侏罗纪公园问题
Terraform 只会查找它所管理的资源。它能做到这一点是因为它维护着一个状态文件。这个状态文件就像《侏罗纪公园》认为自己拥有的恐龙清单。对于《侏罗纪公园》来说,这种做法很糟糕。但对于 Terraform 来说,这是件好事:
图 1.15 – Terraform 忽略外部创建的资源,即便这些资源与 Terraform 创建的资源有依赖关系
为什么?因为并非所有资源都会——或需要——由 Terraform 创建和管理。通过清晰地界定 Terraform 负责的内容(以及它不负责的内容),它使 Terraform 在允许组织选择与 Terraform 互动的程度上更加灵活。一些团队和组织会从小处开始,只使用 Terraform 部署少量内容。与此同时,其他团队可能会非常积极,使用 Terraform 管理一切。不过,仍然可能存在 Terraform 无法识别的操作。Terraform 的状态文件构建了防护栏,确保 Terraform 只在其许可范围内工作,明确它可以处理哪些内容。这样做使得 Terraform 能够与其他工具兼容,并给予团队和个人在环境控制方面使用任何方法或工具的自由。
状态文件
Terraform 状态文件是一个 JSON 数据文件,存储在 Terraform 知道如何访问的某个位置。这个文件维护了一个资源列表。每个资源都有一个资源类型标识符和该资源的所有配置信息。
状态文件反映了我们在代码中描述的内容,但比代码中声明的内容要详细得多。以下代码生成一个长度为四个字符的随机字符串,且不包含大写字母和特殊字符:
resource "random_string" "foo" {
length = 4
upper = false
special = false
}
运行 terraform apply
后,Terraform 会生成一个状态文件,其中包含相同的资源,但会提供更多上下文信息:
{
"mode": "managed",
"type": "random_string",
"name": "foo",
"provider":
"provider[\"registry.terraform.io/hashicorp/random\"]",
"instances": [
{
"schema_version": 2,
"attributes": {
"id": "vyoi",
"keepers": null,
"length": 4,
"lower": true,
"min_lower": 0,
"min_numeric": 0,
"min_special": 0,
"min_upper": 0,
"number": true,
"numeric": true,
"override_special": null,
"result": "vyoi",
"special": false,
"upper": false
},
"sensitive_attributes": []
}
]
}
provider
和 type
实例有助于识别该资源的类型以及开发者使用的 Terraform 提供者。
资源属性中的 schema_version
参数有助于识别当前资源是否与提供者的当前版本兼容。如果不兼容,它可以帮助提供者判断如何将资源升级到最新版本的架构。
部分资源管理
由于 Terraform 本身是一个 开源软件 (OSS),并且内置假设这些云服务商是各自独立的软件,在不断演化,与 Terraform 提供者的更新速度不同,因此会有一段时间,云服务商会推出 Terraform 无法识别的功能。
当这种情况发生时,我们不希望 Terraform 与云提供商争论禁用这些功能,仅仅因为 Terraform 无法识别它们。这个场景非常常见,因为当一个环境由 Terraform 和特定版本的 Terraform 提供程序管理时,它自然而然地会出现。随着 Terraform 提供程序不断增加新功能以跟上目标云平台的步伐,提供程序版本在 Terraform 代码中并不总是保持最新——而且它不必一直保持最新。
假设我们使用 Terraform 和 v1.0 提供一个环境,使用的是我们最喜欢的云平台的 Terraform 提供程序。第二天,我们最喜欢的云提供商增加了一个令人惊叹的功能,Feature X。我们依然使用相同的代码和 Terraform 状态文件,但我们非常渴望尝试 Feature X。然而,我们使用的是最新版本的 Terraform 提供程序——v1.0——它并不支持 Feature X。
我们该怎么办呢?嗯,我们可以等着我们那些贡献于 Terraform 提供程序开源项目的友好的互联网陌生人来增加对 Feature X 的支持。但是,我们不知道什么时候会有支持。
我们提到过我们非常渴望尝试 Feature X 吗?如果我们实在等不及,我们可以直接在我们最喜欢的云平台上启用 Feature X。这样不会造成偏差吗,你可能会问?在正常情况下——是的——因为我们在使用最喜欢的云平台的网页界面修改 Terraform 管理的资源。通常,下次我们运行 terraform apply
时,Terraform 会检测到资源在外部环境发生了变化,并将这些更改回滚。但是,由于我们使用的是 v1.0 版本的 Terraform 提供程序,Terraform 对 Feature X 幸灾乐祸地一无所知。因此,我们对 Feature X 配置所做的任何更改都不会被 Terraform 注意到。这也意味着,如果你删除了那个 terraform destroy
资源并重新创建它,你将不得不再次去门户网站手动重新配置 Feature X。
也就是说,直到我们升级到 v1.1 版本的 Terraform 提供程序,而该版本是在我们手动在资源上设置 Feature X 的第二天发布的。现在,我们正在使用 v1.1 版本的 Terraform 提供程序,Terraform 用来将该服务部署到我们最喜欢的云平台的资源现在已经知道了 Feature X。如果我们的代码没有变化,它会认为 Feature X 不应该被启用,并且会将其移除。
为了避免这种情况,我们需要仔细使用 v1.1 版本的 Terraform 提供程序运行 terraform plan
,查看 Terraform 在使用这个升级版本的提供程序时计划做出哪些更改。然后,我们需要更新我们的代码以按现有配置配置 Feature X。一旦我们完成这个操作,Terraform 会看到无需做出更改,且 Terraform 将会将 Feature X 纳入管理:
图 1.16 – 管理随着新云平台功能的创建、通过 Terraform 提供者暴露并在 Terraform 代码库中采纳所发生的持续变化
现在我们已经了解了 Terraform 如何维护状态以及这一架构方面如何影响 Terraform 创建和执行计划,让我们转向一个更实际的话题:开发和使用模块。
理解如何构建和使用模块
Terraform 最强大的功能之一是它组织和打包可重用代码的便捷性,这增加了代码库的可维护性并提高了架构中常见模式的可重用性。
传统开发者相对容易——你只需要创建一个新方法来封装可重用的代码块。而在其他基础设施即代码(IaC)工具中,实现同样的功能却是一个挑战。在 Terraform 中,你所需要的只是一个新文件夹。
Terraform 将每个模块作用域限定在一个文件夹内。当你运行terraform init
时,Terraform 会将当前工作目录转变为工作区的根模块。你可以通过使用相对路径来引用存储在同一仓库内其他文件夹中的模块。在 Terraform 社区中,将本地模块存储在接近根模块目录的modules
目录下是一种标准约定。
考虑一下这个文件夹结构:
/terraform
/modules
/rando
/root
根模块的路径是/terraform/root
。rando
模块的路径是/terraform/modules/rando
。
考虑根模块的内容:
main.tf
outputs.tf
variables.tf
versions.tf
上述文件列表是模块文件结构的典型约定。在versions.tf
文件中,你应该声明terraform
块,该块包含 Terraform 版本及每个引用的 Terraform 提供者和它们对应的版本。
在variables.tf
文件中,你应该声明该模块期望的所有输入变量。将所有输入变量集中声明在一个地方是非常重要的,这样可以让模块的使用者更容易理解该模块的合同。
同样,在outputs.tf
文件中,你应该声明该模块将生成的所有输出值。
由于可以在文件夹内的任何 .tf
文件中声明输入变量和输出,因此没有什么阻止你采用这种方法。然而,你不希望其他开发者需要扫描模块文件夹中的每个文件,寻找 variable
块,以便清楚了解模块的接口。
在main.tf
文件中,你应该声明模块的核心内容。这是发生魔法的地方。然而,你并不局限于只有一个文件。根据需要,你可以创建额外的.tf
文件,将更复杂的模块更好地组织成相关资源的不同部分或分组。
我们需要理解相对路径,以便从根模块引用 rando
模块。这个相对路径是基于根模块的工作目录来计算的。因此,rando
模块的声明应该如下所示:
module "foo" {
source = "../modules/rando"
}
source
元素是每个 module
块的必需属性。你会注意到,声明模块与声明资源或数据源稍有不同。例如,声明模块时省略了资源类型。这是因为 module
块既是一个块类型,也是一个资源类型。因此,除了模块块的定义外,我们只需要一个引用名称。
我们可以通过简单地认识到 module
是资源类型来引用模块的输出值:
locals {
dynamic_name = "foo-${module.rando.result}"
}
如你在前面的代码中看到的,我们引用了名为 foo
的模块上的 result
属性,因为模块不像资源类型那样具有描述性,因此在引用名称中提供更多的细节就显得尤为重要。
现在我们已经理解了创建和引用自定义模块的基础知识,让我们更深入地探讨模块设计的问题。
模块设计
在许多方面,在 Terraform 中创建模块的决定与在传统编程语言(如 Java 或 C#)中决定编写新方法相同。
就像在传统编程语言中一样,你可以在一个文件中从头到尾编写所有代码,使用一个方法,如果有重复的部分,你会复制粘贴它们。
就像在传统编程语言中一样,编写封装重复代码块的方法是有原因的。否则,如果你没有将这些代码封装到方法中,就必须反复复制粘贴。
关于何时创建模块与仅将其放在根模块中的决定是一个重要的决策。你应该有充分的理由来创建模块。你应该始终关注价值。当某人使用你的模块时——可能是你自己或你的团队——使用它是否让他们的生活更轻松?
根模块
在 Terraform 中有许多不同的方式来设置根模块。关于哪种方法最好,辩论仍在继续,有些人强烈支持一种方法。了解不同的方法很重要,这样你就能在看到它们时识别出来,并评估哪种方法最适合你。
每个环境一个文件夹
一种常见的根模块结构化方法是为每个你想要提供和维护的环境设置不同的文件夹。在这种方法中,每个长期存在的环境都有一个文件夹。这个文件夹包含一个可以独立于其他环境的根模块。考虑以下文件夹结构:
/terraform
/dev
main.tf
versions.tf
variables.tf
terraform.tfvars
/test
main.tf
versions.tf
variables.tf
terraform.tfvars
/prod
main.tf
versions.tf
variables.tf
terraform.tfvars
上述文件夹结构包含三个环境:dev
、test
和 prod
。每个环境都有自己的根模块,完全与其他模块隔离。它有自己的 required_providers
块,并定义了自己的提供程序声明。这种方法在每个环境之间有强隔离,几乎每个部署的方面都可以在不同环境中进行调整。Terraform 版本、提供程序版本以及解决方案中使用的其他模块版本、输入参数及其值都在对应环境文件夹中的文件内进行定制。
这种方法更常见于那些不熟悉使用 GitFlow、管理其他分支并跟随 develop
到更成熟分支(例如,main
——生产代码所在的分支)的实践者。
每个环境的变量文件
另一种技术是维护一个单一的 Terraform 代码库,并为每个环境使用多个输入变量文件。这种方法侧重于在环境之间保持一致性和兼容性。采用这种方法时,更难在环境之间做出大规模的结构差异,因为合并分支之间的更改会变得困难。
请考虑以下文件夹结构:
/terraform
/modules
/solution
main.tf
versions.tf
variables.tf
/env
dev.tfvars
test.tfvars
prod.tfvars
main.tf
versions.tf
variables.tf
terraform.tfvars
与之前的方法一样,我们为每个环境创建了明确的文件夹,这种方法仍然允许环境之间的变化,但需要你为每个环境维护长期存在的分支,以便在你对根模块的核心结构进行更改时。这种方法更符合一种名为 GitFlow 的软件开发流程(更多内容请参见 第六章)。
这种方法的关键特点是,环境差异通过不同的输入变量值保存在对应的 .tfvars
文件中。目标是将环境之间的任何变化最终存储在这些文件中,并且每个环境的代码库——存储在几个长期存在的源代码分支中——最终会相互镜像。这使我们能够在生产环境和开发环境之间拥有不同的大小和数量,并在每个环境中保持架构和配置的一致性。
可重用模块
现在我们已经控制了根模块,是时候开始思考何时创建可以在根模块中使用的可重用模块,从而构建精密的云架构,推动我们的应用程序和解决方案。
复杂性的封装
你计划在模块中封装的资源数量是一个重要的衡量标准,因为它可以表明你通过创建模块是在减少复杂性,还是在增加复杂性(剧透警告:增加复杂性是不好的)。模块的资源数量可以从一个资源到几十个,甚至是成百上千个资源不等。在考虑将资源放入模块时,你应该考虑当有人使用这个模块时,你所带来的价值。
如果你的模块仅封装了一个资源块,那么通过直接引用该资源,你的代码可能会更简单。在这种情况下,模块只是对你正在配置的基础资源增加了一层抽象。如果仅仅是这样做,那么你需要进一步简化复杂性,以证明创建模块的必要性。
假设你的模块封装了一些紧密相关的资源,这些资源高度依赖于彼此,并且与其他资源的集成点有限。例如,创建一个 NSG 和一组规则。当你创建一个封装这些紧密耦合资源的模块时,这可能是个好主意,因为它将使得开发人员创建 NSG 变得更加简洁和容易。在这种情况下,这就是创建模块的最佳时机。你可能需要为一个或两个额外的输入变量换取一个或两个额外的相应资源块。这是一个不错的权衡:
图 1.17 – 模块设计:封装复杂性
前面的图示显示,该模块正在配置三种资源类型。我们的模块定义了一个单一的接口,用来配置这一组资源。一些简单的输入变量,A
和 B
,被传递到主资源和子资源 1 中。一个更复杂的输入对象,C
,它恰好是一个数组,被传入并用来为列表中的每个项构建资源块。
重复模式
另一种常见的情况是,当你有许多资源,并希望根据集合的大小(无论是列表还是映射)进行重复。在这种情况下,你应该告诉每个资源你希望它重复多少次,并传入所有的输入变量以满足其需求:
图 1.18 – 模块设计:在模块内重复
然而,如果你将重复使用的资源封装进模块,而不是重复每个资源,你将重复模块。这种方法可以显著提高代码的可读性和可维护性:
图 1.19 – 模块设计:在模块外重复
模块的外部消费者负责对模块资源本身进行迭代:
module "foo" {
source = "../modules/rando"
count = 5
}
通过将迭代器应用到模块本身,我们能够实现与在模块中为每个资源添加count
并将资源数量作为输入变量传入模块相同的结果。然而,在模块内操作每个资源会变得更加困难。
当你设计模块以便由父模块重复使用时,你的模块不需要考虑父模块希望在集合中创建多少个资源的复杂性。每个模块实例只需关心每个资源的一个实例。
它是否以某种方式简化或扁平化资源,使得资源更容易使用?
如果你是从零开始,最好让这些模式随着时间的推移自然形成。方法中的代码本质上是具有强烈个人观点的代码。一旦你确定了一个模式,所需的只是销毁、重构和重新应用,你就可以使用你的新模块了。
销毁整个环境并重新开始并非总是一个可行的选择。这种方法只能在开发或测试环境中使用。在生产环境中,你需要采取不同的方法。
有时,你可以编写一个在多种场景中都能使用的方法。这种方法在开发框架代码时最为常见,尤其是当框架处理的是一个横向问题空间。但有时方法的设计是为了完成某些非常特定的任务。
同样的原则适用于 Terraform 模块设计。有些模块非常灵活,设计成框架化的,而另一些模块则更像是嘿,我想做这个特定的事情,并且想让它保持简单。对于场景驱动的模块,其接口会非常简单,因为它只是将依赖输入导入模块的作用域,模块所需的那些依赖,而模块自身在其作用域内没有这些依赖。
框架模块通常具有更复杂的接口;因此,它会有更多的杠杆,供模块使用者操控。有时,这些输入不再是简单的基本类型(string
、bool
、number
);它们是你需要构造并传入的复杂对象。随着模块支持的场景数量增加,模块的复杂性也会增加。你需要传入更多的参数来配置它。随着这些复杂对象的增加,传递它们会变得更加繁琐且容易出错,因为你可能需要使用局部变量实现更多的对象构造逻辑。
大多数 Terraform 提供者的资源都不需要你构造复杂的对象来使用。你将使用基本类型,有时是集合类型和嵌套块。
然而,在构建模块时,你确实可以创建复杂对象作为输入变量。你应该避免过于复杂的数据结构,因为它会增加复杂性。通常,资源之间的依赖关系较小。所以,如果你只需要小路径来连接两个对象,为什么要创建庞大的 数据传输对象(DTOs)来传递上下文呢?这样会让代码更易于理解和维护。未来的开发者和你的模块使用者会像在糟糕的传统软件中那样诅咒你的名字。
我曾见过一些软件,在这些软件中,方法是将所有内容都定义为字符串,而不是使用正确的原始类型,如 bool
和 number
。这样可以吗?当然可以。但这使得理解变得容易吗?是否会增加额外的复杂性,例如不断将输入值在字符串和其正确类型之间进行类型转换?你应该使用正确的类型并简化接口。
我们需要在使用复杂类型和模块输入变量过多之间找到平衡,因为过多的输入变量会影响环路复杂度,使得维护变得困难。然而,与其他语言不同,使用复杂对象时,HCL 的工作方式具有挑战性。在构建和转换大型复杂数据类型时,开发人员可能效率较低。HCL 在声明和关联资源时非常适合开发人员,尤其是通过将输出变量传递到输入变量。
使用模块
现在我们理解了在设计模块时的考虑因素,以及如何设计良好的模块,让我们来看看如何使用和管理模块,从小型场景驱动的模块到强版本控制的框架模块。
本地模块
本地模块可以在 Terraform 解决方案中最大化代码重用,而无需建立和维护单独的模块仓库。
使用本地模块来实现特定于应用程序的模式,例如架构中的组件或层,是组织 Terraform 代码的一个好方法。部署到云中的一个典型模式是活跃-活跃、多区域部署。在这种情况下,您应该设计模块来将应用程序部署到单一区域,然后使用 count
或 for_each
元参数将该模块部署到一组可配置的区域:
图 1.20 – 使用模块封装云平台单一区域中提供的资源
通过这种方法,你可以在根模块中创建负载均衡资源,将流量分配到各个区域的端点,同时在所需区域的多个实例中部署区域模块。
当当前项目中只使用该模块时,这种消费方式是理想的。这个场景可以在分层或多区域架构中体现出来。
远程仓库
使用外部模块是充分利用你架构中高度可复用模式的最佳方式。Terraform 允许你引用一个不存储在项目源代码仓库中的模块。引用远程模块最常见的方式是通过 Git 仓库。这种方法适用于任何 Git 兼容的仓库,从 GitHub 到 Azure DevOps 再到 GitLab。
将你的模块公开发布到开放的互联网上使得从任何源代码仓库(无论是公共的还是私有的)引用它们变得极为简单。然而,在一些企业场景中,公共仓库是不允许的——公司治理可能只允许私有仓库。在这些情况下,你必须选择一种身份验证机制,以便作为最终用户以及在你的流水线中访问这些模块。你可以使用 SSH 密钥或公共访问令牌来验证私有的远程 Terraform 模块仓库。
一旦你完成了对存储模块的 Git 仓库的身份验证,你就必须在源代码中引用该模块:
module "foo" {
source = "git::ssh://git@ssh.dev.azure.com/v3/{AZDO_ORG}/{AZDO_PROJECT}/{AZDO_REPO}//{MODULE_PATH}"
}
上述示例展示了如何引用托管在 Azure DevOps 上 Git 仓库中的特定模块。采用这种方法,你将访问 Git 仓库的默认分支,通常为 main
,并获取该分支的最新提交——这通常不是一个好主意。
正确的方法是为特定模块版本指定一个引用。当你使用 ref
查询字符串参数来指定 Git 仓库 URL 时,你可以定位到 Git 仓库中的特定标签、分支或提交:
module "foo" {
source = "git::ssh://git@ssh.dev.azure.com/v3/{AZDO_ORG}/{AZDO_PROJECT}/{AZDO_REPO}//{MODULE_PATH}?ref={AZDO_TAG}"
}
标签是保证特定版本的理想方法,因为在 Git 仓库中创建标签不需要更改分支策略。一旦你完成模块的测试,就可以推送一个标签,并放心地知道当你将该标签作为 ref
参数时,你总是能获取到该模块的确切版本。
Terraform 注册表
HashiCorp 提供了一种机制,供第三方模块发布者分发他们的模块。这个仓库可以通过 registry.terraform.io
访问,并且在一个公开可访问、稳定且版本化的环境中,存放了大量的 Terraform 模块。当你在此发布模块时,必须满足特定要求,以便你和其他人可以通过简单的名称和版本来引用该模块:
module "caf" {
source = "aztfmod/caf/azurerm"
version = "~>5.5.0"
}
Terraform 模块注册表最终在后台使用 GitHub,因此你实际上是在引用 GitHub 仓库中的模块。然而,它允许你使用简化的模块名称和版本,而无需 GitHub 仓库的额外复杂信息。
现在我们知道如何使用模块构建更易管理的 IaC 解决方案,并理解模块可以在不同的上下文中提供不同的功能,接下来我们将更好地理解 CLI,以便我们可以围绕 Terraform 构建自动化,将其集成到我们的发布管道和 CI/CD 过程中。
理解如何有效使用 CLI
现在我们已经了解了 Terraform 的核心架构,接下来我们将研究其 CLI 以及如何与之互动。有很多不同的命令,但我们将重点讨论实现核心 Terraform 工作流的重要命令。我建议你探索 HashiCorp 的文档,以了解一些更冷门的命令,稍后在 第十七章 中,当我们讨论如何使用 Terraform 管理现有环境时,我们将介绍一些在此上下文中有用的命令。
init
这是一个重要的命令,可能是你在使用 Terraform 时执行的第一个命令。原因在于,Terraform 是在工作目录中运行的,而不像其他工具(例如 ARM 或 CloudFormation)在单个文件上操作,或者像 Ansible 在入口文件上操作。Terraform 还依赖于隐藏目录来加载关于工作空间的重要上下文。这种方法与 Git 在克隆仓库时的工作方式非常相似。因此,我们必须允许 Terraform 设置一切,使其所需的所有东西都在正确的位置并使其能够正常运行。terraform init
命令正是执行这一操作:
terraform init
Terraform 初始化命令完成了几项工作:
-
提供程序安装
-
模块安装
-
后端初始化
提供程序安装
首先,它分析目录并搜索提供程序声明,下载并安装这些提供程序。它不会连接到这些提供程序,所以成功的 init
过程并不表示你的提供程序凭证是有效的。它表示你指定的提供程序及其特定版本存在,并且已被安装。作为 Terraform 的扩展,每个提供程序只是一个 Terraform 与之交互的 Golang 可执行文件。因此,Terraform 需要下载并将该可执行文件放置在某个位置,以便在需要时执行它。
每个提供程序的二进制文件会被下载并存储在 init
过程创建的隐藏目录中。这些隐藏目录及其内容使得其他 Terraform 操作能够正常运行。然而,这些文件并不需要特殊保护,所以如果你不小心删除了它们(或者故意删除),也不必过于担心。要恢复它们,只需重新运行 init
,Terraform 会像之前一样重新生成这些文件。
模块安装
第二,Terraform 会分析工作目录并搜索代码库中的模块声明。然后,它会从各自的源位置下载并安装这些模块。无论你是使用相对路径还是远程的 GitHub 仓库引用模块,都无关紧要;Terraform 会下载该模块文件夹的本地副本,并将其存储在 Terraform 执行时使用的隐藏目录中。与提供程序二进制文件类似,这些模块文件必须存在,才能确保将来 Terraform 操作的成功。同样,像提供程序二进制文件一样,这些文件无需保护,因为 Terraform 会通过一次 terraform init
调用自动带回它们。
如果你正在开发可复用的模块,你很可能同时在一个根模块中使用这些模块来进行测试。你在根模块的文件夹中运行 terraform init
,而根模块会引用你的可复用模块。需要注意的是,如果你修改了模块,仅仅重新运行 init
并不会自动带入这些更新。如果模块引用的版本没有变化,Terraform 会检查加载模块的文件夹,看到它已经下载了该模块的版本。要强制重新下载模块的副本,你需要增加模块的版本(在模块开发期间,这可能会显得繁琐),或者通过从 .terraform
目录中手动删除模块来清空它们。
后端初始化
最后,Terraform 会在工作目录的 .tf
文件中的 terraform
块里寻找一个 backend
块。大多数后端需要一些配置设置才能工作。最终,Terraform 后端提供了一个存放 Terraform 状态文件的位置,因此这些配置设置会引导 Terraform 后端如何找到 Terraform 状态文件。
例如,要使用 ARM 后端,你必须指定一种方式来三角定位到正确的 Azure Blob 存储账户容器状态文件。Terraform 在到达目标状态文件位置的过程中会经过多个标志:首先是存储账户所在的资源组,然后是存储容器所在的存储账户,再接着是状态文件所在的存储容器,最后是状态文件的名称,Terraform 通过 key
值和当前的 Terraform 工作区名称来定位它。
完整的 Azure Terraform 后端配置会使用 key
值和当前的 Terraform 工作区名称。
完整的 Azure Terraform 后端配置如下所示:
terraform {
backend "azurerm" {
resource_group_name = "rg-foo"
storage_account_name = "stfoo"
container_name = "tfstate"
key = "foo.tfstate"
}
}
Azure 后端将使用resource_group_name
、storage_account_name
和container_name
来找到 Azure 上存储文件的位置。然后,key
和工作区名称将用于制定状态文件的名称。如果你使用的是默认工作区,那么状态文件的名称将是key
的值。然而,如果你使用一个命名工作区,Azure 后端将生成一个类似foo.tfstate:env:prod
的状态文件名称,表示一个名为prod
的工作区。
每个 Terraform 后端插件都有不同的策略来读取和写入状态文件,以及生成最终存储状态文件的状态文件名的逻辑。了解你的提供者、可用的后端以及如何配置它是至关重要的。
验证
terraform validate
是一个有用的方法,实际上它是最接近编译器的东西。它分析作用范围内的所有代码文件,验证引用和语法。如果有任何对数据源或资源的引用断裂,运行此命令将帮助你在无需初始化后端的情况下找到它们。因此,validate
命令是一个有用的命令,作为早期警告,用于在你继续其他步骤之前检测代码中的任何问题。
工作区
terraform workspace
是关于创建同一个 Terraform 解决方案的分支,以便拥有不同的实例或分支的 Terraform 状态。就像在源代码中一样,当你创建一个分支时,目标是修改代码,这些修改将长期保持。因此,你可能永远不会将新分支的代码库合并到main
分支。
不管你是否意识到,你正在使用 Terraform 工作区。只是你没有使用自定义命名的工作区。你可以通过运行terraform workspace show
命令来发现这一点,它会显示default
。
为每个长期存在的环境创建一个新的工作区是一个好主意——即使你计划将后端进行分段。
运行terraform workspace new dev
将为你的开发环境创建一个新的工作区。你可以对生产环境运行相同的命令,例如terraform workspace new prod
。从此以后,任何利用状态的 Terraform 操作都将使用所选工作区的状态文件。你可以通过像这样切换工作区来在这些状态文件之间来回切换:terraform workspace select dev
或terraform workspace select prod
。
使用工作区时,你可能会创建一个工作区来测试某些内容,目的是最终在原始工作区中进行相同的更新。
工作区代表完全不同的环境,因为开发环境总是与测试、预发布或生产环境略有不同。这些环境将生活在孤立的工作区中,并在它们的状态文件中具有相同的隔离性。
共同的主题是工作空间基于相同的代码库。其思想是你将拥有相同的代码库,并利用它部署多个环境——很可能是长期存在的环境,但不一定如此。
计划
terraform plan
是一个只读操作,它需要访问你的后端状态,并要求你先执行 terraform init
。此外,如果你使用的是非默认工作空间,你应该在运行 plan
之前选择你的工作空间。terraform workspace select
命令可以让你做到这一点。
terraform plan
将执行一个只读操作,检查状态文件并与状态文件中的每个资源进行核对。这个过程可能需要一些时间,具体取决于状态文件中有多少资源以及提供程序与其交互的响应时间。因此,为了保持你的 Terraform 项目精简和高效,考虑将一个 Terraform 工作空间的作用范围限制在多大范围内。
如果代码块太大,你可以考虑将其拆分为子工作空间。我见过一些项目,其中一个完整的解决方案放在一个 Terraform 状态文件中,执行一个计划就需要 45 分钟。如果工作空间隔离过于宽泛,会变得非常痛苦,我强烈建议你考虑系统组件的边界,并组织你的 Terraform 工作空间,使其成为较小、半依赖的工作空间。工作空间之间有依赖关系是可以的,但你需要通过数据源来明确这些依赖关系,避免出现两个 Terraform 工作空间之间的循环引用问题。
Terraform 需要在运行 plan
操作之前设置所有输入变量。你可以通过三种方式完成:通过单个命令行参数、变量文件和环境变量。
单个命令行参数对于具有交互式命令行会话的小项目非常有帮助,但当环境变得更加复杂或你希望使用管道工具时,这种方法很快就变得难以管理——这是我们本书的主要讨论场景。
环境变量方法在管道工具方法中起着重要作用,因为它允许你在不修改命令参数的情况下执行 Terraform 命令。
应用
terraform apply
是工具集中的最关键操作。在执行之前,该命令需要先成功执行 terraform init
。选择与指定输入参数对应的正确工作空间也至关重要。
terraform apply
与其他操作相比也有其独特之处:你可以通过指向单个文件来执行它,而不是一个工作目录。terraform plan
命令输出计划文件。如果没有指定计划文件,terraform apply
会在 apply
阶段之前执行计划。
最佳实践是在执行 apply
时总是传入一个计划文件。这样做可以确保您在执行时没有任何意外。然而,仍然有可能在您上次运行 plan
和最终执行 apply
之间,环境中发生了变化。
这在多个团队成员可能对环境进行更改时尤为重要,无论是通过本地使用 Terraform 还是通过 CI/CD 管道。更改也可能通过云平台管理门户中的手动更改引入到 Terraform 之外。当您运行 terraform apply
时,使用 Terraform 计划文件将有助于确保您执行的计划与您在资源配置时的意图保持一致,并且利用了当时可用的最佳信息。
与 plan
一样,输入变量的值可以通过多种方式进行设置。
销毁
terraform destroy
是您可以完全删除整个环境的命令。具备此能力在您的解决方案跨多个目标平台的逻辑组或使用多个提供商时尤为有利。
逻辑容器删除
一些平台使管理相关资源的生命周期变得简单。例如,Microsoft Azure 要求每个资源在资源组内进行配置,在 Google Cloud Platform (GCP) 中,所有资源都在项目的上下文中进行配置。Azure 资源组和 Google Cloud 项目是您可以用来快速清理的逻辑容器,通过级联删除操作实现。缺乏此功能的平台则会使清理工作变得非常繁琐,比如在 AWS 中,您必须导航到许多不同的门户页面,以确保删除所有资源。精明的命令行高级用户可以通过精心设计的标签方案将清理脚本串联起来。然而,像 Terraform 这样的工具在通过单一命令删除您所配置的所有资源时,提供了极大的价值。
跨平台删除
即使在具有逻辑容器的云平台上,为了共同管理相关资源的生命周期,您仍然需要处理在其他系统或平台中配置的关联资源。
总结
在本章中,我们深入探讨了 Terraform 的架构,重点关注两个关键的架构组件:状态和模块化。充分理解 Terraform 的架构对您有效使用 Terraform 至关重要。最后,我们还介绍了 Terraform 的命令行接口(CLI),这将使您在准备好后能够将 Terraform 与您自己的 CI/CD 管道集成。在下一章中,我们将探索 HCL,以便为使用 Terraform 构建基础设施即代码(IaC)奠定基础。
第二章:2
使用 HashiCorp 配置语言
在第一章中,我们研究了 Terraform 的架构。它是一个简单的命令行工具,接收代码并创建一个计划,之后可以根据用户的要求执行。在这一章中,我们将研究如何利用 Terraform 的语言——HashiCorp 配置语言(HCL)——来定义基础设施作为代码,以便我们可以使用 Terraform 构建复杂的云架构。
本章涵盖以下主题:
-
资源和数据源
-
本地变量和类型
-
变量和输出
-
元参数
-
循环和迭代
-
表达式
-
函数
资源和数据源
资源和数据源在 Terraform 中扮演着至关重要的角色,它们可能是最重要的语言构造,理解它们非常关键,因为它们允许你访问现有资源并创建新资源。
资源
资源是你在 HCL 编程时最常使用的块。resource
块就是 Terraform 的核心。你可以将每个资源视为 Terraform 将在现实世界中配置的某物的数字双胞胎:
resource "random_string" "foobar" {
length = 4
upper = false
special = false
}
块的定义包含三个部分:resource
,资源类型是 random_string
,引用名称是 foobar
。为了在资源之间创建依赖关系,我们使用引用名称和类型来访问资源的输出值:
resource "azurerm_resource_group" "foobar" {
name = "rg-${random_string.foobar.result}"
location = var.location
}
在前面的代码中,我们通过引用 foobar
随机字符串的输出值 result
来创建一个 Azure 资源组。
每个 Terraform 提供者中的资源都是一个小型的半独立计算机程序,旨在管理特定的底层系统架构。这些资源定义了一个架构,允许你控制这些底层组件的配置。有时,这个架构是简单的;而有时,它可能非常复杂,由原始类型属性和额外的自定义块定义组成,嵌套在资源块中。
这些嵌套块允许你在一个资源中声明一个或多个子资源。资源决定了它期望的每种类型的嵌套块的数量。有时,资源允许多个相同的嵌套块实例,而有时,它们可能只允许一个。
例如,Azure Cosmos DB 服务允许你创建超大规模的 NoSQL 数据库,并迅速在多个地理位置之间设置复制。每个地理位置都是 Cosmos DB resource
块中的嵌套块:
resource "azurerm_cosmosdb_account" "db" {
name = "cosmos-foobar"
location = azurerm_resource_group.foobar.location
resource_group_name = azurerm_resource_group.foobar.name
offer_type = "Standard"
kind = "MongoDB"
consistency_policy {
consistency_level = "Eventual"
}
geo_location {
location = "westus"
failover_priority = 0
}
geo_location {
location = "eastus"
failover_priority = 1
}
}
正如你所看到的,geo_location
块在 azurerm_cosmosdb_account
块内被重复多次。每个 geo_location
嵌套块的实例告诉这个 Cosmos DB 账户在哪里复制 MongoDB 数据库,以及故障转移优先级。
数据源
从最基础的形式来看,Terraform 主要是用来配置资源的,但正如我们所看到的,它远不止这些。一旦 Terraform 配置了资源,接下来会发生什么?当你通过其他方式配置资源时会发生什么?你还能从 Terraform 中引用它吗?资源创建的是新的东西,而数据源访问的是已经存在的东西。
数据源虽然不如资源普遍,但仍然扮演着关键角色。首先,它们允许你引用在当前 Terraform 工作区外部配置的资源,无论它们是如何配置的——通过 GUI、其他自动化工具或其他 Terraform 工作区:
data "azurerm_resource_group" "bar" {
name = "rg-foo"
location = "westus"
}
和资源一样,数据源块的定义有三部分:块类型、资源类型和引用名称。在上面的示例中,块类型是 data
,资源类型是 azurerm_resource_group
,引用名称是 bar
。为了在资源和数据源之间创建依赖关系,我们使用引用名称和类型来访问数据源中的输出值,就像我们在资源中做的那样,但我们还需要在引用前加上 data
,以明确告诉 Terraform 该引用的是新项还是现有项:
resource "azurerm_storage_account" "fizzbuzz" {
name = "stfizzbuzz"
resource_group_name = data.azurerm_resource_group.bar.name
location = data.azurerm_resource_group.bar.location
account_tier = "Standard"
account_replication_type = "GRS"
}
在上述代码中,我们通过引用 bar
Azure 资源组的输出值(name
和 location
)来创建一个 Azure 存储账户。
现在我们已经理解了 Terraform 负责的核心组件(资源和数据源——新东西和旧东西),让我们来看一下我们将用于内部和外部数据结构的数据类型。
局部变量和类型
在资源和数据源之后,下一个最重要的内容是如何使用局部变量,这使我们能够在 Terraform 解决方案中创建内部变量和类型,以便操作数据。
局部变量
Terraform 允许你对多种类型执行复杂的操作。有时,有必要使用中间值来存储计算出的值,以便在整个代码库中进行引用。理解如何做到这一点,以及在模块内部处理内部数据时和在定义 Terraform 模块之间契约时可用的数据类型,至关重要。
locals
块允许你声明局部变量。你可以把它们想象成类中的成员变量或函数中的局部变量,只不过它们在 Terraform 工作区的扁平化作用域中合并成一个构造。
你可以通过声明一个 locals
块,并在其中声明和定义局部变量,在 HCL 代码中的任何位置定义一个局部变量。在声明局部变量时,你必须指定一个值:
locals {
foo = "bar"
}
上面的代码声明了一个名为foo
的局部变量。Terraform 通过使用双引号来推断该类型为 string
。
你可以在任何 .tf
文件中声明任意数量的 locals
块。像其他语言一样,你可以将局部变量嵌套在其他局部变量的值中。你可以通过使用 local
对象前缀来做到这一点。使用元素的类型从代码中的其他地方引用它,类似于引用资源和数据源:
locals {
foo = "foo"
bar = "bar"
foobar = "${local.foo}${local.bar}"
}
记住可能有些棘手,但局部变量总是以复数块名称(locals
而不是 local
)声明,并在单数中引用,local.*
。复数和单数术语的混合可能会显得奇怪,因为 Terraform 中的大多数块是以单数块声明并以单数形式引用的。
原始数据类型
由于设计原因,HCL 仅支持有限数量的数据类型。这个设计鼓励代码简洁,并避免在类型转换上使用过于复杂的逻辑。原则上,你应该避免在 HCL 中进行复杂逻辑运算,依赖 Terraform 模块化架构中内建的一致输入输出模型,在 Terraform 之外完成繁重的工作,然后传递已知有效的值作为输入,并使用支持的类型。
只有三种原始数据类型:string
、number
和 bool
。
字符串
虽然有 number
和 bool
,它们的使用或功能并不复杂。然而,string
很容易变得非常复杂。如果你在 GitHub 上查找 HCL 代码,会发现代码中通常嵌入了复杂的字符串操作。仅仅因为你能做,并不意味着你应该这么做。这就是方式。
尽量避免复杂的字符串操作,必要时将其封装成局部值,以便在运行apply
之前,方便测试输出。
字符串插值
string
对象:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags {
Name = "${var.application_name}-${var.environment_name}"
}
}
在前面的例子中,我们传入两个变量并将它们连接起来构造 Name
标签,这是 AWS 控制台常用的标签:
locals {
name = "${var.application_name}-${var.environment_name}"
}
或者,我们可以声明一个构造名称的local
,并直接使用local
的值来设置标签:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags {
Name = local.name
}
}
它简化了我们 AWS VPC 的资源块,通过消除了字符串插值语法(由两个双引号 "
和两个插值块 ${ ... }
组成)。它还创建了一个可复用的 local
,我们可以用来一致地标记我们的资源。这能提高代码的可读性,最终提升代码的可维护性,特别是当字符串需要在多个资源中重用时。
跨越多行的字符串
根据使用的提供者和资源,有时必须嵌入跨越多行的大字符串。Terraform 使用heredoc
风格来声明多行字符串:
locals {
shopping_list = <<EOT
apples
oranges
grapes
bananas
EOT
}
字符串内容可以是结构化的,也可以是非结构化的。如果 HCL 支持字符串的结构,您应该考虑使用本地语法来表示内容。嵌入 HCL 中的结构化内容最常见的形式是 JSON 或 YAML 格式的字符串,因为许多云平台的服务定义了其配置架构,您必须以这两种格式之一提供。在这种情况下,您应该使用jsonencode
或yamlencode
函数将本地 HCL 声明的对象转换为适当格式的字符串。后续章节将进一步介绍此内容。
另一个考虑因素是是否有充分的理由保持内容的原始字符串格式。以下是一些可能需要保持原格式的情况:
-
太大:如果内容庞大,且转换为 HCL 相当繁琐,进行转换可能并不具备成本效益。
-
file
函数。
集合类型
HCL 只支持两种集合类型:list
和map
。
List
如果您在使用 Terraform 之前有过开发经验,或者在编程时使用过 HCL,您一定对数组的概念非常熟悉。每种编程语言使用不同的语法、类型和类来表示这一概念。列表是一个有序的对象列表,可以通过列表中项的索引来访问该项:
-
string[] array = { "westus", "``eastus" }
-
array := [``4]string{"westus", "eastus"}
-
array = ["``westus", "eastus"]
-
var array = ["``westus", "eastus"];
-
array = ["``westus", "eastus"]
你注意到与我们所看过的语言有任何相似之处吗?Python 无疑是最相似的语言。JavaScript 紧随其后,只是多了一些分号等。
正如预期的那样,HCL 列表中的项在这些语言中具有相似的访问方式:
a = array[1]
a
的值将是eastus
。
list
对象类型在需要为列表中的每个项配置一个对应资源的场景中非常有用。
Map
类似于数组的概念,HCL 中的map
对应着其他编程语言中许多名称所指的另一种常见集合类型。从Dictionary
到KeyValuePair
再到map
。它们都共享一个概念:键——在集合中是唯一的——与对应的值相关联。要查找值时,您不通过其在集合中的索引来访问它,而是通过key
值来访问:
networks = {
"westus" = "10.0.0.0/16"
"eastus" = "10.1.0.0/16"
}
当我们希望访问特定网络的值时,我们指定所使用的区域名称:
a = networks["westus"]
a
的值将是10.1.0.0/16
。
这个解决方案仅在集合的键是唯一时有效。在前面的示例中,这允许我们快速为我们选择的每个区域设置一个网络;然而,按照当前设计,如果我们想为同一区域设置两个网络,便无法做到:
networks = {
"westus" = "10.0.0.0/16"
"eastus" = "10.1.0.0/16"
"eastus" = "10.2.0.0/16"
}
我们不能这么做,因为为任何区域添加第二个条目会导致错误。这个错误非常隐蔽。当我们尝试在我们的map
中访问eastus
的值时,只有最后匹配的条目会返回。因此,结果就像是10.1.0.0/16
并不存在一样。代码中与 Terraform 配置之间的这种不一致可能导致困惑和预期不一致,因此请记住,在使用map
时,应该仅在键是唯一的情况下使用它。
复杂对象
HCL 不是面向对象的;因此,它不像其他语言那样有定义类的机制来表示复杂类型。然而,它支持复杂对象。它使用动态类型,这意味着 Terraform 会在运行时评估对象的类型。
动态类型既是福也是祸。它是福,因为我们不需要遵守严格的对象结构。如果我们需要另一个属性,可以迅速添加它。它是祸,因为这意味着我们需要一个官方的结构定义来源,并且在重构从一个模块或资源传递到另一个模块或资源的对象结构时,我们不得不依赖引用追踪。
network_config = {
name = "westus"
network = "10.0.0.0/16"
}
定义一个对象就像声明对象引用并将其设置为一个块的值一样简单——这个块由{
和}
符号表示。这个块可以包含任何数量的原始类型、集合或复杂对象。
现在我们理解了 Terraform 识别的数据类型以及如何使用它们构造内部local
变量,我们可以从外部来看看如何定义我们模块与外部世界之间的数据契约:即它的输入和输出。
输入与输出
接下来,让我们看看如何使用输入变量和输出将数据传入和传出 Terraform。
输入
正如我们在上一章中学习的那样,Terraform 在模块的上下文中运行。这个模块在物理上被限定在一个目录内。根模块是一个特殊的情况,它的目录与 Terraform 的工作目录相同。无论你是在写根模块还是其他模块的 HCL,你都在模块的上下文中进行编写;因此,你需要考虑如何将数据传入模块以及如何将数据从模块传出。
输入变量是一个重要的设计考虑因素,因为 Terraform 模块是简单的机器,它们接受输入并生成输出。
输入完全是关于模块需要什么信息来进行配置,这些输入可以来自任何地方。在设计输入时,它们应该是原子的。
模块应该能够接收输入,而无需任何额外的操作或逻辑来解析输入变量的值。你应该在模块之外解析值。这并不意味着输入不能是复杂对象或集合,但保持接口尽可能简单是个好主意。输入越复杂,你的模块也会越复杂——无论是根模块还是你在工作区间共享的可重用模块。
你的根模块可能具有最复杂的输入。考虑根据如何将参数注入到变量中的方式,来设计根模块的输入结构。如果你使用的是以 HCL 编写的变量文件,使用跨越多行的复杂类型,无论是列表、映射还是复杂对象,都非常简单。然而,如果你计划使用Linux 环境变量或命令行参数的方法来传入参数,那么你应该重新考虑使用复杂对象作为输入,因为它们可能会导致故障排除变得困难,并且很难验证你是否将正确的值传递到了该输入中。
当你使用 Linux 环境变量时,记住它们并不适合存储具有复杂模式的结构化数据是很重要的。你在 Linux 环境变量中看到的最复杂的模式是某些分隔文本。PATH
就是一个很好的例子,它是一个分隔文本值。你可以使用分隔文本值作为输入变量来简化值的注入。缺点是,你必须在将值传入 Terraform 后进行解析。
对于根模块来说,这可能是理想的,因为它减少了两个工具(Terraform 和其他可执行文件)接口的复杂性。这种集成在自动化流水线中很常见,其中独立的流水线任务执行不同的工具,并将一个工具的输出传递到另一个工具的输入中。从命令行界面将值传递到 Terraform,类似于我们将一个模块的输出传递到另一个模块的输入。然而,这在 Terraform 中更加无缝,因为可以使用 HCL 来传递值。相比之下,使用命令行工具时,你需要额外的解析步骤,将值转换为 Terraform 可以快速处理的所需格式:
variable "foo" {
type = string
description = "This is the value for foo. It is needed because 'reasons'. Its value must be less than 6 characters."
}
在上述代码中,我们声明了一个输入变量 foo
,类型为 string
,并在 description
中向模块的用户提供了关于如何使用该输入变量的指导。
敏感数据
有时,你可能需要输入敏感数据,如密码、连接字符串或访问密钥。你必须对输入变量进行标注,以确保 Terraform 不会在其输出中显示它们,因为这可能会导致通过 Terraform 发出的操作日志泄露秘密:
variable "super_secret_password" {
type = string
description = "Password that I get from somewhere else"
sensitive = true
}
在上述代码中,我们使用 sensitive
属性标注了我们的超级机密密码,以防止 Terraform 输出该机密。
可选
在构建可以支持多种场景的模块时,你通常需要提供输入变量,以支持每个场景的数据需求。每个支持的场景可能只需要指定输入变量的一个子集。在这种情况下,我们应该将输入变量设置为可选。
对于原始类型,你可以通过将默认值设置为null
来简单地实现这一点:
variable "totally_optional_field" {
type = string
description = "Yes, No, or Maybe"
default = null
}
在前面的代码中,我们将default
设置为null
,允许用户完全忽略此输入变量。
当处理复杂对象时,将输入变量设置为可选可能更加复杂,因为我们可能希望整个对象或其属性都为可选。
请考虑以下代码:
variable "person" {
type = object({
first_name = string
middle_name = string
last_name = string
})
}
在前面的代码中,我们声明了一个名为person
的变量。不幸的是,它不仅是一个非可选的输入变量,而且person
对象上的每个属性都必须被指定。
让我们看看是否能放宽一些限制:
variable "person" {
type = object({
first_name = string
middle_name = optional(string)
last_name = string
})
default = null
}
在前面的代码中,请注意,添加default = null
到person
变量块,允许使用此模块的用户完全忽略此输入变量。此外,如果用户提供了person
对象,则middle_name
属性不是必需的。现在,支持的输入对于用户来说更加灵活。
以下值将整个对象设置为null
:
person = null
以下值将输入变量设置为对象,但省略了middle_name
属性:
person = {
first_name = "Keyser"
last_name = "Söze"
}
最后,我们将输入变量设置为对象,并为所有属性指定值:
person = {
first_name = "Keyser"
middle_name = ""
last_name = "Söze"
}
多亏了在输入变量声明中包含了default
和optional
,所有这些都是我们模块有效的参数值。
验证
在创建你将在组织中更广泛使用的模块时,考虑在模块的输入中添加一些基本的验证。validation
块提供了一种方法,可以对传入的输入变量值进行基本的数据验证。
添加验证可以显著减少应用时的失败,特别是当输入值有来自底层提供者的要求时,而这些要求可能无法通过模块的界面显现出来。例如,云平台可能对资源强制执行命名约定,如只能使用字母和数字、全部小写、少于 30 个字符等等。除非使用者了解平台特定的限制,否则他们在尝试弄清楚如何提供正确的输入值时,可能会遇到困难——尤其是当你的模块以某种方式抽象了底层云平台的资源时:
variable "name" {
type = string
description = "Name of the thing"
validation {
condition = length(name) < 30
error_message = "Length of name must be less than 30"
}
}
在前面的代码中,我们指定了一个条件,检查输入变量的长度是否小于30
。我们可以使用任何有效的布尔表达式。如果返回true
或false
,都可以。我们可以使用 Terraform 支持的任何函数。然而,在 Terraform 1.9.0 版本之前,我们只能引用定义validation
块所在的变量——这意味着我们无法引用其他变量来创建复杂的多变量输入验证,也无法使验证基于 Terraform 中声明的其他元素(无论是其他变量、资源、数据源、局部变量等)有条件。这一切在 Terraform 1.9.0 版本中发生了变化,输入变量验证可以引用模块内的其他块。这使得你可以使用局部变量和数据源等来实现更加复杂的验证,帮助验证输入。
输出
输出变量也是一个至关重要的考虑因素。只输出你需要的值非常关键。你应该避免输出不必要的内容——这一点在根模块中比较容易遵循,但在编写可重用模块时会更难,因为你更难预测模块的使用者会需要什么:
output "foo" {
value = "bar"
description = "This is my output for foo"
}
在前面的代码中,我们声明了一个名为foo
的输出,它返回一个常量值bar
。尽管这个示例可以更具实际意义,但它的简洁性具有教学意义。也就是说,输出值可以是 HCL 中的任何有效表达式。我特别提到这一点,因为你无疑会遇到许多示例,它们输出资源上的某个属性,但输出块远比这强大。结合 HCL 中可用的所有工具——我们将在本章后面详细介绍——你可以构建任何你需要的值。了解这一能力对于帮助你平滑地整合 Terraform 和其他工具至关重要。
为了告知模块的使用者(无论是通过命令行工具使用模块的用户,还是通过其他 Terraform 模块使用的用户)你打算输出的内容及其用途,并且告知他们可以期待的数据类型,建议给输出加上description
属性进行注释。
敏感数据
有时,你可能需要输出敏感数据,例如密码、连接字符串或访问密钥。大多数资源都会定义哪些属性被视为敏感,因此 Terraform 如果需要,会发出警告:
output "super_secret_password" {
value = "NewEnglandClamChowder"
is_sensitive = true
}
在前面的代码中,我们使用is_sensitive
属性来标注我们超级秘密的密码,告知 Terraform 这不是我们希望它大声公布于世的数据!
现在我们了解了如何在 Terraform 模块中声明输入和输出,以及我们经常使用的基本结构——资源、数据源和本地变量——我们可以继续学习语言中的一些更复杂的结构。我们的第一站是元参数。听起来很有趣,对吧?
元参数
元参数 是你可以在任何资源块上设置的属性。它们允许你控制与资源相关的不同方面,如上下文、依赖关系和生命周期。每个元参数都允许更精细的控制,开发者可以向 Terraform 提供非常专注的资源特定指令。
提供者
provider
元参数是一个引用,它允许你指定在哪个提供者上下文中部署资源。上下文的范围取决于你使用的提供者。最常见的两个范围是你要部署到的公共云区域和你正在使用的认证凭证。
你需要理解你所使用的提供者的特定作用域机制。本书将使用 aws
、azurerm
和 google
提供者。每个提供者定义的作用域不同。AWS 提供者的作用域是 AWS 账户和 AWS 区域。GCP 提供者的作用域是 GCP 项目和 GCP 区域。Azure 提供者的作用域仅限于 Azure 订阅:
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "secondary"
region = "us-west-1"
}
然后,在附加到资源时,如果你没有指定 provider
元参数,所有你声明的资源将由 Terraform 使用默认的 aws
提供者进行部署:
resource "aws_instance" "foo" {
}
当你想要将资源部署到 aws
提供者的 secondary
实例时,你需要在 resource
块中使用 provider
元参数进行声明:
resource "aws_instance" "bar" {
provider = aws.secondary
}
因此,当使用 AWS 和 GCP 进行多区域部署时,你会看到 provider
元参数用于将资源部署到不同的 AWS 和 GCP 区域。然而,在 Azure 上,你只会看到 provider
元参数用于跨多个订阅进行部署——这是一种非常独特的部署方式。
依赖于
有时候,当 Terraform 进行计划时,它需要帮助来正确获取依赖图。这是因为 Terraform 只能检测显式依赖关系——但有时,依赖关系是隐式的,取决于提供者和该提供者下的资源。这些隐式依赖关系发生在某个资源需要另一个资源时,但资源之间没有直接关系:
图 2.1 – Key Vault 密钥和 Key Vault 访问策略之间的隐式依赖关系
一个很好的例子是,当你使用 Azure Key Vault 时。在创建机密之前,你需要在密钥保管库本身中获得权限。因此,默认情况下,运行 Terraform 的身份无法在它刚创建的密钥保管库中创建机密:
resource "azurerm_key_vault" "top_secret" {
name = "kv-top-secret"
sku_name = "standard"
}
resource "azurerm_key_vault_secret" "foo" {
name = "foo"
value = "bar"
key_vault_id = azurerm_key_vault.top_secret.id
}
上述代码会失败。你需要显式地授予 Terraform 所运行的身份访问 Key Vault 的权限,以便创建机密。你可以通过添加一个 访问 策略资源 来实现这一点:
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault_access_policy" "terraform" {
key_vault_id = azurerm_key_vault.top_secret.id
tenant_id = data.azurerm_client_config.current.tenant_id
object_id = data.azurerm_client_config.current.object_id
secret_permissions = [
"Get", "List", "Set" ]
}
不幸的是,在添加了这个访问策略资源后,我的代码仍然无法正常工作。当我运行 apply
时,它会告诉我代码中出现了问题!因为机密和访问策略之间没有显式的依赖关系,Terraform 认为它们可以并行创建——从而在 Terraform 尝试创建机密时导致竞争条件并最终失败。
因此,我们需要确保定义机密对访问策略的依赖关系,这样 Terraform 才知道它需要在尝试创建机密之前先创建访问策略:
resource "azurerm_key_vault_secret" "foo" {
name = "foo"
value = "bar"
key_vault_id = azurerm_key_vault.top_secret.id
depends_on = [azurerm_key_vault_access_policy.terraform]
}
通过显式声明机密和访问策略之间的依赖关系,我们将在我们的 Terraform 计划中解决它们之间的竞争条件问题。
生命周期
lifecycle
元数据参数是可以出现在任何资源块中的一段代码,用于改变该资源的生命周期控制——即资源的创建与删除——诞生与消亡。每个选项允许你处理那些希望 Terraform 按照不同于通常行为的方式处理的边缘情况。
创建先于销毁
这种情况让我想起了经典电影场景《印第安纳·琼斯与失落的宝藏》中的一幕,印第安纳在一个布满陷阱的秘鲁神庙里。为了获得金色雕像,他必须用某个东西替换它。首先,印第安纳需要制作一袋泥土来替换金色雕像。然后,他必须小心翼翼地将金色雕像替换成这袋泥土。我们英雄印第安纳所面临的情境就是 create_before_destroy
的精髓。在这种情况下,你需要在销毁旧物之前先获得新物。这个场景有很多例子,适用于证书、安全组等。
锁定资源
对于某些资源,你可能不希望冒险让一个不够小心的管理员在没有彻底分析 terraform plan
的情况下销毁它们——这种情况不幸地很常见。在这种情况下,prevent_destroy
就派上用场了。通过将这个元数据参数添加到你的资源中,你就添加了另一个用户必须明确删除的门槛,才能销毁该资源。许多云服务原生支持此功能,但并非所有云服务都支持。因此,Terraform 允许你在 HCL 代码中为任何对环境健康至关重要或可能包含状态数据的资源启用此功能,这些数据在销毁时会丢失。
忽略更改
有时,Terraform 是多个在同一环境中操作的工具之一,或者你希望在部署后手动管理某个特定功能。在这种情况下,ignore_changes
选项允许你用 Terraform 配置资源,但忽略 Terraform 外部做出的更改:
lifecycle {
ignore_changes = [
tags
]
}
忽略更改是相对于你定义的 resource
块的对象引用数组。你可以想象在列表中的任何引用前都有 this
。如果尝试引用外部资源,你将会收到错误。
替换触发条件
许多 Terraform 资源已经知道在什么情况下应触发 Terraform 替换资源。通常,触发替换的原因是某个关键属性的值发生了变化。然而,有时你希望在更新或替换另一个资源时触发 Terraform 替换资源。通常,这种情况发生在资源之间没有直接依赖关系时,这使得 Terraform 难以判断是否发生了破坏性操作。
例如,对于 Azure 虚拟桌面主机池,主机池及其 虚拟机(VMs)是独立声明的。但它们与一个名为 VM 扩展的第三方资源相关联,VM 扩展会启动虚拟机与主机池的连接。在这种情况下,Terraform 知道虚拟机与 VM 扩展的关系,以及 VM 扩展与主机池的关系。然而,由于你通过 VM 扩展中的一个属性来创建主机池与虚拟机的关系,如果该属性被更新,也不会触发替换。因此,如果虚拟机发生变化,它将不会重新连接到主机池:
图 2.2 – 主机池与虚拟机之间的隐式依赖关系阻止 Terraform 在主机池变化时替换虚拟机
现在我们理解了如何通过元参数修改 Terraform 的默认行为,我们可以更好地处理与云资源相关的边缘情况,无论使用哪个提供商。接下来,是时候通过学习如何将 Terraform 转变为资源工厂,从而批量生成复杂的配置,而无需复制粘贴所有内容了!
循环和迭代
在 HCL 中有三种不同的迭代方式。最常用的是两个元参数 for_each
和 count
,它们作用于资源、模块或数据源块。同时,第三种选项是使用 for
表达式,它作用于任何集合。
数量
count
元参数是 Terraform 最早的资源迭代方法:虽然是老方法,但依然非常好用。count
元参数非常适合当你需要多次创建相同的资源块,并且没有唯一标识符作为依据时。在这种情况下,你将使用列表中的项的索引来确定其唯一性。如果列表中的项发生变化,从而导致每个项的索引发生变化,可能会带来挑战。
管理此问题的最佳方法是将你的列表视为仅追加(append-only)的方式,因为这样可以避免替换相关的资源。从列表中间添加或删除项目将导致该项下方的所有项目其索引发生变化,进而导致销毁和重建。
例如,如果你想配置一个五节点集群,当你缩减节点时,你不会从集群中删除一个特定的节点,而是减少节点的数量。你不在乎删除哪些节点,只在乎节点的数量。在这种情况下,使用 count
是理想的选择:
resource "aws_instance" "node" {
count = var.node_count
# the rest of the configuration
}
每个
count
的替代方法是 for_each
元参数,它允许你从 map
集合中创建多个块。与 count
技术相比,这种方法有显著的改进,因为集合中项目的顺序并不重要,只有键才重要。如果你更新代码以删除某个键,Terraform 将删除相应的项。如果该项与集合中的其他项交换了顺序,它不会影响 Terraform 的计划。
这种方法仅适用于 map
类型的集合,因为对于 map
集合类型,每个项目必须有一个唯一的键来标识它。
因此,使用 for_each
在部署到多个区域时效果很好,因为通常情况下,你不会在同一地区进行多个部署;因此,区域名称作为驱动 for_each
循环的 map
的唯一键非常适用。你可以添加或删除区域而不必担心移动集合中项目的索引。
locals {
regions = {
westus = {
node_count = 5
}
eastus = {
node_count = 8
}
}
}
考虑前面的 map
配置。使用它作为集合,我们可以驱动任意数量的资源、数据源或模块:
module "regional_deployment" {
for_each = local.regions
node_count = each.value.node_count
# the rest of the configuration
}
在前面的代码中,我们看到我们将 for_each
源设置为存储在 local.regions
中的 map
。然后,我们可以在模块块中的任何位置使用 each
前缀来访问键或值,分别使用 each.key
和 each.value
。无论值的类型如何,我们都可以像平常一样通过 each.value
来引用对象。
for
表达式
for
表达式是 Terraform 中一种迭代方式,它不需要你将其附加到一个块(例如,资源、数据源或模块)中。你可以使用 for
表达式在内存中构造对象,并应用对象转换来简化基于块的迭代或用于输出。
遍历一个列表
在遍历 list
时,你必须只为 for
表达式指定一个参数。这个参数将代表你 list
中的每个项,以便你可以在输出块中访问每个项:
region_names_list = [
for s in var.regions :
upper("${s.region}${s.country}")]
在前面的例子中,我们遍历了 var.regions
中的所有对象。在每次迭代中,当前的值可以通过 s
参数访问。我们可以使用输出块生成我们希望在这个 for
表达式创建的新列表中创建的任何对象。
遍历一个 map
在迭代一个map
时,必须改变for
表达式的结构。你需要在for
关键字后立即指定两个参数,而不是一个:
region_array_from_map = [
for k, v in var.regions :
{
region = k,
address_space = v.address_space
node_count = v.node_count
}
]
在前面的示例中,你会看到我们为for
表达式指定了两个参数:k
和v
。我们选择这些名称作为一种约定,帮助我们记住这些变量在for
表达式范围内的含义。k
表示映射的键,而v
表示值。值可以是任何类型,无论是基本类型、集合还是复杂对象。如果我们想访问value
对象,我们根据它的类型来访问它。在这个示例中,值是一个具有两个属性的复杂对象。在for
表达式的输出块中,我们指定了我们希望结果数组中每个项的对象结构。
在这种情况下,我们正在创建一个包含三个属性的对象数组:region
、address_space
和node_count
,基本上是将原始的map
扁平化为一个对象数组。输出如下所示:
region_array_from_map = [
{
"address_space" = "10.0.1.0/24"
"node_count" = 5
"region" = "eastus"
},
{
"address_space" = "10.0.0.0/24"
"node_count" = 8
"region" = "westus"
},
]
输出一个列表
for
表达式将始终输出一个list
或一个对象。你可以通过将for
块包裹在特定字符中来选择你想要的输出类型。如果将for
表达式包裹在方括号中,则该表达式将输出一个list
:
region_list = [for s in var.regions : "${s.region}${s.country}"]
上面的for
表达式将生成以下输出:
region_list = [
"westus",
"eastus",
]
有时,模块或资源输出的名称与其他资源所需的输入不完全对齐。因此,使用for
表达式并输出一个列表可以帮助将这些不一致的输出值转换为适合在代码的另一部分中使用的格式。
输出一个对象
将for
表达式包裹在大括号中将输出一个对象:
locals {
region_config_object = {
for s in var.regions : "${s.region}${s.country}" =>
{
node_count = s.node_count
}
}
}
这种方法将输出一个对象,其中包含regions
输入变量中每个区域项的属性。每个属性将采用区域和国家名称的连接作为名称,其值将是一个具有单个属性node_count
的对象。输出结果如下所示:
region_config_object = {
"eastus" = {
"node_count" = 8
}
"westus" = {
"node_count" = 8
}
}
输出一个对象在需要生成 JSON 或 YAML 负载的场景中非常有用。你可以在另一个资源中引用该负载,或者将其输出,以便通过terraform
的output
命令让另一个工具从 Terraform 中提取该值。
将列表转换为映射
一个常见问题是将列表转换为映射。这是必要的,因为尽管列表有时是存储简单集合的最简洁方式,但它不能与for_each
迭代器一起使用。因此,如果你想两者兼得,就需要将该列表转换为一个映射。这可以通过一个简单的for
表达式来完成,该表达式在内存中迭代列表并输出一个映射:
locals {
foo_list = ["A", "B", "C"]
foo_map = {
for idx, element in local.foo_list : element => idx
}
}
在前面的代码中,我们调用了 for
表达式并使用大括号 ({}
) 输出一个对象。我们将列表中的每个元素设为 map
的键,将该元素在 list
中的索引设为值。需要注意的是,这只有在 list
中的项目不是重复项时才有效。
现在,我们已经了解了如何进行循环、跳跃、迭代和交叉组合,可以通过利用 Terraform 的三种极其强大的迭代器——count
、for_each
和 for
——来构建资源、数据源或任何事物的动态集合,从而避免了复制粘贴的陷阱!
我们即将结束对 HCL 深入探讨的旅程。接下来,我们将看看一些语言表达式,这些表达式帮助我们在使用动态集合和条件逻辑来丰富模块时应对挑战!
表达式
HCL 有一些独特的表达式,用于处理复杂的场景,比如条件逻辑、引用动态类型以及迭代嵌套块。在深入了解该语言中的函数库之前,我们将学习这些最终的语言结构。
条件表达式
在其他编程语言中,这种技术称为三元条件运算符——即命令式的那种。这个术语其实是在说 HCL 中的 if
语句。通常,一个 if
块会跨越多行代码,并使用某种方式来作用域化这两个条件:
if (x == 5) {
// do something
} else {
// do something else
}
这个经典例子展示了条件语句在命令式语言中的表现形式。大括号提供了代码作用域,当布尔表达式为 true
或 false
时,计算机会执行相应的代码。在命令式代码中,你可以在这些作用域内做任何事情。
这种方法与使用 三元条件运算符 的区别在于,前者的目标始终是生成一个值。
使用三元条件运算符时,每个条件必须返回一个值。请考虑以下表达式:
y = x == 5 ? x * 10 : 0
上述表达式会在 x
为 5
时将 y
的值设置为 50
,对于 x
的其他任何值,它会将值设置为 0
。等效的命令式代码如下所示:
int y;
if (x == 5) {
y = x * 10
} else {
y = 0
}
这种命令式替代方案与三元条件运算符语句的区别在于,开发者可以在作用域区域内做其他与设置 y
值相关或无关的事情。
展开表达式
附加了 count
或 for_each
元参数:
resource "aws_instance" "node" {
count = var.node_count
# the rest of the configuration
}
考虑这个 AWS EC2 实例集合。该块使用 count
元参数动态创建这些资源,从 0
到 n
,其中 n
是 var.node_count
的值。因此,我们不能像通常那样直接访问该资源的输出值,因为它不是单个资源。我们需要使用索引来指定要访问的 resource
块的实例:
locals {
first_instance_id = aws_instance.node[0].id
}
在这种情况下,我们指定索引0
并访问其id
属性。但如果我们想要同时获取所有 EC2 实例的id
值呢?使用for
表达式,我们可以遍历aws_instance
资源的列表。然而,有一种更好的方法——使用 splat 表达式:
locals {
all_instance_ids = aws_instance.node[*].id
}
使用*
而不是实际的数字索引,告诉 Terraform 我们想要激活一个 splat 表达式。我们不再访问单一对象,而是想访问数组中的所有对象。完成后,id
返回一个list
,包含我们所有 EC2 实例的id
值。
动态块
我们知道,Terraform 将它所提供的对象表示为resource
块,而我们也知道,resource
块支持嵌套块。当嵌套块支持多个实例时,有时使用基于对象集合的resource
块动态声明它们会更有帮助。
让我们以 Cosmos DB 账户的示例来说明,它需要在多个地理位置之间复制数据库。我们可以根据需要添加任意数量的geo_location
嵌套块,并相应地配置它们,但这可能会变得繁琐——尤其是当每个块的配置相对一致时:
resource "azurerm_cosmosdb_account" "db" {
name = "cosmos-foobar"
location = azurerm_resource_group.foobar.location
resource_group_name = azurerm_resource_group.foobar.name
offer_type = "Standard"
kind = "MongoDB"
geo_location {
location = "westus"
failover_priority = 0
}
geo_location {
location = "eastus"
failover_priority = 1
}
}
我们也可以使用动态块来实现相同的效果。假设我们声明一个映射,定义我们希望跨区域复制的区域和每个区域的故障转移优先级。在这种情况下,我们可以使用这个映射来驱动一个动态块,正确且简洁地设置我们的 Cosmos DB 账户:
locals {
regions = {
westus = 0
eastus = 1
}
}
现在,配置了包含我们复制区域设置的map
后,我们可以使用动态块来指示 Cosmos DB 资源如何设置其geo_location
嵌套块:
resource "azurerm_cosmosdb_account" "db" {
name = "cosmos-foobar"
location = azurerm_resource_group.foobar.location
resource_group_name = azurerm_resource_group.foobar.name
offer_type = "Standard"
kind = "MongoDB"
dynamic "geo_location" {
for_each = local.regions
content {
location = geo_location.key
failover_priority = geo_location.value
}
}
}
请注意,geo_location
在我们遍历local.regions
时,变成了对local.regions
中每个项的引用,因为local.regions
是一个map
,这意味着geo_location
是这个map
中的一项。也就是说,每个geo_location
都是一个键值对,我们可以使用key
和value
来访问它们,在我们为嵌套块的content
设置location
和failover_priority
时。
就这样!我们已经完成了 HCL 中的所有概念、语法和修饰符。我们已经准备好征服 Terraform 世界了,对吧?
等一下——在我们做这个之前,让我们给我们的战斗腰带装上能帮助我们应对各种棘手情况的工具:函数!
函数
HCL 包括了许多可以帮助你使用 Terraform 开发基础设施的函数。然而,我不打算一一讲解每个函数,因为我认为很多函数涉及的概念超出了本书的范围。我会专注于开发优质云基础设施中最相关和实用的函数。Terraform 1.8.0 版本的一个新特性是引入了提供商特定的函数。这允许提供商的作者(如aws
、azurerm
等)创建特定于该提供商的有用功能。这些功能可以是常用工具,比如解析 AWS ARN 或 Azure 资源标识符。
数值函数
数值函数作用于number
类型的对象。常见的数值操作包括绝对值、向上取整/向下取整、最小值/最大值等。
鉴于这些函数涉及到数学中的基础概念,因此它们主要不在本书的讨论范围之内。我建议你查看 HashiCorp 提供的关于这些函数的优秀文档。
字符串函数
字符串函数作用于string
类型的对象。和数值函数一样,常见的字符串操作都可以用这些函数实现。
有一些与字符串处理相关的函数,比如split
、replace
和join
。
拆分
split
函数在处理逗号分隔的值时非常有用,尤其是当你将其作为输入变量传入时。你可以使用string
类型的输入变量传递一组值。使用环境变量或命令行参数使得可靠地传入像list
、map
或object
这样的复杂结构变得困难。因此,简化这些复杂结构为多个逗号分隔的string
输入变量是很常见的做法。
有一些函数用于清除数据中不必要的空格,这些空格可能是字符串处理时产生的。
格式化
format
函数可以作为一种更简洁的字符串处理方式,避免了真正难看的字符串插值—那些额外的${}
符号可能会影响代码的可读性,并将对象标记分隔开:
locals {
foo = "rg-${var.fizz}${var.buzz}${var.wizz}"
}
你可以用以下代码替换之前的代码:
locals {
foo = format("rg-%s%s%s, var.fizz, var.buzz, var.wizz)
}
fizz
、buzz
和wizz
值被传递到相应的%s
中。%s
符号是一个标记,用来插入具有特定格式指令的传入值。不同的数据类型和格式选项有不同的动词。
这个函数的一个变种叫做formatlist
,它的功能相同,但作用于list(string)
。它是一个便捷函数,避免了在for
表达式中包裹format
函数的额外复杂性。
替换
replace
函数是另一种常用的字符串函数。许多云服务对命名约定有特定要求,因此当你想要清除无效字符以使命名在不同服务中一致时,replace
非常有用:
locals {
full_name = "foo-bar"
}
例如,Azure 存储账户不允许在名称中使用连字符,而 Azure 资源组则允许。因此,如果你传递一个输入变量以在所有资源中设置一个公共前缀并包含连字符,当 Terraform 尝试创建 Azure 存储账户时,你将遇到问题:
locals {
storage_account_name = replace(local.full_name, "-", "")
}
替换(Replace)可以快速消除这种命名约定的失误。
集合函数
集合函数是一组允许你操作list
或map
类型对象的函数。它们包含日常的集合操作、布尔检查和访问器。
长度
length
函数可能是最常见的集合函数。它通常与count
元参数一起使用,因为它提供了一种简单而动态的方法来获取列表中项目的数量:
locals {
fault_domains = [1, 2, 3, 4, 5]
fault_domain_count = length(local.fault_domains)
}
在前面的代码中,我们使用length
方法来计算fault_domains
列表中的故障域数量。此方法将为fault_domain_count
局部变量生成一个值5
。
范围
range
函数对于计算数量并为其创建索引数组非常有用。它有三个重载,其中最有用的重载如下:
range(start, limit)
range
函数的这个重载接受一个start
数值和一个limit
数值。这种功能很有用,因为云平台通常有部署边界,这对于我们如何构建架构至关重要。这些边界显著影响架构的可靠性——比如地区、可用区、故障域等等——所以我们必须认真对待它们。
当我们在这些边界内外工作时,常见的一个问题是,我们的部署往往需要非常具体地指定目标边界中的某个区域。例如,我需要指定我的子网位于可用区 2,或者我的虚拟机需要位于故障域 3。
问题的根源在于,云平台通常没有一种统一或一致的方式告诉我们特定边界的值域。例如,云平台可能不会给我们一个可用区 1、可用区 2和可用区 3的列表——这些是我们需要的值,以便将资源准确地定位到目标区域——而是可能会给我们一个更为晦涩的“该区域有 5 个可用区,祝你好运!”。当我们需要具体信息时,它们却给了我们笼统的信息。这种不一致性往往不能产生理想的结果。
如果我们在硬编码资源时,这种做法是可以接受的。但当你希望为动态的可用区列表进行部署时,这将允许你遍历可用区列表并为每个可用区部署一些资源,你就需要以某种方式将范围转换为一个离散的元素列表,以便你能对列表中的每个项目进行对齐。听音乐吧,range
来拯救你了!
locals {
max_fault_domains = 5
fault_domains = range(1, local.max_fault_domains)
}
在前面的代码中,假设云平台提供了硬编码的故障域数量。我们需要创建一个可以迭代的列表,为每个故障域配置一个虚拟机。多亏了 range
,我们可以生成以下列表:
fault_domains = [1, 2, 3, 4, 5]
使用前面的列表,我们可以快速迭代,利用 count
元参数和 fault_domains
列表的长度在正确的子网中创建虚拟机。
Flatten
flatten
函数在处理存储在不同数组中的同质数据元素时非常有用。当一个模块返回一组子资源时,可能会出现这种情况:
图 2.3 – 网络模块;每个模块生成其子网列表,您需要将其聚合为一个单一的子网列表
在前面的图示中,我们可以看到有一个模块用于配置网络。该模块输出一个子网列表。通常,我们希望一个模块能够封装与模块的主资源紧密相关的子资源。尽管这种方法使代码更具可维护性,但如果我们希望在其输出的所有子资源上统一操作,也会带来上游的困难。
使用 flatten
,我们可以将多个列表压缩成一个单一的平铺列表,其中每个子网作为一个项目。这样做将使我们能够统一处理从我们正在开发的消费模块中获取的所有子网。
Transpose
transpose
函数在处理具有复杂父子关系的对象层次结构时也非常有用:
图 2.4 – 与一组安全组关联的资源虚拟机
例如,在前面的图示中,两个虚拟机与一组安全组关联。我们在这种情况下将其设置为一个 map
集合。键将是虚拟机,值将是一个安全组集合。
当我们想要迭代顶级对象——虚拟机时,这个 map
表现得非常好,但如果我们想要迭代子对象——安全组呢?我们使用 transpose
函数翻转这个关系!
图 2.5 – 与一组虚拟机关联的安全组
前面的图示表明,与其将顶级对象设置为虚拟机,现在是安全组。transpose
操作还将子对象替换为虚拟机。然而,您会注意到,该函数保持了父子关系——我们只是通过不同的视角来看待这种关系。
Zipmap
zipmap
函数在你有两个 list
对象时非常有用,一个包含键,另一个包含相应的值。两个列表的对应项位于相同的索引处,且这两个列表的长度必须相同。
这个名称很有趣,因为它尝试进行文字描绘。想象一下拉链的构造:两个独立但平行的凹槽列表,当拉链依次应用时,左侧的每个凹槽都与右侧的对应凹槽连接:
图 2.6 – 虚拟机列表及其对应的安全组列表
在前面的图示中,我们有两个列表,一个包含虚拟机,另一个包含安全组。通过 zipmap
函数,我们可以创建一个新的 map
对象,将虚拟机和安全组列表中的每个元素关联起来。
编码函数
编码函数让你能够处理各种编码格式的文本,并在它们之间进行相互转换。这些包括基于字符串的操作,如 Base64,以及字符串到对象的编码格式,如 JSON 和 YAML。
许多服务通常要求将输入数据编码为 Base64,以简化复杂数据的传输,对应的两个 encode
和 decode
函数如你所料地工作。
JSON 和 YAML 的 encode
和 decode
函数像典型的序列化器一样工作,encode
函数接收一个对象并生成一个字符串,而 decode
函数接收一个字符串并生成一个对象。
文件系统函数
文件系统函数提供了一组工具,使得与本地文件的操作更加简便。如果你需要为自动化管道的下一步创建一个配置文件,或者需要访问包含资源配置所需内容的文件,这些工具将非常有用。
文件
file
函数会简单地将磁盘上指定路径的文件内容加载到一个字符串中。path.module
特殊变量用于指定当前模块的工作目录。在模块开发过程中,这非常有用,因为它允许你嵌入并使用文件来存储模块内所需的内容:
locals {
template_content = file("${path.module}/template.json")
}
该方法通常与 jsondecode
和 yamldecode
函数一起使用,将字符串内容转换为一个可以更轻松地在 HCL 中使用的对象:
locals {
template = jsondecode(
file("${path.module}/template.json")
)
}
模板文件
templatefile
函数与 file
函数的作用相似,但有所不同。它允许你传入参数,并将这些参数替换文件中相应的占位符:
locals {
template = jsondecode(
templatefile(
"${path.module}/template.json",
{
hostname = "foo"
ip_address = "10.0.1.8"
}
)
)
}
hostname
和 ip_address
参数表示文件中应替换为其对应值(foo
和 10.0.1.8
)的令牌。如果文件内容包含 ${hostname}
或 ${ip_address}
令牌,它们将被替换为相应的值。这是将重要配置细节动态注入到基础设施中,在 Terraform 执行其计划后才可使用的一种非常方便的方法。
日期/时间函数
日期/时间函数提供了一组用于创建时间戳的工具。这些函数可以帮助为短期访问令牌设置过期日期,或将日期/时间字符串转换为特定资源所期望的正确格式。
在 Terraform 中处理时间时,重要的是要注意没有显式的日期/时间类型。Terraform 使用 string
类型处理日期/时间值。Terraform 使用的默认格式是 RFC 3339 格式,格式如下:
2023-09-14T13:24:19Z
上述值具有以下格式:
{YYYY}-{MM}-{DD}T{HH}:{mm}:{ss}Z
了解这个行为非常重要,因为所有可用的日期/时间函数都会接收或生成这种格式的时间戳。
哈希/加密函数
就像编码函数一样,哈希和加密函数也有多个选项。这些函数实现了多种哈希算法,如 MD5 和 SHA,以及加密算法,如 RSA。还有对应的基于文件的操作,允许你从文件中读取内容。
重要的是要记住,哈希(hashing)与加密(encryption)是不同的,当使用加密时,应该确保你在 Terraform 状态文件中使用的密钥在内外部都得到保护。
IP 网络函数
IP 网络函数使得操作 CIDR 块更容易,将地址空间划分为子网。
尽管 Terraform 的网络函数支持 IPv4 和 IPv6,我们的示例将聚焦于 IPv4 地址,以便更容易理解。
IPv4 地址是一个 32 位值,包含 4 个字节,每个字节 8 位。我们构造 CIDR 块来识别一组 IP 地址,这些地址分配给虚拟网络或该虚拟网络内的子网。
Terraform 有一个 cidrsubnet
函数,它计算这些 CIDR 块范围,使得动态生成 CIDR 块范围变得更加容易,而不是硬编码它们或使用字符串操作来构建它们:
cidrsubnet(prefix, newbits, netnum)
prefix
参数是你想要拆分的网络空间。newbits
参数是你希望拆分后每个子块的大小——它的值与地址空间块的大小成反比。随着 newbits
值的增大,子块的大小会减小;而随着 newbits
值的减小,子块的大小会增大:
locals {
cidr_block_start = "10.0.0.0/16"
cidr_chunk_size = 8
cidr_block_list = range(0, 4)
dynamic_cidr_blocks = [for i in local.cidr_block_list :
cidrsubnet(local.cidr_block_start, local.chunk_size, i)]
}
在上述代码中,我们正在使用10.0.0.0/16
的地址空间进行划分,每个块增加了8
个额外的位。这意味着我们正在寻找大小为/24
的块 — 即 256 个 IP 地址。如果我们使用6
,我们将寻找大小为/22
的块 — 即 1,024 个 IP 地址。正如你所见,额外的位数越少,每个块中的 IP 地址就越多,我们能够容纳的块数就越少,因为它们占用了主地址空间:
resource "aws_subnet" "subnet" {
count = 4
cidr_block = cidrsubnet(var.cidr, local.chunk_size, count.index)
}
在上述代码中,我们可以使用此函数在 AWS VPC 中为每个子网构建地址空间。由于count
元参数的存在,我们不需要for
表达式来构建地址空间列表。我们可以使用count.index
来指定netnum
输入,以选择我们希望我们的子网使用的块。
类型转换函数
明确的类型转换在 Terraform 中非常少见,但如果有必要,可以使用多个函数来帮助你,例如tonumber
、tobool
、tolist
等。
摘要
在本章中,我们深入研究了驱动 Terraform 的语言:HCL。我们看了它的核心结构:资源、数据源、本地变量和模块。我们查看了输入和输出,这将帮助我们在模块和外部世界之间设计更好的合同。我们看了允许我们构建动态资源集合的语言结构 — 这帮助我们在没有所有复制粘贴的情况下扩展我们的代码。最后,我们看了一系列函数,这些函数可以帮助我们在 HCL 中编码,快速高效地解决复杂问题。
在下一章中,我们将探讨 HashiCorp 发布的实用程序提供程序,这些提供程序为我们的 Terraform 解决方案提供关键的跨平台功能。
第三章:3
利用 HashiCorp 实用程序提供程序
正如我们在第一章中讨论的,当我们了解 Terraform 的架构时,Terraform 被设计为可扩展的。在上一章中,我们花了很多时间研究 HashiCorp 配置语言 (HCL),它提供了许多工具,可以帮助我们定义 基础设施即代码 (IaC)。然而,这些语言工具并不总是足够的。这就是为什么 HashiCorp 构建了一套实用的提供程序,它提供了一种基础类库或一套可重用的功能,适用于特定场景,无论你使用什么云平台来构建你的 IaC 解决方案。
本章涵盖以下主题:
-
处理现实
-
适配与集成
-
文件系统
-
操作系统和网络
处理现实
当我们用 IaC 构建架构时,产物不是代码,而是活生生的环境。虽然代码存在于我们大脑的抽象领域中,但这些环境在现实世界中运作,就像我们的最佳计划会被现实击碎一样——我们的环境也会如此。
因此,我们需要一些工具来准备我们的环境,以面对现实并与之应对。random
和 time
提供程序使我们能够避免资源和环境之间的冲突——无论是某个事物的名称,还是某个事物的过期时间。这些都是我们解决方案设计中的关键元素,当架构遇到现实世界时,它们可能决定成败。
随机化
random
提供程序提供了多种方法来为你的 Terraform 解决方案添加随机性。每种 random
资源类型可能会生成不同类型的随机值,并具有其他属性来控制输出。然而,除了少数几个例外,它们都通过一个叫做 result
的输出生成随机值。它们还至少有一个名为 keepers
的属性,用于触发 Terraform 重新创建资源。当你有频繁替换的临时资源时,这个属性特别有用,它可以帮助你确保在销毁并重新创建资源时没有名称冲突。
随机字符串
生成随机字符串是保证部署唯一性的好方法,特别是在动态生成短生命周期环境的情况下。根据不同的场景,有两种生成字符串的方式——一种用于非敏感数据,如资源名称,另一种用于敏感数据,如访问密钥和密码。
生成非敏感动态名称可以通过 random_string
来完成。同时,random_password
可以生成敏感值,你应该通过将其标记为敏感来防止泄漏,如果你输出它们,还需要通过保护你的状态来确保安全,因为 Terraform 会将结果值存储在状态中:
resource "random_string" "name_suffix" {
length = 6
special = true
override_special = "/@£$"
}
上述代码生成一个随机字符串,可以在项目中生成唯一的资源名称。当您的资源名称长度要求较小时,使用短的随机字符串嵌入到资源名称中是一个很好的策略,因为当一个或两个资源需要异常小的名称长度时,创建一致的命名约定会变得很有挑战性。这种情况通常出现在资源需要具有全球唯一名称时,比如 S3 存储桶或 Azure 存储帐户:
locals {
resource_name = "foobar${random_string.name_suffix.result}"
}
当您将随机名称后缀与命名约定的部分结合时,仍然可以获得一个相对合理的资源名称:
resource "random_password" "database" {
length = 16
special = true
override_special = "!#$%&*()-_=+[]{}<>:?"
}
唯一标识符
您还可以生成random_uuid
。这对于您的资源支持非常长的名字时很有帮助,因为这些不区分大小写的字母数字值采用以下格式:00000000-0000-0000-0000-000000000000
。您可能需要这个来生成唯一的关联标识符,以便在部署中使用公共标签将资源链接起来。
纯粹为了娱乐
还有一个有趣的小资源叫做random_pet
,它向古老的宠物与牲畜笑话致敬,您可以用它生成宠物名字。这个资源可能对生产环境没有太大用处,但在开发或实验室环境中,当您可以更加自由地为资源命名时,它是很有帮助的。random_pet
资源的id
输出将生成形容词-名词格式的名字。以下是我通过书中这一章的示例生成的一些示例值:
-
notable-coyote
-
quiet-parakeet
-
pure-woodcock
-
healthy-monkey
-
mint-foal
-
pet-serval
-
ideal-lab
-
special-urchin
您可以看到几乎所有的名字都没有什么意义,但有些可能很有趣。
随机数
生成随机数还可以帮助生成随机名称或从数组中生成随机索引。random_integer
提供了一个简单的解决方案,允许您从指定的min
和max
值之间选择一个数字。
考虑以下 AWS 可用区数组:
locals {
azs = [
"us-west-1a",
"us-west-1c",
"us-west-1d",
"us-west-1e"
]
}
如果我们想从这个list
中选择一个随机的可用区,我们可以使用random_integer
来生成一个随机索引:
resource "random_integer" "az" {
min = 0
max = length(local.azs) – 1
}
上面的代码将允许我们在0
和list
长度减去1
之间生成一个随机整数,即4 - 1 = 3
。因此,我们将随机生成0
、1
、2
或3
。
我们可以通过以下代码访问随机的可用区:
locals {
selected_az = local.azs[random_integer.az.result]
}
最后,我们可以使用可用区名称来配置我们的 AWS 资源:
resource "aws_elb" "foo" {
availability_zones = [local.selected_az]
# ... and other aws_elb arguments ...
}
超越简单的整数
当简单的整数不足以满足需求时,您可以使用random_id
来生成更复杂的输出。唯一的输入是byte_length
,它控制生成的随机数的大小。这个资源与其他random
提供者的资源不同,因为它没有result
输出,而是有几个其他输出,以不同格式呈现随机数,包括十进制、十六进制和 Base64:
resource "random_id" "foo" {
byte_length = 8
}
上述代码生成了一个长度为 8 字节的随机数。不同格式的输出值示例如下:
-
IpVgeF7uUY0
-
2492004038924456333
-
229560785eee518d
-
IpVgeF7uUY0=
-
=
IpVgeF7uUY0
同样,根据你的命名规范,这对创建独特标识资源的名称或标签非常有用。
混洗
在前面的例子中,当从列表中选择可用区时,如果我们想从列表中随机选择多个项,我们需要生成多个 random_integer
资源。使用 random_integer
来做到这一点已经相当繁琐,如果我们有要求确保第二个 random_integer
的结果不与第一个相同,问题会更加复杂。
幸运的是,选择数组索引的替代方法是使用内建资源来从 list
中选择随机子集。你可以使用 random_shuffle
资源来实现此方法,通过 list
和你想要的项数(通过 result_count
属性传递)来实现。输出的 result
将是一个你可以使用的字符串 list
。如果我们希望 AWS Elastic Load Balancer (ELB) 跨多个可用区展开,这种方法会极大简化我们的解决方案。
请看以下 AWS 可用区的数组:
locals {
azs = [
"us-west-1a",
"us-west-1c",
"us-west-1d",
"us-west-1e"
]
}
我们将使用 random_shuffle
来为我们的 ELB 生成两个可用区:
resource "random_shuffle" "azs" {
input = local.azs
result_count = 2
}
最后,我们使用 random_shuffle
的 result
来设置 availability_zones
属性,因为它的输出是正确类型的 list(string)
:
resource "aws_elb" "foo" {
availability_zones = random_shuffle.azs.result
# ... and other aws_elb arguments ...
}
这个资源非常有用,但使用时需要小心,因为它可能导致你的解决方案变得非确定性——意味着 Terraform 需要等到 random_shuffle
资源创建完成后,才能确定如何创建计划。这可能需要你使用有针对性的 terraform apply
操作,以避免第一次应用失败。
与时间相关的工作
在 Terraform 中,time
提供商提供了几项功能,使得在资源生命周期管理由时间决定的各种场景下更容易处理。
尽管大多数云提供商为资源调度提供了更好的解决方案,但在一些情况下,时间仍然在资源配置中起着至关重要的作用。这种情况通常涉及证书,你需要为证书设置固定的或滚动的过期时间窗口。在这种情况下,你可以使用未来的特定日期/时间,或者相对于当前日期/时间的日期/时间。
当前日期/时间
有时,你可能想捕捉当前的日期和时间,这些信息可能用于一个秘密值的生效日期。Terraform 提供了两种获取当前日期的方法——一个是函数,另一个是 time_static
资源:
locals {
option1 = timestamp()
}
上述代码演示了如何使用 timestamp()
函数。以下代码展示了如何使用 time
提供商中的 time_static
资源:
resource "time_static" "current_time" {}
这两种方法都会在你第一次运行 apply
时生成当前的日期/时间。不同之处在于,timestamp()
函数每次运行 apply
时都会生成当前的日期/时间戳。这使得它在一些场景中更加理想,比如给资源打上最后修改日期的标签,这有助于确定资源最后一次被 Terraform 修改的时间。另一个常见的场景是触发资源更新,你希望每次都触发更新,但要避免这种做法,因为这会导致解决方案中的持续变化。
与此同时,time_static
资源将在 State 中维护第一次 Terraform apply
时的原始日期/时间戳。这对于资源的生命周期管理很有用,可以帮助确定部署最初创建的时间,或根据年龄设置备份、扩展或退役策略。
固定日期/时间
可以使用日期/时间的字符串表示形式来创建一个未来的特定时间,方法是使用绝对的日期/时间:
locals {
future_date = "2024-05-04T00:00:00Z"
}
上述代码会将过期日期设置为 2024 年 5 月 4 日。日期/时间的字符串表示格式为 YYYY-MM-DDTHH:MM:SSZ
。
另一种选择是使用 time_static
资源并设置 rfc3339
属性,但由于其相较于简单设置本地时间的有限值,通常很少使用:
resource "time_static" "may_the_fourth" {
rfc3339 = "2024-05-04T00:00:00Z"
}
时间偏移
可以使用相对于当前日期的周期来创建未来的特定时间,方法是使用 time_offset
:
resource "time_offset" "certificate_expiration" {
offset_years = 1
}
上述代码会将过期日期设置为恰好一年后的未来日期。有不同的属性可以调整偏移日期/时间戳,单位包括年份、月份、天数、小时、分钟和秒。你可以设置 base_rfc3339
属性来改变偏移的日期/时间的基准。这是动态设置某些过期日期的好方法。但你需要确保定期运行 Terraform 来保持目标日期在未来。
旋转
在某些情况下,你可能需要定期重建资源。这个密钥可能需要每 90 天或 XXX 天更新一次。在这些情况下,time_rotating
资源相较于其静态同类资源 time_static
和 time_offset
提供了优势。
时间偏移看起来是旋转的解决方案,因为它是相对于当前日期的,但就像 time_static
一样,它只不过是另一种计算 Terraform 将存储在 State 中的静态日期/时间戳的方法。time_rotating
资源的超级能力是,当 rotation_days
期限相对于原始日期到期时,你会看到该资源触发替换:
resource "time_rotating" "certificate" {
rotation_days = 90
}
这也要求你定期运行 Terraform 以保持未来的值。如果你使用了这样的资源,确保与变更管理流程进行协调,因为当你执行 terraform plan
时,它们可能会在不经意间出现在你面前,而你可能会发现自己已经错过了那个关键日期。
在本节中,我们学习了如何使用 random
提供者随机化资源的名称,甚至生成我们可以在 Terraform 在为其提供密码之前可以使用的密钥的部分自动化环境。我们还学习了在第二章中查看的 timestamp()
函数和其他创建时间段和旋转窗口的高级场景中何时使用 time
提供者。
接下来,我们将查看一些实用程序提供者,这些提供者帮助我们克服 Terraform 遇到不具备内置解决方案或已有提供者解决该问题的情况时的一些限制。
适应和集成
正如我们讨论过的,Terraform 及其提供者是开源项目,因此它可能存在其无法执行或无法获取的限制。因此,我们经常需要找到克服这些限制的方法,即使是暂时性的。在本节中,我们将查看几个提供者,这些提供者帮助 Terraform 扩展外部并利用可以增强 Terraform 并帮助其克服缺乏内置解决方案的情况的程序和系统。
访问外部资源
像许多实用程序提供者一样,external
提供者很小。它只有一个与提供者同名的数据源:external
。顾名思义,此数据源允许您与第三方组件集成。它使您能够执行本地程序,传递其输入并处理输出。当您希望从外部源获取动态配置,对来自其他提供者的输入执行复杂转换,并集成您希望与 Terraform 集成的第三方工具时,此功能可能非常有利。
此提供者非常关注您指定的程序的运行时要求。首先,程序必须成功运行并以零退出码退出。如果程序返回非零代码,提供者将向 Terraform 发出警报并散播错误消息。其次,提供者期望输入和输出都以 JSON 格式提供。
当您的第三方程序满足所有这些要求时,使用 external
提供者非常完美。这确实是一个幸运的巧合!如果您正在集成这样的程序,那太棒了。但是,当您编写明确满足这些合同义务的自定义脚本或程序时,此提供者是正确的选择。
为了尽可能跨平台,编写这些自定义脚本的理想编程语言是 Python 或 Go。使用这些编程语言,您可以创建一个轻量级脚本,专门设计和构建,适合与您选择的外部系统进行通信,并提供 Terraform 友好的输出和错误处理:
data "external" "example" {
program = ["python", "${path.module}/example-data-source.py"]
query = {
# arbitrary map from strings to strings, passed
# to the external program as the data query.
id = "abc123"
}
}
在上面的代码中,我们正在本地机器上执行 Python 程序——这可能是我们的笔记本电脑,或者是我们持续集成/持续交付(CI/CD)管道的构建代理。
当你想从无到有地创建某样东西时
Terraform 及其提供者是开源项目。这意味着我们依赖于那些积极跟进平台和技术变化的友好互联网陌生人,他们在帮助我们实现自动化时提供支持。尽管 Terraform 对于广泛的公共云平台和技术有着出色的支持,但有时它还是需要一些帮助。可能有一些小特性没有得到支持,而你无法通过提供者中的资源原生配置。那些小小的调整有时在配置中发挥着关键作用,我们需要从其他可以通过 Terraform 提供者原生配置的资源中引入它们的依赖。这就是 null_resource
的作用所在。
空资源
null_resource
允许我们利用元参数如 provisioner
来执行本地和远程脚本执行。这使你能够执行一些关键的命令行脚本,这些脚本必须在 Terraform 继续其计划之前完成。因此,null_resource
没有像其他 Terraform 资源那样的属性。它唯一的属性是一个名为 triggers
的 list(string)
类型。当该数组中的任何字符串发生变化时,null_resource
就会被替换。这是一个重要的生命周期控制因素,在配置你附加的 provisioner
块时,你需要考虑这一点。
时间睡眠
还有一种什么都不做的技巧。在某些情况下,你试图触发的操作是非确定性的,这意味着你无法准确知道它何时完成。这可能是上下文之外的,或者是你使用的资源或适配器的真正技术限制。time
提供者提供了一个名为 time_sleep
的资源,可以让你创建一个睡眠定时器。你必须声明 depends_on
元参数,以确保在所需资源之间调用睡眠定时器:
# This resource will destroy (potentially immediately) after null_resource.next
resource "null_resource" "previous" {}
resource "time_sleep" "wait_30_seconds" {
depends_on = [null_resource.previous]
create_duration = "30s"
}
# This resource will create (at least) 30 seconds after null_resource.previous
resource "null_resource" "next" {
depends_on = [time_sleep.wait_30_seconds]
}
延迟 destroy
可以通过一个名为 destroy_duration
的不同属性来完成。
发起 HTTP 请求
有时,你可以不使用本地脚本或命令行工具,通过直接访问 REST API 端点来访问外部数据。当你希望从静态 HTTP 端点获取配置信息,访问管理在 Terraform 之外的资源的信息,或直接通过 REST API 与云提供商或外部服务集成,甚至将健康检查集成到 Terraform 流程中时,这种方法特别有利。
http
提供者提供了一个名为 http
的单一数据源,允许你进行 HTTP GET
操作。唯一必需的输入是 url
,但你可以提供一些你期望在 HTTP 请求中设置的属性,比如 HTTP 请求头和正文内容:
data "http" "foo" {
url = "https://foo"
}
在 Terraform 发出 HTTP 请求后,你可以访问 HTTP 响应状态码、头信息和正文内容。
在本节中,我们学习了如何与各种外部组件集成——本地程序或脚本、远程服务器或完全不使用任何组件。
接下来,我们将查看一些有助于我们创建和访问文件的实用程序提供者。我们已经遇到了一些能够启用这些场景的函数,但一些额外的场景只有在使用实用程序提供者时才可能。
文件系统
在使用 Terraform 构建基础设施即代码(IaC)解决方案时,有许多情况需要使用现有文件或创建新文件。在本节中,我们将查看使更高级场景成为可能的实用程序提供者,这些场景超出了 file
和 templatefile
函数的范围,以及何时应该使用提供者而不是函数。
读取和写入本地文件
有许多情况下,读取或写入文件可以大大简化 Terraform 如何与其他工具集成,例如通过创建配置文件、脚本或任何其他基础设施部署所需的工件。为了生成所需的输出,你可以在 HCL 代码中使用模板文件、输入变量或其他表达式来定义内容。
Terraform 有一个名为 local
的实用程序提供者,提供了此功能。该提供者有两个资源和两个数据源,分别名为 local_file
和 local_sensitive_file
。
为什么不直接使用函数呢?函数不会参与依赖图,因此当你使用 file
或 template_file
函数时,不能与在 Terraform 操作过程中动态生成的文件一起使用。因此,如果你计划在 Terraform 中生成并使用文件,应该始终使用 local
提供者的资源。
写入文件
local_file
资源(以及相应的 local_sensitive_file
资源)允许你在 filename
属性中指定的目标位置创建一个新文件。有几种选项可以用于获取内容,可以通过使用 Terraform 内部动态生成的内容或现有文件来提供:
-
content
:此属性允许你传递任何 UTF-8 编码的字符串,可以使用资源输出、局部变量或函数中的简单字符串来生成该字符串。 -
content_base64
:此属性允许你传递作为 Base64 编码字符串的二进制数据。 -
source
:此属性允许你传递一个现有文件的路径,用于读取文件内容。当使用此属性时,你将原始文件复制到新位置。
在以下代码中,我们将 content
属性设置为一个简单的常量字符串,并使用 ${path.module}
特殊标记指定当前模块的工作目录作为名为 foo.bar
文件的输出位置:
resource "local\_file" "foo" {
content = "foo!"
filename = "${path.module}/foo.bar"
}
在销毁时要小心,特别是当你在 Terraform 中动态生成文件内容时,使用jsonencode
、yamlencode
或任何其他方法时——你可能会遇到问题,因为 Terraform 处理这种资源类型依赖关系的方式。
同时,当将敏感数据写入文件时要小心,因为这可能会带来安全风险。同样,文件系统访问或 I/O 失败可能会为执行 Terraform 时创造额外的故障点。
鉴于这些常见的陷阱,使用 Terraform 生成文件内容仍然是非常有效的。一个这样的场景是为 Ansible 生成 YAML 清单,这是将 Terraform 和 Ansible 集成作为更广泛的维护过程的一部分的一个极好的方法,适用于需要操作系统级别配置管理变更的长期环境。
读取文件
local_file
数据源(以及相应的local_sensitive_file
数据源)允许你读取现有文件的内容,并以各种格式输出其内容,供你在代码库中的其他资源和模块中作为输入。这一功能与file
函数类似,但提供了一些优势。
首先,它可以创建一个独立的块,从多个资源中引用,而不必在等效的函数调用中重复文件名的路径。创建一个对本地文件的集中引用可以使你的代码更易于维护,因为它让 Terraform 和人类对文件的依赖关系更加明确。
其次,通过利用数据源,你可以立即获得多种输出数据的方式,包括 Base64 字符串、SHA 和 MD5 选项。通过利用这些输出选项,你可以避免进行额外的嵌套函数调用,以执行相同的编码操作。
在以下代码中,我们通过foo.bar
文件名访问模块当前目录中的现有文件:
data "local_file" "foo" {
filename = "${path.module}/foo.bar"
}
然后,我们可以通过使用数据源的content
输出或之前提到的其他编码选项来访问文件的原始内容。
模板文件和目录
在上一章中,我们介绍了模板文件,并调查了 HCL 内置函数,包括名为template_file
的函数。当你处理单个文件时,应该使用这个函数,但template
提供者提供了一个资源,你可以用它在给定目录中的所有文件上应用模板化。
该资源从source_dir
属性获取输入文件,并且对于每个文件,它会将指定的输入变量替换为相应的占位符,将每个输出文件写入已建立的destination_dir
。使用这个资源是更新跨多个文件的配置的一个好方法,但你必须确保所有文件都使用一致的占位符集,以便模板引擎以与template_file()
函数相同的方式替换。
生成文件归档
有时,有必要将 Terraform 的输出打包成压缩归档文件,以便用于多个不同的目的,例如将配置高效地传输到应用部署管道的下一个阶段,将配置打包以便轻松分发到其他外部存储库,生成文档或其他工件以跟踪部署历史、环境变化或配置快照,以便随着时间的推移进行变化。
当处理机密或敏感数据时,应避免使用此方法,因为对于这些场景存在更合适的解决方案。与基础设施相关的元数据,由 Terraform 生成并需要由其他工具以不同格式使用,是这种方法的理想场景。
archive_file
资源在指定的 output_path
位置生成一个 ZIP 文件,并包含一个或多个文件,使用现有文件或动态生成的文件。
包含现有文件
当你想要引用存储在 Terraform 模块目录中的现有文件或 Terraform 使用 local
提供程序生成的文件时,应该使用 source_file
或 source_dir
来分别将单个文件或整个目录的文件包含到归档中:
data "archive_file" "init" {
type = "zip"
source_file = "${path.module}/foo.txt"
output_path = "${path.module}/files/out.zip"
}
包含动态生成的文件
当你想输出 Terraform 动态生成的内容,使用本地变量或其他方式构造对象时,需要使用一个或多个 source
块,并指定文件的 content
和 filename
,将其包含到归档中:
data "archive_file" "dotfiles" {
type = "zip"
output_path = "${path.module}/files/out.zip"
source {
content = "foobar"
filename = "foo.txt"
}
}
如果你只想在归档中包含一个动态生成的文件,你可以使用顶级的 source_content
和 source_filename
来生成一个文件:
data "archive_file" "dotfiles" {
type = "zip"
output_path = "${path.module}/files/out.zip"
source_content = "foobar"
source_content_filename = "foo.txt"
}
前面的示例生成相同的输出,但后者在单文件归档场景中稍显简洁。同时,前者允许你在未来使用额外的 source
块向归档中添加任意数量的文件。
需要注意的是,将文件包含到归档中的每种方法是互斥的,因此必须选择一种方法,并且只能选择一种方法。
在本节中,我们学习了如何利用 local
、archive
和 template
提供程序来处理更复杂的文件系统访问场景,在这些场景中,利用现有功能可能并不理想。
最后,我们将看看一些实用程序提供程序,帮助我们设置操作系统配置、安全性和网络访问控制。
操作系统和网络
HashiCorp 创建了 Terraform 的实用程序提供程序,以解决跨云平台的常见问题。因此,一些实用程序提供程序解决了与设置或连接到服务器时会遇到的常见架构场景相关的问题。
生成证书和 SSH 密钥
在 Terraform 中,tls
提供程序提供与 传输层安全性 (TLS) 和加密相关的一般功能。你应该与证书颁发机构一起使用此提供程序来生成生产工作负载的签名证书。然而,许多功能在开发和实验环境中非常有用,可以简化工作效率。
SSH 密钥
在处理虚拟机时,常见的用例是需要生成 ssh-keygen
,但 Terraform 有一个提供此任务的资源,这使得将 SSH 密钥生成与将要使用它的基础设施封装在一起变得极其简单和方便。这是一个非常适合短期实验环境的工具,但在使用这种方法时,你需要保护你的 Terraform 状态。
resource "tls_private_key" "ssh_key" {
algorithm = "RSA"
rsa_bits = 4096
}
现在 Terraform 已经生成了你的资源,你可以将其放入你选择的密钥管理器中:
resource "azurerm_key_vault_secret" "ssh_private_key" {
name = "ssh-key"
value = tls_private_key.ssh_key.private_key_openssh
key_vault_id = azurerm_key_vault.main.id
}
在前面的示例中,我们将 private_key_openssh
值放入 Azure Key Vault 秘密中,这样我们就可以使用 Azure 门户中的 SSH 密钥直接连接到机器,或者使用 Azure Bastion。
RSA
是最常用的 SSH 密钥算法。它经过验证,但你应该使用至少 4,096 位的密钥大小。较新的算法,如 ECDSA
和 ED25519
也受支持,但在广泛应用于你的组织之前,你应确保你的客户端支持这些算法。
当你生成 SSH 密钥时,不需要将其保存到文件系统。你应该将其保存到一个证书管理服务中,如 AWS 证书管理器 (ACM)、Azure Key Vault 或 Google Cloud 的证书管理器。
由于 Terraform 规划的工作方式,为了让 Terraform 知道 SSH 密钥资源是否存在,以及你的证书管理器是否有该资源,它需要在状态文件中维护关键属性。这些关键属性中有许多是高度敏感的信息,包括公钥和私钥。因此,保护 Terraform 状态的后端对于防止未授权访问至关重要。这个任务本身并不困难,但在你有一个安全的后端策略之前,可能最好不要在生产工作负载中使用这种方法。
证书
在生成证书时,通常需要生成一个 PEM
文件,并提供有关证书主题的详细信息,包括一些人类可读的信息,如组织名称、物理地址和网络地址信息,如域名或 IP 地址。
Terraform 有一个资源 tls_cert_request
,可以生成 CSR。与生成 SSH 密钥的 tls_private_key
资源类似,该资源执行的是人类操作员通过图形界面或命令行界面生成 CSR 的任务。该资源的输出结果需要传递给 CA 以生成签名证书。
你可以使用foo
资源在本地生成证书。CSR 提供程序将在运行 Terraform 的本地机器上尝试处理它。
首先,你需要一个私钥,可以使用我们用来生成 SSH 密钥的tls_private_key
资源来创建:
resource "tls_private_key" "foo" {
algorithm = "RSA"
}
然后,我们需要使用tls_cert_request
资源生成 CSR:
resource "tls_cert_request" "foo" {
private_key_pem = tls_private_key.foo.private_key_pem
subject {
common_name = "foo.com"
organization = "Foobar, Inc"
}
}
最后,我们可以使用私钥和 CSR 生成证书:
resource "tls_locally_signed_cert" "vault" {
cert_request_pem = tls_cert_request.foo.cert_request_pem
ca_key_algorithm = tls_private_key.foo.algorithm
ca_private_key_pem = tls_private_key.foo.private_key_pem
ca_cert_pem = tls_self_signed_cert.foo.cert_pem
validity_period_hours = 17520
allowed_uses = [
"server_auth",
"client_auth",
]
}
生成 CloudInit 配置
Cloud Init是一个开源的跨平台工具,用于向云托管的虚拟机提供启动配置。它通过云的元数据和用户数据来配置新实例。例如,你可以设置主机名、设置用户和组、挂载和格式化磁盘、安装包以及运行自定义脚本。
基本使用方法
有时候,你可以使用数据源来生成内容,而不是与外部系统交互。cloudinit_config
就是一个完美的例子。它通过一种模式帮助简化生成有时冗长的 Cloud-Init 配置文件,这些文件会作为输入传递给新创建的虚拟机。
Cloud-Init 支持几种不同的部分类型。每种类型都会推断出不同的模式和格式来传递内容,有时使用 JSON、YAML、bash 脚本或纯文本。Cloud-Init 能够做的全部内容超出了本书的范围,但我鼓励你查看在线文档以获取更多详情。我将介绍一些常见的用例,展示如何使用现有的 Cloud-Init 知识,并在使用cloudinit
Terraform 提供程序时应用它:
data "cloudinit_config" "foo" {
gzip = false
base64_encode = false
}
为了将输出作为用户数据附加到 AWS 上的新弹性计算云(EC2)实例,我们将使用以下代码:
resource "aws_instance" "web" {
# other ec2 attributes
user_data = data.cloudinit_config.foo.rendered
}
加载外部内容
当你有大量外部内容需要下载到实例时,可以使用x-include-url
和x-include-once-url
:
data "cloudinit_config" "foo" {
gzip = false
base64_encode = false
part {
content_type = "text/x-include-url"
content = "http://foo.com/bar.sh"
}
}
使用自定义脚本
当你想要执行存储在 Terraform 模块目录中的自定义脚本时,可以使用几种不同的部分类型在其他条件下运行这些脚本:
-
x-shellscript
:这个脚本将在每次实例启动时运行。也就是说,无论是创建后的第一次启动还是随后的重启,它都会执行。 -
x-shellscript-per-boot
:这与x-shellscript
相同。它也将在每次实例启动时运行。 -
x-shellscript-per-instance
:这个脚本将只在每个实例上运行一次。也就是说,脚本将在实例创建后的第一次启动时运行,但在随后的重启中不会再运行。这部分对于只需要每个实例做一次的初始化任务非常有用,例如设置跨重启持续的软件下载或用户。 -
x-shellscript-per-once
:这个脚本将只在所有实例中运行一次。如果你创建多个带有相同脚本的实例,这个脚本只会在第一个启动的实例上运行。这个功能对于只需要在一组实例中执行一次的任务很有用,例如设置数据库或集群中的主节点。
考虑以下脚本,它存储在 Terraform 模块根文件夹中的一个名为foo.sh
的 bash 脚本文件里:
#!/bin/bash
sudo apt-get update -y
sudo apt-get install nginx -y
echo '<h1>Hello from Terraform Cloud-Init!</h1>' | sudo tee /var/www/html/index.html
我们可以将其嵌入到cloudinit_config
数据源中,以生成我们传递给新创建虚拟机的用户数据:
data "cloudinit_config" "foo" {
gzip = false
base64_encode = false
part {
content_type = "text/x-shellscript"
content = file("${path.module}/foo.sh")
}
}
Cloud 配置文件
Cloud-Init 支持一种自定义模式,用于执行各种日常任务。支持多种不同的部分类型,允许你在 Cloud-Init 包中以多种格式包含 cloud config 数据:
-
cloud-config
:这是最常用的内容类型,用于标准的 cloud-init YAML 配置文件。你可以使用cloud-config
内容类型来执行通用实例配置任务,例如设置用户和组、管理软件包、运行命令和写入文件。 -
cloud-config-archive
:这种内容类型在一个文件中提供多个 cloud-config 部分。cloud-config-archive
文件是一个 YAML 文件,包含一个 cloud-config 部分的列表,每个部分是一个包含文件名、内容类型和内容本身的映射。你应该在按特定顺序应用多个 cloud-config 文件时使用它。它们在列表中的顺序决定了应用的顺序。 -
cloud-config-jsonp
:这种内容类型允许你编写带填充的 JSON(JSONP)响应。JSONP 通常用于绕过网页浏览器的跨域策略。如果你在编写一个需要与不同域上的服务器交互并使用 JSONP 来绕过同源策略的 Web 应用程序,你可能会使用这种内容类型。
cloud config 的完整功能超出了本书的范围,但我鼓励你通过在线文档更详细地探索它们。以下配置演示了我们如何使用cloud init
来生成一个用户、一个组、将用户分配到该组,并为机器设置权限和配置:
#cloud-config
groups:
- bar
users:
- name: foo
groups: sudo, bar
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
ssh_authorized_keys:
- ssh-rsa your-public-key
我们可以将其嵌入到cloudinit_config
数据源中,以生成我们传递给新创建虚拟机的用户数据:
data "cloudinit_config" "foo" {
gzip = false
base64_encode = false
part {
content_type = "text/cloud-config"
content = file("${path.module}/users.yaml")
}
}
配置 DNS 记录
使用自动化管理域名系统(DNS)对于某些发布策略(如蓝绿部署)至关重要。Terraform 提供了一个可扩展的框架,可以轻松处理这种重要配置。
虽然大多数云服务提供商都提供自己的 DNS 服务,从 Amazon 的 Route 53 到 Azure 的私有 DNS 区域,通常都有针对你选择的云的第一方 DNS 管理解决方案。许多第三方公共 DNS 注册商提供 DNS 服务,如 Cloudflare、Akamai、GoDaddy 和 DynDNS。
然而,由于 Terraform 提供了如此可扩展的基础,管理 DNS 并不仅限于通过各自的提供商管理公共云平台。你还可以管理本地 DNS 服务器或任何在你选择的公共或私有云中使用基础设施即服务构建的自定义 DNS 基础设施。
您可以使用支持秘密密钥(RFC 2845
)或 GSS-TSIG(RFC 3645
)身份验证方法的任何 DNS 服务器与DNS
提供商配合使用。
摘要
在本章中,我们深入探讨了 HashiCorp 构建的实用提供商,帮助我们增强我们的 IaC 解决方案。我们学习了如何与外部系统集成,如何处理存储在文件系统上的资产,以及如何在 Terraform 中随机化和填充空白。这些 Terraform 提供商非常多才多艺,随着你深入探索,你会发现它们越来越有价值。
如同使用其他提供商一样,始终在required_providers
块中明确指定版本号来引用提供商。不要隐式使用最新版本。除此之外,这些提供商的资源可以嵌入到任何 Terraform 模块中。在设计可重用模块时,需要特别注意,确保上游客户端模块对提供商的要求尽可能少,因为如果模块需要声明多个不同的提供商,这可能会增加希望重用该模块的用户的复杂度!
在下一章中,我们将建立一些可以应用于我们的 IaC 的架构概念,无论我们瞄准的是哪个云平台。毕竟,我们的 IaC 的好坏取决于它所定义的架构。因此,我们必须充分理解云服务解剖的典型元素,以及虚拟机和容器等不同计算范式的机制。接下来,我们将通过多云视角来审视虚拟机架构。
第二部分:云架构与自动化概念
如果没有扎实的云架构和软件开发流程的基础,进入基础设施即代码(IaC)的旅程将是徒劳的。幸运的是,许多这些概念跨越了云平台,一旦你理解了关键概念,就能将这些知识应用到你选择的云平台中——无论是 AWS、Azure 还是 GCP。
本部分包括以下章节:
-
第四章,云架构基础——虚拟机与基础设施即服务
-
第五章,超越虚拟机——容器与 Kubernetes 的核心概念
-
第六章,将一切连接在一起——GitFlow、GitOps 与 CI/CD
第四章:4
云架构基础 – 虚拟机与基础设施即服务(IaaS)
本书旨在帮助你掌握 Terraform,但成为真正的 Terraform 大师需要什么条件呢?Terraform 是一个基础设施即代码(IaC)工具,能够帮助你通过代码描述云架构。如果没有对基础架构的深入理解,你永远无法成为 Terraform 的真正大师。因此,我在接下来的章节中将提供一些基础概念,为后续章节打下基础,届时我们将在三种不同的云计算范式下构建复杂的云架构:
-
虚拟机
-
容器
-
无服务器
有了这些基础知识,你将理解必要的概念,以便跟随后续章节中我们所构建的解决方案架构。
在本章中,我们将重点关注理解、架构设计和自动化基于虚拟机的解决方案所必需的关键概念。首先,我们将为一些基本的网络概念打下基础,例如子网、路由、边界安全、对等连接、虚拟专用网络(VPNs)以及专用网络连接。
接下来,我们将深入探讨虚拟机的基本结构,包括磁盘和网络接口。然后,我们将考虑 Windows 和 Linux 虚拟机之间的微妙差异。接下来,我们将介绍自动扩展。
最后,我们将通过讨论虚拟机的配置来总结这一部分内容,涵盖可变基础设施和不可变基础设施的实践,以及相应的基础设施即代码(IaC)实践和工具。
本章涉及以下内容:
-
理解网络的关键概念
-
理解计算的关键概念
-
理解虚拟机镜像的作用
理解网络的关键概念
根据你和你的组织如何利用云,你可能会涉及到三种基础设施配置范式中的一种或多种:虚拟机、容器或无服务器。每种范式都有不同的优缺点,你在选择它们作为解决方案架构时,必须仔细考虑这些因素。不过,重要的是要认识到每种范式都有适合的时机和场合,使其变得有价值。在本书中,我希望帮助你学习如何利用 Terraform 在这三种主要的云计算范式下,在三大云厂商(在写作时)上部署复杂的解决方案。
每种范式都有一些特定的概念,这些概念跨越不同的云平台,作为从业者和架构师的你需要理解这些概念,以便使用 Terraform 设计和实施解决方案。
虚拟机是每个云平台上的标准服务,因为大多数组织希望将现有应用程序以最小的变化利用云计算。虚拟机使这些组织能够完全控制从操作系统到环境配置的每个方面。通过这种低级别的控制,组织可以在最小变化的情况下将应用程序迁移到云基础设施,同时保持完全的控制权。
这种方法是实用的,因为虚拟机是大多数 IT 组织都非常熟悉的概念和架构。那些希望迁移到云平台的组织,可能已经在其本地数据中心使用虚拟机。
这意味着,在你将基础设施自动化到云中时,你需要理解核心概念和常见的架构模式。
使用虚拟机的好消息是,大多数云平台的架构相对相似,因此,如果你知道自己在寻找什么,通常可以找到实现该解决方案特定方面的相应服务或 Terraform 资源。不同的云平台之间可能存在细微差别,你需要通过详细分析和优化解决方案来了解这些差异。不过,只要你理解了基本概念,就能很容易地在云平台之间进行映射,并且能相对快速地使用 Terraform 提高生产力。
本书中,我们将使用 AWS、Azure 和 Google Cloud Platform 上的虚拟机构建一个端到端的解决方案。为此,你必须理解一些跨越云平台的关键概念,这些概念将帮助你在各自云平台的 Terraform 提供者中导航架构和相关的 Terraform 资源。
网络
所有虚拟机都运行在网络上,每个云平台都有一个相应的服务来处理解决方案的这一方面。网络本身相对简单,创建它只需要一个主要信息:网络地址空间,即一个 IP 地址块,属于一个连续的范围。
一个 IP 地址由 32 个位组成,这些位被分成八位字节,并转换为0
到255
之间的整数。IPv4 的单个 IP 地址包含四个八位字节,从而产生超过 40 亿个可寻址的 IP 地址。而 IPv6 则有 16 个八位字节,能够提供更多的 IP 地址。
CIDR 表示法是一种将 IP 地址范围表示为连续块的方法。CIDR 块由起始地址和前缀长度组成,两者之间用斜杠分隔。例如,10.0.1.0/24
表示一个从 10.0.1.0
开始并扩展到 10.0.1.255
的 IP 地址范围——共 256 个 IP 地址。10.0.1.0
是起始 IP 地址,24
是应共享的位数。由于一个 IP 地址由 32 位组成,每个十进制数代表 8 位,因此 24 位意味着四个八位组中的前三个是共享的,只有最后一个数字发生变化。由于最后一个数字范围从 0
到 255
,这就给我们提供了从 10.0.1.0
到 10.0.1.255
的 256 个 IP 地址。
存在一些保留的 IP 地址范围用于私有网络。10.0.0.0/8
和 172.0.0.0/12
是企业中最常见的范围,而我敢肯定你在家里也遇到过 192.168.0.0/16
。
熟悉 CIDR 表示法,并理解选择不同大小的前缀的影响非常重要。通常,/16
是最大的前缀(65,536 个 IP 地址),而 /28
是最小的(16 个 IP 地址),这些是云平台支持的前缀——不过也有所不同,所以你应该查看云平台的文档。更重要的是,考虑你的需求,如果你公司有内部网络团队,在确定适合你解决方案的范围时,一定要咨询他们。
通常,组织会维护一份已分配给不同团队或应用程序的 IP 地址范围列表,以防止 IP 地址冲突。如果你的组织已经有本地网络,那么在首次进入云计算时,这项做法至关重要。如果你使用的是默认值——比如10.0.0.0/16
——或者总是使用相同的地址范围,如果你想将项目连接到组织内的其他网络时,可能会遇到麻烦。
尽管这在不同的云平台上可能会有所不同,但通常你会在特定区域内配置虚拟网络,比如在 AWS 和 Azure 上。然而,在 Google Cloud Platform 上,虚拟网络是全球性的,跨越所有区域。
子网
一旦你为网络选定了 IP 地址空间,你将把它划分为子网。子网允许你根据不同的需求对网络进行分段,包括提高安全性或改善组织和操作效率。
从安全角度来看,子网非常重要,因为它能够隔离架构中的组件,从而减少发生问题时的影响范围。通过创建路由规则来控制子网之间的网络流量,你可以通过减少攻击面来提高安全性。
根据云平台的不同,子网可能还会影响资源的物理位置,例如可用区。这在 AWS 中是这样的。然而,Azure 和 GCP 并没有这个限制,因为它们的子网可以包含跨越整个区域的资源。
路由
一旦您使用子网对虚拟网络进行了分段,建立网络流量模式变得至关重要,这时需要使用路由表。
路由表允许您根据不同流量类型的规则,将网络流量引导到正确的终端。例如,可能会区分互联网流量,它会路由到互联网网关或 NAT 网关。类似的网络路由规则可以通过 VPN 或 Direct Connect 连接、对等虚拟网络、传输网关或服务终端,将流量路由到本地网络。
网络安全
一旦您拥有了一个虚拟网络和一组子网,每个子网都有自己的用途和资源,您很可能需要应用安全控制,确保只有预期的网络流量能够在不同子网的资源之间传递。
大多数云平台都有某种形式的这个概念,但它们可能有不同的名称。它们可能有其他附加机制——可以附加在子网、虚拟机或虚拟的Allow
和Deny
规则上,而有时它们只支持Allow
规则。
Azure 和 AWS 提供了一个较低级别的机制,主要关注物理网络层,而较高级别的机制则侧重于逻辑应用层。Google Cloud Platform 将这两个概念合并为一个结构,称之为防火墙规则。
AWS 有网络访问控制列表(NACLs),它们附加到子网并控制子网之间的网络流量流动。因此,它们仅适用于网络地址范围——而非 AWS 资源,如网络网关或服务终端。它们是无状态的,这意味着在大多数情况下,您需要使入站和出站规则匹配才能确保连接成功。
相对而言,AWS 还有安全组,这些安全组是有状态的,只支持Allow
规则,并允许您使用其唯一标识符在不同的网络地址范围和 AWS 资源之间路由流量。安全组可以逻辑上附加到子网,或者直接附加到虚拟机(EC2 实例)上,但 AWS 会在虚拟机级别评估它们。将安全组附加到子网仅会导致该安全组隐式地级联附加到该子网中的所有虚拟机。
Azure 也有两种用于限制网络流量的构造:网络安全组(NSGs)和应用安全组(ASGs)。NSGs 在许多方面结合了 AWS 的 NACL 和安全组,但在关注物理网络层的同时,去除了某些逻辑附加功能。ASGs 是逻辑性的,可以通过 NIC 与虚拟机关联。就像 AWS 的 NACL 一样,你可以将 NSGs 看作是控制网络之间流量的工具,而 AWS 的安全组和 Azure 的 ASGs 都专注于在网络内部以更精细的粒度——以应用为中心——控制资源之间的流量。
Google Cloud Platform 有一个构造:防火墙规则。这个构造是有状态的,但也支持 Allow
和 Deny
规则。它可以附加到虚拟网络或区域,或者可以全局附加。
网络对等连接
虚拟网络对等连接 是大多数云平台提供的网络功能,允许你在同一云平台内连接虚拟网络,无需额外的基于 VPN 的连接。
要在两个虚拟网络之间创建对等连接,它们必须位于同一云平台中,并且它们的网络地址空间中不应存在冲突。这种潜在的困境是需要仔细思考和应用适当的网络地址范围治理的原因之一。
对等连接(Peering)是一种能力,可以消除通过 VPN 连接实现更复杂的私有站点到站点连接的需求,是在云中连接网络的首选方法。
服务端点
大多数云平台提供的服务主要是通过互联网直接访问的。在安全至关重要的情况下,避免通过互联网传输数据是至关重要的。服务端点是云平台提供的功能,能够在不经过公共互联网的情况下,实现虚拟网络与云环境内特定服务之间的私有网络通信。
虽然这一概念和目标在我们本书中涉及的所有云提供商中都是存在且保持一致的,但它有不同的名称,在每个平台的服务产品中支持程度不同,可能还有其他的附加和路由机制来设置服务端点。
VPN 和 Direct Connect
当虚拟网络对等连接不可用时,你始终可以利用传统的站点到站点 VPN 连接选项,将网络从本地网络或跨云提供商的网络进行连接。
在设置 VPN 连接时,大多数云平台要求你提供一个表示源网络和目标网络配置的资源。
目标网络是你为 VPN 配置的入口点所在的网络,VPN 流量会通过此网络到达云中托管的资源。源网络则是需要连接到目标网络的设备所在的网络。源网络通常是本地网络,但不一定如此。最常见的使用场景是连接两个不同云平台上的网络。
在本节中,我们学习了云网络的关键概念,无论你使用的是哪个云平台,只要配置虚拟机,你都会遇到这些概念。每个平台可能会有一些细微的差别,这些差别会影响你使用虚拟机的方式,以及它们如何影响你架构的可用性和结构,但其功能本质上是相同的。
接下来,我们将介绍虚拟机的关键概念,包括基本结构。这包括磁盘、网络接口卡(NIC)以及虚拟机本身,此外,还包括 Windows 和 Linux 之间的操作系统特定差异以及云特定功能,如自动伸缩。
理解计算的关键概念
虚拟机是物理计算机的软件仿真。就像普通计算机一样,它运行操作系统以及你安装的任何应用程序。最终,它是运行在物理硬件上的。然而,在云平台上,云平台将物理硬件和管理虚拟机的虚拟化管理程序(Hypervisor)抽象出来,用户无需直接接触。
虚拟机在云平台上最常见的两种形式是:Linux 和 Windows,云平台通过市场提供支持各种当前和历史版本。
虚拟机的主要配置属性包括其大小、作为操作系统磁盘使用的虚拟机镜像、附加的数据磁盘和网络配置。
云平台使用库存单位(SKU)来创建一个标准配置,规定虚拟机的大小和硬件配置文件。这个模式在云平台中很常见,但 SKU 名称遵循不同的命名规范。云平台通常有一个类似的组织体系,包含一些子类别,如通用型、计算优化型和内存优化型。还有一些虚拟机配备了特定的硬件组件,如图形处理单元(GPU)。
虚拟机镜像是一个预配置操作系统的磁盘镜像,具体内容可以根据镜像的用途而包含额外的预安装软件。虚拟机镜像是自动化虚拟机部署中的一个关键组成部分。我们稍后会进一步详细讨论这一点。
磁盘
虚拟机可以附加额外的数据磁盘以增加存储空间。与虚拟机一样,这些磁盘可以有不同的大小和性能特性。与虚拟机大小不同,虚拟机使用表示固定配置类型的类别 SKU 进行变更,而磁盘则使用连续度量单位进行大小设置:千兆字节(GB)。
除了磁盘大小,您还可以选择几种不同的性能类别,针对不同的工作负载场景进行优化,例如通用、优化吞吐量和预配置的 IOPS,这些都旨在确保可靠的性能水平。
您选择的 SKU 会影响您可以附加到虚拟机的磁盘数量和类别,较大的虚拟机支持更多数量的磁盘。
网络接口卡(NIC)
虚拟机可以附加逻辑上代表物理网络接口卡的 NIC。与磁盘一样,虚拟机的大小会影响您可以添加的 NIC 数量及可启用的功能。
通过 NIC 配置,您可以将网络接口组合在一起以创建更高的带宽,或将它们连接到不同的子网以连接虚拟机。后一种选项让您能够跨越两个独立的网络。
Linux 与 Windows
Linux 和 Windows 虚拟机在虚拟机大小、磁盘和 NIC 等方面是解剖学上相同的。然而,在使用 Terraform 和其他工具进行管理时,有一些关键差异需要注意。
身份验证与远程访问
Windows 虚拟机通常需要一个管理员用户名和密码,而 Linux 虚拟机通常需要 SSH 密钥。初始设置后,您可以配置 Windows 支持 SSH 访问,但首次登录仍然需要基于密码的凭据。
这个警告也体现在使用 Windows 和 Linux 远程访问虚拟机时。Windows 使用远程桌面协议(RDP),需要基于密码的登录。Linux 使用 SSH,可以支持基于密码或密钥的登录。
配置脚本
Windows 默认支持几种不同类型的脚本。最常见的脚本是批处理脚本,使用 Windows 的命令行解释器(CMD),以及PowerShell。虽然微软最初是为自动化 Windows 上的管理任务而开发 PowerShell,但现在也在 Linux 上增加了对 PowerShell 的支持,尽管社区的采用度尚未达到关键质量标准。
虽然 Linux 发行版不同,ksh
、csh
和 tsch
—它们的功能与 Bash 相似,但受欢迎程度各异。
Windows 也通过引入Windows 子系统 Linux(WSL)加入了这一行列,安装后可以在 Windows 上原生执行 Bash 脚本。
自动扩展
利用云计算的一个典型优势是能够在大规模上为解决方案增加弹性。也就是说,当应用程序的使用量很高时,能够增加容量;而当应用程序的使用量减少时,又能减少容量。
云平台提供了使这一过程变得非常简单的机制。虽然它们可能会为这一功能起不同的名称,但解决方案的结构保持不变。你只需提供虚拟机镜像的详细信息,包括虚拟机应有多大、实例的硬性范围约束(如最小值和最大值)、以及最后,提供一些参数来控制何时以及多快地进行扩容或缩容。
本节已经教会了我们如何配置虚拟机的一些基本概念。这些概念在不同的云平台上都有体现。尽管各云平台之间可能有一些小的细微差别,但它们的操作方式是类似的。
接下来,我们将探讨虚拟机镜像在我们如何自动化虚拟机中的作用。
了解虚拟机镜像的角色
虚拟机需要安装操作系统和其他应用程序才能发挥其作用。虚拟机镜像是一个包含已安装可启动操作系统的虚拟磁盘的单一文件。它是虚拟机在某一特定时间点的快照。这个快照包含虚拟机的状态,包括操作系统、已安装的应用程序以及其他设置。
静态虚拟机
在设置单个虚拟机,或者在解决方案架构中具有不同角色和责任的一组虚拟机时,需要进行配置,以使每个虚拟机处于能够执行其作为解决方案一部分的职责所需的状态。
这些配置包括以下步骤:
-
安装操作系统
-
配置操作系统
-
安装软件更新和安全补丁
-
安装第三方软件
-
配置第三方软件
当然,以上每个步骤可能会根据虚拟机在解决方案中的角色而有所变化。步骤越往后,配置的变化可能性越大,其中操作系统安装是最稳定的,而第三方软件配置则因虚拟机角色的不同而具有最多样性。
例如,一个简单的二层架构,要求 Java Web 应用程序与 PostgreSQL 数据库进行通信,将会有两个角色。一个角色负责安装 Java Web 应用服务器,而另一个角色则安装 PostgreSQL 数据库。在这种情况下,两个虚拟机可能会共享完全相同的操作系统、配置和安全补丁。然而,当涉及到第三方软件时,一个虚拟机可能需要 Java Web 应用服务器软件,而另一个则可能需要 PostgreSQL 数据库服务器软件。
每个角色都需要不同的配置步骤来配置服务器以实现其目的。例如,这些步骤可能包括安装软件包、设置环境变量、更新配置文件、创建用户账户、设置权限、运行自定义脚本,或者任何其他需要的操作来设置机器。
在使用云时,你通过指定操作系统磁盘镜像将此配置传递给虚拟机。使用的磁盘镜像将决定虚拟机是启动为仅安装了干净的 Ubuntu 22.04 系统——准备好手动配置,还是启动为一个完全运行的 Java Web 应用服务器,无需任何手动干预。
每个云平台都提供了一套大量的磁盘镜像,你可以用它们来启动不同用途的虚拟机。最常见的是基线镜像,安装了特定版本的操作系统,如 Windows Server 2019、Ubuntu 22.04 或 RedHat Enterprise Linux。
由于市场上有许多提供基线操作系统安装的镜像,你可以启动一个 Ubuntu 22.04 虚拟机,安装 Java Web 应用软件,按照你的要求进行配置,并创建一个新的虚拟机镜像。这个新的虚拟机镜像将启动为 Java Web 应用服务器,而不是全新的 Ubuntu 22.04 安装,这意味着你离将这个虚拟机用于托管你的 Web 应用更近一步。
你可以使用管理此配置的自动化技术来执行你可能手动执行的操作,假设你是从一个干净的操作系统安装开始的。有多个自动化工具专注于这个问题——你可能会惊讶地发现,Terraform 其实并不是其中之一。虽然 Terraform 可以通过几种不同的技术提供此配置,但这并不是它的主要关注点。通常,Terraform 应该与另一个专注于此的工具一起工作。这两种工具应该共同决定如何分担部署此配置的责任。
使用配置管理器
一种常见的做法是利用 Terraform 来提供解决方案中所需的虚拟机,并依赖配置管理工具处理操作系统中每个虚拟机的其余配置。
这种方法的好处是将配置管理的责任完全隔离到一个适合处理这项任务的工具中。一些流行的工具例子包括 Chef 或 Puppet,它们使用代理将配置应用到虚拟机上——或者它也可以是一个像 Ansible 这样的工具,它不需要代理,使用 SSH 作为应用配置的主要方法。
由于 Ansible 强烈依赖 SSH 而 Windows 对这种远程访问方法的支持有限,Ansible 在历史上并不是管理基于 Windows 的虚拟机的理想工具。像 Chef 和 Puppet 这样的工具,在 Windows Server 是主导的服务器操作系统的企业 IT 环境中得到了更广泛的采用。然而,这种情况似乎正在发生变化,Ansible 得到了更多支持,新的 Windows 版本使得使用这种方法进行管理变得更加容易。
自定义虚拟机镜像
当你将虚拟机配置到足够接近其在系统中角色的位置,只有一些小的最终配置更改时,你可以捕捉操作系统磁盘的快照,并从中创建一个虚拟机镜像,以便你可以使用该镜像启动额外的虚拟机。当你使用此镜像时,这些虚拟机将已经包含你之前设置的配置,无需重新设置。
这种方法的好处是提高了启动速度。由于你在构建镜像时已经完成了大部分工作,每次启动新虚拟机时就不需要重复这些工作。你只需要等待云平台启动虚拟机,它会自动安装并准备好所有所需的内容,而无需等待配置管理器设置所有内容。
最常用的工具是 Packer。它是一个由 HashiCorp 发布的开源产品。
你可以使用 JSON 或 HCL 编写 Packer 模板。但你应该使用后者,因为它能让你更容易管理和组织代码。一个 Packer 模板由三部分组成:
-
用于建立与目标平台连接以构建虚拟机的构建器
-
配置工具提供在创建镜像之前必须在虚拟机上执行的指令
-
后处理器在构建器和配置工具执行之后执行,并在创建工件之前执行最后的操作
Packer 的配置工具包括三种主要类型:
-
脚本执行:在支持 Windows 和 Linux 的各种 shell 环境中执行脚本
-
文件:从本地环境上传文件或目录到虚拟机
-
流程控制:暂停执行或触发 Windows 重启以使设置生效
构建与烘焙
将操作系统进行干净安装后,使用配置管理工具应用所需的状态,这就是我所说的构建方法。其对立面,烘焙方法,使用自动化工具——例如 Packer——启动一个临时虚拟机,设置所有内容,然后创建一个新的虚拟机镜像。
构建选项非常适合用于第二天的操作,因为它使您能够轻松地应用补丁并随着时间推移管理环境。在配置管理工具的控制下,您可以与虚拟机保持实时连接,快速更新它们而不会中断服务。相比之下,使用烘焙方法时,您首先需要烘焙一个新的镜像,然后升级所有虚拟机以使用最新镜像。这会导致在使用旧镜像拆除机器并使用新镜像启动机器时出现停机时间。开发虚拟机镜像也可能是一个缓慢的过程,因为每次烘焙可能需要相当长的时间,而配置管理工具在出现问题时能提供相对接近实时的反馈。
当您需要快速启动额外的虚拟机并且不希望等待配置管理工具在虚拟机上做一次完整的解决方案堆栈安装时,烘焙方法会显得特别有用,因为这会消耗掉宝贵的时间,而这些时间本可以用来响应终端用户的请求。以下是一些可以受益于此方法的情况:
-
故障转移和恢复:当您发现一个先前健康的虚拟机变得不健康并且需要迅速更换时,这种情况可能是由于停机或瞬时硬件故障引起的。
-
自动扩展:当您需要扩展以应对服务的流量激增时,理想情况下,您的新虚拟机应在触发扩展事件时尽快接管负载。如果没有,您可能需要通过减少扩展上限并增加缩减下限来预留更多的缓冲时间。这种方法允许系统更早地启动资源,并更慢地关闭它们,从而确保固有的延迟不会影响终端用户。
构建与烘焙并不是互斥的操作。通常,两者之间存在一定的分歧。在大多数情况下,有些配置是你无法烘焙到镜像中的。这些配置属于以下几类:
-
频率:您应该将那些很少变化的配置烘焙进镜像。相反,您应该将那些在运行时可能需要调整的配置包含在构建中。
-
后配置值:您应该将需要在配置后才能获得的值烘焙进镜像。这些值可能包括私有 IP 地址、DNS 主机名或其他在配置过程中生成的元数据,这些信息只有在结束时才能获得。
到此,我们本章内容结束。
总结
本章回顾了理解多云平台虚拟机所需的核心概念。在本书中,我们将使用虚拟机架构为每个三大云提供商(AWS、Azure 和 Google Cloud Platform)构建端到端解决方案。每个提供商将在其实现和应用这些概念时有所不同。资源会有所变化,但这些概念在我们的架构中所呈现的方式将保持相对一致。
在本章中,我介绍了在各大云平台中,自动化解决方案所需理解的常见概念。这些概念包括虚拟网络、子网、对等连接和服务端点等云网络概念,它们对于创建和管理隔离的网络环境以及确保资源之间的高效通信至关重要。我们还探讨了计算概念,如用于网络连接的虚拟网卡(NIC)和用于可扩展存储解决方案的虚拟磁盘。
另一个重要话题是“构建与预配置”困境,讨论了操作系统配置应在机器镜像中构建多少,和应在机器部署后添加多少。这个问题涉及理解预配置镜像(预设)与部署后配置(构建)之间的权衡,前者有助于简化部署流程,而后者则增强了灵活性并减少了镜像管理的复杂性。通过理解这些概念,您将能够更好地设计和自动化跨多个云平台的健壮、可扩展的解决方案。
在下一章,我们将探讨新型云计算范式所需的核心概念:容器。
第五章:5
超越虚拟机——容器和 Kubernetes 的核心概念
在上一章中,我们熟悉了虚拟机(VM)架构和自动化基于虚拟机的解决方案所需的核心概念和机制。在本书中,我们将构建端到端的解决方案,涵盖三大超大规模云服务提供商——亚马逊 Web 服务(AWS)、Azure 和 谷歌云平台(GCP)——并涵盖三种云计算范式:虚拟机、容器和无服务器。在本章中,我们将探讨使用各云平台提供的托管 Kubernetes 服务来解决基于容器的架构问题所需的核心概念。
为了实现这一目标,我们必须了解容器、Kubernetes 的基础知识,以及它们如何融入 Terraform 生态系统。就像虚拟机和用于配置管理的工具链,以及构建与烘焙的困境一样,在基于容器的架构中,我们需要做出一些决策,关于 Terraform 与其他工具之间的边界在哪里,以及如何最好地将容器和容器编排工具的配置管理与我们提供的云基础设施进行集成,以托管这些容器。
本章涵盖以下主题:
-
理解容器架构的关键概念
-
利用 Docker 构建容器镜像
-
使用容器注册表
-
理解容器编排和 Kubernetes 的关键概念
-
理解 Kubernetes 清单
-
利用 Kubernetes 提供者来配置 Kubernetes 资源
-
利用 Helm 提供者来配置 Kubernetes 资源
理解容器架构的关键概念
虚拟机(VM) 在你希望最小化对应用程序和软件操作变更时,在云中运行是非常有效的,但它们也有缺点。通过拥有完整的虚拟机(无论你配置的是何种大小),你可以自由使用虚拟机的任何资源(多或少)。然而,许多组织发现,即使在遵循工作负载隔离的最佳实践或单一职责原则(SRP)的情况下,它们的虚拟机阵列仍然面临低利用率的问题。
反过来,当最大化利用率成为目标时,组织会将许多不同的服务和组件加载到一个单一的虚拟机中,以至于每个虚拟机——虽然被高度利用——却变成了一种难以管理和维护的困境。虚拟机会有无数的依赖冲突,并且在同一个虚拟机内,独立但共存的进程之间会出现资源争用。
这种工作负载隔离与资源利用之间的矛盾是容器技术旨在解决的问题,也是容器编排工具(如 Kubernetes)通过提供弹性和可扩展性来帮助解决的地方。
在本书中,我们将使用基于 Kubernetes 的容器技术,在 AWS、Azure 和 GCP 上构建端到端的解决方案。为此,你需要理解一些关键概念,这些概念超越了云平台,帮助你在各个云平台的 Terraform 提供者中导航架构和相关的 Terraform 资源。
容器
容器 允许你将应用程序打包到一个逻辑上与其他应用程序隔离的环境中,而无需虚拟化底层物理硬件和完整操作系统所带来的开销。无论是 Windows 还是 Linux,操作系统都会消耗资源,影响你的计算能力。
容器使用两个 Linux 内核原语:命名空间 和 控制组。这些构造使容器运行时能够在 Linux 操作系统中设置一个隔离的环境。命名空间的核心是隔离,它允许我们将操作系统分割成多个虚拟操作系统,每个虚拟系统都有自己的进程树、根文件系统、用户等。每个容器可能感觉像一个常规操作系统,但实际上并不是。控制组负责监管主机系统资源的分配——包括 CPU、内存和磁盘 I/O——以确保实际的物理服务器不会因容器消耗的资源而被压垮。
启用容器的最后一个组件是分层文件系统。这类似于我们以前构建虚拟机镜像的方式——只是层之间有更好的隔离。当我们构建虚拟机层时,当我们对虚拟机镜像进行更改并创建新的镜像时,我们无法再将基础层从顶层分离出来。容器可以应用只包含下层差异的文件系统层。这种方法创造了一种极为紧凑且高效的方式,将更改分层到每个容器镜像上,从而组成容器操作的最终文件系统——最顶层是可由容器本身写入的。
容器的一个关键优势是它们的高效性。与虚拟机不同,虚拟机需要为每个实例分配独立的操作系统和资源,而容器直接利用主机系统的内核。这意味着它们消耗的资源更少,并且启动速度比虚拟机更快。多个容器可以在单个主机上同时运行,从而更有效地利用系统资源。这使得我们能够创建更高密度的工作负载——从而减少宝贵的系统资源(如 CPU 和内存)空闲时的浪费,而在云端工作时,这种浪费就像把钱倒进水沟里!
现在我们已经对容器是什么以及它与虚拟机的区别有了清晰的理解,让我们来看看管理单个容器配置的事实标准工具:Docker。虽然本书的主题并非专门关于 Docker,但如果你打算精通 Terraform 并与基于容器的架构一起工作,你不可避免地会直接接触到这个工具,或者需要将其集成到 持续集成/持续部署(CI/CD)过程中。
利用 Docker 构建容器镜像
Docker 引擎使得容器设置过程更加简单。它提供了一种一致的元语言来描述容器,并提供命令行工具来构建、查询和运行容器镜像。
编写 Dockerfile
Docker 使用一种简单的语法,你可以用它来定义容器的基本信息。这个基本结构包括构建基础镜像的指令(FROM
)、作者(MAINTAINER
)、要复制的文件和执行的命令(COPY
和 RUN
),以及入口点进程(CMD
)。
这与 Packer 模板的结构相似,除了入口点的过程。使用 Packer 时,它只是一个虚拟机;无论运行哪些进程,都会根据你的配置启动。而在 Docker 中,你需要明确指出要启动哪个进程,因为容器在隔离中只运行一个进程。
你还可以通过设置工作目录、添加环境变量以及暴露网络端口来进一步配置运行时。
一个简单的 Dockerfile 看起来像这样:
# Use an official Python runtime as a parent image
FROM python:3.7-slim
# Set the working directory in the container to /app
WORKDIR /app
# Copy the current directory contents into the container at /app
COPY . /app
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Make port 80 available to the world outside this container
EXPOSE 80
# Run app.py when the container launches
CMD \["python", "app.py"]
请注意,我们正在从一个名为 python:3-7slim
的基础镜像构建,并将当前文件夹的内容复制到容器的 /app
目录中。这一步将 app.py
脚本复制到容器中,以便我们在文件底部设置它作为执行点时可以使用。这个 Python 脚本设置了一个 Web 服务器,并将其暴露到端口 80
。
构建 Docker 镜像
就像 Terraform 一样,Docker 使用当前工作目录来推导其上下文。因此,在构建 Docker 镜像时,你需要从 Dockerfile 所在的同一目录执行 docker build
命令。然而,你可以通过指定不同的路径来覆盖这一点:
docker build -t your-image-name .
-t
标志让你为镜像打上一个易于记住的标签。.
实例可能看起来不太合适,但它告诉 Docker 在当前目录中查找 Dockerfile。
构建完成后,你可以运行以下命令查看镜像:
docker images
运行 Docker 镜像
Docker 镜像就像我们使用 Packer 构建的虚拟机镜像,它们代表一个尚未启动的虚拟机。它们有潜在的能量,但需要作为虚拟机的操作系统磁盘启动,才能转化为动能,变成一个运行中的虚拟机。Docker 镜像对于容器也是一样的。我们需要使用镜像启动一个容器,并指定运行时配置:
docker run -p 4000:80 your-image-name
在这种情况下,由于我们在容器中暴露了端口80
,我们需要将一个端口映射到容器的端口80
。-p
标志将容器内部的网络端口映射到主机机器的端口。这个设置将把主机的4000
端口的流量路由到容器的80
端口。
你可以运行任意数量的容器,前提是主机机器能够承载。你唯一的限制是主机机器的技术资源。有时,云平台会根据你主机所运行的虚拟机 SKU 强加一些限制。
要查看正在运行的容器,你可以执行以下 Docker 命令:
docker ps
本节内容应帮助你理解与 Docker 镜像合作的基本原理。虽然 Docker 提供了许多其他命令和标志来管理你的镜像和容器,但这些超出了本书的范围。我将为你提供足够的理论和实践,使你能够高效地构建基于容器的架构,并使用 Terraform 进行管理。
在这一部分中,我们熟悉了用于创建容器镜像的命令行工具:Docker。
在接下来的部分,我们将探讨如何将我们使用 Docker 创建的容器镜像发布到容器注册表,以便我们可以使用它们部署容器。
与容器注册表合作
容器注册表只是一个服务器端应用,作为中央存储并允许你将容器镜像分发到需要运行这些镜像的主机机器。采用这种方法在利用 CI/CD 流水线时非常有优势,因为你需要一个中央位置来拉取容器镜像。
它们通常提供版本控制、标签和共享机制,允许你跟踪不同版本的容器镜像、保持稳定的发布版本,并与他人共享镜像——无论是在你的组织内部,还是公开共享。
就像git
一样,任何人都可以自行设置容器注册表,但多个托管服务在各自的云平台上提供最佳服务。此外,还有一个云无关且面向社区的解决方案:Docker Hub。Docker Hub 是 Docker 查找镜像的默认注册表,你可以用它来共享你希望公开的镜像,或将其私密用于内部目的。它提供免费套餐以及更多存储和功能的付费套餐。
Docker Hub
与容器注册表交互的机制大体相似,具体取决于所使用的服务——只有少许差异。举个例子,因为它是 Docker 默认使用的容器注册表,我将展示如何使用Docker Hub进行身份验证、标记、推送和拉取镜像。
首先,你需要进行身份验证。根据你的注册表服务,这一步可能需要额外的工具。然而,使用 Docker Hub 时,你不需要安装其他工具,但你需要在 Docker Hub 上注册一个帐户:
docker login
前面的命令将启动一个交互式登录过程,你需要提供你的 Docker Hub 用户名和密码。
在将镜像推送到注册表之前,你必须用注册表的地址对其进行标记:
docker tag foo:1.0 markti/foo:1.0
前面的命令首先指定了特定版本1.0
的my-image
源镜像。然后,它指定了同一镜像和版本在我的markti
Docker Hub 账户下的目标镜像。保持本地和远程环境之间的镜像名称和版本同步对于确保环境的一致性至关重要。在镜像标记完成后,你可以将其推送到注册表:
docker push markti/foo:1.0
前面的命令将镜像推送到远程容器注册表。现在,你可以使用合适的权限,通过使用 Docker Hub 用户名作为注册表名称、容器镜像名称和标签来拉取镜像:
docker pull markti/foo:1.0
请记住,容器注册表可能会有稍微不同的命名规范和认证流程。
在本节中,我们学习了如何使用容器注册表,它们是容器架构中至关重要的基础设施。在下一节中,我们将准备好从架构角度和开发者、运维人员以及 CI/CD 流水线中的实际使用来学习 Kubernetes。
理解容器编排和 Kubernetes 的关键概念
Kubernetes 是一个扩展容器运行时职责的平台,容器运行时在单个主机级别操作。Kubernetes 的工作是跨多个节点执行这一任务。正如我们在本章第一节中学到的,容器运行时使用 Linux 操作系统构造——控制组——通过确保容器运行的物理(或虚拟)主机保持健康,从而保护操作系统的健康。Kubernetes 基本上做的就是同样的事情,只不过是跨多个服务器进行。
大多数应用程序或系统通常会被组织成不同的组件、层或微服务——每个组件都有自己的责任、相应的应用程序代码和技术栈来实现其功能。系统中的每个组件都会有自己的容器,里面安装了相应的软件。
当我们使用虚拟机(VM)部署系统时,我们会以这样的方式部署:同一个组件会部署到两个或更多的虚拟机上,并确保这些虚拟机不共享相同的底层物理设备。这种隔离可以是简单的不同物理主机在同一机架上,甚至可以是完全不同数据中心中的不同物理主机——有时这些主机之间可能相距数十,甚至数百英里。这使得我们能够在发生故障或影响底层硬件组件的问题时,实现高可用性(HA)和恢复力。
与使用虚拟机时不同,我们的应用程序组件并不是在独立的虚拟机上运行,而是部署在集群节点上,通常与来自其他应用程序的 pod 一起运行。
Kubernetes 尝试确保我们的应用程序容器不会部署在同一节点上。这样,如果集群中的某个节点发生故障,我们的应用程序就不会宕机。Kubernetes 还进一步智能地将容器重新安排到其他健康节点上。为了实现这一点,Kubernetes 在其内部逻辑层和底层物理层之间保持分隔,并通过分配逻辑部署或 pod 到物理部署和节点来映射设备。这种逻辑层与物理层的分离是 Kubernetes 的巨大优势之一,也是它在管理应用程序和服务时能在潜在的无限物理基础设施之上发挥如此高效的原因。
图 5.1 – 逻辑-物理分离
这就是全部内容,但我们可以通过多种方式自定义应用程序组件在 Kubernetes 上的部署,以满足我们应用程序的特定需求。
Kubernetes 足够灵活,能够在云提供商的虚拟机群或物理裸金属服务器上运行,甚至可以在单台计算机上运行——例如你的笔记本电脑。这种灵活性使它成为混合云场景的理想选择。它通过允许开发人员在本地运行整个解决方案的副本,轻松模拟生产环境,从而简化了集成测试的繁琐工作。
Kubernetes 提供了丰富的功能集,满足大部分大规模运行工作负载的需求,例如服务发现、密钥管理、水平扩展、自动化发布和回滚,以及自愈能力——使其成为运行无状态和有状态应用程序的大规模解决方案,同时避免供应商锁定的理想候选。
Kubernetes 架构由一组松散耦合且可扩展的组件构成。这种模块化设计使得可以根据不同的云提供商进行适配,整合它们特定的网络、存储、服务网格等解决方案。
与 Terraform 一样,谷歌设计 Kubernetes 的目的是通过采用声明式方法来鼓励基础设施即代码(IaC)的使用,以定义应用程序的运行时环境。由于 Terraform 和 Kubernetes 都具有扩展性,因此存在多种集成选项。在本章中,我们将讨论其中的一些方法及其伴随的权衡——但在此之前,我们需要介绍 Kubernetes 内部架构和操作模型的一些关键概念。只有在掌握这些基础知识之后,我们才能最大化地发挥 Terraform 和 Kubernetes 联合使用的潜力。
Kubernetes 架构
Kubernetes 是一个分布式软件系统,它的设计与其他类似系统相似。因为它的责任范围涵盖一个由互联计算机系统组成的集群,这个集群的规模可以从几个节点扩展到几千个节点,所以它的组织方式就像一支军队。里面有军官、士兵和中央指挥部。士兵们被分成较小的子群体,每个子群体都需要与中央指挥部保持持续的联系,以便通过接收新的命令和提供当前情况的状态来有效地运作。中央指挥部接收来自各个军官的状态报告,这些军官负责监管他们的士兵以及执行命令,判断是否需要在不同战区增兵或减兵,并下达命令将不同的士兵小组重新部署到战场的不同位置。让我们深入了解每个组件及其角色。
主节点
主节点是 Kubernetes 集群的中央指挥——它本质上就是军队的将军们所在的地方。对于较小的冲突,通常只有一个中央指挥,但对于真正史诗般的交战,你可能需要为每个战区配备多个指挥部。它负责监管整个系统并做出高层决策。像任何好的中央指挥部一样,它必须执行几个重要职能:
-
API 服务器:任何军队都必须接收来自其民间政府的指令,政府为其提供任务目标并定义什么才算成功。从某种程度上讲,这与 API 服务器的角色非常相似。它不像通过红色电话从政治家那里获取指令,而是通过基于 REST 的接口从最终用户(通常是系统管理员或软件开发者)处获取输入。成功的定义也有所不同,这个定义就是最终用户的应用程序和服务应该如何部署,以及如何判断它们是否健康。
-
控制器管理器:拿破仑·波拿巴曾经名言:“一支军队依赖粮草”,这突显了在战争中良好后勤的重要性。一支军队不仅仅是靴子和枪支,还需要食物、水、制服和帐篷,甚至卡车和火车的燃料。控制器管理器执行的功能类似,它负责监控资源库存并分配资源,以确保军队的理想状态得以维持,并且能够执行任务。
-
调度器:我们的乔治·华盛顿曾名言:“纪律是军队的灵魂”——为了加强纪律,一支军队必须有一个有效的指挥系统,能够在战场上执行命令,合理分配士兵到最需要的地方。从这个意义上讲,调度器根据资源的可用性和任务目标的不同,将容器组(pods)分配到合适的节点上。
-
etcd
在 Kubernetes 中扮演这一角色,通过维护配置数据、集群状态,并创建一个单一真实数据源(SSOT)。
工作节点
工作节点是这个军队的战场,士兵们在这里完成必须执行的任务,以实现目标。它们是物理(或虚拟)机器,容器就在这些机器上运行。在任何战场上,都必须有一名指挥士兵的小队长。在 Kubernetes 中,小队长被称为 Kubelet。像小队长一样,Kubelet 在其战场区域内具有自主性,执行从指挥部接收到的命令,并指挥其小队——Pods——同时它保持与上级指挥部(或主节点)的指挥链,接收并执行新的指令。
节点中运行的容器在 Kubelet 的精心监控下需要一个容器运行时来运行。有多种容器运行时,例如 containerd
、CRI-O 或 Docker,我们在本章的第一部分已经了解过它们。虽然有许多容器运行时,我们仍然使用相同的工具——Docker——来构建镜像。运行时实际上只负责运行容器,虽然其中有一些其他细节,绝对是一个深入的领域,但在本书的背景下,这就是我们需要了解的内容。
在战场上,士兵们分布广泛,因此需要有一种方式将信息在士兵、指挥官和指挥部之间传递。在战场上,这种方式随着历史的变化从旗帜、横幅、烟雾信号、鼓声、号角到现代的电报、电台和卫星通信不断演变。对于 Pods 来说,这就是被路由到节点的网络流量。kube-proxy 就像 Kubelet 一样,运行在每个节点上,负责将网络流量路由到正确的目的地。
Pods
说了这么多关于“大人物”的事情,接下来该谈谈士兵了。士兵是战场上最小的参与者,而士兵们共同构成了军事行动中的主要力量。在 Kubernetes 中,Pods 就是这一角色。Pods 是所有工作实际发生的地方。集群中的其他一切运作,都是为了支持 Pods 达成其各自的目标,就像战场上许多角色通过制定明智的战略决策、分配资源、组织士兵成单位并下达命令,支持我们前线的战士一样。
Pod 不是容器,而是 Kubernetes 特有的构造,就像士兵一样,它是集群中最小的部署单元。一个 Pod 可以包含一个或多个容器,这些容器共享资源和配置,执行共同的目标。
与其直接部署单个容器,不如创建一个 Pod 并将容器放入其中。当你在同一个 Pod 内声明多个容器时,你实际上是在将它们紧密地绑定在一起——因为它们共享相同的网络命名空间、进程间通信(IPC)、命名空间和文件系统。
下图展示了 Kubernetes 架构的核心组件:
图 5.2 – 关键的 Kubernetes 架构组件
现在我们已经了解了架构的核心组件,接下来将深入探讨其他几个重要主题。我想特别指出,本书的核心是掌握 Terraform,尽管这一过程的一部分是了解你将要设计和配置的架构,但本书并不打算成为 Kubernetes 的深入指南。因此,我将重点关注在使用 Terraform 构建解决方案时需要了解的关键概念。
服务
对于更复杂的军事行动,我们可能需要分配更大的军事单位来完成任务。这就像是有一个中尉指挥多个小队。中尉将命令下达给适当的小队,每个小队被部署到战场的不同区域。这与 Kubernetes 中的 服务 角色类似,它允许我们将多个 Pod 组在一起,赋予它们共同的目标,并分布到多个节点上。服务负责在 Pod 之间进行负载均衡,任何针对这些 Pod 的传入请求都会首先被路由到服务,就像上级指挥官下达的命令通过中尉传达给小队,后者再将命令分配给自己指挥的小队一样。
通过这种方式,服务在需要与 Pod 进行稳定通信的工作负载中起着至关重要的作用,例如 Web 应用或 REST API。因为 Kubernetes 会为服务分配一个稳定的 IP 地址和 DNS 名称,即使底层的 Pod 发生变化,这些信息也保持不变,从而使得集群内外的其他应用或服务能够与该服务建立可靠的连接。
命名空间
最后,我们需要讨论 Kubernetes 逻辑模型中的一个重要概念:命名空间。命名空间在逻辑层面上提供了与集群中所有服务和 Pod 的完全隔离。命名空间不适用于集群的物理资源,例如节点或持久卷。它们仅适用于 Kubernetes 逻辑范围内与 Pod 和其他相关资源相关的内容。你可以把它看作是军队中的不同兵种。不同命名空间中的资源,就像不同兵种中的士兵,虽然共享一个中央指挥,但它们在指挥链和资源分配上是隔离的。因此,不同命名空间中的 Pod 可以在同一节点上运行,但不能共存于同一服务中,因为服务本身也有一个命名空间。
我们已经介绍了 Kubernetes 架构的关键组件。虽然还有很多内容超出了本书的范围,但这些概念应该足够让你从高层次上理解 Kubernetes 架构。接下来,我们将深入探讨一些用于配置 Pod 和服务的资源。
配置和机密
Terraform 和 Kubernetes 可能会交互的一个关键领域是配置和机密。这是因为,Terraform 经常负责配置其他资源,这些资源将提供端点 URL、身份验证凭证、日志记录或身份配置。因此,理解应该使用哪些 Kubernetes 资源来将这些配置设置连接到 Kubernetes 部署中的适当位置非常重要。
ConfigMaps
ConfigMap 是一种特殊的 Kubernetes 资源,可以用来为 Pod 提供非敏感的配置。配置以一组键值对的形式存储,可以用来配置容器的环境变量,或配置你希望在容器内运行的应用程序的命令行参数。
一个 Pod 可以引用一个或多个 ConfigMap 对象,然后应用程序可以引用键值对中的键来获取其值。这种方式实现了将运行在 Pod 中的应用程序与存储在 ConfigMap 中的配置分离。这意味着同一个 ConfigMap 可以被多个 Pod 规范共享。
默认情况下,只有同一命名空间内的其他 Pod 可以访问 ConfigMap。如果你需要更精细的安全控制,可以申请基于角色的访问控制(RBAC)。
机密
虽然 Kubernetes 确实有一种内部方法来存储机密并使其可供您的 pod 使用,但在将应用部署到云环境时,您通常会使用云特定的机密提供程序。利用外部机密存储有许多优点。首先,使用外部机密存储,您将拥有更集中化的管理,这将使操作员更容易管理环境。其次,大多数外部机密提供程序提供 Kubernetes 内建的机密存储所不具备的功能和能力,比如支持机密的版本控制和轮换。最后,将机密存储外包会减轻集群中 etcd
数据库的负担,从而释放更多资源给您的 pod 中运行的工作负载。
当您使用外部机密存储时,Terraform 很可能会同时配置该存储及您的 pod 所需的机密。为了利用外部机密存储,您需要配置一个 SecretProviderClass
资源,它是您计划使用的外部机密存储特定的资源。它将为您的 pod 和存储在外部机密存储中的机密之间提供一个桥梁。根据您使用的云平台,通常会有平台本地的配置来配置此提供程序。大多数托管 Kubernetes 服务提供商都为相应的机密存储服务提供内建支持,并简化了 pod 访问机密所需的身份验证和授权。
在本书中,我们将使用三大云平台的托管 Kubernetes 服务:Amazon 弹性 Kubernetes 服务(EKS)、Azure Kubernetes 服务(AKS)和 Google Kubernetes 引擎(GKE)。
持续部署(CD)
Kubernetes 提供了多种资源配置方式。它既支持命令式,也支持声明式,通过 kubectl
命令行工具和 Kubernetes YAML 清单(同样使用 kubectl
命令行工具)分别实现。因为这是一本关于 Terraform 的书,我想大家应该清楚我们更倾向于哪种方式!没错——声明式!由于 Kubernetes 也有自己的 REST API,因此完全可以构建一个与之通信的 Terraform 提供程序。所有这些方法,无论是通过 kubectl
执行命令式命令或 YAML 清单,还是使用 terraform
和 kubernetes
Terraform 提供程序,都是传统的推送模型的例子。
推送模型
kubectl
命令,可以是普通的 bash
或 YAML 清单文件,使用 kubectl apply -f foo.yaml
:
图 5.3 – 使用 Terraform 和 Kubernetes 命令行接口的 CI/CD 管道
在这种情况下,云环境在 kubectl
中定义,执行该命令以在新创建或现有的 Kubernetes 集群上创建部署。Kubernetes 集群的存在将取决于是否第一次执行了 terraform apply
。
下一个方法是使用 Terraform 完成这两个阶段,将 kubectl
阶段替换为第二个 Terraform 阶段,这次使用一个仅使用 Terraform Kubernetes 提供程序的第二个 Terraform 根模块。提供云环境的 Terraform 根模块保留在自己的文件夹中,并与第二个 Terraform 代码库完全隔离:
图 5.4 – 使用 Terraform 和 Kubernetes 提供程序的 CI/CD 管道
第一个 Terraform 阶段仍然使用我们目标云平台的 Terraform 提供程序来配置 Kubernetes 集群和云环境中的其他所需资源。同样,CI/CD 管道仍然将从第一个 Terraform 阶段输出的 Kubernetes 集群配置传递给第二个 Terraform 阶段,在该阶段我们使用 Terraform 的 Kubernetes 提供程序将 Kubernetes 资源配置到我们的 Kubernetes 集群中。
拉取模型
推送模型的替代方案是 拉取模型,它将事情颠倒过来。与 Kubernetes 资源由集群外部的某个实体配置不同,CI/CD 管道将在集群上安装一个 CD 服务,该服务连接到一个包含 Kubernetes YAML 清单的指定源代码库,并在 Kubernetes 集群上配置资源:
图 5.5 – 使用 Terraform 和 ArgoCD 的 CI/CD 管道
这种方法利用了基于 YAML 的 Kubernetes 部署的不可变性和声明性,并在 Git 源代码库中为 Kubernetes 部署创建了一个 SSOT。因此,这种方法越来越被认为是完全拥抱 GitOps 的最佳实践,接下来我们将在下一章中更详细地探讨这个主题。
在本节中,我们从高层次了解了 Kubernetes——它的作用,它是如何工作的,以及它如何将我们的容器与我们所配置的底层基础设施互联。这些都是我们在使用 Terraform 配置和管理 Kubernetes 基础设施时必须理解的关键内容,接下来,让我们看一下 Kubernetes 如何原生处理部署,然后与我们使用 Terraform 的 Kubernetes 提供程序的做法进行对比。
理解 Kubernetes 清单
正如我们在前一节中讨论的,kubectl
是一个命令行应用程序,可以用来命令式或声明式地执行对 Kubernetes 集群的命令。你可以使用 kubectl
来部署资源、检查和管理集群资源等常见操作活动。
Kubernetes 清单
在将资源部署到 Kubernetes 集群时,你可以直接使用kubectl
命令执行操作以提供资源,或者使用 YAML 清单来定义资源的期望状态,并使用kubectl
来执行这些清单。这两种使用kubectl
的方式类似于通过相应的命令行工具为 AWS 和 Azure 等云平台提供资源的命令式方法,以及 Terraform 在terraform apply
过程中为资源提供期望状态的方式。
当你直接使用kubectl
命令时,你是在命令行中直接给出指令。例如,如果你想创建一个部署,可能会发出如下命令:
kubectl run nginx --image=nginx
在这种情况下,kubectl
将使用大部分默认设置创建nginx
的部署,并立即执行。
这种方法对于快速、一时性的创建或需要立即修改时非常有用。
使用 YAML 清单时,你是在声明性地编写资源的期望状态。例如,部署可以在 YAML 文件中像这样编写:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
然后,你可以使用kubectl
来应用这个文件,方法如下:
kubectl apply -f my-deployment.yaml
这告诉 Kubernetes 将集群的实际状态与文件中描述的期望状态进行匹配。
这种方法的好处是,文件作为资源配置的事实来源(SOT)。这些文件可以进行版本控制,便于跟踪变更、回滚(如有需要)以及重复使用配置。
通常,建议使用配置文件来管理你的 Kubernetes 资源,特别是在生产环境中。话虽如此,直接使用kubectl
命令对于调试和快速原型设计任务是有用的,但从长远来看,你应该考虑使用声明性方法来管理资源。
部署清单
在 Kubernetes 中创建应用程序时,使用部署来指定你希望如何配置它。然后,Kubernetes 会自动调整应用程序的当前状态,使其与期望的配置相匹配:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: my-app:1.0
ports:
- containerPort: 8080
该部署清单描述了一个期望的状态,其中包括运行my-app
应用程序的三个实例(或副本)。
服务清单
服务是一种将一组 Pod 组合在一起形成应用程序的方法,使它们能够作为网络服务进行展示:
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
selector:
app: my-app
ports:
- protocol: TCP
port: 80
targetPort: 8080
该服务清单将创建一个网络服务,将流量路由到my-app
的8080
端口的 Pods。
配置和秘密(Secrets)
因为 Kubernetes 是我们托管应用程序和服务的地方,我们需要提供运行时配置设置,包括非敏感和敏感的配置。
配置映射(ConfigMaps)
正如我们在上一节中讨论的那样,ConfigMap 是我们将非敏感数据传递到 Pod 中的方式。ConfigMap 是 Terraform 和 Kubernetes 集成的关键区域,因为许多配置设置很可能是由 Terraform 生成的。在设计如何将资源配置到 Kubernetes 时,这是一个重要的考虑因素,因为你希望尽量减少手动步骤。我们将在未来的章节中探讨如何避免这种情况,特别是关于 Kubernetes 和 Helm 提供程序的内容:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
data:
my-value: "Hello, Kubernetes!"
这个 ConfigMap 被命名为my-config
,它包含一个键值对my-value:
Hello, Kubernetes!
。
现在,当我们想要在我们的部署中引用这个 ConfigMap 时,我们只需要使用configMapRef
块来从 ConfigMap 中提取正确的值,并在我们的容器内部设置环境变量:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 1
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: my-app:1.0
env:
- name: MY_VALUE
valueFrom:
configMapKeyRef:
name: my-config
key: my-value
在此部署中,my-app
应用有一个名为MY_VALUE
的环境变量,其值从my-config
ConfigMap 中提取,当 Pod 运行时,它可以从该环境变量中获取Hello, Kubernetes!
的值。
密钥
就像处理非敏感配置设置一样,我们的许多密钥将通过 Terraform 使用目标云平台的秘密管理服务进行配置。因此,我们将不会使用 Kubernetes 的Secret
资源,而是将定义一个SecretProviderClass
资源,该资源将启用与云平台的秘密管理服务的集成,并拉取所需的密钥。由于这与云平台特定相关,我们将在接下来的 AWS、Azure 和 GCP 的解决方案中详细介绍这些内容,使用它们各自的托管 Kubernetes 服务。
在本节中,我们探讨了 Kubernetes 如何本地处理部署——既通过其自身的kubectl
命令行工具,也通过基于 YAML 的部署清单,允许我们以声明式的方式描述我们希望在 Kubernetes 中配置的资源——这类似于 Terraform 在底层云基础设施中的作用。在下一节中,我们将介绍 Kubernetes 提供程序,它为我们提供了使用 Terraform 本地管理 Kubernetes 资源的方式。
使用 Kubernetes 提供程序来配置 Kubernetes 资源
Terraform 的 Kubernetes 提供程序是一个插件,允许 Terraform 管理 Kubernetes 集群中的资源。这包括创建、更新和删除如部署、服务和 Pod 等资源。
使用 Kubernetes Terraform 提供程序时,你的基础设施描述将采用 HCL 而非 YAML。这是 Terraform 用于描述基础设施和服务配置的语言。
Kubernetes Terraform 提供程序
正如我们在上一节中讨论的那样,由于 Kubernetes 拥有一个 REST API,作为所有管理操作的统一控制平面,因此可以创建一个 Terraform 提供程序,我们可以像在 AWS、Azure 和 GCP 云平台中一样使用它来进行自动化。
就像其他云平台一样,我们需要对控制平面进行身份验证。与 Kubernetes 的一个重大区别是,管理控制平面托管在 Kubernetes 集群本身上——更具体地说,正如我们在本章的理解容器编排和 Kubernetes 的关键概念部分中讨论的那样,它位于主节点上。这意味着我们需要指定 Kubernetes 集群的端点地址。通常,这个地址由在目标云平台上配置 Kubernetes 集群的 Terraform 资源提供。
为了与 Kubernetes 集群进行身份验证,我们通常需要使用集群证书,但一些云平台支持更复杂的身份验证方法,这些方法与您的组织目录系统(例如 Microsoft Entra ID)相连接。
下面是使用基于证书的身份验证时,提供程序配置的典型示例:
provider "kubernetes" {
host = var.cluster_endpoint
client_certificate = file(var.client_cert_path)
client_key = file(var.client_key_path)
cluster_ca_certificate = file(var.cluster_ca_cert_path)
}
这里是每个字段的用途:
-
host
:Kubernetes 主节点的主机名(以 URI 形式)。可以通过KUBE_HOST
环境变量获取。 -
client_certificate
:用于对 Kubernetes REST API 进行客户端身份验证。 -
client_key
:与client_certificate
配对使用,作为传输层安全性(TLS)握手的一部分,该握手发生在 Terraform 提供程序和 Kubernetes REST API 之间。 -
cluster_ca_certificate
:这是 Kubernetes 集群的证书颁发机构(CA),用于验证 Kubernetes 集群 REST API 的真实性。
另一种常见的配置 Terraform Kubernetes 提供程序的方法是使用 kube_config
文件:
provider "kubernetes" {
load_config_file = true
config_path = "~/.kube/config"
context = "foo"
}
在这种情况下,连接和身份验证所需的所有详细信息都存储在文件中。我们只需将提供程序指向该文件所在的位置。默认情况下,该位置是 ~/.kube/config
。当然,这个文件可以包含多个集群连接,每个连接称为一个 context。因此,我们可能需要指定上下文。不过,如果您在 CI/CD 流水线中运行,这种情况很不常见,因为您很可能会使用自定义路径。
Kubernetes 资源
当您使用 Terraform 的 Kubernetes 提供程序时,我们会获得与 Kubernetes 原生 YAML 清单相同的声明式模型,但我们还能够使用 HCL 的所有特性和功能。这使我们可以传递输入变量,生成动态的本地值,并使用字符串插值——一应俱全!
然而,这一切的缺点是我们必须使用 HCL 来定义 Kubernetes 资源。这与 Kubernetes 生态系统的主流做法相悖,因为大多数 Kubernetes 文档和在线提问或回答问题的实践者都使用 YAML。如果我们能够忍受从 YAML 到 HCL 的转换,那么可以考虑使用 Terraform 的 Kubernetes 提供程序:
resource "kubernetes_deployment" "my_app" {
metadata {
name = "my-app"
}
spec {
replicas = 3
selector {
match_labels = {
app = "my-app"
}
}
template {
metadata {
labels = {
app = "my-app"
}
}
spec {
container {
image = "my-app:1.0"
name = "my-app"
port {
container_port = 8080
}
}
}
}
}
}
上面的示例是 Kubernetes YAML 的 HCL 等效版本,用于配置 Kubernetes 部署资源。请注意大量使用大括号,这对于习惯于查看 YAML 的人来说可能显得有些突兀。
评估权衡
在这种方法下,您的 Kubernetes 资源是在 HCL 中定义的,接着您使用 terraform apply
命令来创建或更新这些资源,而不是使用 kubectl
无论是命令式地还是声明式地。
与 Kubernetes 的原生 YAML 方法一样,这个过程也是声明式的,意味着您描述想要的内容,但利用 Terraform 来确定如何实现。这类似于 Kubernetes 本身的工作方式,不过您使用 Terraform 提供程序来生成计划并完成工作。
尽管使用一种语言——HCL——来管理基础设施的其他部分(如 AWS 或 GCP 上的云资源)并用它来管理 Kubernetes 资源似乎是一个不错的选择,但由于大多数 Kubernetes 文档和示例都是基于 YAML 的,您将花费大量时间将 YAML 映射到 HCL。这会使得在大规模上学习和有效管理 Kubernetes 变得困难。
因此,通常最好让 Terraform 管理 Kubernetes 所依赖的底层基础设施,同时使用 Kubernetes 自身的声明式方法(通过 YAML 和 kubectl
)来管理 Kubernetes。然而,如果您能克服将 YAML 转换为 HCL 的问题——或者是我们稍后将讨论的一个更好的选择:将 Kubernetes 部署封装为 Helm charts——那么使用 Terraform 的 Kubernetes 提供程序可能更容易,避免了在 terraform apply
操作结束时需要与嵌入在 bash
脚本中的 kubectl
命令进行额外集成。
也许还有一些 Kubernetes 资源,它们与您的云平台以及 Terraform 为您管理的配置紧密耦合。这些资源可能是单独的或独立的资源,用于将 Kubernetes 服务帐户连接到云平台身份,或者是一个将大部分值从 Terraform 输出获取的 ConfigMap。
在这一节中,我们讨论了如何使用 Terraform 来配置 Kubernetes 资源,并将这种方法与使用 kubectl
的 Kubernetes 原生选项进行了对比和比较——无论是命令式还是使用基于 YAML 的清单进行声明式配置。在下一节中,我们将查看 Helm 提供程序,看看它是否提供了比我们目前评估的选项更好的替代方案。
利用 Helm 提供程序来配置 Kubernetes 资源
正如我们之前讨论的,Kubernetes 有一个基于 YAML 的内建声明式模型,允许你为集群提供资源。然而,正如我们所见,使用这个模型的一个挑战是,你无法在基于 YAML 的规范中使用动态值。这就是 Helm 的作用。在这一节中,我们将详细了解 Helm 的定义、基本结构、使用方法,以及如何将其与 Terraform 流水线集成,或者如何直接通过 Terraform 的 Helm 提供程序使用它。
什么是 Helm?
Helm 被广泛称为 Kubernetes 的包管理器,但作为一名习惯于使用软件库的包管理器(如 Maven、NuGet 或 npm
)或操作系统包管理器(如 apt
或 Chocolatey)的软件开发人员,我发现这个定义有些令人困惑。我想在某些层面上,它们的确有相似之处,都将多个组件聚合成一个版本化的包,并提供了一个方便的方式将这些包拉入其他项目进行重用。
然而,我认为 Helm 架构的一个重大区别和独特之处在于模板引擎的性质。Helm 的核心允许你创建包含一个或多个 Kubernetes YAML 清单的模板,并在 Kubernetes 资源中注入更多动态自定义,从而使 Kubernetes 部署更加可重用,并且更易于管理和维护。这些模板被称为 图表 或 Helm 图表。
从许多方面来看,Helm 图表更让我联想到 Terraform 模块,而非传统的软件包管理工具——无论是 apt
还是 NuGet。在将 Terraform 模块与 Helm 图表进行对比时,相似之处非常多。它们都在一个文件夹内运行,并定义了一种接收输入变量并生成输出的方法:
图 5.6 – Terraform 模块的输入、输出和资源
Terraform 模块封装了多个 Terraform 资源(或其他模块)的聚合,这些资源在 .tf
文件中定义,而 HCL 允许你利用语言内置的功能实现任意数量的动态配置:
图 5.7 – Helm 图表的输入、输出和资源
如前所述,Helm 图表执行类似的聚合,但它聚合的是定义在 .yaml
文件中的 Kubernetes 资源,并使用基于 Kubernetes YAML 的标记语言。Helm 定义了一个基于 Go 模板的自有模板引擎,提供了广泛的功能,允许你实现与 HCL 相似水平的动态配置。
如您所见,Helm 图表的基本结构相当简单。与 Terraform 模块不同的是,我们有嵌套文件夹,这些文件夹不允许用户将 Helm 图表清晰地嵌套在彼此内。子图表需要在特殊的 charts
目录中创建,并且可以完全封装在此文件夹内,或者简单地引用托管在其他地方的现有图表。这与 Terraform 模块的工作方式类似,您可以引用本地模块或托管在多个远程位置。微妙的差异在于 Terraform 模块可以在任何 .tf
文件中声明,并且它们的定义仅需存储在另一个本地文件夹或远程位置:
图 5.8 – Helm 图表解剖
Chart.yaml
文件是 Helm 图表内的特殊文件,作为包含关键标识元数据和其他依赖项(如本地或远程位置定义的其他 Helm 图表)的主要入口点文件:
apiVersion: v2
name: my-webapp
version: 0.1.0
description: A basic web application Helm chart
values.yaml
文件是定义 Helm 图表输入变量的文件。这是一个例子,在 HCL 中,我们没有限制输入变量的放置位置,按照约定——也是为了我们自己的清晰起见,我们将输入变量放入 variables.tf
文件中。在 Helm 中,这种隔离输入变量声明的约定被正式化为一个公认的文件,远远超出了简单的约定:
replicaCount: 1
image:
repository: nginx
tag: stable
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
ingress:
enabled: false
annotations: {}
path: /
hosts:
- my-webapp.local
tls: []
templates
文件夹是所有基于 YAML 的清单文件的存放位置。然而,YAML 有所不同,因为它很可能会使用 Go 模板约定({{
和 }}
)将许多动态值注入其中,Helm 将使用 Go 模板引擎解析这些值:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ template "my-webapp.fullname" . }}
labels:
app: {{ template "my-webapp.name" . }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ template "my-webapp.name" . }}
template:
metadata:
labels:
app: {{ template "my-webapp.name" . }}
spec:
containers:
- name: {{ template "my-webapp.name" . }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 80
使用另一个称为 helm
的命令行工具,可以将 Helm 图表安装到 Kubernetes 集群上。该工具执行多种不同的功能,包括自动生成基本图表结构、打包图表以进行分发、管理图表仓库以及将图表安装到集群上:
kubectl
和 helm
在认证 Kubernetes 集群时使用相同的方法,但在管理集群时用途不同,就像 kubectl
一样,它可以使用以下命令应用声明式 Kubernetes 配置:
kubectl apply -f <file>.yaml
可以使用 helm
命令将 Helm 图表配置到 Kubernetes 集群中,命令如下:
helm install my-webapp ./my-webapp
在这方面,Helm 类似地可以集成到 Terraform CI/CD 管道中,该管道首先使用 Terraform 和相关的云平台提供商(例如 aws
、azurerm
或 googlecloud
)来配置云环境,然后使用 helm
命令行工具根据 Terraform 管道阶段输出提供的连接和身份验证信息将 Helm 图表安装到 Kubernetes 集群上:
图 5.9 – Helm 图表解剖:Terraform 和 Helm 在 CI/CD 流水线中的集成
在下一小节中,我们将探讨如何通过使用 Terraform 的 Helm 提供程序来简化相同的过程,从而取代那些执行 helm
命令的 bash
脚本,并使用 Terraform 进行管理。
Helm Terraform 提供程序
在上一节中,我们了解了 Helm 的工作原理、Helm 图表的结构,以及它的结构和功能与 Terraform 模块的对比。现在,我们将看看如何使用 Terraform 来管理我们的 Kubernetes 环境,并使用 Terraform 的 Helm 提供程序。这个提供程序与 Terraform 的 Kubernetes 提供程序关系密切,因为它们都通过 Kubernetes 的 REST API 作为控制平面来管理 Terraform 资源。
使用 Terraform 与 Helm 的优势在于,它使你能够将 Kubernetes 应用程序与其他基础设施一起管理,使用相同的配置语言和工具集。正如我们所知,Helm 允许我们使用 Kubernetes 的声明式 YAML 清单和模板语言来创建参数化的模板,但我们仍然需要使用 bash
脚本来执行 helm
命令并传递参数给 Helm 图表。有些 Helm 图表可能具有非常复杂的配置,包含数十个参数。因此,使用 Terraform 消除了与外部 bash
脚本的额外集成,这些脚本用于执行 helm
命令。
同时,它还允许 Kubernetes 从业者在他们的本地工具集中开发 Kubernetes 模板。所以,如果你们组织中有希望构建自己定制 Helm 图表的 Kubernetes 专家,这将允许他们在使用 Terraform 进行声明式部署的同时,继续做他们自己的工作。这也使你能够利用现有的庞大生态系统,无需额外将其转换为 HCL。
与 Kubernetes 提供程序一样,你需要首先通过声明它为必需的提供程序来初始化该提供程序:
terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = "~> 2.0.0"
}
}
}
然后,在你的根模块中,你需要创建一个提供程序的实例。Helm 提供程序的配置与 Kubernetes 提供程序的配置非常相似:
provider "helm" {
kubernetes {
config_path = "~/.kube/config"
}
}
实际上,Helm 和 Kubernetes 提供程序可以在同一个 Terraform 模块中并行使用,以便在需要为 Helm 图表本身提供额外的 Kubernetes 资源时进行扩展。
Helm 提供程序可以用于创建一个两阶段的 Terraform CI/CD 流水线,其中第一阶段使用 Terraform 和相应云平台的提供程序来配置云环境。第二阶段使用第一阶段输出的集群连接和身份验证设置来配置 Helm 提供程序,并使用包含 Helm 配置的不同 Terraform 代码库再次运行 terraform apply
:
图 5.10 – Helm 图表结构:Terraform 与 Helm 在 CI/CD 管道中的集成
第二阶段的 Terraform 代码库通常非常小,仅使用一个资源。helm_release
资源是该提供程序中的唯一资源——如果你曾经使用过 AWS、Azure 或 GCP 等云平台提供商,你会发现这与之有很大的不同!
helm_release
资源只是接受我们期望传递给helm install
命令的输入,通过指定图表名称和版本,以及外部仓库(如果需要):
resource "helm_release" "my_application" {
name = "my-application"
repository = https://kubernetes-charts.storage.googleapis.com/
chart = "my-application-chart"
version = "1.0.0"
}
这部分内容讲解了 Helm 提供程序的相关知识。
总结
在本章中,我们学习了理解容器、容器编排器以及如何通过kubectl
和 Helm 使用 Kubernetes 原生工具以及相应的 Terraform 提供程序来配置和管理基于容器的基础设施所需的基本概念。
这标志着跨平台、云中立的知识的结束,我们需要这些知识来构建基于 VM 和容器的架构,涵盖所有三大超大规模云平台。由于无服务器架构本质上是平台特定的,并且提供了对底层基础设施的显著抽象,我将在各自的章节中介绍每个超大规模云平台的服务。
在下一章中,我们将超越云架构范式,花些时间了解团队如何通过 CI/CD 管道交付 IAC 解决方案,这些管道将基础设施配置、管理和应用程序部署过程融合成一个统一的端到端工作流。
第六章:6
将所有内容连接起来——GitFlow、GitOps 和 CI/CD
GitOps 是一种现代的软件开发与运维方法,旨在使基础设施和应用管理变得更加简单高效。它通过使用 Git 作为主要的真实状态来源,并尽可能采用声明式方法来实现这一目标。这种方法结合了版本控制和持续交付的原则,优化软件开发生命周期,并促进开发和运维团队之间更好的协作——有时甚至将两者的职能融合成一个真正的 DevOps 团队。
本章涵盖以下主题:
-
理解 GitOps 的关键概念
-
利用 GitHub 进行源代码管理
-
利用 GitHub Actions 实现 持续集成/持续部署(CI/CD)管道
理解 GitOps 的关键概念
实现 GitOps 的方法有很多,我们将在本章中探讨几种,但从本质上讲,GitOps 是将软件开发生命周期应用于应用程序源代码和基础设施配置——即基础设施即代码(IaC)。Git 仓库成为生产环境中真实状态的来源,包含了当前生产环境、曾经的生产环境以及即将投入生产的内容。为了实现这一点,Git 仓库必须包含配置文件、应用程序代码、基础设施定义和部署清单——所有必要的内容,以便重新生成一个完全可工作的应用程序版本。
相较于编译后的构件,声明式表示更为优选,但当源代码被编译成构件时,需要对这些构件进行版本控制,并且与 Git 仓库中的提交关联。诸如 Terraform、Docker 和 Kubernetes 等工具会解释这些声明式文件,并自动将变更应用到系统中,以使其符合预期状态。
对 Git 仓库的任何更改都会自动且持续地应用到目标环境中,无论该环境处于生命周期的哪个阶段——开发、测试还是生产环境。这个自动化过程确保了一致性,并减少了手动错误的风险。
这可以通过 推送 或 拉取 模型实现,前一章我们在讨论基于 Kubernetes 的解决方案的 CI/CD 管道时已经看到了这两种方法。由于 Kubernetes 在 GitOps 领域的影响,通常情况下,我们预设的目标是建立拉取模型。然而,实现 GitOps 并不一定需要拉取模型。实现 GitOps 的方式有很多,每种方法都有不同的权衡,应该根据你所在的具体环境进行评估。
无论你使用推送模型还是拉取模型,GitOps 的一个主要优点是它通过在常规源代码管理过程中保留所有部署和更新的日志,提供对系统变更的透明度和可视化。Git 提交历史被转化为审计追踪,使得理解何时发生了哪些变更以及由谁做出的变更变得更加容易。完整配置和代码结合生产出端到端工作的系统,并且拥有版本化的副本,这使得在遇到问题时相对容易回滚到先前的状态。当然,你系统中的有状态部分可能需要额外的工程设计,以确保新的部署和回滚都能顺利进行。
使用这种方法可以改进软件交付过程,从而提高效率、可靠性和可扩展性,同时促进开发、运维和其他团队之间的协作。这就是为何采用这种方法对推动组织内 DevOps 文化至关重要的关键原因。
由于 Git 的使用依赖较重——Git 通常是软件开发工具——没有应用开发背景的团队成员可能会感到困难。因此,如果你来自非开发者背景,比如系统管理员、网络或安全工程师,或者其他基础设施领域,那么花时间学习基本的 Git 命令和Gitflow流程是非常重要的,因为这些知识将对你在团队中的有效性至关重要。
Terraform 及类似工具是 GitOps 工具链中的一个关键组成部分,因为 IaC(基础设施即代码)的使用是这种方法的一个重要支柱,但必须记住,Terraform 通常只是整个方案中的一个组成部分,源代码管理和管道工具在促进整个过程中的作用更为关键。这就是为什么在本书中,我们将使用 Terraform 和 CI/CD 管道来设置复杂的架构并进行配置。在我们深入探讨之前,我们需要明确理解 CI/CD 管道是什么以及如何构建一个,这正是我们在下一节中将要讨论的内容。
理解 CI/CD
CI/CD 管道是一套自动化的步骤和过程,帮助软件开发团队快速且可靠地构建、测试和部署他们的应用程序。它是实现 GitOps 流程的基本组成部分,因为它在促进从开发到生产的持续变更流动中发挥着至关重要的作用,确保新代码被自动集成、测试,并以工作系统的形式交付给最终用户:
图 6.1 – CI/CD 管道的结构概览
正如其名字所示,CI/CD 流水线实际上由两个过程组成,并将它们结合在一起。首先是持续集成流水线,负责构建和确保系统应用代码的内建质量;其次是持续部署流水线,负责将应用代码部署到相应环境中。
CI/CD 流水线将这两个历史上不同的过程聚合在一起:集成测试和部署。然而,通过将它们结合,它提供了一种系统化和自动化的方法,能够持续不断地将新特性和 bug 修复交付给用户,减少了与手动部署相关的时间和风险。这反过来又促进了开发团队内的合作文化、频繁反馈和快速创新。
使用 Terraform 来配置基础设施并将最新代码版本部署到该基础设施的 CI/CD 流水线通常有两个目标。首先,生成经过测试并验证具有令人满意的内建质量的软件版本;其次,配置一个环境——无论其样貌如何——来托管与软件需求兼容并能够正确高效运行的应用程序。第三步也是最后一步是将应用程序部署到该环境中。
流水线并不会对你的云架构的稳健性做出评判。根据你的需求,你可以选择牺牲解决方案架构的某些特性来追求便捷或降低成本。流水线的工作是提供你所需的环境,并将软件部署到该环境中,因此一旦流水线完成,应用程序就准备好接受用户的访问流量。
在接下来的部分中,我们将深入探讨 CI/CD 流水线的内部结构,并讨论沿途发生的机制。
流水线结构
在前面的部分中,我们了解了 GitOps 的基本原理,并知道 CI/CD 流水线是基于像 Git 这样的版本控制系统建立的,开发人员在该系统中提交他们的代码更改。我们可以配置 CI/CD 流水线,使其在代码库中发生某些关键事件时触发,例如将更改推送到特定分支。
一旦版本控制系统中发生某些关键事件,比如开发者将更改推送到特定的分支或路径,CI/CD 流水线便会触发。它将拉取最新的代码,构建应用程序,并运行一系列自动化测试以验证应用程序代码的功能和完整性:
图 6.2 – CI/CD 流水线结构
可以进行多种测试,包括单元测试、集成测试,有时甚至是验收测试,以确保代码符合质量标准并且没有引入回归问题。
单元测试在单独的组件上操作,并使用模拟对象(mocks)来隔离测试结果,针对单一组件通过注入占位符来替代该组件的下游依赖项:
图 6.3 – 单元测试孤立于单一组件
集成测试跨越两个或更多组件进行操作。它们可以使用模拟对象,也可以不使用,重点是组件间交互的可靠性。有时,对于非常复杂或复杂的组件,你可能希望进行集成测试,重点关注围绕它们的各种使用场景,同时使用模拟对象保持其他组件的输出可预测:
图 6.4 – 集成测试关注两个或更多组件及它们如何交互
系统测试引入了现实世界的依赖项,如数据库或消息传递子系统,并允许你在不完全部署系统的情况下,实现跨系统的更真实的覆盖:
图 6.5 – 系统测试
系统测试的关注范围更广,通常会引入现实世界的依赖项,如数据库和外部系统
端到端测试是指提供整个应用程序的宿主环境——如同它在生产环境中的表现——并执行尽可能接近实际客户端应用程序或最终用户的测试:
图 6.6 – 端到端测试
端到端测试尝试尽可能真实地模拟最终用户活动,在系统完全运行的情况下,进行从头到尾的测试。
这取决于特定应用程序和组织的要求,需要进行什么样的测试,以及在应用程序上需要做多少工作。Terraform 还可以在持续集成过程中发挥关键作用,通过为系统或端到端测试环境提供及时(JIT)环境。Terraform 允许你动态创建适合目的的环境,执行测试,然后关闭所有资源。
根据你希望在发布过程中的可靠性水平,你可以选择在启动持续部署过程之前进行更深入、更强大的测试。
在持续集成过程成功完成后,应用程序被打包成一个部署包(例如 Docker 容器或 JAR 文件),其中包含所有必要的依赖项和配置,并准备好进行部署。
在持续部署过程中,Git 源代码和部署包都会被用来提供环境并将包部署到目标环境中。Terraform 在提供或更新所需基础设施方面至关重要,如虚拟机、容器或无服务器资源。正如我们在前几章中所看到的,Terraform 还可以选择通过预构建的虚拟机镜像或预构建的容器镜像进行应用程序的部署。
部署完成后,CD 流水线可以运行额外的验证测试,通过应用程序和基础设施中内建的健康检查,确保应用程序在目标环境中正确运行。
无论架构如何,CD 流水线的结果是将特定环境的配置应用到工件上——这些配置通常来源于 Terraform 输出,包含重要的配置细节——从而将工件定制为目标环境。这些配置可能包括数据库连接字符串、API 端点或其他在不同环境之间有所不同的设置。
正如你所看到的,Terraform 在这一过程中发挥着至关重要的作用,但并不是唯一的参与者。该过程中的每一步都同样重要,并在持续发布具备内建质量的软件方面发挥着关键作用。在本书中,我们将回顾三种架构以及与之对应的三种部署技术,针对三种云托管范式:虚拟机、容器和无服务器。我们将使用 GitHub 作为源代码仓库,并使用 GitHub Actions 作为我们实现 CI/CD 流水线的工具。根据软件架构及其在环境中的托管方式,部署技术可能会有所不同。
在接下来的章节中,我们将讨论 GitOps 的源代码管理方面,包括为我们的 DevOps 团队提供结构的开发者工作流,这些团队以这种方式执行任务。
利用 GitHub 进行源代码管理
GitHub 只是源代码管理软件的一种选择。在本书中我们将使用它,但你需要理解的是,无论你最终使用哪个源代码控制提供商,使用 GitHub 实现的概念和模式是一致的。尽管实现和执行流水线的语法和机制可能有所不同,但源代码管理系统在背后仍然是git
。
源代码管理的一个重要部分是如何在团队中——无论是大团队还是小团队——以结构化的方式使用它。这些是你们团队可以采用的约定,从而确保团队在新功能通过开发过程并最终进入生产时,能有一致的预期。
Gitflow 是一种常见的模型,使用了知名的、长期存在的、且一致的命名规范来管理短期生命周期的分支。正如我们将在下一小节中看到的,它具有高度的可定制性,稍显像是选择你自己的冒险,这也是它成为全球各地开发团队最常见的操作模型之一的原因,无论团队规模如何。
我们还将了解一种名为 GitHub flow 的简化变体,它是基于主干开发的一个例子。该模型主张保持main
分支始终可部署,并最小化长期存在的分支使用。开发者直接在main
分支上工作,使用短暂的feature
分支,快速合并回main
,而不是为各种目的和设计创建长期存在的稳定分支。
在下一部分,我们将更深入地了解 Gitflow,看看开发者的实际体验如何,以及它如何与我们使用 Terraform 构建的自动化系统集成。
Gitflow
Gitflow 是全球开发团队最受欢迎的分支模型和工作流之一。它的广泛应用促使了各种变体和适配的出现,以适应不同的开发环境和团队的需求。在其核心,Gitflow 利用main
分支表示生产质量的代码,develop
分支为开发团队提供了一个安全的合并和集成测试的环境:
图 6.7 – Gitflow 的最简形式
在 Gitflow 中,main
是表示生产就绪代码的主要分支。只有准备好投入生产的代码才能存在于此分支中。处于开发中的功能由开发者在自己的feature/*
分支上创建,随后合并到共享的develop
分支,这个分支有点像是一个预发布环境,之后再合并到main
。
然而,正如前面所提到的,Gitflow 具有高度的可定制性,多年来已经出现了几种对这个核心模型的扩展,且在不同的环境中有不同程度的采用。
有时,release
分支用于准备和测试发布,起始于develop
并合并回develop
和main
。这样可以让团队更好地控制何时以及如何将一组功能发布到生产环境。
现实世界变化迅速。因此,有时需要快速对生产环境进行关键修改,以解决特定问题。这时,hotfix
分支从main
开始,经过完全测试后再合并回develop
和main
。
图 6.8 – Gitflow 扩展版
Gitflow 高度可定制:
-
main
: 仅生产代码(1) -
release
: 发布准备(2) -
develop
: 集成测试(3) -
feature/*
: 功能开发(4) -
hotfix/*
: 生产环境的关键补丁(5)
Gitflow 不指定特定的版本控制方案,但通常使用语义化版本(例如,{主版本}.{次版本}.{修订版本}
)表示每个发布中所做更改的重要性。Gitflow 提供了任务的清晰分离,适合需要严格控制开发和发布流程的大型团队和项目。然而,这种结构对于较小的团队或实验性项目可能会感到压倒性:
图 6.9 – Gitflow 与 CI/CD 流水线集成
Gitflow 过程有几个关键事件可以触发自动化:
-
feature/*
分支合并到develop
。这通常会触发包括应用代码、内建质量、单元和集成测试的 CI/CD 流水线。合并此拉取请求会启动一个部署到开发环境的发布流水线。 -
develop
分支合并到release
。这通常包括额外的测试,如系统甚至端到端测试。合并此拉取请求会启动一个部署到暂存或发布环境的发布流水线。 -
release
分支合并到main
。这通常包括额外的端到端测试变体,检查性能或负载,并可能包括升级或版本测试。合并此拉取请求会启动一个部署到生产环境的发布流水线。 -
hotfix/*
分支合并到main
。这可能执行一个较小的测试套件目录,但可能包括版本或升级测试。合并此拉取请求会启动一个部署到生产环境的发布流水线。
值得指出的是,这可能是 Gitflow 最复杂的配置,但是作为人类,我相信肯定有人已经提出了更复杂的 Gitflow 版本。在下一节中,让我们通过回顾 GitHub 流程来看一些更简单、更轻量的内容。
GitHub 流程
正如我们所讨论的,GitHub 流程是 Gitflow 的小兄弟。它更简单、更轻量,非常适合小团队或实验。它专注于仅有一个分支—main
—新功能从个别feature/*
分支引入。开发人员从 main
创建 feature
分支,进行修改,然后提交拉取请求将它们合并回 main
分支。在彻底测试后,版本通常从 main
打上标签:
图 6.10 – 适用于小团队或实验的 GitHub 流程
主要的区别在于,没有官方的流程来创建像 develop
或 release
这样的阶段分支,通常在这些分支上进行集成测试。集成测试的责任由每个功能开发者在他们自己的 feature
分支上承担——本质上是对他们的改动在生产环境中能否正常工作负责。
这也意味着我们触发 CI/CD 流水线的关键事件变少了。我们只有从 feature/*
分支提交到 main
的拉取请求,然后合并到 main
,来触发事件。额外的测试可以在 feature/*
分支上进行,或者团队可以选择为生产版本发布引入手动触发器,这样就能有更多时间在 main
上进行测试。
如前所述,GitHub flow 非常适合没有专门负责集成测试的小团队!
每种 Gitflow 变体都有其优缺点,工作流的选择取决于项目的具体需求、团队规模、开发流程以及用于版本控制的工具或平台。评估团队和项目的需求与偏好,选择最合适的分支模型是至关重要的。在本书中,我会更详细地介绍一些选项,但大多数情况下,我会使用 GitHub Flow 来简化我的示例。
使用 GitHub Actions 进行 CI/CD 流水线
GitHub Actions 是 GitHub 提供的 CI/CD 服务,为你提供一个平台,可以在你选择的任何工作流中实现自动化,围绕你的源代码管理过程进行操作。
为了挂钩到 GitHub Actions,你需要定义 YAML 文件,指定你希望自动化的任务。这些文件被称为 .github/workflows
目录,位于你的源代码仓库中。一个工作流的基本结构由多个 jobs 组成。每个 job 包含多个 steps。步骤可以是你执行的简单脚本,或者是更复杂的打包在一起的 action:
jobs:
build:
runs-on: ubuntu-latest # The type of runner (virtual machine) that the job will run on
steps:
- name: Checkout code # Name of the step
uses: actions/checkout@v2 # Use a pre-built action to checkout the current repo
- name: Run a command
run: echo "Hello, World!" # Commands to run
test:
needs: build # Specifies that this job depends on the 'build' job
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Run tests
run: |
npm install
npm test**
上面的代码包含了两个 jobs:build
和 test
。这些 jobs 被归类在 jobs:
部分,每个 job 下有多个 steps,归类在 steps:
部分。你可以通过 runs-on
属性自定义 job 运行的镜像。这允许你指定一个符合需求的容器镜像,使用合适的 Linux 发行版或软件配置。
默认情况下,步骤只是通过 run
属性执行一个 bash 脚本,但你可以通过指定 uses
属性来使用一个 action。
要执行 Terraform,你只需在代理上安装它。可以使用 HashiCorp 提供的一个名为 hashicorp\setup-terraform@v2
的 action 来轻松实现。以下代码片段展示了如何在指定 Terraform 的特定版本的同时完成此操作:
steps:
- uses: hashicorp/setup-terraform@v2
with:
terraform_version: 1.5.5
还有其他一些属性,但它们更多是针对边缘案例,超出了本书的范围。我建议你查看该操作的文档,了解所有可用的选项:github.com/hashicorp/setup-terraform
。
你必须始终将敏感数据存储为机密,以确保数据不会暴露在日志中。这可以通过利用 GitHub 环境或其他机密管理服务轻松实现。
虚拟机工作负载
在构建自动化流水线以配置虚拟机托管的工作负载时,你的工具链应该包括能够设置虚拟机初始配置、配置虚拟机以及随时间推移更新虚拟机配置的工具。本书将涵盖用于这些目的的工具:Packer、Terraform 和 Ansible。
Packer 构建流水线
正如我们在讨论 Packer 模板开发时提到的,开发人员使用HashiCorp 配置语言(HCL)编写并提交 Packer 配置文件到其 Git 仓库。
当对存放 Packer 配置文件的文件夹进行更改并推送到版本控制系统时,会触发一个独立的流水线。在该流水线中,Packer 用于为每个服务器角色(例如,前端、后端和数据库)构建虚拟机镜像。Packer 会根据应用程序中每个角色的最新配置进行配置,包括每一层独有的必要软件和设置。在成功构建每个镜像后,Packer 会创建优化后的机器镜像,适配所选云服务提供商(例如,Amazon Machine Images(AMIs)用于Amazon Web Services(AWS)或用于 Azure 的 Azure 管理镜像)。
有时候,Packer 可能会因虚拟机本身的暂时性问题或脚本中的错误而失败。你可以在 Packer 中使用调试模式,该模式允许你在临时虚拟机上暂停构建过程。这将允许你连接到该机器,手动执行失败的命令,并排查环境中的问题。
根据目标云平台的不同,生成的机器镜像会被存储在制品库中,或直接存储在云服务提供商的镜像仓库中,以便 Terraform 后续使用。
Terraform 应用流水线
现在,虚拟机镜像已发布到镜像仓库,Terraform 只需引用正确的镜像,就能为虚拟机配置合适的镜像。类似于 Packer 构建流水线,开发人员将 Terraform 配置文件提交到其 Git 仓库,并且每当对存储 Terraform 配置文件的文件夹进行更改并推送时,都会触发一个独立的流水线。
Terraform 配置定义了网络基础设施,包括子网、安全组和负载均衡器,所有虚拟机都需要这些资源。Terraform 从工件仓库或云提供商的镜像仓库中拉取 Packer 构建的机器镜像,并为每个角色配置所需数量的虚拟机,设置任何必要的负载均衡器,以便在多台服务器之间分配负载,从而确保高可用性和容错性。
Terraform 有时可能会失败,既可能是由于临时问题,也可能是因为你尝试配置的资源之间存在潜在的竞争条件,这些资源是相互依赖的。我们将在第十七章中详细讨论更复杂的故障排除场景,但现在,重要的是要认识到 Terraform 是幂等的,这意味着你可以反复运行它以达到期望的状态——因此,有时候,重新运行任务就能帮助你解决最初遇到的问题。
Ansible 应用管道
最后,在 Terraform 应用基础设施更改并使用 Packer 镜像设置虚拟机后,环境已准备就绪。然而,环境尚未完全投入使用,因为可能还需要对特定于环境的配置进行一些更改,这些更改在 Packer 镜像构建阶段无法获得。这就是我所说的最后一公里配置——我们通过应用在 Terraform apply
执行后才能得知的配置设置来对环境做最后的调整。执行这些最后一公里配置更改有不同的选择。你可以使用 Terraform 动态配置用户数据,直接传递给虚拟机,或者使用其他工具来完成这项工作。
由于大多数虚拟机也需要执行一些常规维护,因此考虑使用配置管理工具是很好的,它可以在不关闭或重启虚拟机的情况下,通过更改使用的 Packer 镜像版本来更新环境。这就是像 Ansible 这样的工具派上用场的地方。
Ansible 可以作为配置管理工具,除了对虚拟机执行持续维护外,还可以在虚拟机上执行最后一公里配置。Ansible 脚本会应用于已部署的虚拟机,设置特定于环境的值、配置服务,并执行其他必要任务。这样,环境现在已准备好供操作员使用已建立的 Ansible 配置执行常规维护。
与 Terraform 类似,Ansible 也是幂等的,并且可能会遭遇类似的暂时性错误。然而,像 Packer 一样,Ansible 会在操作系统内部进行变更。因此,你只需连接到其中一台虚拟机,排查 Ansible 执行脚本时失败的命令。
通过采用这种方法,可以有效地基于虚拟机的解决方案进行配置和操作,贯穿应用的生命周期。这使得部署过程具有可复制性、可扩展性和自动化,并为不同的环境提供必要的灵活性,同时确保解决方案中每个角色的设置一致且可靠。
容器工作负载
在构建自动化流水线以配置容器基础的工作负载时,您的工具链应包括可以用来设置需要部署的各个容器的初始配置、配置 Kubernetes 集群以托管容器以及支持 Kubernetes 集群操作的底层基础设施的工具,最后,通过多种选项使用 Kubernetes 的 REST API 来向 Kubernetes 控制平面配置 Kubernetes 资源。
由于容器镜像的不可变性及其轻量级和快速特性,实施复杂的滚动更新变得非常容易,可以在现有的部署中推出新版本的容器镜像。因此,容器基础的工作负载的配置和维护机制实际上就是构建新的容器镜像,并在 Kubernetes 配置中引用所需的镜像,以触发部署更新。
Docker 构建流水线
正如我们在讨论 Docker 原理以及它如何工作的过程中提到的,开发者使用 Git 仓库编写并提交 Docker 文件。
当向版本控制系统推送更改并影响存储 Docker 配置文件的文件夹时,会触发一个独立的流水线。在该流水线中,Docker 用于为应用中的每个服务器角色(例如,前端、后端和数据库)构建容器镜像。Docker 配置了每个角色的最新配置,包括每一层独有的必要软件和设置。生成的 Docker 镜像充当我们的部署包。因此,它被版本化并存储在名为容器注册表的包仓库中(我们在第五章中讨论过)。一旦新的 Docker 镜像存储完成,我们可以从 Kubernetes 配置中引用它,并通过多种方式在 Kubernetes 中触发部署。
Kubernetes 清单更新流水线
在这个管道中,开发人员修改清单,以引用在上一步中构建并发布的新的 Docker 镜像版本,并提交拉取请求以更新更改。我们使用的触发器可以是推送模型或拉取模型。如果你还记得,在第五章《基于容器的架构》中,我们讨论了实现推送模型的几种不同方法。一些选项使用kubectl
和 Kubernetes YAML 清单,另一些则使用带有一组 YAML 清单的 Helm Chart,这些清单通过 Helm 转化为更动态的模板。
作为替代方案,使用拉取模型,我们可以使用托管在 Kubernetes 集群上的持续部署代理,例如 ArgoCD,它会监控 Git 仓库中的更改并将其应用于集群。因为 ArgoCD 持续监控包含 Kubernetes 清单(或 Helm Charts)的 Git 仓库,每当仓库中有新的提交时,它将自动触发部署过程。ArgoCD 并没有做什么神奇的事情;它只是使用kubectl apply
将最新版本的清单应用到 Kubernetes 集群中。
Terraform 应用管道
正如我们在第五章中所讨论的,由于 Kubernetes 的架构,Kubernetes 集群通常是一个共享资源,多个团队会通过针对集群中的各自命名空间来部署他们自己的工作负载。这也是为什么这个管道通常由与拥有 Docker 构建和 Kubernetes 清单管道的团队不同的团队来管理。这个管道由负责提供和维护 Kubernetes 集群的团队管理。他们的责任是确保集群正常运行,并随时准备接受 ArgoCD 的部署。
Terraform 可以作为可选项用于管理集群上的 Kubernetes 资源,但正如我们在第五章中所讨论的,这在所有情况下可能并不理想,因为团队和组织的动态因素。在这种情况下,最好根据你的具体背景来做出适合你团队和组织的决策。
在大多数情况下,Terraform 仅用于在所选的云平台上提供 Kubernetes 集群和周围的基础设施。开发人员会将 Terraform 配置文件提交到他们的 Git 仓库,每当对存储 Terraform 配置的文件夹进行更改时,管道会被触发。
这种方法使开发人员能够专注于代码开发和测试,而无需担心底层基础设施和部署过程。开发团队可以依赖 Kubernetes 集群中的隔离环境进行部署,实际上只需要维护他们的代码库和用于配置应用程序的 Docker 文件。
无服务器工作负载
在无服务器架构中,部署过程可以大大简化。通常,您需要有两个主要的管道来管理无服务器框架及周边服务,以及实际的函数代码。
Terraform 应用管道
该管道负责为支持无服务器工作负载提供基础设施。它使用 Terraform 来定义和管理基础设施组件。管道可能会创建诸如负载均衡器、API 网关、事件触发器以及其他作为无服务器函数基础的逻辑组件。这些通常是轻量级的云服务,能够极其快速地配置。
无服务器部署管道
该管道负责将单个无服务器函数部署到目标平台(例如 AWS Lambda 或 Azure Functions)。每个无服务器函数通常都有自己的管道来处理其部署、测试和版本控制。这确保了不同组件之间的自治,并允许团队根据他们如何管理代码库来组织责任。这条管道实际上仅涉及打包函数代码、定义配置并将其部署到选定的云平台。
无服务器方法简化了代码的部署和管理,开发人员可以更多地专注于编写应用程序逻辑,同时依赖自动化部署管道来处理基础设施的配置和无服务器函数的部署。
Terraform 工具
有很多工具可以帮助提升 Terraform 代码的美观性、功能性和可维护性。我不会在这里面面俱到,但我会提到一些对任何 Terraform 持续集成过程绝对必需的关键工具。
格式化
在开发过程中,您应安装 HashiCorp Terraform 插件到 Visual Studio Code 中。这将启用编辑器中的大量有用的生产力功能,同时它还会在每次保存文件时自动执行 Terraform 内置的格式化功能 terraform fmt
。这将极大地帮助在代码库中保持一致的格式化。这是一种主动的做法,依赖开发人员采取措施正确配置他们的开发环境。
为了验证每个开发人员是否采用这种技术以保持项目的 Terraform 代码整洁,您需要在拉取请求流程中使用一个代码检查工具。将 tflint
添加到您的拉取请求流程中将有助于防止格式不良的代码进入您的 main
分支!
文档
现在代码格式已经正确,我们应该为我们的模块生成一些文档。无论你是编写根模块还是可重用模块,这都会非常有用。当terraform-docs
工具指向一个 Terraform 模块目录时,它将生成一个 markdown 格式的README
文件,记录 Terraform 模块的关键方面,包括 Terraform 和所使用的提供者的版本要求,以及输入和输出变量的详细信息。这个工具非常适合设置为预提交操作,确保每次合并代码时都能自动生成文档。它会读取 HCL 中的注解,如description
、type
、required
和任何默认值。
你可以在terraform-docs.io/user-guide/introduction/
了解更多。
安全扫描
Checkov是一个静态代码分析工具,可以扫描你的 Terraform 计划文件,检测安全性和合规性违规。它内置了成千上万的策略,覆盖了许多平台,最重要的是包括本书中探索的云平台:AWS、Azure 和 Google Cloud。然而,在写作时,AWS 的策略覆盖最为全面,而 Azure 和 Google Cloud 的覆盖范围相对较少。
你可以在github.com/bridgecrewio/checkov
了解更多。
总结
在这一章中,我们学习了源代码控制管理的基本概念,包括对大型和小型团队使用的不同分支和工作流策略的详细拆解。我们还讨论了我们的自动化系统,即 CI/CD 管道,如何在关键事件时与这些流程进行集成。
在下一章中,我们将从概念知识转向实际操作,开始着手第一个解决方案,即利用第一个公共云 AWS 上的虚拟机。
第三部分:在 AWS 上构建解决方案
在掌握了 Terraform 的概念知识和跨越主要公共云平台实现细节的架构概念后,我们将探索如何在亚马逊云服务(AWS)上构建解决方案,涵盖三种云计算范式:虚拟机、使用 Kubernetes 的容器以及使用 AWS Lambda 的无服务器架构。
本部分包括以下章节:
-
第七章,在 AWS 上入门 – 利用 AWS EC2 构建解决方案
-
第八章,使用 AWS 容器化 – 利用 AWS EKS 构建解决方案
-
第九章,使用 AWS 无服务器架构 – 利用 AWS Lambda 构建解决方案
第七章:7
在 AWS 上入门 – 使用 AWS EC2 构建解决方案
现在我们已经掌握了构建现实世界云解决方案并使用 Terraform 进行基础设施即代码(IaC)自动化所需的概念基础,我们将从目前最受欢迎的云平台之一 – Amazon Web Services(AWS)开始我们的旅程。
在本章中,我们将采取逐步的方法来设计、构建和自动化使用 AWS 的虚拟机服务 – Elastic Cloud Compute(EC2)或简称 EC2 的解决方案。我们还将探讨几个其他对确保我们解决方案稳健性和生产就绪性至关重要的 AWS 服务,如密钥管理、日志记录和网络安全。
我们有很多工作要完成,但这正是实践真正开始的地方。我们将开始真正应用我们讨论过的概念,并在 AWS 上实际应用它们。
本章涵盖以下主题:
-
奠定基础
-
设计解决方案
-
构建解决方案
-
自动化部署
奠定基础
云基础设施的好坏取决于部署到其上的应用程序和服务,因此在本书中,我们将围绕一个名为 Söze Enterprises 的虚构公司的功能用例构建我们的样例架构。Söze Enterprises 是由一个神秘的土耳其亿万富翁 Keyser Söze 创立的,他希望通过建立一个平台将自动驾驶车辆推向新的高度,使地面和空中的车辆(来自任何制造商)协调行动,以提高安全性和效率。不知何故,Keyser 已经获得了 Elon 的支持,所以其他电动车供应商很快也会效仿。
我们继承了 Söze Enterprises 的另一个部门的团队,这个团队有一支强大的 C# .NET 开发人员核心团队,所以我们将使用.NET 技术构建平台的 1.0 版本。这位神秘的 CEO Keyser 上周末在摩纳哥与 Jeff Bezos 交际,公司的消息称我们将使用 AWS 来托管这个平台。因为团队对容器的经验不多,时间又很紧,我们决定建立一个简单的三层架构,并在 AWS 的 EC2 服务上托管。我们决定使用 Linux 操作系统,以便将来更容易转换容器:
图 7.1 – 自动驾驶车辆平台的逻辑架构
平台将需要一个前端,这将是一个使用 ASP.NET Core Blazor 构建的 Web 用户界面。前端将由一个 REST API 后端提供支持,该后端将使用 ASP.NET Core Web API 构建。将核心功能封装到 REST API 中将允许自动驾驶车辆直接与平台通信,并使我们能够通过将客户端接口与其他前端技术(如原生移动应用和未来的虚拟或混合现实)结合,进行扩展。后端将使用 PostgreSQL 数据库进行持久存储,因为它轻量、符合行业标准且相对便宜。
设计解决方案
由于团队面临紧迫的时间表,我们希望保持云架构的简单性。因此,我们将保持简单,并使用 AWS 的经过验证的服务来实现平台,而不是尝试学习新东西。我们需要做出的第一个决定是每个逻辑架构组件将托管在哪个 AWS 服务上。
我们的应用架构由三个组件组成:前端、后端和数据库。前端和后端是应用组件,需要托管在提供通用计算的云服务上,而数据库则需要托管在云数据库服务上。两种类型的服务都有很多选择:
图 7.2 – 自动驾驶车辆平台的逻辑架构
由于我们已经决定使用虚拟机(VM)来托管我们的应用,因此我们已缩小了可以用来托管应用的不同服务的选择范围,并决定 AWS EC2 是我们当前情况的理想选择。还有其他选项,例如Elastic Beanstalk,它们也使用虚拟机,但我们希望对解决方案有完全的控制权,并尽可能保持跨平台的能力,以防我们未来需要迁移到不同的云平台:
图 7.3 – 我们仓库的源代码控制结构
这个解决方案将由六个部分组成。我们仍然有前端和后端的应用代码以及 Packer 模板。接着,我们有 GitHub Actions 来实现我们的 CI/CD 流程,以及使用 Terraform 来配置我们的 AWS 基础设施,并引用 Packer 构建的 VM 镜像来部署我们的 EC2 实例。
云架构
我们设计的第一部分是将我们解决方案的架构适配到目标云平台:AWS。这涉及到将应用架构组件映射到 AWS 服务,并深入思考这些服务的配置,以确保它们符合我们解决方案的要求。
虚拟网络
虚拟机必须部署在虚拟网络内。在 AWS 上,我们使用 AWS EC2 服务提供虚拟机,并使用 AWS 虚拟私有云(VPC)提供虚拟网络。在 AWS 上工作时,术语EC2 实例与虚拟机可以互换使用。同样,术语VPC也可以与虚拟网络互换使用。在本书中,我将尽量使用行业标准术语。你应该养成这种思维习惯,因为这将使你在不同的云平台之间更好地过渡:
图 7.4 – AWS 虚拟网络架构
正如我们之前所讨论的,虚拟网络被划分为一组子网。在 AWS 上,虚拟网络的范围限定在特定的区域内,而子网的范围限定在该区域内的可用区。因此,为了在 AWS 上构建高可用的系统,我们必须将工作负载分布在多个可用区。如果一个可用区发生故障,我们将工作负载部署到另一个可用区,以避免对最终用户的干扰。
我们应用程序的虚拟机需要在虚拟网络中的子网中进行部署。我们的应用程序前端需要可以通过互联网访问,而后端只需要对前端可访问。因此,我们应该为可通过互联网访问的前端和私有后端分别配置子网。这是创建公有和私有子网的常见模式:
图 7.5 – 前端和后端应用组件的公有子网与私有子网
在这种模式下,创建了两对公有子网和私有子网。每一对都部署在相同的可用区内。每对子网共享同一可用区的原因是前端和后端之间存在依赖关系。例如,如果后端所在的可用区发生故障,前端将无法运行。同样,如果前端所在的可用区发生故障,将无法将流量路由到后端。我们可以根据区域内的可用区数量创建任意多对公有/私有子网。大多数区域有四到五个可用区,但通常,两个到三个可用区就足以应对大部分工作负载。之后,您更有可能从设置多区域部署中受益。
网络路由
还有一些其他组件需要在此虚拟网络中进行配置,以确保虚拟机能够正常运行。在 AWS 中,当你在虚拟网络中配置虚拟机时,虚拟机将无法访问互联网!对于大多数连接的应用程序来说,互联网访问是必需的,因为它允许连接到第三方服务。没有这个功能,操作员将会面临不便,因为他们无法使用托管在互联网上的软件包仓库来进行操作系统的升级和修补:
图 7.6 – 互联网和 NAT 网关为子网内的虚拟机提供互联网访问
互联网网关连接到区域级别的虚拟网络,为整个 VPC 提供互联网访问,而 NAT 网关则部署在每个公有子网的可用区级别,允许私有子网中的 EC2 实例访问互联网,同时不会直接暴露给互联网。每个 NAT 网关还需要拥有自己的静态公共 IP 地址才能提供访问权限。这可以通过使用 AWS 的弹性 IP 服务来实现:
图 7.7 – 路由表与子网关联,将流量引导到正确的网关
在为私有子网中的虚拟机建立互联网访问的最后一步中,我们需要将面向互联网的流量路由到每个子网的正确 NAT 网关;而公有子网中的虚拟机可以直接访问互联网。这可以通过路由表来实现。在公有子网中,我们将互联网流量路由到互联网网关;在私有子网中,我们将互联网流量路由到 NAT 网关。
负载均衡
现在,我们的子网已经设置完毕,并通过适当的路由表进行了连接,我们可以开始配置虚拟机(VMs)。为了实现高可用性,我们需要为每个子网配置至少一台虚拟机,前端和后端都需要如此。我们可以通过增加每个子网中的虚拟机数量来实现更高的可靠性或扩展性:
图 7.8 – 为我们的虚拟网络配置的虚拟机
当前设计的问题在于,我们需要一种方式让系统能够正确响应影响其中一个可用区的故障。这时,负载均衡器发挥了作用。它允许我们获得双重好处:将流量路由到健康的终端,并将负载均匀分配到我们的资源上。
在 AWS 中,应用负载均衡器(ALB)服务执行此功能。负载均衡器的任务是作为客户端发送请求的单一接入点。负载均衡器随后将流量转发到虚拟机,并将相应的响应从请求来源返回给客户端:
图 7.9 – 负载均衡器将流量转发到跨可用区的虚拟机
在 AWS ALB 中,我们首先需要设置监听器。在这个监听器上,你需要指定端口、协议以及在接收到请求时希望执行的一个或多个操作。最基本的操作类型是将请求转发到目标组。
在我们的解决方案中,目标组将由一组虚拟机(VM)组成。目标组指定请求应该发送到哪个端口和协议,以及一个具有特定应用路径的健康探测。健康探测可以选择性地设置在不同的端口和协议上,它提供了几个不同的设置来控制探测频率以及如何评估端点是否健康或不健康。健康通常通过 HTTP 状态码200
表示,其他任何情况都被视为不健康。
对于我们的前端和后端,我们为目标组设置了一组简单的虚拟机,端点配置为 HTTP 协议,端口为5000
(ASP.NET Core 的默认端口)。
前端是ASP.NET Core Blazor应用程序。因此,它使用SignalR(抽象了 WebSocket 通信)来提供浏览器和服务器之间的实时连接。因此,我们需要启用粘性会话,以便其能够正常工作。粘性会话将允许客户端继续使用相同的虚拟机,从而保持 WebSocket 连接不受更改与哪个 Web 服务器通信的影响。
对于健康探测,前端将使用 Web 应用程序的根路径,而后端将使用一个特殊路径,该路径会路由到一个已配置响应健康探测的控制器。
网络安全
现在,我们的虚拟网络已经完全配置好,虚拟机也已经设置在负载均衡器后面,我们需要思考希望允许哪些网络流量通过系统。在 AWS 中,可以通过创建安全组来控制这一点,安全组允许在特定端口和协议上,组件之间的流量传输。
这个过程的第一步是思考网络流量在通过解决方案时的逻辑停靠点:
图 7.10 – 我们架构的逻辑组件
应用组件,包括前端和后端,列在此列表中,后面是数据库。然而,这些并不是我们网络流量流动的唯一地方。由于我们在前端和后端前面引入了负载均衡器,因此我们有两个额外的网络流量停靠点。
下一步是思考每个组件如何与其他组件通信。这不仅包括端口和协议,还包括流量的方向。为此,我们需要从每个组件的角度思考网络流量:
图 7.11 – 前端负载均衡器网络流量
从前端负载均衡器的角度来看,我们将通过端口80
使用 HTTP 协议接收来自互联网的流量。这些入站流量称为5000
,使用 HTTP 协议。这些出站流量称为egress:
图 7.12 – 前端网络流量
从前端的角度来看,我们将通过端口5000
使用 HTTP 协议接收来自前端负载均衡器的流量。C#应用程序代码将向后端托管的 REST Web API 发出请求,但我们将通过后端负载均衡器将所有请求通过端口80
使用 HTTP 协议路由到后端:
图 7.13 – 后端负载均衡器网络流量
从后端负载均衡器的角度来看,我们将通过端口80
使用 HTTP 协议接收来自前端的流量。由于目标组配置,我们将把这些请求转发到后端的端口5000
,并使用 HTTP 协议:
图 7.14 – 后端网络流量
从后端的角度来看,我们将通过端口5000
使用 HTTP 协议接收来自后端负载均衡器的流量。C#应用程序代码将使用 HTTPS 协议向 PostgreSQL 数据库发出请求,端口为5432
。
秘密管理
像数据库凭证或服务访问密钥这样的秘密需要安全存储。每个云平台都有提供此功能的服务。在 AWS 上,这项服务称为AWS Secrets Manager:
图 7.15 – 存储在 AWS Secrets Manager 中的秘密可以在虚拟机具备必要的 IAM 权限后进行访问
你只需在此服务上使用一致的命名约定创建秘密,然后构建一个具有访问这些秘密权限的 IAM 角色。以下 IAM 策略将仅授予以fleetportal/
开头的秘密的权限:
{
“Version”: “2012-10-17”,
“Statement”: [
{
“Effect”: “Allow”,
“Action”: “secretsmanager:GetSecretValue”,
“Resource”:
“arn:aws:secretsmanager:region:account
id:secret:fleetportal/*”
}
]
}
region
和account-id
的值需要更改,以反映秘密创建的地点。需要注意的是,AWS 账户通常作为应用程序和环境的安全边界。因此,我们可能会为解决方案的开发和生产环境使用不同的 AWS 账户,此外还有可能需要的其他环境。这将使我们的秘密管理器的秘密在 AWS 账户和区域的上下文中得到隔离。
我们用来授予权限的两个主要属性是action
和resource
。在实施最小权限原则时,尽可能具体地定义某个身份所需的操作非常重要。如果不需要访问,就不要授予。类似地,我们还应该确保授予这些权限的资源尽可能狭窄。很容易懒惰,留下*
作为资源或操作,但我们需要意识到,恶意攻击者可能会利用过于宽泛的权限在我们的环境中横向移动。
虚拟机
现在我们已经准备好了所有解决方案所需的内容,接下来可以讨论我们的应用组件将运行在哪里:在使用 AWS EC2 配置的虚拟机中。
在 AWS 上配置虚拟机(VM)时,您有两种选择。首先,您可以提供静态虚拟机。在这种方式中,您需要为每个虚拟机指定关键特性。或者,您可以使用AWS 自动扩展组来动态配置和管理虚拟机。在这种方式中,您提供自动扩展组一些配置和参数,说明何时进行扩展,何时进行缩减,然后自动扩展组将处理其余的所有事情。
在 AWS 上配置静态虚拟机时,您需要将其与AWS 密钥对关联,以确保您可以连接到其操作系统。这将允许操作员执行诊断、更新或修补软件和操作系统。
所有虚拟机都需要连接到虚拟网络,因此,当您设置静态虚拟机时,您需要指定网络配置。这可以通过创建网络接口并将其与虚拟机关联来实现。网络接口将虚拟机连接到适当的子网,子网是您附加一个或多个安全组的地方。
您虚拟机的内部配置由两个关键属性控制:虚拟机镜像和用户数据。正如我们在第四章中讨论的那样,虚拟机镜像可以是操作系统的原始安装版本,也可以是您的应用程序的完全配置版本。构建与烘焙的决定由您决定。
用户数据允许您在虚拟机启动时执行最后一公里配置。这可以通过使用业界标准的cloud-init
配置来完成,执行各种任务,例如设置用户/组、设置环境变量或挂载磁盘:
图 7.16 – 静态创建的资源虚拟机
AWS 可以根据虚拟机负载的变化动态管理你的虚拟机。这是通过 Auto Scaling 组来完成的。Auto Scaling 组负责虚拟机的配置,因此,Auto Scaling 组需要在启动模板中定义虚拟机集的关键特性。Auto Scaling 组使用该启动模板来指定每个虚拟机的配置:
图 7.17 – 使用 Auto-Scaling 组动态创建和管理的虚拟机
除了这个启动模板,Auto Scaling 组只需要告知哪些子网应该为虚拟机(VM)提供服务,以及在什么情况下它应该为当前管理的虚拟机集群提供或移除虚拟机。
监控
AWS 有一个跨服务的工具叫做 CloudWatch,它可以捕获你在解决方案中使用的各种 AWS 服务的日志和遥测数据。在本书中,我们将使用它作为主要的日志记录机制。许多服务开箱即用支持 CloudWatch,几乎不需要配置就能开始使用。同时,其他服务和场景则需要授权才能让该服务在 CloudWatch 中记录日志。
部署架构
现在我们已经对解决方案在 AWS 上的云架构有了清晰的了解,我们需要制定一个计划,来决定如何配置环境和部署我们的代码。
虚拟机配置
在我们的解决方案中,有两个虚拟机角色:前端角色负责处理终端用户浏览器的网页请求,后端角色负责处理来自 Web 应用程序的 REST API 请求。这些角色有不同的代码和配置,且每个角色需要单独的 Packer 模板来构建一个虚拟机镜像,我们可以用它来在 AWS 上启动虚拟机:
图 7.18 – 使用 Packer 管道构建前端的虚拟机镜像
一个 GitHub Actions 工作流会根据前端应用程序代码和前端 Packer 模板的变化触发执行 packer build
,并为解决方案的前端创建一个新的虚拟机镜像。
前端和后端都会有一个相同的 GitHub 工作流来执行 packer build
。工作流之间的主要区别在于它们执行的代码库。前端和后端可能有稍微不同的操作系统配置,并且都需要不同的部署包来处理各自的应用程序组件:
图 7.19 – 使用 Packer 管道构建后端的 VM 镜像
需要注意的是,应用程序代码将被集成到虚拟机(VM)镜像中,而不是复制到已经运行的虚拟机中。这意味着,为了更新运行在虚拟机上的软件,每个虚拟机需要重启,以便使用包含最新代码副本的新虚拟机镜像。
这种方法使得虚拟机镜像成为一个不可变的部署工件,每次发布需要部署的应用程序代码时,它都会被版本化和更新。
云环境配置
一旦为前端和后端构建完成虚拟机镜像,我们就可以执行最终工作流,既配置又部署我们的解决方案到 AWS:
图 7.20 – 虚拟机镜像作为 Terraform 代码的输入,Terraform 在 AWS 上配置环境
Terraform 代码库将包含两个输入变量,分别对应前端和后端的虚拟机镜像版本。当需要部署应用软件的新版本时,这些版本的输入参数将递增,以反映目标版本。工作流执行时,terraform apply
将用新的虚拟机镜像替换现有虚拟机。
现在我们已经有了如何使用 AWS 实现云架构和使用 GitHub Actions 实现部署架构的明确计划,接下来就开始构建吧!在下一节中,我们将详细解析用于实现 Terraform 和 Packer 解决方案的 HashiCorp 配置语言代码。
构建解决方案
设计已定,接下来我们只需编写实现该设计的代码。
Packer
我们的解决方案包含前端和后端应用程序组件。尽管应用程序代码有很大不同,但我们构建虚拟机镜像的方式是相同的。
AWS 插件
正如我们在第四章中讨论的,Packer——与 Terraform 类似——是一个可扩展的命令行可执行文件。每个云平台为 Packer 提供一个插件,用于封装与其服务的集成:
packer {
required_plugins {
amazon = {
source = “github.com/hashicorp/amazon”
version = “~> 1.2.6”
}
}
}
插件需要在 Packer 解决方案中声明。截止目前,AWS Packer 插件的最新版本是1.2.6
。
AWS Packer 插件提供了一个amazon-ebs
构建器,它通过从基础镜像创建一个新虚拟机、执行配置程序、拍摄弹性块存储(EBS)磁盘镜像快照,并从中创建Amazon Machine Image(AMI),来生成一个 AMI。此行为由 Amazon 构建器控制:
data “amazon-ami” “ubuntu2204” {
filters = {
architecture = “x86_64”
virtualization-type = “hvm”
root-device-type = “ebs”
name = “ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*”
}
owners = [“099720109477”]
most_recent = true
region = var.aws_primary_region
}
Amazon amazon-ebs
构建器的第一个输入是创建初始虚拟机时所使用的基础镜像,Packer 模板的配置程序将在该虚拟机上执行。上述代码引用了目标 AWS 区域内最新版本的 Ubuntu 22.04
虚拟机镜像:
source “amazon-ebs” “vm” {
region = var.aws_primary_region
ami_name = “${var.image_name}-${var.image_version}”
instance_type = var.aws_instance_type
ssh_username = “ubuntu”
ssh_interface = “public_ip”
communicator = “ssh”
source_ami = data.amazon-ami.ubuntu2204.id
}
amazon-ebs
构建器引用amazon-ami
数据源,以确保在执行提供者之前使用正确的基础镜像。在此,ami_name
可能是该块中最重要的属性,因为它决定了虚拟机镜像在terraform
apply
操作中引用的版本名称。
操作系统配置
为了避免访问控制问题,最好为要执行的提供者(provisioners)建立一个执行环境:
locals {
execute_command = “chmod +x {{ .Path }}; {{ .Vars }} sudo -E sh ‘{{ .Path }}’”
}
这是一个标准的execute_command
参数,可以用来设置所有提供者的执行环境。它使得你可以在安装脚本中消除不必要的sudo
命令。前面的execution_command
参数将允许 Packer 模板脚本以特权用户身份执行。
我们的解决方案是使用 ASP.NET Core 构建的。因此,为了确保解决方案在虚拟机上正常运行,我们需要安装 .NET 6.0 SDK。Ubuntu,像其他基于 Debian 的 Linux 发行版一样,使用apt
命令行应用程序来执行软件包管理。默认情况下,Ubuntu 包括了几个公共仓库,这些仓库包含了大多数常见的软件包。然而,有时当默认仓库无法使用时,我们需要设置额外的软件包仓库。微软为apt
提供了一个软件包仓库,里面包含了我们需要安装 .NET 6.0 的正确软件包。因此,在使用apt
安装 .NET 6.0 之前,我们需要先添加该仓库。
我们的 Packer 模板包括一个名为dotnet.pref
的文件,内容如下:
Package: *
Pin: origin “packages.microsoft.com”
Pin-Priority: 1001
我们使用 Packer 的file
提供者将此文件复制到虚拟机的正确位置:
provisioner “shell” {
execute_command = local.execute_command
inline = [
“cp /tmp/dotnet.pref /etc/apt/preferences.d/dotnet.pref”
]
}
接着,我们执行install-dotnet6-prereq.sh
Bash 脚本,它会下载一个.deb
文件并使用dpkg
工具进行安装。这个操作将会通过 Debian 包管理工具注册微软提供的第三方仓库。
现在,我们可以简单地运行apt-get update -y
,从所有仓库中获取软件包的最新版本,之后就可以准备安装 .NET 6.0 了:
provisioner “shell” {
execute_command = local.execute_command
inline = [
“apt-get install dotnet-sdk-6.0 -y”
]
}
如果我们没有包括packages.microsoft.com
仓库,那么apt-get install
命令将会失败,并显示错误信息,提示找不到dotnet-sdk-6.0
软件包。
在 Linux 中设置服务
大多数应用程序在 Linux 中以进程的形式运行,并且通常是持续运行的。这种情况通常发生在应用程序需要监听网络流量时——比如 Web 服务器。设置 Linux 服务的另一个好处是,操作系统可以在每次虚拟机重启时自动启动服务。为此,你需要设置一个服务定义文件:
[Unit]
Description=Fleet Portal
[Service]
WorkingDirectory=/var/www/fleet-portal
ExecStart=/usr/bin/dotnet /var/www/fleet-portal/FleetPortal.dll
Restart=always
RestartSec=10 # Restart service after 10 seconds if the dotnet service crashes
SyslogIdentifier=fleet-portal
User=fleet-portal-svc
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
[Install]
WantedBy=multi-user.target
这个服务文件需要被复制到/etc/systemd/system
文件夹中。通过运行systemctl
命令,它会被启用,这样操作系统就会在机器重启时自动启动该服务。systemctl
命令还可以用于start
、stop
以及检查服务的status
状态。
最佳实践是使用其自己的身份运行服务。这样可以仅将服务访问需要的 VM 上的资源:
provisioner “shell” {
execute_command = local.execute_command
inline = [
“groupadd fleet-portal-svc”,
“useradd -g fleet-portal-svc fleet-portal-svc”,
“mkdir -p /var/www/fleet-portal”,
“chown -R fleet-portal-svc:fleet-portal-svc /var/www/fleet-portal”
]
}
前面的代码设置了一个本地用户和组,用于服务运行,并更改了应用程序文件夹 /var/www/fleet-portal
的所有者,以便服务的用户帐户有足够的访问权限来访问应用程序的可执行文件和支持文件。用户和应用程序的工作目录都在服务定义文件中指定。
一旦用户准备好,我们可以安装服务定义文件并启用服务:
provisioner “shell” {
execute_command = local.execute_command
inline = [
“cp /tmp/fleet-portal.service /etc/systemd/system/fleet-portal.service”,
“systemctl enable fleet-portal.service”
]
}
这部分内容涉及操作系统配置,可以集成到虚拟机镜像中。任何额外的配置步骤都需要来自 Terraform 所提供的云环境的更多信息。
Terraform
正如我们在设计中讨论的那样,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都有一个需要部署的应用程序代码库。因为这是我们第一次使用 aws
提供程序,我们将首先查看基本的提供程序设置以及在查看架构的每个组件的细节之前如何配置后端。
提供者设置
我们需要在 required_providers
块中指定我们打算在此解决方案中使用的所有提供者:
terraform {
required_providers {
aws = {
source = “hashicorp/aws”
version = “~> 5.17”
}
cloudinit = {
source = “hashicorp/cloudinit”
version = “~> 2.3.2”
}
}
}
我们还需要配置 AWS 提供程序以确保它使用所需的目标区域,使用 primary_region
输入变量:
provider “aws” {
region = var.primary_region
}
有时,您可能希望在将来添加次要区域,因此在启动项目时建立主要区域是一个好主意。即使您只部署到一个区域,您仍然有一个 主要区域。
AWS 提供程序确实需要一些额外的参数来指定连接到 AWS 所使用的凭据,但因为这些是敏感值,我们不想将它们嵌入到代码中。稍后在自动化部署时,我们将传递这些值,使用标准的 AWS AWS_ACCESS_KEY_ID
和 AWS_SECRET_ACCESS_KEY
环境变量。需要注意的是,有许多不同的方法可以配置 AWS 提供程序与 AWS 进行身份验证。我建议使用环境变量,因为这是跨云平台和其他 Terraform 提供程序的一种一致方法,并且可以轻松集成到不同的流水线工具中,例如我们将在下一节和将来的章节中使用的 GitHub Actions。
后端
因为我们将使用 CI/CD 流水线来长期进行环境的配置和维护,所以我们需要为我们的 Terraform 状态设置一个远程后端。因为我们的解决方案将托管在 AWS 上,我们将使用 AWS 简单存储服务 (S3) 后端来存储我们的 Terraform 状态。
就像 AWS 提供程序一样,我们不想在代码中硬编码后端配置,因此我们将简单地为后端设置一个占位符:
terraform {
...
backend “s3” {
}
}
我们将在 CI/CD 管道中运行 terraform init
时,使用 -backend-config
参数来配置后端的参数。
确保用于身份验证的 AWS IAM 身份有权访问该 S3 存储桶非常重要。否则,您将遇到身份验证错误。
输入变量
最好的做法是传入能够标识应用程序名称和环境的短名称。这可以让您在组成解决方案的资源上嵌入一致的命名规范,从而使您更容易在 AWS 控制台中识别和追踪这些资源。
primary_region
、vpc_cidr_block
和 az_count
输入变量驱动部署的关键架构特性。它们不能硬编码,因为那样会限制 Terraform 代码库的可重用性。
vpc_cidr_block
输入变量建立了虚拟网络地址空间,这通常是由企业治理机构严格管理的。通常会有一个流程,确保组织内部的各个团队不会使用冲突的 IP 地址范围,从而避免未来无法使这两个应用程序集成,或者无法与企业内的共享网络资源进行集成。
az_count
输入变量允许我们配置解决方案中的冗余度。这个设置将影响解决方案的高可用性以及部署的成本。正如你可以想象的那样,成本也是云基础设施部署中严格管控的一个特性。
一致的命名和标记
AWS 控制台的设计方式使得从应用程序角度查看部署变得相当困难。因此,确保在部署的资源中留下能够指示它们所属应用程序和环境的线索至关重要。几乎所有 AWS 提供的资源都拥有一个名为 tags
的 map
属性:
resource “aws_vpc” “main” {
cidr_block = var.vpc_cidr_block
tags = {
Name = “${var.application_name}-${var.environment_name}-network”
application = var.application_name
environment = var.environment_name
}
}
你应该养成习惯,为你的设备设置 AWS 控制台识别的 Name
标签,并采用一种标记方案,以确定该资源属于哪个应用程序和环境。对于我们的解决方案,我们使用两个顶级输入变量 application_name
和 environment_name
来设定这个上下文,并将这些值嵌入到我们所有的资源中。
AWS 可以通过称为资源组的功能,在 AWS 控制台中创建一个应用程序中心的视图。与其他平台不同,AWS 中的资源组并不是一组资源的强边界,而是基于公共标记方案所衍生的资源之间的松耦合关系:
resource “aws_resourcegroups_group” “main” {
name = “${var.application_name}-${var.environment_name}”
resource_query {
query = jsonencode(
{
ResourceTypeFilters = [
“AWS::AllSupported”
]
TagFilters = [
{
Key = “application”
Values = [var.application_name]
},
{
Key = “environment”
Values = [var.environment_name]
}
]
}
)
}
}
上述代码创建了一个 AWS 资源组,为您提供了一个中央位置,从一个地方访问所有相关资源。只需在所有资源上添加 application
和 environment
标签即可包含它们。
虚拟网络
因为我们的解决方案是标准的三层架构,我们将虚拟网络配置为公共和私有子网,分别对应前端和后端应用组件。
我们希望将虚拟机分布到不同的可用区,以确保解决方案的高可用性。与其硬编码可用区或仅选择前两个可用区,不如使用aws_availability_zones
数据源和random
提供商中的random_shuffle
资源,从给定区域的可用可用区列表中随机选择所需的可用区数量:
data “aws_availability_zones” “available” {
state = “available”
}
resource “random_shuffle” “az” {
input = data.aws_availability_zones.available.names
result_count = var.az_count
}
如果az_count
输入变量的值为2
,则上述代码将随机选择当前 AWS 提供商区域中的两个可用区。请记住,AWS 提供商是针对特定区域的,当我们初始化提供商时,我们使用primary_region
输入变量来设置该值。
与其为我们的子网硬编码地址空间,不如利用 HCL 的内置函数来计算子网的地址空间。cidrsubnet
函数允许我们将地址空间拆分成更小的地址空间:
locals {
azs_random = random_shuffle.az.result
public_subnets = { for k, v in local.azs_random :
k => {
cidr_block = cidrsubnet(var.vpc_cidr_block, var.cidr_split_bits, k)
availability_zone = v
}
}
private_subnets = { for k, v in local.azs_random :
k => {
cidr_block = cidrsubnet(var.vpc_cidr_block, var.cidr_split_bits, k + var.az_count)
availability_zone = v
}
}
}
上述代码将生成两个映射,一个用于公共子网,另一个用于私有子网。它通过选择随机的可用区,并使用cidrsubnet
为每个可用区抓取下一个可用的/24
或256
个 IP 地址块来实现(这足够让我们的应用在每个可用区的前端和后端扩展到大量虚拟机):
public_subnets = {
“0” = {
“availability_zone” = “us-west-2c”
“cidr_block” = “10.0.0.0/24”
}
“1” = {
“availability_zone” = “us-west-2a”
“cidr_block” = “10.0.1.0/24”
}
}
private_subnets = {
“0” = {
“availability_zone” = “us-west-2c”
“cidr_block” = “10.0.2.0/24”
}
“1” = {
“availability_zone” = “us-west-2a”
“cidr_block” = “10.0.3.0/24”
}
}
上述代码是当使用vpc_cidr_block
值为10.0.0.0/16
、cidr_split_bits
值为8
和az_count
值为2
时,public_subnets
和private_subnets
映射的评估值。
通过操作这些输入变量,我们可以合理地确定虚拟网络及其相应子网的大小,以便不会独占可用地址空间,给其他可能在更广泛组织内部署的应用程序留出空间。例如,将vpc_cidr_block
设置为10.0.0.0/22
为我们的应用分配了总计1024
个 IP 地址。设置az_count
值为2
和cidr_split_bits
值为2
,我们可以为四个子网分配地址空间,每个子网的地址为/24
,有256
个 IP 地址。这为我们的应用提供了足够的扩展空间,而不会过度分配宝贵的 IP 地址空间:
resource “aws_subnet” “frontend” {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
availability_zone = each.value.availability_zone
cidr_block = each.value.cidr_block
}
我们通过遍历相应的子网地址空间映射来创建每个子网。上述代码演示了如何使用此映射为每个子网设置正确的可用区和地址空间。
网络路由
根据我们的设计,公共子网将互联网流量路由到互联网网关:
resource “aws_route_table” “frontend” {
vpc_id = aws_vpc.main.id
route {
cidr_block = “0.0.0.0/0”
gateway_id = aws_internet_gateway.main.id
}
}
resource “aws_route_table_association” “frontend” {
for_each = aws_subnet.frontend
subnet_id = each.value.id
route_table_id = aws_route_table.frontend.id
}
我们使用aws_route_table
资源定义路由,然后使用aws_route_table_association
将路由表与相应的子网关联。
私有子网将它们的互联网流量路由到 NAT 网关,该网关在每个私有子网中进行配置:
resource “aws_eip” “nat” {
for_each = local.private_subnets
}
resource “aws_nat_gateway” “nat” {
for_each = local.private_subnets
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.backend[each.key].id
depends_on = [aws_internet_gateway.main]
}
因为每个私有子网都有自己的 NAT 网关,我们需要为每个子网配置一张路由表,以将流量路由到正确的 NAT 网关:
resource “aws_route_table” “backend” {
for_each = local.private_subnets
vpc_id = aws_vpc.main.id
route {
cidr_block = “0.0.0.0/0”
nat_gateway_id = aws_nat_gateway.nat[each.key].id
}
}
resource “aws_route_table_association” “backend” {
for_each = local.private_subnets
subnet_id = aws_subnet.backend[each.key].id
route_table_id = aws_route_table.backend[each.key].id
}
请注意,与共享相同路由表的公共子网不同,我们需要遍历private_subnets
映射,为每个私有子网创建一个不同的路由表,并使用each
符号将其与相应的私有子网关联。
负载均衡
根据我们的设计,我们需要两个 AWS ALB 实例——一个用于前端,另一个用于后端。我们将使用aws_lb
资源以及带有aws_lb
前缀的相关资源来配置目标组和监听器:
resource “aws_lb_target_group” “frontend_http” {
name = “${var.application_name}-${var.environment_name}-frontend-http”
port = 5000
protocol = “HTTP”
vpc_id = aws_vpc.main.id
slow_start = 0
load_balancing_algorithm_type = “round_robin”
stickiness {
enabled = true
type = “lb_cookie”
}
health_check {
enabled = true
port = 5000
interval = 30
protocol = “HTTP”
path = “/”
matcher = 200
healthy_threshold = 3
unhealthy_threshold = 3
}
}
请注意,ASP.NET Core Blazor Web 应用程序的 WebSocket 配置所需的粘性会话配置是通过一个嵌套的stickiness
块实现的。同样,健康检查是通过一个嵌套的health_check
块实现的。这个结构对于前端和后端都是相同的,但配置会有所不同,后端不需要粘性会话,并且健康检查的路径不同。
虚拟机通过aws_lb_target_group_attachment
资源明确地包含在目标组中:
resource “aws_lb_target_group_attachment” “frontend_http” {
for_each = aws_instance.frontend
target_group_arn = aws_lb_target_group.frontend_http.arn
target_id = each.value.id
port = 5000
}
请注意,我们正在遍历相应的aws_instance
资源映射,并使用each.value.id
引用 AWS EC2 实例 ID。
最后,我们必须配置 AWS ALB 本身:
resource “aws_lb” “frontend” {
name = “${var.application_name}
${var.environment_name}-frontend”
internal = false
load_balancer_type = “application”
subnets = [for subnet in values(aws_subnet.frontend) : subnet.id]
security_groups = [aws_security_group.frontend_lb.id]
tags = {
Name = “${var.application_name}-${var.environment_name}-frontend-lb”
application = var.application_name
environment = var.environment_name
}
}
请注意,我们正在动态构建一个子网列表,通过相应的aws_subnet
资源映射。当资源块通过count
值进行配置时,该资源块变成一个列表,而当它通过for_each
迭代器进行配置时,它变成一个映射。当你想从其他资源引用它时,这个细节非常重要。
最后,我们必须通过监听器将我们的 AWS ALB 连接到目标组:
resource “aws_lb_listener” “frontend_http” {
load_balancer_arn = aws_lb.frontend.arn
port = “80”
protocol = “HTTP”
default_action {
type = “forward”
target_group_arn =
aws_lb_target_group.frontend_http.arn
}
}
网络安全
根据我们的设计,我们的解决方案架构有三个逻辑组件,网络流量将通过这些组件传输。每个组件需要自己的安全组和规则集,以允许入站和出站流量:
resource “aws_security_group” “frontend_lb” {
name = “${var.application_name}-${var.environment_name}-frontend-lb-sg”
description = “Security group for the load balancer”
vpc_id = aws_vpc.main.id
}
安全组是通过aws_security_group
资源创建的,并附加到虚拟网络上。
并不是架构中的所有组件都需要入站和出站规则,但思考网络流量应该如何在系统中流动是非常重要的:
resource “aws_security_group_rule” “frontend_lb_ingress_http” {
type = “ingress”
from_port = 80
to_port = 80
protocol = “tcp”
security_group_id = aws_security_group.frontend_lb.id
cidr_blocks = [“0.0.0.0/0”]
}
resource “aws_security_group_rule” “frontend_lb_egress_http” {
type = “egress”
from_port = 5000
to_port = 5000
protocol = “tcp”
security_group_id = aws_security_group.frontend_lb.id
source_security_group_id = aws_security_group.frontend.id
}
上面的代码建立了我们为前端负载均衡器设计的规则,允许来自互联网的流量(例如,0.0.0.0/0
)进入,并允许流量流向前端虚拟机(例如,aws_security_group.frontend.id
)。
密钥管理
为了让我们的虚拟机访问 AWS Secrets Manager 资源,我们需要定义一个 IAM 角色,并将其与我们的虚拟机关联。这将使我们的虚拟机在由 IAM 策略定义的安全上下文中操作:
resource “aws_iam_role” “backend” {
name = “${var.application_name}-${var.environment_name}-backend”
assume_role_policy = jsonencode({
Version = “2012-10-17”
Statement = [
{
Action = “sts:AssumeRole”
Effect = “Allow”
Sid = “”
Principal = {
Service = “ec2.amazonaws.com”
}
},
]
})
}
上述代码为后端虚拟机创建了 IAM 角色,这些虚拟机需要访问我们将存储在 AWS Secrets Manager 中的 PostgreSQL 数据库连接字符串。除非定义了策略,否则 IAM 角色本身不会做任何事情。我们需要将策略定义附加到角色上,以授予虚拟机特定的权限:
resource “aws_iam_role_policy” “backend” {
name = “${var.application_name}-${var.environment_name}-backend”
role = aws_iam_role.backend.id
policy = jsonencode({
Version = “2012-10-17”
Statement = [
{
Action = [
“secretsmanager:GetSecretValue”,
]
Effect = “Allow”
Resource = “arn:aws:secretsmanager:secret:${var.application_name}/${var.environment_name}/*”
},
]
})
}
上述代码授予所有操作的虚拟机访问权限,这些虚拟机与访问 AWS Secrets Manager 中以fleet-ops/dev
前缀开头的机密相关联。我们必须使用我们的标准命名约定输入变量application_name
和environment_name
来构建此前缀,分别将fleet-ops
和dev
作为值。当我们配置fleet-ops
平台的生产版本时,environment_name
输入变量将设置为prod
,确保dev
环境中的虚拟机无法访问prod
环境中的机密。将我们应用程序的不同环境部署到隔离的 AWS 账户中,也会创建一个更安全的安全边界。
虚拟机
在配置静态虚拟机时,我们可以更好地控制每台机器的配置。一些虚拟机有特定的网络和存储配置,以满足工作负载需求:
resource “aws_network_interface” “frontend” {
for_each = aws_subnet.frontend
subnet_id = each.value.id
}
resource “aws_network_interface_sg_attachment” “frontend” {
for_each = aws_instance.frontend
security_group_id = aws_security_group.frontend.id
network_interface_id = each.value.primary_network_interface_id
}
上述代码创建了一个网络接口,我们可以将其附加到虚拟机上。请注意,我们正在遍历前端子网。这将确保每个子网中有正好一个虚拟机(因此每个可用区中都有一个)。这个网络接口是我们附加给前端虚拟机的安全组所在的位置。
最后,我们使用aws_instance
资源来配置虚拟机,并确保使用正确的实例类型、网络接口和 AWS AMI:
resource “aws_instance” “frontend” {
for_each = aws_subnet.frontend
ami = data.aws_ami.frontend.id
instance_type = var.frontend_instance_type
key_name = data.aws_key_pair.main.key_name
user_data = data.cloudinit_config.frontend.rendered
monitoring = true
network_interface {
network_interface_id = aws_network_interface.frontend[each.key].id
device_index = 0
}
}
AWS 有一个跨服务的服务,叫做 CloudWatch,它收集各种 AWS 服务的日志和遥测数据。要在 EC2 实例上启用 CloudWatch,您只需要添加monitoring
属性并将其设置为true
。
监控
根据服务及其在用于配置的 Terraform 资源中的可用配置选项,要启用 CloudWatch,您可能需要经过配置额外资源并设置额外 IAM 权限的过程,以授予相应的资源写入 CloudWatch 的权限。
我们需要设置的第一件事是一个 IAM 策略,它将允许特定服务访问并假设一个 IAM 角色。在这种情况下,我们正在授予 VPC 流日志访问权限来假设一个 IAM 角色:
data “aws_iam_policy_document” “vpc_assume_role” {
statement {
effect = “Allow”
principals {
type = “Service”
identifiers = [“vpc-flow-logs.amazonaws.com”]
}
actions = [“sts:AssumeRole”]
}
}
我们将在设置 IAM 角色时使用此策略,以授予 VPC 流日志服务访问该特定 IAM 角色的权限。稍后当我们将所有内容链接在一起时,这将变得非常重要:
resource “aws_iam_role” “vpc” {
name = “${var.application_name}-${var.environment_name}-network”
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
上述代码允许 VPC Flow Logs 假设该角色,从而最终授予它将日志写入 CloudWatch 的权限。
接下来,我们需要设置另一个 IAM 策略,该策略将授予写入 CloudWatch 日志的权限。你可以通过限制允许的操作和策略授予访问权限的资源,进一步缩小访问策略的范围:
data “aws_iam_policy_document” “cloudwatch” {
statement {
effect = “Allow”
actions = [
“logs:CreateLogGroup”,
“logs:CreateLogStream”,
“logs:PutLogEvents”,
“logs:DescribeLogGroups”,
“logs:DescribeLogStreams”,
]
resources = [“*”]
}
}
在前面的代码中,我们通过指定特定的操作(例如 logs:PutLogEvents
)来明确我们希望授予访问权限的操作类型。然而,资源设置为 *
,这是一种非常广泛的访问级别。我们应该考虑将其限制到仅需要的资源。
下一步是将策略附加到 IAM 角色:
resource “aws_iam_role_policy” “cloudwatch” {
name = “${var.application_name}-${var.environment_name}-network-cloudwatch”
role = aws_iam_role.vpc.id
policy = data.aws_iam_policy_document.cloudwatch.json
}
此时,我们已经有一个允许写入 CloudWatch 的 IAM 角色,并且我们已经允许 VPC Flow Logs 假设该角色。
接下来,我们需要创建一个 CloudWatch 日志组,以存储来自 VPC 的日志:
resource “aws_cloudwatch_log_group” “vpc” {
name = “${var.application_name}-${var.environment_name}-network”
}
最后,我们将把 VPC Flow Logs 连接到日志组,并分配它应该使用的 IAM 角色以获得写入 CloudWatch 的权限:
resource “aws_flow_log” “main” {
iam_role_arn = aws_iam_role.vpc.arn
log_destination = aws_cloudwatch_log_group.vpc.arn
traffic_type = “ALL”
vpc_id = aws_vpc.main.id
}
上述代码还将我们的 VPC 与 VPC Flow Logs 服务关联,从而完成流动并将网络日志放入相应的 CloudWatch 日志组中。
至此,我们已经实现了 Packer 和 Terraform 解决方案,并且有了一个工作代码库,该代码库将为我们的前端和后端应用组件构建 VM 镜像,并将我们的云环境配置到 AWS。在下一节中,我们将深入探讨 YAML 和 Bash,并实现 GitHub Actions 工作流。
自动化部署
正如我们在设计中讨论的,我们的解决方案由两个应用组件组成:前端和后端。每个组件都有一个代码库,其中包含应用程序代码和一个封装在 Packer 模板中的操作系统配置。然后,这两个应用组件被部署到 AWS 云环境中,这个环境在我们的 Terraform 代码库中定义。
还有一个我们尚未讨论的额外代码库:我们的自动化管道。我们将使用 GitHub Actions 来实现我们的自动化管道:
图 7.21 – 我们 GitHub 仓库中的源代码结构
在 GitHub Actions 中,自动化管道被称为工作流,并且它们存储在源代码库中的特定文件夹中,即 /.github/workflows
。我们的每个代码库都存储在一个单独的文件夹中。我们的解决方案源代码库的文件夹结构如下所示:
- .github
- workflows
- dotnet
- backend
- frontend
- packer
- backend
- frontend
- terraform
根据我们的设计,我们将拥有 GitHub Actions 工作流,这些工作流将执行 Packer 并构建前端(例如,packer-frontend.yaml
)和后端(例如,packer-backend.yaml
)的 VM 镜像。我们还将拥有运行 terraform plan
和 terraform apply
的工作流:
- .github
- workflows
- packer-backend.yaml
- packer-frontend.yaml
- terraform-apply.yaml
- terraform-plan.yaml
每个文件夹路径将允许我们控制哪些 GitHub Actions 工作流应该触发,这样我们就不会在没有相关变更的情况下不必要地运行工作流。
因为我们遵循 GitFlow,所以我们将有一个主分支,所有生产版本的代码都将在该分支上。开发人员,无论是在应用代码(例如 C#)的更新、操作系统配置(例如 Packer 模板)还是云环境配置(例如 Terraform 模板)上进行工作,都将从 main
分支创建一个以 feature/*
命名约定的分支。
一旦完成这些操作,开发人员可以提交拉取请求。这表明开发人员认为他们的代码更改已经准备好合并回 main
分支——换句话说,他们的代码更改已经准备好投入生产!
图 7.22 – GitFlow 的拉取请求流程
拉取请求是检查我们解决方案代码的好时机。对于应用代码,这可以表现为构建、静态代码分析以及单元测试或集成测试。这些操作分别测试了应用代码的不同方面。构建(即编译 C# 代码库)是我们可以执行的最基本的测试之一。它只是测试应用代码是否是有效的 C# 代码,并且没有固有的语言语法错误。静态代码分析可以涵盖广泛的代码质量检查,包括可读性、可维护性,或安全性和漏洞评估。单元和集成测试检查软件组件的功能,确保它们单独工作和共同工作来实现软件的基本业务目标。定期执行这些测试被称为持续集成(CI),它是著名且经常令人困惑的CI/CD 管道的一部分,其中CD代表持续交付。
CI 管道减少了与应用代码内建质量相关的例行工作。如果没有它,这些检查需要通过人工代码审查和手动测试来执行。我们仍然需要进行代码审查和手动测试,但一个好的 CI 管道将减少人力所需的工作量。
现在我们已经讨论了可以在应用代码上实现的内建质量控制,我们可以对操作系统和云环境配置做些什么呢?是否有办法在不部署基础设施的情况下测试基础设施即代码(IaC)?有的,但存在一些限制。
Packer
因为虚拟机镜像充当着不可变的工件,包含了应用代码和操作系统配置的版本化副本,所以每当应用代码或操作系统配置发生变化时,我们都需要更新这个工件:
on:
push:
branches:
- main
paths:
- ‘src/packer/frontend/**’
- ‘src/dotnet/frontend/**’
这意味着我们需要在两个代码库上设置触发器,以影响 Packer 的最终制品,包括应用程序代码和 Packer 模板本身中的操作系统配置。在 GitHub Actions 中,我们可以添加一个 paths
列表,触发我们的工作流。
每次有拉取请求或推送到 main
时,我们应该构建一个新的虚拟机镜像。当 Packer 执行时,本质上是在进行一个相当严格的集成测试。因此,将其作为我们的 CI 过程的一部分进行执行是非常有用的。这意味着我们需要有一个经过测试并验证为生产就绪的虚拟机镜像,然后才能将代码推送到 main
分支:
图 7.23 – 虚拟机镜像版本控制
我们的 Packer 工作流将为它生成的每个虚拟机镜像创建一个唯一的名称和版本。我们可以在 Packer 模板中构建测试,验证 Web 服务器是否正在运行,并监听端口 5000
。使用该版本的镜像,我们还可以启动一个新的虚拟机,并亲自检查操作系统的配置,以确保一切正常。
当我们确信应用程序代码或操作系统配置的代码更改完全正常时,我们可以批准拉取请求,并将其合并到 main
分支。这将触发一个新的虚拟机镜像版本,从 main
分支中的生产就绪代码生成。我们可以使用该生产就绪虚拟机镜像的新版本来更新我们的云环境配置,当我们准备好将这些更改部署到环境时。
GitHub Actions 工作流需要建立一些基本规则,以控制软件的特定版本和代码库中的关键位置。始终明确是非常重要的。这意味着要使用特定版本的软件,而不是依赖互联网的神明来决定你将使用哪个版本。当你在本地计算机上运行代码并解决不可避免的问题和冲突时,这可能会很好用,但对于自动化流水线来说,那里没有人类来在问题发生时进行修正;只有假设——关于你正在使用的软件版本的假设。
我们将使用两款软件:.NET SDK 和 Packer。同样,我们有两个代码库:用于应用程序的 C# .NET 代码库和用于 Packer 的 HCL 代码库。因此,我们必须非常明确和提前地确定这些代码库的位置。为它们设置流水线变量是实现这一目标的一个非常有用的方法,因为它确保它们在 YAML 文件中突出显示,并且存储在可重复使用的变量中,以防需要多次重复使用:
env:
DOTNET_VERSION: ‘6.0.401’ # The .NET SDK version to use
PACKER_VERSION: ‘1.9.4’ # The version of Packer to use
WORKING_DIRECTORY: “./src/packer/frontend”
DOTNET_WORKING_DIRECTORY: “./src/dotnet/frontend/FleetPortal”
现在我们已经为工作流设置了触发器和一些变量,接下来需要构建作业结构。对于每个 Packer 模板,我们将有两个作业:一个是构建 C# .NET 应用程序代码并生成部署包,另一个是运行packer build
以生成虚拟机镜像:
jobs:
build:
runs-on: ubuntu-latest
steps:
...
packer:
runs-on: ubuntu-latest
steps:
...
build
作业执行一个相当标准的.NET 构建过程,包括从 NuGet(.NET 包管理器)恢复包依赖、构建代码、运行单元测试和集成测试、发布可部署的工件,并存储该工件,以便将来管道中的其他作业使用:
图 7.24 – Packer 工作流
packer
作业会立即下载包含部署工件的.zip
文件,并将其放入 Packer 模板的file
提供程序期望的位置。然后,它会生成一个唯一版本的虚拟机镜像名称,成功的话将被生产出来:
- id: image-version
name: Generate Version Number
run: |
echo “version=$(date +’%Y.%m’).${{ github.run_number }}” >> “$GITHUB_OUTPUT”
它通过使用 Bash 生成当前的年份和月份,并附加github.run_number
来确保唯一性,以防我们一天内多次运行该管道。
接下来,它获取运行 GitHub Actions 工作流的虚拟机的公共 IP 地址:
- id: agent-ipaddress
name: Check Path
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
ipaddress=$(curl -s http://checkip.amazonaws.com)
echo $ipaddress
echo “ipaddress=$ipaddress” >> “$GITHUB_OUTPUT”
它这么做是为了在运行packer build
时,配置 Packer 的 AWS 插件,打开防火墙以允许来自 GitHub Actions 机器到运行在 AWS 上的临时虚拟机的 SSH 流量,Packer 提供程序将在该虚拟机上执行。
接下来,它安装特定版本的 Packer:
- id: setup
name: Setup `packer`
uses: hashicorp/setup-packer@main
with:
version: ${{ env.PACKER_VERSION }}
最后,它执行packer build
,确保指定AWS_ACCESS_KEY_ID
和AWS_SECRET_ACCESS_KEY
环境变量,这些是 AWS 插件依赖于它们来认证 AWS 的 REST API:
- id: build
name: Packer Build
env:
AWS_ACCESS_KEY_ID: ${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
PKR_VAR_image_version: ${{ steps.image-version.outputs.version }}
PKR_VAR_agent_ipaddress: ${{ steps.agent-ipaddress.outputs.ipaddress }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
packer init ./
packer build -var-file=variables.pkrvars.hcl ./
它还通过使用以PKR_VAR_
为前缀的环境变量技术,指定了两个输入变量给 Packer 模板,这样就包括了镜像版本和构建代理 IP 地址,这两个值在 GitHub Actions 工作流中动态生成。
Terraform
随着两个虚拟机镜像构建完成并将其版本输入到我们的tfvars
文件中,Terraform 自动化管道已经准备好接管,不仅能够配置我们的环境,还能部署我们的解决方案(虽然技术上说不完全是)。部署实际上是在packer build
过程中完成的,物理部署包被复制到主目录,并且 Linux 服务设置已经准备就绪。Terraform 通过使用这些镜像启动虚拟机来完成任务:
on:
push:
branches:
- main
paths:
- ‘src/terraform/**’
这意味着我们只需要在 Terraform 代码库发生变化时触发 Terraform 自动化管道。这可能包括对资源的配置更改,简单来说,就是tfvars
文件中更新了虚拟机镜像版本:
图 7.25 – Terraform apply 工作流
结果是,Terraform 流水线非常简单。我们只需要执行 terraform plan
或 terraform apply
,具体取决于我们是想评估还是执行针对云环境的更改。
继续秉承 始终具体 的原则,我们必须恭敬地指定想要使用的 Terraform 版本,并使用流水线变量指定 Terraform 代码库的位置:
env:
TERRAFORM_VERSION: ‘1.5.7’
WORKING_DIRECTORY: “./src/terraform”
接下来,我们必须使用 HashiCorp 发布的 setup-terraform
GitHub Action 安装特定版本的 Terraform,它将为我们处理安装的细节:
- id: setup
name: Setup `terraform`
uses: hashicorp/setup-terraform@main
with:
version: ${{ env.TERRAFORM_VERSION }}
最后,它再次执行 terraform apply
,确保包括 AWS 凭证和 Terraform 状态的目标后端位置:
- id: apply
name: Terraform Apply
env:
AWS_ACCESS_KEY_ID: ${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
BACKEND_BUCKET_NAME: ${{ vars.BUCKET_NAME }}
BACKEND_REGION: ${{ vars.BUCKET_REGION }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
terraform init \
-backend-config=’bucket=’$BACKEND_BUCKET_NAME \
-backend-config=’region=’$BACKEND_REGION \
-backend-config=”key=aws-vm-sample”
terraform apply -target “random_shuffle.az” -auto-approve
terraform apply -auto-approve
后端配置是通过 -backend-config
命令行参数设置的,这样我们就不必在源代码中硬编码这些设置。
请注意,我们执行了两次 terraform apply
。第一次,我们对 random_shuffle.az
资源进行有针对性的应用,之后再执行一般性的应用。有针对性的应用确保我们在计算网络的 IP 地址空间之前,已经选择了目标的可用区。这是由于使用 cidrsubnet
函数动态计算地址空间的需要。如果我们想避免这种有针对性的应用方式,可以选择一种更硬编码的方式,指定可用区和相应的地址空间。
就这样!随着我们完成 Terraform GitHub Actions 工作流的配置,我们为端到端 CI/CD 流水线画上了完美的句号。我们基于 AWS 的解决方案将迅速启动并运行我们的虚拟机云架构。
摘要
在本章中,我们使用 AWS 和虚拟机构建了一个多层云架构,建立了一个完全可操作的 GitFlow 流程,并使用 GitHub Actions 创建了端到端的 CI/CD 流水线。
在下一章中,Söze Enterprises 的无畏领导者将把我们推入混乱,带来一些新的重大创意,我们将不得不响应他的行动号召。原来,我们的 CEO Keyser 最近熬夜看了一些关于下一个大趋势——容器——的 YouTube 视频,经过与他的好友 Jeff 在超级游艇上的交谈,他决定我们需要重构整个解决方案,使其能够在 Docker 和 Kubernetes 上运行。幸运的是,亚马逊的好心人提供了一项可能帮助我们的服务:AWS 弹性 Kubernetes 服务(EKS)。
第八章:8
使用 AWS 容器化——使用 AWS EKS 构建解决方案
在上一章中,我们在 AWS 上构建并自动化了我们的解决方案,同时使用了弹性云计算(EC2)。我们使用 Packer 构建了虚拟机镜像,并通过 Terraform 对虚拟机进行配置。在本章中,我们将沿着类似的路径前进,但这次,我们不再使用虚拟机,而是将把我们的应用程序托管在 Kubernetes 集群中的容器里。
为了实现这一目标,我们需要改变策略,放弃 Packer,改用 Docker 来创建我们应用程序的可部署制品。我们仍将使用aws
提供程序来运行 Terraform,但这次,我们将引入一个新的内容:kubernetes
提供程序,它将在使用aws
提供程序为 Terraform 配置完 AWS 基础设施后,将资源配置到 Kubernetes 集群中。
再次说明,在这种方法中,我们将仅关注新的和不同的部分。我会指出我们在哪些部分是在之前章节的基础上构建的,以及哪些内容是完全新的。
本章涉及以下主题:
-
打下基础
-
设计解决方案
-
构建解决方案
-
自动化部署
打下基础
我们的故事通过 Söze Enterprises 的视角继续进行,该公司由神秘的土耳其亿万富翁 Keyser Söze 创办。我们的团队一直在努力构建下一代自动驾驶汽车编排平台。此前,我们希望通过利用亚马逊坚如磐石的平台,借助团队现有的技能,专注于功能开发,从而超越竞争对手。团队刚刚开始步入正轨时,却迎来了一个意外的挑战。
事实证明,在周末,我们那位难以捉摸的高管受到了与 AWS 首席执行官 Andy Jassy 的会面影响,他们在加拉帕戈斯群岛海岸附近潜水时,接触到了稀有而奇特的海洋生物。Keyser 听说了更高效的资源利用方式,这带来了更好的成本优化以及更快的部署和回滚时间,他被吸引住了。他的新型自动驾驶平台需要利用云的力量,而基于容器的架构就是实现这一目标的方式。所以,他决定加快采用云原生架构的计划!
转向基于容器的架构的消息意味着需要重新评估他们的方法,深入了解新技术,甚至可能重新调整团队的动态。对于团队来说,容器一直是长期计划,但现在,事情需要加快进度,这将需要大量的时间、资源和培训投资。
当团队忙于调整计划时,他们不禁感到一种兴奋与不安的交织。他们知道,在 Keyser 的领导下,他们正在参与一项开创性的工作。他对于自动驾驶未来的愿景大胆而具有变革性。尽管他的做法可能不拘一格,但他们已经学会了他的直觉往往是正确的。在本章中,我们将探讨从 VM 到容器的转变,使用 AWS 来实现这一目标。
设计解决方案
正如我们在前一章中所见,我们使用 AWS EC2 构建了解决方案,并通过 Packer 配置的 VM 镜像完全控制操作系统。现在,我们将转向在 AWS 弹性 Kubernetes 服务(EKS)上托管我们的解决方案,我们需要引入一个新工具,将 VM 镜像替换为容器镜像——Docker:
图 8.1 – 自动驾驶平台的逻辑架构
我们的应用架构,包括前端、后端和数据库,将保持不变,但我们需要使用 Terraform 来配置不同的资源,并利用 Docker 和 Kubernetes 的新工具来自动化将解决方案部署到这一新基础设施中:
图 8.2 – 我们代码库的源代码控制结构
在这个解决方案中,我们将有七个部分。我们依然保留前端和后端的应用代码和 Dockerfile(替代基于 Packer 的 VM 镜像)。我们仍然使用 GitHub Actions 来实施我们的 CI/CD 流程,但现在我们有两个 Terraform 代码库——一个用于配置 AWS 的基础设施,另一个用于将我们的应用部署到 EKS 上托管的 Kubernetes 集群中。然后,我们还有两个代码库,分别用于应用的前端和后端。
云架构
在前一章中,我们的云托管解决方案是由一组专用 VM 组成的。在本章中,我们的目标是利用 AWS EKS,使用由 Kubernetes 管理的共享 VM 池来托管我们的应用。为了实现这一目标,我们将使用一些新的资源,这些资源专为基于容器的工作负载设计。然而,网络、负载均衡及其他组件大部分将保持不变。
虚拟网络
回顾我们在第七章中使用 EC2 实例和虚拟网络的工作,为 AWS EKS 设置虚拟私有云(VPC)遵循类似的流程。核心网络依然存在,依旧充满了繁琐的细节,从子网——包括公有和私有子网——到路由表、互联网网关和 NAT 网关等一系列细节,我们为 EKS 集群构建的虚拟网络将与我们之前创建的网络大致相同。唯一的不同是我们如何使用它:
图 8.3 – AWS 虚拟网络架构
之前,我们为前端 VM 使用了公共子网,而为后端使用了私有子网。正如我们在第五章中所学到的,当我们将 Kubernetes 引入时,我们将过渡到一个共享的 VM 池,托管我们作为 Pod 运行的应用程序。这些 VM 将托管在私有子网中,负载均衡器将托管在公共子网中。
容器注册表
基于我们在第五章中对容器架构的探讨,我们知道我们需要构建容器镜像,并将它们存储在容器注册表中。为此,AWS 提供了弹性容器注册表(ECR)。这是一个私有容器注册表,不同于我们在第五章中提到的公共注册表,例如 Docker Hub。
我们需要利用 Docker 命令行工具来构建和推送镜像到 ECR。为了实现这一点,我们需要授予某个身份必要的权限。正如我们在上一章所看到的,当我们使用 Packer 构建 VM 镜像时,我们可能会有一个 GitHub Actions 工作流,用于构建并将容器镜像推送到 ECR。GitHub Actions 工作流执行的身份需要具备这些权限。将这些 Docker 镜像上传到 ECR 后,最后一步是授予我们的集群访问权限,从注册表中拉取镜像:
图 8.4 – IAM 策略授予一个组推送容器镜像到 ECR 的权限
我们将设置一个 IAM 组,并授予该组相应权限。这样,我们就可以将 GitHub Action 的用户以及任何其他希望直接从命令行推送镜像的用户添加到该组。在 AWS 中,IAM 策略非常灵活;它们可以独立声明,或者与它们附加的身份内联声明。这使得我们可以创建可重用的策略,并将其附加到多个身份上。在这个案例中,我们将定义一个授予推送镜像到 ECR 的权限的策略,然后将其附加到该组。接着,加入该组的成员将获得这些权限。
最后一步是授予集群访问权限,以便它在节点内调度 Pod 时可以从我们的 ECR 拉取镜像。为此,我们可以使用 AWS 提供的内建策略 AmazonEC2ContainerRegistryReadOnly
。我们需要使用其完全限定的 ARN 来引用它,即 arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
。内建策略的 ARN 前缀通常是 arn:aws:iam::aws:policy
,这标识它们是由 AWS 发布的,而非某个具体用户在其 AWS 账户内发布的。当我们发布自己的策略时,完全限定的 ARN 会包含我们的账户号码。
负载均衡
与上一章中我们配置和管理自己的 AWS 应用负载均衡器(ALB)不同,使用 Amazon EKS 的一个优点是 EKS 承担了大部分配置和管理负载均衡器的责任。我们可以使用 Kubernetes 注解来引导和影响其操作,但大部分工作已由 EKS 为我们处理。在我们的解决方案中,为了简化流程,我们将使用 NGINX 作为入口控制器,并配置它为我们设置一个 AWS 网络负载均衡器(NLB):
图 8.5 – 弹性负载均衡器与 NGINX 入口控制器协同工作,将流量路由到我们应用的 Pod
要将此责任委托给 EKS,我们需要授予其配置和管理这些资源所需的 IAM 权限。因此,我们需要配置一个 IAM 策略并将其附加到 EKS 集群。我们可以使用分配给集群节点组的 IAM 角色来完成此操作:
图 8.6 – 允许 EKS 配置和管理弹性负载均衡器的 IAM 策略
然后,我们为 Kubernetes 资源(例如服务和入口控制器)配置资源,并添加注解,以告知我们希望 EKS 代表我们实施的弹性负载均衡器的具体配置。
网络安全
在 Kubernetes 上托管服务并使其可在集群外部访问有很多种方式。在我们的解决方案中,我们将使用 AWS 弹性负载均衡器,通过我们的 NGINX 控制器将外部流量引入集群。还有其他选项,例如 NodePort,允许你通过节点上暴露的端口直接访问 Pod。这需要集群节点的公共访问,而且从安全性和可扩展性角度来看,这并不是首选方法。
如果我们希望使用kubectl
访问集群,那么我们需要开启公共端点访问。这在你自己开发一些小项目时很有用,但在企业环境中并不理想。你很可能已经有了私有网络基础设施,因此永远不需要启用公共端点。
机密管理
将机密信息集成到 Amazon EKS 集群中的 Pod 可以通过多种方式实现,每种方式都有其优缺点。正如我们在上一章中处理虚拟机时所做的那样,我们将探索的方式是使用 AWS Secrets Manager 中的机密信息。Kubernetes 也有一种内置的方法,使用 Kubernetes Secrets。这种方法简单明了,直接集成到 Kubernetes 中,但在安全性方面有局限性,因为机密信息是用 Base64 编码的,任何具有集群访问权限的人都可以访问。
与 AWS Secrets Manager 的集成可以帮助解决这个问题,但为了访问我们存储在其中的机密,我们需要使我们的 Kubernetes 部署能够与 AWS 身份与访问管理 (IAM) 进行身份验证。这通常被称为工作负载身份,它是一个在各大云平台中相对常见的方法:
图 8.7 – AWS EKS 与工作负载身份
要在 EKS 上设置工作负载身份,我们需要使用 OpenID Connect (OIDC) 提供程序来配置集群。然后,我们必须设置一个 IAM 角色,该角色有一个策略,允许 Kubernetes 服务账户假设该角色。然后,可以为此 IAM 角色授予对 Kubernetes 部署所需的任何 AWS 权限和资源的访问权限,包括 Secrets Manager 的机密。最后,我们需要在 Kubernetes 中使用相同名称配置一个 Kubernetes 服务账户,并为其添加特殊注释以将其连接到 IAM 角色。
完成这些步骤后,我们的 Kubernetes 部署将被允许访问 AWS Secrets Manager 的机密,但它们不会使用该访问权限。最后一步是配置 Kubernetes 部署以拉取机密并使其对运行在 Pod 中的应用程序代码可访问:
图 8.8 – AWS EKS 与 Secrets Manager 集成
Kubernetes 通常使用卷挂载的方式来实现这一点。因此,存在一个名为 secrets store 容器存储接口 (CSI) 提供程序的常见 Kubernetes 提供程序。这是一种与云平台无关的技术,可以将 Kubernetes 与外部秘密存储(如 AWS Secrets Manager)集成。此方法提供了增强的安全性和可扩展性,但需要更多的设置和维护。
为了使其正常工作,我们需要将两个组件部署到我们的 EKS 集群中:秘密存储 CSI 驱动程序,然后是该驱动程序的 AWS 提供程序,允许它与 AWS Secrets Manager 进行交互。这两个组件可以通过 SecretProviderClass
部署到我们的 EKS 集群中。这是一种资源类型,通过 CSI 驱动程序连接到 AWS Secrets Manager,从而访问特定的机密。它使用我们通过 IAM 角色及其权限授予访问权限的服务账户连接到 Secrets Manager 中的特定机密。
Kubernetes 集群
Amazon EKS 提供了一项托管的 Kubernetes 服务,简化了容器化应用程序在 AWS 上的部署和管理。EKS 集群是此架构的核心。EKS 负责设置、操作和维护 Kubernetes 控制平面和节点,这些节点本质上是 EC2 实例。在设置 EKS 集群时,用户定义节点组,这些节点组表现为 EC2 实例集合,EKS 服务负责为其配置和管理。
有几种选项可以用来承载您的工作负载的节点组。最常见的例子是 AWS 管理的节点组和自管理的节点组。AWS 管理的节点组本质上是为 EKS 集群分配的按需 EC2 实例。AWS 简化了这些节点的管理,但这也会对可以使用的 AWS 特性施加一些限制。自管理节点本质上也是按需 EC2 实例,但它们提供了更大的控制权,允许对可用的特性和配置选项进行更精细的调整。
优化成本的一个好方法是使用 Fargate 节点组。这个选项利用了 AWS 的无服务器计算引擎,省去了配置和管理 EC2 实例的需求。然而,这可能更适用于不可预测的工作负载,而不是那些需要保持稳定状态的工作负载。在这种情况下,您可以利用自动扩展、现货实例和预留实例的组合,从而获得显著的折扣和成本降低:
图 8.9 – AWS EKS 集群结构
IAM 策略是 EKS 配置的重要部分,因为服务的特性以及我们如何委派责任来管理 AWS 资源。这与我们在 AWS 自动扩展组中的做法类似,但更加重要。IAM 策略附加在集群和单独的节点组上。根据您希望在集群和节点组中启用的功能,您可能需要额外的策略。
AmazonEKSClusterPolicy
策略授予集群访问权限,以控制集群内部的工作方式,包括节点组、CloudWatch 日志和集群内的访问控制。
AmazonEKSVPCResourceController
策略授予集群访问权限,以管理网络资源,例如网络接口、IP 地址分配和安全组附加到 VPC 的操作。
有四个策略(AmazonEKSWorkerNodePolicy
、AmazonEKS_CNI_Policy
、AmazonEC2ContainerRegistryReadOnly
和 CloudWatchAgentServerPolicy
)对于 EKS 工作节点的运行至关重要。这些策略必须附加到您分配给 EKS 节点组的 IAM 角色上。它们授予访问 EKS 集群控制平面的权限,并允许节点组内的节点与集群提供的核心基础设施集成,包括网络、容器注册表和 CloudWatch。如前所述,我们还添加了一个可选的策略,允许 EKS 集群管理弹性负载均衡器。
部署架构
现在我们已经对在 AWS 上构建的解决方案的云架构有了一个清晰的了解,我们需要制定计划,如何配置环境并部署代码。
云环境配置
基于我们在第七章中为 EC2 实例配置建立的方法,我们在配置 AWS EKS 环境时将采用类似的模式。此过程的核心在于利用 GitHub Actions,其基本设置和操作将保持不变:
图 8.10 – Terraform 代码在 AWS 上配置环境
然而,与我们之前配置 EC2 实例不同,Terraform 代码将根据 EKS 环境的需要进行定制。这包括创建 EKS 集群和 ECR。GitHub Action 将自动执行此 Terraform 代码,遵循我们之前使用的相同工作流模式。
通过复用 GitHub Actions 工作流并使用不同的 Terraform 脚本,我们在保持部署过程一致性的同时,适应了 EKS 环境不同的基础设施需求。此步骤需要在独立模式下执行,以确保某些先决条件已到位,例如容器注册表。只有在容器注册表配置完成后,我们才能构建并将容器镜像推送到容器注册表中,用于前端和后端应用组件。
此步骤还将配置托管 Kubernetes 控制平面的 EKS 集群。我们将在最后一步与容器镜像一起使用它来部署我们的应用程序。
容器配置
与 Packer 不同,Packer 不依赖任何现有基础设施来配置应用程序部署工件(例如,由 Packer 构建的 AMI),我们的容器镜像在配置之前需要有一个容器注册表:
图 8.11 – 构建前端容器镜像的 Docker 流水线
工作流与 Packer 非常相似,我们将应用程序代码与存储操作系统配置的模板结合。在这种情况下,它存储的是 Dockerfile,而不是 Packer 模板。
Kubernetes 配置
一旦我们发布了前端和后端的容器镜像,就可以通过添加一个最终步骤来完成部署,该步骤使用 Kubernetes 提供程序执行 Terraform,从而将我们的应用程序部署到 EKS 集群:
图 8.12 – 容器镜像作为 terraform 代码的输入,terraform 代码在 EKS 的 Kubernetes 控制平面上配置环境
我们将输出前一个 Terraform 步骤中配置 AWS 基础设施的关键信息。这将包括关于 ECR 仓库和 EKS 集群的详细信息。我们可以将这些作为最终 Terraform 执行步骤的输入,在该步骤中我们将使用 Kubernetes 提供程序。我们已经将此步骤分离到不同的 Terraform 工作空间,以将其与 AWS 基础设施解耦。这考虑到了 Kubernetes 控制平面层和底层基础设施之间的硬依赖关系。它使我们能够独立管理底层基础设施,而不需要更改 Kubernetes 部署,同时可以在 Kubernetes 控制平面中进行隔离的更改,从而加快发布过程。
在这一部分中,我们回顾了架构中的关键变化,即从基于虚拟机的架构转向基于容器的架构。在下一部分中,我们将采取战术性的步骤来构建解决方案,但我们会小心地基于上一章中我们第一次在 AWS 上使用 EC2 启动虚拟机时所构建的基础,继续构建。
构建解决方案
在这一部分中,我们将把理论知识应用于一个具体的、可运行的解决方案,同时利用 AWS 平台上的 Docker、Terraform 和 Kubernetes 的力量。这个过程的某些部分将需要重大更改,例如我们使用 Terraform 配置 AWS 基础设施时;其他部分将会有一些小的更改,例如我们用来将应用程序部署到 Kubernetes 集群的 Kubernetes 配置;还有一些部分将是全新的,例如构建并推送 Docker 镜像到容器注册表的过程。
Docker
正如我们在上一章中看到的,当我们在 Packer 中构建虚拟机镜像时,需要进行一定的操作系统配置。在 Docker 中,我们基本上做的是相同的事情,但我们是为特定的进程进行配置。这意味着我们在设置 Linux 服务时所做的很多工作被省略了,因为容器运行时控制着应用程序是否运行。这与配置 Linux 操作系统将可执行文件作为服务运行是根本不同的。因此,许多冗余的配置被省略了。
另一个主要区别是,使用 Packer 镜像时,我们在 Packer 外部构建应用程序,并将一个包含应用程序的压缩工件作为 Packer 构建的一部分。而使用 Docker 时,我们将在容器构建过程中构建应用程序并生成工件。这个过程完成后,我们将遵循一个类似的过程,将部署包放入干净的容器镜像层中,以消除任何残留的构建工件:
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
WORKDIR /app
以下这一行设置了构建阶段的基础镜像。它使用了官方的 Microsoft .NET SDK 镜像(版本 6.0
),来自Microsoft 容器 注册表(MCR):
COPY ./FleetPortal/FleetPortal.csproj ./FleetPortal/
RUN dotnet restore ./FleetPortal/FleetPortal.csproj
在我们构建项目之前,需要解决其依赖关系。dotnet restore
命令会通过从 NuGet(.NET 包管理器)拉取所有依赖项来完成此操作:
COPY . ./
RUN dotnet publish ./FleetPortal/FleetPortal.csproj -c Release -o out
在这里,我们执行dotnet publish
命令,创建项目的二进制文件。-c Release
选项指定构建应该为生产环境优化。我们将文件放入out
文件夹中,供后续步骤使用:
FROM mcr.microsoft.com/dotnet/aspnet:6.0
WORKDIR /app
COPY --from=build-env /app/out .
我们以.NET 运行时镜像作为基础,启动一个新的构建阶段,并将我们从上一个阶段构建的二进制文件复制到这个新的构建阶段。这将确保任何中间构建的工件不会层叠到容器镜像中:
ENTRYPOINT [“dotnet”, “FleetPortal.dll”]
最后,我们为容器设置启动命令。当容器启动时,它将运行dotnet FleetPortal.dll
,这将启动我们的 ASP.NET 应用程序,并开始监听传入的 Web 服务器流量。
Terraform
正如我们在设计中讨论的那样,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都有一个需要部署的应用程序代码库。然而,使用 Kubernetes 解决方案时,基础设施变得更简单,因为我们只需要一个 Kubernetes 集群(以及一些其他组件)。重要的部分是 Kubernetes 平台本身的配置。
结果是,Terraform 的设置与我们在上一章所做的非常相似,因此我们只会关注为我们的解决方案所需的新资源。如果你想使用完整的解决方案,可以在 GitHub 上查看本书的完整源代码。
容器注册表
首先,我们将使用 AWS ECR 为应用程序的前端和后端设置仓库。为了简化 ECR 仓库的动态创建,我们可以设置一个名为repository_list
的本地变量,该变量包含我们需要仓库的两个容器镜像的常量:
locals {
repository_list = [“frontend”, “backend”]
repositories = { for name in local.repository_list : name => name }
}
然后,我们将使用for
表达式从这个列表中生成一个映射,接着使用for_each
迭代器来创建相应的 ECR 仓库:
resource “aws_ecr_repository” “main” {
for_each = local.repositories
name = “ecr-${var.application_name}-${var.environment_name}-${each.key}”
image_tag_mutability = “MUTABLE”
}
接下来,我们将设置一个 IAM 组,授予其推送容器镜像的权限:
resource “aws_iam_group” “ecr_image_pushers” {
name = “${var.application_name}-${var.environment_name}-ecr-image-pushers”
}
现在,我们需要生成一个 IAM 策略,该策略授予对每个 ECR 仓库的访问权限,并将其附加到我们之前创建的 IAM 组:
resource “aws_iam_group_policy” “ecr_image_pushers” {
for_each = local.repositories
name = “${var.application_name}-${var.environment_name}-${each.key}-ecr-image-push-policy”
group = aws_iam_group.ecr_image_pushers.name
policy = jsonencode({
Version = “2012-10-17”,
Statement = [
{
Effect = “Allow”,
Action = [
“ecr:GetDownloadUrlForLayer”,
“ecr:BatchGetImage”,
“ecr:BatchCheckLayerAvailability”,
“ecr:PutImage”,
“ecr:InitiateLayerUpload”,
“ecr:UploadLayerPart”,
“ecr:CompleteLayerUpload”
],
Resource = aws_ecr_repository.main[each.key].arn
}
]
})
}
最后,我们必须授予该组访问权限。我们将向我们团队中的开发人员身份或将作为 CI/CD 过程的一部分推送新镜像的 GitHub Actions 工作流授予访问权限:
resource “aws_iam_group_membership” “ecr_image_pushers” {
name = “${var.application_name}-${var.environment_name}-ecr-image-push-membership”
users = var.ecr_image_pushers
group = aws_iam_group.ecr_image_pushers.name
}
Kubernetes 集群
现在,我们的容器注册表已经设置完毕,且能够向其中推送镜像,我们需要设置我们的 Kubernetes 集群。AWS EKS 就是为此而来。集群的配置相对简单,但我们需要在 IAM 方面做一些工作,以使其正常运作。
在我们配置 EKS 集群之前,我们需要设置它将用来与 AWS 平台其余部分交互的 IAM 角色。这个角色不是我们的节点或 Kubernetes 部署将使用的角色,而是 EKS 将用于在所有 AWS 资源上执行对集群进行的配置更改的角色:
data “aws_iam_policy_document” “container_cluster_assume_role” {
statement {
effect = “Allow”
principals {
type = “Service”
identifiers = [“eks.amazonaws.com”]
}
actions = [“sts:AssumeRole”]
}
}
因此,EKS 服务将承担此角色。因此,assume
策略需要允许一个 Service
类型的主体,其标识符为 eks.amazonaws.com
:
resource “aws_iam_role” “container_cluster” {
name = “eks-${var.application_name}-${var.environment_name}-cluster-role”
assume_role_policy = data.aws_iam_policy_document.container_cluster_assume_role.json
}
通过这个角色,我们将使 EKS 能够在我们的 AWS 账户中配置和管理它所需的资源。因此,我们需要附加内置的 AmazonEKSClusterPolicy
和 AmazonEKSVPCResourceController
策略:
resource “aws_iam_role_policy_attachment” “eks_cluster_policy” {
policy_arn = “arn:aws:iam::aws:policy/AmazonEKSClusterPolicy”
role = aws_iam_role.container_cluster.name
}
上述代码是如何为其中一个策略执行此操作的示例。你可以为每个策略创建一个 aws_iam_role_policy_attachment
资源,或者使用迭代器遍历我们需要附加的策略集合。
现在这个 IAM 角色已经准备好,我们可以使用 aws_eks_cluster
资源来设置我们的集群:
resource “aws_eks_cluster” “main” {
name = local.cluster_name
role_arn = aws_iam_role.container_cluster.arn
vpc_config {
security_group_ids = [
aws_security_group.cluster.id,
aws_security_group.cluster_nodes.id
]
subnet_ids = local.cluster_subnet_ids
endpoint_public_access = true
endpoint_private_access = true
}
// Other configurations like logging, encryption, etc.
}
配置的大部分内容是在 vpc_config
块中完成的,它引用了我们在上一章中配置的许多相同的结构。
有一点你可能想要记住的是,IAM 策略对于成功配置 EKS 集群的重要性。由于 IAM 角色的策略附件之间没有直接关系,你应该确保在我们尝试配置 EKS 集群之前,先创建 IAM 角色权限。以下代码演示了如何使用 depends_on
属性,它允许我们显式地定义这种关系:
depends_on = [
aws_iam_role_policy_attachment.eks_cluster_policy,
aws_iam_role_policy_attachment.eks_vpc_controller_policy,
aws_cloudwatch_log_group.container_cluster
]
EKS 集群只是控制平面。为了使我们的集群具有实用性,我们需要添加工作节点。我们可以通过添加一个或多个节点组来实现。这些节点组将由一组 EC2 实例组成,这些实例将作为工作节点加入。这些节点也需要它们自己的 IAM 角色:
data “aws_iam_policy_document” “container_node_group” {
statement {
sid = “EKSNodeAssumeRole”
actions = [“sts:AssumeRole”]
principals {
type = “Service”
identifiers = [“ec2.amazonaws.com”]
}
}
}
一个关键区别在于,由于该角色将由工作节点承担,而工作节点是 EC2 实例,因此 IAM 角色的 assume
策略需要与此事实保持一致。
就像之前设置 EKS 集群时需要设置 IAM 角色作为前提条件一样,节点组也是如此。现在节点组的 IAM 角色已经准备好,我们可以使用以下代码来创建一个与先前定义的集群关联的 EKS 节点组。它指定了节点组的期望大小、最小大小和最大大小,以及其他配置,如 AMI 类型和磁盘大小:
resource “aws_eks_node_group” “main” {
cluster_name = aws_eks_cluster.main.name
node_group_name = “ng-user”
node_role_arn = aws_iam_role.container_node_group.arn
subnet_ids = local.cluster_subnet_ids
scaling_config {
desired_size = 3
min_size = 1
max_size = 4
}
ami_type = var.node_image_type
instance_types = [var.node_size]
}
同样,像 EKS 集群一样,IAM 角色的策略附件对于使节点组功能正常至关重要。因此,您需要确保在开始配置节点组之前,将所有策略附件附加到 IAM 角色上。正如我们在前一节中讨论的那样,有四个策略(AmazonEKSWorkerNodePolicy
、AmazonEKS_CNI_Policy
、AmazonEC2ContainerRegistryReadOnly
和 CloudWatchAgentServerPolicy
)对于 EKS 工作节点的操作至关重要:
depends_on = [
aws_iam_role_policy_attachment.eks_worker_node_policy,
aws_iam_role_policy_attachment.eks_cni_policy,
aws_iam_role_policy_attachment.eks_ecr_policy,
aws_iam_role_policy_attachment.eks_cloudwatch_policy
]
当您为 EKS 集群添加额外功能时,您可能会引入额外的 IAM 策略,授予集群及其工作节点在 AWS 中的不同权限。当您这样做时,别忘了在这些depends_on
属性中也包含这些策略,以确保操作顺利进行。
日志记录和监控
我们可以通过简单地将enabled_cluster_log_types
属性添加到aws_eks_cluster
资源中来启用集群的 CloudWatch 日志:
enabled_cluster_log_types = [“api”, “audit”]
这个属性可以接受一个或多个不同的日志类型。我建议查看文档以了解所有支持的不同选项。接下来,我们需要为集群配置一个 CloudWatch 日志组:
resource “aws_cloudwatch_log_group” “container_cluster” {
name = “/aws/eks/${local.cluster_name}/cluster”
retention_in_days = 7
}
这要求使用特定的命名约定,并且需要与您为集群使用的名称匹配。因此,最好将传递给aws_eks_cluster
资源的name
属性的值提取为本地变量,以便在两个地方使用。
工作负载身份
配置好集群后,我们需要从集群中获取 OIDC 发行者证书,以便将其用于配置 AWS IAM 的 OpenID Connect 提供商。以下代码使用来自tls
实用程序提供商的tls_certificate
数据源,正如我们在第三章中介绍的那样,获取关于证书的附加元数据:
data “tls_certificate” “container_cluster_oidc” {
url = aws_eks_cluster.main.identity[0].oidc[0].issuer
}
有了这些额外的元数据,我们可以使用aws_iam_openid_connect_provider
资源,通过引用sts.amazonaws.com
,将集群连接到 AWS IAM OIDC 提供商:
resource “aws_iam_openid_connect_provider” “container_cluster_oidc” {
client_id_list = [“sts.amazonaws.com”]
thumbprint_list = [data.tls_certificate.container_cluster_oidc.certificates[0].sha1_fingerprint]
url = data.tls_certificate.container_cluster_oidc.url
}
我们已经设置了几个 IAM 角色,包括一个用于 EKS 集群的角色和另一个用于集群工作节点的角色。因此,我不会重复创建工作负载身份的aws_iam_role
资源。然而,这个新角色需要有一个非常明确的假设策略。工作负载身份 IAM 角色需要引用 OIDC 提供商和一个尚未配置的 Kubernetes 服务账户:
data “aws_iam_policy_document” “workload_identity_assume_role_policy” {
statement {
actions = [“sts:AssumeRoleWithWebIdentity”]
effect = “Allow”
condition {
test = “StringEquals”
variable = “${replace(aws_iam_openid_connect_provider.container_cluster_oidc.url, “https://”, “”)}:sub”
values = [“system:serviceaccount:${var.k8s_namespace}:${var.k8s_service_account_name}”]
}
principals {
identifiers = [aws_iam_openid_connect_provider.container_cluster_oidc.arn]
type = “Federated”
}
}
}
如您所见,在前面的代码中,服务帐户遵循非常具体的命名约定:system:serviceaccount:<namespace>:<service-account-name>
。我们用 Kubernetes 命名空间的名称替换 <namespace>
,同样,用服务帐户的名称替换 <service-account-name>
。需要指出的是,我们正在引用尚未存在的资源。因此,在工作负载身份 IAM 角色的假设策略中对这些资源的引用是指向或占位符,指向这些尚未创建的资源。Kubernetes 命名空间和服务帐户都是需要在 Kubernetes 控制平面中创建的资源。我们将在下一节中使用 kubernetes
Terraform 提供者来处理这一问题。
秘密管理
现在我们已经为工作负载身份创建了 IAM 角色,我们只需要授予它访问我们希望使用的 AWS 资源的权限。因此,我们将再次使用 aws_iam_policy_document
数据源来生成一个 IAM 策略,并将其附加到工作负载身份的 IAM 角色上。在这里,我们有机会授予它对 AWS 中任何我们应用代码所需资源的访问权限。对于我们的解决方案,我们将首先授予它访问 AWS Secrets Manager 秘密的权限,允许它通过 secretsmanager:GetSecretValue
操作读取秘密:
data “aws_iam_policy_document” “workload_identity_policy” {
statement {
effect = “Allow”
actions = [
“secretsmanager:GetSecretValue”,
“secretsmanager:DescribeSecret”,
]
resources = [
“arn:aws:secretsmanager:${var.primary_region}:${data.aws_caller_identity.current.account_id}:secret:*”,
]
}
}
该策略将授予 IAM 角色对该帐户内秘密的访问权限。我们可以通过增强 *
通配符路径来进一步细化其访问权限,以确保它仅访问某些秘密。这可以通过实施一种命名约定来完成,使用唯一的前缀为您的秘密命名。application_name
和 environment_name
变量是实现此命名约定的完美方式,并能加强对 AWS Secrets Manager 中 Kubernetes 工作负载的访问控制。
现在,我们只需要使用正确的命名约定将秘密配置到 Secrets Manager 中:
resource “aws_secretsmanager_secret” “database_connection_string” {
name = “${var.application_name}-${var.environment_name}-connection-string”
description = “Database connection string”
}
AWS Secrets Manager 使用名为 aws_secretsmanager_secret
的父资源作为秘密本身的逻辑占位符,但认识到秘密的值可能随着时间的推移而发生变化:
resource “aws_secretsmanager_secret_version” “database_connection_string” {
secret_id = aws_secretsmanager_secret.database_connection_string.id
secret_string = random_password.database_connection_string.result
}
秘密的不同值存储在 aws_secretsmanager_secret_version
资源中。您可以使用 random
提供者生成复杂的秘密,但通常更常见的做法是从其他资源的输出中获取 secret_string
。
Kubernetes
在 第五章 中,我们介绍了使用 YAML 和 HashiCorp 配置语言(HCL)的 Kubernetes 架构和自动化技术。在本书中的解决方案中,我们将使用 Terraform 提供者来自动化应用程序的部署。这使我们能够将 Kubernetes 配置参数化,避免被硬编码到 YAML 文件中,并使用相同的部署过程配置 Kubernetes 原语和 Helm 图表的组合。
提供者设置
按讽刺意味来说,我们设置 kubernetes
提供程序的第一步是初始化 aws
提供程序,以便我们能够获取有关 EKS 集群的信息。我们可以通过使用提供的数据源和一个输入变量来完成此操作:集群名称。当然,AWS 区域也是此操作的一个隐含参数,但它是 aws
提供程序配置的一部分,而不是数据源的输入参数:
data “aws_eks_cluster” “cluster” {
name = var.eks_cluster_name
}
我们将使用 aws_eks_cluster
和 aws_eks_cluster_auth
数据源来获取初始化 kubernetes
提供程序所需的数据:
provider “kubernetes” {
host = data.aws_eks_cluster.cluster.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.cluster.certificate_authority[0].data)
token = data.aws_eks_cluster_auth.cluster.token
load_config_file = false
}
有趣的是,Helm 提供程序的设置几乎与 Kubernetes 提供程序配置完全相同。看起来有点冗余,但其实相对简单:
provider “helm” {
kubernetes {
host = data.aws_eks_cluster.main.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.main.certificate_authority[0].data)
token = data.aws_eks_cluster_auth.main.token
}
}
命名空间
创建 Kubernetes 命名空间非常简单:
resource “kubernetes_namespace” “main” {
metadata {
name = var.k8s_namespace
labels = {
name = var.k8s_namespace
}
}
}
它将充当我们为应用程序配置的所有 Kubernetes 资源的逻辑容器。
服务账户
在上一部分中,当我们在 AWS 内设置 OpenID Connect 提供程序配置,并提前指定 Kubernetes 命名空间和服务账户名称时,我们已经搭建了这座桥梁的一半。现在,我们将通过配置 kubernetes_service_account
并确保 namespace
和 name
与我们的 AWS 配置匹配,来完成这座桥梁的搭建:
resource “kubernetes_service_account” “workload_identity” {
metadata {
name = var.k8s_service_account_name
namespace = var.k8s_namespace
annotations = {
“eks.amazonaws.com/role-arn” = var.workload_identity_role
}
}
}
我们还需要添加一个注释,引用工作负载身份的 IAM 角色的唯一标识符(或 ARN)。我们可以将其设置为我们 Terraform 工作空间中的输出变量,该工作空间负责配置 AWS 基础设施,并将其值路由到 Kubernetes 配置的 Terraform 工作空间中的输入变量。这是一个很好的例子,展示了如何使用 Terraform 的 kubernetes
提供程序来配置与云平台紧密耦合的 Kubernetes 资源。
密钥存储 CSI 驱动程序
设置好服务账户后,我们的应用程序离能够访问 AWS Secrets Manager 中的密钥又近了一步。然而,在我们能做到这一点之前,我们需要设置密钥存储 CSI 驱动程序。如前所述,这是一个常见的 Kubernetes 组件,它提供了一种标准机制,通过使用卷挂载来分发远程管理的密钥给运行在 Kubernetes 中的工作负载。该驱动程序具有极高的灵活性,可以通过充当不同外部密钥管理系统适配器的提供程序进行扩展。
首先,我们需要安装密钥存储 CSI 驱动程序的 Helm 图表:
resource “helm_release” “csi_secrets_store” {
name = “csi-secrets-store”
repository = “https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts”
chart = “secrets-store-csi-driver”
namespace = “kube-system”
set {
name = “syncSecret.enabled”
value = “true”
}
}
我们可以选择通过使用 syncSecret.enabled
属性启用密钥同步,使得密钥可以从 Kubernetes 密钥中访问。这使得将密钥注入到我们应用程序的 Pod 中变得非常方便,无需自定义代码从挂载的卷中检索它们。
接下来,我们需要为 CSI 驱动程序安装 AWS 提供程序:
resource “helm_release” “aws_secrets_provider” {
name = “secrets-provider-aws”
repository = “https://aws.github.io/secrets-store-csi-driver-provider-aws”
chart = “secrets-store-csi-driver-provider-aws”
namespace = “kube-system”
}
这两个 Helm 图表会在kube-system
命名空间下为你的集群配置多个不同的 Kubernetes 资源。如果遇到错误,检查托管这些组件的 Pod 是调试配置的好地方。
秘密提供者类
一旦我们安装了 CSI 驱动程序及其 AWS 提供程序,就可以准备连接到 AWS Secrets Manager。到目前为止,我们只是启用了这个功能;我们还没有通过访问秘密来实际使用它。
这正是SecretProviderClass
资源的作用。它连接到 AWS Secrets Manager 中的一组特定秘密。你会注意到,这种类型的资源配置方式与 Kubernetes 中的其他资源不同。虽然其他资源类型有相应的 Terraform 资源,SecretProviderClass
使用的是kubernetes_manifest
资源。
这是因为该资源类型是通过 Kubernetes 自定义资源定义(CRD)进行管理的;它不是 Kubernetes 内置的类型:
resource “kubernetes_manifest” “secret_provider_class” {
manifest = {
apiVersion = “secrets-store.csi.x-k8s.io/v1”
kind = “SecretProviderClass”
metadata = {
name = “${var.application_name}-${var.environment_name}-secret-provider-class”
namespace = var.k8s_namespace
}
spec = {
provider = “aws”
parameters = {
objects = yamlencode([ ... ])
}
secretObjects = [ ... ]
}
}
}
SecretProviderClass
的结构分为两部分。首先,parameters
是我们声明希望导入的秘密的地方:
{
objectName = “fleet-portal-dev-connection-string”
objectType = “secretsmanager”
objectVersionLabel = “AWSCURRENT”
}
在这里,objectName
对应的是 Secrets Manager 秘密的相对名称或秘密的完全合格 ARN。接下来,objectType
表示应使用哪个 CSI 驱动程序提供程序来访问秘密,而objectVersionLabel
允许我们在 Secrets Manager 中选择秘密的特定版本。对于 AWS,若要访问最新版本(可能是最常见的用例),你需要指定AWSCURRENT
作为值。
接下来,有一个secretObjects
集合,用于定义相应的 Kubernetes 秘密对象:
{
data = [
{
key = “fleet-portal-dev-connection-string”
objectName = “fleet-portal-dev-connection-string”
}
]
secretName = “fleet-portal-dev-connection-string”
type = “Opaque”
}
这些secretObjects
稍后将在我们应用的部署规范中使用,用来为 Pod 中的每个秘密创建环境变量。
部署
Kubernetes 部署是我们必须在 Kubernetes 中配置的最重要的资源之一。因此,它可能会有些令人生畏,因为其中包含一些复杂的嵌套部分。部署中最重要的部分是容器规范。它为我们的 Pod 设置了实际的运行时环境。
最重要的信息是我们希望在 Pod 中使用的容器镜像。为了配置这个,我们需要构建指向存储在 ECR 中的容器镜像的完全合格路径。为此,我们需要两项信息。首先,我们需要 AWS 账户编号,其次,我们需要 ECR 存储库所在的 AWS 区域名称:
locals {
account_id = data.aws_caller_identity.current.account_id
container_registry = “${local.account_id}.dkr.ecr.${var.primary_region}.amazonaws.com/”
}
AWS 账户编号可以很容易地从aws_caller_identity
数据源中获取。这个数据源非常简单,提供关于 Terraform 使用aws
提供程序的 AWS 账户和 IAM 身份的上下文信息。因此,要创建这个数据源,你只需创建它而不带任何参数:
data “aws_caller_identity” “current” {}
这是访问 Terraform 提供程序认证上下文和云平台配置范围的常见模式 – 在本例中,我们正在为哪个 AWS 帐户和哪个区域进行配置。
这是将相同 YAML 代码版本转换为使用输入变量在实体上设置不同属性的 HCL 的版本:
resource “kubernetes_deployment” “web_app” {
metadata {
name = local.web_app_name
namespace = var.k8s_namespace
}
spec {
replicas = 3
selector {
match_labels = {
app = local.web_app_name
}
}
template {
metadata {
labels = {
app = local.web_app_name
}
}
spec {
service_account_name = kubernetes_service_account.workload_identity.metadata[0].name
container {
image = local.web_app_image_name
name = local.web_app_name
port {
container_port = 5000
}
env_from {
config_map_ref {
name = kubernetes_config_map.web_app.metadata.0.name
}
}
}
}
}
}
}
我们用于容器镜像名称的本地变量是我们在 ECR 中容器镜像的完全限定路径。它遵循 <account>.dkr.ecr.<region>.amazonaws.com/<repository>:<tag>
的结构。在这里,<account>
是 AWS 帐户号码,可以使用 aws_caller_identity
数据源访问。然后,<region>
是 AWS 区域,可以从输入变量中访问。最后,<repository>
是 ECR 仓库名称,<version>
是容器镜像的特定版本的标签。
我们可以通过引用此 Terraform 工作空间中提供的其他 Kubernetes 资源来设置 service_account_name
。这是使用 YAML 和 Terraform 的 kubernetes
提供程序之间的关键区别。如果我们使用 YAML,这将不得不硬编码,而在 HCL 中,我们可以引用 Terraform 工作空间中的其他资源。
要引用 AWS Secrets Manager 秘密,我们需要修改 container
块,以便包含另一个 env
块:
env {
name = “DB_CONNECTION_STRING”
value_from {
secret_key_ref {
name = “fleet-portal-dev-connection-string”
key = “fleet-portal-dev-connection-string”
}
}
}
这使我们能够引用我们在 SecretProviderClass
中声明的 secretObjects
对象之一,并为其提供一个环境变量名称,我们的应用程序代码可以引用以访问该密钥。
Service
Kubernetes 服务主要是一种网络路由机制。它定义了服务应向外部客户端公开的端口以及应将网络流量转发到 pod 上的哪个端口:
resource “kubernetes_service” “web_app” {
metadata {
name = “${local.web_app_name}-service”
namespace = var.k8s_namespace
}
spec {
type = “ClusterIP”
port {
port = 80
target_port = 5000
}
selector = {
app = local.web_app_name
}
}
}
在这里,selector
指定应将流量转发到哪些 pod,并且它应该与服务选择器的 app
标签设置为相同的值匹配。
ConfigMap
正如我们从 第五章 中所知道的,ConfigMap 资源是将非敏感配置设置传递给您的 pod 的绝佳方法:
resource “kubernetes_config_map” “web_app” {
metadata {
name = “${local.web_app_name}-config”
namespace = var.k8s_namespace
}
data = {
BackendEndpoint = “”
}
}
通常,提供基础设施的 Terraform 工作空间将输出需要包含在 Kubernetes ConfigMap 中的几个不同值(URI、AWS ARN、DNS 等)。
Ingress
Ingress 控制器是 Kubernetes 的一个组件,用于将外部网络流量路由到集群中。它与 Kubernetes Ingress 一起工作,后者定义了为特定服务路由流量的具体规则。这与 CSI 驱动程序和 SecretProviderClass
的结构非常相似。一个提供基础子系统,从而实现能力,而另一个使用该底层子系统实现特定配置。
最受欢迎的 ingress 控制器之一是名为 NGINX 的负载均衡器。我们可以使用 Helm chart 来设置 NGINX ingress 控制器。该 Helm chart 部署的组件正是我们需要额外的 IAM 策略来允许 EKS 集群配置 AWS ELB 资源的原因。因为 ingress 控制器和 ingress 资源的 Kubernetes 配置将由 EKS 解释,并以 AWS ELB 资源的配置和提供形式体现出来。这意味着,不需要显式地使用 aws
Terraform 提供程序来配置 ELB 资源,你将注解 Kubernetes 部署,必要的 ELB 资源会自动为你提供和配置。
我们需要做的第一件事是使用 Helm chart 安装 NGINX ingress 控制器:
resource “helm_release” “ingress” {
name = “ingress”
repository = “https://charts.bitnami.com/bitnami”
chart = “nginx-ingress-controller”
create_namespace = true
namespace = “ingress-nginx”
set {
name = “service.type”
value = “LoadBalancer”
}
set {
name = “service.annotations”
value = “service.beta.kubernetes.io/aws-load-balancer-type: nlb”
}
}
这将安装 NGINX,并在我们指定的命名空间下部署一个 Kubernetes 服务。下一步是为我们的应用配置 ingress:
resource “kubernetes_ingress_v1” “ingress” {
metadata {
name = “${local.web_app_name}-ingress”
namespace = var.k8s_namespace
annotations = {
“kubernetes.io/ingress.class” = “nginx”
}
}
spec {
rule {
http { ... }
}
}
}
ingress 资源相对简单。你需要设置命名空间并指定要使用的 ingress 控制器。然后,你需要指定路径,以便将网络流量路由到正确的 Kubernetes 服务:
path {
path = “/”
path_type = “Prefix”
backend {
service {
name = kubernetes_service.web_app.metadata[0].name
port {
number = 80
}
}
}
}
对于前端和后端应用部署以及 ingress 控制器,建立明确的 depends_on
语句也非常重要,因为我们在 HCL 配置中并未直接引用它们:
depends_on = [
kubernetes_service.web_app,
kubernetes_service.web_api,
helm_release.ingress
]
现在,我们已经构建了架构的三个组件,在下一节中,我们将探讨如何使用 Docker 来构建和发布容器镜像,并通过 Terraform 来配置我们的基础设施,将解决方案部署到 Kubernetes 上,从而实现自动化部署。
部署自动化
在本节中,我们将把重点从构建应用和环境转移到实现部署自动化,以便高效地将解决方案提供给 AWS。基于容器的架构涉及三项核心部署操作。首先,我们必须创建并发布容器镜像到容器注册表。接着,我们需要为容器托管创建并配置 Kubernetes 集群环境。最后,我们需要部署 Kubernetes 资源,以便在 Kubernetes Pods 中创建容器并引用我们发布的容器镜像。
Docker
就像我们在上一章中用 Packer 构建的虚拟机镜像一样,容器镜像作为一个不可变的工件,包含了应用代码和操作系统配置的版本化副本。每当应用代码或操作系统配置发生变化时,我们需要更新这个工件:
on:
push:
branches:
- main
paths:
- ‘src/dotnet/frontend/**’
就像在 Packer 中一样,我们需要每次在 Dockerfile 中配置应用代码和操作系统时触发新的容器镜像构建。通过 GitHub Actions,我们可以添加一份 paths
列表来触发我们的工作流:
图 8.13 – 虚拟机镜像版本控制
现在我们已经设置了触发器和一些变量,接下来我们需要构建 jobs
。对于每个 Packer 模板,我们将有两个作业:一个构建 C# .NET 应用代码并生成部署包,另一个运行 packer build
生成虚拟机镜像:
jobs:
build:
runs-on: ubuntu-latest
steps:
...
packer:
runs-on: ubuntu-latest
steps:
...
build
作业执行一个相当标准的 .NET 构建过程,包括从 NuGet(.NET 包管理器)恢复包依赖项、构建代码、运行单元测试和集成测试、发布可部署的构件,并存储该构件,以便未来的作业可以在流水线中使用:
图 8.14 – Docker 工作流
docker
作业立即运行 Terraform 以获取我们要目标的 ECR 容器仓库的输出。我们不必在这里运行 Terraform,但我们可以明确指定 ECR 仓库的完全限定路径。
然后,它生成一个唯一的容器镜像名称版本,如果成功生成的话。我们将根据当前日期和 GitHub Action 的运行号来生成这个镜像版本。这将确保镜像版本唯一,以便我们不必手动设置或担心在推送到仓库时发生冲突:
- id: image-version
name: Generate Version Number
run: |
echo “version=$(date +’%Y.%m’).${{ github.run_number }}” >> “$GITHUB_OUTPUT”
接下来,我们需要设置 Docker:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
现在,我们必须使用官方的 AWS GitHub Action 配置 AWS 凭证。我们将使用 GitHub 环境设置指定的 AWS 访问密钥和密钥访问密钥:
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ vars.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.PRIMARY_REGION }}
配置好凭证后,我们可以使用 amazon-ecr-login
Action 来连接到 ECR:
- name: Log in to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
最后,我们将使用官方的 Docker GitHub Action 来构建并推送镜像。需要注意的是,这个 Action 并不特定于 AWS。它使用标准的容器注册表协议,通过我们在 tags
参数中指定的 ECR 仓库的完全限定路径与 ECR 进行通信:
- name: Build and push Docker image to ACR
uses: docker/build-push-action@v5
with:
context: ${{ env.DOCKER_WORKING_DIRECTORY }}
push: true
tags: ${{ steps.terraform.outputs.registry_endpoint }}:${{ steps.image-version.outputs.version }}
我们的两个应用组件(前端和后端)将各自有一个仓库,因此注册表终端会根据我们推送的容器镜像不同而有所不同。
Terraform
在 第七章 中,我们已经全面介绍了创建与 AWS 认证的 Terraform GitHub Action 的过程。因此,我们不会进一步深入讨论。建议你回顾 第七章 来复习该过程。
Kubernetes
当我们使用 Terraform 自动化 Kubernetes 时,我们只需再次运行 terraform apply
,并使用不同的根模块。这次,根模块将配置 kubernetes
和 helm
提供程序,以及 aws
提供程序。然而,我们不会使用 aws
提供程序创建新资源;我们只会从我们在之前的 terraform apply
命令中配置的现有资源中获取数据源,该命令将基础设施部署到 AWS。
结果是,执行此过程的 GitHub Action 将与我们在 AWS 中执行 Terraform 的方式非常相似。某些变量可能会发生变化,加入像容器镜像详情和集群信息等内容。
摘要
在本章中,我们设计、构建并自动化了一个完整的端到端解决方案,采用了基于容器的架构。我们在第七章的基础上进行了扩展,在第七章中,我们与 AWS VPC 的基础设施打交道,但这次我们增加了 AWS EKS 来托管我们的容器化应用程序。在 AWS 之旅的下一步,也是最后一步,我们将探索无服务器架构,超越基础设施本身,让平台本身带领我们的解决方案迈向新的高度。
第九章:9
使用 AWS 实现无服务器 – 利用 AWS Lambda 构建解决方案
在本章中,我们将翻开三部分系列的最后一章,探讨 Amazon Web Services(AWS)的无服务器解决方案。此前,我们在 第七章 使用 虚拟机(VMs)在 AWS 上构建了解决方案,又在 第八章 使用容器进行过构建,现在我们将探索在 AWS 上构建真正的无服务器解决方案的样子。
尽管前几章的基础概念和实践对我们有所帮助,但解决方案的某些方面在这里完全没有涉及。具体来说,我们不需要担心任何操作系统的配置,无论是在 Packer 还是 Docker 中。
我们现在的重点转向调整应用代码以适应 Lambda 的应用模型。虽然这需要对应用代码进行修改,以与 Lambda 的方法保持一致,但也为我们提供了在不再需要管理服务器的情况下提升可扩展性和效率的机会。焦点的转变意味着更简化和高效的流程。我们将花更多的时间来调整应用代码以符合 Lambda 模式,而不是使用 Terraform 配置新服务。
本章涉及以下主题:
-
奠定基础
-
设计解决方案
-
构建解决方案
-
自动化部署
奠定基础
我们的故事通过 Söze Enterprises 公司的视角继续展开,这家公司由神秘的土耳其亿万富翁 Keyser Söze 创办。我们的团队一直在努力构建下一代自动驾驶车辆编排平台。最初,我们的策略是尽量减少变动,让团队专注于将功能快速推进到产品中。然而,我们那位难以捉摸的 CEO 则有着不同的想法,他推动我们采用容器技术,以使我们的产品在未来变得更加灵活和可扩展。与 Keyser 一起工作,永远不会无聊,但如此迅速地管理如此激进的变革也令人感到沮丧。
与此同时,在瑞士达沃斯,世界经济论坛正在如火如荼地进行着,Keyser 在意大利浓缩咖啡吧与 Werner Vogels 偶遇,并立即与他聊得非常投机。当 Werner 看到 Keyser 对自动驾驶平台的宏大愿景时,他随意地建议 Keyser 不必再为基础设施问题操心,利用 AWS 的无服务器产品可以将他从基础设施管理的枷锁中解脱出来,让他专注于实现他的宏大愿景。
多亏了 Werner 的洞察力和 Keyser 独特的决策方式,我们的团队更深入地进入了 AWS,明确地从 Amazon Elastic Kubernetes Service(EKS)过渡到 AWS Lambda,实现无服务器计算。这可能需要对我们的应用架构进行彻底重新思考,但它也可能将我们从管理底层基础设施的巨大操作负担中解脱出来。
设计解决方案
在本节中,我们将考虑我们的解决方案的整体设计,鉴于从基于虚拟机和容器的架构转向无服务器架构的转变。无服务器架构的核心目标是消除堆栈中沉重的基础设施。因此,我们将寻找方法,去除任何需要高额固定成本的 AWS 服务,如 EC2 实例或 EKS 集群,并用无服务器选项替代。这种在操作环境和技术领域的变化将要求我们重新思考解决方案的设计、实现和部署策略:
图 9.1 – 自动驾驶平台的逻辑架构
我们应用程序的架构并不会发生显著变化,但我们将使用不同的 Azure 服务来托管它。在这种情况下,我们将使用 Azure 存储来托管应用程序的前端,使用 Azure Functions 来托管应用程序的后端:
图 9.2 – 我们仓库的源代码控制结构
在这个解决方案中,我们将有四部分代码库。前两部分是用于配置环境的 Terraform 代码和执行部署过程的 GitHub Actions 代码。然后是我们应用程序前端和后端的两个代码库。
云架构
在 第七章 中,我们的云托管解决方案是由一组专用的 EC2 实例组成。在 第八章 中,它是由我们的 Kubernetes 集群的节点池管理的一组共享 EC2 实例。无论是独立的虚拟机,还是作为 Kubernetes 节点池一部分的虚拟机,使用虚拟机的沉没成本是最高的。
在 第八章 中,我们的整个解决方案是通过容器执行的,前端和后端作为同一虚拟机上的一组容器共存。这节省了一些费用,但我们仍然需要服务器来托管工作负载。在本章中,我们有一个新的目标:通过利用云原生服务的力量,抽象掉底层基础设施,从而只为我们使用的部分付费。AWS 的无服务器服务将在这一过程中对我们至关重要。
前端
在之前的章节中,我们将前端托管在面向公众的服务器上,这些服务器返回构成我们 Web 应用程序的 HTML 和 JavaScript。然而,在两种解决方案中,我们仍然需要一个云托管的解决方案来托管这些文件并响应请求。
然而,由于 Web 应用程序运行在最终用户的浏览器中,我们不需要使用云托管的虚拟机来托管本质上是静态文件的内容。我们可以使用简单的云存储来托管前端作为静态网站,并依赖云平台承担返回 Web 内容的负载。
在 AWS 上,我们可以使用 简单存储服务 (S3)。该服务允许我们托管可通过互联网访问的静态网页内容。S3 处理所有的负载均衡、SSL 终止,并且根据需求波动进行自动扩展:
图 9.3 – S3 处理网页请求,Lambda 处理 REST API 请求
为了实现这一点,我们需要一个 S3 存储桶,并且需要启用对其内容的公共互联网访问。这将需要 S3 和 IAM 配置的组合。所有 S3 存储桶都有一个可通过互联网访问的公共域名。当我们启用 S3 的静态网站功能时,互联网流量将被路由到托管在存储桶中的内容。
这样做将带来巨大的优势,因为 S3 没有沉没成本。创建一个 S3 存储桶每月完全不收取任何费用。像其他无服务器服务一样,它采用微交易来衡量你的活动,并按实际使用量收费。在 S3 中,这可能有些复杂,因为多个度量标准会产生费用:
度量 | 单位 | 规模 | 价格 |
---|---|---|---|
存储 | GB | 1,000 | $0.023 |
读取交易 | 交易次数 | 10,000 | $0.0004 |
写入交易 | 交易次数 | 10,000 | $0.005 |
其他操作 | 交易次数 | 10,000 | $0.01 |
表 9.1 – AWS S3 的微交易定价
上表展示了使用 AWS 托管静态网站时会遇到的所有费用。所列价格适用于写作时的 AWS 美国西部(俄勒冈)区域。在你阅读此内容时,价格可能已发生变化,因此最好检查最新价格以获得最准确的费用估算。
我列出这些价格是为了说明一个问题。我们可以在一个三节点的 Kubernetes 集群上以大约每月 $300 的费用托管一个静态网站,或者在 AWS S3 上托管每月不到 $0.01。你会选择哪种方式?
后端
与我们的前端相似,在之前的章节中,我们的后端也以两种不同的方式托管在虚拟机上:专用虚拟机和 Kubernetes 集群中的节点池共享虚拟机。
与前端不同,我们的后端无法完全在最终用户的 Web 浏览器中运行客户端代码。在后端,我们有需要在服务器上运行的自定义代码。因此,我们需要找到一种托管这些组件的解决方案,而不必承受大量虚拟机带来的开销。
我们可以在 AWS 上使用 Lambda 函数来完成此任务。AWS Lambda 是一项托管服务,允许你部署代码而无需为底层虚拟机支付沉没成本。与 S3 类似,它采用微交易定价模型,按实际使用量收费:
度量 | 单位 | 规模 | 价格($) |
---|---|---|---|
执行时间 | GB/s | 1 | $0.0000166667 |
总执行次数 | 交易次数 | 1,000,000 | $0.020 |
表 9.2 – AWS Lambda 的微交易定价
上表显示了将代码部署到 Lambda 函数所产生的费用。你可能会注意到,和 S3 类似,这些价格非常低,而且只衡量平台上极少量的活动。
例如,执行时间度量的单位是 GB/s,表示 Lambda 函数每秒使用的内存量(单位为 GB)。鉴于它是按秒进行度量的,你不需要长时间运行 Lambda 函数就能产生相当多的费用。执行时间费用可以根据你分配的内存量进行调整。你可以选择分配从 128 MB 到 10 GB 之间的任意内存。
尽管过程简单,总执行次数度量受 AWS Lambda 内置限制的影响,包括执行时间限制。例如,每次执行的时间限制为 15 分钟。假设你正在尝试响应来自 Web 应用程序的请求,在这种情况下,你可能不希望将 Lambda 函数设计成运行 15 分钟,因为这对于 Web 浏览器的最终用户来说体验非常差。在这种情况下,你会希望 Lambda 函数在几秒钟内返回结果。然而,Lambda 函数可以用于许多不同的任务,除了响应来自浏览器的 HTTP 请求。在这些情况下,你必须小心设计 Lambda 解决方案,以确保不会超过此执行时间限制。这可能需要你考虑如何将工作分解,以便它能够通过成百上千个 Lambda 函数实例进行更并行的处理:
图 9.4 – 使用 Lambda 的后端架构
之前,我们的 ASP.NET REST API 是通过传统的 ASP.NET 项目设置的,该项目使用控制器来实现 REST API 端点。然而,在过渡到 Lambda 函数时,我们预计代码结构会有很大的不同。为了将我们的 REST API 托管为 Lambda 函数,我们需要遵循 Lambda 所要求的框架。因此,ASP.NET 控制器类必须进行重构,以符合这一标准。在下一节中,我们将深入探讨使这一切成为可能的代码。
部署架构
现在我们对 AWS 上解决方案的云架构有了清晰的了解,我们需要制定一个计划来配置我们的环境并部署代码。
在第七章中,当我们将应用程序部署到 VM 时,我们使用 Packer 将编译的应用程序代码嵌入到 VM 映像中。同样,在第八章中,当我们将应用程序部署到我们的 Kubernetes 集群上的容器时,我们使用 Docker 将应用程序代码嵌入到容器映像中。使用无服务器计算时,这完全改变了,因为 AWS 的无服务器提供完全抽象化的操作系统。这意味着我们唯一需要负责的是生成兼容的部署包。
创建部署包
正如我们在上一节中讨论的那样,我们的应用程序有两个组件:前端和后端。每个组件有不同的部署目标。对于前端,我们将作为静态网站部署到 AWS S3,而后端将作为 AWS Lambda 函数部署。由于两者都是.NET 项目,我们将使用.NET 和 AWS 平台特定的工具来创建部署包并将其部署到目标 AWS 服务。下图显示了我们将执行的环境配置、应用程序代码打包和部署到 AWS 目标环境的流程:
图 9.5 – 用于构建我们的.NET 应用程序代码以部署到 AWS 的资源部署流水线
对于前端,这意味着启用将我们的 ASP.NET Blazor Web 应用程序部署为 Web 程序集的功能。这将允许前端作为完全客户端运行的静态网站进行托管,而无需服务器端渲染。这仅有可能是因为我们设计的前端 Web 应用程序的方式,它使用 HTML、CSS 和 JavaScript 与服务器端 REST API 进行交互。值得注意的是,ASP.NET Blazor 支持两种托管选项。但我们选择了仅客户端的路径,并消除了对服务器端页面渲染的任何依赖。因此,当我们使用.NET CLI 发布我们的 ASP.NET Blazor 项目时,它将生成一个包含静态 Web 内容的文件夹。然后,使用 AWS CLI,我们可以将此文件夹的内容上传到我们的 S3 存储桶以完成部署。
使用.NET CLI,我们将发布我们的后端项目,这会生成 AWS Lambda 服务所需的所有文件,以便识别和执行我们的 Lambda 函数。
完成此操作后,我们必须将此文件夹压缩为 ZIP 存档。最后,我们可以使用 AWS CLI 将此 ZIP 存档部署到我们的 Lambda 函数。
现在,我们已经制定了关于如何实现使用 AWS 的云架构和使用 GitHub Actions 的部署架构的坚实计划,让我们开始构建吧!在接下来的章节中,我们将分解使用的 HashiCorp 配置语言代码来实现 Terraform,并修改应用程序代码以符合 AWS Lambda 的框架。
构建解决方案
现在我们有了一个稳固的解决方案设计,可以开始构建它。正如前一部分所讨论的,由于我们将使用 AWS 的无服务器服务,如 AWS S3 和 Lambda 函数来托管我们的应用程序,我们需要对应用程序代码做一些更改。在第七章和第八章中我们从未需要做这件事,因为我们能够通过将应用程序打包为虚拟机镜像(使用 Packer)或容器镜像(使用 Docker)来将应用程序部署到云端。因此,我们需要编写一些 Terraform 代码,并更新我们在 C#中的应用程序代码来构建我们的解决方案。
Terraform
正如我们在设计中所讨论的,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都有自己需要部署的应用程序代码。在前几章中,我们也有操作系统配置。现在,由于我们使用的是无服务器服务,这不再是我们的责任,因为平台会为我们处理这些。
Terraform 的设置与我们在前几章中做的非常相似,因此我们将只关注为我们的解决方案所需的新资源。如果你想使用完整的解决方案,可以查看本书的完整源代码,代码在 GitHub 上可用。
前端
首先,我们需要创建一个 AWS S3 桶来部署我们的前端。S3 桶是最常见的 Terraform 资源之一,因为许多其他 AWS 服务都使用 S3 桶来执行不同的任务:
resource "aws_s3_bucket" "frontend" {
bucket = "${var.application_name}-${var.environment_name}-frontend"
tags = {
Name = "${var.application_name}-${var.environment_name}-frontend"
application = var.application_name
environment = var.environment_name
}
}
然而,我们需要通过使用一些额外的资源来配置我们的 S3 桶。首先,我们需要使用aws_s3_bucket_public_access_block
资源来配置公共访问权限。然后,我们需要使用aws_s3_bucket_website_configuration
资源来配置我们的静态网站:
resource "aws_s3_bucket_public_access_block" "frontend" {
bucket = aws_s3_bucket.frontend.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
配置相当简单,但对于使我们的 S3 桶可以通过互联网访问至关重要。通过在这里更改配置,我们还可以选择托管不对互联网开放的静态网站。这对于我们只希望在私人网络上访问的内部网站来说可能是理想的:
resource "aws_s3_bucket_website_configuration" "frontend" {
bucket = aws_s3_bucket.frontend.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
这会配置 S3 桶,以指定当它将 Web 流量重定向到存储在桶中的内容时的默认网页。index.html
页面与我们的 ASP.NET Blazor Web 应用程序默认使用的页面一致。
最后,我们需要配置aws
提供程序,使用数据源资源来生成 IAM 策略文档,然后可以将其附加到其他已创建的资源:
data "aws_iam_policy_document" "frontend" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.frontend.arn}/*"]
principals {
type = "*"
identifiers = ["*"]
}
}
}
上述数据源会生成正确的策略文档,我们可以在使用aws_s3_bucket_policy
资源配置 S3 桶的策略时使用它:
resource "aws_s3_bucket_policy" "frontend" {
bucket = aws_s3_bucket.frontend.id
policy = data.aws_iam_policy_document.frontend.json
depends_on = [aws_s3_bucket_public_access_block.frontend]
}
后端
Lambda 函数被部署到一个 aws_lambda_function
资源中,但首先需要设置的最重要的事情是你将为 Lambda 函数使用的 IAM 角色。这将是我们允许 Lambda 函数访问 AWS 上其他资源(如密钥和日志)的方式。它也是我们允许 Lambda 函数与数据库以及应用程序代码需要通信的其他服务进行通信的方式:
data "aws_iam_policy_document" "lambda" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
我们将从一个 sts:AssumeRole
权限的 IAM 策略文档开始,并将其作用域限定为 Lambda 函数。然后,我们定义 IAM 角色并将其用作 assume_role_policy
:
resource "aws_iam_role" "lambda" {
name = "${var.application_name}-${var.environment_name}-lambda"
assume_role_policy = data.aws_iam_policy_document.lambda.json
}
我们可以通过定义额外的策略并将其附加到这个 IAM 角色来授予更多权限;稍后会详细介绍。现在,是时候配置我们的 Lambda 函数了:
resource "aws_lambda_function" "main" {
function_name = "${var.application_name}-${var.environment_name}"
role = aws_iam_role.lambda.arn
runtime = "dotnet6"
filename = "deployment.zip"
handler = "FleetAPI::FleetAPI.Function::FunctionHandler"
tags = {
Name = "${var.application_name}-${var.environment_name}-lambda"
application = var.application_name
environment = var.environment_name
}
}
和前两章一样,我们必须始终为我们的 AWS 资源打上 application
和 environment
标签。这些标签将我们的部署组织成一个 AWS 资源组,便于集中管理。
这里的一个关键属性是 runtime
,在我们的案例中是 .NET 6。根据你的技术栈,当然会有所不同。然而,也许最重要的属性是 handler
。这是设置起来最棘手的,因为它需要与我们的应用程序代码严格对齐。handler
是应用程序代码中某个组件的路径。在 .NET 中,这个路径由三部分组成:命名空间、完全限定的类名和方法名。
我们还可以使用一个可选的嵌套块来设置额外的环境变量,以帮助配置 Lambda 函数:
environment {
variables = {
SECRET_SAUCE = random_string.secret_sauce.result
}
}
这可以是传递配置给 Lambda 的一种有用方式,这些配置是由其他 Terraform 资源输出的。
日志记录
正如我们所见,AWS 使用 IAM 策略来授予访问平台上其他基础服务的权限。这对于日志记录等操作也是必要的:
resource "aws_iam_policy" "lambda_logging" {
name = "${var.application_name}-${var.environment_name}-lambda-logging-policy"
description = "Allow Lambda to log to CloudWatch"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
Effect = "Allow"
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
在前面的代码中,我们正在创建一个允许 Lambda 函数写入 CloudWatch 的策略。
最后,我们必须将这个策略附加到我们为 Lambda 函数创建的 IAM 角色上:
resource "aws_iam_role_policy_attachment" "lambda_logging" {
role = aws_iam_role.lambda.name
policy_arn = aws_iam_policy.lambda_logging.arn
}
它的样子是这样的:
图 9.6 – IAM 策略以授予对 CloudWatch 日志的访问权限
这将允许我们使用 CloudWatch 查看每次 Lambda 函数执行时应用程序代码中发生了什么,这对于故障排除和调试至关重要。
秘密管理
我们看到可以为 Lambda 函数设置环境变量。然而,如果我们想要更好地控制我们的秘密信息,可能需要使用 AWS Secrets Manager 来管理它们,然后配置 Lambda 函数从那里访问它们。
例如,我们将使用来自random
工具提供商的random_password
资源来设置密码,该提供商我们在第三章中已经讨论过。有时,AWS 服务会为你生成密钥,有时它们允许你指定自己的密钥。在这种情况下,random_password
资源就非常有用:
resource "random_password" "secret_sauce" {
length = 8
lower = false
special = false
}
上面的代码声明了我们将用作密钥的密码。接下来,我们需要创建一个 Secrets Manager secret
来存储这个密钥:
resource "aws_secretsmanager_secret" "secret_sauce" {
name = "secret-sauce"
tags = {
application = var.application_name
environment = var.environment_name
}
}
上面的代码生成了密钥,但你必须将密钥值存储在aws_secretsmanager_secret_version
子资源中:
resource "aws_secretsmanager_secret_version" "secret_sauce" {
secret_id = aws_secretsmanager_secret.secret_sauce.id
secret_string = random_string.secret_sauce.result
}
你还可以启用其他功能,以处理自动旋转和自定义加密,值得考虑。
现在我们的密钥已经在 Secrets Manager 中创建并存储,我们必须创建一个 IAM 策略来授予 Lambda 函数访问权限:
resource "aws_iam_policy" "lambda_secrets" {
name = "${var.application_name}-${var.environment_name}-secrets-policy"
description = "Policy to allow Lambda function to access secrets."
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Action = ["secretsmanager:GetSecretValue"],
Effect = "Allow",
Resource = [
aws_secretsmanager_secret.secret_sauce.arn
]
}
]
})
}
我们将使用aws_iam_role_policy_attachment
将策略附加到 Lambda 函数的 IAM 角色,就像我们为 CloudWatch 日志权限所做的那样。如果你需要使用额外的密钥,可以继续将它们添加到secret_sauce
已经添加的资源数组中。
图 9.7 – 资源 IAM 策略以授予访问 Secrets Manager 密钥的权限
如你所见,Lambda 函数的部署要简单得多。我们不需要虚拟网络或我们在前几章中配置的其他周边资源就能启动。对于大多数应用程序来说,Lambda 函数和 Secrets Manager 的内建安全性已经足够。如果我们希望启用私有网络,因为我们的应用程序需要遵循某些合规要求,我们也可以这么做。然而,这是可选的。
应用程序代码
AWS Lambda 本质上是事件驱动的。每个 Lambda 函数都由不同类型的事件触发。AWS Lambda 服务提供了许多不同的事件类型,可以从各种 AWS 服务触发 Lambda 函数。这使得设计可以响应 AWS 环境中各种活动的 Lambda 函数变得容易。为了本书的目的,我们将只关注应用程序负载均衡器。如果你对这个话题感兴趣,我建议你查看 AWS Lambda 的其他选项——它们非常丰富。
图 9.8 – 资源 ASP.NET MVC 控制器类结构
在传统的 ASP.NET REST API 解决方案中,你会有控制器类,表示特定的路由,并实现该路由下的不同操作。控制器类必须使用ApiController
特性进行装饰,告知 ASP.NET 运行时该类应当用于处理指定在Route
特性中的路由上的传入 Web 请求。
每个方法都用一个装饰器标记,指示该方法应响应的 HTTP 动词。在前面的例子中,我们使用了 HttpGet
,但对于每个支持的 HTTP 动词,都有相应的装饰器。该方法可以接收强类型参数,这些参数可以是路由、查询字符串或请求体的一部分。该方法默认返回一个 IActionResult
,允许我们根据请求的结果返回不同的数据结构。
要使用 Lambda 函数实现 REST API,我们需要使用 SDK Lambda 函数实现一个类。这要求我们稍微调整类和方法的实现方式。我们将采用不同的类和方法属性来实现类似的目标:定义一个在特定路由上响应 Web 请求的端点。
Lambda 函数类没有使用任何装饰器。一个方法应该接收一个请求对象和一个 ILambdaContext
对象。该方法还应该返回一个对应的响应对象。根据你设计 Lambda 函数响应的事件类型,你将需要为请求和响应对象使用不同的类。AWS 已发布了一些库,封装了这些不同类型的常见结构,使其更容易构建:
图 9.9 – 资源 AWS Lambda 函数类结构
本书中,我们使用的是应用程序负载均衡器(Application Load Balancer);因此,我们使用了 Amazon.Lambda.ApplicationLoadBalancerEvents
库来提供请求和响应对象的标准实现。如你所见,我们接收一个 ApplicationLoadBalancerRequest
并返回一个 ApplicationLoadBalancerResponse
。
如果我们想实现一个更复杂的 Lambda 函数,支持不同的功能或操作,我们可以围绕 ApplicationLoadBalancerRequest
对象的 Path
和 HttpMethod
属性实现路由逻辑。这些属性对应于 ASP.NET 框架中装饰每个控制器类及其方法的路由和 HTTP 动词属性。
如我们所见,云架构大大简化了系统。然而,一个权衡是我们的后端代码需要适配 AWS Lambda 框架。这将需要开发和测试工作,将我们的代码库转化为这种新的托管模型。这与我们在前几章中探索的内容形成鲜明对比,在那些章节中,我们将应用托管在虚拟机或容器中,并且部署在 Kubernetes 集群上。虽然符合 AWS Lambda 应用模型确实需要一些工作,但它的好处是双重的。首先,它让我们可以利用几乎为零的沉没成本。其次,它完全将底层基础设施抽象给我们,让 AWS 平台负责可扩展性和高可用性。这使得我们可以更多地专注于解决方案的功能,而不是维持系统正常运行所需的复杂设置。
现在我们已经实现了 Terraform 来配置我们的解决方案,并对应用程序代码进行了更改,使其符合 AWS Lambda 框架,接下来我们将深入学习 YAML 和 Bash,并实现 GitHub Actions 工作流。
自动化部署
如前一节所讨论的,像 AWS Lambda 和 S3 这样的无服务器服务抽象了操作系统配置。因此,当我们部署时,我们只需要一个与目标平台兼容的应用程序包。在这一节中,我们将使用 GitHub Actions 创建一个自动化管道,将我们的应用程序部署到 AWS 的新的无服务器环境中。
Terraform
我们需要做的第一件事是将我们的环境部署到 AWS。这将与我们在前几章中做的非常相似。在第七章中,我们需要确保在执行 Terraform 之前我们的虚拟机镜像已经构建并可用,因为 Terraform 代码库在配置虚拟机时引用了这些镜像。对于我们的虚拟机架构,应用程序的部署发生在 Terraform 配置环境之前:
图 9.10 – Packer 生成的虚拟机镜像是 Terraform 的前提
在第八章中,我们在没有这样的前提条件下,通过 AWS EKS 配置了我们的 Kubernetes 集群。事实上,应用程序的部署发生在 Kubernetes 集群上线之后。这意味着,在基于容器的架构下,应用程序的部署是在 Terraform 配置环境之后发生的:
图 9.11 – Docker 生成的容器镜像在 Terraform 执行后被部署到 Kubernetes
使用 AWS 的无服务器服务时,部署过程与我们将应用程序作为容器部署到 Kubernetes 时所看到的过程类似。就像这种方法一样,我们需要为 AWS 的无服务器服务构建一个部署工件。对于前端,这意味着只需生成静态 Web 内容。对于后端,这意味着生成一个 Lambda 函数的 ZIP 存档。这些工件与 Docker 镜像的作用类似,它们都是一种兼容目标服务的应用程序打包方式:
图 9.12 – .NET CLI 生成的部署工件在 Terraform 执行后被部署到 AWS
正如你所看到的,无服务器部署与使用基于容器的架构时的方法非常相似。这是因为 AWS 在无服务器方法中承担了 Kubernetes 的角色。AWS 只不过有定制化的工具来帮助应用程序的部署。
部署
既然 Terraform 已经为我们的无服务器解决方案提供了所需的 AWS 基础设施,我们需要采取最后一步,将部署工件部署到 AWS 中的适当位置。
我们将使用 .NET 和 AWS 自定义工具来生成并部署工件到这些目标位置。
前端
正如我们在其他章节中看到的,我们的 .NET 应用程序代码需要遵循持续集成流程,即使用自动化单元测试和其他内置质量控制构建和测试代码。唯一不同的是,我们需要为这些流程生成的部署工件添加一些特殊处理,以确保它能够被我们的 GitHub Action 作业获取,进而部署到适当的位置。
dotnet publish
命令输出 .NET 应用程序代码的部署工件。对于 ASP.NET Blazor Web 应用程序,这个输出是一个文件夹容器:其中包含 HTML、JavaScript 和 CSS 的一系列松散文件。为了高效地将这些文件从一个 GitHub Actions 作业传递到另一个作业,我们需要将它们打包成一个单一的文件:
- name: Generate the Deployment Package
run: |
zip -r ../deployment.zip ./
working-directory: ${{ env.DOTNET_WORKING_DIRECTORY }}/publish
既然静态网页内容已被打包成 ZIP 存档,我们将使用 upload-artifact
GitHub Action 将此文件保存到 GitHub Actions 中。这将使该文件可以供未来在管道中执行的作业使用:
- name: Upload Deployment Package
uses: actions/upload-artifact@v2
with:
name: dotnet-deployment
path: ${{ env.DOTNET_WORKING_DIRECTORY }}/deployment.zip
未来的作业可以简单地使用相应的 download-artifact
GitHub Action 和上传时使用的相同名称来下载工件:
- uses: actions/download-artifact@v3
with:
name: dotnet-deployment
由于 ASP.NET Blazor Web 应用程序将作为静态网页内容托管在我们的 AWS S3 存储桶中,我们需要在上传内容之前确保解压它。如果我们将 ZIP 存档上传到 S3,Web 应用程序将无法正确工作,因为所有网页内容都会被困在存档文件中:
- name: Unzip Deployment Package
run: |
mkdir -p ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
unzip ./deployment.zip -d ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
既然静态网页内容已经被解压到暂存目录,我们可以使用 aws s3 sync
命令将该目录中的所有文件部署到 S3 存储桶:
- id: deploy
name: Upload to S3 Bucket
env:
AWS_ACCESS_KEY_ID: ${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ vars.BACKEND_REGION }}
working-directory: ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
run: |
aws s3 sync . s3://${{ needs.terraform.outputs.frontend_bucket_name }}
后端
要部署 Lambda 函数,必须遵循完全相同的流程,将从 GitHub Actions 作业中构建的部署工件传递到实际部署它的作业。
唯一的不同之处是,我们将使用 aws lambda update-function-code
命令将 ZIP 存档部署到 Lambda 函数:
- name: Deploy
env:
AWS_ACCESS_KEY_ID: ${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ vars.BACKEND_REGION }}
FUNCTION_NAME: ${{needs.terraform.outputs.lambda_function_name}}
run: |
aws lambda update-function-code --function-name $FUNCTION_NAME --zip-file fileb://deployment.zip
与我们为前端提供服务的方式不同,我们不需要为 Lambda 函数解压部署包。AWS Lambda 期望我们的应用程序代码以 ZIP 存档的形式打包。
就是这样!现在我们的应用程序已完全部署到 AWS S3 和 Lambda!
概述
在本章中,我们开始了一项雄心勃勃的旅程,从一个先前在虚拟机和 Kubernetes 上架构的.NET 解决方案(使用 Amazon EKS)过渡到一个完全的无服务器架构,利用 AWS Lambda Functions。这一变革性步骤涉及将我们传统的.NET REST API 转换为一套 Lambda Functions,并将前端托管为 Amazon S3 上的静态网站,标志着我们虚拟公司自动驾驶车队平台的云原生开发之旅迈出了重要的一步。
在我们结束本章时,我们已在 AWS 上构建了三个独立的解决方案,涵盖了虚拟机、Kubernetes 以及现在的无服务器架构。我们还展示了在 AWS 的多样化能力下,如何根据不断变化的需求进行导航并加以利用。
展望未来,我们准备在 Microsoft Azure 的云之旅中迈出新的一步。在我们那位难以捉摸且富有远见的 CEO Keyser Söze 的指导下,他现在已与微软建立了合作关系,我们站在探索 Azure 生态系统中类似架构的门槛上。随着我们将目光投向 Azure,我邀请你继续与我们一起进入这个全新的世界,准备迎接新的挑战,并在这个完全不同的云平台上发掘新的可能性。
第四部分:在 Azure 上构建解决方案
凭借对 Terraform 概念性知识和超越主要公共云平台实现细节的架构概念的掌握,我们将探索在 Microsoft Azure 上构建解决方案的三种云计算范式:虚拟机、使用 Kubernetes 的容器以及使用 Azure Functions 的无服务器架构。
本部分包含以下章节:
-
第十章,在 Azure 上入门——使用 Azure 虚拟机构建解决方案
-
第十一章,在 Azure 上进行容器化——使用 Azure Kubernetes Service 构建解决方案
-
第十二章,在 Azure 上实现无服务器架构——使用 Azure Functions 构建解决方案
第十章:在 Azure 上入门——使用 Azure 虚拟机构建解决方案
现在,我们已经在 AWS 平台上从头到尾构建了解决方案,并跟随团队的旅程,从他们最初的虚拟机架构,到 Kubernetes,最后到无服务器,我们准备切换思路,进入另一个现实,在那里 Keyser 与他在微软的亲密朋友们并肩作战。在接下来的几章中,我们将遵循与第七章到第九章类似的路径,但在这个替代版本中,我们将与微软 Azure 合作。
本章将从我们在 AWS 上的旅程开始的地方接续;在第七章,我们使用 AWS 构建了一个双胞胎解决方案。在那一章中,我们详细讲解了完全不依赖云平台的解决方案要素。包括如何使用 Packer 将基于.NET 的应用程序代码部署到 Linux 虚拟机上,以及如何为虚拟机上的 CI/CD 管道设置 GitHub Actions 的详细说明。
由于大多数内容在转移到 Azure 时保持不变,我们不会在本章中以相同的长度重新讨论这些话题。然而,我建议你在第七章中做个书签,并经常参考它。
本章涉及以下主题:
-
打好基础
-
设计解决方案
-
构建解决方案
-
自动化部署
打好基础
我们在 Söze 企业的团队为他们成功应对无畏领导者 Keyser Söze 的技术路线调整而喝彩,并对他们成功在 AWS 上成功推出产品的成就与运气感到惊叹。在这里,他们使用了虚拟机、Kubernetes 和无服务器技术。当 AWS 控制台的舒适橙色外观开始消退时,空气中突然弥漫着一种既陌生又熟悉的声音:doodle-oo doodle-oo doodle-oo。一个意外的二人组合出现在眼前——一位是戴着黑色棒球帽、披肩发的棕发男,穿着简单的黑色 T 恤;另一位则是典型的书呆子魅力,金色乱发,戴着厚重的黑框眼镜,穿着红蓝格子法兰绒衬衫,下面是白色 Aerosmith T 恤。他们开始熟悉的吟唱:doodle-oo doodle-oo doodle-oo。突然间,我们被带到了另一个世界——也许是另一个宇宙,在那里 Azure 的深蓝色取代了 AWS 的亮橙色。Söze 企业与微软合作开发他们的下一代自动驾驶平台。
和之前一样,我们从 Söze 企业的其他部门接管了一支拥有强大核心团队的 C# .NET 开发者团队,因此我们将使用.NET 技术构建平台的 1.0 版本。那位神秘的 CEO Keyser 上周末在纽约市的 Met Gala 盛会上被看到与 Satya Nadella 亲密交谈,企业总部已经下令我们将使用 Microsoft Azure 来托管平台。由于团队对容器的经验并不丰富,而且时间紧迫,我们决定构建一个简单的三层架构并托管在 Azure 虚拟机上:
图 10.1 – 自动驾驶平台的逻辑架构
该平台将需要一个前端,前端将是一个使用 ASP.NET Core Blazor 构建的 Web UI。前端将由 REST API 后端提供支持,后端将使用 ASP.NET Core Web API 构建。将核心功能封装成 REST API 将使自动驾驶汽车能够直接与平台进行通信,并允许我们通过在未来添加客户端接口(如原生移动应用和虚拟或混合现实)来扩展功能。后端将使用 PostgreSQL 数据库进行持久化存储,因为它轻量级、行业标准且相对便宜。
设计解决方案
由于团队面临紧迫的时间表,我们希望保持云架构的简单性。因此,我们将保持简单,使用经过验证的 Microsoft Azure 服务来实现该平台,而不是尝试学习新东西。我们必须做出的第一个决定是,每个逻辑架构组件将托管在 Azure 的哪个服务上。
我们的应用架构由三个组件组成:前端、后端和数据库。前端和后端是应用程序组件,需要托管在提供通用计算的云服务上,而数据库则需要托管在云数据库服务上。两种服务都有许多选择:
图 10.2 – 自动驾驶平台的逻辑架构
由于我们已经决定使用虚拟机(VMs)来托管我们的应用程序,我们已经缩小了可以用来托管应用程序的不同服务,并决定 Azure 虚拟机服务是当前情况下的理想选择。其他选项,如 Azure 应用服务,也使用虚拟机,但我们希望对解决方案有完全的控制,并尽可能保持跨平台的能力,以防我们将来需要迁移到其他云平台:
图 10.3 – 我们的代码库的源代码控制结构
在这个解决方案中,我们将有六个部分。我们仍然需要前端和后端的应用程序代码以及 Packer 模板。然后,我们需要 GitHub Actions 来实现 CI/CD 过程,使用 Terraform 来部署 Azure 基础设施,并引用 Packer 构建的虚拟机镜像用于我们的 Azure 虚拟机。
云架构
在第七章中,我们使用 AWS 和其相应的虚拟机服务开发了一个类似的解决方案。因此,我们为 Azure 的设计将会非常相似。我们在 AWS 上使用的许多云服务在 Microsoft Azure 上都有对应的服务。这主要是因为虚拟机、网络和网络安全在行业中的认知已经趋于稳定。不要期望在命名约定和工作方式上看到太大的差异。在使用这种云计算范式时,各个平台之间的差异通常非常细微。在本书中,我将尽力突出不同云平台之间的同义词,帮助您更好地将概念知识从一个云平台迁移到另一个云平台。
虚拟网络
虚拟机必须部署在虚拟网络中。正如您可能记得的,在我们第七章中,当我们在 AWS 上部署这个解决方案时,我们需要为我们的解决方案设置多个子网,以便跨可用区展开。这是因为 AWS 上虚拟网络的结构、虚拟网络的范围与 AWS 区域的关系,以及子网的范围与 AWS 可用区的关系。Azure 不同。
在 Azure 上,虚拟网络和子网的范围限定在一个区域内。区域弹性内置于虚拟网络中。Azure 有两种弹性模式:一种基于故障域或区域,另一种基于可用区或区域性。虚拟机可以在这两种模式下进行部署。
要提供区域虚拟机解决方案,您需要创建一个可用性集并指定要分布虚拟机的故障域数量。当虚拟机在此可用性集中部署时,Azure 平台会确保它们部署到不共享相同电源和网络交换机的硬件上,从而降低整个工作负载因单一故障域中的故障而发生故障的可能性。如果不使用可用性集,Azure 将根据可用容量分配虚拟机,并不会保证您的虚拟机不会位于同一个故障域。
要提供区域虚拟机解决方案,您只需要指定要使用的可用区来部署虚拟机,并确保您的虚拟机分布在多个可用区中。与故障域相比,可用区提供了更高的弹性,因为它不仅保证您的虚拟机不会共享相同的电源和网络交换机,还保证您的虚拟机位于区域内不同的物理数据中心。在本书中,我们将专注于确保我们的解决方案实现区域弹性:
图 10.4 – 资源 Azure 虚拟网络架构
在前面的图示中,您可以看到我们的虚拟网络及其两个子网可以支持该区域内所有可用性区域中的虚拟机:
图 10.5 – 前端和后端应用组件的隔离子网
这意味着我们不需要像在 AWS 上那样,根据云平台的可靠性边界来设计我们的子网;我们可以根据工作负载的需求来设计我们的子网。在这种情况下,我们需要为解决方案的前端设计一个子网,前端托管了 ASP.NET Core Blazor Web 应用程序;还需要为解决方案的后端设计一个子网,后端托管了 ASP.NET Core Web API。无论我们选择按区域部署虚拟机,利用 Azure 的故障域,还是按可用区部署虚拟机,利用 Azure 的可用性区域,都不会影响网络设计。我们在决定部署虚拟机时,都可以选择这两种方式。
网络路由
在第七章中,当我们在 AWS 上设置此解决方案时,我们需要为我们的虚拟机配置互联网网关、NAT 网关和路由表,以便虚拟机能够访问互联网。而在 Azure 上,我们不需要配置等效的组件,因为 Azure 提供了默认网关并自动配置虚拟机使用它。如果我们想阻止互联网访问或以其他方式路由互联网流量,我们则需要配置额外的资源。
负载均衡
在讨论负载均衡器作为我们架构的一部分时,我们不可避免地会使用一些成熟且熟悉的术语,但我们会在不同的上下文中使用它们,这可能会让人感到困惑。因此,我希望能先解决这个难题。我们的解决方案有前端——为最终用户的 Web 浏览器提供网页的 Web 应用程序。我们的解决方案还有后端——Web 应用程序调用的 REST Web API,用于与数据库交互并执行有状态操作。我们的解决方案还将使用两个负载均衡器:一个分配负载到运行 Web 应用程序的前端 Web 服务器,另一个分配负载到运行 Web API 的后端 Web 服务器:
图 10.6 – 前端和后端过多
在每个负载均衡器的上下文中,每个负载均衡器都会有一个前端和后端。需要注意的是,使用这些术语时要理解上下文,因为我们解决方案的前端指的是一个完全不同架构粒度的不同架构组件。我们需要明白,当我们提到我们解决方案的前端时,我们是在谈论所有确保解决方案前端正常工作的组件,而当我们提到前端负载均衡器的前端时,我们指的是接受流量的网络端点,专门为我们解决方案的前端服务。
在第七章中,当我们在 AWS 上设置此解决方案时,我们使用了 AWS 应用负载均衡器(ALB)服务。在 Azure 上,我们将使用 Azure Load Balancer 服务。这两项服务功能非常相似,但它们的结构略有不同,并且使用不同的术语来描述相似的概念:
AWS | Azure | 描述 |
---|---|---|
ALB | Azure Load Balancer | 负载均衡器 |
监听器 | 前端 IP 配置 | 接受负载均衡器传入流量的单一端点 |
目标组 | 后端地址池 | 一组将接收传入流量的虚拟机 |
健康检查 | 健康探针 | 每个后端虚拟机发布的一个端点,表明它是健康的并且准备好处理流量 |
表 10.1 – AWS 和 Azure 之间负载均衡器组件的同义映射
正如我们在第四章中讨论的,负载均衡器提供了一个单一的前端端点,并将网络流量分配到多个后端虚拟机。在 AWS 中,虽然他们称这个前端端点为监听器,而在 Azure 中,它被称为前端 IP 配置。同样,AWS 中的后端虚拟机被称为目标组,而在 Azure 中,它们被称为后端地址池:
图 10.7 – 前端和后端应用组件的资源隔离子网
Azure Load Balancer 使用规则来决定如何将传入的流量路由到后端池。
Azure Load Balancer 使用规则来组织如何路由传入流量。每个规则都有一个协议、一个前端组件和一个后端组件。规则的前端组件配置了网络流量应如何进入负载均衡器。这包括暴露的端口,在哪个前端 IP 配置上暴露端口,以及使用哪个健康探针来判断哪些后端节点是健康的并且准备好接收流量。规则的后端组件指定了将流量路由到哪个后端地址池,并且指定了使用的端口。
/
)以及后端 – REST Web API – 将继续使用我们在 /health
设置的自定义健康检查端点。
网络安全
在 第七章中,我们为 AWS 设置了四个安全组,用于管理网络流量在解决方案架构中的每个逻辑节点。Azure 中我们只需要两个安全组,因为 Azure 负载均衡器会根据我们在其中配置的规则自动授予虚拟机访问权限:
图 10.8 – 前端节点池网络流量流向
从前端处理流量的虚拟机(VMs)角度来看,它们将通过 HTTP 协议在端口 5000
上接收流量。C# 应用程序将向后端托管的 REST Web API 发起请求,但我们会通过后端负载均衡器在端口 80
上使用 HTTP 协议将所有请求路由到后端。在 Azure 中,我们不需要在网络中显式允许这种出站流量:
图 10.9 – 后端节点池网络流量流向
从后端处理流量的虚拟机(VMs)角度来看,它们将通过 HTTP 协议在端口 5000
上接收流量。C# 应用程序代码将通过 HTTPS 协议向端口 5432
上的 PostgreSQL 数据库发起请求。在 Azure 中,我们不需要在网络中显式允许这种出站流量。
机密管理
像数据库凭据或服务访问密钥这样的机密需要被安全地存储。每个云平台都有提供此功能的服务。在 Azure 中,这项服务称为 Azure Key Vault:
AWS | Azure | 描述 |
---|---|---|
IAM | Microsoft Entra | 身份提供者 |
Secrets Manager | Key Vault | 安全的机密存储 |
IAM 角色 | 用户分配的托管身份 | 机器间交互的身份 |
IAM 策略 | 基于角色的访问 控制 (RBAC) | 提供对特定服务或资源执行特定操作的权限 |
IAM 角色策略 | 角色分配 | 将特定权限与特定身份关联 |
表 10.2 – 映射 AWS 和 Azure 之间的同义身份和访问管理组件
存储在 Azure Key Vault 中的机密可以在虚拟机获得必要的 RBAC 权限后访问。在 第七章中,我们使用 AWS IAM 角色分配来允许虚拟机执行此操作。Azure 也类似,通过将一个或多个用户分配的托管身份附加到虚拟机,然后为这些托管身份创建角色分配,以便它们拥有授予必要权限的特定角色:
图 10.10 – 密钥保管库架构
将附加到虚拟机的托管身份授予 Key Vault Secrets User 角色,将允许虚拟机从密钥保管库中读取机密值。这不会将机密存储在机器上。虚拟机将需要使用 Azure CLI 来访问密钥保管库中的机密。
虚拟机
现在我们已经为解决方案准备好了所需的一切,我们可以继续讨论应用程序组件将运行的位置:在 Azure 的虚拟机服务上配置的虚拟机。当在 Azure 上配置虚拟机时,你有两种选择。首先,你可以配置静态虚拟机。在这种方式下,你需要为每个虚拟机指定关键特性。第二种选择是配置一个虚拟机规模集(VMSS)。这样,你可以根据需求动态扩展或缩减,并且在虚拟机失败时自动修复:
AWS | Azure | 描述 |
---|---|---|
EC2 | 虚拟机 | 虚拟机服务 |
AMI | 虚拟机镜像 | 来自市场或自定义构建的虚拟机镜像(例如,使用工具如 Packer) |
IAM 角色 | 用户分配的托管身份 | 用于机器间交互的身份 |
自动扩展 组(ASG) | VMSS | 一组可以通过虚拟机配置模板动态扩展/缩减的虚拟机 |
启动模板 | 虚拟机配置文件 | 用于创建新虚拟机的配置模板 |
表 10.3 – AWS 与 Azure 之间相似虚拟机服务组件的映射
在 第七章 中,我们使用 AWS 弹性云计算(EC2)配置了我们的解决方案。Azure 虚拟机的结构与 EC2 实例类似。像在 AWS 上一样,Azure 虚拟机通过虚拟网络接口连接到它们对应的子网。然而,在 Azure 上,我们有两种类型的网络安全规则:网络安全组(NSG)和应用程序安全组(ASG)。虽然这两者都用于控制 Azure 上的流量,NSG 侧重于指定低级别的网络规则,如端口和协议过滤,适用于定义为 IP 地址范围的网络级资源。而 ASG 提供了更高层次的抽象,允许你根据应用程序内角色将资源分组:
图 10.11 – Azure VM 架构
或者,你可以使用 Azure VMSS 来动态配置和管理虚拟机。在这种方式下,你为 VMSS 提供一些配置和参数,指明何时扩展和何时缩减,VMSS 会处理其他所有事情:
图 10.12 – Azure VMSS 架构
Azure VMSS 允许你为它将代表你启动的每个虚拟机提供细粒度的配置。它还提供了一组策略,让你控制 VMSS 的行为,比如在实例意外失败时、Azure 需要更新它们时,或是否根据需求扩展或缩减虚拟机的数量。
部署架构
现在我们已经对我们的解决方案在 Azure 上的云架构有了一个清晰的概念,我们需要制定一个计划,来配置我们的环境并部署我们的代码。
虚拟机配置
在我们的解决方案中,我们有两个虚拟机角色:前端角色,负责处理来自最终用户浏览器的网页请求;后端角色,负责处理来自网页应用的 REST API 请求。每个角色都有不同的代码和不同的配置需要设置。每个角色都需要自己的 Packer 模板来构建虚拟机镜像,以便我们在 Azure 上启动虚拟机:
图 10.13 – 使用 Packer 管道构建前端虚拟机镜像
一个 GitHub Actions 工作流会在前端应用代码和前端 Packer 模板发生变化时触发,执行 packer build
,并为解决方案的前端创建一个新的虚拟机镜像。
前端和后端将有相同的 GitHub Actions 工作流,执行 packer build
。工作流之间的主要区别在于它们执行的代码库。前端和后端可能有稍微不同的操作系统配置,并且它们分别需要不同的部署包来部署各自的应用组件:
图 10.14 – 使用 Packer 管道构建后端虚拟机镜像
需要注意的是,应用代码会被集成到虚拟机镜像中,而不是复制到已经运行的虚拟机上。这意味着,要更新虚拟机上运行的软件,每台虚拟机都需要重新启动,以便使用包含最新代码的新的虚拟机镜像重新启动。
这种方法使得虚拟机镜像本身成为一个不可变的部署产物,每次有应用代码发布时,都会对其进行版本控制和更新。
云环境配置
一旦前端和后端的虚拟机镜像构建完成,我们就可以执行最终的工作流,既配置环境又将我们的解决方案部署到 Azure 上:
图 10.15 – 虚拟机镜像作为输入到 Terraform 代码中,Terraform 会在 Azure 上配置环境
Terraform 代码库将有两个输入变量,分别用于前端和后端虚拟机镜像的版本。当需要部署新的应用软件版本时,这些版本的输入参数将会递增,以反映目标版本。当工作流执行时,terraform apply
将会用新的虚拟机镜像替换现有的虚拟机。
现在我们已经有了一个完整的计划,说明如何使用 Azure 实现云架构,如何使用 GitHub Actions 实现部署架构,让我们开始构建吧!在下一节中,我们将详细解析用于实现 Terraform 和 Packer 解决方案的 HCL 代码。
构建解决方案
现在我们有了一个稳固的解决方案设计,可以开始构建它了。正如上一节所述,我们将使用 Azure 虚拟机。就像我们在 第七章 中使用 AWS 一样,我们需要使用 Packer 将我们的应用程序打包成虚拟机镜像,然后使用这些虚拟机镜像来配置一个环境。
Packer
在本节中,我们将学习如何实现 Packer 模板中的配置工具,以便我们可以在 Linux 虚拟机上安装我们的 .NET 应用程序代码。如果你因对 AWS 不感兴趣而跳过了 第七章 到 第九章,我不会因此责怪你——尤其是如果你读这本书的主要目的是在 Microsoft Azure 云平台上工作。然而,我还是建议你回顾一下 第七章 中的相关部分,看看我们如何使用 Packer 的配置工具配置基于 Debian 的 Linux 虚拟机,并在其上部署 .NET 应用程序代码。
Azure 插件
正如我们在 第四章 中讨论的那样,Packer —— 就像 Terraform —— 是一个可扩展的命令行可执行文件。每个云平台都为 Packer 提供了一个插件,封装了与其服务的集成:
packer {
required_plugins {
azure = {
source = "github.com/hashicorp/azure"
version = "~> 2.0.0"
}
}
}
在 第七章 中,我们学习了如何将 Packer 插件声明为 AWS 所需的插件。前面的代码演示了如何声明 Azure 插件——截至本文撰写时,最新版本是 2.0.0
。
Packer 的 Azure 插件提供了一个 azure-arm
构建器,它通过从基础镜像创建一个新的虚拟机,执行配置工具,拍摄 Azure 托管磁盘的快照,并从中创建一个 Azure 托管镜像,从而生成 Azure 虚拟机镜像。与 AWS 插件类似,这种行为被封装在 Azure 构建器中。
就像 AWS 插件封装了在 AWS 上构建虚拟机的逻辑,并且其配置采用 AWS 中心的术语一样,Azure 插件也封装了在 Azure 上构建虚拟机的所有逻辑,并且其配置采用 Azure 中心的术语。Packer 并不试图为不同的云平台创建一个标准化的构建器接口——而是将特定平台的配置封装在构建器中。这使得熟悉目标平台的用户能够简化操作,并允许构建器在不增加额外复杂性的情况下利用任何平台特有的功能,而不是通过在每个平台之间尝试统一语法来增加复杂度。
因此,AWS 和 Azure 构建器的结构几乎在所有方面都截然不同——从它们如何进行身份验证,到它们如何看待市场镜像。虽然它们有一些共同的字段和相似之处,但它们是截然不同的存在。
第一个重大区别是它们传递身份验证凭证的方式。正如我们在 第七章 中看到的,AWS 插件允许我们使用环境变量传递 AWS 访问密钥和密钥进行身份验证,而 Azure 提供者不支持这种方法,要求传递所有四个属性以使用 Microsoft Entra(前身为 Azure Active Directory)服务主体进行身份验证。这四个属性如下:
-
租户 ID:Microsoft Entra 租户的唯一标识符
-
订阅 ID:Microsoft Azure 订阅的唯一标识符
-
客户端 ID:Microsoft Entra 服务主体的唯一标识符,我们将其作为 Terraform 的身份使用
-
客户端密钥:Microsoft Entra 服务主体的密钥
以下代码展示了如何通过输入变量将四个 Microsoft Azure 凭证属性传递到 Azure 构建器:
source "azure-arm" "vm" {
client_id = var.arm\_client\_id
client\_secret = var.arm\_client\_secret
subscription\_id = var.arm\_subscription\_id
tenant\_id = var.arm\_tenant\_id
...
}
以下代码展示了如何引用 Azure 市场版的 Ubuntu 22.04 虚拟机:
source "azure-arm" "vm" {
...
image_offer = "0001-com-ubuntu-server-jammy"
image_publisher = "canonical"
image_sku = "22_04-lts"
...
}
请注意,与 AWS 版本不同,在 AWS 中我们使用 amazon-ami
数据源来查找特定 AWS 区域中的相同镜像,而在 Microsoft Azure 中我们无需这么做。由于 Azure 市场镜像的结构方式,我们不需要查找 VM 镜像的区域特定唯一标识符。
Azure 构建器的最后部分应该与 AWS 版本非常相似:
source "azure-arm" "vm" {
...
location = var.azure_primary_location
communicator = "ssh"
os_type = "Linux"
vm_size = var.vm_size
allowed_inbound_ip_addresses = [var.agent_ipaddress]
}
在前面的代码中,我们看到相同的 communicator
属性被设置为 ssh
,一个与 AWS 等效的 vm_size
属性,对应于 AWS 的 instance_type
,以及一个与 AWS 等效的 allowed_inbound_ip_addresses
属性,对应于 AWS 的 temporary_security_group_source_cidrs
,这个属性在安全组中打了一个孔,允许 GitHub Actions 执行的机器访问 Packer 部署的临时虚拟机。
操作系统配置
要配置操作系统,我们必须安装软件依赖项(如 .NET 6.0),将应用程序代码的部署包复制并部署到本地文件系统的正确位置,配置一个在启动时运行的 Linux 服务,并设置一个具有必要访问权限的本地用户和组,以便该服务能够以该身份运行。
我在 第七章 的相关章节中详细展开了这些步骤,因此如果你想刷新记忆,建议回顾这一章节。
平台特定的构建任务
Packer 提供了一种只在特定构建器上执行配置程序的方法。这使得即使在操作系统配置中,也能适应平台特定的差异。
在 Microsoft Azure 中,我们需要执行一个平台特定的命令,作为 Packer 关闭虚拟机并创建镜像之前的最后一步。那些有设置 Microsoft Windows 虚拟机镜像经验的朋友应该会熟悉一个叫做 sysprep
的工具。这个工具用于准备虚拟机,以便我们可以从其磁盘创建镜像。尽管我们并没有使用 Windows 操作系统,但 Microsoft Azure 需要我们执行一个类似的命令,以便我们能够准备好 Linux 虚拟机以创建镜像:
provisioner "shell" {
execute_command = local.execute_command
inline = ["/usr/sbin/waagent -force -deprovision+user && export HISTSIZE=0 && sync"]
only = ["azure-arm"]
}
过时的 waagent
命令并不重要。你只需要知道,这个命令需要作为最后一步执行,以确保从 Packer 构建的虚拟机镜像在启动新虚拟机时可以正常引导。但是,请注意 only
属性,它接受一个 list
类型的 string
值。在这个 list
中,我们设置的唯一值是 azure-arm
。这表示 Packer 只在我们使用该插件构建镜像时执行此配置器。正如我们所知,同一个 Packer 模板可以用于多目标构建,这意味着你可以在同一个模板中构建多个镜像,同时针对不同的云平台或区域进行构建。这也意味着你可以在 AWS、Azure 和 Google Cloud 上同时构建相同的虚拟机镜像,甚至可以在 AWS 的所有 30 多个区域中构建相同的虚拟机镜像。尽管这并不实用,因为有更好的方法可以跨区域复制虚拟机镜像,但它是可行的。
Terraform
正如我们在设计中讨论的那样,我们的解决方案由两个应用组件组成:前端和后端。每个组件都有需要部署的应用程序代码库。由于这是我们第一次使用 azurerm
提供商,我们将在介绍架构各组件的具体内容之前,先了解基本的提供商设置和后端配置。
提供商设置
我们需要在 required_providers
区块中指定我们打算在此解决方案中使用的所有提供商:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.75.0"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = "~> 2.3.2"
}
}
backend "azurerm" {
}
}
我们还需要配置 Azure 提供商。与 AWS 提供商不同,Azure 提供商不局限于特定区域。这意味着您可以在所有 Azure 区域中配置资源,而无需声明不同的 Azure 提供商区块:
provider "azurerm" {
features {}
}
Azure 提供商需要一些额外的参数来指定用于连接 Azure 的凭据,但由于这些是敏感信息,我们不希望将它们嵌入到代码中。我们稍后会在自动化部署时,通过标准的 Azure 凭据环境变量传递这些值:
-
ARM_TENANT_ID
-
ARM_SUBSCRIPTION_ID
-
ARM_CLIENT_ID
-
ARM_CLIENT_SECRET
后端
因为我们将使用 CI/CD 流水线来长期配置和维护我们的环境,所以我们需要为 Terraform 状态设置一个远程后端。由于我们的解决方案将托管在 Azure 上,我们将使用 Azure Blob 存储后端来存储 Terraform 状态。
就像 Azure 提供商一样,我们不想在代码中硬编码后端配置,所以我们将简单地设置一个后端占位符:
terraform {
...
backend "azurerm" {
}
}
我们将在 CI/CD 流水线中运行 terraform init
时,使用 -backend-config
参数来配置后端的参数。
输入变量
最佳实践是传入能够标识应用程序名称和环境的简短名称。这可以让你在组成解决方案的资源中嵌入一致的命名规范,从而更容易在 Azure 门户中识别和跟踪资源。
primary_region
、vnet_cidr_block
和 az_count
输入变量驱动部署的关键架构特性。它们不能被硬编码,因为这会限制 Terraform 代码库的重用性。
vnet_cidr_block
输入变量用于建立虚拟网络地址空间,这通常由企业治理机构严格管理。通常会有一个流程,确保组织内部的团队不会使用冲突的 IP 地址范围,这样将来就无法将这两个应用程序集成,或与企业内部的共享网络资源进行集成。
az_count
输入变量允许我们配置解决方案中所需的冗余程度。这将影响解决方案的高可用性,也会影响部署的成本。正如你可以想象的那样,成本也是云基础设施部署中一个严格管理的特性。
一致的命名和标签
与 AWS 控制台不同,Azure 的设计使得获得应用程序中心的部署视图变得非常容易。为此,你可以使用资源组:
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr_block
tags = {
Name = "${var.application_name}-${var.environment_name}-network"
application = var.application_name
environment = var.environment_name
}
}
resource "azurerm_virtual_network" "main" {
...
tags = {
application = var.application_name
environment = var.environment_name
}
}
仍然很重要的是对你部署的资源进行标签标注,指明它们属于哪个应用程序和环境。这有助于满足其他报告需求,比如预算和合规性。几乎所有 Azure 提供商中的资源都有一个 map
属性叫做 tags
。与 AWS 不同,每个资源都有一个 name
值作为必需属性。
虚拟网络
就像我们在第七章中做的那样,我们需要构建一个虚拟网络,并将其地址空间保持尽可能紧凑,以避免未来为更广泛的组织浪费不必要的地址空间:
resource "azurerm_virtual_network" "main" {
name = "vnet-${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = [var.vnet_cidr_block]
}
在 Azure 中创建网络比我们在 AWS 中做的要简单,因为我们不需要根据可用区来细分子网:
resource "azurerm_subnet" "frontend" {
name = "snet-frontend"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [cidrsubnet(var.vnet_cidr_block, 2, 1)]
}
负载均衡
正如我们在设计中讨论的那样,Azure 负载均衡器服务与 AWS 对应服务的结构差异很大:
resource "azurerm_public_ip" "frontend" {
name = "pip-lb-${var.application_name}-${var.environment_name}-frontend"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
allocation_method = "Static"
sku = "Standard"
zones = [1, 2, 3]
}
resource "azurerm_lb" "frontend" {
name = "lb-${var.application_name}-${var.environment_name}-frontend"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
sku = "Standard"
frontend_ip_configuration {
name = "PublicIPAddress"
public_ip_address_id = azurerm_public_ip.frontend.id
zones = [1, 2, 3]
}
}
需要特别指出的是,要实现区域弹性,我们需要确保架构中的所有组件都以区域弹性的方式部署。这通常需要设置 zones
属性,并指定我们希望部署的可用区。
Azure 负载均衡器的后端配置是一个简单的逻辑容器,用于存放后端地址池:
resource "azurerm_lb_backend_address_pool" "frontend" {
loadbalancer_id = azurerm_lb.frontend.id
name = "frontend-pool"
}
这个逻辑容器必须链接到静态虚拟机或虚拟机规模集(VMSS):
resource "azurerm_network_interface_backend_address_pool_association" "frontend" {
count = var.az_count
network_interface_id = azurerm_network_interface.frontend[count.index].id
ip_configuration_name = "internal"
backend_address_pool_id = azurerm_lb_backend_address_pool.frontend.id
}
在前面的后端地址池关联资源中,我们正在遍历 var.az_count
。这个数字与我们遍历虚拟机的数量相同,这使得我们可以将每个虚拟机放入每个可用区。与 AWS 不同,AWS 的负载均衡规则将监听器和目标组配置拆分开来,而 Azure 的负载均衡规则将二者结合,并将其链接到相应的健康探针:
resource "azurerm_lb_probe" "frontend_probe_http" {
loadbalancer_id = azurerm_lb.frontend.id
name = "http"
protocol = "Http"
port = 5000
request_path = "/"
}
resource "azurerm_lb_rule" "frontend_http" {
loadbalancer_id = azurerm_lb.frontend.id
name = "HTTP"
protocol = "Tcp"
frontend_port = 80
backend_port = 5000
frontend_ip_configuration_name = "PublicIPAddress"
probe_id = azurerm_lb_probe.frontend_probe_http.id
backend_address_pool_ids = [azurerm_lb_backend_address_pool.frontend.id]
disable_outbound_snat = true
}
注意负载均衡规则是如何连接多个组件的,包括前端 IP 配置、AWS 上的监听器、健康探针和后端地址池——AWS 上的目标组。
网络安全
首先,我们需要为每个应用程序架构组件设置逻辑 ASG。我们将为前端和后端分别设置一个:
resource "azurerm_application_security_group" "frontend" {
name = "asg-${var.application_name}-${var.environment_name}-frontend"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
接下来,我们需要创建 NSG,以允许必要的流量进入每个 ASG:
resource "azurerm_network_security_group" "frontend" {
name = "nsg-${var.application_name}-${var.environment_name}-frontend"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
resource "azurerm_network_security_rule" "frontend_http" {
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.frontend.name
name = "allow-http"
priority = "2001"
access = "Allow"
direction = "Inbound"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "5000"
source_address_prefix = "*"
destination_address_prefix = "*"
destination_application_security_group_ids = [azurerm_application_security_group.frontend.id]
}
秘密管理
首先,我们将设置 Key Vault:
resource "azurerm_key_vault" "main" {
name = "kv-${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tenant_id = data.azurerm_client_config.current.tenant_id
soft_delete_retention_days = 7
purge_protection_enabled = false
sku_name = "standard"
enable_rbac_authorization = true
}
然后,我们将为每个应用程序架构组件设置托管身份:
resource "azurerm_user_assigned_identity" "frontend" {
name = "${var.application_name}-${var.environment_name}-frontend"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
接下来,我们将使用 Azure 角色分配授予托管身份必要的权限:
resource "azurerm_role_assignment" "frontend_keyvault" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_user_assigned_identity.frontend.principal_id
}
虚拟机
首先,我们将从输入变量中获取虚拟机镜像。我们使用 Packer 构建了这个虚拟机镜像,并将其部署到一个不同的 Azure 资源组中:
data "azurerm_image" "frontend" {
name = var.frontend_image.name
resource_group_name = var.frontend_image.resource_group_name
}
然后,我们将通过遍历 var.az_count
输入变量来为每个虚拟机创建网络接口:
resource "azurerm_network_interface" "frontend" {
count = var.az_count
name = "nic-${var.application_name}-${var.environment_name}-frontend${count.index}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
ip_configuration {
name = "internal"
subnet_id =azurerm_subnet.frontend.id
private_ip_address_allocation = "Dynamic"
}
}
最后,我们将设置虚拟机,配置所有必要的属性,并将其链接到网络接口、虚拟机镜像和托管身份:
resource "azurerm_linux_virtual_machine" "frontend" {
count = var.az_count
name = "vm-${var.application_name}-${var.environment_name}-frontend${count.index}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_F2"
admin_username = var.admin_username
zone = count.index + 1
network_interface_ids = [
azurerm_network_interface.frontend[count.index].id
]
admin_ssh_key {
username = var.admin_username
public_key = tls_private_key.ssh.public_key_openssh
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_id = data.azurerm_image.frontend.id
user_data = data.cloudinit_config.frontend.rendered
}
在此基础上,我们已实现了 Packer 和 Terraform 解决方案,并且有了一个可工作的代码库,该代码库将为我们的前端和后端应用程序组件构建虚拟机镜像,并将我们的云环境部署到 Azure。接下来,我们将深入研究 YAML 和 Bash,并实现 GitHub Actions 工作流。
自动化部署
正如我们在设计中讨论的那样,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都包含应用程序代码和操作系统配置,这些内容都被封装在 Packer 模板中。这两个应用程序组件随后被部署到定义在 Terraform 代码库中的 Azure 云环境中。
就像我们在 第七章 讨论 AWS 解决方案时所做的那样,还有一个额外的代码库需要讨论:我们的 GitHub Actions 自动化流水线。
在 第七章 中,我们回顾了代码库的文件夹结构以及 GitHub Actions 如何适应其中,确保我们知道我们的自动化流水线被称为工作流,并存储在 /.github/workflows
中。我们的每个代码库都存储在各自的文件夹中。我们的解决方案源代码仓库的文件夹结构将如下所示:
-
.``github
-
workflows
-
dotnet
-
backend
-
frontend
-
-
packer
-
backend
-
frontend
-
-
terraform
-
根据我们的设计,我们将会有 GitHub Actions 工作流来执行 Packer 并为前端(例如 packer-frontend.yaml
)和后端(例如 packer-backend.yaml
)构建虚拟机镜像。我们还会有工作流来运行 terraform plan
和 terraform apply
:
-
.``github
-
workflows
-
packer-backend.yaml
-
packer-frontend.yaml
-
terraform-apply.yaml
-
terraform-plan.yaml
-
-
在第七章中,我们更详细地讲解了 GitFlow 流程及其如何与我们的 GitHub Actions 工作流交互,因此现在让我们深入探讨这些流水线在面向 Azure 平台时的差异。
Packer
在第七章中,我们详细介绍了执行 Packer 来构建虚拟机镜像的 GitHub Actions 工作流的每一个步骤。由于 Packer 的云无关架构的特性,这些内容基本上保持不变。唯一变化的是我们执行 Packer 的最后一步。
由于 Packer 需要配置以在 Microsoft Azure 上构建虚拟机,因此我们需要传入一些特定于 Azure 的输入变量。这些变量包括 Microsoft Azure 凭证属性、Azure 区域以及 Azure 资源组名称。
就像我们为 AWS 的 Packer 模板准备输入变量一样,我们必须确保所有 Azure 输入变量都以 azure_
为前缀。如果我们将来想要引入多目标支持,这将非常有帮助,因为许多云平台将具有相似的必需输入,比如目标区域和虚拟机大小。虽然大多数云平台有相似的输入要求,但输入值并不是可以互换的。
例如,Azure 和 AWS 都要求您指定 Packer 将临时虚拟机放置在的区域,并且最终的虚拟机镜像将存储在哪里。在 Azure 上,区域的值为 westus2
,而在 AWS 上,则为 us-west-2
。它们看起来非常相似,但实际上相距甚远(玩笑话)。Azure 的西部美国 2 区域与 AWS 的西部美国 2 区域完全不同——事实上,除了在不同的云平台上,它们物理位置也不同,Azure 的西部美国 2 区域位于华盛顿州,而 AWS 的西部美国 2 区域则位于俄勒冈州。虽然邻近,但绝对不是同一个地方。
这又回到了 Packer 将平台特定配置隔离在构建器中的策略。因此,如果我们要做多目标支持,AWS 插件需要特定于 AWS 的输入变量,而 Azure 插件需要特定于 Azure 的输入变量。因此,当我们将这些插件合并成一个 Packer 模板时,我们需要为两者都准备输入变量。
结果是,我们的 aws_primary_region
,其值为 us-west-2
,可以与 azure_primary_region
(值为 westus2
)并排显示,而不会产生任何冲突或混淆。同样,我们的 aws_instance_type
,值为 t2.small
,也可以与 azure_vm_size
,值为 Standard_DS2_v2
,并排显示。随着我们在构建器中利用更多平台特定的功能,差异可能会更加明显。
GitHub Actions 工作流 YAML 文件与 Azure 相同,唯一不同的是需要指定的额外输入变量:
- id: build
name: Packer Build
env:
PKR_VAR_arm_subscription_id: ${{ vars.ARM_SUBSCRIPTION_ID }}
PKR_VAR_arm_tenant_id: ${{ vars.ARM_TENANT_ID }}
PKR_VAR_arm_client_id: ${{ vars.PACKER_ARM_CLIENT_ID }}
PKR_VAR_arm_client_secret: ${{ secrets.PACKER_ARM_CLIENT_SECRET }}
PKR_VAR_image_version: ${{ steps.image-version.outputs.version }}
PKR_VAR_agent_ipaddress: ${{ steps.agent-ipaddress.outputs.ipaddress }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
packer init ./
packer build -var-file=variables.pkrvars.hcl ./
上述代码引用了四个 Azure 凭证属性,这些属性作为 GitHub Actions 变量和秘密存储,并通过带有 PKR_VAR_
前缀的环境变量传递给 Packer。
Terraform
在构建完我们的两个虚拟机镜像并将其版本输入到 tfvars
文件后,我们的 Terraform 自动化流水线已经准备好接管工作,不仅能够配置我们的环境,还能部署我们的解决方案(尽管从技术上讲,部署过程是在 packer build
中完成的,物理部署包被复制到主目录,并且 Linux 服务已准备就绪)。Terraform 完成了工作,通过实际启动这些镜像来创建虚拟机。
在第七章中,我们详细介绍了执行 Terraform 来配置云环境和部署应用程序代码的 GitHub Actions 工作流的每一步。得益于 Terraform 的云无关架构,这些步骤大体保持不变。唯一变化的是我们执行 Terraform 的最后一步。
就像在第七章中与 AWS 提供商一起做的一样,我们可以使用特定于 azurerm
提供商的环境变量来设置身份验证上下文。在这种情况下,四个 Azure 凭证属性通过以下环境变量传递:
-
ARM_TENANT_ID
-
ARM_SUBSCRIPTION_ID
-
ARM_CLIENT_ID
-
ARM_CLIENT_SECRET
就像在第七章中与 AWS 提供商一起做的一样,我们需要使用 -backend-config
命令行参数配置用于存储 Terraform 状态的 Azure 特定后端。与只需指定一个 S3 存储桶名称来配置 AWS 后端保存 Terraform 状态到 S3 不同,为了配置 Azure 后端,我们需要指定三个字段,以便在 Azure Blob 存储中三角定位存储 Terraform 状态的位置——资源组、存储账户和 Blob 存储容器。
Azure 资源的层次结构如下所示:
-
资源组
-
存储账户
-
Blob 存储容器
- Terraform 状态文件
-
-
与 AWS 提供商类似,后端使用密钥和 Terraform 工作区名称来唯一标识存储状态文件的位置:
- id: apply
name: Terraform Apply
env:
ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }}
ARM_CLIENT_ID: ${{ vars.TERRAFORM_ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }}
BACKEND_RESOURCE_GROUP_NAME: ${{ vars.BACKEND_RESOURCE_GROUP_NAME }}
BACKEND_STORAGE_ACCOUNT_NAME: ${{ vars.BACKEND_STORAGE_ACCOUNT_NAME }}
BACKEND_STORAGE_CONTAINER_NAME: ${{ vars.BACKEND_STORAGE_CONTAINER_NAME }}
TF_BACKEND_KEY: ${{ env.APPLICATION_NAME }}-${{ env.ENVIRONMENT_NAME }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
terraform init \
-backend-config="resource_group_name=$BACKEND_RESOURCE_GROUP_NAME" \
-backend-config="storage_account_name=$BACKEND_STORAGE_ACCOUNT_NAME" \
-backend-config="container_name=$BACKEND_STORAGE_CONTAINER_NAME" \
-backend-config="key=$TF_BACKEND_KEY"
terraform apply -auto-approve
注意,与 AWS 解决方案不同的是,我们不需要执行特定的 terraform apply
。这是因为我们不需要根据区域中的可用区数量来动态计算,从而配置我们的虚拟网络。这是因为 Azure 虚拟网络及其子网跨越了区域内所有的可用区,而在 AWS 中,子网被限制在父虚拟网络区域的特定可用区内。
云平台之间这些微妙的架构差异,即使使用相同的技术部署相同的解决方案,也可能会带来根本性的结构变化。这提醒我们,虽然我们在第四章至第六章中学习的核心概念有助于我们超越单一云平台,走向多云视角,但要实施实际解决方案,我们需要理解每个平台的微妙差异。
总结
在本章中,我们使用 Azure 虚拟机构建了一个多层次云架构,并配备了完整的 GitFlow 流程和基于 GitHub Actions 的端到端 CI/CD 管道。
在下一章中,我们在 Söze Enterprises 的无畏领导者将带领我们进入动荡的新局面,提出一些大新想法,我们将不得不回应他的号召。原来,我们的 CEO Keyser 最近熬夜看了一些关于下一大热点——容器——的 YouTube 视频,在与他的朋友 Satya 一起乘坐超级游艇时,他决定我们需要重新构建整个解决方案,使其能够在 Docker 和 Kubernetes 上运行。幸运的是,微软的好心人提供了一项可能帮助我们的服务:Azure Kubernetes 服务(AKS)。
第十一章:在 Azure 上容器化 – 使用 Azure Kubernetes 服务构建解决方案
在上一章中,我们使用 Azure 虚拟机(VM)构建并自动化了我们的解决方案。我们通过 Packer 构建虚拟机镜像,并使用 Terraform 配置虚拟机。在本章中,我们将走类似的路径,但不再使用虚拟机,而是考虑将我们的应用程序托管在 Kubernetes 集群中的容器里。
为了实现这一目标,我们需要改变方法,摒弃 Packer,改用 Docker 来为我们的应用程序创建可部署的工件。我们将再次使用 Terraform 的azurerm
提供程序,并重新回顾在与 AWS 合作时使用过的 Terraform 的kubernetes
提供程序。
由于迁移到 Azure 后,大多数内容保持不变,我们在本章中将不会再详细回顾这些主题。然而,我建议你将第八章标记为书签,并经常参考。
本章内容包括以下主题:
-
打下基础
-
设计解决方案
-
构建解决方案
-
自动化部署
打下基础
我们的故事通过 Söze 企业的视角展开,该公司由神秘的土耳其亿万富翁凯瑟·索泽(Keyser Söze)创立。我们的团队一直在努力构建下一代自动驾驶车辆编排平台。此前,我们曾希望通过利用 Azure 稳定的平台,发挥我们团队现有的技能,并专注于功能开发,从而超越竞争对手。就在团队刚进入状态时,一个意外的变故出现了。
在这个周末,我们那位难以捉摸的高管在阿布扎比与微软云与 AI 部门总裁 Scott Guthrie 的会面中受到了影响。亚斯马里纳赛道充满了活力,夕阳西下,金色的光芒洒在赛道上,粉丝和名人们聚集在一起,等待赛季结束的阿布扎比大奖赛。凯瑟在独特的 Paddock 俱乐部里看见 Scott “Gu”穿着标志性的红色 polo 衫,靠近开胃小菜的地方。Scott 兴奋地分享了有关Azure Kubernetes 服务(AKS)的一些最新改进消息。凯瑟被更高效的资源利用率、成本优化的改进,以及更快速的部署和回滚时间所吸引,他顿时心动。为了他的全新自动驾驶平台,他需要利用云计算的力量,而基于容器的架构正是实现这一目标的途径。因此,他决定加速推进云原生架构的采纳计划!
转向容器化架构的消息意味着重新评估他们的方法,深入研究新技术,甚至可能重新调整团队的动态。对团队而言,容器化一直是长期规划,但现在,事情需要加速推进,这将需要大量的时间、资源和培训投入。
当团队急忙调整计划时,他们不禁感到一阵兴奋与不安的混合情绪。他们知道,在 Keyser 的领导下,他们正在参与一项开创性的工作。他对自动驾驶汽车未来的愿景大胆且具有变革性。尽管他的做法可能不拘一格,但他们已经学会了,他的直觉往往是正确的。在本章中,我们将探讨如何通过 Microsoft Azure 从虚拟机(VM)迁移到容器的过程。
设计解决方案
正如我们在上一章中所见,我们使用 Azure 上的虚拟机构建了解决方案,并通过我们使用 Packer 配置的虚拟机镜像完全控制操作系统配置。就像在 第八章 我们在 AWS 上经历相同的过程时所做的那样,我们将需要引入一个新工具,使用容器镜像替代虚拟机镜像——Docker:
图 11.1 – 自动驾驶平台的逻辑架构
我们的应用架构,包括前端、后端和数据库,将保持不变,但我们需要使用 Terraform 配置不同的资源,并利用 Docker 和 Kubernetes 中的新工具来自动化将我们的解决方案部署到这套新基础设施上:
图 11.2 – 我们的代码库的源控制结构
在这个解决方案中,我们将有七个部分。我们仍然拥有前端和后端的应用代码和 Dockerfile(替代基于 Packer 的虚拟机镜像)。我们依旧使用 GitHub Actions 来实施我们的 CI/CD 流程,但现在我们有了两个 Terraform 代码库——一个用于配置 Azure 上的基础设施,另一个用于将我们的应用程序部署到托管在 AKS 上的 Kubernetes 集群。接着,我们有两个代码库,分别用于我们应用的前端和后端。
云架构
我们在 第八章 中所做的工作与我们在 AWS 上进行的虚拟机到容器的过渡有很多相似之处。我们将专注于关键的差异,避免重复讨论相同的内容。为了获得完整的多云视角,我建议你阅读 第八章(如果你跳过了它的话),以及接下来的章节,我们将讨论如何在 Google Cloud Platform(GCP)上解决同样的问题。
虚拟网络
在上一章中,我们为两组不同的虚拟机设置了一个虚拟网络,然后将我们的应用程序连接到一个数据库托管服务。在为 Kubernetes 集群设置虚拟网络时,我们将采用类似的方法。然而,考虑的因素略有不同。我们不再有独立的、松散的虚拟机来托管应用程序的不同组件。然而,根据 Kubernetes 集群的配置,我们可能需要考虑配置的不同节点池的放置,以及我们希望在该网络内配置的其他服务,以便允许我们在 Kubernetes 上托管的工作负载访问它们:
图 11.3 – 使用 AKS 时,虚拟网络子网沿着基础设施边界而非应用边界组织
在最简单的形式中,单一子网可以为 AKS 集群内的所有节点池指定,但随着工作负载随时间扩展,这可能会非常限制。在更高级的场景中,您应根据节点池设计和每个工作负载的规模考虑仔细划分子网。通过这样做,您可以为集群上托管的各种工作负载提供更好的网络隔离。
正如我们在第八章与亚马逊 Kubernetes 产品合作时所看到的那样,Azure 的 Kubernetes 产品也支持两种网络模式:Kubenet和CNI。本书中,我们将重点讨论 Kubenet,因为它是最常用的选项。
容器注册表
正如我们在 AWS 中看到的那样,Azure 也提供了一个强大的容器注册服务,称为Azure 容器注册表(ACR)。它充当一个私有注册表,用于托管和管理您的容器镜像和 Helm 图表。正如我们在亚马逊探险中所做的那样,我们将使用 Docker 将我们的容器镜像发布到该仓库,以便稍后从为 AKS 集群配置资源的 Terraform 代码中引用它们。我们需要使用 Azure 托管标识和 Azure 基于角色的访问控制(RBAC)来授予集群访问权限,这类似于我们使用 AWS 的 IAM 服务策略授予 Amazon EKS 访问权限的方式。
负载均衡
使用 Kubernetes 托管服务托管基于容器的工作负载的最大优势之一是,大部分底层基础设施会自动为您配置和维护。该服务解析您的 Kubernetes 资源配置,并在集群内配置必要的资源,以正确配置 Azure 以支持您的工作负载。有时,这一过程是透明处理的,其他时候,则会有特殊的挂钩让您对 Azure 上底层资源的配置拥有更多控制。
通过这种方式,AKS 在幕后使用基本的 Azure 负载均衡器或功能更丰富的 Azure 应用程序网关来简化负载均衡。AKS 在 Kubernetes 集群中创建LoadBalancer
类型服务时,会自动管理这些负载均衡器的创建和配置。为了获得更多的控制,用户还可以利用 Ingress 控制器,如 NGINX 或 Azure 应用程序网关入口控制器(AGIC),以实现高级路由、SSL 终止和其他功能:
图 11.4 – AKS 集群的网络流量流向
正如我们在第八章中所看到的,在使用 AWS 时,我们将使用 NGINX 入口控制器,但这一次,我们将配置一个 Azure 应用程序网关服务来将流量路由到 NGINX。与在 AWS 上不同,NGINX 入口控制器通过 Kubernetes 注解自动配置 ALB。在 Azure 上,我们需要先设置 NGINX 入口控制器,然后配置 Azure 应用程序网关,将流量转发到 NGINX。
网络安全
在 AKS 中,网络安全的管理方式类似于在第十章中描述的虚拟机的实践,因为它们被部署在 Azure 虚拟网络中,从而允许它们与现有的 Azure 网络功能无缝集成。然而,由于 Kubernetes 有一个名为 Kubenet 的叠加网络,这是我们的工作负载(或 Pod)所在的网络,我们需要使用 Kubernetes 网络策略来基于 Kubernetes 标签或命名空间控制工作负载之间的网络流量。当你使用 Azure CNI 和其他开源解决方案(如 Calico)时,有更多高级的网络安全功能,但这些内容超出了本书的范围。
密钥管理
就像我们在亚马逊的巡游中看到的那样,Azure 的 Kubernetes 服务也与其他 Azure 服务集成,例如 Azure 的密钥管理服务——Azure Key Vault。这种集成通过在集群本身启用 AKS 扩展以及在集群内配置 Kubernetes 资源来完成,从而为我们的 Pod 创建可以用作访问 Azure Key Vault 上托管的密钥的通道。同样,使用本地的 Kubernetes 密钥也没有问题,但 Azure Key Vault 提供了一个更加简化且安全的机制来访问 Azure 密钥。它使我们能够保持密钥的最新状态,以避免在密钥轮换时发生停机,并且它允许我们使用托管身份来访问密钥,而不是将密钥存储在集群本身。
就像我们在 第八章 中使用 AWS EKS 构建解决方案时看到的那样,我们需要在 Kubernetes 和云平台的身份管理系统之间架设一座桥梁。在 AWS 上,这个系统是 IAM;在 Azure 上,是 Entra ID。过程大致相同,但术语有所不同:
图 11.5 – AKS 与 Workload Identity
首先,我们需要创建一个托管身份来代表工作负载。这是一个 Azure 资源,表示一个由 Azure 平台管理的 Entra ID 身份。像我们在 EKS 中做的那样,我们需要在 Kubernetes 集群和 Entra ID 之间进行联合。在 Azure 上,我们通过创建一个联合身份凭证来实现这一点,该凭证链接了托管身份、AKS 集群的内部 Open ID Connect 提供程序和 Entra ID。与 AWS 类似,我们为这个托管身份植入一个种子,以便它可以与 Kubernetes 中稍后创建的 Kubernetes 服务帐户资源进行关联:
图 11.6 – AKS Secrets Manager 集成
在 Workload Identity 建立之后,我们可以授予对 Azure 资源的访问权限,例如 Key Vault 和数据库,如 Azure Cosmos DB 或 Azure SQL 数据库。正如我们在 第八章 中与 EKS 一起做的那样,我们将使用秘密存储 CSI 驱动程序和 Azure 提供程序,将我们的 Kubernetes 部署与 Azure Key Vault 集成。
Kubernetes 集群
最后,使用 AKS 创建一个 Kubernetes 集群涉及一些关键组件。正如我们已经确认的,我们需要一个虚拟网络、托管身份以及足够的 RBAC 权限来访问集群所需的资源,例如容器注册表和 Azure Key Vault 秘密。然而,我们 Kubernetes 集群的主要组件是节点池,它们提供计算资源来托管我们的 Pods:
图 11.7 – AKS 集群的结构
默认情况下,每个 AKS 集群都有一个默认节点池,用于托管 Kubernetes 的系统服务。我们可以添加额外的节点池,以隔离我们的应用工作负载或提供不同类型的计算资源,例如不同的硬件配置,以满足不同工作负载的特定需求。
部署架构
正如我们在云架构中看到的那样,我们在 第八章 与 AWS 一起完成的工作有许多相似之处。部署架构也将与我们在 第八章 中看到的相似。在上一章中,我们看到了 Terraform 提供程序的差异,当时我们配置了 azurerm
提供程序,将我们的解决方案部署到 Azure 虚拟机上。
现在,使用基于容器的架构时,与我们在 第八章中使用 AWS 部署的方式相比,唯一真正的区别将是我们如何进行容器注册表和 Kubernetes 集群的身份验证。我鼓励你查看 第八章中相应部分描述的部署架构方法。在接下来的部分,我们将深入讨论如何在 Azure 上构建相同的解决方案,但再次强调,我们会小心避免重复相同的内容。
在本节中,我们回顾了当我们从基于虚拟机的架构过渡到基于容器的架构时,架构中发生的关键变化。我们小心避免重复 第八章中已经讨论的内容,因为我们已经在 AWS 上进行了这一转变。在接下来的部分,我们将开始实施具体的解决方案,但同样,我们会小心地基于上一章中我们在使用虚拟机设置 Microsoft Azure 解决方案时所打下的基础进行构建。
构建解决方案
在本节中,我们将把理论知识应用到一个切实可行的解决方案中,同时利用 Docker、Terraform 和 Kubernetes 在 Microsoft Azure 平台上的强大功能。这个过程中有些部分需要进行重大更改,例如我们使用 Terraform 配置 Azure 基础设施时;其他部分会有小的变化,比如我们用来将应用程序部署到 Kubernetes 集群的 Kubernetes 配置;还有一些几乎没有变化的部分,比如我们构建并推送 Docker 镜像到容器注册表时。
Docker
在本节中,我们将学习如何实现我们的 Dockerfile,它将安装我们的 .NET 应用程序代码并在容器中运行服务。如果你因为对 AWS 缺乏兴趣而跳过了第七章到第九章,我不会怪你——特别是如果你读这本书的主要目的是在 Microsoft Azure 云平台上工作。然而,我鼓励你查看 第八章中的相关部分,看看我们如何使用 Docker 来配置一个容器并运行我们的 .NET 应用程序代码。
基础设施
正如我们在前一节中所讨论的,使用基于容器的架构时,基础设施大部分保持不变。因此,在本节中,我们将重点关注使用 Azure 的 Kubernetes 管理服务时的不同之处。
容器注册表
我们需要配置的第一个组件是我们的 容器注册表。容器注册表通常作为单独的部署部分进行配置,专门用于跨多个应用程序共享的基础设施。当你有一组通用的自定义构建镜像,多个团队或项目需要在它们的应用程序或服务中使用时,这种方式非常有用。然而,你应该记住,容器注册表作为一个重要的安全边界,因此,如果你希望确保应用程序团队只能访问为他们的应用程序构建的镜像,你应该为每个项目团队配置一个隔离的容器注册表:
resource "azurerm_container_registry" "main" {
name = replace("acr${var.application_name}${var.environment_name}", "-", "")
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "Premium"
admin_enabled = true
zone_redundancy_enabled = true
}
上面的代码配置了 Azure 容器注册表。需要注意的是,此资源对名称有非常具体的要求:
resource "azurerm_role_assignment" "acr_push" {
count = length(var.container_registry_pushers)
scope = azurerm_container_registry.main.id
role_definition_name = "AcrPush"
principal_id = var.container_registry_pushers[count.index]
}
上面的代码创建了一个角色分配,允许不同的用户将容器镜像推送到这个容器注册表。这是一个关键要求,它允许我们的 GitHub Action 将我们构建的 Docker 镜像发布到 Azure 容器注册表。在这里,principal_id
必须设置为 GitHub Action 模拟的服务帐户的身份。在这种情况下,我传递了一组这些身份,并使用 count
元参数遍历了这组身份。对于角色分配来说,由于这些资源非常轻量级,因此使用 for_each
或 count
不太重要,因为使用 count
时的频繁创建删除操作对部署影响较小。
Kubernetes 集群
下一步是使用 azurerm_kubernetes_cluster
资源来配置一个 Kubernetes 集群。这个资源将在我们的 AKS 基础设施中扮演核心角色:
resource "azurerm_kubernetes_cluster" "main" {
name = "aks-${var.application_name}-${var.environment_name}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
dns_prefix = "${var.application_name}-${var.environment_name}"
node_resource_group = "${azurerm_resource_group.main.name}-cluster"
sku_tier = "Standard"
...
}
上面的代码配置了一些重要的顶级属性,这些属性会影响定价、网络和内部管理的资源分配。AKS 将为两个资源组配置资源。一个是 AKS 资源所在的资源组,另一个是 AKS 配置内部 Azure 资源以组成集群内部结构的资源组。这个次级资源组的名称由 node_resource_group
属性控制。我总是建议将 node_resource_group
的名称设置为与 AKS 集群资源本身的命名约定一致的名称。
正如我们在 第五章 中学到的,Kubernetes 有几个系统服务需要部署并保持良好状态,才能确保集群正常运行。我们的 AKS 集群需要有一个或多个节点池来承载系统和用户工作负载。默认的节点池是承载这些系统服务的理想位置:
resource "azurerm_kubernetes_cluster" "main" {
...
default_node_pool {
name = "systempool"
vm_size = var.aks_system_pool.vm_size
enable_auto_scaling = true
min_count = var.aks_system_pool.min_node_count
max_count = var.aks_system_pool.max_node_count
vnet_subnet_id = azurerm_subnet.kubernetes.id
os_disk_type = "Ephemeral"
os_disk_size_gb = 30
orchestrator_version = var.aks_orchestration_version
temporary_name_for_rotation = "workloadpool"
zones = [1, 2, 3]
upgrade_settings {
max_surge = "33%"
}
...
}
可以创建额外的节点池,如下所示,以便我们将自定义部署隔离到专用的计算资源上,从而避免它们影响集群的日常操作:
resource "azurerm_kubernetes_cluster_node_pool" "workload" {
name = "workloadpool"
kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
vm_size = var.aks_workload_pool.vm_size
enable_auto_scaling = true
min_count = var.aks_workload_pool.min_node_count
max_count = var.aks_workload_pool.max_node_count
vnet_subnet_id = azurerm_subnet.kubernetes.id
os_disk_type = "Ephemeral"
orchestrator_version = var.aks_orchestration_version
mode = "User" # Define this node pool as a "user" aka workload node pool
zones = [1, 2, 3]
upgrade_settings {
max_surge = "33%"
}
node_labels = {
"role" = "workload"
}
node_taints = [
"workload=true:NoSchedule"
]
}
通过在该节点池中的节点上设置污点,我们可以确保只有明确指定要调度到此节点池的 Kubernetes 部署才会在此处进行调度。通过对额外的节点池应用污点,您可以将 Kubernetes 系统服务与默认节点池隔离开,并将工作负载保留在自己的空间中。尽管这会增加额外的成本,但它将大大提高集群的健康状况和性能。如果您计划将生产工作负载部署到集群中,建议这么做——但如果只是简单地试用集群,完全可以跳过这一步!
身份与访问管理
托管身份在 AKS 配置中发挥着至关重要的作用,具体表现在多个方面。首先也是最重要的一点是,AKS 用于提供内部资源的托管身份:
resource "azurerm_kubernetes_cluster" "main" {
...
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.cluster.id]
}
...
}
这个身份需要被分配 Managed Identity Operator
角色才能执行此功能:
resource "azurerm_role_assignment" "cluster_identity_operator" {
scope = azurerm_resource_group.main.id
role_definition_name = "Managed Identity Operator"
principal_id = azurerm_user_assigned_identity.cluster.principal_id
}
上述代码使用 用户分配的托管身份 创建了这个角色分配。我们在上一章中讨论过这个话题,所以我们知道这是一种特殊类型的托管身份,我们显式地进行配置并分配角色。这与系统分配的身份不同,后者是由平台自动配置和管理的托管身份。
还有一个需要在 AKS 集群中设置的重要身份:每个节点中部署的 kubelet 系统服务所使用的托管身份:
resource "azurerm_kubernetes_cluster" "main" {
...
kubelet_identity {
client_id = azurerm_user_assigned_identity.cluster_kubelet.client_id
object_id = azurerm_user_assigned_identity.cluster_kubelet.principal_id
user_assigned_identity_id = azurerm_user_assigned_identity.cluster_kubelet.id
}
}
上述代码配置了集群的 kubelet 身份。与在 azurerm
提供程序中通常附加托管身份的方式稍有不同,因此从用户分配的身份到 kubelet_identity
块的正确属性获取正确的输出非常重要。
正如我们在 第五章 中学到的,kubelet 系统服务会处理来自调度器的命令。为了做到这一点,kubelet 需要访问我们的 ACR 来拉取容器镜像。这将要求将 AcrPull
角色分配给前述的托管身份:
resource "azurerm_role_assignment" "cluster_kubelet_acr" {
principal_id = azurerm_user_assigned_identity.cluster_kubelet.principal_id
role_definition_name = "AcrPull"
scope = azurerm_container_registry.main.id
}
密钥管理
为了与 Azure 的密钥管理服务 Key Vault 集成,我们需要采取几个步骤。第一个步骤是仅仅在集群本身启用该子系统。AKS 对此类功能采用了可扩展的模型——包括但不限于启用与其他 Azure 服务以及 Kubernetes 功能的集成,例如 Kubernetes 事件驱动架构(KEDA)、Azure Monitor 和 Open Service Mesh:
resource "azurerm_kubernetes_cluster" "main" {
...
key_vault_secrets_provider {
secret_rotation_enabled = true
secret_rotation_interval = "5m"
}
...
}
上述代码启用了并配置了密钥旋转。这只是启用 AKS 与 Azure Key Vault 集成的第一步;我们还需要为 Pods 设置 CSI 提供程序,以便从 Key Vault 中提取机密。在下一节中,我们将开始为 Kubernetes 控制平面配置资源时详细介绍这一点。
工作负载身份
为了允许我们的 Pods 访问部署到 Azure 的其他资源,我们需要允许它们模拟一个托管身份。像与 Key Vault 的集成一样,我们首先需要在 AKS 集群上启用此扩展:
resource "azurerm_kubernetes_cluster" "main" {
...
oidc_issuer_enabled = true
workload_identity_enabled = true
...
}
上述代码激活了一个内部OpenID Connect(OIDC)端点,用于为集群内的服务账户签名并颁发JSON Web Tokens(JWTs)。启用此功能后,我们还需要 Azure 联邦身份凭证,一旦将其与 AKS 集群的 OIDC 颁发者端点以及将由工作负载使用的托管身份链接,就在集群和 Microsoft Entra ID 之间创建了联邦。这使得使用相应 Kubernetes 服务账户的 Pods 能够利用托管身份的权限与 Azure 服务进行交互:
resource "azurerm_federated_identity_credential" "main" {
name = azurerm_user_assigned_identity.workload.name
resource_group_name = azurerm_resource_group.main.name
audience = ["api://AzureADTokenExchange"]
issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url
parent_id = azurerm_user_assigned_identity.workload.id
subject = "system:serviceaccount:${var.k8s_namespace}:${var.k8s_service_account_name}"
}
正如我们在第八章中与 AWS 配合使用时所做的那样,接下来在将资源配置到 Kubernetes 时,我们将其与 Kubernetes 服务账户关联。
Kubernetes
正如我们在第八章中看到的,我们使用 Kubernetes 的 Terraform 提供者构建了 Kubernetes 部署。像 Packer 和 Docker 一样,Kubernetes 以其独特的方式提供了一个在多个云平台之间始终如一运行的控制平面。因此,不论你选择哪个云平台,Kubernetes 部署的许多过程都是可复用的。这也是 Kubernetes 作为实现云无关或云便携工作负载的方式之一,能够利用 Kubernetes 托管服务提供的效率和弹性。
在本章中,我们不会重复相同的主题。如果你因为对 AWS 不感兴趣而跳过了第七章到第九章,我强烈推荐你回去复习第八章中的相关部分,了解更多关于 Kubernetes 部署的实现细节。
提供者设置
正如我们在第八章中看到的,当使用 Kubernetes 提供者执行 Terraform 来向 Kubernetes 控制平面配置资源时,我们不需要做太多修改。我们仍然通过目标云平台进行身份验证,我们依然遵循 Terraform 的核心工作流,并且我们仍然传入针对特定平台的额外输入参数,以便引用我们需要的资源。最重要的是,集群信息、其他 Azure 服务,如 ACR、Key Vault 和托管身份,以及其他细节,可能需要放入 Kubernetes ConfigMaps 中,供 Pods 使用,指向它们的数据库端点:
data "azurerm_kubernetes_cluster" "main" {
name = var.kubernetes_cluster_name
resource_group_name = var.resource_group_name
}
在这里,我们采用分层方法首先配置基础设施,然后再配置 Kubernetes。因此,我们可以引用由负责 Azure 基础设施的 Terraform 工作区配置的资源的数据源,从而可以访问重要的连接细节,而无需在部署过程中导出并传递它们。
上述代码是对先前部署阶段中配置的 AKS 集群的引用。通过这个引用,我们可以使用多个数据片段初始化 kubernetes
提供程序,以用于与集群进行身份验证:
provider "kubernetes" {
host = data.azurerm_kubernetes_cluster.main.kube_admin_config[0].host
client_key = base64decode(data.azurerm_kubernetes_cluster.main.kube_admin_config[0].client_key)
client_certificate = base64decode(data.azurerm_kubernetes_cluster.main.kube_admin_config[0].client_certificate)
cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.main.kube_admin_config[0].cluster_ca_certificate)
}
客户端密钥是用于身份验证的私钥,客户端证书是与私钥配对以执行身份验证的证书,集群的 CA 证书是用于验证 Kubernetes API 服务器的证书颁发机构的证书。
另外,可以使用相同的参数配置 helm
提供程序。这有助于通过 Helm charts 提供 Kubernetes 资源的预打包模板:
provider "helm" {
kubernetes {
...
}
}
秘密
在前一节中,我们在集群本身上启用了密钥保管库扩展。现在,我们需要提供一种方式,使 Pod 能够连接到 Azure Key Vault。这要求我们使用 Kubernetes secrets store Container Storage Interface (CSI) 驱动程序。此配置充当了一个通道,授予工作负载身份从指定的 Key Vault 中读取特定秘密所需的权限:
resource "kubernetes_manifest" "secret_provider_class" {
manifest = {
apiVersion = "secrets-store.csi.x-k8s.io/v1"
kind = "SecretProviderClass"
metadata = {
name = "web-app-secrets"
namespace = var.namespace
}
spec = {
provider = "azure"
secretObjects = [
{
data = [
{
key = "db-admin-password"
objectName = "db-admin-password"
}
]
secretName = "db-admin-password"
type = "Opaque"
}
]
parameters = {
usePodIdentity = "false"
clientID = var.service_account_client_id
keyvaultName = var.keyvault_name
cloudName = ""
objects = yamlencode([
{
objectName = "db-admin-password"
objectType = "secret"
objectVersion = ""
}
])
tenantId = var.tenant_id
}
}
}
}
在上述代码中,我们需要将此 Kubernetes 资源配置到我们计划部署 Pod 的命名空间中,并指定 Key Vault、我们与 Azure 联合身份凭证配置的托管身份以及 Kubernetes 服务账户。
工作负载身份
为了确保我们的 Pod 使用托管身份,我们需要执行几个操作,这些操作同时使用 Azure 特定的架构和标准的 Kubernetes 架构,在 Kubernetes 内部和 Pod 部署规范中配置资源。
我们需要做的第一件事是创建一个 Kubernetes 服务账户。这是 Kubernetes 中的一个标准资源,但我们使用 Azure 特定的架构将其与 Azure 联合身份凭证关联起来:
resource "kubernetes_service_account" "main" {
metadata {
namespace = var.namespace
name = var.service_account_name
annotations = {
"azure.workload.identity/client-id" = var.service_account_client_id
}
}
}
使用 Terraform 允许我们替换在前期的配置过程中创建的动态值。Kubernetes 有自己的工作方式,但它涉及使用 Helm,并有额外的实施开销。
现在,服务账户在 Kubernetes 中已存在,并链接到适当的 Azure 托管身份凭证,下一步是在部署中启用 Azure Workload Identity。为此,我们需要指定一个特殊标签,azure.workload.identity/use
,并将其值设置为 true
:
labels = {
"azure.workload.identity/use" = "true"
}
这将指示 AKS 将此部署中的 pod 连接到通过 Azure 联邦身份凭证链接的托管身份。
下一步是指定相应的 Kubernetes 服务账户,我们已经在上一节中将其链接到 Azure 联邦身份凭证。此服务账户在部署中的 pod 规范中进行设置:
spec {
...
service_account_name = "workload"
...
}
现在我们已经构建了架构的三个组件,在下一节中,我们将学习如何使用 Docker 自动化部署过程,这样我们就可以构建并发布容器镜像。然后,我们将使用 Terraform 来配置基础设施并将解决方案部署到 Kubernetes。
自动化部署
在本节中,我们将学习如何自动化基于容器的架构的部署过程。我们将使用在 第八章 中看到的类似技术,当时我们走过了同样的过程。结果,我们将专注于当我们想要部署到 Microsoft Azure 和 AKS 时需要做出的更改。
Docker
在 第八章 中,我们详细介绍了 GitHub Actions 工作流的每个步骤,该工作流执行 Docker 来构建、标记并推送我们的 Docker 容器镜像。得益于 Docker 的云无关架构,这一过程大体保持不变。唯一变化的是我们必须如何配置 Docker,以便它能够指向我们的 Azure 容器注册表。
与 第八章 中一样,我们需要连接到我们通过 Terraform 配置的容器注册表。在 Azure 上,这意味着我们需要 Entra ID 服务主体的客户端 ID 和客户端密钥:
- name: Login to Azure Container Registry
uses: docker/login-action@v3
with:
registry: ${{ steps.terraform.outputs.registry_endpoint }}
username: ${{ vars.DOCKER_ARM_CLIENT_ID }}
password: ${{ secrets.DOCKER_ARM_CLIENT_SECRET }}
这个服务主体与我们在 Terraform 中配置为输入的身份相同,用于配置基础设施。作为该过程的一部分,AcrPush
角色分配已与该身份关联。这授予其将镜像发布到 ACR 的权限:
- name: Build and push Docker image to ACR
uses: docker/build-push-action@v5
with:
context: ${{ env.DOCKER_WORKING_DIRECTORY }}
push: true
tags: ${{ steps.terraform.outputs.registry_endpoint }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.image-version.outputs.version }}
前面的代码使用 docker\build-push-action
将我们在这个 GitHub Action 中构建的容器镜像推送到 Azure 容器注册表。正如我们在 AWS 中所做的那样,我们引用 Terraform 基础设施阶段的输出,以获取 ACR 端点。
Terraform
在 第十章 中,我们全面介绍了创建一个 Terraform GitHub Action 的过程,该过程通过 Microsoft Entra ID 服务主体在 Azure 上进行身份验证。因此,我们不会再深入探讨这个内容。我鼓励你回顾 第十章 以复习该过程。
Kubernetes
当我们用 Terraform 自动化 Kubernetes 时,我们只是使用不同的根模块再次运行 terraform apply
。这次,根模块除了配置 azurerm
提供者之外,还会配置 kubernetes
和 helm
提供者。然而,我们不会使用 azurerm
提供者创建新资源;我们只会获取数据源,以获取我们在之前的 terraform apply
命令中为 Azure 配置的现有资源的信息。
结果是,执行此过程的 GitHub Action 将与我们使用 Azure 执行 Terraform 的方式非常相似。某些变量可能会发生变化,包括容器镜像详情和集群信息等内容。
总结
在本章中,我们设计、构建并自动化了一个完整的端到端解决方案,采用基于容器的架构。我们在第十章的基础上进行扩展,该章节中我们处理了 Azure 虚拟网络的基础设施,但这次我们在 AKS 上运行容器来托管我们的应用程序。在我们 Azure 之旅的下一个也是最后一步中,我们将探讨无服务器架构,超越基础设施本身,让平台本身将我们的解决方案提升到新的高度。
第十二章:在 Azure 上使用无服务器架构——用 Azure Functions 构建解决方案
你准备好了吗?我们即将翻开微软 Azure 的新篇章——但只有在将我们的应用迁移到无服务器架构的最后一步完成后,才会开始。如同在亚马逊云服务(AWS)平台上,我们在过去的两章中,努力通过虚拟机(VMs)和容器来实施解决方案。
我们花了一些时间对比了 AWS 和微软 Azure 上的工作方式,帮助我们理解这两种云平台之间微妙甚至有时并不那么微妙的差异。
我们注意到,尽管我们的 Terraform 代码在不同云平台之间发生了相当一致的变化,但我们的应用代码和操作系统配置——无论是在 Packer 还是 Docker 中——都没有发生变化。当我们迈出微软 Azure 的最后一步时,我们将经历与将应用迁移到AWS Lambda时类似的过程。我们将不得不彻底重构应用代码。
本章涵盖以下主题:
-
打下基础
-
设计解决方案
-
构建解决方案
-
自动化部署
打下基础
我们的故事通过 Söze 企业的视角继续展开,该公司由神秘的土耳其亿万富翁凯泽·索泽(Keyser Söze)创立。我们的团队一直在努力构建下一代自动驾驶车辆编排平台。我们的初步策略是尽量减少变化,以便团队能够专注于将功能加入到产品中。然而,我们那位难以捉摸的 CEO 有其他想法,他推动我们采用容器技术,使我们的产品在未来更加灵活和可扩展。与凯泽一起工作,永远不会有无聊的时刻,但如此迅速地管理如此剧烈的变化,有时确实令人沮丧。
与此同时,在圣巴特岛,随着加勒比海的日落和鸡尾酒会的热烈进行,凯泽在酒吧偶遇微软 Azure 的首席技术官马克·鲁西诺维奇(Mark Russinovich)。两人一见如故,边喝莫吉托边聊天。当马克看到凯泽对自动驾驶平台的宏大愿景时,他随口提到,凯泽或许根本不需要担心基础设施问题。马克解释说,通过利用 Azure Functions 和其他无服务器服务,可以将凯泽从基础设施管理的束缚中解放出来,让他能够全身心地专注于宏伟的愿景。
多亏了马克的洞察力和凯泽的异想天开,我们的团队更深入地进入了微软 Azure,明确地从Azure Kubernetes Service(AKS)过渡到Azure Functions进行无服务器计算。这可能需要我们完全重新考虑应用架构,但这将使我们摆脱管理低层基础设施的重大运营负担。
设计解决方案
在本节中,我们将考察解决方案的整体设计,考虑到从基于虚拟机和容器的架构转向无服务器架构的转变。正如我们在第九章中看到的那样,服务器无服务器架构的核心目标是消除堆栈中的重型基础设施。因此,我们将寻找方法来摆脱任何需要较大固定成本的 Azure 服务,例如虚拟机或 Kubernetes 集群,并将它们替换为无服务器选项。这种操作环境和技术景观的变化可能需要我们重新思考一些关于解决方案的设计、实现和部署策略:
图 12.1 – 自动驾驶平台的逻辑架构
我们应用程序的架构没有显著变化,但我们将使用不同的 Azure 服务来承载它。在这种情况下,我们将使用 Azure Storage 来承载应用程序的前端,使用 Azure Functions 来承载应用程序的后端:
图 12.2 – 我们代码库的源控制结构
在这个解决方案中,我们的代码库将由四个部分组成。首先,我们将有用于配置环境的 Terraform 代码和执行部署过程的 GitHub Actions 代码。接着,我们将有用于应用程序前端和后端的两部分代码库。
云架构
在第十章中,我们的云托管解决方案是一组专用虚拟机,在第十一章中,它是我们 Kubernetes 集群节点池中的一组共享虚拟机。使用虚拟机带来最大的沉没成本,无论它们是独立的虚拟机还是 Kubernetes 节点池的一部分。
在第十一章中,我们的整个解决方案是在容器中执行的,这些容器允许前端和后端作为一组容器共存于相同的虚拟机上。这为我们节省了一些资金,但我们仍然需要服务器来承载工作负载。在这一章中,我们有了新的目标:利用云的力量,通过使用云原生服务,抽象化底层基础设施,并真正按需付费。Azure 的无服务器服务将在这一过程中对我们至关重要。
前端
在前几章中,我们将前端托管在面向公众的服务器上,这些服务器返回构成我们 Web 应用程序的 HTML 和 JavaScript。在那里,我们仍然需要一个云托管解决方案来托管文件并响应请求。
然而,由于 web 应用程序运行在最终用户的浏览器中,我们不需要使用云托管的虚拟机来托管这些本质上是静态文件的内容。我们可以使用简单的云存储来托管前端作为静态网站,并依赖云平台来承担返回网页内容的负担。
对此,我们可以使用 Azure 存储。该服务内置了几种不同的存储功能,但对于我们的静态网站,我们将使用 Azure Blob 存储。Blob 存储允许我们托管互联网可访问的静态网页内容,并且 Azure 存储处理所有负载均衡、SSL 终止以及应对巨大需求峰值时的扩展:
图 12.3 – Azure 存储处理网页请求,而 Azure Functions 处理 REST API 请求
为此,我们需要一个 $web
,以便发布网页内容。所有 Azure 存储帐户都有一个可以公开访问的互联网域。当我们启用 Azure 存储的静态网站功能时,互联网流量将被路由到存储在 $web
存储容器中的内容。
这将为我们带来巨大的优势,因为 Azure 存储没有任何沉没成本。当您创建一个 Azure 存储帐户时,每月的费用为零($0)。像其他无服务器服务一样,它使用一套微交易来衡量您的活动并根据您的使用量收取费用。在Azure Blob 存储中,这可能有些复杂,因为多个计量项会产生费用。
以下表格显示了当使用 Azure 存储来托管静态网站时,您可能遇到的所有费用:
计量 | 单位 | 规模 | 价格 |
---|---|---|---|
存储 | GBs | 1,000 | $0.0518 |
读取事务 | 事务数 | 10,000 | $0.004 |
写入事务 | 事务数 | 10,000 | $0.1125 |
其他操作 | 事务数 | 10,000 | $0.004 |
表 12.1 – Azure 存储微交易定价
我选择的定价是最昂贵的选项,具有地理冗余、区域冗余存储以及在备用区域的只读访问权限。这里列出的价格适用于 Azure 的西美国 2 区域,尽管在您阅读本文时价格可能已经发生变化,因此最好检查最新的价格,以获得最准确的成本估算。
我列出了这些价格是为了说明一个问题。我们可以在一个三节点的 Kubernetes 集群上托管一个静态网站,费用大约为每月 $300,或者我们可以在 Azure 存储上托管一个静态网站,费用不到每月 $0.01,使用 Azure 提供的最稳定的存储层。你会选择哪种方式?
后端
就像我们的前端一样,在前几章中,我们的后端也以两种不同的方式托管在虚拟机上:专用虚拟机和在 Kubernetes 集群节点池中的共享虚拟机。
与前端不同,我们的后台没有完全在最终用户的 Web 浏览器中客户端运行的选项。在后台,我们有需要在服务器上运行的自定义代码。因此,我们需要找到一种解决方案,在没有大量虚拟机开销的情况下托管这些组件。
在 Azure 上,我们可以使用 Azure Functions 来完成此任务。Azure Functions 是一项托管服务,允许你部署代码,而无需为任何底层虚拟机支付沉没成本。与 Azure 存储类似,它采用微交易定价模式,只针对你实际使用的部分收费。
以下表格展示了将代码部署到 Azure Functions 时会产生的费用:
度量 | 单位 | 规模 | 价格 |
---|---|---|---|
执行时间 | GB/s | 1 | $0.000016 |
总执行次数 | 交易 | 1,000,000 | $0.020 |
表 12.2 – Azure Functions 微交易定价
你可能首先注意到的是,与 Azure 存储一样,这些价格非常低,但它们衡量的是平台上非常少量的活动。
例如,执行时间度量单位是 GB/s,表示你的 Azure Function 每秒使用的内存量(以 GB 为单位)。由于它是按每秒进行测量的,因此你不需要长时间运行 Azure Functions 就能累积相当多的费用。
总执行次数是一个相对简单的度量,似乎没有限制,但 Azure Functions 本身有自然的限制。例如,每个执行的时间限制为 10 分钟。如果你正在尝试响应来自 Web 应用的请求,你可能不希望将 Azure Function 设计为 10 分钟,这对于最终用户在浏览器中的体验来说显然是非常差的。在这种情况下,你希望 Azure Function 能在几秒钟内返回。然而,除了响应浏览器中的 HTTP 请求,Azure Functions 还可以用于许多其他任务,有时,长时间运行的活动也是合适的。在这些情况下,你可以选择将 Azure Functions 托管在高级 Azure Functions 服务计划上,这样就能移除执行时长限制,因为你支付的不是按交易计费,而是预留容量。
Azure Functions 有多种托管选项。我们之前讨论过的高级服务计划允许你预留容量,连接到私人网络,移除 Azure Function 执行时长的 10 分钟限制,并允许 Azure Functions 最长运行 60 分钟。由于这些高级计划需要预先分配 Azure 资源以确保 Azure Functions 最大性能运行,因此它们具有沉没成本。你甚至可以选择不同的硬件配置(CPU 和内存),以更好地适应工作负载的需求:
图 12.4 – Azure Functions 被部署到功能应用中,功能应用托管在应用服务计划上
与此形成鲜明对比的是消费服务计划,它没有沉没成本,但在使用上有更多的限制,且无法控制主机环境的扩展和资源配置。消费服务计划非常适合开发和测试,但如果你打算运行生产工作负载,我强烈建议选择高级服务计划。
之前,我们的 ASP.NET REST API 是使用传统的 ASP.NET 项目设置的,该项目通过控制器来实现 REST API 端点。然而,当过渡到 Azure Functions 时,这种解决方案结构与 Azure Functions 框架不兼容。为了能够将我们的 REST API 托管为 Azure Functions,我们需要遵循 Azure Functions 所要求的框架。这意味着,ASP.NET 控制器类需要进行重构,以符合这一标准。在下一节中,我们将深入探讨使这一切成为可能的代码。
部署架构
现在我们对 Azure 上的云架构有了一个清晰的了解,我们需要制定一个计划,来确定如何配置环境并部署我们的代码。
在第十章中,当我们将应用程序部署到虚拟机时,我们通过 Packer 将编译后的应用程序代码打包到虚拟机镜像中。同样,在第十一章中,当我们将应用程序部署到 Kubernetes 集群中的容器时,我们使用 Docker 将应用程序代码打包成容器镜像。对于无服务器架构,这完全改变了,因为 Azure 的无服务器服务完全抽象了操作系统。这意味着我们只需要负责生成一个兼容的部署包。
创建部署包
如我们在上一节中讨论的,我们的应用程序有两个组成部分:前端和后端。它们各自有不同的部署目标。我们将把前端部署为静态网站,而后端则作为 Azure Function 部署。由于这两者都是 .NET 项目,我们将使用 .NET 和 Azure 平台特定的工具来创建部署包并将其部署到目标 Azure 服务。下图展示了我们将要经历的过程,涉及到如何配置环境、打包应用程序代码并将其部署到 Azure 的目标环境中:
图 12.5 – 部署管道:构建我们的 .NET 应用程序代码并部署到 Azure
对于前端,这意味着启用功能将我们的 ASP.NET Blazor Web 应用程序部署为 WebAssembly。这将允许前端作为静态网站托管,并且可以完全在客户端运行,而不需要任何服务器端渲染。之所以能做到这一点,是因为我们设计前端 Web 应用程序的方式,它使用 HTML、CSS 和 JavaScript 与服务器端 REST API 进行交互。需要注意的是,ASP.NET Blazor 支持两种托管选项,但我们特别选择了只使用客户端的路径,并消除了对服务器端页面渲染的任何依赖。因此,当我们使用 .NET CLI 发布我们的 ASP.NET Blazor 项目时,它将生成一个包含静态 Web 内容的文件夹。然后,我们可以使用 Azure CLI 将该文件夹的内容上传到我们的 Azure Blob 存储帐户的 $web
容器中,完成部署。
对于后端,再次使用 .NET CLI,我们需要发布我们的项目。这将生成所有需要的文件,以便正确地将我们的 Azure 函数告知 Azure Functions 服务。一旦完成,我们需要将这个文件夹压缩成一个 zip 压缩包。最后,我们可以使用 Azure CLI 将这个 zip 压缩包部署到我们的 Azure Function。
现在我们已经有了一个坚实的计划,来实施通过 Azure 构建云架构和使用 GitHub Actions 实现部署架构,接下来就开始构建吧!在接下来的部分中,我们将详细解析我们可以使用的HashiCorp 配置语言(HCL)代码,用来实现 Terraform 代码,并修改应用程序代码,使其符合 Azure Functions 框架。
构建解决方案
现在我们有了一个稳固的解决方案设计,我们可以开始构建它了。正如我们在上一部分中讨论的那样,由于我们将使用 Azure 的无服务器服务,如 Azure 存储和 Azure Functions 来托管我们的应用程序,因此我们需要对应用程序代码进行一些修改。这是我们在第十章和第十一章中不需要做的事情,因为那时我们可以通过将应用程序打包成 VM 镜像(使用 Packer)或容器镜像(使用 Docker)来部署到云端。因此,为了构建我们的解决方案,我们需要编写一些 Terraform 代码,并更新我们用 C# 编写的应用程序代码。
Terraform
正如我们在设计中讨论的那样,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都有一个需要部署的应用程序代码库。与前几章不同,那时我们还需要操作系统配置,但现在我们使用无服务器服务时,这不再是我们的责任,因为平台会为我们处理这一部分。
Terraform 设置的大部分内容与我们在前几章中做的非常相似,所以我们将只关注解决方案所需的新资源。如果你想使用完整的解决方案,可以查看本书的 GitHub 仓库中的完整源代码。
前端
首先,我们需要配置一个存储帐户,用来部署我们的前端。Azure 存储帐户是最常见的 Terraform 资源之一,因为许多其他 Azure 服务都使用存储帐户来实现不同的目的。然而,我们需要通过使用一个名为 static_website
的可选块来不同地配置我们的存储帐户。此块将启用静态网站功能,并默认将 $web
容器放置在我们的存储帐户中:
resource "azurerm_storage_account" "frontend" {
name = "st${var.application_name}${var.environment_name}${random_string.main.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
static_website {
index_document = "index.html"
error_404_document = "404.html"
}
}
后端
Azure Functions 被部署到一个叫做功能应用程序的资源上。它们有两种类型——一种是 Windows 类型,另一种是 Linux 类型。这可能会让人困惑——使用无服务器产品的主要目的不就是为了不用考虑操作系统吗?然而,底层操作系统可能会影响 Azure Function 支持的运行时类型。
要提供一个功能应用程序,我们需要一个服务计划。正如我们在上一节中提到的,有多种类型的服务计划。主要有两种类型:消费型(Consumption)和高级型(Premium)。使用消费型服务计划时,你需要使用 Y1
作为 SKU 名称,而使用高级型服务计划时,你需要选择 EP1
、EP2
或 EP3
作为 SKU 名称。每种高级服务计划的 SKU 都有不同的计算和内存资源:
resource "azurerm_service_plan" "consumption" {
name = "asp-${var.application_name}-${var.environment_name}-${random_string.main.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
os_type = var.function_app_os_type
sku_name = var.function_app_sku
}
现在我们有了服务计划,我们可以为其配置一个或多个功能应用程序。功能应用程序不需要共享相同的资源组,因此你可以让一个中央团队管理服务计划,并让各个团队管理其在服务计划中托管的功能应用程序:
resource "azurerm_windows_function_app" "main" {
name = "func-${var.application_name}-${var.environment_name}-${random_string.main.result}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
service_plan_id = azurerm_service_plan.consumption.id
storage_account_name = azurerm_storage_account.function.name
storage_account_access_key = azurerm_storage_account.function.primary_access_key
site_config {
application_stack {
dotnet_version = "v6.0"
}
cors {
allowed_origins = ["https://portal.azure.com"]
support_credentials = true
}
}
}
重要的是,服务计划的操作系统应该与功能应用程序的 Terraform 资源类型匹配。只有 azurerm_windows_function_app
资源应该部署到操作系统类型为 Windows
的服务计划上,同样,只有 azurerm_linux_function_app
资源应该部署到操作系统类型为 Linux
的服务计划上。
功能应用程序也需要一个存储帐户。这个存储帐户应该与用于配置前端的存储帐户不同。虽然为功能应用程序配置一个专用存储帐户是一个常见做法,但技术上可以使用同一个存储帐户来同时配置功能应用程序和前端。然而,由于额外的存储帐户没有额外费用,你只需为存储付费。因此,我建议配置一个专用的存储帐户,以保持架构中两个组件之间的分离。
密钥管理
功能应用程序的一个重要块是 app_settings
块。在这里,我们可以将密钥传递给 Azure Functions,以及其他影响部署策略和运行时配置的参数:
app_settings = {
"SCM_DO_BUILD_DURING_DEPLOYMENT" = "false"
"WEBSITE_RUN_FROM_PACKAGE" = "1"
"STORAGE_CONNECTION_STRING" = azurerm_storage_account.function.primary_connection_string
"QUEUE_CONNECTION_STRING" = azurerm_storage_account.function.primary_connection_string
}
在这里,我们正在设置用于连接到应用程序中的 Blob 和队列存储的 Azure Storage 帐户连接字符串。我们还可以使用 Key Vault 存储这些机密,使用特殊语法:
@Microsoft.KeyVault(VaultName=kv-fleetops-dev;SecretName=QUEUE_CONNECTION_STRING)
如果我们用新的设置替换之前的设置,我们将不再在 Azure Function 应用中存储机密。机密只存在于 Key Vault 中:
app_settings = {
"QUEUE_CONNECTION_STRING" = "@Microsoft.KeyVault(VaultName=${azurerm_keyvault.main.name};SecretName=${azurerm_keyvault_secret.queue_connection_string.name})"
}
这也要求我们设置角色分配,以授予 Azure Function 用户分配的身份必要的权限,从而访问存储在 Key Vault 中的机密。没有这个必要的角色分配,即使我们使用特殊语法正确引用 Key Vault 秘密,Azure Functions 也无法访问这些机密:
图 12.6 – Azure Functions 资源结构
如你所见,Azure Function 是一个更为简洁的部署。我们不需要虚拟网络或我们在前几章中配置的其他周边资源就可以启动。对于大多数应用程序,Azure Functions 和 Key Vault 的内建安全性就足够了。然而,如果我们想启用私有网络,因为我们的应用必须遵守某些法规合规要求,我们是可以做到的,但除此之外并不需要。
应用程序代码
Azure Functions 本质上是基于事件的。每个 Azure Function 都是由不同类型的事件触发的,Azure Functions SDK 提供了一个可扩展的框架,用于根据不同类型的事件进行触发。Azure Functions 已经为各种不同的 Azure 服务实现了多个不同的触发器,这使得设计能够响应 Azure 环境中发生的各种活动的 Azure Functions 变得容易。对于本书,我们只关注 HTTP 触发器,但如果你有兴趣,我建议你查看 Azure Functions 提供的所有其他选项——它非常广泛。
在传统的 ASP.NET REST API 解决方案中,你有控制器类,它们体现了特定的路由,然后是实现该路由下不同操作的方法:
图 12.7 – ASP.NET MVC 控制器类结构
控制器类需要使用ApiController
特性进行装饰,以通知 ASP.NET 运行时该类应用于处理指定在Route
特性中的路由的传入 Web 请求。
每个方法都用一个特性进行装饰,表示该方法应响应哪种 HTTP 动词。在上面的示例中,我们使用了HttpGet
,但每个支持的 HTTP 动词都有相应的特性。方法可以采用强类型参数,这些参数可以是路由的一部分、查询字符串的一部分或请求体的一部分。方法默认返回IActionResult
,这使得我们可以根据请求的结果返回不同的数据结构。
要使用 Azure Functions 实现一个 REST API,我们需要使用 Azure Function SDK 实现一个类。这要求我们稍微调整实现类和方法的方式。我们将使用不同的类和方法属性来实现一个类似的结果:定义一个响应特定路由的网页请求的端点。
Azure Function 类没有被修饰任何属性:
图 12.8 – Azure Function 类结构
只有方法需要使用FunctionName
属性进行修饰,这将使它们与 Azure Function 框架中的命名范围相关联。这个属性类似于Route
属性,它告知所有在此命名上下文中实现的方法的基础路由。Azure Function 类可以实现为静态类或非静态类。我推荐使用非静态类,因为它们允许你使用依赖注入,极大地提高了 Azure Function 的可测试性。
Azure Function 类中的方法是我们与 Azure Functions 的事件触发框架结合的地方。在响应网页请求时,我们需要方法的第一个参数为HttpRequest
类型,并且我们需要在该方法参数上使用HttpTrigger
属性。由于我们已经使用FunctionName
属性修饰了该方法,Azure Function 框架会知道检查此方法是否有可用的事件触发器。因此,提供附加HttpTrigger
属性的HttpRequest
将满足匹配条件,Azure Function 会将该方法与传入的网页流量连接,以便做出相应响应。
这种模式与传统的 ASP.NET 实现使用控制器类非常相似。然而,它采用了略有不同的结构。所有相同的解剖元素都存在,只是位置不同:HTTP 动词、端点路由、输入参数(可以是查询字符串或请求体中的一部分)以及响应体。
与传统的 ASP.NET 项目不同,HTTP 动词不是方法级别的属性。它是HttpTrigger
属性的一个参数。该方法确实允许我们将额外的输入参数作为查询字符串或路由的一部分添加,但不能作为请求体的一部分。
如我们所见,云架构大大简化了事情,但其权衡是我们的后台代码需要适应 Azure Functions 框架。这将需要开发和测试工作来将我们的代码库转换为这种新的托管模型。这与我们在前几章中探讨的内容形成鲜明对比,在前几章中,我们是托管在虚拟机上,或者将应用程序容器化并托管在 Kubernetes 集群上。虽然适应 Azure Functions 模型需要一定的工作,但它的好处是双重的:首先,它使我们能够利用接近零的沉没成本;其次,它让我们可以完全抽象化底层基础设施,Azure 平台可以负责可伸缩性和高可用性。这使得我们能够更多地专注于解决方案的功能,而不是维持系统运行所需的底层基础设施。
现在我们已经实施了 Terraform 来配置我们的解决方案,并修改了应用程序代码以使其符合 Azure Functions 框架,接下来的章节中,我们将深入探讨 YAML 和 Bash,并实施必要的 GitHub Actions 工作流。
自动化部署
正如我们在前一节中讨论的那样,无服务器服务,如 Azure Functions 和 Azure Storage,抽象化了操作系统的配置。因此,当我们进行部署时,我们只需要一个与目标平台兼容的应用程序包。在本节中,我们将使用 GitHub Actions 创建一个自动化流水线,将我们的应用程序部署到 Azure 中新的无服务器环境。
Terraform
我们首先需要做的是将环境配置到 Azure。这将与我们在前几章中所做的非常相似。在第十章中,我们需要确保虚拟机镜像已经构建并可用,然后才能执行 Terraform,因为 Terraform 的代码库在配置虚拟机时引用了这些虚拟机镜像。这意味着,在我们的虚拟机架构中,应用程序部署发生在 Terraform 配置环境之前:
图 12.9 – Packer 生产的虚拟机镜像是 Terraform 的前提条件
在第十一章中,当我们使用 Azure Kubernetes 配置 Kubernetes 集群时,并没有这样的前提条件。应用程序部署发生在 Kubernetes 集群上线之后。这意味着,在基于容器的架构中,应用程序部署发生在 Terraform 配置环境之后:
图 12.10 – Docker 生产的容器镜像在 Terraform 执行后被提供给 Kubernetes
在使用 Azure 的无服务器服务时,部署过程与我们将应用程序部署为容器到 Kubernetes 时看到的过程相似。就像这种方法一样,我们需要为 Azure 的无服务器服务构建部署工件。对于前端,这意味着仅仅生成静态网页内容,而对于后端,这意味着生成 Azure Functions 的 ZIP 压缩包。这些工件与 Docker 镜像类似,都是将我们的应用程序打包为目标服务兼容的部署方式。
如下图所示,无服务器部署与我们在基于容器的架构中使用的方法非常相似:
图 12.11 – .NET CLI 生成的部署工件,在 Terraform 执行后被提供到 Azure
这是因为 Azure 在使用无服务器方法时,扮演了 Kubernetes 的角色。Azure 只是提供了自定义工具来促进应用程序的部署。
部署
现在,Terraform 已经为我们的无服务器解决方案提供了所需的 Azure 基础设施,我们需要执行最后一步,将这两个部署工件部署到 Azure 中的相应位置。
我们将使用 .NET 和 Azure 自定义工具来生成工件并将其部署到这些目标位置。
前端
正如我们在其他章节中看到的,我们的 .NET 应用程序代码需要遵循一个持续集成的过程,其中代码通过自动化单元测试和其他内置的质量控制进行构建和测试。在这里没有什么变化,唯一需要注意的是,我们需要为这些过程生成的部署工件添加一些特殊处理,确保它能够在将工作负载部署到合适位置的 GitHub Action 工作中可用。
dotnet publish
命令用于输出 .NET 应用程序代码的部署工件。对于 ASP.NET Blazor Web 应用程序,这个输出是一个文件夹容器,包含 HTML、JavaScript 和 CSS 等松散的文件。为了高效地将这些文件从一个 GitHub Actions 工作传递到另一个,我们需要将它们打包成一个单独的文件:
- name: Generate the Deployment Package
run: |
zip -r ../deployment.zip ./
working-directory: ${{ env.DOTNET_WORKING_DIRECTORY }}/publish
现在,静态网页内容已经被压缩成一个 ZIP 文件,我们可以使用 upload-artifact
GitHub Action 将此文件保存到 GitHub Actions 中。这将使得文件可以在未来的工作中被使用,该工作将在流水线中执行:
- name: Upload Deployment Package
uses: actions/upload-artifact@v2
with:
name: dotnet-deployment
path: ${{ env.DOTNET_WORKING_DIRECTORY }}/deployment.zip
未来的工作可以使用相应的 download-artifact
GitHub Action,通过上传时使用的相同名称简单地下载工件:
- uses: actions/download-artifact@v3
with:
name: dotnet-deployment
因为 ASP.NET Blazor Web 应用程序将作为静态网页内容托管在我们的 Azure 存储帐户中,所以我们需要确保解压缩它以将内容上传到 Azure Blob 存储。如果我们直接将 ZIP 文件上传到 Blob 存储,Web 应用程序将无法正常工作,因为所有网页内容都被困在压缩包内:
- name: Unzip Deployment Package
run: |
mkdir -p ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
unzip ./deployment.zip -d ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
现在静态网页内容已经被解压到暂存目录,我们可以使用 az storage blob upload-batch
命令将所有文件部署到 $``web
容器中:
- id: deploy
name: Upload to Blob
env:
ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }}
ARM_CLIENT_ID: ${{ vars.TERRAFORM_ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }}
working-directory: ${{ env.DOTNET_WORKING_DIRECTORY }}
run: |
az login --service-principal -u $ARM_CLIENT_ID -p $ARM_CLIENT_SECRET --tenant $ARM_TENANT_ID
az account set --subscription $ARM_SUBSCRIPTION_ID
az storage blob upload-batch -s ./upload-staging/wwwroot -d \$web --account-name ${{ steps.terraform.outputs.frontend_storage_account_name }}
我们需要确保与 Azure 进行身份验证,并确保我们正在使用具有目标 Azure 存储帐户的正确 Azure 订阅。因此,我们需要执行 az login
命令进行身份验证,然后使用 az account set
来确保我们正在使用正确的订阅。完成这些操作后,我们就可以执行 az storage blob upload-batch
命令,将暂存目录中的所有文件递归上传。
Azure Function
要部署 Azure Function,必须遵循相同的流程,将从 GitHub Actions 作业中构建的部署工件传递到部署工件的作业中。
像 az storage blob upload-batch
命令一样,我们还需要进行身份验证并设置正确的 Azure 订阅。唯一的区别是,我们使用的是 az functionapp deployment source config-zip
命令来将 ZIP 文件部署到 Azure Function:
- name: Deploy
env:
ARM_SUBSCRIPTION_ID: ${{ vars.ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ vars.ARM_TENANT_ID }}
ARM_CLIENT_ID: ${{ vars.TERRAFORM_ARM_CLIENT_ID }}
ARM_CLIENT_SECRET: ${{ secrets.TERRAFORM_ARM_CLIENT_SECRET }}
RESOURCE_GROUP_NAME: ${{needs.terraform.outputs.resource_group_name}}
FUNCTION_NAME: ${{needs.terraform.outputs.function_name}}
run: |
az login --service-principal -u $ARM_CLIENT_ID -p $ARM_CLIENT_SECRET --tenant $ARM_TENANT_ID --output none
az account set -s $ARM_SUBSCRIPTION_ID --output none
az functionapp deployment source config-zip -g $RESOURCE_GROUP_NAME -n $FUNCTION_NAME --src ./deployment.zip
与我们为前端提供的方式不同,我们不需要解压部署包来为 Azure Function 服务。Azure Functions 期望我们的应用代码已被打包成 ZIP 文件:
app_settings = {
"SCM_DO_BUILD_DURING_DEPLOYMENT" = "false"
"WEBSITE_RUN_FROM_PACKAGE" = "1"
}
你可能还记得在上一节中我们在 Azure Function 上设置的 app_settings
,我们设置了两个配置项——SCM_DO_BUILD_DURING_DEPLOYMENT
和 WEBSITE_RUN_FROM_PACKAGE
。这两个配置项告诉 Azure Functions,我们的应用代码已经预编译并打包成一个 ZIP 文件。
就这样!通过这个步骤,我们的应用程序已经成功部署到 Azure 存储和 Azure Functions!
总结
在这一章中,我们设计、构建并自动化了使用 Azure Functions 构建完整端到端解决方案的部署。为了实现这一点,我们最终需要对应用程序代码进行一些重大更改,以便它符合无服务器运行时的要求。在采用无服务器服务时,你必须做出这个明显且重要的决策,因为它将你的应用代码与目标云平台紧密耦合。
在这整个过程中,我们通过利用虚拟机、通过Azure Kubernetes 服务(AKS)的 Kubernetes,以及现在的无服务器架构(Azure Functions),在 Azure 平台上精心构建了三种不同的解决方案。
随着我们结束这个以 Azure 为中心的叙述,我们站在一个令人兴奋的新替代现实的边缘。在我们神秘的 CEO Keyser Söze 的远见引领下,我们准备开始与 Google 展开一次冒险性的合作。这个伙伴关系将在无限可能的领域中展开,反映我们在 Azure 上的成就,迁移到 Google Cloud 上。我们的叙述将转向探索 Google Cloud 上的类似架构,敬请关注,随着我们与 Keyser Söze 一起进入这个替代宇宙,深入挖掘 Google Cloud 的服务,并继续创新我们的云计算解决方案。
第五部分:在 Google Cloud 上构建解决方案
我们将以 Terraform 的概念知识和超越主要公共云平台实现细节的架构概念为武器,探索使用三种云计算范式在 Google Cloud 上构建解决方案:虚拟机、使用 Kubernetes 的容器以及 Google Cloud Functions 的 Serverless 架构。
本部分包括以下章节:
-
第十三章,在 Google Cloud 上入门 – 使用 GCE 构建解决方案
-
第十四章,在 Google Cloud 上容器化 – 使用 GKE 构建解决方案
-
第十五章,在 Google Cloud 上使用 Serverless – 使用 Google Cloud Functions 构建解决方案
第十三章:在 Google Cloud 上入门——使用 GCE 构建解决方案
你成功了。在前六章中,我们使用了两个不同的云平台和三种不同的云计算范式,构建了六个不同的解决方案,现在我们终于准备好通过将我们的解决方案适应Google Cloud Platform(GCP)来踏上最后的旅程。
像之前的两次冒险一样,在这个平行宇宙中,我们将通过在 Google Cloud 上使用虚拟机(VM)架构来开始我们的旅程。正如我们在 AWS 和 Azure 之间迁移时所看到的,当我们比较在两个不同的云平台上如何构建相同的解决方案架构时,有些东西变化很大,而有些东西变化很小——或者根本没有变化。我们注意到,在所有章节中,我们的 Terraform 代码几乎保持一致。然而,其他的一些内容,比如 Packer、Docker 和 GitHub Actions 工作流,只是稍微发生了变化。我们的基于.NET 的应用程序代码无论是在虚拟机还是容器中托管,都没有变化,但当我们进入无服务器时,应用程序代码经历了彻底的重构。
同样的情况也适用于我们将解决方案移到 GCP 时。因此,我们在本章中不会再详细讨论这些主题。不过,我鼓励你收藏第七章和第八章,并经常参考它们。本章将重点介绍我们在 GCP 上部署解决方案时必须做出的更改。
本章涵盖以下内容:
-
奠定基础
-
设计解决方案
-
构建解决方案
-
自动化部署
奠定基础
我们在 Söze 企业的团队对他们响应他们无畏的领导者 Keyser Söze 的技术调整,取得的成就表示赞赏,并且对他们在微软 Azure 上成功推出产品的成功和运气感到惊讶。他们使用了虚拟机(VM)、Kubernetes,最终还使用了无服务器技术。Azure 门户的深蓝色渐渐褪去,突然,空气中充满了一种神秘而又熟悉的声音:doodle-oo doodle-oo doodle-oo。熟悉的二重奏出现在我们面前——坐在一个温馨的木质面板地下室里,仿佛是 90 年代末和 2000 年代初郊区地下室的典型装饰。墙上挂着海报和纪念品,其中一个显眼的芝加哥熊队旗帜突出展示了 Wayne 对摇滚音乐和体育的热爱。他们开始熟悉的咒语:doodle-oo doodle-oo doodle-oo。突然间,我们被传送到了另一个世界——也许是另一个宇宙,在这个宇宙里,Google Cloud 的光滑多彩标志取代了 Azure 的深蓝色。Söze 企业现在已经与 Google Cloud 合作,打造下一代自动驾驶平台。
正如之前所说,我们从 Söze Enterprises 的其他部门继承了一个拥有强大核心团队的 C# .NET 开发人员团队,因此我们将使用 .NET 技术构建平台的 1.0 版本。据信,神秘的 CEO Keyser 曾和 Google 联合创始人 Sergey Brin 一起在阿马尔菲海岸附近的超级游艇 Dragonfly 上,公司的消息传下来,我们将使用 Google Cloud 来托管平台。由于团队对容器技术不太熟悉,而且时间紧迫,我们决定构建一个简单的三层架构并托管在 Azure 虚拟机上:
图 13.1 – 自动驾驶平台的逻辑架构
平台需要一个前端,它将是一个使用 ASP.NET Core Blazor 构建的 Web UI。前端将由一个 REST API 后端提供支持,后端将使用 ASP.NET Core Web API 构建。将我们的核心功能封装到 REST API 中将允许自动驾驶车辆直接与平台进行通信,并使我们能够通过添加客户端接口(例如原生移动应用程序和未来的虚拟或混合现实技术)进行扩展。后端将使用 PostgreSQL 数据库进行持久存储,因为它轻量、行业标准且相对廉价。
设计解决方案
由于团队面临紧迫的时间表,我们希望保持云架构的简单性。因此,我们将保持简单,使用 Google Cloud 服务,这将允许我们利用熟悉的虚拟机技术进行资源配置,而不是尝试学习新技术。我们需要做出的第一个决策是,确定我们的逻辑架构中每个组件将托管在 Google Cloud 的哪个服务上。
我们的应用架构由三个组件组成:前端、后端和数据库。前端和后端是应用程序组件,需要托管在提供通用计算的云服务上,而数据库则需要托管在云数据库服务上。对于这两种类型的服务,有很多选择:
图 13.2 – 自动驾驶平台及其主机的逻辑架构
由于我们已经决定使用虚拟机来托管我们的应用程序,我们已缩小了可以用来托管应用程序的不同服务范围。我们已决定 Google Compute Engine (GCE) 是当前情况下的理想选择:
图 13.3 – 我们代码库的源控制结构
该解决方案将包括六个部分。我们仍然需要前端和后端的应用代码以及 Packer 模板。然后,我们使用 GitHub Actions 来实现我们的 CI/CD 流程,并使用 Terraform 来配置我们的 Google Cloud 基础设施,并引用 Packer 构建的 VM 镜像用于我们的 GCE 实例。
云架构
我们设计的第一部分是将解决方案架构适配到目标云平台:Google Cloud。这涉及到将应用架构组件映射到 GCP 服务,并深入思考这些服务的配置,以确保它们符合我们解决方案的要求。
项目和 API 访问
在开始之前,我们需要在组织内创建一个项目,用于为 Terraform 创建服务账户。该服务账户需要被授予 roles/resourcemanager.projectCreator
组织角色的访问权限。这样,你就可以使用 Terraform 创建项目,保持整个解决方案的完整性,避免使用命令行界面在 Terraform 外部执行额外的样板代码。
完成这些设置后,你需要在 Terraform 服务账户所在的项目中启用 Cloud Resource Manager API。由于 Google Cloud 在项目级别通过特定方式授予对平台不同功能的访问权限,因此该 API 在 Google Cloud 项目上下文中是必需的。它为 Google Cloud 身份访问 GCP 上的资源创建了另一道关卡。
你的 Terraform 服务账户还需要访问 Cloud Storage,你计划用它来存储 Terraform 状态。在使用 AWS 和 Azure 提供程序时,你可以使用与配置环境时不同的凭证来访问 Terraform 后端。在 Google Cloud 上,可以通过设置 GOOGLE_BACKEND_CREDENTIALS
,指定与 Google Cloud Storage 存储桶进行通信所用的身份凭证,和设置 GOOGLE_APPLICATION_CREDENTIALS
,指定与 Google Cloud 进行通信以配置环境时所用的身份凭证来实现。
虚拟网络
虚拟机必须部署在虚拟网络中。你可能还记得在 第七章 中,当我们在 AWS 上配置这个解决方案时,我们需要为解决方案设置多个子网,以跨越多个可用区。在 第八章 中,当我们将解决方案部署到 Azure 时,我们只需要两个子网——一个用于前端,一个用于后端。这是因为 Azure 的虚拟网络架构与 AWS 不同,Azure 上的子网跨越多个可用区。
Google Cloud 的虚拟网络服务结构也不同。与 AWS 和 Azure 都只将虚拟网络范围限定于特定区域不同,GCP 上的虚拟网络默认跨多个区域。子网范围限定于区域,这意味着,像 Azure 一样,GCP 上的子网可以承载来自多个可用区的虚拟机。
以下图示显示,Google 计算网络不像在 AWS 和 Azure 中那样与区域绑定:
图 13.4 – Google Cloud 网络架构
尽管这看起来是部署层次结构根本上的一个显著差异,但它并不会实质性地影响设计,因为子网(或子网络)仍然与一个区域相关联:
图 13.5 – 前端和后端应用组件的隔离子网
在构建单区域解决方案时,Google Cloud 的多区域功能可能显得有些过剩。然而,自动跨越多个区域的功能简化了基础设施管理,因为企业不必手动设置和维护跨区域连接。这不仅减少了管理开销,还通过使得活跃-活跃的多区域部署更易于构建和维护,支持了企业对变化需求的更敏捷和可扩展的响应。
网络路由
在 Google Cloud 网络内,默认设置旨在提供简便且安全的连接。如我们所知,默认情况下,Google Cloud 网络是全球资源,这意味着同一网络中的所有子网(或子网络)都可以相互通信,无论它们位于哪个区域,而无需显式路由或 VPN。这种子网间通信使用网络中的系统生成路由。
对于路由配置,Google Cloud 有 routes,其功能类似于 AWS 的路由表,根据 IP 范围来引导流量。对于需要发起出站连接到互联网但又不希望暴露其 IP 的实例,Google Cloud 提供了 Cloud NAT,这类似于 AWS 的 NAT gateways。
像 Azure 一样,Google Cloud 没有一个直接等效的名为 internet gateway 的组件。相反,GCP 中的互联网连接是通过系统生成的路由和防火墙规则的组合进行管理的。
负载均衡
Google Cloud 有两种负载均衡器选项:全球和区域。全球负载均衡器将流量分配到多个区域,确保用户从最靠近或最适合的区域获得服务,而区域负载均衡器则在单个区域内分配流量。选择使用哪种通常取决于应用程序的用户分布以及低延迟访问的需求。然而,有时候,其他的限制条件也会迫使你做出选择:
图 13.6 – Google Cloud 区域负载均衡器
不幸的是,区域负载均衡器的目标池不允许您为后端实例指定不同的端口。这意味着目标池将把流量转发到接收流量时使用的相同端口。例如,如果转发规则在端口80
上监听,目标池将把流量发送到后端实例的端口80
。
要实现从端口80
转发到端口5000
的目标,您需要使用全球负载均衡器,而不是区域负载均衡器:
图 13.7 – Google Cloud 全球负载均衡器
全球负载均衡器要求您设置实例组,以组织负载将要分配到的虚拟机(VM)。Google Cloud 实例组类似于 AWS 的自动扩展组和 Azure 的虚拟机规模集(VMSS),但它们更具灵活性,您可以提供虚拟机模板并允许 GCP 来管理实例,或者您可以显式地配置实例并稍后将它们添加到实例组中。这种双模式能力类似于 Azure 的 VMSS,而不是 AWS 的自动扩展组,后者只能在管理模式下运行。
正如我们在比较 AWS 和 Azure 时所看到的,负载均衡器的所有基本组成部分都存在并得到体现——它们可能只是使用不同的名称,并以略微不同的方式连接。下表扩展了我们在 AWS 和 Azure 之间的映射,并包括了 GCP 的对应项:
AWS | Azure | GCP | 描述 |
---|---|---|---|
应用负载均衡器(ALB) | Azure 负载均衡器 | URL 映射 | 负载均衡器 |
监听器 | 前端 IP 配置 | 全球转发规则 | 接受负载均衡器传入流量的单一端点 |
目标组 | 后端地址池 | 后端服务 | 传入流量被转发到的虚拟机集合 |
健康检查 | 健康探针 | 健康检查 | 每个后端虚拟机发布的端点,指示其健康且准备好处理流量 |
表 13.1 – AWS、Azure 与 GCP 之间同义负载均衡器组件的映射
URL 映射和目标 HTTP 代理共同构成了全球负载均衡器,它连接到转发规则,后者充当单一端点,以及后端服务,后端服务代表着用于分配负载的虚拟机集合。
网络安全
为了控制网络流量,Google Cloud 提供了防火墙规则,允许用户指定哪些数据包可以进出实例。虽然 Google Cloud 的防火墙规则与 AWS 的网络访问控制列表(NACLs)有一些相似之处,但需要注意的是,GCP 防火墙规则是有状态的,而 AWS NACLs 是无状态的。
秘密管理
像数据库凭证或服务访问密钥这样的秘密需要安全存储。每个云平台都有提供此功能的服务。在 GCP 中,这项服务称为 Google Cloud Secret Manager。
再次,我们会看到命名约定的细微差别,但所有的结构部分都在其中。以下表格扩展了我们在 AWS 和 Azure 之间所做的映射,并包括了 GCP 的等效项:
AWS | Azure | GCP | 描述 |
---|---|---|---|
IAM | Microsoft Entra | Cloud Identity | 身份提供者 |
Secret Manager | Key Vault | Secret Manager | 安全的秘密存储 |
IAM 角色 | 用户分配的托管身份 | 服务账户 | 机器间交互的身份 |
IAM 策略 | 基于角色的访问控制 (RBAC) | IAM 成员 | 执行特定操作所需的权限 |
IAM 角色策略 | 角色分配 | IAM 成员 | 将特定权限关联到特定身份 |
表 13.2 – AWS、Azure 和 GCP 之间同义 IAM 组件的映射
存储在 Google Cloud Secret Manager 中的秘密可以通过虚拟机(VM)访问,前提是它们已获得必要的访问权限。在 第七章 中,我们使用了 AWS IAM 角色分配来允许 VM 执行此操作,而在 Azure 中,我们使用了用户分配的托管身份和角色分配。在 GCP 中,我们需要使用服务账户并授予它对特定秘密的权限:
图 13.8 – Key Vault 架构
授予附加到 VM 的托管身份对 Key Vault Secrets User 角色的访问权限将允许 VM 从 Key Vault 中读取密钥值。这不会将秘密存储到机器上。VM 需要使用 Azure CLI 来访问 Key Vault 中的秘密。
虚拟机(VM)
现在我们已经准备好了解决方案所需的所有内容,我们可以最后讨论应用程序组件将运行的位置:在 Google Cloud 的 Compute Engine 服务上配置的虚拟机(VM)。在 GCP 上配置 VM 时,有两种选择。首先,可以提供静态 VM。在这种方法中,需要为每个 VM 指定关键特征。可以将这些 VM 组织到实例组中,以更好地管理其健康状况和生命周期。第二种选择是配置实例组管理器。这将允许根据需求动态地进行扩展和缩减,并且可以自动修复失败的 VM:
图 13.9 – Google Cloud Compute Engine 实例架构
类似于 Azure,Google Cloud 将通过应用生命周期将 VM 组织在一起的概念与其健康管理和动态配置区分开来。在 Azure 中,可用性集是一个逻辑组,可以将单独的 VM 放入其中,使其关系被底层平台考虑:
图 13.10 – 实例组管理器架构
在 Google Cloud 上,这就是一个实例组。两者都允许你轻松地将虚拟机池附加到其他相关服务,这些服务有助于多个虚拟机共同解决问题,例如负载均衡器和健康监测。为了添加动态配置和管理,在 Azure 上,你需要一个 VMSS。在 Google Cloud 上,这被称为实例组管理器。
再次强调,正如我们之前看到的那样,名称已经更改以保护无辜者,但请不要误解,它们的工作方式是相同的。下表扩展了我们在 AWS 和 Azure 之间所做的映射,并包括了 GCP 的等效项:
AWS | Azure | GCP | 描述 |
---|---|---|---|
EC2 | 虚拟机 | 计算实例 | 虚拟机服务 |
AMI | 虚拟机映像 | Google 计算映像 | 来自市场或自定义构建的虚拟机映像(例如,使用 Packer 等工具) |
IAM 角色 | 用户分配的托管身份 | 服务账户 | 用于机器到机器交互的身份 |
自动伸缩组 | VMSS | 实例组管理器 | 一组动态配置的虚拟机,可以使用虚拟机配置模板进行扩展/缩减 |
启动模板 | 虚拟机配置 | 实例模板 | 用于创建新虚拟机的配置模板 |
表 13.3 – AWS、Azure 和 GCP 之间的虚拟机服务组件同义词映射
在 第七章 中,我们使用 AWS 弹性云计算(EC2)服务配置了我们的解决方案,而在 第八章 中,我们做了同样的事情,但使用的是 Azure 虚拟机服务。像这两个平台一样,在 GCP 上,虚拟机通过网络接口连接到虚拟网络。与 AWS 和 Azure 不同的是,这些网络接口不能独立于虚拟机进行配置,而是在后期附加。
我们还讨论了 Azure 和 AWS 在处理网络安全方面的细微差别,其中 AWS 通过 NACL 处理低级别的网络安全,这些 NACL 附加在子网中,并通过更具逻辑性的安全组附加在实例上,按状态方式处理网络流量。Azure 有类似的构造,网络安全组更多关注物理端点之间的网络流量(IP 地址范围和网络网关),而应用程序安全组则关注逻辑应用程序端点之间的网络流量。Google Cloud 将两者结合起来,使用 Google Compute 防火墙资源,能够利用物理网络特性(如 IP 地址范围)和逻辑构造(如服务账户和标签)控制网络流量。
使用标签来附加行为或授予权限的模式是 GCP 中常见的模式,你应该注意这一点,因为其他平台并不将标签视为建立安全边界的方法。
部署架构
现在我们已经有了一个大致的 Google Cloud 云架构方案,我们需要制定一个计划,来配置我们的环境并部署我们的代码。
VM 配置
在我们的解决方案中,我们有两个 VM 角色:前端角色,负责处理来自终端用户网页浏览器的网页请求,以及后端角色,负责处理来自 Web 应用程序的 REST API 请求。这两个角色有不同的代码和配置,需要进行设置。每个角色都需要自己的 Packer 模板来构建一个 VM 镜像,我们可以用这个镜像在 Google Cloud 上启动 VM:
图 13.11 – 使用 Packer 管道为前端构建 VM 镜像
一个 GitHub Actions 工作流,触发前端应用程序代码和前端 Packer 模板的更改,执行 packer build
并为解决方案的前端创建一个新的 VM 镜像。
前端和后端将有相同的 GitHub 工作流,执行 packer build
。这两个工作流的主要区别在于它们执行的代码库。前端和后端可能有稍微不同的操作系统配置,并且它们分别需要不同的部署包来部署各自的应用组件:
图 13.12 – 使用 Packer 管道为后端构建 VM 镜像
需要注意的是,应用程序代码将被“烘焙”进 VM 镜像,而不是复制到已经运行的 VM 上。这意味着,要更新运行在 VM 上的软件,每个 VM 都需要重新启动,以便用包含最新代码副本的新 VM 镜像重新启动。
这种方法使得 VM 镜像成为一个不可变的部署工件,每次发布需要部署的应用程序代码时,镜像都会进行版本更新和更新。
云环境配置
一旦为前端和后端构建了 VM 镜像,我们就可以执行最终的工作流,该工作流将同时配置并将我们的解决方案部署到 Google Cloud:
图 13.13 – VM 镜像作为 Terraform 代码的输入,用于在 Google Cloud 上配置环境
Terraform 代码库将有两个输入变量,分别表示前端和后端的 VM 镜像版本。当需要部署新版本的应用软件时,这些版本的输入参数将增加,以反映目标版本。当工作流执行时,terraform apply
将简单地用新的 VM 镜像替换现有的 VM。
现在我们已经有了一个明确的计划,如何使用 Google Cloud 来实现云架构,以及如何使用 GitHub Actions 来实现部署架构,让我们开始构建吧!在下一节中,我们将解析我们将用于实现 Terraform 和 Packer 解决方案的HashiCorp 配置语言(HCL)代码。
构建解决方案
现在我们已经有了一个坚实的解决方案设计,我们可以开始构建它了。正如在前一节中讨论的那样,我们将使用由 Google Cloud Compute Engine 提供支持的虚拟机。正如我们在第七章和第十章中分别做的那样,我们需要使用 Packer 将应用程序打包成虚拟机镜像,然后再配置一个使用这些虚拟机镜像的环境。
Packer
在本节中,我们将介绍如何实现 Packer 模板配置器,以便我们能够在 Linux 虚拟机上安装我们的.NET 应用程序代码。如果你由于对 AWS 不感兴趣而跳过了第七章到第九章,我不怪你——特别是如果你阅读本书的主要兴趣是在 GCP 上工作。然而,我建议你查看第七章中的相关部分,看看我们如何使用 Packer 的配置器在基于 Debian 的 Linux 虚拟机上配置.NET 应用程序代码。
Google Cloud 插件
正如我们在第四章中讨论的,Packer – 与 Terraform 类似 – 是一个可扩展的命令行可执行文件。每个云平台都为 Packer 提供了一个插件,封装了与其服务的集成:
packer {
required_plugins {
googlecompute = {
source = "github.com/hashicorp/googlecompute"
version = "~> 1.1.2"
}
}
}
在第七章和第十章中,我们展示了如何声明 AWS 和 Azure 的 Packer 插件(分别)作为必需插件。前面的代码演示了如何声明 Google Cloud 的插件——截至写作时,最新版本是 1.1.2。
Packer 的 Google Cloud 插件提供了一个googlecompute
构建器,该构建器将通过从基础镜像创建新虚拟机、执行配置器、拍摄 Google Cloud 实例启动磁盘的快照,并从中创建 Google Cloud 计算镜像来生成 Google Cloud 计算镜像。像 AWS 和 Azure 插件一样,这一行为被封装在 Google Cloud 的构建器内部。
就像其他插件封装了在各自平台上构建虚拟机的逻辑一样,其配置使用了特定平台的术语。Packer 并不试图在各云平台之间创建一个标准的构建器接口 – 而是将特定平台的配置封装在构建器内部。这使得对于熟悉目标平台的用户来说,操作更为简单,同时允许构建器利用平台特有的功能,而不需要通过在每个平台之间统一语法来增加额外的复杂性。
因此,AWS、Azure 和 Google Cloud 的构建器在几乎每个方面都存在根本差异——从它们的身份验证方式到如何查找市场镜像。虽然它们有一些共同的字段和相似性,但它们实质上是完全不同的:
source "googlecompute" "vm" {
project_id = var.gcp_project_id
source_image = "ubuntu-pro-2204-jammy-v20220923"
ssh_username = "packer"
zone = var.gcp_primary_region
image_name = "${var.image_name}-${var.image_version}"
}
上述代码展示了我们如何引用 Google Cloud 市场版的 Ubuntu 22.04 虚拟机。请注意,与其他提供者不同,后者有相对复杂的查找机制,Google Cloud 只需一个字符串来表示所需的镜像。每种方式都能产生相同的结果:我们选择一个由云平台托管的市场镜像作为我们的启动磁盘,但我们能看到三种不同的云平台展现出不同的组织理念。
操作系统配置
我们必须配置操作系统,以便它安装软件依赖项(例如 .NET 6.0),将我们的应用程序代码的部署包复制并部署到本地文件系统中的正确位置,配置一个在启动时运行的 Linux 服务,并设置一个具有必要访问权限的本地用户和组,以便该服务能够运行。
我在第七章的相应部分详细扩展了这些步骤,因此如果你想刷新记忆,建议查看该部分。
Terraform
正如我们在设计中讨论的那样,我们的解决方案由两个应用组件组成:前端和后端。每个组件都有需要部署的应用代码库。由于这是我们第一次使用google
提供者,我们将首先了解基本的提供者设置以及如何配置后端,然后再深入讨论我们架构中每个组件的细节。
提供者设置
首先,我们需要在required_providers
块中指定所有我们打算在此解决方案中使用的提供者:
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.1.0"
}
cloudinit = {
source = "hashicorp/cloudinit"
version = "~> 2.3.2"
}
random = {
source = "hashicorp/random"
version = "~> 3.5.1"
}
}
backend "gcs" {
}
}
我们还将配置 Google Cloud 提供者。与 AWS 不同,Google Cloud 提供者像 Azure 一样,并不限于某个特定区域。Google Cloud 提供者甚至不需要限定为某个项目。通过这种方式,它非常灵活,可以用于在不同项目和多个区域之间使用相同的提供者声明来配置资源:
provider "google" {
project = var.gcp_project
region = var.primary_region
}
Google 提供者与 AWS 和 Azure 提供者之间的一个主要区别是身份验证的方式。虽然 Azure 和 AWS 有环境变量指定身份,但 Google Cloud 提供者依赖于认证文件,因此这将改变我们的管道工具与 Terraform 集成的方式,以确保 Google Cloud 解决方案具有正确的身份。GOOGLE_APPLICATION_CREDENTIALS
环境变量指定该文件的路径。需要注意的是,这个文件是一个 JSON 文件,但它包含机密信息,因此应当将其视为凭证并加以保护。
后端
因为我们将使用 CI/CD 流水线来长期提供和维护我们的环境,所以我们需要为我们的 Terraform 状态设置一个远程后端。由于我们的解决方案将在 Google Cloud 上托管,我们将使用 Google Cloud Storage 后端来存储 Terraform 状态。
就像 Google Cloud 提供商一样,我们不希望在代码中硬编码后端配置,因此我们将简单地为后端设置一个占位符:
terraform {
...
backend "gcs" {
}
}
我们将在 CI/CD 流水线中运行 terraform init
时,使用 -backend-config
参数来配置后端的参数。
输入变量
传递短名称来标识应用程序的名称和环境是良好的实践。这使得你可以在构成解决方案的资源中嵌入一致的命名约定,从而更容易在 Google Cloud 控制台中识别和跟踪资源。
primary_region
、network_cidr_block
和 az_count
输入变量驱动部署的关键架构特征。它们不应硬编码,因为这样会限制 Terraform 代码库的可重用性。
network_cidr_block
输入变量建立了虚拟网络地址空间,通常会受到企业治理机构的严格控制。通常会有一个流程来确保组织内的各个团队不会使用冲突的 IP 地址范围,从而避免将来无法让这两个应用程序集成,或无法与企业内部共享的网络资源集成。
az_count
输入变量允许你配置在解决方案中所需的冗余度。这将影响解决方案的高可用性,但也会影响部署的成本。正如你可以想象的那样,成本也是云基础设施部署的一个严格受控的特性。
一致的命名和标签
与 AWS 控制台不同,并且与 Azure 非常相似,Google Cloud 的设计方式使得你通过项目非常容易获得应用程序中心的部署视图。因此,为应用程序指定标签并不是像组织策略那样重要。默认情况下,你将获得一个项目中心的视图,查看 Google Cloud 上的所有资源:
resource "google_compute_network" "main" {
...
tags = {
application = var.application_name
environment = var.environment_name
}
}
标记你部署的资源仍然很重要,这些资源可以指示它们属于哪个应用程序和哪个环境。这有助于其他报告需求,如预算和合规性。几乎所有 Google Cloud 提供商中的资源都有一个名为 tags
的 map
属性。像 Azure 一样,每个资源通常都有 name
作为必填属性。
虚拟网络
正如我们在 第七章 和 第八章 中所做的那样,我们需要构建一个虚拟网络,并尽可能将其地址空间保持紧凑,以避免在将来为更广泛的组织吞噬不必要的地址空间:
resource "google_compute_network" "main" {
name = "${var.application_name}-${var.environment_name}"
auto_create_subnetworks = false
}
在 Google Cloud 中创建网络比我们在 AWS 中所做的更简单,因为我们无需根据可用区来划分子网。这种方式类似于 Azure 如何结构化子网以跨可用区展开:
resource "google_compute_subnetwork" "frontend" {
name = "frontend"
region = var.primary_region
network = google_compute_network.main.self_link
ip_cidr_range = cidrsubnet(var.network_cidr_block, 2, 1)
}
负载均衡
正如我们在设计中讨论的,Google Cloud 的负载均衡服务与 AWS 和 Azure 的类似服务结构差异较大。
全局转发规则充当全局负载均衡器的主要入口点:
resource "google_compute_global_forwarding_rule" "frontend" { name = "my-forwarding-rule" ip_protocol = "TCP" port_range = "80" target = google_compute_target_http_proxy.http_proxy.self_link }
然后,它会引用一个目标 HTTP 代理:
resource "google_compute_target_http_proxy" "http_proxy" {
name = "my-http-proxy"
url_map = google_compute_url_map.url_map.self_link
}
随后,这将引用一个 URL 映射:
resource "google_compute_url_map" "url_map" {
name = "my-url-map"
default_service = google_compute_backend_service.backend_service.self_link
}
URL 映射指向一个后端服务,最终定义哪些 Google Cloud 服务将处理请求:
resource "google_compute_backend_service" "backend_service" {
name = "my-backend-service"
port_name = "http"
protocol = "HTTP"
timeout_sec = 10
dynamic "backend" {
for_each = google_compute_instance_group.frontend
content {
group = backend.value.self_link
}
}
health_checks = [google_compute_http_health_check.frontend.self_link]
}
在前面的代码中,你可以看到我们正在将后端连接到健康检查和包含虚拟机的实例组,最终这些虚拟机会处理传入的请求:
resource "google_compute_http_health_check" "frontend" {
name = "${var.application_name}-${var.environment_name}-hc"
port = 5000
request_path = "/"
}
健康检查为平台提供配置,以判断后端服务是否健康,请求会发送到相应后端服务的健康检查端点,以确定该服务是否足够健康以接收传入流量。
网络安全
首先,我们需要为每个应用程序架构组件设置逻辑防火墙。我们将为前端和后端分别设置一个:
resource "google_compute_firewall" "default-hc-fw" {
name = "${var.application_name}-${var.environment_name}-hc"
network = google_compute_network.main.self_link
allow {
protocol = "tcp"
ports = [5000]
}
source_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
target_tags = ["allow-lb-service"]
}
Google Cloud 通常具有一些特定的知名 IP 地址,必须将这些地址包括在你的防火墙规则中,以便它们授予服务之间必要的通信权限。
密钥管理
在第七章中,我们使用 AWS Secrets Manager 设置了密钥,在第八章中,我们在 Microsoft Azure 的 Key Vault 中也做了类似的设置。正如你可能还记得的,在第八章中,Azure Key Vault 是在一个区域内配置的。密钥的创建是在这个上下文中进行的。Google Cloud 的 Secret Manager 服务与 AWS 相似,因为不需要配置逻辑端点来划定密钥的范围。以下代码展示了如何在 Google Cloud Secret Manager 中配置密钥:
resource "google_secret_manager_secret" "db_password" {
secret_id = "db-password-secret"
replication {
automatic = true
}
}
这是一个用于存储密钥的逻辑容器,由于定期的密钥轮换,它的生命周期内可能有许多不同的值。以下代码展示了如何定义密钥的特定版本:
resource "google_secret_manager_secret_version" "db_password_version" {
secret = google_secret_manager_secret.db_password.id
secret_data = "abc1234"
}
这可能是一个我们从其他 Google Cloud 资源中提取的值。以下代码授予服务帐户访问我们在 Google Cloud Secret Manager 中的密钥:
resource "google_secret_manager_secret_iam_member" "secret_iam" {
secret_id = "YOUR_SECRET_ID"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:YOUR_SERVICE_ACCOUNT_EMAIL"
}
虚拟机
在配置静态虚拟机时,我们可以更好地控制每台机器的配置。一些虚拟机具有特定的网络和存储配置,以满足工作负载的需求。
首先,我们将从输入变量中获取虚拟机镜像。这是我们使用 Packer 构建并部署到另一个 Google Cloud 项目的虚拟机镜像:
data "google_compute_image" "frontend" {
name = var.frontend_image_name
}
接下来,我们将在 Google Cloud 实例中创建虚拟机。该资源将包含网络接口、磁盘和服务帐户配置,以设置我们的虚拟机并将其连接到虚拟网络中的正确子网:
resource "google_compute_instance" "frontend" {
count = var.frontend_instance_count
name = "vm${var.application_name}-${var.environment_name}-frontend-${count.index}"
machine_type = var.frontend_machine_type
zone = local.azs_random[count.index % 2]
boot_disk {
initialize_params {
image = data.google_compute_image.frontend.self_link
}
}
// Local SSD disk
scratch_disk {
interface = "NVME"
}
network_interface {
subnetwork = google_compute_subnetwork.frontend.self_link
access_config {
// Ephemeral public IP
}
}
service_account {
# Google recommends custom service accounts that have cloud-platform scope and permissions granted via IAM Roles.
email = google_service_account.main.email
scopes = ["cloud-platform"]
}
tags = ["ssh-access", "allow-lb-service"]
}
然后,我们将通过遍历var.az_count
输入变量来为每个虚拟机创建网络接口:
locals {
zone_instances = { for z in local.azs_random : z =>
{
instances = flatten([
for i in google_compute_instance.frontend :
i.zone == z ? [i.self_link] : []
])
}
}
}
此时,我们可以为每个区域设置实例组:
resource "google_compute_instance_group" "frontend" {
count = var.az_count
named_port {
name = "http"
port = 5000
}
name = "frontend-${count.index}"
zone = local.azs_random[count.index]
instances = local.zone_instances[local.azs_random[count.index]].instances
}
最后,我们将设置虚拟机的所有必要属性,然后将其与网络接口、虚拟机镜像和托管身份关联。
通过这些步骤,我们已经实现了 Packer 和 Terraform 解决方案,并且拥有一个可以为前端和后端应用程序组件构建虚拟机镜像的工作代码库,同时将我们的云环境部署到 Google Cloud 中。在下一节中,我们将深入探讨 YAML 和 Bash,并实现所需的 GitHub Actions 工作流。
自动化部署
正如我们在设计中所讨论的,我们的解决方案由两个应用程序组件组成:前端和后端。每个组件都有一个封装在 Packer 模板中的应用程序代码和操作系统配置。这两个应用程序组件随后被部署到我们 Terraform 代码库中定义的 Azure 云环境中。
就像我们在第七章和第八章中讨论的 AWS 和 Azure 解决方案一样,还有一个额外的代码库需要讨论:我们在 GitHub Actions 上的自动化流水线。
在第七章中,我们讨论了代码库的文件夹结构以及我们的 GitHub Actions 如何适应其中,从而了解到我们的自动化流水线被称为工作流,它们存储在/.github/workflows
中。我们的每个代码库都存储在各自的文件夹中。我们解决方案的源代码仓库的文件夹结构如下所示:
-
.``github
-
workflows
-
dotnet
-
backend
-
frontend
-
-
packer
-
backend
-
frontend
-
-
terraform
-
根据我们的设计,我们将拥有 GitHub Actions 工作流,执行 Packer 并为前端(例如,packer-frontend.yaml
)和后端(例如,packer-backend.yaml
)构建虚拟机镜像。我们还将拥有执行terraform plan
和terraform apply
的工作流:
-
.``github
-
workflows
-
packer-backend.yaml
-
packer-frontend.yaml
-
terraform-apply.yaml
-
terraform-plan.yaml
-
-
在第七章中,我们更详细地讨论了 GitFlow 流程及其如何与我们的 GitHub Actions 工作流交互。因此,现在让我们深入了解这些流水线在面向 Azure 平台时将如何不同。
Packer
在第七章中,我们详细介绍了执行 Packer 以构建虚拟机镜像的 GitHub Actions 工作流的每一步。由于 Packer 具有云平台无关的架构,这部分基本保持不变。唯一的变化是在最后一步,我们执行 Packer。
因为 Packer 需要配置在 Google Cloud 上构建虚拟机,所以我们需要传入一些 Google Cloud 特有的输入变量。这些变量包括 Google Cloud 凭证文件的文件路径、Google Cloud 区域以及 Google Cloud 项目 ID。
就像我们在为 AWS 配置 Packer 模板时使用输入变量一样,我们必须确保所有 Google Cloud 输入变量都以 gcp_
为前缀。如果我们将来想要实现多目标支持,这将非常有帮助,因为许多云平台需要相似的输入,例如目标区域和虚拟机大小。虽然大多数云平台需要类似的输入,但这些输入值是不能互换的。
例如,每个云平台都要求你指定一个区域,以便 Packer 将临时虚拟机提供到该区域,并将生成的虚拟机镜像存储到该区域。在 Google Cloud 上,区域的值为us-west2-a
,正如我们在 Azure 和 AWS 中看到的那样,每个云平台都会有令人恼火的相似且略有不同的区域名称。
Google Cloud 在凭证指定方式上有一个重要的区别。与 AWS 和 Azure 通常使用特定的环境变量来存储上下文和凭证不同,Google Cloud 使用一个文件。因此,在运行 Packer 之前,我们需要确保 Google Cloud 密钥文件已经被放置在一个已知的位置,以便我们的 Packer 操作可以找到它:
- name: Create Secret File
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
# Create a local file with the secret value
echo -n "$GOOGLE_APPLICATION_CREDENTIALS" > gcp.json
GitHub Actions 工作流 YAML 文件对于 Google Cloud 来说是相同的,唯一的区别是需要使用一个输入变量来指定凭证文件的路径——即 gcp.json
:
- id: build
name: Packer Build
env:
GOOGLE_APPLICATION_CREDENTIALS: "gcp.json"
PKR_VAR_gcp_project_id: ${{ vars.GOOGLE_PROJECT }}
PKR_VAR_image_version: ${{ steps.image-version.outputs.version }}
PKR_VAR_agent_ipaddress: ${{ steps.agent-ipaddress.outputs.ipaddress }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
packer init ./
packer build -var-file=variables.pkrvars.hcl ./
上述代码引用了我们从 GitHub Actions 秘密中创建的密钥文件。Packer 的 Google Cloud 插件将使用 GOOGLE_APPLICATION_CREDENTIALS
环境变量加载密钥文件,以便进行 Google Cloud 认证。
Terraform
我们的虚拟机镜像已经构建完成,并且它们的版本已经输入到.tfvars
文件中,Terraform 自动化管道已经准备好接管,不仅可以配置我们的环境,还可以部署我们的解决方案(尽管严格来说,部署是在packer build
过程中完成的)。部署实际上是在packer build
过程中完成的,物理部署包被复制到主目录,Linux 服务已设置并准备就绪。Terraform 通过实际启动虚拟机并使用这些镜像来完成剩余的工作。
在第七章中,我们介绍了执行 Terraform 来配置云环境并部署应用代码的 GitHub Actions 工作流的每一步。由于 Terraform 的云无关架构的特性,这部分几乎保持不变。唯一不同的是最终一步,我们执行 Terraform。
就像我们在第七章和第八章中与 AWS 和 Azure 提供程序一样,我们需要使用特定于google
提供程序的环境变量来设置身份验证上下文。在这种情况下,传递单一的GOOGLE_APPLICATION_CREDENTIALS
属性来连接提供程序,并指定如何与 Terraform 进行身份验证以配置环境:
- name: Create Secret File for Terraform
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
# Create a local file with the secret value
echo -n "$GOOGLE_APPLICATION_CREDENTIALS" > gcp-terraform.json
上述代码生成了 Terraform 所需的密钥文件,以便与 Google Cloud 进行身份验证,从而配置环境。
就像我们在第七章和第八章中与 AWS 和 Azure 提供程序一样,我们需要通过使用-backend-config
命令行参数配合terraform init
命令来配置存储 Terraform 状态的 Google Cloud 特定后端。额外的GOOGLE_BACKEND_CREDENTIALS
参数告知 Terraform 如何与我们用来存储 Terraform 状态的 Google Cloud Storage 后端进行身份验证:
- name: Create Secret File for Backend
env:
GOOGLE_BACKEND_CREDENTIALS: ${{ secrets.GOOGLE_BACKEND_CREDENTIALS }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
# Create a local file with the secret value
echo -n "$GOOGLE_BACKEND_CREDENTIALS" > gcp-backend.json
上述代码生成了 Terraform 所需的密钥文件,以便与 Google Cloud 进行身份验证,从而可以存储和检索环境的 Terraform 状态。
与 AWS 和 Azure 提供程序不同——并且强调 Terraform 后端实现的差异有多大——该后端使用前缀和 Terraform 工作区名称来唯一标识存储状态文件的位置:
- id: plan
name: Terraform Apply
env:
GOOGLE_BACKEND_CREDENTIALS: gcp-backend.json
GOOGLE_APPLICATION_CREDENTIALS: gcp-terraform.json
BACKEND_BUCKET_NAME: ${{ vars.BACKEND_BUCKET_NAME }}
TF_VAR_gcp_project: ${{ vars.GOOGLE_PROJECT }}
working-directory: ${{ env.WORKING_DIRECTORY }}
run: |
terraform init \
-backend-config='bucket='$BACKEND_BUCKET_NAME \
-backend-config="prefix=gcp-vm-sample"
terraform apply -auto-approve
注意到与 Azure 解决方案类似,我们不需要执行有针对性的terraform apply
命令。这是因为我们无需根据区域中的可用性区域数量进行动态计算来配置虚拟网络。
这些云平台之间微妙的架构差异可能会产生根本性的结构变化,即使我们使用相同的技术部署相同的解决方案。这提醒我们,虽然我们在第四章到第六章中学到的核心概念将帮助我们提升到多云视角,但要实现实际的解决方案,我们需要理解每个平台的微妙差异。
摘要
在本章中,我们使用 Google Cloud Compute Engine 提供的虚拟机构建了一个多层次的云架构,并结合完全运作的 GitFlow 流程和使用 GitHub Actions 的端到端 CI/CD 管道。
在下一章,我们的无畏领袖——Söze 企业的首席执行官,将会带给我们一些令人激动的新想法,我们将不得不响应他的号召。原来,我们的 CEO,Keyser,最近熬夜观看了一些关于下一个大热趋势——容器的 YouTube 视频,在与他的小伙伴 Sundar 在超级游艇上讨论后,他决定重构整个解决方案,使其能够在 Docker 和 Kubernetes 上运行。幸运的是,Google 的好心人提供了一项可能对我们有所帮助的服务:Google Kubernetes Engine (GKE)。
第十四章:在 Google Cloud 上容器化 – 使用 GKE 构建解决方案
在上一章中,我们通过利用 Google Compute Engine(GCE)在 Google Cloud 上构建并自动化了解决方案。我们使用 Packer 构建了 虚拟机(VM)镜像,并通过 Terraform 配置了我们的虚拟机。在本章中,我们将采取类似的路径,但我们不再使用虚拟机,而是将重点放在在 Kubernetes 集群中托管我们的应用程序。
为了实现这一目标,我们需要通过放弃 Packer 并用 Docker 替代它来创建可部署的应用程序工件。我们将再次使用 Terraform 的google
提供程序,并重新审视我们在使用 AWS 和 Azure 进行相同操作时所用的 Terraform kubernetes
提供程序。
由于当我们迁移到 Google Cloud 时,大部分内容保持不变,因此我们在本章中不会对这些话题进行重复深入的讨论。然而,我建议你收藏 第八章 并经常参考。
本章涵盖以下主题:
-
打下基础
-
设计解决方案
-
构建解决方案
-
自动化部署
打下基础
我们的故事通过 Söze Enterprises 的视角继续,Söze Enterprises 是由神秘的土耳其亿万富翁 Keyser Söze 创立的。我们的团队一直在努力构建下一代自动驾驶汽车编排平台。之前,我们希望通过利用 Google Cloud 的强大平台、发挥团队现有技能并专注于功能开发,来超越竞争对手。团队刚刚找到节奏时,一个意外的变故突然出现。
在周末,我们神秘的高管受到与 Alphabet 和 Google 母公司 CEO Sundar Pichai 在新加坡会面的影响。据称,Keyser 和 Sundar 一起在 Satay Street 街头品尝街头小吃。在这次简短而愉快的会面中,Sundar 大力赞扬了 Kubernetes 的优点和强大功能,以及 Google 作为开源技术的原始开发者的独特地位。Keyser 被 Kubernetes 所带来的更高效资源利用的前景所吸引,从而改善了成本优化、加快了部署和回滚速度,他深深地被打动了。他的新自动驾驶平台需要利用云的力量,而基于容器的架构正是实现这一目标的方式。因此,他决定加速推进采纳云原生架构的计划!
转向基于容器的架构的消息意味着需要重新评估他们的方法,深入研究新技术,甚至可能需要重新调整团队动态。对于团队来说,容器一直是长期计划,但现在,事情需要加速,这将需要大量的时间、资源和培训投入。
当团队匆忙调整计划时,他们不禁感到一阵兴奋与忧虑的混合情绪。他们知道,在凯泽的领导下,他们正参与一项具有突破性的工作。他对自动驾驶未来的愿景既大胆又具变革性。尽管他的方法可能不同寻常,但他们已经学会了,他的直觉通常是正确的。在本章中,我们将探讨如何利用 Google Cloud 将解决方案从虚拟机转向容器。
设计解决方案
正如我们在前一章中看到的,我们在 Google Cloud 上构建的解决方案,通过我们用 Packer 提供的虚拟机镜像,完全控制操作系统配置。就像我们在第八章和第十一章的 AWS 和 Azure 之旅中所做的那样,我们现在需要引入一个新工具,用容器镜像替代虚拟机镜像——Docker:
图 14.1 – 自动驾驶平台的逻辑架构
我们的应用架构,包括前端、后端和数据库,保持不变,但我们需要通过 Terraform 配置不同的资源,并借助 Docker 和 Kubernetes 的新工具来自动化部署到这一新基础设施上:
图 14.2 – 我们仓库的源代码控制结构
该解决方案将包括七个部分。我们依然拥有前端和后端的应用代码和 Dockerfile(取代基于 Packer 的虚拟机镜像)。我们依然使用 GitHub Actions 来实现 CI/CD 流程,但现在我们有两个 Terraform 代码库——一个用于在 Google Cloud 上配置基础设施,另一个用于将我们的应用部署到托管在 GKE 上的 Kubernetes 集群中。然后,我们有两个代码库,分别用于前端和后端的应用。
云架构
Google Kubernetes Engine(GKE)是一个复杂的产品,它允许你以多种方式提供管理的 Kubernetes 集群,具体取决于你的目标,无论是最大化操作简便性,还是高度自定义的配置。
自动驾驶
在 Google Cloud 上操作 Kubernetes 集群最简单的方式之一是使用 GKE 的 Autopilot 功能。开启 Autopilot 功能可以将 Kubernetes 集群管理中的许多复杂性抽象化。这个选项彻底改变了操作模式,甚至可以说,它更像是其他云提供的基于容器的无服务器选项,而不是我们在前几章中深入探讨的托管 Kubernetes 服务。因此,这超出了本书的范围。然而,如果这种方式对你有吸引力,我建议你在 Google 的文档中进一步研究 (cloud.google.com/kubernetes-engine/docs/concepts/autopilot-overview
)。我之所以提到这一点,是因为与 AWS 和 Azure 提供的独立品牌的服务不同,Google Cloud Platform (GCP) 将这种功能与其托管 Kubernetes 服务结合在一起。
区域集群与单区集群
GKE 支持两种主要的集群类型:区域集群和单区集群。集群类型会影响集群底层物理基础设施在 GCP 中的部署方式,这进而影响 Kubernetes 集群的弹性:
图 14.3 – GKE 区域集群托管控制平面和所有节点在单一可用区内
单区集群部署在给定区域内的单一可用区内。如我们所知,每个区域都有一个名称,例如 us-west1
。为了引用特定的可用区,我们将在区域名称后面加上可用区的编号。例如,要引用西美国 1 区的 A 可用区,我们可以通过其名称来引用——即 us-west1-a
:
图 14.4 – GKE 区域集群在该区域的所有可用区内复制控制平面和节点
区域集群部署在给定区域内的多个可用区中。当你部署区域集群时,默认情况下,集群会在该区域的三个可用区内部署。这种方式可以提高高可用性和弹性,以防某个可用区发生故障。
虚拟网络
正如我们在上一章中讨论的,当我们在 Google Cloud 上设置基于虚拟机的解决方案时,我们需要一个虚拟网络来托管我们的 GKE 集群。这将允许我们配置一个私有的 GKE 集群,使得 Kubernetes 控制平面和节点拥有私有 IP 地址,并且无法直接从互联网访问。
在前一章节中,我们设置了基于 VM 的解决方案,配置了两个子网:一个用于前端,一个用于后端。然而,在使用 Kubernetes 集群托管我们的解决方案时,前端和后端将托管在相同的 Kubernetes 节点上。
这种简单的方法,其中多个节点池共享一个子网,适用于较简单的配置。然而,尽管这种设置简化了网络管理,但由于共享网络资源和地址空间的限制,它可能会限制单个节点池的可扩展性。
对于更具可扩展性和灵活性的架构,特别是在较大或动态变化的环境中,通常会为不同的节点池分配单独的子网。这种方法使得每个节点池可以独立扩展,优化了网络组织,提供了更好的资源分配和隔离。随着 Kubernetes 部署的复杂性和规模的增加,这种结构化的子网划分变得越来越重要,成为 GKE 网络规划和配置中的关键考虑因素。
容器镜像仓库
像我们在本书中深入探讨的其他云平台一样,Google Cloud 也提供了一个强大的容器镜像仓库服务,称为 Google Artifact Registry,它是一个用于托管和管理容器镜像和 Helm 图表的私有仓库。Artifact Registry 除了支持容器镜像之外,还支持许多其他格式,但我们只会在这个功能下使用它。
Google Artifact Registry 的设置方式与其他云提供商非常相似。然而,它与 Azure Container Registry 更为相似,因为它可以托管多个仓库,允许你在同一个 Artifact Registry 中托管多个容器镜像。
负载均衡
GKE 与我们在本书中查看的其他托管 Kubernetes 服务提供商有非常相似的体验。默认情况下,当 Kubernetes 服务被部署到私有集群时,GKE 会自动为该服务配置一个内部负载均衡器。这将使 Kubernetes 服务在虚拟网络内可用,但无法访问外部网络。
这对于我们的后端 REST API 很有效,但对于我们的公共 Web 应用程序则不适用,因为该应用程序旨在可以从公共互联网访问。像在 AWS 和 Azure 上一样,要使前端服务能够访问互联网,我们需要在集群上配置一个入口控制器,并配置一个具有公共 IP 地址的公共负载均衡器,该负载均衡器将流量路由到 GKE 集群上的入口控制器:
图 14.5 – 配置了 NGINX 入口控制器的 GKE 集群,自动化 Google Cloud 负载均衡器
正如我们在前几章中所做的那样,我们将设置一个 NGINX 入口控制器,并配置它以自动配置所需的外部负载均衡器。
网络安全
在使用 GKE 时,网络安全的管理方式类似于第十三章中描述的虚拟机实践,利用 Google Cloud 生态系统中的相似概念和工具。GKE 集群通常部署在虚拟网络中,允许它们与其他 Google Cloud 服务无缝集成。
与其他托管的 Kubernetes 服务类似,虚拟网络作为网络安全的主要边界,GKE 在其中拥有其内部网络,Pods 和服务在该网络内进行通信。Google Cloud 防火墙用于在子网级别定义安全规则,控制进出流量,类似于在虚拟机中使用的方式。
此外,GKE 利用原生 Kubernetes 网络策略对集群内部进行更细粒度的控制,允许管理员定义 Pods 如何相互通信以及如何与虚拟网络中的其他资源通信。这种双层方法将虚拟网络的外部安全控制与 GKE 的内部机制结合起来,为 Kubernetes 部署创建了一个全面且强大的网络安全环境。
工作负载身份
就像我们在前几章中对 AWS 和 Azure 的处理方式一样,我们将设置工作负载身份,使我们的应用程序的 Pods 能够通过 Google Cloud 身份提供者与其他 Google Cloud 服务进行身份验证。这将使我们能够使用内置的基于角色的访问控制,授予 Kubernetes 服务帐户访问其他 Google Cloud 资源的权限。
密钥管理
与其他云平台不同,GKE 没有与 Google Secrets Manager 进行直接集成。相反,你可以选择利用原生 Kubernetes 密钥,或通过应用程序代码本身访问 Google Secrets Manager。这种方式确实具有一些安全优势,但由于它将应用程序与 GCP SDK 紧密耦合,因此并不理想。
Kubernetes 集群
使用 GKE 构建 Kubernetes 集群涉及一些关键决策,这些决策决定了集群的模式。正如我们在本书中讨论的那样,为了与本书中讨论的其他云平台的托管 Kubernetes 服务保持一致,我们将省略使用 Autopilot。所以,我们将专注于构建一个拥有自己虚拟网络的私有 Kubernetes 集群。
与其他托管的 Kubernetes 服务类似,GKE 提供了根据工作负载类型配置节点池的灵活性,但与这些服务不同的是,你无需为运行核心 Kubernetes 服务设置节点池。GKE 会为你处理所有这些!这种抽象极大地简化了集群设计。总体而言,GKE 的简单性和强大的功能集使我们能够以最小的努力构建高度可扩展的 Kubernetes 集群。
部署架构
正如我们在云架构中所看到的那样,在使用 AWS 和微软 Azure 时,我们在第八章和第十一章的工作有许多相似之处。部署架构也将与我们在这些章节中看到的架构相一致。在上一章中,我们看到了在配置 google
提供程序时,Terraform 提供程序的差异,用于将我们的解决方案通过 GCE 部署到虚拟机。
在基于容器的架构中,与我们之前在 AWS 和 Azure 上的部署相比,唯一显著的区别将是我们如何进行容器注册表和 Kubernetes 集群的身份验证。回顾我们在第八章中概述的部署架构方法是非常重要的。在接下来的部分,我们将在 GCP 上构建相同的解决方案,确保我们不会重复相同的信息。
在本节中,我们回顾了从基于虚拟机的架构过渡到基于容器的架构时,架构中的关键变化。我们小心避免重复第八章中的内容,该章节中我们首次在 AWS 平台上进行了这一转型。在接下来的部分,我们将具体实施解决方案,但仍会小心地基于我们在上一章中首次使用虚拟机在 GCP 上搭建解决方案时打下的基础。
构建解决方案
在本节中,我们将把理论知识应用到一个具体且可操作的解决方案上,同时在 GCP 上利用 Docker、Terraform 和 Kubernetes 的强大功能。这个过程中有些部分将需要显著的变化,例如当我们使用 Terraform 配置 Google Cloud 基础设施时;其他部分则会有较小的变化,比如我们用来将应用程序部署到 Kubernetes 集群的 Kubernetes 配置,而有些部分几乎不会有任何变化,例如当我们构建并推送 Docker 镜像到容器注册表时。
Docker
在本节中,我们将详细介绍如何实现我们的 Dockerfile,它安装我们的 .NET 应用程序代码并在容器中运行服务。如果你因为对 AWS 缺乏兴趣而跳过了第七章到第九章的内容,我不能怪你——特别是如果你主要是为了在 GCP 上工作而阅读本书的话。不过,我还是建议你查看第八章中的相关部分,看看我们如何使用 Docker 来配置带有 .NET 应用程序代码的容器。
基础设施
正如我们所知,Terraform 不是一个一次写入、到处运行的解决方案。它是一个高度可扩展的 基础设施即代码 (IaC) 工具,采用定义良好的策略模式来促进多个云平台的管理。这提供了概念上非常相似的解决方案,但在每个云平台的不同实现细节和术语中嵌入了显著的差异。
正如我们在上一节中讨论的,虚拟网络配置基本相同,负载均衡器将通过 GKE 的 NGINX ingress 控制器自动配置。因此,在本节中,我们将只关注需要替换我们的虚拟机(VM)以构建 Kubernetes 集群的新资源。
容器注册表
我们需要的第一件事是一个 Google Cloud Artifact Registry,用于将 Docker 镜像推送到其中。我们将在后续的 Docker 构建过程中使用它,当我们构建并推送 Docker 镜像以供 GKE 集群使用时:
resource "google_artifact_registry_repository" "main" {
project = google_project.main.project_id
location = var.primary_region
repository_id = "${var.application_name}-${var.environment_name}"
format = "DOCKER"
}
服务账户
为了授予我们的应用程序和服务能够隐式地与 Google Cloud 进行身份验证并访问其中托管的其他服务和资源的能力,我们需要设置一个服务账户,并将其与集群上运行的工作负载关联起来。这类似于我们在 AWS 和 Azure 上指定的 IAM 角色和托管身份:
resource "google_service_account" "cluster" {
project = google_project.main.project_id
account_id = "sa-gke-${var.application_name}-${var.environment_name}-${random_string.project_id.result}"
display_name = "sa-gke-${var.application_name}-${var.environment_name}-${random_string.project_id.result}"
}
Kubernetes 集群
这段 Terraform 代码创建了一个带有自定义名称的 GKE 集群——也就是 Google Cloud 区域。location
属性非常关键,因为它的值可以决定集群是区域性的还是按可用区划分的。仅仅将 us-west1
更改为 us-west1-a
就能产生如下效果:
resource "google_container_cluster" "main" {
project = google_project.main.project_id
name = "gke-${var.application_name}-${var.environment_name}-${random_string.project_id.result}"
location = var.primary_region
remove_default_node_pool = true
initial_node_count = 1
}
默认情况下,GKE 将自动配置一个默认节点池。这是一种常见做法,不幸的是,它优先考虑了通过 Google Cloud 控制台提供的图形用户体验,而忽视了基础设施即代码(IaC)体验。这个问题并非 Google Cloud 独有;AWS 和 Azure 也有类似的摩擦点,自动化往往是事后的考虑。结果,我们至少拥有了一些属性,可以让我们绕过这种行为。通过将 remove_default_node_pool
设置为 true
,我们可以确保消除这种默认行为。此外,将 initial_node_count
设置为 1
可以进一步加速这个过程。
正如我们之前讨论的,GKE 抽象化了 Kubernetes 主节点服务,这样我们就不需要担心为这些 Kubernetes 系统组件部署节点池。因此,我们只需要为我们的应用程序和服务定义节点池以运行:
resource "google_container_node_pool" "primary" {
project = google_project.main.project_id
name = "gke-${var.application_name}-${var.environment_name}-${random_string.project_id.result}-primary"
location = var.primary_region
cluster = google_container_cluster.main.name
node_count = var.node_count
node_config {
...
}
}
节点池资源的基本配置将其连接到相应的集群,并指定一个node_count
值。node_config
块用于配置节点池中节点的更多细节。节点池配置应类似于我们在第八章和第十一章中配置 AWS 和 Azure 的托管 Kubernetes 服务时所看到的。节点池有一个计数,控制我们可以启动多少个虚拟机,以及一个虚拟机大小,指定每个节点的 CPU 核心数和内存。我们还需要指定节点池将运行的服务账户:
node_config {
machine_type = var.node_size
preemptible = false
spot = false
service_account = google_service_account.cluster.email
oauth_scopes = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/logging.write",
"https://www.googleapis.com/auth/monitoring"
]
}
在这里,oauth_scopes
用于指定节点应具有的访问权限。为了启用 Google Cloud 日志记录和监控,我们需要添加范围,以允许节点访问这些现有的 Google Cloud 服务。
工作负载身份
要启用工作负载身份,我们需要修改集群和节点池的配置。集群需要定义workload_identity_config
块,并设置workload_pool
,它将使用特定的魔法字符串在集群内配置 GKE 元数据服务:
resource "google_container_cluster" "main" {
...
workload_identity_config {
workload_pool = "${google_project.main.project_id}.svc.id.goog"
}
}
一旦 GKE 元数据服务在集群内可用,我们需要配置我们的节点池,使其通过workload_metadata_config
块与该服务进行集成。我们可以通过将GKE_METADATA
指定为模式来实现这一点:
node_config {
...
workload_metadata_config {
mode = "GKE_METADATA"
}
}
Kubernetes
在第八章和第十一章中,我们分别使用 AWS 和 Azure 上的 Kubernetes Terraform 提供程序构建了 Kubernetes 部署。在这里,我们将沿用相同的方法,基于上一节中配置的基础设施继续构建。
提供程序设置
正如我们在第十一章中看到的,当使用 Kubernetes 提供程序执行 Terraform 以向 Kubernetes 控制平面提供资源时,几乎没有什么变化。我们仍然会对目标云平台进行身份验证,遵循 Terraform 的核心工作流,并传入额外的输入参数,以引用我们需要的特定平台资源。最重要的是,关于集群和其他 GCP 服务的信息,例如 Secrets Manager 等,可能需要放入 Kubernetes 的 ConfigMaps 中,供 Pod 使用并指向其数据库端点。
正如我们在第八章和第十一章中所看到的,当我们在 AWS 和 Azure 上完成相同的任务时,我采用了一种分层的方法,先配置基础设施,然后再部署到 Kubernetes。因此,我们可以使用 Terraform 工作空间中的数据源引用 Kubernetes 集群,该工作空间提供 Google Cloud 基础设施。这使我们能够访问重要的连接详细信息,而无需将其导出到 Terraform 外部并在部署过程中传递。
data "google_container_cluster" "main" {
name = var.cluster_name
location = var.primary_region
}
正如你所见,在前面的代码中,当使用数据源时,我们只需要指定集群名称及其目标区域。使用这个数据源后,我们可以初始化kubernetes
提供程序:
provider "kubernetes" {
token = data.google_client_config.current.access_token
host = data.google_container_cluster.main.endpoint
client_certificate = base64decode(data.google_container_cluster.main.master_auth.0.client_certificate)
client_key = base64decode(data.google_container_cluster.main.master_auth.0.client_key)
cluster_ca_certificate = base64decode(data.google_container_cluster.main.master_auth.0.cluster_ca_certificate)
}
这个配置与我们在之前章节中与 AWS 和 Azure 使用的提供者初始化技术略有不同,增加了 token
。类似于我们如何在其他云平台上初始化 helm
提供者,我们可以传递相同的输入来设置 Helm 提供者。
工作负载身份验证
正如我们在第八章和第十一章中讨论的,在 AWS 和 Azure 上实现工作负载身份验证,我们需要一种方法让我们的 Kubernetes 工作负载能够隐式地与 Google Cloud 的服务和资源进行身份验证。为此,我们需要在 Google Cloud 中设置一个身份,在本章的前一节中已经看到,但我们还需要在 Kubernetes 中设置一些内容,将我们的 Pod 规范连接到 Google Cloud 的服务账号:
resource "kubernetes_service_account" "main" {
metadata {
namespace = var.namespace
name = var.service_account_name
annotations = {
"iam.gke.io/gcp-service-account" = var.service_account_email
}
}
automount_service_account_token = var.service_account_token
}
前面的代码将会配置 Kubernetes 服务账号,完成与我们在前一节中配置的 Google Cloud 配置的连接。
现在我们已经构建了架构的三个组件,在下一节中,我们将继续讨论如何使用 Docker 自动化部署,以便构建和发布容器镜像。我们还将探讨如何使用 Terraform 来实现这一点,以便为 Kubernetes 部署基础设施和解决方案。
自动化部署
在这一节中,我们将看看如何自动化容器化架构的部署过程。我们将采用类似的技术,就像我们在第八章中采取了相同的方法时,这一旅程是在亚马逊上。因此,我们将关注当我们希望部署到 Microsoft Azure 和 Azure Kubernetes 服务时需要做哪些改变。
Docker
在第八章中,我们详细介绍了 GitHub Actions 工作流程的每个步骤,导致 Docker 构建、标记和推送我们的 Docker 容器镜像。由于 Docker 的云无关架构的特性,这一过程基本上保持不变。
唯一变化的是 Google Cloud 将服务账号的凭据封装到一个 JSON 文件中,该文件从 Google Cloud 控制台下载,而不是像 AWS 或 Azure 上的密钥字符串。因此,大部分 Google Cloud 的工具设置是要在特定路径位置查找此文件。
因此,我们需要使用一个特殊的用户名 _json_key
并引用存储在 GitHub Actions 秘密中的 JSON 文件的值:
- name: Login to Google Container Registry
uses: docker/login-action@v3
with:
registry: ${{ needs.terraform-apply.outputs.container_registry_endpoint }}
username: _json_key
password: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
唯一变化的是我们必须配置 Docker 以便它能够对准 Google Artifact Registry。
Terraform
在第十三章中,我们全面讲解了创建一个 Terraform GitHub 动作的过程,该动作使用服务账号在 GCP 上进行身份验证。因此,我们不会进一步深入讨论它。我鼓励您参考第十章以回顾这个过程。
Kubernetes
当我们使用 Terraform 自动化 Kubernetes 时,只需使用不同的根模块再次运行 terraform apply
。这一次,根模块将配置 kubernetes
和 helm
提供程序,除了 google
提供程序之外。然而,我们不会使用 google
提供程序创建新的资源;我们只会获取先前 terraform apply
命令中提供的基础设施的数据源。
因此,执行此流程的 GitHub Action 看起来与我们如何在 Google Cloud 中执行 Terraform 非常相似。一些变量可能会改变,包括容器映像详细信息和集群信息。
摘要
在本章中,我们使用基于容器的架构设计、构建和自动化部署了一个完整的端到端解决方案。我们在 第十三章 的基础上构建,那里我们处理了 Google Cloud 网络的基础设施,但在 GKE 上承载我们的应用程序容器。在我们的 GCP 旅程的下一步和最后一步中,我们将探讨无服务器架构,从而超越底层基础设施,让平台本身将我们的解决方案推向新的高度。
第十五章:在 Google Cloud 上实现无服务器计算 – 使用 Google Cloud Functions 构建解决方案
我们快要完成了!在本章中,我们将构建本书中将要构建的九个解决方案中的最后一个。我们即将关闭 Google Cloud 的大门——但只有在我们完成将应用程序迁移到无服务器架构的最后一步之后,就像我们在 AWS 和 Azure 上所做的那样。在前两章中,我们努力使用虚拟机和容器在 Google Cloud 上实现了解决方案。
我们花时间对比了三大云平台之间的工作方式,帮助我们理解它们之间的微妙差异,有时候甚至是那些不那么微妙的差异。
我们注意到,虽然我们的 Terraform 代码在不同的云平台之间发生了持续变化,但我们的应用程序代码和操作系统配置——无论是在 Packer 还是 Docker 中——并没有发生变化。当我们完成在 Google Cloud 上的最后一步时,我们将经历与当初将应用程序迁移到 AWS Lambda 和 Azure Functions 时类似的过程。我们将不得不完全重构应用程序代码。
本章涵盖以下主题:
-
奠定基础
-
设计解决方案
-
构建解决方案
-
自动化部署
奠定基础
我们经验丰富的团队刚刚完成了 Kubernetes 配置中最后一个 ConfigMap 的最终调整,接到了一通不那么意外的电话。看起来 Keyser 似乎又有了灵感,这次是在与 Larry Page 一起闲逛时。
在走过 Larry 的私人终端,前往他等待的个人波音 767 时,Keyser 和 Larry 讨论了 Keyser 的新业务。Larry 顺便提到,“Keyser,你为什么要投资基础设施呢?现在大家都在做无服务器计算。专注于你的平台,让 Google Cloud 专注于如何 扩展它。”
“尤里卡!” Keyser 大叫道,嘴里塞了一些温暖的腰果后接着说道,“哦天啊,Larry,你说得太对了!我需要立即让我的团队着手这个!我当时在想什么呢?我们没有时间做管道工作;我们需要快速行动,保持领先于 竞争对手!”
回到总部,团队正在适应这个激动人心却又突如其来的方向变化。现在,得益于 Keyser 大胆的新战略,他们正准备深入探索无服务器计算。这一转变不仅仅需要重新包装应用程序——他们必须完全重构代码!
设计解决方案
在本节中,我们将考虑解决方案的整体设计,考虑到从虚拟机和基于容器的架构转向无服务器架构的变化。正如我们在之前的转型中所见,无服务器的核心目标是从堆栈中去除繁重的基础设施。因此,我们将寻找替代方案,剔除任何需要显著固定成本的 Google Cloud 服务,例如虚拟机或Kubernetes 集群,并用无服务器选项替代。我们操作环境和技术架构的这种变化可能会让我们重新思考解决方案的一些方面,包括其设计、实现和部署策略:
图 15.1 – 自动驾驶平台的逻辑架构
尽管我们应用的架构没有发生显著变化,但我们将使用不同的 Google Cloud 服务来托管它。在这种情况下,我们将使用 Google Cloud Storage 来托管应用的前端,并使用 Google Cloud Functions 来托管应用的后端,如图 15.2所示:
图 15.2 – 我们仓库的源代码控制结构
在这个解决方案中,我们将有四部分代码库:用于配置环境的 Terraform 代码、执行部署过程的 GitHub Actions 代码,以及应用前端和后端的两部分代码库。
云架构
在第十三章中,我们的云托管解决方案是一组专用的虚拟机,而在第十四章中,它是我们 Kubernetes 集群节点池中的一组共享虚拟机。使用虚拟机会产生最大程度的沉没成本,无论它们是独立的虚拟机还是 Kubernetes 节点池的一部分。
在第十四章中,我们的整个解决方案是在容器上执行的,这些容器使前端和后端能够作为一组容器共存在同一虚拟机上。这节省了一些费用,但我们仍然需要服务器来托管工作负载。在这一章中,我们有一个新的目标:通过利用云原生服务来发挥云计算的优势,这些服务将底层基础设施从我们身上抽象出来,让我们真正只为我们使用的部分付费。Google Cloud 的无服务器服务将在这个过程中对我们至关重要。
前端
在前面的章节中,我们将前端托管在面向公众的服务器上,这些服务器返回组成我们 Web 应用的 HTML 和 JavaScript,我们仍然需要一个云托管的解决方案来托管文件并响应请求。
然而,由于网页应用在最终用户的浏览器中运行的特性,我们实际上不需要使用云托管的虚拟机来托管本质上是平面文件的内容。我们可以使用简单的云存储来托管前端作为静态网站,并依赖云平台来承担返回网页内容的工作。
我们可以在 Google Cloud 上使用 Google Cloud Storage 服务。该服务允许我们托管可以通过互联网访问的静态网页内容。正如我们在前几章中对 AWS 和 Azure 所做的那样,所有这些功能通过添加一个存储桶并启用其托管网页内容来实现。然而,与我们在 AWS 和 Azure 上处理的方式不同,我们需要添加自己的负载均衡器,以确保我们的网页应用正常运行,正如图 15.3所示:
图 15.3 – Google Cloud Storage 处理网页请求,而 Google Cloud Functions 处理 REST API 请求
正如我们在其他平台上看到的,我们将获得巨大的优势,因为 Google Cloud Storage 完全没有沉没成本。当你创建一个 Google Cloud Storage 存储桶时,每月的费用为零美元($0)。像其他无服务器服务一样,它通过一系列微交易来衡量你的活动,并按你实际使用的量收费。在 Google Cloud Storage 中,这可能有些复杂,因为有几个衡量指标会产生费用。
表 15.1 显示了你在使用 Google Cloud Storage 托管静态网站时可能遇到的所有费用:
度量 | 单位 | 规模 | 价格 |
---|---|---|---|
存储 | GB | 1,000 | $0.023 |
写入事务 | 事务 | 1,000 | $0.01 |
读取事务 | 事务 | 1,000 | $0.0004 |
表 15.1 – Google Cloud Storage 微交易定价
注意
在本文撰写时,所列出的价格适用于 Google Cloud 的西部美国 2 区域。到你阅读本文时,价格可能已经发生变化,因此最好查看最新价格以获取最准确的成本估算。
我列出这些价格是为了强调一个观点。我们可以在一个三节点的 Kubernetes 集群上托管一个静态网站,每月大约需要 $300,或者我们可以在 Google Cloud Storage 上托管一个静态网站,每月费用不到 $0.01,并且使用的是 Google Cloud 提供的最稳定的存储层。你会选择哪种方式?
后端
与我们的前端一样,在前几章中,我们的后端也托管在虚拟机上,采用两种不同的方式:专用虚拟机和在 Kubernetes 集群节点池中的共享虚拟机。
与我们的前端不同,我们的后端不能完全在最终用户的 Web 浏览器内以客户端方式运行。在后端,我们有需要在服务器上运行的自定义代码。因此,我们需要找到一种解决方案来托管这些组件,而不需要使用一整套虚拟机带来的额外开销。在 Google Cloud 上,我们可以使用 Google Cloud Functions 来实现这一点。Google Cloud Functions 是一种托管服务,允许你部署代码而无需支付任何基础虚拟机的沉没成本。像 Google Cloud Storage 一样,它有自己的微交易定价模型,根据你实际使用的量收费。
表 15.2 显示了将代码部署到 Google Cloud Functions 时可能产生的费用:
指标 | 单位 | 规模 | 价格 |
---|---|---|---|
计算 | GHz/s | 1 | $0.00001 |
内存 | GB/s | 1 | $0.0000025 |
总执行次数 | 交易 | 1,000,000 | $0.40 |
表 15.2 – Google Cloud Functions 微交易定价
你可能首先注意到的是,像 Google Cloud Storage 一样,这些价格非常低,但它们衡量的是平台上非常小的一部分活动。
例如,计算和内存指标的单位对应于该资源每秒的度量单位。计算指标的单位是每秒 GHz,内存指标的单位是每秒 GB。这些度量单位使你能够在执行时灵活调整云函数可以访问的计算和内存资源量。由于它是以每秒为间隔进行度量的,因此你无需长时间运行 Google Cloud Functions,就能产生相当多的费用。图 15.4 展示了 Google Cloud Functions 将应用程序代码部署到 Google Cloud Storage:
图 15.4 – Google Cloud Functions 运行应用程序代码,该代码被部署到 Google Cloud Storage
以前,我们的 ASP.NET REST API 是通过传统的 ASP.NET 项目设置的,该项目使用控制器实现 REST API 端点。然而,在过渡到 Google Cloud Functions 时,这种解决方案结构与 Google Cloud Functions 框架不兼容。为了能够将我们的 REST API 托管为 Google Cloud Functions,我们需要遵循 Cloud Functions 所规定的框架。这意味着,ASP.NET 控制器类需要重构以符合这一标准。在下一节中,我们将深入探讨实现这一点的代码。
部署架构
现在我们已经对在 Google Cloud 上的解决方案的云架构有了清晰的了解,我们需要制定一个计划来配置环境并部署代码。
在第十二章中,当我们将应用程序部署到虚拟机时,我们使用 Packer 将编译后的应用程序代码打包成虚拟机镜像:
图 15.5 – 使用 Packer 构建的虚拟机镜像进行虚拟机部署过程
同样,在第十三章中,当我们将应用程序部署到 Kubernetes 集群上的容器时,我们使用 Docker 将应用程序代码打包成容器镜像:
图 15.6 – 使用 Docker 构建的容器镜像进行 Kubernetes 部署过程
使用无服务器架构时,情况完全改变,因为 Google Cloud 的无服务器服务抽象化了操作系统。这意味着我们需要负责的仅仅是生成兼容的部署包。
创建部署包
如前一节所述,我们的应用程序有两个组件:前端和后端。它们有不同的部署目标。对于前端,我们将作为静态网站进行部署,而后端将作为 Google Cloud 函数进行部署。由于这两者都是.NET 项目,我们将使用.NET 和 Google Cloud 平台特定的工具来创建部署包并将其部署到目标 Google Cloud 服务。下图展示了我们为配置环境、打包应用程序代码并将其部署到 Google Cloud 目标环境的整个过程:
图 15.7 – 构建我们的.NET 应用程序代码并部署到 Google Cloud 的部署管道
对于前端,这意味着启用将我们的 ASP.NET Blazor Web 应用程序部署为 WebAssembly 的功能。这将允许前端作为静态网站托管,完全在客户端运行,而无需任何服务器端渲染。之所以能够实现这一点,是因为我们设计前端 Web 应用程序的方式,它使用 HTML、CSS 和 JavaScript 与服务器端 REST API 进行交互。需要注意的是,ASP.NET Blazor 支持两种托管选项,但我们特别选择了仅客户端路径,并消除了对服务器端页面渲染的任何依赖。因此,当我们使用.NET CLI 发布我们的 ASP.NET Blazor 项目时,它将生成一个包含静态 Web 内容的文件夹。然后,使用 Google Cloud CLI,我们可以将该文件夹的内容上传到我们的 Google Cloud Storage 桶中以完成部署,如图 15.8所示:
图 15.8 – 使用自定义构建部署包部署 Google Cloud Functions 的过程
对于后端,与 AWS 和 Azure 不同,Google Cloud 上的应用代码不应该被编译,因为它需要由 Google Cloud Functions 处理。这意味着需要上传实际的源代码文件,而不是以前我们做过的已编译的构件。因此,我们必须将源代码文件夹压缩为 ZIP 归档文件。另一个主要的不同之处在于,Google Cloud 的 Terraform 提供程序要求此 ZIP 归档文件由 Terraform 上传:
图 15.9 – GitFlow 流程创建新的版本化构件
这个过程将与第六章中讨论的 GitFlow 流程很好地结合。在我们开发每一个新特性时,我们将打开一个新的特性分支,当我们准备好将更新合并到主要工作中时,我们将提交一个拉取请求:
图 15.10 – GitFlow 流程创建新的版本化构件
这个拉取请求将触发 GitHub Actions,运行内置的质量检查,检查我们的应用代码,并运行terraform plan
来评估对我们长期运行环境的影响。在代码合并之前,我们可以进行任意数量的测试,这有助于验证我们的更新——无论是应用代码还是基础设施代码——都不会对目标环境产生负面影响。一旦我们的拉取请求被批准并合并,它将触发额外的 GitHub Actions,应用更改到目标环境。
现在我们已经有了一个完善的计划,来实现使用 Google Cloud Platform 的云架构和使用 GitHub Actions 的部署架构,让我们开始构建吧!在下一节中,我们将分解我们用来实现 Terraform 的HashiCorp 配置语言(HCL)代码,并查看我们需要对应用代码进行的更改,以便通过 Google Cloud Functions 使我们的应用程序上线。
构建解决方案
现在我们已经有了一个坚实的解决方案设计,我们可以开始构建它。正如我们在前一节中讨论的,因为我们将使用 Google Cloud 的无服务器服务(如 Google Cloud Storage 和 Google Cloud Functions)来托管我们的应用,因此我们需要对应用代码做一些更改。这是我们在第十三章和第十四章中从未遇到过的事情,因为我们曾通过将应用打包成虚拟机镜像(使用 Packer)或容器镜像(使用 Docker)来部署我们的应用到云中。因此,为了构建我们的解决方案,我们需要编写一些 Terraform 代码,并对我们的应用代码进行 C#更新。
Terraform
如我们在设计中讨论的那样,我们的解决方案包括两个应用组件:前端和后端。每个组件都有自己的应用代码库需要部署。不同于之前的章节,我们不再需要操作系统配置,因为现在我们正在使用无服务器服务,平台会替我们处理这部分:
图 15.11 – 谷歌云函数资源结构
大部分的 Terraform 设置与之前的章节非常相似,因此我们只关注解决方案所需的新资源。如果您希望使用完整的解决方案,请查看本书的完整源代码,这些代码可以在 GitHub 上找到。
前端
正如我们在之前的章节中看到的,在使用谷歌云时,我们需要激活所需的谷歌 API 来为新项目提供资源。对于前端,我们主要将使用谷歌云存储,但我们还需要 compute.googleapis.com
API。
首先,我们需要为前端部署一个谷歌云存储桶。但是,我们需要使用一个名为 website
的可选块来不同配置我们的谷歌云存储桶,以启用静态网站功能:
resource "google_storage_bucket" "frontend" {
project = google_project.main.project_id
name = "${var.application_name}-${var.environment_name}-frontend-${random_string.project_id.result}"
location = "US"
website {
main_page_suffix = "index.html"
not_found_page = "404.html"
}
cors {
origin = ["*"]
method = ["GET", "HEAD"]
response_header = ["Authorization", "Content-Type"]
max_age_seconds = 3600
}
}
为了允许匿名的互联网流量访问存储桶中存储的内容,我们需要与身份和访问管理服务设置绑定。这将授予 allUsers
访问权限以查看存储桶内的对象:
resource "google_storage_bucket_iam_binding" "frontend" {
bucket = google_storage_bucket.frontend.name
role = "roles/storage.objectViewer"
members = [
"allUsers"
]
}
在之前的章节中,我们已经设置了谷歌云负载均衡,该负载均衡器作为前端并允许配置多种不同类型的后端:
图 15.12 – 谷歌云负载均衡将流量路由到托管在谷歌云存储上的前端
在这种情况下,负载均衡器的后端变得非常简单;它只是一个谷歌云存储桶:
resource "google_compute_backend_bucket" "frontend" {
project = google_project.main.project_id
name = "${var.application_name}-${var.environment_name}-frontend-${random_string.project_id.result}"
bucket_name = google_storage_bucket.frontend.name
enable_cdn = true
}
谷歌云存储桶需要设置为负载均衡器的后端,这将允许流量路由到适当的位置。
后端
我们的后端将托管在谷歌云函数上,因此我们需要启用 logging.googleapis.com
,以便从谷歌云控制台访问谷歌云函数的遥测数据。
正如我们在前一节讨论的那样,谷歌云函数要求我们上传源代码,而不是编译后的工件;这是由于谷歌云函数处理我们应用程序包装的方式所造成的。因此,这创建了对 cloudbuild.googleapis.com
的依赖,谷歌云函数使用它来基于我们上传的源代码创建打包工件。
为了使我们的 Google Cloud Functions 执行,我们需要两个额外的 Google API:Cloud Run API(即 run.googleapis.com
)和 Cloud Functions API(即 cloudfunctions.googleapis.com
)。Google Cloud Functions 是建立在 Cloud Run API 之上的一层,提供了额外的抽象层和更多功能来创建事件驱动的工作流,而 Cloud Run API 提供了一个基础服务来运行无状态容器,这些容器可以通过 HTTP 请求进行调用。
Google Cloud Functions 拥有相对简单的部署模型。像 AWS Lambda 一样,您必须声明一个资源来为函数本身提供支持。该资源有两个主要的配置组件——构建配置和服务配置——如下所示:
resource "google_cloudfunctions2_function" "backend" {
project = google_project.main.project_id
name = "func-${var.application_name}-${var.environment_name}-backend-${random_string.project_id.result}"
location = var.primary_region
description = "a new function"
}
构建配置控制执行运行时的类型(例如,Python、Java 或 .NET)、应用程序代码中的入口点,以及可以找到应用程序代码的存储位置:
build_config {
runtime = "dotnet6"
entry_point = "FleetAPI.Function"
source {
storage_source {
bucket = google_storage_bucket.backend.name
object = google_storage_bucket_object.deployment.name
}
}
}
服务配置控制云函数在被调用时可以访问多少资源。因此,这个配置也是成本的主要驱动因素:
service_config {
max_instance_count = 1
available_memory = "256M"
timeout_seconds = 60
}
服务配置块还允许你设置环境变量,供云函数用来传递非敏感的配置设置:
service_config {
...
environment_variables = {
SERVICE_CONFIG_TEST = "config_test"
}
}
密钥管理
正如我们在前几章看到的,我们只有在启用了 secretmanager.googleapis.com
API 后,才能使用 Google Cloud Secrets Manager 配置密钥。
首先,我们需要定义一个带有唯一密钥标识符的密钥,以便我们可以从应用代码中查找密钥的值。如果我们正在构建多区域部署,我们还可以设置希望将此密钥复制到的区域:
resource "google_secret_manager_secret" "sauce" {
secret_id = "sauce"
replication {
user_managed {
replicas {
location = var.primary_region
}
}
}
}
正如我们在前面章节中看到的 aws
提供程序,密钥只是一个占位符,一种查找密钥值的独特方式。我们需要创建密钥的版本来存储实际的密钥值:
resource "google_secret_manager_secret_version" "sauce" {
secret = google_secret_manager_secret.secret.name
secret_data = "secret"
enabled = true
}
在配置好密钥和密钥版本后,我们可以从 Google Cloud Functions 中访问它。注入密钥到云函数中有两种方法;第一种是使用环境变量:
secret_environment_variables {
key = "sauce"
project_id = google_project.main.project_id
secret = google_secret_manager_secret.sauce.secret_id
version = "latest"
}
前面的代码演示了我们如何将一个密钥添加到云函数的服务配置块中,利用密钥的标识符将存储在 Google Secret Manager 中的密钥注入到云函数中。
第二种方法可能更安全,因为它避免了将密钥暴露在进程的环境中:
secret_volumes {
mount_path = "/etc/secrets"
project_id = google_project.main.project_id
secret = google_secret_manager_secret.secret.secret_id
}
前面的代码演示了如何在文件系统中设置挂载点,并使用密钥的标识符将密钥值存放在那里。
应用程序代码
Google Cloud Functions 天生是基于事件的。每个云函数都由来自多种 Google Cloud 服务的不同类型事件触发。为了本书的目的,我们将仅关注 HTTP 触发器,但如果你有兴趣,我建议你查看 Google Cloud Functions 提供的所有其他选项——它们非常广泛:
图 15.13 – ASP.NET MVC 控制器类结构
在传统的 ASP.NET REST API 解决方案中,你会有控制器类,它代表一个特定的路由,然后方法在该路由上实现不同的操作。控制器类需要用 ApiController
属性修饰,以告知 ASP.NET 运行时该类应该用于处理指定 Route
属性中路由的传入 Web 请求。
每个方法都用一个属性修饰,表示该方法应该响应哪种 HTTP 动词。在前面的示例中,我们使用 HttpGet
,但也有对应的属性用于每个支持的 HTTP 动词。方法可以接收强类型参数,这些参数可以是路由的一部分、查询字符串的一部分或请求体的一部分。方法默认返回 IActionResult
,这使我们能够根据请求的结果返回不同的数据结构。
为了使用 Azure Functions 实现一个 REST API,我们需要使用 Azure Function SDK 来实现一个类。这要求我们稍微调整实现类和方法的方式。我们将采用不同的类和方法属性,以便实现类似的结果,定义一个端点,在特定路由下响应 Web 请求:
图 15.14 – Google Cloud Functions 类结构
Google Cloud Functions 有一个非常简单的集成底层云服务驱动运行时的方法。唯一的要求是实现 IHttpFunction
接口。该接口只有一个要求,即实现一个名为 HandleAsync
的方法,该方法以 HttpContext
对象作为唯一参数。没有返回对象。因此,我们唯一能响应客户端的方式是通过向可以从 HttpContext
对象访问的响应对象写入数据。
如我们所见,云架构大大简化了,但一个权衡是我们的后端代码需要适应 Google Cloud Functions 框架。这将需要开发和测试工作,以便将我们的代码库转变为这种新的托管模型。这与我们在前几章中探索的内容形成鲜明对比,前几章我们是在虚拟机上托管,或将应用容器化并托管在 Kubernetes 集群上。虽然遵循 Google Cloud Functions 模型确实需要付出一些努力,但它的好处是双重的。首先,它让我们能够利用几乎为零的沉没成本;其次,它使我们能够完全抽象掉底层基础设施,由 Google Cloud Platform 负责可扩展性和高可用性。这使我们可以更多地专注于解决方案的功能性,而不是管理底层基础设施的繁杂工作。
现在,我们已经实现了 Terraform 来配置我们的解决方案,并修改了应用程序代码以使其符合 Google Cloud Functions 框架。在下一节中,我们将深入探讨 YAML 和 Bash,并实现 GitHub Actions 工作流。
自动化部署
正如我们在前一节中讨论的那样,像 Google Cloud Functions 和 Google Cloud Storage 这样的无服务器产品抽象了操作系统配置。因此,当我们进行部署时,只需要一个与目标平台兼容的应用程序包。在本节中,我们将使用 GitHub Actions 创建一个自动化管道,将我们的应用程序部署到 Google Cloud 的全新无服务器环境中。
Terraform
我们需要做的第一件事是将我们的环境配置到 Google Cloud。这将与我们在前几章中做的非常相似。在第十三章中,我们需要确保在执行 Terraform 之前,我们的虚拟机镜像已经构建并可用,因为 Terraform 代码基在配置虚拟机时引用了这些虚拟机镜像。这意味着,采用虚拟机架构时,应用程序部署发生在 Terraform 配置环境之前,如图 15.15所示:
图 15.15 – Packer 生成的虚拟机镜像是 Terraform 的先决条件
在第十四章中,我们使用Google Kubernetes Engine (GKE)来配置我们的 Kubernetes 集群,且没有这样的先决条件。实际上,应用程序部署是在 Kubernetes 集群上线后进行的。这意味着,采用基于容器的架构时,应用程序部署是在 Terraform 配置环境后进行的:
图 15.16 – Docker 生成的容器镜像在 Terraform 执行后被配置到 Kubernetes
当使用 Google Cloud 的无服务器产品时,部署过程被拆分。虽然我们的应用程序的前端和后端都需要创建部署包,但它们的部署方式是不同的。对于前端,像在其他平台一样,我们只是生成静态网页内容。然而,针对后端,由于 Google Cloud Functions 在打包和部署方面的独特方法,我们需要生成一个包含应用程序源代码的 ZIP 归档文件。这些工件与 Docker 镜像的用途相似,它们是符合目标服务的应用程序打包方式,以便部署,如图 15.17所示:
图 15.17 – 包含源代码的 ZIP 存档作为部署文件,在 Terraform 执行时被部署到 Google Cloud
如你所见,后端部署与基于虚拟机架构的方法非常相似。Terraform 代码引用打包好的部署文件,并负责将其部署到它所配置的 Google Cloud Functions 中。
部署
既然 Terraform 已经为我们的无服务器解决方案配置好了所需的 Google Cloud 基础设施,接下来我们需要完成最后一步,将部署文件部署到 Google Cloud 的适当位置。
我们将使用 .NET 和 Google Cloud 自定义工具生成部署文件并部署前端。然而,后端将由 Terraform 配置。
前端
正如我们在其他章节中所见,我们的 .NET 应用程序代码需要遵循持续集成过程,在该过程中代码会通过自动化单元测试和其他内建的质量控制进行构建和测试。这里没有变化,唯一的不同是我们需要对这些流程生成的部署文件进行一些特别处理,以确保它能够供负责将工作负载部署到适当位置的 GitHub Actions 作业使用:
- name: Upload to Google Cloud Storage Bucket
working-directory: ${{ env.DOTNET_WORKING_DIRECTORY }}/upload-staging
run: |
gsutil -o Credentials:gs_service_key_file=../gcp-terraform.json -m cp -r . gs://${{ needs.terraform.outputs.frontend_bucket_name }}
我们需要确保与 Google Cloud 进行身份验证,并将目标设置为正确的 Google Cloud 项目以及正确的 Google Cloud Storage 存储桶。我们使用的 Google Cloud 命令行工具叫做 gsutil
。它可以通过多种方式进行配置以获取凭证,但最安全的做法是指定 Google Cloud 凭证文件的路径。我们可以使用 GitHub Actions 密钥生成一个文件,然后在调用 gsutil
时引用该文件。完成后,我们可以执行 gsutil
来递归上传暂存目录中的所有文件。
后端
为了部署 Google Cloud function,我们需要修改我们的 Terraform 配置,以便为要上传的 ZIP 存档指定位置,并指定包含应用程序源代码的 ZIP 存档:
resource "google_storage_bucket" "backend" {
project = google_project.main.project_id
name = "${var.application_name}-${var.environment_name}-backend-${random_string.project_id.result}"
location = "US"
}
在 Google Cloud Storage 存储桶配置完成后,我们必须上传部署包:
resource "google_storage_bucket_object" "deployment" {
name = "deployment.zip"
bucket = google_storage_bucket.backend.name
source = "deployment.zip"
}
上述代码将引用 Terraform 根目录中的 deployment.zip
文件,并将其上传到 Google Cloud Storage 存储桶。
就这样!现在,我们的应用程序已经完全部署到 Google Cloud Functions!
总结
在本章中,我们设计、构建并自动化了一个完整的端到端解决方案,使用了基于 Google Cloud Functions 的无服务器架构。为了实现这一目标,我们最终不得不对应用程序代码进行一些重大更改,以符合无服务器运行时的要求。在采用无服务器产品时,必须做出这个明确且重要的决定,因为它将你的应用程序代码与目标云平台紧密耦合。
在我们结束本章以及以 Google Cloud 为中心的叙述时,我们已经成功地在三个不同的云平台上实现了云架构——亚马逊 Web 服务(AWS)、微软 Azure 和 Google Cloud Platform。
在与神秘的 CEO Keyser Söze 一起的旅程中,我们看到许多从一个云平台到另一个云平台的相似之处,但我们也看到各云平台之间的显著差异,从小的命名约定差异、设计和实现变动,到整个云平台分类法中的大规模结构性变化。除了探索这三个云平台外,我们还见证了许多组织在云迁移过程中所面临的挑战——是坚持使用他们熟悉的技术,还是跳入新功能和服务,这些新服务虽然面临学习曲线带来的挑战,但也为简化运营并更好地利用公共云的规模经济提供了潜在的机会。
在下一章,我们将转换话题,探讨当我们不是从零开始,而是尝试将现有环境和架构适配到基础设施即代码(Infrastructure-as-Code)世界时所面临的独特挑战。
第六部分:第 2 天操作及以后
在本部分中,我们将探讨使用 Terraform 时,操作现有环境时的挑战和常见陷阱,既包括导入最初通过 Terraform 之外的方式创建的现有环境,也包括使用 Terraform 长期管理环境。
本部分包括以下章节:
-
第十六章,已经部署?导入现有环境的策略
-
第十七章,使用 Terraform 管理生产环境
-
第十八章,展望未来——认证、 emerging trends 和下一步
第十六章:已经配置过?导入现有环境的策略
本书前九章主要集中于如何使用多种云计算范式,在多个云平台上实现新的云架构。现在,我们将稍微调整一下方向,重点讨论如何处理现有环境。不幸的是,有时(实际上是很多时候),你所管理的云环境并不是最初通过基础设施即代码(IaC)使用 Terraform 进行配置的。它们可能是通过其他工具配置的,甚至是手动配置的,现在你正试图使用 Terraform 整合你的云操作。
本章涵盖以下主题:
-
导入单个资源
-
识别需要导入的资源
-
导入现有环境
-
最佳实践
导入单个资源
Terraform 支持两种导入资源到状态中的方式。一种方式本质上是命令式和程序化的,通常在 GitOps 流程之外,使用 Terraform 的命令行界面(CLI)执行。还有另一种更新的方式,允许我们在代码中声明导入操作,并按照标准的 GitFlow 流程将这些更改推送到生产环境。
导入命令
import
命令允许你导入一个已经通过其他方式(而非 Terraform)配置好的现有资源:
terraform import [options] ADDRESS ID
Terraform import
命令(developer.hashicorp.com/terraform/cli/commands/import
)有两个关键参数。各种选项超出了本书的范围。我建议你查阅文档,了解所有可用选项的详细信息。
第一个参数,即 Terraform 代码库中资源的地址,非常关键。它是我们用来访问 Terraform 工作区中资源的相同引用。与我们在 HashiCorp 配置语言代码库中工作时不同,我们不再受到当前 Terraform 模块范围的限制。该地址遵循你的 Terraform 提供者的命名约定。例如,你需要资源类型和对象引用才能导入一个虚拟机。
第二个参数是资源在目标云平台上的唯一标识符。不同云平台之间,这个唯一标识符会有很大不同。在接下来的部分中,我们将看到每个云平台之间的差异。
import
命令非常适合用于在terraform apply
过程中由于临时问题失败的单个资源。如果你需要导入整个解决方案,那么为每个资源编写一个import
命令将会非常繁琐。即使是一个简单的虚拟机,也可能包含十多个资源。
导入块
import
命令很有用且可用,但它需要通过命令行通过人工操作来对你的 IaC 代码库进行更改。导入块是在 Terraform 1.5.0 版本中引入的,它允许通过源代码更改来完成这些更改,这对于维持 GitFlow 流程至关重要。这反过来是 GitOps 模型的关键组成部分。
与使用 Terraform CLI 执行命令不同,你需要在代码库中嵌入一个像这样的导入块:
import {
to = ADDRESS
id = ID
}
它看起来与import
命令的参数非常相似,但它利用你执行 Terraform 时所处的现有上下文。它还使用 HashiCorp 配置语言(HCL)来定义导入操作。
这种技术不仅允许我们将状态管理操作作为 GitOps 流程的一部分进行,还简化了这个过程。导入资源只需要两个拉取请求:第一个是引入我们希望导入的资源的导入块,第二个是在成功执行Terraform Apply
后删除导入块,当资源被导入到 Terraform 状态中时。
导入多个资源
Import
命令和导入块支持使用for_each
和count
元参数导入资源。
要导入通过for_each
块配置的资源,你只需要定义一个包含你希望导入的资源唯一标识符的map
:
locals {
resources = {
"zone1" = "ID-for-zone1"
"zone2" = "ID-for-zone2"
}
}
导入块的唯一标识符将来自你定义的map
。然后,在导入块中使用匹配的for_each
,该for_each
引用与你的资源块相同的map
,并通过each.key
引用相应的资源:
import {
for_each = local.resources
to = ADDRESS[each.key]
id = each.value
}
同样,当导入通过count
元参数配置的资源时,我们必须声明一个包含唯一标识符的数组:
locals { resources = [ "ID-for-zone1", "ID-for-zone2" ] }
最后,我们可以在导入块上使用count
元参数,并像处理资源块一样进行迭代:
import {
count = length(local.resources)
to = ADDRESS[count.index]
id = local.resources[count.index]
}
使用import
命令稍微复杂一些。你需要为map
中的每个项目执行terraform import
命令,引用正确的key
并将其映射到相应的值:
terraform import 'ADDRESS["key"]' ID
使用类似的技巧导入通过count
配置的资源:
terraform import 'ADDRESS[index]' ID
当处理for_each
配置的资源时,我们需要为数组中的每个项目执行terraform import
命令,并手动将索引与正确的唯一标识符关联。
尽管通过一些高级的 bash 脚本技术可以实现,但推荐的方法是使用 HashiCorp 配置语言中的导入块,因为这种方法更容易实现且更不容易出错。
我们已经分别研究了通过 import
命令和导入块将现有资源导入 Terraform 的命令式和声明式方法。现在,让我们研究如何在本书中覆盖的三个云平台——Amazon Web Services(AWS)、Microsoft Azure 和 Google Cloud Platform——中识别每个现有资源的正确唯一标识符。
确定要导入的资源
就像在本书前几章中我们开发的每种云架构之间存在细微差别一样,现有资源导入到 Terraform 的方式也受到了云平台之间结构性和不太明显差异的影响。
AWS
AWS 对 EC2 实例的命名约定通常是这样的:i-abcd1234
。它通常由两个组件组成:前缀和标识符,前缀在不同的 AWS 服务之间有所不同。
i-
前缀表示这是一个 vol-
(卷)或 sg-
(安全组)。
在这种情况下,abcd1234
标识符是实例的唯一标识符。AWS 通常为每个实例分配一个十六进制字符串,以区分它与其他资源。这种命名约定帮助用户和 AWS 服务在 AWS 生态系统内识别和引用资源。你需要识别正确的唯一标识符,以便将资源从 AWS 及其他云平台导入 Terraform。
使用 AWS 的导入命令时,它看起来是这样的:
terraform import aws_instance.foo i-abcd1234
相应的导入块看起来像这样:
import {
to = aws_instance.foo
id = i-abcd1234
}
理解地址和唯一标识符之间的区别非常重要。地址是 Terraform 内部的对象引用,而唯一标识符是目标云平台上资源的外部引用。这一理解将帮助你更有效地导航导入过程。
Azure
在 Azure 中,唯一标识符称为 Azure 资源 ID。它采用一种完全不同的格式,使用 Azure 中云资源位置的多个不同地标来构建。它遵循一个结构化的格式,包含几个组件:订阅、资源组、资源提供者、资源类型和本地化的资源名称:
/subscriptions/{subscription-id}/resourceGroups/{resource-group-name}/providers/{resource-provider}/{resource-type}/{resource-name}
例如,Azure 虚拟机的 Azure 资源 ID 看起来像这样:
/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-foo/providers/Microsoft.Compute/virtualMachines/vmfoo001
在此示例中,我们可以看到资源 ID 路径中每个组件的具体值:
-
00000000-0000-0000-0000-000000000000
订阅的 GUID。 -
rg-foo
。 -
Microsoft.Compute
是 Azure 计算服务的资源提供者,其中包括 Azure 虚拟机。 -
virtualMachines
用于 Azure 虚拟机。资源提供者与资源类型结合,创建一个完全限定的 Azure 资源类型:Microsoft.Compute\virtualMachines
。 -
vmfoo001
。
每个资源提供者中的资源类型也有子类型。这些子类型通过额外的斜杠分隔(例如虚拟机扩展:Microsoft.Compute/virtualMachines/{vm-name}/extensions/{extension-name}
)。Azure 的资源 ID 命名约定使用了资源路径策略,而不是像 AWS 那样使用前缀和唯一标识符策略。因此,Azure 的资源 ID 可能会相当长,但它们确实有一种合理的方式来解构,从而收集关于特定资源部署上下文的有价值信息,避免了额外的查找。
当在 Azure 上使用导入命令时,命令如下所示:
terraform import azurerm_linux_virtual_machine.foo "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-foo/providers/Microsoft.Compute/virtualMachines/vmfoo001"
对应的导入块如下所示:
import {
to = azurerm_linux_virtual_machine.foo
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-foo/providers/Microsoft.Compute/virtualMachines/vmfoo001"
}
重要的是要记住,地址是 Terraform 中的内部对象引用。唯一标识符是指向目标云平台上资源的外部引用。
Google Cloud Platform
在 Google Cloud 中,资源的唯一标识符称为 资源路径,就像在 Azure 中一样,它由 Google Cloud 中资源位置的一些重要标志组成。这些标志与 Azure 的不同,因为两个平台的结构差异以及其他设计考虑:
projects/{{project}}/zones/{{zone}}/instances/{{name}}
例如,Google 计算实例的 Google 资源路径如下所示:
projects/proj-foo/zones/us-central1-a/instances/vmfoo001
在这个例子中,我们看到了资源路径中每个组件的具体值:
-
proj-foo
。 -
区域:这表示资源在 Google Cloud 区域和可用区内的物理位置。
-
vmfoo001
。
尽管 Google Cloud 确实具有更高层次的组织结构,如 Google Cloud 组织及其下的文件夹,但资源路径只包括 Google Cloud 项目 ID。这类似于 Azure 的资源 ID,它包括 Azure 订阅和资源组,因为这些是平台中资源的逻辑容器。Google 选择了更简化的路径,只包含项目 ID。Google Cloud 资源路径与 Azure 资源 ID 之间的一个主要区别是,Google 资源路径中包括了区域信息。区域表示资源在 Google Cloud 区域中的物理位置。Azure 的资源 ID 仅包括逻辑结构,如订阅、资源组、资源提供者和类型,不包括诸如 Azure 区域或可用区这样的物理位置。
当在 Google Cloud Platform 上使用导入命令时,命令如下所示:
terraform import google_compute_instance.foo "projects/proj-foo/zones/us-central1-a/instances/vmfoo001"
对应的导入块如下所示:
import {
to = google_compute_instance.foo
id = "projects/proj-foo/zones/us-central1-a/instances/vmfoo001"
}
重要的是要记住,地址是 Terraform 中的内部对象引用,唯一标识符是指向目标云平台上资源的外部引用。
现在,我们已经了解了一些关于如何识别现有资源以及需要映射到我们代码库中定义的资源的唯一标识符的信息,我们完全准备好开始手动导入资源到我们的 Terraform 代码中。然而,这真的是唯一的方法吗?是否有一种更具成本效益或时间敏感性的方法,能够让我们批量导入资源?在下一部分中,我们将探索一些工具,帮助我们找到并导入现有资源,并生成相应的 Terraform 代码。
导入现有环境
正如我们在本章前面的部分所看到的,Terraform 包含了广泛的导入机制,这些机制允许我们将单个资源和大量现有资源导入到我们的 Terraform 代码库中。这些工具可以帮助我们克服暂时性错误,避免产生需要通过现有 Terraform 代码库和 Terraform 状态文件管理的孤立资源。
然而,当我们没有编写任何 Terraform 代码,并且许多现有资源已经在云环境中配置好时,如何处理呢?从头开始手动反向工程所有 Terraform 代码似乎不是一个有用的时间投入方式。这就是为什么会有工具来帮助自动化这个过程!
在本节中,我们将检查解决这个问题的几个最流行的开源工具。
Terraformer
Terraformer 是由 Google 开发的一个开源工具,帮助将现有的云基础设施导入到 Terraform 配置和状态中。它支持多种云服务提供商,包括我们在本书中探索过的那些。当然,Google Cloud 得到了很好的支持,包括它的主要竞争对手(AWS 和 Azure),但许多其他 Terraform 提供商也得到了支持。与 Terraform 内建功能不同,这个工具的设计目的是根据分布在云环境中的现有资源生成 Terraform 代码和状态。
这个工具以及类似的工具,通过利用云提供商的 REST API 来收集有关已配置的各种资源的信息。你只需要将其指向正确的方向,并为其设置一些限制,以便缩小其视野范围。你只需选择想要打包在同一个 Terraform 工作区和状态文件中的资源。
允许你将 Terraformer 限定为仅处理你感兴趣的资源的关键命令行参数包括资源类型、区域和标签。根据提供商的不同,可能会存在资源类型支持的限制,因此最好使用以下命令检查当前支持的资源列表:
terraformer list --provider=aws
这将帮助你了解如何查询特定的云平台。例如,在从 AWS 导入资源时,我们可以确定 s3
和 ec2_instance
是支持的资源类型:
terraformer import aws --resources=s3,ec2_instance --regions=us-west-1
在 Azure 上,我们将使用 Azure 特定的资源类型,并经常使用--resource-group
参数来指定这种 Azure 特定的逻辑结构以导入资源:
terraformer import azure --resources=resource_group,vm --resource-group=your-resource-group
同样,在 Google Cloud 上,我们将使用 Google Cloud 项目,这是对应于 Azure 资源组的逻辑结构,以缩小领域:
terraformer import google --resources=gcs,compute_instance --projects=your-project-id --regions=your-region
标签起着重要作用,因为它们提供了一种非常精细的方式来将我们想要的内容导入到我们的 Terraform 工作空间中:
terraformer import google --filter="Name=tags.Environment;Value=Production"
我们可以指定一组非常具体的标签,这些标签我们预先在我们的环境中设置,以在导入过程中获得最高效率。
Azure 导出工具
有其他商业和平台特定的工具,可能比像 Google 的 Terraformer 这样的通用工具做得更好。其中一个例子是azurerm
提供者和azapi
提供者,这两个 Terraform 提供者可以用来配置和管理 Azure 资源。
与 Terraformer 一样,Azure 导出工具有几种查询现有资源的机制,应包括在代码生成过程中。它支持其他导入选项,如全订阅导入,并消除了指定资源类型的需求。这可以通过使用azurerm
和azapi
提供者的组合来加快 Azure 代码生成过程。由于azapi
提供者支持每个 Azure 资源的完全支持,因此在azurerm
资源不可用时,它可以作为多功能填充使用,而不会出现基于资源类型的兼容性问题。
导入给定 Azure 资源组内所有资源的命令将简单地如下所示:
aztfexport resource-group rg-foo
它可以在交互模式或非交互模式下运行。交互模式允许最终用户查看将被导入并映射到其相应引用的资源在 Terraform 代码中的资源。
尽管 Azure 导出工具不像 Terraformer 项目那样广为人知,但它确实在 Azure 和更广泛的 Terraform 社区中有一些有趣的功能。例如,附加功能允许您执行有针对性的代码生成,并将现有资源附加到现有的 Terraform 工作空间中。
限制
一个高效的基础设施即代码(IaC)和 Terraform 代码生成工具的吸引力确实存在。但是,在涉足这一领域时,你应该意识到它并非没有局限性和常见陷阱。
Terraform 的代码生成工具面临的最大挑战,并非 Terraform 和 IaC 领域独有的问题,而是普遍存在于逆向工程或代码生成方法中的问题。使用逆向工程工具生成的代码通常缺乏手写代码从一开始就融入的工艺感。这不仅可能导致功能缺陷需要排查,还可能出现无数的代码质量和可读性问题,这些问题必须在代码库真正用于其预期目的(即通过 IaC 维护云环境)之前解决。
在导入的 Terraform 代码库中,常见的一个功能性问题是过度定义的显式依赖关系,使用了 depends_on
元数据参数。depends_on
子句是解决 Terraform 无法自动识别的资源之间隐式依赖关系的有价值工具。然而,在大多数情况下,显式定义这些资源之间的依赖关系是没有必要的,会增加代码库的冗余和复杂性,还会影响代码的可读性。
另一个例子是,当从云平台提取资源配置时,其值通常作为硬编码值导入,这些值分散在所有声明的资源中。这会立即产生技术债务,需对相关常量值进行合理化处理,并提取一组合理且理想的输入变量,以便用于定义相关的配置设置。
最后,Terraform 资源上常常存在仅可写属性,这些属性不会通过云平台的 REST API 返回,因为它们包含敏感或机密信息。出于保护机密信息泄露的设计考虑,这是正常现象。如果该资源最初是通过 Terraform 配置的,这就不是问题,因为这些敏感值会存储在状态文件中。然而,这会带来一定的重构过程,因为在大多数情况下,你的 Terraform 代码库将无法通过 terraform validate
,更不用说 terraform plan
,因为会有需要解决的错误。
在生成代码并导入资源后立即运行 plan
,有助于发现导入过程中可能存在的细微差异和不规则之处。这是有可能发生的,因为 Terraform 代码生成的准确度远未达到 100%。
如我们所见,在工具领域中,有一些相当不错的选项可以自动化大规模云资源的代码生成和 Terraform 状态文件创建,包括我们在本书中重点讨论的三大云平台。然而,虽然代码生成可以加快过程中的某些部分,但它也可能带来需要解决的挑战。在下一节中,我们将权衡取舍,并讨论一些最佳实践和替代方案,用于将现有环境纳入 Terraform 管理。
最佳实践
我们已经看过 Terraform 内置的能力,能够导入单个资源,并且了解了如何识别在不同云平台上要导入的现有资源。我们认识到内置功能的一些局限性,并且看了 3rd party 替代方案,这些替代方案提供了批量导入整个环境的选项,以及这些选项的当前局限性。现在,我们将探讨最佳实践,如何以及何时使用这些不同的方法将现有资源和环境导入并纳入 Terraform 管理。
爆炸半径
在导入现有资源并将其纳入 Terraform 管理时,仔细思考这些资源的组织方式以及你希望如何将它们划分为长期有效的 IaC 解决方案是很重要的。这就是最小化爆炸半径的设计原则。当我们导入资源时,实际上是在确定我们根模块或 Terraform 工作空间的边界。
这是进行此设计的理想时机,因为工作空间尚未组织好。考虑清楚这一点很重要,因为它会影响你如何管理、更新和复制基础架构的部分内容,这取决于你如何将资源分组。
你应该考虑资源将要发挥的功能以及谁负责管理它们。假设一个中央团队负责维护架构中的某个部分,那么你可能希望考虑将这些资源组织到同一个 Terraform 工作空间内,以便更容易控制访问并减少团队之间的摩擦。
在使用 Terraformer 或其他工具在 Terraform 工作空间中生成代码时,使用标签来缩小资源筛选范围。为云资源预先添加合适用途的标签,将有助于你最大化使用 Terraform 导入工具的效果。这一点在 AWS 中尤为重要,因为与 Azure 和 Google Cloud 上的资源组和项目不同,AWS 缺乏类似的资源逻辑容器。
有时,慢慢移动就是快速前进
作为使用导入工具批量导入资源的替代方法,你可以使用一种轻量级的技术,利用内建的导入工具。这个过程有点繁琐,但有时缓慢推进让你能够更加有目的和深思熟虑。这一过程仅仅涉及使用查询技术来识别你计划导入的资源,然后使用最基本的 Terraform 资源定义将它们搭建起来。这个资源定义只是一个占位符,很可能与之前配置的资源的配置不匹配——但这并不是重点。
关键在于将现有资源导入状态,然后运行terraform plan
来确定配置差异。接着,你可以使用生成的计划来调整资源定义的配置,以匹配计划输出,直到不再需要任何更改。
采用这种方法,你是在采取与批量工具导入相反的策略。与其挥舞砍刀穿越丛林,不如挥舞手术刀,做出极其细致的切割。你需要手动配置它,但这将让你对所导入并纳入管理的组件有一个更系统化、一步步的理解。这种更深入的理解可以帮助你识别出在批量导入过程中可能被忽略的依赖关系和设计缺陷。
蓝绿部署
另一种选择是考虑替代导入的方法。导入是一个繁琐且容易出错的过程。如果你有一些手动配置的关键基础设施,你可能想要考虑用已经由 Terraform 管理的新环境替换它们。
这种方法被称为蓝绿部署。这是一种广为人知的发布管理策略,通过构建一个新的绿环境来替换现有的蓝环境。在绿环境经过全面测试并准备就绪后,我们执行切换操作,从蓝环境切换到绿环境。
你可以设置新环境,并将工作负载和应用程序迁移到其中。这将允许你在手动配置且没有适当治理的环境与遵循最佳实践的环境之间实现清晰的分离。你可以逐步地、一步一步地将工作负载迁移到新的有序环境,直到旧环境完全关闭。
使用代码生成器可能会产生质量极差的代码,需要大量重构。虽然其中一些只是简单的输入变量提取,但随着环境复杂性的增加,将资源移入模块将变得异常繁琐。执行导入、重构和转换的工作量实际上可能比从零开始编写代码并逐步切换还要大。
当你权衡将遗留环境置于“保持灯火通明”模式的成本时,同时构建新的世界秩序,这样可以让你的组织在保持一定常态的同时,逐渐适应使用 IaC 管理环境的变化,而不是一蹴而就。
在本节中,我们讨论了一些在 Terraform 管理下导入现有资源和环境的重要经验法则。如果你计划进行批量导入,首先要认识到你将使用的工具的局限性,并为重构预留充足的时间。最重要的是,通过定义一个集中的爆炸半径来缩小关注范围,围绕你的部署进行管理。
如果翻遍一大堆杂乱的代码并通过广泛重构清理它们听起来不太符合你的兴趣,可以考虑通过高度集中的逐步导入过程慢慢重建环境,或者直接进行蓝绿部署。我的首选方法是蓝绿部署,但你必须仔细评估对生产环境的影响,以确定这是否是最适合你的选择。
摘要
在本章中,我们探讨了 Terraform 内置的现有资源导入 Terraform 状态的能力,采用命令式和声明式的方法。尽管内置的导入功能缺乏任何形式的代码生成,但我们也介绍了一些开源工具,这些工具分析现有环境并生成 HashiCorp 配置语言(HCL)代码来管理资源,并将其导入到状态中。我们讨论了这些不同导入技术的相关权衡以及何时考虑使用它们,这将帮助你为组织和团队决定最佳的行动方案。在下一章中,我们将讨论如何使用 Terraform 管理和操作现有环境。
第十七章:使用 Terraform 管理生产环境
本书的核心内容是使用 Terraform 来管理你的环境,因为这可能是我们解决方案基础设施中最重要的操作方面:管理它。常常情况下,基础设施即代码被当作一种权宜之计,将系统扩展并快速部署到云端,却未考虑到第二天以及之后的日常运维。
这种 第 1 天运维 思维方式很常见,尽管从心理学角度来看可以理解,但从事基础设施即代码的人天生是建设者。我们热衷于构建新事物,并不断寻求改进的方式。但我认为,基础设施即代码解决方案中最重要(且常被忽视)的设计考量之一,不是可扩展性、性能、安全性,甚至是高可用性——而是可操作性。
我们能否在没有宕机或延迟的情况下有效地操作我们的环境,避免影响环境健康,最终影响我们对客户的承诺?如果答案是否定的,那么我们作为基础设施即代码开发者、云工程师和云架构师就失败了。
在本章中,我们将探讨如何将基础设施即代码与通过 Terraform 实现的流程和技术相结合,以达成这些目标。
本章涵盖以下主题:
-
操作模型
-
应用变更
-
故障修复
操作模型
在本节中,我们将深入探讨适合使用 Terraform 来配置和管理基础设施的团队和组织的不同操作模型。让我们从 Terraform 操作的基础开始:状态管理。接着,我们将探索团队如何将 Terraform 融入其操作模型中。根据团队在组织中的角色以及他们所管理的云基础设施,团队的动态可能会有所不同。这也可能影响他们与其他不使用 Terraform 的组织部分之间的合作方式。
状态管理
在开始使用 Terraform 管理长期运行的环境时,无论是用于开发、测试,还是实际的生产工作负载,操作模型的基础性变化是引入了Terraform 状态。我们在本书的 第一章 中讨论过 Terraform 状态,深入分析了 Terraform 的架构,因此我们已经知道它作为环境状态判定标准的价值,但创建状态并管理它是使用 Terraform 管理环境时日常操作的一部分。
拒绝手动状态操作
如我们所述,Terraform 状态文件本质上只是包含在上次 Terraform Apply 中创建的资源清单的 JSON 文本文件。你可能会想通过打开它并手动修改内容来操作状态文件。然而,这是不建议的做法。Terraform CLI 提供了许多命令,能够安全地执行状态操作,而 HashiCorp 甚至开始积极推广 HashiCorp 配置语言(HCL)功能,以便通过代码本身进行状态操作,而不是让管理员通过 CLI 进行手动操作。除了从命令式方法转向声明式方法外,它还为模块作者提供了额外的好处,使版本升级路径更加顺畅,构建了安全的更新方式,无需进行成本高昂的蓝绿部署或实施会演变为长期问题的短期修复。
访问控制
由于 Terraform 本身具备可扩展的特性,作为一个能够通过其提供者插件适应每个目标平台的基础设施即代码工具,它还通过使用的后端提供者来适应目标平台。默认情况下,Terraform 使用本地文件系统来存储状态,但在管理长期存在的环境时,显然不会使用这一方式。如我们所讨论的,当我们在本书中涉及的三大云平台上实现解决方案时,我们使用了不同的后端提供者,在相应的云平台上存储状态。
在 AWS 中,我们使用了 s3
后端,它将我们的状态存储在 AWS 的 azurerm
后端上,而在 Google Cloud Platform 中,当我们使用 gcs
后端时,Terraform 会将状态文件存储在各自云平台的相应存储服务中。像 AWS 一样,其他云平台也实现了类似的访问控制,以防止未经授权的访问。在 Azure 中,这种访问控制表现为在资源组或订阅级别指定的 Azure 基于角色的访问控制(RBAC)。在 Google Cloud 中,这种控制则表现为在项目级别驱动的访问控制列表。
加密
除了可以应用于托管 Terraform 状态文件的云服务的基于身份的访问控制外,我们还可以利用这些服务的内建功能,采用不同层级的加密。最简单的加密方式是内建的透明数据加密,它可以保护我们免受云服务提供商的物理数据泄漏攻击。这是一种不错的保险,但它也是比较不太可能的攻击途径。
更有可能导致我们的 Terraform 状态文件暴露的方式是,如果我们的身份和访问管理控制存在漏洞。为数据增加额外安全层的一种方法是利用存储服务本身的加密功能。当你这样做时,访问文件本身不再足够,你还需要访问加密密钥本身。
在 AWS 上,这通过使用 AWS 密钥管理服务(KMS)来实现,该服务允许你创建和管理自己的密钥,用于加密 Terraform 状态文件。类似的功能在 Azure 和 Google Cloud 上也存在。在 Azure 上,你将使用在 Azure 密钥保管库中创建的客户管理密钥,而在 Google 上,你将采用相同的方法,但当然使用等效的 Google Cloud 服务,称为 Google Cloud KMS。如果你希望采用一个与云平台无关的方法,你可以利用多云密钥管理解决方案,例如 HashiCorp Vault。
备份
在上一章中,我们探讨了如何将现有环境导入到 Terraform,并看到即使有内置工具来执行此操作,仍然可能会繁琐且容易出错。不幸的是,唯一将你的环境保持在“由 Terraform 管理”的分类中的东西就是状态文件。如果你丢失了状态文件,或者文件损坏或与实际环境完全不同,那么你原本由 Terraform 配置的干净环境很容易变成一个孤立环境,变得不再由 Terraform 管理,你需要考虑是否需要重新导入或重新配置。
不要让这种情况发生!你应该保持状态文件的备份。我们查看的绝大多数 Terraform 后端都支持以几种不同的方式开箱即用地进行备份。首先,它通过启用版本控制来实现这一点,这样你就可以在存储服务本身内拥有一个版本化的状态文件历史。这是一种非常方便且具成本效益的方法,帮助你解决一些小问题,例如人为错误或暂时的部署失败。
然而,你还应考虑更高级的跨区域复制功能,来帮助你应对更大范围的故障。Terraform 状态暂时下线或不可用并不会影响你的解决方案的可用性,但它会影响你在故障发生时对环境的控制能力。因此,思考如何实现跨区域复制和备份策略,以确保覆盖所有可能的场景是非常重要的。
组织
你可以控制的最简单的事情之一就是 Terraform 工作空间的存储位置。如果你正确地划分了 Terraform 工作空间,并在云平台提供的安全边界内工作,那么保护状态文件并不需要太多复杂的手段。
在 AWS 上,你可能希望创建更多的 S3 存储桶,并将这些存储桶放入不同的 AWS 账户中,以确保不会因为过于宽松的 IAM 策略而发生机密泄露。
同样,在 Azure 上,你可以创建更多的存储帐户,并将这些帐户放置在 Azure 订阅中,以便更有效地通过隔离它们来避免过于宽松的订阅级别权限。
在 Google Cloud 上,要仔细考虑将 Google Cloud Storage 服务部署到哪个项目中,并选择一个隔离的项目来存储 Terraform 状态。这将确保应用程序及其管理员不必访问 Terraform 状态文件中的机密信息。
独立应用程序
在本书的大部分时间里,我们一直作为一个小团队,在神秘的亿万富翁凯泽·索泽的公司构建下一代车队运营平台。在这些场景中,我们跨多个云工作,并在整个过程中使用三种不同的云计算范式来实现我们的解决方案:
图 17.1 – 小团队部署独立应用程序的操作模型
在这种情况下,我们看到一个应用程序开发团队正在开发一个典型的 N 层架构应用程序。这个团队大约有 6-8 人,他们是软件开发人员或软件测试人员。最终目标不是提供基础设施,而是为团队正在开发的应用软件提供发布过程的支持。
在这些类型的团队中,应用程序代码和基础设施即代码通常会保存在同一个源代码库中。这种简单的做法承认了基础设施和应用程序之间的自然依赖关系,源自部署过程。基础设施即代码会提供已知的密钥,但在应用程序初始化时会被引用。将这一切保存在我们的源代码库中,可以让我们最小化在单个功能分支、拉取请求中进行更改并最终合并到main
分支时,跨基础设施和应用程序代码库的变化机制。
我们有一个单一的 Terraform 根模块,用于部署我们的环境,并且我们更改输入变量以适当配置它,以适应不同环境实例的需求:DEV
和 PROD
。这使得我们能够通过简单地更改工作区,来管理多个环境,方法是使用 terraform workspace
命令,或者通过更改我们用来在后端中划分工作区的后端键。
我们构建并部署的解决方案是一个端到端的解决方案,包含多个架构组件,构成了整个应用程序——在本例中,是一个具有网页前端和 REST API 后端的应用程序,这并不罕见。由于我们的解决方案非常简单,我们能够以完全自包含的方式运行。在更大的团队和更大的环境中,尤其是在企业中,情况并不总是如此——稍后我们会看到。
在我们第七章至第十五章的解决方案开发过程中,我们并没有真正讨论如何在生产环境中管理这些环境。在正常的产品开发过程中,我们需要为各种目的配置多个环境,并在这些环境中管理我们的发布生命周期,直到我们最终通过将产品部署到生产环境中来发布产品。
正如我们在这个过程中所看到的,除了云平台之间那些细微的,甚至有时并不那么细微的差异外,根据云计算范式的不同,我们会使用不同的媒介来打包我们的应用部署,这有时使我们能够通过引用虚拟机镜像或容器镜像将部署产物集成到我们的 Terraform 配置中;但有时,如在无服务器情况下,我们不得不实现一个额外的独立部署过程,该过程将在 Terraform 配置完我们的环境后执行。
共享基础设施
与我们跟随的应用开发团队不同,这些工程团队并不编写应用代码,但它们仍然是 Terraform 的重度用户。这些团队管理着一个组织的共享基础设施。这些团队可能由传统的基础设施工程师组成,他们之前可能管理过本地虚拟化环境、网络安全或其他类似的 IT 基础设施领域。根据组织的规模,这可能是一个庞大的工作,涉及多个团队和组织,每个团队和组织都有自己负责的范围和职责,或者这可能是一个单一的团队:
图 17.2 – 用于部署支持组织内一个或多个应用团队的基础设施的共享基础设施团队操作模型
这种操作模型与简单的独立应用开发项目不同,因为该团队与组织中的其他团队之间存在一些固有的依赖关系。这些外部团队在没有承诺任何符合共享基础设施团队操作模型的模式下,拉取它们的依赖关系。因此,这些团队可能并未使用 Terraform 或任何自动化工具。
他们正在管理的环境可能是共享网络、集中监控和日志记录、数据库、数据湖、数据仓库,甚至是共享计算池,例如 Kubernetes 集群。在大多数情况下,他们不会有自己的应用程序代码,但通常会有自己的部署包——无论这些包是他们自己创建的虚拟机还是容器镜像,还是软件供应商通过商业或开源关系提供给他们的第三方商业外包(COTS)软件包。
在大型组织中,虚拟机和容器镜像仓库本身通常作为共享基础设施由平台团队构建和管理,以便在整个组织中重复使用。
这些工作负载可能也会有多个环境,但可能没有应用开发团队那么多,并且可能选择仅通过非生产/生产维度来划分环境。这种方法可以最大程度地为非生产工作负载重复使用,并减少了为依赖团队可能有的每个用例进一步分割共享基础设施的开销。
由于缺少应用程序代码,部署过程变得简化,但共享基础设施团队应仔细考虑如何组织他们的 Terraform 工作区,以最小化外部团队之间的摩擦,这些团队对他们有依赖。这是爆炸半径在设计和将共享基础设施工作负载分割成离散和可管理的 Terraform 工作区中起重要作用的地方。
共享服务
最后,最复杂的操作模型是共享服务。在这种场景中,我们结合了独立应用和共享基础设施的各个方面。共享服务不仅具有它们需要构建和部署的应用程序代码库,还有组织内其他团队对它们的依赖。然而,与共享基础设施团队不同的是,这些依赖可能在应用接口层上,嵌入在两个系统用于互操作性的基于消息的协议中。共享服务团队很可能由负责维护一种(或多种)微服务组合中的服务的开发人员和测试人员组成:
图 17.3 – 共享服务团队
共享服务团队在大型组织中很常见,因此,他们通常在一个环境中运作,可能会依赖组织内其他共享服务和共享基础设施团队的服务。这有助于减少共享服务团队的责任范围,因为他们可以将一些责任交给负责底层基础设施的共享基础设施团队,比如广域网、安全性、日志记录和监控,以及更高层次的基础设施,如 Kubernetes,甚至是共享 Kafka 或 Cassandra 集群。
尽管这种责任的分配有助于将共享服务团队的精力集中于其服务的开发和维护,但它也创造了额外的协调工作,需要同步变更和发布流程,以及下游和上游服务之间的版本兼容性。
现在我们已经了解了几种使用 Terraform 管理现有环境的不同操作模型,我们将深入探讨在管理环境时需要执行的一些常见操作。无论你的团队是什么样子,或者你用 Terraform 管理的是哪种工作负载,这些场景都是不可避免的!
应用变更
当我们管理一个长期存在的环境时,最终我们不得不对这个环境进行更改——无论是大是小。变化是不可避免的。这可能是与我们解决方案本身相关的变更,也可能是由于工具和底层平台本身的升级所需的变更。变化可以是预期的——比如计划中的发布——也可以是意外的——比如区域或区域性的故障。在本节中,我们将探讨经常发生在我们环境中的各种类型的变更,并讨论在使用 Terraform 管理环境时我们应该如何最好地处理它们。
修补
在使用 Terraform 时,我们的代码中有多个地方需要我们明确决定使用哪些版本的组件。这些地方包括 Terraform 可执行文件的版本,以及你在配置中使用的提供程序和模块。
升级 Terraform 可执行文件
我们首先需要考虑的是我们想要使用哪个版本的 Terraform。这可能让你感到惊讶——我意思是,为什么你不总是想使用最新最好的 Terraform 版本呢?然而,当你管理现有系统时,升级你所使用的 Terraform 版本有一些非常重要的原因,应该谨慎对待。
你使用的 Terraform 版本可能会影响你所使用的提供程序版本是否受支持,这可能会导致级联的升级需求,迫使你在代码库中进行比最初计划更多的更改。
虽然 Terraform 的新版本常常带来令人兴奋的新特性、功能和 bug 修复,但它们也可能带来弃用和向后不兼容的变更。这是 HashiCorp 一直做得很好的事情,他们通过最小化变更的影响来管理这些问题,但它仍然是需要关注的事情,因为偶尔会发生。Terraform 版本对操作有重大影响的最新例子是 Terraform 的版本 0.12
。在这种情况下,如果你使用的是 aws
提供商,且升级到 Terraform 版本 0.12
,你将需要升级到 aws
提供商的版本 2.20.0
。
Terraform 的版本通常会在根模块和可重用模块的 required_versions
块中引用。因此,你还应该评估升级对你 Terraform 管理环境的影响,以及任何你所引用的模块。
升级提供商
像 Terraform 本身一样,我们用来为各种云平台和其他平台配置资源的每个提供商也有自己的版本。这使得我们在升级 Terraform 时遇到的问题更加复杂,因为我们需要在每个使用的提供商中进行升级。然而,大多数 Terraform 部署只使用一个云平台的提供商,但可能还包括其他针对不同控制平面的提供商。
云平台特别有问题,因为它们发展迅速且覆盖面广。例如,AWS、Azure 和 Google Cloud 的资源提供商分别有超过 700、600 和 400 种不同的资源类型!现在,你可能不会在某个 Terraform 解决方案中使用所有这些资源类型,但由于每个提供商提供了这么多不同的资源类型,任何一个服务添加新特性时,都可能带来变更。因此,它们经常变化,提供商的新版本每周发布,甚至有时更频繁!
在升级提供商的版本时,最好有目的性地进行。虽然你不一定要遵循提供商的每周发布节奏,但最好不要让提供商的版本停滞不前,因为这样会不断积累技术债务,直到变成紧急情况。紧急情况可能会以两种方式出现。首先,你可能会在配置中使用被弃用的资源、块或属性,这些内容最终会被移除支持。其次,你可能想利用你正在使用的某个资源的新特性或功能,但在当前版本中不被支持。
升级模块
模块是另一个需要考虑版本的地方。当你从 Terraform 注册表引用一个模块时,你需要明确设置你想使用的版本。如果你使用的是存储在其他、不太结构化的位置的模块,比如 Git 仓库,你应该小心地使用特定的标签来引用它们。
升级模块版本的影响,就像提供程序中的每个资源类型一样,取决于模块新版本中的破坏性更改——或者没有破坏性更改。有时,模块在版本之间可能会发生巨大的变化,这可能会对这些模块的消费者产生显著的负面影响,尤其是当他们天真地升级时,认为一切都会正常工作。
对于模块来说,Terraform Plan 通常足以检测是否引入了重大变化,但当提供程序和模块版本更改重叠时,执行测试部署来验证升级通常是个好主意。这对于任何你试图引入环境中的更改都适用。
重构
随着我们开发更高级的配置,通常会发现我们模块中的一些组件——无论是根模块还是可重用模块——理想情况下可以被提取到自己的模块中,因为它们实现了可重复的模式,这些模式可以在更细粒度的上下文中,在其他模块和部署中重复使用。
正是在这些情况下,我们可能需要将资源从一个模块移动到另一个模块。如果我们在代码中这样做,任何我们立即配置的新环境都可以从中受益,但现有环境将受到影响,因为它们会检测到更改。我们从一个模块移动到另一个模块的资源,在 Terraform 执行计划时,将会有一个全新的路径。从 Terraform 的角度来看,旧位置的资源已被删除,需要在新位置创建一个新的资源。这种删除-创建操作在管理现有环境时会带来巨大的干扰。
与导入资源类似,我们有两种方法来移动资源。一种是 terraform state mv
命令行操作,另一种是 moved
块,我们可以在 HCL 配置中定义后者:
moved {
from = module.foo.azurerm_network_security_rule.nsg_443
to = module.bar.azurerm_network_security_rule.main[0]
}
命令行操作非常简单,结构如你所预期的那样:
terraform state mv SOURCE DESTINATION
SOURCE
和 DESTINATION
命令行参数分别对应 moved
块中的 from
和 to
属性。
让我们看一个具体的例子。在我们使用 Kubernetes 构建解决方案的章节中,我们看到多个资源在前端和后端组件的配置中重复,几乎是相同的配置。这些资源包括 kubernetes_deployment
、kubernetes_service
和 kubernetes_config_map
:
图 17.4 – 资源的可见重复模式
在我们进行重构之前,我们需要创建一个模块,替代这三个重复的资源:
图 17.5 – 重构步骤 2 – 构建一个可重复使用的模块,可以配置以替换重复模式中的每个实例
现在,模块已创建,我们需要在根模块中创建模块实例,并删除重复模式中的先前资源:
图 17.6 – 重构步骤 3 – 用模块引用和移动的块替换松散的资源
最后,我们创建moved
块,帮助 Terraform 识别这些资源不需要被销毁和重新创建,因为它们已经被配置,但路径发生了变化。
失败规划
有时,意外的情况发生,部分基础设施受到影响,因为目标云平台出现某种故障。在这些情况下,我们需要能够快速响应并对现有环境进行更改,以最小化损失或从故障中恢复。
主动-被动
首先,我们来看看在单个 Terraform 工作区内部署的主动-被动工作负载:
图 17.7 – 在单一 Terraform 工作区内部署的主动-被动工作负载
下面是在故障期间的情况:
图 17.8 – 灾难发生时的主动-被动工作负载!
美国西部的应用程序和数据库不可用。幸运的是,我们有美国东部的数据库,之前正在进行复制。然而,我们需要创建一个在线环境,开始使用这个数据库为我们的客户提供服务:
图 17.9 – 恢复步骤 1:在不同区域中配置新环境
我们使用 Terraform 在新的 Terraform 工作区中配置一个新环境。我们将新的根模块配置为使用美国东部作为主要区域,并将美国中部作为另一个健康的次要区域。这个环境是健康的,但缺少我们的数据。
图 17.10 – 恢复步骤 2:从旧环境向新环境复制数据
我们重新配置我们的新工作区,以通过将旧数据库导入到状态中来引用它,实际上是用旧数据库替换了新的空数据库。这也可能导致复制配置的替换,开始从旧数据库到位于美国中部地区的新灾难恢复数据库的复制:
图 17.11 – 恢复步骤 3:切换到新环境并完全停用旧环境
现在,位于美国东部的旧灾难恢复数据库是我们的主要生产数据库,我们在美国中部有一个新的灾难恢复站点,以备我们需要再次执行相同操作时使用。此时,我们已经准备好通过允许流量回到我们的应用来恢复与客户的服务。由于之前从美国西部到美国东部的复制,数据库将是最新的。由于请求在美国西部被记录下来但可能未通过复制传输到美国东部,在这短暂的时间窗口期间,某些客户可能会有轻微的数据丢失。
活跃-活跃
现在,这是在没有使用任何模块的情况下,在单一 Terraform 工作区内的活跃-活跃部署:
图 17.12 – 在单一 Terraform 工作区内的活跃-活跃部署,不使用任何模块
为了实现更高的系统可用性,我们可以选择进行活跃-活跃跨区域部署。在这种情况下,我们将在两个区域部署我们的应用实例,并在数据库之间进行复制。这样可以确保如果一个区域发生故障,我们的客户将通过将流量路由到健康的区域继续获得服务。
在之前的方法中,我们在单一 Terraform 工作区内创建了我们的多区域部署,这意味着两个区域都会在一个 Terraform 应用中进行更新。这可能会带来问题,因为如果一个区域出现故障,那么我们的部署的一半可能会无法响应,从而影响我们在整个环境中执行更改的能力。这可能会影响我们进行故障切换、增加容量或调整未受影响区域的自动扩展设置的能力。
为了开始避免将所有区域部署到一个单一的 Terraform 工作区,最好将整个区域的部署封装到一个可重用的模块中。这样做会让我们更容易根据区域划分 Terraform 工作区,并且随着我们扩展时,可以轻松添加额外的区域:
图 17.13 – 模块设计,封装我们的应用部署到单一地区
该模块会将需要部署到单个区域的所有内容都包含在内。此外,可能会有可选组件,如数据库复制配置,这些组件可能不需要启用,具体取决于该区域是否为主区域或次要区域之一。因此,我们的模块需要两个输入变量。首先是该实例将要部署的区域。其次是一个功能标志,用于启用或禁用数据库复制。当该区域为主区域时,将启用此功能;当它作为次要区域时,将禁用此功能。
这是一个示例;根据你使用的数据库或技术,你的情况可能会有所不同,但重要的是要认识到,在这样的模块中,利用功能标志来允许定制模块的每个实例以完成其特定角色是一个常见的场景:
图 17.14 – 使用模块在单个 Terraform 工作空间中部署主动-主动配置以为每个区域提供资源
现在我们有了模块,我们可以在单个 Terraform 工作空间中使用它为两个区域提供资源。这种方法使得在单个 Terraform Apply 中可以轻松为其他区域提供资源,但在发生故障时可能会受到操作影响。如果你已经设计了故障转移机制,并且确保备用区域能够自给自足,那么这种方法也许并不不合理,但请记住,发生故障时你可能会失去执行 Terraform Apply 操作的能力。
即使在执行定向应用时,它也会在整个工作空间中执行计划。因此,虽然理论上,定向的terraform apply
只会改变你所定向的资源,因为它必须执行完整的计划,如果你所定向的控制平面在某些区域或可用区受到影响,你将无法执行该操作。
图 17.15 – 分离工作空间的主动-主动配置
过渡到完全分离的工作空间来为每个区域管理,可以帮助你通过 Terraform 更好地控制环境,因为你将在每个区域的上下文中执行terraform apply
操作。这会在稳定状态下增加额外的操作开销,因为它会创建额外的 Terraform 工作空间来管理,并且在日常维护环境时会有额外的机制。因此,许多人仍然选择使用单一工作空间来管理多区域环境。
正如我们在本节中看到的,即使是计划中的变更,事情也可能充满挑战。我们构建和使用的模块会发生变化,我们所使用的云服务的设计和能力也会发生变化,这将导致我们使用的各个资源发生变化,最后,Terraform 执行文件本身也会发生变化。这些变化都是我们计划和控制的!此外,还可能会出现额外的变化,比如我们部署解决方案的可用区或区域内出现意外的故障。在下一节中,我们将讨论如何应对意外错误并执行一些常规的修复。
修复
现在,我们已经了解了我们知道会发生的变化,以及我们知道会发生但无法控制何时发生的变化,我们需要从更具战术性的角度来看待,以帮助我们应对在使用 Terraform 管理现有环境的过程中不可避免的小波折。这些通常是较小的问题,虽然不会造成巨大的影响,但如果我们准备不足,确实会变得负担沉重。但一旦你习惯了这些问题——并学会了如何应对——它们就变得容易管理了!
应用时故障
虽然 terraform plan
为我们提供了有关需要对环境进行哪些变更(或没有变更)的出色信息,但即使是最有意图的计划,在 terraform apply
操作过程中,有时事情也会以意想不到的方式出错。你可以做一些事情,尽量提前预防这些问题,并减少遇到它们的频率。
正如我们所知道的,Terraform 会在我们所针对的云平台上的某个身份下执行,因此,Terraform 拥有该身份的权限或特权。了解 Terraform 身份的权限以及你的代码正在做什么,能够帮助你识别是否存在可能导致授权失败的权限差距,因为这些差距通常是隐式的,很难被 Terraform 检测到。一些云平台甚至要求你在使用某些服务之前,显式启用整个类别的云服务。正如我们在第十三章到第十五章中看到的,Google Cloud 就以此著称,我们需要在 Google Cloud 项目中启用相关的 Google Cloud API,才能尝试进行资源配置。在 Azure 上,大多数常见服务默认启用,但一些较为冷门的资源提供者需要显式启用。
除了仅仅启用您想使用的服务外,所有云平台都会实现默认配额,这些配额会限制您在某些上下文中能够配置的资源量。这些上下文通常基于区域或 SKU。它们为云平台提供了容量规划方面的联合保护,同时也为我们作为客户提供保护,防止我们不小心配置非常昂贵的资源或某一种资源过多。配额并不是云平台强加的唯一限制,通常每个服务在给定部署上下文中(例如在 AWS 账户、Azure 订阅或 Google Cloud 项目中)都会设置资源限制。
除了配额和服务限制外,当使用涉及私有网络的云服务时,如果网络设置配置错误,您可能会遇到问题,例如虚拟网络和子网配置错误,或者安全组规则阻止资源的创建或访问。有时,Terraform 在云服务的数据平面上操作,而当您将其配置为私有网络时,该数据平面可能无法访问。确保 Terraform 配置了适当的私有网络,以便能够访问必要的数据平面。
另一些问题可能出现在 Terraform 无法通过配置和计划确定的隐式资源依赖关系上。这种情况可能发生在某个资源依赖另一个资源时,但这种关系或依赖关系并没有通过资源之间的直接引用在配置中为 Terraform 所知。还可能存在与现有资源的冲突,比如尝试创建已存在的、名称相同的资源,或者在给定范围内不能存在多个资源的其他设置——无论是在网络层面还是在云平台的控制平面层面。
操作可能比预期的时间更长,其他暂时性平台错误可能导致超时。超时可能导致资源最终成功配置,但因为发生在操作超时之后,Terraform 并不知道这一点。当配置大型资源或发生网络延迟时,这种情况可能会发生。
从状态中移除
在上一节中,我们讨论了应用时失败及其对 Terraform 基础设施管理的影响。另一个常见的情况是,在使用 Terraform 管理环境时,可能需要从状态中移除资源。正如我们在前面的章节中讨论的那样,这可以通过命令terraform state rm
以命令式方式完成,或者通过在您的 HashiCorp 配置语言代码中使用removed
块以声明式方式完成。
其中一种情况是你需要退役资源。如果你在 Terraform 之外手动删除了一个资源,需要从状态文件中将其移除,以避免下次terraform apply
时出现错误。同样地,如果某个资源被错误地导入到 Terraform 配置中的错误位置,它可以在重新正确导入之前被移除。
在团队协作中,如果其他人已经删除了某个资源,但你的本地状态文件尚未更新,解决这种差异可能需要从状态文件中移除该资源。清理孤立资源是另一个重要的使用场景。如果一个资源由于手动更改或配置错误变成了孤立资源(不再由 Terraform 管理),就可以将其从状态文件中移除。
你可能还需要移除资源的另一个情况是重构过程。当然,正如我们之前讨论的,通常在这种情况下更常见的是移动资源,但也有可能需要移除资源的情况,例如将一个大配置拆分成多个小模块;也就是说,可能需要从状态文件中移除资源,然后再将它们导入到新的位置。此外,如果某个资源需要被新资源替代(例如,由于资源配置更改而需要重新创建),旧资源可能需要从状态文件中移除,然后再创建新资源。在测试或调试过程中,暂时从状态文件中移除资源有助于隔离问题或测试特定场景。如果你正在将多个类似的资源合并为一个资源(例如,将多个安全组合并为一个),旧资源可能会被移除出状态文件。
导入到状态
在第十六章中,我们深入探讨了如何导入那些在 Terraform 之外已经配置好的现有环境。在本章继续探索管理现有环境时,我们将遇到一些需要导入资源的情况,这对于在已经由 Terraform 管理的环境中进行故障修复至关重要。
即使是在最初通过 Terraform 配置的环境中,导入资源也是一种必要的操作,尤其是在terraform apply
过程中发生临时错误时。这些错误可能会导致一种奇怪的状态,其中资源被配置好了,但 Terraform 报告它们为不健康状态,从而导致terraform apply
失败。然而,这些资源可能会在稍后完成配置或被云平台恢复。在这种情况下,我们面临的选择是删除这些资源并重新运行terraform apply
,或者将它们导入状态。
在这种情况下,导入资源作为一种修复故障的形式,类似于修补润滑良好的机器中的漏洞。它允许我们协调实际云环境与 Terraform 对其理解之间的差异,就像我们通过确保适当的权限、配额和网络配置来解决应用时失败一样。
另一种可能需要导入资源的场景是在处理在 Terraform 之外手动或自动化创建或修改的资源时。这可能会导致 Terraform 状态与实际基础设施之间的漂移,类似于云服务限制或网络设置中的意外更改可能导致的问题。这可能源于人为操作员在基础设施即代码流程之外工作,也可能源于自动化系统执行企业治理标准。通过将新创建或修改的资源导入到 Terraform 状态中,我们可以使配置与基础设施的当前状态重新对齐,确保随后的 Terraform 操作顺利进行。
摘要
在本章中,我们探讨了如何使用 Terraform 管理现有环境。我们首先全面检视了团队在各种组织规模中扮演不同角色和责任的各种运作模式。我们研究了这些团队如何将 Terraform 整合到他们的日常运营中——从管理简单的独立应用程序到处理诸如集中网络等共享基础设施服务的复杂性,并解决在企业各处交织的构建共享服务的细微差异。我们讨论了在共享基础设施中遇到的相互依赖性挑战,以及它们自己的应用程序开发发布过程。
本章的一个重要部分是简单应用更改到我们现有的环境中。这包括看似单调的过程,例如升级我们的 Terraform 工具——从 Terraform 可执行文件本身到我们使用的 Terraform 提供程序和我们解决方案中使用的模块。我们还讨论了我们自己代码可能需要的重构,并讨论了如何处理未计划的更改——例如遇到灾难时如何准备。这个讨论类似于为风暴做好准备;正如人们会确保他们的窗户和门安全一样,我们探讨了如何使用 Terraform 来准备我们的环境,以便在需要采取行动时使用。
我们通过讨论更常见的故障修复场景来结束了这一章,这些场景在您日常管理现有环境中使用 Terraform 时会经常遇到。
在下一章中,我们将讨论一些重要的事项,帮助您在掌握 Terraform 的下一步工作时考虑。
第十八章:展望未来 – 认证、 emerging trends 和下一步
我们已经走到了一个阶段,必须停下来问自己,接下来是什么? 在此之前,让我们回顾一下我们走过的路。
我们已经学习了 Terraform 是什么以及如何使用它。我们学习了一些跨越你当前可能使用的特定云平台的云架构基本概念。我们在三大主要超大规模云平台——AWS、Azure 和 GCP 上构建了三种复杂的架构,最后,我们还学习了如何与现有环境合作,无论是将它们导入 Terraform,还是将其作为长期生产环境或其他环境与 Terraform 管理。
这可真不简单!这一路走来可谓非凡,那么接下来该往哪里走呢?答案就在你自己身上。我希望这意味着你在这段旅程中学到了东西,并且你能将这些带入到日常工作中,构建出能够解决现实世界问题的惊人系统,并且借助 Terraform 的力量,更好地构建和管理这些系统。
在本章结束之前,我想讨论一些可能的下一步,无论是个人成长还是扩展学习。你已经投入了大量的时间阅读这本书,并且开始了掌握 Terraform 的旅程。你可能想要考虑通过认证来验证你的技能和知识,并向潜在的雇主展示你的专业能力。
此外,随着你继续深化对 Terraform 的掌握,你可能还想考虑一些其他技术。我们将探讨一些这些选项,包括与 Terraform 相辅相成的工具和平台,以及它们如何增强你的 基础设施即代码 (IaC) 能力。到本章结束时,你应该已经有了一个清晰的学习路线图,并为你在云基础设施管理领域的未来努力打下了坚实的基础。
本章涵盖以下主题:
-
准备考试
-
Terraform Cloud
-
接下来是什么?
准备考试
在你通读本书并掌握 Terraform 的过程中,你已经获得了丰富的知识和实践经验。那么,如何将你的专业能力展示给世界呢?最有效的方式之一就是通过认证。在本节中,我们将深入探讨如何为 Terraform 认证考试做准备。我们将涵盖你需要掌握的关键主题,考试的形式,以及优化你的学习时间和方法的策略。本节结束时,你将具备足够的工具和信心,能够应对认证考试,并在掌握 Terraform 的过程中迈出重要的一步。
范围和主题
HashiCorp Terraform 助理认证是一个入门级认证,它将测试你对 IaC 的一般概念、Terraform 与其他工具的竞争地位、使用HashiCorp 配置语言(HCL)编写代码的能力,以及使用核心 Terraform 工作流程和通过各种子命令在命令行界面(CLI)中支持的其他工作流程。
有一项新的考试即将推出,名为Terraform 编写与操作专业认证。这项考试是为那些在管理生产系统、开发可重用模块以及在成熟企业 IaC 环境中操作方面具有丰富经验的资深从业者设计的。它旨在验证那些高效编写和管理大规模 Terraform 配置所需的高级技能和深刻理解。考生可以预期会在实施复杂基础架构架构、优化工作流程以及确保企业环境中的最佳实践方面接受测试。此认证是那些希望展示在复杂且动态的基础架构环境中充分利用 Terraform 全部功能的专业知识的人的绝佳机会。
本书旨在帮助你超越助理认证考试中涵盖的许多基础概念,并帮助你为专业认证考试做准备。因此,本书的内容采用了高度实用的方式,实际构建了使用 Terraform 的云架构,并实现了与 Terraform 和自动化流水线工具(在本书中为 GitHub Actions)协作的流程。
如下图所示,我将本书中的章节映射到助理认证考试中实际涵盖的主题:
图 18.1 – 将助理认证主题与本书章节的映射
在本书的前三章中,我们快速浏览了 IaC 的基础概念层、Terraform 的架构和 HCL。这是经过精心设计的,因为本书面向的是中级到高级的读者,而不是初学者指南。
这使我们能够在第四章到第六章中花时间关注大多数 Terraform 从业者实际操作的概念层——他们在设计和配置的云架构,这些架构是我们在所有三大云平台中设置的云计算范式基础。
然后,我们开始实际动手,针对每个云平台构建三个真实世界的解决方案。正如你所看到的,我们在本书中大部分时间都花在了在所有三个云平台——AWS、Azure 和 GCP——以及三种云计算模式——虚拟机、容器和无服务器——上实现 HCL 代码。在这九个解决方案中,我们实现了 Terraform 模块,并深入研究了使用三个相应 Terraform 提供程序:aws
、azure
和 GCP
的配置。
在第十六章和第十七章中,我们重点讨论了涉及状态管理和配置导入的非核心工作流,这些内容在处理现有环境时是常见且必要的——无论它们是否最初是使用 Terraform 配置的。
在专业考试中,这仍然涵盖了所有关键主题,唯一的一个关键例外是:
图 18.2 – 本书中专业认证主题与章节的对应关系图
特别的例外是Terraform Cloud,我选择将其作为本书的重点内容排除,因为我认为目前掌握 Terraform 并不需要了解 Terraform Cloud。我们将在本章的下一部分讨论 Terraform Cloud,因为我认为这是一个有趣的主题,如果你想利用它的一些功能来简化组织的协作工作流,可以进一步研究。
准备工作
去看看关于初级和专业考试的学习指南。这些学习指南中包含了指向官方文档的有用链接,可以补充本书的内容。记住,这本书旨在成为那些希望掌握 Terraform 以进行云架构配置的人的实用指南。这并不意味着需要记住 Terraform CLI 的每个细节。我特意选择专注于那些能够帮助你无论使用何种云和云计算模式都能开始开发真实世界解决方案的技能。
考试可能会有一些棘手的边缘情况或鲜少使用的命令,所以要为此做好准备,但我不会建议花大量时间在这些内容上。你可以为这些不常用的命令做一个快速的备忘单,记录它们的使用方法。如果觉得有必要,可以使用闪卡帮助记忆它们。如果你对 Terraform 的架构有扎实的理解并且有实际操作经验,应该可以应对得很好。
我最好的建议是通过动手实践来学习。拿本书中的项目,随时从我的 GitHub 仓库中克隆它们,但为了最大化你自己的掌握能力,在 GitHub 上自己的仓库中从头开始开发它们。重建这些项目,为你自己和环境配置好必要的资源,然后加以改进。考虑如何将其中的组件模块化,以便更具可重用性。去实施这些模块,然后重构你的环境以使用新模块。把你的环境当作生产环境来看待,通过确保避免替换现有环境中的资源来减少对环境的影响,同时尝试重构以使用你的新模块。
我是在 2023 年 10 月的 HashiConf 大会期间参加的考试。我没有学习,没有准备,甚至没有读学习指南。我以 84.21%的分数通过了考试。我对此感到非常高兴。我告诉你这些是为了炫耀吗?不是。好吧,可能有一点,但说真的,我的意思是,最好的准备方式是通过实践。怎么做到完全不学习就能通过?因为我已经使用 Terraform 好几年,深知其内外。我知道 Terraform CLI 中的每个晦涩命令吗?当然不是。我曾经使用过 Terraform Cloud 吗?一点也没有。你必须问问自己,你是想通过考试,还是想提升自己,成为云自动化领域中一个强大的力量,掌握世界上最强大的自动化工具之一?我认为答案很明显。专注于主要目标,以羚羊般的专注度,辅以研究一些晦涩的知识,你一定会做得很好。
现在,你已经充满了动力,感受到通过这本书学到的所有内容赋予你的力量,并准备参加 Terraform Associate 或即将到来的 Professional 考试,让我们一起期待一些每个 Terraformer(不仅仅是 Azure 方面的)都应该了解的有趣话题。
Terraform Cloud
在本节中,我们将探讨 Terraform Cloud,这是一个强大的平台,旨在增强 Terraform 在团队和企业环境中的功能。Terraform Cloud 提供了一个集中式的中心,用于管理和自动化 Terraform 工作流程,提供如版本控制集成、远程状态管理和协作式 IaC 开发等功能。
我们将简要了解 Terraform Cloud 的核心组件,包括一些高级功能,如工作区管理、私有模块注册表、成本估算和策略执行。
通过了解 Terraform Cloud 的功能和使用案例,你将能够洞察它如何简化你的基础设施管理流程,并促进团队成员之间的协作。如果你希望快速提升组织的 IaC 成熟度,它绝对是一个值得考虑的选择。
功能
Terraform Cloud 的使命是简化使用基础设施即代码(IaC)管理环境的过程。这将包括处理使用 Terraform 时日常操作问题的功能,以及与赋能团队和在大型企业中扩展相关的更高级功能。以下是展示这些功能分组的图表:
图 18.3 – Terraform Cloud 功能
这些分组涵盖了四个功能领域:
-
核心工作流操作
-
组织和物流
-
模块管理与发布
-
第二天操作支持
我们将单独探讨这些功能领域,以便更好地了解 Terraform Cloud 所提供的价值。
核心工作流操作
正如我们所知道的,Terraform 本身是一个简单的命令行工具,处理 HCL 代码,并利用多个提供者生成计划,然后通过在多个提供者之间协调资源创建来执行该计划。Terraform Cloud 是一个多租户 SaaS 服务,它封装了命令行工具的功能,并将其作为托管服务提供。
因此,Terraform Cloud 的一个重要价值来源于 Terraform 本身——也就是那个完成所有工作的命令行工具。然而,Terraform Cloud 提供了许多超出命令行工具内建功能的内容。作为一个托管服务,它建立在我们的版本控制系统之上,充当一个流水线工具,执行 Terraform 的 plan
和 apply
命令。在本书中,我们与 GitHub Actions 一起工作,将 Terraform 命令行工具集成到我们的工作流中,运行核心的 Terraform 工作流,包括 plan
和 apply
。
Terraform Cloud 在 核心工作流操作 类别中的功能侧重于提供 Terraform 作为一种服务,类似于 GitHub Actions 等流水线工具为通用流水线所做的工作,但这是专门为 Terraform 定制的。这意味着 Terraform Cloud 本质上是一个自动化托管平台,专门执行 Terraform 配置。因此,该服务针对 Terraform 的特定需求进行了优化,包括远程状态管理等功能。然而,它还包括了通用流水线工具中常见的一些基本功能,例如源代码管理集成、云平台凭据管理和安全的变量存储。它还提供了与外部工具集成的扩展点,使其能够融入更广泛的自动化编排中。
组织和物流
Terraform Cloud 的组织和物流方面设计旨在促进组织内部团队的协作和管理,无论组织大小。它提供了一个结构化的环境,用于跨逻辑项目组织用户、角色和权限,从而创建和操作 Terraform 工作空间—确保团队成员拥有适当的访问权限和权限,有效地执行任务。像Azure DevOps和GitHub Enterprise等其他通用的自动化平台一样,在这些协作环境中维持秩序和控制是基础功能。
模块管理和发布
Terraform Cloud 在模块管理和发布方面的功能使团队和组织能够在整个组织内构建、维护和共享自己的 Terraform 模块库。正如我们所讨论的,Terraform 模块通常封装了组织认可的最佳实践,并通常由负责其实现和内建质量的中央组织进行维护。Terraform Cloud 通过集成 Terraform 模块测试和验证来支持这一发布过程,以确保在 Terraform 模块的新版本在组织内分发之前达到质量标准。此外,这些模块可以在无代码环境中提供,向最终用户提供类似服务目录的体验。这使得团队能够标准化并扩展其基础设施管理工作,同时也让那些不熟悉基础设施即代码(IaC)或 Terraform 的组织部门能够使用他们构建的解决方案。
第 2 天操作支持
Terraform Cloud 中的第 2 天操作支持功能旨在管理和维护生产环境中现有的系统。它包括持续验证,以确保环境与代码中描述的期望状态保持一致,并且具备漂移检测功能,以识别 Terraform 代码之外的更改。此外,还提供了更高级的企业功能,如审计日志记录,帮助大型组织满足合规性标准并实施风险管理策略,以检测和防止环境中未经计划的更改。另一个关键特性是Sentinel提供的基于代码的策略功能,它允许对 Terraform Cloud 管理的环境进行治理和安全控制。
定价层级
Free 版本提供了 Terraform 的所有基础设施即代码(IaC)功能作为开箱即用的托管服务,包含所有核心工作流操作功能——包括远程状态、安全变量存储、动态提供者凭证和源代码控制集成。这是一个很好的方式,让你初步了解平台,并学习如何以 Terraform Cloud 为基础,而不是作为一个通用管道工具来工作。正如预期的那样,它的功能有限,只有一个并发作业,并且对更高级的企业功能(如政策即代码和运行任务)有限制,这些功能旨在帮助你将 Terraform Cloud 扩展到一个更大、更复杂的基于 IaC 的组织。免费版允许你最多配置 500 个资源。
Standard 版本增加了团队管理,并将并发作业的数量从一个提高到三个,这在团队环境中可能是合适的。定价模型是按每个资源每小时计费,这意味着你在 Terraform 配置中声明的每个资源都会计入使用量。根据当前的定价,每个资源每小时的费用为 $0.00014。为了让你了解这将花费多少,我管理的一个环境是一个小型 Kubernetes 集群及其所有相关的支持基础设施。我在这个环境中使用 Terraform 配置了正好 110 个资源:
110 个资源 x 每个资源每小时 $0.00014 = 每小时 $0.0154
每小时 $0.0154 * 每天 24 小时 * 每月 30 天 = 每月 $11.088
所以,每月大约 11 美元,我就可以使用 Terraform Cloud 来管理我的环境。这还不包括我为源代码管理系统支付的费用和环境的云托管费用。
Plus 版本引入了第 2 天操作支持场景,例如审计日志、漂移检测、持续验证、临时工作区、ServiceNow 集成和无限制的政策即代码(Policy-as-Code),帮助你更好地管理环境并与日常操作集成。
Enterprise 版本本质上是托管服务,允许你将 Terraform Enterprise 部署到自己的数据中心,这对于不愿意利用 HashiCorp 的多租户产品 Terraform Cloud 降低运营成本的大型企业来说非常重要。
在这一部分,我们介绍了 Terraform Cloud,包括它在功能方面提供的内容,并且认识到与更通用的自动化平台不同,它专门为使用 Terraform 的 IaC 管理和协作量身定制。与这些更通用的管道工具相比,Terraform Cloud 通过提供一些专门为 Terraform 工作流设计的功能,如远程状态管理、安全变量存储和集成模块管理,使其脱颖而出。专注于 Terraform 特定功能使其成为那些希望将 IaC 流程提升到下一个层次的团队的理想选择。接下来,我们将探讨一些其他值得关注的关键趋势,虽然它们超出了本书的范围,但对于任何希望真正掌握 Terraform 的人来说,应该引起足够的关注。
接下来是什么?
在这一部分,我们将探索 Terraform 社区中的一些新兴趋势,这些趋势对于任何与 Terraform 打交道的人来说都至关重要。虽然这些话题技术上超出了本书的范围,包括尚未最终确定并可能随时间发展而变化的即将发布的功能,理解这些新兴话题能够为你提供有关 Terraform 未来发展方向的宝贵背景知识,并帮助你在掌握 Terraform 的过程中走在前沿。
CDK
Terraform Cloud 开发工具包(CDK)是一种使用你已经知道并在应用程序开发中使用的命令式编程语言开发 Terraform 配置的方法。可以使用任何语言,从 Python 到 C#,从 TypeScript 到 Java。任何 Terraform 提供程序和 Terraform 模块也都可以使用。它本质上与使用 HCL 相同,但可以使用你选择的编程语言。
图 18.4 – 使用你选择的编程语言
无论你选择哪种语言,最终都会编译成一个 Terraform 兼容的 JSON 文件,然后 Terraform 会像处理 HCL 文件一样进行解释。
这个选项非常适合已经使用编程语言的现有开发团队,他们不想花时间学习 HCL。然而,对于非开发人员来说,HCL 绝对是最佳选择,因为它提供了一种简单、功能强大的语言,更容易上手,并且已经有一个庞大的生态系统,很多从业者正在使用它,提出问题、回答问题并在公共 GitHub 仓库上共享代码,这些都可以帮助你在学习过程中前进。
Terraform 堆栈
Terraform 堆栈,这是 Terraform 未来的一项备受期待的功能,承诺将彻底改变我们在多个控制平面上设计和管理复杂架构的方式。预计这一创新功能将为使用 Terraform Cloud 和Terraform 社区版(命令行工具)的用户提供无缝集成的体验。通过允许更复杂的基础设施即代码(IaC)组织和模块化,Terraform 堆栈旨在简化大规模、多层次环境的部署和管理过程。我们将深入了解目前 HashiCorp 公开的信息,并探讨它发布时预计的功能。
当前状态
在当前的 Terraform 使用环境中,单一的根模块作为基础设施部署的基石。这个根模块包含提供者配置,并与各种 Terraform 资源进行交互,既可以直接交互,也可以通过模块引用进行交互。通过为根模块提供不同的输入参数,根模块的多功能性得以增强,从而根据所需的部署环境进行定制。为了进一步隔离每个根模块实例的部署,Terraform 工作区被用来生成单独的 Terraform 状态文件。这些状态文件将与特定的环境(如DEV
、TEST
或PROD
)唯一关联,有效地封装了每个环境中部署的基础设施的配置和状态:
图 18.5 – 当前状态:Terraform 工作区和根模块
在使用 Terraform 配置复杂环境的过程中,通常需要使用多个根模块来根据其依赖关系划分架构层次,例如考虑爆炸半径或具体的控制平面依赖关系,例如云平台与 Kubernetes 控制平面之间的依赖关系。这并不是唯一会遇到控制平面依赖的场景,但随着托管 Kubernetes 服务的日益普及,这已经成为一个常见的情况。依赖关系可能会在你使用两个或更多提供者配置资源时产生,其中一个提供者配置的资源将用于配置另一个 Terraform 提供者。根据该依赖提供者的初始化方式,你可能会遇到冲突,因为依赖于其他提供者资源中的控制平面来进行配置的提供者,可能会在terraform apply
和terraform destroy
时发生死锁。这是因为 Terraform 无法规划尚未存在的控制平面资源。
我遇到的其他常见场景是使用 grafana
提供程序在 Terraform 中为其配置资源。这与 kubernetes
提供程序创建的依赖关系类似。无论你使用哪个云平台,都没关系。许多云平台都有类似的托管服务,能通过它们相应的提供程序进行配置,并产生一个可以通过为该控制平面设计的 Terraform 提供程序自动化的端点。即使是像 minecraft
提供程序这样有趣的情况也是如此——无论你使用的是 EC2、Azure 虚拟机,还是 GCE!
尽管有两种主要方法可以实现这一点,但两者都需要多次执行 terraform apply
。第一种方法是独立配置每个阶段的部署,然后通过数据源将上游依赖关系链接到下游阶段,并由输入变量提供值。这种方法允许不同的团队相对独立地部署不同阶段,但也增加了配置管理的负担,因为每个下游依赖关系必须显式引用之前配置的上游阶段。因此,这种方法导致了高度串行的部署模式,要求每个上游依赖关系在进行下一个下游依赖关系之前必须先部署并稳定:
图 18.6 – 当前状态:独立部署与数据源依赖
使用 Terraform 配置复杂环境的另一种方法偏离了独立部署,而是采用了一个单体流水线,顺序执行 terraform apply
。在此模型中,依赖关系通过直接将上游依赖关系的 Terraform 输出传递到下游依赖关系的输入,从而无缝集成。尽管这种方法简化了自动化,但也导致了环境的紧密耦合。无论是哪种方法——无论是独立部署还是单体流水线——都需要实现大量的 粘合 以连接多个 terraform apply
步骤。这意味着需要编写 Bash 脚本或类似的自动化工具,作为连接的“粘合剂”,确保正确的值从一个流水线作业传递到下一个作业,从而在不同阶段之间保持部署过程的完整性。
图 18.7 – 当前状态:集成部署与基于输出的依赖
堆栈
在 .tfstack
文件中定义,堆栈允许你声明一个或多个 component
块,这些块本质上定义了当前的根模块。这些组件代表了部署中的离散且确定的配置阶段:
图 18.8 – 未来状态:Terraform Stacks
在前面的图示中,我们看到了构成我们堆栈的三个组件:
-
网络基础设施
-
计算基础设施
-
Kubernetes 部署
这将在 .tfstack
文件中以如下方式定义:
component "network" {
source = "./network"
inputs = {
region = var.region
}
providers = {
aws = providers.aws.this
}
}
计算基础设施将在同一文件中定义,但这次将引用它依赖的网络组件的输出。这告知 Terraform 首先配置 network
组件,并首先解决该阶段的部署,然后再尝试部署 compute
基础设施组件:
component "compute" {
source = "./compute"
inputs = {
region = var.region
network_name = component.network.network_name
}
providers = {
aws = providers.aws.this
}
}
在 compute
组件部署完毕后,我们将拥有一个准备好部署我们的应用和服务的 Kubernetes 集群。因此,我们声明堆栈的最后一个组件,即应用组件:
component "app" {
source = "./app"
inputs = {
region = var.region
cluster_name = component.compute.cluster_name
}
providers = {
aws = providers.aws.this
kubernetes = providers.kubernetes.this
helm = providers.helm.this
}
}
这使我们能够在必要的步骤完成以配置 Kubernetes 集群之后再初始化 kubernetes
和 helm
提供者,而 Kubernetes 集群在我们开始执行计划之前是绝对必要的。
部署
在 .tfdeploy
文件中定义的部署允许你声明一个或多个 deployment
块,这些块本质上定义了一个 Terraform 工作区,一旦部署完成,它就会表现为一个独立的 Terraform 状态文件,代表一个已配置的环境。引入部署使我们能够在配置中声明性地建立不同的环境,而不是通过组织 Terraform 工作区和执行 Terraform 核心工作流操作(如 plan
和 apply
)的上下文隐式实现。
部署充当了提供者配置的核心位置。这包括将每个提供者的首选身份验证方法与之关联。这是通过一个名为 identity_token
的新块完成的,该块会像这样为 AWS 定义:
identity_token "aws" {
audience = ["aws.workload.identity"]
}
这将在 .tfdeploy
文件中以如下方式定义:
deployment "dev" {
variables = {
region = "us-west-2"
identity_token_file = identity_token.aws.jwt_filename
}
}
如你所见,deployment
块允许我们建立多个 Stacks 实例,并使用它们各自的输入变量和提供者上下文进行配置,包括相关的身份验证和授权上下文。
Terraform Stacks 是 Terraform Cloud 中一种令人兴奋的新功能,正在预览阶段,计划发布至 Terraform Cloud 和 Terraform Community Edition。正如你所见,通过这种方法,我们将能够消除目前在管道中投入的大量 复杂操作(即 GitHub Actions,Azure DevOps,Jenkins 等),并将其替换为可以通过我们在 第六章 中学到的 Gitflow 标准来管理的 Terraform 配置。如果你计划使用 Terraform 管理复杂解决方案,这是一个值得关注的功能,未来的版本中会有!
总结
在《掌握 Terraform》的最后一章中,我们探讨了那些希望加深对 Terraform 掌握并跟上社区新兴趋势的读者的下一步发展。我们讨论了 Terraform 认证的重要性,重点介绍了助理级和专业级考试。
我们还深入探讨了 Terraform Cloud,它可以增强 IaC 过程中的自动化和协作,建立在我们在本书中覆盖的工作流和概念之上。
Terraform 社区充满活力,并不断发展,新的趋势和替代路径定期涌现。我们探讨了一些最新的进展,包括 Terraform CDK,它允许你使用熟悉的编程语言来使用 Terraform,最后,我们还展望了一些 Terraform 的令人兴奋的即将推出的功能,例如 Terraform Stacks,它承诺通过提供更多的灵活性和模块化,革新我们通过 IaC 管理环境的方式,从而更好地定义和部署复杂的分层云架构。
结语
我们走得很远,也走得很长。我们探索了 Terraform,它的架构,它的能力,以及它的形式和功能。除了学习 Terraform,真正成为 Terraform 的大师,我们需要将自己扎根于作为 IaC 从业者所期望的架构和工作模式中。这意味着我们需要深入了解云架构,以便充分发挥 Terraform 的潜力。这包括我们在实际环境中会遇到的各种事物——从虚拟机到容器,再到无服务器架构——以及所有支持这些架构的辅助资源。一旦我们掌握了这些核心概念知识,我们将能够更好地在当今的多云世界中航行,真正超越当前所选的云服务商,做好迎接未来的准备——无论未来是什么。
感谢你与我一同踏上这段旅程。我希望你喜欢我这种非常专注和实用的 Terraform 掌握方法。我认为最好的学习方式就是通过实践,所以我鼓励你前往 GitHub,克隆本书中描述的任何或所有解决方案,开始你的 Terraform 掌握之旅!