data "aws_ami" で most_recent = true を使っていると、AWSが新しいAMIを公開したタイミングで terraform apply がEC2インスタンスをdestroy → createします。
原因はAMI IDの変更がForceNew属性であること。本番ではAMI IDを固定値で管理するのが対策です。この記事では原因の解説に加えて、CI/CDパイプラインで再作成を自動検知してブロックするGitHub Actions設定まで解説します。
何が起きたか
Terraformでインフラ管理してたんですよ。コードは一切変更してないのに、ある日 terraform plan を実行したら差分が出た。
# aws_instance.app must be replaced
-/+ resource "aws_instance" "app" {
~ ami = "ami-aaa111" -> "ami-bbb222" # forces replacement
...
}
Plan: 1 to add, 0 to change, 1 to destroy.
forces replacement — つまりEC2インスタンスが消されて作り直される。コード変えてないのに。
原因はこの書き方でした。
# この書き方が地雷 data "aws_ami" "latest" { most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-*-x86_64"] } } resource "aws_instance" "app" { ami = data.aws_ami.latest.id instance_type = "t3.micro" }
原因
2つの仕組みの組み合わせが地雷になっています。
1つ目: data "aws_ami" は terraform plan のたびにAWS APIを叩いて最新のAMI IDを取得する。AWSが新しいAMIを公開した瞬間、返ってくるIDが変わる。
2つ目: aws_instance の ami 属性はForceNew。AMI IDが変わるとin-place更新ができず、destroy → createの2ステップになる。
Day 1(初回apply): data.aws_ami.latest.id = ami-aaa111(2月15日公開) → EC2作成、正常稼働 Day 30(AWSが新AMIを公開): data.aws_ami.latest.id = ami-bbb222(3月15日公開) → terraform plan: ami "ami-aaa111" → "ami-bbb222" # forces replacement → terraform apply: EC2がdestroy → create → IPアドレスも変わる、EBSも消える(delete_on_termination=trueの場合)
書いた時点では正しく動くので、AMIが更新されるまで気づかない時限爆弾です。
AL2023の場合、AMIは月に2〜3回程度更新されます。数週間おきに terraform plan を実行すると突然この差分が出てくることになります。
解決方法
対策1: AMI IDを固定値で管理する(確実)
variable "ami_id" { description = "Pinned AMI ID for production" type = string default = "ami-0xxxxxxxxxxxxxxxxx" } resource "aws_instance" "app" { ami = var.ami_id instance_type = "t3.micro" }
AMI更新は terraform.tfvars を意図的に書き換えて、terraform plan で差分を確認してから apply する。最もシンプルで確実な方法です。
対策2: SSM Parameter Store経由で更新タイミングを制御する
Golden AMIパイプライン(Packer → テスト → SSM Parameter Storeに登録)と組み合わせる構成。
data "aws_ssm_parameter" "ami" { name = "/golden-ami/app/latest" } resource "aws_instance" "app" { ami = data.aws_ssm_parameter.ami.value instance_type = "t3.micro" lifecycle { ignore_changes = [ami] } }
most_recent = true との違いは、AMIの更新タイミングを自分でコントロールできる点。AWSが新AMIを公開しても、自分のパイプラインで検証してからParameter Storeに登録するまでは影響しない。
lifecycle { ignore_changes = [ami] } を付けると、Parameter Storeの値が変わってもTerraformは差分として扱いません。更新を反映したいときは ignore_changes を一時的に外すか、terraform apply -replace でリソースを再作成対象にします。
なぜ気づきにくいか
- 書いた直後の
plan/applyは期待通り動く - エラーは出ない。
-/+ (destroy and then create)の表示を見落とすと、そのままapplyしてしまう - AMIの更新はAWS側のタイミング次第。数週間〜数ヶ月後に突然差分が出る
CI/CDでの防御策(GitHub Actions設定例)
手動 terraform plan では見落としが発生します。PRに対して自動で plan を走らせ、forces replacement を検知したらCIを失敗させる仕組みを作ると確実です。
ただし注意点として、このワークフローは pull_request イベント(PRのcommit push時)にトリガーされます。PRが長期間openのまま放置されている間にAWSが新しいAMIを公開した場合、ワークフローは再実行されません。長期PRの場合はマージ前に手動で再実行するか、workflow_dispatch や定期スケジュール(schedule)で補完する運用を検討してください。
GitHub Actionsでforces replacementを自動検知する
# .github/workflows/terraform-plan.yml name: Terraform Plan Check on: pull_request: branches: [main] paths: - "**.tf" - "**.tfvars" jobs: plan: runs-on: ubuntu-latest permissions: id-token: write contents: read pull-requests: write steps: - uses: actions/checkout@v4 - uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.10.0" - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} aws-region: ap-northeast-1 - name: Terraform Init run: terraform init - name: Terraform Plan id: plan run: | set -o pipefail terraform plan -no-color -detailed-exitcode -out=tfplan 2>&1 | tee plan_output.txt echo "exitcode=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT # forces replacement を検知したらPRコメントで警告 - name: Check for forces replacement run: | if grep -q "forces replacement" plan_output.txt; then echo "## ⚠️ EC2インスタンスの再作成が検知されました" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "以下のリソースが **destroy → create** されます。意図した変更か確認してください。" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY grep -A 3 "forces replacement" plan_output.txt >> $GITHUB_STEP_SUMMARY exit 1 # CIを失敗させてマージをブロック fi # PRにterraform planの結果をコメント - name: Post Plan to PR uses: actions/github-script@v7 if: always() with: script: | const fs = require('fs'); const plan = fs.readFileSync('plan_output.txt', 'utf8'); const truncated = plan.length > 60000 ? plan.substring(0, 60000) + '\n...(truncated)' : plan; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `## Terraform Plan Result\n\`\`\`\n${truncated}\n\`\`\`` });
このワークフローは:
1. PRの .tf 変更に対して terraform plan を実行
2. forces replacement が含まれていたらCI失敗(マージをブロック)
3. plan結果をPRコメントに自動投稿
exit 1 の部分を exit 0 にしてコメントだけ残す運用(警告のみ)にも変えられます。チームの運用に合わせて調整してください。
Atlantisを使う場合
セルフホストでAtlantisを運用している場合は atlantis.yaml でより細かく制御できます。
# atlantis.yaml version: 3 automerge: false # forces replacement が出た場合に自動マージを防ぐ projects: - name: production dir: environments/production workspace: default autoplan: when_modified: ["*.tf", "*.tfvars", "../modules/**/*.tf"] enabled: true apply_requirements: - approved # apply前に承認必須 - mergeable # PRがマージ可能な状態であること
Atlantisは terraform plan 結果をPRコメントに自動投稿します。apply_requirements: [approved] は「承認なしに atlantis apply を実行できない」という制御であり、plan内容の自動検査(forces replacement のブロック等)をAtlantis単体で行うわけではありません。レビュアーがplanコメントを確認し、forces replacement が含まれていないことを目視で判断してから承認する運用を前提としています。plan内容の自動検査まで行いたい場合は、pre_workflow_hooks でgrepチェックを追加するか、Conftest等のポリシーツールと組み合わせる方法があります。
tfcmtを使う場合
軽量に導入したい場合は tfcmt が便利です。
# GitHub Actionsに組み込む例 - name: Terraform Plan with tfcmt run: | tfcmt plan -- terraform plan -no-color env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tfcmtはPRコメントのフォーマットを自動整形してくれます。forces replacement があると目立つ形で表示されるので、見落としが大幅に減ります。
AMI更新の通知を受け取る(EventBridge)
Qiitaなどの既存記事では「terraform apply実行時に気づく」前提のものが多いですが、AMI更新自体を事前に検知して準備できます。
# AMI更新イベントをEventBridgeで受け取り、SNSに通知する resource "aws_cloudwatch_event_rule" "ami_update" { name = "ami-state-change" description = "Notify when new AMI becomes available" event_pattern = jsonencode({ source = ["aws.ec2"] detail-type = ["EC2 AMI State Change"] detail = { State = ["available"] } }) } resource "aws_cloudwatch_event_target" "ami_update_sns" { rule = aws_cloudwatch_event_rule.ami_update.name target_id = "SendToSNS" arn = aws_sns_topic.ops_alerts.arn }
ただしAmazon公式AMI(AL2023等)はAWS管理アカウントから公開されるため、自アカウントのEventBridgeでは直接キャッチできません。自社ゴールデンAMIパイプライン(EC2 Image Builder / Packer経由)を使っている場合に有効です。
Amazon公式AMIの更新タイミングを知りたい場合はAWS Security Bulletinsのフィードを購読する方が現実的です。
まとめ
most_recent = true は「常に最新を使う」という意図で書きがちですけど、EC2の ami がForceNew属性なので「AMI更新のたびにインスタンスが消える」という結果になります。
対策のまとめ:
| 方法 | コスト | 向いている場面 |
|---|---|---|
| AMI ID固定(variable) | 低 | 小規模・シンプルな構成 |
| SSM Parameter Store経由 | 中 | ゴールデンAMIパイプラインがある |
| GitHub Actions自動検知 | 中 | チーム開発・PR運用あり |
| Atlantis | 高(インフラ必要) | Terraform専用CI/CDが欲しい |
本番ではAMI IDを固定値で管理して、更新は意図的に行う。terraform plan で forces replacement が出たら「これは意図した変更か?」を確認する。GitHub ActionsでCI失敗にしておけば、そもそもマージ時に気づける仕組みになります。
参考
- Terraform aws_ami data source — data sourceの公式ドキュメント
- Add option to aws_ami data source to avoid always refreshing(Issue #13044) — 常にリフレッシュされる問題の議論
- Manage resource lifecycle(HashiCorp Tutorial) — lifecycle設定の公式チュートリアル
- Announcing the Golden AMI Pipeline(AWS Blog) — Golden AMIパイプラインの構成例
- tfcmt(GitHub) — terraform plan結果をPRコメントに投稿するツール
- Atlantis(公式) — Terraform用CI/CDツール