Cloud Tasks にもローカルエミュレータが欲しかったので作った
Pub/Sub にはあるのに、Cloud Tasks には無い
Google Cloud をローカルで開発していると、当たり前のようにエミュレータを起動する。Firestore も Datastore も Bigtable も、そして Pub/Sub も。
gcloud beta emulators pubsub start
export PUBSUB_EMULATOR_HOST=localhost:8085
これだけで、本番に一切触れずにローカルで完結する。クライアントライブラリが PUBSUB_EMULATOR_HOST を勝手に見て、insecure・無認証でそのアドレスに繋いでくれる。最高だ。
ところが Cloud Tasks には公式エミュレータが無い。
非同期ジョブを Cloud Tasks に投げて HTTP ハンドラで処理する、というよくある構成。これをローカルやCIでテストしようとすると、急に手段が無くなる。本番のキューに繋ぐわけにもいかず、かといって自前でキューの挙動をモックすると、リトライやバックオフやレート制限といった「Cloud Tasks が本来やってくれること」が全部抜け落ちる。
無いなら作るしかない。というわけで、Pub/Sub エミュレータの思想にならって in-memory な Cloud Tasks エミュレータ を書いた。
まず使い方
公式の Cloud Tasks クライアント(Go / Python / Node.js / Java …)を改変なしでそのまま繋げる。タスクが期限を迎えると、エミュレータが実際にあなたの HTTP エンドポイントへリクエストを飛ばし、キューのリトライ/バックオフ/レート制限ポリシーを本番同様に適用する。
# go install
go install github.com/ken109/cloud-tasks-emulator@latest
cloud-tasks-emulator -host localhost -port 8123
# もしくは ghcr のマルチarchイメージ (amd64 + arm64)
docker run --rm -p 8123:8123 ghcr.io/ken109/cloud-tasks-emulator:latest
クライアント側は、エンドポイントを明示して insecure gRPC で繋ぐだけ。
conn, _ := grpc.NewClient("localhost:8123",
grpc.WithTransportCredentials(insecure.NewCredentials()))
client, _ := cloudtasks.NewClient(ctx, option.WithGRPCConn(conn))
client.CreateTask(ctx, &taskspb.CreateTaskRequest{
Parent: queue.GetName(),
Task: &taskspb.Task{
MessageType: &taskspb.Task_HttpRequest{
HttpRequest: &taskspb.HttpRequest{
Url: "http://localhost:8080/handle",
HttpMethod: taskspb.HttpMethod_POST,
Body: []byte(`{"hello":"world"}`),
},
},
},
})
これで http://localhost:8080/handle に、本物の Cloud Tasks が送ってくるのと同じヘッダー付きで POST が飛ぶ。
Pub/Sub エミュレータと違う、地味だけど重要な点
ここで「Pub/Sub と同じく env で繋がるんでしょ?」と思うと、ハマる。
Pub/Sub クライアントは PUBSUB_EMULATOR_HOST を内部で自動検出する(Firestore/Datastore も *_EMULATOR_HOST 規約に従う)。だから環境変数を立てるだけで統合が終わる。
Cloud Tasks のクライアントライブラリには、この env 規約が存在しない。 cloudtasks.NewClient(ctx) は常に本番エンドポイントを向く。なので上のように option.WithGRPCConn で insecure 接続を明示的に渡す必要がある。
この「自動検出が無い」という一点が、Cloud Tasks をローカルで扱うときの最初のつまずきポイントになっている。エミュレータ側でどうにかできる問題ではなく、クライアントの仕様なので、繋ぎ方をドキュメントで明示するようにした。
設計: 共有コア + 薄いアダプタ
Cloud Tasks の gRPC API には google.cloud.tasks.v2(安定版)と google.cloud.tasks.v2beta3 の2つの面がある。両方サポートしたかったが、2つのプロトコルぶんロジックを書くのは地獄だ。
そこでアーキテクチャをこうした。
┌─────────────────┐ ┌─────────────────────┐
v2 client → │ v2 adapter │ │ v2beta3 adapter │ ← v2beta3 client
└────────┬────────┘ └──────────┬──────────┘
│ proto ⇄ neutral 変換 │
└───────────┬───────────┘
┌────────▼────────┐
│ core engine │ 全部の挙動と状態
│ CRUD / 検証 / │
│ スケジューリング /│
│ リトライ / 配信 │
└─────────────────┘
- core … バージョン非依存の中立な型とエンジン。キューやタスクの状態、スケジューリング、リトライ、バックオフ、レート制限、HTTP 配信、ヘッダー生成といった「振る舞い」を全部ここに集約する。
-
adapter (
v2.go/v2beta3.go) … 各 protobuf の面を core の中立型へ/から変換するだけの薄い翻訳層。ロジックは持たない。
新しい挙動は core に足す。アダプタは純粋な翻訳に留める。これで両バージョンが1つのエンジンから供給され、片方だけ挙動がズレる事故が起きない。v2beta3 固有のキューレベル HTTP override や PULL キューといった差分も、中立型に吸収させてアダプタで出し分けている。
「本番と同じ」をどこまで詰めたか
エミュレータの価値は忠実度に尽きる。ローカルで通ったのに本番で挙動が違ったら意味がない。なので Cloud Tasks のドキュメントを読み込んで、地味な仕様まで再現した。いくつか面白かったところを挙げる。
リトライのバックオフ
RetryConfig の指数バックオフは、ドキュメントに載っている挙動を正確に追った。間隔は min_backoff から始まって max_doublings 回ぶん倍々になり、その後は 2^max_doublings × min_backoff ずつ線形に増え、max_backoff で頭打ちになる。
ドキュメントの例(min_backoff=10s, max_doublings=3)だと、間隔は
10s, 20s, 40s, 80s, 160s, 240s, 300s, 300s, ...
となる。80s までが倍々(10→20→40→80)で、そこからは 2^3 × 10 = 80s ずつ足され(80→160→240→320…)、max_backoff=300s で止まる。このドキュメント記載のシーケンスを回帰テストにピン留めして、壊れたら即気付くようにした。
余談: レビュー中に「バックオフが2倍大きい」という指摘が出たが、これは
max_doublings=4を前提にした誤りで、ドキュメントの例は3。実際にドキュメントの数列と突き合わせて、実装が正しいことを証明できた。テストにドキュメントの値を直書きしておくと、こういう議論が一発で片付く。
リトライを止める条件
「max_attempts か max_retry_duration のどちらかに達したら止める」と思いがちだが、ドキュメントを正確に読むと 両方の制約を満たすまで止まらない。max_attempts=-1 や max_retry_duration=0 は「無制限」を意味し、その軸では決して止まらない。ここは OR で実装してしまうと早く諦めすぎるので、ドキュメントどおり「制約している全ての軸が上限に達したときだけ停止」に直した。
リクエストヘッダー
本番の Cloud Tasks がターゲットへ送るヘッダーを、ターゲット種別ごとに正しいプレフィックスで再現している。
-
HTTP ターゲット:
X-CloudTasks-QueueName/-TaskName/-TaskRetryCount/-TaskExecutionCount/-TaskETA、リトライ時は-TaskPreviousResponseと-TaskRetryReason。User-Agent: Google-Cloud-Tasks。 -
App Engine ターゲット: 同じ項目を
X-AppEngine-プレフィックスで、さらにX-AppEngine-FailFast: false。User-Agent: AppEngine-Google; (+http://code.google.com/appengine)。
地味だが、TaskRetryCount は dispatch回数 - 1、TaskExecutionCount は「ハンドラからレスポンスを受け取った回数」だけ増える(接続自体が失敗したら増えない)、TaskETA は 秒.マイクロ秒 フォーマット、といった細部まで合わせてある。
リダイレクトは追わない
本番同様、3xx は「配信失敗」として扱う。http.Client.CheckRedirect で http.ErrUseLastResponse を返して、リダイレクトを追わないようにしている。2xx だけが成功、それ以外はリトライ対象。
OIDC トークンだけは「原理的に」再現できない
正直に書いておくと、ここだけは完全再現が不可能だ。
本番の Cloud Tasks は OIDC トークンを Google の秘密鍵で署名して付与する。受信側(Cloud Run や IAP)は Google の公開鍵で署名を検証する。エミュレータは Google の秘密鍵を持っていないので、本物の署名トークンは絶対に作れない。
なので、エミュレータが吐くのは alg=none の無署名 JWT だ。設定したサービスアカウントの email と audience はクレームとして載るので「トークンが正しく付与され、audience が合っているか」はローカルで検証できる。でも署名検証までは通らない。
これは Pub/Sub エミュレータも同じ割り切りで、そもそも認証をエミュレートしない。エミュレータなんだから、ローカルの受信側は認証検証をスキップする前提、という思想だ。Google のエミュレータ群に揃えて、ここは無理に寄せず素直に「無署名」とした。
どうしてもローカルで署名検証まで通したいなら、エミュレータ自身が鍵を持って RS256 署名し JWKS を公開する手はある(Firebase Auth エミュレータ方式)。ただし Pub/Sub エミュレータがやっていない上乗せ機能になるので、今は入れていない。
テストでの使い方
どの言語でも: Testcontainers
一番ポータブルなのは、公開イメージを Testcontainers で立ち上げる方法。コンテナを起動し、マッピングされたポートを読み、公式クライアントを insecure gRPC で繋ぐ。Java / Go / Python / Node.js / .NET / Rust などにモジュールがあるので、スタックを問わず同じ手順でいける。
ctr, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "ghcr.io/ken109/cloud-tasks-emulator:latest",
Cmd: []string{"-host", "0.0.0.0", "-port", "8123"},
ExposedPorts: []string{"8123/tcp"},
WaitingFor: wait.ForListeningPort("8123/tcp"),
},
Started: true,
})
endpoint, _ := ctr.PortEndpoint(ctx, "8123/tcp", "")
// endpoint へ insecure gRPC で繋いで公式クライアントを使う
Go なら: プロセス内に埋め込む
すでに Go なら、Docker すら要らない。プロセス内でエミュレータを起動できる。Register が v2 と v2beta3 の両方を一度に配線する。
lis, _ := net.Listen("tcp", "127.0.0.1:0")
gs := grpc.NewServer()
emulator.New(emulator.Config{}).Register(gs) // v2 + v2beta3
go gs.Serve(lis)
defer gs.Stop()
conn, _ := grpc.NewClient(lis.Addr().String(),
grpc.WithTransportCredentials(insecure.NewCredentials()))
client, _ := cloudtasks.NewClient(ctx, option.WithGRPCConn(conn))
emulator.Config で DefaultAppEngineHost / TaskTTL / TombstoneTTL を調整できる。テストではライフサイクルを短くしておくと回しやすい。
スコープと限界
正直なところを最後に。
- 実装しているのは公式 Go クライアントが公開する
v2/v2beta3の面。BufferTaskや pull-lease 系(LeaseTasks/AcknowledgeTask、これはv2beta2)は対象外。PULLキューやPullMessageは保存はするがリース不可。 - IAM ポリシーは保存・返却するが、強制はしない(Pub/Sub エミュレータと同じ)。
- 状態は全て in-memory。再起動で消える。
- OIDC は前述のとおり無署名、OAuth はプレースホルダ。
- App Engine の 503「配信ペース調整」は再現していない。
このあたりはローカル開発・テスト用途としては割り切れる範囲だと思っている。
おわりに
「Pub/Sub にはあるのに Cloud Tasks には無い」という小さな不便から始まって、ドキュメントを読み込んでリトライ・バックオフ・ヘッダーまで詰めたら、思ったより本番に近いものになった。テストは100%ステートメントカバレッジを CI で強制している。
ローカルやCIで Cloud Tasks を使うテストを書きたい人の役に立てば嬉しい。
Discussion