デプロイに失敗しました。リソースはまだ実行中です。
デプロイコマンドが構成段階で失敗したとき、ネットワークと仮想マシンはすでに作成され稼働したままである。非ゼロの終了コードはそのプロセスの状態を述べるだけで、リモートシステムの状態ではない。したがって復旧は再実行ではなく、状態を持つワークフローに頼るべきである。
日本語
コピー

デプロイは構成フェーズで止まった。 ネットワークは作成済み、仮想マシンも作成済み、構成は最後まで走らず、ヘルスチェックは一度も実行されていない。パイプラインは赤いが、クラウド事業者のコンソール上ではそれらのリソースがまだ生きている。 どちらの見方も技術的には正しい。コマンドは失敗した。インフラも実際に変わった。 問題は、自動化が前者の事実を後者の反証と取り違えることにある。非ゼロの終了コードが表すのはプロセスの結果であって、そのプロセスが終わった後に複数のリモートシステムが置かれている完全な状態ではない。
コマンドの失敗はロールバックではない
インフラのワークフローは、オペレーターには一つの操作として見えるが、その裏では多数の独立した呼び出しを発行している。1回のデプロイが、ネットワークの作成、アドレスの予約、仮想マシンの作成、疎通の待機、OS の構成、サービスのプローブを順に実行することがある。 同じコマンドから発行されたからといって、これらの呼び出しが1つのアトミックなトランザクションになるわけではない。4番目のステップが失敗しても、最初の3つは完了しているかもしれない。 Microsoft は補償トランザクションのパターンの説明で、分散操作について同じことを述べている。完了済みの作業には専用の補償アクションが必要になることが多く、そのアクション自体も失敗しうる。復旧は状態を持つワークフローであり、自動巻き戻しボタンではない。 CI ではこの違いが見落とされやすい。パイプラインは結果を緑か赤かに潰してしまうからだ。緑はプロセスが想定された終点に達したことを意味し、赤は達しなかったことを意味する。どちらの色も、失敗する前にクラウド事業者側のどの変更が確定したかをオペレーターに伝えてはくれない。
やみくもな再試行は状態を悪化させる
実行が失敗したとき、たいていはそのまま再実行したくなる。それが安全なのは、システムが最初の試行で何をしたかを確定できる場合だけだ。 クラウド事業者が作成リクエストを受け付けたものの、応答が接続断で失われたとしよう。呼び出し側に見えるのはエラーだ。再試行すると、リソースが重複したり、名前の衝突が返ったり、後続のステップが誤ったオブジェクトに紐づいたりしかねない。 この原則は API 設計ではよく知られている。HackerNoon の冪等性と再試行のガイドは、応答が不確かなときにリクエストを繰り返すことがなぜ安全でないかを説明している。サーバーとクライアントがその操作について安定した識別子を共有していない限りは、という条件付きで。Google Cloud Workflows もこの点を明確にしている。再試行の指針では、冪等なステップとそうでないステップを別扱いにしている。 冪等性は役に立つが、すべての答えではない。同じ論理リクエストが同じ副作用を2度生むのは防げても、どの先行ステップが完了し、どの後続ステップが一度も始まっておらず、失敗したリソースを続けて使うべきか削除すべきかは、オーケストレーターには分からない。 それには永続化された操作の記録が要る。
変更を実行する前に状態遷移を書く
最も重要な状態の書き込みは、制御が provider に渡る前に行われる。
検証とプリチェックが通ったら、runtime はまず running のような非終端のマーカーを永続化すべきだ。このマーカーには操作の識別子、対象、解決済みの入力、タイムスタンプ、そして前回の実行で得られた出力を含める必要がある。ここまで済んでから、provider を呼び出して変更を実行する。
状態の書き込みが失敗したら——ディスクが満杯、パスが使えない、バックエンドがコミットできない——provider の呼び出しは始めるべきではない。
このルールは厳しすぎるように聞こえる。ローカルのストレージの問題がデプロイの停止に変わるのだから。だが逆の順序はもっと悪い。インフラは変わったのに、その変更が始まったことを示す永続的な記録がどこにもない状態になる。
保守的な境界ケースが1つある。プロセスが running を永続化した後、provider に到達する前に停止することがある。その後オペレーターは、不確定な操作——実際にはリモートに何も変更していない——を確認しなければならない。provider が何かを作成したかもしれないのに、古い destroyed のマーカーが残っているよりはましだ。
runtime がすでに把握している情報を残す
失敗処理は状態を更新すべきであって、空の結果で上書きすべきではない。 縮小された状態レコードはおおよそこうなる:
{
"module_ref": "platform/example/service",
"status": "error",
"failed_command": "apply",
"last_error": "provider failed after create",
"outputs": {
"resource_id": "previously-known-resource"
},
"rerun_inputs_file": "/config/rerun-inputs.yml",
"resolved_inputs_file": "/resolved.inputs.yml",
"run_id": ""
}
出力には「以前から判明していた」とわざわざ注記が付いている。初回の作成時、provider はオブジェクトを作成した後に識別子を返す前に失敗したかもしれない。受け取っていない値を自動化が保持することはできない。それでも入力、操作の識別子、エラー、タイムスタンプ、実行記録は残せる——後で provider に問い合わせて結果を突き合わせるのに必要なのは、まさにこれらだ。 この話はインフラエンジン自身の状態と関係するが、別物でもある。HackerNoon のリモート Terraform 状態バックエンドの解説は、リソースのバインディングとロックがなぜ1台のワークステーションをまたいで存続しなければならないかを説明している。HashiCorp 自身の状態のドキュメントは、状態を構成内のリソースインスタンスとリモートオブジェクトの対応付けとして記述している。 オーケストレーション runtime の役割は違う。エンジンをまたいでどのライフサイクル操作が試され、どのステップで失敗し、次にどのアクションが安全なのかを覚えておくことだ。provider の状態と操作の状態は互いを補完するものであり、どちらかが他方のふりをすべきではない。
実行されなかった作業は失敗ではない
部分的な失敗は依存グラフの見え方も変える。4つのステップを考えてみよう:
network ok
virtual machine error
configuration pending
health check pending
構成とヘルスチェックは失敗していない。そもそも実行されていない。この3つのステップをまとめて失敗と呼ぶと、実際の境界が覆い隠され、誤った復旧経路に誘導されかねない。完了済みの作業を繰り返したり、仮想マシンの状態が安定していないうちにヘルスチェックを実行したりする経路だ。
依存グラフには、complete、error、pending、retained、destroyed といった状態を区別するだけの語彙が必要だ。内部のワークフローエンジンは、実行されなかったステップを deferred と呼ぶかもしれない。オペレーターにとって意味はもっと単純で、前の状態遷移が終わるのを待っているということだ。
ここが reconciliation が retry より役に立つ所以だ。最近の HackerNoon の大規模 reconciliation に関する記事では、安定した操作識別子、条件付きの状態変更、外部システムに対する検証が紹介されている。同じ原則はインフラにも当てはまる。復旧は、検証できる事実に基づくべきであって、「失敗したプロセスは何も変えていない」という仮定に基づくべきではない。
破棄は検証を要する操作である
エラー状態は、まだ生きている可能性があるものとして扱わなければならない。
破棄ロジックが ok とマークされていないステップをすべて飛ばすなら、apply の失敗後にこそ片付けるべきリソースを飛ばすことになる。より安全な規則はこうだ。absent か destroyed にあると確認できた状態だけを飛ばす。error のマークが付いたリソースは、provider が調べて解体できる。
この過程では帰属関係が効いてくる。親リソースが削除されると、子リソースも一緒に消えることがある。たとえば仮想マシンを削除すると guest レベルの状態も同時に消えるが、仮想マシンが存在しなくなれば、その状態にもう単独ではアクセスできない。
親リソースの削除が始まった時点で、子リソースを destroyed とマークしてはならない。子リソースが終態に入るのは、親リソースが存在しないと確認できた後だけだ。親リソースの削除が失敗すれば、子リソースは未解決のまま残る。
Kubernetes は同じような考え方を finalizers で表現している。削除リクエストはオブジェクトを terminating 状態に置き、必要なクリーンアップが終わるまでオブジェクトは残り続ける。API リクエストは、リソースが消えた証拠とはみなされない。
インフラの解体も同じくらい正直であるべきだ。
これは Disaster Recovery as a Governance System で私が述べた主張の延長にある。復旧には明示的な判断、検証済みの結果、そして実際に何が起きたかの記録が要る。そしてそうしたガバナンスは、ランタイムがまず失敗した操作の正確な記録を保持していて初めて成り立つ。
失敗シナリオを契約にする
私はオープンソースのインフラランタイム、HybridOps Core をメンテナンスしている。その失敗時の挙動を固めるにあたり、回帰テストの経路を一つ追加した。古い destroyed マークを書き込み、新しい apply を開始し、部分的に失敗した provider エラーを返す、という流れだ。
期待される契約は厳格である。provider を呼び出す前に、状態が running を読めなければならない。失敗後は、状態が error を読み、それまでの出力を保持し、どのコマンドが失敗したかを示し、なおかつ destroy できなければならない。変更前の状態書き込みが失敗した場合に provider が実行されないことを確かめるテストも別にある。
実装とテストは partial-mutation change にある。このプロジェクトは実装の一つにすぎない。この契約は、リモートシステム間で状態を持つ変更を調整するあらゆる自動化層に当てはまる。
残す価値のある5つのルール
- 最初の provider 変更の前に、非終態を永続化する。
- ランタイムがその状態を永続化できないなら、変更を実行しない。
- 失敗後は、既知の出力と確定済みの復旧入力を保持する。
- 実行されなかった依存作業は failed ではなく pending として表示する。
- リソースが本当に存在しないと確認できてから、解体の完了を宣言する。
これらのルールはインフラの原子性を約束するものではない。もっと現実的なことをする。プロセスが何を試み、インフラが今何を含んでいる可能性があるかの間に、正直な境界線を引くのだ。
赤いパイプラインが証明するのは、実行が失敗に終わったことだけであり、環境がロールバックされたことではない。次の運用者は、provider コンソールと再構築の練習からではなく、永続化された状態と有効な復旧経路から始めるべきだ。