個人開発でSupabase & Vercel を利用していて、Cronで定期実行したいタスクが発生しました。
コストはかけたくなかったのでSupabaseの無料枠で実行可能な方法を見つけたので個人の備忘録も兼ねて記事化しました。
3行まとめ
- 「定期的にアプリのロジックを実行したい」を、アプリ側にスケジューラを持たず Postgres の pg_cron + pg_net だけで実現する構成
- pg_cron で時間発火し、pg_net で自分の HTTP エンドポイントを叩くことで、SQLでは書けない処理(外部SDK呼び出しなど)も定期実行
- 非同期・秘密管理・履歴肥大・ローカル再現などハマりどころが多いので、勘所とセット
何を解決したいか
「一定時間ごとに、ある条件を満たしたレコードをまとめて処理したい」という要件はよくある。例えば:
- 期限切れの pending レコードを確定・失効させる
- 一定時間放置されたタスクをタイムアウト扱いにする
- 定期的に集計してスナップショットを作る
こういう 時刻起点のバッチ は、ユーザーのリクエストからは駆動されない。誰かがアクセスしなくても、時間が来たら勝手に走ってほしい。だから定期実行の仕組みが要る。
素直な選択肢は「アプリのホスト側の cron」(Vercel Cron、GitHub Actions、外部スケジューラ)だ。ただしそれぞれに制約がある。例えば Vercel Cron は Hobby プランだと1日1回までで、1時間粒度で回したいだけでも上位プランが必要になったりする。
そこで今回は スケジューラを DB(Postgres)の中に持たせる 構成を採った。頻度にプラン別の上限がなく、Supabase の無料プランでも毎分レベルで回せる。「安く・そこそこの頻度で・自分のAPIを叩きたい」という要件にはこれが驚くほどハマる。
全体像
肝は「処理本体は SQL では書けない」ケースが多いこと。外部SDK(決済・メール・プッシュ等)を呼ぶ処理は当然アプリ側(TypeScript / 任意の言語)にある。SQLだけで完結しない。
そこで「pg_cron が時間で発火 → pg_net でアプリの HTTP エンドポイントを叩く → その先はいつものアプリコード」という橋渡し構成にする。
[Postgres] [App (Next.js など)]
pg_cron ──(毎時0分に発火)──▶ SQL
└─ net.http_post(...) ──HTTP POST──▶ /api/cron/xxx
└─ 本来のロジック(外部SDK含む)
DBは「時間で発火 → 自分のAPIにHTTPを投げる」までを担い、実処理は普段どおりアプリ、という分担になる。
pg_cron:Postgres の中に住むスケジューラ
pg_cron は「cron デーモンを DB の中に埋め込む」拡張だ。登録したスケジュールで SQL を実行してくれる。時計を持っているのはこいつ。
create extension if not exists pg_cron;
select cron.schedule(
'my-job',
'0 * * * *', -- 毎時0分(cron書式)
$$ select 1; $$ -- 実行するSQL
);
cron.schedule('ジョブ名', 'スケジュール', 'SQL') を1回登録すると、その内容が cron.job テーブルに保存され、以後 DB が生きている限り永続的に回る。アプリの再デプロイやサーバー再起動とは無関係に動くのが、外部スケジューラとの一番の違いだ。
管理はすべて DB のテーブルで完結する。
| テーブル | 中身 |
|---|---|
cron.job |
登録済みジョブ(スケジュール・SQL本文) |
cron.job_run_details |
各回の実行履歴(開始/終了・成否) |
select jobname, schedule, active from cron.job;
select status, return_message, start_time
from cron.job_run_details order by start_time desc limit 5;
⏰ タイムゾーン注意:pg_cron は DB の
TimeZone基準で動く。Supabase のデフォルトは UTC なので、ローカルタイムの特定時刻に寄せたいなら cron 式を UTC で計算する(例: JST 9:00 =0 0 * * *)。最小粒度は1分、'30 seconds'形式でサブ分スケジュールも可(Postgres 15.1.1.61+)。
pg_net:DBから外へHTTPを投げる
pg_cron は SQL しか実行できない。アプリのロジックを起動するには、SQL の中から HTTP を投げる必要がある。それが pg_net だ。
create extension if not exists pg_net;
select net.http_post(
url := 'https://example.com/api/cron/xxx',
headers := jsonb_build_object('Authorization', 'Bearer ...'),
timeout_milliseconds := 20000
);
ここで pg_net は非同期 なのが最大の注意点だ。http_post は「リクエストをキューに積んだ」時点で即 return する。実際のレスポンス(成否・ステータス・本文)は、あとから net._http_response テーブルに溜まる。
select status_code, content, error_msg
from net._http_response order by created desc limit 1;
つまり cron.job_run_details 上はジョブ成功に見えても、アプリが実際に処理できたかは別で確認する必要がある。「投げた成功」と「届いて処理された成功」は違う。
もう一つ、timeout_milliseconds のデフォルトは 2000ms。外部SDKを含む処理は2秒を超えがちなので、明示的に伸ばしておく(今回は 20000ms)。
Vault:秘密をSQLに埋めない
pg_net から叩くエンドポイントは無防備には晒せない。今回は共有シークレットを Authorization: Bearer で検証する方式にした(署名検証の代わり。webhook と同じ発想)。
この秘密を migration にベタ書きすると git に残る。Supabase Vault に逃がし、SQL からは名前で参照する。
select vault.create_secret('https://example.com/api/cron/xxx', 'app_endpoint_url');
select vault.create_secret('<ランダムな長い文字列>', 'cron_secret');
-- cron の SQL 内では復号ビューから名前で引く
url := (select decrypted_secret from vault.decrypted_secrets where name = 'app_endpoint_url')
同じ秘密値をアプリ側の環境変数にも入れ、エンドポイントで突き合わせる。秘密の実体が置かれるのは Vault とアプリ env の2箇所だけ、migration には名前しか出ない。
秘密値は高エントロピーなランダム文字列を使う。ヘッダ・env・Vaultのどこに置いても escape 事故が起きない16進が扱いやすい。
openssl rand -hex 32 # 64文字のhex
実装:migration全体
create extension if not exists pg_cron;
create extension if not exists pg_net;
-- 冪等に組み直せるよう、既存ジョブがあれば解除してから登録
select cron.unschedule('my-job')
where exists (select 1 from cron.job where jobname = 'my-job');
select cron.schedule(
'my-job',
'0 * * * *',
$$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'app_endpoint_url'),
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization',
'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'cron_secret')
),
-- 外部SDKを含むと既定2000msでは足りない。余裕を持たせる
timeout_milliseconds := 20000
);
$$
);
アプリ側:薄いエンドポイント + 冪等な処理
エンドポイントは「秘密の検証」と「ロジックへの委譲」だけの薄いコントローラにする。session認証はなく、署名の代わりに共有シークレットで本人性を担保する。
export async function POST(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) return Response.json({ error: "not configured" }, { status: 500 });
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${secret}`) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
const result = await runBatch(); // 本来の処理
return Response.json(result); // { processed, settled, errors } など
}
バッチ本体では 冪等性 が効いてくる。時刻起点のバッチは「対象を毎回クエリで拾い直す」形にし、処理済みかどうかを状態で判別できるようにしておく。
- 処理対象は「まだ処理していないもの(例:
status = 'pending'かつdeadline < now())」だけを毎回クエリで拾う。 - 処理すると状態が変わる(
pending → done)ので、同じ行を二度処理しない。 - pg_net が非同期でたまに取りこぼしても、次回実行で「まだ pending の行」として再度拾える。
pg_net の非同期・不確実性という弱点を、バッチを冪等にすることで実質無害化しているわけだ。副作用(課金・送信など)を伴う処理では、この「対象は未処理のみ・処理後に状態遷移」の形が二重実行防止の要になる。
for (const row of overdue) {
try {
await settle(row);
settled += 1;
} catch (err) {
console.error(`[my-job] settle FAILED id=${row.id} (will retry next run)`, err);
errors += 1; // 1件のエラーで全体を止めない。次回実行で拾い直す
}
}
ハマりどころ集
1. pg_net は非同期
前述。job_run_details の成功 ≠ アプリが処理した。実処理は net._http_response かアプリのランタイムログで見る。
2. デフォルト2秒タイムアウト
外部API呼び出しを含むなら timeout_milliseconds を伸ばす。
3. cron.job_run_details は自動で消えない
毎回1行溜まる(毎時なら年8,760行 ≒ 数MB)。量自体は微々たるものだが、pg_cron は自動purgeしないので放置すると無限に増える。保持期間を切る掃除ジョブを1本足すのが定石。
select cron.schedule('purge-cron-history', '30 3 * * *',
$$ delete from cron.job_run_details where end_time < now() - interval '30 days'; $$);
なお net._http_response の方は pg_net 自身がTTLで自動削除するので放置でよい。手動管理が要るのは job_run_details だけ。
4. マネージド無料プランの自動ポーズ
Supabase Free などはDBアクティビティが一定期間少ないとプロジェクトがポーズされ、pg_cron も止まる。アプリが普通に使われていれば非問題だが、無風の初期は注意。
5. ローカル再現のネットワーク
cron→pg_net の一連の流れをローカル(supabase start 等の Docker スタック)で再現しようとすると、Postgres は Docker 内なので、そこから見た localhost はコンテナ自身。ホストのdevサーバー(localhost:3000)には host.docker.internal:3000 でしか届かない。
現実的には、cron/pg_net の配線はリモートで、エンドポイントとロジックはローカルで curl で と役割分担するのがラク。ロジック検証に cron は要らない。
curl -X POST http://localhost:3000/api/cron/xxx \
-H "Authorization: Bearer <ローカルのCRON_SECRET>"
動作確認の手順
毎時を待たず、cron が実行するのと同じSQLを手で叩けば配線を即検証できる。
-- 手動発火(cronの中身と同じ)
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name='app_endpoint_url'),
headers := jsonb_build_object('Authorization','Bearer ' ||
(select decrypted_secret from vault.decrypted_secrets where name='cron_secret')),
timeout_milliseconds := 20000
);
-- 数秒後、アプリが返したものを見る
select status_code, content from net._http_response order by created desc limit 1;
-- → 200 と期待するJSONが返れば pg_cron → pg_net → アプリ が全部疎通
status_code = 401 なら Vault の秘密とアプリ側の秘密がズレている。処理結果まで見たいなら、対象になる行をテスト用に用意して発火し、状態が遷移することを確認する。もう一度発火して対象が0件なら冪等性もOK。
まとめ
- pg_cron + pg_net なら、アプリにスケジューラを置かず、DBの中で「時間で発火 → 自分のAPIを叩く」まで完結できる。頻度上限がなく低コスト。SQLで書けない処理も、エンドポイント越しに定期実行できる。
- 代償は pg_net の非同期性・秘密管理・履歴肥大。特に バッチを冪等にすることが、非同期の不確実性を吸収する鍵になる。
- 秘密は Vault + アプリenv に寄せ、migration には名前しか残さない。
- インフラ機能(cron/pg_net)はリモートで、ロジックはローカルで、と検証を分けると開発が回しやすい。
「DBにcronを埋め込む」は一見トリッキーだが、要件が「安く・そこそこの頻度で・自分のAPIを叩きたい」なら、驚くほど素直な選択肢になる。