Nginx 完全教程
从一节指令讲到生产架构:配置语法、location 匹配、反向代理、负载均衡、HTTPS、缓存限流、性能调优,每个知识点都配可运行配置、验证命令与输出结果。
第一部分 · 入门
1Nginx 是什么,为什么用它
Nginx(读作 "engine-x")是一个高性能的 HTTP 服务器和反向代理服务器。它最出名的两个特点:事件驱动的异步架构,以及极低的内存占用。
1.1 它到底解决什么问题
一个典型的现代 Web 系统里,Nginx 通常站在最前面,扮演"门卫 + 调度员"的角色:
1.2 与 Apache 的核心区别
| 维度 | Nginx | Apache(prefork 模式) |
|---|---|---|
| 并发模型 | 事件驱动,单 worker 处理数千连接 | 每连接一个进程/线程 |
| 内存占用 | 低,1 万并发约几十 MB | 高,随并发线性增长 |
| .htaccess | 不支持(这是优点:不用每次请求读磁盘) | 支持,方便但慢 |
| 配置重载 | reload 即生效,不断连接 | 通常需重启 |
| 动态模块 | 需编译时指定或动态加载 | 运行时加载方便 |
| 静态文件 | 极快(sendfile + 零拷贝) | 较慢 |
| 配置风格 | 整体式,一次性写好 | 可分散在目录级 |
.htaccess 的共享主机或大量 PHP 老项目。
1.3 三大核心用途
① 静态 Web 服务器
直接吐 HTML/CSS/JS/图片,性能是所有方案里最好的之一。
② 反向代理(Reverse Proxy)
客户端请求 Nginx,Nginx 转发给后端应用(Node / .NET / Python),再把结果返回。好处:
- 隐藏后端:客户端不知道后端有几个、跑在哪、什么语言
- 统一入口:HTTPS 只在 Nginx 上配一次,后端裸跑 HTTP
- 多服务共存:一台机器一个 443 端口服务多个应用
- 可加能力:缓存、压缩、限流、鉴权都在这一层加,后端代码不用改
③ 负载均衡
后端有多台时,Nginx 按策略分发请求,还能自动剔除挂掉的节点。
1.4 什么时候不该用 Nginx
- 需要处理业务逻辑 —— 那是后端框架的事,Nginx 只做它擅长的:转发、静态、策略
- 极简的内部小服务 —— 一个 Node 服务本身能扛几百并发,前面硬套一层反而多一跳
- HTTP/3 重度需求 —— Nginx 支持但要额外编译,Caddy 开箱更省事
if 是"伪指令",官方文档明确写着 "if is evil"。很多逻辑应该用 map 或 try_files 实现。第 33 章会详细讲这个坑。
2安装与目录结构
2.1 各平台安装
# 更新索引并安装
sudo apt update
sudo apt install -y nginx
# 查看版本与编译参数
nginx -v
nginx -V # 大写 V 会输出全部编译选项,排查模块有无就看这个
# 启动并设为开机自启
sudo systemctl enable --now nginx
systemctl status nginx
# 官方源方式(比 EPEL 版本新)
sudo tee /etc/yum.repos.d/nginx.repo >/dev/null <<'EOF'
[nginx-stable]
name=nginx stable repo
baseurl=http://nginx.org/packages/centos/$releasever/$basearch/
gpgcheck=1
enabled=1
gpgkey=https://nginx.org/keys/nginx_signing.key
module_hotfixes=true
EOF
sudo yum install -y nginx
sudo systemctl enable --now nginx
brew install nginx
brew services start nginx
# 配置文件位置与 Linux 完全不同:
# /opt/homebrew/etc/nginx/nginx.conf (Apple Silicon)
# /usr/local/etc/nginx/nginx.conf (Intel)
docker run -d --name web \
-p 8080:80 \
-v $(pwd)/site:/usr/share/nginx/html:ro \
nginx:1.26-alpine
curl -I http://localhost:8080/
2.2 目录结构(Ubuntu 官方包)
| 路径 | 作用 | 要不要动 |
|---|---|---|
/etc/nginx/nginx.conf | 主配置,全局参数与 http 块 | 少量改(worker、日志格式) |
/etc/nginx/conf.d/*.conf | 被主配置 include,放站点/通用配置 | 常改 ★ |
/etc/nginx/sites-available/ | 站点配置"仓库"(Debian 系特有) | 常改 ★ |
/etc/nginx/sites-enabled/ | 软链到 available,只有这里才生效 | 常改 ★ |
/etc/nginx/mime.types | 扩展名 → Content-Type 映射 | 基本不动 |
/etc/nginx/snippets/ | 可复用片段(如 ssl 参数) | 按需 |
/var/www/html/ | 默认站点根目录 | 常改 ★ |
/var/log/nginx/ | access.log 与 error.log | 排查看 ★ |
/usr/share/nginx/html | 官方仓库时的默认站点根 | 视安装方式 |
nginx.conf 里同时有:include /etc/nginx/conf.d/*.conf;include /etc/nginx/sites-enabled/*;两者都在
http { } 块内部,所以放在这两个目录的文件不需要再写 http 块,直接写 server { } 即可。conf.d 是 Nginx 官方惯例,sites-* 是 Debian 特有惯例,两套混用在 Ubuntu 上完全合法。
2.3 默认站点是怎么工作的
# 看默认站点配置
cat /etc/nginx/sites-enabled/default
# 关键几行:
# listen 80 default_server;
# root /var/www/html;
# index index.html index.htm;
Host 头没有匹配到任何 server_name 时,交给标了 default_server 的那个 server 块处理。同一端口只允许一个 default_server,否则启动报错 duplicate default server。这就是"直接用 IP 访问"时看到的那个站点。
2.4 第一个自己的站点
- 建站点目录并放一个页面
sudo mkdir -p /var/www/mysite echo '<h1>Hello Nginx</h1>' | sudo tee /var/www/mysite/index.html - 写站点配置
/etc/nginx/conf.d/mysite.confserver { listen 8080; server_name _; root /var/www/mysite; index index.html; location / { try_files $uri $uri/ =404; } } - 检查语法并重载
sudo nginx -t sudo systemctl reload nginxnginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful - 验证
curl -i http://127.0.0.1:8080/HTTP/1.1 200 OK Server: nginx/1.24.0 Content-Type: text/html Content-Length: 21 <h1>Hello Nginx</h1>
nginx -t → reload。跳过 -t 直接 reload,配置有错会导致新配置不生效但服务仍在跑旧配置,很容易误以为改动没生效。
3配置文件全貌与加载顺序
3.1 一张图看懂配置层级
3.2 一个完整的主配置
这是 Ubuntu 的 /etc/nginx/nginx.conf,带逐行注释:
# ── main 上下文:进程级设置 ──
user www-data; # worker 进程以哪个用户运行
worker_processes auto; # 工作进程数,auto = CPU 核数
pid /run/nginx.pid; # PID 文件位置
# 把工作进程绑到不同 CPU 核心,减少上下文切换
# worker_cpu_affinity 0101 1010; # 4 核示例
error_log /var/log/nginx/error.log warn; # 全局错误日志与级别
include /etc/nginx/modules-enabled/*.conf; # 动态模块
events {
worker_connections 768; # 每个 worker 最大连接数
multi_accept on; # 一次接受所有新连接
use epoll; # Linux 下的事件模型(默认已是最优)
}
http {
# ── 基础 ──
sendfile on; # 零拷贝发送文件,静态服务必开
tcp_nopush on; # sendfile 时攒满包再发,提升吞吐
tcp_nodelay on; # 长连接上立即发送,降低延迟
types_hash_max_size 2048;
server_tokens off; # 隐藏版本号(安全)
include /etc/nginx/mime.types; # 必须:否则所有文件都是 octet-stream
default_type application/octet-stream;
# ── 超时 ──
keepalive_timeout 65; # 客户端长连接保持时间
keepalive_requests 1000; # 单连接最多处理多少请求
client_header_timeout 30s; # 读请求头超时(防慢速攻击)
client_body_timeout 30s;
send_timeout 30s;
# ── 请求体限制 ──
client_max_body_size 10m; # 上传大小上限,超了返回 413
client_body_buffer_size 128k;
# ── 压缩 ──
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json
application/javascript application/xml+rss
image/svg+xml;
# ── 日志 ──
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# ── 引入站点 ──
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
http { } 内部,所以被引入的文件里写的 server { } 天然位于 http 上下文中。很多人把 conf.d/xxx.conf 里也包一层 http { },结果报错 "http" directive is not allowed here —— 就是没搞清这个。
3.3 配置加载顺序(排查问题的关键)
Nginx 启动时按以下顺序处理:
- 读主配置
nginx.conf,遇到include就原地展开(就像 C 语言的#include) - 按
include出现的顺序依次加载各文件。用 glob 的(如*.conf)按文件名字母序排列 - 构建 server 列表,同一监听端口内按
server_name建立匹配表 - 请求进来 → 按端口选 server 组 → 按 Host 选 server → 按 URI 选 location
server 都监听 80 且 server_name 相同),后加载的生效,先加载的静默失效——不报错。排查这类问题的杀手锏是:
nginx -T # 大写 T:输出合并展开后的完整配置
它会把你所有 include 的文件拼成一份,还标注每个片段的来源文件。怀疑"配置没生效"时,先跑这个看实际生效的是哪一段。
3.4 用 -T 定位配置来源
# 看完整生效配置
sudo nginx -T
# 只看 server_name 相关(配合 grep 极高效)
sudo nginx -T 2>/dev/null | grep -n "server_name"
# 看某个 server 块的 root 到底是谁
sudo nginx -T 2>/dev/null | grep -E "server_name|root "
4常用命令与热重载
4.1 命令速查
| 命令 | 作用 | 使用频率 |
|---|---|---|
nginx -t | 校验配置语法 | ★★★★★ 每次改完必跑 |
nginx -T | 输出合并后的完整配置 | ★★★★☆ 排查"没生效" |
nginx -s reload | 热重载(等价 systemctl reload) | ★★★★★ |
nginx -s stop | 立即停止(丢弃连接) | ★ |
nginx -s quit | 优雅停止(处理完现有请求) | ★★ |
nginx -s reopen | 重新打开日志文件(配合切割) | ★★★ |
nginx -v | 显示版本 | ★★ |
nginx -V | 版本 + 全部编译参数 | ★★★ 查模块有无 |
nginx -c /path/x.conf | 指定主配置文件 | ★ |
nginx -g "daemon off;" | 前台运行(Docker 里常用) | ★★★ |
4.2 reload 到底做了什么
具体流程:
- 向 master 进程发
SIGHUP - master 校验新配置 —— 有错就拒绝加载,继续用旧配置(所以 reload 不会把服务搞挂)
- 用新配置启动新 worker
- 向老 worker 发信号,让它们处理完当前请求后退出
- 整个过程连接不断、请求不丢
restart 会先停全部进程再启动,中间有几百毫秒的空窗期,期间新请求被拒绝。生产环境改配置一律用 reload。但注意:改
listen 端口、换 SSL 证书文件、改 user 指令这几类改动,reload 可能不生效或不够——需要 systemctl restart nginx。
4.3 进程模型
ps -ef | grep nginx
| 进程 | 运行用户 | 职责 |
|---|---|---|
| master | root | 读配置、管理 worker、绑定 80/443 等特权端口、处理信号 |
| worker | user 指令指定 | 实际处理请求。数量由 worker_processes 决定 |
| cache manager | — | 启用了 proxy_cache 才出现,负责淘汰过期缓存 |
| cache loader | — | 启动时载入磁盘缓存索引,只跑一次 |
www-data(CentOS 是 nginx)身份跑,这是安全设计。副作用是:网站目录和文件的权限必须让 www-data 能读。遇到 403 Forbidden 时,第一个要查的就是权限。
# 目录 755,文件 644,属主可设为当前用户
sudo chown -R $USER:www-data /var/www/mysite
sudo find /var/www/mysite -type d -exec chmod 755 {} \;
sudo find /var/www/mysite -type f -exec chmod 644 {} \;
4.4 日志实时观察
# 实时跟踪访问日志
sudo tail -f /var/log/nginx/access.log
# 只看错误
sudo tail -f /var/log/nginx/error.log
# 看最近 50 条非 200 的请求(排查接口异常)
sudo tail -200 /var/log/nginx/access.log | awk '$9 != 200'
# 统计状态码分布
sudo awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
499 不是 HTTP 标准码,是 Nginx 自己发明的:表示"客户端在服务端返回响应前就断开了连接"。常见于用户刷新页面、或后端太慢用户等不及关掉。如果 499 很多,说明后端响应慢,要去查后端而不是 Nginx。
第二部分 · 配置语法
5指令、块与作用域
5.1 三种语法元素
Nginx 配置只有三种元素,别无其他:
| 元素 | 写法 | 例子 |
|---|---|---|
| 简单指令 | 名字 参数... ; | worker_processes auto; |
| 块指令 | 名字 { ... } | server { ... } |
| 注释 | # 到行尾 | # 这是注释 |
server {
listen 80
server_name example.com;
}
报错是 invalid number of arguments in "listen" directive —— 它把 server_name 和 example.com; 都当成了 listen 的参数。看到"参数个数不对",第一反应先查上一行有没有漏分号。
5.2 作用域与继承
配置是树形嵌套的,指令只在其允许的上下文中出现。指令的作用范围就是它所在的块及其子块。
http {
# 这里设的,所有 server 都能用(继承)
client_max_body_size 10m;
gzip on;
server {
listen 80;
server_name a.com;
# 这里设的,只对本 server 的 location 生效
client_max_body_size 50m; # 覆盖上面的 10m
location /upload/ {
# 这里设的,只对本 location 生效(优先于 server 和 http)
client_max_body_size 500m;
proxy_pass http://127.0.0.1:9000;
}
location /api/ {
# 这个 location 没设,继承 server 的 50m
proxy_pass http://127.0.0.1:5000;
}
}
}
继承规则分两类:
- 普通指令:就近覆盖,内层设了就完全用内层的值
- 数组类指令(如
add_header、proxy_set_header):内层只要设了任意一条,外层的全部失效。这是个很隐蔽的坑。
http {
add_header X-Frame-Options "SAMEORIGIN"; # 全局安全头
add_header X-Content-Type-Options "nosniff";
server {
location /static/ {
add_header Cache-Control "max-age=31536000";
# ❌ 上面两个安全头在这里全部消失了!
# 因为本层出现了 add_header,外层被整体屏蔽
}
}
}
正确做法:要么在本层把需要的头全部重写,要么用 include 把公共头抽成片段,每层都 include 一次。这类问题"看起来没报错但功能失效",最容易被忽略。
5.3 各指令的合法上下文
不确定某条指令能写在哪,查官方文档最准。几个高频的归属关系:
| 指令 | 可用上下文 | 典型位置 |
|---|---|---|
worker_processes | main | nginx.conf 顶部 |
worker_connections | events | events 块内 |
include | 任意 | 常用于 http / server |
server | http | 站点定义 |
location | server, location | 路由(可嵌套) |
upstream | http | http 块内,不能放 server 里 |
limit_req_zone | http | http 块内(注意!) |
proxy_cache_path | http | http 块内 |
proxy_pass | location, if, limit_except | location 内 |
root / alias | http, server, location, if | 各级都行 |
map | http | http 块内,必须 |
set | server, location, if | 不能放 http 块 |
add_header | http, server, location, if | 各级都行(注意覆盖规则) |
server {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; # ❌
# 报错:"limit_req_zone" directive is not allowed here
}
*_zone 系列指令(limit_req_zone、limit_conn_zone、proxy_cache_path、map)都是在 http 上下文声明共享内存区、在 server/location 里引用的设计。声明与使用必须分开。
5.4 变量与引用的写法
# 指令参数里引用变量,直接用 $ 开头
add_header X-Backend $upstream_addr;
# 变量后面紧跟别的字符时,用花括号界定边界
proxy_pass http://127.0.0.1:5000/api/${version}/data;
# 字符串拼接不需要 &
set $full "$scheme://$host$request_uri";
6变量系统全解
Nginx 的变量分两类:内置变量(Nginx 自己维护,只读)和自定义变量(用 set 或 map 定义)。变量是写高级配置的基础。
6.1 内置变量速查(按用途分组)
请求信息
| 变量 | 含义 | 示例值 |
|---|---|---|
$request | 完整请求行 | GET /api/user?id=1 HTTP/1.1 |
$request_method | 请求方法 | GET / POST |
$request_uri | 原始 URI(含查询串) | /api/user?id=1 |
$uri | 当前 URI(不含查询串,且经 rewrite 后可能变化) | /api/user |
$args | 查询字符串 | id=1 |
$arg_名称 | 某个具体查询参数 | $arg_id → 1 |
$query_string | 同 $args | id=1 |
$is_args | 有查询串时为 ?,否则空 | ? |
$request_body | 请求体(需 proxy_pass_request_body on 语境) | — |
$request_length | 请求总字节数(含头体) | 512 |
$content_type | 请求的 Content-Type | application/json |
$content_length | 请求的 Content-Length | 128 |
$request_uri= 客户端原始请求,永远不变,含?和参数$uri= 当前正在处理的路径,经过 rewrite 后会变,不含参数
$request_uri;想基于当前重写后的路径判断,用 $uri。两者用混了会出现"跳转后参数丢了"或"跳转死循环"。
服务端与连接
| 变量 | 含义 | 示例值 |
|---|---|---|
$host | 请求的 Host(优先取 Host 头,无则用 server_name) | superyg.cloud |
$http_host | 原始 Host 头(可能为空或可被伪造) | superyg.cloud |
$server_name | 当前匹配到的 server_name | superyg.cloud |
$server_addr | 服务器 IP | 10.0.0.5 |
$server_port | 服务器端口 | 443 |
$scheme | 协议 | http / https |
$https | 是 HTTPS 时为 on,否则空 | on |
$remote_addr | 客户端 IP(直连的,过 CDN 后是 CDN 的 IP) | 1.2.3.4 |
$remote_port | 客户端端口 | 54321 |
$remote_user | Basic Auth 认证后的用户名 | admin |
$binary_remote_addr | 客户端 IP 的二进制形式(限流键专用,省内存) | — |
$request_time | 从收到首字节到发送完响应的总耗时(秒,毫秒精度) | 0.235 |
$upstream_response_time | 后端响应耗时(秒) | 0.180 |
$upstream_addr | 实际处理请求的后端地址 | 127.0.0.1:5000 |
$upstream_status | 后端返回的状态码 | 200 |
$upstream_cache_status | 缓存命中情况 | HIT / MISS / BYPASS |
$connection | 连接序号 | 1024 |
$connection_requests | 当前连接上已处理的请求数 | 7 |
$pipe | 是否使用 pipeline | p / . |
响应与时间
| 变量 | 含义 | 示例值 |
|---|---|---|
$status | 响应状态码 | 200 |
$body_bytes_sent | 响应体字节数(不含头) | 15320 |
$bytes_sent | 发送总字节数(含头) | 15600 |
$time_local | 本地时间 | 15/Sep/2026:17:30:00 +0800 |
$time_iso8601 | ISO 格式时间 | 2026-09-15T17:30:00+08:00 |
$msec | 当前时间(毫秒精度) | 1726392600.123 |
$http_名称 | 任意请求头(- 变 _,小写) | $http_user_agent |
$sent_http_名称 | 任意响应头 | $sent_http_content_type |
$cookie_名称 | 某个 Cookie 的值 | $cookie_token |
X-Forwarded-For → $http_x_forwarded_for;User-Agent → $http_user_agent。规则是:全小写 + 连字符换成下划线。反过来,$sent_http_ 用于读响应头,规则相同。
6.2 自定义变量:set
server {
listen 80;
server_name example.com;
# 在 server 上下文定义(注意:set 不能写在 http 块)
set $env "production";
location /api/ {
# 也可以在这里覆盖
set $backend "api.internal";
add_header X-Env $env;
proxy_set_header X-Backend $backend;
proxy_pass http://127.0.0.1:5000;
}
}
set 属于 rewrite 阶段的指令,写在哪一层就在该层处理时执行。location 里的 set 只在该 location 被选中后执行。这意味着它不能用于影响 location 的选择——那是 map 和 if 的活。
6.3 更强大的 map
map 在 http 块定义,可以把一个变量的值映射成另一个值,比 if 更优雅、性能也更好。
http {
map $host $backend_upstream {
default "http://127.0.0.1:5000";
api.example.com "http://127.0.0.1:5001";
admin.example.com "http://127.0.0.1:5002";
}
server {
listen 80;
server_name _;
location / {
proxy_pass $backend_upstream;
proxy_set_header Host $host;
}
}
}
http {
map $http_user_agent $is_mobile {
default 0;
"~*Mobile" 1; # ~* 表示不区分大小写正则
"~*Android" 1;
"~*iPhone" 1;
}
map $request_uri $is_api {
default 0;
"~^/api/" 1;
"~^/graphql" 1;
}
}
http {
# 白名单:不在列表里的都返回 1
map $remote_addr $is_blocked {
default 1;
127.0.0.1 0;
10.0.0.0/8 0;
"~^192\.168\." 0;
}
server {
if ($is_blocked) {
return 403;
}
}
}
map 在配置加载时就建立好映射表(hash 或正则表),运行时是一次查表,极快。if 在 rewrite 阶段求值,且行为有很多反直觉之处(官方称"if is evil")。能用 map 就别用 if。
6.4 map 的特殊用法
map "$host$request_uri" $cache_key_extra {
default "";
"~^example\.com/api/" "nocache";
}
map $http_x_forwarded_proto $real_scheme {
default $scheme; # 没有这个头就用实际协议
https https;
http http;
}
default:兜底,不匹配时用- 普通字符串:精确匹配,不区分大小写
~前缀:区分大小写正则~*前缀:不区分大小写正则hostnames参数:允许用*.example.com这样的通配
7语法陷阱与调试方法
这一章专门收集"看起来对、跑起来不对"的情况。Nginx 不报错的错误最耗时间。
7.1 陷阱清单
| 现象 | 原因 | 解决 |
|---|---|---|
| 改配置后"没生效" | 忘了 reload,或改的文件没被 include | nginx -T 确认文件在里面 |
duplicate default server | 两个 server 都标了 default_server | 只留一个 |
directive is not allowed here | 指令放错上下文(如 map 放进 server) | 查官方文档的 Context 段 |
invalid number of arguments | 上一行漏了分号 | 检查上一行结尾 |
unknown directive | 模块没编译进去 | nginx -V 查编译参数 |
加 add_header 后其他头没了 | 数组类指令的"全有或全无"覆盖 | 本层重写全部头,或用 include |
| 403 但文件存在 | 权限 / SELinux / 路径不存在 | 查 error.log 的 Permission denied |
| 404 但文件确实在 | root 与 alias 用混,或路径重复拼接 | 看第 9 章对照表 |
| 跳转死循环 | rewrite 规则互为条件 | 加 break 或用 return |
| 正则匹配不到 | ~ 与 ~* 用错,或忘了转义 | 注意大小写与转义字符 |
| 变量在 if 里不生效 | set 在 rewrite 阶段,执行顺序有讲究 | 换用 map |
| 中文文件名 404 | URI 编码问题 | 用英文命名,或注意编码一致性 |
7.2 调试手段
① 打开 debug 日志
# 修改主配置
error_log /var/log/nginx/error.log debug;
# 如果启动报错说 unknown log level "debug",说明编译时没带 --with-debug
# Ubuntu 官方包默认不带,要么重装带 debug 的版本:
# apt install nginx-dbg (仅提供符号,不开启 debug 日志)
# 最实用的替代方案是给自己加一个只打印少量信息的日志级别:info
error_log /var/log/nginx/error.log info;
warn 或 error。
② 按 server / location 分别记日志
server {
listen 80;
server_name debug.example.com;
# 单独一个日志文件,不受其他站点干扰
access_log /var/log/nginx/debug.access.log;
error_log /var/log/nginx/debug.error.log info;
location / {
return 200 "uri=$uri request_uri=$request_uri host=$host\n";
add_header Content-Type text/plain;
}
}
return 200 "变量值=$变量"; 把它吐出来。比猜快一百倍。
curl -s http://127.0.0.1/api/test?a=1
# 输出:uri=/api/test request_uri=/api/test?a=1 host=127.0.0.1
③ 用 curl 看实际行为
# -v 看完整请求响应过程
curl -v http://127.0.0.1/
# 只看响应头(-I 发 HEAD 请求)
curl -I http://127.0.0.1/
# 强制指定 Host 头(测虚拟主机最有用,不用改 DNS)
curl -I -H "Host: csharp.superyg.cloud" http://127.0.0.1/
# 强制指定 IP(绕过 DNS 缓存)
curl --resolve example.com:443:1.2.3.4 https://example.com/
# 跟随跳转并显示最终地址
curl -sIL http://example.com/ | grep -i "^location\|^HTTP"
# 只测响应时间
curl -o /dev/null -s -w "连接:%{time_connect} 首字节:%{time_starttransfer} 总计:%{time_total}\n" https://example.com/
④ 校验单个 server 块的匹配
# 当前生效的所有 server_name(按文件分组)
nginx -T 2>/dev/null | grep -E "^# configuration|server_name|listen"
# 检查 location 的书写顺序是否有问题
nginx -T 2>/dev/null | grep -n "location"
7.3 一个真实排错案例
现象:给某个站点加了 add_header Strict-Transport-Security 安全头,用 curl -I 验证却看不到它。
- 确认配置写对了,
nginx -t也通过。 - 用
nginx -T确认这个 server 块确实被加载。 - 发现问题:该 location 里已经有一条
add_header Cache-Control,导致外层(http/server 级)的 HSTS 头被整体屏蔽。 - 修正:把 HSTS 也写进这个 location,或把公共头抽成
snippets/security-headers.conf并在每一层 include。
# snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
server {
location /static/ {
include /etc/nginx/snippets/security-headers.conf; # ← 每层都要 include
add_header Cache-Control "public, max-age=31536000, immutable";
expires 1y;
}
location /api/ {
include /etc/nginx/snippets/security-headers.conf; # ← 这里也要
proxy_pass http://127.0.0.1:5000;
}
}
add_header ... always; 表示无论状态码是什么都加这个头。不加 always 时,只在 2xx / 3xx 响应上添加——所以 404、500 页面会丢掉安全头。涉及安全的头一律加 always。
第三部分 · 静态服务
8server 块与虚拟主机
8.1 server 块的完整参数
server {
# ── 监听 ──
listen 80; # IPv4
listen [::]:80; # IPv6
listen 443 ssl http2; # HTTPS + HTTP/2(1.25.1 起改为单独指令)
listen 127.0.0.1:8080; # 只监听本机(不对外)
listen 443 ssl default_server; # 作为默认 server
listen 80 backlog=2048; # 半连接队列长度
# ── 域名 ──
server_name example.com; # 单个
server_name example.com www.example.com; # 多个
server_name *.example.com; # 通配(只能放开头或结尾)
server_name ~^(?<sub>.+).example\.com$; # 正则(注意转义与顺序!)
server_name _; # 无意义占位,配合 default_server
# ── 根目录 ──
root /var/www/example;
index index.html;
location / { ... }
}
listen 443 ssl http2;;之后推荐拆成两行:
listen 443 ssl;
http2 on;
老写法仍兼容但会有警告。查版本:nginx -v。
8.2 server_name 的匹配优先级
请求进来后,Nginx 按以下顺序挑选 server:
- 精确匹配 ——
server_name example.com; - 最长的以
*开头的通配 ——*.example.com - 最长的以
*结尾的通配 ——www.example.* - 按配置中出现顺序的第一个正则 ——
~^.+\.example\.com$ - default_server —— 都没匹配上时兜底
server_name ~^api\.example\.com$; # 写在前面就赢
server_name ~^.+\.example\.com$; # 这条永远轮不到 api 的请求
把更具体的正则写在前面。另外通配 *.example.com 会优先于正则,无论正则写多前。
8.3 虚拟主机的三种组织方式
方式一:一个文件一个站点(推荐)
# /etc/nginx/conf.d/site-a.conf
server {
listen 80;
server_name a.example.com;
root /var/www/site-a;
}
# /etc/nginx/conf.d/site-b.conf
server {
listen 80;
server_name b.example.com;
root /var/www/site-b;
}
优点:改一个站点不影响另一个,出问题容易定位,删站点直接删文件。
方式二:Debian 的 sites-available / sites-enabled
# 站点配置写在 available
sudo tee /etc/nginx/sites-available/site-a >/dev/null <<'EOF'
server {
listen 80;
server_name a.example.com;
root /var/www/site-a;
}
EOF
# 建软链到 enabled 才生效
sudo ln -s /etc/nginx/sites-available/site-a /etc/nginx/sites-enabled/
# 下线站点(不删配置)
sudo rm /etc/nginx/sites-enabled/site-a
sudo systemctl reload nginx
优点:软链即开关,临时下线不丢配置,是 Debian 系的主流做法。
方式三:全部写在 nginx.conf 里
只在站点极少(1-2 个)时考虑。站点一多就是灾难,不推荐。
8.4 一个 IP 上多套 HTTPS
因为 TLS 握手发生在读到 HTTP 请求之前,Nginx 靠 SNI(Server Name Indication)来知道客户端想访问哪个域名,从而选对证书。
# 站点 A
server {
listen 443 ssl;
server_name a.example.com;
ssl_certificate /etc/letsencrypt/live/a.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/a.example.com/privkey.pem;
}
# 站点 B(同一个 IP、同一个 443)
server {
listen 443 ssl;
server_name b.example.com;
ssl_certificate /etc/letsencrypt/live/b.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/b.example.com/privkey.pem;
}
# 兜底:SNI 不匹配或客户端不支持 SNI 时用这张证书
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/letsencrypt/live/a.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/a.example.com/privkey.pem;
return 444; # 444 是 Nginx 专有码:直接关闭连接不回包
}
return 444 让 Nginx 直接断开 TCP 连接、不发任何响应。用于对付扫描器和恶意 SNI 探测,比返回 404 更干净(不浪费带宽,也不给对方任何信息)。现代浏览器都支持 SNI,所以 default_server 只是给爬虫和老客户端的兜底。
9root 与 alias 的区别
这是初学者最容易搞错的一对指令。搞混了就会出现"404 但文件明明在"的诡异状况。
9.1 核心差异
| 指令 | 路径拼接方式 | 记忆诀窍 |
|---|---|---|
root | root 值 + 完整 URI | root = 根,后面保留 location 里的路径 |
alias | alias 值 替换 location 匹配的部分 | alias = 别名,把 location 那段换掉 |
9.2 对照实例
9.3 完整对照示例
# ── 方式 A:root(路径会多一层 static)──
location /static/ {
root /var/www/site;
# 请求 /static/a.css → /var/www/site/static/a.css
}
# 所以目录必须是 /var/www/site/static/a.css,多一层
# ── 方式 B:alias(路径被替换掉)──
location /static/ {
alias /var/www/site/assets/;
# 请求 /static/a.css → /var/www/site/assets/a.css
}
# 目录是 /var/www/site/assets/a.css,不保留 static
9.4 alias 的坑
# ❌ 错误:正则 location 配 alias 不能省略捕获
location ~ ^/img/(.*)$ {
alias /data/$1; # 必须用 $1 承接捕获
}
# ✅ 正确写法的一种
location ~* ^/uploads/(.+\.(?:jpg|png|webp))$ {
alias /data/uploads/$1;
}
# ❌ 这样写会有诡异行为,try_files 不接受 alias
location /files/ {
alias /data/files/;
try_files $uri =404;
}
# ✅ 改用 root,或干脆不要 try_files
location /files/ {
root /data;
try_files $uri =404;
# 请求 /files/a.txt → /data/files/a.txt
}
- 能靠 root + 目录结构解决的,就用 root —— 行为直观,坑少
- 只有当磁盘路径与 URL 路径没有公共前缀时才用 alias
- 用 alias 时,location 和 alias 都以
/结尾,这条规则能规避绝大多数问题
9.5 一次真实的 404 排查
# 配置
location /download/ {
alias /data/files; # ← 结尾漏了斜杠
}
# 请求 /download/report.pdf
# 实际去读 /data/filesreport.pdf ← 拼接错了,404
# error.log 里是:
# open() "/data/filesreport.pdf" failed (2: No such file or directory)
sudo grep "failed" /var/log/nginx/error.log | tail -5
10location 匹配规则详解
location 决定"一个请求交给谁处理"。它有一套明确的优先级规则,是 Nginx 里最需要吃透的部分。
10.1 五种修饰符
| 写法 | 名称 | 规则 | 优先级 |
|---|---|---|---|
= /path | 精确匹配 | URI 完全相等才匹配 | 1(最高) |
^~ /path | 前缀匹配(不查正则) | 前缀匹配成功后跳过正则 | 2 |
~ /re | 正则匹配(区分大小写) | 按出现顺序,第一个命中即用 | 3 |
~* /re | 正则匹配(不区分大小写) | 同上 | 3 |
/path | 普通前缀匹配 | 记录最长匹配,若没被 ^~ 截断则继续试正则 | 4(最低) |
10.2 匹配流程(必须理解这张图)
= 最优先,然后看前缀,前缀被 ^~ 标记就到此为止,否则正则说了算,正则都没命中才回退到最长前缀。
10.3 实例推演
server {
location = / { return 200 "A: 精确根路径\n"; }
location / { return 200 "B: 所有请求兜底\n"; }
location /images/ { return 200 "C: 图片目录\n"; }
location ^~ /static/ { return 200 "D: 静态资源,跳过正则\n"; }
location ~ \.php$ { return 200 "E: PHP 正则\n"; }
location ~* \.(jpg|png|gif)$ { return 200 "F: 图片扩展名\n"; }
location /api/ { return 200 "G: API 前缀\n"; }
add_header Content-Type text/plain;
}
for url in / /index.html /images/a.png /static/b.css /x.php /photo.JPG /api/user /nothing; do
printf "%-18s → %s\n" "$url" "$(curl -s http://127.0.0.1$url)"
done
逐个解释:
| 请求 | 命中 | 推理过程 |
|---|---|---|
/ | A | = 精确匹配,最高优先级,直接定 |
/index.html | B | 前缀匹配命中 /;正则 \.php$ 和图片规则都不匹配,回退到最长前缀 / |
/images/a.png | F | 前缀命中 /images/,但它没带 ^~,所以继续查正则;\.(jpg|png|gif)$ 命中 → 正则优先于普通前缀 |
/static/b.css | D | 前缀 ^~ /static/ 命中,直接截断,不再查正则 |
/x.php | E | 没有更长前缀;正则 \.php$ 第一个命中 |
/photo.JPG | F | ~* 不区分大小写,.JPG 也能命中 |
/api/user | G | 最长前缀 /api/;无正则命中 → 用它 |
/nothing | B | 只有 / 这个前缀能匹配 |
/images/ 匹配上了就该用 C,结果命中的是 F。原因:普通前缀匹配只是"候选",正则有更高的决定权。想让 /images/ 说了算,必须写成 location ^~ /images/。
10.4 location 的嵌套
location /api/ {
proxy_pass http://127.0.0.1:5000;
# 嵌套 location:只在 /api/ 内部再细分子路径
location ~ ^/api/(user|order)/ {
proxy_set_header X-Domain $1;
proxy_pass http://127.0.0.1:5001;
}
location = /api/health {
return 200 "ok";
add_header Content-Type text/plain;
}
}
10.5 实战:一套常用的 location 结构
server {
listen 443 ssl;
server_name example.com;
root /var/www/example;
# 1. 隐藏文件,任何情况都不许访问
location ~ /\.(?!well-known) {
deny all;
access_log off;
log_not_found off;
}
# 2. ACME 挑战专用(签证书要留,且必须在隐藏文件规则之后放宽)
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
}
# 3. 静态资源:长缓存 + 跳过正则
location ^~ /static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# 4. 上传目录:不许执行任何脚本
location ^~ /uploads/ {
# 只允许返回静态文件
location ~ \.(php|jsp|asp|aspx|sh)$ { deny all; }
}
# 5. API 反代
location /api/ {
proxy_pass http://127.0.0.1:5000;
include /etc/nginx/snippets/proxy-headers.conf;
}
# 6. 兜底
location / {
try_files $uri $uri/ /index.html;
}
}
11index 与 try_files
11.1 index 指令
当请求的 URI 以 / 结尾(指向一个目录)时,Nginx 尝试用 index 指定的文件作为响应。
# 可以指定多个,按顺序尝试
index index.html index.htm index.php;
# ⚠️ 有个默认值 index.html,写了 index 指令会覆盖它
# 如果只想加不想覆盖,必须把 index.html 一起写上
# ❌ 这样写,index.html 不再被当作索引文件!
index main.htm;
# ✅ 要保留默认行为
index index.html main.htm;
11.2 try_files:Nginx 最有用的指令之一
它按顺序检查一系列路径,返回第一个存在的文件;都不存在时执行最后一个参数指定的"兜底动作"。
try_files 文件1 文件2 ... 文件N 兜底;
前 N-1 个参数是要检查的路径,最后一个参数是兜底——可以是:
- 一个 URI → 内部重定向(
/index.html、@named_location) - 一个状态码 →
=404、=403
11.3 四种典型用法
location / {
try_files $uri $uri/ /index.html;
}
# 请求 /user/123 → 磁盘上没有这个文件 → 内部转到 index.html
# 由前端路由接管,浏览器地址栏不变,这是 SPA 的标准配法
location / {
try_files $uri $uri/ =404;
}
# 文件不存在就返回真 404(对 SEO 友好,也便于排查)
location / {
# 优先返回 .gz 预压缩版本,没有就返回源文件
try_files $uri.gz $uri =404;
gzip_static on; # 更优雅的做法:需要 ngx_http_gzip_static_module
}
location / {
# 静态文件优先,其次给应用处理,最后 404
try_files $uri $uri/ @backend;
}
location @backend {
proxy_pass http://127.0.0.1:5000;
include /etc/nginx/snippets/proxy-headers.conf;
}
location @name 定义的块不能被外部请求直接访问,只能被 try_files 或 error_page 内部引用。用来组织"兜底逻辑"非常合适。
11.4 try_files 完整请求链路图
11.5 try_files 的注意事项
# ❌ 最后一个参数是 URI,它必须是必然存在的,否则内部循环 → 500
try_files $uri /not-exist.html;
# ✅ 用命名 location 或状态码兜底最安全
try_files $uri $uri/ @fallback;
try_files $uri $uri/ =404;
/ 的参数会触发"目录检查"。如果目录存在,Nginx 会尝试对其做一次内部重定向(补上结尾斜杠),可能触发额外的 index 处理。纯 API 站点通常不需要 $uri/,直接 try_files $uri @backend; 更干净。
12目录列表、错误页与自定义
12.1 打开目录浏览
location /files/ {
alias /data/files/;
autoindex on; # 开启目录列表
autoindex_exact_size off; # 显示为 KB/MB 而不是字节
autoindex_localtime on; # 用本地时间而非 GMT
autoindex_format html; # html | json | xml | jsonp
}
需要对外提供下载列表时,至少加一层访问控制:
location /files/ {
alias /data/files/;
autoindex on;
# 只允许内网
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
}
12.2 自定义错误页
# 单个错误码
error_page 404 /404.html;
# 多个错误码共用
error_page 500 502 503 504 /50x.html;
# 内部跳转(浏览器地址栏不变)—— 默认行为
error_page 404 /404.html;
# 重定向(浏览器地址栏会变成 /404.html)
error_page 404 =301 /404.html;
# 替换状态码:返回 200 但内容是错误页(慎用,会误导爬虫)
error_page 404 =200 /404.html;
# 直接返回状态码,不做任何跳转
error_page 403 = @denied;
location @denied {
return 403 "Access denied";
}
location /api/ {
proxy_pass http://127.0.0.1:5000;
proxy_intercept_errors on; # 让 Nginx 接管后端 4xx/5xx
error_page 502 503 504 /50x.html;
}
proxy_intercept_errors on; 之后,后端返回的 4xx/5xx 会触发本地的 error_page 规则。想做"后端挂了显示友好页面"就必须开这个。
12.3 错误页的完整配置
# 把错误页统一放在一个目录
error_page 400 401 403 404 /errors/404.html;
error_page 500 502 503 504 /errors/50x.html;
location ^~ /errors/ {
root /var/www;
internal; # 只允许内部跳转访问,外部直接请求返回 404
}
# 页面里可以用 SSI 显示状态码
# /var/www/errors/50x.html:
# <h1>服务暂时不可用</h1>
# <p>错误码:<!--# echo var="status" default="500" --></p>
# 需在 http/server 打开:ssi on;
internal 的 location 不能被外部直接请求,只能由 Nginx 内部(error_page、try_files、X-Accel-Redirect)访问。可以用来保护内部资源:
location /private/ {
internal;
alias /data/private/;
}
# 外部访问 /private/a.pdf → 404
# 后端返回 X-Accel-Redirect: /private/a.pdf → 正常下载
# 这是给后端做"下载鉴权"的经典手法
12.4 其他常用响应定制
# 隐藏 Nginx 版本号
server_tokens off;
# 完全改掉 Server 头(需 headers-more 模块)
more_set_headers "Server: web";
# 补上或修改任意响应头
add_header X-Request-Id $request_id always;
# 按状态码单独设缓存
map $status $cache_by_status {
200 "public, max-age=3600";
404 "no-cache";
default "no-store";
}
add_header Cache-Control $cache_by_status;
# 自定义 MIME 类型
types {
application/wasm wasm;
font/woff2 woff2;
}
default_type application/octet-stream;
mime.types 里没有 .wasm、.woff2、.mjs,会导致浏览器拒绝加载(MIME 类型不对)。遇到"文件能下载但浏览器报错不执行",先查 Content-Type。
第四部分 · 反向代理
13proxy_pass 原理与路径拼接
反向代理是 Nginx 最有价值的用途。而 proxy_pass 的路径拼接规则,是初学者翻车最多的地方——一条斜杠的有无,结果完全不同。
13.1 代理的基本原理
13.2 路径拼接:四种情况
规则只有一条,但要精确理解:
proxy_pass 的值里带路径(哪怕只有一个 /),就会用这个路径替换掉 location 匹配的部分;不带路径时,把原始 URI 原样拼接上去。
location /api/ {
proxy_pass http://127.0.0.1:5000;
}
# 请求 /api/user → 转发到 http://127.0.0.1:5000/api/user
# ↑ 原样保留 /api/user
location /api/ {
proxy_pass http://127.0.0.1:5000/;
}
# 请求 /api/user → 转发到 http://127.0.0.1:5000/user
# ↑ /api/ 被替换成了 /
location /api/ {
proxy_pass http://127.0.0.1:5000/v2/;
}
# 请求 /api/user → 转发到 http://127.0.0.1:5000/v2/user
# ↑ /api/ 被替换成了 /v2/
# 换一种写法实现同样效果
location /api/ {
proxy_pass http://127.0.0.1:5000/v2; # 注意这里没有结尾斜杠
}
# 请求 /api/user → http://127.0.0.1:5000/v2user ❌ 错了!
# 必须两边都有斜杠才对应
/ 时,proxy_pass 的路径也要带 /,否则会拼成 v2user 这种畸形路径。这条和 alias 的规则完全一样。
location ~ ^/api/(.*)$ {
proxy_pass http://127.0.0.1:5000/$1; # 用捕获组显式指定
}
location ^~ /static/ {
proxy_pass http://127.0.0.1:8080/; # ^~ 也遵循"替换"规则
}
proxy_pass 必须带路径(因为无法从 location 推导前缀)。写 proxy_pass http://127.0.0.1:5000; 会报错:
"proxy_pass" cannot have URI part in location given by regular expression,
or inside named location, or inside "if" statement, or inside "limit_except"
13.3 对照速查表
| location | proxy_pass | 请求 /api/user 转发到 |
|---|---|---|
/api/ | http://srv | http://srv/api/user |
/api/ | http://srv/ | http://srv/user |
/api/ | http://srv/v2/ | http://srv/v2/user |
/api/ | http://srv/v2 | http://srv/v2user ❌ |
/api(无尾斜杠) | http://srv | http://srv/api/user |
/api(无尾斜杠) | http://srv/ | http://srv//user ⚠️ 多一个斜杠 |
~ ^/api/(.*) | http://srv/$1 | http://srv/user |
location /api/ {
proxy_pass http://127.0.0.1:5000/;
add_header X-Debug-URI "$uri" always;
add_header X-Debug-Upstream "$upstream_addr" always;
}
curl -sI http://127.0.0.1/api/user | grep -i x-debug
13.4 一个完整可跑的例子
# 先用 Python 起一个回显后端(临时试验用)
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_GET(self):
body = f'path={self.path}\n'.encode()
self.send_response(200)
self.send_header('Content-Type','text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers(); self.wfile.write(body)
def log_message(self,*a): pass
HTTPServer(('127.0.0.1',5000),H).serve_forever()
" &
server {
listen 8081;
server_name _;
# 不替换,原样透传
location /keep/ {
proxy_pass http://127.0.0.1:5000;
}
# 替换掉前缀
location /strip/ {
proxy_pass http://127.0.0.1:5000/;
}
# 换成另一段前缀
location /remap/ {
proxy_pass http://127.0.0.1:5000/v2/;
}
}
for p in /keep/a/b /strip/a/b /remap/a/b; do
printf "%-12s → %s" "$p" "$(curl -s http://127.0.0.1:8081$p)"
done
三种行为一目了然。记住这个实验,以后就不用再纠结斜杠了。
14必须设置的代理请求头
14.1 默认代理头的问题
Nginx 转发请求时,默认会做两件事:把 Host 改成 proxy_pass 里的主机名,并补一个 Connection: close。
这会带来一系列问题:
- 后端看到的是
Host: 127.0.0.1,拿不到真实域名,无法做多域名逻辑、生成正确的绝对链接 - 后端拿到的
$remote_addr是 Nginx 的 IP,不是客户端 IP - 后端不知道原始协议是 http 还是 https,可能导致重定向到 http
- 每次请求都新建后端连接,性能差
14.2 标准代理头片段
把这段存成 /etc/nginx/snippets/proxy-headers.conf,所有反代 location 都 include 它:
# /etc/nginx/snippets/proxy-headers.conf
proxy_set_header Host $host; # 真实域名
proxy_set_header X-Real-IP $remote_addr; # 客户端 IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;# IP 链
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# 保留长连接,必须两条一起写
proxy_http_version 1.1;
proxy_set_header Connection "";
14.3 每个头的详细说明
| 头 | 作用 | 不设的后果 |
|---|---|---|
Host | 告诉后端用户访问的域名 | 后端看到 127.0.0.1,多站点/绝对链接出错 |
X-Real-IP | 客户端真实 IP | 日志里全是 Nginx 的 IP |
X-Forwarded-For | 经过的代理 IP 链,逗号分隔 | 无法追溯真实来源;限流失效 |
X-Forwarded-Proto | 原始协议(http/https) | 框架误判为非 HTTPS,生成 http 链接、重定向循环 |
X-Forwarded-Host | 原始 Host(与 Host 分开保留) | 某些框架需要它来生成绝对 URL |
X-Forwarded-Port | 原始端口 | 生成 URL 时端口不对 |
X-Request-Id | 请求唯一标识,便于全链路追踪 | 排查跨服务问题时无法关联日志 |
14.4 $host 与 $http_host 的取舍
# 方案 A:用 $host(推荐)
proxy_set_header Host $host;
# $host 的顺序是:请求行里的主机 → Host 头 → 匹配的 server_name
# 好处:即使客户端不发 Host 头(HTTP/1.0),也有值兜底
# 方案 B:用 $http_host
proxy_set_header Host $http_host;
# 完全照搬客户端的 Host 头,包含端口,如 example.com:8443
# 风险:客户端可伪造,且为空时导致后端收到空 Host
Host 头来生成密码重置链接、回调地址,攻击者可以伪造 Host 把链接指向自己的服务器。Nginx 层面的防护是明确列出允许的 server_name,并给不匹配的请求一个兜底 server:
server {
listen 80 default_server;
server_name _;
return 444; # 未知 Host 直接断连
}
server {
listen 80;
server_name example.com;
# 只有明确列出的域名才被服务
}
14.5 X-Forwarded-For 的传递链
X-Forwarded-For 头!如果 Nginx 直接信任整条链的最左值,攻击者发一个 X-Forwarded-For: 1.1.1.1 就能伪装成任何 IP,绕过基于 IP 的限流和白名单。正确做法:只信任已知代理。用
real_ip 模块:
# 声明哪些上游是可信代理
set_real_ip_from 5.5.5.5;
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 从右往左跳过可信代理,取第一个不可信的
# 此后 $remote_addr 就是真实客户端 IP,可直接用于限流和日志
14.6 给后端传递自定义头
location /api/ {
proxy_pass http://127.0.0.1:5000;
# 传递应用级信息
proxy_set_header X-App-Env "production";
proxy_set_header X-Request-Id $request_id;
proxy_set_header X-Trace-Id $http_x_trace_id;
# 把客户端 IP 单独再传一份(有时后端只认这个头名)
proxy_set_header X-Client-IP $remote_addr;
# 清除客户端伪造的头(安全)
proxy_set_header X-Admin "";
}
$request_id 为每个请求生成唯一 ID。把它写进 access_log 并传给后端,前后端日志就能用同一个 ID 串起来——排查跨服务问题是质变。
log_format trace '$request_id $remote_addr "$request" $status $request_time';
access_log /var/log/nginx/access.log trace;
15超时、缓冲与长连接
15.1 超时参数
| 参数 | 默认 | 含义 | 建议 |
|---|---|---|---|
proxy_connect_timeout | 60s | 与后端建立连接的超时 | 5-10s(内网更快) |
proxy_send_timeout | 60s | 向后端发送请求的超时 | 30-60s |
proxy_read_timeout | 60s | 等待后端返回响应的超时 | 按业务定,见下 |
send_timeout | 60s | 向客户端发送响应的超时 | 30s |
keepalive_timeout | 75s | 客户端长连接空闲时间 | 30-65s |
client_header_timeout | 60s | 读客户端请求头超时 | 10-30s(防慢速攻击) |
client_body_timeout | 60s | 读客户端请求体超时 | 30s |
resolver_timeout | 30s | DNS 解析超时(proxy_pass 用域名时) | 5s |
# 报表导出类接口可能要几分钟
location /api/report/export {
proxy_pass http://127.0.0.1:5000;
proxy_read_timeout 600s; # 10 分钟
proxy_send_timeout 600s;
proxy_buffering off; # 边生成边发
}
反过来,普通接口不要设太大——后端真的挂了时,长超时会让连接堆积,拖垮 Nginx。
15.2 缓冲机制
默认情况下 Nginx 会把后端响应先收进缓冲区再发给客户端。好处是后端可以快速释放(不用等慢客户端),坏处是实时性差、大响应吃内存/磁盘。
location /api/ {
proxy_pass http://127.0.0.1:5000;
# ── 开启缓冲(默认,适合普通 JSON 接口)──
proxy_buffering on;
proxy_buffers 8 16k; # 8 个 16k 缓冲
proxy_buffer_size 16k; # 响应头缓冲
proxy_busy_buffers_size 32k; # 可同时向客户端发送的缓冲上限
proxy_max_temp_file_size 1024m; # 超出缓冲时写临时文件的上限
# ── 关闭缓冲(适合流式 / SSE / 大文件)──
# proxy_buffering off;
# proxy_max_temp_file_size 0;
}
| 场景 | proxy_buffering | 原因 |
|---|---|---|
| 普通 JSON 接口 | on | 让后端快速释放连接 |
| SSE / 实时推送 | off | 必须逐条实时推送,不能被攒着 |
| 大文件下载 | off + max_temp_file_size 0 | 避免落磁盘临时文件 |
| 流式响应(LLM 输出) | off | 用户要看到逐字输出 |
| 慢客户端 + 小响应 | on | 让后端不被慢客户端拖住 |
proxy_buffering off 会自动导致 gzip 对代理响应失效——配置上没报错,但压缩悄悄不生效了。要给流式响应加压缩,得在后端做,或者接受不压缩。
15.3 后端长连接(性能关键)
默认情况下 Nginx 和后端每个请求都新建一条 TCP 连接,用完就关。高并发下这是巨大的开销。
location /api/ {
proxy_pass http://127.0.0.1:5000;
# 没做任何配置,每请求新建连接,TIME_WAIT 堆积
}
upstream backend {
server 127.0.0.1:5000;
keepalive 32; # 维持 32 条空闲长连接(★ 关键)
keepalive_timeout 60s;
keepalive_requests 1000; # 每条连接最多复用多少次
}
location /api/ {
proxy_pass http://backend; # 注意:必须走 upstream,不能直接写 IP
proxy_http_version 1.1; # ★ HTTP/1.0 不支持长连接
proxy_set_header Connection ""; # ★ 清空,否则会带 Connection: close
}
proxy_pass指向 upstream 名(直接写 IP 时 keepalive 不生效)proxy_http_version 1.1proxy_set_header Connection "";—— 清空而不是设成 "keep-alive"
ss -tan | grep :5000 | wc -l 是否稳定。
# 压测前记录后端连接数
ss -tan | grep ":5000" | wc -l
# 发 100 个请求
for i in $(seq 1 100); do curl -s -o /dev/null http://127.0.0.1/api/ping; done
# 再看连接数
ss -tan | grep ":5000" | wc -l
# 生效时:稳定在 keepalive 设定值附近(如 32 左右)
# 未生效:出现大量 TIME_WAIT
15.4 客户端长连接
http {
keepalive_timeout 65; # 空闲连接保持
keepalive_requests 1000; # 单连接最多处理请求数
keepalive_time 1h; # 连接最长存活时间(1.19.10+)
}
客户端长连接的收益:省掉 TCP 三次握手和 TLS 握手,静态资源密集的页面上效果显著。
- 太短:连接频繁重建,握手开销大
- 太长:空闲连接占用 worker 的
worker_connections配额,并发能力下降 - 常见取值 30-75 秒。前面有 CDN 时参考 CDN 的默认值
16WebSocket 与 SSE 代理
16.1 为什么普通反代配不了 WebSocket
WebSocket 用 HTTP 的 Upgrade 机制把连接从 HTTP 协议升级成双向的 WS 协议。而 Nginx 默认在转发时把 Connection: upgrade 改成了 Connection: close(这是 HTTP/1.1 代理的常规做法),协商就失败了。
另外,WebSocket 连接是长期存在的,proxy_read_timeout 到了就会被断开。
16.2 正确的 WebSocket 配置
http {
# 客户端要升级时传 upgrade,否则传 close
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
}
proxy_set_header Connection "upgrade";,但这样所有请求都带 upgrade 头,包括普通 HTTP 请求——某些后端会因此行为异常。map 的做法是:有 Upgrade 头才传 upgrade,否则传 close。既支持 WS 又不影响普通请求。
location /ws/ {
proxy_pass http://127.0.0.1:666;
proxy_http_version 1.1;
# ★ 这两条是 WebSocket 的核心
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_read_timeout 3600s;
proxy_send_timeout 3600s;
# ★ 关闭缓冲,保证消息实时
proxy_buffering off;
}
16.3 完整可跑示例
// server.js —— 需要一个 ws 库:npm i ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 666 });
wss.on('connection', (ws, req) => {
// 验证代理头是否正确传过来
console.log('client ip:', req.headers['x-real-ip']);
console.log('host :', req.headers['host']);
ws.send(JSON.stringify({ type: 'welcome', ts: Date.now() }));
const timer = setInterval(() => {
ws.send(JSON.stringify({ type: 'tick', ts: Date.now() }));
}, 1000);
ws.on('message', (data) => {
ws.send(JSON.stringify({ type: 'echo', data: data.toString() }));
});
ws.on('close', () => clearInterval(timer));
});
console.log('ws server on :666');
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8082;
server_name _;
location /ws/ {
proxy_pass http://127.0.0.1:666/;
proxy_http_version 1.1;
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_read_timeout 3600s;
proxy_buffering off;
}
}
// test.js 运行:node test.js
const WebSocket = require('ws');
const ws = new WebSocket('ws://127.0.0.1:8082/ws/');
ws.on('open', () => {
console.log('✅ 连接成功(经过 Nginx 代理)');
ws.send('hello');
});
ws.on('message', (d) => console.log('← 收到:', d.toString()));
setTimeout(() => process.exit(0), 3500);
| 现象 | 原因 |
|---|---|
| 握手返回 400/426 | 缺 Upgrade / Connection 头 |
| 连上就断(约 60s) | proxy_read_timeout 太小 |
| 消息延迟或成批到达 | proxy_buffering 没关 |
| 一直连不上,无日志 | 用了 HTTP/1.0(缺 proxy_http_version 1.1) |
| 生产偶尔断连 | 需要心跳(ping/pong)保活,或云负载均衡空闲超时 |
16.4 SSE(Server-Sent Events)
SSE 是单向的"服务器推"技术,用普通 HTTP 长连接实现,配置比 WebSocket 简单,但有几个专属要点:
location /events/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
# ★ 必须关闭缓冲,否则消息被攒着不发出
proxy_buffering off;
proxy_cache off;
# ★ 关闭分块传输的缓冲(1.7.11+)
proxy_set_header X-Accel-Buffering no;
# 长超时
proxy_read_timeout 24h;
# ★ 不能压缩:压缩需要缓冲,会破坏实时性
gzip off;
# 响应头(后端也应设,这里双保险)
add_header Cache-Control "no-cache" always;
add_header X-Accel-Buffering "no" always;
# 关闭客户端连接复用,避免消息卡在连接里
proxy_set_header Connection '';
}
X-Accel-Buffering: no,Nginx 就会对这一个响应关闭缓冲,不用改全局配置。这比在 Nginx 里写 proxy_buffering off 更灵活——同一个 location 里,普通接口仍可缓冲,只有流式接口关闭。做 LLM 流式输出时这个头非常有用。
app.get('/events/stream', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // ★ 告诉 Nginx 别缓冲
});
res.flushHeaders();
let n = 0;
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ n: n++, ts: Date.now() })}\n\n`);
if (n > 5) { clearInterval(timer); res.end(); }
}, 800);
req.on('close', () => clearInterval(timer));
});
curl -N http://127.0.0.1:8082/events/stream
# -N 关闭 curl 自己的缓冲,才能看到逐条输出
16.5 WebSocket vs SSE 选择
| 对比项 | WebSocket | SSE |
|---|---|---|
| 方向 | 全双工 | 单向(服务器→客户端) |
| 协议 | 独立协议 (ws://) | 普通 HTTP |
| 自动重连 | 需自己实现 | 浏览器内置 |
| 代理配置 | 需 Upgrade 头 + map | 只需关缓冲 |
| 二进制支持 | 完整支持 | 只能文本 |
| 连接数限制 | 无(HTTP/2 下共享) | HTTP/1.1 下 6 个/域名 |
| 典型用途 | 聊天、游戏、协作编辑 | 通知、进度、日志流、AI 流式输出 |
17upstream 负载均衡
17.1 基本结构
http {
upstream backend {
server 127.0.0.1:5000;
server 127.0.0.1:5001;
server 127.0.0.1:5002;
}
server {
listen 80;
location / {
proxy_pass http://backend; # 用 upstream 名字,不是具体 IP
}
}
}
upstream 和引用它的 server 放在同一个 conf.d/xxx.conf 文件里——只要这个文件被 include 在 http 上下文(Ubuntu 默认就是这样)。
17.2 六种负载均衡策略
| 策略 | 写法 | 适用场景 |
|---|---|---|
| 轮询(默认) | 无额外参数 | 各后端性能相近,无状态服务 |
| 加权轮询 | server ... weight=3; | 机器配置不均 |
| IP 哈希 | ip_hash; | 无 session 共享,需会话粘连 |
| 最少连接 | least_conn; | 请求耗时差异大 |
| 一致性哈希 | hash $key consistent; | 缓存服务器,扩容时减少抖动 |
| 随机 + 两选一 | random two least_conn; | 大规模集群,避免热点 |
17.3 加权轮询
upstream backend {
server 10.0.0.1:5000 weight=5; # 性能好,分担 5/8
server 10.0.0.2:5000 weight=2; # 中等,2/8
server 10.0.0.3:5000 weight=1; # 较弱,1/8
}
# weight 默认是 1,范围 1-10000
# 让某台机器暂时不接收新请求(但仍保留在配置里)
upstream backend {
server 10.0.0.1:5000 weight=5;
server 10.0.0.2:5000 down; # 完全下线
}
17.4 IP 哈希(会话粘连)
upstream backend {
ip_hash; # 同一客户端 IP 固定打到同一后端
server 10.0.0.1:5000;
server 10.0.0.2:5000;
server 10.0.0.3:5000;
}
- 拿不到真实 IP 时失效:如果前面还有 CDN,
ip_hash拿到的可能是 CDN 的 IP,所有用户会被分到同一台后端。必须先配real_ip模块。 - 扩缩容会重新洗牌:增加或移除一台机器,大量用户的归属会变化,session 丢失。
- 同一出口 IP 的用户挤在一起:公司/学校出口 IP 相同,会被全部分到一台。
17.5 最少连接
upstream backend {
least_conn;
server 10.0.0.1:5000;
server 10.0.0.2:5000;
server 10.0.0.3:5000;
}
把新请求交给当前活跃连接数最少的后端。当接口耗时差异大(有的 10ms、有的 3s)时,轮询会导致某台机器被慢请求占满,而 least_conn 能自动避开。
least_conn 可以和 weight 一起用,算法变成"按权重的加权最少连接"——机器越强,允许的活跃连接越多。
17.6 一致性哈希
# 按请求 URI 哈希(适合缓存服务器)
upstream cache_servers {
hash $request_uri consistent;
server 10.0.0.1:8080;
server 10.0.0.2:8080;
server 10.0.0.3:8080;
}
# 按客户端 IP(不用 ip_hash,但可控性更好)
upstream backend {
hash $remote_addr consistent;
server 10.0.0.1:5000;
server 10.0.0.2:5000;
}
consistent 后采用一致性哈希环,增删一个节点只影响 1/N 的 key。做本地缓存分片时这是必选项。
17.7 权重与状态标记
| 参数 | 作用 | 示例 |
|---|---|---|
weight=N | 权重,默认 1 | weight=3 |
max_conns=N | 该后端最大并发连接数(1.11.5+) | max_conns=100 |
max_fails=N | 失败几次后标记不可用,默认 1 | max_fails=3 |
fail_timeout=时间 | 标记不可用后,多久再试;也作为统计窗口 | fail_timeout=30s |
backup | 备用机,只有主全挂才启用 | backup |
down | 手动标记下线 | down |
resolve | 动态解析域名(需 resolver,商业版或 1.27.3+) | resolve |
upstream backend {
least_conn;
server 10.0.0.1:5000 weight=3 max_fails=3 fail_timeout=30s;
server 10.0.0.2:5000 weight=2 max_fails=3 fail_timeout=30s;
server 10.0.0.3:5000 backup; # 前两台都挂才顶上
keepalive 64;
keepalive_requests 1000;
keepalive_timeout 60s;
}
max_fails 设为 0 表示关闭失败计数,这台后端永远不被自动剔除。只在明确知道后端会短暂返回错误、且你希望它继续接收流量时才这么配。
17.8 验证负载均衡是否生效
# 起三个回显后端,各自返回自己的端口
for p in 5000 5001 5002; do
python3 -c "
from http.server import HTTPServer, BaseHTTPRequestHandler
PORT = $p
class H(BaseHTTPRequestHandler):
def do_GET(self):
b = f'backend-{PORT}\n'.encode()
self.send_response(200); self.send_header('Content-Length',str(len(b)))
self.end_headers(); self.wfile.write(b)
def log_message(self,*a): pass
HTTPServer(('127.0.0.1',PORT),H).serve_forever()
" &
done
upstream backend {
server 127.0.0.1:5000 weight=3;
server 127.0.0.1:5001 weight=2;
server 127.0.0.1:5002 weight=1;
}
server {
listen 8083;
location / { proxy_pass http://backend; }
}
for i in $(seq 1 12); do curl -s http://127.0.0.1:8083/; done | sort | uniq -c
比例 6:4:2 正好是 3:2:1,权重生效。
18健康检查与故障转移
18.1 Nginx 开源版的健康检查机制
重要事实:Nginx 开源版没有主动健康检查(不会定时去 ping 后端)。它只有被动检查——靠真实业务请求的失败来标记后端不可用。
| 开源版 | 商业版 (Nginx Plus) | |
|---|---|---|
| 被动检查 | ✅ 通过 max_fails / fail_timeout | ✅ |
| 主动检查 | ❌ 无 | ✅ health_check 指令 |
| 健康状态面板 | ❌ | ✅ API |
| 慢启动 | ❌ | ✅ slow_start |
| 动态增删节点 | ❌(改配置 + reload) | ✅ API 动态调整 |
18.2 被动健康检查的工作原理
upstream backend {
server 10.0.0.1:5000 max_fails=3 fail_timeout=30s;
server 10.0.0.2:5000 max_fails=3 fail_timeout=30s;
}
- 后端已经挂了,但要等到有请求失败才被发现——期间那些请求的失败由用户承担
- 恢复检测也是靠真实请求"试"出来的,恢复瞬间会有一两个失败请求
- 低流量时段(凌晨没人访问)故障可能长时间不被标记
proxy_next_upstream 让失败请求自动重试到另一台,用户几乎无感。见下一节。
18.3 故障转移:proxy_next_upstream
这是开源版实现"用户无感故障转移"的关键指令。
location /api/ {
proxy_pass http://backend;
# 什么情况算"失败",可以换下一台重试
proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
# 最多试几台(含第一台)
proxy_next_upstream_tries 3;
# 总重试时间上限
proxy_next_upstream_timeout 10s;
# 请求体已经发给后端时是否还重试(默认 off,建议保持)
# proxy_next_upstream_non_idempotent off;
}
| 值 | 含义 | 建议 |
|---|---|---|
error | 连接后端出错(拒绝、超时等) | ✅ 开 |
timeout | 与后端通信超时 | ✅ 开 |
http_500 | 后端返回 500 | ⚠️ 视情况 |
http_502 | 后端返回 502 | ✅ 开 |
http_503 | 后端返回 503 | ✅ 开 |
http_504 | 后端返回 504 | ✅ 开 |
http_403 | 后端返回 403 | ❌ 关(是业务结果,不该重试) |
http_404 | 后端返回 404 | ❌ 关 |
invalid_header | 后端返回了非法响应头 | ✅ 开 |
non_idempotent | 允许重试非幂等请求(POST 等) | ❌ 谨慎!见下 |
proxy_next_upstream error timeout invalid_header http_502 http_503 http_504;
proxy_next_upstream_tries 3;
proxy_next_upstream_timeout 10s;
proxy_next_upstream 不重试非幂等请求(POST/PUT/DELETE),这是为了保护你。因为"请求已发出但响应没收到"时重试,可能导致重复下单、重复扣款。加
non_idempotent 会放开这个限制——除非你的接口有幂等设计(如唯一请求 ID),否则不要加。
18.4 用 error_page 做优雅降级
upstream backend {
server 10.0.0.1:5000 max_fails=2 fail_timeout=10s;
server 10.0.0.2:5000 max_fails=2 fail_timeout=10s;
}
location /api/ {
proxy_pass http://backend;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
# 所有后端都挂了 → 返回友好降级内容
proxy_intercept_errors on;
error_page 502 503 504 = @degraded;
}
location @degraded {
default_type application/json;
return 503 '{"code":503,"message":"服务暂时不可用,请稍后重试"}';
}
- 返回
503而不是200——让监控和搜索引擎知道这是故障,不要伪装成正常 - 响应体给结构化信息(JSON),前端好处理;给 HTML 只适合页面级降级
- 加
Retry-After头告诉客户端多久后再试
location @degraded {
add_header Retry-After 30 always;
add_header Content-Type application/json always;
return 503 '{"code":503,"message":"服务暂时不可用","retryAfter":30}';
}
18.5 开源版做"主动检查"的变通方案
社区常见做法:用 OpenResty(带 Lua 的 Nginx 分支)或第三方模块 nginx_upstream_check_module。另一个轻量思路是用 Nginx 自身做探针:
# 方案:加一个内部 location 做探活,配合外部脚本动态改 upstream
# 实际生产中更常用的是"容器编排层做健康检查":
# - Kubernetes 用 readinessProbe,直接不把不健康的 Pod 放进 Service
# - Docker Compose 用 healthcheck,配合上游负载均衡器
# 结论:Nginx 开源版不必自己做主动检查,交给编排层更合适
# 如果确实需要,最简单的方案是加一台 backup 兜底:
upstream backend {
server 10.0.0.1:5000 max_fails=3 fail_timeout=15s;
server 10.0.0.2:5000 max_fails=3 fail_timeout=15s;
server 10.0.0.3:5000 backup; # 降级实例:功能精简但能扛
}
18.6 健康检查相关的日志与监控
# 在日志里记录实际命中的后端,才能看出负载分布和故障转移
log_format upstream_log '$remote_addr [$time_local] "$request" '
'$status '
'upstream=$upstream_addr ' # 实际处理的后端
'ustatus=$upstream_status ' # 后端返回码
'urt=$upstream_response_time ' # 后端耗时
'rt=$request_time'; # 总耗时
access_log /var/log/nginx/access.log upstream_log;
$upstream_addr 会显示多个用逗号分隔的地址:
upstream=10.0.0.1:5000, 10.0.0.2:5000 ustatus=502, 200 urt=0.001, 0.180
说明第一个后端返回 502,自动换到第二个成功。这是验证故障转移是否真的生效最直接的办法。
18.7 后端挂掉后的排查顺序
- 看 Nginx 错误日志
sudo tail -50 /var/log/nginx/error.logconnect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, server: example.com, request: "GET /api/ping HTTP/1.1", upstream: "http://127.0.0.1:5000/api/ping", host: "example.com" - 确认后端进程是否在跑
systemctl is-active myapp ss -lntp | grep 5000 - 从 Nginx 所在机器直连后端(排除网络问题)
curl -v http://127.0.0.1:5000/api/ping - 看 access_log 里的 upstream 字段,确认是"全部后端都失败"还是"某台失败"
sudo awk '{print $NF}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
| error.log 里的关键词 | 含义 |
|---|---|
Connection refused | 后端没监听那个端口 / 进程挂了 |
Connection timed out | 网络不通,或后端排队太深 |
upstream timed out | 后端处理太慢,超过 proxy_read_timeout |
no live upstreams | 所有后端都被标记不可用 |
upstream prematurely closed | 后端处理中主动断开(常是崩溃) |
upstream sent invalid header | 后端返回了非法响应头(如中文 key) |
SSL_do_handshake() failed | 后端 HTTPS 握手失败(证书/协议不匹配) |
第五部分 · HTTPS 与安全
19TLS/SSL 配置实战
19.1 HTTPS 握手与证书基础
- TLS 是 SSL 的继任者。现在说 "SSL 证书" 其实都是 TLS 证书,术语混用是历史习惯
- 证书链:你的证书 → 中间 CA → 根 CA。必须把中间证书一起发,所以 Nginx 要用
fullchain.pem而不是cert.pem - SNI:客户端在握手时告诉服务器"我要访问哪个域名",一个 IP 才能托管多张证书
19.2 用 Certbot 免费签证书(Let's Encrypt)
# Ubuntu / Debian
sudo apt install -y certbot python3-certbot-nginx
# CentOS
sudo dnf install -y certbot python3-certbot-nginx
certbot --version
# 前提:域名已解析到本机,且 80 端口可访问(做 HTTP-01 校验)
sudo certbot --nginx -d example.com -d www.example.com
# 非交互式(脚本里用)
sudo certbot --nginx \
-d example.com -d www.example.com \
--non-interactive \
--agree-tos \
-m you@example.com \
--redirect
# 只签证书不改配置
sudo certbot certonly --nginx -d example.com
- 在 server 块里插入
listen 443 ssl;和ssl_certificate等指令 - 添加一个 80 端口的 server 块做跳转
- 给改动行加
# managed by Certbot注释
# 1. Nginx 里先放好挑战目录
server {
listen 80;
server_name example.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# 2. 签发(webroot 模式不会碰你的配置)
sudo certbot certonly --webroot \
-w /var/www/html \
-d example.com -d www.example.com
# 3. 手工在 server 块里配证书路径
# 泛域名必须用 DNS-01,因为它要验证 *.example.com
# 以 DNSPod 为例(需 API 密钥)
sudo certbot certonly \
--dns-dnspod \
--dns-dnspod-credentials /root/.secrets/dnspod.ini \
-d example.com -d "*.example.com"
# /root/.secrets/dnspod.ini 内容:
# dns_dnspod_api_id = 12345
# dns_dnspod_api_key = xxxxxxxxxxxxxxxx
chmod 600 /root/.secrets/dnspod.ini
| 验证方式 | 要求 | 能签泛域名 | 适合 |
|---|---|---|---|
| HTTP-01 | 80 端口公网可达 | ❌ | 普通域名,最简单 |
| DNS-01 | DNS API 密钥 | ✅ | 泛域名、内网机器、通配证书 |
| TLS-ALPN-01 | 443 端口可达 | ❌ | 只能用 443 的场景 |
-d,签出来的是一张带多个 SAN 的证书,不是多张:
sudo certbot --nginx -d a.example.com -d b.example.com -d c.example.com
泛域名(*.example.com)相比多个 SAN,优势只是"以后加子域不用重签"。子域数量固定时,多 SAN 更简单,也不用管 DNS API 密钥。
19.3 推荐的 TLS 配置
server {
listen 443 ssl;
http2 on; # Nginx 1.25.1+;老版本写 listen 443 ssl http2;
server_name example.com;
# ── 证书(必须用 fullchain)──
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ── 协议版本:只留 TLS 1.2 / 1.3 ──
ssl_protocols TLSv1.2 TLSv1.3;
# ── 加密套件(让 Nginx 决定优先级)──
ssl_prefer_server_ciphers off; # TLS 1.3 下建议 off,让客户端选(更快)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# ── 会话复用(减少重复握手)──
ssl_session_cache shared:SSL:10m; # 10MB 约存 4 万个会话
ssl_session_timeout 1d;
ssl_session_tickets off; # 关了更安全(前向保密),开了性能好
# ── OCSP Stapling(加速证书状态校验)──
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 223.5.5.5 119.29.29.29 valid=300s;
resolver_timeout 5s;
# ── ECDH 曲线 ──
ssl_ecdh_curve X25519:secp256r1:secp384r1;
ssl_dhparam /etc/nginx/ssl/dhparam.pem; # 使用 DHE 套件才需要
# ── 安全响应头 ──
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / { try_files $uri $uri/ =404; }
}
/etc/letsencrypt/ 下会有:
options-ssl-nginx.conf—— 由 certbot 维护的推荐 TLS 参数ssl-dhparams.pem—— 预生成的 DH 参数(2048 位,生成一次要几分钟)
include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 就够了,不用自己手写那一大段。
19.4 生成 dhparam(如果要用)
# 2048 位是当前最低安全标准,生成约需 1-5 分钟
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048
# 4096 位更安全但生成可能需要 30 分钟以上,一般没必要
# 注意:TLS 1.3 不使用自定义 DH 参数,只有 TLS 1.2 的 DHE 套件才用
ssl_dhparam 根本用不到。省略它能省掉一个配置项和几分钟等待。只有需要兼容很老的客户端(如 Java 6、老 Android)才配 DHE。
19.5 证书自动续期
# Let's Encrypt 证书有效期 90 天,certbot 会自动建定时任务
systemctl list-timers | grep certbot
systemctl status certbot.timer
# 手动测试续期(不会真的续,只演练)
sudo certbot renew --dry-run
# 手动强制续期
sudo certbot renew --force-renewal
# 续期后自动 reload nginx(certbot 默认会做,但建议显式配)
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
systemctl is-active certbot.timer必须是activecertbot renew --dry-run必须成功(很多问题只有演练才暴露)- 续期后 nginx 要 reload,否则仍在用旧证书(certbot 的 nginx 插件会自动做,webroot 模式不会)
# 证书剩余天数(<15 天就该告警)
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -checkend 1296000 \
&& echo "✅ 剩余超过 15 天" || echo "⚠️ 即将过期"
19.6 验证 TLS 配置
# 看证书信息
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
# 测试支持的协议版本
for v in tls1_2 tls1_3; do
printf "%-8s " "$v"
echo | openssl s_client -connect example.com:443 -servername example.com -$v 2>/dev/null \
| grep -q "Verify return code: 0" && echo "✅ 支持" || echo "❌ 不支持"
done
# 检查证书链是否完整(比浏览器严格)
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null \
| grep -c "BEGIN CERTIFICATE"
# 应输出 2 或 3(叶子证书 + 中间证书)
- SSL Labs(qualys.com/ssllabs)—— 最权威,会指出具体问题
testssl.sh—— 命令行工具,能本地跑
20HTTP 跳转 HTTPS 与 HSTS
20.1 标准跳转配置
# HTTP → HTTPS 跳转
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# 302 是临时,301 是永久(SEO 上推荐 301)
return 301 https://$host$request_uri;
}
$host:用客户端请求的域名跳转。多域名共用一个 server 块时必须用它$server_name:只用第一个 server_name。多域名时会跳错(www 的请求被跳到非 www)
$host。如果想把 www 统一规范到主域(或反过来),见下一节。
20.2 www 与非 www 的规范化
# 主站
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/site;
location / { try_files $uri $uri/ =404; }
}
# www 跳主域(证书必须同时包含 example.com 和 www.example.com)
server {
listen 443 ssl;
http2 on;
server_name www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
return 301 https://example.com$request_uri;
}
www.example.com 的请求会先做 TLS 握手,握手时就要用对证书。如果证书里只有 example.com,用户访问 https://www.example.com 会先看到证书错误,跳转根本没机会发生。所以签证书时要一起签:
sudo certbot --nginx -d example.com -d www.example.com --expand
--expand 表示"扩展已有证书",不会新建一套。
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
if ($host = www.example.com) {
return 301 https://example.com$request_uri;
}
root /var/www/site;
location / { try_files $uri $uri/ =404; }
}
20.3 HSTS:让浏览器记住"只用 HTTPS"
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
| 参数 | 含义 |
|---|---|
max-age=31536000 | 浏览器记住这个策略的秒数(1 年) |
includeSubDomains | 所有子域名同样强制 HTTPS |
preload | 申请加入浏览器内置的 HSTS 列表(需在 hstspreload.org 提交) |
max-age 期限内:
- 用户访问
http://时浏览器自动改成 https,且不允许用户点击"继续访问" - 证书一旦过期或配错,站点会彻底打不开,用户无法绕过
includeSubDomains会波及所有子域——某个子域没配 HTTPS 就会直接不可访问
- 先只加
max-age=300(5 分钟),观察几天确认没异常 - 逐步提到
86400(1 天)→604800(1 周) - 确认所有子域都支持 HTTPS 后,再加
includeSubDomains - 最后才考虑
preload
20.4 混合内容问题
HTTPS 页面里引用了 http:// 资源,浏览器会拦截(图片/脚本)或警告。三种处理方式:
add_header Content-Security-Policy "upgrade-insecure-requests" always;
sub_filter 'http://example.com' 'https://example.com';
sub_filter_once off; # 替换所有出现处,不只是第一处
sub_filter_types text/html text/css application/javascript;
proxy_set_header Accept-Encoding ""; # ★ 关键:必须让后端不压缩,否则无法改写
- 必须先关闭后端压缩(
Accept-Encoding ""),因为压缩后的字节流没法做文本匹配 - 如果启用了
proxy_buffering off或响应很大,可能因缓冲区不足而漏替换 - 性能有损耗,用在关键页面即可,不要全局开
proxy_set_header X-Forwarded-Proto $scheme;
# 后端框架据此生成 https:// 开头的绝对链接
# ASP.NET Core:
# app.UseForwardedHeaders(new ForwardedHeadersOptions {
# ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor
# });
# Express:
# app.set('trust proxy', 1);
21访问控制与 Basic Auth
21.1 基于 IP 的访问控制
location /admin/ {
# allow 和 deny 按顺序匹配,第一条命中的生效
allow 10.0.0.0/8; # 内网整段
allow 192.168.1.100; # 单个 IP
allow 2001:db8::/32; # IPv6
deny all; # 兜底拒绝
proxy_pass http://127.0.0.1:5000;
}
# ✅ 正确:先具体允许,最后兜底拒绝
allow 10.0.0.1;
deny all;
# ❌ 错误:deny all 放前面,后面 allow 永远不会被检查
deny all;
allow 10.0.0.1; # 这行失效!
server {
listen 127.0.0.1:8080; # 只有本机能连,公网/内网都访问不到
server_name _;
location /metrics { proxy_pass http://127.0.0.1:9090; }
}
$remote_addr 是代理的 IP,基于 IP 的限制会全部失效——要么全放行要么全拒绝。必须先配 real_ip 模块把真实 IP 还原:
set_real_ip_from 5.5.5.5; # 你的 CDN 回源 IP
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 此后 allow/deny、$remote_addr 都基于真实客户端 IP
21.2 Basic Auth
# 需要 htpasswd 工具
sudo apt install -y apache2-utils # Ubuntu
# sudo dnf install -y httpd-tools # CentOS
# 创建第一个用户(-c 会新建文件,会覆盖已有内容!)
sudo htpasswd -c /etc/nginx/.htpasswd admin
# 输入两次密码
# 追加更多用户(不能用 -c,否则清空之前的)
sudo htpasswd /etc/nginx/.htpasswd user2
# 查看(密码是 bcrypt 哈希,看不懂是正常的)
cat /etc/nginx/.htpasswd
# admin:$apr1$xxx$yyyyy
htpasswd -c 的 c 是 create。第二次执行带 -c 会把之前所有用户删掉。加用户一律不加 -c。这个坑在"为什么突然登不上了"的案例里非常常见。
location /admin/ {
auth_basic "Restricted Area"; # 浏览器弹窗的提示文字
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:5000;
}
# 全局密码保护(整个站点)
server {
auth_basic "Site Locked";
auth_basic_user_file /etc/nginx/.htpasswd;
}
# 精确控制哪些路径免密(常见需求:健康检查接口开放)
location = /health {
auth_basic off;
return 200 "ok";
}
# 不带凭据 → 401
curl -i http://127.0.0.1/admin/
# 带凭据
curl -i -u admin:密码 http://127.0.0.1/admin/
# 用 -I 只看头
curl -sI -u admin:密码 http://127.0.0.1/admin/ | head -3
21.3 限制请求方法与隐藏目录
# 只允许 GET / HEAD(静态站点常用)
if ($request_method !~ ^(GET|HEAD)$) {
return 405;
}
# 禁止访问隐藏文件和目录(.git、.env、.htaccess)
location ~ /\.(?!well-known) {
deny all;
access_log off;
log_not_found off;
}
# 禁止访问备份文件和编辑器临时文件
location ~* \.(bak|backup|old|orig|save|swp|tmp|sql|log)$ {
deny all;
}
# 只允许特定扩展名的文件存在(白名单,更安全)
location ~* ^/uploads/ {
location ~* \.(jpg|jpeg|png|gif|webp|pdf)$ { } # 空块 = 允许
location ~ .* { deny all; }
}
.git/(部署时直接 git clone 了),攻击者可以把整个源码仓库下载下来,里面有历史提交、数据库密码、API 密钥。必须屏蔽。
# 快速自查
curl -sI https://example.com/.git/config
# 返回 200 就是严重的漏洞
21.4 CORS 跨域
# 场景:Web 前端在一个域,API 在另一个域
# ── 方式 1:简单请求的响应头(在 API 的 location 里)──
location /api/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
# 预检请求直接返回 204,不转发给后端
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "https://app.example.com";
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
proxy_pass http://127.0.0.1:5000;
}
Access-Control-Allow-Origin: *—— 任何网站都能调用你的 API。只能用于完全公开的接口*与Allow-Credentials: true同时出现 —— 浏览器会直接拒绝,配置无效。带凭据时必须回显具体域名
map 做白名单回显:
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
"~^https://[a-z0-9]+\.example\.com$" $http_origin; # 子域通配
}
server {
location /api/ {
# $cors_origin 为空时不会加这个头,等价于不放行
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary Origin always; # ★ 缓存要考虑 Origin 差异
proxy_pass http://127.0.0.1:5000;
}
}
22限流与限并发
22.1 两种限制:速率 vs 并发
| limit_req(限速) | limit_conn(限并发) | |
|---|---|---|
| 限制什么 | 单位时间内的请求数 | 同时打开的连接数 |
| 计量单位 | 请求/秒(r/s)或 请求/分(r/m) | 连接数 |
| 适用 | 防爬虫、防接口滥用、保护后端 | 防大文件下载占满带宽、防慢连接攻击 |
| 超限响应 | 默认 503 | 默认 503 |
22.2 limit_req 完整用法
http {
# 语法:limit_req_zone 键 变量 zone=名称:内存大小 rate=速率
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone $server_name zone=perserver:10m rate=1000r/s;
# 如果在前置代理后面,要用真实 IP
# limit_req_zone $http_x_forwarded_for zone=perip:10m rate=10r/s;
}
$binary_remote_addr 每个 IP 占 64 字节(IPv4)或 128 字节(IPv6)。10m = 10MB ≈ 可存约 16 万个 IP 的状态。用
$binary_remote_addr 而不是 $remote_addr 是因为它省内存(二进制 vs 字符串)。
location /api/ {
# 速率 10r/s,突发允许 20 个请求排队
limit_req zone=perip burst=20 nodelay;
proxy_pass http://127.0.0.1:5000;
}
22.3 burst 与 nodelay 的关系(最关键)
rate=1r/s burst=100 不带 nodelay:第 100 个请求要等 100 秒才被处理。而这个等待占着 worker 的连接。攻击者只要持续打满 burst,就能用极少的连接把 worker 的 worker_connections 配额耗光。面向公网的服务,绝大多数情况应该加 nodelay。
22.4 限流状态码与日志
location /api/ {
limit_req zone=perip burst=20 nodelay;
# 默认超限返回 503,改成 429 更符合语义
limit_req_status 429;
# 自定义超限响应
error_page 429 = @too_many;
}
location @too_many {
add_header Retry-After 5 always;
add_header Content-Type application/json always;
return 429 '{"code":429,"message":"请求过于频繁,请稍后重试"}';
}
error.log 里,access.log 也会有一条记录(状态码是 429 或配置的值)。想单独统计:
sudo grep -c "limiting requests" /var/log/nginx/error.log
# 输出示例:
# 2026/09/15 17:30:00 [error] 1234#0: *567 limiting requests, excess: 1.500 by zone "perip",
# client: 1.2.3.4, server: example.com, request: "GET /api/list HTTP/1.1"
22.5 limit_conn 限并发
http {
# 按 IP 限制并发连接数
limit_conn_zone $binary_remote_addr zone=addr:10m;
# 按 server 限制总连接数(保护单站不占满全局)
limit_conn_zone $server_name zone=perserver:10m;
}
server {
# 每个 IP 最多 10 个并发连接
limit_conn addr 10;
# 每台服务器总共最多 1000 个
limit_conn perserver 1000;
limit_conn_status 429;
# 单个连接的下载速度上限(字节/秒)
limit_rate 512k;
# 前 1MB 不限速,之后才限(让首屏快速加载)
limit_rate_after 1m;
location /download/ {
# 下载接口限得更严
limit_conn addr 2;
limit_rate 256k;
alias /data/files/;
}
}
limit_rate_after 让用户先快速拿到前 1MB(首屏体验好),之后才限速。视频网站常用这个策略——观众不需要等整个文件下完,前面的缓冲已经够了。
22.6 组合策略:一套完整的限流配置
http {
# ── 声明限流区 ──
limit_req_zone $binary_remote_addr zone=req_perip:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=req_strict:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;
limit_conn_zone $server_name zone=conn_perserver:10m;
server {
listen 443 ssl;
server_name example.com;
limit_conn conn_perip 20;
limit_conn conn_perserver 2000;
limit_conn_status 429;
# 静态资源:宽松
location /static/ {
limit_req zone=req_perip burst=50 nodelay;
expires 1y;
}
# 普通 API:适中
location /api/ {
limit_req zone=req_perip burst=30 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# 登录/短信等敏感接口:严格
location ~ ^/api/(login|register|sms|reset) {
limit_req zone=req_strict burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend;
}
# 下载:限并发 + 限速
location /download/ {
limit_conn conn_perip 2;
limit_rate_after 1m;
limit_rate 512k;
alias /data/files/;
}
}
}
- 在上一层负载均衡器做(如云厂商的 WAF/API 网关)
- 或用 OpenResty + Redis 做分布式计数
23常见安全加固清单
23.1 基础加固项
http {
server_tokens off;
# 响应头会变成:Server: nginx (不再带版本号)
}
# 隐藏文件(保留 ACME 挑战目录)
location ~ /\.(?!well-known) { deny all; }
# 备份/临时文件
location ~* \.(bak|old|orig|save|swp|swo|tmp|sql|gz|tar|zip|log)$ { deny all; }
# 编辑器与 IDE 目录
location ~* /(\.git|\.svn|\.hg|\.idea|\.vscode)/ { deny all; }
# 常见配置文件
location ~* ^/(wp-config|config|settings|\.env|composer\.(json|lock)|package-lock\.json) {
deny all;
}
# 全局默认 10m,上传接口单独放大
client_max_body_size 10m;
client_body_buffer_size 128k;
location /api/upload {
client_max_body_size 100m;
proxy_request_buffering off; # 大文件不缓冲在内存/磁盘,直接透传
proxy_pass http://backend;
}
client_body_temp),磁盘 IO 翻倍。关掉后数据流式传给后端,省一半磁盘写入、降低延迟。代价是后端失败时无法重试(因为请求体已经流走了)。
# Slowloris 攻击:用极慢的速度发请求头,占住连接不放
# 收紧这些超时能有效缓解
client_header_timeout 10s;
client_body_timeout 15s;
keepalive_timeout 30s;
send_timeout 15s;
# 限制请求头大小(防超大 header 攻击)
large_client_header_buffers 4 8k;
client_header_buffer_size 4k;
client_body_buffer_size 128k;
# 抽成 snippet,各 location 用 include 引入
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# 按需,可能有兼容风险:
# add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:;" always;
| 响应头 | 防什么 |
|---|---|
X-Content-Type-Options: nosniff | 浏览器猜 MIME 类型导致的上传文件被当脚本执行 |
X-Frame-Options | 点击劫持(被 iframe 嵌套) |
Content-Security-Policy | XSS、资源注入(最强但最难配) |
Referrer-Policy | URL 中的敏感参数泄漏给第三方 |
Permissions-Policy | 第三方脚本滥用摄像头/麦克风/定位 |
23.2 一个完整的加固配置
# /etc/nginx/snippets/security.conf
# ── 版本隐藏 ──
server_tokens off;
# ── 敏感路径 ──
location ~ /\.(?!well-known) { deny all; access_log off; log_not_found off; }
location ~* \.(bak|old|orig|save|swp|tmp|sql|log|tar|gz|zip)$ { deny all; }
location ~* /(\.git|\.svn|\.idea|\.vscode)/ { deny all; }
# ── 超时 ──
client_header_timeout 10s;
client_body_timeout 15s;
send_timeout 15s;
keepalive_timeout 30s;
# ── 请求体 ──
client_max_body_size 10m;
client_body_buffer_size 128k;
large_client_header_buffers 4 8k;
# ── 安全头 ──
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── 隐藏后端标识 ──
proxy_hide_header X-Powered-By;
proxy_hide_header Server;
23.3 端口暴露最小化
# ❌ 危险:后端直接监听 0.0.0.0,公网可绕过 Nginx 直连
# ASP.NET Core: app.Run("http://0.0.0.0:5000")
# Node: app.listen(5000) // 默认就是 0.0.0.0!
# Python: app.run(host='0.0.0.0')
# ✅ 正确:只监听本机,强制走 Nginx
# ASP.NET Core: --urls "http://127.0.0.1:5000"
# Node: app.listen(5000, '127.0.0.1')
# Python: app.run(host='127.0.0.1')
自查方法(从外部机器测,不要在本机测):
# 看哪些端口真的对外可达
for p in 5000 5001 666 3000 8080; do
timeout 3 bash -c "</dev/tcp/你的公网IP/$p" 2>/dev/null \
&& echo " :$p ⚠️ 公网可达" || echo " :$p ✅ 已阻断"
done
两层防护都要做:(1) 应用只监听 127.0.0.1;(2) 安全组/防火墙不开放这些端口。只做一层都有被绕过或误放行的风险。
23.4 加固检查清单
- ☐
server_tokens off已设置 - ☐ 隐藏文件、备份文件、
.git已屏蔽(curl -I /.git/config应返回 403/404) - ☐ 后端端口不对外暴露(从外部实测)
- ☐ HTTPS 已启用,HTTP 301 跳转
- ☐ TLS 只允许 1.2 / 1.3
- ☐ 证书自动续期已配置且
--dry-run通过 - ☐ 安全响应头(HSTS / nosniff / X-Frame-Options)已加,且用
curl -I验证实际返回 - ☐ 敏感接口(登录、短信)有限流
- ☐
client_max_body_size已设为合理值(默认 1m 常常太小,但也不该无限大) - ☐ 错误页面不泄漏堆栈信息(
proxy_intercept_errors+ 自定义 5xx) - ☐ 日志中有
$request_id便于追踪 - ☐ 已配置日志切割(见第 29 章),避免日志撑爆磁盘
- ☐
nginx -t通过,配置有备份
第六部分 · 缓存与性能
24浏览器缓存策略
24.1 两种缓存机制
| 强缓存 | 协商缓存 | |
|---|---|---|
| 靠什么 | Cache-Control: max-age / Expires | ETag / Last-Modified |
| 是否发请求 | 不发,直接用本地副本 | 发请求,服务端可能返回 304 |
| 速度 | 最快(0 请求) | 较快(1 个往返,无响应体) |
| 适用 | 带 hash 的静态资源 | HTML、可能变的资源 |
24.2 Cache-Control 常用指令
| 指令 | 含义 |
|---|---|
max-age=秒 | 强缓存有效期,从响应生成时算起 |
s-maxage=秒 | 只对共享缓存(CDN/代理)生效,优先级高于 max-age |
no-cache | 可以缓存,但每次必须验证(走协商缓存) |
no-store | 完全不许缓存,连磁盘都不写 |
private | 只允许浏览器缓存,CDN 不许缓存(含用户数据的响应) |
public | 允许任何缓存(含 CDN) |
must-revalidate | 过期后必须验证,不允许用过期副本 |
immutable | 有效期内容永不变化,刷新时也不重新验证 |
stale-while-revalidate=秒 | 过期后仍可先用旧的,同时后台更新 |
no-cache= "可以存,但用之前必须问服务器"。省的是响应体传输(304 无 body)no-store= "完全不要存"。每次都是完整请求
no-store,否则用户登出后按后退键还能看到,或共用电脑上的下一个人能看到。
24.3 按文件类型的推荐策略
server {
root /var/www/site;
# ── ① HTML:绝不缓存(否则用户永远看不到新版本)──
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate" always;
etag on;
}
# ── ② 带 hash 的静态资源:永久缓存 ──
# 如 app.a3f9c2.js —— 内容变了 hash 就变,文件名也变,所以可以永久缓存
location ~* \.[0-9a-f]{6,}\.(js|css|woff2?|png|jpg|jpeg|gif|svg|webp)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
access_log off;
}
# ── ③ 不带 hash 的静态资源:中等缓存 ──
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
access_log off;
}
# ── ④ 字体:长缓存(字体很少变)──
location ~* \.(woff|woff2|ttf|otf|eot)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000" always;
add_header Access-Control-Allow-Origin "*" always; # 字体跨域需要
access_log off;
}
# ── ⑤ API:不缓存(但要区分公开数据)──
location /api/ {
add_header Cache-Control "no-store" always;
proxy_pass http://127.0.0.1:5000;
}
# ── ⑥ 公开的只读数据:短缓存 + CDN 缓存 ──
location ~ ^/api/(public|config|categories) {
add_header Cache-Control "public, max-age=60, s-maxage=300" always;
proxy_pass http://127.0.0.1:5000;
}
# ── ⑦ 用户相关数据:私有,绝不进 CDN ──
location ~ ^/api/(me|profile|orders) {
add_header Cache-Control "private, no-store" always;
proxy_pass http://127.0.0.1:5000;
}
}
main.a3f9c2.js。内容一变,文件名就变,于是可以放心设 immutable + 1 年。配合 HTML 的
no-cache,就实现了完美的缓存策略:HTML 每次验证(很小),JS/CSS 永不重复下载。这是所有现代网站的标配。
24.4 expires 与 add_header 的关系
# expires 会自动生成两个头
expires 1y;
# 等价于:
# Expires: <当前时间 + 1年>
# Cache-Control: max-age=31536000
# 想要更精细的控制(如加 public、immutable),就要自己写 Cache-Control
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
# ⚠️ 注意:这样会同时出现两个 Cache-Control 头!
# ✅ 更干净的做法:只用 add_header
add_header Cache-Control "public, max-age=31536000, immutable" always;
expires 又用 add_header Cache-Control 会产生两个同名头,浏览器行为不确定。要么只用 expires,要么只用 add_header。
# 验证实际返回的头
curl -sI http://example.com/static/app.js | grep -i "cache-control\|expires"
24.5 304 协商缓存是怎么工作的
# 第一次响应
HTTP/1.1 200 OK
ETag: "5f8a3c2b-1a4"
Last-Modified: Mon, 15 Sep 2026 09:00:00 GMT
Content-Type: text/html
Content-Length: 15320
# 第二次请求(浏览器自动加上验证头)
GET /index.html HTTP/1.1
If-None-Match: "5f8a3c2b-1a4"
If-Modified-Since: Mon, 15 Sep 2026 09:00:00 GMT
# 服务器判断没变化
HTTP/1.1 304 Not Modified
ETag: "5f8a3c2b-1a4"
# ↑ 注意:没有响应体!省掉了 15KB 传输
| 头 | 由谁生成 | 特点 |
|---|---|---|
ETag | Nginx 根据文件 inode + 修改时间 + 大小自动生成 | 精确,但多机部署时同一文件在不同机器上 ETag 不同 |
Last-Modified | 文件修改时间 | 精度只到秒;对"同一秒内改两次"无法区分 |
# 关闭 ETag(多机部署时避免不一致)
etag off;
# 关闭 Last-Modified(安全性考虑,避免暴露文件修改时间)
if_modified_since off;
25压缩:gzip 与 brotli
25.1 gzip 基础配置
http {
gzip on;
gzip_comp_level 6; # 1-9,越大压得越小但越耗 CPU
gzip_min_length 1024; # 小于 1KB 不压(压了反而变大)
gzip_vary on; # 加 Vary: Accept-Encoding,让 CDN 正确缓存
gzip_proxied any; # 对反代响应也压缩
gzip_disable "msie6"; # 老 IE 有 bug
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
application/rss+xml
application/atom+xml
image/svg+xml
font/ttf
font/otf
application/vnd.ms-fontobject;
# ⚠️ text/html 是默认就压的,不用列也不该列
# ⚠️ 图片/视频/压缩包本身已压缩,不要加(浪费 CPU 且可能变大)
}
jpg / png / webp / mp4 / zip / gz 这些格式内部已经压缩过,再 gzip 一次:
- 几乎不会变小(可能变大 1-2%)
- 白白消耗 CPU
25.2 压缩级别怎么选
| 级别 | 压缩率 | CPU 开销 | 建议 |
|---|---|---|---|
| 1 | 较低 | 极低 | CPU 极度紧张时 |
| 4-5 | 良好 | 低 | 高并发场景推荐 |
| 6 | 接近最优 | 中等 | 默认推荐(性价比拐点) |
| 7-9 | 略好一点点 | 高很多 | 不推荐 |
预压缩是更好的方案:构建时用最高级别压好
.gz 文件,运行时直接发送,零 CPU 开销。
25.3 预压缩(gzip_static)
# 1. 构建时生成 .gz 文件(可以用最高压缩级别,反正只做一次)
gzip -9 -k -f /var/www/site/static/*.js /var/www/site/static/*.css
# 2. Nginx 配置(需 ngx_http_gzip_static_module,官方包已内置)
location ^~ /static/ {
gzip_static on; # 有 .gz 就直接发,没有则回退到动态压缩
gzip_static always; # always = 即使客户端不支持也发(一般不用)
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# 3. 也可以和 try_files 配合
location / {
try_files $uri.gz $uri =404;
}
ls -lh /var/www/site/static/app.js*
# -rw-r--r-- 1 root root 245K app.js
# -rw-r--r-- 1 root root 68K app.js.gz ← 压掉了 72%
nginx -T | grep gzip_static
25.4 Brotli(比 gzip 好 15-25%)
# Nginx 官方包不含 brotli,需要额外安装
# Ubuntu(需先加 nginx 官方源,第三方模块与官方包版本要匹配)
# 检查是否已有
nginx -V 2>&1 | grep -o with-http_brotli
# 编译方式(简要):
# ./configure --add-module=/path/to/ngx_brotli \
# --with-compat ...
# 或用 openresty / 预编译包
# ── 配置 ──
http {
brotli on;
brotli_comp_level 6; # 1-11,注意:brotli 级别比 gzip 更耗 CPU
brotli_min_length 1024;
brotli_types
text/plain text/css text/xml application/javascript
application/json application/xml image/svg+xml
font/woff2;
}
brotli -q 11 -k -f app.js # 生成 app.js.br
# Nginx 侧
brotli_static on;
预压缩可以用最高级别 11,压缩率比 gzip 好 20% 左右,且运行时零开销。
25.5 压缩效果实测
# 起一个测试文件
python3 -c "
import random, string
words = ['function','return','const','let','var','async','await','export','import']
with open('/tmp/test.js','w') as f:
for i in range(5000):
f.write(' '.join(random.choices(words, k=10)) + ';\n')
"
ls -lh /tmp/test.js
# 分别用不同级别压缩,看效果
for lv in 1 6 9; do
gzip -$lv -c /tmp/test.js > /tmp/test.js.$lv.gz
printf "gzip -%-3s %s\n" "$lv" "$(du -h /tmp/test.js.$lv.gz | cut -f1)"
done
printf "原始 %s\n" "$(du -h /tmp/test.js | cut -f1)"
结论:1 → 6 收益巨大(92K → 68K),6 → 9 几乎没收益(68K → 66K,只差 3%)。这就是为什么推荐级别 6。
# 对比有无 Accept-Encoding 的响应大小
echo "未压缩:" && curl -s -o /dev/null -w "%{size_download} 字节\n" https://example.com/
echo "gzip :" && curl -s -o /dev/null -w "%{size_download} 字节\n" \
-H "Accept-Encoding: gzip" https://example.com/
# 看响应头确认
curl -sI -H "Accept-Encoding: gzip" https://example.com/ \
| grep -iE "content-encoding|content-length|vary"
proxy_buffering off会让 gzip 对代理响应静默失效(配置没报错,但实际不压缩)- 流式接口(SSE、LLM 输出)为了实时性必须关缓冲,那就只能放弃压缩,或让后端自己压
curl -sI -H "Accept-Encoding: gzip" /api/xxx 看有没有 Content-Encoding。
26代理缓存 proxy_cache
26.1 缓存能带来什么
Nginx 可以把后端响应缓存到磁盘,后续相同请求直接由 Nginx 返回,不碰后端。典型效果:
| 指标 | 未缓存 | 命中缓存 |
|---|---|---|
| 响应时间 | 150ms(含后端处理) | 1-3ms |
| 后端负载 | 100% | 降 80-95% |
| 并发能力 | 受后端限制 | 受 Nginx 限制(强得多) |
26.2 完整配置
http {
# 缓存目录、层级、内存索引区、上限、清理策略
proxy_cache_path /var/cache/nginx/api
levels=1:2 # 二级目录,避免单目录文件过多
keys_zone=api_cache:10m # 内存索引区,10m 约存 8 万个 key
max_size=1g # 磁盘缓存上限
inactive=60m # 60 分钟未被访问就删除
use_temp_path=off; # 直接写入最终目录,省一次拷贝
# 可以定义多个,用于不同场景
proxy_cache_path /var/cache/nginx/static
levels=1:2 keys_zone=static_cache:10m max_size=5g inactive=7d use_temp_path=off;
# 缓存键:决定"什么算同一个请求"
proxy_cache_key "$scheme$request_method$host$request_uri";
}
sudo mkdir -p /var/cache/nginx/api /var/cache/nginx/static
sudo chown -R www-data:www-data /var/cache/nginx
# 忘了建目录会报错:
# mkdir() "/var/cache/nginx/api" failed (2: No such file or directory)
location /api/public/ {
proxy_pass http://backend;
# ── 启用缓存 ──
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
# ── 哪些状态码缓存多久 ──
proxy_cache_valid 200 302 10m; # 成功响应缓存 10 分钟
proxy_cache_valid 404 1m; # 404 也缓存短时间,防止穿透
proxy_cache_valid any 1m; # 其他状态码缓存 1 分钟
# ── 缓存使用策略 ──
proxy_cache_methods GET HEAD; # 哪些方法可缓存(默认就这俩)
proxy_cache_min_uses 2; # 被请求 2 次才缓存(防一次性请求占空间)
# ── 绕过缓存的条件 ──
proxy_cache_bypass $cookie_session $arg_nocache;
proxy_no_cache $cookie_session $arg_nocache;
# ── 后端挂了时用过期缓存 ──
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # 后台异步更新过期缓存
# ── 防止缓存击穿:同一时刻只有一个请求回源 ──
proxy_cache_lock on;
# ── 调试用:把缓存状态加到响应头 ──
add_header X-Cache-Status $upstream_cache_status always;
}
26.3 缓存键的设计
# ── 默认(实际是这个)──
proxy_cache_key "$scheme$proxy_host$request_uri";
# ── 推荐:把方法也带上,避免混淆 ──
proxy_cache_key "$scheme$request_method$host$request_uri";
# ── 忽略某些查询参数(如跟踪参数)──
# 需求:?utm_source=xxx 不影响缓存命中
map $request_uri $cache_key_uri {
default $request_uri;
"~^(?<path>[^?]*)\?(.*)$" "$path"; # 去掉所有查询参数
}
# ── 只保留有效参数 ──
map $args $filtered_args {
default "";
"~*(?:^|&)page=([^&]*)" "page=$1";
"~*(?:^|&)sort=([^&]*)" "$filtered_args&sort=$1";
}
proxy_cache_key "$scheme$host$uri?$filtered_args";
- 键里包含时间戳、随机数、用户 token → 永远命中不了,缓存等于没开
- 键里漏掉影响内容的参数(如翻页的
page) → 第 2 页和第 1 页返回同样内容,是严重 bug
26.4 关键指令详解
| 指令 | 作用 | 注意 |
|---|---|---|
proxy_cache_valid | 按状态码设缓存时长 | 不写的话默认只缓存 200/301/302 |
proxy_cache_min_uses | 被访问 N 次后才缓存 | 防"只被访问一次的长尾"占空间 |
proxy_cache_bypass | 满足条件时跳过读取缓存,直接回源 | 用于强制刷新 |
proxy_no_cache | 满足条件时不存缓存 | 两个常一起用 |
proxy_cache_use_stale | 后端故障时使用过期缓存 | ★ 高可用关键 |
proxy_cache_background_update | 返回旧内容的同时后台更新 | 配合 stale 用,用户无等待 |
proxy_cache_lock | 同一 key 同时只允许一个请求回源 | ★ 防缓存击穿 |
proxy_cache_revalidate | 过期后带 If-Modified-Since 验证 | 省带宽 |
proxy_cache_convert_head | HEAD 转 GET 后缓存 | 默认 on |
# 场景:?nocache=1 时强制拿最新数据
location /api/public/ {
proxy_pass http://backend;
proxy_cache api_cache;
# bypass:跳过"读缓存",直接去后端
proxy_cache_bypass $arg_nocache;
# no_cache:不去"写缓存"
proxy_no_cache $arg_nocache;
# 为什么两个都要写?
# 只写 bypass:拿到了最新数据,但同时把它写进缓存(可能覆盖好的缓存)
# 只写 no_cache:仍然读到了旧缓存 → 用户看到的还是旧的
# 一起写才是"真正的绕过"
}
26.5 缓存状态解读
add_header X-Cache-Status $upstream_cache_status always;
| 状态 | 含义 | 是否健康 |
|---|---|---|
MISS | 缓存里没有,回源了 | 首次请求正常 |
HIT | 命中缓存,直接返回 | ✅ 目标状态 |
EXPIRED | 缓存过期,回源取新的 | 正常 |
STALE | 用了过期缓存(后端故障) | ⚠️ 后端有问题了 |
UPDATING | 正在后台更新,先返回旧的 | 正常 |
REVALIDATED | 后端返回 304,继续用缓存 | ✅ 高效 |
BYPASS | 被 bypass 规则跳过 | 配置生效 |
# 第一次(应该 MISS)
curl -sI http://127.0.0.1/api/public/list | grep -i x-cache
# 第二次(应该 HIT)
curl -sI http://127.0.0.1/api/public/list | grep -i x-cache
# 第三次连着看
for i in 1 2 3; do curl -sI http://127.0.0.1/api/public/list | grep -i x-cache; done
# 需要在日志里记录 $upstream_cache_status
log_format cache '$remote_addr "$request" $status cache=$upstream_cache_status';
access_log /var/log/nginx/access.log cache;
# 统计
sudo awk -F'cache=' '{print $2}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
健康的目标:HIT 占比 70% 以上。低于 50% 说明缓存键设计有问题,或缓存时间太短。
26.6 缓存的三个经典问题
① 缓存穿透
问题:大量请求查询"不存在的数据"(如不存在的 ID),缓存里没有、每次都要回源,缓存形同虚设。
# 对策:把 404 也缓存一小段时间
proxy_cache_valid 404 1m;
# 或让后端返回带空结果标记的 200,缓存 30s
② 缓存击穿
问题:某个热点 key 恰好过期,瞬间大量并发请求同时回源,把后端打挂。
# 对策 1:缓存锁(只放一个请求回源,其他等待)
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_lock_age 5s;
# 对策 2:返回旧内容,后台更新
proxy_cache_use_stale updating;
proxy_cache_background_update on;
③ 缓存雪崩
问题:大批缓存同时过期,请求全部涌向后端。
# 对策:在缓存键或过期时间上引入随机扰动
# Nginx 层面可以给不同路径设不同的缓存时长
proxy_cache_valid 200 10m;
# 更根本的解法是在应用层给过期时间加随机值(如 10min ± 2min)
27静态文件性能调优
27.1 三个必开的核心开关
http {
sendfile on; # ★ 零拷贝:文件数据不经用户态直接发到 socket
tcp_nopush on; # ★ 配合 sendfile:攒满一个 TCP 包再发,减少包数量
tcp_nodelay on; # ★ 长连接上不等待,立即发送(与上面看似矛盾,实则各管一段)
}
tcp_nopush:发送完整文件时,攒够一个 MSS 再发,减少包数量(吞吐优化)tcp_nodelay:发送零散小数据(如响应头、小 JSON)时,立即发送不等(延迟优化)
27.2 open_file_cache:减少磁盘 IO
http {
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
}
| 参数 | 作用 |
|---|---|
max=10000 | 最多缓存 1 万个文件描述符信息 |
inactive=30s | 30 秒内没被访问就移出缓存 |
open_file_cache_valid 60s | 每 60 秒检查一次文件是否变化 |
open_file_cache_min_uses 2 | 被访问 2 次以上才缓存(过滤长尾) |
open_file_cache_errors on | 也缓存"文件不存在"的结果(防大量 404 打磁盘) |
open()/stat() 系统调用。小文件巨多的站点(图片、图标、分片 JS)上效果明显,通常能降 20-30% 的磁盘 IO。
27.3 高并发下的关键参数
# ── 系统层(/etc/security/limits.conf 或 systemd override)──
# nginx 的 worker 需要足够的文件描述符
# /etc/systemd/system/nginx.service.d/limits.conf:
[Service]
LimitNOFILE=65535
# 内核参数 /etc/sysctl.conf
net.core.somaxconn = 65535 # listen 队列上限
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_tw_reuse = 1
net.core.netdev_max_backlog = 65535
fs.file-max = 2097152
# ── Nginx 层 ──
worker_processes auto; # = CPU 核数
worker_rlimit_nofile 65535; # 每个 worker 可打开的文件数
events {
worker_connections 10240; # 每 worker 连接数
multi_accept on; # 一次接受所有就绪连接
use epoll; # Linux 最优
}
http {
# 静态资源相关的缓冲
client_body_buffer_size 128k;
client_header_buffer_size 4k;
large_client_header_buffers 4 16k;
output_buffers 4 32k;
postpone_output 1460;
}
worker_processes × worker_connections。4 核 × 10240 = 4 万并发连接。但要注意
worker_connections 同时包含客户端连接和后端连接。如果每个请求都要反代到后端,实际能处理的客户端并发只有一半左右。还有一个隐含约束:
worker_rlimit_nofile 必须 ≥ worker_connections × worker_processes,否则受文件描述符限制。
27.4 大文件与流式传输
# ── 大文件下载:关闭缓冲,直接流 ──
location /download/ {
alias /data/files/;
sendfile on;
tcp_nopush on;
# 不落临时文件
proxy_max_temp_file_size 0;
output_buffers 1 128k;
# 支持断点续传(Nginx 默认支持 Range,但要确保没被中间层破坏)
add_header Accept-Ranges bytes;
expires 7d;
}
# ── 视频流:支持 Range 请求 ──
location /video/ {
alias /data/videos/;
mp4; # 需 ngx_http_mp4_module,优化 moov 位置
mp4_buffer_size 1m;
mp4_max_buffer_size 5m;
add_header Accept-Ranges bytes;
}
# 请求前 100 字节
curl -sI -H "Range: bytes=0-99" http://example.com/download/big.zip | head -5
# 应返回 206 Partial Content 而不是 200
# HTTP/1.1 206 Partial Content
# Content-Range: bytes 0-99/104857600
# Content-Length: 100
200 而不是 206,说明 Range 请求没被正确处理——视频无法拖动进度条、下载无法续传。常见原因是中间有代理层丢掉了 Range 头,或用了 proxy_buffering on 把响应攒起来了。
27.5 静态文件优化清单
- ☐
sendfile on(必开) - ☐
tcp_nopush on+tcp_nodelay on - ☐
open_file_cache已配置 - ☐
expires/Cache-Control按类型分策略 - ☐
gzip_static on+ 预压缩文件已生成 - ☐ 静态资源的 location 用
^~跳过正则匹配 - ☐
access_log off(静态资源不记日志,省 IO) - ☐
worker_rlimit_nofile与系统LimitNOFILE已调大 - ☐ 文件系统挂载参数带
noatime(省一次写入)
28全局参数与 worker 调优
28.1 worker_processes 怎么设
# 推荐:auto(= CPU 核数)
worker_processes auto;
# 手动指定(不推荐,除非有特殊考虑)
worker_processes 4;
# 查看核数
nproc
lscpu | grep "^CPU(s):"
例外情况:如果服务大量依赖磁盘 IO(如频繁读大文件),可以设为核数的 1.5-2 倍,让部分 worker 在等待 IO 时其他 worker 能顶上。
28.2 worker_connections 与并发上限
events {
worker_connections 10240;
multi_accept on;
use epoll;
}
# 全局最大并发 = worker_processes × worker_connections
# 但受 worker_rlimit_nofile 限制(必须 ≥ 上面那个乘积)
# 每个客户端请求如果都要反代到后端,会占用 2 个连接:
# 1 个来自客户端 + 1 个到后端
# 所以 worker_connections=10240 时,实际能处理的客户端并发约 5120
# 验证当前的连接使用情况
ss -s
# Total: 1234
# TCP: 856 (estab 412, closed 380, orphaned 12, timewait 380)
# ↑ 412 个已建立连接,其中一部分是对客户端的,一部分是对后端的
28.3 缓冲区与超时的平衡
http {
# ── 客户端 ──
client_body_buffer_size 128k; # 请求体缓冲,超出写临时文件
client_header_buffer_size 4k; # 请求头缓冲
large_client_header_buffers 4 16k; # 超大请求头(如长 Cookie)
# ── 输出 ──
output_buffers 4 32k;
postpone_output 1460; # 攒够 1460 字节再发(一个 MSS)
# ── 超时 ──
client_header_timeout 15s;
client_body_timeout 30s;
send_timeout 30s;
keepalive_timeout 65s;
keepalive_requests 1000;
}
client_header_buffer_size 4k(1k 起)+ large_client_header_buffers 4 8k。如果应用往 Cookie 里塞了太多东西(JWT token、用户信息),请求头超过 8k 就会返回 400 Bad Request,且错误信息是"request header too large"。两个解法:调大缓冲,或者(更好的)减少 Cookie 体积——JWT 应该放
Authorization 头而不是 Cookie。
28.4 日志的性能影响
# 高并发下日志是真实的性能瓶颈(磁盘 IO)
# ── 优化手段 ──
# 1. 静态资源不记日志
location ^~ /static/ {
access_log off;
}
# 2. 用缓冲写日志(批量落盘)
access_log /var/log/nginx/access.log main buffer=32k flush=5s;
# 3. 关闭文件不存在的日志(防扫描器刷日志)
log_not_found off;
# 4. 只记录必要的字段
log_format minimal '$remote_addr "$request" $status $request_time';
access_log /var/log/nginx/access.log minimal;
| 方式 | IO 模式 | 吞吐影响 |
|---|---|---|
access_log 无缓冲 | 每请求一次 write | 高并发下明显 |
buffer=32k | 攒够 32k 才写 | 大幅降低 |
access_log off | 不写 | 无 |
写到 /dev/null | 仍走系统调用 | 几乎无收益,不如 off |
28.5 一份生产级的主配置
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 10240;
multi_accept on;
use epoll;
}
http {
# ── 基础 ──
sendfile on;
tcp_nopush on;
tcp_nodelay on;
types_hash_max_size 2048;
server_tokens off;
server_names_hash_bucket_size 64;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# ── 超时 ──
keepalive_timeout 65;
keepalive_requests 1000;
client_header_timeout 15s;
client_body_timeout 30s;
send_timeout 30s;
reset_timedout_connection on;
# ── 缓冲 ──
client_max_body_size 10m;
client_body_buffer_size 128k;
client_header_buffer_size 4k;
large_client_header_buffers 4 16k;
output_buffers 4 32k;
postpone_output 1460;
# ── 文件缓存 ──
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# ── 压缩 ──
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_disable "msie6";
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/json application/xml
application/xml+rss image/svg+xml;
# ── 日志 ──
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" rt=$request_time '
'up=$upstream_addr urt=$upstream_response_time';
access_log /var/log/nginx/access.log main buffer=32k flush=5s;
log_not_found off;
# ── 缓存区声明 ──
proxy_cache_path /var/cache/nginx levels=1:2
keys_zone=main_cache:10m max_size=1g inactive=60m use_temp_path=off;
# ── WebSocket 支持 ──
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ── 引入站点 ──
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
第七部分 · 日志与排障
29日志格式与切割
日志是排障时唯一不会骗你的证据。但 Nginx 默认的 combined 格式信息量偏少——没有请求耗时、没有上游耗时、没有后端地址。真出问题时,你会恨不得当时多记几个字段。
29.1 access_log 与 log_format
日志格式用 log_format 定义(必须在 http 块内),然后由 access_log 引用。
http {
# ── 定义格式:名字 → 字段串 ──
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent"';
# 官方 builtin 的 combined 就是上面这个
# ── 生产推荐:加耗时与上游信息 ──
log_format detail '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time urt=$upstream_response_time '
'ua=$upstream_addr us=$upstream_status '
'cs=$upstream_cache_status';
# ── 引用 ──
access_log /var/log/nginx/access.log detail;
server {
listen 80;
server_name example.com;
# server 内可覆盖成别的格式或别的文件
access_log /var/log/nginx/example.access.log detail;
location /health {
# 健康检查不记日志,否则日志被刷爆
access_log off;
return 200 "ok";
}
}
}
$request_time — 从读到请求第一个字节到发完响应的总耗时,客户端感知的耗时。$upstream_response_time — 后端处理耗时,可能形如 0.031, 0.028(重试了两次)。$upstream_addr — 实际命中的后端地址列表,负载均衡分到哪台一目了然。$upstream_cache_status — HIT / MISS / BYPASS / EXPIRED。判断"慢在 Nginx 还是慢在后端",看 rt 与 urt 的差值就够了。
29.2 日志格式字段速查
| 变量 | 含义 | 排障用途 |
|---|---|---|
$remote_addr | 客户端 IP(经过代理时是代理的 IP) | 基础统计;配合 real_ip 才是真 IP |
$realip_remote_addr | 建立 TCP 连接的原始 IP | 装了 real_ip 模块后,用它拿到直连方 |
$http_x_forwarded_for | XFF 头内容 | 链路还原(可被伪造,只作参考) |
$request | 完整请求行,如 GET /a?b=1 HTTP/1.1 | 看请求方法、路径、协议版本 |
$request_method | 仅方法名 | 统计 GET/POST 比例 |
$request_uri | 原始 URI(含 query,不解码) | 日志里记这个最忠实 |
$uri | 解码并按规则处理后的 URI | 看 rewrite 之后的实际路径 |
$args | query 字符串 | 调试参数 |
$status | 响应码 | 错误率统计 |
$body_bytes_sent | 响应体字节数(不含头) | 流量统计 |
$bytes_sent | 含响应头的总字节 | 更精确的带宽统计 |
$request_length | 请求总字节(含头与体) | 防大请求攻击的指标 |
$http_referer | 来源页 | 流量来源分析 |
$http_user_agent | UA | 识别爬虫、区分端 |
$http_host | 请求头里的 Host | 多域名共用日志时区分站点 |
$server_name | 命中的 server_name | 区分虚拟主机 |
$scheme | http 或 https | 检查跳转是否生效 |
$ssl_protocol / $ssl_cipher | 协商出的 TLS 版本与套件 | 排查"为什么客户端连不上" |
$connection / $connection_requests | 连接序号 / 该连接上已处理请求数 | 看 keepalive 是否生效 |
$request_time | 总耗时(秒,毫秒精度) | 核心性能指标 |
$upstream_response_time | 后端耗时 | 定位瓶颈在后端还是链路 |
$upstream_connect_time | 连接后端耗时 | 后端是否 backlog 满 |
$upstream_header_time | 后端返回首字节耗时 | 后端处理是否慢 |
$upstream_status | 后端返回码 | 后端 502 还是 Nginx 502 |
$upstream_cache_status | 缓存命中情况 | 调缓存必看 |
$gzip_ratio | 压缩比 | 评估 gzip 收益 |
log_format 里写了 rt=$request_time 这种带等号的写法,
后续用 awk/logstash 解析时按空格切分即可。不要格式串里一会儿有等号一会儿没有,会让解析脚本难以维护。
另外日志字段之间的分隔符建议统一用空格,不要用 tab 混空格。
29.3 JSON 格式日志:给 ELK / Loki 用
结构化日志比文本日志好解析得多。把格式定义为一行 JSON:
http {
log_format json escape=json '{'
'"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"referer":"$http_referer",'
'"ua":"$http_user_agent",'
'"host":"$http_host",'
'"xff":"$http_x_forwarded_for",'
'"rt":$request_time,'
'"urt":"$upstream_response_time",'
'"uaddr":"$upstream_addr",'
'"ustatus":"$upstream_status",'
'"cache":"$upstream_cache_status"'
'}';
access_log /var/log/nginx/access.json.log json;
}
" 或反斜杠,日志里就会出现未转义的字符,
JSON 解析直接失败——而且这属于可以被人为构造的输入,等于给了攻击者一个日志注入点。
29.4 日志切割:别让日志撑爆磁盘
Ubuntu 装 Nginx 时自带 /etc/logrotate.d/nginx,默认按天切、留 14 天。但如果你有自定义日志文件,需要自己加:
# /etc/logrotate.d/nginx-custom
/var/log/nginx/*.log {
daily # 每天切
rotate 30 # 保留 30 份
missingok # 文件不存在不报错
notifempty # 空文件不切
compress # 压缩旧日志
delaycompress # 最近一份先不压(可能还在写)
dateext # 用日期做后缀而不是序号
dateformat -%Y%m%d # 后缀格式
sharedscripts # 所有文件处理完只跑一次脚本
create 0640 www-data adm # 新文件权限与属主
postrotate
# 让 nginx 重新打开日志文件,否则它还在写已被 rename 的旧 inode
if [ -f /run/nginx.pid ]; then
kill -USR1 $(cat /run/nginx.pid)
fi
endscript
}
mv access.log access.log.1。但 Nginx 持有的是文件描述符,不是文件名,
所以你 mv 之后它还在往被移走的那个 inode 里写——新文件永远是空的,旧文件无限增长。两种解法:
1.
kill -USR1 $(cat /run/nginx.pid)(推荐,无中断,不重载配置)2.
systemctl reload nginx(也可以,但会重新读配置,多余开销)绝对不要用
> access.log 清空日志——在写日志的同时清空会产生 \0\0\0... 一堆空洞(稀疏文件),
后面用文本工具读会出问题。
29.5 手动切割一例
#!/bin/bash
# /root/scripts/nginx-log-rotate.sh
LOG_DIR=/var/log/nginx
STAMP=$(date -d "yesterday" +%Y%m%d)
for f in "$LOG_DIR"/*.log; do
[ -s "$f" ] || continue # 空文件跳过
mv "$f" "${f}.${STAMP}"
gzip -f "${f}.${STAMP}"
done
# 让 nginx 重新打开日志(USR1,不是 HUP)
[ -f /run/nginx.pid ] && kill -USR1 "$(cat /run/nginx.pid)"
# 只留 30 天
find "$LOG_DIR" -name "*.gz" -mtime +30 -delete
echo "rotated at $(date)"
29.6 从日志里看出东西来
几个日常最常用的日志分析命令,背下来能省很多时间:
# ── 1. 访问量最高的 20 个 IP ──
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
# ── 2. 所有非 200 的请求(看错误) ──
awk '$9 != 200 {print $9, $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# ── 3. 响应码分布 ──
awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# ── 4. 最慢的 10 个请求(detail 格式,找 rt= 字段) ──
grep -o 'rt=[0-9.]*' /var/log/nginx/access.log | sort -t= -k2 -rn | head -10
# ── 5. 找出耗时 > 1s 的请求 ──
awk -F'rt=' '{split($2,a," "); if(a[1]+0 > 1) print}' /var/log/nginx/access.log
# ── 6. 慢请求的后端分布(慢在哪个后端) ──
awk -F'urt=' '{split($2,a," "); if(a[1]+0 > 1) print $0}' \
/var/log/nginx/access.log | grep -oP 'ua=\S+' | sort | uniq -c | sort -rn
# ── 7. 缓存命中率 ──
grep -o 'cs=[A-Z]*' /var/log/nginx/access.log | sort | uniq -c | sort -rn
# ── 8. 实时看日志(带颜色高亮状态码) ──
tail -f /var/log/nginx/access.log | \
awk '{c=$9; color=(c>=500)?"\033[31m":(c>=400)?"\033[33m":(c>=300)?"\033[36m":"\033[32m";
print color $0 "\033[0m"}'
# ── 9. 统计 QPS(每秒请求数) ──
awk -F'[][]' '{print $2}' /var/log/nginx/access.log | \
cut -d: -f2-4 | uniq -c | awk '{print $2, $3, $4, "QPS="$1}' | tail -20
# ── 10. 爬虫占比 ──
grep -icE 'bot|spider|crawler' /var/log/nginx/access.log
$1=IP $2=- $3=-(或用户名)
$4=[日 $5=期]
$6="GET $7=路径 $8=HTTP/1.1" $9=状态码。一旦你改了
log_format,这些位置就全变了。用自定义格式就老老实实加 key=,别依赖位置。
29.7 error_log 级别与排障用法
# 语法:error_log 路径 级别;
# 级别从松到严:debug | info | notice | warn | error | crit | alert | emerg
error_log /var/log/nginx/error.log warn; # 生产默认
# error_log /var/log/nginx/error.log notice; # 想看连接建立/关闭
# error_log /var/log/nginx/error.log info; # 想看重试、缓存细节
# error_log /var/log/nginx/error.log debug; # 极度详细,仅临时调试
# 可以发到内存缓冲减少磁盘 IO(满了才落盘)
error_log /var/log/nginx/error.log warn;
# error_log /var/log/nginx/error.log warn buffer=32k flush=5s;
# 可以发到 syslog 或 stderr(容器里用它把日志交给 docker)
error_log stderr warn;
# 可以同时输出到多个目的地
error_log /var/log/nginx/error.log warn;
error_log syslog:server=unix:/dev/log,facility=local7,tag=nginx warn;
--with-debug。设了 debug 也不输出,还会在启动时警告。检查方法:
nginx -V 2>&1 | grep -- --with-debug另外
debug 级别日志量极大,一台普通站一秒能出几 MB,调完立刻改回去,别忘了。
29.8 只记异常请求:把日志量压下来
全量日志在高流量站上很占地方。可以用 map 做条件日志——只在特定条件下写日志:
http {
# 只有状态码是 4xx/5xx 或者耗时超过 1s 才记
map $status $log_by_status {
~^[45] 1;
default 0;
}
map $request_time $log_by_time {
~^([1-9]|[0-9]{2,}) 1; # >= 1 秒
default 0;
}
# 两个条件合成一个开关
server {
listen 80;
set $loggable 0;
if ($log_by_status) { set $loggable 1; }
if ($log_by_time) { set $loggable 1; }
access_log /var/log/nginx/error-only.log detail if=$loggable;
access_log /var/log/nginx/all.log detail; # 全量另存
}
}
if 在 location 里的行为有反直觉的地方,
特别是和 try_files、proxy_pass 混用时会出意外(见第 33 章)。上面这个例子里的
if 是安全的用法——只做 set,不做 return/rewrite。
能用 map 就别用 if,能用 return 就别用 rewrite。
29.9 一个坑:$request_time 单位是秒,但要当心人为构造的慢请求
$request_time 是从"读完请求头"开始算的,不包括客户端发完请求体的时间。所以一个客户端慢慢悠悠传 1MB 上传的时间不会算进去。想覆盖这部分,看 $upstream_response_time 也没有——那只是后端时间。真正要防的是slowloris 攻击(客户端只发一半请求头占住连接),这靠调 client_header_timeout 和 client_body_timeout 来防(见第 23 章)。
29.10 本章小结
- 把
log_format加上$request_time/$upstream_response_time/$upstream_addr,这是排障的最低配置 - JSON 格式要加
escape=json - logrotate 后必须发
USR1,绝不能> file清空 - 日志爆炸时用
map+access_log if=做条件日志 error_log的 8 个级别里,生产用 warn,临时调试用 info,debug 慎用
30故障排查方法论
排障这件事,最怕的是"东试一下西改一点"。下面这套流程是按从外到内、从粗到细的顺序设计的——每一步都能把问题范围砍掉一半。
30.1 五分钟定位法
30.2 第一刀:配置语法对不对
# 只检查语法
nginx -t
# 检查 + 打印实际加载的完整配置(include 全部展开)
nginx -T
# 检查 + 指定配置文件
nginx -t -c /etc/nginx/nginx.conf
# 检查 + 指定前缀目录(测试别的安装)
nginx -t -p /opt/nginx/
conf.d/site.conf:18 直接告诉你去哪一行。2. 错误级别 —
[emerg] 致命(起不来)、[warn] 警告(能起但有问题)、[error] 中间。3. 是否真的用了你以为的文件 —
nginx -t 只测 /etc/nginx/nginx.conf。
如果你改的是别的路径的文件,测了也是白测。
30.3 第二刀:进程与端口状态
# 服务状态(看 active / 启动时间 / 最近日志)
systemctl status nginx
# 是否开机自启
systemctl is-enabled nginx
# 进程详情(master + worker 各几个)
ps -ef | grep nginx | grep -v grep
# 主进程 PID 文件
cat /run/nginx.pid
# 端口监听情况(必须有 *:80 或 0.0.0.0:80)
ss -lntp | grep nginx
# 老版本用 netstat
netstat -lntp | grep nginx
# 是不是 80 端口被别的程序占了?
ss -lntp | grep ':80 '
fuser -n tcp 80
# 连接状态统计(TIME_WAIT 过多说明短连接过多)
ss -s
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
Memory: 12.4M 觉得是不是没加载什么。Nginx 静态托管确实就这么点内存,
每个 worker 也就几 MB。内存暴涨通常是缓冲区配置过大(client_body_buffer_size、
proxy_buffers 设成了几 MB)或者连接数泄漏。
30.4 第三刀:curl 逐层打
这是最有价值的一招。绕过 DNS、绕过外网、直接打本地,把变量一个个消掉:
# 基础:本地打(默认 Host: 127.0.0.1 —— 很可能命中 default_server!)
curl -v http://127.0.0.1/
# 关键:手动指定 Host,才能命中你想要的虚拟主机
curl -v -H "Host: example.com" http://127.0.0.1/
# HTTPS 且用域名做 SNI
curl -v --resolve example.com:443:127.0.0.1 https://example.com/
# 只看响应头
curl -sI -H "Host: example.com" http://127.0.0.1/
# 只看状态码
curl -so /dev/null -w "%{http_code}\n" -H "Host: example.com" http://127.0.0.1/
# 看耗时分解
curl -so /dev/null -w "dns=%{time_namelookup} conn=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
-H "Host: example.com" http://127.0.0.1/
# 跟踪跳转(验证 301 是否正确)
curl -sIL -H "Host: example.com" http://127.0.0.1/
# 指定 SNI 和 Host 分开(排查证书与 Host 不匹配)
curl -v --resolve wrong.com:443:127.0.0.1 https://wrong.com/ --connect-to wrong.com:443:right.com:443
Host 时 curl 发的是 Host: 127.0.0.1,
这跟你的域名 server_name 一个都不匹配,Nginx 会分配给该端口上的第一个 server 块
(或者显式标了 default_server 的那个)。所以"本地 curl 返回了别的站点"是最常见的误判源——不是你配错了,是你没带 Host。 排查时永远加
-H "Host: 你的域名"。
30.5 第四刀:确认命中了哪个 server 和 location
Nginx 不会直接告诉你"你命中了哪个 server",但有几个间接方法:
# 方法 1:给每个 server 配独立的 access_log,然后看日志落在哪
# (这是最可靠的做法,推荐长期保留)
# 方法 2:临时加一个响应头,标出命中信息
# 在 server 块内加:
# add_header X-Server-Hit "example.com" always;
# 在 location 块内加:
# add_header X-Location-Hit "api" always;
# 方法 3:用 return 200 临时打断,看是否命中
# 在怀疑的 location 第一行加:
# return 200 "HIT-LOCATION-API\n";
# 注意:这会完全中断代理,只用于定位,事后删掉
# 方法 4:开 rewrite 日志(会记下 rewrite 与 location 的选择过程)
# 在 server 或 location 里加:
# rewrite_log on;
# 然后 error_log 调到 notice 级
# error_log /var/log/nginx/error.log notice;
# 推荐的"调试头"组合,只在测试环境开
server {
listen 80;
server_name example.com;
# always 表示无论状态码(包括 4xx/5xx)都加这个头
add_header X-Debug-Server "example.com" always;
add_header X-Debug-Upstream "$upstream_addr" always;
add_header X-Debug-Cache "$upstream_cache_status" always;
add_header X-Debug-Scheme "$scheme" always;
add_header X-Debug-RealIP "$remote_addr" always;
location /api/ {
add_header X-Debug-Location "api" always;
proxy_pass http://127.0.0.1:5000;
}
}
server 块里加了 5 个 add_header,
又在 location 里加了 1 个——location 里那 5 个继承的头全部消失,
只剩 location 自己那 1 个。这是 Nginx 数组类指令的覆盖规则。要在 location 里保留父级头 + 加新头,必须把父级的全部重写一遍。这是无数人踩过的坑。
30.6 第五刀:error.log 怎么读
# 看最近的错误(按时间)
tail -100 /var/log/nginx/error.log
# 按关键字找
grep -i "upstream" /var/log/nginx/error.log | tail -20
grep -i "permission denied" /var/log/nginx/error.log | tail -20
grep -i "no such file" /var/log/nginx/error.log | tail -20
# 统计错误类型分布(找出主要矛盾)
awk -F', ' '{print $2}' /var/log/nginx/error.log | sort | uniq -c | sort -rn | head
# 看特定客户端的报错
grep "1.2.3.4" /var/log/nginx/error.log
30.7 用 strace / tcpdump 往下钻
当常规手段都说不清问题时,就只能看系统调用了:
# ── strace 跟一个 worker,看它到底在干什么 ──
# 找出某 worker 的 PID
WPID=$(pgrep -f "nginx: worker" | head -1)
strace -f -p "$WPID" -e trace=network,file -s 200 2>&1 | head -100
# 只看文件相关的系统调用,找"打开了哪个文件"
strace -f -p "$WPID" -e trace=openat -s 200 2>&1 | grep -i html
# ── tcpdump 看网络层 ──
# 抓 80 端口,看请求有没有到机器
tcpdump -i any -n -A 'tcp port 80 and host 1.2.3.4' -c 20
# 看是否连到后端
tcpdump -i lo -n 'tcp port 5000' -c 20
# 看 TLS 握手失败
tcpdump -i any -n 'tcp port 443 and (tcp[tcpflags] & tcp-syn != 0)' -c 50
# ── 看内核层面的连接队列溢出 ──
nstat -az | grep -i -E 'listen|overflow|drop'
ss -lnt | awk 'NR>1 {print $2, $3, $4}' # Recv-Q / Send-Q
# Listen 状态下的 Send-Q 就是 backlog,Recv-Q 是已排满但还没 accept 的连接数
ss -lnt 输出里,LISTEN 状态下如果 Recv-Q 长期不为 0,
说明已完成三次握手但还没来得及被 worker accept 的连接堆积了。这通常意味着:worker 忙不过来(并发上限太低)或
backlog 太小。调整 listen 80 backlog=2048; 并检查 worker 数量。
30.8 一个真实案例的排查过程
记录下来,因为它包含了三个独立的坑。
现象:https://api.example.com/health 返回 502,其他路径正常。
第 1 步 nginx -t
→ 语法 OK。排除配置语法。
第 2 步 systemctl status nginx
→ active (running),正常。
第 3 步 curl -v -H "Host: api.example.com" http://127.0.0.1/health
→ 502 Bad Gateway。
结论:不是外网问题,是 Nginx 自己吐的 502。
第 4 步 tail -20 /var/log/nginx/error.log
→ connect() failed (111: Connection refused) while connecting to upstream,
upstream: "http://127.0.0.1:8080/health"
结论:后端 8080 连不上。
第 5 步 ss -lntp | grep 8080
→ 只有 [::1]:8080,没有 127.0.0.1:8080
结论:后端只监听了 IPv6 的 localhost(::1),而 Nginx 配的是 127.0.0.1(IPv4)。
修复:把 upstream 改成 http://[::1]:8080 或者让后端监听 0.0.0.0:8080。
【坑 1】 Java/Node 里写 "localhost" 时,双栈系统可能解析成 ::1 优先,
导致只监听了 IPv6。配 Nginx 时用 IP 而不要用 localhost 能减少歧义。
继续验证时发现新问题:
第 6 步 curl -v -H "Host: api.example.com" http://127.0.0.1/health
→ 200 但返回内容是首页 HTML,不是 JSON。
第 7 步 检查配置,发现:
location /health {
proxy_pass http://127.0.0.1:8080/; ← 末尾这个斜杠!
}
后端真实路径是 /health,但 Nginx 把 location 前缀 /health 替换成了 /,
所以实际请求的是 http://127.0.0.1:8080/ → 后端返回首页。
【坑 2】 proxy_pass 带路径时做前缀替换,末尾斜杠会把整个 location 前缀吃掉。
修复:改成 proxy_pass http://127.0.0.1:8080;(不带路径,原样透传)
第 8 步 再测,200 且返回 JSON,但响应头里没有 CORS。
第 9 步 add_header 排查,发现 location 里加了一个 add_header,
结果 server 块里配的 CORS 头全被覆盖掉了。
【坑 3】 add_header 是全有或全无的覆盖,location 里加一个就会丢掉父级全部。
修复:把 CORS 头在 location 里重写一遍,或者用 map 统一在 server 层处理。
30.9 本章小结
- 顺序:
nginx -t→systemctl status→curl -v -H "Host: ..."→ 看error.log - 本地 curl 必须带 Host,否则会命中 default_server,误导判断
- 给每个 server 配独立 access_log,是长期最有价值的排障投资
- error.log 的六类高频错误背下来:文件不存在、连接被拒、读超时、目录禁止、权限不足、无限流可用
- 三件套速记:
111 Connection refused= 后端没起;110 timed out= 后端慢;2 No such file= 路径错
31常见错误码对照与处理
Nginx 能产生的状态码就那么些,但每个码后面的原因各不相同。这一章按码查因,当手册用。
31.1 4xx 客户端错误
| 码 | 含义 | Nginx 场景下的常见原因 | 处理 |
|---|---|---|---|
| 400 | Bad Request | 请求头过大超过 large_client_header_buffers;HTTP/2 里出现了非法字符; 客户端发了畸形请求(扫描器) |
调大 client_header_buffer_size 与 large_client_header_buffers;扫器流量可忽略或封 IP |
| 401 | Unauthorized | auth_basic 生效,用户没带凭证或凭证错 |
确认 htpasswd 文件正确;auth_basic_user_file 路径对 |
| 403 | Forbidden | 目录无 index 文件且未开 autoindex("directory index is forbidden"); 文件或目录权限不足(www-data 读不到); 被 deny 规则拦了;SELinux/AppArmor 拦截 |
补 index 文件或开 autoindex;chown -R www-data:www-data 并 chmod -R a+rX;检查 allow/deny;getenforce 查 SELinux |
| 404 | Not Found | root/alias 路径拼错;try_files 兜底文件不存在;SPA 没配 fallback |
用 ls 确认实际路径;error_log 会打印它实际找的绝对路径 |
| 405 | Method Not Allowed | 静态文件收到了 POST/PUT/DELETE;limit_except 限制生效 |
静态目录用 limit_except GET HEAD { deny all; } 显式声明,报错更清晰 |
| 408 | Request Timeout | 客户端建了连接不发送请求头,超过 client_header_timeout(默认 60s) |
调小到 10s;这是 slowloris 攻击的典型特征 |
| 411 | Length Required | POST 请求没带 Content-Length 也没用 chunked |
客户端问题;也可以配 chunked_transfer_encoding on |
| 413 | Payload Too Large | 请求体超过 client_max_body_size(默认 1m) |
上传功能必踩。调大:client_max_body_size 100m; |
| 414 | URI Too Long | URL 超过 large_client_header_buffers 限制 |
调大;但 URL 过长通常是设计问题 |
| 416 | Range Not Satisfiable | 断点续传请求的 Range 超出文件大小 | 正常现象,客户端会重新完整下载 |
| 429 | Too Many Requests | limit_req 或 limit_conn 触发 |
检查 burst/rate 是否过严;给 API 单独放宽 |
| 499 | Client Closed Request | 不是标准码,Nginx 私有。客户端在 Nginx 返回响应前主动断开 | 通常是用户点了停止/关了页面,或客户端超时太短。大量 499 且 rt 很大 = 后端太慢 |
31.2 5xx 服务端错误
| 码 | 含义 | 原因 | 处理 |
|---|---|---|---|
| 500 | Internal Server Error | 后端的错(Nginx 只是转发); FastCGI/PHP-FPM 返回的错; 少见的 Nginx 内部错误 |
看后端日志。Nginx 的 error_log 里通常只写 "upstream sent invalid header" 之类 |
| 501 | Not Implemented | 请求方法 Nginx 不支持(如 PATCH 到静态文件) | 反代后端;或显式拒绝 |
| 502 | Bad Gateway | 最高频。后端没起来 / 端口错 / 后端崩了 / 后端返回了畸形响应; 后端只监听 IPv6; upstream 全部被标记 down |
ss -lntp 查后端在不在;curl 直连后端;看 error_log 的 upstream: 地址 |
| 503 | Service Unavailable | limit_conn 触发(连接数满);upstream 无可用节点; worker_connections 用尽 |
看 error_log 有没有 "limiting connections";调大上限 |
| 504 | Gateway Timeout | 后端在 proxy_read_timeout(默认 60s)内没返回 |
先确认是不是后端真慢(看 urt);确实慢就调大超时,但更重要的是优化后端 |
| 507 | Insufficient Storage | Nginx 写入磁盘失败(日志盘满、临时文件盘满) | df -h 查磁盘;清日志;检查 client_body_temp_path |
502 往后端进程/端口/协议找;504 往后端处理时间/超时配置找。
另外 499 也值得注意:它说明客户端等不及走了——往往是 Nginx 表现得比用户预期慢, 虽然 Nginx 自己不认为这是错误,但对你来说是真实的用户体验损失。
31.3 3xx 重定向相关
| 码 | 含义 | 语义 | 典型用法 |
|---|---|---|---|
| 301 | Moved Permanently | 永久重定向,浏览器会长期缓存,后续直接跳 | HTTP → HTTPS;域名迁移;裸域 → www |
| 302 | Found | 临时重定向,不缓存 | 临时跳转;登录后跳回 |
| 307 | Temporary Redirect | 临时,且严格保持原请求方法(POST 还是 POST) | API 临时迁移 |
| 308 | Permanent Redirect | 永久,且严格保持请求方法 | API 永久迁移(替代 301,避免 POST 变 GET) |
| 304 | Not Modified | 协商缓存命中,让浏览器用本地副本 | 由 If-Modified-Since/If-None-Match 触发 |
拿不准就用 302。确认稳定后再改 301。
31.4 一个决策表:502 到底查什么
31.5 自定义错误页
server {
listen 80;
server_name example.com;
root /var/www/html;
# 关掉 Nginx 版本号(默认会在错误页和 Server 头里显示)
server_tokens off;
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
# 用 @ 命名 location 承接(不改 URL,浏览器地址栏不变)
error_page 502 503 504 @maintenance;
location = /404.html {
internal; # 只允许内部跳转,外部直接访问会 404
root /var/www/errors;
}
location = /50x.html {
internal;
root /var/www/errors;
}
location @maintenance {
internal;
# 维护页可以反代到一个专门的静态站,或者直接返回
root /var/www/errors;
try_files /maintenance.html =503;
}
# 也可以把错误转发给后端的一个路径
# error_page 404 = @fallback;
location @fallback {
proxy_pass http://127.0.0.1:5000;
}
}
internal,用户可以直接访问 /50x.html,
看到 200 状态码的"服务不可用"页面——监控会误以为服务正常。error_page 404 =200 /index.html; 里的 =200 可以覆盖返回码,
SPA 里这么用很常见(把前端路由的 404 变成 200 让 SPA 自己处理),但要清楚你在放弃正确的语义。
31.6 只返回码不返回页面
# 最简单:return 直接返回状态码
location /hidden/ {
return 404; # 或 return 403; 不给任何内容
}
# 返回码 + 自定义头
location = /blocked {
add_header X-Reason "policy" always;
return 403;
}
# 健康检查端点
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# 返回 JSON(注意 content-type)
location = /api/status {
default_type application/json;
return 200 '{"status":"up","version":"1.2.3"}';
}
# 返回 444:直接关连接,不给任何响应(对付扫描器)
location /admin.php {
return 444;
}
return 444 会让 Nginx 直接关闭 TCP 连接、不返回任何内容。
这对扫描器/爬虫是很有效的"我没有这个端口"信号,比返回 403/404 更省资源也更能隐藏信息。
日志里会记录 $status 为 444。
31.7 本章小结
- 413:上传功能必须调
client_max_body_size,默认只有 1m - 499:不是错误,是"客户端跑了"。大量出现说明你太慢
- 502 vs 504:连不上 vs 等不到。
ss -lntp和 error.log 里的upstream:是唯一答案 - 301 慎用,浏览器缓存几乎不可撤销
- 自定义错误页记得加
internal,并且开server_tokens off
第八部分 · 进阶主题
32rewrite 与 return 重写规则
这是 Nginx 里最容易配错、也最容易产生"玄学现象"的一块。核心原因只有一个:rewrite 会重新走一遍 location 匹配,而很多人以为它只是"改一下 URL"。
32.1 五个 flag 的区别
# 语法:rewrite 正则 替换目标 [flag];
rewrite ^/old/(.*)$ /new/$1 last; # 重写后重新匹配 location
rewrite ^/old/(.*)$ /new/$1 break; # 重写后不再匹配,留在当前 location
rewrite ^/old/(.*)$ /new/$1 redirect; # 302 临时跳转(客户端感知)
rewrite ^/old/(.*)$ /new/$1 permanent; # 301 永久跳转(客户端感知)
rewrite ^/old/(.*)$ /new/$1; # 不带 flag,在 location 内等同 last,
# 在 server 内等同 break
| flag | 返回码 | 是否重新匹配 location | 浏览器地址栏 | 用在哪 |
|---|---|---|---|---|
last | 无(内部) | 是 | 不变 | 需要跳到另一个 location 处理时 |
break | 无(内部) | 否 | 不变 | 只需改 URI,接着由当前 location 处理 |
redirect | 302 | 否(返回给客户端) | 改变 | 临时迁移 |
permanent | 301 | 否(返回给客户端) | 改变 | 永久迁移 |
rewrite 循环计数器,上限是 10 次。
如果你的规则互相指来指去(A rewrite 到 B,B 又 rewrite 回 A),
10 次之后 Nginx 返回 500 并在 error.log 里写 "rewrite or internal redirection cycle"。看到这个错误 = 你的规则有环。不要试图调大上限,去找那个环。
32.2 return 优先,能用就不用 rewrite
官方建议:能用 return 就不要用 rewrite。return 不需要跑正则引擎,性能更好,语义也更清楚。
# ── 整站跳 HTTPS(最推荐的写法,性能最好) ──
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
# ── 根路径跳到子路径 ──
location = / {
return 301 /portal/;
}
# ── 单个旧地址跳新地址 ──
location = /old-page.html {
return 301 /new-page/;
}
# ── 裸域跳 www(注意 $server_name 的坑,见下) ──
server {
listen 443 ssl;
server_name example.com;
return 301 https://www.example.com$request_uri;
}
$server_name 是你配置文件里写死的名字,
可能有多个(server_name a.com b.com; 时它只返回第一个)。$host 是客户端实际请求的 Host(优先取请求行的 host,其次 Host 头,最后 server_name)。做"保留原域名"的跳转必须用
$host。用 $server_name 会导致
访问 b.com 被跳到 a.com——看起来像"随机乱跳",实际是配置写死了。
32.3 rewrite 正则里的捕获组
# $1 $2 ... 引用括号捕获的内容
rewrite ^/user/([0-9]+)$ /profile?id=$1 last;
rewrite ^/blog/([0-9]{4})/([0-9]{2})/(.*)$ /post/$1-$2-$3 last;
# 命名捕获(Nginx 支持 ?<name> 语法)
rewrite ^/user/(?<uid>[0-9]+)$ /profile?id=$uid last;
# $1 在 if 里也能用
if ($request_uri ~ ^/api/v(\d+)/) {
set $api_version $1;
proxy_pass http://backend_v$api_version;
}
# 注意:正则里的 . 要转义,否则匹配任意字符
rewrite ^/file\.txt$ /file2.txt last;
if 块里 set 的变量,出了 if 块仍然有效——
但也意味着如果条件不成立,变量会保持上一次请求的值或空值。每次都要给默认值,不要依赖 if 一定执行。这是 Nginx 里一类经典 bug 的来源,和第 33 章会讲的
map 正好是解药。
32.4 正则大小写与否定
# 区分大小写
location ~ \.php$ { }
# 不区分大小写(更慢一点)
location ~* \.(jpg|jpeg|png|gif|webp)$ { }
# 否定匹配
location !~ \.php$ { } # 不匹配 .php
location !~* \.PHP$ { } # 不区分大小写地否定
# 前缀匹配 + 否定
if ($http_user_agent !~* "bot|crawler") { }
32.5 实战:URL 规范化
server {
listen 443 ssl;
server_name example.com;
# ── 1. 去掉重复斜杠://a//b → /a/b ──
if ($request_uri ~ "^//+") {
return 301 $scheme://$host$uri;
}
# ── 2. 去掉末尾斜杠(非目录请求) ──
# 注意:不要对目录做这件事,会破坏相对路径的静态资源引用
rewrite ^/(.*)/$ /$1 permanent;
# ── 3. 小写化 URL(SEO 需要) ──
# Nginx 没有内置小写函数,得用 perl 模块或 lua
# 纯配置的做法是列出所有大写变体,不现实
# 务实做法:在应用层做,或加 canonical 头
# ── 4. 旧的 .html 后缀跳无后缀 ──
location ~ ^/(.*)\.html$ {
return 301 /$1;
}
# ── 5. 带 www 跳裸域(或者是反过来,二选一) ──
# 这个要在单独的 server 块做,见 32.2
# ── 6. 强制保留 query string ──
# return/rewrite 默认不自动带 query,除非你用 $request_uri
rewrite ^/search$ /find permanent; # /search?q=1 → /find(q 丢了!)
rewrite ^/search$ /find?$args permanent; # /search?q=1 → /find?q=1(正确)
rewrite ^/a$ /b yyds; 会把 /a?x=1 变成 /b,参数没了。正确写法:
rewrite ^/a$ /b?yyds; 或者 rewrite ^/a$ /b?$args;而
return 不一样:return 301 /b; 也会丢参数,
必须写 return 301 /b$is_args$args;。$is_args 有参数时是 ?,没参数时是空串——这个变量的存在就是为了这种情况。
32.6 rewrite 的执行顺序
这一点很多人搞混:rewrite 在 location 匹配之前就已经在 server 层跑过一遍了。
请求进来
↓
【1】server 层(server 块直属)的 rewrite 指令,按出现顺序执行
↓
【2】location 匹配(此时 $uri 可能已被第 1 步改过)
↓
【3】进入命中的 location,执行其中的 rewrite
↓
如果 flag = last → 回到步骤【2】重新匹配(计数 +1)
如果 flag = break → 不再匹配,继续执行当前 location 后续指令
↓
【4】执行 content 阶段的指令(proxy_pass / root / return / try_files ...)
last 和 break
在这里行为一致——都是改完 URI 继续往下走(下一步就是 location 匹配)。这就是为什么"不带 flag 的 rewrite 在 server 层等同 break"。
32.7 一个完整案例:老站迁移
场景:老站是 old.example.com,新站是 new.example.com,路径结构也变了。
# ── 老站 vhost:只做跳转 ──
server {
listen 80;
server_name old.example.com;
return 301 https://new.example.com$request_uri;
}
server {
listen 443 ssl;
server_name old.example.com;
ssl_certificate /etc/letsencrypt/live/old.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/old.example.com/privkey.pem;
# ── 路径映射规则(从上到下,第一条匹配就跳出) ──
# 产品页:/product/123 → /p/123
rewrite ^/product/([0-9]+)$ https://new.example.com/p/$1 permanent;
# 分类页:/category/books → /c/books/
rewrite ^/category/([a-z\-]+)$ https://new.example.com/c/$1/ permanent;
# 博客:/blog/2020/01/title → /posts/title
rewrite ^/blog/[0-9]{4}/[0-9]{2}/(.+)$ https://new.example.com/posts/$1 permanent;
# 静态资源沿用(不要跳,会破坏缓存)
location ~* \.(css|js|jpg|png|svg|woff2?)$ {
return 301 https://new.example.com$request_uri;
}
# 其余全部原样跳
location / {
return 301 https://new.example.com$request_uri;
}
}
2. 静态资源直接原样跳。重新映射路径会让 CDN 和浏览器缓存全部失效。
3. 老域名证书要留着。不保留证书,用户访问 https 老域名会先撞证书错误, 跳转根本没机会生效。老域名要一直续证书到它彻底没人访问为止。
32.8 本章小结
- 能用
return别用rewrite;能用map别用if last重新匹配 location(上限 10 次),break不重新匹配- 跳转保留原域名用
$host,不要用$server_name - query string 会丢:
rewrite加?$args,return加$is_args$args - 出现
redirection cycle= 规则有环,别去调上限
33map 与条件逻辑
Nginx 没有编程语言的 if/else 体系,但有一个比 if 强大得多的工具:map。它解决的问题是"根据某个变量,决定另一个变量的值"。
33.1 map 的基本语法
# map 必须在 http 块内
http {
# 语法:map $源变量 $目标变量 { ... }
map $http_user_agent $is_bot {
default 0;
~*bot 1;
~*spider 1;
~*crawler 1;
~*curl 1;
~*wget 1;
}
server {
listen 80;
server_name example.com;
# 用法:$is_bot 现在是个普通变量
if ($is_bot) {
return 403;
}
}
}
$目标变量 时才算,不用的 map 零开销。2. 可以定义在任何地方使用 — 定义在 http 块,但 server、location、if 里都能用。
3. 匹配规则和 location 类似 — 字符串精确匹配优先,然后是正则(按书写顺序),最后 default。
但注意:map 里的正则匹配也是按顺序、第一个命中即停,所以规则的先后顺序有意义。
33.2 六种典型用法
用法 1:WebSocket 升级头(最经典)
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close; # upgrade 头为空时,Connection 必须是 close
}
server {
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
}
proxy_set_header Connection "upgrade";,那么每一个普通 HTTP 请求
也会带上 Connection: upgrade,而它并不是一个 WebSocket 请求。
这会让部分后端(尤其 Java/Tomcat)困惑,甚至影响 keepalive 复用。map 的作用就是根据是否真的有 Upgrade 头,动态决定 Connection 的值。
用法 2:灰度发布开关
http {
# 按 Cookie 分流:有 gray=1 的走新版本
map $cookie_gray $backend_pool {
default "prod";
"1" "canary";
}
upstream prod {
server 10.0.0.1:8080;
server 10.0.0.2:8080;
}
upstream canary {
server 10.0.0.9:8080;
}
server {
listen 443 ssl;
server_name example.com;
location /api/ {
# 变量形式的 upstream 名,Nginx 会自动取同名 upstream
proxy_pass http://$backend_pool;
proxy_set_header Host $host;
}
# 提供一个开关接口,让测试同学自己打开灰度
location = /gray/on {
add_header Set-Cookie "gray=1; Path=/; Max-Age=86400";
return 302 /;
}
location = /gray/off {
add_header Set-Cookie "gray=; Path=/; Max-Age=0";
return 302 /;
}
}
}
proxy_pass http://$backend_pool; 这种变量形式的写法,
会让 Nginx 跳过启动时的 upstream 解析,改用运行时的解析器。后果:如果
$backend_pool 的值不是已定义的 upstream 名,
Nginx 会试图把它当成域名去 DNS 解析——而不是报配置错误。另外,变量形式下 URI 部分会被原样拼接(即使写了路径也不做前缀替换), 且
upstream 的 keepalive 可能失效。用之前务必实测。
用法 3:按 Accept 决定返回格式
http {
map $http_accept $resp_type {
default "json";
"~*application/xml" "xml";
"~*text/html" "html";
}
server {
location /api/data {
default_type application/$resp_type;
proxy_set_header Accept $http_accept;
proxy_pass http://backend;
}
}
}
用法 4:CORS 白名单
http {
map $http_origin $cors_origin {
default ""; # 不在白名单 → 空(不加头)
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
"~^https://([a-z0-9-]+)\.example\.com$" $http_origin;
}
map $request_method $cors_method {
default "";
OPTIONS "1";
}
server {
listen 443 ssl;
server_name api.example.com;
# 预检请求:直接 204 返回,不打扰后端
location /api/ {
if ($cors_method) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}
# 正常请求
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
proxy_pass http://backend;
}
}
}
* 且同时 Allow-Credentials: true——
浏览器会直接拒绝,而且这是安全漏洞。必须回显具体的 origin。2. 必须加
Vary: Origin——否则 CDN/代理会把 A 站的响应缓存后给 B 站用,
等于 CORS 白名单失效。3.
add_header 只对 2xx/3xx 生效,错误响应不会带 CORS 头,
除非加 always 参数。不加的话前端拿到 500 时会报"跨域错误"而不是真正的错误信息,
白白浪费时间排查。这条坑死过无数人。
用法 5:根据环境变量切换后端
http {
# 用 Host 前缀区分环境:dev.example.com / staging.example.com / example.com
map $http_host $env_name {
default "prod";
"~^dev\." "dev";
"~^staging\." "staging";
"~^test\." "test";
}
map $env_name $backend_host {
default "10.0.1.10:8080"; # prod
"dev" "10.0.2.10:8080";
"staging" "10.0.3.10:8080";
"test" "10.0.4.10:8080";
}
server {
listen 443 ssl;
server_name ~^(?<sub>[a-z]+\.)?example\.com$;
location / {
proxy_pass http://$backend_host;
proxy_set_header Host $host;
}
}
}
用法 6:给不同文件类型设不同缓存时间
http {
map $sent_http_content_type $expires {
default off;
text/html -1; # 不缓存
application/json off;
text/css 30d;
application/javascript 30d;
~image/ 1y;
~font/ 1y;
}
server {
expires $expires;
}
}
33.3 关于 if 的真相
Nginx 官方 wiki 有一篇著名的文章标题就叫 "If is Evil"。它说的不是 if 不能用,而是if 在 location 里用错了会出问题。
| 场景 | 是否安全 | 说明 |
|---|---|---|
server 块里 if + return | 安全 | 这是最常见的用法,可以放心用 |
server 块里 if + rewrite | 基本安全 | 但要注意 last/break 的语义 |
location 里 if + return | 安全 | 没问题 |
location 里 if + set | 安全 | 这是 if 最正当的用法之一 |
location 里 if + proxy_pass | 危险 | 会让同 location 的其他配置(如 try_files)行为异常 |
location 里 if + add_header | 危险 | 会造成 header 覆盖,且 if 不成立时头也不加 |
location 里嵌套 if | 危险 | 不要嵌套,行为难以预测 |
33.4 if 的经典反例
# ✗ 错误:用 if 做文件存在性判断(Nginx 里 if 不支持 AND/OR,只能靠嵌套,而嵌套危险)
location / {
if (-f $request_filename) {
expires 30d;
}
if (-f $request_filename.html) {
rewrite (.*) $1.html break; # break 在 if 里行为诡异
}
proxy_pass http://backend; # ← if 里出现过 proxy_pass,这里就不可靠了
}
# ✓ 正确:用 try_files(第 11 章)
location / {
try_files $uri $uri/ $uri.html @backend;
}
location @backend {
proxy_pass http://backend;
}
# ✓ 正确:用 map 做条件判断
http {
map $request_filename $file_expires {
default off;
"~*\.(css|js)$" 30d;
"~*\.(jpg|png)$" 1y;
}
}
try_files 做的事就是"依次尝试,第一个存在的就用",
这正是 if (-f ...) 想做但做不好的事。而且
try_files 会在内部正确触发 location 重新匹配,语义清晰、性能好、不会踩坑。
凡是遇到"如果文件存在就…否则…",一律用 try_files。
33.5 geo 与 split_clients:另外两个声明式工具
http {
# ── geo:按客户端 IP 段生成变量 ──
geo $remote_addr $is_internal {
default 0;
127.0.0.1 1;
10.0.0.0/8 1;
172.16.0.0/12 1;
192.168.0.0/16 1;
}
# geo 也支持用 CIDR 文件
# geo $remote_addr $country {
# include /etc/nginx/geoip/CN.txt;
# }
server {
location /metrics {
# 只允许内网访问
if ($is_internal = 0) { return 403; }
stub_status;
}
}
# ── split_clients:按比例随机分流(A/B 测试) ──
split_clients $request_id $ab_test {
10% "variant_a"; # 10% 流量
10% "variant_b"; # 10% 流量
* "control"; # 剩下 80%
}
# 用 $remote_addr 做种子则同一用户始终分到同一组
split_clients $remote_addr $bucket {
50% "group1";
* "group2";
}
log_format ab '$remote_addr "$request" $status bucket=$bucket';
}
10%、33.3% 都是合法的,但总和超过 100% 时后面的会被忽略(不报错,很坑)。种子选择很关键:用
$request_id 每次请求都随机(一人多次看到不同版本);
用 $remote_addr 或 $cookie_userid 则同一用户稳定命中同一组——A/B 测试应该用后者。
33.6 本章小结
map定义在 http 块,惰性求值,正则按顺序第一个命中即停- 四大经典用途:WebSocket 头、灰度分流、CORS 白名单、缓存时间
if+return/set安全;if+proxy_pass/add_header危险- "如果文件存在"永远用
try_files,不要用if -f - CORS 头一定要加
always和Vary: Origin
34多站点与配置拆分
一个站点的配置可能就有几百行。多个站点全塞在一个文件里,维护会变成灾难。这一章讲怎么拆、拆完之后 Nginx 怎么加载、以及拆的时候有哪些坑。
34.1 两种主流的组织方式
# ── 方式 A:conf.d(CentOS/RHEL 系默认,Ubuntu 也有) ──
/etc/nginx/
├── nginx.conf
├── conf.d/
│ ├── site-a.conf
│ ├── site-b.conf
│ └── api.conf
└── ...
nginx.conf 里:
include /etc/nginx/conf.d/*.conf;
# ── 方式 B:sites-available + sites-enabled(Debian/Ubuntu 系默认) ──
/etc/nginx/
├── nginx.conf
├── sites-available/
│ ├── default
│ ├── site-a
│ └── site-b
└── sites-enabled/
├── site-a -> ../sites-available/site-a ← 软链接
└── site-b -> ../sites-available/site-b
nginx.conf 里:
include /etc/nginx/sites-enabled/*;
启用:ln -s /etc/nginx/sites-available/site-a /etc/nginx/sites-enabled/
禁用:rm /etc/nginx/sites-enabled/site-a(保留 available 里的文件)
sites-available/sites-enabled 的好处是"禁用站点"只需删软链接,
配置本体留着,随时能恢复。但 Ubuntu 的
nginx.conf 里两个 include 都有——
如果你在 conf.d/ 和 sites-enabled/ 里放了同名站点,会重复加载导致
conflicting server name 警告。只用一个目录。
34.2 关键:include 是原地展开
理解这一点能避免 90% 的配置结构错误。
/etc/nginx/conf.d/site.conf,里面带着 http { ... } 外壳,
然后 nginx -t 报:"http" directive is not allowed here原因就是上面说的:
conf.d 已经被 http 块 include 进去了,
你再写一层 http 就是嵌套。解决办法:conf.d 里的文件只写 server 块。
34.3 拆分的四个层次
/etc/nginx/
├── nginx.conf # 只放 global + events + http 框架
├── conf.d/
│ ├── 00-global-map.conf # 全局 map(定义要被后面用)
│ ├── 01-upstreams.conf # 所有 upstream 定义集中管理
│ ├── 10-site-portal.conf # 站点 1
│ ├── 11-site-blog.conf # 站点 2
│ ├── 20-redirects.conf # 所有跳转规则
│ └── 99-default.conf # default_server 兜底
├── snippets/
│ ├── proxy-common.conf # 反代公共头
│ ├── ssl-common.conf # SSL 公共参数
│ ├── security-headers.conf # 安全头
│ └── static-cache.conf # 静态资源缓存
└── upstreams/
└── backend.conf
拆分的核心思路:把重复出现 3 次以上的配置抽成 snippet,用 include 引用。
34.4 snippets:可复用的配置片段
# /etc/nginx/snippets/proxy-common.conf
proxy_http_version 1.1;
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_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# /etc/nginx/snippets/ssl-params.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 223.5.5.5 119.29.29.29 valid=300s;
resolver_timeout 5s;
# /etc/nginx/snippets/security-headers.conf
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'" always;
站点里这样用:
server {
listen 443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
root /var/www/example;
index index.html;
# 静态资源不走应用,直接给 Nginx 处理
location ~* \.(css|js|jpg|jpeg|png|gif|svg|webp|ico|woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
location /api/ {
include /etc/nginx/snippets/proxy-common.conf;
proxy_pass http://app_backend;
}
location / {
try_files $uri $uri/ /index.html;
include /etc/nginx/snippets/security-headers.conf;
}
}
snippets/security-headers.conf 里有 5 个 add_header,
你在同一个 location 里又写了 1 个不同的 add_header——那 5 个全没了(第 30.5 节讲过)。解决办法:把同一个 location 需要的所有 add_header 都放进同一个 snippet,不要在 snippet 外再单独加。 或者用
more_set_headers(需要 headers-more 模块,行为是"追加"而非"覆盖")。
34.5 配置加载顺序:glob 是按字典序的
include /etc/nginx/conf.d/*.conf;
conf.d/ 里有:
1-a.conf、10-b.conf、2-c.conf,
加载顺序是 1-a → 10-b → 2-c(按字符串比较)。这会影响两件事:
1. map 必须先定义后使用(虽然 map 是惰性的,但语法上要在同一个 http 块内)
2. default_server 的选择——同一端口上如果多个 server 都没标
default_server,
第一个被加载的会成为默认所以文件名一定用两位数字前缀:
00-、10-、99-。
34.6 同一个 server_name 出现在多处
$ nginx -t
nginx: [warn] conflicting server name "example.com" on 0.0.0.0:443, ignored
危险之处:你以为新配置生效了,其实没有——流量还在走旧的那个 server 块。
排查方法:
nginx -T | grep -n "server_name.*example.com",看看有几处。
常见来源:1.
conf.d/ 和 sites-enabled/ 里都有同一个站(Ubuntu 双 include 的坑)2.
sites-enabled/ 里的软链接还指向已改名的文件3.
default 文件没删,里面写了 server_name _;(这个不冲突但会抢 default_server)
34.7 一个生产级的 nginx.conf 骨架
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
# error_log /var/log/nginx/error.log notice; # 排查时临时开
events {
worker_connections 4096;
multi_accept on;
use epoll;
}
http {
# ── 基础 ──
include /etc/nginx/mime.types;
default_type application/octet-stream;
charset utf-8;
server_tokens off;
# ── 日志 ──
log_format detail '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for" rt=$request_time urt=$upstream_response_time '
'ua=$upstream_addr us=$upstream_status cs=$upstream_cache_status';
access_log /var/log/nginx/access.log detail;
# ── 性能 ──
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
types_hash_max_size 2048;
client_max_body_size 100m;
client_body_buffer_size 128k;
client_header_buffer_size 4k;
large_client_header_buffers 4 16k;
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 30s;
reset_timedout_connection on;
# ── 压缩 ──
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml application/atom+xml application/x-javascript
image/svg+xml font/woff2;
gzip_disable "msie6";
# ── 文件缓存 ──
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# ── SSL 全局 ──
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
# ── 限流区(必须先声明) ──
limit_req_zone $binary_remote_addr zone=general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
# ── 全局 map ──
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ── 引入 ──
include /etc/nginx/conf.d/*.conf;
}
34.8 配置管理的好习惯
- 配置纳入 Git。至少放个本地 git,改动留痕,出问题能 diff
- 每次改完先
nginx -t再systemctl reload nginx,永远不要直接 restart - 改之前备份:
cp site.conf site.conf.bak.$(date +%Y%m%d-%H%M) - 文件命名用数字前缀:
00-、10-、99- - 重复配置抽 snippet,但注意 add_header 的覆盖语义
- 不要用
default文件(Ubuntu 自带那个),删掉或改名,避免和你的站点抢 default_server
34.9 本章小结
include是原地文本展开,conf.d/*.conf里只写 server 块,不写 http 块sites-available/sites-enabled用软链接控制启用,conf.d直接放- glob 按字典序加载,文件名用两位数字前缀
- 重复配置抽成 snippet,但
add_header的覆盖语义要小心 conflicting server name不报错只警告,用nginx -T | grep找出来
35Nginx 与容器化部署
容器里跑 Nginx 有两件事和裸机不一样:日志不能写文件(容器一删就没了),以及 Docker 的默认 DNS 是 127.0.0.11(变量形式的 proxy_pass 会用到)。
35.1 最小可用 Dockerfile
# ── 阶段 1:构建前端 ──
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── 阶段 2:Nginx 提供静态服务 ──
FROM nginx:1.27-alpine
# 删掉默认站点,避免端口冲突和多余配置
RUN rm -f /etc/nginx/conf.d/default.conf
# 拷贝自定义配置
COPY nginx.conf /etc/nginx/conf.d/app.conf
# 拷贝构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1/health || exit 1
EXPOSE 80
# 官方镜像自带的 CMD 就是 nginx -g 'daemon off;',不用重写
# CMD ["nginx", "-g", "daemon off;"]
35.2 容器里的 nginx.conf
# /etc/nginx/conf.d/app.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── 日志写 stdout/stderr,交给 docker logs ──
access_log /dev/stdout;
error_log /dev/stderr warn;
# ── 健康检查 ──
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# ── 静态资源强缓存 ──
location ~* \.(css|js|jpg|jpeg|png|gif|svg|webp|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
try_files $uri =404;
}
# ── SPA fallback ──
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
access_log /dev/stdout; — 直接写设备文件,简单直接access_log /dev/stdout main; — 带格式名在某些环境(容器 + 特殊存储驱动)
/dev/stdout 是符号链接到 /proc/self/fd/1,
多进程可能有问题。更稳的写法:access_log /proc/self/fd/1;或者直接用官方镜像的默认行为——不配 access_log,它会写到
/var/log/nginx/access.log,
再软链到 stdout(官方镜像已经做了:ln -sf /dev/stdout /var/log/nginx/access.log)。
35.3 为什么容器里 Nginx 要用 127.0.0.11 解析
# ── 场景:Nginx 容器要反代同一 Docker 网络里的另一个容器 ──
# ✗ 不推荐:写死在 upstream 里
upstream api {
server api:8080; # 容器名。启动时解析一次,之后 IP 变了不更新
}
# 问题:如果 api 容器重启换了 IP,Nginx 还在往旧 IP 发,直到你 reload
# ✓ 推荐:用变量形式 + Docker 内置 DNS,每次请求都解析
server {
listen 80;
# Docker 内置 DNS 固定是 127.0.0.11
resolver 127.0.0.11 valid=10s ipv6=off;
location /api/ {
set $api_upstream "api:8080";
proxy_pass http://$api_upstream;
proxy_set_header Host $host;
}
}
proxy_pass http://$api_upstream; 换来动态 DNS,代价是:1. 失去 upstream 的所有功能——负载均衡、健康检查、keepalive 池全没了
2. 每次请求都要做一次 DNS 查询(有
valid=10s 缓存,10 秒一次)3. URI 不再做前缀替换,
proxy_pass http://$x/; 里的斜杠不生效结论:如果你需要负载均衡,还是用
upstream + 定期 reload(或者用商业版的 resolve 参数)。
只有在"单后端 + 需要跟随容器 IP 变化"时才用变量形式。
35.4 docker-compose 完整示例
version: "3.9"
services:
nginx:
image: nginx:1.27-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/certs:/etc/nginx/certs:ro
- ./nginx/logs:/var/log/nginx
- ./dist:/usr/share/nginx/html:ro
depends_on:
- api
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
interval: 30s
timeout: 3s
retries: 3
api:
build: ./api
expose:
- "8080" # 只对内网暴露,不映射到宿主
environment:
- ASPNETCORE_URLS=http://+:8080
restart: unless-stopped
redis:
image: redis:7-alpine
expose:
- "6379"
restart: unless-stopped
对应的 Nginx 配置:
# ./nginx/conf.d/site.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location = /health {
access_log off;
return 200 "ok\n";
}
location /api/ {
# 这里的 api 是 compose 里的服务名,Docker DNS 能解析
# 因为用 upstream 静态解析,需要 nginx 启动时 api 已在 DNS 中
# compose 里 nginx depends_on api 能保证顺序(但不保证 api 已就绪)
proxy_pass http://api:8080;
proxy_http_version 1.1;
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 Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 60s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
depends_on: [api] 只保证 api 容器先启动,
不保证 api 已经监听就绪。但 Nginx 的
proxy_pass http://api:8080(不带变量)会在启动时解析 api 这个主机名——
如果此时 DNS 里还没有这个记录,Nginx 直接启动失败退出:nginx: [emerg] host not found in upstream "api"这在
docker compose up 首次启动时很常见(尤其镜像要下载时)。三种解法:
1. 用变量形式 +
resolver 127.0.0.11(启动时不必解析)2. 在 Nginx 配置里加
upstream api { server api:8080 max_fails=0; } —— 没用,还是要解析3. 给 Nginx 加启动脚本,先等
getent hosts api 成功再启动 nginx最省事的做法是第 1 种。或者用
docker compose up --wait 配合 healthcheck。
35.5 容器里的四个特有坑
# ── 坑 1:worker_processes auto 在容器里会读宿主的 CPU 数 ──
# 如果容器限了 cpu=1,auto 还是按宿主 32 核起 32 个 worker,内存浪费
# 检查:docker exec nginx nproc
# 解决:显式写 worker_processes 2; 或者用 cpus 限制 + 环境变量
# ── 坑 2:client_body_temp_path 在只读文件系统上会失败 ──
# 如果以 read_only: true 启动容器,大文件上传会 500
# 解决:加 tmpfs
# docker run --read-only --tmpfs /var/cache/nginx --tmpfs /tmp nginx
# ── 坑 3:域名解析失败导致启动崩溃 ──
# 上面讲过,upstream 里有无法解析的名字 → [emerg] host not found → 容器反复重启
# 排查:docker logs <container> | tail -20
# ── 坑 4:配置挂载成目录而不是文件,改配置后容器不感知 ──
# ✗ -v ./nginx.conf:/etc/nginx/nginx.conf
# 编辑宿主文件时,容器里还是旧内容(inode 变了)
# ✓ -v ./nginx/conf.d:/etc/nginx/conf.d
# 挂目录,改文件容器能看到
# 修复已挂文件的情况:docker cp 进去,或者重启容器
docker exec nginx nginx -t — 测配置docker exec nginx nginx -T — 打印全部展开配置docker exec nginx nginx -s reload — 热重载(改配置后执行)docker exec nginx nginx -V 2>&1 — 看编译参数和模块如果容器里没装
vim/curl(alpine 精简镜像),用宿主机的
docker run --rm -it --network container:nginx nicolaka/netshoot curl -v localhost/ 这类调试容器。
35.6 生产级 Dockerfile:非 root + 无供应商锁定
FROM nginx:1.27-alpine
# 装一些有用的工具(可选)
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 删默认站点
RUN rm -f /etc/nginx/conf.d/default.conf
# 改 nginx.conf:pid 路径 + 临时目录(为了支持非 root)
RUN sed -i 's|pid /var/run/nginx.pid;|pid /tmp/nginx.pid;|' /etc/nginx/nginx.conf && \
sed -i 's|user nginx;|user nginx;|' /etc/nginx/nginx.conf
COPY site.conf /etc/nginx/conf.d/site.conf
COPY dist/ /usr/share/nginx/html/
# 非 root 运行需要这些目录可写
RUN chown -R nginx:nginx /usr/share/nginx/html /var/cache/nginx /var/log/nginx && \
touch /tmp/nginx.pid && chown nginx:nginx /tmp/nginx.pid
USER nginx
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1
USER nginx 运行时,下面三处都必须可写,否则启动失败:1. PID 文件 — 默认
/var/run/nginx.pid,非 root 无权写 /var/run,改成 /tmp/nginx.pid2. 缓存目录 —
/var/cache/nginx 下会建 proxy_temp、client_body_temp 等子目录3. 日志目录 — 如果还写文件的话
另外
listen 80 在非 root 下也绑不上(1024 以下端口需要特权),
所以非 root 镜像内部要监听 8080,由 compose/k8s 映射成外部的 80。
35.7 K8s 环境的三点提示
- livenessProbe 不要用
/,用专门的/health且access_log off,否则健康检查请求会把日志刷爆,也会拖慢探针 - ConfigMap 挂载的配置改动不会自动 reload。可以用 sidecar(
nginx-reloader)监听文件变化并触发 reload,或者在 Helm chart 里算配置的 sha256 作为 annotation 触发滚动更新 - Pod 的
$remote_addr是 k8s Service 的 IP,不是真实客户端。要在 Nginx 前有 LoadBalancer/Ingress 时,得配real_ip_from才能拿到真 IP(见第 37 章)
35.8 本章小结
- 容器里日志走 stdout/stderr,交给
docker logs - Docker 内置 DNS 是
127.0.0.11;变量形式proxy_pass才能跟随容器 IP 变化(代价是失去 upstream 功能) upstream里的容器名必须启动时可解析,否则host not found崩溃循环- 挂配置要挂目录而不是单个文件
- 非 root 运行要改 PID 路径 + 缓存目录权限 + 监听 1024 以上端口
36Nginx 与前端 SPA 部署
SPA(单页面应用)的部署有几个固定套路,但每个套路都有坑。这一章把 React/Vue 部署到 Nginx 的完整细节讲清楚。
36.1 核心问题:前端路由 vs 服务端路由
SPA 的路由在前端(用 History API 操作地址栏)。当用户直接访问 /user/123 或刷新页面时,浏览器会真的向服务器请求 /user/123——而服务器上并没有这个文件,于是 404。
36.2 标准 SPA 配置(React / Vue 通用)
server {
listen 443 ssl;
http2 on;
server_name app.example.com;
root /var/www/app/dist;
index index.html;
# ── 1. 入口 HTML:绝对不缓存 ──
# index.html 里引用的 js/css 文件名带 hash,HTML 一更新就指向新文件
# 如果 index.html 被缓存,用户会一直加载旧的 js 引用 → 白屏 / 功能不更新
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
etag off;
}
# ── 2. 带 hash 的静态资源:永久强缓存 ──
# Vite: /assets/index-Bx3kL9mN.js
# webpack: /static/js/main.a1b2c3d4.chunk.js
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
try_files $uri =404;
}
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|avif|ico|woff|woff2|ttf|eot|map)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
try_files $uri =404;
}
# ── 3. 不缓存的文件(service worker、manifest) ──
location ~* ^/(sw\.js|service-worker\.js|manifest\.json|manifest\.webmanifest)$ {
add_header Cache-Control "no-cache" always;
}
# ── 4. API 反代 ──
location /api/ {
proxy_pass http://app_backend;
proxy_http_version 1.1;
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 Connection "";
}
# ── 5. SPA fallback:必须放在最后 ──
location / {
try_files $uri $uri/ /index.html;
}
}
1.
index.html 不缓存 → 用户每次访问都拿最新的 HTML2. 新 HTML 里引用了新 hash 的文件名 → 浏览器发现是新 URL,必然重新请求
3. 旧 hash 的文件还在服务器上(或 CDN 上)→ 别的用户还引用着它,不会 404
4. 静态资源
immutable 强缓存 → 同一个 hash 的文件永远不会变,缓存收益拉满关键:构建工具必须开启 content hash。Vite 默认开;webpack 需要
[contenthash]。另外 部署时要保留旧文件至少一个版本,否则正在浏览的用户点进新页面时会 404。
36.3 Vue Router 的两种模式
# ── History 模式(createWebHistory)──
# URL 好看:/user/123
# 需要服务端 fallback(就是上面的 try_files)
location / {
try_files $uri $uri/ /index.html;
}
# ── Hash 模式(createWebHashHistory)──
# URL 带 #:/#!/user/123
# #后面的内容不发到服务器,所以不需要 fallback
# 但要注意:带 # 的 URL 对 SEO 不友好,且部分统计工具抓不到
location / {
try_files $uri $uri/ =404; # 常规静态服务即可
}
try_files $uri $uri/ /index.html;$uri — 先看是不是一个存在的文件$uri/ — 再看是不是一个存在的目录(会触发目录索引查找)/index.html — 最后兜底如果写反成
try_files $uri/ $uri /index.html;,目录会被优先匹配,可能导致
/foo 没有斜杠时行为不一致。固定用 $uri $uri/ /index.html 这个顺序。
36.4 多环境部署
/var/www/
├── app-prod/ ← 当前生产版本
│ └── dist/
├── app-prod-prev/ ← 上一个版本(回滚用)
└── app-staging/ ← 测试环境
# 用软链接做蓝绿切换,切换只需改链接 + reload(毫秒级)
# /var/www/app-current -> /var/www/app-prod-20260915
server {
listen 443 ssl;
server_name app.example.com;
root /var/www/app-current/dist;
...
}
#!/bin/bash
# 一键发布 + 可回滚
set -euo pipefail
NEW_DIR="/var/www/app-release-$(date +%Y%m%d-%H%M%S)"
CURRENT="/var/www/app-current"
# 1. 上传新版本到 NEW_DIR
rsync -az --delete ./dist/ "root@server:$NEW_DIR/"
# 2. 原子切换软链接(ln -sfn 是原子的)
ssh root@server "
set -e
ln -sfn $NEW_DIR $CURRENT
nginx -t && systemctl reload nginx
# 3. 保留最近 3 个版本,其余删掉
cd /var/www && ls -1dt app-release-* | tail -n +4 | xargs -r rm -rf
"
echo "deployed to $NEW_DIR"
ln -sfn target link 会先建一个新符号链接再 rename 覆盖旧的,是原子操作——不会有一瞬间链接不存在。但
ln -sf(不带 n)在 link 已经是目录时会失败或行为怪异,一定要带 -n。另外 rsync 先灌满新目录再切换,这样用户不会看到"文件传了一半"的中间状态。
36.5 前端资源跨域与预检
# 前后端分离时,前端域名和 API 域名不同 → 浏览器预检
# 推荐方案:让 Nginx 同域反代 API,前端完全不跨域
server {
listen 443 ssl;
server_name app.example.com;
root /var/www/app/dist;
# /api/* 全部反代到后端,浏览器视角里同域,没有 CORS 问题
location /api/ {
proxy_pass http://backend;
# proxy_pass 带路径时做前缀替换:/api/x → /x
# 不带路径则原样透传:/api/x → /api/x
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
# 如果后端必须独立域名(如 api.example.com),那就得配 CORS
server {
listen 443 ssl;
server_name api.example.com;
location / {
add_header Access-Control-Allow-Origin "$http_origin" always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age 86400 always;
add_header Vary "Origin" always;
if ($request_method = OPTIONS) {
add_header Content-Length 0;
return 204;
}
proxy_pass http://backend;
}
}
Access-Control-Allow-Origin "$http_origin" 等于允许所有来源——
任何网站都能带着用户的 Cookie 调你的 API。正确做法是用
map 做白名单(见第 33.2 节的用法 4):map $http_origin $cors_origin { default ""; "https://app.example.com" $http_origin; }然后再
add_header Access-Control-Allow-Origin $cors_origin always;不在白名单时就返回空串,浏览器自然拒绝。同时必须加
Vary: Origin,防止缓存串味。
36.6 常见白屏问题速查
| 现象 | 原因 | 修复 |
|---|---|---|
| 首页正常,刷新子路由 404 | 缺 SPA fallback | 加 try_files $uri $uri/ /index.html; |
| 改了代码,用户还是看到旧版本 | index.html 被缓存了 |
给 /index.html 加 no-cache;检查 CDN 缓存 |
刷新后 /assets/x.js 404 | 旧版本的资源被删了 | 保留至少一个旧版本的 assets/ 目录 |
| JS/CSS 加载成了 HTML 内容 | 请求了不存在的 js,被 fallback 成 index.html |
静态资源 location 里用 try_files $uri =404; 而不是 fallback |
| 接口 404 但直接访问后端正常 | proxy_pass 路径拼接问题 |
检查 proxy_pass 末尾斜杠;看后端收到的实际路径 |
| source map 404 控制台刷屏 | 生产包没带 .map 文件 | 关掉 devtools 的 source map,或部署时带上 .map |
| gzip 没生效 | gzip_types 缺 application/javascript |
补上类型;注意默认只压 text/html |
location / { try_files $uri $uri/ /index.html; } 这条规则意味着:
请求 /assets/old-hash.js(文件不存在)时会返回 index.html 内容,
状态码 200。浏览器拿到一段 HTML 当 JS 解析,报错是
Uncaught SyntaxError: Unexpected token '<'——
这个错误信息完全不提 404,能让你查半天。解法:给所有静态资源 location 加
try_files $uri =404;,
让不存在的静态资源明确返回 404,报错才看得懂。
36.7 本章小结
- SPA 必备:
try_files $uri $uri/ /index.html;放最后 - 黄金组合:index.html 不缓存 + 带 hash 的资源强缓存
- 静态资源 location 里加
try_files $uri =404;,避免"JS 变 HTML" - 同域反代比配 CORS 更省事;必须跨域时用 map 白名单,别直接回显
$http_origin - 发布用软链接切换(
ln -sfn),保留旧版本供回滚
第九部分 · 实战
37传递真实 IP 的完整链路
这是生产环境里最容易长期出错、又最难发现的一块。你的应用日志里记的很可能一直是 Nginx 自己的 IP(127.0.0.1),而你以为那是用户 IP。
37.1 问题从哪来
用户(1.2.3.4) → CDN / WAF → 负载均衡(SLB) → Nginx → 应用
每一跳都会:把自己的对端 IP 记成 remote_addr,把真实的放进 XFF 头
到 Nginx 时:
$remote_addr = SLB 的内网 IP(如 10.0.1.5) ← 假的
$http_x_forwarded_for = "1.2.3.4, 203.0.113.9" ← 真的在最左边
到应用时(如果 Nginx 没配):
RemoteAddr = 127.0.0.1(Nginx 自己) ← 假的更离谱
X-Forwarded-For = "10.0.1.5, 1.2.3.4, 203.0.113.9"
37.2 第一层:Nginx 还原真实 IP
http {
# ── 声明哪些 IP 是「可信代理」 ──
# 只有来自这些 IP 的请求,Nginx 才会去读它的 XFF 头来覆盖 remote_addr
# 1. 本机(同机反代时)
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
# 2. 内网段(公司内网 / K8s Pod 网段 / Docker 网段)
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;
# 3. 云厂商 SLB 的回源 IP 段(下面举例,实际按你的云厂商文档填)
# 腾讯云 CLB 回源段(示例,需查最新文档)
# set_real_ip_from 100.64.0.0/10;
# 阿里云 SLB 回源段
# set_real_ip_from 100.64.0.0/10;
# 4. CDN 回源 IP(以 Cloudflare 为例)
# set_real_ip_from 173.245.48.0/20;
# set_real_ip_from 103.21.244.0/22;
# set_real_ip_from 103.22.200.0/22;
# ... 完整列表要定期同步
# ── 从哪里取真实 IP ──
real_ip_header X-Forwarded-For; # 最常用
# real_ip_header X-Real-IP; # 只有一跳且单值时用
# real_ip_header proxy_protocol; # 用了 PROXY protocol 时
# ── 递归深度:从 XFF 右侧往左剥几层 ──
# 1 = 只取最右边那个(适用于只有一层可信代理)
real_ip_recursive on; # on = 从右往左找,跳过所有可信代理
# off = 直接取 XFF 最右边的值(默认)
}
off(默认):直接取 XFF 里最后一个值作为 remote_addr。on:从 XFF 最右边开始往左走,跳过所有在 set_real_ip_from 列表里的 IP,
取第一个不在列表里的。举例:XFF =
"1.2.3.4, 10.0.1.5",可信列表含 10.0.0.0/8·
off → remote_addr = 10.0.1.5(错的!这是代理)·
on → remote_addr = 1.2.3.4(对的)所以多层代理时一定要
on。只有一层代理两者等效。
攻击者只要发
X-Forwarded-For: 1.2.3.4,你的日志里、限流里、封禁逻辑里,
他就变成了 1.2.3.4。后果:
· 限流被绕过——每次请求伪造不同 IP,
limit_req 完全失效· IP 封禁被绕过——或者更糟,你能封掉任意别人的 IP(伪造管理员的 IP 让系统把自己封了)
· 审计日志全是假的——出事后完全无法追溯
只填你自己实际控制、且会给你打 XFF 的代理的 IP。
37.3 第二层:Nginx 传给应用
location /api/ {
proxy_pass http://app_backend;
# ── 四个必带头 ──
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_set_header X-Original-URI $request_uri;
# 保留协议(应用判断是否该发 HSTS 时用)
proxy_set_header X-Forwarded-Ssl $https; # https 时值为 on
}
$http_x_forwarded_for, $remote_addr——把来源 XFF 拼上当前对端 IP。如果请求原本没有 XFF 头,结果就是单纯的
$remote_addr(不会有前导逗号)。对比:
proxy_set_header X-Forwarded-For $remote_addr; 会覆盖掉上游传下来的 XFF,
整条链路信息就只剩最后一跳了。除非你明确只想传一跳,否则一律用 $proxy_add_x_forwarded_for。
37.4 第三层:应用侧怎么读
不同框架读法不同,读错了就等于白配。
ASP.NET Core
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 关键:默认情况下 ASP.NET Core 不信任任何代理头
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto;
// 清空默认值——默认只信任 loopback,且清空后必须显式加回
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
// 加入你的代理 IP(生产环境必须明确列出)
options.KnownProxies.Add(IPAddress.Parse("127.0.0.1"));
options.KnownProxies.Add(IPAddress.Parse("10.0.1.6"));
// 或者整个内网段
options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
});
var app = builder.Build();
// 必须在 UseRouting 之前调用
app.UseForwardedHeaders();
app.MapGet("/whoami", (HttpContext ctx) => new
{
RemoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
Xff = ctx.Request.Headers["X-Forwarded-For"].ToString(),
XRealIp = ctx.Request.Headers["X-Real-IP"].ToString(),
Scheme = ctx.Request.Scheme,
IsHttps = ctx.Request.IsHttps
});
app.Run();
KnownProxies 里没有代理 IP,转发头会被静默忽略——不报错,就是没生效。坑 2:必须加在
UseRouting() 之前。加载顺序错了同样静默失效。另外注意:
KnownNetworks.Clear() 之后如果不加回任何网段,等于完全不信任任何代理。调试方法:写个
/whoami 端点把上面几个值打出来,一眼就能看出到底哪一层没生效。
Node.js / Express
const express = require('express');
const app = express();
// 关键:不设这个,req.ip 永远是 Nginx 的 IP
app.set('trust proxy', 'loopback'); // 只信本机
// app.set('trust proxy', '10.0.0.0/8'); // 信任内网段
// app.set('trust proxy', 1); // 信任最近的一跳代理
// app.set('trust proxy', true); // 信任全部 ← 危险,等于没有防护
app.get('/whoami', (req, res) => {
res.json({
ip: req.ip, // 由 trust proxy 决定
ips: req.ips, // XFF 拆成的数组
xForwardedFor: req.get('X-Forwarded-For'),
xRealIp: req.get('X-Real-IP'),
protocol: req.protocol, // 需要 trust proxy 才正确
hostname: req.hostname,
secure: req.secure
});
});
// 获取最左侧(最原始)的客户端 IP
function getClientIp(req) {
const xff = req.get('X-Forwarded-For');
if (xff) return xff.split(',')[0].trim();
return req.socket.remoteAddress;
}
app.listen(5000, '127.0.0.1');
true 意味着"信任任何来源的 XFF",效果等同于 Nginx 那边写
set_real_ip_from 0.0.0.0/0。攻击者直接访问应用(如果端口暴露了)或者构造请求,就能伪造
req.ip。正确做法是指明具体 IP 或网段。如果 Nginx 和应用在同一台机器上,用
'loopback' 就够了。
37.5 验证整条链路
# ── 从外网真实地打一次,看每一层记到什么 ──
# 命令行模拟不了真实链路(XFF 是中间设备打的),所以要真的用手机/外网访问
# 1. 看 Nginx 日志里的 IP
tail -1 /var/log/nginx/access.log
# 期望:第一个字段是真实公网 IP,不是内网 IP
# 2. 看应用拿到的(假设有 /whoami)
curl -s https://app.example.com/whoami | jq
# 3. 用带 XFF 的请求测「伪造是否会生效」(在 Nginx 后面的应用上测)
curl -s -H "X-Forwarded-For: 8.8.8.8" https://app.example.com/whoami
# 如果 remote_addr 变成了 8.8.8.8 → 你的配置信任了不该信任的来源!有漏洞
# 4. 检查 Nginx 里有没有危险的 0.0.0.0/0
nginx -T | grep -n "set_real_ip_from"
grep -rn "0.0.0.0/0" /etc/nginx/ # 应该没有任何输出
# 5. 检查应用里有没有 trust all
grep -rn "trust proxy.*true\|KnownProxies.Clear()" --include="*.js" --include="*.cs" .
X-Forwarded-For: 8.8.8.8 的请求,
然后看你的应用认为客户端是谁。如果它认为是 8.8.8.8,说明你的
set_real_ip_from 太宽(或者根本没配,而应用直接读了 XFF)。
这是一个真实可利用的漏洞。正确的是:应用看到的应该还是你的真实 IP,因为来自非可信来源的 XFF 头必须被忽略。
37.6 PROXY protocol:另一种方案
如果上游是 TCP 层负载均衡(如 AWS NLB、腾讯云 CLB 的 TCP 模式),它不改 HTTP 头,而是用 PROXY protocol 在连接开头塞一小段二进制信息。
server {
# 必须要 listen 指令上声明才启用
listen 80 proxy_protocol;
set_real_ip_from 10.0.0.0/8;
real_ip_header proxy_protocol;
location / {
proxy_pass http://app_backend;
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;
}
}
listen 80 proxy_protocol;,Nginx 只会接受带 PROXY protocol 头的连接。你直接
curl http://127.0.0.1/ 会收到 400 Bad Request,
因为 curl 发的是纯 HTTP。调试时要手动构造:
printf 'PROXY TCP4 1.2.3.4 5.6.7.8 12345 80\r\nGET / HTTP/1.1\r\nHost: x\r\n\r\n' | nc 127.0.0.1 80所以这个开关要谨慎,保持模式一致——不能既想直连又想收 PROXY protocol。
37.7 本章小结
- 三层都要配:Nginx 的
real_ip↔ Nginx 的proxy_set_header↔ 应用的信任配置 set_real_ip_from只能填你自己控制的代理 IP,绝不能写0.0.0.0/0- 多层代理用
real_ip_recursive on,单层用 off 即可 - 转发用
$proxy_add_x_forwarded_for(追加),不要用$remote_addr(覆盖) - 应用侧:ASP.NET Core 要
UseForwardedHeaders()放最前 + 显式 KnownProxies;Express 用trust proxy指明白名单,别用true - 自检:发一个伪造 XFF 的请求,看应用是否被骗
38灰度发布与 A/B 测试
灰度发布的目标是:让一小部分流量先吃到新版本,确认没问题再放大。Nginx 层面有四种主流做法,各适用不同场景。
38.1 四种分流策略对比
| 策略 | 实现 | 用户是否稳定命中 | 适用场景 |
|---|---|---|---|
| 按比例 | split_clients $remote_addr |
是(同一 IP 稳定) | 最常用的灰度 |
| 按 Cookie | map $cookie_version |
是(可手动控制) | 内部测试、定向放量 |
| 按 Header | map $http_x_version |
是(调用方决定) | 服务间调用、API 灰度 |
| 按 IP 列表 | map $remote_addr + 文件 |
是 | 白名单内测 |
| 按权重 upstream | upstream ... weight= |
否(每次请求随机) | 不是灰度,是负载均衡 |
weight=1 和 weight=9 确实能实现 10% 流量到新版本,
但同一个用户会在新旧版本之间来回跳——登录态的 Redis key 格式变了、上传的文件在新版本读不到、
购物车数据不一致,用户体验直接崩掉。灰度必须是「按用户粘性」的。要么按 IP、要么按 Cookie,保证一个用户全程只看到一个版本。
38.2 方案一:按比例灰度(split_clients)
http {
# 按客户端 IP 计算哈希,10% 走新版本
# 用 $remote_addr 而不是 $request_id,保证同一用户稳定命中
split_clients $remote_addr $version {
10% "v2";
* "v1";
}
upstream app_v1 {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
keepalive 32;
}
upstream app_v2 {
server 10.0.2.10:8080; # 新版本只有一台
keepalive 32;
}
server {
listen 443 ssl;
server_name app.example.com;
location /api/ {
# 变量形式的 proxy_pass,upstream 名由变量决定
proxy_pass http://app_$version;
proxy_http_version 1.1;
proxy_set_header Connection "";
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-App-Version $version;
# 关键:加响应头,方便在浏览器/devtools 里一眼看出命中了哪个版本
add_header X-Served-By $version always;
}
# 提供一个查看当前命中版本的接口
location = /version {
default_type text/plain;
return 200 "you are served by $version\n";
}
}
}
split_clients 的百分比是写死在配置里的,改比例需要 nginx -s reload。想动态调(比如从 10% 慢慢涨到 50%)有两个办法:
1. 把 upstream 名单和比例放到一个
include 的文件里,脚本改文件 + reload(reload 是优雅的,不丢连接)2. 用 Cookie/Header 方案替代,比例由应用侧的发 Cookie 逻辑控制
另外注意:
split_clients 的哈希不会因为 upstream 变化而重算——它只哈希你指定的那个变量,
所以改 upstream 名单不影响已分好的用户组。这是它的优点。
38.3 方案二:按 Cookie 灰度(推荐,可控性最好)
http {
# 优先看 cookie,其次看 header,都没有就走默认
map $cookie_app_version $ver_by_cookie {
default "";
"v2" "v2";
"v1" "v1";
}
map $http_x_app_version $ver_by_header {
default "";
"v2" "v2";
}
# 组合:header > cookie > 默认
map "$ver_by_header:$ver_by_cookie" $version {
default "v1";
"v2:*" "v2"; # header 指定了 v2
":v2" "v2"; # cookie 指定了 v2
"v2:v1" "v2"; # header 优先
}
upstream app_v1 { server 10.0.1.10:8080; }
upstream app_v2 { server 10.0.2.10:8080; }
server {
listen 443 ssl;
server_name app.example.com;
location /api/ {
proxy_pass http://app_$version;
add_header X-Served-By $version always;
}
# ── 运营/测试可自助切版本 ──
location = /ver/v1 {
add_header Set-Cookie "app_version=v1; Path=/; Max-Age=604800; SameSite=Lax" always;
return 302 /;
}
location = /ver/v2 {
add_header Set-Cookie "app_version=v2; Path=/; Max-Age=604800; SameSite=Lax" always;
return 302 /;
}
location = /ver/reset {
add_header Set-Cookie "app_version=; Path=/; Max-Age=0" always;
return 302 /;
}
}
}
/ver/v2 就切过去,不用改服务器配置2. 可解释 — 用户反馈问题时,让他看 cookie 就知道他在哪个版本
3. 可精确放量 — 应用登录接口里判断"用户 ID 尾号 % 10 == 0 就下发 v2 cookie",比例由业务决定
4. 无需 reload — 全新的分流逻辑不需要动 Nginx
生产中的标准做法:Nginx 只负责读 cookie 路由,放量比例由应用在登录/首次访问时决定下发哪个 cookie。 这样比例可以随时调整、逐步放大,完全不碰 Nginx。
38.4 方案三:粘性 IP 哈希(无状态应用)
upstream backend {
ip_hash; # 同一 IP 固定到同一台后端
server 10.0.1.10:8080;
server 10.0.1.11:8080;
server 10.0.2.10:8080; # 新增的机器
}
# 问题:ip_hash 是取模算法,加/减机器会大范围重分配
# 解决:用一致性哈希(需要 upstream_hash 模块,通常已内置)
upstream backend_consistent {
hash $remote_addr consistent; # 一致性哈希,加节点只影响 1/N 的流量
server 10.0.1.10:8080;
server 10.0.1.11:8080;
server 10.0.2.10:8080;
}
# 也可以按 URL 哈希(提高缓存命中率)
upstream cache_cluster {
hash $request_uri consistent;
server 10.0.1.20:8080;
server 10.0.1.21:8080;
}
ip_hash — 老算法,基于 IP 前 3 段(IPv4)做取模。加一个节点会导致大部分用户的会话迁移到别的机器,
如果会话存在本机内存里,用户会突然掉登录。hash $key consistent — 一致性哈希,加一个节点只影响约 1/N 的流量。
需要扩缩容的场景必须用这个。但更好的做法是把会话拿出来放到 Redis,然后就不用粘性了——这是更彻底的解决方案。
38.5 A/B 测试:与灰度的区别
灰度是"逐步验证新版本可用性",A/B 是"同时跑两个版本,比较指标"。技术上类似,但有三个额外要求:
http {
split_clients $cookie_userid $ab_bucket {
50% "A";
* "B";
}
log_format ab_json escape=json '{'
'"time":"$time_iso8601",'
'"user":"$cookie_userid",'
'"bucket":"$ab_bucket",'
'"uri":"$request_uri",'
'"status":$status,'
'"rt":$request_time,'
'"urt":"$upstream_response_time"'
'}';
server {
listen 443 ssl;
server_name ab.example.com;
access_log /var/log/nginx/ab.json.log ab_json;
location / {
proxy_pass http://backend_$ab_bucket;
# 把分桶传给应用,应用打点到业务埋点里
proxy_set_header X-AB-Bucket $ab_bucket;
add_header X-AB-Bucket $ab_bucket always;
}
}
}
$request_id——那会导致同一用户在不同请求里进不同组,指标就废了。2. 样本量要够。分流不均(比如实际 47% / 53%)在小样本下会让结论完全错误。 上线前先跑一天,统计一下两组的请求量是否接近。
3. 要能归因到业务指标。Nginx 只能记请求级数据(耗时、状态码)。 转化率、留存这类指标必须由应用把 bucket 写进埋点,两边通过 user key 关联。光靠 Nginx 日志做不了 A/B 分析。
38.6 灰度发布的完整流程
阶段 0 部署新版本到 v2 集群(不接流量)
↓ 健康检查通过
阶段 1 内部白名单 < 1%
按 Header 或 Cookie,只让测试同学命中
观察:错误率、P99 耗时、日志异常
↓ 稳定 2 小时
阶段 2 小比例 5% 真实流量
按 remote_addr 哈希,保证用户粘性
观察:业务指标(下单成功率、接口成功率)
↓ 稳定 1 天
阶段 3 放大到 30% → 50% → 100%
每次放量后至少观察 2 小时
↓
阶段 4 全量。旧版本保留 1 天(方便秒级回滚),然后下线
任意阶段出现异常:
回滚 = 把灰度开关关掉(改 map 的 default 或删 cookie)
★ 不需要重新部署,不需要 reload 都行(Cookie 方案)
★ split_clients 方案需要 reload(毫秒级,不丢连接)
这就是为什么Cookie 方案最优:把
/ver/reset 或者"清除 cookie"作为应急手段,
用户下一次请求就回到 v1 了。而如果是靠
upstream 权重或 split_clients 比例,
回滚虽然也只是 reload,但你得先把配置改回来——多了几步,多了出错的机会。记住:灰度方案的设计里,回滚路径的可靠性比放量路径更重要。
38.7 本章小结
- 灰度必须按用户粘性分流,不能用 upstream weight
split_clients $remote_addr按比例;Cookie/Header 方案可控性最好- 生产推荐:Nginx 读 cookie 路由 + 应用决定下发哪个 cookie,放量完全不碰 Nginx
ip_hash取模扩缩容会大范围重分配;用hash $key consistent- A/B 测试的分桶种子必须稳定(用户 ID),且业务指标要靠应用的埋点,Nginx 日志不够
- 设计灰度时先想清楚怎么秒级回滚
39性能压测与调优实证
调优最大的陷阱是凭感觉优化。这一章讲怎么用数据说话:先压测建立基线,找到真正的瓶颈,改一个参数,再压一次对比。
39.1 先看现状:四个命令建立基线
# ── 1. 看连接与请求统计 ──
ss -s
# Total: 245
# TCP: 431 (estab 12, closed 380, orphaned 0, timewait 380)
# ↑ TIME_WAIT 多说明短连接多
# ── 2. 看 worker 是否均衡(所有 worker 的 cpu 时间应接近) ──
ps -eo pid,comm,pcpu,time | grep "nginx: worker"
# ── 3. Nginx 自己的 stub_status ──
# 先配置:见 39.2
curl -s http://127.0.0.1:8080/nginx_status
# ── 4. 系统级指标 ──
top -bn1 | head -20
vmstat 1 5
iostat -x 1 3
sar -n DEV 1 3 # 看网卡是否打满
39.2 开启 stub_status 监控
server {
listen 127.0.0.1:8080; # 只监听本机,绝不对外
server_name _;
location = /nginx_status {
stub_status;
access_log off;
allow 127.0.0.1;
deny all;
}
}
accepts ≠ handled → 有连接被拒。worker_connections 不够或 backlog 太小。requests / accepts < 2 → 每个连接平均不到 2 个请求,keepalive 基本没用上。检查 keepalive_timeout,
以及有没有哪个环节在发 Connection: close。Writing 持续很高 → 正在往外发数据,可能是大文件下载多,或者带宽打满了。Reading 很高且持续 → 客户端发请求慢(slowloris 攻击特征),调小 client_header_timeout。
39.3 压测工具选型
| 工具 | 特点 | 适合 |
|---|---|---|
| ab | Apache 自带,简单,单线程 | 快速粗略测试;结果偏乐观 |
| wrk | 多线程 + epoll,C 写的,性能强 | Linux 上压 Nginx 的首选 |
| wrk2 | wrk 的改进版,支持恒定速率压测 | 测延迟分布(更重要) |
| hey | Go 写的,跨平台,易装 | Windows/Mac 本地测试 |
| k6 | JS 脚本化,可写复杂场景 | 业务级压测、CI 集成 |
| JMeter | 图形化,功能全,重 | 复杂场景、有测试团队时 |
# ── 安装 ──
apt install apache2-utils # ab
apt install wrk # 或从源码编译获得更新版本
# ── ab:100 并发,总共 10000 个请求 ──
ab -n 10000 -c 100 -k http://127.0.0.1/
# -n 总请求数 -c 并发数 -k 使用 keepalive
# ── ab 带 Host 头 ──
ab -n 10000 -c 100 -H "Host: example.com" http://127.0.0.1/
# ── wrk:4 线程,100 连接,持续 30 秒 ──
wrk -t4 -c100 -d30s --latency http://127.0.0.1/
# -t 线程数(建议 = CPU 核数) -c 连接数 -d 持续时间 --latency 打印延迟分布
# ── wrk 带 Host 和脚本 ──
wrk -t4 -c100 -d30s --latency -H "Host: example.com" http://127.0.0.1/
# ── 测试 HTTPS ──
wrk -t4 -c100 -d30s --latency https://example.com/
# ── 测 POST ──
wrk -t4 -c100 -d30s -s post.lua http://127.0.0.1/api/
另外不要对生产环境压测(除非有明确授权和隔离措施)。对线上下游服务压测可能触发限流、告警, 甚至真的把业务压垮。搭一套配置相同的压测环境是必要投入。
39.4 读懂 wrk 的结果
$ wrk -t4 -c100 -d30s --latency http://127.0.0.1/
Running 30s test @ http://127.0.0.1/
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.32ms 612.44us 28.61ms 82.31% ← 平均延迟
Req/Sec 19.24k 2.11k 26.71k 74.30% ← 每线程每秒请求数
Latency Distribution
50% 1.17ms
75% 1.52ms
90% 2.05ms
99% 4.23ms ← P99,最该关注的
15218347 requests in 30.06s, 46.13GB read
Requests/sec: 506256.31 ← ★ 核心指标 QPS
Transfer/sec: 1.53GB ← 吞吐,注意是否网卡打满
2. P99 Latency — 比平均值重要得多。平均值 1ms 但 P99 是 500ms,说明有 1% 的用户在受苦。 用户感知的是尾部延迟。
3. Stdev / +/- Stdev — 波动。Stdev 很大说明性能不稳定。
4. Transfer/sec — 换算成 Gbps,跟你的网卡上限对比。1.53GB/s = 12.2 Gbps, 这时候瓶颈很可能是网卡(或 loopback 的拷贝开销),不是 Nginx 本身。
压测 loopback(127.0.0.1)会得到很高的数字,但不代表真实网络环境下的性能, 因为省掉了物理网卡和协议栈的很多开销。
39.5 一次完整的调优实证
目标:找出这台机器静态托管的性能上限,并验证各项优化的实际收益。
环境:4 核 8GB 腾讯云,Ubuntu 24.04,Nginx 1.24
内容:一个 10KB 的静态 HTML
客户端:同可用区另一台 4 核机器(内网互通,避免公网带宽成为瓶颈)
═══ 基线:默认配置 ═══
配置:全默认(worker_processes auto,keepalive_timeout 65,无 gzip,无 open_file_cache)
$ wrk -t4 -c100 -d30s --latency -H "Host: bench.local" http://10.0.0.5/
Requests/sec: 28412.55
Latency P50: 3.12ms
Latency P99: 11.44ms
Transfer/sec: 245.17MB
═══ 优化 1:sendfile + tcp_nopush ═══
追加:sendfile on; tcp_nopush on;
$ wrk -t4 -c100 -d30s --latency -H "Host: bench.local" http://10.0.0.5/
Requests/sec: 41208.13 ↑ +45%
Latency P99: 8.21ms ↓ 28%
结论:sendfile 收益巨大(零拷贝),必开。
═══ 优化 2:再加 open_file_cache ═══
追加:
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
$ wrk -t4 -c100 -d30s --latency ...
Requests/sec: 48933.72 ↑ 再 +18.7%
Latency P99: 6.98ms ↓ 15%
结论:open_file_cache 减少 stat() 系统调用,中等收益。文件数量多时收益更大。
═══ 优化 3:worker_connections 与 backlog ═══
events { worker_connections 4096; } (默认 768)
listen 80 backlog=2048; (默认 511)
$ wrk -t4 -c100 -d30s --latency ...
Requests/sec: 49102.33 ↑ 0.3% ← 几乎没变
Latency P99: 6.91ms
结论:100 并发下远没到上限。这两个参数在低并发场景下无感,
但在高并发(>1000 连接)下是必须的。★ 不要为了"优化"而调它。
═══ 优化 4:加 gzip ═══
gzip on; gzip_types text/html text/css application/javascript; gzip_comp_level 6;
$ wrk -t4 -c100 -d30s --latency ...
Requests/sec: 39214.88 ↓ -20% (QPS 下降)
Transfer/sec: 52.11MB ↓ -79% (带宽大幅下降)
结论:★ gzip 是典型的「CPU 换带宽」。
10KB 文件压到 2KB,带宽省了 79%,但 QPS 掉了 20%。
★ 是否开启取决于瓶颈在哪:带宽紧张就开,CPU 紧张就不开。
★ 生产推荐:静态资源预压缩(gzip_static on)+ 只压 >1KB 的文本类型。
═══ 优化 5:worker_processes 从 1 调到 auto(4)═══
$ wrk -t4 -c400 -d30s --latency ... ← 提高并发到 400
worker=1: Requests/sec: 18344.22 Latency P99: 42.1ms
worker=4: Requests/sec: 61552.91 Latency P99: 12.8ms
结论:单 worker 在 400 并发下严重饱和。★ auto 是必须的。
═══ 优化 6:keepalive_timeout 65s → 15s ═══
$ wrk -t4 -c100000 -d30s ... ← 极端并发测试
timeout=65s: accepts 后大量 499,部分连接被拒
timeout=15s: 稳定
结论:keepalive_timeout 太长会占住连接数。
高并发场景下调小(15-30s)能显著提高可承载的并发量。
═══ 最终配置的稳定表现 ═══
$ wrk -t4 -c400 -d60s --latency -H "Host: bench.local" http://10.0.0.5/
Requests/sec: 62891.44
Latency P99: 11.92ms
Transfer/sec: 540.33MB ← 4.3 Gbps,接近内网网卡上限
结论:★ 此时瓶颈已转移到网卡,继续调 Nginx 没有意义。
★ 判断瓶颈的方法:top 看 nginx 的 CPU 是否打满、sar -n DEV 看网卡是否打满。
2. 调优要一个一个来,每次都测。一起改五个参数,你不知道哪个有用、哪个有害。
3. 有些"优化"在低并发下测不出效果(backlog、worker_connections),不代表它们没用——是不符合当前测试场景。
4. 一定要找到瓶颈在哪再优化。最后 QPS 卡在 6.3 万是因为网卡打满了, 这时候再调 Nginx 参数是浪费时间。看 CPU、看网卡、看磁盘,先找到"哪个资源是满的"。
39.6 调优参数速查(按收益排序)
| 参数 | 推荐值 | 收益 | 说明 |
|---|---|---|---|
sendfile on | on | 极大 | 零拷贝,静态文件必开 |
worker_processes | auto | 极大(高并发) | 等于 CPU 核数 |
worker_connections | 4096+ | 大(高并发) | 单 worker 最大连接数 |
open_file_cache | max=10000 | 中 | 减少 stat 系统调用,小文件多时收益大 |
tcp_nopush on | on | 中 | 配合 sendfile,攒满一个包再发 |
tcp_nodelay on | on | 中 | 小包立即发,降延迟 |
keepalive_timeout | 15-65s | 中 | 高并发下调小 |
keepalive_requests | 1000 | 小 | 单连接最多处理多少请求 |
access_log | buffer=32k flush=5s | 中 | 缓冲写日志,减少 IO |
gzip | on, level 6 | 负(CPU) | 省带宽,但吃 CPU。视瓶颈决定 |
gzip_static on | on | 正向 | 用预压缩文件,几乎零 CPU 开销 |
proxy_buffering | on(默认) | 中 | 让后端快速释放,但要防大响应 |
upstream keepalive | 32-64 | 大(反代场景) | 复用后端连接,降低后端压力 |
multi_accept on | on | 小 | 一次 accept 所有新连接 |
reuseport | on | 中 | 每个 worker 独立 listen socket,减少锁竞争(Linux 3.9+) |
39.7 CPU 亲和性与 reuseport
worker_processes auto;
events {
worker_connections 8192;
multi_accept on;
use epoll;
}
http {
# 配合 worker_processes auto 使用
# 让每个 worker 绑定到不同的 CPU 核,减少上下文切换和缓存失效
# 注意:worker_processes 必须等于 CPU 核数,或者用 auto
worker_cpu_affinity auto;
server {
# reuseport:每个 worker 有自己的 listen socket
# 内核直接把连接分给 worker,避免所有 worker 抢同一个 accept 锁
# ★ 在高并发下(十万级 QPS)收益明显
listen 80 reuseport;
listen 443 ssl reuseport;
http2 on;
server_name example.com;
}
}
但它要求
worker_processes 和核数匹配。如果 worker 数超过核数(比如设了 16 个 worker 但只有 4 核),
affinity 会变成多个 worker 共用一个核,收益就没了。另外在容器里要小心:
auto 读的是宿主机的核数,容器如果被 cgroup 限了 2 核,
affinity 会把 worker 绑到不允许的核上,导致性能异常。容器里显式写死 worker_processes 2。
39.8 反代场景的压测差异
# 静态托管测的是 Nginx 自己的极限
# 反代场景测的是「Nginx + 后端」的组合,通常瓶颈在后端
# 压测时一定要看这两个指标:
# 1. Nginx 侧的 rt 与 urt(第 29 章)
# rt ≈ urt → 瓶颈在后端
# rt >> urt → 瓶颈在 Nginx 或链路(带宽、客户端)
# 2. Nginx 的 CPU 占用
# CPU 高 + urt 低 → Nginx 是瓶颈(考虑加机器、开 reuseport、减少 location 数量)
# CPU 低 + urt 高 → 后端是瓶颈(去优化后端)
# CPU 低 + urt 低 + 吞吐上不去 → 网络或客户端是瓶颈
# 用 upstream keepalive 前后对比(这是反代场景最有效的优化)
# 不开:每个请求建立一次到后端的 TCP 连接
# 实测:后端连接数持续增长,出现大量 TIME_WAIT,QPS 受限
# 优化后:QPS +30~50%,后端 CPU 明显下降
upstream app_backend {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
keepalive 64; # 每个 worker 保持的空闲连接数
keepalive_requests 1000; # 单连接最多复用多少次
keepalive_timeout 60s;
}
location /api/ {
proxy_pass http://app_backend;
# ★ 这三行是 upstream keepalive 生效的必要条件,缺一不可
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}
proxy_http_version 1.1 — HTTP/1.0 默认不支持长连接2. 没写
proxy_set_header Connection "" — 不把这个头清空,
客户端传上来的 Connection: close 会被转发给后端,后端就关了连接3.
proxy_pass 用了变量形式 — 变量形式会绕过 upstream,keepalive 池用不上验证方法:在后端机器上
ss -tn state established | grep 8080 | wc -l,
应该稳定在 worker数 × keepalive值 附近,而不是随请求数增长。
39.9 本章小结
- 先建立基线再优化;一次只改一个参数,改完再压
- 看 wrk 结果看四个数:QPS、P99、Stdev、Transfer/sec(换算成 Gbps 对比网卡)
- 收益最大的三项:
sendfile on、worker_processes auto、upstream keepalive(反代场景) - gzip 是 CPU 换带宽,不一定是"优化";静态资源用
gzip_static - 优化到瓶颈转移了就停手——继续调参数是浪费时间
- 反代场景用
rt vs urt判断瓶颈在前端还是后端
40完整生产站点配置案例
最后这一章,把前面 39 章讲的东西组装成一个可以直接用的完整配置。场景是一个真实常见的架构:门户站 + API 后端 + 静态资源 + HTTPS + 限流 + 缓存 + 日志。
40.1 场景与架构
域名:
example.com / www.example.com 门户站(静态 + 少量服务端渲染)
api.example.com REST API(反代到后端)
static.example.com 静态资源 CDN 源站
admin.example.com 管理后台(有访问控制)
后端:
app_backend 10.0.1.10:8080, 10.0.1.11:8080 (主应用)
admin_backend 10.0.2.10:9000 (后台)
redis 10.0.1.20:6379 (缓存)
要求:
· 全站 HTTPS,HTTP 自动跳转
· gzip 压缩
· API 限流(普通 20r/s,登录接口 1r/s)
· 静态资源强缓存(hash 命名)
· 真实 IP 传递
· 独立日志便于分析
· 错误页自定义
· 安全响应头
· 健康检查不记日志
40.2 主配置文件
# /etc/nginx/nginx.conf
user www-data;
worker_processes auto;
worker_rlimit_nofile 65535;
worker_cpu_affinity auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log warn;
events {
worker_connections 8192;
multi_accept on;
use epoll;
}
http {
# ══════════════════════════════════════════
# 基础
# ══════════════════════════════════════════
include /etc/nginx/mime.types;
default_type application/octet-stream;
charset utf-8;
server_tokens off;
# ══════════════════════════════════════════
# 日志
# ══════════════════════════════════════════
log_format detail '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'"$http_x_forwarded_for" '
'rt=$request_time urt=$upstream_response_time '
'ua=$upstream_addr us=$upstream_status cs=$upstream_cache_status';
log_format json escape=json '{'
'"t":"$time_iso8601","ip":"$remote_addr","host":"$http_host",'
'"m":"$request_method","u":"$request_uri","s":$status,'
'"b":$body_bytes_sent,"rt":$request_time,'
'"urt":"$upstream_response_time","ua":"$upstream_addr",'
'"us":"$upstream_status","ref":"$http_referer"'
'}';
access_log /var/log/nginx/access.log detail buffer=64k flush=5s;
# ══════════════════════════════════════════
# 性能
# ══════════════════════════════════════════
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 30;
keepalive_requests 1000;
types_hash_max_size 2048;
server_names_hash_bucket_size 64;
client_max_body_size 100m;
client_body_buffer_size 128k;
client_header_buffer_size 4k;
large_client_header_buffers 4 16k;
client_body_timeout 15s;
client_header_timeout 15s;
send_timeout 30s;
reset_timedout_connection on;
open_file_cache max=10000 inactive=60s;
open_file_cache_valid 80s;
open_file_cache_min_uses 2;
open_file_cache_errors on;
# ══════════════════════════════════════════
# 压缩
# ══════════════════════════════════════════
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_disable "msie6";
gzip_types text/plain text/css text/xml
text/javascript application/json
application/javascript application/xml
application/rss+xml application/atom+xml
image/svg+xml font/woff2;
# 如果服务器上有 .gz 预压缩文件,直接用(几乎零 CPU)
gzip_static on;
# ══════════════════════════════════════════
# SSL 全局(各 vhost 用 include 引用)
# ══════════════════════════════════════════
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
ssl_buffer_size 4k;
# ══════════════════════════════════════════
# 真实 IP:只信任自己的代理
# ══════════════════════════════════════════
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
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;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# ══════════════════════════════════════════
# 限流区(必须先声明)
# ══════════════════════════════════════════
limit_req_zone $binary_remote_addr zone=general:20m rate=20r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_req_zone $binary_remote_addr zone=search:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
limit_req_status 429;
limit_conn_status 429;
# ══════════════════════════════════════════
# 缓存区
# ══════════════════════════════════════════
proxy_cache_path /var/cache/nginx/api
levels=1:2
keys_zone=api_cache:20m
max_size=2g
inactive=30m
use_temp_path=off;
proxy_cache_path /var/cache/nginx/static
levels=1:2
keys_zone=static_cache:10m
max_size=5g
inactive=7d
use_temp_path=off;
# ══════════════════════════════════════════
# 全局 map
# ══════════════════════════════════════════
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
map $status $loggable {
~^[23] 0;
default 1;
}
map $http_x_requested_with $is_ajax {
default 0;
XMLHttpRequest 1;
}
# ══════════════════════════════════════════
# upstream
# ══════════════════════════════════════════
upstream app_backend {
least_conn;
server 10.0.1.10:8080 max_fails=3 fail_timeout=15s;
server 10.0.1.11:8080 max_fails=3 fail_timeout=15s;
keepalive 64;
keepalive_requests 1000;
keepalive_timeout 60s;
}
upstream admin_backend {
server 10.0.2.10:9000 max_fails=2 fail_timeout=30s;
keepalive 16;
}
# ══════════════════════════════════════════
# 引入站点
# ══════════════════════════════════════════
include /etc/nginx/conf.d/*.conf;
}
40.3 公共 snippet
# /etc/nginx/snippets/ssl.conf
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
ssl_stapling on;
ssl_stapling_verify on;
resolver 223.5.5.5 119.29.29.29 valid=300s;
resolver_timeout 5s;
# /etc/nginx/snippets/security.conf
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# /etc/nginx/snippets/proxy.conf
proxy_http_version 1.1;
proxy_set_header Connection "";
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_connect_timeout 3s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;
# /etc/nginx/snippets/static-cache.conf
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
try_files $uri =404;
40.4 门户站 vhost
# /etc/nginx/conf.d/10-portal.conf
# ── HTTP → HTTPS(含 www) ──
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# ACME 挑战放行(Let's Encrypt 续期用)
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
}
location / {
return 301 https://$host$request_uri;
}
}
# ── 裸域 → www ──
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name example.com;
include /etc/nginx/snippets/ssl.conf;
return 301 https://www.example.com$request_uri;
}
# ── 主站 ──
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name www.example.com;
include /etc/nginx/snippets/ssl.conf;
root /var/www/portal/current;
index index.html;
access_log /var/log/nginx/portal.access.log detail buffer=32k flush=5s;
error_log /var/log/nginx/portal.error.log warn;
# ── 健康检查 ──
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# ── ACME 挑战 ──
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
access_log off;
}
# ── 静态资源:强缓存 ──
location ^~ /assets/ {
include /etc/nginx/snippets/static-cache.conf;
}
location ~* \.(css|js|jpg|jpeg|png|gif|svg|webp|avif|ico|woff|woff2|ttf|eot|map)$ {
include /etc/nginx/snippets/static-cache.conf;
}
# ── 入口 HTML:绝不缓存 ──
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
etag off;
include /etc/nginx/snippets/security.conf;
}
# ── API 反代(同域,避免 CORS) ──
location /api/ {
limit_req zone=general burst=40 nodelay;
limit_conn perip 50;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
include /etc/nginx/snippets/security.conf;
}
# ── 登录接口:严格限流 ──
location = /api/auth/login {
limit_req zone=login burst=3 nodelay;
limit_req zone=general burst=10 nodelay;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
include /etc/nginx/snippets/security.conf;
}
# ── 搜索接口:中等限流 ──
location = /api/search {
limit_req zone=search burst=10 nodelay;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
}
# ── SPA fallback ──
location / {
try_files $uri $uri/ /index.html;
include /etc/nginx/snippets/security.conf;
}
# ── 错误页 ──
error_page 404 /404.html;
error_page 429 /429.html;
error_page 500 502 503 504 /50x.html;
location = /404.html { internal; root /var/www/errors; }
location = /429.html { internal; root /var/www/errors; }
location = /50x.html { internal; root /var/www/errors; }
# ── 拒绝访问隐藏文件 ──
location ~ /\.(?!well-known) {
deny all;
access_log off;
log_not_found off;
}
}
40.5 API 独立域名 vhost
# /etc/nginx/conf.d/20-api.conf
server {
listen 80;
server_name api.example.com;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
include /etc/nginx/snippets/ssl.conf;
access_log /var/log/nginx/api.access.log json buffer=64k flush=5s;
error_log /var/log/nginx/api.error.log warn;
# ── CORS 白名单 ──
# (记在 map 里更好,这里为演示放在 server 内不方便,实际见 33.2 节)
location = /health {
access_log off;
default_type application/json;
return 200 '{"status":"ok"}';
}
# ── 只读接口:可以缓存 ──
location ~ ^/api/v1/(products|categories|config) {
limit_req zone=general burst=40 nodelay;
proxy_cache api_cache;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 5m;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_revalidate on;
add_header X-Cache-Status $upstream_cache_status always;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
}
# ── 写接口:严格限流 ──
location ~ ^/api/v1/(auth|order|payment) {
limit_req zone=login burst=5 nodelay;
limit_conn perip 20;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
# 请求体最大 10M(上传场景)
client_max_body_size 10m;
}
# ── WebSocket ──
location /ws/ {
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_cache off;
}
# ── SSE(Server-Sent Events) ──
location /sse/ {
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
proxy_buffering off;
proxy_cache off;
gzip off;
chunked_transfer_encoding on;
proxy_read_timeout 3600s;
}
# ── 其余接口 ──
location / {
limit_req zone=general burst=40 nodelay;
limit_conn perip 50;
proxy_pass http://app_backend;
include /etc/nginx/snippets/proxy.conf;
}
# ── 隐藏文件 ──
location ~ /\. {
deny all;
access_log off;
}
}
40.6 静态资源 CDN 源站
# /etc/nginx/conf.d/30-static.conf
server {
listen 80;
server_name static.example.com;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name static.example.com;
ssl_certificate /etc/letsencrypt/live/static.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/static.example.com/privkey.pem;
include /etc/nginx/snippets/ssl.conf;
root /var/www/static;
access_log /var/log/nginx/static.access.log detail buffer=128k flush=5s;
error_log /var/log/nginx/static.error.log warn;
# ── 允许 CDN 回源,拒绝其他 ──
# CDN 回源 IP 段(示例,按你的 CDN 厂商文档填)
# allow 203.0.113.0/24;
# deny all;
# ── 大文件下载优化 ──
sendfile on;
tcp_nopush on;
# ── 缓存与 CORS(字体需要 CORS) ──
location / {
expires 1y;
add_header Cache-Control "public, immutable" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Timing-Allow-Origin "*" always;
access_log off;
try_files $uri =404;
# 断点续传
add_header Accept-Ranges bytes always;
}
# ── 禁止列目录 ──
autoindex off;
# ── 隐藏文件与源码 ──
location ~ /\. { deny all; }
location ~* \.(php|asp|aspx|jsp|sh|bak|sql|conf)$ { deny all; }
# ── 目录请求直接 404(不要返回 index) ──
location ~ /$ {
return 404;
}
}
40.7 管理后台 vhost(带访问控制)
# /etc/nginx/conf.d/40-admin.conf
server {
listen 80;
server_name admin.example.com;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
include /etc/nginx/snippets/ssl.conf;
access_log /var/log/nginx/admin.access.log detail;
error_log /var/log/nginx/admin.error.log warn;
root /var/www/admin/current;
index index.html;
# ── 三层防护 ──
# 1. IP 白名单(办公网 / VPN 出口)
allow 203.0.113.0/24;
allow 198.51.100.10;
allow 10.0.0.0/8;
deny all;
# 2. Basic Auth(作为第二道,防止 IP 段被攻破)
auth_basic "Admin Area";
auth_basic_user_file /etc/nginx/.htpasswd-admin;
# 3. 限流更严
limit_req zone=general burst=10 nodelay;
location = /health {
access_log off;
return 200 "ok\n";
}
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public" always;
access_log off;
try_files $uri =404;
}
location /api/ {
# 后台接口不做缓存
proxy_no_cache 1;
proxy_cache_bypass 1;
proxy_pass http://admin_backend;
include /etc/nginx/snippets/proxy.conf;
proxy_read_timeout 120s; # 后台有导出等慢操作
}
location / {
try_files $uri $uri/ /index.html;
}
# ── 后台不做缓存,加 noindex ──
add_header X-Robots-Tag "noindex, nofollow, noarchive" always;
location ~ /\. { deny all; }
}
40.8 日志切割与监控
# /etc/logrotate.d/nginx
/var/log/nginx/*.log {
daily
rotate 30
missingok
notifempty
compress
delaycompress
dateext
dateformat -%Y%m%d
sharedscripts
create 0640 www-data adm
postrotate
if [ -f /run/nginx.pid ]; then
kill -USR1 $(cat /run/nginx.pid)
fi
endscript
}
# ── 监控脚本:检查关键指标并告警 ──
#!/bin/bash
# /root/scripts/nginx-check.sh
ALERT=0
STATUS=$(curl -s --max-time 3 http://127.0.0.1:8080/nginx_status)
if [ -z "$STATUS" ]; then
echo "CRITICAL: nginx_status 无响应"
exit 2
fi
ACTIVE=$(echo "$STATUS" | awk '/Active/ {print $3}')
READING=$(echo "$STATUS" | awk '/Reading/ {print $2}')
WRITING=$(echo "$STATUS" | awk '/Writing/ {print $4}')
WAITING=$(echo "$STATUS" | awk '/Waiting/ {print $6}')
ACCEPTS=$(echo "$STATUS" | sed -n '3p' | awk '{print $1}')
HANDLED=$(echo "$STATUS" | sed -n '3p' | awk '{print $2}')
REQUESTS=$(echo "$STATUS" | sed -n '3p' | awk '{print $3}')
echo "active=$ACTIVE reading=$READING writing=$WRITING waiting=$WAITING"
echo "accepts=$ACCEPTS handled=$HANDLED requests=$REQUESTS"
# 检查 1:连接被丢弃
if [ "$ACCEPTS" != "$HANDLED" ]; then
echo "WARN: accepts($ACCEPTS) != handled($HANDLED) —— 有连接被拒"
ALERT=1
fi
# 检查 2:活跃连接过高
if [ "$ACTIVE" -gt 20000 ]; then
echo "WARN: 活跃连接 $ACTIVE 过高"
ALERT=1
fi
# 检查 3:平均每连接请求数(keepalive 效果)
if [ "$ACCEPTS" -gt 0 ]; then
RATIO=$(awk "BEGIN {printf \"%.2f\", $REQUESTS / $ACCEPTS}")
echo "requests/accepts = $RATIO"
# 如果小于 5,说明 keepalive 基本没用上(正常应该在 10 以上)
fi
# 检查 4:5xx 比例
FIVE_XX=$(grep -c ' 5[0-9][0-9] ' /var/log/nginx/access.log 2>/dev/null || echo 0)
TOTAL=$(wc -l < /var/log/nginx/access.log 2>/dev/null || echo 1)
if [ "$TOTAL" -gt 1000 ]; then
ERR_RATE=$(awk "BEGIN {printf \"%.2f\", $FIVE_XX / $TOTAL * 100}")
echo "5xx rate = ${ERR_RATE}%"
if awk "BEGIN {exit !($ERR_RATE > 1)}"; then
echo "WARN: 5xx 比例 ${ERR_RATE}% 超过 1%"
ALERT=1
fi
fi
exit $ALERT
40.9 部署与验证清单
# ══════════════════════════════════════════
# 部署前
# ══════════════════════════════════════════
# 1. 语法检查
nginx -t
# 2. 看看实际加载的配置(确认 include 都生效了)
nginx -T | head -100
nginx -T | grep -c "server_name"
nginx -T | grep -n "conflicting" # 应该无输出
# 3. 检查有没有危险的配置
nginx -T | grep -n "0.0.0.0/0"
nginx -T | grep -n "server_tokens on" # 应该无输出
# ══════════════════════════════════════════
# 部署
# ══════════════════════════════════════════
# 4. 备份当前配置
cp -r /etc/nginx /root/backups/nginx-$(date +%Y%m%d-%H%M%S)
# 5. reload(不是 restart)
systemctl reload nginx
# 6. 确认 reload 成功
systemctl status nginx --no-pager | head -5
# ══════════════════════════════════════════
# 部署后验证
# ══════════════════════════════════════════
# 7. 各域名可访问性
for h in example.com www.example.com api.example.com static.example.com admin.example.com; do
code=$(curl -so /dev/null -w "%{http_code}" --resolve "$h:443:127.0.0.1" "https://$h/health" 2>/dev/null)
echo "$h → $code"
done
# 8. HTTP 跳转是否正确
curl -sI http://127.0.0.1/ -H "Host: example.com" | head -3
# 期望:HTTP/1.1 301 Moved Permanently + Location: https://example.com/
# 9. 证书是否正确
echo | openssl s_client -connect 127.0.0.1:443 -servername example.com 2>/dev/null | \
openssl x509 -noout -subject -dates -ext subjectAltName
# 10. 检查 HSTS 与安全头
curl -sI --resolve example.com:443:127.0.0.1 https://example.com/ | \
grep -iE "strict-transport|x-frame|x-content-type|referrer"
# 11. 检查 gzip
curl -sI -H "Accept-Encoding: gzip" --resolve example.com:443:127.0.0.1 https://example.com/index.html | grep -i content-encoding
# 12. 检查限流
for i in $(seq 1 30); do
curl -so /dev/null -w "%{http_code} " --resolve api.example.com:443:127.0.0.1 https://api.example.com/api/v1/auth/login
done; echo
# 前面几个 200/405,后面应该出现 429
# 13. 检查真实 IP 是否生效
curl -s --resolve api.example.com:443:127.0.0.1 https://api.example.com/health
tail -1 /var/log/nginx/api.access.log
# 日志第一列应该是你的真实 IP,不是 127.0.0.1
# 14. 检查静态资源缓存头
curl -sI --resolve static.example.com:443:127.0.0.1 https://static.example.com/assets/app-abc123.js | \
grep -iE "cache-control|expires|etag"
# 15. 检查日志切割未来能正常工作(手动跑一次 dry-run)
logrotate -d /etc/logrotate.d/nginx
40.10 日常运维命令备忘
# ── 服务控制 ──
systemctl start nginx
systemctl stop nginx
systemctl reload nginx # ★ 热重载,不中断连接,改配置后优先用这个
systemctl restart nginx # 重启,会断连接,仅在必要时用
systemctl status nginx
# ── 信号(reload 的底层) ──
nginx -s reload # 等同 systemctl reload
nginx -s reopen # 重新打开日志文件(可代替 USR1)
nginx -s stop # 快速停止
nginx -s quit # 优雅停止(处理完当前请求)
kill -HUP $(cat /run/nginx.pid) # reload
kill -USR1 $(cat /run/nginx.pid) # 重新打开日志
kill -USR2 $(cat /run/nginx.pid) # 升级二进制
kill -WINCH $(cat /run/nginx.pid) # 优雅关闭 worker(升级时用)
# ── 配置检查 ──
nginx -t # 测语法
nginx -T # 打印全部展开配置
nginx -t -c /path/to.conf # 测指定文件
nginx -v # 版本
nginx -V 2>&1 # 版本 + 编译参数 + 模块列表
# ── 二进制平滑升级 ──
# 1. 替换二进制
# 2. kill -USR2 $(cat /run/nginx.pid) ← 启动新 master
# 3. kill -WINCH $(cat /run/nginx.pid) ← 旧 worker 优雅退出(此时旧 master 还在)
# 4. 观察一段时间,确认新版本正常
# 5. kill -QUIT $(cat /run/nginx.pid.oldbin) ← 停旧 master
# 6. 确认回滚方案:kill -HUP $(cat /run/nginx.pid.oldbin) 可恢复到旧版本
40.11 全教程地图回顾
40.12 收尾:从"会改配置"到"能独立配站"
这份教程覆盖了从配置语法到生产运维的完整路径。如果只能带走五条,那就是这五条:
2. Nginx 的坑几乎全在细节里。
proxy_pass 末尾的斜杠、add_header 的全有或全无、try_files 的顺序、
rewrite 丢掉的参数——这些东西文档里都有,但只有踩过一遍才会真正记住。3. 先改一处,测一次。 这个纪律在任何运维场景都适用。一次性改八处大概率能"修好"问题, 但你从此失去了"知道哪个改动真正起作用"的能力。
4. 配置检查 + 本地验证,永远两步。
nginx -t 说语法 OK 不等于逻辑正确。
curl -v -H "Host: 你的域名" http://127.0.0.1/ 才是真正验证。
别忘了 -H "Host: ...",这是最常见的误判源。5. 排障时先找证据,别猜。
error.log 里的错误消息是 Nginx 在告诉你答案。
$request_time vs $upstream_response_time 能告诉你瓶颈在哪一层。
猜出来的结论会把你带到更远的地方。
这份教程覆盖了从配置语法到生产运维的完整路径。接下来最重要的一步是:在你自己的机器上
apt install nginx,然后照着第 40 章的完整站点案例动手配一遍。看懂配置和写对配置之间,隔着一整个"真正的掌握"——Nginx 的坑几乎全在细节里。