aiml
SearchMy learning
Menu

Lesson 20 of 20Module 5

Deploy an ADK agent to Cloud Run

Serve an ADK agent on private Cloud Run, separate caller and runtime permissions, and inspect what happens to local sessions across a revision change.

Prerequisites

The API-server lesson, gcloud, jq, and an approved Cloud Run project with a runtime service account.

By the end, you can

  • Separate Cloud Run caller identity from the runtime account’s model permissions.
  • Propagate HTTP errors and isolate concurrent shell-client response files.
  • Explain why local sessions disappear across instance or revision replacement.
Runtime
Python 3.12+, gcloud and jq; Google Cloud access for the recorded integration
Dependencies
google-adk[gcp] 2.12.0; google-cloud-aiplatform 2.6.0

Lab code, tests and fixtures (ZIP, 16 KB)Quiz check your understanding

Progress is saved in this browser.

Cloud Run hosts the ADK API server as a container service. Here we inspect a private deployment of the docuctl docs assistant, call its session and /run endpoints with an identity token, and examine two failures: the runtime account could not call the model, and a local session disappeared after a new revision.

When you need this. Your application already calls an HTTP agent service and your team operates Cloud Run. You can keep that interface, but the caller’s permission to reach the service, the container’s permission to call the model, and the durability of its sessions are three separate decisions.

Recorded example. The archived “Deploy an ADK agent to Cloud Run” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Recorded revision change and missing sessionRecorded output (excerpt) · textFull output
 A new revision
$ gcloud run services update docs-helper --region us-central1 --project $GOOGLE_CLOUD_PROJECT --update-env-vars=DOCS_LOG_LEVEL=info
Done.
Service [docs-helper] revision [docs-helper-00002-gpw] has been deployed and is serving 100 percent of traffic.

# The session from before the update, on the new revision
$ SESSION=b9300104-e457-44b2-819c-4aef2e6d7e0e ./request.sh "And on staging?"
HTTP 404 in 1.399506 s
{
  "detail": "Session not found: b9300104-e457-44b2-819c-4aef2e6d7e0e"
}

# A new session on the same instance
$ ./request.sh "How do I undo the last production release? I run shipctl 2.x."
HTTP 200 in 0.074428 s
session 64e911d1-11c0-4852-830c-bcde5b41d74e
HTTP 200 in 6.269696 s
call   read_page {"page":"rollback"}
result read_page
answer To undo the last production release in shipctl 2.x, run:

```bash
shipctl rollback --env production
```

Omitting `--to` automatically rolls traffic back to the immediately preceding release.

Note: Database migrations are not rolled back automatically. If the release ran a migration, you must deploy a separate migration to reverse it.
data flowreturnartifact hand-offfailurecallerCloud Run IAMidentitytokenADK API serverget_fast_api_appRunner + LlmAgentacceptedGeminiglobalpredictcaller rejectedbefore agent executionno tokenmodel deniedrun failspermissionInMemorySessionServiceeventssession absent on new revisionrecorded request returned not foundreplacementdata flowreturnartifact hand-offfailurecallerCloud Run IAMidentity tokenADK API serverget_fast_api_appRunner + LlmAgentacceptedno tokenGeminiglobalInMemorySessionServicepredicteventsmodel deniedrun failssession lostnew revisionpermissionreplace
Cloud Run checks the caller token before the request reaches the ADK API server built by get_fast_api_app. The server creates a Runner for the docs_helper LlmAgent; its model call uses the container’s runtime account. The saved run first rejected the caller, then failed at the model call after accepting that caller. InMemorySessionService held the events inside the container; an old session was not found after a new revision.

Lab setup. Download the complete lab ZIP, extract it and open a terminal in adk-cloud-run/. Keep its code, tests and fixtures together. Create the pinned environment for local checks:

Create the lab environmentTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

The saved cloud results can be read without provisioning services. The cloud steps require the project access listed in the prerequisites.

1. Deploy a private API-server service

Deploy the agent directory with adk deploy cloud_run, passing Cloud Run flags after -- to set explicit resource limits, a dedicated runtime service account and --no-allow-unauthenticated:

Deploy the private Cloud Run serviceTerminal · bash
adk deploy cloud_run --project $GOOGLE_CLOUD_PROJECT --region us-central1 \
  --service_name docs-helper --app_name docs_helper docs_helper -- \
  --no-allow-unauthenticated \
  --service-account=docs-helper-run@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
  --min-instances=0 --max-instances=1 --cpu=1 --memory=512Mi \
  --labels=aiml-lesson=deploy-an-adk-agent-to-cloud-run

ADK copies the agent directory into the build context and generates a Dockerfile whose command starts adk api_server. The deployed process loads docs_helper.root_agent; it does not turn the agent into a separate Cloud Run service for each tool. docs_helper is both the application name passed to this deploy and the name used in the HTTP paths below. Changing the container port or instance count changes how the server is hosted, not the agent’s instruction or tools.

Recorded Cloud Run deploymentRecorded output (excerpt) · textFull output
$ adk deploy cloud_run --project $GOOGLE_CLOUD_PROJECT --region us-central1 --service_name docs-helper --app_name docs_helper docs_helper -- --no-allow-unauthenticated --service-account=docs-helper-run@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com --min-instances=0 --max-instances=1 --cpu=1 --memory=512Mi --labels=aiml-lesson=deploy-an-adk-agent-to-cloud-run
Start generating cloud_run source files in $TMPDIR/deploy_src/20261021_213948
Copying agent source code...
Copying agent source code completed.
Creating Dockerfile...
Creating Dockerfile complete: $TMPDIR/deploy_src/20261021_213948/Dockerfile
Deploying to cloud_run...
Building using Dockerfile and deploying container to Cloud Run service [docs-helper] in project [vertex-ai-experminent] region [us-central1]
Building and deploying new service...
Validating Service...done
Uploading sources...INFO: Not using ignore file.
INFO: Uploading [$TMPDIR/file.zip] to [run-sources-vertex-ai-experminent-us-central1/services/docs-helper/SOURCE.zip]
..done
Building Container...done
Creating Revision...done
Routing traffic.....done
Done.
Service [docs-helper] revision [docs-helper-00001-js6] has been deployed and is serving 100 percent of traffic.
Service URL: https://docs-helper-PROJECT_NUMBER.us-central1.run.app
INFO: Display format: "none"
Cleaning up the temp folder: $TMPDIR/deploy_src/20261021_213948

The source build creates an image and may create an upload bucket. Track those resources as well as the service. In the original run, the image lived in an existing repository and the upload bucket was new; cleanup removed only the resources recorded for that exercise. Do not delete a shared repository or upload bucket simply because a lesson command mentions its name.

2. Separate the caller from the runtime account

Without an identity token, the private service returned HTTP 403 before the agent ran. With the caller accepted, session creation succeeded, but the model request failed because the runtime service account lacked the model prediction permission. These are different requests under different principals.

Recorded caller and runtime permission failuresRecorded output (excerpt) · textFull output
# 1. No identity token
$ curl -s -o /dev/null -w "HTTP %{http_code}\n" $SERVICE_URL/list-apps
HTTP 403

# 2. The runtime service account has no Agent Platform role yet
$ ./request.sh "I run shipctl 1.x. How do I undo release r42 in production?"
HTTP 200 in 0.119380 s
session 4dfba80e-3d99-4762-a061-f5d2ae2c5d54
HTTP 500 in 3.082162 s
Internal Server Error

# The service log for that 500
$ gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="docs-helper" AND textPayload:"PERMISSION_DENIED"' --project $GOOGLE_CLOUD_PROJECT --freshness=15m --limit 1
google.genai.errors.ClientError: 403 PERMISSION_DENIED. {'error': {'code': 403, 'message': "Permission 'aiplatform.endpoints.predict' denied on resource '//aiplatform.googleapis.com/projects/PROJECT_ID/locations/global/publishers/google/models/gemini-3.8-flash' (or it may not exist). Remediate access with this Trouble
...

The recorded exercise granted roles/aiplatform.user to its lesson-only runtime account. That broad project role is the configuration used for this run, not a claim of minimum privilege. The first retries still failed while the change propagated. Treat a permission failure as a configuration problem; repeatedly retrying a model call does not supply the missing permission.

The caller’s identity token is for the Cloud Run service audience. The runtime account’s credentials are for its outbound calls, including the Gemini request through Vertex AI. Granting the caller permission to invoke Cloud Run does not grant the runtime account model access. Use the account approved for this agent and check its outbound permissions separately.

3. Create a session and call the agent

request.sh asks gcloud for an identity token, creates a session when SESSION is absent, and sends the message to /run. It allocates a per-process temporary response file and exits nonzero after a failed session creation or failed run:

request.shSource · bashDownload
#!/usr/bin/env bash
# Call the private Cloud Run service. Usage: SERVICE_URL=https://... ./request.sh "message"
set -euo pipefail
APP=docs_helper USER_ID=dana
TOKEN=$(gcloud auth print-identity-token)
BODY=$(mktemp)
trap 'rm -f "$BODY"' EXIT
call() { curl --fail-with-body -sS -o "$BODY" -w "HTTP %{http_code} in %{time_total} s\n" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" "$@"; }

SESSION=${SESSION:-}
if [ -z "$SESSION" ]; then
  if ! call -X POST "$SERVICE_URL/apps/$APP/users/$USER_ID/sessions"; then cat "$BODY"; exit 1; fi
  SESSION=$(jq -r .id "$BODY"); echo "session $SESSION"
fi
if ! call "$SERVICE_URL/run" -d "$(jq -n --arg a $APP --arg u $USER_ID --arg s "$SESSION" --arg m "$1" \
  '{app_name: $a, user_id: $u, session_id: $s, new_message: {role: "user", parts: [{text: $m}]}}')"; then cat "$BODY"; exit 1; fi
jq -r 'if type == "array" then .[] | .content.parts[]? |
    if .functionCall then "call   \(.functionCall.name) \(.functionCall.args|tojson)"
    elif .functionResponse then "result \(.functionResponse.name)"
    elif (.text and (.thought|not)) then "answer \(.text)" else empty end
  else . end' "$BODY"

After Cloud Run accepts the caller, the ADK API server handles two different operations. The session POST creates a conversation under /apps/docs_helper/users/{user_id}/sessions. The /run body names app_name, user_id and session_id and contains the new message. The server finds that session, obtains a Runner for the application, and calls run_async. The Runner drives the LlmAgent, executes its requested tools and yields Event objects. The session service records the non-partial events; the HTTP handler returns the collected events as a JSON list.

/run_sse instead frames events as server-sent events. That endpoint changes how the caller receives a turn; it does not make the local session store durable. For either endpoint, derive the ADK user ID from the caller’s authenticated application identity. Cloud Run authenticates access to the service; it does not decide which application user owns a supplied user_id.

Because curl exits 0 whenever the HTTP transport succeeds—even on an HTTP 403 or 500—a shell client that ignores the status code or writes to a fixed /tmp/body.json path will mask server errors and race across concurrent callers. test_client.py runs request.sh against fake curl and gcloud executables to verify these failure paths offline:

Session creation fails. Stop before constructing the model request. The client prints the failed response and exits nonzero.

The agent request returns an HTTP error. Propagate failure to the calling script; a JSON error body is not an answer.

Two clients overlap. Each client uses its own response file, removed on exit.

Offline shell-client regression testsRecorded output · textDownload
test_a_failed_run_exits_nonzero (test_client.HttpClient.test_a_failed_run_exits_nonzero) ... ok
test_failed_session_creation_stops_before_the_model_request (test_client.HttpClient.test_failed_session_creation_stops_before_the_model_request) ... ok
test_simultaneous_clients_have_separate_response_files (test_client.HttpClient.test_simultaneous_clients_have_separate_response_files) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.525s

OK

The tests do not replace a deployed IAM, revision or durability test. The earlier recording supplies those observations, including its failures.

4. Choose a session store that survives replacement

For this deployment, adk deploy cloud_run selects memory:// when no session-service URI or local-storage override is supplied. That creates an InMemorySessionService in each server process. The recorded deploy used that local session storage. After the service moved to a new revision, a request naming the earlier session returned HTTP 404. Creating a new session on the new revision succeeded. A container’s filesystem and memory are not a shared, durable conversation store across instances or revisions.

A separate session store is a candidate remedy for the recorded loss of local history, not a result demonstrated by this deployment. To configure that candidate, supply --session_service_uri before the deploy command’s -- separator. The agentengine:// service URI selects VertexAiSessionService; use the managed instance’s full resource name after the scheme, as in agentengine://projects/PROJECT/locations/REGION/reasoningEngines/INSTANCE. This configures session storage separately from the Cloud Run service that executes the agent. The runtime account needs access to that instance. The URI supplies its ID to VertexAiSessionService, so the API server can keep the docs_helper application name. Test a session across replacement and across more than one instance. This recording deliberately shows the default local-store failure; it does not claim that a managed-store Cloud Run deployment was tested. The model’s global endpoint is also separate from the session service’s region.

Clean up resources you created

The original service, image, upload bucket and lesson account were removed after recording. The saved teardown is historical output, not a reusable bulk-delete script. For your own deployment, compare each resource with the inventory from your create step, remove the service first, and remove an image, bucket or account only when this exercise created it and nothing else uses it.

What’s next

Return to the API-server lesson when debugging the application’s HTTP behavior separately from its cloud identity and hosting layer.

Read the unchanged docs assistant
docs_helper/agent.pySource · pythonDownload
"""The docs assistant on Gemini Enterprise Agent Platform: Gemini through Vertex, recall through Memory Bank."""
import os
from pathlib import Path

from google.adk import Agent
from google.adk.models.google_llm import Gemini
from google.adk.tools import load_memory
from google.genai import types

DOCS = Path(__file__).resolve().parent / "docs"
PAGES = sorted(p.stem for p in DOCS.glob("*.md"))
# The recorded run uses global for this model. Sessions and Memory Bank stay regional
# (GOOGLE_CLOUD_LOCATION), so the model client gets its own location.
MODEL_LOCATION = os.environ.get("DOCS_MODEL_LOCATION", "global")


def read_page(page: str) -> dict:
    """Read one page of the docuctl documentation: config, deploy, rollback or tokens."""
    if page not in PAGES:
        return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
    return {"page": page, "text": (DOCS / f"{page}.md").read_text(encoding="utf-8")}


INSTRUCTION = """You answer questions from developers about docuctl, the team's deploy CLI.
Read the relevant page with read_page before you answer. Answers depend on the developer's
docuctl major version (1.x or 2.x). If the developer has not said it in this conversation,
call load_memory to look for it in earlier conversations before you ask. Keep the answer
under 80 words."""

root_agent = Agent(
    name="docs_helper",
    model=Gemini(model="gemini-3.8-flash", client_kwargs={"location": MODEL_LOCATION}),
    instruction=INSTRUCTION,
    tools=[read_page, load_memory],
    generate_content_config=types.GenerateContentConfig(
        temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low")),
)

Check your understanding

Cloud Run accepts the caller, but the model request fails under the runtime service account. Which permission boundary remains unresolved?

Choose one answer, then check your reasoning.

Read the explanation

Caller invocation permission and the container’s outbound model permission belong to different principals. Check the runtime account separately.

References

Official documentation and installed ADK deployment flags checked October 22, 2026.

Written by Arjun Prabhulal.

Module 5

Deploy agents on Google Cloud