/var/www/yatta47.log

/var/www/yatta47.log

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

Terraform EC2: ignore_changes した user_data を安全に適用する方法まとめ

この記事は EC2 user_data シリーズ(全3回) の3本目です。

aws_instance の user_data に lifecycle { ignore_changes } を入れると意図しない stop/start は止まります。でも「いざ新しい user_data を適用したくなったらどうするのか」が次の問題になる。方法はいくつかあるけど、万能なものはない。それぞれのトレードオフを整理しました。

きっかけ

user_data 変更でサービス断が起きる問題の対策として ignore_changes を入れた。止まらなくなったのはいいけど、「次に user_data を変えたくなったときにどうするんだ」と思ったんですよね。ignore_changes は「差分を見ない」設定なので、Terraform からは user_data を反映する手段がなくなる。

方法の全体像

方法 ダウンタイム State整合性 新user_data適用
terraform apply -replace あり(再作成) 整合する 確実
replace_triggered_by + terraform_data あり(再作成) 整合する 確実
cloud-init clean + reboot あり(reboot) 影響なし 古い内容が走る(罠)
SSM Run Command なし 乖離する スクリプト次第
薄いブートストラップ + S3 pull なし 影響なし 確実
Launch Template + ASG なし 整合する 確実

短期: 作り直す

terraform apply -replace

一番シンプルで確実です。

sequenceDiagram
    participant Op as オペレータ
    participant TF as Terraform
    participant AWS as AWS

    Op->>TF: terraform apply -replace
    TF->>AWS: DestroyInstance(旧)
    AWS-->>TF: destroyed
    TF->>AWS: CreateInstance(新user_data)
    AWS-->>TF: running
    Note over AWS: 新インスタンス<br/>→ cloud-init 初回実行<br/>→ スクリプト適用される ✓
    TF-->>Op: Apply complete
terraform plan -replace="aws_instance.example"
terraform apply -replace="aws_instance.example"

-replace は ignore_changes の有無に関係なく、リソースを強制的に destroy → create する。plan で影響範囲を事前確認できるのが最大の利点。

旧コマンドの terraform taint は deprecated です。taint は state ファイルを先に書き換えるので、チーム運用で他のメンバーが plan を流したときに意図しない再作成が走るリスクがあった。

注意点:

  • インスタンス ID が変わる(SSM・CloudWatch 等の参照が切れる可能性)
  • EBS ルートボリュームが delete_on_termination = true(デフォルト)だと消える
  • パブリック IPv4 が変わる(EIP 未使用の場合)

replace_triggered_by + terraform_data

-replace の「コード管理版」です。動きは -replace と同じで、トリガーがコードに組み込まれている点が違う。

sequenceDiagram
    participant Dev as 開発者
    participant Git as Git
    participant TF as Terraform
    participant AWS as AWS

    Dev->>Git: terraform_data.input を "v2" に変更
    Git->>TF: terraform apply
    TF->>TF: replace_triggered_by を検知
    TF->>AWS: DestroyInstance(旧)
    AWS-->>TF: destroyed
    TF->>AWS: CreateInstance(新user_data)
    AWS-->>TF: running
    TF-->>Dev: Apply complete
    Note over Git: 「いつ・なぜ再作成したか」<br/>が git log に残る
resource "terraform_data" "user_data_version" {
  input = "v2"  # ← ここを変えると aws_instance が replace される
}

resource "aws_instance" "example" {
  ami              = var.ami
  instance_type    = "t3.small"
  user_data_base64 = base64encode(file("${path.module}/user_data.sh"))

  lifecycle {
    ignore_changes       = [user_data, user_data_base64]
    replace_triggered_by = [terraform_data.user_data_version]
  }
}

-replace と同じ結果だけど、「いつ、なぜ再作成したか」が git log に残る。CI/CD にも乗る。チーム運用だとこっちの方が安全です。

なお terraform_data リソースは Terraform v1.4 以降で使えます。それ以前は null_resource + null provider で同様のことができる。

cloud-init clean は罠

「インスタンスを壊したくないから cloud-init を再実行すればいいのでは」と思うのは自然なんですけど、罠があります。

sequenceDiagram
    participant Op as オペレータ
    participant EC2 as EC2インスタンス
    participant CI as cloud-init
    participant IMDS as IMDS

    Op->>EC2: SSH / SSM でログイン
    Op->>CI: cloud-init clean --logs --reboot
    CI->>CI: セマフォ削除
    EC2->>EC2: reboot

    CI->>IMDS: user_data を取得
    Note over IMDS: ignore_changes のせいで<br/>AWS側は古い user_data のまま
    IMDS-->>CI: 古い user_data を返す
    CI->>CI: 古いスクリプトを再実行 ✗

    Note over Op,CI: 結果: 再実行されたが<br/>古い内容が走っただけ
sudo cloud-init clean --logs --reboot

これ自体は正しく動く。セマフォが削除されて、reboot 後に cloud-init が user-data スクリプト(scripts-user モジュール)を再実行する。

問題は、cloud-init が IMDS(Instance Metadata Service)から取得するのは AWS 側に保存されている user_data だということ。ignore_changes のせいで Terraform は AWS 側の user_data を更新していないので、IMDS には古い内容が入っている。

「cloud-init を再実行したのに変わらない」の正体がこれ。新しい内容を適用したつもりが、古いスクリプトがもう一度走るだけです。

回避するには AWS CLI で直接 user_data を更新してから cloud-init clean する必要があるけど、それには stop/start が要るし Terraform state とも乖離する。結局 -replace と比べてメリットが薄い。

ダウンタイム不可: SSM Run Command

再起動せずにスクリプトを流す方法。

sequenceDiagram
    participant Op as オペレータ
    participant SSM as SSM
    participant EC2 as EC2インスタンス
    participant S3 as S3

    Op->>SSM: send-command
    SSM->>EC2: RunShellScript
    EC2->>S3: スクリプト取得
    S3-->>EC2: setup.sh
    EC2->>EC2: スクリプト実行 ✓
    EC2-->>SSM: 実行結果
    SSM-->>Op: 完了

    Note over EC2: running のまま<br/>再起動なし・IP変更なし
    Note over Op: ただし Terraform state とは乖離する
aws ssm send-command \
  --instance-ids "i-xxxxx" \
  --document-name "AWS-RunShellScript" \
  --parameters 'commands=["aws s3 cp s3://my-bucket/setup.sh /tmp/setup.sh && chmod 700 /tmp/setup.sh && /tmp/setup.sh"]'

SSM Agent が動いていれば running のままスクリプトを実行できる。IP も変わらない。

ただし Terraform state と実態が乖離する。user_data と SSM で流すスクリプトの二重管理にもなりやすい。「緊急パッチを当てたい」には向いてるけど、恒久的な構成管理には向かない。

長期: そもそも user_data を変えない設計にする

ここまでの方法は「user_data を変えた後にどうするか」の話だった。発想を変えて、user_data 自体を変えない構成にすれば問題ごと消える。

薄いブートストラップ + S3 pull

user_data を2層に分ける。user_data には「前提条件のセットアップ」と「外部からスクリプトを取得して実行する」だけを書く。

sequenceDiagram
    participant TF as Terraform
    participant SSM as SSM Parameter Store
    participant S3 as S3
    participant EC2 as EC2インスタンス

    Note over TF: user_data は変わらない<br/>(薄いブートストラップのみ)

    rect rgb(230, 245, 255)
    Note over EC2: 新規起動時(自動)
    EC2->>SSM: スクリプトURL取得
    SSM-->>EC2: s3://bucket/setup.sh
    EC2->>S3: スクリプト取得
    S3-->>EC2: setup.sh
    EC2->>EC2: 実行 ✓
    end

    rect rgb(255, 245, 230)
    Note over EC2: 既存インスタンス(手動)
    Note right of EC2: SSM Run Command で<br/>同じスクリプトを実行
    end

    Note over TF,S3: 変更管理は S3 のスクリプトで行う<br/>user_data は触らない
#!/bin/bash
set -euo pipefail

# Layer 1: 前提ツール確認
# Amazon Linux 2023 なら aws-cli プリインストール済みなのでこの行は不要
# 他のディストリビューションも対象にする場合は残しておく
command -v aws >/dev/null || dnf install -y aws-cli 2>/dev/null || yum install -y awscli

# ネットワーク到達性チェック
for i in {1..10}; do
  aws sts get-caller-identity --region ap-northeast-1 && break
  sleep 5
done

# Layer 2: S3 からスクリプト取得して実行
SCRIPT_URL=$(aws ssm get-parameter \
  --name /ec2/setup-script-url \
  --query Parameter.Value --output text \
  --region ap-northeast-1)

aws s3 cp "${SCRIPT_URL}" /tmp/setup.sh
chmod 700 /tmp/setup.sh
/tmp/setup.sh
rm -f /tmp/setup.sh

user_data 自体は一切変わらない。変更管理は S3 のスクリプトで行う。新規インスタンスは自動で最新スクリプトが適用され、既存インスタンスには SSM Run Command で手動適用できる。

注意点:

  • eval は使わない。Parameter Store の値を直接 eval するのはコードインジェクションのリスクがある
  • プライベートサブネットでは VPC エンドポイント(SSM 用の Interface 型、S3 用の Gateway 型)がないとサイレント失敗する
  • Parameter Store の Standard は 4KB 制限。スクリプト本体は S3 に置き、URL だけ Parameter Store に入れるのが筋がいい

Launch Template + ASG + Instance Refresh

aws_instance を捨てて ASG 構成に移行するパターン。user_data は Launch Template のバージョンで管理し、Instance Refresh で段階的にインスタンスを入れ替える。

sequenceDiagram
    participant Dev as 開発者
    participant TF as Terraform
    participant LT as Launch Template
    participant ASG as ASG
    participant EC2old as EC2(旧)
    participant EC2new as EC2(新)

    Dev->>TF: user_data を変更して apply
    TF->>LT: 新バージョン作成 (v2)
    TF->>ASG: Instance Refresh 開始

    ASG->>EC2new: 新インスタンス起動(v2 user_data)
    Note over EC2new: cloud-init 初回実行 ✓
    EC2new-->>ASG: healthy

    ASG->>EC2old: 旧インスタンス終了
    EC2old-->>ASG: terminated

    Note over ASG: ダウンタイムなし<br/>State 整合性あり<br/>ignore_changes 不要

ダウンタイムなしで State 整合性も保てる唯一の方法。ただし aws_instance からの移行コストがある。

どこに向かうべきか

EC2 のプロビジョニング手法としての優先順位はこう考えています。

1. Golden AMI(EC2 Image Builder)
   AMI にソフトウェアを焼き込む。ビルド時にテスト済み

2. Launch Template + ASG + Instance Refresh
   user_data を Launch Template のバージョンで管理

3. 薄いブートストラップ + S3 pull
   aws_instance 単体で使い続ける場合の現実的な妥協案

4. user_data 直書き
   最もシンプルだが変更管理の問題をすべて自分で抱える

ポイントは Launch Template が「user_data のバージョニング」を仕組みとして持っていること。単体 aws_instance にはこれがないので、ignore_changes や Parameter Store pull のようなワークアラウンドが必要になる。Launch Template + ASG に乗れるなら、これらのワークアラウンド自体が不要です。

自分はどうするか

「ダウンタイムなし + State 整合性あり + Terraform 管理下」を全部満たす銀の弾丸はない。

短期的には -replace で凌ぎつつ、長期的には「user_data に変わるものを入れない」設計に寄せていくのが現実的。

個人的に一番運用しやすかったのはLaunch Template方式。初期構築する+運用が許すようであればこの方式にするのが一番安定する。

万能な手段がないなら問題の構造を変える — これは user_data に限らず、インフラの自動化で繰り返し出てくるパターンだと思っています。

関連記事

この記事は EC2 user_data シリーズの3本目です。

参考