NginxのRatelimit発動時に、安定したアクセスを提供するngx-smart-ratelimitを開発しました

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のハンドラ内で、リクエストに対してセッションキーを払い出し、そのセッションキーをアクセス順に許可されたコネクション数までアクセスを可能にするプログラムです。処理の流れは下記のとおりです。

  1. NginxがRateLimitに該当したリクエストを、ステータスコード512でエラー扱いにし、/smart_limit エンドポイントへ内部ルーティング
  2. /smart_limitmruby_rewrite_handler 内で、下記の処理を行う
    1. クッキーにセッションIDがなければ、新規に払い出しを行い、待ち行列リストにセッションIDを追加
    2. クッキーにセッションIDがあれば、そのセッションIDが待ち行列リストの先頭かつ、過去1分のコネクション数と現在のコネクション数が指定されたコネクション数に到達していなければ、そのセッションIDは10分間RateLimitに関係なく、オリジンサーバにプロキシすることができる。
    3. クッキーのセッション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 200Other
RateLimit559945
SmartRateLimit50904910

ステータス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レベルなのですが、いずれどこかで、バーーーンとお話できると思うのでお楽しみにお待ち下さい。