空雲 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/client@5.10.0-devまで達したところで、圧縮時に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

1import { PrismaPg } from '@prisma/adapter-pg';
2import WASM from '@prisma/client/runtime/query-engine.wasm';
3import { PrismaAccelerate, PrismaAccelerateConfig, ResultError } from 'prisma-accelerate-local/lib';
4import { getPrismaClient } from '@prisma/client/runtime/wasm.js';
5import pg from 'pg';
6
7export interface Env {
8 SECRET: string;
9 KV: KVNamespace;
10}
11
12const getAdapter = (datasourceUrl: string) => {
13 const url = new URL(datasourceUrl);
14 const schema = url.searchParams.get('schema');
15 const pool = new pg.Pool({
16 connectionString: url.toString(),
17 });
18 return new PrismaPg(pool, {
19 schema: schema ?? undefined,
20 });
21};
22
23let prismaAccelerate: PrismaAccelerate;
24const getPrismaAccelerate = async ({
25 secret,
26 onRequestSchema,
27 onChangeSchema,
28}: {
29 secret: string;
30 onRequestSchema: PrismaAccelerateConfig['onRequestSchema'];
31 onChangeSchema: PrismaAccelerateConfig['onChangeSchema'];
32}) => {
33 if (prismaAccelerate) {
34 return prismaAccelerate;
35 }
36 prismaAccelerate = new PrismaAccelerate({
37 secret,
38 adapter: (datasourceUrl) => getAdapter(datasourceUrl),
39 getQueryEngineWasmModule: async () => {
40 return WASM;
41 },
42 getPrismaClient: getPrismaClient as never,
43 onRequestSchema,
44 onChangeSchema,
45 });
46 return prismaAccelerate;
47};
48
49export default {
50 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
51 const prismaAccelerate = await getPrismaAccelerate({
52 secret: env.SECRET ?? 'test',
53 onRequestSchema: ({ engineVersion, hash, datasourceUrl }) => {
54 return env.KV.get(`${engineVersion}:${hash}:${datasourceUrl}`);
55 },
56 onChangeSchema: ({ inlineSchema, engineVersion, hash, datasourceUrl }) => {
57 return env.KV.put(`${engineVersion}:${hash}:${datasourceUrl}`, inlineSchema, { expirationTtl: 60 * 60 * 24 * 7 });
58 },
59 });
60
61 const url = new URL(request.url);
62 const paths = url.pathname.split('/');
63 const [_, version, hash, command] = paths;
64 const headers = Object.fromEntries(request.headers.entries());
65 const createResponse = (result: Promise<unknown>) =>
66 result
67 .then((r) => {
68 return new Response(JSON.stringify(r), {
69 headers: { 'content-type': 'application/json' },
70 });
71 })
72 .catch((e) => {
73 if (e instanceof ResultError) {
74 return new Response(JSON.stringify(e.value), {
75 status: e.code,
76 headers: { 'content-type': 'application/json' },
77 });
78 }
79 return new Response(JSON.stringify(e), {
80 status: 500,
81 headers: { 'content-type': 'application/json' },
82 });
83 });
84
85 if (request.method === 'POST') {
86 const body = await request.text();
87 switch (command) {
88 case 'graphql':
89 return createResponse(prismaAccelerate.query({ body, hash, headers }));
90 case 'transaction':
91 return createResponse(
92 prismaAccelerate.startTransaction({
93 body,
94 hash,
95 headers,
96 version,
97 })
98 );
99 case 'itx': {
100 const id = paths[4];
101 switch (paths[5]) {
102 case 'commit':
103 return createResponse(
104 prismaAccelerate.commitTransaction({
105 id,
106 hash,
107 headers,
108 })
109 );
110 case 'rollback':
111 return createResponse(
112 prismaAccelerate.rollbackTransaction({
113 id,
114 hash,
115 headers,
116 })
117 );
118 }
119 }
120 }
121 } else if (request.method === 'PUT') {
122 const body = await request.text();
123 switch (command) {
124 case 'schema':
125 return createResponse(
126 prismaAccelerate.updateSchema({
127 body,
128 hash,
129 headers,
130 })
131 );
132 }
133 }
134 return new Response('Not Found', { status: 404 });
135 },
136};

wrangler.toml

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

1minify = true
2node_compat = true
3
4[[kv_namespaces]]
5binding = "KV"
6id = "xxxxxx"
7
8[vars]
9SECRET = "**********"

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

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

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

1npx prisma-accelerate-local -s abc -m postgres://postgres:xxxx@db.example.com:5432/postgres?schema=public

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

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

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

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

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

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

1import { PrismaClient } from '@prisma/client/edge';
2
3export interface Env {
4 DATABASE_URL: string;
5 prisma: Fetcher;
6}
7
8const initFetch = (env: Env) => {
9 const that = globalThis as typeof globalThis & { originFetch?: typeof fetch };
10 if (that.originFetch) return;
11 const originFetch = globalThis.fetch;
12 that.originFetch = originFetch;
13 globalThis.fetch = async (input: RequestInfo, init?: RequestInit) => {
14 const url = new URL(input.toString());
15 const databaseURL = new URL(env.DATABASE_URL);
16 if (['127.0.0.1', 'localhost'].includes(url.hostname)) {
17 url.protocol = 'http:';
18 return originFetch(url.toString(), init);
19 }
20 if (url.hostname === databaseURL.hostname) {
21 return env.prisma.fetch(input, init);
22 }
23 return originFetch(input, init);
24 };
25};
26
27export default {
28 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
29 initFetch(env);
30 const url = new URL(request.url);
31 if (url.pathname !== '/') return new Response('Not found', { status: 404 });
32
33 const prisma = new PrismaClient({ datasourceUrl: env.DATABASE_URL });
34 await prisma.post.create({ data: {} });
35 const result = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } });
36 return new Response(JSON.stringify(result, undefined, ' '), {
37 headers: { 'content-type': 'application/json' },
38 });
39 },
40};
41

まとめ

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