DEV Community

Cover image for Zero to Multi-Region: High Availability Serverless with Cloud Run and Cross-Region Failover & Failback
Caleb Duff for Google Developer Group

Posted on

Zero to Multi-Region: High Availability Serverless with Cloud Run and Cross-Region Failover & Failback

Google just made multi-region Cloud Run significantly easier. Here is the full picture; what changed, what it means in practice, and how to build it right.

Most teams discover they need multi-region architecture the hard way and sadly, during an outage. Whether you're running a global e-commerce platform, a real-time gaming API, or a financial services application, users expect your service to be available whenever they need it. There is a conversation that happens in almost every engineering team at some point. It usually starts with a post-mortem. A regional Google Cloud outage or a Cloud Run service that hit a cold start spike, or a single-region deployment that could not handle the latency demands of users spread across Lagos, Nairobi, and London simultaneously, caused enough pain that someone finally asked: why are we only deployed in one region?

The answer is usually one of three things: it felt complex, it felt expensive, or no one had prioritised it yet.

In July 2026, Google moved Cloud Run Service Health to General Availability and the timing was hard to miss. Six days earlier, a power cut at Google's Netherlands data centre had knocked three services offline. The GA release brings automatic cross-region failover to Cloud Run with what Google describes as a two-step setup: add a readiness probe, set minimum instances to at least 1. The load balancer does the rest.

This article covers the full architecture, what Service Health is, how readiness probes underpin it, how to set up the Global Load Balancer correctly, and how to test that failover actually works. It also covers the production details.

What changed: Service Health and readiness probes

Before Service Health, multi-region Cloud Run required you to implement a /health endpoint in your application and configure a separate HTTPS health check at the load balancer level. This worked, but it had a significant gap. The load balancer's health check only knew whether the Cloud Run service endpoint was responding, not whether the individual container instances behind it were actually ready to serve traffic.

Service Health introduces two new capabilities that close this gap:

Readiness probes operate at the container instance level. Cloud Run periodically sends an HTTP request to a path you specify on each running container instance. If the probe fails, Cloud Run stops routing requests to that instance until the probe succeeds again. Critically, a failing readiness probe does not kill the instance (that is what a liveness probe does), it simply marks the instance as not ready for traffic.

Service Health aggregates the readiness state of all container instances in a region into a single regional health signal. This aggregated health status is exposed through the Serverless NEGs (Network Endpoint Groups) for that region. When the Global Load Balancer reads the NEG's health status and sees a region is unhealthy, because enough instances are failing their readiness probes; it automatically reroutes traffic to a healthy region. When the failing region recovers, traffic is gradually restored without any operator action.

The result: failover and failback capabilities are now fully automated, triggered by real instance-level health rather than a synthetic endpoint check.

Container instance (readiness probe fails)
        │
        ▼
Cloud Run aggregates probe results across all instances in the region to determine the overall health status of each regional service
        │
        ▼
Service Health: region marked UNHEALTHY
        │
        ▼
Serverless NEG reports unhealthy status to Global Load Balancer
        │
        ▼
Load Balancer stops routing to this region → shifts traffic to healthy region
        │
        ▼
Region recovers → Load Balancer gradually restores traffic
Enter fullscreen mode Exit fullscreen mode

This is available in all Cloud Run regions at no extra charge beyond the CPU and memory consumed while readiness probes run.

Architecture overview

                    ┌──────────────────────────────────┐
                    │   Global Anycast IP (single IP)  │
                    │   + SSL Certificate (managed)    │
                    └───────────────┬──────────────────┘
                                    │
                    ┌───────────────▼──────────────────┐
                    │  Global External HTTP(S) LB      │
                    │  (URL map + forwarding rules)    │
                    └──────┬──────────────────┬────────┘
                           │                  │
        ┌──────────────────▼──┐         ┌─────▼──────────────────┐
        │  Serverless NEG     │         │  Serverless NEG        │
        │  africa-south1      │         │  us-central1           │
        │  (Service Health    │         │  (Service Health       │
        │   status: healthy)  │         │   status: healthy)     │
        └──────────┬──────────┘         └───────────┬────────────┘
                   │                                │
   ┌───────────────▼───────────┐     ┌──────────────▼──────────────┐
   │  Cloud Run Service        │     │  Cloud Run Service          │
   │  africa-south1            │     │  us-central1                │
   │  Readiness probe: /health │     │  Readiness probe: /health   │
   │  min-instances: 1+        │     │  min-instances: 1+          │
   │  (auto-scales 0–N)        │     │  (auto-scales 0–N)          │
   └───────────────────────────┘     └─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Prerequisites and setup

export PROJECT_ID="your-project-id"
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID \
  --format="value(projectNumber)")
export SERVICE="my-api"
export REGION_A="africa-south1"
export REGION_B="us-central1"
export DOMAIN="api.yourdomain.com"
export IMAGE="gcr.io/${PROJECT_ID}/${SERVICE}:latest"

gcloud config set project $PROJECT_ID

# Enable required APIs
gcloud services enable \
  run.googleapis.com \
  compute.googleapis.com \
  artifactregistry.googleapis.com \
  cloudbuild.googleapis.com \
  networkservices.googleapis.com

# Grant Cloud Build service account the Cloud Run builder role
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:${PROJECT_NUMBER}-   compute@developer.gserviceaccount.com" \
  --role="roles/run.builder"
Enter fullscreen mode Exit fullscreen mode

Step 1: Implement the readiness probe endpoint

The first step and the most important one for Service Health to work, is adding a readiness probe endpoint to your application. Unlike the previous/alternative approach where the /health endpoint was for the load balancer's benefit, this endpoint is called directly by Cloud Run on each container instance to determine per-instance readiness.

Two rules from the official docs that matter here:

  • Use an HTTP/1 endpoint (the Cloud Run default, not HTTP/2)
  • The endpoint path must match the path in your probe configuration exactly
// Node.js / Express
// Lightweight — no DB calls, no downstream dependencies
// This runs frequently on every instance
app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'healthy',
    region: process.env.REGION,
    timestamp: new Date().toISOString()
  });
});

// If you want the probe to reflect actual readiness
// (e.g. connection pool initialised), you can check internal state:
let isReady = false;
app.get('/health', (req, res) => {
  if (!isReady) {
    return res.status(503).json({ status: 'not_ready' });
  }
  res.status(200).json({ status: 'healthy', region: process.env.REGION });
});

// Set isReady = true after your startup tasks complete
pool.connect().then(() => { isReady = true; });
Enter fullscreen mode Exit fullscreen mode
# Python / FastAPI
import os
from datetime import datetime
from fastapi import FastAPI, Response

app = FastAPI()
is_ready = False

@app.get("/health")
async def readiness_probe(response: Response):
    if not is_ready:
        response.status_code = 503
        return {"status": "not_ready"}
    return {
        "status": "healthy",
        "region": os.environ.get("REGION"),
        "timestamp": datetime.utcnow().isoformat()
    }

@app.on_event("startup")
async def startup_event():
    global is_ready
    # Initialise connections, warm caches, etc.
    await init_database_pool()
    is_ready = True
Enter fullscreen mode Exit fullscreen mode

The is_ready pattern is the key upgrade over a basic /health endpoint. The readiness probe on each instance will return 503 until your startup tasks complete, preventing the load balancer from routing traffic to an instance that is running but not yet ready.

Step 2: Deploy to multiple regions with readiness probes

The gcloud run deploy supports deploying to multiple regions in a single command, and the --readiness-probe flag attaches the probe configuration at deploy time. Failovers require at least two (2) services from different regions.

# Deploy to both regions simultaneously with readiness probe
gcloud run deploy $SERVICE \
  --image=$IMAGE \
  --regions=$REGION_A,$REGION_B \
  --min=1 \
  --max-instances=100 \
  --concurrency=80 \
  --cpu=1 \
  --memory=512Mi \
  --timeout=30s \
  --readiness-probe="httpGet.path=/health" \
  --set-env-vars="ENV=production" \
  --allow-unauthenticated
Enter fullscreen mode Exit fullscreen mode

The --readiness-probe="httpGet.path=/health" flag is the new way to configure probes at deploy time. You can also configure additional probe parameters:

# Full readiness probe configuration
gcloud run deploy $SERVICE \
  --image=$IMAGE \
  --regions=$REGION_A,$REGION_B \
  --min=2 \
  --readiness-probe="httpGet.path=/health,periodSeconds=10,failureThreshold=3,successThreshold=1,timeoutSeconds=5"
Enter fullscreen mode Exit fullscreen mode

Or via YAML service definition (the Terraform-friendly approach):

# service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: my-api
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "1"
        autoscaling.knative.dev/maxScale: "100"
    spec:
      containers:
      - image: gcr.io/PROJECT_ID/my-api:latest
        resources:
          limits:
            cpu: "1"
            memory: 512Mi
        env:
        - name: ENV
          value: production
        readinessProbe:
          httpGet:
            path: /health
          periodSeconds: 10
          failureThreshold: 3
          successThreshold: 1
          timeoutSeconds: 5
        livenessProbe:
          httpGet:
            path: /health
          periodSeconds: 30
          failureThreshold: 3
Enter fullscreen mode Exit fullscreen mode

The difference between readiness and liveness probes

Both probe types are supported on Cloud Run. Understanding the distinction is critical:

Readiness probe failure: Cloud Run stops routing requests to that instance. The instance continues running. Once the probe succeeds again, routing resumes. Service Health aggregates these to determine regional health.

Liveness probe failure: Cloud Run restarts the container instance. Use liveness probes for detecting deadlocks or unrecoverable stuck states.

For Service Health's automatic failover, readiness probes are what matter. Liveness probes are a complement, they handle instance-level recovery, while readiness probes handle traffic routing decisions.

Step 3: Set up the global external Application Load Balancer

With the new Service Health model, the load balancer configuration is simpler than before, you no longer need to configure a separate HTTPS health check at the load balancer level. Service Health exposes regional health through the Serverless NEG itself.

Create the backend service

# Single backend service, both regions are added as NEG backends
gcloud compute backend-services create $SERVICE-bs \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --global
Enter fullscreen mode Exit fullscreen mode

Note: unlike the earlier approach with separate backend services per region, Service Health works with a single backend service that has multiple regional NEG backends. The load balancer reads health from each NEG and routes accordingly.

Reserve a global static IP (Set up a global static external IP address to reach your load balancer:)

gcloud compute addresses create $SERVICE-ip \
  --network-tier=PREMIUM \
  --ip-version=IPV4 \
  --global

export GLOBAL_IP=$(gcloud compute addresses describe $SERVICE-ip \
  --global --format="get(address)")

echo "Global IP: ${GLOBAL_IP}"
# → Update your DNS A record to this IP before proceeding
Enter fullscreen mode Exit fullscreen mode

Create URL map, proxy, and forwarding rules

# Create a URL map to route incoming requests to the backend service:
gcloud compute url-maps create $SERVICE-lb \
  --default-service=$SERVICE-bs

# For HTTPS (recommended for production):
# Create Google-managed SSL certificate
gcloud compute ssl-certificates create $SERVICE-ssl \
  --domains=$DOMAIN \
  --global

# Create the target HTTPS proxy to route requests to your URL map:
gcloud compute target-https-proxies create $SERVICE-https-proxy \
  --url-map=$SERVICE-lb \
  --ssl-certificates=$SERVICE-ssl

# Create the HTTPS forwarding rule  to route incoming requests to the proxy:
gcloud compute forwarding-rules create $SERVICE-https-fr \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --network-tier=PREMIUM \
  --address=$SERVICE-ip \
  --target-https-proxy=$SERVICE-https-proxy \
  --global \
  --ports=443

# HTTP forwarding rule (redirect to HTTPS)
gcloud compute target-http-proxies create $SERVICE-http-proxy \
  --url-map=$SERVICE-lb

gcloud compute forwarding-rules create $SERVICE-http-fr \
  --load-balancing-scheme=EXTERNAL_MANAGED \
  --network-tier=PREMIUM \
  --address=$SERVICE-ip \
  --target-http-proxy=$SERVICE-http-proxy \
  --global \
  --ports=80
Enter fullscreen mode Exit fullscreen mode

Step 4: Create Serverless NEGs and attach them

# Serverless NEG for africa-south1
gcloud compute network-endpoint-groups create $SERVICE-neg-$REGION_A \
  --region=$REGION_A \
  --network-endpoint-type=serverless \
  --cloud-run-service=$SERVICE

# Serverless NEG for us-central1
gcloud compute network-endpoint-groups create $SERVICE-neg-$REGION_B \
  --region=$REGION_B \
  --network-endpoint-type=serverless \
  --cloud-run-service=$SERVICE

# Add both NEGs to the single backend service
gcloud compute backend-services add-backend $SERVICE-bs \
  --global \
  --network-endpoint-group=$SERVICE-neg-$REGION_A \
  --network-endpoint-group-region=$REGION_A

gcloud compute backend-services add-backend $SERVICE-bs \
  --global \
  --network-endpoint-group=$SERVICE-neg-$REGION_B \
  --network-endpoint-group-region=$REGION_B
Enter fullscreen mode Exit fullscreen mode

At this point, Service Health is active. Cloud Run is running readiness probes on every instance in both regions, aggregating the results into a regional health signal, and the load balancer reads that signal via the Serverless NEGs.

Step 5: Monitor Service Health with Cloud Monitoring

Service Health exposes two metrics through Cloud Monitoring that you should track from day one:

run.googleapis.com/container/instance_count_with_readiness, the number of instances passing their readiness probe per region. Watch this metric to see the health state of your instance pool in each region in real time.

run.googleapis.com/service_health_count, the regional Cloud Run service health as reported to the load balancer. Possible values: HEALTHY, UNHEALTHY, UNKNOWN. The load balancer uses this to make failover decisions. UNKNOWN is reported until the service has enough data from probes to determine health, typically within the first few minutes of deployment.

# View current service health status via gcloud
gcloud run services describe $SERVICE \
  --region=$REGION_A \
  --format="value(status.conditions)"

# Or check via the Console:
# Cloud Run → your service → Metrics tab → "Instance count with readiness"
Enter fullscreen mode Exit fullscreen mode

Set up an alerting policy that fires when service_health_count for any region transitions to UNHEALTHY:

# alert-policy.yaml
displayName: "Cloud Run region unhealthy  failover active"
conditions:
- displayName: "Service health UNHEALTHY"
  conditionThreshold:
    filter: |
      resource.type="cloud_run_revision"
      metric.type="run.googleapis.com/service_health_count"
      metric.labels.health_status="UNHEALTHY"
    comparison: COMPARISON_GT
    thresholdValue: 0
    duration: 60s
    aggregations:
    - alignmentPeriod: 60s
      perSeriesAligner: ALIGN_MAX
notificationChannels:
- projects/${PROJECT_ID}/notificationChannels/YOUR_CHANNEL_ID
documentation:
  content: |
    A Cloud Run region has become unhealthy and traffic is being
    rerouted to the remaining healthy region(s). Investigate the
    failing region's logs and instance readiness metrics immediately.
Enter fullscreen mode Exit fullscreen mode

Step 6: Testing failover

Testing is not optional, it is the only way to know your failover actually works before your users discover it during a real incident.

Method 1: Use the sample application's toggle (for the official sample)

The Google Cloud sample application (golang-samples/run/service-health) includes a built-in toggle button in its UI that marks a region as unhealthy. For production applications, use Method 2.

Method 2: Force readiness probe failure via environment variable

# Redeploy africa-south1 with a flag that makes /health return 503
gcloud run deploy $SERVICE \
  --image=$IMAGE \
  --region=$REGION_A \
  --set-env-vars="FORCE_UNHEALTHY=true,ENV=production"
Enter fullscreen mode Exit fullscreen mode

In your application, check this variable:

app.get('/health', (req, res) => {
  if (process.env.FORCE_UNHEALTHY === 'true') {
    return res.status(503).json({ status: 'forced_unhealthy' });
  }
  res.status(200).json({ status: 'healthy', region: process.env.REGION });
});
Enter fullscreen mode Exit fullscreen mode

Observe the failover sequence

# Get load balancer IP
export LBIP=$(gcloud compute addresses describe $SERVICE-ip \
  --global --format='value(address)')

# Continuous requests — watch region shift in responses
while true; do
  RESPONSE=$(curl -s https://${DOMAIN}/health)
  echo "$(date '+%H:%M:%S')$RESPONSE"
  sleep 2
done
Enter fullscreen mode Exit fullscreen mode

You should observe:

  1. Requests showing "region": "africa-south1" — normal operation
  2. A mix of responses as the probe failure propagates across instances
  3. All requests showing "region": "us-central1" — failover complete
  4. The service_health_count metric for africa-south1 showing UNHEALTHY

Restore the region:

gcloud run deploy $SERVICE \
  --image=$IMAGE \
  --region=$REGION_A \
  --set-env-vars="ENV=production"
Enter fullscreen mode Exit fullscreen mode

Traffic gradually returns to africa-south1 as instances pass their readiness probes and Service Health transitions back to HEALTHY.

Safe rollout strategy using readiness probes

One of the most powerful features of the new readiness probe model is the ability to do canary deployments across regions with automatic rollback via Service Health.

The official recommended rollout process:

# Step 1: Deploy new revision to ONE region with 1% traffic
gcloud run deploy $SERVICE \
  --image=$IMAGE_NEW \
  --region=$REGION_A \
  --readiness-probe="httpGet.path=/health" \
  --min=1 \
  --no-traffic  # Deploy but send no traffic yet

# Step 2: Send 1% of traffic to new revision in REGION_A only
gcloud run services update-traffic $SERVICE \
  --region=$REGION_A \
  --to-revisions=LATEST=1

# Step 3: Monitor readiness metric
# run.googleapis.com/container/instance_count_with_readiness
# If this stays healthy, continue increasing traffic

# Step 4: Ramp to 100% in REGION_A
gcloud run services update-traffic $SERVICE \
  --region=$REGION_A \
  --to-revisions=LATEST=100

# Step 5: Once REGION_A service_health_count is stable HEALTHY,
# deploy to REGION_B
gcloud run deploy $SERVICE \
  --image=$IMAGE_NEW \
  --region=$REGION_B \
  --readiness-probe="httpGet.path=/health" \
  --min=1
Enter fullscreen mode Exit fullscreen mode

If the new revision's readiness probes fail in REGION_A, Service Health marks that region unhealthy, the load balancer routes traffic to REGION_B (still running the old revision), and you have automatic rollback without a single manual step.

Known limitations of Service Health (GA)

The official documentation lists several limitations worth knowing before you build:

  • Minimum instances required. You must configure at least one minimum instance per region for Service Health to calculate health. A region with zero running instances cannot report health, which means a cold-start region cannot participate in automatic failover.
  • Minimum two regions. Failover requires at least two services from different regions. If you only deploy to one region and it fails, the load balancer returns no healthy upstream.
  • Max 5 NEG backends for cross-region internal LB. The limitation applies to the internal load balancer variant, not the global external LB.
  • No URL masks or tags in Serverless NEGs. If your routing requires URL masks, you cannot use Service Health's NEG model.
  • No IAP from the backend service. If you need Identity-Aware Proxy, configure it directly on the Cloud Run service, not at the load balancer backend.
  • First probe on new instances. A newly started instance will not have its first readiness probe counted before it begins receiving traffic. This means a very brief window where traffic may route to an instance before it has confirmed readiness.
  • Revisions without probes are treated as unknown. The load balancer treats unknown health as healthy, so if you deploy a revision without a readiness probe configured, it will receive traffic regardless.

The last two points are important for zero-downtime deployments. The recommended safe rollout process (canary in one region before the other) directly addresses both.

What this architecture does not solve

Database availability. Compute-layer failover is irrelevant if your Cloud Run service connects to a single-region Cloud SQL instance. The database tier needs its own HA: Cloud SQL cross-region read replicas, Cloud Spanner for global consistency, or Firestore in Native mode (inherently multi-region).

Stateful sessions. Cloud Run is stateless. Cross-region routing will invalidate in-memory sessions. Use Cloud Memorystore (Redis) or stateless JWT-based sessions.

Data residency. Routing traffic across regions may conflict with NDPR, GDPR, or sector-specific regulations. Know your data residency obligations before deploying multi-region.

Pub/Sub push subscriptions. By default, Pub/Sub delivers messages to push endpoints in the same region where it stores the messages. A multi-region Cloud Run setup behind a global LB does not automatically receive Pub/Sub push traffic from all regions. The official docs provide a workaround, review the Pub/Sub multi-region push documentation before building event-driven architectures on this pattern.

Top comments (0)