ECSのタスク定義をTerraformで管理していて、何も変更していないのに terraform plan で差分が出てきたんですよね。
Day 1: パッチングツール実行 → terraform apply 成功(センサー v7.10) Day 2: 何もしていない Day 3: パッチングツールを再実行 → terraform plan → 差分あり!(センサー v7.11 に上がってた)
最初は「stateがズレた?」「誰か手動で変えた?」って思ったんですけど、どっちでもなかった。
原因
タスク定義パッチング方式の仕組みが原因でした。
パッチングツール実行時の流れ:
パッチングツール実行
↓
CrowdStrike API から最新センサーイメージを取得
↓
タスク定義JSONに falcon-init コンテナを注入
↓
パッチ済みJSON → Terraform が管理
ポイントは「CrowdStrike APIから最新のセンサーイメージを取得する」ステップです。パッチングツールを実行するたびに、その時点の最新バージョンが入ります。
- 月曜にパッチすると
falcon-sensor:7.10.0 - 木曜にパッチすると
falcon-sensor:7.11.0(CrowdStrike側で更新済み)
同じアプリ、同じTerraformコード、同じ手順でも、パッチ済みJSONの中身が実行日によって変わります。IaCの「同じコードからは同じ結果が出る」という前提が壊れているんですよね。
jq --sort-keys では解決しない
ここでよくある誤解があって、「JSONのキー順序の揺れだろうから jq --sort-keys で正規化すれば消えるはず」って思いがちなんですけど、これは的外れです。
キー順序の問題ではなく、JSONの中身(センサーイメージのURIやバージョン)が実際に変わっているので、正当な差分です。
対処法
選択肢は3つあります。
| 対処法 | 効果 | トレードオフ |
|---|---|---|
イメージパッチ方式(falconutil)に移行 |
根本解決 | CI/CDパイプラインの変更が必要 |
ignore_changes で container_definitions 全体を除外 |
差分は消える | アプリ本体のコンテナ設定変更も検知できなくなる |
| パッチ済みJSONのバージョンを固定する運用 | 再現性は確保 | センサーの自動更新を捨てることになる |
根本解決: イメージパッチ方式への移行
根本的に解決するなら、イメージパッチ方式(falconutil)への移行が筋です。
イメージパッチ方式(falconutil)の流れ: CI/CD パイプライン 1. docker build(通常通り) 2. falconutil でセンサーを焼き込み 3. docker push(パッチ済みイメージ) タスク定義: SYS_PTRACE を追加するだけ (センサーのバージョンはイメージタグで固定)
この方式だと、センサーがアプリイメージに焼き込まれます。タスク定義にはサイドカーもinitコンテナも入らないので、Terraformが管理するタスク定義JSONにセンサーバージョンが露出しません。
センサーバージョンの更新はアプリの再ビルド・再デプロイ時に行われるため、意図しないタイミングで差分が出ることがなくなります。
パッチ方式のまま差分ゼロにするTerraformコード
イメージパッチ方式への移行がすぐにできない場合、タスク定義パッチ方式のままでも差分ゼロを目指せます。パッチャーの出力JSONをTerraformに正しく取り込む方法です。
パッチャー出力JSONをfile()で管理する
パッチャーの出力はフルのタスク定義JSONです。container_definitions に渡すのはその中の containerDefinitions 配列だけなので、jsondecode で抽出します。
locals { # パッチャー出力はフルのタスク定義JSON # container_definitionsに渡すのはcontainerDefinitions配列だけ patched_task_def = jsondecode(file("${path.module}/patched-task-def.json")) patched_container_definitions = jsonencode(local.patched_task_def.containerDefinitions) } resource "aws_ecs_task_definition" "app" { family = "my-app" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = "256" memory = "512" execution_role_arn = aws_iam_role.task_exec.arn task_role_arn = aws_iam_role.task_role.arn # パッチ済みコンテナ定義(containerDefinitions配列を抽出済み)を渡す container_definitions = local.patched_container_definitions # CrowdStrikeが追加するボリュームをTerraformにも定義(必須) volume { name = "crowdstrike-falcon-volume" } }
ここで注意が必要なのが volume ブロックです。パッチャーはタスク定義JSONに crowdstrike-falcon-volume を追加しますが、Terraform側にも同じ volume を書いておかないと plan で差分が出続けます。
パッチャーを通さずHCLで完全管理する
パッチャーを使わず、CrowdStrikeが追加する要素をすべてHCLで書く方法もあります。センサーイメージのURIとCIDをvariableで管理します。
variable "falcon_sensor_image" { description = "ECR上のFalconセンサーイメージURI(タグ含む)" type = string } variable "falcon_cid" { description = "CrowdStrikeのCustomer ID" type = string sensitive = true } resource "aws_ecs_task_definition" "app" { family = "my-app" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = "512" memory = "1024" execution_role_arn = aws_iam_role.task_exec.arn task_role_arn = aws_iam_role.task_role.arn container_definitions = jsonencode([ { name = "crowdstrike-falcon-init-container" image = var.falcon_sensor_image essential = false mountPoints = [{ containerPath = "/tmp/CrowdStrike" sourceVolume = "crowdstrike-falcon-volume" readOnly = false }] }, { name = "app" image = "123456789.dkr.ecr.ap-northeast-1.amazonaws.com/my-app:latest" essential = true portMappings = [{ containerPort = 8080 }] entryPoint = [ "/tmp/CrowdStrike/rootfs/lib64/ld-linux-x86-64.so.2", "--library-path", "/tmp/CrowdStrike/rootfs/lib64", "/tmp/CrowdStrike/rootfs/bin/bash", "/tmp/CrowdStrike/rootfs/entrypoint-ecs.sh" ] linuxParameters = { capabilities = { add = ["SYS_PTRACE"] } } environment = [ { name = "FALCONCTL_OPTS", value = "--cid=${var.falcon_cid}" } ] mountPoints = [{ containerPath = "/tmp/CrowdStrike" sourceVolume = "crowdstrike-falcon-volume" readOnly = true }] dependsOn = [{ containerName = "crowdstrike-falcon-init-container" condition = "SUCCESS" }] } ]) volume { name = "crowdstrike-falcon-volume" } }
この方式のメリットは、パッチャーの出力に依存しないのでTerraformだけで完結すること。デメリットは、CrowdStrikeのパッチャーが追加する要素(entryPoint の書き換え、mountPoints、dependsOn 等)を自分で正確に再現する必要があること。パッチャーの仕様変更に追従する責任が自分に移ります。
どちらを選ぶか
- パッチャーの出力JSONを信頼して取り込むなら
file()方式 - パッチャーに依存したくない(CI/CDで毎回パッチャーを回すのが面倒)なら HCL完全管理方式
- どちらも厳しければ次の
ignore_changesで応急処置
ignore_changes は応急処置
ignore_changes は差分を消してくれますが、センサーのバージョン変更を検知できなくなります。「何も変えてないのに差分が出る」を消すために「本当に変わったときも検知できない」状態にするのは、問題をすり替えてるだけなんですよね。
応急処置としては使えるけど、長期運用するなら早めにイメージパッチ方式に移行した方がいいです。
これはCrowdStrike固有の問題ではない
同じ構造の問題は、外部APIから最新バージョンを取得してTerraformの管理対象に埋め込むパターン全般で起きます。
共通パターン: 外部API → 最新バージョン取得 → Terraform管理のリソースに埋め込み → 実行日によって結果が変わる = IaCの再現性が壊れる
AMI IDを most_recent = true で取得するケースも同じ構造です。対策の基本は「外部から取得した値をどこで固定するか」を設計に織り込むことです。
まとめ
CrowdStrikeのタスク定義パッチング方式は、パッチング実行時に最新センサーバージョンを取得するため、terraform planの結果が実行日によって変わります。根本解決にはイメージパッチ方式(falconutil)への移行が有効です。
参考
- CrowdStrike/falconutil-action — イメージパッチ方式のGitHub Action
- ECS Fargate Guide - Container-Security — タスク定義パッチ方式のガイド
- Deploy Falcon Agent on ECS tasks with CloudFormation — CloudFormation向けの実装例