バルクヘッドパターン(Bulkhead Pattern)は、リソースを複数の区画へ分け、1つの処理の過負荷や障害がシステム全体へ広がることを防ぐ設計です。
船の内部を隔壁で分け、1区画が浸水しても他の区画を守る構造に由来します。
共有リソースが障害を広げる
外部の画像APIが遅くなった場面を考えます。画像APIと決済APIが同じ接続プールや同時実行枠を使っていると、遅い画像リクエストが枠を使い切り、正常な決済まで待たされます。
flowchart LR
subgraph Shared["共有Pool"]
A["画像API<br/>遅延"] --> P["接続・実行枠を消費"]
B["決済API"] --> P
end
P --> F["全機能が待つ"]
バルクヘッドでは、用途ごとに使える量を分けます。

画像API: 同時実行 20、待ち行列 40
決済API: 同時実行 10、待ち行列 10
画像用の区画が満杯になっても、決済用の10枠は残ります。重要なのは、障害そのものを消すことではなく、影響を越えさせない境界を作ることです。
何を分離するか
分離対象はスレッドだけではありません。
| リソース | 分離例 |
|---|---|
| 同時実行数 | 外部サービス別のSemaphore |
| 接続 | 接続先別のHTTP・DB connection pool |
| 待ち行列 | 機能別・優先度別のqueue |
| CPU・memory | container、process、worker |
| 保存容量 | tenant別quota |
| 配置 | cell、cluster、availability zone |
論理的なSemaphoreは小さく始めやすい一方、CPUやメモリの暴走までは隔離できません。process、container、cellの分離は強力ですが、運用とコストが増えます。守りたい障害に合う境界を選びます。
容量と待ち行列
区画を作るだけでは不十分です。各区画に次の上限を決めます。
- 同時に実行できる処理数
- 待ち行列へ入れられる件数
- 待てる時間
- 1リクエストが使えるメモリや接続数
待ち行列を無制限にすると、処理能力を超えたリクエストがメモリへたまり、最終的に全体を停止させます。上限へ達したら、早く失敗させる、後で再試行させる、機能を縮退させる、といった動作を決めます。
flowchart LR
R["Request"] --> C{"実行枠あり?"}
C -->|Yes| E["実行"]
C -->|No| Q{"Queueに空き?"}
Q -->|Yes| W["期限付きで待つ"]
Q -->|No| X["即時拒否・縮退"]
無制限に待たせるより、429 Too Many Requestsや503 Service Unavailableを返し、Retry-Afterを示すほうが回復しやすい場合があります。再試行する側には指数バックオフとjitterを入れ、同時再試行を避けます。
最小の同時実行制限
次は、外部処理を用途別のSemaphoreで囲む概念例です。
class Semaphore {
private active = 0;
private readonly waiters: Array<() => void> = [];
constructor(private readonly limit: number) {}
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.active >= this.limit) {
await new Promise<void>((resolve) => this.waiters.push(resolve));
}
this.active += 1;
try {
return await task();
} finally {
this.active -= 1;
this.waiters.shift()?.();
}
}
}
const imagePool = new Semaphore(20);
const paymentPool = new Semaphore(10);
この例の待ち行列には上限もtimeoutもないため、そのまま本番用にはできません。本番では次を追加します。
- queue件数の上限
- 待機timeoutと処理timeout
- キャンセルされたrequestの除去
- overload時の明確なerror
- active、queued、rejected、latencyのmetrics
ライブラリやクラウド機能に同等の仕組みがある場合は、自作よりもその制限・キャンセル・監視機能を確認して利用します。
容量を決める
区画のサイズは、均等に割ればよいとは限りません。次を使って決めます。
- 各処理の通常時とピーク時のリクエスト数
- 平均だけでなくp95・p99の処理時間
- 接続先が受け入れられる同時実行数
- 自システムが使えるCPU、memory、connection
- 重要機能へ最低限残す容量
たとえば、平均10msの処理と平均2秒の処理へ同じ枠数を与えると、後者が長くリソースを占有します。リクエスト数だけでなく、占有時間を含めて考えます。
上限が小さすぎれば正常なピークを拒否し、大きすぎれば隔離の意味が薄れます。最初から正解を決めるのではなく、負荷試験と本番メトリクスを見て調整します。
Failure Boundaryを選ぶ
何を同じ障害として扱うかによって、分離単位が変わります。
依存先ごと
画像API、決済API、メールAPIのように、遅延やrate limitが独立して起きる相手ごとに接続と同時実行数を分けます。
機能の重要度ごと
購入やログインなどの主要機能と、推薦・分析などの補助機能を分けます。過負荷時は補助機能を先に縮退し、主要機能の容量を守ります。
tenantごと
1社の大量処理が他社へ影響するSaaSでは、tenantごとのquotaや複数tenantを組み合わせたshuffle shardingを検討します。完全な専用環境より共有効率を保ちながら、影響範囲を小さくできます。
cellごと
利用者を複数の独立したcellへ分けると、DBやserviceを含む大きな障害範囲を限定できます。ただし、cell間のデータ移動、配置、容量の余白、運用が必要です。単純な同時実行制限が必要なだけなら過剰です。
他の耐障害性パターンとの違い
| パターン | 主な目的 |
|---|---|
| Bulkhead | 利用できるリソースを分け、影響範囲を限定する |
| Timeout | 終わらない処理を待ち続けない |
| Circuit Breaker | 失敗中の依存先への呼び出しを一時停止する |
| Rate Limit | 一定時間に受け付ける量を制限する |
| Retry | 一時的な失敗を再試行する |
これらは競合する選択肢ではなく、組み合わせます。Bulkheadだけでは遅い処理が枠を長く占有するためtimeoutが必要です。Retryだけを追加すると負荷を増やすため、上限やCircuit Breakerも検討します。
監視する指標
区画ごとに観測しないと、障害を隔離できたか判断できません。
- 実行中の処理数と使用率
- queue長と待ち時間
- timeout・拒否・失敗の件数
- 処理時間のp50・p95・p99
- 下流サービス別のエラー率
- 縮退処理が使われた回数
全体の平均だけでは、1区画の飽和を見逃します。「使用率が高い」だけで自動的に容量を増やすと、遅い依存先へさらに負荷を送ることがあります。遅延、エラー、下流の制限を合わせて原因を判断します。
向かない場合と注意点
小さなアプリで、すべての処理が同じ短いDBアクセスだけなら、細かなpool分割は容量を遊ばせ、設定と監視を増やす場合があります。
また、共有リソースが残っていれば、その地点で障害は再び合流します。別のworker poolでも、同じ小さなDB poolや同じmemory上限を共有していれば、完全には隔離できません。入口から依存先まで経路を追って境界を確認します。
区画をまたぐ同期トランザクションも、互いを待たせて独立性を弱めます。必要なら非同期連携や補償処理を検討しますが、整合性要件とのトレードオフがあります。
まとめ
- Bulkheadは、リソースを区画へ分けて障害の波及を止める
- 同時実行数だけでなく、queue、timeout、memory、connectionを制限する
- 容量は負荷、処理時間、下流の上限、重要度から決める
- 依存先、機能、tenant、cellなど、守りたいfailure boundaryで分ける
- Timeout、Circuit Breaker、Rate Limitと組み合わせる
- 区画ごとの使用率、待ち時間、拒否数を監視して調整する
参考リソース
- Microsoft - Bulkhead pattern
- AWS Builders’ Library - Workload isolation using shuffle-sharding
- Release It! 2nd Edition - Michael Nygard