ブログを Vercel から Cloudflare Workers の無料プランに移行した話(Next.js + a-blog cms)
目次
はじめに
このブログ(Next.js 16 + a-blog cms)を、Vercel から Cloudflare Workers の無料プランに移行しました。費用は無料のまま、ほぼ同じ機能で動いています。
この記事では、移行しようと思った経緯、最初に試した方法がうまくいかなかった理由、最終的な構成、移行で失ったものをまとめます。
移行しようと思った経緯
きっかけはドメインでした。
お名前.com から「与信失敗により、ドメインの更新手続きが完了できませんでした」という通知が来ていたのですが、気づかないまま期限が過ぎ、旧ドメイン(uidev.jp)が停止してブログが見られなくなりました。調べてみると、ドメインはまだ削除されておらず、復旧できる状態でした。
ただ、個人のブログなので、この機会にドメインを変えてもよいと考えました。そこで、更新料が毎年同じで DNS も自分で管理できる Cloudflare Registrar で、新しいドメイン(uidev.me)を取得しました。旧ドメインは復旧して、旧 URL からの流入を守るために 1 年ほど転送用に維持します。
ドメインと DNS が Cloudflare に集まったので、ブログのホスティングも Cloudflare にまとめることにしました。もう 1 つの理由は、Vercel の無料プラン(Hobby)が個人の非商用利用向けで、広告(Google AdSense)を載せたいブログには向かないことです。
最初に試した OpenNext と無料枠の上限
Next.js を Cloudflare で動かす方法として、最初に OpenNext(@opennextjs/cloudflare)を試しました。Next.js のコードをほぼ変えずに Workers で動かせます。
ところが workers.dev に検証用にデプロイして wrangler tail で CPU 時間を測ると、静的なページでも Worker を通り、1 リクエストあたり 30〜80 ミリ秒の CPU を使っていました。Workers の無料プランの上限は、1 リクエストあたり 10 ミリ秒です。実際に、一部のページが表示されなかったり、プレビューが Error 1102(リソース超過)になったりしました。
有料プランにすれば解決しますが、「まずは無料プランで」と決めていたので、構成を見直すことにしました。
最終的な構成:静的書き出しと小さな Worker
最終的に、次の構成にしました。
ページは Next.js の静的書き出し(
output: 'export')で、ビルド時に HTML にするHTML や画像は Cloudflare の静的アセットとして配信する(Worker の CPU 時間を使わない)
動的な処理が必要な
/api/*(検索とプレビュー)だけを、自前の小さな Worker で処理する
記事を更新したときの流れは、次のとおりです。
a-blog cms で記事を保存 → Deploy Hook → Workers Builds(master から静的書き出し)→ 静的アセットを更新wrangler.jsonc の要点は次のとおりです。
{
"name": "my-blog-site-frontend",
"main": "worker/index.ts",
"compatibility_flags": ["nodejs_compat"],
// ダッシュボードで設定した変数・Secret を、デプロイで消さない
"keep_vars": true,
"assets": {
"directory": "./out",
"not_found_handling": "404-page",
// /api/* だけを Worker で処理する。それ以外は静的アセットを直接返す
"run_worker_first": ["/api/*"]
}
}run_worker_first を /api/* だけにするのが大事です。それ以外のリクエストは Worker を起動せず、静的アセットが直接返ります。
検索とプレビューの作り方
検索
検索ページ自体は静的な HTML にして、キーワードはブラウザから Worker の API(/api/blog/)に送って取得します。Worker は a-blog cms の API を呼んで結果を返すだけです。
プレビュー
下書き記事のプレビューは a-blog cms の管理画面から開く想定です。静的なページには下書きを含められないので、プレビュー用のページ(/blog/preview/)を用意して、ブラウザで本文を取得して描画する形にしました。
認証には署名付きのクッキーを使っています。
a-blog cms のプレビュー用キーは、ブラウザの JavaScript にもクッキーにも出さない
Worker が記事のコードと有効期限(30 分)を HMAC-SHA256 で署名したクッキー(
__Secure-preview)を発行する以降のリクエストは署名を検証して、通ったものだけ下書きを返す
つまずき:プレビューで目次が出ない
プレビューを本番で試すと、目次が表示されませんでした。目次や外部リンクなどを作る処理は、ページを開いた直後に 1 回だけ動きます。一方でプレビューの本文はそのあとで取得して描画するためです。本文が表示されたあとで、同じ処理をもう一度呼ぶようにして対応しました。
記事を更新したら自動で再ビルドする
静的書き出しなので、記事を更新してもページは自動では変わりません。Vercel のときも、a-blog cms の Webhook から Vercel の Deploy Hook を呼んで再ビルドしていました。同じ仕組みを Cloudflare に作り直しました。
Deploy Hook を使う
当初は GitHub Actions を呼ぶ想定でした。ただ、a-blog cms のスタンダードライセンスでは、Webhook に Authorization などのヘッダーを指定できず、GitHub の API を直接は呼べません。
そこで、Cloudflare Workers Builds の Deploy Hook を使いました。Deploy Hook は URL に POST するだけでビルドが始まり、ヘッダーが要りません。URL 自体が認証になるので、公開はせず、秘密として扱います。
ビルドそのものも、GitHub ではなく Cloudflare(Workers Builds)が master から行います。
a-blog cms から Deploy Hook が呼ばれると、ビルド履歴には「a-blog cms - deploy hook」から起動したビルドが並びます。
定期ビルド
Webhook の通知漏れや、Zenn の記事一覧の更新に備えて、6 時間ごとに GitHub Actions から Deploy Hook を呼ぶ定期ビルドも入れました。GitHub に置くのは、Deploy Hook の URL(Secret)だけです。
ビルド用と実行時の変数は別
Cloudflare では、ビルド時に使う変数と、Worker が実行時に読む変数が別々の場所で管理されます。接続先のホスト名やキーを変えるときは、両方の更新が必要です。片方だけだと、ビルドは通るのに検索やプレビューが動かない、あるいはその逆になります。
ダウンタイムなしの切り替えと旧 URL の転送
切り替えの手順
旧ドメインからの切り替えは、DNS のレコードを作り直さずに、次の順で行いました。
Vercel の Git 連携を先に切断する
Cloudflare の Worker に Route(
blog.uidev.me/*)を追加するblog.uidev.meの CNAME を「DNS のみ」から「Proxied(オレンジの雲)」に変更する
Worker の Route は、ダッシュボードの「Domains」で次のように見えます。
1 を先にやるのは、静的書き出しを master にマージすると Vercel も同じコードで再ビルドしてしまうからです。Vercel には Worker が無いので、検索とプレビューの API が無くなってしまいます。
CNAME を削除せず、プロキシ経由で Worker の Route を先に動かす「Route 方式」なので、DNS の空白ができず、ダウンタイムはありませんでした。
旧 URL の転送
旧ドメインの DNS は別の場所にあり、Cloudflare に移せません。そのため、旧 URL から新 URL への転送(308)は Vercel のプロジェクトに残しています。ルートのドメイン(uidev.me)も、Cloudflare のリダイレクトルールで blog.uidev.me に転送しています。
CMS のホスト名も変更
CMS(a-blog cms)も、新ドメイン側のホスト名に移しました。接続先が変わるので、前述のとおり、ビルド用と実行時の両方の変数を更新しました。
移行でなくなった機能
移行でなくなった機能も書いておきます。
画像の最適化がなくなりました。Vercel では、
next/imageが画面サイズに合わせた縮小や WebP への変換を自動で行っていました。静的書き出しでは使えないため、元の画像がそのまま配信されます。PR ごとの確認用の環境がなくなりました。Vercel は PR のたびに確認用の URL を作ってくれましたが、今は CI のビルドとテストで確認しています。
一方で、記事を更新してから再ビルドが走る流れ、リダイレクト、OGP 画像、CDN、HTTPS は変わりません。
まとめ
ドメインの更新に失敗したことをきっかけに、ドメインとホスティングを Cloudflare にまとめました。OpenNext は無料プランの CPU 上限を超えましたが、静的書き出しと、検索・プレビューだけの小さな Worker にすると、無料プランに収まりました。
今後の課題は、以前はnext/image がやってくれていた画像の最適化を補う方法を考えることです。また進展があったらブログに残したいと思います。