模块组成
在一个简单的 OpenTofu 配置中,只有一个根模块,我们创建一组平坦的资源,并使用 OpenTofu 的表达式语法来描述这些资源之间的关系
resource "aws_vpc" "example" {
cidr_block = "10.1.0.0/16"
}
resource "aws_subnet" "example" {
vpc_id = aws_vpc.example.id
availability_zone = "us-west-2b"
cidr_block = cidrsubnet(aws_vpc.example.cidr_block, 4, 1)
}
当我们引入 module
块时,我们的配置将从平坦变为层次结构:每个模块包含它自己的一组资源,以及它自己的子模块,这可能会创建一个深层次的、复杂的资源配置树。
但是,在大多数情况下,我们强烈建议保持模块树平坦,只有一级子模块,并使用类似于上述方法的技巧,使用表达式来描述模块之间的关系
module "network" {
source = "./modules/aws-network"
base_cidr_block = "10.0.0.0/8"
}
module "consul_cluster" {
source = "./modules/aws-consul-cluster"
vpc_id = module.network.vpc_id
subnet_ids = module.network.subnet_ids
}
我们称这种平坦的模块使用方式为模块组成,因为它将多个可组合的构建块模块组合在一起,以生成更大的系统。模块不是嵌入它的依赖项,创建并管理自己的副本,而是接收来自根模块的依赖项,因此根模块可以以不同的方式连接相同的模块以产生不同的结果。
本页的其余部分讨论一些更具体的组成模式,这些模式在使用 OpenTofu 描述更大的系统时可能很有用。
依赖倒置
在上面的示例中,我们看到了一个 consul_cluster
模块,它可能描述了在 AWS VPC 网络中运行的一组 HashiCorp Consul 服务器,因此它需要 VPC 本身和 VPC 内的子网的标识符作为参数。
另一种设计方法是让 consul_cluster
模块描述它自己的网络资源,但如果我们这样做,那么 Consul 集群将很难与同一网络中的其他基础设施共存,因此,只要有可能,我们更倾向于保持模块相对较小,并将它们的依赖项传递给它们。
这种依赖倒置方法也有助于提高未来重构的灵活性,因为 consul_cluster
模块不知道也不关心这些标识符是如何由调用模块获取的。未来的重构可能会将网络创建分离到它自己的配置中,因此我们可以从数据源而不是模块中将这些值传递给模块
data "aws_vpc" "main" {
tags = {
Environment = "production"
}
}
data "aws_subnet_ids" "main" {
vpc_id = data.aws_vpc.main.id
}
module "consul_cluster" {
source = "./modules/aws-consul-cluster"
vpc_id = data.aws_vpc.main.id
subnet_ids = data.aws_subnet_ids.main.ids
}
条件对象创建
在同一个模块跨多个环境使用的情况下,常见的情况是,某些必要对象在某些环境中已经存在,但在其他环境中需要创建。
例如,这可能会在开发环境场景中出现:出于成本原因,某些基础设施可能会跨多个开发环境共享,而在生产中,基础设施是唯一的,并由生产配置直接管理。
与其尝试编写一个模块,让它自己尝试检测某样东西是否存在,如果不存在就创建它,我们建议采用依赖倒置方法:通过输入变量让模块接受它需要的对象作为参数。
例如,考虑这种情况,一个 OpenTofu 模块根据磁盘映像部署计算实例,在某些环境中可以使用专门的磁盘映像,而在其他环境中共享一个通用的基本磁盘映像。与其让模块本身处理这两种情况,我们可以声明一个表示磁盘映像的输入变量。以 AWS EC2 为例,我们可以声明 aws_ami
资源类型和数据源架构的通用子类型
variable "ami" {
type = object({
# Declare an object using only the subset of attributes the module
# needs. OpenTofu will allow any object that has at least these
# attributes.
id = string
architecture = string
})
}
现在,这个模块的调用者可以直接表示这是否是一个要在线创建的 AMI,或者是一个要从其他地方检索的 AMI
# In situations where the AMI will be directly managed:
resource "aws_ami_copy" "example" {
name = "local-copy-of-ami"
source_ami_id = "ami-abc123"
source_ami_region = "eu-west-1"
}
module "example" {
source = "./modules/example"
ami = aws_ami_copy.example
}
# Or, in situations where the AMI already exists:
data "aws_ami" "example" {
owner = "9999933333"
tags = {
application = "example-app"
environment = "dev"
}
}
module "example" {
source = "./modules/example"
ami = data.aws_ami.example
}
这与 OpenTofu 的声明式风格一致:与其创建包含复杂条件分支的模块,我们直接描述什么应该已经存在,以及我们希望 OpenTofu 自行管理什么。
通过遵循这种模式,我们可以明确地说明我们期望 AMI 在哪些情况下已经存在,以及哪些情况下不存在。然后,将来阅读配置的人员可以直接了解其意图,而无需先检查远程系统的状态。
在上面的示例中,要创建或读取的对象足够简单,可以作为单个资源在线提供,但我们也可以像本页其他地方所述那样组合多个模块,在依赖项本身足够复杂以至于需要抽象的情况下。
假设和保证
每个模块都具有隐式的假设和保证,它们定义了模块期望接收哪些数据,以及模块为使用者生成哪些数据。
- 假设: 一个必须为真的条件,才能使特定资源的配置可用。 例如,
aws_instance
配置可以假设给定的 AMI 将始终配置为x86_64
CPU 架构。 - 保证: 对象的一个特性或行为,配置的其余部分应该能够依赖它。 例如,
aws_instance
配置可以保证 EC2 实例将在分配给它私有 DNS 记录的网络中运行。
我们建议使用自定义条件来帮助捕获和测试假设和保证。 这有助于未来的维护人员了解配置的设计和意图。 自定义条件也会在更早的阶段并以更直接的方式返回有关错误的有用信息,帮助使用者更轻松地诊断其配置中的问题。
以下示例创建了一个前提条件,用于检查 EC2 实例是否具有加密的根卷。
output "api_base_url" {
value = "https://${aws_instance.example.private_dns}:8433/"
# The EC2 instance must have an encrypted root volume.
precondition {
condition = data.aws_ebs_volume.example.encrypted
error_message = "The server's root volume is not encrypted."
}
}
多云抽象
OpenTofu 本身有意不尝试抽象化不同供应商提供的类似服务,因为我们希望公开每个产品中的全部功能,而将多个产品统一到单个接口后面往往需要“最低公分母”方法。
但是,通过模块组合,可以根据您对哪些平台功能重要的权衡来创建自己的轻量级多云抽象。
在多个供应商实现相同概念、协议或开放标准的任何情况下,都会出现此类抽象的机会。 例如,域名系统的基本功能在所有供应商中都是通用的,尽管一些供应商通过诸如地理定位和智能负载均衡等独特功能来区分自己,但您可能会得出结论,在您的用例中,您愿意放弃这些功能以换取创建模块,这些模块可以抽象多个供应商之间的通用 DNS 概念。
module "webserver" {
source = "./modules/webserver"
}
locals {
fixed_recordsets = [
{
name = "www"
type = "CNAME"
ttl = 3600
records = [
"webserver01",
"webserver02",
"webserver03",
]
},
]
server_recordsets = [
for i, addr in module.webserver.public_ip_addrs : {
name = format("webserver%02d", i)
type = "A"
records = [addr]
}
]
}
module "dns_records" {
source = "./modules/route53-dns-records"
route53_zone_id = var.route53_zone_id
recordsets = concat(local.fixed_recordsets, local.server_recordsets)
}
在上面的示例中,我们创建了一个“记录集”对象的轻量级抽象。 它包含描述 DNS 记录集的一般概念的属性,这些属性应该映射到任何 DNS 提供商。
然后,我们将该抽象的一种特定实现实例化为一个模块,在本例中,我们将我们的记录集部署到 Amazon Route53。
如果我们以后想要切换到不同的 DNS 提供商,我们只需要用针对该提供商的新实现替换 dns_records
模块,并且所有生成记录集定义的配置都可以保持不变。
我们可以通过定义代表相关概念的 OpenTofu 对象类型,然后将这些对象类型用于模块输入变量,来创建像这样的轻量级抽象。 在这种情况下,我们所有“DNS 记录”实现都将声明以下变量
variable "recordsets" {
type = list(object({
name = string
type = string
ttl = number
records = list(string)
}))
}
虽然 DNS 只是一个简单的示例,但还有很多机会可以利用不同供应商之间的共同元素。 更复杂的示例是 Kubernetes,现在有许多不同的供应商提供托管 Kubernetes 集群,以及更多自行运行 Kubernetes 的方法。
如果所有这些实现之间的通用功能足以满足您的需求,您可以选择实现一组不同的模块,这些模块描述特定 Kubernetes 集群实现,并且所有模块都具有将集群主机名作为输出值导出的共同特征。
output "hostname" {
value = azurerm_kubernetes_cluster.main.fqdn
}
然后,您可以编写其他模块,这些模块仅期望 Kubernetes 集群主机名作为输入,并将它们与您的任何 Kubernetes 集群模块互换使用。
module "k8s_cluster" {
source = "modules/azurerm-k8s-cluster"
# (Azure-specific configuration arguments)
}
module "monitoring_tools" {
source = "modules/monitoring_tools"
cluster_hostname = module.k8s_cluster.hostname
}
仅数据模块
大多数模块包含 resource
块,因此描述要创建和管理的基础设施。 有时,编写不描述任何新基础设施的模块可能会有用,而是仅使用数据源检索有关使用其他方式创建的现有基础设施的信息。
与传统模块一样,我们建议仅在模块以某种方式提高抽象级别时使用此技术,在这种情况下,通过封装数据检索的确切方式。
此技术的常见用法是,当系统被分解为多个子系统配置,但某些基础设施在所有子系统之间共享时,例如公用 IP 网络。 在这种情况下,我们可以编写一个名为 join-network-aws
的共享模块,该模块可以被任何在 AWS 中部署时需要有关共享网络信息的配置调用。
module "network" {
source = "./modules/join-network-aws"
environment = "production"
}
module "k8s_cluster" {
source = "./modules/aws-k8s-cluster"
subnet_ids = module.network.aws_subnet_ids
}
network
模块本身可以通过多种方式检索此数据:它可以使用 aws_vpc
和 aws_subnet_ids
数据源直接查询 AWS API,或者可以使用 consul_keys
从 Consul 集群读取保存的信息,或者它可能使用 terraform_remote_state
直接从管理网络的配置状态读取输出。
这种方法的主要好处是,此信息来源可以随着时间的推移而改变,而无需更新所有依赖它的配置。 此外,如果您设计您的仅数据模块,使其与相应的管理模块具有类似的输出集,那么您可以在重构时相对轻松地在这两者之间切换。