Cloud Schedulerで重い繰り返し処理をするな
はじめに
Cloud Functions for Firebaseを使い始めたとき、「インフラを意識することなく、サーバーサイドの処理を簡単に実装できる」という開発体験の良さを感じました。しかし、インフラの仕組みを何も考えずに実装すると、意図しない挙動になってしまうかもしれません。
開発者には裏のGCPの仕組みが見えにくくなっており、初学者にとっては導入しやすい反面、運用・設計ではしっかりとインフラの理解が必要になります。
本記事では、筆者が実際に運用で感じた「実践でよくハマる3つのポイント」に絞って解説したいと思います。具体的には、Cloud Schedulerの設計方法、Pub/Subの冪等性、グローバル変数の扱いについてです。
Cloud Functions for Firebaseとは
Cloud Functionsとは、関数単位でデプロイできるGCPのサーバーレス コンピューティングサービスです。AWSのLambdaと似た位置付けのサービスといえるでしょう。
サーバーレス フレームワークです。バックグラウンド イベント、HTTPS リクエスト、Admin SDK、Cloud Scheduler ジョブによってトリガーされたイベントに応じて、バックエンド コードを自動的に実行できます。JavaScript、TypeScript、または Python のコードは Google Cloud インフラストラクチャに保存され、マネージド環境で実行されます。独自のサーバーを管理およびスケーリングする必要はありません。
と説明されています。
Cloud Functionsとの違い
Cloud Functions for FirebaseはGoogle Cloud Functionsを簡素化してFirebaseで使えるようにしたものです。
基本的には同じサービスですが、CFFの特徴はFirebase Hosting、Auth、Firestore、Realtime DBと強く統合されている点です。Firebase CLIでデプロイでき、Firebaseプロジェクトに紐づく開発がしやすくなっています。
初学者にとっては導入しやすいメリットがある一方で、裏のGCPの仕組みが見えにくくなっているため、運用で思わぬ落とし穴にハマることがあります。
Cloud Functionsのライフサイクル
Cloud Functionsはイベント駆動で動くサーバーレス関数です。関数には次のようなライフサイクルがあります。
- トリガーされる(HTTP, Pub/Sub, Firestore, etc.)
- Cloud Functions インスタンスが起動(cold startまたはwarm start)
- 関数の処理が実行される
- 処理が完了すると、インスタンスは一時的にアイドル状態(warm状態)になる
- 一定時間アイドル状態が続くと、インスタンスは削除される(シャットダウン)
cold startが起きると処理遅延やタイムアウトリスクが発生します。また、利用するトリガー(HTTP、Firestore、Pub/Subなど)によってcold startの傾向も異なってきます。
- HTTP Functions: 比較的速い(~100ms〜500ms)
- Firestore/Storageトリガー: より遅くなることがある(~1秒〜数秒)
これらの原因としては、起動に必要な依存モジュールの読み込みやリージョンによる差があります。対策として、不要な依存を削減したり、keep warm対策(Cloud Scheduler + ping)を入れたり、minInstancesを活用するなどの方法があります。
Cloud Functionsのスケールの仕組み
Cloud Functionsでは同時リクエストを処理する際、複数のインスタンスを並行実行します。
-
並行実行(Concurrent execution)
- 同じ Function でも複数のリクエストが同時に来ると、複数のインスタンスが並行して立ち上がります。
- 順番に処理されるのではなく、同時に処理されます。
-
インスタンスの管理
- Google Cloud が自動的にインスタンス数を調整
- 負荷に応じてスケールアップ・ダウン
- 最大並行数の制限もあります(デフォルトは1000インスタンス)
Cloud Schedulerで重い繰り返し処理をしたいとき
Cloud Functionsのライフサイクルとスケールの仕組みを理解したところで、本題に入ります。
Cloud Schedulerは定期実行に便利なサービスですが、関数1つで重い処理を行うと失敗リスクが高いというのが筆者の実感です。
例えば、全ユーザーへの通知送信や、複数ユーザーへの電話発信などの処理を想像してみてください。
これらを1つの関数で処理しようとすると、以下の問題が発生します:
- Cloud Functionsはデフォルトで最大540秒までしか実行できない
- 処理対象が多いとタイムアウトしやすい
- 一部の処理が失敗した場合の復旧が困難
解決策:処理の分割
筆者が推奨する解決策は、「処理のトリガーをFirestoreやPub/Subに分割」することです。具体的には:
- Schedulerではトリガードキュメントのみを作成
- 実処理はonCreate関数で並列実行させる構成にする
この構成にすることで、スケーラブルで安全な処理が可能になります。Schedulerは「きっかけ」だけ作る役割に徹し、実際の処理は並列実行されるトリガー関数に切り出すのがベストプラクティスです。
Pub/Subが2回実行されてしまうかもしれない対策
Pub/Subトリガーの重要な特性として、at-least-once delivery(少なくとも1回配信)があります。
つまり、同じメッセージが2回以上届くのは仕様通りであり、関数が2回動いても正常な挙動です。この特性を知らないと「意図しない2重処理」が発生してしまいます。
対策:冪等性を確保する
意図しない2重処理の対策として、副作用(メール送信、決済、通知など)を伴う処理では冪等性(idempotency)を確保する必要があります。
具体的な対策例:
- messageIdを使って処理済みチェック
- Firestoreに処理済みフラグを保存
- トランザクションや条件付き書き込みを活用
これらの対策により、同じメッセージが複数回届いても、副作用が重複して発生することを防げます。
筆者は冪等性を確保するため、triggerOnceというユーティリティ関数を作成しています。
こちらのユーティリティ関数でPub/Subの処理をラップすることで、意図しない2重処理を防いでいます。
下記のように使用します。
Cloud Functionsでグローバル変数を使いたいとき
初心者がよくやりがちなパターンとして、「グローバル変数に状態を持たせる設計」があります。しかし、これはwarm状態で問題になります。
状態(カウンタ、キャッシュなど)が前回の処理から残っている可能性があるため、意図しない挙動を引き起こすことがあります。
基本的な考え方
Cloud Functionsはステートレスを前提とするのが基本です。どうしてもグローバルに値を持たせたいときは、以下の条件に限定することをおすすめします:
- 読み取り専用(設定値、接続プールなど)に限定
- 状態管理はDBなどの永続層に逃がす
一方で、DB接続やSDK初期化のような高コストな処理は、lazy initでグローバルに置くのが良いパターンです。これにより、warm状態でのパフォーマンスを向上させることができます。
さいごに
Firebase Functionsは確かに便利なサービスで、インフラを意識することなくサーバーサイドの処理を実装できるため、初心者にも使いやすいツールだと思います。しかし、GCPベースの仕組みを正しく理解していないと思わぬ落とし穴にハマってしまうかもしれません。
本記事で紹介した3つのポイント(Cloud Schedulerの設計、グローバル変数の扱い、Pub/Subの冪等性)は、小規模開発では気づかずにスルーされがちですが、実運用やスケーラビリティの観点では非常に大きな差になってきます。
この記事がFirebase Functionsの設計・運用を一歩進めるきっかけになれば幸いです。
Discussion