【Cloud Functions】FirestoreデータをBigQueryに日次で読み込んでみた

#  0. はじめに

Qiita執筆2作目です。
この記事では、「**Firestoreに入ってくるスマホデータを、BigQueryのテーブルデータとすることで、データ分析基盤を構築する**」ということを目的にしています。

## TL; DR

* [Stream Collections to BigQuery](https://firebase.google.com/products/extensions/firebase-firestore-bigquery-export?hl=ja) を使わずに、FirestoreデータをBigQueryに読み込んだ
* FirestoreのエクスポートデータをBigQueryに読み込むうえで、普通の外部テーブルの作成と異なる点・注意するべき点をまとめてみた
* 上記処理を日次で自動実行する処理を作成した

## 目次

1. この記事の目的・背景
2. Firestore のエクスポートデータをBQで読み込む際の注意点
3. 全体アーキテクチャ設計と詳細の実装について

# 1. 目的・背景

私の会社では、「[Uvoice](https://u-voice.net/)」というアプリをリリースしており、アプリに関する情報の一部は「**Firestore Database**」にて管理されています。
一方で、データ分析基盤の構築は異なるGCPプロジェクトのBigQueryで行っており、そのBQ上でFirestoreのデータを読み込むことができていませんでした。
今回はそんな課題を解決するために、以下のような要件の下で、FirestoreデータをBigQueryに日次で読み込んでみました。

### 1-1. エクスポートをリアルタイムで行う必要がない

今回、記事を執筆しようと思ったメインの背景はこちらになります。もともと、FirestoreのデータをBigQueryにエクスポートするにあたって、「リアルタイム」で同期する必要はなく、「日次で分析基盤であるBigQueryにエクスポートしたい」という要件でした。
その上で、リアルタイム性を重視しない設計においては、「[Stream Collections to BigQuery](https://firebase.google.com/products/extensions/firebase-firestore-bigquery-export?hl=ja) 」を使う必要性が高くないという判断を行い、Cloud Functions を用いた実装を行うことにしました。

### 1-2. Firestore のエクスポートデータをBigQueryで読み込みたい

今回の要件では、FirestoreとBigQueryが異なるプロジェクトに存在しており、その間でのデータのやり取りも設計の考慮に入れました。

# 2. Firestore のエクスポートデータをBQで読み込む際の注意点

私自身、Firestore のエクスポートデータを、他のテキストデータやJSONデータを外部テーブルで参照するのと同じような感覚で実装していろいろ痛い目にあったので、Firestore のエクスポートデータを扱う上での注意点をまとめてみます。

### 2-1. エクスポートデータの構成について

実際に、GCP Consoleを使ってFirestoreのデータをGCSにエクスポートしてみると、以下のような構成のディレクトリが作成されていることがわかります。（やり方は[公式のドキュメント](https://cloud.google.com/firestore/docs/manage-data/export-import?hl=ja#gcloud)を参照）

``` 
GCS_root_bucket/
    └ [PARENT_FOLDER_NAME]/
            ├ all_namespaces/
            │       └ [KIND_COLLECTION_ID]/
            │               ├ all_namespaces_[KIND_COLLECTION_ID].export_metadata
            │               ├ output-0
            │               └ output-...
            │
            └ [PARENT_FOLDER_NAME].overall_export_metadata
```

ここで、
* `[PARENT_FOLDER_NAME]`: エクスポートデータ全体を管理する親フォルダ―名
    * もし、指定しない場合は`2017-05-25T23:54:39_76544/` のように、日時とエクスポート操作を一意に定めるシーケンス番号で構成されます
* `[KIND_COLLECTION_ID]`: エクスポートされたコレクションの ID 名です
    * `kind_[指定したコレクションID]`という表記のフォルダになっています

となっています。


上記のディレクトリ構成からもわかるように、
* `[KIND_COLLECTION_ID]` 配下のデータは、各コレクションIDに関するデータが格納されている
* `outoput-...` というファイル内に具体的なエクスポートデータが入っており、エクスポートデータの容量に応じて複数のファイルに分割されます
* `[PARENT_FOLDER_NAME].overall_export_metadata` には、（おそらく）エクスポート処理全体に関するメタデータが格納されている

ということがわかります。

### 2-2. エクスポート自動実行時のディレクトリ設計について

エクスポートをGCP Consoleから手動で実行できたので、次にこの処理をBigQueryで読み込みやすい形に自動化する必要があります。
自動化する上で、以下のどちらの設計にするべきかというところで少し迷いました。

1. 日次でコレクションの「新規に作成 or 更新 されたデータのみ」をエクスポートする
2. 日次でコレクションの全データをエクスポートする

そもそも、1. の場合は以下のようなディレクトリ構成をBigQueryで読み込む必要があります。

``` 
GCS_root_bucket/
    ├　[2024/05/01 に変更があった分のエクスポートデータ]
    ├　[2024/05/02 に変更があった分のエクスポートデータ]
    ...
```

外部テーブルで、参照先をワイルドカード（`*`）で指定ができるので、上記のような設計も可能なように感じますが、**Firestoreエクスポートデータを参照先に使用する場合は上記のような設計はできません**。なので、必然的にFirestoreのデータをエクスポートする際は、コレクション内の全データをエクスポートする必要があります。

これは余談ですが、[Stream Collections to BigQuery](https://firebase.google.com/products/extensions/firebase-firestore-bigquery-export?hl=ja)ではFirestoreのスナップショットを使用した設計になっている（と考えられる）ので、その設計を有効利用することでもう少しスマートな設計が組める可能性はありますが私にはわかりませんでした。

<details><summary>（蛇足） </summary>

以下、[公式Doc](https://cloud.google.com/bigquery/docs/loading-data-cloud-firestore?hl=ja#bq) 二つの説明が若干語弊を招きやすい表現になっているので、この点は注意する必要があります。

##### 一番初めの制限事項特記
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3519380/fd0f3d23-8cf1-5518-d856-d8c4e38e64ea.png)

##### 説明詳細部分（Firestore のエクスポート サービスデータの読み込み）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3519380/ef2840ed-77df-758c-9a51-0ea9f1fed236.png)

</details>

結論、最初の制限事項の説明が正しく、「**一般的にワイルドカードの使用は可能だが、Firestore のエクスポートデータの場合はその例外である**」という解釈でいいと思います。


### 2-3. 自動実行するための言語選定

公式では、「GCP Consoleで手動実行する」か、「Cloud Shellでbqコマンドを打つ」かの二つの方法のみが掲載されており、そのどちらも自動定期実行には向いていない方法です。

その他のドキュメントをいろいろ調べたところ、[PythonのSDK](https://cloud.google.com/python/docs/reference/firestore/latest/google.cloud.firestore_admin_v1.services.firestore_admin.client.FirestoreAdminClient#google_cloud_firestore_admin_v1_services_firestore_admin_client_FirestoreAdminClient_export_documents)にはこれに準ずる関数があると分かったので、Pythonを採用しました。（Pythonにあるなら、他の言語にもあるはず）

以下に公式サンプルコードを参照しますが、自動生成されたもので不十分な情報しか載っていませんでした。
次のセクションにて、GitHubのPython SDKのClassの実装を見ながら、リクエストに必要なパラメータを整理しています。

https://cloud.google.com/python/docs/reference/firestore/latest/google.cloud.firestore_admin_v1.services.firestore_admin.client.FirestoreAdminClient#google_cloud_firestore_admin_v1_services_firestore_admin_client_FirestoreAdminClient_export_documents

# 3. 全体アーキテクチャ設計と詳細の実装について

整理すると今回の要件は以下の通りです。
* GCPのプロジェクトAにあるFirestoreのデータを、異なるプロジェクトBにあるBigQueryで読み込んで、データ分析基盤に組み込みたい
* リアルタイムで同期する必要はなく、日次でデータが同期されると望ましい

上記の要件に基づいて、下記に示すようなアーキテクチャ設計にしました。
1. 日次でプロジェクトA内のGCSにFirestoreデータをエクスポートする（Cloud Functions）
2. プロジェクトAからプロジェクトBにGCSの該当データを転送する処理を行う（Storage Transfer Service /Cloud Functions） 
3. プロジェクトB内でエクスポートデータを参照する外部テーブルを作成する（BigQuery（作成時のみ）

![GCP architechture_1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3519380/0e15f638-b1d4-f8d9-9f90-426310a9f2a6.jpeg)



実装当初は、データ転送もCloud Functionsで実装していましたが、データ数が多くなると実行時間制限もあり、あまり向いていないことが分かったため、「**Storage Transfer Service**」での実装に変更しました。

## 3-1. エクスポート用CloudFunctionsの実装詳細

これは、公式のドキュメントが不十分であったこともかねての備忘録です。同じような悩みを持っている人にとっての助けになれば幸いです。特に、

``` python
request = firestore_admin_v1.ExportDocumentsRequest(
        name=database_name,
        output_uri_prefix=output_uri_prefix,
        collection_ids=[collection_name],  # 特定のコレクションを指定する場合はそのIDのリストを指定します。
    )
``` 

の部分で工夫した点として、`output_uri_prefix=output_uri_prefix` というパラメータによって、**親フォルダ（`[PARENT_FOLDER_NAME]`）の名前を固定にする**ことで、BigQueryに読み込むエクスポートメタデータのPathを不変にしました。

このリクエストのパラメータははGitHubを参照することでわかります。

https://github.com/googleapis/python-firestore/blob/main/google/cloud/firestore_admin_v1/types/firestore_admin.py#L574

<details><summary>実装したサンプルコード </summary>

`main.py`
``` python
from google.cloud import firestore_admin_v1
from google.cloud.firestore_admin_v1 import types
from google.cloud import storage
import os

def sample_export_documents(event, context):
    collection_name = os.environ["COLLECTION_NAME"]
    source_bucket_name = os.environ["SOURCE_BUCKET_NAME"] 
    all_namespaces = os.environ["ALL_NAMESPACES"] # 'all_namespaces'

    ## dir の作成
    path = f'/tmp/system/{all_namespaces}/kind_{collection_name}'
    os.makedirs(path, exist_ok=True)
    print(f'Created directories up to: {path}')

    # クライアントを作成します
    client = firestore_admin_v1.FirestoreAdminClient()
    
    # エクスポートを行いたいFirestoreデータベースのパスを指定します
    # "projects/[プロジェクト名]/databases/(default)"
    database_name = os.environ["DATABASE_NAME"]
    
    # エクスポートのオプション指定
    output_uri_prefix = f"gs://{source_bucket_name}/system"

    # リクエスト初期化
    request = firestore_admin_v1.ExportDocumentsRequest(
        name=database_name,
        output_uri_prefix=output_uri_prefix,
        collection_ids=[collection_name],  # 特定のコレクションを指定する場合はそのIDのリストを指定します。
    )

    storage_client = storage.Client()
    
    # リクエスト実行
    try:
        # はじめにすでにあるエクスポートデータを一旦削除する
        blobs = storage_client.list_blobs(source_bucket_name, prefix="system/")
        for blob in blobs:
            blob.delete()

        operation = client.export_documents(request=request)
        print("Waiting for operation to complete...")
    except Exception as e:
        print(f"Error: {e}")
        return
    else:
        response = operation.result()

    print(f'Export operation completed with response: {response}')
```

`requirements.txt`
``` txt
google-cloud-firestore
google-cloud-storage
```

</details>

# まとめ

「Stream Collections to BigQuery」という拡張機能 を用いずに、FirestoreデータをBigQueryに読み込むという実装を行いました。その上で、FirestoreのエクスポートデータをBigQueryに読み込むうえで、普通の外部テーブルの作成と異なる点・注意するべき点をまとめてみました。

最後にこの実装を通しての良かった点と良くなかった点を振り返って、記事の締めにします。

#### 良かった点

* 拡張機能以外の選択肢を視野に入れて、要件に対して適切なアーキテクチャ設計を考えられたこと
* Python SDKのGitHubを眺めることで、Requestパラメータをどのようにするのが適切であるかを判断できたこと

#### 良くなかった点

* データが増築していくほどにエクスポートを日次で行うことのコスト面での懸念があるということ
* 上記を踏まえると初めに紹介した[Stream Collections to BigQuery](https://firebase.google.com/products/extensions/firebase-firestore-bigquery-export?hl=ja) を使用して、差分のみをBigQueryに読み込むという手法が適切であるような気もします
    * Stream Collections to BigQueryが異なるプロジェクト間にも対応しているかの懸念はあります

結論、Firestoreの機能であるsnapshotsのリアルタイム性を享受しないのであれば、そもそも差分エクスポートを適切に行う実装はかなり難しいと考えており、「リアルタイム性」を取るか、「差分エクスポートによるコスト軽減」を取るかのどちらかであるような気がしています。
なので、要件やデータサイズなどの様々な要因を考慮して、適切な選択を取るための手助けになればいいなと考えています。