2023年4月21日
Prismaでサーバーレスコールドスタートを9倍高速化する方法
コールドスタートは、サーバーレスアプリケーションでの高速なユーザーエクスペリエンスにとって大きな障害ですが、本質的に避けられません。コールドスタートの原因と、Prisma ORMを使用して構築されたすべてのサーバーレスアプリをさらに高速化する方法を探りましょう。
目次
- サーバーレスとエッジの利点を開発者が享受できるようにする
- 恐ろしいコールドスタート🥶
- Prismaがコールドスタートにどのように貢献するか
- 起動パフォーマンスが9倍向上
- 余談:TLSに関する発見
- これはほんの始まりにすぎない
- ご協力ください!
サーバーレスとエッジの利点を開発者が享受できるようにする
Prismaでは、サーバーレスおよびエッジアプリケーションの前提を強く信じています!これらのデプロイメントパラダイムには大きな利点があり、開発者はアプリケーションをよりスケーラブルで低コストの方法でデプロイできます。Vercel(Next.js APIルートを使用)やAWS Lambdaなどのサーバーレスプロバイダーは、その素晴らしい例です。
ただし、これらのパラダイムには、特にデータを扱う場合に、新たな課題も伴います!
そのため、過去数か月で、サーバーレスおよびエッジテクノロジーの利点を活用し、享受しながら、データドリブンなアプリケーションを構築する開発者を支援するために、これらのデプロイメントパラダイムに焦点を当ててきました。
私たちは2つの角度からこれに取り組んでいます。
- これらのエコシステムに伴う新たな課題を解決する製品の構築(Accelerate、グローバルに分散されたデータベースキャッシュなど)
- サーバーレスおよびエッジ環境でのPrisma ORMのエクスペリエンスの向上
この記事では、サーバーレス環境でデータドリブンなアプリケーションを構築する際に開発者が直面する主要な問題の1つである、Prisma ORMを使用する際のコールドスタートをどのように改善したかについて説明します。
恐ろしいコールドスタート🥶
サーバーレス環境で作業する場合の最も頻繁なパフォーマンスの問題の1つは、長いコールドスタートです。しかし、コールドスタートとは何でしょうか?
残念ながら、この用語は多くの曖昧さを抱えており、誤解されることがよくあります。一般的には、サーバーレス関数の環境がインスタンス化され、関数が最初のリクエストを処理するときにそのコードが実行されるまでにかかる時間を表します。これは基本的な技術的説明ですが、コールドスタートについて留意すべきいくつかの具体的な事項があります。
それらは本質的に避けられない
コールドスタートは、サーバーレス環境で作業する際の避けられない現実です。サーバーレスの主な「利点」は、トラフィックが増加するとアプリケーションが無限にスケールアップし、使用されていないときはゼロにスケールダウンできることです。その機能がなければ、サーバーレスは...サーバーレスではありません!
しばらくリクエストがない場合、実行中のすべての環境がシャットダウンされます。これはコストが発生しないことも意味するため、非常に優れています。しかし、これは受信リクエストに即座に応答できる関数が残っていないことも意味します。それらは最初に再び開始する必要があり、それには少し時間がかかります。
それらは現実世界への影響がある
コールドスタートは技術的な影響だけでなく、サーバーレス関数をデプロイするビジネスに現実世界の問題も引き起こします。
ユーザーに可能な限り最高のエクスペリエンスを提供することは最も重要であり、起動パフォーマンスが遅いとユーザーが離れてしまう可能性があります。
Peer Richelsen(Cal.com)は、アプリケーションが長いコールドスタートに苦しんでいることに気づき、最近Twitterに投稿しました。
最終的に、サーバーレス環境で作業する開発者の目標は、コールドスタート時間を可能な限り短くすることです。コールドスタート時間が長いと、ユーザーに悪いエクスペリエンスを与える可能性があるためです。
それらはあなたが考えるよりも複雑である
上記のコールドスタートの説明は非常に簡単ですが、コールドスタートに寄与するさまざまな要因を理解することが重要です。次のいくつかのセクションでは、サーバーレス関数が最初にスポーンされて実行されるときに実際に何が起こるかを説明します。
注:これは、サーバーレス関数がインスタンス化および呼び出される方法の一般的な概要であることを念頭に置いてください。そのプロセスの具体的な詳細は、クラウドプロバイダーと構成によって異なる場合があります(主にAWS Lambdaを基準として使用します)。
これらのステップを説明するための例として、この単純なサーバーレス関数を使用します。
ステップ1:環境の立ち上げ
関数がリクエストを受信したが、そのインスタンスが現在利用できない場合、クラウドプロバイダーはサーバーレス関数を実行する実行環境を初期化します。このフェーズでは、複数のステップが発生します。
- 仮想環境は、サーバーレス関数に割り当てたCPUとメモリリソースを使用して作成されます。
- コードはアーカイブとしてダウンロードされ、新しい環境のファイルシステムに展開されます。(AWS Lambdaを使用している場合、関連するLambdaレイヤーもダウンロードされます。)
- ランタイム(つまり、関数が実行される言語固有の環境)が初期化されます。関数がJavaScriptで記述されている場合、これはNode.jsランタイムになります。
その後、関数はまだリクエストを処理する準備ができていません。仮想環境は準備ができており、すべてのコードが配置されていますが、ランタイムによってまだコードは処理されていません。ハンドラーを呼び出す前に、次のステップで説明するようにアプリケーションを初期化する必要があります。
注:関数の起動に関するこれらの詳細は構成可能ではなく、クラウドプロバイダーによって処理されます。これの仕組みについて、あなたが発言権を持つことはあまりありません。
ステップ2:アプリケーションの起動
一般的に、アプリケーションコードは2つの異なるスコープに存在します。
- ハンドラー関数の外側のコード
- ハンドラー関数の内側のコード
このステップでは、クラウドプロバイダーはハンドラーの外側にあるコードを実行します。ハンドラーの内側のコードは、次のステップで実行されます。
AWS Lambdaは、上記の関数を実行すると、次のようにログに記録します。
外部のconsole.log("Executed when the application starts up!")
が、AWS Lambdaによって実際のSTART RequestId
がログに記録される前に実行されていることがわかります。インポート、コンストラクター呼び出し、またはその他のコードがある場合、それらもこの時点で実行されます。
(関数へのウォームスタートリクエストでは、この行はもうまったくログに記録されません。ハンドラーの外部のコードは、コールドスタート中に一度だけ実行されます。)
ステップ3:アプリケーションコードの実行
起動プロセスの最後の部分では、ハンドラー関数が実行されます。受信HTTPリクエスト(つまり、リクエストヘッダー、ボディなど)を受け取り、実装したロジックを実行します。
前のステップからのAWS Lambdaログが続きます。
これで、関数のコールドスタートが完了し、実行環境はそれ以降のリクエストを処理する準備が整いました。
余談:AWS Lambdaは、ステップ3で発生するハンドラー内側のコードの実行にかかった時間を
Duration
としてログに記録します。Init Duration
は、環境の起動とアプリケーションの起動の両方であるため、ステップ1と2です。
Prismaがコールドスタートにどのように貢献するか
コールドスタートとは何か、およびサーバーレス関数を初期化するために実行されるステップを理解した上で、Prismaが起動時間にどのような役割を果たしているかを見ていきます。
-
Prisma Clientは、関数のコードの外部にあるNode.jsモジュールであり、実行環境のメモリにロードするために時間とリソースが必要です。関数アーカイブ全体を何らかのストレージからダウンロードし、ファイルシステムに展開する必要があります。これはすべてのNode.jsモジュールに当てはまりますが、コールドスタートに追加され、プロジェクトで使用される依存関係が増えるほど増加します。Prismaもその1つになる可能性があります。
-
コードがメモリにロードされると、ハンドラーのファイルにインポートする必要があり、Node.jsインタープリターによって解釈される必要があります。Prisma Clientの場合、通常は
const { PrismaClient } = require('@prisma/client')
を呼び出すことを意味します。 -
Prisma Clientが
const prisma = new PrismaClient()
でインスタンス化されると、Prisma Query Engineをロードする必要があり、クライアントが正しく動作できるようにする入力タイプや関数などのものを生成します。これを行うために、内部スキーマビルダーを使用します。 -
最後に、仮想環境が関数の最初の呼び出しを実行する準備が整うと、ハンドラーはコードの実行を開始します。そのコード内のPrismaクエリ(
await prisma.user.findMany()
など)は、await prisma.$connect()
を明示的に呼び出すことによって接続がまだ開かれていない場合、最初にデータベースへの接続を開始し、クエリを実行してデータをアプリケーションに返します。
この理解に基づいて、Prismaのコールドスタートへの影響をどのように改善したかを説明に進むことができます。
起動パフォーマンスが9倍向上
過去数か月で、これらのコールドスタートの問題に対処するためのエンジニアリングの取り組みを強化し、目覚ましい進歩を遂げたと誇りを持って言えます🎉
一般的に、Prisma ORMの構築中に「動作させ、正しくし、高速化する」という哲学に従ってきました。2020年にPrisma ORMを本番環境向けにリリースし、複数のデータベースのサポートを追加し、広範な機能を実装した後、ついにそのパフォーマンスの向上に焦点を当てています。
私たちの進捗状況を示すために、以下のグラフを検討してください。最初のグラフは、改善の取り組みを開始する前の、比較的大きなPrismaスキーマ(500モデル)を持つアプリのコールドスタート時間を示しています。

改善前
次のグラフは、最近のパフォーマンス強化の取り組み後の現在の数値を示しています。

改善後
ここで状況を美化するつもりはありません。Prismaの起動時間は以前は非常に不満が残るものであり、人々は正当にそれを指摘してきました。
ご覧のとおり、現在ではコールドスタート時間が大幅に短縮されています。ここでの進歩は、コードベースの強化、サーバーレス関数の動作に関する発見、およびベストプラクティスの適用という形で実現しました。次のセクションでは、これらについて詳しく説明します。
新しいJSONベースのワイヤープロトコル
下のグラフは、上記で示した改善前のグラフと同じものです。

改善前
このグラフでは、青のPrisma Clientバーのセクションは、関数の最初の呼び出し中にfindMany
クエリを実行するのに費やされた時間を表しています。その時間は、Internalsバーで2つのチャンクに分割されています:紫と赤。
すぐに、このグラフはあまり意味をなさないことに気づきました。クエリの実行にかかる時間の大部分は...クエリを実行していないことに費やされていました!
この紫のセグメントは、findMany
クエリセグメントの大部分を占めており、DMMF(データモデルメタフォーマット)と呼ばれるものの解析に費やされた時間を表しています。これは、Prismaのクエリエンジンに送信されるクエリを検証するために使用される内部構造です。
赤のセグメントは、実際にクエリを実行するのに費やされた時間を表しています。
ここでの根本的な問題は、Prisma Clientがクエリエンジンとの通信にGraphQLのような言語をワイヤープロトコルとして使用していたことです。GraphQLには、Prisma Clientがクエリをシリアル化するためにDMMF(数メガバイトのJSONになる可能性があります)を使用することを強制する一連の制限があります。
Prismaを長年使用している場合は、Prisma 1がはるかにGraphQLに焦点を当てたツールであったことを覚えているかもしれません。Prisma 2としてPrismaを再構築する際、プレーンなデータベースORMになることに完全に焦点を当て、アーキテクチャのこの部分を疑問視することなく、そしてパフォーマンスへの影響を測定することなく維持しました。

Serhiiの啓示
私たちが思いついた解決策は、ワイヤープロトコルをプレーンJSONでゼロから再設計することでした。これにより、Prisma Clientとクエリエンジン間の通信が大幅に効率的になります。メッセージをシリアル化するためにDMMFが不要になったためです。
ワイヤープロトコルを再設計した後、グラフから紫のセグメント全体を効果的に削除し、次のようになりました。

JSONプロトコルを使用
注:変更内容に興味がある場合は、プルリクエストprisma-engines#3624とprisma#17911で実際の変更を確認できます。
新しいJSONベースのワイヤープロトコルを試したユーザーからのGitHubでの素晴らしいフィードバックをご覧ください。

注:JSONベースのワイヤープロトコルは現在、プレビュー版です。本番環境に対応できるようになると、Prisma Clientがクエリエンジンと通信する方法のデフォルトになります。ぜひお試しいただき、フィードバックをお寄せください。この機能を一般提供するためにプロセスを迅速化するのに役立ちます。
データベースと同じリージョンに関数をホストする
JSONプロトコルへの切り替え後、大きな邪魔な紫セクションがグラフから消え、残りの部分に集中できるようになりました。

JSONプロトコルを使用
薄赤と赤セクションが、次の大きな候補として明確に認識されました。これらは、Prismaがトリガーする実際のデータベースとの通信を表しています。
従来のリレーショナルデータベースへのアクセスが必要なアプリケーションまたは関数をホストする場合は、そのデータベースへの接続を開始する必要があります。これには時間がかかり、遅延が発生します。実行するクエリにも同じことが言えます。
目標は、その時間と遅延を最小限に抑えることです。現時点でこれを行うための最良の方法は、アプリケーションまたは関数がデータベースサーバーと同じ地理的リージョンにデプロイされていることを確認することです。

リクエストがデータベースサーバーに到達するために移動する必要がある距離が短いほど、その接続はより迅速に確立されます。これはサーバーレスアプリケーションをデプロイする際に留意すべき非常に重要なことであり、これを実行しないことから生じる悪影響は重大なものになる可能性があります。
そうしないと、次の時間が影響を受ける可能性があります。
- TLSハンドシェイクを完了する
- データベースとの接続を確保する
- クエリを実行する
これらの要因はすべてコールドスタート中にアクティブ化されるため、Prismaでデータベースを使用することがアプリケーションのコールドスタートに与える影響に寄与します。
恥ずかしながら、AWS Lambdaのサーバーレス関数をeu-central-1
で、RDS PostgreSQLインスタンスをus-east-1
でホストして、最初の数回のテストを実行していたことに気づきました。すぐに修正したところ、「改善後」の測定では、これがデータベースのレイテンシにどれほど大きな影響を与える可能性があるかが明確に示されています。接続の作成だけでなく、実行されるすべてのクエリにも影響があります。

関数と同じリージョンにデータベースを使用
関数にできるだけ近くないデータベースを使用すると、コールドスタートの時間が直接的に長くなるだけでなく、ウォームリクエストの処理中に後でクエリが実行されるたびに同じコストが発生します。
最適化された内部スキーマ構築
以前に示したグラフでは、Internalsバーの3つのセグメントのうち2つだけがデータベースに直接関連していることに気づいたかもしれません。他のセグメントである「スキーマビルダー」(青緑で表示)はそうではありません。これは、このセグメントが改善の可能性がある領域であるという指標でした。

関数と同じリージョンにデータベースを使用
Prisma Clientバーの緑セグメントは、Prisma Clientがデータベースとの接続を確立するために$connect
関数を実行している間に費やされた時間を表しています。このセグメントは、Internalsバーで2つのチャンクに分割されています:青緑と薄赤。
薄赤セグメントは、データベース接続を実際に作成するのに費やされた時間を表し、青緑セグメントは、PrismaのクエリエンジンがPrismaスキーマを読み取り、それを使用して受信Prisma Clientクエリを検証するために使用するスキーマを生成するのに費やす時間を示しています。
これらの項目が以前に生成された方法は、可能な限り最適化されていませんでした。そのセグメントを短縮するために、そこで見つけることができるパフォーマンスの問題に取り組みました。
具体的には、クエリスキーマを構築する前にクエリエンジンが起動されたときに内部Prismaスキーマを変換する高価なコードの一部を削除する方法を見つけました。
また、クエリスキーマ内の多くのタイプの名前の文字列を遅延的に生成するようになりました。これにより、測定可能な差が生じました。
その変更とともに、メモリレイアウトを改善するためにスキーマビルダー内のコードを最適化する方法も見つけました。これにより、パフォーマンス(実行時間)が大幅に向上しました。
これらの変更を適用した後、以前のリクエストは次のようになりました。

スキーマビルダーの強化を使用
青緑セグメントが大幅に短縮されていることに注目してください。これは大きな勝利ですが、まだ青緑セグメントがあります。これは、データベースに関係のないことを実行するのに時間が費やされていることを意味します。このセグメントを(完全にゼロではないにしても)ゼロに近づける可能性のある強化をすでに特定しています。
さまざまな小さな改善
その過程で、改善できた多くの小さな非効率性も見つけました。これらはたくさんあったため、それぞれについては説明しませんが、良い例は、Linux環境でOpenSSLライブラリを検索するために使用されるプラットフォーム検出ルーチンに対して行った最適化です(その強化のプルリクエストはこちらにあります)。
この強化により、コールドスタートが平均で約10〜20ms短縮されます。それほど多くはないように思えるかもしれませんが、この強化と、行った他の小さな強化の蓄積により、さらにかなりの時間を節約できます。
余談:TLSに関する発見
この取り組み中に得られたもう1つの注目すべき発見は、TLSを介してデータベース接続にセキュリティを追加すると、データベースがサーバーレス関数とは異なるリージョンでホストされている場合、コールドスタート時間に大きな影響を与える可能性があることです。
TLSハンドシェイクには、データベースとの間のラウンドトリップが必要です。これは、データベースが関数と同じリージョンでホストされている場合は非常に高速ですが、それらが遠く離れている場合は非常に遅くなる可能性があります。
Prisma Clientは、データベースへのより安全な接続方法であるため、デフォルトでTLSを有効にしています。このため、データベースが関数と同じリージョンにない一部の開発者は、TLSハンドシェイクによってコールドスタート時間が長くなることに気づくかもしれません。
下のグラフは、TLSを有効にした場合(最初)と無効にした場合(接続文字列でsslmode=disable
を設定)の異なるコールドスタート時間を示しています。

データベースが関数と同じリージョンでホストされている場合、上記のTLSオーバーヘッドは無視できます。
Nodeエコシステムの他のデータベースクライアントおよびORMの一部は、PostgreSQLデータベースの場合、デフォルトでTLSを無効にしています。Prisma ORMのパフォーマンスをそれらと比較する場合、残念ながら、このデフォルトのセキュリティの違いによってパフォーマンスの印象が生じる可能性があります。
パフォーマンスを向上させるためにセキュリティを妥協するのではなく、データベースと関数を同じリージョンに移動することをお勧めします。これにより、データベースのセキュリティが維持され、コールドスタートがさらに速くなります。
これはほんの始まりにすぎない
過去数か月で目覚ましい進歩を遂げましたが、まだ始まったばかりです。
私たちは次のことを望んでいます。
- スキーマビルダー(グラフの青緑セグメント)を、その作業の一部を遅延的に行うか、クエリ検証中に行うことで、ほぼゼロまたは実際にゼロに近づけるように最適化します。
- Prismaのロード(グラフの黄セグメント)を最適化し、Prismaのロードにかかる時間も可能な限り小さくします。
- 上記のすべての学習内容をPostgreSQL以外の他のデータベースにも適用します。
- 大きなもの:Prisma Clientクエリのパフォーマンスを見て、これらのクエリにかかる時間を、データが少ない場合と多い場合のどちらでも最適化します。
コールドスタートパフォーマンスをさらに向上させた場合は、このブログ投稿の更新、またはPrisma ORMのパフォーマンスを向上させるための取り組みの進捗状況に応じて、数週間または数か月後に別のブログ投稿を期待できます。
ご協力ください!
サーバーレス環境におけるPrismaの体験を可能な限りスムーズにすることが私たちの目標であり、非常に意欲的な目標です。Prisma Clientを使用したサーバーレス関数の起動パフォーマンスを改善するために専念している素晴らしいチームがありますが、同時に、この取り組みに貢献したいと熱心な開発者の大規模なコミュニティが存在することも認識しています。
特にサーバーレスの起動時間という文脈において、Prisma Clientのパフォーマンス向上にご協力いただければ幸いです。
サーバーレス環境やエッジ環境でアクセス可能なワールドクラスのORMを提供するという目標に貢献できる方法はたくさんあります。
- サーバーレス環境でPrismaを使用する際に発見したissue(問題)を提出してください。
- 改善方法についてアイデアをお持ちですか?ディスカッションを開始してください!
jsonProtocol
プレビュー機能を試して、フィードバックをお寄せください。
Prisma ORMはオープンソースプロジェクトであり、そのため、私たちはコミュニティからのフィードバックと参加の重要性を十分に理解しています。私たちは、すべての開発者の利益のためにPrismaを前進させるのに役立つフィードバック、批判、質問、そしてあらゆるものを歓迎します。
次回の投稿もお見逃しなく!
Prismaニュースレターに登録する