QUEST LOG · December 20, 2025· UPDATED October 29, 2026
コネクションプールに注意:serverless から DB につなぐときの小さな落とし穴
従来のバックエンドでは常識だったコネクションプールも、serverless に持ってくると地雷になります。
「客家エンジニアの生存法則」の記事で少し触れましたが、書き終えてみると「爆発する」としか言っておらず、「なぜ爆発するのか、どう書けば爆発しないのか」には触れていませんでした。この記事ではこの落とし穴をしっかり掘り下げて、ついでにそのままコピペできる書き方もまとめておきます。
# コネクションプールの前提:プログラムがずっと生きていること
従来の Node / Go / Java のバックエンドだと、コネクションプールはだいたいこんな感じです。
// server.ts —— 開機時建一次,之後所有 request 共用
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
app.get('/posts', async () => {
const { rows } = await pool.query('select * from posts')
return rows
})
これが動くのは、明文化されていないひとつの前提があるからです。この process は長く生き続ける、ということ。起動時に少し時間をかけて TCP 接続を 10 本張っておけば、あとは 1 万件の request が順番に借りては返すだけで、DB 側から見える接続はずっと 10 本のままです。
serverless は、この前提を丸ごと取り払ってしまいます。
# Workers ではどう壊れるか
上のような書き方を初めて Cloudflare Workers に持っていったとき、直感的に「module scope で pool を 1 回作っておけば、今までと同じでしょ」と思いました。結果、3 つの問題にぶつかります。
1. 接続を request をまたいで再利用できない
Workers の I/O オブジェクト(TCP socket を含む)は、それを作った request に紐づいています。前の request が開いた接続を次の request で使おうとすると、いきなりこう怒られます。
Error: Cannot perform I/O on behalf of a different request.
つまり、module scope の pool は、Workers ではそもそも元のやり方で共有できません。
2. インスタンス数は自分で決められない
request をまたいで接続を保持できる serverless プラットフォームであっても、トラフィックが来ればプラットフォームが一気にインスタンスを増やし、それぞれが自分の pool を持ちます。インスタンス 10 個 × 各 10 本で、接続は 100 本。Postgres のデフォルトの max_connections は 100 なので、管理画面や migration、cron の分はまだ計算にすら入っていません。
3. インスタンスが回収されるとき、接続がちゃんと閉じられるとは限らない
インスタンスは容赦なく回収されます。end() されなかった接続は DB 側に残り、timeout になるまで掃除されません。ピークが過ぎると DB にはゾンビ接続が大量にぶら下がっていて、次のトラフィックの波はそのまま拒否されます。
一番つらいのは、普段はすべて正常で、トラフィックが多いときだけ爆発することです。 そしてトラフィックが多いときこそ、一番生きていてほしいときなんですよね。
# 解決策 1:Hyperdrive で、コネクションプールを DB の隣に移す
どうしても Postgres を使いたいなら、Cloudflare の Hyperdrive が一番手間のかからない解決策です。Cloudflare 側でコネクションプールを管理してくれるので、Worker は request ごとに DB へ直接つなぐのではなく、Hyperdrive につなぐだけで済みます。
ポイントは、client は request の中で作り、使い終わったら waitUntil の中で閉じることです。
import postgres from 'postgres'
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// 每個 request 自己建 client;真正的連接池在 Hyperdrive 那一側
const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false })
const posts = await sql`select id, title from posts order by published_at desc limit 20`
// 回應送出後再關連線,不擋住使用者
ctx.waitUntil(sql.end())
return Response.json(posts)
},
}
wrangler.jsonc に binding を 1 つ追加します。
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<your-hyperdrive-id>" }]
こうすれば、DB から見える接続数は Hyperdrive の pool のサイズだけになり、Worker のインスタンス数と一緒に膨れ上がることはなくなります。
# 解決策 2:HTTP 経由のデータベース driver
もう 1 つの道は、いっそ長時間つなぎっぱなしの接続を使わないことです。Turso(libSQL)や Neon の serverless driver なら、クエリ 1 回をそのまま 1 回の HTTP リクエストにできます。
import { createClient } from '@libsql/client/web'
export default {
async fetch(request: Request, env: Env) {
const db = createClient({ url: env.TURSO_URL, authToken: env.TURSO_TOKEN })
const { rows } = await db.execute('select id, title from posts limit 20')
return Response.json(rows)
},
}
管理すべき TCP 接続がないので、コネクションプールの問題もありません。その代わり、クエリごとに HTTP のレイテンシが少し増えますし、トランザクション(transaction)の書き方もその driver がサポートする形に変える必要があります。このブログも以前 Turso につないでいたときは、この方法でした。
# 解決策 3:そもそもデータベースを使わない
最後の道は一番力技ですが、個人プロジェクトの多くには実はこれが一番合っています。データ量が少なく、書き込み頻度も低いなら、データベースはいらない。
このブログが今まさにそうです。記事一覧は R2 上の index.json ひとつ、本文は Markdown ファイルの集まりで、フロント側は R2 の読み取り専用権限しか持っていません。接続もなければ pool もなく、max_connections もない。なので、この記事で話している落とし穴はひとつも存在しません。
# どう選ぶか
| 状況 | おすすめ |
|---|---|
| すでに Postgres があり、リレーショナルなクエリやトランザクションが必要 | Hyperdrive + request ごとに client を作る |
| 新規プロジェクト、データは複雑でなく、インフラ管理を減らしたい | Turso / Neon のような HTTP driver |
| 個人サイト、書き込みは月に数回 | データベースは不要、KV か R2 + JSON で十分 |
# ひとことでまとめると
コネクションプールは「長生きする process」のために設計されたものです。 serverless の process は長生きしないうえに、数も自分では管理できません。だから pool はプラットフォームに任せる(Hyperdrive)か、pool のいらない接続方法に変える(HTTP driver)か、いっそデータベースごとやめるか、のどれかです。
ここをきちんと押さえておけば、トラフィックが一番多い夜に too many connections を眺めながら人生を見つめ直す羽目にならずに済みます。
公開中のアプリ
iOS・Android で無料。


