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
- Què és FaaS i quina és la unitat de desplegament
- Cloud Functions avui: la 2a generació sobre Cloud Run i Eventarc
- Funcions HTTP: la primera funció i el seu desplegament
- Autenticació: per què gairebé mai no han de ser públiques
- Funcions per esdeveniments: CloudEvents i Eventarc
- El cas pendent:
procesar-imagen-productode principi a fi - Idempotència, reintents i dead letter
- El cicle de vida real: arrencades en fred i concurrència
- Configuració, secrets i identitat
- Connexió a la VPC per arribar a Cloud SQL
- Límits i cost
- Proves locals i desplegament des de Cloud Build
- Quan una funció i quan un servei
- 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.
- 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.
- 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:
# 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}), 200Tres detalls que convé notar:
- El decorador
@functions_framework.httpmarca 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-prodCada 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.
- 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-prodI 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.
- 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 tipusEventarc é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-prodUna 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.
- El cas pendent:
procesar-imagen-producto de principi a fi
procesar-imagen-producto de principi a fiAquí 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-prodEl --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.
- 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 NoneLa 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 reintentaDead 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-prodDespré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—.
- 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-prodQuan 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ó.
- 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-prodSecrets 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-prodAmb 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.
- 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-prodDetalls que importen:
- El rang
/28ha d'estar lliure i no encavalcar-se ambsn-web-euw1nisn-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=2pagues 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-onlyenvia per la VPC només el trànsit a rangs privats; la resta surt per internet directament. Amball-traffic, tot passa per la VPC i surt pel Cloud NATalpinashop-nat-euw1de 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.
- 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ò:
- Bucles infinits (apartat 6). El més car amb diferència.
--min-instancesen funcions que no el necessiten. És cost fix 24×7 i anul·la l'avantatge del model.- Timeout llarg amb errors penjats. Una funció amb
--timeout=540sque es queda esperant un servei caigut paga nou minuts per invocació fallida. Timeouts curts i ajustats a la realitat.
- 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=8080I 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_ONLYEl 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ó"—.
- 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-cobrarfunciona ifuncio-notificarfalla, 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:
- Es puja
productos/piolet.jpg→ esdeveniment → la funció la processa. - Escriu
productos/miniaturas/piolet.jpg→ esdeveniment → ignorada per la guarda. Correcte. - Escriu
productos/bn/piolet.jpg→ esdeveniment → la guarda NO ho cobreix → es processa. - En processar
productos/bn/piolet.jpgescriuproductos/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-prodPosar --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}")
returnAmb 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
- Què és Google Cloud Platform?
- Configuració del teu compte de GCP
- Descripció general de la consola de GCP
- Projectes, jerarquia de recursos i facturació
- Regions, zones i model de responsabilitat compartida
- Cloud Shell i la CLI de gcloud
Mòdul 2: Serveis principals de GCP
- Compute Engine: màquines virtuals a Google Cloud
- Cloud Storage: emmagatzematge d'objectes
- Cloud SQL: bases de dades relacionals gestionades
- App Engine: plataforma com a servei
- Google Kubernetes Engine (GKE)
- Bases de dades NoSQL: Firestore, Bigtable i Spanner
- Com triar el servei de còmput adequat
Mòdul 3: Xarxes i seguretat
- Xarxes VPC
- Balanceig de càrrega al núvol
- Cloud CDN
- Gestió d'identitat i accés (IAM)
- Cloud Armor
- Secrets i xifratge: Secret Manager i Cloud KMS
- Cloud DNS, certificats TLS i publicació segura de serveis
Mòdul 4: Dades i anàlisi
- BigQuery: el magatzem de dades analític
- Cloud Dataflow: processament de dades per lots i en temps real
- Cloud Dataproc: Spark i Hadoop gestionats
- Cloud Pub/Sub: missatgeria asíncrona
- Cloud Data Fusion: integració de dades sense codi
- Orquestració de pipelines amb Cloud Composer i Workflows
- Govern de les dades i taulers amb Dataplex i Looker Studio
Mòdul 5: Aprenentatge automàtic i IA
- Vertex AI: la plataforma d'aprenentatge automàtic de GCP
- AutoML: models a mida sense escriure codi
- TensorFlow a GCP: entrenament i servei de models
- API de llenguatge natural
- API de visió
- IA generativa a Vertex AI: models Gemini i incrustacions
- MLOps: del model al producte amb Vertex AI Pipelines
Mòdul 6: DevOps i monitoratge
- Cloud Build: integració contínua a GCP
- Cloud Source Repositories i gestió del codi font
- Cloud Functions: funcions sense servidor
- Cloud Monitoring (abans Stackdriver): mètriques, taulers i alertes
- Cloud Deployment Manager i infraestructura com a codi nativa
- Cloud Logging i Cloud Trace: registres, traces i diagnòstic
- Terraform a GCP: infraestructura com a codi a la pràctica
Mòdul 7: Temes avançats de GCP
- Híbrid i multinúvol amb Anthos
- Computació sense servidor amb Cloud Run
- Xarxes avançades: VPC compartida, aparellament i connectivitat híbrida
- Bones pràctiques de seguretat
- Gestió i optimització de costos
- Fiabilitat: SLO, alta disponibilitat i recuperació de desastres
- Govern a escala: organització, polítiques i auditoria
