モジュール — 再利用より境界が先だ
一言でいうと
モジュールは、コードを短くするための仕組みではなく、変更が波及する範囲を制限するための仕組みです。
なぜ必要なのか
同じスタックを、dev・stage・prodに作るとします。最初は、ディレクトリを3つコピーするのが最も速いです。実際に速いです。問題は、6か月後です。prodに急いで入れた設定が、devにはなく、stageには半分だけ入っています。3つのディレクトリは、いまや互いに別の生き物になっており、「prodでだけ起きる問題」の原因を探すには、3つのファイルを並べてdiffを取る必要があります。
ここでの本当のコストは、重複した行数ではありません。変更を3回行う必要があるという事実と、3回のうち1回を忘れても誰も気づかないという事実です。モジュールは、この問題を「実装は1つ、入力だけ3つ」に逆転させます。そうすれば、変更は1か所で行われ、環境間の違いは、コードではなく値として現れます。
どう動くのか
モジュールは、ただ.tfファイルが入っているディレクトリです。特別な文法はなく、慣例があるだけです。
| ファイル | 役割 |
|---|---|
variables.tf |
このモジュールが受け取る入力。モジュールの公開API |
main.tf |
実装。外から見えないようにすべき部分 |
outputs.tf |
外に出す値。モジュールの戻り値 |
3つのファイルに分ける理由は、好みではなく読む順序のためです。他人のモジュールを初めて見るとき、人が知りたいのは「何を入れると何が出てくるのか」であり、実装はその次です。
覚えておくべき性質が4つあります。
1つ目に、モジュールはカプセルです。モジュール内のリソースは、外から直接参照できません。module.primary.local_file.box.filenameのようなアドレスは存在しません。値を外に取り出すには、必ずoutputを通す必要があります。面倒に見えますが、この制約のおかげで、モジュール内のリソース名を変えても、使う側が壊れません。
2つ目に、アドレスが変わります。モジュール内のリソースの状態アドレスには、module.<호출이름>.というプレフィックスが付きます(プレースホルダーは呼び出し名です)。ネストすると、module.bundle.module.left.local_file.boxのように積み重なっていきます。リファクタリングがそのままアドレスの変更になるという事実が、第3回の状態手術につながります。
3つ目に、プロバイダーはルートが注入します。子モジュールの中にproviderブロックを置くと、そのモジュールを使う側がプロバイダー設定を変更できなくなり、さらに悪いのは、そのモジュールの呼び出しをコードから削除できなくなることです。リソースを破棄するにはプロバイダー設定が必要ですが、その設定が、消えたモジュールの中にあるためです。そのため、モジュールにはrequired_providersだけを置き、実際の設定はルートに置きます。
4つ目に、同じソースを何度も呼び出します。インスタンスを増やしたいからとモジュールのディレクトリをコピーすると、最初のコピペの問題に逆戻りします。module "primary"とmodule "secondary"のように、同じsourceを名前だけ変えて呼び出すのが正解です。
現場での姿
1つ目に、分ける基準は「一緒に変わるか」です。リソースの種類ごとに分けると(すべてvpcモジュール、すべてiamモジュール)、見た目はすっきりしますが、実際の変更は、いつも複数のモジュールにまたがって起きます。一緒に生まれて一緒になくなるものを、1つのモジュールにまとめる必要があります。状態ファイルを分ける基準も同じです。爆発半径とapplyの時間が基準であり、フォルダの美学ではありません。
2つ目に、検証はモジュールの境界で行うのが最も安上がりです。誤った値がモジュールの中に入ると、エラーは、ずっと下のリソースで発生します。variableのvalidationブロックで入口で止めれば、エラーメッセージに「どのモジュールのどの変数がなぜ拒否されたのか」がそのまま表示されます。デバッグ時間が何倍も変わります。
3つ目に、モジュールはバージョンを持ちます。ローカルの相対パスは、学習や単一リポジトリでは楽ですが、複数のチームが使うモジュールなら、レジストリとバージョンの固定が必要です。そうしないと、他人のコミット1つが、私たちのprodのプランを変えてしまいます。
モジュールをいつ作り、どこまで隠すか
モジュールは再利用のために作ると教わりますが、実際には2つ目の使用先ができたときに 作るのが正しいです。1か所でしか使わないモジュールは、ファイルをもう1つ開かせるだけです。
早く作ったモジュールは、必ず誤った抽象化になります。最初の使用先の要求だけを知って作ったから です。2つ目の使用先ができると変数を1つ増やし、3つ目でまた増やしているうちに、 変数が40個あるモジュールになります。その時点のモジュールは、隠しているものがないので、存在 する理由がありません。
良いモジュールは、決定を含みます。リソースをまとめるのではなく、「私たちの組織では、この種類の ものはこう作る」という判断を含める必要があります。暗号化は有効にし、ログはどこに送り、 タグはこう付け、バックアップは何日間保管するという決定を中に入れ、外には少数だけ 公開します。
変数は少ないほどよく、素通りさせるだけの変数は悪いです。中のリソースの属性をそのまま 外に出す変数が多いなら、そのモジュールは薄い殻です。それなら、モジュールを なくしてリソースを直接使うほうが、読みやすいです。
バージョンを固定し、上げていきます。レジストリやgitタグでバージョンを決め、使用先はその
バージョンを明示します。ref=mainにしておくと、モジュールを修正した瞬間に、誰も計画していない
変更がすべての使用先に広がります。
module "db" {
source = "git::https://git.example.com/tf-modules.git//rds?ref=v2.3.0"
...
}
ネストは2段階までです。モジュールの中のモジュールの中のモジュールになると、どの変数がどこまで 渡されるのかを、誰も追跡できません。値を渡すコードが増えるだけで、プランを 読むときにリソースアドレスが長くなり、何が変わるのかが見えなくなります。
出力は契約です。使用先がその出力に依存しているため、名前を変えると、すべての使用先が 壊れます。必要なものだけを出力し、名前は、中の実装ではなく外から見たときの意味で 付けます。
次のラボですること
fileboxという小さな子モジュールを、入力・実装・出力の3つのファイルで作り、同じソースをprimary・secondaryの2つの名前で呼び出します。モジュール内のリソースの状態アドレスがどう変わるかを自分で確認し、モジュールの中から再びモジュールを呼び出す、ネスト構造を作ります。最後に、変数の検証で誤った値をモジュールの境界で拒否し、3つのモジュールの出力を1つのマップにまとめます。