En 02-07 se escribió una decisión y se dejó pendiente. DA-001: el catálogo web de AlpinaShop debe pasar a Cloud Run. Se justificó con una tabla, se firmó, y se guardó en el README de alpinashop-catalogo. Desde entonces el curso ha vuelto a ella una docena de veces —en el balanceador de 03-02, en los secretos de 03-06, en Pub/Sub push de 04-04, en Cloud Functions de 06-03, en el agente de operaciones de 06-04— siempre con la misma coletilla: "en 07-02".
Esta es 07-02. Aquí se cumple.
No es un capricho de coherencia narrativa: es que hasta ahora no estaban las condiciones. Mover el catálogo de producción a otra plataforma sin un pipeline que construya imágenes reproducibles, sin observabilidad para saber si ha ido bien, sin infraestructura como código para revertir y sin un balanceador delante que absorba el cambio, es una temeridad. Con las cuatro cosas hechas —módulos 3 y 6— el movimiento es una tarde de trabajo con red de seguridad.
Al terminar la lección, el catálogo de AlpinaShop servirá desde Cloud Run, detrás de la misma IP, el mismo dominio, el mismo certificado, la misma CDN y el mismo WAF que tenía; habrá pasado por un despliegue canario al 10 %; el MIG alpinashop-web-mig estará apagado; y la factura de cómputo del catálogo habrá bajado de unos 50 € al mes a menos de 10. Y sabrás por qué la concurrencia es el parámetro que más gente configura mal.
Contenido
- Qué es Cloud Run y por qué importa el contrato del contenedor
- Servicios y trabajos (jobs): el criterio para elegir
- Desplegar el catálogo:
gcloud run deploybandera a bandera - El mismo servicio en Terraform
- Revisiones y reparto de tráfico: canario, etiquetas y reversión
- Concurrencia: el concepto que más se malinterpreta
- Escalado: cero, mínimo, máximo y el modelo de facturación de CPU
- Conectar Cloud Run con el resto de la plataforma
- Identidad y seguridad del servicio
- Detrás del balanceador global: el NEG sin servidor
- La migración desde el MIG, paso a paso y sin corte
- Coste: el modelo y el cálculo comparado
- Límites y cuándo Cloud Run no encaja
- La tabla madura: Cloud Run vs Functions vs GKE vs Compute Engine
- Apagar
alpinashop-web-migy lo que eso simplifica
- Qué es Cloud Run y por qué importa el contrato del contenedor
Cloud Run ejecuta contenedores sin servidor. Le das una imagen, te da una URL HTTPS, y escala de cero a muchas instancias según el tráfico, cobrando por lo que uses.
Dicho así suena a App Engine con otro nombre, y no lo es. La diferencia está en la unidad de despliegue:
| Plataforma | Le entregas | Consecuencia |
|---|---|---|
| App Engine estándar | Código fuente + app.yaml |
Runtimes fijos, formato propietario, poco portable |
| Cloud Functions | Una función y su firma | Un punto de entrada; el runtime lo pone Google |
| Cloud Run | Una imagen de contenedor | Cualquier lenguaje, cualquier binario, cualquier dependencia del sistema |
| GKE | Manifiestos de Kubernetes | Control total y complejidad total |
Que la unidad sea un contenedor estándar es lo que hace a Cloud Run distinto y lo que resolvió el punto más delicado de DA-001: la portabilidad. La misma imagen que corre hoy en alpinashop-cluster correrá mañana en Cloud Run sin tocar una línea. Y si algún día hiciera falta salir, esa imagen corre en cualquier sitio donde haya un runtime de contenedores.
El contrato del contenedor
Cloud Run no ejecuta cualquier imagen de cualquier manera: exige cumplir un contrato, y es deliberadamente corto.
| Regla | Detalle | Por qué |
|---|---|---|
Escuchar en $PORT |
La variable de entorno PORT (por defecto 8080), en 0.0.0.0 |
La plataforma elige el puerto y enruta hacia él |
| Responder a HTTP/gRPC/WebSocket | El tráfico entra por la red, no por eventos locales | Es una plataforma de peticiones |
| Arrancar rápido | Escuchar en el puerto en segundos, no en minutos | Cada arranque en frío es latencia para un usuario |
| Sin estado en disco | El sistema de ficheros es efímero y vive en memoria | Las instancias nacen y mueren sin avisar |
| Sin procesos en segundo plano fuera de la petición | Salvo con CPU siempre activa | Por defecto la CPU se congela entre peticiones |
| Contenedor Linux x86-64 o arm64 | Sin privilegios, sin acceso al núcleo | Aislamiento multiinquilino |
Esas seis líneas son todo. No hay que aprender ningún formato de despliegue nuevo, y esa es la razón por la que Cloud Run se ha convertido en la opción por defecto para aplicaciones web en Google Cloud.
Dos errores frecuentes contra el contrato, dichos ya:
# MAL: puerto fijo y solo escucha en localhost
app.run(host="127.0.0.1", port=5000)
# BIEN: el puerto lo dice la plataforma y se escucha en todas las interfaces
import os
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))El primero produce el error más común de los principiantes en Cloud Run: "The user-provided container failed to start and listen on the port defined provided by the PORT environment variable". Cuando lo veas, ya sabes qué mirar.
Cómo funciona por dentro
flowchart TB
U["Usuarios"] --> LB["Frontend HTTPS gestionado<br/>o balanceador global"]
LB --> CR["Cloud Run - servicio alpinashop-web"]
subgraph CR
direction LR
R1["Revision 0042<br/>90% del trafico"]
R2["Revision 0043<br/>10% del trafico"]
end
R1 --> I1["Instancia 1<br/>concurrencia 80"]
R1 --> I2["Instancia 2"]
R2 --> I3["Instancia 3"]
I1 -.-> SQL["Cloud SQL<br/>alpinashop-pedidos"]
I1 -.-> SM["Secret Manager"]
I1 -.-> GCS["Bucket<br/>alpinashop-catalogo"]
Tres conceptos que hay que fijar desde ya:
- Servicio: la entidad estable, con nombre y URL.
alpinashop-web. - Revisión: una versión inmutable del servicio (imagen + configuración).
alpinashop-web-00042-abc. No se modifica nunca: cada cambio crea una revisión nueva. - Instancia: un contenedor en ejecución de una revisión. Nacen y mueren solas.
La inmutabilidad de las revisiones es lo que hace posible el reparto de tráfico y la reversión instantánea del apartado 5.
- Servicios y trabajos (jobs): el criterio para elegir
Cloud Run tiene dos formas de ejecutar un contenedor, y elegir mal es una fuente clásica de arquitecturas raras.
| Servicio (service) | Trabajo (job) | |
|---|---|---|
| Se dispara por | Una petición HTTP entrante | Una ejecución explícita (jobs execute, Scheduler, Workflows) |
Debe escuchar en $PORT |
Sí | No |
| Termina | Nunca (vive mientras haya tráfico) | Cuando el proceso acaba con código 0 |
| Escala por | Peticiones concurrentes | --tasks: N tareas en paralelo |
| Duración máxima | Hasta 60 minutos por petición | Hasta 24 horas por tarea |
| Reintentos | Los hace el cliente | Integrados: --max-retries |
| Facturación | Por tiempo de instancia | Por tiempo de tarea |
La regla de decisión, en una frase:
Si algo responde a alguien, es un servicio. Si algo hace una cosa y termina, es un trabajo.
Aplicado a AlpinaShop:
| Carga | Tipo | Por qué |
|---|---|---|
| Catálogo web | Servicio | Responde peticiones de clientes |
| API interna de stock | Servicio | Responde al proceso de sincronización |
| Informe nocturno de ventas | Trabajo | Se ejecuta, produce un fichero, termina |
| Reindexado del buscador | Trabajo | Proceso por lotes, 20 minutos, sin cliente esperando |
| Migración de esquema de BD | Trabajo | Una ejecución, con reintentos y código de salida |
| Procesar imagen subida | Cloud Function (06-03) | Evento único, ya resuelto y funcionando |
El antipatrón clásico —y se ve mucho— es montar el informe nocturno como un servicio con un endpoint /ejecutar-informe que llama Cloud Scheduler. Funciona, pero: tienes un endpoint HTTP que hay que proteger, la ejecución está limitada por el tiempo máximo de petición, no hay reintentos integrados, y si tarda de más el proceso se corta a mitad. Un trabajo elimina los cuatro problemas.
Un trabajo real de AlpinaShop, el informe nocturno de 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_analiticaY su ejecución desde Cloud Scheduler, sin exponer ningún HTTP propio:
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.comNótese --time-zone="Europe/Madrid": sin ella, el 30 3 * * * es UTC y el informe se ejecutará a las 5:30 en verano. Es un error que se descubre en marzo o en octubre y desconcierta durante horas.
- Desplegar el catálogo:
gcloud run deploy bandera a bandera
gcloud run deploy bandera a banderaLa imagen del catálogo ya está en Artifact Registry desde 06-01, construida por Cloud Build y etiquetada con $COMMIT_SHA. Solo falta desplegarla. Este es el comando completo de AlpinaShop, y a continuación cada bandera explicada.
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| Bandera | Qué hace | Por qué este valor en AlpinaShop |
|---|---|---|
--image |
Imagen a desplegar | Etiquetada con el hash del commit, nunca latest (06-01). Con latest no sabes qué hay corriendo |
--region |
Región del servicio | europe-west1, donde está todo lo demás. Cloud Run es regional |
--platform=managed |
Cloud Run gestionado | La alternativa era gke, en desuso (07-01) |
--service-account |
Identidad del servicio | Su propia cuenta con mínimo privilegio (03-04). Sin esto usa la cuenta por defecto de Compute, que es demasiado permisiva |
--no-allow-unauthenticated |
Exige IAM para invocar | El servicio no se expone a internet directamente: entrará por el balanceador (apartado 10) |
--port |
Puerto del contenedor | 8080, el mismo del contrato y de la sonda hc-catalogo |
--cpu |
vCPU por instancia | 1 vCPU. Se puede fraccionar (0.5, 0.25) o llegar a 8 |
--memory |
Memoria por instancia | 512 MiB. Medida, no adivinada: ver apartado 6 |
--concurrency |
Peticiones simultáneas por instancia | 80, el valor por defecto, validado midiendo (apartado 6) |
--min-instances |
Instancias siempre encendidas | 1, para que ningún cliente sufra arranque en frío |
--max-instances |
Techo de instancias | 30, como cortafuegos de coste y de conexiones a Cloud SQL |
--timeout |
Tiempo máximo por petición | 60 s. El máximo es 60 min, pero un catálogo que tarde 60 s ya está roto |
--cpu-throttling |
CPU solo durante la petición | Modo por defecto y el más barato |
--execution-environment=gen2 |
Entorno de ejecución | Segunda generación: compatibilidad completa con Linux, arranque algo más lento, necesario para montar volúmenes |
--set-env-vars |
Variables de entorno | Configuración no sensible |
--set-secrets |
Secretos montados | La contraseña nunca como variable en claro (03-06) |
--add-cloudsql-instances |
Conector a Cloud SQL | Habilita el socket Unix hacia alpinashop-pedidos |
--labels |
Etiquetas | Las cuatro de AlpinaShop desde 01-04, imprescindibles para 07-05 |
--no-traffic |
Despliega sin enviarle tráfico | Crea la revisión pero no la activa. Clave para el canario |
--tag=candidata |
Etiqueta de revisión | Le da una URL propia para probarla antes de darle tráfico |
Las dos últimas son las que convierten un despliegue en un despliegue seguro, y merecen su propio apartado.
- El mismo servicio en Terraform
AlpinaShop gestiona su infraestructura con Terraform desde 06-07, así que el comando anterior es para experimentar: lo que va a main es 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
# Solo trafico interno y del balanceador: nadie llega por la URL run.app
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 solo durante la peticion
startup_cpu_boost = true # CPU extra durante el arranque
}
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]
}
}
}
# Reparto de trafico explicito: 100% a la ultima revision estable
traffic {
type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
percent = 100
}
labels = {
entorno = "prod"
equipo = "desarrollo"
centro-coste = "tienda"
aplicacion = "catalogo"
}
}Cuatro observaciones importantes sobre este HCL:
- Usa
google_cloud_run_v2_service, nogoogle_cloud_run_service. El recursov1sigue existiendo por compatibilidad y su sintaxis (basada en anotaciones de Knative) es mucho peor. Todo lo nuevo va env2. cpu_idle = truees el equivalente a--cpu-throttling: la CPU solo se factura durante las peticiones.falsesignifica CPU siempre activa, y es hasta cuatro veces más caro.startup_cpu_boost = trueda CPU adicional durante el arranque. Reduce el arranque en frío entre un 20 % y un 50 % en aplicaciones con inicialización pesada (JVM, frameworks grandes), y solo se paga ese medio segundo.ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"es la pieza de seguridad: nadie puede llegar al servicio por su URL*.run.app. Solo entra tráfico desde el balanceador global.
Y hay una tensión que conviene resolver explícitamente, porque es la pregunta que todo el mundo se hace al llegar aquí: si el despliegue lo hace el pipeline, ¿qué gestiona Terraform?
| Aspecto | Quién lo gestiona | Motivo |
|---|---|---|
| Existencia del servicio, cuenta de servicio, ingress, secretos, escalado, VPC | Terraform | Es la forma; cambia por decisión humana |
| Versión de la imagen desplegada | Cloud Build | Cambia varias veces al día; es contenido |
| Reparto de tráfico entre revisiones | Cloud Build durante el canario, Terraform en reposo | Es una operación, no una configuración |
Es exactamente la regla de 06-07: Terraform gestiona la forma, no el contenido. En la práctica se resuelve con lifecycle { ignore_changes = [template[0].containers[0].image, traffic] } en el recurso, para que Terraform no intente devolver la imagen a la versión de su fichero cada vez que corre.
- Revisiones y reparto de tráfico: canario, etiquetas y reversión
Aquí está una de las mejores capacidades de Cloud Run, y es prácticamente gratis.
Etiquetas de revisión: probar sin arriesgar
Al desplegar con --no-traffic --tag=candidata, Cloud Run crea la revisión y le asigna una URL propia:
Esa URL apunta solo a esa revisión, con cero tráfico de producción. Sirve para:
- Probar la versión nueva contra la base de datos real, sin que ningún cliente la vea.
- Pasar las pruebas de humo automáticas del pipeline.
- Enseñársela a alguien antes de publicarla.
# Prueba de humo contra la revision candidata, con token porque el servicio es privado
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 "FALLA LA SONDA"; exit 1; }El canario al 10 %
Si la prueba pasa, se le da un porcentaje pequeño de tráfico real:
A partir de ese momento, una de cada diez peticiones va a la revisión nueva. Se observa durante veinte o treinta minutos en el panel de 06-04 —tasa de error, latencia p95, errores en Error Reporting— y se decide.
Progresión completa recomendada:
| Paso | Tráfico | Qué se observa | Duración |
|---|---|---|---|
| 1 | 0 % (solo etiqueta) | Pruebas de humo | 2 min |
| 2 | 10 % | Tasa de error 5xx, latencia p95 | 20-30 min |
| 3 | 50 % | Lo mismo, con volumen | 30-60 min |
| 4 | 100 % | Estabilización | — |
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-latestLa reversión instantánea
Y esta es la parte que cambia cómo se duerme por las noches. Si la revisión nueva falla:
# Ver las revisiones y su reparto actual
gcloud run revisions list --service=alpinashop-web --region=europe-west1
# Volver TODO el trafico a la revision anterior conocida como buena
gcloud run services update-traffic alpinashop-web \
--region=europe-west1 \
--to-revisions=alpinashop-web-00041-xyz=100Tarda segundos y no reconstruye ni redespliega nada, porque la revisión anterior sigue existiendo íntegra. Compárese con el MIG: había que crear una plantilla de instancia nueva, lanzar una actualización progresiva y esperar a que las VM se sustituyeran una a una — entre cinco y quince minutos, con el sitio degradado mientras tanto.
Consejo operativo: ten el comando de reversión escrito en el runbook (07-06) con el número de revisión rellenado como variable. A las tres de la madrugada nadie recuerda la sintaxis de
--to-revisions.
Dos advertencias sobre el canario
- El canario no protege contra cambios de esquema de base de datos. Si la revisión nueva hace
ALTER TABLE, el 90 % que sigue en la vieja también ve la tabla cambiada. Las migraciones de esquema deben ser compatibles hacia atrás (añadir columnas, nunca renombrarlas en el mismo despliegue), y ejecutarse como un trabajo aparte. - Con poco tráfico, el 10 % no significa nada estadísticamente. Si AlpinaShop recibe 30 peticiones por minuto fuera de campaña, el 10 % son 3 peticiones. Un canario que no ve errores durante 20 minutos con 60 peticiones no ha demostrado gran cosa. En volúmenes bajos conviene subir el porcentaje y alargar el tiempo, o hacer el canario en horas de más tráfico.
- Concurrencia: el concepto que más se malinterpreta
Si te llevas un solo apartado de esta lección, que sea este.
Concurrencia es el número máximo de peticiones que Cloud Run envía simultáneamente a una misma instancia antes de arrancar otra.
El valor por defecto es 80. Y aquí está la fuente de todos los malentendidos: mucha gente llega a Cloud Run desde Cloud Functions o desde AWS Lambda, donde el modelo es una petición por instancia, y asume que Cloud Run funciona igual. No.
Comparación honesta de los dos modelos
Una petición por instancia (--concurrency=1) |
Concurrencia alta (--concurrency=80) |
|
|---|---|---|
| Aislamiento | Total: cada petición tiene su contenedor | Compartido: 80 peticiones comparten memoria y CPU |
| Razonamiento | Trivial: no hay concurrencia que gestionar | Hay que pensar en variables globales y estado compartido |
| Coste | Muy alto: una instancia por petición | Bajo: 80 peticiones pagan una instancia |
| Arranques en frío | Muchísimos: cada pico crea instancias | Pocos |
| Rendimiento en E/S | Malísimo: la CPU está ociosa esperando la BD | Excelente: mientras una petición espera, otra usa la CPU |
| Aplica bien a | Cargas de CPU intensiva, código no seguro para hilos, ML | Aplicaciones web y APIs normales |
La clave está en la penúltima fila. Una petición típica del catálogo de AlpinaShop pasa así sus 200 ms:
|-- 5 ms --|------------ 170 ms ------------|-- 25 ms --| parsear esperando a Cloud SQL render (CPU) (CPU OCIOSA) (CPU)
El 85 % del tiempo la CPU no hace nada: espera a la base de datos. Con --concurrency=1, pagas una vCPU entera para que esté ociosa 170 ms de cada 200. Con --concurrency=80, esa misma vCPU atiende a otras peticiones durante esa espera.
Cómo elegirla midiendo
La concurrencia correcta no se adivina, se mide. El procedimiento:
Paso 1 — Averiguar el modelo de concurrencia de tu aplicación. Es lo primero y casi nadie lo comprueba:
| Servidor | Modelo | Concurrencia útil |
|---|---|---|
Flask con app.run() (desarrollo) |
Un hilo | 1. No pongas esto en producción |
| Gunicorn con workers síncronos | workers × 1 |
Igual al número de workers |
Gunicorn con gthread |
workers × threads |
Ese producto |
| Node.js / Express | Bucle de eventos | Alta: 80-250 |
| Go / Java con hilos | Alta | 80-1000 |
Python con asyncio (FastAPI + Uvicorn) |
Bucle de eventos | Alta: 80-250 |
El error más caro de todos: poner
--concurrency=80en un servidor que solo procesa una petición a la vez. Cloud Run le manda 80, el servidor las encola, y las peticiones 2 a 80 esperan su turno. La latencia se dispara y parece un problema de la plataforma. Es un problema de configuración del servidor de aplicaciones.
Para el catálogo de AlpinaShop, que es Flask, el Dockerfile termina así:
# 4 procesos x 20 hilos = 80 peticiones simultaneas reales
CMD exec gunicorn --bind :$PORT \
--workers 4 \
--threads 20 \
--worker-class gthread \
--timeout 0 \
main:app--timeout 0 desactiva el temporizador de Gunicorn porque el tiempo de espera lo gobierna Cloud Run con --timeout=60s. Tener dos temporizadores compitiendo produce cortes inexplicables.
Paso 2 — Prueba de carga con distintos valores. Se despliegan revisiones con etiqueta y se mide cada una:
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
doneY con una herramienta de carga (hey, k6, vegeta) contra cada URL etiquetada. Resultados reales del catálogo, con 50 peticiones por segundo sostenidas durante 5 minutos:
| Concurrencia | Instancias usadas | Latencia p50 | Latencia p95 | Coste relativo |
|---|---|---|---|---|
| 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× |
Lectura de la tabla, que es donde está el aprendizaje:
- De 1 a 80, la latencia p50 apenas sube (190 → 210 ms) y el coste se divide por doce. Regalo evidente.
- De 80 a 160 la latencia se dispara: se han superado los 80 hilos reales de Gunicorn y las peticiones se encolan. El techo no lo pone Cloud Run, lo pone tu aplicación.
- El p95 se degrada antes que el p50. Por eso se mide el p95: es donde se ve el sufrimiento antes de que sea evidente.
Paso 3 — Ajustar CPU y memoria a la concurrencia elegida. Son parámetros acoplados:
- Con concurrencia 80, la instancia necesita memoria para 80 peticiones simultáneas. Si cada una consume 4 MiB de estructuras, son 320 MiB solo de peticiones, más el runtime.
- Si la aplicación es intensiva en CPU, la concurrencia alta hace que las peticiones compitan por la misma vCPU. Ahí se sube CPU o se baja concurrencia.
La señal de que la memoria se queda corta es inconfundible en los logs:
Cuando eso ocurre, la instancia muere y las peticiones en curso se pierden. Es de los pocos fallos de Cloud Run que se manifiestan como errores 5xx sin traza de la aplicación.
- Escalado: cero, mínimo, máximo y el modelo de facturación de CPU
Escalar a cero
Sin tráfico, Cloud Run apaga todas las instancias y no cobra nada. Es la propiedad que hace que un entorno de desarrollo cueste céntimos.
El precio de esa propiedad es el arranque en frío (cold start): la primera petición tras un periodo de inactividad tiene que esperar a que se cree una instancia, se descargue la imagen y arranque la aplicación.
| Fase | Tiempo típico | Cómo se reduce |
|---|---|---|
| Aprovisionar el sandbox | 100-300 ms | No está en tu mano |
| Descargar la imagen | 200 ms - 5 s | Imagen pequeña. Es la palanca principal |
| Arrancar la aplicación | 100 ms - 10 s | Menos dependencias, carga perezosa, startup_cpu_boost |
Números orientativos de arranque en frío completo: un binario de Go en una imagen distroless de 15 MB arranca en unos 300 ms; una aplicación Python con muchas dependencias, en 1-3 s; una JVM con Spring Boot sin ajustar, entre 8 y 20 s. La diferencia la marca la imagen y el runtime, no Cloud Run.
--min-instances: pagar por no tener frío
Mantiene N instancias siempre vivas. La instancia inactiva se factura a una tarifa reducida —del orden de la décima parte de la activa—, así que una instancia mínima es barata: unos pocos euros al mes.
Criterio para AlpinaShop:
| Servicio | --min-instances |
Razón |
|---|---|---|
alpinashop-web (prod) |
1 | Un cliente que llega a las 7:00 no debe esperar 2 s |
alpinashop-web (dev) |
0 | Nadie sufre un arranque en frío en desarrollo |
| Servicios internos | 0 | Los llama software, que puede esperar |
| Durante la campaña de otoño | 3 | Amortiguar el arranque de la mañana |
Advertencia importante: --min-instances reduce mucho los arranques en frío, pero no los elimina. Si el tráfico sube de golpe, hay que crear instancias nuevas y esas arrancan en frío igualmente. Y hay un detalle contraintuitivo: con CPU limitada a las peticiones (cpu_idle=true), las instancias mínimas tienen la CPU congelada entre peticiones, así que si tu aplicación hace algo periódico en segundo plano, no se ejecutará.
--max-instances: el cortafuegos de coste
Esto es protección, y hay que ponerlo siempre. Dos motivos:
- Coste. Un bucle infinito en el código, un rastreador agresivo o un ataque pueden lanzar cientos de instancias. Sin techo, la factura del día es un incidente.
- Dependencias aguas abajo. Cloud SQL tiene un límite de conexiones. Si cada instancia abre 5 conexiones y arrancan 200 instancias, son 1000 conexiones contra una base de datos que admite 100: Cloud Run escala perfectamente y tumba la base de datos.
La fórmula para elegirlo:
max-instances = min(
capacidad_de_la_dependencia_mas_debil / conexiones_por_instancia,
pico_de_trafico_esperado / concurrencia * margen
)Para AlpinaShop: Cloud SQL admite ~100 conexiones útiles, el pool por instancia es de 3 → 33 instancias. El pico esperado en campaña son 20 req/s, con concurrencia 80 → menos de 1 instancia, con margen ×5 para picos bruscos → 5. Se elige 30: cómodo para el tráfico, por debajo del límite de la base de datos.
Cuando se alcanza el máximo, Cloud Run no rechaza: encola las peticiones y las va sirviendo, y solo devuelve 429 si la cola se satura. Es un comportamiento amable, pero conviene tener una alerta:
gcloud monitoring channels list # el canal de Marta, de 06-04
# Alerta cuando se supera el 80% del maximo durante 5 minutosCPU siempre activa frente a CPU durante la petición
--cpu-throttling (por defecto) |
--no-cpu-throttling |
|
|---|---|---|
| CPU entre peticiones | Congelada | Disponible |
| Precio de la vCPU | Base | Del orden de 4 veces menor por segundo, pero se paga todo el tiempo de vida de la instancia |
| Procesos en segundo plano | No funcionan | Funcionan |
| Casos de uso | Aplicaciones web normales | Consumidores de streaming, servicios con caché que refresca sola, WebSockets con trabajo entre mensajes |
Para el catálogo, --cpu-throttling es lo correcto: entre peticiones no hay nada que hacer. La trampa clásica es lanzar un hilo en segundo plano para "enviar métricas cada 30 segundos" y descubrir que no se ejecuta nunca. Con CPU congelada, ese hilo se para.
- Conectar Cloud Run con el resto de la plataforma
Un servicio aislado no vale nada. Estas son las cinco conexiones de AlpinaShop.
Cloud SQL: por conector, no por IP
Cloud Run monta un socket Unix en /cloudsql/<nombre-de-conexión> que habla con la instancia por un canal cifrado, sin IP pública y sin abrir firewall. La cadena de conexión de la aplicación:
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"], # inyectada desde Secret Manager
database="tienda",
query={"unix_sock": f"/cloudsql/{CONEXION}/.s.PGSQL.5432"},
),
pool_size=3, # bajo: multiplica por el numero de instancias
max_overflow=0, # sin conexiones extra sorpresa
pool_pre_ping=True, # detecta conexiones muertas tras una pausa
pool_recycle=1800,
)Las cuatro opciones del pool no son decorativas:
pool_size=3: recuérdese la fórmula de--max-instances. Un pool de 20 por instancia es la receta para agotar la base de datos.max_overflow=0: impide que el pool abra conexiones adicionales bajo carga, que es justo cuando no te lo puedes permitir.pool_pre_ping=True: imprescindible en Cloud Run. Una instancia con la CPU congelada durante minutos vuelve con conexiones muertas; sin pre-ping, la primera petición tras la pausa falla.pool_recycle=1800: recicla conexiones cada media hora antes de que las corte el servidor.
La alternativa moderna es conectar por IP privada a través de la VPC (siguiente punto), que evita el conector y da menos latencia. Es lo que AlpinaShop hará cuando la red esté ordenada en 07-03.
Secret Manager: montado, no copiado
La aplicación lee os.environ["DB_PASSWORD"] y no sabe que viene de Secret Manager. Alternativa, montarlo como fichero:
Diferencia práctica que conviene conocer: una variable de entorno se lee una vez al arrancar la instancia; un fichero montado se puede releer. Si rotas un secreto, las instancias vivas con variable de entorno siguen usando el valor viejo hasta que se reemplacen. Con :latest y ficheros, la rotación se propaga sola.
Y la contrapartida: usar :latest significa que una rotación mal hecha rompe el servicio sin que nadie despliegue nada. Para secretos críticos, fijar la versión (:7) y actualizarla en un despliegue controlado es más seguro.
VPC directa: salir por la red privada
Por defecto, el tráfico saliente de Cloud Run va a internet. Para llegar a recursos con IP privada —una réplica de Cloud SQL, Memorystore, o el ERP de Sabadell al otro lado del túnel de 07-03— hay que conectar el servicio a la VPC:
gcloud run services update alpinashop-web \
--region=europe-west1 \
--network=alpinashop-vpc \
--subnet=sn-web-euw1 \
--vpc-egress=private-ranges-onlyEsto es VPC directa (Direct VPC egress), que sustituye al antiguo conector de acceso a VPC sin servidor (Serverless VPC Access connector):
| Conector (antiguo) | VPC directa (actual) | |
|---|---|---|
| Infraestructura | VM gestionadas que hay que dimensionar y pagar | Ninguna |
| Coste | Del orden de decenas de € al mes | Sin coste adicional |
| Latencia | Un salto extra | Directa |
| Escalado | Manual (número de instancias del conector) | Automático |
Si encuentras documentación que habla de conectores, es anterior. VPC directa es lo que hay que usar hoy.
El valor de --vpc-egress decide qué sale por la VPC:
| Valor | Efecto |
|---|---|
private-ranges-only |
Solo los rangos RFC 1918 van por la VPC; internet sale directo. El habitual |
all-traffic |
Todo sale por la VPC, incluido internet. Necesario si quieres que salga por el Cloud NAT alpinashop-nat-euw1 con IP fija (03-01), por ejemplo porque la pasarela de pago tiene lista blanca de IP |
Pub/Sub push con OIDC
En 04-04 se dijo que el modelo push encaja con Cloud Run. Así se conecta, sin abrir el servicio a nadie:
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=5Lo esencial es --push-auth-service-account: Pub/Sub firma cada entrega con un token OIDC de esa cuenta. El servicio, que está en --no-allow-unauthenticated, solo acepta la petición si esa cuenta tiene roles/run.invoker:
gcloud run services add-iam-policy-binding alpinashop-web \
--region=europe-west1 \
--member=serviceAccount:[email protected] \
--role=roles/run.invokerNingún secreto compartido, ninguna cabecera mágica, ninguna comprobación en el código. La plataforma valida el token antes de que la petición llegue a tu contenedor.
Eventarc: reaccionar a lo que pasa en GCP
Eventarc entrega a Cloud Run eventos de más de un centenar de servicios de Google Cloud, con el formato estándar CloudEvents:
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.comLa relación con lo ya visto: Cloud Functions 2ª generación (06-03) está construida sobre Cloud Run y Eventarc. La función procesar-imagen-producto es, por debajo, un servicio de Cloud Run con un disparador de Eventarc. Saberlo explica por qué comparten límites, opciones de escalado y modelo de facturación.
- Identidad y seguridad del servicio
Cuatro decisiones, todas coherentes con el módulo 3.
1. Cuenta de servicio propia y mínima. sa-catalogo-web tiene exactamente lo que necesita y nada más:
# Leer el secreto de la contrasena
gcloud secrets add-iam-policy-binding db-password-catalogo \
--member=serviceAccount:[email protected] \
--role=roles/secretmanager.secretAccessor
# Conectarse a Cloud SQL
gcloud projects add-iam-policy-binding alpinashop-prod \
--member=serviceAccount:[email protected] \
--role=roles/cloudsql.client
# Leer imagenes del bucket del catalogo
gcloud storage buckets add-iam-policy-binding gs://alpinashop-catalogo \
--member=serviceAccount:[email protected] \
--role=roles/storage.objectViewerSi no se especifica --service-account, Cloud Run usa la cuenta de servicio por defecto de Compute Engine, que históricamente tiene el rol Editor sobre todo el proyecto. Desplegar así es dar a tu aplicación web permiso para borrar la base de datos. Es el fallo de seguridad más común de todo Cloud Run.
2. --no-allow-unauthenticated. El servicio exige un token de identidad válido de alguien con roles/run.invoker. Quién puede invocarlo:
| Invocador | Cómo |
|---|---|
| El balanceador global | El NEG sin servidor invoca sin token; se controla con ingress |
| Pub/Sub | Token OIDC de sa-pubsub-invoker |
| Otro servicio de Cloud Run | Token OIDC de su propia cuenta |
| Una persona (para depurar) | curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" |
| Un usuario final autenticado con Google | IAP delante del balanceador |
3. Control de entrada (ingress). Es distinto de la autenticación y complementario:
| Valor | Quién puede llegar |
|---|---|
all |
Todo internet (la URL run.app es pública) |
internal |
Solo desde la VPC, VPC Service Controls y otros servicios del proyecto |
internal-and-cloud-load-balancing |
Lo anterior más el balanceador global. El de AlpinaShop |
Con internal-and-cloud-load-balancing, la URL *.run.app deja de responder desde fuera. Todo el tráfico de clientes tiene que pasar por Cloud Armor y la CDN, sin excepción. Esto es lo que impide el atajo de "voy a probar directamente contra run.app", que es el agujero por el que se salta el WAF.
4. IAP para servicios internos. El panel de administración interno se publica con el mismo patrón de 03-04: balanceador + IAP + Cloud Run privado. Los empleados entran con su cuenta de Google, sin VPN y sin usuarios propios.
- Detrás del balanceador global: el NEG sin servidor
En 03-02 se montó el balanceador global de AlpinaShop y se dejó dicho que el día que el catálogo fuera a Cloud Run bastaría con cambiar el backend. Ese día ha llegado.
Un NEG sin servidor (serverless network endpoint group) es un backend que apunta a un servicio de Cloud Run, App Engine o Cloud Functions en lugar de a un grupo de instancias.
flowchart LR
C["Clientes"] --> IP["alpinashop-lb-ip<br/>IP anycast global"]
IP --> CERT["alpinashop-cert<br/>TLS gestionado"]
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["NEG sin servidor<br/>neg-catalogo-run"]
NEG --> CR["Cloud Run<br/>alpinashop-web"]
style NEG fill:#e6f4ea,stroke:#34a853
style CR fill:#e6f4ea,stroke:#34a853
Todo lo azul del diagrama —IP, certificado, WAF, mapa de URL, CDN— no se toca. Solo cambia lo verde.
Creación del NEG:
gcloud compute network-endpoint-groups create neg-catalogo-run \
--region=europe-west1 \
--network-endpoint-type=serverless \
--cloud-run-service=alpinashop-webY su incorporación al servicio de backend existente:
gcloud compute backend-services add-backend bs-catalogo-web \
--global \
--network-endpoint-group=neg-catalogo-run \
--network-endpoint-group-region=europe-west1Cuatro particularidades del NEG sin servidor que hay que conocer, porque son distintas de todo lo visto en 03-02:
- No hay comprobaciones de estado. El servicio de backend con NEG sin servidor no usa
hc-catalogo. La salud la gestiona Cloud Run. Al principio desconcierta; es correcto. - No hay modo de balanceo ni capacidad. No existen
max-rate-per-instancenicapacity-scaler: Cloud Run escala solo. - El NEG es regional, aunque el balanceador sea global. Para servir desde varias regiones se crean varios NEG, uno por región, y se añaden al mismo servicio de backend — que es exactamente cómo se hace la alta disponibilidad multirregión de 07-06.
- La CDN sigue funcionando igual. Las cabeceras
Cache-Controlque emite Cloud Run gobiernan la caché exactamente como lo hacían las del MIG (03-03).
- La migración desde el MIG, paso a paso y sin corte
Este es el procedimiento real que ejecutó Marta. Está escrito para poder seguirlo, y su virtud es que en ningún momento hay un punto sin retorno.
Fase 0 — Preparación (días antes)
# 1. Verificar que la imagen cumple el contrato, en local
docker run -e PORT=8080 -p 8080:8080 \
europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo:a3f91c2
curl -sf localhost:8080/salud && echo "CONTRATO OK"
# 2. Desplegar en desarrollo primero. SIEMPRE.
gcloud run deploy alpinashop-web --project=alpinashop-dev \
--image=... --region=europe-west1 --min-instances=0Y una lista de comprobación previa que evita el 90 % de los sustos:
- [ ] La aplicación escucha en
$PORTy en0.0.0.0. - [ ] No escribe nada importante en disco local.
- [ ] No guarda sesiones en memoria (las de AlpinaShop están en Firestore desde 02-06).
- [ ] Los logs van a
stdout/stderren JSON estructurado (06-06). - [ ] Gunicorn está configurado con hilos suficientes para la concurrencia elegida.
- [ ] El pool de conexiones es pequeño y tiene
pool_pre_ping. - [ ] No hay hilos de fondo que dependan de CPU entre peticiones.
Fase 1 — Desplegar en producción sin tráfico
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 \
[... el resto de banderas del apartado 3 ...]El servicio existe, funciona contra la base de datos real y ningún cliente lo ve. Aquí se hacen las pruebas de humo y se compara la latencia con la del MIG.
Fase 2 — Añadir el NEG al balanceador con peso cero
Se crea el NEG y se añade al servicio de backend bs-catalogo-web, que ahora tiene dos backends: el MIG y Cloud Run. El balanceador reparte entre ellos según capacidad.
Para controlar el reparto con precisión, AlpinaShop usa la técnica del capacity-scaler sobre el backend del MIG:
# Estado inicial: todo al 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.0Fase 3 — El trasvase progresivo
Se va reduciendo la capacidad del MIG, lo que empuja tráfico hacia Cloud Run:
| Momento | capacity-scaler del MIG |
Tráfico aproximado a Cloud Run | Qué se vigila |
|---|---|---|---|
| T+0 | 1.0 | ~0 % | — |
| T+30 min | 0.5 | ~30 % | 5xx, p95, conexiones a Cloud SQL |
| T+2 h | 0.2 | ~70 % | Lo mismo, más coste |
| T+1 día | 0.0 | 100 % | Todo, durante 24 h |
En cada escalón se miran las mismas cuatro cosas en el panel de 06-04, y se comparan por backend:
# En el explorador de metricas, filtrando por backend_target_name loadbalancing.googleapis.com/https/request_count -> reparto real loadbalancing.googleapis.com/https/backend_latencies -> p50 y p95 por backend run.googleapis.com/container/instance_count -> instancias creadas cloudsql.googleapis.com/database/postgresql/num_backends -> conexiones abiertas
La métrica de conexiones a Cloud SQL es la que más sorpresas da, y es el motivo por el que el trasvase se hace en horas y no en minutos.
Fase 4 — Convivencia vigilada
Durante 24-72 horas el MIG sigue existiendo con capacity-scaler=0.0: no recibe tráfico pero está encendido. La reversión es un solo comando:
gcloud compute backend-services update-backend bs-catalogo-web --global \
--instance-group=alpinashop-web-mig \
--instance-group-zone=europe-west1-b \
--capacity-scaler=1.0Y en segundos vuelve todo al MIG. Pagar dos días de VM apagada de tráfico es el precio de dormir tranquilo, y es un precio ridículo.
Fase 5 — Retirada
Solo cuando han pasado los tres días y se ha atravesado al menos un pico de tráfico:
# 1. Quitar el MIG del balanceador
gcloud compute backend-services remove-backend bs-catalogo-web --global \
--instance-group=alpinashop-web-mig \
--instance-group-zone=europe-west1-b
# 2. Reducir el MIG a cero antes de borrarlo (permite volver rapido)
gcloud compute instance-groups managed resize alpinashop-web-mig \
--size=0 --zone=europe-west1-b
# 3. Una semana despues, borrar de verdad, desde Terraform
# (eliminar el recurso del .tf y hacer plan/apply, nunca a mano)El paso 2 es importante: reducir a cero antes de borrar. Un MIG de tamaño cero no cuesta cómputo, conserva su plantilla, y volver a levantarlo es un resize. El borrado definitivo se hace desde Terraform, porque si se hace a mano el siguiente plan intentará recrearlo.
- Coste: el modelo y el cálculo comparado
El modelo de facturación
Cloud Run cobra tres cosas, y solo mientras hay instancias vivas:
| Concepto | Unidad | Orden de magnitud en europe-west1 |
|---|---|---|
| CPU | vCPU-segundo | ~0,000024 $ por vCPU-s activa; la instancia mínima ociosa, alrededor de una décima parte |
| Memoria | GiB-segundo | ~0,0000025 $ por GiB-s |
| Peticiones | Por millón | ~0,40 $ por millón |
| Salida a internet | GB | Tarifa estándar de egress (07-05) |
Y hay un nivel gratuito mensual generoso —del orden de 180.000 vCPU-s, 360.000 GiB-s y 2 millones de peticiones— que hace que muchos proyectos pequeños cuesten literalmente cero.
Los precios cambian y varían por región. Consulta siempre la página de precios oficial y la calculadora. Lo que sigue son órdenes de magnitud para razonar, no un presupuesto.
El cálculo de AlpinaShop
Escenario: 1,5 millones de peticiones al mes, latencia media 200 ms, 1 vCPU, 512 MiB, concurrencia 80, min-instances=1, CPU limitada a la petición.
Tiempo de instancia activa. Con concurrencia 80, las peticiones se solapan. Una estimación conservadora del tiempo con al menos una petición en vuelo, a partir de la distribución horaria real, da unas 28 horas al mes de actividad ≈ 100.000 vCPU-s.
| Concepto | Cálculo | Coste |
|---|---|---|
| CPU activa | 100.000 vCPU-s × 0,000024 $ | ~2,40 $ |
| CPU ociosa (min-instances=1) | 2.592.000 s × ~0,0000025 $ | ~6,50 $ |
| Memoria activa | 50.000 GiB-s × 0,0000025 $ | ~0,13 $ |
| Memoria ociosa | 1.296.000 GiB-s × ~0,00000025 $ | ~0,32 $ |
| Peticiones | 1,5 M × 0,40 $/M | ~0,60 $ |
| Menos nivel gratuito | −2 a −3 $ | |
| Total | ≈ 7-8 $/mes ≈ 7 €/mes |
Comparación con lo que se venía pagando, usando las cifras de 02-07:
| Concepto | MIG (antes) | Cloud Run (después) |
|---|---|---|
| Cómputo | 2 × e2-medium 24×7 ≈ 50 €/mes |
≈ 7 €/mes |
| Balanceador global + IP | ~20 €/mes | ~20 €/mes (igual) |
| Discos persistentes | ~4 €/mes | 0 € |
| Agente de operaciones | Incluido, pero hay que instalarlo | No hace falta |
| Total infraestructura | ≈ 74 €/mes | ≈ 27 €/mes |
| Parcheo del SO, plantillas, actualizaciones progresivas | ~2-4 h/mes de Marta | 0 h |
Ahorro: unos 47 €/mes, un 63 % del coste de la tienda. Y esa es la parte pequeña. Las 2-4 horas mensuales de Marta valen más que los 47 €, y el tiempo de reversión pasa de 10 minutos a 5 segundos.
Pero hay que decir la verdad completa, porque este curso no vende:
- Si el tráfico fuera constante y alto las 24 horas, el MIG con descuento por uso comprometido sería más barato. Cloud Run gana con tráfico irregular, que es el caso de AlpinaShop, no todos los casos.
- En campaña de otoño la factura de Cloud Run sube, porque se paga el uso. Es lo correcto —se paga cuando se vende— pero significa que la factura ya no es plana. Se planifica en 07-05.
- El balanceador global sigue costando lo mismo y ahora representa el 74 % de la factura del catálogo. Es lo que pasa cuando se optimiza bien: el mayor coste se desplaza a otra parte.
- Límites y cuándo Cloud Run no encaja
Los límites que hay que conocer antes de decidir (verifícalos en la documentación, cambian a mejor con el tiempo):
| Límite | Valor orientativo |
|---|---|
| Tiempo máximo por petición | 60 minutos |
| Memoria por instancia | Hasta 32 GiB |
| CPU por instancia | Hasta 8 vCPU |
| Concurrencia máxima | 1.000 |
| Instancias máximas | 1.000 por defecto (ampliable) |
| Tamaño de petición y respuesta | 32 MiB (sin streaming) |
| GPU | Disponible en regiones seleccionadas |
| Almacenamiento local | En memoria (cuenta contra el límite) o volúmenes montados |
Y los escenarios donde Cloud Run no es la respuesta:
| Escenario | Por qué no | Alternativa |
|---|---|---|
| Procesos que deben correr permanentemente sin peticiones | La CPU se congela; con min-instances y CPU siempre activa se puede, pero pagas por una VM cara |
Compute Engine o GKE |
| Bases de datos, colas, Elasticsearch | Necesitan estado persistente y disco | Servicios gestionados |
| Aplicaciones que necesitan disco local rápido y grande | El sistema de ficheros vive en memoria | Compute Engine con SSD local |
| Tareas de más de 60 minutos | Límite duro | Cloud Run jobs (24 h) o Batch |
| Software con licencia atada al hardware | No hay hardware que licenciar | Compute Engine con licencia propia |
| Protocolos que no sean HTTP/gRPC/WebSocket (por ejemplo, un servidor de juego UDP) | Solo entra tráfico HTTP | Compute Engine con balanceador de red |
| Necesidad de acceso al núcleo, módulos o privilegios | Sandbox sin privilegios | GKE con nodos propios |
- La tabla madura: Cloud Run vs Functions vs GKE vs Compute Engine
En 02-07 se hizo esta comparación con la información de entonces. Ahora, con seis módulos más de recorrido, se puede hacer con criterio de verdad.
| Criterio | Cloud Run | Cloud Functions 2ª gen | GKE Autopilot | Compute Engine |
|---|---|---|---|---|
| Unidad de despliegue | Contenedor | Función | Contenedor + manifiestos | VM |
| Escala a cero | Sí | Sí | No (plano de control fijo) | No |
| Facturación | Por uso, al segundo | Por uso | Por recursos de los pods | Por VM encendida |
| Arranque en frío | Segundos, mitigable | Segundos | No aplica | No aplica |
| Trabajo operativo | Mínimo | Mínimo | Medio | Alto |
| Portabilidad | Alta (contenedor estándar) | Media | Muy alta | Media |
| Curva de aprendizaje | Baja | Muy baja | Alta | Media |
| Servicios que se llaman entre sí | Bien, hasta cierta escala | Regular | Excelente | Manual |
| Cargas por lotes largas | Jobs, hasta 24 h | No (límite corto) | Sí | Sí |
| Estado en disco | No | No | Sí (volúmenes) | Sí |
| Control del sistema operativo | No | No | Parcial | Total |
| Coste con tráfico irregular | El mejor | El mejor para eventos | Malo (coste base) | Malo |
| Coste con tráfico constante alto | Bueno | Malo | Bueno | El mejor con compromiso |
| Elección de AlpinaShop | Catálogo web | procesar-imagen-producto |
Se retira | Se retira |
Las cuatro preguntas que resuelven casi todas las decisiones, en este orden:
- ¿Es un contenedor HTTP sin estado con tráfico irregular? → Cloud Run. Es el caso mayoritario.
- ¿Es una reacción a un evento único, de código breve? → Cloud Functions (que por debajo es Cloud Run).
- ¿Hay muchos servicios que se llaman entre sí, con estado, DaemonSets u operadores? → GKE.
- ¿Necesitas el sistema operativo, disco local, licencias atadas o protocolos no HTTP? → Compute Engine.
Y la observación que cierra el continuo de abstracción de 02-07: la respuesta correcta para AlpinaShop resultó ser el escalón que no se había probado. Se pasó por VM, por App Engine y por Kubernetes antes de llegar a la opción que encajaba. Eso no fue tiempo perdido: fue cómo se adquirió el criterio para decidir.
- Apagar
alpinashop-web-mig y lo que eso simplifica
alpinashop-web-mig y lo que eso simplificaLa lista de cosas que dejan de existir al retirar el MIG es más larga de lo que parece, y es el argumento más fuerte a favor del movimiento:
| Desaparece | Qué implicaba |
|---|---|
| La plantilla de instancia | Un recurso más en Terraform, con su imagen base y su versión |
| El startup script | Cien líneas de bash que instalaban Docker y arrancaban el contenedor |
| El parcheo del sistema operativo | Actualizaciones de seguridad mensuales de Marta |
| El agente de operaciones | Instalación, configuración y actualización para tener métricas de memoria |
La comprobación de estado hc-catalogo |
Ya no hace falta para el backend (sigue útil para las uptime checks) |
| La política de autoescalado del MIG | CPU objetivo, periodo de estabilización, retardo de inicialización |
| Las actualizaciones progresivas | max-surge, max-unavailable y esperar diez minutos |
| Las claves SSH y el acceso por IAP a las VM | Una superficie de ataque entera |
| Los discos persistentes y sus instantáneas | Recursos que se olvidan y se pagan (07-05) |
| La reserva de capacidad para la campaña | Cloud Run escala solo |
Diez elementos menos que mantener, entender, documentar y explicar a quien venga después. Para una empresa con una persona de infraestructura, esa reducción vale más que el ahorro económico.
Lo que sí hay que seguir haciendo, para que nadie se confunda:
- Actualizar la imagen base del
Dockerfiley reconstruir. Cloud Run parchea el sandbox, no tu contenedor. - Vigilar las vulnerabilidades de la imagen en Artifact Registry (07-04).
- Gestionar migraciones de esquema de la base de datos.
- Todo lo relacionado con la aplicación en sí.
La arquitectura de cómputo de AlpinaShop queda, por fin, así:
flowchart TB
subgraph BORDE["Borde global"]
IP["alpinashop-lb-ip"] --> ARM["Cloud Armor pol-catalogo-web"] --> CDN["Cloud CDN"]
end
CDN --> NEG["NEG sin servidor"]
NEG --> RUN["Cloud Run alpinashop-web<br/>0 a 30 instancias"]
RUN --> SQL["Cloud SQL alpinashop-pedidos<br/>HA regional"]
RUN --> FS["Firestore - sesiones"]
RUN --> SEC["Secret Manager"]
GCS["Bucket alpinashop-catalogo"] --> CDN
GCS -->|"evento"| FN["Cloud Function<br/>procesar-imagen-producto"]
RUN -->|"pedido"| 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
No queda ni una máquina virtual en la ruta de servicio de la tienda. Y alpinashop-cluster queda sin cargas de producción: se conserva como entorno de pruebas de contenedores y para lo que pueda venir, con una nota en DA-004 para revisar si merece la pena mantenerlo.
Errores Comunes y Consejos
- Escuchar en un puerto fijo o en
127.0.0.1. Es el error número uno. Lee$PORTy escucha en0.0.0.0. - Desplegar sin
--service-account. Heredas la cuenta por defecto de Compute Engine, que suele tenerEditorsobre el proyecto. Tu web tendría permiso para borrar la base de datos. - Poner
--concurrency=80con un servidor de un solo hilo. Las peticiones se encolan dentro del contenedor y la latencia se dispara. Configura Gunicorn, Uvicorn o lo que uses antes de tocar la concurrencia. - No poner
--max-instances. Un bucle infinito o un rastreador te crea cientos de instancias, y además tumba Cloud SQL por agotamiento de conexiones. - Pool de conexiones grande.
pool_size=20× 30 instancias = 600 conexiones contra una base de datos que aguanta 100. Pool pequeño,max_overflow=0ypool_pre_ping=True. - Guardar algo en el sistema de ficheros y esperar encontrarlo. El disco es efímero y vive en memoria: escribir un fichero de 300 MiB consume 300 MiB de tu límite de memoria.
- Lanzar hilos en segundo plano con CPU limitada a la petición. No se ejecutan. O usas CPU siempre activa, o mueves ese trabajo a un job o a Cloud Scheduler.
- Usar la URL
*.run.appcomo URL pública del producto. Cambia si recreas el servicio, se salta Cloud Armor y la CDN, y no es tu dominio. Balanceador siempre. - Confundir autenticación con
ingress.--no-allow-unauthenticateddice quién puede invocar;ingressdice por dónde puede llegar. Hacen falta los dos. - Desplegar con
latest. Pierdes la trazabilidad de qué versión corre y la reversión deja de ser fiable. Etiqueta con$COMMIT_SHA. - Creer que el canario protege de todo. No protege de cambios de esquema de base de datos ni sirve de nada con tráfico bajo.
- Olvidar
--timeout 0en Gunicorn. Dos temporizadores compitiendo producen cortes que parecen aleatorios. - Consejo: mide la concurrencia con una prueba de carga real antes de fijarla. Es el parámetro con mayor impacto conjunto en coste y latencia.
- Consejo: usa
--tagen cada despliegue, aunque no hagas canario. Tener una URL por revisión es útil para depurar y no cuesta nada. - Consejo: activa
startup_cpu_boost. Es gratis en la práctica y reduce el arranque en frío de forma apreciable en runtimes pesados. - Consejo: la imagen pequeña es la mejor optimización de arranque en frío. Una imagen
distrolessoalpinebien hecha arranca mucho antes que unaubuntucon medio sistema dentro.
Ejercicios
Ejercicio 1 — Elegir concurrencia, CPU, memoria e instancias
AlpinaShop va a desplegar en Cloud Run un servicio nuevo: alpinashop-buscador, una API de búsqueda del catálogo.
Datos medidos en el entorno de desarrollo:
- Escrito en Python con FastAPI + Uvicorn (asíncrono, un solo proceso).
- Cada petición: 15 ms de CPU (tokenizar y puntuar) + 120 ms esperando a Elasticsearch.
- Consumo de memoria: 180 MiB en reposo + ~2 MiB por petición en vuelo.
- Tráfico: 5 peticiones/segundo de media, 40 peticiones/segundo en campaña.
- Elasticsearch admite 200 conexiones concurrentes en total y lo comparte con otros dos servicios.
- Nadie tolera más de 800 ms de latencia p95.
Determina --concurrency, --cpu, --memory, --min-instances y --max-instances, justificando cada valor con los datos. Indica además qué cambiarías en el arranque de la aplicación.
Ejercicio 2 — Diseñar el despliegue seguro en el pipeline
Escribe los pasos de Cloud Build (cloudbuild.yaml) que implementan el despliegue canario del catálogo con estas reglas:
- Construir la imagen etiquetada con
$COMMIT_SHA. - Desplegar sin tráfico, con la etiqueta
candidata. - Ejecutar una prueba de humo contra la URL de la etiqueta; si falla, abortar.
- Dar el 10 % del tráfico.
- Esperar 10 minutos y comprobar automáticamente que la tasa de error 5xx de la revisión nueva está por debajo del 1 %; si no, revertir a 0 % y fallar la compilación.
- Solo entonces, esperar la aprobación manual de Marta para pasar al 100 %.
Indica también qué permisos necesita la cuenta de servicio del pipeline y por qué el paso 5 es el que más gente omite.
Ejercicio 3 — Diagnosticar una migración que va mal
Tres horas después de mover el 70 % del tráfico a Cloud Run, Marta observa esto:
- La latencia p50 del backend de Cloud Run es de 210 ms; la del MIG, 195 ms. Aceptable.
- La latencia p99 de Cloud Run es de 4,2 segundos; la del MIG, 380 ms.
- En Cloud Logging aparecen unos 40 errores por hora:
sqlalchemy.exc.OperationalError: server closed the connection unexpectedly. - El número de instancias oscila entre 1 y 9 constantemente, con subidas y bajadas cada pocos minutos.
cloudsql.googleapis.com/database/postgresql/num_backendsha pasado de 12 a 34.- Error Reporting muestra 6 casos de
Memory limit of 512 MiB exceeded.
Diagnostica cada síntoma, indica la causa raíz probable de cada uno, y escribe el plan de corrección ordenado por prioridad. Decide además si hay que revertir al MIG o se puede corregir en caliente.
Soluciones
Solución 1 — Elegir concurrencia, CPU, memoria e instancias
Punto de partida: el perfil de la petición. 15 ms de CPU frente a 120 ms de espera. La CPU trabaja el 11 % del tiempo. Es un servicio dominado por la E/S, exactamente el caso donde la concurrencia alta es rentable.
--concurrency. Tres restricciones a la vez, y hay que respetar la más estricta:
- Por CPU: con 15 ms de CPU cada 135 ms totales, una vCPU podría sostener teóricamente unas 9 peticiones simultáneas antes de saturarse. Este es el límite duro.
- Por Elasticsearch: 200 conexiones compartidas entre tres servicios, digamos 60 para el buscador. Si cada petición abre una conexión,
concurrencia × instancias ≤ 60. - Por latencia: el presupuesto p95 es de 800 ms, y la petición base son 135 ms. Hay margen para algo de cola, pero no mucho.
Con 1 vCPU, poner concurrencia por encima de 9-10 hace que las peticiones esperen CPU. Se elige --concurrency=10. Y si se sube a --cpu=2, se podría ir a 20; con el tráfico de este servicio no compensa.
--cpu=1. Suficiente para la concurrencia elegida. Subir a 2 vCPU solo tiene sentido si se sube también la concurrencia, y el tráfico no lo justifica.
--memory=512Mi. Cálculo: 180 MiB de base + 10 peticiones × 2 MiB = 200 MiB, más el runtime de Python y margen. 512 MiB deja holgura de más del doble. Poner 256 MiB sería jugar con fuego, porque quedarse sin memoria mata la instancia, no ralentiza.
--max-instances. El pico son 40 req/s. Con 135 ms por petición y concurrencia 10, cada instancia sostiene unas 74 req/s en teoría; en la práctica, con margen, digamos 40-50. Bastarían 1-2 instancias. Pero el límite real lo pone Elasticsearch: 60 conexiones / 10 por instancia = 6 instancias. Se elige --max-instances=6, que cubre el pico con margen amplio y protege el backend compartido, que es el recurso escaso.
--min-instances=1. Un buscador es de cara al cliente y un arranque en frío de 1-2 s en la primera búsqueda de la mañana es visible y molesto. Una instancia mínima cuesta unos pocos euros al mes. En alpinashop-dev, 0.
Qué cambiar en el arranque. FastAPI con Uvicorn en un solo proceso es asíncrono, así que puede con varias peticiones simultáneas —pero solo si el código es realmente asíncrono. La comprobación crítica:
# MAL: cliente sincrono dentro de una funcion async. Bloquea el bucle de eventos
# y la concurrencia efectiva pasa a ser 1, por muy async que sea el endpoint.
@app.get("/buscar")
async def buscar(q: str):
r = requests.get(f"{ES}/catalogo/_search", json=consulta(q)) # BLOQUEA
return r.json()
# BIEN: cliente asincrono
@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()Este es exactamente el error del apartado 6 con otra cara: un async def que llama a una biblioteca síncrona no es concurrente, y la concurrencia 10 configurada en Cloud Run no sirve de nada.
Además: crear el cliente HTTP una vez al arrancar y reutilizarlo (no en cada petición, como en el ejemplo simplificado), y arrancar con uvicorn main:app --host 0.0.0.0 --port $PORT --workers 1 — un solo worker, porque el paralelismo lo da el bucle de eventos y Cloud Run añade instancias.
Solución 2 — Diseñar el despliegue seguro en el pipeline
# cloudbuild.yaml — despliegue canario de alpinashop-web
substitutions:
_REGION: europe-west1
_SERVICIO: alpinashop-web
_IMAGEN: europe-west1-docker.pkg.dev/alpinashop-prod/alpinashop/catalogo
steps:
# 1. Construir con el hash del commit
- 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. Desplegar SIN trafico, con etiqueta
- 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. Prueba de humo contra la URL de la etiqueta
- 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 "FALLO EN $$RUTA"; exit 1; }
done
# 4. Canario al 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. Esperar y verificar la tasa de error de la revision nueva
- id: verificar-canario
name: gcr.io/google.com/cloudsdktool/cloud-sdk
entrypoint: bash
args:
- -c
- |
set -e
REV=${_SERVICIO}-$SHORT_SHA
sleep 600 # 10 minutos de observacion
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 "Peticiones: $$TOTAL, errores 5xx: $$ERR"
if [ "$$TOTAL" -lt 100 ]; then
echo "AVISO: muestra insuficiente ($$TOTAL peticiones). No se puede concluir."
exit 1
fi
RATIO=$(echo "scale=4; $$ERR / $$TOTAL * 100" | bc)
echo "Tasa de error: $$RATIO %"
if [ $(echo "$$RATIO > 1" | bc) -eq 1 ]; then
echo "REVIRTIENDO: tasa de error por encima del 1%"
gcloud run services update-traffic ${_SERVICIO} \
--region=${_REGION} --to-tags=candidata=0
exit 1
fi
options:
logging: CLOUD_LOGGING_ONLYEl paso 6, la aprobación manual, no es un paso del cloudbuild.yaml: se configura en el disparador de Cloud Build con --require-approval, tal como se montó en 06-01 para producción. Cuando la compilación llega al final, queda a la espera de que Marta apruebe, y el paso al 100 % se hace en un disparador separado o manualmente con --to-latest.
Permisos de la cuenta de servicio del pipeline (sa-deploy-prod):
| Rol | Para qué |
|---|---|
roles/run.developer |
Desplegar servicios y cambiar el reparto de tráfico |
roles/iam.serviceAccountUser sobre sa-catalogo-web |
Poder asignar esa cuenta al servicio. Este es el que todo el mundo olvida y produce un PERMISSION_DENIED desconcertante |
roles/artifactregistry.writer |
Publicar la imagen |
roles/monitoring.viewer |
Leer las métricas del paso 5 |
roles/logging.logWriter |
Escribir los logs de la compilación |
Nótese que no tiene roles/editor ni permisos sobre Cloud SQL o Secret Manager: despliega, no administra.
Por qué el paso 5 es el que casi todo el mundo omite. Porque es el único que requiere pensar. Los pasos 1-4 son mecánicos y salen en cualquier tutorial; el 5 exige decidir qué métrica, qué umbral, cuánto tiempo y qué hacer si no hay datos suficientes. Sin él, el canario es teatro: se despliega al 10 %, nadie mira, y a los diez minutos alguien pulsa "aprobar" porque no ha saltado ninguna alarma — que es distinto de que todo esté bien.
La comprobación de muestra mínima (TOTAL -lt 100) es la sutileza que separa un canario real de uno decorativo: con pocas peticiones, la ausencia de errores no demuestra nada, y fallar la compilación obliga a mirarlo en lugar de aprobar a ciegas.
Solución 3 — Diagnosticar una migración que va mal
Diagnóstico síntoma a síntoma.
(1) p50 correcto pero p99 de 4,2 s. El p50 sano descarta un problema general de rendimiento: la mayoría de peticiones van bien. Un p99 veinte veces peor con un p50 normal es la firma inconfundible del arranque en frío. El servicio se desplegó con --min-instances=0, o el tráfico es lo bastante irregular como para crear instancias constantemente. La confirmación está en el síntoma (4).
(2) server closed the connection unexpectedly, ~40/hora. Conexiones del pool que llevaban tiempo inactivas y que Cloud SQL, o la propia congelación de CPU de la instancia, ha dado por muertas. Es el caso exacto de pool_pre_ping: sin él, la primera consulta tras un periodo de inactividad usa una conexión zombi y falla. La causa raíz es falta de pool_pre_ping=True y de pool_recycle en la configuración de SQLAlchemy, agravada por la congelación de CPU entre peticiones que Cloud Run aplica y el MIG no aplicaba.
(3) Instancias oscilando entre 1 y 9 cada pocos minutos. Es la causa de (1), y a su vez tiene su propia causa. Con concurrencia 80 y el tráfico de AlpinaShop, una sola instancia debería bastar de sobra. Que suba a 9 significa que las instancias mueren y se recrean: eso lo explica el síntoma (6). Es un ciclo vicioso —muere por memoria, arranca en frío, atiende, vuelve a morir— que además genera parte de (2), porque cada muerte se lleva por delante las conexiones del pool.
(4) Conexiones a Cloud SQL de 12 a 34. Coherente con 9 instancias × pool de 3-4. No es un problema por sí mismo —34 está lejos del límite— pero confirma la aritmética y avisa: si el máximo de instancias estuviera mal puesto, aquí es donde se vería el desastre. Es un síntoma para vigilar, no para corregir.
(5) Seis casos de Memory limit exceeded. Esta es la causa raíz principal. 512 MiB no bastan para la concurrencia configurada. Las hipótesis, por probabilidad: la concurrencia es 80 y cada petición del catálogo consume más memoria de lo estimado (renderiza plantillas, carga imágenes en memoria); o hay una caché en proceso que crece sin límite; o la aplicación escribe ficheros temporales, que en Cloud Run cuentan como memoria.
Cadena causal completa:
Memoria insuficiente (512 MiB con concurrencia 80)
|
v
La instancia muere al superar el limite
|
+--> Se recrean instancias -> oscilacion 1 a 9 (sintoma 3)
| |
| v
| arranques en frio -> p99 de 4,2 s (sintoma 1)
| |
| v
| mas instancias -> 34 conexiones (sintoma 4)
|
+--> Se pierden conexiones del pool
|
v
(agravado por falta de pool_pre_ping)
|
v
OperationalError, 40/hora (sintoma 2)¿Revertir o corregir en caliente?
No hay que revertir. El razonamiento: el 30 % del tráfico sigue en el MIG, el p50 es correcto, los errores son 40 a la hora sobre miles de peticiones (~0,5 %) y todas las causas son de configuración, no de código. Revertir costaría el resto del día y no aportaría información nueva. Ahora bien, sí hay que dejar de subir tráfico hasta que esté corregido, y tener el comando de reversión preparado por si algo empeora.
Si en lugar de esto los errores fueran del 10 %, o hubiera pérdida de pedidos, o el p50 estuviera degradado, la decisión sería la contraria: revertir primero, diagnosticar después. En un incidente, primero se para el daño.
Plan de corrección por prioridad:
| Prio | Acción | Comando / cambio | Efecto esperado |
|---|---|---|---|
| 1 | Subir memoria a 1 GiB | gcloud run services update alpinashop-web --region=europe-west1 --memory=1Gi |
Corta la causa raíz; desaparecen (3), (1) y buena parte de (2) |
| 2 | Bajar concurrencia a 40 mientras se investiga | --concurrency=40 |
Reduce la memoria simultánea a la mitad. Coste algo mayor, aceptable durante el diagnóstico |
| 3 | Añadir pool_pre_ping=True y pool_recycle=1800 |
Cambio de código, despliegue canario | Elimina el residuo de (2) |
| 4 | Poner --min-instances=1 |
--min-instances=1 |
Elimina el arranque en frío del primer usuario |
| 5 | Alerta de memoria | Política sobre run.googleapis.com/container/memory/utilizations > 80 % |
Que la próxima vez avise antes de morir |
| 6 | Perfilar la memoria de verdad | Cloud Profiler o análisis local con carga | Saber si 1 GiB es la solución o solo un parche sobre una fuga |
El paso 6 es el que no hay que saltarse. Subir la memoria es la acción correcta ahora, porque para el sangrado. Pero si hay una fuga de memoria real —una caché que crece sin límite, por ejemplo— con 1 GiB simplemente tardará el doble en morir, y el problema reaparecerá en campaña con el peor momento posible. La diferencia entre resolver un incidente y aplazarlo es exactamente ese último paso, y es el que se cancela cuando todo vuelve a estar verde.
Conclusión
DA-001 está cumplida. El catálogo de AlpinaShop corre en Cloud Run, detrás de la misma IP, el mismo dominio, el mismo certificado, la misma CDN y el mismo WAF; el MIG está apagado; y no queda una sola máquina virtual en la ruta de servicio de la tienda.
Sabes qué es Cloud Run —contenedores sin servidor— y por qué la unidad de despliegue lo cambia todo: el contrato son seis reglas (escuchar en $PORT y en 0.0.0.0, hablar HTTP, arrancar rápido, no guardar estado, no hacer nada entre peticiones, ser un contenedor Linux sin privilegios) y cumplirlas hace que la misma imagen corra en Cloud Run, en Kubernetes o en tu portátil. Distingues servicio, revisión e instancia, y sabes que las revisiones son inmutables — que es lo que hace posible todo lo demás.
Tienes el criterio entre servicios y trabajos: si responde a alguien, servicio; si hace algo y termina, trabajo. Con el antipatrón identificado: el informe nocturno como endpoint HTTP disparado por Scheduler, que hereda cuatro problemas que un job no tiene.
Has desplegado el catálogo con gcloud run deploy bandera a bandera, sabiendo qué hace cada una y por qué tiene ese valor, y lo has escrito en Terraform con google_cloud_run_v2_service, con cpu_idle, startup_cpu_boost e ingress restringido al balanceador, y con la frontera clara de qué gestiona Terraform (la forma) y qué gestiona el pipeline (la imagen y el tráfico).
Dominas las revisiones y el reparto de tráfico: la etiqueta que da una URL propia para probar sin arriesgar, la progresión 0 → 10 → 50 → 100 con lo que se observa en cada escalón, y la reversión instantánea que tarda segundos porque la revisión anterior nunca dejó de existir. Con las dos advertencias honestas: el canario no protege de cambios de esquema, y con tráfico bajo el 10 % no demuestra nada.
Entiendes la concurrencia mejor que la mayoría: qué es, por qué el modelo de una petición por instancia es doce veces más caro para una carga dominada por E/S, cómo se mide con una prueba de carga real, y —lo más importante— que el techo no lo pone Cloud Run sino tu servidor de aplicaciones. Un --concurrency=80 sobre un Gunicorn de un worker síncrono, o un async def que llama a una biblioteca bloqueante, convierten la concurrencia en una cola invisible.
Sabes gobernar el escalado: el escalado a cero y su precio en arranques en frío, --min-instances que los mitiga pero no los elimina, --max-instances como cortafuegos de coste y de conexiones a la base de datos con su fórmula, y la elección entre CPU durante la petición y CPU siempre activa, con la trampa de los hilos en segundo plano que no se ejecutan.
Has conectado el servicio con toda la plataforma: Cloud SQL por conector con un pool pequeño y pool_pre_ping, Secret Manager montado como variable o como fichero con la diferencia práctica en la rotación, VPC directa —que sustituye al viejo conector— con sus dos modos de egress, Pub/Sub push con OIDC sin un solo secreto compartido, y Eventarc, con la revelación de que Cloud Functions 2ª generación es Cloud Run por debajo.
Has asegurado el servicio con las cuatro decisiones: cuenta de servicio propia y mínima —evitando el fallo más común, heredar la cuenta por defecto con Editor—, --no-allow-unauthenticated, ingress restringido al balanceador para que nadie pueda saltarse el WAF por la URL run.app, e IAP para lo interno.
Has hecho la migración sin corte: preparación con lista de comprobación, despliegue sin tráfico, NEG sin servidor añadido al balanceador existente, trasvase progresivo con capacity-scaler vigilando cuatro métricas, tres días de convivencia con reversión de un comando, y retirada en dos pasos con resize 0 antes del borrado desde Terraform. Y conoces las cuatro particularidades del NEG sin servidor: sin comprobaciones de estado, sin capacidad, regional aunque el balanceador sea global, y con la CDN funcionando igual.
Tienes el coste desmenuzado: vCPU-segundo, GiB-segundo y peticiones, con nivel gratuito, y el cálculo comparado que baja el cómputo del catálogo de unos 50 € a unos 7 € al mes — con la honestidad de señalar que con tráfico constante alto el MIG con compromiso ganaría, que en campaña la factura sube porque se paga el uso, y que ahora el balanceador es el 74 % de lo que cuesta la tienda. Conoces los límites y los siete escenarios donde Cloud Run no encaja. Y te llevas la tabla madura con las cuatro preguntas que resuelven casi cualquier decisión de cómputo.
Por último, has visto lo que desaparece al apagar el MIG: diez elementos —plantilla, script de arranque, parcheo, agente, autoescalado, actualizaciones progresivas, claves SSH, discos, instantáneas, reserva de campaña— que ya no hay que mantener, entender ni explicar. Para un equipo de una persona, eso vale más que el ahorro.
Y sin embargo, dos apartados de esta lección han dejado un cabo suelto que apunta al mismo sitio. La conexión a Cloud SQL sería mejor por IP privada a través de la VPC. Salir hacia la pasarela de pago con IP fija exige pasar por el Cloud NAT. Y el ERP de Sabadell, que DA-004 dejó al otro lado de un túnel que todavía no existe, necesita que Cloud Run pueda alcanzarlo por la red privada.
Los tres son el mismo problema: la red de AlpinaShop se quedó en lo básico en el módulo 3 y ya no basta. Una sola VPC en un solo proyecto, tres proyectos que se ignoran entre sí, ninguna conectividad con la tienda física y ningún perímetro que impida que los datos de alpinashop-datos salgan por la puerta. La siguiente lección lo resuelve: VPC compartida, emparejamiento, Private Service Connect, Cloud VPN HA hacia Sabadell y controles de servicio de VPC.
Curso de Google Cloud Platform (GCP)
Módulo 1: Introducción a Google Cloud Platform
- ¿Qué es Google Cloud Platform?
- Configuración de tu cuenta de GCP
- Descripción general de la consola de GCP
- Proyectos, jerarquía de recursos y facturación
- Regiones, zonas y modelo de responsabilidad compartida
- Cloud Shell y la CLI de gcloud
Módulo 2: Servicios principales de GCP
- Compute Engine: máquinas virtuales en Google Cloud
- Cloud Storage: almacenamiento de objetos
- Cloud SQL: bases de datos relacionales gestionadas
- App Engine: plataforma como servicio
- Google Kubernetes Engine (GKE)
- Bases de datos NoSQL: Firestore, Bigtable y Spanner
- Cómo elegir el servicio de cómputo adecuado
Módulo 3: Redes y seguridad
- Redes VPC
- Balanceo de carga en la nube
- Cloud CDN
- Gestión de identidad y acceso (IAM)
- Cloud Armor
- Secretos y cifrado: Secret Manager y Cloud KMS
- Cloud DNS, certificados TLS y publicación segura de servicios
Módulo 4: Datos y análisis
- BigQuery: el almacén de datos analítico
- Cloud Dataflow: procesamiento de datos por lotes y en streaming
- Cloud Dataproc: Spark y Hadoop gestionados
- Cloud Pub/Sub: mensajería asíncrona
- Cloud Data Fusion: integración de datos sin código
- Orquestación de pipelines con Cloud Composer y Workflows
- Gobierno del dato y cuadros de mando con Dataplex y Looker Studio
Módulo 5: Aprendizaje automático e IA
- Vertex AI: la plataforma de machine learning de GCP
- AutoML: modelos a medida sin escribir código
- TensorFlow en GCP: entrenamiento y servicio de modelos
- API de lenguaje natural
- API de visión
- IA generativa en Vertex AI: modelos Gemini y embeddings
- MLOps: del modelo al producto con Vertex AI Pipelines
Módulo 6: DevOps y monitoreo
- Cloud Build: integración continua en GCP
- Cloud Source Repositories y gestión del código fuente
- Cloud Functions: funciones sin servidor
- Cloud Monitoring (antes Stackdriver): métricas, paneles y alertas
- Cloud Deployment Manager e infraestructura como código nativa
- Cloud Logging y Cloud Trace: logs, trazas y diagnóstico
- Terraform en GCP: infraestructura como código en la práctica
Módulo 7: Temas avanzados de GCP
- Híbrido y multinube con Anthos
- Computación sin servidor con Cloud Run
- Redes avanzadas: VPC compartida, peering y conectividad híbrida
- Mejores prácticas de seguridad
- Gestión y optimización de costos
- Fiabilidad: SLO, alta disponibilidad y recuperación ante desastres
- Gobierno a escala: organización, políticas y auditoría
