Terraform 系列 - 使用 for-each 对本地 json 进行迭代

本文最后更新于:2025年2月22日 下午

系列文章

概述

前文 Grafana 系列 - Grafana Terraform Provider 基础 介绍了使用 Grafana Terraform Provider 创建 Datasource.

现在有这么一个现实需求:

有大量的同类型 (type) 的 datasource 需要批量添加,而且这些 datasource 的基本信息是以 json 的格式已经存在。

需要对 json 进行解析 / 精简 / 重构等操作并将 json 作为 Terraform 的 datasource.

Json 的格式可能类似于这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"env_name": "dev",
"prom_url": "http://dev-prom.example.com",
"es_url": "http://dev-es.example.com:9200",
"jaeger_url": "http://dev-jaeger.example.com"
},
{
"env_name": "test",
"prom_url": "http://test-prom.example.com",
"es_url": "http://test-es.example.com:9200",
"jaeger_url": "http://test-jaeger.example.com"
}
]

📝Notes:

举一反三,后面的解决方案也适用于其他任意 Json 格式。

该如何实现?🤔

解决方案

通过 Terraform 的 locals jsondecode for 循环 和 for_each 实现。

具体如下:

  • 构造一个 local 变量
  • local 变量从 .json 文件中读取并内容并通过 jsondecode + file 将 json 文件解码为 object
  • 使用 for 循环,将 object 根据当前需求调整,将例子中 env_name 作为 key, 将其他作为 value
  • 批量创建资源时,通过 for_each, 进行批量创建。

基本概念

locals

locals 为 表达式 指定一个名称,所以你可以在一个模块中多次使用这个名称,而不用重复表达式。

如果你熟悉传统的编程语言,把 Terraform 模块比作函数定义可能会很有用:

一旦声明了一个本地值,你可以在 表达式 中以 local.<NAME> 的形式引用它。

本地值有助于避免在配置中多次重复相同的值或表达式,只有在一个单一的值或结果被用于许多地方的情况下,才可以适度地使用本地值。能够在一个中心位置轻松地改变数值是本地值的关键优势。

file 函数

file 读取指定路径下的文件内容,并将其作为 string 返回。

1
2
> file("${path.module}/hello.txt")
Hello World

jsondecode 函数

jsondecode 将一个给定的 string 解释为 JSON,返回该字符串的解码结果。

该函数以如下方式将 JSON 值映射到 Terraform 语言 type:

JSON type Terraform type
String string
Number number
Boolean bool
Object object(...) 的属性类型根据此表确定
Array tuple(...) 的元素类型根据此表确定
Null Terraform 语言的 null 值

Terraform 语言的自动类型转换规则意味着你通常不需要担心一个给定的值到底会产生什么类型,只需以直观的方式使用结果即可。

1
2
3
4
5
6
> jsondecode("{\"hello\": \"world\"}")
{
"hello" = "world"
}
> jsondecode("true")
true

jsonencode 执行相反的操作,将一个 string 编码为 JSON。

for 表达式

一个 for 表达式通过转换另一个复杂类型的值来创建一个复杂类型的值。输入值中的每个元素可以对应于结果中的一个或零个值,并且可以使用一个任意的表达式来将每个输入元素转化为输出元素。

例如,如果 var.list 是一个字符串的列表,那么下面的表达式将产生一个全大写字母的字符串的元组:

1
[for s in var.list : upper(s)]

这个 for 表达式遍历了 var.list 中的每个元素,然后评估表达式 upper(s),将 s 设置为每个相应的元素。然后它用所有执行该表达式的结果按相同的顺序建立一个新的元组值。

一个 for 表达式的输入(在 in 关键字之后给出)可以是一个列表,一个集合,一个元组,一个 map,或者一个对象 (object)。

上面的例子显示了一个只有一个临时符号 s 的 for 表达式,但是一个 for 表达式可以选择声明一对临时符号,以便也使用每个项目的键或索引:

1
[for k, v in var.map : length(k) + length(v)]

对于 map 或对象类型,像上面那样,k 符号是指当前元素的键或属性名称。你也可以对列表和 map 使用双符号形式,在这种情况下,额外的符号是每个元素的索引,从 0 开始,常规的符号名称是 i 或 idx,除非选择一个很有帮助的更具体的名称:

1
[for i, v in var.list : "${i} is ${v}"]

索引或关键符号总是可选的。如果你在 for 关键字后面只指定一个符号,那么这个符号将总是代表输入集合的每个元素的值。

for 表达式周围的括号的类型决定了它产生的结果的类型。

上面的例子使用 [和],产生一个元组。如果你用 {和} 代替,结果是一个对象,你必须提供两个结果表达式,用 => 符号分开:

1
{for s in var.list : s => upper(s)}

这个表达式产生一个对象,其属性是来自 var.list 的原始元素,其相应的值是大写版本。例如,产生的值可能如下:

1
2
3
4
5
{
foo = "FOO"
bar = "BAR"
baz = "BAZ"
}

单独的 for 表达式只能产生一个对象值或一个元组值,但 Terraform 的自动类型转换规则意味着你通常可以在期望使用列表、map 和集合 (set) 的地方使用其结果。

一个 for 表达式也可以包括一个可选的 if 子句来过滤源集合中的元素,产生一个比源值更少元素的值:

1
[for s in var.list : upper(s) if s != ""]

在 for 表达式中过滤集合的一个常见原因是根据一些标准将一个源集合分成两个独立的集合。例如,如果输入的 var.users 是一个对象的映射,其中每个对象都有一个属性 is_admin,那么你可能希望产生包含管理员和非管理员对象的单独映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
variable "users" {
type = map(object({
is_admin = bool
}))
}

locals {
admin_users = {
for name, user in var.users : name => user
if user.is_admin
}
regular_users = {
for name, user in var.users : name => user
if !user.is_admin
}
}

因为 for 表达式可以从无序类型(map、对象、集合 set)转换为有序类型(列表、元祖),Terraform 必须为无序集合的元素选择一个隐含的排序。

对于 map 和对象,Terraform 通过键或属性名称对元素进行排序,使用词法排序。

对于字符串的集合,Terraform 按其值排序,使用词法排序。

for 表达式机制是为了在表达式中从其他集合值中构建集合值,然后你可以将其分配给期待复杂值的单个资源参数。

for_each 元参数

默认情况下,一个 资源块 配置一个真实的基础设施对象(同样,一个 模块块 将一个子模块的内容纳入一次配置)。然而,有时你想管理几个类似的对象(比如一个固定的计算实例池),而不需要为每个对象单独写一个块。Terraform 有两种方法可以做到这一点: count 和 for_each。

如果一个资源或模块块包括一个 for_each 参数,其值是一个 map 或字符串集合,Terraform 为该 map 或字符串集合的每个成员创建一个实例。

版本说明: for_each 是在 Terraform 0.12.6 中添加的。Terraform 0.13 中增加了对 for_each 的模块支持;以前的版本只能在资源中使用它。

** 注意:** 一个特定的资源或模块块不能同时使用 count 和 for_each。

for_each 是 Terraform 语言定义的一个元参数。它可以与模块和每一种资源类型一起使用。

for_each 元参数接受一个 map 或字符串集合,并为该 map 或字符串集合的每个项目创建一个实例。每个实例都有一个独特的基础设施对象与之相关联,每个实例都在应用配置时被单独创建、更新或销毁。

Map:

1
2
3
4
5
6
7
8
resource "azurerm_resource_group" "rg" {
for_each = {
a_group = "eastus"
another_group = "westus2"
}
name = each.key
location = each.value
}

字符串集合:

1
2
3
4
resource "aws_iam_user" "the-accounts" {
for_each = toset( ["Todd", "James", "Alice", "Dottie"] )
name = each.key
}

在设置了 for_each 的区块中,表达式中还有一个 each 对象,所以你可以修改每个实例的配置。这个对象有两个属性:

  • each.key - 这个实例对应的 map 键(或集合成员)。
  • each.value - 该实例对应的 map 值。(如果提供了一个集合,这与 each.key 相同。)

当 for_each 被设置时,Terraform 区分了区块本身和与之相关的多个资源或模块实例。实例由提供给 for_each 的值中的一个 map 键(或集合成员)来识别。

  • <TYPE>.<NAME> 或 module.<NAME> (例如,azurerm_resource_group.rg) 代表这个块。
  • <TYPE>.<NAME>[<KEY>] 或 module.<NAME>[<KEY>] (例如,azurerm_resource_group.rg["a_group"], azurerm_resource_group.rg["another_group"], etc.) 代表独立的实例

这与没有 count 或 for_each 的资源和模块不同,它们可以在没有索引或键的情况下被引用。

String & Template

字符串是 Terraform 中最复杂的一种文字表达,也是最常用的一种。

Terraform 同时支持字符串的引号语法和 heredoc 语法。这两种语法都支持用于插值和操作文本的模板序列。

带引号的字符串是一系列由双引号字符(")划定的字符。

有两个不使用反斜线的特殊转义序列:

Sequence Replacement
$${ 字面意思是 ${,不会开始一个插值序列。
%%{ 字面意思是 %{,不会开始一个模板指令序列。

${ ... } 序列是一个插值,它评估标记之间给出的表达式,如果有必要,将结果转换为字符串,然后将其插入到最终的字符串中:

1
"Hello, ${var.name}!"

在上面的例子中,命名的对象 var.name 被访问,其值被插入到字符串中,产生的结果类似 “Hello, Juan!”。

%{ ... } 序列是一个指令,它允许有条件的结果和对集合的迭代,类似于条件和 for 表达式。

以下指令被支持:

  • %{if <BOOL>}/%{else}/%{endif} 指令根据一个 bool 表达式的值在两个模板之间进行选择:

    1
    "Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"

    else 部分可以省略,在这种情况下,如果条件表达式返回 false,结果就是一个空字符串。

  • %{for <NAME> in <COLLECTION>}/%{endfor} 指令在给定的集合或结构值的元素上进行迭代,对每个元素评估一次给定的模板,将结果串联起来:

    1
    2
    3
    4
    5
    <<EOT
    %{ for ip in aws_instance.example.*.private_ip }
    server ${ip}
    %{ endfor }
    EOT

实战

需求:

有大量的同类型 (type) 的 datasource 需要批量添加,而且这些 datasource 的基本信息是以 json 的格式已经存在。

需要对 json 进行解析 / 精简 / 重构等操作并将 json 作为 Terraform 的 datasource.

Json 的格式可能类似于这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
"env_name": "dev",
"prom_url": "http://dev-prom.example.com",
"es_url": "http://dev-es.example.com:9200",
"jaeger_url": "http://dev-jaeger.example.com"
},
{
"env_name": "test",
"prom_url": "http://test-prom.example.com",
"es_url": "http://test-es.example.com:9200",
"jaeger_url": "http://test-jaeger.example.com"
}
]

解决方案:

  • 构造一个 local 变量
  • local 变量从 .json 文件中读取并内容并通过 jsondecode + file 将 json 文件解码为 object
  • 使用 for 循环,将 object 根据当前需求调整,将例子中 env 作为 key, 将其他作为 value
  • 批量创建资源时,通过 for_each, 进行批量创建。

串起来,最终如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
locals {
# 将 json 文件转换为 对象
user_data = jsondecode(file("${path.module}/env-details.json"))
# 构造一个 map
# key 是 env_name
# value 又是一个 map, 其 key 是 grafana datasource type, value 是 url
envs = { for env in local.user_data : env.env_name =>
{
prometheus = env.prom_url
# 利用 ${} 构造新的 url
jaeger = "${env.jaeger_url}/trace/"
es = env.es_url
}
}
}

resource "grafana_data_source" "prometheus" {
# 通过 for_each 迭代
for_each = local.envs

type = "prometheus"
name = "${each.key}_prom"
uid = "${each.key}_prom"
url = each.value.prometheus

json_data_encoded = jsonencode({
httpMethod = "POST"
})
}

resource "grafana_data_source" "jaeger" {
for_each = local.envs

type = "jaeger"
name = "${each.key}_jaeger"
uid = "${each.key}_jaeger"
url = each.value.jaeger
}

resource "grafana_data_source" "elasticsearch" {
for_each = local.envs

type = "elasticsearch"
name = "${each.key}_es"
uid = "${each.key}_es"
url = each.value.es
database_name = "[example.*-]YYYY.MM.DD"

json_data_encoded = jsonencode({
esVersion = "6.0.0"

interval = "Daily"
includeFrozen = false
maxConcurrentShardRequests = 256
timeField = "@timestamp"

logLevelField = "level"
logMessageField = "message"
})
}

完成🎉🎉🎉

📚️参考文档