2016年04月01日

Thundering herd 対策の本命、 EPOLLEXCLUSIVE を試してみた

はてなブックマークに登録

epoll を使った prefork 型アプリケーションサーバーにおける Thundering herd 対策の決定版として注目されていた EPOLLEXCLUSIVE が、 3/13 にリリースされた Linux 4.5 で導入されました。

昨年 SO_REUSEPORT というソケットオプションが登場して、 Thundering herd 対策として話題になったものの、ワーカーごとに listen キューが作られるため graceful restart するときに listen キューに入ってるリクエストを取りこぼす可能性があり利用するのが難しい状況でした。

参考: epoll の thundering herd 問題について解説しているサイト

一方、 EPOLLEXCLUSIVE は、 listen キューは一本のまま、その listen キューを待っている複数プロセスの epoll_wait() を全部起こすのではなく、1つ以上(1つとは限らない)だけ起こすという機能です。 graceful shutdown 中は epoll_wait() をしていないはずなので、 listen キューに来たリクエストは他のワーカープロセスの epoll_wait() に通知され、取りこぼしがありません。

http://man7.org/linux/man-pages/man2/epoll_ctl.2.html

       EPOLLEXCLUSIVE (since Linux 4.5)
              Sets an exclusive wakeup mode for the epoll file descriptor
              that is being attached to the target file descriptor, fd.
              When a wakeup event occurs and multiple epoll file descriptors
              are attached to the same target file using EPOLLEXCLUSIVE, one
              or more of the epoll file descriptors will receive an event
              with epoll_wait(2).  The default in this scenario (when
              EPOLLEXCLUSIVE is not set) is for all epoll file descriptors
              to receive an event.  EPOLLEXCLUSIVE is thus useful for
              avoiding thundering herd problems in certain scenarios.

使い方は、 EPOLL_CTL_ADD するときに、 events で EPOLLIN などと bitwise OR で組み合わせて利用します。

struct epoll_event ev = {EPOLLIN | EPOLLEXCLUSIVE, NULL};
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

環境準備

Ubuntu 16.04 LTS xenial xerus や Amazon Linux 2016.03 といった最新(あるいはもうすぐリリースされる)のディストリビューションでも、採用しているのは Linux 4.4 なので、そのままでは試すことができません。 (Fedora 24 は Linux 4.5 らしいですが、普段使わないので除外しました)

そこで、 Ubuntu 15.10 を元に、 https://wiki.ubuntu.com/KernelTeam/GitKernelBuild にある手順に従い Linux 4.5 をソースからビルドして再起動することにしました。 書いてある手順通りで問題ないのですが、私の場合 Ubuntu 15.10 の公式 AMI のデフォルトディスクサイズ (8GB) で容量が足らずに一度ビルドに失敗して32GBに拡張したので、同じくAWSを使われる方は注意してください。

カーネルのインストールが終わって再起動したら、Linux 4.5 カーネルを利用していることを確認します。 また、 <sys/epoll.h> にはまだ EPOLLEXCLUSIVE が定義されていないので、定数の値を確認しておきます。

$ uname -a
Linux ip-10-0-1-221 4.5.0-custom #1 SMP Thu Mar 31 07:35:29 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux

~/linux-4.5/include$ ag EPOLLEXC
uapi/linux/eventpoll.h
30:#define EPOLLEXCLUSIVE (1 << 28)

EPOLLEXCLUSIVE を使った場合にどれくらい性能に影響がでるか、簡単なベンチマークをしたいので、 TIME_WAIT で詰まる問題を回避するおまじないをしておきます。

$ echo 'net.ipv4.tcp_tw_reuse = 1' | sudo tee -a /etc/sysctl.conf
$ sudo sysctl -p
net.ipv4.tcp_tw_reuse = 1

meinheld

meinheld は奥一穂さんの picoev を利用した Python アプリケーション用の HTTP サーバーです。

listen ソケット以外に、ワーカー間で共有しつつ epoll を利用する fd はないので、 EPOLL_CTL_ADD しているところで無条件に EPOLLEXCLUSIVE してしまいます。

diff --git a/meinheld/server/picoev_epoll.c b/meinheld/server/picoev_epoll.c
index 06e1dbb..773cf3c 100644
--- a/meinheld/server/picoev_epoll.c
+++ b/meinheld/server/picoev_epoll.c
@@ -115,6 +115,7 @@ int picoev_update_events_internal(picoev_loop* _loop, int fd, int events)
     SET(EPOLL_CTL_MOD, 0);
     if (epoll_ret != 0) {
       assert(errno == ENOENT);
+      ev.events |= (1 << 28);  // EPOLLEXCLUSIVE (from linux 4.5)
       SET(EPOLL_CTL_ADD, 1);
     }
   }
@@ -124,7 +125,12 @@ int picoev_update_events_internal(picoev_loop* _loop, int fd, int events)
   if ((events & PICOEV_READWRITE) == 0) {
     SET(EPOLL_CTL_DEL, 1);
   } else {
-    SET(target->events == 0 ? EPOLL_CTL_ADD : EPOLL_CTL_MOD, 1);
+    if (target->events == 0) {
+      ev.events |= (1 << 28);  // EPOLLEXCLUSIVE (from linux 4.5)
+      SET(EPOLL_CTL_ADD, 1);
+    } else {
+      SET(EPOLL_CTL_MOD, 1);
+    }
   }

 #endif

試験に使っていたのは AWS の c4.xlarge で、4コアなので、8ワーカー起動し、 "hello, world" を返すだけのアプリに ab で4並列で負荷をかけてみました。 (wrk ではなく ab を使ったのは、今回は keep-alive しない試験だったからです。)

結果は、改造前がだいたい 15k req/sec で、 改造後がだいたい 20k req/sec を超えるくらいでした。

uWSGI

Python だけでなくいろんな言語に対応している uWSGI というサーバーでも試してみました。 epoll に追加する関数が read と write で別れているので、accept で利用する read 側にだけ EPOLLEXCLUSIVE を追加します。

diff --git a/core/event.c b/core/event.c
index 36751a6..32a1934 100644
--- a/core/event.c
+++ b/core/event.c
@@ -514,7 +514,7 @@ int event_queue_add_fd_read(int eq, int fd) {
        struct epoll_event ee;

        memset(&ee, 0, sizeof(struct epoll_event));
-       ee.events = EPOLLIN;
+       ee.events = EPOLLIN | (1 << 28);
        ee.data.fd = fd;

        if (epoll_ctl(eq, EPOLL_CTL_ADD, fd, &ee)) {

こちらもだいたい meinheld と同じような数値 (15k req/sec -> 20+k req/sec) で性能向上を確認できました。

また、1並列で ab を実行したときの accept の回数を、 strace で確認してみました。

ubuntu:~/app$ strace -f ~/.local/bin/uwsgi --master --workers=8 --http-socket :8080 --wsgi hello 2>trace.log
ubuntu:~$ ab -c1 -n1000 http://localhost:8080/  # 別セッションから
ubuntu:~/app$ grep -c '] accept4' trace.log  # 改造前
6884
ubuntu:~/app$ grep -c '] accept4' trace.log   # 改造後
1557

改造前は 6884 回ですが、これは 8 プロセスのうち accept に成功した 1 プロセスが、レスポンスを返してから epoll し直している間に、他のプロセスが次のリクエストを accept しているから、7000回弱になっているのでしょう。

一方改造後は 1557 回になっていて、リクエストの回数の約 1.5 倍になっています。 man に書いてあるとおり、必ずしも1つのイベントに対して1つの epoll だけが起こされるわけではなく、複数の epoll が起こされる場合があるのだと思います。

雑感

meinheld を strace していて気づいたのですが、 thundering herd 問題が起きている状況で multi accept (1度 epoll が起きたときに、 accept を1度だけでなく、エラーが出るまで複数回実行する) をすると、「起こされたのにすることがない」状況が発生しやすくなります。

keep-alive に対応しないシンプルなアプリケーション・サーバーなら、レスポンスを返したあと次にすることがなので、「自分(レスポンスを返したばかりのプロセス)が寝るか、他人(起こされたばかり)が寝るか」の違いでしかありません。自分で実行したほうが、コンテキストスイッチを待たずに処理を始められるので、ベンチマークスコアは上がると思います。 一方、 keep-alive に対応していたり、 nginx のようにいろんな処理を行う場合は、 multi accept を無効化した方が綺麗にコネクションを分散させることができて良いかもしれません。

あと、今回はかなり雑な感じに EPOLLEXCLUSIVE 対応したのですが、古いディストリビューションを使いつつもカーネルをアップグレードすることはよくあるので、 #ifdef EPOLLEXCLUSIVE せずに、今回のような強引な方法で対応するのは意外と現実的な気がします。 実際、 xenial の標準のカーネル (Linux 4.4) で EPOLLEXCLUSIVE を有効にしても、 epoll_ctl がエラーを返すこともなく普通に動作しました。


@methane

songofacandy at 15:58│Comments(0)TrackBack(0)kernel | network

トラックバックURL

この記事にコメントする

名前:
URL:
  情報を記憶: 評価: 顔   
 
 
 
Blog内検索
Archives
このブログについて
DSASとは、KLab が構築し運用しているコンテンツサービス用のLinuxベースのインフラです。現在5ヶ所のデータセンタにて構築し、運用していますが、我々はDSASをより使いやすく、より安全に、そしてより省力で運用できることを目指して、日々改良に勤しんでいます。
このブログでは、そんな DSAS で使っている技術の紹介や、実験してみた結果の報告、トラブルに巻き込まれた時の経験談など、広く深く、色々な話題を織りまぜて紹介していきたいと思います。
最新コメント
「最新トラックバック」は提供を終了しました。