
Cloud Tasksでタスク単位のリトライ設定とバッチ作成・削除がGAになったので試してみる
はじめに
データ事業本部のkobayashiです。
以前、Cloud Tasksのキューを使ってWorkflowsの実行レートを制限する記事と、最大同時ディスパッチ数の挙動を確認する記事を書きました。
これらの記事ではリトライの設定をキュー単位で行い、タスクはshellスクリプトで1件ずつ登録していました。
2026年9月30日のアップデートでCloud Tasksの タスク単位のリトライ設定 と タスクのバッチ作成・バッチ削除 がGAになりました。今回はこれらの機能を試してみたので、その内容をまとめます。
- Set retry parameters for a task | Cloud Tasks | Google Cloud
- Create HTTP target tasks | Cloud Tasks | Google Cloud
- Manage queues and tasks | Cloud Tasks | Google Cloud
今回GAになった機能
| 機能 | API | 内容 |
|---|---|---|
| タスク単位のリトライ設定 | tasks.create の task.retryConfig |
タスク作成時にリトライ設定を指定し、キューの retryConfig を上書きする |
| バッチ作成 | tasks.batchCreate |
複数のタスクを1リクエストで同じキューに登録する |
| バッチ削除 | tasks.batchDelete |
複数のタスクを1リクエストで削除する |
タスクに指定できるリトライのパラメータ(maxAttempts、maxRetryDuration、minBackoff、maxBackoff、maxDoublings)はキューの retryConfig と同じです。タスクに retryConfig を指定しなければ、これまで通りキューの設定が使われます。なお、タスク単位で上書きできるのはリトライ設定だけで、ディスパッチの速度や同時実行数はキューの設定に従います。
ドキュメントに記載されているバッチ操作の上限は以下の通りです。
| 項目 | 上限 |
|---|---|
| バッチ作成で1回に登録できるタスク数 | 100 |
| バッチ作成で1回に送れる全体のサイズ | 10 MiB |
| バッチ削除で1回に削除できるタスク数 | 1,000 |
また、APIのクォータでは、バッチ内のタスク1件がAPIリクエスト1回として数えられます。
なお、バッチ操作はアトミックではなく、一部のタスクだけが失敗することがあります。レスポンスは長時間実行オペレーションで、結果はオペレーションの状態で確認します。
執筆時点(Google Cloud SDK 588.0.0)では gcloud tasks コマンドはこれらの機能に対応しておらず、gcloud beta tasks コマンドで使えます。今回はPythonからREST APIを呼び出して試してみます。
検証環境の準備
今回使用した環境は以下の通りです。
$ python --version
Python 3.13.9
$ gcloud version | head -1
Google Cloud SDK 588.0.0
$ pip list | grep -iE "^(google-auth|requests) "
google-auth 2.60.0
requests 2.34.2
認証にはApplication Default Credentialsを使います。
$ pip install google-auth requests
$ gcloud auth application-default login
リトライを観察するためのCloud Runサービス
リトライの挙動を確認するため、リクエストを受けると必ず500を返すCloud Runサービスを作成します。何回目の試行かがわかるように、Cloud Tasksが付与するリクエストヘッダーをログに出力しています。
import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/")
def handler():
task_name = request.headers.get("X-CloudTasks-TaskName")
retry_count = request.headers.get("X-CloudTasks-TaskRetryCount")
print(f"task={task_name} retry_count={retry_count}")
return "always fail", 500
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
flask==3.*
gunicorn==23.*
web: gunicorn -b :$PORT main:app
サービスは認証なしの呼び出しを許可せずにデプロイし、Cloud TasksからはOIDCトークン付きで呼び出します。そのため roles/run.invoker を付与したサービスアカウント tasks-invoker を作成します。プロジェクトのオーナーでない場合は、タスクを作成するアカウントに tasks-invoker の roles/iam.serviceAccountUser も付与しておきます。
$ gcloud run deploy always-fail --source . --region=asia-northeast1 --no-allow-unauthenticated
$ gcloud iam service-accounts create tasks-invoker
$ gcloud run services add-iam-policy-binding always-fail --region=asia-northeast1 \
--member="serviceAccount:tasks-invoker@{プロジェクトID}.iam.gserviceaccount.com" \
--role="roles/run.invoker"
roles/run.invoker を付与した直後に登録したタスクは、最初の3回の試行が403になりました。権限が反映されるまで少し待ってからタスクを登録しています。
キューの作成
タスク側の設定と区別がつくように、キュー側のリトライ設定は「最大5回、間隔は10秒から」としておきます。実行結果をCloud Loggingで確認するため、--log-sampling-ratio=1.0 でCloud Tasksのログをすべて出力します。
$ gcloud tasks queues create test-retry-queue --location=asia-northeast1 \
--max-attempts=5 --min-backoff=10s --max-backoff=300s --max-doublings=3 \
--log-sampling-ratio=1.0
共通のヘルパー
以降のスクリプトで共通して使う認証とタスクの組み立ては common.py にまとめておきます。
import google.auth
import google.auth.transport.requests
PROJECT_ID = "{プロジェクトID}"
LOCATION = "asia-northeast1"
QUEUE_ID = "test-retry-queue"
TARGET_URL = "https://always-fail-{プロジェクト番号}.asia-northeast1.run.app/"
INVOKER_SA = f"tasks-invoker@{PROJECT_ID}.iam.gserviceaccount.com"
QUEUE_PATH = f"projects/{PROJECT_ID}/locations/{LOCATION}/queues/{QUEUE_ID}"
BASE_URL = f"https://cloudtasks.googleapis.com/v2/{QUEUE_PATH}"
def auth_headers() -> dict:
credentials, _ = google.auth.default(
scopes=["https://www.googleapis.com/auth/cloud-platform"]
)
credentials.refresh(google.auth.transport.requests.Request())
return {
"Authorization": f"Bearer {credentials.token}",
"Content-Type": "application/json; charset=utf-8",
}
def http_task(task_id: str, schedule_time: str | None = None) -> dict:
task = {
"name": f"{QUEUE_PATH}/tasks/{task_id}",
"httpRequest": {
"url": TARGET_URL,
"httpMethod": "POST",
"oidcToken": {"serviceAccountEmail": INVOKER_SA},
},
}
if schedule_time:
task["scheduleTime"] = schedule_time
return task
タスク単位のリトライ設定を試してみる
同じキューに以下の2つのタスクを登録してみます。
retry-default:retryConfigを指定しない(キューの設定通り、最大5回・10秒から)retry-override:retryConfigで「最大2回、間隔は1秒」を指定する
import requests
from common import BASE_URL, auth_headers, http_task
headers = auth_headers()
# キューの retryConfig をそのまま使うタスク
default_task = http_task("retry-default")
# タスク単位で retryConfig を上書きするタスク
override_task = http_task("retry-override")
override_task["retryConfig"] = {
"maxAttempts": 2,
"minBackoff": "1s",
"maxBackoff": "1s",
}
for task in [default_task, override_task]:
res = requests.post(f"{BASE_URL}/tasks", headers=headers, json={"task": task})
res.raise_for_status()
print(res.json())
実行すると、retry-override のレスポンスにだけ次の retryConfig が含まれていました。
'retryConfig': {'maxAttempts': 2, 'minBackoff': '1s', 'maxBackoff': '1s', 'maxDoublings': 16}
指定していない maxDoublings がキューの値の3ではなく16になっています。こちらは後述します。
Cloud Loggingのログエクスプローラーで、試行ごとのログを確認してみます。
resource.type="cloud_tasks_queue"
resource.labels.queue_id="test-retry-queue"
severity>=DEFAULT
応答のログ(attemptResponseLog)を時刻順に並べると以下のようになりました。retryTime はログに記録されている次回の試行予定時刻です。
| 時刻(UTC) | タスク | 試行 | retryTime |
|---|---|---|---|
| 19:13:09.036 | retry-default | 1 | 19:13:19.036 |
| 19:13:13.131 | retry-override | 1 | 19:13:14.131 |
| 19:13:17.132 | retry-override | 2 | なし |
| 19:13:21.126 | retry-default | 2 | 19:13:41.126 |
| 19:13:41.229 | retry-default | 3 | 19:14:21.228 |
| 19:14:21.332 | retry-default | 4 | 19:15:41.332 |
| 19:15:41.469 | retry-default | 5 | なし |
retry-override は2回の試行で終わり、retry-default はキューの設定通り5回まで試行されました。retryTime は、retry-override がタスクに指定した1秒後、retry-default が10秒後、20秒後、40秒後、80秒後です。実際にディスパッチされた時刻は、retryTime より数秒遅れているものがありました。
試行回数の上限に達したタスクは、gcloud tasks list の一覧に表示されなくなりました。
指定しなかった項目はどうなるか
retryConfig で指定しなかった項目がどうなるかを確認するため、retryConfig の中身を変えたタスクを登録してレスポンスを見てみます。キューの設定は先程と同じ(maxAttempts=5、minBackoff=10s、maxBackoff=300s、maxDoublings=3)です。
送った retryConfig |
レスポンスの retryConfig |
|---|---|
{"maxAttempts": 3} |
maxAttempts=3、minBackoff=0.100s、maxBackoff=3600s、maxDoublings=16 |
{"minBackoff": "1s"} |
maxAttempts=100、minBackoff=1s、maxBackoff=3600s、maxDoublings=16 |
{} |
maxAttempts=100、minBackoff=0.100s、maxBackoff=3600s、maxDoublings=16 |
今回の結果では、タスクに retryConfig を付けると、指定しなかった項目はキューの値を引き継がず、上表の値になりました。たとえば minBackoff だけを変えるつもりで {"minBackoff": "1s"} を指定すると、試行回数の上限もキューの5回から100回に変わります。変えない項目もタスク側に指定しておくのが安全です。
maxAttemptsとmaxRetryDurationを両方指定する
maxAttempts と maxRetryDuration を両方指定したタスクも登録してみました。minBackoff と maxBackoff はどちらも5秒にしています。
送った retryConfig |
試行回数 | 最後の試行 |
|---|---|---|
maxAttempts=2、maxRetryDuration=120s |
13回 | 初回から約121秒後 |
maxAttempts=10、maxRetryDuration=20s |
10回 | 初回から約3分後 |
retryTime はどちらも応答の5秒後でしたが、実際のディスパッチは1つ目のタスクが約10秒おき、2つ目のタスクが最初の3回は約51秒、40秒、31秒の間隔で、その後は約10秒おきでした。
どちらのタスクも、maxAttempts と maxRetryDuration の両方の条件を満たすまでリトライが続きました。ドキュメントの説明とも一致しています。
バッチ作成を試してみる
次にバッチ作成です。100件のタスクを tasks:batchCreate の1リクエストで登録し、tasks.create を100回呼んだ場合と時間を比べてみます。登録したタスクが実行されないよう、どちらも scheduleTime を1時間後にしています。
タスク名は uuid4() で付けています。ドキュメントでは、連番の名前はレイテンシとエラー率が上がるため、ハッシュのような名前にすることが推奨されています。
requests の各要素には parent にキューのパスを指定します。parent を省略したところ、次のエラーでリクエスト全体が400になりました。
All tasks in a batch request must target the same queue. Found task for queue in batch request for queue projects/{プロジェクトID}/locations/asia-northeast1/queues/test-retry-queue.
作成したスクリプトは以下になります。作成したタスク名は後のバッチ削除で使うため、ファイルに書き出しています。今回はオペレーションの failedRequests を確認せずに、200件すべての名前を書き出しています。
import json
import time
import uuid
from datetime import datetime, timedelta, timezone
import requests
from common import BASE_URL, QUEUE_PATH, auth_headers, http_task
headers = auth_headers()
schedule_time = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
single_ids = [uuid.uuid4().hex for _ in range(100)]
batch_ids = [uuid.uuid4().hex for _ in range(100)]
# 1件ずつ tasks.create を呼ぶ
start = time.perf_counter()
for task_id in single_ids:
task = http_task(task_id, schedule_time)
res = requests.post(f"{BASE_URL}/tasks", headers=headers, json={"task": task})
res.raise_for_status()
print(f"single create x100: {time.perf_counter() - start:.2f}s")
# tasks:batchCreate で100件をまとめて登録する
body = {
"requests": [
{"parent": QUEUE_PATH, "task": http_task(task_id, schedule_time)}
for task_id in batch_ids
]
}
start = time.perf_counter()
res = requests.post(f"{BASE_URL}/tasks:batchCreate", headers=headers, json=body)
res.raise_for_status()
operation = res.json()
print(f"batchCreate request: {time.perf_counter() - start:.2f}s")
print(operation)
# オペレーションの完了を待つ
op_url = f"https://cloudtasks.googleapis.com/v2/{operation['name']}"
while not operation.get("done"):
time.sleep(1)
operation = requests.get(op_url, headers=headers).json()
print(f"batchCreate done: {time.perf_counter() - start:.2f}s")
print(operation)
# バッチ削除で使うタスク名を書き出す
with open("created_tasks.json", "w") as f:
json.dump([f"{QUEUE_PATH}/tasks/{i}" for i in single_ids + batch_ids], f)
実行結果は以下です(オペレーションの表示は一部を抜粋しています)。
$ python batch_create.py
single create x100: 27.45s
batchCreate request: 0.46s
{'name': 'projects/{プロジェクトID}/locations/asia-northeast1/operations/17c34694-094f-46a4-950a-262d935a112a', 'metadata': {'@type': 'type.googleapis.com/google.cloud.tasks.v2.BatchCreateTasksMetadata', ..., 'state': 'SUCCEEDED'}, 'done': True, 'response': {'@type': 'type.googleapis.com/google.cloud.tasks.v2.BatchCreateTasksResponse', 'tasks': [...]}}
batchCreate done: 0.46s
(以下省略)
| 登録方法 | 100件の登録にかかった時間 |
|---|---|
tasks.create を100回 |
27.45秒 |
tasks:batchCreate を1回 |
0.46秒 |
1回だけ測った値ですが、今回の環境ではバッチ作成のほうが短い時間で登録できました。レスポンスが返った時点で done: True になっており、metadata.state は SUCCEEDED でした。
上限を超えた場合と一部が失敗する場合
上限を超える101件を送った場合と、既に登録済みのタスク名を混ぜた場合も試してみました。
import uuid
from datetime import datetime, timedelta, timezone
import requests
from common import BASE_URL, QUEUE_PATH, auth_headers, http_task
headers = auth_headers()
schedule_time = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
# 101件を1回で送る
body = {
"requests": [
{"parent": QUEUE_PATH, "task": http_task(uuid.uuid4().hex, schedule_time)}
for _ in range(101)
]
}
res = requests.post(f"{BASE_URL}/tasks:batchCreate", headers=headers, json=body)
print(res.status_code, res.json())
# 先に1件作成しておき、同じ名前と新しい名前を混ぜて送る
dup_id = uuid.uuid4().hex
new_id = uuid.uuid4().hex
res = requests.post(
f"{BASE_URL}/tasks", headers=headers, json={"task": http_task(dup_id, schedule_time)}
)
res.raise_for_status()
print(f"existing={dup_id} new={new_id}")
body = {
"requests": [
{"parent": QUEUE_PATH, "task": http_task(dup_id, schedule_time)},
{"parent": QUEUE_PATH, "task": http_task(new_id, schedule_time)},
]
}
res = requests.post(f"{BASE_URL}/tasks:batchCreate", headers=headers, json=body)
print(res.status_code, res.json())
$ python batch_create_errors.py
400 {'error': {'code': 400, 'message': 'A maximum of 100 tasks can be created in a single batch, but got 101 tasks.', 'status': 'INVALID_ARGUMENT'}}
existing=949eb0dc70bd492eb77dcd0483e93126 new=51a9f38682364f289deb8194a8976619
409 {'error': {'code': 409, 'message': 'Requested entity already exists', 'status': 'ALREADY_EXISTS'}}
101件のリクエストは、リクエスト全体が400になりました。重複したタスク名を先頭に置いたリクエストは409になりましたが、その後タスクの一覧を確認すると、新しい名前のタスク(51a9f386...)は登録されていました。
なお、重複したタスクを2番目以降に置いた場合は、HTTP 200でオペレーションが返り、metadata.state は PARTIALLY_SUCCEEDED、failedRequests は {"1": {"code": 6, "message": "Requested entity already exists"}} のようになりました。今回試した並びでは、どちらの場合も重複していないタスクは登録されていました。
バッチ削除を試してみる
最後にバッチ削除です。先程 created_tasks.json に書き出したタスク200件を、tasks:batchDelete の1リクエストで削除してみます。このスクリプトもオペレーションの完了を待って表示するだけで、metadata.state や failedRequests による成否の判定はしていません。
import json
import time
import requests
from common import BASE_URL, auth_headers
headers = auth_headers()
with open("created_tasks.json") as f:
names = json.load(f)
res = requests.post(f"{BASE_URL}/tasks:batchDelete", headers=headers, json={"names": names})
res.raise_for_status()
operation = res.json()
op_url = f"https://cloudtasks.googleapis.com/v2/{operation['name']}"
while not operation.get("done"):
time.sleep(1)
operation = requests.get(op_url, headers=headers).json()
print(operation)
$ python batch_delete.py
{'name': 'projects/{プロジェクトID}/locations/asia-northeast1/operations/9ab70545-ddfa-45e5-8b84-1d8978e402c8', 'metadata': {'@type': 'type.googleapis.com/google.cloud.tasks.v2.BatchDeleteTasksMetadata', 'startTime': '2026-10-10T19:15:39.233324648Z', 'endTime': '2026-10-10T19:15:39.291555170Z', 'state': 'SUCCEEDED'}, 'done': True, 'response': {'@type': 'type.googleapis.com/google.protobuf.Empty'}}
$ gcloud tasks list --queue=test-retry-queue --location=asia-northeast1
TASK_NAME TYPE CREATE_TIME SCHEDULE_TIME DISPATCH_ATTEMPTS RESPONSE_ATTEMPTS LAST_ATTEMPT_STATUS
51a9f38682364f289deb8194a8976619 http 2026-10-10T19:15:08Z 2026-10-10T20:15:07.229857Z 0 0 Unknown
949eb0dc70bd492eb77dcd0483e93126 http 2026-10-10T19:15:07Z 2026-10-10T20:15:07.229857Z 0 0 Unknown
retry-default http 2026-10-10T19:13:08Z 2026-10-10T19:15:41.332400Z 4 0 INTERNAL(13): HTTP status code 500
200件が1リクエストで削除され、created_tasks.json に含めていない3件だけが残りました。なお RESPONSE_ATTEMPTS は、500の応答を受けていても0と表示されています。Cloud Loggingのログの responseCount も0でした。
存在しないタスクを混ぜた場合も試してみました。試行回数の上限に達してキューから消えた retry-override と、残っている 949eb0dc... を names に入れて削除すると、次のようになりました。
200 {..., 'metadata': {..., 'state': 'PARTIALLY_SUCCEEDED', 'failedRequests': {'0': {'code': 5, 'message': 'Requested entity was not found.'}}}, 'done': True, ...}
HTTPのステータスは200で、failedRequests には names の中の位置(0始まり)をキーにして、失敗したタスクが入っています。残っていた 949eb0dc... は削除されていました。
このほか、今回試した範囲では次のような結果になりました。
- 指定したタスクが1件も存在しない場合は、HTTP 200のままオペレーションに
error(code: 10、None of the requests succeeded)が付く - 1,001件を渡すと、リクエスト全体が400になる
まとめ
Cloud Tasksでタスク単位のリトライ設定とタスクのバッチ作成・バッチ削除がGAになったので試してみました。リトライの方針が違うタスクを1つのキューで扱えるようになり、多数のタスクの登録や削除もまとめて行えます。タスクに retryConfig を付ける場合は、変えない項目も含めて指定しておくとよさそうです。
最後まで読んで頂いてありがとうございました。











