Cloud Functions for Firebaseのコールドスタート対策を実施した

[Gincoさんの記事](https://tech.ginco.io/post/ginco-engineer-meetup-2018-cloud-functions/)を参考に、[自分のアプリ](https://t.co/MeJCVXAgLn?amp=1)で使用している関数のコールドスタート対策を行ったのでそのまとめです。

## 対象の関数の概要
アプリのデータの保存先としてFirestoreを使用しており、基本的にはアプリから直接FirestoreにRead/Write処理を行っているのですが、セキュリティの関係で一部のWrite処理をCloud Functionsで実装しています。

対象の関数は以下のようにExpressアプリケーションとして実装しています。
例えば`/posts`や`/posts/{id}`といったように複数のエンドポイントを一つの`api`という関数にまとめています。

```typescript:index.ts
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as express from 'express'
import { createPost, deletePost } from './controllers/post-controller'
import { FirestoreApi } from './apis/firestore-api'
import { verifyToken } from './controllers/verify-token'

admin.initializeApp(functions.config().firebase)
const firestoreApi = new FirestoreApi()

const app = express()

app.use(verifyToken)
app.post('/posts',  createPost(firestoreApi))
app.delete('/posts/:docId', deletePost(firestoreApi))
...

export const api = functions
    .runWith({
        timeoutSeconds: 120,
        memory: '256MB'
    })
    .https.onRequest(app)
```

## コールドスタート対策
コールドスタート対策として以下のような手段が有効なようです。

1. 実行対象の関数のモジュールのみをロードする
2. デプロイ先のリージョンを変更する
3. 依存モジュールをバージョンアップする
4. 不要なモジュール・コードを削除する
5. Node.jsのバージョンは8を使用する
6. Firestoreクライアントの使用を回避する

先述の通り対象の関数はExpressアプリケーションとして実装しており、これをエンドポイントごとに個別の関数にするというのは少し手間がかかるため、**1の対策は今回は見送りました**。

**5については実施済みでした。**

先述の記事では[Firestoreクライアントの使用を回避する](https://tech.ginco.io/post/ginco-engineer-meetup-2018-cloud-functions/#cloud-firestore%E3%81%AEclient%E3%81%AEcold-start%E3%81%AE%E6%94%B9%E5%96%84)ことでレスポンス時間を高速化していましたが、今回対象とする関数はFirestoreへの書き込みがメインとなるため、クライアントの使用を回避するわけには行きません。
そこで**6については別の方法で対策を行いました**。
詳しくは後述します。

以上の理由から、今回は以下の3つを実施しました。

- **デプロイ先のリージョンを変更する**
- **依存モジュールをバージョンアップする**
- **不要なモジュール・コードを削除する**

## デプロイ先のリージョンを変更する
デプロイ先がデフォルトの`us-central1`になっていたので、これを`asia-northeast1`に変更しました。
日本のみで公開しているアプリなので、これでレイテンシーの改善が期待できます。

以下のようにリージョンを指定するコードを追加するだけでOKです。

```index.ts
export const api = functions
    .runWith({
        timeoutSeconds: 60,
        memory: '256MB'
    })
    .region('asia-northeast1')  // 追加
    .https.onRequest(app)
```

## 依存モジュールをバージョンアップする
以下2つのモジュールのバージョンを最新にアップデートしました。
最新にすることでモジュールがCloud Functions上のキャッシュからロードされる確率が高まり、ロード時間の短縮が期待できます。

`firebase-functions`: `2.1.0` → `2.2.0`
`firebase-admin`: `6.0.0` → `7.0.0`

## 不要なモジュール・コードを削除する
開発当初に使用していた`puppeteer`がインポートされたままになっていたので、これと関連コードを削除しました。
`puppeteer`は依存モジュールも多く比較的サイズの大きなモジュールのため、これを削除するだけでもモジュールのロード時間が削減され、レスポンス速度の向上が期待できるはずです。

## 検証手順と結果
1. コマンドで関数をデプロイ: `firebase deploy --only functions:api`
2. デプロイ直後(※)にローカルPC上のPostmanから関数を実行し、レスポンス時間を計測

※デプロイされた関数が反映されるまで30秒かかります

上記の手順を3回繰り返し、レスポンス時間の平均を比較します。
**たまに外れ値が計測されることがあるので、それは除外して計測し直しています。**

実行対象となる関数には、Authorizationヘッダのトークンの検証と、1つのドキュメントおよびそのサブコレクションに対する3つのドキュメントの書き込みで構成されるトランザクション処理が含まれています。

レスポンス時間の結果は以下の通りとなりました。

|                    | 対策前 | リージョン変更 | モジュールバージョンアップ | モジュール削除 |
|--------------------|--------|----------------|----------------------------|----------------|
| コールドスタート時 | 7238ms | 6274.7ms       | 4905.3ms                   | 4390.7ms       |
| 通常時             | 346ms  | 510.3ms        | 533.3ms                    | 542ms          |

「モジュールバージョンアップ」には「リージョン変更」も含まれています。
同様に「モジュール削除」には「リージョン変更」と「モジュールバージョンアップ」も含まれています。

対策前と比較すると、コールドスタート時のレスポンス時間は**対策前と比較して2.8秒ほど高速化されました**。

ただ、通常時のレスポンス時間が対策前より遅くなってしまっているのが気になります。。
こちらについては今のところ原因がわかっていません。

## Firestoreクライアントの問題
[こちらのIssue](https://github.com/firebase/firebase-functions/issues/263#issuecomment-451297155)で言及されていますが、firestore-adminが提供するFirestoreクライアントはgRPCのコネクションを張る関係で初回接続時に遅延が発生します。
コールドスタート対策を実施してもまだ4390msも時間がかかっている原因はここにあります。

この問題に対しては**関数に割り当てるメモリサイズを大きくし、CPUの性能を向上させる**という対策が有効です。
メモリサイズとCPUクロックの関係は[こちらのドキュメント](https://cloud.google.com/functions/pricing#compute_time)で確認できます。

実は個人アプリということでなるべく費用を抑えるためにメモリサイズを`256MB`にしていました。
今回これを最大サイズである`2GB`にしたところ、レスポンス時間が**1718.3ms**にまで改善されました。

|                    | コールドスタート対策後 | メモリサイズ増加後 |
|--------------------|------------------------|--------------------|
| コールドスタート時 | 4390.7ms               |         1718.3ms    |
| 通常時             | 542ms                  |     492ms            |

## まとめ
コールドスタート対策を行うことにより、今回は2.8秒の高速化を行うことができました。
さらに、メモリサイズを大きくし、CPUの性能を上げることによって、Firestoreクライアントの初回接続時の遅延時間を短縮し、レスポンス時間の大幅な短縮を行うことができました。

## 参考
- [GincoにおけるCloud Functionsの利用とその高速化](https://tech.ginco.io/post/ginco-engineer-meetup-2018-cloud-functions/)
- [Extreme delay with functions and firestore on cold starts.](https://github.com/firebase/firebase-functions/issues/263)
- [Cloud Functionsドキュメント](https://cloud.google.com/functions/pricing#compute_time)