共有サーバーのPHP cronでFCMの予約プッシュを送る

8本のFlutterアプリに当選結果を知らせるプッシュを、5分ごとのPHP cronとFCM HTTP v1で送る。データが出たときだけ、1日1回、そして200を信じない。

私たちのFlutterアプリのうち8本は、宝くじや競馬の結果を表示します。これらのアプリが送れる最も役に立つ通知は「結果が出ました」です。このメッセージには厳しい条件が2つあります。結果が出る前に送ってはいけない、そして二重に送ってはいけない。

Firebaseのコンソールでも決まった時刻に送る予約はできますが、抽選結果が実際に公開されたかどうかは確かめられません。そこで予約は自社サーバーで持つことにしました。共有ホスティング上のPHPスクリプトを、cronで5分ごとに動かし、FCMのHTTP v1 APIを直接呼びます。以下は、その作りと、効いた細部です。

SDKなしで送る:素のPHPでFCM HTTP v1

固定のサーバーキーを使う旧FCM APIは使えなくなりました。v1では、サービスアカウントから得るOAuth2のアクセストークンが必要です。SDKなしでやると3段階です。

// 1. サービスアカウントの秘密鍵で署名したJWT(RS256)
$head  = b64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
$claim = b64url(json_encode([
    'iss' => $sa['client_email'],
    'scope' => 'https://www.googleapis.com/auth/firebase.messaging',
    'aud' => 'https://oauth2.googleapis.com/token',
    'iat' => $now, 'exp' => $now + 3600,
]));
openssl_sign("$head.$claim", $sig, $sa['private_key'], 'sha256WithRSAEncryption');

// 2. アクセストークンに交換し、期限の5分前までキャッシュ
$exp = $now + (int)($j['expires_in'] ?? 3600) - 300;
file_put_contents(FCM_TOKEN_CACHE, json_encode(['token' => $tok, 'exp' => $exp]));
@chmod(FCM_TOKEN_CACHE, 0600);

// 3. projects/<id>/messages:send にPOST。401なら新しいトークンを取り、1回だけ再試行

運用して得た細部:

  • トークンはファイルにキャッシュする。 cronの実行は毎回別のPHPプロセスなので、実行をまたぐメモリはありません。トークンは所有者だけが読めるファイル(0600)に置き、期限の5分前に更新します。
  • 401のときの再試行は1回だけ。 期限切れや失効したトークンはよくあることです。それ以外は本物のエラーなので、ループせずログに残します。
  • タイムアウトは短く。 接続8秒、全体15秒。固まった実行は次の実行を妨げます。
  • ドライランには validate_only。 FCMは送らずに検証だけできるので、管理画面のプレビューや cron.php --dry で全経路を安全に試せます。
  • 鍵はサーバーに置く。 サービスアカウントの役割はFCM送信の1つだけで、鍵はWebサーバーが配信を拒否するディレクトリに置いています。

トピックと、「200 OK」が何も意味しない理由

FCM v1には「このアプリのすべてのインストールに送る」呼び出しがありません。送り先はトークン、トピック、条件のいずれかです。各アプリは起動時に <アプリID>_all というトピックを購読し、サーバーはそのトピックに送ります。

落とし穴は、購読者が0人のトピックに送っても200が返ることです。最初にこれでだまされたので、送信関数の上にコメントとして書いてあります。初めてスケジュールを有効にしたとき、トピックを購読するビルドをまだ出していないアプリがありました。ログ上は全部成功に見え、そのメッセージはどこにも届いていませんでした。

到達は端末側で測るしかありません。各メッセージのデータには、ランダムな12文字の push_id を入れます。ユーザーが通知からアプリを開くと、アプリがそれを報告します。

FirebaseMessaging.onMessageOpenedApp.listen((m) {
  final id = m.data['push_id'];
  if (id is String && id.isNotEmpty) AdConfig.reportPushOpen(id);  // config.php?...&ping=1&push_id=
});

アプリが前面にあるときに受け取った通知は、開封として数えません。報告にはアプリが元から使っている設定エンドポイントを ping=1 付きで使うので、起動回数の統計は膨らみません。

iOSでは、APNsのトークンができる前にトピックを購読しようとすると失敗することがあります。アプリは最初の onTokenRefresh で一度だけ購読をやり直します。

cron:5分ごとに動き、送るのは最大1日1回

スケジューラーはホスティングの管理パネルに */5 * * * * で登録し、コマンドラインから php cron.php を実行します。毎回の流れは次のとおりです。

  1. ノンブロッキングの flock を取る。前回の実行がまだ動いていれば skipped: already_running を出して終わる
  2. 有効なスケジュールを読み、それぞれについて「今送るべきか」を判定する
  3. 送信し、試行をログに残し、成功を記録する

「今送るべきか」の判定は、送らない理由を必ず返します。

理由 意味
曜日が違う 今日は抽選がない
already_sent_today スケジュールのタイムゾーンでの今日が last_sent_date と同じ
時刻前 まだ早い
window_closed 時刻+猶予時間を過ぎた。今日はあきらめる
trigger_unavailable 結果のデータ元が読めない。確信がなければ送らない

役に立つのはトリガーです。スケジュールは時計だけでなくデータを待てます。「宝くじのAPIが今日の抽選日を返したら送る」「最新の回号が保存値から変わったら送る」。複数のデータ元は && でつなげます。

いちばん大事なのは、送信済みと記録する行です。

if ($res['ok']) {
    // 成功したときだけ送信済みにする。失敗なら次の回で、猶予時間が終わるまで再試行される
    $today = $nowUtc->setTimezone(new DateTimeZone($row['tz'] ?: 'Asia/Tokyo'))->format('Y-m-d');
    $db->prepare('UPDATE tlabo_ad_push_schedule SET last_sent_date = ?, last_sent_at = NOW() WHERE id = ?')
       ->execute([$today, (int)$row['id']]);
}

失敗した送信は5分後に再試行され、猶予時間が終わるまで続きます。「今日」はスケジュール自身のタイムゾーンで計算します。日本の抽選と米国の抽選は、同じ日付を共有していないからです。

運用上の細部がもう1つあります。何もない回は、cronは何も出力しません。共有ホスティングはcronの出力をアカウントの持ち主にメールで送るので、5分ごとに何か出力するジョブは1日288通のメールになります。出力するのは送信か失敗があったときだけです。実行のたびにハートビートのファイルも書くので、メールを読まなくても「cronが生きているか」を確かめられます。

ぶつかった問題

  • 同じプッシュが2回。 あるアプリのプッシュが、データ元ごとに複数のスケジュール行に分かれていました。各行がそれぞれ独立して「結果が出た」と判断していたのです。データ元をすべて && でつないだ1行にして、判断も送信も1回にしました。
  • PHPの構文解析の落とし穴。 メッセージのテンプレートで、変数の直後にマルチバイト文字がありました("$v≠")。PHPはその文字を変数名の一部として読みました。{$v} と書いて解決しました。
  • タイムゾーンは読者ごと、トピックはそうではない。 トピックはすべての購読者に同時に届きます。米国向けに東部時間9:00に送る朝のプッシュは、太平洋時間では6:00に届きます。地域ごとのトピックにすれば解決しますが、今は既知の制約です。言語グループごとの朝のプッシュは、30分ずつずらしています。

これまでの数字

トピックを購読するアプリは、2026年11月5日にストアで承認されたばかりなので、サーバー側の開封率はまだ集まり始めたところです。比較の基準になるのは、この仕組みより前にコンソールから送っていたプッシュの数字だけです。Google Analyticsでは push_opened が4,688回記録され、最大のアプリではセッションの約11%でした。予約プッシュはこの基準と比べることになります。

チェックリスト

  • [ ] サービスアカウントはFCMの役割だけ。鍵は公開領域の外
  • [ ] アクセストークンは0600のファイルにキャッシュし、期限前に更新、401なら1回だけ再試行
  • [ ] ノンブロッキングのロック、何もない回は出力しない、ハートビートのファイル
  • [ ] FCMがメッセージを受け付けてから送信済みにする。「今日」はスケジュールのタイムゾーンで計算する
  • [ ] 時刻だけでなくデータで送る。データ元が読めなければ送らない
  • [ ] 開封は端末から計測する。200を到達とみなさない

よくある質問

Firebase SDKなしで、素のPHPからFCM v1を送れる?

送れます。サービスアカウントの鍵でJWTに署名し(openssl_sign、RS256)、GoogleのOAuth2トークンエンドポイントで firebase.messaging スコープのアクセストークンに交換し、期限の少し前までキャッシュして、curlで messages:send にPOSTします。

FCMから200が返れば、通知は届いた?

いいえ。トピック送信は、購読者が0人でも200を返します。FCMがメッセージを受け付けたという意味でしかありません。到達は端末側で計測する必要があります。たとえば開封をサーバーに報告させます。

cronが5分ごとに動くのに、同じプッシュを二重に送らないためには?

スケジュールごとに最後に送った日付を、そのスケジュールのタイムゾーンで保存し、送信が成功したときだけ更新します。さらにノンブロッキングのファイルロックで、重なった実行はすぐ終了させます。

  • #FCM
  • #Firebase
  • #プッシュ通知
  • #PHP
  • #cron
  • #Flutter

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑