Gunicorn kept restarting. The culprit was 8MB of JSON.
Flask behind Gunicorn: four workers restart silently every 5-10 minutes with no traceback. An 8MB JSON file was re-parsed on every request in before_request.
💡 What You Will Learn
Flask behind Gunicorn: four workers restart silently every 5-10 minutes with no traceback. An 8MB JSON file was re-parsed on every request in before_request.
📜 Table of Contents
Gunicorn kept restarting. The culprit was 8MB of JSON.
No 500s, no traceback, only a few worker-exit lines. Monitoring showed RSS climbing, then dropping. The clue was the memory curve.
Who kills the worker
| Symptom | Cause | Confirm with |
|---|---|---|
WORKER TIMEOUT |
Request exceeded the worker timeout | --log-level debug |
Worker exiting (pid) |
Hit --max-requests |
Check start flags |
Killed process ... (python) |
OOM killer | dmesg -T \| grep -i oom |
Here the platform supervisor killed workers when RSS crossed a threshold, so nothing reached the app log.
Four commands
ps -eo pid,rss,etime,cmd --sort=-rss | grep gunicorn | head
watch -n 5 'ps -eo pid,rss,cmd | grep "[g]unicorn"'
dmesg -T | grep -i -E "oom|killed process"
gunicorn app:app --log-level debug --access-logfile - --error-logfile -
All four workers climbing together means per-request allocation that never returns.
Root cause
@app.before_request
def load_config():
with open("/srv/app/config.json") as f: # 8MB
g.config = json.load(f)
g.index = build_index(g.config)
before_request runs on every request, including health checks. An 8MB JSON expands 5-20x in Python objects, plus a full copy for the index. Peak memory rises and the allocator does not return it to the OS.
Fix
def _load_config():
with open("/srv/app/config.json", "rb") as f:
return json.load(f)
CONFIG = _load_config()
INDEX = build_index(CONFIG)
Open in binary mode, build indexes at startup so lookups are O(1), and remember each worker holds its own copy โ the default is (2 ร CPUs) + 1 workers.
gunicorn app:app --preload --workers 4
gunicorn app:app --workers 4 --max-requests 2000 --max-requests-jitter 200
--preload shares read-only pages via copy-on-write; move DB connections and threads into post_fork. --max-requests is a tourniquet, not a fix.
Verify
Load-test for five minutes with wrk while watching RSS. A flat curve is the proof, along with zero restarts over 24 hours.
FAQ
Why no errors? The process was killed externally, by the platform or the kernel.
What value for --max-requests? No universal number; it admits a leak exists.
Only fails in production? Local runs are single-worker, low traffic, short-lived.
Gunicorn defaults: see official docs. Supervisor thresholds vary.
Related
Written by our editorial team; tools listed here are tested or verified against public sources. Links point to official sites or GitHub repos for reference only โ no paid placements.
