TL;DR
- 自分の変更は追加だけのはずなのに、planに
2 to changeが出ました。中身を見たら、自分の変更とは無関係なdriftでした -
terraform applyは自分がいま書いた変更にだけ効くのではなく、plan全体に効きます。 他人の未解決driftがあれば一緒に適用されます - 混ざっていたのは、IaCの外(コンソール等)で直接追加されたIAMポリシーの例外許可でした。そのままapplyすると、別の場所で下された判断を無言で覆します
- 承認の記録は、ポリシーのステートメントIDの文字列に日付が入っていること以外、どこにもありませんでした。「なぜその許可があるのか」を1か月誰も参照できませんでした
- そのためPR自体が19日間applyされずに止まり、その間planは毎回汚れ続けていました
- 結論: planはテスト結果ではなく、これから発生する副作用のmanifestである
観測日: 2026-07-31
はじめに
連載の3本目です。1本目で、Terraformをほぼ知らない人間がLLMに書かせたインフラコードをapplyするために証拠を積んでいる話を書きました。その工程のうち「planの意味を人間が確認する」の段が、なぜサマリの目視では足りないのかという話です。
作業ルールにこう書いています。
X to add, Y to change, Z to destroy のサマリだけを見るな。
change / destroy は一件ずつ中身を確認せよ。
書いた理由が実例です。
1. planの読み方を間違えると何が起きるか
Terraformを使い始めた頃、私はplanをこう読んでいました。
テストみたいなもの。
成功したらOK、失敗したらNG。
これは二重に間違っています。
まず、planは成否を判定しません。planが成功したというのは、Terraformが宣言と実態を突き合わせられたという意味です。危険な変更でも淡々と成功します。
もっと重要なのは範囲です。planは「自分がいま書いた変更」を表示するものではありません。「いまapplyしたら何が起きるか」の全部 を表示します。
plan の内容 = 宣言 と 実インフラ の差分すべて
差分の出どころ:
- 自分がいま書いた変更
- 別のPRでmerge済みだが、まだapplyされていない変更
- 誰かがコンソールやCLIで直接触ったことによるdrift
- providerのバージョン差による表現の揺れ
そして terraform apply は、この全部に効きます。
つまり、自分の変更をapplyしようとすると、他人の未解決driftも一緒にapplyされます。これは -target を使わない限り避けられません(そして -target は例外手段です)。
2. 実際に踏んだケース
作業内容そのものは小さいものでした。ブランチの棚卸しをしていて、放置されていたPRを1本処理しようとしていました。
planを取ったら、こうなりました。
0 to add, 2 to change, 0 to destroy
自分の変更は、コードの記述を実態に合わせるものだけでした。changeが2件出る想定はありました。しかし念のため1件ずつ中身を見たら、1件は完全に無関係なdrift でした。
無関係だった側は、IAMポリシーに存在する2つのステートメントでした。
そのポリシーのコードは、冒頭で明確な要件を掲げていました。生成AIのモデル呼び出しを国内リージョンに限定する、というものです。具体的には国内完結のクロスリージョン推論プロファイルだけを許可し、グローバルのプロファイルはリソース指定で除外する、という書き方をしていました。
ところが実際のIAMポリシーには、その例外にあたる2ステートメントが存在しました。特定のモデルについて、グローバルプロファイル経由の呼び出しを許可するものです。
IaCの外で、コンソールから直接追加されたものでした。
3. 承認の記録がステートメントIDの文字列にしかなかった
ここが一番効いた問題です。
追加されていたステートメントのIDには、...ExceptionApproved<日付> のような文字列が入っていました。つまりその日に承認された例外だ、と読める。
それ以外に記録はどこにもありませんでした。 Terraformのコードにも、設計ドキュメントにも、PRにも、Issueにもありません。
結果として何が起きたか。
- planには恒常的なdriftとして現れ続ける
- 素でapplyすると、その許可が消える
- 消していいのかどうか、誰も判断できない
- 判断できないのでapplyされない
- applyされないのでPRが止まる
該当PRは19日間applyされずに止まっていました。PR本文には「このdriftがあるのでapplyできない」という警告が書かれていました。書いた人は正しく気づいていたわけです。しかし判断の材料が無いので止まる。
そして止まっている間、planは毎回汚れ続けます。 つまりこのリポジトリで作業する他の全員が、毎回この2件のchangeを見ることになります。そして毎回「これは自分のじゃないな」と判断するコストを払います。
これがdriftの本当のコストだと思っています。1件のdriftが、そのリポジトリで作業する全員のplan確認を汚染します。
4. どう処理したか
「消す」か「残す」かを決めるには、前提を取り直す必要がありました。1か月前の判断の根拠がもう有効かどうか分からないからです。
提供状況を実測した
例外が必要だった理由は「使いたいモデルが国内リージョンのプロファイルで提供されていない」というものでした。それが今も成立するかを、実際にAPIで問い合わせて確認しました。
| プロファイル | 1か月前(コード記載) | 実測 |
|---|---|---|
| 軽量モデル | 提供あり | 提供あり |
| 標準モデル(旧世代) | 提供あり | 提供あり |
| 標準モデル(新世代) | 未提供 | 提供あり |
| 上位モデル(3世代) | 未提供 | 提供あり |
分かったことは2つでした。
- 例外が必要だという前提は、一部のモデルについては今も成立する。国内プロファイルが用意されていないモデルは依然として存在した
- 一方で「上位モデルは国内提供が無い」という1か月前の制約は解消していた。これはコード側の暫定的な措置を解除できるという別の good news でもあった
判断の前提が動いていたわけです。driftを放置すると、判断の前提が変わったことにも気づけません。
実使用を確認した
次に、その許可が実際に使われているかを見ました。
アプリケーション側のモデル選択のコードを読むと、すべての経路が国内プロファイルを指していました。グローバルプロファイルの記述は、環境変数の例示ファイルにコメントアウトされた1行だけでした。推論プロファイルの定義そのものも、国内リージョンだけを指していました。
つまり、越境の許可は付与されているが使われていませんでした。
ここで正直に書いておくべき限界があります。モデル呼び出しのログ出力を有効にしていなかったので、AWS側から実際の呼び出し履歴は追えませんでした。上の判定はコードと定義の読解によるものです。
これは1本目に書いた「実環境で確認する」の段が弱かったケースです。コードが何を意図しているかは読めますが、実際に何が呼ばれたかは記録が無ければ分かりません。「その権限が実際に使われているか」を後から確認できる状態にしておくべきだった、というのが持ち帰りです。
残す判断をして、コード化した
将来そのモデルを使う可能性を残すため「残す」と決めました。そのうえで、コードに書いたのは許可そのものだけではありません。
- これが国内完結要件の例外であること
- グローバルプロファイルは国外を含むリージョンへルーティングされうること
- 承認の経緯と、IaCの外で追加されてdriftになっていた事実
- 現時点で実使用が無いこと
- 例外の範囲をリソース指定で1モデルに限定していること(他のグローバル専用モデルはこの許可では呼べない)
最後の点が効いています。コンソールで追加されたときの範囲をそのままコード化するのではなく、必要な範囲に絞り直しました。
処理の結果はこうなりました。
| 項目 | before | after |
|---|---|---|
terraform plan |
0 to add, 2 to change, 0 to destroy(うち1件は無関係なdrift) |
No changes |
| 実ポリシーのステートメント | 4件(うち2件がIaC管理外) | 4件(すべてIaC管理下) |
もう1件の差分は、リスト内の順序が入れ替わるだけの実質差分なしでした。これも見ないと分かりません。
5. 「applyしたらmergeまで通す」の裏側
このケースからもうひとつ規則が出ました。
apply したら merge まで通す。
「apply済み・未merge」は、実インフラにあってコードに無いものを作る。
今回のdriftがまさにそれでした。しかも該当ブランチは既に削除されていたため、PR本文の記述以外に経緯を辿る手段が残っていませんでした。
そしてこの規則には裏側があります。「merge済み・未apply」も同じくらい厄介です。
実際に、後の別の作業でこれを踏みました。あるPRのplanを取ったら、こうなりました。
Plan: 5 to import, 1 to add, 16 to change, 1 to destroy.
自分の変更は 5 to import だけです。残りの 1 to add / 16 to change / 1 to destroy は、mergeはされているがapplyされていない別の2本のPR に由来していました。
内容を実測して確認したら、片方は15リポジトリへの説明文追加、もう片方はリポジトリの改名でした。改名はURLが変わる不可逆に近い操作で、他の人がverifyすべきものです。
素でapplyすると、この改名も一緒に実行されます。
このときの判断は「applyしない」でした。選択肢は3つあって、
- 先行の2本をapplyする人がapplyしてから、自分のPRを素でapplyする(推奨)
-
-targetで自分の分だけapplyする(例外手段。状態が中途半端になりやすい) - PRをopenのまま置く
1番を選ぶために、PRに状況を書いて判断を仰ぐ形にしました。「planに無関係なものが混ざっているのでapplyできない」は、正常な停止です。 押し通すほうが危険です。
6. ドリフト修正PRを寝かせてはいけない
ここまでの話をまとめると、こういう規則になります。
「宣言を実態に合わせる」種類のPRは、寝かせてはいけない。
理由は、そのPRがmergeされるまで、mainのplanにdriftを出し続けるからです。そしてそのdriftは、無関係な作業のapplyを汚染します。
滞留の「量」ではなく「種類」の問題です。他の種類のPRなら1週間寝ていても誰も困りませんが、drift修正PRは2日でも十分に危ないと考えています。
なので、drift修正PRは他の変更より優先して標準フロー(レビュー → apply → merge)を完了させ、それから自分のブランチをrebaseして再planを取る、という順序にしました。
もうひとつ、これに関連して規則にしたことがあります。
apply前に、組織横断でopen PRを1回引く。
IaCが複数のリポジトリに分かれていると、PRは各リポジトリに立ちます。自分が見ている1つのリポジトリだけを見ていると、他で何がmerge待ちなのか分かりません。組織全体を対象にopen PRを検索するコマンドを1回打つ、というのを手順に入れました。
7. コンソールで触ったら、その日のうちに書き戻す
最後に、この一連の話の根本原因への対策です。
IaC管理下のリソースをコンソールで変更したら、
同じ日のうちにTerraformへ書き戻す。
そして重要なのは、これに続く一文です。
ステートメントIDやタグに承認日を書くだけでは、記録にならない。
今回のケースでは、追加した人は記録を残そうとしていました。ステートメントIDに承認日を入れています。意図は明確です。
でも足りませんでした。「いつ」は書けても「なぜ」と「誰が」と「どういう条件で」が書けないからです。 そして1か月後に判断しようとした人間は、そこから何も読み取れませんでした。
コンソール操作が悪いという話ではありません。緊急時にコンソールで触るのは正当です。問題は、触ったあとに書き戻していないことです。
まとめ
- planはテスト結果ではない。これから発生する副作用のmanifestである
-
terraform applyは自分の変更ではなく、plan全体に効く -
X to add, Y to change, Z to destroyのサマリで判断してはいけない。changeとdestroyは1件ずつ中身を見る - 「planに無関係なものが混ざっているのでapplyしない」は正常な停止である
- driftは、そのリポジトリで作業する全員のplan確認を汚染する
- drift修正PRは、量ではなく種類の問題として優先して閉じる。2日でも危ない
- 「apply済み・未merge」は、実インフラにあってコードに無いものを作る
- 「merge済み・未apply」は、他人のapplyに自分の変更を混ぜ込む
- コンソールで触ったら同じ日にコードへ書き戻す。IDやタグの日付は記録ではない
- driftを放置すると、判断の前提が変わったことにも気づけなくなる
- 「その権限が実際に使われているか」を後から確認できる状態(ログ出力)を用意しておく
1本目に書いた工程のうち、この記事は「planの意味を人間が確認する」の段の話でした。ここで人間がやるべきなのは、HCLを読んで安全性を判定することではなく、planに出ているものが全部自分の意図と一致しているかを確認すること です。
そして一致していないものが混ざっていたら、その混ざりものを先に片付けます。押し通さないことが、この段の一番大事な作法だと思っています。
この記事は 七夕研究所 Qiita Organization の記事です。開発と運用で実際に踏んだものを順次書いています。