バルクヘッド パターン: フォールト トレラントなマイクロサービスの設計
マイクロサービス アーキテクチャでは、単一のアプリケーションが数十または数百の独立した連携サービスに分割されます。この設計により、モジュール性とスケーラビリティが向上しますが、1 つのサービスで障害が発生すると連鎖的にシステム全体が停止する可能性があります。 という大きなリスクも伴います。
ダウンストリーム サービスが遅くなったり応答しなくなったりすると、アップストリーム サービスへの受信リクエストが蓄積され始めます。すべてが同じメモリ、CPU、またはスレッド プールを共有している場合、依存関係が遅いと利用可能なリソースがすぐに使い果たされ、アプリケーション全体がクラッシュする可能性があります。
この連鎖的な失敗はドミノ効果として知られています。これを防ぐために、システム設計者は バルクヘッド パターン を使用します。
このガイドでは、簡単な例え話、アーキテクチャ上の概念、Java (Resilience4j) と Go のコード例を使用して、バルクヘッド パターンとは何か、その仕組み、実装方法について説明します。
現実世界のたとえ: 船の防水隔壁
このパターンの名前は造船業界に由来しています。
隔壁 は、船の船体の内側に作られた水密壁です。船体の内部に単一の巨大なオープンスペースがあるのではなく、内部はいくつかの独立した密閉されたコンパートメントに分割されています。
船が障害物に衝突して船体が破損すると、損傷した区画に水が浸入します。ただし、水密隔壁のおかげで、水はその 1 つのコンパートメントに閉じ込められます。船の残りの部分は乾いていて浮力があるため、浮いたまま安全な場所に到達できます。
隔壁がなければ、水が船体全体を自由に流れ、最終的には船が沈没してしまいます。
ソフトウェアエンジニアリングでは:
- Ship はアプリケーションまたはサービス全体です。
- コンパートメントは、分離されたリソース プール (スレッド、接続、CPU) です。
- ハル違反 とは、下流のマイクロサービスでの障害または速度低下です。
- フラッディングはリソースの枯渇です。
問題: 共有リソース プールとスレッドの枯渇
なぜバルクヘッドが必要なのかを理解するために、リソースがグローバルに共有される場合に何が起こるかを見てみましょう。
ユーザーリクエストを処理する API ゲートウェイまたは Web サーバーを想像してください。すべての着信呼び出しを処理するための 100 スレッドからなる単一のグローバル スレッド プールがあります。サーバーは 3 つのダウンストリーム サービスと対話します。
- カタログ サービス (高速、製品リストの読み取り)
- 支払いサービス (迅速なチェックアウト処理)
- レコメンドサービス (遅い、パーソナライズされたアイテムを計算します)
通常、すべてが正常に動作します。しかし、レコメンデーション サービスがデータベースのデッドロックに悩まされ、応答に 200 ミリ秒ではなく 30 秒かかり始めたとします。
何が起こるかというと、次のとおりです。
- ユーザーは引き続きホームページにアクセスし、レコメンデーション サービスへのリクエストをトリガーします。
- サーバーは、グローバル プールから各リクエストにスレッドを割り当てます。
- レコメンデーション サービスが遅いため、これらのスレッドは応答を待ったままになります。
- 数秒以内に、プール内の 100 個のスレッドすべてが Recommendation Service を待機します。
- 新しいユーザーがカタログをチェックアウトまたは表示しようとすると、サーバーにはその要求を処理するためのスレッドが残っていません。
カタログ サービスと支払いサービスは完全に正常ですが、遅いレコメンデーション サービスが共有スレッド プールを使い果たしたため、現在はアクセスできません。システム全体がオフラインになりました。
解決策: バルクヘッド パターン
バルクヘッド パターン は、ある領域での障害が他の領域に影響しないようにリソース プールを分割することでこの問題を解決します。
単一のグローバル プールの代わりに、サービスまたはダウンストリームの依存関係ごとに個別の制限されたプールを割り当てます。
レコメンデーション サービス専用に 10 個のスレッドを割り当てると、最大 10 個のスレッドがそれを待機してブロックされる可能性があります。レコメンデーション サービスの速度が低下すると、これら 10 個のスレッドが枯渇し、後続のレコメンデーション リクエストは即座に拒否されます (フェイルファスト)。
ただし、残りの 90 スレッドはカタログおよび支払いサービス用に予約されています。レコメンデーション ウィジェットが一時的に利用できなくなった場合でも、ユーザーは製品を閲覧して購入することができます。
隔壁隔離の種類
ソフトウェア システムにバルクヘッドを実装するには、主に 2 つの方法があります。
1. スレッドプールの分離
このモデルでは、各ダウンストリーム依存関係に独自の専用スレッド プールと実行キューが割り当てられます。
- 仕組み: メイン アプリケーション スレッドは、タスクを特定のスレッド プールに渡します。プールがいっぱいの場合、リクエストはキューに入れられるか拒否されます。
- 長所: 完全な絶縁を提供します。サービスが遅くなった場合、そのスレッド プールのみが影響を受けます。スレッドはオペレーティング システム/JVM レベルで分離されます。
- 短所: スレッドのスケジューリング、コンテキストの切り替え、キュー管理により、余分な CPU オーバーヘッドが発生します。
2. セマフォの分離
セマフォ分離では、新しいスレッド プールを作成する代わりに、カウンター (セマフォ) を使用して、特定のサービスに許可される同時呼び出しの数を制限します。
- 仕組み: リクエストが開始されると、セマフォから許可を取得しようとします。許可が利用可能な場合、呼び出し側スレッドでリクエストを実行し、完了すると許可を解放します。利用可能な許可がない場合、要求は直ちに拒否されます。
- 長所: スレッド コンテキストの切り替えが関与しないため、実質的にオーバーヘッドがゼロで非常に軽量です。
- 短所: 糸の分離がありません。適切なタイムアウトを設定せずにネットワーク ソケットで呼び出しがブロックされた場合でも、呼び出しスレッドがブロックされる可能性があります。
実装例
2 つの一般的なバックエンド言語でバルクヘッドを実装する方法を見てみましょう。
1. Java (Resilience4j および Spring Boot)
Resilience4j は、Java 用に設計された軽量で使いやすいフォールト トレランス ライブラリです。以下は、Spring Boot アプリケーションでダウンストリーム支払いサービスのバルクヘッドを構成する方法です。
設定 (application.yml)
resilience4j.bulkhead:
instances:
paymentService:
maxConcurrentCalls: 10
maxWaitDuration: 10ms
resilience4j.threadpoolbulkhead:
instances:
paymentService:
maxThreadPoolSize: 10
coreThreadPoolSize: 5
queueCapacity: 20
コードの実装
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@Service
public class OrderService {
private final RestTemplate restTemplate;
public OrderService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// Apply semaphore bulkhead
@Bulkhead(name = "paymentService", fallbackMethod = "paymentFallback")
public String processPayment(OrderDetails details) {
return restTemplate.postForObject("http://payment-service/charge", details, String.class);
}
// Fallback method executed when the bulkhead is full
public String paymentFallback(OrderDetails details, Throwable throwable) {
return "Payment service is currently busy. Please try again later.";
}
}
2. Go (Golang)
Go では、言語がゴルーチンやバッファーされたチャネルなどのネイティブ同時実行プリミティブを提供するため、必ずしも重いフレームワークは必要ありません。バッファー付きチャネルを使用してクリーンなセマフォ バルクヘッドを実装できます。
package main
import (
"errors"
"fmt"
"net/http"
"time"
)
// Bulkhead represents a concurrency limiter
type Bulkhead struct {
semaphore chan struct{}
}
// NewBulkhead initializes a bulkhead with a max concurrency limit
func NewBulkhead(maxConcurrency int) *Bulkhead {
return &Bulkhead{
semaphore: make(chan struct{}, maxConcurrency),
}
}
// Execute runs the task if resource permit is available, otherwise returns error
func (b *Bulkhead) Execute(task func() error) error {
select {
case b.semaphore <- struct{}{}:
// Acquired permit
defer func() { <-b.semaphore }() // Release permit
return task()
default:
// Bulkhead is full, reject immediately
return errors.New("bulkhead is full: request rejected")
}
}
func main() {
// Allow maximum of 3 concurrent calls
paymentBulkhead := NewBulkhead(3)
mockTask := func() error {
fmt.Println("Processing payment...")
time.Sleep(2 * time.Second) // Simulate network delay
return nil
}
// Simulate 5 rapid requests
for i := 1; i <= 5; i++ {
go func(reqID int) {
err := paymentBulkhead.Execute(mockTask)
if err != nil {
fmt.Printf("Request %d failed: %v\n", reqID, err)
} else {
fmt.Printf("Request %d completed successfully\n", reqID)
}
}(i)
}
// Keep main alive to watch output
time.Sleep(3 * time.Second)
}
バルクヘッド パターンの一般的な使用例
バルクヘッド パターンの実装が重要な一般的なシナリオをいくつか示します。
- API ゲートウェイ ルーティング: さまざまなバックエンド サービスのルートを分離します。レコメンデーション サービスがダウンしても、ゲートウェイ上の注文サービス ルートは完全に動作し続けます。
- データベース接続プール: データベース接続プールをサービスまたはテナントごとに分割します。 1 つのテナントから大量の分析クエリが急増しても、使用可能な接続ハンドルがすべて使い果たされることはなく、他のテナントのトランザクション クエリが節約されます。
- マルチテナント SaaS アプリケーション: プレミアム テナントと無料テナントのコンピューティング リソースまたは実行キューを分離します。無料層のリソースが急増しても、プレミアム層の CPU やメモリのリクエストが枯渇することはありません。
- サードパーティ API 統合: 外部の支払いゲートウェイ、配送プロバイダー、または通知エンジン専用の別個の HTTP クライアント プール。 1 つのサードパーティ サービスの速度が低下しても、他の外部インタラクションはブロックされることなく続行されます。
Kafka/メッセージ ブローカーがバルクヘッド パターンを置き換えられない理由
よくある質問は次のとおりです: 「Apache Kafka のようなメッセージ ブローカーがあるのに、なぜバルクヘッド パターンが必要なのでしょうか? キューを使用してリクエストをバッファリングすることはできないのですか?」
メッセージ ブローカーはシステムを分離しますが、バルクヘッド パターンを置き換えることはできません。その理由は次のとおりです。
1. 同期通信と非同期通信
Kafka は 非同期、イベント駆動型 アーキテクチャ向けに設計されています。プロデューサはメッセージをトピックにプッシュし、最終的にコンシューマがそれを処理します。 ただし、ユーザー向けアプリケーションでは、同期 (リクエスト-レスポンス) 通信 (例: REST/gRPC API を介した製品カタログのロードやクレジット カードへのチャージなど) が必要になることがよくあります。ここで Kafka を導入するには、複雑な要求と応答のパターンが必要となり、高いレイテンシとオーバーヘッドが追加されます。バルクヘッドは、これらの同期実行スレッドをリアルタイムで保護するために特別に設計されています。
2. Kafka コンシューマー内のスレッド不足
システムが完全にイベント駆動型で Kafka を使用している場合でも、バルクヘッドは依然として必要です。
単一のコンシューマー マイクロサービスが複数の Kafka トピック (user-registrations や video-transcoding など) をリッスンするとします。コンシューマが、遅い video-transcoding ジョブの大規模なバッチを処理するためにすべての内部ワーカー スレッドを割り当てると、スレッド不足が発生します。コンシューマは、パーティションが正常であっても、軽量の user-registrations メッセージを処理できません。作業を分離するには、コンシューマ サービス内に内部バルクヘッド (個別のスレッド プール) が必要です。
3. クライアント側のオーバーヘッドとフェイルファストの要件
ダウンストリーム サービスがダウンすると、バルクヘッドにより呼び出し側サービスがフェイルファストしてフォールバック応答をすぐに返すことができます。代わりに Kafka ですべてをキューに入れると、キューが無限に増大し、リクエストが古くなり、メモリ消費量が多くなり、システムが回復するときにタイムアウトが遅延する可能性があります。
つまり、Kafka はネットワーク上のシステム間の通信を分離し、バルクヘッドは実行中のアプリケーション インスタンス内のリソースの実行を分離します。これらは相互に排他的ではなく、補完的なものです。
バルクヘッドを使用する場合のベスト プラクティス
- 常にタイムアウトを設定: バルクヘッドは同時実行を制限しますが、ソケット読み取りの遅さは解決しません。バルクヘッドと厳密なネットワーク タイムアウトを組み合わせて、できるだけ早くスレッドを解放します。
- サーキットブレーカーと組み合わせる: サーキットブレーカーと一緒にバルクヘッドを使用します。バルクヘッドが一貫してリクエストを拒否し始めた場合は、サーキット ブレーカーが作動してトラフィックを完全に停止し、下流のサービスに回復のための余地を与える必要があります。
- プールの飽和度を監視: バルクヘッド キューの長さとアクティブなスレッド数に関するアラートを実装します。バルクヘッドが常に満杯の場合は、インフラストラクチャを拡張するか、ダウンストリーム サービスを最適化する必要がある場合があります。
- サイズを個別に調整する: 単一のサイズ制限を使用しないでください。各依存関係のレイテンシとリクエスト レートを測定して、正しいバルクヘッド制限を決定します。
## 結論
バルクヘッド パターン は、回復力のあるクラウド スケール システムを構築するために不可欠な設計パターンです。リソースを分割することで、障害を隔離し、連鎖的な影響を防ぎ、局所的なバグが世界的な停止に発展しないようにします。