💰

予算の上限に達したらFirestoreを自動で停止するやつで請求青天井の恐怖に怯える日々を卒業する

2022/10/20に公開

先日、こちらの記事を拝見したのですが、Firebase卒業の理由1位に 「クラウド破産の恐怖」 が挙げられていました。

https://zenn.dev/mitsuruokura/articles/5ec6511efeff48

Firetoreは並列性の高いデータベースですから、ちゃんと作っていれば100万人同時接続でもびくともしません。しかし逆に言えば、もしプログラムに誤りがあって大量の無駄な読み書きが発生すれば、ものすごい勢いでコストが嵩んでいくことになります。実際私も何度か開発中にコーディングミスをして無限再読み込みをしてしまったことがあります。その時は明らかにアプリの動作速度が低下したのですぐに気付きましたが、この危険については私もそれなりに気になるところではあります。そこで、 予算をオーバーしたらFirestoreを自動で停止する仕組み を作ってみることにしました。

プログラムから予算とコストを管理する

プロジェクトの現在のコストや予算の通知を受ける方法については、以下の公式ドキュメントに書いてあります。

https://cloud.google.com/billing/docs/how-to/budgets-programmatic-notifications?hl=ja

具体的には、Pub/Subトピックを作成してCloud Billingと接続することで、一定時間ごとにコストや予算の通知を受けることができます。ここでは、budget-alertという名前でトピックを作成しました。このトピックをトリガーに何か処理を行うには、FirebaseであればCloud Functions for Firebaseをそのトピックをトリガーに起動するようにすればいいでしょう。Cloud Functionsは次のように書きました。

import * as functions from "firebase-functions";
import * as pubsub from "@google-cloud/pubsub";

export const budgetAlert = functions.pubsub
  .topic("budget-alert")
  .onPublish(async (message, context) => {
    functions.logger.log("attribute", message.attributes);
    functions.logger.log("data", message.json);
    if (message.json.budgetAmount <= message.json.costAmount) {
      const ps = new pubsub.PubSub();
      const topic = ps.topic("disable-firestore");
      await topic.publishMessage({
        data: `Reached your budget limit! Disabling Firestore...`,
      });
    }
  });

functions.pubsub.topic("budget-alert")でトピック budget-alert を監視して、 budget-alert のメッセージが発行されたらこの onPublishの中身が実行されます。引数の message に予算や現在のコストなどのデータが入っていますので、コスト message.json.costAmount が予算 message.json.budgetAmount を超えたら、別のトピック disable-firestore を発行しています。

Firestoreを一時停止する

予算とコストに応じて何らかの処理を行うことはできましたので、次はコストが予算を超えたときにFirestoreを停止する処理を書きます。といっても、実はFirestoreを一時停止する一般的な方法はありません。仕方がないので、すべてのアクセスを禁止するセキュリティルールをデプロイすることでFirestoreを使えなくする 作戦でいこうと思います[1]

具体的には、上で説明した disable-firestore が発行されたとき、それをトリガーにしてCloud BuildでFirestoreのデプロイを行い、セキュリティルールを上書きします。ここでは詳しい方法は説明しませんが、Cloud Build上でFirebaseのデプロイを行う方法はこのあたりのドキュメントに載っています。

https://cloud.google.com/build/docs/deploying-builds/deploy-firebase?hl=ja

cloudbuild.yamlfirestore.rulesはこんな感じです。

cloudbuild.yaml
steps:
  - name: gcr.io/プロジェクト名/firebase:latest
    args: ["deploy", "--project=プロジェクト名", "--only=firestore"]
firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

実際に動かしてみる

それでは実際に動かしてみます。予算を5円に設定し(ケチ)、前述の Cloud Functions budgetAlert をデプロイします。Cloud Functionsが呼び出されると、引数の message.datamessage.attributes には次のような感じのデータが入っていました。確かに予算と現在のコストがわかります。

message.attributes
{
	billingAccountId: "xxxxx-xxxxx-xxxxx"
	budgetId: "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyyyy"
	message: "attribute"
	schemaVersion: "1.0"
}
message.data
 {
	alertThresholdExceeded: 1
	budgetAmount: 5
	budgetAmountType: "SPECIFIED_AMOUNT"
	budgetDisplayName: "Firebase Project"
	costAmount: 0.9
	costIntervalStart: "2022-10-01T07:00:00Z"
	currencyCode: "JPY"
	message: "data"
}

ちなみにこの関数は、どうも30分に1回くらい呼ばれてる感じにみえます。ドキュメントには「1日に数回」という記述があったのですが、それよりはもう少し頻度が高い感じです。予算を超えたら最大30分程度で停止できそうです。

適当なクライアントプログラムを作り、Firestoreを50000回くらい読み書きしてコストを発生させます。

次にCloud Functionsが呼び出されたときにdisable-firestoreトピックが発行されてCloud Build上でビルドが実行され、Firestoreのセキュリティルールが更新されます。

これでFirestoreを読み書きしようとしてもセキュリティのエラーになり、これ以上のコストは発生しません。こんな感じで、予算に応じたFirestoreの自動停止を実証できました。まあちょっと設定は面倒なのですが、やろうと思えばぜんぜんできなくはない、という感じです。

そのほかのFirebaseのヒント

件の記事では他にもいろいろな Firebase卒業ポイント が指摘されていますが、それについても簡単に見ていきましょう。

Firebaseの各種制限については、もちろん使い方次第ではあるのですが、設計の見直しで制限を回避できる余地はありそうに思います。件の記事ではコストをケチって1ドキュメントに多数のデータを詰め込むようなことをしていたというような記述がありますが、そういう設計はFirestoreでは基本的にNGです。それが原因でFirestoreのドキュメントのサイズ制限にひっかかってしまったのではないかという疑いはあります。

また、ドキュメント自体に、それにアクセスできるユーザーのIDのリストを入れるというのは、普通に考えれば設計ミスだと思われます。どうしてもドキュメント単位、ユーザー単位でアクセス制御をしたいのであれば、そのドキュメントのサブドキュメントとして権限を表すドキュメントを用意すればいいと思います。

ドキュメントの件数単位の課金については、先述したようにコストを抑えるために1ドキュメントにデータを詰め込む設計は避けるべきです。ではどうやってコストを押さえればいいのかというと、まず検討するべき方法はデータバンドルでしょう。

FirestoreのIPアドレス制限については、IPアドレスの制限が欲しくなるようなアプリは、そもそもあまりFirestore向きではない可能性があります。Firestoreは様々な強い制限と引き換えに多数のクライアントの同時接続をさばけるところが長所です。しかし、IPアドレスで接続を制限するということはつまり多数のクライアントの同時接続が想定されていないということで、Firestoreの長所が生かされずFirestoreの制限ばかりに縛られることになってしまいます。

また、Firestoreのセキュリティを向上させるには、App Checkの使用も検討しても良さそうです。それでもどうしてもIPアドレスで制限したければ、Cloud Functions越しにアクセスさせる手がないわけではありません。

Firebase SDKのモックについては、エミュレータを使えばいいでしょう。単なるモックではなく、デプロイ後の動作とまったく同じ動作をローカルで試せるので、モックでテストするよりも更に信頼性の高いテストができます。また、Firestoreのセキュリティルールのエラーの詳細はエミュレータでしか取得できないので、Firestoreを使うならエミュレータは必須だといっても過言ではありません。

セキュリティルールのつらさについては私も首肯するところなのですが、セキュリティにとって致命的な部分に限ってセキュリティルールを書くように私はしています。これは、セキュリティルールにデータの整合性の検証を書いてしまうと、本当にセキュリティにとって重要な記述が埋もれてしまって危険だと思うからです。もちろんデータベース側でデータの整合性まで厳密に検証できればいいのですが、Firestoreはそういうことができないデータベースだと割り切っています。データの整合性のチェックについては、各クライアント側で zod などを使って検証を行っています。逆にいえば、データの厳密な整合性が必須なアプリであれば、今すぐFirestoreなんて使うのは止めてRDBMSに乗り換えてしまうのがいいと思います。

Storybookの件についてはまったくの濡れ衣でしょう。別にFirebaseでなくても同じことがいえるはずです。

Cloud Functionsのデプロイ時間の遅さについては、Cloud Functionsのデプロイはまあまあ遅いとはいえ、30分以上かかるのは「異常に遅い」というレベルかなと思います。Cloud Functionsだから必ず遅くなるということではないはずなので、デプロイの最適化を試してみる価値はあります。たとえば、依存関係のバージョンを固定することでデプロイ時間が短縮できるという話があります。

Cloud Functionsの起動の遅さについては、Cloud Functionsの数の多さがそのまま起動速度の低下につながっている可能性があります。というのも、Cloud Functions for Firebase では index.ts にすべての関数のエントリポイントがエクスポートされるので、普通に書くと不要なモジュールまで読み込んでしまうのです。関数が呼ばれたときに本当に必要なモジュールだけを読み込むようにすると起動時間を短縮できるというテクニックがあります。

クライアントコードの肥大については私もつらいと思っているところですが、そもそもReactのようなクライアントサイドのUIフレームワークを使っていると、どうしてもクライアントサイドのコードは増えていきます。もちろん場合によるのですが、コードの肥大が必ずしも具体的に大きな問題を引き起こすわけではないですし、昨今のクライアントサイド偏重の傾向としてやむを得ないものだとして受け入れています。また、どうしてもクライアントサイドのコードを減らしたければ、これもCloud Functionsなどでサーバーサイドに持ってくる手もないわけではないです。

さいごに

私もそれなりにFirebaseを使い込んでいて、つらみもかなり実感しつつあります。データベースについても、全面的にFirestoreを使うのは止めようかなと思っているところです。とはいえ、件の記事で指摘されている点については、使い方しだいで十分解決できるものが多いように思います。Firebaseつらいなあと思っている人は、もう一度公式ドキュメントを見返してみるとよりより開発のためのヒントが見つかるかもしれません。

脚注
  1. 別にセキュリティルールのデプロイ自体を行わなくても、Firestoreの特定のひとつのドキュメントの状態ですべてのセキュリティルールを無効にできるようなマスターなフラグを定義して止める方法もあるとは思います。でもそれをやるとすべてのアクセスでそのドキュメントを読むことになってコストが増えそうですし、セキュリティルールの可読性もちょっと悪化したり、そもそも本当にFirestoreが停止できているのか心配になったりしそうです。そうなると、もっとも汎用的でなおかつ確実なのは、どんなアクセスも禁止するセキュリティルール自体を再デプロイするこの方法かなと思いますが、もし他にもっといい方法があったら教えてください。 ↩︎

Discussion