今日のゴール
分散システムにおける障害検知と復旧の仕組みを理解し、可用性の高いシステムを構築する方法を学びます。
はじめに
分散システムでは、障害は「起こるかもしれないもの」ではなく「必ず起こるもの」として設計する必要があります。ネットワークの断絶、サーバーのクラッシュ、ディスク障害など、様々な障害が日常的に発生します。
nindaは、これらの障害に対して自動的に検知・復旧する機能を提供しています。本日は、障害に強いシステムを構築するための戦略を学びましょう。
障害の種類
分散システムで発生する主な障害は3種類あります。
障害タイプの比較
| 障害タイプ | 例 | 検知難易度 | nindaの対応 |
|---|---|---|---|
| クラッシュ障害 | プロセス停止、マシンダウン | 容易 | ハートビート監視 |
| ネットワーク障害 | 分断、遅延、パケットロス | 中程度 | タイムアウト、クォーラム |
| ビザンチン障害 | データ改ざん、不正応答 | 困難 | 対象外(信頼ネットワーク前提) |
なぜビザンチン障害に対応しないのか
ビザンチン障害への対応は複雑で、通常3f+1ノード(fは障害ノード数)が必要になります。nindaは信頼できる内部ネットワークでの利用を想定しており、クラッシュ障害とネットワーク障害への対応に集中しています。
Supervisor による障害管理
nindaのSupervisorモジュールは、Erlang/OTPスタイルの再起動戦略を提供します。
再起動戦略の選択
| 戦略 | 動作 | ユースケース |
|---|---|---|
rsOneForOne |
障害プロセスのみ再起動 | 独立したワーカー群 |
rsOneForAll |
全子プロセスを再起動 | 相互依存するプロセス |
rsRestForOne |
障害以降のプロセスを再起動 | 順序依存のパイプライン |
Supervisorの使い方
let supervisor = newSupervisor(
strategy = rsOneForOne,
intensity = RestartIntensity(maxRestarts: 3, withinMs: 60_000)
)
ポイント: intensityは再起動の頻度を制限します。60秒間に3回以上再起動すると、無限ループを防ぐためにSupervisor自体が停止します。
タプルスペースによる障害検知
nindaの特徴は、タプルスペース自体を監視の基盤として使うことです。
ハートビート監視の仕組み
核心となるコード
# ワーカー側: ハートビート送信
let now = getTime().toUnix() * 1000
await heartbeats.writeAsync(
toTuple(strVal("heartbeat"), strVal(workerId), intVal(now))
)
# モニター側: タイムアウト検知
for hb in await heartbeats.readAllAsync(pattern):
if now - hb[2].intVal > TIMEOUT_MS:
echo "Worker ", hb[1].strVal, " is dead!"
このアプローチの利点:
- タプルスペースが監視の基盤 → 分散環境で自然に動作
- ハートビートがタプルとして可視化 → デバッグが容易
- カスタム監視ロジックの追加が簡単
SupervisedSystem: 統合された監視
SupervisedSystemは、SupervisorとMonitorを統合した使いやすいAPIです。
使い方
let sys = newSupervisedSystem(manager, defaultSupervisedConfig())
sys.addWorker(SupervisedWorker(
id: "worker-1",
task: myWorkerProc,
restart: rpAlways
))
await sys.start()
ハートビート送信は自動化されるため、ワーカーはビジネスロジックに集中できます。
ネットワーク障害への対応
スプリットブレインとは
ネットワーク分断により、クラスタが2つ以上のグループに分かれ、それぞれが独立してリーダーを選出してしまう状態です。
対策: クォーラム(定足数)
過半数の合意がなければ書き込みを拒否するというシンプルなルールで、スプリットブレインを防ぎます。
| ノード数 | クォーラムサイズ | 許容障害数 |
|---|---|---|
| 3 | 2 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
💡 なぜ奇数ノード? 偶数だと分断時に同サイズのパーティションができ、どちらもクォーラムを満たせなくなる可能性があります。
障害復旧パターン
分散システムでよく使われる3つの復旧パターンを紹介します。
1. サーキットブレーカー
連続した失敗を検知すると、一時的にリクエストを遮断して障害の連鎖を防ぎます。
いつ使う? 外部APIやデータベースへの呼び出しを保護する場合
2. 指数バックオフ
失敗するたびに待機時間を指数的に増やします。
試行1: 100ms 待機
試行2: 200ms 待機
試行3: 400ms 待機
試行4: 800ms 待機
...
ポイント: ジッター(ランダム要素)を加えることで、複数クライアントが同時にリトライする「雷撃群問題」を避けられます。
3. バルクヘッド(隔壁)
リソースを分離して、1つのサービスの障害が全体に波及するのを防ぎます。
var bulkheads = {
"database": newBulkhead(maxConcurrent = 10),
"external-api": newBulkhead(maxConcurrent = 5)
}.toTable
船の隔壁(bulkhead)のように、一区画が浸水しても船全体は沈まないようにする設計です。
グレースフルデグラデーション
障害時に完全停止するのではなく、機能を縮退させてサービスを継続する戦略です。
縮退レベルの設計例
| レベル | 状態 | 無効化される機能 |
|---|---|---|
| Normal | 通常運用 | なし |
| Partial | 一部制限 | 分析、通知 |
| Minimal | 最小限 | 検索、バルク操作も無効 |
| Maintenance | メンテナンス | 読み取り専用 |
フォールバック戦略
フルテキスト検索 → 失敗
↓
簡易検索 → 失敗
↓
キャッシュから返す → 失敗
↓
空の結果を返す(エラーではない)
重要: ユーザーにとって「遅い」や「機能制限」は許容できても、「エラー画面」は許容できないことが多いです。
設計原則
障害に強いシステムを設計するための原則をまとめます。
1. 障害を前提とした設計
❌ 悪い考え方: 「障害が起きたらどうしよう」
✅ 良い考え方: 「障害は必ず起きる。どう対処するか」
2. 検知・隔離・復旧のサイクル
このサイクルを自動化することが重要です。
3. 部分障害でもサービス継続
全体の1%のリクエストが失敗しても、99%は成功させる。これが分散システムの基本姿勢です。
本日のまとめ
学んだこと
| トピック | ポイント |
|---|---|
| 障害の種類 | クラッシュ、ネットワーク、ビザンチンの3種類 |
| Supervisor | 再起動戦略と強度制限 |
| ハートビート監視 | タプルスペースを監視基盤として活用 |
| スプリットブレイン対策 | クォーラムによる過半数合意 |
| 復旧パターン | サーキットブレーカー、指数バックオフ、バルクヘッド |
| グレースフルデグラデーション | 機能縮退によるサービス継続 |
設計原則
- 障害は必ず起こるものとして設計する
- 検知・隔離・復旧のサイクルを自動化する
- 部分障害でもサービスを継続できるようにする
- 監視とアラートで早期発見を心がける
演習問題
問題22-1: サーキットブレーカー
サーキットブレーカーを実装し、外部APIへの呼び出しを保護するシステムを作成してください。状態遷移(Closed → Open → HalfOpen)を正しく実装しましょう。
問題22-2: ヘルスチェックAPI
タプルスペースの状態を監視するヘルスチェックAPIを実装してください。応答時間、エラー率、ディスク使用量などを確認できるようにしましょう。
問題22-3: 自動フェイルオーバー
プライマリノードが障害を起こした際に、自動的にセカンダリへフェイルオーバーするシステムを構築してください。
💡 完全な実装例は GitHubリポジトリ の
src/ninda/actor/を参照してください。
次回は、パフォーマンスチューニングについて学びます。システムの性能を最大限に引き出す方法を探りましょう。