Skip to content

Commit 12a8a65

Browse files
committed
feat: add cloud_run_worker sample for Google Cloud Run deployment
Signed-off-by: Mike German <mike@stepsventures.com>
1 parent bfec885 commit 12a8a65

13 files changed

Lines changed: 480 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Some examples require extra dependencies. See each sample's directory for specif
6161
* [batch_sliding_window](batch_sliding_window) - Batch processing with a sliding window of child workflows.
6262
* [bedrock](bedrock) - Orchestrate a chatbot with Amazon Bedrock.
6363
* [cloud_export_to_parquet](cloud_export_to_parquet) - Set up schedule workflow to process exported files on an hourly basis
64+
* [cloud_run_worker](cloud_run_worker) - Run a Temporal Worker inside a Google Cloud Run container with graceful SIGTERM shutdown and optional mTLS for Temporal Cloud.
6465
* [context_propagation](context_propagation) - Context propagation through workflows/activities via interceptor.
6566
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
6667
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.

cloud_run_worker/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM python:3.12-slim
2+
3+
WORKDIR /app
4+
5+
# Install uv for fast dependency installation
6+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
7+
8+
# Install runtime dependency
9+
RUN uv pip install --system "temporalio>=1.28.0,<2"
10+
11+
# Copy the cloud_run_worker package
12+
COPY __init__.py activities.py workflows.py worker.py starter.py ./cloud_run_worker/
13+
14+
# The worker polls Temporal, and it also serves a tiny health endpoint on $PORT
15+
# (default 8080) so Cloud Run's startup probe passes. See worker.py.
16+
EXPOSE 8080
17+
18+
CMD ["python", "-m", "cloud_run_worker.worker"]

cloud_run_worker/README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Cloud Run Worker
2+
3+
This sample demonstrates how to run a Temporal Worker inside a
4+
[Google Cloud Run](https://cloud.google.com/run) container. It shows:
5+
6+
- Reading Temporal connection config from environment variables.
7+
- Handling **SIGTERM gracefully** — Cloud Run sends SIGTERM and allows 10 seconds
8+
for the container to exit; the worker drains in-progress tasks before stopping.
9+
- Optional **mTLS** authentication for [Temporal Cloud](https://temporal.io/cloud)
10+
via base64-encoded certificate/key env vars.
11+
12+
The sample registers a simple greeting Workflow and Activity. The same pattern
13+
applies to any Workflow/Activity definitions.
14+
15+
## Prerequisites
16+
17+
- A [Temporal Cloud](https://temporal.io/cloud) namespace **or** a self-hosted
18+
Temporal server reachable from Cloud Run (e.g. via VPC connector or Cloud Run
19+
direct VPC egress).
20+
- [Docker](https://www.docker.com/) for local testing.
21+
- [Google Cloud SDK (`gcloud`)](https://cloud.google.com/sdk) for deployment.
22+
- Python 3.10+
23+
24+
## Files
25+
26+
| File | Description |
27+
|------|-------------|
28+
| `worker.py` | Entry point — connects to Temporal, registers Workflows/Activities, and handles SIGTERM |
29+
| `workflows.py` | Sample Workflow that executes a greeting Activity |
30+
| `activities.py` | Sample Activity that returns a greeting string |
31+
| `starter.py` | Helper script to start a Workflow execution from a local machine |
32+
| `Dockerfile` | Container definition for Cloud Run |
33+
| `deploy.sh` | Builds the image with Cloud Build and deploys to Cloud Run |
34+
| `pyproject.toml` | Standalone project manifest for this sample |
35+
36+
## Environment Variables
37+
38+
| Variable | Required | Description |
39+
|----------|----------|-------------|
40+
| `TEMPORAL_ADDRESS` | Yes | `host:port` of the Temporal frontend (e.g. `<ns>.<acct>.tmprl.cloud:7233`) |
41+
| `TEMPORAL_NAMESPACE` | Yes | Temporal namespace (e.g. `<ns>.<acct>`) |
42+
| `TEMPORAL_TASK_QUEUE` | No | Task queue name (default: `cloud-run-task-queue`) |
43+
| `TEMPORAL_TLS_CERT` | For Temporal Cloud | Base64-encoded mTLS client certificate |
44+
| `TEMPORAL_TLS_KEY` | For Temporal Cloud | Base64-encoded mTLS client private key |
45+
46+
For Temporal Cloud, encode your credentials:
47+
48+
```bash
49+
export TEMPORAL_TLS_CERT=$(base64 -i client.pem)
50+
export TEMPORAL_TLS_KEY=$(base64 -i client.key)
51+
```
52+
53+
## Local Development
54+
55+
### 1. Start a local Temporal dev server
56+
57+
```bash
58+
temporal server start-dev
59+
```
60+
61+
### 2. Run the worker
62+
63+
```bash
64+
TEMPORAL_ADDRESS=localhost:7233 TEMPORAL_NAMESPACE=default uv run python worker.py
65+
```
66+
67+
### 3. Start a workflow
68+
69+
In a separate terminal, from inside this directory:
70+
71+
```bash
72+
TEMPORAL_ADDRESS=localhost:7233 TEMPORAL_NAMESPACE=default uv run python starter.py
73+
```
74+
75+
## Docker
76+
77+
### Build
78+
79+
```bash
80+
docker build -t cloud-run-worker .
81+
```
82+
83+
### Run locally (against local dev server)
84+
85+
```bash
86+
docker run --rm \
87+
-e TEMPORAL_ADDRESS=host.docker.internal:7233 \
88+
-e TEMPORAL_NAMESPACE=default \
89+
cloud-run-worker
90+
```
91+
92+
### Run locally (against Temporal Cloud)
93+
94+
```bash
95+
docker run --rm \
96+
-e TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233 \
97+
-e TEMPORAL_NAMESPACE=<ns>.<acct> \
98+
-e TEMPORAL_TLS_CERT="$(base64 -i client.pem)" \
99+
-e TEMPORAL_TLS_KEY="$(base64 -i client.key)" \
100+
cloud-run-worker
101+
```
102+
103+
## Deploy to Cloud Run
104+
105+
Use the provided `deploy.sh` script (requires `gcloud` and appropriate IAM
106+
permissions to push to Container Registry and deploy Cloud Run services):
107+
108+
```bash
109+
export TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233
110+
export TEMPORAL_NAMESPACE=<ns>.<acct>
111+
112+
./deploy.sh my-temporal-worker us-central1 my-gcp-project
113+
```
114+
115+
The script:
116+
1. Builds the container image with Cloud Build and pushes to Container Registry.
117+
2. Deploys the Cloud Run service with the necessary env vars.
118+
3. Reads `TEMPORAL_TLS_CERT` and `TEMPORAL_TLS_KEY` from Secret Manager secrets
119+
named `<service>-tls-cert` and `<service>-tls-key`.
120+
121+
Or run `gcloud run deploy` directly:
122+
123+
```bash
124+
gcloud run deploy my-temporal-worker \
125+
--image gcr.io/my-gcp-project/my-temporal-worker:latest \
126+
--region us-central1 \
127+
--platform managed \
128+
--no-allow-unauthenticated \
129+
--set-env-vars "TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233,TEMPORAL_NAMESPACE=<ns>.<acct>" \
130+
--set-secrets "TEMPORAL_TLS_CERT=my-temporal-worker-tls-cert:latest,TEMPORAL_TLS_KEY=my-temporal-worker-tls-key:latest" \
131+
--min-instances 1 \
132+
--no-cpu-throttling
133+
```
134+
135+
Setting `--min-instances 1` ensures the worker is always polling; without it
136+
Cloud Run may scale to zero and leave no worker available for task execution.
137+
138+
### Why a health server?
139+
140+
Cloud Run services must listen on the port named by `$PORT` (default 8080) or the
141+
deploy's startup probe fails and the service never goes healthy. A Temporal worker
142+
only polls Temporal, so `worker.py` runs a tiny HTTP health endpoint in a background
143+
thread purely to satisfy that contract. The `--no-cpu-throttling` flag keeps CPU
144+
allocated between requests so the worker keeps polling, since Cloud Run otherwise
145+
throttles CPU to near zero when no HTTP request is in flight.
146+
147+
## Further Reading
148+
149+
- [Temporal Python SDK documentation](https://python.temporal.io)
150+
- [Temporal Cloud — production worker deployment](https://docs.temporal.io/production-deployment)
151+
- [Cloud Run — container contract](https://cloud.google.com/run/docs/container-contract)
152+
- [Cloud Run — handling signals (SIGTERM)](https://cloud.google.com/run/docs/reference/container-contract#lifecycle)

cloud_run_worker/__init__.py

Whitespace-only changes.

cloud_run_worker/activities.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from temporalio import activity
2+
3+
4+
@activity.defn
5+
async def hello_activity(name: str) -> str:
6+
activity.logger.info("HelloActivity started with name: %s", name)
7+
return f"Hello, {name}!"

cloud_run_worker/deploy.sh

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
# Deploy the Cloud Run worker to Google Cloud Run.
3+
#
4+
# Usage:
5+
# ./deploy.sh <service-name> <region> <project>
6+
#
7+
# Example:
8+
# ./deploy.sh my-temporal-worker us-central1 my-gcp-project
9+
#
10+
# Required env vars (pass via --set-env-vars or Secret Manager):
11+
# TEMPORAL_ADDRESS - e.g. <namespace>.<account>.tmprl.cloud:7233
12+
# TEMPORAL_NAMESPACE - e.g. <namespace>.<account>
13+
# TEMPORAL_TLS_CERT - base64-encoded mTLS client certificate (Temporal Cloud)
14+
# TEMPORAL_TLS_KEY - base64-encoded mTLS client private key (Temporal Cloud)
15+
16+
set -euo pipefail
17+
18+
SERVICE="${1:?Usage: $0 <service-name> <region> <project>}"
19+
REGION="${2:?Usage: $0 <service-name> <region> <project>}"
20+
PROJECT="${3:?Usage: $0 <service-name> <region> <project>}"
21+
22+
IMAGE="gcr.io/${PROJECT}/${SERVICE}:latest"
23+
24+
echo "Building and pushing image: ${IMAGE}"
25+
gcloud builds submit --tag "${IMAGE}" --project "${PROJECT}" .
26+
27+
echo "Deploying Cloud Run service: ${SERVICE}"
28+
gcloud run deploy "${SERVICE}" \
29+
--image "${IMAGE}" \
30+
--region "${REGION}" \
31+
--project "${PROJECT}" \
32+
--platform managed \
33+
--no-allow-unauthenticated \
34+
--set-env-vars "TEMPORAL_ADDRESS=${TEMPORAL_ADDRESS},TEMPORAL_NAMESPACE=${TEMPORAL_NAMESPACE}" \
35+
--set-secrets "TEMPORAL_TLS_CERT=${SERVICE}-tls-cert:latest,TEMPORAL_TLS_KEY=${SERVICE}-tls-key:latest" \
36+
--min-instances 1 \
37+
--max-instances 10 \
38+
--no-cpu-throttling
39+
40+
echo "Done. Service URL:"
41+
gcloud run services describe "${SERVICE}" --region "${REGION}" --project "${PROJECT}" \
42+
--format "value(status.url)"

cloud_run_worker/pyproject.toml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[project]
2+
name = "temporalio-samples-cloud-run-worker"
3+
version = "0.1a1"
4+
description = "Temporal.io Python SDK Cloud Run worker sample"
5+
authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }]
6+
requires-python = ">=3.10"
7+
readme = "README.md"
8+
license = "MIT"
9+
dependencies = ["temporalio>=1.28.0,<2"]
10+
11+
[dependency-groups]
12+
dev = [
13+
"ruff>=0.5.0,<0.6",
14+
"mypy>=1.4.1,<2",
15+
"poethepoet>=0.36.0",
16+
]
17+
18+
[build-system]
19+
requires = ["hatchling"]
20+
build-backend = "hatchling.build"
21+
22+
[tool.hatch.metadata]
23+
allow-direct-references = true
24+
25+
[tool.hatch.build.targets.wheel]
26+
packages = ["."]
27+
28+
[tool.poe.tasks]
29+
format = [
30+
{ cmd = "uv run ruff check --select I --fix" },
31+
{ cmd = "uv run ruff format" },
32+
]
33+
lint = [
34+
{ cmd = "uv run ruff check --select I" },
35+
{ cmd = "uv run ruff format --check" },
36+
{ ref = "lint-types" },
37+
]
38+
lint-types = "uv run --all-groups mypy --check-untyped-defs --namespace-packages ."
39+
40+
[tool.ruff]
41+
target-version = "py310"

cloud_run_worker/starter.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Helper script to start a SampleWorkflow execution against the Cloud Run worker.
3+
4+
Run from the repo root:
5+
6+
TEMPORAL_ADDRESS=<address> TEMPORAL_NAMESPACE=<namespace> \\
7+
uv run python -m cloud_run_worker.starter
8+
"""
9+
10+
import asyncio
11+
import os
12+
13+
from temporalio.client import Client, TLSConfig
14+
15+
from cloud_run_worker.workflows import TASK_QUEUE, SampleWorkflow
16+
17+
18+
def _tls_config() -> TLSConfig | None:
19+
cert_b64 = os.environ.get("TEMPORAL_TLS_CERT")
20+
key_b64 = os.environ.get("TEMPORAL_TLS_KEY")
21+
if cert_b64 and key_b64:
22+
import base64
23+
24+
return TLSConfig(
25+
client_cert=base64.b64decode(cert_b64),
26+
client_private_key=base64.b64decode(key_b64),
27+
)
28+
return None
29+
30+
31+
async def main() -> None:
32+
address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")
33+
namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
34+
task_queue = os.environ.get("TEMPORAL_TASK_QUEUE", TASK_QUEUE)
35+
36+
client = await Client.connect(
37+
address,
38+
namespace=namespace,
39+
tls=_tls_config() or False,
40+
)
41+
print(f"Connected to Temporal Service at {address}")
42+
43+
result = await client.execute_workflow(
44+
SampleWorkflow.run,
45+
"Cloud Run Worker!",
46+
id="cloud-run-workflow-id-1",
47+
task_queue=task_queue,
48+
)
49+
print(f"Workflow result: {result}")
50+
51+
52+
if __name__ == "__main__":
53+
asyncio.run(main())

0 commit comments

Comments
 (0)