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

  1. Per què kubectl logs no basta
  2. Com funciona el registre a Kubernetes per sota
  3. kubectl logs a fons: l'eina de primera línia i els seus límits
  4. L'arquitectura del patró de recol·lecció per node
  5. Fluentd davant de Fluent Bit
  6. La configuració real del recol·lector
  7. Elasticsearch: índexs, plantilles i cicle de vida
  8. Kibana: patró d'índex, KQL i quadre de comandament d'errors
  9. Logs estructurats en JSON: la decisió que més rendibilitat dóna
  10. Correlació entre logs i mètriques
  11. Què NO s'ha de registrar mai
  12. Alternatives més lleugeres i el cost real d'una pila de registre
  13. Errors comuns i consells
  14. Exercicis

  1. Per què kubectl logs no basta

kubectl 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.

  1. 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

  1. El procés escriu una línia a stdout.
  2. El runtime de contenidors (containerd o CRI-O) captura aquella sortida.
  3. El runtime l'escriu en un fitxer del node, en format CRI.
  4. El kubelet gestiona aquell fitxer: el rota i crea enllaços simbòlics amb nom parlant.
  5. kubectl logs demana 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/&lt;ns&gt;_&lt;pod&gt;_&lt;uid&gt;/&lt;contenidor&gt;/0.log"]
    F -.enllaç simbòlic.-> L["/var/log/containers/&lt;pod&gt;_&lt;ns&gt;_&lt;contenidor&gt;-&lt;id&gt;.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:

minikube ssh -p rutas-norte
sudo ls -la /var/log/containers/ | head -8
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:

<nom-del-pod>_<namespace>_<nom-del-contenidor>-<id-del-contenidor>.log

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-0dos 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

sudo tail -3 /var/log/containers/api-reserves-*_rutas-norte-pro_api-*.log
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 conservar

Amb 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.

  1. kubectl logs a fons: l'eina de primera línia i els seus límits

Encara 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=10

El 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 -rn

Els 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.

  1. 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:

  1. Llegir els fitxers de /var/log/containers/, seguint les escriptures noves i recordant per on anava després d'un reinici.
  2. Parsejar el format CRI i, si el contingut és JSON, expandir-lo en camps.
  3. 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.
  4. 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.

  1. 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

  1. 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-config

Nota de seguretat, que reprendrem al mòdul 8. Aquest DaemonSet munta hostPath sobre el sistema de fitxers del node. Encara que sigui en només lectura, qualsevol que pugui executar un exec en 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:5

Per 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: 30s

I 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"

  1. 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-se

Avantatges 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 GB

I 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: 100Gi

Total 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.

  1. 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:

Nom:                 Logs Rutas Norte Producció
Patró d'índex:       rutasnorte-pro-*
Camp de temps:       @timestamp

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 >= 500

Comparació 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:

kubernetes.namespace_name: "rutas-norte-pro" and nivell: ("error" or "fatal")

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ó:

api-reserves          4821  (94.2%)
worker-notificacions   287  (5.6%)
botiga-web              11  (0.2%)

Pas 4 — Trobar el missatge dominant. Clic a missatge.keyword:

connection pool exhausted                          4102
timeout acquiring connection from pool              619
Error: read ECONNRESET                              100

Pas 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:

kubernetes.namespace_name: "rutas-norte-pro"

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.

  1. 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 exhausted

Problemes 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 consultar duracio_ms > 1000 ni 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í:

nivell: "error" and duracio_ms > 3000 and error_tipus: "PoolExhaustedError"

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 Moment de l'esdeveniment
nivell debug|info|warn|error|fatal Severitat, en minúscules
component cadena Nom del component, igual que l'etiqueta app
traca_id UUID en peticions Identificador que segueix la petició entre components
missatge cadena 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:

traca_id: "8f3a2c91-4b7d-4e2a-9c15-7f8d3e1a6b04"

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=30012ms

Dos 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.

  1. 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.

  1. 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-Santander

acaba 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
end

Aquest camp _emmascarat és més valuós del que sembla. Amb una consulta a Kibana:

_emmascarat: true

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:

  1. Documentar per escrit quins camps es registren de cada component i presentar-ho a la persona responsable de compliment.
  2. Acordar amb ella el termini de retenció de cada índex, i aplicar-lo a la política ILM.
  3. Definir qui té accés als índexs de producció, i revisar-ho periòdicament.
  4. Auditar amb la consulta _emmascarat: true quins components continuen emetent dades sensibles i corregir-los al codi.
  5. 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.

  1. 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 info en producció, debug només a rutas-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 dev i pre (7 dies) que per a pro (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
  1. Enumera tots els problemes d'aquesta línia, inclosos els de protecció de dades.
  2. Escriu la configuració d'nginx que emeti el mateix esdeveniment en JSON, d'acord amb l'estàndard de camps de Rutas Norte.
  3. Escriu el parser de Fluent Bit necessari si no poguessis canviar la configuració d'nginx.
  4. Què faries amb el paràmetre dni de 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.

  1. Escriu la consulta KQL que localitza els documents afectats.
  2. Enumera, per ordre de prioritat, les accions a prendre en les properes 24 hores.
  3. Escriu el filtre d'emmascarament que evita que torni a passar, i explica per què no és suficient.
  4. 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.

  1. Recalcula el volum diari i l'emmagatzematge necessari per a 30 dies.
  2. Proposa tres mesures per reduir el volum sense perdre capacitat de diagnòstic, amb una estimació de l'estalvi de cadascuna.
  3. Compara EFK i Loki per a aquest escenari concret, amb números.
  4. 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.821 està 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 component ni nivell: 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-Agent contribueix 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ó:

  • $uri en 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, un User-Agent amb cometes trenca el JSON i Fluent Bit no el pot parsejar.
  • access_log off a 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:float

I 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
end

4. 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: true

I 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.

  1. 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.
  2. Restringir l'accés als índexs afectats només a l'equip que gestiona l'incident, mitjançant rols d'Elasticsearch.
  3. 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.

  1. 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.
  2. Verificar que els documents nous ja arriben emmascarats, comprovant l'índex del dia.

Hora 4-12 — Corregir l'origen.

  1. Localitzar al codi les crides al logger que emeten el correu. A worker-notificacions és al log SMTP heretat; a api-reserves, probablement en un log.info(reserva) que bolca l'objecte sencer.
  2. Corregir el codi: substituir l'adreça per un hash, com fa l'adaptador de l'apartat 9.
  3. Afegir redact a la configuració del logger com a xarxa de seguretat de capa 2.
  4. Desplegar a pre i després a pro.

Hora 12-24 — Sanejar i prevenir.

  1. 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.

  1. 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.
  2. 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
end

Per què no és suficient, en quatre raons:

  1. 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.
  2. 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.
  3. 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.
  4. É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 €/mes

Un 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
end

El 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. Desplegar Loki en paral·lel, sense tocar EFK.
  2. Configurar Fluent Bit amb dues sortides simultànies durant dues setmanes.
  3. Reconstruir a Grafana els quadres de comandament d'errors que eren a Kibana.
  4. Validar amb un incident real que Loki respon a les preguntes necessàries.
  5. 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 logs no 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 logs com a eina de primera línia, amb --previous com el flag decisiu davant d'un CrashLoopBackOff.
  • 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 kubernetes que 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 keyword de text, 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_id que permet seguir una petició pels sis components; i hem tancat el cercle de l'adaptador de logs de worker-notificacions que 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

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats