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.
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.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:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txtThe 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:
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-runADK 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.
$ 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_213948The 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.
# 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:
#!/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.
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
OKThe 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
"""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
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.