公開記事の画像404を687本まとめて復旧した
今回やったこと / この記事について
運用中の静的ブログにおいて、公開済み記事から参照されている画像のうち687本が404(リンク切れ)になっていた状態を、個別の手作業修正ではなく専用の復旧スクリプトを作成して一括解消した。
この障害は、記事の一覧や本文を開いた時点ではサムネイル画像が正常に表示されているため目視点検をすり抜け、読者が画像を拡大表示しようとクリックした瞬間に初めて404が露出するという、潜伏性の高い壊れ方をしていた。本稿では、なぜこの欠落が発生したのか、記事本文内に残されていた出所リンクからどのように原寸画像を逆引きして復元したのか、そして静的アセットを腐らせないための検証ゲートの設計について記録する。
なぜ壊れていたのか:サムネイルだけ移行されていた罠
発端は、過去に実施したWordPressからオブジェクトストレージ(Cloudflare R2)へのメディア移行処理にある。旧ブログシステム由来の画像群は、サムネイル(<hash>-s.jpg)と原寸画像(<hash>.jpg)の2種類が存在していた。
しかし、エクスポート時のスクリプトの条件分岐の不備により、サムネイル画像のみがストレージへ転送され、原寸画像のファイルが転送対象から漏れていた。記事本文のHTML構造は次のようになっていた。
<a href="https://media.example.com/imgs/abc12345.jpg">
<img src="https://media.example.com/imgs/abc12345-s.jpg" alt="写真">
</a>
ブラウザで記事を開くと、<img> タグが参照しているサムネイル画像は正常にHTTP 200を返し、ページ上に美しくレンダリングされる。目視確認や <img> タグの src だけを走査する簡易クローラではエラーを検知しにくい。しかし、アンカータグ <a href> で指定された原寸画像をクリックすると、リンク先のストレージ上にオブジェクトが存在しないためHTTP 404が返る。
記事資産全体を精査したところ、欠落していた687件の出現箇所はすべてこの <a href> による原寸リンクであり、<img> やアイキャッチ(featuredImage)の欠落は0件であった。つまり「サムネイルだけが生きていて原寸だけが死んでいる」状態が全数で成立していた。
記事内の残存情報から出所URLを逆引きする
欠落した687本の画像を1枚ずつ外部から探して手作業で再アップロードするのは現実的ではない。また、記事本文内の画像リンク先URLを変更すると、過去に共有されたリンクや検索エンジンのインデックスとの整合性が損なわれる恐れがある。原則として記事側の記述を変更せず、ストレージ上の同じキーへ原寸画像を復旧させる必要があった。
幸いなことに、過去の記事生成パイプラインの仕様により、HTMLソース内には移行元データに保持されていた外部ホスティングサービス(i.imgur.com)の出所URLが、原寸アンカータグの直前に可視リンクとして保持されていた。
<a class="image" href="https://i.imgur.com/example.jpg">出所</a>
<a href="https://media.example.com/imgs/abc12345.jpg">
<img src="https://media.example.com/imgs/abc12345-s.jpg">
</a>
この規則性を利用し、次のような手順で復旧ロジックを組み立てた。
- 全記事ファイル(Markdown)からメディア配信ドメインを指すURLをすべて抽出する。
- R2クライアントに対して
head_objectリクエストを並行送信し、ストレージ上に存在しないキー(404対象)を特定する。 - 欠落した各URLについて、記事本文中でそのURLが出現する位置から手前400文字の範囲を走査し、直前に存在する外部出所URLを正規表現で抽出する。
- 出所URLから元画像をダウンロードし、厳格なバリデーションを通過したものだけをR2の元のキーへ
put_objectする。
正規表現では、属性の並び順(class="image" href="..." と href="..." class="image" の両パターン)に対応できるよう配慮した。
復旧スクリプトの検証ゲートと安全設計
外部サービスから画像を取得して自社ストレージに配置する処理には、壊れたデータや不要なレスポンスを誤って登録しないための厳密なゲートが必要となる。スクリプトでは以下の4段階の検証を設け、1つでも満たさない場合は投入を見送る設計とした。
| 検査項目 | 判定基準 | 目的 |
|---|---|---|
| リダイレクト検査 | 最終URLに removed が含まれないこと(Imgur) | 削除済み画像への代替リダイレクトを誤保存するのを防ぐ |
| MIMEタイプ | Content-Type が image/* であること | HTMLエラーページやテキストレスポンスを排除 |
| ファイルサイズ | データサイズが 1,500 バイト以上であること | 破損した空ファイルや数バイトのエラー応答を除外 |
| 画像デコード検証 | Pillow (PIL) で Image.open() 後に im.load() が通ること | 途中切断やデータ破損のある画像バイナリを弾く |
この検証ロジックにより、対象となった687本のうち685本は外部の出所URLから高品質な原寸画像を取得・検証し、正しいCache-Controlヘッダー(public, max-age=31536000, immutable)を付与してストレージへ復元できた。復元できた685本については、原則として記事本文側の記述を変更せずに済んでいる。
残る2本については、外部サービス側でも既に削除されており、Wayback MachineなどのWebアーカイブにもスナップショットが存在しなかった。これらについては、記事ファイル側の死んだ <a href> ラッパーのみを除去し、生きているサムネイルの <img> 表示だけを残す手当てを行った。
到達性の実測と再発防止
復旧処理の完了後、記事から参照されている全画像URL(1,675本)に対してHTTP HEADリクエストを一括送信し、公開エンドポイントにおける到達性を実測した。
- 参照URL総数: 1,675本
- HTTP 200: 1,675本
- 非200(404等): 0本
結果として、すべての画像参照が正常に応答することを確認した。
作成した復旧ツールは使い捨てのコードにせず、以下の3つのモードを持つ保守スクリプトとしてリポジトリ内に配備した。
# 欠落状況の調査のみ(ストレージへの書き込みなし)
python3 scripts/restore_missing_media.py
# 実際に外部から取得してストレージへ復旧
python3 scripts/restore_missing_media.py --apply
# 公開エンドポイントの到達性をHEADで実測
python3 scripts/restore_missing_media.py --verify
静的サイトのメディア資産は、ビルドが通ってHTMLが生成されているからといって安全とは限らない。特に外部ストレージやCDNを併用する場合、「見た目上は表示されるが内部リンクが切れている」という潜伏バグは静かに進行する。定期的な到達性スキャンと、記事側の出所情報を失わないデータ構造の維持が、長期的な資産保全において極めて有効である。
更新履歴
- 2026-09-19: 初稿。静的ブログにおける画像404の潜伏要因と出所URLからの自動復元手順を記録。
訂正履歴
- なし。