Nginxには RateLimit moduleが標準で含まれており、下記のような定義を行うことで、大量のアクセスが来た場合に、リクエストを絞ることが出来ます。
limit_req_zone $server_name zone=example:10m rate=10r/s;
server {
location / {
limit_req zone=example;
proxy_pass http://example_upstream;
}
}
このように定義すると、Nginxをプロキシサーバとして利用している場合に、大量のアクセスでプロキシ先のオリジンサーバが過負荷に陥ることを避けることが出来ます。
しかし、このRateLimitの仕組みも万能ではなく、例えば、上記の定義のように1サイトあたり秒間100リクエストと定義した場合に秒間1000リクエストがNginxに到達すると、900リクエストは503で即時エラー応答となり、100リクエストのみ、オリジンサーバにトラフィックが到達し、レスポンスを返すことが出来ます。
昨今のWEBページは、多くのファイルで構成されており、そのファイルの一部がアクセスできないともはやWEBページとしての機能を提供することが出来ません。例えば、CSSファイルだけがリクエスト成功して、Javascriptがリクエスト失敗すると動的なページが動かないというケースが起こりえます。
これらを改善するには、RateLimitでアクセスを制限することに加えて、何かしらの仕組みで一定数のクライアントをオリジンサーバに対して安定して通信させることが必要になります。具体的には、1サイト辺り、秒間100リクエストのRateLimitがあったとして、そのRateLimitに該当したとしても、そのうちの10クライアントだけは、そのRateLimitを無視してアクセスできるというような仕組みです。
今回開発した、nginx-smart-ratelimit はngx_mrubyのハンドラ内で、リクエストに対してセッションキーを払い出し、そのセッションキーをアクセス順に許可されたコネクション数までアクセスを可能にするプログラムです。処理の流れは下記のとおりです。
- NginxがRateLimitに該当したリクエストを、ステータスコード512でエラー扱いにし、
/smart_limit
エンドポイントへ内部ルーティング /smart_limit
のmruby_rewrite_handler
内で、下記の処理を行う- クッキーにセッションIDがなければ、新規に払い出しを行い、待ち行列リストにセッションIDを追加
- クッキーにセッションIDがあれば、そのセッションIDが待ち行列リストの先頭かつ、過去1分のコネクション数と現在のコネクション数が指定されたコネクション数に到達していなければ、そのセッションIDは10分間RateLimitに関係なく、オリジンサーバにプロキシすることができる。
- クッキーのセッションIDが待ち行列の先頭ではなかった場合、待ち行列の先頭のセッションIDが20秒以内にアクセスされているかどうかを確認し、20秒以内にアクセスされていない場合は、不在とみなし、待ち行列リストの先頭のレコードを削除し、次点を繰り上げる
これを図にしたのが下記のとおりです。
効果測定
セッションキーをいい感じに保存しつつ、ベンチマークかけれるようなツールがなかったので、自分で簡単なものを実装して測定しました。 測定項目としてはnginx-smart-ratelimitを利用した場合と利用していない場合のステータス200とそれ以外のレスポンスコードが応答された数を比較します。
測定条件としてはRateLimitは下記の通り、IPあたり10request/secで制限されています。
limit_req_zone $binary_remote_addr zone=test:10m rate=10r/s;
smart-ratelimitの同時接続数は MAX_CONNECTION=5
としました。
Nginxのプロキシ先のオリジンサーバとしてはredmineを利用しています。
結果は下記です。
なんかもうあまりにも差がありすぎてわからないのですが、
Status 200 | Other | |
RateLimit | 55 | 9945 |
SmartRateLimit | 5090 | 4910 |
ステータス200応答が約100倍程度になり、かなりアクセスできていることがわかります。性能については簡易的ではありますが、timeコマンドを利用して3回取得してみます。
Ratelimit
% bash -c "time ./bench"
real 0m32.050s
user 0m1.987s
sys 0m2.577s
% bash -c "time ./bench"
real 0m37.175s
user 0m2.144s
sys 0m2.846s
% bash -c "time ./bench"
real 0m39.236s
user 0m2.103s
sys 0m2.741s
平均、36秒くらいなので、だいたい277req/sec
SmartRateLimit
% bash -c "time ./bench"
real 1m18.973s
user 0m2.860s
sys 0m3.281s
% bash -c "time ./bench"
real 1m23.712s
user 0m2.870s
sys 0m3.330s
% bash -c "time ./bench"
real 1m21.885s
user 0m2.847s
sys 0m3.306s
平均、80秒くらいなので、だいたい125req/sec。100倍オリジンにアクセスしてると考えると、妥当というかだいぶ速いという印象を個人的に持ちました。
実装詳細
それぞれの技術要素について、コードを交えて紹介します。実装としてはngx_mrubyからRedisをセッションストアとして利用することで実現しています。
待ち行列リスト
待ち行列は、追加、参照、削除があります。
追加についてはMULTIでトランザクションをとりつつ、RPUSHして、EXPIREを設定しています。レートリミットがかかるような局面であれば自動でTTLが伸びていき、RateLimitがかからないくらいに落ち着いたら自動で待ち行列リストが消えるような動作を想定しています。
def add_wait_list(redis, list_key, skey)
redis.multi
redis.rpush(list_key, skey)
redis.expire(list_key, LIST_TTL)
redis.exec
Nginx.errlogger Nginx::LOG_INFO, "#{skey} set value to redis"
end
またRPUSHは重複排除がないので、重複して追加されることを避けるために、呼び出し元でSETNXでロックをとって実行しています。
elsif redis.set(skey, WAITING, "NX" => true) == 'OK'
add_wait_list(redis, LIST_KEY, skey)
end
参照は、素朴にLRANGEを使っています。
next_sess = (a = redis.lrange(LIST_KEY, 0, 0)) ? a.first : nil
削除についてはSETNXでロックを取りつつ、LPOPしています。原理的には起きないと思うのですが、もし意図しないキーをPOPしてしまった場合は、改めて先頭に追加し直すような動作をします。
def delete_from_list(redis, list_key, skey)
pop = redis.lpop(list_key)
if skey != pop
Nginx.errlogger Nginx::LOG_ERR, "different list value pop:#{pop} session:#{skey}"
redis.lpush(list_key, pop)
return false
end
return true
end
待ち行列の先頭の管理
待ち行列の先頭にいるセッションIDがすでにリクエストを諦めている場合に、以降の待ちが解消されないので、RedisのTTLを利用して、そのセッションIDの有効性を確認しています。
# 待ち行列の先頭が無効
elsif (!redis.get(next_sess) || redis.get(next_sess) == ACCEPT) && redis.set(LOCK_KEY, "1", "NX" => true) == 'OK'
begin
delete_from_list(redis, LIST_KEY, next_sess)
ensure
redis.del(LOCK_KEY)
end
end
上記の redis.get
で戻り値がnilであれば、20秒に設定した、TTLが切れているとみなします。TTLについては予め、アクセスごとに延命するようにしているので、TTL以内にアクセスすれば、再度20秒延長されるような挙動になっています。また、RedisのValueに許可済みフラグをもっているので、すでに先頭のレコードがアクセス許可済みであればその場合も待ち行列リストから先頭のレコードを削除します。
同時アクセス数の管理
同時アクセス数についてはRedisのSADDを利用して、タイムスパンを1分とし、現在の時間と、過去1分を管理しています。
def last_connection(redis, host)
redis.scard("#{host}_#{(Time.now-60).min.to_s}")
end
def current_connection(redis, host)
redis.scard("#{host}_#{(Time.now).min.to_s}")
end
def count_connection(redis, host, value)
redis.multi
redis.sadd("#{host}_#{(Time.now).min.to_s}", value)
redis.expire("#{host}_#{(Time.now).min.to_s}", 121)
redis.exec
end
SADDは重複排除もあり、計算量も低いので簡単にカウンターを実装することが出来ます。SADDされたキーは、121秒保持しておくことで、過去1分の情報が確実に見れるようにするのと不要になったら自動で消えるようにしています。
最後に
お察しのよい方は気づかれたかもしれませんが、503のレンダリングページにJavascriptなどを用いて、タイマーを回して10秒ごとにリロードするというような動作をした場合、いわゆるWaitingRoom(待合室)のような動作を実現することが出来ます。現在のコネクション数や、ユーザーが待ち行列の何番目にいるかというのも取得することができるので、大量にアクセスを受けるサービスで有効に機能すると思います。今回はまだ僕もプロダクションに入れていない、PoCレベルなのですが、いずれどこかで、バーーーンとお話できると思うのでお楽しみにお待ち下さい。