20 min read

Centralised Logging at Scale: Architecture, Tools, and Best Practices

Build reliable log aggregation infrastructure that scales. Learn structured logging best practices, compare ELK Stack vs Grafana Loki, and implement Kubernetes logging patterns for production environments.

Centralised Logging at Scale: ELK, Splunk, and Loki

Key Takeaways

  • Centralised logging enables debugging, compliance, and incident response across distributed systems
  • Structured JSON logging with correlation IDs is essential for queryability
  • Grafana Loki offers a cost-effective alternative to Elasticsearch for log aggregation
  • Log retention policies must balance operational needs with compliance and cost
  • DaemonSet pattern is the standard approach for Kubernetes log collection

Why Centralised Logging?

In distributed systems, logs are scattered across dozens or hundreds of containers, VMs, and serverless functions. Without centralisation, debugging becomes a nightmare of SSH sessions and fragmented context.

Centralised Logging Architecture diagram showing log sources flowing through collectors and processing pipelines to storage backends and visualisation dashboards
Centralised Logging Architecture: Logs flow from sources through collectors and processing pipelines to storage and analysis tools

Key Benefits

  • Unified Search: Query logs across all services from a single interface
  • Correlation: Link logs to traces and metrics using correlation IDs
  • Retention: Meet compliance requirements with consistent retention policies
  • Alerting: Detect patterns and anomalies across the entire system
  • Incident Response: Quickly investigate issues without accessing individual hosts

Compliance Requirements

Regulations like GDPR, PCI-DSS, and SOC 2 require audit trails and log retention. Centralised logging provides the foundation for compliance with consistent policies and access controls.

Log Aggregation Architecture

A typical log aggregation pipeline consists of collection agents, optional message queues for buffering, a storage backend, and a query interface.

┌─────────────────────────────────────────────────────────────────────┐
│                     LOG AGGREGATION ARCHITECTURE                     │
└─────────────────────────────────────────────────────────────────────┘

┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐
│ Service A│  │ Service B│  │ Service C│  │ Service D│
│  (logs)  │  │  (logs)  │  │  (logs)  │  │  (logs)  │
└────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘
     │             │             │             │
     └──────┬──────┴──────┬──────┴──────┬──────┘
            │             │             │
            ▼             ▼             ▼
     ┌─────────────────────────────────────────┐
     │         Collection Agents               │
     │    (Fluent Bit / Fluentd / Vector)      │
     └─────────────────┬───────────────────────┘
                       │
                       ▼
     ┌─────────────────────────────────────────┐
     │      Message Queue (optional)           │
     │         (Kafka / Redis)                 │
     └─────────────────┬───────────────────────┘
                       │
                       ▼
     ┌─────────────────────────────────────────┐
     │         Storage Backend                 │
     │  (Elasticsearch / Loki / CloudWatch)    │
     └─────────────────┬───────────────────────┘
                       │
                       ▼
     ┌─────────────────────────────────────────┐
     │        Query & Visualisation            │
     │    (Kibana / Grafana / CloudWatch)      │
     └─────────────────────────────────────────┘

Collection Agent Comparison

AgentMemoryThroughputBest For
Fluent Bit~2MBHighResource-constrained environments, Kubernetes
Fluentd~40MBMediumComplex routing, many plugins
Vector~10MBVery HighHigh-performance pipelines
Logstash~1GBMediumComplex transformations, ELK stack

Structured Logging Best Practices

Structured logging outputs logs in a machine-parseable format (typically JSON) rather than unstructured text. This enables powerful queries and correlation.

JSON Logging Format

TYPESCRIPT
// Bad: Unstructured log// Output: User 123 purchased item 456 for £99.99// Output: User 123 purchased item 456 for £99.99// Good: Structured JSON logGood: Structured JSON log
logger.info({
  event: "purchase_completed",
  user_id: "123",
  item_id: "456",
  amount: 99.99,
  currency: "GBP",
  trace_id: "abc123def456",
  span_id: "789xyz",
  timestamp: "2025-12-19T10:30:00Z",
  level: "info",
  service: "checkout-service",
  version: "1.2.3"
});

Log Levels

  • TRACE: Very detailed diagnostic info (usually disabled in production)
  • DEBUG: Detailed diagnostic information for developers
  • INFO: General operational information
  • WARN: Potential issues that don't prevent operation
  • ERROR: Errors that need attention but aren't critical
  • FATAL: Critical errors causing system shutdown

Correlation IDs

Include trace IDs and correlation IDs in every log entry to link logs across services and correlate with distributed traces.

TYPESCRIPT
// Node.js middleware for correlation IDs// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation context// Logger that automatically includes correlation contextogger that automatically includes correlation context
function log(level, message, data = {}) {
  const context = asyncLocalStorage.getStore() || {};
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    level,
    message,
    correlation_id: context.correlationId,
    trace_id: context.traceId,
    ...data
  }));
}

Sensitive Data Handling

Never Log Sensitive Data

Avoid logging passwords, API keys, credit card numbers, or PII. Use redaction or masking for any potentially sensitive fields.

TYPESCRIPT
// PII redaction example
function redactPII(data) {
  const sensitiveFields = ['password', 'credit_card', 'ssn', 'email'];
  const redacted = { ...data };
  
  for (const field of sensitiveFields) {
    if (redacted[field]) {
      redacted[field] = '[REDACTED]';
    }
  }
  
  // Mask email partially
  if (redacted.user_email) {
    const [local, domain] = redacted.user_email.split('@');
    redacted.user_email = `${local.slice(0, 2)}***@${domain}`;
  }
  
  return redacted;
}

ELK Stack Setup Guide

The ELK Stack (Elasticsearch, Logstash, Kibana) is a popular open-source solution for log aggregation. Elasticsearch stores and indexes logs, Logstash processes them, and Kibana provides visualisation.

Elasticsearch Architecture

  • Nodes: Individual Elasticsearch instances in a cluster
  • Indices: Collections of documents (like database tables)
  • Shards: Subdivisions of indices for distribution
  • Replicas: Copies of shards for fault tolerance

Logstash Pipeline

BASH
# logstash.conf
input {
  beats {
    port => 5044
  }
}

filter {
  # Parse JSON logs
  json {
    source => "message"
    target => "parsed"
  }
  
  # Add geolocation for IP addresses
  geoip {
    source => "[parsed][client_ip]"
    target => "geoip"
  }
  
  # Parse timestamps
  date {
    match => ["[parsed][timestamp]", "ISO8601"]
    target => "@timestamp"
  }
  
  # Remove sensitive fields
  mutate {
    remove_field => ["[parsed][password]", "[parsed][credit_card]"]
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "logs-%{[parsed][service]}-%{+YYYY.MM.dd}"
    user => "elastic"
    password => "${ELASTIC_PASSWORD}"
  }
}

Index Lifecycle Management (ILM)

JSON
# Create ILM policy
PUT _ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_size": "50gb",
            "max_age": "1d"
          }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "shrink": {
            "number_of_shards": 1
          },
          "forcemerge": {
            "max_num_segments": 1
          }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "freeze": {}
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": {
          "delete": {}
        }
      }
    }
  }
}

Grafana Loki Setup Guide

Grafana Loki is a cost-effective alternative to Elasticsearch. Unlike ELK, Loki only indexes labels (metadata), not the full log content, making it significantly cheaper to operate at scale.

Loki Architecture

  • Distributor: Receives logs from clients, validates, and forwards
  • Ingester: Builds compressed chunks of log data
  • Querier: Handles LogQL queries against stored logs
  • Compactor: Deduplicates and compacts index files

When to Choose Loki over ELK

  • Cost is a primary concern (lower operating cost at scale, per Grafana benchmarks)
  • You already use Grafana for metrics dashboards
  • Simple label-based queries are sufficient
  • You don't need full-text search on log content

Promtail Configuration

YAML
# promtail-config.yaml
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: kubernetes-pods
    kubernetes_sd_configs:
      - role: pod
    
    relabel_configs:
      # Keep logs from pods with logging enabled
      - source_labels: [__meta_kubernetes_pod_annotation_logging_enabled]
        action: keep
        regex: "true"
      
      # Set namespace label
      - source_labels: [__meta_kubernetes_namespace]
        target_label: namespace
      
      # Set pod name label
      - source_labels: [__meta_kubernetes_pod_name]
        target_label: pod
      
      # Set container name label
      - source_labels: [__meta_kubernetes_container_name]
        target_label: container
    
    pipeline_stages:
      # Parse JSON logs
      - json:
          expressions:
            level: level
            trace_id: trace_id
      
      # Add level as label
      - labels:
          level:

LogQL Query Examples

BASH
# Basic label filter# Filter by log content# Filter by log content# JSON parsing and filtering# JSON parsing and filtering# JSON parsing and filtering# Regex matching# Regex matching# Regex matching# Regex matching# Aggregation - error count per service# Aggregation - error count per service# Rate of errors# Rate of errors# Rate of errors# Rate of errors# Rate of errors# Rate of errors# Rate of errorsof errors
sum(rate({namespace="production"} |= "error" [1m]))

Cloud-Native Logging Services

Cloud providers offer managed logging services that integrate tightly with their ecosystems. These reduce operational overhead but may result in vendor lock-in.

AWS CloudWatch Logs

Cloud Native

Features

  • Log Insights queries
  • Metric filters
  • Cross-account aggregation
  • Lambda integration

Best For

AWS-native workloads needing tight integration

Pricing

Per GB ingested + stored

Azure Monitor Logs

Cloud Native

Features

  • KQL queries
  • Log Analytics workspace
  • Application Insights correlation
  • Workbooks

Best For

Azure workloads and hybrid environments

Pricing

Per GB ingested with commitment tiers

Google Cloud Logging

Cloud Native

Features

  • BigQuery export
  • Error Reporting integration
  • Log-based metrics
  • Log Router

Best For

GCP workloads and BigQuery analytics

Pricing

Per GB ingested above free tier

Datadog Logs

Commercial

Features

  • Live tail
  • Log patterns
  • Trace correlation
  • Archive to cloud storage

Best For

Teams wanting unified observability platform

Pricing

Per GB ingested + retention

Log Retention and Compliance

Retention Policies

Balance operational needs, compliance requirements, and storage costs when defining retention policies.

Log TypeHot StorageCold StorageArchive
Application Logs7-30 days90 days1 year
Security/Audit Logs30 days1 year7 years
Debug Logs3-7 days--
Financial/PCI30 days1 year7 years

GDPR Considerations

  • Implement right to erasure for PII in logs
  • Minimise personal data in logs where possible
  • Document retention periods and legal basis
  • Ensure logs are processed in compliant regions

Cost Optimisation

BASH
# Archive logs to S3 with lifecycle policy
resource "aws_s3_bucket_lifecycle_configuration" "logs_archive" {
  bucket = aws_s3_bucket.logs_archive.id

  rule {
    id     = "archive-old-logs"
    status = "Enabled"

    transition {
      days          = 30
      storage_class = "STANDARD_IA"
    }

    transition {
      days          = 90
      storage_class = "GLACIER"
    }

    transition {
      days          = 365
      storage_class = "DEEP_ARCHIVE"
    }

    expiration {
      days = 2555  # 7 years
    }
  }
}

Kubernetes Logging Patterns

DaemonSet Pattern (Recommended)

Deploy a log collector as a DaemonSet to run on every node. This is the most common and efficient pattern for Kubernetes logging.

YAML
# fluent-bit-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluent-bit
  namespace: logging
  labels:
    app: fluent-bit
spec:
  selector:
    matchLabels:
      app: fluent-bit
  template:
    metadata:
      labels:
        app: fluent-bit
    spec:
      serviceAccountName: fluent-bit
      tolerations:
        - key: node-role.kubernetes.io/master
          effect: NoSchedule
      containers:
        - name: fluent-bit
          image: fluent/fluent-bit:2.2
          resources:
            limits:
              memory: 200Mi
              cpu: 200m
            requests:
              memory: 100Mi
              cpu: 100m
          volumeMounts:
            - name: varlog
              mountPath: /var/log
              readOnly: true
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
            - name: config
              mountPath: /fluent-bit/etc/
      volumes:
        - name: varlog
          hostPath:
            path: /var/log
        - name: varlibdockercontainers
          hostPath:
            path: /var/lib/docker/containers
        - name: config
          configMap:
            name: fluent-bit-config

Sidecar Pattern

Use sidecars when applications write logs to files rather than stdout, or when you need application-specific log processing.

Fluent Bit Configuration for Kubernetes

BASH
# fluent-bit.conf
[SERVICE]
    Flush         1
    Log_Level     info
    Daemon        off
    Parsers_File  parsers.conf

[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Parser            docker
    Tag               kube.*
    Refresh_Interval  5
    Mem_Buf_Limit     50MB
    Skip_Long_Lines   On

[FILTER]
    Name                kubernetes
    Match               kube.*
    Kube_URL            https://kubernetes.default.svc:443
    Kube_CA_File        /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
    Kube_Token_File     /var/run/secrets/kubernetes.io/serviceaccount/token
    Merge_Log           On
    K8S-Logging.Parser  On
    K8S-Logging.Exclude On

[FILTER]
    Name    modify
    Match   kube.*
    Remove  stream
    Remove  logtag

[OUTPUT]
    Name            loki
    Match           kube.*
    Host            loki.logging.svc.cluster.local
    Port            3100
    Labels          job=fluent-bit, cluster=production
    Auto_Kubernetes_Labels on

Alerting on Logs

Pattern-Based Alerting

Alert on specific log patterns that indicate problems:

YAML
# Prometheus alerting rules for log-based metrics
groups:
  - name: log-alerts
    rules:
      # Alert on high error rate
      - alert: HighErrorRate
        expr: |
          sum(rate(
            log_messages_total{level="error"}[5m]
          )) by (service) > 10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate in {{ $labels.service }}"
          description: "Error rate is {{ $value }} errors/sec"

      # Alert on specific error patterns
      - alert: DatabaseConnectionErrors
        expr: |
          sum(rate(
            log_messages_total{message=~".*connection refused.*database.*"}[5m]
          )) > 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Database connection errors detected"

      # Alert on authentication failures
      - alert: AuthenticationFailureSpike
        expr: |
          sum(rate(
            log_messages_total{event="auth_failed"}[5m]
          )) > 100
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Spike in authentication failures"

Log-Based Metrics

Convert log patterns to metrics for alerting and dashboards:

BASH
# CloudWatch Logs metric filter
resource "aws_cloudwatch_log_metric_filter" "errors" {
  name           = "error-count"
  pattern        = "[timestamp, level=ERROR, ...]"
  log_group_name = aws_cloudwatch_log_group.app.name

  metric_transformation {
    name      = "ErrorCount"
    namespace = "Application"
    value     = "1"
    dimensions = {
      Service = "$.service"
    }
  }
}

resource "aws_cloudwatch_metric_alarm" "high_errors" {
  alarm_name          = "high-error-rate"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "ErrorCount"
  namespace           = "Application"
  period              = 300
  statistic           = "Sum"
  threshold           = 50
  alarm_description   = "Error count exceeded threshold"
  alarm_actions       = [aws_sns_topic.alerts.arn]
}

Troubleshooting

Common issues and solutions when implementing centralised logging at scale.

Log Ingestion Backpressure

Symptom: Logs delayed or dropped during high-volume periods.

Common causes:

  • Shipper buffer limits exceeded
  • Elasticsearch/OpenSearch indexing too slow
  • Network bandwidth saturation
  • Parser errors causing retries

Solution:

# Fluent Bit - enable disk buffering
[SERVICE]
    storage.path              /var/log/flb-storage/
    storage.sync              normal
    storage.checksum          off
    storage.backlog.mem_limit 50M

[OUTPUT]
    Name             es
    storage.total_limit_size 5G
    Retry_Limit      False

# Elasticsearch - increase refresh interval during bulk loads
PUT /logs-*/_settings
{
  "index": {
    "refresh_interval": "30s",
    "number_of_replicas": 0
  }
}

Parsing Failures Causing Data Loss

Symptom: Logs arriving but fields not extracted, or logs routed to error queue.

Common causes:

  • Log format changed without updating parsers
  • Multi-line logs not handled correctly
  • Character encoding issues
  • Regex patterns too strict

Solution:

# Route unparseable logs to separate index for debugging
[FILTER]
    Name   parser
    Match  app.*
    Key_Name log
    Parser json
    Reserve_Data On
    Preserve_Key On

[OUTPUT]
    Name  es
    Match app.*
    Tag_Key @log_tag
    # Failed parses retain original message

# Use dead letter queue for investigation
[OUTPUT]
    Name  file
    Match _error.*
    Path  /var/log/flb-errors/

Query Performance Degradation

Symptom: Dashboard loading slowly, searches timing out.

Common causes:

  • Too many indices being searched
  • High-cardinality fields not optimised
  • Missing index patterns or aliases
  • Cluster resources exhausted

Solution:

# Use data streams and ILM for time-based access patterns
PUT _ilm/policy/logs-policy
{
  "policy": {
    "phases": {
      "hot": { "actions": { "rollover": { "max_size": "50GB", "max_age": "1d" }}},
      "warm": { "min_age": "7d", "actions": { "shrink": { "number_of_shards": 1 }}},
      "cold": { "min_age": "30d", "actions": { "freeze": {} }},
      "delete": { "min_age": "90d", "actions": { "delete": {} }}
    }
  }
}

# Optimise field mappings
PUT _index_template/logs
{
  "mappings": {
    "properties": {
      "message": { "type": "text", "index": false },
      "level": { "type": "keyword" }
    }
  }
}

Storage Costs Spiralling

Symptom: Log storage growing faster than expected, high monthly bills.

Common causes:

  • Debug logging enabled in production
  • No retention policies configured
  • Duplicate logs from multiple collectors
  • Large stack traces and payloads

Solution:

# Sample verbose logs before ingestion
[FILTER]
    Name         throttle
    Match        app.debug.*
    Rate         100
    Window       60
    Print_Status true

# Drop unnecessary fields to reduce storage
[FILTER]
    Name record_modifier
    Match *
    Remove_key kubernetes.pod_id
    Remove_key kubernetes.container_hash

# Compress cold data
PUT _ilm/policy/logs
{
  "phases": {
    "warm": {
      "actions": {
        "forcemerge": { "max_num_segments": 1 },
        "shrink": { "number_of_shards": 1 }
      }
    }
  }
}

Missing Logs from Specific Services

Symptom: Some services' logs not appearing in central system.

Common causes:

  • Log shipper not deployed to all nodes
  • Container logs not writing to stdout/stderr
  • Log path misconfigured
  • Namespace or label selector excluding pods

Solution:

# Verify DaemonSet is running on all nodes
kubectl get ds fluent-bit -n logging -o wide

# Check if container writes to stdout
kubectl logs <pod-name> -c <container-name>

# Verify input configuration includes the service
[INPUT]
    Name              tail
    Path              /var/log/containers/*.log
    Exclude_Path      /var/log/containers/*_kube-system_*.log
    Tag               kube.*

# Check for pod annotations that might exclude logging
kubectl get pod <pod-name> -o yaml | grep -A5 annotations

Conclusion

Centralised logging is essential for operating distributed systems at scale. By aggregating logs from all services into a single queryable store, teams can debug issues faster, meet compliance requirements, and gain insights into system behaviour.

Choose your logging stack based on your needs: ELK for full-text search, Loki for cost-effective label-based queries, or cloud-native services for reduced operational overhead. Regardless of the backend, invest in structured logging, correlation IDs, and thoughtful retention policies.

For Kubernetes environments, the DaemonSet pattern with Fluent Bit provides an efficient, low-overhead solution that scales with your cluster. Combined with log-based alerting, you'll have complete visibility into your applications' behaviour.

Frequently Asked Questions

Centralised logging is the practice of aggregating logs from multiple distributed services, containers, and infrastructure components into a single, unified storage and query system. This enables teams to search, analyse, and correlate logs across the entire system from one interface, making debugging, incident response, and compliance auditing significantly easier than accessing logs on individual hosts.

The ELK Stack is a popular open-source log aggregation solution consisting of three components: Elasticsearch (a distributed search and analytics engine for storing and indexing logs), Logstash (a data processing pipeline for ingesting and transforming logs), and Kibana (a visualisation platform for querying and dashboarding). Many organisations also add Beats or Fluent Bit as lightweight log shippers, making it the 'Elastic Stack'.

Grafana Loki is a cost-effective alternative to Elasticsearch that only indexes labels (metadata) rather than full log content. In Grafana's own benchmarks, Loki costs significantly less to operate at scale, though results vary by workload. The tradeoff is that you cannot perform full-text search on log content. Loki is ideal when you already use Grafana, want label-based queries similar to Prometheus, and cost is a primary concern. Elasticsearch is better when you need full-text search across log content.

Structured logging outputs logs in a machine-parseable format, typically JSON, rather than unstructured plain text. Each log entry contains discrete fields (timestamp, level, service, trace_id, etc.) that can be queried and filtered. This enables detailed analysis, correlation across services using trace IDs, and easier integration with log aggregation systems compared to parsing free-form text with regex patterns.

Log retention depends on the log type and compliance requirements. Application logs typically need 7-30 days in hot storage and up to 1 year archived. Security and audit logs often require 1-7 years retention for regulations like PCI-DSS and SOC 2. Debug logs can usually be deleted after 3-7 days. Use tiered storage (hot, warm, cold, archive) to balance cost with accessibility, moving older logs to cheaper storage like S3 Glacier.

The recommended pattern for Kubernetes logging is deploying a log collector (like Fluent Bit) as a DaemonSet, ensuring one collector runs on every node. Applications should write logs to stdout/stderr rather than files, allowing Kubernetes to capture them automatically. Use structured JSON logging with correlation IDs, add Kubernetes metadata (namespace, pod, container) as labels, and implement log sampling for high-volume debug logs to control costs.

References & Further Reading

Related Articles

Ayodele Ajayi

Principal Engineer based in Kent, UK. Specialising in security governance, cloud architecture, and platform engineering.