限流键取错:一个被"网络太慢"掩盖的全局额度桶
限流日志里出现这样一行:
{"event": "ratelimit 60 per 1 minute (10.89.3.1) exceeded at endpoint: /"}
10.89.3.1 是 podman 网桥网关。配置里写的是"每个客户端 IP 每分钟 60 次",但实际上全站所有用户共用这一个桶。
根因:客户端 IP 一直在变,变的不是它
应用用的是 slowapi 的 get_remote_address,它读的是 TCP 对端地址。请求路径是:
浏览器 → nginx(宿主机) → 127.0.0.1:8000 → 容器内 uvicorn
最后一段是 podman 的端口转发,容器看到的对端永远是网关地址,不管来的是哪个用户。nginx 其实一直在传 X-Forwarded-For,但 uvicorn 默认只信任 127.0.0.1 这个来源,这个头被直接丢弃了。
为什么这么久都没人报障
因为链路慢。请求零星到达,每个用户的请求被摊薄在时间里,60 次/分钟的全局桶根本用不完。反过来,当 CDN 换成灰云、延迟从 15 秒降到 80 毫秒之后,SPA 单页并发十几个 API 请求,桶瞬间见底——
于是每个正常用户打开页面,先被自己的并发请求打到 429。
这类 bug 特别隐蔽:它以"性能优化之后开始报错"的形式出现,很容易被误判成优化本身的问题。
修法
让 uvicorn 采信上游代理的 X-Forwarded-For。它有现成的环境变量:
FORWARDED_ALLOW_IPS=10.89.0.0/16
这个变量要写在 compose 文件里而不是只写进 env 文件——因为很多部署脚本在升级时会重新生成 env 文件,只写 env 的话下次升级就丢了。
顺带修掉一个伪造入口
nginx 默认的 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 是追加语义:客户端自带一个 X-Forwarded-For: 9.9.9.9,代理再把真实地址追加到后面,拼接结果的第一段仍然是假的。而 uvicorn 取的正是第一段——等于把限流键交给了客户端决定。
正确做法是覆盖写:
proxy_set_header X-Forwarded-For $remote_addr;
如果前面还有 CDN,再用 nginx 的 real_ip 模块限定"只有来自 CDN 网段的连接才采信 CF-Connecting-IP",把真实来源还原出来,同时挡住客户端伪造。
验证方式
改完之后别只看日志,查一下审计表里的 client_ip:
| 场景 | 期望结果 |
|---|---|
| 正常访问 | 记录真实客户端 IP |
请求头带伪造 X-Forwarded-For | 仍记录真实 IP |
请求头带伪造 CF-Connecting-IP | 仍记录真实 IP |
第二、三行比第一行更重要:第一行只能证明"改对了",后两行才能证明"改对了而且没留后门"。