- Overview
- Loki Architecture Deep Dive
- Deployment Mode Selection
- Mastering LogQL Queries
- Log Collection Pipeline Built on Grafana Alloy
- Storage Design and Retention Management
- Loki vs Elasticsearch
- Configuring Alert Rules
- Practical Troubleshooting
- Disaster Recovery Procedures
- Summary of Major Loki 3.x Changes
- Operations Checklist
- Conclusion
- References

Overview
Logs are the last line of defense in incident analysis. If metrics tell you "what is broken", logs explain "why it broke". But in a large-scale distributed system, collecting and searching hundreds of gigabytes of logs a day is never simple. The Elasticsearch-based ELK stack offers powerful full-text search, but its indexing cost and operational complexity are high.
Grafana Loki takes a fundamentally different approach to this problem. It does not index the log body; it keeps only a label-based index and stores compressed log chunks in object storage. Thanks to this design, storage cost is 70-80% lower than Elasticsearch, and even multi-terabyte log volumes can be operated on cheap object storage such as S3 or GCS.
This article covers the internal architecture of Loki 3.x, the three deployment modes, LogQL query syntax and optimization patterns, a log collection pipeline built on Grafana Alloy, TSDB storage configuration and retention, alert rule composition, real-world troubleshooting cases, and disaster recovery procedures.
Loki Architecture Deep Dive
Loki is designed as a microservice-based architecture in which each component has a clearly defined role. The core philosophy is "treat logs like metrics". It applies the Prometheus label model to logs as-is, so metrics and logs can be correlated through the same label set.
Core Components
Distributor is the entry point that receives push requests from log collection agents such as Alloy and Promtail. It validates the labels of the incoming log streams and routes them to the right Ingester through a consistent hash ring. Rate limiting and validation are handled at this stage, so malformed labels or excessive traffic are blocked before they reach an Ingester.
Ingester accumulates log data in an in-memory buffer, then compresses it into chunks and flushes them to object storage. It uses a WAL (Write-Ahead Log) to prevent data loss when the process terminates abnormally. The Ingester is the component that consumes the most memory, so it deserves the most attention when sizing resources.
Query Frontend receives query requests from clients and spreads their execution across multiple Queriers. It improves query performance through query splitting, which divides a time range into several intervals, and through result caching.
Querier is the worker that actually processes queries. It searches the in-memory data in the Ingesters and the block data in object storage at the same time, then merges the results.
Compactor periodically merges the index files in object storage and deletes expired data according to the retention policy. From Loki 3.6 a horizontally scalable Compactor was introduced, which greatly improved the processing speed of large deletion requests.
Index Gateway handles queries against index data centrally, reducing how often Queriers reach object storage directly. It is especially useful in microservices mode.
Data Flow
[Application] --> [Grafana Alloy] --> [Distributor]
|
[Hash Ring]
|
[Ingester]
/ \
[WAL] [Object Storage (S3/GCS)]
|
[Compactor] <-------+
|
[Query Frontend] ---+---> [Querier]
|
[Index Gateway]
Deployment Mode Selection
Loki offers three deployment modes; pick one according to log volume and operational maturity.
Monolithic Mode
All components run in a single process. This suits development environments or small deployments handling a few gigabytes a day or less.
# Helm values - Monolithic mode
loki:
deploymentMode: SingleBinary
auth_enabled: false
commonConfig:
replication_factor: 1
storage:
type: filesystem
schemaConfig:
configs:
- from: '2024-01-01'
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
singleBinary:
replicas: 1
persistence:
size: 50Gi
Simple Scalable Mode (Recommended)
This is the default mode of the Loki Helm chart, split into three targets: read, write, and backend. It can handle anywhere from a few hundred gigabytes to roughly 1TB of logs a day, which makes it the best choice for most production environments.
# Helm values - Simple Scalable mode
loki:
deploymentMode: SimpleScalable
auth_enabled: true
commonConfig:
replication_factor: 3
storage:
type: s3
s3:
endpoint: s3.ap-northeast-2.amazonaws.com
region: ap-northeast-2
bucketnames: company-loki-logs
access_key_id: ${AWS_ACCESS_KEY_ID}
secret_access_key: ${AWS_SECRET_ACCESS_KEY}
schemaConfig:
configs:
- from: '2024-01-01'
store: tsdb
object_store: s3
schema: v13
index:
prefix: index_
period: 24h
write:
replicas: 3
persistence:
size: 50Gi
resources:
requests:
cpu: '1'
memory: 2Gi
limits:
memory: 4Gi
read:
replicas: 3
resources:
requests:
cpu: '1'
memory: 2Gi
limits:
memory: 4Gi
backend:
replicas: 2
persistence:
size: 50Gi
gateway:
replicas: 2
The write target is deployed as a StatefulSet so that it retains WAL data, and the read target is deployed as a Deployment so that it can autoscale. You must place a reverse proxy (gateway) in front to route API requests to the read/write nodes.
Microservices Mode
Each component is deployed as an independent process. Choose this for environments that exceed 1TB a day, or when fine-grained per-component scaling is required. A default Helm deployment creates 3 Distributors, 3 Ingesters, 3 Queriers, 2 Query Frontends, 2 Index Gateways, and 1 Compactor.
| Deployment mode | Daily log volume | Operational complexity | Primary target |
|---|---|---|---|
| Monolithic | A few GB or less | Low | Dev/test environments |
| Simple Scalable | Tens of GB ~ 1TB | Medium | Most production deployments |
| Microservices | Over 1TB | High | Large-scale multi-tenant |
Mastering LogQL Queries
LogQL is a Loki-specific query language inspired by the PromQL of Prometheus. It starts with a log stream selector and chains pipeline stages, and it supports two types: log search (Log Query) and metric conversion (Metric Query).
Stream Selectors and Line Filters
Every LogQL query starts with a stream selector. The stream selector is what uses the Loki index, so making it as specific as possible improves query performance.
# Basic stream selector
{namespace="production", app="payment-service"}
# Chaining line filters - filters apply in order, so put the narrowest filter first
{namespace="production", app="payment-service"}
|= "error"
!= "health-check"
|~ "timeout|connection refused"
# Pattern match filter in Loki 3.x - 10x faster than regex
{namespace="production"} |> "error <_> timeout"
Filter operators:
|=: string contains!=: string does not contain (not contains)|~: regex match!~: regex does not match|>: pattern match (Loki 3.x)!>: pattern does not match (Loki 3.x)
Parsers and Label Extraction
Loki provides the json, logfmt, pattern, regexp, and unpack parsers. For JSON or logfmt formats the dedicated parser is the most efficient, and for unstructured logs the pattern parser is faster than regexp.
# JSON parser - extract fields from structured logs
{app="api-gateway"} | json | status >= 500
# logfmt parser
{app="auth-service"} | logfmt | level="error" | duration > 5s
# pattern parser - parsing an nginx access log
{app="nginx"}
| pattern "<ip> - - [<timestamp>] \"<method> <path> <_>\" <status> <bytes>"
| status >= 400
| line_format "{{.ip}} {{.method}} {{.path}} {{.status}}"
# regexp parser - complex unstructured logs
{app="legacy-service"}
| regexp "(?P<timestamp>\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) \\[(?P<level>\\w+)\\] (?P<message>.*)"
| level="ERROR"
Parser performance rule of thumb: json/logfmt are the fastest, pattern comes next, and regexp is the slowest. Where possible, place a line filter ahead of the parser so that the number of log lines to be parsed is reduced first.
Metric Queries - Turning Logs into Numbers
LogQL metric queries convert log streams into time series data. This makes it possible to build dashboards and alerts from log data alone, without collecting separate metrics.
# Error log rate per second
rate({namespace="production", app="payment-service"} |= "error" [5m])
# 5xx error count per service
sum by (app) (
count_over_time(
{namespace="production"} | json | status >= 500 [5m]
)
)
# P99 response time computed with unwrap
quantile_over_time(0.99,
{app="api-gateway"}
| json
| unwrap response_time_ms
| __error__=""
[5m]
) by (endpoint)
# Average response time trend
avg_over_time(
{app="api-gateway"}
| json
| unwrap duration_seconds
| __error__=""
[5m]
) by (service)
# bytes_over_time - monitoring log volume
sum by (namespace) (bytes_over_time({namespace=~".+"} [1h]))
The __error__="" filter is the essential pattern for excluding lines that carry an error from the parsing or unwrap stage. Omit it and non-numeric values slip in, producing inaccurate results.
Practical Query Optimization Tips
- Make the stream selector as specific as possible. Instead of
{app="payment"}, adding labels as in{namespace="production", app="payment", env="prod"}shrinks the set of streams that has to be searched. - Place line filters ahead of parsers. The order
|= "error" | jsonis faster than| json |= "error", because the line filter runs first and fewer lines are left to parse. - Use pattern matching or string filters instead of regex.
|= "error" |= "timeout"is far faster than|~ "error.*timeout". - Keep the time range as narrow as possible. The performance gap between searching the last hour and the last 7 days is dramatic.
- Do not extract labels you do not need.
| jsonextracts every JSON field as a label, so when only specific fields are needed, name them explicitly as in| json status, duration.
Log Collection Pipeline Built on Grafana Alloy
Promtail entered LTS in February 2025 and reaches EOL in March 2026. Grafana Alloy is the official successor collector, and it collects not only logs but also metrics, traces, and profiles with a single agent.
Promtail vs Alloy
| Item | Promtail | Grafana Alloy |
|---|---|---|
| Collection scope | Logs only | Logs, metrics, traces, profiles |
| Configuration language | YAML (scrape_configs) | River (HCL-like DSL) |
| OTel compatibility | Not supported | Native OTLP support |
| Kubernetes log collection | File system based (/var/log/containers) | Kubernetes API based (loki.source.kubernetes) |
| Processing pipeline | stages block | loki.process component |
| Status | EOL (2026-03-02) | Actively developed |
| Migration tooling | - | alloy convert --source-format=promtail |
Alloy Configuration Example - Kubernetes Log Collection
// Kubernetes Pod discovery
discovery.kubernetes "pods" {
role = "pod"
}
// Label remapping - keep only the labels you need
discovery.relabel "pods" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app"]
target_label = "app"
}
}
// Kubernetes log source
loki.source.kubernetes "pods" {
targets = discovery.relabel.pods.output
forward_to = [loki.process.pipeline.receiver]
}
// Log processing pipeline
loki.process "pipeline" {
// Parse JSON logs
stage.json {
expressions = {
level = "level",
message = "msg",
}
}
// Normalize the level label
stage.label_drop {
values = ["filename", "stream"]
}
// Drop debug logs - cut volume in production
stage.match {
selector = "{level=\"debug\"}"
action = "drop"
}
forward_to = [loki.write.default.receiver]
}
// Send to Loki
loki.write "default" {
endpoint {
url = "http://loki-gateway.monitoring.svc:3100/loki/api/v1/push"
tenant_id = "production"
}
external_labels = {
cluster = "prod-kr-01",
region = "ap-northeast-2",
}
}
Migrating from Promtail to Alloy
A command is provided that converts an existing Promtail configuration to Alloy.
# Convert a Promtail config to Alloy River syntax
alloy convert --source-format=promtail --output=alloy-config.river promtail-config.yaml
# Verify the conversion result (dry-run)
alloy run --stability.level=generally-available alloy-config.river
# Deploy as a Kubernetes DaemonSet (Helm)
helm upgrade --install alloy grafana/alloy \
--namespace monitoring \
--set alloy.configMap=true \
-f alloy-values.yaml
Storage Design and Retention Management
TSDB Index Store
TSDB, introduced in Loki 2.8, is the currently recommended index store. Query performance improved over the older BoltDB Shipper, and TCO (Total Cost of Ownership) is lower. The index period must be set to 24 hours.
# loki.yaml - TSDB + S3 storage configuration
schema_config:
configs:
- from: '2024-01-01'
store: tsdb
object_store: s3
schema: v13
index:
prefix: loki_index_
period: 24h
storage_config:
tsdb_shipper:
active_index_directory: /loki/tsdb-index
cache_location: /loki/tsdb-cache
aws:
s3: s3://ap-northeast-2/company-loki-chunks
s3forcepathstyle: false
# Loki 3.4+ unified storage configuration (Thanos Object Storage Client)
common:
storage:
s3:
endpoint: s3.ap-northeast-2.amazonaws.com
region: ap-northeast-2
bucketnames: company-loki-data
access_key_id: ${AWS_ACCESS_KEY_ID}
secret_access_key: ${AWS_SECRET_ACCESS_KEY}
From Loki 3.4 the Thanos Object Storage Client is integrated, so the same storage configuration format is used as for other Grafana databases such as Mimir and Pyroscope. Existing AWS SDK based configuration still works, but the unified configuration is recommended for new deployments.
Configuring the Retention Policy
Retention is handled by the Compactor. retention_enabled: true must be set, and both global retention and per-stream retention are supported.
# loki.yaml - retention configuration
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
limits_config:
retention_period: 720h # global: 30 days
retention_stream:
- selector: '{namespace="production"}'
priority: 1
period: 2160h # production: 90 days
- selector: '{namespace="staging"}'
priority: 2
period: 168h # staging: 7 days
- selector: '{level="debug"}'
priority: 3
period: 72h # debug logs: 3 days
Caution: retention_delete_delay is the delete delay. If you set retention too short by mistake, reverting the setting within that window prevents data loss. Setting it to at least 2 hours is strongly recommended.
Storage Cost Optimization
- Chunk compression algorithm:
gzipcompresses better thansnappy(the default) but uses more CPU. If storage cost is the main concern pick gzip; if write performance matters pick snappy. - chunk_target_size: The default is 1.5MB. Raising it reduces the number of objects and therefore the API call cost, but increases Ingester memory usage.
- S3 Intelligent-Tiering: Log access patterns fall off over time, so applying S3 Intelligent-Tiering or a lifecycle policy cuts long-term retention cost.
Loki vs Elasticsearch
This section lays out the differences between the two systems most often compared when choosing a log management solution.
| Comparison item | Grafana Loki | Elasticsearch (ELK) |
|---|---|---|
| Indexing approach | Labels only | Full-text indexing |
| Storage cost | Very low (object storage) | High (SSD block storage) |
| Query language | LogQL | KQL / Lucene |
| Search speed (unstructured) | Slow (grep style) | Fast (inverted index) |
| Search speed (label-based) | Fast | Fast |
| Memory requirement | Low | High (JVM heap) |
| Operational complexity | Medium | High (sharding, rebalancing) |
| Grafana integration | Native | Plugin required |
| Cost at 100GB/day | 20-30% of Elasticsearch | Baseline |
| Suitable environment | Kubernetes, cloud native | Full-text search, security analytics (SIEM) |
When to choose Loki: you need cost-efficient log management in a Kubernetes environment, you use Grafana as your main dashboard, and label-based structured queries are your primary pattern.
When to keep Elasticsearch: arbitrary full-text search over unstructured logs is frequent, or you use logs for security log analysis (SIEM) or business intelligence.
Configuring Alert Rules
Loki supports LogQL-based alert rules; the Ruler component runs the queries periodically and sends an alert to Alertmanager when the condition holds.
# loki-alert-rules.yaml
groups:
- name: application-errors
rules:
- alert: HighErrorRate
expr: |
sum by (app, namespace) (
rate({namespace="production"} |= "error" [5m])
) > 10
for: 5m
labels:
severity: critical
annotations:
summary: 'High error rate in {{ $labels.app }}'
description: '{{ $labels.app }} in {{ $labels.namespace }} has error rate {{ $value }}/s for 5 minutes.'
- alert: SlowResponseTime
expr: |
quantile_over_time(0.95,
{app="api-gateway"}
| json
| unwrap response_time_ms
| __error__=""
[5m]
) by (app) > 3000
for: 10m
labels:
severity: warning
annotations:
summary: 'P95 response time exceeds 3s for {{ $labels.app }}'
- alert: LogVolumeSpike
expr: |
sum by (namespace) (bytes_over_time({namespace=~".+"} [5m]))
/ sum by (namespace) (bytes_over_time({namespace=~".+"} [5m] offset 1h))
> 3
for: 15m
labels:
severity: warning
annotations:
summary: 'Log volume spike in {{ $labels.namespace }} (3x increase)'
The LogVolumeSpike alert is particularly useful. A sudden jump in log volume can mean the application has a problem, or that a wrong debug setting was deployed. Left alone it leads to Ingester OOM or an explosion in storage cost.
Practical Troubleshooting
Symptom 1: Ingester OOM (Out of Memory)
Cause: Label cardinality is too high, or a single tenant has an excessive number of active streams. Typically this happens when unique values such as request_id or trace_id are used as labels.
Diagnosis:
# Check the number of active streams
curl -s http://loki:3100/metrics | grep loki_ingester_streams_created_total
# Check the stream count per tenant
curl -s http://loki:3100/loki/api/v1/index/stats \
--header "X-Scope-OrgID: production" \
--data-urlencode 'query={namespace="production"}' \
--data-urlencode 'start=1h'
# Check Ingester memory usage
kubectl top pods -n monitoring -l app.kubernetes.io/component=ingester
Solution:
- Move high-cardinality labels to structured metadata.
- Set the
max_streams_per_userlimit appropriately. - Raise the Ingester memory limit or increase replicas.
Symptom 2: Query Timeout
Cause: The query time range is too wide, or the stream selector is too broad.
Solution:
# limits_config tuning
limits_config:
max_query_length: 721h # maximum query time range
max_query_parallelism: 32 # query parallelism
query_timeout: 5m # single query timeout
split_queries_by_interval: 30m # query split interval
max_query_series: 500 # maximum number of series
Symptom 3: Out-of-Order Logs Rejected
By default Loki requires logs to arrive in timestamp order. When several agents write to the same stream, or when there is network delay, the order can be reversed.
# Loki 3.4+ out-of-order tolerance setting
limits_config:
unordered_writes: true
max_chunk_age: 2h
Symptom 4: Compactor Lag
When the Compactor cannot keep up, index files pile up and query performance degrades.
Solution: Use the horizontally scalable Compactor in Loki 3.6, or lower compaction_interval and raise the CPU/memory resources of the Compactor.
Disaster Recovery Procedures
Ingester Failure Recovery
# 1. Identify the failed Ingester pod
kubectl get pods -n monitoring -l app.kubernetes.io/component=ingester
# 2. Check the WAL state
kubectl exec -n monitoring ingester-0 -- ls -la /loki/wal/
# 3. Restart the failed pod (data is recoverable because of the WAL)
kubectl delete pod -n monitoring ingester-0
# 4. Check the hash ring state after recovery
curl -s http://loki:3100/ring | jq '.shards[] | {addr, state, tokens}'
# 5. Force a flush (if needed)
curl -X POST http://loki:3100/ingester/flush
Object Storage Access Failure
When object storage cannot be reached, data keeps piling up in the Ingester WAL until OOM eventually occurs.
- Immediately check the S3/GCS connection state and the IAM permissions.
- Temporarily raising the Ingester
flush_check_periodandchunk_idle_periodbuys time. - Once storage recovers, flush the accumulated data with
POST /ingester/flush. - During a long outage, monitor Ingester disk capacity and expand the PVC if necessary.
Summary of Major Loki 3.x Changes
- 3.0: Bloom filter based query acceleration (experimental), native OpenTelemetry support, pattern match filter operators introduced
- 3.3: Query performance improvements, stronger Structured Metadata support
- 3.4: Thanos Object Storage Client integration, sizing guidance published, Promtail merging into Alloy announced
- 3.6: Horizontally scalable Compactor, refactored from OpenTracing to the OpenTelemetry tracing library
Bloom filters greatly speed up filter queries that search for a specific text string such as an error message or a UUID. The feature is still experimental, but in large environments it can dramatically reduce grep-style full scans.
Operations Checklist
This section lists the items to confirm before a production Loki deployment.
Pre-deployment
- Has the daily log volume been estimated and the deployment mode decided?
- Is the TSDB index store in use, with period set to 24h?
- Have the object storage bucket and IAM permissions been created?
- Is replication_factor set to 3 or more (production)?
- Is the Ingester memory limit generous enough (at least 4Gi recommended)?
- Is the PVC for the WAL directory large enough?
Label Design
- Is label cardinality at a manageable level (within tens of thousands of streams)?
- Are unique values such as request_id, trace_id, and user_id kept out of labels?
- Are only fixed labels such as namespace, app, and env in use?
- Has searchable metadata been split out using Structured Metadata?
In Production
- Is retention_enabled true on the Compactor?
- Is the retention policy differentiated per namespace and environment?
- Are Ingester memory usage and active stream count monitored?
- Is an alert configured for log volume spikes?
- Is there a volume control pipeline in Alloy, such as dropping debug logs?
- Is LogQL query performance reviewed regularly?
Conclusion
Grafana Loki is the system that proved "large-scale logs can be managed efficiently even without an index". In most operational environments that do not need the full-text indexing of Elasticsearch, Loki delivers the same level of incident analysis capability at 70-80% less cost.
The key is label design. Too many labels cause Ingester OOM and performance degradation; too few turn every query into a full scan. Starting with labels at the namespace, app, env, and level granularity and then adjusting incrementally against real query patterns is the recommended path.
The Bloom filters, pattern match filters, and native OTel support in Loki 3.x are quickly closing the old gap of "search is slow". Moving from Promtail to Alloy and adopting the TSDB index store are no longer optional but essential. Starting with Simple Scalable mode and switching to Microservices mode as log volume grows is the most realistic strategy for reducing failure.
References
- Grafana Loki Architecture - Official Docs
- Loki Deployment Modes - Official Docs
- LogQL Query Reference - Official Docs
- Loki Storage and Retention - Official Docs
- Grafana Loki 3.4 Release - Standardized Storage and Promtail Merging into Alloy
- Migrate from Promtail to Grafana Alloy - Official Docs
- Single Store TSDB - Official Docs
- Loki v3.6 Release Notes
- Loki Metric Queries - Official Docs