Repository navigation
filter_multiline: per-tag streams never freed, unbounded memory growth on high pod churn #12670
Description
Bug Report
Describe the bug
When running filter_multiline in buffered mode (buffer true / buffer on), Fluent Bit allocates internal stream tracking structures (struct ml_stream and per-parser struct flb_ml_stream groups) for each unique (input_instance, tag) tuple via get_or_create_stream().
In Kubernetes environments where tags are derived from container metadata (e.g., kube.<namespace>.<pod>.<container>), every distinct container produces a unique stream key. In upstream Fluent Bit, these streams are appended to ctx->ml_streams and are never evicted or freed until process termination (cb_ml_exit() in plugins/filter_multiline/ml.c).
As a result, on nodes with container churn (such as CI/CD runners, batch workloads, or short-lived serverless tasks), dead streams accumulate indefinitely. Resident memory (RSS) grows linearly without bound as new container tags appear, eventually causing the host or container runtime to kill the process (OOMKill) and lose in-flight logs.
To Reproduce
- Rubular link if applicable: N/A (the issue is in stream allocation lifetime and heap retention, not regular expression matching).
- Example log message if applicable:
{
"time": "2026-10-28T01:15:30.123456789Z",
"stream": "stderr",
"_p": "F",
"log": "Exception in thread \"main\" java.lang.NullPointerException\n at com.example.service.Worker.process(Worker.java:42)\n at com.example.service.Main.main(Main.java:10)"
}- Steps to reproduce the problem:
- Deploy Fluent Bit v4.2.8 (or any release through master) as a DaemonSet on a Kubernetes cluster with the following configuration:
fluent-bit.conf:
[SERVICE]
Flush 1
Log_Level info
Daemon off
Parsers_File parsers.conf
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
[INPUT]
Name tail
Tag kube.*
Path /var/log/containers/*.log
Parser cri
DB /var/log/fluentbit.db
Mem_Buf_Limit 50MB
Skip_Long_Lines On
Refresh_Interval 1
Read_From_Head On
[FILTER]
Name multiline
Match kube.*
buffer true
flush_ms 500
multiline.key_content log
multiline.parser go, java, python
[OUTPUT]
Name null
Match *parsers.conf:
[PARSER]
Name cri
Format regex
Regex ^(?<time>[^ ]+) (?<stream>stdout|stderr) (?<_p>F|P) (?<log>.*)$
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L%zSet the container memory limit to 64Mi or 128Mi to observe rapid OOMKills, or leave unconstrained to observe linear monotonic heap retention.
- Generate short-lived container churn emitting multi-line stack traces across unique pod and container names:
#!/bin/bash
# Generate 50 batches of short-lived pods with unique pod and container names
for b in $(seq 1 50); do
echo "Launching batch ${b}/50..."
for p in $(seq 1 20); do
pod="churn-b${b}-p${p}-${RANDOM}"
kubectl run "${pod}" --restart=Never --image=busybox -- sh -c '
echo "Exception in thread \"main\" java.lang.NullPointerException"
echo " at com.example.service.Worker.process(Worker.java:42)"
echo " at com.example.service.Main.main(Main.java:10)"
sleep 2
' >/dev/null 2>&1
done
# Wait for batch containers to complete execution
kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod -l run=churn --timeout=30s >/dev/null 2>&1
# Delete completed pods (containers already exited naturally, avoiding SIGTERM stalls)
kubectl delete pod --all --grace-period=0 >/dev/null 2>&1
sleep 1
done(Note: A standalone client-go test driver is available upon request to automate batch orchestration, track actual started streams from Kubelet CRI metrics, and sample memory).
- Monitor Fluent Bit resident memory (RSS), cgroup memory usage, and container restart count:
kubectl top pod -l k8s-app=fluent-bit
kubectl get pod -l k8s-app=fluent-bit -o jsonpath='{.items[*].status.containerStatuses[*].restartCount}'- Observe that after all churn pods terminate and
/var/log/containers/*.logfiles are completely deleted, Fluent Bit memory does not return to baseline. Instead, memory remains permanently elevated at its post-churn plateau. Under typical container memory limits, the process is repeatedlyOOMKilled(exit code 137).
Expected behavior
Streams that have completed multiline concatenation and have remained idle for a configurable period should be evicted, and their associated message buffers and parser stream groups freed. When active container churn ceases, resident memory should return to its baseline. If a container with a previously evicted tag logs again in the future, the stream should be re-created transparently without data loss.
Screenshots
Empirical Benchmark Results on Kubernetes 1.37
Below are measured memory dynamics from live benchmark runs generating 3,000 scheduled pods (5,348 actual started container streams) on a dedicated single-node cluster:
| Metric / Event | Upstream v4.2.8 (64Mi Limit, P=3) |
Upstream v4.2.8 (128Mi Limit, P=3) |
Production Agent (500Mi Limit, P=6) |
Valgrind Massif Profiler (T+10m Idle, P=3) |
|---|---|---|---|---|
| Baseline RSS | 5.97 MB | 5.97 MB | 25.92 MB | 1.57 MB (baseline system files) |
| Active Churn Peak | 64.00 MB (OOM) | 121.70 MB (OOM) | 327.40 MB | 155.82 MB (open file buffers) |
Post-Churn Idle (T+10m) |
N/A (OOMKilled) | N/A (OOMKilled) | 218.26 MB (permanent plateau) | 115.92 MB (retained heap) |
| Net Retained Leak | OOM at 480 pods (+35s) | OOM at 1,280 pods (+4m29s) & 3,000 pods | +192.34 MB | +115.92 MB (94.27% of total heap) |
| Actual Started Streams | ~480 streams | 2,441 streams (1st OOM) / 5,348 streams (2nd) | 5,338 streams | 5,348 streams |
| Leak Rate / Started Stream | ~24.8 KB / stream (jemalloc) | ~24.8 KB / stream (jemalloc) | 37.8 â 41.1 KB / stream | 21.68 KB / stream (useful heap) |
| OOM Termination | OOMKilled (Exit 137) @ Batch 6 | OOMKilled (Exit 137) @ Batch 16 & 38 | None (under 500Mi) | N/A (512Mi profiler ceiling) |
(Time-series RSS metrics and Valgrind Massif memory snapshot trees can be provided upon request).
Your Environment
- Version used: 4.2.8 (also verified against
masterbranch commitf68b7b15d56373f28fe95b562ce91b26d5c298e9/ v5.1.5 logic) - Configuration:
in_tail(cri parser) âfilter_multiline(mode parser, buffer true, flush_ms 500) âout_null - Environment name and version (e.g. Kubernetes? What version?): Kubernetes 1.37 (GKE Standard)
- Server type and version: Linux 6.6
- Operating System and version: Container-Optimized OS (cos-117-18613-242-29)
- Filters and plugins:
in_tail,filter_multiline,out_null
Additional context
1. Root Cause & Code Analysis
- Linear Stream Accumulation (
plugins/filter_multiline/ml.c): In buffered mode, incoming records are processed bycb_ml_filter(), which callsget_or_create_stream():
mk_list_foreach_safe(head, tmp, &ctx->ml_streams) {
stream = mk_list_entry(head, struct ml_stream, _head);
name_check = strcmp(stream->input_name, i_ins->name);
tag_check = strcmp(stream->tag, tag);
if (tag_check == 0 && name_check == 0) {
return stream;
}
}
On a cache miss, it allocates a new struct ml_stream, calls flb_ml_stream_create() to instantiate stream state across all configured parsers, and appends it to ctx->ml_streams via mk_list_add(&stream->_head, &ctx->ml_streams).
- Streams Only Freed on Process Shutdown: In
cb_ml_exit():
mk_list_foreach_safe(head, tmp, &ctx->ml_streams) {
stream = mk_list_entry(head, struct ml_stream, _head);
mk_list_del(&stream->_head);
ml_stream_destroy(stream);
}
There is no mechanism in filter_multiline to evict streams whose containers have terminated or that have been idle. This is explicitly acknowledged in the source header at plugins/filter_multiline/ml.h#L35:
/*
* input instance + tag is the unique identifier
* for a multiline stream
* TODO: implement clean up of streams that haven't been used recently
*/- Auto-Flush Timer Overhead: The auto-flush timer callback (
cb_ml_flush_timer/flb_ml_flush_parser_instanceinsrc/multiline/flb_ml.c) iterates over all registered streams every 500ms (flush_ms). As dead streams accumulate into the thousands, CPU time per tick increases steadily even when the node is completely idle.
2. Valgrind Massif DWARF Heap Attribution (94.27% of Post-Churn Heap Is filter_multiline)
Heap snapshots captured 10 minutes after churn completed (T+10m idle, N = 5,348 streams) cleanly isolate the permanent leak: while in_tail freed 100% of its closed-file buffers, filter_multiline retained 100% of all streams ever seen (115.92 MB out of 121.41 MB useful heap = 95.48% of useful heap / 94.27% of total heap):
| Rank | Source Function & Line (Fluent Bit v4.2.8) | Retained Useful Bytes (N = 5,348) |
% of Total Heap |
Useful Bytes / Stream (P = 3) |
Jemalloc Rounded / Stream (P = 3 / P = 6) |
What It Allocates |
|---|---|---|---|---|---|---|
| #1 | stream_group_create (src/multiline/flb_ml_stream.c:67) |
65,988,972 B (65.99 MB) |
53.66% |
12,339 B (3 Ã 4,113 B) |
15,360 B / 30,720 B |
group->buf = flb_sds_create_size(4096) (4,113 B rounded to 5,120 B slab by jemalloc) |
| #2 | flb_ml_register_context (src/multiline/flb_ml.c:175 â sbuffer.h:81) |
43,810,816 B (43.81 MB) |
35.63% |
8,192 B (1 Ã 8,192 B) |
8,192 B / 8,192 B |
group->mp_sbuf.data (MSGPACK_SBUFFER_INIT_SIZE = 8192). Flush only resets .size = 0, never freeing .data |
| #3 | stream_group_create (src/multiline/flb_ml_stream.c:52) |
3,208,800 B (3.21 MB) |
2.61% |
600 B (3 Ã 200 B) |
672 B / 1,344 B |
flb_calloc(1, sizeof(struct flb_ml_stream_group)) (200 B rounded to 224 B in jemalloc) |
| (4) | flb_tail_file_append (plugins/in_tail/tail_file.c:1339) |
1,572,864 B (1.57 MB) |
1.28% |
(Baseline only) | (Baseline only) | 48 active system container log files à 32,768 B (file->buf_data) |
| #5 | stream_create (src/multiline/flb_ml_stream.c:196) |
1,540,224 B (1.54 MB) |
1.25% |
288 B (3 Ã 96 B) |
288 B / 576 B |
flb_calloc(1, sizeof(struct flb_ml_stream)) |
| #6 | get_or_create_stream (plugins/filter_multiline/ml.c:523) |
766,019 B (0.77 MB) |
0.62% |
~143.2 B |
~160 B / ~160 B |
stream->tag = flb_sds_create(tag) |
| #7 | stream_group_create (src/multiline/flb_ml_stream.c:57) |
272,748 B (0.27 MB) |
0.22% |
51 B (3 Ã 17 B) |
72 B / 144 B |
group->name = flb_sds_create("_default") |
| #8 | get_or_create_stream (plugins/filter_multiline/ml.c:516) |
213,920 B (0.21 MB) |
0.17% |
40 B |
48 B / 48 B |
flb_calloc(1, sizeof(struct ml_stream)) |
| #9 | get_or_create_stream (plugins/filter_multiline/ml.c:532) |
123,004 B (0.12 MB) |
0.10% |
23 B |
32 B / 32 B |
stream->input_name = flb_sds_create(i_ins->name) |
| Total | All filter_multiline allocations (N = 5,348 streams) |
115,924,503 B (115.92 MB) |
94.27% |
21,676.2 B / stream |
24,824 B (P=3) / 41,216 B (P=6) |
100% permanently leaked until process exit |
3. Related Upstream Issues
- Slow memory leak after adding multiline filter - 1.9.9Â #6192 ("Slow memory leak after adding multiline filter - 1.9.9"): Closed as stale/not_planned. A 2026 comment highlighted this exact mechanism and the
ml.h#L35TODO. - Steady memory increase on high pod churn #6881 ("Steady memory increase on high pod churn"): Closed as not_planned, reporting the identical symptom on Kubernetes nodes with churning pods.
4. Proposed Fix: Opt-In Idle Stream Eviction (stream_idle_timeout)
We have implemented and tested a fix that resolves the leak by introducing opt-in idle stream eviction:
- Configuration Property: Add
stream_idle_timeout(typeFLB_CONFIG_MAP_TIME, default"0"to preserve existing behavior for current deployments). - Monotonic Inactivity Tracking: Track
last_usedtimestamps onstruct ml_streamusingCLOCK_MONOTONIC(ml_stream_clock_ms()) so system wall-clock steps do not trigger premature eviction. - Throttled Sweep: During
cb_ml_filter(), run an eviction sweep at most once everymax(1s, stream_idle_timeout / 2). - Safe Eviction (Zero Log Loss): For idle streams, invoke
flb_ml_stream_id_destroy_all()to force-flush any pending unmerged content before destroying the core parser stream groups, and then unlink and freestruct ml_stream. - Bounded Latency: Limit eviction to a maximum of 256 streams per filter call (
FLB_MULTILINE_STREAM_EVICT_MAX) to prevent latency spikes on the event loop, continuing on subsequent calls if additional streams remain pending. - Metrics: Expose an eviction counter (
multiline_streams_evicted_total) for Prometheus observability. - Transparent Re-creation: If an evicted tag logs again in the future,
get_or_create_stream()automatically allocates a fresh stream.
The fix includes a runtime test (tests/runtime/filter_multiline.c) covering eviction, re-creation of evicted streams, and multiline concatenation on a re-created stream.
Activity
filter_multiline: add stream_idle_timeout to evict idle per-tag streams
filter_multiline: add stream_idle_timeout to evict idle per-tag streams