システム開発・運用負荷を下げるためのFirebase導入と得たノウハウ

@eaglesakura です。

DroidKaigi 2018の公募で落ちたため、Qiitaに書きます。

ある（私の感覚としては）規模の大きな案件を成功させるにあたり、プロジェクト全体でFirebaseをフル活用する方針をとり、概ね成功したと思われるので、どのようなことを行ったのか後々のために書き残しておきます。

# 前提

Android / iOS両対応で、コンシューマ向けアプリ開発でのお話です。

# Firebase Realtime Databaseの導入理由

アプリ仕様上、サーバー上の `マスターデータ` を無数のAndroid / iOS端末でリアルタイム同期しなければならないため、必然的にFirebase Realtime Databaseが候補として上がり、それを導入することとしました。

# Firebase Realtime Databaseは信頼に値するのか？

**デモ日にFirebase Realtime Databaseが落ちました。**

これは運がなかったんだろーなー程度に思っています。
もともとFirebase RDBは稀によくおちるため、それを前提としたシステム設計を行っています。

## Firebaseのデータをどのように生成したか

このアプリでは「マスターデータ＝アプリ内に登場するキャラクター複数体のデータを、全端末がリアルタイムに知る」が主な要件です。

 * サーバー側の処理

 1. マスターデータ をサーバーが生成する
    2. これはほぼ１秒ごとに新しく生成される
 1. サーバー側にマスターデータを保存する
 1. サーバーがFirebase Realtime Databaseのデータを書き換える

 * アプリ側の処理

 1. Firebase Realtime Databaseに接続する
 1. サーバーがRealtime Databaseを書き換えた時点でデータがPushされてくるのでアプリの必要に応じた処理を行う

この仕組みのため、アプリがFirebase限界の同時接続100k端末を超えるまではGAE/Go側は負荷を気にせずに済みます。


## Firebase Functionsでサーバーとの連携

このアプリでは、マスターデータを生成するために幾つかのAndroid端末（業務端末）からデータを収集する必要がありました。

業務端末がデータをFirebase Realtime Databaseの `/${非公開データパス}/${端末ごとに割り当てられたパス}` に書き込むと、Firebase Functionsが起動してGAE/Goにデータを投入します。
GAEは投入されたデータをもとにマスターデータを生成し、Firebase Realtime Databaseの `/${マスターデータのパス}` に書き込みます。

### FunctionsのQuota

業務端末のデータは１秒間に数回以上書き込まれ、その回数だけFirebase Functionsが起動し、サーバーとのhttps通信が発生します。
通信に関わるQuotaを湯水の如く使い、あっという間にQuota制限に引っかかります。

今回はGoogleに連絡してQuotaを引き上げることで対策しましたが、 `Quotaは案外あっという間に引っかかる` ということに留意して設計すると良いでしょう。

## Firebaseが落ちたらどうするのか

Firebase Realtime Databaseが落ちたことを端末が検出した場合、アプリは直接サーバーのAPIを叩くように接続先を変更し、一定時間ごとにポーリングするようになります。

サーバーではFirebase Realtime Databaseに書き込んでいたデータそのものを保持し続けているため、APIとしてデータ取得する口を作るだけで良いです。、マスターデータは常に新しく作成されるため、ほぼ間違いなくMemcacheから高速に取得することができます。

リアルタイム性は圧倒的に少なくなりますが、「全く使えない」という事態は避けられるようにしています。

*幸運かGoogleの努力の賜物か、アプリの提供期間中にこのバックアップ処理が役に立つことはありませんでした。*

## Realtime Databaseが落ちたことをどのように検知しているのか

Realtime Databaseが落ちた場合、何が起きるかは不定なようです。また、落ちた場合の処理を再現するのも難しそうです。

アプリ内では下記のチェックを行い、それ以外のケースは運用で回避可能なように設定するという方針を取りました。

 * 検出チェック事項
 
 1. `/.info/connected` が一定時間falseならばダウンと判断する
    * https://firebase.google.com/docs/database/android/offline-capabilities?hl=ja
 1. 一定時間以上マスターデータが更新されなければダウンと判断する

# Firebase Storageの利用

アプリ内で利用するリソースの多くは、Firebase Storageに保存してアプリ初回利用時にダウンロードする形態を取っています。このとき、Firebase Storage SDKの幾つかの問題を解決する必要がありました。

## ファイル一覧を取得できない問題

Firebase Storage SDKを見た限り、 `指定Path配下にある全てのファイルを列挙する` という処理は存在しないようです。

リソース一覧＆バージョン取得、もしくはリソースダウンロードのようなAPIをGAEに作ることも手ですが、万単位の端末からAPIを叩かれる（なおかつデータベース系アクセスを伴うような）状態の動作検証やテストを行うより、ビジネスロジックとして重要な部分に開発リソースを集中することを選択しました。

そのため、 `ダウンロード対象のリソースのパス一覧を記述したファイル=resources.json` を自動生成してFirebase Storageにアップロードすることにしました。

 1. ファイル一覧を記述した `resources.json` をダウンロードする
 2. ↑のファイルに書き込まれたファイルをすべて個別ダウンロードする

この `resources.json` 生成はデプロイ処理に組み込まれているため、自動的に生成・再配置されます。

## ダウンロード時間が長すぎる問題

Firebase Storageに配置するデータは主に画像のため、作成時にはPNGで合計100MBほどにもなりました。アプリは主に屋外でダウンロード・利用されるため、このままではアプリ開始までに何十分も待つことになってしまいます。

そこで、使用する画像はすべて `webp` 形式に圧縮しています。これによりダウンロード合計容量は10MB程度まで小さくなりました。webpに圧縮する処理もデプロイ処理に組み込まれており、圧縮率の調整もすべてCIで行えるようにしています。

## リソース更新検出問題

Firebase Storageにアップロードしたデータは基本的に固定ですが、何かしらの事情が生じて更新されることが想定されました。

SDKを通じて更新を検出することは可能ですが、すべてのファイルに対してAPI経由で更新チェックしていたら処理時間が非常に長くなってしまいます。

そこで、このアプリでは `Remote Configにリソースバージョン番号を持たせる` `リソースバージョンが更新されたら、リソースをすべて再ダウンロード` という戦略を取りました。

webp変換によりリソース自体を小さくできたことと、仕組みを単純化したことにより概ね問題なくクリアできたかと思います。

# Firebase Remote Configに何をもたせたか

アプリ固有の機能以外にも、運営側で便利な幾つかのプロパティをRemote Config側にもたせました。

1. リソースバージョン
     * 更新されたらFirebase Storageからリソース再ダウンロード
     * 一部のユーザーだけに先行配信し、問題なかったら全ユーザーに浸透
1. アプリ信頼フラグ
     * Remote ConfigのA/Bテスト機能により、「不具合が生じたバージョンは強制的にアップデートダイアログを出し、次へ進めない」などの運用が行われました
1. Firebase Realtime Database接続許可フラグ
     * falseの場合、Realtime Databaseを使わずにサーバーと直接APIでやり取りするようにしました
     * Realtime Databaseが落ちたことをどうしても検知できない場合はこのフラグで強制的に向き先を変えることが想定されました
        * その他、接続デバイス数が限界を超えた場合にロードバランサー的な役割をもたせることも想定されました

# Firebase Cloud Messagingによる「ふっかつのじゅもん」実装

Firebase Cloud Messaging(FCM)はユーザーに新着通知などを送ることが多いですが（実際、今回もそのようなユースケースで使用しています）、その他にFirebase Realtime Databaseと組み合わせてAndroid業務端末の死活監視に用いました。

Android業務端末はRealtime Databaseに常に書き込み続けますが、一定時間更新がない端末は「アプリが落ちた」と判断できます。
（実際には、コネクション切断時にRDBデータを書き換えることができるのでそれで判定しています）

通常であればアプリを再起動すれば済む話ですが、この案件で使用した業務端末は稼働後物理的に触れるのが困難な場所にあるため、全てを遠隔でチェックし、制御する必要がありました。そこで、アプリが落ちた場合はFCMを経由して `デバイスID` `命令` のペアを送り、Google Play Service経由でアプリを「叩き起こす」「特定の動作をさせる」ことができるようにしています。

 1. 運営側で「業務端末のアプリ落ちた」と判断する
 1. FCM経由でアプリ再起動命令を送信する
 1. アプリプロセスが再起動し、再度業務端末が機能を復活させる

# Firebase Authenticationの活用

Firebase Realtime DatabaseやStorageにはセキュリティのためAuthを必須としました。
その際、Firebase Custom Tokenを利用することで `デフォルトでRead/Write不許可` `Android, iOSは指定パスのみRead-Only` `業務端末は指定パスに書き込み可` としています。

Custom Token生成はサーバー側で行うため、ユーザーが集中した場合に負荷が発生することが予想されましたが、Datastoreアクセスを伴わない（すべてCPU/メモリで完結する）ため、GAEのスケーリングに任せれば問題なく処理を行えます。

# Firebase Testlab

Android限定ですが、CIでAndroidアプリのUnit Testを行うためにFirebase Testlabを利用しています。
今回のアプリでは開発者１名に対して$500 / month程度課金が発生しています。

また、Testlabの端末は起動が遅い上に `場合によってはTest Labの端末が起動せずにタイムアウトする` という不具合（不安定性）があるようです。初期状態としてGPSはONですがBluetoothがOFFになっているなど、多少設定に癖があります。
そのため、どうしても起動が遅かったり不安定になるTest lab端末がある場合は「一時的に別な端末にテスト対象を変える」という判断もすると良いです。

Activityを起動するようなテストは特に不安定だったため、25端末（マトリクス）で流すと１～２端末は概ね落ちます。そのため、ActivityやFragmentが関わるテストは手元実機（Android Studio経由）でのみ行うように調整を行いました。

# Firebase Analytics / Crash Reporting

アプリ改善に使用される以外に、デバッグに非常に役立ちます。

このアプリではレアケースの例外やフローに対してEventを発火させることで、Analytics情報の一部として不具合などの情報を取得しています。

また、「正常系ではあるが稀にしか起きない」ようなケースも正しくそれが発生したことを確認できます。
何れにせよ、要望として収集する情報以外にもAnalyticsは開発や継続的な改善に有用です。

Crash Reportingは自動的に情報収集・アップロードされますし、クラッシュ以外のExceptionも送信が可能です。

# 最後に

Firebaseは（Realtime Databaseを除いて）GCP各サービスのラッパーとして非常に強力で優秀なサービスです。
（Realtime Databaseがたまに落ちる以外は）安定した稼働状況ですので、ユースケースがあっているならば積極的に導入してみてください。