Cloud Functions、温めますか?

この投稿は Goodpatch Advent Calendar 2022 4日目の記事です。

オンラインホワイトボード Strap の開発を担当している、バックエンドエンジニアのほっしーです!

Strapではバックエンド環境にGoogle Cloud Platform(以下GCP)を使用しており、パフォーマンス改善やコスト削減に継続的に取り組んでいます!
そんな中でも特に使用頻度が高く重要なサービスの一つである Cloud Functions のパフォーマンス改善について個人的に調査・検討を行った結果を紹介します😊

初めに

Cloud Functionsとは

本題に入る前にCloud Functionsについて軽く触れておきます。 Cloud FunctionsはGCPが提供するサーバレスのコンピューティングサービスで、いわゆるFaaS(Function as a Service)の一つです。
FunctionをGCP上にデプロイした後にその実行がトリガーされると、その都度インスタンスが起動し処理が実行されるようになります。
なお、ここで言うトリガーには主に以下の二つのタイプがあります。

  • HTTPリクエストによるトリガー
  • GCP内の他サービス(Cloud Storage、Cloud Pub/Sub、Cloud Firestore、Eventarcなど)のイベント起因によるトリガー

特にGCP内の他サービスとの親和性が高いため、組み合わせることで更にCloud Functionsのメリットを享受することが出来ます。

Cloud FunctionsのPros & Cons

Cloud Functionsは実行中に消費するリソース(データ量・呼び出し回数・処理時間など)に応じて課金される従量課金制のサービスです。
Functionの実行時のみインスタンスが起動しそれ以外はサーバが稼働しない(= 課金対象にならない)ため、実行時間の短縮やリソース量の調整などを工夫することで一般的なサーバでかかるようなコストを大幅に抑えつつ運用することが可能になります。
また、Functionが実行されるサーバ自体はフルマネージドなので開発者はサーバのメンテナンスを気にする必要がなくなります。 アプリケーションエンジニアとしては複雑な設定が不要で開発だけに集中することが出来るメリットは大きいと思っています。 更に、 Functionはそれぞれが独立したマイクロサービスとして存在しています。 そのため、障害時の切り分けやスケーリングを他と依存関係なく行うことが出来ます。

このようにメリットが盛りだくさんのサービスですが、その反面デメリットもあります💧
例えば、非効率な処理や巨大なモジュールを使用してしまうと実行コストが嵩み、その結果課金額にも響いてくることがあり得ます。
また、実行時のCPUやメモリのスペック、ベースとなるサーバ自体のストレージ容量などに制限があります。 大きなファイルをストリーム処理する際や長時間のバッチ処理などは気をつけていないとメモリ不足でサーバがダウンする危険性もあります。
更に、実行の都度インスタンスを立ち上げるコールドスタートが発生するため、通常のAPIに比べて実行時間が増加しやすくなる特徴もあります。

StrapとCloud Functionsはどんな関係?

冒頭でも書きましたが、StrapではCloud Functionsをバックエンドサービスとして利用しています。

バックエンドのシステム構成
ちなみに、そもそもなぜバックエンドにCloud Functionsを選択したのかについては別のブログで紹介していますので、そちらをご覧ください😉

goodpatch-tech.hatenablog.com

上のブログでも書かれているように、Strapでは

Backendでしなくていいこと(Frontendでできること)はしない

ということを基本方針としています。
そのため、認証認可や権限設定などセキュリティの担保が必要な処理や、ボード複製など時間がかかる処理のように必要条件を満たす場合に限り、Cloud Functionsを使用するようにしています。

Strap上での課題

Functionのレスポンスが遅いことがある…

そんなCloud Functionsですが、Strap上での使用にある課題がありました。
それはConsでも紹介した、Functionsのレスポンスが遅いということです。
一例ですが、下はStrap上で他のワークスペースに切り替えた際の挙動をキャプチャしたものです。 ローディング完了までに約7秒かかっています。
3秒待たされるとイライラしてしまうと言われているWebサービスの世界において、この時間は見過ごせない課題だと感じていました。

ワークスペースの切り替えに7秒かかる…

確認したところ、この操作では認可処理のためにHTTPリクエストでコールしたFunctionを呼び出しているのですが、7秒のうちのほとんどはそのリクエスト - レスポンスの時間でした。
また、直後に同じ操作をした際には同じHTTPリクエスト完了までの時間が3~4秒程度短くなることも分かりました。
これは、実行時に立ち上がったインスタンスがホットスタンバイ状態となり、暫くの間はリクエストを待機しているためだと思われます。
このことから、一定期間が経過した後のFunctionへのリクエストに対して対策を取ることで、ある程度のパフォーマンス改善が見込めそうです。

原因と対策を考える

Functionで時間がかかる原因と対策ですが、ざっと調べたところ主に下のようにケース分けが出来そうでした。

  • Functionの処理に時間がかかっているから
    • indexに全ての関数を定義しておりロード時のコード量が多いため、余分なFunctionのコードを読み込まないようにする
    • デフォルトのメモリ割り当て量を使用しておりスペックが処理に追いついていないため、メモリの割り当て量を増やす
    • Functionが配置されるリージョンが遠い場合は通信コストがかかるため、ロケーションを利用者の近い場所に変更する
    • 依存ライブラリをアップデートする
  • Functionの起動に時間がかかっているから
    • 常時起動インスタンスを設定し初回ロード時のコールドスタートを減らす
    • 定期的にFunctionを呼び出し常時ホットスタンバイ状態にする

まず、今回の事象の関心事はコールドスタンバイへの対策のため、一つ目の"処理"に関する対策は検証の対象外としました。
二つ目の"起動"に関する対策は有効性を確認したいため、動作検証を行うことにしました👨‍🔬

動作検証してみよう!

V1とV2のインスタンス性能はどう違う?

最初に"初回ロード時のコールドスタートを減らす"について検証してみます。 まず、インスタンスを増設してコールドスタートを回避する方法については、Cloud Functionsのバージョンに依って挙動に差異があります。

記事執筆時、StrapではCloud FunctionsのV1を使用しています。
ただ、2022年8月にCloud Function V2がGAされたためいつでも移行が可能な状態になっています。
なお、V1とV2の主な特徴の違いは以下の通りです。

Cloud Functions V1とV2の違い

インスタンスの同時実行数を見ると、V1の場合は1インスタンスにつき同時実行処理数が1なのに対して、V2では最大で1000まで設定可能になっています。
もし同時に2つ以上のリクエストが届いた場合、V1ではリクエスト毎にインスタンスを立ち上げてから処理がスタートすることになるため、その分のリードタイムが発生します。
一方、V2では最初のリクエストでインスタンスが立ち上がりさえすれば残りの処理は同じインスタンスを使い回すことが出来るため、V1に比べて良い速度性能が期待できそうです。

このことを確認するために実験をしてみました。

  1. Firestoreのドキュメントを10回取得するだけのFunctionをV1、V2両方用意しデプロイ(最小インスタンス数は1に設定)
  2. FunctionにHTTPリクエストを送るcurlコマンドを3つ同時に実行する
  3. curl -wconsole.timeなどでリクエストからレスポンスまでにかかった時間と、Function内の実行時間をそれぞれ計測しておく
  4. 平均値算出のために1~3を5回繰り返す

※ 1の都度デプロイはFunctionをコールドスタート状態に戻すために行っています。

概念図 - インスタンス性能の確認

なお、それぞれのコード・コマンドは以下を使用しました。

// Deployed Function V1

import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';

if (admin.apps.length === 0) {
  admin.initializeApp();
}

const firestoreAdmin: admin.firestore.Firestore = admin.firestore();
const FUNCTION_REGION = 'asia-northeast1';

export const warmstarttestv1 = functions
  .region(FUNCTION_REGION)
  .runWith({ minInstances: 1 })  // 最小インスタンス数を設定
  .https.onCall(
    async (): Promise<void> => {
      console.time(`timer.warmStartTest`);

      const collectionName = ${MY_COLLECTION_NAME};
      const documentId = ${MY_DOCUMENT_ID};

      for (let i = 0; i < 10; i++) {
        await firestoreAdmin
          .collection(collectionName)
          .doc(documentId)
          .get();
      }

      console.timeEnd(`timer.warmStartTest);  // Function内の処理にかかった時間を計測
    },
  );
// Deployed Function V2

import * as admin from 'firebase-admin';
import { onCall } from 'firebase-functions/v2/https';

if (admin.apps.length === 0) {
  admin.initializeApp();
}

const firestoreAdmin: admin.firestore.Firestore = admin.firestore();
const FUNCTION_REGION = 'asia-northeast1';

export const warmstarttestv2 = onCall(
  { region: FUNCTION_REGION, minInstances: 1 },  // 最小インスタンス数を設定
  async (): Promise<void> => {
    console.time(`timer.warmStartTest`);

    const collectionName = ${MY_COLLECTION_NAME};
    const documentId = ${MY_DOCUMENT_ID};

    for (let i = 0; i < 10; i++) {
      await firestoreAdmin
        .collection(collectionName)
        .doc(documentId)
        .get();
    }

    console.timeEnd(`timer.warmStartTest);  // Function内の処理にかかった時間を計測
  },
);
// HTTP trigger script

#!/bin/bash

### warmStartTest ###
res=$(curl \
-w ‘\ntime_total: %{time_total}\n’ \  // HTTPリクエスト自体にかかった時間を取得
-m 70 \
-X POST https://my-cloudfunctions.net/warmstarttestv1 \
-H “Content-Type: application/json” \
-d “{
\“data\“: {
}
}“)

echo “${res}”
// Command for 5 times request at the same time
$ time seq 1 5 | xargs -I ZZ -P 5 ./HTTP_trigger_script.sh

得られた結果は以下です。
(比較対象として、起動インスタンス数=0のデフォルト設定時の結果も記載しています。)

最小インスタンス設定なしの場合
最小インスタンス=1の場合

ここから得られる情報としては、

  • HTTPリクエスト全体の時間は最小インスタンスを設定することでV2が圧倒的に速くなる
  • Function内の処理も最小インスタンスを設定することでV2が高速化した
    • もしかしたらV2でFirestoreなど他サービスへの問い合わせに必要なSDKにも改善があるのかもしれない
  • インスタンスの起動にかかった時間だけで見てもV2に顕著な改善が見られた

って感じかと思います。
サンプル数が少ないものの最小インスタンスを設定したV2の全体速度が有意に上がっていることが確認出来たため、V2を使うべしという予想通りの結果になったと言えそうです👍

ホットスタンバイの継続時間は?

次に"常時ホットスタンバイ状態にする"について検証してみます。
こちらはインスタンスが起動してから何分後にインスタンスが消滅するのかを知ることが出来れば、消滅の直前に再度Functionを呼び出し続けることでインスタンスを存続させることが可能になり、コールドスタートを防止出来そうです。
こちらも"初回ロード時のコールドスタートを減らす"のコードとスクリプトを使用して、V1、V2それぞれで一定の時間間隔(5分、10分、15分、20分)をあけて再度リクエストした際にかかった時間を計測しました。

そして得られた結果は以下です。

コールドスタートに切り替わる時間は?

ここから得られる情報としては、

  • V1は5~10分経った後インスタンスが消滅する
  • V2は15~20分経った後インスタンスが消滅する

って感じかと思います。
V2ではだいたい15分間隔で定期実行してあげることで、インスタンスを常時立ち上げ続けることが可能であることが分かりました。
また、V1とV2でスタンバイの時間も明確に差が出たことは少し意外でした。
V2の方がインスタンスのライフサイクルが長いようなので、この観点でもV2を使わない手はないですね!

じゃあ、どうすれば良い?

V1 → V2へ移行 & 定期実行でホットスタンバイ状態にするが最適解!

検証では、Function内の処理速度やインスタンス毎のリクエスト処理数などパフォーマンスが格段に向上するV2を使用し、その上で最小インスタンスを設定するかコールドスタートを防止するために15分毎に定期実行しに行くのが良さそうだということが分かりました。

ただ、最小インスタンスの設定は1インスタンスにつき約6ドル/月以内の料金が加算されるため、複数Functionを持つサービスでこれを使おうとすると地味に痛い費用になります….
対して、定期実行の方は15分に一回リクエストを送れば良いので、4回/時間 * 24時間 * 30日 = 2880回が一つのFunctionに対する1ヶ月のリクエスト数となります。
これならFunctionの数に依らず十分Cloud Functionsの無料枠に収まりますし、万が一無料枠を超えていても$0.4/100万回の課金はそこまで痛手にはならないはずです。

定期実行でホットスタンバイを実現するには?

Functionの定期実行はScheduled Functionから該当のFunctionにHTTPリクエストを出すかたちで実現するのが良いかと思います。

// Scheduled Function

import * as functions from 'firebase-functions';

const FUNCTION_REGION = 'asia-northeast1';

export const scheduledDelete = functions
  .region(FUNCTION_REGION)
  .pubsub.schedule('*/15 * * * *')  // 15分置きに起動
  .timeZone('Etc/GMT')
  .onRun(async () => {
    console.log('Regularly running functions started!');

    // 以下、node-fetchなどでリクエストを作成しFunctionをコール
  });

なお、V2がまだGAして間もなく細かな部分でV1からの移植に手間がかかりそうだったため、実際にプロジェクトに適用した検証などはまだ出来ていません。
その辺りは、また別の機会にご紹介出来ればと思います!

まとめ

今回はCloud Functionsのパフォーマンス改善について色々と検証してみました。
Cloudサービスはコストをかければ簡単にある程度のパフォーマンス改善が見込める一方、低コストで高品質を実現するためには使う側でも一工夫が必要ですね。
今回の検証が大きな学びになりました。

最後に、Strapは誰でも簡単に情報や意思を共有することが出来るオンラインホワイトボードツールです。
今回の記事に使用した画像もほとんどStrap上で作成したものです!
もしこの記事でバックエンド開発、そしてStrapに興味を持ってくれた方がいれば幸いです😃