エンジニアリングブログ - Prexy:超高速の検索・置換ルール

当社の初のエンジニアリングブログへようこそ。他のブログに比べ、少し技術的な内容になっているかもしれませんが、どなたにも理解していただけるよう努めました。この記事では、 Clonable 内の新しいソフトウェアである「Prexy」について解説します。これは、置換ルールを適用するために使用されるものです。

背景

Clonable をご利用の方であれば、すでに「置換ルール」機能をご存じかもしれません。この検索・置換ルールを使用すると、テキストやコードの一部を、ご自身で選択した内容に置き換えることができます。例えば、APIキーやアナリティクスIDを置き換えることで、元のWebサイトと翻訳されたクローンサイトそれぞれについて、個別の分析を行うことが可能になります。 内部的には、これらの同じルールが、元のドメイン名をクローンのドメイン名に置き換える際や、その他いくつかの処理にも使用されています。

Clonable の最初のバージョン以来、この機能は一度も変更されていませんでしたが、それ自体は不思議なことではありません。なぜなら、十分にその役割を果たしていたからです。とはいえ、パフォーマンスや使いやすさの面では、改善の余地は確かにありました。私たちは時折、長い間手をつけられていなかった Clonable の機能を取り上げ、改善に取り組んでいます。 例えば昨年はデータインフラ全体を見直し、より高速でスケーラブルかつ耐障害性の高いシステムを実現するため、URL変換機能を一から再構築しました。今四半期は置換ルールの改善に取り組みました。

従来の状況と制約

Clonable では、置換ルールはクライアントへのレスポンスを返す直前の最終段階で適用されます。そのため、 Clonable の最初のバージョンでは、OpenRestyが開発したモジュール「replace-filter-nginx-module」を使用して、WebサーバーNGINX内でこれを実装することとしました。 このモジュールは、同じくOpenRestyによって開発されたsregexをストリーミング正規表現エンジンとして使用しています。このケースでは、すべてのレスポンスをメモリに完全に読み込みたくないため、ストリーミング処理が重要です。もしそうした場合、いくつかの大きなレスポンスによって、利用可能なメモリが不足し、NGINXがクラッシュしたりフリーズしたりする可能性があります。sregexの2つ目の重要な特徴は、複数のルールを並行して処理できることです。 各クローンには標準ルールが存在し、クローン固有のルールが追加される場合もあるためです。同時に処理できない場合、最初のルールの処理後に2番目のルールを処理するために再度レスポンスを走査する必要が生じ、結果としてレスポンス全体をバッファに格納しなければならなくなります。

しかし、昨年に入り、奇妙なパフォーマンスの問題が頻繁に発生するようになりました。これらの問題は多くの場合、短時間で解消されましたが、極端なケースではページの応答時間が数秒長くなることもありました。 こうしたピークが過ぎるとページは多くの場合通常通りに読み込まれるため、問題を再現するのは困難でした。これをさらにデバッグするためにすべてのレスポンスに「Clonable-Timings」ヘッダーを追加したところ、翻訳プロセスのどのステップがそれほど多くの時間を要していたかを大まかに特定できるようになりました。 その結果、ほぼすべてのケースでアップストリームの応答は迅速であり、翻訳自体も比較的スムーズに進んでいることが判明しました。しかし、翻訳の完了とリクエストの完全な完了との間にタイムラグがあり、これが置換ルールに関連している可能性を示唆する手がかりとなりました。

NGINXの並行処理モデル

しかし、これだけでは、なぜこうした遅延が散発的にしか発生しなかったのか、また、サーバーの負荷が容量の半分にも達していない状況でなぜこのような現象が起こったのかという疑問には、まだ答えが得られませんでした。この現象をより深く理解するためには、NGINXがワークロードをどのように処理しているのかを、もう少し詳しく見ていく必要があります。

NGINXは、1つのマスタープロセスと複数のワーカープロセスからなるワーカーアーキテクチャを採用しています。 接続はワーカー間で分散され、複数のリクエストを同時に処理できるようになっています。しかし、この構成には欠点もあります。あるワーカーがリクエストの処理で忙しくなると、その同じワーカーに割り当てられている他のリクエストは待機せざるを得なくなります。これにより、たとえ他のワーカーに何も処理がない場合でも、1つの負荷の高いリクエストが複数のリクエストの遅延を引き起こす可能性があります。 この現象は、以下の図でよくわかります。この図を作成している間、10の異なる接続に対してストレステストが実行されていました。図からは、3つのワーカーが処理に追われている一方で、4つ目のワーカーはほとんど何もしていないことがはっきりと見て取れます。

Prexyの登場です。

ボトルネックが明確に把握できたため、これを改善するための計画を立てました。このプロジェクトには、「Prexy」という名前が付けられました。これは、RegExとProxyを組み合わせた造語です。最終成果物には、以下の3つの主な要件がありました:

  1. 現在のセットアップの「ドロップイン・リプレースメント」となる必要があります。既存のセットアップで不具合が生じないよう、置換ルールは同じ方法で適用される必要があります。

  2. ソリューションが過剰なメモリを使用することを防ぐため、置換ルールはストリーミング方式で適用する必要があります。

  3. 新しいソリューションは、現在のソリューションよりも高速である必要があります。

まず最初に、リクエストを効率的に処理できる強力なマルチスレッドランタイムを探す必要がありました。いくつかの選択肢を検討した結果、プログラミング言語Rust用のTokioランタイムを採用することに決定しました。Rustは、C++などの他の低レベル言語に見られるような安全性のリスクを伴わずに、高速なプログラムを作成できることで知られています。 Tokioランタイムはネットワークアプリケーション向けに設計された柔軟な非同期ランタイムです。私たちにとって最も重要な機能の一つは、それが「ワークスティーリング」に対応しているという点です。これは、NGINXとは異なり、リクエストが1つのワーカーに固定されるのではなく、何も処理していないワーカーが他のワーカーから作業を「奪う」ことができることを意味します。これにより、サーバーのリソースをより効率的に活用できるようになります。

最初のプロトタイプ

基盤となる技術を選定した後、本プロジェクトによってどの程度の速度向上が見込めるか、またそもそも取り組む価値があるかを評価するため、最初のプロトタイプを構築することにしました。正規表現エンジン(実際にルールを処理・適用する部分)の最初の実装として、標準の正規表現ライブラリ(Rustの用語では「crate」と呼ばれます)を使用しました。 このエンジンはsregexと同様にノンバックトラッキング方式を採用しており、ReDoSの問題が発生するリスクが低くなります。 ReDoSが発生すると、正規表現は特定の入力に遭遇した際に、その入力を評価するのにかかる時間が指数関数的に増加してしまいます。これはパフォーマンスに壊滅的な影響を及ぼす可能性があり、Cloudflareではこの問題により 大規模な障害が発生したことがあります。したがって、私たちが使用するエンジンがノンバックトラッキングであることは非常に重要です。

この正規表現エンジンを用いて最初のプロトタイプを構築しました。このプロトタイプではハードコーディングされたルールを使用しており、正規表現エンジンはレスポンスをストリーミングするのではなくバッファリングしていましたが、最初のパフォーマンステストを行うには十分でした。 

この正規表現エンジンを用いて最初のプロトタイプを構築しました。このプロトタイプではハードコーディングされたルールを使用しており、正規表現エンジンはレスポンスをストリーミングするのではなく全体をバッファリングしていましたが、最初のパフォーマンステストを行うには十分でした。テスト環境は2台のVMで構成されていました。1台のVMでは旧ソリューションと新ソリューションの両方を実行できる環境を構築し、両方の手法を簡単に比較できるようにしました。 もう一方のVMでは、オリジンサーバーとして機能するNGINXがテストファイルを配信し、同時に負荷生成ツール「wrk」も実行していました。正確なデータを得るには負荷生成ツールを別のVMで実行するのが理想的ですが、本VMには十分なリソースがあり、NGINXとwrkが互いに干渉することはありませんでした。

最初のテスト結果は明白でした。Prexyは1つのリクエストの処理速度において、約22倍の速さを示しました(下の図を参照)。これほど大きな差があれば、機能の完成過程で発生しうるパフォーマンスの低下をある程度許容できる余地があるため、このプロジェクトは正式に承認されました。

最初のテスト実施後、コンパイル済みの正規表現キャッシュの導入など、いくつかの明らかな最適化を実施しました。さらに、アップストリーム接続の再利用を可能にするより効率的な方法を実装しました。これにより、約20%の速度向上を達成しました。 その後、ピーク負荷下でも両方のソリューションをテストし、サーバーに可能な限り多くのリクエストを送信しました。ここでもその差は明確に現れ、Prexyは従来のソリューションに比べて約25倍のスループットを達成しました。

ストリーミング正規表現エンジン

このプロジェクトの要件の一つは、使用する正規表現(regex)エンジンがストリーミング対応であることでした。プロトタイプの正規表現エンジンはストリーミング対応ではなかったため、この点に関しては追加の作業が必要でした。しかし、その正規表現エンジンは非常に最適化されていたため、私たちはこの実装をストリーミングエンジンの基盤として採用することにしました。データを正規表現エンジンにストリーミングする際、注意すべき点がいくつかあります。 まず、今後どのようなデータが送られてくるかは分かりません。そのため、部分一致(まだ完全には一致していませんが、すでに1文字以上が一致している状態)を扱う必要があります。完全一致が見つかった際には、重複する部分一致がないかを確認する必要があります。なぜなら、重複する部分一致は最終的に一致とみなされる可能性があり(重複が発生した場合、より長い一致がより短い一致に優先されるため)です。 したがって、その時点で存在するすべての部分一致が完全一致ではないことを確認したうえで、初めて当該一致を処理できます。

さらなる最適化

正規表現エンジンの再構築は、予想通り、Prexyのパフォーマンスに悪影響を及ぼしました。しかし、従来のソリューションと比較すると依然として十分な余裕があり、さらなる最適化を加えた結果、最終的には正規表現エンジンの再構築前よりも高いパフォーマンスを達成することができました。私たちが実施した最適化の一つは、特定のファイルに対してルールを適用すべきかどうかを記憶しておくことでした。 CSSやJSファイルなどの静的アセットはほとんど変更されません。したがって、そのファイルには一致しないルールを毎回実行しても意味がありません。 これに対する代替案として置換結果をキャッシュする方法もありましたが、この戦略の欠点はすべてのファイルを保存するために多くのメモリを消費してしまうことです。どのルールを適用すべきかを記憶することで、その情報をビットマップとして保存するため、ファイルあたりわずか数バイトしか必要としません。

さらに、置換処理で使用されないキャプチャグループは追跡しないなど、正規表現エンジン内の最適化機能を最大限に活用しています。これにより、正規表現エンジンはマッチ全体のはじめと終わりだけを記憶すればよく、処理負荷を軽減できます。また、ストリーミング方式ではかなり複雑だったため、名前付きキャプチャグループを無効にしました。旧ソリューションでもこれをサポートしていなかったため、必ずしも必要ではなかったからです。 これらの最適化により、最終的には次のようなパフォーマンスが実現されました:

最終成果物

Prexyを徹底的にテストし、その動作が従来のソリューションと確かに同じであることを確認した後、段階的な導入を開始しました。約1週間の期間をかけて、各クローンに対してPrexyを有効化していきました。 モニタリングでは、有効化のタイミングをはっきりと確認できました。レスポンス時間が約50%短縮されるケースも珍しくありませんでした。また、置換ルールがほとんどない他の顧客においても、約10%程度の大きな改善が見られました。当社のウェブサイトも、約30%高速化(約50ms)しました。

Prexyの導入後、当社のインフラ全体において、以下の違いが見られます。

  • RAM使用量が33%削減

  • CPU使用量が20%削減されます。

  • 応答速度10~50%向上

総じて、Prexyは成功したプロジェクトであったと言えます。旧式のNGINXモジュールを置き換えたことで、より新しく効率的な技術を活用できるようになり、すべてのお客様に明確かつ測定可能な違いをもたらしています。今後も、新機能の追加や既存機能の改善を通じて、 Clonableの改良に引き続き取り組んでまいります。


このエンジニアリングブログをお読みいただき、ありがとうございます。当社の製品に関するこのような技術的な裏話を、今後も頻繁に紹介してほしいという場合は、ぜひお知らせください。このプロジェクトにすっかり興味を持たれましたか? ぜひ、当社の求人情報ページもご覧ください。

今すぐ Clonable を無料で始めましょう

1つのプラットフォームから国際的なウェブサイトを集中管理する方法をご覧ください。

当社は、お客様の体験を向上させ、ウェブサイトのトラフィックを分析し、サービスを強化するためにクッキーを使用しています。

クッキーポリシーをご覧ください。