分散したスクレイピングを、公式nginxのまま国単位のCIDRで止めた話

あるWebサイトで、特定の国のIPからのアクセスが目立って増えていることに気づきました。調べてみると、よくある「1台のサーバーから猛烈に叩いてくる」タイプではなく、非常に多くのIPに分散して、1IPあたりは控えめなペースでアクセスしてくるスクレイピングでした。

この記事は、その正体の見立てと、どう塞ぐかを「ブラスト半径(壊れたときの影響範囲)」で選び、稼働中のサーバーを止めずに展開するまでの記録です。同じような分散アクセスに悩む方の手がかりになれば嬉しいです。

何が起きていたか

アクセスログを集計すると、増えていたトラフィックには次のような特徴がありました。

つまり攻撃ではなく、レート制限を回避するように設計されたコンテンツ収集ボットでした。記事をひたすら集めているようです。住宅・モバイル回線のプロキシ網を経由していると見られます。

ここで効いてくるのが、この「1IPあたりは控えめ」という点です。「短時間に大量アクセスしてくるIPを検知して個別に弾く」という、送信元ごとの振る舞いを見るやり方が、この相手にはほぼ効きません。

観点 ありがちな大量アクセス 今回の分散アクセス
送信元IP 少数に集中 1.5万超に分散
1IPあたりの頻度 高く、突出する 低く、正規利用者と見分けにくい
個別IP単位で弾けるか ◎ 閾値で検知しやすい ✕ 閾値を越えず素通り。厳しくすると正規を巻き込む

個別IPの振る舞いで戦うのが分の悪い相手なら、送信元の「国/ネットワーク単位」でまとめて塞ぐのが素直な解になります。幸い、このボットは特定の国のISPに集中していました。

なぜ「IPアドレス帯(CIDR)」で塞ぐのか

国単位で塞ぐと聞くと、「1.5万個のIPをリストにするのか」と身構えるかもしれません。実際にはその必要はなく、CIDR(IPアドレス帯)で扱います。

CIDRは 203.0.113.0/24 のような表記で、1エントリが1つのIPではなく、1つのネットワーク帯を表します。帯はかなり広く、数字(プレフィックス長)が小さいほど広大になります。

CIDR表記 1行でカバーするIP数(目安)
/24 約 256
/16 約 6.5 万
/12 約 104 万
/10 約 419 万

つまり /10 1行で約419万IPを覆えます。

なぜ1行でこれだけ覆えるのか。IPアドレスを2進数で見ると、プレフィックス長までがネットワーク部(一致を見る範囲)、その先がホスト部(どんな値でも一致)になるからです。/24 なら下位8ビットが自由になり、1行で256個をまとめて指せます。

ある国に割り当てられたアドレス空間は、集約版のCIDRリストにするとおおよそ5,500行で表せます。この5,500行が覆うIPの総数は3.4億を超えます。しかも重要なのは——

個別IPを1つずつ塞ぐのは、3.4億を相手にしたモグラ叩きです。国単位のCIDRリストなら、約5,500行で先回りして面で塞げる。これが本質的な利点でした。

実装方式は「ブラスト半径」で選んだ

国単位のCIDR遮断を実現する方法はいくつかあります。今回は前提として、前段にロードバランサーやCDNが無く、各サーバーが直接 80/443 を公開している構成でした。つまり、最前面のnginxが転ぶと、その1台はWebごとオフラインになります。

この前提だと、選定基準は性能よりも「壊れたときに、どこまで巻き添えになるか(ブラスト半径)」が主役になります。3つの候補を並べました。

方式 仕組み 壊れたときの影響範囲
ホストのファイアウォール カーネルで対象IP帯を破棄 ホスト全体。設定を誤ると自分のSSHごと締め出す危険。コンテナ宛の通し方も間違えやすい
nginx + 外部のGeoIPデータベース 専用モジュールで国を判定 nginxコンテナ内。ただし公式イメージにモジュールが無く、自前ビルドのイメージを所有することになる
nginx標準のgeoモジュール + CIDRリスト 標準機能でIP帯を判定 nginxコンテナ内。データファイルが壊れても起動前の検証で弾け、nginx本体は無傷

3つを「効果 × ブラスト半径(壊れたときの影響範囲)」で並べると、選ぶべき位置がはっきりします。

方式選定の図:横軸が影響範囲の大小、縦軸が効果の高低。nginx geo+CIDR が効果大・影響小の象限に位置する

採用したのは3番目の nginx標準の geo モジュール + CIDRリストです。決め手は次の点でした。

外部GeoIPデータベース方式は、概念としては正しいものの、公式イメージを捨てて最前面のnginxイメージを自前管理に格上げすることになります。最前面が転ぶと1台落ちる構成では、その「自前で抱える故障面の拡大」が割に合いませんでした。多国対応や高精度が要るようになった時点で移行すれば十分、と判断しています。ホストのファイアウォールは効率は最高ですが、締め出し事故の確率が一番高いので、ボリューム型の攻撃に備えた最終手段として温存しました。

設定はとても素朴です。http コンテキストでCIDRリストを読み込み、

# 0/1 を引くだけの判定テーブル
geo $blocked {
    default 0;
    include /etc/nginx/blocked_cidr.conf;   # 「203.0.113.0/24 1;」の羅列
}

server コンテキストで、一致したら接続を即座に閉じます(444 はnginxがレスポンスを返さず切断する内部ステータス)。

if ($blocked) {
    return 444;
}

リストの中身は、対象国のCIDRに 1; を付けただけの行が並びます。

203.0.113.0/24 1;
198.51.100.0/22 1;
...

geo は内部的に基数木(radix tree)で管理されるため、数千エントリでも判定コストはほぼ無視できます。

リストの自動更新を、無人で安全に回す

CIDRの割り当ては少しずつ変わるので、リストは定期的に更新したいところです。一方で、更新のたびに人手をかけたくないし、壊れたリストでnginxを再起動して1台落とすのは絶対に避けたい。そこで、各サーバー上の定期ジョブで自動更新しつつ、何重かの安全装置を入れました。

リスト自動更新の流れ図:生成→フル設定で検証→失敗なら何もしない/成功なら同一inodeで上書きして再起動→メトリクス記録

ポイントが3つあります。

1. 反映前に「本物の設定」で検証する。 新しいリスト単体ではなく、実際の設定ファイルと証明書を組み合わせたフル構成を、使い捨てのコンテナで nginx -t にかけます。ここを通らなければ本番ファイルには一切触れません。ダウンロードや生成に失敗しても、稼働中のnginxは無傷です。

2. 上書きは「同じファイルを書き換える」形で行う(inodeを保つ)。 設定をファイル単位でコンテナにマウントしていると、ファイルを置き換え(rename)た場合にコンテナ側が古い実体(inode)を見続けるという、地味だが嵌まりやすい挙動があります。そこで、新しいファイルへ差し替えるのではなく、既存ファイルの中身を上書き(同一inodeを保持)してから再起動します。

3. 同時再起動を避ける。 ロードバランサーが無い構成では、複数台が同時に再起動すると瞬間的に受け口が細ります。各サーバーの定期ジョブにランダムな待機を入れて、再起動のタイミングをばらしました。

更新の成否はメトリクスとして出し、失敗時は気づけるようにしてあります。

無停止のフリートに、カナリアで反映する

ロードバランサーが無いので、「本番の1台だけで先に試す」を通常のデプロイでは作れません(同じ設定ファイルが全台に配られるため)。そこで、設定の出し方を工夫しました。

  1. まずは遮断ロジックをコメントアウトした状態で全台に配る。 判定テーブルやリスト、自動更新の仕組みは入っているが、遮断は効いていない(=安全な不活性状態)。
  2. 1台だけ、手元で遮断を有効化して再起動する(カナリア)。
  3. その1台の実トラフィックで、反映前後を計測する。
  4. 問題なければ、設定上のコメントを外して全台へ展開する。

カナリアの実測は、はっきりした結果になりました。

観点 反映前 反映後
対象国IPの扱い 数千件規模がそのまま通過 ほぼすべて遮断
取りこぼし ごくわずか(HTTP→HTTPSリダイレクトが先に走る分のみ。コンテンツは渡らない)
対象外(正規)の誤遮断 ゼロ
エラー率 平常 変化なし

終日観測しても再起動ループ等は起きず、安定していました。「効いていること」と「正規利用者を巻き込んでいないこと」を、推測ではなく数字で確認してから全台へ広げられたのは安心でした。

トレードオフと限界

正直に書いておくと、国単位の遮断には割り切りが要ります。

それでも、最小の工事で・公式イメージのまま・壊れても本体に波及しない形で、当面の負荷をはっきり下げられたのは、費用対効果の良い一手でした。

まとめ

派手さは無いものの、「壊さない作り方」を一つずつ積んだ事例として、どこかで同じ状況に出会った方の役に立てば幸いです。