HAProxy/nginxとのベンチマークで見えたSNI peekの実コストと、負荷テストで初めて表面化したJoinSetのメモリリーク

一通りの実装が完了したL4 TCPプロキシを、HAProxy・nginxと同じバックエンド構成でベンチマークします。

計測方法・条件

12コア/62GB/Ubuntu Server 22.04の同一マシン上で、クライアント(wrk)・本プロキシ・HAProxy・nginx・バックエンドをすべて動かしています。クライアントとサーバーが同じマシンのCPUを取り合う構成なので、絶対値は本番相当のネットワーク構成での数値とは一致しません。3者の相対比較として読んでください。

バックエンドはnginxで静的ファイルを配信する軽量な構成にしました。HAProxy・nginxリバースプロキシは共通の3台(90019003)へ、本プロキシだけはProxy Protocolを常に送る仕様のため、proxy_protocolを有効にした別の3台(90119013)へ向けています。open-source版nginxはHTTPリバースプロキシとして上流へProxy Protocolを送る機能を持たない(NGINX Plus限定)ため、本プロキシ側のコードは変えず、バックエンド側の受け口を分けることで対応しました。

負荷はwrk -t4 -c100 -d20s(一部60s)です。CPU使用率は/proc/[pid]/statから瞬間値を自前で計算しています(ps%CPUはプロセス起動からの累積平均になるため、今回の用途には使えません)。nginxはワーカープロセスが複数(この環境ではworker_processes autoで12個)に分かれるため、全プロセスのCPU・メモリを合算しています。

結果

対象 Req/s ベースライン比
ベースライン(バックエンド直叩き) 287,201 100%
HAProxy 140,729 49.0%
本プロキシ(デフォルト設定) 26,635 9.3%
本プロキシ(SNI_PEEK_TIMEOUTを1msに変更) 103,104 35.9%
nginx(リバースプロキシ) 76,651 26.7%

本プロキシはデフォルト設定のままだとnginxにも届きませんが、SNI_PEEK_TIMEOUTという1つの定数を変更するだけでnginxを上回ります。この定数の意味は補足で扱います。

CPU使用率とメモリ(RSS)は次の通りです(本プロキシはSNI_PEEK_TIMEOUT=1ms、後述のメモリリーク修正後の値)。

対象 Req/s CPU使用率(平均) RSS
本プロキシ 103,075 約175%(≒1.75コア) 6〜8MBで安定
HAProxy 140,729 約543%(≒5.4コア) 約8MBで安定
nginx(全13プロセス合算) 76,651 約1010%(≒10.1コア、ほぼ全コア) 約53MBで安定

1コアあたりの処理数(Req/s ÷ 使用コア数)で並べ直すと、スループットの順位が逆転します。

  • 本プロキシ: 約58,900 req/s/コア
  • HAProxy: 約25,900 req/s/コア
  • nginx: 約7,600 req/s/コア

検証結果について

スループットの絶対値はHAProxy > 本プロキシ > nginxでしたが、1コアあたりの仕事量では本プロキシが3者の中で最も効率的でした。nginxが遅く見えるのはCPU効率が悪いからではなく、この12コア環境の全コアを使い切ってもなお、本プロキシ(1.75コアしか使っていない)に届いていないという点です(ただしnginx側は検証用途向けのチューニングをしていないデフォルト設定である点は差し引いて読む必要があります)。

ただし、この効率差を「本プロキシの設計が優れている」根拠として読むのは早計です。HAProxy・nginxはHTTPモードでヘッダ解析やロギング基盤などL7の仕事までこなした上でこの数値ですが、本プロキシはHTTPを一切解釈しない純粋なL4のバイトポンプです。1コアあたりの効率が良いのは、単に処理している仕事の量そのものが少ないからという可能性が高く、公平な設計比較にはなっていません。

本プロキシとHAProxyの残り27%の差、そして本プロキシがデフォルト設定のままでは大きく水をあけられる点については、それぞれ補足で扱います。

SNI peekタイムアウトによるパフォーマンス低下

TLSパススルー/SNIルーティングの実装は、「非TLS接続はSNI_PEEK_TIMEOUT(デフォルト200ms)をまるごと待たされる」という既知のトレードオフを持っています。今回のベンチマークバックエンドはnginxのデフォルトkeepalive_requests 100により、約100リクエストごとにTCP接続が張り直されます。そのたびに本プロキシだけがこの200msを支払っていました。実際、デフォルト設定での計測時、tcpproxy_connections_totalは5,440件(1接続あたり約98リクエスト、keepalive_requestsの値とほぼ一致)で、5,440接続 × 200ms ≒ 1,088秒分の遅延を100並列で捌いていた計算になり、結果表の落ち込みと辻褄が合います。

デフォルト設定での大きな差の正体は、抽象化のオーバーヘッドではなく、Phase 10で意図的に選んだ「非TLS判定の待ち時間」という具体的な設計コストでした。「まだ受信中」と「そもそもTLSでない」を区別できないという既知の限界が、今回のベンチマークで実コストとして裏付けられた形です。

負荷テストで見つかったJoinSetのメモリリーク

CPU効率を計測していた際、本プロキシのRSSだけが計測中ずっと増え続ける(61MB→85MB/20秒)ことに気づきました。HAProxy・nginxはどちらも横ばいです。

原因はmain.rsの接続受付ループにありました。

tasks.spawn(async move {
    // ...
});

グレースフルシャットダウン実装のtokio::task::JoinSetは、完了したタスクの結果をjoin_next()で回収するまでメモリ上に保持し続けます。ところが通常運用中のacceptループはtasks.spawn(...)するだけで、join_next()はシャットダウン時のドレイン処理でしか呼ばれていませんでした。処理が終わったタスクの残骸が、プロセスの生存期間中ずっと蓄積し続ける実装になっていたことになります。

これを裏付けるため、ベンチマーク直後(実際の同時接続はほぼ0のはず)にSIGTERMを送ってみると、ドレイン件数がプロセス起動からの累計接続数(tcpproxy_connections_total)とぴったり一致しました。

draining 61981 in-flight connection(s)

Rustの型システムが保証するのは「解放済みメモリへのアクセスをしない」「二重解放をしない」「データ競合を起こさない」ことであり、「確保したものを回収し忘れない」ことまでは保証しません。Rc/Arcの循環参照が典型例として知られる通り、リークはsafeなコードのまま普通に起こり得ます。今回のケースも、型システムの穴ではなく、通常運用中に完了タスクを回収する経路が単純に欠けていた、というアプリケーションロジックの不備でした。

修正は、acceptループに完了タスクを回収する分岐を1つ足すだけです。

Some(result) = tasks.join_next(), if !tasks.is_empty() => {
    if let Err(e) = result {
        eprintln!("task join error: {e}");
    }
}

if !tasks.is_empty()のガードが必須です。これがないと、タスクが1つもないアイドル時にjoin_next()が即座にNoneを返し続け、select!がビジーループしてCPUを浪費します。空でないときだけ分岐を有効にすることで回避しています。

修正後、60秒間の同条件の負荷テストでRSSは6〜8MBの範囲で安定し、シャットダウン時のドレイン件数も0になりました。スループットへの影響はありません(103,075 req/s、修正前と同水準)。「結果」に載せた数値はこの修正後のものです。

プロダクト実装として詰める余地

ここまでの結果は、あくまで「学習目的でスコープを絞った実装」としての比較です。本番投入を前提に性能を追い込むなら、少なくとも次のような改善余地が残っています。

  • ゼロコピー転送: copy_bidirectionalはユーザー空間のバッファを経由するread/writeです。HAProxyとの残り27%の差の一因はここにあると見ていて、Linuxのsplice()でソケット間をカーネル空間のまま転送すれば、この差はさらに縮む可能性があります。これはunsafeなFFIを伴う本格的な実装追加で、「学習用にシンプルにした部分を削る」レベルの話ではありません。
  • コネクションプールのチューニング: コネクションプーリングPOOL_TARGET_SIZEは学習用の小さな値(4)のままで、かつ「使い捨て」設計です。実運用の負荷レベルに合わせたサイジングや、本当の意味での接続再利用は未着手です。
  • SNI判定ロジックの精緻化: 上で触れた「まだ受信中」と「そもそもTLSでない」の区別は、根本的な解消はまだしていません。
  • nginx側のチューニング不足: 今回のnginxはワーカー数を含めデフォルト設定のままで、この検証用途に最適化していません。公平な最終結論を出すには、nginx側のチューニングも必要です。

これらは「今回は追わない」という判断であって、「Rustだから無理」という話ではありません。