Gincoのバックエンドを担当している鷲見(@soichisumi)です。

2018年10月4日にヒカラボで開催した【Ginco Engineer Meetup】Firebaseを使ったモダンなブロックチェーンアプリ開発の裏側で話した内容の解説記事です。

発表では Ginco における Cloud Functions for Firebase (以下 Cloud Functions と書きます)の利用事例と、Production で Cloud Functions を利用するにあたって行った高速化について話しました。


発表資料はこちらです。

GincoにおけるCloud Functionsの利用とその高速化


Cloud Functionsとは

Google が提供するフルマネージドな関数の実行環境です。トリガーと実行する関数を記述して、APIを実装することができます。 トリガーとは、Cloud Functions が起動する条件のことで、様々な事象を元に関数を起動することができます。トリガーの種類としては、HTTP リクエスト、データベース上のデータの変更、メッセージング基盤へのメッセージ送信などがあります。

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

Cloud Functionsを利用する利点としては、下記の4点があります。

  • リクエストに対する処理を書くだけで API が実装でき、簡単
  • デプロイされた個々の関数は完全に異なる環境で実行される。そのためコード量が増えても機能の追加・修正が容易で、エンジニアが増えた場合にすぐに開発へ参加可能
  • リクエスト量に応じてオートスケールする
  • トリガーの種類が豊富で、様々な処理を実行できる


GincoでのCloud Functionsの利用事例

Ginco では Cloud Functions を4つの用途に使用しています。

  1. ブロックチェーンノードの RPC 呼び出し API
  2. マーケット情報取得 API (ウィジェット用)
  3. プッシュ通知
  4. Firestore の定期バックアップ & バックアップデータの BigQuery 連携

下記が Cloud Functions 周りのアーキテクチャ図です。


1. ブロックチェーンノードのRPC呼び出しAPI

GKE上で動作している複数のブロックチェーンノードのAPIを同じインターフェースで利用するため、各RPCをラップしたAPIを提供します。 開発初期はHTTPトリガーを利用していましたが、現在はonCallトリガーを用いて、関数呼び出しの際にFirebase Authenticationによるユーザ認証を通すようにしています。

2. マーケット情報取得API

Ginco は DB に Cloud Firestore を採用しており、ほとんどのデータの CRUD はクライアントから直接行います。 しかしウィジェットなど、認証なしにデータを取得したい場合には、Firestore の一部のデータを Cloud Functions 経由で返しています。

3. プッシュ通知

送金、着金の進行状況や新しいお知らせが追加されたなど、新しい情報があった場合にユーザにプッシュ通知を送っています。 取引履歴やお知らせも Firestore で管理しているため、Cloud Firestore トリガーでFirestoreのイベントを監視し、通知を送っています。

4. Firestoreの定期バックアップ & バックアップデータのBigQuery連携

Firestore の定期バックアップや BigQuery 連携にも Cloud Functions を利用しています。

Firestore の定期バックアップでは、Cloud Pub/Sub トリガー Firestoreを Cloud Storage へバックアップする API を叩く関数を起動します。App Engineとcronjobを利用してPub/Subにメッセージを送ることにより定期的にバックアップを行っています。

BigQuery 連携では、Cloud Storage トリガーで Cloud Storage に新しくファイルが作成された場合に関数を起動し、そのファイルが Firestore のバックアップファイルだった場合、BigQuery にロードする API を叩きます。


Cloud Functionsの高速化

ここからは Production で Cloud Functions を利用していて出会った問題とその対策について説明します。

Cloud Functions は便利ですが、関数の数が増えるにつれ、レスポンスがかなり遅くなる(最大約13sec!)問題が出てきました。Cloud Functions でサービスを構築する場合、意識して実装しないとこの問題に出会うと思います。 何故起こるか、どう改善するかについて説明し、最後に改善前後のレスポンス時間を比較します。

Cloud Functionsの仕組み

Cloud Functions は次のように動作します。

  • 1 リクエストに対し 1 インスタンスで処理する
  • 新しくリクエストを受け取った場合
    • 空いているインスタンスがあればそのインスタンスにリクエストを渡す
    • 空いているインスタンスが無ければ新しくインスタンスを作成し、そのインスタンスにリクエストを渡す
  • しばらくリクエストを処理していないインスタンスがある場合、そのインスタンスは削除される

新しくインスタンスを作成する場合のリクエストの処理には時間がかかります。この現象は Cloud Functions の Cold Start と呼ばれます。 仕組み上起きることであり避けられませんが、軽減することができます。またインスタンスの作成自体には数百msecほどしかかからないため、大部分を実装上の工夫で解決できます。

Cold Start が起きる場合を図にすると次のようになります。Cloud Functions は Routing サービスとその裏のインスタンス群で表すことができ、リクエストが来た場合に空きインスタンスが無い場合に新しいインスタンスが作成されます。この時レスポンスに時間がかかる現象が Cold Start と呼ばれています。

参考: Life of a Serverless Event: Under the Hood of Serverless on GCP

Cold Startの改善

Cold Start 時に時間がかかっている箇所を調べると、次の3点で時間がかかっている事が分かりました。

  • Cloud Functions の Cold Start
  • Cloud Functions のリージョン
  • Cloud Firestore の client の Cold Start

この3点に対して行った対策を紹介します。


Cloud FunctionsのCold Startの改善

実行対象の関数のモジュールのみをロードする

前述した通り、Cold Start 時のリクエストの処理には時間がかかります。 しかし、時間がかかっている箇所を調べると、インスタンスの作成自体にはほとんど時間がかかっておらず(数百 msec 程度)、そのほとんどがインスタンス立ち上げ後のライブラリのロード時間と初期化処理でした。

Cloud Functions では、Cold Start 時に index.js 全体が評価されます。 例えば、公式ドキュメントにある実装を少し整理して、次のように実装していたとします。


index.js

exports.Func1 = require('./funcs/func1');
exports.Func2 = require('./funcs/func2');
exports.Func3 = require('./funcs/func3');
...

funcs/func1.js

const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
const db = admin.firestore();

module.exports = functions.https.onRequest((req, res)=>{
    // ......
});

funcs/func2.js

const functions = require('firebase-functions')
const google = require('googleapis');
const rp = require('request-promise');
const projectId = process.env.GCLOUD_PROJECT;
module.exports = functions.storage.bucket(`${projectId}-bucket`)
                     .object()
                     .onFinalize((obj)=>{
    // ......
});


上記は、export する関数とその依存ライブラリを外出しした NodeJS コードです。この実装だと、Cold Start 時に index.js 全体が評価されるため、Func1 を起動する際にも Func2、Func3 のモジュールのロードや初期化処理が実行されてしまいます。そのため関数の数が増えるほど Cold Start 時のレスポンスに時間がかかります。

そこで index.js を下記のように修正しました。これにより、起動する関数の export 文のみが評価されます。Cloud Functions では、実行時に環境変数 FUNCTION_NAME に実行対象の関数名が入ります。そのため、firebase コマンドでデプロイする際には全ての関数が評価され、関数の実行時にはその関数のみが評価されます。


index.js

if (!process.env.FUNCTION_NAME || process.env.FUNCTION_NAME === 'Func1') {
    exports.Func1 = require('./funcs/func1');
}

if (!process.env.FUNCTION_NAME || process.env.FUNCTION_NAME === 'Func2') {
    exports.Func2 = require('./funcs/func2');
}
...


依存モジュールのバージョンアップ

Cloud Functions は NodeJS モジュールをキャッシュし、世界中の関数で共有しています。 そのため、もしロードするモジュールがキャッシュにあれば、ライブラリのロード時間を短縮することができ、Cold Start 時のレスポンスの改善が期待できます。 そこで、可能なものについては新しいバージョンを使用するよう修正しました。依存している 13 モジュール中 6 モジュールを最新のバージョンへ更新しました。

参考: Improving Cloud Function cold start timeのStep2


Cloud Functions を適切なリージョンで利用する

Cloud Functions の導入当初は米国リージョンのみでしたが、6月下旬ごろに東京リージョンが利用可能になりました🎉

通常時のレスポンス時間を改善するため東京リージョンにも関数をデプロイします。 次のように、少しの変更で別のリージョンに関数をデプロイすることができます。


funcs/func1.js

const functions = require('firebase-functions');

module.exports = functions.region('asia-northeast1') // ここでregion指定を追加
                          .https
                          .onRequest((req, res) => {
    // ……
});


Cloud FirestoreのclientのCold Startの改善

上述してきた対策を行っても、まだ Cold Start 時のレスポンスに 6 秒ほどかかっていました。 そこで残るボトルネックを調べたところ、1回目の Firestore の get に 5-6 秒かかっていることが分かりました。 これはFirestore の client の問題であり、解決には時間がかかるようでした。そのため回避するよう修正しました。

Ginco の実装ではリクエストがユーザからのものか確認するため、ほぼ全ての関数で Firestore のデータ取得を行っていましたが、本処理ではほとんどの関数でデータ取得を行っていませんでした。そのため、onCallトリガーを用いてリクエストの検証を Firebase Authentication で行い、Firestore のデータ取得部分を削除しました。

onCall トリガーの関数は下記のようになります。トリガー指定箇所を onCall に変更し、data からリクエストボディのパラメータを取得するよう変更するだけで対応できます。


funcs/func1.js

const functions = require('firebase-functions')

module.exports = functions.region('asia-northeast1')
                          .https
                          .onCall((data, ctx) => { //トリガーをonCallへ変更
    // 認証済みユーザからのリクエストかチェック
    if (!ctx.auth) {
        throw new functions.https.HttpsError('unauthenticated', 'This function must be called while authenticated.');
    }

    // ……
})


例えば iOS の Firebase SDK を利用する場合、下記のように onCall トリガーの関数を呼び出すことができます。client のオブジェクト生成時にリージョンを指定し、関数名とパラメータを渡すことで呼び出せます。

let functions = Functions.functions(region: "asia-northeast1")
functions.httpsCallable(“Func1”).call([“data": “hoge”]) {(result, error) in
    // ......
}


検証

以上の対策によって Cold Start が改善されたか検証しました。

実験は次のように行いました。

  • 対策の実施前後でレスポンス時間を計測
  • 実験対象はBitcoin Coreの RPC、sendrawtransaction を叩くAPI
  • ローカルPCまたはアプリから 3 回リクエストを送り、レスポンス時間の平均値を比較する
    • onCall の関数を叩く場合にアプリからリクエストを送る
  • デプロイ直後のレスポンスを Cold Start 時のレスポンス、それ以降のレスポンスを通常時のレスポンスとする

下図が結果です。レスポンス時間を大きく短縮することができました。通常時は 200msec 以内、Cold Startでも1秒程度と十分高速になりました。

  • Cold Start
    • 改善前 : 12.7sec
    • 改善後 : 1.2sec
    • 11.2sec 短縮
  • 通常時
    • 改善前 : 798msec
    • 改善後 : 193msec
    • 605msec 短縮



まとめ

本記事では、Ginco での Cloud Functions の利用事例を紹介し、Cloud Functions を Production で使用する際に行った高速化を紹介しました。

今回述べたことと、公式ドキュメントのヒントとアドバイスの内容に気をつけて実装すれば、速く、楽に開発が行え、かつスケーラブルなシステムが構築できるようになると思います。

皆さんもぜひ Cloud Functions & Firebase を使ってみてください!

質問、指摘等ありましたら、鷲見(@soichisumi)までお願いします。


Tip us!

エンジニアチームがブログを書くモチベーションが上がります 💪

address

0xd6d478dCe4585a394834690158cf83581223C08f