Cloud FunctionsをGoで書く。またはFirebaseのためのマイクロサービスアーキテクチャ

f:id:laiso:20191216155312j:plain

Firebase Advent Calendar 2019 の17日目です。16日目はKesin11さんの「Firebase Emulator Suiteをフル活用してTDDで開発しよう」でした。

はじめに

FirebaseプロジェクトでCloud Firestoreを利用する時は通常Node.jsによるCloud Functionsでトリガーとなる処理を記述します。その他には関連するAPIサーバー、WebアプリのフロントエンドのSSR、バックエンドの非同期処理など、多くの場面でCloud Functionsが活用されています。

この開発→デプロイサイクルをお手軽に行ってくれるのがfirebase-toolsというnpmモジュールです。JavaScriptでFunctionを実装し、firebase deployコマンドを実行するだけでFirebaseプロジェクト用のCloud Functionsが自動で登録されます。

解決したい問題

firebase-toolsは便利に使っていたのですが、私たちのプロジェクトでは日々いくつものCloud Functionsのモジュールが増えてゆく過程で以下の問題が発生しました。

  1. デプロイ対象となるソースコードの量が増え、デプロイ完了までの時間がかかるようになった(--onlyオプションを付けても遅い)
  2. SSRを含むフロントエンド向けビルド、APIサーバーとして機能するFunction、Firebase固有の処理、などの依存が単一の package.json で管理されていてモジュール更新の影響が大きかった
  3. 複数のFirebaseプロジェクトでプライベートリポジトリにある自作モジュールを共有したかった
  4. Cloud Tasksや複数Functionへの分散した水平スケールでは実現できない、リアルタイムの大量データ処理を垂直方向にスケーリングしたかった

そこで私たちはまずはじめにfirebase-toolsの仕組みやCloud Functionsがどのように動作しているのかを理解して、最終的にfirebase-toolsで構成していたFunctionのビルドを徐々にマイクロなモジュールに分割していくことにしました。

Cloud Functions for Firebaseの仕組み

Cloud Functions for Firebase*1

Cloud Functionsは - リソース - イベント - 関数名

という組合せでFunctionを管理しています。特定のリソースAに発生したイベントαにアップロードしてビルド済みのプログラムから指定の関数を実行します。

/**
 * [Google Cloud Firestore トリガー](https://cloud.google.com/functions/docs/calling/cloud-firestore?hl=ja)
 */

const Firestore = require('@google-cloud/firestore');

const firestore = new Firestore({
  projectId: process.env.GCP_PROJECT,
});

exports.makeUpperCaseOne = (data, context) => {
  const {resource} = context;
  const affectedDoc = firestore.doc(resource.split('/documents/')[1]);

  const curValue = data.value.fields.original.stringValue;
  const newValue = curValue.toUpperCase();
  console.log(`Replacing value: ${curValue} --> ${newValue}`);

  return affectedDoc.set({
    original: newValue,
  });
};

という関数を持つ index.js のみのコードを用意して

# package.json を生成する
npm init && npm i @google-cloud/firestore

gcloud functions deploy makeUpperCaseOne --runtime nodejs8 \
  --trigger-event providers/cloud.firestore/eventTypes/document.create \
  --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId}"

というコマンドでデプロイすると(gcloudコマンドは別途セットアップします)

projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId} のリソースに対して providers/cloud.firestore/eventTypes/document.create というイベントが発生した時に 関数 makeUppercaseOne() をトリガーする。

というCloud Functionsのリソースが登録されます*2

試しにコンソールからFirestoreのドキュメントを作成してみます

実行されドキュメントが更新されました。

同等の処理を行うFunctionをCloud Functions for Firebaseで実装してみることにします。

以下のようにfirebase-functionsモジュールを使ってFunctionを定義すると

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

exports.makeUppercase = functions.firestore.document('/messages/{documentId}')
    .onCreate((snap, context) => {
      const original = snap.data().original;
      console.log('Uppercasing', context.params.documentId, original);
      const uppercase = original.toUpperCase();
      return snap.ref.set({uppercase}, {merge: true});
    });

firebase deploy --only functions の内部で先程のgcloudコマンド同様のリソースができるわけです。

gcloud functions deploy makeUpperCase --runtime nodejs8 \
  --trigger-event providers/cloud.firestore/eventTypes/document.create \
  --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{docId}"

元のFunction用のコードからソースコードをそのままに1つの関数だけを抽出して、フォルダ構成としてはこのような独立したnpmパッケージとして管理できるようになります

functions/makeUppercase/
├── index.js
├── node_modules/
├── package-lock.json
└── package.json

Goランタイムへの置き換え

Cloud Functionsへ直接デプロイすることができたので、次にFunctionをGo言語で実装してCPUメモリあたりの実行速度の高速化を図ります。ボトルネックがI/Oではない典型的なデータ処理のFunctionはこれだけでスループットが向上が期待できます。

var projectID = os.Getenv("GCLOUD_PROJECT")
var client *firestore.Client

func init() {
    conf := &firebase.Config{ProjectID: projectID}

    ctx := context.Background()

    app, err := firebase.NewApp(ctx, conf)
    if err != nil {
        log.Fatalf("firebase.NewApp: %v", err)
    }

    client, err = app.Firestore(ctx)
    if err != nil {
        log.Fatalf("app.Firestore: %v", err)
    }
}

更新した値をFirestoreに書き込み更新する場合、Firestore Clientの準備をします。

init() はランタイムにより指定されているインスタンス毎の初期化処理で、Node.js版でいうグローバル変数にセットアップ済みの値を入れておく方法を似ています。

type FirestoreEvent struct {
    OldValue   FirestoreValue `json:"oldValue"`
    Value      FirestoreValue `json:"value"`
    UpdateMask struct {
        FieldPaths []string `json:"fieldPaths"`
    } `json:"updateMask"`
}

type FirestoreValue struct {
    CreateTime time.Time `json:"createTime"`
    Fields     Message    `json:"fields"`
    Name       string    `json:"name"`
    UpdateTime time.Time `json:"updateTime"`
}

type Message struct {
    Original struct {
        StringValue string `json:"stringValue"`
    } `json:"original"`
}

func MakeUpperCaseGo(ctx context.Context, e FirestoreEvent) error {
    fullPath := strings.Split(e.Value.Name, "/documents/")[1]
    pathParts := strings.Split(fullPath, "/")
    collection := pathParts[0]
    doc := strings.Join(pathParts[1:], "/")

    curValue := e.Value.Fields.Original.StringValue
    log.Printf("Uppercasing: %q", curValue)

    newValue := strings.ToUpper(curValue)
    data := map[string]string{"original": newValue}
    _, err := client.Collection(collection).Doc(doc).Set(ctx, data)
    if err != nil {
        return fmt.Errorf("Set: %v", err)
    }

    return nil
}

Function本体の処理です。Go言語の型システムの仕様上Firestore内の値のパース処理を、Node.js版でいう firebase-functions にあたるユーティリティがないためドキュメントパスの解決などを自分で実装する必要があります。

デプロイします。

gcloud functions deploy MakeUpperCaseGo --runtime go111 \
  --trigger-event providers/cloud.firestore/eventTypes/document.write \
  --trigger-resource "projects/$GCP_PROJECT_ID/databases/(default)/documents/messages/{documentId}"
func TestFunc(t *testing.T) {
    var projectID = os.Getenv("GCLOUD_PROJECT")
    jsonStr := `{
      "original": {"stringValue": "hello"}
  }`
    var message Message
    var err error
    err = json.Unmarshal([]byte(jsonStr), &message)
    if err != nil {
        log.Fatal(err)
    }
 
    value := FirestoreValue{
        Name:   "projects/" + projectID + "/databases/(default)/documents/messages/1",
        Fields: message,
    }

    result := MakeUpperCaseGo(context.Background(), FirestoreEvent{Value: value})
    if result != "HELLO" {
        t.Error()
    }
}

動作確認をGoのユニットテストとして記述できます。

写真のリサイズFunctionをGoで実装してみる

Functionのイメージ*3

ユーザーがアプリケーションから写真を登録したらシステムで必要なサイズの画像を自動で生成するようなFunctionをGoに置き換えてみます。

  1. Document更新トリガで関数を実行
  2. パースしてきたURLから画像をダウンロードしてくる
  3. リサイズを実行
  4. 画像をCloud Storageに保存

という一連の流れです

conf := &firebase.Config{ProjectID: projectID}
opt := option.WithCredentialsJSON([]byte(os.Getenv("SERVICE_ACCOUNT_JSON")))
app, err := firebase.NewApp(ctx, conf, opt)

Firebase Admin SDKの初期化時にクレデンシャルを含むJSONファイルのパスを指定するのではなく、環境変数から読み込むようにします(confも同じ形式にできるのですが、秘密情報を含まないためコードで指定しています)。

gcloud functions deploy Resizing --set-env-vars SERVICE_ACCOUNT_JSON=$(cat secretkey.json)
// var fbStorage *storage.Client
fbStorage, err = app.Storage(ctx)
if err != nil {
    log.Fatalf("app.Firestore: %v", err)
}

Cloud Storageのクライアントも init() で初期化しておきます。

type User struct {
    ProfileImageUrl struct {
        StringValue string `json:"stringValue"`
    } `json:"profile_image_url"`
}

ドキュメントの構造体をこのように定義しました。profile_image_url というキーに画像がアップロードされたURLが保存されます。

func Resizing(ctx context.Context, e FirestoreEvent) error {
    url := e.Value.Fields.ProfileImageUrl.StringValue
    cli := http.Client{}
    resp, err := cli.Get(url)
    if err != nil {
        log.Fatal(err)
    }
    src, _, err:= image.Decode(resp.Body)
    if err != nil {
        log.Fatal(err)
    }

    size := 320
    img := imaging.Resize(src, size, 0, imaging.Lanczos)
    encoded := &bytes.Buffer{}
    jpeg.Encode(encoded, &*img, nil)

    bucket, err := fbStorage.Bucket(bucketName)
    if err != nil {
        log.Fatal(err)
    }
    path := fmt.Sprintf("resized_images/%dx.jpg", size)
    obj:= bucket.Object(path)
    writer := obj.NewWriter(ctx)
    io.Copy(writer, encoded)
    defer writer.Close()

    return nil
}

Function本体です。imaging(https://github.com/disintegration/imaging )を使い 320x にリサイズしてアップロードします(別途Storageへの追加をトリガーにしてUserドキュメントにパスをセットします)

検証してみたところNode.js版での公式ドキュメントでの解説にあるようなImageMagickのconvertコマンドを外部で実行するような方法*4と比べて、ファイルに書き出しがない部分がうまく効いて大量のリサイズが一度の実行でできそうでした(リサイズ処理の品質に差がでるかもしれないので別途評価が必要です)。

デプロイ速度やFunction起動速度について

Cloud Functionsのデプロイはローカルにあるソースコードを対象として、依存モジュールが記述されている package.jsongo.mod から自動的にクラウド環境でビルドが走るようです。

firebase-tools を使ったデプロイを行っていた時は、functions/ にあるすべてのファイルが対象になり1つのFunctionのリソースへアップロードされていました(GCPコンソールからアップロード済みファイルが取得できるので確認できます)。

またFirebaseユーザーの間でFunctionの高速化テクニックとして環境変数から探索して、Node.jsの依存モジュールの動的読み込み制御する方法が知られています。*5

これらの方法と比べてアーキテクチャ的に改善する可能性はあるなと思いつつも、まだ安定性やアーキテクチャの評価中なので詳しくは比較できていない状態です。

ビルド+デプロイツールの改善

firebase-toolsを使わくなることで、複数のFunctionの依存を管理するための方法や開発やデプロイを楽にする方法を別途用意しなければいけません。

Functionを分割して複数の依存を管理するパッケージができたことで、ソースコードはmonorepoの状態になります。そのためlerna(https://lerna.js.org/ )やBazel(https://www.bazel.build/ )のようなツールが機能する環境になるかもしれません。

ただfirebase-toolsを使った環境は並行して維持できるので、段階的に移行して検討するつもりです。

GCPサービスを使ったさらなるFirebaseアプリケーションの拡張

※Firebase & Google Cloud Platform*6

Cloud FunctionsはGoランタイムの他にはPythonランタイムもあり、そちらでも同様にトリガーFunctionが書けるので何か活用法があるかもしれません。

また各FirebaseやGCPサービスには対応したREST APIが公開されていることも多く、SDK対応言語以外でもクライアントを自作して拡張することができます。

もちろんCloud Functionsですべてを行うことにこだわらずとも、HTTPリクエストをトリガーとしたAPIサーバーのFunctionや、SSRを実行して動的HTMLを返す常に待ち受けしているFunctionは、同時処理数に優れたCloud Runに移すことができそうです。

またCloud Run同士で連携して(RESTやunary gRPC)サーバー間通信でシステムを拡張の目的でも利用できそうです。

他にはCloud SchedulerとCloud Tasks、Cloud Dataflowを使ったバッチ処理。Firebase AnalyticsとCloud FirestoreをBigQueryにエクスポートして分析し、その結果をシステムに反映させるなどを私たちも既に行っています。

まとめ

このようにFirebaseはGCPの既存の仕組みを使い易くラップしたものなので、必要に応じてGCPのリソースを活用して最適化することができます。

Firebase Advent Calendar 2019 - Qiita

明日の担当はVexus2さんです。お楽しみに。

参考文献