nginx 配置优化笔记:三条"不继承"规则,与 16 个值得逐项检查的点
本文解析nginx配置继承规则与常见陷阱,涵盖反向代理、TLS及缓存配置,助你避免配置失效导致的静默错误。
本文解析nginx配置继承规则与常见陷阱,涵盖反向代理、TLS及缓存配置,助你避免配置失效导致的静默错误。
nginx 的配置继承是逐指令定义的,没有统一规律。 这就是为什么一份"一直能跑"的配置里,往往同时藏着几个已经失效很久、却从不报错的设置。
大多数 nginx 配置问题不会报错。nginx -t 说语法没问题,服务也正常响应,
只是某些配置从来没生效过——或者生效了,但语义是错的。
这篇笔记先讲最容易踩的三条继承规则,再按反向代理、TLS、压缩缓存、基础项逐项过一遍, 最后是验证方法和一份可以直接拿来扫的清单。
nginx 的继承不是"父层设了、子层就有"。每条指令的继承行为都是各自定义的, 有的继承,有的不继承,有的看起来不继承、实际是语义冲突。
最容易踩的三条:
| 指令 | 实际的继承行为 | 踩坑后果 |
|---|---|---|
add_header | 不继承:子层级一旦自己写了,父层级的全部作废 | CORS / 安全头静默丢失 |
proxy_pass | 不继承:只认当前 location | 404,或请求打到错误的后端 |
try_files + alias | 语义冲突 | 静默回落到兜底 location,功能"看着正常"但走了慢路径 |
add_header:子层级一写,父层级全丢location /download/ {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
if ($request_method = OPTIONS) {
add_header Access-Control-Max-Age 1728000; # ← 这一行让上面三个头全部失效
return 204;
}
}
规则是:只要某个层级自己出现了 add_header,从上层继承来的 add_header 就全部作废。
不是合并,是替换。
于是 OPTIONS 预检返回的 204 里只有 Access-Control-Max-Age,
没有 Access-Control-Allow-Origin,浏览器直接判定 CORS 失败——
而且失败得毫无线索,因为响应状态码是正常的 204。
修复就是老老实实重复一遍:
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Max-Age 1728000 always;
return 204;
}
这条规则有个常见变体:把安全头(HSTS、X-Frame-Options)写在 http{} 或 server{} 层,
以为全局生效,结果任何一个写了 add_header 的 location 都会把它们丢掉。
安全头必须跟着每个含 add_header 的 location 走。
proxy_pass:只认当前 locationlocation /api/websocket/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 没有 proxy_pass
}
这是很好识别、但很容易漏掉的一类错误。缺了 proxy_pass,nginx 不会往上转发,
而是去 root 目录里找一个叫 /api/websocket/ 的静态文件——必然 404。
location /api/websocket/ {
proxy_pass http://127.0.0.1:8080/api/websocket/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
这类问题最麻烦的地方在于它的症状是"某个功能坏了",而不是"服务挂了"。 会话终端、实时日志、推送面板这类功能不是每次打开网站都会用到, 所以可以坏上几个月没人察觉。
try_files + alias:静默回落到兜底 locationlocation ~ ^/static/(.*\.(css|js|png|svg))$ {
alias /var/www/app/build/static/$1;
try_files $uri @app;
}
alias 指向的是一个文件,而 try_files $uri 会按 alias 的规则重新拼一次路径,
结果几乎必然拼错、找不到文件,然后静默回落到 @app。
也就是说:所有静态资源实际上一直是应用进程在代劳,只是"能用",所以没人发现。
更隐蔽的是,这时候 expires、add_header 这些配置根本没机会生效——
因为请求压根没命中这个 location。
怎么判断它是不是一直在回落? 用"存在的文件 vs 不存在的文件"对照:
# 一个真实存在的文件
curl -sI https://example.com/static/app.abc123.css
# 200,响应里没有应用层的特征头 → 说明是 nginx 从磁盘发的
# 一个故意不存在的文件
curl -sI https://example.com/static/nope-12345.css
# 404 + Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
# ↑ 这是 Next.js 的 404 特征头
两条一对比就清楚了:404 那次是应用回的。如果"存在的文件"那次也带着同样的应用层头, 说明它同样被打到了应用,静态文件根本没走 nginx。
proxy_http_version 1.1 不能省nginx 反代时默认用 HTTP/1.0 与上游通信。这意味着没有 keepalive、不支持 chunked 传输, 每个请求都要新建一条到上游的 TCP 连接。
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; # 补上它
}
这一条还有个间接影响:HTTP/1.0 没有 Upgrade 机制,
所以如果你的反代目标包含 WebSocket,不写这句的话,即使头部转发全对,升级也不可能成功。
WebSocket 的握手,本质是在同一条 TCP 连接上换协议:
GET /api/websocket HTTP/1.1
Upgrade: websocket # 想换成什么协议
Connection: Upgrade # 这条连接要改变用途(注意这里是关键字,不是协议名)
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
服务器同意就回 101 Switching Protocols;不同意就返回一个普通的 HTTP 响应(200/404),
连接继续以 HTTP 存在。
两个头容易混:
| 头 | 值 | 回答的问题 |
|---|---|---|
Upgrade | websocket | 换成什么协议 |
Connection | Upgrade | 这条连接要不要改变用途 |
Connection: Upgrade 里的 Upgrade 是一个连接指令,和 keep-alive、close 是一类东西,
不是协议名。
关键是:Connection 是逐跳头(hop-by-hop)。 它的语义是给"直接对端"的指令,
按 HTTP 规范,代理必须把它消费掉,不得原样转发。
这就解释了一个常见困惑——为什么 nginx 反代 WebSocket 时非得手写这两行?
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
不是 nginx 忘了传,而是规范上不允许它自动传。理解这一点, 就不会再把它当成"魔法配置"照抄了。
Connection 用 map,不要硬编码# 不推荐
proxy_set_header Connection "upgrade";
这行是从各种 WebSocket 教程里抄来的。问题在于它对所有请求都生效,
包括那些根本不带 Upgrade 头的普通请求——等于告诉上游"这条连接要改作他用",
语义上是矛盾的。
多数后端会忽略这个矛盾的头,所以它通常"看起来没事",但语义确实是错的:
一旦上层再叠一层代理、或后端自己判断了 Connection,连接行为就会变得不可预期。
正确做法是让这个头只在需要升级时出现:
map $http_upgrade $connection_upgrade {
default upgrade; # 带 Upgrade 头 → 发 upgrade
'' close; # 不带 → 明确不用这条连接做升级
}
# 然后所有 location 统一写这两行
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
顺带一个排查技巧:定义了 map 却从没引用它,是典型的"写了但没用"。
直接 grep 一下 $connection_upgrade 在文件里出现了几次,就能确认。
proxy_read_timeout 管一旦 101 发出,nginx 就把这条连接降级成一条透传的字节管道,不再解析 HTTP。
由此推出几个后果:
add_header、缓存头、error_page
全部失效——它们只作用于 HTTP 响应,而这条连接已经不说 HTTP 了。
这就是 WebSocket 的 location 总要单独写、单独配的原因。proxy_read_timeout 控制。 普通请求几十毫秒就结束了,
而 WebSocket 可能几分钟没有数据流过。读超时一到,nginx 就当成"上游卡死"把连接掐掉,
前端只能反复重连——症状是"界面偶尔转圈、状态延迟刷新",很难联想到超时设置。location /api/websocket {
proxy_pass http://127.0.0.1:8080/api/websocket;
proxy_http_version 1.1;
proxy_read_timeout 900s; # 实时推送类服务,别用默认的 60s
proxy_send_timeout 900s;
proxy_set_header Upgrade $http_upgrade;
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 $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_set_header X-Real_IP $remote_addr; # 错
proxy_set_header X-Real-IP $remote_addr; # 对
nginx 不会校验 proxy_set_header 的头部名。它会老老实实发一个叫 X-Real_IP 的头,
后端所有依赖 X-Real-IP 取真实 IP 的逻辑就全部拿不到值——又一个静默失败。
同类问题还有 X-Forwarded-for(大小写不规范,HTTP 头名本身大小写不敏感,
但会让日志分析和 WAF 规则失配)。
http2 on;# 旧写法(nginx 1.25.1 起废弃,会打警告)
listen 443 ssl http2;
# 新写法
listen 443 ssl;
http2 on; # 写在 http{} 里,对所有 server 生效
值得单独确认一下你有没有真的开出 HTTP/2:很多配置写着 listen 443 ssl; 就完事了,
HTTP/2 从来没生效过,但因为浏览器会安静地退回 HTTP/1.1,所以毫无感知。
openssl s_client -connect example.com:443 -servername example.com \
-alpn "h2,http/1.1" </dev/null 2>&1 | grep -i "ALPN protocol"
# 期望:ALPN protocol: h2
ssl_protocols 别把 TLS 1.3 漏了ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
几个要点:
ssl_ciphers 控制,那条指令只管 TLS 1.2 及以下。
要自定义得用 ssl_conf_command Ciphersuites ...,默认的三个已经够用,一般不用动。nginx -V 2>&1 | tr ' ' '\n' | grep -i openssl。
显示 1.0.2 的(比如某些老发行版自带包)根本不具备 TLS 1.3 能力。ssl_reject_handshake 替代 return 444兜底 server 的常见写法是 return 444;,但它是在 TLS 握手完成之后才拒绝的——
陌生 SNI 依然会拿到你的证书。
server {
listen 443 ssl default_server;
ssl_reject_handshake on; # 握手阶段就拒绝,证书都不给
return 444;
}
ssl_reject_handshake 需要 nginx 1.19.4+,现在几乎所有在用的版本都满足。
ssl_certificate 提到 http{}http {
ssl_certificate /etc/nginx/certs/example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem;
}
nginx 1.15.9 起,这两条指令可以在 http{} 上下文里写。多子域配置里很值得这么做——
否则每个 server 块都要重复两行,加新子域时漏改一个,就会出现"某个域名证书不对"的问题。
验证方式是抽查几个 vhost 实际下发的证书指纹是否一致:
for h in a.example.com b.example.com c.example.com; do
echo -n "$h : "
echo | openssl s_client -connect $h:443 -servername $h 2>/dev/null \
| openssl x509 -noout -fingerprint -sha256
done
Debian/Ubuntu 的默认 nginx.conf 里,gzip 那一段常年是注释状态:
# gzip on;
后果是只压了 text/html(nginx 的默认 gzip_types),
而 CSS、JS、JSON 全部明文传输。
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5; # 常用折中值,6 以上收益很小、CPU 明显上升
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/xml+rss text/javascript image/svg+xml application/wasm;
两个细节:gzip_types 里不需要写 text/html(nginx 默认就压它);
gzip_vary on 会加上 Vary: Accept-Encoding,对 CDN 和中间缓存很重要。
Cache-Control 就够# 不推荐:会发出两个 Cache-Control
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
expires 自己就会生成 Cache-Control: max-age=...,add_header 再加一条,
响应里就有两个。浏览器会合并处理,但属于无意义的混乱,留一条即可。
immutable 的适用边界add_header Cache-Control "public, max-age=31536000, immutable";
immutable 的含义是"这个 URL 的内容永远不会变",浏览器连条件请求都不会发。
app.abc123.css)完全正确。# 上传目录用这个
add_header Cache-Control "public, max-age=604800";
server_tokens off;默认(或注释掉)时,nginx 会在响应里带上 Server: nginx/1.31.5。
这不是"靠隐藏求安全",而是没必要免费告诉扫描器你的精确版本。
client_max_body_size 提到 http{}http {
client_max_body_size 25M;
}
这个指令很容易只在第一个 server 里写,导致"主站上传大文件正常、其他站报 413"。
放在 http{} 层一次解决。
events {
worker_connections 7680;
multi_accept on; # 一次 accept 完积压连接,少一轮轮询
}
http {
sendfile on;
sendfile_max_chunk 1m; # 避免单个大文件长时间独占 worker
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000; # 单连接最多处理多少请求
}
proxy_pass http://127.0.0.1:28052; # http:// 上游
proxy_ssl_name $host; # ← 只对 https:// 上游生效,无效
proxy_ssl_server_name on; # ← 同上
proxy_set_header X-NginX-Proxy true; # ← 老教程遗留,没人读这个头
这几条大概率是从某个 HTTPS 反代示例里抄来的。它们不会报错、也不会生效, 只会让后来读配置的人多花时间去确认"这个到底起不起作用"。删掉。
nginx -t 只能证明语法没错,证明不了逻辑生效。
真正有效的验证是从外部走一遍真实的 TLS 握手和 HTTP 请求。这里有几个客户端的坑。
openssl s_client -brief 不打印 ALPN。
openssl s_client -connect host:443 -servername host -brief # 看不到 ALPN
openssl s_client -connect host:443 -servername host -alpn "h2,http/1.1" </dev/null 2>&1 | grep -i alpn
用 -brief 时它会省略 ALPN 行,很容易据此误判成"HTTP/2 没生效"。
Windows 自带的 curl 是 Schannel 后端。
C:\Windows\System32\curl.exe 用的是 Schannel,不打印 SSL connection using TLSv1.3
那一行(那是 OpenSSL 后端的专属输出)。输出里没有版本信息 ≠ 没协商 TLS 1.3。
而且它的 Features 里没有 HTTP2,curl --http2 会直接失败且不输出错误。
判断协议版本用 openssl 或浏览器 F12 的 Security 面板更可靠。
代理环境变量会污染测试结果。
如果环境里设了 HTTP_PROXY / HTTPS_PROXY,curl 会老老实实走代理,
你测的其实是代理而不是目标服务器(症状是输出里出现 CONNECT: no ALPN negotiated)。
两个办法:
curl --noproxy "*" ... # 显式绕过
openssl s_client ... # openssl 不读这些环境变量
不要只看状态码——nginx 和应用都会返回 200。要看响应头里的特征:
# 对比一个存在的文件和一个故意不存在的文件
curl -sI https://example.com/static/app.abc123.css
curl -sI https://example.com/static/nope-12345.css
如果"不存在的文件"返回的 404 带着应用框架自己的头(比如 Next.js 的
Cache-Control: private, no-cache, no-store, ...),而"存在的文件"没有,
说明静态资源确实由 nginx 直接从磁盘发出。
如果两者带着同一套应用层头,那静态资源还在走应用,配置没命中。
| 检查项 | 命令 | 期望结果 |
|---|---|---|
| HTTP/2 | openssl s_client -alpn "h2,http/1.1" ... | ALPN protocol: h2 |
| TLS 1.3 | openssl s_client -tls1_3 ... | 握手成功 |
| gzip | curl -sI -H "Accept-Encoding: gzip" https://h/x.css | Content-Encoding: gzip |
| 缓存头没重复 | 数 Cache-Control 出现次数 | 只出现 1 次 |
| CORS 预检 | curl -sI -X OPTIONS https://h/api/download/ | 204 且带 Access-Control-Allow-Origin |
| WebSocket | curl -sI -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://h/ws | 101 Switching Protocols |
| 未知 SNI | openssl s_client -servername no-such.invalid ... | unrecognized name,无证书下发 |
| 请求体上限 | 对一个超过默认值的文件 POST | 不出现 413 |
nginx -t && nginx -s reload
# 容器里:docker exec <容器名> nginx -s reload
改动前后按这份清单扫一遍:
proxy_pass 的 location 都有 proxy_http_version 1.1proxy_set_header、没有 proxy_pass"的裸 locationConnection 走 map,不硬编码 "upgrade";且 map 确实被引用了proxy_read_timeout 足够长(实时推送类服务建议 900s)add_header 都检查过是否吃掉了父层的头(尤其安全头)try_files 没有和"指向文件"的 alias 组合http2 on;(不是 listen ... http2)ssl_protocols 同时含 TLSv1.2 和 TLSv1.3ssl_certificate 提到了 http{},只写一处server_tokens off;gzip_types 含 CSS / JS / JSONCache-Control 只有一个来源;immutable 只用于带哈希的文件名client_max_body_size 在 http{} 层ssl_reject_handshake on; 替代 return 444proxy_ssl_* 挂在 http:// 上游上这一类问题有个共同特征:它们不会 500,不会告警,只会让你多花一点 CPU、 慢个几十毫秒,或者让某个不常用的功能悄悄失效。
静态资源一直由应用进程代劳、CORS 预检一直失败、HTTP/2 一直没开—— 这三件事可以同时存在于一个"运行良好"的站点上,持续几个月甚至几年。
所以配置优化的重点其实不是"学会更多指令",而是养成验证的习惯: 改完之后,从外部实际请求一次,看服务端到底返回了什么。 看配置文件本身是看不出问题的——你看到的只是你以为会生效的东西。