Al cerrar la lección anterior quedó planteada una pregunta que las métricas no pueden responder. Cuando ApiReservasTasaErrorAlta dispara a las 03:14, sabemos que el 7 % de las peticiones falla, en qué ruta y con qué latencia. Lo que no sabemos es por qué: la excepción concreta, el mensaje que devolvió PostgreSQL, la línea de código que reventó. Eso no vive en una métrica. Vive en los logs.

Y hoy, en Rutas Norte, los logs son un desastre organizado: están repartidos por los nodos, se pierden cuando un pod se recrea, no hay forma de buscar entre componentes y desaparecen del todo si el nodo muere. En esta lección montamos la pila completa que anunciamos en 06-02 al desplegar el recolector como DaemonSet: Elasticsearch, Fluentd y Kibana. Veremos cómo funciona el registro en Kubernetes por debajo, cómo se recolectan y enriquecen los logs, por qué el JSON estructurado es la decisión que más mejora todo el sistema, y —crítico para una plataforma que guarda el DNI y el teléfono de sus clientes— qué no debe acabar nunca escrito en un log.

Contenido

  1. Por qué kubectl logs no basta
  2. Cómo funciona el registro en Kubernetes por debajo
  3. kubectl logs a fondo: la herramienta de primera línea y sus límites
  4. La arquitectura del patrón de recolección por nodo
  5. Fluentd frente a Fluent Bit
  6. La configuración real del recolector
  7. Elasticsearch: índices, plantillas y ciclo de vida
  8. Kibana: patrón de índice, KQL y cuadro de mando de errores
  9. Logs estructurados en JSON: la decisión que más rentabilidad da
  10. Correlación entre logs y métricas
  11. Qué NO se debe registrar nunca
  12. Alternativas más ligeras y el coste real de una pila de registro
  13. Errores comunes y consejos
  14. Ejercicios

  1. Por qué kubectl logs no basta

kubectl logs es la primera herramienta que aprendimos en 01-05 y sigue siendo utilísima. Pero tiene cuatro límites estructurales que la hacen insuficiente en cuanto la plataforma crece.

Se pierde al recrear el pod. Los logs de un contenedor viven en el sistema de ficheros del nodo, asociados al pod. Cuando el pod se borra —un despliegue, un desalojo, un escalado— los ficheros se borran con él. Si api-reservas entró en CrashLoopBackOff a las 03:14 y a las 08:00 alguien ya lo había reiniciado a mano, la evidencia ha desaparecido.

No busca entre componentes. Un cliente reporta que su compra falló. La petición pasó por tienda-web, luego por api-reservas, que consultó redis-cache y postgres-reservas, llamó a la pasarela externa y encoló un mensaje para worker-notificaciones. Eso son seis kubectl logs distintos, en pods distintos, cada uno con su propio formato y sin ninguna forma de saber qué línea de uno corresponde a qué línea del otro.

No correlaciona. Aunque abras las seis terminales, tendrás que cuadrar marcas de tiempo a ojo. Con seis réplicas de api-reservas ni siquiera sabes en cuál cayó la petición.

Desaparece con el nodo. Si rutas-norte-worker-2 muere, todos sus logs mueren con él. Y precisamente cuando un nodo muere es cuando más falta hacen.

A esos cuatro se suma uno de gobernanza: no hay retención definida ni control de acceso granular. Cualquiera con permiso pods/log ve todo lo que escriben las aplicaciones, incluidos los datos que no deberían estar ahí. Volveremos a esto en el apartado 11.

  1. Cómo funciona el registro en Kubernetes por debajo

Antes de recolectar nada hay que entender dónde están físicamente los logs. Sin esto, la configuración del recolector es magia.

El contrato: stdout y stderr

Kubernetes no define ningún API de logs para las aplicaciones. El contrato es el de Docker y el de los doce factores:

La aplicación escribe sus logs a la salida estándar (stdout) y a la de error (stderr). No gestiona ficheros, ni rotación, ni destinos.

Una aplicación que escribe en /var/log/miapp.log dentro del contenedor está haciéndolo mal: ese fichero es invisible para Kubernetes, crece sin control dentro del contenedor y desaparece al reiniciarse.

El recorrido del dato

  1. El proceso escribe una línea en stdout.
  2. El runtime de contenedores (containerd o CRI-O) captura esa salida.
  3. El runtime la escribe en un fichero del nodo, en formato CRI.
  4. El kubelet gestiona ese fichero: lo rota y crea enlaces simbólicos con nombre parlante.
  5. kubectl logs pide al kubelet que lea ese fichero y lo devuelve.
flowchart TD
    APP["Proceso de api-reservas<br/>console.log(...)"] -->|stdout| RT[containerd]
    RT -->|escribe| F["/var/log/pods/&lt;ns&gt;_&lt;pod&gt;_&lt;uid&gt;/&lt;contenedor&gt;/0.log"]
    F -.enlace simbólico.-> L["/var/log/containers/&lt;pod&gt;_&lt;ns&gt;_&lt;contenedor&gt;-&lt;id&gt;.log"]
    KL[kubelet] -->|rota| F
    KUBECTL["kubectl logs"] --> KL
    KL --> F
    L --> REC["Recolector<br/>DaemonSet de 06-02"]

Los ficheros del nodo

Entremos en un nodo de nuestro minikube a mirarlos:

minikube ssh -p rutas-norte
sudo ls -la /var/log/containers/ | head -8
api-reservas-7d9f8c4b5-x2klm_rutas-norte-pro_api-3f8a2c...9b1.log -> /var/log/pods/rutas-norte-pro_api-reservas-7d9f8c4b5-x2klm_a4f2.../api/0.log
postgres-reservas-0_rutas-norte-pro_postgres-7c2d...4e8.log -> /var/log/pods/...
postgres-reservas-0_rutas-norte-pro_exportador-pg-1b9f...2a7.log -> /var/log/pods/...
tienda-web-6c8b9d7f4-hj3ks_rutas-norte-pro_nginx-5d1e...8c3.log -> /var/log/pods/...
worker-notificaciones-5f7c8b9d4-tz2mv_rutas-norte-pro_worker-9a4b...6f2.log -> ...
worker-notificaciones-5f7c8b9d4-tz2mv_rutas-norte-pro_adaptador-logs-2c8d...1e5.log -> ...

El nombre del fichero es la clave de todo. Su estructura es:

<nombre-del-pod>_<namespace>_<nombre-del-contenedor>-<id-del-contenedor>.log

De ahí el recolector extrae, sin consultar a nadie, el pod, el namespace y el contenedor de cada línea. Es lo que hace posible el enriquecimiento del apartado 6.

Fíjate también en que postgres-reservas-0 tiene dos ficheros: uno por el contenedor postgres y otro por el sidecar exportador-pg que añadimos en 06-04. Cada contenedor tiene su propio flujo de logs.

El formato CRI

sudo tail -3 /var/log/containers/api-reservas-*_rutas-norte-pro_api-*.log
2026-08-06T03:14:22.183947621Z stdout F {"nivel":"info","mensaje":"peticion atendida","ruta":"/api/rutas"}
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ínea tiene cuatro campos separados por espacios:

Campo Ejemplo Significado
Marca de tiempo 2026-08-06T03:14:22.183947621Z RFC3339 con nanosegundos, siempre en UTC
Flujo stdout / stderr De dónde vino
Etiqueta F / P F = línea completa (full), P = parcial (partial)
Contenido El resto Lo que escribió la aplicación

La etiqueta P aparece cuando una línea supera los 16 KB: el runtime la parte. Un recolector mal configurado tratará cada trozo como una línea distinta. Fluentd y Fluent Bit saben reensamblarlas, pero hay que activarlo.

La rotación del kubelet

Sin rotación, un contenedor charlatán llenaría el disco del nodo y provocaría desalojos de pods. El kubelet la gestiona con dos parámetros de su configuración:

# /var/lib/kubelet/config.yaml
containerLogMaxSize: 10Mi     # tamaño máximo por fichero antes de rotar
containerLogMaxFiles: 5       # cuántos ficheros rotados conservar

Con los valores por defecto, cada contenedor conserva como máximo 50 MB de logs en el nodo. api-reservas bajo carga genera unos 200 MB al día por réplica: eso significa que conserva menos de seis horas de historia.

Consecuencia directa y muy concreta: si el incidente ocurrió a las 03:14 y lo miras a las 11:00, los logs ya se han rotado y ya no existen, aunque el pod no se haya reiniciado. Es una razón adicional, junto a las cuatro del apartado 1, para centralizar.

  1. kubectl logs a fondo: la herramienta de primera línea y sus límites

Aunque montemos EFK, kubectl logs sigue siendo lo primero que se ejecuta ante un problema. Merece dominarse.

# Lo básico
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm

# Un contenedor concreto de un pod multicontenedor (imprescindible desde 06-04)
kubectl -n rutas-norte-pro logs postgres-reservas-0 -c exportador-pg

# EL FLAG MÁS IMPORTANTE: logs de la ejecución ANTERIOR del contenedor.
# Cuando un pod está en CrashLoopBackOff, el contenedor actual acaba de
# arrancar y no tiene nada útil. La causa está en la ejecución que murió.
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --previous

# Seguir en tiempo real
kubectl -n rutas-norte-pro logs -f api-reservas-7d9f8c4b5-x2klm

# Solo lo reciente: las últimas 100 líneas, o los últimos 15 minutos
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --tail=100
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --since=15m
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --since-time="2026-08-06T03:10:00Z"

# Con marcas de tiempo añadidas por kubectl (útil si la app no las pone)
kubectl -n rutas-norte-pro logs api-reservas-7d9f8c4b5-x2klm --timestamps

# VARIOS PODS A LA VEZ mediante selector de etiquetas: las 6 réplicas juntas
kubectl -n rutas-norte-pro logs -l app=api-reservas --tail=50 --prefix

# Todos los contenedores de un pod, incluidos los sidecars
kubectl -n rutas-norte-pro logs postgres-reservas-0 --all-containers=true

# Todos los componentes de la plataforma en el entorno
kubectl -n rutas-norte-pro logs -l app.kubernetes.io/part-of=rutas-norte \
  --tail=20 --prefix --max-log-requests=10

El flag --prefix antepone el nombre del pod a cada línea, imprescindible al usar -l:

[pod/api-reservas-7d9f8c4b5-x2klm/api] {"nivel":"error","mensaje":"pool agotado"}
[pod/api-reservas-7d9f8c4b5-mn8pq/api] {"nivel":"info","mensaje":"peticion atendida"}

Combinaciones útiles en el día a día:

# Solo los errores de las últimas dos horas, en todas las réplicas
kubectl -n rutas-norte-pro logs -l app=api-reservas --since=2h --prefix | grep -i error

# Contar errores por tipo
kubectl -n rutas-norte-pro logs -l app=api-reservas --since=1h | \
  jq -r 'select(.nivel=="error") | .mensaje' | sort | uniq -c | sort -rn

Sus límites, ahora explícitos

Límite Consecuencia práctica
Solo el pod actual y el anterior Un pod recreado tres veces ha perdido las dos primeras ejecuciones
Solo lo que queda tras la rotación Menos de 6 h de historia en componentes verbosos
-l tiene un máximo de peticiones concurrentes Con 40 pods, --max-log-requests se queda corto
Sin búsqueda estructurada grep sobre texto plano, sin filtrar por campo
Sin agregación Imposible contar "cuántos errores por hora en la última semana"
Sin retención garantizada No sirve para auditoría ni cumplimiento normativo

Regla de uso: kubectl logs para lo que está pasando ahora; la pila centralizada para lo que pasó.

  1. La arquitectura del patrón de recolección por nodo

Hay tres formas de recolectar logs en Kubernetes. Solo una es la buena para el caso general.

Patrón Cómo funciona Cuándo usarlo
Agente por nodo Un DaemonSet lee los ficheros de /var/log/containers El estándar. Un agente por nodo, sirve para todas las aplicaciones
Sidecar de streaming Un contenedor extra por pod lee logs y los reemite Solo si la aplicación escribe a fichero y no puede cambiarse
Empuje desde la aplicación La aplicación envía directamente al almacén Casi nunca: acopla la aplicación al backend

Elegimos el primero, y ya lo tenemos desplegado: en 06-02, al estudiar DaemonSets, desplegamos un recolector de logs con un pod por nodo, tolerations para el plano de control y un hostPath montado sobre /var/log/containers, anunciando que la pila completa se montaría aquí. Ha llegado el momento.

flowchart TB
    subgraph N1["Nodo rutas-norte-worker-1"]
        P1["Pods: api-reservas,<br/>tienda-web"] -->|stdout| F1["/var/log/containers/*.log"]
        F1 --> FB1["Fluent Bit<br/>(DaemonSet, 06-02)"]
    end
    subgraph N2["Nodo rutas-norte-worker-2"]
        P2["Pods: postgres-reservas,<br/>worker-notificaciones"] -->|stdout| F2["/var/log/containers/*.log"]
        F2 --> FB2["Fluent Bit"]
    end
    subgraph N3["Nodo rutas-norte-worker-3"]
        P3["Pods: redis-cache,<br/>informes-ocupacion"] -->|stdout| F3["/var/log/containers/*.log"]
        F3 --> FB3["Fluent Bit"]
    end
    FB1 --> AGG["Fluentd agregador<br/>(Deployment)<br/>parseo pesado,<br/>enmascarado, buffer"]
    FB2 --> AGG
    FB3 --> AGG
    API[(API Server)] -.metadatos.-> FB1
    API -.metadatos.-> FB2
    API -.metadatos.-> FB3
    AGG --> ES[("Elasticsearch<br/>StatefulSet")]
    ES --> KB["Kibana<br/>Deployment"]
    KB --> U["Persona de guardia"]

Las cuatro responsabilidades del recolector:

  1. Leer los ficheros de /var/log/containers/, siguiendo las escrituras nuevas y recordando por dónde iba tras un reinicio.
  2. Parsear el formato CRI y, si el contenido es JSON, expandirlo en campos.
  3. Enriquecer con los metadatos de Kubernetes: namespace, pod, contenedor, nodo, etiquetas y anotaciones del pod. Estos no están en el fichero: se obtienen consultando la API a partir del nombre del fichero.
  4. Enviar al almacén, con buffer y reintentos para no perder nada si el destino se cae.

El enriquecimiento es lo que convierte una línea de texto en un documento consultable:

{
  "@timestamp": "2026-08-06T03:14:22.891Z",
  "mensaje": "Error: connection pool exhausted",
  "kubernetes": {
    "namespace_name": "rutas-norte-pro",
    "pod_name": "api-reservas-7d9f8c4b5-x2klm",
    "container_name": "api",
    "host": "rutas-norte-worker-2",
    "labels": {
      "app": "api-reservas",
      "entorno": "pro",
      "app_kubernetes_io/part-of": "rutas-norte"
    }
  },
  "stream": "stderr"
}

Ahora sí se puede buscar "todos los errores de api-reservas en pro entre las 03:00 y las 04:00", que es lo que necesitábamos.

Arquitectura de dos niveles

Hemos puesto un agregador entre los agentes y Elasticsearch. No es obligatorio, pero en producción se justifica:

  • Los agentes de nodo (Fluent Bit) se quedan ligerísimos: solo leer y reenviar.
  • El parseo pesado, el enmascarado de datos sensibles y el enrutado se hacen en un solo sitio, más fácil de auditar y de cambiar.
  • Elasticsearch recibe conexiones de 3 agregadores en vez de 30 agentes, lo que reduce mucho la presión.
  • El agregador hace de amortiguador: si Elasticsearch se cae media hora, el buffer del agregador retiene los datos.

  1. Fluentd frente a Fluent Bit

Los dos son proyectos de la CNCF y de la misma familia. La confusión sobre cuál usar es constante.

Aspecto Fluentd Fluent Bit
Lenguaje Ruby (con partes en C) C puro
Memoria en reposo ~40-100 MB ~2-5 MB
CPU Notablemente mayor Muy baja
Plugins disponibles Más de 1000 ~100 (los importantes están)
Extensibilidad Gemas de Ruby, muy flexible Plugins en C o Go, o filtros en Lua
Configuración Directivas XML-like Clásica (INI) o YAML
Papel típico Agregador Agente por nodo
Rendimiento Miles de eventos/s Decenas de miles de eventos/s

La regla práctica que sigue la industria:

Fluent Bit como agente en cada nodo (DaemonSet): consume casi nada, y multiplicado por 30 nodos esa diferencia importa mucho.

Fluentd como agregador central (Deployment): donde hace falta la flexibilidad de los mil plugins, el enrutado complejo y las transformaciones caras.

Para Rutas Norte con tres nodos, honestamente, Fluent Bit solo bastaría. Montamos los dos niveles porque el enmascarado de datos personales del apartado 11 se beneficia mucho de centralizarse, y porque es la arquitectura que encontrarás en cualquier clúster serio.

Comparación de coste en nuestro clúster:

Configuración Memoria total Nota
Fluentd como DaemonSet (3 nodos) ~300 MB Innecesariamente caro
Fluent Bit como DaemonSet (3 nodos) ~30 MB 10 veces menos
Fluent Bit + agregador Fluentd ~30 MB + 512 MB El extra se concentra y se controla

  1. La configuración real del recolector

Fluent Bit como DaemonSet

Recuperamos y completamos el DaemonSet de 06-02:

# k8s/base/registro/fluent-bit-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: registro
  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        # necesita leer pods de la API
      # Tolerations para desplegarse TAMBIÉN en el plano de control, como
      # vimos en 06-02: sus logs también nos interesan.
      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:
            # Los logs del nodo, en SOLO LECTURA
            - name: varlog
              mountPath: /var/log
              readOnly: true
            # Los ficheros reales a los que apuntan los enlaces simbólicos
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
            - name: config
              mountPath: /fluent-bit/etc/
            # Posiciones de lectura: sobrevive al reinicio del pod para no
            # reenviar todo desde el principio.
            - name: posiciones
              mountPath: /var/fluent-bit/state
      volumes:
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers
        - name: posiciones
          hostPath:
            path: /var/fluent-bit/state
            type: DirectoryOrCreate
        - name: config
          configMap:
            name: fluent-bit-config

Nota de seguridad, que retomaremos en el módulo 8. Este DaemonSet monta hostPath sobre el sistema de ficheros del nodo. Aunque sea en solo lectura, cualquiera que pueda ejecutar un exec en este pod puede leer los logs de todos los contenedores del nodo, incluidos los de otros namespaces. El recolector es un objetivo de alto valor: debe tener su propio namespace, RBAC mínimo y acceso muy restringido.

La configuración de Fluent Bit

# k8s/base/registro/fluent-bit-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: registro
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        # expone métricas para Prometheus (07-03)

    # =====================================================================
    # ENTRADA: leer los ficheros de los contenedores
    # =====================================================================
    [INPUT]
        Name              tail
        Tag               kube.*
        Path              /var/log/containers/*.log
        # No leer los logs del propio recolector: bucle infinito garantizado
        Exclude_Path      /var/log/containers/fluent-bit*.log,/var/log/containers/*_registro_*.log
        Parser            cri
        # Fichero donde recuerda por dónde iba cada log
        DB                /var/fluent-bit/state/posiciones.db
        Mem_Buf_Limit     32MB
        Skip_Long_Lines   On
        Refresh_Interval  10
        # REENSAMBLAR líneas partidas por el runtime (la etiqueta P del
        # formato CRI). Sin esto, una línea de 20 KB llega troceada.
        multiline.parser  cri

    # =====================================================================
    # FILTRO 1: enriquecer con metadatos de Kubernetes
    # =====================================================================
    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_Tag_Prefix     kube.var.log.containers.
        # Consulta la API para obtener etiquetas y anotaciones del pod
        Merge_Log           On
        Merge_Log_Key       log_procesado
        Keep_Log            Off
        K8S-Logging.Parser  On
        K8S-Logging.Exclude On
        Labels              On
        Annotations         Off
        Buffer_Size         32k

    # Merge_Log On es la línea más rentable de toda la configuración:
    # si el contenido del log es JSON válido, lo EXPANDE en campos en vez
    # de dejarlo como una cadena. Es lo que hace útil el apartado 9.

    # =====================================================================
    # FILTRO 2: reensamblar trazas de excepción multilínea
    # =====================================================================
    [FILTER]
        Name                  multiline
        Match                 kube.*
        multiline.key_content log
        multiline.parser      java_excepcion, node_excepcion

    # =====================================================================
    # FILTRO 3: enmascarar datos sensibles (ver apartado 11)
    # =====================================================================
    [FILTER]
        Name    lua
        Match   kube.*
        script  /fluent-bit/etc/enmascarar.lua
        call    enmascarar_datos_personales

    # =====================================================================
    # SALIDA: al agregador Fluentd
    # =====================================================================
    [OUTPUT]
        Name          forward
        Match         kube.*
        Host          fluentd-agregador.registro.svc.cluster.local
        Port          24224
        # Buffer en disco: si el agregador se cae, no se pierde nada
        storage.total_limit_size  2G
        Retry_Limit   False

  parsers.conf: |
    # Parser del formato CRI: los cuatro campos del apartado 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ínea para trazas de Java: una línea nueva empieza por fecha;
    # las que empiezan por espacios+at o por Caused by son continuación.
    [MULTILINE_PARSER]
        Name          java_excepcion
        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ínea para trazas de Node.js
    [MULTILINE_PARSER]
        Name          node_excepcion
        Type          regex
        Flush_Timeout 1000
        Rule          "start_state"  "/^(Error|TypeError|ReferenceError)/" "cont"
        Rule          "cont"         "/^\s+at\s/"                          "cont"

El problema de las trazas multilínea

Este es el detalle que más frustración causa y que más se agradece resolver.

Una excepción de Node.js llega al log así:

Error: connection pool exhausted
    at Pool.connect (/app/node_modules/pg-pool/index.js:200:35)
    at ReservasRepo.buscar (/app/src/repos/reservas.js:47:22)
    at async ReservasCtrl.crear (/app/src/ctrl/reservas.js:88:18)
    at async /app/src/rutas/reservas.js:31:5

Para el runtime son cinco líneas independientes. Sin el filtro multilínea, Elasticsearch recibe cinco documentos:

  • Uno con Error: connection pool exhausted, sin ninguna traza.
  • Cuatro con fragmentos de traza, sin ningún contexto de qué error los produjo.

Y en Kibana, ordenados por fecha entre los logs de otros cinco pods, resulta imposible reconstruir la excepción. Con veinte líneas de traza, el problema se multiplica.

El filtro multiline reconoce que las líneas que empiezan por espacios y at son continuación de la anterior y las une en un solo documento con la traza completa en un campo. Es la diferencia entre poder depurar y no poder.

El agregador Fluentd

# k8s/base/registro/fluentd-agregador-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluentd-agregador-config
  namespace: registro
data:
  fluent.conf: |
    # Entrada: recibe de todos los Fluent Bit del clúster
    <source>
      @type forward
      port 24224
      bind 0.0.0.0
    </source>

    # Enrutado por namespace: separar entornos en índices distintos
    # permite retenciones y permisos diferentes por entorno.
    <match kube.**>
      @type rewrite_tag_filter
      <rule>
        key $.kubernetes.namespace_name
        pattern /^rutas-norte-pro$/
        tag produccion.${tag}
      </rule>
      <rule>
        key $.kubernetes.namespace_name
        pattern /^rutas-norte-(dev|pre)$/
        tag noproduccion.${tag}
      </rule>
      <rule>
        key $.kubernetes.namespace_name
        pattern /.+/
        tag sistema.${tag}
      </rule>
    </match>

    # Salida a Elasticsearch, con índices separados por entorno y por día
    <match produccion.**>
      @type elasticsearch
      host elasticsearch.registro.svc.cluster.local
      port 9200
      scheme https
      ssl_verify true
      user "#{ENV['ES_USUARIO']}"
      password "#{ENV['ES_PASSWORD']}"

      # Escribe a un flujo de datos gestionado por ILM (apartado 7)
      index_name rutasnorte-pro
      suppress_type_name true

      <buffer>
        @type file
        path /var/log/fluentd/buffer/produccion
        # Buffer en disco: si Elasticsearch se cae, se acumula aquí
        total_limit_size 8GB
        chunk_limit_size 16MB
        flush_interval 10s
        retry_type exponential_backoff
        retry_max_interval 60
        retry_forever true          # nunca descartar logs de producción
        overflow_action block
      </buffer>
    </match>

    <match noproduccion.**>
      @type elasticsearch
      host elasticsearch.registro.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í se puede descartar
      </buffer>
    </match>

El parámetro retry_forever true en producción y false fuera de ella es una decisión de diseño consciente: perder logs de rutas-norte-dev durante una caída de Elasticsearch es aceptable; perder los de producción, no.

Monitorizar el recolector con lo aprendido en 07-03

El recolector es infraestructura crítica: si falla en silencio, te quedas sin logs sin enterarte.

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: fluent-bit
  namespace: registro
spec:
  selector:
    matchLabels:
      app: fluent-bit
  podMetricsEndpoints:
    - port: http-metricas      # el puerto 2020 del [SERVICE]
      path: /api/v1/metrics/prometheus
      interval: 30s

Y la alerta correspondiente, con el criterio de 07-04:

- alert: RecolectorLogsDescartandoRegistros
  expr: |
    rate(fluentbit_output_retries_failed_total[10m]) > 0
  for: 15m
  labels:
    severity: aviso
    equipo: plataforma
  annotations:
    summary: "Fluent Bit está descartando registros en {{ $labels.node }}"
    description: >
      El recolector no consigue entregar logs y los está perdiendo.
      Revisar el agregador y Elasticsearch. Sin logs, el diagnóstico
      de cualquier otro incidente será a ciegas.
    runbook_url: "https://runbooks.rutasnorte.example/recolector-logs"

  1. Elasticsearch: índices, plantillas y ciclo de vida

Elasticsearch es un motor de búsqueda distribuido que indexa documentos JSON. Para nuestro caso, cada línea de log enriquecida es un documento.

Índices por día

Los logs son datos temporales que envejecen: los de hoy se consultan constantemente, los de hace tres semanas casi nunca, y los de hace un año probablemente nunca. Por eso no se guardan todos en un índice, sino en índices por día:

rutasnorte-pro-2026.08.06     ← el de hoy, escribiendo activamente
rutasnorte-pro-2026.08.05
rutasnorte-pro-2026.08.04
...
rutasnorte-pro-2026.07.08     ← el más antiguo, a punto de borrarse

Ventajas decisivas de esta división:

  • Borrar es instantáneo. Eliminar un índice entero es una operación de metadatos. Borrar documentos sueltos dentro de un índice grande es lentísimo y costoso.
  • Las consultas por fecha solo tocan los índices necesarios. Buscar en las últimas 24 horas no recorre 30 días de datos.
  • Cada índice puede tener configuración distinta: los recientes en discos rápidos, los antiguos comprimidos.

Plantillas de índice

Una plantilla define cómo se configura cualquier índice nuevo que coincida con un patrón. Sin ella, Elasticsearch adivina los tipos de cada campo, y adivina mal.

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" },
        "nivel":       { "type": "keyword" },
        "componente":  { "type": "keyword" },
        "traza_id":    { "type": "keyword" },
        "mensaje":     { "type": "text" },
        "duracion_ms": { "type": "long" },
        "codigo_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" },
                "entorno": { "type": "keyword" }
              }
            }
          }
        }
      }
    }
  }
}

La distinción entre keyword y text es fundamental y confunde a todo el mundo:

Tipo Cómo se indexa Sirve para Ejemplo
keyword Valor exacto, sin trocear Filtrar, agregar, ordenar nivel: "error", pod_name
text Troceado en palabras (analizado) Búsqueda de texto libre mensaje

Si nivel fuera text, no podrías hacer una agregación "cuántos logs por nivel", que es exactamente lo que quieres en un cuadro de mando. Si mensaje fuera keyword, no podrías buscar "pool" dentro de "connection pool exhausted".

refresh_interval: 30s es un ajuste de rendimiento importante: por defecto es 1 segundo, lo que obliga a Elasticsearch a hacer visible cada documento casi al instante, a un coste alto. Para logs, 30 segundos de retraso es perfectamente aceptable y multiplica el rendimiento de escritura.

Ciclo de vida (ILM)

La gestión del ciclo de vida de índices automatiza el envejecimiento. Sin ella, alguien tiene que acordarse de borrar índices antiguos, y nadie se acuerda hasta que el disco se llena.

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": { "tipo_nodo": "frio" }
          },
          "set_priority": { "priority": 0 }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": { "delete": {} }
      }
    }
  }
}
Fase Cuándo Qué se hace Por qué
Caliente (hot) Día 0-3 Escritura activa, réplicas, prioridad alta Se consulta constantemente
Templada (warm) Día 3-14 Solo lectura, sin réplica, segmentos fusionados Se consulta a veces; ahorra 50 % de espacio
Fría (cold) Día 14-30 Movido a nodos con discos lentos y baratos Se consulta casi nunca
Borrado Día 30 Índice eliminado Ya no aporta

El punto crítico: la fase de borrado. Los 30 días no son un número técnico, es una decisión de negocio y de cumplimiento normativo. Si los logs contienen datos personales (que no deberían, pero es lo que ocurre en la práctica), la retención debe ajustarse a lo que exija la normativa aplicable y a lo que haya definido el responsable de protección de datos de la empresa. Volvemos a ello en el apartado 11.

Dimensionado mínimo realista

Esta es la parte que se subestima siempre.

Estimación para Rutas Norte:
  6 componentes × ~4 réplicas medias = 24 contenedores
  Volumen medio: 300 líneas/minuto por contenedor en horario activo
  Tamaño medio de un documento enriquecido: ~800 bytes

  24 × 300 × 60 × 16 h ≈ 6,9 millones de documentos al día
  6,9 M × 800 B ≈ 5,5 GB/día en bruto
  Con overhead de indexación de Elasticsearch (×1,3): ~7,2 GB/día
  Con 1 réplica en la fase caliente: ~14 GB/día los primeros 3 días

  Retención de 30 días:
    3 días calientes con réplica: 43 GB
    27 días templados/fríos sin réplica: 194 GB
    TOTAL ≈ 240 GB, más un 25 % de margen operativo → 300 GB

Y el mínimo de recursos de cómputo:

Componente Mínimo realista para Rutas Norte
Elasticsearch 3 nodos (para quórum), 4 GB de heap cada uno (8 GB de RAM), 100 GB de disco cada uno
Fluentd agregador 2 réplicas, 512 MB de RAM, 10 GB de disco para buffer
Fluent Bit 1 por nodo, 64-192 MB de RAM
Kibana 1 réplica, 1 GB de RAM

Elasticsearch necesita tres nodos, no uno. Con un solo nodo no hay tolerancia a fallos y la pila de logs se cae entera cuando ese pod se reinicia. Con dos hay riesgo de cerebro dividido (split-brain). Tres es el mínimo real.

Regla de oro del heap: asigna como máximo el 50 % de la memoria del contenedor y nunca más de 31 GB (por encima de ese umbral la JVM pierde la compresión de punteros y rinde peor con más memoria).

# Fragmento del StatefulSet de Elasticsearch
env:
  - name: ES_JAVA_OPTS
    value: "-Xms4g -Xmx4g"     # heap = mitad de los 8Gi del contenedor
resources:
  requests:
    cpu: "1"
    memory: "8Gi"
  limits:
    memory: "8Gi"              # igual que request: QoS Guaranteed
volumeClaimTemplates:
  - metadata:
      name: datos
    spec:
      storageClassName: rutasnorte-rapida   # la clase de 05-04
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 100Gi

Total aproximado: 24 GB de RAM y 300 GB de disco rápido solo para poder leer logs. Esa cifra es la que hay que poner sobre la mesa antes de decidir, y la que motiva el apartado 12.

  1. Kibana: patrón de índice, KQL y cuadro de mando de errores

Vista de datos

Antes de buscar nada hay que decirle a Kibana qué índices consultar. En Stack Management → Data Views → Create data view:

Nombre:              Logs Rutas Norte Producción
Patrón de índice:    rutasnorte-pro-*
Campo de tiempo:     @timestamp

El campo de tiempo es lo que permite el selector de rango temporal y los histogramas. Sin él, Kibana no puede ordenar cronológicamente.

Buscar con KQL

KQL (Kibana Query Language) es el lenguaje de la barra de búsqueda. Es mucho más simple que PromQL y se aprende en diez minutos.

# Todos los errores
nivel: "error"

# Errores de un componente concreto
nivel: "error" and kubernetes.labels.app: "api-reservas"

# Un pod específico
kubernetes.pod_name: "api-reservas-7d9f8c4b5-x2klm"

# Búsqueda de texto libre en el mensaje (campo text)
mensaje: "pool exhausted"

# Frase exacta
mensaje: "connection pool exhausted"

# Comodines en el nombre del pod: todas las réplicas
kubernetes.pod_name: api-reservas-*

# Negación: todo menos las sondas de salud (07-01), que son puro ruido
kubernetes.labels.app: "api-reservas" and not ruta: ("/salud" or "/preparado")

# Rangos numéricos: peticiones lentas
duracion_ms > 1000

# Combinaciones con paréntesis
(nivel: "error" or nivel: "fatal")
  and kubernetes.namespace_name: "rutas-norte-pro"
  and not mensaje: "ECONNRESET"

# Existencia de un campo
traza_id: *

# Errores 5xx
codigo_http >= 500

Comparación rápida con lo que ya sabemos:

Quiero... En PromQL (07-03) En KQL
Filtrar por etiqueta {app="api-reservas"} kubernetes.labels.app: "api-reservas"
Negar {codigo!="200"} not codigo_http: 200
Expresión regular {ruta=~"/api/.*"} ruta: /api/*
Rango numérico metrica > 100 duracion_ms > 100

El flujo de trabajo real ante un incidente

Volvamos al escenario del apartado 1: la alerta ApiReservasTasaErrorAlta dispara a las 03:14.

Paso 1 — Acotar en el tiempo. Selector de rango: 2026-08-06 03:00 a 2026-08-06 04:00.

Paso 2 — Ver la forma del problema. Consulta amplia y mirar el histograma:

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

El histograma muestra de un vistazo si los errores empezaron de golpe (un despliegue, una caída) o crecieron progresivamente (agotamiento de un recurso).

Paso 3 — Identificar el componente. En el panel de campos, hacer clic en kubernetes.labels.app para ver la distribución:

api-reservas          4821  (94.2%)
worker-notificaciones  287  (5.6%)
tienda-web              11  (0.2%)

Paso 4 — Encontrar el mensaje dominante. Clic en mensaje.keyword:

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

Paso 5 — Abrir un documento y leer la traza completa. Gracias al filtro multilínea del apartado 6, la excepción está entera en un solo documento.

Paso 6 — Correlacionar hacia atrás. ¿Qué pasó justo antes del primer error? Quitar el filtro de nivel y mirar los cinco minutos anteriores:

kubernetes.namespace_name: "rutas-norte-pro"

Ahí suele aparecer la causa: un despliegue, un CronJob que arrancó, una consulta lenta de PostgreSQL.

Diagnóstico completo en cinco minutos. Con kubectl logs en seis pods, esto habría llevado una hora, y probablemente los logs ya no existirían.

Cuadro de mando de errores

En Dashboards → Create dashboard, con estas visualizaciones:

Visualización Tipo Configuración
Errores en el tiempo Barras verticales Eje X: @timestamp (intervalo 5 min). Eje Y: recuento. Desglose: nivel
Errores por componente Tarta o barras horizontales Términos de kubernetes.labels.app, ordenado por recuento
Mensajes de error más frecuentes Tabla Términos de mensaje.keyword, top 20, con el recuento
Errores por pod Mapa de calor Eje X: tiempo. Eje Y: kubernetes.pod_name. Color: recuento
Últimos errores Tabla de documentos Columnas: hora, componente, pod, mensaje. Ordenado descendente
Distribución por nivel Métrica Recuento filtrado por cada nivel

El mapa de calor por pod es especialmente revelador: si los errores se concentran en un solo pod de seis, el problema es de ese pod (un nodo con problemas, una réplica con estado corrupto). Si están repartidos uniformemente, el problema es sistémico (la base de datos, una dependencia externa). Esa distinción, que en kubectl logs es casi imposible de ver, aquí salta a la vista en un segundo.

  1. Logs estructurados en JSON: la decisión que más rentabilidad da

Todo lo anterior funciona muchísimo mejor si las aplicaciones escriben JSON en lugar de texto libre. Es, con diferencia, el cambio de menor coste y mayor impacto de toda la lección.

El antes

2026-08-06 03:14:22 [ERROR] api-reservas - Fallo al crear reserva para el trayecto Bilbao-Santander del usuario 48213 tras 4821ms: connection pool exhausted

Problemas de esta línea, que parece perfectamente razonable:

  • Para filtrar por nivel hay que buscar la subcadena [ERROR], lo que también encuentra un mensaje que diga "el usuario vio un [ERROR] en pantalla".
  • La duración 4821ms es texto: no se puede consultar duracion_ms > 1000 ni calcular percentiles.
  • El identificador de usuario está incrustado en la frase: no se puede filtrar por él.
  • No hay identificador de traza: no se puede seguir esta petición por los demás componentes.
  • Si mañana alguien cambia el formato del mensaje, todos los filtros guardados dejan de funcionar.

El después

{
  "timestamp": "2026-08-06T03:14:22.891Z",
  "nivel": "error",
  "componente": "api-reservas",
  "traza_id": "8f3a2c91-4b7d-4e2a-9c15-7f8d3e1a6b04",
  "mensaje": "Fallo al crear reserva",
  "error_tipo": "PoolExhaustedError",
  "error_detalle": "connection pool exhausted",
  "duracion_ms": 4821,
  "ruta": "/api/reservas",
  "metodo": "POST",
  "codigo_http": 500,
  "trayecto_origen": "Bilbao",
  "trayecto_destino": "Santander",
  "version_app": "2.8.1"
}

Ahora sí:

nivel: "error" and duracion_ms > 3000 and error_tipo: "PoolExhaustedError"

Y agregaciones: la duración media de las peticiones fallidas, los diez trayectos con más errores, la evolución de error_tipo en el tiempo.

Nota crítica sobre lo que no está en ese JSON: no aparece el nombre del cliente, ni su DNI, ni su teléfono, ni su correo. Aparece traza_id, que es un identificador opaco. Si hace falta saber qué cliente era, se cruza el traza_id con la base de datos, en un sistema con control de acceso. Es una decisión deliberada, y el apartado 11 explica por qué es obligatoria.

El estándar de campos de Rutas Norte

Todo componente de la plataforma debe emitir estos campos:

Campo Tipo Obligatorio Descripción
timestamp ISO 8601 UTC con milisegundos Momento del evento
nivel debug|info|warn|error|fatal Severidad, en minúsculas
componente cadena Nombre del componente, igual que la etiqueta app
traza_id UUID en peticiones Identificador que sigue la petición entre componentes
mensaje cadena Descripción legible, sin datos variables incrustados
error_tipo cadena Si hay error Clase de la excepción
duracion_ms entero Si aplica Duración de la operación
version_app cadena Recomendado Versión desplegada: permite correlacionar con un despliegue

Regla sobre mensaje: debe ser constante para el mismo tipo de evento, con los valores variables en campos aparte. "Fallo al crear reserva" con duracion_ms: 4821 es mucho más útil que "Fallo al crear reserva tras 4821ms", porque permite agregar por mensaje.

Implementación en api-reservas

// registro.js — logger estructurado con pino
const pino = require('pino');

const registro = pino({
  level: process.env.NIVEL_LOG || 'info',
  // Escribe a stdout: el contrato de Kubernetes del apartado 2
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: {
    level: (etiqueta) => ({ nivel: etiqueta }),   // "level":30 -> "nivel":"info"
  },
  base: {
    componente: 'api-reservas',
    version_app: process.env.APP_VERSION,
    entorno: process.env.ENTORNO,
  },
  // REDACCIÓN AUTOMÁTICA: la última línea de defensa del apartado 11.
  // Si alguien registra por error un objeto con estos campos, se sustituyen.
  redact: {
    paths: [
      'req.headers.authorization', 'req.headers.cookie',
      '*.password', '*.dni', '*.telefono', '*.email', '*.correo',
      '*.tarjeta', '*.cvv', '*.iban',
      'cliente.nombre', 'cliente.apellidos',
    ],
    censor: '[REDACTADO]',
  },
});

module.exports = registro;

Y su uso, con el identificador de traza propagado:

const { randomUUID } = require('crypto');
const registro = require('./registro');

// Middleware: cada petición recibe un traza_id, o reutiliza el que venga
app.use((req, res, next) => {
  req.trazaId = req.headers['x-traza-id'] || randomUUID();
  res.setHeader('x-traza-id', req.trazaId);
  // Logger hijo: TODOS los logs de esta petición llevarán el traza_id
  req.log = registro.child({ traza_id: req.trazaId });
  next();
});

app.post('/api/reservas', async (req, res) => {
  const inicio = Date.now();
  try {
    const reserva = await crearReserva(req.body);
    req.log.info({
      ruta: '/api/reservas',
      metodo: 'POST',
      codigo_http: 201,
      duracion_ms: Date.now() - inicio,
      trayecto_origen: reserva.origen,
      trayecto_destino: reserva.destino,
      // Nota: NO registramos reserva.cliente.dni ni .telefono ni .email
    }, 'Reserva creada');
    res.status(201).json(reserva);
  } catch (err) {
    req.log.error({
      ruta: '/api/reservas',
      metodo: 'POST',
      codigo_http: 500,
      duracion_ms: Date.now() - inicio,
      error_tipo: err.constructor.name,
      error_detalle: err.message,
      traza: err.stack,
    }, 'Fallo al crear reserva');
    res.status(500).json({ error: 'error interno', traza_id: req.trazaId });
  }
});

// Al llamar a otros componentes, propagar el traza_id
async function llamarPasarelaPagos(req, datos) {
  return fetch('https://pagos.proveedorexterno.example/cobros', {
    method: 'POST',
    headers: { 'x-traza-id': req.trazaId },
    body: JSON.stringify(datos),
  });
}

Fíjate en el último detalle: devolvemos el traza_id al cliente en el cuerpo del error. Cuando alguien llame al servicio de atención diciendo "no he podido comprar", con ese identificador se localiza en Kibana la petición exacta y todo su recorrido:

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

Sin filtrar por componente: todos los logs de todos los componentes que participaron en esa petición, en orden. Eso es correlación de verdad.

El adaptador de logs de worker-notificaciones

En 06-04 añadimos a worker-notificaciones un contenedor adaptador que convierte su log propietario a JSON. Aquí es donde se explica del todo por qué.

worker-notificaciones usa una biblioteca antigua de envío de correo que escribe así, y no se puede modificar sin reescribir el componente:

[2026-08-06 03:15:44] SMTP-SEND [email protected] subject="Confirmacion de reserva" result=FAILED reason=timeout duration=30012ms

Dos problemas: no es JSON, y contiene la dirección de correo de un cliente, que es un dato personal.

El adaptador, un sidecar que lee ese log y emite JSON limpio:

# Fragmento del Deployment worker-notificaciones (06-04)
initContainers:
  - name: adaptador-logs
    image: registry.rutasnorte.example/adaptador-logs:1.3.0
    restartPolicy: Always        # sidecar nativo 1.29+
    args:
      - --entrada=/var/log/worker/smtp.log
      - --formato=smtp-legado
      - --salida=stdout
      - --enmascarar=email,telefono      # enmascara ANTES de emitir
    volumeMounts:
      - name: logs-worker
        mountPath: /var/log/worker
    resources:
      requests:
        cpu: "10m"
        memory: "16Mi"
      limits:
        cpu: "50m"
        memory: "32Mi"

Su salida:

{
  "timestamp": "2026-08-06T03:15:44.000Z",
  "nivel": "error",
  "componente": "worker-notificaciones",
  "mensaje": "Fallo al enviar correo",
  "operacion": "smtp_send",
  "destinatario_hash": "sha256:4f2a...9b1c",
  "asunto_tipo": "confirmacion_reserva",
  "error_tipo": "SmtpTimeout",
  "duracion_ms": 30012
}

Dos transformaciones clave: el formato pasa a JSON consultable, y la dirección de correo se sustituye por un hash. El hash permite responder a "¿cuántos correos han fallado para el mismo destinatario?" sin que la dirección aparezca en ningún índice.

  1. Correlación entre logs y métricas

Los logs y las métricas son dos vistas del mismo sistema, y su valor se multiplica cuando se pueden cruzar. La clave es usar las mismas etiquetas en ambos.

Concepto En Prometheus (07-03) En los logs
Componente componente="api-reservas" componente: "api-reservas"
Entorno entorno="pro" kubernetes.labels.entorno: "pro"
Namespace namespace="rutas-norte-pro" kubernetes.namespace_name: "rutas-norte-pro"
Pod pod="api-reservas-7d9f..." kubernetes.pod_name: "api-reservas-7d9f..."
Versión version="2.8.1" version_app: "2.8.1"

Con esa correspondencia, el flujo de investigación es directo:

flowchart LR
    A["Alerta<br/>ApiReservasTasaErrorAlta"] --> B["Grafana: el gráfico<br/>muestra el pico a las 03:14"]
    B --> C["Copiar pod, namespace<br/>y ventana temporal"]
    C --> D["Kibana: filtrar por esos<br/>mismos valores"]
    D --> E["Leer el mensaje de error<br/>y la traza completa"]
    E --> F["Filtrar por traza_id para<br/>ver TODA la petición"]

Tres formas prácticas de enlazar ambos mundos:

1. Enlace desde el panel de Grafana. En las opciones del panel, un Data link que abre Kibana con los filtros ya aplicados:

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}"'))

Con eso, un clic en el pico del gráfico lleva a los logs de ese pod en esa ventana exacta. Ahorra muchísimo tiempo bajo presión.

2. Métricas derivadas de logs. Fluentd puede contar eventos y exponerlos a Prometheus, lo que permite alertar sobre patrones de log:

<match produccion.**>
  @type copy
  <store>
    @type elasticsearch
    # ... configuración normal
  </store>
  <store>
    @type prometheus
    <metric>
      name rutasnorte_logs_por_nivel_total
      type counter
      desc Registros emitidos por nivel y componente
      <labels>
        nivel ${nivel}
        componente ${componente}
      </labels>
    </metric>
  </store>
</match>

Cuidado con la cardinalidad, exactamente igual que en 07-03: nunca uses el mensaje completo como etiqueta.

3. Grafana como visor unificado. Grafana puede añadir Elasticsearch como fuente de datos adicional y mostrar en el mismo cuadro de mando un panel de métricas y otro de logs. Con la fuente Loki (apartado 12) la integración es aún más estrecha.

Lo que falta para cerrar el círculo por completo son las trazas distribuidas (OpenTelemetry, Jaeger, Tempo): la tercera pata de la observabilidad, que registra el recorrido completo de una petición con los tiempos de cada salto. Nuestro traza_id es una versión artesanal y muy útil de esa idea, pero las trazas quedan fuera del alcance de este curso.

  1. Qué NO se debe registrar nunca

Este apartado es el más importante de la lección, y el que más consecuencias tiene fuera del ámbito técnico.

El problema

postgres-reservas guarda datos personales de los clientes de Rutas Norte: nombre, DNI, teléfono y correo electrónico. Y en cuanto montas una pila de registro centralizado, todo lo que las aplicaciones escriban se copia, se indexa y se conserva durante 30 días en un sistema al que accede mucha más gente que a la base de datos.

Ese es el riesgo real, y es fácil de subestimar. Un log tan inocente como este:

2026-08-06 03:14:22 INFO Creando reserva para Marta Ruiz Sánchez (DNI 12345678Z, tel 611223344, [email protected]) trayecto Bilbao-Santander

acaba de convertir tu sistema de logs en un fichero de datos personales con todas las obligaciones que eso conlleva.

Lista de lo que nunca debe aparecer

Categoría Ejemplos Riesgo
Credenciales Contraseñas, tokens, claves de API, cookies de sesión, cabeceras Authorization Acceso no autorizado inmediato
Datos de pago Número de tarjeta, CVV, fecha de caducidad, IBAN Fraude; incumplimiento de PCI DSS
Datos personales identificativos Nombre y apellidos, DNI/NIE, teléfono, correo, dirección postal Normativa de protección de datos
Datos de categoría especial Salud, discapacidad, origen étnico, afiliación Protección reforzada por normativa
Cuerpos de petición completos req.body volcado tal cual Contiene todo lo anterior sin filtrar
URLs con parámetros sensibles /api/reservas?dni=12345678Z Aparecen en los logs de nginx y del Ingress

Un caso que se escapa siempre: los logs de acceso del Ingress y de tienda-web. Registran la URL completa de cada petición. Si alguna ruta lleva datos en la cadena de consulta, quedan registrados sin que la aplicación intervenga. Solución: no poner datos sensibles en URLs, jamás.

Las cuatro capas de defensa

Ninguna es suficiente por sí sola. Se aplican todas.

Capa 1 — En el código (la más eficaz). Que el dato no se escriba nunca. Es la única defensa que no tiene fugas, porque si nunca sale del proceso no hay nada que filtrar. Requiere revisión en los pull requests.

Capa 2 — Redacción en la librería de logging. Como el redact de pino del apartado 9: una red de seguridad para los descuidos.

Capa 3 — Enmascarado en el recolector. La última defensa técnica antes de que el dato se persista.

-- k8s/base/registro/enmascarar.lua
-- Filtro Lua de Fluent Bit: enmascara patrones de datos personales
-- ANTES de que salgan del nodo. Es una red de seguridad, NO un sustituto
-- de no registrar el dato: si el patrón cambia, esto no lo detecta.

function enmascarar_datos_personales(tag, timestamp, registro)
    local modificado = false

    for clave, valor in pairs(registro) do
        if type(valor) == "string" then
            local original = valor

            -- DNI español: 8 dígitos + letra
            valor = string.gsub(valor, "%d%d%d%d%d%d%d%d%a", "[DNI-REDACTADO]")

            -- Correo electrónico
            valor = string.gsub(valor, "[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTADO]")

            -- Teléfono español: 9 dígitos empezando por 6, 7, 8 o 9
            valor = string.gsub(valor, "%f[%d][6789]%d%d%d%d%d%d%d%d%f[%D]", "[TEL-REDACTADO]")

            -- Tarjeta de crédito: 16 dígitos con o sin separadores
            valor = string.gsub(valor, "%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d",
                                "[TARJETA-REDACTADA]")

            -- IBAN español
            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-REDACTADO]")

            if valor ~= original then
                registro[clave] = valor
                modificado = true
            end
        end
    end

    -- Eliminar campos que NUNCA deben persistirse, sea cual sea su contenido
    local campos_prohibidos = {
        "password", "contrasena", "token", "authorization", "cookie",
        "api_key", "secret", "cvv", "tarjeta"
    }
    for _, campo in ipairs(campos_prohibidos) do
        if registro[campo] ~= nil then
            registro[campo] = nil
            modificado = true
        end
    end

    -- Marcar los registros modificados: permite AUDITAR qué componentes
    -- siguen intentando escribir datos sensibles y corregirlos en el código.
    if modificado then
        registro["_enmascarado"] = true
    end

    return 2, timestamp, registro
end

Ese campo _enmascarado es más valioso de lo que parece. Con una consulta en Kibana:

_enmascarado: true

y agregando por componente, obtienes la lista de componentes que están escribiendo datos personales y que hay que corregir en el código. Convierte una medida defensiva en una herramienta de mejora.

Advertencia honesta sobre las expresiones regulares: no son fiables. Un DNI escrito como 12.345.678-Z no lo detecta el patrón anterior. Un nombre y apellidos no tiene ningún patrón detectable. La única defensa real es la capa 1.

Capa 4 — Control de acceso y retención. Aunque no haya fugas, restringe quién puede ver los logs de producción, con índices separados por entorno (como hicimos en el agregador) y roles de Elasticsearch que solo den acceso a lo necesario.

Retención

La retención de 30 días del apartado 7 es una decisión que combina tres criterios:

Criterio Consideración
Operativo ¿Cuánto atrás hace falta mirar para diagnosticar? Normalmente 7-14 días bastan
Legal ¿Qué exige la normativa aplicable al sector y al tipo de dato?
Económico Cada día de retención cuesta unos 7 GB de disco rápido

Y una regla que a menudo sorprende: si los logs contienen datos personales, conservarlos "por si acaso" no es aceptable. La normativa de protección de datos exige que los datos personales se conserven solo el tiempo necesario para la finalidad que justificó su recogida, y "por si algún día hace falta depurar" no suele ser una finalidad válida.

⚠️ Advertencia: revisión por el responsable de cumplimiento normativo

Este es un punto que no puede resolverse solo con criterio técnico.

La configuración de esta lección —qué se registra, cuánto tiempo se conserva, quién puede consultarlo y desde dónde— debe ser revisada y aprobada por el responsable de cumplimiento normativo y protección de datos de la organización antes de ponerse en producción.

Rutas Norte trata datos personales de sus clientes (nombre, DNI, teléfono y correo electrónico), lo que sitúa a la plataforma dentro del ámbito de aplicación del RGPD y de la normativa nacional de protección de datos. Un sistema de registro centralizado que capture, aunque sea accidentalmente, esos datos, tiene consecuencias concretas:

  • Pasa a ser un tratamiento de datos personales que debe figurar en el registro de actividades de tratamiento, con su base legal y su finalidad documentadas.
  • Exige un plazo de conservación justificado y aplicado técnicamente, no una retención indefinida "por si acaso".
  • Obliga a controlar y registrar los accesos: quién consulta los logs de producción y con qué finalidad.
  • Puede requerir una evaluación de impacto si el volumen o la naturaleza del tratamiento lo justifican.
  • Complica notablemente el ejercicio de los derechos de supresión y de acceso: si el DNI de un cliente aparece en veinte índices distribuidos, atender una solicitud de supresión se vuelve técnicamente muy costoso.
  • Si Elasticsearch está alojado fuera del Espacio Económico Europeo, activa las obligaciones sobre transferencias internacionales de datos.

Qué hacer, en la práctica:

  1. Documentar por escrito qué campos se registran de cada componente y presentarlo a la persona responsable de cumplimiento.
  2. Acordar con ella el plazo de retención de cada índice, y aplicarlo en la política ILM.
  3. Definir quién tiene acceso a los índices de producción, y revisarlo periódicamente.
  4. Auditar con la consulta _enmascarado: true qué componentes siguen emitiendo datos sensibles y corregirlos en el código.
  5. Incluir la revisión de logs en la lista de comprobación de cualquier funcionalidad nueva que trate datos de clientes.

El equipo técnico proporciona los mecanismos —enmascarado, retención, control de acceso, separación por entornos—, pero la decisión sobre qué es aceptable registrar y durante cuánto tiempo no es una decisión técnica.

  1. Alternativas más ligeras y el coste real de una pila de registro

El coste real

Recapitulando el apartado 7, la pila EFK mínima para Rutas Norte:

Recurso Cantidad Coste orientativo mensual (nube)
3 nodos de Elasticsearch (8 GB RAM, 2 vCPU) 24 GB RAM, 6 vCPU 350-500 €
Disco rápido (300 GB SSD) 300 GB 30-60 €
Fluentd agregador (2 réplicas) 1 GB RAM 15-25 €
Fluent Bit (3 nodos) ~200 MB RAM Despreciable
Kibana 1 GB RAM 15-25 €
Total ~450-650 €/mes

Más el coste humano: alguien tiene que mantener Elasticsearch, dimensionar los fragmentos, vigilar el estado del clúster, gestionar las actualizaciones y responder cuando se pone en rojo. Elasticsearch no es un componente que se instale y se olvide.

Para una plataforma con seis componentes y tres nodos, es un coste considerable. Merece la pena preguntarse si hay algo más ligero.

Loki con Promtail: la alternativa ligera

Loki es el sistema de logs de Grafana Labs, con una idea de diseño radicalmente distinta:

Loki no indexa el contenido de los logs. Solo indexa las etiquetas.

Es "Prometheus para logs": el mismo modelo de etiquetas, el mismo lenguaje de consulta (LogQL, muy parecido a PromQL) y almacenamiento en objetos baratos (S3) en lugar de discos rápidos.

Aspecto Elasticsearch (EFK) Loki
Indexa Todos los campos Solo las etiquetas
Almacenamiento Disco rápido Objetos (S3, GCS)
Coste relativo Alto 5-10 veces menor
Búsqueda de texto libre Instantánea Más lenta (escaneo secuencial)
Agregaciones complejas Muy potentes Limitadas
Consumo de recursos 24 GB de RAM 2-4 GB de RAM
Integración con Grafana Buena Nativa y muy estrecha
Complejidad operativa Alta Baja
# Ejemplo de consulta LogQL, si vienes de PromQL te resultará familiar
{namespace="rutas-norte-pro", app="api-reservas"} |= "error" | json | duracion_ms > 1000

# Y hasta puedes derivar métricas de los logs
sum(rate({namespace="rutas-norte-pro"} |= "error" [5m])) by (app)

Cuándo elegir cada uno:

Elige EFK si... Elige Loki si...
Necesitas búsqueda de texto libre muy rápida sobre grandes volúmenes Buscas casi siempre por componente y ventana temporal
Haces agregaciones complejas sobre los campos Ya usas Grafana y quieres logs y métricas juntos
Necesitas Elasticsearch para otras cosas El presupuesto y el equipo de operación son limitados
El equipo ya sabe operar Elasticsearch Quieres empezar hoy con poco esfuerzo

Recomendación honesta para Rutas Norte: con seis componentes, tres nodos y un equipo pequeño, Loki sería la elección más sensata. Hemos montado EFK porque es lo que encontrarás en la mayoría de empresas establecidas y porque enseña los conceptos (índices, plantillas, ILM, mapeos) que después se aplican en cualquier sistema. Pero si mañana empezaras de cero, empieza por Loki.

Servicios gestionados

La tercera vía es no operar nada:

Servicio Notas
Elastic Cloud Elasticsearch gestionado por el propio fabricante
Grafana Cloud Logs Loki gestionado, con capa gratuita generosa
AWS CloudWatch Logs / OpenSearch Integración directa con EKS (10-06)
Google Cloud Logging Integración directa con GKE, muy buena
Datadog, New Relic, Splunk Plataformas completas, muy caras a volumen

Ventaja: cero operación. Inconvenientes: coste por GB ingerido que se dispara con el volumen, y —importante para el apartado 11— los datos salen de tu infraestructura, lo que hay que revisar con el responsable de cumplimiento normativo, especialmente si el proveedor almacena fuera del EEE.

La medida que más ahorra: registrar menos

Antes de dimensionar nada, reduce el volumen. Es gratis y siempre funciona:

# En Fluent Bit: descartar el ruido de las sondas de salud (07-01).
# Con 6 réplicas y sondas cada 5 s, son más de 100 000 líneas diarias
# que no aportan absolutamente nada.
[FILTER]
    Name    grep
    Match   kube.*
    Exclude log ^.*"ruta":"/(salud|preparado)".*$

# Descartar los logs de nivel debug de producción
[FILTER]
    Name    grep
    Match   kube.var.log.containers.*_rutas-norte-pro_*
    Exclude nivel ^debug$

Medidas complementarias:

  • Nivel info en producción, debug solo en rutas-norte-dev.
  • access_log off; para el endpoint de salud de nginx, como ya hicimos en 07-01.
  • Retención más corta para dev y pre (7 días) que para pro (30 días).
  • Revisar periódicamente qué componente genera más volumen:
# En Kibana: agregación de recuento por kubernetes.labels.app
# Suele descubrirse que un solo componente genera el 60 % del volumen
# por un log de depuración que alguien dejó activado hace meses.

Errores Comunes y Consejos

1. La aplicación escribe a fichero en vez de a stdout. Rompe el contrato de Kubernetes: el recolector no lo ve, el fichero crece dentro del contenedor y desaparece al reiniciarse. Si no puedes cambiar la aplicación, usa un sidecar adaptador como el de worker-notificaciones.

2. No configurar el reensamblado multilínea. Cada traza de excepción se convierte en veinte documentos inconexos, justo lo que más falta hace leer entero durante un incidente.

3. El recolector lee sus propios logs. Bucle infinito: cada log generado produce otro log. El Exclude_Path del apartado 6 no es opcional.

4. Elasticsearch con un solo nodo. Sin quórum, sin tolerancia a fallos, y la pila de logs entera cae cuando ese pod se reinicia. Tres nodos es el mínimo real.

5. Heap de Elasticsearch mal dimensionado. Máximo el 50 % de la memoria del contenedor, y nunca más de 31 GB. Por encima de ese umbral la JVM pierde la compresión de punteros y rinde peor con más memoria.

6. Sin política ILM. Los índices se acumulan hasta llenar el disco. Cuando eso pasa, Elasticsearch pasa a solo lectura y dejas de recibir logs justo cuando más falta hacen.

7. Mapear como text lo que debería ser keyword. Sin keyword no puedes agregar por ese campo, y las agregaciones son la mitad del valor de Kibana.

8. Registrar datos personales. El error con más consecuencias fuera de lo técnico. Aplica las cuatro capas de defensa y, sobre todo, no lo escribas en el código.

9. No definir un estándar de campos. Si cada componente usa level, severity y nivel para lo mismo, no hay ninguna consulta que funcione para todos. Acuerda el estándar antes de instrumentar.

10. No propagar el traza_id. Sin él, correlacionar una petición entre seis componentes es imposible por mucha pila de logs que tengas.

11. Sin buffer en el recolector. Si Elasticsearch se cae diez minutos y no hay buffer, se pierden diez minutos de logs de producción, probablemente los más interesantes.

12. No monitorizar la pila de logs. El recolector puede estar descartando registros en silencio. Usa el PodMonitor y la alerta del apartado 6: aplica lo aprendido en 07-03 y 07-04 a la propia infraestructura de observabilidad.

13. Registrar demasiado. El nivel debug en producción multiplica por diez el volumen y el coste, y hace más difícil encontrar lo importante entre el ruido.

Ejercicios

Ejercicio 1 — Convertir un log de texto a estructurado

tienda-web (nginx) genera logs de acceso en formato combinado:

83.45.12.99 - - [06/Aug/2026:03:14:22 +0000] "POST /api/reservas?dni=12345678Z HTTP/1.1" 500 187 "https://www.rutasnorte.example/comprar" "Mozilla/5.0" 4.821
  1. Enumera todos los problemas de esta línea, incluidos los de protección de datos.
  2. Escribe la configuración de nginx que emita el mismo evento en JSON, conforme al estándar de campos de Rutas Norte.
  3. Escribe el parser de Fluent Bit necesario si no pudieras cambiar la configuración de nginx.
  4. ¿Qué harías con el parámetro dni de la URL?

Ejercicio 2 — Diagnosticar una fuga de datos personales

Una auditoría interna revela que el índice rutasnorte-pro-2026.08.* contiene 47 000 documentos con direcciones de correo de clientes en texto claro. Los logs provienen de worker-notificaciones y de api-reservas.

  1. Escribe la consulta KQL que localiza los documentos afectados.
  2. Enumera, por orden de prioridad, las acciones a tomar en las próximas 24 horas.
  3. Escribe el filtro de enmascarado que evita que vuelva a ocurrir, y explica por qué no es suficiente.
  4. ¿Qué papel tiene el responsable de cumplimiento normativo en este incidente y en qué momento hay que involucrarlo?

Ejercicio 3 — Dimensionar y decidir la arquitectura

Rutas Norte se expande: pasa de 3 a 12 nodos, de 6 a 15 componentes, y el volumen de logs se multiplica por 5 respecto a la estimación del apartado 7. El presupuesto de infraestructura no se multiplica por 5.

  1. Recalcula el volumen diario y el almacenamiento necesario para 30 días.
  2. Propón tres medidas para reducir el volumen sin perder capacidad de diagnóstico, con una estimación del ahorro de cada una.
  3. Compara EFK y Loki para este escenario concreto, con números.
  4. Recomienda una arquitectura final y justifícala.

Soluciones

Solución 1

1. Problemas de la línea.

De formato:

  • No es JSON: cada campo hay que extraerlo con expresiones regulares, frágiles ante cualquier cambio.
  • La fecha usa formato nginx (06/Aug/2026:03:14:22 +0000), no ISO 8601: requiere un parser específico.
  • La duración 4.821 está en segundos, no en milisegundos, e incumple el estándar de Rutas Norte (duracion_ms).
  • No hay traza_id: imposible correlacionar esta petición con la de api-reservas.
  • No hay componente ni nivel: no se puede filtrar por severidad.

De protección de datos (los graves):

  • La URL contiene un DNI: ?dni=12345678Z. Queda registrado en el log de nginx, en el del Ingress y en cualquier proxy intermedio. Este es el problema más grave.
  • La IP del cliente (83.45.12.99) es un dato personal según el RGPD. Debe tratarse como tal: anonimizarla o justificar su conservación.
  • El User-Agent contribuye a la huella digital del navegador y, combinado con otros datos, puede ser identificativo.

2. Configuración de nginx en JSON.

# k8s/base/tienda-web/nginx.conf
http {
    # Anonimizar la IP: conservar solo los tres primeros octetos.
    # Suficiente para geolocalización aproximada y detección de abuso,
    # sin identificar a una persona concreta.
    map $remote_addr $ip_anonima {
        ~^(?<pre>\d+\.\d+\.\d+)\.    "$pre.0";
        default                       "0.0.0.0";
    }

    # Propagar el traza_id: usar el de la petición o generar uno nuevo.
    map $http_x_traza_id $traza_id {
        ""      $request_id;    # nginx genera un id único por petición
        default $http_x_traza_id;
    }

    # Nivel según el código de respuesta
    map $status $nivel_log {
        ~^[45]  "error";
        ~^3     "info";
        default "info";
    }

    log_format rutasnorte_json escape=json
    '{'
      '"timestamp":"$time_iso8601",'
      '"nivel":"$nivel_log",'
      '"componente":"tienda-web",'
      '"traza_id":"$traza_id",'
      '"mensaje":"peticion http",'
      '"metodo":"$request_method",'
      '"ruta":"$uri",'                          # SIN la cadena de consulta
      '"codigo_http":$status,'
      '"duracion_ms":$msec_duracion,'
      '"bytes_enviados":$body_bytes_sent,'
      '"ip_anonima":"$ip_anonima",'
      '"protocolo":"$server_protocol"'
    '}';

    # Duración en milisegundos como entero
    map $request_time $msec_duracion {
        ~^(?<s>\d+)\.(?<ms>\d{3})$  "${s}${ms}";
        default                      "0";
    }

    server {
        access_log /dev/stdout rutasnorte_json;
        error_log  /dev/stderr warn;

        # Las sondas de 07-01 no generan log: puro ruido y volumen
        location = /nginx-salud {
            access_log off;
            return 200 "ok\n";
        }
    }
}

Puntos clave de esta configuración:

  • $uri en vez de $request: $uri es la ruta sin la cadena de consulta, así que el ?dni=... nunca llega al log. Es la solución al problema más grave.
  • escape=json es obligatorio: sin él, un User-Agent con comillas rompe el JSON y Fluent Bit no puede parsearlo.
  • access_log off en la sonda de salud, coherente con lo que hicimos en 07-01.

3. Parser si no se puede cambiar nginx.

[PARSER]
    Name        nginx_combinado
    Format      regex
    Regex       ^(?<ip_cliente>[^ ]+) [^ ]* [^ ]* \[(?<tiempo>[^\]]+)\] "(?<metodo>\S+) (?<ruta_completa>\S+) (?<protocolo>[^"]+)" (?<codigo_http>\d+) (?<bytes>\d+) "(?<referer>[^"]*)" "(?<agente>[^"]*)" (?<duracion_s>[\d.]+)$
    Time_Key    tiempo
    Time_Format %d/%b/%Y:%H:%M:%S %z
    Types       codigo_http:integer bytes:integer duracion_s:float

Y un filtro Lua que normalice al estándar y limpie la cadena de consulta:

function normalizar_nginx(tag, timestamp, registro)
    -- Separar la ruta de la cadena de consulta y DESCARTAR esta última
    if registro["ruta_completa"] then
        local ruta = string.match(registro["ruta_completa"], "^([^?]+)")
        registro["ruta"] = ruta
        registro["ruta_completa"] = nil   -- eliminar: puede llevar el DNI
    end

    -- Anonimizar la IP: cuarto octeto a cero
    if registro["ip_cliente"] then
        registro["ip_anonima"] = string.gsub(registro["ip_cliente"],
                                             "(%d+%.%d+%.%d+)%.%d+", "%1.0")
        registro["ip_cliente"] = nil
    end

    -- Duración a milisegundos
    if registro["duracion_s"] then
        registro["duracion_ms"] = math.floor(registro["duracion_s"] * 1000)
        registro["duracion_s"] = nil
    end

    -- Campos del estándar de Rutas Norte
    registro["componente"] = "tienda-web"
    registro["nivel"] = (registro["codigo_http"] >= 400) and "error" or "info"
    registro["mensaje"] = "peticion http"
    registro["agente"] = nil   -- descartar: contribuye a la huella digital

    return 2, timestamp, registro
end

4. Qué hacer con el dni de la URL.

La respuesta correcta tiene tres niveles, y solo el primero resuelve el problema de raíz:

Nivel 1 (la solución real): cambiar la aplicación. Un DNI no debe viajar nunca en la cadena de consulta de una URL. Las URLs quedan registradas en el navegador, en el historial, en el Referer que se envía a terceros, en los logs de nginx, del Ingress y de cualquier proxy. La consulta debe ser POST con el dato en el cuerpo, o usar un identificador opaco.

Nivel 2 (mitigación inmediata): no registrar la cadena de consulta. Usar $uri en lugar de $request, como en la solución 2. Se implementa en minutos y corta la fuga hacia los logs.

Nivel 3 (red de seguridad): enmascarar en el recolector. El filtro Lua del apartado 11 detecta el patrón de DNI y lo sustituye. Es la última defensa, y no es fiable por sí sola: un DNI escrito como 12.345.678-Z se le escapa.

Aplicar los tres. Y muy importante: los logs que ya contienen el DNI siguen ahí. Hay que borrarlos, y eso nos lleva al ejercicio siguiente.

Solución 2

1. Consulta KQL para localizar los documentos.

# Buscar el patrón de correo en cualquier campo de texto
mensaje: *@*.* or destinatario: * or email: * or correo: *

Más precisa, aprovechando el marcador del enmascarado:

kubernetes.namespace_name: "rutas-norte-pro"
  and (mensaje: *"@"* or destinatario: *"@"*)
  and not _enmascarado: true

Y para cuantificar y localizar el origen, una agregación en Elasticsearch:

POST rutasnorte-pro-2026.08.*/_search
{
  "size": 0,
  "query": {
    "query_string": {
      "query": "*@*.*",
      "fields": ["mensaje", "destinatario", "error_detalle"]
    }
  },
  "aggs": {
    "por_componente": {
      "terms": { "field": "componente", "size": 20 },
      "aggs": {
        "por_dia": {
          "date_histogram": { "field": "@timestamp", "calendar_interval": "day" }
        }
      }
    }
  }
}

Esa agregación te da exactamente qué componente, cuántos documentos y desde qué día: los tres datos que necesitas para el informe.

2. Acciones en 24 horas, por prioridad.

Hora 0-1 — Contener y notificar.

  1. Notificar al responsable de cumplimiento normativo inmediatamente. No es una decisión técnica que se pueda diferir: los plazos de notificación de brechas son cortos y el reloj empieza a correr desde que se tiene conocimiento.
  2. Restringir el acceso a los índices afectados solo al equipo que gestiona el incidente, mediante roles de Elasticsearch.
  3. Documentar el alcance: qué componentes, qué campos, cuántos documentos, desde qué fecha, quién ha accedido a esos índices (los logs de auditoría de Elasticsearch).

Hora 1-4 — Cortar la fuga.

  1. Desplegar el filtro de enmascarado en Fluent Bit (punto 3) para que dejen de entrar documentos nuevos. Es rápido y no requiere tocar las aplicaciones.
  2. Verificar que los documentos nuevos ya llegan enmascarados, comprobando el índice del día.

Hora 4-12 — Corregir el origen.

  1. Localizar en el código las llamadas al logger que emiten el correo. En worker-notificaciones está en el log SMTP heredado; en api-reservas, probablemente en un log.info(reserva) que vuelca el objeto entero.
  2. Corregir el código: sustituir la dirección por un hash, como hace el adaptador del apartado 9.
  3. Añadir redact en la configuración del logger como red de seguridad de capa 2.
  4. Desplegar a pre y luego a pro.

Hora 12-24 — Sanear y prevenir.

  1. Eliminar o depurar los documentos afectados, según lo que decida el responsable de cumplimiento:
POST rutasnorte-pro-2026.08.*/_delete_by_query
{
  "query": {
    "bool": {
      "must": [
        { "query_string": { "query": "*@*.*", "fields": ["mensaje", "destinatario"] } },
        { "terms": { "componente": ["worker-notificaciones", "api-reservas"] } }
      ]
    }
  }
}

Advertencia: _delete_by_query sobre 47 000 documentos es una operación pesada. Si los índices son diarios y están muy contaminados, borrar el índice entero es mucho más rápido y más seguro, al coste de perder también los logs limpios de ese día.

  1. Añadir una comprobación automática en la integración continua que rechace un pull request si detecta patrones de datos personales en llamadas al logger.
  2. Escribir un informe para el responsable de cumplimiento con cronología, alcance, causa raíz y medidas.

3. El filtro de enmascarado y por qué no basta.

function enmascarar_correos(tag, timestamp, registro)
    for clave, valor in pairs(registro) do
        if type(valor) == "string" then
            registro[clave] = string.gsub(valor,
                "[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTADO]")
        end
    end
    -- Campos que nunca deben persistirse
    registro["destinatario"] = nil
    registro["email"] = nil
    registro["correo"] = nil
    return 2, timestamp, registro
end

Por qué no es suficiente, en cuatro razones:

  1. Las expresiones regulares se escapan. marta.ruiz [arroba] ejemplo.example, un correo partido entre dos campos, o uno con caracteres poco habituales no coinciden con el patrón. El enmascarado da una falsa sensación de seguridad.
  2. Solo cubre lo que ya sabes buscar. Un nombre y apellidos no tiene patrón detectable. Una dirección postal tampoco. El filtro protege contra correos, DNIs y tarjetas; contra el resto, nada.
  3. El dato existe hasta el filtro. Sale del proceso, se escribe en el fichero del nodo, viaja hasta el recolector. Cualquiera con acceso al nodo (o al pod del recolector, que monta hostPath) lo ve sin enmascarar.
  4. Es una capa de mitigación, no de prevención. La única defensa real es que el dato no se escriba nunca. Todo lo demás son redes que atrapan lo que se escapa.

Por eso el paso 7 (corregir el código) es el importante, y el filtro solo compra tiempo mientras se despliega.

4. El papel del responsable de cumplimiento normativo.

Cuándo involucrarlo: en la primera hora, antes de tomar ninguna acción de saneamiento. Es un error frecuente "arreglarlo primero y avisar después": borrar los documentos antes de que él documente el alcance puede destruir la evidencia que necesita para evaluar la brecha, y los plazos de notificación corren desde que se tiene conocimiento del hecho, no desde que se termina de arreglar.

Sus responsabilidades en este incidente:

  • Calificar el incidente: determinar si constituye una violación de seguridad de datos personales según el RGPD.
  • Decidir sobre la notificación: si procede notificar a la autoridad de control (en España, la AEPD) en el plazo legal de 72 horas, y si procede comunicarlo a los afectados.
  • Evaluar el riesgo para los derechos y libertades de las personas afectadas, teniendo en cuenta el volumen, la naturaleza del dato y quién ha podido acceder.
  • Decidir el saneamiento: qué se borra, qué se conserva como evidencia y durante cuánto tiempo.
  • Registrar el incidente en el registro interno de violaciones de seguridad, obligatorio incluso cuando no procede notificar.
  • Aprobar las medidas correctoras y verificar que se han implantado.

Y hacia adelante, su papel es preventivo: revisar y aprobar qué campos se registran, los plazos de retención y la política de acceso, tal como establece la advertencia del apartado 11. El equipo técnico proporciona los mecanismos; la decisión sobre qué es aceptable registrar no es técnica.

Solución 3

1. Recálculo del volumen.

Situación nueva:
  12 nodos, 15 componentes
  Volumen × 5 respecto a la estimación original

  Original: 7,2 GB/día indexados
  Nuevo:    7,2 × 5 = 36 GB/día indexados

  Retención de 30 días con la política ILM del apartado 7:
    3 días calientes con 1 réplica:  36 × 3 × 2 = 216 GB
    27 días templados sin réplica:   36 × 27     = 972 GB
    TOTAL ≈ 1 188 GB → con 25 % de margen: ~1,5 TB

  Recursos de Elasticsearch necesarios:
    Regla práctica: ~1 nodo de datos por cada 300-500 GB indexados
    → 4-5 nodos de datos con 16 GB de RAM cada uno (heap de 8 GB)
    → 64-80 GB de RAM y 1,5 TB de disco rápido

  Coste orientativo mensual: 1 400-1 900 €/mes

Un incremento de aproximadamente tres veces el coste original, además de un salto cualitativo en complejidad operativa: con 5 nodos de datos hay que gestionar fragmentos, reequilibrado y actualizaciones continuas.

2. Tres medidas de reducción.

Medida A — Muestreo de logs de éxito. Ahorro estimado: 45 %.

El 90 % de los logs son peticiones que fueron bien y que nadie mirará jamás. Conservar el 10 % de ellas es estadísticamente suficiente para ver tendencias, y el 100 % de los errores.

function muestrear_exitos(tag, timestamp, registro)
    -- Conservar SIEMPRE errores y avisos
    if registro["nivel"] == "error" or registro["nivel"] == "fatal"
       or registro["nivel"] == "warn" then
        return 2, timestamp, registro
    end
    -- Conservar SIEMPRE lo lento, aunque haya ido bien
    if registro["duracion_ms"] and registro["duracion_ms"] > 1000 then
        return 2, timestamp, registro
    end
    -- Conservar siempre lo que toca dinero
    if registro["ruta"] and string.match(registro["ruta"], "^/api/reservas") then
        return 2, timestamp, registro
    end
    -- Del resto, conservar 1 de cada 10
    if math.random(10) == 1 then
        registro["_muestreado"] = 10   -- factor, para poder extrapolar
        return 2, timestamp, registro
    end
    return -1, timestamp, registro     -- -1 = descartar
end

El campo _muestreado permite multiplicar por 10 los recuentos al hacer agregaciones y obtener cifras correctas.

Medida B — Retención escalonada por entorno y por nivel. Ahorro estimado: 30 %.

Índice Retención actual Retención propuesta Justificación
rutasnorte-pro-* errores 30 días 30 días Sin cambio: es lo que se investiga
rutasnorte-pro-* info 30 días 7 días Rara vez se mira más atrás
rutasnorte-pre-* 30 días 7 días Entorno de pruebas
rutasnorte-dev-* 30 días 3 días Se depura en el momento
Logs de sistema 30 días 14 días Compromiso razonable

Se implementa con dos flujos de datos distintos, enrutados en el agregador Fluentd por el campo nivel, cada uno con su política ILM.

Medida C — Descartar ruido en el origen. Ahorro estimado: 20 %.

# Sondas de salud (07-01): con 15 componentes y sondas cada 5 s,
# son cientos de miles de líneas diarias que no aportan nada.
[FILTER]
    Name    grep
    Match   kube.*
    Exclude ruta ^/(salud|preparado|metrics|nginx-salud)$

# Nivel debug fuera de dev
[FILTER]
    Name    grep
    Match   kube.var.log.containers.*_rutas-norte-(pro|pre)_*
    Exclude nivel ^(debug|trace)$

# Logs de arranque repetitivos de bibliotecas
[FILTER]
    Name    grep
    Match   kube.*
    Exclude mensaje ^(Loaded plugin|Initializing module|Warming cache)

Efecto combinado (las medidas no son puramente aditivas porque se solapan):

36 GB/día
  − 45 % por muestreo        → 19,8 GB/día
  − 20 % por descarte de ruido → 15,8 GB/día
  Con retención escalonada, el almacenamiento total:
    ~15,8 GB/día × promedio ponderado de 12 días ≈ 190 GB
  Frente a los 1 500 GB originales: reducción del 87 %

Y lo importante: sin perder capacidad de diagnóstico, porque se conserva el 100 % de los errores, el 100 % de lo lento y el 100 % de lo que toca reservas.

3. Comparación EFK frente a Loki con números.

Sobre el volumen ya optimizado de 15,8 GB/día:

Concepto EFK Loki
Nodos de almacenamiento 3 × 16 GB RAM 3 × 4 GB RAM (ingester/querier)
RAM total 48 GB 12 GB
Almacenamiento 250 GB SSD rápido 250 GB en objetos (S3)
Coste del almacenamiento ~50 €/mes (SSD) ~6 €/mes (S3)
Coste de cómputo ~700 €/mes ~180 €/mes
Coste total mensual ~750 € ~190 €
Búsqueda de texto libre en 30 días 1-3 segundos 10-60 segundos
Búsqueda filtrando por componente y 1 hora < 1 segundo < 1 segundo
Agregaciones complejas Muy potentes Limitadas
Complejidad operativa Alta Baja
Integración con Grafana (07-04) Buena Nativa

El dato decisivo está en la comparación de las dos filas de búsqueda: Loki es lento en búsquedas de texto libre sobre todo el histórico, pero igual de rápido en el caso que representa el 95 % del uso real, que es "logs de este componente, en esta ventana de tiempo, filtrando por nivel". Y eso es exactamente cómo se investiga un incidente: nunca buscas una cadena en 30 días de todos los componentes; siempre acotas por componente y por ventana, porque la alerta ya te dio ambos.

4. Arquitectura recomendada.

Recomendación: migrar a Loki, con las tres medidas de reducción aplicadas.

flowchart TB
    subgraph Nodos["12 nodos"]
        FB["Fluent Bit (DaemonSet)<br/>+ filtros de muestreo,<br/>enmascarado y descarte"]
    end
    FB --> LOKI["Loki<br/>3 réplicas, 4 GB RAM"]
    LOKI --> S3[("Almacenamiento de objetos<br/>250 GB, retención 30 d")]
    LOKI --> GRAF["Grafana<br/>(ya desplegada en 07-04)"]
    PROM["Prometheus<br/>(07-03)"] --> GRAF
    GRAF --> USR["Persona de guardia:<br/>métricas y logs<br/>en la misma pantalla"]

Justificación, en cinco puntos:

  1. Coste: 190 € frente a 750 € al mes. La diferencia (6 700 € al año) es difícil de justificar cuando el caso de uso predominante rinde igual en ambos.
  2. Complejidad operativa. Elasticsearch con 5 nodos de datos requiere alguien que sepa gestionar fragmentos, reequilibrados y actualizaciones. Loki, mucho menos. Con un equipo pequeño, ese tiempo vale más que la diferencia de coste.
  3. Integración con lo que ya tenemos. Grafana ya está desplegada desde 07-04. Loki aparece como una fuente de datos más, y el mismo cuadro de mando puede tener un panel de métricas encima y uno de logs debajo, con la misma ventana temporal. Ese salto de contexto ahorrado en un incidente vale mucho.
  4. El modelo de etiquetas es el que ya conocemos. LogQL es tan parecido a PromQL que el equipo lo aprende en una tarde, mientras que KQL y las agregaciones de Elasticsearch son un cuerpo de conocimiento aparte.
  5. Se conserva la arquitectura de recolección. Fluent Bit sigue siendo el agente por nodo, con la misma configuración de parseo, multilínea y enmascarado. La migración solo cambia el destino de la salida, lo cual la hace de bajo riesgo.

Cuándo NO seguir esta recomendación:

  • Si el equipo ya opera Elasticsearch para otras cosas (buscador de la web, análisis), el coste marginal de añadir logs es mucho menor.
  • Si hay requisitos de auditoría que exigen búsqueda de texto libre en todo el histórico con tiempos de respuesta garantizados.
  • Si se necesitan agregaciones complejas sobre campos de log de forma habitual.

Plan de migración de bajo riesgo:

  1. Desplegar Loki en paralelo, sin tocar EFK.
  2. Configurar Fluent Bit con dos salidas simultáneas durante dos semanas.
  3. Reconstruir en Grafana los cuadros de mando de errores que estaban en Kibana.
  4. Validar con un incidente real que Loki responde a las preguntas necesarias.
  5. Retirar EFK, conservando una copia de los índices de producción hasta agotar la retención acordada con el responsable de cumplimiento normativo.

Ese último punto no es un detalle: no se puede borrar EFK antes de tiempo si su retención está comprometida con cumplimiento.

Conclusión

Rutas Norte ya no pierde su historia. En esta lección hemos:

  • Entendido por qué kubectl logs no basta: se pierde al recrear el pod, no busca entre componentes, no correlaciona y desaparece con el nodo. Y hemos visto un límite adicional poco conocido: con la rotación por defecto del kubelet, un componente verboso conserva menos de seis horas de logs en el nodo.
  • Visto cómo funciona el registro por debajo: la aplicación escribe a stdout, el runtime lo guarda en /var/log/containers/ con un nombre parlante del que se extraen pod, namespace y contenedor, y el kubelet lo rota.
  • Dominado kubectl logs como herramienta de primera línea, con --previous como el flag decisivo ante un CrashLoopBackOff.
  • Montado la arquitectura de recolección por nodo sobre el DaemonSet que desplegamos en 06-02: Fluent Bit como agente ligero en cada nodo y Fluentd como agregador central, con el filtro kubernetes que enriquece cada línea y el reensamblado multilínea que evita que una excepción se convierta en veinte documentos inconexos.
  • Configurado Elasticsearch con índices por día, plantillas que distinguen keyword de text, y una política ILM con fases caliente, templada, fría y borrado; y hemos dimensionado la pila con números realistas: unos 24 GB de RAM y 300 GB de disco solo para poder leer logs.
  • Usado Kibana con KQL para pasar de una alerta a la causa raíz en cinco minutos, siguiendo un flujo de trabajo concreto: acotar, ver la forma, identificar el componente, encontrar el mensaje dominante, leer la traza y correlacionar hacia atrás.
  • Adoptado los logs estructurados en JSON como la decisión de mayor rentabilidad, con el estándar de campos de Rutas Norte y el traza_id que permite seguir una petición por los seis componentes; y hemos cerrado el círculo del adaptador de logs de worker-notificaciones que introdujimos en 06-04.
  • Y, sobre todo, hemos establecido qué no debe registrarse nunca: credenciales, datos de pago y datos personales de los clientes. Con cuatro capas de defensa —el código, la librería, el recolector y el control de acceso— sabiendo que solo la primera es realmente fiable, y con la advertencia expresa de que la configuración de registro debe ser revisada y aprobada por el responsable de cumplimiento normativo antes de llegar a producción.
  • Comparado el coste real de EFK con las alternativas más ligeras, con la conclusión honesta de que para una plataforma del tamaño de Rutas Norte, Loki sería hoy la elección más sensata.

Ya tenemos las tres señales: las sondas dicen si un componente está sano, las métricas dicen cuánto y cómo funciona, y los logs dicen exactamente qué pasó. Lo que aún no tenemos es un método para usarlas juntas.

Porque cuando a las 03:14 la tienda web devuelve 502 y suena el teléfono, saber usar Grafana y Kibana no basta: hace falta un procedimiento que vaya del síntoma a la causa sin dar vueltas, saber que los eventos de Kubernetes caducan en una hora y hay que capturarlos antes, y conocer una tabla mental de síntoma → causa probable → comando que lo confirma. En 07-06, la última lección del módulo, construiremos esa metodología y la aplicaremos, paso a paso, a un incidente real de Rutas Norte.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados