En 05-05 quedó un cabo suelto explícito. AlpinaShop diseñó una arquitectura por eventos para procesar automáticamente cada imagen de producto que se sube al bucket alpinashop-catalogo: un mensaje al topic imagenes-subidas, una llamada a la Vision API, las etiquetas escritas en alpinashop_analitica y una miniatura generada. El diseño estaba claro. La implementación se aplazó porque faltaba una pieza: algo que ejecute código cuando ocurre un evento, sin que haya un servidor esperando.
Esa pieza son las Cloud Functions, y es lo que vamos a construir aquí de principio a fin.
Pero antes conviene entender por qué el problema no es trivial. La solución "obvia" sería añadir un endpoint al catálogo Flask que reciba el aviso y procese la imagen. Es una mala idea por tres motivos: el procesamiento de una imagen tarda segundos y bloquearía a un proceso de gunicorn que debería estar sirviendo peticiones de clientes; el catálogo escalaría por la carga de imágenes en lugar de por el tráfico web; y un fallo del procesamiento afectaría a la tienda. La reacción a eventos quiere su propio ciclo de vida, y ese es exactamente el hueco que llena FaaS.
Contenido
- Qué es FaaS y cuál es la unidad de despliegue
- Cloud Functions hoy: la 2ª generación sobre Cloud Run y Eventarc
- Funciones HTTP: la primera función y su despliegue
- Autenticación: por qué casi nunca deben ser públicas
- Funciones por eventos: CloudEvents y Eventarc
- El caso pendiente:
procesar-imagen-productode principio a fin - Idempotencia, reintentos y dead letter
- El ciclo de vida real: arranques en frío y concurrencia
- Configuración, secretos e identidad
- Conexión a la VPC para llegar a Cloud SQL
- Límites y coste
- Pruebas locales y despliegue desde Cloud Build
- Cuándo una función y cuándo un servicio
- Qué es FaaS y cuál es la unidad de despliegue
Repasa mentalmente el continuo de abstracción de 02-07, ahora con una casilla más:
| Modelo | Unidad de despliegue | Qué gestionas | Qué pagas |
|---|---|---|---|
| IaaS (Compute Engine) | La máquina virtual | SO, parches, escalado, todo | La máquina encendida |
| CaaS (GKE) | El contenedor | El clúster y los manifiestos | Los nodos |
| PaaS (App Engine) | La aplicación | El código y su configuración | Instancias |
| FaaS (Cloud Functions) | La función | Solo el código de la función | Invocaciones y tiempo de cómputo |
Function as a Service lleva la abstracción a su extremo práctico: escribes una función —una unidad de código con una firma concreta—, la plataforma la empaqueta, la despliega, la ejecuta cuando algo la dispara y la apaga cuando no hace falta.
Las cuatro propiedades que definen el modelo:
- Se dispara por un evento. Una petición HTTP, un mensaje de Pub/Sub, un fichero nuevo en un bucket, un documento modificado en Firestore. La función no espera: la despiertan.
- Escala a cero. Sin eventos, no hay instancias y no se paga cómputo. Diferencia radical con una VM del MIG, que cuesta lo mismo a las cuatro de la madrugada que en plena campaña.
- Escala hacia arriba sola. Si llegan mil eventos a la vez, la plataforma arranca instancias. No hay que configurar un autoescalador.
- Es efímera y sin estado. Una instancia procesa y puede desaparecer. Nada que se guarde en memoria o en disco local sobrevive de forma fiable.
Esa última propiedad es la que más cuesta interiorizar y la que produce más errores de diseño. El estado vive fuera: en Cloud SQL, en Firestore, en Cloud Storage, en BigQuery. Una función que acumula algo en una variable global y espera encontrarlo en la invocación siguiente funciona en las pruebas —porque reutiliza la misma instancia— y falla en producción de forma intermitente e inexplicable.
- Cloud Functions hoy: la 2ª generación sobre Cloud Run y Eventarc
Este es el apartado que hay que entender bien para no equivocarse con la documentación antigua que sigue circulando.
Cloud Functions nació en 2016 con una plataforma propia. En 2022 apareció la 2ª generación, y el cambio no es cosmético:
Una Cloud Function de 2ª generación es un servicio de Cloud Run con un contenedor construido automáticamente por Google a partir de tu código, y sus disparadores de eventos son suscripciones de Eventarc.
Google construye el contenedor por ti usando buildpacks, lo despliega en Cloud Run y conecta Eventarc para que los eventos lleguen como peticiones HTTP. Todo lo que Cloud Run sabe hacer, la función lo hereda.
flowchart TD
A[Tu código:<br/>main.py + requirements.txt] --> B[Cloud Build:<br/>buildpacks]
B --> C[Imagen en<br/>Artifact Registry]
C --> D[Servicio de Cloud Run<br/>gestionado por Cloud Functions]
E[Pub/Sub, Cloud Storage,<br/>Firestore, Audit Logs] --> F[Eventarc]
F -->|CloudEvent por HTTP POST| D
G[Petición HTTP directa] --> D
Las diferencias prácticas, que son las que importan al diseñar:
| Característica | 1ª generación | 2ª generación |
|---|---|---|
| Plataforma | Propia | Cloud Run + Eventarc |
| Concurrencia por instancia | 1 petición | Hasta 1.000 configurables |
| Tiempo máximo (HTTP) | 9 minutos | 60 minutos |
| Tiempo máximo (eventos) | 9 minutos | 9 minutos (por Eventarc) |
| Memoria máxima | 8 GB | 32 GB |
| CPU | Ligada a la memoria | Configurable, hasta 8 vCPU |
| Reparto de tráfico entre versiones | No | Sí, como Cloud Run |
| Fuentes de eventos | Unas pocas nativas | Más de 130 vía Eventarc |
| Instancias mínimas | Limitado | Sí |
| Coste base | Menor en cargas mínimas | Ligeramente superior, más capacidad |
La concurrencia es la diferencia más importante. En la 1ª generación, cada instancia atendía una petición: 100 peticiones simultáneas significaban 100 instancias, con 100 arranques en frío y 100 conexiones a la base de datos. En la 2ª, una instancia con concurrencia 80 atiende 80 peticiones a la vez, lo que reduce drásticamente los arranques en frío, el coste y la presión sobre Cloud SQL —problema muy real: el número de conexiones de Cloud SQL es limitado, y una función de 1ª generación mal dimensionada agota el pool con una facilidad pasmosa—.
La regla en 2026: usa siempre la 2ª generación. --gen2 es obligatorio en los ejemplos de esta lección. La 1ª generación solo aparece en funciones antiguas que nadie ha migrado.
Y la pregunta natural: si una función de 2ª generación es un Cloud Run, ¿por qué no usar Cloud Run directamente? Es una pregunta excelente y tiene respuesta, pero la dejamos para el apartado 13, cuando tengas el contexto para valorarla.
- Funciones HTTP: la primera función y su despliegue
El Functions Framework es la biblioteca que convierte una función Python normal en un servicio HTTP. Se instala como una dependencia más y usa decoradores.
La estructura mínima de una función son dos ficheros:
# main.py
import functions_framework
from flask import jsonify
@functions_framework.http
def comprobar_stock(request):
"""Devuelve el stock de un SKU. Punto de entrada HTTP."""
# request es un objeto Request de Flask: mismo API que ya conoces
sku = request.args.get("sku")
if not sku:
return jsonify({"error": "falta el parametro sku"}), 400
unidades = consultar_stock(sku) # implementación omitida
if unidades is None:
return jsonify({"error": "sku desconocido"}), 404
return jsonify({"sku": sku, "unidades": unidades, "disponible": unidades > 0}), 200Tres detalles que conviene notar:
- El decorador
@functions_framework.httpmarca el punto de entrada. El nombre de la función Python (comprobar_stock) es el que se pasa en--entry-point. requestes un objeto de Flask. Si has escrito el catálogo con Flask, la API te resulta familiar:request.args,request.get_json(),request.headers.- El valor de retorno sigue las convenciones de Flask: una cadena, una tupla
(cuerpo, código)o una respuesta completa.
El despliegue:
gcloud functions deploy comprobar-stock \
--gen2 \
--region=europe-west1 \
--runtime=python312 \
--source=. \
--entry-point=comprobar_stock \
--trigger-http \
--no-allow-unauthenticated \
--service-account=sa-funcion-stock@alpinashop-prod.iam.gserviceaccount.com \
--memory=256Mi \
--timeout=30s \
--max-instances=20 \
--project=alpinashop-prodCada opción, y por qué está ahí:
| Opción | Qué hace | Por qué este valor |
|---|---|---|
--gen2 |
Usa la 2ª generación | Siempre, por el apartado 2 |
--region |
Dónde se despliega | europe-west1, junto al resto |
--runtime |
Versión del lenguaje | Fijada explícitamente, no implícita |
--source |
De dónde sale el código | . local; también gs:// o un repositorio |
--entry-point |
Nombre de la función Python | Debe coincidir exactamente |
--trigger-http |
Se dispara por HTTP | Frente a los disparadores de eventos |
--no-allow-unauthenticated |
Exige autenticación IAM | Apartado 4 |
--service-account |
Identidad de ejecución | Nunca la cuenta por defecto |
--memory |
Memoria por instancia | Determina también la CPU asignada |
--timeout |
Tiempo máximo por invocación | Corto: si tarda más, algo va mal |
--max-instances |
Techo de escalado | Protege lo que hay detrás |
--max-instances merece una explicación, porque su ausencia causa incidentes reales. Sin techo, un pico de tráfico —o un bucle infinito, o un ataque— puede arrancar cientos de instancias que abren cientos de conexiones a alpinashop-pedidos y tumban la base de datos. El escalado infinito no es una virtud si lo que hay detrás no escala igual. Poner un límite convierte una caída total en una degradación parcial, que es infinitamente preferible.
- Autenticación: por qué casi nunca deben ser públicas
Es tentador desplegar con --allow-unauthenticated porque "es más fácil de probar". Es exactamente cómo se filtran datos.
Una función pública tiene una URL adivinable en un patrón conocido, está expuesta a todo internet, la puede invocar cualquiera tantas veces como quiera —y tú pagas cada invocación— y suele tener permisos IAM sobre bases de datos y buckets de tu proyecto. Es un endpoint privilegiado sin puerta.
Con --no-allow-unauthenticated, invocar la función exige el rol roles/run.invoker —de Cloud Run, coherente con el apartado 2—:
# Solo el catálogo web puede llamar a esta función
gcloud functions add-invoker-policy-binding comprobar-stock \
--region=europe-west1 \
--member="serviceAccount:[email protected]" \
--project=alpinashop-prodY desde el catálogo, la llamada se autentica con un token OIDC:
import google.auth.transport.requests
import google.oauth2.id_token
URL_STOCK = "https://europe-west1-alpinashop-prod.cloudfunctions.net/comprobar-stock"
def consultar_stock_remoto(sku: str) -> dict:
# El "audience" debe ser exactamente la URL de la función
peticion = google.auth.transport.requests.Request()
token = google.oauth2.id_token.fetch_id_token(peticion, URL_STOCK)
respuesta = requests.get(
URL_STOCK,
params={"sku": sku},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
respuesta.raise_for_status()
return respuesta.json()El token lo obtiene la biblioteca de la identidad del entorno —la cuenta de servicio adjunta al pod de GKE— sin ninguna clave. Es el mismo mecanismo de las suscripciones push con OIDC de 04-04.
Los tres casos en los que una función sí puede ser pública, y qué hace falta en cada uno:
| Caso | Qué necesitas además |
|---|---|
| Webhook de un tercero (pasarela de pago) | Verificar la firma del proveedor en el cuerpo de la petición |
| Endpoint público de verdad (formulario web) | Cloud Armor delante (03-05), límite de peticiones y validación estricta |
| Desarrollo y pruebas | Que sea en alpinashop-dev y que no toque nada real |
El caso del webhook merece una nota: una función pública que recibe notificaciones de pago debe validar la firma HMAC que el proveedor incluye en la cabecera. Sin esa validación, cualquiera puede enviar un POST diciendo "el pedido 1234 está pagado". Es un fallo que aparece con una frecuencia deprimente.
- Funciones por eventos: CloudEvents y Eventarc
Una función por eventos no recibe una petición HTTP: recibe un CloudEvent, un formato estándar del sector —no una invención de Google— con metadatos comunes (id, source, type, time, subject) y un dato específico del tipo de evento.
En Python se declara con @functions_framework.cloud_event:
import functions_framework
@functions_framework.cloud_event
def procesar(evento):
print("id:", evento["id"]) # identificador único del evento
print("tipo:", evento["type"]) # google.cloud.pubsub.topic.v1.messagePublished
print("origen:", evento["source"]) # el recurso que lo generó
datos = evento.data # la carga útil, según el tipoEventarc es el enrutador universal de eventos de GCP. Recibe eventos de más de 130 fuentes, los normaliza a CloudEvents y los entrega al destino. Los disparadores más útiles:
| Fuente | Tipo de evento | Se dispara cuando | Uso típico en AlpinaShop |
|---|---|---|---|
| Pub/Sub | ...pubsub.topic.v1.messagePublished |
Se publica en un topic | imagenes-subidas (apartado 6) |
| Cloud Storage | ...storage.object.v1.finalized |
Se crea o sobrescribe un objeto | Alternativa directa al topic |
| Cloud Storage | ...storage.object.v1.deleted |
Se borra un objeto | Limpiar referencias huérfanas |
| Firestore | ...firestore.document.v1.written |
Se escribe un documento | Reaccionar a un carrito |
| Firestore | ...document.v1.created / .deleted |
Alta o baja | Auditoría funcional |
| Logs de auditoría | google.cloud.audit.log.v1.written |
Cualquier operación registrada | Alertar de cambios de firewall |
| Cloud Scheduler | Vía Pub/Sub | A la hora programada | Tareas periódicas ligeras |
| BigQuery | Vía logs de auditoría | Un trabajo termina | Encadenar procesos analíticos |
El disparador por logs de auditoría es el menos conocido y uno de los más potentes: permite reaccionar a cualquier operación de la API de GCP. Por ejemplo, ejecutar una función cada vez que alguien modifica una regla de firewall en alpinashop-prod:
gcloud functions deploy alertar-cambio-firewall \
--gen2 --region=europe-west1 --runtime=python312 \
--entry-point=alertar --source=. \
--trigger-event-filters="type=google.cloud.audit.log.v1.written" \
--trigger-event-filters="serviceName=compute.googleapis.com" \
--trigger-event-filters="methodName=v1.compute.firewalls.insert" \
--service-account=sa-alertas-seguridad@alpinashop-prod.iam.gserviceaccount.com \
--project=alpinashop-prodUna nota importante sobre Cloud Storage: se puede disparar directamente desde el bucket (storage.object.v1.finalized) o publicando en un topic de Pub/Sub y disparando desde ahí. AlpinaShop eligió en 05-05 la segunda opción, y la razón es sólida: con Pub/Sub en medio, varios consumidores pueden reaccionar al mismo evento. Hoy solo procesa imágenes; mañana, un segundo suscriptor puede actualizar el índice de búsqueda sin tocar nada de lo existente. Con el disparador directo, cada consumidor nuevo obliga a reconfigurar el bucket.
- El caso pendiente:
procesar-imagen-producto de principio a fin
procesar-imagen-producto de principio a finAquí se cierra la punta suelta de 05-05. Recordemos el flujo diseñado entonces:
flowchart LR
A[Dani sube foto a<br/>gs://alpinashop-catalogo] -->|notificación| B[Topic Pub/Sub<br/>imagenes-subidas]
B -->|CloudEvent vía Eventarc| C[Función<br/>procesar-imagen-producto]
C --> D[Vision API:<br/>etiquetas, colores, SafeSearch]
C --> E[Miniatura 400px<br/>a Cloud Storage]
D --> F[BigQuery<br/>imagenes_vision]
C -.->|error tras 5 intentos| G[imagenes-subidas-dlq]
Paso 1: notificar el bucket al topic. Un solo comando, que probablemente ya está hecho desde 05-05:
gcloud storage buckets notifications create gs://alpinashop-catalogo \
--topic=imagenes-subidas \
--event-types=OBJECT_FINALIZE \
--object-prefix=productos/ \
--project=alpinashop-prodEl --object-prefix=productos/ evita procesar objetos que no son fotos de producto —logotipos, ficheros temporales—, y es la forma más barata de filtrar: el evento ni siquiera se genera.
Paso 2: la función. Está comentada por bloques porque cada uno resuelve un problema concreto:
# main.py
import base64
import json
import os
from datetime import datetime, timezone
import functions_framework
from google.cloud import bigquery, storage, vision
from PIL import Image
import io
# Los clientes se crean UNA VEZ, fuera de la función.
# Se reutilizan mientras la instancia viva: ahorra cientos de ms por invocación.
cliente_vision = vision.ImageAnnotatorClient()
cliente_storage = storage.Client()
cliente_bq = bigquery.Client()
PROYECTO_DATOS = os.environ["PROYECTO_DATOS"] # alpinashop-datos
TABLA = f"{PROYECTO_DATOS}.alpinashop_analitica.imagenes_vision"
BUCKET_MINIATURAS = os.environ["BUCKET_MINIATURAS"] # alpinashop-catalogo
ANCHO_MINIATURA = 400
@functions_framework.cloud_event
def procesar_imagen(evento):
"""Procesa una imagen nueva: Vision API + miniatura + BigQuery."""
# 1. Decodificar el mensaje de Pub/Sub. El dato viene en base64.
mensaje = base64.b64decode(evento.data["message"]["data"]).decode("utf-8")
aviso = json.loads(mensaje)
bucket_nombre = aviso["bucket"]
ruta = aviso["name"]
generacion = aviso["generation"] # identifica la VERSIÓN exacta del objeto
# 2. Guarda: ignorar lo que no debe procesarse.
# Sin esto, la miniatura que escribimos abajo dispararía otro evento
# y tendríamos un bucle infinito que además cuesta dinero.
if ruta.startswith("productos/miniaturas/"):
print(f"Ignorada miniatura: {ruta}")
return
if not ruta.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
print(f"Ignorado formato no soportado: {ruta}")
return
# 3. Idempotencia: si esta generación ya se procesó, no repetir.
id_imagen = f"gs://{bucket_nombre}/{ruta}#{generacion}"
if ya_procesada(id_imagen):
print(f"Ya procesada, se omite: {id_imagen}")
return
# 4. Vision API sobre el objeto en Cloud Storage, sin descargarlo.
imagen = vision.Image(source=vision.ImageSource(
gcs_image_uri=f"gs://{bucket_nombre}/{ruta}"))
respuesta = cliente_vision.annotate_image({
"image": imagen,
"features": [
{"type_": vision.Feature.Type.LABEL_DETECTION, "max_results": 10},
{"type_": vision.Feature.Type.IMAGE_PROPERTIES},
{"type_": vision.Feature.Type.SAFE_SEARCH_DETECTION},
],
})
if respuesta.error.message:
# Error de la API: lanzar excepción para que Pub/Sub reintente
raise RuntimeError(f"Vision API: {respuesta.error.message}")
# 5. Generar la miniatura y subirla al prefijo que el paso 2 ignora
blob = cliente_storage.bucket(bucket_nombre).blob(ruta)
original = Image.open(io.BytesIO(blob.download_as_bytes()))
original.thumbnail((ANCHO_MINIATURA, ANCHO_MINIATURA))
salida = io.BytesIO()
original.convert("RGB").save(salida, format="JPEG", quality=82)
ruta_mini = f"productos/miniaturas/{os.path.basename(ruta)}"
cliente_storage.bucket(BUCKET_MINIATURAS).blob(ruta_mini).upload_from_string(
salida.getvalue(), content_type="image/jpeg")
# 6. Escribir en BigQuery, con el identificador que garantiza idempotencia
fila = {
"id_imagen": id_imagen,
"ruta_gcs": f"gs://{bucket_nombre}/{ruta}",
"ruta_miniatura": f"gs://{BUCKET_MINIATURAS}/{ruta_mini}",
"etiquetas": [
{"descripcion": e.description, "puntuacion": round(e.score, 4)}
for e in respuesta.label_annotations
],
"color_dominante": color_dominante(respuesta),
"safesearch_adulto": respuesta.safe_search_annotation.adult.name,
"procesada_en": datetime.now(timezone.utc).isoformat(),
}
errores = cliente_bq.insert_rows_json(TABLA, [fila], row_ids=[id_imagen])
if errores:
raise RuntimeError(f"BigQuery: {errores}")
print(f"Procesada correctamente: {id_imagen}")Los tres detalles que hacen que esto funcione en producción, y que separan un ejemplo de tutorial de código real:
Los clientes fuera de la función. Crear un ImageAnnotatorClient implica resolver credenciales y establecer conexiones: cientos de milisegundos. Creándolo a nivel de módulo, se crea una vez por instancia y se reutiliza en todas las invocaciones que esa instancia atienda. En una función con concurrencia 20 y mil eventos, la diferencia es enorme.
La guarda contra el bucle infinito (paso 2). Es el error clásico de las funciones disparadas por Cloud Storage y merece que se explique despacio: la función escribe la miniatura en el mismo bucket, lo que genera un evento OBJECT_FINALIZE, que dispara la función, que genera otra miniatura, que dispara la función… Cada iteración cuesta invocaciones, llamadas a la Vision API —que se facturan— y filas en BigQuery. Un bucle así descubierto un lunes por la mañana puede haber consumido un presupuesto entero durante el fin de semana. Las tres defensas posibles son el prefijo en el filtro de notificación, la guarda en el código y escribir en otro bucket; usa al menos dos.
El row_ids en insert_rows_json. BigQuery deduplica por ese identificador durante una ventana de tiempo. Es una segunda red bajo la comprobación de idempotencia del paso 3.
Paso 3: desplegar con su identidad y sus permisos.
# Cuenta de servicio propia con permisos mínimos
gcloud iam service-accounts create sa-procesar-imagen \
--display-name="Funcion procesar-imagen-producto" --project=alpinashop-prod
[email protected]
gcloud projects add-iam-policy-binding alpinashop-prod \
--member="serviceAccount:${SA}" --role=roles/storage.objectAdmin
gcloud projects add-iam-policy-binding alpinashop-datos \
--member="serviceAccount:${SA}" --role=roles/bigquery.dataEditor
gcloud projects add-iam-policy-binding alpinashop-prod \
--member="serviceAccount:${SA}" --role=roles/eventarc.eventReceiver
gcloud functions deploy procesar-imagen-producto \
--gen2 --region=europe-west1 --runtime=python312 \
--source=. --entry-point=procesar_imagen \
--trigger-topic=imagenes-subidas \
--service-account="${SA}" \
--set-env-vars=PROYECTO_DATOS=alpinashop-datos,BUCKET_MINIATURAS=alpinashop-catalogo \
--memory=1Gi \
--timeout=120s \
--max-instances=50 \
--retry \
--project=alpinashop-prod--memory=1Gi no es capricho: abrir una foto de producto de 12 megapíxeles con Pillow consume bastante memoria, y en la 2ª generación la CPU asignada crece con la memoria, así que además va más rápido. --retry activa los reintentos, que es el tema del apartado siguiente.
- Idempotencia, reintentos y dead letter
Esta es la parte que distingue una función que funciona de una función en la que se puede confiar. Y arranca de un hecho que ya se estableció en 04-04:
Pub/Sub garantiza la entrega al menos una vez. Tu función recibirá mensajes duplicados. No es una posibilidad remota: es una certeza estadística.
Los duplicados llegan por causas perfectamente normales: la función procesó correctamente pero tardó más que el plazo de confirmación; hubo un fallo de red al confirmar; la instancia se reinició tras procesar y antes de confirmar; o el evento se reenvió porque una invocación anterior lanzó una excepción.
Idempotente significa que procesar el mismo evento N veces produce el mismo resultado que procesarlo una vez. Analicemos las tres acciones de nuestra función:
| Acción | ¿Idempotente por naturaleza? | Qué pasaría con un duplicado |
|---|---|---|
| Llamar a la Vision API | Sí en resultado, no en coste | Se paga dos veces por lo mismo |
| Escribir la miniatura | Sí: misma ruta, se sobrescribe | Sin daño, solo cómputo desperdiciado |
| Insertar en BigQuery | No | Fila duplicada: las estadísticas mienten |
La tercera es la peligrosa, y por eso el diseño incluye tres capas de defensa:
Capa 1, el identificador estable. gs://bucket/ruta#generacion identifica una versión concreta de un objeto. Fíjate en que la ruta sola no bastaría: si Dani sube una foto corregida con el mismo nombre, es un objeto distinto que sí debe reprocesarse, y la generation lo distingue.
Capa 2, la comprobación previa con una tabla ligera de control:
def ya_procesada(id_imagen: str) -> bool:
"""Comprueba en una tabla de control si este id ya se procesó."""
consulta = f"""
SELECT 1 FROM `{PROYECTO_DATOS}.alpinashop_analitica.imagenes_procesadas`
WHERE id_imagen = @id
AND procesada_en > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
LIMIT 1
"""
trabajo = cliente_bq.query(consulta, job_config=bigquery.QueryJobConfig(
query_parameters=[bigquery.ScalarQueryParameter("id", "STRING", id_imagen)]))
return next(trabajo.result(), None) is not NoneLa ventana de 7 días es un compromiso deliberado: los duplicados de Pub/Sub llegan en segundos o minutos, no días, y limitar la consulta permite aprovechar el particionado de la tabla y no escanear el histórico completo en cada invocación. Es la misma lógica de coste de 04-01.
Capa 3, el row_ids de BigQuery, que deduplica automáticamente aunque las dos anteriores fallen por una condición de carrera.
Reintentos. Con --retry, si la función lanza una excepción, Pub/Sub no recibe confirmación y reentrega el mensaje. Y aquí hay una decisión de diseño que se hace mal constantemente:
| Tipo de error | Ejemplo | ¿Reintentar? | Qué hacer |
|---|---|---|---|
| Transitorio | Vision API con 503, timeout de red | Sí | Lanzar excepción |
| Permanente | Imagen corrupta, formato no soportado | No | Registrar y retornar normalmente |
| De configuración | Falta un permiso IAM | No ayuda | Registrar como error grave y alertar |
Un error permanente reintentado es una función que falla eternamente sobre el mismo mensaje, consumiendo cuota y llenando los logs. La regla: lanza excepción solo si volver a intentarlo tiene alguna posibilidad de funcionar.
try:
original = Image.open(io.BytesIO(blob.download_as_bytes()))
except UnidentifiedImageError:
# Error PERMANENTE: reintentar mil veces no arreglará un fichero corrupto
print(f"ERROR PERMANENTE: imagen ilegible {ruta}")
registrar_fallo_permanente(id_imagen, "imagen_ilegible")
return # retorno normal → Pub/Sub confirma y no reintentaDead letter. Aun con la distinción anterior, algún mensaje fallará indefinidamente. El topic de mensajes fallidos evita que bloquee la cola:
gcloud pubsub topics create imagenes-subidas-dlq --project=alpinashop-prod
gcloud pubsub subscriptions update eventarc-europe-west1-procesar-imagen-sub \
--dead-letter-topic=projects/alpinashop-prod/topics/imagenes-subidas-dlq \
--max-delivery-attempts=5 \
--project=alpinashop-prodTras cinco intentos, el mensaje va al DLQ en lugar de reintentarse para siempre. Y un DLQ que nadie mira es peor que no tener DLQ, porque genera una falsa sensación de control: hay que poner una alerta sobre su número de mensajes sin confirmar —exactamente el tipo de alerta que se configura en 06-04—.
- El ciclo de vida real: arranques en frío y concurrencia
Una instancia de función pasa por tres fases, y entender cuál es cara explica casi todo el comportamiento observado:
flowchart LR
A[Sin instancias<br/>coste 0] -->|llega un evento| B[ARRANQUE EN FRÍO<br/>crear instancia +<br/>cargar código +<br/>ejecutar el módulo]
B --> C[Invocación<br/>ARRANQUE CALIENTE]
C -->|más eventos| C
C -->|sin eventos<br/>unos minutos| A
El arranque en frío es el tiempo desde que llega el evento hasta que tu código empieza a ejecutarse: aprovisionar la instancia, cargar el runtime, importar las dependencias y ejecutar el código a nivel de módulo. Va de unos cientos de milisegundos a varios segundos, y depende sobre todo de cuánto pesan tus importaciones.
Las cinco palancas para mitigarlo, ordenadas por eficacia real:
| Palanca | Cómo | Efecto | Coste |
|---|---|---|---|
--min-instances |
Mantener N instancias siempre vivas | Elimina el frío para el tráfico base | Se paga la instancia inactiva |
| Menos dependencias | Importar solo lo necesario | Reduce mucho el arranque | Ninguno |
| Importación diferida | import dentro de la función |
Solo si no se usa siempre | Complica el código |
| Concurrencia alta | --concurrency=80 |
Menos instancias, menos arranques | Requiere código seguro en hilos |
| Más CPU | --cpu=2 |
Arranque más rápido | Más caro por segundo |
# Función crítica en la ruta del cliente: sin arranques en frío
gcloud functions deploy comprobar-stock --gen2 --region=europe-west1 \
--min-instances=2 --max-instances=50 --concurrency=80 \
--project=alpinashop-prodCuándo importa el arranque en frío y cuándo no, que es la decisión que de verdad hay que tomar:
| Situación | ¿Importa? | Recomendación |
|---|---|---|
| Función en la ruta de una petición de cliente | Mucho | --min-instances ≥ 1 |
| Webhook de la pasarela de pago | Sí: hay timeouts | --min-instances=1 |
procesar-imagen-producto |
No | --min-instances=0: dos segundos más no molestan a nadie |
| Tarea nocturna programada | No | 0 |
--min-instances cuesta dinero incluso sin tráfico, y ese es el precio de renunciar parcialmente a "escala a cero". Ponerlo por defecto en todas las funciones anula una de las ventajas económicas del modelo. Ponlo donde la latencia la percibe un cliente.
Sobre la concurrencia, una advertencia importante: con --concurrency=80, ochenta peticiones se ejecutan simultáneamente en el mismo proceso Python. Cualquier variable global mutable pasa a ser compartida:
# PELIGROSO con concurrencia > 1
resultados = [] # ¡compartido entre invocaciones simultáneas!
@functions_framework.http
def procesar(request):
resultados.append(request.args["id"]) # condición de carrera
return str(len(resultados)) # devuelve cualquier cosa
# CORRECTO: los objetos globales solo para clientes reutilizables e inmutables
cliente_bq = bigquery.Client() # seguro: está diseñado para uso concurrente
@functions_framework.http
def procesar(request):
resultados_locales = [] # estado dentro de la invocación
...La regla: a nivel de módulo, solo clientes y constantes. Todo estado mutable, dentro de la función.
- Configuración, secretos e identidad
Las tres cosas que toda función de producción necesita bien resueltas.
Variables de entorno para configuración no sensible:
gcloud functions deploy procesar-imagen-producto --gen2 \
--set-env-vars=PROYECTO_DATOS=alpinashop-datos,ENTORNO=prod,ANCHO_MINIATURA=400 \
--region=europe-west1 --project=alpinashop-prodSecretos desde Secret Manager, nunca como variable de entorno con el valor:
gcloud functions deploy procesar-pago --gen2 \
--set-secrets='API_KEY_PASARELA=api-key-pasarela-pago:latest' \
--region=europe-west1 --project=alpinashop-prodCon esa sintaxis, la función lee os.environ["API_KEY_PASARELA"] con normalidad, pero el valor no está en la configuración del despliegue: se inyecta en tiempo de ejecución desde Secret Manager (03-06). Consecuencias prácticas: el valor no aparece en la consola, no queda en el historial de despliegues, se puede rotar sin redesplegar si usas :latest, y el acceso queda registrado en los logs de auditoría. También se pueden montar como fichero con --set-secrets='/etc/claves/api=secreto:latest', que es preferible para valores grandes como certificados.
Y el detalle que se olvida: la cuenta de servicio de la función necesita roles/secretmanager.secretAccessor sobre ese secreto concreto, concedido en la política del secreto.
La identidad. Por defecto, una función usa la cuenta de servicio de Compute Engine del proyecto, que suele tener el rol Editor —el mismo antipatrón de 06-01—. Siempre --service-account con una cuenta propia por función. Una función que solo escribe en BigQuery no debe poder borrar buckets.
- Conexión a la VPC para llegar a Cloud SQL
Por defecto, una función vive fuera de tu VPC: puede salir a internet pero no puede alcanzar recursos con IP privada. Si alpinashop-pedidos tiene solo IP privada —como debe ser, según 03-01— una función no llega.
El puente es un conector de acceso a VPC sin servidor:
gcloud compute networks vpc-access connectors create conector-alpinashop \
--region=europe-west1 \
--network=alpinashop-vpc \
--range=10.8.0.0/28 \
--min-instances=2 --max-instances=4 \
--machine-type=e2-micro \
--project=alpinashop-prod
gcloud functions deploy sincronizar-stock --gen2 \
--vpc-connector=projects/alpinashop-prod/locations/europe-west1/connectors/conector-alpinashop \
--egress-settings=private-ranges-only \
--region=europe-west1 --project=alpinashop-prodDetalles que importan:
- El rango
/28debe estar libre y no solaparse consn-web-euw1nisn-datos-euw1. Es un requisito estricto y una fuente habitual de errores. - El conector se factura por instancia y hora, esté o no en uso. Con
--min-instances=2pagas dos máquinas pequeñas permanentemente. Rompe parcialmente el "escala a cero", así que se comparte entre todas las funciones que lo necesiten. --egress-settings=private-ranges-onlyenvía por la VPC solo el tráfico a rangos privados; el resto sale por internet directamente. Conall-traffic, todo pasa por la VPC y sale por el Cloud NATalpinashop-nat-euw1de 03-01, lo que da una IP de salida fija —útil si un tercero exige lista blanca de IP—.
| Necesidad | Solución | Coste añadido |
|---|---|---|
| Cloud SQL con IP pública | Conector de Cloud SQL sobre TLS | Ninguno |
| Cloud SQL con IP privada | Conector de VPC | Instancias del conector |
| IP de salida fija | Conector + all-traffic + Cloud NAT |
Conector + NAT |
| Solo APIs de Google | Nada: ya funciona | Ninguno |
Consejo: no añadas un conector "por si acaso". Si la función solo habla con APIs de Google —Vision, BigQuery, Storage, como procesar-imagen-producto— no lo necesita, y añadirlo es coste y complejidad gratuitos.
- Límites y coste
Los límites de la 2ª generación que conviene tener presentes —verifica siempre los valores vigentes en la documentación oficial—:
| Límite | Valor aproximado | Qué hacer si te queda corto |
|---|---|---|
| Tiempo máximo (HTTP) | 60 minutos | Trabajo largo → Cloud Run Jobs o Dataflow |
| Tiempo máximo (eventos) | 9 minutos | Trocear el trabajo, encadenar por Pub/Sub |
| Memoria | Hasta 32 GB | Procesamiento pesado → Cloud Run o Batch |
| CPU | Hasta 8 vCPU | Igual |
| Tamaño del código desplegado | Decenas de MB comprimido | Modelos y datos en Cloud Storage |
| Tamaño de la petición HTTP | 32 MB | Subida directa a Cloud Storage + evento |
| Instancias simultáneas | Miles (cuota ampliable) | Solicitar aumento de cuota |
| Variables de entorno | Unos pocos KB en total | Configuración en Firestore o Secret Manager |
El coste tiene cuatro componentes, y hay un nivel gratuito mensual generoso:
| Componente | Se paga por | Comentario |
|---|---|---|
| Invocaciones | Cada llamada | Céntimos por millón |
| GB-segundo | Memoria × tiempo | La palanca principal |
| GHz-segundo | CPU × tiempo | Ligado a la memoria |
| Salida de red | GB hacia internet | Nulo entre servicios de la misma región |
Cálculo orientativo para procesar-imagen-producto. Supongamos 20.000 imágenes al mes, 1 GiB de memoria, 3 segundos por imagen:
- Invocaciones: 20.000 → cubiertas de sobra por el nivel gratuito.
- GB-segundo: 20.000 × 3 s × 1 GiB = 60.000 GB-s, mayormente dentro del nivel gratuito.
- GHz-segundo: proporcional, mismo orden.
- Coste real de cómputo: prácticamente cero.
Y ahora el dato que importa de verdad: la Vision API de esas 20.000 imágenes, con tres funcionalidades cada una, cuesta un orden de magnitud más que toda la ejecución de la función. La lección económica es que en las arquitecturas por eventos el cómputo casi nunca es el coste principal: lo son las APIs que se llaman y los datos que se mueven. Optimizar la memoria de la función mientras se hacen llamadas redundantes a una API de pago es optimizar lo que no importa.
Dicho esto, tres formas de que la factura se dispare de verdad:
- Bucles infinitos (apartado 6). El más caro con diferencia.
--min-instancesen funciones que no lo necesitan. Es coste fijo 24×7 y anula la ventaja del modelo.- Timeout largo con errores colgados. Una función con
--timeout=540sque se queda esperando a un servicio caído paga nueve minutos por invocación fallida. Timeouts cortos y ajustados a la realidad.
- Pruebas locales y despliegue desde Cloud Build
En local, el Functions Framework levanta un servidor:
pip install functions-framework
functions-framework --target=procesar_imagen --signature-type=cloudevent --port=8080Y se simula un evento de Pub/Sub con la estructura real, base64 incluido:
DATOS=$(printf '{"bucket":"alpinashop-catalogo","name":"productos/piolet-01.jpg","generation":"1712345678"}' | base64 -w0)
curl -X POST http://localhost:8080 \
-H "Content-Type: application/json" \
-H "ce-id: 1234" \
-H "ce-source: //pubsub.googleapis.com/projects/alpinashop-prod/topics/imagenes-subidas" \
-H "ce-type: google.cloud.pubsub.topic.v1.messagePublished" \
-H "ce-specversion: 1.0" \
-d "{\"message\":{\"data\":\"${DATOS}\"}}"Mejor aún, pruebas unitarias que no necesitan levantar nada:
# test_main.py
import base64, json
from unittest.mock import patch, MagicMock
from cloudevents.http import CloudEvent
import main
def crear_evento(bucket, nombre, generacion="1"):
datos = base64.b64encode(json.dumps(
{"bucket": bucket, "name": nombre, "generation": generacion}).encode())
return CloudEvent(
{"type": "google.cloud.pubsub.topic.v1.messagePublished",
"source": "//pubsub.googleapis.com/", "id": "1"},
{"message": {"data": datos.decode()}})
def test_ignora_miniaturas():
"""La guarda del bucle infinito es la prueba MÁS importante de todas."""
with patch.object(main, "cliente_vision") as vision_mock:
main.procesar_imagen(crear_evento("alpinashop-catalogo",
"productos/miniaturas/x.jpg"))
vision_mock.annotate_image.assert_not_called()
def test_omite_si_ya_procesada():
with patch.object(main, "ya_procesada", return_value=True), \
patch.object(main, "cliente_vision") as vision_mock:
main.procesar_imagen(crear_evento("alpinashop-catalogo", "productos/a.jpg"))
vision_mock.annotate_image.assert_not_called()La primera prueba merece un comentario: verifica que no se llama a la Vision API para una miniatura. Es la prueba que protege contra el fallo más caro posible de esta arquitectura, y cuesta seis líneas.
Despliegue desde Cloud Build, encajando con 06-01 y 06-02:
# cloudbuild.yaml del repositorio de funciones
steps:
- name: 'python:3.12-slim'
id: 'pruebas'
entrypoint: 'bash'
args:
- '-c'
- |
pip install -r requirements.txt -r requirements-dev.txt -t /workspace/lib
PYTHONPATH=/workspace/lib python -m pytest -q
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim'
id: 'desplegar'
waitFor: ['pruebas']
args:
- 'gcloud'
- 'functions'
- 'deploy'
- 'procesar-imagen-producto'
- '--gen2'
- '--region=europe-west1'
- '--runtime=python312'
- '--source=.'
- '--entry-point=procesar_imagen'
- '--trigger-topic=imagenes-subidas'
- '--service-account=sa-procesar-imagen@alpinashop-prod.iam.gserviceaccount.com'
- '--set-env-vars=PROYECTO_DATOS=alpinashop-datos,BUCKET_MINIATURAS=alpinashop-catalogo'
- '--memory=1Gi'
- '--max-instances=50'
- '--retry'
- '--project=alpinashop-prod'
options:
logging: CLOUD_LOGGING_ONLYLa cuenta de servicio de Cloud Build necesita roles/cloudfunctions.developer y roles/iam.serviceAccountUser sobre sa-procesar-imagen —este segundo permiso se olvida siempre y produce un error de permisos que no menciona la palabra "función"—.
- Cuándo una función y cuándo un servicio
Volvemos a la pregunta que quedó abierta en el apartado 2: si una función de 2ª generación es un Cloud Run, ¿por qué elegir una función?
| Criterio | Cloud Functions (2ª gen) | Cloud Run |
|---|---|---|
| Qué despliegas | Código fuente | Un contenedor |
| Quién construye la imagen | Google, con buildpacks | Tú, con tu Dockerfile |
| Control del entorno | El runtime que ofrece Google | Total |
| Rutas HTTP | Una función, un punto de entrada | Cualquier aplicación web completa |
| Disparadores de eventos | Integrados en el despliegue | Eventarc configurado aparte |
| Curva de entrada | Muy baja | Media |
| Migración a otro sitio | Depende de la plataforma | Un contenedor corre donde sea |
| Dependencias del sistema | Las del runtime | Las que instales |
Elige Cloud Functions cuando:
- El trabajo es una sola cosa disparada por un evento:
procesar-imagen-productoes el ejemplo perfecto. - No necesitas dependencias del sistema fuera de lo habitual.
- Quieres el camino más corto entre "tengo una idea" y "está funcionando".
- El equipo no quiere mantener Dockerfiles para cada pieza pequeña.
Elige Cloud Run cuando:
- Es una aplicación con varias rutas, como el catálogo Flask.
- Necesitas controlar la imagen: una versión concreta de una biblioteca del sistema, un binario, un modelo empaquetado.
- Ya tienes un contenedor —AlpinaShop lo tiene desde 02-05—.
- Quieres portabilidad real entre GKE, Cloud Run y cualquier otro sitio.
Para AlpinaShop, la asignación queda así, coherente con la decisión DA-001 de llevar el catálogo a Cloud Run (que se desarrolla en 07-02):
| Pieza | Elección | Motivo |
|---|---|---|
| Catálogo web Flask | Cloud Run (07-02) | Aplicación completa, contenedor propio, DA-001 |
procesar-imagen-producto |
Cloud Function | Un evento, una acción |
| Webhook de la pasarela de pago | Cloud Function | Endpoint único con verificación de firma |
| Informes nocturnos | Workflows + Cloud Run Job (04-06) | Trabajo largo, no encaja en 9 minutos |
La advertencia: el monolito distribuido de funciones
Hay un antipatrón que aparece cuando las funciones gustan demasiado, y conviene reconocerlo antes de caer en él:
flowchart LR
A[funcion-validar] --> B[funcion-calcular-precio]
B --> C[funcion-comprobar-stock]
C --> D[funcion-reservar]
D --> E[funcion-cobrar]
E --> F[funcion-notificar]
Seis funciones encadenadas para procesar un pedido. Parece modular. En realidad es un monolito troceado con latencia de red entre sus líneas de código, y hereda todos los inconvenientes de ambos mundos:
- Latencia acumulada: seis arranques en frío posibles en lugar de uno.
- Depuración muy difícil: seguir una petición exige correlacionar seis conjuntos de logs. Es exactamente el problema que resuelve el rastreo distribuido de 06-06, pero que es mejor no tener.
- Sin transacciones: si
funcion-cobrarfunciona yfuncion-notificarfalla, el sistema queda en un estado incoherente y hay que compensar a mano. - Cambios que cruzan seis despliegues: modificar el flujo obliga a coordinar seis funciones.
- Coste multiplicado: seis invocaciones y seis veces la sobrecarga.
La señal de alarma es sencilla: si tus funciones se llaman entre sí de forma síncrona, probablemente deberían ser un solo servicio. Y si de verdad necesitas un flujo de varios pasos con estado, la herramienta correcta no es encadenar funciones: es Workflows, la orquestación que AlpinaShop ya eligió en 04-06, que gestiona el estado, los reintentos y los errores de forma explícita.
Las funciones brillan cuando son hojas del árbol: reaccionan a un evento, hacen su trabajo y terminan. Cuando empiezan a ser nodos intermedios que coordinan a otras, se han elegido mal.
Errores Comunes y Consejos
El bucle infinito de Cloud Storage. Una función disparada por un bucket que escribe en ese mismo bucket. Es el error más caro de esta lección. Defiéndete con al menos dos de las tres barreras: prefijo en el filtro de notificación, guarda en el código y bucket distinto para las salidas.
Suponer que los eventos llegan exactamente una vez. Llegan al menos una vez. Toda función por eventos necesita un identificador estable y una comprobación de idempotencia. Sin eso, tus datos tendrán duplicados y no sabrás por qué.
Reintentar errores permanentes. Una imagen corrupta no se arregla al quinto intento. Lanza excepción solo si reintentar puede funcionar; para lo demás, registra y retorna con normalidad.
Crear los clientes dentro de la función. Cientos de milisegundos desperdiciados en cada invocación. Los clientes, a nivel de módulo. Pero solo clientes y constantes: cualquier estado mutable global es una condición de carrera esperando a que subas la concurrencia.
Desplegar con --allow-unauthenticated "para probar". Ese "para probar" se queda. Usa --no-allow-unauthenticated y concede run.invoker a quien deba llamarla.
Dejar la cuenta de servicio por defecto. Es la de Compute Engine, con Editor. Una cuenta propia por función, con los permisos exactos.
No poner --max-instances. Un pico de eventos puede abrir cientos de conexiones a Cloud SQL y tumbar la base de datos. El escalado sin límite no es una virtud si lo de detrás no escala igual.
Meter secretos en variables de entorno. Usa --set-secrets con Secret Manager: no queda en la configuración, no aparece en la consola y se rota sin redesplegar.
Añadir un conector de VPC "por si acaso". Cuesta dinero permanentemente. Solo si necesitas alcanzar IP privadas.
Usar la 1ª generación por copiar un tutorial antiguo. --gen2 siempre: concurrencia, más memoria, más tiempo y muchas más fuentes de eventos.
Y el consejo que resume el apartado 13: si tus funciones se llaman unas a otras, para y replantea. Probablemente sea un servicio, o un Workflow.
Ejercicios
Ejercicio 1: diseñar una función idempotente para pedidos
AlpinaShop quiere una función disparada por el topic pedidos-nuevos que envíe un correo de confirmación al cliente y añada una fila a la tabla de facturación en BigQuery. Enviar un correo no es idempotente: el cliente se molesta si recibe tres. Diseña la función explicando el mecanismo de idempotencia, qué errores reintentarías y cuáles no, y qué configuración de despliegue usarías. Indica el punto exacto donde una condición de carrera podría producir un correo duplicado y cómo lo mitigarías.
Ejercicio 2: decidir entre función, servicio y flujo de trabajo
Para cada uno de estos cuatro casos de AlpinaShop, elige Cloud Function, Cloud Run o Workflows, y justifica la decisión con criterios de esta lección: (a) generar el PDF de la factura de un pedido, unos 2 segundos por factura, disparado tras el pago; (b) el panel de administración interno, unas 30 rutas HTTP, usado por 5 personas en horario de oficina; (c) el proceso nocturno que recalcula recomendaciones, unos 40 minutos, con 6 pasos secuenciales dependientes; (d) redimensionar imágenes de opiniones de clientes, con picos de 500 imágenes en pocos minutos tras una campaña.
Ejercicio 3: diagnosticar una factura inesperada
Un lunes, Marta ve que la factura del fin de semana ha sido 40 veces la habitual. Los datos: procesar-imagen-producto registró 1,2 millones de invocaciones en 48 horas frente a las 600 habituales; la tabla imagenes_vision tiene 1,2 millones de filas nuevas, muchas con la misma ruta_gcs; el bucket alpinashop-catalogo ha crecido 300 GB; y el DLQ imagenes-subidas-dlq está vacío. El viernes se desplegó un cambio "menor": guardar también una versión en blanco y negro de cada foto. Diagnostica la causa, explica por qué el DLQ vacío es una pista y no una tranquilidad, y detalla las medidas de contención inmediata y de prevención.
Soluciones
Solución 1
El problema central: la función tiene dos efectos con propiedades opuestas. Insertar en BigQuery es controlable con row_ids; enviar un correo es irreversible. Una vez enviado, no hay deshacer.
Mecanismo de idempotencia con marca previa al envío. La clave es registrar la intención antes de la acción irreversible, con una escritura condicional atómica. Firestore encaja mejor que BigQuery aquí porque ofrece transacciones y latencia baja:
from google.cloud import firestore
db = firestore.Client()
cliente_bq = bigquery.Client()
@functions_framework.cloud_event
def confirmar_pedido(evento):
mensaje = json.loads(base64.b64decode(evento.data["message"]["data"]))
id_pedido = mensaje["id_pedido"]
id_evento = evento["id"] # identificador único del mensaje Pub/Sub
ref = db.collection("correos_enviados").document(id_pedido)
# 1. Reserva atómica: create() falla si el documento ya existe.
# Es una operación atómica del lado del servidor, no un "leer y escribir".
try:
ref.create({
"estado": "enviando",
"id_evento": id_evento,
"iniciado_en": firestore.SERVER_TIMESTAMP,
})
except google.api_core.exceptions.AlreadyExists:
doc = ref.get().to_dict()
if doc["estado"] == "enviado":
print(f"Correo ya enviado para {id_pedido}, se omite")
return # confirma sin reintentar
# Estado "enviando": otra invocación está en ello o murió a mitad
if antiguedad(doc["iniciado_en"]) < 300:
print(f"Otra invocación procesando {id_pedido}")
return
print(f"ADVERTENCIA: reserva huérfana en {id_pedido}, se reintenta")
# 2. Acción irreversible
try:
enviar_correo_confirmacion(mensaje)
except ErrorTransitorioCorreo as e:
ref.delete() # liberar la reserva para poder reintentar
raise # excepción → Pub/Sub reintenta
except ErrorPermanenteCorreo as e:
ref.update({"estado": "fallido", "error": str(e)})
registrar_para_revision(id_pedido, e)
return # NO reintentar
ref.update({"estado": "enviado", "enviado_en": firestore.SERVER_TIMESTAMP})
# 3. Acción idempotente por diseño: puede repetirse sin daño
cliente_bq.insert_rows_json(TABLA_FACTURACION, [fila_de(mensaje)],
row_ids=[id_pedido])Clasificación de errores:
| Error | ¿Reintentar? | Manejo |
|---|---|---|
| Timeout del servidor de correo | Sí | Liberar reserva + excepción |
| 5xx del proveedor de correo | Sí | Igual |
| Dirección de correo inválida | No | Marcar fallido, avisar a atención al cliente |
| Falta un campo obligatorio del mensaje | No | Registrar y retornar; es un error del emisor |
| Permiso denegado en BigQuery | No ayuda | Error grave + alerta: es un fallo de configuración |
Despliegue:
gcloud functions deploy confirmar-pedido --gen2 --region=europe-west1 \
--runtime=python312 --entry-point=confirmar_pedido \
--trigger-topic=pedidos-nuevos \
--service-account=sa-confirmar-pedido@alpinashop-prod.iam.gserviceaccount.com \
--set-secrets='API_KEY_CORREO=api-key-correo:latest' \
--memory=512Mi --timeout=60s --max-instances=30 --min-instances=1 --retry \
--project=alpinashop-prod--min-instances=1 aquí sí se justifica: un cliente que acaba de pagar espera su confirmación, y dos segundos de arranque en frío en ese momento se notan.
La condición de carrera y su mitigación. El hueco está entre el create() y el enviar_correo(). Si la instancia muere justo ahí, la reserva queda en estado enviando para siempre y el cliente nunca recibe el correo, porque las reentregas verán la reserva y se retirarán.
El código lo mitiga con el temporizador de 300 segundos: una reserva enviando más antigua que eso se considera huérfana y se reintenta. La contrapartida es explícita y hay que asumirla: si la instancia no murió sino que simplemente tardó mucho, se enviarán dos correos.
Y aquí está la lección de fondo: con una acción externa irreversible no existe la garantía de exactamente una vez. Solo puedes elegir de qué lado fallar:
| Estrategia | Riesgo | Cuándo elegirla |
|---|---|---|
| Marcar antes de enviar | Puede no enviarse | Cuando duplicar es peor (cobros) |
| Marcar después de enviar | Puede enviarse dos veces | Cuando no enviar es peor (avisos) |
| Marcar antes + temporizador | Ambos, con baja probabilidad | Compromiso razonable |
Para un correo de confirmación, un duplicado ocasional es molesto pero inocuo, mientras que no enviarlo genera una llamada a atención al cliente. Por eso el temporizador es la elección correcta aquí. Para un cargo en tarjeta, la respuesta sería la contraria, y la solución adecuada pasaría por una clave de idempotencia del propio proveedor de pago.
Solución 2
(a) PDF de factura: Cloud Function. Es el caso canónico: un evento, una acción acotada, 2 segundos de trabajo, sin dependencias exóticas. Disparada por pedidos-nuevos o por un topic propio de "pago confirmado", con --memory=512Mi y --max-instances moderado. Matiz: si la generación del PDF necesitara fuentes corporativas o LaTeX, la dependencia del sistema empujaría hacia Cloud Run con un contenedor propio.
(b) Panel de administración: Cloud Run. Treinta rutas HTTP son una aplicación, no una función. Una Cloud Function tiene un punto de entrada, y meter un enrutador dentro sería usar la herramienta al revés. Además el patrón de uso —cinco personas en horario de oficina— hace que escalar a cero por las noches y fines de semana sea ideal, y ese es precisamente Cloud Run. --min-instances=0, y si el arranque en frío molesta a primera hora, --min-instances=1 solo en horario laboral. Es coherente con DA-001.
(c) Recálculo nocturno de 40 minutos: Workflows. Dos razones, cada una suficiente. Primera, 40 minutos superan el límite de 9 minutos de una función por eventos. Segunda, y más importante: seis pasos secuenciales dependientes son orquestación, y encadenarlos con funciones sería el monolito distribuido del apartado 13. Workflows —ya elegido en 04-06— gestiona el estado, los reintentos por paso y los errores explícitamente, y cada paso invoca lo que corresponda: un Cloud Run Job, un trabajo de BigQuery o un pipeline de Vertex AI.
(d) Redimensionar imágenes de opiniones: Cloud Function. Es idéntico a procesar-imagen-producto: un evento, una acción, sin estado. Los picos de 500 imágenes son exactamente donde el modelo brilla —escala solo y vuelve a cero después—. Configuración: --memory=1Gi, --max-instances=100 para que el pico se absorba rápido, --min-instances=0 porque nadie espera en tiempo real, y --retry con idempotencia por generation.
| Caso | Elección | Criterio decisivo |
|---|---|---|
| (a) PDF de factura | Cloud Function | Un evento, una acción |
| (b) Panel de administración | Cloud Run | Aplicación con muchas rutas |
| (c) Recálculo nocturno | Workflows | Supera 9 min + es orquestación |
| (d) Imágenes de opiniones | Cloud Function | Evento, sin estado, picos |
El criterio que unifica los cuatro: cuenta cuántas cosas distintas hace la pieza y cuánto tarda. Una cosa y poco tiempo → función. Muchas rutas → servicio. Muchos pasos coordinados → flujo de trabajo. Y cuando dudes entre función y servicio para algo que ya está contenedorizado, Cloud Run casi siempre gana por portabilidad.
Solución 3
Diagnóstico: un bucle infinito, exactamente el del apartado 6.
El cambio "menor" del viernes añadió la escritura de una versión en blanco y negro en el mismo bucket, y con toda probabilidad en un prefijo que no es productos/miniaturas/, que es lo único que la guarda del código comprueba. Reconstruyendo la secuencia:
- Se sube
productos/piolet.jpg→ evento → la función la procesa. - Escribe
productos/miniaturas/piolet.jpg→ evento → ignorada por la guarda. Correcto. - Escribe
productos/bn/piolet.jpg→ evento → la guarda NO lo cubre → se procesa. - Al procesar
productos/bn/piolet.jpgescribeproductos/bn/bn/piolet.jpg→ evento → se procesa…
Cada nivel genera el siguiente. El crecimiento es exponencial hasta que algo lo frena. Las cuatro evidencias encajan sin excepción: 1,2 millones de invocaciones (recursión); filas repetidas con la misma ruta_gcs en imagenes_vision (cada nivel escribe una fila, y la idempotencia no protege porque cada objeto derivado es una ruta distinta); 300 GB de crecimiento (los objetos generados); y la factura disparada, dominada por la Vision API, no por la función.
Por qué el DLQ vacío es una pista y no una tranquilidad. El instinto dice "el DLQ está vacío, nada ha fallado". Es exactamente al revés: el DLQ vacío confirma que todo funcionó correctamente. La función no tenía ningún error; hacía perfectamente lo que se le pidió, un millón doscientas mil veces. Es el peligro de los sistemas por eventos: el fallo caro no produce errores, produce éxitos. Ninguna alerta basada en tasa de error habría detectado esto. La que sí lo habría detectado es una alerta sobre el volumen de invocaciones —tema de 06-04—, y su ausencia es el verdadero hallazgo del incidente.
Contención inmediata, en este orden:
# 1. CORTAR YA: max-instances a 0 detiene el procesamiento sin borrar nada
gcloud functions deploy procesar-imagen-producto --gen2 \
--region=europe-west1 --max-instances=0 --project=alpinashop-prod
# 2. Vaciar la cola de eventos pendientes, que puede ser enorme
gcloud pubsub subscriptions seek eventarc-europe-west1-procesar-imagen-sub \
--time=$(date -u +%Y-%m-%dT%H:%M:%SZ) --project=alpinashop-prod
# 3. Medir el alcance antes de borrar nada
gcloud storage du -s gs://alpinashop-catalogo/productos/bn/ --project=alpinashop-prodPoner --max-instances=0 en lugar de borrar la función es deliberado: detiene el sangrado de inmediato, conserva toda la configuración para el diagnóstico y es reversible en un comando.
Limpieza: borrar los objetos derivados recursivamente (productos/bn/bn/... y siguientes) conservando el primer nivel si resulta útil; eliminar de imagenes_vision las filas cuya ruta_gcs contenga /bn/; y comprobar si algún proceso posterior —el índice de búsqueda, productos_color de 05-05— consumió esos datos y necesita rehacerse.
Prevención, en cinco medidas:
| Medida | Qué evita | Coste |
|---|---|---|
Escribir las salidas en otro bucket (alpinashop-derivadas) |
La recursión, de raíz | Ninguno |
Filtro de notificación con prefijo productos/originales/ |
Que el evento se genere | Ninguno |
| Guarda por lista blanca, no por lista negra | La próxima variante olvidada | Ninguno |
| Alerta sobre invocaciones/hora de la función | Detectarlo en 15 minutos, no en 48 horas | Ninguno |
| Alerta de presupuesto en el proyecto (01-04) | Que se repita cualquier fuga de coste | Ninguno |
La tercera medida es la lección de diseño más transferible. La guarda original era una lista negra: "si empieza por productos/miniaturas/, ignorar". Una lista negra falla siempre que aparece un caso nuevo, y aparecerá. Lo correcto es una lista blanca:
PREFIJO_ORIGINALES = "productos/originales/"
if not ruta.startswith(PREFIJO_ORIGINALES):
print(f"Ignorada, no es un original: {ruta}")
returnCon esa versión, el cambio del viernes habría sido inocuo: la función solo procesa lo que está explícitamente permitido. En una arquitectura por eventos, prohibir lo conocido es frágil; permitir solo lo conocido es robusto.
Y la reflexión final del incidente: no hubo un fallo técnico. Hubo un cambio de una línea, revisado por nadie, en un sistema donde escribir en un bucket significa disparar código. Tres cosas lo habrían impedido y las tres están en este módulo: la revisión de código de 06-02 —alguien habría preguntado dónde se escribe el fichero nuevo—, la prueba unitaria del apartado 12 —que verifica que no se llama a Vision para objetos derivados—, y la observabilidad de 06-04 —una alerta de volumen que avisa en minutos—. La factura de 40× es el precio de no tener ninguna de las tres.
Conclusión
La punta suelta del módulo 5 está cerrada. procesar-imagen-producto existe, se dispara con imagenes-subidas, llama a la Vision API, genera la miniatura, escribe en alpinashop_analitica y no despierta a nadie de madrugada.
Sabes qué es FaaS y cuál es su unidad de despliegue —la función—, con las cuatro propiedades que lo definen: se dispara por eventos, escala a cero, escala solo hacia arriba, y es efímera y sin estado, con la consecuencia práctica de que todo lo que deba sobrevivir vive fuera.
Entiendes qué es hoy una Cloud Function de 2ª generación: un servicio de Cloud Run con un contenedor construido por Google y disparadores gestionados por Eventarc. Y sabes qué te da eso —concurrencia configurable en lugar de una petición por instancia, hasta 60 minutos en HTTP, hasta 32 GB, reparto de tráfico y más de 130 fuentes de eventos— con la regla clara de usar --gen2 siempre.
Sabes escribir una función HTTP con el Functions Framework y desplegarla con las opciones que importan, incluido --max-instances, porque el escalado sin límite no es una virtud si Cloud SQL no escala igual. Y sabes que casi ninguna función debe ser pública: --no-allow-unauthenticated, run.invoker a quien corresponda y token OIDC en el llamante, con las tres excepciones legítimas y lo que exige cada una.
Conoces las funciones por eventos, los CloudEvents y la tabla de qué dispara qué —Pub/Sub, Cloud Storage, Firestore y los logs de auditoría—, y por qué AlpinaShop pasa por un topic en lugar de disparar directamente del bucket: para que mañana un segundo consumidor pueda reaccionar sin tocar nada.
Tienes el caso completo resuelto, con los tres detalles que lo hacen viable en producción: clientes creados a nivel de módulo, la guarda contra el bucle infinito y el row_ids de BigQuery. Y tienes la disciplina que separa una función que funciona de una en la que se puede confiar: idempotencia con identificador estable —ruta más generation—, comprobación previa acotada en el tiempo y deduplicación en destino; la distinción entre errores transitorios que se reintentan y permanentes que no; y un dead letter con alerta, porque un DLQ que nadie mira es peor que no tenerlo.
Sabes qué es un arranque en frío, las cinco palancas para mitigarlo y —más importante— cuándo importa y cuándo no: --min-instances donde lo percibe un cliente, cero en el procesamiento por lotes. Conoces el peligro de las variables globales mutables con concurrencia alta, con la regla de que a nivel de módulo solo van clientes y constantes. Sabes inyectar configuración con variables de entorno y secretos con --set-secrets desde Secret Manager, dar a cada función su propia cuenta de servicio, y conectar a la VPC con un conector cuando —y solo cuando— hay que alcanzar una IP privada.
Tienes los límites y el coste con su cálculo orientativo, y la lección económica que trasciende el ejemplo: en una arquitectura por eventos, el cómputo casi nunca es el coste principal; lo son las APIs que llamas y los datos que mueves. Sabes probar en local, escribir la prueba unitaria de seis líneas que protege contra el fallo más caro posible, y desplegar desde Cloud Build.
Y tienes el criterio del apartado 13: función para una cosa disparada por un evento, servicio para una aplicación con muchas rutas, flujo de trabajo para varios pasos coordinados, con la advertencia contra el monolito distribuido de funciones y su señal de alarma —si tus funciones se llaman entre sí de forma síncrona, replantea—.
Ahora mira el estado de AlpinaShop. Hay una tienda en GKE, una función procesando imágenes, pipelines de datos, modelos entrenándose solos, un pipeline de CI/CD y una infraestructura de red que sostiene todo eso. Muchas piezas. Muchas más de las que cabían hace tres módulos.
Y sigue habiendo una sola forma de saber si funcionan: que un cliente escriba un correo diciendo que la web va lenta.
Ese es el problema de 06-04. Toca dejar de mirar la consola y empezar a medir: métricas, paneles y alertas con Cloud Monitoring, para enterarse de los problemas antes que los clientes.
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
