مقال

Project 01: an ADK agent on Cloud Run, deployed with Terraform

Un premier agent ADK (météo) derrière une API HTTPS privée sur Cloud Run, déployé de bout en bout avec Terraform.

مبتدئ 2h

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Goal: write a small AI agent in Python with Google's ADK, try it on your machine, then have one terraform apply build it, push it, and run it on Cloud Run behind a private HTTPS URL. Then call it, test it, watch it, and delete it.

You'll understand: what an "agent" really is in code · how ADK turns it into a web API · what a container is · what Cloud Run does · what every Terraform line does · how Google Cloud IAM protects the agent.

Start with the root README sections 1 (setup) and 2 (Terraform in 10 minutes).


Contents

  1. Concepts first (for beginners)
  2. The files, and what each one does
  3. The agent code, explained line by line
  4. The web server (main.py), explained
  5. The container (Dockerfile), explained
  6. The Terraform, explained block by block
  7. Step by step: run locally → deploy → call
  8. Calling the agent (API reference)
  9. Testing
  10. Monitoring: logs, traces, metrics
  11. Change the agent and redeploy
  12. Sessions: why only 1 instance
  13. Best practices and next steps
  14. Troubleshooting
  15. Clean up

1. Concepts first

LLM (Gemini). A model that takes text and returns text. On its own it can't do anything: it can't look up the weather, query a database, or send an email.

Agent. An LLM plus instructions (its job) plus tools (Python functions it may call). The loop is:

user: "Weather in Paris?"
  │
  ▼
LLM reads instruction + question + the list of available tools
  │  decides: "I should call get_weather(city='Paris')"
  ▼
ADK runs YOUR Python function get_weather("Paris") → {"status":"success","report":"...sunny, 24°C"}
  │
  ▼
LLM reads the tool result → writes the final answer: "It's sunny and 24°C in Paris."

The LLM never runs code itself. It asks ADK to call the function, and ADK sends the result back to it. That's why a tool's docstring and type hints matter so much: they are the only thing the LLM reads to decide when to call the tool and with which arguments.

ADK (Agent Development Kit). Google's Python library (pip install google-adk) that provides:

  • the Agent class (the loop above),
  • sessions (the conversation history, so the agent remembers the previous message),
  • a ready-made web API + dev web UI (get_fast_api_app),
  • CLI tools: adk web, adk run, adk deploy.

Container / Docker image. A packaged box holding Python + your libraries + your code. It runs the same way everywhere. The Dockerfile is the recipe; docker build bakes the image; docker push uploads it to Artifact Registry (Google's private image store).

Cloud Run. "Give me a container image and I'll run it as an HTTPS service." It handles HTTPS, scaling (0 → N instances by traffic), and revisions (each deploy is a new immutable version). You pay only while requests are being handled.

Service account (SA). An identity for a program (not a person). Our container runs as weather-agent-sa@…, and we give that SA only the permissions it needs (call Gemini, write logs). That's least privilege.

IAM. "Who can do what on which resource." Example: roles/run.invoker on the service = "may call it".

Terraform. Describes all of the above as code, so one command can create, update, or delete it.


2. The files

01-adk-cloud-run-terraform/
├── agents/                      ← every sub-folder here = one agent "app"
│   └── weather_agent/
│       ├── __init__.py          ← makes it a Python package, loads agent.py
│       ├── agent.py             ← THE AGENT: tool + instructions + model
│       └── .env.example         ← env vars for local runs (copy to .env)
├── main.py                      ← web server: turns agents/ into an HTTP API
├── requirements.txt             ← Python dependencies (pinned)
├── Dockerfile                   ← how to build the container image
├── .dockerignore                ← files NOT to put in the image
├── test.sh                      ← end-to-end smoke test (works locally and on Cloud Run)
└── terraform/
    ├── versions.tf              ← Terraform + provider versions, provider config
    ├── variables.tf             ← inputs (project, region, model, public?, web UI?)
    ├── main.tf                  ← APIs, registry, build, SA + IAM, Cloud Run
    ├── outputs.tf               ← URL, image, SA
    └── terraform.tfvars.example ← copy to terraform.tfvars

How they connect:

agent.py ──imported by──▶ main.py ──run by──▶ Dockerfile (uvicorn) ──built/pushed by──▶ terraform/main.tf
                                                                                         └─▶ Cloud Run runs that image

3. The agent code explained

File: agents/weather_agent/agent.py

def get_weather(city: str) -> dict:
    """Returns the current weather report for a city.

    Args:
        city: Name of the city, e.g. "Paris".
    ...
    """
  • This is a tool: a normal Python function. ADK reads its name, type hints (city: str), and docstring, and sends them to Gemini as a "function declaration". Gemini then knows it exists and what it's for.
  • It returns a dict with a status key. That's the ADK convention: Gemini can read "status": "error" and tell the user politely instead of inventing an answer.
  • The data is hard-coded (the ponytail: comment marks this). In real life you'd call a weather API here, or a database, or your company's internal API. The agent doesn't care.
root_agent = Agent(
    name="weather_agent",
  • ADK looks for a variable named exactly root_agent in each agent package. name must be a valid identifier and becomes the app name in the API URLs (/apps/weather_agent/...).
    model=Gemini(
        model=os.getenv("MODEL", "gemini-3.5-flash"),
        client_kwargs={"enterprise": True, "location": "global"},
    ),
  • Which LLM to use. The model ID comes from the MODEL env var (Terraform sets it), so switching models needs no code change.
  • enterprise: True → call Gemini through Google Cloud (Agent Platform) with your project's billing and IAM, not through an AI Studio API key. No API key to leak.
  • location: "global" → use Google's global Gemini endpoint. New models are available there first, and it works whatever region Cloud Run runs in.
  • The GCP project is read from the GOOGLE_CLOUD_PROJECT env var.
    description="Answers questions about the current weather in a city.",
  • A description for other agents and humans: when this agent is a sub-agent or appears in a registry, this is how others know what it does.
    instruction=("You are a friendly weather assistant. When the user asks about the weather in a city, call get_weather. ..."),
    tools=[get_weather],
)
  • instruction = the system prompt: the agent's job description. Be explicit about when to use which tool and what to do on errors.
  • tools = the list of functions the model may call.

File: agents/weather_agent/init.py contains from . import agent, so that when ADK imports the package weather_agent, agent.py (and root_agent) get loaded.


4. The web server (main.py)

File: main.py

app = get_fast_api_app(
    agents_dir=".../agents",
  • get_fast_api_app is ADK's built-in server. It scans agents/; each sub-folder with a root_agent becomes an app. You get a complete REST API for free (see section 8).
  • It returns a FastAPI app (FastAPI = a popular Python web framework), and uvicorn is the program that actually serves it over HTTP.
    session_service_uri=os.getenv("SESSION_SERVICE_URI"),
  • Where conversation history is stored. Not set → in memory (lost on restart; see section 12). Set it to a DB URL to make sessions durable.
    web=os.getenv("SERVE_WEB_UI", "false") == "true",
  • Turns on the ADK dev UI (a chat page with an event inspector) at /dev-ui/. Great for learning. Terraform controls it with serve_web_ui.
    otel_to_cloud=os.getenv("OTEL_TO_CLOUD", "false") == "true",
)
  • Sends OpenTelemetry data (traces of every LLM call and tool call, plus logs and metrics) to Cloud Trace, Logging, and Monitoring. Terraform sets it to true on Cloud Run; locally it stays off.
if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")))
  • Lets you run python main.py locally. Cloud Run always provides a PORT env var, and the server must listen on it.

5. The container (Dockerfile)

File: Dockerfile

LineWhy
FROM python:3.12-slimStart from an official small image with Python 3.12
ENV PYTHONUNBUFFERED=1Print logs immediately (otherwise they're buffered and show up late in Cloud Logging)
COPY requirements.txt . then RUN pip install ...Install dependencies before copying the code. Docker caches each step, so changing agent.py doesn't reinstall everything (much faster builds)
COPY main.py . / COPY agents/ agents/Add your code
RUN adduser ... && chown / USER appDon't run as root: if the app is ever compromised, the attacker isn't root in the container
CMD exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080}Start the server. main:app = "the app object in main.py". exec lets Cloud Run's stop signal reach uvicorn cleanly

.dockerignore keeps terraform/, .env (secrets!), caches, and docs out of the image.


6. The Terraform explained

versions.tf

terraform {
  required_version = ">= 1.9"
  required_providers {
    google = { source = "hashicorp/google", version = "~> 8.4" }
  }
  # backend "gcs" { ... }
}
provider "google" {
  project = var.project_id
  region  = var.region
}
  • Pins the Terraform and provider versions so the code behaves the same next year. ~> 8.4 means ≥ 8.4 and < 9.0.
  • backend "gcs" (commented out) = where the state is stored. Commented → a local file terraform.tfstate.
  • The provider block sets defaults: every google_* resource uses this project and region unless told otherwise. Authentication comes from ADC (gcloud auth application-default login).

variables.tf

The inputs you can change without touching code: project_id (required), region, service_name, model, public, serve_web_ui. You set them in terraform.tfvars.

main.tf, block by block

locals: computed values

src_files = concat(["Dockerfile","main.py","requirements.txt"], sort(tolist(fileset(local.src_dir, "agents/**/*.py"))))
src_hash  = sha1(join("", [for f in local.src_files : filesha1("${local.src_dir}/${f}")]))
image     = ".../${var.service_name}:${substr(local.src_hash, 0, 12)}"
  • Lists every file that goes into the image, hashes each one, and hashes all the hashes together.
  • The image tag is that hash. Change one character in agent.py → new hash → new tag → Terraform sees the image changed → rebuild + new Cloud Run revision. Nothing changed → plan says "No changes". (Using :latest instead would break this: Terraform couldn't tell anything changed.)

① google_project_service.apis: enable the APIs A fresh project has most APIs off. for_each creates one resource per API: Cloud Run, Artifact Registry, aiplatform (Gemini), and the observability APIs.

② google_artifact_registry_repository.agents: the image store A private Docker repository at europe-west1-docker.pkg.dev/PROJECT/agents. depends_on = [google_project_service.apis] because the API must be enabled first.

③ terraform_data.image: build and push

resource "terraform_data" "image" {
  triggers_replace = local.image
  provisioner "local-exec" { command = "gcloud auth configure-docker ...; docker build ...; docker push ..." }
}
  • terraform_data is a do-nothing resource that exists to run a command. local-exec runs it on your machine.
  • triggers_replace = local.image → re-run it only when the image tag (the source hash) changes.
  • --platform linux/amd64 because Cloud Run runs x86-64.
  • Honest note: Terraform isn't a build tool. In a real team, CI (Cloud Build / GitHub Actions) builds the image and Terraform only receives the tag. For learning, "one command does everything" is easier to follow.

④ google_service_account.agent + google_project_iam_member.agent: the agent's identity Creates weather-agent-sa and grants it exactly 4 roles: call Gemini (aiplatform.user) and write logs, metrics, and traces. It can't read your buckets, databases, or anything else. If you add a tool that reads BigQuery, you add roles/bigquery.dataViewer here, and only that.

⑤ google_cloud_run_v2_service.agent: the service

template {
  service_account = google_service_account.agent.email   # run as our SA
  scaling { min_instance_count = 0, max_instance_count = 1 }
  containers {
    image = local.image
    resources { limits = { cpu = "1", memory = "1Gi" } }
    env { name = "GOOGLE_CLOUD_PROJECT" ... }  env { name = "MODEL" ... } ...
  }
}
depends_on = [terraform_data.image, google_project_iam_member.agent]
  • template = what each revision looks like. Any change inside it creates a new revision, and Cloud Run shifts 100% of traffic to it once it's healthy (a zero-downtime deploy).
  • min 0 → scale to zero when idle (cost ≈ 0; the first request after idle takes a few seconds: a "cold start").
  • max 1 → see Sessions.
  • The env blocks are how configuration reaches agent.py / main.py.
  • depends_on: the image must be pushed and the IAM granted before the service starts, or it fails.
  • deletion_protection = false so terraform destroy works (keep it true in production).

⑥ google_cloud_run_v2_service_iam_member.public: who can call it With count = var.public ? 1 : 0 this only exists if you set public = true. By default the service is private: requests without a valid Google identity token get 403. You're the project owner, so you can call it. To let a colleague call it:

gcloud run services add-iam-policy-binding weather-agent --region europe-west1 \
  --member=user:colleague@your-company.com --role=roles/run.invoker

outputs.tf

url, image, service_account. Read one with terraform -chdir=terraform output -raw url.

What terraform apply does, in order

Terraform builds a dependency graph from the references (google_service_account.agent.email in the service means "SA first") plus the depends_on lines:

APIs ─▶ Artifact Registry repo ─▶ build+push image ─┐
     └▶ service account ─▶ IAM roles ───────────────┴─▶ Cloud Run service ─▶ (public IAM, optional)

7. Step by step

7.1 Run locally (no cloud deploy yet: fastest feedback)

cd ~/Bureau/trash && source .venv/bin/activate      # see root README §1
cd 01-adk-cloud-run-terraform
gcloud services enable aiplatform.googleapis.com     # the local agent calls Gemini in your project
cp agents/weather_agent/.env.example agents/weather_agent/.env   # then put your project ID in it

# Option A: the ADK dev UI (best for learning: shows every event, tool call, and state change)
adk web agents            # open http://localhost:8000, pick "weather_agent", ask "weather in Tokyo?"

# Option B: terminal chat
adk run agents/weather_agent

# Option C: exactly what Cloud Run will run
export $(cat agents/weather_agent/.env | xargs) && SERVE_WEB_UI=true python main.py
URL=http://localhost:8080 ./test.sh                 # in another terminal

In the dev UI, click an event to see the function call that Gemini requested and the function response your code returned. That's the agent loop from section 1, live.

7.2 Deploy

cd terraform
cp terraform.tfvars.example terraform.tfvars    # set project_id
terraform init
terraform plan        # read it: ~12 resources to add
terraform apply       # type "yes"; the first run takes ~3-5 min (docker build + push)
terraform output url

7.3 Open the dev UI on the deployed agent (private service, no public URL needed)

gcloud run services proxy weather-agent --region europe-west1 --port 8080
# open http://localhost:8080/dev-ui/   (the proxy adds your identity token to every request)

8. Calling the agent (API reference)

Every request to the private service needs Authorization: Bearer $(gcloud auth print-identity-token).

URL=$(terraform -chdir=terraform output -raw url)
TOKEN=$(gcloud auth print-identity-token)
Method + pathWhat
GET /list-appsAgents served: ["weather_agent"]
POST /apps/{app}/users/{user}/sessions/{session}Create a session with your own ID (body = initial state, {})
POST /apps/{app}/users/{user}/sessionsCreate a session with a generated ID
GET /apps/{app}/users/{user}/sessions/{session}Read a session: full history + state
GET /apps/{app}/users/{user}/sessionsList a user's sessions
DELETE /apps/{app}/users/{user}/sessions/{session}Delete a session
POST /runSend a message and get all events as a JSON list when the run finishes
POST /run_sseSame, but streamed as Server-Sent Events (for chat UIs)
GET /docsInteractive OpenAPI docs (Swagger)
# 1. create a session
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  $URL/apps/weather_agent/users/u1/sessions/s1 -d '{}'

# 2. ask something
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $URL/run -d '{
  "app_name": "weather_agent", "user_id": "u1", "session_id": "s1",
  "new_message": {"role": "user", "parts": [{"text": "What is the weather in London?"}]}}'

# 3. follow-up in the same session: the agent remembers "London"
curl ... $URL/run -d '{... "session_id": "s1", "new_message": {"role":"user","parts":[{"text":"And tomorrow?"}]}}'

# streaming
curl -N -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" $URL/run_sse -d '{
  "app_name":"weather_agent","user_id":"u1","session_id":"s1","streaming":true,
  "new_message":{"role":"user","parts":[{"text":"weather in Tokyo?"}]}}'

The /run response is a list of events, typically:

  1. content.parts[0].functionCall → {"name":"get_weather","args":{"city":"London"}} (Gemini asks for the tool)
  2. content.parts[0].functionResponse → your function's return value
  3. content.parts[0].text → the final answer

From Python (any app):

import requests, google.auth.transport.requests, google.oauth2.id_token
token = google.oauth2.id_token.fetch_id_token(google.auth.transport.requests.Request(), URL)  # works with a service account
requests.post(f"{URL}/run", headers={"Authorization": f"Bearer {token}"}, json={...})

9. Testing

LevelHowWhat it proves
Tool unit testpython -c "from agents.weather_agent.agent import get_weather; print(get_weather('Paris'))"Your Python logic works, no LLM involved
Interactiveadk web agentsThe LLM calls the tool at the right moments
Smoke test./test.sh (Cloud Run) or URL=http://localhost:8080 ./test.shFull path works: auth → server → session → Gemini → tool → answer. Fails if the tool wasn't called
Evaluationadk eval with a .test.json of expected tool calls and answersQuality doesn't regress when you change the prompt or model. Add this when the agent gets serious

Run ./test.sh after every deploy.


10. Monitoring

Logs (every request, plus Python print/logging output):

gcloud run services logs read weather-agent --region europe-west1 --limit 50
gcloud run services logs tail weather-agent --region europe-west1      # live

Console: Cloud Run → weather-agent → Logs, or Logging → Logs Explorer with resource.type="cloud_run_revision" resource.labels.service_name="weather-agent".

Traces (thanks to otel_to_cloud): Console → Trace → Trace explorer. Each request shows a waterfall: invoke_agent → call_llm (how long Gemini took, tokens used) → execute_tool get_weather. This is the tool for answering "why is my agent slow?"

Metrics: Cloud Run → weather-agent → Metrics: request count, latency (p50/p95/p99), 4xx/5xx, instance count, CPU/memory. Create an alert: Monitoring → Alerting → Create policy → Cloud Run Revision → Request count, filter response_code_class = 5xx.

Revisions (every deploy):

gcloud run revisions list --service weather-agent --region europe-west1

11. Change and redeploy

  1. Edit agent.py (e.g. add "madrid": "hot, 35°C" or a new tool).
  2. Test locally: adk web agents.
  3. cd terraform && terraform plan → you'll see terraform_data.image replaced and the Cloud Run service updated in place (new image tag).
  4. terraform apply, then ./test.sh.

Rollback = redeploy the previous code (git revert + apply). Emergency rollback without Terraform: gcloud run services update-traffic weather-agent --to-revisions=PREVIOUS_REVISION=100 --region europe-west1 (the next terraform apply will switch back to what the code says).


12. Sessions

Sessions live in the server's memory. Cloud Run may run several instances, and each one would have different memory: message 1 lands on instance A, message 2 on instance B, and the agent has "forgotten" the conversation. So we set max_instance_count = 1 (marked ponytail: in the code).

To scale out, use a shared session store and then raise max_instance_count:

  • Agent Runtime sessions (managed; easiest): SESSION_SERVICE_URI=agentengine://projects/P/locations/europe-west1/reasoningEngines/ID (a reasoning engine from project 02)
  • Cloud SQL / Postgres: SESSION_SERVICE_URI=postgresql+asyncpg://... (credentials from Secret Manager)

13. Best practices

  • Private by default (done). Expose to users through something that authenticates them: Gemini Enterprise, Google Chat (project 03), IAP, or API Gateway. Never public = true with no auth: anyone could spend your Gemini budget.
  • Least-privilege service account per agent (done).
  • Pin dependency versions (requirements.txt) (done). Update them on purpose, then retest.
  • Secrets (API keys for your tools): Secret Manager + env { value_source { secret_key_ref {...} } } in Terraform, never in code or in .env files baked into the image (.dockerignore excludes .env).
  • Good tools = clear names, typed args, precise docstrings, and they return {"status": ...}. Keep each tool doing one thing.
  • Instructions: say when to call each tool and what to do on errors. Keep them short and testable.
  • Turn off the dev UI in production (serve_web_ui = false).
  • Production: remote state (GCS backend), build in CI, terraform plan in PRs, deletion_protection = true, min_instance_count = 1 if cold starts hurt.
  • Next steps: make it an A2A agent (get_fast_api_app(a2a=True) plus an agent.json Agent Card in the agent folder, served under /a2a/weather_agent) so other agents and Agent Registry can discover its skills (see project 02).

14. Troubleshooting

SymptomCause → fix
could not find default credentialsgcloud auth application-default login
403 Forbidden calling the URLMissing or wrong token: use gcloud auth print-identity-token (identity token, not access token), or you lack run.invoker
docker: permission deniedAdd yourself to the docker group: sudo usermod -aG docker $USER, then log out and back in
denied: Permission "artifactregistry.repositories.uploadArtifacts"Run gcloud auth configure-docker europe-west1-docker.pkg.dev with an account that has access
Service fails to start ("container failed to listen on PORT")Check gcloud run services logs read ...: usually an import error in agent.py
404 model not found / Publisher model ... not foundThe model ID isn't available: set model = "..." in tfvars to a current one (Console → Agent Platform → Model Garden)
429 RESOURCE_EXHAUSTEDGemini quota: retry later, or request a quota increase
Agent "forgets" the conversationYou're using a new session_id, or you raised max_instance_count with in-memory sessions (§12)
Error 409: already exists on applyThe resource was created outside Terraform: terraform import it or delete it

15. Clean up

cd terraform && terraform destroy

Removes the service, the SA and its roles, and the repository (with its images). APIs stay enabled (harmless, free).

الموارد

تنزيل الكل (.zip)