[{"data":1,"prerenderedAt":690},["ShallowReactive",2],{"content-/r2-video-shiftjis-corruption":3,"all-pages-for-dir":688,"og-image-/r2-video-shiftjis-corruption":689},{"id":4,"title":5,"body":6,"category":668,"description":669,"extension":670,"meta":671,"navigation":438,"ogImage":672,"path":673,"project_name":674,"published":675,"publishedAt":676,"seo":677,"stem":678,"tags":679,"todo":672,"unpublished":675,"updatedAt":672,"__hash__":687},"pages/2026-05/2026-05-04/r2-video-shiftjis-corruption.md","Cloudflare R2の動画が再生できない原因はWordPress時代のShift_JISファイル名だった話とCDNキャッシュバスター対応",{"type":7,"value":8,"toc":659},"minimark",[9,30,45,50,53,83,97,121,124,128,135,138,158,161,171,200,211,217,220,224,227,238,241,393,397,404,463,466,470,473,476,487,578,581,584,595,598,601,640,655],[10,11,12,13,17,18,21,22,25,26,29],"p",{},"ユーザーから「本番の動画が再生できない」とSlackが飛んできた。該当ページを開いてDevToolsを見ると、動画リクエストは堂々の ",[14,15,16],"code",{},"HTTP 200 OK"," を返している。レスポンスヘッダの ",[14,19,20],{},"Content-Type"," も ",[14,23,24],{},"video/mp4"," になっている。なのに ",[14,27,28],{},"\u003Cvideo>"," タグはくるくる回ったまま動かない。",[10,31,32,33,36,37,40,41,44],{},"レスポンスボディをダウンロードして ",[14,34,35],{},"file"," コマンドにかけたら、出てきた答えは ",[14,38,39],{},"HTML document, UTF-8 Unicode text"," だった。中身を覗くと、49KBの「お探しのページは見つかりません」HTMLが ",[14,42,43],{},".mp4"," の皮を被って配信されていた。",[46,47,49],"h2",{"id":48},"http-200で安心したら中身がhtmlだった","HTTP 200で安心したら中身がHTMLだった",[10,51,52],{},"最初の30分、症状の特定で迷子になった。",[54,55,56,64,77],"ul",{},[57,58,59,60,63],"li",{},"ブラウザのNetworkタブ → ",[14,61,62],{},"200 OK"," 緑",[57,65,66,69,70,73,74],{},[14,67,68],{},"curl -I"," → ",[14,71,72],{},"HTTP/2 200","、",[14,75,76],{},"content-type: video/mp4",[57,78,79,82],{},[14,80,81],{},"curl -o test.mp4"," でダウンロード → ファイルは保存される",[10,84,85,86,88,89,92,93,96],{},"ここまで全部「正常」に見える。動画プレイヤー側のバグを疑って ",[14,87,28],{}," の ",[14,90,91],{},"preload"," 属性をいじったり、",[14,94,95],{},"Range"," リクエストの挙動を確認したりして時間を溶かした。",[10,98,99,100,103,104,107,108,111,112,115,116,120],{},"転機は ",[14,101,102],{},"ls -lh test.mp4"," で実体サイズが ",[14,105,106],{},"49K"," と出たとき。本物の動画なら数百KB〜数MBあるはず。中身を ",[14,109,110],{},"head"," で覗いて、",[14,113,114],{},"\u003C!DOCTYPE html>"," で始まっているのを見て頭が真っ白になった。",[117,118,119],"strong",{},"HTTP 200を返しながら中身がHTML 404ページ","という最悪のパターンだった。",[10,122,123],{},"会計士視点で言うと、貸借対照表の借方合計と貸方合計が一致していて安心していたら、両方ともゼロを足し算していて気づかなかった、みたいな話に近い。「合計が合っている」は「中身が合っている」を保証しない。",[46,125,127],{"id":126},"犯人はwordpress時代のshift_jisエンコード","犯人はWordPress時代のShift_JISエンコード",[10,129,130,131,134],{},"R2に上がっているファイル名を確認したら、",[14,132,133],{},"参照_03_絶対参照.mp4"," みたいな日本語ファイル名だった。「ああ、日本語ファイル名のURLエンコード周りで何か事故ったんだな」と当たりをつけて、移行スクリプトを掘り返した。",[10,136,137],{},"移行スクリプトはこういう動きをしていた。",[139,140,141,148,155],"ol",{},[57,142,143,144,147],{},"旧WordPress（eurekapu.com）の記事HTMLから ",[14,145,146],{},"\u003Cvideo src=\"...\">"," のURLを抽出",[57,149,150,151,154],{},"そのURLを ",[14,152,153],{},"requests.get()"," で叩いてmp4をダウンロード",[57,156,157],{},"ダウンロードしたバイナリをR2にアップロード",[10,159,160],{},"スクリプト自体はシンプル。なのに4本だけ49KBのHTMLを掴んでいた。WordPressの該当記事のHTMLソースを開いて、URLの実物を確認して原因が見えた。",[162,163,168],"pre",{"className":164,"code":166,"language":167},[165],"language-text","\u003C!-- WordPressのHTML内の実際のURL -->\nhttps://eurekapu.com/wp-content/uploads/2018/.../%8eQ%8f%c6_03_%90%e2%91%ce%8eQ%8f%c6.mp4\n","text",[14,169,166],{"__ignoreMap":170},"",[10,172,173,176,177,180,181,184,185,188,189,73,192,195,196,199],{},[14,174,175],{},"%8eQ%8f%c6"," で始まっている。これはShift_JISでエンコードされた「参照」の3バイト（",[14,178,179],{},"8E 51","の",[14,182,183],{},"%8e","が壊れているように見えるが",[14,186,187],{},"参","はShift_JISで",[14,190,191],{},"8E51",[14,193,194],{},"照","は",[14,197,198],{},"8FC6","）。旧WordPressがファイル名をShift_JISエンコードのまま記事HTMLに埋め込んでいた。",[10,201,202,203,206,207,210],{},"移行スクリプトはこのURLをそのまま叩いていた。WordPressのストレージは「UTF-8パーセントエンコード（",[14,204,205],{},"%E5%8F%82%E7%85%A7","）でアクセスすれば実体MP4を返す」サーバーになっていた。Shift_JISパーセントエンコードでアクセスすると当然マッチせず、404 HTMLが返ってくる。けれどステータスコードはなぜか ",[14,208,209],{},"200"," だった（WordPressのテーマが404ページを200で返す設定だったのが致命傷）。",[10,212,213,214,216],{},"スクリプトは200を見て「OK」と判断し、49KBのHTML本文を ",[14,215,43],{}," として保存し、R2に上げていた。",[10,218,219],{},"税理士視点だと、過去のクライアントから受け取ったCSV帳票がShift_JISで、UTF-8前提のスクリプトに食わせたら文字化けしたまま会計ソフトに登録されていた、という事故と構造が同じ。文字コードは消えない。",[46,221,223],{"id":222},"_100ファイルを並列調査して4本特定した","100ファイルを並列調査して4本特定した",[10,225,226],{},"「他にも壊れてるやつあるんじゃないか」が次の疑問。R2の動画ファイル全100本を全部チェックする必要があった。",[10,228,229,230,233,234,237],{},"PythonでR2のpublicドメイン経由で全URLにHEADリクエストを投げ、",[14,231,232],{},"Content-Length"," が50KB未満のものをリストアップする検証スクリプトをClaude Codeに書いてもらった。",[14,235,236],{},"asyncio.gather"," で20並列でHEAD投げる作りにしたら、ものの数秒で「4本だけ49KB前後、残り96本は500KB〜2MB」と判明した。",[10,239,240],{},"ただ、ここで一度事故った。並列度を上げすぎてCloudflareにレート制限を食らい、自分のIPからのcurlが数分間503で返ってくるようになった。「動画を直す検証スクリプトのせいで動画が見られなくなる」という本末転倒。並列度を5に絞り直して再実行した。",[162,242,246],{"className":243,"code":244,"language":245,"meta":170,"style":170},"language-python shiki shiki-themes vitesse-light vitesse-light","# 修正後（並列度を抑えた）\nsem = asyncio.Semaphore(5)\nasync def check(url):\n    async with sem:\n        async with session.head(url) as resp:\n            return url, resp.headers.get(\"content-length\")\n","python",[14,247,248,257,287,309,325,355],{"__ignoreMap":170},[249,250,253],"span",{"class":251,"line":252},"line",1,[249,254,256],{"class":255},"sxvE3","# 修正後（並列度を抑えた）\n",[249,258,260,264,268,271,274,277,280,284],{"class":251,"line":259},2,[249,261,263],{"class":262},"sG7-3","sem ",[249,265,267],{"class":266},"shFtX","=",[249,269,270],{"class":262}," asyncio",[249,272,273],{"class":266},".",[249,275,276],{"class":262},"Semaphore",[249,278,279],{"class":266},"(",[249,281,283],{"class":282},"sM54T","5",[249,285,286],{"class":266},")\n",[249,288,290,294,297,301,303,306],{"class":251,"line":289},3,[249,291,293],{"class":292},"stQ0i","async",[249,295,296],{"class":292}," def",[249,298,300],{"class":299},"senZ8"," check",[249,302,279],{"class":266},[249,304,305],{"class":262},"url",[249,307,308],{"class":266},"):\n",[249,310,312,316,319,322],{"class":251,"line":311},4,[249,313,315],{"class":314},"sHkkW","    async",[249,317,318],{"class":314}," with",[249,320,321],{"class":262}," sem",[249,323,324],{"class":266},":\n",[249,326,328,331,333,336,338,340,342,344,347,350,353],{"class":251,"line":327},5,[249,329,330],{"class":314},"        async",[249,332,318],{"class":314},[249,334,335],{"class":262}," session",[249,337,273],{"class":266},[249,339,110],{"class":262},[249,341,279],{"class":266},[249,343,305],{"class":262},[249,345,346],{"class":266},")",[249,348,349],{"class":314}," as",[249,351,352],{"class":262}," resp",[249,354,324],{"class":266},[249,356,358,361,364,367,369,371,374,376,379,381,385,389,391],{"class":251,"line":357},6,[249,359,360],{"class":314},"            return",[249,362,363],{"class":262}," url",[249,365,366],{"class":266},",",[249,368,352],{"class":262},[249,370,273],{"class":266},[249,372,373],{"class":262},"headers",[249,375,273],{"class":266},[249,377,378],{"class":262},"get",[249,380,279],{"class":266},[249,382,384],{"class":383},"sMJiu","\"",[249,386,388],{"class":387},"sdGka","content-length",[249,390,384],{"class":383},[249,392,286],{"class":266},[46,394,396],{"id":395},"修復-wordpressから実体を取り直してr2に再アップロード","修復: WordPressから実体を取り直してR2に再アップロード",[10,398,399,400,403],{},"壊れている4本のファイル名がわかったので、WordPressに実体MP4が残っているかを確認した。今度は",[117,401,402],{},"UTF-8パーセントエンコード","でURLを組み立てて叩いた。",[162,405,409],{"className":406,"code":407,"language":408,"meta":170,"style":170},"language-bash shiki shiki-themes vitesse-light vitesse-light","# Shift_JISで叩いた旧URL → 49KBのHTML\ncurl -I \"https://eurekapu.com/.../%8eQ%8f%c6_03_%90%e2%91%ce%8eQ%8f%c6.mp4\"\n\n# UTF-8で叩いた新URL → 1.89MBのMP4\ncurl -I \"https://eurekapu.com/.../%E5%8F%82%E7%85%A7_03_%E7%B5%B6%E5%AF%BE%E5%8F%82%E7%85%A7.mp4\"\n# Content-Length: 1985432\n","bash",[14,410,411,416,434,440,445,458],{"__ignoreMap":170},[249,412,413],{"class":251,"line":252},[249,414,415],{"class":255},"# Shift_JISで叩いた旧URL → 49KBのHTML\n",[249,417,418,421,425,428,431],{"class":251,"line":259},[249,419,420],{"class":299},"curl",[249,422,424],{"class":423},"snbK4"," -I",[249,426,427],{"class":383}," \"",[249,429,430],{"class":387},"https://eurekapu.com/.../%8eQ%8f%c6_03_%90%e2%91%ce%8eQ%8f%c6.mp4",[249,432,433],{"class":383},"\"\n",[249,435,436],{"class":251,"line":289},[249,437,439],{"emptyLinePlaceholder":438},true,"\n",[249,441,442],{"class":251,"line":311},[249,443,444],{"class":255},"# UTF-8で叩いた新URL → 1.89MBのMP4\n",[249,446,447,449,451,453,456],{"class":251,"line":327},[249,448,420],{"class":299},[249,450,424],{"class":423},[249,452,427],{"class":383},[249,454,455],{"class":387},"https://eurekapu.com/.../%E5%8F%82%E7%85%A7_03_%E7%B5%B6%E5%AF%BE%E5%8F%82%E7%85%A7.mp4",[249,457,433],{"class":383},[249,459,460],{"class":251,"line":357},[249,461,462],{"class":255},"# Content-Length: 1985432\n",[10,464,465],{},"WordPressのストレージにはちゃんと実体が残っていた。7本分（壊れていた4本＋念のため疑わしい3本）のUTF-8 URLからmp4をダウンロードし、R2に再アップロードした。HEADで再確認して、全部500KB以上のmp4になっていることを確認した。",[46,467,469],{"id":468},"cdnキャッシュが古いhtmlを返し続けた","CDNキャッシュが古いHTMLを返し続けた",[10,471,472],{},"「これで終わり」と思ってブラウザで動画ページを再読込したら、まだ動かない。DevToolsで見ると依然として49KBが返ってきている。",[10,474,475],{},"Cloudflare CDNのエッジキャッシュが、古い「200 OKのHTML 49KB」を握ったまま離さない。R2のオブジェクト自体は新しいmp4に差し替わっているのに、CDNが古いレスポンスを配り続けていた。",[10,477,478,479,486],{},"Cloudflareダッシュボードからキャッシュパージを叩く手もあったが、対象URLが7本だけなので",[117,480,481,482,485],{},"データファイルの動画URLに ",[14,483,484],{},"?v=2"," を付ける","キャッシュバスターで対応した。クエリ文字列が変わればCDNは新規リクエスト扱いでオリジン（R2）に取りに行く。",[162,488,492],{"className":489,"code":490,"language":491,"meta":170,"style":170},"language-typescript shiki shiki-themes vitesse-light vitesse-light","// 動画データの定義ファイル\nexport const videos = [\n  {\n    title: \"絶対参照の動画\",\n    url: \"/videos/参照_03_絶対参照.mp4?v=2\", // ← ?v=2 を追加\n  },\n  // ...\n]\n","typescript",[14,493,494,499,517,522,541,561,566,572],{"__ignoreMap":170},[249,495,496],{"class":251,"line":252},[249,497,498],{"class":255},"// 動画データの定義ファイル\n",[249,500,501,504,507,511,514],{"class":251,"line":259},[249,502,503],{"class":314},"export",[249,505,506],{"class":292}," const ",[249,508,510],{"class":509},"s4oTP","videos",[249,512,513],{"class":266}," =",[249,515,516],{"class":266}," [\n",[249,518,519],{"class":251,"line":289},[249,520,521],{"class":266},"  {\n",[249,523,524,528,531,533,536,538],{"class":251,"line":311},[249,525,527],{"class":526},"sz8Xr","    title",[249,529,530],{"class":266},": ",[249,532,384],{"class":383},[249,534,535],{"class":387},"絶対参照の動画",[249,537,384],{"class":383},[249,539,540],{"class":266},",\n",[249,542,543,546,548,550,553,555,558],{"class":251,"line":327},[249,544,545],{"class":526},"    url",[249,547,530],{"class":266},[249,549,384],{"class":383},[249,551,552],{"class":387},"/videos/参照_03_絶対参照.mp4?v=2",[249,554,384],{"class":383},[249,556,557],{"class":266},", ",[249,559,560],{"class":255},"// ← ?v=2 を追加\n",[249,562,563],{"class":251,"line":357},[249,564,565],{"class":266},"  },\n",[249,567,569],{"class":251,"line":568},7,[249,570,571],{"class":255},"  // ...\n",[249,573,575],{"class":251,"line":574},8,[249,576,577],{"class":266},"]\n",[10,579,580],{},"ブラウザで再読込したら、今度は1.89MBのmp4が返ってきて再生が始まった。",[46,582,583],{"id":583},"同じ問題が同じ日に2回再発した",[10,585,586,587,590,591,594],{},"夕方、別セッションで同様の事象を踏み直した。記憶が新しいうちにissueドキュメントを ",[14,588,589],{},".claude/issues/2026-05-04-r2-video-shiftjis.md"," に残し、検証用Pythonスクリプトも ",[14,592,593],{},"scripts/check-r2-videos.py"," として汎用化してリポジトリに置いた。",[10,596,597],{},"issueドキュメントの中身は「症状（HTTP 200だがContent-LengthがHTML相当）」「原因（Shift_JIS URLを叩いて404 HTMLを保存）」「再発時のワンライナー（HEADで全件チェック→閾値以下を抽出）」の3点に絞った。次に同じ事象を踏んだとき、issueを開けば即座にチェックスクリプトを叩ける状態にした。",[46,599,600],{"id":600},"今日の学び",[54,602,603,613,623,626,637],{},[57,604,605,608,609,612],{},[14,606,607],{},"HTTP 200"," は「リクエストが届いた」だけを意味する。",[117,610,611],{},"Content-Lengthとマジックバイトを必ず見る","。WordPressのテーマが404ページを200で返す設定だと、ステータスコード信頼が崩壊する",[57,614,615,616,619,620,622],{},"移行スクリプトは「ダウンロードしたバイナリのサイズ・先頭バイトが期待通りか」のアサーションを必ず入れる。",[14,617,618],{},"magic"," ライブラリで ",[14,621,24],{}," を確認するだけで今回の事故は防げた",[57,624,625],{},"WordPress時代の遺産（Shift_JISパーセントエンコード）は、UTF-8前提のスクリプトに食わせると静かに壊れる。古いCMSからの移行は文字コードを必ず疑う",[57,627,628,629,632,633,636],{},"Cloudflare CDNのエッジキャッシュは強い。R2を直したら",[117,630,631],{},"クエリ文字列のキャッシュバスター"," or ",[117,634,635],{},"キャッシュパージAPI"," をセットで叩く",[57,638,639],{},"並列リクエストは5〜10に絞る。自分のCDNに自分でDoSをかけてレート制限を食らう事故は本当にやる",[10,641,642,643,646,647,650,651,654],{},"会計士・税理士視点だと、古いお客さまからのCSV帳票がShift_JIS、Excelで開くと文字化け、",[14,644,645],{},"pandas.read_csv()"," でも ",[14,648,649],{},"UnicodeDecodeError"," で止まる、という場面に直結する。",[14,652,653],{},"encoding=\"cp932\""," で開き直して、UTF-8で再保存して以降の処理に流すフローを最初から組んでおくと、移行プロジェクトのこの手の事故が消える。",[656,657,658],"style",{},"html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html pre.shiki code .sG7-3, html code.shiki .sG7-3{--shiki-default:#393A34;--shiki-dark:#393A34}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .sM54T, html code.shiki .sM54T{--shiki-default:#2F798A;--shiki-dark:#2F798A}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .snbK4, html code.shiki .snbK4{--shiki-default:#A65E2B;--shiki-dark:#A65E2B}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}",{"title":170,"searchDepth":259,"depth":259,"links":660},[661,662,663,664,665,666,667],{"id":48,"depth":259,"text":49},{"id":126,"depth":259,"text":127},{"id":222,"depth":259,"text":223},{"id":395,"depth":259,"text":396},{"id":468,"depth":259,"text":469},{"id":583,"depth":259,"text":583},{"id":600,"depth":259,"text":600},"dev","本番環境で4本の動画が再生できないとユーザーから報告。HTTP 200は返るのに中身が49KBの404 HTMLになっていた。原因はWordPressからの移行時、Shift_JIS（%8eQ%8f%c6...）でエンコードされた日本語ファイル名のURLをUTF-8で叩いて404 HTMLを掴み、それをそのまま.mp4としてR2に上げていたこと。実体MP4を再取得してR2に再アップロードし、Cloudflare CDNが古いHTMLを返してくる対策に動画URLへ`?v=2`を付けてキャッシュバスターした。","md",{},null,"/r2-video-shiftjis-corruption","eurekapu-nuxt4",false,"2026-05-04T00:00:00.000Z",{"title":5,"description":669},"2026-05/2026-05-04/r2-video-shiftjis-corruption",[680,681,682,683,684,685,686],"cloudflare","r2","wordpress","shift-jis","cdn-cache","nuxt4","incident","ZqnioGckF9Lq9NE7a3g_ijAm4yCEh4OQFSaxqdEEdDY",[],"https://log.eurekapu.com/og/blog/r2-video-shiftjis-corruption.png?v=2026-05-04T00%3A00%3A00.000Z&title=Cloudflare%20R2%E3%81%AE%E5%8B%95%E7%94%BB%E3%81%8C%E5%86%8D%E7%94%9F%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84%E5%8E%9F%E5%9B%A0%E3%81%AFWordPress%E6%99%82%E4%BB%A3%E3%81%AEShift_JIS%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D%E3%81%A0%E3%81%A3%E3%81%9F%E8%A9%B1%E3%81%A8CDN%E3%82%AD%E3%83%A3%E3%83%83%E3%82%B7%E3%83%A5%E3%83%90%E3%82%B9%E3%82%BF%E3%83%BC%E5%AF%BE%E5%BF%9C&author=Kei%20Komatsu&sig=299de5731fdefdef",1782528832936]