terraformのmoduleを作成するようになって知った6つのことのメモ

真面目にterraformでmoduleを書くようになって知った事をメモしてみる。これは自分用のメモなのであまり丁寧な説明はしない。 知った経緯がmoduleの作成ということであって、terraform moduleに関する機能というわけではない。

具体的には以下6つが知らなかった事柄

  • countで作成する/しないの制御できること
  • lifecycleのignore_changesで差分の判定条件を調整できること
  • moduleのvariableのtypeにはネストしたobjectのような複雑な型が書けること
  • moduleのvariableのtypeは渡される値の部分型であれば良いということ
  • listにはconcat()、mapにはmerge()でいわゆるmonoidっぽく扱えること
  • terraformにもfor expressionという内包表記的な記述が存在していること

countで作成する/しないの制御できること

実はドキュメントに普通に書かれているのだけれど知らなかった。

これはresourceを作ったときに、そのresourceは常に count というパラメーターを渡せる、そしてそのパラメーターが0の場合には対象のresourceの作成を行わないようにできるというような機能。

VPCの設定などでcountが1以上のものが使われていたりするのは見ていたが、0を指定して作らないようにするというのは初めて見た。例えば以下の様なコードはaws_instanceを作成しない。

main.tf

resource "aws_instance" "server" {
  count = 0 // 0なのでresourceを作成しない

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  tags = {
    Name = "Server ${count.index}"
  }
}

これだけではあまり意味のないコードだが、variableでboolを受け取ってあげると基礎的な依存部分を作成するかしないか制御できるようになる。

variables.tf

variable "create_instance" {
    type = bool
    default = false
}

例がトリビアルだけれど、まぁ以下の様な形で。

main.tf

resource "aws_instance" "server" {
  // create_instanceがtrueのときだけaws_instanceを作成する
  count = var.create_instance ? 1 : 0

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  tags = {
    Name = "Server ${count.index}"
  }
}

例えばawsのlog_groupの作成など多くのresourceで共用で利用されるものの作成をmodule側で持つときに使える。

lifecycleのignore_changesで差分の判定条件を調整できること

こちらもドキュメントに書かれている。

例えば、ECSを使うときにdesired_countの調整は他のツールに任せたい。そういうときにdesired_count部分を差分チェックから外せる。

以下の例では、tagsの変更は差分と認識されない。

main.tf

resource "aws_instance" "example" {
  # ...

  lifecycle {
    ignore_changes = [
      # Ignore changes to tags, e.g. because a management agent
      # updates these based on some ruleset managed elsewhere.
      tags,
    ]
  }
}

あとは、なぜか特定の設定のときに無駄にresourceの作り直しが発生することがあって、それを制御するためのwork-aroundとして使える。

moduleのvariableのtypeにはネストしたobjectのような複雑な型が書けること

これは、世に存在するmoduleの定義や例がstringやnumberやboolなどのプリミティブな型で受け取ることものがほとんどであったので、勝手にvariableで受け取る引数の型に制限があると思っていた1

型についてはドキュメントが存在して、実はこのページでもobject型について言及している。

後者のほうが詳しい。普通にこれらのページに書かれている型が自由に使えたと言う話だった。

例えば、object型の引数をdefault_configurationなどの名前で書いておくと、ちょっとした1段階の差分プログラミングのような記述が可能になる2

main.tf

locals {
  default_configuration = {
    x = 10
    y = 20
    z = 30
  }
}

module "aaa" {
  source = "./modules/xyz"

  name = "aaa"
  default_configuration = local.default_configuration
}

module "bbb" {
  source = "./modules/xyz"

  name = "bbb"
  default_configuration = local.default_configuration
}

variables.tf

variable "default_configuration" {
  type = object({
    x = number
    y = number
    z = number
  })
}

このようにしておくと、実質的に継承と似たようなことができ、あるmoduleが依存するパラメーターが増えたときに、全てのmoduleの利用箇所に追加して回るということをしなくて良い。 なにより、依存のパラメーターの数だけmoduleのvariableの定義が増え、どれが主要なパラメーターであったのかわからなくなるということも防げる。

少し異なる拡張性に関する話としてData-only modulesというものも存在するが、こちら途中から導入するには大量のリファクタリングが必要になったり、初期状態からいきなり導入するには過度な最適化感があったりと、まだ導入には慎重な姿勢を崩していない3

moduleのvariableのtypeは渡される値の部分型であれば良いということ

これは先程の話にも関連するが、moduleのvariableに複雑なobject型を定義できることがわかった。その上で、既存のresourceの値をそのまま渡したいと思うことがあった。例えば、awsのresourceで言えば、tagsの設定をするために、arnやidだけではなくname的な情報もmodule側に渡したいと思うことがあった。

具体的な例で言うと、名前解決のためにcloud mapにserviceを登録するときに、そのmoduleの中ではnamespaceのidだけではなくname的な情報も欲しい事がある。そしてそのresource自体はどちらの値も保持している。こういうようなときにいちいちlocal的な束縛を用意して、これを経由してmoduleを渡すのは面倒だったりする。

こちらは、localな束縛を経由して値を渡す例。ちょっとめんどくさい。

main.tf

locals {
  default_namespace = {
    name = aws_service_discovery_private_dns_namespace.default.name
    arn = aws_service_discovery_private_dns_namespace.default.name
  }
}

module "xxx" {
  source = "../modules/my-web-service"

  namespace = local.default_namespace // idもarnも欲しい
  ...
}

これが以下のように直接渡せたら便利ではという話。

main.tf

module "xxx" {
  source = "../modules/my-web-service"

  namespace = aws_service_discovery_private_dns_namespace.default
  ...
}

ここで、通常の感覚で捉えると、moduleがvariableで指定する型の定義と渡されるresourceの型が完全に一致していなければいけないと感じるが、moduleの定義側では余分な引数を可能な限り減らしたい。もし仮にここでの代入的な操作(module configuration blockでのパラメーターの受け渡し)が、moduleのvariableのtypeが渡されるであろう型のsubsetであれば動くのであれば、すごく嬉しい。そしてこれが実際に動く。

今の所上手くこれが明示された仕様なのか把握しきれてはいないのだけれど、実装を見る感じ思った以上に動的な定義になっているし、もしかしたら行けるかも?と思って試してみたら動作した。この挙動の出自を明らかにするのは個人的なtodo。

例えば、aws_service_discovery_private_dns_namespace戻り値部分のコード

つまり先ほどのnameとarnを取る例であれば以下のようなvariableの定義で十分ということになる。

variables.tf

variable "namespace" {
  type = object({
    arn = string
    name = string
  })
}

ちなみにどのresourceがどのような値として使えるかを確認する良い方法が見つかっていないので知りたい。これも2つ目のtodo。 現状はecho <resouce> | terraform console みたいにして調べている。

追記

terraformは内部でgo-ctyが使われている。このパッケージのドキュメントに関連した説明があった。

まずはこちら。mapを経由して渡されると書かれている。

https://github.com/zclconf/go-cty/blob/main/docs/convert.md#conversion-between-object-types

Object types are constructed by passing a map[string]Type to cty.Object. Object values can be created by passing a map[string]Value to cty.ObjectVal, in which the keys and value types define the object type that is implicitly created for that value.

このページ中に型変換に関する詳細はこちらというリンクが存在してそちらにも関連する情報が書かれている。

余分なattributeは静かに破棄されると書かれている4

https://github.com/zclconf/go-cty/blob/main/docs/convert.md#conversion-between-object-types

If the input type has additional attributes that are not mentioned at all in the target type, those additional attributes are silently discarded during conversion, leading to a new object value that has a subset of the attributes of the input value, and whose type therefore conforms to the target type constraint.

listにはconcat()、mapにはmerge()でいわゆるmonoidっぽく扱えること

これは素直な話で単純に組込の関数を把握しきれていなかっただけ5

例えば、環境変数の設定をenvironments的な値で持つ事が多い。これを外部から注入したいとなるとvariableとして口を開ける必要がある。一方でデフォルト値として幾分かの設定を持っておきたいことがある。この辺はいわゆるhaskellscalaで言うmonoidみたいな形になっていると嬉しい。無害な単位元を足し合わせる演算をデフォルトとして設定してあげれば良いので。

例えば、environmentsを以下の様に定義してあげれば良い。そうすると値をmodule利用時に追加可能なリストをパラメーターとして提供する事ができるようになる。

modules/xxx/variables.tf

variable "environments" {
  default = []
  type = list(object({
    name = string
    value = string
  }))
}

modules/xxx/main.tf

resource "aws_ecs_task_definition" "service" {
  family = "service"
  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "myapp"
      cpu       = 10
      memory    = 512
      environments = concat([
       {
         name = "name"
         value = var.name
       },
      ], var.environments) // これ
    },
  ])
  ...
}

main.tf

module "xxx" {
  source = "modules/xxx"
  environments = [
    {
      name = "controlled-by"
      value = local.controlled.name
    }
  ]
}

あとはgoで実装されていることもあって、zero値がundefinedなものとして機能していそう。これも無害なデフォルト設定を渡すときに良い。

terraformにもfor expressionという内包表記的な記述が存在していること

これはへーと思う程度ではあるんだけれど、terraformにも内包表記の文法があった。ただしご利用は計画的にという注釈は常につく。

たとえば、先程の {"name": "<ENV_NAME>", "value": "<ENV_VALUE>"} のような記述の矯正を {"<ENV_NAME>": "<ENV_VALUE>"} と言う形で書けるようにできる。

modules/xxx/main.tf

resource "aws_ecs_task_definition" "service" {
  family = "service"
  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "myapp"
      cpu       = 10
      memory    = 512
      environments = concat([
       {
         name = "name"
         value = var.name
       },
      ], [for k,v in var.environments : {"name": k, "value": v}]) // これ
    },
  ])
  ...
}

条件を指定してフィルタリングもできるので全体を渡してmodule側で絞って使うということもできたりする。

似たような話ではあるが別の話としてdynamic blocksという記述の仕方もあるが、個人的にはdynamic blockの利用はあまり好きではない。こちらもご利用は計画的に。

まとめ

まぁそんな感じでterraformのmoduleの利用を契機に色々学んだことをメモしてみた。


  1. outputには制限があったはず

  2. ちょっとした継承のようなことができる。差分プログラミングは実装の複雑さを招くと言う意味では忌避される面もあるかもしれない。とはいえ1段階程度の差分プログラミングなら弱毒化されて悪くはない。

  3. 個人的な意見というか態度の表明。汎用的なmoduleを考えるのは一旦忘れている。現状では再利用性を重視せずに具体的なmoduleだけを書いていこうという指針で動いてはいる。

  4. ちなみに、この記事で少しだけ言及したゼロ値やoptionalな値に関する諸々は実験的な機能として、特別な取り扱いをサポートする事(ObjectWithOptionalAttrsまわり)が書かれているが、現状terraformでどう使われているかわかっていない。調べきれていない

  5. この他にも組み込みの関数が色々あって圧倒されている状態ではあったりする。これ以外ではjsonencodeが好み。terraform側でのリテラルをコメント付きJSONとして扱える。あと一応JSONのスーパーセットとして機能する文法だったはず。