空雲 Blog

Eye catchPrisma Accelerate の機能を Cloudflare Workers で実装する

publication: 2024/02/04
update:2024/02/20

PrismaのQueryEngineとCloudflareWorkers

PrismaのQueryEngineはRustで実装されています。今までは各ネイティブバイナリにコンパイルされていましたが、WebAssemblyでのサポートも追加され、ネイティブライブラリが動かないCloudflareWorkersでも動作可能になりました。しかし問題点がありました。QueryEngineのwasmファイルのサイズが圧縮時で1MBを超えてしまっていたのです。無料プランでは1MBを超えることはできないので、実質的に有料プランでしか利用できませんでした。

しかし状況は変わりました。@prisma/[email protected]まで達したところで、圧縮時に900KBまで縮みました。これによってDBにアクセスするための最低限のAdapterやコードを載せても1MB以内に収まるようになりました。

最低限の実装をした場合の容量

CloudflareWorkersでPrismaをPostgreSQLに接続するための実装を行うと、圧縮容量970KB程度になります。つまり残り圧縮容量50KB程度でアプリケーション本体を実装しなければなりません。不可能ではありませんが、実用的とは言い難いサイズです。バックエンド用のフレームワークを載せたらすぐに突破してしまいます。

Workersを分ける

CloudflareWorkersの1MB制限は、Worker1つあたりのサイズです。複数のWokerにまたがって適用されるわけではないので、DBにアクセスするための専用Workerを作れば容量制限が回避できます。ここで利用するのがprisma-accelerate-localです。

https://www.npmjs.com/package/prisma-accelerate-local

Prismaには標準でDataProxy機能があり、QueryEngineのやり取りをネットワークを通じて行うことができます。元々prisma-accelerate-localは、PrismaAccelerateへのアクセスエミュレートしてローカルDBに接続するための作ったパッケージなのですが、外に出せばDataProxy用のサーバとして使えます。これをWorkersに組み込んでしまえば良いのです。

サンプルコード

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

PrismaのDataProxyの要求を受け取って、必要な処理を返しています。ここで面倒だったのが、Workersの仕様でPOSTとPUTメソッドでインスタンスが別に生成されるという問題です。PrismaのDataProxyはPrismaのスキーマをPUTで送って、各種QueryをPOSTで処理します。Queryの処理にスキーマが必要なのでこのデータを渡す必要がありました。この部分はKVを経由して引き渡すようにしています。

DenoDeployで同じものを作ったときは、容量制限もないしインスタンスもPUTとPOSTで別扱いということも無かったので素直に実装できたのですが、Workersはクセがありました。

  • src/index.ts

import { PrismaPg } from '@prisma/adapter-pg'; import WASM from '@prisma/client/runtime/query-engine.wasm'; import { PrismaAccelerate, PrismaAccelerateConfig, ResultError } from 'prisma-accelerate-local/lib'; import { getPrismaClient } from '@prisma/client/runtime/wasm.js'; import pg from 'pg'; export interface Env { SECRET: string; KV: KVNamespace; } const getAdapter = (datasourceUrl: string) => { const url = new URL(datasourceUrl); const schema = url.searchParams.get('schema'); const pool = new pg.Pool({ connectionString: url.toString(), }); return new PrismaPg(pool, { schema: schema ?? undefined, }); }; let prismaAccelerate: PrismaAccelerate; const getPrismaAccelerate = async ({ secret, onRequestSchema, onChangeSchema, }: { secret: string; onRequestSchema: PrismaAccelerateConfig['onRequestSchema']; onChangeSchema: PrismaAccelerateConfig['onChangeSchema']; }) => { if (prismaAccelerate) { return prismaAccelerate; } prismaAccelerate = new PrismaAccelerate({ secret, adapter: (datasourceUrl) => getAdapter(datasourceUrl), getQueryEngineWasmModule: async () => { return WASM; }, getPrismaClient: getPrismaClient as never, onRequestSchema, onChangeSchema, }); return prismaAccelerate; }; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const prismaAccelerate = await getPrismaAccelerate({ secret: env.SECRET ?? 'test', onRequestSchema: ({ engineVersion, hash, datasourceUrl }) => { return env.KV.get(`${engineVersion}:${hash}:${datasourceUrl}`); }, onChangeSchema: ({ inlineSchema, engineVersion, hash, datasourceUrl }) => { return env.KV.put(`${engineVersion}:${hash}:${datasourceUrl}`, inlineSchema, { expirationTtl: 60 * 60 * 24 * 7 }); }, }); const url = new URL(request.url); const paths = url.pathname.split('/'); const [_, version, hash, command] = paths; const headers = Object.fromEntries(request.headers.entries()); const createResponse = (result: Promise<unknown>) => result .then((r) => { return new Response(JSON.stringify(r), { headers: { 'content-type': 'application/json' }, }); }) .catch((e) => { if (e instanceof ResultError) { 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' }, }); }); if (request.method === 'POST') { const body = await request.text(); switch (command) { case 'graphql': return createResponse(prismaAccelerate.query({ body, hash, headers })); case 'transaction': return createResponse( prismaAccelerate.startTransaction({ body, hash, headers, version, }) ); case 'itx': { const id = paths[4]; switch (paths[5]) { case 'commit': return createResponse( prismaAccelerate.commitTransaction({ id, hash, headers, }) ); case 'rollback': return createResponse( prismaAccelerate.rollbackTransaction({ id, hash, headers, }) ); } } } } else if (request.method === 'PUT') { const body = await request.text(); switch (command) { case 'schema': return createResponse( prismaAccelerate.updateSchema({ body, hash, headers, }) ); } } return new Response('Not Found', { status: 404 }); }, };

wrangler.toml

こちらは最低限必要な設定になります。容量削減のためのminifyとpgパッケージを動かすためにnode_compatが必要です。また、KVの設定とAPIKey用のSECRETが必要です。

minify = true node_compat = true [[kv_namespaces]] binding = "KV" id = "xxxxxx" [vars] SECRET = "**********"

クライアントからの使い方

まずはapi_keyを作ります。Keyの中にDBのアドレスを含めます。

npx prisma-accelerate-local -s SECRET -m DB_URL

npx prisma-accelerate-local -s abc -m postgres://postgres:[email protected]:5432/postgres?schema=public

PrismaのDATABASE_URLをデプロイしたアドレスと、先程生成したapi_keyを設定します。

DATABASE_URL="prisma://xxxx.workers.dev/?api_key=xxx"

あとは以下のようにedgeランタイム用のPrismaClientを呼び出せば動作します。

import { PrismaClient } from '@prisma/client/edge';

こちらはwasmを含む必要がないので、Workers上で実装する場合も、さほど容量を気にする必要はありません。

Workers上でService Bindingsを使用する場合は、fetchに手を入れる必要があります。以下ではprismaという名前で
Service Bindingsを用意し、要求が来たらそちらへ処理を振り分けています。また、開発用のローカルアドレスへの要求はhttpsをhttpに変換するようにしています。これは開発中にprisma-accelerate-localをhttpモードで動かしている場合の対策です。

import { PrismaClient } from '@prisma/client/edge'; export interface Env { DATABASE_URL: string; prisma: Fetcher; } const initFetch = (env: Env) => { const that = globalThis as typeof globalThis & { originFetch?: typeof fetch }; if (that.originFetch) return; const originFetch = globalThis.fetch; that.originFetch = originFetch; globalThis.fetch = async (input: RequestInfo, init?: RequestInit) => { const url = new URL(input.toString()); const databaseURL = new URL(env.DATABASE_URL); if (['127.0.0.1', 'localhost'].includes(url.hostname)) { url.protocol = 'http:'; return originFetch(url.toString(), init); } if (url.hostname === databaseURL.hostname) { return env.prisma.fetch(input, init); } return originFetch(input, init); }; }; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { initFetch(env); const url = new URL(request.url); if (url.pathname !== '/') return new Response('Not found', { status: 404 }); const prisma = new PrismaClient({ datasourceUrl: env.DATABASE_URL }); await prisma.post.create({ data: {} }); const result = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } }); return new Response(JSON.stringify(result, undefined, ' '), { headers: { 'content-type': 'application/json' }, }); }, };

まとめ

@prisma/[email protected]系統が正式版になったら、本格的にCloudflareWokersで無料のシステムが作れるようになりそうです。これに先立って画像最適化も無料プランで出来るようにしたので、無料乞食精神で必要なものがあれば逐次投入していく予定です。