7月 302016
 

キャパシティプランニングをする際に頭がいたいものの一つに通常ではないアクセスがあります。
ぱっと思いつくので

  • 閲覧数がページに表示されているのでF5押しっぱなし
  • スクリプトでスクレイピングしようとしているのか暴走している
  • 足跡をつけるために尋常じゃない速度で訪問しまくる
  • ログイン試行
  • 画像をひたすらダウンロード

などなどいろいろあります。
これらに共通なのが、通常ではないリクエストで大量のリソースを消費することです。(もちろん他の問題(セキュリティ)があるものもあります)
もしキャッシュしていたとしても、アウトバウンド帯域を過剰に利用しますし、キャッシュが出来なければwsやdbなどでの負荷になります。
キャパシティプランニングをする際には様々な条件を考えて構築していきます。
単純にユーザーが増えて負荷が増えていくのは望ましく、喜んでインスタンスを増やしたり負荷対策をしますが
そうでない場合は、通常のユーザが巻き添えを食らわないようにキャップしたいものです。
今回は、Varnishでスロットルを行ってみようという記事です。

Varnishでスロットルをかけるのはすごい簡単で、vmod_vsthrottleというVMODを使用します。(ドキュメント)
これはvarnish-modulesに含まれています。
varnish-modulesはVarnish Softwareが作ったVMODで特に他のライブラリに依存しないものを集めたものです。(なのでdigestが入っていない)
ちなみに以下のVMODが含まれています。

  • cookie
  • header
  • saintmode
  • softpurge
  • tcp
  • var
  • vsthrottle
  • xkey

機会があれば、ほかのもそのうち紹介します。

まずはインストールです。
READMEにも書いてあるとおりにインストールします。


xcir@gw01:~$ sudo apt-get install libvarnishapi-dev
xcir@gw01:~$ git clone https://github.com/varnish/varnish-modules.git
xcir@gw01:~$ cd varnish-modules/
xcir@gw01:~/varnish-modules$ ./bootstrap
xcir@gw01:~/varnish-modules$ ./configure
xcir@gw01:~/varnish-modules$ make
xcir@gw01:~/varnish-modules$ sudo make install

./bootstrapでaclocalとかlibtoolizeで引っかかったらautomakeとかlibtoolを入れましょう
インストールはこれだけです。

次に使い方です。


BOOL is_denied(STRING key, INT limit, DURATION period)

keyはクライアントの識別子(IPなど)をいれます。
limitとperiodは組み合わせてつかいます。
period時間中にlimit回数を超えたらアウトみたいな感じです。
要はトークンバケットアルゴリズムです。
なので時間経過で使用した分は回復していきます。(呼び出し時に経過時間を調べて回復させます)
また、limitとperiodの組み合わせで平均RPSとバースト時のRPSを表現できます。
limitがバースト時RPSです。
limit / periodで平均のRPSになります。
なので例えば
limit=6 period=6sであれば平均は1RPSでバースト時は6RPS
limit=6 period=2sであれば平均は3RPSでバースト時は6RPS
みたいな感じになります。
うまく組み合わせて使いたいですね。

また、keyとありますが実際のkey生成にはkey + limit + periodのsha256をとっていますので
同じkeyを指定しても、limitやperiodが変われば別物として扱われます。

とりあえずテストでは/img/以下の画像(jpg|gif|png)を10秒の間に5回リクエストしたら制限をかけて429を返すようにします。
keyはclient.ipです。
image:というprefixをつけているのは、ルールを複数作ることも考えて意図せず被らないようにするためです。


vcl 4.0;
import vsthrottle;

sub vcl_recv {
  if(req.url ~ "^/img/.*\.(jpg|gif|png)" && vsthrottle.is_denied("image:" + client.ip, 5, 10s)) {
    return (synth(429, "Too Many Requests"));
  }
}

早速リロードしてみましょう


xcir@gw01:~/varnish-modules$ sudo varnishncsa -q "requrl ~ '/img/'"
***** - - [30/Jul/2016:01:40:14 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:14 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:14 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:14 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:15 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:15 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 429★ 275 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:15 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 429 275 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:15 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 429 275 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:32★ +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200★ 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 429 275 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:01:40:33 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 429 275 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
6

6件目から429が帰ってるのがわかります。
また制限が10秒のうちに5回なので、しばらくまってリクエストして制限が解除されているのもわかります。
割と簡単だと思います。
ちなみに、1keyあたり100byteのメモリを使うのと、mutexを使用しているのでそこは頭の片隅に入れておくと良いかと思います。
といってもmutexは16分割してるので、通常の利用であればそこまで気にする程ではないと思います。


※ここから先は特にそのサイトのワークロードによってかなり考え方が変わります。
 なのでそのまま適用するのではなく、それぞれのサイトで考えてもらえればよいかと思います。
 また、異常行動に対してのキャップなので、継続的な改善サイクルを回す必要があります。
 そういうのは最初に想定仕切るの難しかったり、ページによってコストも違うからです。

とりあえずこれでスロットル早速やってみよう!という人がいましたらちょっと注意が必要です。
スロットルをかけるということは、ユーザの体験を制限してしまう可能性があります。
そのため安易に設定し、誤爆して普通の使い方をしているユーザを制限してしまわないように細心の注意を払う必要があります。
とはいっても、それでゆるゆるな制限をかけても全く有効ではありません。
個人的にスロットルをする上では2つのことが重要だと考えています。

  • 誤爆しない
  • 理不尽でない

あまりにも厳しく、普通に使っているユーザが誤爆されるような低い閾値であればイライラして離れていってしまうでしょう。
また、仮に引っかかっても、まぁしょうがないなとある程度納得ができるのも重要です。
例えば連打が許容されるボタンがあったとして、それを10秒で100回も連打して引っかかったら、まぁしょうがないなと思えるんじゃないかなと思います。
この辺りは、サイトの特性によって違いますので各々で考える必要があります。

誤爆しないための工夫
閾値の設定はすごく難しいです。
サイトの保護を優先したければ閾値は低くしたいですし
誤爆を防ぐには閾値は高くしたいです。

しかし、低くすれば誤爆が増え、高くすれば意味がなくなります。
ではどうすればよいかというと、トークンバケットへの理解とユーザの行動を考えてみることです。

最初にトークンバケットですが、これを何かに例えるとするとSuicaのチャージです。
飲み物や昼食などのお小遣いとして毎朝1000円チャージするとします。
チャージされてる分は自由に使って良くて、例えば一気に5000円使っても問題はありません(残額があれば)
また、Suicaのチャージ上限は2万円なので、そこに達した時点でそれ以上チャージができなくなります。
さらに開始時点から上限の2万円がチャージされているとします。
これをvsthrottle.is_deniedで表現すると


vsthrottle.is_denied(client.ip, 20000, 20d);

といった感じです。
平均すると1日辺り1000円使えますが、もし毎日2000円使うとなると20日経過した時点で初期にチャージされてた2万を使いきって一日待たないと2000円は使えなくなります。
1000円を超えてる部分はburstといった感じになります。

次に、ユーザの行動をYahooのトップ/ニュースページで考えてみましょう。(記事ページを守る)
ニュースの欄には複数のリンクがあって複数気になる記事があります。
その場合、行動は2パタンに別れると思います。

  1. 開いて読んで、読み終わったら戻って次の記事を読む
  2. 気になる記事を一気に開いて順次読んでいく

1は特に気にする必要はありません。記事を1秒で読んで素早く戻って次の記事を見ることができる人類はまずいないでしょう。
もし、1だけを考えるのであれば1秒に1回で制限をかけてしまえば良いと思います。
しかし、2の場合は一気にリンクを開くため、1秒で2~3は開くことは人類でもできるので先ほどのの制限では誤爆してしまいます。(本筋ではないですがChromeがbackspaceで戻れなくなったのでこういう人増えそうですね)
では、次に制限したいものを考えてみましょう。
今回はすべての記事を舐めてくようなクローラーで想定してみます。
このクローラーは3RPSで舐めていきます。
とりあえず登場するものが揃ったので条件を整理します。

1. 1ページ開いて5秒程度で読んで戻ってまた開くユーザの場合

  • avg=0.2RPS
  • burst=1RPS

2. リンクを最大6リンク程度開いて(秒間3程度)各ページを5秒程度で読むユーザの場合

  • avg=0.1875RPS
  • burst=3RPS

3. クローラー

  • avg=3RPS
  • burst=3RPS

条件が揃いました。
許容したいburstは2の3RPSです。
しかしavgでは0.2もあれば十分です。
これを単純に落としこむと
limit=3 period=15s (avg=0.2 burst=3)
となります。
しかし制限したいクローラーに対してはまだ余裕があるので自分ならですが
limit=4 period=10s (avg=0.4 burst=4)
ぐらいに設定すると思います。

とりあえずVCLを書いてみます。


vcl 4.0;
import vsthrottle;
import std;

sub vcl_recv {
  if(req.url ~ "^/pickup/" && vsthrottle.is_denied("pickup:" + client.ip, 4, 10s)) {
    std.log("THROTTLE:pickup:");
    //return (synth(429, "Too Many Requests"));
  }
}

ここでは一旦429は出さずにlogに出力しています。
これは実際に投入した後に、どういうクライアントが引っかかるかを試すためです。(テストのため引っ掛けるルールをちょろっと変えてます)


xcir@gw01:~$ sudo varnishncsa -q "vcl_log ~ 'THROTTLE:pickup:'"
***** - - [30/Jul/2016:19:48:38 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "http://blog.xcir.net/?p=2283" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"
***** - - [30/Jul/2016:19:48:38 +0900] "GET http://xcir.net/img/bg2.png HTTP/1.1" 200 10355 "http://blog.xcir.net/?p=2283" "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36"

vslqueryを使用して引っかかるものだけを確認して、問題がなければreturnのコメントを外して有効にしましょう。
個人的には、std.logはそのまま残しておいたほうがqueryで絞込ができるので便利だと思います。

IPは本当に個人を識別するものか
先ほどの例では、クライアントを識別するのにclient.ipを利用しました。
しかし、これは本当に個人を識別できているでしょうか?
例えば、アクセスログを見てるとHost名がgoogle-proxy-*.google.comというホストでアクセスしてくるクライアントがいます。
これはChromeのデータセーバーで一旦Googleを経由しています。(参考1, 2
つまり、同時に同一proxyから異なるユーザがリクエストしてくる可能性があります。
その場合はスロットルが意図せず掛かる可能性があります。
もちろん、Chromeのデータセーバー以外にもこのようなproxyがあります。
サイトの規模にもよりますが、大規模な場合はこの辺りも意識する必要があります。(リクエストが多ければ多いほどかぶる可能性が上がるので)
じゃぁキーをどうすればよいのかというといろいろあります。
ログイン後のページで制限をかけたいのであれば、クッキーからキーを抜き出しても良いと思います。
非ログインの場合は、ブラウザフィンガープリントを調べると良いかなと思います。(参考1, 2
他にもいろいろありますが、制限をしたい箇所によって最適な方法を模索していくと良いかなと思います。

最後に
スロットルはサイトでユーザがどう行動するかや、何が異常でそうでないかの見極めをきちんとしないとユーザ・運営共に酷いことになってしまいます。
しかしうまく使うことができれば、サイトの安定性を向上させることが出来ますので一度検討してみてはいかがでしょうか?