A 05-05 va quedar un cap solt explícit. AlpinaShop va dissenyar una arquitectura per esdeveniments per processar automàticament cada imatge de producte que es puja al bucket alpinashop-catalogo: un missatge al topic imagenes-subidas, una crida a la Vision API, les etiquetes escrites a alpinashop_analitica i una miniatura generada. El disseny era clar. La implementació es va ajornar perquè faltava una peça: alguna cosa que executi codi quan passa un esdeveniment, sense que hi hagi un servidor esperant.

Aquella peça són les Cloud Functions, i és el que construirem aquí de principi a fi.

Però abans convé entendre per què el problema no és trivial. La solució "òbvia" seria afegir un endpoint al catàleg Flask que rebi l'avís i processi la imatge. És una mala idea per tres motius: el processament d'una imatge triga segons i bloquejaria un procés de gunicorn que hauria d'estar servint peticions de clients; el catàleg escalaria per la càrrega d'imatges en lloc de pel trànsit web; i una fallada del processament afectaria la botiga. La reacció a esdeveniments vol el seu propi cicle de vida, i aquell és exactament el buit que omple FaaS.

Contingut

  1. Què és FaaS i quina és la unitat de desplegament
  2. Cloud Functions avui: la 2a generació sobre Cloud Run i Eventarc
  3. Funcions HTTP: la primera funció i el seu desplegament
  4. Autenticació: per què gairebé mai no han de ser públiques
  5. Funcions per esdeveniments: CloudEvents i Eventarc
  6. El cas pendent: procesar-imagen-producto de principi a fi
  7. Idempotència, reintents i dead letter
  8. El cicle de vida real: arrencades en fred i concurrència
  9. Configuració, secrets i identitat
  10. Connexió a la VPC per arribar a Cloud SQL
  11. Límits i cost
  12. Proves locals i desplegament des de Cloud Build
  13. Quan una funció i quan un servei

  1. Què és FaaS i quina és la unitat de desplegament

Repassa mentalment el continu d'abstracció de 02-07, ara amb una casella més:

Model Unitat de desplegament Què gestiones Què pagues
IaaS (Compute Engine) La màquina virtual SO, pedaços, escalat, tot La màquina encesa
CaaS (GKE) El contenidor El clúster i els manifestos Els nodes
PaaS (App Engine) L'aplicació El codi i la seva configuració Instàncies
FaaS (Cloud Functions) La funció Només el codi de la funció Invocacions i temps de còmput

Function as a Service porta l'abstracció al seu extrem pràctic: escrius una funció —una unitat de codi amb una signatura concreta—, la plataforma l'empaqueta, la desplega, l'executa quan alguna cosa la dispara i l'apaga quan no cal.

Les quatre propietats que defineixen el model:

  • Es dispara per un esdeveniment. Una petició HTTP, un missatge de Pub/Sub, un fitxer nou en un bucket, un document modificat a Firestore. La funció no espera: la desperten.
  • Escala a zero. Sense esdeveniments, no hi ha instàncies i no es paga còmput. Diferència radical amb una VM del MIG, que costa el mateix a les quatre de la matinada que en plena campanya.
  • Escala cap amunt sola. Si arriben mil esdeveniments alhora, la plataforma arrenca instàncies. No cal configurar un autoescalador.
  • És efímera i sense estat. Una instància processa i pot desaparèixer. Res del que es guardi en memòria o en disc local sobreviu de manera fiable.

Aquella última propietat és la que més costa interioritzar i la que produeix més errors de disseny. L'estat viu fora: a Cloud SQL, a Firestore, a Cloud Storage, a BigQuery. Una funció que acumula alguna cosa en una variable global i espera trobar-la a la invocació següent funciona a les proves —perquè reutilitza la mateixa instància— i falla en producció de manera intermitent i inexplicable.

  1. Cloud Functions avui: la 2a generació sobre Cloud Run i Eventarc

Aquest és l'apartat que cal entendre bé per no equivocar-se amb la documentació antiga que continua circulant.

Cloud Functions va néixer el 2016 amb una plataforma pròpia. El 2022 va aparèixer la 2a generació, i el canvi no és cosmètic:

Una Cloud Function de 2a generació és un servei de Cloud Run amb un contenidor construït automàticament per Google a partir del teu codi, i els seus activadors d'esdeveniments són subscripcions d'Eventarc.

Google construeix el contenidor per tu utilitzant buildpacks, el desplega a Cloud Run i connecta Eventarc perquè els esdeveniments arribin com a peticions HTTP. Tot el que Cloud Run sap fer, la funció ho hereta.

flowchart TD
    A[El teu codi:<br/>main.py + requirements.txt] --> B[Cloud Build:<br/>buildpacks]
    B --> C[Imatge a<br/>Artifact Registry]
    C --> D[Servei de Cloud Run<br/>gestionat per Cloud Functions]
    E[Pub/Sub, Cloud Storage,<br/>Firestore, Audit Logs] --> F[Eventarc]
    F -->|CloudEvent per HTTP POST| D
    G[Petició HTTP directa] --> D

Les diferències pràctiques, que són les que importen en dissenyar:

Característica 1a generació 2a generació
Plataforma Pròpia Cloud Run + Eventarc
Concurrència per instància 1 petició Fins a 1.000 configurables
Temps màxim (HTTP) 9 minuts 60 minuts
Temps màxim (esdeveniments) 9 minuts 9 minuts (per Eventarc)
Memòria màxima 8 GB 32 GB
CPU Lligada a la memòria Configurable, fins a 8 vCPU
Repartiment de trànsit entre versions No Sí, com Cloud Run
Fonts d'esdeveniments Unes poques natives Més de 130 via Eventarc
Instàncies mínimes Limitat Sí
Cost base Menor en càrregues mínimes Lleugerament superior, més capacitat

La concurrència és la diferència més important. A la 1a generació, cada instància atenia una petició: 100 peticions simultànies significaven 100 instàncies, amb 100 arrencades en fred i 100 connexions a la base de dades. A la 2a, una instància amb concurrència 80 atén 80 peticions alhora, cosa que redueix dràsticament les arrencades en fred, el cost i la pressió sobre Cloud SQL —problema molt real: el nombre de connexions de Cloud SQL és limitat, i una funció de 1a generació mal dimensionada esgota el pool amb una facilitat esbalaïdora—.

La regla el 2026: fes servir sempre la 2a generació. --gen2 és obligatori als exemples d'aquesta lliçó. La 1a generació només apareix en funcions antigues que ningú no ha migrat.

I la pregunta natural: si una funció de 2a generació és un Cloud Run, per què no fer servir Cloud Run directament? És una pregunta excel·lent i té resposta, però la deixem per a l'apartat 13, quan tinguis el context per valorar-la.

  1. Funcions HTTP: la primera funció i el seu desplegament

El Functions Framework és la biblioteca que converteix una funció Python normal en un servei HTTP. S'instal·la com una dependència més i utilitza decoradors.

L'estructura mínima d'una funció són dos fitxers:

funcion-salud/
├── main.py
└── requirements.txt
# main.py
import functions_framework
from flask import jsonify

@functions_framework.http
def comprobar_stock(request):
    """Retorna l'estoc d'un SKU. Punt d'entrada HTTP."""
    # request és un objecte Request de Flask: la mateixa API que ja coneixes
    sku = request.args.get("sku")
    if not sku:
        return jsonify({"error": "falta el parametre sku"}), 400

    unitats = consultar_stock(sku)           # implementació omesa
    if unitats is None:
        return jsonify({"error": "sku desconegut"}), 404

    return jsonify({"sku": sku, "unitats": unitats, "disponible": unitats > 0}), 200
# requirements.txt
functions-framework==3.*
google-cloud-firestore==2.*

Tres detalls que convé notar:

  • El decorador @functions_framework.http marca el punt d'entrada. El nom de la funció Python (comprobar_stock) és el que es passa a --entry-point.
  • request és un objecte de Flask. Si has escrit el catàleg amb Flask, l'API et resulta familiar: request.args, request.get_json(), request.headers.
  • El valor de retorn segueix les convencions de Flask: una cadena, una tupla (cos, codi) o una resposta completa.

El desplegament:

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ó, i per què hi és:

Opció Què fa Per què aquest valor
--gen2 Utilitza la 2a generació Sempre, per l'apartat 2
--region On es desplega europe-west1, al costat de la resta
--runtime Versió del llenguatge Fixada explícitament, no implícita
--source D'on surt el codi . local; també gs:// o un repositori
--entry-point Nom de la funció Python Ha de coincidir exactament
--trigger-http Es dispara per HTTP Davant dels activadors d'esdeveniments
--no-allow-unauthenticated Exigeix autenticació IAM Apartat 4
--service-account Identitat d'execució Mai el compte per defecte
--memory Memòria per instància Determina també la CPU assignada
--timeout Temps màxim per invocació Curt: si triga més, alguna cosa va malament
--max-instances Sostre d'escalat Protegeix el que hi ha al darrere

--max-instances mereix una explicació, perquè la seva absència causa incidents reals. Sense sostre, un pic de trànsit —o un bucle infinit, o un atac— pot arrencar centenars d'instàncies que obren centenars de connexions a alpinashop-pedidos i tomben la base de dades. L'escalat infinit no és una virtut si el que hi ha al darrere no escala igual. Posar un límit converteix una caiguda total en una degradació parcial, que és infinitament preferible.

  1. Autenticació: per què gairebé mai no han de ser públiques

És temptador desplegar amb --allow-unauthenticated perquè "és més fàcil de provar". És exactament com es filtren dades.

Una funció pública té una URL endevinable en un patró conegut, està exposada a tot internet, la pot invocar qualsevol tantes vegades com vulgui —i tu pagues cada invocació— i sol tenir permisos IAM sobre bases de dades i buckets del teu projecte. És un endpoint privilegiat sense porta.

Amb --no-allow-unauthenticated, invocar la funció exigeix el rol roles/run.invoker —de Cloud Run, coherent amb l'apartat 2—:

# Només el catàleg web pot cridar aquesta funció
gcloud functions add-invoker-policy-binding comprobar-stock \
  --region=europe-west1 \
  --member="serviceAccount:[email protected]" \
  --project=alpinashop-prod

I des del catàleg, la crida s'autentica amb un testimoni 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_remot(sku: str) -> dict:
    # L'"audience" ha de ser exactament la URL de la funció
    peticio = google.auth.transport.requests.Request()
    token = google.oauth2.id_token.fetch_id_token(peticio, URL_STOCK)

    resposta = requests.get(
        URL_STOCK,
        params={"sku": sku},
        headers={"Authorization": f"Bearer {token}"},
        timeout=5,
    )
    resposta.raise_for_status()
    return resposta.json()

El testimoni l'obté la biblioteca de la identitat de l'entorn —el compte de servei adjuntat al pod de GKE— sense cap clau. És el mateix mecanisme de les subscripcions push amb OIDC de 04-04.

Els tres casos en què una funció sí que pot ser pública, i què cal en cadascun:

Cas Què necessites a més
Webhook d'un tercer (passarel·la de pagament) Verificar la signatura del proveïdor al cos de la petició
Endpoint públic de debò (formulari web) Cloud Armor al davant (03-05), límit de peticions i validació estricta
Desenvolupament i proves Que sigui a alpinashop-dev i que no toqui res real

El cas del webhook mereix una nota: una funció pública que rep notificacions de pagament ha de validar la signatura HMAC que el proveïdor inclou a la capçalera. Sense aquella validació, qualsevol pot enviar un POST dient "la comanda 1234 està pagada". És una fallada que apareix amb una freqüència depriment.

  1. Funcions per esdeveniments: CloudEvents i Eventarc

Una funció per esdeveniments no rep una petició HTTP: rep un CloudEvent, un format estàndard del sector —no una invenció de Google— amb metadades comunes (id, source, type, time, subject) i una dada específica del tipus d'esdeveniment.

En Python es declara amb @functions_framework.cloud_event:

import functions_framework

@functions_framework.cloud_event
def processar(esdeveniment):
    print("id:",   esdeveniment["id"])       # identificador únic de l'esdeveniment
    print("tipus:", esdeveniment["type"])    # google.cloud.pubsub.topic.v1.messagePublished
    print("origen:", esdeveniment["source"]) # el recurs que el va generar
    dades = esdeveniment.data                # la càrrega útil, segons el tipus

Eventarc és l'encaminador universal d'esdeveniments de GCP. Rep esdeveniments de més de 130 fonts, els normalitza a CloudEvents i els lliura a la destinació. Els activadors més útils:

Font Tipus d'esdeveniment Es dispara quan Ús típic a AlpinaShop
Pub/Sub ...pubsub.topic.v1.messagePublished Es publica en un topic imagenes-subidas (apartat 6)
Cloud Storage ...storage.object.v1.finalized Es crea o se sobreescriu un objecte Alternativa directa al topic
Cloud Storage ...storage.object.v1.deleted S'esborra un objecte Netejar referències òrfenes
Firestore ...firestore.document.v1.written S'escriu un document Reaccionar a una cistella
Firestore ...document.v1.created / .deleted Alta o baixa Auditoria funcional
Registres d'auditoria google.cloud.audit.log.v1.written Qualsevol operació registrada Alertar de canvis de tallafoc
Cloud Scheduler Via Pub/Sub A l'hora programada Tasques periòdiques lleugeres
BigQuery Via registres d'auditoria Un treball acaba Encadenar processos analítics

L'activador per registres d'auditoria és el menys conegut i un dels més potents: permet reaccionar a qualsevol operació de l'API de GCP. Per exemple, executar una funció cada vegada que algú modifica una regla de tallafoc a 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 important sobre Cloud Storage: es pot disparar directament des del bucket (storage.object.v1.finalized) o publicant en un topic de Pub/Sub i disparant des d'allà. AlpinaShop va triar a 05-05 la segona opció, i la raó és sòlida: amb Pub/Sub pel mig, diversos consumidors poden reaccionar al mateix esdeveniment. Avui només processa imatges; demà, un segon subscriptor pot actualitzar l'índex de cerca sense tocar res del que ja hi ha. Amb l'activador directe, cada consumidor nou obliga a reconfigurar el bucket.

  1. El cas pendent: procesar-imagen-producto de principi a fi

Aquí es tanca la punta solta de 05-05. Recordem el flux dissenyat aleshores:

flowchart LR
    A[Dani puja foto a<br/>gs://alpinashop-catalogo] -->|notificació| B[Topic Pub/Sub<br/>imagenes-subidas]
    B -->|CloudEvent via Eventarc| C[Funció<br/>procesar-imagen-producto]
    C --> D[Vision API:<br/>etiquetes, colors, SafeSearch]
    C --> E[Miniatura 400px<br/>a Cloud Storage]
    D --> F[BigQuery<br/>imagenes_vision]
    C -.->|error després de 5 intents| G[imagenes-subidas-dlq]

Pas 1: notificar el bucket al topic. Una sola comanda, que probablement ja està feta des de 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 processar objectes que no són fotos de producte —logotips, fitxers temporals—, i és la forma més barata de filtrar: l'esdeveniment ni tan sols es genera.

Pas 2: la funció. Està comentada per blocs perquè cadascun resol un problema concret:

# 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

# Els clients es creen UNA VEGADA, fora de la funció.
# Es reutilitzen mentre la instància visqui: estalvia centenars de ms per invocació.
client_vision = vision.ImageAnnotatorClient()
client_storage = storage.Client()
client_bq = bigquery.Client()

PROJECTE_DADES = os.environ["PROJECTE_DADES"]        # alpinashop-datos
TAULA = f"{PROJECTE_DADES}.alpinashop_analitica.imagenes_vision"
BUCKET_MINIATURES = os.environ["BUCKET_MINIATURES"]  # alpinashop-catalogo
AMPLE_MINIATURA = 400


@functions_framework.cloud_event
def procesar_imagen(esdeveniment):
    """Processa una imatge nova: Vision API + miniatura + BigQuery."""

    # 1. Descodificar el missatge de Pub/Sub. La dada ve en base64.
    missatge = base64.b64decode(esdeveniment.data["message"]["data"]).decode("utf-8")
    avis = json.loads(missatge)
    bucket_nom = avis["bucket"]
    ruta = avis["name"]
    generacio = avis["generation"]     # identifica la VERSIÓ exacta de l'objecte

    # 2. Guarda: ignorar el que no s'ha de processar.
    #    Sense això, la miniatura que escrivim a sota dispararia un altre esdeveniment
    #    i tindríem un bucle infinit que a més costa diners.
    if ruta.startswith("productos/miniaturas/"):
        print(f"Ignorada miniatura: {ruta}")
        return
    if not ruta.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
        print(f"Ignorat format no suportat: {ruta}")
        return

    # 3. Idempotència: si aquesta generació ja s'ha processat, no repetir.
    id_imagen = f"gs://{bucket_nom}/{ruta}#{generacio}"
    if ja_processada(id_imagen):
        print(f"Ja processada, s'omet: {id_imagen}")
        return

    # 4. Vision API sobre l'objecte a Cloud Storage, sense descarregar-lo.
    imatge = vision.Image(source=vision.ImageSource(
        gcs_image_uri=f"gs://{bucket_nom}/{ruta}"))
    resposta = client_vision.annotate_image({
        "image": imatge,
        "features": [
            {"type_": vision.Feature.Type.LABEL_DETECTION, "max_results": 10},
            {"type_": vision.Feature.Type.IMAGE_PROPERTIES},
            {"type_": vision.Feature.Type.SAFE_SEARCH_DETECTION},
        ],
    })
    if resposta.error.message:
        # Error de l'API: llançar excepció perquè Pub/Sub reintenti
        raise RuntimeError(f"Vision API: {resposta.error.message}")

    # 5. Generar la miniatura i pujar-la al prefix que el pas 2 ignora
    blob = client_storage.bucket(bucket_nom).blob(ruta)
    original = Image.open(io.BytesIO(blob.download_as_bytes()))
    original.thumbnail((AMPLE_MINIATURA, AMPLE_MINIATURA))
    sortida = io.BytesIO()
    original.convert("RGB").save(sortida, format="JPEG", quality=82)

    ruta_mini = f"productos/miniaturas/{os.path.basename(ruta)}"
    client_storage.bucket(BUCKET_MINIATURES).blob(ruta_mini).upload_from_string(
        sortida.getvalue(), content_type="image/jpeg")

    # 6. Escriure a BigQuery, amb l'identificador que garanteix idempotència
    fila = {
        "id_imagen": id_imagen,
        "ruta_gcs": f"gs://{bucket_nom}/{ruta}",
        "ruta_miniatura": f"gs://{BUCKET_MINIATURES}/{ruta_mini}",
        "etiquetas": [
            {"descripcion": e.description, "puntuacion": round(e.score, 4)}
            for e in resposta.label_annotations
        ],
        "color_dominante": color_dominante(resposta),
        "safesearch_adulto": resposta.safe_search_annotation.adult.name,
        "procesada_en": datetime.now(timezone.utc).isoformat(),
    }
    errors = client_bq.insert_rows_json(TAULA, [fila], row_ids=[id_imagen])
    if errors:
        raise RuntimeError(f"BigQuery: {errors}")

    print(f"Processada correctament: {id_imagen}")

Els tres detalls que fan que això funcioni en producció, i que separen un exemple de tutorial de codi real:

Els clients fora de la funció. Crear un ImageAnnotatorClient implica resoldre credencials i establir connexions: centenars de mil·lisegons. Creant-lo a nivell de mòdul, es crea una vegada per instància i es reutilitza en totes les invocacions que aquella instància atengui. En una funció amb concurrència 20 i mil esdeveniments, la diferència és enorme.

La guarda contra el bucle infinit (pas 2). És l'error clàssic de les funcions disparades per Cloud Storage i mereix que s'expliqui a poc a poc: la funció escriu la miniatura al mateix bucket, cosa que genera un esdeveniment OBJECT_FINALIZE, que dispara la funció, que genera una altra miniatura, que dispara la funció… Cada iteració costa invocacions, crides a la Vision API —que es facturen— i files a BigQuery. Un bucle així descobert un dilluns al matí pot haver consumit un pressupost sencer durant el cap de setmana. Les tres defenses possibles són el prefix al filtre de notificació, la guarda al codi i escriure en un altre bucket; fes servir-ne almenys dues.

El row_ids a insert_rows_json. BigQuery desduplica per aquell identificador durant una finestra de temps. És una segona xarxa sota la comprovació d'idempotència del pas 3.

Pas 3: desplegar amb la seva identitat i els seus permisos.

# Compte de servei propi amb permisos mínims
gcloud iam service-accounts create sa-procesar-imagen \
  --display-name="Funcio 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=PROJECTE_DADES=alpinashop-datos,BUCKET_MINIATURES=alpinashop-catalogo \
  --memory=1Gi \
  --timeout=120s \
  --max-instances=50 \
  --retry \
  --project=alpinashop-prod

--memory=1Gi no és un caprici: obrir una foto de producte de 12 megapíxels amb Pillow consumeix bastanta memòria, i a la 2a generació la CPU assignada creix amb la memòria, així que a més va més ràpid. --retry activa els reintents, que és el tema de l'apartat següent.

  1. Idempotència, reintents i dead letter

Aquesta és la part que distingeix una funció que funciona d'una funció en la qual es pot confiar. I arrenca d'un fet que ja es va establir a 04-04:

Pub/Sub garanteix el lliurament almenys una vegada. La teva funció rebrà missatges duplicats. No és una possibilitat remota: és una certesa estadística.

Els duplicats arriben per causes perfectament normals: la funció va processar correctament però va trigar més que el termini de confirmació; hi va haver una fallada de xarxa en confirmar; la instància es va reiniciar després de processar i abans de confirmar; o l'esdeveniment es va reenviar perquè una invocació anterior va llançar una excepció.

Idempotent significa que processar el mateix esdeveniment N vegades produeix el mateix resultat que processar-lo una vegada. Analitzem les tres accions de la nostra funció:

Acció Idempotent per naturalesa? Què passaria amb un duplicat
Cridar la Vision API Sí en resultat, no en cost Es paga dues vegades pel mateix
Escriure la miniatura Sí: mateixa ruta, se sobreescriu Sense dany, només còmput malgastat
Inserir a BigQuery No Fila duplicada: les estadístiques menteixen

La tercera és la perillosa, i per això el disseny inclou tres capes de defensa:

Capa 1, l'identificador estable. gs://bucket/ruta#generacio identifica una versió concreta d'un objecte. Fixa't que la ruta sola no bastaria: si en Dani puja una foto corregida amb el mateix nom, és un objecte diferent que sí que s'ha de reprocessar, i la generation el distingeix.

Capa 2, la comprovació prèvia amb una taula lleugera de control:

def ja_processada(id_imagen: str) -> bool:
    """Comprova en una taula de control si aquest id ja s'ha processat."""
    consulta = f"""
        SELECT 1 FROM `{PROJECTE_DADES}.alpinashop_analitica.imagenes_procesadas`
        WHERE id_imagen = @id
          AND procesada_en > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
        LIMIT 1
    """
    treball = client_bq.query(consulta, job_config=bigquery.QueryJobConfig(
        query_parameters=[bigquery.ScalarQueryParameter("id", "STRING", id_imagen)]))
    return next(treball.result(), None) is not None

La finestra de 7 dies és un compromís deliberat: els duplicats de Pub/Sub arriben en segons o minuts, no dies, i limitar la consulta permet aprofitar el particionat de la taula i no escanejar l'històric complet a cada invocació. És la mateixa lògica de cost de 04-01.

Capa 3, el row_ids de BigQuery, que desduplica automàticament encara que les dues anteriors fallin per una condició de cursa.

Reintents. Amb --retry, si la funció llança una excepció, Pub/Sub no rep confirmació i torna a lliurar el missatge. I aquí hi ha una decisió de disseny que es fa malament constantment:

Tipus d'error Exemple Reintentar? Què fer
Transitori Vision API amb 503, timeout de xarxa Sí Llançar excepció
Permanent Imatge corrupta, format no suportat No Registrar i retornar normalment
De configuració Falta un permís IAM No ajuda Registrar com a error greu i alertar

Un error permanent reintentat és una funció que falla eternament sobre el mateix missatge, consumint quota i omplint els registres. La regla: llança excepció només si tornar-ho a intentar té alguna possibilitat de funcionar.

    try:
        original = Image.open(io.BytesIO(blob.download_as_bytes()))
    except UnidentifiedImageError:
        # Error PERMANENT: reintentar mil vegades no arreglarà un fitxer corrupte
        print(f"ERROR PERMANENT: imatge il·legible {ruta}")
        registrar_fallada_permanent(id_imagen, "imagen_ilegible")
        return          # retorn normal → Pub/Sub confirma i no reintenta

Dead letter. Fins i tot amb la distinció anterior, algun missatge fallarà indefinidament. El topic de missatges fallits evita que bloquegi la cua:

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

Després de cinc intents, el missatge va al DLQ en lloc de reintentar-se per sempre. I un DLQ que ningú no mira és pitjor que no tenir DLQ, perquè genera una falsa sensació de control: cal posar una alerta sobre el seu nombre de missatges sense confirmar —exactament el tipus d'alerta que es configura a 06-04—.

  1. El cicle de vida real: arrencades en fred i concurrència

Una instància de funció passa per tres fases, i entendre quina és cara explica gairebé tot el comportament observat:

flowchart LR
    A[Sense instàncies<br/>cost 0] -->|arriba un esdeveniment| B[ARRENCADA EN FRED<br/>crear instància +<br/>carregar codi +<br/>executar el mòdul]
    B --> C[Invocació<br/>ARRENCADA CALENTA]
    C -->|més esdeveniments| C
    C -->|sense esdeveniments<br/>uns minuts| A

L'arrencada en fred és el temps des que arriba l'esdeveniment fins que el teu codi comença a executar-se: aprovisionar la instància, carregar el runtime, importar les dependències i executar el codi a nivell de mòdul. Va d'uns centenars de mil·lisegons a diversos segons, i depèn sobretot de quant pesen les teves importacions.

Les cinc palanques per mitigar-ho, ordenades per eficàcia real:

Palanca Com Efecte Cost
--min-instances Mantenir N instàncies sempre vives Elimina el fred per al trànsit base Es paga la instància inactiva
Menys dependències Importar només el necessari Redueix molt l'arrencada Cap
Importació diferida import dins de la funció Només si no s'utilitza sempre Complica el codi
Concurrència alta --concurrency=80 Menys instàncies, menys arrencades Requereix codi segur en fils
Més CPU --cpu=2 Arrencada més ràpida Més car per segon
# Funció crítica a la ruta del client: sense arrencades en fred
gcloud functions deploy comprobar-stock --gen2 --region=europe-west1 \
  --min-instances=2 --max-instances=50 --concurrency=80 \
  --project=alpinashop-prod

Quan importa l'arrencada en fred i quan no, que és la decisió que de debò cal prendre:

Situació Importa? Recomanació
Funció a la ruta d'una petició de client Molt --min-instances ≥ 1
Webhook de la passarel·la de pagament Sí: hi ha timeouts --min-instances=1
procesar-imagen-producto No --min-instances=0: dos segons més no molesten ningú
Tasca nocturna programada No 0

--min-instances costa diners fins i tot sense trànsit, i aquell és el preu de renunciar parcialment a "escala a zero". Posar-lo per defecte a totes les funcions anul·la un dels avantatges econòmics del model. Posa'l on la latència la percep un client.

Sobre la concurrència, un advertiment important: amb --concurrency=80, vuitanta peticions s'executen simultàniament al mateix procés Python. Qualsevol variable global mutable passa a ser compartida:

# PERILLÓS amb concurrència > 1
resultats = []               # compartit entre invocacions simultànies!

@functions_framework.http
def processar(request):
    resultats.append(request.args["id"])     # condició de cursa
    return str(len(resultats))               # retorna qualsevol cosa

# CORRECTE: els objectes globals només per a clients reutilitzables i immutables
client_bq = bigquery.Client()    # segur: està dissenyat per a ús concurrent

@functions_framework.http
def processar(request):
    resultats_locals = []        # estat dins de la invocació
    ...

La regla: a nivell de mòdul, només clients i constants. Tot estat mutable, dins de la funció.

  1. Configuració, secrets i identitat

Les tres coses que tota funció de producció necessita ben resoltes.

Variables d'entorn per a configuració no sensible:

gcloud functions deploy procesar-imagen-producto --gen2 \
  --set-env-vars=PROJECTE_DADES=alpinashop-datos,ENTORN=prod,AMPLE_MINIATURA=400 \
  --region=europe-west1 --project=alpinashop-prod

Secrets des de Secret Manager, mai com a variable d'entorn amb el valor:

gcloud functions deploy procesar-pago --gen2 \
  --set-secrets='API_KEY_PASARELA=api-key-pasarela-pago:latest' \
  --region=europe-west1 --project=alpinashop-prod

Amb aquella sintaxi, la funció llegeix os.environ["API_KEY_PASARELA"] amb normalitat, però el valor no és a la configuració del desplegament: s'injecta en temps d'execució des de Secret Manager (03-06). Conseqüències pràctiques: el valor no apareix a la consola, no queda a l'historial de desplegaments, es pot rotar sense tornar a desplegar si utilitzes :latest, i l'accés queda registrat als registres d'auditoria. També es poden muntar com a fitxer amb --set-secrets='/etc/claves/api=secreto:latest', que és preferible per a valors grans com certificats.

I el detall que s'oblida: el compte de servei de la funció necessita roles/secretmanager.secretAccessor sobre aquell secret concret, concedit a la política del secret.

La identitat. Per defecte, una funció utilitza el compte de servei de Compute Engine del projecte, que sol tenir el rol Editor —el mateix antipatró de 06-01—. Sempre --service-account amb un compte propi per funció. Una funció que només escriu a BigQuery no ha de poder esborrar buckets.

  1. Connexió a la VPC per arribar a Cloud SQL

Per defecte, una funció viu fora de la teva VPC: pot sortir a internet però no pot arribar a recursos amb IP privada. Si alpinashop-pedidos només té IP privada —com ha de ser, segons 03-01— una funció no hi arriba.

El pont és un connector d'accés a VPC sense 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

Detalls que importen:

  • El rang /28 ha d'estar lliure i no encavalcar-se amb sn-web-euw1 ni sn-datos-euw1. És un requisit estricte i una font habitual d'errors.
  • El connector es factura per instància i hora, estigui o no en ús. Amb --min-instances=2 pagues dues màquines petites permanentment. Trenca parcialment l'"escala a zero", així que es comparteix entre totes les funcions que el necessitin.
  • --egress-settings=private-ranges-only envia per la VPC només el trànsit a rangs privats; la resta surt per internet directament. Amb all-traffic, tot passa per la VPC i surt pel Cloud NAT alpinashop-nat-euw1 de 03-01, cosa que dona una IP de sortida fixa —útil si un tercer exigeix llista blanca d'IP—.
Necessitat Solució Cost afegit
Cloud SQL amb IP pública Connector de Cloud SQL sobre TLS Cap
Cloud SQL amb IP privada Connector de VPC Instàncies del connector
IP de sortida fixa Connector + all-traffic + Cloud NAT Connector + NAT
Només API de Google Res: ja funciona Cap

Consell: no afegeixis un connector "per si de cas". Si la funció només parla amb API de Google —Vision, BigQuery, Storage, com procesar-imagen-producto— no el necessita, i afegir-lo és cost i complexitat gratuïts.

  1. Límits i cost

Els límits de la 2a generació que convé tenir presents —verifica sempre els valors vigents a la documentació oficial—:

Límit Valor aproximat Què fer si et queda curt
Temps màxim (HTTP) 60 minuts Feina llarga → Cloud Run Jobs o Dataflow
Temps màxim (esdeveniments) 9 minuts Trossejar la feina, encadenar per Pub/Sub
Memòria Fins a 32 GB Processament pesat → Cloud Run o Batch
CPU Fins a 8 vCPU Igual
Mida del codi desplegat Desenes de MB comprimit Models i dades a Cloud Storage
Mida de la petició HTTP 32 MB Pujada directa a Cloud Storage + esdeveniment
Instàncies simultànies Milers (quota ampliable) Sol·licitar augment de quota
Variables d'entorn Uns pocs KB en total Configuració a Firestore o Secret Manager

El cost té quatre components, i hi ha un nivell gratuït mensual generós:

Component Es paga per Comentari
Invocacions Cada crida Cèntims per milió
GB-segon Memòria × temps La palanca principal
GHz-segon CPU × temps Lligat a la memòria
Sortida de xarxa GB cap a internet Nul entre serveis de la mateixa regió

Càlcul orientatiu per a procesar-imagen-producto. Suposem 20.000 imatges al mes, 1 GiB de memòria, 3 segons per imatge:

  • Invocacions: 20.000 → cobertes de sobres pel nivell gratuït.
  • GB-segon: 20.000 × 3 s × 1 GiB = 60.000 GB-s, majorment dins del nivell gratuït.
  • GHz-segon: proporcional, mateix ordre.
  • Cost real de còmput: pràcticament zero.

I ara la dada que importa de debò: la Vision API d'aquelles 20.000 imatges, amb tres funcionalitats cadascuna, costa un ordre de magnitud més que tota l'execució de la funció. La lliçó econòmica és que a les arquitectures per esdeveniments el còmput gairebé mai no és el cost principal: ho són les API que es criden i les dades que es mouen. Optimitzar la memòria de la funció mentre es fan crides redundants a una API de pagament és optimitzar el que no importa.

Dit això, tres maneres que la factura es dispari de debò:

  1. Bucles infinits (apartat 6). El més car amb diferència.
  2. --min-instances en funcions que no el necessiten. És cost fix 24×7 i anul·la l'avantatge del model.
  3. Timeout llarg amb errors penjats. Una funció amb --timeout=540s que es queda esperant un servei caigut paga nou minuts per invocació fallida. Timeouts curts i ajustats a la realitat.

  1. Proves locals i desplegament des de Cloud Build

En local, el Functions Framework aixeca un servidor:

pip install functions-framework
functions-framework --target=procesar_imagen --signature-type=cloudevent --port=8080

I se simula un esdeveniment de Pub/Sub amb l'estructura real, base64 inclòs:

DADES=$(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\":\"${DADES}\"}}"

Millor encara, proves unitàries que no necessiten aixecar res:

# test_main.py
import base64, json
from unittest.mock import patch, MagicMock
from cloudevents.http import CloudEvent
import main

def crear_esdeveniment(bucket, nom, generacio="1"):
    dades = base64.b64encode(json.dumps(
        {"bucket": bucket, "name": nom, "generation": generacio}).encode())
    return CloudEvent(
        {"type": "google.cloud.pubsub.topic.v1.messagePublished",
         "source": "//pubsub.googleapis.com/", "id": "1"},
        {"message": {"data": dades.decode()}})

def test_ignora_miniatures():
    """La guarda del bucle infinit és la prova MÉS important de totes."""
    with patch.object(main, "client_vision") as vision_mock:
        main.procesar_imagen(crear_esdeveniment("alpinashop-catalogo",
                                                "productos/miniaturas/x.jpg"))
        vision_mock.annotate_image.assert_not_called()

def test_omet_si_ja_processada():
    with patch.object(main, "ja_processada", return_value=True), \
         patch.object(main, "client_vision") as vision_mock:
        main.procesar_imagen(crear_esdeveniment("alpinashop-catalogo", "productos/a.jpg"))
        vision_mock.annotate_image.assert_not_called()

La primera prova mereix un comentari: verifica que no es crida la Vision API per a una miniatura. És la prova que protegeix contra la fallada més cara possible d'aquesta arquitectura, i costa sis línies.

Desplegament des de Cloud Build, encaixant amb 06-01 i 06-02:

# cloudbuild.yaml del repositori de funcions
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=PROJECTE_DADES=alpinashop-datos,BUCKET_MINIATURES=alpinashop-catalogo'
      - '--memory=1Gi'
      - '--max-instances=50'
      - '--retry'
      - '--project=alpinashop-prod'

options:
  logging: CLOUD_LOGGING_ONLY

El compte de servei de Cloud Build necessita roles/cloudfunctions.developer i roles/iam.serviceAccountUser sobre sa-procesar-imagen —aquest segon permís s'oblida sempre i produeix un error de permisos que no esmenta la paraula "funció"—.

  1. Quan una funció i quan un servei

Tornem a la pregunta que va quedar oberta a l'apartat 2: si una funció de 2a generació és un Cloud Run, per què triar una funció?

Criteri Cloud Functions (2a gen) Cloud Run
Què desplegues Codi font Un contenidor
Qui construeix la imatge Google, amb buildpacks Tu, amb el teu Dockerfile
Control de l'entorn El runtime que ofereix Google Total
Rutes HTTP Una funció, un punt d'entrada Qualsevol aplicació web completa
Activadors d'esdeveniments Integrats al desplegament Eventarc configurat a part
Corba d'entrada Molt baixa Mitjana
Migració a un altre lloc Depèn de la plataforma Un contenidor corre on sigui
Dependències del sistema Les del runtime Les que instal·lis

Tria Cloud Functions quan:

  • La feina és una sola cosa disparada per un esdeveniment: procesar-imagen-producto és l'exemple perfecte.
  • No necessites dependències del sistema fora del que és habitual.
  • Vols el camí més curt entre "tinc una idea" i "està funcionant".
  • L'equip no vol mantenir Dockerfiles per a cada peça petita.

Tria Cloud Run quan:

  • És una aplicació amb diverses rutes, com el catàleg Flask.
  • Necessites controlar la imatge: una versió concreta d'una biblioteca del sistema, un binari, un model empaquetat.
  • Ja tens un contenidor —AlpinaShop en té des de 02-05—.
  • Vols portabilitat real entre GKE, Cloud Run i qualsevol altre lloc.

Per a AlpinaShop, l'assignació queda així, coherent amb la decisió DA-001 de portar el catàleg a Cloud Run (que es desenvolupa a 07-02):

Peça Elecció Motiu
Catàleg web Flask Cloud Run (07-02) Aplicació completa, contenidor propi, DA-001
procesar-imagen-producto Cloud Function Un esdeveniment, una acció
Webhook de la passarel·la de pagament Cloud Function Endpoint únic amb verificació de signatura
Informes nocturns Workflows + Cloud Run Job (04-06) Feina llarga, no encaixa en 9 minuts

L'advertiment: el monòlit distribuït de funcions

Hi ha un antipatró que apareix quan les funcions agraden massa, i convé reconèixer-lo abans de caure-hi:

flowchart LR
    A[funcio-validar] --> B[funcio-calcular-preu]
    B --> C[funcio-comprovar-stock]
    C --> D[funcio-reservar]
    D --> E[funcio-cobrar]
    E --> F[funcio-notificar]

Sis funcions encadenades per processar una comanda. Sembla modular. En realitat és un monòlit trossejat amb latència de xarxa entre les seves línies de codi, i hereta tots els inconvenients de tots dos móns:

  • Latència acumulada: sis arrencades en fred possibles en lloc d'una.
  • Depuració molt difícil: seguir una petició exigeix correlacionar sis conjunts de registres. És exactament el problema que resol el rastreig distribuït de 06-06, però que és millor no tenir.
  • Sense transaccions: si funcio-cobrar funciona i funcio-notificar falla, el sistema queda en un estat incoherent i cal compensar a mà.
  • Canvis que creuen sis desplegaments: modificar el flux obliga a coordinar sis funcions.
  • Cost multiplicat: sis invocacions i sis vegades la sobrecàrrega.

El senyal d'alarma és senzill: si les teves funcions es criden entre si de manera síncrona, probablement haurien de ser un sol servei. I si de debò necessites un flux de diversos passos amb estat, l'eina correcta no és encadenar funcions: és Workflows, l'orquestració que AlpinaShop ja va triar a 04-06, que gestiona l'estat, els reintents i els errors de manera explícita.

Les funcions brillen quan són fulles de l'arbre: reaccionen a un esdeveniment, fan la seva feina i acaben. Quan comencen a ser nodes intermedis que coordinen altres, s'han triat malament.

Errors Habituals i Consells

El bucle infinit de Cloud Storage. Una funció disparada per un bucket que escriu en aquell mateix bucket. És l'error més car d'aquesta lliçó. Defensa-te'n amb almenys dues de les tres barreres: prefix al filtre de notificació, guarda al codi i bucket diferent per a les sortides.

Suposar que els esdeveniments arriben exactament una vegada. Arriben almenys una vegada. Tota funció per esdeveniments necessita un identificador estable i una comprovació d'idempotència. Sense això, les teves dades tindran duplicats i no sabràs per què.

Reintentar errors permanents. Una imatge corrupta no s'arregla al cinquè intent. Llança excepció només si reintentar pot funcionar; per a la resta, registra i retorna amb normalitat.

Crear els clients dins de la funció. Centenars de mil·lisegons malgastats a cada invocació. Els clients, a nivell de mòdul. Però només clients i constants: qualsevol estat mutable global és una condició de cursa esperant que pugis la concurrència.

Desplegar amb --allow-unauthenticated "per provar". Aquell "per provar" es queda. Fes servir --no-allow-unauthenticated i concedeix run.invoker a qui l'hagi de cridar.

Deixar el compte de servei per defecte. És el de Compute Engine, amb Editor. Un compte propi per funció, amb els permisos exactes.

No posar --max-instances. Un pic d'esdeveniments pot obrir centenars de connexions a Cloud SQL i tombar la base de dades. L'escalat sense límit no és una virtut si el del darrere no escala igual.

Ficar secrets en variables d'entorn. Fes servir --set-secrets amb Secret Manager: no queda a la configuració, no apareix a la consola i es rota sense tornar a desplegar.

Afegir un connector de VPC "per si de cas". Costa diners permanentment. Només si necessites arribar a IP privades.

Utilitzar la 1a generació per copiar un tutorial antic. --gen2 sempre: concurrència, més memòria, més temps i moltes més fonts d'esdeveniments.

I el consell que resumeix l'apartat 13: si les teves funcions es criden les unes a les altres, atura't i replanteja. Probablement sigui un servei, o un Workflow.

Exercicis

Exercici 1: dissenyar una funció idempotent per a comandes

AlpinaShop vol una funció disparada pel topic pedidos-nuevos que enviï un correu de confirmació al client i afegeixi una fila a la taula de facturació a BigQuery. Enviar un correu no és idempotent: el client s'empipa si en rep tres. Dissenya la funció explicant el mecanisme d'idempotència, quins errors reintentaries i quins no, i quina configuració de desplegament faries servir. Indica el punt exacte on una condició de cursa podria produir un correu duplicat i com ho mitigaries.

Exercici 2: decidir entre funció, servei i flux de treball

Per a cadascun d'aquests quatre casos d'AlpinaShop, tria Cloud Function, Cloud Run o Workflows, i justifica la decisió amb criteris d'aquesta lliçó: (a) generar el PDF de la factura d'una comanda, uns 2 segons per factura, disparat després del pagament; (b) el tauler d'administració intern, unes 30 rutes HTTP, utilitzat per 5 persones en horari d'oficina; (c) el procés nocturn que recalcula recomanacions, uns 40 minuts, amb 6 passos seqüencials dependents; (d) redimensionar imatges d'opinions de clients, amb pics de 500 imatges en pocs minuts després d'una campanya.

Exercici 3: diagnosticar una factura inesperada

Un dilluns, la Marta veu que la factura del cap de setmana ha estat 40 vegades l'habitual. Les dades: procesar-imagen-producto va registrar 1,2 milions d'invocacions en 48 hores davant de les 600 habituals; la taula imagenes_vision té 1,2 milions de files noves, moltes amb la mateixa ruta_gcs; el bucket alpinashop-catalogo ha crescut 300 GB; i el DLQ imagenes-subidas-dlq és buit. El divendres es va desplegar un canvi "menor": guardar també una versió en blanc i negre de cada foto. Diagnostica la causa, explica per què el DLQ buit és una pista i no una tranquil·litat, i detalla les mesures de contenció immediata i de prevenció.

Solucions

Solució 1

El problema central: la funció té dos efectes amb propietats oposades. Inserir a BigQuery és controlable amb row_ids; enviar un correu és irreversible. Un cop enviat, no hi ha desfer.

Mecanisme d'idempotència amb marca prèvia a l'enviament. La clau és registrar la intenció abans de l'acció irreversible, amb una escriptura condicional atòmica. Firestore encaixa millor que BigQuery aquí perquè ofereix transaccions i latència baixa:

from google.cloud import firestore

db = firestore.Client()
client_bq = bigquery.Client()

@functions_framework.cloud_event
def confirmar_pedido(esdeveniment):
    missatge = json.loads(base64.b64decode(esdeveniment.data["message"]["data"]))
    id_comanda = missatge["id_pedido"]
    id_esdeveniment = esdeveniment["id"]   # identificador únic del missatge Pub/Sub

    ref = db.collection("correus_enviats").document(id_comanda)

    # 1. Reserva atòmica: create() falla si el document ja existeix.
    #    És una operació atòmica del costat del servidor, no un "llegir i escriure".
    try:
        ref.create({
            "estat": "enviant",
            "id_esdeveniment": id_esdeveniment,
            "iniciat_a": firestore.SERVER_TIMESTAMP,
        })
    except google.api_core.exceptions.AlreadyExists:
        doc = ref.get().to_dict()
        if doc["estat"] == "enviat":
            print(f"Correu ja enviat per a {id_comanda}, s'omet")
            return                       # confirma sense reintentar
        # Estat "enviant": una altra invocació hi és o va morir a mitges
        if antiguitat(doc["iniciat_a"]) < 300:
            print(f"Una altra invocació processant {id_comanda}")
            return
        print(f"ADVERTIMENT: reserva òrfena a {id_comanda}, es reintenta")

    # 2. Acció irreversible
    try:
        enviar_correu_confirmacio(missatge)
    except ErrorTransitoriCorreu as e:
        ref.delete()                     # alliberar la reserva per poder reintentar
        raise                            # excepció → Pub/Sub reintenta
    except ErrorPermanentCorreu as e:
        ref.update({"estat": "fallit", "error": str(e)})
        registrar_per_revisio(id_comanda, e)
        return                           # NO reintentar

    ref.update({"estat": "enviat", "enviat_a": firestore.SERVER_TIMESTAMP})

    # 3. Acció idempotent per disseny: es pot repetir sense dany
    client_bq.insert_rows_json(TAULA_FACTURACIO, [fila_de(missatge)],
                               row_ids=[id_comanda])

Classificació d'errors:

Error Reintentar? Tractament
Timeout del servidor de correu Sí Alliberar reserva + excepció
5xx del proveïdor de correu Sí Igual
Adreça de correu invàlida No Marcar fallit, avisar atenció al client
Falta un camp obligatori del missatge No Registrar i retornar; és un error de l'emissor
Permís denegat a BigQuery No ajuda Error greu + alerta: és una fallada de configuració

Desplegament:

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í que es justifica: un client que acaba de pagar espera la seva confirmació, i dos segons d'arrencada en fred en aquell moment es noten.

La condició de cursa i la seva mitigació. El buit és entre el create() i l'enviar_correu(). Si la instància mor just allà, la reserva queda en estat enviant per sempre i el client mai no rep el correu, perquè els relliuraments veuran la reserva i es retiraran.

El codi ho mitiga amb el temporitzador de 300 segons: una reserva enviant més antiga que això es considera òrfena i es reintenta. La contrapartida és explícita i cal assumir-la: si la instància no va morir sinó que simplement va trigar molt, s'enviaran dos correus.

I aquí hi ha la lliçó de fons: amb una acció externa irreversible no existeix la garantia d'exactament una vegada. Només pots triar de quin costat fallar:

Estratègia Risc Quan triar-la
Marcar abans d'enviar Pot no enviar-se Quan duplicar és pitjor (cobraments)
Marcar després d'enviar Pot enviar-se dues vegades Quan no enviar és pitjor (avisos)
Marcar abans + temporitzador Tots dos, amb baixa probabilitat Compromís raonable

Per a un correu de confirmació, un duplicat ocasional és molest però innocu, mentre que no enviar-lo genera una trucada a atenció al client. Per això el temporitzador és l'elecció correcta aquí. Per a un càrrec en targeta, la resposta seria la contrària, i la solució adequada passaria per una clau d'idempotència del propi proveïdor de pagament.

Solució 2

(a) PDF de factura: Cloud Function. És el cas canònic: un esdeveniment, una acció acotada, 2 segons de feina, sense dependències exòtiques. Disparada per pedidos-nuevos o per un topic propi de "pagament confirmat", amb --memory=512Mi i --max-instances moderat. Matís: si la generació del PDF necessités tipografies corporatives o LaTeX, la dependència del sistema empenyeria cap a Cloud Run amb un contenidor propi.

(b) Tauler d'administració: Cloud Run. Trenta rutes HTTP són una aplicació, no una funció. Una Cloud Function té un punt d'entrada, i ficar-hi un encaminador a dins seria fer servir l'eina al revés. A més el patró d'ús —cinc persones en horari d'oficina— fa que escalar a zero les nits i els caps de setmana sigui ideal, i això és precisament Cloud Run. --min-instances=0, i si l'arrencada en fred molesta a primera hora, --min-instances=1 només en horari laboral. És coherent amb DA-001.

(c) Recàlcul nocturn de 40 minuts: Workflows. Dues raons, cadascuna suficient. Primera, 40 minuts superen el límit de 9 minuts d'una funció per esdeveniments. Segona, i més important: sis passos seqüencials dependents són orquestració, i encadenar-los amb funcions seria el monòlit distribuït de l'apartat 13. Workflows —ja triat a 04-06— gestiona l'estat, els reintents per pas i els errors explícitament, i cada pas invoca el que correspongui: un Cloud Run Job, un treball de BigQuery o un pipeline de Vertex AI.

(d) Redimensionar imatges d'opinions: Cloud Function. És idèntic a procesar-imagen-producto: un esdeveniment, una acció, sense estat. Els pics de 500 imatges són exactament on el model brilla —escala sol i torna a zero després—. Configuració: --memory=1Gi, --max-instances=100 perquè el pic s'absorbeixi ràpid, --min-instances=0 perquè ningú no espera en temps real, i --retry amb idempotència per generation.

Cas Elecció Criteri decisiu
(a) PDF de factura Cloud Function Un esdeveniment, una acció
(b) Tauler d'administració Cloud Run Aplicació amb moltes rutes
(c) Recàlcul nocturn Workflows Supera 9 min + és orquestració
(d) Imatges d'opinions Cloud Function Esdeveniment, sense estat, pics

El criteri que unifica els quatre: compta quantes coses diferents fa la peça i quant triga. Una cosa i poc temps → funció. Moltes rutes → servei. Molts passos coordinats → flux de treball. I quan dubtis entre funció i servei per a alguna cosa que ja està contenidoritzada, Cloud Run gairebé sempre guanya per portabilitat.

Solució 3

Diagnòstic: un bucle infinit, exactament el de l'apartat 6.

El canvi "menor" del divendres va afegir l'escriptura d'una versió en blanc i negre al mateix bucket, i amb tota probabilitat en un prefix que no és productos/miniaturas/, que és l'únic que la guarda del codi comprova. Reconstruint la seqüència:

  1. Es puja productos/piolet.jpg → esdeveniment → la funció la processa.
  2. Escriu productos/miniaturas/piolet.jpg → esdeveniment → ignorada per la guarda. Correcte.
  3. Escriu productos/bn/piolet.jpg → esdeveniment → la guarda NO ho cobreix → es processa.
  4. En processar productos/bn/piolet.jpg escriu productos/bn/bn/piolet.jpg → esdeveniment → es processa…

Cada nivell genera el següent. El creixement és exponencial fins que alguna cosa el frena. Les quatre evidències encaixen sense excepció: 1,2 milions d'invocacions (recursió); files repetides amb la mateixa ruta_gcs a imagenes_vision (cada nivell escriu una fila, i la idempotència no protegeix perquè cada objecte derivat és una ruta diferent); 300 GB de creixement (els objectes generats); i la factura disparada, dominada per la Vision API, no per la funció.

Per què el DLQ buit és una pista i no una tranquil·litat. L'instint diu "el DLQ és buit, res no ha fallat". És exactament al revés: el DLQ buit confirma que tot va funcionar correctament. La funció no tenia cap error; feia perfectament el que se li va demanar, un milió dues-centes mil vegades. És el perill dels sistemes per esdeveniments: la fallada cara no produeix errors, produeix èxits. Cap alerta basada en taxa d'error no hauria detectat això. La que sí que ho hauria detectat és una alerta sobre el volum d'invocacions —tema de 06-04—, i la seva absència és la veritable troballa de l'incident.

Contenció immediata, en aquest ordre:

# 1. TALLAR JA: max-instances a 0 atura el processament sense esborrar res
gcloud functions deploy procesar-imagen-producto --gen2 \
  --region=europe-west1 --max-instances=0 --project=alpinashop-prod

# 2. Buidar la cua d'esdeveniments pendents, que pot 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. Mesurar l'abast abans d'esborrar res
gcloud storage du -s gs://alpinashop-catalogo/productos/bn/ --project=alpinashop-prod

Posar --max-instances=0 en lloc d'esborrar la funció és deliberat: atura el sagnat de manera immediata, conserva tota la configuració per al diagnòstic i és reversible amb una comanda.

Neteja: esborrar els objectes derivats recursivament (productos/bn/bn/... i següents) conservant el primer nivell si resulta útil; eliminar d'imagenes_vision les files la ruta_gcs de les quals contingui /bn/; i comprovar si algun procés posterior —l'índex de cerca, productos_color de 05-05— va consumir aquelles dades i necessita refer-se.

Prevenció, en cinc mesures:

Mesura Què evita Cost
Escriure les sortides en un altre bucket (alpinashop-derivadas) La recursió, d'arrel Cap
Filtre de notificació amb prefix productos/originales/ Que l'esdeveniment es generi Cap
Guarda per llista blanca, no per llista negra La propera variant oblidada Cap
Alerta sobre invocacions/hora de la funció Detectar-ho en 15 minuts, no en 48 hores Cap
Alerta de pressupost al projecte (01-04) Que es repeteixi qualsevol fuita de cost Cap

La tercera mesura és la lliçó de disseny més transferible. La guarda original era una llista negra: "si comença per productos/miniaturas/, ignorar". Una llista negra falla sempre que apareix un cas nou, i apareixerà. El correcte és una llista blanca:

PREFIX_ORIGINALS = "productos/originales/"

if not ruta.startswith(PREFIX_ORIGINALS):
    print(f"Ignorada, no és un original: {ruta}")
    return

Amb aquella versió, el canvi del divendres hauria estat innocu: la funció només processa el que està explícitament permès. En una arquitectura per esdeveniments, prohibir el que és conegut és fràgil; permetre només el que és conegut és robust.

I la reflexió final de l'incident: no hi va haver una fallada tècnica. Hi va haver un canvi d'una línia, revisat per ningú, en un sistema on escriure en un bucket significa disparar codi. Tres coses ho haurien impedit i les tres són en aquest mòdul: la revisió de codi de 06-02 —algú hauria preguntat on s'escriu el fitxer nou—, la prova unitària de l'apartat 12 —que verifica que no es crida Vision per a objectes derivats—, i l'observabilitat de 06-04 —una alerta de volum que avisa en minuts—. La factura de 40× és el preu de no tenir cap de les tres.

Conclusió

La punta solta del mòdul 5 està tancada. procesar-imagen-producto existeix, es dispara amb imagenes-subidas, crida la Vision API, genera la miniatura, escriu a alpinashop_analitica i no desperta ningú de matinada.

Saps què és FaaS i quina és la seva unitat de desplegament —la funció—, amb les quatre propietats que el defineixen: es dispara per esdeveniments, escala a zero, escala sol cap amunt, i és efímera i sense estat, amb la conseqüència pràctica que tot el que hagi de sobreviure viu fora.

Entens què és avui una Cloud Function de 2a generació: un servei de Cloud Run amb un contenidor construït per Google i activadors gestionats per Eventarc. I saps què et dona això —concurrència configurable en lloc d'una petició per instància, fins a 60 minuts en HTTP, fins a 32 GB, repartiment de trànsit i més de 130 fonts d'esdeveniments— amb la regla clara de fer servir --gen2 sempre.

Saps escriure una funció HTTP amb el Functions Framework i desplegar-la amb les opcions que importen, inclòs --max-instances, perquè l'escalat sense límit no és una virtut si Cloud SQL no escala igual. I saps que gairebé cap funció no ha de ser pública: --no-allow-unauthenticated, run.invoker a qui correspongui i testimoni OIDC al cridant, amb les tres excepcions legítimes i el que exigeix cadascuna.

Coneixes les funcions per esdeveniments, els CloudEvents i la taula de què dispara què —Pub/Sub, Cloud Storage, Firestore i els registres d'auditoria—, i per què AlpinaShop passa per un topic en lloc de disparar directament del bucket: perquè demà un segon consumidor pugui reaccionar sense tocar res.

Tens el cas complet resolt, amb els tres detalls que el fan viable en producció: clients creats a nivell de mòdul, la guarda contra el bucle infinit i el row_ids de BigQuery. I tens la disciplina que separa una funció que funciona d'una en la qual es pot confiar: idempotència amb identificador estable —ruta més generation—, comprovació prèvia acotada en el temps i desduplicació a destinació; la distinció entre errors transitoris que es reintenten i permanents que no; i un dead letter amb alerta, perquè un DLQ que ningú no mira és pitjor que no tenir-lo.

Saps què és una arrencada en fred, les cinc palanques per mitigar-la i —més important— quan importa i quan no: --min-instances on ho percep un client, zero en el processament per lots. Coneixes el perill de les variables globals mutables amb concurrència alta, amb la regla que a nivell de mòdul només hi van clients i constants. Saps injectar configuració amb variables d'entorn i secrets amb --set-secrets des de Secret Manager, donar a cada funció el seu propi compte de servei, i connectar a la VPC amb un connector quan —i només quan— cal arribar a una IP privada.

Tens els límits i el cost amb el seu càlcul orientatiu, i la lliçó econòmica que transcendeix l'exemple: en una arquitectura per esdeveniments, el còmput gairebé mai no és el cost principal; ho són les API que crides i les dades que mous. Saps provar en local, escriure la prova unitària de sis línies que protegeix contra la fallada més cara possible, i desplegar des de Cloud Build.

I tens el criteri de l'apartat 13: funció per a una cosa disparada per un esdeveniment, servei per a una aplicació amb moltes rutes, flux de treball per a diversos passos coordinats, amb l'advertiment contra el monòlit distribuït de funcions i el seu senyal d'alarma —si les teves funcions es criden entre si de manera síncrona, replanteja—.

Ara mira l'estat d'AlpinaShop. Hi ha una botiga a GKE, una funció processant imatges, pipelines de dades, models entrenant-se sols, un pipeline de CI/CD i una infraestructura de xarxa que sosté tot això. Moltes peces. Moltes més de les que cabien fa tres mòduls.

I continua havent-hi una sola manera de saber si funcionen: que un client escrigui un correu dient que el web va lent.

Aquest és el problema de 06-04. Toca deixar de mirar la consola i començar a mesurar: mètriques, taulers i alertes amb Cloud Monitoring, per assabentar-se dels problemes abans que els clients.

Curs de Google Cloud Platform (GCP)

Mòdul 1: Introducció a Google Cloud Platform

Mòdul 2: Serveis principals de GCP

Mòdul 3: Xarxes i seguretat

Mòdul 4: Dades i anàlisi

Mòdul 5: Aprenentatge automàtic i IA

Mòdul 6: DevOps i monitoratge

Mòdul 7: Temes avançats de GCP

Mòdul 8: Projecte final

© Copyright 2026. Tots els drets reservats