Files
Daniel Schaaff c17c123402 improve cgroup memory usage calculation to avoid counting purgeable cache
When running under a cgroup limit, get_cgroup() reported
stat.usage_in_bytes (memory.current on v2, memory.usage_in_bytes on v1)
as the process memory usage. That counter includes all page
cache, including cold reclaimable cache (inactive_file). For a
workload that writes logs and spool to disk, cold cache can dominate:
on a real pod memory.current was ~51GB while inactive_file was ~46GB
and the true non-cache footprint was ~1.8GB. kumod saw usage near its
limit, hit get_headroom() == 0, and ran
shrink_ready_queue_due_to_low_mem
and other reductions against pressure that was almost entirely
reclaimable cache the kernel would drop before any OOM.

Report the working set instead:
```
working_set = max(memory.current - inactive_file, anon)
```
This matches container_memory_working_set_bytes (kubelet/cAdvisor) and
the kernel's own reclaimability accounting. It still counts everything
that can drive an OOM: anonymous memory, dirty page cache, active_file,
tmpfs/shm, and slab.

Signed-off-by: Daniel Schaaff <daniel@danielschaaff.com>
closes: https://github.com/KumoCorp/kumomta/pull/549
2026-07-07 10:02:02 +01:00

148 lines
6.6 KiB
Markdown

---
tags:
- memory
---
# Memory Management
KumoMTA makes aggressive use of memory in the interest of performance,
but memory is a finite resource.
This section of the documentation discusses the general strategies
employed by KumoMTA when it comes to managing its working set.
## Memory Limits and Headroom
On startup KumoMTA will determine the maximum RAM that is available
but checking the following things in this same sequence:
* A `cgroup` memory limit, checking v2 cgroups before v1 cgroups.
* A `ulimit` constraint as observed via `getrlimit(2)`.
* The physical RAM available to the system
A threshold is calculated at 75% of whichever of the above constraints
is detected first of all.
The calculated limit is published via the prometheus metrics endpoint
as `memory_limit`, and is reported in bytes.
A background task is started to monitor the memory usage of the system
which is derived from:
* If running under a cgroup memory limit, the cgroup *working set* (see
[Working Set under a cgroup](#working-set-under-a-cgroup) below). Note that
this may include memory used by other processes in that same cgroup.
* Otherwise, the Resident Set Size (RSS) as reported in `/proc/self/statm`.
* The value configured via
[kumo.set_memory_soft_limit](kumo/set_memory_soft_limit.md), if any,
will always take precedence over the above.
The current usage is published via the prometheus metrics endpoint
as `memory_usage` and is reported in bytes.
### Working Set under a cgroup
When KumoMTA runs under a cgroup memory limit, the value that drives
`memory_usage` (and therefore headroom and load shedding) depends on the
KumoMTA version:
|KumoMTA Version|`memory_usage` under a cgroup|
|---------------|-----------------------------|
|Earlier versions|raw `memory.current` (cgroup v2) / `memory.usage_in_bytes` (cgroup v1)|
|{{since('dev', inline=True)}}|working set: `max(current - inactive_file, anon)`|
The earlier raw counter includes cold, kernel-reclaimable file-backed page
cache (`inactive_file`), which the kernel drops under memory pressure before it
would OOM-kill the container. For workloads that write a lot of logs or spool
to disk that cache can dominate, driving headroom to 0 and triggering load
shedding against memory that was never at risk.
The working set subtracts that reclaimable cache. It is the same quantity that
kubelet/cAdvisor reports as `container_memory_working_set_bytes`, and it aligns
with the kernel's own OOM reclaimability accounting. It still counts everything
that can actually drive an OOM kill: anonymous memory, dirty page cache not yet
written back, warm file-backed cache (`active_file`), tmpfs/shm, and slab.
The raw cgroup counters that feed this (`memory.current`, `inactive_file`,
`active_file`) are not republished by KumoMTA; query them from the kernel
directly or from tooling such as cAdvisor/kubelet, which expose
`container_memory_working_set_bytes` and the underlying cache figures.
If the cgroup `memory.stat` cannot be read, KumoMTA falls back to reporting the
raw `memory.current` as `memory_usage` and logs a single warning.
### Headroom and Load Shedding
The *memory headroom* of the system is defined as the current value of
`memory_limit - memory_usage`, clamping negative numbers to 0.
If the memory headroom hits 0, at the point of transitioning from non-zero to
zero, the system will take measures to scale back memory usage:
* Each *ready queue* will be walked and each message will be subject to
a *shrink* operation that will ensure that the message body is journalled
to spool (if using deferred spooling) and then free up the message body
memory.
* Each LRU cache in the system will be purged. The DNS subsystem, the
memoize function and DKIM signer caches are commonly used examples
of LRU caches
* If using RocksDB for the spool, RocksDB will be asked to flush any memtables
and caches. You can monitor the usage of these objects via the
`rocks_spool_mem_table_total` and `rocks_spool_mem_table_readers_total`
prometheus metrics.
While the system is operating with a memory headroom of 0 the liveness
check will indicate that it is unhealthy and neither the SMTP or HTTP
listeners will accept any new incoming messages.
Once the system recovers and the headroom increases above zero, incoming
messages will again be accepted and delivered.
### Passive Measures
In addition to the active measures when headroom reaches zero, there
are a couple of passive measures:
* The various thread pools in the system continuously signal to the jemalloc
allocator when they are idle, allowing memory to returned from its per-thread
caches and to make it available to other threads, or to be reclaimed.
* When the `memory_usage` is >= 80% of the `memory_limit`, messages moving into
a ready queue, or being freshly inserted, will be subject to the same shrink
operation described above. You may configure this value via
[kumo.set_memory_low_thresh](kumo/set_memory_low_thresh.md).
## Budgeting/Tuning Memory
Assuming ideal conditions, where the rate of egress is equal to the rate of
ingress, the primary contributor to core memory usage is message bodies in the
*ready queue*.
The default fast path is that an incoming message is received and its body
is retained in RAM until after the first delivery attempt.
If your average message size is 100kb and you have `max_ready = 1000` then you
are effectively budgeting `1000 x 100kb` of RAM for a given ready queue in its
worst case.
If you have `max_ready` set very large by default then you can increase memory
pressure in the case where you have an issue with the rate of egress.
In general you should size `max_ready` to be just large enough to accommodate
your sustained egress rate for a given queue. For example, if you have a
throughput of `1000/s` then you might want to set `max_ready` to approximately
`2000` in order to avoid transient delays if the ready queue is filled up. The
precise value for your system might be a slightly different single digit
multiple of the per-second rate; this number is just a ballpark suggestion.
Conversely, if your *maximum* egress rate is `1000/s` and you have
over-provisioned `max_ready` to a very large number like `100,000`, and you
have an issue where your egress rate drops to zero, then you will be allowing
the system to use up to 50x as much memory as your normal rate of throughput
would need. If you have multiple queues over-provisioned in the same way, the
system will be placed under a lot of memory pressure.
The recommendation is to keep `max_ready` at a reasonably small value by
default, but to increase it for your top-5 or top-10 destination sites by
egress rate in order to achieve the sweet spot in throughput.