Open6

service-binding で prisma 用の worker を分割する

mizchimizchi

https://zenn.dev/chimame/articles/d3e7af9a612038 で prisma-d1 を動かすことはできるが、サイズ面の難があるという話

PrismaがやっとEdge Functionsに対応してきましたが、残念ながらまだまだ使うには超えなければならないハードルがあります。それは容量です。PrismaはEngine部分をwasm化してEdge Functionsに対応してきましたが、Prismaの容量が1MBを超えます。

経験上、remix や next は React のコンポーネントなどのUIのライブラリがビルドサイズをかさ増しするので、そもそもWebフレームワークとは別のサービスに分割して、完全にAPIエンドポイントとして分割すればいいのでは? と考えた。bundled プランでしか動かないのは変わらないが、bundled のさらに上限を回避するのには有効だろう。

で、cloudflare workers には service binding といって複数の worker を通信させる方法がある。

https://developers.cloudflare.com/workers/configuration/bindings/about-service-bindings/

これを使って prisma 用のAPI エンドポイントだけを切り離すのを試みる。

手元の最終的な構成はこんな感じ

$ tree -I node_modules
.
├── README.md
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.json
├── turbo.json
└── workers
    ├── db-worker
    │   ├── main.ts
    │   ├── migrate.mjs
    │   ├── migrations
    │   │   └── 20240321105522_add_user_model.sql
    │   ├── package.json
    │   ├── prisma
    │   │   ├── dev.db
    │   │   ├── dev.db-journal
    │   │   ├── migrations
    │   │   │   ├── 20240321105522_add_user_model
    │   │   │   │   └── migration.sql
    │   │   │   └── migration_lock.toml
    │   │   └── schema.prisma
    │   ├── tsconfig.json
    │   └── wrangler.toml
    ├── main-worker
    │   ├── main.ts
    │   ├── package.json
    │   ├── tsconfig.json
    │   ├── worker-configuration.d.ts
    │   └── wrangler.toml
    └── sub-worker
        ├── main.ts
        ├── package.json
        ├── worker-configuration.d.ts
        └── wrangler.toml
mizchimizchi

モノレポの設計

複数 worker 環境を作る準備として、 workers/ の下をモノレポとして管理するようにする。
細かい設定は省略するが、だいたい pnpm turbo dev で dev を起動すると思っていい。

package.json
{
  "private": true,
  "packageManager": "pnpm@8.15.4",
  "scripts": {
    "dev": "run-p dev",
    "test": "exit 0"
  },
  "license": "MIT",
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20240320.1",
    "npm-run-all": "^4.1.5",
    "turbo": "^1.12.5",
    "typescript": "^5.4.3",
    "wrangler": "^3.36.0"
  }
}
pnpm-workspace.toml
packages:
  - 'workers/*'
turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "types": {
      "cache": false,
      "persistent": true
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
tsconfig.json
{
  "compilerOptions": {
    "target": "es2021",
    "module": "ESNext",
    "allowJs": true,
    "moduleResolution": "Bundler",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["@cloudflare/workers-types"]
  }
}
mizchimizchi

まずローカル環境で単純な service binding を動かす

sub-worker

まず、binding 起動される最小のワーカーを作ってみる。

場所は workers/sub-worker/*

export default {
  fetch(_request: Request, env: Env) {
    return Response.json({ message: "foo" })
  }
}
package.json
{
  "name": "sub-worker",
  "private": true,
  "scripts": {
    "dev": "wrangler dev --port 8001 --inspector-port 8101",
    "types": "wrangler types",
    "release": "wrangler publish"
  },
  "license": "MIT"
}
wrangler.toml
name = "mzmw-sub"
main = "main.ts"
compatibility_date = "2024-03-21"

大事なのは、wrangler dev --port 8001 --inspector-port 8101 の部分で、複数同時に worker を起動しようとすると、デバッグ用の inspector-portがぶつかって起動が止まってしまう。

mzmw- はテスト用のプレフィックスで(mizchi-multi-worker) 適当に変更してもよいが、binding されるための名前を与えておくのが大事。

これ自体は pnpm dev で起動できる。

main-worker

sub を使う側。 場所は workers/main-worker/*

package.json
{
  "name": "main-worker",
  "private": true,
  "scripts": {
    "dev": "wrangler dev --port 8000 --inspector-port 8100",
    "types": "wrangler types",
    "release": "wrangler publish"
  },
  "license": "MIT"
}
wrangler.toml
name = "mzmw-main"
main = "main.ts"
compatibility_date = "2024-03-21"

[[services]]
binding = "SUB_WORKER"
service = "mzmw-sub"

これを定義した後、 pnpm wrangler types で型定義ファイルを生成できる。

// Generated by Wrangler on Thu Mar 21 2024 20:10:56 GMT+0900 (Japan Standard Time)
// by running `wrangler types`

interface Env {
	SUB_WORKER: Fetcher;
}

main.ts

main.ts
export default {
  async fetch(request: Request, env: Env) {
    const sub = await env.SUB_WORKER?.fetch(request)
      .then(res => res.json()).catch(err => "No sub-worker");
    return Response.json({ message: "main!", sub })
  }
}

env.SUB_WORKER に service が binding されているので、それに対して fetch できる。今回はそのままリクエストを横流ししているが、加工してもしなくてもいい。

起動

これら2つのプロセスを起動すると、ローカル環境でも name を与えていることで疎通する。

今回は turborepo 環境なので、 turbo devhttp://localhost:8000 と 8001 が立ち上がって、8000 の方にアクセスするとこうなるはず。

{ "message": "hello", "sub" : { "message": "foo" }}
mizchimizchi

準備が整ったので、prisma db 用の worker を作る。

prisma-d1 自体の使い方は https://zenn.dev/chimame/articles/d3e7af9a612038 を参考にしている。

db-worker

workers/db-worker の下で実装する。開発用の port は 8002 としておく。

package.json
{
  "name": "db-worker",
  "private": true,
  "scripts": {
    "dev": "wrangler dev --port 8002 --inspector-port 8102 --local",
    "types": "wrangler types",
    "release": "wrangler publish"
  },
  "license": "MIT",
  "devDependencies": {
    "@prisma/adapter-d1": "^5.11.0",
    "@prisma/client": "^5.11.0",
    "prisma": "^5.11.0"
  }
}

このモジュールで pnpm wrangler d1 create prisma-test を叩いてテスト用の DB を作成。

wrangler.toml
name = "mzmw-db"
main = "main.ts"
compatibility_date = "2024-03-21"

[[d1_databases]]
binding = "DB"
database_name = "prisma-test"
database_id = "your-db-id"
# prisma init --datasource-provider sqlite
prisma/schema.prisma
generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    Int  @id @default(autoincrement())
  email String
  name  String?
}

マイグレーションコマンドを発行

$ prisma migrate dev --name add_user_model

local migrate

ローカル用のDB (.wrangler/ の下にある)にマイグレーションする。

migrate.mjs
#!/usr/bin/env zx

await $`mkdir -p ./migrations`

const packages = await glob(['prisma/migrations/*/migration.sql'])
for (let i = 0; i < packages.length; i++) {
  const migrationName = packages[i]
    .replace('prisma/migrations/', '')
    .split('/')[0]
  if (!fs.existsSync(`migrations/${migrationName}.sql`)) {
    await $`cp ${packages[i]} migrations/${migrationName}.sql`
  }
}

await $`pnpm wrangler d1 migrations apply prisma-test --local`

$ npx zx migrate.mjs を叩く。

db-worker の実装

準備が整ったので、prisma 用の db-workerを実装する。

src/main.ts
import { PrismaClient } from '@prisma/client'
import { PrismaD1 } from '@prisma/adapter-d1'

type Env = {
  DB: D1Database;
}

const connection = async (db: D1Database) => {
  const adapter = new PrismaD1(db)
  return new PrismaClient({ adapter })
}

export default {
  async fetch(request: Request, env: Env) {
    const client = await connection(env.DB);
    const users = await client.user.findMany();
    return Response.json({
      users: users,
    });
  }
}

この worker に対して $ pnpm wrangler devhttp://localhost:8002 を叩くと、{"users": []} という形でレスポンスが見える。(今回は疎通確認なので実際にデータを突っ込むのは略)

main-worker から db-worker を binding

やることは sub-worker を追加したときと一緒。 main-worker の wrangler.toml に binding を追加する。

name = "mzmw-main"
main = "main.ts"
compatibility_date = "2024-03-21"

[[services]]
binding = "SUB_WORKER"
service = "mzmw-sub"

[[services]]
binding = "DB_WORKER"
service = "mzmw-db"

main.ts から叩く。プロセスが存在しなかったら No *-worker のメッセージを返す。

workers/main-worker/main.ts
export default {
  async fetch(_request: Request, env: Env) {
    // console.log(env);
    const sub = await env.SUB_WORKER?.fetch(_request)
      .then(res => res.json()).catch(err => "No sub-worker");
    const db = await env.DB_WORKER?.fetch(_request)
      .then(res => res.json()).catch(err => "No db-worker");
    return Response.json({ message: "Hello, main!", sub, db })
  }
}

これで pnpm turbo dev で全部の worker を叩き起こすと疎通する。

mizchimizchi

本番環境へのデプロイ手順は略。基本的には各サービスは普通に wrangler publish しつつ、d1 migrate するときは --local を外すだけ。

まとめ

ビルドサイズ問題を回避するために service binding で worker を分割する実装が可能。

ただ、これは API レイヤーに分割することを強制しているので、2つのサービス間で型レベルの疎通プロトコルを別途作らないと、あまり使い心地が良くならない。 trpc で REST エンドポイントの型を付けたり、型だけ引っこ抜いた偽の Prisma クライアントを生成する、みたいな案がパッと浮かぶ。

もしかしたらサービス間の疎通に edge proxy が使えるのかもしれないが、詳しくないので誰かお願い。

mizchimizchi

型を共有したうえで入力を横流しするだけの雑なプロキシを一応書いてみた。

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

type ClientType = InstanceType<typeof PrismaClient>;

const createProxy = (fetcher: typeof fetch) => new Proxy({}, {
  get(_target: any, prop: string) {
    if (prop.startsWith('$')) {
      throw new Error(`${prop} is not supported`)
    }
    return new Proxy({}, {
      get(_target: any, method: string) {
        if (prop.startsWith('$')) {
          throw new Error(`${prop} is not supported`)
        }
        return async (...args: any[]) => {
          return fetcher('http://localhost:8787', {
            method: 'POST',
            body: JSON.stringify([
              prop,
              method,
              ...args
            ])
          }).then(res => res.json());        
        }
      }
    });
  }
});

// 受け取り側の worker 実装例
async function handler(request: Request) {
  const [table, method, ...args] = await request.json() as any;
  const result = await (client as any)[table][method](...args);
  return Response.json(result);
}

// Usage
const client = createProxy(fetch) as ClientType;
const users = await client.user.findFirst({
  where: {}
});

console.log(users);

当たり前だが、これでは client.$transaction() に対応できない。また全てのインターフェースがJSONシリアライズを前提としていいかもわからない。ちゃんと考える必要がありそう。