最初に白状しておくと、自分は一度 terraform apply の Enter を押した瞬間に「あ、これRDS作り直されるやつだ」と気づいて心臓が止まりかけたことがあります。幸い検証環境だったので実害はゼロだったんですが、あれが本番だったら今このブログを書く余裕はなかったと思います。
前回(第4回)はモジュール化とディレクトリ分割で、dev と prod を同じコードで管理する話を書きました。コード自体はきれいになったんですが、「じゃあこれを本番で回すときに何に気をつけるの?」という部分がまったく片付いていなかったので、シリーズ最終回はそこを埋めていきます。
この記事でわかること
- Terraform本番運用で最も危険な「意図しない再作成」の防ぎ方
- tfstateの安全な置き場所と秘密情報の扱い方
- CI/CDから安全にapplyするためのOIDC設定と権限分離
- 壊さずに構成を変えるlifecycle・moved・removedブロックの使い方
- ドリフト検知と静的解析の自動化
本番運用でいちばん怖いのは「意図しない再作成」
Terraform の事故で多いのは、削除したつもりがないリソースが destroy されるパターンです。原因として多いのは、変更できない属性(ForceNew 扱いの属性)をうっかり書き換えてしまうことです。RDS の engine_version のメジャー更新とか、EC2 の availability_zone とか、DynamoDB の hash_key あたりは「変更内容によっては置き換え(replace)になりやすい」ポイントとして扱われがちです。
で、これを防ぐ方法は結局「plan をちゃんと読む」しかないんですが、人間は読み飛ばすので機械的に引っかける仕組みにしました。
terraform plan -out=tfplan.bin
terraform show -json tfplan.bin | jq -r '
.resource_changes[]
| select(.change.actions == ["delete","create"] or .change.actions == ["create","delete"])
| .address'
これで「replace されるリソースの一覧」だけが出てきます。CI でこの出力が空じゃなかったら apply を止める、くらいでもかなり安心感が違いました。plan の出力に出てくる # forces replacement という行を目で探すよりは確実です。
あと地味に大事なのが、plan と apply を必ずファイル経由でつなぐこと。
terraform plan -out=tfplan.bin
terraform apply tfplan.bin # plan なしの apply は本番では封印
plan を見た人と apply する人の間で状態が変わっていると、レビューした内容と違うものが適用されます。ここを守るだけで、レビューが「気持ち」ではなく実体を持つようになる気がします。
tfstate は「漏れたら終わる」ファイルとして扱う
これは前のシリーズでリモートステートを設定したときには軽く流してしまったんですが、tfstate にはパスワードやアクセスキーなどの秘密情報が平文で入ってしまうケースがあります。Terraform 側で自動的に「中身を消してくれる」わけではないので、置き場所側で守る必要があります。
terraform {
required_version = "~> 1.13"
backend "s3" {
bucket = "nyanchu-tfstate-prod"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
kms_key_id = "arn:aws:kms:ap-northeast-1:123456789012:key/xxxx"
use_lockfile = true
}
}
use_lockfile = true は S3 backend のロック機構を有効化するオプションです。これを使うと、従来の dynamodb_table によるロックに頼らずに済む構成にできます。DynamoDB によるロックは deprecated 扱いで、これから作る環境なら S3 側のロックへ移行するのが推奨されているので、基本はそっちでいいのかなと思っています。テーブルを1個減らせるぶん IAM ポリシーもシンプルになりました。
バケット側は、パブリックアクセスブロック・バージョニング有効・SSE-KMS の3点セットは最低ラインだと思います。バージョニングは「セキュリティ」というより、state が壊れたときの命綱です。実際に一度、途中で回線が切れて state が中途半端になったとき、前のバージョンを戻して助かりました。
秘密情報を state に入れない選択肢
Terraform 1.10 以降では ephemeral resources がサポートされています。また、プロバイダ側が write-only な属性(write-only arguments)を提供している場合、それらを使うことで値を state に書き込まずに渡せるようになってきています。自分はまだ RDS のパスワードを Secrets Manager 側で自動生成させて、Terraform からは ARN しか触らない方式で逃げています。正直、ephemeral 周りはまだちゃんと理解できていないので、ここは別で調べ直したいところです。
CI から apply するなら OIDC と権限分離
GitHub Actions に AWS_SECRET_ACCESS_KEY を置くのはやめました。OIDC で一時クレデンシャルを取る形にすると、Secrets に長期キーを置かなくて済みます。
permissions:
id-token: write
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/tf-plan-role
aws-region: ap-northeast-1
- run: terraform init
- run: terraform plan -out=tfplan.bin -lock-timeout=180s
ポイントは plan 用ロールと apply 用ロールを分けることです。plan は基本 ReadOnly で足りますが、state バケットへの書き込みとロック取得が必要なので、そこだけは許可しないと動きません(ここでしばらくハマりました)。apply 用ロールは environment の承認必須ブランチからしか assume できないよう、IAM の信頼ポリシーで sub を絞っています。
-lock-timeout を付けておくと、他のジョブがロックを持っている間に即エラーで落ちずに待ってくれます。逆に、ジョブが強制終了してロックが残ったときは terraform force-unlock ですが、これは本当に誰も動いていないと確認してから叩くやつなので、CI で自動実行するのはやめました。
壊さずに構成を変えるための書き方
lifecycle ブロックは保険として効きます。
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
ignore_changes = [password]
}
}
prevent_destroy が付いていると destroy が必要な plan の時点でエラーになるので、事故る前に止まります。ただしこれを付けたままリソースを消したいときは、いったん外してから apply という二段構えになるので少し面倒です。
リファクタで名前を変えるときは moved ブロック。以前は terraform state mv をローカルで叩いていましたが、コードに残るほうがレビューできて健全でした。
moved {
from = aws_instance.web
to = module.web.aws_instance.this
}
removed {
from = aws_s3_bucket.old_logs
lifecycle { destroy = false }
}
removed ブロックは「コードからは消すけど実物は残す」用です。手動で state rm しなくていいので、これを知ってからだいぶ気が楽になりました。
あと、state ファイルは用途ごとに分割したほうがいいと感じています。ネットワーク・データストア・アプリで3分割にしたら、1回の apply の影響範囲が小さくなって plan も速くなりました。値の受け渡しは terraform_remote_state ではなく SSM パラメータストア経由にしています(他の state を読める権限を配りたくなかったので)。
ドリフト検知と静的解析を「勝手に回る」状態にする
手でコンソールをいじる人がゼロになることはないので、差分は定期的に検知しておきたいところ。-detailed-exitcode が便利です。
terraform plan -refresh-only -detailed-exitcode
# exit 0 = 差分なし / 2 = 差分あり / 1 = エラー
これを毎朝 GitHub Actions のスケジュール実行で回して、exit code が 2 なら Slack に飛ばすようにしました。自動で直そうとすると事故りそうなので、通知だけにしています。
静的解析は tflint と Trivy(旧 tfsec が統合されたやつ)を PR で走らせています。「S3 バケットの暗号化が無効」みたいな指摘を機械がしてくれるのは、初学者的にはかなりありがたいです。ルールを全部真面目に直すと終わらないので、重大度 HIGH 以上だけ落とす設定にしています。このへんの落としどころは、まだ正解がわかっていません。
余談ですが、Infracost も入れてみたら「月 $40 増えます」と PR に書かれて、そっと構成を見直しました。財布に効くフィードバックは強いです。
まとめ
5回かけて Terraform を触ってきて、いちばん変わったのは「コードを書く技術」より「壊さない手続き」を考えるようになったところでした。plan を必ずファイルに落とす、state は分割して守る、CI から長期キーを消す。どれも派手さはないけど、これがないと本番で使う気にはなれないなと。
ポリシー as code(OPA や Sentinel)と Terraform Stacks はまだ触れていないので、そのあたりは次に手を出したいと思っています。あと、OpenTofu をわざわざ選ぶ理由があるかどうかも一度自分で確かめておきたいところです。

