この記事の要点
• バルクヘッドパターンはリソースを隔離して障害の波及を防ぐ設計パターン
• カスケード障害の防止が最大の目的で、サーキットブレーカーとの併用が推奨
• セマフォ/スレッドプール/接続プール/セル分離など強度に応じた実装方式を選択する
バルクヘッドパターン(Bulkhead Pattern)は、船舶の船体が隔壁(bulkhead)で仕切られ、1区画が浸水しても船全体が沈没しないよう設計されていることに由来するソフトウェア設計パターンです。アプリケーションのリソースを隔離することで、1つの障害がシステム全体に波及することを防ぎます。本記事では、バルクヘッドパターンの原則から実装、ベストプラクティスまでを体系的に解説します。
概要
バルクヘッドパターンとは
バルクヘッドパターンは、障害分離(Fault Isolation)を実現するアーキテクチャパターンです。リソース(スレッド、コネクション、メモリなど)を論理的/物理的に分割し、ある区画で問題が発生しても他の区画に影響を与えないようにします。
flowchart TB
subgraph NoBulkhead["バルクヘッドなし"]
Client1["クライアント"]
SharedPool["共有スレッドプール<br/>(100 threads)"]
SvcA1["Service A<br/>(遅い)"]
SvcB1["Service B"]
SvcC1["Service C"]
Client1 --> SharedPool
SharedPool --> SvcA1
SharedPool --> SvcB1
SharedPool --> SvcC1
SvcA1 -. 全スレッド占有 .-> SharedPool
end
subgraph WithBulkhead["バルクヘッドあり"]
Client2["クライアント"]
PoolA["Pool A<br/>(30 threads)"]
PoolB["Pool B<br/>(40 threads)"]
PoolC["Pool C<br/>(30 threads)"]
SvcA2["Service A"]
SvcB2["Service B"]
SvcC2["Service C"]
Client2 --> PoolA --> SvcA2
Client2 --> PoolB --> SvcB2
Client2 --> PoolC --> SvcC2
end
style NoBulkhead fill:#fee
style WithBulkhead fill:#efe
なぜ必要か
分散システムでは「カスケード障害」が大きなリスクです。あるサービスの遅延や障害が、呼び出し元のリソースを枯渇させ、結果としてシステム全体がダウンする現象です。バルクヘッドパターンは、この連鎖的障害を防ぐ最も基本的な手段です。
原則・定義
基本原則
- リソース分離(Resource Isolation): コンシューマー別/機能別にリソースを分ける
- 失敗の局所化(Fail Locally): 障害を発生箇所に閉じ込める
- 独立スケール(Independent Scaling): 区画ごとに容量を調整可能にする
- 優雅な劣化(Graceful Degradation): 一部機能停止でもコアは動作する
- 明示的な上限(Explicit Limits): 各区画に明確な制限を設ける
バルクヘッドの種類
| 種類 | 説明 | 適用レイヤ |
|---|---|---|
| スレッドプール分離 | 呼び出し先ごとにスレッドを分離 | アプリケーション |
| セマフォ分離 | 同時実行数をセマフォで制限 | アプリケーション |
| プロセス分離 | 別プロセス/コンテナに分離 | インフラ |
| ノード分離 | 物理的に別ノードへ配置 | インフラ |
| セルベース分離 | 顧客/テナント単位のフルスタック分離 | アーキテクチャ |
構成要素
全体像
flowchart LR
Client["Client Request"]
Router["Request Router"]
subgraph Bulkhead1["Bulkhead: Critical"]
Q1["Queue (limit: 50)"]
T1["Threads (20)"]
DB1["DB Pool (10)"]
end
subgraph Bulkhead2["Bulkhead: Standard"]
Q2["Queue (limit: 100)"]
T2["Threads (30)"]
DB2["DB Pool (15)"]
end
subgraph Bulkhead3["Bulkhead: Batch"]
Q3["Queue (limit: 200)"]
T3["Threads (10)"]
DB3["DB Pool (5)"]
end
Client --> Router
Router --> Bulkhead1
Router --> Bulkhead2
Router --> Bulkhead3
構成要素の詳細
- Request Classifier: リクエストを優先度/種類で分類する
- Queue: 区画ごとのキュー。オーバーフロー時は拒否
- Worker Pool: 区画専用のワーカースレッド/ファイバー
- Resource Pool: DB接続、HTTPクライアントなど区画専用リソース
- Health Monitor: 区画ごとの健全性を監視
実装例
1. セマフォベースの実装(TypeScript)
class Semaphore {
private permits: number;
private queue: Array<() => void> = [];
constructor(permits: number) {
this.permits = permits;
}
async acquire(): Promise<void> {
if (this.permits > 0) {
this.permits--;
return;
}
return new Promise((resolve) => this.queue.push(resolve));
}
release(): void {
this.permits++;
const next = this.queue.shift();
if (next) {
this.permits--;
next();
}
}
}
class BulkheadExecutor {
constructor(
private readonly name: string,
private readonly semaphore: Semaphore,
private readonly maxQueueSize: number,
) {}
private pending = 0;
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.pending >= this.maxQueueSize) {
throw new BulkheadFullError(
`Bulkhead [${this.name}] is full: ${this.pending}/${this.maxQueueSize}`,
);
}
this.pending++;
try {
await this.semaphore.acquire();
try {
return await fn();
} finally {
this.semaphore.release();
}
} finally {
this.pending--;
}
}
}
class BulkheadFullError extends Error {}
// 使用例
const criticalBulkhead = new BulkheadExecutor("payment", new Semaphore(20), 50);
const standardBulkhead = new BulkheadExecutor("catalog", new Semaphore(50), 200);
async function processPayment(orderId: string) {
return criticalBulkhead.execute(() => paymentGateway.charge(orderId));
}
async function fetchCatalog() {
return standardBulkhead.execute(() => catalogService.list());
}
2. スレッドプール分離(Node.js Worker Threads)
import { Worker } from "node:worker_threads";
class WorkerPool {
private readonly workers: Worker[] = [];
private readonly queue: Array<{
task: unknown;
resolve: (v: unknown) => void;
reject: (e: Error) => void;
}> = [];
private readonly idleWorkers: Worker[] = [];
constructor(
private readonly scriptPath: string,
private readonly size: number,
) {
for (let i = 0; i < size; i++) {
const w = new Worker(scriptPath);
this.workers.push(w);
this.idleWorkers.push(w);
}
}
async run<T>(task: unknown): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.queue.push({ task, resolve: resolve as (v: unknown) => void, reject });
this.dispatch();
});
}
private dispatch() {
while (this.queue.length && this.idleWorkers.length) {
const worker = this.idleWorkers.pop()!;
const job = this.queue.shift()!;
const onMessage = (msg: unknown) => {
cleanup();
this.idleWorkers.push(worker);
job.resolve(msg);
this.dispatch();
};
const onError = (err: Error) => {
cleanup();
this.idleWorkers.push(worker);
job.reject(err);
this.dispatch();
};
const cleanup = () => {
worker.off("message", onMessage);
worker.off("error", onError);
};
worker.on("message", onMessage);
worker.on("error", onError);
worker.postMessage(job.task);
}
}
}
// 区画ごとにプールを分離
const imagePool = new WorkerPool("./image-worker.js", 4);
const pdfPool = new WorkerPool("./pdf-worker.js", 2);
3. HTTP クライアントの接続プール分離
import { Agent } from "node:https";
// サービスごとに Agent を分ける = 接続プールが独立
const userServiceAgent = new Agent({
maxSockets: 20,
keepAlive: true,
timeout: 5000,
});
const paymentServiceAgent = new Agent({
maxSockets: 10, // 決済は厳しく制限
keepAlive: true,
timeout: 10000,
});
async function callUserService(path: string) {
const res = await fetch(`https://user-service${path}`, {
// @ts-expect-error Node-specific option
agent: userServiceAgent,
});
return res.json();
}
async function callPaymentService(path: string, body: unknown) {
const res = await fetch(`https://payment-service${path}`, {
method: "POST",
// @ts-expect-error Node-specific option
agent: paymentServiceAgent,
body: JSON.stringify(body),
});
return res.json();
}
4. Kubernetes でのバルクヘッド(ResourceQuota + 名前空間)
apiVersion: v1
kind: Namespace
metadata:
name: critical-services
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: critical-quota
namespace: critical-services
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
pods: "50"
---
apiVersion: v1
kind: LimitRange
metadata:
name: critical-limits
namespace: critical-services
spec:
limits:
- default:
cpu: 500m
memory: 512Mi
defaultRequest:
cpu: 100m
memory: 128Mi
type: Container
5. セルベースアーキテクチャ(AWS風)
flowchart TB
DNS["Route 53 / Shuffle Sharding"]
DNS --> Cell1
DNS --> Cell2
DNS --> Cell3
subgraph Cell1["Cell 1 (Tenants A, B, C)"]
LB1["ALB"]
App1["App Tier"]
DB1["RDS"]
LB1 --> App1 --> DB1
end
subgraph Cell2["Cell 2 (Tenants D, E, F)"]
LB2["ALB"]
App2["App Tier"]
DB2["RDS"]
LB2 --> App2 --> DB2
end
subgraph Cell3["Cell 3 (Tenants G, H, I)"]
LB3["ALB"]
App3["App Tier"]
DB3["RDS"]
LB3 --> App3 --> DB3
end
各セルは完全に独立したフルスタックで、1セルの障害が他セルに波及しません。シャッフルシャーディング(Shuffle Sharding)により、特定テナントの問題が特定セルに固定されることを防ぎます。
メリット・デメリット
メリット
- カスケード障害の防止: 1サービス障害が全体停止を引き起こさない
- 可観測性の向上: 区画ごとのメトリクスで問題を特定しやすい
- 優先度制御: 重要処理に専用リソースを確保できる
- 独立スケーリング: 区画単位でキャパシティを調整できる
- セキュリティ境界: テナント間のリソース干渉を防ぐ
デメリット
- リソース使用率の低下: 各プールに余剰を持たせるため全体効率が下がる
- 複雑性の増加: 設定・チューニングすべきパラメータが増える
- 容量計画の難易度: 区画ごとに適切なサイズ見積もりが必要
- デッドロックの懸念: 区画間をまたぐ処理設計に注意が必要
- 過剰設計のリスク: 単純なシステムには不要
ユースケース
適した場面
- 決済処理: 他機能の障害に影響されず必ず実行させたい
- マルチテナントSaaS: テナント間の「ノイジーネイバー」防止
- BFF(Backend for Frontend): 下流サービスごとの分離
- バッチとオンラインの共存: バッチがオンラインAPIを圧迫しない
- 外部API統合: サードパーティAPIの遅延から自サービスを保護
適さない場面
- リクエスト数が非常に少ない小規模システム
- 同期処理が完全に1経路しか存在しないアプリ
- リソース制約が非常に厳しい組込み環境
注意: プールサイズの設定は勘ではなくリトルの法則(同時実行数 = スループット x レイテンシ)に基づいて計算し、負荷試験で検証してください。
落とし穴
1. プールサイズの誤設定
プールが小さすぎると健全なリクエストまで拒否され、大きすぎるとバルクヘッドの意味がなくなります。リトルの法則(Little’s Law)で 同時実行数 = スループット × レイテンシ を計算し、観測に基づいて調整しましょう。
2. キュー無限化
キューを無制限にすると、遅延が積み重なり結局OOMになります。必ず有限のキュー上限を設定してください。
3. 区画をまたぐトランザクション
1リクエストの中で複数の区画にまたがる処理は、デッドロックや相互枯渇の原因です。区画境界は処理フローの自然な切れ目に合わせましょう。
4. モニタリング不足
区画ごとの「拒否率」「キュー深さ」「平均待ち時間」を観測しないと、過剰/過少を判断できません。Prometheus等で区画別メトリクスを公開します。
5. シャッフルシャーディングなしのセル分割
単純な区画分割だけでは、1テナントの障害がそのテナントの属するセル内の全顧客に波及します。シャッフルシャーディングで分散させることで「影響範囲」をさらに狭められます。
比較表
他の耐障害性パターンとの比較
| パターン | 目的 | 仕組み | バルクヘッドとの関係 |
|---|---|---|---|
| バルクヘッド | 障害の隔離 | リソース分離 | 本記事のテーマ |
| サーキットブレーカー | 障害の拡散防止 | 呼び出し遮断 | 併用推奨 |
| リトライ | 一時障害の回復 | 再試行 | バルクヘッド内で実施 |
| タイムアウト | 待機時間の制限 | 時間上限 | 前提条件 |
| レートリミット | 過負荷防止 | 入口制限 | 上位層で連携 |
| フォールバック | 代替応答 | 縮退動作 | バルクヘッド枯渇時に発動 |
分離方式の比較
| 方式 | 強度 | コスト | 実装難度 |
|---|---|---|---|
| セマフォ | 低 | 低 | 易 |
| スレッドプール | 中 | 中 | 中 |
| プロセス分離 | 中高 | 中高 | 中 |
| コンテナ分離 | 高 | 高 | 中 |
| セル分離 | 最高 | 最高 | 難 |
実践メモ: バルクヘッドは単独ではなく、サーキットブレーカー・タイムアウト・フォールバックと組み合わせて使うことで最大の効果を発揮します。
ポイント: 完璧な分離より、重要なクリティカルパスを守る実用的な分離を目指しましょう。
ベストプラクティス
- サーキットブレーカーと併用: 拒否ではなくフェイルファストへ
- メトリクスを常に公開: 区画別の飽和度を Grafana で可視化
- 負荷試験で上限決定: 勘ではなく実測値でサイジング
- フォールバック応答を用意: 拒否時にも UX を損なわない応答
- タイムアウトを必ず設定: 区画外へ出る呼び出しには必須
- キューは必ず有限: バックプレッシャーを上流へ伝播させる
- セル境界=テナント境界: マルチテナントではセルで隔離
- カオステストで検証: 実際に障害注入し、分離が効くか確認
まとめ
バルクヘッドパターンは、分散システムで「一部の障害で全体が倒れない」ことを保証する基本パターンです。
- 本質: リソースを分離し、障害を局所化する
- 実装: セマフォ/スレッドプール/接続プール/セル分離
- 併用: サーキットブレーカー、タイムアウト、リトライ
- 計測: 区画ごとのメトリクスで継続的に調整
- 設計: 重要度と処理特性でバルクヘッドを切る
完璧な分離より、重要なクリティカルパスを守る実用的な分離を目指しましょう。
参考リソース
- Microsoft - Bulkhead pattern
- Martin Fowler - CircuitBreaker
- AWS Well-Architected - Reliability Pillar
- Netflix Tech Blog - Fault Tolerance in a High Volume, Distributed System
- Release It! 2nd Edition - Michael Nygard
- AWS Builders’ Library - Workload isolation using shuffle-sharding