ブログ一覧

Astro の /_astro/ 画像が移行後に 404 で叩かれ続ける原因と対処

週次で見ている Cloudflare のレポートで、あるサイトの 404 の上位 2 つが同じ形の URL でした。

373  example.com/_astro/75a669c5-328d-4500-8a84-5a0b44c8be1e-585x1024_Z2ccwWR.jpeg
332  example.com/_astro/85e68c11-2307-4a0c-ac07-046ddb0ba5a0-1024x717_2tfiA2.jpeg

/_astro/ は Astro がビルド時に生成するアセットの置き場所で、ファイル名の末尾にビルドごとのハッシュが付きます。このサイトは 1 か月ほど前に、WordPress をヘッドレスに使った Astro の静的ビルドから、Astro 製の CMS「EmDash」へ作り直していました。旧ビルドの画像 URL に、週 700 回以上のアクセスが来ていたことになります。

最初は「画像クローラだろう」で片付けました。28 日で約 180 種類、計 300 件ほどだったので、放っておけば消えると思ったからです。翌週、件数が 1 日 30〜50 件から 200〜290 件に跳ねました。User-Agent を実測すると、普通のスマートフォンのブラウザでした。Referer はこのプランの集計では取れないので推定になりますが、画像検索から着地している人がいると考えるのが自然でした。

旧 URL の形と、どこから来たか

旧ビルドは Astro 2 と @astrojs/image で、WordPress の記事本文にある画像を getPicture / getImage に通していました。生成される URL はこの形です。

/_astro/<元のファイル名>[-WxH]_<ビルドのハッシュ>.<拡張子>

-1024x717 は WordPress が作るサムネイルのサイズ接尾辞で、_2tfiA2 がビルドのハッシュです。ハッシュは内容から決まるので、同じ画像なら同じ URL が毎回のビルドで出ます。だからこそ長い間同じ URL で配信され、検索エンジンに載りました。サイトを作り直した時点で、その URL は全部消えました。

現行ビルドの dist/client/_astro/ に画像はありません。CSS と JS だけで、Base.Cws9tkSz.css のようなドット区切りのハッシュです。つまり /_astro/ 配下で画像の拡張子を持つリクエストは、すべて旧ビルドの URL だと判定できます。

リダイレクト表では救えなかった

EmDash にはリダイレクトの表(_emdash_redirects)があり、管理画面や API から旧 URL → 新 URL の 301 を登録できます。28 日分の 404 を集計すると 2,237 パスあり、そのうち 747 パスは <元のファイル名>.<拡張子> でメディア一覧と突き合わせると現物が見つかりました。これを exact の 301 として 747 行投入しました。

1 件も発火しませんでした。

EmDash のリダイレクトミドルウェア(emdash/dist/astro/middleware/redirect.mjs)を読むと、先頭でこう抜けています。

const SKIP_PREFIXES = ["/_emdash", "/_image"];
const ASSET_EXTENSION = /\.\w{1,10}$/;

if (SKIP_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return next();
if (ASSET_EXTENSION.test(pathname)) return next();

拡張子付きのパスは、リダイレクト表を引く前に素通りします。静的ファイルのリクエストで表を引かないための設計で、それ自体は妥当です。副作用として、このミドルウェアの後半にある 404 のログも同じ return next() で飛ばされるので、/_astro/*.jpeg の 404 は EmDash の 404 ログにも 1 件も残っていませんでした。Cloudflare 側の集計で見つかったのはそのためです。

747 行は削除しました。

ミドルウェアでファイル名からメディアを引いて 301 する

表が使えないので、アプリ側のミドルウェアで受けました。旧 URL からファイル名を取り出し、サイズ接尾辞を外した候補も含めてメディアのテーブルを引き、現物があれば配信 URL へ 301 します。

const OLD_ASTRO_IMAGE = /^\/_astro\/(.+)_[A-Za-z0-9-]+\.(jpe?g|png|gif|webp|avif)$/i;
const WP_SIZE_SUFFIX = /-\d+x\d+$/;

const redirectOldAstroImages = defineMiddleware(async (context, next) => {
	const match = context.url.pathname.match(OLD_ASTRO_IMAGE);
	if (!match) return next();

	const [, base, ext] = match;
	const candidates = [`${base}.${ext}`.toLowerCase()];
	if (WP_SIZE_SUFFIX.test(base)) {
		candidates.push(`${base.replace(WP_SIZE_SUFFIX, "")}.${ext}`.toLowerCase());
	}

	const db = await getDb();
	const row = await db
		.selectFrom("media")
		.select("storage_key")
		.where(({ eb, fn, ref, or }) =>
			or(candidates.map((name) => eb(fn("lower", [ref("filename")]), "=", name))),
		)
		.where("status", "=", "ready")
		.executeTakeFirst();

	if (!row?.storage_key) return next();
	return context.redirect(`/_emdash/api/media/file/${row.storage_key}`, 301);
});

現行ビルドの本物の /_astro/* は Workers の静的アセット配信が Worker より先に返すので、このミドルウェアに届くのは旧 URL だけです。対象は 1 日 60 件ほどなので、1 件ごとに D1 を 1 回引くコストは無視できます。EmDash のミドルウェアは全て order: "pre" で先に走るため、既存のリダイレクト解決や認証には影響しません。

デプロイした 8 月 20 日の日別の数字です。

8/19:  404 266 件
8/20:  301  40 件 / 404 203 件   (昼にデプロイ)
8/21:  301 119 件 / 404  28 件
8/22:  301  71 件 / 404  14 件

週で見ると、修正前の 1 週間(8/13〜19)は 404 が 1,640 件、修正後の 1 週間(8/22〜28)は 301 が 777 件、404 が 156 件でした。

残った 1 枚は、同じ画像が別の名前で入っていた

修正後も 75a669c5-… だけが週 160 回、404 のまま残りました。メディアのテーブルに 75a669c5 で始まるファイルが無い。移行時に消えたのだと思っていました。

WordPress のエクスポート(WXR)を引き直すと、この画像は WordPress に 2 回アップロードされていて、記事本文が参照していたのは 476f17d3-….jpeg の方でした。現行サイトでは同じバイト列(sha256 が一致、71,209 バイト)が別の名前で配信されています。ファイル名の突き合わせでは構造的に見つかりません。

この 1 枚だけは Cloudflare の Redirect Rule を 1 本置いて、配信 URL へ 301 しました。ルールを増やしたくない理由(無料プランは本数に上限があり、例外を増やすほど管理が要る)はありますが、1 本で週 160 回を止められるなら割に合います。

この構成で気をつけること

  • Astro の /_astro/ 画像 URL は内容ハッシュなので、同じビルド設定の間は安定して検索エンジンに載る。作り直すと全部消える
  • /_astro/ に画像を置かない構成にしたなら、/_astro/*.jpeg へのリクエストは旧 URL だと断定できる
  • EmDash のリダイレクト表は拡張子付きのパスを引かない。ファイル URL の 301 はミドルウェアか Cloudflare 側で受ける
  • 同じ理由で、拡張子付きの 404 は EmDash の 404 ログに残らない。Cloudflare の集計で見る
  • 「クローラだろう」は User-Agent を見てから言う。今回は実ユーザーだった
  • ファイル名で突き合わせて残った分は、同じ画像が別名で入っていることを疑う。バイト列で比べる

サイトを作り直したときに旧 URL の 301 は用意していましたが、記事の URL だけでした。画像の URL が検索結果から直接踏まれる、という経路を数えていませんでした。

関連

この記事をシェア