Unit 4.3 · Search and Log Analytics: Elasticsearch, OpenSearch, ELK
The Log Pipeline: Fluent Bit, Logstash, Kibana, and Friends
ELK vs EFK vs Loki, shipping container logs with Fluent Bit, parsing with ingest pipelines or Logstash, buffering and backpressure, index templates for logs, mapping explosions, and dashboards.
Start here
The mental model
A log pipeline is a conveyor belt with four stations: COLLECT (read log files on every node), PROCESS (parse lines into fields, add Kubernetes metadata, drop noise, mask secrets), STORE (index into OpenSearch/Elasticsearch), and VIEW (search and dashboards in Kibana or OpenSearch Dashboards). When the store slows down, the belt must slow down too without dropping everything. That's BUFFERING and BACKPRESSURE.
Go deeper
How it works inside
01ELK, EFK, and the alternatives
ELK = Elasticsearch + Logstash + Kibana. EFK swaps Logstash for Fluentd or, more commonly now, FLUENT BIT: a tiny C agent that runs as a DaemonSet, tails /var/log/containers/*.log, enriches records with pod labels from the Kubernetes API, and ships them. LOGSTASH is heavier (JVM) but has powerful filters (grok, dissect, translate, aggregate); many teams now parse in the agent or in Elasticsearch/OpenSearch INGEST PIPELINES instead. The OpenTelemetry Collector can also collect logs.
LOKI (Observability course) indexes only labels, not the log text, so it's much cheaper to store at scale, with grep-like queries. Search engines index everything, so they're better for rich ad-hoc queries, analytics, and security use cases (SIEM), at a higher cost. Many companies run both, or choose by budget.
02Structure at the source
The best parser is not needing one: have apps log JSON with consistent field names (level, service, trace_id, msg), using a shared schema such as Elastic Common Schema (ECS) or OpenTelemetry semantic conventions. Then the pipeline only decodes JSON and adds metadata. Parsing free-text lines with grok works, but it's CPU-heavy and breaks when the format changes (Observability course, parsing logs you didn't write).
03Mapping explosion
With dynamic mapping, every NEW JSON key becomes a new field in the index mapping. An app that logs {"user_42_cart": ...} or dumps arbitrary request headers creates thousands of fields; the cluster state bloats, the managers slow down, and at index.mapping.total_fields.limit (1000) documents are rejected. Protect log indexes: templates with explicit fields for known keys, dynamic: false (unknown fields are kept in _source but not indexed) or the flattened/flat_object type for free-form objects.
04Buffering and backpressure
When OpenSearch returns 429 (too many requests) or is down, the agent must hold data. Fluent Bit's in-memory buffer is small and lost on restart; use storage.type filesystem with a size limit so logs survive restarts and outages up to the limit, and alert when it fills. For very large volumes, put Kafka between agents and indexers (agents → Kafka → Logstash/consumers → OpenSearch): Kafka absorbs spikes and lets you replay into a new cluster (Unit 2.1).
Do it
Hands-on lab
- 1
An ingest pipeline that parses and masks
Parse a JSON message, set the timestamp, and mask anything that looks like a card number before it's stored (DevSecOps course, secrets and sensitive data).
terminal$ curl -s -XPUT localhost:9200/_ingest/pipeline/shop-logs -H 'content-type: application/json' -d '{"processors": [{ "json": { "field": "log", "target_field": "app", "ignore_failure": true } },{ "date": { "field": "app.ts", "formats": ["ISO8601"], "ignore_failure": true } },{ "gsub": { "field": "app.msg", "pattern": "\\b\\d{13,16}\\b", "replacement": "[CARD]", "ignore_missing": true } },{ "remove": { "field": "log", "ignore_missing": true } } ] }'curl -s -XPOST 'localhost:9200/_ingest/pipeline/shop-logs/_simulate' -H 'content-type: application/json' -d '{"docs":[{"_source":{"log":"{\"ts\":\"2026-09-27T10:00:00Z\",\"level\":\"error\",\"msg\":\"charge failed for 4111111111111111\"}"}}]}' | jq '.docs[0].doc._source'── expected output ──{"app": { "ts": "2026-09-27T10:00:00Z", "level": "error", "msg": "charge failed for [CARD]" },"@timestamp": "2026-09-27T10:00:00.000Z"} - 2
An index template that prevents mapping explosions
Known fields get explicit types; everything else under
app.extrais stored as oneflat_objectfield; unknown top-level fields aren't indexed.logs-template.jsonwhole filejson { "index_patterns": ["logs-shop-*"], "template": { "settings": { "number_of_shards": 1, "number_of_replicas": 1, "refresh_interval": "30s", "default_pipeline": "shop-logs", "index.mapping.total_fields.limit": 500 }, "mappings": { "dynamic": false, "properties": { "@timestamp": { "type": "date" }, "kubernetes": { "properties": { "namespace_name": { "type": "keyword" }, "pod_name": { "type": "keyword" }, "labels": { "type": "flat_object" } } }, "app": { "properties": { "level": { "type": "keyword" }, "service": { "type": "keyword" }, "trace_id": { "type": "keyword" }, "msg": { "type": "text" }, "extra": { "type": "flat_object" } } } } } } }terminal$ curl -s -XPUT localhost:9200/_index_template/logs-shop -H 'content-type: application/json' -d @logs-template.json | jq .acknowledged── expected output ──true - 3
Fluent Bit as a DaemonSet
Install with the Helm chart and give it this output config: filesystem buffering, retries, and the Kubernetes filter for pod metadata. The
Logstash_Format-style daily indexes are avoided; we write to the rollover alias from Unit 4.2.fluent-bit-values.yamlwhole fileyaml config: service: | [SERVICE] Flush 5 storage.path /var/fluent-bit/state/buffer storage.max_chunks_up 128 inputs: | [INPUT] Name tail Path /var/log/containers/*.log multiline.parser docker, cri Tag kube.* storage.type filesystem Mem_Buf_Limit 50MB filters: | [FILTER] Name kubernetes Match kube.* Labels On Annotations Off outputs: | [OUTPUT] Name opensearch Match kube.* Host opensearch.logging.svc Port 9200 Index logs-shop Suppress_Type_Name On Retry_Limit False storage.total_limit_size 5Gterminal$ helm repo add fluent https://fluent.github.io/helm-chartshelm upgrade --install fluent-bit fluent/fluent-bit -n logging --create-namespace -f fluent-bit-values.yamlkubectl -n logging get ds fluent-bit── expected output ──NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLEfluent-bit 3 3 3 3 3 - 4
Query logs like an operator
In Dashboards' Discover, or via the API: errors from checkout in the last 15 minutes, grouped by pod. The same query in PPL/SQL is available in OpenSearch (
source=logs-shop | where app.level='error').terminal$ curl -s localhost:9200/logs-shop/_search -H 'content-type: application/json' -d '{"size":0,"query":{"bool":{"filter":[{"term":{"app.level":"error"}},{"term":{"kubernetes.labels.app":"checkout"}},{"range":{"@timestamp":{"gte":"now-15m"}}}]}},"aggs":{"pods":{"terms":{"field":"kubernetes.pod_name"}}}}' | jq -c '.aggregations.pods.buckets'── expected output ──[{"key":"checkout-6d8f9c7b5-x4k2p","doc_count":412},{"key":"checkout-6d8f9c7b5-q9w7n","doc_count":3}]
Operate it
Knobs that matter
| Setting | Default | What it does | When to change it |
|---|---|---|---|
| storage.type (Fluent Bit input) | memory | Where chunks are buffered. | filesystem with storage.total_limit_size so outages don't lose logs or OOM the agent. |
| Mem_Buf_Limit | unlimited | Memory cap per input. | Set (e.g. 50MB) so a backlog can't OOMKill the DaemonSet. |
| index.mapping.total_fields.limit | 1000 | Max fields per index. | Keep it; fix the source of new fields rather than raising it. |
| Logstash pipeline.workers / batch.size | CPU cores / 125 | Parallelism and batch size of Logstash filters. | Raise batch size for throughput; add persistent queues (queue.type: persisted) for durability. |
3am practice
Failure drills
Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.
Drill #1
Documents rejected: too many fields
A new release logs the whole request header map as JSON. An hour later, log ingestion errors spike and cluster-manager CPU is high.
Drill #2
Fluent Bit pods OOMKilled during an outage
OpenSearch was unavailable for 20 minutes. During that time the Fluent Bit DaemonSet crash-looped on busy nodes, and some logs were lost.
Decide
Log backends
| OpenSearch / Elasticsearch | Loki | CloudWatch Logs | |
|---|---|---|---|
| Indexes | Every field (full-text) | Labels only; content scanned at query time | Managed; Logs Insights queries |
| Query power | Rich search, aggregations, SIEM | LogQL: filter, parse, metrics from logs | Good for AWS-native, basic analytics |
| Cost at scale | High (compute + disk) | Low (object storage) | Per-GB ingest + storage, can get expensive |
| Ops burden | High (or managed service) | Medium | None |
| Pick when | Search-heavy, security, analytics | Kubernetes logs with Grafana, cost-sensitive | AWS services, small teams |
The bigger picture
Connects to
System Design · System 12.21 — Distributed Logging
Collecting logs from 10k services into one searchable sink — the ELK/opentelemetry story.
Observability · Log flood
When logging volume itself becomes the incident.
Observability · PII in logs
Masking sensitive data in the pipeline, as the ingest pipeline does here.
Kubernetes · DaemonSets
Why log agents run as one pod per node.
Unit 2.1 · Kafka
Using Kafka as a durable buffer between agents and the log store.
Prove it
Interview questions
Walk through how logs get from a Kubernetes pod to Kibana.
ELK vs Loki?
What is a mapping explosion and how do you prevent it?