/var/www/yatta47.log

/var/www/yatta47.log

やったのログ置場です。スクラップみたいな短編が多いかと。

Terraform data "aws_ami" の most_recent=true でEC2が意図せず再作成される原因と対策

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_instanceami 属性は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 planforces replacement が出たら「これは意図した変更か?」を確認する。GitHub ActionsでCI失敗にしておけば、そもそもマージ時に気づける仕組みになります。

参考