En tancar la lliçó anterior va quedar plantejada una pregunta que les mètriques no poden respondre. Quan ApiReservesTaxaErrorAlta dispara a les 03:14, sabem que el 7 % de les peticions falla, en quina ruta i amb quina latència. El que no sabem és per què: l'excepció concreta, el missatge que va retornar PostgreSQL, la línia de codi que va petar. Això no viu en una mètrica. Viu en els logs.
I avui, a Rutas Norte, els logs són un desastre organitzat: estan repartits pels nodes, es perden quan un pod es recrea, no hi ha manera de buscar entre components i desapareixen del tot si el node mor. En aquesta lliçó muntem la pila completa que vam anunciar a 06-02 en desplegar el recol·lector com a DaemonSet: Elasticsearch, Fluentd i Kibana. Veurem com funciona el registre a Kubernetes per sota, com es recullen i s'enriqueixen els logs, per què el JSON estructurat és la decisió que més millora tot el sistema, i —crític per a una plataforma que desa el DNI i el telèfon dels seus clients— què no ha d'acabar mai escrit en un log.
Contingut
- Per què
kubectl logsno basta - Com funciona el registre a Kubernetes per sota
kubectl logsa fons: l'eina de primera línia i els seus límits- L'arquitectura del patró de recol·lecció per node
- Fluentd davant de Fluent Bit
- La configuració real del recol·lector
- Elasticsearch: índexs, plantilles i cicle de vida
- Kibana: patró d'índex, KQL i quadre de comandament d'errors
- Logs estructurats en JSON: la decisió que més rendibilitat dóna
- Correlació entre logs i mètriques
- Què NO s'ha de registrar mai
- Alternatives més lleugeres i el cost real d'una pila de registre
- Errors comuns i consells
- Exercicis
- Per què
kubectl logs no basta
kubectl logs no bastakubectl logs és la primera eina que vam aprendre a 01-05 i continua sent utilíssima. Però té quatre límits estructurals que la fan insuficient tan bon punt la plataforma creix.
Es perd en recrear el pod. Els logs d'un contenidor viuen al sistema de fitxers del node, associats al pod. Quan el pod s'esborra —un desplegament, un desallotjament, un escalat— els fitxers s'esborren amb ell. Si api-reserves va entrar en CrashLoopBackOff a les 03:14 i a les 08:00 algú ja l'havia reiniciat a mà, l'evidència ha desaparegut.
No busca entre components. Un client reporta que la seva compra va fallar. La petició va passar per botiga-web, després per api-reserves, que va consultar redis-cache i postgres-reserves, va cridar la passarel·la externa i va encuar un missatge per a worker-notificacions. Això són sis kubectl logs diferents, en pods diferents, cadascun amb el seu propi format i sense cap manera de saber quina línia d'un correspon a quina línia de l'altre.
No correlaciona. Encara que obris les sis terminals, hauràs de quadrar marques de temps a ull. Amb sis rèpliques d'api-reserves ni tan sols saps en quina va caure la petició.
Desapareix amb el node. Si rutas-norte-worker-2 mor, tots els seus logs moren amb ell. I precisament quan un node mor és quan més falta fan.
A aquests quatre se n'hi suma un de governança: no hi ha retenció definida ni control d'accés granular. Qualsevol amb permís pods/log veu tot el que escriuen les aplicacions, inclosos els que no haurien de ser-hi. Hi tornarem a l'apartat 11.
- Com funciona el registre a Kubernetes per sota
Abans de recollir res cal entendre on són físicament els logs. Sense això, la configuració del recol·lector és màgia.
El contracte: stdout i stderr
Kubernetes no defineix cap API de logs per a les aplicacions. El contracte és el de Docker i el dels dotze factors:
L'aplicació escriu els seus logs a la sortida estàndard (
stdout) i a la d'error (stderr). No gestiona fitxers, ni rotació, ni destinacions.
Una aplicació que escriu a /var/log/laplicacio.log dins del contenidor ho està fent malament: aquell fitxer és invisible per a Kubernetes, creix sense control dins del contenidor i desapareix en reiniciar-se.
El recorregut de la dada
- El procés escriu una línia a
stdout. - El runtime de contenidors (containerd o CRI-O) captura aquella sortida.
- El runtime l'escriu en un fitxer del node, en format CRI.
- El kubelet gestiona aquell fitxer: el rota i crea enllaços simbòlics amb nom parlant.
kubectl logsdemana al kubelet que llegeixi aquell fitxer i el retorna.
flowchart TD
APP["Procés d'api-reserves<br/>console.log(...)"] -->|stdout| RT[containerd]
RT -->|escriu| F["/var/log/pods/<ns>_<pod>_<uid>/<contenidor>/0.log"]
F -.enllaç simbòlic.-> L["/var/log/containers/<pod>_<ns>_<contenidor>-<id>.log"]
KL[kubelet] -->|rota| F
KUBECTL["kubectl logs"] --> KL
KL --> F
L --> REC["Recol·lector<br/>DaemonSet de 06-02"]
Els fitxers del node
Entrem en un node del nostre minikube a mirar-los:
api-reserves-7d9f8c4b5-x2klm_rutas-norte-pro_api-3f8a2c...9b1.log -> /var/log/pods/rutas-norte-pro_api-reserves-7d9f8c4b5-x2klm_a4f2.../api/0.log
postgres-reserves-0_rutas-norte-pro_postgres-7c2d...4e8.log -> /var/log/pods/...
postgres-reserves-0_rutas-norte-pro_exportador-pg-1b9f...2a7.log -> /var/log/pods/...
botiga-web-6c8b9d7f4-hj3ks_rutas-norte-pro_nginx-5d1e...8c3.log -> /var/log/pods/...
worker-notificacions-5f7c8b9d4-tz2mv_rutas-norte-pro_worker-9a4b...6f2.log -> ...
worker-notificacions-5f7c8b9d4-tz2mv_rutas-norte-pro_adaptador-logs-2c8d...1e5.log -> ...El nom del fitxer és la clau de tot. La seva estructura és:
D'aquí el recol·lector extreu, sense consultar ningú, el pod, el namespace i el contenidor de cada línia. És el que fa possible l'enriquiment de l'apartat 6.
Fixa't també que postgres-reserves-0 té dos fitxers: un pel contenidor postgres i un altre pel sidecar exportador-pg que vam afegir a 06-04. Cada contenidor té el seu propi flux de logs.
El format CRI
2026-08-06T03:14:22.183947621Z stdout F {"nivell":"info","missatge":"peticio atesa","ruta":"/api/rutes"}
2026-08-06T03:14:22.891043128Z stderr F Error: connection pool exhausted
2026-08-06T03:14:22.891098412Z stderr F at Pool.connect (/app/node_modules/pg-pool/index.js:200:35)Cada línia té quatre camps separats per espais:
| Camp | Exemple | Significat |
|---|---|---|
| Marca de temps | 2026-08-06T03:14:22.183947621Z |
RFC3339 amb nanosegons, sempre en UTC |
| Flux | stdout / stderr |
D'on va venir |
| Etiqueta | F / P |
F = línia completa (full), P = parcial (partial) |
| Contingut | La resta | El que va escriure l'aplicació |
L'etiqueta P apareix quan una línia supera els 16 KB: el runtime la parteix. Un recol·lector mal configurat tractarà cada tros com una línia diferent. Fluentd i Fluent Bit saben reassemblar-les, però cal activar-ho.
La rotació del kubelet
Sense rotació, un contenidor xerraire ompliria el disc del node i provocaria desallotjaments de pods. El kubelet la gestiona amb dos paràmetres de la seva configuració:
# /var/lib/kubelet/config.yaml
containerLogMaxSize: 10Mi # mida màxima per fitxer abans de rotar
containerLogMaxFiles: 5 # quants fitxers rotats conservarAmb els valors per defecte, cada contenidor conserva com a màxim 50 MB de logs al node. api-reserves sota càrrega genera uns 200 MB al dia per rèplica: això significa que conserva menys de sis hores d'història.
Conseqüència directa i molt concreta: si l'incident va passar a les 03:14 i ho mires a les 11:00, els logs ja s'han rotat i ja no existeixen, encara que el pod no s'hagi reiniciat. És una raó addicional, juntament amb les quatre de l'apartat 1, per centralitzar.
kubectl logs a fons: l'eina de primera línia i els seus límits
kubectl logs a fons: l'eina de primera línia i els seus límitsEncara que muntem EFK, kubectl logs continua sent el primer que s'executa davant d'un problema. Val la pena dominar-lo.
# El bàsic
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm
# Un contenidor concret d'un pod multicontenidor (imprescindible des de 06-04)
kubectl -n rutas-norte-pro logs postgres-reserves-0 -c exportador-pg
# EL FLAG MÉS IMPORTANT: logs de l'execució ANTERIOR del contenidor.
# Quan un pod està en CrashLoopBackOff, el contenidor actual acaba
# d'arrencar i no té res útil. La causa és a l'execució que va morir.
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm --previous
# Seguir en temps real
kubectl -n rutas-norte-pro logs -f api-reserves-7d9f8c4b5-x2klm
# Només el recent: les últimes 100 línies, o els últims 15 minuts
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm --tail=100
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm --since=15m
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm --since-time="2026-08-06T03:10:00Z"
# Amb marques de temps afegides per kubectl (útil si l'app no les posa)
kubectl -n rutas-norte-pro logs api-reserves-7d9f8c4b5-x2klm --timestamps
# DIVERSOS PODS ALHORA mitjançant selector d'etiquetes: les 6 rèpliques juntes
kubectl -n rutas-norte-pro logs -l app=api-reserves --tail=50 --prefix
# Tots els contenidors d'un pod, inclosos els sidecars
kubectl -n rutas-norte-pro logs postgres-reserves-0 --all-containers=true
# Tots els components de la plataforma a l'entorn
kubectl -n rutas-norte-pro logs -l app.kubernetes.io/part-of=rutas-norte \
--tail=20 --prefix --max-log-requests=10El flag --prefix anteposa el nom del pod a cada línia, imprescindible en fer servir -l:
[pod/api-reserves-7d9f8c4b5-x2klm/api] {"nivell":"error","missatge":"pool exhaurit"}
[pod/api-reserves-7d9f8c4b5-mn8pq/api] {"nivell":"info","missatge":"peticio atesa"}Combinacions útils al dia a dia:
# Només els errors de les últimes dues hores, en totes les rèpliques
kubectl -n rutas-norte-pro logs -l app=api-reserves --since=2h --prefix | grep -i error
# Comptar errors per tipus
kubectl -n rutas-norte-pro logs -l app=api-reserves --since=1h | \
jq -r 'select(.nivell=="error") | .missatge' | sort | uniq -c | sort -rnEls seus límits, ara explícits
| Límit | Conseqüència pràctica |
|---|---|
| Només el pod actual i l'anterior | Un pod recreat tres vegades ha perdut les dues primeres execucions |
| Només el que queda després de la rotació | Menys de 6 h d'història en components verbosos |
-l té un màxim de peticions concurrents |
Amb 40 pods, --max-log-requests es queda curt |
| Sense cerca estructurada | grep sobre text pla, sense filtrar per camp |
| Sense agregació | Impossible comptar "quants errors per hora l'última setmana" |
| Sense retenció garantida | No serveix per a auditoria ni compliment normatiu |
Regla d'ús: kubectl logs per al que està passant ara; la pila centralitzada per al que va passar.
- L'arquitectura del patró de recol·lecció per node
Hi ha tres maneres de recollir logs a Kubernetes. Només una és la bona per al cas general.
| Patró | Com funciona | Quan fer-lo servir |
|---|---|---|
| Agent per node | Un DaemonSet llegeix els fitxers de /var/log/containers |
L'estàndard. Un agent per node, serveix per a totes les aplicacions |
| Sidecar de streaming | Un contenidor extra per pod llegeix logs i els reemet | Només si l'aplicació escriu a fitxer i no es pot canviar |
| Empenta des de l'aplicació | L'aplicació envia directament al magatzem | Gairebé mai: acobla l'aplicació al backend |
Triem el primer, i ja el tenim desplegat: a 06-02, en estudiar DaemonSets, vam desplegar un recol·lector de logs amb un pod per node, tolerations per al pla de control i un hostPath muntat sobre /var/log/containers, anunciant que la pila completa es muntaria aquí. Ha arribat el moment.
flowchart TB
subgraph N1["Node rutas-norte-worker-1"]
P1["Pods: api-reserves,<br/>botiga-web"] -->|stdout| F1["/var/log/containers/*.log"]
F1 --> FB1["Fluent Bit<br/>(DaemonSet, 06-02)"]
end
subgraph N2["Node rutas-norte-worker-2"]
P2["Pods: postgres-reserves,<br/>worker-notificacions"] -->|stdout| F2["/var/log/containers/*.log"]
F2 --> FB2["Fluent Bit"]
end
subgraph N3["Node rutas-norte-worker-3"]
P3["Pods: redis-cache,<br/>informes-ocupacio"] -->|stdout| F3["/var/log/containers/*.log"]
F3 --> FB3["Fluent Bit"]
end
FB1 --> AGG["Fluentd agregador<br/>(Deployment)<br/>parseig pesat,<br/>emmascarament, buffer"]
FB2 --> AGG
FB3 --> AGG
API[(API Server)] -.metadades.-> FB1
API -.metadades.-> FB2
API -.metadades.-> FB3
AGG --> ES[("Elasticsearch<br/>StatefulSet")]
ES --> KB["Kibana<br/>Deployment"]
KB --> U["Persona de guàrdia"]
Les quatre responsabilitats del recol·lector:
- Llegir els fitxers de
/var/log/containers/, seguint les escriptures noves i recordant per on anava després d'un reinici. - Parsejar el format CRI i, si el contingut és JSON, expandir-lo en camps.
- Enriquir amb les metadades de Kubernetes: namespace, pod, contenidor, node, etiquetes i anotacions del pod. Aquestes no són al fitxer: s'obtenen consultant l'API a partir del nom del fitxer.
- Enviar al magatzem, amb buffer i reintents per no perdre res si la destinació cau.
L'enriquiment és el que converteix una línia de text en un document consultable:
{
"@timestamp": "2026-08-06T03:14:22.891Z",
"missatge": "Error: connection pool exhausted",
"kubernetes": {
"namespace_name": "rutas-norte-pro",
"pod_name": "api-reserves-7d9f8c4b5-x2klm",
"container_name": "api",
"host": "rutas-norte-worker-2",
"labels": {
"app": "api-reserves",
"entorn": "pro",
"app_kubernetes_io/part-of": "rutas-norte"
}
},
"stream": "stderr"
}Ara sí que es pot buscar "tots els errors d'api-reserves a pro entre les 03:00 i les 04:00", que és el que necessitàvem.
Arquitectura de dos nivells
Hem posat un agregador entre els agents i Elasticsearch. No és obligatori, però en producció es justifica:
- Els agents de node (Fluent Bit) es queden lleugeríssims: només llegir i reenviar.
- El parseig pesat, l'emmascarament de dades sensibles i l'encaminament es fan en un sol lloc, més fàcil d'auditar i de canviar.
- Elasticsearch rep connexions de 3 agregadors en lloc de 30 agents, cosa que redueix molt la pressió.
- L'agregador fa d'amortidor: si Elasticsearch cau mitja hora, el buffer de l'agregador reté les dades.
- Fluentd davant de Fluent Bit
Tots dos són projectes de la CNCF i de la mateixa família. La confusió sobre quin fer servir és constant.
| Aspecte | Fluentd | Fluent Bit |
|---|---|---|
| Llenguatge | Ruby (amb parts en C) | C pur |
| Memòria en repòs | ~40-100 MB | ~2-5 MB |
| CPU | Notablement més gran | Molt baixa |
| Plugins disponibles | Més de 1000 | ~100 (els importants hi són) |
| Extensibilitat | Gemes de Ruby, molt flexible | Plugins en C o Go, o filtres en Lua |
| Configuració | Directives estil XML | Clàssica (INI) o YAML |
| Paper típic | Agregador | Agent per node |
| Rendiment | Milers d'esdeveniments/s | Desenes de milers d'esdeveniments/s |
La regla pràctica que segueix la indústria:
Fluent Bit com a agent a cada node (DaemonSet): consumeix gairebé res, i multiplicat per 30 nodes aquesta diferència importa molt.
Fluentd com a agregador central (Deployment): on cal la flexibilitat dels mil plugins, l'encaminament complex i les transformacions cares.
Per a Rutas Norte amb tres nodes, honestament, Fluent Bit sol n'hi hauria prou. Muntem els dos nivells perquè l'emmascarament de dades personals de l'apartat 11 es beneficia molt de centralitzar-se, i perquè és l'arquitectura que trobaràs en qualsevol clúster seriós.
Comparació de cost al nostre clúster:
| Configuració | Memòria total | Nota |
|---|---|---|
| Fluentd com a DaemonSet (3 nodes) | ~300 MB | Innecessàriament car |
| Fluent Bit com a DaemonSet (3 nodes) | ~30 MB | 10 vegades menys |
| Fluent Bit + agregador Fluentd | ~30 MB + 512 MB | L'extra es concentra i es controla |
- La configuració real del recol·lector
Fluent Bit com a DaemonSet
Recuperem i completem el DaemonSet de 06-02:
# k8s/base/registre/fluent-bit-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: fluent-bit
namespace: registre
labels:
app: fluent-bit
app.kubernetes.io/part-of: rutas-norte
spec:
selector:
matchLabels:
app: fluent-bit
template:
metadata:
labels:
app: fluent-bit
spec:
serviceAccountName: fluent-bit # necessita llegir pods de l'API
# Tolerations per desplegar-se TAMBÉ al pla de control, com
# vam veure a 06-02: els seus logs també ens interessen.
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: fluent-bit
image: fluent/fluent-bit:3.1.4
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "192Mi"
volumeMounts:
# Els logs del node, en NOMÉS LECTURA
- name: varlog
mountPath: /var/log
readOnly: true
# Els fitxers reals als quals apunten els enllaços simbòlics
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
- name: config
mountPath: /fluent-bit/etc/
# Posicions de lectura: sobreviu al reinici del pod per no
# reenviar-ho tot des del principi.
- name: posicions
mountPath: /var/fluent-bit/state
volumes:
- name: varlog
hostPath:
path: /var/log
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
- name: posicions
hostPath:
path: /var/fluent-bit/state
type: DirectoryOrCreate
- name: config
configMap:
name: fluent-bit-configNota de seguretat, que reprendrem al mòdul 8. Aquest DaemonSet munta
hostPathsobre el sistema de fitxers del node. Encara que sigui en només lectura, qualsevol que pugui executar unexecen aquest pod pot llegir els logs de tots els contenidors del node, inclosos els d'altres namespaces. El recol·lector és un objectiu d'alt valor: ha de tenir el seu propi namespace, RBAC mínim i accés molt restringit.
La configuració de Fluent Bit
# k8s/base/registre/fluent-bit-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: fluent-bit-config
namespace: registre
data:
fluent-bit.conf: |
[SERVICE]
Flush 5
Log_Level info
Daemon off
Parsers_File parsers.conf
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020 # exposa mètriques per a Prometheus (07-03)
# =====================================================================
# ENTRADA: llegir els fitxers dels contenidors
# =====================================================================
[INPUT]
Name tail
Tag kube.*
Path /var/log/containers/*.log
# No llegir els logs del propi recol·lector: bucle infinit garantit
Exclude_Path /var/log/containers/fluent-bit*.log,/var/log/containers/*_registre_*.log
Parser cri
# Fitxer on recorda per on anava cada log
DB /var/fluent-bit/state/posicions.db
Mem_Buf_Limit 32MB
Skip_Long_Lines On
Refresh_Interval 10
# REASSEMBLAR línies partides pel runtime (l'etiqueta P del
# format CRI). Sense això, una línia de 20 KB arriba trossejada.
multiline.parser cri
# =====================================================================
# FILTRE 1: enriquir amb metadades de Kubernetes
# =====================================================================
[FILTER]
Name kubernetes
Match kube.*
Kube_URL https://kubernetes.default.svc:443
Kube_Tag_Prefix kube.var.log.containers.
# Consulta l'API per obtenir etiquetes i anotacions del pod
Merge_Log On
Merge_Log_Key log_processat
Keep_Log Off
K8S-Logging.Parser On
K8S-Logging.Exclude On
Labels On
Annotations Off
Buffer_Size 32k
# Merge_Log On és la línia més rendible de tota la configuració:
# si el contingut del log és JSON vàlid, l'EXPANDEIX en camps en lloc
# de deixar-lo com una cadena. És el que fa útil l'apartat 9.
# =====================================================================
# FILTRE 2: reassemblar traces d'excepció multilínia
# =====================================================================
[FILTER]
Name multiline
Match kube.*
multiline.key_content log
multiline.parser java_excepcio, node_excepcio
# =====================================================================
# FILTRE 3: emmascarar dades sensibles (vegeu l'apartat 11)
# =====================================================================
[FILTER]
Name lua
Match kube.*
script /fluent-bit/etc/emmascarar.lua
call emmascarar_dades_personals
# =====================================================================
# SORTIDA: a l'agregador Fluentd
# =====================================================================
[OUTPUT]
Name forward
Match kube.*
Host fluentd-agregador.registre.svc.cluster.local
Port 24224
# Buffer al disc: si l'agregador cau, no es perd res
storage.total_limit_size 2G
Retry_Limit False
parsers.conf: |
# Parser del format CRI: els quatre camps de l'apartat 2
[PARSER]
Name cri
Format regex
Regex ^(?<time>[^ ]+) (?<stream>stdout|stderr) (?<logtag>[FP]) (?<log>.*)$
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L%z
# Multilínia per a traces de Java: una línia nova comença per data;
# les que comencen per espais+at o per Caused by són continuació.
[MULTILINE_PARSER]
Name java_excepcio
Type regex
Flush_Timeout 1000
Rule "start_state" "/^\d{4}-\d{2}-\d{2}/" "cont"
Rule "cont" "/^\s+at\s|^Caused by:|^\s+\.{3}/" "cont"
# Multilínia per a traces de Node.js
[MULTILINE_PARSER]
Name node_excepcio
Type regex
Flush_Timeout 1000
Rule "start_state" "/^(Error|TypeError|ReferenceError)/" "cont"
Rule "cont" "/^\s+at\s/" "cont"El problema de les traces multilínia
Aquest és el detall que més frustració causa i que més s'agraeix resoldre.
Una excepció de Node.js arriba al log així:
Error: connection pool exhausted
at Pool.connect (/app/node_modules/pg-pool/index.js:200:35)
at ReservesRepo.buscar (/app/src/repos/reserves.js:47:22)
at async ReservesCtrl.crear (/app/src/ctrl/reserves.js:88:18)
at async /app/src/rutes/reserves.js:31:5Per al runtime són cinc línies independents. Sense el filtre multilínia, Elasticsearch rep cinc documents:
- Un amb
Error: connection pool exhausted, sense cap traça. - Quatre amb fragments de traça, sense cap context de quin error els va produir.
I a Kibana, ordenats per data entre els logs d'altres cinc pods, resulta impossible reconstruir l'excepció. Amb vint línies de traça, el problema es multiplica.
El filtre multiline reconeix que les línies que comencen per espais i at són continuació de l'anterior i les uneix en un sol document amb la traça completa en un camp. És la diferència entre poder depurar i no poder.
L'agregador Fluentd
# k8s/base/registre/fluentd-agregador-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: fluentd-agregador-config
namespace: registre
data:
fluent.conf: |
# Entrada: rep de tots els Fluent Bit del clúster
<source>
@type forward
port 24224
bind 0.0.0.0
</source>
# Encaminament per namespace: separar entorns en índexs diferents
# permet retencions i permisos diferents per entorn.
<match kube.**>
@type rewrite_tag_filter
<rule>
key $.kubernetes.namespace_name
pattern /^rutas-norte-pro$/
tag produccio.${tag}
</rule>
<rule>
key $.kubernetes.namespace_name
pattern /^rutas-norte-(dev|pre)$/
tag noproduccio.${tag}
</rule>
<rule>
key $.kubernetes.namespace_name
pattern /.+/
tag sistema.${tag}
</rule>
</match>
# Sortida a Elasticsearch, amb índexs separats per entorn i per dia
<match produccio.**>
@type elasticsearch
host elasticsearch.registre.svc.cluster.local
port 9200
scheme https
ssl_verify true
user "#{ENV['ES_USUARI']}"
password "#{ENV['ES_PASSWORD']}"
# Escriu a un flux de dades gestionat per ILM (apartat 7)
index_name rutasnorte-pro
suppress_type_name true
<buffer>
@type file
path /var/log/fluentd/buffer/produccio
# Buffer al disc: si Elasticsearch cau, s'acumula aquí
total_limit_size 8GB
chunk_limit_size 16MB
flush_interval 10s
retry_type exponential_backoff
retry_max_interval 60
retry_forever true # no descartar mai logs de producció
overflow_action block
</buffer>
</match>
<match noproduccio.**>
@type elasticsearch
host elasticsearch.registre.svc.cluster.local
port 9200
index_name rutasnorte-noprod
<buffer>
@type file
path /var/log/fluentd/buffer/noprod
total_limit_size 2GB
flush_interval 30s
retry_forever false # aquí sí que es pot descartar
</buffer>
</match>El paràmetre retry_forever true en producció i false fora d'ella és una decisió de disseny conscient: perdre logs de rutas-norte-dev durant una caiguda d'Elasticsearch és acceptable; perdre els de producció, no.
Monitorar el recol·lector amb el que hem après a 07-03
El recol·lector és infraestructura crítica: si falla en silenci, et quedes sense logs sense assabentar-te'n.
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: fluent-bit
namespace: registre
spec:
selector:
matchLabels:
app: fluent-bit
podMetricsEndpoints:
- port: http-metriques # el port 2020 del [SERVICE]
path: /api/v1/metrics/prometheus
interval: 30sI l'alerta corresponent, amb el criteri de 07-04:
- alert: RecollectorLogsDescartantRegistres
expr: |
rate(fluentbit_output_retries_failed_total[10m]) > 0
for: 15m
labels:
severity: avis
equip: plataforma
annotations:
summary: "Fluent Bit està descartant registres a {{ $labels.node }}"
description: >
El recol·lector no aconsegueix lliurar logs i els està perdent.
Revisar l'agregador i Elasticsearch. Sense logs, el diagnòstic
de qualsevol altre incident serà a cegues.
runbook_url: "https://runbooks.rutasnorte.example/recollector-logs"
- Elasticsearch: índexs, plantilles i cicle de vida
Elasticsearch és un motor de cerca distribuït que indexa documents JSON. Per al nostre cas, cada línia de log enriquida és un document.
Índexs per dia
Els logs són dades temporals que envelleixen: els d'avui es consulten constantment, els de fa tres setmanes gairebé mai, i els de fa un any probablement mai. Per això no es desen tots en un índex, sinó en índexs per dia:
rutasnorte-pro-2026.08.06 ← el d'avui, escrivint activament
rutasnorte-pro-2026.08.05
rutasnorte-pro-2026.08.04
...
rutasnorte-pro-2026.07.08 ← el més antic, a punt d'esborrar-seAvantatges decisius d'aquesta divisió:
- Esborrar és instantani. Eliminar un índex sencer és una operació de metadades. Esborrar documents solts dins d'un índex gran és lentíssim i costós.
- Les consultes per data només toquen els índexs necessaris. Buscar a les últimes 24 hores no recorre 30 dies de dades.
- Cada índex pot tenir configuració diferent: els recents en discos ràpids, els antics comprimits.
Plantilles d'índex
Una plantilla defineix com es configura qualsevol índex nou que coincideixi amb un patró. Sense ella, Elasticsearch endevina els tipus de cada camp, i endevina malament.
PUT _index_template/rutasnorte-logs
{
"index_patterns": ["rutasnorte-pro-*", "rutasnorte-noprod-*"],
"priority": 200,
"template": {
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"refresh_interval": "30s",
"index.lifecycle.name": "politica-logs-rutasnorte",
"index.lifecycle.rollover_alias": "rutasnorte-pro"
},
"mappings": {
"properties": {
"@timestamp": { "type": "date" },
"nivell": { "type": "keyword" },
"component": { "type": "keyword" },
"traca_id": { "type": "keyword" },
"missatge": { "type": "text" },
"duracio_ms": { "type": "long" },
"codi_http": { "type": "short" },
"stream": { "type": "keyword" },
"kubernetes": {
"properties": {
"namespace_name": { "type": "keyword" },
"pod_name": { "type": "keyword" },
"container_name": { "type": "keyword" },
"host": { "type": "keyword" },
"labels": {
"properties": {
"app": { "type": "keyword" },
"entorn": { "type": "keyword" }
}
}
}
}
}
}
}
}La distinció entre keyword i text és fonamental i confon tothom:
| Tipus | Com s'indexa | Serveix per a | Exemple |
|---|---|---|---|
keyword |
Valor exacte, sense trossejar | Filtrar, agregar, ordenar | nivell: "error", pod_name |
text |
Trossejat en paraules (analitzat) | Cerca de text lliure | missatge |
Si nivell fos text, no podries fer una agregació "quants logs per nivell", que és exactament el que vols en un quadre de comandament. Si missatge fos keyword, no podries buscar "pool" dins de "connection pool exhausted".
refresh_interval: 30s és un ajust de rendiment important: per defecte és 1 segon, cosa que obliga Elasticsearch a fer visible cada document gairebé a l'instant, a un cost alt. Per a logs, 30 segons de retard és perfectament acceptable i multiplica el rendiment d'escriptura.
Cicle de vida (ILM)
La gestió del cicle de vida d'índexs automatitza l'envelliment. Sense ella, algú ha de recordar-se d'esborrar índexs antics, i ningú se'n recorda fins que el disc s'omple.
PUT _ilm/policy/politica-logs-rutasnorte
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_primary_shard_size": "30gb",
"max_age": "1d"
},
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "3d",
"actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"allocate": { "number_of_replicas": 0 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "14d",
"actions": {
"allocate": {
"require": { "tipus_node": "fred" }
},
"set_priority": { "priority": 0 }
}
},
"delete": {
"min_age": "30d",
"actions": { "delete": {} }
}
}
}
}| Fase | Quan | Què es fa | Per què |
|---|---|---|---|
| Calenta (hot) | Dia 0-3 | Escriptura activa, rèpliques, prioritat alta | Es consulta constantment |
| Tèbia (warm) | Dia 3-14 | Només lectura, sense rèplica, segments fusionats | Es consulta de vegades; estalvia 50 % d'espai |
| Freda (cold) | Dia 14-30 | Mogut a nodes amb discos lents i barats | Es consulta gairebé mai |
| Esborrat | Dia 30 | Índex eliminat | Ja no aporta |
El punt crític: la fase d'esborrat. Els 30 dies no són un número tècnic, és una decisió de negoci i de compliment normatiu. Si els logs contenen dades personals (que no haurien, però és el que passa a la pràctica), la retenció s'ha d'ajustar al que exigeixi la normativa aplicable i al que hagi definit el responsable de protecció de dades de l'empresa. Hi tornem a l'apartat 11.
Dimensionament mínim realista
Aquesta és la part que se subestima sempre.
Estimació per a Rutas Norte:
6 components × ~4 rèpliques mitjanes = 24 contenidors
Volum mitjà: 300 línies/minut per contenidor en horari actiu
Mida mitjana d'un document enriquit: ~800 bytes
24 × 300 × 60 × 16 h ≈ 6,9 milions de documents al dia
6,9 M × 800 B ≈ 5,5 GB/dia en brut
Amb overhead d'indexació d'Elasticsearch (×1,3): ~7,2 GB/dia
Amb 1 rèplica a la fase calenta: ~14 GB/dia els primers 3 dies
Retenció de 30 dies:
3 dies calents amb rèplica: 43 GB
27 dies tebis/freds sense rèplica: 194 GB
TOTAL ≈ 240 GB, més un 25 % de marge operatiu → 300 GBI el mínim de recursos de còmput:
| Component | Mínim realista per a Rutas Norte |
|---|---|
| Elasticsearch | 3 nodes (per al quòrum), 4 GB de heap cadascun (8 GB de RAM), 100 GB de disc cadascun |
| Fluentd agregador | 2 rèpliques, 512 MB de RAM, 10 GB de disc per al buffer |
| Fluent Bit | 1 per node, 64-192 MB de RAM |
| Kibana | 1 rèplica, 1 GB de RAM |
Elasticsearch necessita tres nodes, no un. Amb un sol node no hi ha tolerància a errors i la pila de logs cau sencera quan aquell pod es reinicia. Amb dos hi ha risc de cervell dividit (split-brain). Tres és el mínim real.
Regla d'or del heap: assigna com a màxim el 50 % de la memòria del contenidor i mai més de 31 GB (per sobre d'aquest llindar la JVM perd la compressió de punters i rendeix pitjor amb més memòria).
# Fragment del StatefulSet d'Elasticsearch
env:
- name: ES_JAVA_OPTS
value: "-Xms4g -Xmx4g" # heap = meitat dels 8Gi del contenidor
resources:
requests:
cpu: "1"
memory: "8Gi"
limits:
memory: "8Gi" # igual que request: QoS Guaranteed
volumeClaimTemplates:
- metadata:
name: dades
spec:
storageClassName: rutasnorte-rapida # la classe de 05-04
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100GiTotal aproximat: 24 GB de RAM i 300 GB de disc ràpid només per poder llegir logs. Aquesta xifra és la que cal posar sobre la taula abans de decidir, i la que motiva l'apartat 12.
- Kibana: patró d'índex, KQL i quadre de comandament d'errors
Vista de dades
Abans de buscar res cal dir-li a Kibana quins índexs consultar. A Stack Management → Data Views → Create data view:
El camp de temps és el que permet el selector de rang temporal i els histogrames. Sense ell, Kibana no pot ordenar cronològicament.
Buscar amb KQL
KQL (Kibana Query Language) és el llenguatge de la barra de cerca. És molt més simple que PromQL i s'aprèn en deu minuts.
# Tots els errors
nivell: "error"
# Errors d'un component concret
nivell: "error" and kubernetes.labels.app: "api-reserves"
# Un pod específic
kubernetes.pod_name: "api-reserves-7d9f8c4b5-x2klm"
# Cerca de text lliure al missatge (camp text)
missatge: "pool exhausted"
# Frase exacta
missatge: "connection pool exhausted"
# Comodins al nom del pod: totes les rèpliques
kubernetes.pod_name: api-reserves-*
# Negació: tot menys les sondes de salut (07-01), que són pur soroll
kubernetes.labels.app: "api-reserves" and not ruta: ("/salut" or "/preparat")
# Rangs numèrics: peticions lentes
duracio_ms > 1000
# Combinacions amb parèntesis
(nivell: "error" or nivell: "fatal")
and kubernetes.namespace_name: "rutas-norte-pro"
and not missatge: "ECONNRESET"
# Existència d'un camp
traca_id: *
# Errors 5xx
codi_http >= 500Comparació ràpida amb el que ja sabem:
| Vull... | En PromQL (07-03) | En KQL |
|---|---|---|
| Filtrar per etiqueta | {app="api-reserves"} |
kubernetes.labels.app: "api-reserves" |
| Negar | {codi!="200"} |
not codi_http: 200 |
| Expressió regular | {ruta=~"/api/.*"} |
ruta: /api/* |
| Rang numèric | metrica > 100 |
duracio_ms > 100 |
El flux de treball real davant d'un incident
Tornem a l'escenari de l'apartat 1: l'alerta ApiReservesTaxaErrorAlta dispara a les 03:14.
Pas 1 — Acotar en el temps. Selector de rang: 2026-08-06 03:00 a 2026-08-06 04:00.
Pas 2 — Veure la forma del problema. Consulta àmplia i mirar l'histograma:
L'histograma mostra d'un cop d'ull si els errors van començar de cop (un desplegament, una caiguda) o van créixer progressivament (esgotament d'un recurs).
Pas 3 — Identificar el component. Al panell de camps, fer clic a kubernetes.labels.app per veure la distribució:
Pas 4 — Trobar el missatge dominant. Clic a missatge.keyword:
connection pool exhausted 4102
timeout acquiring connection from pool 619
Error: read ECONNRESET 100Pas 5 — Obrir un document i llegir la traça completa. Gràcies al filtre multilínia de l'apartat 6, l'excepció és sencera en un sol document.
Pas 6 — Correlacionar cap enrere. Què va passar just abans del primer error? Treure el filtre de nivell i mirar els cinc minuts anteriors:
Allà sol aparèixer la causa: un desplegament, un CronJob que va arrencar, una consulta lenta de PostgreSQL.
Diagnòstic complet en cinc minuts. Amb kubectl logs en sis pods, això hauria portat una hora, i probablement els logs ja no existirien.
Quadre de comandament d'errors
A Dashboards → Create dashboard, amb aquestes visualitzacions:
| Visualització | Tipus | Configuració |
|---|---|---|
| Errors en el temps | Barres verticals | Eix X: @timestamp (interval 5 min). Eix Y: recompte. Desglossament: nivell |
| Errors per component | Pastís o barres horitzontals | Termes de kubernetes.labels.app, ordenat per recompte |
| Missatges d'error més freqüents | Taula | Termes de missatge.keyword, top 20, amb el recompte |
| Errors per pod | Mapa de calor | Eix X: temps. Eix Y: kubernetes.pod_name. Color: recompte |
| Últims errors | Taula de documents | Columnes: hora, component, pod, missatge. Ordenat descendent |
| Distribució per nivell | Mètrica | Recompte filtrat per cada nivell |
El mapa de calor per pod és especialment revelador: si els errors es concentren en un sol pod de sis, el problema és d'aquell pod (un node amb problemes, una rèplica amb estat corrupte). Si estan repartits uniformement, el problema és sistèmic (la base de dades, una dependència externa). Aquesta distinció, que a kubectl logs és gairebé impossible de veure, aquí salta a la vista en un segon.
- Logs estructurats en JSON: la decisió que més rendibilitat dóna
Tot l'anterior funciona moltíssim millor si les aplicacions escriuen JSON en lloc de text lliure. És, de llarg, el canvi de menor cost i major impacte de tota la lliçó.
L'abans
2026-08-06 03:14:22 [ERROR] api-reserves - Error en crear reserva per al trajecte Bilbao-Santander de l'usuari 48213 després de 4821ms: connection pool exhaustedProblemes d'aquesta línia, que sembla perfectament raonable:
- Per filtrar per nivell cal buscar la subcadena
[ERROR], cosa que també troba un missatge que digui "l'usuari va veure un [ERROR] a la pantalla". - La durada
4821msés text: no es pot consultarduracio_ms > 1000ni calcular percentils. - L'identificador d'usuari està incrustat a la frase: no es pot filtrar per ell.
- No hi ha identificador de traça: no es pot seguir aquesta petició pels altres components.
- Si demà algú canvia el format del missatge, tots els filtres desats deixen de funcionar.
El després
{
"timestamp": "2026-08-06T03:14:22.891Z",
"nivell": "error",
"component": "api-reserves",
"traca_id": "8f3a2c91-4b7d-4e2a-9c15-7f8d3e1a6b04",
"missatge": "Error en crear reserva",
"error_tipus": "PoolExhaustedError",
"error_detall": "connection pool exhausted",
"duracio_ms": 4821,
"ruta": "/api/reserves",
"metode": "POST",
"codi_http": 500,
"trajecte_origen": "Bilbao",
"trajecte_desti": "Santander",
"versio_app": "2.8.1"
}Ara sí:
I agregacions: la durada mitjana de les peticions fallides, els deu trajectes amb més errors, l'evolució d'error_tipus en el temps.
Nota crítica sobre el que no és en aquell JSON: no hi apareix el nom del client, ni el seu DNI, ni el seu telèfon, ni el seu correu. Hi apareix traca_id, que és un identificador opac. Si cal saber quin client era, es creua el traca_id amb la base de dades, en un sistema amb control d'accés. És una decisió deliberada, i l'apartat 11 explica per què és obligatòria.
L'estàndard de camps de Rutas Norte
Tot component de la plataforma ha d'emetre aquests camps:
| Camp | Tipus | Obligatori | Descripció |
|---|---|---|---|
timestamp |
ISO 8601 UTC amb mil·lisegons | Sí | Moment de l'esdeveniment |
nivell |
debug|info|warn|error|fatal |
Sí | Severitat, en minúscules |
component |
cadena | Sí | Nom del component, igual que l'etiqueta app |
traca_id |
UUID | Sí en peticions | Identificador que segueix la petició entre components |
missatge |
cadena | Sí | Descripció llegible, sense dades variables incrustades |
error_tipus |
cadena | Si hi ha error | Classe de l'excepció |
duracio_ms |
enter | Si aplica | Durada de l'operació |
versio_app |
cadena | Recomanat | Versió desplegada: permet correlacionar amb un desplegament |
Regla sobre missatge: ha de ser constant per al mateix tipus d'esdeveniment, amb els valors variables en camps a part. "Error en crear reserva" amb duracio_ms: 4821 és molt més útil que "Error en crear reserva després de 4821ms", perquè permet agregar per missatge.
Implementació a api-reserves
// registre.js — logger estructurat amb pino
const pino = require('pino');
const registre = pino({
level: process.env.NIVELL_LOG || 'info',
// Escriu a stdout: el contracte de Kubernetes de l'apartat 2
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level: (etiqueta) => ({ nivell: etiqueta }), // "level":30 -> "nivell":"info"
},
base: {
component: 'api-reserves',
versio_app: process.env.APP_VERSION,
entorn: process.env.ENTORN,
},
// REDACCIÓ AUTOMÀTICA: l'última línia de defensa de l'apartat 11.
// Si algú registra per error un objecte amb aquests camps, se substitueixen.
redact: {
paths: [
'req.headers.authorization', 'req.headers.cookie',
'*.password', '*.dni', '*.telefon', '*.email', '*.correu',
'*.targeta', '*.cvv', '*.iban',
'client.nom', 'client.cognoms',
],
censor: '[REDACTAT]',
},
});
module.exports = registre;I el seu ús, amb l'identificador de traça propagat:
const { randomUUID } = require('crypto');
const registre = require('./registre');
// Middleware: cada petició rep un traca_id, o reutilitza el que vingui
app.use((req, res, next) => {
req.tracaId = req.headers['x-traca-id'] || randomUUID();
res.setHeader('x-traca-id', req.tracaId);
// Logger fill: TOTS els logs d'aquesta petició portaran el traca_id
req.log = registre.child({ traca_id: req.tracaId });
next();
});
app.post('/api/reserves', async (req, res) => {
const inici = Date.now();
try {
const reserva = await crearReserva(req.body);
req.log.info({
ruta: '/api/reserves',
metode: 'POST',
codi_http: 201,
duracio_ms: Date.now() - inici,
trajecte_origen: reserva.origen,
trajecte_desti: reserva.desti,
// Nota: NO registrem reserva.client.dni ni .telefon ni .email
}, 'Reserva creada');
res.status(201).json(reserva);
} catch (err) {
req.log.error({
ruta: '/api/reserves',
metode: 'POST',
codi_http: 500,
duracio_ms: Date.now() - inici,
error_tipus: err.constructor.name,
error_detall: err.message,
traca: err.stack,
}, 'Error en crear reserva');
res.status(500).json({ error: 'error intern', traca_id: req.tracaId });
}
});
// En cridar altres components, propagar el traca_id
async function cridarPassarelaPagaments(req, dades) {
return fetch('https://pagos.proveedorexterno.example/cobros', {
method: 'POST',
headers: { 'x-traca-id': req.tracaId },
body: JSON.stringify(dades),
});
}Fixa't en l'últim detall: retornem el traca_id al client al cos de l'error. Quan algú truqui al servei d'atenció dient "no he pogut comprar", amb aquell identificador es localitza a Kibana la petició exacta i tot el seu recorregut:
Sense filtrar per component: tots els logs de tots els components que van participar en aquella petició, en ordre. Això és correlació de debò.
L'adaptador de logs de worker-notificacions
A 06-04 vam afegir a worker-notificacions un contenidor adaptador que converteix el seu log propietari a JSON. Aquí és on s'explica del tot per què.
worker-notificacions fa servir una biblioteca antiga d'enviament de correu que escriu així, i no es pot modificar sense reescriure el component:
[2026-08-06 03:15:44] SMTP-SEND [email protected] subject="Confirmacio de reserva" result=FAILED reason=timeout duration=30012msDos problemes: no és JSON, i conté l'adreça de correu d'un client, que és una dada personal.
L'adaptador, un sidecar que llegeix aquell log i emet JSON net:
# Fragment del Deployment worker-notificacions (06-04)
initContainers:
- name: adaptador-logs
image: registry.rutasnorte.example/adaptador-logs:1.3.0
restartPolicy: Always # sidecar natiu 1.29+
args:
- --entrada=/var/log/worker/smtp.log
- --format=smtp-heretat
- --sortida=stdout
- --emmascarar=email,telefon # emmascara ABANS d'emetre
volumeMounts:
- name: logs-worker
mountPath: /var/log/worker
resources:
requests:
cpu: "10m"
memory: "16Mi"
limits:
cpu: "50m"
memory: "32Mi"La seva sortida:
{
"timestamp": "2026-08-06T03:15:44.000Z",
"nivell": "error",
"component": "worker-notificacions",
"missatge": "Error en enviar el correu",
"operacio": "smtp_send",
"destinatari_hash": "sha256:4f2a...9b1c",
"assumpte_tipus": "confirmacio_reserva",
"error_tipus": "SmtpTimeout",
"duracio_ms": 30012
}Dues transformacions clau: el format passa a JSON consultable, i l'adreça de correu se substitueix per un hash. El hash permet respondre a "quants correus han fallat per al mateix destinatari?" sense que l'adreça aparegui en cap índex.
- Correlació entre logs i mètriques
Els logs i les mètriques són dues vistes del mateix sistema, i el seu valor es multiplica quan es poden creuar. La clau és fer servir les mateixes etiquetes en tots dos.
| Concepte | A Prometheus (07-03) | Als logs |
|---|---|---|
| Component | component="api-reserves" |
component: "api-reserves" |
| Entorn | entorn="pro" |
kubernetes.labels.entorn: "pro" |
| Namespace | namespace="rutas-norte-pro" |
kubernetes.namespace_name: "rutas-norte-pro" |
| Pod | pod="api-reserves-7d9f..." |
kubernetes.pod_name: "api-reserves-7d9f..." |
| Versió | versio="2.8.1" |
versio_app: "2.8.1" |
Amb aquesta correspondència, el flux d'investigació és directe:
flowchart LR
A["Alerta<br/>ApiReservesTaxaErrorAlta"] --> B["Grafana: el gràfic<br/>mostra el pic a les 03:14"]
B --> C["Copiar pod, namespace<br/>i finestra temporal"]
C --> D["Kibana: filtrar per aquests<br/>mateixos valors"]
D --> E["Llegir el missatge d'error<br/>i la traça completa"]
E --> F["Filtrar per traca_id per<br/>veure TOTA la petició"]
Tres maneres pràctiques d'enllaçar tots dos mons:
1. Enllaç des del panell de Grafana. A les opcions del panell, un Data link que obre Kibana amb els filtres ja aplicats:
https://logs.rutasnorte.example/app/discover#/?
_g=(time:(from:'${__from:date}',to:'${__to:date}'))&
_a=(query:(language:kuery,query:'kubernetes.pod_name:"${__field.labels.pod}"'))Amb això, un clic al pic del gràfic porta als logs d'aquell pod en aquella finestra exacta. Estalvia moltíssim temps sota pressió.
2. Mètriques derivades de logs. Fluentd pot comptar esdeveniments i exposar-los a Prometheus, cosa que permet alertar sobre patrons de log:
<match produccio.**>
@type copy
<store>
@type elasticsearch
# ... configuració normal
</store>
<store>
@type prometheus
<metric>
name rutasnorte_logs_per_nivell_total
type counter
desc Registres emesos per nivell i component
<labels>
nivell ${nivell}
component ${component}
</labels>
</metric>
</store>
</match>Compte amb la cardinalitat, exactament igual que a 07-03: no facis servir mai el missatge complet com a etiqueta.
3. Grafana com a visor unificat. Grafana pot afegir Elasticsearch com a font de dades addicional i mostrar al mateix quadre de comandament un panell de mètriques i un altre de logs. Amb la font Loki (apartat 12) la integració és encara més estreta.
El que falta per tancar el cercle del tot són les traces distribuïdes (OpenTelemetry, Jaeger, Tempo): la tercera pota de l'observabilitat, que registra el recorregut complet d'una petició amb els temps de cada salt. El nostre traca_id és una versió artesanal i molt útil d'aquesta idea, però les traces queden fora de l'abast d'aquest curs.
- Què NO s'ha de registrar mai
Aquest apartat és el més important de la lliçó, i el que més conseqüències té fora de l'àmbit tècnic.
El problema
postgres-reserves desa dades personals dels clients de Rutas Norte: nom, DNI, telèfon i correu electrònic. I tan bon punt muntes una pila de registre centralitzat, tot el que les aplicacions escriguin es copia, s'indexa i es conserva durant 30 dies en un sistema al qual accedeix molta més gent que a la base de dades.
Aquest és el risc real, i és fàcil de subestimar. Un log tan innocent com aquest:
2026-08-06 03:14:22 INFO Creant reserva per a Marta Ruiz Sánchez (DNI 12345678Z, tel 611223344, [email protected]) trajecte Bilbao-Santanderacaba de convertir el teu sistema de logs en un fitxer de dades personals amb totes les obligacions que això comporta.
Llista del que mai ha d'aparèixer
| Categoria | Exemples | Risc |
|---|---|---|
| Credencials | Contrasenyes, tokens, claus d'API, galetes de sessió, capçaleres Authorization |
Accés no autoritzat immediat |
| Dades de pagament | Número de targeta, CVV, data de caducitat, IBAN | Frau; incompliment de PCI DSS |
| Dades personals identificatives | Nom i cognoms, DNI/NIE, telèfon, correu, adreça postal | Normativa de protecció de dades |
| Dades de categoria especial | Salut, discapacitat, origen ètnic, afiliació | Protecció reforçada per normativa |
| Cossos de petició complets | req.body bolcat tal qual |
Conté tot l'anterior sense filtrar |
| URL amb paràmetres sensibles | /api/reserves?dni=12345678Z |
Apareixen als logs d'nginx i de l'Ingress |
Un cas que s'escapa sempre: els logs d'accés de l'Ingress i de botiga-web. Registren la URL completa de cada petició. Si alguna ruta porta dades a la cadena de consulta, queden registrades sense que l'aplicació hi intervingui. Solució: no posar dades sensibles a les URL, mai.
Les quatre capes de defensa
Cap no és suficient per si sola. S'apliquen totes.
Capa 1 — Al codi (la més eficaç). Que la dada no s'escrigui mai. És l'única defensa que no té fuites, perquè si mai surt del procés no hi ha res a filtrar. Requereix revisió a les peticions de canvi.
Capa 2 — Redacció a la llibreria de logging. Com el redact de pino de l'apartat 9: una xarxa de seguretat per als descuits.
Capa 3 — Emmascarament al recol·lector. L'última defensa tècnica abans que la dada es persisteixi.
-- k8s/base/registre/emmascarar.lua
-- Filtre Lua de Fluent Bit: emmascara patrons de dades personals
-- ABANS que surtin del node. És una xarxa de seguretat, NO un substitut
-- de no registrar la dada: si el patró canvia, això no ho detecta.
function emmascarar_dades_personals(tag, timestamp, registre)
local modificat = false
for clau, valor in pairs(registre) do
if type(valor) == "string" then
local original = valor
-- DNI espanyol: 8 dígits + lletra
valor = string.gsub(valor, "%d%d%d%d%d%d%d%d%a", "[DNI-REDACTAT]")
-- Correu electrònic
valor = string.gsub(valor, "[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTAT]")
-- Telèfon espanyol: 9 dígits començant per 6, 7, 8 o 9
valor = string.gsub(valor, "%f[%d][6789]%d%d%d%d%d%d%d%d%f[%D]", "[TEL-REDACTAT]")
-- Targeta de crèdit: 16 dígits amb o sense separadors
valor = string.gsub(valor, "%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d",
"[TARGETA-REDACTADA]")
-- IBAN espanyol
valor = string.gsub(valor, "ES%d%d[ ]?%d%d%d%d[ ]?%d%d%d%d[ ]?%d%d[ ]?%d%d%d%d%d%d%d%d%d%d",
"[IBAN-REDACTAT]")
if valor ~= original then
registre[clau] = valor
modificat = true
end
end
end
-- Eliminar camps que MAI han de persistir-se, sigui quin sigui el contingut
local camps_prohibits = {
"password", "contrasenya", "token", "authorization", "cookie",
"api_key", "secret", "cvv", "targeta"
}
for _, camp in ipairs(camps_prohibits) do
if registre[camp] ~= nil then
registre[camp] = nil
modificat = true
end
end
-- Marcar els registres modificats: permet AUDITAR quins components
-- continuen intentant escriure dades sensibles i corregir-los al codi.
if modificat then
registre["_emmascarat"] = true
end
return 2, timestamp, registre
endAquest camp _emmascarat és més valuós del que sembla. Amb una consulta a Kibana:
i agregant per component, obtens la llista de components que estan escrivint dades personals i que cal corregir al codi. Converteix una mesura defensiva en una eina de millora.
Advertiment honest sobre les expressions regulars: no són fiables. Un DNI escrit com 12.345.678-Z no el detecta el patró anterior. Un nom i cognoms no té cap patró detectable. L'única defensa real és la capa 1.
Capa 4 — Control d'accés i retenció. Encara que no hi hagi fuites, restringeix qui pot veure els logs de producció, amb índexs separats per entorn (com vam fer a l'agregador) i rols d'Elasticsearch que només donin accés al necessari.
Retenció
La retenció de 30 dies de l'apartat 7 és una decisió que combina tres criteris:
| Criteri | Consideració |
|---|---|
| Operatiu | Quant enrere cal mirar per diagnosticar? Normalment 7-14 dies basten |
| Legal | Què exigeix la normativa aplicable al sector i al tipus de dada? |
| Econòmic | Cada dia de retenció costa uns 7 GB de disc ràpid |
I una regla que sovint sorprèn: si els logs contenen dades personals, conservar-los "per si de cas" no és acceptable. La normativa de protecció de dades exigeix que les dades personals es conservin només el temps necessari per a la finalitat que va justificar la seva recollida, i "per si algun dia cal depurar" no sol ser una finalitat vàlida.
⚠️ Advertiment: revisió pel responsable de compliment normatiu
Aquest és un punt que no es pot resoldre només amb criteri tècnic.
La configuració d'aquesta lliçó —què es registra, quant de temps es conserva, qui ho pot consultar i des d'on— ha de ser revisada i aprovada pel responsable de compliment normatiu i protecció de dades de l'organització abans de posar-se en producció.
Rutas Norte tracta dades personals dels seus clients (nom, DNI, telèfon i correu electrònic), cosa que situa la plataforma dins de l'àmbit d'aplicació del RGPD i de la normativa nacional de protecció de dades. Un sistema de registre centralitzat que capturi, encara que sigui accidentalment, aquestes dades, té conseqüències concretes:
- Passa a ser un tractament de dades personals que ha de figurar al registre d'activitats de tractament, amb la seva base legal i la seva finalitat documentades.
- Exigeix un termini de conservació justificat i aplicat tècnicament, no una retenció indefinida "per si de cas".
- Obliga a controlar i registrar els accessos: qui consulta els logs de producció i amb quina finalitat.
- Pot requerir una avaluació d'impacte si el volum o la naturalesa del tractament ho justifiquen.
- Complica notablement l'exercici dels drets de supressió i d'accés: si el DNI d'un client apareix en vint índexs distribuïts, atendre una sol·licitud de supressió es torna tècnicament molt costós.
- Si Elasticsearch està allotjat fora de l'Espai Econòmic Europeu, activa les obligacions sobre transferències internacionals de dades.
Què fer, a la pràctica:
- Documentar per escrit quins camps es registren de cada component i presentar-ho a la persona responsable de compliment.
- Acordar amb ella el termini de retenció de cada índex, i aplicar-lo a la política ILM.
- Definir qui té accés als índexs de producció, i revisar-ho periòdicament.
- Auditar amb la consulta
_emmascarat: truequins components continuen emetent dades sensibles i corregir-los al codi.- Incloure la revisió de logs a la llista de comprovació de qualsevol funcionalitat nova que tracti dades de clients.
L'equip tècnic proporciona els mecanismes —emmascarament, retenció, control d'accés, separació per entorns—, però la decisió sobre què és acceptable registrar i durant quant de temps no és una decisió tècnica.
- Alternatives més lleugeres i el cost real d'una pila de registre
El cost real
Recapitulant l'apartat 7, la pila EFK mínima per a Rutas Norte:
| Recurs | Quantitat | Cost orientatiu mensual (núvol) |
|---|---|---|
| 3 nodes d'Elasticsearch (8 GB RAM, 2 vCPU) | 24 GB RAM, 6 vCPU | 350-500 € |
| Disc ràpid (300 GB SSD) | 300 GB | 30-60 € |
| Fluentd agregador (2 rèpliques) | 1 GB RAM | 15-25 € |
| Fluent Bit (3 nodes) | ~200 MB RAM | Menyspreable |
| Kibana | 1 GB RAM | 15-25 € |
| Total | ~450-650 €/mes |
Més el cost humà: algú ha de mantenir Elasticsearch, dimensionar els fragments, vigilar l'estat del clúster, gestionar les actualitzacions i respondre quan es posa en vermell. Elasticsearch no és un component que s'instal·li i s'oblidi.
Per a una plataforma amb sis components i tres nodes, és un cost considerable. Val la pena preguntar-se si hi ha alguna cosa més lleugera.
Loki amb Promtail: l'alternativa lleugera
Loki és el sistema de logs de Grafana Labs, amb una idea de disseny radicalment diferent:
Loki no indexa el contingut dels logs. Només indexa les etiquetes.
És "Prometheus per a logs": el mateix model d'etiquetes, el mateix llenguatge de consulta (LogQL, molt semblant a PromQL) i emmagatzematge en objectes barats (S3) en lloc de discos ràpids.
| Aspecte | Elasticsearch (EFK) | Loki |
|---|---|---|
| Indexa | Tots els camps | Només les etiquetes |
| Emmagatzematge | Disc ràpid | Objectes (S3, GCS) |
| Cost relatiu | Alt | 5-10 vegades menor |
| Cerca de text lliure | Instantània | Més lenta (escaneig seqüencial) |
| Agregacions complexes | Molt potents | Limitades |
| Consum de recursos | 24 GB de RAM | 2-4 GB de RAM |
| Integració amb Grafana | Bona | Nativa i molt estreta |
| Complexitat operativa | Alta | Baixa |
# Exemple de consulta LogQL, si véns de PromQL et resultarà familiar
{namespace="rutas-norte-pro", app="api-reserves"} |= "error" | json | duracio_ms > 1000
# I fins i tot pots derivar mètriques dels logs
sum(rate({namespace="rutas-norte-pro"} |= "error" [5m])) by (app)Quan triar cadascun:
| Tria EFK si... | Tria Loki si... |
|---|---|
| Necessites cerca de text lliure molt ràpida sobre grans volums | Busques gairebé sempre per component i finestra temporal |
| Fas agregacions complexes sobre els camps | Ja fas servir Grafana i vols logs i mètriques junts |
| Necessites Elasticsearch per a altres coses | El pressupost i l'equip d'operació són limitats |
| L'equip ja sap operar Elasticsearch | Vols començar avui amb poc esforç |
Recomanació honesta per a Rutas Norte: amb sis components, tres nodes i un equip petit, Loki seria l'elecció més sensata. Hem muntat EFK perquè és el que trobaràs a la majoria d'empreses establertes i perquè ensenya els conceptes (índexs, plantilles, ILM, mapatges) que després s'apliquen en qualsevol sistema. Però si demà comencessis de zero, comença per Loki.
Serveis gestionats
La tercera via és no operar res:
| Servei | Notes |
|---|---|
| Elastic Cloud | Elasticsearch gestionat pel mateix fabricant |
| Grafana Cloud Logs | Loki gestionat, amb capa gratuïta generosa |
| AWS CloudWatch Logs / OpenSearch | Integració directa amb EKS (10-06) |
| Google Cloud Logging | Integració directa amb GKE, molt bona |
| Datadog, New Relic, Splunk | Plataformes completes, molt cares a volum |
Avantatge: zero operació. Inconvenients: cost per GB ingerit que es dispara amb el volum, i —important per a l'apartat 11— les dades surten de la teva infraestructura, cosa que cal revisar amb el responsable de compliment normatiu, especialment si el proveïdor emmagatzema fora de l'EEE.
La mesura que més estalvia: registrar menys
Abans de dimensionar res, redueix el volum. És gratis i sempre funciona:
# A Fluent Bit: descartar el soroll de les sondes de salut (07-01).
# Amb 6 rèpliques i sondes cada 5 s, són més de 100 000 línies diàries
# que no aporten absolutament res.
[FILTER]
Name grep
Match kube.*
Exclude log ^.*"ruta":"/(salut|preparat)".*$
# Descartar els logs de nivell debug de producció
[FILTER]
Name grep
Match kube.var.log.containers.*_rutas-norte-pro_*
Exclude nivell ^debug$Mesures complementàries:
- Nivell
infoen producció,debugnomés arutas-norte-dev. access_log off;per a l'endpoint de salut d'nginx, com ja vam fer a 07-01.- Retenció més curta per a
devipre(7 dies) que per apro(30 dies). - Revisar periòdicament quin component genera més volum:
# A Kibana: agregació de recompte per kubernetes.labels.app
# Sol descobrir-se que un sol component genera el 60 % del volum
# per un log de depuració que algú va deixar activat fa mesos.Errors Comuns i Consells
1. L'aplicació escriu a fitxer en lloc de a stdout. Trenca el contracte de Kubernetes: el recol·lector no ho veu, el fitxer creix dins del contenidor i desapareix en reiniciar-se. Si no pots canviar l'aplicació, fes servir un sidecar adaptador com el de worker-notificacions.
2. No configurar el reassemblatge multilínia. Cada traça d'excepció es converteix en vint documents inconnexos, justament el que més falta fa llegir sencer durant un incident.
3. El recol·lector llegeix els seus propis logs. Bucle infinit: cada log generat produeix un altre log. L'Exclude_Path de l'apartat 6 no és opcional.
4. Elasticsearch amb un sol node. Sense quòrum, sense tolerància a errors, i la pila de logs sencera cau quan aquell pod es reinicia. Tres nodes és el mínim real.
5. Heap d'Elasticsearch mal dimensionat. Màxim el 50 % de la memòria del contenidor, i mai més de 31 GB. Per sobre d'aquest llindar la JVM perd la compressió de punters i rendeix pitjor amb més memòria.
6. Sense política ILM. Els índexs s'acumulen fins a omplir el disc. Quan això passa, Elasticsearch passa a només lectura i deixes de rebre logs justament quan més falta fan.
7. Mapar com a text el que hauria de ser keyword. Sense keyword no pots agregar per aquell camp, i les agregacions són la meitat del valor de Kibana.
8. Registrar dades personals. L'error amb més conseqüències fora del terreny tècnic. Aplica les quatre capes de defensa i, sobretot, no ho escriguis al codi.
9. No definir un estàndard de camps. Si cada component fa servir level, severity i nivell per al mateix, no hi ha cap consulta que funcioni per a tots. Acorda l'estàndard abans d'instrumentar.
10. No propagar el traca_id. Sense ell, correlacionar una petició entre sis components és impossible per molta pila de logs que tinguis.
11. Sense buffer al recol·lector. Si Elasticsearch cau deu minuts i no hi ha buffer, es perden deu minuts de logs de producció, probablement els més interessants.
12. No monitorar la pila de logs. El recol·lector pot estar descartant registres en silenci. Fes servir el PodMonitor i l'alerta de l'apartat 6: aplica el que has après a 07-03 i 07-04 a la mateixa infraestructura d'observabilitat.
13. Registrar massa. El nivell debug en producció multiplica per deu el volum i el cost, i fa més difícil trobar l'important entre el soroll.
Exercicis
Exercici 1 — Convertir un log de text a estructurat
botiga-web (nginx) genera logs d'accés en format combinat:
83.45.12.99 - - [06/Aug/2026:03:14:22 +0000] "POST /api/reserves?dni=12345678Z HTTP/1.1" 500 187 "https://www.rutasnorte.example/comprar" "Mozilla/5.0" 4.821- Enumera tots els problemes d'aquesta línia, inclosos els de protecció de dades.
- Escriu la configuració d'nginx que emeti el mateix esdeveniment en JSON, d'acord amb l'estàndard de camps de Rutas Norte.
- Escriu el parser de Fluent Bit necessari si no poguessis canviar la configuració d'nginx.
- Què faries amb el paràmetre
dnide la URL?
Exercici 2 — Diagnosticar una fuita de dades personals
Una auditoria interna revela que l'índex rutasnorte-pro-2026.08.* conté 47 000 documents amb adreces de correu de clients en text clar. Els logs provenen de worker-notificacions i d'api-reserves.
- Escriu la consulta KQL que localitza els documents afectats.
- Enumera, per ordre de prioritat, les accions a prendre en les properes 24 hores.
- Escriu el filtre d'emmascarament que evita que torni a passar, i explica per què no és suficient.
- Quin paper té el responsable de compliment normatiu en aquest incident i en quin moment cal involucrar-l'hi?
Exercici 3 — Dimensionar i decidir l'arquitectura
Rutas Norte s'expandeix: passa de 3 a 12 nodes, de 6 a 15 components, i el volum de logs es multiplica per 5 respecte a l'estimació de l'apartat 7. El pressupost d'infraestructura no es multiplica per 5.
- Recalcula el volum diari i l'emmagatzematge necessari per a 30 dies.
- Proposa tres mesures per reduir el volum sense perdre capacitat de diagnòstic, amb una estimació de l'estalvi de cadascuna.
- Compara EFK i Loki per a aquest escenari concret, amb números.
- Recomana una arquitectura final i justifica-la.
Solucions
Solució 1
1. Problemes de la línia.
De format:
- No és JSON: cada camp s'ha d'extreure amb expressions regulars, fràgils davant de qualsevol canvi.
- La data fa servir format nginx (
06/Aug/2026:03:14:22 +0000), no ISO 8601: requereix un parser específic. - La durada
4.821està en segons, no en mil·lisegons, i incompleix l'estàndard de Rutas Norte (duracio_ms). - No hi ha
traca_id: impossible correlacionar aquesta petició amb la d'api-reserves. - No hi ha
componentninivell: no es pot filtrar per severitat.
De protecció de dades (els greus):
- La URL conté un DNI:
?dni=12345678Z. Queda registrat al log d'nginx, al de l'Ingress i a qualsevol proxy intermedi. Aquest és el problema més greu. - La IP del client (
83.45.12.99) és una dada personal segons el RGPD. S'ha de tractar com a tal: anonimitzar-la o justificar-ne la conservació. - L'
User-Agentcontribueix a l'empremta digital del navegador i, combinat amb altres dades, pot ser identificatiu.
2. Configuració d'nginx en JSON.
# k8s/base/botiga-web/nginx.conf
http {
# Anonimitzar la IP: conservar només els tres primers octets.
# Suficient per a geolocalització aproximada i detecció d'abús,
# sense identificar una persona concreta.
map $remote_addr $ip_anonima {
~^(?<pre>\d+\.\d+\.\d+)\. "$pre.0";
default "0.0.0.0";
}
# Propagar el traca_id: fer servir el de la petició o generar-ne un de nou.
map $http_x_traca_id $traca_id {
"" $request_id; # nginx genera un id únic per petició
default $http_x_traca_id;
}
# Nivell segons el codi de resposta
map $status $nivell_log {
~^[45] "error";
~^3 "info";
default "info";
}
log_format rutasnorte_json escape=json
'{'
'"timestamp":"$time_iso8601",'
'"nivell":"$nivell_log",'
'"component":"botiga-web",'
'"traca_id":"$traca_id",'
'"missatge":"peticio http",'
'"metode":"$request_method",'
'"ruta":"$uri",' # SENSE la cadena de consulta
'"codi_http":$status,'
'"duracio_ms":$msec_duracio,'
'"bytes_enviats":$body_bytes_sent,'
'"ip_anonima":"$ip_anonima",'
'"protocol":"$server_protocol"'
'}';
# Durada en mil·lisegons com a enter
map $request_time $msec_duracio {
~^(?<s>\d+)\.(?<ms>\d{3})$ "${s}${ms}";
default "0";
}
server {
access_log /dev/stdout rutasnorte_json;
error_log /dev/stderr warn;
# Les sondes de 07-01 no generen log: pur soroll i volum
location = /nginx-salut {
access_log off;
return 200 "ok\n";
}
}
}Punts clau d'aquesta configuració:
$urien lloc de$request:$uriés la ruta sense la cadena de consulta, així que el?dni=...no arriba mai al log. És la solució al problema més greu.escape=jsonés obligatori: sense ell, unUser-Agentamb cometes trenca el JSON i Fluent Bit no el pot parsejar.access_log offa la sonda de salut, coherent amb el que vam fer a 07-01.
3. Parser si no es pot canviar nginx.
[PARSER]
Name nginx_combinat
Format regex
Regex ^(?<ip_client>[^ ]+) [^ ]* [^ ]* \[(?<temps>[^\]]+)\] "(?<metode>\S+) (?<ruta_completa>\S+) (?<protocol>[^"]+)" (?<codi_http>\d+) (?<bytes>\d+) "(?<referer>[^"]*)" "(?<agent>[^"]*)" (?<duracio_s>[\d.]+)$
Time_Key temps
Time_Format %d/%b/%Y:%H:%M:%S %z
Types codi_http:integer bytes:integer duracio_s:floatI un filtre Lua que normalitzi a l'estàndard i netegi la cadena de consulta:
function normalitzar_nginx(tag, timestamp, registre)
-- Separar la ruta de la cadena de consulta i DESCARTAR aquesta última
if registre["ruta_completa"] then
local ruta = string.match(registre["ruta_completa"], "^([^?]+)")
registre["ruta"] = ruta
registre["ruta_completa"] = nil -- eliminar: pot portar el DNI
end
-- Anonimitzar la IP: quart octet a zero
if registre["ip_client"] then
registre["ip_anonima"] = string.gsub(registre["ip_client"],
"(%d+%.%d+%.%d+)%.%d+", "%1.0")
registre["ip_client"] = nil
end
-- Durada a mil·lisegons
if registre["duracio_s"] then
registre["duracio_ms"] = math.floor(registre["duracio_s"] * 1000)
registre["duracio_s"] = nil
end
-- Camps de l'estàndard de Rutas Norte
registre["component"] = "botiga-web"
registre["nivell"] = (registre["codi_http"] >= 400) and "error" or "info"
registre["missatge"] = "peticio http"
registre["agent"] = nil -- descartar: contribueix a l'empremta digital
return 2, timestamp, registre
end4. Què fer amb el dni de la URL.
La resposta correcta té tres nivells, i només el primer resol el problema d'arrel:
Nivell 1 (la solució real): canviar l'aplicació. Un DNI no ha de viatjar mai a la cadena de consulta d'una URL. Les URL queden registrades al navegador, a l'historial, al Referer que s'envia a tercers, als logs d'nginx, de l'Ingress i de qualsevol proxy. La consulta ha de ser POST amb la dada al cos, o fer servir un identificador opac.
Nivell 2 (mitigació immediata): no registrar la cadena de consulta. Fer servir $uri en lloc de $request, com a la solució 2. S'implementa en minuts i talla la fuita cap als logs.
Nivell 3 (xarxa de seguretat): emmascarar al recol·lector. El filtre Lua de l'apartat 11 detecta el patró de DNI i el substitueix. És l'última defensa, i no és fiable per si sola: un DNI escrit com 12.345.678-Z se li escapa.
Aplicar els tres. I molt important: els logs que ja contenen el DNI continuen allà. Cal esborrar-los, i això ens porta a l'exercici següent.
Solució 2
1. Consulta KQL per localitzar els documents.
# Buscar el patró de correu en qualsevol camp de text
missatge: *@*.* or destinatari: * or email: * or correu: *Més precisa, aprofitant el marcador de l'emmascarament:
kubernetes.namespace_name: "rutas-norte-pro"
and (missatge: *"@"* or destinatari: *"@"*)
and not _emmascarat: trueI per quantificar i localitzar l'origen, una agregació a Elasticsearch:
POST rutasnorte-pro-2026.08.*/_search
{
"size": 0,
"query": {
"query_string": {
"query": "*@*.*",
"fields": ["missatge", "destinatari", "error_detall"]
}
},
"aggs": {
"per_component": {
"terms": { "field": "component", "size": 20 },
"aggs": {
"per_dia": {
"date_histogram": { "field": "@timestamp", "calendar_interval": "day" }
}
}
}
}
}Aquesta agregació et dóna exactament quin component, quants documents i des de quin dia: les tres dades que necessites per a l'informe.
2. Accions en 24 hores, per prioritat.
Hora 0-1 — Contenir i notificar.
- Notificar al responsable de compliment normatiu immediatament. No és una decisió tècnica que es pugui diferir: els terminis de notificació de bretxes són curts i el rellotge comença a córrer des que se'n té coneixement.
- Restringir l'accés als índexs afectats només a l'equip que gestiona l'incident, mitjançant rols d'Elasticsearch.
- Documentar l'abast: quins components, quins camps, quants documents, des de quina data, qui ha accedit a aquells índexs (els logs d'auditoria d'Elasticsearch).
Hora 1-4 — Tallar la fuita.
- Desplegar el filtre d'emmascarament a Fluent Bit (punt 3) perquè deixin d'entrar documents nous. És ràpid i no requereix tocar les aplicacions.
- Verificar que els documents nous ja arriben emmascarats, comprovant l'índex del dia.
Hora 4-12 — Corregir l'origen.
- Localitzar al codi les crides al logger que emeten el correu. A
worker-notificacionsés al log SMTP heretat; aapi-reserves, probablement en unlog.info(reserva)que bolca l'objecte sencer. - Corregir el codi: substituir l'adreça per un hash, com fa l'adaptador de l'apartat 9.
- Afegir
redacta la configuració del logger com a xarxa de seguretat de capa 2. - Desplegar a
prei després apro.
Hora 12-24 — Sanejar i prevenir.
- Eliminar o depurar els documents afectats, segons el que decideixi el responsable de compliment:
POST rutasnorte-pro-2026.08.*/_delete_by_query
{
"query": {
"bool": {
"must": [
{ "query_string": { "query": "*@*.*", "fields": ["missatge", "destinatari"] } },
{ "terms": { "component": ["worker-notificacions", "api-reserves"] } }
]
}
}
}Advertiment: _delete_by_query sobre 47 000 documents és una operació pesada. Si els índexs són diaris i estan molt contaminats, esborrar l'índex sencer és molt més ràpid i més segur, al cost de perdre també els logs nets d'aquell dia.
- Afegir una comprovació automàtica a la integració contínua que rebutgi una petició de canvi si detecta patrons de dades personals en crides al logger.
- Escriure un informe per al responsable de compliment amb cronologia, abast, causa arrel i mesures.
3. El filtre d'emmascarament i per què no basta.
function emmascarar_correus(tag, timestamp, registre)
for clau, valor in pairs(registre) do
if type(valor) == "string" then
registre[clau] = string.gsub(valor,
"[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTAT]")
end
end
-- Camps que mai han de persistir-se
registre["destinatari"] = nil
registre["email"] = nil
registre["correu"] = nil
return 2, timestamp, registre
endPer què no és suficient, en quatre raons:
- Les expressions regulars s'escapen.
marta.ruiz [arrova] ejemplo.example, un correu partit entre dos camps, o un amb caràcters poc habituals no coincideixen amb el patró. L'emmascarament dóna una falsa sensació de seguretat. - Només cobreix el que ja saps buscar. Un nom i cognoms no té patró detectable. Una adreça postal tampoc. El filtre protegeix contra correus, DNI i targetes; contra la resta, res.
- La dada existeix fins al filtre. Surt del procés, s'escriu al fitxer del node, viatja fins al recol·lector. Qualsevol amb accés al node (o al pod del recol·lector, que munta
hostPath) la veu sense emmascarar. - És una capa de mitigació, no de prevenció. L'única defensa real és que la dada no s'escrigui mai. Tota la resta són xarxes que atrapen el que s'escapa.
Per això el pas 7 (corregir el codi) és l'important, i el filtre només compra temps mentre es desplega.
4. El paper del responsable de compliment normatiu.
Quan involucrar-l'hi: a la primera hora, abans de prendre cap acció de sanejament. És un error freqüent "arreglar-ho primer i avisar després": esborrar els documents abans que ell documenti l'abast pot destruir l'evidència que necessita per avaluar la bretxa, i els terminis de notificació corren des que es té coneixement del fet, no des que s'acaba d'arreglar.
Les seves responsabilitats en aquest incident:
- Qualificar l'incident: determinar si constitueix una violació de seguretat de dades personals segons el RGPD.
- Decidir sobre la notificació: si escau notificar a l'autoritat de control (a Espanya, l'AEPD) en el termini legal de 72 hores, i si escau comunicar-ho als afectats.
- Avaluar el risc per als drets i llibertats de les persones afectades, tenint en compte el volum, la naturalesa de la dada i qui hi ha pogut accedir.
- Decidir el sanejament: què s'esborra, què es conserva com a evidència i durant quant de temps.
- Registrar l'incident al registre intern de violacions de seguretat, obligatori fins i tot quan no escau notificar.
- Aprovar les mesures correctores i verificar que s'han implantat.
I cap endavant, el seu paper és preventiu: revisar i aprovar quins camps es registren, els terminis de retenció i la política d'accés, tal com estableix l'advertiment de l'apartat 11. L'equip tècnic proporciona els mecanismes; la decisió sobre què és acceptable registrar no és tècnica.
Solució 3
1. Recàlcul del volum.
Situació nova:
12 nodes, 15 components
Volum × 5 respecte a l'estimació original
Original: 7,2 GB/dia indexats
Nou: 7,2 × 5 = 36 GB/dia indexats
Retenció de 30 dies amb la política ILM de l'apartat 7:
3 dies calents amb 1 rèplica: 36 × 3 × 2 = 216 GB
27 dies tebis sense rèplica: 36 × 27 = 972 GB
TOTAL ≈ 1 188 GB → amb 25 % de marge: ~1,5 TB
Recursos d'Elasticsearch necessaris:
Regla pràctica: ~1 node de dades per cada 300-500 GB indexats
→ 4-5 nodes de dades amb 16 GB de RAM cadascun (heap de 8 GB)
→ 64-80 GB de RAM i 1,5 TB de disc ràpid
Cost orientatiu mensual: 1 400-1 900 €/mesUn increment d'aproximadament tres vegades el cost original, a més d'un salt qualitatiu en complexitat operativa: amb 5 nodes de dades cal gestionar fragments, reequilibrat i actualitzacions contínues.
2. Tres mesures de reducció.
Mesura A — Mostreig de logs d'èxit. Estalvi estimat: 45 %.
El 90 % dels logs són peticions que van anar bé i que ningú mirarà mai. Conservar-ne el 10 % és estadísticament suficient per veure tendències, i el 100 % dels errors.
function mostrejar_exits(tag, timestamp, registre)
-- Conservar SEMPRE errors i avisos
if registre["nivell"] == "error" or registre["nivell"] == "fatal"
or registre["nivell"] == "warn" then
return 2, timestamp, registre
end
-- Conservar SEMPRE el que és lent, encara que hagi anat bé
if registre["duracio_ms"] and registre["duracio_ms"] > 1000 then
return 2, timestamp, registre
end
-- Conservar sempre el que toca diners
if registre["ruta"] and string.match(registre["ruta"], "^/api/reserves") then
return 2, timestamp, registre
end
-- De la resta, conservar 1 de cada 10
if math.random(10) == 1 then
registre["_mostrejat"] = 10 -- factor, per poder extrapolar
return 2, timestamp, registre
end
return -1, timestamp, registre -- -1 = descartar
endEl camp _mostrejat permet multiplicar per 10 els recomptes en fer agregacions i obtenir xifres correctes.
Mesura B — Retenció esglaonada per entorn i per nivell. Estalvi estimat: 30 %.
| Índex | Retenció actual | Retenció proposada | Justificació |
|---|---|---|---|
rutasnorte-pro-* errors |
30 dies | 30 dies | Sense canvi: és el que s'investiga |
rutasnorte-pro-* info |
30 dies | 7 dies | Rarament es mira més enrere |
rutasnorte-pre-* |
30 dies | 7 dies | Entorn de proves |
rutasnorte-dev-* |
30 dies | 3 dies | Es depura en el moment |
| Logs de sistema | 30 dies | 14 dies | Compromís raonable |
S'implementa amb dos fluxos de dades diferents, encaminats a l'agregador Fluentd pel camp nivell, cadascun amb la seva política ILM.
Mesura C — Descartar soroll a l'origen. Estalvi estimat: 20 %.
# Sondes de salut (07-01): amb 15 components i sondes cada 5 s,
# són centenars de milers de línies diàries que no aporten res.
[FILTER]
Name grep
Match kube.*
Exclude ruta ^/(salut|preparat|metrics|nginx-salut)$
# Nivell debug fora de dev
[FILTER]
Name grep
Match kube.var.log.containers.*_rutas-norte-(pro|pre)_*
Exclude nivell ^(debug|trace)$
# Logs d'arrencada repetitius de biblioteques
[FILTER]
Name grep
Match kube.*
Exclude missatge ^(Loaded plugin|Initializing module|Warming cache)Efecte combinat (les mesures no són purament additives perquè se solapen):
36 GB/dia
− 45 % per mostreig → 19,8 GB/dia
− 20 % per descart de soroll → 15,8 GB/dia
Amb retenció esglaonada, l'emmagatzematge total:
~15,8 GB/dia × mitjana ponderada de 12 dies ≈ 190 GB
Davant dels 1 500 GB originals: reducció del 87 %I l'important: sense perdre capacitat de diagnòstic, perquè es conserva el 100 % dels errors, el 100 % del que és lent i el 100 % del que toca reserves.
3. Comparació EFK davant de Loki amb números.
Sobre el volum ja optimitzat de 15,8 GB/dia:
| Concepte | EFK | Loki |
|---|---|---|
| Nodes d'emmagatzematge | 3 × 16 GB RAM | 3 × 4 GB RAM (ingester/querier) |
| RAM total | 48 GB | 12 GB |
| Emmagatzematge | 250 GB SSD ràpid | 250 GB en objectes (S3) |
| Cost de l'emmagatzematge | ~50 €/mes (SSD) | ~6 €/mes (S3) |
| Cost de còmput | ~700 €/mes | ~180 €/mes |
| Cost total mensual | ~750 € | ~190 € |
| Cerca de text lliure en 30 dies | 1-3 segons | 10-60 segons |
| Cerca filtrant per component i 1 hora | < 1 segon | < 1 segon |
| Agregacions complexes | Molt potents | Limitades |
| Complexitat operativa | Alta | Baixa |
| Integració amb Grafana (07-04) | Bona | Nativa |
La dada decisiva és a la comparació de les dues files de cerca: Loki és lent en cerques de text lliure sobre tot l'històric, però igual de ràpid en el cas que representa el 95 % de l'ús real, que és "logs d'aquest component, en aquesta finestra de temps, filtrant per nivell". I això és exactament com s'investiga un incident: mai busques una cadena en 30 dies de tots els components; sempre acotes per component i per finestra, perquè l'alerta ja te'ls va donar tots dos.
4. Arquitectura recomanada.
Recomanació: migrar a Loki, amb les tres mesures de reducció aplicades.
flowchart TB
subgraph Nodes["12 nodes"]
FB["Fluent Bit (DaemonSet)<br/>+ filtres de mostreig,<br/>emmascarament i descart"]
end
FB --> LOKI["Loki<br/>3 rèpliques, 4 GB RAM"]
LOKI --> S3[("Emmagatzematge d'objectes<br/>250 GB, retenció 30 d")]
LOKI --> GRAF["Grafana<br/>(ja desplegada a 07-04)"]
PROM["Prometheus<br/>(07-03)"] --> GRAF
GRAF --> USR["Persona de guàrdia:<br/>mètriques i logs<br/>a la mateixa pantalla"]
Justificació, en cinc punts:
- Cost: 190 € davant de 750 € al mes. La diferència (6 700 € l'any) és difícil de justificar quan el cas d'ús predominant rendeix igual en tots dos.
- Complexitat operativa. Elasticsearch amb 5 nodes de dades requereix algú que sàpiga gestionar fragments, reequilibrats i actualitzacions. Loki, molt menys. Amb un equip petit, aquell temps val més que la diferència de cost.
- Integració amb el que ja tenim. Grafana ja està desplegada des de 07-04. Loki apareix com una font de dades més, i el mateix quadre de comandament pot tenir un panell de mètriques a sobre i un de logs a sota, amb la mateixa finestra temporal. Aquell salt de context estalviat en un incident val molt.
- El model d'etiquetes és el que ja coneixem. LogQL és tan semblant a PromQL que l'equip l'aprèn en una tarda, mentre que KQL i les agregacions d'Elasticsearch són un cos de coneixement a part.
- Es conserva l'arquitectura de recol·lecció. Fluent Bit continua sent l'agent per node, amb la mateixa configuració de parseig, multilínia i emmascarament. La migració només canvia la destinació de la sortida, cosa que la fa de baix risc.
Quan NO seguir aquesta recomanació:
- Si l'equip ja opera Elasticsearch per a altres coses (cercador del web, anàlisi), el cost marginal d'afegir-hi logs és molt menor.
- Si hi ha requisits d'auditoria que exigeixen cerca de text lliure en tot l'històric amb temps de resposta garantits.
- Si es necessiten agregacions complexes sobre camps de log de manera habitual.
Pla de migració de baix risc:
- Desplegar Loki en paral·lel, sense tocar EFK.
- Configurar Fluent Bit amb dues sortides simultànies durant dues setmanes.
- Reconstruir a Grafana els quadres de comandament d'errors que eren a Kibana.
- Validar amb un incident real que Loki respon a les preguntes necessàries.
- Retirar EFK, conservant una còpia dels índexs de producció fins a esgotar la retenció acordada amb el responsable de compliment normatiu.
Aquest últim punt no és un detall: no es pot esborrar EFK abans d'hora si la seva retenció està compromesa amb compliment.
Conclusió
Rutas Norte ja no perd la seva història. En aquesta lliçó hem:
- Entès per què
kubectl logsno basta: es perd en recrear el pod, no busca entre components, no correlaciona i desapareix amb el node. I hem vist un límit addicional poc conegut: amb la rotació per defecte del kubelet, un component verbós conserva menys de sis hores de logs al node. - Vist com funciona el registre per sota: l'aplicació escriu a
stdout, el runtime ho desa a/var/log/containers/amb un nom parlant del qual s'extreuen pod, namespace i contenidor, i el kubelet el rota. - Dominat
kubectl logscom a eina de primera línia, amb--previouscom el flag decisiu davant d'unCrashLoopBackOff. - Muntat l'arquitectura de recol·lecció per node sobre el DaemonSet que vam desplegar a 06-02: Fluent Bit com a agent lleuger a cada node i Fluentd com a agregador central, amb el filtre
kubernetesque enriqueix cada línia i el reassemblatge multilínia que evita que una excepció es converteixi en vint documents inconnexos. - Configurat Elasticsearch amb índexs per dia, plantilles que distingeixen
keyworddetext, i una política ILM amb fases calenta, tèbia, freda i esborrat; i hem dimensionat la pila amb números realistes: uns 24 GB de RAM i 300 GB de disc només per poder llegir logs. - Fet servir Kibana amb KQL per passar d'una alerta a la causa arrel en cinc minuts, seguint un flux de treball concret: acotar, veure la forma, identificar el component, trobar el missatge dominant, llegir la traça i correlacionar cap enrere.
- Adoptat els logs estructurats en JSON com la decisió de major rendibilitat, amb l'estàndard de camps de Rutas Norte i el
traca_idque permet seguir una petició pels sis components; i hem tancat el cercle de l'adaptador de logs deworker-notificacionsque vam introduir a 06-04. - I, sobretot, hem establert què no s'ha de registrar mai: credencials, dades de pagament i dades personals dels clients. Amb quatre capes de defensa —el codi, la llibreria, el recol·lector i el control d'accés— sabent que només la primera és realment fiable, i amb l'advertiment exprés que la configuració de registre ha de ser revisada i aprovada pel responsable de compliment normatiu abans d'arribar a producció.
- Comparat el cost real d'EFK amb les alternatives més lleugeres, amb la conclusió honesta que per a una plataforma de la mida de Rutas Norte, Loki seria avui l'elecció més sensata.
Ja tenim les tres senyals: les sondes diuen si un component està sa, les mètriques diuen quant i com funciona, i els logs diuen exactament què va passar. El que encara no tenim és un mètode per fer-les servir juntes.
Perquè quan a les 03:14 la botiga web retorna 502 i sona el telèfon, saber fer servir Grafana i Kibana no basta: cal un procediment que vagi del símptoma a la causa sense donar voltes, saber que els esdeveniments de Kubernetes caduquen en una hora i cal capturar-los abans, i conèixer una taula mental de símptoma → causa probable → ordre que ho confirma. A 07-06, l'última lliçó del mòdul, construirem aquesta metodologia i l'aplicarem, pas a pas, a un incident real de Rutas Norte.
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
