Aurora PostgreSQL深夜対応で学んだ接続プール・バキューム設定の見直し方
「なんとなく動いてるからOK」と思っていたら深夜インシデントに。pgBouncer設定ミスとバキューム問題で痛い目を見た経験から、Aurora PostgreSQL 16の実践的な運用ノウハウをまとめました。
本番でやらかしてから学んだこと
先日、うちのサービスでAurora PostgreSQL 16のバキューム問題に起因するスループット低下が起きて、深夜対応になった。ピーク時にConnections per secondが跳ね上がって、pgBouncerの設定が追いつかなくなったのが直接原因だったんだけど、根っこにあったのは「なんとなく動いてるからOK」という慢心だった。
あれ以来、Aurora PostgreSQLとRDSの設定を一から見直して、2026年現在の運用ベストプラクティスに更新した。教科書的なことは公式ドキュメントを読めばいいので、ここでは「実際にハマった」「これで救われた」という話を中心に書いていく。
なお、インシデント対応の一般的なフレームワークについては インシデント対応の最新ベストプラクティス2026 で詳しく書いているので、そちらも参照してほしい。
Aurora PostgreSQL 16の接続管理:pgBouncerをちゃんと設定する
正直、pgBouncerの設定は「とりあえず動く設定をコピペして使い続ける」パターンが多いと思う。うちもそうだった。でも2026年現在、Aurora PostgreSQL 16と組み合わせる場合はいくつか意識すべきポイントがある。
アーキテクチャ全体像
graph TB
subgraph VPC["VPC: ap-northeast-1"]
subgraph AZ1["AZ: ap-northeast-1a"]
App1["ECS Fargate\n(アプリサーバー)"]
PgB1["pgBouncer\n(接続プール)"]
end
subgraph AZ2["AZ: ap-northeast-1c"]
App2["ECS Fargate\n(アプリサーバー)"]
PgB2["pgBouncer\n(接続プール)"]
end
subgraph Aurora["Aurora Cluster"]
Writer["Aurora Writer\n(PostgreSQL 16.4)"]
Reader1["Aurora Reader 1"]
Reader2["Aurora Reader 2"]
end
PgB1 -->|書き込み| Writer
PgB2 -->|書き込み| Writer
PgB1 -->|読み取り| Reader1
PgB2 -->|読み取り| Reader2
App1 --> PgB1
App2 --> PgB2
end
subgraph Monitoring["モニタリング"]
CW["CloudWatch\nメトリクス"]
RDSPerf["Performance\nInsights"]
end
Aurora --> CW
Aurora --> RDSPerf
この構成を採用しているのには理由がある。pgBouncerをアプリと同じAZに置くことで、クロスAZのデータ転送コストを削減できる。地味だけど、トラフィックが多いサービスではじわじわ効いてくる。
pgBouncer設定の実際
# pgbouncer.ini
[databases]
# 書き込み用(Writerエンドポイント)
mydb_write = host=mydb-cluster.cluster-xxxx.ap-northeast-1.rds.amazonaws.com \
port=5432 dbname=mydb
# 読み取り用(Readerエンドポイント)
mydb_read = host=mydb-cluster.cluster-ro-xxxx.ap-northeast-1.rds.amazonaws.com \
port=5432 dbname=mydb
[pgbouncer]
# 2026年時点でtransaction modeが最もバランスが良い
# prepared statementsを使うならsession modeだが、パフォーマンスは落ちる
pool_mode = transaction
# Aurora PostgreSQL 16のmax_connectionsは LEAST({DBInstanceClassMemory/9531392}, 5000)
# r6g.2xlargeなら約1700。そのうち10〜15%をpgBouncerに割り当てる
max_client_conn = 2000
default_pool_size = 50 # pgBouncerからDBへの実接続数
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 5
# 接続タイムアウト(秒)
server_connect_timeout = 10
server_login_retry = 5
client_login_timeout = 30
# セキュリティ設定
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
# ヘルスチェック
server_check_query = select 1
server_check_delay = 30
# ログ設定
log_connections = 0 # 本番では0推奨(ログ量が爆発する)
log_disconnections = 0
stats_period = 60
ハマりポイントとして、pool_mode = transaction でprepared statementsを使おうとすると壊れる。これはpgBouncerの仕様なんだけど、最初に引っかかる人が多い。ORMのprepared statement機能を無効化するか、pool_mode = session にするかを選ぶ必要がある。うちはprepared statementsを諦めてtransaction modeにしたら、接続効率が約3倍になった。個人的にはこの選択に後悔はないけど、ORMの設定変更に気づかずしばらく謎エラーが出続けたのは笑えなかった。
接続数のモニタリング
# pgBouncerのstatsをCloudWatchに送るLambda(簡易版)
import psycopg2
import boto3
from datetime import datetime
def get_pgbouncer_stats(host, port=6432):
conn = psycopg2.connect(
host=host,
port=port,
dbname='pgbouncer',
user='pgbouncer_monitor',
password=os.environ['PGBOUNCER_PASS']
)
cur = conn.cursor()
# SHOW POOLS でプール状態を確認
cur.execute('SHOW POOLS;')
pools = cur.fetchall()
cw = boto3.client('cloudwatch')
metrics = []
for pool in pools:
database, user, cl_active, cl_waiting, sv_active, sv_idle = \
pool[0], pool[1], pool[2], pool[3], pool[4], pool[5]
# 待機クライアント数が増えてきたら要注意
if cl_waiting > 0:
metrics.append({
'MetricName': 'PgBouncerWaitingClients',
'Dimensions': [
{'Name': 'Database', 'Value': database},
{'Name': 'User', 'Value': user}
],
'Value': cl_waiting,
'Unit': 'Count',
'Timestamp': datetime.utcnow()
})
if metrics:
cw.put_metric_data(
Namespace='Custom/PgBouncer',
MetricData=metrics
)
cur.close()
conn.close()
cl_waiting が継続的に0より大きい場合は default_pool_size を増やすシグナルだ。ただし、DBのmax_connectionsを超えないように計算する必要がある。この辺は数値で見ておかないと、感覚でチューニングしても意味がない。
自動バキューム:設定を放置すると本番で死ぬ
バキューム問題で深夜対応になったと最初に書いたけど、これが本当につらかった。Aurora PostgreSQL特有の話も混じっているので整理しておく。
デフォルト設定の何が問題か
PostgreSQL(とAurora PostgreSQL)のデフォルト自動バキューム設定は、小〜中規模のワークロードを前提にしている。高頻度のINSERT/UPDATE/DELETEがあるテーブルでは全然追いつかなくて、トランザクションIDのwraparound問題に近づいてしまう。
-- テーブルごとのバキューム状況を確認するクエリ
SELECT
schemaname,
relname AS table_name,
n_dead_tup,
n_live_tup,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_tup_ratio,
last_autovacuum,
last_autoanalyze,
autovacuum_count,
-- wraparound危険度の確認
age(relfrozenxid) AS xid_age,
-- 2億を超えてきたら要注意
2000000000 - age(relfrozenxid) AS xid_remaining
FROM pg_stat_user_tables
JOIN pg_class ON relname = pg_stat_user_tables.relname
WHERE schemaname = 'public'
ORDER BY n_dead_tup DESC
LIMIT 20;
このクエリを週次でSlackに流しているだけで、問題の芽を早期に摘めるようになった。シンプルだけど、これだけで深夜呼び出しのリスクがかなり減る。
テーブル単位でのバキューム設定
高トラフィックなテーブルには個別設定を入れるのが現実的だ。
-- 注文テーブル(高頻度更新)のバキューム設定を強化
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- デフォルト0.2→1%でバキューム発火
autovacuum_analyze_scale_factor = 0.005, -- 0.5%でanalyze実行
autovacuum_vacuum_cost_delay = 2, -- ms(デフォルト20ms→2msで高速化)
autovacuum_vacuum_threshold = 100, -- 最低100行で発火
autovacuum_vacuum_insert_scale_factor = 0.01
);
-- イベントログテーブル(大量INSERT、ほぼ更新なし)
ALTER TABLE event_logs SET (
autovacuum_vacuum_scale_factor = 0.1,
autovacuum_analyze_scale_factor = 0.05,
autovacuum_vacuum_cost_delay = 20 -- デフォルトのまま(I/O優しく)
);
-- パーティションテーブルの場合は親テーブルに設定しても
-- 子テーブルには引き継がれないので要注意(PostgreSQL 16でも同様)
-- 子テーブルに個別に設定が必要
Aurora PostgreSQL特有の注意点
Aurora PostgreSQL 16では、Auroraクラスターストレージのアーキテクチャの関係で、バキューム後もストレージが即座に解放されない。これはBloatが解消されているかどうかをサイズで判断しようとすると混乱する原因になる。pg_freespace や pgstattuple で確認するのが正確だ。
-- pgstattuple で実際のbloat確認(拡張機能が必要)
-- RDS/Auroraでは事前にCREATE EXTENSION pgstattuple;
SELECT
table_len,
tuple_count,
tuple_len,
dead_tuple_count,
dead_tuple_len,
ROUND(dead_tuple_len::numeric / table_len * 100, 2) AS dead_tuple_percent,
free_space,
ROUND(free_space::numeric / table_len * 100, 2) AS free_percent
FROM pgstattuple('public.orders');
このクエリ、実行するとテーブル全体をスキャンするので本番での実行タイミングは慎重に。ピーク時間帯は絶対に避けること。「ちょっと確認したい」の気持ちをぐっと我慢して、深夜か早朝に実行する習慣をつけた。
pg_stat_statementsで遅いクエリを根絶する
Performance Insightsも使っているけど、正直 pg_stat_statements を直接クエリするほうが柔軟に調査できる場面が多い。皆さんはどうしてます?
設定と初期セットアップ
-- Aurora PostgreSQL 16ではデフォルトで有効化されているが念のため確認
SHOW shared_preload_libraries;
-- 出力例: pg_stat_statements,auto_explain
-- 拡張の有効化(初回のみ)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 設定の確認
SHOW pg_stat_statements.max; -- 追跡するクエリ数(デフォルト5000)
SHOW pg_stat_statements.track; -- 追跡レベル(all/top/none)
RDSパラメータグループで設定しておくべき項目をまとめるとこうなる。
| パラメータ | 推奨値 | デフォルト | 備考 |
|---|---|---|---|
pg_stat_statements.max | 10000 | 5000 | 追跡クエリ数 |
pg_stat_statements.track | all | top | サブクエリも追跡 |
pg_stat_statements.track_utility | 1 | 0 | DDLも追跡 |
auto_explain.log_min_duration | 1000 | -1 | 1秒超えるクエリを自動explain |
auto_explain.log_analyze | 1 | 0 | EXPLAINではなくEXPLAIN ANALYZE |
auto_explain.log_buffers | 1 | 0 | バッファ使用量も記録 |
auto_explain は地味に便利で、遅いクエリの実行計画が自動でCloudWatch Logsに流れてくる。ログ量が増えるのでコストと相談だけど、本番で原因不明の遅延が出たときの解析時間が大幅に短縮された。「さっき遅かったクエリのEXPLAINを今から取る」が不要になるのはかなりありがたい。
スロークエリ検出の自動化
-- 上位10件の遅いクエリを抽出(実運用で使っているクエリ)
SELECT
LEFT(query, 100) AS query_snippet,
calls,
ROUND(total_exec_time::numeric / 1000, 2) AS total_exec_sec,
ROUND(mean_exec_time::numeric, 2) AS mean_exec_ms,
ROUND(stddev_exec_time::numeric, 2) AS stddev_exec_ms,
rows,
ROUND(rows::numeric / calls, 2) AS avg_rows,
-- バッファヒット率(低いほどディスクI/Oが多い)
ROUND(
shared_blks_hit::numeric /
NULLIF(shared_blks_hit + shared_blks_read, 0) * 100,
2
) AS cache_hit_ratio,
shared_blks_read AS disk_reads
FROM pg_stat_statements
WHERE calls > 100 -- 呼び出し回数が少ないものは除外
ORDER BY mean_exec_time DESC
LIMIT 10;
cache_hit_ratio が90%を切るクエリは要注意だ。インデックスが効いていない可能性が高い。
インデックス効果の検証例
実際にあった事例。注文検索クエリで mean_exec_time が200ms出ていたのをインデックス追加で3msに改善した。
-- 問題のクエリ(簡略版)
SELECT * FROM orders
WHERE user_id = $1
AND status IN ('pending', 'processing')
AND created_at > NOW() - INTERVAL '30 days'
ORDER BY created_at DESC
LIMIT 50;
-- EXPLAIN ANALYZE の結果
-- Seq Scan on orders (cost=0.00..45123.00 rows=234 width=256)
-- Actual time: 198.234..198.456 ms
-- Rows Removed by Filter: 987654
-- 複合インデックスを追加(CONCURRENTLYで本番影響なし)
CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders(user_id, status, created_at DESC)
WHERE status IN ('pending', 'processing');
-- 追加後のEXPLAIN ANALYZE
-- Index Scan using idx_orders_user_status_created on orders
-- Actual time: 2.891..3.012 ms
-- Index Cond: (user_id = $1 AND ...)
部分インデックス(WHERE status IN (...) の部分)がポイントで、インデックスサイズを抑えながら効果を最大化できる。ここは好み分かれるかもしれないけど、うちのチームではよく使うパターンだ。200msが3msになったときはさすがにガッツポーズした。
CloudWatch + Performance Insights の実践的な使い方
CloudWatch vs Datadog 2026 の記事でも触れたけど、RDSに関してはPerformance Insightsがかなり強力で、Datadogと組み合わせてもコスト効率がいい。
重視しているメトリクスと閾値
xychart-beta
title "Aurora PostgreSQLモニタリング指標(典型的な1日の推移)"
x-axis [0時, 3時, 6時, 9時, 12時, 15時, 18時, 21時, 24時]
y-axis "DBLoad (vCPU)" 0 --> 8
line [0.5, 0.3, 0.2, 2.1, 4.8, 5.2, 4.9, 6.1, 1.2]
bar [0.4, 0.2, 0.1, 1.8, 4.2, 4.8, 4.5, 5.8, 1.0]
DBLoadがvCPU数を超えるとパフォーマンスが劣化し始める。うちでは DBLoad > vCPU * 0.8 でSlackアラートが飛ぶように設定している。CPU使用率よりも実態に即したシグナルが得られるので、アラームの基準としてはこちらのほうが断然おすすめだ。
CloudFormation/CDKでのアラーム設定例
// CDK: Aurora PostgreSQLの主要アラーム設定
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as sns from 'aws-cdk-lib/aws-sns';
const alertTopic = sns.Topic.fromTopicArn(this, 'AlertTopic', topicArn);
// 接続数が多すぎる(pgBouncerの問題かも)
new cloudwatch.Alarm(this, 'RdsConnectionsHigh', {
metric: new cloudwatch.Metric({
namespace: 'AWS/RDS',
metricName: 'DatabaseConnections',
dimensionsMap: { DBClusterIdentifier: cluster.clusterIdentifier },
statistic: 'Average',
period: cdk.Duration.minutes(5),
}),
threshold: 200, // r6g.2xlargeの場合
evaluationPeriods: 3,
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
alarmDescription: 'DB接続数が高い。pgBouncerまたはアプリの接続リークを確認',
});
// WriterのCPU使用率
new cloudwatch.Alarm(this, 'RdsCpuHigh', {
metric: new cloudwatch.Metric({
namespace: 'AWS/RDS',
metricName: 'CPUUtilization',
dimensionsMap: { DBClusterIdentifier: cluster.clusterIdentifier },
statistic: 'Average',
period: cdk.Duration.minutes(5),
}),
threshold: 80,
evaluationPeriods: 3,
alarmDescription: 'Aurora CPU高騰。スロークエリまたはバキュームの暴走を確認',
});
// フリーストレージ(Aurora Serverless v2では不要だが念のため)
new cloudwatch.Alarm(this, 'RdsFreeStorageLow', {
metric: new cloudwatch.Metric({
namespace: 'AWS/RDS',
metricName: 'FreeLocalStorage',
dimensionsMap: { DBClusterIdentifier: cluster.clusterIdentifier },
statistic: 'Average',
period: cdk.Duration.minutes(10),
}),
// 5GB以下でアラート
threshold: 5 * 1024 * 1024 * 1024,
evaluationPeriods: 2,
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
});
正直まだ検証中なんだけど、Aurora Serverless v2のACUスケーリングとPerformance Insightsを組み合わせて、夜間の低負荷時間帯にバキューム・ANALYZEを集中実行するスケジュールを試している。うまくいったら別途記事にする予定。
運用コストの実績
pie title Aurora PostgreSQL 月額コスト内訳(実績)
"インスタンス (r6g.2xlarge×2)" : 52
"Aurora Storage" : 18
"I/O" : 12
"Performance Insights (拡張)" : 8
"バックアップストレージ" : 6
"Data Transfer" : 4
I/Oコストが思ったより高くて最初は驚いた。Aurora I/O Optimizedモードへの移行を検討中だけど、I/O量が一定以上ないとコスト増になるので、実際のI/O数と費用をきちんと計算してから判断することをすすめる。SLI/SLO設計2026完全ガイド でも触れているように、コスト最適化も信頼性設計の一部として考えるようになってからだいぶ判断が変わってきた。
まとめ
Aurora PostgreSQL・RDSの運用で2026年現在実際に効いているポイントをまとめると:
-
pgBouncerはtransaction modeを基本に。prepared statementsとの相性問題を把握した上で選択する。
cl_waitingをモニタリングしてdefault_pool_sizeを動的に調整する仕組みを作る -
自動バキュームの設定はテーブル単位で。
autovacuum_vacuum_scale_factor・autovacuum_vacuum_cost_delayをデフォルトのままにしないこと。特に高頻度更新テーブルは個別チューニング必須 -
pg_stat_statementsのcache_hit_ratioとmean_exec_timeを定期チェック。
CREATE INDEX CONCURRENTLYで本番影響なしにインデックスを追加できるので、問題クエリを見つけたらすぐ対応できる -
CloudWatchアラームはDBLoadをvCPUベースで設定する。CPU使用率より実態に近いシグナルが得られる
-
コスト内訳を定期的に確認する。I/Oコストが想定外に高いケースがあり、Aurora I/O Optimizedモードへの移行判断材料になる
次のアクションとして、まずは pg_stat_statements のセットアップとスロークエリ定期レポートの自動化から始めるのがいい。これだけでも「なんか遅い気がする」から「どのクエリが何ミリ秒使っているか」という具体的な議論ができるようになる。データ品質管理の観点からも、DB層の可視化は重要で データ品質管理2026年版 も合わせて読んでみてほしい。
皆さんのチームでRDS運用で困っていることがあれば、ぜひコメントやXで教えてください。特にAurora Serverless v2のACUチューニングについては情報が少ないので、知見を持っている人と話したい。