In 02-07 a decision was written down and left pending. DA-001: AlpinaShop's web catalogue must move to Cloud Run. It was justified with a table, signed off, and stored in the README of alpinashop-catalogo. Since then the course has come back to it a dozen times — in the load balancer of 03-02, in the secrets of 03-06, in Pub/Sub push in 04-04, in Cloud Functions in 06-03, in the Ops Agent in 06-04 — always with the same refrain: "in 07-02".

This is 07-02. Here it gets done.

It is not narrative-consistency indulgence: it is that until now the conditions were not there. Moving the production catalogue to another platform without a pipeline that builds reproducible images, without observability to know whether it went well, without infrastructure as code to roll back and without a load balancer in front to absorb the change, is reckless. With all four things in place — modules 3 and 6 — the move is an afternoon's work with a safety net.

By the end of the lesson, AlpinaShop's catalogue will be served from Cloud Run, behind the same IP, the same domain, the same certificate, the same CDN and the same WAF it had; it will have gone through a canary deployment at 10 %; the alpinashop-web-mig MIG will be switched off; and the catalogue's compute bill will have dropped from about €50 a month to under €10. And you will know why concurrency is the parameter most people configure wrongly.

Contents

  1. What Cloud Run is and why the container contract matters
  2. Services and jobs: how to choose
  3. Deploying the catalogue: gcloud run deploy flag by flag
  4. The same service in Terraform
  5. Revisions and traffic split: canary, tags and rollback
  6. Concurrency: the concept that is most misunderstood
  7. Scaling: zero, minimum, maximum and the CPU billing model
  8. Connecting Cloud Run to the rest of the platform
  9. Service identity and security
  10. Behind the global load balancer: the serverless NEG
  11. The migration from the MIG, step by step and with no downtime
  12. Cost: the model and the comparative calculation
  13. Limits and when Cloud Run does not fit
  14. The mature table: Cloud Run vs Functions vs GKE vs Compute Engine
  15. Switching off alpinashop-web-mig and what that simplifies

  1. What Cloud Run is and why the container contract matters

Cloud Run runs containers serverlessly. You give it an image, it gives you an HTTPS URL, and it scales from zero to many instances according to traffic, charging for what you use.

Put like that it sounds like App Engine under another name, and it is not. The difference is in the unit of deployment:

Platform What you hand over Consequence
App Engine standard Source code + app.yaml Fixed runtimes, proprietary format, not very portable
Cloud Functions A function and its signature One entry point; Google supplies the runtime
Cloud Run A container image Any language, any binary, any system dependency
GKE Kubernetes manifests Total control and total complexity

That the unit is a standard container is what makes Cloud Run different and what resolved the most delicate point of DA-001: portability. The same image that runs today on alpinashop-cluster will run tomorrow on Cloud Run without touching a line. And if one day you had to leave, that image runs anywhere there is a container runtime.

The container contract

Cloud Run does not run any image any way it likes: it demands compliance with a contract, and it is deliberately short.

Rule Detail Why
Listen on $PORT The PORT environment variable (default 8080), on 0.0.0.0 The platform chooses the port and routes to it
Answer HTTP/gRPC/WebSocket Traffic comes in over the network, not from local events It is a request platform
Start fast Listen on the port in seconds, not minutes Every cold start is latency for a user
No state on disk The file system is ephemeral and lives in memory Instances are born and die without warning
No background processes outside the request Unless CPU is always allocated By default the CPU is frozen between requests
Linux x86-64 or arm64 container No privileges, no kernel access Multi-tenant isolation

Those six lines are all of it. There is no new deployment format to learn, and that is the reason Cloud Run has become the default option for web applications on Google Cloud.

Two frequent breaches of the contract, stated up front:

# WRONG: fixed port and only listens on localhost
app.run(host="127.0.0.1", port=5000)

# RIGHT: the platform states the port and you listen on all interfaces
import os
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))

The first produces the most common beginner's error on Cloud Run: "The user-provided container failed to start and listen on the port defined provided by the PORT environment variable". When you see it, you already know what to look at.

How it works inside

flowchart TB
    U["Users"] --> LB["Managed HTTPS frontend<br/>or global load balancer"]
    LB --> CR["Cloud Run - service alpinashop-web"]
    subgraph CR
      direction LR
      R1["Revision 0042<br/>90% of traffic"]
      R2["Revision 0043<br/>10% of traffic"]
    end
    R1 --> I1["Instance 1<br/>concurrency 80"]
    R1 --> I2["Instance 2"]
    R2 --> I3["Instance 3"]
    I1 -.-> SQL["Cloud SQL<br/>alpinashop-pedidos"]
    I1 -.-> SM["Secret Manager"]
    I1 -.-> GCS["Bucket<br/>alpinashop-catalogo"]

Three concepts to fix in your mind right away:

  • Service: the stable entity, with a name and a URL. alpinashop-web.
  • Revision: an immutable version of the service (image + configuration). alpinashop-web-00042-abc. It is never modified: every change creates a new revision.
  • Instance: a running container of a revision. They are born and die on their own.

The immutability of revisions is what makes the traffic split and the instant rollback of section 5 possible.

  1. Services and jobs: how to choose

Cloud Run has two ways of running a container, and choosing badly is a classic source of odd architectures.

Service Job
Triggered by An incoming HTTP request An explicit execution (jobs execute, Scheduler, Workflows)
Must listen on $PORT Yes No
Ends Never (it lives while there is traffic) When the process exits with code 0
Scales by Concurrent requests --tasks: N tasks in parallel
Maximum duration Up to 60 minutes per request Up to 24 hours per task
Retries Done by the client Built in: --max-retries
Billing By instance time By task time

The decision rule, in one sentence:

If something answers somebody, it is a service. If something does one thing and finishes, it is a job.

Applied to AlpinaShop:

Workload Type Why
Web catalogue Service It answers customer requests
Internal stock API Service It answers the synchronisation process
Nightly sales report Job It runs, produces a file, finishes
Search index rebuild Job Batch process, 20 minutes, with no client waiting
DB schema migration Job One execution, with retries and an exit code
Processing an uploaded image Cloud Function (06-03) Single event, already solved and running

The classic antipattern — and it is seen a lot — is to build the nightly report as a service with an /ejecutar-informe endpoint that Cloud Scheduler calls. It works, but: you have an HTTP endpoint that has to be protected, execution is capped by the maximum request time, there are no built-in retries, and if it takes too long the process is cut off halfway. A job removes all four problems.

A real AlpinaShop job, the nightly report from 04-06:

gcloud run jobs create informes-nocturnos \
  --image=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/informes:v3 \
  --region=europe-west1 \
  --service-account=sa-informes-nocturnos@alpinashop-prod.iam.gserviceaccount.com \
  --tasks=1 \
  --max-retries=2 \
  --task-timeout=30m \
  --memory=1Gi \
  --set-env-vars=DATASET=alpinashop_analitica

And its execution from Cloud Scheduler, without exposing any HTTP endpoint of your own:

gcloud scheduler jobs create http disparar-informes-nocturnos \
  --location=europe-west1 \
  --schedule="30 3 * * *" \
  --time-zone="Europe/Madrid" \
  --uri="https://run.googleapis.com/v2/projects/alpinashop-prod/locations/europe-west1/jobs/informes-nocturnos:run" \
  --http-method=POST \
  --oauth-service-account-email=sa-informes-nocturnos@alpinashop-prod.iam.gserviceaccount.com

Note --time-zone="Europe/Madrid": without it, the 30 3 * * * is UTC and the report will run at 5:30 in summer. It is a mistake you discover in March or October and that baffles you for hours.

  1. Deploying the catalogue: gcloud run deploy flag by flag

The catalogue image has been in Artifact Registry since 06-01, built by Cloud Build and tagged with $COMMIT_SHA. All that is left is to deploy it. This is AlpinaShop's full command, with each flag explained below.

gcloud run deploy alpinashop-web \
  --image=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2 \
  --region=europe-west1 \
  --platform=managed \
  --service-account=sa-catalogo-web@alpinashop-prod.iam.gserviceaccount.com \
  --no-allow-unauthenticated \
  --port=8080 \
  --cpu=1 \
  --memory=512Mi \
  --concurrency=80 \
  --min-instances=1 \
  --max-instances=30 \
  --timeout=60s \
  --cpu-throttling \
  --execution-environment=gen2 \
  --set-env-vars=ENTORNO=prod,REGION=europe-west1 \
  --set-secrets=DB_PASSWORD=db-password-catalogo:latest \
  --add-cloudsql-instances=alpinashop-prod:europe-west1:alpinashop-pedidos \
  --labels=entorno=prod,equipo=desarrollo,centro-coste=tienda,aplicacion=catalogo \
  --no-traffic \
  --tag=candidata
Flag What it does Why this value at AlpinaShop
--image Image to deploy Tagged with the commit hash, never latest (06-01). With latest you do not know what is running
--region Region of the service europe-west1, where everything else is. Cloud Run is regional
--platform=managed Managed Cloud Run The alternative was gke, now deprecated (07-01)
--service-account Identity of the service Its own account with least privilege (03-04). Without this it uses the default Compute account, which is far too permissive
--no-allow-unauthenticated Requires IAM to invoke The service is not exposed to the internet directly: it will come in through the load balancer (section 10)
--port Container port 8080, the same as in the contract and in the hc-catalogo probe
--cpu vCPU per instance 1 vCPU. It can be fractional (0.5, 0.25) or go up to 8
--memory Memory per instance 512 MiB. Measured, not guessed: see section 6
--concurrency Simultaneous requests per instance 80, the default value, validated by measuring (section 6)
--min-instances Instances always kept on 1, so no customer suffers a cold start
--max-instances Instance ceiling 30, as a cost firewall and a Cloud SQL connection firewall
--timeout Maximum time per request 60 s. The maximum is 60 min, but a catalogue that takes 60 s is already broken
--cpu-throttling CPU only during the request Default mode and the cheapest
--execution-environment=gen2 Execution environment Second generation: full Linux compatibility, slightly slower start-up, required for mounting volumes
--set-env-vars Environment variables Non-sensitive configuration
--set-secrets Mounted secrets The password never as a plain-text variable (03-06)
--add-cloudsql-instances Cloud SQL connector Enables the Unix socket to alpinashop-pedidos
--labels Labels AlpinaShop's four from 01-04, indispensable for 07-05
--no-traffic Deploys without sending traffic to it Creates the revision but does not activate it. Key for the canary
--tag=candidata Revision tag Gives it its own URL so you can test it before giving it traffic

The last two are what turn a deployment into a safe deployment, and they deserve their own section.

  1. The same service in Terraform

AlpinaShop has managed its infrastructure with Terraform since 06-07, so the previous command is for experimenting: what goes to main is HCL.

# entornos/prod/cloud_run_catalogo.tf

resource "google_cloud_run_v2_service" "catalogo" {
  name     = "alpinashop-web"
  location = var.region                       # europe-west1
  project  = var.proyecto_prod

  # Only internal and load balancer traffic: nobody reaches it via the run.app URL
  ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  template {
    service_account = google_service_account.sa_catalogo_web.email

    scaling {
      min_instance_count = 1
      max_instance_count = 30
    }

    max_instance_request_concurrency = 80
    timeout                          = "60s"

    containers {
      image = "europe-west1-docker.pkg.dev/${var.proyecto_prod}/alpinashop/catalogo:${var.version_catalogo}"

      ports {
        container_port = 8080
      }

      resources {
        limits = {
          cpu    = "1"
          memory = "512Mi"
        }
        cpu_idle          = true    # CPU only during the request
        startup_cpu_boost = true    # extra CPU during start-up
      }

      env {
        name  = "ENTORNO"
        value = "prod"
      }

      env {
        name = "DB_PASSWORD"
        value_source {
          secret_key_ref {
            secret  = "db-password-catalogo"
            version = "latest"
          }
        }
      }

      volume_mounts {
        name       = "cloudsql"
        mount_path = "/cloudsql"
      }
    }

    volumes {
      name = "cloudsql"
      cloud_sql_instance {
        instances = [google_sql_database_instance.pedidos.connection_name]
      }
    }
  }

  # Explicit traffic split: 100% to the latest stable revision
  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
  }

  labels = {
    entorno      = "prod"
    equipo       = "desarrollo"
    centro-coste = "tienda"
    aplicacion   = "catalogo"
  }
}

Four important observations about this HCL:

  1. Use google_cloud_run_v2_service, not google_cloud_run_service. The v1 resource still exists for compatibility and its syntax (based on Knative annotations) is far worse. Everything new goes in v2.
  2. cpu_idle = true is the equivalent of --cpu-throttling: CPU is only billed during requests. false means CPU always allocated, and it is up to four times more expensive.
  3. startup_cpu_boost = true gives additional CPU during start-up. It reduces the cold start by between 20 % and 50 % in applications with heavy initialisation (JVM, large frameworks), and you only pay for that half second.
  4. ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" is the security piece: nobody can reach the service via its *.run.app URL. Only traffic from the global load balancer gets in.

And there is a tension worth resolving explicitly, because it is the question everybody asks on getting here: if the pipeline does the deploying, what does Terraform manage?

Aspect Who manages it Reason
Existence of the service, service account, ingress, secrets, scaling, VPC Terraform It is the shape; it changes by human decision
Version of the deployed image Cloud Build It changes several times a day; it is content
Traffic split between revisions Cloud Build during the canary, Terraform at rest It is an operation, not a configuration

It is exactly the rule of 06-07: Terraform manages the shape, not the content. In practice it is resolved with lifecycle { ignore_changes = [template[0].containers[0].image, traffic] } on the resource, so that Terraform does not try to put the image back to the version in its file every time it runs.

  1. Revisions and traffic split: canary, tags and rollback

Here is one of Cloud Run's best capabilities, and it is practically free.

Revision tags: testing without risk

When you deploy with --no-traffic --tag=candidata, Cloud Run creates the revision and assigns it a URL of its own:

https://candidata---alpinashop-web-x7fq2abc-ew.a.run.app

That URL points only to that revision, with zero production traffic. It is useful for:

  • Testing the new version against the real database, without any customer seeing it.
  • Passing the pipeline's automated smoke tests.
  • Showing it to somebody before publishing it.
# Smoke test against the candidate revision, with a token because the service is private
URL=$(gcloud run services describe alpinashop-web --region=europe-west1 \
        --format='value(status.traffic[?tag==`candidata`].url)')

curl -sf -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
     "$URL/salud" || { echo "PROBE FAILS"; exit 1; }

The 10 % canary

If the test passes, it is given a small percentage of real traffic:

gcloud run services update-traffic alpinashop-web \
  --region=europe-west1 \
  --to-tags=candidata=10

From that moment on, one in every ten requests goes to the new revision. You watch for twenty or thirty minutes on the dashboard from 06-04 — error rate, p95 latency, errors in Error Reporting — and you decide.

Recommended full progression:

Step Traffic What you watch Duration
1 0 % (tag only) Smoke tests 2 min
2 10 % 5xx error rate, p95 latency 20-30 min
3 50 % The same, with volume 30-60 min
4 100 % Stabilisation —
gcloud run services update-traffic alpinashop-web --region=europe-west1 --to-tags=candidata=50
gcloud run services update-traffic alpinashop-web --region=europe-west1 --to-latest

The instant rollback

And this is the part that changes how you sleep at night. If the new revision fails:

# See the revisions and their current split
gcloud run revisions list --service=alpinashop-web --region=europe-west1

# Send ALL the traffic back to the previous known good revision
gcloud run services update-traffic alpinashop-web \
  --region=europe-west1 \
  --to-revisions=alpinashop-web-00041-xyz=100

It takes seconds and rebuilds or redeploys nothing, because the previous revision still exists intact. Compare that with the MIG: you had to create a new instance template, launch a rolling update and wait for the VMs to be replaced one by one — between five and fifteen minutes, with the site degraded in the meantime.

Operational tip: keep the rollback command written in the runbook (07-06) with the revision number filled in as a variable. At three in the morning nobody remembers the syntax of --to-revisions.

Two warnings about the canary

  1. The canary does not protect against database schema changes. If the new revision runs ALTER TABLE, the 90 % still on the old one also sees the changed table. Schema migrations must be backwards compatible (add columns, never rename them in the same deployment), and be run as a separate job.
  2. With little traffic, 10 % means nothing statistically. If AlpinaShop receives 30 requests per minute outside the campaign, 10 % is 3 requests. A canary that sees no errors for 20 minutes across 60 requests has not demonstrated much. At low volumes it is better to raise the percentage and lengthen the time, or run the canary during busier hours.

  1. Concurrency: the concept that is most misunderstood

If you take just one section away from this lesson, make it this one.

Concurrency is the maximum number of requests Cloud Run sends simultaneously to a single instance before starting another one.

The default value is 80. And here is the source of all the misunderstandings: many people come to Cloud Run from Cloud Functions or from AWS Lambda, where the model is one request per instance, and assume Cloud Run works the same way. It does not.

An honest comparison of the two models

One request per instance (--concurrency=1) High concurrency (--concurrency=80)
Isolation Total: every request has its own container Shared: 80 requests share memory and CPU
Reasoning Trivial: there is no concurrency to manage You have to think about global variables and shared state
Cost Very high: one instance per request Low: 80 requests pay for one instance
Cold starts Loads of them: every spike creates instances Few
I/O performance Dreadful: the CPU sits idle waiting for the DB Excellent: while one request waits, another uses the CPU
Fits well with CPU-intensive workloads, thread-unsafe code, ML Normal web applications and APIs

The key is in the penultimate row. A typical AlpinaShop catalogue request spends its 200 ms like this:

|-- 5 ms --|------------ 170 ms ------------|-- 25 ms --|
   parse        waiting for Cloud SQL          render
   (CPU)            (CPU IDLE)                 (CPU)

For 85 % of the time the CPU does nothing: it waits for the database. With --concurrency=1, you pay for a whole vCPU to sit idle for 170 ms out of every 200. With --concurrency=80, that same vCPU serves other requests during that wait.

How to choose it by measuring

The right concurrency is not guessed, it is measured. The procedure:

Step 1 — Find out your application's concurrency model. It comes first and almost nobody checks it:

Server Model Useful concurrency
Flask with app.run() (development) One thread 1. Do not put this in production
Gunicorn with synchronous workers workers × 1 Equal to the number of workers
Gunicorn with gthread workers × threads That product
Node.js / Express Event loop High: 80-250
Go / Java with threads High 80-1000
Python with asyncio (FastAPI + Uvicorn) Event loop High: 80-250

The most expensive mistake of all: setting --concurrency=80 on a server that only processes one request at a time. Cloud Run sends it 80, the server queues them, and requests 2 to 80 wait their turn. Latency goes through the roof and it looks like a platform problem. It is an application-server configuration problem.

For AlpinaShop's catalogue, which is Flask, the Dockerfile ends like this:

# 4 processes x 20 threads = 80 real simultaneous requests
CMD exec gunicorn --bind :$PORT \
                  --workers 4 \
                  --threads 20 \
                  --worker-class gthread \
                  --timeout 0 \
                  main:app

--timeout 0 disables Gunicorn's timer because the timeout is governed by Cloud Run with --timeout=60s. Having two timers competing produces inexplicable cut-offs.

Step 2 — Load test with different values. You deploy tagged revisions and measure each one:

for C in 1 10 40 80 160; do
  gcloud run deploy alpinashop-web \
    --image=$IMAGEN --region=europe-west1 \
    --concurrency=$C --no-traffic --tag=c$C
done

And with a load tool (hey, k6, vegeta) against each tagged URL. Real results for the catalogue, at 50 requests per second sustained for 5 minutes:

Concurrency Instances used p50 latency p95 latency Relative cost
1 12 190 ms 240 ms 12×
10 5 195 ms 260 ms 5×
40 2 200 ms 290 ms 2×
80 1 210 ms 340 ms 1×
160 1 380 ms 1200 ms 1×

Reading the table, which is where the learning is:

  • From 1 to 80, p50 latency barely rises (190 → 210 ms) and the cost is divided by twelve. An obvious gift.
  • From 80 to 160 latency goes through the roof: Gunicorn's 80 real threads have been exceeded and requests queue up. The ceiling is not set by Cloud Run, it is set by your application.
  • The p95 degrades before the p50 does. That is why you measure the p95: it is where you see the pain before it becomes obvious.

Step 3 — Tune CPU and memory to the chosen concurrency. They are coupled parameters:

  • With concurrency 80, the instance needs memory for 80 simultaneous requests. If each one consumes 4 MiB of structures, that is 320 MiB in requests alone, plus the runtime.
  • If the application is CPU intensive, high concurrency makes requests compete for the same vCPU. That is when you raise the CPU or lower the concurrency.

The sign that memory is running short is unmistakable in the logs:

Memory limit of 512 MiB exceeded with 518 MiB used. Consider increasing the memory limit.

When that happens, the instance dies and the in-flight requests are lost. It is one of the few Cloud Run failures that shows up as 5xx errors with no application trace.

  1. Scaling: zero, minimum, maximum and the CPU billing model

Scaling to zero

With no traffic, Cloud Run switches off all the instances and charges nothing. It is the property that makes a development environment cost pennies.

The price of that property is the cold start: the first request after a period of inactivity has to wait for an instance to be created, the image downloaded and the application started.

Phase Typical time How it is reduced
Provision the sandbox 100-300 ms Not in your hands
Download the image 200 ms - 5 s A small image. It is the main lever
Start the application 100 ms - 10 s Fewer dependencies, lazy loading, startup_cpu_boost

Indicative figures for a full cold start: a Go binary in a 15 MB distroless image starts in about 300 ms; a Python application with lots of dependencies, in 1-3 s; an untuned JVM with Spring Boot, between 8 and 20 s. The difference is made by the image and the runtime, not by Cloud Run.

--min-instances: paying not to be cold

gcloud run services update alpinashop-web --region=europe-west1 --min-instances=1

It keeps N instances always alive. An idle instance is billed at a reduced rate — of the order of a tenth of an active one — so one minimum instance is cheap: a few euros a month.

Criteria for AlpinaShop:

Service --min-instances Reason
alpinashop-web (prod) 1 A customer arriving at 07:00 should not wait 2 s
alpinashop-web (dev) 0 Nobody suffers from a cold start in development
Internal services 0 They are called by software, which can wait
During the autumn campaign 3 To cushion the morning start-up

Important warning: --min-instances greatly reduces cold starts, but it does not eliminate them. If traffic rises suddenly, new instances have to be created and those start cold just the same. And there is a counterintuitive detail: with CPU limited to requests (cpu_idle=true), the minimum instances have their CPU frozen between requests, so if your application does something periodic in the background, it will not run.

--max-instances: the cost firewall

gcloud run services update alpinashop-web --region=europe-west1 --max-instances=30

This is protection, and it must always be set. Two reasons:

  1. Cost. An infinite loop in the code, an aggressive crawler or an attack can launch hundreds of instances. With no ceiling, the day's bill is an incident.
  2. Downstream dependencies. Cloud SQL has a connection limit. If each instance opens 5 connections and 200 instances start, that is 1,000 connections against a database that accepts 100: Cloud Run scales perfectly and knocks the database over.

The formula for choosing it:

max-instances = min(
    capacity_of_the_weakest_dependency / connections_per_instance,
    expected_traffic_peak / concurrency * margin
)

For AlpinaShop: Cloud SQL accepts ~100 usable connections, the pool per instance is 3 → 33 instances. The expected campaign peak is 20 req/s, with concurrency 80 → less than 1 instance, with a ×5 margin for sharp spikes → 5. 30 is chosen: comfortable for the traffic, below the database's limit.

When the maximum is reached, Cloud Run does not reject: it queues the requests and serves them gradually, and it only returns 429 if the queue saturates. It is friendly behaviour, but it is worth having an alert:

gcloud monitoring channels list   # Marta's channel, from 06-04
# Alert when 80% of the maximum is exceeded for 5 minutes

CPU always allocated versus CPU during the request

--cpu-throttling (default) --no-cpu-throttling
CPU between requests Frozen Available
vCPU price Base Of the order of four times lower per second, but you pay for the whole lifetime of the instance
Background processes Do not work Work
Use cases Normal web applications Streaming consumers, services with a self-refreshing cache, WebSockets with work between messages

For the catalogue, --cpu-throttling is the right choice: between requests there is nothing to do. The classic trap is to launch a background thread to "send metrics every 30 seconds" and discover that it never runs. With the CPU frozen, that thread stops.

  1. Connecting Cloud Run to the rest of the platform

An isolated service is worth nothing. These are AlpinaShop's five connections.

Cloud SQL: through the connector, not by IP

--add-cloudsql-instances=alpinashop-prod:europe-west1:alpinashop-pedidos

Cloud Run mounts a Unix socket at /cloudsql/<connection-name> that talks to the instance over an encrypted channel, with no public IP and without opening the firewall. The application's connection string:

import os, sqlalchemy

CONEXION = "alpinashop-prod:europe-west1:alpinashop-pedidos"

engine = sqlalchemy.create_engine(
    sqlalchemy.engine.url.URL.create(
        drivername="postgresql+pg8000",
        username="catalogo",
        password=os.environ["DB_PASSWORD"],       # injected from Secret Manager
        database="tienda",
        query={"unix_sock": f"/cloudsql/{CONEXION}/.s.PGSQL.5432"},
    ),
    pool_size=3,           # low: multiply by the number of instances
    max_overflow=0,        # no surprise extra connections
    pool_pre_ping=True,    # detects dead connections after a pause
    pool_recycle=1800,
)

The four pool options are not decorative:

  • pool_size=3: recall the --max-instances formula. A pool of 20 per instance is the recipe for exhausting the database.
  • max_overflow=0: stops the pool opening additional connections under load, which is precisely when you cannot afford it.
  • pool_pre_ping=True: indispensable on Cloud Run. An instance with its CPU frozen for minutes comes back with dead connections; without pre-ping, the first request after the pause fails.
  • pool_recycle=1800: recycles connections every half hour before the server cuts them.

The modern alternative is to connect over a private IP through the VPC (next point), which avoids the connector and gives lower latency. It is what AlpinaShop will do once the network is tidied up in 07-03.

Secret Manager: mounted, not copied

--set-secrets=DB_PASSWORD=db-password-catalogo:latest

The application reads os.environ["DB_PASSWORD"] and does not know it comes from Secret Manager. Alternatively, mount it as a file:

--set-secrets=/secretos/api-pago=api-key-pasarela-pago:latest

A practical difference worth knowing: an environment variable is read once when the instance starts; a mounted file can be re-read. If you rotate a secret, live instances using an environment variable keep using the old value until they are replaced. With :latest and files, the rotation propagates on its own.

And the flip side: using :latest means a badly done rotation breaks the service without anybody deploying anything. For critical secrets, pinning the version (:7) and updating it in a controlled deployment is safer.

Direct VPC: going out over the private network

By default, Cloud Run's outbound traffic goes to the internet. To reach resources with private IPs — a Cloud SQL replica, Memorystore, or the Sabadell ERP on the other side of the tunnel from 07-03 — you have to connect the service to the VPC:

gcloud run services update alpinashop-web \
  --region=europe-west1 \
  --network=alpinashop-vpc \
  --subnet=sn-web-euw1 \
  --vpc-egress=private-ranges-only

This is Direct VPC egress, which replaces the old Serverless VPC Access connector:

Connector (old) Direct VPC (current)
Infrastructure Managed VMs that have to be sized and paid for None
Cost Of the order of tens of € a month No additional cost
Latency An extra hop Direct
Scaling Manual (number of connector instances) Automatic

If you find documentation talking about connectors, it is older. Direct VPC is what you should use today.

The value of --vpc-egress decides what goes out over the VPC:

Value Effect
private-ranges-only Only RFC 1918 ranges go over the VPC; internet goes out directly. The usual choice
all-traffic Everything goes out over the VPC, including the internet. Needed if you want it to leave through the alpinashop-nat-euw1 Cloud NAT with a fixed IP (03-01), for example because the payment gateway has an IP allowlist

Pub/Sub push with OIDC

In 04-04 it was said that the push model fits Cloud Run. This is how it is connected, without opening the service to anybody:

gcloud pubsub subscriptions create pedidos-nuevos-a-catalogo \
  --topic=pedidos-nuevos \
  --push-endpoint=https://alpinashop-web-x7fq2abc-ew.a.run.app/eventos/pedido \
  --push-auth-service-account=sa-pubsub-invoker@alpinashop-prod.iam.gserviceaccount.com \
  --ack-deadline=60 \
  --dead-letter-topic=pedidos-nuevos-dlq \
  --max-delivery-attempts=5

The essential part is --push-auth-service-account: Pub/Sub signs every delivery with an OIDC token from that account. The service, which is on --no-allow-unauthenticated, only accepts the request if that account has roles/run.invoker:

gcloud run services add-iam-policy-binding alpinashop-web \
  --region=europe-west1 \
  --member=serviceAccount:[email protected] \
  --role=roles/run.invoker

No shared secret, no magic header, no check in the code. The platform validates the token before the request reaches your container.

Eventarc: reacting to what happens on GCP

Eventarc delivers to Cloud Run events from more than a hundred Google Cloud services, in the standard CloudEvents format:

gcloud eventarc triggers create catalogo-imagen-nueva \
  --location=europe-west1 \
  --destination-run-service=alpinashop-web \
  --destination-run-path=/eventos/imagen \
  --event-filters="type=google.cloud.storage.object.v1.finalized" \
  --event-filters="bucket=alpinashop-catalogo" \
  --service-account=sa-eventarc@alpinashop-prod.iam.gserviceaccount.com

How this relates to what you have already seen: Cloud Functions 2nd generation (06-03) is built on Cloud Run and Eventarc. The procesar-imagen-producto function is, underneath, a Cloud Run service with an Eventarc trigger. Knowing that explains why they share limits, scaling options and billing model.

  1. Service identity and security

Four decisions, all consistent with module 3.

1. Its own, minimal service account. sa-catalogo-web has exactly what it needs and nothing more:

# Read the password secret
gcloud secrets add-iam-policy-binding db-password-catalogo \
  --member=serviceAccount:[email protected] \
  --role=roles/secretmanager.secretAccessor

# Connect to Cloud SQL
gcloud projects add-iam-policy-binding alpinashop-prod \
  --member=serviceAccount:[email protected] \
  --role=roles/cloudsql.client

# Read images from the catalogue bucket
gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
  --member=serviceAccount:[email protected] \
  --role=roles/storage.objectViewer

If --service-account is not specified, Cloud Run uses the default Compute Engine service account, which historically has the Editor role over the whole project. Deploying like that means giving your web application permission to delete the database. It is the most common security failure in all of Cloud Run.

2. --no-allow-unauthenticated. The service requires a valid identity token from somebody with roles/run.invoker. Who can invoke it:

Invoker How
The global load balancer The serverless NEG invokes without a token; it is controlled with ingress
Pub/Sub OIDC token from sa-pubsub-invoker
Another Cloud Run service OIDC token from its own account
A person (for debugging) curl -H "Authorization: Bearer $(gcloud auth print-identity-token)"
An end user authenticated with Google IAP in front of the load balancer

3. Ingress control (ingress). It is different from authentication and complementary to it:

Value Who can reach it
all The whole internet (the run.app URL is public)
internal Only from the VPC, VPC Service Controls and other services in the project
internal-and-cloud-load-balancing The above plus the global load balancer. AlpinaShop's choice

With internal-and-cloud-load-balancing, the *.run.app URL stops answering from outside. All customer traffic has to pass through Cloud Armor and the CDN, without exception. This is what prevents the "I'll just test straight against run.app" shortcut, which is the hole through which the WAF gets bypassed.

4. IAP for internal services. The internal admin dashboard is published with the same pattern as in 03-04: load balancer + IAP + private Cloud Run. Employees log in with their Google account, with no VPN and no accounts of their own.

  1. Behind the global load balancer: the serverless NEG

In 03-02 AlpinaShop's global load balancer was built and it was noted that the day the catalogue went to Cloud Run it would be enough to change the backend. That day has come.

A serverless NEG (serverless network endpoint group) is a backend that points to a Cloud Run, App Engine or Cloud Functions service instead of to an instance group.

flowchart LR
    C["Clients"] --> IP["alpinashop-lb-ip<br/>global anycast IP"]
    IP --> CERT["alpinashop-cert<br/>managed TLS"]
    CERT --> ARM["Cloud Armor<br/>pol-catalogo-web"]
    ARM --> UM["alpinashop-url-map"]
    UM -->|"/*"| BS["bs-catalogo-web<br/>+ Cloud CDN"]
    UM -->|"/img/*"| BB["bb-catalogo-imagenes<br/>bucket"]
    BS --> NEG["Serverless NEG<br/>neg-catalogo-run"]
    NEG --> CR["Cloud Run<br/>alpinashop-web"]

    style NEG fill:#e6f4ea,stroke:#34a853
    style CR fill:#e6f4ea,stroke:#34a853

Everything blue in the diagram — IP, certificate, WAF, URL map, CDN — is untouched. Only the green part changes.

Creating the NEG:

gcloud compute network-endpoint-groups create neg-catalogo-run \
  --region=europe-west1 \
  --network-endpoint-type=serverless \
  --cloud-run-service=alpinashop-web

And adding it to the existing backend service:

gcloud compute backend-services add-backend bs-catalogo-web \
  --global \
  --network-endpoint-group=neg-catalogo-run \
  --network-endpoint-group-region=europe-west1

Four peculiarities of the serverless NEG you have to know, because they differ from everything seen in 03-02:

  1. There are no health checks. A backend service with a serverless NEG does not use hc-catalogo. Health is managed by Cloud Run. It is disconcerting at first; it is correct.
  2. There is no balancing mode or capacity. There is no max-rate-per-instance or capacity-scaler: Cloud Run scales by itself.
  3. The NEG is regional, even though the load balancer is global. To serve from several regions you create several NEGs, one per region, and add them to the same backend service — which is exactly how the multi-region high availability of 07-06 is done.
  4. The CDN still works the same. The Cache-Control headers Cloud Run emits govern the cache exactly as the MIG's did (03-03).

  1. The migration from the MIG, step by step and with no downtime

This is the actual procedure Marta ran. It is written so you can follow it, and its virtue is that at no point is there a point of no return.

Phase 0 — Preparation (days before)

# 1. Verify locally that the image meets the contract
docker run -e PORT=8080 -p 8080:8080 \
  europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2
curl -sf localhost:8080/salud && echo "CONTRACT OK"

# 2. Deploy to development first. ALWAYS.
gcloud run deploy alpinashop-web --project=alpinashop-dev \
  --image=... --region=europe-west1 --min-instances=0

And a pre-flight checklist that avoids 90 % of the frights:

  • [ ] The application listens on $PORT and on 0.0.0.0.
  • [ ] It writes nothing important to local disk.
  • [ ] It does not keep sessions in memory (AlpinaShop's have been in Firestore since 02-06).
  • [ ] The logs go to stdout/stderr in structured JSON (06-06).
  • [ ] Gunicorn is configured with enough threads for the chosen concurrency.
  • [ ] The connection pool is small and has pool_pre_ping.
  • [ ] There are no background threads that depend on CPU between requests.

Phase 1 — Deploy to production with no traffic

gcloud run deploy alpinashop-web \
  --project=alpinashop-prod \
  --image=europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2 \
  --region=europe-west1 \
  --no-traffic --tag=candidata \
  [... the rest of the flags from section 3 ...]

The service exists, works against the real database and no customer sees it. This is where the smoke tests are run and the latency compared with the MIG's.

Phase 2 — Add the NEG to the load balancer with zero weight

The NEG is created and added to the bs-catalogo-web backend service, which now has two backends: the MIG and Cloud Run. The load balancer splits between them according to capacity.

To control the split precisely, AlpinaShop uses the capacity-scaler technique on the MIG backend:

# Initial state: everything to the MIG
gcloud compute backend-services update-backend bs-catalogo-web --global \
  --instance-group=alpinashop-web-mig \
  --instance-group-zone=europe-west1-b \
  --capacity-scaler=1.0

Phase 3 — The progressive shift

The MIG's capacity is gradually reduced, which pushes traffic towards Cloud Run:

Moment MIG capacity-scaler Approximate traffic to Cloud Run What is watched
T+0 1.0 ~0 % —
T+30 min 0.5 ~30 % 5xx, p95, Cloud SQL connections
T+2 h 0.2 ~70 % The same, plus cost
T+1 day 0.0 100 % Everything, for 24 h

At each step the same four things are looked at on the 06-04 dashboard, and compared by backend:

# In Metrics Explorer, filtering by backend_target_name
loadbalancing.googleapis.com/https/request_count      -> actual split
loadbalancing.googleapis.com/https/backend_latencies  -> p50 and p95 per backend
run.googleapis.com/container/instance_count           -> instances created
cloudsql.googleapis.com/database/postgresql/num_backends -> open connections

The Cloud SQL connections metric is the one that produces the most surprises, and it is the reason the shift is done over hours rather than minutes.

Phase 4 — Supervised coexistence

For 24-72 hours the MIG carries on existing with capacity-scaler=0.0: it receives no traffic but it is switched on. The rollback is a single command:

gcloud compute backend-services update-backend bs-catalogo-web --global \
  --instance-group=alpinashop-web-mig \
  --instance-group-zone=europe-west1-b \
  --capacity-scaler=1.0

And in seconds everything goes back to the MIG. Paying for two days of a VM with no traffic is the price of sleeping soundly, and it is a ridiculous price.

Phase 5 — Decommissioning

Only once the three days have passed and at least one traffic peak has been weathered:

# 1. Remove the MIG from the load balancer
gcloud compute backend-services remove-backend bs-catalogo-web --global \
  --instance-group=alpinashop-web-mig \
  --instance-group-zone=europe-west1-b

# 2. Scale the MIG down to zero before deleting it (allows a fast return)
gcloud compute instance-groups managed resize alpinashop-web-mig \
  --size=0 --zone=europe-west1-b

# 3. A week later, delete it for real, from Terraform
#    (remove the resource from the .tf and run plan/apply, never by hand)

Step 2 is important: scale to zero before deleting. A MIG of size zero costs no compute, keeps its template, and bringing it back up is a resize. The final deletion is done from Terraform, because if it is done by hand the next plan will try to recreate it.

  1. Cost: the model and the comparative calculation

The billing model

Cloud Run charges for three things, and only while there are live instances:

Item Unit Order of magnitude in europe-west1
CPU vCPU-second ~$0.000024 per active vCPU-s; a minimum idle instance, around a tenth of that
Memory GiB-second ~$0.0000025 per GiB-s
Requests Per million ~$0.40 per million
Egress to the internet GB Standard egress rate (07-05)

And there is a generous monthly free tier — of the order of 180,000 vCPU-s, 360,000 GiB-s and 2 million requests — that makes many small projects cost literally zero.

Prices change and vary by region. Always check the official pricing page and the calculator. What follows are orders of magnitude for reasoning, not a quotation.

AlpinaShop's calculation

Scenario: 1.5 million requests a month, average latency 200 ms, 1 vCPU, 512 MiB, concurrency 80, min-instances=1, CPU limited to the request.

Active instance time. With concurrency 80, requests overlap. A conservative estimate of the time with at least one request in flight, based on the real hourly distribution, gives about 28 hours a month of activity ≈ 100,000 vCPU-s.

Item Calculation Cost
Active CPU 100,000 vCPU-s × $0.000024 ~$2.40
Idle CPU (min-instances=1) 2,592,000 s × ~$0.0000025 ~$6.50
Active memory 50,000 GiB-s × $0.0000025 ~$0.13
Idle memory 1,296,000 GiB-s × ~$0.00000025 ~$0.32
Requests 1.5 M × $0.40/M ~$0.60
Less the free tier −$2 to −$3
Total ≈ $7-8/month ≈ €7/month

Comparison with what was being paid, using the figures from 02-07:

Item MIG (before) Cloud Run (after)
Compute 2 × e2-medium 24×7 ≈ €50/month ≈ €7/month
Global load balancer + IP ~€20/month ~€20/month (the same)
Persistent disks ~€4/month €0
Ops Agent Included, but has to be installed Not needed
Total infrastructure ≈ €74/month ≈ €27/month
OS patching, templates, rolling updates ~2-4 h/month of Marta's time 0 h

Saving: about €47/month, 63 % of the shop's cost. And that is the small part. Marta's 2-4 hours a month are worth more than the €47, and the rollback time goes from 10 minutes to 5 seconds.

But the whole truth has to be told, because this course is not selling anything:

  • If traffic were constant and high 24 hours a day, the MIG with a committed use discount would be cheaper. Cloud Run wins with irregular traffic, which is AlpinaShop's case, not every case.
  • During the autumn campaign the Cloud Run bill goes up, because you pay for usage. That is correct — you pay when you sell — but it means the bill is no longer flat. It is planned for in 07-05.
  • The global load balancer still costs the same and now accounts for 74 % of the catalogue's bill. That is what happens when you optimise well: the biggest cost moves somewhere else.

  1. Limits and when Cloud Run does not fit

The limits you need to know before deciding (check them in the documentation, they change for the better over time):

Limit Indicative value
Maximum time per request 60 minutes
Memory per instance Up to 32 GiB
CPU per instance Up to 8 vCPU
Maximum concurrency 1,000
Maximum instances 1,000 by default (can be raised)
Request and response size 32 MiB (without streaming)
GPU Available in selected regions
Local storage In memory (counts against the limit) or mounted volumes

And the scenarios where Cloud Run is not the answer:

Scenario Why not Alternative
Processes that must run permanently with no requests The CPU freezes; with min-instances and CPU always allocated it is possible, but you pay for an expensive VM Compute Engine or GKE
Databases, queues, Elasticsearch They need persistent state and disk Managed services
Applications needing fast, large local disk The file system lives in memory Compute Engine with local SSD
Tasks longer than 60 minutes Hard limit Cloud Run jobs (24 h) or Batch
Software with a licence tied to hardware There is no hardware to license Compute Engine with your own licence
Protocols other than HTTP/gRPC/WebSocket (a UDP game server, for example) Only HTTP traffic comes in Compute Engine with a network load balancer
Need for kernel access, modules or privileges Unprivileged sandbox GKE with your own nodes

  1. The mature table: Cloud Run vs Functions vs GKE vs Compute Engine

In 02-07 this comparison was made with the information available then. Now, with six more modules behind you, it can be made with real judgement.

Criterion Cloud Run Cloud Functions 2nd gen GKE Autopilot Compute Engine
Deployment unit Container Function Container + manifests VM
Scales to zero Yes Yes No (fixed control plane) No
Billing By usage, per second By usage By pod resources Per running VM
Cold start Seconds, mitigable Seconds Not applicable Not applicable
Operational work Minimal Minimal Medium High
Portability High (standard container) Medium Very high Medium
Learning curve Low Very low High Medium
Services calling one another Fine, up to a certain scale Middling Excellent Manual
Long batch workloads Jobs, up to 24 h No (short limit) Yes Yes
State on disk No No Yes (volumes) Yes
Control of the operating system No No Partial Total
Cost with irregular traffic The best The best for events Poor (base cost) Poor
Cost with constant high traffic Good Poor Good The best with a commitment
AlpinaShop's choice Web catalogue procesar-imagen-producto Being retired Being retired

The four questions that resolve almost every decision, in this order:

  1. Is it a stateless HTTP container with irregular traffic? → Cloud Run. It is the majority case.
  2. Is it a reaction to a single event, with brief code? → Cloud Functions (which underneath is Cloud Run).
  3. Are there many services calling one another, with state, DaemonSets or operators? → GKE.
  4. Do you need the operating system, local disk, tied licences or non-HTTP protocols? → Compute Engine.

And the observation that closes the abstraction continuum of 02-07: the right answer for AlpinaShop turned out to be the rung that had not been tried. It went through VMs, App Engine and Kubernetes before reaching the option that fitted. That was not wasted time: it was how the judgement to decide was acquired.

  1. Switching off alpinashop-web-mig and what that simplifies

The list of things that stop existing when the MIG is retired is longer than it looks, and it is the strongest argument in favour of the move:

Disappears What it entailed
The instance template One more resource in Terraform, with its base image and its version
The startup script A hundred lines of bash that installed Docker and started the container
Operating system patching Monthly security updates from Marta
The Ops Agent Installation, configuration and updating just to have memory metrics
The hc-catalogo health check No longer needed for the backend (still useful for the uptime checks)
The MIG's autoscaling policy Target CPU, stabilisation period, initialisation delay
Rolling updates max-surge, max-unavailable and waiting ten minutes
SSH keys and IAP access to the VMs An entire attack surface
Persistent disks and their snapshots Resources that get forgotten and get paid for (07-05)
The capacity reservation for the campaign Cloud Run scales by itself

Ten fewer elements to maintain, understand, document and explain to whoever comes next. For a company with one infrastructure person, that reduction is worth more than the financial saving.

What you do still have to do, so nobody gets confused:

  • Update the base image in the Dockerfile and rebuild. Cloud Run patches the sandbox, not your container.
  • Watch for image vulnerabilities in Artifact Registry (07-04).
  • Manage database schema migrations.
  • Everything to do with the application itself.

AlpinaShop's compute architecture finally looks like this:

flowchart TB
    subgraph BORDE["Global edge"]
      IP["alpinashop-lb-ip"] --> ARM["Cloud Armor pol-catalogo-web"] --> CDN["Cloud CDN"]
    end
    CDN --> NEG["Serverless NEG"]
    NEG --> RUN["Cloud Run alpinashop-web<br/>0 to 30 instances"]
    RUN --> SQL["Cloud SQL alpinashop-pedidos<br/>regional HA"]
    RUN --> FS["Firestore - sessions"]
    RUN --> SEC["Secret Manager"]
    GCS["Bucket alpinashop-catalogo"] --> CDN
    GCS -->|"event"| FN["Cloud Function<br/>procesar-imagen-producto"]
    RUN -->|"order"| PS["Pub/Sub pedidos-nuevos"]
    PS --> DF["Dataflow"] --> BQ["BigQuery alpinashop_analitica"]
    JOB["Cloud Run job<br/>informes-nocturnos"] --> BQ

    style RUN fill:#e6f4ea,stroke:#34a853
    style JOB fill:#e6f4ea,stroke:#34a853

Not a single virtual machine is left in the shop's serving path. And alpinashop-cluster is left with no production workloads: it is kept as a container testing environment and for whatever may come, with a note in DA-004 to review whether it is worth keeping.

Common Mistakes and Tips

  • Listening on a fixed port or on 127.0.0.1. It is the number one mistake. Read $PORT and listen on 0.0.0.0.
  • Deploying without --service-account. You inherit the default Compute Engine account, which usually has Editor over the project. Your website would have permission to delete the database.
  • Setting --concurrency=80 with a single-threaded server. Requests queue up inside the container and latency goes through the roof. Configure Gunicorn, Uvicorn or whatever you use before touching the concurrency.
  • Not setting --max-instances. An infinite loop or a crawler creates hundreds of instances for you, and it also knocks Cloud SQL over by exhausting connections.
  • A large connection pool. pool_size=20 × 30 instances = 600 connections against a database that holds 100. Small pool, max_overflow=0 and pool_pre_ping=True.
  • Saving something to the file system and expecting to find it. The disk is ephemeral and lives in memory: writing a 300 MiB file consumes 300 MiB of your memory limit.
  • Launching background threads with CPU limited to the request. They do not run. Either you use CPU always allocated, or you move that work to a job or to Cloud Scheduler.
  • Using the *.run.app URL as the product's public URL. It changes if you recreate the service, it bypasses Cloud Armor and the CDN, and it is not your domain. Always the load balancer.
  • Confusing authentication with ingress. --no-allow-unauthenticated says who can invoke; ingress says by which route they can arrive. You need both.
  • Deploying with latest. You lose traceability of which version is running and the rollback stops being reliable. Tag with $COMMIT_SHA.
  • Believing the canary protects against everything. It does not protect against database schema changes and it is useless with low traffic.
  • Forgetting --timeout 0 in Gunicorn. Two competing timers produce cut-offs that look random.
  • Tip: measure the concurrency with a real load test before fixing it. It is the parameter with the greatest combined impact on cost and latency.
  • Tip: use --tag on every deployment, even if you are not doing a canary. Having a URL per revision is useful for debugging and costs nothing.
  • Tip: enable startup_cpu_boost. It is free in practice and noticeably reduces the cold start on heavy runtimes.
  • Tip: a small image is the best cold-start optimisation. A well-made distroless or alpine image starts far sooner than an ubuntu one with half a system inside.

Exercises

Exercise 1 — Choosing concurrency, CPU, memory and instances

AlpinaShop is going to deploy a new service on Cloud Run: alpinashop-buscador, a catalogue search API.

Figures measured in the development environment:

  • Written in Python with FastAPI + Uvicorn (asynchronous, a single process).
  • Each request: 15 ms of CPU (tokenising and scoring) + 120 ms waiting for Elasticsearch.
  • Memory consumption: 180 MiB at rest + ~2 MiB per in-flight request.
  • Traffic: 5 requests/second on average, 40 requests/second during the campaign.
  • Elasticsearch accepts 200 concurrent connections in total and shares them with two other services.
  • Nobody tolerates more than 800 ms of p95 latency.

Determine --concurrency, --cpu, --memory, --min-instances and --max-instances, justifying each value with the figures. Also state what you would change in the application's start-up.

Exercise 2 — Designing the safe deployment in the pipeline

Write the Cloud Build steps (cloudbuild.yaml) that implement the catalogue's canary deployment with these rules:

  1. Build the image tagged with $COMMIT_SHA.
  2. Deploy with no traffic, with the candidata tag.
  3. Run a smoke test against the tag's URL; if it fails, abort.
  4. Give it 10 % of the traffic.
  5. Wait 10 minutes and check automatically that the new revision's 5xx error rate is below 1 %; if not, roll back to 0 % and fail the build.
  6. Only then, wait for Marta's manual approval to move to 100 %.

Also state what permissions the pipeline's service account needs and why step 5 is the one most people leave out.

Exercise 3 — Diagnosing a migration that is going badly

Three hours after moving 70 % of the traffic to Cloud Run, Marta observes this:

  • The p50 latency of the Cloud Run backend is 210 ms; the MIG's, 195 ms. Acceptable.
  • The p99 latency of Cloud Run is 4.2 seconds; the MIG's, 380 ms.
  • In Cloud Logging there are about 40 errors an hour: sqlalchemy.exc.OperationalError: server closed the connection unexpectedly.
  • The instance count oscillates between 1 and 9 constantly, going up and down every few minutes.
  • cloudsql.googleapis.com/database/postgresql/num_backends has gone from 12 to 34.
  • Error Reporting shows 6 cases of Memory limit of 512 MiB exceeded.

Diagnose each symptom, state the likely root cause of each one, and write the remediation plan ordered by priority. Also decide whether to roll back to the MIG or whether it can be fixed live.

Solutions

Solution 1 — Choosing concurrency, CPU, memory and instances

Starting point: the request profile. 15 ms of CPU against 120 ms of waiting. The CPU works 11 % of the time. It is an I/O-dominated service, exactly the case where high concurrency pays off.

--concurrency. Three constraints at once, and you have to respect the strictest:

  1. By CPU: with 15 ms of CPU out of every 135 ms total, one vCPU could theoretically sustain about 9 simultaneous requests before saturating. This is the hard limit.
  2. By Elasticsearch: 200 connections shared between three services, say 60 for the search service. If each request opens a connection, concurrency × instances ≤ 60.
  3. By latency: the p95 budget is 800 ms, and the base request is 135 ms. There is room for a little queuing, but not much.

With 1 vCPU, setting concurrency above 9-10 makes requests wait for CPU. --concurrency=10 is chosen. And if you raise it to --cpu=2, you could go to 20; with this service's traffic it is not worth it.

--cpu=1. Enough for the chosen concurrency. Going up to 2 vCPU only makes sense if you also raise the concurrency, and the traffic does not justify it.

--memory=512Mi. Calculation: 180 MiB of baseline + 10 requests × 2 MiB = 200 MiB, plus the Python runtime and headroom. 512 MiB leaves more than double the slack. Setting 256 MiB would be playing with fire, because running out of memory kills the instance, it does not slow it down.

--max-instances. The peak is 40 req/s. At 135 ms per request and concurrency 10, each instance sustains about 74 req/s in theory; in practice, with headroom, say 40-50. One or two instances would be enough. But the real limit is set by Elasticsearch: 60 connections / 10 per instance = 6 instances. --max-instances=6 is chosen, which covers the peak with a wide margin and protects the shared backend, which is the scarce resource.

--min-instances=1. A search service is customer-facing and a cold start of 1-2 s on the first search of the morning is visible and annoying. One minimum instance costs a few euros a month. In alpinashop-dev, 0.

What to change in the start-up. FastAPI with Uvicorn in a single process is asynchronous, so it can handle several simultaneous requests — but only if the code is genuinely asynchronous. The critical check:

# WRONG: synchronous client inside an async function. It blocks the event loop
# and the effective concurrency becomes 1, however async the endpoint may be.
@app.get("/buscar")
async def buscar(q: str):
    r = requests.get(f"{ES}/catalogo/_search", json=consulta(q))   # BLOCKS
    return r.json()

# RIGHT: asynchronous client
@app.get("/buscar")
async def buscar(q: str):
    async with httpx.AsyncClient() as cli:
        r = await cli.post(f"{ES}/catalogo/_search", json=consulta(q))
    return r.json()

This is exactly the mistake from section 6 wearing a different face: an async def that calls a synchronous library is not concurrent, and the concurrency of 10 configured in Cloud Run is worth nothing.

Also: create the HTTP client once at start-up and reuse it (not on every request, as in the simplified example), and start with uvicorn main:app --host 0.0.0.0 --port $PORT --workers 1 — a single worker, because the parallelism comes from the event loop and Cloud Run adds instances.

Solution 2 — Designing the safe deployment in the pipeline

# cloudbuild.yaml — canary deployment of alpinashop-web
substitutions:
  _REGION: europe-west1
  _SERVICIO: alpinashop-web
  _IMAGEN: europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo

steps:

# 1. Build with the commit hash
- id: construir
  name: gcr.io/cloud-builders/docker
  args: ['build', '-t', '${_IMAGEN}:$COMMIT_SHA', '.']

- id: publicar
  name: gcr.io/cloud-builders/docker
  args: ['push', '${_IMAGEN}:$COMMIT_SHA']

# 2. Deploy WITHOUT traffic, with a tag
- id: desplegar-candidata
  name: gcr.io/google.com/cloudsdktool/cloud-sdk
  entrypoint: gcloud
  args:
    - run
    - deploy
    - ${_SERVICIO}
    - --image=${_IMAGEN}:$COMMIT_SHA
    - --region=${_REGION}
    - --no-traffic
    - --tag=candidata
    - --revision-suffix=$SHORT_SHA

# 3. Smoke test against the tag URL
- id: humo
  name: gcr.io/google.com/cloudsdktool/cloud-sdk
  entrypoint: bash
  args:
    - -c
    - |
      set -e
      URL=$(gcloud run services describe ${_SERVICIO} --region=${_REGION} \
            --format='value(status.traffic[?tag==`candidata`].url)')
      TOKEN=$(gcloud auth print-identity-token)
      for RUTA in /salud /producto/1 /api/categorias; do
        CODIGO=$(curl -s -o /dev/null -w '%{http_code}' \
                 -H "Authorization: Bearer $$TOKEN" "$$URL$$RUTA")
        echo "$$RUTA -> $$CODIGO"
        [ "$$CODIGO" = "200" ] || { echo "FAILURE ON $$RUTA"; exit 1; }
      done

# 4. Canary at 10%
- id: canario-10
  name: gcr.io/google.com/cloudsdktool/cloud-sdk
  entrypoint: gcloud
  args:
    - run
    - services
    - update-traffic
    - ${_SERVICIO}
    - --region=${_REGION}
    - --to-tags=candidata=10

# 5. Wait and verify the new revision's error rate
- id: verificar-canario
  name: gcr.io/google.com/cloudsdktool/cloud-sdk
  entrypoint: bash
  args:
    - -c
    - |
      set -e
      REV=${_SERVICIO}-$SHORT_SHA
      sleep 600     # 10 minutes of observation

      FILTRO="metric.type=\"run.googleapis.com/request_count\" \
              AND resource.labels.service_name=\"${_SERVICIO}\" \
              AND resource.labels.revision_name=\"$$REV\""

      TOTAL=$(gcloud monitoring time-series list \
              --filter="$$FILTRO" --interval-end-time=now --interval-start-time=-10m \
              --format='value(points.value.int64Value)' | paste -sd+ | bc)

      ERR=$(gcloud monitoring time-series list \
            --filter="$$FILTRO AND metric.labels.response_code_class=\"5xx\"" \
            --interval-end-time=now --interval-start-time=-10m \
            --format='value(points.value.int64Value)' | paste -sd+ | bc)

      TOTAL=$${TOTAL:-0}; ERR=$${ERR:-0}
      echo "Requests: $$TOTAL, 5xx errors: $$ERR"

      if [ "$$TOTAL" -lt 100 ]; then
        echo "WARNING: insufficient sample ($$TOTAL requests). No conclusion possible."
        exit 1
      fi

      RATIO=$(echo "scale=4; $$ERR / $$TOTAL * 100" | bc)
      echo "Error rate: $$RATIO %"

      if [ $(echo "$$RATIO > 1" | bc) -eq 1 ]; then
        echo "ROLLING BACK: error rate above 1%"
        gcloud run services update-traffic ${_SERVICIO} \
          --region=${_REGION} --to-tags=candidata=0
        exit 1
      fi

options:
  logging: CLOUD_LOGGING_ONLY

Step 6, the manual approval, is not a step in the cloudbuild.yaml: it is configured on the Cloud Build trigger with --require-approval, exactly as it was set up in 06-01 for production. When the build reaches the end, it waits for Marta to approve, and the move to 100 % is done in a separate trigger or manually with --to-latest.

Permissions for the pipeline's service account (sa-deploy-prod):

Role What for
roles/run.developer Deploy services and change the traffic split
roles/iam.serviceAccountUser on sa-catalogo-web To be able to assign that account to the service. This is the one everybody forgets and it produces a baffling PERMISSION_DENIED
roles/artifactregistry.writer Publish the image
roles/monitoring.viewer Read the metrics in step 5
roles/logging.logWriter Write the build logs

Note that it does not have roles/editor or permissions over Cloud SQL or Secret Manager: it deploys, it does not administer.

Why step 5 is the one almost everybody leaves out. Because it is the only one that requires thinking. Steps 1-4 are mechanical and appear in any tutorial; step 5 requires deciding which metric, what threshold, how long and what to do if there is not enough data. Without it, the canary is theatre: you deploy at 10 %, nobody looks, and ten minutes later somebody clicks "approve" because no alarm went off — which is a different thing from everything being fine.

The minimum sample check (TOTAL -lt 100) is the subtlety that separates a real canary from a decorative one: with few requests, the absence of errors proves nothing, and failing the build forces you to look at it instead of approving blind.

Solution 3 — Diagnosing a migration that is going badly

Diagnosis symptom by symptom.

(1) Correct p50 but a p99 of 4.2 s. A healthy p50 rules out a general performance problem: most requests are fine. A p99 twenty times worse with a normal p50 is the unmistakable signature of the cold start. The service was deployed with --min-instances=0, or the traffic is irregular enough to be creating instances constantly. The confirmation is in symptom (4).

(2) server closed the connection unexpectedly, ~40/hour. Pool connections that had been idle for a while and that Cloud SQL, or the instance's own CPU freeze, has given up for dead. It is the exact case for pool_pre_ping: without it, the first query after a period of inactivity uses a zombie connection and fails. The root cause is the lack of pool_pre_ping=True and pool_recycle in the SQLAlchemy configuration, aggravated by the CPU freeze between requests that Cloud Run applies and the MIG did not.

(3) Instances oscillating between 1 and 9 every few minutes. This is the cause of (1), and in turn it has its own cause. With concurrency 80 and AlpinaShop's traffic, a single instance ought to be more than enough. Its going up to 9 means the instances are dying and being recreated: that is explained by symptom (6). It is a vicious circle — it dies from memory, starts cold, serves, dies again — that also generates part of (2), because every death takes the pool's connections with it.

(4) Cloud SQL connections from 12 to 34. Consistent with 9 instances × a pool of 3-4. It is not a problem in itself — 34 is a long way from the limit — but it confirms the arithmetic and gives warning: if the maximum instance count were set badly, this is where the disaster would show up. It is a symptom to watch, not to fix.

(5) Six cases of Memory limit exceeded. This is the main root cause. 512 MiB is not enough for the configured concurrency. The hypotheses, in order of probability: the concurrency is 80 and each catalogue request consumes more memory than estimated (it renders templates, loads images into memory); or there is an in-process cache growing without bound; or the application writes temporary files, which on Cloud Run count as memory.

Full causal chain:

Insufficient memory (512 MiB with concurrency 80)
      |
      v
The instance dies on exceeding the limit
      |
      +--> Instances get recreated -> oscillation 1 to 9 (symptom 3)
      |                                   |
      |                                   v
      |                          cold starts -> p99 of 4.2 s (symptom 1)
      |                                   |
      |                                   v
      |                          more instances -> 34 connections (symptom 4)
      |
      +--> Pool connections are lost
                    |
                    v
        (aggravated by the lack of pool_pre_ping)
                    |
                    v
        OperationalError, 40/hour (symptom 2)

Roll back or fix live?

There is no need to roll back. The reasoning: 30 % of the traffic is still on the MIG, the p50 is correct, the errors are 40 an hour out of thousands of requests (~0.5 %) and all the causes are configuration, not code. Rolling back would cost the rest of the day and would provide no new information. That said, you must stop increasing traffic until it is fixed, and keep the rollback command ready in case anything gets worse.

If instead of this the errors were 10 %, or orders were being lost, or the p50 were degraded, the decision would be the opposite: roll back first, diagnose afterwards. In an incident, you stop the bleeding first.

Remediation plan by priority:

Prio Action Command / change Expected effect
1 Raise memory to 1 GiB gcloud run services update alpinashop-web --region=europe-west1 --memory=1Gi Cuts off the root cause; (3), (1) and much of (2) disappear
2 Lower concurrency to 40 while investigating --concurrency=40 Halves the simultaneous memory. Slightly higher cost, acceptable during the diagnosis
3 Add pool_pre_ping=True and pool_recycle=1800 Code change, canary deployment Eliminates the residue of (2)
4 Set --min-instances=1 --min-instances=1 Eliminates the cold start for the first user
5 Memory alert Policy on run.googleapis.com/container/memory/utilizations > 80 % So that next time it warns before dying
6 Profile the memory properly Cloud Profiler or local analysis under load To know whether 1 GiB is the solution or just a patch over a leak

Step 6 is the one not to skip. Raising the memory is the right action now, because it stops the bleeding. But if there is a real memory leak — a cache growing without bound, for example — with 1 GiB it will simply take twice as long to die, and the problem will reappear during the campaign at the worst possible moment. The difference between resolving an incident and postponing it is exactly that last step, and it is the one that gets cancelled once everything is green again.

Conclusion

DA-001 is done. AlpinaShop's catalogue runs on Cloud Run, behind the same IP, the same domain, the same certificate, the same CDN and the same WAF; the MIG is switched off; and there is not a single virtual machine left in the shop's serving path.

You know what Cloud Run is — serverless containers — and why the deployment unit changes everything: the contract is six rules (listen on $PORT and on 0.0.0.0, speak HTTP, start fast, keep no state, do nothing between requests, be an unprivileged Linux container) and meeting them means the same image runs on Cloud Run, on Kubernetes or on your laptop. You distinguish service, revision and instance, and you know revisions are immutable — which is what makes everything else possible.

You have the criterion between services and jobs: if it answers somebody, a service; if it does something and finishes, a job. With the antipattern identified: the nightly report as an HTTP endpoint triggered by Scheduler, which inherits four problems a job does not have.

You have deployed the catalogue with gcloud run deploy flag by flag, knowing what each one does and why it has that value, and you have written it in Terraform with google_cloud_run_v2_service, with cpu_idle, startup_cpu_boost and ingress restricted to the load balancer, and with a clear boundary between what Terraform manages (the shape) and what the pipeline manages (the image and the traffic).

You have mastered revisions and the traffic split: the tag that gives a URL of its own for testing without risk, the progression 0 → 10 → 50 → 100 with what is observed at each step, and the instant rollback that takes seconds because the previous revision never stopped existing. With the two honest warnings: the canary does not protect against schema changes, and with low traffic 10 % proves nothing.

You understand concurrency better than most: what it is, why the one-request-per-instance model is twelve times more expensive for an I/O-dominated workload, how it is measured with a real load test, and — most importantly — that the ceiling is not set by Cloud Run but by your application server. A --concurrency=80 on a Gunicorn with one synchronous worker, or an async def that calls a blocking library, turn concurrency into an invisible queue.

You know how to govern scaling: scale to zero and its price in cold starts, --min-instances that mitigates but does not eliminate them, --max-instances as a cost firewall and a database connection firewall with its formula, and the choice between CPU during the request and CPU always allocated, with the trap of background threads that never run.

You have connected the service to the whole platform: Cloud SQL through the connector with a small pool and pool_pre_ping, Secret Manager mounted as a variable or as a file with the practical difference at rotation time, Direct VPC — which replaces the old connector — with its two egress modes, Pub/Sub push with OIDC without a single shared secret, and Eventarc, with the revelation that Cloud Functions 2nd generation is Cloud Run underneath.

You have secured the service with the four decisions: its own, minimal service account — avoiding the most common failure, inheriting the default account with Editor — --no-allow-unauthenticated, ingress restricted to the load balancer so nobody can bypass the WAF via the run.app URL, and IAP for internal things.

You have done the migration with no downtime: preparation with a checklist, deployment with no traffic, a serverless NEG added to the existing load balancer, a progressive shift with capacity-scaler while watching four metrics, three days of coexistence with a one-command rollback, and decommissioning in two steps with resize 0 before deleting from Terraform. And you know the four peculiarities of the serverless NEG: no health checks, no capacity, regional even though the load balancer is global, and with the CDN working just the same.

You have the cost broken down: vCPU-second, GiB-second and requests, with a free tier, and the comparative calculation that brings the catalogue's compute down from about €50 to about €7 a month — with the honesty to point out that with constant high traffic the MIG with a commitment would win, that during the campaign the bill goes up because you pay for usage, and that the load balancer is now 74 % of what the shop costs. You know the limits and the seven scenarios where Cloud Run does not fit. And you take away the mature table with the four questions that resolve almost any compute decision.

Finally, you have seen what disappears when the MIG is switched off: ten elements — template, startup script, patching, agent, autoscaling, rolling updates, SSH keys, disks, snapshots, campaign reservation — that no longer have to be maintained, understood or explained. For a one-person team, that is worth more than the saving.

And yet, two sections of this lesson have left a loose end that points at the same place. The Cloud SQL connection would be better over a private IP through the VPC. Going out to the payment gateway with a fixed IP requires passing through the Cloud NAT. And the Sabadell ERP, which DA-004 left on the other side of a tunnel that does not exist yet, needs Cloud Run to be able to reach it over the private network.

All three are the same problem: AlpinaShop's network stopped at the basics in module 3 and is no longer enough. A single VPC in a single project, three projects that ignore one another, no connectivity with the physical shop and no perimeter stopping the data in alpinashop-datos walking out of the door. The next lesson resolves it: Shared VPC, peering, Private Service Connect, HA Cloud VPN to Sabadell and VPC Service Controls.

Google Cloud Platform (GCP) Course

Module 1: Introduction to Google Cloud Platform

Module 2: Core GCP Services

Module 3: Networking and Security

Module 4: Data and Analytics

Module 5: Machine Learning and AI

Module 6: DevOps and Monitoring

Module 7: Advanced GCP Topics

Module 8: Final Project

© Copyright 2026. All rights reserved