Terragruntを使い始めると、最初に悩むのがフォルダ構成です。公式ドキュメントにもいくつかのパターンが紹介されていますが、「結局どれがいいの?」となりがちです。
本記事では、個人開発で使っているTerragruntのフォルダ構成を紹介します。シンプルでありながら、環境の追加やモジュールの拡張にも対応しやすい構成になっています。
全体のフォルダ構成
.
├── app/ # アプリケーションコード
│ └── hello-world/
│ └── lambda_function.py
└── terragrunt/
├── environments/ # 環境ごとの設定
│ ├── common_vars.yaml # 全環境共通の変数
│ └── dev/
│ ├── env_vars.yaml # 環境固有の変数
│ ├── root.hcl # 環境のルート設定
│ └── lambda/
│ └── terragrunt.hcl
└── modules/ # Terraformモジュール
└── lambda/
├── main.tf
├── variables.tf
└── initial-code/
└── lambda_function.py
大きく分けて、app(アプリケーションコード)とterragrunt(インフラコード)の2つに分かれています。インフラコードの中はenvironmentsとmodulesで構成されます。
この構成の主な意図
アプリケーションコードとインフラコードの変更を分離することです。
Lambdaを例にすると、「Lambda関数を作る」のはインフラの仕事、「Lambda関数の中身を更新する」のはアプリケーションの仕事です。これらを同じデプロイフローで管理すると、ちょっとしたコード修正のたびにTerraformを実行することになり、面倒ですし事故のリスクも上がります。
この構成では、Terraformは「箱」だけを管理し、中身のコードはCI/CDなど別の仕組みでデプロイします。
この構成のポイント
| ポイント | 説明 |
|---|---|
| アプリとインフラの分離 |
app/とterragrunt/を分け、デプロイフローを独立させる |
| ignore_changes の活用 | インフラ更新時にLambdaコードが上書きされないようにする |
| 環境変数の階層化 |
common_vars.yamlで共通設定、env_vars.yamlで環境固有設定 |
| 環境とモジュールの分離 |
environmentsとmodulesを明確に分けることで責務が明確に |
| root.hcl による共通設定 | backend、provider、terraformバージョンを一箇所で管理 |
それぞれ詳しく見ていきます。
environments:環境ごとの設定
common_vars.yaml
region: ap-northeast-1
name: terragrunt-folder-structure
全環境で共通の変数を定義します。リージョンやプロジェクト名など、環境によらず同じ値を使うものはここに書きます。
env_vars.yaml
env: dev
force_destroy: true
環境固有の変数を定義します。ここがポイントで、環境ごとにきめ細やかな設定ができます。
たとえばforce_destroyのような設定を考えてみます。
| 環境 | force_destroy | 理由 |
|---|---|---|
| dev | true | 検証用なのでS3やECRを即座に削除できるようにする |
| stg | true | ステージングも同様 |
| prod | false | 本番はデータが入っていたら削除をブロックする |
このように、common_vars.yamlでプロジェクト全体の設定を、env_vars.yamlで環境ごとの設定を行うことで、設定の階層化が実現できます。開発環境では気軽にリソースを作り直せるけど、本番では慎重に、という運用が自然にできます。
root.hcl
環境のルート設定ファイルです。ここがTerragruntの設定の中心になります。
# ----------
# backend.tf の設定
# ----------
remote_state {
backend = "s3"
generate = {
path = "_backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "terragrunt-${local.env}-${local.name}-terraform-tfstate-s3-bucket"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "${local.region}"
encrypt = true
bucket_sse_algorithm = "AES256"
dynamodb_table = "terragrunt-${local.env}-${local.name}-terraform-tfstate-lock"
s3_bucket_tags = {
"Environments" = "${path_relative_to_include()}"
"ServiceName" = "${local.name}"
"CreatedByTerragrunt" = "true"
}
}
}
remote_stateブロックでS3バックエンドを設定しています。path_relative_to_include()を使うことで、各モジュールのtfstateファイルが自動的に異なるキーで保存されます。たとえばlambdaモジュールならlambda/terraform.tfstateというキーになります。
# ----------
# provider の設定
# ----------
generate "provider" {
path = "_provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.region}"
default_tags {
tags = {
Environments = "${local.env}"
ServiceName = "${local.name}"
ManagedByTerraform = true
}
}
}
EOF
}
generateブロックでproviderの設定を自動生成しています。default_tagsを設定しておくと、全リソースに自動でタグが付与されるので便利です。
# ----------
# terraform の設定
# ----------
generate "version" {
path = "_terraform.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_version = "~> 1.10.0"
required_providers {
aws = {
version = "~> 5.87.0"
source = "hashicorp/aws"
}
archive = {
version = "~> 2.0"
source = "hashicorp/archive"
}
}
}
EOF
}
TerraformとProviderのバージョンもroot.hclで一元管理します。環境内の全モジュールで同じバージョンを使うことが保証されます。
# ----------
# local 変数
# ----------
locals {
env_vars = yamldecode(file(find_in_parent_folders("env_vars.yaml")))
common_vars = yamldecode(file(find_in_parent_folders("common_vars.yaml")))
env = local.env_vars.env
region = local.common_vars.region
name = local.common_vars.name
}
ここがポイントです。find_in_parent_folders()でYAMLファイルを探し、yamldecode()でパースしています。これにより、YAMLで定義した変数をHCL内で使えるようになります。
terragrunt.hcl(各モジュール)
include "root" {
path = find_in_parent_folders("root.hcl")
}
# ----------
# main.tf の設定
# ----------
terraform {
source = "../../../modules/lambda"
}
# ----------
# variable の設定
# ----------
inputs = {
env = local.env
name = local.name
}
# ----------
# local 変数
# ----------
locals {
env_vars = yamldecode(file(find_in_parent_folders("env_vars.yaml")))
common_vars = yamldecode(file(find_in_parent_folders("common_vars.yaml")))
env = local.env_vars.env
name = local.common_vars.name
}
各モジュールのterragrunt.hclはシンプルです。includeでroot.hclを読み込み、terraform.sourceでモジュールのパスを指定し、inputsでモジュールに渡す変数を設定するだけです。
modules:Terraformモジュール
main.tf
# ----------
# Lambda Assume Role
# ----------
data "aws_iam_policy_document" "lambda_assume_policy" {
version = "2012-10-17"
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "lambda_assume_role" {
name = "${var.name}-lambda-assume-role"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_policy.json
}
resource "aws_iam_role_policy_attachment" "lambda_exec_policy" {
role = aws_iam_role.lambda_assume_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# ----------
# Lambda 関数の定義
# ----------
data "archive_file" "hello_world_lambda_zip" {
type = "zip"
source_dir = "${path.module}/initial_code"
output_path = "${path.module}/lambda_zip"
}
resource "aws_lambda_function" "hello_world_lambda" {
filename = data.archive_file.hello_world_lambda_zip.output_path
function_name = "${var.name}-hello-world"
role = aws_iam_role.lambda_assume_role.arn
handler = "lambda_function.lambda_handler"
runtime = "python3.13"
source_code_hash = data.archive_file.hello_world_lambda_zip.output_base64sha256
lifecycle {
ignore_changes = [
filename,
source_code_hash,
]
}
}
ここがポイントです。lifecycleブロックのignore_changesでfilenameとsource_code_hashを指定しています。これにより、初回デプロイ後はterraform applyを実行してもLambdaのコードは上書きされません。
インフラ側で変更したいのはIAMロールやランタイムバージョンなどであり、関数のコード自体はCI/CDで別途デプロイします。この設定がないと、terraform applyのたびにダミーコードで上書きされてしまいます。
variables.tf
variable "env" {
type = string
}
variable "name" {
type = string
}
モジュールが受け取る変数を定義します。terragrunt.hclのinputsで渡された値がここに入ります。
環境を追加するには
stg環境を追加したい場合は、以下のようにします。
terragrunt/environments/
├── common_vars.yaml
├── dev/
│ ├── env_vars.yaml # env: dev, force_destroy: true
│ ├── root.hcl
│ └── lambda/
│ └── terragrunt.hcl
└── stg/ # 追加
├── env_vars.yaml # env: stg, force_destroy: true
├── root.hcl # devからコピー(変更不要)
└── lambda/
└── terragrunt.hcl # devからコピー(変更不要)
devフォルダをコピーして、env_vars.yamlの値を変えるだけです。root.hclやterragrunt.hclは変更不要です。変数がYAMLから読み込まれるので、環境名が自動的に反映されます。
本番環境を追加する場合は、force_destroy: falseにするなど、環境に応じた設定をenv_vars.yamlで行います。
なぜこの構成なのか
アプリとインフラを分離する理由
これがこの構成の核心です。分離することで以下のメリットがあります。
- デプロイ頻度の違いに対応: アプリは頻繁に更新、インフラは比較的安定
- 権限の分離: アプリ開発者にインフラ変更権限を与えなくて済む
- 事故リスクの低減: コード修正でうっかりインフラを壊すことがない
- CI/CDの簡素化: アプリのパイプラインにTerraformを組み込む必要がない
ignore_changesを使うことで、Terraformは「箱」の管理に専念し、中身は触らないという役割分担が実現できます。
initial-codeを置く理由
Lambdaモジュールにinitial-codeフォルダがあるのは、初回デプロイ用のダミーコードです。Terraformでaws_lambda_functionを作成するには、何らかのコードが必要だからです。
初回デプロイ後はignore_changesによってこのダミーコードは無視され、実際のアプリケーションコード(app/hello-world/)がCI/CDでデプロイされます。
1. terraform apply → ダミーコードでLambda関数を作成
2. CI/CD → app/hello-world/ のコードをLambdaにデプロイ
3. terraform apply → Lambda関数のコードは触らない(ignore_changes)
この流れにより、インフラ変更とアプリ変更を完全に独立してデプロイできます。
YAMLで変数を管理する理由
HCLのlocalsで直接値を書くこともできますが、YAMLにする利点があります。
- 階層化が直感的: 共通設定と環境固有設定を別ファイルで管理
-
差分が明確: 環境間の違いが
env_vars.yamlを見るだけで分かる - 他のツールとの連携: CI/CDやスクリプトからも参照しやすい
特に、force_destroyのような「開発環境では有効、本番では無効」といった設定を管理するのに便利です。env_vars.yamlを見れば、その環境の特性がすぐに把握できます。
root.hclを環境ごとに置く理由
root.hclをリポジトリのルートに1つだけ置く構成もありますが、環境ごとに置くことで以下のメリットがあります。
- 環境ごとにTerraformバージョンを変えられる(段階的なアップグレードが可能)
- 環境ごとにProviderの設定を変えられる(本番だけ別リージョンなど)
- 環境のコピーが簡単(フォルダごとコピーするだけ)
まとめ
本記事では、個人開発で使っているTerragruntのフォルダ構成を紹介しました。
この構成の核心はアプリケーションコードとインフラコードの分離です。
- ignore_changes でコードを保護: インフラ更新時にLambdaコードが上書きされない
- initial-code でダミーデプロイ: 初回だけTerraformでデプロイ、以降はCI/CDに任せる
-
環境変数の階層化:
common_vars.yamlで共通設定、env_vars.yamlで環境ごとのきめ細やかな設定 - environments と modules の分離: 責務を明確にし、再利用性を高める
-
環境追加はフォルダコピー:
env_vars.yamlの値を変えるだけ
Terragruntのフォルダ構成に正解はありませんが、迷ったときの一つの選択肢として参考にしていただければ幸いです。
コードはGitHubで公開しています。