前提
なにそれ?
- まずFirebase FunctionsとはCloud Functionsをベースにしている
- Cloud Functionsは2022年3月に第二世代(Gen2)が発表された
- Gen1はGoogleが独自実装したサーバーレス環境で動作
- Gen2はサーバーレスのOSS実装として非常にスタンダードなkNativeベースで動作
- ちなみに最近各所で採用例が増えてきているCloud RunもkNativeベースで動作している
- というか、Gen2は勝手にCloudRunで動くことになる
- ベースとなっているCloud Functionsが第二世代になったのだが、Firebase Functionsだって第二世代になるっしょ?使えるっしょ?という期待があったわけだが、公式的にはまだ対応していない
- 諸々の公式ドキュメントを見ても、Firebase Functionsとしての第二世代サポートは一切言及していない
Gen2になってどんな良いことがある?
- 詳しくはこちらをどうぞ
- ざっくりいうと、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 を前提に検証
- ドキュメントなどでは全然言及がないので、独自に検証 → 文章としてまとめておくことにした次第
ざっくりまとめ
- Firebase Functions Gen2はアルファ版でありGAはまだ先
- が、現時点でかなり仕上がってきている
- がんがんアップデート入っているので来週にはまた事情が変わってくるかも
- そもそもGen1とGen2では裏側の仕組みがかなり異なるせいで、仕様変更や制約がかなりあるので、本番利用するなら注意
- 関数名は数字小文字ハイフンのみ
- Gen1に比べてコストがかかる
- エンドポイントURLが全く異なる
- Firebase Configは使用不可(現状)
- etc.
検証環境
- firebase-functions 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エンドポイントが異なる状態になってしまう
- 生成URLが異なる
- 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としてはもはやエイリアスでしかない。
結構コストが高くなる
同じ最小1インスタンス, メモリ256MBでデプロイしたとして、
- Gen1: $0.62/month
- Gen2: $2.88/month
ぐらい変わる。ただし、そこそこリクエストが飛んでくるFuncである場合、Gen1ならインスタンスが立ちまくってコストがどんどん膨らむ。一方でGen2なら1インスタンスのみで捌ける可能性がある。となると、コストが逆転するかもしれない。モノによるので何とも言えない。
Gen2を利用するには
- firebase-functions/v2 を使うだけ
- import * as functions from "firebase-functions";
+ import * as functions from "firebase-functions/v2";
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統合
-
セットするのは今までと全く同じ
$ 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が設定される。
-
使うときはGlobalOptionsから読み込ませる
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!"); });
メモリとかインスタンス数とかinvokerの設定
- 単純にGlobalOptionsとやらを設定してあげるだけ。
- 以下の設定だと、東京リージョン, 1GiB, 最小インスタンス*3, 並列処理数1000, 未認証実行許可 でデプロイしている
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
で至って普通にデプロイできる。⚠ 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
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
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
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
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