AWS コンソールでポチッと直しただけなのに、なぜ terraform plan が差分まみれになるのか?
障害対応中、AWS コンソールから直接セキュリティグループのルールを 1 行足して収束させた経験があるはずだ。翌朝 CI の terraform plan を回したら、自分が足した箇所どころか何十行もの差分が表示されて青ざめた——という展開に見覚えはないだろうか。この差分の正体は、Terraform が設定ファイルと現実のインフラを直接比べているのではなく、state という記録を仲介させているという事実にある。
- Terraform は設定ファイルと実際のクラウドリソースを直接照合するのではなく、両者をつなぐ台帳である
stateを介して動く。 - コンソールで直接変更すると state と現実がずれる「ドリフト」が起き、次の
planがそのズレをまとめて差分表示する。 - 複数人が同時に
applyすると state が壊れるため書き込みロックが要る。ロックの実装は S3 + DynamoDB から進化し、今は DynamoDB 無しでも実現できる。
設定ファイルだけでは「今の現実」を追跡できない
Terraform はIaC(Infrastructure as Code、インフラの構成をコードで宣言的に記述する手法)を代表するツールで、.tf ファイルに「こういうリソースが欲しい」という望む状態を書き、terraform apply を実行するとクラウド API を叩いて実際のリソースを作る。
ここで問題になるのが、望む状態を書いた設定ファイルには、作成後に初めて割り振られる情報が存在しないことだ。たとえば EC2 インスタンスの ID は AWS が起動時に発行するもので、.tf ファイルのどこにも書かれていない。次に plan を実行するとき、Terraform は「このコードブロックはどの実物に対応するのか」を突き合わせる手段が要る。HashiCorp の公式ドキュメントも「Terraform の設定を現実世界にマッピングするには何らかのデータベースが要る」と説明しており、この役割を担うのが terraform.tfstate、通称 tfstate(Terraform が管理するリソースと実際のクラウドオブジェクトを対応づける記録ファイル)である。
倉庫の在庫管理に近い。棚に並ぶ実物(現実のクラウドリソース)とは別に、何が何個あるはずかを書いた台帳(state)がある。誰かが棚から商品を持ち出して台帳に書かずに立ち去ると、次の棚卸し(plan)で数が合わず全体を洗い直す羽目になる。台帳は 1 冊しかないので、2 人が同時に書き込もうとすれば片方は先に借りている人が書き終えるまで待つ必要がある——これが後述するロックだ。
state の中身: リソース ID だけでなく依存関係も記録されている
tfstate の実体は JSON で、各リソースの実際の属性値(ID や ARN など)に加えて、リソース同士の依存関係もメタデータとして記録している。
{
"resources": [
{
"type": "aws_instance",
"name": "web",
"instances": [
{
"attributes": {
"id": "i-0abcdef1234567890",
"ami": "ami-0123456789abcdef0"
},
"dependencies": ["aws_security_group.web"]
}
]
}
]
}
依存関係が要るのは削除の場面だ。設定ファイルからリソースを消すと、そのリソースの定義自体がもう存在しないため、Terraform は設定を読むだけでは「何が何に依存していたか」を再現できない。state に残った dependencies を頼りに、子リソースから先に壊すといった正しい削除順序を決める。加えて、リソース数が数百を超えるような大きな環境では、plan のたびに全リソースをクラウド API に問い合わせているとレート制限や待ち時間が無視できなくなる。state はその照会結果をキャッシュしておく役割も兼ねている。
ドリフトの正体: plan は「設定・state・現実」の三者を突き合わせている
ここで冒頭の疑問に戻る。terraform plan は state に記録された値をそのまま信じるのではなく、実行のたびに対象リソースの現在値をクラウド API から読み直し(リフレッシュ、state を実際のリソースの現在値で更新する処理)、それを設定ファイルと比較して差分を出す。
.tfファイル 実クラウド
(望む状態) (今の現実)
│ │
└──────┬──────────┘
▼
state (前回の記録)
│
▼
terraform plan
│
▼
差分を表示
コンソールから直接変更すると、state に記録された値と実際のリソースの値がずれる。これをドリフト(インフラの実体が Terraform の記録から乖離すること)と呼ぶ。次の plan はリフレッシュでこのズレを検知し、「設定通りに戻すための差分」として大量の変更を提示してくる——これが冒頭のセキュリティグループ 1 行の話が何十行もの差分に化ける理由だ。差分を作らずにズレだけを確認したい場合は terraform plan -refresh-only を使うと、リソースを変更する計画は立てずに state と現実のギャップだけを表示できる。
ロック: 同時 apply が state を壊す
state はただの記録ではなく、チームで共有する 1 つのファイルでもある。2 人が同時に apply を実行すると、両者が同じ state を読み込んだまま別々の書き込みを行い、後から書き込んだ側が先の変更を上書きして消してしまう競合が起きる。これを防ぐため、Terraform は state を書き換える可能性のある操作のたびにステートロック(他の書き込みを排除する目的で state に自動的にかかるロック)を取得し、ロックを取得できない場合はその操作自体を実行しない。
このロックはバックエンドの実装に依存する。S3 をバックエンドに使う構成では、長らく S3 自体に書き込みロックの仕組みが無かったため、別途 DynamoDB のテーブルを用意してロック専用に使うのが定石だった。
terraform {
backend "s3" {
bucket = "my-tfstate"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
dynamodb_table = "terraform-lock" # 旧来: 別テーブルでロック
}
}
ここに見覚えのある DynamoDB テーブルは今後は要らなくなる。Terraform 1.10(2024 年 11 月)で S3 の Conditional Writes(オブジェクトが存在しなければ書き込みを成功させ、存在すれば失敗させる機能)を使ったネイティブロックが導入され、use_lockfile = true を設定するだけで S3 単体でロックできるようになった。Terraform 1.11 では dynamodb_table 引数が非推奨になっている。
terraform {
backend "s3" {
bucket = "my-tfstate"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true # S3 単体でロック (Terraform 1.10+)
}
}
ロックを設定しない場合、デフォルトでは同時書き込みに対する保護が無いバックエンドもあるため、複数人・複数 CI ジョブから同じ state を触る運用ではロックの有無を必ず確認する必要がある。それでも CI ジョブが強制終了されるなどでロックが解放されないまま残ることがあり、その際は force-unlock(残ったロックを手動で解除するコマンド)で復旧するが、本当に他の書き込みが進行中でないかを確かめてから使わないと、今度は自分がロックの意味を壊す側になる。
「インシデント対応で緊急にコンソールから直した後、次の CI の plan が謎の差分を出す」はまさにこの記事の内容そのものだ。緊急変更をした後は terraform import や設定ファイルの追記で state を追いつかせておかないと、次の apply がその緊急変更を意図せず巻き戻してしまう。バックエンド設定のコードレビューでロックの有無を確認するのも、この仕組みを知っていて初めて意味が分かるチェック項目になる。
クラウドアカウントが無くても、state ファイルの実物を自分の目で確認できる。
brew install terraform # 未インストールの場合
mkdir tf-demo && cd tf-demo
cat > main.tf <<'EOF'
resource "null_resource" "example" {}
EOF
terraform init
terraform apply -auto-approve
cat terraform.tfstate | python3 -m json.tool
resources の中に null_resource.example の ID がクラウドを一切使わずに記録されているのが確認できるはずだ。これが state の最小構成であり、AWS などの本物のリソースでも記録されるものの種類は同じである。
- tfstate は単なるキャッシュだから消しても apply し直せば直る — state はどのリソースを Terraform が管理しているかを示す唯一の記録であり、消すと Terraform は既存リソースの存在を忘れる。次の apply は「まだ無いもの」として扱い、重複作成や既存リソースの孤立を招く。
- コンソールで直した後に設定ファイルも同じ内容に書き換えれば state も自動で追いつく — 設定ファイルを直しても state は自動更新されない。次の plan / apply でリフレッシュが実行されて初めて実際の値が読み直される。
- state を S3 に置けばそれだけで同時書き込みからも安全になる — S3 に置くのは保管場所の話であり、ロックの有無とは別問題。
use_lockfileや DynamoDB テーブルなど、ロックを有効にする設定を明示しない限り同時 apply の競合は防げない。
- tfstate
- Terraform が管理するリソースと実際のクラウドオブジェクトを対応づける JSON 形式の記録ファイル。
- ドリフト
- インフラの実体が Terraform の記録(state)から乖離すること。コンソールでの直接変更などで起きる。
- リフレッシュ
- plan / apply の実行時に、対象リソースの現在値をクラウド API から読み直して state を更新する処理。
- ステートロック
- state を書き換える操作中に他の書き込みを排除する仕組み。同時 apply による state 破損を防ぐ。
- force-unlock
- 解放されずに残ったステートロックを手動で解除するコマンド。他の書き込みが進行中でないことを確認してから使う。
- Purpose of Terraform State - HashiCorp Developer — state がなぜ必要かを公式ドキュメントが正面から説明している。
- State: Locking - HashiCorp Developer — ロックがどの操作で発生し、force-unlock がどう危険かを解説している。
- Backend Type: s3 - HashiCorp Developer —
use_lockfileによる S3 ネイティブロックとdynamodb_table非推奨の詳細。