よくある問題:誰がデータベースをお掃除してくれるのか?
こういう状況に何度も遭遇してきた。PostgreSQLは快調に動いているのに、数ヶ月後にはaudit_logsテーブルが誰も掃除しないせいで数十GBも膨らんでいる。あるいは日次売上を集計するMATERIALIZED VIEWを毎朝手動でリフレッシュしなければならず、忘れるとレポートの数字がおかしくなる。
そのときの解決策は.shスクリプトを書いてLinuxのcrontabに登録することだった。最初は問題なさそうに見えた。しかしやがて複雑さが増してくる。データベースのパスワードを.pgpassファイルや環境変数に保存しなければならず、スクリプトはサーバー上に散在し、ログはどこで確認するのか?新しいサーバーに移行するとcronの再設定を忘れがちだ。複数のサーバーに複数のデータベースがあるとなれば、もう収拾がつかない。
PostgreSQLにはこういう問題に直面するたびに頼りにしているものがある。それは十分に広いextensionエコシステムだ。ほとんどの場合、外部ツールに頼らなくても済む。そしてpg_cronこそが、あの散らかったスクリプト群を解消してくれるextensionだ。
システムcron + psqlが頭痛の種になる理由
一見すると従来のcronスクリプトはシンプルに見える:
# /etc/cron.d/cleanup-db
0 2 * * * postgres psql -U myuser -d mydb -c "DELETE FROM logs WHERE created_at < NOW() - INTERVAL '30 days';"
しかし実際のproduction環境に入ると、次々と問題が生まれてくる:
- Credential management:パスワードをどこかに保存しなければならない—
.pgpassファイル、環境変数、またはpeer認証の使用。どの方法にもセキュリティや柔軟性のトレードオフがある。 - 履歴の追跡が困難:ジョブは成功したか失敗したか?確認するにはシステムログを見てgrepするしかない。一元的な可視性がない。
- ロジックの分散:データベーススキーマはPostgreSQL内にあるが、ジョブスケジュールはOS側にある。他の人への引き継ぎやディザスタリカバリの際に見落としやすい。
- ポータビリティの欠如:データベースをdumpして別のサーバーにrestoreしても、ジョブは一緒についてこない。cronを最初からセットアップし直す必要がある。
代替手段
pgAgent
pgAgentはpgAdminに付属するジョブスケジューラーだ。cronより強力で管理UIがあり、マルチステップジョブやretryロジックもサポートしている。ただしその分、サーバーにpgAgentデーモンを別途インストールし、管理にはpgAdminが必要で、セットアップがかなり煩雑だ。毎晩ログを削除するだけのジョブのために専用デーモンを立ち上げるのは大げさすぎる。
pg_cron — PostgreSQLに直接統合されたExtension
pg_cronはCitus Data(現在はMicrosoft)によって開発されたextensionだ。PostgreSQLプロセスの内部で直接動作し、おなじみのcron構文を使い、ジョブスケジュールをデータベース内に直接保存する。外部デーモン不要、pgAdmin不要、OS上のスクリプトファイルも不要だ。
最大のメリットは、pg_dumpでデータベースをバックアップすると、pg_cronのジョブメタデータもすべて一緒に保存されることだ。サーバーを移行してrestoreすれば、すぐに動く—cronを最初からセットアップし直す手間がない。
pg_cronのインストールと設定
Ubuntu/Debianへのインストール
# PostgreSQLのリポジトリを追加(まだの場合)
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
sudo apt update
sudo apt install postgresql-16-cron # 16を使用中のPostgreSQLバージョンに変更すること
Amazon RDS / Aurora PostgreSQLへのインストール
朗報として、pg_cronはRDS PostgreSQLとAurora PostgreSQLで利用可能であり、Parameter Groupから有効化するだけでよい:
# RDS Parameter Groupで以下を追加:
shared_preload_libraries = pg_cron
cron.database_name = postgres # pg_cronのメタデータを格納するデータベース
postgresql.confの設定
セルフホストのPostgreSQLの場合、postgresql.confを開いて以下を追加する:
shared_preload_libraries = 'pg_cron'
cron.database_name = 'postgres'
その後、PostgreSQLを再起動する:
sudo systemctl restart postgresql
データベースでextensionを有効化する
-- 'postgres'データベースに接続(またはcron.database_nameで設定したデータベース)
CREATE EXTENSION pg_cron;
-- ジョブをスケジュールするユーザーに権限を付与(スーパーユーザーでなくてよい)
GRANT USAGE ON SCHEMA cron TO myuser;
pg_cronの実際の使い方
基本的な構文
SELECT cron.schedule(
'job-name', -- ジョブ名(一意である必要がある)
'0 2 * * *', -- Cron式(分 時 日 月 曜日)
$$SQL query here$$ -- 実行するSQLコマンド
);
Cron式は標準のUnix cron構文をそのまま使用する。よく使われる例:
'0 2 * * *'— 毎日午前2:00に実行'*/15 * * * *'— 15分ごとに実行'0 0 * * 0'— 毎週日曜日の0:00に実行'0 9 1 * *'— 毎月1日の午前9:00に実行
例1:30日以上古いログを削除する
SELECT cron.schedule(
'delete-old-logs',
'0 3 * * *',
$$DELETE FROM audit_logs WHERE created_at < NOW() - INTERVAL '30 days'$$
);
例2:Materialized Viewを毎時リフレッシュする
SELECT cron.schedule(
'refresh-daily-revenue',
'0 * * * *',
$$REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_revenue$$
);
-- 注意:CONCURRENTLYにはマテリアライズドビューにUNIQUE INDEXが必要
例3:別のデータベースのストアドプロシージャを呼び出す
デフォルトでpg_cronはcron.database_nameで設定されたデータベース上でジョブを実行する。ただしpg_cron v1.4+からは、ターゲットデータベースを指定できる:
SELECT cron.schedule_in_database(
'cleanup-app-db',
'0 4 * * *',
$$CALL cleanup_expired_sessions()$$,
'myapp_production' -- ターゲットデータベース名
);
例4:大きなテーブルの定期的なVACUUMとANALYZE
SELECT cron.schedule(
'vacuum-orders-table',
'30 1 * * 6', -- 毎週土曜日の午前1:30
$$VACUUM ANALYZE orders$$
);
ジョブの管理とモニタリング
アクティブなジョブ一覧を確認する
SELECT jobid, jobname, schedule, command, active, database
FROM cron.job
ORDER BY jobid;
実行履歴を確認する — これがkiller feature
SELECT
jrd.jobid,
j.jobname,
jrd.start_time,
jrd.end_time,
jrd.return_message,
jrd.status
FROM cron.job_run_details jrd
JOIN cron.job j ON j.jobid = jrd.jobid
ORDER BY jrd.start_time DESC
LIMIT 20;
statusカラムはsucceededまたはfailedになる。return_messageカラムには影響を受けた行数、またはジョブがクラッシュした場合はエラーメッセージが入る。もうログをgrepする必要はない。
ジョブを一時的に無効化する
-- 無効化
UPDATE cron.job SET active = false WHERE jobname = 'delete-old-logs';
-- 再有効化
UPDATE cron.job SET active = true WHERE jobname = 'delete-old-logs';
ジョブを削除する
SELECT cron.unschedule('delete-old-logs');
-- またはjobidで指定:
SELECT cron.unschedule(1);
古いジョブ履歴を削除する(job_run_detailsテーブルの肥大化を防ぐ)
productionで数ヶ月運用して得た教訓:cron.job_run_detailsテーブルは思ったより速く蓄積する。5つのジョブが毎時実行されるだけで、90日後には10,000件以上のレコードが溜まる—15分ごとに実行するジョブがある場合はなおさらだ。解決策は?pg_cron自身を使って掃除させることだ:
SELECT cron.schedule(
'cleanup-job-history',
'0 0 * * 0', -- 毎週日曜日の0:00
$$DELETE FROM cron.job_run_details WHERE end_time < NOW() - INTERVAL '7 days'$$
);
実戦から得たTipsとベストプラクティス
1. 意味のあるジョブ名を付ける。ジョブ名はuniqueで、何をするのかを明確に説明するものでなければならない。job-1やcleanupは悪い名前だ。delete-audit-logs-30dやrefresh-mv-daily-revenueは良い名前—見た瞬間に何をするかわかる。
2. タイムゾーンに注意。pg_cronはPostgreSQLサーバーのタイムゾーンで動作し、デフォルトは通常UTCだ。日本時間(JST、UTC+9)の午前2時にジョブを実行したい場合は、UTCで前日の17:00に設定する:
-- 午前2:00 JST (UTC+9) = 前日17:00 UTC
SELECT cron.schedule('nightly-cleanup', '0 17 * * *', $$...$$);
-- またはPostgreSQLのタイムゾーンを確認:
SHOW timezone;
3. アトミック性が必要な場合はトランザクションを使う。複数ステップからなるジョブで失敗時にrollbackが必要な場合は、例外ハンドリング付きのストアドプロシージャにラップする—SQLの文字列をそのままpg_cronに詰め込むのはやめよう。
4. ジョブの失敗を定期的に監視する。このクエリをモニタリングスクリプトやGrafanaアラートに組み込もう:
-- 過去24時間で失敗したジョブを探す
SELECT jobname, start_time, return_message
FROM cron.job_run_details jrd
JOIN cron.job j ON j.jobid = jrd.jobid
WHERE status = 'failed'
AND start_time > NOW() - INTERVAL '24 hours';
5. 重いETLにpg_cronを使いすぎない。pg_cronはcleanup、ビューのリフレッシュ、統計の更新といった軽量なメンテナンスタスクに適している。何百万行も処理し、多くの変換を伴う複雑なETLはAirflowやdbtの仕事であって、pg_cronのものではない。
6. スケジュール登録前に直接呼び出してジョブをテストする。テストしていないSQLをスケジュールに登録してはいけない。まずpsqlで手動実行して結果が正しいことを確認してから、pg_cronに登録する。pg_cronを実験台にするのはやめよう。
cronスクリプトをpg_cronに移行してから、データベースジョブの管理がずっと楽になった。cronを確認するためにサーバーにSSHすることも、エラーを探すためにsyslogをgrepすることもない。すべてをpsqlでクエリでき、pg_dumpと一緒にバックアップされる—新しいサーバーを構築するとき、データベースをrestoreすれば完了だ。

