🔥

Firestoreの複数データベースを使って、Firebaseのローカル開発環境を構築・運用している話

2024/01/31に公開

こんにちは。
PharmaX でエンジニアをしている諸岡(@hakoten)です。

この記事の概要

2023年の夏頃から、Firebaseの1プロジェクトに対して、複数のFirestoreデータベースを作成することができるようになりました。

https://cloud.google.com/blog/ja/products/databases/manage-multiple-firestore-databases-in-a-project

この記事では、Firestoreの複数データベースを使った開発環境の構築方法の説明および弊社アプリケーションでの運用について、紹介します。

弊社アプリケーションの説明

弊社アプリケーションでは、Firebaseを採用しており、ユーザーの管理には、FirebaseAuth + Identity Platformによるマルチテナンシーを採用しています。またチャットなど一部のデータ管理にFirestoreを使用しています。

Firestoreには、該当テナントのユーザーのみアクセスできるようなセキュリティルールが適用されており、マルチテナント前提のシステム構成となっています。

Firebaseのローカル開発環境

Firebaseの開発環境構築では、ローカル開発環境としてエミュレータ が公式に提供されており、各機能充実したローカル開発環境を構築することができます。

前述の、Identity Platformによるマルチテナンシーについても、「テナントの作成をコンソール画面でできない」など一部本番とは同等ではない部分もありますが、基本的には同等に対応することが可能です。

しかし、弊社では ローカル開発環境にエミュレータは使用せず、開発用のFirebaseプロジェクトを本番やステージングと同様に作成し、開発環境として使用しています。

エミュレータを使用していない理由

エミュレータを採用しなかった理由を簡単に書いておきます。

① エミュレータのマルチテナント対応には一部不具合が存在

https://github.com/firebase/firebase-tools/issues/5623

2024年1月時点で、エミュレータのマルチテナントの対応には一部不具合があり、まだ比較的不安定であると感じでいます。
不具合自体は迂回可能ではあるのですが、ローカル環境用の開発をそれほど考慮したくないという意見がありました。

② テナント作成など、一部開発環境用のコードを書く必要がある

エミュレータには、Identity Platform用のWebコンソールは存在していないため、テナントはAdminSDKなどを使って、作成する必要があります。

(コード例)

admin
    .auth()
    .tenantManager()
    .createTenant({
      displayName: 'test-tenant'// テナント名
    })

スクリプトを管理すれば良い問題ではありますが、弊社バックエンドがRubyという言語的な事情や、手軽に開発環境を管理したいという理由からこちらも懸念材料の一つになりました。

③ エミュレータ用のコード分岐が想定よりも多かった

これは弊社アプリケーション構成が大きく影響する話ですが現在の構成では

  • Firestoreへの書き込みはサーバー
  • Firestoreの読み込みはクライアント

となっており、エミュレータ対応は、サーバーとクライアントの両方のアプリケーションで行う必要があります。

エミュレータ接続の対応をするにはアプリケーション側でいくつかのエミュレータ用分岐コードが必要になるのですが、実際に運用を想定した場合に、これらの分岐が想定よりも多く、メンテナンスのコストが大きそうというのが懸念にありました。

(分岐のサンプル)

export const getFirestore = (): Firestore => {
  if (useEmulator) {
    const db = getFirestoreOrigin();
    try {
      // エミュレータのときは、エミュレータ接続処理の分岐を書く必要がある
      connectFirestoreEmulator(db, 'localhost', 8080);
    } catch (e) {
      console.error('emulator error', e);
      null;
    }
    return db;
  }
  return getFirestoreOrigin(app);
};

このような理由から、「本番と変わらない設定で動かせるFirebaseプロジェクトを開発用に作ってしまったほうが良いのではないか?」となり、エミュレータを使わない現在の構成になっています。

メンバーで一つのプロジェクトを開発環境として使用したときの問題点

ローカル開発用にFirebaseプロジェクトを使って運用を始めたのは、昨年(2023年)の6月頃で、まだFiresoteデータベースを複数作成することができない時期でした。

一つのFirebaseプロジェクトを開発メンバーで共有し、開発を続ける中でしばらくすると「IDが重複したデータがFirestoreに残ってしまう」という問題点が出てきました。

Firestoreには主に「ユーザーとのチャット情報」「ユーザーのメタ情報」などを管理しているのですが、ユーザーのメタ情報の一部にRDBのIDを持つものがあります。

ローカル開発環境では、各々で開発環境のRDBをリセットするケースが多々あり、その度に古いユーザーの情報がFirestoreに残ってしまっていました。

また、それとは別にRDB自体は開発者の各々のローカルDBを参照しているため、開発者同士でも同じIDの異なるデータを参照してしまい、ローカル環境のフロントエンドの挙動がおかしくなるなど問題が度々発生していました。

複数データベース機能によって、問題を解決する

これを解決するための救世主となったのが、本題である「1つのFirebaseプロジェクトで複数のFirestoreデータベースを作成する」機能です。

一つのFirebaseプロジェクトに複数のFirestoreを紐付けられるようになったことにより、開発者同士で同じ、テナントやユーザープールを使いつつも、Firestoreのアクセスのみを開発者個人に紐づく自由な環境にすることが可能になります。

これにより、手軽にFirestoreをリセットしたり、セキュリティルールを個別に変更した修正ができるようになったため、ローカル開発環境として開発体験が向上しました。

複数データベースの作成方法・運用方法

ここからは、実際に複数のFirestoreの作成方法について、簡単に説明していきます。

Firestoreのデータベースを作成する

Firebase CLIの準備

Firebaseプロジェクトへ Firestoreのデータベースを新しく追加するには Firebase CLI のコマンドを使用します。

https://firebase.google.com/docs/cli?hl=ja

Firebase CLIが手元にインストールされていない人は、上記を参考に先にインストールしてください。

CLIをインストールしたら、上記の公式ページを参考に、まず任意のディレクトリで firebase init を実行し、プロジェクトを初期化してください。

firebase init のサンプル
mor:firebase-test% firebase init

     ######## #### ########  ######## ########     ###     ######  ########
     ##        ##  ##     ## ##       ##     ##  ##   ##  ##       ##
     ######    ##  ########  ######   ########  #########  ######  ######
     ##        ##  ##    ##  ##       ##     ## ##     ##       ## ##
     ##       #### ##     ## ######## ########  ##     ##  ######  ########

You're about to initialize a Firebase project in this directory:

  /Users/mor/repos/cl-work/github.com/pharma-x/firebase-test

? Which Firebase features do you want to set up for this directory? Press Space to select features, then Enter to confirm your choices. (Press
<space> to select, <a> to toggle all, <i> to invert selection, and <enter> to proceed)
 ◯ Realtime Database: Configure a security rules file for Realtime Database and (optionally) provision default instance
❯◉ Firestore: Configure security rules and indexes files for Firestore
 ◯ Functions: Configure a Cloud Functions directory and its files
 ◯ Hosting: Configure files for Firebase Hosting and (optionally) set up GitHub Action deploys
 ◯ Hosting: Set up GitHub Action deploys
 ◯ Storage: Configure a security rules file for Cloud Storage
 ◯ Emulators: Set up local emulators for Firebase products
(Move up and down to reveal more choices)

CLIの指示に従って、Firestoreを選択し、次に進めます。
選択するとFirebaseアカウントとプロジェクトの選択に移るので、対象のFirebaseのプロジェクトへアクセスできるアカウントと対象プロジェクトを選択して先に進めてください。

アカウント選択 サンプル
=== Account Setup

Which account do you want to use for this project? Choose an account or add a new one now

? Please select an option: <your account>

✔  Using account: <your account>
プロジェクト選択 サンプル
=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Please select an option: Use an existing project
? Select a default Firebase project for this directory: sample-firestore-9ee41 (sample-firestore)
i  Using project sample-firestore-9ee41 (sample-firestore)

Firestoreのセットアップを進めると、セキュリティルールの設定ファイルとインデックスファイルを書き出すか聞かれるため、そのまま書き出しを行い初期化が完了します。

Firestore Setup サンプル
=== Firestore Setup

Firestore Security Rules allow you to define how and when to allow
requests. You can keep these rules in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore Rules? firestore.rules

Firestore indexes allow you to perform complex queries while
maintaining performance that scales with the size of the result
set. You can keep index definitions in your project directory
and publish them with firebase deploy.

? What file should be used for Firestore indexes? firestore.indexes.json

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

✔  Firebase initialization complete!

データベースの作成

firestoreのデータベースを新しく追加するのは、 firestore:databases:create コマンドを使用します。

firebase firestore:databases:create <作成したいデータベースの名前> --location=<データベースを作成するリージョン>
実行サンプル
mor:firebase-test% firebase firestore:databases:create test --location=asia-northeast1
Successfully created projects/sample-firestore-9ee41/databases/test
Please be sure to configure Firebase rules in your Firebase config file for
the new database. By default, created databases will have closed rules that
block any incoming third-party traffic.

コマンドが成功すると、データベースは作成されていますが、注意点としては作成されたデータベースは、Firebaseのコンソールには表示されません。
連携されている、Google CloudのFirestoreコンソールから確認する必要があります。

Google CloudのFirestoreを確認すると、新しいデータベースが作成されていることが確認できます。

セキュリティルールとインデックスを新しいデータベースに適用する

Please be sure to configure Firebase rules in your Firebase config file for
the new database. By default, created databases will have closed rules that
block any incoming third-party traffic.

firestore:databases:create を実行した時に、コンソールに上記のようなメッセージが表示されていると思います。

作成されたデータベースは、セキュリティルールの書き込み・読み込み設定が無効になっていて、このままの状態では、クライアントから書き込み・読み込みができません

クライアントから使えるようにするために別途セキュリティルールを適用する必要があります。


セキュリティルールの読み込み・書き込みがfalseに設定されている

作成した(default)以外のデータベースにセキュリティルールを適用するには、firebase deploy コマンドを使って設定ファイルを反映します。

firebase deploy --only firestore

セキュリティルールの設定を変更

コマンドを実行する前に、Firebaseプロジェクトのディレクトリにある firestore.rules ファイルを編集します。

firestore.rules
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    match /{document=**} {
      // デフォルトでは、30日間すべての書き込み・読み込みを許可する設定のファイルが作成される
      allow read, write: if request.time < timestamp.date(2024, 2, 13);
    }
  }
}

今回は、サンプルのため意図的にすべての書き込み・読み込みを許可する設定に変更します。

firestore.rules(変更後)
rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    match /{document=**} {
	allow read, write: if true;
    }
  }
}

インデックスファイルの設定

firestore.indexes.json
{
  "indexes": [],
  "fieldOverrides": []
}

今回は、サンプルのためインデックスは特に設定せずに、デフォルトで進めます。

複数データベースに対応するために、firebase.json を編集

セキュリティルールとインデックスを設定したら、プロジェクト設定ファイルの firebase.json を複数データベース用に設定します。

firebase.json(編集前)
{
  "firestore": {
    "rules": "firestore.rules",
    "indexes": "firestore.indexes.json"
  }
}

プロジェクト初期化時には、firebase.jsonのfirestoreの設定は、上記のような状態になっています。
これを複数データベースに対応するように次のように編集します。

firebase.json(編集後)
{
  "firestore": [
      {
        "database": "(default)",
        "rules": "firestore.rules",
        "indexes": "firestore.indexes.json"
      },
      {
        "database": "test", // 作成したデータベースの名前を指定する
        "rules": "firestore.rules",
        "indexes": "firestore.indexes.json"
      }
  ]
}

複数データベースの場合は、JSONオブジェクトではなく、JSON配列でdatabseキーに作成したデータベース名を指定したJSONオブジェクトを追加します。各オブジェクトには個別にセキュリティルールとインデックスを指定できます。

詳細は、公式のドキュメントも参照ください。

設定が完了したら、deployコマンドを実行し、セキュリティルールとインデックスを反映します。

firebase deploy --only firestore

Google CloudのFirestoreコンソールから作成したデータベースを選択して、セキュリティルールが更新されていれば成功です。これでクライアントから接続することができます。

Firebase SDKからデータベースを指定した接続を行う

SDKを使ってクライアントからFirestoreデータベースへ接続する場合、何も指定しないと「(default)」のデータベースへ接続される設定になっています。

ここでは、作成した個別のデータベースに対して、FirebaseSDKのクライアントから、データベースへ接続する方法を説明します。

Javascript SDK

const firebaseConfig = {
  //...
};

const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app, "<作成したデータベース名>");

JavaScriptのSDKの場合は、firestoreインスタンスの初期化関数(getFirestore)の第2引数名に作成したデータベースの名前(databaseID)を指定してください。

export const getFirestore = (): Firestore => {
  const databaseId = process.env.NEXT_PUBLIC_FIRESTORE_DATABASE_ID || '(default)';
  return getFirestore(app, databaseId);
};

弊社アプリケーションでは、ローカルでのみ環境変数でDatabaseIdを渡すようにしていて、何も指定しないときは、(default)に繋がるようになっています。

Ruby SDK

Firestoreへの書き込みはサーバーで行っているため、弊社ではRuby側の対応も必要になります。Rubyでは google-cloud-firestore というGemを使っていますが、こちらもJavaScript SDKと同様にdatabase_idをインスタンスの初期化時に渡すことで、個別のデータベースに接続することができます。

Google::Cloud::Firestore.new(
	project_id: ENV.fetch("FIREBASE_PROJECT_ID"),
	credentials: JSON.parse(ENV.fetch("GOOGLE_CLOUD_FIRESTORE_CREDENTIALS", nil)),
	// Webクライアントと同様に環境変数で、指定できるように実装している。
	database_id: ENV.fetch("FIRESTORE_DATABASE_ID", "(default)")
)

このように、別データベースを使ってローカル環境を構築した場合、本番との差分は「DatabaseId」の環境変数のみになります。エミュレータを使った場合と比べると分岐コードが少ない点にメリットが大きいと感じています。

運用Tips

その他、弊社アプリケーションのFirebase環境の運用を少しだけ紹介します。

セキュリティルールの運用

弊社では、セキュリティルールを次のように環境ごとに分けて、CIで自動テストが実行されるように運用しています。

firestore.prod.rules
firestore.stg.rules
firestore.dev.rules
firebase.json
{
  "firestore": [
    {
      "database": "(default)",
      "rules": "firestore.rules",
      "indexes": "firestore.indexes.json"
    },
    {
      "database": "morooka",
      "rules": "firestore.dev.rules",
      "indexes": "firestore.indexes.json"
    }
  ]
}

ローカルで作成した環境には、基本はdevのルールを適用しています。

インデックスの運用

現時点では、インデックスが環境によって変更されることは無いため、基本的には一つの firestore.indexes.json をすべての環境で共通で使用しています。

インデックスを更新する場合は、Firestoreのコンソールでインデックスを更新した後に、firebaseのコマンドを使ってコードへエクスポートしています。

firebase firestore:indexes > firestore.indexes.json

その後各環境へ firebase deploy コマンドで反映します。

終わりに

以上、Firebaseプロジェクトで複数のFirestoreデータベースを使ったローカル環境の運用について、ご紹介させていただきました。最後までお読みいただきありがとうございます!

今後エミュレータが使いやすくなり、もう少し手軽に複数データベースが扱えるようになった場合は、エミュレータに移行する可能性はありますが、しばらくはこの運用で継続する予定です。

PharmaXでは、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。
もし、興味をお持ちの場合は、私のXアカウント(@hakoten)や記事のコメントにお気軽にメッセージいただけますと幸いです。まずはカジュアルにお話できれば嬉しいです!

PharmaXテックブログ

Discussion