🔮

PrismaDataProxyが遅い問題をなんとかする

2022/06/20に公開
2

概要

PrismaDataProxyが遅いので、セルフホストするためのライブラリを自作して解決しました、というお話です。

本記事で作成・紹介したライブラリはOSSとして公開&npmにpublishしていますので自由にお使いください。

https://github.com/aiji42/prisma-data-proxy-alt

PrismaDataProxy とは

Prisma.ioが提供する、データベース接続管理とプーリングのためのプロキシサーバです。

https://www.prisma.io/data-platform より

With the Proxy Without the Proxy

Cloudflare WorkersやVecel Edge Functionなどは、データベースとのネイティブ(TCP)接続ができません。
そこでPrismaDataProxyデータベースとの接続の間に入り、WorkerからはHTTP接続でデータベースとの仮想接続を実現します。

以降、長いのでPrismaDataProxyのことをPDPと記載します。

PrismaDataProxy の弱点

PDPは https://cloud.prisma.io から、Webコンソール上でインスタンスを作成することで構築が可能です。
しかし、2022/06/19現在、選択できるリージョンは、バージニア北部とフランクフルトの2拠点のみです。

そして、サーバレスでのサービス提供となっておりコールドスタンバイによるレイテンシの影響も受けます。

PDPの利用ケースはCloudflare Workersなどのエッジサイドからのデータソース利用が大半ですが、データリクエストのレイテンシが大きいとエッジケースのメリットは薄れてしまいます。

手元の計測では、バージニア北部のインスタンスを選択し、同じリージョンにPlanetscaleを構築して接続した際に、コールドスタンバイ時では2.6s前後、コールドスタンバイなしでも600ms前後のレイテンシを観測しています。

このパフォーマンスではサービスに投入することは現実的ではありません。
しかし、Prismaの強力な型生成機能の恩恵を受けたかったため、なんとかPDPを日本リージョンにセルフホストし、更にコールドスタンバイの影響の小さいアーキテクチャで構築できないかと考えました。

Prisma Clientのコードを読む

まず、PDPのサーバソースが公開されていないかと思い探してみましたが、残念ながら公開はされていませんでした。
そこで、Prisma Client側のコードを読み、PDPとどのような通信を行っているかを見ることで、ブラックボックスなPDPの内部を解き明かせないかと考えました。

PrismaのClientコードは、ライブラリモード(デフォルト)とバイナリモード、そしてDataProxyモードの3つにエンジンのコードが分かれています。
3つめのDataProxyモードのエンジンが、PDPと通信を行うコードです。

https://github.com/prisma/prisma/blob/185a1a23f1dff8c5ad2575e50aa0e3ff58d0e7cb/packages/client/src/runtime/core/engines/data-proxy/DataProxyEngine.ts#L327-L353

requestInternalの実装をみると、GraphQLぽいリクエストをPDPのエンドポイントに投げていることがわかります。

ソースにconsole.logを仕込んでbodyを除いてみると、db.link.findMany({ select: { id: true, url: true, User: true }, where: { id: 1 } })というクエリを実行したときに、このようなクエリが投げられていることが確認できました。

query query {
  findManyLink(where: { id: 1 }) {
    id
    url
    User {
      id
      createdAt
      updatedAt
      name
      email
    }
  }
}

一部集計系オペレーションなどに例外はありますが、おおよそこのような法則になっていました。

[query|mutation] query {
  [オペレーション名][モデル名]([select以外の引数(where,take,skip,etc...)]) {
    selectで示したフィールド
    [リレーションモデル] {
      selectで示したフィールド
    }
  }
}

この法則に従ったGraphQLサーバを構築すれば、PDPをセルフホストしたものに代替できそうです。

PrismaDataProxy用のGraphQLサーバを仮組みする

前述のリクエストに対応するスキーマとリゾルバを定義し、Apolloサーバを構築したときに正しく、Prisma Clientがレスポンスを正しく解釈できるか実験してみます。

今回使用するデータベースのスキーマ定義
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Link {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  url       String
  shortUrl  String
  userId    String?
  User      User?    @relation(fields: [userId], references: [id])
}

model User {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  name      String?
  email     String
  links     Link[]
}

スキーマは schema.prismaの定義とほぼ同じです。

scalar DateTime
scalar Any

type Link {
  id: String!
  createdAt: DateTime!
  updatedAt: DateTime!
  url: String!
  shortUrl: String!
  userId: String
  User: User
}

type User {
  id: String!
  createdAt: DateTime!
  updatedAt: DateTime!
  name:      String!
  email:     String!
  links:     [Link!]!
}

type Query {
  findManyLink(where: Any): [Link!]!
}

とりあえず、findManyLink用のクエリだけ用意しました。
whereの引数は、面倒なので適当なscalar Anyを定義しました。

リゾルバはこんな感じです。
子のモデルであるUserは、親の検索結果からidを取り出して再取得します。

const resolver = {
  Link: {
    User: ({ id }, args) => {
      return db.link.findUnique({ where: { id } }).User(args)
    }
  },
  Query: {
    findManyLink: (_, args) => {
      return db.link.findMany(args)
    }
  }
}

このスキーマとリゾルバでApolloサーバーを構築し、DBとセットでdocker-composeで立ち上げます。
しかし、Clientの実装を見るとhttpsでしかリクエストが投げられない事がわかります。

https://github.com/prisma/prisma/blob/185a1a23f1dff8c5ad2575e50aa0e3ff58d0e7cb/packages/client/src/runtime/core/engines/data-proxy/DataProxyEngine.ts#L246-L248

そこで https-portal を入れてサービスをSSL化します。


ここまでして、Client側からクエリを投げた際にデータの取得に成功しました。

await db.list.findMany({ select: { id: true, url: true, User: true }, where: { url: { endsWith: 'com' } } })
[
  {
    id: '05fcafd2-d013-4a77-b4eb-ac73470cb790',
    url: 'https://paige.com',
    User: {
      id: '3d293f83-659a-481a-8eb6-56d10b6d5c45',
      createdAt: '2022-04-15T13:24:22.500Z',
      updatedAt: '2022-04-15T13:24:22.501Z',
      name: 'Freddie',
      email: 'Naomie_Weimann43@hotmail.com'
    }
  },
  ...
]

PrismaDataProxyの代替サーバを本組みする

仮組みの実験がうまく行ったので、いよいよサーバを本組みしていきます。

スキーマの自動生成

スキーマを手動で一つずつ定義するのは面倒ですし、なによりライブラリとして一般公開するためには自動化されている必要があります。
そこDMMFと呼ばれる、prisma generateの際に自動生成される構造体を利用します。
このDMMFには、定義されているモデル情報やオペレーションのリストなどの情報が入っており、prisma clientのからクエリを生成する際に使用されます。

import { Prisma } from "@prisma/client";

console.dir(Prisma.dmmf)

このように@prisma/clientから取り出して、中身を見ることができますので、興味がある方はぜひ試してみてください。
nexus-prismazod-prismaなどの、一般に公開されているジェネレートプラグインも子のDMMFを利用して、プラグインが構築されています。

https://www.prisma.io/docs/concepts/components/prisma-schema/generators

DMMFからスキーマ(TypeDefs)を作成する関数を作成しました。

https://github.com/aiji42/prisma-data-proxy-alt/blob/main/src/helpers/makeTypeDefs.ts

リゾルバの自動生成

リゾルバもスキーマ同様にDMMFを使用しすれば構築ができそうですが、
ダイナミックに定義する方法が無いか探してみたところ、Proxyオブジェクトを利用する方法をredditで見つけたのでその方法を利用します。

https://www.reddit.com/r/graphql/comments/ereech/dynamic_resolvers_howto/

Proxyオブジェクトはオブジェクトに対してmethod missingを実装する際によく利用されます。あまり頻出なAPIではありませんが、覚えておいて損はない便利なものです。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Proxy

実際、Prisma clientでモデル名でアクセスができるのは、Proxyオブジェクトが利用されているためです。

https://github.com/prisma/prisma/blob/main/packages/client/src/runtime/core/model/applyModel.ts#L26-L70

これを利用して、Query, Mutation, そして関連モデルに対してのリゾルバを自動生成する関数を作成しました。

https://github.com/aiji42/prisma-data-proxy-alt/blob/main/src/helpers/makeResolver.ts


これらとApolloサーバを組み合わせれば、PDPをセルフホストできます。

デプロイ

ここまで作成したものを prisma-data-proxy-alt という名前でnpmにパブリッシュしましたので、これを利用してサーバをデプロイします。

https://github.com/aiji42/prisma-data-proxy-alt

今回はCloud Runにデプロイします。
最低インスタンス数を1にしておけば、アクセスがなくてもコールドスタートからのレイテンシを少なくできますし、CPU時間での課金なのでアクセス無い時間の費用を抑えることができます。
もちろんリージョンも幅広く選択できますので、今回のPDPの弱点をカバーするにはもってこいです。

サーバースクリプトを定義
https://github.com/aiji42/prisma-data-proxy-alt/blob/main/example/gcp/index.ts

Dockerfileを定義
https://github.com/aiji42/prisma-data-proxy-alt/blob/main/example/gcp/Dockerfile

ビルドの設定を定義
https://github.com/aiji42/prisma-data-proxy-alt/blob/main/example/gcp/cloudbuild.yml

準備が整いましたのでgithubにpushし、GCPのCloud build側でリポジトリと紐付けを行えば、あとは自動的にデプロイされます。

substitutionを設定しているので、リージョン、DBの接続URL、プロキシ接続時の任意のトークンを設定可能です。

クライアントから接続

早速、クライアントから接続してみます。
DataProxy用のクライアントは、--data-proxyをつけて実行することで生成できます。

yarn prisma generate --data-proxy

また、CloudflareやVecel EdgeなどのServiceWorkerなランタイムでは、@prisma/client/edgeを利用してください。

詳しくは公式のドキュメントに記載されています。

https://www.prisma.io/docs/concepts/data-platform/data-proxy

環境変数DATABASE_URLは先程デプロイしたCloud Runのドメインと、ビルド時に設定したトークン(API_KEY)を利用して作成します。

DATABASE_URL=prisma://{Cloud Runのドメイン}?api_key={ビルド時に設定したトークン}

これで準備は整いました。

パフォーマンス

実際に接続してパフォーマンスを計測してみます。

前提条件

今回データベースはPlanetscaleを利用し、それぞれのPDPインスタンスと同一のリージョンになるように設置します。

  1. cloud.prisma.io が提供する公式のPDP (バージニア北部) + Planetscale (バージニア北部)
  2. Cloud RunにデプロイしたAlternative PDP (東京) + Planetscale (東京)
  3. Cloud RunにデプロイしたAlternative PDP (バージニア北部) + Planetscale (バージニア北部)

計測に使用するコマンドは次のとおりです。

const { PrismaClient } = require('@prisma/client');
const db = new PrismaClient();
const hrstart = process.hrtime();
await db.link.findMany({ select: { id: true }, take: 100 });
const hrend = process.hrtime(hrstart);
console.info('Execution time (hr): %ds %dms', hrend[0], hrend[1] / 1000_000);

このコマンドでそれぞれ5回ずつ計測を行います。

計測結果

公式PDP Virginia セルフホストPDP Tokyo セルフホストPDP Virginia
669.824264ms 98.33391ms 243.413536ms
685.022400ms 110.355187ms 235.073404ms
747.648396ms 95.039208ms 242.249807ms
639.583797ms 91.521624ms 242.825970ms
634.054569ms 106.338754ms 254.642930ms
Avg 675.226685ms 🥉 100.317736ms 🥇 243.641129ms 🥈

改めて公式のPDPのレイテンシがかなり大きいことがわかります。
驚くべきは、今回Tokyoリージョンに設置したセルフホストのPDPが早いのはもちろんですが、同一リージョンのバージニア北部に設置したPDPも、遥かに公式のPDPよりもレイテンシが小さいということです。
おそらく、公式のPDPはデータベースとのコネクションを逐一行っており、それがレイテンシの増加につながっていると思われます。

公式のPDPのリージョンは、そのうち追加されると思われますが、コールドスタンバイとデータベースとのコネクションによるレイテンシは解消できませんので、このAlternativePDPを使用する価値は十分にありそうです。

まとめ

  • PrismaDataProxyの弱点である、リージョンの制限とコールドスタンバイによるレイテンシを、代替サーバをセルフホストすることで解決しました
  • PrismaDataProxyのソースは公開されていませんが、クライアントと実際のリクエストの内容から、実態がGraphQLであると推測し、ライブラリ化に成功しました

これでなんとかCloudflare Workersから満足にPrismaを使用することができそうです。

本記事で作成・紹介したライブラリはOSSとして公開&npmにpublishしていますので自由にお使いください。

https://github.com/aiji42/prisma-data-proxy-alt

GitHubで編集を提案

Discussion

matsmats

解説ありがとうございます。非常に興味深く拝見させていただきました。

実態としては スキーマが自動生成された GraphQL とのことですが、同じ TypeScript 製の PostGraphile にコードを追加し Query や Mutation の命名規則を揃えることで(機能的には可能だと認識しています)、より本ライブラリで記述する内容が減らせたりしますでしょうか?

aiji42aiji42

コメントありがとうございます🙏
私自身、PostGraphileに対しての知識があまりないので、実際に記述量が減らせるかというところに関しては正直わかりませんが、理論上は実現可能だと思います。