
Cloud Run and GKE Autopilot are both reasonable places to run an ADK agent, and that’s exactly why I wanted to put them side by side rather than take either one on faith. Cloud Run is the serverless option: point it at a container and it scales with request volume without you touching a node, a cluster, or a networking layer. For an agent that spends most of its life idle and occasionally handles a burst of requests, that’s a genuinely good fit. GKE Autopilot is the other end of the same idea: still hands-off on node management, Google runs the control plane and provisions capacity for you, but it’s still Kubernetes underneath, which means the platform-level controls (RuntimeClasses, custom networking, GPU node pools) that Cloud Run doesn’t expose are still there if an agent workload actually needs them.
Both are plausible defaults, and I hadn’t actually run one against the other before, so I built the same ADK agent container and deployed it unchanged to both. Same image, cold starts measured on both across repeated runs, one real load test pattern rather than the three I’d originally planned (I’ll explain that scope cut honestly further down). Nothing here is invented, rounded, or smoothed over. This is what actually happened when I ran it.
Architecture

Figure 1: Same image, same destination API, different identity path. The Workload Identity binding on the GKE side is not optional, and nothing on the Cloud Run side needed it.
One container image, built once, pushed to Artifact Registry, deployed unchanged to both platforms:
- Cloud Run: min-instances=0, autoscaling, default Compute service account, already holding roles/aiplatform.user.
- GKE Autopilot: same image, a Deployment I manually scaled between 0 and 1 replicas to simulate idle/cold-start behavior (Autopilot has no native scale-to-zero; I'll say that plainly here and again later, because it matters), plus a RuntimeClass attaching gVisor sandboxing, which is really the whole reason this build exists.
Both were deliberately configured to sit at zero when idle, not because that’s how you’d necessarily run either in production, but because a cold-start comparison only means anything if both platforms are actually starting from cold. min-instances=0 on Cloud Run and scaling the GKE Deployment down to zero replicas are the two mechanisms that make that possible, and they're the reason the numbers in the Cold-Start Comparison section exist at all.
Walkthrough
Deployed side by side, so the equivalent step on each platform sits next to its counterpart rather than buried in two separate sections.
Shared: one image, one Artifact Registry repo
gcloud builds submit --tag europe-west2-docker.pkg.dev/PROJECT_ID/adk-agent-repo/adk-agent:latest

Figure 2: One image, one repo, deployed unchanged to both platforms below.
Cloud Run
gcloud run deploy adk-agent-cloudrun \
--image=europe-west2-docker.pkg.dev/PROJECT_ID/adk-agent-repo/adk-agent:latest \
--region=europe-west2 \
--min-instances=0 \
--max-instances=3

Figure 3: Deployed, scale-to-zero by default, no identity configuration step required.
No identity step here, none. The service ran as the default Compute service account, which already held roles/aiplatform.user from earlier testing, and calls to Vertex AI just worked.
GKE Autopilot
gcloud container clusters create-auto adk-agent-cluster \
--region=europe-west2

Figure 4a: Cluster provisioned.

Figure 4b: Confirmed running in Autopilot mode.

Figure 5: Service endpoint live, deployment deliberately scaled to zero: the manual stand-in for scale-to-zero used throughout this build.
The Workload Identity gap
This is the bit I want to slow down on, because it’s the clearest evidence for the whole point of this article. Every single request to Vertex AI from the GKE pod failed:
403 PermissionDenied: aiplatform.endpoints.predict
Worth checking rather than assuming: confirm roles/aiplatform.user is actually bound to the Compute service account before going further.
gcloud projects get-iam-policy PROJECT_ID \
--flatten="bindings[].members" \
--filter="bindings.role:roles/aiplatform.user" \
--format="table(bindings.members)"
It was. The Compute service account already had roles/aiplatform.user, the exact identity Cloud Run had just used successfully. GKE Autopilot enforces Workload Identity by default, and a pod does not automatically inherit the node's service account permissions the way Cloud Run's runtime does. Fixing it took three explicit steps, none of which have a Cloud Run equivalent:
kubectl create serviceaccount adk-agent-ksa
gcloud iam service-accounts add-iam-policy-binding \
PROJECT_ID-compute@developer.gserviceaccount.com \
--role="roles/iam.workloadIdentityUser" \
--member="serviceAccount:PROJECT_ID.svc.id.goog[default/adk-agent-ksa]"
kubectl annotate serviceaccount adk-agent-ksa \
iam.gke.io/gcp-service-account=PROJECT_ID-compute@developer.gserviceaccount.com
Then serviceAccountName: adk-agent-ksa on the pod spec, and the same identity Cloud Run got for free finally worked on GKE too. This is a real, non-trivial cost of choosing GKE Autopilot. [¹]
Cold-Start Comparison
Cloud Run, 4 runs, min-instances=0, 5 to 10 minutes idle between each:


Figure 6: Real cold-start timings, unedited. The variance is part of the finding.
That spread is a real finding, and I’m not going to smooth it into an average and move on. 2.83s to 20.32s across cold starts that should be identical is wide. The idle gaps between runs weren’t perfectly consistent, and I didn’t isolate Vertex AI’s own API-side latency from container cold-start time for these specific runs, so I can’t attribute the spread precisely. Some of it might be Gemini Flash’s own latency at Google’s end, not Cloud Run’s container startup at all.
GKE Autopilot, pod scale 0 to 1, timed via kubectl wait, then a timed request once ready:


Figure 7: Scale-from-zero timed across four runs, verified against a real Vertex AI response.
Worth explaining why there’s no dramatic first run outlier here. Early in this build, a Workload Identity permissions gap meant every request to the GKE deployment was failing with a 403, including my first cold-start pass, which returned fast because it was failing fast, not completing a real call. Once the identity issue was fixed and I reran the test against a confirmed working endpoint, the numbers below are what I got: no fresh-node spike, likely because the node was already warm from earlier testing by that point, not because the effect doesn’t exist.
**GKE doesn’t scale to zero natively.** Every number in the GKE table above comes from me manually scaling a `Deployment` between 0 and 1 replicas with `kubectl scale`, standing in for the zero-infrastructure-cost idle state that Cloud Run’s `min-instances=0` gives you as a first-class primitive. Autopilot still bills for cluster management regardless, and a real production deployment would more likely run a small non-zero minimum than actually scale pods to zero.[²]
A real Gemini Flash call through the GKE deployment, checked separately, came back with model_call_seconds: 2.278 and the response "Hello, nice to meet you." So these are genuine successful calls through a working identity chain, not fast failures dressed up as timings.
Load Test
One traffic pattern, not the three I’d originally planned, and I want to name that as a deliberate call rather than let it slide by as an unmentioned scope cut. Steady-moderate load, roughly 240 requests over about two minutes, run separately against each platform rather than at the same time. This isn’t a head-to-head resource-contention test, just each platform’s own behavior under the same shape of traffic.

Figure 8: Same load shape, two different metrics: Cloud Run’s own request-rate curve against GKE’s container CPU-time curve.
Cloud Run’s request-count metric ramps cleanly to roughly 2 requests/second, peaks sharply, then drops off as the burst ends. GKE’s CPU usage time metric tells the same story structurally: ramp, a sustained plateau through the load window, then the same drop-off, just measured as CPU-time rather than a request rate, since that’s what the console actually gave me for this metric. I’m reading the shape off the chart here rather than quoting an exact peak figure, since I don’t have a precise value pulled from the underlying data, only the rendered graph.
Cost: I’m deliberately not quoting a session total here. A short, low-volume test session doesn’t represent what either platform actually costs to run at real traffic. If cost is the deciding factor for your workload, model it against your own traffic pattern and Vertex AI call volume in a production-like environment rather than against anything in this article.
The gVisor Example
This is where GKE Autopilot’s extra complexity stops being overhead and turns into something you’d actually reach for.
# deployment.yaml (relevant excerpt)
spec:
template:
spec:
runtimeClassName: gvisor
serviceAccountName: adk-agent-ksa
kubectl apply -f deployment.yaml
kubectl get pod -l app=adk-agent -o jsonpath='{.items[0].spec.runtimeClassName}'

Figure 9: Confirmed from the live pod spec, not the manifest. The pod is actually running under gVisor.
That’s the pod’s live spec talking, not the YAML I wrote, proof it’s actually running under gVisor’s sandboxed runtime, not just asking nicely for one. GKE Autopilot lets you attach a RuntimeClass and get syscall-level sandbox isolation on a per-workload basis, chosen deliberately rather than inherited by default. Cloud Run isn't unsandboxed by any stretch (its own execution environment already runs on gVisor), but it doesn't give you this specific lever: the ability to pick and attach it, per workload, on purpose. [³]
For an ADK agent, that lever matters most in one specific case: a workload executing generated code, handling genuinely untrusted input, or otherwise needing a harder isolation boundary than the platform default gives you automatically. That’s the real, specific reason to reach for GKE Autopilot here, not some vague sense that more control is always better.
My Actual Recommendation
Default to Cloud Run. The evidence from this build backs it up plainly: cold starts were broadly comparable between the two platforms once the corrected GKE data reflected real calls rather than fast failures, the identity story needed zero extra configuration, and scale-to-zero is a real primitive rather than a kubectl scale script standing in for one. For the request-response shape most ADK agents actually have, GKE Autopilot's extra machinery just sits there unused.
Move to GKE Autopilot specifically when you need gVisor-level sandboxing or dedicated GPU node pools: node-level controls Cloud Run genuinely doesn’t expose. Go in knowing about the Workload Identity step ahead of time: it’s not optional, it produces a 403 with zero obvious connection to its actual cause if you haven’t been warned. That’s the honest price of the extra control, weighed against what that control actually buys you
Limitations
Cloud Run’s cold-start variance isn’t fully explained, and I’d rather say so than round it away. 2.83s to 20.32s across runs that should be identical is a real spread, but I didn’t isolate container startup time from Vertex AI’s own API-side latency for these specific runs. A cleaner follow-up would instrument `model_call_seconds` on the Cloud Run side the way I did for one GKE call, to actually know how much of that spread is Cloud Run and how much is Gemini Flash itself.
GKE’s scale-to-zero is simulated, not native. Every GKE cold-start number came from manually scaling a Deployment with `kubectl scale`, not a platform-level scale-to-zero primitive. Autopilot doesn’t have one the way Cloud Run does.
Only one traffic pattern got load-tested: steady-moderate, roughly 240 requests over two minutes, run separately against each platform rather than concurrently. The original plan covered three patterns (bursty/idle, steady-low, steady-moderate); this is a deliberate scope cut, not something I’m pretending didn’t happen.
Cost wasn’t rigorously measured, and I’m not pretending otherwise. Validate cost against your own production traffic pattern and Vertex AI call volume before treating either platform as the cheaper option. A short test session isn’t a substitute for that.
A short test session isn’t sustained production load. A handful of cold-start runs and one two-minute load test give an honest snapshot of this specific workload’s behavior on the day I ran it, not evidence of how either platform holds up under real, variable production traffic over days or weeks.
Key Takeaways
– Cloud Run is the right default for most ADK agents. Broadly comparable cold starts to GKE in the common case, zero identity configuration, and genuine scale-to-zero.
– GKE Autopilot enforces Workload Identity by default, and it caught me out. A pod doesn’t inherit the node’s service account permissions the way Cloud Run’s runtime does. Budget time for this if you choose GKE.
– The actual reason to choose GKE Autopilot here is gVisor-level sandboxing or dedicated GPU node pools, not a general sense that more control is better. Pick it for a specific, named requirement Cloud Run doesn’t expose.
Originally published at https://danhenderson.cloud on July 31, 2026.
[¹]: Google Cloud. *Use Workload Identity Federation for GKE*. https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity
[²]: Google Cloud. *GKE pricing*. https://cloud.google.com/kubernetes-engine/pricing
[³]: Google Cloud. *About GKE Sandbox (gVisor)*. https://cloud.google.com/kubernetes-engine/docs/concepts/sandbox-pods
Cloud Run vs. GKE Autopilot: A Comparison for ADK Agents was originally published in Google Cloud – Community on Medium, where people are continuing the conversation by highlighting and responding to this story.
Source Credit: https://medium.com/google-cloud/cloud-run-vs-gke-autopilot-a-comparison-for-adk-agents-2295c2061571?source=rss—-e52cf94d98af—4
