VPSからCloudflare Workersへのサーバー移行イメージ

Article

さくらのVPS → Cloudflare Workers 移植Tips:月額コスト0円化の実践記録

2026年3月16日
8 min read
#Cloudflare Workers#インフラ#サーバー移行#Hono

さくらの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チームが同時起動されそれぞれの領域を担当してくれた:

  1. 認証チーム - OAuth、APIトークン、middleware
  2. ドキュメントCRUDチーム - 最大のピース
  3. コレクション管理チーム
  4. 添付ファイル + R2ストレージチーム
  5. Queues + 検索 + AI機能チーム

各チームへの指示には、元ソースのファイルパスと移植ルールを明確に渡すことが重要だった。また、下記のようなトレーサビリティコメントを残すルールにしたことで、後から何を移植したのか追いやすくなった。

// from: app/routes/api.py
export async function handleDocumentCreate(c: Context) {
  // ...
}

共有ファイル(types.tsd1.tsapi.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_atupdated_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(&quot;value&quot;)">ボタン</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_publicallow_editcreated_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から移行しても大きな違和感はなかった。