0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「失敗した」より「何もしていない」が怖い。Supabaseのpg_cronとpg_netで作る、静かな失敗に気づく監視

0
Posted at

はじめに

🤔「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)

参考になれば幸いです🙏

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?