Terraform公式ドキュメントに学ぶmoduleのアンチパターンとその対処法5選

# 前書き

Terraformの機能の中でもmoduleはworkspace(旧environment)と並んで評価が分かれる1つだと思います。
僕自身も複数の人からmoduleに否定的な意見を聞いたことがあり、実際にmoduleを使い始めた頃はあまり便利だとは感じませんでした。

しかし、公式サイトのmoduleのページ[^1][^2]を読んでいくうちに間違ったmoduleの使い方をしてしまっているケースが多いこと、そういったアンチパターンを実行した/目にした結果moduleが使えない・実用的ではないといった誤解を招いているケースが数多くあることに気づきました。そういったアンチパターンにならないよう気を配り数ヶ月moduleを使ってみると、moduleはとても便利で汎用性の高い機能であることに気づきます。

モジュール機能の有用性をもっと世のterraformerに知ってほしいと思ったため、このページでは公式サイトで述べられているmodule利用のアンチパターンとそれに対するベストプラクティスを5つ紹介します。これらを理解してモジュール機能のポテンシャルを100%引き出しましょう！

# アンチパターンとその対処法

僕がよりハマりやすい・より重要だと感じたものほど上に書きました。

## アンチパターン1. 1つのリソースのみ含むモジュールを作ってしまう

以下のような1つのリソースのみ含むモジュールを作ってしまう。

```subnet.tf
variable "vpc_id" {}
variable "cidr_block" {}
variable "availability_zone" {}

resource "aws_subnet" "subnet" {
  vpc_id            = var.vpc_id
  cidr_block        = var.cidr_block
  availability_zone = var.availability_zone
}

output "subnet_id" {
  value = aws_subnet.subnet.id
}
```

引用元: [Creating Modules \- Terraform by HashiCorp](https://www.terraform.io/docs/modules/index.html#when-to-write-a-module)

### アンチパターンの理由

1つのリソースしか含まないモジュールのことを引用元ページでは "単一リソースの薄いラッパー" と表現しています。

これには次のデメリットがあります。

1. モジュールが多くなりすぎる
    * 数多くのモジュールの仕様を理解するにはそれだけコストがかかります
2. モジュールの汎用性が低い
    * 元のリソースの `argument / output` の一部を取り出してモジュールの `variables / outputs` にすることでいわば初心者用リソース定義と言ったものを作るのが "この薄いラッパーモジュール" です
    * そのためシステムの複雑化や拡張、terraform記述者の成長によりすぐに要を満たさなくなります
    * 逆に `variables / outputs` を増やしていくとやがてリソース定義そのものと等価になりモジュールの意味がなくなります
3. モジュールのドキュメントの質が低くなりがち
    * 数多くの `variables / outputs` を書かなければいけないため大抵descriptionが省略されます
    * モジュール作成者は単純なモジュールなので自明だろうと考えREADMEもほとんど書かれることはありません。しかしそもそもモジュールの方向性がはっきりしていないため機能拡張・機能追加をどの方向でやるべきか後々メンテナーを困らせることになります

### ベストプラクティス

モジュールでラップするのではなく直接リソースをその場で宣言しましょう。
モジュールは **2つ以上のリソースをまとめることで意味ある単位として扱える** ときのみ有効に働きます。

### 余談

私はこのアンチパターンを異なる3社で見たことがあるのですが、主に以下の動機で行われるようです。

1. リソースのプロパティへデフォルト値を設定したい
2. 指定できるプロパティを限定したい
3. 特定のプロパティの設定を強制したい[^var]

[^var]: variableを使うことで指定を強制できます

以上の動機をまとめるとそのリソース種別に対してある種の標準化を行いたい、といった背景が伺えます。
その動機自体は一概に間違ったものではないのですが、Terraformの仕様上そういった目的にはmoduleは不向きなようです。

リソースのプロパティへ制約やルールを設けたい場合、モジュールではなく以下のLinterを用いるのが良いでしょう。

* [terraform\-linters/tflint](https://github.com/terraform-linters/tflint)
* [Sentinel \- HashiCorp](https://www.terraform.io/docs/cloud/sentinel/index.html)
* [instrumenta/conftest](https://github.com/instrumenta/conftest)

## アンチパターン2. チャイルドモジュールに他のモジュールを含んでしまう

例えばネットワークリソースへ依存するKubernetesのモジュールがあった場合、次のようにKubernetesモジュールでネットワークモジュールを内包してしまう。

![](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOhsBarABSfBBKajVjoyw77JZiVDInytxtdSl3fMGLVNJY7wSNFlu-RcnyqTZvlNWoIQrgEhuueEoiylmQeRZ-bS3gbvAK0N0000)

引用元: [Creating Modules \- Terraform by HashiCorp](https://www.terraform.io/docs/modules/index.html#module-structure)

### アンチパターンの理由

上記のKubernetesモジュールとネットワークモジュールの例で考えてみます。

このインフラに対してデータベースを追加する場合、

![](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOeEoiylUToyw77JZiVDInytxtdSl3fMGLVNJY7QkpGfjIWljIIr-77ZYiVDxI39flsFcsU26JiVDwv3ryT5eXw4NO-RUnytDpzkxdZSl1wiT3s6sGwfUIb05mC0)

のように既存のKubernetesモジュールへ相乗りさせる方法がありますが、これでは他のRootモジュールでKubernetes(モジュール)だけ使いたいときに無駄なデータベースまでついてくることになります。

![](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOeEoiylUToyw77JZiVDInytxtdSl3fMGLVNJY7QkpGfjIWljIIr65sU2qqEK-_Zvjx7pSsFcpiUDwy7IJIjHrS7iHcNxdQmflsFcsU26JinT3kavgK0tGy0)
一方このようにRootモジュール直下へデータベースモジュールを置いた場合、データベースモジュールの `variable` で必要なネットワーク情報をネットワークモジュールからKubernetesモジュール経由で引き回す必要があります。また、KubernetesモジュールはKubernetesに関するモジュールであるはずなのにネットワークの情報がKubernetesモジュールの `output` になってしまいます。これはカプセル化や単一責任の原則などに反しておりよくありません。

以上のようにチャイルドモジュールが他のモジュールを内包しているとモジュールの再利用性が下がるためアンチパターンだと言われています。

### ベストプラクティス

以下のようになるべくモジュール構造をフラットにすることが一般的には良いです。こうすれば上で発生したような不要な引き回しは発生しません。また各モジュールを汎用性の高い粗結合にできます。

![](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOeEoiylUToyw77JZiVDInytxtdSl3fMGLVNJY7wSNFlu-RcnyqTZvlNWoIQrgEhmwVRkpGfjIWljIIr65qUlvtjOAlsFswU2MRinD3ja9gN0dGv0000)

#### 例外

このパターンが良くないのはネストされた(孫)モジュールを複数のモジュールから参照するときに問題が発生するためです。ですのでそのモジュールを参照するモジュールが1つだけだと確信できているなら問題ありません。
例えばKubernetesクラスタを構成する各ノードの情報は通常Kubernetesモジュールの外側で必要になることは稀ですので、以下のようにネストすることは問題ないと考えられます[^eks-module]。

![](http://www.plantuml.com/plantuml/png/SoWkIImgAStDuOeEoiylUToyw77JZiVDInytxtdSl3fMGLVNJY7QkpGfjIWljIIr65sU2qqEK-_ZvlKWVjDgi6WVcEmanvXvZvjsFcxQZzSOXzIy5A1V0000)

[^eks-module]: [実際にterraformの公開モジュールの中で最もStarが多いEKSモジュールでもこういった構造を採用しています。](https://github.com/terraform-aws-modules/terraform-aws-eks/blob/eaa4d2c6/examples/managed_node_groups/main.tf#L96-L112)


## アンチパターン3. tfファイル1つだけのモジュールを作ってしまう / READMEや利用例を書かない

以下のように1つのtfファイルへvariable, resource, outputをすべて書いてしまう。

```subnet.tf
variable "vpc_id" {}
variable "cidr_block" {}
variable "availability_zone" {}

resource "aws_subnet" "subnet" {
  vpc_id            = var.vpc_id
  cidr_block        = var.cidr_block
  availability_zone = var.availability_zone
}

output "subnet_id" {
  value = aws_subnet.subnet.id
}
```

またREADMEや利用例を置かない。

引用元: [Standard Module Structure \| Terraform \| HashiCorp Developer](https://developer.hashicorp.com/terraform/language/modules/develop/structure)

### アンチパターンの理由

モジュールのファイル構造は後述の通り標準が決まっています。

この構造はTerraformが公開されてから数年間の間のベストプラクティスが詰め込まれています。
これを無視してオレオレ構造をやってしまうと先人がすでに踏んだ問題へ不要に行き当たってしまいます。
例えば `variables.tf` を使わずあらゆるtfファイルの中で `variable` を宣言してしまっていると、モジュールの利用者は使用時にどのvariableを渡す必要があるのか調べるのに苦労します。

また標準構造を無視したモジュール構造にしてしまうと標準構造に慣れた人を戸惑わせてしまうことになります。
メンテナンスもモジュール作成者以外にはしづらくなるでしょう。

### ベストプラクティス

[公式サイト](https://www.terraform.io/docs/modules/index.html#standard-module-structure)で述べられている以下の標準構造に従います。

```
.
├── examples
│   └── basic
│       └── main.tf  [可能な限り] このモジュールのミニマムな使用例を書きます
|                                何も変更せずともこのbasicディレクトリで
|                                terraform init && terraform apply が通るようにします
├── modules          [必要に応じて] ネストしたモジュール(このモジュール内でのみ使用するモジュール)はここで宣言します
│   ├── master-nodes [必要に応じて]
│   └── worker-nodes [必要に応じて]
├── LICENSE          [必要に応じて] 公開モジュールの場合は必ず置きましょう
├── README.md        [必須] モジュールの概要と用途を書きます。場合によっては図を含めましょう
|                           使用例は examples/basic 以下に実際に動くコードとして書き、
|                           ここからはリンクするにとどめたほうがよいです
|── variables.tf     [必須] 何もvariableがない場合でも空のファイルを作ります。
|                           またvariableのdescriptionは必ず書きます。
|                           単にリソースのargumentへ引き回しており自明に思えるときは
|                           https://www.terraform.io/docs/providers/aws/r/instance.html#ami
|                           などそのargument項目へのリンクを書くと良いです。
├── main.tf          [必須] 基本的にはここへリソース宣言を置きます。
├── another.tf       [必要に応じて] main.tfが長くなりすぎた場合はRoot Moduleと同じく
|                                 種別ごとにtfファイルを分けて書きます。
└── outputs.tf       [必須] 何もoutputがない場合でも空のファイルを作ります。
                            またoutputのdescriptionは必ず書きます。
                            単にリソースのoutputを引き回しており自明に思えるときは
                            https://www.terraform.io/docs/providers/aws/r/instance.html#id
                            などそのoutput項目へのリンクを書くと良いです。
```



## アンチパターン4. チャイルドモジュール内で `provider` ブロックを書いてしまう

```aws-networkモジュール.txt
.
├── LICENSE
├── README.md
|── variables.tf
├── main.tf
├── outputs.tf
└── providers.tf    <---- コレ
```

以下のようにチャイルドモジュール内でproviderブロックを書いてしまう。

```providers.tf
provider "aws" {
  version = "~> 2.54"
  region  = "ap-northeast-1"
}
```

引用元: [Modules \- Configuration Language \- Terraform by HashiCorp](https://www.terraform.io/docs/configuration/modules.html#providers-within-modules)

### アンチパターンの理由

モジュールを削除したときに `terraform apply` で以下のようなエラーが出て続行できなくなるためです。

```
$ terraform apply

Error: Provider configuration not present

To work with module.master_nodes.random_id.server its original provider
configuration at module.master_nodes.provider.random is required, but it has
been removed. This occurs when a provider configuration is removed while
objects created by that provider still exist in the state. Re-add the provider
configuration to destroy module.master_nodes.random_id.server, after which you
can remove the provider configuration again.
```

terraformでは各リソースは必ず単一のプロバイダとその設定へ紐付けられるのですが、このプロバイダ設定はtfstateファイルに保存されていません。`terraform`コマンド実行時に毎回Configuration(*.tfのこと)内のproviderブロックを見に行きます。
この仕様のため、チャイルドモジュール内に `provider` ブロックが書かれているとチャイルドモジュール内のプロバイダとチャイルドモジュール内のリソースが紐づくことになります。
ここまでは一見問題ないように思えるのですが、このチャイルドモジュール宣言を削除して `terraform apply` するときに問題が発生します。
モジュールを削除すると当然モジュール内のリソース宣言がなくなるためterraformはそのリソースを削除しようとするのですが、そのリソースと紐付けられたプロバイダブロックはすでに存在しません。そのためそのリソースの削除方法がわからないとエラーが出て `terraform apply` は正常に実行できなくなります。

### ベストプラクティス

チャイルドモジュールでは `provider` ブロックは書かずに必ずルートモジュールで宣言するようにします[^rod]。
違和感があるかもしれませんが特定のチャイルドモジュール内でのみ使われる `provider` だったとしてもルートモジュールに書きます。
プロバイダのバージョンを限定したい場合はチャイルドモジュールで `terraform` ブロック内に `required_providers` ブロックを宣言します。

```hcl
terraform {
  required_providers {
    aws = ">= 2.7.0"
  }
}
```

[^rod]: ここオブジェクト指向やDDDを習った人には依存関係が逆行していて非常に気持ち悪く感じるかもしれませんが現状のterraformではこれがベストプラクティスなのです

### 余談

この `terraform apply` がエラーを出して続行できなくなる状況に陥ってしまった場合、概ね2つの解決策があります。

#### 1. `provider` ブロックをチャイルドモジュールからルートモジュールへ移動させる

この方法なら根本的な問題を解決できます。最も手軽でもあるためおすすめです。

#### 2. 問題のプロバイダへ紐付けられているリソースを `terraform destroy -target` で削除する

こちらはどうしてもtfファイルを変更したくない場合の手段です。

アンチパターンの理由のところで述べた通り、リソースを削除するときにプロバイダ宣言がないためにエラーが起きます。ですので以下の手順でモジュール宣言があるうちにリソースを削除してしまえばエラーは起きません。

1. モジュール宣言を削除せず残したままにしておく
2. `terraform destroy -target=...` でチャイルドモジュール内部のプロバイダへ紐付けられたリソースをすべて削除する
3. モジュール宣言を削除する
4. `terraform apply` してエラーがないことを確認する

しかし、この方法には以下の問題があります。

1. 根本的な問題が解決しない
2. 手動で削除する必要のあるリソースを人間の手で選り分ける必要がある
3. うっかりパラメータを付けず `terraform destroy` してしまうと大惨事が起きる

そのためこの方法はなるべく避けましょう。

## アンチパターン5. モジュールのバージョンを最低バージョンで指定してしまう

```hcl
module "consul" {
  source  = "hashicorp/consul/aws"
  version = "~> 1.2"
}
```

引用元: [Modules \- Configuration Language \- Terraform by HashiCorp](https://www.terraform.io/docs/configuration/modules.html#module-versions), [Version Constraints \- Configuration Language \- Terraform by HashiCorp](https://www.terraform.io/docs/language/expressions/version-constraints.html#module-versions)

### アンチパターンの理由

`terraform` コマンドが実行されるPC/インスタンスにより使用されるバージョンが変わってしまうためです。

Terraformモジュールのバージョン指定には `>=` や `~>` といった冗長性のあるバージョン指定ができます。
しかしGemfileに対するGemfile.lockやpackage.jsonに対するpackage-lock.jsonのようにバージョンを固定する方法がないため、実際にインストールされるモジュールバージョンは実行した日時によって変わってしまいます。また `terraform init` はすでにダウンロード済みのprovider/moduleが存在しかつバージョン指定を満たしている場合は新たに最新のバージョンを持ってきたりはしません。
この仕様の元で `>=` や `~>` のようなバージョン指定をしてしまうと実行した日時や場所によりバージョンが違ってしまい、結果的に `terraform plan/apply` の結果が違うという非常に厄介な問題を引き起こします[^3728]。

### ベストプラクティス

バージョンは `>=` や `~>` などではなく直接特定のバージョンを指定しましょう。

```hcl
module "consul" {
  source  = "hashicorp/consul/aws"
  version = "1.2"
}
```

固定されたモジュールバージョンの更新は手動かまたは[Dependabot](https://dependabot.com/terraform/)へ任せることもできるようです(Dependabotは記事作成者未確認)。

どうしても範囲で指定したい場合はその時点で配布されている最新版を使用するルールにしてこまめに `terraform init -upgrade` で最新版を取得しましょう。

### 注意点

モジュールと違いプロバイダーはロックファイルが使われるようになりました！
ですので、プロバイダーについてはむしろ `>=` や `~>` を使うことが推奨されます。

# 蛇足

公式サイトに書かれているわけではないけども個人的に良いと思ったプラクティスをこちらにまとめました。
よければこちらも参考にしてもらえればと思います。

* [【Terraform】モジュールに関する個人的ベストプラクティス \- Qiita](https://qiita.com/bigwheel/items/a9e8dd4e12062ac12fe8)

# 引用元ページ

* [Modules \- Configuration Language \- Terraform by HashiCorp](https://www.terraform.io/docs/configuration/modules.html)
* [Creating Modules \- Terraform by HashiCorp](https://www.terraform.io/docs/modules/index.html)

[^1]: [Modules \- Configuration Language \- Terraform by HashiCorp](https://www.terraform.io/docs/configuration/modules.html)
[^2]: [Creating Modules \- Terraform by HashiCorp](https://www.terraform.io/docs/modules/index.html)
[^3728]: みんなセマンティックバージョニングに従っているからそうそう問題は起こらないだろうと最初は思うんですが、awsプロバイダですら[2.51 → 2.52](https://github.com/terraform-providers/terraform-provider-aws/blob/master/CHANGELOG.md#2520-march-06-2020)というマイナーバージョンアップで破壊的変更を入れてきたりするので無条件で信頼するのはよくありません。また単純に新たなバグが混入しているケースもままあります。