Teruhiro Komaki

日々の暮らし、技術的な学び、そして仕事の記録

Cloudflare WorkersとPostgres.jsで「write CONNECTION_ENDED」エラーが出た原因と対処法

Cloudflare WorkersからPostgres.jsを使ってPostgreSQLに接続する際、特定条件下でエラーが発生しました。原因と解決策を備忘録としてまとめます。

参照ドキュメントについて

実装にあたり、以下のドキュメントおよびサンプルコードを参考にしました。

公式ドキュメント等では、以下のようにグローバルスコープでクライアントを初期化するパターンが多く見られます。

// db.js
import postgres from 'postgres'

const sql = postgres({ /* options */ }) // will use psql environment variables

export default sql
Postgres.jsのGitHubより引用

発生した問題

上記のパターンを参考に以下のコードをデプロイしたところ、デプロイ直後の1回目のリクエストは成功するものの、数秒後に再度リクエストを送るとエラーになるという現象が発生しました。

修正前のコード(エラー発生)

DB接続用のクライアントをグローバルで定義し、コントローラー内でクエリ実行後に sql.end() を呼び出していました。

db.service.ts
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)
    }
}
getCustomerById.controller.ts

エラーログの確認

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
2回目以降のリクエストログ

Workersのライフサイクルとグローバル変数

このエラーの原因は、Cloudflare Workersのインスタンスが再利用(ウォームスタート)されるケースの考慮不足に問題があります。

  1. Cloudflare Workersは、パフォーマンス向上のため、一度起動したインスタンスを一定期間メモリ上に保持し、次のリクエストで再利用します。
  2. グローバル変数(export const sql)も、リクエスト間で共有・維持されます。
  3. 1回目のリクエスト: sql.end() が呼ばれ、接続が閉じられます。
  4. 2回目のリクエスト: 同じインスタンスが使い回されますが、sql オブジェクトは「接続終了済み(ended)」の状態のままです。
  5. 閉じた接続に対してクエリを投げようとしたため、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,
		});
}
db.service.ts(修正後)
// 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)
    }
}
getCustomerById.controller.ts(修正後)

パフォーマンスに関する考慮

現在の修正方法だと、都度接続と切断をするため、パフォーマンスが良くないです。そのため、以下のような選択肢があることも考慮しておくと良いと思います。

  • 【sql.end() を呼ばない】 グローバル変数の定義に戻し、sql.end() の呼び出しを削除します。これにより、インスタンスが生きている間は接続を使い回すことができます(Postgres.jsはアイドル接続を適切に処理します)。
  • 【Cloudflare Hyperdrive】 接続プーリングサービスである Hyperdrive を利用し、接続のオーバーヘッドを減らします。

用途に合わせて最適な実装を選択していきたいと思います。


コメントを投稿する

記事への感想やフィードバックなど、お気軽に書き込んでいただけると嬉しいです。

Googleログイン または 匿名 で利用できます。また、自分のコメントは後から編集・削除が可能です。

データは管理者が運用するサーバー(https://comentario.teruhirokomaki.com)に保存・管理されます。