AWSやGCPのみならず、GitHubやDatadog等、様々なサービスをTerraformでコード化して管理できるようになり、世はまさに大Terraform時代となりました。
そんな時代の中、人類誰もが一度はTerraformのディレクトリ構造で悩んだことがあるのではないでしょうか?
様々なベストプラクティスが提案されてきましたが、デファクトスタンダードと言えるものが未だ登場していないイメージがあります。
背景
なかなかスタンダードな構成が決まらない背景には、以下の要因が影響していると思われます。
環境ごとの差異がどれだけあるかは人それぞれ
本番環境とステージング環境は完全に同一の構成であることが理想です。
同一の構成をとることで、ステージング環境では発生せず、本番環境でのみ発生する不具合を減らすことができるからです。
しかし、様々な理由でどうしてもステージング環境と本番環境の間に差異が発生してしまうケースがあります。
- コスト的にインスタンスタイプ、インスタンス数を小さくしなければならない
- 連携するシステムの都合でステージング環境と本番環境でネットワーク構成を変える必要がある
- 既に構築された本番環境とステージング環境で差分がある環境をterraform importする場合
こういった理由で発生する差異がどれだけあるか、今後どれだけ増えていくかによって、Terraformのディレクトリ構成を決定する際に考慮する必要があります。
特にterraformのリポジトリを用意するサービス開発の序盤では将来的な構成まで見通すことが難しいため、「ディレクトリ構成で失敗した」となりがちです。
宣言的コードにおいてDRY原則と可読性のどちらを優先するかは人それぞれ
Terraformはインフラストラクチャを宣言的に記述する仕様です。
(反対に、インフラストラクチャを手続き的に記述するのがCDKだったりします)
宣言的に記述する言語仕様において、DRY原則と可読性のどちらを優先すべきかという議論がしばしば発生します。(主に私の脳内で)
重複する部分をすべてmoduleを使って共通化することもできますが、過度にmoduleを使いすぎるとリソースの設定値が各ファイルに散らばってしまい、可読性が低くなってしまうケースもあります。
Terraformにおけるmoduleによる共通化は、一般的なプログラミング言語における関数等による共通化よりも実装コストが高い(詳細は後述)ため、あまり気軽に使えるものとは考えないのが無難かもしれません。
最初はキレイに共通部分を切り出す実装にできていたとしても、度重なるリソースの追加とリソース間の値の参照によりmodule間で参照しまくりな可読性の低いヤバイ実装を生み出してしまった体験は誰しもがあるのではないでしょうか?(私は何度もあります)
パターン集
この記事では、Terraormのディレクトリ構成に関して、いくつかの所謂デザインパターンのようなものを紹介します。
それぞれのパターンごとに向き不向きがあるので、自分の担当サービスや社内全体のサービスの傾向などから適切なパターンを選択し実装するのが良いでしょう。
前提条件
- 想定IaaS: AWS
- 本番環境とステージング環境を構築するためのコードを書く
- Terraform Cloud等でも利用できるように、DockerやMakefileによるラッパースクリプトは使用しない
1ディレクトリでworkspaceを使うパターン
TerraformのWorkspace機能を利用して、1ディレクトリに本番環境とステージング環境で利用するtfファイルを全て配置するパターンです。
例としてファイルは下記のようにフラットに並べます。
terraform-repo
├── alb.tf
└── s3.tf
本番環境、ステージング環境でそれぞれリソースが作成されるように terraform.workspace
変数を利用しリソースの名前の衝突などを回避します。
resource "aws_s3_bucket" "bucket" {
bucket = "${terraform.workspace}-foo-bucket"
}
上記コードを staging
というworkspaceで実行すれば、 staging-foo-bucket
というS3バケットが作成され、 production
というworkspaceで実行すれば、production-foo-bucket
というS3バケットを作ることができます。
インスタンスタイプやAutoScalingによる台数程度であれば、variableを利用して各環境で異なる値を設定するといったことも可能です。
メリット
- DRYに記述できる
- 本番環境、ステージング環境以外に環境を増やす際も楽
- (意図的にif文などを使わなければ)本番環境とステージング環境の構成を同一にできる
- この構成を理由に「ステージング環境だけにxxxのリソース作ってください」という依頼を一蹴できる
デメリット
- 本番環境だけに作成するリソース等がある場合は、countとif文を使う必要がある
- ステージング環境に自動applyし、動作確認後、本番環境に自動applyする運用の実現には工夫が必要
- masterブランチにマージ→自動applyだけでは上記を実現できない
- ブランチ運用を工夫するか、Terraform Cloudのconfirm機能を使う等が必要になる
環境ごとにディレクトリを切るパターン
本番環境、ステージング環境でそれぞれディレクトリを分け、tfファイルもそれぞれで用意するパターンです。
このパターンでは、下記のようにstaging環境にだけWAFのリソースを作成する、といったような対応を楽に行うことができます。
terraform-repo
├── production
│ ├── alb.tf
│ └── s3.tf
└── staging
├── alb.tf
├── s3.tf
└── waf.tf
また、本番環境のtfファイルは production
ディレクトリ配下に全て存在し、workspaceのような変数や後述のmodule等を利用しないため、各リソースの実装がよりシンプルで可読性が高いといった利点があります。
メリット
- 本番環境とステージング環境に構成的な差があっても柔軟に対応できる
- 可読性が極めて高い
- 実装難易度が低い
デメリット
- DRY原則を完全にあきらめている
- コピペですむケースが多いが、環境の数だけtfファイルを実装する必要がある
- 本番環境だけ実装漏れ等が発生するリストが増える
共通部分をmoduleに切り出すパターン
環境ごとにディレクトリを切るパターン
を少しでもDRYに書こうとすると行き着く1つのパターンです。
以下には、共通するリソースをcommonというmoduleに切り出した場合のディレクトリ構成を示します。
terraform-repo
├── common
│ ├── alb.tf
│ ├── output.tf
│ ├── s3.tf
│ └── variable.tf
├── production
│ └── main.tf
└── staging
├── main.tf
└── waf.tf
productionからcommonモジュールを呼び出すには下記のようにtfファイルを記述します。
# common moduleを呼び出す
module "common" {
source = "../common"
# moduleに渡す変数を列挙していく
# ここで渡すことで、commonモジュール内で var.bucket_prefix で参照できるようになる
bucket_prefix = "production"
}
moduleへ値を渡す、module内の値を参照する実装コストが高い点に注意が必要です。
# common/s3.tf
resource "aws_s3_bucket" "foo" {
bucket = "${var.bucket_prefix}-foo-bucket"
}
# common/output.tf
output "foo_bucket_name" {
value = aws_s3_bucket.foo.name
}
# production/bar.tf
# commonモジュール内のバケット名を参照したいなんらかのリソース
resource "aws_xxxx" "bar" {
# outputで定義した値しか参照できない
bucket = module.common.foo_bucket_name
}
moduleの境界を超えて値を参照するリソースが増えると、値を参照するための設定のコードを大量に書く羽目になってしまいます。
また、この値の参照は複数ファイル辿らないと実際の値にたどり着かない実装になりがちなので、多用すると可読性が大きく損なわれてしまいます。
メリット
- DRYに書きつつも、無理なく環境毎の差異も実装できる
デメリット
- moduleの境界をうまく考えて実装する必要がある
- 単純に、本番環境とステージング環境で同じ実装だからcommonモジュール内に入れてしまうと、属性の参照で辛い目を見るケースもある
- 変数参照が多いと可読性が低くなりがち
どのパターンを使うべきか?
簡単に分類すると下記のようになると思います。
-
質問1: 環境ごとに差異はある?
- Yes: 質問2へ
- No: 1ディレクトリでworkspaceを使うパターン
-
質問2: 環境固有のリソースは独立している?(共通リソースへの参照が少ない?)
- Yes: 質問3へ
- No: 環境ごとにディレクトリを切るパターン
-
質問3: moduleを使いこなす自身はある?
- Yes: 共通部分をmoduleに切り出すパターン
- No: 環境ごとにディレクトリを切るパターン
まとめ
個人的にできることなら1ディレクトリでworkspaceを使うパターンを採用するのが望ましいです。
ステージング環境はできる限り本番環境と同一の環境を再現することで、様々な問題をステージング環境で事前に検証することができるためです。
しかし、実際の開発の現場では、コストや既存の構成の都合で、どうしてもステージング環境と本番環境間に構成の差分が存在するケースがあります。
そんな場合は環境ごとにディレクトリを切るパターンが最も柔軟性に優れるパターンなので、とりあえずこれを採用するケースが多いです。
「こういう構成もいいよ!」とかあればコメントに記載していただけると喜んで参考にさせていただきます。