侧边栏壁纸
  • 累计撰写 142 篇文章
  • 累计收到 2 条评论

Docker 容器里获取客户端 IP 全是 172 网段?排查 Nginx 反代与真实 IP 穿透我踩过的几个坑

2026-10-2 / 0 评论 / 5 阅读
AI 摘要由 AI 生成

排查中发现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 上处理端口映射有两种途径:

  1. iptables DNAT 规则:通过内核的 PREROUTING 链将目标端口重定向到容器 IP,这种方式可以保留原始数据包的源 IP。
  2. 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 地址。

快速自测与验证命令清单

排查此类问题时,不要盲目去改业务代码,按照以下三步自测能省下大量摸索时间:

  1. 先在宿主机验证 Nginx 是否将头部正确注入:
    在宿主机直接通过 curl 模拟请求,并检查 Nginx 访问日志中的 $http_x_forwarded_for 和 $remote_addr:

    curl -I http://127.0.0.1/api/health
  2. 在容器内部临时起一个简单的 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 配置全通,问题百分之百出在后端应用框架的配置解析上。

  3. 通过 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 报文层的职责边界,把边缘接入、代理转发和应用解析三处的配置对齐,这类网段漂移的问题就能彻底根治。

评论一下?

OωO
取消