As the previous lesson closed we were left with a question metrics cannot answer. When BookingsApiHighErrorRate fires at 03:14, we know that 7 % of requests are failing, on which route and with what latency. What we do not know is why: the specific exception, the message PostgreSQL returned, the line of code that blew up. That does not live in a metric. It lives in the logs.

And today, at Rutas Norte, the logs are an organised disaster: they are scattered across the nodes, they are lost when a pod is recreated, there is no way to search across components and they disappear entirely if a node dies. In this lesson we build the complete stack we announced in 06-02 when we deployed the collector as a DaemonSet: Elasticsearch, Fluentd and Kibana. We will see how logging works under the hood in Kubernetes, how logs are collected and enriched, why structured JSON is the single decision that improves the whole system most, and — critically for a platform that stores its customers' ID numbers and phone numbers — what must never end up written in a log.

Contents

  1. Why kubectl logs is not enough
  2. How logging works under the hood in Kubernetes
  3. kubectl logs in depth: the front-line tool and its limits
  4. The architecture of the per-node collection pattern
  5. Fluentd versus Fluent Bit
  6. The real collector configuration
  7. Elasticsearch: indices, templates and life cycle
  8. Kibana: index pattern, KQL and an error dashboard
  9. Structured JSON logs: the decision with the best return
  10. Correlating logs and metrics
  11. What must NEVER be logged
  12. Lighter alternatives and the real cost of a logging stack
  13. Common mistakes and tips
  14. Exercises

  1. Why kubectl logs is not enough

kubectl logs is the first tool we learned in 01-05 and it remains extremely useful. But it has four structural limits that make it insufficient as soon as the platform grows.

It is lost when the pod is recreated. A container's logs live on the node's filesystem, tied to the pod. When the pod is deleted — a deployment, an eviction, a scale-down — the files are deleted with it. If bookings-api went into CrashLoopBackOff at 03:14 and by 08:00 somebody had already restarted it by hand, the evidence is gone.

It does not search across components. A customer reports that their purchase failed. The request went through web-store, then through bookings-api, which queried redis-cache and bookings-postgres, called the external gateway and queued a message for notifications-worker. That is six different kubectl logs, on different pods, each with its own format and with no way of knowing which line in one matches which line in another.

It does not correlate. Even if you open the six terminals, you will have to line up timestamps by eye. With six bookings-api replicas you do not even know which one the request landed on.

It disappears with the node. If rutas-norte-worker-2 dies, all its logs die with it. And a node dying is exactly when they are most needed.

To those four you can add one of governance: there is no defined retention and no granular access control. Anybody with the pods/log permission sees everything the applications write, including the data that should not be there. We will come back to this in section 11.

  1. How logging works under the hood in Kubernetes

Before collecting anything you have to understand where the logs physically are. Without this, the collector's configuration is magic.

The contract: stdout and stderr

Kubernetes defines no logging API for applications. The contract is Docker's and the twelve-factor one:

The application writes its logs to standard output (stdout) and standard error (stderr). It manages no files, no rotation and no destinations.

An application that writes to /var/log/myapp.log inside the container is doing it wrong: that file is invisible to Kubernetes, grows uncontrolled inside the container and disappears on restart.

The data's journey

  1. The process writes a line to stdout.
  2. The container runtime (containerd or CRI-O) captures that output.
  3. The runtime writes it to a file on the node, in CRI format.
  4. The kubelet manages that file: it rotates it and creates symbolic links with meaningful names.
  5. kubectl logs asks the kubelet to read that file and returns it.
flowchart TD
    APP["bookings-api process<br/>console.log(...)"] -->|stdout| RT[containerd]
    RT -->|writes| F["/var/log/pods/&lt;ns&gt;_&lt;pod&gt;_&lt;uid&gt;/&lt;container&gt;/0.log"]
    F -.symbolic link.-> L["/var/log/containers/&lt;pod&gt;_&lt;ns&gt;_&lt;container&gt;-&lt;id&gt;.log"]
    KL[kubelet] -->|rotates| F
    KUBECTL["kubectl logs"] --> KL
    KL --> F
    L --> REC["Collector<br/>DaemonSet from 06-02"]

The node's files

Let us go into one of our minikube nodes and look at them:

minikube ssh -p rutas-norte
sudo ls -la /var/log/containers/ | head -8
bookings-api-7d9f8c4b5-x2klm_rutas-norte-pro_api-3f8a2c...9b1.log -> /var/log/pods/rutas-norte-pro_bookings-api-7d9f8c4b5-x2klm_a4f2.../api/0.log
bookings-postgres-0_rutas-norte-pro_postgres-7c2d...4e8.log -> /var/log/pods/...
bookings-postgres-0_rutas-norte-pro_metrics-exporter-1b9f...2a7.log -> /var/log/pods/...
web-store-6c8b9d7f4-hj3ks_rutas-norte-pro_nginx-5d1e...8c3.log -> /var/log/pods/...
notifications-worker-5f7c8b9d4-tz2mv_rutas-norte-pro_worker-9a4b...6f2.log -> ...
notifications-worker-5f7c8b9d4-tz2mv_rutas-norte-pro_log-adapter-2c8d...1e5.log -> ...

The file name is the key to everything. Its structure is:

<pod-name>_<namespace>_<container-name>-<container-id>.log

From it the collector extracts, without asking anybody, the pod, the namespace and the container of every line. It is what makes the enrichment in section 6 possible.

Note too that bookings-postgres-0 has two files: one for the postgres container and another for the metrics-exporter sidecar we added in 06-04. Each container has its own log stream.

The CRI format

sudo tail -3 /var/log/containers/bookings-api-*_rutas-norte-pro_api-*.log
2026-08-06T03:14:22.183947621Z stdout F {"level":"info","message":"request served","route":"/api/routes"}
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)

Each line has four space-separated fields:

Field Example Meaning
Timestamp 2026-08-06T03:14:22.183947621Z RFC3339 with nanoseconds, always in UTC
Stream stdout / stderr Where it came from
Tag F / P F = full line, P = partial
Content The rest What the application wrote

The P tag appears when a line exceeds 16 KB: the runtime splits it. A badly configured collector will treat each fragment as a separate line. Fluentd and Fluent Bit know how to reassemble them, but it has to be enabled.

The kubelet's rotation

Without rotation, a chatty container would fill the node's disk and cause pod evictions. The kubelet handles it with two parameters in its configuration:

# /var/lib/kubelet/config.yaml
containerLogMaxSize: 10Mi     # maximum size per file before rotating
containerLogMaxFiles: 5       # how many rotated files to keep

With the default values, each container keeps at most 50 MB of logs on the node. bookings-api under load generates around 200 MB a day per replica: that means it keeps less than six hours of history.

The direct and very concrete consequence: if the incident happened at 03:14 and you look at 11:00, the logs have already rotated and no longer exist, even if the pod has not restarted. It is an additional reason, on top of the four in section 1, to centralize.

  1. kubectl logs in depth: the front-line tool and its limits

Even with EFK in place, kubectl logs is still the first thing you run when there is a problem. It is worth mastering.

# The basics
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm

# A specific container of a multi-container pod (essential since 06-04)
kubectl -n rutas-norte-pro logs bookings-postgres-0 -c metrics-exporter

# THE MOST IMPORTANT FLAG: logs of the container's PREVIOUS run.
# When a pod is in CrashLoopBackOff, the current container has just
# started and has nothing useful. The cause is in the run that died.
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm --previous

# Follow in real time
kubectl -n rutas-norte-pro logs -f bookings-api-7d9f8c4b5-x2klm

# Only the recent stuff: the last 100 lines, or the last 15 minutes
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm --tail=100
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm --since=15m
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm --since-time="2026-08-06T03:10:00Z"

# With timestamps added by kubectl (useful if the app does not add them)
kubectl -n rutas-norte-pro logs bookings-api-7d9f8c4b5-x2klm --timestamps

# SEVERAL PODS AT ONCE via a label selector: all 6 replicas together
kubectl -n rutas-norte-pro logs -l app=bookings-api --tail=50 --prefix

# Every container in a pod, sidecars included
kubectl -n rutas-norte-pro logs bookings-postgres-0 --all-containers=true

# Every platform component in the environment
kubectl -n rutas-norte-pro logs -l app.kubernetes.io/part-of=rutas-norte \
  --tail=20 --prefix --max-log-requests=10

The --prefix flag puts the pod name in front of each line, essential when using -l:

[pod/bookings-api-7d9f8c4b5-x2klm/api] {"level":"error","message":"pool exhausted"}
[pod/bookings-api-7d9f8c4b5-mn8pq/api] {"level":"info","message":"request served"}

Combinations that are useful day to day:

# Only the errors of the last two hours, across every replica
kubectl -n rutas-norte-pro logs -l app=bookings-api --since=2h --prefix | grep -i error

# Count errors by type
kubectl -n rutas-norte-pro logs -l app=bookings-api --since=1h | \
  jq -r 'select(.level=="error") | .message' | sort | uniq -c | sort -rn

Its limits, now made explicit

Limit Practical consequence
Only the current and previous pod A pod recreated three times has lost the first two runs
Only what survives rotation Less than 6 h of history on chatty components
-l has a maximum of concurrent requests With 40 pods, --max-log-requests falls short
No structured search grep over plain text, with no filtering by field
No aggregation Impossible to count "how many errors per hour over the last week"
No guaranteed retention No use for auditing or regulatory compliance

Rule of use: kubectl logs for what is happening now; the centralized stack for what happened.

  1. The architecture of the per-node collection pattern

There are three ways to collect logs in Kubernetes. Only one is right for the general case.

Pattern How it works When to use it
Per-node agent A DaemonSet reads the files in /var/log/containers The standard. One agent per node, works for every application
Streaming sidecar An extra container per pod reads logs and re-emits them Only if the application writes to a file and cannot be changed
Push from the application The application sends straight to the store Almost never: it couples the application to the backend

We choose the first, and we already have it deployed: in 06-02, while studying DaemonSets, we deployed a log collector with one pod per node, tolerations for the control plane and a hostPath mounted over /var/log/containers, announcing that the complete stack would be built here. The moment has arrived.

flowchart TB
    subgraph N1["Node rutas-norte-worker-1"]
        P1["Pods: bookings-api,<br/>web-store"] -->|stdout| F1["/var/log/containers/*.log"]
        F1 --> FB1["Fluent Bit<br/>(DaemonSet, 06-02)"]
    end
    subgraph N2["Node rutas-norte-worker-2"]
        P2["Pods: bookings-postgres,<br/>notifications-worker"] -->|stdout| F2["/var/log/containers/*.log"]
        F2 --> FB2["Fluent Bit"]
    end
    subgraph N3["Node rutas-norte-worker-3"]
        P3["Pods: redis-cache,<br/>occupancy-reports"] -->|stdout| F3["/var/log/containers/*.log"]
        F3 --> FB3["Fluent Bit"]
    end
    FB1 --> AGG["Fluentd aggregator<br/>(Deployment)<br/>heavy parsing,<br/>masking, buffering"]
    FB2 --> AGG
    FB3 --> AGG
    API[(API Server)] -.metadata.-> FB1
    API -.metadata.-> FB2
    API -.metadata.-> FB3
    AGG --> ES[("Elasticsearch<br/>StatefulSet")]
    ES --> KB["Kibana<br/>Deployment"]
    KB --> U["On-call person"]

The collector's four responsibilities:

  1. Read the files in /var/log/containers/, following new writes and remembering where it had got to after a restart.
  2. Parse the CRI format and, if the content is JSON, expand it into fields.
  3. Enrich with the Kubernetes metadata: namespace, pod, container, node, pod labels and annotations. These are not in the file: they are obtained by querying the API using the file name.
  4. Send to the store, with buffering and retries so that nothing is lost if the destination goes down.

Enrichment is what turns a line of text into a queryable document:

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

Now you really can search for "every bookings-api error in pro between 03:00 and 04:00", which is what we needed.

A two-tier architecture

We have put an aggregator between the agents and Elasticsearch. It is not mandatory, but in production it is justified:

  • The node agents (Fluent Bit) stay extremely light: just read and forward.
  • The heavy parsing, the masking of sensitive data and the routing happen in a single place, easier to audit and to change.
  • Elasticsearch receives connections from 3 aggregators instead of 30 agents, which greatly reduces the pressure.
  • The aggregator acts as a shock absorber: if Elasticsearch goes down for half an hour, the aggregator's buffer holds the data.

  1. Fluentd versus Fluent Bit

Both are CNCF projects and from the same family. The confusion about which to use is constant.

Aspect Fluentd Fluent Bit
Language Ruby (with parts in C) Pure C
Memory at rest ~40–100 MB ~2–5 MB
CPU Noticeably higher Very low
Plugins available More than 1000 ~100 (the important ones are there)
Extensibility Ruby gems, very flexible Plugins in C or Go, or Lua filters
Configuration XML-like directives Classic (INI) or YAML
Typical role Aggregator Per-node agent
Throughput Thousands of events/s Tens of thousands of events/s

The rule of thumb the industry follows:

Fluent Bit as the agent on every node (DaemonSet): it consumes almost nothing, and multiplied by 30 nodes that difference matters a great deal.

Fluentd as the central aggregator (Deployment): where you need the flexibility of the thousand plugins, complex routing and expensive transformations.

For Rutas Norte with three nodes, honestly, Fluent Bit alone would be enough. We build both tiers because the masking of personal data in section 11 benefits greatly from being centralized, and because it is the architecture you will find in any serious cluster.

A cost comparison in our cluster:

Configuration Total memory Note
Fluentd as a DaemonSet (3 nodes) ~300 MB Needlessly expensive
Fluent Bit as a DaemonSet (3 nodes) ~30 MB 10 times less
Fluent Bit + Fluentd aggregator ~30 MB + 512 MB The extra is concentrated and controlled

  1. The real collector configuration

Fluent Bit as a DaemonSet

We pick up and complete the DaemonSet from 06-02:

# k8s/base/logging/fluent-bit-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: logging
  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        # it needs to read pods from the API
      # Tolerations so it also deploys on the control plane, as we
      # saw in 06-02: we are interested in its logs too.
      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:
            # The node's logs, READ ONLY
            - name: varlog
              mountPath: /var/log
              readOnly: true
            # The real files the symbolic links point to
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
            - name: config
              mountPath: /fluent-bit/etc/
            # Read positions: they survive the pod restarting so that it does
            # not resend everything from the beginning.
            - name: positions
              mountPath: /var/fluent-bit/state
      volumes:
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers
        - name: positions
          hostPath:
            path: /var/fluent-bit/state
            type: DirectoryOrCreate
        - name: config
          configMap:
            name: fluent-bit-config

A security note, which we will pick up again in module 8. This DaemonSet mounts a hostPath over the node's filesystem. Even read-only, anybody who can run an exec in this pod can read the logs of every container on the node, including those of other namespaces. The collector is a high-value target: it must have its own namespace, minimal RBAC and very restricted access.

The Fluent Bit configuration

# k8s/base/logging/fluent-bit-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: logging
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        # exposes metrics for Prometheus (07-03)

    # =====================================================================
    # INPUT: read the container files
    # =====================================================================
    [INPUT]
        Name              tail
        Tag               kube.*
        Path              /var/log/containers/*.log
        # Do not read the collector's own logs: a guaranteed infinite loop
        Exclude_Path      /var/log/containers/fluent-bit*.log,/var/log/containers/*_logging_*.log
        Parser            cri
        # File where it remembers where it had got to in each log
        DB                /var/fluent-bit/state/positions.db
        Mem_Buf_Limit     32MB
        Skip_Long_Lines   On
        Refresh_Interval  10
        # REASSEMBLE lines split by the runtime (the P tag of the
        # CRI format). Without this, a 20 KB line arrives in pieces.
        multiline.parser  cri

    # =====================================================================
    # FILTER 1: enrich with Kubernetes metadata
    # =====================================================================
    [FILTER]
        Name                kubernetes
        Match               kube.*
        Kube_URL            https://kubernetes.default.svc:443
        Kube_Tag_Prefix     kube.var.log.containers.
        # Queries the API to obtain the pod's labels and annotations
        Merge_Log           On
        Merge_Log_Key       processed_log
        Keep_Log            Off
        K8S-Logging.Parser  On
        K8S-Logging.Exclude On
        Labels              On
        Annotations         Off
        Buffer_Size         32k

    # Merge_Log On is the most profitable line in the whole configuration:
    # if the log content is valid JSON, it EXPANDS it into fields instead
    # of leaving it as a string. It is what makes section 9 worthwhile.

    # =====================================================================
    # FILTER 2: reassemble multi-line exception stack traces
    # =====================================================================
    [FILTER]
        Name                  multiline
        Match                 kube.*
        multiline.key_content log
        multiline.parser      java_exception, node_exception

    # =====================================================================
    # FILTER 3: mask sensitive data (see section 11)
    # =====================================================================
    [FILTER]
        Name    lua
        Match   kube.*
        script  /fluent-bit/etc/mask.lua
        call    mask_personal_data

    # =====================================================================
    # OUTPUT: to the Fluentd aggregator
    # =====================================================================
    [OUTPUT]
        Name          forward
        Match         kube.*
        Host          fluentd-aggregator.logging.svc.cluster.local
        Port          24224
        # On-disk buffer: if the aggregator goes down, nothing is lost
        storage.total_limit_size  2G
        Retry_Limit   False

  parsers.conf: |
    # Parser for the CRI format: the four fields from section 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

    # Multi-line for Java stack traces: a new line starts with a date;
    # those starting with spaces+at or with Caused by are continuations.
    [MULTILINE_PARSER]
        Name          java_exception
        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"

    # Multi-line for Node.js stack traces
    [MULTILINE_PARSER]
        Name          node_exception
        Type          regex
        Flush_Timeout 1000
        Rule          "start_state"  "/^(Error|TypeError|ReferenceError)/" "cont"
        Rule          "cont"         "/^\s+at\s/"                          "cont"

The multi-line stack trace problem

This is the detail that causes the most frustration and that is most appreciated when solved.

A Node.js exception reaches the log like this:

Error: connection pool exhausted
    at Pool.connect (/app/node_modules/pg-pool/index.js:200:35)
    at BookingsRepo.find (/app/src/repos/bookings.js:47:22)
    at async BookingsCtrl.create (/app/src/ctrl/bookings.js:88:18)
    at async /app/src/routes/bookings.js:31:5

To the runtime these are five independent lines. Without the multi-line filter, Elasticsearch receives five documents:

  • One with Error: connection pool exhausted, with no stack trace at all.
  • Four with stack trace fragments, with no context about which error produced them.

And in Kibana, sorted by date among the logs of another five pods, reconstructing the exception becomes impossible. With twenty lines of stack trace, the problem multiplies.

The multiline filter recognises that lines starting with spaces and at are continuations of the previous one and joins them into a single document with the complete stack trace in one field. It is the difference between being able to debug and not.

The Fluentd aggregator

# k8s/base/logging/fluentd-aggregator-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluentd-aggregator-config
  namespace: logging
data:
  fluent.conf: |
    # Input: receives from every Fluent Bit in the cluster
    <source>
      @type forward
      port 24224
      bind 0.0.0.0
    </source>

    # Routing by namespace: separating environments into different indices
    # allows different retentions and permissions per environment.
    <match kube.**>
      @type rewrite_tag_filter
      <rule>
        key $.kubernetes.namespace_name
        pattern /^rutas-norte-pro$/
        tag production.${tag}
      </rule>
      <rule>
        key $.kubernetes.namespace_name
        pattern /^rutas-norte-(dev|pre)$/
        tag nonproduction.${tag}
      </rule>
      <rule>
        key $.kubernetes.namespace_name
        pattern /.+/
        tag system.${tag}
      </rule>
    </match>

    # Output to Elasticsearch, with indices split by environment and by day
    <match production.**>
      @type elasticsearch
      host elasticsearch.logging.svc.cluster.local
      port 9200
      scheme https
      ssl_verify true
      user "#{ENV['ES_USER']}"
      password "#{ENV['ES_PASSWORD']}"

      # Writes to a data stream managed by ILM (section 7)
      index_name rutasnorte-pro
      suppress_type_name true

      <buffer>
        @type file
        path /var/log/fluentd/buffer/production
        # On-disk buffer: if Elasticsearch goes down, it piles up here
        total_limit_size 8GB
        chunk_limit_size 16MB
        flush_interval 10s
        retry_type exponential_backoff
        retry_max_interval 60
        retry_forever true          # never discard production logs
        overflow_action block
      </buffer>
    </match>

    <match nonproduction.**>
      @type elasticsearch
      host elasticsearch.logging.svc.cluster.local
      port 9200
      index_name rutasnorte-nonprod
      <buffer>
        @type file
        path /var/log/fluentd/buffer/nonprod
        total_limit_size 2GB
        flush_interval 30s
        retry_forever false         # here we can afford to discard
      </buffer>
    </match>

The retry_forever true in production and false outside it is a conscious design decision: losing rutas-norte-dev logs during an Elasticsearch outage is acceptable; losing production ones is not.

Monitoring the collector with what we learned in 07-03

The collector is critical infrastructure: if it fails silently, you lose your logs without noticing.

apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: fluent-bit
  namespace: logging
spec:
  selector:
    matchLabels:
      app: fluent-bit
  podMetricsEndpoints:
    - port: http-metrics       # port 2020 from the [SERVICE] block
      path: /api/v1/metrics/prometheus
      interval: 30s

And the corresponding alert, following the criteria from 07-04:

- alert: LogCollectorDroppingRecords
  expr: |
    rate(fluentbit_output_retries_failed_total[10m]) > 0
  for: 15m
  labels:
    severity: warning
    team: platform
  annotations:
    summary: "Fluent Bit is dropping records on {{ $labels.node }}"
    description: >
      The collector cannot deliver logs and is losing them.
      Check the aggregator and Elasticsearch. Without logs, diagnosing
      any other incident will be done blind.
    runbook_url: "https://runbooks.rutasnorte.example/log-collector"

  1. Elasticsearch: indices, templates and life cycle

Elasticsearch is a distributed search engine that indexes JSON documents. For our purposes, each enriched log line is a document.

Indices by day

Logs are time-based data that age: today's are queried constantly, those from three weeks ago hardly ever, and those from a year ago probably never. That is why they are not all kept in one index, but in daily indices:

rutasnorte-pro-2026.08.06     ← today's, being written actively
rutasnorte-pro-2026.08.05
rutasnorte-pro-2026.08.04
...
rutasnorte-pro-2026.07.08     ← the oldest, about to be deleted

The decisive advantages of this split:

  • Deleting is instantaneous. Removing a whole index is a metadata operation. Deleting individual documents inside a large index is extremely slow and expensive.
  • Date queries only touch the necessary indices. Searching the last 24 hours does not traverse 30 days of data.
  • Each index can have a different configuration: the recent ones on fast disks, the old ones compressed.

Index templates

A template defines how any new index matching a pattern is configured. Without one, Elasticsearch guesses each field's type, and it guesses wrong.

PUT _index_template/rutasnorte-logs
{
  "index_patterns": ["rutasnorte-pro-*", "rutasnorte-nonprod-*"],
  "priority": 200,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1,
      "refresh_interval": "30s",
      "index.lifecycle.name": "rutasnorte-logs-policy",
      "index.lifecycle.rollover_alias": "rutasnorte-pro"
    },
    "mappings": {
      "properties": {
        "@timestamp":  { "type": "date" },
        "level":       { "type": "keyword" },
        "component":   { "type": "keyword" },
        "trace_id":    { "type": "keyword" },
        "message":     { "type": "text" },
        "duration_ms": { "type": "long" },
        "http_code":   { "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" },
                "environment": { "type": "keyword" }
              }
            }
          }
        }
      }
    }
  }
}

The distinction between keyword and text is fundamental and confuses everybody:

Type How it is indexed Good for Example
keyword The exact value, not broken up Filtering, aggregating, sorting level: "error", pod_name
text Broken into words (analysed) Free-text search message

If level were text, you could not run a "how many logs per level" aggregation, which is exactly what you want on a dashboard. If message were keyword, you could not search for "pool" inside "connection pool exhausted".

refresh_interval: 30s is an important performance setting: the default is 1 second, which forces Elasticsearch to make every document visible almost instantly, at a high cost. For logs, a 30-second delay is perfectly acceptable and multiplies write throughput.

Life cycle (ILM)

Index lifecycle management automates ageing. Without it, somebody has to remember to delete old indices, and nobody remembers until the disk fills up.

PUT _ilm/policy/rutasnorte-logs-policy
{
  "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": { "node_type": "cold" }
          },
          "set_priority": { "priority": 0 }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": { "delete": {} }
      }
    }
  }
}
Phase When What is done Why
Hot Day 0–3 Active writing, replicas, high priority It is queried constantly
Warm Day 3–14 Read-only, no replica, merged segments Queried sometimes; saves 50 % of space
Cold Day 14–30 Moved to nodes with slow, cheap disks Queried almost never
Delete Day 30 Index removed It no longer contributes

The critical point: the delete phase. The 30 days are not a technical number, they are a business and regulatory compliance decision. If the logs contain personal data (which they should not, but which is what happens in practice), the retention must match what the applicable regulation requires and what the company's data protection officer has defined. We come back to this in section 11.

A realistic minimum sizing

This is the part that always gets underestimated.

Estimate for Rutas Norte:
  6 components × ~4 average replicas = 24 containers
  Average volume: 300 lines/minute per container during active hours
  Average size of an enriched document: ~800 bytes

  24 × 300 × 60 × 16 h ≈ 6.9 million documents a day
  6.9 M × 800 B ≈ 5.5 GB/day raw
  With Elasticsearch indexing overhead (×1.3): ~7.2 GB/day
  With 1 replica in the hot phase: ~14 GB/day for the first 3 days

  30-day retention:
    3 hot days with a replica: 43 GB
    27 warm/cold days with no replica: 194 GB
    TOTAL ≈ 240 GB, plus 25 % operational headroom → 300 GB

And the minimum compute resources:

Component Realistic minimum for Rutas Norte
Elasticsearch 3 nodes (for quorum), 4 GB of heap each (8 GB of RAM), 100 GB of disk each
Fluentd aggregator 2 replicas, 512 MB of RAM, 10 GB of disk for buffering
Fluent Bit 1 per node, 64–192 MB of RAM
Kibana 1 replica, 1 GB of RAM

Elasticsearch needs three nodes, not one. With a single node there is no fault tolerance and the whole logging stack goes down when that pod restarts. With two there is a risk of split-brain. Three is the real minimum.

The golden rule for the heap: allocate at most 50 % of the container's memory and never more than 31 GB (above that threshold the JVM loses pointer compression and performs worse with more memory).

# Fragment of the Elasticsearch StatefulSet
env:
  - name: ES_JAVA_OPTS
    value: "-Xms4g -Xmx4g"     # heap = half of the container's 8Gi
resources:
  requests:
    cpu: "1"
    memory: "8Gi"
  limits:
    memory: "8Gi"              # same as the request: QoS Guaranteed
volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      storageClassName: rutasnorte-fast     # the class from 05-04
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 100Gi

Approximate total: 24 GB of RAM and 300 GB of fast disk just to be able to read logs. That is the figure to put on the table before deciding, and the one that motivates section 12.

  1. Kibana: index pattern, KQL and an error dashboard

The data view

Before searching for anything you have to tell Kibana which indices to query. Under Stack Management → Data Views → Create data view:

Name:            Rutas Norte Production Logs
Index pattern:   rutasnorte-pro-*
Time field:      @timestamp

The time field is what enables the time range picker and the histograms. Without it, Kibana cannot sort chronologically.

Searching with KQL

KQL (Kibana Query Language) is the search bar's language. It is far simpler than PromQL and takes ten minutes to learn.

# Every error
level: "error"

# Errors from a specific component
level: "error" and kubernetes.labels.app: "bookings-api"

# A specific pod
kubernetes.pod_name: "bookings-api-7d9f8c4b5-x2klm"

# Free-text search in the message (a text field)
message: "pool exhausted"

# An exact phrase
message: "connection pool exhausted"

# Wildcards in the pod name: every replica
kubernetes.pod_name: bookings-api-*

# Negation: everything except the health probes (07-01), which are pure noise
kubernetes.labels.app: "bookings-api" and not route: ("/health" or "/ready")

# Numeric ranges: slow requests
duration_ms > 1000

# Combinations with brackets
(level: "error" or level: "fatal")
  and kubernetes.namespace_name: "rutas-norte-pro"
  and not message: "ECONNRESET"

# The existence of a field
trace_id: *

# 5xx errors
http_code >= 500

A quick comparison with what we already know:

I want to... In PromQL (07-03) In KQL
Filter by label {app="bookings-api"} kubernetes.labels.app: "bookings-api"
Negate {code!="200"} not http_code: 200
Regular expression {route=~"/api/.*"} route: /api/*
Numeric range metric > 100 duration_ms > 100

The real workflow during an incident

Let us go back to the scenario from section 1: the BookingsApiHighErrorRate alert fires at 03:14.

Step 1 — Narrow it down in time. Range picker: 2026-08-06 03:00 to 2026-08-06 04:00.

Step 2 — See the shape of the problem. A broad query and a look at the histogram:

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

The histogram shows at a glance whether the errors started all at once (a deployment, an outage) or grew progressively (a resource running out).

Step 3 — Identify the component. In the fields panel, click on kubernetes.labels.app to see the distribution:

bookings-api          4821  (94.2%)
notifications-worker   287  (5.6%)
web-store               11  (0.2%)

Step 4 — Find the dominant message. Click on message.keyword:

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

Step 5 — Open a document and read the complete stack trace. Thanks to the multi-line filter in section 6, the exception is complete in a single document.

Step 6 — Correlate backwards. What happened just before the first error? Remove the level filter and look at the previous five minutes:

kubernetes.namespace_name: "rutas-norte-pro"

That is usually where the cause shows up: a deployment, a CronJob that started, a slow PostgreSQL query.

A complete diagnosis in five minutes. With kubectl logs across six pods, this would have taken an hour, and the logs would probably no longer exist.

An error dashboard

Under Dashboards → Create dashboard, with these visualizations:

Visualization Type Configuration
Errors over time Vertical bars X axis: @timestamp (5 min interval). Y axis: count. Split by: level
Errors by component Pie or horizontal bars Terms of kubernetes.labels.app, sorted by count
Most frequent error messages Table Terms of message.keyword, top 20, with the count
Errors by pod Heatmap X axis: time. Y axis: kubernetes.pod_name. Colour: count
Latest errors Document table Columns: time, component, pod, message. Sorted descending
Distribution by level Metric Count filtered by each level

The heatmap by pod is especially revealing: if the errors concentrate on one pod out of six, the problem belongs to that pod (a node with trouble, a replica with corrupt state). If they are spread evenly, the problem is systemic (the database, an external dependency). That distinction, almost impossible to see with kubectl logs, jumps out here in a second.

  1. Structured JSON logs: the decision with the best return

Everything above works far better if the applications write JSON rather than free text. It is, by a wide margin, the lowest-cost, highest-impact change in the whole lesson.

Before

2026-08-06 03:14:22 [ERROR] bookings-api - Failed to create booking for the Bilbao-Santander journey of user 48213 after 4821ms: connection pool exhausted

Problems with this line, which looks perfectly reasonable:

  • To filter by level you have to search for the substring [ERROR], which also matches a message saying "the user saw an [ERROR] on screen".
  • The duration 4821ms is text: you cannot query duration_ms > 1000 or calculate percentiles.
  • The user identifier is embedded in the sentence: you cannot filter by it.
  • There is no trace identifier: you cannot follow this request through the other components.
  • If somebody changes the message format tomorrow, every saved filter stops working.

After

{
  "timestamp": "2026-08-06T03:14:22.891Z",
  "level": "error",
  "component": "bookings-api",
  "trace_id": "8f3a2c91-4b7d-4e2a-9c15-7f8d3e1a6b04",
  "message": "Failed to create booking",
  "error_type": "PoolExhaustedError",
  "error_detail": "connection pool exhausted",
  "duration_ms": 4821,
  "route": "/api/bookings",
  "method": "POST",
  "http_code": 500,
  "journey_origin": "Bilbao",
  "journey_destination": "Santander",
  "app_version": "2.8.1"
}

Now you can write:

level: "error" and duration_ms > 3000 and error_type: "PoolExhaustedError"

And aggregations: the average duration of failed requests, the ten journeys with most errors, the evolution of error_type over time.

A critical note about what is not in that JSON: the customer's name does not appear, nor their ID number, nor their phone, nor their email. What appears is trace_id, an opaque identifier. If you need to know which customer it was, you cross-reference the trace_id with the database, in a system with access control. It is a deliberate decision, and section 11 explains why it is mandatory.

The Rutas Norte field standard

Every platform component must emit these fields:

Field Type Mandatory Description
timestamp ISO 8601 UTC with milliseconds Yes When the event happened
level debug|info|warn|error|fatal Yes Severity, in lower case
component string Yes The component's name, the same as the app label
trace_id UUID Yes on requests Identifier that follows the request across components
message string Yes A readable description, with no variable data embedded
error_type string If there is an error The exception's class
duration_ms integer Where applicable The operation's duration
app_version string Recommended The deployed version: allows correlation with a deployment

The rule about message: it must be constant for the same type of event, with the variable values in separate fields. "Failed to create booking" with duration_ms: 4821 is far more useful than "Failed to create booking after 4821ms", because it lets you aggregate by message.

Implementation in bookings-api

// logging.js — structured logger with pino
const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  // Writes to stdout: the Kubernetes contract from section 2
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: {
    level: (label) => ({ level: label }),         // keep our own field name
  },
  base: {
    component: 'bookings-api',
    app_version: process.env.APP_VERSION,
    environment: process.env.ENVIRONMENT,
  },
  // AUTOMATIC REDACTION: the last line of defence from section 11.
  // If somebody logs an object with these fields by mistake, they are replaced.
  redact: {
    paths: [
      'req.headers.authorization', 'req.headers.cookie',
      '*.password', '*.national_id', '*.phone', '*.email', '*.mail',
      '*.card', '*.cvv', '*.iban',
      'customer.first_name', 'customer.last_name',
    ],
    censor: '[REDACTED]',
  },
});

module.exports = logger;

And how it is used, with the trace identifier propagated:

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

// Middleware: each request gets a trace_id, or reuses the incoming one
app.use((req, res, next) => {
  req.traceId = req.headers['x-trace-id'] || randomUUID();
  res.setHeader('x-trace-id', req.traceId);
  // Child logger: EVERY log of this request will carry the trace_id
  req.log = logger.child({ trace_id: req.traceId });
  next();
});

app.post('/api/bookings', async (req, res) => {
  const start = Date.now();
  try {
    const booking = await createBooking(req.body);
    req.log.info({
      route: '/api/bookings',
      method: 'POST',
      http_code: 201,
      duration_ms: Date.now() - start,
      journey_origin: booking.origin,
      journey_destination: booking.destination,
      // Note: we do NOT log booking.customer.national_id, .phone or .email
    }, 'Booking created');
    res.status(201).json(booking);
  } catch (err) {
    req.log.error({
      route: '/api/bookings',
      method: 'POST',
      http_code: 500,
      duration_ms: Date.now() - start,
      error_type: err.constructor.name,
      error_detail: err.message,
      stack: err.stack,
    }, 'Failed to create booking');
    res.status(500).json({ error: 'internal error', trace_id: req.traceId });
  }
});

// When calling other components, propagate the trace_id
async function callPaymentGateway(req, data) {
  return fetch('https://pagos.proveedorexterno.example/charges', {
    method: 'POST',
    headers: { 'x-trace-id': req.traceId },
    body: JSON.stringify(data),
  });
}

Note the last detail: we return the trace_id to the client in the error body. When somebody calls customer support saying "I couldn't buy", that identifier locates the exact request and its whole journey in Kibana:

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

With no component filter: every log from every component that took part in that request, in order. That is real correlation.

The notifications-worker log adapter

In 06-04 we added a log adapter container to notifications-worker that converts its proprietary log to JSON. Here is where it becomes fully clear why.

notifications-worker uses an old mail-sending library that writes like this, and it cannot be modified without rewriting the component:

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

Two problems: it is not JSON, and it contains a customer's email address, which is personal data.

The adapter, a sidecar that reads that log and emits clean JSON:

# Fragment of the notifications-worker Deployment (06-04)
initContainers:
  - name: log-adapter
    image: registry.rutasnorte.example/log-adapter:1.3.0
    restartPolicy: Always        # native sidecar 1.29+
    args:
      - --input=/var/log/worker/smtp.log
      - --format=smtp-legacy
      - --output=stdout
      - --mask=email,phone               # masks BEFORE emitting
    volumeMounts:
      - name: worker-logs
        mountPath: /var/log/worker
    resources:
      requests:
        cpu: "10m"
        memory: "16Mi"
      limits:
        cpu: "50m"
        memory: "32Mi"

Its output:

{
  "timestamp": "2026-08-06T03:15:44.000Z",
  "level": "error",
  "component": "notifications-worker",
  "message": "Failed to send email",
  "operation": "smtp_send",
  "recipient_hash": "sha256:4f2a...9b1c",
  "subject_type": "booking_confirmation",
  "error_type": "SmtpTimeout",
  "duration_ms": 30012
}

Two key transformations: the format becomes queryable JSON, and the email address is replaced by a hash. The hash lets you answer "how many emails have failed for the same recipient?" without the address appearing in any index.

  1. Correlating logs and metrics

Logs and metrics are two views of the same system, and their value multiplies when they can be cross-referenced. The key is using the same labels in both.

Concept In Prometheus (07-03) In the logs
Component component="bookings-api" component: "bookings-api"
Environment environment="pro" kubernetes.labels.environment: "pro"
Namespace namespace="rutas-norte-pro" kubernetes.namespace_name: "rutas-norte-pro"
Pod pod="bookings-api-7d9f..." kubernetes.pod_name: "bookings-api-7d9f..."
Version version="2.8.1" app_version: "2.8.1"

With that correspondence, the investigation flow is direct:

flowchart LR
    A["Alert<br/>BookingsApiHighErrorRate"] --> B["Grafana: the graph<br/>shows the spike at 03:14"]
    B --> C["Copy pod, namespace<br/>and time window"]
    C --> D["Kibana: filter by those<br/>same values"]
    D --> E["Read the error message<br/>and the full stack trace"]
    E --> F["Filter by trace_id to<br/>see the WHOLE request"]

Three practical ways to link the two worlds:

1. A link from the Grafana panel. In the panel options, a Data link that opens Kibana with the filters already applied:

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

With that, a click on the spike in the graph takes you to that pod's logs in that exact window. It saves an enormous amount of time under pressure.

2. Metrics derived from logs. Fluentd can count events and expose them to Prometheus, which allows alerting on log patterns:

<match production.**>
  @type copy
  <store>
    @type elasticsearch
    # ... normal configuration
  </store>
  <store>
    @type prometheus
    <metric>
      name rutasnorte_logs_by_level_total
      type counter
      desc Records emitted by level and component
      <labels>
        level ${level}
        component ${component}
      </labels>
    </metric>
  </store>
</match>

Watch the cardinality, exactly as in 07-03: never use the full message as a label.

3. Grafana as a unified viewer. Grafana can add Elasticsearch as an additional data source and show a metrics panel and a logs panel on the same dashboard. With the Loki data source (section 12) the integration is even tighter.

What is missing to close the circle completely is distributed tracing (OpenTelemetry, Jaeger, Tempo): the third pillar of observability, which records a request's complete journey with the timings of each hop. Our trace_id is a home-made and very useful version of that idea, but tracing is outside this course's scope.

  1. What must NEVER be logged

This section is the most important in the lesson, and the one with the most consequences outside the technical sphere.

The problem

bookings-postgres stores personal data about Rutas Norte's customers: name, ID number, phone and email address. And as soon as you build a centralized logging stack, everything the applications write is copied, indexed and kept for 30 days in a system many more people can access than the database.

That is the real risk, and it is easy to underestimate. A log as innocent as this one:

2026-08-06 03:14:22 INFO Creating booking for Marta Ruiz Sánchez (ID 12345678Z, tel 611223344, [email protected]) journey Bilbao-Santander

has just turned your logging system into a personal data file with all the obligations that entails.

The list of what must never appear

Category Examples Risk
Credentials Passwords, tokens, API keys, session cookies, Authorization headers Immediate unauthorized access
Payment data Card number, CVV, expiry date, IBAN Fraud; PCI DSS non-compliance
Identifying personal data Full name, ID number, phone, email, postal address Data protection regulation
Special category data Health, disability, ethnic origin, membership Reinforced protection under regulation
Complete request bodies req.body dumped as-is It contains all of the above, unfiltered
URLs with sensitive parameters /api/bookings?national_id=12345678Z They appear in the nginx and Ingress logs

One case that always slips through: the access logs of the Ingress and of web-store. They record the complete URL of every request. If any route carries data in the query string, it gets logged without the application being involved at all. The fix: never put sensitive data in URLs, ever.

The four layers of defence

None is sufficient on its own. All of them are applied.

Layer 1 — In the code (the most effective). Make sure the data is never written. It is the only defence with no leaks, because if it never leaves the process there is nothing to filter. It requires review in pull requests.

Layer 2 — Redaction in the logging library. Like pino's redact from section 9: a safety net for slips.

Layer 3 — Masking in the collector. The last technical defence before the data is persisted.

-- k8s/base/logging/mask.lua
-- Fluent Bit Lua filter: masks personal data patterns
-- BEFORE they leave the node. It is a safety net, NOT a substitute
-- for not logging the data: if the pattern changes, this does not catch it.

function mask_personal_data(tag, timestamp, record)
    local modified = false

    for key, value in pairs(record) do
        if type(value) == "string" then
            local original = value

            -- Spanish national ID: 8 digits + a letter
            value = string.gsub(value, "%d%d%d%d%d%d%d%d%a", "[ID-REDACTED]")

            -- Email address
            value = string.gsub(value, "[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTED]")

            -- Spanish phone number: 9 digits starting with 6, 7, 8 or 9
            value = string.gsub(value, "%f[%d][6789]%d%d%d%d%d%d%d%d%f[%D]", "[PHONE-REDACTED]")

            -- Credit card: 16 digits with or without separators
            value = string.gsub(value, "%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d[ %-]?%d%d%d%d",
                                "[CARD-REDACTED]")

            -- Spanish IBAN
            value = string.gsub(value, "ES%d%d[ ]?%d%d%d%d[ ]?%d%d%d%d[ ]?%d%d[ ]?%d%d%d%d%d%d%d%d%d%d",
                                "[IBAN-REDACTED]")

            if value ~= original then
                record[key] = value
                modified = true
            end
        end
    end

    -- Remove fields that must NEVER be persisted, whatever their content
    local forbidden_fields = {
        "password", "passwd", "token", "authorization", "cookie",
        "api_key", "secret", "cvv", "card"
    }
    for _, field in ipairs(forbidden_fields) do
        if record[field] ~= nil then
            record[field] = nil
            modified = true
        end
    end

    -- Flag the modified records: it lets you AUDIT which components
    -- are still trying to write sensitive data and fix them in the code.
    if modified then
        record["_masked"] = true
    end

    return 2, timestamp, record
end

That _masked field is more valuable than it looks. With a query in Kibana:

_masked: true

and aggregating by component, you get the list of components that are writing personal data and that need fixing in the code. It turns a defensive measure into an improvement tool.

An honest warning about regular expressions: they are not reliable. An ID written as 12.345.678-Z is not caught by the pattern above. A person's name has no detectable pattern at all. The only real defence is layer 1.

Layer 4 — Access control and retention. Even with no leaks, restrict who can see the production logs, with indices separated by environment (as we did in the aggregator) and Elasticsearch roles that grant access only to what is needed.

Retention

The 30-day retention from section 7 is a decision that combines three criteria:

Criterion Consideration
Operational How far back do you need to look to diagnose? Usually 7–14 days is enough
Legal What does the regulation applicable to the sector and to the data type require?
Economic Every day of retention costs around 7 GB of fast disk

And a rule that often comes as a surprise: if the logs contain personal data, keeping them "just in case" is not acceptable. Data protection regulation requires personal data to be kept only for as long as is necessary for the purpose that justified collecting it, and "in case we ever need to debug" is not usually a valid purpose.

⚠️ Warning: review by the compliance officer

This is a point that cannot be settled by technical judgement alone.

This lesson's configuration — what is logged, how long it is kept, who can consult it and from where — must be reviewed and approved by the organisation's compliance and data protection officer before going into production.

Rutas Norte processes personal data about its customers (name, ID number, phone and email address), which places the platform within the scope of the GDPR and of national data protection regulation. A centralized logging system that captures that data, even accidentally, has concrete consequences:

  • It becomes a processing operation on personal data that must appear in the record of processing activities, with its legal basis and purpose documented.
  • It requires a justified and technically enforced retention period, not indefinite retention "just in case".
  • It obliges you to control and record access: who consults the production logs and for what purpose.
  • It may require an impact assessment if the volume or nature of the processing justifies one.
  • It considerably complicates the exercise of the rights of erasure and access: if a customer's ID number appears in twenty distributed indices, handling an erasure request becomes technically very expensive.
  • If Elasticsearch is hosted outside the European Economic Area, it triggers the obligations on international data transfers.

What to do, in practice:

  1. Document in writing which fields each component logs and present it to the person responsible for compliance.
  2. Agree with them the retention period for each index, and apply it in the ILM policy.
  3. Define who has access to the production indices, and review it periodically.
  4. Audit with the _masked: true query which components are still emitting sensitive data and fix them in the code.
  5. Include a log review in the checklist for any new feature that handles customer data.

The technical team provides the mechanisms — masking, retention, access control, separation by environment — but the decision about what is acceptable to log and for how long is not a technical decision.

  1. Lighter alternatives and the real cost of a logging stack

The real cost

Recapping section 7, the minimum EFK stack for Rutas Norte:

Resource Quantity Indicative monthly cost (cloud)
3 Elasticsearch nodes (8 GB RAM, 2 vCPU) 24 GB RAM, 6 vCPU €350–500
Fast disk (300 GB SSD) 300 GB €30–60
Fluentd aggregator (2 replicas) 1 GB RAM €15–25
Fluent Bit (3 nodes) ~200 MB RAM Negligible
Kibana 1 GB RAM €15–25
Total ~€450–650/month

Plus the human cost: somebody has to maintain Elasticsearch, size the shards, watch the cluster's health, manage the upgrades and respond when it goes red. Elasticsearch is not a component you install and forget.

For a platform with six components and three nodes, that is a considerable cost. It is worth asking whether there is something lighter.

Loki with Promtail: the lightweight alternative

Loki is Grafana Labs' logging system, with a radically different design idea:

Loki does not index the log content. It only indexes the labels.

It is "Prometheus for logs": the same label model, the same query language (LogQL, very similar to PromQL) and storage in cheap object stores (S3) instead of fast disks.

Aspect Elasticsearch (EFK) Loki
Indexes Every field Only the labels
Storage Fast disk Objects (S3, GCS)
Relative cost High 5–10 times lower
Free-text search Instant Slower (sequential scan)
Complex aggregations Very powerful Limited
Resource consumption 24 GB of RAM 2–4 GB of RAM
Grafana integration Good Native and very tight
Operational complexity High Low
# An example LogQL query; if you come from PromQL it will look familiar
{namespace="rutas-norte-pro", app="bookings-api"} |= "error" | json | duration_ms > 1000

# And you can even derive metrics from the logs
sum(rate({namespace="rutas-norte-pro"} |= "error" [5m])) by (app)

When to choose each:

Choose EFK if... Choose Loki if...
You need very fast free-text search over large volumes You almost always search by component and time window
You do complex aggregations over the fields You already use Grafana and want logs and metrics together
You need Elasticsearch for other things Budget and the operations team are limited
The team already knows how to run Elasticsearch You want to start today with little effort

An honest recommendation for Rutas Norte: with six components, three nodes and a small team, Loki would be the more sensible choice. We have built EFK because it is what you will find in most established companies and because it teaches the concepts (indices, templates, ILM, mappings) that then apply in any system. But if you were starting from scratch tomorrow, start with Loki.

Managed services

The third route is to run nothing yourself:

Service Notes
Elastic Cloud Elasticsearch managed by the vendor itself
Grafana Cloud Logs Managed Loki, with a generous free tier
AWS CloudWatch Logs / OpenSearch Direct integration with EKS (10-06)
Google Cloud Logging Direct integration with GKE, very good
Datadog, New Relic, Splunk Complete platforms, very expensive at volume

The advantage: zero operations. The drawbacks: a per-GB ingestion cost that skyrockets with volume, and — important for section 11 — the data leaves your infrastructure, which has to be reviewed with the compliance officer, especially if the provider stores it outside the EEA.

The measure that saves most: log less

Before sizing anything, reduce the volume. It is free and it always works:

# In Fluent Bit: discard the noise from the health probes (07-01).
# With 6 replicas and probes every 5 s, that is more than 100,000 lines a day
# that contribute absolutely nothing.
[FILTER]
    Name    grep
    Match   kube.*
    Exclude log ^.*"route":"/(health|ready)".*$

# Discard debug-level logs in production
[FILTER]
    Name    grep
    Match   kube.var.log.containers.*_rutas-norte-pro_*
    Exclude level ^debug$

Complementary measures:

  • Level info in production, debug only in rutas-norte-dev.
  • access_log off; for the nginx health endpoint, as we already did in 07-01.
  • Shorter retention for dev and pre (7 days) than for pro (30 days).
  • Periodically review which component generates the most volume:
# In Kibana: a count aggregation by kubernetes.labels.app
# You usually discover that a single component generates 60 % of the volume
# because of a debug log somebody left switched on months ago.

Common Mistakes and Tips

1. The application writes to a file instead of to stdout. It breaks the Kubernetes contract: the collector does not see it, the file grows inside the container and disappears on restart. If you cannot change the application, use an adapter sidecar like the one for notifications-worker.

2. Not configuring multi-line reassembly. Every exception stack trace becomes twenty disconnected documents, exactly what you most need to read whole during an incident.

3. The collector reads its own logs. An infinite loop: every log generated produces another log. The Exclude_Path from section 6 is not optional.

4. Elasticsearch with a single node. No quorum, no fault tolerance, and the whole logging stack goes down when that pod restarts. Three nodes is the real minimum.

5. A badly sized Elasticsearch heap. At most 50 % of the container's memory, and never more than 31 GB. Above that threshold the JVM loses pointer compression and performs worse with more memory.

6. No ILM policy. The indices pile up until the disk fills. When that happens, Elasticsearch goes read-only and you stop receiving logs exactly when they are most needed.

7. Mapping as text what should be keyword. Without keyword you cannot aggregate by that field, and aggregations are half the value of Kibana.

8. Logging personal data. The mistake with the greatest non-technical consequences. Apply the four layers of defence and, above all, do not write it in the code.

9. Not defining a field standard. If each component uses level, severity and lvl for the same thing, there is no query that works for all of them. Agree the standard before instrumenting.

10. Not propagating the trace_id. Without it, correlating a request across six components is impossible however big your logging stack is.

11. No buffer in the collector. If Elasticsearch goes down for ten minutes and there is no buffer, you lose ten minutes of production logs, probably the most interesting ones.

12. Not monitoring the logging stack. The collector can be silently dropping records. Use the PodMonitor and the alert from section 6: apply what you learned in 07-03 and 07-04 to the observability infrastructure itself.

13. Logging too much. The debug level in production multiplies volume and cost tenfold, and makes it harder to find what matters among the noise.

Exercises

Exercise 1 — Convert a text log into a structured one

web-store (nginx) generates access logs in combined format:

83.45.12.99 - - [06/Aug/2026:03:14:22 +0000] "POST /api/bookings?national_id=12345678Z HTTP/1.1" 500 187 "https://www.rutasnorte.example/buy" "Mozilla/5.0" 4.821
  1. List every problem with this line, including the data protection ones.
  2. Write the nginx configuration that emits the same event in JSON, conforming to the Rutas Norte field standard.
  3. Write the Fluent Bit parser you would need if you could not change the nginx configuration.
  4. What would you do with the national_id parameter in the URL?

Exercise 2 — Diagnose a personal data leak

An internal audit reveals that the rutasnorte-pro-2026.08.* index contains 47,000 documents with customers' email addresses in clear text. The logs come from notifications-worker and from bookings-api.

  1. Write the KQL query that locates the affected documents.
  2. List, in order of priority, the actions to take in the next 24 hours.
  3. Write the masking filter that prevents it happening again, and explain why it is not enough.
  4. What is the compliance officer's role in this incident and at what point must they be involved?

Exercise 3 — Size it and decide the architecture

Rutas Norte expands: it goes from 3 to 12 nodes, from 6 to 15 components, and the log volume multiplies by 5 compared with the section 7 estimate. The infrastructure budget does not multiply by 5.

  1. Recalculate the daily volume and the storage needed for 30 days.
  2. Propose three measures to reduce the volume without losing diagnostic capability, with an estimate of the saving from each.
  3. Compare EFK and Loki for this specific scenario, with numbers.
  4. Recommend a final architecture and justify it.

Solutions

Solution 1

1. Problems with the line.

Format problems:

  • It is not JSON: every field has to be extracted with regular expressions, fragile against any change.
  • The date uses the nginx format (06/Aug/2026:03:14:22 +0000), not ISO 8601: it needs a specific parser.
  • The duration 4.821 is in seconds, not milliseconds, and breaches the Rutas Norte standard (duration_ms).
  • There is no trace_id: impossible to correlate this request with the bookings-api one.
  • There is no component or level: you cannot filter by severity.

Data protection problems (the serious ones):

  • The URL contains an ID number: ?national_id=12345678Z. It is recorded in the nginx log, in the Ingress log and in any intermediate proxy. This is the most serious problem.
  • The client's IP (83.45.12.99) is personal data under the GDPR. It must be treated as such: anonymised or its retention justified.
  • The User-Agent contributes to browser fingerprinting and, combined with other data, can be identifying.

2. The nginx configuration in JSON.

# k8s/base/web-store/nginx.conf
http {
    # Anonymise the IP: keep only the first three octets.
    # Enough for approximate geolocation and abuse detection,
    # without identifying a specific person.
    map $remote_addr $anon_ip {
        ~^(?<pre>\d+\.\d+\.\d+)\.    "$pre.0";
        default                       "0.0.0.0";
    }

    # Propagate the trace_id: use the request's or generate a new one.
    map $http_x_trace_id $trace_id {
        ""      $request_id;    # nginx generates a unique id per request
        default $http_x_trace_id;
    }

    # Level based on the response code
    map $status $log_level {
        ~^[45]  "error";
        ~^3     "info";
        default "info";
    }

    log_format rutasnorte_json escape=json
    '{'
      '"timestamp":"$time_iso8601",'
      '"level":"$log_level",'
      '"component":"web-store",'
      '"trace_id":"$trace_id",'
      '"message":"http request",'
      '"method":"$request_method",'
      '"route":"$uri",'                         # WITHOUT the query string
      '"http_code":$status,'
      '"duration_ms":$msec_duration,'
      '"bytes_sent":$body_bytes_sent,'
      '"anon_ip":"$anon_ip",'
      '"protocol":"$server_protocol"'
    '}';

    # Duration in milliseconds as an integer
    map $request_time $msec_duration {
        ~^(?<s>\d+)\.(?<ms>\d{3})$  "${s}${ms}";
        default                      "0";
    }

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

        # The 07-01 probes generate no log: pure noise and volume
        location = /nginx-health {
            access_log off;
            return 200 "ok\n";
        }
    }
}

Key points of this configuration:

  • $uri instead of $request: $uri is the path without the query string, so the ?national_id=... never reaches the log. It is the fix for the most serious problem.
  • escape=json is mandatory: without it, a User-Agent containing quotes breaks the JSON and Fluent Bit cannot parse it.
  • access_log off on the health probe, consistent with what we did in 07-01.

3. The parser if nginx cannot be changed.

[PARSER]
    Name        nginx_combined
    Format      regex
    Regex       ^(?<client_ip>[^ ]+) [^ ]* [^ ]* \[(?<time>[^\]]+)\] "(?<method>\S+) (?<full_path>\S+) (?<protocol>[^"]+)" (?<http_code>\d+) (?<bytes>\d+) "(?<referer>[^"]*)" "(?<user_agent>[^"]*)" (?<duration_s>[\d.]+)$
    Time_Key    time
    Time_Format %d/%b/%Y:%H:%M:%S %z
    Types       http_code:integer bytes:integer duration_s:float

And a Lua filter that normalises to the standard and strips the query string:

function normalize_nginx(tag, timestamp, record)
    -- Split the path from the query string and DISCARD the latter
    if record["full_path"] then
        local route = string.match(record["full_path"], "^([^?]+)")
        record["route"] = route
        record["full_path"] = nil   -- remove: it may carry the ID number
    end

    -- Anonymise the IP: fourth octet to zero
    if record["client_ip"] then
        record["anon_ip"] = string.gsub(record["client_ip"],
                                        "(%d+%.%d+%.%d+)%.%d+", "%1.0")
        record["client_ip"] = nil
    end

    -- Duration to milliseconds
    if record["duration_s"] then
        record["duration_ms"] = math.floor(record["duration_s"] * 1000)
        record["duration_s"] = nil
    end

    -- Rutas Norte standard fields
    record["component"] = "web-store"
    record["level"] = (record["http_code"] >= 400) and "error" or "info"
    record["message"] = "http request"
    record["user_agent"] = nil   -- discard: it contributes to fingerprinting

    return 2, timestamp, record
end

4. What to do with the national_id in the URL.

The correct answer has three levels, and only the first solves the problem at its root:

Level 1 (the real fix): change the application. An ID number must never travel in a URL's query string. URLs are recorded in the browser, in the history, in the Referer sent to third parties, in the nginx and Ingress logs and in any proxy. The query must be a POST with the data in the body, or use an opaque identifier.

Level 2 (immediate mitigation): do not log the query string. Use $uri instead of $request, as in solution 2. It takes minutes to implement and cuts off the leak into the logs.

Level 3 (safety net): mask in the collector. The Lua filter from section 11 detects the ID pattern and replaces it. It is the last defence, and it is not reliable on its own: an ID written as 12.345.678-Z slips past it.

Apply all three. And very importantly: the logs that already contain the ID number are still there. They have to be deleted, and that takes us to the next exercise.

Solution 2

1. The KQL query to locate the documents.

# Search for the email pattern in any text field
message: *@*.* or recipient: * or email: * or mail: *

More precise, taking advantage of the masking flag:

kubernetes.namespace_name: "rutas-norte-pro"
  and (message: *"@"* or recipient: *"@"*)
  and not _masked: true

And to quantify it and locate the source, an Elasticsearch aggregation:

POST rutasnorte-pro-2026.08.*/_search
{
  "size": 0,
  "query": {
    "query_string": {
      "query": "*@*.*",
      "fields": ["message", "recipient", "error_detail"]
    }
  },
  "aggs": {
    "by_component": {
      "terms": { "field": "component", "size": 20 },
      "aggs": {
        "by_day": {
          "date_histogram": { "field": "@timestamp", "calendar_interval": "day" }
        }
      }
    }
  }
}

That aggregation gives you exactly which component, how many documents and from what day: the three facts you need for the report.

2. Actions within 24 hours, by priority.

Hour 0–1 — Contain and notify.

  1. Notify the compliance officer immediately. It is not a technical decision that can be deferred: breach notification deadlines are short and the clock starts running from the moment you become aware.
  2. Restrict access to the affected indices to the incident team only, through Elasticsearch roles.
  3. Document the scope: which components, which fields, how many documents, from what date, who has accessed those indices (the Elasticsearch audit logs).

Hour 1–4 — Stop the leak.

  1. Deploy the masking filter in Fluent Bit (point 3) so that no new documents get in. It is quick and requires no changes to the applications.
  2. Verify that new documents are now arriving masked, by checking the day's index.

Hour 4–12 — Fix the source.

  1. Locate in the code the logger calls that emit the address. In notifications-worker it is in the legacy SMTP log; in bookings-api, probably in a log.info(booking) that dumps the whole object.
  2. Fix the code: replace the address with a hash, as the adapter in section 9 does.
  3. Add redact to the logger configuration as a layer 2 safety net.
  4. Deploy to pre and then to pro.

Hour 12–24 — Clean up and prevent.

  1. Delete or scrub the affected documents, according to what the compliance officer decides:
POST rutasnorte-pro-2026.08.*/_delete_by_query
{
  "query": {
    "bool": {
      "must": [
        { "query_string": { "query": "*@*.*", "fields": ["message", "recipient"] } },
        { "terms": { "component": ["notifications-worker", "bookings-api"] } }
      ]
    }
  }
}

A warning: _delete_by_query over 47,000 documents is a heavy operation. If the indices are daily and heavily contaminated, deleting the whole index is far faster and safer, at the cost of also losing that day's clean logs.

  1. Add an automatic check in continuous integration that rejects a pull request if it detects personal data patterns in logger calls.
  2. Write a report for the compliance officer with a timeline, scope, root cause and measures.

3. The masking filter and why it is not enough.

function mask_emails(tag, timestamp, record)
    for key, value in pairs(record) do
        if type(value) == "string" then
            record[key] = string.gsub(value,
                "[%w%.%-_]+@[%w%.%-]+%.%a%a+", "[EMAIL-REDACTED]")
        end
    end
    -- Fields that must never be persisted
    record["recipient"] = nil
    record["email"] = nil
    record["mail"] = nil
    return 2, timestamp, record
end

Why it is not enough, for four reasons:

  1. Regular expressions slip. marta.ruiz [at] ejemplo.example, an address split across two fields, or one with unusual characters do not match the pattern. Masking gives a false sense of security.
  2. It only covers what you already know to look for. A person's name has no detectable pattern. Neither does a postal address. The filter protects against emails, ID numbers and cards; against the rest, nothing.
  3. The data exists up to the filter. It leaves the process, is written to the node's file, travels to the collector. Anybody with access to the node (or to the collector's pod, which mounts a hostPath) sees it unmasked.
  4. It is a mitigation layer, not a prevention one. The only real defence is that the data is never written. Everything else is a net that catches what escapes.

That is why step 7 (fixing the code) is the important one, and the filter only buys time while it is deployed.

4. The compliance officer's role.

When to involve them: within the first hour, before taking any clean-up action. It is a frequent mistake to "fix it first and tell them later": deleting the documents before they have documented the scope may destroy the evidence they need to assess the breach, and the notification deadlines run from the moment you become aware of the event, not from when you finish fixing it.

Their responsibilities in this incident:

  • Classify the incident: determine whether it constitutes a personal data breach under the GDPR.
  • Decide on notification: whether to notify the supervisory authority (in Spain, the AEPD) within the 72-hour legal deadline, and whether to communicate it to the individuals affected.
  • Assess the risk to the rights and freedoms of the individuals affected, taking into account the volume, the nature of the data and who may have had access.
  • Decide on the clean-up: what is deleted, what is kept as evidence and for how long.
  • Record the incident in the internal register of security breaches, mandatory even when notification is not required.
  • Approve the corrective measures and verify that they have been implemented.

And going forward, their role is preventive: reviewing and approving which fields are logged, the retention periods and the access policy, as set out in the warning in section 11. The technical team provides the mechanisms; the decision about what is acceptable to log is not a technical one.

Solution 3

1. Recalculating the volume.

New situation:
  12 nodes, 15 components
  Volume × 5 compared with the original estimate

  Original: 7.2 GB/day indexed
  New:      7.2 × 5 = 36 GB/day indexed

  30-day retention with the ILM policy from section 7:
    3 hot days with 1 replica:   36 × 3 × 2 = 216 GB
    27 warm days with no replica: 36 × 27    = 972 GB
    TOTAL ≈ 1,188 GB → with 25 % headroom: ~1.5 TB

  Elasticsearch resources needed:
    Rule of thumb: ~1 data node per 300–500 GB indexed
    → 4–5 data nodes with 16 GB of RAM each (8 GB heap)
    → 64–80 GB of RAM and 1.5 TB of fast disk

  Indicative monthly cost: €1,400–1,900/month

An increase of roughly three times the original cost, plus a qualitative jump in operational complexity: with 5 data nodes you have to manage shards, rebalancing and continuous upgrades.

2. Three reduction measures.

Measure A — Sampling successful logs. Estimated saving: 45 %.

90 % of the logs are requests that went fine and that nobody will ever look at. Keeping 10 % of them is statistically enough to see trends, and 100 % of the errors.

function sample_successes(tag, timestamp, record)
    -- ALWAYS keep errors and warnings
    if record["level"] == "error" or record["level"] == "fatal"
       or record["level"] == "warn" then
        return 2, timestamp, record
    end
    -- ALWAYS keep the slow ones, even if they went fine
    if record["duration_ms"] and record["duration_ms"] > 1000 then
        return 2, timestamp, record
    end
    -- Always keep anything that touches money
    if record["route"] and string.match(record["route"], "^/api/bookings") then
        return 2, timestamp, record
    end
    -- Of the rest, keep 1 in every 10
    if math.random(10) == 1 then
        record["_sampled"] = 10   -- the factor, so counts can be extrapolated
        return 2, timestamp, record
    end
    return -1, timestamp, record  -- -1 = discard
end

The _sampled field lets you multiply the counts by 10 when aggregating and get correct figures.

Measure B — Tiered retention by environment and by level. Estimated saving: 30 %.

Index Current retention Proposed retention Rationale
rutasnorte-pro-* errors 30 days 30 days No change: this is what gets investigated
rutasnorte-pro-* info 30 days 7 days Rarely looked at further back
rutasnorte-pre-* 30 days 7 days Test environment
rutasnorte-dev-* 30 days 3 days Debugged on the spot
System logs 30 days 14 days A reasonable compromise

It is implemented with two different data streams, routed in the Fluentd aggregator by the level field, each with its own ILM policy.

Measure C — Discard noise at source. Estimated saving: 20 %.

# Health probes (07-01): with 15 components and probes every 5 s,
# that is hundreds of thousands of lines a day contributing nothing.
[FILTER]
    Name    grep
    Match   kube.*
    Exclude route ^/(health|ready|metrics|nginx-health)$

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

# Repetitive library start-up logs
[FILTER]
    Name    grep
    Match   kube.*
    Exclude message ^(Loaded plugin|Initializing module|Warming cache)

The combined effect (the measures are not purely additive because they overlap):

36 GB/day
  − 45 % from sampling      → 19.8 GB/day
  − 20 % from noise removal → 15.8 GB/day
  With tiered retention, total storage:
    ~15.8 GB/day × a weighted average of 12 days ≈ 190 GB
  Against the original 1,500 GB: an 87 % reduction

And the important part: with no loss of diagnostic capability, because 100 % of the errors, 100 % of the slow requests and 100 % of anything touching bookings are kept.

3. Comparing EFK against Loki with numbers.

On the already optimised volume of 15.8 GB/day:

Item EFK Loki
Storage nodes 3 × 16 GB RAM 3 × 4 GB RAM (ingester/querier)
Total RAM 48 GB 12 GB
Storage 250 GB fast SSD 250 GB in object storage (S3)
Storage cost ~€50/month (SSD) ~€6/month (S3)
Compute cost ~€700/month ~€180/month
Total monthly cost ~€750 ~€190
Free-text search over 30 days 1–3 seconds 10–60 seconds
Search filtering by component and 1 hour < 1 second < 1 second
Complex aggregations Very powerful Limited
Operational complexity High Low
Grafana integration (07-04) Good Native

The decisive figure is in the comparison of the two search rows: Loki is slow at free-text searches across the whole history, but just as fast in the case that represents 95 % of real usage, which is "logs from this component, in this time window, filtered by level". And that is exactly how an incident is investigated: you never search for a string across 30 days of every component; you always narrow by component and by window, because the alert already gave you both.

4. The recommended architecture.

Recommendation: migrate to Loki, with the three reduction measures applied.

flowchart TB
    subgraph Nodes["12 nodes"]
        FB["Fluent Bit (DaemonSet)<br/>+ sampling, masking<br/>and discard filters"]
    end
    FB --> LOKI["Loki<br/>3 replicas, 4 GB RAM"]
    LOKI --> S3[("Object storage<br/>250 GB, 30 d retention")]
    LOKI --> GRAF["Grafana<br/>(already deployed in 07-04)"]
    PROM["Prometheus<br/>(07-03)"] --> GRAF
    GRAF --> USR["On-call person:<br/>metrics and logs<br/>on the same screen"]

The justification, in five points:

  1. Cost: €190 against €750 a month. The difference (€6,700 a year) is hard to justify when the predominant use case performs the same on both.
  2. Operational complexity. Elasticsearch with 5 data nodes needs somebody who knows how to manage shards, rebalancing and upgrades. Loki, far less so. With a small team, that time is worth more than the cost difference.
  3. Integration with what we already have. Grafana has been deployed since 07-04. Loki appears as one more data source, and the same dashboard can have a metrics panel above and a logs panel below, with the same time window. That saved context switch during an incident is worth a lot.
  4. The label model is the one we already know. LogQL is so similar to PromQL that the team learns it in an afternoon, whereas KQL and the Elasticsearch aggregations are a separate body of knowledge.
  5. The collection architecture is preserved. Fluent Bit remains the per-node agent, with the same parsing, multi-line and masking configuration. The migration only changes the output destination, which makes it low-risk.

When NOT to follow this recommendation:

  • If the team already runs Elasticsearch for other things (the website's search engine, analytics), the marginal cost of adding logs is much lower.
  • If there are audit requirements demanding free-text search over the whole history with guaranteed response times.
  • If complex aggregations over log fields are needed routinely.

A low-risk migration plan:

  1. Deploy Loki in parallel, without touching EFK.
  2. Configure Fluent Bit with two simultaneous outputs for two weeks.
  3. Rebuild in Grafana the error dashboards that were in Kibana.
  4. Validate with a real incident that Loki answers the necessary questions.
  5. Retire EFK, keeping a copy of the production indices until the retention agreed with the compliance officer runs out.

That last point is not a detail: you cannot delete EFK early if its retention is committed to compliance.

Conclusion

Rutas Norte no longer loses its history. In this lesson we have:

  • Understood why kubectl logs is not enough: it is lost when the pod is recreated, it does not search across components, it does not correlate and it disappears with the node. And we have seen an additional, little-known limit: with the kubelet's default rotation, a chatty component keeps less than six hours of logs on the node.
  • Seen how logging works under the hood: the application writes to stdout, the runtime stores it in /var/log/containers/ with a meaningful name from which the pod, namespace and container are extracted, and the kubelet rotates it.
  • Mastered kubectl logs as a front-line tool, with --previous as the decisive flag in the face of a CrashLoopBackOff.
  • Built the per-node collection architecture on top of the DaemonSet we deployed in 06-02: Fluent Bit as the lightweight agent on each node and Fluentd as the central aggregator, with the kubernetes filter that enriches every line and the multi-line reassembly that stops an exception turning into twenty disconnected documents.
  • Configured Elasticsearch with daily indices, templates that distinguish keyword from text, and an ILM policy with hot, warm, cold and delete phases; and we have sized the stack with realistic numbers: some 24 GB of RAM and 300 GB of disk just to be able to read logs.
  • Used Kibana with KQL to go from an alert to the root cause in five minutes, following a concrete workflow: narrow down, see the shape, identify the component, find the dominant message, read the stack trace and correlate backwards.
  • Adopted structured JSON logs as the decision with the best return, with the Rutas Norte field standard and the trace_id that lets you follow a request across the six components; and we have closed the circle on the notifications-worker log adapter we introduced in 06-04.
  • And, above all, we have established what must never be logged: credentials, payment data and customers' personal data. With four layers of defence — the code, the library, the collector and access control — knowing that only the first is truly reliable, and with the explicit warning that the logging configuration must be reviewed and approved by the compliance officer before it reaches production.
  • Compared the real cost of EFK with the lighter alternatives, with the honest conclusion that for a platform the size of Rutas Norte, Loki would be the more sensible choice today.

We now have the three signals: the probes say whether a component is healthy, the metrics say how much and how well it is working, and the logs say exactly what happened. What we still do not have is a method for using them together.

Because when the web store returns 502 at 03:14 and the phone rings, knowing how to use Grafana and Kibana is not enough: you need a procedure that goes from symptom to cause without wandering, you need to know that Kubernetes events expire after an hour and have to be captured beforehand, and you need a mental table of symptom → probable cause → the command that confirms it. In 07-06, the module's last lesson, we will build that methodology and apply it, step by step, to a real Rutas Norte incident.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved