TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

Terraformのfor_eachを使ってコードをスッキリさせる

for_eachで何ができるのか

for_eachresoucemoduleブロックで使える繰り返しのしくみです。同様のリソースを1つの定義で記述できます。
たとえば下記のようなリソースは、

resource "aws_iam_user" "ichiro" {
  name     = "ichiro"
}
resource "aws_iam_user" "jiro" {
  name     = "jiro"
}
resource "aws_iam_user" "saburo" {
  name     = "saburo"
}

下記のように1つにまとめて記述できます。

resource "aws_iam_user" "accounts" {
  for_each = toset( ["ichiro", "jiro", "saburo"] )
  name     = each.key
}

for_eachで扱える型

If a resource or module block includes a for_each argument whose value is a map or a set of strings, Terraform creates one instance for each member of that map or set.

上記公式ドキュメントに記載の通りfor_eachで扱えるのは、Terraformで扱える型のうちsetmapの2つです。

for_eachのさまざまな使い方

aws_identitystore_userリソースを使ってAWSのIAM Identity Centerのユーザを作成する場合を例にさまざまな使い方を紹介します。
Terraformのインストール、AWS ProviderなどTerraformの実行に必要な基本的な設定は済んでいる前提です。
検証環境は

  • Terraformバージョン: 1.5.7
  • AWS Providerバージョン: 5.1.0

で実行しています。

1. listをループする

単純な1次元データでIAM Identity Centerのユーザを作成します。
下記のようにusersというローカル変数を定義し[]にカンマ区切りでデータを入れます。

locals {
  users = [
    "synergy.ichiro",
    "synergy.jiro",
    "synergy.saburo",
  ]
}

上記のように書くとuserslist型になるので、toset関数でfor_eachで扱えるset型に変換して渡します。
for_eachに渡した値はeach.keyで参照できます。

data "aws_ssoadmin_instances" "example" {}

resource "aws_identitystore_user" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]

  for_each     = toset(local.users)
  display_name = each.key
  user_name    = each.key

  name {
    family_name = replace(each.key, "/\\..+$/", "")
    given_name  = replace(each.key, "/^.+\\./", "")
  }
}

上記をterraform planした結果は下記のようになります。

  # aws_identitystore_user.example["synergy.ichiro"] will be created
  + resource "aws_identitystore_user" "example" {
      + display_name      = "synergy.ichiro"
      + external_ids      = (known after apply)
      + id                = (known after apply)
      + identity_store_id = "XXXXXXXXXXX"
      + user_id           = (known after apply)
      + user_name         = "synergy.ichiro"

      + name {
          + family_name = "synergy"
          + given_name  = "ichiro"
        }
    }
    
  # aws_identitystore_user.example["synergy.jiro"] will be created
  + resource "aws_identitystore_user" "example" {
      + display_name      = "synergy.jiro"
      + external_ids      = (known after apply)
      + id                = (known after apply)
      + identity_store_id = "XXXXXXXXXXX"
      + user_id           = (known after apply)
      + user_name         = "synergy.jiro"

      + name {
          + family_name = "synergy"
          + given_name  = "jiro"
        }
    }

  # aws_identitystore_user.example["synergy.saburo"] will be created
  + resource "aws_identitystore_user" "example" {
      + display_name      = "synergy.saburo"
      + external_ids      = (known after apply)
      + id                = (known after apply)
      + identity_store_id = "XXXXXXXXXXX"
      + user_id           = (known after apply)
      + user_name         = "synergy.saburo"

      + name {
          + family_name = "synergy"
          + given_name  = "saburo"
        }
    }

なぜfor_eachlistのまま渡したらダメなの?と思う方がいるかもしれませんが、terraform planの結果を見ると理由がわかります。
下記のようにeach.keyはリソース名に使われています。リソース名は重複してはいけないのでユニークなset型である必要があります。

# aws_identitystore_user.example["synergy.saburo"] will be created
# aws_identitystore_user.example["synergy.jiro"] will be created
# aws_identitystore_user.example["synergy.ichiro"] will be created

これで3つのリソースを1つの定義で書けてコードがスッキリしました。パチパチ👏
……なんて世の中そんなに甘くはありません。 いちいちローカル変数にカンマ区切りでデータを入れるのは面倒なのでリストはファイルから読み込みたいです。 そんな方は次をご覧ください。

2. ファイルをループする

下記のように1行毎にデータを持ったファイル(users.txt)を作成します。

synergy.ichiro
synergy.jiro
synergy.saburo

そして、次のようにしてlistを生成してusersローカル変数に入れます。

locals {
  users = split("\n", chomp(file("users.txt")))
}

users.txtfile関数で文字列に読み込み、chomp関数で末尾の改行を削除した上で、split関数を用いて改行で分割しています。 あとはresource定義は1のままでterraform planを実行すれば1と同じ結果が返ってきます。

これで複数のリソースを1つの定義で書けて、ファイルも分かれてコードがスッキリしました。パチパチ👏
……なんて世の中そんなに甘くはありません。
データは1次元だけではないんです。IAM Identity CenterのユーザにはニックネームやEメールアドレスなど、もっとたくさんの情報を登録するので多次元のデータを使いたいです。
そんな方は次をご覧ください。

3. mapをループする

下記のようにユーザ名をキーとするmapを作成します。値はそのユーザに関する情報のmapです。2段階にネストされたmapになります。

locals {
  users = {
    "synergy.ichiro" = {
      email        = "synergy.ichiro@synergy101.jp"
      display_name = "シナジー 一郎"
      nickname     = "いっさん"
    }
    "synergy.jiro" = {
      email        = "synergy.jiro@synergy101.jp"
      display_name = "シナジー 二郎"
      nickname     = "じろきち"
    }
    "synergy.saburo" = {
      email        = "synergy.saburo@synergy101.jp"
      display_name = "シナジー 三郎"
      nickname     = "さぶちゃん"
    }
  }
}

これをfor_eachに渡すと、each.keyでユーザ名を、each.value.<ネストしたキー名>でユーザに関する情報を参照できます。

data "aws_ssoadmin_instances" "example" {}

resource "aws_identitystore_user" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]

  for_each     = local.users
  display_name = each.value.display_name
  user_name    = each.key
  nickname     = each.value.nickname

  name {
    family_name = replace(each.key, "/\\..+$/", "")
    given_name  = replace(each.key, "/^.+\\./", "")
  }

  emails {
    primary = true
    type    = "work"
    value   = each.value.email
  }
}

上記をterraform planした結果は下記のようになります。(1つ目のみ表示)

  # aws_identitystore_user.example["synergy.ichiro"] will be created
  + resource "aws_identitystore_user" "example" {
      + display_name      = "シナジー 一郎"
      + external_ids      = (known after apply)
      + id                = (known after apply)
      + identity_store_id = "XXXXXXXXXXX"
      + nickname          = "いっさん"
      + user_id           = (known after apply)
      + user_name         = "synergy.ichiro"

      + email {
          + primary = true
          + type    = "work"
          + value   = "synergy.ichiro@synergy101.jp"
        }

      + name {
          + family_name = "synergy"
          + given_name  = "ichiro"
        }
    }

これで多次元のデータも1つの定義で書けてコードがスッキリしました。パチパチ👏
……なんて世の中そんなに甘くはありません。
多次元のデータもファイルから読み込みたいです。
そんな方は次をご覧ください。

4. CSVファイルをループする

下記のようなCSVファイル(users.csv)を作成します。

user_name,email,display_name,nickname
synergy.ichiro,synergy.ichiro@synergy101.jp,"シナジー 一郎","いっさん"
synergy.jiro,synergy.jiro@synergy101.jp,"シナジー 二郎","じろきち"
synergy.saburo,synergy.saburo@synergy101.jp,"シナジー 三郎","さぶちゃん"

そして、users.csvfile関数で文字列に読み込み、それをcsvdecode関数に渡して、usersローカル変数に入れます。

locals {
  users = csvdecode(file("users.csv"))
}

CSVファイルが複数ある場合は下記のようにconcat関数で結合することもできます。

locals {
  users = concat(local.dev1_users, local.dev2_users, local.dev3_users)

  dev1_users = csvdecode(file("dev1_users.csv"))
  dev2_users = csvdecode(file("dev2_users.csv"))
  dev3_users = csvdecode(file("dev3_users.csv"))
}

csvdecode関数は、CSVのヘッダー行をmapのキーとしてmaplistを生成します。
consoleコマンドで見ると、csvdecode関数で読み込んだデータは下記のデータ構造になっていることがわかります。

$ terraform console

> local.users
tolist([
  {
    "display_name" = "シナジー 一郎"
    "email" = "synergy.ichiro@synergy101.jp"
    "nickname" = "いっさん"
    "user_name" = "synergy.ichiro"
  },
  {
    "display_name" = "シナジー 二郎"
    "email" = "synergy.jiro@synergy101.jp"
    "nickname" = "じろきち"
    "user_name" = "synergy.jiro"
  },
  {
    "display_name" = "シナジー 三郎"
    "email" = "synergy.saburo@synergy101.jp"
    "nickname" = "さぶちゃん"
    "user_name" = "synergy.saburo"
  },
])

そして3の定義のfor_eachの箇所だけ下記のように書き換え、その他の定義はそのままでterraform planを実行すれば3と同じ結果が返ってきます。

resource "aws_identitystore_user" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]

  for_each     = { for i in local.users : i.user_name => i }
  display_name = each.value.display_name
  user_name    = each.key
  nickname     = each.value.nickname

  name {
    family_name = replace(each.key, "/\\..+$/", "")
    given_name  = replace(each.key, "/^.+\\./", "")
  }

  emails {
    primary = true
    type    = "work"
    value   = each.value.email
  }
}

{ for i in local.users : i.user_name => i }の部分がわかりにくいと思うので補足すると、csvdecode関数はmaplistを生成するだけで、先ほどconsoleコマンドで見た通り、users変数に入っているのはlistです。
これをfor_eachに渡すにはmapに変換する必要があるので、for式を使って、user_nameをキーにしたmapに変換しています。
mapに変換したものはconsoleコマンドで見ると下記のデータ構造になります。

$ terraform console

> { for i in local.users : i.user_name => i }
{
  "synergy.ichiro" = {
    "display_name" = "シナジー 一郎"
    "email" = "synergy.ichiro@synergy101.jp"
    "nickname" = "いっさん"
    "user_name" = "synergy.ichiro"
  }
  "synergy.jiro" = {
    "display_name" = "シナジー 二郎"
    "email" = "synergy.jiro@synergy101.jp"
    "nickname" = "じろきち"
    "user_name" = "synergy.jiro"
  }
  "synergy.saburo" = {
    "display_name" = "シナジー 三郎"
    "email" = "synergy.saburo@synergy101.jp"
    "nickname" = "さぶちゃん"
    "user_name" = "synergy.saburo"
  }
}

お気づきの方がいるかもしれませんが、この場合user_nameeach.keyでもeach.value.user_name でもどちらを使っても参照できます。
これで多次元データもファイルに分けてコードがスッキリしました。パチパチ👏
……なんて世の中そんなに甘くはありません。
特定のキーだけループさせたい時もあるんです。
そんな方は次をご覧ください。

5. 特定のキーをループする

4の定義のfor_eachの箇所にifで特定のキーを抽出する条件を追加します。
usersローカル変数から、nicknameの値が「〇〇ちゃん」のものだけをループさせる場合、 下記のように書き、terraform planを実行すればsynergy.saburoさんのリソースだけが返ってきます。
なお、対象のキーがなくてもリソースが作られないだけでエラーになることはありません。

resource "aws_identitystore_user" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]

  for_each = {
    for i in local.users : i.user_name => i 
    if can(regex("ちゃん$", i.nickname))
  }
  display_name = each.value.display_name
  user_name    = each.key
  nickname     = each.value.nickname

  name {
    family_name = replace(each.key, "/\\..+$/", "")
    given_name  = replace(each.key, "/^.+\\./", "")
  }

  emails {
    primary = true
    type    = "work"
    value   = each.value.email
  }
}

if can(regex("ちゃん$", i.nickname))の部分をもう少し詳しく説明すると、まずregex関数でnicknameが「〇〇ちゃん」に一致するものを抽出します。
regex関数は、パターンが一致する場合は一致する文字列を返し、一致しない場合はエラーを返します。なので、regex関数の結果をcan関数に渡し、エラーなしで実行できたかを評価させます。
そしてcan関数でtrueを返したものだけをifでフィルターしてfor_eachに渡しています。
下記のようにconsoleコマンドで見るとregex関数とcan関数の動きを確認できると思います。

$ terraform console

> regex("ちゃん$", "さぶちゃん")
"ちゃん"
> regex("ちゃん$", "いっさん")
╷
│ Error: Error in function call
│ 
│   on <console-input> line 1:
│   (source code not available)
│ 
│ Call to function "regex" failed: pattern did not match any part of the given
│ string.
╵


> { for i in local.users : i.nickname => can(regex("ちゃん$", i.nickname)) }
{
  "いっさん" = false
  "さぶちゃん" = true
  "じろきち" = false
}

> { for i in local.users : i.user_name => i if can(regex("ちゃん$", i.nickname)) }
{
  "synergy.saburo" = {
    "display_name" = "シナジー 三郎"
    "email" = "synergy.saburo@synergy101.jp"
    "nickname" = "さぶちゃん"
    "user_name" = "synergy.saburo"
  }
}

最後に

aws_identitystore_userリソースを例にfor_eachを使ってコードをスッキリさせる方法をいくつか紹介しました。
これらを応用するとその他のリソースでも使える場面はあると思います。
これを機にfor_eachを使ってコードをスッキリさせてはいかがでしょうか?

シナジーマーケティング株式会社では一緒に働く仲間を募集しています。