AI個人開発の技術スタック

PostgreSQL運用の考え方

Next.js側とGoのAPI側、同じPostgreSQLに対して2つの言語からアクセスする構成では、テーブル定義の変更管理からクエリの書き方、本番のコネクションプーリングまで、最初に決めておくべき勘所がいくつかあります。DrizzleとGoのマイグレーション、sqlc、PgBouncerとRLSの組み合わせを解説します。

AIに直接ALTER TABLEを打たせない

AIコーディングエージェントは、本番DBへ直接SQLを実行する権限さえあれば、確認なしにテーブルを書き換えることもできてしまいます。マイグレーションツールを間に挟むのは、変更内容をファイルとして残し、レビューしてから適用するためです。「動いたから良し」ではなく、変更をGitの差分として追える状態を保ちます。

Next.js側はDrizzle Kitでスキーマ駆動

この手引のNext.jsの基礎で紹介しているとおり、テーブル定義はTypeScriptのコードとして書き、そこからマイグレーションSQLを生成します。

npx drizzle-kit generate
npx drizzle-kit migrate

generateはschema.tsの変更点を検出し、適用用のSQLファイルと、適用順を記録するjournalファイルを生成します。生成されたSQLは、実行する前に必ず目を通します。AIにスキーマ変更を依頼したときも、生成されたSQLファイルの内容がレビュー対象になります。

Drizzleに「down」はない:Drizzle Kitは標準では戻すためのマイグレーションを生成しません。本番適用後に問題が見つかった場合は、手動でschema.tsを元に戻し、新しいマイグレーションとして「戻す変更」を生成します。適用済みのファイルは書き換えません。

GoのAPI側はgolang-migrateでup/downを書く

Go向けのマイグレーションツールにはgooseなど他の選択肢もありますが、golang-migrateはGitHub上のスター数で最も採用が多く、対応DBの種類も広い「王道」の位置づけです。採用実績が多いぶん、AIコーディングエージェントに書かせたときの正確性も期待しやすいツールです。

migrate create -ext sql -dir migrations -seq add_devices_table

このコマンドで、適用用と巻き戻し用が別ファイルとして生成されます。

-- 000001_add_devices_table.up.sql
CREATE TABLE devices (
    id BIGSERIAL PRIMARY KEY,
    push_token TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 000001_add_devices_table.down.sql
DROP TABLE devices;

Goのアプリはこの手引のGoのAPIで触れているとおり単一バイナリで配置します。golang-migrateもiofsドライバを使えば、マイグレーションファイルをバイナリへ埋め込んで持ち運べます。

package main

import (
    "database/sql"
    "embed"

    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/postgres"
    "github.com/golang-migrate/migrate/v4/source/iofs"
)

//go:embed migrations/*.sql
var embedMigrations embed.FS

func runMigrations(db *sql.DB) error {
    driver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        return err
    }
    src, err := iofs.New(embedMigrations, "migrations")
    if err != nil {
        return err
    }
    m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
    if err != nil {
        return err
    }
    return m.Up()
}
起動時に自動適用するかは要検討:アプリ起動時に毎回m.Up()を呼ぶ構成は手軽ですが、Cloud Runのように複数インスタンスが同時に起動しうる環境では、マイグレーションの同時実行に注意が必要です。デプロイのワークフロー側で、アプリ起動前に1回だけCLIのmigrate upを実行する構成のほうが安全です。

GoのクエリはsqlcでSQLを手放さない

Goには全部入りのORMであるGORMもありますが、この手引はNext.js側でPrismaではなくDrizzleを選んだのと同じ理由で、Go側もSQLが見えなくなる方向へは進みません。GORMはオブジェクト指向的にDBを扱えて書き味は良い一方、実際どんなSQLが発行されているかを追うのに一段掘り下げが必要になりがちです。

代わりに勧めるのはsqlcです。素のSQLを書き、それをコンパイル時に型安全なGoのコードへ変換します。

-- name: GetDeviceByToken :one
SELECT id, push_token, created_at
FROM devices
WHERE push_token = $1;
sqlc generate

生成されたコードは、そのままGoの関数として呼び出せます。

device, err := queries.GetDeviceByToken(ctx, token)

スキーマを変更したらsqlc generateをやり直すだけで、SQLとGoの構造体の不整合はコンパイルエラーとして即座に検出されます。「コードのどの部分が、実際にどのSQLになっているか」を見失わないという、この手引が一貫して重視している点をGo側でも保てます。

PgBouncerのtransactionモードとRLSは相性が悪い

本番では、Cloud RunやCloudflare Workersのように短命な接続が大量に発生する環境向けに、PgBouncerなどのコネクションプーラーをPostgresの手前に置くことがあります。特に接続数を効率よく使い回せるtransactionモードは、いくつかのPostgresの機能と相性が悪く、知らずに使うと事故につながります。

  • プリペアドステートメント:プリペアドステートメントはセッション単位のため、transactionモードでは直前と別のバックエンド接続に回された途端にエラーになります。PgBouncer 1.21以降はプロトコルレベルのプリペアドステートメントをmax_prepared_statementsで扱えますが、SQL文のPREPARE自体は対象外です。
  • アドバイザリロック:セッション単位のpg_advisory_lockは、ロックした接続と解放する接続が別になり得るため、ロックが永久に解放されない事故が起きます。トランザクション単位で自動解放されるpg_advisory_xact_lockを使います。
  • LISTEN/NOTIFY:非同期通知の受信には接続を専有し続ける必要があるため、transactionモードのプールとは構造的に噛み合いません。

この中でも特に見落としやすいのが、RLS(行単位セキュリティ)と組み合わせたときの認可コンテキストの漏えいです。テナントやユーザーIDをRLSポリシーで参照する場合、次のようにSETではなくSET LOCAL(またはトランザクション限定のset_config)で渡します。

ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE devices FORCE ROW LEVEL SECURITY;

CREATE POLICY devices_select ON devices
    FOR SELECT
    USING (user_id = current_setting('app.current_user_id')::bigint);

CREATE POLICY devices_write ON devices
    FOR ALL
    USING (user_id = current_setting('app.current_user_id')::bigint)
    WITH CHECK (user_id = current_setting('app.current_user_id')::bigint);
tx, err := db.BeginTx(ctx, nil)
// ...
_, err = tx.ExecContext(ctx, "SELECT set_config('app.current_user_id', $1, true)", userID)
// 第3引数のtrue(is_local)がSET LOCAL相当。バインド変数も使える
// ...
tx.Commit()

SETのようにセッション全体へ設定してしまうと、transactionモードでは同じバックエンド接続が次のリクエスト(別の利用者かもしれません)に使い回されたとき、前のリクエストのapp.current_user_idがそのまま残り、他人のデータが見える・書き換えられるという認可バグになります。SET LOCAL/set_config(..., true)はコミットかロールバックの時点で必ず破棄されるため、この漏えいを防げます。

ポリシーはコマンドごとに必要:RLSはSELECT・INSERT・UPDATE・DELETEそれぞれにポリシーが必要です。SELECT用だけ作ってUPDATE/DELETE用を作り忘れると、更新が0件で失敗して原因が分かりにくくなります。また、テーブルの所有者やスーパーユーザーはデフォルトでRLSの対象外になるため、意図して制限する場合はFORCE ROW LEVEL SECURITYを明示します。トランザクションを必ずCOMMITかROLLBACKで閉じる実装にしておくことも、SET LOCALの安全性の前提です。

言語が違っても、共通のルールは同じ

  • 適用済みのマイグレーションファイルは書き換えず、修正は新しいファイルで行う
  • 本番へ適用する前に、ステージング環境か複製したDBで一度試す
  • GitHub Actions上のテスト用DBに対して、CIでもマイグレーションを自動適用してからテストを実行する
  • 破壊的な変更(列の削除など)は、アプリ側の対応が完了してから適用する

Drizzleとgolang-migrateはツールとしては別物ですが、「変更をコードとして残し、レビューしてから適用する」という考え方は共通です。Next.jsとGoの両方を使う構成でも、この原則さえ揃っていれば、DB自体は1つのPostgreSQLを安全に共有できます。

DB設計・運用から相談したい方へ

マイグレーション運用の設計から、本番環境の構築・運用まで対応します。

運用について相談する