無料で始めた本番環境の話
家族で使う生涯収支のWebアプリを、Next.jsとPrismaで書いてRenderに置いた。render.yamlを1枚書けば、Webサービスと PostgreSQLがまとめて作られ、DATABASE_URLも自動で結ばれる。Googleアカウントで家族だけがログインできるようにして、月額ゼロ円で本番が動いた。4月の時点では、これで十分だと思っていた。
databases:
- name: shokai-navi-db
plan: free
services:
- type: web
name: shokai-navi
runtime: node
plan: free
buildCommand: npm ci && npx prisma generate && sh ./scripts/migrate-with-retry.sh && npm run build
buildCommandにmigrate-with-retry.shが挟まっているのが、この記事の前半だ。
接続が一過性に切れる。Prismaの P1017
動かし始めてすぐ、ログにP1017 Server has closed the connectionが出るようになった。PrismaがDBに投げたクエリの途中で、サーバー側から接続を閉じられるエラーだ。Renderの無料PostgreSQLは、しばらく使われないと接続を落とし、次の問い合わせがその死んだ接続を引いて失敗する。家族が1日に数回しか開かないアプリでは、ほぼ毎回「最初の1回」がこれに当たった。
まずPrismaのコネクションプールの設定を保守的にした。idleTimeoutMillisを30秒から10秒へ、maxLifetimeSecondsを60秒から30秒へ縮めて、死んだ接続を早く捨てて頻繁に作り直す。TCPのkeepaliveも5秒で始めるようにした。これだけでは消えなかった。
次に2段の防御を足した。モジュール読み込み時にSELECT 1を投げて接続を温める。失敗してもアプリは続行し、警告ログだけ出す。それからPrismaの$extendsで全オペレーションを包み、P1017を検出したら1回だけ再試行する。再試行の時点ではプールが死んだ接続を弾いている可能性が高いので、1回で足りる。レビューで、再試行の対象は読み取り系に絞った。書き込みをやみくもに2回投げるわけにはいかない。
ここまでで実行時のP1017はほぼ収まった。
ビルドの中でも切れる
次に失敗したのはデプロイだった。buildCommandのprisma migrate deployがP1017で落ち、デプロイ全体が失敗する。実行時に入れたプールの設定も$extendsの再試行も、Prisma CLIのマイグレーションには効かない。CLIは自分で接続を張る。
最初はYAMLの中にリトライのワンライナーを書いた。これが2度目のデプロイで再びP1017で落ちて、しかも5回ループした形跡がログに無い。YAMLの中でシェル構文がどう解釈されたのか追う気にならず、シェルスクリプトのファイルに切り出した。
while [ "$i" -le "$ATTEMPTS" ]; do
echo "[migrate] attempt $i / $ATTEMPTS"
if npx prisma migrate deploy >"$output_file" 2>&1; then
cat "$output_file"; exit 0
fi
cat "$output_file"
if ! grep -q "P1017" "$output_file"; then
echo "[migrate] non-P1017 failure; aborting without retry"; exit 1
fi
sleep 10
i=$((i + 1))
done
リトライするのはP1017のときだけにした。スキーマの不整合やSQLの文法エラーまで5回やり直すと、ログが汚れるうえに50秒無駄になる。試行回数を毎回ログに出すのは、再試行が効いているかをビルドログで目視できるようにするためだ。ワンライナーのときは、それが分からなくて困った。
90日で消える、と書いてあった
P1017との付き合い方が固まった頃に、別の事実に気づいた。Renderの無料PostgreSQLは、作成から90日で期限が切れる。期限が来ると停止し、有料プランに上げなければそのまま削除される。無料枠の説明に最初から書いてあったのだが、render.yamlでplan: freeと書いたときには読んでいなかった。
家計のデータを載せたDBが、放っておくと3か月で消える。バックアップを取ればいい話にも見えるが、無料のDBには自動バックアップも付いていない。90日ごとに作り直して流し込む運用を続ける気にはなれなかった。
ここで、接続の不安定さと期限の問題をまとめて解決する方向に切り替えた。アプリ本体はVercelのHobbyプラン、DBはNeonの無料枠にする。Neonはサーバーレスの PostgreSQLで、無料枠に期限は無い(容量の上限はある)。VercelのMarketplaceから直接つなげられる。
移行で引っかかった3つ
1つ目は接続文字列が2本要ることだ。Neonはpgbouncerを経由するpooled接続と、直接つなぐdirect接続を別のURLで出す。実行時のクエリはpooledでいいが、prisma migrateはpgbouncer経由では動かない。Prismaの設定ファイルで、マイグレーションにはDIRECT_URLを優先して使うようにした。
const MIGRATION_URL =
process.env.DIRECT_URL ?? process.env.DATABASE_URL;
2つ目はスキーマだ。Render時代は共有DBの中にshokai_naviというスキーマを切っていて、その癖で接続文字列に?schema=shokai_naviを付けた手順を書いた。ところがマイグレーションはpublic前提で書かれていて、この手順どおりに進めるとデプロイが壊れる。後から手順をpublicに直し、代わりに?sslmode=requireを付けることにした。自分で書いた手順書に自分で騙されかけた。
3つ目はデータの移し替えだ。アプリの設定画面に、全データをJSONで書き出す機能を先に作ってあった(家族のデータなので、いつでも持ち出せるようにしておきたかった)。これを読む取り込みスクリプトを書き、upsertで新しいDBへ入れる。自動採番の列はsetvalでシーケンスを揃え直さないと、移行後の最初のINSERTで主キーが衝突する。監査ログは移さず、旧DBに残した。
Vercel側のビルドにも同じmigrate-with-retry.shを通している。Neonはしばらく使わないとコンピュートが止まり、起動直後の接続が遅れる(コールドスタート)。ビルドログに[migrate] attempt N / 5が出ていれば、リトライが仕事をしている。
無料枠は期限付きの試用として読む
Renderが悪いという話ではない。無料枠の条件はすべて公開されていて、読まなかったのは自分だ。ただ、P1017への対策を4段も積んだ時間を振り返ると、最初に無料枠の条件を読んで「本番に使えるか」を判断しておけば、プールの設定をいじる前に移っていたと思う。
個人開発で無料のマネージドDBを使うときに、今はこう考えている。期限と自動バックアップの有無を最初に確かめる。アプリにデータの書き出し機能を、移行が必要になる前に付けておく。マイグレーションのリトライは、CLIが自分で接続を張ることを前提に、ビルド側に別途入れる。この3つは、どのホスティングに移っても持ち越せた。