Terraform Bootstrap — 鶏卵問題の解決

Terraform を使うと、最初にぶつかる構造的な問題が 「state ファイルをどこに置くか」。本記事ではその解決策である S3 backend + DynamoDB Lock 構成と、それを「Terraform 自身では作れない」鶏卵問題、そして bootstrap シェルスクリプトでの解消について書きます。

そもそも state とは

Terraform は .tf ファイルに「望ましい状態」を書きます。実際の AWS の状態は刻々と変わるので、Terraform は「いま自分が管理しているリソース」を記録するファイルを持っています。これが stateterraform.tfstate)です。

state はマシンの中で「現実とコードの差分計算」に使われます。これが無いと terraform plan ができません。

state をローカルに置くとマズい理由

初心者向けチュートリアルでは terraform.tfstate がローカルに作られます。これを本番運用するとすぐに事故ります:

S3 backend + DynamoDB Lock 構成

これらの問題を解決する定石が、Terraform の S3 backend + DynamoDB Lock です:

本シリーズでは以下の名前で作りました:

用途名前設定
state バケットiigtn-tfstate-XXXXXXXXXXXXバージョニング ON, AES256, Public Access Block 全 ON
ロックテーブルiigtn-tflockPK = LockID (String), On-demand

S3 バケット名にアカウント ID を入れているのは、S3 バケット名は AWS 全体でグローバル一意 という制約があるため。アカウント ID を suffix にするのが慣例です。

鶏卵問題

ここで問題が発生します。S3 バケットと DynamoDB テーブルを Terraform で作ろうとすると…

「state を S3 に置きたい」 → 「S3 を Terraform で作る」 → 「でも Terraform を動かすには state を置く場所が必要」 → 「その場所がまだ無い」 → 詰み

これが Terraform 始めの定番の鶏卵問題です。S3 backend を初めて使う人がここで詰まります。

解決策: bootstrap だけ AWS CLI で手動

解決法はシンプル: state を置く S3 バケットと DynamoDB テーブルだけは AWS CLI で手動で作る。これら 2 個は Terraform で管理しません。それ以降のすべてのリソースは Terraform で管理します。

本シリーズでは、これを terraform/bootstrap/setup.sh という idempotent なシェルスクリプトにまとめました。何度実行しても安全(既に存在すればスキップ)。

setup.sh の中身

#!/usr/bin/env bash
set -euo pipefail

REGION="${REGION:-ap-northeast-1}"
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
BUCKET="iigtn-tfstate-${ACCOUNT_ID}"
TABLE="iigtn-tflock"

# ── S3 バケット作成 (idempotent) ────────────────────
if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
  echo "[skip] $BUCKET already exists"
else
  aws s3api create-bucket \
    --bucket "$BUCKET" \
    --region "$REGION" \
    --create-bucket-configuration LocationConstraint="$REGION"
fi

# バージョニング ON
aws s3api put-bucket-versioning \
  --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled

# 暗号化 (AES256)
aws s3api put-bucket-encryption \
  --bucket "$BUCKET" \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'

# Public Block 全 ON
aws s3api put-public-access-block \
  --bucket "$BUCKET" \
  --public-access-block-configuration \
    BlockPublicAcls=true,IgnorePublicAcls=true,\
BlockPublicPolicy=true,RestrictPublicBuckets=true

# ── DynamoDB テーブル作成 (idempotent) ──────────────
if aws dynamodb describe-table --table-name "$TABLE" --region "$REGION" >/dev/null 2>&1; then
  echo "[skip] $TABLE already exists"
else
  aws dynamodb create-table \
    --table-name "$TABLE" \
    --attribute-definitions AttributeName=LockID,AttributeType=S \
    --key-schema AttributeName=LockID,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST \
    --region "$REGION"
  aws dynamodb wait table-exists --table-name "$TABLE" --region "$REGION"
fi

echo "Done."

実行手順

AWS CLI のプロファイルを prod に切り替えて、まず aws sts get-caller-identity で確認してから実行します:

export AWS_PROFILE=aws-prod
aws sts get-caller-identity
# Account: XXXXXXXXXXXX (期待通りの prod アカウントか確認)

bash terraform/bootstrap/setup.sh
プロファイルの確認をサボると、dev 側に bootstrap してしまう事故を起こします(私自身、最初これをやらかしました)。

実行後の確認:

aws s3 ls | grep iigtn-tfstate
# 2026-04-25 12:00:00 iigtn-tfstate-XXXXXXXXXXXX

aws dynamodb list-tables --region ap-northeast-1
# "TableNames": ["iigtn-tflock", ...]

Terraform 側の backend 設定

bootstrap が終わったら、Terraform の各環境ディレクトリに backend.tf を書きます:

# terraform/envs/prod/backend.tf
terraform {
  backend "s3" {
    bucket         = "iigtn-tfstate-XXXXXXXXXXXX"
    key            = "envs/prod/terraform.tfstate"
    region         = "ap-northeast-1"
    dynamodb_table = "iigtn-tflock"
    encrypt        = true
  }
}

key は state ファイルのパス。envs/prod/envs/dev/ で別 key にすれば、同じバケットに複数環境の state を置けます。

backend ブロックには 変数(var.xxx)が使えません。アカウント ID 等もハードコードする必要があります。これは Terraform の仕様で、init 時に backend が確定している必要があるためです。

terraform init で接続

cd terraform/envs/prod
terraform init

init が成功すると、Terraform は iigtn-tfstate-XXXXXXXXXXXX バケットの envs/prod/terraform.tfstate を state として使うようになります。

destroy したくなった時

Terraform で作ったリソースは terraform destroy で消せますが、bootstrap の S3 + DynamoDB は Terraform 管理外なので手動で消します:

# DynamoDB テーブル削除
aws dynamodb delete-table --table-name iigtn-tflock --region ap-northeast-1

# S3 バケット中身削除 (バージョン付きなので version も消す必要)
aws s3 rm "s3://iigtn-tfstate-XXXXXXXXXXXX" --recursive
aws s3api delete-objects --bucket iigtn-tfstate-XXXXXXXXXXXX \
  --delete "$(aws s3api list-object-versions \
    --bucket iigtn-tfstate-XXXXXXXXXXXX \
    --output json \
    --query '{Objects: Versions[].{Key: Key, VersionId: VersionId}}')"

# バケット削除
aws s3api delete-bucket --bucket iigtn-tfstate-XXXXXXXXXXXX
これを実行すると Terraform が「現状」を見失います。本番運用後にやってはいけません。

State Lock の効果を体感する

setup.sh の DynamoDB テーブルは「同時に 2 人が terraform apply しないようにする」仕組みです。試しに同じディレクトリで 2 つの Git Bash を開き、両方で同時に terraform plan を打つと、片方が次のメッセージで待たされます:

Acquiring state lock. This may take a few moments...

ロックが取れた方が処理を進め、もう片方は待つか諦めます。これが効いていれば、複数人が同時に apply して state が壊れる事故は起きません。

Lock が取れない時の force-unlock

Terraform が異常終了して Lock が残ってしまった時:

terraform force-unlock <LOCK_ID>

Lock ID はエラーメッセージに表示されます。それでも消えなければ、DynamoDB テーブルの該当アイテムを直接削除します(最終手段)。

次の記事

Bootstrap が終わったら、いよいよ terraform init をやります… が、実は Windows + Git Bash 環境だと、ここで 「TLS bad record MAC」 という見たことのないエラーが出てプロバイダがダウンロードできない事故に遭遇しました。

次の記事ではその原因究明と回避策について書きます。Windows で Terraform を使うすべての人に役立つ内容です。

📚 用語集

state (tfstate)
Terraform が「いま自分が管理しているリソースの状態」を記録する JSON ファイル。terraform.tfstate。これが無いと差分計算ができない。
backend
Terraform が state ファイルを保管する場所の設定。ローカルファイル / S3 / Terraform Cloud などがある。本シリーズでは S3 を使う。
S3 バケット
S3 のファイル保管単位。名前は AWS 全体で一意でなければならない。
バージョニング (Versioning)
S3 オブジェクトの旧バージョンを保持する機能。state ファイルがこわれた時に旧版に戻せる。tfstate バケットでは必須。
SSE-S3 (AES256)
S3 がデフォルトで提供する保存時暗号化。AWS 管理キーで完全無料。tfstate には機密値が含まれるので暗号化必須。
Public Access Block
S3 バケットの「うっかり public 化」を構造的に防ぐ 4 つのスイッチ。tfstate バケットでは全部 true にする。
DynamoDB Lock
Terraform の S3 backend オプションで、DynamoDB テーブルを使って state ロックを取る仕組み。同時 apply 防止。
PAY_PER_REQUEST (On-demand)
DynamoDB の課金モード。読み書き回数で課金。アクセス薄いなら最安。
idempotent (冪等)
「何度実行しても同じ結果になる」性質。bootstrap スクリプトは idempotent にしておくと、エラー時の再実行が安全。
鶏卵問題
「A を作るには B が必要、B を作るには A が必要」という相互依存。bootstrap を Terraform 管理外にすることで解決する。
terraform init
Terraform の初期化コマンド。プロバイダ(AWS など)のダウンロード、backend への接続、モジュールの取得を行う。
terraform plan
「いま apply したら何が変わるか」を表示するコマンド。実際の変更はしない、安全に何度でも流せる。
terraform apply
plan の内容を実際に AWS に反映するコマンド。state lock を取る。
terraform destroy
Terraform 管理下のリソースをすべて削除するコマンド。本番運用後は基本的に使わない。
terraform force-unlock
異常終了等で残った state lock を強制的に解除するコマンド。Lock ID を引数で指定する。
aws s3api
AWS CLI の S3 低レベル API。aws s3(高レベル)と違って、バケットレベルの設定(バージョニング・暗号化・Public Block 等)はこちらで操作する。