目次11
Cloudflare Workers で定期実行を組もうとして最初に当たる壁が、無料プランの cron トリガー上限だった。アカウント単位で5本まで。1つのサービスで巡回・送信・日次保守…と用途ごとに cron を切っていくと、すぐ5本に届く。同じアカウントで別のサービスも動かしたい、となると足りない。
個人でヤスゴロという価格ウォッチャーを Workers + D1 で作って公開した。定期処理は「毎分の通知送信」「5分ごとの巡回」「1日1回の保守」など粒度がバラバラにある。これを cron 5本以内、しかも他サービスと分け合える形にするために、cron を毎分1本に集約して tick の中で時刻分岐する設計にした。その組み方を共有します。
cron は毎分1本、tick の中で時刻を見て振り分ける
やり方は単純で、cron 式は * * * * *(毎分)1本だけ登録し、scheduled() ハンドラの中で現在時刻を見て「今この分は何をやるか」を分岐する。用途ごとに cron を増やさない。
// 唯一の cron 式(毎分)。wrangler.toml の crons と一致させる
export const CRON_TICK = '* * * * *';
export async function runScheduledTick(env: Env, now: Date): Promise<void> {
if (isPatrolTick(now)) await runPatrol(env, now); // 5分毎
if (isKaidokiEveTick(now)) await runKaidokiEve(env, now); // JST23:50
if (isKaidokiDayTick(now)) await runKaidokiDay(env, now); // JST00:00
if (isHousekeepingTick(now)) {
await runHousekeeping(env, now); // JST03:30
await runSaleIngest(env, now);
}
if (isRankingIngestTick(now)) await runRankingIngest(env, now); // JST04:00
await runDispatchCron(env, now); // 毎分(送信)
}
毎分の tick が来るたびに、その分に該当するジョブだけが走る。6種類の定期処理を動かしていても、消費する cron 上限は1本のままだ。scheduled() は ctx.waitUntil(runScheduledTick(env, new Date())) で見届ける。
flowchart TD
Cron["cron * * * * * 毎分1本"] --> Tick[runScheduledTick now]
Tick --> Dispatch[送信: 毎分]
Tick --> Patrol[巡回: 分%5==0]
Tick --> Eve[買い時前夜: UTC14:50]
Tick --> Day[買い時当日: UTC15:00]
Tick --> House[日次保守+取込: UTC18:30]
Tick --> Rank[ランキング取込: UTC19:00]
分岐の述語は UTC で書く
時刻分岐は Date の UTC メソッドで書く。Workers の scheduled に渡る時刻は UTC なので、ローカルタイムのつもりで書くとズレる。日本時間で動かしたいジョブは JST = UTC+9 で換算しておく。
export const isPatrolTick = (n: Date) => n.getUTCMinutes() % 5 === 0;
export const isKaidokiEveTick = (n: Date) => n.getUTCHours() === 14 && n.getUTCMinutes() === 50; // JST23:50
export const isKaidokiDayTick = (n: Date) => n.getUTCHours() === 15 && n.getUTCMinutes() === 0; // JST00:00
export const isHousekeepingTick = (n: Date) => n.getUTCHours() === 18 && n.getUTCMinutes() === 30; // JST03:30
「5分ごと」は getUTCMinutes() % 5 === 0、特定時刻は時と分の一致で書く。述語を1つの関数に切り出しておくと、runScheduledTick 側は「該当したら呼ぶ」を並べるだけで読める。テストでも new Date('...Z') を渡して分岐を単体で確かめられる。
専用 cron を複数置くのと信頼性は変わらない
「毎分1本にまとめると日次処理が不安定になるのでは」と感じるかもしれないが、信頼性は専用 cron と同じだ。理由は依存先が同じだから。
「UTC18:30 に発火する専用 cron」と「毎分 cron の UTC18:30 の発火で時刻を判定する」は、どちらも Cloudflare が その分に cron を発火させること に等しく依存している。Cloudflare が発火を落とせば専用 cron も落ちるし、逆に毎分 tick が来ていれば時刻判定も成立する。集約したことで増える不確実性は無い。
一点だけ運用で効くのは、wrangler.toml の [triggers] crons とコード側の cron 定数を 1文字違わず一致させる こと。ここがズレると発火しないか、意図より多く登録される。yasugoro では両者を突き合わせるテストを1本置いて、ズレをビルドで気づけるようにしている。
無料枠のサブリクエスト(50)にバッチを収める
Workers の無料プランは、1回の実行あたりの外向き fetch(サブリクエスト)が50本までという別の制限がある。cron を集約しても、1 tick の処理がこの50本を超えると失敗する。だからジョブごとに「1件で何サブリクエスト使うか」からバッチサイズを逆算する。
- 巡回: 1件が1リクエスト前後なので、1バッチ40件(
PATROL_BATCH_SIZE)で50に余裕を持たせる - 送信: 1件が「送信直前の再照会」+「配信」で最大2サブリクエスト。だからバッチは送信定数の半分(
PUSH_BATCH_SIZE / 2)に絞る
// 送信: 1件=再照会+配信で2サブリクエスト → バッチは半分に
const batchSize = Math.floor(PUSH_BATCH_SIZE / 2);
await runDispatch(deps, batchSize, now);
1回のバッチで対象を捌き切れないジョブは、対象をローテーションで分割する。ヤスゴロのランキング取り込みは、対象カテゴリを tick ごとに数件ずつに割り、毎日同じ時刻に発火しても1回は数カテゴリに収め、数日かけて全体を一周する(実質週次相当)。50本の壁は「一度に全部やらない」で越える。
依存のあるジョブは呼ぶ順番で吸収する
同じ tick の中でジョブに依存関係があるときは、runScheduledTick での呼ぶ順番で解く。ヤスゴロは送信を tick の最後に置いている。
こうすると、同じ tick の前段(買い時の判定など)がキューに投入した通知を、次の tick を待たずに同じ実行の送信で拾える。順序を逆にすると、投入された分は最短でも1分後の tick まで滞留する。重い日次処理(保守とランキング取り込み)を別の時刻(UTC18:30 と 19:00)に分けているのも同じ発想で、1 tick に負荷とサブリクエストを集中させないためだ。
まとめ
Cloudflare Workers で定期処理をいくつも回したいが cron 上限を食いたくない、というときは、cron を毎分1本に集約して tick の中で UTC 時刻分岐する形が素直だった。専用 cron を並べるのと信頼性は変わらず、消費する上限は1本で済む。効いてくるのはむしろ、1実行50サブリクエストという別の無料枠のほうで、そこはバッチサイズの逆算と「一度に全部やらない」分割で収める。
この cron が実際に何を回しているか——通知をログイン無しでどう届けているかは匿名 device_token で Web Push を届ける話に書いた。動いているものはヤスゴロで、ログイン不要のまま触れる。
よくある質問
なぜ専用 cron を複数置かず、1本にまとめるの?
Cloudflare Workers の無料プランは cron トリガーがアカウント単位で5本までだからです。同じアカウントで複数サービスを動かすと、1 サービスで5本使い切ると他が置けません。yasugoro は cron を毎分1本(* * * * *)だけにして、その1本の中で UTC 時刻を見て各ジョブへ振り分けています。上限を1本しか消費しないので、他サービスと分け合えます。
毎分1本で時刻分岐して、日次処理は確実に動く?
動きます。「専用 cron がその分(例 UTC18:30)に発火する」のと「毎分 cron のその分の発火で時刻を判定する」のは、どちらも同じ Cloudflare の cron 発火に依存します。つまり日次処理の信頼性は専用 cron と同等で、発火が飛べばどちらも同じように飛びます。分岐の述語は getUTCHours() / getUTCMinutes() で書き、JST は UTC+9 で換算します。
Workers 無料枠のサブリクエスト制限にはどう収める?
1 回の実行あたりの外向き fetch(サブリクエスト)は無料プランで50本までです。まず「1件の処理が何サブリクエスト使うか」を数え、そこからバッチサイズを逆算します。yasugoro では巡回を1バッチ40件、送信は1件が再照会+配信で2サブリクエストなのでバッチを PUSH_BATCH_SIZE/2 に絞っています。1回で終わらない取り込みは対象をローテーションで分割し、数日かけて全体を網羅します。
同じ tick で投入したジョブを同じ実行で処理できる?
処理順を決めれば可能です。yasugoro は送信(outbox のディスパッチ)を tick の最後に回しています。こうすると、同じ tick の前段(買い時判定など)が outbox に投入した通知を、同じ実行の中で拾って送れます。順序を逆にすると投入分は次の tick まで待つことになるので、依存関係のあるジョブは呼ぶ順番で吸収します。