プログラムフレンドリーなアクセス制限〜nginx で Too Many Requets と Retry-After を返す〜

本エントリは「I love Web Server」と公言するBacklog チームのリーダやまもとの代打にてお送りします。

Tsf9otzQcL7yOQDR-C667B

Backlog は他の Web サービスと同様、ユーザの皆様が自分たちの業務のフロー にあわせた処理を柔軟に行えるように API を 提供しています。 先日の親子課題(サブタスク)やテーマ機能の追加にあわせて新しい API の追加や既存の API も更新がされていますので、まだの方は是非チェックしてみてくださいね。

さて API や Git、 Subversion そして WebDAV といった機能へは、 専用プログラムから機械的にリクエストする事が簡単に出来る事もあり、 ユーザが意図せずに大量のリクエストを発生させてしまうことがあります。 実際、過去に同一クライアントから1分間で2万近いリクエストを 受け取った事もあります。 ご想像に難くない通り、そういった大量のリクエストをそのまま受け付けると、 場合によってはサーバダウンを引き起こしてしまいます。

そこで Backlog では同一のクライアントから一定の閾値を越えたリクエストを 拒否する処理をアプリケーションサーバの前段にリバースプロキシとして配置された nginx にて行っています。

Tsf9otzQcL7yOQDR-D38EA

具体的には nginx の limit_req モジュール を用いて、以下のような形で設定しています。 これで同一の IP アドレスから 1 秒間に 4 リクエストまでは受け付けます。 (これはサンプルで、実稼働環境のものとは異なります)

limit_req_zone  $binary_remote_addr  zone=api:1m rate=4r/s;

location /api {
    limit_req  zone=api;
:
}

 さて、ここまでは nginx を利用されている方にとっては普通の話。

ここからちょっとイイ話 をご紹介します。

limit_req モジュールはリクエストを拒否する場合にステータスコード 503 (Service Unavailable) を返します。 503 は通常サーバ側に問題があることを表しますが、リクエスト数で制御を行っている場合は拒否された原因はむしろクライアント側にあります。 ここで 503 が返されてしまうと、クライアント側には本当の原因が分かりませんし、 むやみにリトライをすると状況を悪化させかねません。

そういった状況に対応するため RFC 6585 で提案されているのが、 ステータスコード 429 (Too Many Requests) です。 読んで字の如く、まさに limit_req モジュールで返したいステータスコードです。 内容を RFC 6585 より抜粋しますと以下の通りです。(日本語は抄訳です)

The 429 status code indicates that the user has sent too many
 requests in a given amount of time ("rate limiting").

 ステータスコード 429 は一定の時間内にユーザが大量にリクエストを行った事を表す。

 The response representations SHOULD include details explaining the
 condition, and MAY include a Retry-After header indicating how long
 to wait before making a new request.

 応答時にはその制限の詳細について説明する事が望ましく、また次のリクエストを
 行う前にどれくらい待てばよいかを Retry-After ヘッダにて送信してもよい。

ただ RFC 6585 は 2012年4月 に策定された比較的新しいものということもあり、

  • nginx 1.2 では未対応
  • nginx 1.4 では limit_req_status によるステータスコードの設定が可能

といった範囲に対応が留まっています。そこで Backlog では、nginx 1.2.x に以下のパッチをあてて、429 を返しつつ Retry-After をリスポンスヘッダに追加するようにしています。

このパッチを適用すると、Retry-After ヘッダは limit_req の設定値から ( 20r/m の場合 3sec、10r/s などの 1 秒間に複数回のリクエストを許可する場合は 1sec 固定など) 自動的に設定されます。このモジュール内で Retry-After ヘッダを設定している理由としては、標準の add_header では 429 や 503 では利用でないという制約があるためです。( ワークアラウンドとして Perl など動的言語を利用したエラーページを用意する といったものもありますが、やりたい事に対しては少し大袈裟な感が否めません )

このパッチの適用方法は以下のようになります。

$ wget http://nginx.org/download/nginx-1.2.9.tar.gz
$ wget --no-check-certificate -O - https://gist.github.com/tksmd/97aa35a556d9628c2eea/download | gunzip  > nginx-1.2.x-too-many-requests-retryafter.patch
$ tar zxvf nginx-1.2.9.tar.gz
$ cd nginx-1.2.9
$ patch -p1 < ../nginx-1.2.x-too-many-requests-retryafter.patch
$ ./configure
$ make
$ sudo make install

パッチを適用し、また RFC にあるように適切な情報をエラーページに含めるような設定例は以下のようになります。

limit_req_zone  $binary_remote_addr  zone=api:1m   rate=4r/s;

location = /429_API.html {
    internal;
    root html;
}

location /api {
    limit_req  zone=api;
    error_page 429 /429_API.html;
    :
}

RFC に記載されているように、Retry-After をヘッダに含めれば、例えば API を利用するクライアントからはリスポンスヘッダからリトライするまでの スリープタイムを適切に入れられるようになります。Python のサンプルは以下のようになります。

def main():
    # username / password / yourspace は環境にあわせて読み替えてください
    uri = 'https://username:password@yourspace.backlog.jp/XML-RPC'
    server = ServerProxy(uri)
    try :
         server.backlog.getProjects()
    except ProtocolError, e :
         if e.errcode == 429 :
             retry_after = e.headers.get('Retry-After')
             if retry_after is not None and retry_after.isdigit():
                time.sleep(int(retry_after))
                server.backlog.getProjects()

if __name__ == '__main__' :
    main()

limit_req モジュールでのアクセス制御は、今回紹介したように API のように 機械的に大量のアクセスが発生しやすい所でよく使われているのではないでしょうか。 そういった場合に、適切なステータスコード、エラーの内容、及び Retry-After を返すことでクライアント側、サーバ側双方にメリットがあります。

nginx で limit_req モジュールを利用して運用されているかたは是非お試しください。

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる