terraformのfor_eachでリソースを定義するまでの体験談

記事タイトルとURLをコピーする

こんにちは!サービス開発部の布施です。
本記事はterraform アドベントカレンダー12日目の記事になります!

私は普段、Cloud Automatorというwebアプリケーションの開発を行っています。しかし、つい最近は社内システムの新規開発プロジェクトを担当しており、インフラはterraformを使って構築しました。本ブログではその道中、for_eachを使った体験談とそこで出会ったterraformの仕様のことを書いていきます。

なお、ブログ執筆時のterraformのバージョンは1.9.7です。

for_eachなんぞや?

まず今回の肝でもある「for_eachなんぞや」という話ですが、これは「ループ処理を実現するための引数」です。
たとえば、AWSのIAMユーザーを1つ作る時の定義は以下の通りです。桃太郎さんのIAMユーザーを作るとしましょう。

resource "aws_iam_user" "momotarou" {
  name = "momotarou"
}

桃太郎はキジやサルやイヌを仲間にします。仲間たちもAWSにアクセスしたいと思うので、IAMユーザーを作ります。

resource "aws_iam_user" "momotarou" {
  name = "momotarou"
}

resource "aws_iam_user" "kiji" {
  name = "kiji"
}

resource "aws_iam_user" "saru" {
  name = "saru"
}

resource "aws_iam_user" "inu" {
  name = "inu"
}

こんな風にIAMユーザーごとにresource定義をしても良いのですが、for_eachを使うことでまとめて1つのブロックで定義することができます。

locals {
  iam_user_names = [
    "momotarou",
    "kiji",
    "saru",
    "inu",
  ]
}

resource "aws_iam_user" "users" {
  for_each = toset(local.iam_user_names)

  name = each.key
}

localsやtosetの話は後述するので一旦わからなくても問題ないです。ここでは

  • いわゆる配列のようなものを定義してfor_eachに渡すことで1つのリソースブロックで複数のIAMユーザーを作成できること
  • 各リソースの名前がeach.keyで定義されていること、つまりループ処理の中で配列のようなものの各要素はeach.keyで表現できること

が分かれば「for_each完全に理解した」と言っても過言ではないでしょう。

(余談ですが、countではなくfor_eachを使う理由が「詳細terraform」という本に書かれています。terraform初心者の私でもわかりやすい本だったのでおすすめです)
https://www.oreilly.co.jp/books/9784814400522/

for_each使う?使わない?

さて、直近のプロジェクトではAWS Lambda 関数を10個弱定義する必要がありました。「これはfor_eachの使い所かもしれない…!」と思っていた反面、以下のような理由で「わざわざfor_eachを使わなくても良いのでは…?」という気持ちもありました。

  • 10個弱のLambda関数ならそれぞれresourceで定義した方が後で特定のLambda関数だけ属性値を修正するときに都合が良いかもしれない
  • 今回作成したtfファイルはアプリケーションエンジニア(terraformを恒常的に触っているわけではないの意)が片手間に改修することになりそうなので、for_eachを使わない方がterraformの理解が小さく済む

ということで上記の経緯をチーム内の先輩エンジニア(つよい)に相談をしました。

なるほど、その通りだな…と思い、for_eachを使ってLambda関数を定義することを決めました。

なにをfor_eachに渡すのか

早速Lambda関数をfor_eachを使って定義します。先ほどのIAMの例に則って定義をすると以下のような形になります。

locals {
  functions_name = [
    "function_name1",
    "function_name2",
    "function_name3",
    "function_name4",
    ...
  ]
}

resource "aws_lambda_function" "functions" {
  for_each = toset(local.functions_names)

  name = each.key
  (細かい属性値は省略...)
}

しかし、今回はもう少し要件が複雑でした。Lambda関数のnameはキャメルケースを使用するのですが、handlerにはパスカルケースで関数名を定義する必要があったのです。

他のケース記法は動物由来なのに、Pascalは言語由来だと初めて知りました

そのため、イメージとしては配列のようなものではなく、keyをスネークケース、valueをパスカルケースとした”何か”を定義したいのです。果たしてそんなことが可能でしょうか…?

for_eachが受け取れる型

結論、できます。terraformではどんなデータ型が存在し、for_eachの値ではどのようなデータ型を受け取ることができるのかをみていきましょう。

terraformでは複数の値をもつ型として以下の5つが存在しています。

構造 説明
object key value 固定された key 名を持ち、key ごとに value の型を定義する。
map key value 任意の key 名を持ち、value は key によらず全て同じ型を持つ。例えばmap(string)ならvalueは全てstring型。
tuple 一次元配列 要素の数と型が決まっている一次元配列。
list 一次元配列 同じ型の要素を順番に並べた一次元配列。
set 一次元配列 同じ型の要素を重複なく持つ順序のない一次元配列。

そして、for_eachの値として使うことができるのはmap, setの2つです。 ドキュメントにも以下のように書かれています。

The for_each meta-argument accepts a map or a set of strings, and creates an instance for each item in that map or set.

for_eachメタ引数は、mapまたはset(string)を受け取り、そのmapまたはsetの各項目についてインスタンスを生成する。

実際にlist型をfor_eachで使用するとterraform planを実行した際に以下のようなエラーがでます。

│ The given "for_each" argument value is unsuitable: the "for_each" argument must be a map, or
│ set of strings, and you have provided a value of type list of string.

mapかsetを使ってほしい、list(string)ではダメだよーと怒られていますね。
ただし、私の手元ではobjectを使ってリソースを作成できるケースがあることを確認しました。以下のコードでterraform applyが正常に動作しました。

variable "user_names" {
  type = object(
    {
      name1 = string
      name2 = string
    }
  )
  default = {
    name1 = "sato"
    name2 = "tanaka"
  }
}

resource "aws_iam_user" "test" {
  name = each.value
  for_each = var.user_names
}

user_namesという変数ではデフォルト値がobject型になるように定義しており、for_eachでデフォルト値を使っています。
ドキュメントやエラーメッセージにはobjectについての言及はなかったことを鑑みると、もしかしたらobjectを使うことでエラーになるケースがあるのかもしれません。 少なくとも私が検証した限りではそのようなケースに遭遇しませんでした。

ところで話を少し前に戻して、IAMユーザーを作成するサンプルコードではtosetという関数を使っていました。これは先ほどの話の通り、list型で定義した値はfor_eachで受け取れないので、set型に変更するよという組み込み関数だったわけです。

locals {
  iam_user_names = [
    "momotarou",
    "kiji",
    "saru",
    "inu",
  ]
}

resource "aws_iam_user" "users" {
    // ここでlist → setしている意味がよくわかりますね
  for_each = toset(local.iam_user_names)

  name = each.key
}

話をLambda関数の定義に戻します。事前に用意したkey, valueを使ってnameとhandlerを定義しますが、valueは全てstringなので今回は(objectでも良さそうですが)mapを使うのが無難そうですね。なのでコードは以下の通り。

locals {
  function_names = {
    "function_name1" = "FunctionName1" 
    "function_name2" = "FunctionName2" 
    "function_name3" = "FunctionName3" 
    "function_name4" = "FunctionName4" 
     ...
  }
}

resource "aws_lambda_function" "functions" {
  for_each = local.function_names

  name = each.key
  handler = "${each.value}.lambda_handler"
}

function_namesはkey valueなのでeach.valueでvalueの値を取得していることもわかりますね。
これで無事にLambda関数を定義することができたのでめでたしめでたしーーー

としたいところですが、一つ疑問が残りました。それはlocalsなんぞや?という話です。そして型の話をしたのにも関わらずlocalsでは型宣言がされてないように見えますよね。function_namesは本当にmapなんでしょうか?

localsとは一体何者なんでしょう…?

localsと型定義

ざっくりお伝えするとlocalsは「変数を定義する場所」です。terraformでは変数の定義方法がいくつか用意されていますが、moduleの内部でのみ使用できる変数を定義できる場所がlocalsです。moduleについてやその他の変数定義をする方法は割愛しますが、localsで定義した値は一旦このブログでは自由に使えると考えてもらって大丈夫です。

大事なのはここからで、「localsの中で変数を定義すると自動的に型の推論が行われる」はずなのです。これはドキュメントにないので念の為「はず」という表現をしました。

いくつか例を見ていきましょう。
1つ目に先ほどの例で使ったfunction_namesです。これはmapだと思って定義をしていました。
とりわけvalueがstring統一のシンプルなkey valueでしたね。

locals {
  function_names = {
    "function_name1" = "FunctionName1"
    "function_name2" = "FunctionName2"
    "function_name3" = "FunctionName3"
    "function_name4" = "FunctionName4"
  }
}

こいつの型を調べたいと思います。
型定義はterraform consoleでtype(local.function_names)実行して調べることができます。
結果は以下のとおりです。

> type(local.function_names)
object({
    function_name1: string,
    function_name2: string,
    function_name3: string,
    function_name4: string,
})

こ、こいつobjectなのか、mapじゃないのか... 予想では値の型がstringに統一されているのでmapだとばかり思っていました。が、どうやらobjectらしく、だとしてもfor_eachの値に渡すことが可能なのは先ほども書いたとおりです。

では次に各keyの値が異なる型だったらどうでしょうか?
以下のような変数をlocals内に用意しました。

locals {
  user = {
    "name"   = "tanaka"
    "age"      = 20
    "person" = true
  }
}

このuserの型は…

> type(local.user)
object({
    age: number,
    name: string,
    person: bool,
})

object型ですね。objectでは各keyに対して値の型を定義できるのでこれは順当というか直感的です。

こうなってくると、どうにかmapで定義したくなるのが人間の性です。tomap関数を使うことで強引にmapを定義することができないかなと...

locals {
  function_names = tomap({
      "function_name1" = "FunctionName1"
      "function_name2" = "FunctionName2"
      "function_name3" = "FunctionName3"
      "function_name4" = "FunctionName4"
  })
}

> type(local.function_names)
map(string)

できました。map型で全てのvalueはstringを取ります。

ちなみに先ほどのuserをtomapするとどうなるでしょう。私の予想では各値に異なる型を持っているのでmapとして定義はできずに、エラーが出るのではないかと思っています。実験してみました。

locals {
  user = tomap({
    "name"   = "tanaka"
    "age"      = 20
    "person" = true
  })
}

> type(local.user)
map(string)

お、エラーとならずmap型になりましたね。20やtrueはどうなったのでしょうか?

> local.user
tomap({
  "age" = "20"
  "name" = "tanaka"
  "person" = "true"
})

なるほど20を"20"、trueを"true"とstringで解釈することでエラーなくmap(string)になるみたいです。
こぼれ話なのでこの辺で終わりにしますが、兎にも角にもlocals内での型定義は明示的に指定するようなものではなく、推論されることがわかっていただけたかと思います。

これでLambda関数をfor_eachで無事に定義することができました。

終わりに

長々と書いてきましたが、プロジェクトはすでに終了しfor_eachで定義されたLambda関数たちは今日も元気(なはず)です。
型の話はそこまで気にせずとも動くものを作れるのが正直なところでしたが、自分のコードに責任を持てないな...と思っていたので、せっかくなら細かい仕様を学びつつ実装するか〜というモチベーションで調べました。

本当はもっとプロジェクトの話も書きたかったのですが(なんでhandlerでパスカルケース使ってるの?とかキャメルケースの配列からパスカルケースの配列作れるよね?とか)のですが、ブログが長くなってしまいそうなのでこの辺で終わりにします。 最後まで読んでいただいた方、ありがとうございました。

ふせ ゆきひろ(執筆記事の一覧)

サービス開発部 2022年新卒入社

隙あらば柴犬の動画を見ています

2024 Japan AWS All Certifications Engineers