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
- Why
kubectl logsis not enough - How logging works under the hood in Kubernetes
kubectl logsin depth: the front-line tool and its limits- The architecture of the per-node collection pattern
- Fluentd versus Fluent Bit
- The real collector configuration
- Elasticsearch: indices, templates and life cycle
- Kibana: index pattern, KQL and an error dashboard
- Structured JSON logs: the decision with the best return
- Correlating logs and metrics
- What must NEVER be logged
- Lighter alternatives and the real cost of a logging stack
- Common mistakes and tips
- Exercises
- Why
kubectl logs is not enough
kubectl logs is not enoughkubectl 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.
- 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
- The process writes a line to
stdout. - The container runtime (containerd or CRI-O) captures that output.
- The runtime writes it to a file on the node, in CRI format.
- The kubelet manages that file: it rotates it and creates symbolic links with meaningful names.
kubectl logsasks 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/<ns>_<pod>_<uid>/<container>/0.log"]
F -.symbolic link.-> L["/var/log/containers/<pod>_<ns>_<container>-<id>.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:
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:
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
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 keepWith 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.
kubectl logs in depth: the front-line tool and its limits
kubectl logs in depth: the front-line tool and its limitsEven 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=10The --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 -rnIts 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.
- 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:
- Read the files in
/var/log/containers/, following new writes and remembering where it had got to after a restart. - Parse the CRI format and, if the content is JSON, expand it into fields.
- 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.
- 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.
- 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 |
- 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-configA security note, which we will pick up again in module 8. This DaemonSet mounts a
hostPathover the node's filesystem. Even read-only, anybody who can run anexecin 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:5To 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: 30sAnd 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"
- 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 deletedThe 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 GBAnd 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: 100GiApproximate 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.
- 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:
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 >= 500A 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:
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:
Step 4 — Find the dominant message. Click on message.keyword:
connection pool exhausted 4102
timeout acquiring connection from pool 619
Error: read ECONNRESET 100Step 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:
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.
- 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 exhaustedProblems 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
4821msis text: you cannot queryduration_ms > 1000or 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:
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:
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=30012msTwo 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.
- 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.
- 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-Santanderhas 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
endThat _masked field is more valuable than it looks. With a query in Kibana:
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:
- Document in writing which fields each component logs and present it to the person responsible for compliance.
- Agree with them the retention period for each index, and apply it in the ILM policy.
- Define who has access to the production indices, and review it periodically.
- Audit with the
_masked: truequery which components are still emitting sensitive data and fix them in the code.- 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.
- 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
infoin production,debugonly inrutas-norte-dev. access_log off;for the nginx health endpoint, as we already did in 07-01.- Shorter retention for
devandpre(7 days) than forpro(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- List every problem with this line, including the data protection ones.
- Write the nginx configuration that emits the same event in JSON, conforming to the Rutas Norte field standard.
- Write the Fluent Bit parser you would need if you could not change the nginx configuration.
- What would you do with the
national_idparameter 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.
- Write the KQL query that locates the affected documents.
- List, in order of priority, the actions to take in the next 24 hours.
- Write the masking filter that prevents it happening again, and explain why it is not enough.
- 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.
- Recalculate the daily volume and the storage needed for 30 days.
- Propose three measures to reduce the volume without losing diagnostic capability, with an estimate of the saving from each.
- Compare EFK and Loki for this specific scenario, with numbers.
- 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.821is in seconds, not milliseconds, and breaches the Rutas Norte standard (duration_ms). - There is no
trace_id: impossible to correlate this request with thebookings-apione. - There is no
componentorlevel: 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-Agentcontributes 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:
$uriinstead of$request:$uriis the path without the query string, so the?national_id=...never reaches the log. It is the fix for the most serious problem.escape=jsonis mandatory: without it, aUser-Agentcontaining quotes breaks the JSON and Fluent Bit cannot parse it.access_log offon 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:floatAnd 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
end4. 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: trueAnd 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.
- 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.
- Restrict access to the affected indices to the incident team only, through Elasticsearch roles.
- 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.
- 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.
- Verify that new documents are now arriving masked, by checking the day's index.
Hour 4–12 — Fix the source.
- Locate in the code the logger calls that emit the address. In
notifications-workerit is in the legacy SMTP log; inbookings-api, probably in alog.info(booking)that dumps the whole object. - Fix the code: replace the address with a hash, as the adapter in section 9 does.
- Add
redactto the logger configuration as a layer 2 safety net. - Deploy to
preand then topro.
Hour 12–24 — Clean up and prevent.
- 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.
- Add an automatic check in continuous integration that rejects a pull request if it detects personal data patterns in logger calls.
- 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
endWhy it is not enough, for four reasons:
- 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. - 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.
- 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. - 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/monthAn 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
endThe _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 % reductionAnd 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:
- 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.
- 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.
- 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.
- 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.
- 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:
- Deploy Loki in parallel, without touching EFK.
- Configure Fluent Bit with two simultaneous outputs for two weeks.
- Rebuild in Grafana the error dashboards that were in Kibana.
- Validate with a real incident that Loki answers the necessary questions.
- 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 logsis 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 logsas a front-line tool, with--previousas the decisive flag in the face of aCrashLoopBackOff. - 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
kubernetesfilter 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
keywordfromtext, 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_idthat lets you follow a request across the six components; and we have closed the circle on thenotifications-workerlog 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
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
