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本目です。
- Terraform aws_instance の user_data 変更で意図しない stop/start が発生する原因と対策 — そもそもなぜ stop/start が起きるのか、Terraform・Provider・EC2 API・cloud-init の4層のメカニズムを解説
- Terraform user_data_replace_on_change とは — Provider v4で変わったEC2の挙動と使い分け — この stop/start 問題が生まれた経緯と、
user_data_replace_on_changeの true/false の判断基準
参考
- aws_instance | Terraform Registry —
ignore_changesとreplace_triggered_byの公式ドキュメント - Terraform lifecycle meta-argument — lifecycle ブロックの詳細
- How to re-run cloud-init — cloud-init 再実行の公式手順
- Run user data after the initial launch of an EC2 instance | AWS re:Post — AWS公式のFAQ
- AWS Systems Manager Run Command — SSM Run Command の公式ドキュメント