Heroku Scheduler からの移行:Next.js 採用時に Vercel Cron Jobs ではなく Cloud Functions for Firebase を選んだ理由

Heroku Scheduler からの移行:Next.js 採用時に Vercel Cron Jobs ではなく Cloud Functions for Firebase を選んだ理由

この記事では、PHP と Symfony(API Platform)で構築して Heroku 上で運用していた業務システムを Next.js へリプレイスする過程で直面した、定期実行タスク(Cron)の設計課題とその解決策を説明します。

Heroku / Symfony 構成が抱えていた運用の限界

対象のシステムは、バックエンドに PHP のフレームワークである Symfony とそのエコシステムである API Platform を採用し、インフラ基盤として Heroku を利用していました。運用開始当時は、REST API を迅速に構築できる API Platform の生産性と、インフラ管理の手間を抑えられる Heroku の PaaS 環境が開発要件に適していました。

しかし、システムの運用期間が長期化するにつれて、保守工数と固定費の双方が増加していきました。Heroku 上で十分なリソースを確保するための Dyno 費用や、データベースをはじめとする各種アドオンの月額費用が積み重なり、アクセス数の変動に関わらず一定のインフラ固定費が発生し続ける状態となっていました。また、フロントエンドとバックエンドが別のコードベース・別のランタイムに分かれていたため、PHP と Node.js それぞれのバージョンアップと依存ライブラリの互換性確認を二重に行う必要があり、追従作業に相応の工数を要していました。

そこで、画面表示から API エンドポイントの提供までを一貫して扱える Next.js へのリプレイスを決定しました。ホスティング先には Next.js の開発元でもある Vercel を採用し、同時にサーバーレスインフラへの移行を進める方針を固めました。

Next.js 移行に伴う定期実行タスク(Cron)の課題

移行にあたって論点となったのが、Heroku 上で動作していた定期実行タスクの移行先です。

既存環境では、Heroku Scheduler アドオンを利用して、日次の集計バッチや顧客向けのリマインド通知配信といった複数のバッチ処理を実行していました。これらの処理は Symfony のコンソールコマンドとして実装されており、決まった時刻に Dyno が立ち上がってコマンドを実行し、終了後に停止する形態をとっていました。

ホスティング先として Vercel を選定したため、定期実行についてもまず Vercel の標準機能である Vercel Cron Jobs を検討しました。これは vercel.json にスケジュールとエンドポイントを定義することで、指定した日時に Next.js の Route Handlers を HTTP リクエスト経由で自動呼び出しできる機能です。追加のインフラを管理せず Next.js のコードベース内で定期処理を完結できるため、有力な選択肢と考えられました。

Vercel Cron Jobs における検証環境の制約

しかし、仕様の確認を進める中で、Vercel Cron Jobs には開発フロー上の制約があることが判明しました。Vercel Cron Jobs は仕様上、Production(本番)環境へのデプロイでのみ実行され、Preview(検証)環境ではトリガーが発火しません。

この挙動は、本番リリース前に Preview 環境でスケジュール実行の検証を行いたいという開発方針と衝突しました。対象システムのバッチには顧客向けのリマインド通知配信が含まれており、スケジュールの誤りはそのまま顧客への通知漏れや誤送信につながります。そのため、本番環境に適用する前に、Preview 環境上で実際にスケジュールに沿って動作するかを確認できる状態を維持する必要がありました。

curl コマンドなどによる手動実行や、テストコードでのモック呼び出しで代用する案も検討しました。しかし、手動実行では Cron の構文ミスやタイムゾーン設定の誤りを検知できません。また、認証ヘッダーの受け渡しを含めたインフラ間の接続検証が本番リリースまで行えない状態は、環境変数の設定不備や認証の失敗による本番障害につながる恐れがあります。そのため、Preview 環境でも本番環境と同様にスケジュール実行を再現できる別の手段を検討することにしました。

解決策としての Cloud Functions for Firebase 採用

Preview 環境での動作保証と運用の安定性を両立するため、定期実行の基盤として Google Cloud の Cloud Functions for Firebase(第 2 世代、以下 Firebase Functions)を採用しました。

Firebase Functions の第 2 世代は Cloud Run 上で動作し、スケジュール実行には Cloud Scheduler を利用します。onSchedule で定義した関数をデプロイすると Cloud Scheduler のジョブが自動的に作成され、指定した日時に Cloud Run 上で関数を実行するコンテナインスタンスが起動します。Firebase のプロジェクトを Preview 環境用と本番環境用でそれぞれ独立して作成すれば、どちらの環境でも本番とまったく同じスケジュール設定でタスクを発火させることができます。

アーキテクチャとしては、Firebase Functions はスケジューラーおよび呼び出し元としての役割に特化させ、実際のビジネスロジックは Next.js 側の Route Handlers に持たせる構成を採りました。Firebase Functions から Next.js の特定エンドポイントに対して HTTP POST リクエストを送信し、Next.js 側で処理を実行します。対象システムのバッチはいずれも数秒から数十秒で完了するため、Vercel Functions の実行時間上限(執筆時点で Hobby は 300 秒、Pro と Enterprise はデフォルト 300 秒・最大 800 秒)の範囲に収まります。なお、Cloud Scheduler が HTTP リクエストの完了を待てる時間は最大 30 分であり、この構成で長時間のバッチを同期的に実行することは想定していません。

Next.js 側のエンドポイントが外部から不正に呼び出されるのを防ぐため、両者の間で共有するシークレットトークンを用いた認証を設けています。シークレットは Firebase Functions 側では defineSecret を使って Secret Manager から読み込み、Next.js 側では Vercel の環境変数として設定しています。

Firebase Functions 側の実装例は次のようになります。

import { onSchedule } from "firebase-functions/v2/scheduler";
import { defineSecret } from "firebase-functions/params";

const cronSecretToken = defineSecret("CRON_SECRET_TOKEN");

export const executeDailyReport = onSchedule(
  {
    schedule: "0 9 * * *",
    timeZone: "Asia/Tokyo",
    region: "asia-northeast1",
    secrets: [cronSecretToken],
  },
  async () => {
    const targetUrl = process.env.APP_BATCH_ENDPOINT;
    const authToken = cronSecretToken.value();

    if (!targetUrl || !authToken) {
      throw new Error("必要な環境変数が設定されていません。");
    }

    const response = await fetch(`${targetUrl}/api/cron/daily-report`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${authToken}`,
        "Content-Type": "application/json",
      },
    });

    if (!response.ok) {
      throw new Error(`バッチ処理の呼び出しに失敗しました: ${response.statusText}`);
    }
  }
);

Next.js 側の Route Handlers(app/api/cron/daily-report/route.ts)では、リクエストヘッダーのトークンを検証します。トークンの比較には、文字列の単純比較ではなく crypto.timingSafeEqual を用いて比較時間からトークンが推測されないようにしています。

import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";

function isValidToken(authHeader: string | null, expectedToken: string): boolean {
  const received = Buffer.from(authHeader ?? "");
  const expected = Buffer.from(`Bearer ${expectedToken}`);
  return received.length === expected.length && timingSafeEqual(received, expected);
}

export async function POST(request: NextRequest) {
  const expectedToken = process.env.CRON_SECRET_TOKEN;

  if (!expectedToken || !isValidToken(request.headers.get("authorization"), expectedToken)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    // ここに日次集計などの業務ロジックを記述します
    return NextResponse.json({ success: true });
  } catch (error) {
    console.error(error);
    return NextResponse.json(
      { error: "Internal Server Error" },
      { status: 500 }
    );
  }
}

Preview 環境向けには、もう一点対応が必要です。Vercel の Preview デプロイには既定で Deployment Protection(Vercel Authentication)がかかっており、外部からの HTTP リクエストはログイン画面にリダイレクトされます。Firebase Functions から Preview 環境のエンドポイントを呼び出すには、Vercel のプロジェクト設定で Protection Bypass for Automation のトークンを発行し、リクエストヘッダー x-vercel-protection-bypass に付与する必要があります。この設定は Preview 環境用の Firebase プロジェクトにのみ持たせ、本番環境用には含めていません。

また、Firebase Functions 側で例外を投げると Cloud Scheduler の設定によっては再試行が行われます。今回は再試行を有効にしていないため、失敗時はエラーログを確認して手動で再実行する運用としています。

この構成により、Next.js のアプリケーションロジックを共通化しつつ、Preview 環境と本番環境の双方で自動実行テストが可能な基盤を整えることができました。

得られた知見

移行の結果、常時起動していた Dyno や固定額のアドオンが不要になり、インフラ費用はアクセスや実行回数に応じた従量課金へと最適化されました。また、PHP と Symfony のランタイム管理から解放され、TypeScript に統一されたコードベースによって保守性も向上しました。

SaaS やサーバーレスプラットフォームの選定では、本番環境の機能性だけでなく、検証環境で実動作をテストできるかという開発サイクルの制約を初期段階で確認する必要があります。Vercel Cron Jobs 自体は設定が容易で有用な機能ですが、Preview 環境での実行可否という要件によっては、外部のスケジューラーと疎結合に組み合わせる構成が妥当な判断となります。

今回の構成では、定期実行の基盤は Vercel と Google Cloud の二つのプラットフォームにまたがることになりましたが、ビジネスロジックは Next.js 側に集約できています。スケジューラーの役割を外部に切り出すことで、将来 Vercel Cron Jobs が Preview 環境に対応した場合や、別のスケジューラーへ乗り換える場合にも、Next.js 側のコードを変更せずに移行できる構成になっています。

すーざん

すーざん

Web Engineer / AI Engineer