Cloudflare R2の画像を差分アップロードに変え、第三者権利のある図を本番から下ろす

開発eurekapu-nuxt4

前の晩、本番のサイトで図の原本が開けないのを見ていた。ただ、そのときは手をつけないままにしていた。翌朝いちばんにやったのは、その件ではなく、前日の積み残しを並べさせることだった。

出てきたのは4系統。このリポジトリの未pushと未デプロイ、講義用テキストの図レビューの続き、QA教材のフェーズ残り、それとサイト本体側で公開記事の図3枚が未作成でリンク切れの疑い。最後のがいちばん痛いが、まず手前を片づけることにした。未コミット分をコミットしてデプロイする。学習ゲートはスキップでいいと伝えた。

デプロイは通ったのに、旧版の図が出ない

同じ作業ツリーを別のセッションが触っていた点だけ、先に確認させた。最初は講義用テキストの図レビュー側だと言われた。それだとビルドの途中でSVGが書き換わり、その途中状態が本番に乗ってしまう。実際に動いていたのはQA教材のほうで、触るのはコンテンツの元ファイルとメモだけだった。ビルド出力には入らない。混入の心配は消えた。

pushは通った。デプロイもバックグラウンドで走らせ、しばらくして exit 0 が返ってきた。ただ、このデプロイスクリプトはビルドに失敗しても「Status: built」を返すことがある。既知の落とし穴としてissueに残してあるので、成功と書いてあることは信用せず、実物を3点確かめさせた。ビルド出力の.mjsが0件でないこと、本番の図JSONが手元と一致すること、SVGのmd5が合うこと。8件は8件とも一致した。

そのうえでギャラリーを開いたら、旧版の枠が2つ空いていた。v2とv3が読み込めていない。数えさせると、旧版のSVGが23件リンク切れになっていた。デプロイのせいではなかった。前日にR2へ上げたとき、この23件が対象から漏れていた。前の晩に見た「原本が開けない」の正体がこれだった。

本当にコマンドで済むのか

上げ直すためのスクリプトはある。ただ、走らせる前に引っかかった。全部上げ直すのだとしたら、ほかのものまで書き潰さないだろうか。コミット済みなら戻せるとはいえ、アップロード先はGitの管理下にない。

実行させずに全文を読ませた。結論は、削除は一切しない、putしかしない、というものだった。ただ読ませたおかげで別のものが出てきた。第三者に権利のある図が1枚だけあり、それがスクリプトの使用例で除外対象として名指しされていた。手動の-Excludeで外す運用になっている。

書き込みなしのモードで対象を数えさせ、全件ではなく欠けている23件だけを上げる案に絞った。権利のある図が混ざっていないことも確かめてから実行させた。23件すべて成功と出た。

上げたのに404が返ってくる

ところが、上げたはずのものが本番ではまだ404を返した。

切り分けさせたら、R2には正しい実体があった。md5は手元と完全に一致している。404を返していたのはCloudflareのエッジで、さっきまでの404をキャッシュしていた。cf-cache-statusはHIT、max-ageは14400秒。4時間は自力で消えない。キャッシュを迂回すれば23件とも200で返ることまで確認させた。

パージ用のスクリプトは無い。APIキーを取りに行こうとして.envに触れたところで弾かれた。読まないルールにしてあるので、そこは正しい挙動だった。

ブラウザでパージさせる

コマンドで押し切るより、自分がログイン済みのブラウザでやらせたほうが早い。Chrome DevTools経由でダッシュボードを操作させることにした。

アカウントIDを取らせてからキャッシュの設定ページへ移動し、カスタムパージを開かせた(すべてパージは押させない)。URL指定の上限は30件で、こちらは23件。貼り付けてから、押す前に中身を検証させた。23行、想定外のURLは0、重複も0。

ここでボタンが有効にならなかった。テキストは入っているのに、React側の状態に伝わっていない。正規のinputイベントを発火させて同期させたら押せるようになった。画面を目視で確かめてから実行させた。

確認は旧版51件をまとめて行い、すべて200で返った。v2とv3の枠に図が出て、画像の読み込み失敗は0件になった。

1枚だけ直したときに何が起きるのか

ここで気になったことを聞いた。図を作り直したら、そのスクリプトを回すのは全件再アップロードになるのか。1枚だけ修正しても。

そのとおりだった。両方のスクリプトとも差分検知を持っていない。しかも意図的で、upload-images-to-r2.ps1の7行目にこう書いてある。

すでに R2 上に同じ key が存在しても上書きする(差分検知はしない=シンプル運用)

思いつきをそのまま言った。変更したらGitの履歴に入るのだから、履歴からファイル名を拾って上げ直せばいいのではないか。そんなに難しいことをやろうとしているわけではないはずだ。無料枠が100万回なら費用は問題にならないけれど、安く作れるならそのほうがいい。

Gitの履歴では捕まらないもの

返ってきた反論で、自分の案がこの日のバグを捕まえられないことがわかった。

404だった図の1枚は8月16日にコミット済みだった。Gitから見れば「とっくに変更済み」で、その後に何も起きていない。落ちていた理由は、変更されたのに上げ忘れたことだった。Gitが答えるのは「何が変わったか」であって、「R2に何が無いか」ではない。

ETagを比較すれば両方まとめて拾えて、コード量も同じくらいになる。Git由来のリストも作れるが、二重管理になるぶんかえって複雑になる。ETag比較でいくことにした。

弱いETagで51件の誤検出

実装させて書き込みなしで回したら、差分51件と出た。合っているはずのものが全部ずれている。

原因は圧縮配信だった。SVGは圧縮して配られるので、ETagがW/"..."という弱い形になる。値そのものは同じmd5で、前後の飾りが違うだけだった。パースを直したら差分ゼロになった。ターミナルの文字化けのほうは、表示だけの問題だった。

差分ゼロは正常だが、それだけでは検出できることの証明にならない。権利つきの1枚はR2に無いはずなので、除外を外せば1件だけ拾える。そういうテストをさせた。

出なかった。その1枚はR2にあって、公開URLで200を返していた。

検出が働くこと自体は別のやり方で確かめ、もう一方のスクリプトにも同じ仕組みを入れた。どちらも差分ゼロで落ち着いた。残ったのは、200を返していた1枚のほうだった。

権利のある図を本番から下ろす

除外は手作業のリストに頼っていて、書き忘れればそのまま通る。実際に本番から配信されていた。

その図はウェブから表示しないことにした。誰が作った図なのか自分でも思い出せず、国が出しているものではないかと口に出した。デプロイを待つあいだに説明は返ってきたが、判断は変えなかった。どのみち後で消す予定だったので、前倒しにした。R2の実体も削除していいと伝えた。

直させたのは3か所。

  • 生成側は、台帳のrightsok以外なら原図そのものを出力しない
  • アップロード側は、台帳のrightsを見て自動で弾く。手動の-Excludeに頼らない
  • R2の実体は消す。復元元が手元にある(244KB)ことを確かめてから実行させた

自動除外は効いた。-Excludeを付けなくてもその1枚は対象外になり、件数452も一致した。教材JSONの差分もその1件だけで、チェックコマンドにも差分は出なかった。

R2から消したのに、まだ200が返ってきた。またパージさせた。R2からも本番経由でも404になり、ほかの図は200のままだった。コミットとデプロイをやり直させ、3.9分で通った。

最後に、次のセッションで図レビューの続きに入るためのプロンプトを出させて終わりにした。

残ったもの

  • Gitが答えられるのは「何が変わったか」まで。「向こうに何が無いか」は別の問いで、答えを持っていたのはR2側のETagだけだった
  • 手動の除外リストは、書いた本人が忘れる。台帳のフラグで自動的に弾く形にしないと、いつか通る
  • R2に上げただけでは直らない。エッジの404は4時間居座る。実体を消したときも同じで、消したのに200が返る
  • 「本当にコマンドで済むのか」と引っかかったのは正解だった。削除しないスクリプトだったが、走らせる前に全文を読ませたおかげで権利つきの1枚が浮かんだ