nginx の拡張モジュールを書いて DoS 対策をした

こんにちは。インフラチームの野島です。

最近、cybozu.com はロードバランサを Apache から nginx に置き換えました。 (参考: cybozu.com のリバースプロキシを nginx にリプレイス)

置き換えの一環として、Apache に実装していた DoS 対策の仕組みを nginx の拡張モジュールにする形で移植しました。今回、この拡張モジュール nginx-maxconn-module を OSS として公開しましたので紹介します。

背景

本題に入る前に、cybozu.com において、HTTP リクエストがどのように処理されているかを説明します。 cybozu.com では、負荷分散のために多数のバックエンドサーバにリクエストを割り振っています。 ここでいうバックエンドサーバは、実際には AP サーバや DB サーバなどに分割されていますが、今回の話の本筋とは関係がないので、この図では1台のサーバとして書いています。

cybozu.com のロードバランシングの仕組み

DoS 対策

cybozu.com では「マルチテナント方式」を採用しています。 つまり、一つのバックエンドを複数のお客様で共有しています。 図では、「○△□株式会社」と「×÷+株式会社」が同じサーバ Backend-1 を使っています。 もし、ここで「○△□株式会社」が(DoS 攻撃や自動プログラムの暴走などにより)大量アクセスを cybozu.com に行い、Backend-1 がダウンしてしまった場合、「×÷+株式会社」もサービスを利用できなくなってしまいます。 したがって、あるお客様の環境に対する大量アクセスがあったときに、他のお客様の環境まで巻き込まれてしまうことを防ぐ仕組みが必要です。

そこで、以下のような仕組みを用意しました。

  • お客様毎 に同時リクエスト数をカウントする。
  • もし、あるお客様の環境に対して予め決められた上限を超える同時リクエストが来たときは、そのお客様のアクセスだけ遮断する。

大量アクセスをしているお客様だけを遮断して、それ以外のアクセスはそのまま通すことが重要です。

秒間リクエスト数 v.s. 瞬間同時リクエスト数

リクエスト数を制限する方法としては、秒間リクエスト数を制限する方法と瞬間同時リクエストを制限する方法があります。例えば、nginx が標準で持っている limit_req ディレクティブは秒間リクエスト数で制限をかけています。 一方、cybozu.com では瞬間同時リクエスト数に対して制限をかけることにしました。 これは、アクセスする URL によってレスポンス時間が異なるからです。

一瞬でレスポンスが返るような URL であれば別に大量にアクセスされても大きな問題にはなりませんが、巨大なデータを作って返すような重たい URL へのアクセスは、そこまでの高頻度でなくても DoS になることがあります。 瞬間同時リクエスト数に対して制限をかけておけば、レスポンスが速くても遅くても正しく制限を書けることができます。

応答時間と同時リクエスト数

実装方針

瞬間同時リクエスト数をカウントしたいので、リクエストが来た時にカウンタをカウントアップして、レスポンスを返した後のカウントダウンすればよさそうです。 問題は、誰 がリクエストをカウントするかということです。

  • ひとつ目の選択肢は nginx のワーカープロセスがそれぞれ同時リクエスト数をカウントする方法です。 この方法だとワーカープロセスのメモリだけで処理が完結するので実装が非常に楽ですが、実際に運用するにはいろいろと問題があります。 特にワーカープロセス数はロードバランサのクラスタ全体で数百に上るため、それぞれ 1 リクエストに制限してもバックエンドにはそれなりの同時リクエストが行ってしまいますし、逆に運の悪いクライアントがたまたま一つのワーカープロセスに2コネクション張ってしまうとリクエストが弾かれてしまいます。 したがって、nginx のワーカープロセスでカウントするという選択肢は却下しました。

  • ふたつ目の選択肢は LB のホスト単位で共有メモリや名前付きパイプなどを使ってカウントを共有する方法です。 これは、limit_req などの nginx の標準的なディレクティブがやっている方法でもあります。 ワーカープロセスでカウントする方法と比べるとワーカープロセス数の影響を受けないため、より便利ですが、ロードバランサは一台ではないため、やっぱりカウントする主体が複数ある状態にはなってしまいます。 さらに、ロードバランサの数は、再起動の過程や入れ替えの都合などにより変化するため、同時リクエスト数の制限の上限が実質的に増えたり減ったりしてしまいます。 ということで、この方法にも問題があります。

  • 最後の選択肢は、ロードバランサのクラスタに一つだけカウントするためのサーバを立て、そこで同時リクエスト数をカウントするという方法です。 実は cybozu.com ã‚’ Apache で運用していたときはこの方法を使って DoS 対策をしていたので、その時の資産を使いまわすことができます。 したがって、nginx でもこの方法で DoS 対策をしていくことにしました。

cybozu.com における DoS 対策の全体像は以下のようになっています。

DoS 対策の仕組み

yrmcds はサイボウズが開発している memcached 互換 KVS です。 yrmcds には counter extension があり、プロセスの突然死などに対してロバストな方法でリソースの利用量をカウントすることができます。 yrmcds を使えば、以下のように同時リクエスト数制限を実装できます。

  • リクエストが来た時に yrmcds に Acquire コマンドを送ってカウントを上げる。 上限値に達していたら Not Acquired というエラーが返ってくるので、この時はアクセスを遮断する。 そうでないときはアクセスを許可する。

  • レスポンスを返したら、yrmcds に対して Release コマンドを送ってカウントを下げる。

nginx-maxconn-module

nginx-maxconn-module は、上で述べたような方法で同時リクエスト数を制限するモジュールです。

基本的な使い方

基本的な使い方は以下のようになります。

server {
    # カウントに使用する yrmcds のアドレスを指定する。
    maxconn_server 127.0.0.1:11215;

    location / {
        # アクセス元 IP アドレス毎に最大 10 同時リクエストまで許可する。
        set $maxconn_key   $remote_addr;
        set $maxconn_limit 10;
        maxconn_acquire;

        # 上限を超えたリクエストが来た時のエラーページ (429 Too Many Requests)
        error_page 429 /path/to/429.html;

        ...
    }

    location /foobar/ {
        # アクセス元 IP アドレスとリクエスト URI の組ごとにリクエスト数をカウントする。
        set $maxconn_key   $remote_addr:$uri;
        set $maxconn_limit 6;
        maxconn_acquire;

        ...
    }
}

まず、maxconn_server で yrmcds のアドレスを指定します。 yrmcds は設定ファイルの以下の行を編集して counter extension を有効にしておく必要があります。

# If true, the counter extension is enabled. (default: false)
counter.enable = true

次に、$maxconn_key という変数にカウントに用いるキーを、$maxconn_limit に同時リクエスト数の上限を指定します。 上の例の / では $maxconn_key に $remote_addr を指定しているため、アクセス元 IP アドレス毎にカウントする挙動になります。 また、/foobar/ では、$maxconn_key に $remote_addr と $uri をコロンで連結した文字列を指定しているため、アクセス元 IP アドレスとリクエスト URI の組に対して同時リクエスト数をカウントするという動作になります。

カウンタのキーや上限値を maxconn_acquire の引数にせず変数にしているのは、キーや上限値を決定する場所と実際に制限をかけたい location が異なる場合でも使えるようにするためです。

maxconn_acquire でカウントアップしたカウンタは、レスポンスをクライアントに返し終えた後に自動的にカウントダウンされます。 また、nginx が何らかの理由で突然死した場合でも、yrmcds 側で自動的にカウントダウンされるので、カウントアップされっぱなしになることはありません。

高度な使い方

画像や動画、zip ファイルなど、大きめの静的ファイルは AP サーバが直接返すのではなく、ブロブサーバが代わりに返すような仕組みになっている場合が多いと思います。 nginx では、X-Accel-Redirect というヘッダをバックエンドサーバがレスポンスに含めていると、そのヘッダの値が指す location にリクエストを内部リダイレクトできるという仕組みがあります。 これを使えば、リクエストの認証を AP サーバで行い、レスポンスの送信をブロブサーバが行うといった分担が可能です。

このように一つのリクエストがAP サーバとブロブサーバにまたがって処理される場合、同時リクエスト数制限を次のようにかけたいと思うことがあります:

  • AP サーバへのアクセスは同時リクエスト数のカウントに含める。
  • ブロブサーバへのアクセスは同時リクエスト数のカウントに含めない。(長時間ダウンロードなどがあるため)

maxconn には maxconn_release というディレクティブが用意されており、これを使えばリクエスト処理の任意のタイミングでカウンタを下げることができます。 (カウンタを下げるのは同じリクエストで maxconn_acquire が成功している場合だけです。)

server {
    maxconn_server 127.0.0.1:11215;

    location / {
        # アクセス元 IP アドレス毎に最大 10 同時リクエストまで許可する。
        set $maxconn_key   $remote_addr;
        set $maxconn_limit 10;
        maxconn_acquire;

        # リバースプロキシと maxconn を同時に使うときはこのディレクティブを
        # on にしておくとよい。
        proxy_ignore_client_abort on;

        proxy_pass http://backend;
    }

    location /blob/ {
        # この location は、バックエンドが X-Accel-Redirect ヘッダを使って
        # リクエストを内部リダイレクトすることで到達する。
        internal;

        # blob へのアクセスは同時リクエスト数のカウントに含めないようにするため、
        # ここで release する。
        maxconn_release;

        proxy_pass http://blob;
    }
}

インストール

nginx の addon として実装されているため、普通の addon と同じようにインストールできます。

まず、github からソースコードを取ってきます。

git clone https://github.com/cybozu/nginx-maxconn-module

次に nginx のソースコードを展開し、configure の引数に --add-module=nginx-maxconn-module を追加して実行します。後は普通に make && make install すれば OK です。

cd /path/to/nginx
./configure --add-module=/path/to/nginx-maxconn-module
make
make install

※ 多くの場合は nginx-maxconn-module 以外のモジュールも必要になる場合が多いと思います。実際に使う場合は ./configure --help を見ながら欲しいモジュールを全部指定するようにして下さい。

おわりに

nginx-maxconn-module が皆様の DoS 対策ライフの一助となれば幸いです。