Wazuh Indexer 5.0 performance tuning
This skill captures everything learned from two rounds of memory investigation:
a shard-consolidation PoC that backfired, and the settings-tuning follow-up it
triggered. Read this before touching any setting or index topology — several
"obvious" fixes here are confirmed dead ends, and re-discovering that costs
hours of load-test time.
Related artifacts
- Settings catalog:
.claude/skills/perf-tuning/SETTINGS_CATALOG.md —
every candidate setting across content-manager, security-analytics,
alerting, and setup, with confirmed defaults, ranges, dynamic/static status,
and recommended test values. Cross-checked against actual registered
Setting<?> objects in source, not just docs — trust this over
docs/ref/modules/*/configuration.md if the two ever disagree until the
docs are updated.
- Team issue: an internal issue titled "Indexer performance settings
tuning" is the authoritative scope/requirements doc for the current
settings-tuning round (not checked into this repo — ask the team for the
current copy). This skill operationalizes that issue; don't diverge from
its constraints (see "Guardrails" below).
- Load-test harness: the internal
indexer_metrics tool (not part of
this repo — ask the team for access) — run-heap-analysis.sh is the
primary script for every attribution test in this skill. See "Running a
test" below.
- Real VM: an internal Vagrant box representative of a small production
deployment (not part of this repo — ask the team for access and current
resource profile). It runs with roughly half the heap of every Docker
test. Any percentage-of-heap setting behaves materially differently here
than in Docker; see "Docker vs. VM" below.
Mental model: three mechanisms, don't conflate them
Every memory-relevant setting or architectural choice bounds exactly one of
these. Attributing an observed effect to the wrong mechanism is the single
most common way to draw a wrong conclusion from a test run.
- Blast-radius / peak-memory-during-fanout — how much memory is held
concurrently while detectors evaluate a batch of events (percolate-query
caps, enrichment max-in-flight, correlation max-in-flight/backpressure).
- Retained state — steady-state resident bytes independent of any single
burst (caches, history retention, sync frequency).
- Structural shard count — number of Lucene index/shard objects resident
regardless of load (index/stream topology, per-log-type index creation).
A setting from one bucket will not explain a delta that's actually coming
from another. If a change to a blast-radius setting doesn't move the needle,
check retained-state and structural explanations before concluding "no
effect."
Known traps — read before proposing a fix
These cost real investigation time to discover. Don't re-derive them.
- Merging index topology can increase memory, not reduce it. A
shard-consolidation PoC (merging several per-category
wazuh-events-v5/
wazuh-findings-v5 streams into a couple of unified ones) cut index/shard
count exactly as designed, but measurably increased avg heap, CPU, and GC
activity relative to baseline — because OpenSearch Security Analytics
scopes detectors purely by which index they read,
with no query-level category filter. Once all categories share one
index, every detector evaluates every event instead of just the one that
used to see real traffic. Any future index-merge proposal must account for
this before being trusted — check whether detectors/monitors are scoped by
index name anywhere in the merge's blast radius, not just whether shard
count drops.
enable_detectors_with_dedicated_query_indices looks like the fix for
per-log-type shard proliferation. It usually isn't. Confirmed via direct
tracing of TransportIndexDetectorAction.java (in
wazuh-indexer-security-analytics): this setting only reduces index count
when multiple detectors share one log type. If the deployment has one
detector per log type (check via _cat/indices/.opensearch-sap-* — count
should equal log-type count), toggling this setting produces the same
index count either way, just a different name. It also never touches the
*-alerts index (set unconditionally), and isn't retroactive for existing
detectors — flipping it live changes nothing until a detector is
deleted+recreated. Verify the 1:1 assumption on the actual deployment
before trusting this analysis; it could change if a future log-type model
allows multiple detectors per type.
- Content-manager's bulk/concurrency settings are NOT dynamic — despite
being informally described as "semaphore-controlled,"
max_items_per_bulk,
max_bulk_bytes, max_concurrent_bulks, client.timeout are NodeScope
only in code, no Dynamic property, no addSettingsUpdateConsumer wiring.
They can only be changed by baking a new value into the node's static
config (opensearch.yml/opensearch.prod.yml) and restarting — a live
PUT _cluster/settings will not work and will silently have no effect on
the running semaphore size.
- The enrichment findings queue is unbounded.
WazuhEnrichedFindingService's
findingsQueue is a plain ConcurrentLinkedQueue with no size cap.
Lowering enriched_findings_max_in_flight narrows concurrent processing
width, but does not bound total backlog bytes — if inflow outpaces the
now-slower drain rate, the queue itself becomes the new memory sink instead
of the in-flight chains. Always watch queue depth (or proxy it via
findings-indexed-vs-consumed lag), not just heap, when tuning this setting.
- Lowering correlation cache TTLs is usually the wrong move.
correlation.detector_cache_ttl / metadata_cache_ttl cache small
ID→object lookups (monitor-id→detector, logtype/rule lists), not full
documents. Zeroing them mainly adds a size: 10000 search query per
finding — trading a small heap saving for real CPU/search-thread-pool load,
which is usually the scarcer resource on a small VM. Prefer
raising these TTLs over lowering them unless a specific measurement shows
otherwise.
events_backpressure must stay enabled. Silent finding-shedding via
correlation.max_pending_findings only happens when
events_backpressure.enabled is false. This mechanism is deliberate,
already-shipped prior work (team issue references GitHub #1683) — assert
it's true at the start of every test run as a guardrail against settings
drift, don't tune it away while chasing memory.
- The Docker harness's
_cat/indices capture is blind to hidden/system
indices. tools/heap-monitor.sh's capture_index_stats() calls
_cat/indices with no expand_wildcards, so .opensearch-sap-* and any
other dot-prefixed index is invisible in every report the harness
produces. If a fix targets shard/index count for one of these families,
patch the capture call (&expand_wildcards=all) first, or the harness
will report success/failure blind to the thing you're actually testing.
- The heap-monitor's elapsed clock starts when the container becomes
healthy, not when the load generator actually starts.
--push
(rebuilds+restarts the indexer service) and --validate (runs the real
CTI-sync pipeline that creates detectors) both happen after the initial
healthcheck but before the tester container starts. Early
index_stats/histogram snapshots can land mid-setup or even mid-restart
(indexer briefly unreachable), reading as "0 documents" when real data
exists once setup finishes. If you're using run-heap-analysis.sh and see
suspiciously empty early snapshots, this is why — check capture_index_stats
in tools/heap-monitor.sh for a retry-on-empty-response guard and confirm
the monitor's start is gated on the load generator actually running, not
just on the container being healthy.
- ISM policy thresholds and index template settings (shard count, replica
count,
refresh_interval) for setup-plugin-owned streams are hardcoded in
bundled JSON resources, not registered Setting<?> objects, and are
actively reasserted on every cluster-manager election.
IndexStateManagement.indexPolicy() upserts the ISM policy document on
every election; StreamIndex.createTemplate() overwrites the index
template on every boot. A manual edit via the ISM/template API will be
silently reverted — there is no live-settings path here, only a code
change to the bundled resources under plugins/setup/src/main/resources/.
Docker vs. the real VM — division of labor
Don't default to "test everything on Docker" or "test everything on the VM."
Each is right for different things.
| Use Docker for |
Use the VM for |
| The main one-variable-at-a-time attribution sweep — controlled, disposable, fast-iterating |
Percentage-of-heap settings (e.g. percolate_query_docs_size_memory_percentage_limit) — the same percentage yields a very different absolute byte budget on Docker's 2GB heap vs. the VM's much smaller heap; Docker cannot predict VM behavior for these |
| JFR allocation recordings, class histograms, full heap dumps |
Settings persistence after a real service restart (_cluster/settings?include_defaults=true diffed before/after) |
| Anything needing many repeated, cheap, from-scratch runs |
Multi-hour slow-leak detection (Docker's ~20 min runs structurally cannot catch this — poll _nodes/stats/jvm,breaker every few minutes over hours) |
|
Startup/config error checks after a settings change (`grep -iE "error |
|
Confirmatory (not exhaustive) spot-checks of a Docker finding against real-world scale/constraints before calling it final |
Never run two Docker containers concurrently on the same host for this
work — resource contention between them reintroduces exactly the kind of
unattributable confound this methodology exists to avoid, one level down.
Running a test
cd <path-to-indexer_metrics>
./run-heap-analysis.sh --package <deb> --push --validate --keep-up \
--duration <seconds> --title <descriptive-title>
--push rebuilds and installs the plugins; --validate runs the real
content-manager CTI-sync pipeline (this is what creates the real
detectors, one per log type in use — don't skip it if the test needs
realistic detector fan-out).
--keep-up leaves the container running afterward for docker exec
inspection — use this when you need to apply a live _cluster/settings
change and re-run without re-paying the push+validate cost.
- Match
--jvm-mem/--container-cpus/--container-mem to the real VM's
specs (ask the team for its current profile) when the goal is a
VM-representative baseline, not the tool's own more generous defaults —
every prior comparison used those defaults, which understates real-world
pressure.
- Output lands in
output/<title>_<timestamp>/, including heap_analysis.md
(the unified report: heap timeline, per-plugin index store size, class
histogram, JFR allocation call trees) and metrics.csv/chart.png.
For one-variable-at-a-time attribution: bake the setting under test into
docker/opensearch.yml before each run (uniform for both dynamic and static
settings, so results are comparable within Docker) — reserve exercising the
live PUT _cluster/settings API path specifically for proving a setting is
genuinely live-tunable, which matters most on the VM.
Methodology for a settings round
- Two default-baseline runs back to back first, to establish the noise
floor. Any candidate delta smaller than ~2x that spread is inconclusive,
not "no effect."
- One unit (normally one setting) changed per run relative to the fixed
baseline. Group two settings into one run only when there's a citable,
code-level reason isolating them separately would be uninformative (e.g.
two settings gate the same flush decision via a
min() of both) — never
for convenience.
- Triage by mechanism: test blast-radius settings first (most directly
tied to detector-fanout-shaped regressions), then retained-state, then
structural/dead-end confirmations.
- Success criteria (anchor to the actual regression this round follows
up on unless the team specifies otherwise): avg heap reduced ≥5% vs.
baseline; peak heap not increased more than +2%; GC activity not
increased at all; effective throughput degradation ≤15%; no
correctness regression (see below); no OOM/crash — always overrides
everything else.
- Instrument for correctness, not just memory. A setting that narrows a
buffer without reducing the work arriving at it converts "held in memory"
into "processed in smaller, more frequent operations" — pair every memory
metric with an operation-frequency/cost proxy (CPU-seconds per event,
thread-pool stats, JFR call-tree frame counts). Diff findings-index doc
counts against source-event counts at run end to catch silent shedding.
Track
_nodes/stats/breaker on every run, not just heap-used-MB — a run
that "wins" on heap but pushes a circuit breaker closer to its trip point
is not an unqualified success.
Guardrails (from the team issue — do not violate)
- This is a configuration/measurement exercise. Only propose a code
change if testing reveals a setting that's missing, unbounded, or should
be exposed but isn't — file that as a separate follow-up issue, don't
bundle it into a settings-tuning deliverable.
- Any changed default must stay within the already-documented valid
range for a dynamic setting. If a range itself needs widening, say so
explicitly rather than silently exceeding it.
- Stability floor is absolute: any configuration that OOMs or crashes
under sustained heavy load (not just a short burst) is unsafe and excluded
outright, regardless of throughput gains.
- Prioritize memory/stability over throughput whenever they trade off.
- Persist adopted defaults either in code or in
wazuh-indexer/distribution/src/config/opensearch.prod.yml (the default
config shipped in new packages) — a live PUT _cluster/settings call is
for testing only and does not survive a fresh install.
1---2name: perf-tuning3description: Reduce or validate Wazuh Indexer 5.0's memory/CPU/GC footprint by tuning existing OpenSearch/plugin settings, or by changing index/shard topology. Covers the settings catalog (content-manager, security-analytics, alerting, setup, OpenSearch core), the Docker load-test harness and the real vagrant VM, one-variable-at-a-time attribution methodology, and known traps (detector fan-out, the SAP dedicated-query-index dead end, unbounded queues). Use when asked to investigate, reduce, or test Wazuh Indexer memory usage, heap pressure, circuit breaker trips, or shard/index count, or to tune throughput-vs-memory tradeoffs.4---56# Wazuh Indexer 5.0 performance tuning78This skill captures everything learned from two rounds of memory investigation:9a shard-consolidation PoC that backfired, and the settings-tuning follow-up it10triggered. Read this before touching any setting or index topology — several11"obvious" fixes here are confirmed dead ends, and re-discovering that costs12hours of load-test time.1314## Related artifacts1516- **Settings catalog**: `.claude/skills/perf-tuning/SETTINGS_CATALOG.md` —17 every candidate setting across content-manager, security-analytics,18 alerting, and setup, with confirmed defaults, ranges, dynamic/static status,19 and recommended test values. Cross-checked against actual registered20 `Setting<?>` objects in source, not just docs — trust this over21 `docs/ref/modules/*/configuration.md` if the two ever disagree until the22 docs are updated.23- **Team issue**: an internal issue titled "Indexer performance settings24 tuning" is the authoritative scope/requirements doc for the current25 settings-tuning round (not checked into this repo — ask the team for the26 current copy). This skill operationalizes that issue; don't diverge from27 its constraints (see "Guardrails" below).28- **Load-test harness**: the internal `indexer_metrics` tool (not part of29 this repo — ask the team for access) — `run-heap-analysis.sh` is the30 primary script for every attribution test in this skill. See "Running a31 test" below.32- **Real VM**: an internal Vagrant box representative of a small production33 deployment (not part of this repo — ask the team for access and current34 resource profile). It runs with **roughly half the heap of every Docker35 test**. Any percentage-of-heap setting behaves materially differently here36 than in Docker; see "Docker vs. VM" below.3738## Mental model: three mechanisms, don't conflate them3940Every memory-relevant setting or architectural choice bounds exactly one of41these. Attributing an observed effect to the wrong mechanism is the single42most common way to draw a wrong conclusion from a test run.43441. **Blast-radius / peak-memory-during-fanout** — how much memory is held45 *concurrently* while detectors evaluate a batch of events (percolate-query46 caps, enrichment max-in-flight, correlation max-in-flight/backpressure).472. **Retained state** — steady-state resident bytes independent of any single48 burst (caches, history retention, sync frequency).493. **Structural shard count** — number of Lucene index/shard objects resident50 regardless of load (index/stream topology, per-log-type index creation).5152A setting from one bucket will not explain a delta that's actually coming53from another. If a change to a blast-radius setting doesn't move the needle,54check retained-state and structural explanations before concluding "no55effect."5657## Known traps — read before proposing a fix5859These cost real investigation time to discover. Don't re-derive them.6061- **Merging index topology can *increase* memory, not reduce it.** A62 shard-consolidation PoC (merging several per-category `wazuh-events-v5`/63 `wazuh-findings-v5` streams into a couple of unified ones) cut index/shard64 count exactly as designed, but measurably increased avg heap, CPU, and GC65 activity relative to baseline — because OpenSearch Security Analytics66 scopes detectors purely by **which index they read**,67 with **no query-level category filter**. Once all categories share one68 index, every detector evaluates every event instead of just the one that69 used to see real traffic. Any future index-merge proposal must account for70 this before being trusted — check whether detectors/monitors are scoped by71 index name anywhere in the merge's blast radius, not just whether shard72 count drops.73- **`enable_detectors_with_dedicated_query_indices` looks like the fix for74 per-log-type shard proliferation. It usually isn't.** Confirmed via direct75 tracing of `TransportIndexDetectorAction.java` (in76 `wazuh-indexer-security-analytics`): this setting only reduces index count77 when *multiple* detectors share one log type. If the deployment has one78 detector per log type (check via `_cat/indices/.opensearch-sap-*` — count79 should equal log-type count), toggling this setting produces the same80 index count either way, just a different name. It also never touches the81 `*-alerts` index (set unconditionally), and isn't retroactive for existing82 detectors — flipping it live changes nothing until a detector is83 deleted+recreated. Verify the 1:1 assumption on the actual deployment84 before trusting this analysis; it could change if a future log-type model85 allows multiple detectors per type.86- **Content-manager's bulk/concurrency settings are NOT dynamic** — despite87 being informally described as "semaphore-controlled," `max_items_per_bulk`,88 `max_bulk_bytes`, `max_concurrent_bulks`, `client.timeout` are `NodeScope`89 only in code, no `Dynamic` property, no `addSettingsUpdateConsumer` wiring.90 They can only be changed by baking a new value into the node's static91 config (`opensearch.yml`/`opensearch.prod.yml`) and restarting — a live92 `PUT _cluster/settings` will not work and will silently have no effect on93 the running semaphore size.94- **The enrichment findings queue is unbounded.** `WazuhEnrichedFindingService`'s95 `findingsQueue` is a plain `ConcurrentLinkedQueue` with no size cap.96 Lowering `enriched_findings_max_in_flight` narrows concurrent *processing*97 width, but does not bound total backlog bytes — if inflow outpaces the98 now-slower drain rate, the queue itself becomes the new memory sink instead99 of the in-flight chains. Always watch queue depth (or proxy it via100 findings-indexed-vs-consumed lag), not just heap, when tuning this setting.101- **Lowering correlation cache TTLs is usually the wrong move.**102 `correlation.detector_cache_ttl` / `metadata_cache_ttl` cache small103 ID→object lookups (monitor-id→detector, logtype/rule lists), not full104 documents. Zeroing them mainly adds a `size: 10000` search query per105 finding — trading a small heap saving for real CPU/search-thread-pool load,106 which is usually the *scarcer* resource on a small VM. Prefer107 raising these TTLs over lowering them unless a specific measurement shows108 otherwise.109- **`events_backpressure` must stay enabled.** Silent finding-shedding via110 `correlation.max_pending_findings` only happens when111 `events_backpressure.enabled` is `false`. This mechanism is deliberate,112 already-shipped prior work (team issue references GitHub #1683) — assert113 it's `true` at the start of every test run as a guardrail against settings114 drift, don't tune it away while chasing memory.115- **The Docker harness's `_cat/indices` capture is blind to hidden/system116 indices.** `tools/heap-monitor.sh`'s `capture_index_stats()` calls117 `_cat/indices` with no `expand_wildcards`, so `.opensearch-sap-*` and any118 other dot-prefixed index is invisible in every report the harness119 produces. If a fix targets shard/index count for one of these families,120 patch the capture call (`&expand_wildcards=all`) first, or the harness121 will report success/failure blind to the thing you're actually testing.122- **The heap-monitor's elapsed clock starts when the container becomes123 healthy, not when the load generator actually starts.** `--push`124 (rebuilds+restarts the indexer service) and `--validate` (runs the real125 CTI-sync pipeline that creates detectors) both happen *after* the initial126 healthcheck but *before* the tester container starts. Early127 `index_stats`/histogram snapshots can land mid-setup or even mid-restart128 (indexer briefly unreachable), reading as "0 documents" when real data129 exists once setup finishes. If you're using `run-heap-analysis.sh` and see130 suspiciously empty early snapshots, this is why — check `capture_index_stats`131 in `tools/heap-monitor.sh` for a retry-on-empty-response guard and confirm132 the monitor's start is gated on the load generator actually running, not133 just on the container being healthy.134- **ISM policy thresholds and index template settings (shard count, replica135 count, `refresh_interval`) for setup-plugin-owned streams are hardcoded in136 bundled JSON resources, not registered `Setting<?>` objects, and are137 actively reasserted on every cluster-manager election.**138 `IndexStateManagement.indexPolicy()` upserts the ISM policy document on139 every election; `StreamIndex.createTemplate()` overwrites the index140 template on every boot. A manual edit via the ISM/template API will be141 silently reverted — there is no live-settings path here, only a code142 change to the bundled resources under `plugins/setup/src/main/resources/`.143144## Docker vs. the real VM — division of labor145146Don't default to "test everything on Docker" or "test everything on the VM."147Each is right for different things.148149| Use Docker for | Use the VM for |150|---|---|151| The main one-variable-at-a-time attribution sweep — controlled, disposable, fast-iterating | Percentage-of-heap settings (e.g. `percolate_query_docs_size_memory_percentage_limit`) — the same percentage yields a very different absolute byte budget on Docker's 2GB heap vs. the VM's much smaller heap; Docker cannot predict VM behavior for these |152| JFR allocation recordings, class histograms, full heap dumps | Settings persistence after a real service restart (`_cluster/settings?include_defaults=true` diffed before/after) |153| Anything needing many repeated, cheap, from-scratch runs | Multi-hour slow-leak detection (Docker's ~20 min runs structurally cannot catch this — poll `_nodes/stats/jvm,breaker` every few minutes over hours) |154| | Startup/config error checks after a settings change (`grep -iE "error|exception|warn"` in the cluster log around a restart) |155| | Confirmatory (not exhaustive) spot-checks of a Docker finding against real-world scale/constraints before calling it final |156157**Never run two Docker containers concurrently on the same host** for this158work — resource contention between them reintroduces exactly the kind of159unattributable confound this methodology exists to avoid, one level down.160161## Running a test162163```bash164cd <path-to-indexer_metrics>165./run-heap-analysis.sh --package <deb> --push --validate --keep-up \166 --duration <seconds> --title <descriptive-title>167```168169- `--push` rebuilds and installs the plugins; `--validate` runs the real170 content-manager CTI-sync pipeline (this is what creates the real171 detectors, one per log type in use — don't skip it if the test needs172 realistic detector fan-out).173- `--keep-up` leaves the container running afterward for `docker exec`174 inspection — use this when you need to apply a live `_cluster/settings`175 change and re-run without re-paying the push+validate cost.176- Match `--jvm-mem`/`--container-cpus`/`--container-mem` to the real VM's177 specs (ask the team for its current profile) when the goal is a178 VM-representative baseline, not the tool's own more generous defaults —179 every prior comparison used those defaults, which understates real-world180 pressure.181- Output lands in `output/<title>_<timestamp>/`, including `heap_analysis.md`182 (the unified report: heap timeline, per-plugin index store size, class183 histogram, JFR allocation call trees) and `metrics.csv`/`chart.png`.184185For one-variable-at-a-time attribution: bake the setting under test into186`docker/opensearch.yml` before each run (uniform for both dynamic and static187settings, so results are comparable within Docker) — reserve exercising the188live `PUT _cluster/settings` API path specifically for proving a setting is189genuinely live-tunable, which matters most on the VM.190191## Methodology for a settings round1921931. **Two default-baseline runs back to back first**, to establish the noise194 floor. Any candidate delta smaller than ~2x that spread is inconclusive,195 not "no effect."1962. **One unit (normally one setting) changed per run relative to the fixed197 baseline.** Group two settings into one run only when there's a citable,198 code-level reason isolating them separately would be uninformative (e.g.199 two settings gate the same flush decision via a `min()` of both) — never200 for convenience.2013. **Triage by mechanism**: test blast-radius settings first (most directly202 tied to detector-fanout-shaped regressions), then retained-state, then203 structural/dead-end confirmations.2044. **Success criteria** (anchor to the actual regression this round follows205 up on unless the team specifies otherwise): avg heap reduced ≥5% vs.206 baseline; peak heap not increased more than +2%; GC activity **not207 increased at all**; effective throughput degradation ≤15%; no208 correctness regression (see below); no OOM/crash — always overrides209 everything else.2105. **Instrument for correctness, not just memory.** A setting that narrows a211 buffer without reducing the work arriving at it converts "held in memory"212 into "processed in smaller, more frequent operations" — pair every memory213 metric with an operation-frequency/cost proxy (CPU-seconds per event,214 thread-pool stats, JFR call-tree frame counts). Diff findings-index doc215 counts against source-event counts at run end to catch silent shedding.216 Track `_nodes/stats/breaker` on every run, not just heap-used-MB — a run217 that "wins" on heap but pushes a circuit breaker closer to its trip point218 is not an unqualified success.219220## Guardrails (from the team issue — do not violate)221222- This is a **configuration/measurement exercise**. Only propose a code223 change if testing reveals a setting that's missing, unbounded, or should224 be exposed but isn't — file that as a **separate follow-up issue**, don't225 bundle it into a settings-tuning deliverable.226- Any changed default must stay within the **already-documented valid227 range** for a dynamic setting. If a range itself needs widening, say so228 explicitly rather than silently exceeding it.229- **Stability floor is absolute**: any configuration that OOMs or crashes230 under sustained heavy load (not just a short burst) is unsafe and excluded231 outright, regardless of throughput gains.232- Prioritize memory/stability over throughput whenever they trade off.233- Persist adopted defaults either in code or in234 `wazuh-indexer/distribution/src/config/opensearch.prod.yml` (the default235 config shipped in new packages) — a live `PUT _cluster/settings` call is236 for testing only and does not survive a fresh install.