ファンアウトシステムとしてのElixir
- Discordでは、メッセージが送信されたり誰かがボイスチャンネルに参加したりするなど、何かが起きるたびに、同じサーバー(「ギルド」とも呼ばれる)にいるオンラインの全ユーザーのクライアントでUIを更新する必要がある
- そのサーバーで起こるすべての出来事の中央ルーティング地点として、ギルドごとに1つのElixirプロセスを使い、接続された各ユーザーのクライアントごとに別のプロセス(「セッション」)を使う
- ギルドプロセスは、そのギルドのメンバーであるユーザーのセッションを追跡し、それらのセッションへ作業をファンアウトする役割を担う
- セッションが更新を受け取ると、WebSocket接続を通じてクライアントへ渡す
- ある作業はサーバー内の全員に適用される一方、別の作業では権限確認が必要なため、そのサーバーのロールやチャンネルの情報だけでなく、ユーザーのロールも把握している必要がある
- ギルドのアクティビティ量はそのサーバーの人数に比例し、1つのメッセージをファンアウトするのに必要な作業量もそのサーバーのオンラインユーザー数に比例する
- つまり、Discordサーバーを処理するために必要な作業量は、サーバー規模に応じて4乗で増加する
- 1つのサーバーに1,000人がオンラインで、全員が1回ずつ「ゼリーが好き」と発言したなら、100万件の通知を処理しなければならないことになる
- 10,000人なら1億件の通知が発生し、10万人なら100億件の通知を配信しなければならない
- 全体的なスループットの問題に加え、サーバーが大きくなるほど一部の作業の速度が遅くなることがある
- メッセージが送信されたら他の人がすぐ見られる必要があり、誰かがボイスチャンネルに参加したらすぐ参加を始められる必要があるなど、サーバーが高い応答性を持っていると感じてもらうには、ほぼすべての作業を高速に処理できなければならない
- コストの高い作業の処理に数秒かかると、ユーザー体験は低下する
- こうした問題があるにもかかわらず、会員数1,000万人超、そのうち100万人が常時オンラインのMidjourneyサーバーをどう支えられたのか?
- まず重要だったのは、システムの性能を把握することだった
- データを確保した後、スループットと応答性の両方を改善できる機会を探した
システム性能を理解する
- Wall time analysis:
Process.info(pid, :current_stacktrace) を使ったスタックトレース
- イベント処理ループを計測し、各種類のメッセージ受信数と、それを処理するのにかかった最大/最小/平均/合計時間を記録
- 全体時間の1%未満しか占めない作業は、極端に暴走している場合を除いてすべて無視
- 安価な作業を除外し、最もコストの高い作業を強調
- Process Heap Memory Analysis
- メモリをどう使っているかを理解することも重要
- すべての要素を1つずつ見る代わりに、大きな(構造体ではない)マップとリストをサンプリングして、推定メモリ使用量を生成するヘルパーライブラリを作成
- このライブラリはGC性能の理解に役立っただけでなく、最適化のために注力する価値のあるフィールドと、最終的には無関係なフィールドを見つけるのにも有用だった
- ギルドプロセスがどこで時間を使っているのか把握した後、ギルドプロセスが100%忙しい状態にならないようにする戦略を立てられるようになった
- 場合によっては、非効率な実装をより効率的に書き直すだけで十分だった
- ただし、この方法だけでは限界があり、より根本的な変更が必要だった
パッシブセッション - 不要な作業を避ける
- スループットのボトルネックを解消する最良の方法の1つは、作業を減らすこと
- そのための1つの方法は、クライアントアプリケーションの要件を考慮すること
- 元のトポロジーでは、すべてのユーザーが自分の所属するすべてのギルドで見えるすべての行動を受信していた
- しかし、一部のユーザーは複数のギルドに所属しており、ギルドによっては何が起きているか確認するためにクリックすらしないこともある
- ユーザーがクリックするまで何も送らないとしたらどうだろうか? すべてのメッセージについて個別に権限確認をする必要がなくなり、その結果クライアントへ送るデータ量も大幅に減る
- これを「Passive」接続と呼び、すべてのデータを受信する必要がある「Active」接続とは別のリストで管理した
- その結果、大規模サーバーではユーザー-ギルド接続の約90%がパッシブ接続だったため、ファンアウト作業のコストを90%削減できた
- これである程度余裕は生まれたが、コミュニティが成長し続けるにつれて、当然これだけでは不十分だった
(作業量が10分の1に減ると、最大規模のコミュニティでは約3倍の利得が得られる)
リレー - 複数マシンにまたがってファンアウトを分割する
- 単一コアのスループット限界を拡張するための標準的な手法の1つは、作業を複数スレッド(Elixirの用語ではプロセス)に分割すること
- このアイデアに基づき、ギルドとユーザーセッションの間に「リレー」というシステムを構築
- セッション処理の作業を1つのプロセスですべて処理する代わりに、複数のリレーへ分割することで、単一ギルドがより多くのリソースを使って大規模コミュニティにサービス提供できるようになった
- 一部の作業は依然としてメインのギルドプロセスで行う必要があるが、これによって数十万人のメンバーを抱えるコミュニティを処理できるようになった
- これを実装するには、リレーで行うべき重要な作業、ギルドで行うべき作業、両システムで実行できる作業を識別する必要があった
- 必要なものを把握した後、システム間で共有できるロジックを抽出するためのリファクタリングに着手
- たとえば、ファンアウトの実行方法に関するロジックの大部分は、ギルドとリレーの両方で使うライブラリへリファクタリングした
- こうして共有できない一部のロジックには別の解決策が必要で、ボイス状態管理は基本的にリレーが最小限の変更だけで全メッセージをギルドへプロキシする方式で実装した
- 最初にリレーをリリースした際の興味深い設計判断の1つは、各リレーの状態に完全なメンバー一覧を含めることだった
- 必要なメンバー情報をすべて利用できるという意味で、単純さの面では良い判断だった
- しかし、数百万人規模のメンバーを持つMidjourneyのスケールでは、この設計は次第に意味を失い始めた
- 数千万人分のメンバー情報を数十個のコピーとしてRAMに保持するだけでなく、新しいリレーを作るたびに全メンバー情報をシリアライズして新リレーへ送る必要があり、その結果ギルドが数十秒遅延する問題が発生した
- この問題を解決するため、リレーが実際に動作するのに必要なメンバーを識別するロジックを追加したが、それは全メンバーのごく一部にすぎなかった
サーバーの応答性を維持する
- スループット限界内に収めることに加えて、サーバーの応答性も維持する必要があった
- ここでもタイミングデータを見ることが有用だった
- 総継続時間よりも、呼び出し1回あたりの継続時間が長い作業に注目するほうが効果的だった
- ワーカープロセス + ETS
- 応答性低下の最大要因の1つは、ギルド内で実行され、全メンバーを反復処理しなければならない作業だった
- こうしたケースは非常にまれだが、実際に発生する。たとえば、誰かが
@everyone のような全員宛てメンションを行うと、そのメッセージを見られるサーバー内の全員を把握しなければならない
- しかし、そのような確認作業には数秒かかることがある。これをどう処理すればよいだろうか?
- ギルドが他の作業を処理している間にこのロジックを実行するのが理想だが、Elixirプロセスはメモリ共有が得意ではない。そのため別の解決策が必要だった
- プロセスが共有可能なメモリにデータを保存するために使えるErlang/Elixirのツールの1つがETS
- これは複数のElixirプロセスが安全にアクセスできる機能を備えたインメモリデータベース
- プロセスヒープ上のデータにアクセスするより効率は落ちるが、それでも非常に高速。さらに、プロセスヒープのサイズを小さくしてGCレイテンシを減らせる利点もある
- メンバー一覧を保持するために、ハイブリッド構造を作ることにした:
- 他のプロセスからも読めるようメンバー一覧をETSに保存しつつ、最近の変更(挿入、更新、削除)もプロセスヒープに保存する
- ほとんどのメンバーは常に更新されるわけではないため、最近の変更集合は全メンバー集合のごく小さな一部にすぎない
- これでETS上のメンバー情報を使ってワーカープロセスを生成し、高コストな作業があるときに処理対象のETSテーブル識別子を渡せるようになった
- ワーカープロセスは、ギルドが他の作業を続けている間に高コスト部分を処理できる。この実装方法の簡単な例も参照されている(原文にコードスニペットあり)
- この方法の一例は、ギルドプロセスをあるマシンから別のマシンへ移す必要があるとき(通常はメンテナンスやデプロイのため)
- この過程では、新しいマシン上でギルドを処理する新プロセスを作成し、旧ギルドプロセスの状態を新プロセスへコピーし、接続中の全セッションを新ギルドプロセスへ再接続し、その作業中に積み上がったバックログを処理しなければならない
- ワーカープロセスを使えば、既存のギルドプロセスが動き続ける間に、メンバーの大部分(数GBのデータになることもある)を転送できるため、ハンドオフのたびに数分単位で発生していた遅延を減らせる
- マニフォールドのオフロード
- 応答性を改善し、スループット限界を打ち破るための別のアイデアは、マニフォールドを拡張して、ギルドプロセスでファンアウトする代わりに、別の「送信者」プロセスを使って受信ノード側でファンアウトを行うことだった
- これによりギルドプロセスの作業量を減らせるだけでなく、ギルドとリレーの間のネットワーク接続の1つが一時的に詰まった場合でも、BEAMのバックプレッシャーから保護できる(BEAMはElixirコードが動作する仮想マシン)
- 理論上は簡単に解決できそうに見えたが、残念ながらこの機能(マニフォールドオフロード)を試してみると、実際には性能が大きく低下することが分かった
- なぜそんなことが起きるのか? 理論上は作業量が減るのに、なぜプロセスはより忙しくなったのか?
- 詳しく調べると、追加作業の大半がGCに関連していることが分かった
- ここで
erlang.trace 関数が救世主のように登場した
- この関数により、ギルドプロセスがGCを実行するたびにデータを収集できるようになり、GCがどれくらい頻繁に発生しているかだけでなく、何がGCを引き起こしたのかについての洞察も得られた
- こうしたトレース情報を基にBEAMのGCコードを調べた結果、マニフォールドオフロードが有効なとき、メジャー(フル)GCのトリガー条件が仮想バイナリヒープであることが分かった
- 仮想バイナリヒープは、プロセスがGCを実行する必要がない場合でも、プロセスヒープ内に保存されていない文字列が使うメモリを解放できるよう設計された機能
- 残念ながら、私たちの利用パターンでは、数百KBのメモリを回収するために、数GB規模のヒープをコピーする代償を払ってGCが繰り返しトリガーされており、明らかに見合わないトレードオフになっていた
- 幸い、BEAMではプロセスフラグ
min_bin_vheap_size を使ってこの挙動を調整できる
- この値を数MBに引き上げると、病的なGC挙動は消え、マニフォールドオフロードを有効にした状態で性能が大きく向上するのを確認できた
9件のコメント
Elixir最高
パッシブセッションは技術的にはそれほど大したものではありませんが、良いアイデアだと思います。
確実に負荷を削減できそうですね。
Discordだけでなく他のところでもこうした機能を実装しているはずですが、サービスごとにどのような違いがあるのか気になります。
すごくかっこいいですね……
最近話題のnextjsのstreaming ssrの行き着く先も、ElixirのPhoenixフレームワークなんですね。いろいろな意味で、Elixirは現代プログラミング言語の最前線にあるようです。
Elixir、頑張れ
数年前にDiscordの技術ブログを参考にしてリアルタイムサービスにElixirを導入することになり、開発速度や安全性の面で私をはじめ担当役員まで非常に満足のいく形でサービスをリリースできたので、良い思い出がたくさんあります。
Elixirがもっと人気になってほしいです
最近は、いわゆる「ネカラ」もそこまでではない気がして、むしろ中小スタートアップのほうがSpring一色のようです。そうしたスタートアップのマネージャーはたいていSpringの専門家なので、仕方ないですね。
あらゆる非効率は、お金と規模で解決すればいいのです。会社はどうせよく分かっていないのですから。