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 に対応したアプリサーバーを動かすほうがシンプルでかつ効率・性能ともに優れた選択肢になるでしょう。

http-parser と picohttpparser との比較

h2o Advent Calendar 2014 の記事です。

Minefield は http-parser (nginx のパーサーと基本的に同じもの) を利用しているのですが、これを picohttpparser に置き換えてみます。 最初は libh2o に置き換えるつもりだったのですが、まだ API が unstable でアプリに組み込む手順も確立されてないので parser だけを置き換えることにしました。

まだ完成していないのですが、 Hello, World くらいは問題なく動くレベルになったので、ここまでで気づいた http-parser と picohttpparser の違いを紹介し、ベンチマーク対決もやってみます。

特に置き換えを始める前は、 perf top で execute_http_parser という http-parser の関数が2%程度しかCPUを消費していないのを見て、実際にサーバーの性能に与える影響については懐疑的だったのですが、実際に置き換えてみると意外に影響が大きいことが分かりました。

イベントドリブン vs シーケンシャル

http-parser はイベントドリブンで、ヘッダのフィールド名、フィールド値など個々にコールバック関数が呼ばれます。毎回与えられた受信バッファを完全に消費する設計になっているので、フィールド名の途中までで一旦コールバックが呼ばれ、次に受信バッファを与えた時にまたフィールド名の続きがコールバックされるかもしれません。

一方 picohttpparser は、ほぼステートレスで、ヘッダを一気にパースしますす。パース結果は受信バッファ上の位置と長さという形で返されるので、ほぼゼロコピーになっています。 (1度に read できなかった場合、2度め以降は前回読み込んだ位置より後ろに HTTP ヘッダの終端があるかどうかをチェックするので、「前回読み込んだ位置」が唯一の状態になります)

http-parser の状態管理や追記に対応するためのデータ構造やコードが面倒で、パーサー本体よりもこちらのほうが性能に影響を与えそうです。 頭のなかに状態表を持ってないとコードが読めないのでメンテナンス性の問題もあります。 picohttpparser にすることで、最初から完全に揃ったヘッダを受け取れるのでかなりコードをシンプルにすることが出来ました。

一方、 http-parser は毎回受信バッファを完全に消費するので1つのバッファをスレッド or プロセス全体で使いまわせるのですが、 picohttpparser では1度の read でヘッダ全体を受信できなかった場合にその受信バッファを保持しておく必要が生じます。

Minefield ではとりあえずコネクションごとに 32KB の read buffer を用意しましたが、 keep-alive 中のコネクションにこのバッファを確保し続けるのはもったいないのでもう少し賢く管理する必要があります。 Minefield は http pipelining にも対応しているので、受信バッファの管理は特に難しい問題になります。

HTTP/1.1 に対する知識

http-parser では、リクエストメソッドenum で渡してもらえますし、リクエストボディも callback で渡されます。

一方 picohttpparser では、リクエストメソッドも先頭位置と長さだけが渡されますし、リクエストボディも自分で処理する必要があります。自分で Content-Length ヘッダや Transfer-Encoding: chunked ヘッダを見つけて、指定バイト数読み取るか、 phr_decode_chunked() するかを決める必要があります。

この点は HTTP/1.1 の仕様の多くをパーサー側でカバーしてくれている http-parser の方が簡単ですし、脆弱性を作りにくく堅牢になると思います。

既存の http-parser を使ったアプリを picohttpparser に移植する際に問題になりそうなこと

HTTP/1.1 の知識が分散することもそうですが、バッファに関する制限が変わり、サーバーの仕様に影響を与えそうです。

どういうことかというと、 http-parser ではヘッダ名、ヘッダ値ごとに callback が呼ばれ、追記される可能性があるので、1つのヘッダの大きさを制限したくなります。最大長が決まっていれば、追記を許すと言ってもリアロケーションが不要になるからです。 一方 picohttpparser では、ヘッダを一括でパースするので、個々のヘッダの大きさよりもヘッダ全体の大きさを制限して、受信バッファの大きさを固定したくなります。

このため、パーサーの置き換えは Apache 2.2 -> 2.4 のような大規模なバージョンアップのタイミングで行った方がよいでしょう。

ベンチマーク

おまちかねのベンチマークです。 Gazelle vs Meinheld と同じ環境 (c3.xlarge) を使い、 wrk から Minefield の並列数は16ずつにします。

nginx が間に入ると Minefield の性能の変化が見えにくくなるので、直接アタックするのみにします。ただし、前回同様の keep-alive に加えて、今回はより性能が上がる(パーサー部分の負荷も増える) pipelining も試してみます。

ベンチマーク詳細 (各4回計測し、2番めに性能が高かった数値を採用)

parser keep-alive pipeline
http-parser 545552.78 782338.04
picohttpparser 567908.05 818628.66

f:id:methane:20141222122716p:plain

4% 強の性能向上が観測できました。今後完成度を高めていく上で、より picohttpparser に最適化されることで性能が上る可能性と、まだケアできてない HTTP/1.1 の仕様を実装することで速度が下がる可能性の両方がありますが、おおまかな目安にはなると思います。

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