Railsのレート制限(rate_limit)入門|gemなしでログイン連打・API乱打を429で止める

Railsのrate_limitが時間窓(within: 3.minutes)の中でリクエストを数え、上限(to: 10)を超えた分だけ429 Too Many Requestsで止める仕組みの図
  • このエントリーをはてなブックマークに追加

スポンサーリンク

ログインフォームに同じIPから何百回もパスワードを試されている。公開したAPIが一部の利用者から毎秒何十回も叩かれている。ログを見てヒヤッとしたこと、ありませんか?

こうした「連打」をはじくのがレート制限(流量制限)です。Railsでレート制限というと、これまでは Rack::Attack gem を入れるのが定番でした。ただ、ログインを少し守りたいだけなのに gem を1つ増やして設定ファイルを書くのは、少し重い。かといって放置するのも怖いですよね。

実は今のRailsには、コントローラに1行書くだけでレート制限がかけられる rate_limit が標準で入っています。gemは不要です。この記事では、ログインフォームとAPIを守る実例を軸に、基本からハマりどころまで通しで解説します。

まず結論:ログインを守る最小コード

細かい説明の前に、完成形を見てしまいましょう。Rails 8の認証ジェネレータが作る SessionsController を例にすると、ログインを守るのはこれだけです。

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create
end

これで「同じIPからのログイン試行(create アクション)を3分間に10回まで」に制限できます。11回目のリクエストは処理されず、429 Too Many Requests が返ります。

やることは本当にこの1行だけです。カウントの保存先やキーの持ち方は、あとからオプションで細かく調整できます。まずは全体像を図で押さえておきましょう。

Railsのrate_limitが時間窓(within: 3.minutes)の中でリクエストを数え、上限(to: 10)を超えた分だけ429 Too Many Requestsで止める仕組みの図
rate_limitは「時間窓(within)の中で何回(to)までか」を数え、超えたリクエストだけ429で止めます。

rate_limitはどのバージョンから使える?

先にバージョンの前提を整理しておきます。ここを勘違いすると、公式ドキュメントのコードを写しても動かない、ということが起きます。

バージョンできること
Rails 7.2rate_limit が ActionController に追加。1コントローラにつき制限は1つまで
Rails 8.0name: オプションが追加。1コントローラに複数の制限を併用できるようになった
Rails 8.1超過時の既定の挙動が変更(後述。例外を投げる方式に)

つまり rate_limit 自体はRails 7.2から使えます。ただし「短期と長期の制限を同時にかける」といった name: を使う書き方はRails 8.0以降です。この記事はRails 8.1を前提に進めます。

基本の3つ:to / within / only

まずは最小構成で使った3つの指定を分解します。レート制限の挙動は、ほぼこの3つで決まります。

  • to: … 許可する回数。「何回まで」の部分
  • within: … 時間窓。「どのくらいの期間で」の部分(3.minutes のようにActiveSupportの期間で書ける)
  • only: … 対象アクション。before_action と同じで except: も使える。省略するとコントローラの全アクションが対象

「to: 10, within: 3.minutes」なら「3分間に10回まで」。回数と期間はセットで考えます。ログインなら厳しめ(3分で10回)、一般的なフォーム送信ならゆるめ(1分で30回)など、守りたいものに合わせて調整してください。

超過したら何が起きる?(429とTooManyRequests)

上限を超えたリクエストがどう扱われるかは、地味ですがRailsのバージョンでAPIの挙動が違う部分です。

Rails 8.1では、超過すると ActionController::TooManyRequests という例外が投げられます。この例外はAction Dispatchが受け止めて、最終的に 429 Too Many Requests のレスポンスに変換してくれます。rescue_from ActionController::TooManyRequests で自分で捕まえることもできます。

Rails 7.2〜8.0では例外ではなく、既定で head :too_many_requests(中身のない429レスポンス)を直接返す挙動でした。どちらのバージョンでも「超過すると429が返る」という結果は同じなので、まずはそこを押さえておけば十分です。

オプション総まとめ:by / with / store / name

基本の3つに加えて、実務で効いてくるのが次の4つのオプションです。まず一覧で俯瞰してから、1つずつ実例を見ていきます。

オプション役割既定値
by:カウントのキー(誰ごとに数えるか)リクエスト元のIP(request.remote_ip)
with:超過したときの挙動を差し替える429を返す(8.1は例外経由)
store:カウントの保存先(キャッシュストア)config.action_controller.cache_store
name:制限に名前を付けて複数併用する(Rails 8.0〜)なし(単一の制限)

by:IP以外のキーで数える

by: は「誰ごとにカウントするか」を決めます。既定はIPアドレスです。callable(ラムダ)を渡せば、好きなキーで数えられます。

たとえばAPIなら、IPではなくAPIキー単位で数えたいことが多いはずです。

rate_limit to: 100, within: 1.minute,
  by: -> { request.headers["X-Api-Key"] || request.remote_ip }

ここで1つ注意があります。ログイン保護で「メールアドレス単位」で数えたくなりますが、攻撃者が他人のメールアドレスを連打すると、その人をわざとロックアウトできてしまうという弱点があります。メールアドレスで数える制限を入れるなら、IP単位の制限も別に併用して守るのが安全です(この併用は後述の name: でやります)。

with:超過時のレスポンスを好きに作る

既定の429は中身のないレスポンスです。ログイン画面ならエラーメッセージ付きでリダイレクト、APIならJSONで理由を返す、といった出し分けを with: で指定できます。

# 画面向け:メッセージを出してログイン画面に戻す
rate_limit to: 10, within: 3.minutes, only: :create,
  with: -> { redirect_to new_session_path, alert: "試行回数が多すぎます。少し待ってから試してください。" }

# API向け:JSONで429を返す
rate_limit to: 100, within: 1.minute,
  with: -> { render json: { error: "rate limit exceeded" }, status: :too_many_requests }

store:カウントをどこに保存するか

rate_limit は「このキーが時間窓の中で何回来たか」をキャッシュに記録して数えています。既定の保存先は config.action_controller.cache_store(最終的には config.cache_store)です。

ここが最大のハマりどころなので、独立した章で後述します。まずは「別のストアを明示的に指定できる」とだけ覚えてください。Redisを使う例はこうなります。

class Api::MessagesController < ApplicationController
  RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])

  rate_limit to: 100, within: 1.minute, store: RATE_LIMIT_STORE
end

name:短期と長期、2段構えで守る(Rails 8の目玉)

Rails 8.0で入った name: が、個人的にいちばん実用的だと思うオプションです。名前を付けることで、同じコントローラに複数の制限を重ねられます。

レート制限は「短期の連打」と「長期の総当たり」で、ちょうどいいしきい値が違います。両方を1つの制限で表そうとすると、どうしても緩すぎたり厳しすぎたりします。name: なら、それぞれ別に設定して重ねられます。

class SessionsController < ApplicationController
  # 短期バースト対策:20秒に5回まで(連打を止める)
  rate_limit to: 5, within: 20.seconds, name: "burst", only: :create

  # 長期の総当たり対策:1時間に20回まで(じわじわ試す攻撃を止める)
  rate_limit to: 20, within: 1.hour, name: "sustained", only: :create
end
Rails 8のname:オプションで、短期バースト(20秒5回)と長期の総当たり(1時間20回)の2つのレート制限を1つのコントローラに重ねて設定する概念図
「一瞬の連打」と「ゆっくりした総当たり」はしきい値が別物。name:を分けて両方かけると、どちらも取りこぼしません。

Rails 7.2には name: がなく、制限は1コントローラに1つだけでした。2つ書いても片方しか効かない(内部のキーが衝突する)ので、この2段構えはRails 8.0以降の書き方だと覚えておいてください。

ハマりどころ:キャッシュストアで消える落とし穴

ここが実務でいちばん事故る部分です。「コードは書いたのに、まったく制限がかからない」という時、だいたい原因はキャッシュストアです。

開発環境で効かない:null_store問題

Rails の開発環境では、キャッシュが既定でオフになっています。このとき cache_store は :null_store(何も保存しないストア)なので、rate_limit がいくら数えようとしてもカウントが毎回ゼロに戻り、制限が一切かかりません。

開発環境で動作確認したいときは、キャッシュを有効にします。

# 開発環境のキャッシュをON(tmp/caching-dev.txt が作られる)
bin/rails dev:cache

「本番では効くのに手元で再現しない」の正体はたいていこれです。テストで挙動を確認したいときも、一時的に ActiveSupport::Cache::MemoryStore に差し替えると安定します。

本番で効かない:プロセス・サーバーをまたげない

本番側の落とし穴はこちらです。ストアがメモリ上のキャッシュ(MemoryStore)だと、カウントがプロセスごとにバラバラになります。

PumaのワーカーやサーバーのインスタンスをN個に増やすと、同じIPのリクエストが別々のプロセスに振り分けられ、それぞれが別々にカウントします。結果、実質的に制限がN倍ゆるくなるのです。

これを防ぐには、全プロセスで共有できるストアが必要です。選択肢は主に2つです。

ストア向いているケース
Solid Cache(Rails 8標準・DBが保存先)Redisを増やしたくない小〜中規模。cache_store をSolid Cacheにしていれば既定のままで共有が効く
Redis(RedisCacheStore)すでにRedisがある/高頻度アクセスで速度を優先したい場合

Rails 8は標準のキャッシュがSolid Cache(DBベース)なので、特別な設定をしなくても共有ストアになっているケースが多いです。Solid系のストアとデータベース設定についてはRails8に入るsolid_queueなどのgemを使った時のdatabase.ymlの書き方にまとめています。

プロキシの後ろでIPが同じになる

もう1つ、by: の既定であるIP(request.remote_ip)がらみの注意点です。Nginxやロードバランサーの後ろにRailsを置くと、適切に設定しないと全リクエストのIPがプロキシのIPに見えてしまい、利用者全員が同じキーでまとめて数えられてしまいます。

X-Forwarded-For を正しく扱う設定(config.action_dispatch.trusted_proxies など)が要ります。そもそも前段にNginxを置くべきかという話はRailsの前段にNginxは必要なのかで整理しているので、構成を考えるときに合わせてどうぞ。

Rack::Attackとの使い分け

「じゃあ Rack::Attack はもう要らないの?」という疑問が出ると思います。結論は「守りたいものによる」です。両者は守る場所が違います。

rate_limit(標準)Rack::Attack(gem)
動く場所コントローラ(Railsの中)Rackミドルウェア(Railsの手前)
設定の単位アクション単位で直感的に書けるアプリ全体のルールを一元管理
得意なこと特定フォーム・APIのピンポイント制限IP遮断(ban)・全体の防御・許可リスト
依存なし(標準装備)gem追加が必要

ログインやサインアップなど「このアクションを守りたい」がはっきりしているなら rate_limit で十分です。逆に、悪質なIPをアプリに入れる前に丸ごと遮断したい、全エンドポイントに横断的なルールを敷きたい、という段階になったら Rack::Attack の出番です。小さく始めて、必要になったら足す、で問題ありません。

導入チェックリスト

最後に、入れるときに確認したいポイントをまとめます。

  • □ 守りたいアクションを only: で絞ったか(全アクションにかけて正常利用を妨げていないか)
  • □ to: / within: は正常な利用を邪魔しない値か(ログインは厳しめ、閲覧系はゆるめ)
  • □ 本番のストアはプロセス・サーバー間で共有できるか(Solid Cache / Redis)
  • □ 開発で確認するなら bin/rails dev:cache でキャッシュをONにしたか
  • □ プロキシの後ろなら、IPが正しく取れる設定になっているか
  • □ 超過時のメッセージは利用者に不親切すぎないか(with: で調整)

まとめ

Railsのレート制限は、rate_limit to: 10, within: 3.minutes の1行から始められます。gemを足さずに、ログインの連打もAPIの乱打も止められるのは大きな利点です。

実務で効くポイントは、①本番は共有ストア(Solid Cache / Redis)を使う、②name: で短期と長期を2段構えにする、③超過時の見せ方を with: で整える、の3つです。まずはログインフォームに1行入れてみて、挙動を確かめるところから始めてみてください。

Rails 8の認証まわりをこれから作る方は、Rails 8の認証ジェネレータとDeviseはどっちを使う?やマジックリンク(パスワードレス)ログインの実装方法も合わせて読むと、ログイン周辺の守りを一通り固められます。

スポンサーリンク

  • このエントリーをはてなブックマークに追加