Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Some examples require extra dependencies. See each sample's directory for specif
* [batch_sliding_window](batch_sliding_window) - Batch processing with a sliding window of child workflows.
* [bedrock](bedrock) - Orchestrate a chatbot with Amazon Bedrock.
* [cloud_export_to_parquet](cloud_export_to_parquet) - Set up schedule workflow to process exported files on an hourly basis
* [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.
* [context_propagation](context_propagation) - Context propagation through workflows/activities via interceptor.
* [custom_converter](custom_converter) - Use a custom payload converter to handle custom types.
* [custom_decorator](custom_decorator) - Custom decorator to auto-heartbeat a long-running activity.
Expand Down
18 changes: 18 additions & 0 deletions cloud_run_worker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM python:3.12-slim

WORKDIR /app

# Install uv for fast dependency installation
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Install runtime dependency
RUN uv pip install --system "temporalio>=1.28.0,<2"

# Copy the cloud_run_worker package
COPY __init__.py activities.py workflows.py worker.py starter.py ./cloud_run_worker/

# The worker polls Temporal, and it also serves a tiny health endpoint on $PORT
# (default 8080) so Cloud Run's startup probe passes. See worker.py.
EXPOSE 8080

CMD ["python", "-m", "cloud_run_worker.worker"]
152 changes: 152 additions & 0 deletions cloud_run_worker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Cloud Run Worker

This sample demonstrates how to run a Temporal Worker inside a
[Google Cloud Run](https://cloud.google.com/run) container. It shows:

- Reading Temporal connection config from environment variables.
- Handling **SIGTERM gracefully** — Cloud Run sends SIGTERM and allows 10 seconds
for the container to exit; the worker drains in-progress tasks before stopping.
- Optional **mTLS** authentication for [Temporal Cloud](https://temporal.io/cloud)
via base64-encoded certificate/key env vars.

The sample registers a simple greeting Workflow and Activity. The same pattern
applies to any Workflow/Activity definitions.

## Prerequisites

- A [Temporal Cloud](https://temporal.io/cloud) namespace **or** a self-hosted
Temporal server reachable from Cloud Run (e.g. via VPC connector or Cloud Run
direct VPC egress).
- [Docker](https://www.docker.com/) for local testing.
- [Google Cloud SDK (`gcloud`)](https://cloud.google.com/sdk) for deployment.
- Python 3.10+

## Files

| File | Description |
|------|-------------|
| `worker.py` | Entry point — connects to Temporal, registers Workflows/Activities, and handles SIGTERM |
| `workflows.py` | Sample Workflow that executes a greeting Activity |
| `activities.py` | Sample Activity that returns a greeting string |
| `starter.py` | Helper script to start a Workflow execution from a local machine |
| `Dockerfile` | Container definition for Cloud Run |
| `deploy.sh` | Builds the image with Cloud Build and deploys to Cloud Run |
| `pyproject.toml` | Standalone project manifest for this sample |

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `TEMPORAL_ADDRESS` | Yes | `host:port` of the Temporal frontend (e.g. `<ns>.<acct>.tmprl.cloud:7233`) |
| `TEMPORAL_NAMESPACE` | Yes | Temporal namespace (e.g. `<ns>.<acct>`) |
| `TEMPORAL_TASK_QUEUE` | No | Task queue name (default: `cloud-run-task-queue`) |
| `TEMPORAL_TLS_CERT` | For Temporal Cloud | Base64-encoded mTLS client certificate |
| `TEMPORAL_TLS_KEY` | For Temporal Cloud | Base64-encoded mTLS client private key |

For Temporal Cloud, encode your credentials:

```bash
export TEMPORAL_TLS_CERT=$(base64 -i client.pem)
export TEMPORAL_TLS_KEY=$(base64 -i client.key)
```

## Local Development

### 1. Start a local Temporal dev server

```bash
temporal server start-dev
```

### 2. Run the worker

```bash
TEMPORAL_ADDRESS=localhost:7233 TEMPORAL_NAMESPACE=default uv run python worker.py
```

### 3. Start a workflow

In a separate terminal, from inside this directory:

```bash
TEMPORAL_ADDRESS=localhost:7233 TEMPORAL_NAMESPACE=default uv run python starter.py
```

## Docker

### Build

```bash
docker build -t cloud-run-worker .
```

### Run locally (against local dev server)

```bash
docker run --rm \
-e TEMPORAL_ADDRESS=host.docker.internal:7233 \
-e TEMPORAL_NAMESPACE=default \
cloud-run-worker
```

### Run locally (against Temporal Cloud)

```bash
docker run --rm \
-e TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233 \
-e TEMPORAL_NAMESPACE=<ns>.<acct> \
-e TEMPORAL_TLS_CERT="$(base64 -i client.pem)" \
-e TEMPORAL_TLS_KEY="$(base64 -i client.key)" \
cloud-run-worker
```

## Deploy to Cloud Run

Use the provided `deploy.sh` script (requires `gcloud` and appropriate IAM
permissions to push to Container Registry and deploy Cloud Run services):

```bash
export TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233
export TEMPORAL_NAMESPACE=<ns>.<acct>

./deploy.sh my-temporal-worker us-central1 my-gcp-project
```

The script:
1. Builds the container image with Cloud Build and pushes to Container Registry.
2. Deploys the Cloud Run service with the necessary env vars.
3. Reads `TEMPORAL_TLS_CERT` and `TEMPORAL_TLS_KEY` from Secret Manager secrets
named `<service>-tls-cert` and `<service>-tls-key`.

Or run `gcloud run deploy` directly:

```bash
gcloud run deploy my-temporal-worker \
--image gcr.io/my-gcp-project/my-temporal-worker:latest \
--region us-central1 \
--platform managed \
--no-allow-unauthenticated \
--set-env-vars "TEMPORAL_ADDRESS=<ns>.<acct>.tmprl.cloud:7233,TEMPORAL_NAMESPACE=<ns>.<acct>" \
--set-secrets "TEMPORAL_TLS_CERT=my-temporal-worker-tls-cert:latest,TEMPORAL_TLS_KEY=my-temporal-worker-tls-key:latest" \
--min-instances 1 \
--no-cpu-throttling
```

Setting `--min-instances 1` ensures the worker is always polling; without it
Cloud Run may scale to zero and leave no worker available for task execution.

### Why a health server?

Cloud Run services must listen on the port named by `$PORT` (default 8080) or the
deploy's startup probe fails and the service never goes healthy. A Temporal worker
only polls Temporal, so `worker.py` runs a tiny HTTP health endpoint in a background
thread purely to satisfy that contract. The `--no-cpu-throttling` flag keeps CPU
allocated between requests so the worker keeps polling, since Cloud Run otherwise
throttles CPU to near zero when no HTTP request is in flight.

## Further Reading

- [Temporal Python SDK documentation](https://python.temporal.io)
- [Temporal Cloud — production worker deployment](https://docs.temporal.io/production-deployment)
- [Cloud Run — container contract](https://cloud.google.com/run/docs/container-contract)
- [Cloud Run — handling signals (SIGTERM)](https://cloud.google.com/run/docs/reference/container-contract#lifecycle)
Empty file added cloud_run_worker/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions cloud_run_worker/activities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from temporalio import activity


@activity.defn
async def hello_activity(name: str) -> str:
activity.logger.info("HelloActivity started with name: %s", name)
return f"Hello, {name}!"
42 changes: 42 additions & 0 deletions cloud_run_worker/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Deploy the Cloud Run worker to Google Cloud Run.
#
# Usage:
# ./deploy.sh <service-name> <region> <project>
#
# Example:
# ./deploy.sh my-temporal-worker us-central1 my-gcp-project
#
# Required env vars (pass via --set-env-vars or Secret Manager):
# TEMPORAL_ADDRESS - e.g. <namespace>.<account>.tmprl.cloud:7233
# TEMPORAL_NAMESPACE - e.g. <namespace>.<account>
# TEMPORAL_TLS_CERT - base64-encoded mTLS client certificate (Temporal Cloud)
# TEMPORAL_TLS_KEY - base64-encoded mTLS client private key (Temporal Cloud)

set -euo pipefail

SERVICE="${1:?Usage: $0 <service-name> <region> <project>}"
REGION="${2:?Usage: $0 <service-name> <region> <project>}"
PROJECT="${3:?Usage: $0 <service-name> <region> <project>}"

IMAGE="gcr.io/${PROJECT}/${SERVICE}:latest"

echo "Building and pushing image: ${IMAGE}"
gcloud builds submit --tag "${IMAGE}" --project "${PROJECT}" .

echo "Deploying Cloud Run service: ${SERVICE}"
gcloud run deploy "${SERVICE}" \
--image "${IMAGE}" \
--region "${REGION}" \
--project "${PROJECT}" \
--platform managed \
--no-allow-unauthenticated \
--set-env-vars "TEMPORAL_ADDRESS=${TEMPORAL_ADDRESS},TEMPORAL_NAMESPACE=${TEMPORAL_NAMESPACE}" \
--set-secrets "TEMPORAL_TLS_CERT=${SERVICE}-tls-cert:latest,TEMPORAL_TLS_KEY=${SERVICE}-tls-key:latest" \
--min-instances 1 \
--max-instances 10 \
--no-cpu-throttling

echo "Done. Service URL:"
gcloud run services describe "${SERVICE}" --region "${REGION}" --project "${PROJECT}" \
--format "value(status.url)"
41 changes: 41 additions & 0 deletions cloud_run_worker/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[project]
name = "temporalio-samples-cloud-run-worker"
version = "0.1a1"
description = "Temporal.io Python SDK Cloud Run worker sample"
authors = [{ name = "Temporal Technologies Inc", email = "sdk@temporal.io" }]
requires-python = ">=3.10"
readme = "README.md"
license = "MIT"
dependencies = ["temporalio>=1.28.0,<2"]

[dependency-groups]
dev = [
"ruff>=0.5.0,<0.6",
"mypy>=1.4.1,<2",
"poethepoet>=0.36.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.metadata]
allow-direct-references = true

[tool.hatch.build.targets.wheel]
packages = ["."]

[tool.poe.tasks]
format = [
{ cmd = "uv run ruff check --select I --fix" },
{ cmd = "uv run ruff format" },
]
lint = [
{ cmd = "uv run ruff check --select I" },
{ cmd = "uv run ruff format --check" },
{ ref = "lint-types" },
]
lint-types = "uv run --all-groups mypy --check-untyped-defs --namespace-packages ."

[tool.ruff]
target-version = "py310"
53 changes: 53 additions & 0 deletions cloud_run_worker/starter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Helper script to start a SampleWorkflow execution against the Cloud Run worker.

Run from the repo root:

TEMPORAL_ADDRESS=<address> TEMPORAL_NAMESPACE=<namespace> \\
uv run python -m cloud_run_worker.starter
"""

import asyncio
import os

from temporalio.client import Client, TLSConfig

from cloud_run_worker.workflows import TASK_QUEUE, SampleWorkflow


def _tls_config() -> TLSConfig | None:
cert_b64 = os.environ.get("TEMPORAL_TLS_CERT")
key_b64 = os.environ.get("TEMPORAL_TLS_KEY")
if cert_b64 and key_b64:
import base64

return TLSConfig(
client_cert=base64.b64decode(cert_b64),
client_private_key=base64.b64decode(key_b64),
)
return None


async def main() -> None:
address = os.environ.get("TEMPORAL_ADDRESS", "localhost:7233")
namespace = os.environ.get("TEMPORAL_NAMESPACE", "default")
task_queue = os.environ.get("TEMPORAL_TASK_QUEUE", TASK_QUEUE)

client = await Client.connect(
address,
namespace=namespace,
tls=_tls_config() or False,
)
print(f"Connected to Temporal Service at {address}")

result = await client.execute_workflow(
SampleWorkflow.run,
"Cloud Run Worker!",
id="cloud-run-workflow-id-1",
task_queue=task_queue,
)
print(f"Workflow result: {result}")


if __name__ == "__main__":
asyncio.run(main())
Loading