Add periodic malloc_trim to prevent unbounded RSS growth in API workers#7481
Add periodic malloc_trim to prevent unbounded RSS growth in API workers#7481
Conversation
bbb84a7 to
a54ced1
Compare
a54ced1 to
4279638
Compare
|
I think a safer and more practical approach might be to just configure a maximum number of requests for a Gunicorn worker to handle before being rebooted. We expose the options to do so on the API entrypoint already: https://github.com/pulp/pulpcore/blob/main/pulpcore/app/entrypoint.py#L153-L154 https://gunicorn.org/guides/docker/?h=memory#out-of-memory |
I agree that --max-requests is a practical safety net and should be a part of the story. However I think these two approaches are complementary rather than alternatives. --max-requests masks the symptom by recycling workers periodically but each worker still grows until it's replaced. Under heavier load in enterprise environments the recycling more frequent and add brief latency during worker replacement. malloc_trim addresses the root cause by making glibc retaining freed pages in the process heap. Calling malloc_trim(0) returns them to the OS and RSS stabilises and never grows further. No worker restart needed. Utilising --max-requests requires changes in the end products (for example AAP doesn't have an option to set it and keep it persistent) and malloc_trim would just work. |
|
Can you briefly try using https://docs.python.org/3/library/tracemalloc.html (or something similar, like I understand that that would measure what is going on with Python's own allocators rather than libc malloc, but still, I wouldn't think there would be much fragmentation accumulating on a service in a case where the same endpoint was merely being called over and over, using and then releasing approximately the same amount every time. So there is probably fragmentation, but it may also be triggered by other misbehavior |
pulpcore/app/entrypoint.py
Outdated
|
|
||
| logger = getLogger(__name__) | ||
|
|
||
| _MEMORY_TRIM_INTERVAL = int(os.environ.get("PULP_MEMORY_TRIM_INTERVAL", "1024")) |
There was a problem hiding this comment.
I guess there is some discussion about this, but in any case, this setting should be defined in settings.py as the others and documented in settings.md. Settings defined there are automatically overridable via PULP_{NAME} envvar.
There was a problem hiding this comment.
@pedro-psb Good call, updated in the latest push:
MEMORY_TRIM_INTERVAL = 1024added tosettings.py(so it picks upPULP_MEMORY_TRIM_INTERVALvia dynaconf automatically)entrypoint.pynow reads fromsettings.MEMORY_TRIM_INTERVALininit_process()instead ofos.environ.get()- Documented in
docs/admin/reference/settings.md
@dralley Sure Here's Baseline snapshot taken after lazy init settled, then 200 sequential RSS vs Python allocations (PID 2, 200 requests):
tracemalloc top diffs (all negative = Python freeing, not leaking): Earlier
|
Gunicorn API workers exhibit unbounded RSS growth over time due to glibc heap fragmentation. Django's per-request allocation pattern creates and destroys many small C-level objects (ORM compilers, SQL strings, psycopg cursor state) which causes glibc's malloc to retain freed pages in the process heap rather than returning them to the OS. Profiling on a live Ansible Automation Platform 2.6 deployment (pulpcore 3.49.49, Django 4.2.27, Python 3.12) confirmed: - Python object counts are completely stable (no object leak) - gc.collect() recovers 0 bytes (no reference cycles) - malloc_trim(0) recovers ~2 MB immediately (fragmentation confirmed) - RSS grows ~1 kB/request without trimming This adds periodic gc.collect() + malloc_trim(0) calls in PulpApiWorker.handle_request() every MEMORY_TRIM_INTERVAL requests (default 1024, configurable via PULP_MEMORY_TRIM_INTERVAL through the standard Django/dynaconf settings, set 0 to disable). The fix is Linux-only (glibc malloc_trim), graceful no-op on other platforms. Testing shows RSS stabilizes completely after one-time lazy initialization, eliminating unbounded growth. closes pulp#7482 Assisted-by: Claude (Anthropic) - investigation, profiling, and code Made-with: Cursor
4279638 to
86faabc
Compare
Summary
PulpApiWorker (gunicorn SyncWorker) exhibits unbounded RSS growth over time due to glibc heap fragmentation. Django's per-request allocation pattern creates and destroys many small C-level objects (ORM compilers, SQL strings, psycopg cursor state), causing glibc's malloc to retain freed pages rather than returning them to the OS.
This PR adds periodic
gc.collect()+malloc_trim(0)calls inPulpApiWorker.handle_request()every N requests (default 1024, configurable viaPULP_MEMORY_TRIM_INTERVALenv var, set to 0 to disable).The fix is Linux-only (glibc
malloc_trim), graceful no-op on other platforms. No new dependencies.Problem
Observed in Ansible Automation Platform 2.6 deployments running pulpcore 3.49 on OpenShift: hub-api worker RSS grows ~1 kB/request even with zero user activity (liveness/readiness probes alone drive growth). Over hours this leads to OOM kills and pod restarts.
Profiling on a live cluster confirmed:
gc.get_objects()delta ~0)gc.collect()recovers 0 bytes (no reference cycles)malloc_trim(0)recovers ~2 MB immediately (heap fragmentation confirmed)The root cause is glibc's default
mallocbehavior: small allocations spread across many arenas cause heap fragmentation, and freed blocks are not returned to the OS untilmalloc_trimis explicitly called.Changes
pulpcore/app/entrypoint.py:libc.malloc_trimviactypesPulpApiWorker.handle_request(): after each request, increment counter; everyPULP_MEMORY_TRIM_INTERVALrequests (default 1024), callgc.collect()thenmalloc_trim(0)Configuration
PULP_MEMORY_TRIM_INTERVAL10240to disable.Test plan
PULP_MEMORY_TRIM_INTERVAL=0disables trimming (no log message)📜 Checklist
See: Pull Request Walkthrough