🐡

Vercel PostgresがどうやってEdge RuntimeでORMとコネクションプールを使えるようにしているのか

2023/05/03に公開

TLTR

  1. 実行時にNeon serverless driver(@neondatabase/serverlessモジュール)node-postgres(pgモジュール)内のSocketクラスをWebSocket実装に置き換える
  2. WebSocket接続を受けたneon.techサーバーがTCP接続に変換してPgBouncerに接続し応答する

Neon serverless driverの解説記事が以下にあります。

https://neon.tech/blog/serverless-driver-for-postgres

Edge RuntimeでNode.jsのSocket APIがサポートされていない問題

Node.jsのORMライブラリはPostgreSQLへの接続にnode-postgresからSocket APIを呼び出しますが、Edge Runtimeは互換性の問題からそのままでは動作しません。

これに対して、各マネージドDBのプロバイダーは専用ライブラリを提供してHTTP経由でDBにアクセスする方法が一般的です(Supabaseなど)。

他に例えばPrismaでは中間にAPIサーバーを立ててORMのインターフェイスはそのままに裏側をHTTPベースの通信にするData Proxyというサービスがあります。

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

しかしVercelのドキュメントを読むとそういった変換レイヤーを挟まないでもEdge RuntimeでKyselyDrizzle ORMがそのまま動作するようなことが書かれています。

https://vercel.com/docs/storage/vercel-postgres/using-an-orm

これは一体どういうことなのか気になるので、それぞれのORMライブラリのサポートが詳細がどうなっているのか見ていきます。

Kysely

これがDB作成時に提示されるKyselyのサンプルコードです。@vercel/postgres-kyselyというラッパーがいかにも何かやってそうなので見てみます。

import { createKyselyPool } from "@vercel/postgres-kysely";

interface Database {
  person: PersonTable; // see github.com/kysely-org/kysely
  pet: PetTable;
  movie: MovieTable;
}

const db = createKyselyPool<Database>();

const person = await db
  .selectFrom('person')
  .innerJoin('pet', 'pet.owner_id', 'person.id')
  .select(['first_name', 'pet.name as pet_name'])
  .where('person.id', '=', id)
  .executeTakeFirst();

@vercel/postgresを内部で呼び出しているだけというのが分かりました。

https://github.com/vercel/storage/blob/781126a9751aaa04bc4ceb128e96c2a0542ff8e8/packages/postgres-kysely/src/index.ts#L4

https://github.com/vercel/storage/blob/781126a9751aaa04bc4ceb128e96c2a0542ff8e8/packages/postgres-kysely/src/index.ts#L67-L74

@vercel/postgres

createPool() を起点に読み進めます。

https://github.com/vercel/storage/blob/781126a9751aaa04bc4ceb128e96c2a0542ff8e8/packages/postgres/src/create-pool.ts#L69

export function createPool(config?: VercelPostgresPoolConfig): VercelPool {
  // ...
  return new VercelPool({
    ...config,
    connectionString,
    maxUses,
  });
}

Poolというのは@neondatabase/serverlessを継承したクラスでした。

https://github.com/vercel/storage/blob/781126a9751aaa04bc4ceb128e96c2a0542ff8e8/packages/postgres/src/create-pool.ts#L17-L18

import { Pool } from '@neondatabase/serverless';
// ...
export class VercelPool extends Pool {
  Client = VercelClient;
  // ...
}

@neondatabase/serverless

知りたい部分に近付いてきました。

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/README.md#L1-L3

このモジュールはファイル構成が特殊で src/ にインテグレーションテストのようなコード、shims/ 以下にNode.js標準ライブラリと同じ名前のモジュール群があり、中身が空のものもいくつかあります。

ls shims/
assert         dns            net            pg-native      stream         tls            util
crypto         fs             path           shims.js       string_decoder url

READMEにこう書いてありましたので該当ファイルを見てみます。

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/README.md#L147-L148

Sockerクラスの実装でWebSocketっぽいのを発見。

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/shims/net/index.ts#L58-L67

EventEmitterはEdge RuntimeのNodeコンパチのものが使えそうだけど見たところbrowserify/eventsを読み込んでいた。

socket.connect()を再定義して——

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/shims/net/index.ts#L149-L213

pgモジュールの名前空間を上書きしてる

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/export/index.ts#L1-L3

https://github.com/neondatabase/serverless/blob/aa5eb0eb75139a853d68c40210bdce67acfc48ce/export/index.ts#L154-L171

これでつまりこういうコードの裏側でWebSocket接続の呼び出しになるということです。

const { Client } = require('pg')
const client = new Client()
await client.connect()
 
const res = await client.query('SELECT $1::text as message', ['Hello world!'])
console.log(res.rows[0].message) // Hello world!
await client.end()

そして

  1. Edge Runtime環境ではNode.jsの net.connect() の中身は動かないけど差し替えた new WebSocket() の実装なら呼び出し可能
  2. neon.techサーバーはWebSocketを待ち受けしてDBに接続できる

ということですね。

https://developers.cloudflare.com/workers/learning/using-websockets/

Drizzle ORM

Kysely以外にもDrizzle ORMの名前もあったのでどんな感じなのか調べてみます。

Drizzle ORMはドキュメントに記載がまだなかったのですがリポジトリを漁っていたらサンプルが出てきました。

https://github.com/vercel/examples/blob/d9f41e726f7d2a19a16ccab8335f0bd507a0a89c/storage/postgres-drizzle/lib/drizzle.ts#L9

import { sql } from '@vercel/postgres'
import { drizzle } from 'drizzle-orm/vercel-postgres'
// ...
export const db = drizzle(sql)

drizzleの場合はVercelのリポジトリではなくdrizzle側でvercel-postgresアダプタを実装しているみたいです。

https://github.com/drizzle-team/drizzle-orm/blob/fb66e3c75948f9b60cf85af600fd76a82902fde6/drizzle-orm/src/vercel-postgres/driver.ts#L11-L18

https://github.com/drizzle-team/drizzle-orm/blob/fb66e3c75948f9b60cf85af600fd76a82902fde6/drizzle-orm/src/vercel-postgres/session.ts#L1-L8

https://github.com/drizzle-team/drizzle-orm/blob/fb66e3c75948f9b60cf85af600fd76a82902fde6/drizzle-orm/src/vercel-postgres/session.ts#L17-L30

内部で@vercel/postgresが使われていました。ということは@vercel/postgres→@neondatabase/serverlessの依存になり、内部接続はWebSocketということで間違いないようです。

Socket APIの謎が解けたので次はコネクションプーリングの問題について確認します。

サーバーレス環境とコネクションプーリング

Vercelのようなサーバーレス環境では起動させっぱなしのインスタンスからDBに持続的にコネクションを張り続けるというプロセスがなく、リクエスト毎にDB接続がopen/closeされ、複数のリクエスト間でコネクションを使い回すことは保証されていません(プラットフォームごとにグローバル変数を使ったキャッシュテクニックなどはある)。

https://vercel.com/guides/connection-pooling-with-serverless-functions

https://zenn.dev/laiso/scraps/595d631fe19ef5

しかし@vercel/postgresのPoolClientを使った場合は、内部でNeon serverless driverの実装のWebSocket接続を通して、neon.techサーバー内でTCP接続に変換され、その背後のPgBouncerによって維持されたPostgreSQLへのコネクションが取得できます。

https://neon.tech/docs/connect/connection-pooling

全体像はこうなります

※WebSocketとTCPの変換レイヤーはNeonが自社で開発するwsproxyを使われています。

コールドスタート

ただしVercelのドキュメントにあるとうりこれはインスタンスがスタンバイ状態である時のシナリオで、5分以上経過して停止している場合はコールドスタートとなり起動時間が最大5秒上乗せされます。

An inactive Vercel Postgres database may experience cold starts. If your database is not accessed within a 5 minute period, your database will be suspended. The next time it is accessed, you will experience a "cold start" of up to 5 seconds. https://vercel.com/docs/storage/vercel-postgres/limits

Q.1: WebSocketプロトコル必要?

リモート接続できて非同期にStreaming形式の応答があればよさそうなのでWebSocketが丁度よいのではないか。

比較対象はSupabseで採用されているPostgRESTなのかも。

https://postgrest.org/en/stable/

Q.2: Neonにロックインされるのでは?

@neondatabase/serverlessが必要なのはWebSocket経由でEdge Runtimeから接続する時だけなので、プラットフォーマー以外はNeonではないPostgreSQLサーバーを使いたくなったら単にNode.jsでpgモジュールをそのまま使うことができます。

Edge Runtimeからどうしても自社のPostgreSQLサーバーに接続したいなら前述のwsproxyを立てるとセルフホストも可能です。

Q.3: PrismaはWebSocketで動くの?

PrismaはこのNeonのアーキテクチャには対応していないようです。

かわりにPrisma Data Proxyを自分で用意して裏側をVercel Postgres(POSTGRES_URL_NON_POOLING)にすると prisma//... のホスト指定でEdge Runtimeで動かすことができそうです。

Note: Prisma does not currently support connections with Edge Functions except with the Prisma Data Proxy. We recommend connecting using Kysely or Drizzle instead. https://vercel.com/docs/storage/vercel-postgres/limits

との記述があります。

余談: cloudflare/workerdの動向

workeredへTCP接続のAPIが入り、将来的にCloudflare WorkersからNode.js互換のSocket APIが使えるようになったら、WebSocket接続の部分は置き換え可能なので、状況が変わるかもしれません。

https://zenn.dev/laiso/articles/02f49dbde85092

まとめ

Vercel PostgresがEdge RuntimeでORMとコネクションプールを使えるようになっているのはだいたいNeonのおかげだった。Neonすごい。

https://neon.tech/

参考

https://developers.cloudflare.com/workers/learning/integrations/databases/

Discussion