PrismaDataProxyが遅い問題をなんとかする
概要
PrismaDataProxyが遅いので、セルフホストするためのライブラリを自作して解決しました、というお話です。
本記事で作成・紹介したライブラリはOSSとして公開&npmにpublishしていますので自由にお使いください。
PrismaDataProxy とは
Prisma.ioが提供する、データベース接続管理とプーリングのためのプロキシサーバです。
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と通信を行うコードです。
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-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-prisma
やzod-prisma
などの、一般に公開されているジェネレートプラグインも子のDMMFを利用して、プラグインが構築されています。
DMMFからスキーマ(TypeDefs)を作成する関数を作成しました。
リゾルバの自動生成
リゾルバもスキーマ同様にDMMFを使用しすれば構築ができそうですが、
ダイナミックに定義する方法が無いか探してみたところ、Proxyオブジェクトを利用する方法をredditで見つけたのでその方法を利用します。
Proxyオブジェクトはオブジェクトに対してmethod missingを実装する際によく利用されます。あまり頻出なAPIではありませんが、覚えておいて損はない便利なものです。
実際、Prisma clientでモデル名でアクセスができるのは、Proxyオブジェクトが利用されているためです。
これを利用して、Query, Mutation, そして関連モデルに対してのリゾルバを自動生成する関数を作成しました。
これらとApolloサーバを組み合わせれば、PDPをセルフホストできます。
デプロイ
ここまで作成したものを prisma-data-proxy-alt
という名前でnpmにパブリッシュしましたので、これを利用してサーバをデプロイします。
今回はCloud Runにデプロイします。
最低インスタンス数を1にしておけば、アクセスがなくてもコールドスタートからのレイテンシを少なくできますし、CPU時間での課金なのでアクセス無い時間の費用を抑えることができます。
もちろんリージョンも幅広く選択できますので、今回のPDPの弱点をカバーするにはもってこいです。
サーバースクリプトを定義
Dockerfileを定義
ビルドの設定を定義
準備が整いましたのでgithubにpushし、GCPのCloud build側でリポジトリと紐付けを行えば、あとは自動的にデプロイされます。
substitutionを設定しているので、リージョン、DBの接続URL、プロキシ接続時の任意のトークンを設定可能です。
クライアントから接続
早速、クライアントから接続してみます。
DataProxy用のクライアントは、--data-proxy
をつけて実行することで生成できます。
yarn prisma generate --data-proxy
また、CloudflareやVecel EdgeなどのServiceWorkerなランタイムでは、@prisma/client/edge
を利用してください。
詳しくは公式のドキュメントに記載されています。
環境変数DATABASE_URL
は先程デプロイしたCloud Runのドメインと、ビルド時に設定したトークン(API_KEY)を利用して作成します。
DATABASE_URL=prisma://{Cloud Runのドメイン}?api_key={ビルド時に設定したトークン}
これで準備は整いました。
パフォーマンス
実際に接続してパフォーマンスを計測してみます。
前提条件
今回データベースはPlanetscaleを利用し、それぞれのPDPインスタンスと同一のリージョンになるように設置します。
- cloud.prisma.io が提供する公式のPDP (バージニア北部) + Planetscale (バージニア北部)
- Cloud RunにデプロイしたAlternative PDP (東京) + Planetscale (東京)
- 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していますので自由にお使いください。
Discussion
解説ありがとうございます。非常に興味深く拝見させていただきました。
実態としては スキーマが自動生成された GraphQL とのことですが、同じ TypeScript 製の PostGraphile にコードを追加し Query や Mutation の命名規則を揃えることで(機能的には可能だと認識しています)、より本ライブラリで記述する内容が減らせたりしますでしょうか?
コメントありがとうございます🙏
私自身、PostGraphileに対しての知識があまりないので、実際に記述量が減らせるかというところに関しては正直わかりませんが、理論上は実現可能だと思います。