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を直接書き換えたことだ。壊れたら全サービスが止まる。
やるべきだったのはこうだ。
- トンネルのconfigに検証用のingressを1本追加する(例
db-pgb.example.com→tcp://localhost:6432)。既存はそのまま - そのDNSレコードと、それを向くHyperdrive設定を別リソースとして作る
- 1サービスだけをその新しいHyperdriveに向けて挙動を見る。壊れてもそのサービスだけで済む
- 通ることを確認してから本番の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すると通るので原因が見えにくい - 経路の途中の層を変えるなら、本番と並行して検証できる別経路を先に作る
ログが空なのは「何も起きていない」ではなく「ログを書く場所まで到達していない」ことがある。