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
- Por qué
kubectl logsno basta - Cómo funciona el registro en Kubernetes por debajo
kubectl logsa fondo: la herramienta de primera línea y sus límites- La arquitectura del patrón de recolección por nodo
- Fluentd frente a Fluent Bit
- La configuración real del recolector
- Elasticsearch: índices, plantillas y ciclo de vida
- Kibana: patrón de índice, KQL y cuadro de mando de errores
- Logs estructurados en JSON: la decisión que más rentabilidad da
- Correlación entre logs y métricas
- Qué NO se debe registrar nunca
- Alternativas más ligeras y el coste real de una pila de registro
- Errores comunes y consejos
- Ejercicios
- Por qué
kubectl logs no basta
kubectl logs no bastakubectl 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.
- 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
- El proceso escribe una línea en
stdout. - El runtime de contenedores (containerd o CRI-O) captura esa salida.
- El runtime la escribe en un fichero del nodo, en formato CRI.
- El kubelet gestiona ese fichero: lo rota y crea enlaces simbólicos con nombre parlante.
kubectl logspide 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/<ns>_<pod>_<uid>/<contenedor>/0.log"]
F -.enlace simbólico.-> L["/var/log/containers/<pod>_<ns>_<contenedor>-<id>.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:
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:
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
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 conservarCon 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.
kubectl logs a fondo: la herramienta de primera línea y sus límites
kubectl logs a fondo: la herramienta de primera línea y sus límitesAunque 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=10El 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 -rnSus 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ó.
- 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:
- Leer los ficheros de
/var/log/containers/, siguiendo las escrituras nuevas y recordando por dónde iba tras un reinicio. - Parsear el formato CRI y, si el contenido es JSON, expandirlo en campos.
- 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.
- 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.
- 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 |
- 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-configNota de seguridad, que retomaremos en el módulo 8. Este DaemonSet monta
hostPathsobre el sistema de ficheros del nodo. Aunque sea en solo lectura, cualquiera que pueda ejecutar unexecen 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:5Para 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: 30sY 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"
- 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 borrarseVentajas 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 GBY 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: 100GiTotal 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.
- 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:
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 >= 500Comparació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:
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:
Paso 4 — Encontrar el mensaje dominante. Clic en mensaje.keyword:
connection pool exhausted 4102
timeout acquiring connection from pool 619
Error: read ECONNRESET 100Paso 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:
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.
- 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 exhaustedProblemas 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
4821mses texto: no se puede consultarduracion_ms > 1000ni 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í:
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 | Sí | Momento del evento |
nivel |
debug|info|warn|error|fatal |
Sí | Severidad, en minúsculas |
componente |
cadena | Sí | Nombre del componente, igual que la etiqueta app |
traza_id |
UUID | Sí en peticiones | Identificador que sigue la petición entre componentes |
mensaje |
cadena | Sí | 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:
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=30012msDos 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.
- 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.
- 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-Santanderacaba 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
endEse campo _enmascarado es más valioso de lo que parece. Con una consulta en Kibana:
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:
- Documentar por escrito qué campos se registran de cada componente y presentarlo a la persona responsable de cumplimiento.
- Acordar con ella el plazo de retención de cada índice, y aplicarlo en la política ILM.
- Definir quién tiene acceso a los índices de producción, y revisarlo periódicamente.
- Auditar con la consulta
_enmascarado: truequé componentes siguen emitiendo datos sensibles y corregirlos en el código.- 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.
- 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
infoen producción,debugsolo enrutas-norte-dev. access_log off;para el endpoint de salud de nginx, como ya hicimos en 07-01.- Retención más corta para
devypre(7 días) que parapro(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- Enumera todos los problemas de esta línea, incluidos los de protección de datos.
- Escribe la configuración de nginx que emita el mismo evento en JSON, conforme al estándar de campos de Rutas Norte.
- Escribe el parser de Fluent Bit necesario si no pudieras cambiar la configuración de nginx.
- ¿Qué harías con el parámetro
dnide 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.
- Escribe la consulta KQL que localiza los documentos afectados.
- Enumera, por orden de prioridad, las acciones a tomar en las próximas 24 horas.
- Escribe el filtro de enmascarado que evita que vuelva a ocurrir, y explica por qué no es suficiente.
- ¿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.
- Recalcula el volumen diario y el almacenamiento necesario para 30 días.
- Propón tres medidas para reducir el volumen sin perder capacidad de diagnóstico, con una estimación del ahorro de cada una.
- Compara EFK y Loki para este escenario concreto, con números.
- 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.821está 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 deapi-reservas. - No hay
componenteninivel: 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-Agentcontribuye 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:
$urien vez de$request:$uries 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=jsones obligatorio: sin él, unUser-Agentcon comillas rompe el JSON y Fluent Bit no puede parsearlo.access_log offen 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:floatY 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
end4. 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: trueY 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.
- 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.
- Restringir el acceso a los índices afectados solo al equipo que gestiona el incidente, mediante roles de Elasticsearch.
- 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.
- 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.
- Verificar que los documentos nuevos ya llegan enmascarados, comprobando el índice del día.
Hora 4-12 — Corregir el origen.
- Localizar en el código las llamadas al logger que emiten el correo. En
worker-notificacionesestá en el log SMTP heredado; enapi-reservas, probablemente en unlog.info(reserva)que vuelca el objeto entero. - Corregir el código: sustituir la dirección por un hash, como hace el adaptador del apartado 9.
- Añadir
redacten la configuración del logger como red de seguridad de capa 2. - Desplegar a
prey luego apro.
Hora 12-24 — Sanear y prevenir.
- 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.
- 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.
- 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
endPor qué no es suficiente, en cuatro razones:
- 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. - 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.
- 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. - 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 €/mesUn 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
endEl 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:
- 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.
- 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.
- 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.
- 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.
- 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:
- Desplegar Loki en paralelo, sin tocar EFK.
- Configurar Fluent Bit con dos salidas simultáneas durante dos semanas.
- Reconstruir en Grafana los cuadros de mando de errores que estaban en Kibana.
- Validar con un incidente real que Loki responde a las preguntas necesarias.
- 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 logsno 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 logscomo herramienta de primera línea, con--previouscomo el flag decisivo ante unCrashLoopBackOff. - 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
kubernetesque 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
keyworddetext, 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_idque permite seguir una petición por los seis componentes; y hemos cerrado el círculo del adaptador de logs deworker-notificacionesque 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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
