planの読み方 — 記号、終了コード、ドリフト
一言でいうと
applyは、planがすでに下した決定を実行するだけです。事故を防げる唯一の場所は、プランを読む数分間で、その数分間は、結局、人ではなくスクリプトが代わりに行う必要があります。
なぜ必要なのか
インフラコードで最も高くつくミスは、「置き換えを、その場での更新と読み間違えたこと」です。画面に出た-/+を~と勘違いすると、本番データベースが削除されて、また作られます。プランの出力は長く、デプロイはたいてい遅い時間に行われ、人の目は、同じ画面を3回見ると、読まなくなります。
そのため、成熟したチームは、2つのことを行います。1つ目は、プランをファイルに保存して、レビューしたそのプランをそのまま適用することです。保存しないと、レビューしたプランと、実際に適用されるプランが違う可能性があります。その間に、誰かが別のものを変更したかもしれないからです。2つ目は、プランを機械が読む形式で取り出して、ルールで判定することです。「削除が1つでもあれば人が承認」のようなルールは、人ではなくパイプラインが守る必要があります。
どう動くのか
人が読むプランには、記号が付きます。
| 記号 | 意味 | 危険度 |
|---|---|---|
+ |
作成 | 低い |
~ |
その場での更新 | 普通 |
- |
削除 | 高い |
-/+ |
削除後の再作成 | 非常に高い |
+/- |
作成後の削除(create_before_destroy) |
高い |
<= |
データソースの読み取り | なし |
置き換えが出てくる理由は、たいてい変更できない引数に触れたからで、そのような行には# forces replacementの表示が付きます。いちばん下の要約行のPlan: X to add, Y to change, Z to destroyで、置き換えが、addとdestroyの両方に1ずつ数えられる点も、覚えておく必要があります。
自動化に必要なのは、記号ではなく、2つのものです。1つは、-detailed-exitcodeです。
| 終了コード | 意味 |
|---|---|
| 0 | 変更なし |
| 1 | エラー |
| 2 | 変更あり |
この3つの値のおかげで、「変更があれば通知する」のような定期的なドリフト検知を、シェルの1行で作れます。もう1つは、JSON表現です。プランファイルをJSONに変換すると、resource_changes配列が出てきて、各項目のchange.actionsが、["create"]、["update"]、["delete"]、置き換えなら["delete","create"]として入っています。そして、resource_driftには、照会の段階で見つけた、コードの外の変更が別に入ります。プランに反映される変更と、すでに起きている変更を、区別して見られるという意味です。
-targetは、グラフの一部だけを選んで処理するオプションです。使うと、ツールが警告を出します。一部だけを適用すると、残りがコードと食い違ったまま残り、状態が一貫しなくなるおそれがあるからです。復旧の状況で使うツールであって、普段のワークフローではありません。
現場での姿
1つ目は、プロバイダーのアップグレード後の大量のドリフトです。メジャーバージョンを上げると、新しく追加された属性のデフォルト値のせいで、数百個のリソースに変更が出ます。大半は、実際のインフラを変更するのではなく、状態に属性を埋めるだけです。この2つを区別せずに、驚いて元に戻したり、逆に確認なしに適用したりすることが、どちらも事故につながります。JSONで取り出して動作ごとに数えてみると、判断が速くなります。
2つ目は、検知そのもののコストです。planは、管理中のすべてのリソースについて、プロバイダーのAPIを呼び出します。規模が大きいとAPIの制限にかかり、検知の最中には状態のロックがかかって、デプロイと衝突します。検知の周期は、タダではありません。
3つ目は、プランのログに残る秘密情報です。プランの出力には、リソースの属性がそのまま出力されます。通知チャネルやCIのログに丸ごと貼り付ける習慣は、そのまま流出経路になります。要約だけを送って、全体はアクセスが制御された場所に置くのが安全です。
プランで必ず目で確認する3つのこと
terraform planの出力は長いです。すべて読むことはできないので、何を探すかを決めておいて見ます。
1つ目は、要約行のdestroyの個数です。Plan: 3 to add, 1 to change, 2 to destroyで、0ではないdestroyは、いつでも手を止めて見る理由です。意図したものなら、何が削除されるのかを名前で確認し、意図していないなら、たいていリソースのアドレスが変わったためです(モジュールを移したか、countをfor_eachに変えたか)。そのときは、削除して作り直す代わりに、movedブロックでアドレスだけを移します。
moved {
from = aws_instance.web[0]
to = aws_instance.web["a"]
}
2つ目は、# forces replacementが付いた属性です。名前を1つ変えただけなのに、データベースが置き換えられるプランが、ここで明らかになります。状態を持つリソースには、prevent_destroyをかけておけば、プランの段階でそもそも防がれます。
lifecycle { prevent_destroy = true }
3つ目は、(known after apply)がどこに付いたかです。この値が多ければ、プランが実際に何をするか、事前にはわかりにくいという意味です。他のリソースのまだない属性を参照するためですが、それ自体は正常です。ただし、重要な決定(セキュリティグループのルール、ポリシードキュメント)が、この値にかかっているなら、適用前にはレビューできないということを、知っておく必要があります。
プランをファイルとして取得して適用します。 プランと適用の間に、誰かが何かを変更すると、見たものとは別のものが適用されます。
terraform plan -out=tfplan
terraform show -json tfplan | jq '[.resource_changes[]
| select(.change.actions | index("delete"))] | map(.address)'
terraform apply tfplan
自動化では、終了コードを使います。 -detailed-exitcodeは、0(変更なし)、1(エラー)、2(変更あり)を返します。CIで「変更があれば、人の承認を受ける」という流れは、ここから生まれます。
次のラボですること
/root/tf/planで、プランをファイルに保存してJSONに変換し、動作ごとの個数を数えて記録します。-detailed-exitcodeの2種類の終了コードを直接確認し、その場での更新と置き換えが、1つのプランの中に一緒に捕捉されるようにします。管理中のファイルを手で直してresource_driftを見て、-targetで範囲を絞りながら、ツールの警告を読みます。最後に、削除が含まれるプランを自分で判定する、レビューレポートを作ります。