未来を形作るテクノロジーの深掘り記事。

防御的エンジニアリング:壊滅的障害に耐えるシステム

本番障害を防ぐ防御的エンジニアリングのパターンを解説。モリーガード、ドライランモード、ソフトデリート, 確認ダイアログが役に立たない理由まで。

透明な蝶番式プラスチックカバーで守られた、鋼鉄パネル上の赤い非常ボタン

中規模のフィンテック企業で働くジュニアエンジニアが、金曜日の午後にデータベースのマイグレーションスクリプトを実行しました。このスクリプトは、ステージング環境で孤立したレコードを削除するはずのものでした。ところが実際には本番データベースに接続し、420万件の顧客取引レコードを削除してしまいました。バックアップはどうだったかというと、3日前のものでした。その後の72時間、会社は全面的なインシデント対応に追われ、決済代行業者のログから手作業でレコードを突き合わせ続けました。エンジニアの工数、顧客への補償、規制当局への報告を合わせると、総コストは40万ドルを超えました。

スクリプトには環境チェックがありませんでした。ドライランフラグもなく、接続先のデータベースを表示する確認プロンプトもありませんでした。接続文字列は環境変数から読み込まれていましたが、その変数は2週間前のデバッグ作業でエンジニアのノートPCが本番を指すように設定されたままになっていたのです。こうした失敗は、どれ一つとして防げないものではありませんでした。

「本当に実行しますか?」ダイアログが機能しない理由

ソフトウェアで最も一般的な安全機構は確認ダイアログです。そして、最も役に立たないものでもあります。確認疲れに関する研究では、同じプロンプトに数回遭遇すると、ユーザーはほぼ例外なく「本当に実行しますか?」を連打して進むことが示されています。プロンプトは見えなくなり、すでにやろうと決めた操作へ向かう途中の、ただの1クリックになってしまうのです。

問題は、確認という仕組みが悪い考えだということではありません。汎用的な確認が、情報をまったく含んでいないことが問題なのです。「このまま続行しますか?」では、何をしようとしているのかが分かりません。これと比べてみてください。「本番環境のprod-us-east-1にあるtransactionsテーブルから4,217,893行を削除しようとしています。確認のためデータベース名を入力してください。」後者なら、何が起きようとしているのかを実際に読まざるを得ません。これが単なる減速帯とモリーガードの違いです。

モリーガードとは何か、なぜ重要なのか

「モリーガード(molly guard)」という言葉は、メインフレームの大きな赤いボタンの上に取り付けられた物理的なプラスチックカバーに由来します。誤ってシャットダウンしないためのものです。プログラマーの幼い娘モリーが、そのボタンを何度も押してしまったことから名付けられたという逸話があります。ソフトウェアにおけるモリーガードとは、破壊的な操作を誤って実行しにくくしつつ、意図的であれば実行できるままにしておく仕組みすべてを指します。

Linuxパッケージのmolly-guardは、まさにこれを行います。SSHセッション上のshutdown、reboot、haltコマンドを横取りし、シャットダウンしようとしているマシンのホスト名を入力させます。惰性で通過することはできず、今どのマシン上にいるのかを確かめなければなりません。

$ sudo reboot
Good grief! You're on a remote SSH session to prod-web-03.
Please type the hostname to confirm: prod-web-03
# Compare with the useless version:
$ sudo reboot
Are you sure? (y/n): y
# *types y without reading, muscle memory*

原則はシンプルです。確認では、操作者がその操作を理解していることを証明する情報を求めるべきです。「y」と入力しても何も証明できません。ホスト名、テーブル名、影響を受けるレコード数を入力させれば、警告を読んだことが証明できます。

ドライランモード:撃つ前に確認する

破壊的な操作には、すべてドライランモードを用意するべきです。ベストプラクティスとして「できれば」という話ではありません。「用意しておけばよかった」と後で必ず後悔することになるからです。ドライランは操作の全ロジックを実行し、実際には何が起きるかをログに出力してから停止します。副作用はなく、全体を把握できます。

組み込みのドライランを持つマイグレーションスクリプトの例を示します。

#!/bin/bash
set -euo pipefail
DRY_RUN=${DRY_RUN:-true}  # Default to dry-run!
DB_HOST=$(get_db_host)
DB_NAME=$(get_db_name)
echo "Target: $DB_HOST / $DB_NAME"
echo "Mode: $([ "$DRY_RUN" = true ] && echo 'DRY RUN' || echo 'LIVE')"
ROWS=$(psql -h "$DB_HOST" -d "$DB_NAME" -t -c \
"SELECT count(*) FROM orphaned_records WHERE created_at < now() - interval '90 days'")
echo "Records to delete: $ROWS"
if [ "$DRY_RUN" = true ]; then
echo "Dry run complete. Set DRY_RUN=false to execute."
exit 0
fi
# Require explicit confirmation for live runs
read -p "Type '$DB_NAME' to confirm deletion: " CONFIRM
if [ "$CONFIRM" != "$DB_NAME" ]; then
echo "Aborted."
exit 1
fi
psql -h "$DB_HOST" -d "$DB_NAME" -c \
"DELETE FROM orphaned_records WHERE created_at < now() - interval '90 days'"
echo "Deleted $ROWS records."

注目すべき点は3つあります。デフォルトがドライランであること(破壊を行うにはオプトアウトではなく、明示的にオプトインする必要があります)、何かを実行する前に対象データベースと行数を表示すること、そして本番モードでもデータベース名の入力を求めることです。3重の防御です。このうちどれか1つでもあれば、冒頭で説明したインシデントは防げたはずです。

データベースマイグレーションの安全網

データベースマイグレーションは、どのデプロイパイプラインでも最もリスクの高い操作の一つです。多くの場合取り消しができず、共有の状態に対して実行され、一つ問題が起きればアプリケーション全体を停止させかねません。それでも多くのチームは、マイグレーションを単なるデプロイ工程の一つとして扱っています。

基本的なものから堅牢なものまで、安全機構の階層を示します。

  1. 環境チェック — マイグレーションスクリプトは、実行前に意図した環境を対象にしているかを検証します。当たり前に聞こえますが、これが欠けているケースの多さには驚かされます。
  2. マイグレーション前のスナップショット — マイグレーションを実行する前に、データベースのスナップショットを自動で作成します。失敗したり問題が起きたりしても、数時間ではなく数分で復元できます。
  3. 行数ガード — マイグレーションがN行を超えて影響する場合は、明示的な確認を要求します。予想外に数百万行に触れるマイグレーションは、ほぼ確実にバグです。
  4. ステートメントのタイムアウト — マイグレーションクエリには厳しめのタイムアウトを設定します。45分も走るマイグレーションはテーブルをロックし、性能を劣化させています。早めに失敗させましょう。
  5. 後方互換性の強制 — すべてのマイグレーションが、現在デプロイされているコードと後方互換であることを強制します。つまり、カラム名の変更、デフォルト値のないNOT NULLの追加、まだ参照されているカラムの削除はしない、ということです。
# Rails example using strong_migrations gem
class AddPhoneToUsers < ActiveRecord::Migration[7.1]
def change
# This will raise an error with a safe alternative:
# add_column :users, :phone, :string, null: false
#
# Instead, do it in two steps:
add_column :users, :phone, :string
# Then in a separate migration after backfill:
# change_column_null :users, :phone, false
end
end

Rails向けのstrong_migrations、PostgreSQL向けのsquawk、MySQL向けのskeemaのようなツールは、危険なパターンを本番到達前に検出してくれます。こうしたツールを使っていないなら、マイグレーションの微妙な問題をコードレビューに頼ることになりますが、レビュアーは見落とすものです。

ソフトデリートと取り消しの猶予期間

ハードデリートは、確信に満ちた一瞬の判断による永続的な約束です。問題は、その確信がしばしば間違っていることです。ソフトデリート、つまりレコードを実際には削除せず、削除済みとしてマークする方法は、ミスから回復するための猶予を与えてくれます。

-- Hard delete: gone forever
DELETE FROM projects WHERE id = 4872;
-- Soft delete: recoverable
UPDATE projects
SET deleted_at = now(),
deleted_by = 'user:priya'
WHERE id = 4872;
-- Recovery is trivial
UPDATE projects
SET deleted_at = NULL,
deleted_by = NULL
WHERE id = 4872;

トレードオフは現実にあります。ソフトデリートはクエリを複雑にし(至るところでWHERE deleted_at IS NULLが必要になります)、ストレージを増やし、データの実際の状態について混乱を招くこともあります。しかし、誤削除が起こりうるユーザー向けデータであれば、そのトレードオフには見合う価値があります。GitHubはリポジトリを90日間は実際には削除しません。Slackは、コンプライアンスのために削除されたメッセージを保持します。Gmailのゴミ箱は30日後に空になります。これらは偶然ではなく、意図的なエンジニアリング上の判断です。

同じ原則はインフラにも当てはまります。EC2インスタンスを終了する代わりに、まず停止します。データベースを削除する代わりに、mydb_deleted_20260315のように名前を変更し、2週間後に実際に削除するようカレンダーにリマインダーを設定します。停止したインスタンスや名前を変えたデータベースを数日残しておくコストは、バックアップから復元するコストに比べれば無視できるほど小さいものです。

デプロイの安全性:カナリア、サーキットブレーカー、ロールバック

デプロイも、多くのチームが十分な注意を払っていない破壊的操作の一つです。問題のあるデプロイは、ドロップされたデータベースと同じくらい効果的に本番を停止させ得ますし、しかもその頻度ははるかに高いのです。

最低限のデプロイ安全対策には、次のものが含まれます。

  • カナリアデプロイ — 新バージョンへのトラフィックを1〜5%に振り分けます。エラー率が跳ね上がったら、影響範囲が広がる前に自動でロールバックします。
  • 自動ロールバックのトリガー — エラー率、レイテンシ、ヘルスチェックの失敗に対するしきい値を定義し、自動ロールバックを発動させます。誰かが深夜2時に気づくのを期待してはいけません。
  • インシデント中のデプロイ凍結 — アクティブなインシデントが発生しているときは、すべてのデプロイをブロックします。火消しの最中に無関係な変更をデプロイされるのは、最も避けたい事態です。
  • ワンクリックのロールバック — ロールバックは、ロールフォワードよりも簡単であるべきです。ロールバックの手順がサーバーへのSSH接続と手動コマンドの実行を伴うなら、ロールバックの仕組みがあるとは言えません。
# Kubernetes progressive delivery with Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 5m }
- analysis:
templates:
- templateName: error-rate-check
- setWeight: 25
- pause: { duration: 5m }
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
# Auto-rollback if error rate exceeds 1%
abortScaleDownDelaySeconds: 30

ここでのパターンは段階的なコミットメントです。0から100%へ一気に移行するのではなく、小さく進め、それぞれを検証し、どの段階でも引き返せる状態を保ちます。全インスタンスに一斉にyoloデプロイするより時間はかかりますが、問題のあるデプロイを全ユーザーに届く前に食い止めた最初の日には、余分にかかった数分をありがたく思うはずです。

アーキテクチャによる予防:誤った操作を不可能にする

最良の安全機構は、慎重であることを求めません。誤った操作を構造的に不可能にします。これはガードレールと注意看板の違いです。

  • イミュータブルなインフラ — 本番サーバーにSSHできなければ、誤ってそこでコマンドを実行することもありません。デプロイが常に既知のイメージから作られた新しいインスタンスであれば、設定のドリフトも起こり得ません。
  • 最小権限のアクセス — エンジニアはノートPCに本番データベースの認証情報を持つべきではありません。例外はありません。監査証跡付きで一時的な認証情報を発行する、ジャストインタイムのアクセスツールを使いましょう。
  • 環境ごとに分けた認証情報 — ステージングと本番で異なる認証情報ストアを使っていれば、ステージング用のツールで誤って本番に接続することは文字どおり不可能になります。
  • 削除保護 — AWSでは、EC2インスタンスに終了保護を、RDSデータベースに削除保護を有効化できます。重要なものにはすべてこれを有効にしましょう。5秒で済む設定変更で、壊滅的なミスのひとつのカテゴリ全体を防げます。

1つのコマンドで本番を壊せてしまうのであれば、問題は人ではありません。1つのコマンドで本番を壊せてしまうシステムこそが問題なのです。

本番インシデントへの対応として、ドキュメントやチェックリストを増やし、研修を強化するチームを見てきました。それらも役には立ちますが、どれも人間が完璧であることに依存しています。人間は完璧ではありません。より良い対応は、そもそもミスが起きないようにシステムを変えること、そしてもしミスが起きても影響範囲を限定し、迅速に復旧できるようにすることです。

安全を最優先するエンジニアリング文化の構築

ツールやアーキテクチャは重要ですが、実際に導入されるかどうかを決めるのは文化です。安全機構を余計な手間や官僚主義とみなすチームは、締め切りのプレッシャーがかかると、それを省いてしまいます。しかも締め切りのプレッシャーは、常にあるものです。

私が見た中で最も効果的なパターンは、安全機構を「あれば良いもの」ではなく、第一級のエンジニアリング要件として扱うことです。すべての破壊的操作について、設計レビューで次のことを具体的に問います。誤った対象に対して実行されたらどうなるか? 2回実行されたらどうなるか? 古いデータで実行されたらどうなるか? どうやって元に戻すか?

非難しないポストモーテムは、今やごく当たり前のものです。ですが、私がもっと重要だと考える、あまり一般的でない習慣がプレモーテムです。リスクの高い変更をリリースする前に、チーム全員で集まり、こう問いかけます。「これが大失敗したと仮定しよう。何が起きたのか?」予測ではなく想像として問いを立てると、人は驚くほど的確に障害モードを挙げられます。そこで挙がった障害こそが、構築すべき安全機構になるのです。

本番データベースを削除してしまったあのジュニアエンジニアは、今も会社にいます。今ではチームで防御的エンジニアリングを最も強く推進する一人になっています。あのインシデントは本人の責任ではなく、システムの障害でした。そして今のシステムは、以前よりずっと壊しにくくなっています。