2023年4月21日
Prisma でサーバーレスのコールドスタートを 9 倍高速化した方法
コールドスタートは、サーバーレスアプリケーションの高速なユーザーエクスペリエンスにとって大きな障害ですが、本質的に避けることはできません。コールドスタートの原因と、Prisma ORMを使用して構築されたすべてのサーバーレスアプリをさらに高速化する方法を探ってみましょう。
目次
- 開発者がサーバーレスとEdgeの恩恵を享受できるように
- 恐ろしいコールドスタート 🥶
- Prismaがコールドスタートにどう貢献するか
- 起動パフォーマンスを9倍改善
- 補足:TLSに関する発見
- これはまだ始まりにすぎない
- ご協力をお願いします!
開発者がサーバーレスとEdgeの恩恵を享受できるように
Prismaでは、サーバーレスおよびエッジアプリケーションという前提を強く信じています!これらのデプロイメントパラダイムは大きな利点をもたらし、開発者がアプリケーションをよりスケーラブルかつ低コストでデプロイすることを可能にします。Vercel(Next.js APIルートを使用)やAWS Lambdaなどのサーバーレスプロバイダーは、その良い例です。
しかし、これらのパラダイムには、特にデータを扱う際に新たな課題も伴います!
そのため、ここ数ヶ月間、私たちはこれらのデプロイメントパラダイムへの注力を強化し、開発者がサーバーレスおよびエッジテクノロジーを活用してデータ駆動型アプリケーションを構築できるよう支援してきました。
私たちはこれを2つの側面から取り組んでいます
- これらのエコシステムに伴う新しい課題を解決する製品の構築(グローバルに分散されたデータベースキャッシュであるAccelerateなど)
- サーバーレスおよびエッジ環境におけるPrisma ORMの体験向上
この記事は、サーバーレス環境でデータ駆動型アプリケーションを構築する際に開発者が直面する主要な問題の1つ、Prisma ORM使用時のコールドスタートをどのように改善したかについてです。
恐ろしいコールドスタート 🥶
サーバーレス環境で作業する際によくあるパフォーマンス問題の1つが、長いコールドスタートです。しかし、コールドスタートとは何でしょうか?
残念ながら、この用語には多くの曖昧さがあり、誤解されることも少なくありません。一般的には、関数が最初のリクエストを処理する際に、サーバーレス関数の環境がインスタンス化され、そのコードが実行されるまでにかかる時間を指します。これは基本的な技術的説明ですが、コールドスタートについて覚えておくべき特定の点がいくつかあります。
それらは本質的に避けられない
コールドスタートは、サーバーレス環境で作業する際に避けられない現実です。サーバーレスの主な「利点」は、トラフィックが増加するとアプリケーションが無限にスケールアップし、使用されていないときはゼロにスケールダウンできることです。その機能がなければ、サーバーレスは...サーバーレスではありえません!
しばらくリクエストがない場合、実行中のすべての環境はシャットダウンされます。これはコストがかからないという意味でもあり、素晴らしいことです。しかし、それはまた、受信リクエストに即座に応答できる関数が残っていないことも意味します。それらはまず再起動される必要があり、それに少し時間がかかります。
それらは現実世界に影響を与える
コールドスタートは技術的な影響だけでなく、サーバーレス関数をデプロイする企業に現実世界の問題も引き起こします。
ユーザーに可能な限り最高の体験を提供することは最重要であり、起動パフォーマンスが遅いとユーザーが離れてしまう可能性があります。
Cal.comのPeer Richelsenは、最近、彼らのアプリケーションが長いコールドスタートに悩まされていることに気づき、Twitterに投稿しました。
最終的に、サーバーレス環境で作業する開発者の目標は、コールドスタート時間を可能な限り短くすることです。なぜなら、長いコールドスタートはユーザーにとって悪い体験につながる可能性があるからです。
それらは思ったよりも複雑
コールドスタートに関する上記の説明は非常に分かりやすいものですが、コールドスタートには様々な要因が寄与していることを理解することが重要です。次のいくつかのセクションでは、サーバーレス関数が最初に起動され実行されるときに実際に何が起こるのかを説明します。
注:これはサーバーレス関数がどのようにインスタンス化され、呼び出されるかについての一般的な概要であることを覚えておいてください。そのプロセスの具体的な詳細は、クラウドプロバイダーと構成によって異なる場合があります(主にAWS Lambdaを参照として使用しています)。
これらの手順を説明するために、このシンプルなサーバーレス関数を例として使用します。
ステップ1:環境を立ち上げる
関数がリクエストを受け取ったとき、そのインスタンスが現在利用できない場合、クラウドプロバイダーはサーバーレス関数を実行する実行環境を初期化します。このフェーズでは複数のステップが発生します。
- サーバーレス関数に割り当てたCPUとメモリリソースで仮想環境が作成されます。
- コードはアーカイブとしてダウンロードされ、新しい環境のファイルシステムに抽出されます。(AWS Lambdaを使用している場合、関連するLambdaレイヤーもダウンロードされます。)
- ランタイム(つまり、関数が実行される言語固有の環境)が初期化されます。関数がJavaScriptで書かれている場合、これはNode.jsランタイムになります。
その後、関数はまだリクエストを処理する準備ができていません。仮想環境は準備され、すべてのコードは配置されていますが、まだランタイムによってコードが処理されていません。ハンドラーが呼び出される前に、次のステップで説明するようにアプリケーションを初期化する必要があります。
注:関数の起動に関するこれらの詳細は設定できず、クラウドプロバイダーによって処理されます。この動作について、あなたはほとんど口出しできません。
ステップ2:アプリケーションの開始
一般的に、アプリケーションコードは2つの異なるスコープに存在します。
- ハンドラー関数の外側のコード
- ハンドラー関数の内側のコード
このステップでは、クラウドプロバイダーがハンドラーの外側にあるコードを実行します。ハンドラーの内側のコードは次のステップで実行されます。
AWS Lambdaは、上記の関数を実行すると次のようにログに記録します。
AWS Lambdaによって実際のSTART RequestId
がログに記録されるよりも前に、外側のconsole.log("Executed when the application starts up!")
が実行されているのがわかります。もし、インポート、コンストラクタ呼び出し、その他のコードがあった場合、それらもこの時点で実行されます。
(関数へのウォームスタートリクエストの場合、この行はもはやまったくログに記録されません。ハンドラー外のコードは、コールドスタート中に一度だけ実行されます。)
ステップ3:アプリケーションコードの実行
起動プロセスの最後の部分で、ハンドラー関数が実行されます。これは受信したHTTPリクエスト(すなわち、リクエストヘッダー、ボディなど)を受け取り、実装したロジックを実行します。
前のステップのAWS Lambdaログは続きます。
これにより、関数のコールドスタートは終了し、実行環境はさらなるリクエストを処理する準備ができました。
補足: AWS Lambdaは、ハンドラーの内部でコードを実行するのにかかった時間を
Duration
としてログに記録し、これはステップ3で発生します。Init Duration
は、環境の開始とアプリケーションの開始の両方であり、つまりステップ1と2です。
Prismaがコールドスタートにどう貢献するか
コールドスタートとは何か、そしてサーバーレス関数を初期化するために取られる手順を理解した上で、Prismaが起動時間にどのような役割を果たすかを見ていきましょう。
-
Prisma Clientは、関数のコードとは外部にあるNode.jsモジュールであり、そのため実行環境のメモリにロードされるのに時間とリソースを必要とします。関数アーカイブ全体がストレージからダウンロードされ、ファイルシステムに抽出される必要があります。これはすべてのNode.jsモジュールに当てはまりますが、コールドスタートに影響を与え、プロジェクトで使用される依存関係が増えるほどその影響は大きくなります。そして、Prismaもその一つになりえます。
-
コードがメモリにロードされると、ハンドラーファイルにインポートされ、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を構築する際には「Make it work, make it right, make it fast(動くようにし、正しくし、速くする)」という哲学に従ってきました。2020年にPrisma ORMを本番稼働させ、複数のデータベースのサポートを追加し、広範な機能セットを実装した後、ついにそのパフォーマンス向上に注力しています。
私たちの進捗を示すために、以下のグラフをご覧ください。最初のグラフは、改善 efforts を開始する前の、比較的大きなPrismaスキーマ(500モデル)を持つアプリのコールドスタート期間を表しています。

改善前
次のグラフは、最近のパフォーマンス向上の取り組み後の数値が現在どのように見えるかを示しています。

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

改善前
このグラフでは、Prisma ClientバーのBlueセクションが、関数の最初の呼び出し中にfindMany
クエリを実行するのに費やされた時間を表しています。その時間はInternalsバーでPurpleとRedの2つの塊に分かれています。
私たちはすぐに、このグラフがあまり意味をなさないことに気づきました。クエリを実行するのにかかった時間の大部分は…クエリを実行していない時間でした!
このPurpleのセグメントは、findMany
クエリセグメントの大部分を占めており、Prismaのクエリエンジンに送信されるクエリを検証するために使用される内部構造であるDMMF(Data Model Meta Format)のパースに費やされた時間を表しています。
Redセグメントは、実際にクエリを実行するのに費やされた時間を表します。
ここでの根本的な問題は、Prisma Clientがクエリエンジンとの通信にワイヤプロトコルとしてGraphQLのような言語を使用していたことでした。GraphQLにはいくつかの制約があり、Prisma ClientがクエリをシリアライズするためにDMMF(JSONで数メガバイトになることもあります)を使用することを強制していました。
Prismaを長くお使いの方なら、Prisma 1がよりGraphQLに特化したツールだったことを覚えているかもしれません。Prisma 2としてPrismaを再構築する際、純粋なデータベースORMであることに完全に注力しましたが、このアーキテクチャの一部を疑問視することなく、そのパフォーマンスへの影響を測定することなく維持していました。

Serhiiの発見
私たちが考案した解決策は、ワイヤプロトコルをゼロからプレーンなJSONで再設計することでした。これにより、DMMFがメッセージをシリアライズする必要がなくなったため、Prisma Clientとクエリエンジンの間の通信がはるかに効率的になります。
ワイヤプロトコルを再設計した後、グラフからPurpleセグメント全体を実質的に削除し、以下のようになりました。

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

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

JSONプロトコル使用時
次に大きな候補として、Light RedとRedのセクションが明確に目につきました。これらはPrismaがトリガーする実際のデータベースとの通信を表しています。
従来のリレーショナルデータベースへのアクセスを必要とするアプリケーションや関数をホストする際には、常にそのデータベースへの接続を開始する必要があります。これには時間がかかり、レイテンシが発生します。実行するすべてのクエリについても同様です。
目標は、その時間とレイテンシを最小限に抑えることです。現時点での最善策は、アプリケーションまたは関数をデータベースサーバーと同じ地理的リージョンにデプロイすることです。

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

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

データベースが関数と同じリージョンにある場合
Prisma ClientバーのGreenセグメントは、Prisma Clientが$connect
関数を実行してデータベースとの接続を確立するのに費やされた時間を表します。このセグメントは、InternalsバーでTealとLight Redの2つの塊に分かれています。
Light Redセグメントは実際にデータベース接続を作成するのに費やされた時間を表し、TealセグメントはPrismaのクエリエンジンがPrismaスキーマを読み込み、それを使用して入力されるPrisma Clientクエリを検証するために使用するスキーマを生成するのに費やす時間を示します。
これらの項目が以前に生成されていた方法は、最適化されているとは言えませんでした。そのセグメントを短縮するために、私たちはそこに見つけられるパフォーマンス問題を解決しました。
より具体的には、クエリエンジンの起動時に、クエリスキーマを構築する前に内部Prismaスキーマを変換するコストのかかるコードを削除する方法を見つけました。
また、現在ではクエリスキーマ内の多くの型の名前の文字列を遅延生成しています。これにより、目に見える違いが生まれました。
その変更に加えて、スキーマビルダー内のコードを最適化してメモリレイアウトを改善する方法も見つけ、これが大幅なパフォーマンス(実行時間)の向上につながりました。
注:私たちが実施したメモリ割り当て関連の修正の詳細にご興味があれば、以下のプルリクエスト例をご覧ください:#3828、#3823
これらの変更を適用した後、以前のリクエストは次のようになりました。

スキーマビルダーの強化による改善
Tealセグメントが大幅に短くなっていることに注目してください。これは大きな成果ですが、まだTealセグメントが存在しており、これはデータベースに関連しないことにも時間が費やされていることを意味します。このセグメントをほぼゼロ(あるいは完全にゼロ)に近づける可能性のある改善策はすでに特定しています。
様々な小さな改善
その過程で、私たちは改善できる多くの小さな非効率性も発見しました。これらは多数あったため、すべてを網羅することはできませんが、良い例としては、Linux環境でOpenSSLライブラリを検索するために使用されるプラットフォーム検出ルーチンに加えた最適化があります(この機能強化に関するプルリクエストはこちらで確認できます)。
この改善により、コールドスタートから平均で約10〜20ミリ秒短縮されます。これは大したことないように見えますが、この改善と私たちが行った他の小さな改善が積み重なることで、さらにかなりの時間を節約できます。
補足:TLSに関する発見
この取り組みの中で注目すべきもう一つの発見は、TLSを介してデータベース接続にセキュリティを追加することが、データベースがサーバーレス関数とは異なるリージョンにホストされている場合のコールドスタート時間に大きな影響を与える可能性があるということです。
TLSハンドシェイクには、データベースとの間で往復が必要です。データベースが関数と同じリージョンにホストされている場合は非常に高速ですが、遠く離れている場合は非常に遅くなる可能性があります。
Prisma Clientは、データベースに接続するより安全な方法であるため、デフォルトでTLSを有効にしています。このため、データベースが関数と同じリージョンにない一部の開発者は、TLSハンドシェイクによってコールドスタート時間が長くなることに気づくかもしれません。
以下のグラフは、TLSを有効にした場合(左)と無効にした場合(接続文字列でsslmode=disable
を設定)のコールドスタート時間の違いを示しています。

データベースが関数と同じリージョンにホストされている場合、上記で示されたTLSのオーバーヘッドはごくわずかです。
Nodeエコシステムにおける他のデータベースクライアントやORMの中には、PostgreSQLデータベースでデフォルトでTLSを無効にしているものもあります。Prisma ORMのパフォーマンスとそれらを比較すると、残念ながら、この初期設定のセキュリティの違いに起因するパフォーマンスの印象につながる可能性があります。
パフォーマンスを向上させるためにセキュリティを損なう可能性のある方法ではなく、データベースと関数を同じリージョンに移動することをお勧めします。これにより、データベースが安全に保たれ、コールドスタートがさらに高速になります。
これはまだ始まりにすぎない
ここ数ヶ月で目覚ましい進歩を遂げましたが、私たちはまだ始まったばかりです。
私たちは以下を望んでいます:
- スキーマビルダー(グラフのTealセグメント)を、一部の作業を遅延実行したり、クエリ検証中に実行したりすることで、ほぼゼロ、または実際にゼロに最適化すること。
- Prismaのロード時間(グラフのYellowセグメント)を最適化し、可能な限り短縮すること。
- 上記のすべての学習をPostgreSQL以外の他のデータベースにも適用すること。
- 最も重要なこと:Prisma Clientのクエリのパフォーマンスを調査し、少量データの場合と大量データの場合の両方で、その実行時間を最適化すること。
コールドスタートパフォーマンスをさらに改善する際には、このブログ記事の更新、あるいは今後数週間から数ヶ月のうちに別のブログ記事が公開されることを期待してください。Prisma ORMのパフォーマンス向上への旅は続きます。
ご協力をお願いします!
サーバーレス環境でのPrisma体験を可能な限りスムーズにするというこの目標は、非常に野心的なものです。Prisma Clientを使用したサーバーレス関数の起動パフォーマンス向上に専念する素晴らしいチームがいますが、この取り組みに貢献したいと熱望している大規模な開発者コミュニティがあることも認識しています。
特にサーバーレスの起動時間という文脈において、Prisma Clientのパフォーマンス向上にご協力をお願いいたします。
サーバーレス環境やエッジで利用可能な世界クラスのORMを提供するというこの目標に貢献できる方法はたくさんあります。
- サーバーレスでPrismaを使用する際に見つけた課題を提出してください。
- 改善のアイデアはありますか?ディスカッションを開始してください!
jsonProtocol
プレビュー機能を試して、フィードバックを提供してください。
Prisma ORMはオープンソースプロジェクトであり、私たちはコミュニティからのフィードバックと参加の重要性を十分に理解しています。私たちは、すべての開発者の利益のためにPrismaを前進させるのに役立つフィードバック、批判、質問、そしてあらゆるものを歓迎します。
次の投稿をお見逃しなく!
Prismaニュースレターに登録する