はじめに
TerraformでAWS環境を構築した際に試行錯誤したディレクトリ構成について書こうと思います。
最後にお役立ち情報的なものも載せているので、何か一つでも参考になる点があれば幸いです。
Githubに今回の構成の簡単なサンプルプロジェクトをあげているので、こちらも何かの参考になればと思います。
TerraformやTerragruntの概要や詳しい使い方については割愛させていただきます。他の方の記事や公式ドキュメントを参考にして下さい。
前提
- 中規模のシステムを想定
数個のサービスしか使わない小規模システムや、マイクロサービス構成のような大規模システムには合わないかと思います。 - 二つ以上の環境(dev, staging, prodなど)を作成
ディレクトリ構成
ディレクトリ構成は以下の通りです。
.
├── envs
│ ├── dev
│ │ ├── app
│ │ │ ├── 〇〇.tf
│ │ │ └── terragrunt.hcl
│ │ ├── cicd
│ │ ├── log
│ │ ├── network
│ │ ├── operation
│ │ ├── routing
│ │ ├── security
│ │ ├── storage
│ │ └── terragrunt.hcl
│ └── prod
├── modules
│ ├── acm
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── ...
└── shared
├── provider.tf
├── variables.tf
└── version.tf
envsディレクトリ
envs配下に環境ごとのディレクトリ、さらに環境の配下にカテゴリに分けてtfファイルを格納しています。
カテゴリのディレクトリは自由に作成できますが、自分は以下のように作っています。
- app
アプリケーション関連サービス
(ECS, ECR, Lambda, Cognito, SES, S3) - cicd
Github Actions用のIDプロバイダーやIAMロール、ecspressoから参照するリソースなど - log
ログ関連サービス
(S3, CloudWatch Logs, Kinesis Firehose, Athena) - network
ネットワーク関連サービス
(VPC, Internet Gateway, NAT Gateway) - operation
運用系サービス
(SNS, Chatbot, CloudWatch Alerm, Config, Cloudtrail, Budgets) - routing
ルーティング関連サービス
(ALB, CloudFront, ACM, Route53) - security
セキュリティ関連サービス
(Security Hub, GuardDuty, KMS) - storage
ストレージ関連サービス
(RDS, S3)
S3のように一つのサービスが複数のカテゴリに含まれていることがありますが、ログ保管用やフロントエンドアプリケーション用、ファイルストレージ用など用途ごとに分けているようなイメージです。
カテゴリの分け方や粒度はシステムの要件や規模で変えてもいいと思います。
modulesディレクトリ
以下のような方針でモジュールを作成しています。
- AWSサービスごとの単位で作成する
- variables.tf, main.tf, outputs.tfファイルを必ず配置する(必要に応じてREADME.md)
- 単一リソースのモジュールは作らない
無駄にモジュールを作りすぎるのを防ぐため。
例外として、複数リージョンにデプロイしたいサービスに関してはその限りではありません。
例えばConfigやGuardDutyなどは全リージョンで有効化することが推奨されているため、以下のようにモジュールの呼び出し側でリージョンを選択するように実装しています。
provider "aws" {
alias = "us-east-2"
region = "us-east-2"
default_tags {
tags = local.tags
}
}
module "guardduty-us-east-2" {
source = "../../../modules/guardduty"
providers = {
aws = aws.us-east-2
}
}
sharedディレクトリ
このディレクトリに格納されているtfファイルを、各カテゴリのディレクトリに対してコピーして使用します。
環境全体で共通して使用する変数やプロバイダーの設定などを記述します。
この構成のメリット・デメリット
メリット
- planやapplyの時間の短縮
変更した部分のみplanやapplyをすることで、リソースが多くなってきても処理時間が増えにくくなります。 - 規模が大きくなってもどこにリソースが存在するか把握しやすい
カテゴリ分けを適切に行うことで、どのディレクトリに何のサービスが記述されているかが把握しやすくなります。 - ライフサイクルが違うリソースを分けることができる
頻繁に変更が入りやすいアプリケーションなどのリソースと、ほとんど変更されないDBやネットワークなどのリソースを分けることで、意図しない変更が加わるのを防ぐことができます。
デメリット
-
sharedディレクトリのファイルを各ディレクトリに配置するのが面倒
おそらくシンボリックリンクを使うのが一般的だと思いますが、ディレクトリを細かく分けるとシンボリックリンクを作るのもかなり面倒になってきます。 -
依存関係の管理の負担
他のtfstate内の値を読み取る必要がある場合、選択肢としてはハードコーディングするか、terraform_remote_stateを使用することになると思います。
ハードコーディングが大変なのは勿論、terraform_remote_stateを使用する場合でもS3バケットの指定などが必要なためかなり大変になると思います。
また、うまく管理しないとどのモジュールがどのモジュールに依存しているかの把握が難しくなります。 -
ディレクトリごとにplan, applyするのが面倒
変更するたびに依存関係を考慮しながら順番にapplyしていくのはかなり骨が折れると思います。 -
モジュールごとにbackendの設定を記述するのが手間
ディレクトリごとにtfstateを格納するS3バケットやtfstateファイルのキーを指定する手間がかかります。
Terragruntの導入によるデメリットの解消
Terragruntを導入し、上で挙げたデメリットを解消する方法を説明していきます。
- sharedディレクトリのファイルを各ディレクトリに配置するのが面倒
Terragruntのgenerateブロックを使用することで、指定したファイルを各ディレクトリに自動で生成することができます。
以下のように書くと、_provider.tfという名前でsharedディレクトリ内のprovider.tfファイルがコピーされます。
generate "provider" {
path = "_provider.tf"
if_exists = "overwrite_terragrunt"
contents = file("../../shared/provider.tf")
}
- 依存関係の管理の負担
dependencyブロックを使用することで、モジュールの実行順序の制御や、モジュール間の値の受け渡しを管理することができるようになります。S3バケットの指定などは必要なく、依存するモジュールのパスを指定するだけなので比較的楽だと思います。
また、mock_outputs_merge_strategy_with_stateにshallowを設定することで、実際に依存先のアウトプットがある場合はそちらを参照し、apply前でまだアウトプットがない場合はmock_outputsの値を参照するようになります。
terragrunt graph-dependenciesコマンドを実行することでモジュールの依存関係を確認することもできます。
# 依存先のlogモジュールからログ保管用S3バケットのIDを取得
dependency "log" {
config_path = "../log"
mock_outputs_merge_strategy_with_state = "shallow"
mock_outputs = {
log_s3_bucket_id = "mock-s3-bucket"
}
}
inputs = {
log_s3_bucket_id = dependency.log.outputs.log_s3_bucket_id
}
$ terragrunt graph-dependencies
digraph {
"app" ;
"app" -> "log";
"app" -> "routing";
"app" -> "security";
"cicd" ;
"cicd" -> "app";
"cicd" -> "log";
"cicd" -> "network";
"cicd" -> "routing";
"cicd" -> "storage";
"log" ;
"log" -> "security";
"network" ;
"network" -> "log";
"operation" ;
"operation" -> "app";
"operation" -> "log";
"operation" -> "routing";
"operation" -> "security";
"operation" -> "storage";
"routing" ;
"routing" -> "log";
"routing" -> "network";
"security" ;
"storage" ;
"storage" -> "network";
"storage" -> "security";
}
-
ディレクトリごとにplan, applyするのが面倒
run-allコマンドを使用することで、複数モジュールの依存関係を自動で解決した上でplanやapplyコマンドを一回のコマンドで実行することができます。 -
モジュールごとにbackendの設定を記述するのが手間
今回の構成では、各環境のディレクトリに配置しているterragrunt.hclにtfstateを格納するS3バケットやロック用のDynamoDBなどの設定を書いて、その配下のディレクトリではそれをincludeブロックで読み込むように設定することで設定値を複数回書く手間を省いています。
remote_state {
backend = "s3"
generate = {
path = "_backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "${get_env("SYSTEM")}-${get_env("ENVIRONMENT")}-terraform-state-${get_env("AWS_ACCOUNT_ID")}-s3-bucket"
key = "${path_relative_to_include()}/terraform.tfstate"
region = get_env("AWS_REGION")
encrypt = true
bucket_sse_algorithm = "AES256"
dynamodb_table = "${get_env("SYSTEM")}-${get_env("ENVIRONMENT")}-terraform-lock-dynamodb-table"
s3_bucket_tags = {
"Terraform" = "true"
"Environment" = get_env("ENVIRONMENT")
"System" = get_env("SYSTEM")
}
dynamodb_table_tags = {
"Terraform" = "true"
"Environment" = get_env("ENVIRONMENT")
"System" = get_env("SYSTEM")
}
}
}
include "root" {
path = find_in_parent_folders()
}
まとめ
実践的なディレクトリ構成ということで、かなり細かくモジュールを分割した構成の紹介と、そこで生じるデメリットをTerragruntで解消する方法を紹介させていただきました。
Terragruntを導入するメリットを少しでも感じていただけたら幸いです。
おまけ
お役立ちtips的なものを雑に挙げていこうと思います。
direnvのすすめ
今回の構成ではディレクトリごとに環境が変わるので、環境変数をいちいち設定し直す手間が発生します。
そこでdirenvを使うと、ディレクトリごとに環境変数を定義して、対象のディレクトリに移動したら自動で環境変数を読み込んでくれるようになります。手間の削減やミスの防止などのために導入をおすすめします。
aquaのすすめ
皆さんはTerraformやTerragruntのバージョン管理をどうしているでしょうか?
tfenv, tgenvなどのツールを使ったり、Dockerを使うなどの選択肢があるかと思います。
Terraformを使っていると他にもtfsecやtflint、上記で紹介したdirenvなど欲しくなってくるCLIツールが出てくると思いますが、これらも一元管理ができると便利になると思います。
そこでおすすめなのがaquaです。
aquaを使用することで上記で書いたツールのバージョン管理を簡単に行うことができます。
またGithub ActionsやRenovateにも対応しているので、ツールのバージョン管理の課題をこれ一つでかなり解決してくれます。
以下が私のaquaの設定ファイルです。
---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
# checksum:
# # https://aquaproj.github.io/docs/reference/checksum/
# enabled: true
# require_checksum: true
# supported_envs:
# - all
registries:
- type: standard
ref: v4.23.0 # renovate: depName=aquaproj/aqua-registry
packages:
- name: aquasecurity/tfsec@v1.28.1
- name: hashicorp/terraform@v1.5.2
- name: gruntwork-io/terragrunt@v0.48.0
- name: terraform-linters/tflint@v0.47.0
- name: reviewdog/reviewdog@v0.14.2
- name: suzuki-shunsuke/tfcmt@v4.4.2
- name: direnv/direnv@v2.32.3
VSCodeの拡張機能
以下の拡張機能を使用しています。
-
HashiCorp.terraform
Terraformの公式拡張機能。コード補完機能や自動フォーマット機能などがあります。 -
hashicorp.hcl
こちらもHashiCorpが出している拡張機能です。hclファイルにシンタックスハイライトがつきます。 -
tfsec.tfsec
tfsecでのスキャンを実行することができます。コマンドでやるよりもチェック結果や修正方法が確認しやすいです。 -
fredwangwang.vscode-hcl-format
hclファイルの自動フォーマットができるようになります。
{
"recommendations": [
"HashiCorp.terraform",
"hashicorp.hcl",
"tfsec.tfsec",
"fredwangwang.vscode-hcl-format"
]
}
VSCodeの設定
files.excludeにexplorerに表示させる必要のない自動生成されたファイルを設定することで、サイドバーの見た目をスッキリさせることができます。
他はファイル保存時の自動フォーマットの設定です。
{
"files.exclude": {
"**/_*.tf": true,
"**/.terraform": true
},
"[terraform]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
"[terraform-vars]": {
"editor.defaultFormatter": "hashicorp.terraform",
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file"
},
}
コマンドエイリアスの登録
terraformやterragruntのコマンドをいちいち打つのは面倒なので、.zshrcに以下のようにエイリアスを登録しています。
alias tf="terraform"
alias tg="terragrunt"
確認をスキップする方法
TerraformでもTerragruntでもapply実行時などに確認を求められますが、勉強中などには邪魔になると思います。
それぞれ以下のオプションで確認をスキップすることができます。
$ terragrunt run-all apply --terragrunt-non-interactive
$ terraform apply -auto-approve
AWSのアカウントIDとリージョンの取得方法
意外とよく使うので覚えておくといいかもしれません。
# アカウントID
data "aws_caller_identity" "current" {}
data.aws_caller_identity.current.account_id
# リージョン
data "aws_region" "current" {}
data.aws_region.current.name
initの-migrate-state、-reconfigure、-upgradeについて
私がTerraformを使い始めた頃はこの3つのオプションの意味が分からず、initを実行したときにエラー文の中に出てきて、言われるがまま実行するような感じでした。
この3つのオプションの意味とどのタイミングでつけるべきかについてまとめました。
- -migrate-state
- backendの設定が変更された時に実行する
- -reconfigureとどちらか一方を選択する
- 既存のstateの内容を可能な限り新しいbackendにコピーする
- -reconfigure
- backendの設定が変更された時に実行する
- -migrate-stateとどちらか一方を選択する
- 既存の設定を無視し、既存のstateの移行は行わない
- -upgrade
- インストール済みのすべてのmoduleを最新のソースコードに更新したい場合
- pluginのバージョンを変更した場合
- 新しいバージョンのpluginがインストールされ、dependency lock fileが更新される
詳しくは以下のドキュメントをご参照ください。
参考