正解が未だに見つからないTerraformのディレクトリ構成を考えてみた
これは XTechグループ 2 Advent Calendar 2020 の9日目の記事です。
はじめに
弊チームでは Infrastructure as Code として Terraform を使っています。
一部、サーバレス部分ではSAMを使っていたり、そのままCloudFormationを使っていたり、例外もありますが、基本的にはTerraformを使っています。
Terraformにはmoduleやworkspaceという機能があり、自由度高くコードを管理できます。
さらには、必要なインフラ環境は、プロジェクトによって微妙に異なるのはもちろん、会社が変わればルールや制約も違って求めるものも大きく異なります。
そのような理由からか、Terraformのデファクトスタンダードなベストプラクティスなディレクトリ構成というものはあまり無いような気がします。
(もしあれば知りたいので教えていただけるとありがたいです)
チームでTerraformを使うにあたり、これまで様々なブログを拝見し参考にさせていただきました。
- Terraformにおけるディレクトリ構造のベストプラクティス | Developers.IO
- Terraformのベストなプラクティスってなんだろうか | フューチャー技術ブログ
- Sansan Labs 開発での Terraform ディレクトリ構成 - Sansan Builders Blog
- Terraformのディレクトリ構成の模索 - Adwaysエンジニアブログ
- Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい - エムスリーテックブログ
- Terraform導入への第一歩 - BASEプロダクトチームブログ
最終的に、これで行こうというディレクトリ構成を決定したので、それを紹介しようと思います。
長くなってしまって文章も微妙なので、先に結論として3行でまとめます。
- 環境差異はworkspaceではなくディレクトリの分離で解決している
projects
->usecases
->elements
という3層構造で、module機能を使って共通化できる部分は共通化している- 運用の方針として、既存の共通部分の修正の影響範囲が大きすぎて大変な場合は似たような共通処理を作ることを厭わないようにしている
採用しているディレクトリ構成
まず、大前提として、workspaceの機能は一切使っていません。
最初はworkspaceを使っていたのですが、基本的な構成は共通化しつつworkspaceを使って開発環境と本番環境を分離するというのが思いの外煩わしく、特に環境によって構築したい構成の差が大きくなってきたときにworkspaceだけでは管理しづらくなってしまいました。
そこで、基本方針としては、環境の差異はディレクトリを分離することで解消するようにしました。
実際に採用しているディレクトリ構成(命名等少し改変してます)は以下の通りです。
. ├── projects │ ├── project_A │ │ ├── dev │ │ │ ├── shared │ │ │ │ └── provider.tf │ │ │ ├── network │ │ │ │ ├── backend.tf │ │ │ │ ├── main.tf │ │ │ │ ├── outputs.tf │ │ │ │ ├── provider.tf -> ../shared/provider.tf │ │ │ │ └── variables.tf │ │ │ ├── application │ │ │ ├── bastion │ │ │ ├── db │ │ │ ︙ │ │ └── prod │ │ ├── shared │ │ ︙ │ ├── project_B │ │ ├── dev │ │ └── prod │ ︙ ├── usecases │ ├── network │ │ ├── main.tf │ │ ├── outputs.tf │ │ └── variables.tf │ ├── cdn │ ├── ec2 │ ├── rds │ ︙ └── elements ├── vpc │ ├── main.tf │ ├── outputs.tf │ └── variables.tf ├── rds ├── s3 ├── security_group ├── subnet ├── cloudfront ︙
大きく3つのディレクトリにわけています。
プロジェクトや開発環境・本番環境によって異なる設定を管理するための projects
、各プロジェクトで再利用可能な必要な構成を大きめのまとまり(コンポーネント)で管理する usecases
、各ユースケースで必要なリソースを集めた再利用可能な小さい粒度で管理する elements
という3つです。
projects
からは usecases
内のmoduleが呼ばれ、 usecases
のmoduleからは elements
内のmoduleが呼ばれます。
つまり、projects
-> usecases
-> elements
という3層構造になっています。
usecases
を用意している理由は、プロジェクトや環境によって基本的に似たような構成になり、再利用できる場面が多いからです。
例えば、ネットワークを構成したいとなると、あるCIDRのVPC、2AZにまたがるパブリック用サブネットワークとプライベート用サブネットワークなど、CIDR以外の設定は同じです。
これを usecases/network
というmoduleとして定義して、 projects
から呼び出すことで再利用できるようにします。
このような構成にしているところは多いと思います。
elements
を用意している理由は、複数の usecases
からも再利用できるようなより細かい粒度の定義があるからです。
例えば、 usecases/network
内だけでも、パブリックサブネットワークとプライベートサブネットワークを作る必要がありますが、中身はCIDRが異なるサブネットワークというだけですし、 usecases
としては異なるEC2やRDSにおいても、セキュリティグループの構築もルールが異なるだけで基本的には同じです。
これを elements/network
というmoduleとして定義して、 usecases
から呼び出すことで再利用できるようにします。
moduleが2段階になっているので煩雑になって追いにくくなりそうですが、今のところ意外とうまく回っています。
より詳細な説明は以下の通りです。
projects
projects
はプロジェクトや開発環境・本番環境によって異なる設定を管理するためのディレクトリです。
projects
配下にプロジェクト毎に専用のディレクトリがあり、さらにそのディレクトリ配下にはdevとprodというディレクトリがあります。
例えば、Aプロジェクトというものがあれば、その開発環境は projects/project_A/dev
で管理し、本番環境は projects/project_A/prod
で管理します。
開発環境と本番環境のディレクトリの中身の構成は基本的に同じで、共通項目を設定するための shared
ディレクトリと構成に必要なある程度の大きさのまとまりのコンポーネントを管理するためのディレクトリ(network
や db
など)を持ちます。
shared
ディレクトリは provider.tf
だけを持ち、各コンポーネントのディレクトリ内からシンボリックリンクで参照されています。
これは、同じ環境であれば同じ設定であるべきという思想のもと、providerの設定には変数が使えないためにシンボリックリンクで対応しています。
network
や db
などのコンポーネントのディレクトリは、よくある backend.tf
(backendの設定) main.tf
(リソースの構築) variables.tf
(リソース構築のための変数定義) outputs.tf
(リソース構築の結果)とシンボリックリンクである provider.tf
からなっています。
このあたりのファイル構成こそ本当に自由だと思うのですが、一旦この構成でやっています。ちなみに、backendはs3を使っています。
そのコンポーネントを構成するリソースの構築を担っているのが main.tf
ですが、これは基本的にmodule呼び出しをしているのみで、moduleの実装は usecases
ディレクトリに寄せています。
例えば projects/project_A/dev/network
内のファイルは以下のようになっています。(サンプル)
main.tf
module "network" { source = "../../../usecases/network" env = local.env network = local.network }
variables.tf
locals { network = { az_list = ["ap-northeast-1a", "ap-northeast-1c"] vpc_cidr = "10.0.0.0/16" private_subnets = ["10.0.0.0/24", "10.0.1.0/24"] public_subnets = ["10.0.2.0/24", "10.0.3.0/24"] } }
outputs.tf
output "network" { value = module.network.network }
この通り、 main.tf
内ではmoduleの機能を使って usecases/network
を呼んでいるだけです。
こうすることで、同じ構成の場合は同じユースケースを呼び、変数のみをそれぞれのプロジェクトに合わせるだけで済みます。
usecases
usecases
は各プロジェクトで必要な構成を再利用可能なコンポーネントとして管理するディレクトリです。
先述の通り、 projects
配下からmodule機能として呼ばれます。
usecases
配下には、コンポーネントとして network
rds
などのディレクトリが含まれます。
そして、各コンポーネントのディレクトリは、よくある main.tf
variables.tf
outputs.tf
を持ちます。
例えば usecaces/network
内のファイルは以下のようになっています。(サンプル)
main.tf
# VPC module "vpc" { source = "../../elements/vpc" env = var.env cidr = var.network.vpc_cidr } # private subnet module "private_subnet" { source = "../../elements/subnet" env = var.env az_list = var.network.az_list subnet_cidr_list = var.network.private_subnets vpc_id = module.vpc.vpc_id modifier = "private" } # public subnet module "public_subnet" { source = "../../elements/subnet" env = var.env az_list = var.network.az_list subnet_cidr_list = var.network.public_subnets vpc_id = module.vpc.vpc_id modifier = "public" }
variables.tf
variable "env" { type = string } variable "network" { type = object({ az_list = list(string) vpc_cidr = string private_subnets = list(string) public_subnets = list(string) }) }
outputs.tf
output "network" { value = { vpc_id = module.vpc.vpc_id private_subnets = module.private_subnet.subnet.* public_subnets = module.public_subnet.subnet.* } }
main.tf
内ではmoduleの機能を使って elements/vpc
や elements/subnet
を呼んでいるだけです。
本当はインターネットゲートウェイやNATゲートウェイやルートテーブルの構築とアタッチも行っていますが、省略しています。
elements
を定義することで、同じサブネットワークの作成でもプライベートサブネットとパブリックサブネットで再利用することができます。
elements
elements
は各ユースケースで必要なリソースを集めた再利用可能な小さい粒度で管理するディレクトリです。
先述の通り、 usecases
配下からmodule機能として呼ばれます。
elements
配下には、コンポーネントとして vpc
subnet
などのディレクトリが含まれます。
そして、各コンポーネントのディレクトリは、よくある main.tf
variables.tf
outputs.tf
を持ちます。
例えば elements/subnet
内のファイルは以下のようになっています。(サンプル)
main.tf
resource "aws_subnet" "subnet" { count = length(var.subnet_cidr_list) vpc_id = var.vpc_id cidr_block = var.subnet_cidr_list[count.index] availability_zone = var.az_list[count.index] tags = { Name = "${var.env}-${var.modifier}-subnet-${var.az_list[count.index]}" Env = var.env } }
variables.tf
variable "env" { type = string } variable "modifier" { type = string } variable "az_list" { type = list(string) } variable "vpc_id" { type = string } variable "subnet_cidr_list" { type = list(string) }
outputs.tf
output "subnet" { value = aws_subnet.subnet.* }
main.tf
内でやっとresourceを呼んでいます。
このように、 projects
-> usecases
-> elements
という3層構造になっており、粒度に合わせて再利用性を意識した作りになっています。
メリットやデメリット
一番のメリットは再利用性です。
弊チームの扱うサービス(プロジェクト)はたくさんあるのですが、基本的にすべて同じような構成になっています。
その際に毎回同じようなtfファイルを構築するのではなく、既存の構成を踏襲できるようにmodule機能を使って再利用できる方が明らかに効率的です。
これだけだと、 usecases
だけを用意すれば良いのではないかという話になりますが、 usecases
内でもサブネットワークの作成やセキュリティグループの作成など再利用できる場面が多くあり、同様に共通化によって効率化できるというメリットを享受するために elements
を用意しています。
デメリットは、共通化されたユースケースを使いたいけれど微妙にニーズが合わずに修正したいと思ったとしても影響範囲がとても広くなってしまうために修正がなかなか難しくなってしまうことです。
ある程度は汎用性があるように共通化するようにしていますが、どうしてもこの設定が気に食わないという場面が出てきてしまいます。
その場合、共通化部分を修正してしまうと、それを利用している他のプロジェクトにも影響が出てしまいます。
実は、これはデメリットではあるのですが、そこまで大きな影響はありません。
その理由の1つに、意外と汎用的な共通化がうまくいっていて、そのまま利用できる場面が多数あるということです。
さらに、似ているユースケースが既にあったとしても少し異なる要件で構築したいなら、無理に似ているユースケースを修正するのではなく、ほぼ同じかもしれない新しいユースケースを用意することを厭わないということにしています。
何らかの方法で既存のユースケースを修正して共通化したくなりますが、無理せず新しいものを作ることで異なるプロジェクトへの影響を無くすことができます。
おわりに
インフラ構築は場合によってはサービス立ち上げ初期でしか触れず、以降はブラックボックス化する懸念が多分にあるので、Infrastructure as Code はしっかりやっていくべきだと思います。
が、やはりインフラは裏方でありアプリケーション側に専念したいという思いもあるので、効率的に管理していきたいです。
今回紹介したディレクトリ構成はメリットもデメリットもありますし、完璧な状態ではありません。
まだ試行錯誤の段階ではあるので今後もより良い構成の追求は続けていきます。
例えば、これを考えていた当初は Terragrunt について知らなかったので、採用することを検討しても良いかもしれません。
Terraformを利用している皆様の手助けに少しでもなれば幸いです。