Vercel Hobby の Cron 2本制限を、1本の dispatcher で乗り切る
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして読んでもらえると幸いです。
はじめに
このサイトは毎朝、いくつかの処理を自動で回している。Strava から走行データを取り込み、当日の結果を OG 画像にして X へ投稿し、日曜なら週次の草グラフも投げ、GitHub のプロフィールを同期し、目標までの進捗バーを更新する。
これだけあると Cron をいくつも仕込みたくなる。ところが Vercel の Hobby プランは、Cron を2本までしか持てない。この記事は、その制約を「1本の dispatcher Cron で複数ジョブを直列実行する」という設計で乗り切った話だ。
Vercel Hobby の Cron は2本まで
まず前提から。Vercel の Hobby(無料)プランでは、プロジェクトあたりの Cron Job が2本に制限されている。
自分の枠は、すでに2本とも埋まっていた。1本は早朝(03:45)の起床チェックイン、もう1本は朝(07:00)の当日ラン投稿。ここに「週次の草グラフ投稿」「GitHub 同期」「進捗バー更新」を足したかったが、3本目以降は作れない。
選択肢は2つあった。Pro プランに上げて Cron 枠を増やすか、既存の2本の中に処理を詰め込むか。
2本目が埋まっていると気づいたときは、軽く「あちゃー」と思った。やりたいことはまだ増えそうなのに、もう枠が無い。お金を払って Pro にすれば一発で解決するのは分かっていたけれど、個人のサイトでそこまでするのもなあ、という気持ちがあった。
それに、無料の枠の中でどうにかする、というのは作っていて普通に楽しい。制限があると、逆に「じゃあどうまとめるか」を考えることになる。今回みたいに「1本にまとめられないか」と考えるきっかけにもなった。だから、お金で枠を増やすより、まず手元の工夫で乗り切る方を選んだ。
結論として、後者を選んだ。追加したい処理はどれも「朝の 07:00 前後に1回動けばいい」もので、わざわざ別 Cron にする必要がなかったからだ。
dispatcher パターン:1本の Cron で複数ジョブを直列実行する
やったことは単純で、朝の post-run Cron 1本の中で、複数のジョブを順番に await で実行するだけだ。1リクエストの寿命の中で全部を直列に流す。
// app/api/cron/post-run/route.ts のヘッダコメントより
/**
* 毎朝 07:00 JST (22:00 UTC) に発火するディスパッチャー Cron。
*
* Vercel Hobby プランの Cron 本数制限 (2 本) を守るため、1 本で:
* 1. Strava の本日走行データを OG 画像化して X へ投稿
* 2. JST 日曜日の場合のみ続けて週次草グラフを投稿
* を連続実行する。
*/
実際の処理順はこうだ。まず Strava を KV に同期し、その後で最新ログを読む。次に当日ランを投稿し、日曜なら週次、続けて GitHub 同期、最後に進捗バー。
// app/api/cron/post-run/route.ts(直列実行の骨格)
// Step 0: Strava → KV 同期 (他ジョブより先に実行)
const stravaSync = await runStravaSyncJob({ dry });
// 同期完了後にログを取得 (KV に最新データが入っている)
const activities = await getAllRunActivities();
const run = await getTodaysRun();
// 当日ラン投稿
const postRun = await runPostRunJob(now, run, { dry, force });
// 日曜だけ週次
const weekly = shouldWeekly ? await runWeeklyJob(now, activities, { dry }) : skipped;
// GitHub 同期
const github = await runGithubSyncJob({ run, now, dry, force });
// 進捗バー
const progress = await runProgressBarJob({
run,
parentTweetId: postRun.tweetId ?? null,
now,
dry,
force,
});
Strava 同期を最初に置いているのは順番に意味があるからだ。後続のジョブは最新の走行データを前提にするので、まず KV を最新にしてからログを読む。getTodaysRun は unstable_cache が効くので、Strava への fetch は1回で済む。取得した run を4つのジョブで使い回している。
各ジョブは戻り値に実行結果(投稿できたか、スキップしたか、エラーか)を持たせて、最後にまとめて JSON で返す。どれか1つでも致命的に失敗したら 502 を返し、Sentry にメッセージを送って朝のうちに気づけるようにしている。
const hadFatalError =
Boolean(postRun.error) ||
(shouldWeekly && Boolean(weekly.error)) ||
Boolean(github.error) ||
Boolean(progress.error);
1本にまとめると聞くと、「1か所でこけたら全部止まるんじゃないか」と不安になる。自分も最初はそこが気になった。
ただ、よく考えると詰め込んだ処理はどれも数秒で終わるもので、朝に1回まとめて動かしても時間が足りなくなる心配はほとんど無かった。それに、どれか1つでも失敗したら全体をエラー扱いにして、すぐ自分に通知が飛ぶようにしてある。黙って止まっているのが一番こわいので、「落ちたら朝のうちに気づける」状態にしておけば、1本にまとめても十分こわくない、と割り切った。
実際、動かしてみて処理が時間切れになったことは今のところ無い。もし将来どれかが重くなって詰まるようなら、そのときに分ければいい。最初から心配して2本に分けるより、まず1本で始めて様子を見る方が、自分には合っていた。
曜日判定で週次ジョブだけ日曜に走らせる
週次の草グラフは、毎日動かす必要はない。日曜の朝にだけ追加で投稿したい。そこで「今日は日曜か」を判定する関数を lib/cron-dispatcher.ts に切り出した。
ここで気をつけたのがタイムゾーンだ。Vercel の実行環境は UTC で動くので、Date.getDay() をそのまま使うと曜日が JST とずれる。日本時間の日曜を確実に拾うために、JST オフセットを足してから曜日を取る。
// lib/cron-dispatcher.ts
const JST_OFFSET_MS = 9 * 60 * 60_000;
export function getJstDayOfWeek(now: Date): JstDayOfWeek {
const shifted = new Date(now.getTime() + JST_OFFSET_MS);
return shifted.getUTCDay() as JstDayOfWeek;
}
/**
* 日曜日の集計ジョブを実行すべきかを判定する。
* JST 基準の日曜が対象 (= ja_JP の週の終わり)。
*/
export function shouldRunWeeklyJob(now: Date): boolean {
return getJstDayOfWeek(now) === 0;
}
この関数は純粋関数なので、now に任意の日時を渡して曜日判定を単体テストできる。「日曜日に true を返すか」「土曜日に false を返すか」を、実際の曜日を待たずに確かめられる。Cron の中に曜日判定を埋め込まず、外に出してテスト可能にしておくのが効いた。
timing-safe な Bearer 比較で cron を認証する
dispatcher は公開エンドポイントなので、誰でも叩けてしまっては困る。Vercel Cron は Authorization: Bearer $CRON_SECRET を自動で付けてくれるので、これを検証する。
比較は素朴な === ではなく、定数時間の比較にした。文字列比較は普通、途中で不一致が見つかった時点で早く返るので、応答時間の差から秘密を1文字ずつ推測される余地がある。それを避けるために、全文字を XOR で舐めてから判定する。
// lib/cron-auth.ts
export function verifyCronRequest(request: Request): CronAuthResult {
const secret = process.env.CRON_SECRET;
if (!secret) {
// 本番で未設定のまま公開しないためのフェイルセーフ。
return { ok: false, status: 500, reason: "CRON_SECRET is not configured" };
}
const authHeader = request.headers.get("authorization") ?? "";
const expected = `Bearer ${secret}`;
if (!timingSafeEqual(authHeader, expected)) {
return { ok: false, status: 401, reason: "unauthorized" };
}
return { ok: true, status: 200 };
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return diff === 0;
}
CRON_SECRET が未設定のときは 500 で止める。設定漏れのまま無防備に公開されるのを防ぐフェイルセーフだ。この認証ガードは Cron だけでなく、外部から叩く場合も同じ Bearer を要求する形にしてあるので、秘密鍵1本で入口を一元化できる。
ジョブを純粋なモジュールに分割する
dispatcher の route 本体は「順番に呼ぶ」係に徹していて、各ジョブの中身は別モジュールに分けてある。Strava 同期は lib/strava-sync.ts、GitHub 同期は lib/github-sync.ts、進捗バーは lib/progress-bar-job.ts。それぞれが結果型(StravaSyncOutcome など)を返す。
こうしておくと、route の中は「どのジョブを、どの条件で、どの順に呼ぶか」というディスパッチの責務だけになる。検証用にクエリパラメータで ?strava=1(Strava だけ)や ?github=1(GitHub だけ)と単独実行できるのも、ジョブが独立しているおかげだ。1本の Cron に詰め込んでいても、中で扱う単位は分かれている。
まとめ
無料枠の制約を、課金ではなく設計で受け止めた話だった。要点だけ拾うと、
- Vercel Hobby の Cron 2本制限に対し、1本の dispatcher Cron で複数ジョブを直列実行
- Strava 同期を先頭に置き、最新データを前提に後続ジョブを流す
- 曜日判定は host TZ に依存しない純粋関数に切り出して単体テスト
- cron 認証は timing-safe な Bearer 比較、未設定時は 500 でフェイルセーフ
- 各ジョブは独立モジュールに分け、route はディスパッチに徹する
朝の07:00に1本の Cron が起きて、走行データの取り込みから SNS 投稿、プロフィール更新までを順番に片付けていく。Cron が2本しか持てないという制約は、結果的にこのサイトの自動化を1か所にまとめるきっかけになった。
シリーズナビ
この記事を書いた人

気に入ったら、次の記事もメールで。月1〜2通だけ。
[ Next Action / 次の動き ]
走行データは、こちらから。
読了ありがとうございます。気が向いたら、生データもどうぞ。