🌔

Terraformの型とループ処理 for_each = { for } について理解する

2022/12/13に公開

本記事の目的

  • Terraformの型について整理する
  • Terraformのループ処理for_each = { for }を理解する
  • Terraformのループ処理の使い所、使い分けについて理解する

Terraformの型について

ループ処理の理解のためには、型の理解が必要なので、ざっくりと説明します。
もちろん、知っている方は読み飛ばしてもらって結構です。

Lists/Tuples/Sets

すべて、[ ]で囲まれた一連の値であり、下記の違いがあります。

要素の型 要素の順序
list 同一 あり ["foo", "foo", "baz"]
tuple 異なる型を許容 あり ["foo", "foo", "baz", 1, true]
set 同一 なし(重複が許されない) ["foo", "bar", "baz"]

Maps/Objects

ともに、{ }で囲まれた一連のkey=valueであり、下記の違いがあります。

  • map: 1つの型のvalueでのみ構成可能
    • ただし、型を map(string)としておけば、暗黙的に型変換されるnumberboolも含めることが可能になります

例:

variable "map" {
  type        = map(string)
  description = "サンプルマップ"
  default     = { a = "hoge", b = 1, c = true }
}
  • object: 複数の型のvalueで構成可能
    • keyの名前とvalueの型を定義する必要があります

例:

variable "object" {
  type = object({
    a = list(string)
    b = number
    c = bool
  })
  description = "サンプルオブジェクト"
  default = { a = ["hoge", "fuga"], b = 1, c = true }
}

any についての補足

map(any)ならobjectのように扱えるのでは?と思う方もいるかもしれません。
実際に試してもらったら分かりますが、うまくいきません。

なぜなら、実はany単一の型でのみ機能するワイルドカードだからです。
…で、実際に複数のデータ型が入った場合は、やはりstringに変換されるため、map(any)map(string)と同じと思って差し支えないと思います。
ただし、実際に色々な型が入ると想定しているのであればmap(any)、文字列だけならmap(string)と正しく定義しましょう。モジュールの利用者や他の開発者に正しい情報を伝えることが大切だからです。

参考

https://developer.hashicorp.com/terraform/language/expressions/types
https://developer.hashicorp.com/terraform/language/expressions/type-constraints

ループ処理

Terraformの型について整理できたところで、本題のループ処理を見ていきます。

for

forは式であり、入出力があります。

  • 入力として、list, set, tuple, map, objectを受け付けます
  • 出力として、[ for ]tuple を、{ for }object を返します
    • [ ]{ }は前章で型について整理した通りですね。

具体例として、下記のようなlistがあった場合、

locals {
  list = ["foo", "bar", "baz"]
}

tupleの生成

echo '[for s in local.list : upper(s)]' | terraform console
[
  "FOO",
  "BAR",
  "BAZ",
]

objectの生成

echo '{ for s in local.list : s => upper(s) }' | terraform console
{
  "bar" = "BAR"
  "baz" = "BAZ"
  "foo" = "FOO"
}

のようになります。

:の右辺が出力で、keyとvalueを出力する場合は、<OUTPUT_KEY> => <OUTPUT_VALUE>の形になります。

正直なところ、for単体ではあまり出番がなく、次に説明するfor_eachとの組み合わせでよく使います。

参考

https://developer.hashicorp.com/terraform/language/expressions/for

for_each

for_eachはメタ引数と呼ばれるもので、resourcemoduleブロック内で定義できます。
定義することで、resourcemoduleの動作を変更できます。
具体的には、for_eachを使うことで、1つのresourceブロックで、複数のリソース定義が可能となります。

for_eachが受け取れる型はset(string)mapです。

set(string)を入力とした場合

例:IAMユーザを2つ作成

locals {
  iam_user_names = toset(["hoge", "fuga"])
}
resource "aws_iam_user" "example" {
  for_each = local.iam_user_names
  name     = each.value
}

local valueの配列は、重複がなくてもtuple扱いになるため、toset()で変換しています。
なぜ、set(string)でないといけないのでしょうか。
terraform planを実行してみましょう。

# aws_iam_user.example["fuga"] will be created
+ resource "aws_iam_user" "example" {
    + arn           = (known after apply)
    + force_destroy = false
    + id            = (known after apply)
    + name          = "fuga"
    + path          = "/"
    + tags_all      = (known after apply)
    + unique_id     = (known after apply)
  }
# aws_iam_user.example["hoge"] will be created
+ resource "aws_iam_user" "example" {
    + arn           = (known after apply)
    + force_destroy = false
    + id            = (known after apply)
    + name          = "hoge"
    + path          = "/"
    + tags_all      = (known after apply)
    + unique_id     = (known after apply)
  }

注目すべき箇所はリソースIDです。

  • aws_iam_user.example["fuga"]
  • aws_iam_user.example["hoge"]

配列の値がそのままTerraformのリソースIDになるため、重複が許されないset(string)しか受け付けないということです。

set(string)以外を渡すとどうなるでしょう。

例:パブリックサブネットを2つ作成

locals {
  vpc_cidr = "10.0.0.0/16"
  vpc_name = "example"
  public_subnets = toset([
    {
      az   = "ap-northeast-1a"
      name = "public"
      cidr = "10.0.1.0/24"
    },
    {
      az   = "ap-northeast-1c"
      name = "public"
      cidr = "10.0.2.0/24"
    }
  ])
}
resource "aws_vpc" "main" {
  cidr_block           = local.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = local.vpc_name
  }
}
resource "aws_subnet" "public" {
  for_each = local.public_subnets

  availability_zone       = each.value.az
  cidr_block              = each.value.cidr
  vpc_id                  = aws_vpc.main.id
  map_public_ip_on_launch = true

  tags = {
    Name = "${local.vpc_name}-${each.value.name}-${substr(each.value.az, -2, 0)}"
  }
}

for_each = local.public_subnetsで、set(object)型のlocal.public_subnetsを渡しています。terraform planを実行してみましょう。

Error: Invalid for_each set argument
The given "for_each" argument value is unsuitable: "for_each" supports maps and sets of strings, but you have provided a set containing type object.

当然、set(object)はダメだよと怒られます。
set(string)だけだと何かと不便そうですね。そこで、mapの場合を見てみましょう。

mapを入力とした場合

先程の例のpublic_subnetsmapにしてみましょう。

locals {
  public_subnets = {
    0 = {
      az   = "ap-northeast-1a"
      name = "public"
      cidr = "10.0.1.0/24"
    }
    1 = {
      az   = "ap-northeast-1c"
      name = "public"
      cidr = "10.0.2.0/24"
    }
  }
}

terraform planを実行すると、期待する結果が得られたと思います。
しかし、マップのkeyを直書きするよりも、できれば、下記のように直感的に書きたいものです。

locals {
  public_subnets = [
    {
      az   = "ap-northeast-1a"
      name = "public"
      cidr = "10.0.1.0/24"
    },
    {
      az   = "ap-northeast-1c"
      name = "public"
      cidr = "10.0.2.0/24"
    }
  ]
}

そこで、{ for }の出番です。{ for }object を返し、objectは暗黙的にmapに変換されます。つまり、下記のように書けるわけです。

locals {
  vpc_cidr = "10.0.0.0/16"
  vpc_name = "example"
  public_subnets = [
    {
      az   = "ap-northeast-1a"
      name = "public"
      cidr = "10.0.1.0/24"
    },
    {
      az   = "ap-northeast-1c"
      name = "public"
      cidr = "10.0.2.0/24"
    }
  ]
}
resource "aws_vpc" "main" {
  cidr_block           = local.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = local.vpc_name
  }
}
resource "aws_subnet" "public" {
+ for_each = { for i in local.public_subnets : i.az => i }
- for_each = local.public_subnets

  availability_zone       = each.value.az
  cidr_block              = each.value.cidr
  vpc_id                  = aws_vpc.main.id
  map_public_ip_on_launch = true

  tags = {
    Name = "${local.vpc_name}-${each.value.name}-${substr(each.value.az, -2, 0)}"
  }
}

再度説明すると、i.az => iの部分はlocal.public_subnetsazをkeyにして、objectのvalueと紐付けているのでしたね。確認してみましょう。

echo '{ for i in local.public_subnets : i.az => i }' | terraform console
{
  "ap-northeast-1a" = {
    "az" = "ap-northeast-1a"
    "cidr" = "10.0.1.0/24"
    "name" = "public"
  }
  "ap-northeast-1c" = {
    "az" = "ap-northeast-1c"
    "cidr" = "10.0.2.0/24"
    "name" = "public"
  }
}

そして、for_eachで作成したリソースの値をoutputしたい場合は、[ for ]を使います。

output "public_subnet_ids" {
  description = "Public Subnet IDs"
  value       = [for value in aws_subnet.public : value.id]
}

このfor_each = { for }の形はTerraformのソースで頻繁に現れるので、ぜひ仕組みを理解しておきたいですね。

応用

下記のようにifで条件を追加できます。
https://github.com/terraform-aws-modules/terraform-aws-rds-aurora/blob/master/main.tf#L226-L236

三項演算子と組み合わせることも可能です。
https://github.com/terraform-aws-modules/terraform-aws-cloudfront/blob/master/main.tf#L5-L13

また、for_eachdynamicとの組み合わせでも頻出です。機会があれば、dynamicの使い方についてもまとめようかと思います。

参考

https://developer.hashicorp.com/terraform/language/meta-arguments/for_each

count

最後にcountですが、countはループ処理での利用は非推奨です。for_eachを使いましょう。
非推奨な理由は、下記の記事が具体例もあって、簡潔で分かりやすいです。
https://tellme.tokyo/post/2022/06/12/terraform-count-for-each/

countの使い所はほぼ一択で、リソース作成のON・OFFで使用します。

https://github.com/terraform-aws-modules/terraform-aws-s3-bucket/blob/master/main.tf#L18-L27

まとめ

今回はTerraformをDRYに書くために必須であるループ処理を紐解いてみました。
もし、役に立った部分があれば、♡をポチッとお願いします。

また、間違い等あればご指摘お願いします。
以上です。

GitHubで編集を提案

Discussion