Cloud RunやCloud Run functionsでグローバル変数を活用してパフォーマンスを向上する

記事タイトルとURLをコピーする

G-gen の佐々木です。当記事では、Cloud Run や Cloud Run functions(旧称:Cloud Functions)のパフォーマンス向上のコツとして、グローバル変数の活用方法を紹介します。

サーバーレスにおけるコールドスタート

Cloud Run、Cloud Run functions といったサーバーレス コンピューティングサービスは、負荷に応じてインスタンスが自動的にスケーリングされます。リクエストがないときはインスタンス数をゼロまでスケールインすることで、リソース利用料を節約することができます。

この動的スケーリングの特徴はサーバーレスの強みであると同時に、コールドスタートという特有の問題も引き起こします。インスタンスが起動するたびにリソースの確保とアプリケーションの初期化処理が行われるため、同じインスタンスを常時起動している場合よりも、レスポンスが遅延する場合があります。

Cloud Run におけるコールドスタートの詳細については以下の記事をご一読ください。

blog.g-gen.co.jp

また、サーバーレスの特徴については、以下の記事でも解説しています。

blog.g-gen.co.jp

グローバル変数によるリクエスト間のオブジェクト再利用

Cloud Run や Cloud Run functions では、リクエストによりインスタンスが起動すると、スケーリングが起こるまでは同じインスタンスを再利用して後続リクエストの処理を行います。

このとき、ソースコード上のグローバルスコープに記述された処理は、アプリケーションの初期化、つまりコールドスタート発生時のみ評価されるという特徴があります。

したがって、負荷の高い処理をグローバルスコープに記述すると、コールドスタート時のみ処理が行われ、別々のリクエスト間で処理結果を再利用することができます。

これにより、コールドスタート時のパフォーマンスは変わりませんが、それ以降のリクエストに対する処理時間を短縮することができます。

グローバル変数のユースケース

前述の通り、グローバル変数は別々のリクエスト間でも値が共有されます。そのため、すべてのリクエストで同じ値となっても問題がないような値を格納すべきです。以下は、その例です。

  • 環境変数の値
  • 初期化したクライアントオブジェクト
  • 初期化した機械学習モデル定義

例えば、以下のような機械学習モデルの初期化は負荷のかかる処理であり、かつ結果をリクエスト間で使いまわすことができるため、グローバルスコープに記述することでパフォーマンス向上に繋がります。

# Gemini モデルの初期化
model = GenerativeModel(
    model_name="gemini-1.5-pro",
)

逆に、以下のようなものに対してはグローバル変数を使用しないようにしましょう。

  • リクエストごとに異なることが期待される値(ユーザー情報、リクエスト受信時刻など)
  • 頻繁に変更される値(アクセストークン、データベースのクエリ結果など)

検証

サンプルコード(Python)

このコードでは負荷の高い処理の例として、10秒待機してから現在時刻を返す heavy_computation 関数が定義されており、グローバル変数である heavy_result に結果を格納しています。この処理はグローバルスコープに記述されているため、インスタンスの起動時のみ実行されます。

したがって、heavy_computation 関数による 10秒のウェイト処理は、インスタンスが処理する最初のリクエストに対してのみ発生することになります。また、変数 heavy_result に格納される時刻は、このインスタンスが処理するどのリクエストでも同じ値となります。

また、ここでは比較用に light_computation 関数を用意しています。この関数は実行された時刻を返すだけの処理であり、リクエストが来たときに実行される main 関数内から呼び出されます。したがって、結果を格納する light_result の値は、リクエストのたびに異なるものになります。

# main.py
import os, time
from datetime import datetime
from flask import Flask, jsonify
  
  
app = Flask(__name__)
  
  
# 軽めの処理
def light_computation():
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")  # 関数が完了した時刻を返す
  
  
# 時間のかかる処理
def heavy_computation():
    time.sleep(10)  # 10秒間の待機
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")  # 関数が完了した時刻を返す
  
  
# 時間のかかる処理の結果をグローバル変数に格納
# コンテナインスタンス起動時に一度だけ実行され、以降のリクエストでは値が使い回される
heavy_result = heavy_computation()
  
  
@app.route("/")
def main():
    # リクエストのたびに実行される
    light_result = light_computation()
  
    return jsonify({"light": light_result, "heavy": heavy_result})  # 2つの関数が完了した時刻をそれぞれ返す
  
  
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
  

上記のコードを Cloud Run サービスとしてデプロイします。このサービスにリクエストを送ると、light_resultheavy_result の値が返ってきます。

動作検証

デプロイ直後はインスタンスが起動している状態のため、一度インスタンス数がゼロになってからリクエストを送信します。

# Cloud Run にリクエストを送信する
$ curl ${Cloud RunのURL}
  
----- 出力 ----- 
# コールドスタートにより、10秒のウェイト処理のあと処理の結果が返ってくる
light: 2024-08-10 07:22:38
heavy: 2024-08-10 07:22:38

コールドスタートによりグローバルスコープに記述した処理が実行され、heavy_computation 関数の10秒のウェイト処理ののち、レスポンスが返ってきます。

初回呼び出し(コールドスタート)の処理内容

次に、5秒おきにリクエストを送信してみます。

# 5秒おきに Cloud Run にリクエストを送信する
$ watch -n 5 -d -t curl ${Cloud RunのURL}
  
------ 出力(1回目) -----
light: 2024-08-10 07:26:05
heavy: 2024-08-10 07:22:38
  
------ 出力(2回目) -----
light: 2024-08-10 07:26:10
heavy: 2024-08-10 07:22:38
  
------ 出力(3回目) -----
light: 2024-08-10 07:26:15
heavy: 2024-08-10 07:22:38
  
------ 出力(4回目) -----
light: 2024-08-10 07:26:20
heavy: 2024-08-10 07:22:38
  
------ 出力(5回目) -----
light: 2024-08-10 07:26:25
heavy: 2024-08-10 07:22:38
  

今回はコールドスタートが発生していないため heavy_computation 関数は実行されません。そのため10秒のウェイトがなく、レスポンスがすぐに返ってきます。

2回目以降の呼び出し(コールドスタートなし)の処理内容

また、グローバル変数である heavy_result の値は初回リクエスト時から維持され、どのリクエストでも一定になっています。それに対して、リクエストのたびに変更される light_result の値は5秒刻みになっていることがわかります。

佐々木 駿太 (記事一覧)

G-gen最北端、北海道在住のクラウドソリューション部エンジニア

2022年6月にG-genにジョイン。Google Cloud Partner Top Engineer 2024に選出。好きなGoogle CloudプロダクトはCloud Run。

趣味はコーヒー、小説(SF、ミステリ)、カラオケなど。