La lliçó anterior va acabar en un forat de 30 segons: el temps que passa entre que inv-bcn deixa de respondre i Patroni promociona inv-vlc. Durant aquest interval res no està "trencat" segons els mecanismes de failover i, tanmateix, la plataforma pot caure sencera. La raó és que la fallada no es queda on es produeix: comandes espera inventari, Kong espera comandes, l'Anna espera Kong, i cada espera consumeix un recurs finit (un fil, una connexió, un lloc en una cua). A 01-04 es va veure un client amb un timeout simple i es va deixar el circuit breaker "per a més endavant"; a 02-03, els deadlines de gRPC. Aquesta lliçó construeix el conjunt complet de patrons de resiliència: les tècniques amb què qui crida un servei que falla no s'enfonsa amb ell. S'implementen des de zero a serveis/comu/resiliencia.py, s'apliquen al client gRPC d'inventari dins de comandes, es reprodueixen en una simulació i es mostra on ha de viure cadascun (llibreria, sidecar o tots dos).
Contingut
- Anatomia d'una fallada en cascada
- Timeouts: per crida, pressupost total i propagació de deadlines
- Reintents segurs: backoff, jitter i retry budget
- Circuit breaker: la màquina d'estats
- Fallback i degradació controlada
- Bulkhead, backpressure i load shedding
- Altres patrons: hedged requests, rate limiting intern, idempotency keys
serveis/comu/resiliencia.pyi el seu ús acomandes- La simulació:
simulacions/cascada.py - On implementar-los: llibreria, sidecar (Envoy) o tots dos
- Errors comuns i consells
- Exercicis i solucions
- Conclusió
- Anatomia d'una fallada en cascada
Suposem la configuració real de Quilòmetre Zero un dissabte de la Setmana de la Verema: comandes té 3 rèpliques amb 32 fils cadascuna (96 en total) i rep 100 peticions/s; cada petició crida inventari (ReservarEstoc, normalment 40 ms) i pagaments. Kong té un pool de 200 connexions cap a comandes.
A les 10:12:00, el disc d'inv-bcn es degrada i ReservarEstoc passa a trigar 4 s.
sequenceDiagram
participant Anna
participant Kong
participant P as comandes (96 fils)
participant I as inventari (4 s / crida)
Note over I: 10:12:00 disc degradat
Anna->>Kong: POST /comandes (x100/s)
Kong->>P: 100 peticions/s
P->>I: ReservarEstoc (sense timeout)
Note over P: 10:12:01 → 96 fils ocupats esperant 4 s cadascun<br/>Capacitat real: 96/4 = 24 peticions/s. N'arriben 100.
Kong->>P: peticions en cua al socket
Note over Kong: 10:12:03 → 200 connexions esgotades.<br/>Kong respon 503 a TOT, inclòs GET /cataleg
Anna-->>Anna: "El web no va"
Note over I: 10:12:30 Patroni commuta a inv-vlc. Ja és tard:<br/>hi ha milers de peticions encuades i reintents acumulats
El que passa, en xifres: amb 4 s per crida, 96 fils atenen com a màxim 24 peticions/s; n'arriben 100, així que cada segon s'acumulen 76 peticions a les cues d'acceptació. En tres segons Kong ha esgotat el seu pool i comença a rebutjar totes les rutes, també /api/v1/cataleg, que no depèn d'inventari per a res. I quan inv-vlc és promocionat, no hi ha alleujament immediat: les cues són plenes de peticions velles els clients de les quals ja s'han rendit, i els reintents del front han multiplicat la càrrega. Això és una fallada en cascada: un component lent propaga la seva lentitud a tothom qui l'espera, a través de l'esgotament de recursos compartits.
Els patrons d'aquesta lliçó ataquen cada baula:
| Baula de la cascada | Patró que la talla |
|---|---|
| Esperar 4 s per una crida que sol trigar 40 ms | Timeout |
| Tornar-ho a intentar sense control i multiplicar la càrrega | Reintents amb backoff, jitter i pressupost |
| Continuar cridant una cosa que fa 30 s que falla | Circuit breaker |
| No tenir res a respondre quan no es pot cridar | Fallback / degradació |
Que l'espera per inventari consumeixi els fils que cataleg necessita |
Bulkhead |
| Acceptar més feina de la que es pot fer | Backpressure / load shedding |
| Reintentar una operació amb efectes (cobrar dues vegades) | Idempotency keys |
- Timeouts: per crida, pressupost total i propagació de deadlines
Un timeout és la decisió de deixar d'esperar. Sense ell, un fil bloquejat en un socket pot quedar-s'hi indefinidament; amb ell, allibera el recurs i retorna un error que la resta del sistema pot gestionar. Tres nivells:
- Per crida: cada operació remota té un límit d'acord amb la seva latència normal.
ReservarEstoctriga 40 ms en p50 i 120 ms en p99: un timeout de 300 ms deixa marge per a la cua i talla l'anòmal. La regla pràctica és fixar-lo entre 2 i 5 vegades el p99 observat (07-01), mai "30 segons per si de cas". - Pressupost total de la petició:
POST /comandespromet p99 < 500 ms. Aquest és el pressupost; cada crida interna en gasta una part, i la següent rep el que queda. SiReservarEstocha consumit 280 ms,Cobrarno pot rebre un timeout de 2 s: en rep 220 ms, o la petició s'avorta abans de cobrar. - Propagació de deadlines: a gRPC (02-03), el deadline viatja a les metadades (
grpc-timeout), i cada servei de la cadena l'hereta i el retalla.inventari, en rebre una crida amb 220 ms de deadline, pot passar 200 ms a la seva consulta SQL (statement_timeout), i si s'esgota, respondreDEADLINE_EXCEEDEDen lloc de fer feina que ningú no llegirà. A HTTP no hi ha estàndard; Quilòmetre Zero propaga una capçaleraX-Deadlineamb l'instant absolut (època en ms), quecomandesconverteix en deadline gRPC.
Una implementació del pressupost:
# km0/serveis/comu/resiliencia.py (part 1: timeouts)
"""Patrons de resiliència per a les crides entre serveis de Quilòmetre Zero."""
import random
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable, Iterable, TypeVar
T = TypeVar("T")
class TempsEsgotat(Exception):
"""El pressupost de temps de la petició s'ha esgotat."""
@dataclass
class Pressupost:
"""Pressupost de temps d'una petició: un instant límit absolut.
Es crea un cop en entrar la petició (o a partir de la capçalera X-Deadline)
i es passa a cada crida interna, que només pot fer servir el que queda.
"""
limit: float # time.monotonic() en què la petició ha d'haver acabat
@classmethod
def de_segons(cls, segons: float) -> "Pressupost":
return cls(limit=time.monotonic() + segons)
def restant(self) -> float:
return max(0.0, self.limit - time.monotonic())
def timeout_per(self, maxim: float) -> float:
"""Timeout d'una crida concreta: el seu màxim propi o el que quedi, el més petit."""
restant = self.restant()
if restant <= 0:
raise TempsEsgotat("pressupost de la petició esgotat")
return min(maxim, restant)
def amb_timeout(fn: Callable[[float], T], maxim: float, pressupost: Pressupost | None = None) -> T:
"""Executa fn(timeout) amb el timeout adequat.
fn rep el timeout en segons i és responsable d'aplicar-lo (gRPC: timeout=;
psycopg: statement_timeout; requests: timeout=). No es fan servir fils per "matar"
la crida: el timeout s'ha de respectar a la mateixa E/S, que és l'única cosa fiable.
"""
timeout = pressupost.timeout_per(maxim) if pressupost else maxim
return fn(timeout)Que amb_timeout no emboliqui la funció en un fil amb join(timeout) mereix una explicació: en Python (i en gairebé qualsevol runtime) no es pot matar un fil bloquejat en E/S; el "timeout" extern només retornaria el control a qui crida deixant el fil bloquejat igualment, que és justament el recurs que es volia protegir. El timeout que funciona és el que aplica la llibreria de xarxa al socket.
- Reintents segurs: backoff, jitter i retry budget
Reintentar és la resposta natural a una fallada transitòria, i la manera més fàcil de convertir un problema petit en una tempesta. Regles perquè un reintent sigui segur:
- Només errors transitoris.
UNAVAILABLE,DEADLINE_EXCEEDED,503, connexió rebutjada o reiniciada: sí.INVALID_ARGUMENT,PERMISSION_DENIED,NOT_FOUND,400,403: no; la segona vegada fallarà igual.FAILED_PRECONDITION("no hi ha estoc"): tampoc, és una resposta, no una fallada. - Només operacions idempotents (02-05).
ConsultarEstoc: sempre.ReservarEstocamb clau d'idempotència (comanda_id+ línia): sí.Cobrarsense clau: mai, perquè un timeout no diu si el cobrament s'ha fet o no. - Backoff exponencial: espera creixent entre intents (100 ms, 200, 400, 800), per donar temps que allò transitori passi.
- Jitter: aleatorietat en l'espera. Sense ella, mil clients que han fallat alhora reintenten alhora, exactament 100 ms després, i després 200 ms més tard: onades sincronitzades que mantenen el servei estès a terra (thundering herd). Amb "full jitter" (
uniform(0, base × 2^n)), les onades es dispersen. - Límit d'intents (3 en total és un bon màxim) i respecte del deadline: no té sentit un tercer intent si el pressupost ja s'ha esgotat.
- Retry budget: a més del límit per crida, un límit global: els reintents no poden superar, per exemple, el 10 % de les crides normals en una finestra. Si
inventariestà caigut del tot, un 10 % extra de càrrega és suportable; un 200 % (3 intents per cada petició) és el que impedeix que es recuperi.
# km0/serveis/comu/resiliencia.py (part 2: reintents)
class PressupostReintents:
"""Retry budget: els reintents no poden superar una fracció de les crides.
Finestra lliscant simple: cada crida normal 'guanya' `ratio` crèdits;
cada reintent en gasta un. Sense crèdits, no es reintenta.
"""
def __init__(self, ratio: float = 0.1, minim: int = 10):
self.ratio, self.minim = ratio, minim
self.credits = float(minim)
self._lock = threading.Lock()
def registrar_crida(self) -> None:
with self._lock:
self.credits = min(self.credits + self.ratio, 100.0)
def permetre_reintent(self) -> bool:
with self._lock:
if self.credits >= 1:
self.credits -= 1
return True
return False
def reintentar(fn: Callable[[float], T], *, maxim_per_intent: float, pressupost: Pressupost,
transitories: Iterable[type[BaseException]], intents: int = 3,
base: float = 0.1, sostre: float = 2.0,
budget: PressupostReintents | None = None,
en_reintentar: Callable[[int, BaseException, float], None] | None = None) -> T:
"""Executa fn amb reintents segurs.
- Només reintenta les excepcions llistades a `transitories`.
- Backoff exponencial amb full jitter: espera uniforme a [0, min(sostre, base * 2^n)].
- Mai espera més del que queda de pressupost; si no en queda, propaga l'últim error.
- Respecta el retry budget global si se'n proporciona un.
"""
transitories = tuple(transitories)
ultim: BaseException | None = None
for n in range(intents):
try:
if budget:
budget.registrar_crida()
return amb_timeout(fn, maxim_per_intent, pressupost)
except transitories as e:
ultim = e
if n == intents - 1:
break
if budget and not budget.permetre_reintent():
break # sense crèdit: no empitjorar la tempesta
espera = random.uniform(0, min(sostre, base * (2 ** n)))
if espera >= pressupost.restant():
break # no hi arribaríem a temps ni intentant-ho
if en_reintentar:
en_reintentar(n + 1, e, espera)
time.sleep(espera)
except TempsEsgotat:
raise
assert ultim is not None
raise ultim
- Circuit breaker: la màquina d'estats
Quan inventari fa 20 segons que retorna UNAVAILABLE a cada crida, continuar intentant-ho és inútil per a comandes (gasta 300 ms de timeout a cada petició per a res) i perjudicial per a inventari (que intenta recuperar-se sota una pluja de peticions). El circuit breaker (Nygard, Release It!) és un interruptor que, a partir d'un cert ratio de fallades, deixa de fer les crides i falla immediatament, i cada cert temps deixa passar una crida de prova per veure si el destí s'ha recuperat.
stateDiagram-v2
[*] --> Tancat
Tancat --> Obert: fallades >= llindar<br/>dins la finestra<br/>(p. ex. 50 % de 20 crides)
Obert --> Semiobert: passa el temps<br/>de refredament (10 s)
Semiobert --> Tancat: la/les crida/es<br/>de prova tenen èxit
Semiobert --> Obert: una crida<br/>de prova falla
note right of Tancat
Trànsit normal.
Es compten èxits i fallades.
end note
note right of Obert
Tota crida falla a l'instant
amb CircuitObert. Zero càrrega
sobre la dependència.
end note
note right of Semiobert
Deixa passar N crides de prova;
la resta continua fallant ràpid.
end note
Decisions de disseny:
- Què compta com a fallada: excepcions transitòries i timeouts; no els errors de negoci (
FAILED_PRECONDITIONper manca d'estoc és una resposta correcta i no ha d'obrir el circuit). - Llindar i finestra: en ratio i amb un mínim de crides (50 % de fallades sobre almenys 20 crides en els últims 10 s), no "5 fallades seguides", que amb trànsit alt s'assoleix per soroll.
- Refredament: quant esperar en obert abans de provar (5-30 s). Massa curt, i es martelleja la dependència; massa llarg, i s'allarga la degradació després de la recuperació.
- Què retornar en obert: una excepció específica (
CircuitObert) que qui crida converteix en fallback (secció 5), en503ambRetry-After, o en una resposta degradada. Mai l'excepció original disfressada. - Un per dependència (i per instància de servei):
comandesté un circuit cap ainventarii un altre cap apagaments; quepagamentsfalli no ha d'obrir el d'inventari. - Observable: el seu estat és un gauge de Prometheus (
km0_circuit_estat{dependencia}, 0/1/2) i cada transició és un log (circuit_obert, el que en Jordi va trobar a 07-02).
# km0/serveis/comu/resiliencia.py (part 3: circuit breaker)
from collections import deque
from prometheus_client import Gauge, Counter
CIRCUIT_ESTAT = Gauge("km0_circuit_estat", "Estat del circuit breaker: 0 tancat, 1 semiobert, 2 obert",
["servei", "dependencia"])
CIRCUIT_REBUTJOS = Counter("km0_circuit_rebutjos_total", "Crides rebutjades amb el circuit obert",
["servei", "dependencia"])
class Estat(Enum):
TANCAT = 0
SEMIOBERT = 1
OBERT = 2
class CircuitObert(Exception):
def __init__(self, dependencia: str, reintentar_en: float):
super().__init__(f"circuit obert cap a {dependencia}; reintentar d'aquí a {reintentar_en:.1f}s")
self.dependencia, self.reintentar_en = dependencia, reintentar_en
class CircuitBreaker:
def __init__(self, servei: str, dependencia: str, *, transitories: Iterable[type[BaseException]],
finestra_s: float = 10.0, minim_crides: int = 20, ratio_fallades: float = 0.5,
refredament_s: float = 10.0, proves_semiobert: int = 3, log=None):
self.servei, self.dependencia = servei, dependencia
self.transitories = tuple(transitories)
self.finestra_s, self.minim, self.ratio = finestra_s, minim_crides, ratio_fallades
self.refredament, self.proves = refredament_s, proves_semiobert
self.log = log
self._estat = Estat.TANCAT
self._obert_des_de = 0.0
self._proves_en_vol = 0
self._resultats: deque[tuple[float, bool]] = deque() # (instant, exit)
self._lock = threading.Lock()
self._publicar()
# --- estat observable --------------------------------------------------
@property
def estat(self) -> Estat:
return self._estat
def _publicar(self) -> None:
CIRCUIT_ESTAT.labels(self.servei, self.dependencia).set(self._estat.value)
def _transicio(self, nou: Estat) -> None:
if nou is not self._estat:
if self.log:
self.log.warning(f"circuit_{nou.name.lower()}", dependencia=self.dependencia,
des_de=self._estat.name.lower())
self._estat = nou
self._publicar()
# --- finestra de resultats ----------------------------------------------
def _podar(self, ara: float) -> None:
while self._resultats and ara - self._resultats[0][0] > self.finestra_s:
self._resultats.popleft()
def _ratio_fallades(self, ara: float) -> tuple[int, float]:
self._podar(ara)
total = len(self._resultats)
fallades = sum(1 for _, ok in self._resultats if not ok)
return total, (fallades / total if total else 0.0)
# --- decisió abans de cridar --------------------------------------------
def _abans(self) -> None:
ara = time.monotonic()
with self._lock:
if self._estat is Estat.OBERT:
transcorregut = ara - self._obert_des_de
if transcorregut < self.refredament:
CIRCUIT_REBUTJOS.labels(self.servei, self.dependencia).inc()
raise CircuitObert(self.dependencia, self.refredament - transcorregut)
self._transicio(Estat.SEMIOBERT)
self._proves_en_vol = 0
if self._estat is Estat.SEMIOBERT:
if self._proves_en_vol >= self.proves:
CIRCUIT_REBUTJOS.labels(self.servei, self.dependencia).inc()
raise CircuitObert(self.dependencia, 1.0)
self._proves_en_vol += 1
# --- registre després de cridar -----------------------------------------
def _despres(self, exit: bool) -> None:
ara = time.monotonic()
with self._lock:
if self._estat is Estat.SEMIOBERT:
if exit:
self._proves_en_vol -= 1
if self._proves_en_vol == 0: # totes les proves bé: tancar
self._resultats.clear()
self._transicio(Estat.TANCAT)
else: # una prova malament: reobrir
self._obert_des_de = ara
self._transicio(Estat.OBERT)
return
self._resultats.append((ara, exit))
total, ratio = self._ratio_fallades(ara)
if self._estat is Estat.TANCAT and total >= self.minim and ratio >= self.ratio:
self._obert_des_de = ara
self._transicio(Estat.OBERT)
def executar(self, fn: Callable[[], T]) -> T:
self._abans()
try:
resultat = fn()
except self.transitories:
self._despres(exit=False)
raise
except BaseException:
self._despres(exit=True) # error de negoci: la dependència funciona
raise
self._despres(exit=True)
return resultat
- Fallback i degradació controlada
Un circuit obert o un timeout no tenen per què acabar en un error per a l'Anna. Un fallback és la resposta alternativa quan la principal no està disponible, i la degradació controlada és dissenyar el producte perquè funcioni "pitjor, però que funcioni":
| Situació | Fallback | Què sacrifica |
|---|---|---|
inventari no respon i cataleg vol mostrar l'estoc en temps real |
Mostrar el catàleg amb l'últim estoc desat a la memòria cau de Redis (04-05) i l'etiqueta "disponibilitat aproximada" | Precisió de l'estoc |
inventari no respon en crear una comanda |
Acceptar la comanda com a "pendent de confirmar": la saga (03-05) queda en un estat intermedi i es completa quan inventari torni; si aleshores no hi ha estoc, compensa i avisa l'Anna |
Confirmació immediata; possibilitat d'un "ho sentim" posterior |
pagaments no respon |
No hi ha fallback per cobrar; però sí per a la comanda: desar la intenció i cobrar després (mateixa saga) | Immediatesa |
repartiment no publica posicions |
El panell mostra l'última posició coneguda amb la seva antiguitat | Frescor |
| Redis caigut | Anar a PostgreSQL directament amb un bulkhead petit per no tombar-lo (04-05, estampida) | Latència |
analitica amb lag |
Res: no és temps real; el DAG es posa al dia | Res de visible |
El fallback es decideix per operació i per negoci, no a la llibreria: la llibreria llança CircuitObert; el codi del domini decideix si això vol dir "estoc aproximat" o "comanda pendent". I cada fallback ha de ser visible: un comptador km0_fallback_total{operacio} (07-01) i un log, perquè una plataforma que degrada en silenci durant setmanes és una plataforma trencada que ningú no veu.
- Bulkhead, backpressure i load shedding
Bulkhead
Els mampares d'un vaixell (bulkheads) impedeixen que una via d'aigua inundi tot el buc. En un servei, l'equivalent és aïllar els recursos per dependència: si comandes té 96 fils i inventari es penja, sense bulkhead els 96 acaben esperant inventari; amb un bulkhead de 24 permisos per a inventari, com a màxim 24 fils esperen, i els altres 72 continuen atenent el que no en depèn (consultar comandes, cancel·lar, salut). En Python, un bulkhead és un semàfor amb adquisició sense espera (o amb una espera molt curta):
# km0/serveis/comu/resiliencia.py (part 4: bulkhead)
BULKHEAD_EN_US = Gauge("km0_bulkhead_en_us", "Permisos del bulkhead ocupats", ["servei", "dependencia"])
class BulkheadPle(Exception):
pass
class Bulkhead:
"""Limita quantes crides concurrents hi pot haver cap a una dependència."""
def __init__(self, servei: str, dependencia: str, permisos: int, espera_max_s: float = 0.05):
self.servei, self.dependencia = servei, dependencia
self._sem = threading.Semaphore(permisos)
self.espera_max = espera_max_s
def executar(self, fn: Callable[[], T]) -> T:
# Esperar com a màxim 50 ms per un permís: si no n'hi ha, fallar ràpid.
if not self._sem.acquire(timeout=self.espera_max):
raise BulkheadPle(f"bulkhead de {self.dependencia} ple")
BULKHEAD_EN_US.labels(self.servei, self.dependencia).inc()
try:
return fn()
finally:
BULKHEAD_EN_US.labels(self.servei, self.dependencia).dec()
self._sem.release()Els pools de connexions (a PostgreSQL, a gRPC) són bulkheads naturals si es dimensionen per dependència; l'error habitual és un únic pool de fils per a tot.
Backpressure i load shedding
Quan arriba més feina de la que es pot fer, hi ha dues respostes possibles: encuar-la (i esperar que passi el pic) o rebutjar-la. Encuar sense límit és la cascada de la secció 1: una cua que creix és latència que creix, fins que tot el que hi ha a la cua ja no importa a ningú. Backpressure és que el consumidor digui al productor que freni (a Kafka, el consumidor simplement no fa poll i el productor continua, perquè Kafka absorbeix; a gRPC streaming i a TCP, el control de flux és natiu; a HTTP síncron no hi ha mecanisme i el que queda és rebutjar). Load shedding és rebutjar aviat, amb 503 i Retry-After, tan bon punt la cua supera un límit o la latència d'espera supera un llindar, abans de fer cap feina. Rebutjar el 20 % de les peticions en 1 ms és molt millor que atendre el 100 % en 8 s, perquè el 80 % restant rep un servei normal.
I el rebuig es fa amb prioritat: si comandes està saturat, es descarten primer les peticions d'analitica (consultes d'informes) i les sondes sintètiques, i en últim lloc el POST /comandes d'un client. Quilòmetre Zero marca les peticions amb X-Prioritat: alta|normal|baixa des de Kong (segons ruta i rol) i el middleware de load shedding descarta de menor a major:
# km0/serveis/comandes/app.py (fragment: load shedding)
EN_VOL_MAX = {"alta": 90, "normal": 60, "baixa": 30} # límits acumulats per prioritat
@app.middleware("http")
async def middleware_load_shedding(request: Request, call_next):
prioritat = request.headers.get("x-prioritat", "normal")
en_vol = PETICIONS_EN_VOL.labels(servei="comandes")._value.get() # gauge de 07-01
if en_vol >= EN_VOL_MAX.get(prioritat, 60):
DESCARTADES.labels(prioritat=prioritat).inc()
return JSONResponse({"error": "servei saturat"}, status_code=503,
headers={"Retry-After": "2"})
return await call_next(request)Amb 96 fils, una petició baixa es rebutja tan bon punt n'hi ha 30 en vol; una alta només quan n'hi ha 90. El resultat: el dissabte de la cascada, els informes de la Marta fallen amb 503 i les comandes de l'Anna entren.
- Altres patrons: hedged requests, rate limiting intern, idempotency keys
- Hedged requests: per a operacions idempotents de lectura amb latència de cua alta, enviar la mateixa petició a una segona rèplica si la primera no ha respost en arribar al p95, i quedar-se amb la primera resposta. Redueix el p99 a canvi d'un 5 % més de càrrega. Útil a
ConsultarEstoccontrainv-bcn/inv-vlc; mai en escriptures. - Rate limiting intern: el de la vora (06-05) protegeix de clients; entre serveis també pot caldre que
analiticano consultiinventarimés de 50 vegades/s durant el DAG. Mateix algorisme (token bucket), aplicat al client o al sidecar; aquí no es desenvolupa més. - Idempotency keys a l'API pública de
comandes: el front de l'Anna, després d'un timeout, no sap si la comanda s'ha creat. Amb la capçaleraIdempotency-Key: <uuid generat pel client>,comandesdesa (clau → resposta) durant 24 h a Redis, i si la clau es repeteix retorna la mateixa resposta sense crear una altra comanda (02-05). És el que fa segur reintentar des de fora, i sense això cap reintent del front no és acceptable per aPOST /comandes.
Taula resum:
| Patró | Problema que resol | Paràmetres típics a Quilòmetre Zero |
|---|---|---|
| Timeout per crida | Esperar indefinidament | 2-5 × p99: ReservarEstoc 300 ms, Cobrar 2 s, SQL 200 ms |
| Pressupost / deadline propagat | Que la suma de crides superi l'SLO | POST /comandes 500 ms; capçalera X-Deadline → deadline gRPC → statement_timeout |
| Reintents amb backoff + jitter | Fallades transitòries; tempestes sincronitzades | 3 intents, base 100 ms, sostre 2 s, full jitter, només UNAVAILABLE/DEADLINE_EXCEEDED, només idempotents |
| Retry budget | Reintents que dupliquen la càrrega | 10 % de les crides |
| Circuit breaker | Martellejar una dependència caiguda; gastar timeouts inútils | 50 % de fallades sobre ≥ 20 crides en 10 s; refredament 10 s; 3 proves |
| Fallback / degradació | No tenir res a respondre | Estoc de la memòria cau; comanda "pendent de confirmar" |
| Bulkhead | Que una dependència esgoti els recursos de tot el servei | 24 permisos cap a inventari, 16 cap a pagaments, 8 cap a Redis |
| Load shedding | Cues que creixen sense límit | 503 + Retry-After per sobre de 30/60/90 en vol segons prioritat |
| Hedged requests | Latència de cua en lectures | Segona petició al p95 (80 ms) a ConsultarEstoc |
| Idempotency key | Reintents externs amb efectes | Idempotency-Key a POST /comandes, 24 h a Redis |
serveis/comu/resiliencia.py i el seu ús a comandes
serveis/comu/resiliencia.py i el seu ús a comandesEls patrons es componen en un ordre que importa: de fora cap a dins, bulkhead → circuit breaker → reintents → timeout → crida. El bulkhead limita quants fils hi entren; el circuit decideix si té sentit intentar-ho; els reintents repeteixen la crida amb timeout. Posar els reintents fora del circuit seria reintentar contra un circuit obert (inútil); posar el bulkhead dins dels reintents ocuparia i alliberaria permisos a cada intent (acceptable, però comptabilitza pitjor).
# km0/serveis/comandes/clients/inventari.py
"""Client gRPC d'inventari que fa servir comandes, amb la pila de resiliència completa."""
import grpc
from contractes import inventari_pb2, inventari_pb2_grpc
from serveis.comu.logs import log
from serveis.comu.metriques import FALLBACK_TOTAL
from serveis.comu.resiliencia import (Bulkhead, BulkheadPle, CircuitBreaker, CircuitObert,
Pressupost, PressupostReintents, TempsEsgotat, reintentar)
class TransitoriaGrpc(Exception):
"""Embolcalla els codis gRPC que es consideren transitoris."""
CODIS_TRANSITORIS = {grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED,
grpc.StatusCode.RESOURCE_EXHAUSTED}
class ClientInventari:
def __init__(self, canal: grpc.Channel):
self.stub = inventari_pb2_grpc.InventariStub(canal)
self.bulkhead = Bulkhead("comandes", "inventari", permisos=24)
self.circuit = CircuitBreaker("comandes", "inventari", transitories=[TransitoriaGrpc], log=log)
self.budget = PressupostReintents(ratio=0.1)
def _cridar(self, metode, peticio, timeout: float):
try:
return metode(peticio, timeout=timeout) # el deadline gRPC de 02-03
except grpc.RpcError as e:
if e.code() in CODIS_TRANSITORIS:
raise TransitoriaGrpc(e.code().name) from e
raise # NOT_FOUND, FAILED_PRECONDITION...: no transitori
def reservar_estoc(self, comanda_id: str, producte: str, unitats: int, pressupost: Pressupost):
peticio = inventari_pb2.ReservaPeticio(comanda_id=comanda_id, producte=producte, unitats=unitats,
clau_idempotencia=f"{comanda_id}:{producte}")
def amb_reintents():
return reintentar(
lambda t: self._cridar(self.stub.ReservarEstoc, peticio, t),
maxim_per_intent=0.3, pressupost=pressupost,
transitories=[TransitoriaGrpc], intents=3, base=0.05, sostre=0.2, budget=self.budget,
en_reintentar=lambda n, e, esp: log.warning("reintent_inventari", intent=n,
causa=str(e), espera_ms=int(esp * 1000)),
)
return self.bulkhead.executar(lambda: self.circuit.executar(amb_reintents))
# A saga_comanda.py, el pas de reserva fa servir el client i decideix el fallback de negoci:
def pas_reservar(saga, linia, pressupost):
try:
return client_inventari.reservar_estoc(saga.comanda_id, linia.producte, linia.unitats, pressupost)
except (CircuitObert, BulkheadPle, TempsEsgotat, TransitoriaGrpc) as e:
# inventari no està disponible: no fer fallar la comanda, deixar-la pendent de confirmar (03-05)
FALLBACK_TOTAL.labels(operacio="reservar_estoc_pendent").inc()
log.warning("reserva_pendent_confirmar", comanda_id=saga.comanda_id, causa=type(e).__name__)
saga.marcar_pendent_confirmacio(linia)
return NoneObserveu que FAILED_PRECONDITION ("no hi ha estoc") no s'embolcalla en TransitoriaGrpc: no es reintenta, no obre el circuit i la saga el tracta com un rebuig normal (compensació). La distinció entre "la dependència falla" i "la dependència diu que no" és la més important de tota la pila.
- La simulació:
simulacions/cascada.py
simulacions/cascada.pyPer veure la cascada i la seva mitigació sense aixecar la plataforma, una simulació amb fils: un inventari fals que triga 40 ms i, a partir del segon 3, 4 s; un comandes amb 32 fils que rep 100 peticions/s durant 12 s; i un comptador del que l'Anna veu.
# km0/simulacions/cascada.py
"""Reprodueix una fallada en cascada i mostra l'efecte de cada patró de resiliència."""
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from collections import Counter
from serveis.comu.resiliencia import (Bulkhead, BulkheadPle, CircuitBreaker, CircuitObert,
Pressupost, TempsEsgotat, amb_timeout)
DURADA_S, TAXA, FILS_COMANDES = 12, 100, 32
class InventariFals:
"""Triga 40 ms; a partir de 'degradar_en' triga 4 s (fallada grisa)."""
def __init__(self, degradar_en: float):
self.inici, self.degradar_en = time.monotonic(), degradar_en
def reservar(self, timeout: float):
latencia = 4.0 if time.monotonic() - self.inici > self.degradar_en else 0.04
if latencia > timeout:
time.sleep(timeout) # l'E/S respecta el timeout: allibera el fil a temps
raise TimeoutError("DEADLINE_EXCEEDED")
time.sleep(latencia)
return "OK"
def escenari(nom: str, cridar):
"""Genera 100 peticions/s durant 12 s contra un pool de 32 fils i compta resultats."""
resultats, latencies = Counter(), []
pool = ThreadPoolExecutor(max_workers=FILS_COMANDES)
lock = threading.Lock()
def peticio():
t0 = time.monotonic()
try:
cridar()
r = "ok"
except TimeoutError:
r = "timeout"
except CircuitObert:
r = "circuit_obert"
except BulkheadPle:
r = "bulkhead_ple"
except TempsEsgotat:
r = "pressupost_esgotat"
with lock:
resultats[r] += 1
latencies.append(time.monotonic() - t0)
inici = time.monotonic()
enviades = 0
while time.monotonic() - inici < DURADA_S:
pool.submit(peticio)
enviades += 1
time.sleep(1 / TAXA)
pool.shutdown(wait=True)
latencies.sort()
p50, p99 = latencies[len(latencies) // 2], latencies[int(len(latencies) * 0.99)]
print(f"{nom:<34} enviades={enviades:4d} " + " ".join(f"{k}={v}" for k, v in sorted(resultats.items()))
+ f" p50={p50*1000:6.0f}ms p99={p99*1000:6.0f}ms")
if __name__ == "__main__":
# 1. Sense res: cada crida espera el que calgui
inv = InventariFals(degradar_en=3)
escenari("sense proteccio", lambda: inv.reservar(timeout=60))
# 2. Només timeout de 300 ms
inv = InventariFals(degradar_en=3)
escenari("timeout 300ms", lambda: amb_timeout(inv.reservar, 0.3))
# 3. Timeout + circuit breaker
inv = InventariFals(degradar_en=3)
cb = CircuitBreaker("sim", "inventari", transitories=[TimeoutError], minim_crides=20,
ratio_fallades=0.5, refredament_s=2.0)
escenari("timeout + circuit breaker", lambda: cb.executar(lambda: amb_timeout(inv.reservar, 0.3)))
# 4. Timeout + circuit breaker + bulkhead de 8
inv = InventariFals(degradar_en=3)
cb = CircuitBreaker("sim", "inventari", transitories=[TimeoutError], minim_crides=20,
ratio_fallades=0.5, refredament_s=2.0)
bh = Bulkhead("sim", "inventari", permisos=8)
escenari("timeout + CB + bulkhead(8)",
lambda: bh.executar(lambda: cb.executar(lambda: amb_timeout(inv.reservar, 0.3))))Sortida (els números varien entre execucions, el patró no):
sense proteccio enviades=1200 ok=302 p50= 4012ms p99= 4088ms timeout 300ms enviades=1200 ok=300 timeout=900 p50= 301ms p99= 312ms timeout + circuit breaker enviades=1200 circuit_obert=838 ok=300 timeout=62 p50= 0ms p99= 301ms timeout + CB + bulkhead(8) enviades=1200 bulkhead_ple=28 circuit_obert=850 ok=300 timeout=22 p50= 0ms p99= 118ms
Com llegir-ho:
- Sense protecció: després del segon 3, els 32 fils queden atrapats 4 s cadascun; el pool només processa 8 peticions/s i les altres 92 s'encuen; el programa triga més d'un minut a buidar la cua (la sortida real mostra el
p50de 4 s de les que han acabat dins del temps; la resta continuen esperant). Això és el que Kong veu com a pool esgotat. - Timeout: cada petició ocupa com a màxim 300 ms; el pool sosté 32/0,3 ≈ 106 peticions/s, just per sobre de les 100 que arriben, així que no hi ha cua, però totes fallen després de 300 ms d'espera inútil, i
inventarirep les 100 crides/s mentre intenta recuperar-se. - Circuit breaker: després de les primeres 20 crides fallides (uns 200 ms), el circuit s'obre; les següents fallen en microsegons (
p50 = 0 ms), i cada 2 s deixa passar 3 proves (timeout=62: les proves que han fallat).inventarirep 3 crides cada 2 s en lloc de 100/s: pot recuperar-se. - Bulkhead: a més, mai no hi ha més de 8 fils esperant
inventari; els altres 24 queden lliures per a tota la resta (a la simulació no hi ha "la resta", però elp99baixa a 118 ms perquè les peticions ja no esperen un fil del pool). Els 28bulkhead_plesón el preu: peticions rebutjades en 50 ms en lloc d'esperar-ne 300.
El que a la simulació són comptadors, al comandes real són fallbacks: circuit_obert i bulkhead_ple es converteixen en "comanda pendent de confirmar".
- On implementar-los: llibreria, sidecar (Envoy) o tots dos
Tot l'anterior viu al codi de comandes. Hi ha una altra opció: un proxy sidecar (Envoy, el proxy del service mesh d'Istio/Linkerd) al costat de cada servei, que intercepta el trànsit sortint i aplica timeouts, reintents, outlier detection (expulsar una instància que falla, un circuit breaker per instància) i límits de connexions, sense tocar el codi:
# km0/vora/envoy-comandes-sidecar.yaml (fragment): cluster cap a inventari
clusters:
- name: inventari
type: STRICT_DNS
connect_timeout: 0.25s
http2_protocol_options: {} # gRPC
load_assignment:
cluster_name: inventari
endpoints:
- lb_endpoints:
- endpoint: {address: {socket_address: {address: inventari-1, port_value: 50051}}}
- endpoint: {address: {socket_address: {address: inventari-2, port_value: 50051}}}
circuit_breakers: # a Envoy, "circuit breaker" = límits de concurrència (bulkhead)
thresholds:
- priority: DEFAULT
max_connections: 32
max_pending_requests: 16
max_requests: 24 # equival al nostre Bulkhead(permisos=24)
max_retries: 3 # reintents concurrents: el retry budget d'Envoy
outlier_detection: # això és el circuit breaker per instància
consecutive_5xx: 5 # (per a gRPC, consecutive_gateway_failure)
interval: 10s
base_ejection_time: 30s # instància expulsada del balanceig 30 s
max_ejection_percent: 50 # mai expulsar-les totes
# A la ruta que envia a aquest cluster:
routes:
- match: {prefix: "/km0.inventari.v1.Inventari/"}
route:
cluster: inventari
timeout: 0.3s
retry_policy:
retry_on: "unavailable,deadline-exceeded,resource-exhausted" # codis gRPC transitoris
num_retries: 2
per_try_timeout: 0.3s
retry_back_off: {base_interval: 0.05s, max_interval: 0.2s} # amb jitter automàtic
retriable_request_headers:
- name: x-idempotent # només reintentar si el client marca la crida com a idempotent
exact_match: "true"| On | Avantatges | Inconvenients | Què posar-hi |
|---|---|---|---|
Llibreria (resiliencia.py, tenacity, pybreaker, Resilience4j en Java, Polly en .NET) |
Coneix el negoci: què és idempotent, quin fallback aplicar, el pressupost de la petició | Cal implementar-la (i mantenir-la) a cada llenguatge; un servei mal configurat trenca la política | Pressupost de temps, fallbacks, idempotència, bulkhead per dependència lògica, load shedding amb prioritat |
| Sidecar / mesh (Envoy via Istio o Linkerd, 07-05) | Uniforme, sense codi, per instància (l'outlier detection veu cada rèplica), observable de sèrie | No sap què és idempotent ni quin fallback fer servir; afegeix latència (1-2 ms) i complexitat operativa | Timeouts per ruta, reintents de connexió, expulsió d'instàncies malaltes, límits de connexions, mTLS (06-04) |
| Tots dos | El millor de cada capa | Risc de duplicar reintents | Vegeu més avall |
El perill de "tots dos" és la multiplicació de reintents: si la llibreria reintenta 3 vegades, i Envoy reintenta 3 vegades cadascuna, i Kong 3 més, una petició fallida genera 27 crides a inventari. La regla: els reintents es fan en una sola capa (normalment la més propera al negoci, que sap què és idempotent, o el sidecar si es vol uniformitat i es marca la idempotència amb una capçalera), i les altres capes tenen num_retries: 0. Els timeouts, en canvi, poden i han de ser a totes, sempre que els exteriors siguin més grans que els interiors (si Kong talla a 500 ms i comandes a 600, comandes continua treballant per a ningú).
Errors Comuns i Consells
- Timeouts "per si de cas" de 30 s. Un timeout llarg és gairebé el mateix que cap: el fil es perd igual. 2-5 × p99 mesurat, i revisat quan canvia la latència.
- Reintentar-ho tot.
INVALID_ARGUMENTiFAILED_PRECONDITIONfallaran igual;Cobrarsense clau d'idempotència cobra dues vegades. Llista explícita d'excepcions transitòries i només operacions idempotents. - Backoff sense jitter. Mil clients sincronitzats picant alhora cada 200 ms. Full jitter sempre.
- Reintents en tres capes. Front × llibreria × sidecar × gateway = tempesta. Una capa reintenta; les altres, zero.
- Circuit breaker que compta errors de negoci. "No hi ha estoc" obre el circuit i totes les comandes queden pendents. Només fallades de la dependència, mai respostes vàlides.
- Llindar per fallades consecutives. Amb 100 crides/s, 5 de seguides és soroll. Ratio sobre una finestra amb mínim de crides.
- Fallback silenciós. Setmanes mostrant l'estoc de la memòria cau sense que ningú ho sàpiga. Comptador i log per cada fallback, alerta si es manté.
- Un sol pool de fils per a totes les dependències. És la definició de cascada. Bulkhead (o pool) per dependència.
- Encuar sense límit "per no perdre peticions". Es perden igual, però més tard i arrossegant-ho tot. Load shedding amb
503i prioritat. - Timeouts interiors més grans que els exteriors. El servei continua treballant per a un client que ja se n'ha anat. Cada capa cap a dins, més curt; propagar el deadline.
Exercicis
Exercici 1. POST /comandes té un pressupost de 500 ms i fa, en ordre, ReservarEstoc (timeout 300 ms, fins a 3 intents, base 50 ms) i Cobrar (timeout 2 s, sense reintents). (a) En el pitjor cas, quant pot trigar la fase de reserva amb reintents si cada intent esgota el timeout, i què fa reintentar amb el segon i el tercer intent segons el pressupost? (b) Si la reserva consumeix 280 ms, amb quin timeout es crida Cobrar i quin problema planteja que la passarel·la trigui de mitjana 318 ms (la traça de 07-02)? (c) Proposa un canvi de disseny a la saga que faci compatible l'SLO de 500 ms amb una passarel·la de 300-2 000 ms, i digues quin patró d'aquesta lliçó o de 03-05 estàs aplicant.
Exercici 2. Un dissabte, Celler Roure Alt llança una oferta i el 60 % de les reserves de vi-crianca responen FAILED_PRECONDITION (sense estoc). La Marta observa que km0_circuit_estat{dependencia="inventari"} passa a 2 i que totes les comandes, també les d'Horta La Vega, queden "pendents de confirmar". (a) Quin error d'implementació hi ha al client, respecte del codi de la secció 8? (b) En arreglar-ho, en Jordi proposa a més abaixar minim_crides a 5 "per reaccionar abans". Quin risc introdueix amb 100 crides/s? (c) Dissenya una alerta (07-01) que avisi d'un circuit obert de manera sostinguda sense avisar de les obertures breus i sanes.
Exercici 3. Quilòmetre Zero instal·la Istio (07-05) i per defecte Envoy aplica num_retries: 2 a tot el trànsit gRPC. La llibreria resiliencia.py continua amb intents=3, i el front del web reintenta POST /comandes dues vegades si rep timeout. (a) Davant d'un inventari completament caigut durant 30 s i 100 comandes/s, quantes crides a inventari per segon es generen en el pitjor cas si cap capa no té circuit breaker ni retry budget? (b) Decideix quina capa reintenta i què fan les altres, justificant-ho amb la taula de la secció 10. (c) Què garanteix que els dos reintents del front no creïn dues comandes, i què passa si aquesta garantia no existeix?
Solucions
Exercici 1.
(a) Sense pressupost, el pitjor cas seria 300 + espera(≤ 50) + 300 + espera(≤ 100) + 300 = fins a 1 050 ms. Amb Pressupost de 500 ms: el primer intent en consumeix 300; amb_timeout per al segon rep min(0.3, 200 ms restants − espera), és a dir, uns 150-200 ms; si aquest també s'esgota, per al tercer restant() és ~0 i timeout_per llança TempsEsgotat (o reintentar detecta que espera >= restant i avorta abans). La reserva mai no supera els 500 ms; el que se sacrifica és el tercer intent. (b) timeout_per(2.0) retorna min(2.0, 0.22) = 220 ms: la crida a la passarel·la, que triga 318 ms de mitjana, fallaria per deadline gairebé sempre; i com que Cobrar no és reintentable sense clau, la comanda acabaria en compensació. Pitjor: la passarel·la podria haver cobrat. (c) Desacoblar el cobrament de la resposta: la saga confirma la reserva, respon 202 "comanda acceptada, pagament en procés" i executa Cobrar de manera asíncrona (pas següent de la saga via outbox, 02-05/03-05) amb el seu propi timeout de 2 s i clau d'idempotència; si falla, compensa la reserva i notifica. És degradació controlada ("acceptar la comanda pendent de confirmar") combinada amb el disseny de saga coreografiada; l'SLO de 500 ms passa a aplicar-se a "acceptar", no a "cobrar", i cal un SLO nou per a "temps fins a la confirmació del pagament" (p99 < 30 s).
Exercici 2.
(a) FAILED_PRECONDITION s'està tractant com a transitori: o bé s'ha afegit a CODIS_TRANSITORIS, o bé l'except grpc.RpcError embolcalla tots els codis en TransitoriaGrpc. A la secció 8 només UNAVAILABLE, DEADLINE_EXCEEDED i RESOURCE_EXHAUSTED s'embolcallen; la resta es propaga tal qual i CircuitBreaker.executar ho compta com a èxit de la dependència (except BaseException: self._despres(exit=True)). Amb l'error, el 60 % de "sense estoc" supera el ratio del 50 % i obre el circuit per a tots els productors. (b) Amb 100 crides/s, 5 crides són 50 ms de trànsit: un sol paquet perdut o una rèplica que es reinicia produeix 3 de 5 fallades i obre el circuit per soroll, i com que el refredament és de 10 s, cada fals positiu costa 10 s de comandes pendents; el mínim de crides ha de ser estadísticament significatiu per a la taxa (20-50 en 10 s va bé per a 100/s; per a pagaments, amb 10/s, potser 10). (c) avg_over_time(km0_circuit_estat{servei="comandes",dependencia="inventari"}[5m]) >= 1.5 amb for: 3m, severitat pàgina: la mitjana del gauge en 5 minuts només arriba a 1,5 si ha estat obert (valor 2) més de tres quartes parts del temps; una obertura de 10 s seguida de tancament amb prou feines mou la mitjana. Complementària de ticket: increase(km0_circuit_rebutjos_total[1h]) > 1000.
Exercici 3.
(a) Per comanda: front 3 intents (1 + 2) × llibreria 3 × Envoy 3 (1 + 2) = 27 crides a inventari per comanda; a 100 comandes/s, 2 700 crides/s contra un servei caigut, i quan torni, aquesta és la càrrega que rebrà en el seu primer segon (davant de les 100 normals): el "recuperar-se" es converteix en "tornar a caure". Amb timeouts de 300 ms, a més, cada comanda triga fins a 27 × 300 ms ≈ 8 s a donar-se per vençuda. (b) Reintenta la llibreria (resiliencia.py): és l'única que sap que ReservarEstoc és idempotent per clau_idempotencia i que Cobrar no ho és, i l'única que coneix el pressupost de 500 ms; Envoy amb num_retries: 0 per a comandes → inventari (o, alternativa vàlida, Envoy reintenta només si la capçalera x-idempotent: true hi és present i la llibreria no reintenta; però no tots dos), quedant-se amb outlier_detection (expulsar instàncies malaltes, que la llibreria no veu per instància) i timeout per ruta; el front no reintenta automàticament, sinó que mostra "no hem pogut confirmar, torna-ho a provar" amb el mateix Idempotency-Key, o reintenta un cop amb backoff llarg (2-5 s) i sempre amb la mateixa clau. I totes les capes amb circuit breaker o retry budget: la llibreria amb PressupostReintents(0.1), Envoy amb max_retries a circuit_breakers. (c) La capçalera Idempotency-Key generada pel front en prémer "comprar" i reutilitzada a cada reintent: comandes la busca a Redis (24 h) i retorna la resposta desada sense tornar a executar la saga. Sense ella, un timeout entre comandes i Kong (la comanda s'ha creat però la resposta no ha arribat) seguit d'un reintent crea dues comandes amb dues reserves i, si el cobrament és síncron, dos cobraments a l'Anna: la fallada de resiliència més cara que existeix.
Conclusió
Un sistema distribuït no falla perquè un component falli; falla perquè els altres esperen el que falla fins a esgotar-se. Els patrons d'aquesta lliçó trenquen aquesta cadena baula a baula: timeouts per crida dimensionats sobre el p99, un pressupost de temps per petició que es propaga com a deadline gRPC i statement_timeout; reintents només d'errors transitoris i operacions idempotents, amb backoff exponencial, full jitter, límit d'intents, respecte del pressupost i retry budget; un circuit breaker amb ratio sobre finestra, refredament, proves en semiobert, un per dependència i observable a Prometheus; fallbacks decidits pel negoci (estoc aproximat, comanda pendent de confirmar) i sempre visibles; bulkheads perquè una dependència no esgoti els fils de tot el servei; load shedding que rebutja aviat i amb prioritat en lloc d'encuar; i idempotency keys perquè reintentar des de fora sigui segur. La simulació ha mostrat la cascada en números (32 fils, 4 segons, 8 peticions/s de capacitat) i com cada patró la talla, i la configuració d'Envoy ha mostrat que bona part d'això pot viure en un sidecar, sempre que els reintents visquin en una sola capa.
Fins aquí, tot s'ha fet a mà: editar docker-compose.yml, arrencar contenidors, copiar un certificat, executar patronictl switchover, ajustar permisos=24. Quilòmetre Zero ja té sis serveis, tres magatzems, Kafka, Redis, MinIO, Kong, Keycloak, Vault, Prometheus, Loki, Tempo, Patroni i etcd: desenes de contenidors amb centenars de paràmetres. Res del que s'ha construït en aquest mòdul (sondes, rèpliques, sidecars, polítiques de reintent) no té sentit si cada desplegament continua sent un dimarts a la tarda amb un ssh i una llista en un document, com al monòlit de 01-06. La lliçó següent tracta l'automatització i l'orquestració: infraestructura com a codi amb Ansible, contenidors immutables, i Kubernetes com l'orquestrador que planifica, autoescala, autorepara i desplega progressivament tot l'anterior, amb el service mesh que aplica sense codi el mTLS de 06-04 i els patrons d'avui.
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
