Deploying an ADK Agent: One Command to Cloud Run and Agent Engine
How `adk deploy` builds, pushes, and ships an agent in a single step — and the ack-after-invocation rule that keeps event-driven agents reliable.
An agent that only runs on your laptop isn’t worth much. The good news in Google’s Agent Development Kit (ADK) is that the code you wrote in every previous post doesn’t change when you go to production — only the packaging and the command do. ADK is deployment-agnostic: the same agent runs on Cloud Run (serverless containers), GKE (Kubernetes), or Vertex Agent Engine (a fully-managed agent runtime). This post covers what actually gets deployed, the one-command adk deploy path, and the reliability rules that matter once a non-human source starts driving your agent.
What actually gets deployed
An ADK agent is served over HTTP by a small web app. The interesting difference between the two languages is what the deployable is.
In Python, a helper builds a FastAPI app that scans a directory of agent packages and exposes them as a REST/SSE API (plus the dev web UI). That app is the thing Cloud Run runs.
# main.py — the ASGI entrypoint
import os
from google.adk.cli.fast_api import get_fast_api_app
AGENTS_DIR = os.path.dirname(os.path.abspath(__file__))
app = get_fast_api_app(
agents_dir=AGENTS_DIR,
session_service_uri=os.environ.get("SESSION_SERVICE_URI"), # e.g. a DB URL
artifact_service_uri=os.environ.get("ARTIFACT_SERVICE_URI"), # e.g. a gs:// bucket
memory_service_uri=os.environ.get("MEMORY_SERVICE_URI"),
allow_origins=["*"],
web=True,
)
# Cloud Run then runs: uvicorn main:app --host 0.0.0.0 --port $PORT
In Go, the compiled binary is the server. The launcher’s web sublaunchers expose the same REST API (web api) and A2A (web a2a), so a tiny container just runs the static binary:
func main() {
ctx := context.Background()
root, err := newRootAgent(ctx) // an llmagent.New(...) as in earlier posts
if err != nil {
log.Fatalf("build agent: %v", err)
}
l := full.NewLauncher()
cfg := &launcher.Config{AgentLoader: agent.NewSingleLoader(root)}
if err := l.Execute(ctx, cfg, os.Args[1:]); err != nil {
log.Fatalf("run failed: %v", err)
}
}
// ./server web -port 8080 api # serve the production REST API
That distinction drives the container. Python ships on python:3.13-slim (interpreter plus dependencies), landing in the hundreds of megabytes. Go builds a static binary (CGO_ENABLED=0) into a distroless/static image — tens of megabytes, no shell, no OS packages to scan. For cold starts and image-scanning surface, Go’s binary is a real operational edge; Python’s larger image buys you a more familiar ecosystem. Both serve the identical agent API.
One Go gotcha: the adk-go web launcher reads its port from the -port flag, not $PORT. Cloud Run’s $PORT defaults to 8080, so a hard-coded -port 8080 matches — but distroless has no shell to expand $PORT, so a dynamic port needs a shell-capable base image.
The one-command path: adk deploy
You rarely need to touch Docker or gcloud by hand. The ADK CLI wraps container build, push, and deploy into a single command with two subcommands:
# Cloud Run — build a container from the agent dir and deploy it
adk deploy cloud_run \
--project=$GOOGLE_CLOUD_PROJECT --region=us-central1 \
--service_name=my-agent ./python
# Vertex Agent Engine — package for the fully-managed runtime
adk deploy agent_engine \
--project=$GOOGLE_CLOUD_PROJECT --region=us-central1 \
--display_name=my-agent ./python
cloud_run does under the hood what a hand-written Dockerfile does — it builds the image, pushes it, and creates the service. agent_engine goes further: it packages the agent and provisions managed sessions and memory, so you don’t run your own database or bucket at all. Keep a Dockerfile around only when you need to customize the image (extra system libraries, private dependencies, a non-default base). For GKE, drop back to docker build/push plus a kubectl apply of a Deployment and Service.
When to use which. Reach for Cloud Run by default — it scales to zero, costs little, and deploys in one line. Choose GKE when you already operate Kubernetes or need sidecars, GPUs, or fine-grained networking. Choose Agent Engine when you want the least ops and are all-in on Vertex; sessions, memory, autoscaling, and tracing come built in.
The one thing you change for production
Locally you used in-memory session, memory, and artifact services. The single most important production change is persistence: point those at real backends. In Python, that’s the *_service_uri arguments above (read from env). In Go, you configure the equivalent services on the launcher config. On Agent Engine they’re managed for you — nothing to swap. Your agent logic stays untouched; only where its state lives changes.
Event-driven agents and ack-after-invocation
Everything so far serves your agent to users over HTTPS. But the same deployed container can be driven by a non-user source — a Pub/Sub push, an Eventarc or Cloud Scheduler trigger, a queue worker. This “ambient” pattern introduces no new ADK primitive: you add one HTTP handler that builds a message and drives the Runner itself for a synthetic user and session, exactly like a normal chat invocation.
The catch is delivery semantics. Event sources deliver at least once, so the same event can arrive twice. Two rules keep replays safe:
- Derive the session id from a stable field of the event (a Pub/Sub
messageId, an order id, a scheduled-run timestamp) instead of a fresh UUID. A redelivery then lands in the same session rather than spawning a duplicate conversation. In Python you also pass the same stableinvocation_id=so a retried run is dedupable downstream. - Ack the push after the invocation succeeds, not before. If you ack on receipt and then the model call or a tool fails, the work is silently lost. Ack last, and a genuine failure gets redelivered and retried.
async def on_event(envelope: dict) -> None:
data = json.loads(base64.b64decode(envelope["message"]["data"]))
msg_id = envelope["message"]["messageId"] # stable, from Pub/Sub
# Pin the session to the event so a redelivery reuses it.
if await runner.session_service.get_session(
app_name="weather", user_id="scheduler", session_id=msg_id) is None:
await runner.session_service.create_session(
app_name="weather", user_id="scheduler", session_id=msg_id)
message = types.Content(role="user", parts=[types.Part(text=data["prompt"])])
async for event in runner.run_async(
user_id="scheduler", session_id=msg_id,
invocation_id=msg_id, new_message=message):
if event.is_final_response():
... # persist / notify, then ack — no HTTP client is waiting
The Go path is the same shape: a handler owning a runner.New(...) calls r.Run(ctx, "scheduler", msgID, msg, ...), using the stable msgID as the session id so a redelivered event replays the same session. Pick a fresh session id only when each event genuinely starts an independent conversation (a brand-new support ticket); pick a stable, derived one whenever retries must fold into one run.
Mental model
Deploying an ADK agent is “wrap it in a web server, containerize, ship” — and adk deploy collapses those three into one command. The code is portable across Cloud Run, GKE, and Agent Engine; only packaging and where-state-lives change. And once events (not humans) drive the agent, one discipline carries the reliability: stable session ids plus ack-after-invocation, so at-least-once delivery never means at-most-once execution.
Next in the series: observability — the logging, tracing, and metrics you want once the agent is running in production.