💪

Next.jsとPrismaをCloudflareにデプロイして月300万のDBクエリに無料で耐える

2024/06/01に公開

はじめに

Next.js を Cloudflare にホスティングしようとすると、必然的に Edge Runtime 環境になります。しかし、Edge Runtime 環境では、Node.js Runtime と異なり、Prisma がそのまま使えません。

最初に思い浮かぶ解決策は Prisma Accelerate です。Prisma Accelerate は公式のサービスで、接続プールイングやグローバルキャッシュ機能を備えており、Edge Runtime でも Prisma を使えるようにします。
しかし、無料プランだと月に 6 万クエリの制限があり、本番運用には不安が残ります。

そこで、今回は Prisma Accelerate を自前で Cloudflare Workers 上に構築し、本番運用に耐えうるサービスを無料で開発する方法を紹介します。この方法なら、無料プランでも 月に 300 万クエリに耐えることができます

なお、実装については後述のパッケージの開発者のこちらの記事並びにサンプルコードを参考にしています。

prisma-accelerate-local

自前で Prisma Accelerate を構築するには、 prisma-accelerate-local というパッケージを使用します。
本来はローカル開発環境用ですが、これを Cloudflare Workers 上で動かすことで、Prisma Accelerate のセルフホスティングが可能です。

Cloudflare Workers を準備する

まず、Cloudflare Workers の開発環境を準備します。以下のリポジトリを clone して、README.md に従って進めるだけで簡単に構築ができます。

https://github.com/yu-3in/prisma-accelerate-pg-workers

また、prisma-accelerate-local の開発者様のサンプルリポジトリも参考にしてください。

https://github.com/SoraKumo001/prisma-accelerate-workers

以下の内容は、上記のリポジトリを 0 から構築する手順です。

Cloudflare Workers プロジェクトの作成

まず初めにプロジェクトを作成します。Cloudflare Wrangler を使って、CLI から作成します。

npx wrangler init prisma-accelerate-pg-workers

全ての質問に対してyまたはyes で大丈夫です。
途中でプロジェクトのタイプの選択肢が出てきたら、 "Hello World" Worker を選択します(スペースキーで選択できます)。
最後の質問に yesと回答すると、Cloudflare Workers に自動でデプロイされます。

What type of application do you want to create?
  ● "Hello World" Worker
  ○ "Hello World" Worker (Python)
  ○ "Hello World" Durable Object
  ○ Website or web app
  ○ Example router & proxy Worker
  ○ Scheduled Worker (Cron Trigger)
  ○ Queue consumer & producer Worker
  ○ API starter (OpenAPI compliant)
  ○ Worker built from a template hosted in a git repository

パッケージのインストール

prisma-accelerate-local パッケージとその他 prisma 関連パッケージをインストールします。
なお、今回は PostgreSQL を使用する想定で進めます。

npm install @prisma/client prisma-accelerate-local @prisma/adapter-pg @prisma/adapter-pg-worker @prisma/pg-worker

.dev.vars ファイルに環境変数を設定

ルートディレクトリに .dev.vars ファイルを作成し、以下のように環境変数を設定します。
xxxには任意のランダムな文字列を設定して下さい。この値は後に API_KEY の生成の種として使います。

.dev.vars
PRISMA_ACCELERATE_SECRET=xxx

デプロイ先にも反映するために以下のコマンドを実行します。
入力が求められるので、上と同じ値を入力してください。

npx wrangler secret put PRISMA_ACCELERATE_SECRET

Cloudflare KV を作成

Cloudflare のダッシュボードに移動します。
左メニューから Workers & Pages を選択し、配下の KV を選択します。

このような画面に遷移するので、右側の Create Namespace をクリックします。
Cloudflare KVのダッシュボード画面

Namespace Name には識別しやすい名前を設定します。ここでは、prisma-accelerate-pg-workers とします。入力したら Add をクリックします。
作成された KV の ID を控えておきます。

wrangler.toml の設定

続いて、wrangler.toml ファイルを編集します。
wrangler.toml では Cloudflare Workers のデプロイメントに関する設定をします。
コメントアウトされた設定から、必要な行のコメントを外して編集します。

kv_namespaces の id には先ほど作成した KV の ID を設定します。

wrangler.toml
#:schema node_modules/wrangler/config-schema.json
name = "prisma-accelerate-pg-workers"
main = "src/index.ts"
compatibility_date = "2024-05-29"
compatibility_flags = ["nodejs_compat"]
+ minify = true

# Automatically place your workloads in an optimal location to minimize latency.
# If you are running back-end logic in a Worker, running it closer to your back-end infrastructure
# rather than the end user may result in better performance.
# Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
+ [placement]
+ mode = "smart"

...

# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
# Docs: https://developers.cloudflare.com/workers/wrangler/configuration/#kv-namespaces
+ [[kv_namespaces]]
+ binding = "KV"
+ id = "xxxxxx"

...

型定義

まず、Wrangler を使って、環境変数を読み込むための型を自動生成します。

npm run cf-typegen

worker-configuration.d.ts に型が生成されます。

worker-configuration.d.ts
interface Env {
  KV: KVNamespace;
  PRISMA_ACCELERATE_SECRET: string;
}

つづいて types ディレクトリを作成し、prisma と wasm に関する型を定義します。

types/prisma-edge.d.ts
declare module '@prisma/client/runtime/wasm.js' {
  export * from '@prisma/client/runtime/library';
}
types/wasm.d.ts
declare module '*.wasm' {
  const content: any;
  export default content;
}

polyfill の追加

polyfills/util.ts
export * from 'node:util';

実装

最後に、src/index.ts に Prisma Accelerate 相当の処理を実装します。

src/index.ts
import { Pool } from '@prisma/pg-worker';
import { PrismaPg } from '@prisma/adapter-pg-worker';
import WASM from '@prisma/client/runtime/query_engine_bg.postgresql.wasm';
import { PrismaAccelerate, PrismaAccelerateConfig, ResultError } from 'prisma-accelerate-local/lib';
import { getPrismaClient } from '@prisma/client/runtime/wasm.js';

const getAdapter = (datasourceUrl: string) => {
  const url = new URL(datasourceUrl);
  const schema = url.searchParams.get('schema') ?? undefined;
  const pool = new Pool({
    connectionString: url.toString() ?? undefined,
  });
  return new PrismaPg(pool, { schema });
};

let prismaAccelerate: PrismaAccelerate;

const getPrismaAccelerate = async ({
  secret,
  onRequestSchema,
  onChangeSchema,
}: {
  secret: string;
  onRequestSchema: PrismaAccelerateConfig['onRequestSchema'];
  onChangeSchema: PrismaAccelerateConfig['onChangeSchema'];
}) => {
  if (prismaAccelerate) {
    return prismaAccelerate;
  }
  prismaAccelerate = new PrismaAccelerate({
    singleInstance: true,
    secret,
    adapter: getAdapter,
    getRuntime: () => require('@prisma/client/runtime/query_engine_bg.postgresql.js'),
    getQueryEngineWasmModule: async () => WASM,
    getPrismaClient,
    onRequestSchema,
    onChangeSchema,
  });
  return prismaAccelerate;
};

const createResponse = async (result: Promise<unknown>) => {
  try {
    const response = await result;
    return new Response(JSON.stringify(response), {
      headers: { 'content-type': 'application/json' },
    });
  } catch (e) {
    if (e instanceof ResultError) {
      console.error(e.value);
      return new Response(JSON.stringify(e.value), {
        status: e.code,
        headers: { 'content-type': 'application/json' },
      });
    }
    return new Response(JSON.stringify(e), {
      status: 500,
      headers: { 'content-type': 'application/json' },
    });
  }
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const prismaAccelerate = await getPrismaAccelerate({
      secret: env.PRISMA_ACCELERATE_SECRET,
      onRequestSchema: ({ engineVersion, hash, datasourceUrl }) => env.KV.get(`schema-${engineVersion}:${hash}:${datasourceUrl}`),
      onChangeSchema: ({ inlineSchema, engineVersion, hash, datasourceUrl }) =>
        env.KV.put(`schema-${engineVersion}:${hash}:${datasourceUrl}`, inlineSchema, { expirationTtl: 60 * 60 * 24 * 7 }),
    });

    const url = new URL(request.url);
    const paths = url.pathname.split('/');
    const command = paths[3];
    const headers = Object.fromEntries(request.headers.entries());

    if (request.method === 'POST') {
      const body = await request.text();
      if (command === 'graphql') {
        return createResponse(prismaAccelerate.query({ body, hash: paths[2], headers }));
      }
      if (command === 'transaction') {
        return createResponse(prismaAccelerate.startTransaction({ body, hash: paths[2], headers, version: paths[1] }));
      }
      if (command === 'itx') {
        const id = paths[4];
        const subCommand = paths[5];
        if (subCommand === 'commit') {
          return createResponse(prismaAccelerate.commitTransaction({ id, hash: paths[2], headers }));
        }
        if (subCommand === 'rollback') {
          return createResponse(prismaAccelerate.rollbackTransaction({ id, hash: paths[2], headers }));
        }
      }
    } else if (request.method === 'PUT' && command === 'schema') {
      const body = await request.text();
      return createResponse(prismaAccelerate.updateSchema({ body, hash: paths[2], headers }));
    }

    return new Response('Not Found', { status: 404 });
  },
};

デプロイ

ここまで実装できたら、Cloudflare Workers にデプロイします。

npm run deploy

Next.js 側の修正

Next.js プロジェクトでは次の 2 点について設定する必要があります。

  1. Prisma クライアントを edge 用に変更
  2. DATABASE_URL を Prisma Accelerate 用に修正

それぞれ順に解説していきます。

1. Prisma クライアントを edge 用に変更

まず、Prisma クライアントを edge 用に変更します。
具体的には、 PrismaClient の import 先を @prisma/clientから @prisma/client/edgeに変更します。
また、PrismaClient を作成する際に withAccelerate を呼び出すようにします。

import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";

const prismaClientSingleton = () => {
  return new PrismaClient().$extends(withAccelerate());
};

declare const globalThis: {
  prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;

const prisma = globalThis.prismaGlobal ?? prismaClientSingleton();

export default prisma;

if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma;

2. DATABASE_URL を Prisma Accelerate 用に修正

次に、環境変数の DATABASE_URL を Cloudflare Workers のものに設定します。

API_KEYを生成

まず初めに次のコマンドを実行します。

--secret には、 .dev.vars で指定した PRISMA_ACCELERATE_SECRET と同じ値を指定します。
--make には、supabase などの DATABASE_URL を指定します。

npx prisma-accelerate-local --secret enter_your_secret --make postgres://xxx

実行すると、eyから始まる API_KEY が出力されるのでコピーしておきます。

環境変数の設定

.env.local などに以下のように設定します。

DATABASE_URL=prisma://xxxx.workers.dev?api_key=your_api_key

注意点として、URL のスキーマが prisma であること、ホストが xxxx.workers.dev であること、クエリパラメータに api_key が含まれていることを確認してください。

おわりに

Cloudflare Workers 上に自前で Prisma Accelerate を構築し、Next.js と Prisma を無料で本番運用する方法を紹介しました。
Edge Runtime 環境での制約を克服し、高いパフォーマンスを維持しながらもコストを抑えることが可能です。特に、月間 300 万アクセスにも耐えうる堅牢なサービスを構築できる点が大きな魅力です。
ぜひ、この方法を試してみて、皆さんのプロジェクトに役立ててください。

エラーハンドリング

PrismaClientValidationError: Invalid client engine type, please use library or binary

prisma generate の引数に --no-engine をつけて再度実行して下さい。

https://www.prisma.io/docs/orm/reference/prisma-cli-reference#options-1

Unauthorized, check your connection string: {"type":"UnknownJsonError","body":{"Unauthorized":{"reason":"InvalidKey"}}}

API_KEYを正しく設定できていません。 API_KEY を生成 の手順を確認してください。

参考

https://www.prisma.io/docs/orm/prisma-client/deployment/edge/deploy-to-cloudflare

https://next-blog.croud.jp/contents/5f33241c-d6b2-4e8b-8b0a-f8d369729ce0

GitHubで編集を提案

Discussion