開発eurekapu-nuxt4

ユーザーから「本番の動画が再生できない」とSlackが飛んできた。該当ページを開いてDevToolsを見ると、動画リクエストは堂々の HTTP 200 OK を返している。レスポンスヘッダの Content-Typevideo/mp4 になっている。なのに <video> タグはくるくる回ったまま動かない。

レスポンスボディをダウンロードして file コマンドにかけたら、出てきた答えは HTML document, UTF-8 Unicode text だった。中身を覗くと、49KBの「お探しのページは見つかりません」HTMLが .mp4 の皮を被って配信されていた。

HTTP 200で安心したら中身がHTMLだった

最初の30分、症状の特定で迷子になった。

  • ブラウザのNetworkタブ → 200 OK
  • curl -IHTTP/2 200content-type: video/mp4
  • curl -o test.mp4 でダウンロード → ファイルは保存される

ここまで全部「正常」に見える。動画プレイヤー側のバグを疑って <video>preload 属性をいじったり、Range リクエストの挙動を確認したりして時間を溶かした。

転機は ls -lh test.mp4 で実体サイズが 49K と出たとき。本物の動画なら数百KB〜数MBあるはず。中身を head で覗いて、<!DOCTYPE html> で始まっているのを見て頭が真っ白になった。HTTP 200を返しながら中身がHTML 404ページという最悪のパターンだった。

会計士視点で言うと、貸借対照表の借方合計と貸方合計が一致していて安心していたら、両方ともゼロを足し算していて気づかなかった、みたいな話に近い。「合計が合っている」は「中身が合っている」を保証しない。

犯人はWordPress時代のShift_JISエンコード

R2に上がっているファイル名を確認したら、参照_03_絶対参照.mp4 みたいな日本語ファイル名だった。「ああ、日本語ファイル名のURLエンコード周りで何か事故ったんだな」と当たりをつけて、移行スクリプトを掘り返した。

移行スクリプトはこういう動きをしていた。

  1. 旧WordPress(eurekapu.com)の記事HTMLから <video src="..."> のURLを抽出
  2. そのURLを requests.get() で叩いてmp4をダウンロード
  3. ダウンロードしたバイナリをR2にアップロード

スクリプト自体はシンプル。なのに4本だけ49KBのHTMLを掴んでいた。WordPressの該当記事のHTMLソースを開いて、URLの実物を確認して原因が見えた。

<!-- WordPressのHTML内の実際のURL -->
https://eurekapu.com/wp-content/uploads/2018/.../%8eQ%8f%c6_03_%90%e2%91%ce%8eQ%8f%c6.mp4

%8eQ%8f%c6 で始まっている。これはShift_JISでエンコードされた「参照」の3バイト(8E 51%8eが壊れているように見えるがはShift_JISで8E518FC6)。旧WordPressがファイル名をShift_JISエンコードのまま記事HTMLに埋め込んでいた。

移行スクリプトはこのURLをそのまま叩いていた。WordPressのストレージは「UTF-8パーセントエンコード(%E5%8F%82%E7%85%A7)でアクセスすれば実体MP4を返す」サーバーになっていた。Shift_JISパーセントエンコードでアクセスすると当然マッチせず、404 HTMLが返ってくる。けれどステータスコードはなぜか 200 だった(WordPressのテーマが404ページを200で返す設定だったのが致命傷)。

スクリプトは200を見て「OK」と判断し、49KBのHTML本文を .mp4 として保存し、R2に上げていた。

税理士視点だと、過去のクライアントから受け取ったCSV帳票がShift_JISで、UTF-8前提のスクリプトに食わせたら文字化けしたまま会計ソフトに登録されていた、という事故と構造が同じ。文字コードは消えない。

100ファイルを並列調査して4本特定した

「他にも壊れてるやつあるんじゃないか」が次の疑問。R2の動画ファイル全100本を全部チェックする必要があった。

PythonでR2のpublicドメイン経由で全URLにHEADリクエストを投げ、Content-Length が50KB未満のものをリストアップする検証スクリプトをClaude Codeに書いてもらった。asyncio.gather で20並列でHEAD投げる作りにしたら、ものの数秒で「4本だけ49KB前後、残り96本は500KB〜2MB」と判明した。

ただ、ここで一度事故った。並列度を上げすぎてCloudflareにレート制限を食らい、自分のIPからのcurlが数分間503で返ってくるようになった。「動画を直す検証スクリプトのせいで動画が見られなくなる」という本末転倒。並列度を5に絞り直して再実行した。

# 修正後(並列度を抑えた)
sem = asyncio.Semaphore(5)
async def check(url):
    async with sem:
        async with session.head(url) as resp:
            return url, resp.headers.get("content-length")

修復: WordPressから実体を取り直してR2に再アップロード

壊れている4本のファイル名がわかったので、WordPressに実体MP4が残っているかを確認した。今度はUTF-8パーセントエンコードでURLを組み立てて叩いた。

# Shift_JISで叩いた旧URL → 49KBのHTML
curl -I "https://eurekapu.com/.../%8eQ%8f%c6_03_%90%e2%91%ce%8eQ%8f%c6.mp4"

# UTF-8で叩いた新URL → 1.89MBのMP4
curl -I "https://eurekapu.com/.../%E5%8F%82%E7%85%A7_03_%E7%B5%B6%E5%AF%BE%E5%8F%82%E7%85%A7.mp4"
# Content-Length: 1985432

WordPressのストレージにはちゃんと実体が残っていた。7本分(壊れていた4本+念のため疑わしい3本)のUTF-8 URLからmp4をダウンロードし、R2に再アップロードした。HEADで再確認して、全部500KB以上のmp4になっていることを確認した。

CDNキャッシュが古いHTMLを返し続けた

「これで終わり」と思ってブラウザで動画ページを再読込したら、まだ動かない。DevToolsで見ると依然として49KBが返ってきている。

Cloudflare CDNのエッジキャッシュが、古い「200 OKのHTML 49KB」を握ったまま離さない。R2のオブジェクト自体は新しいmp4に差し替わっているのに、CDNが古いレスポンスを配り続けていた。

Cloudflareダッシュボードからキャッシュパージを叩く手もあったが、対象URLが7本だけなのでデータファイルの動画URLに ?v=2 を付けるキャッシュバスターで対応した。クエリ文字列が変わればCDNは新規リクエスト扱いでオリジン(R2)に取りに行く。

// 動画データの定義ファイル
export const videos = [
  {
    title: "絶対参照の動画",
    url: "/videos/参照_03_絶対参照.mp4?v=2", // ← ?v=2 を追加
  },
  // ...
]

ブラウザで再読込したら、今度は1.89MBのmp4が返ってきて再生が始まった。

同じ問題が同じ日に2回再発した

夕方、別セッションで同様の事象を踏み直した。記憶が新しいうちにissueドキュメントを .claude/issues/2026-05-04-r2-video-shiftjis.md に残し、検証用Pythonスクリプトも scripts/check-r2-videos.py として汎用化してリポジトリに置いた。

issueドキュメントの中身は「症状(HTTP 200だがContent-LengthがHTML相当)」「原因(Shift_JIS URLを叩いて404 HTMLを保存)」「再発時のワンライナー(HEADで全件チェック→閾値以下を抽出)」の3点に絞った。次に同じ事象を踏んだとき、issueを開けば即座にチェックスクリプトを叩ける状態にした。

今日の学び

  • HTTP 200 は「リクエストが届いた」だけを意味する。Content-Lengthとマジックバイトを必ず見る。WordPressのテーマが404ページを200で返す設定だと、ステータスコード信頼が崩壊する
  • 移行スクリプトは「ダウンロードしたバイナリのサイズ・先頭バイトが期待通りか」のアサーションを必ず入れる。magic ライブラリで video/mp4 を確認するだけで今回の事故は防げた
  • WordPress時代の遺産(Shift_JISパーセントエンコード)は、UTF-8前提のスクリプトに食わせると静かに壊れる。古いCMSからの移行は文字コードを必ず疑う
  • Cloudflare CDNのエッジキャッシュは強い。R2を直したらクエリ文字列のキャッシュバスター or キャッシュパージAPI をセットで叩く
  • 並列リクエストは5〜10に絞る。自分のCDNに自分でDoSをかけてレート制限を食らう事故は本当にやる

会計士・税理士視点だと、古いお客さまからのCSV帳票がShift_JIS、Excelで開くと文字化け、pandas.read_csv() でも UnicodeDecodeError で止まる、という場面に直結する。encoding="cp932" で開き直して、UTF-8で再保存して以降の処理に流すフローを最初から組んでおくと、移行プロジェクトのこの手の事故が消える。