
Article
さくらのVPS → Cloudflare Workers 移植Tips:月額コスト0円化の実践記録
さくらのVPS 2GBでCoolifyを使って運用していた自作のMarkdownエディタを、Cloudflare Workersに完全移植した。Flask + Redis + Celeryの構成から、Hono + D1 + R2 + Queuesへの全面移行で、月額費用をゼロにすることができた。
移行の過程でいくつかハマりどころがあったので、同じような移行を検討している人向けに知見をまとめておく。
移行前後の構成概要
もともとさくらのVPS 2GBで、Coolifyを使ってコンテナ管理をしていた。構成はこんな感じ:
- APIサーバー: Flask(Python)
- データストア: Redis(DB兼セッション管理)
- 非同期処理: Celery
- ファイルストレージ: ローカルディスク
- テンプレートエンジン: Jinja2
これをCloudflareのエコシステムで置き換えた結果、外部依存ゼロの完全Cloudflare完結構成になった。
1. Claude Code Agent Teamによる並行移植
今回の移植では、Claude CodeのAgent Team機能を活用して作業を並行で進めた。
5チームによる並行作業
「Agent Teamを作って並行で作業を進めて」と指示すると、5チームが同時起動されそれぞれの領域を担当してくれた:
- 認証チーム - OAuth、APIトークン、middleware
- ドキュメントCRUDチーム - 最大のピース
- コレクション管理チーム
- 添付ファイル + R2ストレージチーム
- Queues + 検索 + AI機能チーム
各チームへの指示には、元ソースのファイルパスと移植ルールを明確に渡すことが重要だった。また、下記のようなトレーサビリティコメントを残すルールにしたことで、後から何を移植したのか追いやすくなった。
// from: app/routes/api.py
export async function handleDocumentCreate(c: Context) {
// ...
}
共有ファイル(types.ts、d1.ts、api.tsなど)への競合は発生するが、それは統合フェーズでまとめて解消できた。
2. Cloudflareスタック選定
各技術の置き換え対応表はこちら:
| 元の技術 | Cloudflare代替 | 備考 |
|---|---|---|
| Flask API | Hono on Workers | 軽量で型安全、Flaskに近いAPI設計 |
| Redis(DB) | D1(SQLite) | FTS5で全文検索も対応 |
| Redis(セッション) | 署名付きCookie | HMAC-SHA256でステートレス管理 |
| Celery | Cloudflare Queues | Consumer Workerで非同期処理 |
| ローカルファイル | R2 | S3互換、egress無料 |
| Jinja2 SSR | Hono html template | バッククォートテンプレートリテラル |
Upstash Redisは不要だった
当初はRedisの代替としてUpstash Redis(外部サービス)を使う設計で考えていたが、実際にはD1で全て賄えた。外部依存ゼロのCloudflare完結構成にできたのは想定以上のメリットだった。
3. データ移行の戦略
基本方針
データ移行はAPIを経由せず、移行スクリプトからD1に直接INSERTする方針にした。APIを通すとcreated_atやupdated_atが移行時刻で上書きされてしまうため、SQLを直接実行して元の日時を保持するようにした。
旧API → 移行スクリプト → D1直接INSERT(APIを通さない)
ハマりどころ
D1のSQL文サイズ上限(SQLITE_TOOBIG)
1つのSQL文が約100KBを超えるとSQLITE_TOOBIGエラーになる。日本語テキストはUTF-8で3バイト/文字なので、40,000文字で約120KBを超えてしまう。
対策として、contentなしでまずINSERTして、その後contentを10,000文字ずつチャンクでUPDATEする方式にした:
-- まずcontentなしでレコードを作る
INSERT INTO documents (id, title, owner_id, created_at) VALUES (?, ?, ?, ?);
-- contentを分割してAPPEND
UPDATE documents SET content = content || ? WHERE id = ?;
-- 上記を chunk ごとに繰り返す
owner_idの不一致
旧システムと新システムでユーザーIDが異なるため、移行後に一括で書き換えた:
UPDATE documents SET owner_id = '新システムのID' WHERE owner_id = '旧システムのID';
画像URLの書き換え
旧システムでは/uploads/doc_id/filename形式だったURLを、新システムの/r2/doc_id/filename形式に変換する必要があった。D1上でSQLのREPLACE関数を使って一括変換できる:
UPDATE documents
SET content = REPLACE(content, '/uploads/', '/r2/')
WHERE content LIKE '%/uploads/%';
注意点として、/r2/プレフィックスの二重付与(/r2/r2/...になってしまう)には気をつける。更新後に確認クエリを実行しておくのが無難。
差分移行
移行後も旧システムで更新が続いた場合は、差分移行が必要になる。旧APIからsort=updated_descで最新データを取得し、カットオフ時刻以降に更新されたものだけを再移行した。
4. よくあるバグと対策
onclick属性のダブルクォート衝突
innerHTMLで動的にボタンを生成するとき、JSON.stringifyの出力にダブルクォートが含まれると、HTML属性のクォートと衝突してしまう:
<!-- NG: JSON.stringifyのダブルクォートがonclick属性と衝突 -->
<button onclick="fn("value")">ボタン</button>
<!-- OK: HTMLエンティティにエスケープ -->
<button onclick="fn("value")">ボタン</button>
escAttr(JSON.stringify(value))のようなユーティリティ関数を用意してエスケープするのが定番の対策。
FTS5の特殊文字
SQLiteのFTS5では、ハイフン-がNOT演算子として解釈される。例えばQuickNote-20260316で検索するとQuickNote NOT 20260316という意味になってSQLエラーが起きる。
対策はクエリ全体をダブルクォートで囲んでフレーズ検索にすること:
-- NG
SELECT * FROM docs_fts WHERE docs_fts MATCH 'QuickNote-20260316';
-- OK: フレーズ検索にする
SELECT * FROM docs_fts WHERE docs_fts MATCH '"QuickNote-20260316"';
Hono c.req.query のthisバインド切れ
c.req.queryをそのまま変数に代入してから呼び出すと、thisが切れてTypeErrorになる:
// NG: thisが切れる
const q = c.req.query;
q("key"); // TypeError: Cannot read properties of undefined
// OK: ラッパーで囲む
const q = (key: string) => c.req.query(key);
地味にハマりやすいのでご注意を。
wrangler v4の--remoteフラグ
wrangler v3ではR2へのアップロードがデフォルトでリモート(本番環境)に行われていたが、v4では明示的に--remoteを指定しないとローカルのシミュレーション環境にアップロードされる:
# wrangler v4でリモートのR2に上げる場合
npx wrangler r2 object put my-bucket/path/to/file.png \
--file ./file.png \
--remote
--remoteを忘れると本番のR2にデータが入らなくて困ることになる。
Cookie認証とoptionalAuth
optionalAuthミドルウェアは未認証のリクエストも通過させるが、このときuserIdが空文字列になる。プライベートドキュメントへのアクセスで403が返ってしまうケースがあり、本来はログインページへリダイレクトすべきだった。
// optionalAuth後、未認証ユーザーの扱いを明示的にチェック
if (!userId) {
return c.redirect('/login');
}
snake_case vs camelCase
FlaskのAPIはsnake_caseを返していた(is_public、allow_edit、created_atなど)。TypeScript内部ではcamelCaseで扱いたいが、既存のiOSクライアントはsnake_caseを期待している。
対応策として、APIレスポンスでは両方のキーを返すようにした:
return c.json({
// camelCase(新クライアント向け)
isPublic: doc.is_public,
allowEdit: doc.allow_edit,
createdAt: doc.created_at,
// snake_case(既存iOSクライアント向け)
is_public: doc.is_public,
allow_edit: doc.allow_edit,
created_at: doc.created_at,
});
5. DNS切り替え時の注意
CloudflareのDNSで*(ワイルドカード)のAレコードを設定していると、Workerのルート設定より優先されることがある。Workerが呼ばれずに旧サーバーにトラフィックが流れ続けるケースがあった。
対策:ワイルドカードレコードを削除し、必要なサブドメインは個別のレコードとして設定する。
また、ブラウザに旧サーバー用のCookieが残っていて認証が通らないことがある。移行後に動作確認するときはCookieを削除してから試すのが無難。
6. 無料枠の実績値
個人利用(1ユーザー)での実際の使用量と無料上限の比較:
| リソース | 使用量 | 無料上限 | 使用率 |
|---|---|---|---|
| Workers リクエスト/日 | ~1,300 | 100,000 | 1.3% |
| D1 ストレージ | ~35MB | 5GB | 0.7% |
| R2 ストレージ | ~2GB | 10GB | ~20% |
| Queues | ほぼ0 | 100万msg/月 | ~0% |
個人利用であれば完全に無料枠内で運用できる。さくらのVPS 2GBは月額2,000円弱かかっていたので、それがそのままゼロになった計算だ。
7. 未実装・将来の課題
今回の移植で対応できなかった機能もある:
- リアルタイム編集: Socket.IOで実装していた機能はDurable Objectsで代替できるが、有料プラン($5/月〜)が必要になる
- サムネイル生成: Workers環境ではネイティブの画像処理が使えない。Cloudflare Imagesとの統合が候補
- PDF解析: こちらもWorkers環境ではネイティブ不可。クライアント側でテキスト抽出する方法で対応中
まとめ
さくらのVPS 2GB + Coolify構成からCloudflare Workersへの移行で、月額コストをゼロにすることができた。
移行のポイントをざっくりまとめると:
- データ移行はAPIを通さずSQLで直接やる(タイムスタンプ保持のため)
- D1のSQL文サイズ上限に注意(大きいcontentはチャンク更新)
- wrangler v4は
--remoteフラグが必要になった - FTS5の特殊文字エスケープを忘れない
- DNS切り替え時はワイルドカードレコードを削除する
Cloudflareのエコシステムはほぼ全部無料枠で賄えるので、個人プロジェクトの移行先としてかなり優秀だと感じた。Honoも型安全で書きやすく、Flaskから移行しても大きな違和感はなかった。