「ローカルでは 600ms で返ってくるのに、Cloud Run にデプロイした瞬間に最初のリクエストだけ 6 秒固まる」— サーバレスで Gemini API を運用しはじめた個人開発者の方なら、一度は遭遇したことがあるのではないでしょうか。私自身、検索結果を要約するだけのシンプルな API を Cloud Run に載せたとき、本番デプロイの直後に Twitter で「使ってみたら全然動かない、固まった」という指摘を受けて青ざめた経験があります。
公式ドキュメントには「min-instances を 1 にすれば解決する」と書かれていますが、実際にはそれだけでは月 $40〜80 程度の固定費が常時発生し、しかも個人開発の小規模アプリでは過剰な投資になりがちです。ここではCloud Run・AWS Lambda・Cloudflare Workers の 3 つのサーバレス環境で、コストを最小限に保ちながらコールドスタックを 1 秒以下に短縮するための具体的な実装パターンを、実測値とコード付きで共有します。
コールドスタートの正体 — どこで時間が溶けているのかを特定する
最適化の前に、まず時間がどこで溶けているのかを切り分ける点が肝心です。Gemini API のサーバレス実行で観測されるコールドスタート 6 秒は、実は次の 4 つの段階に分解できます。
- コンテナ起動 (1.5〜3.0 秒): Cloud Run / Lambda がコンテナイメージを取得して起動するまでの時間
- ランタイム初期化 (0.3〜1.2 秒): Python・Node.js のインタプリタ起動と依存パッケージのロード
- SDK 初期化 (0.2〜0.8 秒):
genai.Client()のインスタンス化と内部 HTTP クライアントのセットアップ - 最初の API 通信 (0.5〜1.5 秒): TLS ハンドシェイク・認証トークン取得・モデルとの接続確立
それぞれの段階に異なる最適化手法が必要で、闇雲に min-instances を上げてもコンテナ起動時間しか短縮できません。まずはどこで時間が溶けているのかを計測することが出発点になります。私は以下のような最小限のタイムスタンプ計測を本番に仕込んで、コールドスタート時の内訳を取っています。
# main.py — Cloud Run / Lambda 共通の冷却計測パターン
import time
import json
import os
# モジュールロード時刻(コンテナ起動完了直後のタイムスタンプ)
T_MODULE_LOAD = time.perf_counter()
# SDK 初期化はあえて関数内に置く(ホットパス削減の検証用)
_client = None
def get_client():
"""SDK クライアントを遅延初期化する。初回のみコストを払う"""
global _client
if _client is None:
from google import genai
_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
return _client
def handler(request):
t_start = time.perf_counter()
client = get_client()
t_client_ready = time.perf_counter()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=request.json.get("prompt", "Hello"),
)
t_end = time.perf_counter()
# 内訳ログ — Cloud Logging / CloudWatch で集計する
return {
"result": response.text,
"timing": {
"since_module_load": t_start - T_MODULE_LOAD,
"client_init": t_client_ready - t_start,
"api_call": t_end - t_client_ready,
"total": t_end - T_MODULE_LOAD,
},
}このコードを 100 リクエスト程度実行して、since_module_load が大きい場合はコンテナ起動が、client_init が大きい場合は SDK 初期化が、api_call が大きい場合は通信レイヤーがボトルネックです。私の手元の Cloud Run(リージョン: asia-northeast1、メモリ: 512MB、CPU: 1)では、1 回目のリクエストで since_module_load: 2.8s, client_init: 0.42s, api_call: 1.1s という内訳が観測されました。コンテナ起動が支配的で、それだけでは説明がつかない遅さは SDK 初期化の中で発生していました。
パターン1: Cloud Run の min-instances と CPU Always Allocated の使い分け
Cloud Run でコールドスタートを抑える最初の選択肢は --min-instances です。ただし、これを 1 にするだけだと「常時 1 インスタンスは起きているが、リクエストが集中すると新しいインスタンスがコールドスタートする」状態になります。さらに、デフォルトの「CPU is only allocated during request processing」モードでは、リクエストの合間に CPU が割り当てられないため、SDK のコネクションプールがリセットされてしまいます。
私の実運用での結論は次のとおりです。
- アクセスが時間帯依存(営業時間中のみ)の社内ツール:
--min-instances=0でコールドスタートを許容しつつ、SDK 初期化の高速化に注力する - 常時アクセスがある個人 SaaS(DAU 100 程度):
--min-instances=1 --cpu-always-allocatedでコールドスタックを完全に排除する - B2B SLA を提示するアプリ:
--min-instances=2 --cpu-always-allocated以上で冗長性を確保する
特に重要なのは --cpu-always-allocated フラグです。これを付けないと、min-instances=1 でもアイドル中に SDK 内部の HTTP コネクションプールが切断され、次のリクエストで TLS ハンドシェイクからやり直す羽目になります。
# 個人 SaaS 向けの推奨デプロイコマンド
gcloud run deploy gemini-api-service \
--image=gcr.io/PROJECT_ID/gemini-api:latest \
--region=asia-northeast1 \
--memory=512Mi \
--cpu=1 \
--min-instances=1 \
--cpu-always-allocated \
--max-instances=10 \
--concurrency=80 \
--execution-environment=gen2 \
--timeout=60s \
--set-env-vars="GEMINI_API_KEY=YOUR_GEMINI_API_KEY"--execution-environment=gen2 は第2世代ランタイムを使うフラグで、ネットワーク I/O が高速化されます。Gemini API のように外部通信が支配的なワークロードでは、第1世代と比べて TTFB が 100〜200ms 短縮できる印象があります。
「min-instances=1 のコストが気になる」という個人開発者の方には、CPU=1 / メモリ=512Mi 構成での実測コストを共有しておきます。CPU Always Allocated を有効にした場合、東京リージョンで 1 インスタンス常時稼働は月およそ $32〜38 です。ピーク時のスケールアウトは別途課金されますが、個人開発で API トークンコスト(gemini-2.5-flash で月 $5〜20 程度)を払えるレベルのアプリなら、ここに $35 を上乗せする価値はあります。
パターン2: AWS Lambda の SnapStart と Provisioned Concurrency
AWS Lambda の場合、コールドスタート対策には 3 つの選択肢があります。優先度順に書くと次のとおりです。
- SnapStart: Java と Python・.NET でサポート(2026 年 4 月時点)。初期化済みの実行環境スナップショットを保存しておき、コールドスタートを 70〜90% 短縮できます。追加料金なしで利用できる強力な機能
- Provisioned Concurrency: 指定数の実行環境を常に温めておく。月数千円から十数万円のコスト
- Lambda SnapStart + Init フェーズ最適化: SnapStart の前に SDK 初期化を済ませることで、スナップショット復元後の追加初期化を最小化
Python ランタイムでの SnapStart は init フェーズで実行された処理がスナップショットに保存されるため、グローバルスコープでクライアントを初期化しておくのが効果的です。
# lambda_function.py — SnapStart 最適化版
import os
from google import genai
# init フェーズで実行 → スナップショットに含まれる
# SnapStart 復元後はこの初期化が不要になる
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
def lambda_handler(event, context):
body = event.get("body", "{}")
import json
prompt = json.loads(body).get("prompt", "Hello")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"text": response.text}),
}ここで重要な落とし穴があります。SnapStart はスナップショット時点の状態を凍結するため、API キーや IAM 認証トークンといった有効期限のあるシークレットをグローバルに保持していると、復元後に期限切れエラーになるケースがあります。google-genai SDK は内部で OAuth 風の処理を持たないため Gemini API キー方式なら問題ありませんが、Vertex AI 経由(ADC 認証)の場合は復元後に認証情報を再取得する必要があります。
# Vertex AI 経由の SnapStart 対応パターン
from aws_lambda_powertools.utilities.lambda_lifecycle_hooks import register_runtime_hooks
_client = None
def init_after_restore():
"""スナップショット復元後に実行されるフック"""
global _client
from google import genai
# ADC 認証情報を再取得
_client = genai.Client(vertexai=True, project="PROJECT_ID", location="us-central1")
# Runtime API でフックを登録
register_runtime_hooks(after_restore=init_after_restore)SnapStart が使えない Node.js ランタイムの場合は、Provisioned Concurrency が現実的な選択肢になります。ただし、最低 1 同時実行で月 $13〜20 程度かかるため、「コストを抑えるためにサーバレスにした」目的とのトレードオフを冷静に判断する必要があります。
パターン3: Cloudflare Workers のグローバルスコープと初期化戦略
Cloudflare Workers は他のサーバレス環境と性質が大きく異なります。コンテナベースではなく V8 Isolate ベースの軽量ランタイムなので、コールドスタートそのものが 5〜15ms と桁違いに速いのが特徴です。むしろ Workers でコールドスタックを意識する必要があるのは、グローバルスコープで重い処理をしてしまった場合です。
// workers/index.ts — Cloudflare Workers 最適化パターン
import { GoogleGenerativeAI } from "@google/generative-ai";
// グローバルスコープでの初期化は避ける(Isolate 単位で初期化される)
// その代わり、リクエストごとに軽量に初期化する
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// SDK の Isolate 内インスタンスは関数スコープで OK
const genAI = new GoogleGenerativeAI(env.GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const { prompt } = await request.json<{ prompt: string }>();
const result = await model.generateContent(prompt);
return new Response(result.response.text(), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
},
};
interface Env {
GEMINI_API_KEY: string;
}Workers で気をつけるべきは「グローバルスコープに何を置いて何を置かないか」です。私の運用経験では次の使い分けが安定します。
- グローバルに置いてよいもの: 静的な設定値・コンパイル済みの正規表現・小さな辞書データ
- グローバルに置いてはいけないもの: SDK インスタンス(環境変数アクセスが Workers では
env引数経由のため)・大きな JSON データ・リクエスト固有の状態
なお、Workers にも Smart Placement という機能があり、Gemini API へのレイテンシが最も低いリージョンに自動配置されます。wrangler.toml に placement = { mode = "smart" } を追加するだけで、欧州・北米のユーザーに対して 100〜300ms の TTFB 短縮が観測できる場合があります。
SDK 初期化のホットパス削減 — どの環境でも効く共通テクニック
ここまで環境別の対策を見てきましたが、SDK 初期化を遅延化するテクニックはどの環境でも効く共通の最適化です。具体的には、モジュールトップレベルでの重い import を避け、最初のリクエストでのみクライアントを生成するパターンが有効です。
# 悪い例: モジュールロード時に重い import が走る
from google import genai
from google.genai import types
import google.auth # 重い
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def handler(request):
return client.models.generate_content(...)# 良い例: 必要になるまで遅延ロード
_client = None
_genai_module = None
def get_client():
global _client, _genai_module
if _client is None:
# 最初のリクエストでのみ import が走る
from google import genai
_genai_module = genai
_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
return _client
def handler(request):
return get_client().models.generate_content(...)この変更だけで、Cloud Run の冷却計測でモジュールロード時間が 1.2 秒から 0.4 秒に短縮できた例があります。「グローバルスコープに置けば速い」という直感は、コールドスタートの初回リクエストでは逆効果になりがちです。あるリクエストの応答完了までの時間を最小化したいなら、初回コストを許容してでも本当に必要な瞬間まで初期化を遅らせる方が合理的です。
ストリーミングで体感を縮める — TTFB を 200ms に近づける設計
コールドスタートを完全にゼロにするのが難しい場合の現実的な手は、ストリーミングレスポンスで TTFB(最初のバイトが返るまでの時間)を短縮するアプローチです。Gemini API の generate_content_stream を使い、コンテナ起動完了直後にすぐ最初のヘッダーだけでもクライアントに返してしまうと、ユーザーには「速い」と感じてもらえます。
# Cloud Run / Lambda 共通のストリーミングパターン(FastAPI)
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.post("/chat")
async def chat(prompt: str):
client = get_client() # 遅延初期化
def generate():
# 最初に空白文字を1バイトだけ送る → TTFB が即座に返る
yield " "
stream = client.models.generate_content_stream(
model="gemini-2.5-flash",
contents=prompt,
)
for chunk in stream:
if chunk.text:
yield chunk.text
return StreamingResponse(generate(), media_type="text/plain")このテクニックは特に Cloud Run + Cloudflare の組み合わせで効きます。Cloudflare の CDN がレスポンスの初回バイトを受け取った時点で、クライアントへの転送を開始してくれるため、E2E のレイテンシが見かけ上 1〜2 秒短縮されます。詳細はGemini API のストリーミング応答制御で別途解説しています。
コストとレイテンシのトレードオフをどう決めるか
ここまでのテクニックを踏まえて、私が個人開発のサーバレスアプリで実際に使っている判断基準を共有します。
- DAU 50 未満の検証フェーズ: Cloud Run min-instances=0 + SDK 遅延初期化のみ。コールドスタートは許容
- DAU 50〜500 の収益化フェーズ: Cloud Run min-instances=1 + CPU Always Allocated(月 $35)
- DAU 500 以上の事業フェーズ: Cloud Run min-instances=2 + 監視アラート(月 $70 〜)または Cloudflare Workers への移行検討
- B2B SLA を提示する場合: Provisioned Concurrency または Cloud Run min-instances=3 以上で冗長化
サーバレスでの設計判断は、結局のところユースケースの整理に帰着するからです。
よくある間違いと落とし穴
最後に、私自身が踏み抜いてきた落とし穴を 3 つ共有します。
落とし穴 1: min-instances=1 にしただけで安心する
--cpu-always-allocated を付けないと、アイドル中に SDK 内部のコネクションプールがリセットされます。リクエストの合間にも CPU を保持しておかないと、min-instances=1 のメリットの半分以上を失います。
落とし穴 2: Provisioned Concurrency をフルタイムで予約する
24 時間 1 同時実行を予約すると月 $13〜20 ですが、夜間や週末はほぼアクセスがないアプリでは無駄です。AWS Application Auto Scaling のスケジュールベース予約で、平日昼間のみ予約する設計に切り替えると、コストを 50〜70% 削減できます。
落とし穴 3: SnapStart 後の認証情報期限切れに気づかない
Vertex AI 経由の場合、SnapStart のスナップショット作成時の認証トークンは時間経過で無効化されます。Lambda Runtime API の after_restore フックで再認証する処理を入れないと、本番デプロイから 1 時間後あたりで突然 401 エラーが頻発する事故が起きます。
落とし穴 4: Cloud Run・Lambda・Workers を同じ感覚で扱う
3 つのプラットフォームはコールドスタートの性質が大きく異なり、同じコードでも実装ごとにパフォーマンスが劇的に変わります。Lambda で動いていた関数を Cloud Run にそのまま移植しても、SnapStart に相当する仕組みが Cloud Run にはないため、せっかく init フェーズに最適化したコードのメリットが消えます。逆に Cloudflare Workers で 50ms で動いていたものを Lambda にコピーすると、V8 Isolate モデルが再現されないので 1.5 秒程度かかることもあります。プラットフォームを移行するときは、必ず計測をやり直して初期化戦略をゼロから再考してください。片方で最適だった構成は、もう片方では往々にして最適ではありません。
次の一歩
ここで紹介したパターンは、すべて個人開発の小規模 SaaS でも実装可能なものばかりです。まずは現在動いているアプリで since_module_load / client_init / api_call の3指標を計測するところから始めてみてください。多くの場合、コードを 1 行も変えずに --cpu-always-allocated フラグを足すだけで体感速度が劇的に変わります。
レイテンシ全体の話はGemini API のレイテンシを下げる実践テクニックで、レート制限と組み合わせた本番運用はGemini API のレート制限と本番運用設計で深掘りしていますので、合わせて参考にしていただければ幸いです。