62,331件の書籍図版をCloudflare R2へ移行する計画づくりとCodexへの実装委任

開発book-knowledge-base

62,331件の書籍図版をCloudflare R2へ移行する計画づくりとCodexへの実装委任

book-knowledge-base の図版画像(figures/*.png)が 62,331件・2.96GB まで積み上がっていた。発端は同じ日の朝のCドライブ逼迫調査で、data/md/(18GB)の内訳を洗い出したこと。OCRデバッグ画像13.76GBはその場で消せたが、figures/ だけは消すこともDBに入れることもできずに残った。Webビューアが実行時にローカルのファイルシステムから直接ストリームしている唯一の実体だからだ。この山を Cloudflare R2 に逃がす計画を立て、Codexレビューを通し、実装本体はCodexに委任するところまで進めた。

なぜ移行するのか

テキストは93,505件のchunksがTursoのクラウドDBに複製済みなのに、図版だけがこの1台のWindows機のCドライブに縛られている。data/ はGit管理外なので、リポジトリ肥大化の話ではなく、問題は実体の置き場所そのものにある。

  • 単一障害点: 62,329枚のPNGはこのマシンのディスクにしか存在しない。バックアップも複製もなく、ディスク故障が起きたら原本の書籍から図版を切り出し直すしかない
  • 可搬性: DBはHTTP接続でどこからでも読めるのに、画像のためだけにビューアがこの1台に依存し続ける
  • 一貫性: テキストはクラウド、画像はローカルという保管方針のちぐはぐさ

まず事実を集めた

計画を書く前に、土台となる事実を並行して確認させた。

  • 参照記事・現行コード・DBスキーマ・デプロイ構成の4系統を同時にチェック
  • 配信の現実装は web/server/api/figures/[...path].get.tscreateReadStream でローカルファイルをそのまま返しているだけ
  • public/figures への参照は grep がマッチ0件を返し、レガシーな未使用エントリと確定(exit code 1 は grep の仕様上の正常終了)

「動いているコードのどこが figures を読んでいるか」を先に固めておいたおかげで、計画書の変更対象ファイル一覧を推測ではなく事実で書けた。

設計の分かれ目: 93,505件に焼き込まれたURL

方式選定でいちばん効いた制約は、インポート時に <img src="/api/figures/{book_id}/figures/{filename}"> という形へ書き換えた画像URLが、既に93,505件のchunks本文に焼き込まれていることだった。URLスキームを変えるなら93,505件の一括UPDATEとそのロールバックリスクを背負うことになる。

そこで「R2バケットを公開してURLを直参照に書き換える」案は捨て、URLをいっさい変えないサーバープロキシ方式を採った。

  • ブラウザからのURLは今と同じ /api/figures/...。APIルートの内部実装だけを、ローカルfs読み出しからR2の GetObjectCommand + pipeline() ストリーミングに差し替える
  • バケットは非公開のまま。書籍から抽出した図版という著作物由来の画像を、全世界に公開する理由がない
  • アプリのランタイムには「Object Read only」・対象バケット限定でスコープした専用トークンだけを持たせる(最小権限)
  • 認証情報は既存の turso-replicas/.envBOOK_KB_R2_* の命名で相乗りさせ、秘密情報の配布経路を増やさない

移行期間中の「R2になければローカルから」というコード内フォールバックは実装しない。ローカルの figures/ は最終Phaseで明示的に削除するまで安全網として残すが、コード上の分岐は増やさない方針にした。

外部調査で裏取りしたこと

R2は初めて本格導入するので、外部調査ワークフローをバックグラウンドで回して4トピック(料金体系・6万件超の一括アップロード手法・非公開バケットのNode.js読み出し方式・オブジェクトキーの制約)を公式情報で裏取りした。

一括アップロードのツール比較:

手法判定理由
rclone(採用)Cloudflare公式に専用ドキュメントあり。再実行時は差分だけ再送するので中断からの再開に強い
AWS CLI同じく公式ドキュメントあり。rcloneを避けたい場合の代替
wrangler r2 object put×公式自身が「一度に1オブジェクトのみ」と明記。62,329回のループは非現実的
boto3自前スクリプトレジュームの車輪の再発明になる
Super Slurper×移行元はクラウドストレージ限定。ローカルディスク非対応

コストは公式料金ページの当日フェッチで確認した。ストレージ2.96GBは無料枠(月10GB-month)内、初回アップロード62,329回のClass A操作も無料枠(月100万回)内、エグレスは無条件で無料。想定月額は$0で収まる。

調査は落とし穴も拾ってくれた。figures/ には半角スペースを含むファイル名が130件混じっていて、R2では半角スペース入りキーが「object not found」になった報告がコミュニティにある。日本語ファイル名のNFC正規化の仕様も含め、本番アップロード前に危険なファイル名だけの小規模サンプルで先行テストするステップを計画に組み込んだ。

計画書を書いて Codex にレビューさせた

Phase 0(バケット作成・トークン発行)からPhase 4(ローカル削除)までの実行計画を memo/2026-07-16/figures-r2-migration-plan.md に書いた。本番配信コードを触る計画なので、リポジトリのルールに従って Codex(GPT-5.6)にレビューを依頼した。

返ってきた致命的指摘は3件。

  1. 新規インポートした書籍の画像が、R2移行後に即404になる → 配信API切り替えと同時に、インポートスクリプト側にもその書籍分をR2へアップロードするステップを追加する Phase 2.5 を新設。「同時リリース必須」を計画に明記した
  2. 件数一致だけの検証では、キーの取り違えと欠落が相殺して隠れるrclone check によるサイズ・ハッシュの全件照合まで実施する形に強化
  3. ローカル削除後はR2が唯一の実体になる → 削除前にDropbox等の同期フォルダへ二次バックアップを取るステップを追加。R2は高耐久だが誤削除やアカウント側の事故を防ぐものではない、とCloudflare自身が明記している

1件目の指摘で手が止まった。移行を「いま在る62,331件を動かす作業」としてしか見ておらず、明日インポートする1冊のことが頭から抜けていた。移行対象は静止したスナップショットではなく、増え続けるデータだった。おまけにレビューの過程で、移行とは無関係に既に壊れている画像参照の存在まで見つかり、移行前に壊れた参照を洗い出しておくPhase 0.5も足した(移行後に404が出たとき「移行が原因か、元から壊れていたか」を切り分けるため)。3件すべてを反映し、ロールバック表・スコープ外セクション・変更対象ファイル一覧の整合も取り直した。

実装本体は Codex に委任した

Fable 5 のセッションは rate limit を温存したいので、実装本体は Codex に任せる分担にした。引き継ぎプロンプトを作らせてセッションを切り替え、実装が上がってきたら diff レビューだけ Fable 5 側でやらせる形にした。

切り替え先のセッションでは、貼り付けた引き継ぎプロンプトにコピペ崩れの文字欠落が複数あったが、正本の計画書を先に読ませて解釈を確定させる流れにしていたので誤読には至らなかった。着手前の確認では、

  • turso-replicas の .env はキーの存在だけ確認(値は読まない運用)
  • BOOK_KB_R2_* のキーがまだ1つもなく、Phase 0(R2バケット作成・APIトークン発行)は自分側で未実施と判明

という状態が見えた。ここで codex:rescue スキル経由の委任を実行したところ、内部の安全分類器に一度ブロックされた。作業内容は普通のストレージ移行で、意図と無関係にガードレールが誤発動した形だ。ただ委任経路を立て直して続行し、同日中に Phase 0〜4 まで完走した。検証記録(figures-r2-migration-verification.md)によると、全62,331件(3,182,109,461バイト)のアップロード、rclone check --download での全件照合(差分0)、Dropbox同期フォルダへの二次バックアップとMD5照合、表示検証、そしてユーザー承認後のローカル図版削除(残存0件)まで終わっている。

一方、元セッション側には「Codexレビューの完了状況を確認して未反映なら反映する」というチェックを仕込んであり、走らせると「3件すべて反映済み・引き継ぎプロンプト作成済み・未完了作業なし」が返ってきた。次のセッションでも実装状況をこの形で確認できる。

学び

  • 移行計画の盲点は「移行後も増え続けるデータ」。既存分の引っ越しに集中すると、新規流入の経路が抜ける。Codexの3件はどれも「自分では書けなかった運用の穴」だった
  • URLが9万件のDBレコードに焼き込まれているなら、URLを動かさずに裏側だけ差し替える。データの一括書き換えは最後の手段
  • 6万件の一括転送は「転送が完了したか」ではなく「中身が一致しているか」(rclone check)まで照合してはじめて削除の前提になる
  • 消すのは最後。二次バックアップを取ってからローカルを消す。クラウドストレージの耐久性は誤削除を防いでくれない
  • 引き継ぎプロンプトはコピペ崩れが起きる前提で、正本ファイルのパスを含めて「まず正本を読め」と書いておくと壊れない

次にやること(実装完了後の残処理)

  • Phase 0〜4: バケット作成〜アップロード〜全件照合〜二次バックアップ〜ローカル削除(同日中に完了)
  • Dropbox バックアップの「オンラインのみにする」バックグラウンド処理の完了確認
  • 移行用 Object Read & Write トークンの失効