

1/1/1970
Firestore から Postgres へ 2 日で移行する方法
Firebase。この一言だけで、安価なデータベースの 10〜20 倍のコストが簡単にかかってしまい、データベース設計を制約し複雑化させることもあります。
だから、たった 2 日で Firestore からの移行を終えられたときの安堵感は想像に難くないでしょう。まあ、最初の「PostgresSynchronizer」を作るのに費やした時間を含めれば 3 日。ワークショップシステムの移行に費やした時間を含めれば 3.5 日。いずれにしても、速かったです。本当に速かった。
このプロジェクトの当初の見積もりは丸 1 ヶ月の作業量でした! ところが、素晴らしい LLM プロンプトと、CDC について教えてくれた友人のおかげで、はるかに速く完了できました。
pub/sub が欲しい場合、Firebase や Supabase はとても魅力的に聞こえるかもしれません。なにせ、pub/sub を標準でサポートしているデータベースは多くありませんから。でも、ほとんどの中小規模ビジネスのユースケースには、以下のシンプルなアーキテクチャで十分です (秒間およそ 1 万〜10 万オペレーションまでなら問題なくスケールします)。
ただ、pub/sub の仕組みの詳細に入る前に、まず私が踏んだ手順を順番に説明します。
最初に、Cursor で PostgresSynchronizer を構築するためのプロンプトを作成しました。このクラスには handleWrite という関数があり、Firestore のパス、操作の種類 (作成、更新、削除)、データを受け取ります。そして、データを Postgres にアップサートします。次に、Firestore ストアのインスタンスをラップして、Firestore への変更があれば handleWrite も呼び出されるようにしました。
もちろんこれは 100% 完璧ではありません。サーバーが切断したりクラッシュしたりすると、いくつかの細かい変更が永続化されない可能性があります。しかし Foony のユースケースには十分ですし、Postgres スキーマは Firestore よりも優れたデータ整合性の保証 (例えば外部キー制約) を与えてくれます。
次に、すべての Firestore コレクションを反復処理し、各ドキュメントに対して handleWrite を呼び出すバックフィルを作成して実行しました。これにより、Firestore からすべての過去データを取得できます。速度のために、登録ユーザーのみに限定しました (ゲストの皆さんごめんなさい. サインアップしておくべきでしたね)。これはうまく機能し、複数回実行しても安全です。
これで Postgres にデータを投入し、Firestore と (ほぼ) 同期させる方法ができたので、大きく恐ろしい問題、pub/sub に取り組むことができました。
Postgres で、あるいはどんな SQL データベースであれ、pub/sub を一体どうやって実現するのか?
Postgres Change Data Capture (CDC) の登場!
CDC というのは、要するに「データベースに加えられた変更を読み取り、pub/sub システムに公開する」ことを意味する仰々しい言葉です。大企業なら Debezium + Kafka のようなものを使うかもしれません。しかし Kafka はセットアップが面倒で、私たちはすでに Redis を使っており、データベースは秒間約 30 オペレーションしか受けません。たとえスケールを 100 倍にして同時接続ユーザー約 10 万人になったとしても、おそらく単一の Redis インスタンスに収まります。そういうわけで、そうしました。
(Postgres に LISTEN/NOTIFY で pub/sub を組み込むこともできますが、再接続を生き延びませんし、実際にファンアウトが必要になると破綻します。CDC は地味で信頼できる答えです。)
私の友人Ericが CDC について教えてくれて、彼の CDC コードをオープンソース化してくれました。これは論理レプリケーションスロット経由で Postgres の WAL (Write-Ahead Log. Postgres がデータベースに加えるすべての変更を書き込む場所) を読み取り、シンク (例: Redis) に永続化します。
Cursor でのシンプルで詳細なプロンプトにより、リアルタイム CDC とゲートウェイのコードをほぼワンショットで生成できました (多少の微調整と徹底的なコードレビューを経て)。これには 2 つの非常にシンプルなサービスが含まれます:
- WAL を読み取って Redis Pub/Sub に公開するシングルトンの CDC サービス (Streams を使えばさらに耐久性を高められますが、複雑さと引き換えになります)
- JWT 認証と Web クライアントからの WebSocket 接続を処理する、水平方向にオートスケールするゲートウェイサービス。
両方とも golang で書かれており、特にオートスケーリングするゲートウェイ部分が気に入っています。Web クライアントは Firestore のときと同じ方法で購読し、同じフォーマットでデータを受け取ります。クライアントから見ると「usersPublic/」と「usersPrivate/」は異なる権限を持つ別々のコレクションです。ゲートウェイはそのリクエストを基盤となる Postgres テーブル (この場合は users) に変換し、クライアントがそのデータへのアクセス権限を持っているかを検証する責任を負います。
これは驚くほどうまく機能します。pub/sub システム全体のコンピュート + 下り帯域コストはわずか月約 0.50 ドルで、壊れる可動部品も少ないです (CDC、ゲートウェイ、クライアントコードはすべてシンプルで、Redis / Postgres 以外のサードパーティ依存はありません)。
DevEx の観点でも、新しいシステムは間違いなくシンプルです: 開発者は Firestore でのデータモデリングや Firestore セキュリティルールを知る必要がありません。SQL を理解していれば、schema.sql に素早く編集を加え、all.go でルートを追加または修正すれば完了です。ドキュメントも十分しっかりしているので、LLM もそれに従って簡単に変更できます。セキュリティ面でも勝ちです。新しいインフラは固定費かつ非常に安価なので、危険な denial-of-wallet (Wallet DoS) 攻撃にさらされなくなりました。
Postgres インスタンスと日次の S3 バックアップを含め、データベースインフラコストは月 550 ドルからわずか月 40 ドルまで減らせました。クライアントのバンドルサイズも 約 100KB 削減できました。これも嬉しいですね。
新しいシステムができたので、ローカルでテストしてすべて期待通りに動くことを確認しました。それから本番にデプロイしました。何か問題があっても、この時点ではまだ Firestore がソース・オブ・トゥルースなので、クライアントを簡単にロールバックできます。
本番切り替え
サーバー切り替えはちょっと怖かったです (データベースマイグレーションは常にそうですが)。この時点までは、Firestore がまだソース・オブ・トゥルースでした. CDC pub/sub システムは、同期サービスが最新の状態に保つ Postgres ミラーから読み取っていただけです。新しいゲームサーバーをデプロイすることで、書き込みについて Postgres が権威となるよう実際に切り替わります。
ロールアウトの手順は以下の通りです:
- パートナーを先にアップデート。 Foony は FRVR などのパートナーと統合されているので、新しい CDC ゲートウェイを使う新しいクライアントビルドを数日前にデプロイしました。
- プレイヤーに警告。 切り替えの約 10 分前に、(願わくは) 短時間のメンテナンスウィンドウについてお知らせを投稿しました。
- 新しいデータベースバックアップを取得。 大規模なデータベースマイグレーションでは常に良いアイデアです。LLM はこれを「ベルトとサスペンダー」と呼びます。要するに、念には念を入れてバックアッププランを持つということです。
- 両クラスターを同時にデプロイ。 今回はブルー/グリーンなし。すべてのクラスターが同じデータベースに書き込むようにして、潜在的な不整合を避けたかったんです。
ロールバックプランも用意していました。とてもシンプルです: 何かおかしくなったら、以前のクライアント (まだ Firestore に読み書きする) を再デプロイし、サーバーを再デプロイし、バックフィルを再起動して、後でまた試す、というものです。これだとサーバーの再デプロイ中に約 5 分のダウンタイムが発生し、Postgres データベースを Firestore に追いつかせるのに約半日かかったでしょう。
実際の切り替えはわずか約 1 分のダウンタイムで済みました。表面化した唯一の深刻なバグは経験値の更新でした: Postgres の LOWER() が bigint カラムに対して暗黙的に text にキャストしていたのです。はぁ。bigint への単純なキャストで簡単に修正できたので、もう一度サーバーをデプロイしてマイグレーションを続行しました。
すべてがスムーズに進んでいるように見えて驚きました。問題を報告した人はごくわずかで、それもすべて非常に小さなものでした (経験値の件を除いて)。特に衝撃的だったのは、このマイグレーションの大部分が vibe コーディングだったにもかかわらず、これほどスムーズに進んだことです。ニュースで読むような怖い話とはずいぶん違いますね。
ワークショップの書き直し
Postgres が書き込みについて権威となった今、コードベースで Firestore に絡んでいる大きな部分はあと一つだけ残っていました: ワークショップシステムです。プレイヤーは Dino-Might Bomber Online でカスタムマップを共有したり、Draw & Guess で単語リストを共有したりするために使います。Steam Workshop のようなものですが、私たちのゲーム向けです。クライアントとサーバーの両方で最も Firestore に絡んだ機能で、Firestore のデータモデルの制限により、簡素化する必要のある不自然な構造になっていました。
まず、Cursor に次のプロンプトを与えました (Opus 4.7 high をプランモードで使用):
Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.
We'll need to update schema.sql to support the new workshop tables.
Think of the workshop sort of like Steam's workshop.
In other words, the workshop must:
- Support multiple games.
- Support different data formats (e.g. maps in dinomight, word lists in paintjob)
- Support favoriting
- Support like / dislike
- Support a description
Use best practices to ensure that the SQL is efficient.
After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).
Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.
As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.
You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).
Cursor のハーネスにおける Opus 4.7 の能力を見てきた経験から、このマイグレーションは一晩で終わると期待していました。
元のプロンプトで抜けていた大事な点は、エージェントが明確化を求めてきたもので、「過去 1 日」「過去 1 週間」「過去 1 ヶ月」「全期間」のソートをどう効率的に扱うかでした。エージェントはタイムスタンプによるバケット分割アプローチを推していました (あるいはタイムスタンプソートを完全に削除する案も)。しかし共同創業者は、複雑さが増すにもかかわらず、時間ベースのソートを残すことを主張しました。
少し考えた末、減衰係数を使うシンプルな解決策を思いつきました。各ワークショップアイテムには played_count_day、played_count_week、played_count_month、played_count_all のカラムがあり、毎時間の cron ジョブがローリングカラムをそれぞれ 23/24、167/168、(720-1)/720 倍します。各ソート軸の部分インデックス (WHERE private = false AND played_count_day >= 0.368) と組み合わせることで、追加のインフラなしで「時間期間ごとの人気順」クエリが非常に安価に実現できます。Firestore では読み書きのコストが法外なのでこんなことはしなかったでしょうが、Postgres では基本的にタダです。
それで、私は寝ました。起きて、ワクワクしながら作業を確認しました! エージェントは新しい workshop_items、workshop_item_votes、user_subscriptions テーブルを追加し、クライアントが CDC ゲートウェイ経由で個々のアイテムを読み取るように配線し (realtime.use('workshopItems/{id}'))、バックエンドの 6 つのワークショップアクションすべてを Postgres と直接やり取りするように書き直しました。片付ける必要のある些細な点がいくつかありましたが (サーバー上の Firestore へのクエリが 1 つ、Firestore のデータ不足によるバックフィルのエラーなど)、全体としてコードはほぼ完璧でした。
バックフィルを実行し、ローカルですべてが動作することをテストしてから、本番に変更をデプロイしました。これで、コードベースはついに Firestore フリーになりました。美しい。
今後の作業
将来的には、ゲートウェイでパッチもサポートしたいと考えています。今のところ、ゲートウェイは更新ごとにドキュメント全体を JSON として返します。少しもったいないですが、Hetzner にいるおかげで下り帯域は実質無制限です。今すぐ実装してもいいのですが、まだ追加の複雑さを正当化できそうにありません。