La lliçó anterior va acabar amb dues preguntes. La primera: cal un Deployment a EKS, amb les seves rèpliques, les seves sondes, el seu HPA i la seva guàrdia, per generar tres miniatures cada vegada que la Formatgeria Montblanc puja una foto, o per produir un PDF de factura quan arriba un pagament.confirmat? Són tasques petites, esporàdiques, sense estat, que responen a un esdeveniment i acaben. La segona: per què la foto de formatge-curat ha de viatjar des de la regió eu-west-1 fins al mòbil de l'Anna a València a cada visita, pagant egress i 40 ms de latència, quan podria estar desada a vint quilòmetres d'ella? I n'hi ha una tercera, que ja va treure el cap a 08-02: què fa furgoneta-3 amb les seves posicions durant un túnel de tres minuts, o la parada de l'Horta La Vega al mercat de Lleida quan la connexió cau i continuen arribant clients? Les tres preguntes tenen respostes que surten del model "servei en un clúster en una regió": serverless, on el proveïdor executa funcions efímeres en resposta a esdeveniments i cobra per invocació; i edge computing, on el còmput i les dades s'acosten a qui els fa servir, sigui una CDN, una funció a la vora de la xarxa o un dispositiu que funciona sense connexió. Aquesta lliçó desenvolupa tots dos, amb el seu model d'execució, els seus límits, els seus casos adequats i inadequats a Quilòmetre Zero, i el codi que els implementa: una Lambda de miniatures, una de factures, la saga de comanda reescrita en Step Functions, un Worker de Cloudflare que verifica el JWT a la vora i un agregador local per a la furgoneta. El projecte final (08-05) ho integrarà tot.
Contingut
- Serverless i FaaS: el model d'execució
- Arrencada en fred, límits, concurrència i preu
- Casos adequats i inadequats: els quatre de Quilòmetre Zero
- Serverless més enllà de FaaS: cues, bases de dades, API Gateway i Step Functions
- Patrons: fan-out, idempotència obligatòria i DLQ
- Observabilitat, proves locals, lock-in i cost a gran escala
- Codi serverless: miniatures, factures, SAM i la saga en Step Functions
- Edge computing: per què acostar el còmput
- CDN: desar fotos i catàleg a la memòria cau a prop de l'Anna
- Funcions a la vora: verificació de JWT i personalització
- Edge per a IoT: la furgoneta i el mercat sense connexió
- El continu núvol-edge-dispositiu i la seguretat a la vora
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Serverless i FaaS: el model d'execució
"Serverless" no vol dir que no hi hagi servidors: vol dir que no són nostres ni els veiem. El proveïdor els aprovisiona, els escala i els retira; nosaltres lliurem codi i paguem pel que s'executa. La forma més pura és FaaS (Function as a Service): AWS Lambda, Google Cloud Functions, Azure Functions. A 08-03 la taula de responsabilitat compartida ja ho anomenava; aquest és el seu model d'execució:
- Es produeix un esdeveniment: un objecte arriba a un bucket, un missatge entra en una cua o un tòpic, una petició HTTP arriba a un endpoint, un temporitzador salta.
- El proveïdor busca una instància de la funció a punt (un microcontenidor amb el runtime de Python i el nostre codi). Si no n'hi ha cap de lliure, en crea una (arrencada en fred).
- Invoca el gestor (
handler(esdeveniment, context)) amb l'esdeveniment serialitzat. La funció fa la seva feina i retorna. - La instància queda congelada uns minuts per si arriba un altre esdeveniment; si no arriba, es destrueix. No hi ha estat entre invocacions en què es pugui confiar (el sistema de fitxers temporal pot sobreviure, però no està garantit).
- Si arriben mil esdeveniments alhora, el proveïdor crea fins a mil instàncies en paral·lel (escalat a milers); si no n'arriba cap, no n'hi ha cap (escalat a zero) i no es paga res.
La comparació amb el Deployment de 07-05 resumeix la diferència de model:
| Aspecte | Servei a Kubernetes (07-05) | Funció serverless |
|---|---|---|
| Unitat | Contenidor de llarga vida amb diverses rèpliques | Funció efímera, una invocació per instància (a Lambda) |
| Escalat | HPA per mètrica, de n a m rèpliques, en desenes de segons | Automàtic per esdeveniment, de 0 a milers, en mil·lisegons o segons |
| Cost en repòs | Les rèpliques mínimes es paguen sempre | Zero |
| Estat | En memòria mentre viu el Pod; a Redis o base de dades | Només extern (S3, DynamoDB, base de dades) |
| Connexions | Pool de connexions persistent a PostgreSQL, Kafka | Cada instància obre les seves; milers d'instàncies esgoten les connexions d'una base de dades (cal un proxy: RDS Proxy) |
| Durada | Il·limitada (un consumidor de Kafka corre dies) | Limitada (15 min a Lambda) |
| Desplegament | Imatge + manifest + rollout | Paquet de codi (zip o imatge) + definició de l'esdeveniment |
| Operació | Sondes, recursos, guàrdia per SLO | Sense servidors a operar; sí mètriques, errors i DLQ a vigilar |
- Arrencada en fred, límits, concurrència i preu
Arrencada en fred (cold start): crear la instància, carregar el runtime i executar el codi d'inicialització (imports, connexions) abans de la primera invocació. En Python amb poques dependències són 100-300 ms; amb Pillow, boto3 i un client de Kafka, 1-2 s; dins d'una VPC, una mica més. És acceptable per processar una foto i no per respondre a l'Anna. Es mitiga amb concurrència aprovisionada (instàncies mantingudes calentes, que es paguen encara que no es facin servir: es perd l'escalat a zero), amb paquets petits, i traient la inicialització pesada fora del gestor perquè s'executi una vegada per instància i no per invocació.
Límits: temps màxim per invocació (15 min a Lambda, 9-60 min a Cloud Functions segons la generació), memòria (de 128 MB a 10 GB; la CPU s'assigna en proporció a la memòria, així que una funció lenta de vegades s'accelera donant-li memòria), mida del paquet (250 MB descomprimit; o una imatge de contenidor de fins a 10 GB), mida de l'esdeveniment (6 MB síncron, 256 KB asíncron), espai temporal (/tmp de fins a 10 GB).
Concurrència: el nombre d'instàncies simultànies. Té un límit per compte i regió (1 000 per defecte a Lambda, ampliable) i es pot reservar per funció (perquè una funció descontrolada no esgoti la quota de les altres) o limitar (per no esgotar la base de dades del darrere: si km0_analitica accepta 100 connexions, la funció que hi escriu no pot tenir 500 instàncies). Aquest límit és el bulkhead de 07-04 en versió serverless.
Preu: per nombre d'invocacions (de l'ordre de 0,20 € per milió) més per GB-segon d'execució (memòria assignada × durada; de l'ordre de 0,000017 € per GB-s), amb un tram gratuït mensual. Una funció de miniatures amb 512 MB que triga 800 ms costa ≈ 0,000007 € per foto: 100 000 fotos al mes, menys d'1 €. És el que fa que serverless sigui imbatible per a càrregues esporàdiques i el que el fa car per a càrregues constants (apartat 6).
- Casos adequats i inadequats: els quatre de Quilòmetre Zero
| Característica de la càrrega | Adequat per a FaaS | Inadequat per a FaaS |
|---|---|---|
| Freqüència | Esporàdica o amb pics enormes i imprevisibles | Constant i alta (un consumidor que processa 500 esdeveniments/s tot el dia) |
| Durada | Segons, com a molt minuts | Hores (un treball de Spark; un consumidor de Kafka de llarga vida) |
| Estat | Sense estat; tot en serveis externs | Estat en memòria entre peticions (la taula de subscripcions WebSocket de 08-02) |
| Latència | Tolera centenars de ms (processament en segon pla) | p99 < 500 ms exigit a persones (l'SLO de comandes) |
| Connexions | Poques, breus, a serveis que escalen (S3, DynamoDB, SQS) | Pool persistent a PostgreSQL, sessions WebSocket, connexions MQTT |
| Tret | Un esdeveniment amb nom (objecte, missatge, petició, temporitzador) | Un bucle que sondeja o un procés que escolta un port |
Amb aquest filtre, Quilòmetre Zero identifica quatre tasques que avui viuen com a codi dins dels serveis (o com a cron jobs a Kubernetes) i que encaixen millor com a funcions:
- Miniatures en pujar una foto a
km0-fotos. A 04-03 la Formatgeria Montblanc pujavafotos/formatge-curat/original.jpgper URL presignada i "un procés de miniatures" generava les de 300 i 800 px. Aquell procés era un consumidor amb un bucle. Com a funció: esdeveniments3:ObjectCreated→ Lambda amb Pillow → escriuminiatura-300.jpgiminiatura-800.jpg. Esporàdic (centenars de fotos al dia, amb pics quan un productor puja el catàleg sencer), sense estat, 1 s per foto. - Factura PDF al
pagament.confirmat. Avui és un consumidor decomandes.esdevenimentsdins decomandes. Com a funció: esdeveniment de Kafka (MSK com a origen d'esdeveniments de Lambda, per lots) → generar el PDF → escriure'l akm0-factures→ notificar. Ritme de les comandes: milers al dia, pics en campanya, sense estat. - Webhooks de la passarel·la de pagaments. La passarel·la notifica de manera asíncrona els cobraments diferits i les devolucions amb un
POSTa una URL nostra. És un endpoint que rep poques crides, ha d'estar sempre disponible, verificar una signatura i publicar un esdeveniment: API Gateway + Lambda, sense ocupar una rèplica depagamentsper esperar. - Tasques programades lleugeres. Caducar reserves d'estoc no confirmades cada minut, netejar sessions, comprovar que el DAG d'Airflow ha acabat: temporitzador (EventBridge) → Lambda. Substitueixen CronJobs de Kubernetes que ocupaven recursos per executar 200 ms de feina.
I el que no es mou a funcions, amb la raó: comandes i cataleg (latència exigida a persones, pool de connexions, Kong i mesh al davant), el servidor WebSocket de 08-02 (estat i connexions llargues; hi ha serveis gestionats de WebSocket, però el fan-out entre instàncies i la lògica de subscripció són justament el que no hi encaixa), els consumidors de Flink (estat i checkpoints), els treballs de Spark (hores).
- Serverless més enllà de FaaS: cues, bases de dades, API Gateway i Step Functions
FaaS és la part visible; al seu voltant hi ha serveis "serverless" en el sentit de sense capacitat a aprovisionar i amb pagament per ús:
| Servei | Què és | Equivalent que Quilòmetre Zero ja té | Quan preferir-lo |
|---|---|---|---|
| Cues (SQS, Cloud Tasks) | Cua gestionada, escalat il·limitat, pagament per missatge, amb DLQ integrada | RabbitMQ (02-04) | Per connectar funcions entre si i absorbir pics sense operar un broker |
| Bases de dades serverless (DynamoDB, Aurora Serverless, Firestore) | Clau-valor o relacional que escala capacitat automàticament, pagament per petició o per capacitat consumida | Cassandra (04-04), RDS (08-03) | DynamoDB per a l'estat de les funcions (taula d'idempotència, checkpoints); Aurora Serverless per a bases amb càrrega molt variable |
| API Gateway gestionat | Endpoint HTTP que encamina a funcions, amb autenticació (JWT), límits i claus d'API | Kong (06-05) | Per exposar funcions (webhooks) sense passar pel clúster; amb menys plugins que Kong |
| Step Functions / Workflows | Orquestrador de fluxos amb estat: defineix passos, branques, reintents i compensacions en JSON, i executa cada pas invocant funcions o serveis | saga_comanda.py amb la taula sagas (03-05) |
Quan la saga es compon de funcions i no es vol escriure ni operar l'orquestrador |
Step Functions mereix atenció perquè substitueix una cosa que el curs va construir amb esforç. L'OrquestradorSagaComanda de 03-05 persistia l'estat de cada saga a la taula sagas, reintentava les fallades transitòries, distingia les permanents i executava les compensacions en ordre invers. Step Functions fa exactament això com a servei: l'estat de cada execució el desa el proveïdor (amb l'historial complet de cada pas), els reintents i els Catch es declaren per pas, i les compensacions són branques del graf. El que es perd: l'orquestrador viu fora del clúster, cada pas és una invocació (amb la seva latència i el seu cost), la definició és JSON i no Python, i és el més específic del proveïdor que hi ha (lock-in total). La comparació completa és a l'apartat 7, amb la saga reescrita.
- Patrons: fan-out, idempotència obligatòria i DLQ
Fan-out amb cues. Un esdeveniment que s'ha de processar de diverses maneres (una foto pujada: miniatures, anàlisi de contingut, actualització de la fitxa) no dispara tres funcions directament des d'S3; es publica en un tòpic (SNS o EventBridge) que el lliura a una cua per consumidor (SQS), i cada cua dispara la seva funció. Les cues desacoblen (si la funció d'anàlisi falla, les miniatures es generen igualment), absorbeixen pics (un productor que puja 2 000 fotos no crea 2 000 × 3 instàncies de cop: la cua les dosifica amb el límit de concurrència) i donen reintents i DLQ.
Idempotència obligatòria. El proveïdor lliura els esdeveniments almenys una vegada i reintenta automàticament quan la funció falla o esgota el temps: una funció que triga 16 minuts per un pic de mida es torna a invocar, i si la primera instància havia escrit la meitat del resultat, la segona parteix d'aquest estat. Tot el que es va veure a 02-05 sobre consumidors idempotents s'aplica amb més força, perquè aquí els reintents no els controlem nosaltres: cada funció ha de ser segura davant la repetició. Les tècniques: claus de sortida deterministes (la miniatura s'escriu sempre a la mateixa clau: escriure-la dues vegades és inofensiu), taula d'idempotència a DynamoDB amb l'id de l'esdeveniment (la factura F-2026-000124 es genera una sola vegada), i operacions condicionals (PutItem amb attribute_not_exists).
DLQ. Després de n reintents (configurable per funció o per cua), l'esdeveniment va a una cua de missatges morts, amb alerta (07-01) i procediment de reprocessament: la DLQ de 02-05, gestionada. Sense DLQ configurada, un esdeveniment enverinat (una imatge corrupta que fa fallar Pillow) es reintenta fins a esgotar la política i es perd en silenci.
- Observabilitat, proves locals, lock-in i cost a gran escala
Observabilitat. Les funcions emeten logs a CloudWatch (o Cloud Logging) automàticament, i mètriques d'invocacions, errors, durada i throttles. El que cal afegir és el mateix que a 07-02: logs estructurats amb l'X-Request-Id o l'id_esdeveniment per correlacionar, i traces amb OpenTelemetry (o X-Ray) que enllacin la funció amb el servei que va produir l'esdeveniment, perquè una petició de l'Anna que acaba en una factura travessa comandes, Kafka i la Lambda, i sense propagació de context la traça es talla a Kafka. El serveis/comu/traces.py de 07-02 s'empaqueta amb la funció com a capa (layer).
Proves locals. Una funció no es pot executar "sense el proveïdor" si no és amb emuladors: AWS SAM (sam local invoke, sam local start-api) executa la funció en un contenidor amb un esdeveniment d'exemple; LocalStack emula S3, SQS, DynamoDB i més en Docker, cosa que permet proves d'integració amb Testcontainers (07-06) sense compte d'AWS. Cap dels dos és perfecte (els permisos IAM i els límits reals només es veuen al proveïdor), així que el pipeline desplega a un entorn de staging real per a les proves de contracte.
Lock-in. És el més alt de tot el que s'ha vist: el format de l'esdeveniment, el gestor, els serveis del voltant (SQS, DynamoDB, Step Functions) són del proveïdor. Es mitiga separant el gestor (adaptador de 10 línies) de la lògica (funcions Python pures, provables sense proveïdor), com es fa a l'apartat 7; però cal acceptar-ho: qui tria Step Functions tria AWS.
Cost a gran escala. El preu per invocació és imbatible a baixa freqüència i es torna car a alta: una funció amb 512 MB que corre constantment (per exemple, 100 invocacions/s de 200 ms cadascuna, 24 h al dia) consumeix ≈ 2,6 milions de GB-s al mes ≈ 45 €, més 260 milions d'invocacions ≈ 52 €: uns 100 €/mes, davant de ≈ 30 € d'un Pod petit en un node ja pagat. La regla aproximada: quan la utilització mitjana supera el 30-40 % d'un contenidor equivalent, el contenidor surt més barat. Per això les miniatures i les factures són serverless i comandes no.
- Codi serverless: miniatures, factures, SAM i la saga en Step Functions
L'estructura afegida a km0/:
km0/serverless/
├── template.yaml # AWS SAM: funcions, esdeveniments, permisos, DLQ
├── miniatures/
│ ├── handler.py
│ └── requirements.txt # Pillow, boto3
├── factures/
│ ├── handler.py
│ └── requirements.txt # reportlab, boto3
└── saga/
└── saga_comanda.asl.json # Step Functions (Amazon States Language)El flux S3 → Lambda
flowchart LR
Q[Formatgeria Montblanc<br/>PUT per URL presignada] --> S3[(S3 km0-fotos<br/>fotos/formatge-curat/original.jpg)]
S3 -- "s3:ObjectCreated:*<br/>prefix fotos/, sufix original.jpg" --> L[Lambda miniatures<br/>Pillow, 1024 MB, 60 s]
L -- "PUT miniatura-300.jpg<br/>PUT miniatura-800.jpg" --> S3
L -- "esdeveniment foto.processada" --> EB[EventBridge]
EB --> CAT[cataleg: actualitza la fitxa]
L -. "fallada despres de 2 reintents" .-> DLQ[(SQS DLQ miniatures)]
DLQ -. alerta .-> OPS[Alerta 07-01]
serverless/miniatures/handler.py
# km0/serverless/miniatures/handler.py
"""Genera miniatures de 300 i 800 px en pujar un original a km0-fotos.
Idempotent: les claus de sortida són deterministes i l'ETag de l'original es desa
com a metadada de cada miniatura; si ja coincideix, no es regenera.
"""
import io, json, os, urllib.parse
import boto3
from PIL import Image
# Inicialització FORA del gestor: s'executa una vegada per instància (arrencada en fred),
# no una vegada per invocació. Aquí van els clients i tot el que és costós.
s3 = boto3.client("s3")
eventbridge = boto3.client("events")
MIDES = (300, 800)
BUS_ESDEVENIMENTS = os.environ.get("KM0_BUS_ESDEVENIMENTS", "km0")
def _clau_miniatura(clau_original: str, px: int) -> str:
# fotos/formatge-curat/original.jpg -> fotos/formatge-curat/miniatura-300.jpg (convenció de 04-03)
prefix = clau_original.rsplit("/", 1)[0]
return f"{prefix}/miniatura-{px}.jpg"
def _ja_generada(bucket: str, clau: str, etag_original: str) -> bool:
try:
capcalera = s3.head_object(Bucket=bucket, Key=clau)
return capcalera.get("Metadata", {}).get("origen-etag") == etag_original
except s3.exceptions.ClientError:
return False
def processar_objecte(bucket: str, clau: str, etag_original: str) -> list[str]:
"""Lògica pura de negoci: provable sense AWS passant un client fals a s3."""
cos = s3.get_object(Bucket=bucket, Key=clau)["Body"].read()
imatge = Image.open(io.BytesIO(cos))
imatge = imatge.convert("RGB") # PNG amb alfa -> JPEG
generades = []
for px in MIDES:
desti = _clau_miniatura(clau, px)
if _ja_generada(bucket, desti, etag_original): # reintent o duplicat: no refer
generades.append(desti)
continue
copia = imatge.copy()
copia.thumbnail((px, px)) # conserva la proporció; mai amplia
sortida = io.BytesIO()
copia.save(sortida, format="JPEG", quality=85, optimize=True)
s3.put_object(
Bucket=bucket, Key=desti, Body=sortida.getvalue(), ContentType="image/jpeg",
CacheControl="public, max-age=31536000, immutable", # la CDN (apartat 9) la desa un any
Metadata={"origen-etag": etag_original}, # marca d'idempotència
)
generades.append(desti)
return generades
def handler(esdeveniment: dict, context) -> dict:
"""Adaptador: tradueix l'esdeveniment d'S3 a crides a la lògica. 10 línies, sense negoci."""
resultats = []
for registre in esdeveniment["Records"]: # S3 pot agrupar diversos objectes en un esdeveniment
bucket = registre["s3"]["bucket"]["name"]
clau = urllib.parse.unquote_plus(registre["s3"]["object"]["key"]) # les claus arriben codificades com a URL
etag = registre["s3"]["object"].get("eTag", "")
if not clau.endswith("/original.jpg"):
continue # defensa: el filtre de SAM ja ho fa, però no refiar-se'n
generades = processar_objecte(bucket, clau, etag)
eventbridge.put_events(Entries=[{
"Source": "km0.fotos", "DetailType": "foto.processada", "EventBusName": BUS_ESDEVENIMENTS,
"Detail": json.dumps({"bucket": bucket, "original": clau, "miniatures": generades}),
}])
print(json.dumps({"nivell": "info", "msg": "miniatures generades", "clau": clau,
"n": len(generades), "request_id": context.aws_request_id})) # log estructurat (07-02)
resultats.append(clau)
return {"processades": resultats}Decisions a destacar: la idempotència es basa en l'ETag de l'original desat com a metadada de la miniatura, de manera que una reinvocació no refà feina i una foto nova amb la mateixa clau (ETag nou) sí que la refà; CacheControl: immutable és la promesa de 04-03 de no sobreescriure claus desades a la CDN, que aquí es compleix perquè la web referencia miniatura-800.jpg?v=<etag>; i el gestor no conté negoci, perquè processar_objecte es provi amb un client fals i perquè el lock-in es limiti a aquestes línies.
serverless/factures/handler.py
# km0/serverless/factures/handler.py
"""Genera la factura PDF d'una comanda en rebre pagament.confirmat des d'MSK (Kafka).
Lambda rep LOTS de registres per partició. Idempotent per id_esdeveniment amb DynamoDB
(taula km0-idempotencia): la mateixa factura mai no es genera ni s'envia dues vegades.
"""
import base64, io, json, os, time
import boto3
from botocore.exceptions import ClientError
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
s3 = boto3.client("s3")
dynamo = boto3.resource("dynamodb").Table(os.environ["TAULA_IDEMPOTENCIA"])
BUCKET_FACTURES = os.environ["BUCKET_FACTURES"] # km0-factures
TTL_S = 30 * 24 * 3600 # la marca d'idempotència caduca als 30 dies
def reclamar(id_esdeveniment: str) -> bool:
"""Intenta registrar l'esdeveniment. Retorna False si ja hi era (duplicat). Atòmic a DynamoDB."""
try:
dynamo.put_item(Item={"id": f"factura#{id_esdeveniment}", "ttl": int(time.time()) + TTL_S},
ConditionExpression="attribute_not_exists(id)")
return True
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return False
raise
def generar_pdf(comanda: dict) -> bytes:
"""Lògica pura: un PDF senzill amb reportlab."""
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
c.setFont("Helvetica-Bold", 16); c.drawString(50, 800, "Quilòmetre Zero - Factura")
c.setFont("Helvetica", 11)
c.drawString(50, 775, f"Factura: F-{comanda['comanda_id'][2:]} Comanda: {comanda['comanda_id']}")
c.drawString(50, 760, f"Client: {comanda['client']} Mercat: {comanda['mercat']}")
y = 730
for linia in comanda["linies"]:
c.drawString(60, y, f"{linia['unitats']} x {linia['producte']} ({linia['productor']})")
c.drawRightString(540, y, f"{linia['import_cents'] / 100:.2f} EUR"); y -= 16
c.setFont("Helvetica-Bold", 12); c.drawRightString(540, y - 10, f"Total: {comanda['total_cents'] / 100:.2f} EUR")
c.showPage(); c.save()
return buf.getvalue()
def handler(esdeveniment: dict, context) -> dict:
generades, duplicats = 0, 0
# Format de l'esdeveniment d'MSK: {"records": {"comandes.esdeveniments-3": [ {value: <base64>, ...}, ... ]}}
for particio, registres in esdeveniment["records"].items():
for r in registres:
ev = json.loads(base64.b64decode(r["value"]))
if ev["tipus"] != "pagament.confirmat":
continue
if not reclamar(ev["id_esdeveniment"]): # reintent de Lambda o duplicat de Kafka (02-05)
duplicats += 1
continue
comanda = ev["dades"]
clau = f"factures/{comanda['client']}/{comanda['comanda_id']}.pdf" # clau determinista
s3.put_object(Bucket=BUCKET_FACTURES, Key=clau, Body=generar_pdf(comanda),
ContentType="application/pdf", ServerSideEncryption="aws:kms") # xifratge (06-02)
generades += 1
print(json.dumps({"nivell": "info", "msg": "lot processat", "generades": generades,
"duplicats": duplicats, "request_id": context.aws_request_id}))
return {"generades": generades}Un matís important sobre l'ordre de les operacions: reclamar es fa abans de generar el PDF. Si la funció mor després de reclamar i abans d'escriure, la factura no es generarà mai en un reintent (la marca ja existeix). L'alternativa (reclamar després d'escriure) permet duplicats si mor entremig. Com que escriure dues vegades la mateixa clau d'S3 és inofensiu (idempotent per si mateix), la tria correcta aquí és reclamar després, o fer servir l'existència de l'objecte com a marca. Es deixa tal com està a propòsit per a l'exercici 1, que demana corregir-ho: és el tipus de raonament sobre el punt d'ack que 02-05 va ensenyar, i en serverless no hi ha excusa per no fer-lo.
serverless/template.yaml amb AWS SAM
# km0/serverless/template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Funcions d'esdeveniment de Quilòmetre Zero (miniatures, factures)
Globals:
Function:
Runtime: python3.12
Architectures: [arm64] # Graviton: més barat per GB-s
Tracing: Active # traces X-Ray / OpenTelemetry (07-02)
Environment:
Variables: { KM0_ENTORN: prod }
Resources:
DlqMiniatures:
Type: AWS::SQS::Queue
Properties: { QueueName: km0-miniatures-dlq, MessageRetentionPeriod: 1209600 } # 14 dies per reprocessar
Miniatures:
Type: AWS::Serverless::Function
Properties:
CodeUri: miniatures/
Handler: handler.handler
MemorySize: 1024 # Pillow és CPU: més memòria = més CPU = menys durada
Timeout: 60
ReservedConcurrentExecutions: 50 # bulkhead: un productor que puja 2 000 fotos no esgota el compte
DeadLetterQueue: { Type: SQS, TargetArn: !GetAtt DlqMiniatures.Arn }
EventInvokeConfig: { MaximumRetryAttempts: 2 } # invocació asíncrona: 2 reintents i cap a la DLQ
Policies:
- S3CrudPolicy: { BucketName: km0-fotos-prod } # només aquest bucket
- EventBridgePutEventsPolicy: { EventBusName: km0 }
Events:
FotoPujada:
Type: S3
Properties:
Bucket: !Ref BucketFotos
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- { Name: prefix, Value: fotos/ }
- { Name: suffix, Value: original.jpg } # NO es dispara amb les miniatures: evita el bucle infinit
BucketFotos: # referenciat des de Terraform (08-03) o creat aquí a staging
Type: AWS::S3::Bucket
Properties: { BucketName: km0-fotos-prod }
TaulaIdempotencia:
Type: AWS::DynamoDB::Table
Properties:
TableName: km0-idempotencia
BillingMode: PAY_PER_REQUEST # serverless: sense capacitat a aprovisionar
AttributeDefinitions: [{ AttributeName: id, AttributeType: S }]
KeySchema: [{ AttributeName: id, KeyType: HASH }]
TimeToLiveSpecification: { AttributeName: ttl, Enabled: true }
Factures:
Type: AWS::Serverless::Function
Properties:
CodeUri: factures/
Handler: handler.handler
MemorySize: 512
Timeout: 120
Environment:
Variables: { BUCKET_FACTURES: km0-factures-prod, TAULA_IDEMPOTENCIA: !Ref TaulaIdempotencia }
Policies:
- S3WritePolicy: { BucketName: km0-factures-prod }
- DynamoDBCrudPolicy: { TableName: !Ref TaulaIdempotencia }
- KMSEncryptPolicy: { KeyId: !ImportValue km0-kms-dades }
VpcConfig: # MSK és a les subxarxes de dades de la VPC (08-03)
SubnetIds: !Split [",", !ImportValue km0-subxarxes-dades]
SecurityGroupIds: [!ImportValue km0-sg-lambda-msk]
Events:
PagamentConfirmat:
Type: MSK
Properties:
Stream: !ImportValue km0-msk-arn
Topics: [comandes.esdeveniments]
StartingPosition: LATEST
BatchSize: 50 # fins a 50 registres per invocació
MaximumBatchingWindowInSeconds: 5
ConsumerGroupId: factures-lambda # un grup de consumidors més al tòpic (02-04)
DestinationConfig:
OnFailure: { Destination: !GetAtt DlqFactures.Arn }
DlqFactures:
Type: AWS::SQS::Queue
Properties: { QueueName: km0-factures-dlq, MessageRetentionPeriod: 1209600 }Tres detalls del template.yaml que eviten errors clàssics: el filtre per sufix original.jpg impedeix que l'escriptura d'una miniatura torni a disparar la funció (un bucle infinit que també seria una factura infinita); ReservedConcurrentExecutions acota el dany d'un pic; i l'origen d'esdeveniments MSK converteix Lambda en un grup de consumidors més de comandes.esdeveniments, amb el mateix model de particions i offsets de 02-04, cosa que vol dir que una fallada persistent de la funció bloqueja l'avenç d'aquella partició per a aquell grup (i només per a aquell grup) fins que el lot va a la DLQ. Es desplega amb sam build && sam deploy --guided la primera vegada, i des del pipeline després.
La saga de comanda en Step Functions
La saga de 03-05 (reservar estoc → cobrar → confirmar; compensar en ordre invers davant d'una fallada permanent), expressada en Amazon States Language, amb cada pas invocant una funció (o, en producció, el servei corresponent via API):
{
"Comment": "Saga de comanda de Quilòmetre Zero (equivalent a saga_comanda.py, 03-05)",
"StartAt": "ReservarEstoc",
"States": {
"ReservarEstoc": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "km0-inventari-reservar",
"Payload": { "id_reserva.$": "$.comanda_id", "comanda_id.$": "$.comanda_id",
"mercat.$": "$.mercat", "linies.$": "$.linies" }
},
"ResultPath": "$.reserva",
"Retry": [
{ "ErrorEquals": ["FalladaTransitoria", "Lambda.ServiceException", "Lambda.TooManyRequestsException"],
"IntervalSeconds": 1, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["EstocInsuficient"], "ResultPath": "$.error", "Next": "RebutjarComanda" },
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "RebutjarComanda" }
],
"Next": "Cobrar"
},
"Cobrar": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": {
"FunctionName": "km0-pagaments-cobrar",
"Payload": { "idempotency_key.$": "States.Format('cobrament-{}', $.comanda_id)",
"client.$": "$.client", "import_cents.$": "$.total_cents" }
},
"ResultPath": "$.cobrament",
"TimeoutSeconds": 10,
"Retry": [
{ "ErrorEquals": ["FalladaTransitoria", "States.Timeout"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2.0 }
],
"Catch": [
{ "ErrorEquals": ["PagamentRebutjat"], "ResultPath": "$.error", "Next": "AlliberarReserva" },
{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "AlliberarReserva" }
],
"Next": "ConfirmarComanda"
},
"ConfirmarComanda": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "km0-comandes-canviar-estat",
"Payload": { "comanda_id.$": "$.comanda_id", "estat": "pagada" } },
"ResultPath": null,
"Retry": [ { "ErrorEquals": ["States.ALL"], "IntervalSeconds": 1, "MaxAttempts": 5, "BackoffRate": 2.0 } ],
"Next": "Confirmada"
},
"Confirmada": { "Type": "Succeed" },
"AlliberarReserva": {
"Type": "Task",
"Comment": "Compensació C2: idempotent (alliberar dues vegades no duplica estoc)",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "km0-inventari-alliberar",
"Payload": { "id_reserva.$": "$.comanda_id" } },
"ResultPath": null,
"Retry": [ { "ErrorEquals": ["States.ALL"], "IntervalSeconds": 2, "MaxAttempts": 10, "BackoffRate": 2.0 } ],
"Next": "RebutjarComanda"
},
"RebutjarComanda": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "km0-comandes-canviar-estat",
"Payload": { "comanda_id.$": "$.comanda_id", "estat": "rebutjada", "motiu.$": "$.error.Error" } },
"ResultPath": null,
"Retry": [ { "ErrorEquals": ["States.ALL"], "IntervalSeconds": 2, "MaxAttempts": 10, "BackoffRate": 2.0 } ],
"Next": "Rebutjada"
},
"Rebutjada": { "Type": "Fail", "Error": "ComandaRebutjada", "Cause": "Saga compensada" }
}
}Compareu-ho amb l'OrquestradorSagaComanda de 03-05:
| Aspecte | saga_comanda.py + taula sagas (03-05) |
Step Functions |
|---|---|---|
| Estat de la saga | Fila a sagas dins de km0_comandes, escrita pel nostre codi a cada transició |
El desa el servei; historial complet de cada execució consultable |
| Reintents i backoff | Codi propi (max_reintents, FalladaTransitoria) |
Declaratius per pas (Retry) |
| Compensacions | Mètode _compensar que recorre els passos fets en ordre invers |
Branques Catch → estats de compensació; l'ordre el dibuixa el graf |
| Timeouts | Els del client gRPC (07-04) | TimeoutSeconds per pas, i de l'execució completa |
| Recuperació després d'una caiguda de l'orquestrador | En arrencar, continuar() sobre les sagues en curs |
Automàtica: el servei mai no "cau" per a nosaltres |
| Latència per pas | Mil·lisegons (crida gRPC directa) | Desenes de ms per transició + arrencada de cada Lambda |
| Cost | El del servei comandes |
Per transició d'estat (≈ 0,025 € per mil, tipus estàndard): amb 5 transicions i 1,2 M comandes/mes ≈ 150 €/mes |
| Proves | pytest amb dobles (InventariSimulat) i Testcontainers (07-06) |
Emulador local de Step Functions o staging; la lògica de cada pas, en Python |
| Portabilitat | Python, a qualsevol lloc | Només AWS |
| Visibilitat | La que s'instrumenti (07-01/07-02) | Consola amb el graf i l'estat de cada execució, de sèrie |
La decisió de Quilòmetre Zero, que l'ADR-006 de 08-05 formalitza: la saga de comanda es queda a comandes, perquè és al camí crític (latència), té un volum constant (cost) i ja està construïda i provada; Step Functions es reserva per a fluxos de baixa freqüència i llarga durada on la visibilitat i els reintents declaratius compensen, com la baixa d'un productor (cancel·lar els seus productes, liquidar els seus pagaments, arxivar les seves fotos, tot amb esperes de dies).
- Edge computing: per què acostar el còmput
Tot l'anterior continua a la regió del proveïdor. L'edge computing mou còmput i dades cap a la vora: als punts de presència d'una CDN (centenars de ciutats), a les antenes o passarel·les de l'operador, o als mateixos dispositius. Quatre raons, i per a cadascuna un cas de Quilòmetre Zero:
| Raó | Què resol | Cas |
|---|---|---|
| Latència | La llum triga ~10 ms en 1 000 km de fibra (anada); una petició València → Irlanda → València són 40-60 ms abans que el servidor faci res | Les fotos i el catàleg de l'Anna des d'un punt de presència a Madrid o València |
| Amplada de banda i egress | Cada foto servida des de la regió paga egress i ocupa l'enllaç | La CDN serveix el 95 % de les fotos sense tocar S3 (04-03 ho calculava) |
| Autonomia | Funcionar quan la connexió amb el núvol falla | L'app de furgoneta-3 en un túnel; el terminal de l'Horta La Vega al mercat de Lleida sense cobertura |
| Dades locals | Dades que no cal (o no convé) enviar senceres al núvol | Agregar 36 posicions en una durant el túnel; filtrar al dispositiu el que no aporta res |
- CDN: desar fotos i catàleg a la memòria cau a prop de l'Anna
Una CDN (Content Delivery Network: CloudFront, Cloudflare, Fastly, Akamai) és una xarxa de servidors de memòria cau distribuïts pel món, davant de l'origen. 04-05 la va situar com a capa de memòria cau i 04-03 la va justificar per l'egress; aquí es dissenya.
Com funciona. El DNS de km0.example apunta a la CDN; el navegador de l'Anna a València resol al punt de presència més proper; si l'objecte és a la seva memòria cau (hit), el serveix en 5-10 ms; si no (miss), el demana a l'origen (S3 per a les fotos, Kong per a l'API), el desa segons les capçaleres de memòria cau i el serveix. El hit ratio és la mètrica: amb 4 200 productes i immutable, supera el 95 % després de l'escalfament.
Cache keys. La clau de memòria cau és, per defecte, l'URL completa (amb paràmetres de consulta). Dues conseqüències: miniatura-800.jpg?v=abc i ?v=def són objectes diferents, que és exactament el que es vol per versionar (04-03); i una URL amb paràmetres irrellevants (?utm_source=...) fragmenta la memòria cau i abaixa el hit ratio, així que es configura quins paràmetres formen part de la clau. Capçaleres com Accept-Language o cookies es poden afegir a la clau (amb compte: cada valor multiplica les variants).
TTL per tipus de contingut i invalidació:
| Contingut | Origen | Cache-Control |
Invalidació | Per què |
|---|---|---|---|---|
| Miniatures i originals de fotos | S3 km0-fotos |
public, max-age=31536000, immutable |
Mai: l'URL canvia amb el contingut (?v=<etag>) |
Objectes immutables (04-03) |
Fitxa de producte (JSON de /api/v1/cataleg/productes/formatge-curat) |
Kong → cataleg |
public, max-age=60, stale-while-revalidate=300 |
Per URL, disparada pel consumidor d'estoc.actualitzat de 04-05 (a més de Redis) |
Canvia poc; un minut d'obsolescència és acceptable; stale-while-revalidate serveix el vell mentre refresca |
| Llistat d'un mercat | Kong → cataleg |
public, max-age=30 |
Per TTL | Canvia amb cada publicació; barat de recalcular |
Qualsevol cosa sota /api/v1/comandes |
Kong → comandes |
private, no-store |
— | Dades personals; mai en una memòria cau compartida |
Web estàtica (JS, CSS de seguiment.js) |
S3 | immutable amb hash al nom del fitxer |
Mai | Build amb noms amb hash |
WebSocket /ws |
repartiment |
— | — | La CDN el passa (proxy) sense desar-lo; algunes acaben el TLS i el reenvien |
La invalidació explícita (purge per URL o per etiqueta) existeix però és lenta (de segons a minuts per propagar-se a tots els punts de presència) i sovint es cobra; el disseny correcte l'evita per a l'immutable i la fa servir només per al semiestàtic (la fitxa) com a complement del TTL curt. És el mateix principi de 04-05: "TTL com a xarxa de seguretat, invalidació explícita com a mecanisme".
- Funcions a la vora: verificació de JWT i personalització
Les CDN modernes executen codi al punt de presència (Cloudflare Workers, Lambda@Edge i CloudFront Functions, Fastly Compute): funcions molt petites (mil·lisegons, pocs MB) que veuen la petició abans que arribi a l'origen o la resposta abans que arribi al client. Els seus usos a Quilòmetre Zero:
- Verificació de JWT a la vora. Una petició amb un token invàlid o caducat es rebutja a València, sense viatjar a Irlanda ni ocupar Kong: menys latència per a l'error i menys càrrega a l'origen davant d'un atac. La vora verifica la signatura (amb la clau pública de Keycloak, desada a la memòria cau) i la caducitat; l'autorització fina continua al servei (06-01), i Kong torna a verificar (defensa en profunditat, com a 08-02).
- Personalització sense perdre la memòria cau. La fitxa de
formatge-curatés igual per a tothom tret del mercat (preu i disponibilitat per ciutat). El Worker llegeix el mercat d'una cookie o de la geolocalització de la petició i l'afegeix a la clau de memòria cau (/productes/formatge-curat?mercat=valencia), de manera que hi ha una variant per mercat i no una per usuari. - Redireccions i A/B. Redirigir
/es/...segonsAccept-Language, enviar el 10 % dels usuaris a la nova pàgina de producte i mesurar, sense desplegar res a l'origen. - Servir des de la vora amb reserva. Si l'origen falla, servir l'última versió desada encara que hagi caducat (
stale-if-error): el catàleg continua visible durant un incident.
// km0/vora/worker/cataleg.js — Cloudflare Worker: verifica el JWT i serveix de la memòria cau per mercat
import { jwtVerify, createRemoteJWKSet } from "jose"; // biblioteca JOSE; el JWKS de Keycloak es desa a la vora
const JWKS = createRemoteJWKSet(new URL("https://id.km0.example/realms/km0/protocol/openid-connect/certs"));
export default {
async fetch(peticio, entorn, ctx) {
const url = new URL(peticio.url);
if (!url.pathname.startsWith("/api/v1/cataleg/")) return fetch(peticio); // la resta, a l'origen sense tocar
// 1. Verificació del JWT a la vora: signatura, emissor, audiència i caducitat (06-01)
const auth = peticio.headers.get("Authorization") || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : null;
if (!token) return new Response(JSON.stringify({ error: "no_autenticat" }), { status: 401 });
let claims;
try {
({ payload: claims } = await jwtVerify(token, JWKS, { issuer: "https://id.km0.example/realms/km0", audience: "web-km0" }));
} catch (e) {
return new Response(JSON.stringify({ error: "token_invalid" }), { status: 401 });
}
// 2. Clau de memòria cau per mercat (cookie o país de la petició), NO per usuari
const mercat = peticio.headers.get("Cookie")?.match(/mercat=([a-z]+)/)?.[1]
|| { ES: "valencia" }[peticio.cf?.country] || "girona";
const clauCache = new Request(`${url.origin}${url.pathname}?mercat=${mercat}`, { method: "GET" });
// 3. Servir de la memòria cau del punt de presència si hi és
const cache = caches.default;
let resposta = await cache.match(clauCache);
if (resposta) return new Response(resposta.body, { ...resposta, headers: { ...Object.fromEntries(resposta.headers), "X-Cache": "HIT" } });
// 4. Miss: anar a l'origen (Kong) amb el mercat i el token (Kong torna a verificar: defensa en profunditat)
const origen = new Request(`${url.origin}${url.pathname}?mercat=${mercat}`, {
headers: { "Authorization": auth, "X-Request-Id": crypto.randomUUID(), "X-Mercat": mercat },
});
resposta = await fetch(origen);
if (resposta.ok && (resposta.headers.get("Cache-Control") || "").includes("public")) {
ctx.waitUntil(cache.put(clauCache, resposta.clone())); // desar sense retardar la resposta
}
return resposta;
},
};El Worker no conté lògica de negoci ni accedeix a bases de dades: verifica, decideix la clau de memòria cau i reenvia. Tot el que necessiti l'estat real (estoc, comandes) continua anant a l'origen. I hi ha una restricció de disseny que el codi respecta: la resposta desada per mercat no pot contenir dades de l'usuari; si cataleg afegís "els teus preferits" a la fitxa, la memòria cau per mercat serviria els preferits de l'Anna al Marc. Per això la fitxa és pública i els preferits són una altra crida, private.
- Edge per a IoT: la furgoneta i el mercat sense connexió
La vora més extrema és el dispositiu. Dos casos a Quilòmetre Zero exigeixen funcionar sense connexió i sincronitzar després:
La furgoneta. A 08-02, paho encuava en memòria les posicions durant el túnel i les enviava de cop en sortir: 36 missatges que no interessaven a ningú un per un. Un agregador local al dispositiu fa tres coses millor: desa les posicions en una cua persistent a disc (SQLite) perquè un reinici de l'app no les perdi, agrega localment (mentre no hi ha xarxa, resumeix el tram en un únic missatge amb distància recorreguda, temps i trajectòria simplificada), i en recuperar la connexió envia primer la posició actual (l'urgent) i després el resum (l'històric). És l'edge com a filtre: el núvol rep menys dades i més útils.
La parada del mercat. L'Horta La Vega ven al mercat de Lleida amb un terminal que descompta estoc local. Sense connexió, continua venent: és una rèplica que accepta escriptures mentre està desconnectada, és a dir, replicació multilíder (03-04) amb conflictes garantits (el núvol reserva 3 carbassons per a una comanda en línia mentre la parada en ven 5 dels 6 que quedaven). 03-01 va donar l'eina perquè la fusió sigui determinista: els CRDT. Un comptador de vendes de la parada com a G-Counter (només incrementa; fusionar és prendre el màxim per rèplica) se sincronitza sense conflicte; l'estoc disponible, que és una resta, no és un CRDT pur, i es resol amb la regla de negoci de 03-04: la parada té autoritat sobre el seu estoc físic (inv-lleida-parada) i el núvol sobre les reserves en línia, i la reconciliació pot deixar una comanda en línia sense estoc, que la saga rebutja amb compensació. El que l'edge no pot fer és prometre consistència forta sense connexió: és la partició de 03-02, i la parada tria disponibilitat.
# km0/vora/furgoneta/agregador.py
"""Agregador local a la furgoneta: cua persistent a SQLite, agregació sense connexió i
sincronització en tornar. Complementa furgoneta_mqtt.py (08-02)."""
import json, math, sqlite3, time
import paho.mqtt.client as mqtt
RUTA_DB = "/var/lib/km0/cua.sqlite"
T_POS = "km0/repartiment/{id}/posicio"
T_TRAM = "km0/repartiment/{id}/tram"
def _distancia_m(a, b) -> float:
"""Haversine simplificada entre (lat, lon) en metres."""
R = 6_371_000
p1, p2 = math.radians(a[0]), math.radians(b[0])
dp, dl = math.radians(b[0] - a[0]), math.radians(b[1] - a[1])
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(h))
class CuaPersistent:
"""Cua a disc: sobreviu a reinicis de l'app i del dispositiu."""
def __init__(self, ruta: str = RUTA_DB):
self.db = sqlite3.connect(ruta)
self.db.execute("PRAGMA journal_mode=WAL") # escriptures ràpides i segures
self.db.execute("""CREATE TABLE IF NOT EXISTS pendents (
seq INTEGER PRIMARY KEY, data_ms INTEGER, lat REAL, lon REAL, enviada INTEGER DEFAULT 0)""")
def encuar(self, seq: int, data_ms: int, lat: float, lon: float) -> None:
with self.db:
self.db.execute("INSERT OR IGNORE INTO pendents VALUES (?, ?, ?, ?, 0)", (seq, data_ms, lat, lon))
def pendents(self) -> list[tuple]:
return self.db.execute("SELECT seq, data_ms, lat, lon FROM pendents WHERE enviada = 0 ORDER BY seq").fetchall()
def marcar_enviades(self, fins_seq: int) -> None:
with self.db:
self.db.execute("UPDATE pendents SET enviada = 1 WHERE seq <= ?", (fins_seq,))
self.db.execute("DELETE FROM pendents WHERE enviada = 1 AND data_ms < ?",
(int(time.time() * 1000) - 24 * 3600 * 1000,)) # netejar el que té més d'un dia
class Agregador:
def __init__(self, repartidor: str, client: mqtt.Client, cua: CuaPersistent):
self.id, self.client, self.cua = repartidor, client, cua
self.connectat = False
client.on_connect = lambda *a: self._en_connectar()
client.on_disconnect = lambda *a: setattr(self, "connectat", False)
def _en_connectar(self) -> None:
self.connectat = True
self.sincronitzar()
def nova_posicio(self, seq: int, lat: float, lon: float) -> None:
data_ms = int(time.time() * 1000)
self.cua.encuar(seq, data_ms, lat, lon) # SEMPRE a disc primer
if self.connectat:
self._publicar_posicio(seq, data_ms, lat, lon)
self.cua.marcar_enviades(seq)
# sense connexió: no es fa res més; la posició espera a disc
def _publicar_posicio(self, seq, data_ms, lat, lon) -> None:
carrega = json.dumps({"repartidor": self.id, "seq": seq, "lat": lat, "lon": lon, "data_ms": data_ms})
self.client.publish(T_POS.format(id=self.id), carrega, qos=1, retain=True)
def sincronitzar(self) -> None:
"""En recuperar la connexió: primer l'urgent (posició actual), després el resum del tram."""
pendents = self.cua.pendents()
if not pendents:
return
ultima = pendents[-1]
self._publicar_posicio(*ultima) # 1. la posició actual, per al mapa de l'Anna
if len(pendents) > 1: # 2. el tram sense connexió, agregat en UN missatge
punts = [(p[2], p[3]) for p in pendents]
distancia = sum(_distancia_m(punts[i], punts[i + 1]) for i in range(len(punts) - 1))
tram = {"repartidor": self.id, "seq_inici": pendents[0][0], "seq_fi": ultima[0],
"inici_ms": pendents[0][1], "fi_ms": ultima[1], "distancia_m": round(distancia),
"punts": len(punts), "trajectoria": punts[::max(1, len(punts) // 10)]} # 10 punts com a màxim
self.client.publish(T_TRAM.format(id=self.id), json.dumps(tram), qos=1)
self.cua.marcar_enviades(ultima[0])Amb l'agregador, l'exercici 2 de 08-02 canvia de resposta: en sortir del túnel, l'Anna rep una posició (l'actual), analitica rep un tram amb la distància i deu punts (suficient per al càlcul de distància de Flink, que ara pot sumar el distancia_m en comptes de reconstruir-la), i el broker no rep 36 missatges. Els seq continuen sent monòtons i persistits, així que la deduplicació de 08-02 continua funcionant. El que cal afegir al costat del núvol és un consumidor del nou tòpic tram al pont MQTT → Kafka, cap a un tòpic repartiment.trams.
- El continu núvol-edge-dispositiu i la seguretat a la vora
Núvol, vora i dispositiu no són alternatives sinó un continu: cada càlcul i cada dada se situen on l'equilibri entre latència, amplada de banda, autonomia, capacitat i control sigui millor.
| Nivell | Què hi ha | Capacitat | Latència cap a l'usuari | Autonomia | Què hi posa Quilòmetre Zero |
|---|---|---|---|---|---|
| Núvol (regió) | EKS, RDS, MSK, Cassandra, S3, Spark, Flink, Lambda | Il·limitada | 20-80 ms | Cap sense xarxa | Tot el transaccional i analític; la veritat sobre comandes, pagaments, estoc en línia |
| Vora de xarxa (CDN, PoP) | Memòria cau, Workers, terminació TLS, filtratge | Alta, però funcions petites i sense estat propi | 5-15 ms | Serveix memòria cau si el núvol falla | Fotos, catàleg, verificació de JWT, personalització per mercat, protecció |
| Vora local (mercat, magatzem) | Un terminal o miniservidor | Baixa, amb disc | < 1 ms | Total, amb sincronització posterior | Estoc físic de la parada (inv-lleida-parada), vendes presencials, CRDT i reconciliació |
| Dispositiu (furgoneta, mòbil) | App, cua SQLite, agregador | Mínima, bateria | 0 | Total, amb sincronització | Posicions, agregació de trams, ordres pendents, mapa de l'Anna amb l'última posició coneguda |
flowchart TB
subgraph Nuvol[Nuvol: eu-west-1]
K[(Kafka)] --> S[Serveis a EKS] --> D[(RDS, Cassandra, S3)]
L[Lambda miniatures, factures]
end
subgraph Vora[Vora de xarxa: CDN / PoP Valencia]
C[Memoria cau de fotos i cataleg]
W[Worker: JWT, clau per mercat]
end
subgraph Local[Vora local: mercat de Lleida]
T[Terminal de la parada<br/>estoc local, CRDT]
end
subgraph Disp[Dispositius]
F[furgoneta-3<br/>cua SQLite, agregador]
A[Mobil de l'Anna<br/>ultima posicio coneguda]
end
A -- "HTTPS" --> W --> C -. "miss" .-> S
F -- "MQTT, QoS 1" --> K
T -. "sincronitzacio periodica<br/>fusio CRDT" .-> S
S -- "WebSocket" --> A
Seguretat a la vora. Com més lluny del centre, menys control físic: un Worker corre en infraestructura d'un tercer, el terminal del mercat és damunt d'una taula i la furgoneta pot ser robada. Cinc regles:
- Res de secret a la vora de xarxa: el Worker verifica signatures amb la clau pública; mai no té la privada ni credencials de bases de dades. Els secrets que un Worker necessita (una clau d'API de l'origen) es desen al magatzem de secrets de la CDN, no al codi.
- El dispositiu s'autentica amb identitat pròpia i revocable: un certificat o credencial per dispositiu (la contrasenya MQTT de
furgoneta-3de 08-02, emesa en donar-lo d'alta), que es revoca al broker si el dispositiu es perd, i que només autoritza el seu prefix de tòpics (ACL). - Dades en repòs xifrades al dispositiu: la cua SQLite amb posicions i les comandes amb adreces de clients es xifren (SQLCipher o el magatzem segur del sistema operatiu) i s'esborren quan se sincronitzen.
- Mínima dada a la vora: el terminal de la parada no necessita l'adreça ni el telèfon de l'Anna; rep només el que li cal per vendre (producte, unitats, preu), i el que envia (vendes) no porta dades personals.
- La vora no és la veritat: qualsevol dada que vingui del dispositiu es valida al núvol (el pont de 08-02 comprovava que el
repartidordel payload coincideix amb el del tòpic; eltramamb 900 km en 3 minuts es descarta com a invàlid). Un dispositiu compromès pot mentir; el disseny ha de limitar el que una mentida pot causar.
Errors Comuns i Consells
- Funcions per a tot. Un consumidor constant de 500 esdeveniments/s o un servei amb SLO de latència són més barats i previsibles com a contenidor. Serverless per a l'esporàdic, el dirigit per esdeveniments i el que escala a zero.
- Suposar una sola execució. El proveïdor reintenta; la funció que no és idempotent genera dues factures o tres miniatures. Claus deterministes, marques d'idempotència, operacions condicionals.
- La funció que es dispara a si mateixa. Escriure la miniatura al mateix bucket que dispara la funció, sense filtre per prefix o sufix: bucle infinit i factura il·limitada. Filtre a l'esdeveniment i comprovació al codi.
- Connexions a base de dades des de mil instàncies. Mil Lambdes obren mil connexions a PostgreSQL. Límit de concurrència, RDS Proxy, o DynamoDB per a l'estat de les funcions.
- Inicialització dins del gestor. Carregar Pillow o crear el client d'S3 a cada invocació multiplica la durada i el cost. Fora del gestor, una vegada per instància.
- Sense DLQ. Un esdeveniment enverinat es reintenta i es perd sense que ningú ho sàpiga. DLQ amb alerta i procediment de reprocessament, sempre.
- Desar a la CDN respostes amb dades personals. Un
publica/api/v1/comandesserveix les comandes de l'Anna al Marc.private, no-storeper a tot el que és personal, i clau de memòria cau per mercat, mai per usuari, per al que és compartit. - Sobreescriure objectes desats a la CDN. La memòria cau no se n'assabenta. Claus immutables amb versió (
?v=<etag>), com es va decidir a 04-03. - Confiar en el dispositiu. Valida al núvol tot el que vingui de la vora; assumeix que pot estar compromès.
- Consell: separa sempre gestor (adaptador del proveïdor) i lògica (Python pur). Les proves es fan sobre la lògica; el lock-in es limita a l'adaptador.
- Consell: calcula el cost de cada funció a la freqüència real i a deu vegades la real. Si a deu vegades continua sent barata, és un bon cas; si es dispara, prepara la sortida cap a contenidor.
Exercicis
Exercici 1: el punt d'ack de la factura
A serverless/factures/handler.py, reclamar(id_esdeveniment) s'executa abans de generar i escriure el PDF. (a) Descriu la fallada concreta que això produeix i amb quina probabilitat passa. (b) Reescriu la part del gestor perquè la idempotència sigui correcta, raonant per què escriure dues vegades a S3 és acceptable i què passa si el procés mor en cada punt possible. (c) Seria diferent la resposta si, en comptes d'escriure un PDF a S3, la funció enviés un correu amb la factura?
Exercici 2: la CDN i la Setmana del Formatge Artesà
Durant la campanya, la web serveix 6 milions de vistes de producte al dia; cada vista descarrega una miniatura-800 (180 KB) i la fitxa JSON (4 KB). (a) Amb el disseny de l'apartat 9 (fotos immutable, fitxa amb max-age=60), estima el hit ratio esperable de cada tipus i l'egress diari des d'S3 i des de Kong, davant de servir-ho tot des de l'origen. (b) Un dia, la Formatgeria Montblanc canvia la foto de formatge-curat i es queixa que "alguns clients continuen veient l'antiga". Què ha fallat, si el disseny és correcte? (c) L'equip proposa afegir el nom d'usuari a la fitxa ("Hola, Anna") per personalitzar-la. Explica l'impacte en la memòria cau i proposa una alternativa.
Exercici 3: quan Step Functions
Per a cada flux, decideix si Quilòmetre Zero l'hauria d'implementar amb l'orquestrador propi de 03-05, amb Step Functions, o amb una simple funció disparada per esdeveniment, i justifica-ho amb cost, latència, durada i visibilitat: (a) la saga de confirmació de comanda (1,2 M al mes en campanya, al camí crític); (b) la baixa d'un productor (desenes a l'any: cancel·lar productes, esperar que es lliurin les comandes en curs, liquidar pagaments als 30 dies, arxivar fotos, notificar); (c) el reintent de cobraments diferits que la passarel·la rebutja temporalment (centenars al dia, un reintent cada 6 hores durant 3 dies).
Solucions
Exercici 1.
(a) Si la funció mor (timeout de 120 s per un lot gran, error de xarxa amb S3, retirada de la instància) després de reclamar i abans de put_object, la marca factura#<id_esdeveniment> queda escrita i el PDF no. Al reintent de Lambda (o al lot següent, perquè l'offset no ha avançat), reclamar retorna False i la factura se salta per sempre: factura perduda, sense error ni alerta. La probabilitat per esdeveniment és baixa (la finestra són uns mil·lisegons entre dues crides), però amb 1,2 M comandes al mes i reinicis d'instàncies, passarà diverses vegades al mes.
(b) Reordenar: generar i escriure primer, marcar després; i fer servir la marca només per evitar feina repetida, no per a la correcció.
comanda = ev["dades"]
clau = f"factures/{comanda['client']}/{comanda['comanda_id']}.pdf"
if ja_reclamat(ev["id_esdeveniment"]): # consulta, sense escriure: evita refer el PDF si ja s'ha fet
duplicats += 1; continue
s3.put_object(Bucket=BUCKET_FACTURES, Key=clau, Body=generar_pdf(comanda), ...) # clau determinista
marcar(ev["id_esdeveniment"]) # PutItem sense condició (o amb condició ignorant la fallada)Anàlisi per punt de mort: abans de put_object: res escrit, el reintent ho fa tot. Entre put_object i marcar: el PDF existeix, la marca no; el reintent torna a generar el mateix PDF i l'escriu a la mateixa clau (S3 reemplaça l'objecte sencer; amb versionat en queda una versió més, inofensiva) i marca. Després de marcar: tot fet. En cap cas es perd la factura, i el pitjor resultat és un PDF regenerat. Escriure dues vegades és acceptable perquè l'operació és naturalment idempotent: mateix contingut determinista (el PDF no porta l'hora de generació; si la portés, caldria fixar-la al data_ms de l'esdeveniment) a la mateixa clau.
(c) Sí. Enviar un correu no és idempotent per naturalesa: enviar-lo dues vegades molesta l'Anna. Llavors la marca abans d'enviar és necessària per no duplicar, però deixa la finestra de pèrdua. La solució és la de 02-05 amb la passarel·la: fer servir un servei de correu que accepti una clau d'idempotència (molts l'ofereixen per missatge), de manera que es pugui enviar "una altra vegada" sense duplicar; o dividir-ho en dos passos, generar el PDF (idempotent, a S3) i encuar l'enviament en una cua amb deduplicació per id (SQS FIFO amb MessageDeduplicationId), on l'enviament es reintenta amb seguretat. Sense una d'aquestes dues coses, cal triar entre perdre o duplicar, i per a un correu es prefereix duplicar.
Exercici 2.
(a) Fotos: 4 200 productes, objectes immutables, un any de TTL; després de l'escalfament, cada punt de presència té totes les miniatures: hit ratio > 98 % (els misses són només el primer accés a cada objecte a cada PoP i les fotos noves). Egress des d'S3: 6 M × 180 KB ≈ 1,08 TB/dia sense CDN; amb un 98 % de hits, ≈ 22 GB/dia des d'S3 (més l'egress de la CDN cap a l'usuari, que sol ser molt més barat o inclòs). Fitxes: max-age=60 i 4 200 productes × 4 mercats = 16 800 variants; 6 M vistes/dia són 70 per segon, repartides en 16 800 claus: de mitjana cada clau es demana cada 4 minuts, així que a la majoria de PoP la fitxa ha caducat quan es torna a demanar; hit ratio potser del 30-60 % (millor per als productes populars, pitjor per a la cua llarga). stale-while-revalidate=300 fa pujar molt aquest ratio (serveix la caducada i refresca en segon pla), a canvi de fins a 5 minuts d'obsolescència. Egress des de Kong: 6 M × 4 KB = 24 GB/dia sense CDN; amb un 50 %, 12 GB. La foto domina: la CDN elimina el 98 % de ~1,1 TB diaris; per a la fitxa, el benefici és de latència i de càrrega a cataleg més que d'egress.
(b) Si el disseny és correcte (la foto nova té un ETag nou i la web referencia ?v=<etag nou>), el que ha fallat és que la fitxa que conté l'URL de la foto està desada a la memòria cau: els clients que reben una fitxa desada (fins a 60 s, o fins a 5 minuts amb stale-while-revalidate) reben l'URL antiga, que continua apuntant a un objecte immutable i vàlid, la foto antiga. No és una fallada de la foto sinó del TTL de la fitxa, i és el comportament esperat: "alguns clients durant uns minuts". Si el problema durés hores, la causa seria una altra: la web referenciant la foto sense ?v= (sobreescriptura de clau, l'error de 04-03) o el consumidor d'estoc.actualitzat invalidant Redis però no la CDN (04-05 advertia d'invalidar a totes les capes).
(c) Amb "Hola, Anna" al cos de la fitxa, la resposta ja no és igual per a tots els usuaris d'un mercat: o es marca private (i es perd la memòria cau a la CDN: el hit ratio de la fitxa cau a 0 i cataleg rep els 70 req/s sencers) o, pitjor, es desa per mercat i el Marc veu "Hola, Anna". L'alternativa: la fitxa continua sent pública i per mercat, i la personalització es fa al client (el navegador ja té el nom al JWT o a la sessió i el pinta) o amb una segona crida petita i private (/api/v1/jo) que la CDN mai no desa. És el principi del Worker: separar el compartit desable del personal.
Exercici 3.
(a) Orquestrador propi a comandes. Volum alt i constant (≈ 150 €/mes només en transicions de Step Functions, més les invocacions), al camí crític (cada transició afegeix desenes de ms al p99, i compromet l'SLO de 500 ms), durada de segons, i ja construït i provat amb Testcontainers. La visibilitat la donen les traces de 07-02 i la taula sagas.
(b) Step Functions. Desenes a l'any (cost menyspreable), durada de setmanes (esperes de 30 dies que un orquestrador propi hauria de gestionar amb estat persistit i temporitzadors: Step Functions té l'estat Wait amb dates i execucions de fins a un any), fora de qualsevol camí crític, i amb un valor de visibilitat enorme (veure en quin pas és la baixa de cada productor, qui l'ha aprovat, per què ha fallat la liquidació). Cada pas invoca les API dels serveis existents. El lock-in s'accepta perquè el flux és perifèric.
(c) Una funció disparada per temporitzador (o una cua amb retard), no Step Functions ni orquestrador. Centenars al dia amb un reintent cada 6 hores durant 3 dies és un patró de cua amb reintent diferit: SQS amb DelaySeconds (màxim 15 minuts, així que s'encadena) o, més simple, una taula cobraments_pendents amb seguent_intent i una Lambda programada cada 15 minuts que processa els vençuts amb la Idempotency-Key de 02-05 i publica pagament.confirmat o pagament.rebutjat en acabar. Step Functions amb Wait de 6 hores funcionaria, però és una màquina d'estats per al que és un bucle amb una data; el cost i la complexitat no es justifiquen, i la visibilitat la dona la taula.
Conclusió
Serverless i edge computing amplien l'espai de decisions de Quilòmetre Zero en dues direccions oposades a la del clúster en una regió. Amb FaaS, el proveïdor executa funcions efímeres en resposta a esdeveniments, escala de zero a milers i cobra per invocació i GB-segon; a canvi imposa arrencades en fred, límits de temps i memòria, absència d'estat i, sobretot, reintents que no controlem i que fan de la idempotència de 02-05 una obligació i no una bona pràctica. Amb aquest filtre, les miniatures de km0-fotos, les factures de pagament.confirmat, els webhooks de la passarel·la i les tasques programades surten dels serveis i passen a funcions definides amb SAM, amb DLQ, límit de concurrència i filtres que eviten bucles, mentre comandes, el WebSocket de 08-02, Flink i Spark es queden on eren. Els serveis serverless del voltant (cues, DynamoDB, API Gateway, Step Functions) completen el model, i la saga de 03-05 reescrita en Amazon States Language mostra amb precisió què es guanya (estat gestionat, reintents declaratius, visibilitat) i què es paga (latència, cost per transició, lock-in), cosa que deixa la saga de comanda a comandes i reserva Step Functions per als fluxos llargs i esporàdics. En l'altra direcció, l'edge acosta dades i còmput a qui els fa servir: la CDN serveix fotos immutables i fitxes per mercat amb TTL i invalidació dissenyats per tipus de contingut, el Worker verifica el JWT i decideix la clau de memòria cau sense tocar l'origen, l'agregador de la furgoneta converteix 36 posicions de túnel en una posició i un tram, i la parada del mercat continua venent sense connexió amb CRDT i reconciliació per autoritat, acceptant la partició de 03-02 en comptes de negar-la. El continu núvol-vora-dispositiu situa cada dada on l'equilibri és millor, i la seguretat a la vora parteix del fet que la vora pot mentir.
Amb això, totes les peces de Quilòmetre Zero són damunt la taula: des del tall dels bounded contexts de 08-01 fins a la memòria cau a València i la cua SQLite a la furgoneta. L'última lliçó les ajunta: l'arquitectura completa en un sol diagrama, el recorregut d'una comanda de l'Anna d'extrem a extrem amb tot el que deixa al seu pas, les decisions i els seus trade-offs revisitats com a ADR, els cinc símptomes de 01-06 amb la seva resolució, l'avaluació amb els criteris del curs, el que queda fora, el projecte que l'alumne pot construir al seu portàtil i la guia per continuar aprofundint. És el Projecte Final: Quilòmetre Zero d'Extrem a Extrem.
Curs d'Arquitectures Distribuïdes
Mòdul 1: Introducció als Sistemes Distribuïts
- Conceptes Bàsics de Sistemes Distribuïts
- Models de Sistemes Distribuïts
- Avantatges i Desafiaments dels Sistemes Distribuïts
- Les Fal·làcies de la Computació Distribuïda
- Temps, Rellotges i Ordenació d'Esdeveniments
- Del Monòlit a la Plataforma Distribuïda: el Cas Quilòmetre Zero
Mòdul 2: Comunicació en Sistemes Distribuïts
- Protocols de Comunicació
- RPC i RMI
- gRPC i Serialització de Dades
- Missatgeria i Cues de Missatges
- Patrons de Comunicació Asíncrona
Mòdul 3: Consistència i Replicació
- Models de Consistència
- El Teorema CAP i PACELC
- Algorismes de Consens
- Replicació de Dades
- Transaccions Distribuïdes i Sagues
Mòdul 4: Emmagatzematge Distribuït
- Particionament de Dades i Hashing Consistent
- Sistemes de Fitxers Distribuïts
- Emmagatzematge d'Objectes
- Bases de Dades Distribuïdes
- Memòries Cau Distribuïdes
Mòdul 5: Computació Distribuïda
- Models de Computació Distribuïda
- MapReduce i Hadoop
- Spark i Computació en Memòria
- Processament de Fluxos de Dades
- Planificació de Treballs i Pipelines de Dades
Mòdul 6: Seguretat en Sistemes Distribuïts
- Autenticació i Autorització
- Xifratge i Protecció de Dades
- Gestió d'Identitats
- Seguretat entre Serveis: mTLS i Gestió de Secrets
- Passarel·les d'API, Limitació de Taxa i Auditoria
Mòdul 7: Monitoratge i Manteniment
- Monitoratge de Sistemes Distribuïts
- Logs Centralitzats i Traçabilitat Distribuïda
- Gestió de Fallades i Recuperació
- Patrons de Resiliència: Timeouts, Reintents i Circuit Breaker
- Automatització i Orquestració
- Proves en Sistemes Distribuïts i Enginyeria del Caos
