LINEヤフー Tech Blog

LINEヤフー株式会社のサービスを支える、技術・開発文化を発信しています。

プライベートクラウドにおけるService Mesh as a ServiceのCircuit Breaker機能開発(インターンレポート)

こんにちは。大阪大学情報科学研究科修士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に移行します。

circuit-breaker-fsm

circuit-breaker-sequence

このモデルは、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_headerSession::finish_bodyを利用してHTTP 503のレスポンスを返すようにしました。この修正によってCircuit Breaker発動時のレイテンシが大幅に改善し、Circuit Breaker導入前と同等のパフォーマンスを達成できました。

ベンチマーク

Circuit Breaker機能を追加しても通常時の性能に悪影響が出ないことを確認する目的でベンチマークを実施しました。測定では常にHTTP 500を返す上流サーバーを用意し、負荷試験ツールVegetaを利用して一定のRPSで60秒間負荷を与えました。Circuit Breaker機能を有効化したビルド(with Circuit Breaker)と、無効化したビルド(Baseline)を比較しました。機能を有効化したビルドでは、Circuit BreakerをClosedの状態に固定しました。

以下の図はレイテンシの累積分布(CDF)を示しています。両者の曲線はほぼ重なっており、Circuit Breakerのロジックを実装しても通常時の性能は大きく劣化していないと判断しました。

Figure 1

また、Circuit BreakerがOpen状態およびHalf-Open状態に移行した場合の挙動も確認しました。機能を有効化したビルドでCircuit BreakerをOpen状態およびHalf-Open状態に固定し、無効化したビルドと比較しました。

以下のそれぞれの図は、Open状態の比較とHalf-Open状態の比較を示しています。どちらもBaselineの結果は図1と同じものです。BaselineではすべてのリクエストがUpstreamに到達してから500を返すため、応答はmsオーダーの遅延を伴います。一方、Circuit Breakerを有効にすると一部のリクエストが即座に503で返され、1ms未満で応答していることがわかります。これは想定通りの結果です。

Figure 2

Figure 3

まとめ

この取り組みでは、Service Mesh as a Serviceの一部として提供するプロキシにCircuit Breaker機能を追加しました。Service Meshのデータプレーンにはどのような機能が必要か検討し、状態遷移モデルに基づいた設計を行いました。実装の工夫により通常時の性能劣化を抑えつつ、障害時には即座にレスポンスを返す挙動が実現されました。

おわりに

今回のインターンを通じて、LINEヤフーが統合を進めるプライベートクラウドの重要なコンポーネント開発に携われたことは、とても貴重な経験でした。RustやPingoraは優れた特徴を持っているものの、現場ではまだ比較的採用事例の少ない技術です。そういった技術を用いて開発に取り組めたことも大きな学びとなりました。また、曖昧な要求から具体的な設計へ落とし込むプロセスや、本番環境に近い形で多くのトラフィックを想定したベンチマークに挑戦できたことも印象的でした。

一方で、開発を進める上では苦労もありました。特にPingoraは新しいライブラリであることもあり、ドキュメントが比較的少なく、実装中に想定外の挙動に直面した際にはソースコードを読み解きながら解決する必要がありました。これは大変であると同時に、基盤技術を深く理解する良い機会でもあったと感じています。

働き方の面では、オフィス出社時にはチームメンバーと密にコミュニケーションを取ることができ、リモートワークでは集中して作業に取り組めるという、それぞれの利点を活かしながら進めることができました。メンターをはじめ、チームの皆さんからのサポートにも大いに助けられ、安心して課題に挑戦することができました。ありがとうございました。

メンターからの一言

こんにちは。今回、石森さんのインターンシップのメンターを務めさせていただきました清水です。

今回のインターンシップでは、内製のL7プロキシに一つ大きな機能を追加するというチャレンジングなタスクに取り組んでいただきました。このタスクは、マイクロサービスアーキテクチャで構成される分散システムやHTTP/TCPのネットワーキングに関する深い知識に加え、Rustの非同期プログラミングに関わる深い知識や性能劣化を起こさないためのコーディングテクニック、ドキュメントが限られているOSSソフトウェアの理解能力が要求される難易度の高いものだったと思いますが、6週間という限られた期間だったにも関わらず、想定を超えた素晴らしい成果を出していただきました。

また、今回はただ実装するだけではなく、実際のユースケースから仕様を検討するところまで一貫して担当していただきましたが、そのすべての工程を高いレベルでこなせており、プロのソフトウェアエンジニアとして求められる能力を存分に発揮していただけたと感じております。私としても、プロとして十分に活躍できるほどのインターン生のメンターを担当できたことは、非常に貴重な経験となりました。ありがとうございました。