Cloud Runの最小インスタンス数をスケジュールベースで自動スケーリングする

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

G-gen の佐々木です。当記事では、Cloud Run の最小インスタンス数を、特定の時間帯で自動的にスケーリングさせる処理を実装していきます。

前提知識

Cloud Run について

Cloud Run には Cloud Run services と Cloud Run jobs の2種類がありますが、当記事における Cloud Run は Cloud Run services を指すものとします。

当記事は Cloud Run の応用的な使い方を解説するため、Cloud Run そのものに関する解説はしません。
Cloud Run の解説については以下の記事をご一読ください。

blog.g-gen.co.jp

Cloud Run のコールドスタート

最小インスタンス数を0に設定した Cloud Run では、リクエストを受信するとコンテナインスタンスが起動し、処理を行います。
このように最小インスタンス数が0の場合、リクエストがない間は CPU、メモリなどのコンピュートリソースを使用しないため、リソース使用量に応じた料金が発生せず、コストを非常に安く抑えることができます。

その反面、常にコンピュートリソースを使用する場合と比べて、インスタンスを起動する時間だけレイテンシが発生します。これをコールドスタートといいます。

Cloud Run におけるコールドスタート

Cloud Run で実行しているアプリケーションでコールドスタートによるレイテンシが許容できない場合、最小インスタンス数を1以上に設定し、リクエストを処理できるコンピュートリソースを常に確保しておきます。
この場合、当然ながらコンピュートリソースの料金は、最小インスタンスの数だけ常に発生してしまいます。

最小インスタンス数が0の場合と1以上の場合のコンピュートリソース使用料

また、コールドスタートを回避できるのは、最小インスタンス数として設定した数のコンテナインスタンス(常時起動されているインスタンス)だけです。
リクエストの増加によりコンテナインスタンスのスケールアウトが起こると、新たに起動したコンテナインスタンスに送信されたリクエストはコールドスタートの影響を受けます。

したがって、多くのリクエストを処理しなければならない、かつコールドスタートが許容できないアプリケーションでは、予測されるリクエストの規模に応じて最小インスタンス数を調整する(もしくは Cloud Run を使用しない)ことになります。

このように、Cloud Run におけるコストの削減とコールドスタートの回避はトレードオフの関係にあり、アプリケーション実行基盤として Cloud Run を選択する際のノックアウトファクターとなり得ます。

サービスレベルの最小インスタンス数の設定

Cloud Run では、最小インスタンス数はリビジョンレベルで設定を行い、最小インスタンスを変更するたびに新たなリビジョンをデプロイする必要がありました。

リビジョンレベルで最小インスタンス数を更新する

2024年3月のアップデートにて、サービスレベルの最小インスタンス数の設定として、新たなリビジョンをデプロイすることなく最小インスタンス数の設定を変更できるようになりました(2024年4月時点でプレビュー機能)。

サービスレベルで最小インスタンス数を更新する

構成

当記事では、Cloud Run の最小インスタンス数に対して、スケジュールベースの自動スケーリングを行う処理を実装していきます。

ユースケースとして、特定の時間帯にリクエストが増加することがわかっているサービスを想定します。
リクエストが増加する時間帯は Cloud Run の最小インスタンス数を増加させることでコールドスタートの影響を無くし、リクエストが少ない時間帯は最小インスタンス数を0にしてコストを節約します。

当記事では、以下のように時間帯によって最小インスタンス数を変更するようにします。
この場合、スケールアウトは 12:00、スケールインは 19:00 に行います。

時間帯 最小インスタンス数
0:00~12:00 0
12:00~19:00 3
19:00~0:00 0

実装には、以下のサービスを使用していきます。

使用するサービスと構成図

スケーリングの対象となる Cloud Run サービスのデプロイ

まず、スケジュールベースのスケーリングを設定する対象となる Cloud Run サービスを作成します。
当記事では、このサービス自体の処理内容は重要ではないので、Google Cloud が提供するサンプルのコンテナイメージを使用してデプロイします。

# スケーリングの対象となる Cloud Run サービスを作成
$ gcloud run deploy hello \
    --image us-docker.pkg.dev/cloudrun/container/hello \
    --region=${LOCATION} \
    --allow-unauthenticated
  

このコマンドでサービスをデプロイした場合、サービス名は hello となります。
最小インスタンス数は、デフォルトで0に設定されています。

各種ファイルの準備

作成するファイルについて

当記事では、ワーキングディレクトリに以下のファイルを作成していきます。

.
├── scalein.json
├── scaleout.json
└── src
    ├── main.py
    └── requirements.txt
  

Cloud Functions にデプロイするファイル

ディレクトリの作成

Cloud Functions にデプロイする各種ファイルを配置するディレクトリを作成します。
ディレクトリ名はなんでもよいですが、当記事では src とします。

# src ディレクトリを作成
$ mkdir src
  

main.py

当記事では Python 3.12 を使用していきます。

# Python のバージョン
$ python -V
Python 3.12.0
  

src ディレクトリ内に main.py ファイルを作成します。
ファイルの中身は以下のようにします。

# src/main.py
from google.cloud.run_v2.services.services import ServicesClient
import functions_framework
import base64, json
  
  
client = ServicesClient()
  
  
@functions_framework.cloud_event
def update_service(cloud_event):
  
    # Cloud Scheduler から送信されてきたメッセージを処理
    data = json.loads(base64.b64decode(cloud_event.data["message"]["data"]).decode())
    project_id = data["project_id"]
    location = data["location"]
    service_name = data["service_name"]
    min_instance_count = int(data["min_instance_count"])
  
    # スケーリング対象の Cloud Run サービスの情報を取得
    service = client.get_service(name=f"projects/{project_id}/locations/{location}/services/{service_name}")
  
    # Cloud Run サービスの最小インスタンス数を更新
    service.launch_stage = "BETA"  # プレビュー機能のため指定
    service.scaling.min_instance_count = min_instance_count  # リビジョンを更新せず、サービスに対して最小インスタンス数を設定
  
    # Cloud Run サービスの更新
    client.update_service(service=service)
  

main.py には、Cloud Scheduler から(Pub/Sub を介して)スケーリング対象の Cloud Run サービスの名前や、スケーリング後の最小インスタンス数などの情報を受け取り、それを元に最小インスタンス数を変更する処理を実装します。

requirements.txt

src ディレクトリ内に requirements.txt を作成し、使用する外部ライブラリを記載します。
当記事では以下のライブラリを使用していきます。

# src/requirements.txt
functions-framework==3.5.0
google-cloud-run==0.10.5
  

Cloud Scheduler で使用するファイル

Cloud Scheduler のジョブを作成する際に使用する、Cloud Functions に送信するメッセージを記述したファイルを作成します。

最小インスタンス数のスケールアウトとスケールインのそれぞれに対応したファイルを作成していきます。

scaleout.json

ワーキングディレクトリ内に scaleout.json を作成し、以下のように記述します。

{
    "project_id":"{Cloud Runが存在するプロジェクトID}",
    "location":"asia-northeast1",
    "service_name":"hello",
    "min_instance_count":"3"
}
  

このメッセージを受信した Cloud Functions は、対象となる Cloud Run サービスの最小インスタンス数を 3 に変更します。

scalein.json

ワーキングディレクトリ内に scalein.json を作成し、以下のように記述します。

{
    "project_id":"{Cloud Runが存在するプロジェクトID}",
    "location":"asia-northeast1",
    "service_name":"hello",
    "min_instance_count":"0"
}
  

このメッセージを受信した Cloud Functions は、対象となる Cloud Run サービスの最小インスタンス数を 0 に変更します。

各種サービスの作成

シェル変数の設定

シェル変数として、PROJECT 変数にサービスを作成するプロジェクト、 LOCATION 変数にサービスを作成するリージョンを設定しておきます。
当記事では asia-northeast1 リージョンを使用していきます。

PROJECT=myproject
LOCATION=asia-northeast1
  

Pub/Sub トピック

Cloud Scheduler からのメッセージを中継する Pub/Sub のトピックを作成します。
当記事では topic-run-scaler という名前で作成します。

# Pub/Sub トピックの作成
$ gcloud pubsub topics create topic-run-scaler
  

サービスアカウント

Cloud Functions 用サービスアカウント

Cloud Functions に紐付けるサービスアカウントを作成します。
当記事では sa-run-scaler という名前で作成します。

# Cloud Functions 用サービスアカウントの作成
$ gcloud iam service-accounts create sa-run-scaler
  

サービスアカウントに対して、Cloud Run の設定を変更するための roles/run.developer ロールを設定します。

# サービスアカウントに Cloud Run の編集権限を付与
$ gcloud run services add-iam-policy-binding hello \
    --region=${LOCATION} \
    --member="serviceAccount:sa-run-scaler@${PROJECT}.iam.gserviceaccount.com" \
    --role="roles/run.developer"
  

また、Cloud Functions のコードからサービスアカウントの認証情報を取得できるように、roles/iam.serviceAccountUser ロールも設定します。

# サービスアカウントにサービスアカウントユーザー権限を付与
$ gcloud projects add-iam-policy-binding ${PROJECT} \
    --member="serviceAccount:sa-run-scaler@${PROJECT}.iam.gserviceaccount.com" \
    --role="roles/iam.serviceAccountUser"
  

Pub/Sub 用サービスアカウント

Cloud Scheduler と Cloud Functions を中継する Pub/Sub 用のサービスアカウントを作成します。
このサービスアカウントには Cloud Run を呼び出すための権限を付与しますが、対象の Cloud Run がまだ作成されていないので、一旦は作成だけしておきます。

当記事では sa-run-scaler-trigger という名前で作成します。

# Pub/Sub 用 サービスアカウントの作成
$ gcloud iam service-accounts create sa-run-scaler-trigger
  

Cloud Functions

src ディレクトリ内のファイルを使用して Cloud Functions の関数をデプロイします。関数の名前は run-scacler とします。

関数のトリガーとして先ほど作成した Pub/Sub トピックを設定し、Pub/Sub 用に作成したサービスアカウントも --trigger-service-account として指定します。

# Cloud Functions のデプロイ
$ gcloud functions deploy run-scaler \
    --gen2 \
    --runtime=python312 \
    --entry-point="update_service" \
    --region=${LOCATION} \
    --source=./src \
    --run-service-account="sa-run-scaler@${PROJECT}.iam.gserviceaccount.com" \
    --trigger-topic=topic-run-scaler \
    --trigger-service-account="sa-run-scaler-trigger@${PROJECT}.iam.gserviceaccount.com"
  

Cloud Functions のデプロイが完了したら、Pub/Sub 用のサービスアカウントに Cloud Functions を呼び出す権限を付与します。
gcloud functions add-invoker-policy-binding コマンドを使用することで、必要な権限を付与することができます。

# サービスアカウントに Cloud Functions の起動元権限を付与
$ gcloud functions add-invoker-policy-binding run-scaler \
    --region=${LOCATION} \
    --member="serviceAccount:sa-run-scaler-trigger@${PROJECT}.iam.gserviceaccount.com"
  

Cloud Scheduler ジョブ

作成するジョブについて

Cloud Functions のトリガーとなる Cloud Scheduler ジョブを作成していきます。

スケールアウトとスケールインで異なる設定値が必要になるため、ジョブは2つ作成します。
Cloud Functions は Cloud Scheduler から送信されたメッセージの内容に応じた処理を行うため、各ジョブは同一の Pub/Sub を経由して、同一の Cloud Functions 関数にメッセージを送ります。

スケールアウト用ジョブ

スケールアウト用のジョブは scaleout.json ファイルに記述したメッセージを送信するように設定します。
このメッセージを受信した Cloud Functions は、対象となる Cloud Run サービスの最小インスタンス数を 3 に変更します。

ジョブの実行タイミングは --scheduleunix-cron 文字列形式で設定します。
当記事では、毎日12時(0 12 * * *)のタイミングでスケールアウトを実行するようにします。

# Cloud Run のスケールアウト用のジョブをトリガーするスケジュール
$ gcloud scheduler jobs create pubsub schedule-run-scaler-scaleout \
    --location=${LOCATION} \
    --schedule="0 12 * * *" \
    --topic=topic-run-scaler \
    --message-body-from-file=./scaleout.json \
    --time-zone="Asia/Tokyo"
  

スケールイン用ジョブ

スケールイン用のジョブは scalein.json を使用して作成します。
このファイルに記述したメッセージを受信した Cloud Functions は、対象となる Cloud Run サービスの最小インスタンス数を 0 に変更します。

ジョブの実行タイミングは、毎日19時(0 19 * * *)に設定します。

# Cloud Run のスケールイン用のジョブをトリガーするスケジュール
$ gcloud scheduler jobs create pubsub schedule-run-scaler-scalein \
    --location=${LOCATION} \
    --schedule="0 19 * * *" \
    --topic=topic-run-scaler \
    --message-body-from-file=./scalein.json \
    --time-zone="Asia/Tokyo"
  

動作確認

Cloud Run のコンソールから「コンテナ インスタンス数」の指標を確認すると、設定した時間にジョブが実行され、Cloud Run サービスの最小インスタンス数の変更が行われていることがわかります。

コンテナインスタンス数の推移

12:00 にスケールアウトのジョブが実行され、最小インスタンス数が3に設定されています。
3つのコンテナインスタンスが常に idle もしくは active になっており、この3つで処理できるリクエスト量であれば、コールドスタートの影響を受けることはありません。

19:00 にスケールインのジョブが実行され、翌日の 12:00 までは最小インスタンス数が 0 になります。
idle もしくは active 状態のインスタンスがないときにリクエストがあると、そのリクエストに対する処理はコールドスタートの影響を受けますが、コンピュートリソースの使用コストを最小限に抑えることができます。

「課金対象のコンテナ インスタンス時間」の指標を確認すると、最小インスタンス数が3(1以上)になっている時間帯のみ、インスタンスのリソース使用料が発生し続けていることがわかります。

最小インスタンス数が1以上の時間帯のみインスタンスが課金対象となっている

未解決の問題

当記事では、Cloud Run のサービスレベルの最小インスタンス数の設定を活用することで、新たなリビジョンをデプロイすることなく、特定の時間帯で最小インスタンス数を変更する処理を実装してきました。

しかし、最小インスタンス数の最初の変更時のみ、新しいリビジョンがデプロイされてしまいました。

最初の1回だけ新しいリビジョンがデプロイされてしまった

これは推測になるのですが、今回使用したのはプレビュー機能のため、初回変更時はプレビュー機能が使用できるリビジョンに更新する必要があるのかもしれません。

サービスレベルの最小インスタンス数の設定を使用するためには、Cloud Run がプレビュー機能を使用できるように Launch Stage を設定する必要がありました。
以下は Cloud Functions のコードの抜粋です。

# Cloud Run サービスの最小インスタンス数を更新
service.launch_stage = "BETA"  # プレビュー機能のため指定
service.scaling.min_instance_count = min_instance_count  # リビジョンを更新せず、サービスに対して最小インスタンス数を設定
  

この事象については、機能の GA を待ってから改めて確認してみたいと思います。

佐々木 駿太 (記事一覧)

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

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

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