La lliçó anterior va tancar amb una pregunta incòmoda: és que cal tant?
Durant tot el mòdul hem donat per bons uns números que anàvem arrossegant. Que una rèplica d'api-reserves sosté 85 peticions per segon. Que calen 30 rèpliques al pic. Que el requests.cpu correcte és 412m. Però ningú ha preguntat per què una rèplica només aguanta 85 peticions per segon, ni si podria aguantar-ne 200 amb exactament els mateixos recursos.
I aquesta pregunta importa molt, perquè escalar és multiplicar la ineficiència pel nombre de rèpliques. Si cada rèplica malgasta la meitat de la seva capacitat, trenta rèpliques en malgasten quinze. Es paga la ineficiència trenta vegades, s'arrenquen nodes que no caldrien, i el coll d'ampolla real —que gairebé mai és on un es pensa— continua exactament on era.
Aquesta lliçó tanca el mòdul 9 mirant cap endins. Veurem la metodologia abans que els trucs, com fer una prova de càrrega amb k6 que simuli el pont de maig i com llegir-ne els resultats de debò, l'ajust de la capa d'aplicació (que és on gairebé sempre és el problema), la mecànica real de l'estrangulament de CPU, l'arrencada ràpida com a requisit de l'autoescalat, l'ajust del clúster inclòs el ndots: 5 que vam deixar pendent a 04-03, i un pressupost de latència que converteix l'objectiu de negoci en objectius per component.
Contingut
- La metodologia abans que els trucs
- Proves de càrrega amb k6: l'script del pont de maig
- Tipus de prova i quan fer servir cadascun
- Executar la prova dins del clúster
- Llegir els resultats correctament
- Ajust de l'aplicació: el pool de connexions
- Ajust de l'aplicació: consultes, índexs i memòria cau
- Ajust de l'aplicació: actius estàtics i connexions persistents
- Ajust de recursos: la mecànica de l'estrangulament de CPU
- El debat sobre treure el límit de CPU
- Memòria i el recol·lector d'escombraries de Node.js en un contenidor
- La mida de pod adequada
- Arrencada ràpida com a requisit de l'autoescalat
- Ajust del clúster: CoreDNS,
ndotsi kube-proxy - Emmagatzematge: quan el disc és el límit
- El pressupost de latència
- El resultat mesurat del pont de maig
- Errors comuns i consells
- Exercicis
- Conclusió
- La metodologia abans que els trucs
Internet és ple de llistes de «20 trucs per accelerar Kubernetes». Gairebé totes són inútils, perquè l'ajust de rendiment no és aplicar receptes: és un procés d'investigació.
Les quatre regles
Regla 1: mesura abans de tocar res.
Sense un mesurament de partida, no sabràs si el teu canvi va millorar alguna cosa, la va empitjorar, o no va fer res. La sensació subjectiva de «va més ràpid» és completament poc fiable.
El mesurament de partida de Rutas Norte, pres a rutas-norte-pre amb una prova de càrrega controlada:
LINIA BASE (2026-04-10, rutas-norte-pre, 4 repliques d'api-reserves)
Peticions per segon sostingudes: 340 rps
Latencia p50: 42 ms
Latencia p95: 287 ms
Latencia p99: 891 ms
Taxa d'error: 0,02 %
CPU mitjana per replica: 310m de 412m (75 %)
Connexions actives a PostgreSQL: 78 de 200Aquest bloc és el punt de comparació de tot el que vingui després. Desa'l, data'l i anota les condicions exactes.
Regla 2: troba el coll d'ampolla real.
En qualsevol sistema hi ha un recurs que limita abans que els altres. Tot el que no sigui aquest recurs, no importa. Optimitzar alguna cosa que no és el coll d'ampolla produeix exactament zero millora.
flowchart LR
U[Usuari] --> I[Ingress<br/>nginx]
I --> W[botiga-web]
W --> A[api-reserves]
A --> R[redis-cache]
A --> P[postgres-reserves]
A --> M[motor-preus]
style P fill:#f88,stroke:#900,stroke-width:3px
Si el coll d'ampolla és PostgreSQL, pots optimitzar nginx tant com vulguis: no canviarà res. I pitjor: pots escalar api-reserves a trenta rèpliques i empitjorar la situació, perquè trenta rèpliques obren trenta pools de connexions sobre la mateixa base de dades saturada. És exactament l'error que vam advertir a 09-01.
Regla 3: canvia una sola cosa cada vegada.
Si canvies el pool de connexions, afegeixes un índex i puges el limit de CPU alhora, i el rendiment millora un 40 %, no saps quin dels tres ho va fer. Pitjor: potser dos van millorar i un va empitjorar, i estàs arrossegant un canvi perjudicial sense saber-ho.
Un canvi. Mesurar. Anotar. Següent.
Regla 4: torna a mesurar en les mateixes condicions.
Mateix script, mateixa durada, mateix entorn, mateixes dades. Si compares una prova de dimarts a les 10:00 amb una altra de dissabte a les 3:00, estàs comparant soroll.
El cicle complet
flowchart TD
A[1. Definir l'objectiu<br/>SLO: p95 < 300 ms al 95 %] --> B[2. Mesurar la linia base<br/>prova de carrega controlada]
B --> C[3. Identificar el coll d'ampolla<br/>metriques del modul 7]
C --> D[4. Formular UNA hipotesi<br/>'el pool es massa gran']
D --> E[5. Aplicar UN canvi<br/>a rutas-norte-pre]
E --> F[6. Mesurar en les mateixes condicions]
F --> G{Ha millorat?}
G -->|Si| H[7. Anotar, portar a produccio,<br/>verificar a Grafana]
G -->|No| I[Revertir. La hipotesi era falsa.<br/>Anotar-ho tambe: es informacio]
H --> J{S'ha assolit<br/>l'objectiu?}
I --> C
J -->|No| C
J -->|Si| K[FI. Documentar i parar]
style D fill:#cde,stroke:#369
style K fill:#cfc,stroke:#393
Fixa't en el pas final: quan s'assoleix l'objectiu, es para. L'ajust de rendiment sense objectiu definit és un pou sense fons: sempre es pot optimitzar més, amb retorns cada vegada menors. L'SLO del mòdul 7 és el que diu quan has acabat.
Les eines que ja tens
Tot l'instrumental ve del mòdul 7:
| Pregunta | Eina |
|---|---|
| Quina és la latència real per percentils? | Prometheus + histogram_quantile (07-03) |
| Quin component consumeix la latència? | Traces distribuïdes, o histogrames per component |
| Quanta CPU i memòria consumeix cada pod? | kubectl top i metrics-server (07-02) |
| Hi ha estrangulament de CPU? | container_cpu_cfs_throttled_seconds_total |
| Què està esperant l'aplicació? | Registres amb temps per fase (07-05) |
| Què passa al clúster? | Esdeveniments i kubectl describe (07-06) |
| Es compleix l'SLO? | Panells de Grafana i pressupost d'error (07-04) |
Abans de tocar res, aquests panells han d'existir i estar poblats. Optimitzar a cegues és endevinar.
- Proves de càrrega amb k6: l'script del pont de maig
k6 és una eina de proves de càrrega en què els escenaris s'escriuen en JavaScript i s'executen en un motor Go d'alt rendiment. És l'opció natural a Kubernetes: és un binari únic, funciona bé dins d'un contenidor i exporta mètriques a Prometheus.
Un script realista
La clau d'una prova útil és que s'assembli al trànsit real. Un bucle martellejant un sol endpoint no diu res: la càrrega real de Rutas Norte és una seqüència d'accions amb pauses entre elles i proporcions diferents.
// proves/carrega/pont-maig.js
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend, Counter } from 'k6/metrics';
// ---------------------------------------------------------------------------
// METRIQUES PERSONALITZADES
// Les metriques de k6 per defecte mesuren peticions HTTP. Aquestes mesuren
// OPERACIONS DE NEGOCI, que es el que li importa a Rutas Norte.
// ---------------------------------------------------------------------------
const errorReserves = new Rate('errors_reserva');
const duracioCerca = new Trend('duracio_cerca_rutes', true);
const duracioReserva = new Trend('duracio_confirmar_reserva', true);
const reservesConfirmades = new Counter('reserves_confirmades');
// ---------------------------------------------------------------------------
// CONFIGURACIO DE L'ESCENARI
// ---------------------------------------------------------------------------
export const options = {
scenarios: {
// ESCENARI 1: l'allau de l'obertura de la venda.
// Reprodueix el que passa el 25 d'abril a les 10:00.
obertura_venda: {
executor: 'ramping-arrival-rate',
// ramping-arrival-rate mante una TAXA D'ARRIBADA constant,
// independentment de si el sistema respon rapid o lent.
// Es la diferencia critica amb ramping-vus: els usuaris reals NO
// esperen que acabis d'atendre l'anterior per arribar.
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 200,
maxVUs: 3000,
stages: [
{ duration: '2m', target: 20 }, // Calma previa. Linia base.
{ duration: '40s', target: 500 }, // L'ALLAU: x25 en 40 segons
{ duration: '10m', target: 500 }, // Altiplà: dues hores comprimides
{ duration: '5m', target: 120 }, // Descens gradual
{ duration: '3m', target: 20 }, // Tornada a la calma
],
gracefulStop: '30s',
},
},
// ---------------------------------------------------------------------------
// LLINDARS: si no es compleixen, k6 acaba amb codi d'error.
// Aixo converteix la prova en alguna cosa que pot FALLAR en un pipeline de CI.
// ---------------------------------------------------------------------------
thresholds: {
// L'SLO de Rutas Norte: p95 per sota de 300 ms.
'http_req_duration': ['p(95)<300', 'p(99)<1000'],
// Menys de l'1 % d'errors a les reserves. Es el que costa diners.
'errors_reserva': ['rate<0.01'],
// La cerca de rutes es l'operacio mes frequent: mes exigent.
'duracio_cerca_rutes': ['p(95)<200'],
// Confirmar una reserva escriu a PostgreSQL: se li permet mes.
'duracio_confirmar_reserva': ['p(95)<800'],
// Menys del 0,5 % de fallades HTTP en total.
'http_req_failed': ['rate<0.005'],
},
};
// ---------------------------------------------------------------------------
// DADES DE PROVA (fictícies)
// ---------------------------------------------------------------------------
const ORIGENS = ['BIL', 'SDR', 'OVD', 'GIJ', 'SAN', 'VIT', 'LOG', 'PAM'];
const DESTINS = ['MAD', 'BCN', 'ZAZ', 'VLC', 'SVQ', 'BIO', 'LCG'];
const BASE = __ENV.URL_BASE || 'http://api-reserves.rutas-norte-pre.svc.cluster.local';
function aleatori(llista) {
return llista[Math.floor(Math.random() * llista.length)];
}
function dataPont() {
// Les reserves del pont es concentren en tres dies concrets.
const dies = ['2026-04-30', '2026-05-01', '2026-05-02', '2026-05-03'];
return aleatori(dies);
}
// ---------------------------------------------------------------------------
// EL RECORREGUT DE L'USUARI
// Reprodueix el que fa una persona de veritat: cerca, mira, tria, confirma.
// ---------------------------------------------------------------------------
export default function () {
const origen = aleatori(ORIGENS);
const desti = aleatori(DESTINS);
const data = dataPont();
let idRuta = null;
// PAS 1: cercar rutes. Es l'operacio MES FREQUENT (tothom cerca).
group('01_cercar_rutes', function () {
const inici = Date.now();
const res = http.get(
`${BASE}/rutes?origen=${origen}&desti=${desti}&data=${data}`,
{ tags: { operacio: 'cercar_rutes' } }
);
duracioCerca.add(Date.now() - inici);
const ok = check(res, {
'la cerca retorna 200': (r) => r.status === 200,
'la cerca retorna rutes': (r) => {
try {
return JSON.parse(r.body).rutes !== undefined;
} catch (e) {
return false;
}
},
});
if (ok && res.status === 200) {
try {
const rutes = JSON.parse(res.body).rutes;
if (rutes && rutes.length > 0) {
idRuta = aleatori(rutes).id;
}
} catch (e) { /* cos malformat: es comptabilitza al check */ }
}
});
// PAUSA: l'usuari mira els resultats. Entre 2 i 6 segons.
// AQUEST SLEEP ES ESSENCIAL. Sense ell, la prova genera un patro de carrega
// que no s'assembla en res al real, i les conclusions no valen.
sleep(Math.random() * 4 + 2);
if (!idRuta) {
// No hi ha rutes per a aquesta combinacio. L'usuari se'n va. Es realista:
// no tothom troba el que busca.
return;
}
// PAS 2: consultar la disponibilitat de places.
// Aquesta consulta HAURIA de servir-se des de redis-cache.
group('02_consultar_places', function () {
const res = http.get(
`${BASE}/rutes/${idRuta}/places`,
{ tags: { operacio: 'consultar_places' } }
);
check(res, { 'places retorna 200': (r) => r.status === 200 });
});
sleep(Math.random() * 3 + 1);
// PAS 3: confirmar la reserva.
// Nomes el 18 % de les cerques acaben en reserva. Es la taxa de
// conversio real de Rutas Norte, mesurada en produccio.
if (Math.random() > 0.18) {
return;
}
group('03_confirmar_reserva', function () {
const inici = Date.now();
const carrega = JSON.stringify({
idRuta: idRuta,
seient: Math.floor(Math.random() * 54) + 1,
passatger: {
nom: `Client Prova ${__VU}-${__ITER}`,
document: `00000000${(__VU % 10)}X`,
correu: `prova-${__VU}-${__ITER}@ejemplo.example`,
},
});
const res = http.post(`${BASE}/reserves`, carrega, {
headers: { 'Content-Type': 'application/json' },
tags: { operacio: 'confirmar_reserva' },
});
duracioReserva.add(Date.now() - inici);
const ok = check(res, {
'la reserva retorna 201': (r) => r.status === 201,
'la reserva retorna localitzador': (r) => {
try {
return JSON.parse(r.body).localitzador !== undefined;
} catch (e) {
return false;
}
},
});
errorReserves.add(!ok);
if (ok) reservesConfirmades.add(1);
});
sleep(1);
}
// ---------------------------------------------------------------------------
// RESUM FINAL
// ---------------------------------------------------------------------------
export function handleSummary(data) {
const m = data.metrics;
const linia = (etiqueta, valor) => ` ${etiqueta.padEnd(38)} ${valor}\n`;
let sortida = '\n=== PROVA DE CARREGA: PONT DE MAIG ===\n\n';
sortida += linia('Peticions totals', m.http_reqs.values.count);
sortida += linia('Peticions per segon (mitjana)', m.http_reqs.values.rate.toFixed(1));
sortida += linia('Reserves confirmades', m.reserves_confirmades ? m.reserves_confirmades.values.count : 0);
sortida += '\n --- LATENCIA GLOBAL ---\n';
sortida += linia('p50', m.http_req_duration.values['p(50)'].toFixed(1) + ' ms');
sortida += linia('p95', m.http_req_duration.values['p(95)'].toFixed(1) + ' ms');
sortida += linia('p99', m.http_req_duration.values['p(99)'].toFixed(1) + ' ms');
sortida += linia('maxim', m.http_req_duration.values.max.toFixed(1) + ' ms');
sortida += '\n --- ERRORS ---\n';
sortida += linia('Taxa de fallada HTTP', (m.http_req_failed.values.rate * 100).toFixed(3) + ' %');
return {
'stdout': sortida,
'resultats/pont-maig.json': JSON.stringify(data, null, 2),
};
}Els tres detalls que fan útil aquest script
1. ramping-arrival-rate en lloc de ramping-vus. La diferència és fonamental i molt poca gent la coneix:
| Executor | Què manté constant | Comportament si el sistema s'alenteix |
|---|---|---|
ramping-vus |
El nombre d'usuaris virtuals | La càrrega BAIXA sola. Cada usuari espera més, així que fa menys peticions |
ramping-arrival-rate |
La taxa d'arribada (peticions/s) | La càrrega es manté. k6 crea més usuaris virtuals per sostenir el ritme |
Amb ramping-vus, un sistema que es degrada genera menys càrrega, cosa que amaga el problema: la prova s'autolimita. Els usuaris reals no es coordinen per esperar. Si obres la venda a les 10:00, arriben a les 10:00 encara que la teva API trigui 4 segons a respondre.
Fes servir ramping-arrival-rate per a qualsevol prova que vulgui reproduir trànsit real.
2. Els sleep() entre passos. Un usuari real cerca, mira els resultats durant 4 segons, tria, i confirma. Sense aquestes pauses, la prova genera un patró de concurrència completament diferent: molta menys concurrència per usuari i moltes més peticions per segon amb menys connexions obertes. Els resultats serien optimistes i enganyosos.
3. La taxa de conversió del 18 %. Només el 18 % de les cerques acaben en reserva. Si la prova fes una reserva per cada cerca, sobrecarregaria PostgreSQL amb escriptures molt per sobre del real i conclouria que la base de dades és el coll d'ampolla quan no ho és en producció.
La proporció entre operacions és tan important com el volum total.
- Tipus de prova i quan fer servir cadascun
No totes les proves de càrrega responen la mateixa pregunta.
| Tipus | Què fa | Què respon | Durada típica |
|---|---|---|---|
| Fum (smoke) | Càrrega mínima, 1-5 usuaris | Funciona el sistema i l'script? | 1-2 min |
| Càrrega (load) | Càrrega esperada sostinguda | Compleix l'SLO en condicions normals? | 15-30 min |
| Estrès (stress) | Càrrega creixent fins a trencar | On és el límit? Com es trenca? | 20-40 min |
| Punta (spike) | Salt brusc i curt | Aguanta l'obertura de la venda? | 5-10 min |
| Resistència (soak) | Càrrega normal molt prolongada | Hi ha fuites de memòria o degradació? | 4-24 h |
Prova de fum
Sempre la primera. Comprova que l'script no té errors i que el sistema respon.
export const options = {
vus: 3,
duration: '2m',
thresholds: {
'http_req_failed': ['rate<0.01'],
'http_req_duration': ['p(95)<500'],
},
};Costa dos minuts i evita descobrir als quinze minuts d'una prova d'estrès que la URL estava malament.
Prova de càrrega
La prova de referència. Verifica que el sistema compleix l'SLO amb la càrrega esperada.
export const options = {
scenarios: {
carrega_normal: {
executor: 'constant-arrival-rate',
rate: 120, // 120 peticions per segon, el transit habitual
timeUnit: '1s',
duration: '20m',
preAllocatedVUs: 100,
maxVUs: 500,
},
},
thresholds: {
'http_req_duration': ['p(95)<300'],
'http_req_failed': ['rate<0.001'],
},
};Prova d'estrès
La més informativa de totes. Puja la càrrega escalonadament fins que el sistema es trenca, i l'important no és només on es trenca, sinó com.
export const options = {
scenarios: {
estres: {
executor: 'ramping-arrival-rate',
startRate: 50,
timeUnit: '1s',
preAllocatedVUs: 500,
maxVUs: 5000,
stages: [
{ duration: '3m', target: 100 },
{ duration: '3m', target: 200 },
{ duration: '3m', target: 400 },
{ duration: '3m', target: 600 },
{ duration: '3m', target: 800 },
{ duration: '3m', target: 1200 },
{ duration: '5m', target: 0 }, // Recuperacio: es recupera sol?
],
},
},
// SENSE thresholds: volem que arribi fins al final encara que falli.
};El que cal observar:
| Observació | Què significa |
|---|---|
| El punt d'inflexió | El rps a partir del qual la latència es dispara. És la capacitat real |
| La forma de la degradació | Gradual (bo) o de cop (dolent)? |
| El comportament en saturació | Errors ràpids (bo) o timeouts llargs (dolent)? |
| La recuperació | En baixar la càrrega, torna a la normalitat o es queda degradat? |
Aquest últim punt és crític i sol revelar problemes greus. Un sistema que no es recupera sol després d'una punta té un problema estructural: una cua interna que no es buida, connexions que no s'alliberen, un pool esgotat que no es regenera. En producció això significa que un pic de cinc minuts deixa el sistema trencat durant hores.
Prova de punta
Reprodueix exactament l'obertura de la venda.
export const options = {
scenarios: {
punta: {
executor: 'ramping-arrival-rate',
startRate: 20,
timeUnit: '1s',
preAllocatedVUs: 100,
maxVUs: 3000,
stages: [
{ duration: '2m', target: 20 }, // Calma
{ duration: '30s', target: 800 }, // PUNTA BRUTAL
{ duration: '3m', target: 800 }, // Sostingut
{ duration: '2m', target: 20 }, // Tornada a la calma
],
},
},
};És la prova que valida tot el mòdul 9. Amb ella es comprova si l'HPA reacciona a temps, si el coixí de sobreaprovisionament absorbeix el cop, i si el disparador cron de KEDA fa la seva feina. Executa-la dues vegades: amb el preescalfament cron activat i sense, i compara.
Prova de resistència
La més avorrida i la que detecta els problemes més cars.
export const options = {
scenarios: {
resistencia: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '8h', // VUIT HORES
preAllocatedVUs: 100,
maxVUs: 300,
},
},
};El que es busca:
- Fuites de memòria: la memòria dels pods creix de forma monòtona fins a l'
OOMKilled. - Fuites de connexions: les connexions a PostgreSQL creixen i mai s'alliberen.
- Degradació gradual: la latència p95 creix un 5 % cada hora.
- Fragmentació: el rendiment cau sense que pugi cap recurs.
S'executa una vegada abans d'un esdeveniment important. És la prova que evita que la plataforma aguanti bé les dues primeres hores del pont de maig i caigui a la tercera.
- Executar la prova dins del clúster
Executar k6 des del teu portàtil mesura, sobretot, la teva connexió a internet. Per mesurar la plataforma, la prova ha de córrer dins del clúster, contra rutas-norte-pre.
Com un Job de Kubernetes
# k8s/proves/job-carrega-pont-maig.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: script-carrega
namespace: rutas-norte-pre
data:
pont-maig.js: |
// (el contingut de l'script de l'apartat 2)
---
apiVersion: batch/v1
kind: Job
metadata:
name: carrega-pont-maig
namespace: rutas-norte-pre
labels:
app: prova-carrega
app.kubernetes.io/part-of: rutas-norte
spec:
backoffLimit: 0 # Si falla, NO reintentar: falsejaria els resultats
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: prova-carrega
spec:
restartPolicy: Never
# El generador de carrega NO ha de competir per recursos amb el que mesura.
# L'enviem a un node dedicat amb taint i toleration (06-05).
tolerations:
- key: rol
operator: Equal
value: proves
effect: NoSchedule
nodeSelector:
rol: proves
containers:
- name: k6
image: grafana/k6:0.52.0
command: ["k6", "run"]
args:
- "--out"
- "experimental-prometheus-rw" # Envia metriques a Prometheus
- "--tag"
- "prova=pont-maig"
- "/scripts/pont-maig.js"
env:
- name: URL_BASE
value: "http://api-reserves.rutas-norte-pre.svc.cluster.local"
- name: K6_PROMETHEUS_RW_SERVER_URL
value: "http://prometheus-operated.monitoratge.svc.cluster.local:9090/api/v1/write"
- name: K6_PROMETHEUS_RW_TREND_STATS
value: "p(50),p(95),p(99),max"
resources:
# GENEROS A PROPOSIT. Si k6 es queda sense CPU, no genera la carrega
# demanada i la prova MENTEIX: mesuraras un sistema que aguanta be
# perque mai va rebre la carrega que et pensaves.
requests:
cpu: "2"
memory: 2Gi
limits:
cpu: "4"
memory: 4Gi
volumeMounts:
- name: scripts
mountPath: /scripts
volumes:
- name: scripts
configMap:
name: script-carregakubectl apply -f k8s/proves/job-carrega-pont-maig.yaml
kubectl logs -f job/carrega-pont-maig -n rutas-norte-preEl detall del node dedicat no és un luxe. Si k6 corre al mateix node que api-reserves, competeixen per CPU, i no sabràs si la latència alta és del sistema o del generador de càrrega. És un error clàssic que invalida proves senceres.
I l'avís sobre els recursos de k6 mereix èmfasi: una prova de càrrega amb un generador estrangulat és pitjor que no fer la prova, perquè produeix una falsa sensació de seguretat. Comprova sempre a la sortida de k6 que la taxa d'arribada real coincideix amb la demanada.
Amb l'operador de k6
Per a proves distribuïdes entre diversos pods:
apiVersion: k6.io/v1alpha1
kind: TestRun
metadata:
name: carrega-pont-maig
namespace: rutas-norte-pre
spec:
parallelism: 4 # Quatre pods generant carrega en paral·lel
script:
configMap:
name: script-carrega
file: pont-maig.js
runner:
resources:
requests: {cpu: "2", memory: 2Gi}
limits: {cpu: "2", memory: 2Gi}Necessari quan un sol pod no pot generar prou càrrega (a partir d'uns 2.000-3.000 rps, segons l'escenari).
- Llegir els resultats correctament
Aquí és on la majoria de la gent s'equivoca.
La sortida de k6
✓ la cerca retorna 200
✓ la cerca retorna rutes
✗ la reserva retorna 201
↳ 97% — ✓ 8214 / ✗ 254
checks.........................: 98.42% ✓ 47892 ✗ 768
data_received..................: 1.2 GB 1.8 MB/s
data_sent......................: 89 MB 134 kB/s
duracio_cerca_rutes............: avg=98.4ms min=12ms med=71ms max=4.2s p(95)=241ms p(99)=892ms
duracio_confirmar_reserva......: avg=421ms min=89ms med=302ms max=8.9s p(95)=1.4s p(99)=4.1s
errors_reserva.................: 2.96% ✓ 254 ✗ 8214
http_req_blocked...............: avg=1.2ms min=0s med=2µs max=1.1s p(95)=4µs
http_req_connecting............: avg=0.9ms min=0s med=0s max=892ms p(95)=0s
✗ http_req_duration..............: avg=142ms min=8ms med=68ms max=8.9s p(95)=487ms p(99)=2.1s
{ expected_response:true }...: avg=131ms min=8ms med=66ms max=6.2s p(95)=443ms
✗ http_req_failed................: 1.34% ✓ 892 ✗ 65432
http_reqs......................: 66324 99.4/s
iteration_duration.............: avg=6.8s min=3.1s med=6.4s max=22s p(95)=12.4s
iterations.....................: 14204 21.3/s
vus............................: 187 min=20 max=1240
vus_max........................: 3000 min=3000 max=3000
reserves_confirmades...........: 8214 12.3/s
ERRO[0942] thresholds on metrics 'http_req_duration, http_req_failed, errors_reserva' have been crossedPercentils davant de mitjana: la lliçó fonamental
Mira aquestes dues línies:
La mitjana és 142 ms. La mediana és 68 ms. El p95 és 487 ms. El p99 és 2,1 segons.
Si informessis «la latència mitjana és de 142 ms», estaries donant una imatge falsa i tranquil·litzadora. La realitat:
- La meitat dels usuaris tenen una experiència excel·lent (68 ms).
- 5 de cada 100 esperen gairebé mig segon.
- 1 de cada 100 espera més de 2 segons.
- Algú va esperar gairebé 9 segons.
Amb 66.324 peticions, aquest p99 són 663 peticions de més de 2 segons. I amb 100 peticions per segon, això és una petició horrible cada segon, tota l'estona.
La mitjana amaga la cua de la distribució, i la cua és on viu la insatisfacció de l'usuari.
| Mètrica | Què diu | Utilitat |
|---|---|---|
avg (mitjana) |
La mitjana aritmètica | Gairebé cap. Un sol valor extrem la distorsiona |
med / p(50) |
La mediana: la meitat és per sota | Experiència de l'usuari típic |
p(95) |
95 de cada 100 són per sota | La mètrica de referència per a l'SLO |
p(99) |
99 de cada 100 | L'experiència del pitjor 1 %. Importa en escala |
max |
El pitjor cas observat | Útil per detectar timeouts i anomalies |
Un efecte que sorprèn molta gent: en una sessió amb moltes peticions, el p99 es converteix en l'experiència típica. Si carregar la pàgina de Rutas Norte fa 30 peticions, la probabilitat que almenys una caigui al p99 és 1 - 0,99³⁰ = 26 %. Un de cada quatre usuaris pateix el p99 a cada càrrega de pàgina.
Per això els SLO es defineixen sobre p95 i p99, mai sobre la mitjana.
El punt d'inflexió
En una prova d'estrès, aquesta és la taula que cal construir:
| Càrrega (rps) | p50 | p95 | p99 | Errors | CPU mitjana |
|---|---|---|---|---|---|
| 100 | 41 ms | 78 ms | 142 ms | 0,00 % | 24 % |
| 200 | 44 ms | 91 ms | 178 ms | 0,00 % | 47 % |
| 300 | 48 ms | 118 ms | 241 ms | 0,00 % | 68 % |
| 400 | 58 ms | 187 ms | 412 ms | 0,01 % | 84 % |
| 500 | 89 ms | 421 ms | 1.240 ms | 0,4 % | 94 % |
| 600 | 340 ms | 2.100 ms | 6.800 ms | 8,2 % | 98 % |
| 800 | 1.900 ms | 8.400 ms | timeout | 34,1 % | 99 % |
El punt d'inflexió és entre 400 i 500 rps. Fins a 400, la latència creix de manera suau i proporcionada. A partir de 500, es dispara de forma no lineal: de 400 a 500 rps (un 25 % més de càrrega) el p95 es multiplica per 2,25.
Això té una explicació teòrica precisa, i conèixer-la ajuda: és la teoria de cues. Quan la utilització d'un recurs s'acosta al 100 %, el temps d'espera tendeix a infinit. La fórmula aproximada per a una cua simple:
Temps d'espera ≈ TempsServei × (utilitzacio / (1 - utilitzacio))
Utilitzacio 50 %: espera = 1,0 x temps de servei
Utilitzacio 80 %: espera = 4,0 x temps de servei
Utilitzacio 90 %: espera = 9,0 x temps de servei
Utilitzacio 95 %: espera = 19,0 x temps de servei
Utilitzacio 99 %: espera = 99,0 x temps de serveiAquesta és la justificació matemàtica de per què l'objectiu de l'HPA ha d'estar al 60-70 % i no al 90 % (09-01). Al 90 % d'utilització, el temps d'espera ja és nou vegades el temps de servei. El marge del 30 % no és conservadorisme: és evitar la zona on la latència explota.
La capacitat real per rèplica es defineix al punt d'inflexió, no al punt de trencament. Amb 4 rèpliques i una inflexió a 450 rps: 112 rps per rèplica. Aplicant el marge del 30 %, el threshold de KEDA seria 78 rps. Molt a prop dels 85 que fèiem servir: els números del mòdul estaven ben calibrats.
Errors: quins importen
Un 1,34 % de fallades HTTP globals, però un 2,96 % de fallades a les reserves. La diferència importa molt: les fallades es concentren a l'operació que genera ingressos.
Una cerca fallida és molesta; l'usuari reintenta. Una reserva fallida són diners perduts i, potencialment, un cobrament sense bitllet.
Segmenta sempre els errors per operació de negoci, no només globalment. És la raó per la qual l'script defineix errorReserves com a mètrica pròpia.
- Ajust de l'aplicació: el pool de connexions
I arribem a on gairebé sempre és el problema.
L'aritmètica que tomba la base de dades
Aquest és l'error més car i més freqüent a Kubernetes, i és purament aritmètic.
Configuracio "raonable" d'api-reserves (Node.js amb pg-pool):
pool.max = 20 connexions
Situacio normal: 4 repliques.
Connexions totals: 4 x 20 = 80.
postgres-reserves: max_connections = 200.
80 < 200. Tot be.
PONT DE MAIG: l'HPA escala a 30 repliques.
Connexions totals: 30 x 20 = 600.
max_connections = 200.
400 connexions REBUTJADES.El que passa a continuació és una cascada:
1. Les repliques 11 a 30 no aconsegueixen connexio.
2. Les seves peticions fallen amb "too many connections" o esperen fins al timeout.
3. api-reserves retorna 500. La CPU PUJA (gestio d'errors, reintents).
4. L'HPA veu mes CPU i vol escalar MES. maxReplicas ho talla en 30.
5. Les 10 repliques que SI tenen connexio reben tot el transit.
6. PostgreSQL, amb 200 connexions actives, dedica mes temps a canviar de
context entre processos que a executar consultes.
7. Les consultes s'alenteixen. Les connexions es retenen mes temps.
8. Col·lapse total.Escalar va empitjorar la situació. Amb 4 rèpliques la plataforma anava lenta; amb 30 està caiguda.
La regla
El factor 0,8 reserva connexions per a les tasques de manteniment, les còpies de seguretat, l'exportador de mètriques i les connexions d'administració.
Per a Rutas Norte:
maxReplicas d'api-reserves: 30
max_connections de postgres-reserves: 200
Reserva del 20 %: 200 x 0,8 = 160
pool_per_replica <= 160 / 30 = 5,33 -> 5 connexions per replicaCinc connexions per rèplica, no vint.
Però, basten 5 connexions?
La pregunta natural és si amb 5 connexions una rèplica pot atendre el trànsit. La resposta surt de la llei de Little:
Concurrencia = Taxa d'arribada x Temps de servei
Amb 5 connexions i una consulta mitjana de 8 ms:
Consultes per segon = 5 / 0,008 = 625 consultes/s per replica
Si cada peticio HTTP fa 2 consultes de mitjana:
Peticions per segon = 625 / 2 = 312 rps per replica312 rps per rèplica, molt per sobre dels 112 rps del punt d'inflexió. El pool de 5 connexions no és el limitant.
Això revela una cosa important: el pool de 20 connexions mai va estar justificat. Era un valor per defecte copiat sense pensar. I no només era innecessari: era el mecanisme que tombaria la plataforma al pic.
Configuració correcta
# ConfigMap d'api-reserves
apiVersion: v1
kind: ConfigMap
metadata:
name: api-reserves-config
namespace: rutas-norte-pro
data:
# POOL DE CONNEXIONS A POSTGRESQL
#
# Calcul: maxReplicas (30) x POOL_MAX <= max_connections (200) x 0,8 = 160
# POOL_MAX <= 160 / 30 = 5,33 -> 5
#
# Verificacio de capacitat (llei de Little):
# 5 connexions / 8 ms per consulta = 625 consultes/s per replica
# Amb 2 consultes per peticio: 312 rps per replica.
# Punt d'inflexio mesurat: 112 rps per replica.
# El pool NO es el limitant. En sobra amb 5.
#
# NO PUJAR sense recalcular. Amb 30 repliques, cada connexio extra per replica
# son 30 connexions mes contra un limit de 200.
PG_POOL_MAX: "5"
PG_POOL_MIN: "2"
# Timeout d'espera per una connexio lliure. 3 segons: preferim fallar
# rapid (l'usuari reintenta) a acumular peticions esperant.
PG_POOL_ACQUIRE_TIMEOUT_MS: "3000"
# Tancar connexions ocioses als 30 s: allibera recursos a PostgreSQL
# quan l'HPA baixa repliques.
PG_POOL_IDLE_TIMEOUT_MS: "30000"
# Reciclar connexions cada 30 minuts: evita connexions zombis i fuites
# de memoria del costat del servidor.
PG_POOL_MAX_LIFETIME_MS: "1800000"La solució de fons: un pool compartit
Cinc connexions per rèplica funciona, però és fràgil: qualsevol canvi del maxReplicas obliga a recalcular. La solució robusta és PgBouncer, un multiplexor de connexions.
flowchart LR
subgraph API["30 repliques d'api-reserves"]
A1[replica 1<br/>pool: 20]
A2[replica 2<br/>pool: 20]
A3[...]
A30[replica 30<br/>pool: 20]
end
PB[PgBouncer<br/>600 connexions de client<br/>25 al servidor<br/>mode transaction]
PG[(postgres-reserves<br/>max_connections: 200<br/>25 en us)]
A1 --> PB
A2 --> PB
A3 --> PB
A30 --> PB
PB --> PG
style PB fill:#cde,stroke:#369,stroke-width:2px
PgBouncer en mode transaction assigna una connexió de servidor només mentre dura una transacció, no mentre dura la connexió del client. Com que les transaccions duren mil·lisegons i les connexions duren minuts, la multiplexació és enorme: 600 connexions de client sobre 25 de servidor.
# k8s/base/deployment-pgbouncer.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pgbouncer
namespace: rutas-norte-pro
labels:
app: pgbouncer
app.kubernetes.io/part-of: rutas-norte
spec:
replicas: 3
selector:
matchLabels:
app: pgbouncer
template:
metadata:
labels:
app: pgbouncer
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: pgbouncer
containers:
- name: pgbouncer
image: bitnami/pgbouncer:1.22.1
env:
- name: PGBOUNCER_POOL_MODE
# TRANSACTION: la connexio al servidor s'assigna nomes durant
# una transaccio. Es el que permet la multiplexacio massiva.
# ADVERTENCIA: incompatible amb sentencies preparades de sessio,
# cursors amb nom i LISTEN/NOTIFY. L'aplicacio ha d'estar
# escrita tenint-ho en compte.
value: "transaction"
- name: PGBOUNCER_MAX_CLIENT_CONN
# 1000 connexions de CLIENT. Amb 30 repliques x 20 = 600. En sobra.
value: "1000"
- name: PGBOUNCER_DEFAULT_POOL_SIZE
# Nomes 25 connexions al SERVIDOR per base de dades i usuari.
# Amb 3 repliques de pgbouncer: 75 connexions totals a PostgreSQL,
# molt per sota de les 200. I estables: no depenen de l'HPA.
value: "25"
- name: PGBOUNCER_RESERVE_POOL_SIZE
value: "5"
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {cpu: 500m, memory: 256Mi}Avantatge decisiu: el nombre de connexions a PostgreSQL deixa de dependre del nombre de rèpliques. L'HPA pot escalar a 30, 50 o 100 rèpliques sense que PostgreSQL se n'assabenti. És l'única forma robusta de combinar autoescalat agressiu amb una base de dades relacional.
- Ajust de l'aplicació: consultes, índexs i memòria cau
El problema N+1
El patró que més vegades he vist convertir una API ràpida en una API lenta.
// MALAMENT: una consulta per a les rutes, i una MES per cada ruta. N+1.
const rutes = await db.query(
'SELECT id, origen, desti, hora_sortida FROM rutes WHERE origen=$1 AND desti=$2',
[origen, desti]
);
for (const ruta of rutes) {
// Si hi ha 30 rutes, aixo son 30 consultes ADDICIONALS.
ruta.placesLliures = await db.query(
'SELECT COUNT(*) FROM places WHERE ruta_id=$1 AND estat=$2',
[ruta.id, 'lliure']
);
}
// TOTAL: 31 consultes per respondre UNA peticio.Amb 30 rutes i 5 ms per consulta: 155 ms només en anada i tornada a la base de dades, sense comptar el temps de les consultes en si. I 31 consultes per petició significa que 100 rps es converteixen en 3.100 consultes per segon contra PostgreSQL.
// BE: UNA sola consulta amb agregacio.
const rutes = await db.query(`
SELECT
r.id, r.origen, r.desti, r.hora_sortida,
COUNT(p.id) FILTER (WHERE p.estat = 'lliure') AS places_lliures
FROM rutes r
LEFT JOIN places p ON p.ruta_id = r.id
WHERE r.origen = $1 AND r.desti = $2 AND r.data = $3
GROUP BY r.id, r.origen, r.desti, r.hora_sortida
ORDER BY r.hora_sortida
`, [origen, desti, data]);
// TOTAL: 1 consulta.De 31 consultes a 1. L'impacte mesurat a Rutas Norte: de 155 ms a 12 ms a la cerca de rutes. Un factor de 13.
Com detectar-ho: si el nombre de consultes per petició varia amb la mida del resultat, tens un N+1. És visible a les traces distribuïdes (07-03) com una escala de crides idèntiques.
Índexs que falten
-- Diagnostic: com executa PostgreSQL aquesta consulta?
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM rutes WHERE origen = 'BIL' AND desti = 'MAD' AND data = '2026-05-01';Seq Scan on rutes (cost=0.00..18432.00 rows=12 width=84) (actual time=0.031..89.412 rows=8 loops=1)
Filter: ((origen = 'BIL') AND (desti = 'MAD') AND (data = '2026-05-01'))
Rows Removed by Filter: 847992
Buffers: shared hit=2841 read=5591
Planning Time: 0.184 ms
Execution Time: 89.487 msSeq Scan amb Rows Removed by Filter: 847992. PostgreSQL està llegint les 848.000 files de la taula per retornar-ne 8. Cada vegada.
-- L'index compost que resol la consulta
CREATE INDEX CONCURRENTLY idx_rutes_cerca
ON rutes (origen, desti, data)
INCLUDE (hora_sortida, preu_base);Index Scan using idx_rutes_cerca on rutes (cost=0.42..8.61 rows=12 width=84)
(actual time=0.024..0.041 rows=8 loops=1)
Index Cond: ((origen = 'BIL') AND (desti = 'MAD') AND (data = '2026-05-01'))
Buffers: shared hit=5
Planning Time: 0.211 ms
Execution Time: 0.068 msDe 89,5 ms a 0,068 ms. Un factor de 1.316.
Tres detalls importants de l'índex:
CONCURRENTLY: crea l'índex sense bloquejar escriptures. Triga més però no interromp el servei. Obligatori en producció.- L'ordre de les columnes importa: primer les d'igualtat més selectives. Un índex
(origen, desti, data)serveix per a consultes perorigen, perorigen+destii pels tres; no serveix per consultar només perdata. INCLUDE: afegeix columnes a l'índex sense que formin part de la clau, permetent un index-only scan que ni tan sols toca la taula.
Com trobar les consultes problemàtiques:
-- Requereix l'extensio pg_stat_statements
SELECT
substring(query, 1, 80) AS consulta,
calls,
round(mean_exec_time::numeric, 2) AS mitjana_ms,
round(total_exec_time::numeric / 1000, 1) AS total_s,
rows / GREATEST(calls, 1) AS files_per_crida
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;Ordena per total_exec_time, no per mean_exec_time. Una consulta de 2 ms executada un milió de vegades consumeix molt més temps total que una de 500 ms executada cent vegades. El temps total és el que satura la base de dades.
Fer servir redis-cache de debò
redis-cache és a la plataforma des del mòdul 6, però cal comprovar si s'està fent servir bé.
Un 13 % d'encerts és un desastre. El 87 % de les consultes van igualment a PostgreSQL, i a sobre es paga el cost de consultar Redis primer. La memòria cau està fent mal, no bé.
Diagnòstic habitual: claus massa específiques.
// MALAMENT: la clau inclou la marca de temps exacta de la peticio.
// CADA peticio genera una clau diferent. Taxa d'encerts ~0 %.
const clau = `places:${rutaId}:${Date.now()}`;
// MALAMENT: inclou l'identificador de l'usuari, que no afecta el resultat.
const clau = `rutes:${origen}:${desti}:${data}:${usuariId}`;
// BE: nomes els parametres que determinen el resultat.
const clau = `rutes:${origen}:${desti}:${data}`;Estratègia de memòria cau per tipus de dada a Rutas Norte:
| Dada | TTL | Justificació |
|---|---|---|
| Catàleg de rutes i horaris | 1 hora | Canvia amb el canvi de temporada, gairebé mai |
| Disponibilitat de places | 10 segons | Canvia constantment; 10 s és tolerable i absorbeix el 95 % de les lectures |
| Preus calculats | 5 minuts | El motor de preus recalcula cada pocs minuts |
| Dades de l'usuari | 15 minuts | Canvien rarament; s'invaliden en editar |
| Resultat d'una reserva | Mai | És una escriptura; cachejar una escriptura és un error |
El TTL de 10 segons per a la disponibilitat mereix explicació, perquè sembla poc. Amb 500 consultes per segon sobre la mateixa ruta popular:
Sense cache: 500 consultes/s a PostgreSQL.
Amb TTL de 10 s: 1 consulta cada 10 s = 0,1 consultes/s a PostgreSQL.
Reduccio: 5.000 vegades.
Cost: les dades poden estar fins a 10 segons desactualitzades.És acceptable mostrar «queden 12 places» quan en realitat en queden 11? Sí, sempre que la confirmació de la reserva verifiqui la disponibilitat real contra la base de dades. La memòria cau serveix per mostrar; la veritat es comprova en escriure.
Configuració de Redis perquè es comporti com a memòria cau i no com a base de dades:
# ConfigMap de redis-cache
maxmemory 1500mb
maxmemory-policy allkeys-lru
# allkeys-lru: en omplir-se, expulsa les claus menys usades recentment.
# SENSE aixo, Redis creix fins a l'OOMKilled (ho vam veure a 09-02).
- Ajust de l'aplicació: actius estàtics i connexions persistents
Compressió i memòria cau a botiga-web
# ConfigMap de botiga-web: /etc/nginx/conf.d/rutas-norte.conf
# --- COMPRESSIO ---
gzip on;
gzip_vary on;
gzip_min_length 1024; # No comprimir el petit: el cost de CPU no compensa
gzip_comp_level 5; # 5 de 9: bon equilibri. El 9 gasta molta CPU
# per un 2-3 % mes de compressio
gzip_types
text/plain text/css text/javascript
application/javascript application/json
application/xml image/svg+xml;
# Brotli comprimeix un 15-20 % millor que gzip per a text.
# PRECOMPRIMIT al build: cost de CPU ZERO en temps d'execucio.
brotli_static on;
gzip_static on;
# --- CACHE D'ACTIUS ---
# Els fitxers amb hash al nom (app.4f8a2c.js) son IMMUTABLES:
# si canvia el contingut, canvia el nom. Es poden cachejar un any.
location ~* \.(js|css|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off; # No registrar actius: redueix E/S un 80 %
}
# L'HTML NO es cacheja: es el que apunta als fitxers amb hash.
location = /index.html {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
# --- CONNEXIONS PERSISTENTS CAP A api-reserves ---
upstream api_reserves {
server api-reserves.rutas-norte-pro.svc.cluster.local:8080;
# 64 connexions persistents reutilitzades. Sense aixo, nginx obre i tanca
# una connexio TCP per peticio: 3 paquets de handshake mes el tancament.
keepalive 64;
keepalive_requests 1000;
keepalive_timeout 60s;
}
location /api/ {
proxy_pass http://api_reserves/;
# OBLIGATORI perque keepalive funcioni: HTTP/1.1 i sense capcalera
# Connection heretada del client.
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}Impacte mesurat de la compressió:
| Recurs | Sense comprimir | gzip -5 | brotli -11 (precomprimit) |
|---|---|---|---|
app.js |
842 KB | 218 KB | 178 KB |
estils.css |
156 KB | 24 KB | 19 KB |
Resposta de /rutes (JSON) |
48 KB | 6 KB | 5 KB |
Una reducció del 75-90 % en el trànsit, amb un cost de CPU gairebé nul (els actius van precomprimits; només el JSON es comprimeix al vol). En una connexió mòbil, això és la diferència entre 3 segons i 400 ms de càrrega de pàgina.
Connexions persistents: el cost ocult
Sense keepalive, cada petició de nginx a api-reserves obre una connexió TCP nova:
Cost d'una connexio TCP nova (dins del cluster):
SYN -> SYN/ACK -> ACK: ~0,4 ms (3 paquets)
Tancament (FIN/ACK x2): ~0,2 ms
Total: ~0,6 ms de latencia afegida per peticio
Amb 500 peticions/s: 300 ms/s de latencia acumulada.
I pitjor: els ports efimers del costat de nginx s'esgoten.
El rang tipic son ~28.000 ports. Amb TIME_WAIT de 60 s:
28.000 / 60 = 466 connexions noves per segon COM A MAXIM.
Per sobre d'aixo: errors de "cannot assign requested address".L'esgotament de ports efímers és un límit dur que apareix de cop i desconcerta moltíssim quan passa. Les connexions persistents l'eliminen d'arrel.
La mateixa optimització aplica a api-reserves cap a motor-preus i cap a la resta de serveis interns: fes servir sempre un agent HTTP amb keepAlive activat.
// api-reserves: agent HTTP amb connexions persistents
const http = require('http');
const agent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30000,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 5000,
});
- Ajust de recursos: la mecànica de l'estrangulament de CPU
Al mòdul 3 vam veure que el limit de CPU produeix estrangulament. Ara veurem exactament com, perquè el mecanisme explica un comportament que d'altra manera sembla impossible.
El planificador CFS i la quota
El limit de CPU es tradueix en una quota del Completely Fair Scheduler del kernel de Linux, mitjançant dos paràmetres de cgroups:
| Paràmetre | Valor per defecte | Significat |
|---|---|---|
cpu.cfs_period_us |
100.000 µs (100 ms) | La durada de cada període de comptabilitat |
cpu.cfs_quota_us |
Calculat del limit |
Microsegons de CPU permesos per període |
Amb limits.cpu: 1:
Amb limits.cpu: 500m:
El funcionament: cada 100 ms el contenidor rep la seva quota. Quan l'esgota, TOTS els seus fils s'aturen fins al següent període.
Per què l'estrangulament apareix abans del 100 %
Aquí hi ha el punt que sorprèn tothom.
api-reserves amb limits.cpu: 1 -> quota de 100 ms per periode de 100 ms.
Node.js amb 4 fils actius (el bucle d'esdeveniments + 3 del pool de libuv
per a criptografia, compressio i DNS):
Periode de 100 ms:
t=0 ms: els 4 fils comencen a treballar
t=25 ms: 4 fils x 25 ms = 100 ms de CPU consumits. QUOTA ESGOTADA.
t=25 ms: el kernel ATURA tots els fils.
t=100 ms: nou periode. Es reprenen.
RESULTAT: el contenidor nomes treballa 25 ms de cada 100.
Esta aturat el 75 % DEL TEMPS DE RELLOTGE.
I tanmateix, kubectl top mostra:
CPU: 1000m (100 % del limit)
Va consumir exactament la seva quota. Sembla "al limit" i no "estrangulat".
Pero qualsevol peticio que arribi a t=30 ms ESPERA 70 ms sense fer res.Un pod pot estar greument estrangulat mostrant una utilització que sembla raonable, perquè la utilització es mesura com a quota consumida sobre quota total, no com a temps útil sobre temps de rellotge.
Amb limits.cpu: 500m (quota de 50 ms) i els mateixos 4 fils:
t=12,5 ms: quota esgotada.
El contenidor treballa 12,5 ms de cada 100: el 12,5 % del temps.
Una peticio que arribi a t=20 ms espera 80 ms.
I kubectl top mostraria 500m: "al 100 % del seu limit".Com més fils té el procés, abans esgota la quota i més brutal és l'estrangulament. Un contenidor amb 8 fils i un limit d'1 nucli esgota la seva quota en 12,5 ms i està aturat 87,5 ms de cada 100.
La mètrica que ho delata
# Percentatge de periodes en els quals el contenidor va ser estrangulat
rate(container_cpu_cfs_throttled_periods_total{
namespace="rutas-norte-pro", container="api"
}[5m])
/
rate(container_cpu_cfs_periods_total{
namespace="rutas-norte-pro", container="api"
}[5m])
* 100# Segons d'estrangulament per segon: quant temps REAL es perd
rate(container_cpu_cfs_throttled_seconds_total{
namespace="rutas-norte-pro", container="api"
}[5m])Interpretació:
| Percentatge de períodes estrangulats | Diagnòstic |
|---|---|
| 0-1 % | Sa |
| 1-5 % | Acceptable; pics ocasionals |
| 5-25 % | Problema real. La latència p99 està patint |
| > 25 % | Greu. L'aplicació està aturada la major part del temps |
Aquesta mètrica hauria de ser al panell principal de Grafana de qualsevol plataforma a Kubernetes, i gairebé mai hi és. És l'explicació de la majoria dels misteris de latència p99 alta amb CPU aparentment normal.
El mesurament a Rutas Norte
Abans de l'ajust:
api-reserves, limits.cpu: 1, 4 repliques, 340 rps:
Periodes estrangulats: 18,4 %
Temps estrangulat: 0,31 s/s
Latencia p95: 287 ms
Latencia p99: 891 msGairebé un de cada cinc períodes amb el contenidor aturat. Aquest és l'origen del p99 de 891 ms: peticions que van arribar just després d'esgotar-se la quota i van esperar fins al següent període.
- El debat sobre treure el límit de CPU
És un dels debats més vius de la comunitat Kubernetes, i mereix exposar-se amb honestedat perquè hi ha arguments sòlids en tots dos costats.
La proposta
Treure limits.cpu per complet, deixant només requests.cpu.
resources:
requests:
cpu: 412m # Reserva garantida
memory: 800Mi
limits:
# SENSE limits.cpu
memory: 1600Mi # El limit de memoria SI que es manteEls arguments a favor
1. El requests ja garanteix el repartiment just. El requests.cpu no és només una reserva per al planificador: es tradueix en cpu.shares (o cpu.weight en cgroups v2), que és el pes relatiu en el repartiment quan hi ha contenció. Un pod amb 412m de requests rep garantit el seu 412m proporcional quan el node està saturat.
2. La CPU és compressible. A diferència de la memòria, la CPU es pot treure i tornar sense matar res. Un pod que fa servir CPU de més simplement anirà més a poc a poc quan arribi la contenció; no es corromp ni mor.
3. La CPU ociosa es malgasta. Si el node té CPU lliure i el teu pod està estrangulat, aquesta CPU es perd. Sense limit, el pod aprofita el que sobra.
4. Elimina d'arrel l'estrangulament. Sense quota, no hi ha períodes estrangulats. El p99 millora immediatament.
5. L'arrencada és més ràpida. Node.js compilant i carregant mòduls consumeix molta CPU als primers segons. Sense limit, arrenca en 8 segons en lloc de 25. I això és directament rellevant per a l'autoescalat (apartat 13).
Els arguments en contra
1. Un pod defectuós pot afectar els veïns. Un bucle infinit o una fuita consumiria tota la CPU disponible del node. Els altres pods tenen el seu requests garantit, però perden el marge de ràfega.
2. El comportament es torna impredictible. El rendiment d'un pod depèn de què més hi ha al seu node. La mateixa petició triga 40 ms en un node buit i 90 ms en un de ple. Això complica moltíssim el diagnòstic i fa que els mesuraments no siguin reproduïbles.
3. Trenca la QoS Guaranteed. Sense limits iguals a requests, el pod no pot ser Guaranteed (mòdul 3). Per a postgres-reserves, que volem que sigui l'últim candidat al desallotjament, això és inacceptable.
4. Pot emmascarar problemes. Un pod que necessita el triple del seu requests està mal dimensionat. Amb limit, ho notes (estrangulament); sense limit, funciona bé fins que un dia el node s'omple i tot es degrada alhora.
5. Les proves de càrrega deixen de ser fiables. Si mesures en preproducció amb nodes buits, obtindràs números que no es reproduiran en producció amb nodes plens.
La postura recomanada
| Tipus de càrrega | limits.cpu |
Justificació |
|---|---|---|
Serveis al camí crític (api-reserves, botiga-web) |
Sense limits.cpu, o molt folgat |
La latència mana; l'estrangulament és l'enemic |
Treballs per lots (informes-ocupacio) |
Amb limits.cpu |
Poden anar a poc a poc; no han de molestar la resta |
Bases de dades (postgres-reserves) |
Amb limits.cpu = requests |
QoS Guaranteed i rendiment predictible |
| Sidecars i agents | Amb limits.cpu baix |
No han de créixer mai; un exportador amb fuita és un perill |
| Càrregues desconegudes o de tercers | Amb limits.cpu |
Aïllament per precaució |
| Entorns multiinquilí | Amb limits.cpu sempre |
L'aïllament és un requisit, no una opció |
I la decisió per a Rutas Norte:
# api-reserves: SENSE limit de CPU
resources:
requests:
cpu: 412m # Reserva garantida. Pes en el repartiment.
memory: 800Mi
limits:
# SENSE limits.cpu DELIBERADAMENT.
#
# Motiu: amb limits.cpu: 1 mesuravem un 18,4 % de periodes estrangulats
# i un p99 de 891 ms. Node.js fa servir diversos fils (bucle d'esdeveniments
# + pool de libuv) i esgota la quota de 100 ms en ~25 ms de rellotge.
#
# Sense limit:
# - Estrangulament: 0 %
# - p99: 891 ms -> 340 ms
# - Arrencada: 25 s -> 9 s (rellevant per a l'autoescalat, 09-01)
#
# Risc assumit: un pod defectuos pot consumir la CPU sobrant del
# node. Mitigacions:
# - El requests garanteix el minim de tots els altres pods.
# - LimitRange del namespace imposa un sostre de 4 nuclis per contenidor.
# - Alerta si un pod supera 3x el seu requests durant 10 minuts.
#
# El LIMIT DE MEMORIA SI QUE ES MANTE: la memoria NO es compressible i
# un pod sense limit de memoria pot tombar el node sencer.
memory: 1600MiFixa't en l'última línia: el límit de memòria sempre es manté. L'asimetria és essencial. La CPU és compressible (te la treuen i vas més lent); la memòria no ho és (si no n'hi ha, el kernel mata processos). Un pod sense límit de memòria amb una fuita pot provocar l'OOMKilled de pods veïns innocents, o deixar el node NotReady.
I l'alerta que acompanya la decisió:
# Un pod que consumeix mes del triple del seu requests de forma sostinguda
(
rate(container_cpu_usage_seconds_total{namespace="rutas-norte-pro"}[10m])
/
on(pod, container) kube_pod_container_resource_requests{resource="cpu"}
) > 3
- Memòria i el recol·lector d'escombraries de Node.js en un contenidor
El problema del heap per defecte
Node.js decideix la mida màxima del seu heap de JavaScript en arrencar, i la seva heurística mira la memòria del sistema, no la del cgroup del contenidor.
Node de 16 GiB. Contenidor amb limits.memory: 1Gi.
Node.js (segons versio i configuracio) pot decidir un heap maxim
basant-se en els 16 GiB de l'amfitrio, no en l'1 GiB del contenidor.
Resultat: el recol·lector d'escombraries no s'activa amb urgencia perque creu
que te memoria de sobres. El heap creix fins a fregar el GiB del contenidor.
El kernel: OOMKilled.
I el pitjor: el proces mor SENSE avis ni traca. Nomes apareix "OOMKilled"
a l'estat del pod. Node.js ni se n'assabenta.Les versions modernes de Node.js són conscients dels cgroups i s'ajusten millor, però el comportament depèn de la versió i de la configuració del contenidor, així que la pràctica correcta és no confiar en l'heurística.
La solució: fixar el heap explícitament
env:
# --max-old-space-size fixa la mida maxima del heap d'objectes antics
# en MEGABYTES.
#
# Calcul per a un limits.memory de 1600Mi:
# Heap de JavaScript: 1024 MB (--max-old-space-size=1024)
# Buffers i ArrayBuffer: ~200 MB (fora del heap)
# Codi natiu i biblioteques: ~150 MB
# Pila i estructures internes: ~80 MB
# Marge de seguretat: ~146 MB
# -------------------------------------
# TOTAL: 1600 MB
#
# REGLA PRACTICA: heap = 65-70 % del limits.memory. La resta NO es
# malbaratament: es memoria que Node.js necessita fora del heap.
- name: NODE_OPTIONS
value: "--max-old-space-size=1024"La regla del 65-70 % és la que cal recordar. L'error habitual és posar --max-old-space-size igual al limits.memory, cosa que garanteix un OOMKilled tan bon punt el heap s'acosti al seu màxim: la memòria fora del heap no hi cap.
Diagnosticar problemes de memòria
# Us de memoria davant del limit
container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}
/
on(pod, container) kube_pod_container_resource_limits{resource="memory"}# OOMKilled recents: el senyal inequivoc
increase(kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"}[1h]) > 0
and on(pod, container)
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} == 1I la signatura d'una fuita de memòria: el working_set creix de forma monòtona i mai baixa, ni tan sols a les valls de trànsit nocturn. Si baixa a les valls, és comportament normal del recol·lector d'escombraries.
El detall de container_memory_working_set_bytes
És la mètrica que el kernel fa servir per decidir l'OOMKilled, i no és el mateix que container_memory_usage_bytes:
| Mètrica | Què inclou | Per a què serveix |
|---|---|---|
container_memory_usage_bytes |
Tot, inclosa la memòria cau de pàgines reclamable | Gairebé res; sobreestima |
container_memory_working_set_bytes |
Ús real menys la memòria cau reclamable | La que decideix l'OOMKilled |
container_memory_rss |
Memòria anònima resident | Útil per detectar fuites |
Fes servir sempre working_set per a les alertes de memòria. Alertar sobre usage_bytes produeix falsos positius constants, perquè inclou memòria cau de fitxers que el kernel alliberarà sense problema.
- La mida de pod adequada
Amb una quantitat fixa de recursos, molts pods petits o pocs de grans?
Pressupost: 12 nuclis i 24 GiB per a api-reserves.
Opcio A: 30 pods de 400m i 800Mi
Opcio B: 12 pods d'1 nucli i 2 GiB
Opcio C: 6 pods de 2 nuclis i 4 GiB| Criteri | Molts de petits (A) | Pocs de grans (C) |
|---|---|---|
| Granularitat de l'escalat | Fina: +400m per pas | Gruixuda: +2 nuclis per pas |
| Impacte de la caiguda d'un pod | 1/30 = 3,3 % | 1/6 = 16,7 % |
| Sobrecàrrega per pod (runtime, sidecars) | Alta: 30 sidecars, 30 pools | Baixa: 6 |
| Distribució entre nodes i zones | Fàcil | Difícil: potser no hi caben |
| Aprofitament de diversos nuclis | Dolent si l'app és monofil | Bo si l'app és multifil |
| Temps d'arrencada total en escalar | 30 arrencades | 6 arrencades |
| Connexions a PostgreSQL | 30 pools | 6 pools |
| Memòria cau en memòria per pod | Fragmentada, poc efectiva | Consolidada, més efectiva |
| Eficiència d'empaquetament en nodes | Alta | Baixa: forats desaprofitats |
El criteri decisiu: el model de concurrència
Node.js és d'un sol fil per al codi JavaScript. Un procés de Node.js no pot fer servir més d'un nucli per executar el teu codi, per molta CPU que li donis.
Pod d'api-reserves amb 4 nuclis:
- El bucle d'esdeveniments fa servir 1 nucli com a maxim.
- El pool de libuv (4 fils per defecte) fa servir alguna cosa mes per a E/S de fitxers,
DNS, criptografia i compressio.
- A la practica, un proces de Node.js aprofita ~1,3 nuclis.
Els altres 2,7 nuclis ESTAN MALGASTATS.Per a Node.js, molts pods petits és l'opció correcta, amb una mida d'entre 0,5 i 1,5 nuclis per pod.
Per a altres plataformes la resposta canvia:
| Plataforma | Mida recomanada | Motiu |
|---|---|---|
| Node.js (un procés) | 0,5-1,5 nuclis | Monofil per a JavaScript |
Node.js amb cluster |
1 nucli per treballador | Diversos processos al pod |
| Java / JVM | 2-4 nuclis | Multifil real; el GC es beneficia de més nuclis i memòria |
| Go | 1-4 nuclis | Multifil eficient; escala bé en tots dos sentits |
| Python (WSGI) | 0,5-1 nucli per treballador | El GIL limita a un fil per procés |
| nginx | 0,2-0,5 nuclis | Molt eficient; moltes rèpliques petites |
| PostgreSQL | 4-8 nuclis | Un procés per connexió; es beneficia de molta memòria |
Decisió final per a Rutas Norte:
# api-reserves: 12 pods d'1 nucli (opcio B), no 30 de 400m ni 6 de 2 nuclis.
#
# Contra l'opcio A (30 pods de 400m):
# - 30 pools de connexions contra PostgreSQL (veure apartat 6).
# - 30 sidecars exportadors: 30 x 6m = 180m nomes en observabilitat.
# - 400m es poc per a l'ARRENCADA de Node.js, que consumeix molta CPU
# compilant: l'arrencada passaria de 9 s a mes de 30 s.
#
# Contra l'opcio C (6 pods de 2 nuclis):
# - Node.js no aprofita 2 nuclis amb un sol proces.
# - Perdre 1 de 6 repliques es perdre el 16,7 % de la capacitat.
# - Granularitat d'escalat massa gruixuda.
#
# 1 nucli es el punt dolc: suficient per al bucle d'esdeveniments i el pool
# de libuv, amb marge per a l'arrencada, i granularitat fina per a l'HPA.
resources:
requests:
cpu: 412m # Del VPA (09-02): consum real en regim permanent
memory: 800Mi
limits:
memory: 1600Mi # Sense limits.cpu (apartat 10)El requests de 412m amb capacitat de ràfega fins al que el node permeti és el millor de tots dos mons: es reserva el que es consumeix habitualment, però es pot fer servir més durant l'arrencada i les puntes.
- Arrencada ràpida com a requisit de l'autoescalat
Aquest apartat connecta directament amb tot l'anterior del mòdul, i és la raó per la qual el rendiment i l'escalat no són temes separats.
El càlcul
L'HPA detecta la sobrecarrega i demana repliques noves.
Temps fins que una replica nova aten transit:
Decisio de l'HPA: 0-15 s (cicle de 15 s)
Planificacio: 1 s
Descarrega de la imatge: 0-60 s (0 si esta cachejada)
Arrencada del contenidor: 5-30 s
Sonda de readiness OK: 3-10 s
----------------------------------------------
TOTAL: 9-116 sDurant tot aquest temps, les rèpliques existents estan sobrecarregades. Una arrencada de 90 segons converteix l'autoescalat en una cosa gairebé inútil per a puntes ràpides.
Palanca 1: imatge lleugera
# --- Etapa de construccio ---
FROM node:20.15-bookworm AS constructor
WORKDIR /app
COPY package*.json ./
# npm ci amb --omit=dev: nomes dependencies de produccio
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# --- Etapa final: NOMES el necessari per executar ---
FROM node:20.15-alpine
WORKDIR /app
# Usuari sense privilegis (08-02)
RUN addgroup -g 10001 rutasnorte && adduser -u 10001 -G rutasnorte -D rutasnorte
COPY --from=constructor --chown=10001:10001 /app/node_modules ./node_modules
COPY --from=constructor --chown=10001:10001 /app/dist ./dist
COPY --from=constructor --chown=10001:10001 /app/package.json ./
USER 10001
EXPOSE 8080
CMD ["node", "dist/servidor.js"]Impacte mesurat:
| Imatge | Mida | Descàrrega (sense memòria cau) | Descàrrega (cachejada) |
|---|---|---|---|
node:20 (completa) |
1,1 GB | 48 s | 0 s |
node:20-slim |
280 MB | 14 s | 0 s |
node:20-alpine multietapa |
94 MB | 5 s | 0 s |
De 48 a 5 segons. I com a benefici addicional del mòdul 8: menys paquets significa menys superfície d'atac i moltes menys vulnerabilitats a l'escaneig de Trivy (08-06).
Palanca 2: precàrrega d'imatges als nodes
Si la imatge ja és al node, la descàrrega triga zero. Dues formes:
# k8s/entorns/pro/daemonset-precarrega-imatges.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: precarrega-imatges
namespace: rutas-norte-pro
labels:
app: precarrega-imatges
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels:
app: precarrega-imatges
template:
metadata:
labels:
app: precarrega-imatges
spec:
# Prioritat baixa: si cal el forat, que se'n vagi.
priorityClassName: farciment-capacitat
# Els initContainers DESCARREGUEN les imatges al node i acaben.
# A partir d'aqui queden a la cache local de containerd.
initContainers:
- name: precarregar-api
image: registry.rutasnorte.example/api-reserves:1.14.2
command: ["/bin/true"]
- name: precarregar-web
image: registry.rutasnorte.example/botiga-web:3.2.0
command: ["/bin/true"]
- name: precarregar-worker
image: registry.rutasnorte.example/worker-notificacions:2.3.0
command: ["/bin/true"]
containers:
- name: pausa
image: registry.k8s.io/pause:3.9
resources:
requests: {cpu: 5m, memory: 8Mi}
limits: {cpu: 10m, memory: 16Mi}Com que és un DaemonSet, s'executa a cada node, inclosos els que arrenqui el Cluster Autoscaler (09-03). Cost: uns pocs megabytes de disc per node i 5m de CPU. Benefici: 5-48 segons menys a cada escalat.
Aquest DaemonSet és una de les millors relacions cost-benefici de tot el mòdul.
L'alternativa al núvol és coure les imatges a l'AMI o imatge de màquina dels nodes, cosa que és encara més ràpida però requereix reconstruir la imatge de màquina a cada desplegament.
Palanca 3: sondes ben ajustades
Del mòdul 7, però amb el matís del rendiment:
# MALAMENT: el pod triga 20 s de mes a rebre transit sense cap rao.
readinessProbe:
httpGet: {path: /preparat, port: 8080}
initialDelaySeconds: 30 # <- Espera 30 s fins i tot si esta llest en 5
periodSeconds: 10 # <- Comprova cada 10 s: perd fins a 10 s mes
failureThreshold: 3
# BE: startupProbe per a l'arrencada, readinessProbe agressiva despres.
startupProbe:
httpGet: {path: /salut, port: 8080}
# Comprova cada segon des del segon 0. Tan bon punt arrenqui, llest.
periodSeconds: 1
# 40 intents x 1 s = 40 segons de marge maxim per a l'arrencada.
# Si triga mes, alguna cosa va malament i ho volem saber.
failureThreshold: 40
readinessProbe:
httpGet: {path: /preparat, port: 8080}
# SENSE initialDelaySeconds: la startupProbe ja cobreix l'arrencada.
periodSeconds: 2 # Comprovacio frequent: reacciona rapid
timeoutSeconds: 1
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet: {path: /salut, port: 8080}
periodSeconds: 10
timeoutSeconds: 3
# failureThreshold alt: NO matar el pod per un pic de latencia temporal.
# Una livenessProbe agressiva sota carrega alta produeix reinicis en cascada:
# el pod va lent -> falla la sonda -> es reinicia -> menys capacitat ->
# els altres van mes lents -> es reinicien tambe. Espiral de mort.
failureThreshold: 6L'advertència sobre la livenessProbe mereix èmfasi. És una de les causes més freqüents de caigudes totals sota càrrega: la sonda de vida, pensada per detectar processos penjats, acaba matant pods sans que simplement van lents, i el resultat és un col·lapse en cascada precisament en el moment de més trànsit. En cas de dubte, fes la livenessProbe molt més permissiva que la readinessProbe.
El resultat acumulat
ABANS:
Descarrega d'imatge (1,1 GB, sense cachejar): 48 s
Arrencada de Node.js (amb limits.cpu: 1): 25 s
initialDelaySeconds de la readiness: 30 s
Cicles de sonda fins a passar: 10 s
----------------------------------------------------
TOTAL: 113 s
DESPRES:
Descarrega (94 MB, precarregada per DaemonSet): 0 s
Arrencada de Node.js (sense limits.cpu): 9 s
startupProbe (comprova cada segon): 1 s
readinessProbe (periode de 2 s): 2 s
----------------------------------------------------
TOTAL: 12 sDe 113 a 12 segons. Un factor de 9,4.
I això canvia qualitativament l'autoescalat: amb 12 segons d'arrencada, l'HPA pot reaccionar a una punta abans que l'usuari la noti. Amb 113 segons, no.
- Ajust del clúster: CoreDNS,
ndots i kube-proxy
ndots i kube-proxyEl ndots: 5 i la latència de les crides sortints
Aquí resolem una cosa que vam deixar pendent a 04-03.
Tot pod té un /etc/resolv.conf generat per Kubernetes:
nameserver 10.96.0.10
search rutas-norte-pro.svc.cluster.local svc.cluster.local cluster.local
options ndots:5ndots:5 significa: si el nom a resoldre té menys de 5 punts, prova primer amb tots els sufixos de search abans d'intentar-ho com a nom absolut.
Vegem què passa en resoldre api.correus-externs.example (2 punts, menys de 5):
1. api.correus-externs.example.rutas-norte-pro.svc.cluster.local -> NXDOMAIN
2. api.correus-externs.example.svc.cluster.local -> NXDOMAIN
3. api.correus-externs.example.cluster.local -> NXDOMAIN
4. api.correus-externs.example -> OK
QUATRE consultes DNS per resoldre un nom.
I com que el resolutor de glibc consulta A i AAAA: VUIT consultes en total.Amb 300 correus per segon de worker-notificacions, cadascun resolent el servidor SMTP:
300 resolucions/s x 8 consultes = 2.400 consultes DNS/s a CoreDNS.
Si cada resolucio afegeix 3 ms de latencia (4 anades i tornades fallides):
300 x 3 ms = 900 ms/s de latencia acumulada malgastada.
I CoreDNS amb 2.400 consultes/s de les quals el 75 % son NXDOMAIN inutils.Tres solucions, de menor a major abast:
Solució 1: el punt final (FQDN absolut).
// MALAMENT: 4 consultes pel ndots:5
const servidor = 'smtp.correus-externs.example';
// BE: el punt final marca el nom com a ABSOLUT.
// El resolutor NO prova els sufixos de search. UNA sola consulta.
const servidor = 'smtp.correus-externs.example.';Un sol caràcter que elimina el 75 % de les consultes DNS. És la solució més barata que existeix i gairebé ningú la coneix.
Solució 2: baixar ndots al pod.
spec:
dnsConfig:
options:
- name: ndots
# Amb ndots: 2, qualsevol nom amb 2 o mes punts es prova
# primer com a absolut. Els noms interns com
# "postgres-reserves.rutas-norte-pro" (1 punt) continuen funcionant
# amb els sufixos de search.
value: "2"
# Fallar rapid si el DNS no respon.
- name: timeout
value: "1"
- name: attempts
value: "2"Compte: baixar ndots pot trencar la resolució de noms curts de serveis interns. Si el teu codi fa servir postgres-reserves a seques (0 punts), continua funcionant amb ndots: 2. Si fa servir postgres-reserves.rutas-norte-pro (1 punt), també. Però convé provar-ho.
Solució 3: NodeLocal DNSCache.
Un DaemonSet que posa una memòria cau de DNS a cada node. Els pods consulten 169.254.20.10 (una adreça local del node) en lloc d'anar per xarxa a CoreDNS.
| Avantatge | Detall |
|---|---|
| Latència | De ~2 ms (xarxa) a ~0,1 ms (local) |
| Càrrega sobre CoreDNS | Reducció del 70-90 % |
| Resiliència | Una fallada de CoreDNS no trenca les consultes cachejades |
| Connexions | Fa servir TCP cap a CoreDNS, evitant el problema de conntrack amb UDP |
És la solució més completa i la recomanada per a qualsevol clúster amb trànsit seriós. La menciono aquí perquè és el complement natural de l'ajust de ndots.
CoreDNS: rèpliques i memòria cau
# ConfigMap de CoreDNS (kube-system/coredns)
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health { lameduck 5s }
ready
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
prometheus :9153
forward . /etc/resolv.conf {
max_concurrent 1000
# Politica de repartiment entre servidors DNS externs.
policy sequential
}
# CACHE: l'optimitzacio mes important de CoreDNS.
# 3600 s (1 h) per a respostes positives del cluster
# 30 s per a respostes negatives (NXDOMAIN)
# El TTL curt per a negatives es DELIBERAT: quan es crea un
# Service nou, no volem que els pods continuin rebent NXDOMAIN
# durant una hora.
cache 3600 {
success 9984 3600
denial 9984 30
}
loop
reload
loadbalance
}Dimensionament de les rèpliques:
Regla practica: 1 replica de CoreDNS per cada 8-10 nodes, amb un minim de 2.
Cluster de Rutas Norte: 9-12 nodes al pic.
Repliques: 3 (una per zona, veure 09-05).
Si les metriques mostren latencia de DNS alta:
histogram_quantile(0.99,
sum(rate(coredns_dns_request_duration_seconds_bucket[5m])) by (le)
) > 0.05
...la solucio sol ser NodeLocal DNSCache, no mes repliques de CoreDNS.kube-proxy en mode IPVS
Recordem 04-01: kube-proxy té dos modes principals.
| Aspecte | iptables (defecte) |
IPVS |
|---|---|---|
| Estructura | Cadenes de regles seqüencials | Taules hash al kernel |
| Complexitat de cerca | O(n) amb el nombre de serveis | O(1) |
| Temps d'actualització de regles | O(n): amb 5.000 serveis, segons | O(1): mil·lisegons |
| Algoritmes de balanceig | Només aleatori | rr, lc, dh, sh, sed, nq |
| Llindar pràctic | Fins a ~1.000 serveis | Més de 1.000 serveis |
# Activar IPVS a minikube
minikube start -p rutas-norte --extra-config=kube-proxy.mode=ipvs
# Verificar
kubectl logs -n kube-system -l k8s-app=kube-proxy | grep -i "proxy mode"Per a Rutas Norte, amb menys de 50 serveis, IPVS no aporta res mesurable. És una optimització per a clústers grans. La menciono perquè sàpigues quan importa: si el teu clúster té més de mil serveis i observes que els canvis als endpoints triguen a propagar-se, aquest és el símptoma.
L'elecció de la mida de node
| Mida | Avantatges | Inconvenients |
|---|---|---|
| Petits (2-4 nuclis) | Granularitat fina del CA; menor impacte per fallada; empaquetament eficient | Més sobrecàrrega de sistema (~0,5 nuclis i 1,5 GiB per node); més DaemonSets |
| Grans (16-32 nuclis) | Menys sobrecàrrega proporcional; menys DaemonSets; millor per a pods grans | Gra gruixut del CA; una fallada perd molt; empaquetament amb forats |
El càlcul que decideix:
Sobrecarrega per node (kubelet, sistema, DaemonSets): ~0,95 nuclis i 2 GiB.
Nodes de 4 nuclis: 0,95 / 4 = 23,8 % de sobrecarrega
Nodes de 8 nuclis: 0,95 / 8 = 11,9 %
Nodes de 16 nuclis: 0,95 / 16 = 5,9 %
Amb 12 nuclis utils necessaris:
Amb nodes de 4: 12 / 3,05 = 3,93 -> 4 nodes = 16 nuclis comprats (33 % d'exces)
Amb nodes de 8: 12 / 7,05 = 1,70 -> 2 nodes = 16 nuclis comprats (33 % d'exces)
Amb nodes de 16: 12 / 15,05 = 0,80 -> 1 node = 16 nuclis comprats (33 % d'exces)
Coincideixen per casualitat en aquest cas, pero el node de 16 ho te TOT en una
maquina: una fallada s'endu la plataforma sencera. I el CA no pot ajustar
amb granularitat menor a 16 nuclis.Recomanació per a Rutas Norte: nodes de 8 nuclis i 32 GiB. Equilibri entre sobrecàrrega (11,9 %) i granularitat, i suficients perquè postgres-reserves tingui marge al grup de dades.
- Emmagatzematge: quan el disc és el límit
postgres-reserves fa servir la StorageClass rutasnorte-rapida de 05-04. Hi ha un moment en què el disc és el veritable límit, i convé saber reconèixer-lo.
L'aritmètica d'IOPS
StorageClass rutasnorte-rapida: SSD amb 3.000 IOPS aprovisionades.
Una escriptura de reserva a PostgreSQL implica:
- Escriptura al WAL (write-ahead log): 1 IOPS
- fsync del WAL (obligatori per a durabilitat): 1 IOPS
- Actualitzacio de pagines de dades (diferida): ~0,3 IOPS amortitzades
- Actualitzacio d'indexs: ~0,5 IOPS
TOTAL: ~2,8 IOPS per reserva
Reserves per segon maximes per IOPS:
3.000 / 2,8 = 1.071 reserves/s
I les lectures que NO son a shared_buffers ni a la cache del sistema
tambe consumeixen IOPS.Amb 300 transaccions per segon al pic, el disc no és el límit per a les escriptures. Però les lectures poden ser-ho si la memòria cau no és efectiva.
Diagnosticar si el disc és el coll d'ampolla
-- Taxa d'encerts de la cache de PostgreSQL
SELECT
datname,
blks_hit,
blks_read,
round(100.0 * blks_hit / GREATEST(blks_hit + blks_read, 1), 2) AS taxa_encerts
FROM pg_stat_database
WHERE datname = 'reserves'; datname | blks_hit | blks_read | taxa_encerts
----------+-----------+-----------+--------------
reserves | 892471203 | 18472941 | 97.9797,97 % d'encerts: excel·lent. Només el 2 % de les lectures van a disc.
| Taxa d'encerts | Diagnòstic |
|---|---|
| > 99 % | Òptim |
| 95-99 % | Bo |
| 90-95 % | Augmentar shared_buffers o la memòria del pod |
| < 90 % | El disc és probablement el coll d'ampolla |
I les mètriques del node:
# IOPS de lectura i escriptura del volum
rate(node_disk_reads_completed_total{device="nvme1n1"}[5m])
+ rate(node_disk_writes_completed_total{device="nvme1n1"}[5m])
# LA METRICA CLAU: temps que el disc esta ocupat.
# Si supera 0,8 (80 %), el disc esta saturat.
rate(node_disk_io_time_seconds_total{device="nvme1n1"}[5m])node_disk_io_time_seconds_total per sobre de 0,8 significa que el disc està saturat, i cap quantitat de CPU o memòria addicional no ho arreglarà.
Ajustos de PostgreSQL relacionats amb el disc
# ConfigMap de postgres-reserves
data:
postgresql.conf: |
# --- MEMORIA (limits.memory del pod: 8 GiB) ---
# shared_buffers: el 25 % de la memoria del contenidor. Es la
# recomanacio classica i continua sent bona.
shared_buffers = 2GB
# effective_cache_size: estimacio de la cache TOTAL disponible
# (shared_buffers + cache de pagines del sistema). NO reserva memoria:
# nomes informa el planificador de consultes perque prefereixi indexs.
effective_cache_size = 6GB
# work_mem per operacio d'ordenacio o hash. COMPTE: es multiplica
# pel nombre d'operacions concurrents. Amb 200 connexions i
# 3 operacions cadascuna: 200 x 3 x 16MB = 9,6 GB. MES QUE EL LIMIT.
# Per aixo 16MB i no mes.
work_mem = 16MB
maintenance_work_mem = 512MB
# --- ESCRIPTURA A DISC ---
# Repartir els fsync del checkpoint al llarg del 90 % de l'interval,
# en lloc de fer-los de cop. Evita pics de latencia periodics.
checkpoint_completion_target = 0.9
# 15 minuts entre checkpoints: menys escriptures, mes temps de
# recuperacio despres d'una caiguda. Compromis raonable.
checkpoint_timeout = 15min
max_wal_size = 4GB
min_wal_size = 1GB
# --- COSTOS D'ACCES (per al planificador) ---
# random_page_cost = 1.1 en SSD (per defecte es 4.0, pensat per a discos
# mecanics). Sense aquest ajust, el planificador EVITA els indexs creient
# que l'acces aleatori es car, i fa escanejos sequencials inutils.
# ES UN DELS AJUSTOS MES RENDIBLES EN SSD.
random_page_cost = 1.1
effective_io_concurrency = 200
# --- CONNEXIONS ---
max_connections = 200
# --- ESTADISTIQUES ---
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.max = 10000
track_io_timing = onEl random_page_cost = 1.1 mereix destacar-se: és un canvi d'una línia que pot transformar el pla d'execució de moltes consultes. El valor per defecte de 4.0 assumeix discos mecànics on un accés aleatori costa quatre vegades més que un de seqüencial. En SSD la diferència és mínima, i amb el valor per defecte el planificador rebutja índexs perfectament bons.
- El pressupost de latència
Tot l'anterior són tècniques. El pressupost de latència és el que les converteix en un pla.
La idea
El negoci defineix un objectiu: «el 95 % de les cerques de rutes han de respondre en menys de 300 ms». Aquest número és útil per a l'SLO, però no diu a ningú què fer. El pressupost de latència el reparteix entre els components, convertint un objectiu global en objectius locals que cada equip pot mesurar i complir.
El pressupost de Rutas Norte
Objectiu: p95 de la cerca de rutes < 300 ms.
flowchart LR
U[Usuari] -->|20 ms| I[Ingress<br/>nginx]
I -->|5 ms| W[botiga-web]
W -->|3 ms| A[api-reserves]
A -->|4 ms| R[redis-cache]
A -->|8 ms| P[postgres-reserves]
A -->|15 ms| M[motor-preus]
style A fill:#cde,stroke:#369
| Component | Pressupost p95 | % del total | Què inclou |
|---|---|---|---|
| Xarxa de l'usuari a l'Ingress | 40 ms | 13,3 % | Latència d'internet, TLS (no controlable) |
| Ingress (nginx) | 8 ms | 2,7 % | Enrutament, terminació TLS |
| Xarxa interna | 6 ms | 2,0 % | 3 salts de xarxa del clúster |
botiga-web |
12 ms | 4,0 % | Proxy invers, compressió |
api-reserves (propi) |
35 ms | 11,7 % | Lògica, serialització, validació |
redis-cache |
6 ms | 2,0 % | Consulta de disponibilitat (95 % d'encerts) |
postgres-reserves |
45 ms | 15,0 % | Consulta de rutes amb índex |
motor-preus |
80 ms | 26,7 % | Càlcul de preus dinàmics |
| Marge de seguretat | 68 ms | 22,7 % | Variabilitat, reintents, GC |
| TOTAL | 300 ms | 100 % |
Tres coses que fa bé aquest pressupost:
1. Inclou un marge explícit del 22,7 %. Sense marge, qualsevol variació (una pausa del recol·lector d'escombraries, un reintent, un pic de xarxa) trenca l'objectiu. Un pressupost que suma exactament 300 ms està garantit a fallar.
2. Identifica el consumidor principal. motor-preus s'emporta el 26,7 % del pressupost. Si cal optimitzar alguna cosa, és això. Sense el pressupost, ningú sabria per on començar.
3. Separa el controlable del que no ho és. Els 40 ms de xarxa de l'usuari no es poden reduir amb codi; només amb una CDN o un punt de presència més proper. És una decisió diferent i cal veure-la com a tal.
De pressupost a alertes
Cada línia del pressupost es converteix en una alerta:
# api-reserves (temps propi, sense dependencies)
histogram_quantile(0.95,
sum(rate(api_reserves_duracio_propia_segons_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 35
# postgres-reserves (temps de consulta vist des de l'API)
histogram_quantile(0.95,
sum(rate(api_reserves_duracio_consulta_bd_segons_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 45
# motor-preus (el major consumidor: alerta prioritaria)
histogram_quantile(0.95,
sum(rate(motor_preus_duracio_segons_bucket{
namespace="rutas-norte-pro"
}[5m])) by (le)
) * 1000 > 80
# I l'objectiu global, que es el que li importa al negoci
histogram_quantile(0.95,
sum(rate(ingress_nginx_request_duration_seconds_bucket{
ingress="rutas-norte-public", path="/rutes"
}[5m])) by (le)
) * 1000 > 300Quan salta l'alerta global, les alertes per component et diuen immediatament quin s'ha sortit del seu pressupost. És la diferència entre «la plataforma va lenta» i «motor-preus està a 140 ms sobre un pressupost de 80». La segona frase es pot accionar; la primera, no.
La regla del marge
Una heurística que funciona bé:
I una comprovació de coherència: si en fer el pressupost la suma del que realment triguen els components ja supera l'objectiu, no hi ha ajust que valgui. Cal redissenyar: cachejar més agressivament, calcular en segon pla, o canviar l'objectiu.
- El resultat mesurat del pont de maig
Tanquem amb els números. Això és el mòdul 9 complet, mesurat.
La taula de l'abans i el després
| Mètrica | 2025 (abans) | 2026 (després) | Millora |
|---|---|---|---|
| DISPONIBILITAT | |||
| Minuts de caiguda total | 47 min | 0 min | — |
| Peticions amb error | 12,4 % | 0,08 % | 155 vegades millor |
| Reserves perdudes per errors | ~2.800 | 11 | 254 vegades millor |
| LATÈNCIA (p95, cerca de rutes) | |||
| En calma | 287 ms | 94 ms | 3,1 vegades |
| Al pic | 4.200 ms | 168 ms | 25 vegades |
| p99 al pic | timeout | 412 ms | — |
| CAPACITAT | |||
| Peticions per segon sostingudes | 340 | 2.680 | 7,9 vegades |
| Capacitat per rèplica (rps) | 85 | 224 | 2,6 vegades |
| Rèpliques necessàries al pic | 4 (fixes) | 12 (auto) | — |
| RECURSOS | |||
| Estrangulament de CPU | 18,4 % | 0,0 % | — |
| Connexions a PostgreSQL al pic | 600 (400 rebutjades) | 75 (estables) | — |
Encerts de redis-cache |
13,2 % | 94,7 % | 7,2 vegades |
| Consultes a PostgreSQL per segon | 8.400 | 340 | 24 vegades menys |
| Temps d'arrencada d'un pod | 113 s | 12 s | 9,4 vegades |
| COST | |||
| Nodes al pic | 4 (insuficients) | 9 | — |
| Cost mensual mitjà | 712 € | 855 € | +20 % |
| Cost del mes del pont | 712 € | 912 € | +28 % |
| Cost per 1.000 reserves | 8,90 € | 1,12 € | 8 vegades millor |
L'última fila és la que cal ensenyar a la direcció. El cost absolut va pujar un 20 %, però el cost per reserva va baixar vuit vegades, perquè la plataforma ven vuit vegades més.
Què va aportar cada canvi
I ara la taula més útil: el desglossament per canvi, mesurat individualment a rutas-norte-pre.
| Canvi | Aportació a la capacitat per rèplica | Cost d'implementació |
|---|---|---|
| Corregir el N+1 de la cerca de rutes | +65 % (85 → 140 rps) | 1 dia de desenvolupament |
Índex compost a rutes |
+28 % (140 → 179 rps) | 1 hora |
| Memòria cau de disponibilitat a Redis (TTL 10 s) | +19 % (179 → 213 rps) | 2 dies de desenvolupament |
Treure limits.cpu |
+5 % (213 → 224 rps) | 10 minuts |
| Pool de connexions a 5 + PgBouncer | 0 % en règim normal | 1 dia |
Compressió i memòria cau a botiga-web |
0 % (afecta l'amplada de banda) | 2 hores |
random_page_cost = 1.1 |
Inclòs a l'índex | 1 minut |
I cal llegir aquesta taula amb cura, perquè conté la lliçó més important:
Els tres primers canvis són de l'aplicació i aporten el 92 % de la millora. L'ajust de Kubernetes (treure limits.cpu) aporta un 5 %.
L'ajust del pool de connexions aporta 0 % en règim normal, i tanmateix és probablement el canvi més important de la llista: és el que impedeix que la plataforma caigui quan l'HPA escala a 30 rèpliques. El seu valor no és a la millora del rendiment mitjà, sinó a eliminar un mode de fallada catastròfic.
Aquesta distinció —entre canvis que milloren el rendiment i canvis que eliminen modes de fallada— és la que separa un ajust ingenu d'un de professional.
La conclusió sobre el mòdul sencer
Sense l'ajust de rendiment:
Capacitat per replica: 85 rps.
Per a 2.680 rps: 2.680 / 85 = 32 repliques.
CPU necessaria: 32 x 412m = 13,2 nuclis.
Nodes: 5 nodes de 8 nuclis nomes per a api-reserves.
Amb l'ajust:
Capacitat per replica: 224 rps.
Per a 2.680 rps: 2.680 / 224 = 12 repliques.
CPU necessaria: 12 x 412m = 4,9 nuclis.
Nodes: 2 nodes de 8 nuclis.
ESTALVI: 3 nodes al pic. I, mes important, el pic HI CAP en un cluster
que no requereix reconfigurar els grups de nodes ni renegociar quotes.Ajustar el rendiment no és una alternativa a l'escalat: és el que fa que l'escalat sigui assequible. Escalar una aplicació ineficient funciona, però costa el triple i arriba als límits de capacitat molt abans.
Errors Comuns i Consells
Error 1: optimitzar sense mesurar. Canviar coses «que sonen a que aniran millor» produeix, en el millor dels casos, res. En el pitjor, regressions que ningú detecta. Mesura la línia base, data-la, i compara sempre contra ella.
Error 2: informar la latència mitjana. Amb avg=142ms i p99=2.1s, la mitjana és una mentida estadísticament correcta. El p99 és l'experiència d'1 de cada 100 peticions, i en una pàgina amb 30 peticions això és 1 de cada 4 usuaris. Fes servir p95 i p99 sempre.
Error 3: ramping-vus en una prova de càrrega. Quan el sistema s'alenteix, els usuaris virtuals generen menys càrrega, i la prova s'autolimita amagant el problema. Fes servir ramping-arrival-rate: els usuaris reals no es coordinen per esperar.
Error 4: un pool de connexions gran amb autoescalat. maxReplicas × pool ≤ max_connections × 0,8. Amb 30 rèpliques i un pool de 20, tens 600 connexions contra un límit de 200. És una bomba que esclata exactament quan l'HPA escala, o sigui, en el pitjor moment.
Error 5: --max-old-space-size igual al limits.memory. El heap de JavaScript no és tota la memòria del procés: queden fora els buffers, el codi natiu i les estructures internes. La regla és 65-70 % del límit.
Error 6: livenessProbe agressiva. Sota càrrega, un pod que va lent falla la sonda, es reinicia, redueix la capacitat, fa que els altres vagin més lents, i es produeix un col·lapse en cascada. Fes la livenessProbe molt més permissiva que la readinessProbe.
Error 7: creure que l'estrangulament apareix al 100 %. Amb un limit d'1 nucli i 4 fils, la quota s'esgota en 25 ms de cada 100. El pod està aturat el 75 % del temps mostrant una utilització del 100 %. Vigila container_cpu_cfs_throttled_periods_total.
Error 8: initialDelaySeconds alt «per si de cas». Cada segon de retard és un segon en què la rèplica nova no atén trànsit mentre les existents estan saturades. Fes servir startupProbe amb període d'1 segon i treu l'initialDelaySeconds de la readiness.
Error 9: córrer k6 al mateix node que l'aplicació. Competeixen per CPU i no sabràs si la latència és del sistema o del generador. Node dedicat amb taint, i recursos generosos per a k6.
Consell 1: posa l'estrangulament al panell principal. És la mètrica que explica la majoria dels misteris de p99 alt, i gairebé ningú la té. Un panell amb throttled_periods / periods per contenidor estalvia hores d'investigació.
Consell 2: fes servir pg_stat_statements ordenat per total_exec_time. Una consulta de 2 ms executada un milió de vegades satura més que una de 500 ms executada cent. El temps total és el que importa.
Consell 3: el punt final als FQDN externs. smtp.correus-externs.example. amb punt final elimina 3 de cada 4 consultes DNS. Un caràcter, un 75 % menys de trànsit DNS.
Consell 4: automatitza la prova de càrrega al pipeline. Amb thresholds definits, k6 retorna un codi d'error quan no es compleixen. Una prova de càrrega que falla la construcció evita que una regressió de rendiment arribi a producció.
Consell 5: documenta el pressupost de latència al costat del codi. Un fitxer PRESSUPOST-LATENCIA.md al repositori, amb la taula i les alertes, converteix un objectiu abstracte en una responsabilitat compartida i verificable.
Consell 6: desa els resultats de cada prova amb la seva data i la seva configuració. La sèrie històrica és el que detecta les degradacions lentes: un 3 % pitjor cada mes passa desapercebut, però un 30 % en un any és catastròfic.
Consell 7: ajusta abans d'escalar, sempre. Abans de pujar el maxReplicas, pregunta per què una rèplica només aguanta el que aguanta. Costa menys i el benefici es multiplica per cada rèplica.
Exercicis
Exercici 1: llegir una prova de càrrega
Aquesta és la sortida d'una prova d'estrès contra rutas-norte-pre amb 6 rèpliques d'api-reserves:
✓ la cerca retorna 200
✗ la reserva retorna 201
↳ 91% — ✓ 6142 / ✗ 607
checks.........................: 94.21% ✓ 38921 ✗ 2391
duracio_cerca_rutes............: avg=142ms min=18ms med=94ms max=8.1s p(95)=387ms p(99)=2.4s
duracio_confirmar_reserva......: avg=891ms min=112ms med=612ms max=14.2s p(95)=3.1s p(99)=9.8s
errors_reserva.................: 9.00% ✓ 607 ✗ 6142
http_req_duration..............: avg=284ms min=12ms med=118ms max=14.2s p(95)=982ms p(99)=4.2s
http_req_failed................: 3.42% ✓ 1621 ✗ 45782
http_reqs......................: 47403 158.0/s
iterations.....................: 9847 32.8/s
vus............................: 412 min=50 max=1800I les mètriques del sistema durant la prova:
CPU mitjana per replica d'api-reserves: 680m de 1000m limit (68 %)
Periodes de CPU estrangulats: 31,2 %
Memoria mitjana: 620Mi de 1600Mi (39 %)
Connexions actives a PostgreSQL: 118 de 200
Taxa d'encerts de redis-cache: 22,4 %
Consultes a PostgreSQL per segon: 3.840
Latencia p95 de les consultes a PostgreSQL: 41 ms
IOPS del disc de postgres: 2.847 de 3.000 aprovisionades
Temps d'E/S del disc (io_time): 0,91Respon: (a) es compleix un SLO de p95 < 300 ms? (b) quin és el coll d'ampolla principal i com ho justifiques? (c) per què la CPU al 68 % coexisteix amb un 31,2 % d'estrangulament? (d) ordena per prioritat les tres primeres accions que prendries, amb la millora esperada de cadascuna; (e) què NO faries i per què?
Exercici 2: calcular el pool i el pressupost de latència
Rutas Norte afegeix api-empreses, una API per a agències de viatges que reserven lots de bitllets.
Dades:
maxReplicasde l'HPA: 20.- Cada petició fa 6 consultes a PostgreSQL (és un lot de 6 bitllets).
- Durada mitjana de cada consulta: 14 ms.
postgres-reservestémax_connections: 200, compartit ambapi-reserves(que ja fa servir 75 connexions a través de PgBouncer).- Objectiu de negoci: p95 de la reserva d'un lot < 900 ms.
- Trànsit esperat: 40 peticions per segon, fins a 180 al pic.
- Mesurat a
rutas-norte-preamb 4 rèpliques: p95 de 640 ms, dels quals 310 ms són consultes a PostgreSQL.
Calcula: (a) la mida màxima del pool per rèplica; (b) si aquest pool basta per al trànsit del pic, fent servir la llei de Little; (c) un pressupost de latència complet per als 900 ms, amb marge; (d) si el pressupost és assolible amb les dades mesurades, i què faries si no ho és; (e) el threshold de KEDA que faries servir i la seva justificació.
Exercici 3: diagnosticar una degradació progressiva
Rutas Norte té un problema des de fa tres setmanes. Els símptomes:
- La latència p95 d'
api-reservesha passat de 94 ms a 280 ms, pujant uns 10 ms cada dia. - No hi ha hagut desplegaments d'
api-reservesen aquest període. - El nombre de rèpliques ha pujat de 4 a 9 de mitjana, perquè KEDA escala més.
- La CPU per rèplica no ha canviat: continua en uns 310m.
- La memòria per rèplica ha passat de 620Mi a 1.480Mi, pujant cada dia.
- Hi ha
OOMKilledocasionals des de fa quatre dies (2-3 al dia). - El trànsit ha crescut només un 8 % en aquest període.
- La taxa d'encerts de
redis-cacheha baixat del 94,7 % al 71,2 %. - Les consultes a PostgreSQL per segon han passat de 340 a 1.890.
- El p95 de les consultes a PostgreSQL continua en 8 ms.
- El disc de PostgreSQL:
io_timeen 0,34 (sense canvis).
Diagnostica el problema: quina és la causa arrel més probable i quina és la cadena causal completa? Quines ordres executaries, en quin ordre, per confirmar-ho? Quina és la solució immediata i quina la de fons? Quina prova de les de l'apartat 3 hauria detectat això abans que arribés a producció?
Solucions
Solució 1
(a) Es compleix l'SLO de p95 < 300 ms? NO, rotundament.
http_req_duration p95: 982 ms (objectiu: 300 ms) -> 3,3 vegades pitjor
duracio_cerca_rutes p95: 387 ms -> 1,3 vegades pitjor
duracio_confirmar_reserva p95: 3.100 ms -> 10,3 vegades pitjorI els errors són alarmants:
Nou de cada cent clients que intenten comprar un bitllet no ho aconsegueixen. Això són diners perduts directament, i és molt pitjor que la latència.
Fixa't també en l'asimetria: la cerca va malament però tolerable (387 ms); la reserva va catastròficament malament (3,1 s). El problema és al camí d'escriptura.
(b) El coll d'ampolla principal: EL DISC DE POSTGRESQL.
L'evidència decisiva:
IOPS del disc: 2.847 de 3.000 aprovisionades -> 94,9 % de la capacitat
Temps d'E/S del disc (io_time): 0,91 -> 91 % del temps ocupatUn io_time de 0,91 significa que el disc està ocupat el 91 % del temps. Per la teoria de cues de l'apartat 5:
Cada operació de disc espera deu vegades el seu propi temps de servei. El disc està saturat.
I la causa que arribi tanta E/S al disc és la memòria cau:
Taxa d'encerts de redis-cache: 22,4 %
-> El 77,6 % de les consultes de disponibilitat van a PostgreSQL.
Consultes a PostgreSQL: 3.840/s amb 158 peticions/s
-> 24,3 consultes per peticio HTTP.24 consultes per petició és una barbaritat. Amb una petició que hauria de fer 1-2 consultes, això és la signatura inequívoca d'un problema N+1 (apartat 7).
Cadena causal completa:
1. La cache de Redis no funciona (22,4 % d'encerts).
2. Hi ha un problema N+1: 24 consultes per peticio.
3. 3.840 consultes/s contra PostgreSQL.
4. La cache de PostgreSQL no dona l'abast: moltes van a disc.
5. El disc al 91 % d'ocupacio: cada operacio espera 10 vegades el seu temps.
6. Les consultes s'alenteixen (el p95 de 41 ms ja ho reflecteix).
7. Les connexions es retenen mes temps: 118 de 200 actives.
8. Les escriptures (reserves) pateixen mes que les lectures: 3,1 s de p95.
9. Els timeouts produeixen el 9 % d'errors a les reserves.Nota important sobre el que NO és el coll d'ampolla:
CPU: 680m de 1000m (68 %) -> hi ha marge
Memoria: 620Mi de 1600Mi (39 %) -> hi ha molt marge
Connexions: 118 de 200 (59 %) -> hi ha margeEscalar api-reserves NO ajudaria. Més rèpliques farien més consultes contra el mateix disc saturat, empitjorant la situació. És l'error de 09-01 en estat pur.
(c) CPU al 68 % amb 31,2 % d'estrangulament.
Aquesta pregunta posa a prova el de l'apartat 9. L'aparent contradicció es resol entenent que són dues mesures diferents:
"CPU 680m de 1000m (68 %)" es la MITJANA sobre l'interval de mesurament.
"31,2 % de periodes estrangulats" compta periodes de 100 ms individuals.
Que passa realment dins de cada periode de 100 ms:
Periode A (69 % dels casos): el pod fa servir 40 ms dels seus 100 ms de quota.
No s'estrangula.
Periode B (31 % dels casos): arriba una rafega. Node.js fa servir diversos fils
(bucle d'esdeveniments + libuv per a la
serialitzacio JSON i la compressio).
4 fils x 25 ms = 100 ms. QUOTA ESGOTADA
en 25 ms de rellotge.
El pod esta ATURAT 75 ms.
Mitjana ponderada: 0,69 x 40 + 0,31 x 100 = 27,6 + 31 = 58,6...
Ajustant per la distribucio real de rafegues: ~68 %. Coincideix.La mitjana del 68 % amaga que en 3 de cada 10 períodes el pod està aturat 75 ms. I aquestes aturades són exactament el que produeix el p99 de 4,2 segons: peticions que van arribar just després d'esgotar-se la quota.
L'estrangulament és un problema de la cua de la distribució, i per això la mitjana de CPU mai el revela.
(d) Les tres primeres accions, per prioritat:
Acció 1: corregir el N+1 (24 consultes per petició → 2).
És la de més impacte amb diferència. Ataca la causa arrel de tota la cadena.
-- Abans: 1 consulta de rutes + 23 consultes de places i preus
-- Despres: 1 consulta amb agregacio
SELECT
r.id, r.origen, r.desti, r.hora_sortida, r.preu_base,
COUNT(p.id) FILTER (WHERE p.estat = 'lliure') AS places_lliures
FROM rutes r
LEFT JOIN places p ON p.ruta_id = r.id
WHERE r.origen = $1 AND r.desti = $2 AND r.data = $3
GROUP BY r.id, r.origen, r.desti, r.hora_sortida, r.preu_base
ORDER BY r.hora_sortida;Millora esperada:
Consultes: 3.840/s -> 316/s (divisio per 12)
IOPS del disc: 2.847 -> ~240 (per sota del 10 % de la capacitat)
io_time: 0,91 -> ~0,12
p95 de les consultes: 41 ms -> ~6 ms
p95 global estimat: 982 ms -> ~250 ms
Errors de reserva: 9,0 % -> < 0,5 %Cost: 1-2 dies de desenvolupament. Retorn: el més gran dels tres.
Acció 2: arreglar la memòria cau de Redis (22,4 % → > 90 % d'encerts).
# Diagnostic primer
kubectl exec -n rutas-norte-pre redis-cache-0 -- redis-cli INFO stats
kubectl exec -n rutas-norte-pre redis-cache-0 -- redis-cli --scan --count 20Les causes probables, en ordre:
- Claus massa específiques (amb marca de temps o identificador d'usuari).
- TTL massa curt (si és d'1 segon, gairebé mai encerta).
maxmemorysense configurar i les claus sent expulsades abans d'hora.
Correcció típica:
// Clau: nomes els parametres que determinen el resultat
const clau = `places:${rutaId}`;
const ttl = 10; // 10 segons: absorbeix el 95 % de les lecturesMillora esperada: reducció addicional del 60-70 % de les consultes restants.
Cost: 1 dia. Es fa després del N+1 perquè el N+1 és el que genera el volum.
Acció 3: treure limits.cpu (o pujar-lo a 2).
resources:
requests:
cpu: 500m
memory: 800Mi
limits:
# Sense limits.cpu: elimina el 31,2 % de periodes estrangulats
memory: 1600MiMillora esperada:
Estrangulament: 31,2 % -> 0 %
p99: 4,2 s -> ~1,5 s (elimina les esperes de 75 ms)
p95: millora del 10-15 %Cost: 10 minuts. És el més barat de la llista, però no el primer, perquè amb el disc al 91 % la CPU no és el limitant. Un cop resoltes les accions 1 i 2, sí que ho serà.
(e) Què NO faria, i per què:
NO pujar el maxReplicas ni escalar api-reserves.
És el primer que fa molta gent i és exactament el contrari del que cal fer:
Amb 6 repliques: 3.840 consultes/s. Disc al 91 %.
Amb 12 repliques: 7.680 consultes/s. Disc al... no pot passar del 100 %.
Resultat: les consultes s'encuen. La latencia PUJA.
Les connexions s'esgoten (12 repliques x pool). Mes errors.
Cost: el doble. Rendiment: pitjor.NO aprovisionar més IOPS a la StorageClass encara.
Pujar de 3.000 a 10.000 IOPS alleujaria el símptoma i costaria bastant més al mes. Però el problema no és que faltin IOPS: és que es fan 24 consultes on n'haurien de fer 2. Pagar per absorbir una ineficiència és la pitjor decisió possible, perquè la ineficiència continua allà i creix amb el trànsit.
Després de corregir el N+1, els 3.000 IOPS sobraran amb folgança.
NO pujar el max_connections de PostgreSQL.
Amb 118 de 200 en ús, les connexions no són el limitant. I pujar max_connections empitjoraria les coses: més processos de PostgreSQL competint pel mateix disc saturat, amb més canvis de context.
NO afegir més memòria als pods.
39 % d'ús. No és el problema.
La lliçó: les tres accions que NO faria són les tres que un equip sota pressió faria primer, perquè són les més ràpides d'aplicar. Totes costen diners i cap no resol res. Diagnosticar abans d'actuar estalvia més que qualsevol optimització.
Solució 2
(a) Mida màxima del pool per rèplica:
max_connections de postgres-reserves: 200
Reserva per a manteniment, copies i metriques: 40 (20 %)
Disponible per a aplicacions: 160
Ja usades per api-reserves (via PgBouncer): 75
------------------------------------------------------
Disponible per a api-empreses: 85
maxReplicas d'api-empreses: 20
Pool maxim per replica: 85 / 20 = 4,25 -> 4 connexions4 connexions per rèplica.
(b) Basta aquest pool per al pic? Apliquem la llei de Little.
Concurrencia = Taxa d'arribada x Temps de servei
Cada peticio fa 6 consultes de 14 ms = 84 ms de temps de base de dades.
Consultes per segon que sosté una replica amb 4 connexions:
4 connexions / 0,014 s = 285,7 consultes/s
Peticions per segon per replica:
285,7 / 6 consultes = 47,6 peticions/s
Amb 20 repliques: 20 x 47,6 = 952 peticions/sTransit del pic: 180 peticions/s
Capacitat amb 20 repliques: 952 peticions/s
952 >> 180. EL POOL BASTA AMB MOLTISSIM MARGE.Comprovem quantes rèpliques calen de debò:
Amb 4 rèpliques n'hi ha prou per al pic. El maxReplicas: 20 és molt generós, però això no és dolent: dona marge si l'estimació de trànsit es queda curta, i les rèpliques només existeixen si l'escalador les demana.
Verificació addicional: i si les 20 rèpliques estiguessin actives alhora?
20 repliques x 4 connexions = 80 connexions.
Disponibles per a api-empreses: 85.
80 < 85. HI CAP, amb 5 de marge.(c) Pressupost de latència per a 900 ms:
| Component | Pressupost p95 | % | Justificació |
|---|---|---|---|
| Xarxa del client a l'Ingress | 60 ms | 6,7 % | Les agències fan servir API, no navegador: menys latència de xarxa |
| Ingress (nginx) + TLS | 15 ms | 1,7 % | Terminació TLS i enrutament |
| Xarxa interna del clúster | 10 ms | 1,1 % | 2 salts |
api-empreses (lògica pròpia) |
90 ms | 10,0 % | Validació de 6 bitllets, serialització, regles de negoci |
postgres-reserves (6 consultes) |
300 ms | 33,3 % | 6 × 50 ms de pressupost per consulta |
redis-cache (validació de disponibilitat) |
20 ms | 2,2 % | 2 consultes de 10 ms |
motor-preus (preus del lot) |
130 ms | 14,4 % | Càlcul per a 6 bitllets |
| Escriptura i confirmació de la transacció | 60 ms | 6,7 % | Commit amb fsync del WAL |
| Marge de seguretat | 215 ms | 23,9 % | Variabilitat, reintents, GC, contenció |
| TOTAL | 900 ms | 100 % |
Comprovació de la regla del marge:
Suma dels components (sense marge): 685 ms
685 / 900 = 76,1 % <= 75 %... just al limit.
Acceptable, pero ajustat. Convé vigilar-ho.(d) És assolible amb les dades mesurades?
MESURAT a rutas-norte-pre amb 4 repliques:
p95 total: 640 ms
Dels quals, PostgreSQL: 310 ms
Resta (aplicacio, xarxa, motor-preus): 330 ms
PRESSUPOSTAT:
PostgreSQL: 300 ms -> mesurat 310 ms. DESVIACIO: +10 ms (+3,3 %)
Resta: 385 ms -> mesurat 330 ms. Per sota. BE.
Total: 900 ms -> mesurat 640 ms. MARGE: 260 ms (28,9 %)SÍ que és assolible. El sistema ja compleix l'objectiu amb un 29 % de marge.
Però hi ha dues observacions importants:
Observació 1: les consultes a PostgreSQL són just al pressupost.
310 ms mesurats / 6 consultes = 51,7 ms per consulta.
Pressupost: 50 ms per consulta.
I l'enunciat diu que la durada mitjana de cada consulta es 14 ms.
310 ms mesurats / 6 = 51,7 ms de p95 per consulta, davant de 14 ms de mitjana.
RATIO p95/mitjana = 3,7. Es alt.Un ratio p95/mitjana de 3,7 suggereix variabilitat alta: algunes consultes són molt més lentes que altres. Mereix investigar-se:
SELECT
substring(query, 1, 60),
calls,
round(mean_exec_time::numeric, 2) AS mitjana,
round(stddev_exec_time::numeric, 2) AS desviacio,
round(max_exec_time::numeric, 2) AS maxim
FROM pg_stat_statements
WHERE query LIKE '%bitllets%'
ORDER BY total_exec_time DESC;Si una de les 6 consultes és molt més lenta que les altres, és candidata a un índex.
Observació 2: la mesura és amb 4 rèpliques i trànsit baix.
El p95 de 640 ms es va mesurar en condicions favorables. Cal repetir el mesurament amb la càrrega del pic (180 rps) abans de donar el pressupost per bo. La teoria de cues ens avisa que la latència creix de forma no lineal amb la utilització.
Què faria si no fos assolible:
En ordre de rendibilitat:
- Reduir les 6 consultes a 1 o 2. Un lot de 6 bitllets hauria de poder consultar-se amb
WHERE id = ANY($1). De 300 ms a 60 ms. És el canvi de més impacte. - Paral·lelitzar les consultes independents. Si les 6 no depenen les unes de les altres,
Promise.all()les executa en paral·lel: de 300 ms seqüencials a ~60 ms. - Cachejar els preus del lot si les agències consulten rutes repetides.
- Renegociar l'objectiu amb el negoci. 900 ms per reservar 6 bitllets pot ser massa exigent; 1.500 ms potser és perfectament acceptable per a una API B2B.
(e) El threshold de KEDA:
Capacitat mesurada per replica: 47,6 peticions/s (limitada pel pool).
Pero cal verificar quin es el limitant REAL:
- Pel pool: 47,6 rps
- Per CPU: cal mesurar-ho amb la prova d'estres
Prenem el mes restrictiu. Suposem que la prova d'estres dona un punt
d'inflexio en 42 rps per replica (la CPU limita abans que el pool).
Apliquem el marge del 30 % per cobrir el temps d'arrencada:
42 x 0,7 = 29,4 -> threshold: 29 rps per replica
Verificacio al pic:
180 rps / 29 = 6,2 -> 7 repliques.
7 repliques x 4 connexions = 28 connexions a PostgreSQL. Molt folgat.# k8s/entorns/pro/scaledobject-api-empreses.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-empreses
namespace: rutas-norte-pro
spec:
scaleTargetRef:
name: api-empreses
# 3 repliques de terra: una per zona (09-05). Es una API B2B amb contracte
# de servei; no pot tenir arrencades en fred.
minReplicaCount: 3
# 20 repliques de sostre.
# VERIFICACIO DE CONNEXIONS: 20 x 4 = 80 <= 85 disponibles. Hi cap.
# NO PUJAR sense recalcular el pool (veure ConfigMap).
maxReplicaCount: 20
fallback:
failureThreshold: 3
replicas: 7 # La capacitat del pic: numero segur conegut
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 4
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 25
periodSeconds: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus-operated.monitoratge.svc.cluster.local:9090
metricName: api_empreses_peticions_per_segon
query: |
sum(rate(api_empreses_peticions_total{namespace="rutas-norte-pro"}[2m]))
# 29 peticions/s per replica.
#
# Calcul:
# Punt d'inflexio mesurat (prova d'estres): 42 rps per replica.
# Marge del 30 % per cobrir l'arrencada: 42 x 0,7 = 29,4 -> 29.
#
# Verificacio del pool (llei de Little):
# 4 connexions / 14 ms = 285,7 consultes/s per replica
# 285,7 / 6 consultes per peticio = 47,6 rps per replica
# El POOL suporta 47,6 rps; la CPU limita abans en 42.
# El pool NO es el limitant. Correcte.
threshold: "29"
activationThreshold: "2"
authenticationRef:
kind: ClusterTriggerAuthentication
name: prometheus-lecturaI el ConfigMap amb el càlcul documentat:
apiVersion: v1
kind: ConfigMap
metadata:
name: api-empreses-config
namespace: rutas-norte-pro
data:
# POOL DE CONNEXIONS: 4 per replica.
#
# Calcul:
# max_connections de postgres-reserves: 200
# Reserva per a manteniment i metriques (20 %): -40
# Usades per api-reserves (via PgBouncer): -75
# Disponibles per a api-empreses: 85
# maxReplicas d'api-empreses: 20
# Pool = 85 / 20 = 4,25 -> 4
#
# NO PUJAR el pool ni el maxReplicas sense refer aquest calcul.
# Amb 20 repliques, cada connexio extra per replica son 20 connexions mes.
PG_POOL_MAX: "4"
PG_POOL_MIN: "1"
PG_POOL_ACQUIRE_TIMEOUT_MS: "2000"Solució 3
Diagnòstic: una fuita de memòria a api-reserves que està destruint l'efectivitat de la memòria cau.
Anem per parts, perquè la cadena causal és la part interessant.
Les pistes i el que descarten:
| Observació | Què indica |
|---|---|
| Sense desplegaments en 3 setmanes | No és un canvi de codi. És alguna cosa acumulativa |
| La memòria creix cada dia, sense baixar mai | Signatura inequívoca de fuita de memòria |
OOMKilled des de fa 4 dies |
La memòria ha arribat al límit |
| CPU sense canvis (310m) | No és un problema de còmput |
| Trànsit només +8 % | No és creixement orgànic |
| p95 de PostgreSQL sense canvis (8 ms) | PostgreSQL no és el coll d'ampolla |
io_time del disc sense canvis (0,34) |
El disc tampoc |
| Encerts de Redis: 94,7 % → 71,2 % | Aquí hi ha la clau |
| Consultes a PostgreSQL: 340 → 1.890 (5,6×) | Conseqüència de la memòria cau degradada |
La cadena causal:
1. Hi ha una fuita de memoria a api-reserves. La memoria creix ~40Mi/dia.
(620Mi -> 1.480Mi en 21 dies = 41Mi/dia)
2. En arribar a ~1.500Mi d'un limit de 1.600Mi, el pod es OOMKilled.
3. CADA REINICI PER OOMKilled BUIDA LA CACHE EN MEMORIA DEL PROCES.
Si api-reserves mante una cache local (a mes de Redis), es perd.
4. I MES IMPORTANT: els reinicis trenquen el patro d'acces a Redis.
Un pod acabat d'arrencar fa consultes "fredes": demana claus que no
estan cachejades perque la feina de poblar la cache la feia el pod
anterior amb les seves dades de sessio.
5. La taxa d'encerts de Redis cau del 94,7 % al 71,2 %.
6. El 28,8 % de fallades (davant del 5,3 % anterior) es tradueix en
5,4 vegades mes consultes a PostgreSQL: 340 -> 1.890/s.
7. Mes consultes -> mes latencia per peticio (encara que cada consulta
individual continui trigant 8 ms, ara n'hi ha moltes mes per peticio).
8. La latencia p95 puja: 94 ms -> 280 ms.
9. KEDA detecta mes latencia o mes peticions per replica i escala:
4 -> 9 repliques.
10. MES REPLIQUES EMPITJOREN LA CACHE: mes pods, cadascun amb menys
peticions, patrons d'acces mes dispersos, i mes pods acumulant
la fuita en paral·lel.
ES UN CICLE QUE ES REALIMENTA.El detall que confirma el diagnòstic: la CPU no ha canviat.
Si el problema fos més càrrega real, la CPU hauria pujat. Si fos un problema de PostgreSQL, el p95 de les consultes hauria pujat. L'única variable que ha canviat de forma monòtona i sostinguda és la memòria. Tota la resta és conseqüència.
Ordres de diagnòstic, en ordre:
# 1. Confirmar els OOMKilled i la seva frequencia
kubectl get pods -n rutas-norte-pro -l app=api-reserves \
-o custom-columns='NOM:.metadata.name,REINICIS:.status.containerStatuses[0].restartCount,ULTIM:.status.containerStatuses[0].lastState.terminated.reason'NOM REINICIS ULTIM
api-reserves-7c9d4f8b6d-2mkjp 3 OOMKilled
api-reserves-7c9d4f8b6d-4nqzx 2 OOMKilled
api-reserves-7c9d4f8b6d-7wtrv 0 <none># 2. Confirmar el creixement monoton de la memoria (la prova definitiva)
# A Prometheus:
# container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}
# Rang: 30 dies.
#
# Si la linia PUJA I NO BAIXA MAI, ni tan sols a les valls nocturnes,
# es una fuita. Si baixa a les nits, es comportament normal del GC.# 3. Comprovar la configuracio del heap de Node.js
kubectl get deployment api-reserves -n rutas-norte-pro \
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="NODE_OPTIONS")].value}{"\n"}'# 4. Veure la composicio de la memoria del proces
kubectl exec -n rutas-norte-pro api-reserves-7c9d4f8b6d-7wtrv -c api -- \
node -e "const m = process.memoryUsage();
console.log('rss:', (m.rss/1048576).toFixed(0), 'MB');
console.log('heapTotal:', (m.heapTotal/1048576).toFixed(0), 'MB');
console.log('heapUsed:', (m.heapUsed/1048576).toFixed(0), 'MB');
console.log('external:', (m.external/1048576).toFixed(0), 'MB');
console.log('arrayBuffers:', (m.arrayBuffers/1048576).toFixed(0), 'MB');"heapUsed de 1.094 MB creixent: la fuita és al heap de JavaScript, no en buffers natius. Això apunta a objectes JavaScript que s'acumulen.
# 5. Investigar Redis: que ha canviat?
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO stats | grep -E "keyspace|expired|evicted"
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli INFO memory | grep -E "used_memory_human|maxmemory"
kubectl exec -n rutas-norte-pro redis-cache-0 -- redis-cli DBSIZE# 6. Correlacionar reinicis amb caigudes de la taxa d'encerts.
# A Grafana, superposar:
# - kube_pod_container_status_restarts_total{container="api"}
# - redis_keyspace_hits_total / (hits + misses)
#
# Si cada reinici coincideix amb una caiguda de la taxa d'encerts,
# la cadena causal queda CONFIRMADA.# 7. Capturar una instantania del heap per trobar la fuita
kubectl exec -n rutas-norte-pro api-reserves-7c9d4f8b6d-7wtrv -c api -- \
kill -USR2 1
# (requereix que l'aplicacio tingui el gestor de heapdump)Les causes de fuita més probables en una API Node.js:
| Causa | Com es manifesta |
|---|---|
Un Map o array global que creix sense límit |
Una memòria cau en memòria sense TTL ni límit de mida |
| Escoltadors d'esdeveniments que no s'eliminen | emitter.on() sense el seu off() corresponent |
| Temporitzadors que no es cancel·len | setInterval per petició sense clearInterval |
| Clausures que capturen objectes grans | Callbacks que retenen l'objecte de petició complet |
| Un pool de connexions que no allibera | Connexions que no es tornen al pool |
La sospita principal, donat el context: una memòria cau en memòria dins d'api-reserves, afegida potser fa un mes, sense límit de mida ni política d'expulsió. Encaixa perfectament:
- Creix de forma monòtona (cada ruta nova consultada afegeix una entrada).
- Creix més ràpid com més trànsit diferent hi ha.
- No baixa mai, perquè res no expulsa entrades.
// EL PATRO SOSPITOS
const cacheRutes = new Map(); // <- Sense limit de mida
app.get('/rutes', async (req, res) => {
const clau = `${req.query.origen}:${req.query.desti}:${req.query.data}`;
if (cacheRutes.has(clau)) return res.json(cacheRutes.get(clau));
const rutes = await consultarRutes(req.query);
cacheRutes.set(clau, rutes); // <- CREIX PER SEMPRE
res.json(rutes);
});Amb 8 orígens × 7 destins × 365 dates = 20.440 combinacions possibles, i cada entrada ocupant ~50 KB, la memòria cau pot arribar a 1 GB. Exactament el que estem veient.
Solució immediata (avui):
# 1. Pujar el limit de memoria per aturar els OOMKilled mentre
# s'investiga. NO es una solucio: es un pedac.
resources:
requests:
memory: 800Mi
limits:
memory: 2560Mi # De 1600Mi a 2560Mi
env:
# I ajustar el heap coherentment: 65 % de 2560 = 1664
- name: NODE_OPTIONS
value: "--max-old-space-size=1664"# 2. Reinici ordenat de totes les repliques per tornar al punt de partida
kubectl rollout restart deployment/api-reserves -n rutas-norte-proAixò compra uns dies: amb 41Mi/dia i 2.560Mi de límit, el següent OOMKilled arribaria en unes 6-7 setmanes en lloc de 4 dies.
Solució de fons (aquesta setmana):
// Substituir el Map sense limit per una cache LRU acotada
const { LRUCache } = require('lru-cache');
const cacheRutes = new LRUCache({
// SOSTRE DUR: 500 entrades. Amb ~50 KB per entrada, uns 25 MB maxim.
max: 500,
// TTL de 5 minuts: els horaris no canvien, pero acota el creixement.
ttl: 1000 * 60 * 5,
// Sostre per mida tambe, per si alguna entrada es enorme
maxSize: 50 * 1024 * 1024, // 50 MB
sizeCalculation: (valor) => JSON.stringify(valor).length,
updateAgeOnGet: false,
});I la decisió d'arquitectura que mereix plantejar-se: cal aquesta memòria cau en memòria si ja existeix redis-cache?
Cache en memoria del proces:
+ Mes rapida (0,001 ms davant d'1 ms de Redis)
- Es duplica a cada replica: 9 repliques = 9 copies de les mateixes dades
- Es perd a cada reinici
- Fragmenta la taxa d'encerts: cada replica cacheja el seu
- Es la causa d'aquesta fuita
Cache a Redis:
+ Compartida entre TOTES les repliques: molt millor taxa d'encerts
+ Sobreviu als reinicis
+ Un sol punt on ajustar TTL i politica d'expulsio
- 1 ms de latencia de xarxa
RECOMANACIO: eliminar la cache en memoria i fer servir nomes Redis.
El mil·lisegon de diferencia es irrellevant davant del pressupost de
latencia de 300 ms, i elimina la fuita, la fragmentacio i el problema
dels reinicis d'una vegada.I les mesures preventives:
# ALERTA 1: creixement monoton de memoria (la que hauria detectat aixo
# el dia 3, no el dia 21).
# deriv() calcula el pendent: si es positiu durant 6 hores seguides,
# la memoria nomes puja.
deriv(
container_memory_working_set_bytes{namespace="rutas-norte-pro", container="api"}[6h]
) > 0# ALERTA 2: qualsevol OOMKilled
increase(kube_pod_container_status_restarts_total{namespace="rutas-norte-pro"}[1h]) > 0
and on(pod, container)
kube_pod_container_status_last_terminated_reason{reason="OOMKilled"} == 1# ALERTA 3: caiguda de la taxa d'encerts de la cache
(
rate(redis_keyspace_hits_total[10m])
/
(rate(redis_keyspace_hits_total[10m]) + rate(redis_keyspace_misses_total[10m]))
) < 0.85Quina prova ho hauria detectat abans?
La prova de resistència (soak test) de l'apartat 3, sense cap dubte.
export const options = {
scenarios: {
resistencia: {
executor: 'constant-arrival-rate',
rate: 100,
timeUnit: '1s',
duration: '8h', // VUIT HORES de carrega constant
preAllocatedVUs: 100,
maxVUs: 300,
},
},
};Per què aquesta i no una altra:
| Prova | Hauria detectat la fuita? |
|---|---|
| Fum (2 min) | No. Massa curta |
| Càrrega (20 min) | No. La fuita és de 41Mi/dia = 1,7Mi/hora. En 20 minuts són 0,6Mi: soroll |
| Estrès (25 min) | No. Mesura el límit, no l'evolució temporal |
| Punta (8 min) | No. Massa curta |
| Resistència (8 h) | SÍ. 8 hores × 1,7Mi/h = 14Mi de creixement, clarament visible al gràfic |
I amb una prova de resistència més agressiva (trànsit del pic durant 8 hores), el creixement seria molt més gran i evident a la primera hora.
Què mirar durant la prova de resistència:
# Si la memoria puja i NO baixa en 8 hores de carrega CONSTANT, hi ha fuita.
container_memory_working_set_bytes{namespace="rutas-norte-pre", container="api"}
# Si les connexions a PostgreSQL creixen sense alliberar-se, hi ha fuita de connexions.
pg_stat_activity_count{datname="reserves"}
# Si la latencia p95 creix un 5 % cada hora, hi ha degradacio progressiva.
histogram_quantile(0.95, sum(rate(api_reserves_duracio_segons_bucket[10m])) by (le))La lliçó de l'exercici, i del mòdul sencer: una prova de resistència de 8 hores executada un cop al mes costa unes hores d'un node de proves i detecta problemes que en producció triguen tres setmanes a manifestar-se, degraden el servei progressivament, disparen el cost d'infraestructura per l'escalat innecessari, i són extraordinàriament difícils de diagnosticar quan ja han muntat una cadena causal de deu passos.
Conclusió
L'ajust de rendiment és el que fa que tota la resta del mòdul 9 sigui assequible. Escalar una aplicació ineficient funciona, però multiplica la ineficiència pel nombre de rèpliques: es paga trenta vegades, s'arrenquen nodes que no caldrien, i s'arriba als límits de capacitat molt abans.
L'essencial:
- La metodologia abans que els trucs. Mesurar la línia base, trobar el coll d'ampolla real, canviar una sola cosa cada vegada, tornar a mesurar en les mateixes condicions, i parar quan s'assoleix l'objectiu.
- Les proves de càrrega amb k6 han de fer servir
ramping-arrival-rate(els usuaris reals no esperen), incloure pauses realistes entre passos i respectar les proporcions entre operacions. Els cinc tipus —fum, càrrega, estrès, punta, resistència— responen preguntes diferents. - Els percentils, mai la mitjana. Amb
avg=142msip99=2.1s, la mitjana és una mentida tranquil·litzadora. I en una pàgina amb 30 peticions, un de cada quatre usuaris pateix el p99. - El punt d'inflexió defineix la capacitat real, i la teoria de cues explica per què: al 90 % d'utilització l'espera ja és nou vegades el temps de servei. Aquesta és la justificació matemàtica de l'objectiu del 60-70 % a l'HPA.
- El pool de connexions és la trampa més cara de l'autoescalat.
maxReplicas × pool ≤ max_connections × 0,8, o PgBouncer per desacoblar el nombre de connexions del nombre de rèpliques. - L'aplicació és on és el problema. El N+1, els índexs que falten i una memòria cau mal usada van aportar el 92 % de la millora de Rutas Norte. L'ajust de Kubernetes en va aportar un 5 %.
- L'estrangulament de CPU apareix molt abans del 100 %: amb quatre fils i una quota de 100 ms, s'esgota en 25 ms i el pod està aturat el 75 % del temps mostrant una utilització del 100 %. Vigila
container_cpu_cfs_throttled_periods_total. - Treure
limits.cpués defensable per a serveis al camí crític i mala idea per a treballs per lots, bases de dades i entorns multiinquilí. El límit de memòria sempre es manté: la memòria no és compressible. - L'arrencada ràpida és un requisit de l'autoescalat, no un luxe. De 113 a 12 segons amb una imatge lleugera, un DaemonSet de precàrrega i sondes ben ajustades.
- El punt final als FQDN externs elimina tres de cada quatre consultes DNS pel
ndots: 5. Un caràcter. - El pressupost de latència converteix un objectiu de negoci en objectius per component, amb un marge explícit del 25 %, i cada línia es converteix en una alerta que diu exactament qui s'ha sortit.
El resultat mesurat del pont de maig de 2026: zero minuts de caiguda davant de 47, un 0,08 % d'errors davant del 12,4 %, una capacitat per rèplica de 224 peticions per segon davant de 85, i un cost per mil reserves d'1,12 € davant de 8,90 €. La plataforma no només va aguantar: va aguantar costant vuit vegades menys per reserva venuda.
Amb això tanquem el mòdul 9. Rutas Norte té una plataforma que creix quan cal, amb pods de la mida correcta, sobre nodes que apareixen sols, anticipant-se als esdeveniments coneguts, sense trencar-se durant el manteniment, i ajustada per no malgastar la capacitat que paga.
I tanmateix, hi ha alguna cosa que s'ha anat acumulant durant nou mòduls i que ja no es pot ignorar.
Fes un cop d'ull al directori k8s/. Hi ha el Deployment d'api-reserves, el seu Service, el seu ConfigMap, el seu Secret, el seu HPA, el seu VPA, el seu PDB, la seva NetworkPolicy, el seu ServiceMonitor i el seu ScaledObject. Multiplicat per sis components. I multiplicat un altre cop per tres entorns, perquè rutas-norte-dev té minReplicas: 1 i rutas-norte-pro té minReplicas: 4, perquè les imatges porten etiquetes diferents, perquè els límits de recursos difereixen, perquè en desenvolupament no hi ha TLS.
Són més de cent vint fitxers YAML, molts d'ells idèntics llevat de tres línies. Quan canvia el port d'api-reserves, cal tocar-lo en nou llocs. Quan es desplega una versió nova, algú executa una seqüència de kubectl apply de memòria, en l'ordre correcte, esperant no oblidar-se'n cap. Quan algú pregunta «quina versió hi ha a preproducció?», la resposta honesta és «deixa'm mirar».
I hem fregat el problema diverses vegades sense resoldre'l: el camp replicas que cal treure del manifest versionat (09-01), la recomanació del VPA que cal portar a mà a una petició de canvi (09-02), el PDB que va quedar amb un valor del pont de maig anterior (09-05). Tots són símptomes del mateix: la font de veritat està repartida entre cent vint fitxers i la memòria de tres persones.
Al mòdul 10, Ecosistema i Eines de Kubernetes, ataquem exactament això: entorns locals reproduïbles amb minikube i kind, la construcció de clústers amb kubeadm, l'empaquetatge i la parametrització amb Helm, la personalització per entorn sense duplicar amb Kustomize, i el salt definitiu a GitOps amb Argo CD i Flux, on el repositori deixa de ser una carpeta de fitxers que algú aplica a mà i passa a ser l'única font de veritat del que hi ha al clúster. Acabarem amb Kubernetes gestionat —EKS, AKS i GKE— i què canvia quan el pla de control no és teu.
Curs de Kubernetes
Mòdul 1: Introducció a Kubernetes
- Què és Kubernetes?
- Arquitectura de Kubernetes
- Conceptes i Terminologia Clau
- Configuració d'un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objectes, Manifests YAML i el Model Declaratiu
- El Projecte del Curs: la Plataforma Rutas Norte
Mòdul 2: Components Principals de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualitzacions, Rollbacks i Estratègies de Desplegament
- Serveis
- Namespaces
- Etiquetes, Selectors i Anotacions
Mòdul 3: Gestió de Configuració i Secrets
- ConfigMaps
- Secrets
- Variables d'Entorn
- Quotes i Límits de Recursos
- LimitRanges i Classes de Qualitat de Servei (QoS)
- ServiceAccounts i Accés a l'API des dels Pods
Mòdul 4: Xarxes a Kubernetes
- Xarxes de Clúster
- Tipus de Serveis
- DNS Intern i Descobriment de Serveis
- Controladors d'Ingress
- TLS i Gestió de Certificats amb cert-manager
- Polítiques de Xarxa
Mòdul 5: Emmagatzematge a Kubernetes
- Volums
- Volums Persistents
- Reclamacions de Volums Persistents
- Classes d'Emmagatzematge
- Aprovisionament Dinàmic, Expansió i Snapshots
- Còpies de Seguretat i Restauració de Dades
Mòdul 6: Conceptes Avançats de Kubernetes
- StatefulSets
- DaemonSets
- Treballs i CronJobs
- Init Containers, Sidecars i Patrons Multicontenidor
- Planificació: Afinitat, Taints i Toleracions
- Definicions de Recursos Personalitzats (CRDs)
- Operadors i el Patró Controlador
Mòdul 7: Monitoratge i Registre
- Verificacions de Salut i Sondes
- Servidor de Mètriques i kubectl top
- Monitoratge amb Prometheus
- Visualització i Alertes amb Grafana i Alertmanager
- Registre Centralitzat amb Elasticsearch, Fluentd i Kibana (EFK)
- Depuració d'Aplicacions i Esdeveniments del Clúster
Mòdul 8: Seguretat a Kubernetes
- Control d'Accés Basat en Rols (RBAC)
- Contextos de Seguretat i Enduriment del Contenidor
- Polítiques de Seguretat de Pods i Pod Security Standards
- Seguretat de Xarxa
- Seguretat d'Imatges
- Auditoria, Escaneig i Gestió de Vulnerabilitats
Mòdul 9: Escalat i Rendiment
- Autoescalat Horitzontal de Pods
- Autoescalat Vertical de Pods
- Autoescalat de Clúster
- Escalat per Esdeveniments i Mètriques Personalitzades amb KEDA
- Alta Disponibilitat: PodDisruptionBudgets i Topologia
- Ajust de Rendiment
Mòdul 10: Ecosistema i Eines de Kubernetes
- Minikube i Entorns Locals amb kind
- Kubeadm
- Helm
- Kustomize
- GitOps amb Argo CD i Flux
- Kubernetes Gestionat: EKS, AKS i GKE
Mòdul 11: Estudis de Cas i Aplicacions del Món Real
- Desplegament d'una Aplicació Web
- Execució d'Aplicacions amb Estat
- CI/CD amb Kubernetes
- Estratègies de Desplegament: Blue-Green i Canary
- Gestió Multi-Clúster
- Operació en Producció: Incidències, Runbooks i Costos
