0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails + NeonDB で PG::InFailedSqlTransaction が出た時の対処法

0
Posted at

はじめに

個人開発しているRailsアプリ「MabaTalk」(介護向けまばたきコミュニケーション支援アプリ)にご意見箱機能を追加し、本番デプロイしたところ、PG::InFailedSqlTransaction エラーで本番が動かなくなりました。

ローカルでは正常に動くmigrationなのに、本番(Render + NeonDB)でだけ失敗する不可解な状況。

真因究明に半日ほどかかりました。

調査を進めた結果、原因は NeonDBのPooled Connection(PgBouncer) にあると判明。

Rails migrationが内部で使う Advisory Lock と相性が悪かったのです。

ざっくり言うと

  • Pooled Connection(PgBouncer): アプリとDBの間に入って、接続を効率的に使い回す中継役
  • Advisory Lock: Railsのmigrationが「他のmigrationが同時に走らないように」かける鍵
  • PgBouncerが接続を使い回す仕組みのせいで、Railsの鍵が効かなくなる

詳しくは記事の中で説明します。

この記事では、エラーから真因究明まで、デバッグの手順とともにまとめます。

想定読者

  • Rails + NeonDB(Pooled Connection)でデプロイしている方
  • migrationで PG::InFailedSqlTransaction エラーが出た方

環境

  • Ruby on Rails 7.2
  • NeonDB(サーバレス PostgreSQL)
  • Render(ホスティング)
  • Docker環境(ローカル)

1. 起きたエラー

ご意見箱機能の実装で、Feedbackモデルに UUID キーカラムを追加するmigrationを書きました。

class AddKeyToFeedbacks < ActiveRecord::Migration[7.2]
  def up
    add_column :feedbacks, :key, :string

    Feedback.reset_column_information
    Feedback.find_each do |feedback|
      feedback.update_column(:key, SecureRandom.uuid)
    end

    change_column_null :feedbacks, :key, false
    add_index :feedbacks, :key, unique: true
  end
end

ローカル環境では正常に動作することを確認し、本番にデプロイ。
すると、Render のビルドが以下のエラーで失敗しました。

== 20260509231905 AddKeyToFeedbacks: migrating ==
-- add_column(:feedbacks, :key, :string)
   -> 0.0044s
-- change_column_null(:feedbacks, :key, false)
==> Build failed 😞

bin/rails aborted!
PG::InFailedSqlTransaction: ERROR: current transaction is aborted, 
commands ignored until end of transaction block

add_column は成功しているのに、その後の change_column_null で transaction abort と言われる謎エラーでした。

2. 原因の仮説と検証

エラーメッセージ current transaction is aborted は、「もっと前の何かが失敗して、transactionがすでに死んでいる」 という意味。
真の犯人は別にいると判断し、容疑者を順番に検証しました。

仮説1: 既存データの問題?

feedbacks テーブルに不正なデータがあって、update_column で何か失敗してる?

SELECT COUNT(*) FROM feedbacks;
-- 結果: 0

ポイント:
本番には レコードが0件 だったため、Feedback.find_each のループは1回も実行されません。
データは無実

仮説2: NeonDBがDDL(テーブル構造変更)を嫌っている?

NeonDBコンソールで手動で同じDDLを実行:

DDL(Data Definition Language)とは
CREATE TABLE / ALTER TABLE / DROP TABLE のように テーブルの構造を変えるSQL の総称です。
Railsのmigrationは内部でDDLを実行しています。
反対に、SELECT / INSERT / UPDATE のようにデータを操作するSQLは DML(Data Manipulation Language) と呼びます。

ALTER TABLE feedbacks ADD COLUMN key VARCHAR;       -- カラム追加(DDL)
ALTER TABLE feedbacks ALTER COLUMN key SET NOT NULL;-- 制約追加(DDL)
ALTER TABLE feedbacks DROP COLUMN key;              -- カラム削除(DDL)

すべて正常に実行成功。
DDL自体は無実

仮説3: Migrationコードが悪い?

ローカルで本番と同じ条件(0件)でmigrationを実行:

docker compose exec web bin/rails runner 'Feedback.destroy_all'
docker compose exec web bin/rails db:rollback STEP=1
docker compose exec web bin/rails db:migrate
# → 成功

各コマンドの意味:

コマンド 役割
docker compose exec web Docker のwebコンテナ内でコマンド実行
bin/rails runner '<Ruby>' Rails環境で1行のRubyコードを実行(モデル等が使える)
Feedback.destroy_all feedbacksテーブルの全レコードを削除(本番と同じ0件状態に)
bin/rails db:rollback STEP=1 直前の1つのmigrationを取り消す
bin/rails db:migrate 未実行のmigrationを実行

ポイント:
runner「Rails環境を読み込んだ状態でRubyコードを1回実行する」 コマンドです。
bin/rails console の「1コマンドだけ実行版」と考えると分かりやすいです。

コードは無実

ここまでで「ローカルで動くのに本番で動かない」という事実から、環境差が原因だと確信しました。

3. 原因の特定(NeonDB Branch 機能の活用)

「環境差」を切り分けるため、NeonDBのBranch機能で実験しました。

NeonDB Branch とは

NeonDBには本番DBのスナップショットコピーを 無料で作成できる Branch機能 があります。
本番に影響を与えずに、本番と同じデータ・構造でテストできる神機能です。

2種類の接続URLで実験

NeonDBには2種類の接続方法があります:

  • Pooled URL:PgBouncer経由(本番で使用)
  • Direct URL:PostgreSQLに直接接続

両方で同じmigrationを試しました。

Test A: Pooled URL(本番と同じ条件)

docker compose exec \
  -e DATABASE_URL="postgresql://...-pooler.../neondb" \
  web \
  bin/rails db:migrate --trace

本番と同じエラーで失敗

Test B: Direct URL

docker compose exec \
  -e DATABASE_URL="postgresql://.../neondb" \
  web \
  bin/rails db:migrate --trace

成功

各オプションの意味:

部分 役割
docker compose exec Dockerコンテナ内でコマンド実行
\(バックスラッシュ) コマンドの行継続(次の行も同じコマンドの続き)
-e DATABASE_URL="..." 環境変数 DATABASE_URLこのコマンドの実行時だけ上書き
web コンテナ名(Railsアプリのコンテナ)
bin/rails db:migrate Railsのmigration実行コマンド
--trace 詳細なスタックトレースを出力するオプション

ポイント:
-e DATABASE_URL="..." の部分で、「このmigration実行だけ別のDBに接続」 させています。
ローカルの設定を変えずに、debug branchやdirect URLで試せる便利な書き方です。

ポイント:
URLの違いは -pooler. が含まれるかどうかだけ。
「Pooled URLで失敗、Direct URLで成功」 = PgBouncerが真犯人 と確定しました。

なぜPgBouncerが原因なのか

仕組みを順を追って説明します。

ステップ1: Advisory Lock とは

「rails が migration の同時実行を防ぐためにかける、PostgreSQLの鍵」 です。

例えば2人の開発者が同時に migration をデプロイしたら、互いのmigrationが衝突してDBが壊れます。
それを防ぐため、Railsはmigration開始時にPostgreSQLに「私が migration 中です」という鍵をかけます。

# active_record/migration.rb 抜粋
def with_advisory_lock
  connection.get_advisory_lock(lock_id)  # 鍵をかける
  yield                                    # migration実行
ensure
  connection.release_advisory_lock(lock_id) # 鍵を外す
end

Advisory Lock の特徴

  • PostgreSQLが提供する「アプリ側で自由に使える鍵」
  • session(接続)が続いている間だけ有効 ← ★ここが重要
  • 接続が切れると鍵も自動的に外れる

ステップ2: session-scoped とは

「session(接続)が続いている間だけ有効」 という性質のこと。

イメージ:

[Rails] ─── 接続A ──→ [PostgreSQL]
   ↓ 鍵をかける
   [接続A の中だけで有効な鍵]
   ↓ 同じ接続内でmigration実行
   ↓ 鍵を外す

→ 接続Aが続いている間だけ鍵が有効。接続が切れたら鍵も消える。

ステップ3: PgBouncer の動き

NeonDBのPooled Connectionは内部で PgBouncer(transaction mode) を使っています。

PgBouncer とは
複数のRailsアプリ接続を、少ないPostgreSQL接続で使い回す コネクションプーラー(中継役) です。
transaction mode では、transactionが終わると即座にPostgreSQL接続を解放して、次の人に使い回します。

イメージ:

[Rails-A] ─┐
[Rails-B] ─┼→ [PgBouncer] ─→ [PostgreSQL]
[Rails-C] ─┘   接続を共有

Rails-A が transaction 終了 → PgBouncer が接続を解放 → Rails-B が同じ接続を使用

ステップ4: 衝突の発生

Rails migrationを PgBouncer 経由で実行すると:

1. Rails: 鍵をかける(session A の Advisory Lock)
2. Rails: ALTER TABLE ... 実行
3. transaction 終了
4. ★ PgBouncer: 「transactionが終わったから接続を解放!」
5. ★ PostgreSQL: 「接続切れた → session A の鍵も自動解除」
6. Rails: 次のSQLを送る
7. PgBouncer: 別の接続Bを割り当てる可能性
8. → 鍵が外れた状態で migration が続く → 整合性が壊れる → transaction abort

「鍵をかけたまま使う」という前提が、PgBouncer の「接続使い回し」で崩れるのです。

NeonDB公式ドキュメントにも、Pooled Connection でサポートされない機能が明記されています:

Not supported with pooled connections:

  • SET / RESET (session variables)
  • LISTEN / NOTIFY
  • WITH HOLD CURSOR
  • PREPARE / DEALLOCATE (SQL-level prepared statements)
  • Temporary tables with PRESERVE / DELETE ROWS
  • LOAD statement
  • Session-level advisory locks ← ★今回の原因

訳: 「Pooled connection ではサポートされない機能:(中略)Session-level advisory locks(セッションレベルの advisory lock)

(出典: Connection pooling | Neon Docs)

さらに、同ドキュメントには 「migration には Direct 接続を使うべき」 と明記されています:

Use Case Connection Type Why
Schema migrations Direct Tools may not support transaction pooling

(出典: Connection pooling | Neon Docs - "When to use pooled vs direct connections")

→ つまり今回のエラーは、NeonDB公式ドキュメントが既に警告していた問題でした。
気づくのが遅かったですが、結果として公式推奨の方法に修正したことになります。

4. 解決策(コード)

今回私は、migration実行時のみ Direct URL を使う ように修正しました。

通常のWebリクエストはPooled URLのまま(コネクション効率のため)にして、migrationだけ別接続にします。

Step 1: Render の Environment Variables に追加

DATABASE_URL_DIRECT という環境変数を追加します。

DATABASE_URL_DIRECT = postgresql://USER:PASS@ep-xxxxx.eu-central-1.aws.neon.tech/neondb?sslmode=require

-pooler. を含まないURL(NeonDBコンソールのproduction branchから取得)。

Step 2: bin/render-build.sh を修正

bin/render-build.sh とは
Render(ホスティングサービス)が デプロイ時に実行するシェルスクリプト です。
bundle install、asset compile、migration等のビルド処理を1つのファイルにまとめておきます。
Renderが「Build Command」として bash bin/render-build.sh を実行する流れ。

#!/usr/bin/env bash
set -o errexit

bundle install
bundle exec rails assets:precompile
bundle exec rails assets:clean

# migration時のみ Direct URLを使う(NeonDB PgBouncer 回避)
DATABASE_URL="$DATABASE_URL_DIRECT" bundle exec rails db:migrate

各行の意味:

役割
#!/usr/bin/env bash 「このファイルはbashで実行する」宣言(shebang)
set -o errexit コマンドが1つでも失敗したら、後続を実行せず即座にエラー終了
bundle install Gemfileに書かれたgemをインストール
bundle exec rails assets:precompile CSS/JS等のアセットを本番用にコンパイル
bundle exec rails assets:clean 古いアセットを削除
DATABASE_URL="$DATABASE_URL_DIRECT" bundle exec rails db:migrate migration実行(Direct URLで)

ポイント:
DATABASE_URL="$DATABASE_URL_DIRECT" コマンド という書き方で、そのコマンドの実行中だけ環境変数を上書きできます。

通常時:    DATABASE_URL = Pooled URL(Webリクエスト用)
migration時: DATABASE_URL = Direct URL(PgBouncer回避)

通常運用のDATABASE_URL(Pooled)には影響せず、migrationの1コマンドだけ別接続にできます。

5. デプロイ成功

修正をpushして再デプロイ:

== 20260509231905 AddKeyToFeedbacks: migrating ==
-- add_column  0.2351s
-- change_column_null  0.2216s    今度は通る
-- add_index  0.1857s
== migrated (1.6126s)
==> Deploy succeeded 🎉

ご意見箱機能が本番で正常動作することを確認しました。

つまずいたポイント

① エラーメッセージの行番号が真犯人ではなかった

/myapp/db/migrate/20260509231905_add_key_to_feedbacks.rb:10:in `up'

エラーは行10(change_column_null)で発生していると表示されますが、
実際は 「もっと前のSQLが失敗して、transactionが既に死んでいた」 だけで、change_column_null は被害者でした。

PG::InFailedSqlTransaction というエラータイプは、「症状」であって「原因」ではない ことを学びました。

② Render Environment Variable の保存忘れ

最初に修正をデプロイした際、DATABASE_URL_DIRECT の保存ボタンを押し忘れていて、別のエラーが出ました:

bin/rails aborted!
Database URL cannot be empty

ポイント:
DATABASE_URL="$DATABASE_URL_DIRECT" コマンド という記法で $DATABASE_URL_DIRECT が空の場合、DATABASE_URL が空文字列になります。
本番デプロイ前に 環境変数の値が確実に保存されているか確認 することが大事です。

③ ローカルとの差を疑うのに時間がかかった

最初は「コードに何か問題がある」と疑って、コードを何度も見直しました。
しかし真因は 環境差(PgBouncer の有無)

「ローカルで動くのに本番で動かない」 = 環境差が原因 という直感を最初から持つべきでした。

まとめ

  • PG::InFailedSqlTransaction は「症状」。真の原因はもっと前のSQL失敗にある
  • ローカルで動くのに本番で動かない場合は、環境差を疑う
  • NeonDBのPooled Connection(PgBouncer)は Advisory Lock 等の session-scoped 機能と相性が悪い
  • 解決策は migration時のみ Direct URL を使う こと

NeonDB(または PgBouncer 経由)でRailsアプリを運用する場合:

  • Webリクエスト: Pooled URL(接続効率○)
  • Migration: Direct URL(PgBouncer回避○)

の二刀流が安全です。

公式ドキュメントが明記している通り、advisory lockやprepared statementsを使う処理はpooledで動かないことを覚えておきましょう。

理解度チェッククイズ

ここまでの内容をもとに、理解度を確認するクイズを用意しました。

Q1. PG::InFailedSqlTransaction というエラーが出た時、行番号で示された箇所は何を表していますか?

A. 真の原因がそこにある
B. そこで初めて症状が表面化した
C. その行を削除すれば直る
D. ユーザーの操作ミス

答え B. そこで初めて症状が表面化した

PG::InFailedSqlTransaction は「transactionが既に死んでいる」という症状。
真の原因は、その前に失敗したSQLにあります。

Q2. NeonDBのPooled Connectionで使われている技術は何ですか?

A. PgBouncer(transaction mode)
B. PostgreSQL native pooling
C. Redis
D. Pgpool-II

答え

A. PgBouncer(transaction mode)

NeonDBのPooled Connectionは内部でPgBouncerをtransaction modeで動かしています。

各選択肢の補足:

  • A. PgBouncer(transaction mode): 軽量なPostgreSQL専用コネクションプーラー。「transaction mode」は「transactionが終わるたびに接続を解放して使い回す」モード。
  • B. PostgreSQL native pooling: PostgreSQL本体に組み込みのプール機能(PgCat等は別物)。NeonDBは使用していない。
  • C. Redis: キャッシュ用のKVSデータベース。コネクションプーラーではない。
  • D. Pgpool-II: 別のPostgreSQL用プーラー。PgBouncerより高機能だが重い。NeonDBは採用していない。

コネクションプーラーとは
アプリとDBの間に入って、接続を効率的に使い回す中継役。
直接接続だと「アプリ100個 = DB接続100個必要」だが、プーラーがあれば「DB接続10個を100個のアプリで使い回す」ことができる。

Q3. PgBouncerと相性が悪いPostgreSQL機能はどれですか?

A. SELECT/INSERT/UPDATE
B. プライマリキー
C. Advisory Lock 等のsession-scoped機能
D. JSON型

答え

C. Advisory Lock 等のsession-scoped機能

理由を順を追って説明します:

1. session-scoped 機能とは

「同じ接続(session)が続いている間だけ有効」な機能 のこと。

代表例:

  • Advisory Lock: アプリ独自の排他制御の鍵
  • Prepared Statements: SQL文を事前にDBに登録しておく仕組み
  • LISTEN/NOTIFY: PostgreSQLの簡易メッセージング
  • Temporary Tables: 一時テーブル(接続切れると消える)

これらは 「同じ接続で次のSQLも続けて投げる」 ことを前提にしています。

2. PgBouncer は接続を使い回す

PgBouncer の transaction mode の動作:

1. アプリが BEGIN(transaction開始)
2. PgBouncer: PostgreSQL接続Aを割り当て
3. アプリ: SQL を実行
4. アプリ: COMMIT(transaction終了)
5. ★ PgBouncer: 接続Aを解放 → 次の人に渡す ★
6. 次のアプリが同じ接続Aを使う

3. なぜ衝突するか

session-scoped 機能を使うアプリの動き:

1. アプリ: 鍵をかける(Advisory Lock)  ← session A で有効
2. アプリ: BEGIN
3. アプリ: SQL実行
4. アプリ: COMMIT
5. ★ PgBouncer: 接続を解放 → session A 終了 ★
6. ★ PostgreSQL: session Aの鍵を自動解除 ★
7. アプリ: 次の処理(鍵が外れているので意図と違う動き)

アプリは「鍵かけたまま」のつもりだが、実際は外れている という整合性の崩れ。

各選択肢

  • A. SELECT/INSERT/UPDATE: 単純なデータ操作。1transactionで完結するので影響なし。
  • B. プライマリキー: 単なる制約。session関係ない。
  • C. Advisory Lock 等のsession-scoped機能: ★これが正解。
  • D. JSON型: データ型。session関係ない。

解決策
session-scoped機能を使う処理(migrationなど)は、PgBouncerを経由しない Direct接続 で実行する。

Q4. 「ローカルで動くのに本番で動かない」場合、まず疑うべきは何ですか?

A. ライブラリのバージョン
B. 環境差
C. 自分のミス
D. 運の悪さ

答え B. 環境差

ローカルと本番で異なるのは「環境」です。コードは同じはずなので、環境の違いを最初に疑うのが効率的です。

Q5. NeonDBで本番への影響なくmigrationテストをしたい時、最も適した方法はどれですか?

A. 本番DBで直接テストする
B. ローカルDBでテストする
C. NeonDBのBranch機能でスナップショットコピーを作ってテストする
D. テストしない

答え C. NeonDBのBranch機能でスナップショットコピーを作ってテストする

NeonDBには本番DBのスナップショットコピーを無料で作成できるBranch機能があります。
本番に影響を与えずに本番相当の環境でテストできる優れた機能です。

Q6. 以下のシェルスクリプトの動きとして正しいのはどれですか?

DATABASE_URL="$DATABASE_URL_DIRECT" bundle exec rails db:migrate

A. DATABASE_URL を永続的に上書きする
B. このコマンドの実行中だけDATABASE_URLを上書きする
C. DATABASE_URL_DIRECT を消去する
D. エラーが発生する

答え B. このコマンドの実行中だけDATABASE_URLを上書きする

変数=値 コマンド という記法は、そのコマンド実行時のみ環境変数を上書きします。
シェルセッション全体には影響しません。

参考文献

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?