AWS WAFでユーザー体験やSEOを損なわずに、厳しめのレートリミットを設定する

SREの大木 (@2357gi)です。最近スノボのオフトレにトランポリンに行ったら、初めてスノボした時ぐらい体がバキバキの筋肉痛になりました。オススメです。

すでにAWS WAFを導入し、スクレイピング対策としてレートリミットも設定しているものの、GoogleBotなどをブロックしたくないため緩い閾値しか設けることができない状態だったのですが、「厳しめのレート制限でスクレイピングを遮断しつつ、Bot Control のラベリングを用いて必要な Bot を許可する」ことを実現したので、記事にしておきます。

背景

弊社はインターネット上で月極駐車場の検索から契約まで行えるwebサービスである「Park Direct」を開発・運営しています。
Park Directにはたびたびスクレイピングと思われるアクセスが来ており、サービスの信頼性にも悪影響が出ることもありました。

一方で、当然ながらSEOは重要なので、GoogleBotなどはブロックしたくないという前提があり、『レートリミットは設定されているものの、GoogleBotなどが対象にならないような低い閾値でしか設定できていない』という状態でした。

また、ページによってアクセス頻度に差が大きく、かつスクレイピング対象になるページも絞られていたので、それらのページごとに最適な閾値を設定したいというニーズがありました。

解決したい課題

  1. SEOに影響するBotのブロックは行いたくない
  2. レートリミットの閾値を、パスごとに厳しくしてスクレイピングを打ち取りたい

SEOに影響するBotのブロックは行わない

AWS WAFには「Bot Control」という機能があり、スクレーパーや検索エンジンなどのBotを簡単にモニタリング・ブロック・制御を行うことができます。 WAFを通るBotのアクセスを分析し、『検索エンジンBot』『スクレーパー』といったラベリングを自動で行い、かつそれらに対して個別にブロックなどアクションの指定を行うことができます。

この Bot Control の設定を用い、一括でスクレイピングフレームワークやヘッドレスブラウザをブロックすることが可能ではあるのですが、社内サービスやツールからのアクセスなどの都合があり、今回は使用しませんでした。

ただし、この Bot Control に『一般的であり、AWSによって検証可能なBot』に対して付けられるラベルがあり、GoogleBotなどブロックを行いたくないアクセスに対して付与されていることを確認しました。

よって、レートリミットのルール上でこのラベルがついたアクセスを対象に含めないようにすることで、SEOに影響するBotのブロックを行わないことが可能になりました。

設定例

レートリミットの閾値をパスごとに変える

例によって Athena を利用して WAF のログを分析していきます。
WAF のログ自体はパーティション化しやすいようにS3へ保存されているため、公式で用意されているクエリを用いて簡単にテーブルを作成することができます。*1 https://docs.aws.amazon.com/ja_jp/athena/latest/ug/create-waf-table-partition-projection.html

n分間での IP x パスでのアクセス数パーセンタイルをクエリし、レートリミットを設定するパスと、閾値の目安を探ります。
以下のクエリは1分間でパスの第一階層で一纏めに集計をしています。
AWS WAFのレートリミットはタイムレンジが1分, 2分, 5分, 10分から選択できるので、それを目安に集計を行うのが良いです。

WITH base AS (
  SELECT
    regexp_extract(httprequest.uri, '^/[^/]+')       AS path_lv1,
    httprequest.clientip                             AS ip,
    date_trunc('minute', from_unixtime(timestamp/1000)) AS ts_min,
    COUNT(*)                                         AS req
  FROM "waf-access-log"
  WHERE year = '2025'
    AND month IN ('03','04')
  GROUP BY 1,2,3
)

-- 各パスでレートのベースラインを算出
SELECT
  path_lv1,
  approx_percentile(req, 0.80) AS p80,
  approx_percentile(req, 0.90) AS p90,
  approx_percentile(req, 0.95) AS p95,
  approx_percentile(req, 0.99) AS p99,
  MAX(req)                     AS max
FROM base
GROUP BY 1
ORDER BY p99 DESC;

99パーセンタイルにマッチするIPを確認し、正規ユーザーからのアクセスがどれだけ含まれているかを確認します。
ただし、ここの洗い出しはアクセスの性質によってはかなりコストがかかるので、ブロックではなく後述する Challenge アクションを用いるのも良いです。

--  1. まず「IP × 1分窓」でリクエスト数を集計
WITH per_minute AS (
  SELECT
    httprequest.clientip                               AS ip,
    date_trunc('minute', from_unixtime(timestamp/1000)) AS ts_min,
    COUNT(*)                                           AS req
  FROM waf_logs_db."pd-backend-prod"
  WHERE year  = '2025'
    AND month = '05'
    AND regexp_extract(httprequest.uri, '^/[^/]+')
        IN (<対象のパスを羅列>)
  GROUP BY 1,2
),

-- 2. 各 IP について “最も多かった 1分間のリクエスト数” を抽出
ip_peak AS (
  SELECT
    ip,
    MAX(req) AS peak_req_in_1min
  FROM per_minute
  GROUP BY ip
)

-- 3. ピーク値でソートして IP 一覧を出力
SELECT
  ip,
  peak_req_in_1min
FROM ip_peak
ORDER BY peak_req_in_1min DESC
LIMIT 100;

WAFのCHALLENGEアクションを利用する

上記にて策定した閾値を元にレートリミットを設定します。 その際、前述したように偽陽性の検知はかなりのコストを要しますが、確実にスクレイピングであると断定できるところまで閾値を緩めてしまうと、当初の目的を達成できません。

そこで、AWS WAF のアクションの一つである CHALLENGE というものを使用することにしました。
AWS WAFは該当のアクセスに対してどのようなアクションを取るか指定することができます。
『ALLOW(許可)』『BLOCK(拒否)』『COUNT(集計)』といったスタンダードなものの他、『CAPTCHA』や『CHALLENGE』という設定が存在します。
『CAPTCHA』は一般的な画像認証で、ユーザーの操作によって人間かどうかを判定します。

一方で、『CHALLENGE』はCAPTCHAのような人間確認を暗黙的に実施する認証方式です。CHALLENGE アクションが呼び出されると、正規のhtmlの代わりに特定のjsを呼び出すhtmlが 202 で配信され、そのjsの中で人間である検証を行い、成功したらトークンを払い出してそれを元に正規のhtmlが配信されるという仕組みです。

なお認証に失敗すると再検証が続き、 202 で検証用のhtmlが返却され続けるので、実質的には BLOCK と同様の効果が得られます。

一度202リクエストが行われていることが確認できます

CHALLENGE を設定することにより、厳し目の偽陽性が含まれる可能性もある閾値も設定できるようになりました。

CHALLENGE や CAPTCHA アクションは呼び出し回数に応じて従量課金が発生します。大量トラフィックや EDoS(課金攻撃)に備え、CHALLENGEよりも余裕のある閾値で BLOCK するレートリミットを別途設けておくことがAWS公式からも推奨されています。

まとめ

AWS WAFを使用して、GoogleBotなどSEOに影響を及ぼすBotは除外しつつスクレイピングなどを防ぐ方法を紹介しました。
Bot Control を“すべてブロック”運用できない環境でもスクレイピング対策を取りたい方、あるいはレートリミット設定のヒントを探している方の参考になれば幸いです。

*1:CloudFrontに紐づける Global WAF と、ALBなどに紐付ける Regional WAF とでS3のpathが若干違うため気をつけてください。