DiscordエンジニアがElixirのアクターモデルに性能劣化なしで分散トレーシングを追加
Discordのエンジニアは、Elixirのアクターモデルにパフォーマンスペナルティなしで分散トレーシングを追加し、カスタムTransportライブラリと動的サンプリングで大規模ユーザーファンアウトを処理した。
キーポイント
パフォーマンスペナルティなしの分散トレーシング実装
DiscordがElixirのアクターモデルに分散トレーシングを追加し、CPU最適化により10%以上のオーバーヘッドを回復した。
カスタムTransportライブラリの開発
トレースコンテキストでメッセージをラップする独自のTransportライブラリを開発し、動的サンプリングを活用した。
大規模スケール対応の最適化手法
サンプリングされないトレースのスキップやデシリアライゼーション前のコンテキストフィルタリングにより、100万人規模のユーザーファンアウトを効率的に処理できる。
影響分析・編集コメントを表示
影響分析
この技術は、Elixir/Erlangエコシステムにおける大規模分散システムの監視可能性を向上させ、特にリアルタイム通信プラットフォームの運用改善に貢献する。パフォーマンスペナルティなしでの実装は、同様の課題に直面する他の企業にも参考になる実践的な知見を提供している。
編集コメント
技術的な詳細が限定的ではあるが、大規模プラットフォームでの実践的な最適化手法として参考になる。特にパフォーマンスと監視性の両立という課題に対する具体的な解決策を示している点が価値がある。
Discord のエンジニアチームは、数百万人の同時接続ユーザーを処理しながら、Elixir インフラストラクチャに分散トレーシングを追加した詳細を発表しました。同チームは、Elixir のメッセージ渡送システムをトレースコンテキストでラップするカスタム「Transport」ライブラリを開発し、アクターベースアーキテクチャの計装における根本的な課題を解決しました。
HTTP ベースのマイクロサービスではトレースコンテキストがヘッダーを介して伝播しますが、Elixir のアクターモデルではプロセス間で任意のメッセージが渡され、組み込みのメタデータ層が存在しません。Discord はチャットインフラストラクチャ全体にエンドツーエンドの可視性を必要としていましたが、課題がありました。OpenTelemetry の標準的なトレーシングは個々のサービス内では機能しますが、Elixir プロセス間でのコンテキスト伝播には対応できていなかったのです。
チームは、あらゆる解決策に対して 3 つの要件を特定しました。開発者が採用しやすいほど使いやすく(ergonomic)、生メッセージと GenServer の抽象化の両方をサポートし、本番環境の全フラットでダウンタイムゼロでのデプロイを可能にすることです。
Discord の解決策は、トレースコンテキストでメッセージをラップする「Envelope」プリミティブを導入しました。実装は一見単純に見えますが、元のメッセージとシリアライズされたトレースキャリアを含む構造体です:
defmodule Discord.Transport.Envelope do
defstruct [:message, trace_carrier: []]
def wrap_message(message) do
%__MODULE__{
message: message,
trace_carrier: :otel_propagator_text_map.inject([])
}
end
end
このライブラリは、GenServer の call および cast 関数に対する差し替え可能な実装を提供し、送信されるメッセージを自動的にラップします。受信側では、handle_message 関数が従来の裸のメッセージと新しい Envelope で囲まれたメッセージの両方を正規化し、存在する場合はトレースコンテキストを抽出して処理後にクリーンアップします。
この正規化はロールアウト時に極めて重要でした。Discord は一夜にしてすべてのメッセージ転送を変更したり、すべてのノードを同時に更新したりすることはできませんでした。このライブラリは、計測済みコードと非計測済みコードの両方からのメッセージを処理するため、サービス再起動なしで段階的な移行を可能にします。
Discord のアーキテクチャは、独自のスケーリング課題を生み出しています。ユーザーが 100 万人のオンラインメンバーを持つギルドにメッセージを送信した場合、その単一のトレース済み操作は、セッションごとに 1 つずつ子スパンを生成し、メッセージを各クライアントへ転送する可能性があります。
チームはファンアウトサイズに基づいた動的サンプリングを実装しました。単一の受信者に送信されるメッセージは、100% の確率でサンプリング決定が維持されます。100 人の受信者に拡散されるメッセージではサンプリング率が 10% に低下し、10,000 人以上の受信者に対してはセッションのわずか 0.1% がスパンをキャプチャします。このアプローチにより、観測可能性インフラストラクチャを圧迫することなく、有用なトレースデータを維持しています。
(出典: Discord ブログ投稿)
初期のデプロイにより、Discord が想定していなかったトレーシングのオーバーヘッドが明らかになりました。数百万人のメンバーを抱える最も活発なギルドでは、アクティビティに対応しきれない状況が発生しました。プロファイリングの結果、99% 以上の操作でサンプリングが行われていない場合でも、プロセスが trace context(トレースコンテキスト)の展開に多くの時間を費やしていることが示されました。
解決策は、サンプリング対象の操作に対してのみ trace context を伝播させることです。サンプリングされないトレースでは、エンベロープ内にコンテキストを含めないようにし、シリアライゼーションとパースのコストを削減しました。これは従来のヘッドサンプリングのセマンティクスをわずかに変更するものですが、CPU のスパイクを完全に排除することに成功しています。
2 つ目の最適化は、セッションサービスに焦点を当てたものでした。ここでは、ファンアウト(一対多配信)中にスパン(span:処理区間)を取得している際に CPU 使用率が 10 ポイント増加していました。Discord では、ファンアウトされたメッセージを受信した後、セッションが新しいトレースを開始することを禁止しました。セッションは既存のトレースを引き続き継続できますが、独立してサンプリングを決定することはできません。この単一の変更により、ほぼすべてのオーバーヘッドが回復し、CPU 使用率は 55% から 45% に低下しました。
最も劇的な最適化は、gRPC リクエスト処理の分析から導き出されました。Elixir サービスを Discord の Python API にリンクする際、リクエスト処理時間の 75% が trace context の展開に費やされていました。チームは、完全なデシリアライゼーションを行わずに、エンコードされた trace context ストリングからサンプリングフラグを読み取るフィルターを実装しました。もしトレースがサンプリング対象でなければ、コンテキストは全く伝播されません。
その投資は、あるギルドがユーザーのアクティビティに追いつけなくなった最近のインシデントにおいて実を結びました。トレースを見ると、メンバーが影響を受けたギルドプロセスへの接続で 16 分もの遅延を経験していることが示され、これはメトリクスやログだけでは明らかにされない定量化可能なユーザーへの影響でした。また、トレースは下流への連鎖も露呈させました:停止中、ユーザーはギルドにクリックしてアクセスすることさえできませんでした。
Discord のエンジニアである Nick Krichevsky 氏は、このような深刻なパフォーマンス劣化は稀であると指摘しつつ、トレーシングが「以前はデバッグできなかった」問題の調査において不可欠なものになったと述べています。
Transport ライブラリは、アクターベースシステムにおける分散トレーシングに対する実用的なアプローチを表しています。HTTP スタイルのメタデータを無理やり組み込むのではなく、Elixir のメッセージパッシングをラップすることで、Discord はアーキテクチャの強みを維持しつつ、本番環境のワークロードにスケールする観測可能性を獲得しました。
著者について
Steef-Jan Wiggers
Steef-Jan Wiggers は InfoQ のシニアクラウド編集者の一人であり、オランダの VGZ でドメインアーキテクトとして勤務しています。彼の現在の技術的専門分野は、統合プラットフォームの実装、Azure DevOps、AI、および Azure プラットフォームソリューションアーキテクチャに焦点を当てています。Steef-Jan はカンファレンスやユーザーグループで定期的に登壇し、InfoQ 向けに記事も執筆しています。さらに、マイクロソフトからは過去 16 年間にわたり Microsoft Azure MVP に認定されています。
詳細を表示表示しない
原文を表示
Discord engineering published details on how they added distributed tracing to their Elixir infrastructure while handling millions of concurrent users. The team built a custom "Transport" library that wraps Elixir's message-passing system with trace context, solving a fundamental challenge in instrumenting actor-based architectures.
Unlike HTTP-based microservices, where trace context travels in headers, Elixir's actor model passes arbitrary messages between processes with no built-in metadata layer. Discord needed end-to-end visibility across its chat infrastructure, yet it faced a gap: OpenTelemetry's standard tracing worked within individual services but couldn't propagate context between Elixir processes.
The team identified three requirements for any solution: it had to be ergonomic enough for developers to adopt, support both raw messages and GenServer abstractions, and enable zero-downtime deployment across their production fleet.
Discord's solution introduces an "Envelope" primitive that wraps messages with trace context. The implementation is deceptively simple, a struct containing the original message and a serialized trace carrier:
defmodule Discord.Transport.Envelope do
defstruct [:message, trace_carrier: []]
def wrap_message(message) do
%__MODULE__{
message: message,
trace_carrier: :otel_propagator_text_map.inject([])
}
end
end
The library provides drop-in replacements for GenServer's call and cast functions that automatically wrap outgoing messages. On the receiving side, a handle_message function normalizes both old-style bare messages and new Envelope-wrapped ones, extracting trace context when present and cleaning it up after processing.
This normalization proved critical during rollout. Discord couldn't change all message passing overnight or update all nodes simultaneously. The library handles messages from both instrumented and non-instrumented code, enabling gradual migration without service restarts.
Discord's architecture creates unique scaling challenges. When a user sends a message to a guild with a million online members, that single traced operation could spawn a million child spans, one per session, forwarding the message to its client.
The team implemented dynamic sampling based on fanout size. Messages sent to a single recipient preserve their sampling decision 100% of the time. Messages fanned out to 100 recipients dropped to 10% sampling. At 10,000+ recipients, only 0.1% of sessions capture spans. This approach maintains useful trace data without overwhelming their observability infrastructure.
(Source: Discord blog post)
Initial deployments revealed tracing overhead Discord hadn't anticipated. Their busiest guilds, those with millions of members, struggled to keep up with activity. Profiling showed processes spending significant time unpacking trace context, even when 99%+ of operations weren't being sampled.
The fix: only propagate trace context for sampled operations. Unsampled traces simply don't include context in their envelopes, saving serialization and parsing costs. This modified traditional head sampling semantics slightly but eliminated CPU spikes.
A second optimization targeted the sessions service, where capturing spans during fanout increased CPU usage by 10 percentage points. Discord forbade sessions from starting new traces after receiving fanned-out messages. Sessions can continue existing traces, but won't independently decide to sample. This single change recovered nearly all the overhead, dropping CPU usage from 55% to 45%.
The most dramatic optimization came from analyzing gRPC request handling. When linking Elixir services to Discord's Python API, 75% of the request processing time was spent unpacking the trace context. The team built a filter that reads the sampling flag from the encoded trace context string without full deserialization. If the trace isn't sampled, context isn't propagated at all.
The investment paid off during a recent incident where a guild failed to keep up with user activity. Traces showed members experiencing 16-minute delays connecting to the affected guild process, a quantifiable user impact that metrics and logs alone wouldn't reveal. The traces also exposed the downstream cascade: users couldn't even click into the guild during the outage.
Discord engineer Nick Krichevsky notes that while this type of severe degradation is rare, tracing has become essential for investigating issues they "simply couldn't debug before."
The Transport library represents a pragmatic approach to distributed tracing in actor-based systems. By wrapping Elixir's message passing rather than trying to retrofit HTTP-style metadata, Discord maintained its architecture's strengths while gaining observability that scales to production workloads.
About the Author
Steef-Jan Wiggers
Steef-Jan Wiggers is one of InfoQ's senior cloud editors and works as a Domain Architect at VGZ in the Netherlands. His current technical expertise focuses on implementing integration platforms, Azure DevOps, AI, and Azure Platform Solution Architectures. Steef-Jan is a regular speaker at conferences and user groups and writes for InfoQ. Furthermore, Microsoft has recognized him as a Microsoft Azure MVP for the past sixteen years.
Show moreShow less
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み