墨线与电路 INK & WIRE

Nginx · 2026.10.24

Nginx 反向代理:把连接、头与超时都握在手里

proxy_pass 只是开始。真正决定一个反代是否可靠的,是 Upgrade 头怎么透传、X-Forwarded-* 怎么设、超时应该落在哪一层,以及什么情况下你该打开 keepalive。

陈墨 6 分钟阅读 #Nginx#运维#网络

很多人对 Nginx 反向代理的印象停留在一行配置:

nginx
location / {
    proxy_pass http://127.0.0.1:3000;
}

这行配置能让请求跑通,但它同时埋下了一串问题:真实客户端 IP 丢了、WebSocket 握手失败、慢接口在 60 秒处被无声掐断、每个请求都新建一次到上游的 TCP 连接。下面逐条拆开。

一、proxy_pass 的 URI 语义

先记一条容易踩的规则:proxy_pass 后面带不带路径,行为完全不同。

nginx
# A:不带 URI,原始请求路径原样透传
location /api/ {
    proxy_pass http://127.0.0.1:3000;
}
# 请求 /api/users -> 上游收到 /api/users

# B:带 URI,location 前缀被替换
location /api/ {
    proxy_pass http://127.0.0.1:3000/;
}
# 请求 /api/users -> 上游收到 /users

差别就在那个末尾的斜杠。B 的替换逻辑是用 proxy_pass 的 URI 替换掉 location 匹配到的前缀。如果 location 是正则(~ 或 ~*),则不允许带 URI,Nginx 会直接报 "proxy_pass" cannot have URI part in location given by regular expression。

选型建议:单一上游且路径一致时用 A 最省心;只有在确实需要剥掉 /api 前缀时才用 B,并在注释里写清楚,因为半年后没人记得这个斜杠的意思。

二、转发头:把客户端身份传下去

上游服务看到的 remote_addr 是 Nginx 自己,不是用户。必须显式设头:

nginx
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_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-Port  $server_port;

$proxy_add_x_forwarded_for 的含义是「原有的 XFF 追加本次 $remote_addr」。这一步很关键,也很危险:它无条件信任客户端传入的 XFF 头。攻击者可以伪造 X-Forwarded-For: 1.2.3.4,然后你的应用如果取 XFF 的第一段当真实 IP,就拿到了伪造值。

正确的处理是在最外层就把这个头覆盖掉,而不是追加:

nginx
# 若是用户直连的边界 Nginx,直接覆盖,拒绝继承
proxy_set_header X-Forwarded-For $remote_addr;

只有在你确信上游还有一层可信代理(例如 CDN 或负载均衡)时,才用 real_ip 模块显式声明信任来源:

nginx
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header   X-Forwarded-For;
real_ip_recursive on;

set_real_ip_from 的白名单就是这个信任边界。写宽了等于没写。

三、超时:分成四个,别只调一个

proxy_read_timeout 默认 60 秒。一次导出任务跑了 90 秒,网关报 504,而应用日志显示成功——这是最常见的「幽灵失败」。四个超时各管一段:

指令管什么典型场景
proxy_connect_timeout与上游建连上游过载、SYN 队列满
proxy_send_timeout向上游写请求体的两次写间隔大文件上传
proxy_read_timeout从上游读响应的两次读间隔长任务、SSE
send_timeout向客户端写的两次写间隔移动端弱网

注意 proxy_read_timeout 计的是两次读之间的间隔,不是总时长。上游每秒吐一个字节,它永远不会超时。要限制总时长,得靠应用层或 proxy_max_temp_file_size 之类的配套手段。

对长连接类接口(SSE、WebSocket),关掉缓冲是必须的:

nginx
location /events {
    proxy_pass http://upstream_events;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 1h;
    chunked_transfer_encoding on;
}

proxy_buffering off 让 Nginx 不攒够缓冲区再转发。否则 SSE 的「实时」会被 Nginx 的 4KB/8KB 缓冲吞掉,前端看起来像是隔几秒才收到一批。

四、WebSocket:那三行必须同时存在

WebSocket 握手本质是一个带 Upgrade: websocket 的 HTTP 请求。Nginx 默认用 HTTP/1.0 与上游通信,且不带 Upgrade 头,所以握手必然失败。三个条件缺一不可:

nginx
location /ws {
    proxy_pass http://127.0.0.1:3000;

    # 1. 必须用 HTTP/1.1,1.0 不支持 Upgrade
    proxy_http_version 1.1;

    # 2. 透传 Upgrade / Connection($connection_upgrade 见 map)
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    # 3. 长连接需要更长的读超时
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

$connection_upgrade 不是内置变量,要在 http 块里定义:

nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

用 map 而不是硬编码 Connection: upgrade,是因为普通 HTTP 请求走同一个 location 时,发 Connection: upgrade 会让上游的 HTTP 解析器困惑,甚至触发 400。map 保证只有真的带 Upgrade 头时才写 upgrade。

如果前面还有一层 CDN 或 SLB,它们也必须支持 Upgrade 透传,否则你在 Nginx 这一层配得再对也没用。

五、keepalive:省掉三次握手

默认情况下,Nginx 对每个客户端请求都新建一条到上游的连接,用完即关。高 QPS 下这会累积大量 TIME_WAIT,并把延迟里塞进一次额外的握手。

nginx
upstream backend {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    keepalive 64;          # 每个 worker 保留的空闲连接数
    keepalive_requests 1000;
    keepalive_timeout 60s;
}

location / {
    proxy_pass http://backend;
    proxy_http_version 1.1;      # HTTP/1.0 不支持长连接
    proxy_set_header Connection "";  # 清空,避免 close
}

三个要点:upstream 里写 keepalive N、proxy_http_version 1.1、以及 清空 Connection 头。最后一条最常被漏掉——如果客户端发来 Connection: close 而你原样透传,上游就会关连接,keepalive 池永远存不住东西。

keepalive 的数值是「每个 worker 进程」的,不是全局。worker_connections 要留够余量,否则连接池本身会成为瓶颈。

六、缓冲与流式响应的取舍

Nginx 默认开启响应缓冲:先把上游的响应收进内存/临时文件,再逐步发给客户端。好处是慢客户端不会占住上游连接(所谓「慢客户端保护」),坏处是大文件下载会先落盘。

nginx
# 大文件下载:关掉缓冲,直接流式转发
location /downloads/ {
    proxy_pass http://127.0.0.1:9000;
    proxy_buffering off;
    proxy_max_temp_file_size 0;
    proxy_request_buffering off;   # 上传也直通,不先落盘
}

proxy_request_buffering off 对大文件上传尤其重要:默认 Nginx 会把整个上传体先写到临时文件再转给上游,磁盘 IO 翻倍,而且在传输完成前上游收不到任何字节,做不了流式校验。

七、一份可以直接用的完整配置

nginx
upstream app_backend {
    least_conn;
    server 127.0.0.1:3000 max_fails=3 fail_timeout=15s;
    server 127.0.0.1:3001 max_fails=3 fail_timeout=15s;
    keepalive 64;
    keepalive_requests 1000;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;
    server_name blog.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name blog.example.com;

    ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;

    client_max_body_size 50m;
    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml text/plain;
    gzip_min_length 1024;

    # 静态资源直接由 Nginx 发出,不经过上游
    location /assets/ {
        alias /srv/blog/dist/assets/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Connection        $connection_upgrade;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_send_timeout    60s;
        proxy_read_timeout    60s;

        proxy_next_upstream error timeout http_502 http_503;
    }
}

这里 X-Forwarded-For 直接赋 $remote_addr 而不是 $proxy_add_x_forwarded_for,因为这一层就是边界,不接受任何外部声明。

八、上线前该问自己的五个问题

  1. 应用拿到的客户端 IP 是真的客户端,还是可被伪造的头?
  2. 长任务接口的读超时,是不是还停在默认 60 秒?
  3. 上游的健康检查与 max_fails 是否配了,故障实例会不会一直吃流量?
  4. 静态资源是走 Nginx 还是绕回应用?后者会让 Node 白白扛带宽。
  5. TLS 终止点在哪一层?HSTS 头由谁下发?

任何一个答不上来,配置就还没写完。反代的价值不在「能通」,而在「通的过程中把该握住的都握住」。