Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

Fastly の Edge Rate Limiting で苦労せずレート制限を実装する

Retty でエンジニアをしている山下です。
早いもので2024年も残り半分となり、年々1年の長さが短く感じるようになってきました。

Retty では nginx 移行を通じて学んだ Fastly のはじめかた で紹介したように CDN として Fastly を利用しています。
今回は Fastly の Edge Rate Limiting でレート制限を簡単に実装したことについて書こうと思います。

レート制限が必要な理由

レート制限が必要な理由は一般的に「サーバーの負荷軽減」が主になるかと思います。
サーバーの負荷軽減目的のレート制限は、急激なトラフィック増加によってサーバーがダウンするのを防ぐことで安定したサービスの提供が可能となります。

Retty のサービスは多くのユーザーに利用していただいており、飲食店のクチコミや画像といった価値のあるデータもあることから、攻撃やクローリングの脅威にさらされています。インフラエンジニアとアプリケーションエンジニアが協力して対策にあたり、サービスを運用していますが、それでも最近はアクセスの急増によってサーバー負荷の上昇に悩まされるようになってきました。
また、近年急速に発達している大規模言語モデルの学習目的でクローリングされることもあると思います。その恩恵に Retty も預かっていることからこの分野の発展に協力・貢献していきたい一方で、サービスに影響が出るほどのアクセスは適切に防いでいく必要があると考えています。

攻撃は Fastly の Next-Gen WAF (Signal Sciences) でアクセスを拒否できますが、クローラーに関しては防ぐことができません。
そこで、短時間に一定数リクエストをするクライアントをブロックしてサービスの安定性を高めるために Edge Rate Limiting でレート制限を実装することとなりました。

Fastly の Edge Rate Limiting とは

Edge Rate Limiting は Fastly のプロダクトの一部で、リクエスト数をカウントし設定されたレートを超えるクライアントに制限を課すことで、オリジンサーバーへのリクエストを制御する機能を提供します。これによりレート制限を簡単に実装することができます。

docs.fastly.com

Edge Rate Limiting でレート制限を実装する方法

UI からどの期間どれくらいのリクエストがあったらどんなレスポンスを返すかということが設定可能です。一方で UI では複雑な条件分岐を設定できないのですが、Fastly で複雑な処理を設定できる Varnish Configuration Language (以下 VCL) で数行処理を書くことで条件にあったレート制限を実装することもできます。

※ Edge Rate Limiting を有効にするにはサポートに連絡する必要があります。1

UI から設定する方法

UI から設定する方法は以下の URL にある通りです。

docs.fastly.com

レート制限の設定は以下の画像のように HTTP メソッドや1秒間のリクエスト数 (RPS)、評価期間、クライアントの特定方法(IP アドレスか User-Agent または両方)を設定できます。 RPS は最低でも 10 RPSを指定する必要があり、例えば評価期間が60秒で RPS を10とした設定した場合は同一のクライアントから 10 RPS が60秒続いたら (つまり60秒の間に600相当のリクエスト) レート制限の対象となります。

レート制限になったときの挙動も設定できます。基本的にはデフォルトの設定でいいと思いますが、ログに残すだけということもできそうです。
また、デフォルトでは2分間ブロックする設定になっているのでこの設定もお好みで変えるとサーバーの負荷軽減に繋がりそうです。

Custom VCL で設定する方法

User-Agent に特定の文字列が含まれていたらレート制限の対象外にしたり特定のページのみ対象にするという複雑な条件を設定したい場合には、UI ではなく VCL を書くことで実現できます。

www.fastly.com

クライアントの IP アドレスをキーとして、 URL のパスが /ratelimits かつ100RPSが10秒間続いたら15分ブロックする場合は以下のように VCL を定義します。

penaltybox pb { }
ratecounter rc { }

sub vcl_recv {
  if (
    fastly.ff.visits_this_service == 0
    && req.url.path == "/ratelimits"
    && ratelimit.check_rate(client.ip, rc, 1, 10, 100, pb, 15m)
  ) {
    error 429 "Too many requests";
  }
}

レート制限をするクライアントの情報 (通常は IP アドレス) を記録する Penaltybox のオブジェクトを pb、 個々のクライアントのリクエスト数を記録する Ratecounterrc としてオブジェクトを作り、vcl_recv サブルーチン内で ratelimit.check_rate 関数を利用してレート制限を実装します。レート制限の条件に一致すると ratelimit.check_rate 関数は true を返します。
また、オリジンシールド を有効にしている場合にエッジとオリジンシールドの POP で二回処理されないように、fastly.ff.visits_this_service == 0 を if 文の条件に追加してエッジのみで処理するようにしています。
上記の例では100RPSが10秒間続いたらレート制限の対象とするようにしていますが、測定期間は1秒、10秒、60秒のいずれかで設定することができ、RPS は 10 ~ 70,000,000 までを指定できます。また ratelimit.check_rates 関数を利用すると複数条件でレート制限することができます。

注意点としては Limitations にもある通りトリガーするリクエスト数に10%ほどの誤差が出るようです。

Rate counters are not intended to compute rates with high precision and may under-count by up to 10%.

レート制限のレスポンスはキャッシュされるのか

結論から言うと error 429 "Too many requests" を呼び出したレスポンスはキャッシュされません!
というのも、Fastly のキャッシュはバックエンドのフェッチが実行される必要があり、vcl_miss サブルーチンから vcl_fetch サブルーチンを通ることがキャッシュされる条件となっているのですが、error を呼び出すと vcl_error サブルーチンに遷移するので vcl_miss サブルーチンから vcl_fetch サブルーチンを通らないためキャッシュされることはありません。

参考:

https://www.fastly.com/documentation/guides/concepts/edge-state/cache/

Automatically caches HTTP responses based on freshness semantics when you make a fetch from Fastly to a backend.

https://www.fastly.com/documentation/reference/vcl/subroutines/fetch/

If the request arrived in this subroutine from vcl_miss, the fetched object may be cached.

レート制限導入後のメトリクス

下記画像の通り、レート制限を導入直後からレスポンスのステータスコードが 429 (緑色) が早速登場しました。

詳しく調べてみるとブロックされたリクエストには

  • 広告業者のものと思われるクローラーが名前が含まれた User-Agent
  • 機械的に偽装した User-Agent

が含まれていたため、適切にクローラーや悪質なボットのアクセスをブロックできていることが確認できました。

まとめ

Fastly の Edge Rate Limiting でレート制限を簡単に実装する方法を紹介しました。
Edge Rate Limiting でレート制限を実装してみて感じたのは、圧倒的な設定の容易さと柔軟なカスタマイズ性です。 特に Custom VCL で実装するとリクエストの User-Agent などで条件分岐したり、パスごとにレート制限の回数を変えたりできるのは強みだと思います。

Fastly の Next-Gen WAF では防げなかった機械的に URL を生成してアクセスしてくるボットやクローラーをレート制限という形でアクセスをブロックすることで、サーバーの負荷が軽減しレスポンスが遅くなったりサービスダウンしづらい状態になりました。

また、レート制限を実装するにあたりサポートに手厚く丁寧に実装方法や疑問を解消してもらいました。サポートチームの皆さんにはいつも感謝しております。

おまけ: Ratecounter と Penaltybox を直接操作してレート制限を実装する

ratelimit.check_rate / ratelimit.check_rates 関数を利用すると Ratecounter や Penaltybox を意識しなくとも簡単にレート制限を実装できましたが、RPS で測定されるため一定期間で一定回数アクセスされたらレート制限する、ということができません。そのような条件を満たすには Ratecounter や Penaltybox を操作できる ratelimit.ratecounter_incrementratelimit.penaltybox_addratelimit.penaltybox_has 関数を使って実装します。 ratelimit.ratecounter_increment 関数で追加した値は60秒で消えてしまうため、必然的にレート制限を評価する期間は60秒となります。

以下が過去60秒間に100リクエスト以上したクライアントの IP アドレスを15分間ブロックするレート制限の実装例です。

penaltybox pb { }
ratecounter rc { }

sub vcl_recv {
  if (fastly.ff.visits_this_service == 0) {
    declare local var.ratelimit_count INTEGER;
    set var.ratelimit_count = ratelimit.ratecounter_increment(rc, client.ip, 1);
    if (var.ratelimit_count > 100 && !ratelimit.penaltybox_has(pb, client.ip)) {
      ratelimit.penaltybox_add(pb, client.ip, 15m);
    }
    if (ratelimit.penaltybox_has(pb, client.ip)) {
      error 429 "Too many requests";
    }
  }
}

ratelimit.ratecounter_increment 関数の返り値は Ratecounter の値を増加させた後の値を返すため、その値が 100 を越えた場合に ratelimit.penaltybox_add 関数を呼び出して Penaltybox にクライアントの IP アドレスを追加するようにしています。 Penaltybox に追加された値は15分で自動的に消えてほしいので ttl15m を指定します。
また ratelimit.penaltybox_add 関数は呼び出されるたびに Penaltybox の値を更新するので、 ttl が都度更新されないよう if 文の条件に !ratelimit.penaltybox_has(pb, client.ip) を追加しています。この ratelimit.penaltybox_has 関数は Penaltybox に指定したキーがあれば true を返すので、この値を使ってレート制限するかどうかも判断しています。