运维

Nginx 反向代理那些容易踩的坑

Nginx 反向代理看着是个一行配置就能搞定的事:location /api/ { proxy_pass http://backend:8000; }。但真上线后你会发现一堆诡异现象——后端拿不到真实 IP、刷新 SPA 页面 404、Safari 上偶发连接断开、HTTPS 跳转成 HTTP 死循环……

这些坑我一个个都踩过。这篇把最常遇到的几个整理出来,每个都附上经过验证的配置片段。把这几处理顺,线上能少一半莫名其妙的故障。

一、一个标准的 /api/ 反代配置

先上一段我目前在用的、相对完整的反代配置,下面逐条解释为什么这么写:

# /etc/nginx/conf.d/default.conf
server {
    listen 80;
    server_name example.com;

    # 后端 API 反代
    location /api/ {
        proxy_pass http://backend:8000;

        # 传递真实请求信息(关键,见第二节)
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 长连接 & 超时(见第三节)
        proxy_http_version 1.1;
        proxy_set_header   Connection "";
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }
}

二、proxy_set_header 的四个必填项

反代之后,后端拿到的请求来源变成了 Nginx 自己(IP 是 Nginx 所在机器的内网 IP,比如 172.18.0.1),原始用户的信息全丢了。这会导致:

  • 日志里的 IP 全是 Nginx 的,无法做风控、地理统计
  • 依赖 X-Forwarded-Proto 判断 HTTPS 的代码(如 OAuth 回调)会出错
  • 基于 Host 的逻辑(如生成绝对 URL)拿到的是内网地址

所以这四个 header 一个都不能少:

Header 取值 作用
Host$host原始域名,后端生成 URL 用
X-Real-IP$remote_addr用户真实 IP(单值)
X-Forwarded-For$proxy_add_x_forwarded_for代理链路,风控常用
X-Forwarded-Proto$scheme原始协议 http/https
注意 $proxy_add_x_forwarded_for 会把已有的 XFF 值追加上去,而不是覆盖。如果你的 Nginx 直接面向公网,要警惕客户端伪造 XFF,必要时只信任 $remote_addr

三、proxy_http_version 1.1 与长连接

Nginx 默认用 HTTP/1.0 跟后端通信,这意味着每个请求都会新建一次 TCP 连接,用完就关。对高 QPS 的接口,这是巨大的浪费——光握手就够喝一壶。

开启长连接只需两行:

proxy_http_version 1.1;
proxy_set_header   Connection "";

第一行升级协议到 1.1,第二行清掉 Connection: close 这个默认头,让 Nginx 和后端之间复用连接。配合后端的 keep-alive,吞吐能提升 20%~40%。

upstream 里也能开 keepalive:upstream 块里加 keepalive 32;,维护一个到后端的连接池,配合上面的两行,效果最佳。

四、SPA history 模式与 try_files

Vue/React 用 history 路由时,用户刷新 /users/123 这种深层路由,浏览器会向服务器请求这个路径。但服务器上根本没有 /users/123/index.html,于是返回 404。

解决办法是用 try_files 把找不到的路径都回退到 index.html,让前端路由接管:

# 前端静态资源 + SPA history 回退
location / {
    root   /usr/share/nginx/html;
    index  index.html;
    try_files $uri $uri/ /index.html;
}

# 重要:/index.html 不能被缓存,否则发布新版后用户拿到旧入口
location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
}

try_files $uri $uri/ /index.html 的含义是:先找文件、再找目录、最后回退到 index.html。这是 SPA 上线必加的一行,没有它用户一刷新就 404。

五、Safari keepalive 复用断连的奇怪 bug

这是个非常隐蔽的坑,折磨过我整个下午。现象是:iOS Safari 上偶发性出现请求挂起、空白页或 ERR_SPDY_PROTOCOL_ERROR,Chrome 上完全正常。

排查后发现是 keepalive_timeout 设得过长(默认 65s 甚至更高),Safari 对长连接复用有比较严格的超时判断,连接被服务端保持太久后,Safari 误判为已断开,再复用时直接报错。

解法:keepalive_timeout 适当调低,或者在出问题的场景干脆对 Safari 关掉 keep-alive。我现在的折中配置是 keepalive_timeout 30s;,兼顾性能和兼容。
# nginx.conf 的 http 块
keepalive_timeout  30s;
keepalive_requests 1000;   # 单个连接最多复用 1000 次后关闭
send_timeout       60s;

另一个相关的小坑:send_timeout两次写操作之间的间隔超时,不是总时长。慢客户端长连接可能撑很久不被断,必要时配合 client_body_timeoutclient_header_timeout 一起调。

六、静态资源缓存:hash 文件 immutable

前端构建产物带 hash(如 app.7f3a9c.js),内容变了 hash 就变,可以放心地永久缓存。而 index.html 是入口,绝不能缓存——否则发版后用户拿到的还是旧入口,引用的还是旧 hash 文件。

策略很清晰:

# 带 hash 的静态资源:永久缓存(内容变了 hash 就变,安全)
location ~* \.(js|css|png|jpg|jpeg|gif|webp|svg|ico|woff2?)$ {
    root /usr/share/nginx/html;
    expires 30d;
    add_header Cache-Control "public, max-age=2592000, immutable";
    access_log off;
}

# index.html:绝不缓存,保证发版即时生效
location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires off;
}

关键字 immutable 告诉浏览器:这个资源永远不会变,连 304 校验请求都不用发。带 hash 的资源用上它,回访用户的加载速度会有质的提升。同时别忘了关掉这类静态资源的 access_log,省一堆日志噪音。

一个小坑:用 add_header 在子 location 里会覆盖父级的所有 add_header,而不是追加。所以如果你在 server 块设了 HSTS、CSP 等公共头,子 location 里要重新声明一遍,否则会丢失。

✓ 小结

Nginx 反代的坑基本都集中在"信息丢失"和"连接管理"两件事上。四个 proxy_set_header 让后端能拿到真实的用户信息;HTTP/1.1 + Connection "" 让连接能复用;try_files 让 SPA 不再 404;hash 资源 immutable + index.html 不缓存让发版顺畅。每一条都是上线前必须检查的清单。把这些理顺之后,Nginx 这一层基本能稳定运行很久不需要再碰。