情報畑でつかまえてロゴ
本サイトは NTTテクノクロスが旬の IT をキーワードに
IT 部門が今知っておきたい最新テクノロジーに関する情報をお届けするサイトです

Terraform連載 第4回:for_eachの使い方

Terraform連載企画!今回は、コードの効率的な書き方として、for_eachの使い方についてご紹介します。

terraform_banner_r2.png

はじめに

for_eachとは

一言でいえば、1つのresourceブロックで複数のリソースを作れるようにするMeta-Argument(メタ引数)です。
[Terraform公式サイト:The for_each Meta-Argument]

Terraformでリソースを作成するときはresourceブロックを使いますが、原則として1ブロックにつき1つのリソースしか作れません。
実際の環境構成では同じ種類のリソースを複数作成することが良くあります。たとえばAWSでは、サブネットを複数のアベイラビリティゾーンに作成して冗長構成とすることがベストプラクティスとされています([AWS公式サイト:VPC のセキュリティのベストプラクティス])。また、プライベートサブネット・パブリックサブネットに分けて作成することも一般的だと思います。

とはいえ、サブネットの数だけresourceブロックを作成するのは煩雑ですし、同じようなブロックを何度も書いてしまうと可読性も下がってしまいます。こういったケースでは、for_eachが効果を発揮します。

実際に書いてみる

では、実際にfor_eachを使って、1つのaws_subnetのresourceで複数のサブネットを作成するコードを書いてみます。
以前のブログで、importブロックと-generate-config-outを紹介したときに自動生成したコードをリファクタリングする想定で書いてみようと思います。
このとき自動生成されたコードは、import対象のリソースの定義がすべてべた書きになっていて、スマートなものではありませんでした。for_eachを使うにはもってこいと言えます。

ちなみに以前ブログで紹介したときのサブネット作成のコードは以下のようになっていました。

[terraform:vpc-subnet.tf]

resource "aws_subnet" "nara_private_1a" {
 vpc_id            = aws_vpc.nara.id
 availability_zone = "ap-northeast-1a"
 cidr_block        = "172.16.0.64/26"
 tags = {
  Name = "nara-private-1a"
 }
}
 
resource "aws_subnet" "nara_public_1a" {
 vpc_id            = aws_vpc.nara.id
 availability_zone = "ap-northeast-1a"
 cidr_block        = "172.16.0.0/26"
 tags = {
  Name = "nara-public-1a"
 }
}
 
resource "aws_subnet" "nara_public_1c" {
 vpc_id            = aws_vpc.nara.id
 availability_zone = "ap-northeast-1c"
 cidr_block        = "172.16.0.128/26"
 tags = {
  Name = "nara-public-1c"
 }
}
 
resource "aws_subnet" "nara_private_1c" {
 vpc_id            = aws_vpc.nara.id
 availability_zone = "ap-northeast-1c"
 cidr_block        = "172.16.0.192/26"
 tags = {
  Name = "nara-private-1c"
 }
}

今回、for_eachを使って記述したコードはこちらになります。

[terraform:subnet.tf]

# subnet.tf
 
locals {
 subnet_map_list = [
  { "name" = "nara-public-1a", "cidr" = "172.16.0.0/26", "az_name" = "ap-northeast-1a" },
  { "name" = "nara-private-1a", "cidr" = "172.16.0.64/26", "az_name" = "ap-northeast-1a" },
  { "name" = "nara-public-1c", "cidr" = "172.16.0.128/26", "az_name" = "ap-northeast-1c" },
  { "name" = "nara-private-1c", "cidr" = "172.16.0.192/26", "az_name" = "ap-northeast-1c" },
 ]
}
 
resource "aws_subnet" "nara" {
 for_each = { for i in local.subnet_map_list : i.name => i }
 
 vpc_id            = aws_vpc.nara.id
 availability_zone = each.value.az_name
 cidr_block        = each.value.cidr
 tags = {
  Name = each.value.name
 }
}

localsブロックはローカル変数を宣言するブロックです。subnet_map_listはリスト型の変数で、中の要素はmap型となっています(mapの中ではサブネットごとに異なるパラメータのNameタグ、cidrブロック、アベイラビリティゾーンを保持しています [Terraform公式サイト:Local Values])。

aws_subnetのresourceブロック内ではsubnet_map_listをfor_eachでループ処理しますが、for_eachはmap型またはset型しか扱えません。そのためforを用いてlist型をmap型に変換する処理をしたうえでfor_eachでループ処理するように実装しています。
このfor文では、nameの値をkey、list要素のmapをvalueとしたmap型に変換しています。具体的には下記のような形に変換されています。

subnet_map_list = {
  nara-private-1a = {
    az_name = "ap-northeast-1a"
    cidr    = "172.16.0.64/26"
    name    = "nara-private-1a"
  }
  nara-private-1c = {
    az_name = "ap-northeast-1c"
    cidr    = "172.16.0.192/26"
    name    = "nara-private-1c"
  }
  nara-public-1a  = {
    az_name = "ap-northeast-1a"
    cidr    = "172.16.0.0/26"
    name    = "nara-public-1a"
  }
  nara-public-1c  = {
    az_name = "ap-northeast-1c"
    cidr    = "172.16.0.128/26"
    name    = "nara-public-1c"
  }
}

forの変換をせずとも、最初から上記のmap形式でlocalsを定義しても問題ありません。keyをサブネットごとに定義するのも手間ですし、「作成したいサブネットの個数分の要素をもったリスト」のほうが直感的で分かりやすいと感じますので、今回はforでlist型をmap型に定義することとしました。

動作確認

作成したコードでterraform planコマンドを実行してみます。使用するTerraformのバージョンは1.5.6(執筆時点の最新版)です。なお実行環境のコードの構成は以下のようになっています。

providers.tf
subnet.tf
vpc.tf

providers.tfは先述のimportブロックの紹介記事に書いているものと同一です。vpc.tfは同記事からaws_vpcのresourceブロックを抜き出したものです。subnet.tfは今回作成したコードです。

実行結果は以下になります。

$ terraform plan 
 
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:
 
 # aws_subnet.nara["nara-private-1a"] will be created
 + resource "aws_subnet" "nara" {
   + arn                                            = (known after apply)
   + assign_ipv6_address_on_creation                = false
   + availability_zone                              = "ap-northeast-1a"
   + availability_zone_id                           = (known after apply)
   + cidr_block                                     = "172.16.0.64/26"
   + enable_dns64                                   = false
   + enable_resource_name_dns_a_record_on_launch    = false
   + enable_resource_name_dns_aaaa_record_on_launch = false
   + id                                             = (known after apply)
   + ipv6_cidr_block_association_id                 = (known after apply)
   + ipv6_native                                    = false
   + map_public_ip_on_launch                        = false
   + owner_id                                       = (known after apply)
   + private_dns_hostname_type_on_launch            = (known after apply)
   + tags                                           = {
     + "Name" = "nara-private-1a"
    }
   + tags_all                                       = {
     + "Name" = "nara-private-1a"
    }
   + vpc_id                                         = (known after apply)
  }
 
 # aws_subnet.nara["nara-private-1c"] will be created
 + resource "aws_subnet" "nara" {
   (※for_eachで設定したパラメータのみ抜粋)
   + availability_zone                              = "ap-northeast-1c"
   + cidr_block                                     = "172.16.0.192/26"
   + tags                                           = {
     + "Name" = "nara-private-1c"
    }
   + tags_all                                       = {
     + "Name" = "nara-private-1c"
    }
  }
 
 # aws_subnet.nara["nara-public-1a"] will be created
 + resource "aws_subnet" "nara" {
   (※for_eachで設定したパラメータのみ抜粋)
   + availability_zone                              = "ap-northeast-1a"
   + cidr_block                                     = "172.16.0.0/26"
   + tags                                           = {
     + "Name" = "nara-public-1a"
    }
   + tags_all                                       = {
     + "Name" = "nara-public-1a"
    }
  }
 
 # aws_subnet.nara["nara-public-1c"] will be created
 + resource "aws_subnet" "nara" {
   (※for_eachで設定したパラメータのみ抜粋)
   + availability_zone                              = "ap-northeast-1c"
   + cidr_block                                     = "172.16.0.128/26"
   + tags                                           = {
     + "Name" = "nara-public-1c"
    }
   + tags_all                                       = {
     + "Name" = "nara-public-1c"
   }
  }
 
 # aws_vpc.nara will be created
 + resource "aws_vpc" "nara" {
   (省略)
  }
 
Plan: 5 to add, 0 to change, 0 to destroy.
 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.

このように4つのサブネットを作るplan結果が表示され、Nameタグ、cidrブロック、アベイラビリティゾーンもsubnet_map_listで設定した通りの値になっていることが分かります。とても効率的なコードになったのではと思います。

なお、plan結果でも表示されていますが、for_eachを使った場合はリソースの識別名が"aws_subnet.nara[mapのキー名]"のようになります(for_eachを使わなかった場合は"aws_subnet.nara")。
これは、コード上のresourceブロックと実際に作成されるリソースの関係が1:nの関係になることで、"aws_subnet.nara"では対象リソースを一意に特定できなくなり、mapのキー名(set型の場合はsetのメンバー)で区別するようになっているためです。他のresourceブロックからfor_eachで作成したリソースのid等を参照する場合は注意が必要です。
上記仕様については[Terraform公式サイト:Referring to Instances]でも説明されています。

まとめ

今回は、for_eachを使って1つのresourceブロックで複数のリソースを作成する方法を紹介しました。
共通の設定は1回だけ書き、異なる部分だけmapやsetの変数に持たせておく、といった使い方ができるようになります。これにより可読性やメンテナンス性の向上に寄与するのではと思います(例えば今回作ったコードでは、リストの要素を1つ追加するだけでサブネットを1つ追加できます)。

Terraformのコードの書き方は本当にたくさんの方法があって1つの記事では紹介しきれないので、機会があればまた別の記事で紹介してみたいと思います。
お読みいただきありがとうございました。

IOWNデジタルツイン事業部では、Terraform EnterpriseおよびTerraform Cloud Plusに関する、日本語による保守サポートをご提供しております。Terraformの導入にあたってお悩みの方は、ぜひお気軽にご相談ください。

Terraformの導入にあたってお悩みの方へ

terraform_banner_r2.png

連載シリーズ
著者プロフィール
岩瀬 正太郎
岩瀬 正太郎

NTTテクノクロス株式会社
ⅠOWNデジタルツイン事業部
第三ビジネスユニット