はじめに
🤔「cron のバッチ、ちゃんと動いているんだろうか…」
😥「ログには『成功』と出ているのに、結果が反映されていない…」
🫠「失敗したら通知が来るはず。来ないから大丈夫…だよね?」
定期実行のバッチは、失敗しても誰も気づかないことがあります。
画面から呼ばれるわけではないので、止まっていても、すぐには困らないからです。
個人開発している口コミサービス あじぴた では、
Supabase の pg_cron で、十数本の定期実行を回しています⏰
そして、リリースの当日に、エラーもログも出ないまま「何もしていない」バッチが見つかりました。
この記事では、
- cron の「成功」が、実は何も保証していないことがある理由
- 「何もしていない」バッチに、どうやって気づくか
- せっかく気づいても、通知が届かない状態を作らない工夫
を、実際の仕組みと一緒に書きます🔔
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
あじぴたの定期実行
あじぴたの定期実行は、pg_cron で動いています。
PostgreSQL の中で、決まった時刻に SQL を実行してくれる仕組みです。
ジョブは、大きく 2 種類に分かれます。
| 種類 | 例 | 何をしているか |
|---|---|---|
| SQL だけで終わる | 味覚の近さの再計算、称号の付与 | データベースの中で計算する |
| 外部(HTTP)を呼ぶ | 退会予告メールの送信、退会時の写真の処理 | ほかのサーバーに HTTP リクエストを送る |
HTTP を呼ぶジョブでは、pg_net という拡張を使っています。
PostgreSQL の中から HTTP リクエストを送れるようにするものです🌐
この「HTTP を呼ぶ」ジョブで、問題が起きました。
リリース当日に見つかった、2 つの「静かな失敗」
リリースの当日、HTTP を呼ぶジョブで、こんなことが起きていました。
| 起きたこと | cron の記録 |
|---|---|
| 呼び出し先の Vercel が、リクエストを認証エラー(401)で弾いていた | 成功 |
| 呼び出し先の URL が登録されておらず、リクエストを 1 件も送っていなかった | 成功 |
どちらも、cron の実行記録には**「成功(succeeded)」**と並んでいました😇
cron の「成功」は、「ポストに入れた」という意味だった
なぜ、失敗しているのに「成功」なのでしょうか。
pg_net の net.http_post は非同期です。SQL は、リクエストを送信待ちの列に並べた時点で終わります。
実際に送るのも、返事を受け取るのも、そのあと別の仕組みが行います。
手紙で言うと、ポストに入れた時点で「送信完了」と記録しているようなものです📮
cron がジョブを実行
→ リクエストを送信待ちの列に並べる
→ SQL はここで終わる ← cron は「成功」と記録する
→ (あとで)実際に送り、返事が届く ← 認証エラーが返っても、cron は知らない
つまり、HTTP を呼ぶジョブの「成功」は、ポストに入れられたという意味でしかありません。
相手に届いたか、ちゃんと処理されたかは、cron の記録からは分からないのです。
2 つ目の失敗は、もっと静かです。
呼び出し先の URL は、Supabase の Vault(秘密の値を保管する場所)から読む作りでした。
ここに URL が無いと、宛先が見つからないので、手紙を 1 通も書かずに終わります。
SQL としてはエラーではないので、これも「成功」です。
「失敗した」より、「何もしていない」のほうがずっと気づきにくい。
仕組みの全体像
ここからは、この 2 つに気づくために作った仕組みの話です。
先に、全体を 4 行でまとめておきます。
① 送るとき … どのジョブが送ったかの「控え」を残す
② 30 分ごと … 届いた返事を控えと突き合わせて、ジョブごとの「最後に成功した時刻」を残す
③ 30 分ごと … 「しばらく成功していないジョブ」などを、異常として拾い出す
④ 異常があれば … Sentry に通知する(Vercel を通らない道で)
順に見ていきます。
①② 返事を、ジョブごとに記録する
HTTP を呼ぶジョブの成否は、相手から返ってきた返事で判断するしかありません。
返事が「200 番台(2xx)」なら成功、それ以外なら失敗です。
SQL だけで終わるジョブは、cron の「成功」をそのまま信じて大丈夫です。
計算そのものが成功したという意味だからです👍
返事には、差出人が書いていなかった
pg_net が受け取った返事は、net._http_response というテーブルに入ります。
ここを見れば、どのジョブが成功したか分かる、と最初は考えました。
ところが、実際に確かめてみると、2 つの問題がありました。
| 問題 | 手紙で言うと |
|---|---|
| 返事に、どこに送ったリクエストの返事かが書かれていない | 返事の封筒に、差出人が書いていない |
| 返事は 6 時間で自動的に消える | 届いた返事は、6 時間で捨てられる |
これでは、どのジョブの返事なのか分かりません🫠
しかも 6 時間で消えるので、1 日 1 回のジョブの成否を、翌日に確かめることもできません。
送るときに控えを残し、消える前に書き写す
そこで、2 つのことをしました。
① 送るときに、「控え」を残す
pg_net は、リクエストを並べるときに**受付番号(request_id)**を返してくれます。
返事にも、同じ受付番号が付いてきます。
そこで、送るときに受付番号とジョブ名の組を、別のテーブルに控えておくことにしました。
返事に差出人が無くても、受付番号から「どのジョブの返事か」が分かります📝
-- 送ったときの控え:受付番号とジョブ名の組
create table public.cron_http_attempt (
request_id bigint primary key, -- 返事にも付いてくる受付番号
job_name text not null,
requested_at timestamptz not null default now()
);
② 30 分ごとに、返事を書き写す
30 分ごとに、届いた返事を控えと突き合わせて、
**ジョブごとの「最後に成功した時刻」**を別のテーブルに書き写します。
返事が消えるまでの 6 時間より十分短い間隔なので、取りこぼしません。
書き写した控えは、用が済んだので消します。
これで、1 日 1 回のジョブでも、翌日まで成否を覚えておけるようになりました✅
書き写すのと消すのは、同時に行う
ここで 1 つ、コードレビューで指摘を受けた落とし穴がありました。
最初は「返事を書き写す」と「控えを消す」を、別々の処理として考えていました。
ところが、pg_net は別のトランザクションで、こちらの処理とは関係なく返事を書き込みます。
1. 届いている返事を書き写す
2. (この瞬間に、新しい返事が届く)
3. 返事が届いている控えを消す ← 2 で届いた分の控えも、書き写さないまま消える
こうなると、その成功の記録は二度と見つかりません。
そこで、「消した控え」をそのまま書き写すという順番にして、1 つの SQL で同時に行うようにしました。
消したものと書き写したものが、必ず一致するようになります。
SQL で見る(記事用に簡略化しています)
with deleted as (
-- 返事が届いている控えを消し、消した行をそのまま次へ渡す
delete from public.cron_http_attempt a
using net._http_response r
where r.id = a.request_id
returning a.job_name, r.status_code, r.created
)
-- 消した行だけを使って、ジョブごとの「最後に成功した時刻」を出す
select job_name,
max(created) filter (where status_code between 200 and 299) as success_at
from deleted
group by job_name;
実際は、この結果を「最後に成功した時刻」のテーブルへ書き込むところまで、1 つの SQL で行っています。
③ 何を「異常」とするか
成否を残せるようになったので、次は異常の拾い出しです。
30 分ごとに、次の 6 つを確かめています。
| 拾い出すもの | 例 |
|---|---|
| ① 一覧にあるのに、cron に登録されていない | 環境を作り直したときの登録漏れ |
| ② 登録はあるが、止まっている | 一時的に止めたまま、戻し忘れた |
| ③ HTTP を呼ぶジョブが、しばらく成功していない | リリース当日の認証エラー |
| ④ SQL のジョブが、しばらく成功していない | SQL のエラー |
| ⑤ 必要な秘密の値が、Vault に無い | リリース当日の URL 未登録 |
| ⑥ cron にあるのに、一覧に無い | 消し忘れ、一覧の更新漏れ |
リリース当日の 2 つは、③と⑤で拾えます🎯
「あるべきジョブの一覧」を持っておく
①と⑥の「一覧」は、テーブルとして持っています。
ジョブごとに、次のことを登録しています。
- HTTP を呼ぶジョブかどうか(成否の判断の仕方が変わるため)
- 何時間成功が無ければ、異常とみなすか
- 動くために必要な秘密の値は何か
「ジョブは 9 本あるはず」のように、数だけで確かめないようにしたのがポイントです。
数だけだと、1 本消えて別の 1 本が増えたときに、数は合っているのに中身がずれていることに気づけません。
「何時間で異常とみなすか」は、ジョブの実行間隔より長めにしています。
1 日 1 回のジョブなら 26 時間(1 日 + 2 時間の余裕)です。
1 回たまたま遅れただけでは、通知が来ないようにするためです。
新しい環境で、全部が一斉に「異常」になった
この一覧を入れたとき、実際に踏んだ問題がありました。
新しく作った環境で、すべてのジョブが一斉に「異常」と判定されたのです🚨
まだ一度も動いていないので、「最後に成功した時刻」がありません。
それを「ずっと成功していない」と判断してしまっていました。
そこで、一覧に監視を始めた時刻も登録し、
「異常とみなす時間」が過ぎるまでは、異常にしないようにしました。
処理が 0 件でも、異常ではない
もう 1 つ、意識して決めたことがあります。
何件処理したかは、判断に使わないことです。
たとえば、退会予告のメールを送るジョブは、対象者がいなければ 0 件で終わります。
これは正常です。
「0 件なら異常」にすると、普段から通知が鳴り続けて、やがて誰も見なくなります。
判断に使うのは、返事が成功だったか、SQL が成功したかだけです。
④ 通知が「届かない」状態を作らない
異常を拾えても、通知が届かなければ意味がありません。
ここにも、いくつか気をつけた点があります。
通知は、監視している相手とは別の道で送る
最初は、アプリ(Vercel)を経由して Sentry に通知する構成を考えていました。
でも、リリース当日の 1 つ目の失敗は、Vercel の手前で弾かれていたものです。
同じ道を通すと、Vercel に届かない失敗は、通知も一緒に届きません。
そこで、役割を 2 つに分けました。
返事の書き写し・異常の拾い出し … データベースの中(SQL)だけで行う
Sentry への通知 … Supabase の Edge Function から送る(Vercel を通らない)
書き写しを SQL の中に置いたのにも、理由があります。
もし Edge Function に置くと、関数が動かない間に 6 時間が過ぎて、
返事が消え、成否が永久に分からなくなるからです。
返事の書き写しだけは、外部の何にも頼らない形にしています🛡️
同じ通知を、何度も送らない
障害は、直るまで続きます。
30 分ごとに確かめているので、何もしないと同じ通知が 30 分ごとに届き、やがて読まれなくなります。
そこで、同じ異常の通知は 6 時間に 1 回までに絞っています🔕
一方で、直った異常は、記録ごと消すようにしました。
直ったあとにまた起きたら、すぐに「新しい異常」として通知したいからです。
「検知の関数」と「通知を選ぶ関数」を分ける
異常を検知する関数と、通知すべきものを選ぶ関数は、別にしています。
| 関数 | 役割 |
|---|---|
検知の関数(check_cron_health()) |
今この瞬間の異常を、すべて返す |
通知を選ぶ関数(claim_cron_health_findings()) |
その中から、6 時間以内に通知していないものだけを選ぶ |
分けておくと、それぞれを別々にテストできます。
1 つにまとめると、通知が来ないときに「異常を拾えていない」のか「6 時間の制限で止めている」のかを、見分けられません。
送れないなら、「送った」ことにしない
「通知を選ぶ関数」は、選んだ時点で**「通知済み」の印**を付けます。
6 時間の制限は、この印を見て判断しています。
ここに落とし穴がありました。
通知を送れない状態でこの関数を呼ぶと、送っていないのに「通知済み」の印だけが付きます。
たとえば、Sentry の「環境名」の設定が漏れていたとします。
Sentry は、本番の環境名が付いたものだけを通知する設定です。
環境名が違うと、データは Sentry に届くのに、通知は飛びません。
それなのに「通知済み」の印は付くので、その異常は 6 時間、誰にも知らされません😇
そこで、Edge Function では先に「送れる状態か」を確かめ、送れないなら関数を呼ばずに終わるようにしました。
// 環境名には既定値を持たせない。production / development 以外なら「送れない」とみなす
const misconfigured = !SENTRY_DSN
? 'sentry_dsn_unset'
: !SENTRY_ENVIRONMENT
? 'sentry_environment_invalid'
: null
if (misconfigured) {
// 通知を選ぶ関数を呼ばない(「通知済み」の印を付けない)
return new Response(JSON.stringify({ skipped: misconfigured, reported: 0 }))
}
「検知できているのに、誰も知らない」は、監視が無いより悪い。
気づく機会があったと、錯覚してしまうからです。
監視しない範囲を、決めておく
最後に、この仕組みが監視しないものも決めています。
この監視自体も cron で動いているので、pg_cron や pg_net が丸ごと止まると、通知も出ません。
通知の仕組みが壊れたことを、同じ通知の仕組みで知らせることはできないからです。
ここは、Supabase 側の障害通知に任せると割り切りました。
この仕組みが受け持つのは、個々のジョブが、静かに何もしていないケースです。
すべてを監視しようとすると、「監視の監視」が必要になり、きりがありません。
どこまでを受け持つかを決めて、それを書き残しておくほうが、後から読む人(と自分)に親切だと考えています📝
まとめ
- HTTP を呼ぶジョブの cron の「成功」は、ポストに入れただけ。成否は相手からの返事で判断する📮
- 返事にはジョブ名が無く 6 時間で消えるので、送るときに控えを残し、消える前に書き写す
- 「あるべきジョブの一覧」を持ち、新しい環境での一斉の誤報と、0 件での誤報を避ける🔍
- 通知は監視する相手と別の道で送り、送れないなら「送った」ことにしない🔔
定期実行は、止まっていてもすぐには困りません。
だからこそ、「失敗した」ではなく「何もしていない」に気づけるかどうかが、監視の分かれ目になると思います💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
🔗 ディレクトリ構成は「AIへの指示書」になる。Next.js App Routerで自分の設計論を答え合わせした話
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
🔗 RLSが壊れてもテストは緑のまま。漏れても気づけない非公開データを、pgTAPで「落ちるテスト」にする
🔗 CI全緑でも、マージしてはいけないPRがあった。GitHub Actionsは「どこで止めるか」で設計する
🔗 新人が初日に迷うことは、AIも毎回迷う。だから暗黙のルールを、すべて言葉にした
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏