深夜23時、認証が消えた
とあるWebサービスで深夜23時15分、Redisがメモリ不足で応答停止しました。セッション管理がRedisに依存していたため、セッションチェック関数がエラーをキャッチして return true を返した。認証ミドルウェアはその true をtruthy判定で通した。結果、認証なしでAPIにアクセス可能になりました。
23時45分、ユーザーから「ログインしてないのに他人のデータが見える」と報告が入るまで、30分間気づかなかった。
この事故で壊れた防御層は4つです。
-
エラーハンドリング層: 例外時に
trueを返す設計 - 型チェック層: booleanとobjectを区別しない判定
- 監視層: 認証成功率の異常を検知していない
- フロントエンド層: サーバー認証に完全依存
どれか1つでも正しく作られていたら、事故は防げました。これが スイスチーズモデル の話です。
スイスチーズモデル -- 穴が重なった時にシステムは死ぬ
1990年、心理学者のJames Reasonが航空事故の分析から提唱したモデルです。防御システムをスイスチーズのスライスに見立てます。どのスライスにも穴がある。穴のないチーズは存在しません。でも、穴の位置がずれていれば、攻撃は途中で止まります。
防御層1 [○ ● ○] ← 入力バリデーション
防御層2 [● ○ ●] ← ビジネスロジック
防御層3 [○ ● ○] ← データベース制約
防御層4 [● ○ ●] ← 監視・アラート
●=穴(弱点)、○=防御
穴をゼロにすることはできない。穴が重ならないようにする。 これが多層防御の基本思想です。
航空業界の話を持ち出すと大げさに聞こえますが、冒頭のRedis事故はまさにこれです。エラーハンドリングの穴と型チェックの穴と監視の穴が一列に並んだ。単独では無害な穴が、組み合わさって重大事故になる。
Webアプリケーションの4層防御
スイスチーズモデルをWebアプリケーションに適用すると、典型的には4つの防御層に分かれます。
| 層 | 何を防ぐか | 具体例 |
|---|---|---|
| 入力バリデーション | 不正な入力 | 型チェック、範囲チェック、XSS対策 |
| ビジネスロジック | ルール違反 | 権限チェック、重複チェック、整合性検証 |
| データベース制約 | データ不整合 | NOT NULL、UNIQUE、外部キー、CHECK制約 |
| 監視・アラート | 異常の見逃し | エラーレート監視、認証成功率、レイテンシ閾値 |
ポイントは 同じバグを複数の層で検出できるようにする ことです。入力バリデーションで弾き損ねても、ビジネスロジックで止まる。ビジネスロジックが通しても、データベース制約が弾く。全部すり抜けても、監視が異常を検知する。
「どこか1つで止まればいい」は楽観的すぎます。冒頭の事故がそれを証明しています。
サーキットブレーカー -- 連鎖崩壊を止める防御パターン
多層防御の具体的な実装パターンとして、サーキットブレーカーを紹介します。電気回路のブレーカーと同じ発想で、障害が連鎖するのを防ぎます。
実際にPythonで実装して検証しました。
class CircuitBreaker:
def __init__(self, failure_threshold=3, recovery_timeout=30):
self.failure_count = 0
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.state = "CLOSED" # CLOSED=正常, OPEN=遮断, HALF_OPEN=試行
def call(self, func, fallback=None):
if self.state == "OPEN":
if time.time() - self.last_failure_time > self.recovery_timeout:
self.state = "HALF_OPEN"
elif fallback:
return fallback()
else:
raise CircuitOpenError("Circuit is open")
try:
result = func()
if self.state == "HALF_OPEN":
self.state = "CLOSED"
self.failure_count = 0
return result
except Exception:
self.failure_count += 1
self.last_failure_time = time.time()
if self.failure_count >= self.failure_threshold:
self.state = "OPEN"
if fallback:
return fallback()
raise
検証結果
| テスト | 期待動作 | 結果 |
|---|---|---|
| 正常系 | CLOSED、結果を返す | ✅ |
| 失敗3回 | CLOSED→OPEN | ✅ |
| OPEN状態でfallback | fallback値を返す | ✅ |
| OPEN状態でfallbackなし | CircuitOpenError | ✅ |
| recovery_timeout経過後 | HALF_OPEN→成功でCLOSED | ✅ |
冒頭のRedis事故をサーキットブレーカーで防ぐとこうなります。
redis_breaker = CircuitBreaker(failure_threshold=3, recovery_timeout=30)
def check_session(session_id):
def _get():
return redis_client.get(session_id)
result = redis_breaker.call(_get, fallback=lambda: None)
# ^^^^^^^^^^^^^^^^
# trueではなくNoneを返す。これで穴が1つ塞がる
return json.loads(result) if result else None
return true の代わりに fallbackで None を返す。None はfalsyなので、認証ミドルウェアで弾かれる。サーキットブレーカーという1つの層を追加するだけで、穴の重なりが断ち切れます。
明日から始める3つのこと
多層防御は大規模なリアーキテクトを必要としません。明日のPRから始められます。
1. エラーハンドリングで return true を書かない
例外時のデフォルト値は、安全側に倒す。認証なら false、データ取得なら null。「エラーだけど通す」は穴になります。
2. データベース制約を信頼する
アプリケーション層のバリデーションだけに頼らない。NOT NULL、UNIQUE、CHECK制約をデータベースに入れる。アプリが壊れても、データベースが最後の砦になります。テーブル定義を見直すだけなので、コード変更は不要です。
3. 認証成功率をモニタリングする
「認証成功率が95%を下回ったらアラート」。このルール1つで、冒頭の事故は23時15分に検知できました。30分も気づかない、ということはなくなります。
完璧な防御は不可能です。穴のないチーズは存在しません。でも、穴が重ならないように層を配置することはできる。1つの層が1つのPRで追加できるなら、今週中に4層全部揃えることも無理な話ではないはずです。
参考
- James Reason 「Managing the Risks of Organizational Accidents」(1997)
- Eric S. Raymond 「The Art of Unix Programming」 -- Rule 8: Robustness is the child of transparency and simplicity
- Microsoft 「Circuit Breaker pattern」(Azure Architecture Center)