正解が未だに見つからないTerraformのディレクトリ構成を考えてみた

これは XTechグループ 2 Advent Calendar 2020 の9日目の記事です。

はじめに

弊チームでは Infrastructure as Code として Terraform を使っています。
一部、サーバレス部分ではSAMを使っていたり、そのままCloudFormationを使っていたり、例外もありますが、基本的にはTerraformを使っています。

Terraformにはmoduleやworkspaceという機能があり、自由度高くコードを管理できます。
さらには、必要なインフラ環境は、プロジェクトによって微妙に異なるのはもちろん、会社が変わればルールや制約も違って求めるものも大きく異なります。
そのような理由からか、Terraformのデファクトスタンダードなベストプラクティスなディレクトリ構成というものはあまり無いような気がします。
(もしあれば知りたいので教えていただけるとありがたいです)

チームでTerraformを使うにあたり、これまで様々なブログを拝見し参考にさせていただきました。

最終的に、これで行こうというディレクトリ構成を決定したので、それを紹介しようと思います。

長くなってしまって文章も微妙なので、先に結論として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 ディレクトリと構成に必要なある程度の大きさのまとまりのコンポーネントを管理するためのディレクトリ(networkdb など)を持ちます。

shared ディレクトリは provider.tf だけを持ち、各コンポーネントディレクトリ内からシンボリックリンクで参照されています。
これは、同じ環境であれば同じ設定であるべきという思想のもと、providerの設定には変数が使えないためにシンボリックリンクで対応しています。

networkdb などのコンポーネントディレクトリは、よくある 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/vpcelements/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を利用している皆様の手助けに少しでもなれば幸いです。