Python HTTP 服务内存持续增长:用 Gunicorn max-requests 让 Worker 自动重启兜底

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% 归还内存的方式,是结束这个进程。

为什么"找到并修掉泄漏"那么难

理论上应该从源头修代码,但现实里这条路经常走不通:

  1. 内存泄漏定位成本高——tracemallocobjgraphmemory_profiler 都是重武器,需要专门腾时间做分析
  2. 部分泄漏来自无法改源码的第三方依赖
  3. 分配器碎片和 arena 增长根本不是代码 bug,改代码也修不掉
  4. 有些泄漏只在特定请求组合下触发,复现困难

所以生产环境的务实做法是:一边推进根因排查,一边先把"进程轮回"的兜底机制加上,让服务先稳定住。

兜底思路:让进程"轮回"

既然内存只能靠进程结束来回收,那思路就非常直接:定期让 worker 进程"转世重生"

具体来说:

  1. 单个 worker 处理完一定数量的请求后,处理完当前请求就优雅退出
  2. Gunicorn 的 master(仲裁者)进程监测到 worker 退出,立刻拉起一个全新的 worker 顶替
  3. 新 worker 从初始状态启动,内存回到基线水平
  4. 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 INTworker 处理多少请求后重启,默认 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-classworker 类型:syncgthreaduvicorn.workers.UvicornWorker
--threads配合 gthread,每个 worker 的线程数
--timeoutworker 处理单个请求的超时(默认 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. 调大 timeoutgraceful-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:

  1. --max-requests 1000:worker 处理满 1000 个请求后优雅退出、由 master 补位
  2. --max-requests-jitter 100:随机错开每个 worker 的重启点,避免集体重启
  3. gthreaduvicorn.workers.UvicornWorker:两种形态都支持,UvicornWorker 记得用 Gunicorn 24.0.0+
  4. 调大 timeout / graceful-timeout:给在途请求留足时间,保证优雅不丢请求
  5. 监控 Autorestarting worker 日志和 RSS 曲线:确认兜底生效

这套方案成本极低、改动极小、立竿见影,是生产环境应对内存增长的必备护身符。等服务稳定下来,再慢慢排查真正的泄漏来源,把内存使用做健康。

延伸阅读