Skip to main content
RunBook Academy

Docker & ContainersXVII Β· LoggingCentral collection

Central log collection β€” Fluent Bit, Vector, Loki

Intermediate⏱ ~30 mindocker

What you'll learn

  • Compare driver-push, file-tail and API-read collection topologies
  • Explain which topology couples application availability to the log pipeline
  • Configure Fluent Bit or Vector against a Docker host, with state that survives a restart
  • Recognise the rotation race between the shipper and the logging driver
  • Verify end to end that a line emitted on the host arrives in the store

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-12

Not yet marked complete on this device.

Per-host logs answer β€œwhat did this container do”. Central logs answer β€œwhy did the order fail”, which is the question that spans six services and three hosts and is the only one anybody asks during an incident.

Getting there is not one architecture. It is three, and they differ in exactly the property that matters when things break: what happens to your application when the log destination is unavailable.

The three topologies

flowchart LR
  subgraph A["A - driver push"]
    C1[Container] --> D1[Daemon + remote driver] --> S1[(Store)]
  end
  subgraph B["B - agent tails files"]
    C2[Container] --> D2[Daemon json-file] --> F2["/var/lib/docker/containers"] --> AG2[Fluent Bit] --> S2[(Store)]
  end
  subgraph C["C - agent reads the API"]
    C3[Container] --> D3[Daemon any driver] --> API["/containers/id/logs"] --> AG3[Vector] --> S3[(Store)]
  end
A: driver pushB: agent tails filesC: agent reads API
Driver requiredfluentd, gelf, syslog, …json-file onlyany readable driver
Store outage blocks the appYes, by defaultNoNo
Local copy retaineddual-logging cachethe json filesthe driver store
Extra process per hostnoneone agentone agent
Metadata availabledriver-attachedparsed from pathfull container object
Survives agent restartn/awith a position DBwith the driver store

A β€” the driver pushes

The daemon’s logging driver connects directly to the aggregator. No agent, no files, fewest moving parts.

It is also the topology that makes your log aggregator a synchronous dependency of every container on the host. Delivery mode defaults to blocking, so when the aggregator is unreachable the container’s write to stdout does not complete and the application stalls. mode=non-blocking with a bounded max-buffer-size mitigates it by dropping lines instead.

Choose A when you have very tight host resource constraints and you have consciously accepted the coupling and set mode=non-blocking.

B β€” an agent tails the JSON files

The daemon writes json-file as usual. A Fluent Bit or Vector process on the host reads /var/lib/docker/containers/*/*.log, parses, enriches, and ships.

The container never touches the network path. If the store is down, the agent buffers or stalls and the application does not notice. This is the default recommendation for a reason.

C β€” an agent reads the Docker API

Vector’s docker_logs source retrieves logs through the Docker API rather than reading files directly. That makes it driver-agnostic: it works with json-file, local, journald, and β€” via the dual-logging cache β€” with remote drivers too.

It also gets the full container object for free, so container_name, image, and container labels arrive as structured fields rather than being reconstructed from a filesystem path. The cost is a socket dependency: the agent needs access to the Docker API.

Fluent Bit against a Docker host

The keys below are the ones that make the difference between a demo and something that survives a restart:

[SERVICE]
    Flush             5
    Daemon            off
    Log_Level         info
    storage.path      /var/lib/fluent-bit/storage
    storage.sync      normal
    storage.backlog.mem_limit  64M

[INPUT]
    Name              tail
    Path              /var/lib/docker/containers/*/*-json.log
    Parser            docker
    Tag               docker.*
    DB                /var/lib/fluent-bit/tail.db
    Docker_Mode       On
    Skip_Long_Lines   On
    Refresh_Interval  5
    Rotate_Wait       30
    Mem_Buf_Limit     32M
    storage.type      filesystem

[FILTER]
    Name              record_modifier
    Match             docker.*
    Record            host ${HOSTNAME}

[OUTPUT]
    Name              loki
    Match             *
    Host              loki.example.com
    Port              3100
    labels            job=docker, host=${HOSTNAME}
    label_keys        $container_name
    line_format       json

Four of those keys are the ones people leave out and then have an incident about:

  • DB persists the inode-to-offset mapping in a SQLite file. Without it, an agent restart re-reads every log file from the beginning, duplicating weeks of records into the store, or from the end, losing everything written while it was down. Neither is acceptable and the default is no DB at all.
  • Rotate_Wait (default 5 seconds) is how long the agent keeps watching a file after it has been rotated, to drain what is still buffered. Five seconds is not enough on a busy host; see the race below.
  • Docker_Mode (default false) reassembles log lines that Docker split because they exceeded its internal read buffer. Without it, a long JSON log line arrives as several fragments, none of which parses.
  • Skip_Long_Lines (default false) makes the agent skip a line longer than its buffer instead of stopping. Off means one pathological line can wedge the input.

Note what is not in that config: there is no kubernetes filter. That filter enriches records by querying a Kubernetes API server for pod metadata, and on a plain Docker host there is no API server to query. It is a common copy-paste from a Kubernetes example and it does nothing useful here.

Vector against a Docker host

[sources.docker]
type = "docker_logs"
auto_partial_merge = true
exclude_containers = ["vector"]

[transforms.parse]
type = "remap"
inputs = ["docker"]
source = '''
. = parse_json(string!(.message)) ?? { "message": .message }
.container_name = .container_name
'''

[sinks.loki]
type = "loki"
inputs = ["parse"]
endpoint = "http://loki.example.com:3100"
labels = { job = "docker", host = "{{ host }}", container = "{{ container_name }}" }

[sinks.loki.encoding]
codec = "json"

[sinks.loki.buffer]
type = "disk"
max_size = 536870912
when_full = "block"

exclude_containers keeps the agent from ingesting its own output, which is otherwise a genuine feedback loop: the agent logs a delivery failure, reads its own failure message, tries to deliver it, fails, logs that. On a store outage this produces exponential growth of purely self-referential records.

auto_partial_merge (default true) is Vector’s equivalent of Fluent Bit’s Docker_Mode: Docker splits log messages that exceed 16 KB, and this reassembles them.

The rotation race

This is the failure specific to topology B, and it is the reason lines go missing from the store while every component reports healthy.

Read-only / Safemeasure your own budget
CONTAINER=api
CID=$(docker inspect --format '{{.Id}}' "$CONTAINER")
LOGFILE="/var/lib/docker/containers/$CID/$CID-json.log"

A=$(sudo stat -c %s "$LOGFILE"); sleep 10; B=$(sudo stat -c %s "$LOGFILE")
RATE=$(( (B - A) / 10 ))

SIZE_MB=$(docker inspect --format '{{index .HostConfig.LogConfig.Config "max-size"}}' "$CONTAINER")
FILES=$(docker inspect --format '{{index .HostConfig.LogConfig.Config "max-file"}}' "$CONTAINER")
echo "rate=$RATE bytes/s  max-size=$SIZE_MB  max-file=$FILES"
echo "buffer seconds = (max-size bytes * max-file) / $RATE"

Choosing the store

StoreIndexing modelStrengthWatch out for
LokiLabels only; log body unindexedCheapest at volume; native GrafanaLabel cardinality
Elasticsearch / OpenSearchFull textArbitrary queries, aggregationsCost and cluster operations
SplunkFull textCompliance tooling, mature RBACLicensing by ingest volume
CloudWatch LogsLimitedZero operations on AWSQuery ergonomics, per-GB cost
Datadog / SaaSFull textNo infrastructureIngest-based pricing surprises

For a team standing something up today: Loki and Grafana. It is free, it operates simply, and its cost model matches container logging β€” you pay for bytes stored, not for indexing every field of every line.

Verification that can fail

Configuration audits prove nothing about a pipeline. Emit a unique marker on the host and confirm it arrives at the far end.

Read-only / Safeend-to-end probe
MARKER="probe-$(hostname -s)-$(date +%s)"

docker run --rm alpine:3.20 echo "$MARKER"

# Checkpoint 1: did the daemon capture it? (json-file / local / journald)
sudo grep -rl --include='*-json.log' -F "$MARKER" /var/lib/docker/containers/ \
&& echo 'OK: daemon captured the line' \
|| echo 'FAIL: driver did not write it locally'

# Checkpoint 2: did the shipper read it?
docker logs --since 2m fluent-bit 2>&1 | tail -20

# Checkpoint 3: query the store. Adjust for your Loki endpoint.
LOKI=http://loki.example.com:3100
curl -sG "$LOKI/loki/api/v1/query_range" \
--data-urlencode 'query={job="docker"}' \
--data-urlencode "start=$(( $(date +%s) - 300 ))000000000" \
| grep -q -F "$MARKER" \
&& echo 'PASS: marker reached the store' \
|| echo 'FAIL: marker not in the store'

Run it from cron on one host per rack and alert when it fails. A logging pipeline that nobody probes is a pipeline you discover is broken on the day you need it β€” and the evidence you needed was never collected, so there is no recovering it retrospectively.

Also watch the shipper’s own metrics, because β€œit is running” is not β€œit is delivering”:

Read-only / Safeshipper health
$ curl -s http://127.0.0.1:2020/api/v1/metrics | python3 -m json.tool
{
  "input": {
      "tail.0": { "records": 184203, "bytes": 71223104, "files_opened": 62, "files_rotated": 41 }
  },
  "output": {
      "loki.0": { "proc_records": 183940, "errors": 0, "retries": 12, "retries_failed": 0 }
  }
}

Illustrative output

The number to alert on is retries_failed. Nonzero means records were dropped after exhausting the retry budget: they existed, the agent read them, and they are now gone. records minus proc_records growing steadily means the agent is falling behind, which is the leading indicator of the rotation race above.

Knowledge check

Knowledge check Β· 5 questions

  1. Q1. You run Fluent Bit tailing /var/lib/docker/containers/*/*-json.log. A colleague switches the daemon to the local driver to get rotation by default. What happens?

  2. Q2. Which Loki label would you refuse to add for a Docker fleet?

  3. Q3. Why does the Fluent Bit tail input need a DB setting on a production host?

  4. Q4. Which topologies keep the application running when the central store is unreachable? Select all that apply.

  5. Q5. Even with logs shipped to a central store, the per-host log copy still needs a size ceiling.

Passing score: 75%. Answers are checked in this browser.