Unix Domain Socket において keep-alive が性能に与える影響 (Gazelle vs Meinheld)

id:kazeburo さんが Gazelle という高速な Perl 用の Web アプリケーションサーバーを公開されました。

Gazelle の特徴のうち幾つかは、 id:mopemope 作の Meinheld と同じです。

一方で異なる点もあります。

  1. Meinheld は HTTP/1.1 に対応していて、 keep-alive が利用できる。
  2. Meinheld は greenlet というコルーチンを利用して、 long polling や SSE に対応している。
  3. Meinheld が http-parser を使っているのに対し、 Gazelle は picohttpparser を使っている

まず 1 についてですが、 keep-alive に対応することで内部のデータ構造が複雑になり速度が低下するというネガティブな要素と、接続を使いまわせることにより性能が向上するというポジティブな要素があります。

TCP では keep-alive の効果は絶大なのですが、Gazelle は用途を nginx と同じホストで動いている Web アプリケーションサーバーを、 Unix Domain Socket を利用して動かすという前提で設計されています。 Unix Domain Socket は TCP に比べて接続コストが圧倒的に低いため、 keep-alive をサポートしていないのに関わらず非常に高い性能を実現しています。

一方 keep-alive 対応のデメリットですが、性能に一番インパクトがあるのは epoll などのシステムコールが増えるコストです。 keep-alive なしで小さいリクエスト、レスポンスを扱う場合、 Gazelle は accept4(), read(), writev() と3回のシステムコールで1つのリクエストを処理することができます。

一方 Meinheld も、 keep-alive が off の場合のシステムコールの回数にまで気を使っていて、連続してアクセスが来る場合に epoll 1回で複数回 accept4() を行い (multi-accept)、さらにその間にリクエストを処理することで multi-accept が有効になる確率を上げています。

epoll(), accept4(), read(), writev(), accept4(), read(), writev(), ... epoll()

これで、高負荷時には1リクエストあたりのシステムコール数が 4 回未満に抑えられており、システムコールの回数という意味でのオーバーヘッドは最低限になっています。しかし、内部構造としては、 http pipeline への対応などのために複雑になっていることは否めません。

次に 2 についてですが、 Meinheld は greenlet 抜きでビルドすることも可能です。 Meinheld から greenlet を抜いた上で、非同期 API も削除して軽量化した Minefield という fork を作りました。名前は Meinheld と語感を合わせた上で、より実験的な事をするための不安定版というニュアンスが伝わるように「地雷原」という意味になっています。

keep-alive の性能以外の問題

性能以外に keep-alive のデメリットとして、運用の難しさがあります。 例えばアプリケーションサーバーだけ graceful restart したい場合、 keep-alive されているコネクションを強制的に切ると、たまたまその瞬間に来たリクエストがエラーになってしまう可能性があります。

安全に graceful restart するなら、 keep-alive コネクションを維持したままアプリケーション側に graceful restart をかけ、さらに nginx 側を graceful restart すると言った手順が必要になります。さすがにこれは面倒なので、リロード時に稀にエラーになる可能性を許容するか、オーバーヘッドが大きいTCPの場合でも keep-alive しないという選択肢を取る場合があります。

ベンチマーク

OpenResty の nginx を利用し、 nginx の echo モジュール、 Gazelle, Meinheld, Minefield, uWSGI でベンチマークを取りました。 AWS EC2 の c3.8xlarge で、同一ホストから wrk を実行しています。 nginx-echo はリバースプロキシ経由ではなく wrk で直接、 Meinheld, Minefield については nginx からの keep-alive なし、あり、そして直接の3種類を計測しています。

詳しくは次の gist を参照してください。 詳細

結果はこうなりました。

server keep-alive: off keep-alive: on direct
nginx-echo - - 740445.97
Gazelle 207358.57 - -
Meinheld 176373.65 299105.97 475175.82
Minefield 189890.87 332864.76 564465.83
uWSGI 231258.49 - -

20141222044835

今回 wrk, nginx, アプリ全て16プロセス(またはスレッド) にしていたのですが、 Meinheld と Minefield で keep-alive off の場合と、 uWSGI だけ 8 に減らしました。これは、特に Meinheld, Minefield で性能が大きく (10分の1程度) 落ちてしまっていたからです。 確認はしていませんが、負荷をかける側と同じ並列数で動かすと前述の multi-accept が有効に働かないどころか、むしろ thundering herd 問題が発生してしまうためと思われます。 (uWSGI は keep-alive 非対応ですが、 epoll を使ってるので thundering herd が発生します。オプションで thundering herd 回避のためにロックを使うことができますが、ピーク性能は落ちます)

まとめ

Unix Domain Socket においても、 keep-alive をすることで性能が大きく上がることが確認できました。

とはいえ、 keep-alive なしでも 20万 req/sec 出ており、実際に c3.xlarge で数万 rps のサーバーを書く場合は PerlPython ではなく C や Go、 Java などを利用するでしょうから、数千 rps のアプリを動かす程度なら keep-alive なしでも十分です。

thundering herd 問題については、実際に hello world よりずっと重いアプリケーションを実行していれば、その間は epoll や accept が動かないので、適切な並列数を選べば影響を抑えることができます。 しかし、アプリの負荷はアプリのバージョンアップで変わって行くものですし、負荷の予測も難しいので、高rpsなサーバーで適切なワーカー数の選択は難しくなります。 その点、 keep-alive にも複数のアドレスの listen にも対応しないことで、 accept の前に epoll などが要らなくなるので、バックエンドに負荷をかけ過ぎない範囲で多めのワーカーを設定でき、運用が楽になります。

また、 keep-alive なしのサーバーの中では uWSGI が頭ひとつ飛び抜けていました。 これも確認していませんが、 uWSGI はシンプルなバイナリプロトコルである uwsgi プロトコル (プロダクト名が uWSGI, プロトコル名が uwsgi) を使って nginx からリバースプロキシしているのが関係しているものと思われます。 picohttpparser を使うことでアプリサーバー側の HTTP 処理は軽量化できますが、 uwsgi を使うと nginx 側も汎用の http クライアントではなくアプリサーバーとの通信に特化した軽量なクライアントを利用できるので、より効率がよくなります。 http で uWSGI よりも性能を出すためには、 nginx などのリバースプロキシ側の http クライアントの改良が必要になるでしょう。

最後に、 Meinheld のようなアーキテクチャは、アプリサーバーとリバースプロキシが別ホストにあってTCPでリバースプロキシする場合に特に強いと言えます。 アプリサーバーが別ホストのL7ロードバランサの背後で動き、静的ファイル配信もL7ロードバランサか外部のCDNなどで行うという構成は、 Docker の流行で今後増えていくでしょう。そのとき、 Docker の中で nginx とアプリサーバーを動かすより、単体で keep-alive に対応したアプリサーバーを動かすほうがシンプルでかつ効率・性能ともに優れた選択肢になるでしょう。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。