ブログを 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 のレコードを作り直さずに、次の順で行いました。

  1. Vercel の Git 連携を先に切断する

  2. Cloudflare の Worker に Route(blog.uidev.me/*)を追加する

  3. 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 がやってくれていた画像の最適化を補う方法を考えることです。また進展があったらブログに残したいと思います。