1.6.5.1. 设计新模块的模式
Terraform 模块是独立的基础设施即代码片段,抽象了基础设施部署的底层复杂性。Terraform 用户通过使用预置的配置代码加速采用 IaC,并降低了使用门槛。所以,模块的作者应尽量遵循诸如清晰的代码结构以及 DRY("Dont't Repeat Yourself")原则的代码最佳实践。
本篇指导讨论了模块架构的原则,用以帮助读者编写易于组合、易于分享及重用的基础设施模块。这些架构建议对使用任意版本 Terraform 的企业都有助益,某些诸如“私有模块注册表(Registry)”的模式仅在 Terraform Cloud 以及企业版中才能使用。(本文不对相关内容进行翻译)
本文是对 Terraform 模块文档的补充和扩展。
通过阅读文本,读者可以:
- 学习有关 Terraform 模块创建的典型工作流程和基本原则。
- 探索遵循这些原则的示例场景。
- 学习如何通过协作改进 Terraform 模块
- 了解如何创建一套使用模块的工作流程。
1.6.5.1.1. 模块创建的工作流
要创建一个新模块,第一步是寻找一个早期采纳者团队,收集他们的需求。
与这支早期采纳团队一起工作使我们可以通过使用输入变量以及输出值来确保模块足够灵活,从而打磨模块的功能。此外,还可以用最小的代码变更代价吸纳其他有类似需求的团队加入进来。这消除了代码重复,并缩短了交付时间。
完成以上任务后,需要谨记两点:
- 将需求范围划分成合适的模块。
- 创建模块的最小可行产品(Minimum Viable Product, MVP)
1.6.5.1.1.1. 将需求范围划分成合适的模块
创建新 Terraform 模块时最具挑战的方面之一是决定要包含哪些基础设施资源。
模块设计应该是有主见的,并且被设计成能很好地完成一个目标。如果一个模块的功能或目的很难解释,那么这个模块可能太复杂了。在最初确定模块的范围时,目标应当足够小且简单,易于开始编写。
当构建一个模块时,需要考虑以下三个方面:
- 封装:一组始终被一起部署的基础设施资源 在模块中包含更多的基础设施资源简化了终端用户部署基础设施的工作,但会使得模块的目的与需求变得更难理解。
- 职责:限制模块职责的边界 如果模块中的基础设施资源由多个组来负责,使用该模块可能会意外违反职责分离原则。模块中仅包含职责边界内的一组资源可以提升基础设施资源的隔离性,并保护我们的基础设施。
- 变化频率:隔离长短生命周期基础设施资源 举例来说,数据库基础设施资源相对来说较为静态,而团队可能在一天内多次部署更新应用程序服务器。在同一个模块中同时管理数据库与应用程序服务器使得保存状态数据的重要基础设施没有必要地暴露在数据丢失的风险之中。
1.6.5.1.1.2. 创建模块的最小可行产品
如同所有类型的代码一样,模块的开发永远不会完成,永远会有新的模块需求以及变更。拥抱变化,最初的模块版本应致力于满足最小可行产品(MVP)的标准。以下是在设计最小可行产品时需要谨记的指导清单:
- 永远致力于交付至少可以满足 80% 场景的模块
- 模块中永远不要处理边缘场景。边缘场景是很少见的。一个模块应该是一组可重用的代码。
- 在最小可行产品中避免使用条件表达式。最小可行产品应缩小范围,不应该同时完成多种任务。
- 模块应该只将最常被修改的参数公开为输入变量。一开始时,模块应该只提供最可能需要的输入变量。
尽可能多输出
在最小可行产品中输出尽可能多的信息,哪怕目前没有用户需要这些信息。这使得那些通常使用多个模块的终端用户在使用该模块时更加轻松,可以使用一个模块的输出作为下一个模块的输入。
请记住在模块的 README
文档中记录输出值的文档。
1.6.5.1.2. 探索遵循这些原则的一个示例场景
某团队想要通过 Terraform 创建一套包含 Web 层应用、App 层应用的基础设施。
他们想要使用一个专用的 VPC,并遵循传统的三层架构设计。他们的 Web 层应用需要一个自动伸缩组(AutoScaling Group)。他们的 App 层服务需要一个自动伸缩组,一个 S3 存储桶以及一个数据库。下面的架构图描述了期望的结果:
该场景中,一个负责从零开始撰写 Terraform 代码的团队,负责编写一组用以配置基础设施及应用的模块。负责应用程序的团队成员将使用这些模块来配置他们需要的基础设施。
请注意,虽然该示例使用了 AWS 命名,但所描述的模式适用于所有云平台。
经过对应用程序团队的需求进行审核,模块团队将该应用基础设施分割成如下模块:网络、Web、App、数据库、路由,以及安全。
当 Terraform 模块团队完成模块开发后,他们应该将模块导入到私有模块注册表中,并且向对应的团队成员宣传模块的使用方法。举例来说,负责网络的团队成员将会使用开发的网络模块来部署和配置相应的应用程序网络。
1.6.5.1.2.1. 网络模块
网络模块负责网络基础设施。它包含了网络访问控制列表(ACL)以及 NAT 网关。它也可以包含应用程序所需的 VPC、子网、对等连接以及 Direct Connect 等。
该模块包含这些资源是因为它们需要特定权限并且变化频率较低。
- 只有应用程序团队中有权创建或修改网络资源的成员可以使用该模块。
- 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。
网络模块返回一组其他工作区(Workspace)以及模块可以使用的输出值。如果 VPC 的创建过程是由多个方面组成的,我们可能最终会需要将该模块进一步切割成拥有不同功能的不同模块。
1.6.5.1.2.2. 应用程序模块
本场景中有两个应用程序模块 —— 一个是 Web 层模块,另一个是 App 层模块。
Terraform 模块团队完成这两个模块的开发后,它们应被分发给对应的团队成员来部署他们的应用。随着应用程序团队的成员变得越来越熟悉 Terraform 代码,它们可以提出基础设施方面的增强建议,或是通过 Pull Request 配合他们自己的应用代码发布提交对基础设施的变更请求。
Web 模块
Web 模块创建和管理运行 Web 应用程序所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也可以包含应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 Web 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。
该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高。
- 此模块中的资源高度内聚,并且与 Web 应用程序紧密相关(例如,此模块需要一个包含最新 Web 层应用程序代码版本的 AMI)。结果就是它们被编制进同一个模块,这样 Web 应用团队的成员们就可以轻松地部署它们。
- 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。
App 模块
App 模块创建和管理运行 App 层应用所需的基础设施。它包含了负载均衡器和自动伸缩组,同时也包含了应用程序中使用的 EC2 虚拟机实例、S3 存储桶、安全组,以及日志系统。该模块接收一个通过 Packer 预构建的包含最新 App 层应用发布版本代码的虚拟机镜像的 AMI ID 作为输入。
该模块包含这些资源是因为它们是高度封装的,并且它们变化频率较高。
- 此模块中的资源高度内聚,并且与 App 应用程序紧密相关。结果就是它们被编制进同一个模块,这样 App 层应用团队的成员们就可以轻松地部署它们。
- 该模块的资源变更频率较高(每次发布更新版本都需要更新对应基础设施资源)。通过将它们组合在单独的模块中,我们降低了将其他模块的资源暴露在没有必要的数据丢失的风险中的可能性。
数据库模块
数据库模块创建并管理了运行数据库所需的基础设施资源。它包含了应用程序所需的 RDS 实例,也包含了所有关联的存储、备份以及日志资源。
该模块包含这些资源是因为它们需要特定权限并且变化频率较低。
- 只有应用程序团队中有权创建或修改数据库资源的成员可以使用该模块。
- 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。
路由模块
路由模块创建并管理网络路由所需的基础设施资源。它包含了公共托管区域(Hosted Zone)、Route 53 以及路由表,也可以包含私有托管区域。
该模块包含这些资源是因为它们需要特定权限并且变化频率较低。
- 只有应用程序团队中有权创建或修改路由资源的成员可以使用该模块。
- 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。
安全模块
安全模块创建并管理所有安全所需的基础设施资源。它包含一组 IAM 资源,也可以包含安全组(Security Group)及多因素认证(MFA)。
该模块包含这些资源是因为它们需要特定权限并且变化频率较低。
- 只有应用程序团队中有权创建或修改 IAM 或是安全资源的成员可以使用该模块。
- 该模块的资源不会经常变更。将它们组合在单独的模块中可以保护它们免于暴露在没有必要的数据丢失的风险之中。
1.6.5.1.3. 创建模块的提示
除了范围界定之外,我们在创建模块时还应牢记以下几点:
1.6.5.1.3.1. 嵌套模块
嵌套模块是指在当前模块中对另一个模块的引用。嵌套模块可以是外部的,也可以是当前工作空间内的。使用嵌套模块是一项强大的功能;然而我们必须谨慎实践以避免引入错误。
对于所有类型的嵌套模块,请考虑以下事项:
- 嵌套模块可以加速开发速度,但可能会引发未知以及意料之外的结果。请在文档中清晰地记录输入变量、模块行为以及输出值。
- 通常来说,不要让主模块的嵌套深度超过两层。常用且简单的工具模块,例如专门用来定义 Tag 的模块,则不受此限制制约。
- 嵌套模块必须包含必要的用来创建指定的资源配置的输入参数以及输出值。
- 输入参数以及输出值的命名应遵循一致的命名约定,以使得模块可以更容易地被分享,以及将一个模块的的输出值作为另一个模块的输入参数。
- 嵌套模块可能会导致代码冗余。必须同时在父模块与嵌套模块中声明输入参数和输出值。
嵌套的外部模块
当我们需要使用那些定义了被多个应用程序堆栈、应用程序和团队复用的标准化资源的通用模块时,嵌套的外部模块会很有用。外部模块通被集中管理和版本化控制,以使得消费者在使用新版本之前可以对其进行验证。当我们依赖或希望使用位于外部的子模块时,请注意以下几点:
- 外部模块必须被独立维护,并可供任何需要调用它的模块使用。使用模块注册表可以确保这一点。
- 根据模块注册要求,嵌套模块将拥有自己的版本控制代码仓库,独立于调用模块进行版本控制。
- 对嵌套模块的变更可能会影响调用模块,即使调用模块的调用代码及版本没有发生变化,这会破坏调用代码的信任。
- 对调用模块如何使用外部模块在文档中进行记录,使得模块行为以及调用关系可以被轻松理解。
- 对外部模块的变更应该是向后兼容的。如果向后兼容是不可能的,则应清楚地记录需要对任何调用模块进行的更改,并将之分发给所有模块使用者以避免意外。
嵌套的嵌入模块
在当前工作空间中嵌入一个模块使得我们能够清晰地分离模块的逻辑组件,或是创建可在调用模块执行期间多次调用的可重用代码块。在下面的例子中,ec2-instance
是一个嵌入模块,根模块的 main.tf
引用了该模块:
root-module-directory
├── README.md
├── main.tf
└── ec2-instances
└── main.tf
如果我们需要或者倾向于使用嵌入模块,需要考虑以下几点:
- 在“根模块”中添加嵌入模块意味着子模块与根模块被放在一起进行版本控制。
- 任何影响两个模块间兼容性的变更都会被快速发现,因为它们必须被一同测试和发布。
- (嵌入的)子模块不能被代码树之外的其他模块调用,所以可能会增加重复的代码。举例来说,如果嵌入的
ec2-instance
模块是用来创建一台被用在多个地方的标准化的计算实例,该模块无法以这种形式被分享。
标签化模块名并记录在文档中
为我们的模块创建并遵循一个命名约定将使得模块易于理解与使用。这将促进模块的采用和贡献。以下是一个用以提升模块元素一致性的建议列表:
- 使用一个对人类来说一致且易于理解的模块命名约定。举例来说:
terraform | cloud provider | function | full name |
---|---|---|---|
terraform | aws | consul cluster | terraform-aws-consul_cluser |
terraform | aws | security module | terraform-aws-security |
terraform | azure | database | terraform-azure-database |
- 使用人类可以理解的输入变量命名约定。模块是编写一次并多次使用的代码,因此请完整命名所有内容以提升可读性,并在编写代码时在文档中进行记录。
- 对所有模块进行文档记录。确保文档中包含有:
- 必填的输入变量:这些输入变量应该是经过深思熟虑后的选择。如果这些输入变量值未定义,模块运行将失败。只在必要时为这些输入变量设置默认值。例如
var.vpc_id
永远不应该有默认值,因为每次使用模块时值都会不同。 - 可选的输入变量:这些输入变量应该有一个合理的,适用于大多数场景的默认值,同时又可以根据需求进行调整。公告输入变量的默认值。例如
var.elb_idle_timeout
会有一个合理的默认值,但调用者也可以根据需求修改它的值。 - 输出值:列出模块的所有输出值,并将重要的输出和信息性的输出包装在对用户友好的输出模板中。
- 必填的输入变量:这些输入变量应该是经过深思熟虑后的选择。如果这些输入变量值未定义,模块运行将失败。只在必要时为这些输入变量设置默认值。例如
定义并使用一个一致的模块结构
虽然模块结构是一个品味问题,我们应当将模块的结构记录在文档中,并且在我们的所有模块之间保持统一的结构。为了要维持模块结构的一致:
- 定义一组模块必须包含的
.tf
文件,定义它们应包含哪些内容 - 为模块定义一个
.gitignore
(或类似作用的)文件 - 创建供样例代码所使用的输入变量值的标准方式(例如一个
terraform.tfvars.example
文件) - 使用具有固定子目录的一致的目录结构,即使它们可能是空的
- 所有模块目录都必须包含一个
README
文件详细记述目录存在的目的以及如何使用其中的文件
1.6.5.1.4. 模块的协作
随着团队模块的开发工作,简化我们的协作。
- 为每个模块创建路线图
- 从用户处收集需求信息,并按受欢迎程度进行优先级排序。
- 不使用模块的最常见原因是“它不符合我的要求”。收集这些需求并将它们添加到路线图或对用户的工作流程提出建议。
- 检查每一项需求以确认它引用的用例是否正确。
- 公布和维护需求列表。分享该列表并让用户参与列表管理过程。
- 不要为边缘用例排期。
- 将每一个决策记录进文档。
- 在公司内部采用开源社区原则。一些用户希望尽可能高效地使用这些模块,而另一些用户则希望帮助创建这些模块。
- 创建一个社区
- 维护一份清晰和公开的贡献指引
- 最终,我们将允许可信的社区成员获得某些模块的所有权
1.6.5.1.5. 使用源代码控制系统追踪模块
一个 Terraform 模块应遵守所有良好的代码实践:
- 将模块置于源代码控制中以管理版本发布、协作、变更的审计跟踪。
- 为所有
main
分支的发布版本建立版本标签,记录文档(最起码在CHANGELOG
及README
中记录)。 - 对
main
分支的所有变更进行代码审查 - 鼓励模块的用户通过版本标签引用模块
- 为每一个模块指派一位负责人
- 一个代码仓库只负责一个模块
- 这对于模块的幂等性和作为库的功能至关重要。
- 我们应该对模块打上版本标签或是版本化控制。打上版本标签或是版本化的模块应该是不可变的。
- 发布到私有模块注册表的模块必须要有版本标签。
1.6.5.1.6. 开发一套模块消费工作流
定义和宣传一套消费者团队使用模块时应遵循的可重复工作流程。这个工作流程,就像模块本身一样,应该考虑到用户的需求。
1.6.5.1.6.1. 阐明团队应该如何使用模块
- 分散的安全性:如果每个模块都在自己的存储库中进行版本控制,则可以使用存储库 RBAC 来管理谁拥有写访问权限,从而允许相关团队管理相关的基础设施(例如网络团队拥有对网络模块的写访问权限)。
- 培育代码社区:鉴于上述建议,模块开发的最佳实践是允许对存储在私有模块存储库中的模块的所有模块存储库提出 Pull Request。这促进了组织内的代码社区,保持模块内容的相关性和最大的灵活性,并有助于保持模块注册表的长期有效性。