Python HTTP 服务内存持续增长:用 Gunicorn max-requests 让 Worker 自动重启兜底
问题现象
一个用 FastAPI + Gunicorn 部署的 HTTP 服务,上线几天后出现了经典的"内存只涨不降"曲线:
- 刚启动时,每个 worker 占用约 150MB RSS
- 运行 24 小时后,涨到 800MB 左右
- 一周后逼近 2GB,随后被系统 OOM Killer 杀掉,或者被容器平台强制重启
- 日志里反复出现
Killed process ... (gunicorn)或者容器OOMKilled状态 - 每次重启后内存恢复正常,过段时间又开始爬升
如果你也见过这种锯齿形"重启 → 缓慢爬升 → 被杀 → 再重启"的循环,那么问题的核心只有一个:worker 进程的内存无法归还给操作系统。本文会先讲清楚为什么会这样,再给出一个生产环境最实用的兜底方案——让 Gunicorn 按请求数自动重启 worker,把内存问题用"进程轮回"的方式彻底解决。
为什么内存会一直涨
真正的泄漏:对象无法回收
最常见的原因是应用代码里存在"无限增长的引用":
- 模块级缓存无上限:
lru_cache不设maxsize、模块里的字典/列表不断 append 新数据 - 连接对象未释放:数据库连接、Redis 连接、文件句柄、socket 被创建后没人关闭
- asyncio Task 泄漏:FastAPI 里常见,协程任务被创建后持有引用、从不取消,事件循环的待执行队列越来越长
- 第三方库泄漏:例如 boto3 等 SDK 内部的连接池、重试队列在长时间运行中积累
这类问题对象本身会被 Python 的引用计数 + 分代 GC 标记出来,但只要有外部引用指向它们,就永远无法回收,RSS 自然只涨不降。
不是泄漏的泄漏:内存分配器不归还
更隐蔽、也更普遍的情况是——代码根本没有泄漏,但内存还是不降。
Python 进程的内存来自底层 C 分配器(Linux 下通常是 glibc malloc)。glibc malloc 有一个著名的行为:为了降低多线程竞争,会为每个线程创建独立的 arena,这些 arena 一旦建立就很少释放回操作系统。对于一个用 gthread worker(每个 worker 多线程)跑了几天的服务:
- 高峰期创建了很多线程,每个线程触发分配时都可能新建 arena
- 即使这些线程早已结束、对象也释放了,arena 的空间仍被 malloc 缓存着,不还给 OS
- 表现为 RSS 一路爬升,但
gc模块告诉你"没有泄漏"
除此之外还有内存碎片化:对象被释放后留下的零散空洞无法被合并成足够大的连续块,虽然总量没增加,但新对象分配时 malloc 只能继续向 OS 申请新内存。
一句话总结:引用计数和 GC 能回收对象,但回收了对象并不等于把内存还给了操作系统。 唯一能 100% 归还内存的方式,是结束这个进程。
为什么"找到并修掉泄漏"那么难
理论上应该从源头修代码,但现实里这条路经常走不通:
- 内存泄漏定位成本高——
tracemalloc、objgraph、memory_profiler都是重武器,需要专门腾时间做分析 - 部分泄漏来自无法改源码的第三方依赖
- 分配器碎片和 arena 增长根本不是代码 bug,改代码也修不掉
- 有些泄漏只在特定请求组合下触发,复现困难
所以生产环境的务实做法是:一边推进根因排查,一边先把"进程轮回"的兜底机制加上,让服务先稳定住。
兜底思路:让进程"轮回"
既然内存只能靠进程结束来回收,那思路就非常直接:定期让 worker 进程"转世重生"。
具体来说:
- 单个 worker 处理完一定数量的请求后,处理完当前请求就优雅退出
- Gunicorn 的 master(仲裁者)进程监测到 worker 退出,立刻拉起一个全新的 worker 顶替
- 新 worker 从初始状态启动,内存回到基线水平
- master 进程本身很轻、内存稳定,负责监督和补位,服务全程不中断
这个机制就是 Gunicorn 的 max-requests,也是解决长期运行内存增长最经典的答案。
Gunicorn 的答案:max-requests
工作机制
请求进入
│
▼
worker 每处理一个请求,计数 nr 加 1
│
├── nr < max_requests ──► 继续处理
│
└── nr >= max_requests ──► 日志打印 "Autorestarting worker after current request."
│
▼
处理完当前请求,停止接收新连接
│
▼
进程优雅退出(内存随进程归还 OS)
│
▼
master 检测到退出,拉起新 worker
关键点在于优雅二字:正在处理的请求会跑完,不会丢;新请求由内核通过监听 socket 自动路由给其他存活 worker;worker 补位后服务能力自动恢复。对客户端来说,整个过程几乎无感知。
max-requests
| 参数 | 含义 |
|---|---|
--max-requests INT | worker 处理多少请求后重启,默认 0(不限) |
--max-requests-jitter INT | 给每个 worker 的重启阈值加一个随机抖动值 |
max-requests-jitter:为什么要抖动
如果所有 worker 用同一个阈值,会有一个致命问题:它们会同时到达阈值、同时重启。
设想 4 个 worker、max_requests = 1000、QPS 均匀分配的场景——4 个 worker 几乎在同一时刻处理完第 1000 个请求,然后几乎同时退出、同时被拉起来。这个瞬间服务能力骤降,如果期间来一波请求,可能大量超时。
所以 Gunicorn 提供了 max_requests_jitter:实际生效的阈值是 max_requests + random.randint(0, jitter),每个 worker 的重启点随机错开,避免"羊群效应"(thundering herd)。
# 例:实际阈值落在 [1000, 1100] 之间,每个 worker 不同
--max-requests 1000 --max-requests-jitter 100
相关参数
| 参数 | 作用 |
|---|---|
--worker-class | worker 类型:sync、gthread、uvicorn.workers.UvicornWorker 等 |
--threads | 配合 gthread,每个 worker 的线程数 |
--timeout | worker 处理单个请求的超时(默认 30s),超时会杀 worker |
--graceful-timeout | 优雅退出时给 worker 处理在途请求的最长时间(默认 30s),超时强制杀死 |
--preload | 启动时先在 master 中加载应用,再 fork 出 worker |
实战配置
gthread worker:多数场景的首选
同步逻辑多、或者用线程池做并发的最稳妥选择:
# gunicorn.conf.py
import multiprocessing
# 进程数:经典公式 2 × CPU 数 + 1,不要无脑拉大
workers = multiprocessing.cpu_count() * 2 + 1
# gthread:每个进程内再用线程处理并发
worker_class = "gthread"
threads = 4
# 核心:按请求数自动重启,回收泄漏 + 线程 arena
max_requests = 1000
max_requests_jitter = 100
# 给在途请求留足时间
timeout = 60
graceful_timeout = 60
keepalive = 5
bind = "0.0.0.0:8000"
accesslog = "-"
errorlog = "-"
UvicornWorker:FastAPI 异步服务的标配
FastAPI / 异步应用用 Uvicorn 作为 ASGI 服务器跑在 Gunicorn 之下,核心配置几乎一样,只是换掉 worker_class:
# gunicorn.conf.py(FastAPI + Uvicorn)
import multiprocessing
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "uvicorn.workers.UvicornWorker"
max_requests = 1000
max_requests_jitter = 100
timeout = 60
graceful_timeout = 60
keepalive = 5
bind = "0.0.0.0:8000"
accesslog = "-"
errorlog = "-"
CLI 与 Docker 形式
不喜欢配置文件的话,命令行同样支持:
# gthread
gunicorn app.main:app -w 4 -k gthread --threads 4 \
--max-requests 1000 --max-requests-jitter 100 \
--timeout 60 --graceful-timeout 60
# FastAPI + UvicornWorker
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker \
--max-requests 1000 --max-requests-jitter 100
Dockerfile 里同样直接写:
CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "gthread", \
"--threads", "4", "--max-requests", "1000", "--max-requests-jitter", "100"]
如何估算阈值
max_requests 设多少,取决于你想多久让一个 worker "转世"一次:
单 worker 每分钟处理的请求数 × 期望重启间隔(分钟)≈ max_requests
举例:单 worker 每分钟处理 100 个请求,希望每 2 小时重启一次,那么 100 × 120 = 12000。
实际落地建议:
- 从 1000 ~ 2000 起步,观察内存曲线和重启频率再调整
- 优先让"重启周期"短于"内存涨到危险水位的时间"
- 不要设成几十这种激进值——频繁重启会带来日志抖动、连接反复建立、短暂空窗
- jitter 建议约为 max_requests 的 5% ~ 10%
UvicornWorker 的三个坑
用 UvicornWorker 时,max_requests 的坑比 gthread 多,注意以下三点:
1. Gunicorn 版本要够新
历史上存在 bug:worker 日志打印了 Autorestarting worker after current request. 和 Application shutdown complete,但随后 master 报 WORKER TIMEOUT 并给 worker 发 SIGABRT,导致 worker 挂死而不是干净重启。原因是 UvicornWorker 在部分版本里没有按请求调用 notify(),master 误判 worker 无响应。该问题在 Gunicorn 24.0.0 起修复,生产环境务必升级。
gunicorn --version
# 确认 >= 24.0.0
2. 不要用 uvicorn 自带的 --limit-max-requests
Uvicorn 自己也有一个 --limit-max-requests 参数,但它和 Gunicorn 的 max_requests 语义完全不同:Uvicorn 的版本在单 worker 时会直接关停整个应用而不是重启补位。在 Gunicorn 管理下,统一用 Gunicorn 的参数,别混用。
3. 调大 timeout 和 graceful-timeout
UvicornWorker 的优雅退出过程更复杂,graceful-timeout 默认 30s,如果你的接口有长耗时请求,这个值可能不够,worker 会被强杀。建议都设到 60s 以上,并确保大于最长请求耗时。
验证与监控
日志验证
每次自动重启都会在错误日志里留下标记,直接 grep 即可统计:
grep -c "Autorestarting worker after current request" /var/log/gunicorn/error.log
同时会看到 worker 退出与新 worker 启动的配对日志:
[INFO] Worker exiting (pid: 12345)
[INFO] Booting worker with pid: 23456
RSS 内存监控
写一个简单的巡检脚本,盯住 worker 的 RSS,看它是否在 max_requests 触发后回到基线:
# 观察所有 gunicorn worker 进程的内存
ps -o pid,ppid,rss,etime,cmd -e | grep gunicorn | grep -v grep
或者在 Python 里用 psutil 精确抓取:
import psutil
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
if "gunicorn" in " ".join(proc.info["cmdline"][:1]):
try:
rss_mb = proc.memory_info().rss / 1024 / 1024
print(f"PID {proc.pid}: {rss_mb:.1f} MB")
except psutil.NoSuchProcess:
pass
配合 Prometheus 的 process_resident_memory_bytes 指标,可以画出重启前后的锯齿曲线:每次 max_requests 触发后 RSS 回落到基线,就是兜底生效的铁证。
完整模板
汇总一份可直接用于生产的配置:
# gunicorn.conf.py
import multiprocessing
# 基础并发
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "gthread" # 异步应用换成 uvicorn.workers.UvicornWorker
threads = 4
# 内存兜底:按请求数自动重启 worker
max_requests = 1000 # 每个 worker 处理 1000 个请求后转世
max_requests_jitter = 100 # 实际阈值随机落在 [1000, 1100],避免集体重启
# 超时:确保长请求不会被误杀
timeout = 60
graceful_timeout = 60
keepalive = 5
# 网络与日志
bind = "0.0.0.0:8000"
backlog = 2048
accesslog = "-"
errorlog = "-"
# 可选:先在 master 中加载应用再 fork,降低 worker 内存基数
# preload_app = True
别忘了找真凶
max_requests 是兜底,不是根治。它让服务在泄漏存在时依然稳定,但不会让内存使用变高效。如果内存增长幅度很大(比如每个 worker 涨到几个 G),说明有真实的泄漏在浪费资源,仍然值得抽时间定位:
# 用 tracemalloc 快照定位大对象
import tracemalloc
tracemalloc.start()
# ... 跑一段时间请求 ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
print(stat)
排查顺序建议:先搜代码里的无限增长缓存(lru_cache、模块级 list/dict)、再检查所有连接是否有 with 或显式 close、最后检查 asyncio 任务是否有未取消的句柄。
常见误区
| 误区 | 正解 |
|---|---|
只设 max_requests 不设 jitter | 所有 worker 同时重启,瞬间服务能力骤降,务必加 jitter |
max_requests 设得越小越安全 | 太小的阈值导致频繁重启和连接抖动,从 1000~2000 起步 |
UvicornWorker 下用 --limit-max-requests | 单 worker 时会直接关停整个应用,用 Gunicorn 的 max_requests |
| 认为调大 worker 数就能解决内存问题 | worker 越多,总内存基线越高,先算清物理内存再定 workers |
以为 max_requests 能防住所有 OOM | 它防不住"单请求内一次性暴涨",那种情况要靠资源限流和配额 |
总结
Python HTTP 服务长期运行内存只涨不降,本质是对象能回收但内存不归还 OS:可能是真实泄漏,也可能只是 glibc malloc 的线程 arena 和碎片。既然进程结束是归还内存的唯一可靠途径,那就让 Gunicorn 定期"转世"worker:
--max-requests 1000:worker 处理满 1000 个请求后优雅退出、由 master 补位--max-requests-jitter 100:随机错开每个 worker 的重启点,避免集体重启gthread或uvicorn.workers.UvicornWorker:两种形态都支持,UvicornWorker 记得用 Gunicorn 24.0.0+- 调大
timeout/graceful-timeout:给在途请求留足时间,保证优雅不丢请求 - 监控
Autorestarting worker日志和 RSS 曲线:确认兜底生效
这套方案成本极低、改动极小、立竿见影,是生产环境应对内存增长的必备护身符。等服务稳定下来,再慢慢排查真正的泄漏来源,把内存使用做健康。