前回はTerraformの基本的な書き方とAWSリソースの定義方法を紹介しました。今回はそこからもう一段階踏み込んで、「チームで使い始めたときに最初に詰まるところ」を中心に整理していきます。具体的には tfstateのリモート管理(S3バックエンド) と モジュール設計の基本 の2本柱です。
個人で触っているうちはローカルにtfstateが転がっていても別に困らないんですが、複数人で同じリポジトリを触り始めた瞬間にこれが一気に問題になる。自分も最初「なんでこんな大事なこと最初に教えてくれないんだ…」って思いました。
そもそもtfstateって何をしているファイルなのか
Terraformは「あるべき姿(コード)」と「今の現実(インフラ)」を比べて差分を埋めるツールです。その「今の現実」を記録しているのが terraform.tfstate というJSONファイルです。
terraform apply を実行するとき、内部では大体こういう流れになっています:
- tfstateを読んで「現在の状態」を把握する
- コードで定義した「あるべき状態」と比較してPlanを作る
- 差分をAWSに適用する(Apply)
- 適用後の新しい状態をtfstateに書き戻す
つまりtfstateが狂うとTerraformの判断が狂う。AWSコンソールで手動でリソースを消しても、tfstateには「存在する」と書かれたままなので、次回planしたときに「変更なし」と言われたりする。これが「ドリフト」と呼ばれる状態で、地味にやっかいです。
ちなみにtfstateはJSON形式で、こんな感じの内容が入っています:
{
"version": 4,
"terraform_version": "1.11.0",
"serial": 12,
"lineage": "a1b2c3d4-...",
"resources": [
{
"mode": "managed",
"type": "aws_instance",
"name": "web",
"instances": [
{
"attributes": {
"id": "i-0123456789abcdef0",
"instance_type": "t3.micro"
}
}
]
}
]
}
これをgit管理しようとすると、AWSのシークレットや各種パスワードが平文で入っているケースがあって危険です。.gitignore に *.tfstate を入れるのは最低限のルールとして覚えておいてほしいです。
# .gitignore
*.tfstate
*.tfstate.*
.terraform/
# .terraform.lock.hcl はコミット推奨(プロバイダーバージョン固定のため)
ローカル管理の何がまずいのか
ローカルにtfstateがあると、チームで使ったときに以下のことが起きます:
- AさんとBさんがそれぞれ手元のtfstateでapplyして、状態が食い違う
- ほぼ同時にapplyしてtfstateの書き込みが競合し、ファイルが壊れる
- 「どのtfstateが正しいの?」という状況になって混乱する
これ、経験した人はわかると思いますが、復旧がけっこうつらい。そのためtfstateはS3などのリモートストレージで一元管理するのが基本になっています。
S3バックエンドでtfstateをリモート管理する
AWSを使う場合、事実上の標準はS3バックエンドです。設定自体はシンプルで、terraform ブロックに backend "s3" を追加するだけです。
まずバックエンド用のS3バケットを作る
バックエンド自体をTerraformで管理するのが理想的です。ただし「バックエンドを管理するTerraformのtfstateはどこに置くの?」という鶏と卵問題があるので、ここだけはローカルbackendで実行するブートストラップ用ディレクトリを作るのが定石です。
# bootstrap/main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "ap-northeast-1"
}
resource "aws_s3_bucket" "tfstate" {
bucket = "myproject-terraform-tfstate"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
ポイントは3つ:バージョニング有効化(後でtfstateが壊れたときに過去バージョンに戻せる)、暗号化、パブリックアクセスの完全ブロックです。prevent_destroy = true も忘れずに。tfstateバケットを誤削除したら割と致命的なので。
メインプロジェクトにbackend設定を追加する
# backend.tf(main.tfに書いてもいいけど分けるのが慣習)
terraform {
required_version = ">= 1.11"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "myproject-terraform-tfstate"
key = "production/terraform.tfstate" # S3内のパス
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # S3ネイティブのロックファイル方式
}
}
State Lockingの話:DynamoDBはもう不要になった
少し前まで、S3バックエンドで同時実行を防ぐには DynamoDB テーブルを別途作って dynamodb_table を指定する必要がありました。「S3だけで完結しないの?」ってずっと思ってたんですが、Terraformの比較的新しい機能として、S3ネイティブのロックファイル機能(use_lockfile)が入ってきた、という流れになります。
use_lockfile = true を設定すると、S3バケット内にロックファイル(<key>.tflock)を直接書き込む形でロックが管理されます。DynamoDBテーブルを別で用意する必要がなくなったのはかなりすっきりしました。
旧来の設定(例):
backend "s3" { bucket = "myproject-terraform-tfstate" key = "production/terraform.tfstate" region = "ap-northeast-1" dynamodb_table = "terraform-state-lock" # DynamoDBが別途必要だった }新しい設定(例):
backend "s3" { bucket = "myproject-terraform-tfstate" key = "production/terraform.tfstate" region = "ap-northeast-1" use_lockfile = true # これだけでOK }
なお dynamodb_table 引数はdeprecated(非推奨)扱いになっています。既存の設定を持っている人は移行を検討したほうがいいかもしれません。ただし、移行コストと既存の安定性を天秤にかけてから判断で十分かなとは思います。
余談ですが、この変更の背景には「なぜこんなにDynamoDB依存を外したかったのか」というユーザーの声が相当あったらしく、納得感がある。
backendを追加したら terraform init を再実行する
$ terraform init
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend to the
newly configured "s3" backend. No existing state was found in the newly
configured "s3" backend. Do you want to copy this state to the new "s3"
backend? Enter "yes" to copy and "no" to start with an empty state.
Enter a value: yes
Successfully configured the backend "s3"!
Terraform has been successfully initialized!
既存のローカルtfstateがある場合はS3への移行確認が出ます。「yes」を入れるとローカルのtfstateがS3にコピーされて、以後はS3が正本になります。移行後にローカルの terraform.tfstate を消しても問題ないです。
ディレクトリ構成とモジュール設計の考え方
tfstateが整理できたら、次はコードそのものの構成を考える段階です。Terraform初心者がやりがちなのが、全リソースを main.tf 1ファイルに詰め込むこと。最初は動くんですが、300行を超えたあたりから「どこに何が書いてあるかわからない」ゾーンに突入します。
まずはファイル分割から
モジュール化の前段階として、ファイルを役割で分けるだけでだいぶ違います。Terraformはディレクトリ内の .tf ファイルをまとめて読み込むので、ファイルを分けても動作は変わりません。
myproject/
├── main.tf # リソース定義のメイン
├── variables.tf # 変数定義
├── outputs.tf # アウトプット定義
├── backend.tf # バックエンド設定
└── terraform.tfvars # 変数の値(gitignoreに入れることも多い)
モジュール化:同じ構成を使い回す
チームで複数の環境(dev/staging/prod)を管理したり、似たような構成を複数作ったりするときにモジュールが活きてきます。モジュールの実体はただのディレクトリです。
myproject/
├── modules/
│ ├── vpc/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── ec2/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── environments/
├── dev/
│ ├── main.tf
│ ├── backend.tf
│ └── terraform.tfvars
└── prod/
├── main.tf
├── backend.tf
└── terraform.tfvars
モジュール側(呼び出される側)はこんな感じです:
# modules/ec2/variables.tf
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "ami_id" {
type = string
}
variable "subnet_id" {
type = string
}
# modules/ec2/main.tf
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
tags = {
Name = "ec2-instance"
}
}
# modules/ec2/outputs.tf
output "instance_id" {
value = aws_instance.this.id
}
呼び出し側(environments/dev/main.tf)はこうなります:
# environments/dev/main.tf
module "web_server" {
source = "../../modules/ec2"
ami_id = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_id
}
output "web_server_id" {
value = module.web_server.instance_id
}
モジュール内のリソースに変更を加えたいときは、variables.tf に変数を追加して呼び出し側から渡す、という流れになります。これが慣れると「コードのインターフェース設計」っぽい感覚になってきて、正直楽しいです。
stateファイルはモジュール・環境ごとに分ける
環境をディレクトリで分けた場合、それぞれのbackend設定の key を変えることでtfstateを分離できます:
# environments/dev/backend.tf
backend "s3" {
bucket = "myproject-terraform-tfstate"
key = "dev/terraform.tfstate" # ここが環境ごとに違う
region = "ap-northeast-1"
use_lockfile = true
}
# environments/prod/backend.tf
backend "s3" {
bucket = "myproject-terraform-tfstate"
key = "prod/terraform.tfstate" # こっちはprod
region = "ap-northeast-1"
use_lockfile = true
}
これで dev の apply が prod の tfstate に影響を与えることがなくなります。当たり前のようで、最初はこれを一個のtfstateで管理しようとしてひどい目に合う人がけっこういる気がしています(自分含め)。
ロックが残ったり、tfstateが壊れたときの対処
apply途中でプロセスが強制終了すると、ロックが残留することがあります。
Error: Error acquiring the state lock
Lock Info:
ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
Operation: OperationTypeApply
Who: alice@example.com
Created: 2026-07-15 09:00:00 +0000 UTC
誰も本当にapplyしていないことを確認した上で:
$ terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
ただしこれは「本当に誰もapplyしていない」ことを確認してから実行するのが大事です。Lock Infoの Who と Created を見て、その人に声をかけてから実行するのが安全な手順かなと思います。
tfstateが壊れた場合は、S3のバージョニングが効いていれば過去のバージョンに戻せます:
$ aws s3api list-object-versions \
--bucket myproject-terraform-tfstate \
--prefix prod/terraform.tfstate \
--query 'Versions[*].{VersionId:VersionId,LastModified:LastModified}' \
--output table
# 戻したいバージョンを指定してダウンロード
$ aws s3api get-object \
--bucket myproject-terraform-tfstate \
--key prod/terraform.tfstate \
--version-id "戻したいバージョンID" \
terraform.tfstate.backup
バージョニングを有効にしておく理由がここで効いてきます。「どうせ壊れない」と思って無効にしておくと、壊れたときに詰みます。自分はこのためだけにでも有効化を強くすすめたいです。
まとめ
tfstateのS3管理とモジュール設計を整理してきましたが、正直「全部一気に整備しなくていい」とも思っています。まず S3バックエンド + use_lockfile = true だけ入れて、次に環境分離、そのあとモジュール化、という順番で少しずつ整えていくのが現実的かなと。
📚 シリーズ「Terraform × AWS インフラ自動化入門」(第2回 / 全5回)
← 前回の記事: 前回の記事はこちら
→ 次回の記事: 【第3回】Terraform × AWS インフラ自動化入門 — GitHub Actionsと連携してTerraformをCI/CDパイプラインに組み込む

