📮

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 エミュレータ を書いた。

https://github.com/ken109/cloud-tasks-emulator

まず使い方

公式の 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_attemptsmax_retry_duration のどちらかに達したら止める」と思いがちだが、ドキュメントを正確に読むと 両方の制約を満たすまで止まらないmax_attempts=-1max_retry_duration=0 は「無制限」を意味し、その軸では決して止まらない。ここは OR で実装してしまうと早く諦めすぎるので、ドキュメントどおり「制約している全ての軸が上限に達したときだけ停止」に直した。

リクエストヘッダー

本番の Cloud Tasks がターゲットへ送るヘッダーを、ターゲット種別ごとに正しいプレフィックスで再現している。

  • HTTP ターゲット: X-CloudTasks-QueueName / -TaskName / -TaskRetryCount / -TaskExecutionCount / -TaskETA、リトライ時は -TaskPreviousResponse-TaskRetryReasonUser-Agent: Google-Cloud-Tasks
  • App Engine ターゲット: 同じ項目を X-AppEngine- プレフィックスで、さらに X-AppEngine-FailFast: falseUser-Agent: AppEngine-Google; (+http://code.google.com/appengine)

地味だが、TaskRetryCountdispatch回数 - 1TaskExecutionCount は「ハンドラからレスポンスを受け取った回数」だけ増える(接続自体が失敗したら増えない)、TaskETA秒.マイクロ秒 フォーマット、といった細部まで合わせてある。

リダイレクトは追わない

本番同様、3xx は「配信失敗」として扱う。http.Client.CheckRedirecthttp.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.ConfigDefaultAppEngineHost / 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 を使うテストを書きたい人の役に立てば嬉しい。

https://github.com/ken109/cloud-tasks-emulator

GitHubで編集を提案

Discussion