🛠

便利な Terraform 関数集

2023/11/30に公開

クラウドエースの北野です。

Terraform には組込み関数型制約の関数など多数準備されています。その中でも開発でよく使っている関数を紹介します。

概要

紹介する関数は以下の通りです。

関数名 機能概要 使い方例 種類
format 文字列結合 format("%s-%s", var.name, local.prefix) 組込み
distinct 配列の重複排除 distinct(["a", "b", "a", "b"]) 組込み
flatten 配列の入れ子構造の平坦化 flatten([["a"]]) 組込み
merge オブジェクトの統合 merge({name="a"}, {project="b"}) 組込み
concat 配列の結合 concat(["a"], ["b"]) 組込み
yamlencode HCL を yaml 型の string で出力 yamlencode("[{a = "test"}]") 組込み
yamldecode yaml 型データの HCL への取り込み yamldecode("- a: b") 組込み
file 任意のファイルパスのファイルの読み込み file("./test.txt") 組込み
toset 別の型へのキャスト toset(["a","b"]) 組込み
try エラーの返さない引数を返す try(var.name, "default") 組込み
object object 型の変数定義 object({name = string, project = string}) 型制約
optional 変数の null 値に対して、デフォルト値の設定 optional(string, null) 型制約

各関数の機能と使い方

ここからは、各関数の具体的な使いどころと使い方について紹介します。

format 関数

format() は文字列結合をする関数です。文字列を返す関数で、 C 言語の printf 関数のようなものです。
任意のリソース名に環境固有のプレフィックスを付ける場合、プレフィックスを変数として入れて結合させるなどで使えます。

locals {
  project       = "test"
  suffix_number = 1
}

output name {
  value = format("%s-sample-%03d", local.project, local.suffix_number)
}

上記を実行すると、以下のようになります。

terraform apply

Outputs:

name = "test-sample-001"

distinct 関数

distinct() は配列内で重複するオブジェクトをまとめる関数です。 locals ブロックの設定変数を使い data ブロックを呼び出すときに同じ呼び出し先がある場合、その値を1つにするとき使ったりします。distinct() は、 map 型もまとめることができます。

locals {
  distinct_sample = [
    "a", "a", "b", "b", "c"
  ]
}

output "distinct_sample" {
  value = distinct(local.distinct_sample)
}
terraform plan

Changes to Outputs:
  + distinct_sample = [
      + "a",
      + "b",
      + "c",
    ]

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

flatten 関数

flatten() は、配列で入れ子になっているものを平坦化するものです。resource ブロックの仕様で、データの持たせ方が直感的でないとき、管理しやすいようにデータの持たせ方を変えるときなどに使います。
例えば、Google Cloud の IAM を設定するとき、google_project_iam_member を使うことがあるかと思います。このとき、権限を付与する memberrole が 1:1 にする必要があります。そのため、for_each を使ってループで jane@example.com アカウントに対して、sample プロジェクトに roles/compute.adminroles/storage.admin の権限を付与したいとすると、以下の様にデータを持たせる必要があります。

locals {
  iams = [
    {
      project = "sample"

      member = "user:jane@example.com"
      role   = "roles/compute.admin"
    },
    {
      project = "sample"

      member = "user:jane@example.com"
      role   = "roles/storage.admin"
    },
  ]
}

resource "google_project_iam_member" "main" {
  for_each = { for v in local.iams : format("%s/%s/%s", v.member, v.project, v.role) => v }

  project = each.value.project

  role   = each.value.role
  member = each.value.member
}

上記は sampleuser:jane@example.com を複数もっており、見通しが悪いです。また、 jane@example.com に他のプロジェクトに対して権限を付与させると、さらにデータが多数生成され、見通しが悪くなります。
そこで、データを以下のように持たせると、jane@example.com がどのプロジェクトに何の role を有しているかが直感的で分かるかと思います。

locals {
  user_iams = [
    {
      id = "user:jane@example.com"

      project_iams_members = [
        {
          project = "sample"

          roles = [
            "roles/compute.admin",
            "roles/storage.admin"
          ]
        }
      ]
    }
  ]
}

直感的にはなりましたが、上記の内容では google_project_iam_member に渡せないので、データを先のデータに合わすように変形する必要があります。
そこで、使うのが、flatten() 関数です。以下のように使います。

locals {
  user_iams = [
    {
      id = "user:jane@example.com"
      project_iams_members = [
        {
          project = "sample"
          roles = [
            "roles/compute.admin",
            "roles/storage.admin"
          ]
        }
      ]
    }
  ]

  _user_iams = flatten([for v in local.user_iams :
    [for w in v.project_iams_members :
      [for x in w.roles :
        {
          id      = v.id
          project = w.project
          role    = x
        }
      ]
    ]
  ])
}

output "user_iams" {
  value = local._user_iams
}

上記のコードを実行すると、以下のようになります。

terraform apply

Changes to Outputs:
  + user_iams = [
      + {
          + id      = "user:jane@example.com"
          + project = "sample"
          + role    = "roles/compute.admin"
        },
      + {
          + id      = "user:jane@example.com"
          + project = "sample"
          + role    = "roles/storage.admin"
        },
    ]

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

user_iams = [
  {
    "id" = "user:jane@example.com"
    "project" = "sample"
    "role" = "roles/compute.admin"
  },
  {
    "id" = "user:jane@example.com"
    "project" = "sample"
    "role" = "roles/storage.admin"
  },
]

locals ブロックへの記載は、冗長になるので、flatten() での変換部分を含めて、モジュール化すると使いやすくなります。

merge 関数

merge() は map 型のデータ同士を結合する関数です。データの持たせ方を変えたいときに使います。
例えば、 先程説明した flatten() の例内の for 文で map 型を再定義している部分を merge() で以下のように書き換えることができます。
変形後のデータは余分な値を持っていますが、内部変数で参照させないという場合にはこういった書き方も可能です。

locals {
  user_iams = [
    {
      id = "user:jane@example.com"
      project_iams_members = [
        {
          project = "sample"
          roles = [
            "roles/compute.admin",
            "roles/storage.admin"
          ]
        }
      ]
    }
  ]

  _user_iams = flatten([for v in local.user_iams :
    [for w in v.project_iams_members :
      [for x in w.roles :
        merge(v, w, { role = x })
      ]
    ]
  ])
}

output "merge_sample" {
  value = local._user_iams
}

実際 _user_iams 変数の中身を確認していみると、project_iams_membersroles が存在しています。
意図しない振舞いになりうるのであまりおすすめしませんが、記述を省略するであったり、root モジュール内で呼ばないという基準があるならば module 作成時に使うと便利です。

terraform plan

Changes to Outputs:
  + merge_sample = [
      + {
          + id                   = "user:jane@example.com"
          + project              = "sample"
          + project_iams_members = [
              + {
                  + project = "sample"
                  + roles   = [
                      + "roles/compute.admin",
                      + "roles/storage.admin",
                    ]
                },
            ]
          + role                 = "roles/compute.admin"
          + roles                = [
              + "roles/compute.admin",
              + "roles/storage.admin",
            ]
        },
      + {
          + id                   = "user:jane@example.com"
          + project              = "sample"
          + project_iams_members = [
              + {
                  + project = "sample"
                  + roles   = [
                      + "roles/compute.admin",
                      + "roles/storage.admin",
                    ]
                },
            ]
          + role                 = "roles/storage.admin"
          + roles                = [
              + "roles/compute.admin",
              + "roles/storage.admin",
            ]
        },
    ]

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

concat 関数

concat() は配列同士を結合する関数です。locals で定義したデータと、参照したデータを結合させたいときなどに使います。
Google Cloud のファイアーウォールの Egress (送信方向) で Google Cloud の限定公開 Google アクセス( restricted.googleapis.com ) のアドレス範囲への通信と許可したい他のアドレス範囲を許可させたいとき、restricted.googleapis.com の IP アドレスを直接入力するのもよいですが、google_netblock_ip_ranges の data ブロックを使い参照させて入力することも可能です。
そのとき、当該 data ブロックは配列のため、他の許可したいデータブロックを入力する場合に配列結合が必要となります。

上記の内容をコードにすると、以下のようになります。

locals {
  addresses = [
    "8.8.8.8"
  ]

  _addresses = concat(data.google_netblock_ip_ranges.main.cidr_blocks, local.addresses)
}

data "google_netblock_ip_ranges" "main" {
  range_type = "restricted-googleapis"
}

output "ips" {
  value = local._addresses
}

上記の内容を実行すると、以下のようになり、配列が結合されていることが分かります。

terraform apply

Outputs:

ips = [
  "199.36.153.4/30",
  "8.8.8.8",
]

yamlencode 関数

yamlencode() は HCL のデータを yaml のデータに変換し、string 型で表示する関数です。
provider の仕様によっては、yaml ファイル中身をヒアドキュメントで入力する必要があります。そういったとき、yaml ファイルを使わずに HCL の locals ブロックのデータを入力させるのに役に立ちます。
例えば、Google Cloud の google_endpoints_service のリソースの openapi_config は yaml のファイルを入力する必要があるので、こういった箇所で使えます。

以下では、yamlencode() の出力のみを見るコードとなっています。

locals {
  yamlencode_sample = [
    "a", "b", "c"
  ]
}

output "yamlencode_sample" {
  value = yamlencode(local.yamlencode_sample)
}

上記の結果は、以下の通りです。

terraform plan

Changes to Outputs:
  + yamlencode_sample = <<-EOT
        - "a"
        - "b"
        - "c"
    EOT

yamldecode 関数

yamldecode() は yaml のデータ構造を HCL に変換する関数です。HCL は JSON 形式に近いため、 locals のデータが大きくなると、可読性が下がってきます。
そこで、yaml ファイルなどで設定ファイルを HCL の外に持たせたいときなどに yamldecode() は有用です。
ここで、簡単に yamldecode() の振舞いのみを表示します。

output "yamldecode" {
  value = yamldecode("- a: b")
}

上記のファイルを実行すると、以下のようになります。

terraform apply

Outputs:

yamldecode = [
  {
    "a" = "b"
  },
]

file 関数

file() は任意のファイルパスにあるファイルの内容を string 型のデータとして読み込む関数です。先程の yamldecode() と組み合わせると、設定内容だけを yaml ファイルに定義することが可能になります。
先程の flatten() で紹介した user_iams のデータを yaml ファイルにして出力すると、以下のようになります。

- id: 'user:jane@example.com'

  project_iams_members:
    - project: sample

      roles:
        - roles/compute.admin
        - roles/storage.admin
output "yamlconfg" {
  value = yamldecode(file("./iam_members.yaml"))
}

上記の内容を実行すると、以下のようになります。

terraform apply

Outputs:

yamlconfg = [
  {
    "id" = "user:jane@example.com"
    "project_iams_members" = [
      {
        "project" = "sample"
        "roles" = [
          "roles/compute.admin",
          "roles/storage.admin",
        ]
      },
    ]
  },
]

concat() 関数と組み合わせると、設定をファイルごとに分割でき、さらに見通しよく管理できます。

toset 関数

toset() は引数の型を変換する関数です。for_each 文は、map 型または、 string の集合( Set )型でしか使えません。そこで、toset()list(string) 型を string の集合型に変換さて、for_each を実行させるのに使います。

以下が、list(string) 型で for_each 文を回すサンプルとなっています。

locals {
  toset_sample = [
    "a",
    "b"
  ]
}

resource "terraform_data" "main" {
  for_each = toset(local.toset_sample)

  provisioner "local-exec" {
    command = format("echo %s", each.value)
  }
}

実行すると、以下のようになります。

terraform apply

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # terraform_data.main["a"] will be created
  + resource "terraform_data" "main" {
      + id = (known after apply)
    }

  # terraform_data.main["b"] will be created
  + resource "terraform_data" "main" {
      + id = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to destroy.
terraform_data.main["a"]: Creating...
terraform_data.main["b"]: Creating...
terraform_data.main["a"]: Provisioning with 'local-exec'...
terraform_data.main["b"]: Provisioning with 'local-exec'...
terraform_data.main["b"] (local-exec): Executing: ["/bin/sh" "-c" "echo b"]
terraform_data.main["a"] (local-exec): Executing: ["/bin/sh" "-c" "echo a"]
terraform_data.main["a"] (local-exec): a
terraform_data.main["b"] (local-exec): b
terraform_data.main["a"]: Creation complete after 0s [id=1eb67870-7693-6db4-2f91-4c7cee1471ec]
terraform_data.main["b"]: Creation complete after 0s [id=e745589f-8c2c-7b34-d77e-80e32e373c24]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

try 関数

try() は引数を順に評価し、エラーが発生しない値を返す関数です。この関数は、locals ブロックの配列内のオブジェクトすべてを同じ型にしたくないときなどに使います。for_each でループを回すとき、try() を使わないと、null 値や 空配列など入力する必要のない値を明示的に定義する必要があります。しかし、try() を使うと、2番目の引数にデフォルト値を入力すると、デフォルトの値が入力されるようになります。

以下では google_compute_network を try_sample の配列で for_each のループで回しています。以下の様に片方だけ値を定義し、もう片方では定義しないように記載することができます。

locals {
  try_sample = [
    {
      name = "test"

      description = "the object has description"
    },
    {
      name = "sample"
    },
  ]
}

resource "google_compute_network" "main" {
  for_each = { for v in local.try_sample : v.name => v }

  name = each.value.name

  description = try(each.value.description, null)
}

上記の内容で、terraform plan すると以下のようになり、sample の方では、description が定義されていないのが分かります。

terraform plan

Terraform will perform the following actions:

  # google_compute_network.main["sample"] will be created
  + resource "google_compute_network" "main" {
      + auto_create_subnetworks                   = true
      + delete_default_routes_on_create           = false
      + gateway_ipv4                              = (known after apply)
      + id                                        = (known after apply)
      + internal_ipv6_range                       = (known after apply)
      + mtu                                       = (known after apply)
      + name                                      = "sample"
      + network_firewall_policy_enforcement_order = "AFTER_CLASSIC_FIREWALL"
      + project                                   = "sample"
      + routing_mode                              = (known after apply)
      + self_link                                 = (known after apply)
    }

  # google_compute_network.main["test"] will be created
  + resource "google_compute_network" "main" {
      + auto_create_subnetworks                   = true
      + delete_default_routes_on_create           = false
      + description                               = "the object has description"
      + gateway_ipv4                              = (known after apply)
      + id                                        = (known after apply)
      + internal_ipv6_range                       = (known after apply)
      + mtu                                       = (known after apply)
      + name                                      = "test"
      + network_firewall_policy_enforcement_order = "AFTER_CLASSIC_FIREWALL"
      + project                                   = "sample"
      + routing_mode                              = (known after apply)
      + self_link                                 = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to de

なお、try関数のドキュメント で警告が記載されていますが、例で示されるような単純な型変換でのみ使うべきです。 Terraform 構文のエラーを抑制する目的で try() を多用すると保守が困難になる場合があります。

object 関数

object()variable ブロック内で オブジェクト型を宣言するのに使う関数です。 map(string) の型だと、自由に Key-Value が定義できてしまうので、入力させる Key を必須とさせたい場合に使います。
以下は VPC ネットワークのモジュールにおけるサブネットワークを指定する subnet 変数を定義した variable ブロックです。
この subnet の入力には、 name, cidr, region というキーを設定しないとエラーとなります。

variable "subnet" {
  type = object({
    name   = string
    cidr   = string
    region = string
  })
}

optional 関数

optional()variable ブロックの object() 関数内で、任意の変数にデフォルト値を定義するときに使う関数です。
昔のバージョンの Terraform では、 object() で指定したキーは必須であり、データの入力で省略することができませんでした。そのため、不要なキーであっても null や空文字列を持つデータを指定する必要がありました。
optional() でデフォルト値を記述することで、実際のデータを入力する際にキー自体を省略可能になります。
以下は先程の subnet 変数に secondary_ips を定義したものです。こちらは、root モジュールでの呼び出しで値を入力しない場合、 null 値が渡されます。

variable "subnet" {
  type = object({
    name          = string
    cidr          = string
    region        = string
    secondary_ips = optional(map(string), null)	
  })
}

まとめ

Terraform で提供されている様々な関数を紹介しました。ここで紹介している以外にも関数があるので、色々と試してみてください。

Discussion