📊

Cloud Run Functions 第1世代と第2世代のコールドスタート速度を比較検証してみた

に公開

はじめに

こんにちは、クラウドエースの高牟礼です。
皆さんは Google Cloud でサーバーレス開発をする際、どのサービスを選んでいますか?コンテナなら Cloud Run 、 Web アプリなら App Engine 、イベント駆動なら Cloud Functions と使い分けている方も多いと思います。
今回は、 Google Cloud のサーバーレスサービスの中から、 Cloud Functions について触れていきます。

ご存じの方も多いと思いますが、 Cloud Functions は現在、「 Cloud Run Functions 」へとリブランディングされ、 Cloud Run と機能が統合されています。
これに伴い、新しく関数を作成する場合は、実質的に Cloud Functions 第2世代の実行環境( Cloud Run 上で動く関数)を利用することがスタンダードになっています。

しかし、ここでふと疑問が浮かびました。
「 Cloud Run Functions の第1世代と第2世代では、第2世代のほうが本当に優れているの?」

気になって調べてみたところ、公式ドキュメントには、以下のように記載されていました。

第 2 世代の実行環境は、一般に持続的な負荷の下では迅速に動作しますが、いくつかのサービスでは、第 1 世代よりもコールド スタート時間が長くなります。

どうやらコールドスタート(初期起動の遅延)については、第2世代のほうが遅い場合があるようです。ただ、実際どれくらい遅くなるのかはわかりません。
そこで今回は、 Cloud Run Functions の、第1世代と第2世代をそれぞれの最小構成でデプロイし、起動速度にどれだけの差が出るのか検証してみました。

検証環境

検証にあたり、第1世代と第2世代の最小構成で比較を行いました。

  • リージョン: asia-northeast1
  • ランタイム: Python 3.11
  • メモリ:
    • 第1世代: 128 MiB
    • 第2世代: 512 MiB
  • スケーリング設定:最小インスタンス数 0

メモリサイズが異なる点について、第1世代は128 MiB から利用できますが、第2世代は最低512 MiB 必要なためです。今回は、それぞれの世代を最も安価な最小構成で動かした場合の比較とするため、スペックは統一せずに各世代のミニマム設定を採用しました。
また、コールドスタート時間はコードの実行ではなく、その手前のコンテナ等の立ち上げ処理に依存するため、このスペック差が今回の検証結果に与える影響は軽微であると考えられます。

検証コードは、 リクエストの実行時間と内部実行時間を JSON で返すシンプルな計測用処理を行います。

検証方法

  1. 第1世代と第2世代の関数をデプロイする
  2. インスタンスの待機状態を解除するために15分待機
  3. リクエストを送信して、レスポンスが返ってくるまでのレイテンシを計測
  4. 5回繰り返して、平均値を算出する

待機時間を設けることで、リクエストに既存のインスタンスを使い回すことを回避しています。

リクエストの送信には以下のようなコードで行います。

import requests
import time
import json

GEN1_URL = "デプロイした第1世代のURL"
GEN2_URL = "デプロイした第2世代のURL"

def measure_cold_start(url, name, wait_time=900):
    
    print(f"\n--- {name} のコールドスタート検証 ---")
    print(f"次の計測のため、インスタンスが停止するまで {wait_time / 60:.0f}分 待機")

    time.sleep(wait_time) 

    # コールドスタートを狙った初回リクエストの計測
    print("初回リクエストを送信中...")
    start_time = time.time()
    try:

        response = requests.get(url, timeout=30) 
        end_time = time.time()
        
        total_latency = (end_time - start_time) * 1000
        
        response_json = response.json()
        
        print(f"成功: {name} の総レイテンシ: {total_latency:.2f} ms")
        print(f"   (関数内部の実行時間: {response_json.get('runtime_latency_ms'):.2f} ms)")
        return total_latency
        
    except requests.exceptions.Timeout:
        print(f"エラー: {name} のリクエストがタイムアウト")
        return None
    except Exception as e:
        print(f"エラー: {e}")
        return None


results_gen1 = []
results_gen2 = []
NUM_TRIALS = 5 
WAIT_TIME_SECONDS = 900 # 15分待機


for i in range(1, NUM_TRIALS + 1):
    print(f"\n試行 {i}/{NUM_TRIALS} ")
    
    # Gen1を計測
    latency_g1 = measure_cold_start(GEN1_URL, "Gen1", wait_time=WAIT_TIME_SECONDS)
    if latency_g1 is not None:
        results_gen1.append(latency_g1)

    # Gen2を計測
    latency_g2 = measure_cold_start(GEN2_URL, "Gen2", wait_time=WAIT_TIME_SECONDS)
    if latency_g2 is not None:
        results_gen2.append(latency_g2)


print("\n最終結果")
if results_gen1:
    avg_g1 = sum(results_gen1) / len(results_gen1)
    print(f"Gen1 ({len(results_gen1)}回): 平均 {avg_g1:.2f} ms")
else:
    print("Gen1: 計測データなし")

if results_gen2:
    avg_g2 = sum(results_gen2) / len(results_gen2)
    print(f"Gen2 ({len(results_gen2)}回): 平均 {avg_g2:.2f} ms")
else:
    print("Gen2: 計測データなし")

検証結果

結果は以下の通りになりました。

試行回数 第1世代( ms ) 第2世代( ms )
1回目 1058.59 3684.35
2回目 1008.87 3579.08
3回目 900.33 3099.77
4回目 940.28 3806.33
5回目 926.16 3118.99
平均値 966.85 3457.70

予想以上に明確な差が出ました。

  • 第1世代:約1秒弱で起動
  • 第2世代:約3.5秒かかって起動

第2世代は第1世代に比べて、約3.5倍遅いという結果でした。
関数内部の実行時間に差はなかったので、この差は純粋に立ち上がりの時間の差と言えます。

追加検証

第2世代は1つのインスタンスで複数のリクエストを同時処理できるのが特徴です。
単発だと遅いが、まとめてリクエストが来た時は効率よく捌けるのではないか?
という仮説のもと、第1世代、第2世代ともに同時に10件のリクエストを送信して検証しました。
レイテンシの平均時間と、最遅、最速時間を計測します。
リクエスト送信には以下のようなコードで行います。

import requests
import time
import concurrent.futures
import statistics

GEN1_URL = "デプロイした第1世代のURL"
GEN2_URL = "デプロイした第2世代のURL"

CONCURRENT_REQUESTS = 10

def send_request(url):
    start_time = time.time()
    try:
        response = requests.get(url, timeout=30)
        end_time = time.time()
        latency = (end_time - start_time) * 1000
        return {
            "status": response.status_code,
            "latency": latency,
            "error": None
        }
    except Exception as e:
        return {
            "status": None,
            "latency": None,
            "error": str(e)
        }

def measure_batch(url, name, wait_time=0):
    
    if wait_time > 0:
        print(f"\n {name} のコールドスタート準備: インスタンス停止まで {wait_time/60:.0f}分 待機")
        time.sleep(wait_time)
        print(f"{name} に対して {CONCURRENT_REQUESTS} 件の同時リクエストを送信開始")
    else:
        print(f"\n{name} に対して {CONCURRENT_REQUESTS} 件の同時リクエストを送信開始(待機なし)")

    batch_start_time = time.time()
    
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENT_REQUESTS) as executor:
        futures = [executor.submit(send_request, url) for _ in range(CONCURRENT_REQUESTS)]
        
        for future in concurrent.futures.as_completed(futures):
            results.append(future.result())
            
    batch_end_time = time.time()
    total_batch_duration = (batch_end_time - batch_start_time) * 1000

    latencies = [r['latency'] for r in results if r['latency'] is not None]
    success_count = len(latencies)
    
    print(f"\n{name} の検証結果")
    print(f"  成功リクエスト数: {success_count}/{CONCURRENT_REQUESTS}")
    
    if success_count > 0:
        avg_latency = statistics.mean(latencies)
        max_latency = max(latencies)
        min_latency = min(latencies)
        
        print(f"  平均レイテンシ: {avg_latency:.2f} ms")
        print(f"  最遅 :    {max_latency:.2f} ms")
        print(f"  最速 :    {min_latency:.2f} ms")
        print(f"  全体の処理時間: {total_batch_duration:.2f} ms")
        
        # 個別の結果を表示
        print(" 個別レイテンシ一覧 (ms):")
        print("  " + ", ".join([f"{l:.0f}" for l in sorted(latencies, reverse=True)]))
    else:
        print("  すべて失敗")

WAIT_TIME_SECONDS = 900 

print(f" 同時接続数検証を開始(同時リクエスト数: {CONCURRENT_REQUESTS})")

measure_batch(GEN1_URL, "Gen1", wait_time=WAIT_TIME_SECONDS)

measure_batch(GEN2_URL, "Gen2", wait_time=WAIT_TIME_SECONDS)

追加検証の結果

指標 第1世代 (Gen1) 第2世代 (Gen2)
単発時の平均 (参考) 966.85 ms 3457.70 ms
同時実行時の平均 1050.71 ms 1269.02 ms
最遅 1075.01 ms 1475.12 ms
最速 1044.20 ms 1145.83 ms

第1世代は、単発時と同じく約1秒で起動。
第2世代は、単発では約3.5秒かかっていたレイテンシが約1.2秒まで短縮されました。
依然として、第2世代の方が遅い結果ですが、
第1世代との差は単発時の3.5倍から1.2倍程度まで肉薄しています。

考察

2つの検証結果について考察します。

単発実行時は、結果からも分かる通り、純粋なコンテナの立ち上がり速度は第1世代に分があるようです。
なぜこれほどの差が出たのか考えてみたのですが、公式ドキュメントにも記載があるように、アーキテクチャの違いが影響しているのかなと考えられます。
第2世代の実行環境では、 Linux と完全な互換性を持つコンテナ環境を立ち上げ、高機能な環境をセットアップする分、初期起動(コールドスタート)にオーバーヘッドが発生してしまっているのかなと思います。
第2世代は持続的な負荷処理が得意ですが、今回のような単発リクエストの場合、性能を持て余して、逆に起動コストの高さだけが目立つ形になったと言えます。

同時にリクエストを送信した追加検証において、第2世代の平均レイテンシが改善した理由は、1インスタンスで複数のリクエストを処理できるためだと考えられます。
今回の検証では、最初の1リクエスト分の起動コスト(約1.5秒〜)は発生しましたが、同時に到達した他のリクエストは、立ち上がったその1つのインスタンスに相乗りして処理されたため、待ち時間が短縮されたのかなと思います。
つまり、第2世代は後続や同時のリクエストは高速に捌けるという特性を持っていると言えます。

まとめ

新しいから第2世代一択と考えがちですが、今回の検証で、第1世代にも強みがあることがわかりました。下記にどちらの世代を選ぶべきかケースごとに挙げてみました。

  • 第1世代を選ぶべきケース

    • トラフィックが散発的で、コールドスタートが頻繁に発生する
    • ユーザへのレスポンス速度を重視している
    • 処理がシンプルで、長時間実行や大量の並列処理を必要としない
    • 512 MiB 未満のメモリを使用する。(第2世代は 512MiB 以上のメモリが必要)
  • 第2世代を選ぶべきケース

    • トラフィックが多く、インスタンスが暖機された状態が続く
    • 少ないインスタンスで大量のリクエストを捌きたい
    • 迅速なネットワーク機能が必要
    • サービスで Linux cgroup 機能が必要

さいごに

「とりあえず第2世代」にする前に、その関数はどのくらいの頻度で呼ばれるのか、コールドスタートの時間は許容できるかといった観点で一度検討してみると、より良い Cloud Functions 使いになれるかもしれません。
本記事が、皆さんのアーキテクチャの選定の一助になれば幸いです。

Discussion