はじめに
こんにちは、tacomsでエンジニアをしている高橋です。
飲食チェーン向けに「メニューをデリバリープラットフォームへ一斉配信する」プロダクトMenu Managerの開発・運用に携わっています。
ある大手飲食チェーンの利用開始時に、1回の配信中にプラットフォーム側のレート制限に起因する配信エラーが大量に発生してしまったので、このタイミングで配信基盤のアーキテクチャを再設計しました。
前提として、この配信の構造を少し説明します。デリバリープラットフォーム各社の API では、ほとんどがメニューは店舗ごとに登録する仕様になっています。チェーンでは全店ほぼ同じメニューを使うため送る内容(ペイロード)はほとんど共通なのですが、それでも「A店のプラットフォームXのメニュー」「B店のプラットフォームXのメニュー」…と、店舗 × プラットフォームの数だけ API を呼んで反映する必要があります。本記事ではこの配信単位をチャンネルと呼んでいます(例: 100店舗 × 3プラットフォームに展開しているチェーンなら 300 チャンネル)。

1回のメニュー改定で、ほぼ同じ内容のリクエストを店舗数ぶん送ることになる——これが後述するレート制限問題の土台です。
チェーンの本部がメニュー改定をすると、全店舗・全プラットフォームへ一斉に反映する必要があります。価格改定や新商品の発売日は事前に決まっているため、メニューは前もって作成しておき、指定時刻に全店一斉で切り替えなければなりません。 この基盤は「予約した時刻に全チャンネルへメニューを届ける」役割を担っています。
このシステムには特徴的な性質が3つあります。
- 稼働が偏っている: メニュー改定は月の特定タイミングに集中する。動くときは1回で千数百〜数千チャンネルを配信するが、それ以外の時間はほぼ何もしていない
- 秒単位の同時性は要らない: 外部 API のレート制限がある以上、全チャンネルをまったく同じ瞬間に更新することはそもそもできない。店舗が開店する前に配信が完了していればいいので、ある程度のコールドスタート(停止状態からの起動待ち)も許容できる
- 使っていない時間のコストは最小にしたい: 稼働が偏っている分、アイドル時間の定常コストは限りなくゼロにしたい
本記事では、大規模配信で外部 API のレート制限の問題に直面したことを機に、この基盤を Pub/Sub(push サブスクリプション)+ Cloud Run Service + Cloud Run Jobs の構成から Cloud Tasks + Cloud Run Service に置き換えた設計を紹介します。
Before: ポーリングベースの構成
当初の構成要素は Pub/Sub(push 配信)+ Cloud Run Service(配信ワーカー)+ Cloud Run Jobs(定期ジョブ2本)で、3つの層がそれぞれ定期ポーリングで動いていました。Cloud Run Service はリクエストを受けて動く常駐型、Cloud Run Jobs は実行して終わるバッチ型(AWS でいう Fargate service と Fargate task の関係に近い)です。

- 起動: ジョブが6分間隔で DB をスキャンし、時刻が来た予約を起動する(Cloud Run Jobs + Cloud Scheduler)
- fan-out(1件の予約を多数のチャンネルに展開すること): 展開した全チャンネルを Pub/Sub へ一斉 publish。push サブスクリプションなので、メッセージは Pub/Sub 側から購読先(配信ワーカー)へ HTTP POST で押し込まれる
- 完了集約: 別のジョブが5分間隔で全配信の完了/失敗を確認し、予約全体のステータスを確定
配信ゼロの日も含めて、定期起動は合計 528回/日(6分毎の240回 + 5分毎の288回)。当時の規模では、これで問題なく動いていました。
大規模配信で顕在化したレート制限エラー
大手飲食チェーンの利用開始で配信規模がそれまでの数倍になり、1回の配信が千数百チャンネルに達したタイミングで、大規模な配信失敗が発生しました。
数分間でレート制限エラー(HTTP 429 Too Many Requests)が数千件発生し、配信対象の大半のチャンネルに影響。 リトライ回数も使い切り、一部のチャンネルは配信失敗として終わった。
何が起きていたのかを時系列にすると、こうなります。

調査の結果、このレート制限は店舗単位のものではなく、アプリケーション全体(全チャンネル合算)に効くグローバルなレート制限で、その閾値は公開ドキュメントには現れないものでした。いつ再試行すべきかの手掛かり(Retry-After)も得られず、待ち方は自分たちで設計するしかありません。翌日の配信でも再発し、恒常的な問題であることが分かりました。
原因: 同時実行数ではレートを制御できない
当時も無制御だったわけではなく、同時リクエスト数は絞っていました。Cloud Run は(Lambda のような1実行=1リクエストではなく)1インスタンスが複数リクエストを同時に処理するモデルなので、総同時実行数は「max_instances × concurrency(インスタンス数 × 1インスタンスあたりの並行数)」で決まります。当時はこれで流量を絞っていました。
しかしこの方式には構造的な問題が2つあります。
1. 同時実行数は req/s ではない。 1リクエストの処理が速いほど、同じ同時実行数でも秒間リクエスト数は増えます。「同時50」に絞っても、処理が数百msで返るなら秒間数百リクエストが外部に飛びます。(クライアント側で並列リクエストを N 本に制限する場合も同じです。同時数の上限は流量の上限ではありません)

2. あふれた分はエラーになる。 Cloud Run はインスタンス数の上限に達すると、受けられないリクエストを最大30秒待たせた後 429 で弾きます(前述の 429 は配信先 API が返したものですが、こちらは Cloud Run 自身が返す 429 です)。さらに急スパイク時には、上限に達していなくてもインスタンスの起動が追いつかず 500 が返ることがあります(いずれも公式ドキュメント: Troubleshoot Cloud Run issues・About maximum instances に記載の挙動です)。実際、大規模配信時にはこの 500 で処理記録すら残らない失敗も起きました。
さらに Pub/Sub のリトライ設定が minimum_backoff = maximum_backoff = 60s になっており、指数バックオフ(失敗のたびに再試行間隔を延ばす仕組み)が実質無効化されていました。時系列図の通り、失敗した数百チャンネルが60秒ごとに同時に再試行しては、毎回まとめてレート制限に衝突する、を繰り返し続けていたのです。
どこで絞っても、あふれた分がエラーになる。必要なのは「捨てずに待たせる」仕組みでした。
再設計: ポーリング駆動からタスクキュー駆動へ
検討の軸は「既存スタックの延長で解く」です。Cloud Tasks は当時すでにシステムの別機能で運用実績があり、新しいミドルウェアを増やさずに賄えます。アプリ層レートリミッター(インスタンス間のレート共有に Redis が要る)や Kafka(チーム規模に過剰)は採用しませんでした。
Before の3層(起動 / fan-out / 完了集約)を、それぞれ Cloud Tasks の仕組みで置き換えました。

移行前後の全体像を並べるとこうなります(Before は再掲)。


1. レート制御: queue に req/s を宣言する
fan-out 先を Pub/Sub からプラットフォーム別の Cloud Tasks queue に変えました。queue の max_dispatches_per_second が、queue からワーカーへのタスク配信(dispatch)レート= req/s(1秒あたりのリクエスト数)を宣言的に表現します。
このレート制御はトークンバケット方式です。一定速度でトークンが補充される「バケツ」があり、タスクは1件送り出すごとにトークンを1つ消費します。トークンが無ければタスクは queue に溜まったまま待つ——だから、どれだけ一気に投入してもレートを超えません。

- あふれた分はエラーではなく「まだ送っていないタスク」として queue に残る——満杯時に弾くしかなかった Cloud Run とは対照的
- Before で問題だった固定60秒リトライも、queue の retry 設定(指数バックオフ)に置き換えた
- レート調整は queue 設定1行の変更で完結し、コード変更もデプロイも不要
2. 時刻指定タスクでポーリングを置き換える
Cloud Tasks はレート制御だけでなく、「未来の時刻に HTTP リクエストを1本予約する」用途にも使えます。
- 起動: 予約作成時に配信時刻を指定したタスクを1本積む。6分間隔のポーリングは廃止され、発火の遅れは最大6分から秒単位に(要件は分単位だったので、十分すぎる精度)
- 完了集約: fan-out 時に完了確認タスクを同時に予約し、全チャンネルの結果が出そろうまで Cloud Tasks の自動リトライで待つ
時刻を見張るプロセスを常駐させなくても、時刻になればリクエストが飛んでくる。ポーリングジョブ2本はコードごと削除しました。
3. ポーリング廃止後の安全網
定期見回りには「遅くてもいつかは拾う」という性質がありました。廃止する以上、代わりの安全網が要ります。移行に合わせて、次の3つを新設しました。

- 網① 完了チェック: fan-out 時に予約した確認タスクが「全チャンネル出そろったか」を判定し、未完なら自動リトライで待つ
- 網② 失敗の書き残し: リトライが尽きるタスクは最終試行で「失敗」を必ず記録する(Cloud Tasks には DLQ =処理しきれなかったタスクの退避先が無いため)
- 網③ 時間切れの確認: T+1h / T+2h に発火する最終確認タスクが強制的にステータスを確定する
なお、安全網③自身も Cloud Tasks のタスクなので同じ故障モードを持ちます。ここは queue の滞留メトリクスと失敗ログのアラートで監視しており、「番人の番人」は監視側が担います。
③も「未来の実行予約」で実装しており、定期実行はどこにも復活していません。副産物として、旧見回りが「未開始」を「失敗」と誤判定していた通知バグも解消できました。
コスト: アイドル時間の課金がゼロになった
課金の変化を、移行先・新規リソースも含めて同じ土俵で並べます。
| リソース | Before | After |
|---|---|---|
| Cloud Run Jobs(ポーリングジョブ2本) | 配信の有無に関わらず毎日課金 | 廃止 |
| Cloud Run Service(完了集約ハンドラ) | — | 配信がある日だけ僅かに課金 |
| Cloud Tasks(新規) | — | 無料枠内(月間のオペレーション数は枠の1割に満たない) |
(配信ワーカーは移行前後で共通・費用も同等のため比較から除外。Pub/Sub → Cloud Tasks の置換分はどちらも無料枠内です)
もともと金額として大きなコストではありません。得られたのは削減額そのものよりも、コストが配信量に比例するだけの構造になったことです。配信すればその分ワーカーの稼働費は掛かりますが、何もしていない時間の固定費はほぼゼロ——「使った分だけ払う」形が、「たまにまとめて動く」という利用特性と噛み合います。
移行後の運用: レート調整は設定変更のみ
移行後、障害時を上回る規模の配信で、再びレート制限エラーが少数発生しました。原因は「1 dispatch の中で外部 API を2回呼んでいる」こと。queue のレート ≠ API コールレートです。

対応は、レート予算を2コールで按分し直して設定値を1行変更するだけでした。コード変更もデプロイも不要です。発生分も全件リトライで自動回復しており、レート制御を宣言的にした効果がそのまま出た形です。
Cloud Tasks を使うときの注意点
「設定1行」の1行にも仕様があります。実際に運用して確認した・調べたものを挙げます。
- バケットの補充は「平均」レート。アイドル中にトークンが溜まるため、初動は設定レートを超えるバーストが出得る(
max_burst_sizeで抑制。既定は自動・最大500) - dispatch_deadline は既定10分。これを超えると「応答が無い」と見なされて再配信=二重実行になる。長い処理は明示的に延長する
- 配信は at-least-once(最低1回、まれに複数回届く配信保証)。deadline 超過に限らず、稀に同一タスクが2回配送され得る。今回の配信ワーカーは各社 API へ全量洗い替え(同じ内容を2回反映しても最終状態が変わらない冪等な操作)で反映するため実害がないが、副作用が冪等でないワークロードではここの設計が必須になる
- DLQ が無い。
max_attempts(リトライ上限回数)を超えたタスクはサイレント削除される(対処は前述の安全網②)
まとめ
今回の再設計は、突き詰めると Cloud Tasks の2つの使い方に集約されます。
① レートを宣言する queue として
max_dispatches_per_secondで req/s を宣言し、あふれた分はエラーではなく「まだ送っていないタスク」として待たせる- 同時実行数の制限では req/s は絞れない。レートは、レートを宣言できる場所で絞る
② 未来の実行予約として
- 「指定時刻に HTTP リクエストを1本予約する」だけで、時刻を見張る定期ジョブが要らなくなる
- 配信トリガも、完了確認も、時間切れの見張りも、すべてこの予約で実装した——定期実行はゼロに
この2つを、すでに運用実績のあるマネージドサービスの範囲で賄えたのが今回の再設計の肝でした。外部 API のレート制限が公開ドキュメントに無く Retry-After も返らない状況でも、設定1行でレートを調整できる形に寄せておけば運用で追随できます。
本記事が、同じように「たまにまとめて動く」ワークロードを扱う方の参考になれば幸いです。
付録: もし AWS でやるなら?
同じ設計を AWS に移植できるか、概念対応を調べてみました。
| Google Cloud(今回) | AWS でやるなら |
|---|---|
| Cloud Tasks — 時刻指定タスク(未来の実行予約) | EventBridge Scheduler(one-time + 完了後自動削除)✅ 発火粒度は1分 |
| Cloud Run — ゼロスケールの実行環境 | Lambda ✅ ※同時実行モデルは異なる(Lambda は1実行1リクエスト・最大15分) |
| Cloud Tasks — 遅延実行(+1h/+2h の確認) | EventBridge Scheduler(SQS delay は最大15分)✅ |
| Cloud Tasks — queue が req/s と同時実行数を独立に宣言して push | 制御の主体が逆 ⚠ SQS は pull 型で、絞れるのは消費側の同時実行数だけ |
差が出るのはレート制御の主体です。Cloud Tasks は queue 側(push 側)が req/s(max_dispatches_per_second)と同時実行数(max_concurrent_dispatches)を独立に宣言して呼び出します。一方 SQS + Lambda では、消費側の同時実行数(event source mapping の MaximumConcurrency、最小2)や、2025年に追加された Provisioned Mode(ポーラー数によるスループット制御)で絞ることになり、いずれも req/s そのものの宣言ではありません——つまり、今回移行元で課題になった「同時実行数で絞る」方式と同じ構図です。裏を返せば「Lambda の reserved concurrency で絞っているから大丈夫」という構成も、1実行が速ければ req/s は膨らむという同じ穴を持っています。
なお、AWS でも EventBridge API destinations であれば、呼び出しレート/秒(1〜300、引き上げ申請可)を宣言でき、超過分はキューイングされて最大24時間かけてレート内で配送されます。ターゲットが HTTP API 呼び出しに限られるなどの違いはありますが、Cloud Tasks のレート宣言に最も近い選択肢です。

























