Cloudflare WorkersからPostgres.jsを使ってPostgreSQLに接続する際、特定条件下でエラーが発生しました。原因と解決策を備忘録としてまとめます。
参照ドキュメントについて
実装にあたり、以下のドキュメントおよびサンプルコードを参考にしました。
- Connect to a PostgreSQL database with Cloudflare Workers · Cloudflare Workers docs
- porsager/postgres: Postgres.js - GitHub
公式ドキュメント等では、以下のようにグローバルスコープでクライアントを初期化するパターンが多く見られます。
// db.js
import postgres from 'postgres'
const sql = postgres({ /* options */ }) // will use psql environment variables
export default sql
発生した問題
上記のパターンを参考に以下のコードをデプロイしたところ、デプロイ直後の1回目のリクエストは成功するものの、数秒後に再度リクエストを送るとエラーになるという現象が発生しました。
修正前のコード(エラー発生)
DB接続用のクライアントをグローバルで定義し、コントローラー内でクエリ実行後に sql.end() を呼び出していました。
import postgres from 'postgres';
// グローバルスコープで初期化
export const sql = postgres({
host: 'db.example.com',
port: 5432,
database: 'my_database',
username: 'my_username',
password: 'my_password',
});
// getCustomerById.controller.ts
import {Context, Next} from "hono";
import {Customer} from "../../type/shared.type";
import {RowList} from "postgres";
import {sql} from "../../service/db.service";
export async function getCustomerByIdController(c: Context, next: Next): Promise<Response> {
try {
const customerId = c.req.param('customerId')
const customers: RowList<[Customer]> = await sql`
select *
from my_database.customer
where id = ${customerId};
`;
console.log(customers);
// ここで接続を閉じている
await sql.end();
return c.text(`ok`, 200)
} catch (error) {
console.error(error)
return c.text(`error`, 400)
}
}
エラーログの確認
wrangler tail でログを確認すると、2回目以降のリクエストで CONNECTION_ENDED が発生しています。
OPTIONS https://api.example.com/customer/1 - Ok @ 2024/12/10 15:37:27
GET https://api.example.com/customer/1 - Ok @ 2024/12/10 15:37:27
(log) getCustomerByIdController
(error) Error: write CONNECTION_ENDED api.example.com:5432
Workersのライフサイクルとグローバル変数
このエラーの原因は、Cloudflare Workersのインスタンスが再利用(ウォームスタート)されるケースの考慮不足に問題があります。
- Cloudflare Workersは、パフォーマンス向上のため、一度起動したインスタンスを一定期間メモリ上に保持し、次のリクエストで再利用します。
- グローバル変数(
export const sql)も、リクエスト間で共有・維持されます。 - 1回目のリクエスト:
sql.end()が呼ばれ、接続が閉じられます。 - 2回目のリクエスト: 同じインスタンスが使い回されますが、
sqlオブジェクトは「接続終了済み(ended)」の状態のままです。 - 閉じた接続に対してクエリを投げようとしたため、
write CONNECTION_ENDEDエラーが発生しました。
リクエストごとに接続を生成する
今回は、ハンドラー(リクエスト)のスコープ内で都度 postgres クライアントを生成し、使用後に閉じるように修正しました。
// db.service.ts
import postgres from 'postgres';
import {Context} from "hono";
// 関数としてエクスポートし、都度インスタンスを作成する
export function createSql(c: Context) {
return postgres(
{
host: c.env.DB_HOST,
port: c.env.DB_PORT,
database: c.env.DB_DATABASE,
username: c.env.DB_USERNAME,
password: c.env.DB_PASSWORD,
});
}
// getCustomerById.controller.ts
import {Context, Next} from "hono";
import {Customer} from "../../type/shared.type";
import {RowList} from "postgres";
import {createSql} from "../../service/db.service";
export async function getCustomerByIdController(c: Context, next: Next): Promise<Response> {
try {
const customerId = c.req.param('customerId')
// リクエストスコープ内で生成
const sql = createSql(c)
const customers = await sql`
select *
from my_database.customer
where id = ${customerId};
`;
console.log(customers);
// 使用後に閉じる
await sql.end();
return c.text(`ok`, 200)
} catch (error) {
console.error(error)
return c.text(`error`, 400)
}
}
パフォーマンスに関する考慮
現在の修正方法だと、都度接続と切断をするため、パフォーマンスが良くないです。そのため、以下のような選択肢があることも考慮しておくと良いと思います。
- 【sql.end() を呼ばない】 グローバル変数の定義に戻し、
sql.end()の呼び出しを削除します。これにより、インスタンスが生きている間は接続を使い回すことができます(Postgres.jsはアイドル接続を適切に処理します)。 - 【Cloudflare Hyperdrive】 接続プーリングサービスである Hyperdrive を利用し、接続のオーバーヘッドを減らします。
用途に合わせて最適な実装を選択していきたいと思います。
コメントを投稿する
記事への感想やフィードバックなど、お気軽に書き込んでいただけると嬉しいです。
Googleログイン または 匿名 で利用できます。また、自分のコメントは後から編集・削除が可能です。
データは管理者が運用するサーバー(https://comentario.teruhirokomaki.com)に保存・管理されます。