pg_cron:システムCronジョブ不要でPostgreSQL内部でジョブを自動スケジュールする

Database tutorial - IT technology blog
Database tutorial - IT technology blog

よくある問題:誰がデータベースをお掃除してくれるのか?

こういう状況に何度も遭遇してきた。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-1cleanupは悪い名前だ。delete-audit-logs-30drefresh-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すれば完了だ。

Share: