インフラ

Cloud RunのCPUスロットリングがバックグラウンドのゴルーチンを破壊する仕組み

Cloud Runは、リクエストの処理中にのみコンテナにCPUを割り当てるため、ティッカー、ハートビート、キャッシュの更新は静かに停止します。ここではその理由と、4つの修正方法について説明します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

VMで正常に動作していたGoサービスが、Cloud Runに移行したその日からリースロックを失い始めました。30秒ごとに起動する単純なtime.Tickerであるリースハートビートが、一度に数分間静かになってしまうのです。コードには何も変更はありませんでした。原因は、ほとんどのガイドが一行で言及して次に進んでしまうCloud Runのデフォルト設定です。デフォルトでは、コンテナはリクエストを処理している間だけCPUを割り当てられます。リクエストの合間にはCPUがほぼゼロにまでスロットリングされ、バックグラウンドで実行し続けると期待していたgoroutineもそれに伴って停止してしまいます。

Cloud Runでバックグラウンドループ、ティッカー、キャッシュリフレッシャー、ハートビート、キューコンシューマーを実行している場合、これはローカルでは決して現れない形でそれらを破壊する可能性が最も高い単一の挙動です。この記事では、そのメカニズムを説明し、Goのコードでその失敗を示し、それぞれのトレードオフとともに4つの修正方法を順を追って説明します。

Cloud Run が CPU を割り当てる方法

Cloud Run には 2 つの CPU 割り当てモードがあり、デフォルトのモードは人々を驚かせるものです。

デフォルトは、「リクエスト処理中のみ CPU を割り当てる」(CPU allocated during requests) (リクエストベースの課金 (request-based billing) や CPU スロットリング (CPU throttling) とも呼ばれます) です。コンテナは、少なくとも 1 つのリクエストを処理している間、完全な CPU を与えられます。最後の処理中のリクエストが終了した瞬間、Cloud Run は CPU をごくわずかな割合、ほぼゼロにまでスロットリングします。コンテナはまだ生存しており、そのメモリは維持されていますが、CPU サイクルはほとんどスケジュールされません。リクエスト中に使用された CPU に対してのみ課金されるため、このモードはより安価です。

もう 1 つのモードは「CPU を常に割り当てる」(CPU always allocated) (インスタンスベースの課金 (instance-based billing)) です。コンテナは、リクエストが処理中であるかどうかに関わらず、そのライフタイム全体にわたって完全な CPU を維持します。インスタンスが存在する期間全体に対して料金を支払いますが、その代わりにバックグラウンド処理は正常に実行されます。

ローカル環境や通常の VM では、プロセスは常に CPU を持っているため、バックグラウンドの goroutine は問題なく動作します。Cloud Run のデフォルトモードは、コードを一行も変更することなくその前提を覆します。これこそが、この障害が混乱を招くまさにその理由です。プログラムは正しいのです。その下で動作するスケジューリング環境が変わったのです。

リクエスト間で実際に問題となること

安定したバックグラウンド処理を前提とするものは、すべてリスクにさらされます。一般的なケースは以下の通りです。

  • インメモリキャッシュを更新するために固定間隔で起動する time.Ticker。リクエストの合間に更新ゴルーチンがCPU不足になるため、キャッシュが古くなります。
  • 「N秒ごと」に更新するリースまたはロックのハートビート。更新ゴルーチンがスケジュールされない場合、リースは期限切れになり、別のワーカーがそれを横取りします。
  • メモリ内でバッチ処理を行い、タイマーでフラッシュするメトリクスまたはログのフラッシャー。次のリクエストがインスタンスを起動させるまで、フラッシュは遅延します。
  • メッセージを待機してループするバックグラウンドキューまたはPub/Subプルコンシューマー。受信リクエストがない間は何も処理しません。

time.Ticker に関する重要な点は、タイマー自体が問題ではないということです。ティックは実時間に基づいて配信され、その値はチャネルにバッファリングされたままになることさえあります。問題は、そのチャネルを待機しているゴルーチンが、起動して処理を実行するためにCPUを必要とすること、そしてそのCPUこそがスロットリングによって奪われるものだということです。そのため、ティックはスケジュール通りに「発生」しますが、ハンドラの実行は遅れたり、バースト的に実行されたり、あるいはリクエストがインスタンスを完全なCPU状態に戻したときにのみ実行されたりします。

ハートビートのケースは、サイレントであり、共有状態を破損させるため、最も深刻です。単に遅れるだけではありません。保持していると思っていたロックを失います。

自身のリースを失効させるハートビート

これが、問題が発生したコードの形です。ワーカーはリースを取得し、時間のかかるジョブを実行する間、タイマーでリースを更新するためにゴルーチンを開始します。

func (w *Worker) run(ctx context.Context) error {
	if err := w.store.AcquireLease(ctx, w.id, 90*time.Second); err != nil {
		return err
	}
	go w.heartbeat(ctx) // renew the lease in the background
	return w.processLongJob(ctx)
}

func (w *Worker) heartbeat(ctx context.Context) {
	t := time.NewTicker(30 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			// Needs CPU to run. Under request-based throttling,
			// this may not be scheduled until the next request.
			_ = w.store.RenewLease(ctx, w.id, 90*time.Second)
		}
	}
}

VM上では、これは問題ありません。デフォルトの割り当てが設定されたCloud Runでは、processLongJob自体がI/Oを待機しており、新しいリクエストが到着しない場合、インスタンスはスロットリングされ、ハートビートのゴルーチンはスケジュールされず、90秒後にリースは失効します。別のインスタンスが失効したリースを検出し、同じジョブを取得します。その結果、2つのワーカーが同じタスクを実行することになり、これはまさにリースが防ごうとしていた障害そのものです。

根本的な混乱の原因は、そのコードが並行処理として正しく見え、実際にゴルーチンにCPUが与えられる環境下では正しく動作する点にあります。Cloud Runのデフォルト設定では、リクエストの合間にそれが保証されません。解決策は2つあります。バックグラウンドのゴルーチンがCPUを得られるように環境を変更するか、あるいは、バックグラウンドのゴルーチンに全く依存しないように設計を変更するかです。次の4つのセクションでは、その両方について説明します。

オプション1: 最小インスタンスでCPUを常に割り当てる

最も直接的な修正は、スロットリングを停止することです。インスタンスが常にCPUを保持し、少なくとも1つのインスタンスをウォーム状態に保つように設定します。

gcloud run services update my-service \
  --no-cpu-throttling \
  --min-instances=1

--no-cpu-throttling は、サービスをインスタンスベースのCPUに切り替えるため、リクエストの合間にgoroutineが実行されます。--min-instances=1 は、少なくとも1つのインスタンスを稼働させ続けるため、バックグラウンドループが実行されるコンテナが常に存在することになります。両方が必要です。ゼロにスケールダウンしたインスタンス上で常に割り当てられているCPUでも、インスタンス自体が存在しなくなるため、ループは実行されません。

トレードオフはコストです。リクエスト中だけでなく、インスタンスが稼働している間ずっとCPUとメモリの料金を支払うことになります。また、最小インスタンスを設定するということは、トラフィックがゼロであっても24時間料金を支払うことを意味します。常駐のバックグラウンドループを本当に必要とするサービスにとっては、これが正直な価格であり、多くの場合、それは少額です。習慣的にバックグラウンドのgoroutineを追加したサービスにとっては、常駐させる必要のなかったものを維持するために継続的に支払うことになります。これを採用する前に、その処理が本当にリクエストの外部で実行される必要があるのかどうかを問い直してください。もしそうであれば、これがクリーンな答えです。

オプション2: バックグラウンド処理をリクエストパスに移動する

バックグラウンドジョブが次のリクエストのために何かを最新の状態に保つためだけに存在する場合、バックグラウンドループはまったく必要ないかもしれません。リクエストが到着したときに処理を実行し、一定間隔ごとに最大1回しか実行されないように保護します。

type cache struct {
	mu        sync.Mutex
	data      []Item
	refreshed time.Time
}

func (c *cache) get(ctx context.Context, refresh func(context.Context) ([]Item, error)) ([]Item, error) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if time.Since(c.refreshed) < 60*time.Second {
		return c.data, nil // still fresh, no work
	}
	data, err := refresh(ctx)
	if err != nil {
		if c.data != nil {
			return c.data, nil // serve stale on refresh failure
		}
		return nil, err
	}
	c.data, c.refreshed = data, time.Now()
	return c.data, nil
}

これで、更新はトラフィックによってトリガーされるようになります。これはまさにCPUが保証されているときです。タイマーはスケジューラーではなく、陳腐化のチェックになります。これは、キャッシュ、小規模な定期的な再計算、および唯一のコンシューマーがリクエスト自体であるものに適しています。

トラフィックに関係なくスケジュール通りに実行されなければならない処理には適していません。1時間リクエストが到着しない場合、1時間何も更新されません。キャッシュの場合、これは許容範囲です。なぜなら、間隔が空いた後の最初のリクエストが1回限りの更新コストを支払うからです。特定の時間に実行されなければならないタスクの場合、これは許容できず、次の2つのオプションのいずれかが必要になります。

オプション3: 定期的な処理を Cloud Run ジョブと Cloud Scheduler に分割する

真に cron 形式のもの、つまりスケジュールに基づいて実行され、ひとまとまりの作業を行い、終了するような処理の場合、正しいモデルはリクエストを処理するサービス内の長時間実行されるループではありません。それは、Cloud Scheduler によってトリガーされる Cloud Run ジョブです。

Cloud Run ジョブは、リクエストを処理するのではなく、コンテナを完了まで実行します。そして、実行中は常に CPU が割り当てられます。Cloud Scheduler は cron 式に基づいてそれを呼び出します。サービスはリーンかつステートレスな状態を維持し、バッチ処理は独自のタイムアウトと再試行ポリシーを持つ独自の場所で実行されます。

# Deploy the periodic work as a Job (runs to completion, always has CPU)
gcloud run jobs create refresh-job \
  --image=REGION-docker.pkg.dev/PROJECT/repo/refresh:latest \
  --task-timeout=600 --max-retries=1

# Fire it every 10 minutes with Cloud Scheduler
gcloud scheduler jobs create http refresh-schedule \
  --schedule="*/10 * * * *" \
  --uri="https://REGION-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT/jobs/refresh-job:run" \
  --http-method=POST \
  --oauth-service-account-email=[email protected]

これにより、リクエスト/レスポンスモデルとバッチ処理が明確に分離されます。ジョブはリクエストベースのスロットリングを一切受けないため、CPU を確保できます。アイドル時にはゼロにスケールし、実行間のコストはかかりません。Scheduler は、本格的な cron のセマンティクス、再試行、そして調査可能な実行履歴を提供します。

トレードオフは、粒度と結合度です。Cloud Scheduler の最小間隔は 1 分なので、数秒ごとに実行する必要がある処理には向いていません。また、ジョブは別のプロセスで実行されるため、必要な状態はサービスのメモリ内ではなく、両者がアクセスできるデータベースやキャッシュに存在する必要があります。マテリアライズドビューの更新、古いレコードの期限切れ処理、ダイジェストの送信といった、ほとんどの真に定期的なメンテナンスについては、これが処理の形態に合致したモデルです。

オプション 4: Pub/Sub プッシュによるイベント駆動

バックグラウンド ループが実際にキューを消費している場合は、その処理を逆にします。ループ内でメッセージをプルする goroutine の代わりに、Pub/Sub が各メッセージをサービスの HTTP エンドポイントにプッシュするようにします。各配信は通常のリクエストであるため、CPU が割り当てられ、枯渇する常駐ループはありません。

// Pub/Sub push delivers one message per HTTP POST.
// This runs as a request, so it always has CPU.
func handlePush(w http.ResponseWriter, r *http.Request) {
	var env struct {
		Message struct {
			Data []byte `json:"data"`
		} `json:"message"`
	}
	if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
		http.Error(w, "bad envelope", http.StatusBadRequest)
		return
	}
	if err := processMessage(r.Context(), env.Message.Data); err != nil {
		http.Error(w, "retry", http.StatusInternalServerError) // Pub/Sub redelivers
		return
	}
	w.WriteHeader(http.StatusNoContent) // ack
}

確認応答として 2xx を返すか、後で Pub/Sub に再配信させるために 5xx を返します。サブスクリプションの再試行ポリシーとデッドレター ポリシーが失敗を処理するため、手動で作成した再試行ループのクラス全体を削除できます。サービスはゼロにスケールダウンし、次のメッセージで起動できます。

コストは、プッシュ配信が HTTP 経由でメッセージごとに行われる点です。これは、バッチ処理を行うプル コンシューマーとは異なります。高スループットのバッチ処理や順序指定されたプル処理が必要な場合は、常に CPU が割り当てられているコンポーネントでのプルモデルの方が適しています。しかし、一度に 1 つのイベントに反応する場合、プッシュはバックグラウンド作業を通常のリクエスト処理に変えます。これは、Cloud Run のデフォルトが構築されているモデルです。

どのオプションが適しているか、そして常時オン CPU が適切なのはどのような場合か

決定的な問いは「goroutine をどのように生存させ続けるか」ではありません。それは「これは本当に常駐バックグラウンドループを必要とするのか、それとも代わりにリクエスト駆動またはイベント駆動にできるのか」ということです。Cloud Run で中断されるほとんどのバックグラウンドループは、常駐する必要がありませんでした。それらは、リクエスト、スケジュールされたジョブ、またはイベントハンドラとしてより適した作業を行うための便利な方法でした。

作業パターン 最適な選択肢
リクエスト自体が消費するキャッシュ オプション 2、リクエスト時に更新
Cron 形式のメンテナンス、分単位以上の粒度 オプション 3、ジョブとスケジューラ
キューに入れられたイベントへの反応 オプション 4、Pub/Sub プッシュ
本当に継続的に実行する必要があるループ オプション 1、常時オン CPU と最小インスタンス

作業が真にリクエストやイベントとして表現できず、サービングプロセス内で継続的に実行する必要がある場合に、常時割り当て CPU (オプション 1) を選択してください。1 秒未満の内部ティック、ライブのインメモリ集計、接続を開いたままにする必要があるストリーミングプルコンシューマ、WebSocket ファンアウトなどです。これらはその性質上、リクエストの合間に CPU を必要とし、ウォームインスタンスに料金を支払うことが正しい判断です。これは、ティッカーが誤動作したからという理由で切り替えるデフォルト設定ではなく、請求が伴う決定です。

それ以外のすべての場合において、スロットリングは回避すべきバグではありません。それは、その作業がリクエストサービングパスの外に属することを示すシグナルです。リースハートビートは、最後の良い例です。常時オン CPU を使用する場合でも、最も安全なバージョンはハートビートの発行にまったく依存しません。リースを有効期限ベースにし、デッドラインで compare-and-swap を行うことで、更新が失敗した場合でも古いリースを再利用できるようにします。正確性をデータに押し込むことで、一時的に CPU を失ったプロセスが共有状態を破損できないようにします。Cloud Run のデフォルトは、VM よりも早い段階でその規律を強制し、そこから生まれるシステムは、そのおかげでより堅牢になる傾向があります。