初めに
Terraformを触り始めると「tfstateどこに置くのが正解なんだ…?」と毎回迷いませんか?自分も迷ったので、同じ悩みを踏んだ人の時間を節約すべくまとめました。
この記事の前提とゴール
前提:
- Terraformの公式チュートリアルを終えている
- AWSでALB/S3/EC2/RDSくらいは単体でデプロイできる
ゴール
- tfstateをS3+DynamoDBで安全に分割管理し、レイヤー間の依存をremote stateで解決する手順を把握する。
チーム開発とレイヤー分割で出てくる依存と課題(なぜ解決が必要か)
レイヤーは OSI っぽく分けています。ざっくり図示するとこんな感じ。
※今度別記事を書くのでそちらで詳しく解説しようと思います。デプロイを3(回)レイヤに分けていると思ってください。
account ── IAM/ECRなど共通リソース(stateをS3へ)
│
network ── VPC/Subnet/SG/TG(stateをS3へ)
│
application ── ALB/EC2/RDS(networkのstateをremote_stateで参照)
レイヤーを分けるときの問題(依存の扱い)
- アプリ層がネットワークの VPC/Subnet/SG/TG を必要とするのに、network層のstateをパス参照する他無い
- 下位レイヤの state をパスで直参照すると、ディレクトリや命名を変えた瞬間に壊れがち。
- 別端末や別環境で apply/destroy したときに差分が正しく算出できない。
tfstateの扱いで出てくる問題
-
terraform applyでリソース変更すると tfstate も更新される(=常に最新を共有する必要がある)。 - チーム開発なら tfstate はクラウド上で共有しないと属人化する。
- tfstate には機密情報が含まれるので、置き場所とアクセス権の管理に注意が必要。
→ 解決策は「stateをクラウド上で管理し、必要な依存はremote stateで参照する」こと。
tfstateをどこに置くかの選択肢と採用理由
私が思いつく限りは下記のいずれかのパターンになるのかなと思いました。
- ローカル/リポジトリ: 共有しづらい・気密性の高い情報はリポジトリに保存したくないので今回は不採用。
- Secrets Manager / SSM Parameter: シークレット管理は得意だが「ファイル丸ごと」の取り回しはS3に軍配があがる。
- S3 + DynamoDBロック: ファイル扱いのしやすさ、AWS内で完結、コストと運用がシンプル
ファイルで取り回せるのとコストが安そうで、運用も簡単なので3のパターンを採用することにしました。
S3+DynamoDBでstateの管理方法
この章のキモは3点だけです。
- バケット命名とロックテーブル命名をステージ変数で決め打ち
- バージョニング / SSE / HTTPS強制 / prevent_destroy / ログバケット分離
- DynamoDBロックテーブルは PAYG + PITR で削除保護つき
state管理 main.tf のキモだけ抜粋:
- 命名ルール(stageを入れるだけで環境を切り替えやすくする)
locals {
state_bucket_name = "tfstate-{project_nameを記入}-${var.stage}"
lock_table_name = "tfstate-lock-${var.stage}"
}
- S3バケット(本体 + ログ): prevent_destroy で誤削除防止、バージョニングと暗号化、HTTPS必須、アクセスログを別バケットに分離
resource "aws_s3_bucket" "tfstate" {
bucket = local.state_bucket_name
lifecycle { prevent_destroy = true }
tags = { Stage = var.stage }
}
resource "aws_s3_bucket" "tfstate_logs" {
bucket = "${local.state_bucket_name}-logs"
tags = { Stage = var.stage }
}
locals {
all_buckets = {
tfstate = aws_s3_bucket.tfstate
tfstate_logs = aws_s3_bucket.tfstate_logs
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
for_each = local.all_buckets
bucket = each.value.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}
resource "aws_s3_bucket_public_access_block" "this" {
for_each = local.all_buckets
bucket = each.value.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_logging" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
target_bucket = aws_s3_bucket.tfstate_logs.id
target_prefix = "access-logs/"
}
resource "aws_s3_bucket_policy" "this" {
for_each = local.all_buckets
bucket = each.value.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "DenyInsecureTransport"
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = [each.value.arn, "${each.value.arn}/*"]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
}]
})
}
# ログバケットにライフサイクルを設定してコストを抑える(例: 365日で削除)
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
bucket = aws_s3_bucket.tfstate_logs.id
rule {
id = "expire-old-logs"
status = "Enabled"
expiration {
days = 365
}
}
}
- DynamoDBテーブル(ロック + PITR + 削除保護)
resource "aws_dynamodb_table" "tfstate_lock" {
name = local.lock_table_name
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
deletion_protection_enabled = true
point_in_time_recovery { enabled = true }
attribute { name = "LockID"; type = "S" }
tags = { Stage = var.stage }
}
state管理 variables.tf:
variable "aws_region" {
type = string
default = "ap-northeast-1"
}
variable "stage" {
type = string
default = "dev"
}
backend設定
stateをレイヤーごとに分け、同じS3バケット+DynamoDBロックを共有します。keyだけ分けているのがポイントです。
backendブロック例(account / network / application で key を変えるだけ)
※-devと環境を決め打ちしていますが、呼び出し側のmain.ftなので問題ありません。別記事にてディレクトリ構成をまとめるのでそちらを読んでいただけると嬉しいです。
# account
backend "s3" {
bucket = "tfstate-{project_nameを記入}-dev"
dynamodb_table = "tfstate-lock-dev"
region = "ap-northeast-1"
key = "account/terraform.tfstate"
encrypt = true
}
# network
backend "s3" {
bucket = "tfstate-{project_nameを記入}-dev"
dynamodb_table = "tfstate-lock-dev"
region = "ap-northeast-1"
key = "network/terraform.tfstate"
encrypt = true
}
# application
backend "s3" {
bucket = "tfstate-{project_nameを記入}-dev"
dynamodb_table = "tfstate-lock-dev"
region = "ap-northeast-1"
key = "application/terraform.tfstate"
encrypt = true
}
remote stateで依存を解決する
applicationがnetworkの出力を読む部分は remote state を使っています。S3 backend と同じ設定をそのまま渡します。
data_network.tf:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "tfstate-{project_nameを記入}-dev"
dynamodb_table = "tfstate-lock-dev"
region = "ap-northeast-1"
key = "network/terraform.tfstate"
encrypt = true
}
}
セキュリティと運用ガード
- バージョニング有効+削除防止: tfstateの誤削除に備える。
- HTTPS強制ポリシー:
aws:SecureTransportが false のアクセスを拒否。 - SSE(AES256): サーバーサイド暗号化。
- ログバケット分離: 監査用にアクセスログを別バケットへ。アクセスログは溜まりがちなのでライフサイクルで期限を切る(例: 90〜365日でexpire)。
- DynamoDBロック+PITR: 同時applyを防ぎつつ、ロックテーブル自体の復旧性も確保。
- IAMは「誰が tfstate バケット/ロックテーブルに触れるか」を明示したポリシーを用意すると安心。
実行手順とハマりどころ
- まず state管理スタックを apply(まだ backend を作っていないのでローカルstateでOK)。
terraform init terraform apply - その後、account/network/application で
terraform init -reconfigureを実行し、S3 backend を向ける。terraform init -reconfigure terraform plan terraform apply - ロックが残ったときは DynamoDB の該当レコードを確認し、手動で消すか
terraform force-unlockを慎重に使う。 - バケット削除が必要なときは prevent_destroy を外す前にバックアップを取る。
ステージ追加・拡張のヒント
- 命名規則:
tfstate-{project_nameを記入}-${stage}とtfstate-lock-${stage}を使い回し、backendのkeyをaccount/,network/,application/に分けるだけでステージを増やせる。 - バケット名やkeyを tfvars に外出しするとステージごとの調整が楽。
- CI/CDから実行するなら、S3/DynamoDBへのアクセス権を持ったRoleを作り、Assume Roleで実行する運用が無難。
remote stateを増やしすぎないために
- remote state を積み重ねすぎると依存グラフが追いづらくなる(shared-service, monitoring, kinesis などが相互参照し始めるとカオス化する)。
- レイヤーは2〜3段くらいまでに抑え、境界でだけ remote state を使う。
- 細かいリソース依存は module 化でまとめ、module 入力で渡す形にして remote state の乱立を避ける。
まとめ
レイヤーを分けた瞬間に発生する「依存どうする問題」は、stateを分割してS3に置き、remote stateで解決するのがシンプルでした。ここに載せたコードをそのままベースにして、環境や命名規則を合わせればすぐ再現できます。後発の人が迷わず tfstate 設計できる助けになれば幸いです。