Nginxでのアクセス流量制御を検証してみた

皆さんこんにちは

Webサービスを運用していると、時に大量のアクセスが降ってくる場合があります。
これの理由は、攻撃目的だったり何らかのトレンドだったりしますが、なんにせよ大量のアクセスが発生した場合、Webサーバには大きな負荷が発生します。
クラウド環境であれば、まあ、オートスケールで対応するというのがよく言われるプラクティスではありますが、必ずしもオートスケールができる環境ではなかったり、スケールの時間がかかったり、スケールしまくって費用がかかったりします。
DDoSのように集中攻撃が来た場合は、スケーリングでは賄えず、サーバダウンまであります。

そこで、Nginxのngx_http_limit_req_module というモジュールを使うことで、アクセス流量を規制することができるようなので、こいつの使い方を見ていきましょう。

# TL;DR

1. Nginxでrate limitを設定すると、アクセス頻度の閾値を設定でき、その閾値を超えたアクセスを弾くことができる
2. rate limitに加え、burstを設定すると、一時的な大量アクセスに対して、そのリクエストを保持しておき、正しいアクセス頻度で処理を実施してくれる
3. nodelayを設定すると、ある基準に従って即座にリクエストを処理するか弾く

# アクセスの Rate Limit
[こちらのブログで詳細な内容は見れる](https://www.nginx.com/blog/rate-limiting-nginx/)のですがこの「Rate Limit」をなんと訳せばよいのか...
とりあえず、これの設定方法を見ておきましょう。

## システム
今回はNginxで受けてPHP-FPMで処理するという、単純な系を考えます。
FPM側では以下の処理を実行するのみです。

```index.php
<?php
error_log(microtime(true) - 1521443000);
usleep(500000);

echo 'Good';
```
ここでerror_logの部分は、詳細なアクセス時間をmicrotime単位で見るためのただの仕掛けです。
また、usleepの値を変えることで、レスポンスの返却時間を調整することができます。

## 設定
Nginxのconfigは以下のとおりです。

```nginx.conf
user www-data;
worker_processes 4;
pid /run/nginx.pid;
daemon off;

events {
  worker_connections  2048;
  multi_accept on;
  use epoll;
}

http {
  server_tokens off;
  sendfile off;
  tcp_nopush on;
  tcp_nodelay on;
  keepalive_timeout 15;
  types_hash_max_size 2048;
  client_max_body_size 7G;
  include /etc/nginx/mime.types;
  default_type application/octet-stream;
  access_log /var/log/nginx/access.log;
  error_log /var/log/nginx/error.log emerg;
  gzip on;
  gzip_disable "msie6";
  include /etc/nginx/conf.d/*.conf;
  include /etc/nginx/sites-available/*;
  open_file_cache max=100;
  charset UTF-8;
}
```

```sites-available/default.conf
limit_req_zone $binary_remote_addr zone=niisan:10m rate=10r/s;

server {

    listen 80 default_server;
    listen [::]:80 default_server ipv6only=on;

    server_name default;
    root /var/www/;
    index index.php index.html index.htm;
    client_max_body_size 2g;

    location / {
         try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        limit_req zone=niisan;
        try_files $uri /index.php =404;
        fastcgi_read_timeout 600s;
        fastcgi_send_timeout 600s;
        fastcgi_pass php-upstream;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\.ht {
        deny all;
    }
}
```
設定ファイルが2つありますが、大事なのは二つ目の`sites-available/default.conf`のほうです。

## limit_req_zone
`sites-available/default.conf`のはじめの行を見てみましょう。

```
limit_req_zone $binary_remote_addr zone=niisan:10m rate=10r/s;
```
これはrate limitを行うための設定項です。
`$binary_remote_addr`はIPアドレスを示していますが、この設定項目は以下のように翻訳されます。

「IPアドレスごとに10r/sの制限を、'niisan'という名称で定義する。10MB( 10m )分のIPアドレスを保持する」

という意味になります。
10r/sというのは、アクセス頻度のことで、1秒間に10アクセスする程度の頻度を表しています。
また、ここで注意しておくのはここでリクエスト制限を**定義しているだけで、実施してはいない**ということです。

今回はIPアドレスごとの制限を入れることにしましたが、ここには任意の変数を使えます。
GETパラメータに`country`というパラメータがあれば、`$arg_country`を`$binary_remote_addr`の代わりに使うことで、「リクエストパラメータごとのリクエスト制限」をかけることもできます。

## limit_req
`sites-available/default.conf`の

```
limit_req zone=niisan;
```

の部分を見てみましょう。これは先に`limit_req_zone`にて定義した`niisan`という名前の制限を実際に適用している部分です。

## burst
`limit_req`には`burst`という値を定義できます。

```
limit_req zone=niisan burst=20;
```

後ほど説明しますが、これは「リクエストの制限に引っかかっているとき、20個まで次の接続を保持しておく」ことを意味します。

## nodelay
`burst`が設定された場合は、さらに`nodelay`を設定できます。

```
limit_req zone=niisan burst=20 nodelay
```
`nodelay`は非常にややこしい概念ですので、実際にさわってみないとその効果を実感できないと思いますが、burstのように接続を保持したりせず、ある基準に従ってリクエストを即処理するか弾くかを決めるようになります。
その基準については後述します。

# 検証
各設定によって、アクセス流量がどのように制御できるのか確かめてみましょう。
全体に渡り、rate は`10r/s`とします
検証には`ab`を使います

## burstなし
単純な場合の検証をしてみましょう。index.phpの`usleep`の値を0.1secにして以下のコマンドを投げます

```
ab -n 10 -c 1 http://192.168.99.100/
```
すると結果が出ます。

```
Concurrency Level:      1
Time taken for tests:   1.040 seconds
Complete requests:      10
Failed requests:        0
Total transferred:      1600 bytes
HTML transferred:       40 bytes
Requests per second:    9.62 [#/sec] (mean)
Time per request:       103.988 [ms] (mean)
Time per request:       103.988 [ms] (mean, across all concurrent requests)
Transfer rate:          1.50 [Kbytes/sec] received
```
問題なさそうです。

次に、index.phpの`usleep`を0.05secにしてみましょう。

```
Concurrency Level:      1
Time taken for tests:   0.072 seconds
Complete requests:      10
Failed requests:        9
   (Connect: 0, Receive: 0, Length: 9, Exceptions: 0)
Non-2xx responses:      9
Total transferred:      3634 bytes
HTML transferred:       1858 bytes
Requests per second:    138.10 [#/sec] (mean)
Time per request:       7.241 [ms] (mean)
Time per request:       7.241 [ms] (mean, across all concurrent requests)
Transfer rate:          49.01 [Kbytes/sec] received
```
成功したのは一つだけで、他はすべて失敗しました。
ここでわかるように、`rate=10r/s`は、「1秒間に10アクセスまで許可する」を意味していません。**「1秒間に10アクセス相当のアクセス頻度であれば構わない」**を意味しています。
言い換えると、**「アクセス間隔は1/10秒(=100msec) 以上あけないと駄目」**という意味でもあります。
今回の例ではusleepを0.05秒に設定してしまったので、次のリクエストまでの間隔が0.1秒未満になってしまったため、アクセス制限に引っかかったということです。

同じことはusleepを0.2secに設定して、同時接続数を増やしてもいえます。

```
ab -n 10 -c 2 http://192.168.99.100/
```
結果はこんな感じです

```
Concurrency Level:      2
Time taken for tests:   0.527 seconds
Complete requests:      10
Failed requests:        6
   (Connect: 0, Receive: 0, Length: 6, Exceptions: 0)
Non-2xx responses:      6
Total transferred:      2956 bytes
HTML transferred:       1252 bytes
Requests per second:    18.99 [#/sec] (mean)
Time per request:       105.325 [ms] (mean)
Time per request:       52.662 [ms] (mean, across all concurrent requests)
Transfer rate:          5.48 [Kbytes/sec] received
```
やはりエラーが出ています。同時接続ということで、リクエスト間隔がほぼない状態でアクセスされるので、そこで一方が弾かれるみたいです。
エラー数が先程よりも少ないのは2つのアクセスがいい感じの時間間隔でアクセスできたからかなと思います。

## burst あり
次にburstを設定しておきましょう。

```
limit_req zone=niisan burst=10;
```

先程usleepを0.05secに設定した場合はエラーが出ていましたが、今回はどうでしょう？

```
ab -n 10 -c 1 http://192.168.99.100/
```
すると結果は以下のようになります

```
Concurrency Level:      1
Time taken for tests:   0.957 seconds
Complete requests:      10
Failed requests:        0
Total transferred:      1600 bytes
HTML transferred:       40 bytes
Requests per second:    10.45 [#/sec] (mean)
Time per request:       95.700 [ms] (mean)
Time per request:       95.700 [ms] (mean, across all concurrent requests)
Transfer rate:          1.63 [Kbytes/sec] received
```
今回はすべて成功しました。

先程はusleepが0.05secになったことで、次のリクエストまでの間隔が狭くなり、リクエストが弾かれていたわけですが、今回は弾かれることなく、しかもリクエスト間隔はほぼ0.1secとなっています。
`burst`を設定すると、**間隔の狭いリクエストが来た場合、次のリクエストを一旦待機し、rate limitで設定した間隔を満たすタイミングで処理する**ようになります。
Webサービスの負荷は変動しますので、ちょっとリクエスト数が増えたところで、いきなりエラーを連発されても困りますが、burstをつけることで、流量をある程度調節できるということになります。

burstは次のような同時アクセスにおいても有効です。

```
ab -n 40 -c 10 http://192.168.99.100/
```
これの結果は以下のとおりです。

```
Concurrency Level:      10
Time taken for tests:   3.952 seconds
Complete requests:      40
Failed requests:        0
Total transferred:      6400 bytes
HTML transferred:       160 bytes
Requests per second:    10.12 [#/sec] (mean)
Time per request:       987.885 [ms] (mean)
Time per request:       98.788 [ms] (mean, across all concurrent requests)
Transfer rate:          1.58 [Kbytes/sec] received
```
しかし、次の場合は異なる結果が現れます。

```
ab -n 40 -c 11 http://192.168.99.100/
```
この結果は以下のとおりです。

```
Concurrency Level:      11
Time taken for tests:   1.161 seconds
Complete requests:      40
Failed requests:        28
   (Connect: 0, Receive: 0, Length: 28, Exceptions: 0)
Non-2xx responses:      28
Total transferred:      12728 bytes
HTML transferred:       5816 bytes
Requests per second:    34.44 [#/sec] (mean)
Time per request:       319.355 [ms] (mean)
Time per request:       29.032 [ms] (mean, across all concurrent requests)
Transfer rate:          10.70 [Kbytes/sec] received
```
エラーが頻発しています。
`burst=10`という設定をしていましたが、これは、**「保持しておくリクエスト数は10まで」**を意味しており、それ以降のリクエストは制限に従い弾かれます。
今回の例で言えば、usleepが0.05secなので、一つ目のレスポンスが返り再度リクエストしたときには、まだ次の10のリクエストが残ったままになっており、自身が11個めのリクエストとなって弾かれる、という事態が多発しているということです。

## nodelay
nodelayを入れると、rate limitの概念が少し変わるため、注意が必要です。
此処から先は、よりnginxの動きがわかりやすいよう、php側のusleepは0.01secにし、ほとんど影響が出ないようにします。

```
limit_req zone=niisan burst=10 nodelay;
```

まずは普通にabを走らせましょう

```
ab -n 10 -c 1 http://192.168.99.100/
```
結果は以下のようになります。

```
Concurrency Level:      1
Time taken for tests:   0.179 seconds
Complete requests:      10
Failed requests:        0
Total transferred:      1600 bytes
HTML transferred:       40 bytes
Requests per second:    56.02 [#/sec] (mean)
Time per request:       17.851 [ms] (mean)
Time per request:       17.851 [ms] (mean, across all concurrent requests)
Transfer rate:          8.75 [Kbytes/sec] received
```
ここで注目すべきはリクエスト頻度で、`56.02 r/s`と設定値の`10 r/s`を大きく上回っています。

同時接続数を上げるとこの傾向はさらに顕著になります。

```
ab -n 10 -c 10 http://192.168.99.100
```
これの結果は以下のようになります。

```
Concurrency Level:      10
Time taken for tests:   0.054 seconds
Complete requests:      10
Failed requests:        0
Total transferred:      1600 bytes
HTML transferred:       40 bytes
Requests per second:    184.25 [#/sec] (mean)
Time per request:       54.273 [ms] (mean)
Time per request:       5.427 [ms] (mean, across all concurrent requests)
Transfer rate:          28.79 [Kbytes/sec] received
```
リクエスト頻度は設定値の18倍となっています。

こう見ると、rate limitは効いていないように見えますが、次の検証をすると、様子が変わります。

```
ab -n 20 -c 10 http://192.168.99.100
```
nを10 -> 20にしただけですが、結果は以下のとおりです。

```
Concurrency Level:      10
Time taken for tests:   0.067 seconds
Complete requests:      20
Failed requests:        9
   (Connect: 0, Receive: 0, Length: 9, Exceptions: 0)
Non-2xx responses:      9
Total transferred:      5234 bytes
HTML transferred:       1898 bytes
Requests per second:    296.47 [#/sec] (mean)
Time per request:       33.730 [ms] (mean)
Time per request:       3.373 [ms] (mean, across all concurrent requests)
Transfer rate:          75.77 [Kbytes/sec] received
```
一気にエラーが増えました。
もう一つ例を見てみます。

```
ab -n 14 -c 1 http://192.168.99.100
```

```
Concurrency Level:      1
Time taken for tests:   0.182 seconds
Complete requests:      14
Failed requests:        2
   (Connect: 0, Receive: 0, Length: 2, Exceptions: 0)
Non-2xx responses:      2
Total transferred:      2692 bytes
HTML transferred:       460 bytes
Requests per second:    76.87 [#/sec] (mean)
Time per request:       13.008 [ms] (mean)
Time per request:       13.008 [ms] (mean, across all concurrent requests)
Transfer rate:          14.44 [Kbytes/sec] received
```
こちらもエラーが増えています。
しかし、`-c 10`のときに比べると、成功数が一つ多くなっています。

このnodelayについては、nginxのブログを見ると、すごい面倒な説明をしていますが、私なりに理解すると以下のようになります。

1. nodelayが設定されると、burst + 1個のスロットが設定される
2. リクエストが来たとき、空のスロットがある場合は、そのリクエストはそのまま処理され、代わりに空きスロットが使用中になる
3. 空きスロットがない状態でリクエストが来ると**そのリクエストは即弾かれる (503)**
4. burst + 1個のスロットはrate limit で設定された頻度で解放される

今回、`burst=10`なので、スロットは11個できます。
`-n 20 -c 10`のときは、はじめの10個のリクエストは、スロット全てが空いているので、即座に処理されます。そしてその次の10個のリクエストは、開いているスロットが1個しかないので、一つだけ処理され、他は即弾かれます。
`-n 14 -c 1`の場合はリクエストするたびにスロットが埋まっていき、11個埋まった時点で以降は弾かれるようになりますが、最初のリクエストからある程度時間が経っているため、スロットが一つ開放されたことにより、一つだけ正常に処理されるスロットができたということだと思います。

nodelayは、散発的にある程度まとまったリクエストが飛んでくる分には、それほど苦しくないのだけれど、そのまとまったリクエストが継続して飛んで来るようなら苦しくなるので、弾かせてもらうっていうことだと思います。
Nginxのブログでも言及していますが、burst を使うなら、nodelayも使ったほうがいいみたいです。

# まとめ
というわけで、Nginxを使ったアクセス流量制御を検証してみました。
自分のやっているサービスにやたらと大量のアクセスをしてくる人がいるので、流量制限をつけたいなぁって思ったので、調べてみました。
特にnodelayはrate limit の挙動がまるで変わるので、すごく困惑しましたが。とりあえず妥当性は理解したつもりです。

今回はこんなところです。