1.2.1.1. Terraform 资源文件
几乎所有的 Terraform 入门教程与示例都会以一个 main.tf
文件作为开始,对于一个足够简单的模块来说,我们大可将所有 resource
块都声明在此文件内。但是如果我们的模块稍微复杂一些,我们就要考虑将 resource
块拆分到不同的文件中去了。例如:
以 terraform-azurerm-avm-res-storage-storageaccount
模块为例,它的目录下有这些文件名含有 main
的 .tf
文件:
.
├── main.containers.tf
├── main.data_lake.tf
├── main.deprecated.tf
├── main.diagnostics.tf
├── main.locks.tf
├── main.management_policy.tf
├── main.privateendpoint.tf
├── main.queues.tf
├── main.shares.tf
├── main.tables.tf
├── main.telemetry.tf
└── main.tf
这是一个用来管理 Azure Storage Account 的模块。Azure Storage Account 管理了多种类型的数据存储服务:
- Blob
- File
- Table
- Queue
对应了 main.containers.tf
、main.shares.tf
、main.tables.tf
、main.queues.tf
四个文件。每个文件中都包含了一个或多个 resource
块,分别用来管理不同类型的存储服务。azurerm_storage_account
资源块作为核心资源,被定义在 main.tf
文件中。
一个模块应始终含有 main.tf
,其中包含了最核心的代码块。如果资源类型较多,关系较为复杂,那么可以根据相关性,将它们定义在不同的文件中。
1.2.1.1.1. 本地子模块 vs 拆分 .tf 代码文件
对应于将不同资源块拆分到不同的 .tf
文件中,我们还可以将它们拆分到不同的子模块中。考虑下这样的一种做法:将 azurerm_storage_account
资源块放在一个 main.tf
中,将 Blob Container、Table、Queue 等资源块放在不同的子模块(也就是子文件夹)中,然后在 main.tf
文件中通过 module
块来引用它们。这样做的好处是可以将不同的资源块进行更好的隔离,避免了不同资源块之间的相互影响,但同时也会使得模块的结构变得更加复杂。
HashiCorp 在其官方文档专门提到了这一点:
However, in most cases we strongly recommend keeping the module tree flat, with only one level of child modules, and use a technique similar to the above of using expressions to describe the relationships between the modules
在编写可复用模块时如果我们使用了本地子模块,那么对使用我们模块的用户来说,从他们的根模块看去,就有了三层资源结构:根模块 -> 可复用模块 -> 可复用模块的子模块,这会导致理解代码的难度指数增加。所以只有在你确信子模块的收益远大于复杂度增加带来的困难时,才应该这么做。另外,无需多言的是,仅包含一个资源块的子模块很明显是一种反模式,因为我们还不如直接把资源块声明在单独的代码文件中了。
存在一种特殊的模式,即子模块作为单独可复用模块地址发布,我们将在后续章节中介绍这种做法。
1.2.1.1.2. 同一个文件中 resource 和 data 定义的顺序
同一文件中的资源定义,被依赖的资源应定义在前,依赖方资源定义在后。
相互之间有依赖关系的资源,尽量定义在邻近位置。
1.2.1.1.3. 依赖于 resource 或 data 的 Attribute 的 local 定义
对于某些反复出现的表达式,或是比较复杂的表达式,为了代码可读性,我们鼓励将之抽至一个独立的 local
变量中引用。
如果表达式中涉及到 resource
或是 data
,该 local
定义的位置,在表达式所涉及的 resource
或是 data
中最重要的那一个的定义块的下方。同一 resource
或是 data
块下方至多只能有一个 locals
块,所有定义于此的 local
都应该以字母序定义在该块中。两个 local
之间不应空行。
1.2.1.1.4. 使用 count 和 for_each
我们可以使用 count
和 for_each
来部署多个资源,但对 count
的不正确使用会导致意外的行为
只有在创建一组完全一致,或是接近一致的资源时,可以使用 count
。比如说,如果我们使用 count
去迭代一个 list(string)
,那大概率是错误的,因为修改列表中的元素会导致资源的顺序发生变化,引发难以预知的问题。
另一种使用 count
的场景是有条件创建某种资源,例如:
resource "azurerm_network_security_group" "this" {
count = local.create_new_security_group ? 1 : 0
name = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
resource_group_name = var.resource_group_name
location = local.location
tags = var.new_network_security_group_tags
}
1.2.1.1.5. resource、 data、ephemeral 块的内部顺序
resource
、data
和 ephemeral
块中的赋值语句分为三种:Argument 、Meta-Argument与 Nested Block。Argument 的赋值表达式为参数名后接 =
的,例如:
location = azurerm_resource_group.example.location
又或者(注意,虽然有花括号,但也有 =
号,所以它也是 Argument):
tags = {
environment = "Production"
}
Nested Block 的赋值表达式为参数名后接一个 {}
块(没有 =
号),例如:
subnet {
name = "subnet1"
address_prefix = "10.0.1.0/24"
}
Meta-Argument 为所有 resource
与 data
块都可以声明的赋值语句,有:
count
depends_on
for_each
lifecycle
provider
resource
、data
和 ephemeral
块内部赋值语句的声明顺序应为:
以下 Meta-Argument 在块中应声明在最上方,按顺序排列:
provider
count
for_each
然后是必填的 Argument,以字母顺序排列
然后是选填的 Argument,以字母顺序排列
然后是必填的 Nested Block,以字母顺序排列
然后是选填的 Nested Block,以字母顺序排列
以下 Meta-Argument 在块中应声明在最下方,按顺序排列:
depends_on
lifecycle
其中,lifecycle
块中的参数应以以下顺序出现:
create_before_destroy
ignore_changes
prevent_destroy
depends_on
和 ignore_changes
的成员以字母顺序排序。
Meta-Argument、Argument、Nested Block 之间,以空行分隔。
以 dynamic
形式出现的 Nested Block,以 dynamic
后的名字作为排序依据,例如:
dynamic "linux_profile" {
for_each = var.admin_username == null ? [] : ["linux_profile"]
content {
admin_username = var.admin_username
ssh_key {
key_data = replace(coalesce(var.public_ssh_key, tls_private_key.ssh[0].public_key_openssh), "\n", "")
}
}
}
该 dynamic
块视同一个 linux_profile
块进行排序,Meta-Argument、Argument、Nested Block 之间,以空行分隔。
在 Nested Block 中的代码也按照上述规则排序,
1.2.1.1.6. module 块的内部顺序
以下 Meta-Argument 在 module
块中应声明在最上方,按顺序排列:
source
version
然后是
count
for_each
source
、version
与 count
、for_each
之间应以空行分隔。
然后是必填的 Argument,以字母顺序排列
然后是选填的 Argument,以字母顺序排列
以下 Meta-Argument 在 resource
块中应声明在最下方,按顺序排列:
depends_on
providers
Argument 部分与 Meta-Argument 之间应以空行分隔。
1.2.1.1.7. 传递给 provider, depends_on, lifecycle 块中的 ignore_changes 中的值,不允许使用双引号
1.2.1.1.8. 对于可以定义 tags 的资源,始终应该通过 variable 暴露给 Module 调用者,使其可以设置 tags
许多云资源管理工具重度依赖与资源的 tag,如果我们不暴露 tags
使用户可以自由定制,则会造成无法与这些管理工具整合。
1.2.1.1.9. 根据输入参数是否为 null 来判定是否创建某种资源的场景
有时我们要确保创建的资源符合某种最低限度的合规,例如所有的 subnet
都要关联至少一个 network_security_group
。用户可能会传递一个 security_group_id
要求我们关联到一个已经存在的 security_group
上,也有可能用户希望我们为其创建安全组。
直觉上我们会直接这样定义:
variable "security_group_id" {
type = string
}
resource "azurerm_network_security_group" "this" {
count = var.security_group_id == null ? 1 : 0
name = coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
resource_group_name = var.resource_group_name
location = local.location
tags = var.new_network_security_group_tags
}
这种做法的缺点是如果用户直接在 Root Module 中创建安全组,并将其 id
作为 Module 的 variable
时,会引发一个问题,决定 count
具体值的表达式中出现了另一个 resource
的 attribute
,该值在 Plan 阶段为 Known After Apply,所以 Terraform Core 会认为无法在 Plan 阶段得到一个确定的计划。
这种参数我们推荐使用 object
类型进行一次包装:
variable "security_group" {
type = object({
id = string
})
default = null
}
这样做的优点是将 Known After Apply 值封装在 object
内部,而 object
本身的引用是可以轻松判定是否为 null
的。鉴于一个 resource
的 id
不可能为 null
,所以这种用法可以避免这种尴尬的情况。
仅在根据输入参数是否为 null
来判定是否创建某种资源的场景下使用该技巧。
1.2.1.1.10. 可选的 Nested Object Argument 要配合 dynamic
一个社区的例子是:
resource "azurerm_kubernetes_cluster" "main" {
...
dynamic "identity" {
for_each = var.client_id == "" || var.client_secret == "" ? [1] : []
content {
type = var.identity_type
user_assigned_identity_id = var.user_assigned_identity_id
}
}
...
}
请参照例子中的写法,如果只是想有条件地声明某个 Nested 块时,请使用:
for_each = <condition> ? [<some_item>] : []
1.2.1.1.11. 为可空的表达式配置默认值时使用 coalesce
以下例子可以在 var.new_network_security_group_name
为 null
或是 ""
时使用 "${var.subnet_name}-nsg"
coalesce(var.new_network_security_group_name, "${var.subnet_name}-nsg")
有一种情况不适用 coalesce
函数,例如:
coalesce(var.a, var.b)
如果 var.a
和 var.b
有可能同时为 null
,那么 coalesce
在两者都为 null
时可能会有问题,这时可以使用 var.a == null ? var.b : var.a
,或者 try(coalesce(var.a, var.b), null)
。
1.2.1.1.12. 灵活使用 try 函数
例如以下资源定义:
resource "azurerm_public_ip" "pip" {
count = var.create_public_ip ? : 0
allocation_method = "Dynamic"
location = local.resource_group.location
name = "pip-${random_id.id.hex}"
resource_group_name = local.resource_group.name
}
我们使用它时可以不用判断 var.create_public_ip
的值,使用 try
函数简化代码:
ip_configurations = [
{
public_ip_address_id = try(azurerm_public_ip.pip[0].id, null)
primary = true
}
]