🏃‍♂️

CloudflareでNext.js(OpenNext)を運用してVercelに移行した話

に公開

この記事は モニクル Advent Calendar 2025 の16日目の記事です。

CloudflareでNext.jsを運用してみた

本記事は下記のような体験談について書きます。

  • opennextjs/opennextjs-cloudflare でのNext.jsをブログシステムで運用開始
  • エラーレートの高さとServer Response Timeのパフォーマンス改善に難航
  • 真の原因を突き止めることを一旦諦めてVercelに移行してエラーレート、パフォーマンスを改善しました

おことわり

この記事は読み物的に経験談ベースで話を進めます。Bestな構成・対応策を知っていれば解決できた問題が含まれる可能性が十分にあります。(もし同様のケースでうまく動かせていたらぜひ詳しく教えて下さい。)

Serverless Postgres, Workers as origin server, Next.js そのどれもが理想を追い求めるために一定の習熟ハードルがあるものだと痛感しました。それらの組み合わせの中で起きた問題の原因特定に時間を要してしまいました。

その帰結として筆者の環境においてはNext.js を Vercel上で利用するという安定した組み合わせにすることでエラーレートとサイトスピードのパフォーマンスが劇的に改善することになりました

今回の話の前提

  • Next.jsを OpenNext ( opennextjs/opennextjs-cloudflare ) でCloudflare Workersにのせて運用
  • DBは Neon でPostgresを利用
  • ざっくりいえばブログのようなシステム
  • 記事を更新すればその記事のCacheをOn-Demand Revalidation (Tag Cache)する
  • On-Demand Revalidationがうまく行かない場合にも対応できるように一定時間経過後もTime-based revalidation

遭遇した問題の概要

  • Hyperdrive経由のPostgres DB接続においてエラーが頻発
  • 脱Hyperdrive、Neon serverless driverに変更後もRevalidate処理でエラーが頻発
  • OpenNextのConfigを色々設定してみるもエラーが頻発
  • Cacheが意図したように動かずサーバーの応答時間に課題
    • x-opennext-cache: HIT でも300ms台の時も多々(100ms切ってほしい)
    • x-opennext-cache: STALE も遅い(1.5sぐらいかかることもある)
      • STALEって古い情報でも良いから速く返すんじゃないの...

遭遇した様々なエラーとその対応について

Hyperdrive経由のPostgres DB接続においてエラーが頻発

HyperDriveを利用したRequestの1%ぐらいでHyperdriveのErrorが発生

Requestの1%ぐらいでErrorが起きていました。
ただ、原因調査しようにもHyperdriveのMetricsでError件数が表示されるのみで、なんらか他に詳細なログが得られる事はありませんでした。
(WorkersでObservability logsも有効にしていましたがHyperdriveのエラー原因が掴めるようなログは見つかりませんでした。)

HyperdriveによるDB接続があるCacheなしの管理画面で無視できない頻度のエラー発生

起きていたエラーのログは下記のようなものです。

PostgresError: canceling statement due to statement timeout
Error: write CONNECTION_CLOSED

単なるGETリクエストなどで起きるエラーでリトライなどでなんとかなる画面もありましたが、それでもCacheを利用しない管理画面においてエラーが無視できない頻度で発生しており、解決の糸口も見えなかったためHyperdriveを辞めることにしました。

この時一般ユーザー向けのCache前提の画面ではRevalidation処理の時にエラーが頻発していましたが。一般利用者に見える形でエラーが顕在化していたわけではありませんでした。

脱Hyperdrive、Neon serverless driverに変更

WorkersからのDB接続において、Hyperdriveを経由せずにNeon serverless driver、つまりはhttp経由でのDB接続に変更することにしました。

Revalidate処理でエラーが引き続き頻発

概ね管理画面のCacheを利用しないケースにおいてはエラーが発生しなくなりました。

ただ、引き続きWorkers Logsには大量のエラーが記録されていました。といってもやはり一般ユーザー向けの画面ではエラーが発生しておらず、 なぜかRevalidationでDB接続関連のエラーが多発することがわかりました。

WorkersからのNeon DB直接接続のエラー原因調査

起きていたエラーは下記のようなメッセージのエラーです。

Error: NeonDbError: Server error (HTTP status 403): error code: 1003

一見Neonに問題がありそうに見えたのでNeonのサポートにログとともに問い合わせるととても丁寧にログを調査していただき、どうやらNeon側では接続がうまくいっていそうで、Cloudflare側の問題ではないかという見解になりました。
(403 errorも当該timestampにおけるクエリ接続においても一切エラーログがない、と返答いただきました。)

実際に1003のError CodeはCloudflareの下記リンクで説明されています。
https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1003/

WorkersからのNeon DB接続に関するエラーをなくすためにやってみたこと

色々やってみましたがどれも改善しませんでした。

  • 該当箇所にtry catchを仕込んでログの詳細化(上記のログがでるように)
    • これをするまでなんのメッセージもなくErrorとだけ出ていいました
    • import { neonConfig } from "@neondatabase/serverless"neonConfig.fetchFunction optionでtry catchしてログを仕込みました。
      • →これによりエラーログを得ることができました。
  • api callをCloudflare RPC から HTTP Service Binding に変更
    • 1003 Errorの内容的にもDB接続を行うapiがBinding経由で呼ばれていることが怪しいと思い、http経由にしてみることにしました
    • →状況は改善せず
  • api callをBindingを辞めてInternet経由に変更
    • →状況改善せず
  • Neonをhttp接続からTCP接続に変更
    • →状況は改善せず(そしてhttp経由の方が気持ちresponseが速そうだった)

何をやっても改善しませんでしたが、とはいえHyperdriveをやめた以降では一般ユーザー画面/管理画面どちらもユーザーが接する場所ではエラーはあまり起きていなさそうで、エラーがRevalidation処理中でのみ起きていそうだったので様子を見ながら改善を進めていました。

問題といえばユーザー向けのページのレスポンス速度が遅いことでした。

OpenNextのRevalidation処理でエラーが頻発

DB接続のエラーとは別に、OpenNextのRevalidation処理でも様々なエラーが起きました。単純に自分がドキュメントを読めていなかったことに起因するものが多かったとは思うのですが、一例として最後まで下記のエラーは解決できませんでした。

エラーの一例

Failed to set to cache Error: put: Reduce your concurrent request rate for the same object. (10058)

同じobjectに対しての同時request回数を減らしましょうとのことで、これには queueCache を利用して Cache APIによる重複排除をすることで回避できるオプションがあり、試しましたが、それでもこのエラーは出続け、解決には至りませんでした。

OpenNextのコードを読む日々...

OpenNextは積極的に開発が進んでいて、Next.jsのCache方式をCloudflareで再現するために様々なオプションがあり、日々コードを読みに行っていました。(opennextjs/opennextjs-cloudflareopennextjs/opennextjs-awsにコードが分かれるのでなかなか大変でした。)

ただこの時点で結構な労力が割かれていて、さすがに日々エラーの調査をしてconfigを変えてみたりログを仕込んでみたりtry and errorを繰り返すにも改善までの道のりが長く、もうNext.jsを素直に動かすならVercelに移行した方が速いのではという見解になっていきました。
(自分はCloudflareが大好きな人だったので色々意欲的に調べましたがそれでもさすがに徐々に辛くなってきていました...)

パフォーマンス改善も困難

エラーについてばかり取り上げましたが、Server Response Timeもあまり改善できていませんでした。

エラーも相まってCacheが意図したように動かずお世辞にも高速とは言えないサイトでした。

  • x-opennext-cache: HIT でも300ms台の時も多々
  • x-opennext-cache: STALE も遅い(1.5sぐらいかかることも)

OpenNext CloudflareによるResponse Timeへの影響

OpenNext Cloudflareは理論的にはCache APIからResponseが返る場合にはcacheInterceptorという仕組みでNext.jsの処理を通さずにResponseを返します。

https://github.com/opennextjs/opennextjs-aws/blob/c76fd93b8c2c622ed682364a453c7275c5842b63/packages/open-next/src/core/routing/cacheInterceptor.ts#L228

(cacheInterceptor自体はopennextjs-aws側にあり、その中身のCacheを何をどう使うかという実装はopennextjs/opennextjs-cloudflareにあります。)

ただそれでも100ms以上かかることが多く下記のような検証コードを書きました。

  • 特定のpathの場合にOpenNextに到達させずにCloudflareのCache APIでCacheからResponseする
  • その他のpathは通常通りOpenNextにhandleさせる
OpenNextのCustom Workerを用いた検証コード
import { default as handler } from "../.open-next/worker.js"

const cacheHandler = async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
  const url = new URL(request.url)
  
  if (url.pathname === "/hello") {
    return new Response("hello", { status: 200 })
  }
    
  // /posts/xxx のみキャッシュ対象
  if (url.pathname === "/posts/xxx" && request.method === "GET") {
    const cache = await caches.open("default")
    const cachedResponse = await cache.match(request)

    if (cachedResponse) {
      console.log("Cache HIT: /posts/xxx")
      return cachedResponse
    }

    console.log("Cache MISS: /posts/xxx")
    const response = await handler.fetch(request, env, ctx)

    if (response.status === 200) {
      const responseToCache = response.clone()
      const headers = new Headers(responseToCache.headers)
      headers.set("Cache-Control", "public, s-maxage=60, max-age=0")

      const cachedResponse = new Response(responseToCache.body, {
        status: responseToCache.status,
        statusText: responseToCache.statusText,
        headers,
      })

      ctx.waitUntil(
        cache
          .put(request, cachedResponse)
          .then(() => {
            console.log("waitUntil: Cache PUT completed for /posts/xxx")
          })
          .catch((err) => {
            console.error("waitUntil: Cache PUT failed for /posts/xxx", err)
          }),
      )
      console.log("Before response return: Cache PUT scheduled for /posts/xxx")
    }

    return response
  }

  return handler.fetch(request, env, ctx)
}

export default {
  fetch: cacheHandler,
} satisfies ExportedHandler<CloudflareEnv>;

この検証は下記のような結果になりました。

  • OpenNextに渡ったRequestはそうでないRequestの倍近くResponseに時間がかかった
    • ※cf cache HITで 40ms 台、 cf cache MISS かつ opennext-cache HITで 90ms 台(遅い時はこれが 170ms340ms ほどに)
      • この時同じworkersでCache APIを触らず /hello からhelloを返すエンドポイントのresponseは 20ms 台だった
      • 遅くなるのは時間帯により最寄りのedge locationから配信されないことがあるため......(学んだことセクションで後述)

(※cf cache: CloudflareのCache APIをapiに自前で構築したもののstatus)

上記の検証結果だけみるとOpenNext cloudflareが噛むことでResponse Timeが倍近くになっていました。(40ms -> 90ms, 170ms -> 340ms)

当時調べた考察

(これは当時調べた時のコードなので今は変わっている可能性かもしれません。)
例えばCache HITの時の処理中にqueueを送る処理があり、それが waitUntil ではなく await して実行されている処理がありました。とはいえ何らか非同期処理といえばこれぐらいには見えたのでこれだけで倍近くになるのかは少し疑問でした。
https://github.com/opennextjs/opennextjs-aws/blob/fc14edeb3c38f0307a7d81e8197fd0c9f16be261/packages/open-next/src/core/routing/cacheInterceptor.ts#L65-L74

もしかするとOpenNextの前にWorkersを1つ置くという方式も紹介されているのでそれを用いれば解消したのかもしれません。ただDeploy用のCIを書くのは少し大変そうに思いました。

https://github.com/opennextjs/docs/blob/d2f749c863c840f6e9a98512bce3a8b15536c575/pages/cloudflare/howtos/multi-worker.mdx?plain=1#L22-L27

https://opennext.js.org/cloudflare/howtos/multi-worker

Vercelへ移行、エラーがほぼ0、Response Timeが100ms以下に改善

パフォーマンス改善に取り組もうとしましたが、これ以上改善の手を考えるよりVercelを利用した方が手早く改善するだろうという見込みで意を決してVercelに移行しました。

結果、劇的に改善しました。

  • DB接続に関するエラーがほぼ0になった
  • Response Timeは基本的に100msを切るようになった

ただ、依然としてCache時間が短くした場合には接続が切れていそうなエラーが出ることがあるようでした。とはいえ基本的にCacheからResponseが返っておりユーザーへのResponseは正常系です。

補足として、フロントエンドはVercelに移行しましたがバックエンドのapiはCloudflare Workersのまま動かしています。

学んだことをざっくばらんに

ここまでで概ね体験談は終わりですが学んだことを箇条書きにしてみます。

  • OpenNextでCacheを利用する場合は基本的に最初から Large site using revalidation を参考にしよう https://opennext.js.org/cloudflare/caching#large-site-using-revalidation
    • 実は ShardedDOTagCache を試さずに終わったのですがもしかしたらこれで解決する事もあったのかもしれません。(とはいえドキュメントやコードを読んで起きていた問題に対しての解決策ではなさそうと見込んで実施しなかった。これが間違いだったのかもしれない。)
    • 登場人物が多すぎて(Cache API, R2, D1 or DO, Queue)大変なんですがOn-Demand RevalidationでCacheをフル活用して爆速を目指すには全部必要だと思います。
  • Cloudflareは時間帯・契約プランによって遠いエッジロケーションから配信されてResponseが遅くなることがある
    • Workersのプランではなくドメインに対してのプランを契約すると優先されることがある
    • 最速でResponseが返るCache APIのCacheはRegionalに作成されるので結構Response timeに影響を与えると考えられそう(実際遅かった)
      • 関連してGoogleのcrawlerに対してのresponse改善に苦戦した
    • 参考サイト: https://cf.sjr.dev/tools/connection
  • Next.jsのrevalidate設定はPage単位で1日、個別のfetchで1時間とした場合に短い方、つまりfetchのrevalidate1時間設定に引きづられて1時間のみのCacheになる。PageのCacheは別で1日持つというわけではない。
    • つまり routerevalidatefetchrevalidate は基本的にどちらか一方で設定するのが良さそう
    • もしくは「Route単位ではこのくらいのCache期間が良い」という表明に使う程度

https://github.com/vercel/next.js/blob/d1b02c6d93ed612790cbceca9014be0342b15cc2/docs/01-app/03-api-reference/03-file-conventions/route-segment-config.mdx#L80

  • Vercelはすごい

まだ分からなかったこと

  • Revalidation処理中にのみ発生する、CloudflareからNeonへのDB接続エラーの真の原因(Neonサポートの見解では接続は切れていないとのことでしたが)

まとめ

今回は様々な要因で問題が起きたため、問題の原因を突き止めることが難しくなってしまいました。

Next.js自体の仕組みの理解から、opennextjs/opennextjs-cloudflare の理解、HyperdriveやService Binding、WorkersでのDB接続など問題が起きた時に得られるエラーメッセージやそれについての情報があまり得られず1つ1つ切り分ける試行錯誤に多くの時間を費やしました。

Workersを安定運用する難しさを痛感しましたが、それでもなおとても便利なPlatformだと思っていて、シンプルに原因の切り分けのしやすい構成を採用しながら知見をためていきたいと考えています。

株式会社モニクル

Discussion