読みもしないbotに、WordPressがけなげに875KBの404 Not Foundを配り続けて疲弊していた話

Network

先日サーバを整備していたとき、アクセスログに気になるものを見つけました。存在しないURLばかりを機械的に叩いて回るプログラム、いわゆる脆弱性スキャナの痕跡が2,038件。feroxbusterというツールです。/wp-config.php.bak/.git/config/backup.zip。どれも存在しないパスなので、応答はすべて404でした。

問題は、その404の中身です。このブログは存在しないURLへのアクセスにも、律儀に875,617バイトを返していました。攻撃でもバグでもなく、WordPressの404ページが通常ページと同じフルレンダリングで、テーマの装飾もウィジェットも一式載っているからです。スキャナの1リクエストごとに、PHPとデータベースを動かして875KBのページを生成し、返していたことになります。受け取る相手は、ページを一行も読まないbotです。

しかも調べてみると、このスキャンはただの迷惑では済んでいませんでした。この記事では、その調査で分かったことと、応答を875KBから9バイトまで下げた対策を書きます。前回のインフラ編で触れたサーバ整備の一環です。

一日でLightsailサーバを最新イメージに移行して、フィード生成を10倍速くした話 – Claude Code実践録(インフラ編)
2026年11月19日に廃止されるAmazon LightsailのBitnami版WordPressブループリントから、Lightsail公式イメージへClaude Codeと無停止で移行した記録。障害の原因究明から性能改善、監視の設置まで、一日分の手順と実測値をまとめました。

WordPressの404は重くて、しかもキャッシュできない

このブログはCloudFrontを前段に置いているので、普通のページはエッジでキャッシュされ、オリジンのサーバまでは届きません。

それなら404も、1回レンダリングすればあとはキャッシュが効くはずですが、これが効きません。WordPressが404応答にCache-Control: no-store, privateを付けるからです。CloudFrontは指示に従って何もキャッシュせず、同じ存在しないURLを何度叩かれても、毎回オリジンがフルレンダリングしていました。

  • 404ページ1枚:875,617バイト(当時。トップページより重い)
  • キャッシュ:一切効かない
  • 生成コスト:PHPの起動、DBへの問い合わせ、テーマの描画まで一式

スキャナは数百〜数千パスを機械的に試します。当時のログを数えると、この種のbotのアクセスは週に7,000〜9,000件、800を超える別々のIPから届いていました。送る側のコストはほぼゼロなのに、受ける側だけが毎回フルレンダリングの費用を払う構造です。

このスキャンに、実害はあったのか

行儀が悪い、というだけなら我慢もできます。気になったのは、前回のインフラ編で書いた障害との関係です。このブログは7月から8月にかけて実質2ヶ月落ちていて、直接の原因は1GBのメモリに対して過大なPHPの設定でした。スキャンはその引き金を引いていなかったのか。結論から言えばビンゴ、引いていました。

移行前に取ってあったスナップショットを一時インスタンスに復元して、当時のログを掘りました。カーネルログには、メモリ不足でOOM KillerがデータベースのMariaDBを強制終了した記録が3回残っていました。それぞれの直前に何が起きていたかを、アクセスログと突き合わせます。

OOM発生(UTC)直前のアクセス
6月8日 05:01直前10分間 0件
6月21日 18:01直前10分間 0件
7月5日 09:21直前2分間に、1つのIPから309件の脆弱性スイープ

3回のうち2回は、スキャンとは無関係でした。無風だった6月21日のOOMダンプを見ると、46個のPHPワーカーだけで1.4GBを超えています。1GBのサーバは平時から限界を超えて動いていて、毎時1分に走る定期処理のわずかな追加で落ちる状態でした。しかし最後の1回は、309件のバーストの60〜90秒後に起きていました。この後、データベースは57日間沈黙します。

前日にも同じbotが778件のバーストを撃っていて、その578件にサーバは504(タイムアウト)を返していました。上限の45個まで埋まったPHPワーカーの空きを待ち続け、時間切れになった分です。つまりスキャンだけでサーバは飽和していたのです。健全なときは、このbotにトップページ935KBを丸ごと配っています(2日間で合計27.5MB)。

これらのバーストは、CloudFrontを経由せず、オリジンのサーバへ直接届いていました。ログの接続元がCloudFrontのエッジではなくbot自身のIPで、経由時に付くはずの転送元の記録も空だったことから分かります。オリジンのホスト名は証明書の公開記録などから見つかるため、CDNの後ろにいても直接は叩かれるのです。先ほど書いた週7,000〜9,000件も、すべてこの直接アクセスの数でした。309件や778件のバーストは、その日常の中で特に足の速かった1台です。

断っておくと、309件や778件は攻撃としては大した数ではありません。毎秒に直せば2〜3リクエストで、本来はこの程度で落ちてはいけません。同じようなスキャンは何年も前から来ていて、以前は耐えていたはずです。限界を超えた設定のまま動き続けるうちに、いつの間にか、この程度で落ちるサーバになっていました。

とはいえ、スキャンが帯域の無駄では済まず、可用性の問題になっていたことは変わりません。1リクエストごとにPHPを起動してフルレンダリングを試みる構造が、バーストのたびにメモリを食い尽くす。ここからの対策は、この経路を塞ぐためのものです。

対策1:PHPに届く前に弾く

明らかに探索目的のリクエストは、WordPressが起動する前にApacheの段階で弾くように設定しました。足した設定は次の3つです。

  • ドットファイルやVCSのパス(/.git/など)は即404
  • .phpやアーカイブ拡張子(.zip .tar.gzなど)へのリクエストは、実ファイルが存在しない場合のみ404(RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
  • ErrorDocument 404 "Not Found"で応答本文を9バイトに

実際の設定はこうです。

RewriteEngine On

# ドットファイル・VCSのパスは即404
RewriteRule (^|/)\.(env|git|svn|hg|aws|ssh|htpasswd|DS_Store) - [R=404,L]

# アーカイブ系と.phpは、実ファイルがない場合のみ404
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule \.(sql|bak|old|orig|save|swp|tar|tgz|zip|rar|7z)$ - [R=404,L]
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteRule \.php$ - [R=404,L]

# 応答本文は9バイトに
ErrorDocument 404 "Not Found"

ポイントは!-fの条件です。これがないと、wp-content/uploads/で配布している本物のzipファイルまで404になります。拡張子だけで一律に弾くのではなく、「そのパスに実ファイルがないなら探索とみなす」という判定にしました。

これで探索系リクエストの応答は、875,617バイトから9バイトになりました。約97,000分の1です。9バイトが返るのは、探索と判定できるリクエストだけです。訪問者がURLを打ち間違えたときは、これまでどおりWordPressの404ページが表示されます。

対策2:残る404はエッジに5分キャッシュさせる

残ったWordPress本体の404には、Apache側で404応答に限ってCache-Control: public, max-age=300を上書きし、CloudFront側でもエラーキャッシュ(ErrorCachingMinTTL=300)を設定しました。

設定にはひと癖ありました。ApacheのHeader always setで上書きしただけでは、WordPress自身が出すno-storeが残ってCache-Controlが2本並んでしまいます。always付きの指定はエラー用のヘッダ表にしか効かず、PHPが出したヘッダは通常の表に載るからです。Header unsetalwaysなし)で消してから設定します。

実際に入れたのがこの6行です。always付きと無しが並んでいるのは、2つのヘッダ表の両方に対応するためです。

# 404だけキャッシュ可にする(PHPが出したヘッダも消してから設定する)
Header unset Pragma        "expr=%{REQUEST_STATUS} == 404"
Header unset Expires       "expr=%{REQUEST_STATUS} == 404"
Header unset Cache-Control "expr=%{REQUEST_STATUS} == 404"
Header always unset Pragma  "expr=%{REQUEST_STATUS} == 404"
Header always unset Expires "expr=%{REQUEST_STATUS} == 404"
Header always set Cache-Control "public, max-age=300" "expr=%{REQUEST_STATUS} == 404"

設定後のヘッダはこうなります。

$ curl -sI https://blog.hiroaki.jp/hogehoge
HTTP/2 404
cache-control: public, max-age=300
x-cache: Error from cloudfront

実際に存在しないパスへ続けて5回アクセスすると、オリジンのアクセスログに残ったのは1行だけでした。同じパスを何度叩かれても、オリジンに届くのは5分に1回です。

なお404ページ自体も、前回書いたCSSインライン化の解消で875KBから319KBまで縮みました。重い404を軽くする話と、重いままでも回数を絞る話は、独立に効きます。

Cocoonの「CSS縮小化」で逆に肥大化していた話
Cocoonの高速化設定「CSS縮小化」が記事ページの84%を占めるインラインCSSを生んでいた原因と、無効化して転送量をgzip後115KBから42KBへ63%減らした記録です。

やらなかった対策と、その理由

対策を調べると、効きそうで効かないものや、間違えると訪問者がサイトを見られなくなるものにも行き当たります。同じ道を通る方のために、見送った理由を書いておきます。

User-Agentでのブロックは、CloudFront経由のリクエストには効きません。この構成ではUser-Agentがオリジンまで転送されないからです。今回観測したスキャンは全件がCloudFrontを迂回してオリジンへ直接来ていたので、その経路の対策としてだけ意味がありました。

fail2banでWebアクセスをBANするのは危険です。オリジンから見えるアクセス元はCloudFrontのエッジIPなので、閾値を超えた瞬間にBANされるのは攻撃者ではなく配信網の方です。そのエッジを経由する訪問者は、全員サイトに繋がらなくなります。fail2banはSSH専用にして、設定ファイルにその旨をコメントで書き残しました。

AWS WAF(月$7〜10)は今の規模では見送りました。ここまでの対策で1件あたりの応答が9バイトまで落ちた以上、月額を払ってリクエストの数を減らす理由がなくなったからです。

まとめ:1回のスキャンに払うコストを下げる

振り返ると、テーマの装飾まで載せた875KBのページを、それを読まないbotのために組み立て続けていたことになります。スキャン自体は止められません。公開サーバである以上、来るものは来ます。変えられるのは、1回のスキャンにこちらが支払うコストの方です。

効果は、移行後のログが示しています。新しいサーバの最初の48時間に、CloudFrontを経由せずオリジンへ直接来たリクエストが、485のIPから約13,000件ありました。平時の週7,000〜9,000件と比べてもかなり多いのは、移行直後だからです。新しい証明書が公開記録に載ったちょうどその1時間に129件が届いていて、作りたてのサーバは公開されたその日から集中的に探されます。約13,000件のうち、探索・不正系の約10,500件の内訳はこうです。

オリジンへ直接来た探索・不正系(48時間)件数
秘密ヘッダなしを403で門前払い5,459
ランダムパスの総当たり2,026
.php探し1,964
設定ファイル・ドットファイル探し1,011
WordPress系パス・バックアップ探し95

WordPressに到達したスキャンは0件でした。旧構造なら合計3GB超のフルレンダリングになっていた分が、1件あたり数百バイトの応答で済んでいます。言ってみれば、公開初日に外部から負荷テストを受けて、無傷で通過したことになります。なお403のうちUAでスキャナと名乗っていたのは12件だけで、残りはブラウザを装っていました。UAブロックがほぼ仕事にならない理由も、この数字のとおりです。

表の一番上で5,459件を門前払いしている秘密ヘッダは、CloudFrontを前段に置くときに仕込んだ共有シークレットです。仕組みはCloudFront構成の記事で説明しています。

LightsailのWordPressにCloudFrontを0円で追加する
Lightsailの有料CDN(月$2.50)を使わず、素のCloudFrontを無料枠で組んでWordPressブログに0円で追加する手順と、証明書・秘密ヘッダ・404キャッシュのハマりどころをまとめました。

875KBのフルレンダリングを毎回返す構造を、9バイトの静的応答とエッジのキャッシュで受ける構造に置き換える。WordPressの404が重くてキャッシュ不可能なことは見落としやすいので、CloudFrontやCDNを前段に置いている方は、一度curl -Iで自サイトの404のCache-Controlを見てみることをおすすめします。

コメント

タイトルとURLをコピーしました