【GCP】Cloud Run × Cloud Tasks で外部APIのレートリミットを回避する非同期バッチ構成(Python実装例つき)

Google Cloud Tasksで外部APIのレート制限を回避する仕組みの図解 インフラ・サーバー

はじめに

外部サービス(決済API、AI API、LINE通知APIなど)を連携する際、避けて通れないのが「1秒間に〇回まで」というレートリミット(実行制限)です。

以前の記事で紹介したGemini APIを使ったOCR」処理などにはレートリミットが存在します。

リクエストが集中した際に何も対策をしていないと、HTTP status 429(Too Many Requests)が発生し、処理が失敗してしまいます。

💡 エピソード:並列処理で起きる「429エラー」
以前、Gemini APIを使って大量の書類データを連続OCR処理するスクリプトを走らせた際、処理速度を上げようとPythonの並列処理(asyncio や ThreadPoolExecutor)を組みました。
その結果、一瞬でレート制限の上限に達してしまい、大量の 429 Too Many Requests が発生。タスクが途中で止まってしまい、どこまで成功したかをログから追いかけることに。
プログラム側で asyncio.sleep() を挟んで手動制御する方法も試しましたが、コードが複雑になり苦しい思いをしました。

本記事では、Google Cloud Tasks の「流量制御(レート制限)」機能と Cloud Run を組み合わせ、外部APIの制限を確実に守りながら安全に非同期処理を実行するアーキテクチャと具体コードを解説します。

全体アーキテクチャと仕組み

全体像は以下の通りです。フロントや呼び出し元はタスクをキューに積むだけで即座にレスポンスを受け取ることができます。

[クライアント/API]
       │ (1. タスク追加リクエスト)
       ▼
[ Cloud Tasks (Queue) ] ──── (2. 設定したレートでHTTPリクエスト配信) ────┐
                                                                       ▼
                                                              [ Cloud Run (Worker) ]
                                                                       │ (3. 外部API呼び出し)
                                                                       ▼
                                                                  [ 外部API ]

なぜこの組み合わせなのか?

  • Cloud Tasks: キューの配信速度(例: 1秒間に2リクエストまで)を厳格に制御可能。
  • Cloud Run: タスクを受け取るHTTPエンドポイント(Web API)として機能。自動スケールしつつ、認証情報(OIDCトークン)により安全にCloud Tasksからの呼び出しを受け取られる。
Google Cloud Tasksを使った非同期処理のメリットを図解

前提条件・環境

  • Python 3.10+
  • FastAPI
  • google-cloud-tasks ライブラリ
  • GCP プロジェクトおよび gcloud CLI のセットアップ

Google Cloud Runの環境構築については以下の記事を参考にしてください。

この記事にあるようにローカルのWSL2(Ubuntu)から gcloud コマンドを使うには、Google Cloud SDK(google-cloud-cli)のインストールが必要です。

Cloud Tasks API の有効化

GCP プロジェクト側で Cloud Tasks の機能が有効になっていない場合、コマンド実行時にエラーになります。以下のコマンドであらかじめ有効化しておきます。

gcloud services enable cloudtasks.googleapis.com

Step 1: Cloud Tasks のキュー作成(流量制御の設定)

まずは Cloud Tasks のキューを作成し、配信速度の上限を設定します。
使用する外部APIに合わせて適宜調整してください。

# キューの作成
gcloud tasks queues create api-rate-limit-queue \
    --location=asia-northeast1

# 流量制御(レートリミット)の設定
# 例:1秒間に最大2タスク、同時実行数上限2
gcloud tasks queues update api-rate-limit-queue \
    --location=asia-northeast1 \
    --max-dispatches-per-second=2 \
    --max-concurrent-dispatches=2

Step 2: Cloud Run 側の処理(Worker API)の実装

Cloud Tasks から呼び出されるエンドポイントを作成します。ここには「実際の外部API呼び出し処理」を記述します。

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
import logging

app = FastAPI()

class TaskPayload(BaseModel):
    user_id: str
    data: str

@app.post("/process-task")
async def process_task(payload: TaskPayload):
    """
    Cloud Tasks から呼び出されるワーカーエンドポイント
    """
    try:
        # ここに外部APIを呼び出す処理を記述
        logging.info(f"外部API呼び出し開始: User {payload.user_id}")
        
        # 擬似的な外部API処理
        # call_external_api(payload.data)
        
        return {"status": "success", "user_id": payload.user_id}
    except Exception as e:
        logging.error(f"処理失敗: {e}")
        # 500系のエラーを返すと Cloud Tasks が設定に基づき自動リトライしてくれる
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="External API call failed"
        )

Step 3: Cloud Tasks にタスクを投入する処理の実装

呼び出し元(またはCloud Run自身のエンドポイント)から、Cloud Tasksのキューへタスクを追加するコードです。

import json
from google.cloud import tasks_v2

def enqueue_external_api_task(project_id: str, location: str, queue_name: str, worker_url: str, payload: dict):
    """
    Cloud Tasks キューにタスクを追加する
    """
    client = tasks_v2.CloudTasksClient()
    parent = client.queue_path(project_id, location, queue_name)

    # ペイロードの準備
    json_payload = json.dumps(payload).encode()

    task = {
        "http_request": {
            "http_method": tasks_v2.HttpRequest.Method.POST,
            "url": worker_url,
            "headers": {"Content-Type": "application/json"},
            "body": json_payload,
            # Cloud Run 認証用の OIDC トークン設定(セキュアな呼び出し)
            "oidc_token": {
                "service_account_email": f"cloud-tasks-invoker@{project_id}.iam.gserviceaccount.com"
            }
        }
    }

    response = client.create_task(request={"parent": parent, "task": task})
    print(f"Task created: {response.name}")
    return response

Step 4: デプロイと動作確認

1. Cloud Run へのデプロイ

gcloud run deploy worker-service \
    --source . \
    --region asia-northeast1 \
    --no-allow-unauthenticated

2. 権限設定(Cloud Tasks から Cloud Run の呼び出し許可)

Cloud Tasks が安全に Cloud Run を叩けるよう、サービスアカウントに roles/run.invoker を付与します。

Cloud Tasksから非公開のCloud Runへサービスアカウントとroles/run.invokerでアクセスする方法の図解

⚠️ 権限設定で忘れで「403 Forbidden」:
はじめて構築した際、Cloud Run を非公開(–no-allow-unauthenticated)でデプロイしたにもかかわらず、IAM権限の設定を忘れたままタスクを投入してしまいました。 結果、Cloud Tasks からの呼び出しがすべて 403 Forbidden エラーで弾かれ、Cloud Tasks 側のログを見るまで何が起きているのか分からず小一時間悩みました。
Cloud Run をセキュアに保つには、「専用のサービスアカウント作成」 と 「roles/run.invoker 権限の付与」 の2ステップが必須です。

1. サービスアカウントの作成(未作成の場合)

タスク実行用の専用サービスアカウントを作成します。

gcloud iam service-accounts create cloud-tasks-invoker \
    --display-name="Cloud Tasks Invoker Service Account"

gcloud iam service-accounts create:
「IAM(アクセス権限管理)のサービスアカウントを新しく作成します」という指示です。

cloud-tasks-invoker:
システム内部で管理・識別するための「ユニークなID(名前)」です。英数字とハイフンを使って自身で決めます。

–display-name=”Cloud Tasks Invoker Service Account”:
GCP管理画面(コンソール)のリスト等に表示される「人間が読みやすい説明名」です。自身で決めます。日本語やスペースも使えます。

2. Cloud Run 呼び出し権限(roles/run.invoker)の付与

作成したサービスアカウントに対し、以下のコマンドで特定の Cloud Run サービスを呼び出す権限を付与します。

gcloud run services add-iam-policy-binding worker-service \
    --region=asia-northeast1 \
    --member="serviceAccount:cloud-tasks-invoker@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
    --role="roles/run.invoker"

worker-service:
権限を設定したい Cloud Run のサービス名です。先ほどデプロイに使用したサービス名です。

–member=”serviceAccount:…”:
権限を与える対象(メンバー)を指定します。cloud-tasks-invokerは自身のサービス名、YOUR_PROJECT_IDは自身のプロジェクトIDになります。

–role=”roles/run.invoker”:
付与する権限の種類(ロール)です。roles/run.invoker は「Cloud Run を呼び出して実行できる権限」を意味します。

まとめ

  • Cloud Tasks の –max-dispatches-per-second を設定することで、アプリ側で複雑なスロットリング処理を書かずにレートリミットを回避できます。
  • 万が一外部API側がエラーを返しても、Cloud Tasks の自動リトライ機能によりデータ損失を防げます。
  • 完全マネージドな構成のため、サーバーの管理コストゼロで運用可能です。

外部APIとの連携でレートリミットやスパイクアクセスに悩んでいる方は、ぜひ試してみてください。

コメント