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

  1. Qué es FaaS y cuál es la unidad de despliegue
  2. Cloud Functions hoy: la 2ª generación sobre Cloud Run y Eventarc
  3. Funciones HTTP: la primera función y su despliegue
  4. Autenticación: por qué casi nunca deben ser públicas
  5. Funciones por eventos: CloudEvents y Eventarc
  6. El caso pendiente: procesar-imagen-producto de principio a fin
  7. Idempotencia, reintentos y dead letter
  8. El ciclo de vida real: arranques en frío y concurrencia
  9. Configuración, secretos e identidad
  10. Conexión a la VPC para llegar a Cloud SQL
  11. Límites y coste
  12. Pruebas locales y despliegue desde Cloud Build
  13. Cuándo una función y cuándo un servicio

  1. 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.

  1. 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.

  1. 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:

funcion-salud/
├── main.py
└── requirements.txt
# 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}), 200
# requirements.txt
functions-framework==3.*
google-cloud-firestore==2.*

Tres detalles que conviene notar:

  • El decorador @functions_framework.http marca el punto de entrada. El nombre de la función Python (comprobar_stock) es el que se pasa en --entry-point.
  • request es 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-prod

Cada 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.

  1. 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-prod

Y 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.

  1. 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 tipo

Eventarc 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-prod

Una 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.

  1. El caso pendiente: procesar-imagen-producto de principio a fin

Aquí 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-prod

El --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.

  1. 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 None

La 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 reintenta

Dead 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-prod

Tras 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—.

  1. 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-prod

Cuá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.

  1. 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-prod

Secretos 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-prod

Con 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.

  1. 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-prod

Detalles que importan:

  • El rango /28 debe estar libre y no solaparse con sn-web-euw1 ni sn-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=2 pagas 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-only envía por la VPC solo el tráfico a rangos privados; el resto sale por internet directamente. Con all-traffic, todo pasa por la VPC y sale por el Cloud NAT alpinashop-nat-euw1 de 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.

  1. 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:

  1. Bucles infinitos (apartado 6). El más caro con diferencia.
  2. --min-instances en funciones que no lo necesitan. Es coste fijo 24×7 y anula la ventaja del modelo.
  3. Timeout largo con errores colgados. Una función con --timeout=540s que se queda esperando a un servicio caído paga nueve minutos por invocación fallida. Timeouts cortos y ajustados a la realidad.

  1. 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=8080

Y 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_ONLY

La 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"—.

  1. 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-producto es 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-cobrar funciona y funcion-notificar falla, 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:

  1. Se sube productos/piolet.jpg → evento → la función la procesa.
  2. Escribe productos/miniaturas/piolet.jpg → evento → ignorada por la guarda. Correcto.
  3. Escribe productos/bn/piolet.jpg → evento → la guarda NO lo cubre → se procesa.
  4. Al procesar productos/bn/piolet.jpg escribe productos/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-prod

Poner --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}")
    return

Con 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

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados