Skip to content

Latest commit

 

History

History
443 lines (358 loc) · 26.2 KB

README.ja.md

File metadata and controls

443 lines (358 loc) · 26.2 KB

Ruvoy

Ruby, carried by Envoy.

Rust 2024 Ruby 4.0.5 Envoy 1.39.1 Apache License 2.0

English | 简体中文 | 日本語

Ruvoy は Envoy の内部で動く Ruby アプリケーションランタイムです。Envoy が HTTP を担い、Ruby が Rack を担い、その間を Rust の owned メッセージが橋渡し します。

インストール

$ gem install ruvoy ruvoy-envoy

ruvoy gem には動的モジュールが入っています。Envoy は ruvoy-envoy gem か マシンのものを使い、RUVOY_ENVOY、ruvoy-envoy、PATH 上の envoy の順に探します。

Ruby 4.0.x。モジュールはビルド時の Ruby にリンクするため、gem は ABI ごとに公開されます。
プラットフォーム Linux x86_64 と aarch64。Envoy が公式バイナリを公開しているものに揃えています。
Envoy 1.39(動的モジュール ABI 0.1.0)。ruvoy-envoy gem またはマシンのものを使います。

クイックスタート

$ ruvoy examples/hello/config.ru
$ curl localhost:8080/
hello from ruby 4.0.5

ruvoy は rackup を受け取ってそれを配信します。Envoy の設定を生成し、自身を Envoy に置き換えるため、シグナルと終了ステータスは Envoy のものになり、 コンテナにプロセススーパーバイザーは要りません。

$ ruvoy --help
Usage: ruvoy [options] [config.ru]
    -p, --port PORT                  Listen on PORT (default 8080)
    -a, --address ADDRESS            Bind to ADDRESS (default 127.0.0.1)
        --admin-port PORT            Expose Envoy's admin interface on PORT
        --print-config               Print the generated Envoy configuration and exit

自分で構成した Envoy の下で動かす場合、--print-config が Ruvoy の使う listener を 出力します。モジュール名は ruvoy_fiber、その filter config は rackup のパス、またはそれを 含む JSON オブジェクトです(Envoy コンテキストと上流呼び出し を参照)。

ステータス

データパスは実装済みで、CI 上で実際の Envoy に対して走るスイートが覆っています。 設定リロードは稼働中の Envoy を通して検証済みで、1 時間・180 万リクエストの soak の後もヒープ、ファイルディスクリプタ、スレッド数は横ばいでした。 インターフェースは変更される可能性があります。

特徴

  • Envoy ネイティブの HTTP — HTTP/1.1、HTTP/2、コネクション、タイム アウト、ダウンストリームのライフサイクルはすべて Envoy が管理します。
  • Rack アプリケーション — 通常の config.ru を読み込み、Envoy の リクエストから構築された Rack 環境を受け取ります。
  • Fiber 並行性 — scheduler-aware な Ruby I/O が専用の CRuby オーナー スレッド上で交錯実行され、Envoy ワーカーが Ruby に入ることはありません。
  • Owned-data 境界 — スレッドを跨ぐのは Rust が所有するリクエストと レスポンスのみで、Ruby オブジェクトは決して跨ぎません。
  • ストリーミングレスポンス — レスポンスボディはアプリケーションが 生成するたびにチャンク単位で配信され、ダウンストリームのバックプレッ シャーはバッファリングではなく Ruby 側の生成側に直接かかります。
  • ストリーミングリクエスト — ヘッダーが届いた時点でアプリケーション が呼ばれ、ボディは到着しながら読まれます。アプリケーションが追いつけない クライアントはバッファリングではなく減速させられます。
  • 有界なアドミッション — リクエスト数の予算により、超過分は Ruby に 到達する前に拒否されます。
  • 上流 HTTP ホップなし — Rack 呼び出しに、別プロセスの Ruby アプリ ケーションサーバーへの loopback 接続は不要です。
  • Envoy コンテキストと上流呼び出し — オプションで、Envoy がリクエストに ついて持つ route、コネクション、TLS、metadata をアプリケーションから参照でき、 リクエストの中から Envoy の cluster を呼び出せます。

Ruvoy を使う場合と使わない場合

Ruvoy なし Ruvoy あり
リクエスト経路 Envoy → 上流 HTTP → Ruby サーバー Envoy → Rust owned メッセージ → Ruby
Ruby のホスト 独立したサーバープロセス Envoy 内部の専用 CRuby オーナースレッド
プロトコルの受け渡し リクエストは別の HTTP 接続にシリアライズされる リクエストは owned データとしてワーカー境界を越える
I/O 並行性 サーバーのスレッドまたはワーカー Ruby オーナースレッド上の Async ファイバー
障害境界 Envoy と Ruby は別プロセス Envoy と Ruby は同一プロセスを共有
CPU バウンドな Ruby 複数の Ruby ワーカーで複数コアを利用可能 単一の CRuby VM は GVL の制約を受けたまま

Ruvoy は scheduler-aware な I/O と短時間の CPU-light な Rack 処理向けに 設計されています。CPU バウンドな Ruby を並列実行させるものではありません。

アーキテクチャ

client
  → Envoy worker
  → owned Rust request
  → CRuby owner thread
  → Async Fiber
  → owned Rust response
  → original Envoy worker
  → client

Envoy ワーカーが Ruby のハンドルを取得したり VM に入ったりすることはあり ません。Ruby オーナースレッドが Rack アプリケーションを実行し、結果はダウン ストリームの stream を所有するワーカーへコミットバックされます。

現在のスコープ

Ruvoy は Rack 環境を構築し、バイナリの rack.input を提供し、重複した レスポンスヘッダーを保持し、列挙可能なレスポンスボディを消費し、ボディが 対応していれば close を呼び出します。

レスポンスボディはインクリメンタルにストリーミングされます。ヘッダーは一度 だけ送信され、アプリケーションが yield するたびに各チャンクが転送されるため、 ボディの完成前に最初のバイトがクライアントに到達します。遅いクライアントは メモリに滞留させる代わりに Ruby 側の生成側へバックプレッシャーをかけ、切断 したクライアントは列挙を停止させてボディをクローズします。

リクエストボディも同様にストリーミングされます。ヘッダーが届いた時点で アプリケーションが呼ばれ、rack.input を通してボディを到着しながら読むため、 アップロードと処理が重なります。アプリケーションがまだ読んでいない分は Envoy に留まり、Envoy は空きができるまでクライアントからの読み取りを止めます。読み 終えた分は保持され、128 KiB まではメモリに、それを超えると一時ファイルに置かれる ため、以前の Rack 向けに書かれたフレームワークが期待するように rack.input を 巻き戻して読み直せます。生 ソケットのハイジャック(rack.hijack)はサポートされず、コネクションは常に Envoy に属します。WebSocket などのアップグレードは、代わりに Rack 3 の rack.protocol とストリーミングボディで扱い、HTTP/1.1 と HTTP/2 に対応します。 Envoy 側での許可(upgrade_configs、HTTP/2 では加えて allow_connect)が必要です。

レスポンスヘッダーが送出済みになってからボディが失敗した場合、Ruvoy はストリーム をリセットするため、クライアントには完全に見える短いボディではなくエラーが届き ます。

ブロッキング呼び出し

fiber がランタイムスレッドを占有せずに待てるのは、待っている呼び出しが Ruby の fiber スケジューラーと協調する場合だけです。Ruby 自身のソケットと IO、sleep、 多くのドライバーが提供する非同期 API はそうなっています。一方、カーネル内で ブロックする C 拡張の呼び出しは、すべてのリクエストが共有する 1 本のスレッドを 占有するため、リクエストはその後ろに並びます。この種の 50ms の呼び出しは、待つ クライアントがどれだけいても毎秒およそ 20 リクエストしか処理できません。

Ruvoy.offload はそうした呼び出しを専用の Ruby スレッドで実行し、呼び出し元の fiber はその間待機します。

rows = Ruvoy.offload { connection.sync_exec(sql) }

# At most 16 in flight; callers beyond that wait their turn.
DATABASE = Ruvoy::Offload.new(limit: 16)
rows = DATABASE.call { connection.sync_exec(sql) }
  • ブロック中に GVL を解放する拡張に効きます。ブロッキングな C 呼び出しは通常その ように書かれています。GVL を保持したままブロックする拡張はすべてのスレッドを 止めてしまい、Ruby 側では手の打ちようがありません。
  • 呼び出しを途中で打ち切ることはできません。クライアントの切断やタイムアウトで 呼び出し元が中断されても、呼び出しの終了を待ってから抜けるので、使用中の コネクションが使用中のまま返却されることはありません。
  • ブロックは別スレッドで動くため、スレッドローカルな状態(スレッドごとのコネク ションなど)は呼び出し元ではなくそのスレッドのものです。
  • ドライバーにスケジューラー対応の API があればそちらを優先してください。 offload は呼び出しごとにスレッドを 1 本使い、上限を与えない限り流量を制限しません。

Rails

Rails アプリケーションはそのまま動きます。fiber サーバーならどれでも必要な設定を 1 つ加えてください。

config.active_support.isolation_level = :fiber

Active Record は既定でスレッドごとにコネクションを割り当てますが、各リクエストの fiber は 1 本のランタイムスレッドで動くため、この設定がないと並行するリクエストが 1 つのコネクションを共有します。設定すれば、処理中の各リクエストがそれぞれ自分の コネクションを持ちます。

  • Action Cable はサポートされません。rack.hijack が必要なためです。
  • リクエストは Envoy を経由して Rails に届き、Envoy は転送するリクエストに X-Forwarded-Proto を加えます。
  • 静的ファイルは前段の Envoy が返すため、config.public_file_server.enabled = false にすると Rails はリクエストごとにファイルを探さずに済みます。小さなレスポンスで スループットが約 5 分の 1 増えました。
  • test/e2e/rails_app_test.rb は production モードの Rails 8.1 アプリケーションを Ruvoy と参照サーバーの両方で動かして全レスポンスを比較し、並行時と、トランザク ション途中でクライアントが切断したときのコネクションの扱いを確認します。

Envoy コンテキストと上流呼び出し

Rack リクエストが運ぶ以上の Envoy の情報をアプリケーションに渡す、2 つの オプション機能です。どちらも filter config で求めない限り無効で、fiber ランタイムでのみ使えます。その場合、設定は JSON オブジェクトになります。

{
  "rackup": "/srv/app/config.ru",
  "context": { "metadata_namespaces": ["acme.tenant"] },
  "upstream": { "clusters": ["users"], "timeout_ms": 15000, "max_response_bytes": 1048576 }
}

ruvoy.context

リクエストに対する Envoy の見え方です。ヘッダーが届いた時点で worker が コピーし、Ruby の値はアプリケーションが読んだときにだけ作られます。

context = env["ruvoy.context"]
context.route_name        # => "api", or nil for an unnamed route
context.connection        # => {"id"=>7, "source_address"=>"10.0.0.7", "source_port"=>51234, ...}
context.tls               # => nil on plaintext, else {"version"=>"TLSv1.3", "server_name"=>..., "peer_certificate"=>...}
context.dynamic_metadata  # => {"acme.tenant"=>{"id"=>"t-42"}}, from filters ahead of Ruvoy
context.route_metadata    # => the same namespaces, from the matched route
context.to_h              # => all of the above

Envoy のモジュールインターフェースが報告できる範囲から、いくつかの制約が あります。

  • route に名前が付くのは、route 設定で名前を与えた場合だけです。terminal filter でも名前を付けるには route エントリが必要です。 non_forwarding_action: {} を指定してください。
  • server_name はクライアントが送った SNI で、listener に TLS inspector の listener filter を設定したときにだけ Envoy が記録します。
  • peer_certificate はクライアントが証明書を提示したことを示します。クライ アント証明書を必須とし、信頼できる CA による署名を要求する listener で のみ、クライアントの身元として扱えます。
  • 報告されるのは最初の URI と最初の DNS の subject alternative name だけです。
  • metadata の値は文字列、数値(常に Float)、真偽値、およびそれらのリスト です。入れ子の構造は含まれません。

ruvoy.upstream

Envoy に設定された cluster を呼び出し、ステータス、ヘッダー、ボディ全体(String)を返します。

status, headers, body = env["ruvoy.upstream"].call(
  "users", "GET", "/users/42",
  headers: { "accept" => "application/json" }, timeout: 2
)

呼び出しはリクエストの worker が cluster 経由で送るため、cluster のコネク ションプール、TLS 設定、サーキットブレーカーが適用され、x-envoy-retry-on などのヘッダーで Envoy にリトライさせられます。呼び出し中の fiber はランタ イムスレッドを占有せずに待つので、他のリクエストは処理され続け、1 つの リクエストからの複数の呼び出しは並行します。重複したレスポンスヘッダーは Array で返ります。

  • 呼び出せるのは upstream.clusters に列挙した cluster だけで、それ以外は ArgumentError になります。
  • 呼び出しの途中で Envoy 自身が返す応答は通常のレスポンスとして返ります。 タイムアウトは 504、上流に接続できないか途中で切断された場合は 503 です。
  • 呼び出しを送れなかったときは Ruvoy::UpstreamError が発生します。cluster が存在しない場合や、健全なホストがない・サーキットブレーカーが開いている などで cluster がその場で拒否した場合です。呼び出しより先にリクエストが 終わったときも発生します。
  • レスポンスボディが max_response_bytes または Envoy 自身のバッファ上限を 超えたときは、そのサブクラス Ruvoy::UpstreamResponseTooLarge が発生する ため、呼び出し側は「要求を減らす」と「後で再試行する」を区別できます。
  • call はレスポンスを丸ごとバッファするので、max_response_bytes が 1 回の 呼び出しが占有できるメモリの上限になります。

stream は同じ引数を取りますが、ヘッダーが届いた時点で返り、ボディは届いた順に 渡され、そのまま Rack レスポンスとして返せるので、プロキシは 1 行です。

run ->(env) { env["ruvoy.upstream"].stream("api", env["REQUEST_METHOD"], env["PATH_INFO"], body: env["rack.input"]) }

Envoy はこれにフロー制御をかけないため、読み手が upstream.stream_buffer_bytes (既定 1 MiB)以上遅れるとストリームはリセットされます。protocol: "websocket" を 付けると、レスポンスはクライアントも切り替えて双方向に中継します。

呼び出しとストリームはリクエストのトレースコンテキストを引き継ぎます。 traceparent、tracestate、x-request-id と B3 ヘッダーで、呼び出し側が 自分で設定したものはそのままです。

examples/extensions では、代わりのサービスを相手に両方 の機能を動かし、1 つのリクエストからの 2 つの呼び出しが並行する様子も確認できます。

統計

Ruvoy は Envoy の統計を通して報告するため、数値は Envoy に設定されたシンク、 admin インターフェースの /stats/prometheus などに届きます。

  • requests_total、rejected_total{reason}、responses_total{outcome}、 duration_ms:リクエスト数と、アプリケーションに届いたリクエストの終わり方。
  • inflight_requests、reactor_idle_ms、resident_bytes:リクエストの開始と 終了時にサンプリングするため、アイドル中は最後の値が表示されます。クライ アントが途中で離れた後は、次のリクエストまで inflight_requests が 0 より 大きいままのことがあります。
  • upstream_calls_total{cluster, outcome}:アプリケーションから見た呼び出しの 終わり方ごとの件数で、completed、refused、timed_out、overflow、 too_large、reset、abandoned のいずれかです。upstream_duration_ms{cluster} は call ではレスポンス全体まで、stream ではヘッダーまでの時間です。ステー タスコードと Envoy 自身のタイムアウトは cluster の統計にあります。

スケーリング

各 Ruvoy プロセスはちょうど 1 つの CRuby VM を実行するため、1 プロセス内の Ruby 処理は GVL により 1 コアに制限されます。スケールアウトはスレッドでは なくプロセスを増やすことで行います。レプリカごとに 1 つの Ruvoy を実行し、 単一 VM の Ruby サーバーと同様に水平スケールします。

ベンチマーク

環境

コンポーネント 構成
マシン 4 vCPU、32 GiB メモリ、x86_64
システム Linux
Envoy 1.39.0、concurrency 1
Ruby 4.0.5
Rust 1.97.1
Rack 3.2.6
負荷生成 oha 1.15.0

混合ストレス

この実行ではアドミッション制御、回復、クライアントキャンセル、リソースの 安定性を検証しました。3 つの完全なサイクルで構成されます。

  • 定常トラフィック:サイクルあたり HTTP/2 no-op リクエスト 45,000 件、 200 ms 待機付き HTTP/1.1 リクエスト 6,000 件、256 KiB ボディ付き HTTP/1.1 リクエスト 1,200 件;
  • リクエスト数の過負荷:15 秒間、最大 256 リクエストのみ受け入れ;
  • 合計ボディの過負荷:15 秒間、受け入れボディ予算 64 MiB;
  • クライアントキャンセル:意図的にキャンセルした 128 リクエスト;
  • 回復:サイクルあたり no-op 20,000 件、待機リクエスト 2,000 件。
結果 値
総リクエスト数 2,503,763
HTTP 200 251,400
想定内の過負荷 HTTP 503 2,252,363
トランスポートエラー 0
定常・回復フェーズの失敗 0
制御リスナーの最大レイテンシ 35.615 ms
各サイクル終了後のファイルディスクリプタ数 59
Ruby live-heap の変化(回復サイクル 1 → 3) +2 slots
ボディ過負荷中のピーク RSS 489,332 KiB

定常および回復フェーズのリクエストはすべて HTTP 200 を返しました。HTTP 503 はすべて意図的な過負荷フェーズで発生したもので、拒否された仕事として報告 され、成功スループットには数えません。

この実行では負荷生成をサーバーと同一マシンで行い、サーバーはホスト CPU を 飽和させていません。したがってこの結果が支持するのはレジリエンスと有界な 過負荷の主張のみで、最大スループットの測定ではありません。

サーバー比較

固定 commit 上で、Ruvoy、直接接続の Falcon 0.55.6、直接接続の Puma 7.2.0、Envoy → Puma を、同一の Rack fixture で比較しました。

  • oha はローカルネットワーク内の独立した 2 vCPU ホスト(RTT 約 0.03 ms) で実行し、負荷生成側がサーバーと CPU を奪い合うことはありません。
  • 各測定は 10 秒のウォームアップと 30 秒の負荷で構成。セルごとに 7 ラウンド 実施し、ラウンドごとにアーキテクチャの順序をローテーションします。
  • すべてのリクエストが HTTP 200 を返し、トランスポートエラーがゼロの ラウンドのみを採用。報告値は中央値と相対標準偏差です。
  • どのアーキテクチャも Ruby 実行コンテキストは 1 つだけ:Puma はシングル モードで 100 スレッド、Falcon は 1 インスタンス、Ruvoy はオーナースレッド 1 本、Envoy は concurrency 1。
  • 判定基準は実行前に固定:スループットと p99 の両方の改善が、5% と実測の ラウンド間変動のうち大きい方を超えた場合にのみ優位を主張できます。

No-op Rack アプリケーション、HTTP/1.1、100 コネクション、平文:

サーバー RPS 中央値(RSD) p50 ms p99 ms CPU RSS MiB
Ruvoy 31,270(1.9%) 3.06 5.01 189% 94
Falcon 12,716(0.5%) 7.67 9.63 99% 83
Puma 10,410(5.5%) 8.84 20.96 87% 127
Envoy → Puma 9,754(4.0%) 9.92 17.63 159% 185

同一ワークロードを TLS 1.3 で実行——Ruvoy と Envoy → Puma は Envoy が 終端し、Falcon と Puma はプロセス内で終端:

サーバー RPS 中央値(RSD) p50 ms p99 ms CPU RSS MiB
Ruvoy 32,065(2.9%) 2.98 4.85 192% 95
Falcon 11,401(3.0%) 8.73 10.61 99% 91
Envoy → Puma 9,674(4.4%) 9.97 17.44 162% 191
Puma 8,728(6.7%) 9.87 25.52 87% 148

アーキテクチャの差を分離できる比較対象は Falcon です。両者とも fiber-per-request であり、違いはリクエストを loopback の HTTP ホップ経由 ではなく、同一プロセス内で owned データとして運ぶことだけです。Falcon に 対して Ruvoy は平文でスループット +146%・p99 −48%、TLS で +181%・ −54% を維持し、いずれも事前登録したノイズ閾値を超えています。 Envoy → Puma に対する差は +221% と +231% です。

正直に述べるべき 4 つの限定条件:

  • No-op シナリオはリクエストあたりのフレームワークオーバーヘッドを測る ものであり、これらは上限値です。実アプリケーションの処理時間が長くなる ほど相対的な差は縮みます。100 コネクションで 200 ms の待機を模擬すると、 4 つのサーバーはすべて同じ約 495 RPS の並行数上限に張り付きます。
  • Ruvoy の CPU 列には、同一プロセス内で HTTP を処理する Envoy ワーカーが 含まれます(約 1.9 コア、Falcon は約 1.0 コア)。コアあたりでは差は約 +29% で、残りは Ruby スレッドにプロキシ級の HTTP フロントエンドを 組み合わせたことによるものです。
  • ここに載る数字はすべて、待機中に譲るアプリケーションから得たものです。 C 拡張で書かれたドライバーはインタプリタロックは解放しますが fiber は譲らない ため、そうした呼び出しが 1 つあるだけで同じスレッド上の他のリクエストがすべて 止まります。wait50 と block50 のシナリオはこの差を直接測るもので、上記の campaign には含まれていません。
  • 1 MiB レスポンスのシナリオも実行しましたが、順位付けは成立しません でした。毎秒約 1,000 レスポンスを超えると 4 アーキテクチャすべてが ネットワーク経路のスループット上限に収束し、変動は 59–96% に達した ため、このシナリオが測っているのはリンクでありサーバーではありません。

未測定:CPU バウンドな Rack、実際のデータベースや HTTP クライアントの ドライバー、マルチプロセス構成。

測定は bench/run.rb によって生成されました。このスクリプトは 上記のプロトコル——リモート負荷生成、ローテーション、ウォームアップ、 ラウンドごとの検証、事前登録した判定——を強制し、各実行の生の oha 出力、 CPU・RSS サンプル、生成されたサマリーを保存します。

ライセンス

Apache License 2.0。LICENSE を参照してください。