排查中发现Docker容器获取到的客户端IP显示为172网段,原因是Nginx反代配置中未正确传递客户端IP。解决方法是在Nginx配置中明确透传`X-Real-IP`和`X-Forwarded-For`头部,确保IP正确穿透。
上个月我在排查一个接口频控误杀问题时,监控日志里的现象很离奇。线上几千个分布在全国不同城市的普通用户,在短时间内被风控中间件集体判定为高频恶意刷量,直接吃了 429 封禁。
登录后台翻看日志,所有报错请求的客户端来源 IP 几乎一模一样,全是 172.17.0.1 或者 172.18.0.1。这是典型的 Docker 默认网桥网关地址。在业务代码眼里,来自互联网的所有流量全被扣上了同一个局域网 IP,单 IP 限流规则自然一触发就死一片。
很多团队用 Docker 部署后端应用(不管是 Java、Go、Python 还是 Node.js),前面挡一层 Nginx 做反向代理或者 SSL 卸载。架构看起来很简单,但在实际网络链路中,真实客户端 IP 想一路不走样地送进容器内部的业务层,中间至少有五个地方容易断链。这里把排查过程里梳理出来的几个关键卡点完整复盘一遍。
为什么代码读到的 IP 会变成 172.17.0.1?
要定位问题,得先看流量是怎么从用户终端流进容器的。
典型链路通常是:访客浏览器发出 HTTP 请求,到达宿主机的公网网卡,宿主机上的 Nginx 收到请求后,通过反向代理指令转发给本地监听的 Docker 容器映射端口(例如 127.0.0.1:8080),数据包再穿过 Docker 的网桥设备(docker0 或自定义 bridge),最终交给容器内部的 Web 进程。
访客真实IP (203.0.113.195)
│
▼
宿主机公网网卡 (eth0)
│
▼
宿主机 Nginx (反向代理)
│ 建立新的 TCP 连接 (来源IP变更为 172.17.0.1)
▼
Docker 网桥 (docker0 / bridge)
│
▼
容器内业务进程 (Spring Boot / Gin / Express / FastAPI)
在第四层 TCP 协议层面,Nginx 转发请求给 Docker 容器时,是在本地主动发起了新的 TCP 握手。对容器内的应用程序来说,底层 Socket 连接直接打交道的对象就是 Docker 网桥或者宿主机的回环地址,所以套接字层面的 remote_addr 理所当然就是 172.17.0.1。
想让容器里的业务读到外网真实 IP,只能走应用层协议,也就是依赖 HTTP 头部字段(如 X-Real-IP、X-Forwarded-For)把原始 IP 带过去。一旦这条链路里的某一段配置有缺失或参数理解偏差,IP 传递就会彻底失效。
坑一:宿主机 Nginx 漏配代理头,或只配了一半
最常见也是最容易忽略的原因,是宿主机 Nginx 的 location 块里压根没有主动向后端容器注入 IP 请求头。
很多人写 Nginx 配置时只写了最简单的一句:
# 错误示范:没有传递任何客户端 IP 相关头部
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
这种情况下,后端容器接收到的 HTTP 报文里没有包含任何访客信息。应用框架如果没有显式配置,直接调用 request.getRemoteAddr() 就只能去拿底层 Socket 的对端 IP,拿到 172.17.0.1 是必然的。
标准的基础反代配置必须明确透传这两个头部字段:
location /api/ {
proxy_pass http://127.0.0.1:8080;
# 传递最直观的单点 IP
proxy_set_header X-Real-IP $remote_addr;
# 将客户端 IP 依次追加到链条后
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 保留原始请求主机名与协议
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
这里要理清 $remote_addr 和 $proxy_add_x_forwarded_for 的区别:
$remote_addr是当前直接与 Nginx 建立 TCP 连接的客户端对端 IP。如果客户端是直连宿主机 Nginx,这个值就是真实的访客公网 IP。$proxy_add_x_forwarded_for是一个累加变量。如果原始请求里已经带了X-Forwarded-For头部(比如上游经过了 CDN),Nginx 会在原有值后追加逗号和当前的$remote_addr;如果原始请求没带这个头,它的值就等于$remote_addr。
如果只是单机单层 Nginx,加上这两行配置就能解决大部分问题。但一旦生产环境接了云厂商的负载均衡、CDN 或者有多层反代,只配这两行还远远不够。
坑二:Docker 容器的端口映射与 userland-proxy 干扰
部分开发者遇到了更诡异的现象:即便不经过 Nginx,直接把容器端口映射到公网(如 docker run -p 8080:80),容器内拿到的客户端 IP 居然也是 172.17.0.1。
产生这个现象的根源在于 Docker 默认的网络转发机制。
Docker 在 Linux 上处理端口映射有两种途径:
- iptables DNAT 规则:通过内核的 PREROUTING 链将目标端口重定向到容器 IP,这种方式可以保留原始数据包的源 IP。
- docker-proxy (userland-proxy):当 iptables 规则不可用或者从回环地址
127.0.0.1访问端口时,Docker 守护进程会启动一个名为docker-proxy的用户空间代理进程协助转发。这个进程转发数据包时,源地址会被修改(SNAT)为网桥网关地址172.17.0.1。
如果你的系统启用了 UFW 或 firewalld,某些防火墙的重载操作可能会冲掉 Docker 写入 iptables 的 FORWARD 链规则,导致流量全部退化到 userland-proxy 上,进而丢失真实来源。
可以通过查看 Docker 配置文件 /etc/docker/daemon.json 确认配置项:
{
"userland-proxy": false,
"iptables": true
}
把 userland-proxy 设为 false 后重启 Docker 服务,流量将强制全部由 Linux 内核 iptables 的 DNAT 规则转发,既降低了用户态上下文切换的 CPU 开销,也避免了源 IP 被静默替换成网桥 IP。
坑三:套了 CDN 或云厂商 SLB,real_ip 模块没配对
现代 Web 服务很少有公网裸跑的,前面通常都会套一层腾讯云 CDN、阿里云 ESA、Cloudflare 或者私有七层负载均衡。
一旦前面多了这一层,宿主机 Nginx 看到的 $remote_addr 就不再是终端用户,而是云厂商的 CDN 边缘节点 IP。
这时候如果你在宿主机 Nginx 里继续使用 proxy_set_header X-Real-IP $remote_addr;,传给后端容器的 X-Real-IP 就全变成了云厂商的节点地址。不仅限流会出问题,按地理位置做 IP 解析的功能也会全部失效。
解决这个问题的正统方案是启用 Nginx 的 ngx_http_realip_module 模块,通过反向修正把 Nginx 的 $remote_addr 还原为终端访客 IP。
配置模板如下:
# 1. 声明所有可信的上游代理节点网段(以 Cloudflare 或本地反代为例)
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
set_real_ip_from 127.0.0.1;
# 2. 指定从哪个 HTTP 头部提取真实 IP
real_ip_header X-Forwarded-For;
# 3. 极其关键的参数:开启递归查找
real_ip_recursive on;
这里重点解释 real_ip_recursive on 这个参数,无数人在它上面栽过跟头。
X-Forwarded-For 的数据格式通常是用逗号隔开的一长串 IP 列表:
X-Forwarded-For: 203.0.113.195, 120.24.100.5, 10.100.1.20
- 如果
real_ip_recursive off(默认值):Nginx 遇到来自受信任代理的连接时,会无脑直接取X-Forwarded-For头部的最后一个 IP。在多层反代(如 CDN -> SLB -> Nginx)的场景下,最后一个 IP 往往是 SLB 的内网 IP,依然不是访客真实的地址。 - 如果
real_ip_recursive on:Nginx 会从列表的最右侧开始倒序扫描,如果倒数第一个 IP 在set_real_ip_from信任列表里,就继续往前看倒数第二个;直到遇到第一个不在信任网段里的 IP,才把它作为最终的$remote_addr。
只有正确配置了 set_real_ip_from 和 real_ip_recursive on,Nginx 才能在多层代理嵌套时准确剔除中转节点,拿到最左侧合法的终端用户 IP。
坑四:防范 X-Forwarded-For 伪造漏洞
既然提到了 X-Forwarded-For,就必须注意安全性问题。
因为 HTTP 头部是纯文本协议,任何普通用户都可以用 curl 随心所欲地在自己的请求里带上伪造的头部:
curl -H "X-Forwarded-For: 8.8.8.8" https://yourdomain.com/login
如果你的边缘入口 Nginx 没有配 real_ip 模块,而是轻信了客户端传入的字段,直接把整个 X-Forwarded-For 头原封不动往下传,后端的应用程序一旦采用类似于 headers['X-Forwarded-For'].split(',')[0] 的粗暴方式提取客户端 IP,攻击者就能任意伪造客户端 IP 绕过封禁或白名单限制。
防御伪造的核心原则:入口网关必须对非受信任客户端强行覆盖,而不是盲目追加。
如果当前 Nginx 是公网直连用户的第一站(前面没有任何 CDN 或 SLB):
# 公网第一跳直接将 X-Forwarded-For 重置为物理连接的 remote_addr
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
这样即使用户在请求里伪造了 X-Forwarded-For,也会被第一站 Nginx 彻底冲掉,无法穿透到后端。
如果前面确实有可信的 CDN,必须严格限定 set_real_ip_from 的 IP 范围,绝对不能偷懒写成 set_real_ip_from 0.0.0.0/0;,否则就等于向全世界敞开了头部伪造的大门。
坑五:Nginx 传对了,后端应用框架压根没信任代理
很多时候运维同学拿着 tcpdump 抓包,或者在容器内的接入日志里查看,确认 Nginx 确实把 X-Forwarded-For: 203.0.113.195 正常发过来了,但是业务代码调框架 API 时,打印出来的依旧是 172.17.0.1。
这是因为绝大多数现代后端框架出于安全考虑,默认不信任任何代理头,防止开发者在无代理环境下被任意伪造的 HTTP 头欺骗。框架需要显式开启代理信任开关。
1. Spring Boot (Java)
在 application.yml 中必须显式配置转发头策略:
server:
# 开启原生转发头策略解析
forward-headers-strategy: native
或者使用框架内部策略:
server:
forward-headers-strategy: framework
tomcat:
remoteip:
remote-ip-header: X-Forwarded-For
protocol-header: X-Forwarded-Proto
# 信任的内网反代 IP 正则
internal-proxies: "10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}|172\\.(1[6-9]|2[0-9]|3[0-1])\\.\\d{1,3}\\.\\d{1,3}|192\\.168\\.\\d{1,3}\\.\\d{1,3}|127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"
配置后,request.getRemoteAddr() 才会自动被替换为 X-Forwarded-For 中提取出的真实客户端 IP。
2. Node.js (Express)
Express 默认 trust proxy 是关掉的,需要在入口代码显式开启:
const express = require('express');
const app = express();
// 开启代理信任,可以传 true 或具体的受信子网网段
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
app.get('/api/test', (req, res) => {
// 此时 req.ip 会正确解析头部
res.json({ clientIp: req.ip });
});
3. Go (Gin 框架)
Gin 框架对代理信任有非常明确的防嗅探机制:
package main
import (
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
// 设置受信任的反向代理 IP 或 CIDR 网段
// 不要传 nil,更不要信任所有地址
router.SetTrustedProxies([]string{"127.0.0.1", "172.17.0.0/16", "172.18.0.0/16"})
// 指定客户端 IP 解析来源头
router.TrustedPlatform = gin.PlatformCloudflare // 如果上游是 Cloudflare
// 或者依赖默认的 X-Forwarded-For 与 X-Real-IP
router.GET("/api/test", func(c *gin.Context) {
c.JSON(200, gin.H{
"client_ip": c.ClientIP(),
})
})
router.Run(":8080")
}
4. Python (FastAPI / Uvicorn)
使用 Uvicorn 启动 ASGI 容器时,必须带上 --proxy-headers 和 --forwarded-allow-ips 参数:
uvicorn main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips='*'
如果少了这个启动参数,FastAPI 中的 request.client.host 永远只会反映直连容器的 socket 地址。
快速自测与验证命令清单
排查此类问题时,不要盲目去改业务代码,按照以下三步自测能省下大量摸索时间:
-
先在宿主机验证 Nginx 是否将头部正确注入:
在宿主机直接通过 curl 模拟请求,并检查 Nginx 访问日志中的$http_x_forwarded_for和$remote_addr:curl -I http://127.0.0.1/api/health -
在容器内部临时起一个简单的 HTTP 探测服务:
不用启动沉重的业务应用,利用 Python 快速打印收到的原始报文头:python3 -c "from http.server import HTTPServer, BaseHTTPRequestHandler; class H(BaseHTTPRequestHandler): def do_GET(self): print(self.headers) self.send_response(200) self.end_headers() HTTPServer(('0.0.0.0', 8080), H).serve_forever()"从公网发起一次带参数的访问,查看终端打印出来的 Headers 里是否存在
x-real-ip和x-forwarded-for。如果此时已经能看到正确的公网 IP,说明网络和 Nginx 配置全通,问题百分之百出在后端应用框架的配置解析上。 -
通过 curl 显式注入测试伪造风险:
curl -H "X-Forwarded-For: 1.1.1.1" https://yourdomain.com/api/test观察服务返回或日志中记录的 IP 是你注入的
1.1.1.1还是你的真实出口 IP。如果是前者,说明反向代理存在伪造漏洞,需要立即检查set_real_ip_from与重写规则。
网络穿透虽然环节不多,但涉及链路里每一层的默认安全策略。理清 TCP 连接层与 HTTP 报文层的职责边界,把边缘接入、代理转发和应用解析三处的配置对齐,这类网段漂移的问题就能彻底根治。
文章标题:Docker 容器里获取客户端 IP 全是 172 网段?排查 Nginx 反代与真实 IP 穿透我踩过的几个坑
文章链接:https://llbbs.cn/jishujaocheng/203.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?