障害伝播の抑制 ─ Bulkhead / Circuit Breaker / Backpressure
共通基盤は 多くの利用者を抱える。1 利用者が変な挙動をしたら、それが他利用者に伝染しない仕組みが必要。1 つの依存先が壊れたら、それが自分のサービスを倒さない仕組みが必要。
この章では Resilience pattern 5 つを整理する。Netflix が Hystrix で広めた領域。
障害伝播の構造
graph TB
subgraph "Naive な実装"
U[Users] --> P1[Platform]
P1 --> D1[Dependency<br/>これが死ぬと]
D1 -. 全 user に伝播 .-> U
end
subgraph "Resilient な実装"
U2[Users] --> P2[Platform<br/>+ Resilience patterns]
P2 -.制御.- D2[Dependency<br/>これが死んでも]
D2 -.遮断.-> P2
P2 -. 縮退で対応 .-> U2
end
style D1 fill:#ffe1e1
style D2 fill:#fff4e1
style P2 fill:#e1ffe1
パターン 1:Timeout
最も基本で、最も忘れられがち。
❌ Timeout なし
// 依存先が応答しない
const result = await dependentService.call();
// → 永遠に待つ、threads が枯渇、自分も死ぬ
依存先のフリーズが自分のフリーズに伝染する。
✅ Timeout 必須
const result = await Promise.race([
dependentService.call(),
timeout(100), // 100ms で諦める
]);
「全ての外部呼び出しに timeout を付ける」が鉄則。HTTP / DB / Redis / Queue 全部。
Timeout の階層
Client timeout : 5000ms (利用者から)
Service timeout : 4500ms (自分のサービス内)
DB timeout : 1000ms (DB 呼び出し)
Cache timeout : 50ms (Redis)
External API timeout : 2000ms
外側 > 内側 という階層関係を保つ。内側が外側より長いと意味がない。
パターン 2:Retry
一時的失敗(ネットワーク揺らぎ、瞬間的過負荷)には retry が効く。
Naive な retry の罠
// ❌ 即座に retry
for (let i = 0; i < 3; i++) {
try { return await call(); }
catch (e) { /* retry */ }
}
問題:依存先が過負荷のとき、retry が殺到して状況悪化(retry storm)。
✅ Exponential Backoff + Jitter
// 1st: 100ms 後
// 2nd: 200ms + ランダム
// 3rd: 400ms + ランダム
async function withRetry(fn, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try { return await fn(); }
catch (e) {
if (i === attempts - 1) throw e;
const delay = Math.pow(2, i) * 100 + Math.random() * 100;
await sleep(delay);
}
}
}
Jitter(ランダム化)が重要。全 client が同じタイミングで retry すると、依存先がまた倒れる。
Idempotency が前提
retry は idempotent な操作にのみ有効。第 5 章の Idempotency-Key を組み合わせて、副作用ある操作も safely retry できるようにする。
パターン 3:Circuit Breaker
依存先が継続的に壊れている時、呼び出し自体を諦める。
stateDiagram-v2
[*] --> Closed
Closed --> Open: 失敗が閾値超
Open --> HalfOpen: 一定時間経過
HalfOpen --> Closed: 成功
HalfOpen --> Open: 失敗
3 状態:
| 状態 | 挙動 |
|---|---|
| Closed | 通常通り呼び出し |
| Open | 呼び出さずに即座に失敗を返す |
| Half-Open | 試験的に少数の呼び出しを許可 |
実装イメージ
class CircuitBreaker {
private state: 'closed' | 'open' | 'half-open' = 'closed';
private failureCount = 0;
private openedAt: number | null = null;
async call(fn) {
if (this.state === 'open') {
if (Date.now() - this.openedAt > 30000) {
this.state = 'half-open';
} else {
throw new Error('Circuit Open');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (e) {
this.onFailure();
throw e;
}
}
private onFailure() {
this.failureCount++;
if (this.failureCount > 10) {
this.state = 'open';
this.openedAt = Date.now();
}
}
private onSuccess() {
this.failureCount = 0;
this.state = 'closed';
}
}
Netflix Hystrix → Resilience4J
Netflix Hystrix は 2018 年に maintenance mode に入り、後継として Resilience4J が広まった。
Hystrix の特徴:
- thread-based isolation
- complex configuration
- Java のみ、JVM 重い
Resilience4J の特徴:
- semaphore-based + thread-pool 選択可
- lightweight, functional API
- Modern Java、Kotlin 対応
各言語に 同等のライブラリ がある(go-resilience4j, polly for .NET, など)。
パターン 4:Bulkhead
リソースを区画化して、1 区画の沈没が船全体を沈めないようにする。船舶の Bulkhead(隔壁)の比喩。
graph TB
subgraph "❌ 全部共通"
P1[Pool: 100 threads]
P1 --> A[Tenant A 70 req]
P1 --> B[Tenant B 30 req]
Note1[A が 100 全部使って B が止まる]
end
subgraph "✅ Bulkhead"
Pa[Pool A: 50 threads]
Pb[Pool B: 50 threads]
Pa --> A2[Tenant A]
Pb --> B2[Tenant B]
Note2[A が暴走しても<br/>B には影響しない]
end
style Note1 fill:#ffe1e1
style Note2 fill:#e1ffe1
適用例
| リソース | 区画化単位 |
|---|---|
| Connection pool | tenant ごと、依存先ごと |
| Thread pool | priority ごと、依存先ごと |
| Memory | container / VM ごと |
| Disk I/O | tenant ごと(cgroups) |
Trade-off
全部共通: 利用率高 / 障害伝播あり
Bulkhead: 利用率下がる / 障害が局所化
→ Bulkhead は "保険" としてのコスト
利用率と隔離はトレードオフ。重要な依存先には Bulkhead、軽い依存には共通プール。
パターン 5:Backpressure
第 7 章で扱った 早期に 503 で守る 設計。Bulkhead と Backpressure はしばしば組み合わせる:
// Bulkhead + Backpressure
class TenantBulkhead {
private semaphore: Map<string, Semaphore> = new Map();
async execute(tenantId: string, fn) {
const sem = this.getOrCreate(tenantId);
// 待たずに即座に判定
if (!sem.tryAcquire()) {
throw new ServiceUnavailableError('Tenant rate limit');
}
try { return await fn(); }
finally { sem.release(); }
}
}
待つのではなく、即座に拒否することで queue が無限に伸びるのを防ぐ。
5 パターンの組み合わせ
graph LR
R[Request] --> T[Timeout]
T --> CB[Circuit Breaker]
CB --> BH[Bulkhead]
BH --> BP[Backpressure]
BP --> Retry[Retry]
Retry --> D[Dependency]
Note1[全部組み合わせて使う]
style D fill:#fff4e1
順序の意味:
- Timeout: 待ち時間の上限
- Circuit Breaker: 壊れた依存先を遮断
- Bulkhead: tenant / 依存先ごとに分離
- Backpressure: 過負荷時に早期拒否
- Retry: 一時的失敗のリカバリ
Graceful Degradation(縮退運転)
依存先が完全に壊れても、一部機能を犠牲にして サービスを続ける。
例:認証基盤の縮退
async function validateToken(token) {
try {
return await tokenService.validate(token); // 通常
} catch (e) {
// 通常 path 失敗 → 縮退
if (cb.isOpen()) {
// 古い cache でも通す
const cached = await cache.get(`token:${token}`);
if (cached && cached.expires > Date.now() - 3600000) {
return { ...cached, degraded: true }; // 1h 前まで OK とする
}
}
throw e;
}
}
完全に止まるのではなく、少し甘く判定する ことで利用者の体験を守る。
Trade-off
縮退運転には判断が要る:
- セキュリティ的に許容できるか
- どこまで甘くするか
- 利用者に通知すべきか
すべての機能で縮退できるわけではない。Critical path(決済等) は縮退禁止。
レジリエンスのチェックリスト
graph TB
Q1[全外部呼び出しに Timeout] --> Q2[Retry に Backoff + Jitter]
Q2 --> Q3[Idempotency 確認]
Q3 --> Q4[Circuit Breaker 配置]
Q4 --> Q5[Bulkhead で分離]
Q5 --> Q6[Backpressure で守る]
Q6 --> Q7[Graceful Degradation]
Q7 --> OK[完成]
style OK fill:#e1ffe1
この章の要点
- Timeout は鉄則、全外部呼び出しに付与、階層関係を保つ
- Retry は exponential backoff + jitter、idempotent 前提
- Circuit Breaker(Closed / Open / Half-Open)で壊れた依存を遮断
- Hystrix(2018 maintenance mode)→ Resilience4J が後継
- Bulkhead で tenant / 依存ごとにリソース区画化、利用率は下がるが隔離効果
- Backpressure で過負荷時に早期 503
- 5 パターンを組み合わせて使う
- Graceful Degradation で完全停止を避ける、ただし critical path は禁止
次章への問いかけ
単一の障害は局所化できた。だが 特定の tenant が他を倒す 形の障害は、もう一段の対策が要る。
次章で マルチテナンシー ── Hot tenant、Quota、Bulkhead の組織化。