KernelRanch
返回文章列表

Nginx 反向代理的三个“看起来配了其实没生效”

2026-11-10 #基础设施

Nginx 反向代理里,最容易出现“配置写了,行为没变”的是三处:upstream 的 keepalive、proxy_read_timeout、以及 499/502/504 的归因。我在 Windows 11 + Docker Desktop 上起了慢后端、nginx 1.27.5 和容器内客户端,跑了四组 keepalive 配置、每组 500 个请求,又把读超时和客户端中断各复现了一遍。结果很直接:upstream 只写 keepalive 32 时,500 个请求仍然打了 500 条后端连接;补齐三个条件后降到 1 条。proxy_read_timeout 2s 对 sleep 5s 的后端返回 504,对 sleep 1s 返回 200;499 在这次实验里只在客户端先断开时出现,access.log 里的 upstream_status 是 -。

先划一条版本边界,因为它会决定下面整张对照表是否适用。实验镜像是 1.27.5。nginx CHANGES 里 1.29.7(2026-03-24)有两条变更:upstream 的 keepalive 指令改为默认开启,proxy_http_version 的默认值改为 1.1,同时不再默认发送 Connection 头。也就是说在 1.29.7 及以上,上面 A/B/C 三组“配置了却没生效”的现象不会重现,D 组的三元组已经变成默认行为。在 1.27 这类旧版本上,这些结论才适用。

0. 实验台:三个容器,不映射宿主端口

实验环境是 Windows 11、Docker 29.4.3(WSL2)。三个容器都限制 256MB,全部放在同一个 Docker network 内,宿主不映射端口;压测客户端也在容器里,避免 Windows 保留端口段带来的 bind 失败。

docker network create cr-nginx-lab
docker run -d --name cr-backend --network cr-nginx-lab --ip 172.21.0.2 --memory 256m python:3.12-slim sleep infinity
docker run -d --name cr-nginx --network cr-nginx-lab --ip 172.21.0.3 --add-host backend:172.21.0.2 --memory 256m nginx:1.27
docker run -d --name cr-client --network cr-nginx-lab --ip 172.21.0.4 --add-host nginx:172.21.0.3 --memory 256m --entrypoint sleep curlimages/curl:8.10.1 infinity

后端是一个 ThreadingHTTPServer:?ms= 控制 sleep 时长,?code= 控制返回码;每接受一条连接、每处理一个请求,都往 /tmp/backend.log 写一行,其中包含 client_address[1] 即后端看到的客户端端口。连接数就从这些端口去重得到:

grep '^REQ' /tmp/backend.log | grep -o 'port=[0-9]*' | sort -u | wc -l

Nginx 是镜像里的 1.27.5。访问日志用自定义格式,字段依次是状态码、请求耗时、upstream 地址、upstream 状态、upstream 耗时、客户端连接上的请求数:

log_format main '$remote_addr|$time_iso8601|$request|$status|$request_time|$upstream_addr|$upstream_status|$upstream_response_time|$connection_requests';
access_log /var/log/nginx/access_real.log main;

镜像默认把 /var/log/nginx/access.log 链到 /dev/stdout,直接 : > access.log 会截断一个管道,后续 grep 会挂住。实验里改成真实文件 access_real.log,这点在复现时很容易踩到。

本次 Docker Desktop 的容器名解析返回了 NXDOMAIN,所以用 --add-host 把 backend 固定到 172.21.0.2。正常环境可以去掉 --ip 与 --add-host,直接用容器名;这不影响 Nginx 侧的连接复用与超时行为。

1. keepalive:写了参数,不等于连接复用

先看四个配置。A 是基线:upstream 里只有 server,location 里只有 proxy_pass。B 只加 keepalive 32。C 再加 proxy_http_version 1.1。D 再清空 Connection。每个配置都打 500 个 /fast 请求,先顺序执行,再用 20 并发执行。

upstream backend {
    server backend:8000;
    keepalive 32;                 # B/C/D
}

location /fast {
    proxy_pass http://backend/fast;
    proxy_http_version 1.1;      # C/D
    proxy_set_header Connection "";  # D
}
配置upstreamlocation后端看到的协议 / Connection500 顺序500 并发 20
A无无HTTP/1.0 / close500500
Bkeepalive 32无HTTP/1.0 / close500500
Ckeepalive 32proxy_http_version 1.1HTTP/1.1 / close500500
Dkeepalive 32proxy_http_version 1.1 + Connection ""HTTP/1.1 / 无119

500 个请求全部返回 200,差别只在后端连接数。后端日志里的四行原始记录:

A REQ port=53578 ver=HTTP/1.0 conn=close path=/fast ms=0 code=200
B REQ port=53582 ver=HTTP/1.0 conn=close path=/fast ms=0 code=200
C REQ port=53592 ver=HTTP/1.1 conn=close path=/fast ms=0 code=200
D REQ port=53602 ver=HTTP/1.1 conn=None  path=/fast ms=0 code=200

B 的 keepalive 32 确实写进了配置,但请求仍以 HTTP/1.0 发出,Nginx 默认还会带上 Connection: close。后端在响应后关闭连接,连接池没有可复用的连接。C 把协议升到 1.1,但 proxy_set_header 的默认值仍是 Connection close,后端照样关闭。只有 D 同时满足三个条件,500 个顺序请求才落到 1 条连接上。

并发 20 时 D 是 19 条,不是 1 条:并发请求会同时占用多条连接,结束后才回到池里。池上限是 32,本实验的并发度只有 20,所以数字由并发度决定,不是 keepalive 32 的上限。官方文档也明确写了,keepalive 参数不限制 worker 能打开的总连接数。

版本要单独确认。抓取文档时,官网的 keepalive 条目已经按 1.29.7 描述:默认开启、默认 32 条、proxy_http_version 默认 1.1。实验镜像是 1.27.5,属于旧行为。把 1.27 的结论直接套到 1.29.7+ 会误判;反过来,在 1.27 上只写 keepalive 32 也确实不生效。先 nginx -v,再对照文档里的版本注记。

还有一个容易被算到 keepalive 头上的现象。D 配置下,后端未设 TCP_NODELAY 时,20 个顺序请求用了 995ms;在 Handler 里加 disable_nagle_algorithm = True 后降到 201ms,连接数仍然是 1。持久连接会把后端的小响应包放大成约 40ms 的等待。看到“开了 keepalive 反而慢”,先查后端有没有关 Nagle,再查连接数。

2. proxy_read_timeout:2s 与 5s 谁先到

同一套 D 配置,加两个 location:/t2/ 的 proxy_read_timeout 是 2s,/t6/ 是 6s;后端用 ?ms= 控制 sleep。每次请求都记录客户端状态码和总耗时。

locationproxy_read_timeout后端 sleep客户端状态实测耗时
/t2/slow?ms=50002s5000ms5042.002–2.003s(3 次)
/t2/slow?ms=10002s1000ms2001.000–1.001s(3 次)
/t6/slow?ms=50006s5000ms2005.000–5.001s(3 次)
/t6/slow?ms=10006s1000ms2001.001s(1 次)

504 的三次都在 2.002s 左右,不是 5s。说明是 Nginx 在第 2 秒切断,不是后端在 5 秒时返回错误。对应的访问日志与 error.log 原文:

172.21.0.4|2026-11-10T05:34:05+00:00|GET /t2/slow?ms=5000 HTTP/1.1|504|2.002|172.21.0.2:8000|504|2.002|1
2026/11/10 05:34:05 [error] 584#584: *17128 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 172.21.0.4, server: _, request: "GET /t2/slow?ms=5000 HTTP/1.1", upstream: "http://172.21.0.2:8000/slow?ms=5000", host: "nginx"

upstream_status 也是 504,但这不是后端发的 504。后端在 sleep,一个字节都没写;Nginx 给这次 upstream 交互记了 504,并把 504 页面发给客户端。

官方文档对 proxy_read_timeout 的定义是“两次成功读操作之间的超时”,不是整个响应的总时长。后端在 5 秒内没有发出任何响应,所以第 2 秒就触发;后端 1 秒内发了响应头,2 秒就够用。把 proxy_read_timeout 调得比后端最慢响应还短,等于把后端的长尾改写成 504。

如果响应头已经发出,超时之后状态码已经定了,Nginx 只能断开连接,这类请求在 access.log 里不一定是 504。本次用“先回头部、再 sleep”的后端复现到 200 加 curl 退出码 18,未纳入上面的计数;它说明 504 只覆盖“响应头之前”的超时。

默认值是 60s。很多后端在 60s 内返回,所以“没配”看起来正常;一旦后端抖动超过 60s,504 才出现。排查时先量后端 p99 与最大值,再决定这个值,不要只改 Nginx。

3. 499 / 502 / 504:三个数字,三个作者

把四类结果放在一起,差别就很清楚:

状态码谁写的触发条件本次复现access.log 的 upstream_statuserror.log
499Nginx,访问日志客户端在响应头前断开curl --max-time 1,后端 sleep 3-[info] epoll_wait 报告客户端提前关闭
502Nginx,网关连不上后端或后端响应无效后端 8001 端口拒绝连接502[error] connect() failed (111: Connection refused)
504Nginx,网关读响应头超时timeout 2s,后端 sleep 5504[error] upstream timed out (110: Connection timed out)
500后端后端自己返回 500后端 ?code=500500无 [error]

499 的原始记录:

172.21.0.4|2026-11-10T05:33:57+00:00|GET /t6/slow?ms=3000 HTTP/1.1|499|1.001|172.21.0.2:8000|-|1.000|1
2026/11/10 05:33:57 [info] 584#584: *17127 epoll_wait() reported that client prematurely closed connection, so upstream connection is closed too while sending request to upstream, client: 172.21.0.4, server: _, request: "GET /t6/slow?ms=3000 HTTP/1.1", upstream: "http://172.21.0.2:8000/slow?ms=3000", host: "nginx"

客户端侧是 http=000、curl 退出码 28,总耗时 1.000s。Nginx 没有给客户端写响应,只在访问日志里记了 499,upstream_status 是 -,因为后端根本没有返回状态。

499 不在 HTTP 标准里。nginx 源码的注释写得很直白:HTTP 没有定义“客户端在处理过程中关闭连接”的状态码,所以 nginx 引入自己的 499,用于在还没发响应头时记录这种关闭。

502 的原始记录:

172.21.0.4|2026-11-10T05:34:05+00:00|GET /dead HTTP/1.1|502|0.000|172.21.0.2:8001|502|0.000|1
2026/11/10 05:34:05 [error] 584#584: *17130 connect() failed (111: Connection refused) while connecting to upstream, client: 172.21.0.4, server: _, request: "GET /dead HTTP/1.1", upstream: "http://172.21.0.2:8001/dead", host: "nginx"

后端 8001 端口没有监听,Nginx 连不上,生成 502。$upstream_status 也记 502,但这是 Nginx 给失败交互补的状态,不是后端返回的。504 同理,后端没有返回 504,是 Nginx 在 2 秒时补的。504 的响应体里带 Server: nginx/1.27.5 和 nginx/1.27.5 页脚;502 的响应体同样是 Server: nginx/1.27.5,页脚也带版本号:

HTTP/1.1 504 Gateway Time-out
Server: nginx/1.27.5

<center><h1>504 Gateway Time-out</h1></center>
<hr><center>nginx/1.27.5</center>

500 的对照更能说明问题:

172.21.0.4|2026-11-10T05:34:06+00:00|GET /t6/err?code=500 HTTP/1.1|500|0.001|172.21.0.2:8000|500|0.001|1

后端自己返回 500,Nginx 原样转发,access.log 的 upstream_status 是 500,error.log 没有 [error]。5xx 不能一律算到 Nginx 头上:先看 upstream_status,再看 error.log 有没有连接失败或超时记录。

4. 排查顺序

  1. 先 nginx -v。1.27 与 1.29.7+ 的 keepalive 默认值不同,版本不对,后面的对照表整张作废。
  2. 查 keepalive 不要只看配置文本。看后端实际收到的协议和 Connection 头,再数不同客户端端口;三个条件缺一个,连接数就不会降。
  3. 量后端 p99 与最大值,再定 proxy_read_timeout。这个值是两次读之间的间隔,不是总时长;proxy_connect_timeout 是另一件事。
  4. 看到 5xx 先分角色。499 看客户端为什么先断;502 看连接与响应有效性;504 看读超时;500 且 upstream_status=500 时先查后端。

小结

keepalive 的生效条件是一个三元组:连接池、HTTP/1.1、清空 Connection。少一个,配置看起来还在,连接数不会变。1.27 上尤其如此;1.29.7 之后默认值变了,先确认版本再套结论。

proxy_read_timeout 和状态码的关系,则是“谁先写响应头”。后端在超时内没给响应头,Nginx 写 504;客户端先断,Nginx 在访问日志里写 499;连不上后端,Nginx 写 502;后端自己写 500,Nginx 只转发。判断 5xx 时,先看 access.log 的 upstream_status 和 error.log 的那一行,再看配置。

数据来源与检索时间

文档抓取时间为 2026-11-10。实验镜像为 nginx 1.27.5;官网文档抓取时已包含 1.29.7 的默认值变更,引用时按条目中的版本注记区分。

编号来源URL用于
[1]nginx 文档《Module ngx_http_upstream_module》https://nginx.org/en/docs/http/ngx_http_upstream_module.htmlkeepalive 的连接池语义、HTTP/1.1 与 Connection 清空要求、1.29.7 默认值变化
[2]nginx 文档《Module ngx_http_proxy_module》https://nginx.org/en/docs/http/ngx_http_proxy_module.htmlproxy_read_timeout 默认 60s 与“两次读之间”的定义、proxy_http_version 默认 1.0、proxy_set_header Connection close 默认值
[3]nginx release-1.27.5 源码 ngx_http_request.hhttps://raw.githubusercontent.com/nginx/nginx/release-1.27.5/src/http/ngx_http_request.h499 的常量定义与源码注释
[4]nginx《CHANGES》https://nginx.org/en/CHANGES1.29.7(2026-03-24)两条变更:upstream keepalive 默认开启;proxy_http_version 默认 1.1 且不再默认发送 Connection 头

核验说明

  • 容器实验在 2026-11-10 13:21–13:35(Asia/Shanghai)完成;容器内时区为 UTC,文中日志时间保持 UTC 原样。
  • 环境为 Windows 11、Docker 29.4.3(WSL2)、nginx/1.27.5、Python 3.12.16、curlimages/curl:8.10.1;三个容器均限制 256MB。
  • 四组 keepalive 配置各跑两轮,每轮顺序 500 请求与并发 20 的 500 请求;连接数由后端日志中的客户端端口去重得到,两轮结果一致。D 的并发 20 单独重复三次,均为 19 条。
  • 500 个请求全部返回 200;连接数差异不是错误率差异。并发 20 的 D 配置为 19 条,与“并发请求先占用多条连接、随后回收复用”的量级一致,未对那一条差额做进一步归因。
  • Nagle 的对照只用于解释延迟:后端未设 TCP_NODELAY 时 20 个顺序请求 995ms,设置后 201ms,连接数均为 1;该数字不进入 keepalive 连接数结论。
  • 未做的事:只用了 worker_processes 1,没有测多 worker 下每个 worker 各自一份 keepalive 池的合并连接数;没有测 TLS;没有用真实应用后端;502 只复现了连接拒绝,没有覆盖后端 reset 与非法响应头;499 只复现了“响应头前断开”一种;504 的“响应头后超时”只做了一次探索,未完整刻画。
  • 本次 Docker Desktop 的容器名 DNS 返回 NXDOMAIN,实验用 --add-host 固定地址;这是环境限制,不改变 Nginx 的连接复用与超时逻辑。
  • 500 的透传只覆盖“后端自己返回 500”这一种;Nginx 自身产生的 500 未在本次复现。