最近、AWSをシングルアカウントからマルチアカウントへ移行することになり、我流で書いてきたTerraformをリポジトリごと再設計しました。
HashiCorpやAWSが公式でTerraform用のMCPを出しているので、それに頼る前提ではあったのですが、使えそうなTipsを自分の中にためておこうと思い、実装前に以下の書籍を読みました。
大体知っているだろうなと思ってパラパラめくっていたら(Kindleだけど)、本当に知らないことばかりでした... ただの勉強不足と言われたらそれはそうなんですが、本当に買ってよかったのでTerraformをなんとなく使っている方におすすめです。
今回は、書籍から実際に取り入れてよかったTipsを紹介します。初心者向けです。
terraform-docsでモジュールREADMEを自動生成
以前はインフラを一人でやっていたので、新しいメンバーへの情報共有などはあまり考えていなかったのですが、属人化はやっぱりよくないので、モジュールのInputs/Outputs/Resourcesをterraform-docsで自動ドキュメント化しました。
.terraform-docs.ymlでフォーマットを定義して、スクリプトから各モジュールのドキュメントを生成するようにしました。
formatter: "markdown table"
sections:
show:
- header
- requirements
- providers
- modules
- resources
- inputs
- outputs
content: |-
{{ .Header }}
## Requirements
{{ .Requirements }}
## Providers
{{ .Providers }}
## Modules
{{ .Modules }}
## Resources
{{ .Resources }}
## Inputs
{{ .Inputs }}
## Outputs
{{ .Outputs }}
output:
file: "README.md"
mode: inject
template: |-
<!-- BEGIN_TF_DOCS -->
{{ .Content }}
<!-- END_TF_DOCS -->
sort:
enabled: true
by: name
settings:
hide-empty: true
required: true
sensitive: true
type: true
anchor: true
#!/usr/bin/env bash
set -euo pipefail
# terraform-docs の存在確認
if ! command -v terraform-docs >/dev/null 2>&1; then
echo "ERROR: terraform-docs が見つかりません。インストールしてください。" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
CONFIG="${PROJECT_ROOT}/.terraform-docs.yml"
echo "terraform-docs: プロジェクトルート: ${PROJECT_ROOT}"
echo ""
cd "${PROJECT_ROOT}/modules"
SUCCESS=0
FAILED=0
FAILED_MODULES=()
for module_dir in */ ; do
[ -d "$module_dir" ] || continue
module_name="${module_dir%/}"
echo "📝 ドキュメント生成中: ${module_name}"
pushd "$module_dir" >/dev/null
# README の雛形
if [ ! -f "README.md" ]; then
cat > README.md <<'EOF'
# Module
<!-- BEGIN_TF_DOCS -->
<!-- END_TF_DOCS -->
EOF
fi
if terraform-docs --config "$CONFIG" . >/dev/null; then
echo "✅ 成功: ${module_name}"
((SUCCESS++))
else
echo "❌ 失敗: ${module_name}"
FAILED_MODULES+=("${module_name}")
((FAILED++))
fi
popd >/dev/null
echo ""
done
echo "========================================="
echo "terraform-docs 実行結果"
echo "✅ 成功: ${SUCCESS} モジュール"
echo "❌ 失敗: ${FAILED} モジュール"
if [ ${FAILED} -gt 0 ]; then
echo "失敗したモジュール:"
printf ' - %s\n' "${FAILED_MODULES[@]}"
fi
echo "完了しました!"
モジュールのmain.tf, variables.tf, outputs.tfを元に以下のようなドキュメントが作成されます。
CIに組み込むことで、モジュールの改修時に自動でドキュメントを更新しています。GitHub Actionsでpre-commitやPRのチェックに組み込めば、ドキュメントの更新漏れを防げます。
S3バックエンドをuse_lockfileでロック
versions.tfでStateファイルの保管先となるS3バックエンド設定を行っていますが、複数作業者によるterraform plan/applyでStateの不整合が起こらないように、S3のみでロック管理を行うオプションuse_lockfile = trueを追加しました。
こちらはTerraform 1.11からの機能で、それ以前のバージョンではDynamoDBテーブルを別途用意してロック管理する必要がありました。
terraform {
required_version = "~> 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.11.0"
}
}
backend "s3" {
bucket = "terraform-state"
key = "environments/dev/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true
}
}
Stateファイル保管先S3バケットのバージョニング
Stateファイルを誤って削除してしまったときに復元できるよう、S3バケットのバージョニングを有効化しました。
移行前の環境ではこれを普通にやり忘れていました。今思うとゾッとします。
cidrsubnet関数
VPCのCIDRからサブネットのCIDRを割り当てる際、頭で計算してハードコードしていたのですが、cidrsubnet関数を使うと簡単にサブネットを切り出せることがわかりました。地味に便利なので採用しました。
# 例: 192.168.0.0/20 を /24 で連番サブネット化
locals {
vpc_cidr = "192.168.0.0/20"
}
resource "aws_subnet" "a" {
cidr_block = cidrsubnet(local.vpc_cidr, 4, 0)
# 4は/20→/24へ4ビット拡張、0がインデックス、結果は192.168.0.0/24
}
resource "aws_subnet" "b" {
cidr_block = cidrsubnet(local.vpc_cidr, 4, 1)
# 192.168.1.0/24
}
default_tagsをプロバイダに集約してタグ漏れ撲滅
以前は、共通のタグ(Project, Environment)をすべてのリソースに書いていたのですが、書き漏れがあって困っていたので、デフォルトタグ機能を使うことにしました。
また、ManagedBy = "terraform"というタグもデフォルトで導入することで、AWSコンソール上でTerraform管理のリソースかどうかを一目で判別できるようにしました。
provider "aws" {
region = "ap-northeast-1"
default_tags {
tags = {
Project = "MyApp"
Environment = "prod"
ManagedBy = "terraform"
}
}
}
削除されたら困るリソース(DB系, KMS等)へのprevent_destroy
以前の環境で、planの差分をよくみていなかった結果、applyでElastiCache(Valkey)クラスターが削除されてしまうという事故を起こしてしまいました(涙)
このような削除されたら特に困るリソースには、lifecycleでprevent_destroy = trueを設定して、誤ったapply操作で削除されないようにしました。
resource "aws_rds_cluster" "aurora_cluster" {
...
lifecycle {
prevent_destroy = true
}
}
また、Blue/Greenデプロイでターゲットグループが都度切り替わることで、ALBのリスナールールで差分が出てしまう場合には、ignore_changesというlifecycleルールで初回apply以降の差分検知を無視できます。
resource "aws_lb_listener_rule" "rules" {
for_each = var.listener_rules
listener_arn = aws_lb_listener.https[tostring(each.value.listener_port)].arn
priority = each.value.priority
action {
type = each.value.action_type
target_group_arn = aws_lb_target_group.tg[each.value.target_group].arn
}
condition {
host_header {
values = [each.value.host]
}
}
dynamic "condition" {
for_each = each.value.paths != null ? [each.value.paths] : []
content {
path_pattern {
values = condition.value
}
}
}
lifecycle {
ignore_changes = [action[0].target_group_arn]
}
}
importブロック
コード化できていなかったリソース(一部S3, AppConfig, Amplify等)については、参考実装がなかったので、コンソールで手動作成した後にimportする手順で実装しました。
Terraform 1.5.0から導入されたimportブロックを使うと、従来のCLIコマンド(terraform import)よりも宣言的に書けて便利です。
resource "aws_s3_bucket" "example" {
bucket = "my-bucket"
}
import {
to = aws_s3_bucket.example
id = "my-bucket"
}
これをterraform planすると、既存リソースとTerraformコードの差分が表示されます。差分をコードに反映してからterraform applyすれば、リソースがStateに取り込まれます。
設定値をゼロから書くのが大変な場合は、terraform plan -generate-config-out=generated.tfを使えば、既存リソースの設定を自動生成してくれます。
terraform state mvでリソースを再作成せずにアドレス付け替え
Terraform上のリソース名を間違えて"exampl"としてしまい、後から変更を加えてplanをしたらリソース更新ではなく、destroyとcreateが走ってしまいます。そんなときにterraform state mvを使うと、destroyなしでリソース名を変更できます。
terraform state mv aws_s3_bucket.exampl aws_s3_bucket.example
ただ、Stateファイルを直接操作するコマンドなので、実行前のバックアップが必須です(S3のバージョニングができていればOK)。
環境ファイルの分割粒度
リポジトリ再設計で一番試行錯誤したのが、environments/{env}/配下のファイル分割でした。
最初は全部main.tfに書いていたのですが、リソースが増えるたびにファイルが肥大化して、当然のようにスクロール地獄になりました。
そこで、機能別にファイルを分けることにしました。
applications.tf # Cognito、Amplify、SES
compute.tf # ECS、ECR、ALB、Auto Scaling
db.tf # RDS、ElastiCache
monitoring.tf # CloudWatch Logs、SNS、Chatbot
networking.tf # VPC、Subnet、Route Table
security.tf # IAM、Security Group、KMS
s3.tf
lambda.tf
locals.tf
providers.tf
versions.tf
...
分割するときに意識したのは、「このリソースを変更するとき、どのファイルを開きたいか?」という視点です。
例えば、ALBのリスナールールを追加するときは、ECSサービスやターゲットグループも一緒に確認したいので、compute.tfにまとめました。
module "ecs" { ... }
module "ecr" { ... }
module "alb" { ... }
module "ecs_autoscaling_visitor" { ... }
module "ecs_autoscaling_company" { ... }
逆に、ネットワーク周りは、VPC、サブネット、ルートテーブルをセットで見ることが多いので、networking.tfにまとめています。
module "vpc" { ... }
module "subnet" { ... }
module "route_table" { ... }
module "internet_gateway" { ... }
module "nat_gateway" { ... }
module "vpc_endpoint" { ... }
module "network_associations" { ... }
一方で、security.tfは1000行を超えてしまいました...
IAM ロール、IAMポリシー、セキュリティグループ、KMSを全部まとめたら、こうなりました。
後から見ると、こう分けるべきだったかもしれません(これからリファクタします)。
-
iam.tf: IAM ロール・ポリシー -
security_groups.tf: セキュリティグループ -
kms.tf: KMS キー
逆に、これは良かったと思うのが、ElastiCacheとAuroraをまとめたdb.tfです。
DB関連の変更は、キャッシュとAuroraを同時に見ることが多いので、この粒度がちょうど良かったです。
あと、applications.tfも気に入っています。
module "cognito" { ... }
module "amplify" { ... }
module "ses" { ... }
認証、フロントエンド、メール送信という「アプリケーション層のサービス」をまとめたファイルで、ユーザー向け機能を追加するときは、だいたいこのファイルを開きます。
結局、分割粒度には正解はないと思います。
チームの開発スタイルやプロジェクトの規模に合わせて調整するのが一番です。うちの場合は、「関連性の高いリソースは同じファイルに、変更頻度が異なるリソースは別ファイルに」という基準で落ち着きました。
さいごに
参考書籍にはこれら以外にも役立つ内容がたくさん書いてあります。Terraformをなんとなく使っている方にあらためておすすめです。
参考

