WordPress移行で画像3,810件が配信不能になった — 原本の発掘とCloudflare R2への復旧

開発eurekapu

Udemy 講座の受講者ビューには「リソース」という欄がある。そこをクリックすると解説スライドが開いて、学習アプリ側のスライドページに飛ぶ。導線の確認のつもりでブラウザに出したら、スライドの枠だけが並んで、中身が1枚も出てこなかった。

デプロイに失敗したんだろう、と思った。

404ではなくORBブロックとして出ていた

この日はまだ何もデプロイしていなかった。つまり今動いているのは以前のままの本番で、そこで画像が消えている。

コンソールを見てもらうと、404 ではなく net::ERR_BLOCKED_BY_ORB が並んでいた。画像のURLを直接叩くと、こうなる。

GET https://<公開サイト>/wp-content/uploads/2021/01/slide_chapter01_2_0_00_00.svg
→ HTTP 404 / text/html / 15,700B

404ページのHTMLが返っている。ブラウザは「画像を要求したのにHTMLが来た」と判断して拒否する。だからエラー名に404が出てこない。名前だけ眺めていると、CORSか配信側の設定ミスを疑って別の方向に歩き出すところだった。

参照先は公開サイトの /wp-content/uploads/。前日に WordPress から Cloudflare Pages へ移したばかりの場所だった。

被害を測る

範囲を出してもらった。

項目実測
参照が壊れた画像(ユニーク)3,810件
参照しているアプリ側のファイル203件
延べ参照箇所5,274箇所
影響するスライドページ91ページ

助かったのは、画像URLが Vue コンポーネントではなく data/ 配下のJSONに書かれていたことだった。3,736件がJSON内の文字列で、そのうち1,241件はスライド用の1ファイルに集中している。直すとしても機械的な置換で届く。

「他のサイトも同じパスで読んでいたはず」

ここで引っかかった。WordPress の uploads は「年/月/ファイル名」という標準構造で、置いた画像をどこからでも読める。手軽なので、昔は別のサイトからも同じパスで参照していた覚えがある。学習アプリだけ直しても取りこぼす。

移行時に何をしたのかを調べさせたら、キーの作り方そのものが変わっていた。

const key = `eurekapu/${task.postName}/${task.filename}`

日付ベースを捨てて、記事スラッグベースに組み替えている。そして _redirects に書かれた転送ルールは、その記事ベースの1行だけ。日付ベースのパスを受ける行が1本も無い。

移行の対象も「記事本文で使われている画像」に絞られていた。2,351ファイル。スライド画像は記事に紐づかないので、そもそも運ばれていなかった。

原本はローカルに転がっていた

旧サーバーを掘り起こすか、外付けSSDを探すか、と身構えていた。ところが移行用リポジトリのバックアップディレクトリに、8.3GBの documentroot-backup-20260724.tar.gz が置いてあった。中を開くと WordPress のドキュメントルート全体が入っている。

wp-content/uploads 配下は11,169ファイル。アプリが必要とする画像と全件照合させると、4,324件中4,111件(95.1%)が実在した。発見の起点になった slide_chapter01_2_0_00_00.svg2021/01/ にちゃんと居た。

「全部上げる」と決めた直後に、上げてはいけないものが出てきた

判断が2つ残っていた。R2 に置くときのキーをどうするか、範囲をどこまでにするか。

キーは wp-content/uploads/{年}/{月}/{ファイル名} をそのまま使うことにした。転送ルールの :splat にそのまま渡せて、旧URLと1対1で対応する。範囲は全部にした。把握していない参照が後から1件ずつ露見して都度対応になるくらいなら、一度に全部生かしたほうがいい。

ところが展開して中身を数えてもらったら、7.49GBのうち5.40GBが .zip だった。BackWPup のフルバックアップが3本。DBダンプと wp-config.php が入っている。公開すればDBの認証情報も管理者アカウントも出ていく。ほかにも、問い合わせフォームの送信ファイル、.htaccess で直接アクセスを禁じていたディレクトリ、scan_2017-*.json が並んでいた。

方針を変えた。上げてはいけないものが上がるほうが怖い。今回対象になるリンクは全部わかっているのだから、それだけ入れる。リンク切れがまた出たら、その都度足せばいい。

除外リスト方式は「危険なものを漏れなく数え上げられる」ほうに賭けている。今まさに数え上げに失敗しかけたところだった。上げるものを明示する方式なら、リストに無いものは構造的に上がらない。

7.49GB が847MB、4,222ファイルまで絞れた。

抽出の正規表現が括弧で切れていた

最初の照合で「バックアップに無い」が213件出た。並べてみると hokusai_3%20(21 のような形で途中から欠けている。抽出の正規表現が ( を区切り文字に含めていて、半角スペースや括弧を持つファイル名がそこで切られていた。括弧を区切りから外し、decodeURIComponent でデコードしてから突き合わせ直したら、不在は101件まで減った。

残った101件はPDFが上位を占めていて、バックアップの uploads 配下にPDFは1件も入っていなかった(実測0件)。元から別の場所に置かれていたものなので、今回は見送る。

Content-Typeを付け忘れると、同じ穴に落ちる

wrangler r2 object put--content-type を渡さないかぎり Content-Type を一切付けない。付いていないSVGは、今まさに追いかけているのとまったく同じ ORB ブロックで死ぬ。

これは推測ではなく、3日前の移行作業で実際に踏んでいた。既存の同期スクリプトの冒頭に、そのときのコメントが残っていた。

// wrangler r2 object put は --content-type を渡さない限りContent-Typeを一切付与しない
// (svgで実際に発生: ヘッダー欠落によりブラウザがORBでブロックし画像が全滅した。2026-07-24発見)

過去の自分のメモに助けられた。新しいスクリプトには、ホワイトリスト必須・拡張子からのContent-Type明示・再開用マニフェストの3点を入れさせた。

95分のアップロード

いきなり全件は流さず、まず1件だけテストさせた。HTTP 200、Content-Type も image/png。そこから全件を開始。

11分後に進捗を聞いたら401/4,222だった。このままだと残り1時間45分。1件あたり13.5秒かかっていて、その中身は wrangler の起動とAPI往復だという。CPU は空いている。通信待ちが支配的ならコア数を超えて並列にできるので、8並列から32並列に上げさせた。111件/分まで上がった。

後半でまた39件/分に落ちた。.mp4 が142件混ざっているのと、R2 側のレート制限。最終的に 4,222件すべて成功、失敗0、約95分。

転送ルール1行とデプロイ

やることは _redirects への1行追加だけだった。

/wp-content/uploads/* https://<配信ドメイン>/wp-content/uploads/:splat 302

Cloudflare の管理画面はログイン済みのブラウザを渡して、DevTools 越しに設定まで済ませてもらった。pnpm generate してデプロイし、本番で実測。旧パスのSVGが HTTP 200 / image/svg+xml で返ってきた。

学習アプリのリポジトリは、結局1文字も触っていない。JSONの一括置換もアプリ側のデプロイも要らなかったし、戻したくなったら _redirects の1行を消せば元に戻る。

「網羅的に確認してくれたんでしたっけ」

復旧の手応えが出たところで、最初に頼んでいた全講座のリンク洗い出しがどうなったか聞いた。やっていなかった。計画書の第5節に手順として書いてあるだけで、そこで止まっていた。書いたことは、やったことにならない。

その場で実行させた。新規タブを開くとログインを求められるので、既に開いているインストラクター画面のタブを使う。全7コース(公開4・下書き3)。1回で7コース分を取ろうとしてタイムアウトしたので、1コースずつに割り直させた。返ってくるデータが大きいので、途中から画面に出さず直接ファイルへ書かせた。

リソースリンクは180件、うち学習アプリ向けが172件。コース・章・レクチャー・リンク先・ページ実在フラグの9列でTSVに書き出した。

突き合わせのスクリプトが全部NO_IMAGESを返した

「R2に上がったものと、Udemyで実際に表示を確認できるものを突き合わせてほしい」と頼んだら、判定結果が全件 NO_IMAGES で返ってきた。ページが必要とする画像の検出ロジックが動いていなかった。スライドページが実際にどうやって画像を参照しているかを先に見せてから直させた。

出た結果はこうなった。

  • リンク先ページは172/172すべて実在
  • 起点だった chap-1-2 は必要画像125/125が配信済み。ブラウザで実際の描画まで確認(スライド本体・サムネイル・カウンター1/126)
  • PARTIAL と出た132件の実体は、移行前から壊れていた2件だけだった。1つはアプリ側ソースのタイポで 2020/i11/(正しくは 2020/11/、そのパスは配信済み)。もう1つは hokusai_add (19).jpg で、(1)〜(18)はあるのに(19)だけ無い。WordPress時代からの欠落

今回の移行に起因する欠落は0件。学習ゲートはスキップしてコミットし、続きは明日に積み残した。

学びメモ

  • 404ページが text/html で返ると、画像の失敗は404ではなくORBブロックとして現れる。エラー名だけ見て原因を決めない
  • パス設計を変える移行では、旧パスへの転送ルールを書き忘れた時点で、旧パスを踏んでいる全部が同時に落ちる。移行対象を絞った判断は正しかったが、絞って残ったほうの扱いを決めていなかった
  • 「全部上げる」は中身を数える前に決めていい話ではない。展開して初めて5.4GBの地雷に気づいた
  • 除外リストではなく、上げるものを明示する方式にすると、事故が「起きにくい」から「起きようがない」に変わる
  • 並列数をコア数で決めない。1件あたりの時間の中身が通信待ちなら、コア数を超えて並列にできる
  • 3日前に踏んだ罠のコメントが、今日の設計を1つ救った。踏んだ場所にメモを置いておくと効く

残っていること

  • 学習アプリの静的化デプロイがまだなので、Heroku はまだ動いている。アクセスが止まったのを確認してから停止を判断する
  • スライドを別サイトへ移設して、学習アプリへの依存そのものを切る

そして、日付ベースのパスを踏んでいるのが学習アプリだけとは限らない、という最初の引っかかりは残ったままだ。転送ルールは通したので、原本さえR2に載っていれば旧URLは生き返る。ただし今回載せたのは学習アプリが参照している4,222件だけで、他のサイトや外部からの被リンクが今どのファイルを呼んでいるかは、まだ測っていない。