Firebase Functions Gen2を試してみた

# 前提

## なにそれ？

- まずFirebase FunctionsとはCloud Functionsをベースにしている
- Cloud Functionsは2022年3月に第二世代(Gen2)が発表された
    - Gen1はGoogleが独自実装したサーバーレス環境で動作
    - Gen2はサーバーレスのOSS実装として非常にスタンダードなkNativeベースで動作
    - ちなみに最近各所で採用例が増えてきている[Cloud RunもkNativeベースで動作](https://cloud.google.com/blog/ja/products/serverless/knative-based-cloud-run-services-are-ga)している
    - というか、Gen2は勝手にCloudRunで動くことになる
- ベースとなっているCloud Functionsが第二世代になったのだが、Firebase Functionsだって第二世代になるっしょ？使えるっしょ？という期待があったわけだが、公式的にはまだ対応していない
    - 諸々の公式ドキュメントを見ても、Firebase Functionsとしての第二世代サポートは一切言及していない

## Gen2になってどんな良いことがある？

- 詳しくはこちらをどうぞ
    - [https://cloud.google.com/functions/docs/2nd-gen/overview](https://cloud.google.com/functions/docs/2nd-gen/overview)
- ざっくりいうと、kNativeベースになったことでGen1に比べ以下の優位性がある
    - **タイムアウトの上限拡張**
        - Gen1: 540sec → Gen2: 3600sec
    - **1インスタンスに対する複数リクエストの並列処理サポート**
        - Gen1では1リクエストにつき1インスタンスが動的に生成され捌いていた
        - Gen2では1インスタンスで最大1000リクエストを捌ける
            - つまり、その分だけコールドスタートが減り、実処理開始までのレイテンシが大幅に下がる
        - 複数リクエスト処理したとして、それでそのインスタンスが過負荷に陥るようであれば、インスタンスがスケールアウトしていく
    - **Gen1よりも強いメモリとCPUコアの提供**
        - Gen1ではメモリ:8GB, CPUコア:2が最大
        - Gen2ではメモリ:16GB, CPUコア:4まで割り当て可能

---

以下、本題

## 前提

- npmで提供される [firebase-functions](https://www.npmjs.com/package/firebase-functions) を前提に検証
- ドキュメントなどでは全然言及がないので、独自に検証 → 文章としてまとめておくことにした次第

## ざっくりまとめ

- Firebase Functions Gen2はアルファ版でありGAはまだ先
- が、現時点でかなり仕上がってきている
    - がんがんアップデート入っているので来週にはまた事情が変わってくるかも
- そもそもGen1とGen2では裏側の仕組みがかなり異なるせいで、仕様変更や制約がかなりあるので、本番利用するなら注意
    - 関数名は数字小文字ハイフンのみ
    - Gen1に比べてコストがかかる
    - エンドポイントURLが全く異なる
    - Firebase Configは使用不可(現状)
    - etc.

## 検証環境

- [firebase-functions v3.21.1](https://github.com/firebase/firebase-functions/releases/tag/v3.21.1)
- firebase-tools v10.9.2

## 現時点できること

- firebase-functions使って一行変えるだけでGen2を使える
    - Gen1を使った既存コードも一行変えるだけでGen2化できる
- firebase-tools + firebase-functions構成でのデプロイ可能
    - コマンドは何も変わらない
    - つまりfirebase-toolsに依存したCDの場合、そのコードを変えずにv2を扱うことができる
    - ただしバージョンは定かではないが、新しめのfirebase-toolsを使わないとデプロイうまくいかないらしい
- コードベースでメモリやタイムアウト値の指定
    - Gen1の時のrunWithで設定していた内容はGen2でも使える
    - 最小インスタンス数や並列処理数(concurrency)の設定も可能
    - VPCコネクターもサポート
        - CloudSQLなど他のサーバーレスサービスとの連携が容易
- 実行元(Invoker)も設定可能
    - 未認証許可 / 特定のSA認証のみ
    - が、それっぽい実装はあるが、まだちゃんと動いていないっぽい？
- Firebase Emulatorも利用可能
- SecretManager統合をサポート
- firestoreも問題なく読み込み書き込み可能

# できないこと

- Firebase Functions Configは非サポート
    - Configを埋め込むことはできるが、Gen2環境でそれをgetすることができない
- Firebase Emulatorを使えるけどGen2を意識したエミュレーションではない
    - 生成URLが異なる
        - emulatorではGen1ライクなGCFとしてのエンドポイント
        - GCP環境にデプロイするとGen2なエンドポイント
    - GCPとローカル開発系でAPIエンドポイントが異なる状態になってしまう
- Function名は必ず小文字でなければならないので、Gen1でキャメルケースで命名していた場合、全面的にリネームが必須

# 試していないこと

- Firestore trigger
    - 今回の検証バージョンではtriggerは未対応だが最新版だとサポートしてそう
    - 試したら追記か別記事書く予定
    - ref. https://github.com/firebase/firebase-functions/pull/1127


## 注意すべき点

### エンドポイントURLがまるっきり変わる

Gen2はkNativeというかGCPの世界では完全にCloudRunそのものとしてデプロイさせるので、もう完全にエンドポイントが変わってしまう。

- Gen1: https://{region}-hogehoge.cloudfunctions.net/{func-name}
- Gen2: https://{func-revision}.a.run.app

こんな感じ。CloudFunctionsの画面から見ても、もうCloudRunにリダイレクトされるような形になっている。CloudFunctionsとしてはもはやエイリアスでしかない。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80163/df692e12-d9d7-b863-c361-c5eb2b5ad15b.png)


### 結構コストが高くなる

同じ最小1インスタンス, メモリ256MBでデプロイしたとして、

- Gen1: $0.62/month
- Gen2: $2.88/month

ぐらい変わる。ただし、そこそこリクエストが飛んでくるFuncである場合、Gen1ならインスタンスが立ちまくってコストがどんどん膨らむ。一方でGen2なら1インスタンスのみで捌ける可能性がある。となると、コストが逆転するかもしれない。モノによるので何とも言えない。

---

## Gen2を利用するには

- firebase-functions/v2 を使うだけ

```diff
- import * as functions from "firebase-functions";
+ import * as functions from "firebase-functions/v2";
```

```tsx
import * as functions from "firebase-functions/v2";

export const demov2 = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", { structuredData: true });
  response.send("Hello from Firebase!");
});
```

## SecretManager統合

- セットするのは今までと全く同じ
    
    ```bash
    $ firebase functions:secrets:set HOGE
    ? Enter a value for HOGE [hidden]
    ✔  Created a new secret version projects/111111111111/secrets/HOGE/versions/1
    $ firebase functions:secrets:get HOGE
    ┌─────────┬─────────┐
    │ Version │ State   │
    ├─────────┼─────────┤
    │ 1       │ ENABLED │
    └─────────┴─────────┘
    ```
    

- これで勝手にSecretManagerが設定される。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80163/a83feb11-45c8-0097-0617-b6066d0601b3.png)


- 使うときはGlobalOptionsから読み込ませる
    
    ```tsx
    import * as functions from "firebase-functions/v2";
    
    functions.setGlobalOptions({
      secrets: ["HOGE"],
    });
    export const demov2 = functions.https.onRequest((request, response) => {
      functions.logger.info(`secret var: ${process.env.HOGE}`, {
        structuredData: true,
      });
      response.send("Hello from Firebase!");
    });
    ```
    

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80163/f4b7268f-fd0a-b619-5c6a-20bb41db94a2.png)



## メモリとかインスタンス数とかinvokerの設定

- 単純にGlobalOptionsとやらを設定してあげるだけ。
- 以下の設定だと、東京リージョン, 1GiB, 最小インスタンス*3, 並列処理数1000, 未認証実行許可 でデプロイしている

```tsx
import * as functions from "firebase-functions/v2";

functions.setGlobalOptions({
  region: "asia-northeast1",
  memory: "1GiB",
  minInstances: 3,
  concurrency: 1000,
  invoker: "public",
});
export const demov2 = functions.https.onRequest((request, response) => {
  functions.logger.info("Hello logs!", { structuredData: true });
  response.send("Hello from Firebase!");
});
```

- `firebase deploy —only functions`　で至って普通にデプロイできる。
    
    ```bash
    ⚠  functions: The following functions have reserved minimum instances. This will reduce the frequency of cold starts but increases the minimum cost. You will be charged for the memory allocation and a fraction of the CPU allocation of instances while they are idle.
    
            demov2(asia-northeast1): 3 instances, 1GB of memory each
    
    With these options, your minimum bill will be $33.66 in a 30-day month
    ? Would you like to proceed with deployment? Yes
    ✔  functions: functions folder uploaded successfully
    
    The following functions are found in your project but do not exist in your local source code:
            demov2(us-central1)
    
    If you are renaming a function or changing its region, it is recommended that you create the new function first before deleting the old one to prevent event loss. For more info, visit https://firebase.google.com/docs/functions/manage-functions#modify
    
    ? Would you like to proceed with deletion? Selecting no will continue the rest of the deployments. (y/N)
    ```
    

## すっごい簡単なベンチーマーク

Gen1とGen2ですっごい簡単なのを用意してあげる。setTimeoutで意図的に何かそれっぽい重い処理が後ろで走っていると擬似的に再現させる。

### Gen1

```tsx
import * as functions from "firebase-functions";

export const benchmark = functions
  .region("asia-northeast1")
  .runWith({ memory: "256MB", minInstances: 1 })
  .https.onRequest((request, response) => {
    setTimeout(() => {
      functions.logger.info("Hello logs!", { structuredData: true });
      response.send("Hello from Firebase!");
    }, 1000);
  });
```

### Gen2

```tsx
import * as functions from "firebase-functions/v2";

functions.setGlobalOptions({
  region: "asia-northeast1",
  memory: "256MiB",
  minInstances: 1,
  concurrency: 1000,
  invoker: "public",
});

export const benchmark = functions.https.onRequest(
  async (request, response) => {
    setTimeout(() => {
      functions.logger.info("Hello logs!", { structuredData: true });
      response.send("Hello from Firebase!");
    }, 1000);
  }
);
```

## 簡易ベンチマーク

同時リクエスト: 1000 * 10sec走らせて、どういう結果になるか？
実際には、いきなり1000かけるのではなく、少ない数から開始して1000に持っていくような動きになる。

結論からいうと、Gen2はGen1に比べ同時リクエスト処理数の最低値が2倍程度になる。

Gen1はリクエスト数に応じてインスタンスが生成されるので、どうしてもその待ち時間が必要になる。
Gen2は1つで複数リクエストを捌けるのでその時間が短くて済む。

### Gen1

```tsx
k6 run --vus 1000 --duration 10s k6_run_test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: k6_run_test.js
     output: -

  scenarios: (100.00%) 1 scenario, 1000 max VUs, 40s max duration (incl. graceful stop):
           * default: 1000 looping VUs for 10s (gracefulStop: 30s)

running (11.1s), 0000/1000 VUs, 7955 complete and 0 interrupted iterations
default ✓ [======================================] 1000 VUs  10s

     data_received..................: 13 MB  1.2 MB/s
     data_sent......................: 1.2 MB 105 kB/s
     http_req_blocked...............: avg=80.51ms min=0s    med=1µs   max=1.24s    p(90)=313.46ms p(95)=742.97ms
     http_req_connecting............: avg=63.55ms min=0s    med=0s    max=941.37ms p(90)=209.94ms p(95)=608.39ms
     http_req_duration..............: avg=1.23s   min=1.01s med=1.02s max=6.27s    p(90)=2.03s    p(95)=2.49s
       { expected_response:true }...: avg=1.23s   min=1.01s med=1.02s max=6.27s    p(90)=2.03s    p(95)=2.49s
     http_req_failed................: 0.00%  ✓ 0          ✗ 7955
     http_req_receiving.............: avg=255.2µs min=9µs   med=85µs  max=22.33ms  p(90)=447.6µs  p(95)=845µs
     http_req_sending...............: avg=64.56µs min=11µs  med=53µs  max=4.23ms   p(90)=90µs     p(95)=114µs
     http_req_tls_handshaking.......: avg=16.75ms min=0s    med=0s    max=851.61ms p(90)=54.22ms  p(95)=96.44ms
     http_req_waiting...............: avg=1.23s   min=1.01s med=1.02s max=6.27s    p(90)=2.03s    p(95)=2.49s
     http_reqs......................: 7955   719.665337/s
     iteration_duration.............: avg=1.31s   min=1.01s med=1.02s max=6.52s    p(90)=2.52s    p(95)=3.12s
     iterations.....................: 7955   719.665337/s
     vus............................: 56     min=56       max=1000
     vus_max........................: 1000   min=1000     max=1000
```

### Gen2

```tsx
k6 run --vus 1000 --duration 10s k6_run_test.js

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  (‾)  |
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: k6_run_test.js
     output: -

  scenarios: (100.00%) 1 scenario, 1000 max VUs, 40s max duration (incl. graceful stop):
           * default: 1000 looping VUs for 10s (gracefulStop: 30s)

running (11.0s), 0000/1000 VUs, 7914 complete and 0 interrupted iterations
default ✓ [======================================] 1000 VUs  10s

     data_received..................: 6.2 MB 565 kB/s
     data_sent......................: 1.1 MB 101 kB/s
     http_req_blocked...............: avg=85.1ms   min=0s    med=1µs   max=1.19s    p(90)=396.89ms p(95)=771.88ms
     http_req_connecting............: avg=74.15ms  min=0s    med=0s    max=1.04s    p(90)=321.18ms p(95)=685.4ms
     http_req_duration..............: avg=1.24s    min=1.01s med=1.21s max=2.47s    p(90)=1.47s    p(95)=1.54s
       { expected_response:true }...: avg=1.24s    min=1.01s med=1.21s max=2.47s    p(90)=1.47s    p(95)=1.54s
     http_req_failed................: 0.00%  ✓ 0          ✗ 7914
     http_req_receiving.............: avg=534.64µs min=8µs   med=124µs max=82.57ms  p(90)=997µs    p(95)=1.58ms
     http_req_sending...............: avg=59.27µs  min=10µs  med=43µs  max=2.75ms   p(90)=85µs     p(95)=121µs
     http_req_tls_handshaking.......: avg=9.64ms   min=0s    med=0s    max=328.66ms p(90)=47.1ms   p(95)=78.18ms
     http_req_waiting...............: avg=1.24s    min=1.01s med=1.21s max=2.47s    p(90)=1.47s    p(95)=1.54s
     http_reqs......................: 7914   717.570206/s
     iteration_duration.............: avg=1.33s    min=1.01s med=1.23s max=2.63s    p(90)=1.58s    p(95)=1.95s
     iterations.....................: 7914   717.570206/s
     vus............................: 106    min=106      max=1000
     vus_max........................: 1000   min=1000     max=1000
```

# 参考

* [https://github.com/firebase/firebase-functions/pull/1090/files](https://github.com/firebase/firebase-functions/pull/1090/files)
* [https://github.com/firebase/firebase-functions/pull/1079/files](https://github.com/firebase/firebase-functions/pull/1079/files)