AIに直接ALTER TABLEを打たせない
AIコーディングエージェントは、本番DBへ直接SQLを実行する権限さえあれば、確認なしにテーブルを書き換えることもできてしまいます。マイグレーションツールを間に挟むのは、変更内容をファイルとして残し、レビューしてから適用するためです。「動いたから良し」ではなく、変更をGitの差分として追える状態を保ちます。
Next.js側はDrizzle Kitでスキーマ駆動
この手引のNext.jsの基礎で紹介しているとおり、テーブル定義はTypeScriptのコードとして書き、そこからマイグレーションSQLを生成します。
npx drizzle-kit generate
npx drizzle-kit migrategenerateはschema.tsの変更点を検出し、適用用のSQLファイルと、適用順を記録するjournalファイルを生成します。生成されたSQLは、実行する前に必ず目を通します。AIにスキーマ変更を依頼したときも、生成されたSQLファイルの内容がレビュー対象になります。
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)はコミットかロールバックの時点で必ず破棄されるため、この漏えいを防げます。
FORCE ROW LEVEL SECURITYを明示します。トランザクションを必ずCOMMITかROLLBACKで閉じる実装にしておくことも、SET LOCALの安全性の前提です。言語が違っても、共通のルールは同じ
- 適用済みのマイグレーションファイルは書き換えず、修正は新しいファイルで行う
- 本番へ適用する前に、ステージング環境か複製したDBで一度試す
- GitHub Actions上のテスト用DBに対して、CIでもマイグレーションを自動適用してからテストを実行する
- 破壊的な変更(列の削除など)は、アプリ側の対応が完了してから適用する
Drizzleとgolang-migrateはツールとしては別物ですが、「変更をコードとして残し、レビューしてから適用する」という考え方は共通です。Next.jsとGoの両方を使う構成でも、この原則さえ揃っていれば、DB自体は1つのPostgreSQLを安全に共有できます。