Cloudflare Workers からPostgreSQLに繋いでいるサービスを7つ動かしている。負荷試験をしたら、130並列で too many clients が出て18件が500を返した。

Workers は常駐プロセスではないので、アプリケーション側でコネクションプールを持てない。エッジのHyperdriveがプールしてくれるのだが、ここに落とし穴があった。Hyperdriveのプールはサービスごとに独立していて、しかもどれも「このPostgreSQLの上限は50接続」を知らない。 うちは7サービスが1台のPostgreSQLを共有しているので、誰かが溢れさせると全員が巻き込まれる。

答えはDB側にプーラーを置くことだ。PgBouncerを入れた。

そして1回目の本番切替に失敗した。その記録を書く。

サーバー上では完璧に動いていた

さくらVPSにPgBouncer 1.25.2を入れた。設定はこうだ。

listen_addr = 127.0.0.1
listen_port = 6432
pool_mode = transaction
default_pool_size = 4
max_db_connections = 5
max_client_conn = 500
max_prepared_statements = 200

max_prepared_statements は必須だった。postgres.js は既定でプリペアドステートメントを使うので、これが0だと pool_mode = transaction と噛み合わない。PgBouncer 1.21以降ならこの設定で吸収できる。

max_db_connections の決め方も書いておく。PostgreSQLの max_connections は50で、うち3がsuperuser予約なので実質47。DB数 × max_db_connections がこれを超えないようにする。 当時9DBだったので5(9×5=45)、後にDBを1つ足して10DBになったとき4(10×4=40)に下げた。

VPS上での検証は全部通った。

  • psqlの通常クエリ・トランザクション・PREPARE
  • postgres.js のプリペアドステートメント
  • 130並列・260並列とも全件成功、実接続は3本のまま(PgBouncer無しでは130並列で18件が500)

完璧だ。切り替えよう、と思った。

本番に向けた瞬間、APIが500になった

CloudflareトンネルのingressをPostgreSQL(5432)からPgBouncer(6432)に変えた。

APIが500を返し始めた。

ここからの観測が、噛み合わなかった。

  • PgBouncerのログに接続が1件も届いていない。 login attempt の行が1つも出ない。TCPが到達していないように見える
  • でもcloudflaredは Updated to new configuration ... "service":"tcp://localhost:6432" を出していて、設定自体は反映済み
  • IPv6の疑いも外れた。PostgreSQLもPgBouncerも 127.0.0.1 のみで待ち受けていて、5432は同じ条件で動いていた

5432に戻したら即座に復旧した(全エンドポイント200)。とりあえず切り戻して、原因を考えることにした。

「サーバー上のpsqlからは繋がるのに、トンネル越しには届かない」。この材料だとネットワーク経路を疑うのが自然で、実際そちらを先に潰した。cloudflaredの設定にもIPv6にも、何もなかった。

原因: TLSの握手で切れていた

答えは client_tls_sslmode だった。PgBouncerの既定値は disable で、TLSでの接続を受け付けない。

一方、PostgreSQLは ssl = on で動いている。だからHyperdriveはTLSで繋ぎに来る。PgBouncerはそれを握手の段階で拒否する。

そして重要なのはここだ。握手の段階で切れるので、PgBouncerのログに login attempt すら残らない。 認証まで到達していないのだから当然で、これが「接続が1件も届いていない」ように見えた理由だった。

私はログが空なのを見て「ネットワークが届いていない」と解釈した。実際には「届いているが、アプリケーションが受け取る手前で切っていた」。

1分で切り分けられた

原因に思い当たってからは、psqlで即座に確定できた。

psql 'host=127.0.0.1 port=5432 ... sslmode=require'  → 通る(PostgreSQL)
psql 'host=127.0.0.1 port=6432 ... sslmode=require'  → server does not support SSL, but SSL was required
psql 'host=127.0.0.1 port=6432 ... sslmode=disable'  → 通る

きれいに出た。同じポートに require と disable を撃ち分けるだけで、TLSが原因かどうかが分かる。最初からこれをやっていれば、切り戻し自体が要らなかった。

私が「サーバー上のpsqlからは繋がる」と確認したとき、psqlの既定は sslmode=prefer だった。preferは「TLSを試して、駄目なら平文で繋ぐ」ので、PgBouncerがTLSを拒否しても勝手に平文にフォールバックして成功する。手元の検証が通ってしまった理由は、おそらくこれだ。Hyperdriveはフォールバックしない。

対処は pgbouncer.ini の末尾に4行。

client_tls_sslmode = allow
client_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem   ; PostgreSQL と同じ証明書
client_tls_key_file  = /etc/ssl/private/ssl-cert-snakeoil.key
server_tls_sslmode = disable                                   ; 同一ホスト内なので不要

postgres ユーザーが鍵を読めるように usermod -aG ssl-cert postgres も要る。これを忘れるとPgBouncerが起動しない。

2回目は成功した

TLS経由で260並列・実接続5本まで確認してから切り替えた。本番の実測はこうなった。

PgBouncerのログtls=TLSv1.3/TLS_AES_256_GCM_SHA384 で login attempt が並ぶ
全7サービスの /api/health/200
本番への並列40 / 80リクエスト全件200(切替前は130並列で18件が500)
負荷中の実接続数9本で頭打ち(上限50)

130並列で18件落ちていたものが、80並列で全件通り、実接続は9本で頭打ちになった。目的は達成できた。

もうひとつの落とし穴: userlistを忘れる

運用に入ってから踏んだものも書いておく。DBを追加したら、PgBouncerの userlist.txt にも足さないといけない。

Hyperdriveは6432(PgBouncer)経由なので、PostgreSQL側にロールを作っただけでは繋がらない。忘れるとHyperdriveの作成が SASL authentication failed で落ちる。

これも原因が見えにくい。PostgreSQLに直接psqlすると通るからだ。「ロールは作った、パスワードも合っている、psqlで確認もした、なのに繋がらない」となる。プーラーを挟んだ構成では、認証情報が2箇所にあることを忘れないほうがいい。

printf '"<role>" "<password>"\n' | ssh <db> 'sudo tee -a /etc/pgbouncer/userlist.txt >/dev/null'
ssh <db> 'sudo chown postgres:postgres /etc/pgbouncer/userlist.txt && sudo chmod 0640 /etc/pgbouncer/userlist.txt && sudo systemctl reload pgbouncer'

反省: 本番と並行して検証できる形にすべきだった

一番の反省は、本番のingressを直接書き換えたことだ。壊れたら全サービスが止まる。

やるべきだったのはこうだ。

  1. トンネルのconfigに検証用のingressを1本追加する(例 db-pgb.example.com → tcp://localhost:6432)。既存はそのまま
  2. そのDNSレコードと、それを向くHyperdrive設定を別リソースとして作る
  3. 1サービスだけをその新しいHyperdriveに向けて挙動を見る。壊れてもそのサービスだけで済む
  4. 通ることを確認してから本番のingressを切り替える

ローカル(VPS上)で動くことと、トンネルとHyperdriveを跨いで届くことは別物だった。 経路の途中にある層を変える作業は、必ず本番と並行して検証できる形にしてから行うべきだった。

まとめ

  • Workers はプールを持てない。Hyperdriveのプールはサービスごとに独立していて、DB側の上限を知らない。共有DBなら誰かの溢れが全員を巻き込む
  • PgBouncerの max_db_connections は DB数 × 値 < 実質上限で決める。postgres.js を使うなら max_prepared_statements が必須
  • 「接続が1件も届かない」はTLS握手の疑い。 アプリ層のログに何も出ないのは、アプリ層に届く前で切れている証拠
  • 切り分けは psql で sslmode=require と sslmode=disable を撃ち分ける。1分で確定する
  • psqlの既定 sslmode=prefer は平文にフォールバックするので、手元の検証が通ってしまう。 クライアントがフォールバックしない場合と挙動が変わる
  • DBを足したらPgBouncerの userlist.txt にも足す。直接psqlすると通るので原因が見えにくい
  • 経路の途中の層を変えるなら、本番と並行して検証できる別経路を先に作る

ログが空なのは「何も起きていない」ではなく「ログを書く場所まで到達していない」ことがある。