分散したスクレイピングを、公式nginxのまま国単位のCIDRで止めた話
あるWebサイトで、特定の国のIPからのアクセスが目立って増えていることに気づきました。調べてみると、よくある「1台のサーバーから猛烈に叩いてくる」タイプではなく、非常に多くのIPに分散して、1IPあたりは控えめなペースでアクセスしてくるスクレイピングでした。
この記事は、その正体の見立てと、どう塞ぐかを「ブラスト半径(壊れたときの影響範囲)」で選び、稼働中のサーバーを止めずに展開するまでの記録です。同じような分散アクセスに悩む方の手がかりになれば嬉しいです。
何が起きていたか
アクセスログを集計すると、増えていたトラフィックには次のような特徴がありました。
- ほぼ 100% が
GET(ログイン試行やフォーム投稿のような書き込みは無い) - リクエスト先は記事コンテンツのパスに集中(脆弱性スキャンのような不審なパスはほぼ無い)
- User-Agent が複数のブラウザ版を不自然なほど均等にローテーションしている
- 送信元は消費者向けのISPが中心(データセンターではない)
- 1.5万を超える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だけでなく、まだ観測していないIPも最初から含まれる
- プロキシ網が明日から使い始める新しいIPも、同じ国の割り当てなら先回りで含まれる
個別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つを「効果 × ブラスト半径(壊れたときの影響範囲)」で並べると、選ぶべき位置がはっきりします。
採用したのは3番目の nginx標準の geo モジュール + CIDRリストです。決め手は次の点でした。
geoモジュールはnginxに常に組み込まれている標準機能。公式イメージをそのまま使え、設定ファイルを足すだけで動く。- 遮断の核はただのテキストのCIDRリスト。仮に壊れても、後述の検証で反映前に弾ける。最悪でもnginx本体には波及しない。
- 既存で同じ仕組みのブロック(特定のUser-Agentを弾く設定)が動いていたので、同型・最小工事で足せる。切り戻しも設定を戻すだけ。
外部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台落とすのは絶対に避けたい。そこで、各サーバー上の定期ジョブで自動更新しつつ、何重かの安全装置を入れました。
ポイントが3つあります。
1. 反映前に「本物の設定」で検証する。 新しいリスト単体ではなく、実際の設定ファイルと証明書を組み合わせたフル構成を、使い捨てのコンテナで nginx -t にかけます。ここを通らなければ本番ファイルには一切触れません。ダウンロードや生成に失敗しても、稼働中のnginxは無傷です。
2. 上書きは「同じファイルを書き換える」形で行う(inodeを保つ)。 設定をファイル単位でコンテナにマウントしていると、ファイルを置き換え(rename)た場合にコンテナ側が古い実体(inode)を見続けるという、地味だが嵌まりやすい挙動があります。そこで、新しいファイルへ差し替えるのではなく、既存ファイルの中身を上書き(同一inodeを保持)してから再起動します。
3. 同時再起動を避ける。 ロードバランサーが無い構成では、複数台が同時に再起動すると瞬間的に受け口が細ります。各サーバーの定期ジョブにランダムな待機を入れて、再起動のタイミングをばらしました。
更新の成否はメトリクスとして出し、失敗時は気づけるようにしてあります。
無停止のフリートに、カナリアで反映する
ロードバランサーが無いので、「本番の1台だけで先に試す」を通常のデプロイでは作れません(同じ設定ファイルが全台に配られるため)。そこで、設定の出し方を工夫しました。
- まずは遮断ロジックをコメントアウトした状態で全台に配る。 判定テーブルやリスト、自動更新の仕組みは入っているが、遮断は効いていない(=安全な不活性状態)。
- 1台だけ、手元で遮断を有効化して再起動する(カナリア)。
- その1台の実トラフィックで、反映前後を計測する。
- 問題なければ、設定上のコメントを外して全台へ展開する。
カナリアの実測は、はっきりした結果になりました。
| 観点 | 反映前 | 反映後 |
|---|---|---|
| 対象国IPの扱い | 数千件規模がそのまま通過 | ほぼすべて遮断 |
| 取りこぼし | — | ごくわずか(HTTP→HTTPSリダイレクトが先に走る分のみ。コンテンツは渡らない) |
| 対象外(正規)の誤遮断 | — | ゼロ |
| エラー率 | 平常 | 変化なし |
終日観測しても再起動ループ等は起きず、安定していました。「効いていること」と「正規利用者を巻き込んでいないこと」を、推測ではなく数字で確認してから全台へ広げられたのは安心でした。
トレードオフと限界
正直に書いておくと、国単位の遮断には割り切りが要ります。
- 対象国の正規の利用者も遮断されます。サイトの利用者層を踏まえた事業判断が必要です。今回は対象国からの正規アクセスが軽微だったため許容しました。
- 対象国の検索エンジンのクローラも弾かれます。インデックスの価値とのバランスで、必要なら特定のUser-Agentだけ例外にできます。
- そして根本的に、プロキシ網が別の国のIPに切り替えれば無力です。国単位の遮断は「今そこに集中しているから効く」対症療法なので、別の国(例えば他の国からの流入)が増えていないか、移行の兆候は監視し続ける必要があります。
それでも、最小の工事で・公式イメージのまま・壊れても本体に波及しない形で、当面の負荷をはっきり下げられたのは、費用対効果の良い一手でした。
まとめ
- 多数IPに分散した低レートのアクセスは、送信元ごとの振る舞い検知では弾きにくい。国/ネットワーク単位の遮断が素直。
- 国単位はCIDR(IPアドレス帯)で扱えば、わずかな行数で広大なアドレス空間を先回りでカバーできる。
- 稼働中・無LB構成では、実装をブラスト半径で選ぶ。今回は公式nginx標準の
geoモジュールが、壊れても本体に波及しない最小構成だった。 - 自動更新は反映前のフル検証とinodeを保つ上書きで無人でも安全に。展開は不活性配布→1台カナリア→全台で、数字を見ながら進める。
派手さは無いものの、「壊さない作り方」を一つずつ積んだ事例として、どこかで同じ状況に出会った方の役に立てば幸いです。