こんにちは。大阪大学情報科学研究科修士1年の石森大路です。私はサービスインフラグループCloud Infrastructure本部のインタ ーン生として、プライベートクラウドで提供しているService Mesh as a ServiceのCircuit Breaker機能開発に取り組みました。本記事では、背景、設計方針、実装の工夫、検証結果をまとめます。
プライベートクラウドにおけるService Mesh as a Service
私たちのチームでは、LINEヤフーのプライベートクラウド上で提供するService Mesh as a Serviceを開発しています。LINEヤフーでは、多くのサービスがLINEとYahoo! JAPANのそれぞれで開発されていた2つのプライベートクラウドに収容されています。現在は会社合併に伴い、両方のサービスを収容できる新しいプライベートクラウドを開発中です。LINEヤフーが提供するサービスの多くはマイクロサービスの組み合わせで構築されており、マイクロサービス間の通信管理にService Meshを利用しているユーザーも一定数存在します。そのため、プライベートクラウドとしてマネージドなService Mesh as a Serviceを提供することを予定しており、開発を進めています。
その一環として、私たちのチームではService Meshのデータプレーンを内製しています。一般的にはEnvoyがサイドカーコンテナとして担うプロキシの役割を、私たちは自社開発のソフトウェアで提供することにしました。私のインターン課題であるCircuit Breaker機能について説明する前に、なぜこのようなソフトウェアを内製しているのか、その背景に触れておきます。
私たちが内製を選んだ主な理由は、機能の取捨選択が可能である点です。Envoyは豊富な機能を備えていますが、今回の要件で必要とするのはその一部に限られます。大規模なコードベースはコード全体の理 解やデバッグを難しくします。サイドカープロキシはその性質上、ユーザーのアプリケーションに最も近いところで動作します。社内の多くのアプリケーションで使用されることから、我々が当初想定していないような通信により問題が発生する可能性も高くなります。そのため、コード理解の難易度が高いことは障害対応の大きなリスクとなります。内製で必要な機能だけに集中すれば、シンプルで把握しやすいコードベースを維持でき、トラブル対応も容易になります。
さらに、バージョン更新のコントロールもしやすくなります。Envoyはおよそ3か月ごとに新しいリリースがあり、大量の変更によって性能特性が変わることもあります。全社で運用するプロキシを高頻度で更新するのは難しく、更新のたびに性能劣化のリスクを伴います。内製プロキシであれば、必要な修正やセキュリティ対応に応じて更新のタイミングを調整でき、各機能追加による性能変化も追いやすくなります。
このデータプレーンの実装では、安全性と性能を両立できるRustを採用しました。EnvoyはC++で実装されており高性能ですが、安全なコードを記述するのに非常に高いスキルが要求されます。Rustであれば性能と安全性を両立できると考えました。
ロードバランサ機能の実装には、CloudflareのPingoraフレームワークを採用しています。Pingoraは、HTTP通信のハンドリングなどプロキシを構築する上で普遍的な機能をライブラリとして提供しつつ、主要なビジネスロジックをユーザー自身が記述することを可能としたフレームワークです。Cloudflareのインフラにおいて大規模な使用実績があり、十分に成熟したライブラリであるといえます。
背景と目的
マイクロサービスアーキテクチャを採用したシステムでは、あるサービスの局所的な不調が他のサービスを含むシステム全体の連鎖的な障害に発展するリスクを考慮する必要があります。こういった連鎖的な障害の間接的な原因として、サービス間通信における待機時間の増大やそれに起因するスレッド占有、コネクション資源の枯渇が挙げられます。
このような問題を未然に防ぐには、Circuit Breakerパターンが有効です。Circuit Breakerはサービス間の通信に介入し、あるサービスの健全性が低下した際にそのサービスへのリクエストを一時的に遮断します。遮断中は、呼び出し元のサービスはタイムアウト等を待つことなく、即座にエラーを受け取ることになります。Circuit Breakerは障害の波及を防ぎ、システム全体の安定性を保つ役割を果たします。
今回のインターンでは、私たちが内製しているService MeshのデータプレーンにCircuit Breaker機能を追加することを目的としました。
Circuit Breakerの設計方針
Circuit Breakerという機能は多くのソフトウェアに実装されていますが、実際に各ソフトウェアで提供されている機能は一様ではありません。特に通信を遮断する条件や、回復過程におけるリクエストの流量制御はソフトウェアの果たすべき役割によって異なります。Circuit Breakerという用語には幅があるため、今回の開発ではいくつかのオープンソースソフトウェア(OSS)を参考にしなが ら私たちが必要としている機能を明確に定義しました。
私たちの実装では、サービス単位で失敗率を観測し、一定のしきい値を超えた場合にリクエストを遮断する方式を採用しました。これはリソースの枯渇や無駄な待機を防ぐことを主眼に置いた設計です。また、完全に遮断し続けるのではなく、定期的に一部のリクエストを通して回復状況を確認できるようにし、障害が解消されれば自動的に復帰するようにしました。この設計により、利用者に影響を与えすぎずに障害の波及を抑えられることを狙っています。
具体的には、Circuit BreakerはClosed、Open、Half-Openの三つの状態を持つ有限状態機械として表現されます。通常時はClosed状態で、クライアントからのすべてのリクエストをUpstreamに転送します。この間、Upstreamからのレスポンスを監視し失敗率を記録します。一定期間内の失敗率が閾値を超えるとOpen状態へ遷移します。Open状態ではすべてのリクエストに対してHTTP 503エラーを返し、Upstreamに転送しません。その後、一定時間が経過するとHalf-Openに移行します。Half-Open状態では一定割合のリクエストだけをUpstreamに転送します。一定期間経過後に失敗率を再評価し、十分に回復していればClosedに戻ります。そうでなければ再びOpenに移行します。
このモデルは、LINEヤフーが主に開発しているOSSのマイクロサービスフレームワークArmeriaに実装されているCircuit Breakerと近い考え方です。Envoyにも名称が類似するCircuit Breaking機能がありますが、こちらはリクエスト数や接続数に上限を設けるための機能で、リクエストの失敗率に基づく判定は行いません。EnvoyのCircuit Breaking機能は私たちが求めていたものとは異なっていたため、主にArmeriaのアプローチを参考にしています。ただし、障害検知の基準設定やエラーの分類にはEnvoyのOutlier Detectionの仕組みも参考にしました。
本実装で「失敗」と数える事象は、HTTP 5xx、429などのアプリケーション層のステータスに加え、タイムアウトや接続リセットといったトランスポート層のエラーを含みます。これらは設定で調整でき、サービスごとの特性に合わせて柔軟に運用できます。
実装上の工夫
コアロジックの分離
Circuit BreakerのコアロジックはPingoraやTokioに依存しない形で実装しました。このモジュールの公開インターフェイスの概要は以下のようになっています。
impl CircuitBreaker {
fn new(cfg: CircuitBreakerConfig) -> Self;
/// Circuit Breakerを開始し、スケジューリングを初期化する。
/// 状態更新処理 (handle_deadline_exceeded) を呼び出すべき時刻を返す。
fn start(&self, now: Instant) -> NextDeadline;
/// リクエストを通すか拒否するかを判定する。
fn acquire(&self) -> Admission;
/// Upstreamからのレスポンスを記録する。
fn record(&self, ticket: AdmissionTicket, event: UpstreamEvent);
/// 状態更新処理を行う。
/// 次にこの関数を呼び出すべき時刻を返す。
fn handle_deadline_exceeded(&self, now: Instant) -> NextDeadline;
}
struct NextDeadline(Instant);
/// リクエストを通すか拒否するかの判定結果。
enum Admission {
Admit { ticket: AdmissionTicket },
Reject,
}
/// Upstreamからのレスポンス
enum UpstreamEvent {
/// HTTPのステータスコード
Status(u16),
/// トランスポート層でのエラー
Transport,
}
状態遷移を扱う有限状態機械(Machine)を独立したモジュールとして切り出すことで、単体テストが容易になり、テストの網羅性を高めることができました。その上で、HTTPを扱うPingoraやタイマーを扱うTokioと接続するための層を別途実装し、リクエストやレスポンスの処理フローの中で有限状態機械を呼び出す仕組みにしています。
なお、Circuit BreakerとPingoraやTokioを接続するための層は、当初はPingoraのModule機能を利用して実装する構想でした。しかし実装を進める中で、PingoraのModule機能ではネットワークエラーやタイムアウトなどのアプリケーション層以外のエラーを捕捉することが難しそうなことがわかりました。そのため、最終的にはModule機能を使わず、リクエストやレスポンス処理のタイミングで明示的にCircuit Breakerのフックを呼び出す方式に変更しました。これにより、エラーハンドリングの抜け漏れを避けつつ、責務を適切に分離することができました。
RwLockとAtomic変数による状態管理
パフォーマンス面では、Circuit Breakerの状態全体をRwLockで保護しつつ、レスポンスの失敗率を測定するためのカウンタはアトミック変数で保持するようにしました。Circuit Breakerの状態にアクセスする処理は、リクエストの転送可否判定、レスポンスの記録、時間経過による状態遷移の3つです。このうちリクエスト・レスポンス処理は毎秒数千回の高頻度で発生しますが、状態遷移は数秒に一度しか発生しません。状態遷移では状態全体に変更を加えますが 、リクエスト・レスポンス処理ではカウンタをインクリメントするのみです。そこで、高頻度で発生するリクエスト・レスポンス処理には、読み取りロックのみを用いることで同時に実行できるようにしました。低頻度で発生する状態遷移中には、書き込みロックを用いて他の状態更新をブロックします。シンプルな実装で高頻度のリクエストに対応できることを意図してこのような設計を行いました。
struct CircuitBreaker {
cfg: CircuitBreakerConfig,
state: RwLock<State>,
}
struct State {
phase: CircuitPhase, // One of Closed, Open, Half-Open
success: AtomicU64,
failure: AtomicU64,
}
リクエスト拒否時のパフォーマンス問題
初期の実装で後述するベンチマークを実行したところ、Circuit Breaker発動時、すなわち失敗率増加によりリクエストを拒否しHTTP 503を返している状態でレイテンシが悪化していることがわかりました。この状態では、同RPS(Requests Per Second)でもタイムアウトやConnection Refusedが発生しやすい傾向がみられました。調査の過程でPingoraのソースコードを確認したところ、エラーを返すために利用していたSession::write_error_response
メソッドがKeep-Aliveを無効化してしまうことがわかりました。このメソッドはプロキシからエラーレスポンスを返す際に利用する汎用的な機能です。エラーが発生した際には、その原因によってはKeep-Aliveを無効化してConnectionを閉じることが妥当な場合もあります。しかしCircuit Breakerが返す503エラーについてはConnectionを再利用しても問題ありません。そこで、代わりにSession::write_response_header