🌏

Terraform module開発における公式ベストプラクティス

2024/08/04に公開

概要

https://developer.hashicorp.com/terraform/language/modules/develop/composition
自己理解のため、terraformのmodule開発における、Hashicorp公式のベストプラクティスを読み解いてみます。

Module Composition

Module Compositionの概要

一つのroot moduleだけでterraform configurationを記述するとき、リソース間の依存関係を記述する場合を考えます。その際にmoduleブロックを導入するときはフラットにします。

moduleを使うtfファイルの例です。

module "network" {
  source = "./modules/aws-network"

  base_cidr_block = "10.0.0.0/8"
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.subnet_ids
}

ここでconsul_clusternetworkのoutputに依存しています。この時に"./modules/aws-network"の呼び出しそのものをconsul_clusterが呼び出してるmodule "./modules/aws-consul-cluster"の中で行う形にもできますが、それをせずにフラットな構造を維持しましょう。
このようなパターンで実装していく方法をmodule compositionと呼びます。

Dependency Injection

上記の例においてはAWS VPCをmoduleを介して同一terraform configuration内で定義していますが、該当configurationで定義していないAWS VPCを利用する場合を考えます。
その場合"./modules/aws-consul-cluster"のmoduleにおいてVPCの値をハードコーディングすることも考えられますが、依存関係は必ず外から受け取るようにします。
この場合、この"./modules/aws-consul-cluster"は本来はAWS VPCに依存していますが、moduleとして包み、かつ必要な依存を定義し、それに沿ってroot moduleがmoduleを呼び出すようにしているため、依存性の逆転(Dependency Inversion)がなされている、ということのようです。(よく見るクリーンアーキテクチャーで出てくるレイヤーの依存方向を逆転させる実装とはちょっと違うように見えるので濁してます。

これによってmodule自身はidの命名規則、環境の違いなどを考えることなくresourceを定義できます。

data "aws_vpc" "main" {
  tags = {
    Environment = "production"
  }
}

data "aws_subnet_ids" "main" {
  vpc_id = data.aws_vpc.main.id
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = data.aws_vpc.main.id
  subnet_ids = data.aws_subnet_ids.main.ids
}

Conditional Creation of Objects

上記Dependency Injectionを活用した例です。
既に存在しているリソースを流用するケースはdata sourceを使い、新しく定義する場合はそのoutputを利用します。

aws amiのresourceの出力のスキーマを定義

variable "ami" {
  type = object({
    # Declare an object using only the subset of attributes the module
    # needs. Terraform will allow any object that has at least these
    # attributes.
    id           = string
    architecture = string
  })
}

新規にresourceを定義したoutputをmoduleが受け取る

# In situations where the AMI will be directly managed:

resource "aws_ami_copy" "example" {
  name              = "local-copy-of-ami"
  source_ami_id     = "ami-abc123"
  source_ami_region = "eu-west-1"
}

module "example" {
  source = "./modules/example"

  ami = aws_ami_copy.example
}

既に存在している場合 data source のoutputを受け取る

# Or, in situations where the AMI already exists:

data "aws_ami" "example" {
  owner = "9999933333"

  tags = {
    application = "example-app"
    environment = "dev"
  }
}

module "example" {
  source = "./modules/example"

  ami = data.aws_ami.example
}

module自身は入力がどのように作られたかは関知していません。また開発者からみてもどれが新規に定義したリソースか、既存のリソースの流用かの区別がしやすくなっています。

Assumptions and Guarantees

すべてのmoduleは暗黙的な仮定(Assumption)と保証(Guarantee)があります。

  • Assumption: 特定のリソースの設定を使用するために真でなければならない条件。例えば、aws_instanceの設定は、指定されたAMIが常にx86_64 CPUアーキテクチャ用に構成されているという仮定を持つことができます。
  • Guarantee: 設定全体が頼ることができるオブジェクトの特性や動作。例えば、aws_instanceの設定は、EC2インスタンスがプライベートDNSレコードを割り当てるネットワークで実行されるという保証を持つことができます。

Terraformの機能であるcustom conditionsを使って、これらの暗黙的な仮定と保証を確認できるようにしたり、テストすることを推奨します。これによって将来のメンテナーが設計意図などを理解しやすくなりますし、エラーの情報を早期に返すことができるようになります。

output "api_base_url" {
  value = "https://${aws_instance.example.private_dns}:8433/"

  # The EC2 instance must have an encrypted root volume.
  precondition {
    condition     = data.aws_ebs_volume.example.encrypted
    error_message = "The server's root volume is not encrypted."
  }
}

あくまでresourceに対する暗黙的な仮定と保証を記述する目的での使い方なので、テストしているわけではないことに注意

Multi-cloud Abstractions

Terraform自身は異なるクラウドベンダーの機能を抽象化することを意図していません。それをしてしまうと最小公約数的な要素しか実装できなくなってしまうためです。
しかし自分自身でマルチクラウドに対応する抽象化を実装することはできます。
例えばDNSレコードを定義する場合に次のようなobjectのvariableを受け取るように実装します。

variable "recordsets" {
  type = list(object({
    name    = string
    type    = string
    ttl     = number
    records = list(string)
  }))
}

これらはDNSレコードを定義するのに最低限必要な共通の要素です。各クラウドベンダーはDNSサービスに色んな機能を持たせていますが、それらの機能を排して利用することになります。

他にもkubernetesの例ではどのクラウドベンダーでも出力する情報をoutputにもつように実装できます。

output "hostname" {
  value = azurerm_kubernetes_cluster.main.fqdn
}

これによりkubernetesクラスタの定義そのものはクラウドベンダーごとにパラメータが異なるものの、他のkubernetes clusterに依存するmodule自身を交換可能にすることができます。

module "k8s_cluster" {
  source = "modules/azurerm-k8s-cluster"

  # (Azure-specific configuration arguments)
}

module "monitoring_tools" {
  source = "modules/monitoring_tools"

  cluster_hostname = module.k8s_cluster.hostname
}

Data-only Module

ほとんどのmoduleは内部でresourceを定義するように実装されるのを見てきましたが、data sourceを取って返すだけのmoduleを作ることもできます。
一般的なユースケースとしてあるシステムをいくつかのサブシステムに分割したものの、あるインフラリソースが複数のサブシステムで再利用されるような場合です。この時、このリソース情報の取得をmoduleとして実装できます。

AWSのネットワークの例

module "network" {
  source = "./modules/join-network-aws"

  environment = "production"
}

module "k8s_cluster" {
  source = "./modules/aws-k8s-cluster"

  subnet_ids = module.network.aws_subnet_ids
}

このメリットはconfigurationを変更することなく、ソースの情報を変更することができることです。またリファクタリング自身も容易にします。

感想

自分が大事だと感じたポイントは次の2点でした

  • 依存関係はフラットに保つ
  • custom conditionsを使ってresourceの暗黙的な要素を明示的にする

moduleを複数作って依存関係がtree構造になってしまうのはありがちなので、小規模であれば大事かなと思いました。一方で一つのterraform configで大量のマイクロサービスを管理していくようなケースで、個々のmoduleを肥大化させてroot moduleはコンパクトに保つ方針だったりするとちょっとそぐわないこともあるかと思いました。
custom conditionsはどっちかというと入力のバリデーションに使っていたので、ドキュメンテーション的にoutputに対して使うのは考えたことがなかったのでいい学びでした。

それはそれとして、Conditional Creation of ObjectsやMulti-cloud Abstractionsの例でみたような、variableを共通化させるような形を推奨するならほかの言語のような構造体定義を行って、variable定義するときにその定義を参照できるようになったりしないですかね。今のところこれらを実現するのにコピペをせざるを得ないのはなんとも...。

Discussion