Nginx 完全教程

从一节指令讲到生产架构:配置语法、location 匹配、反向代理、负载均衡、HTTPS、缓存限流、性能调优,每个知识点都配可运行配置、验证命令与输出结果。

9 大部分 · 40 章 400+ 配置实例 覆盖 Nginx 1.24 / 1.26 含图解与原理剖析 从装完不会改 → 独立配生产站

第一部分 · 入门

1Nginx 是什么,为什么用它

Nginx(读作 "engine-x")是一个高性能的 HTTP 服务器和反向代理服务器。它最出名的两个特点:事件驱动的异步架构,以及极低的内存占用

1.1 它到底解决什么问题

一个典型的现代 Web 系统里,Nginx 通常站在最前面,扮演"门卫 + 调度员"的角色:

浏览器 手机 App Nginx TLS 终止 路由 / 限流 / 缓存 负载均衡 静态文件 HTML / JS / 图片 API 服务 A :5000 API 服务 B :5001 WebSocket :666 客户端 只看到 443 看不到后端
图 1.1 — Nginx 在架构中的位置:对外一个入口,对内分发到多个后端

1.2 与 Apache 的核心区别

维度NginxApache(prefork 模式)
并发模型事件驱动,单 worker 处理数千连接每连接一个进程/线程
内存占用低,1 万并发约几十 MB高,随并发线性增长
.htaccess不支持(这是优点:不用每次请求读磁盘)支持,方便但慢
配置重载reload 即生效,不断连接通常需重启
动态模块需编译时指定或动态加载运行时加载方便
静态文件极快(sendfile + 零拷贝)较慢
配置风格整体式,一次性写好可分散在目录级
结论绝大多数场景用 Nginx 做前端服务器 + 反向代理;Apache 更适合需要 .htaccess 的共享主机或大量 PHP 老项目。

1.3 三大核心用途

① 静态 Web 服务器

直接吐 HTML/CSS/JS/图片,性能是所有方案里最好的之一。

② 反向代理(Reverse Proxy)

客户端请求 Nginx,Nginx 转发给后端应用(Node / .NET / Python),再把结果返回。好处:

③ 负载均衡

后端有多台时,Nginx 按策略分发请求,还能自动剔除挂掉的节点。

1.4 什么时候不该用 Nginx

一个常被忽视的点Nginx 的 if 是"伪指令",官方文档明确写着 "if is evil"。很多逻辑应该用 maptry_files 实现。第 33 章会详细讲这个坑。

2安装与目录结构

2.1 各平台安装

Ubuntu / Debian(本教程以此为主)
# 更新索引并安装
sudo apt update
sudo apt install -y nginx

# 查看版本与编译参数
nginx -v
nginx -V          # 大写 V 会输出全部编译选项,排查模块有无就看这个

# 启动并设为开机自启
sudo systemctl enable --now nginx
systemctl status nginx
nginx version: nginx/1.24.0 (Ubuntu) ● nginx.service - A high performance web server and a reverse proxy server Loaded: loaded (/lib/systemd/system/nginx.service; enabled) Active: active (running)
CentOS / RHEL / Rocky
# 官方源方式(比 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
macOS(Homebrew)
brew install nginx
brew services start nginx
# 配置文件位置与 Linux 完全不同:
#   /opt/homebrew/etc/nginx/nginx.conf   (Apple Silicon)
#   /usr/local/etc/nginx/nginx.conf      (Intel)
Docker(最省事,适合本地试验)
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官方仓库时的默认站点根视安装方式
两套 include 机制Ubuntu 的 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;
default_server 的含义当请求的 Host没有匹配到任何 server_name 时,交给标了 default_server 的那个 server 块处理。同一端口只允许一个 default_server,否则启动报错 duplicate default server。这就是"直接用 IP 访问"时看到的那个站点。

2.4 第一个自己的站点

用 8080 是有意的教程里做试验尽量别占用 80 端口,避免和现有站点冲突。每次改配置,顺序永远是:改文件 → nginx -treload跳过 -t 直接 reload,配置有错会导致新配置不生效但服务仍在跑旧配置,很容易误以为改动没生效。

3配置文件全貌与加载顺序

3.1 一张图看懂配置层级

main(全局) events { } 连接处理 http { } HTTP 全局 upstream { } server { } 虚拟主机 location { } 路由 指令:root / proxy_pass … 兄弟关系 可多个 层层嵌套 内层可覆盖外层
图 3.1 — Nginx 配置的四层结构,缩进只为人眼,作用域由块决定

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/*;
}
注意 include 的位置两个 include 写在 http { } 内部,所以被引入的文件里写的 server { } 天然位于 http 上下文中。很多人把 conf.d/xxx.conf 里也包一层 http { },结果报错 "http" directive is not allowed here —— 就是没搞清这个。

3.3 配置加载顺序(排查问题的关键)

Nginx 启动时按以下顺序处理:

  1. 读主配置 nginx.conf,遇到 include原地展开(就像 C 语言的 #include
  2. include 出现的顺序依次加载各文件。用 glob 的(如 *.conf)按文件名字母序排列
  3. 构建 server 列表,同一监听端口内按 server_name 建立匹配表
  4. 请求进来 → 按端口选 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 "
# configuration file /etc/nginx/conf.d/superyg.conf: server_name superyg.cloud www.superyg.cloud; root /var/www/portal; # configuration file /etc/nginx/sites-enabled/default: server_name _; root /var/www/html;

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 到底做了什么

reload 前 master worker 1 worker 2 SIGHUP reload 中(双跑) master(不变) 老 worker(收尾中) 老 worker(收尾中) 新 worker(新配置) 新 worker(新配置) 旧连接走完 reload 完成 master worker 1 worker 2 零丢包
图 4.1 — reload 是"平滑换班":老 worker 处理完手头请求再退出,新连接走新配置

具体流程:

  1. 向 master 进程发 SIGHUP
  2. master 校验新配置 —— 有错就拒绝加载,继续用旧配置(所以 reload 不会把服务搞挂)
  3. 用新配置启动新 worker
  4. 向老 worker 发信号,让它们处理完当前请求后退出
  5. 整个过程连接不断、请求不丢
为什么 reload 比 restart 好restart 会先停全部进程再启动,中间有几百毫秒的空窗期,期间新请求被拒绝。生产环境改配置一律用 reload
但注意:listen 端口、换 SSL 证书文件、改 user 指令这几类改动,reload 可能不生效或不够——需要 systemctl restart nginx

4.3 进程模型

ps -ef | grep nginx
root 1234 1 0 10:00 ? 00:00:00 nginx: master process /usr/sbin/nginx www-data 1235 1234 0 10:00 ? 00:00:00 nginx: worker process www-data 1236 1234 0 10:00 ? 00:00:00 nginx: worker process
进程运行用户职责
masterroot读配置、管理 worker、绑定 80/443 等特权端口、处理信号
workeruser 指令指定实际处理请求。数量由 worker_processes 决定
cache manager启用了 proxy_cache 才出现,负责淘汰过期缓存
cache loader启动时载入磁盘缓存索引,只跑一次
只有 master 是 rootworker 以 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
3421 200 187 304 54 404 12 500 3 499
499 是什么499 不是 HTTP 标准码,是 Nginx 自己发明的:表示"客户端在服务端返回响应前就断开了连接"。常见于用户刷新页面、或后端太慢用户等不及关掉。如果 499 很多,说明后端响应慢,要去查后端而不是 Nginx。

第二部分 · 配置语法

5指令、块与作用域

5.1 三种语法元素

Nginx 配置只有三种元素,别无其他:

元素写法例子
简单指令名字 参数... ;worker_processes auto;
块指令名字 { ... }server { ... }
注释# 到行尾# 这是注释
分号是最常见的错误来源简单指令必须以分号结尾。漏掉分号,Nginx 会把下一行当成这条指令的继续参数,于是报出一个和真实问题毫不相干的错:
server {
    listen 80
    server_name example.com;
}
报错是 invalid number of arguments in "listen" directive —— 它把 server_nameexample.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 的"全有或全无"
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_processesmainnginx.conf 顶部
worker_connectionseventsevents 块内
include任意常用于 http / server
serverhttp站点定义
locationserver, location路由(可嵌套)
upstreamhttphttp 块内,不能放 server 里
limit_req_zonehttphttp 块内(注意!)
proxy_cache_pathhttphttp 块内
proxy_passlocation, if, limit_exceptlocation 内
root / aliashttp, server, location, if各级都行
maphttphttp 块内,必须
setserver, location, if不能放 http 块
add_headerhttp, server, location, if各级都行(注意覆盖规则)
高频错误:把 map / limit_req_zone 写进 server
server {
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;   # ❌
    # 报错:"limit_req_zone" directive is not allowed here
}
*_zone 系列指令(limit_req_zonelimit_conn_zoneproxy_cache_pathmap)都是在 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 自己维护,只读)和自定义变量(用 setmap 定义)。变量是写高级配置的基础。

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_id1
$query_string$argsid=1
$is_args有查询串时为 ?,否则空?
$request_body请求体(需 proxy_pass_request_body on 语境)
$request_length请求总字节数(含头体)512
$content_type请求的 Content-Typeapplication/json
$content_length请求的 Content-Length128
$uri 与 $request_uri 的区别(非常高频)
  • $request_uri = 客户端原始请求,永远不变,含 ? 和参数
  • $uri = 当前正在处理的路径,经过 rewrite 后会变,不含参数
做跳转时想保留用户的原始完整地址,用 $request_uri;想基于当前重写后的路径判断,用 $uri。两者用混了会出现"跳转后参数丢了"或"跳转死循环"。

服务端与连接

变量含义示例值
$host请求的 Host(优先取 Host 头,无则用 server_name)superyg.cloud
$http_host原始 Host 头(可能为空或可被伪造superyg.cloud
$server_name当前匹配到的 server_namesuperyg.cloud
$server_addr服务器 IP10.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_userBasic 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是否使用 pipelinep / .

响应与时间

变量含义示例值
$status响应状态码200
$body_bytes_sent响应体字节数(不含头)15320
$bytes_sent发送总字节数(含头)15600
$time_local本地时间15/Sep/2026:17:30:00 +0800
$time_iso8601ISO 格式时间2026-09-15T17:30:00+08:00
$msec当前时间(毫秒精度)1726392600.123
$http_名称任意请求头(-_,小写)$http_user_agent
$sent_http_名称任意响应头$sent_http_content_type
$cookie_名称某个 Cookie 的值$cookie_token
$http_ 的转换规则请求头 X-Forwarded-For$http_x_forwarded_forUser-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 的执行时机set 属于 rewrite 阶段的指令,写在哪一层就在该层处理时执行。location 里的 set 只在该 location 被选中后执行。这意味着它不能用于影响 location 的选择——那是 mapif 的活。

6.3 更强大的 map

maphttp 块定义,可以把一个变量的值映射成另一个值,比 if 更优雅、性能也更好。

基础映射:按 Host 决定后端
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;
    }
}
用 map 做条件判断(替代 if)
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 而不是 ifmap配置加载时就建立好映射表(hash 或正则表),运行时是一次查表,极快。if 在 rewrite 阶段求值,且行为有很多反直觉之处(官方称"if is evil")。能用 map 就别用 if。

6.4 map 的特殊用法

组合多个变量作为键
map "$host$request_uri" $cache_key_extra {
    default                 "";
    "~^example\.com/api/"   "nocache";
}
map 也可以做默认值兜底
map $http_x_forwarded_proto $real_scheme {
    default  $scheme;        # 没有这个头就用实际协议
    https    https;
    http     http;
}
map 的 key 匹配规则
  • default:兜底,不匹配时用
  • 普通字符串:精确匹配,不区分大小写
  • ~ 前缀:区分大小写正则
  • ~* 前缀:不区分大小写正则
  • hostnames 参数:允许用 *.example.com 这样的通配
map 的结果只能是字符串,不能包含复杂逻辑。

7语法陷阱与调试方法

这一章专门收集"看起来对、跑起来不对"的情况。Nginx 不报错的错误最耗时间。

7.1 陷阱清单

现象原因解决
改配置后"没生效"忘了 reload,或改的文件没被 includenginx -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.logPermission denied
404 但文件确实在rootalias 用混,或路径重复拼接看第 9 章对照表
跳转死循环rewrite 规则互为条件break 或用 return
正则匹配不到~~* 用错,或忘了转义注意大小写与转义字符
变量在 if 里不生效set 在 rewrite 阶段,执行顺序有讲究换用 map
中文文件名 404URI 编码问题用英文命名,或注意编码一致性

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;
debug 日志量极大生产环境开着 debug 会迅速吃满磁盘,而且明显拖慢性能。只在本地排查时开,查完立刻改回 warnerror

② 按 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 回显变量不确定某个变量到底等于什么,直接 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/
连接:0.032 首字节:0.185 总计:0.187

④ 校验单个 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 验证却看不到它。

# 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;
    }
}
always 参数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 / { ... }
}
http2 的写法变迁Nginx 1.25.1 之前写 listen 443 ssl http2;;之后推荐拆成两行:
listen 443 ssl;
http2 on;
老写法仍兼容但会有警告。查版本:nginx -v

8.2 server_name 的匹配优先级

请求进来后,Nginx 按以下顺序挑选 server:

  1. 精确匹配 —— server_name example.com;
  2. 最长的以 * 开头的通配 —— *.example.com
  3. 最长的以 * 结尾的通配 —— www.example.*
  4. 按配置中出现顺序的第一个正则 —— ~^.+\.example\.com$
  5. 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 专有码:直接关闭连接不回包
}
444 的用途return 444 让 Nginx 直接断开 TCP 连接、不发任何响应。用于对付扫描器和恶意 SNI 探测,比返回 404 更干净(不浪费带宽,也不给对方任何信息)。现代浏览器都支持 SNI,所以 default_server 只是给爬虫和老客户端的兜底。

9root 与 alias 的区别

这是初学者最容易搞错的一对指令。搞混了就会出现"404 但文件明明在"的诡异状况。

9.1 核心差异

指令路径拼接方式记忆诀窍
rootroot 值 + 完整 URIroot = 根,后面保留 location 里的路径
aliasalias 值 替换 location 匹配的部分alias = 别名,把 location 那段换掉

9.2 对照实例

用 root root /var/www/site; location /images/ { } 请求 /images/logo.png 实际 /var/www/site/images/logo.png 用 alias location /images/ { alias /data/pics/; } 请求 /images/logo.png 实际 /data/pics/logo.png 关键的斜杠规则 alias 的结尾必须有斜杠: alias /data/pics/; ✅ alias /data/pics; ❌ 变成 /data/picslogo.png location 与 alias 的斜杠要对应: location /images/ { alias /a/b/; } 两者都以 / 结尾,替换干净
图 9.1 — root 是"前缀拼接",alias 是"前缀替换",斜杠决定成败

9.3 完整对照示例

场景:把 /static/ 映射到磁盘上另一个位置
# ── 方式 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 的坑

坑 1:alias 用在正则 location 时,必须用捕获组
# ❌ 错误:正则 location 配 alias 不能省略捕获
location ~ ^/img/(.*)$ {
    alias /data/$1;              # 必须用 $1 承接捕获
}

# ✅ 正确写法的一种
location ~* ^/uploads/(.+\.(?:jpg|png|webp))$ {
    alias /data/uploads/$1;
}
坑 2:alias 与 try_files 不兼容
# ❌ 这样写会有诡异行为,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)
排错方法看 error.log 里 Nginx 实际尝试打开的那个路径,一眼就能看出是拼接错了还是权限问题。这句话包含的信息量比猜测大得多:
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 匹配流程(必须理解这张图)

收到请求 URI ① 找 = 精确匹配 命中 → 立即使用,结束 ② 找所有前缀匹配,记住最长的那条 ③ 最长前缀带 ^~ ? 是 → 用它,结束(不查正则) ④ 按顺序试正则 ~ / ~* ⑤ 有正则命中 → 用第一个命中的 ⑥ 都不命中 → 用步骤②的最长前缀 不问顺序 顺序敏感 第一个赢
图 10.1 — location 匹配流程:精确 → 前缀(记最长)→ ^~ 截断 → 正则(顺序)→ 回退最长前缀
一句话记住= 最优先,然后看前缀,前缀被 ^~ 标记就到此为止,否则正则说了算,正则都没命中才回退到最长前缀。

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: 所有请求兜底 /images/a.png → F: 图片扩展名 /static/b.css → D: 静态资源,跳过正则 /x.php → E: PHP 正则 /photo.JPG → F: 图片扩展名 /api/user → G: API 前缀 /nothing → B: 所有请求兜底

逐个解释:

请求命中推理过程
/A= 精确匹配,最高优先级,直接定
/index.htmlB前缀匹配命中 /;正则 \.php$ 和图片规则都不匹配,回退到最长前缀 /
/images/a.pngF前缀命中 /images/,但它没带 ^~,所以继续查正则;\.(jpg|png|gif)$ 命中 → 正则优先于普通前缀
/static/b.cssD前缀 ^~ /static/ 命中,直接截断,不再查正则
/x.phpE没有更长前缀;正则 \.php$ 第一个命中
/photo.JPGF~* 不区分大小写,.JPG 也能命中
/api/userG最长前缀 /api/;无正则命中 → 用它
/nothingB只有 / 这个前缀能匹配
第 3 行最反直觉很多人以为 /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 覆盖默认值的坑
# ❌ 这样写,index.html 不再被当作索引文件!
index main.htm;

# ✅ 要保留默认行为
index index.html main.htm;

11.2 try_files:Nginx 最有用的指令之一

它按顺序检查一系列路径,返回第一个存在的文件;都不存在时执行最后一个参数指定的"兜底动作"。

语法
try_files 文件1 文件2 ... 文件N 兜底;

前 N-1 个参数是要检查的路径,最后一个参数是兜底——可以是:

11.3 四种典型用法

① 单页应用(SPA):所有路径都交给 index.html
location / {
    try_files $uri $uri/ /index.html;
}
# 请求 /user/123 → 磁盘上没有这个文件 → 内部转到 index.html
# 由前端路由接管,浏览器地址栏不变,这是 SPA 的标准配法
② 严格 404,不要 SPA 兜底
location / {
    try_files $uri $uri/ =404;
}
# 文件不存在就返回真 404(对 SEO 友好,也便于排查)
③ 尝试预压缩文件(省 CPU)
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(@ 开头)location @name 定义的块不能被外部请求直接访问,只能被 try_fileserror_page 内部引用。用来组织"兜底逻辑"非常合适。

11.4 try_files 完整请求链路图

请求 /user/123 ① 查 $uri /var/www/site/user/123 不存在 ↓ ② 查 $uri/ (当目录) 目录不存在 ↓ ③ 兜底 → /index.html 内部重定向,URI 对浏览器仍是 /user/123
图 11.1 — 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;
$uri/ 的副作用/ 的参数会触发"目录检查"。如果目录存在,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
}
autoindex 是一把双刃剑它会暴露目录下的全部文件名。用于内部分发或测试时方便,但绝不能对公网开放敏感目录——包括备份文件、配置文件、日志。
需要对外提供下载列表时,至少加一层访问控制:
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默认情况下,后端返回什么 Nginx 就原样透传。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 指令很有用标了 internal 的 location 不能被外部直接请求,只能由 Nginx 内部(error_pagetry_filesX-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;
wasm 与字体文件较老的 mime.types 里没有 .wasm.woff2.mjs,会导致浏览器拒绝加载(MIME 类型不对)。遇到"文件能下载但浏览器报错不执行",先查 Content-Type

第四部分 · 反向代理

13proxy_pass 原理与路径拼接

反向代理是 Nginx 最有价值的用途。而 proxy_pass 的路径拼接规则,是初学者翻车最多的地方——一条斜杠的有无,结果完全不同。

13.1 代理的基本原理

浏览器 :443 Nginx TLS 终止 改请求头 转发到后端 返回响应 后端应用 :5000 纯 HTTP HTTPS HTTP 响应 后端不需证书 不需公网 IP 不需监 443
图 13.1 — Nginx 做 TLS 终止,对外 HTTPS,对内明文 HTTP,后端零配置

13.2 路径拼接:四种情况

规则只有一条,但要精确理解:

核心规则proxy_pass 的值里带路径(哪怕只有一个 /),就会用这个路径替换掉 location 匹配的部分;不带路径时,把原始 URI 原样拼接上去。
情况 1:proxy_pass 不带路径
location /api/ {
    proxy_pass http://127.0.0.1:5000;
}
# 请求 /api/user  →  转发到 http://127.0.0.1:5000/api/user
#                        ↑ 原样保留 /api/user
情况 2:proxy_pass 带 /(斜杠结尾)
location /api/ {
    proxy_pass http://127.0.0.1:5000/;
}
# 请求 /api/user  →  转发到 http://127.0.0.1:5000/user
#                        ↑ /api/ 被替换成了 /
情况 3:proxy_pass 带子路径
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   ❌ 错了!
# 必须两边都有斜杠才对应
斜杠必须成对location 前缀带 / 时,proxy_pass 的路径也要带 /,否则会拼成 v2user 这种畸形路径。这条和 alias 的规则完全一样。
情况 4:location 用正则或 ^~
location ~ ^/api/(.*)$ {
    proxy_pass http://127.0.0.1:5000/$1;     # 用捕获组显式指定
}

location ^~ /static/ {
    proxy_pass http://127.0.0.1:8080/;       # ^~ 也遵循"替换"规则
}
正则 location 里不能省略路径正则 location 中,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 对照速查表

locationproxy_pass请求 /api/user 转发到
/api/http://srvhttp://srv/api/user
/api/http://srv/http://srv/user
/api/http://srv/v2/http://srv/v2/user
/api/http://srv/v2http://srv/v2user
/api(无尾斜杠)http://srvhttp://srv/api/user
/api(无尾斜杠)http://srv/http://srv//user ⚠️ 多一个斜杠
~ ^/api/(.*)http://srv/$1http://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
/keep/a/b → path=/keep/a/b /strip/a/b → path=/a/b /remap/a/b → path=/v2/a/b

三种行为一目了然。记住这个实验,以后就不用再纠结斜杠了。


14必须设置的代理请求头

14.1 默认代理头的问题

Nginx 转发请求时,默认会做两件事:把 Host 改成 proxy_pass 里的主机名,并补一个 Connection: close

这会带来一系列问题:

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 头来生成密码重置链接、回调地址,攻击者可以伪造 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 的传递链

客户端 1.2.3.4 CDN 5.5.5.5 Nginx 10.0.0.5 后端 :5000 XFF 逐跳追加(无人伪造时): 1.2.3.4 → 1.2.3.4, 5.5.5.5 → 1.2.3.4, 5.5.5.5, 10.0.0.5
图 14.1 — $proxy_add_x_forwarded_for 会把上游 IP 追加到已有 XFF 后面,形成链条
XFF 最左边不一定是真实 IP因为客户端可以自己伪造 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 是个好东西Nginx 内置的 $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_timeout60s与后端建立连接的超时5-10s(内网更快)
proxy_send_timeout60s向后端发送请求的超时30-60s
proxy_read_timeout60s等待后端返回响应的超时按业务定,见下
send_timeout60s客户端发送响应的超时30s
keepalive_timeout75s客户端长连接空闲时间30-65s
client_header_timeout60s读客户端请求头超时10-30s(防慢速攻击)
client_body_timeout60s读客户端请求体超时30s
resolver_timeout30sDNS 解析超时(proxy_pass 用域名时)5s
proxy_read_timeout 怎么定它必须大于后端最慢接口的耗时,否则会看到 504 Gateway Timeout,但后端其实还在正常处理。
# 报表导出类接口可能要几分钟
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让后端不被慢客户端拖住
一个隐蔽的坑开启了 gzip 压缩时,Nginx 必须缓冲整个响应才能压缩。所以 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
}
三条必须同时满足后端长连接要生效,这三条缺一不可:
  1. proxy_pass 指向 upstream 名(直接写 IP 时 keepalive 不生效)
  2. proxy_http_version 1.1
  3. proxy_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 握手,静态资源密集的页面上效果显著。

keepalive_timeout 设多少
  • 太短:连接频繁重建,握手开销大
  • 太长:空闲连接占用 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 配置

步骤 1:在 http 块加 map(放在 conf.d 里也可以)
http {
    # 客户端要升级时传 upgrade,否则传 close
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
}
为什么用 map 而不是直接写死常见写法是 proxy_set_header Connection "upgrade";,但这样所有请求都带 upgrade 头,包括普通 HTTP 请求——某些后端会因此行为异常。map 的做法是:有 Upgrade 头才传 upgrade,否则传 close。既支持 WS 又不影响普通请求。
步骤 2:location 配置
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 完整可跑示例

Node 端(后端)
// 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');
Nginx 端
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;
    }
}
验证(Node 客户端)
// 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);
✅ 连接成功(经过 Nginx 代理) ← 收到: {"type":"welcome","ts":1757925000123} ← 收到: {"type":"tick","ts":1757925001124} ← 收到: {"type":"echo","data":"hello"} ← 收到: {"type":"tick","ts":1757925002124}
常见 WebSocket 排错
现象原因
握手返回 400/426Upgrade / 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 是什么这是 Nginx 认识的响应头。后端返回 X-Accel-Buffering: no,Nginx 就会对这一个响应关闭缓冲,不用改全局配置。这比在 Nginx 里写 proxy_buffering off 更灵活——同一个 location 里,普通接口仍可缓冲,只有流式接口关闭。做 LLM 流式输出时这个头非常有用。
SSE 后端示例(Node + Express)
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 自己的缓冲,才能看到逐条输出
data: {"n":0,"ts":1757925010000} data: {"n":1,"ts":1757925010800} data: {"n":2,"ts":1757925011600} data: {"n":3,"ts":1757925012400} data: {"n":4,"ts":1757925013200} data: {"n":5,"ts":1757925014000}

16.5 WebSocket vs SSE 选择

对比项WebSocketSSE
方向全双工单向(服务器→客户端)
协议独立协议 (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 必须定义在 http 块不能写在 server 里。理论上可以把 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_hash 的三个局限
  1. 拿不到真实 IP 时失效:如果前面还有 CDN,ip_hash 拿到的可能是 CDN 的 IP,所有用户会被分到同一台后端。必须先配 real_ip 模块。
  2. 扩缩容会重新洗牌:增加或移除一台机器,大量用户的归属会变化,session 丢失。
  3. 同一出口 IP 的用户挤在一起:公司/学校出口 IP 相同,会被全部分到一台。
更好的方案是把 session 外置(Redis),然后用默认轮询——这才是可水平扩展的正确姿势。

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 参数的价值普通哈希在增删节点时会让几乎所有 key 重新分布(缓存命中率骤降)。加 consistent 后采用一致性哈希环,增删一个节点只影响 1/N 的 key。做本地缓存分片时这是必选项。

17.7 权重与状态标记

参数作用示例
weight=N权重,默认 1weight=3
max_conns=N该后端最大并发连接数(1.11.5+)max_conns=100
max_fails=N失败几次后标记不可用,默认 1max_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 的含义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; }
}
发 12 个请求看分布
for i in $(seq 1 12); do curl -s http://127.0.0.1:8083/; done | sort | uniq -c
2 backend-5002 4 backend-5001 6 backend-5000

比例 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;
}
请求失败计数(fail_timeout = 30s 为一个统计窗口) 第 1 次失败 1 继续转发 ✓ count < max_fails 第 3 次失败 3 达到 max_fails → 标记不可用 并触发"先失败一次"的行为 30 秒后 0 恢复为可用,重新开始接收请求 若立刻又失败,再次标记(要等下一个 window)
图 18.1 — 被动健康检查:在 fail_timeout 窗口内失败次数达到 max_fails 就摘除,窗口过后自动放回
被动检查的固有缺陷
  • 后端已经挂了,但要等到有请求失败才被发现——期间那些请求的失败由用户承担
  • 恢复检测也是靠真实请求"试"出来的,恢复瞬间会有一两个失败请求
  • 低流量时段(凌晨没人访问)故障可能长时间不被标记
缓解办法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;
重试 POST 的风险默认情况下 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 后端挂掉后的排查顺序

  1. 看 Nginx 错误日志
    sudo tail -50 /var/log/nginx/error.log
    connect() 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"
  2. 确认后端进程是否在跑
    systemctl is-active myapp
    ss -lntp | grep 5000
  3. 从 Nginx 所在机器直连后端(排除网络问题)
    curl -v http://127.0.0.1:5000/api/ping
  4. 看 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 握手与证书基础

客户端 Nginx ① ClientHello 支持的版本 / 加密套件 / SNI ② ServerHello + 证书 ① 选定套件 / 发证书链 ③ 验证证书 域名匹配?有效期?受信任 CA? ④ 密钥交换 → 生成会话密钥 ⑤ 之后所有 HTTP 数据用会话密钥加密 ⑥ 加密的 HTTP 请求 / 响应
图 19.1 — TLS 握手:证书的作用是"证明服务器身份",加密靠之后协商出的会话密钥
几个必须分清的概念
  • 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
certbot 2.9.0
方式一:nginx 插件(最省事,会自动改配置)
# 前提:域名已解析到本机,且 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
certbot --nginx 会就地改写你的配置文件它会:
  • 在 server 块里插入 listen 443 ssl;ssl_certificate 等指令
  • 添加一个 80 端口的 server 块做跳转
  • 给改动行加 # managed by Certbot 注释
所以:签证书之前备份配置,签完之后重新拉取配置。如果你在签证书之前复制了一份配置副本,签完后拿它去覆盖,会把 certbot 写入的 SSL 配置全冲掉——这是个真实踩过的坑。
方式二:webroot(不改 Nginx 配置,推荐用于精细控制)
# 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(签泛域名必备)
# 泛域名必须用 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-0180 端口公网可达普通域名,最简单
DNS-01DNS API 密钥泛域名、内网机器、通配证书
TLS-ALPN-01443 端口可达只能用 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; }
}
Ubuntu 的 certbot 会生成两个复用文件签完证书后,/etc/letsencrypt/ 下会有:
  • options-ssl-nginx.conf —— 由 certbot 维护的推荐 TLS 参数
  • ssl-dhparams.pem —— 预生成的 DH 参数(2048 位,生成一次要几分钟)
直接在 server 块里 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 套件才用
其实可以不配 dhparam只用 ECDHE 套件(现代客户端都支持)时,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
证书过期是最常见的事故三个必须确认的检查点:
  1. systemctl is-active certbot.timer 必须是 active
  2. certbot renew --dry-run 必须成功(很多问题只有演练才暴露)
  3. 续期后 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(叶子证书 + 中间证书)
在线检测工具配置完 TLS 后值得跑一次评级:
  • SSL Labs(qualys.com/ssllabs)—— 最权威,会指出具体问题
  • testssl.sh —— 命令行工具,能本地跑
目标:拿到 A 或 A+。常见扣分项是协议版本过低、缺 HSTS、证书链不完整。

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_name
  • $host:用客户端请求的域名跳转。多域名共用一个 server 块时必须用它
  • $server_name:只用第一个 server_name。多域名时会跳错(www 的请求被跳到非 www)
结论:跳转一律用 $host如果想把 www 统一规范到主域(或反过来),见下一节。

20.2 www 与非 www 的规范化

方案 A:非 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 表示"扩展已有证书",不会新建一套。
方案 B:一个 server 块同时处理,用 if 判断
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 提交)
HSTS 是一把不可逆的锁一旦浏览器收到这个头并记住,在 max-age 期限内:
  • 用户访问 http:// 时浏览器自动改成 https,且不允许用户点击"继续访问"
  • 证书一旦过期或配错,站点会彻底打不开,用户无法绕过
  • includeSubDomains 会波及所有子域——某个子域没配 HTTPS 就会直接不可访问
上生产的分阶段策略
  1. 先只加 max-age=300(5 分钟),观察几天确认没异常
  2. 逐步提到 86400(1 天)→ 604800(1 周)
  3. 确认所有子域都支持 HTTPS 后,再加 includeSubDomains
  4. 最后才考虑 preload

20.4 混合内容问题

HTTPS 页面里引用了 http:// 资源,浏览器会拦截(图片/脚本)或警告。三种处理方式:

① 升级请求头(让浏览器自己把 http 换成 https)
add_header Content-Security-Policy "upgrade-insecure-requests" always;
② Nginx 层改写响应内容(需 sub_filter 模块)
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 "";  # ★ 关键:必须让后端不压缩,否则无法改写
sub_filter 的局限它靠"文本替换"工作,因此:
  • 必须先关闭后端压缩Accept-Encoding ""),因为压缩后的字节流没法做文本匹配
  • 如果启用了 proxy_buffering off 或响应很大,可能因缓冲区不足而漏替换
  • 性能有损耗,用在关键页面即可,不要全局开
根本解法还是改源码,sub_filter 只是应急手段。
③ 正确做法:让后端知道原始协议
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;
}
顺序很重要Nginx 从上往下逐条匹配,第一条命中就决定结果,不再往下看。所以:
# ✅ 正确:先具体允许,最后兜底拒绝
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; }
}
allow/deny 看的是 $remote_addr,不是 X-Forwarded-For如果前面有 CDN 或负载均衡,$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

步骤 1:生成密码文件
# 需要 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
-c 参数会清空文件htpasswd -cc 是 create。第二次执行带 -c 会把之前所有用户删掉。加用户一律不加 -c。这个坑在"为什么突然登不上了"的案例里非常常见。
步骤 2:配置 Nginx
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";
}
Basic Auth 必须配 HTTPS它的密码只用 Base64 编码(不是加密),HTTP 明文传输时中间人可以直接解出用户名密码。Basic Auth 只在 HTTPS 下使用,且适合"内部工具临时保护",不适合正式的用户登录体系。
用 curl 测试
# 不带凭据 → 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
HTTP/1.1 401 Unauthorized WWW-Authenticate: Basic realm="Restricted Area"

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/(部署时直接 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;
}
两个最危险的 CORS 配法
  1. Access-Control-Allow-Origin: * —— 任何网站都能调用你的 API。只能用于完全公开的接口
  2. *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 完整用法

第 1 步:在 http 块声明限流区(不能写在 server 里!)
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 字符串)。
第 2 步:在 location / server 里应用
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=10r/s burst=20 单位时间桶容量:每 100ms 补一个令牌,最多存 20 个 令牌桶:当前 0 个(刚被清空) 请求 1-10 令牌够 → 立即放行 请求 11-30 进 burst 队列(20 个) 请求 31+ 队列满 → 503 nodelay 的效果 不带 nodelay: 队列里的请求按速率慢慢处理 用户会感到明显延迟 带 nodelay: 队列里的请求立即处理 只是不允许无限突发 取舍建议 · 接口限流:rate=10r/s burst=20 nodelay —— 用户无感,只挡住异常流量 · 严格削峰:rate=5r/s burst=10(不带 nodelay)—— 请求被均匀化,但有延迟 · 极严格:rate=1r/s burst=0 —— 超出立即 503,用于防刷接口
图 22.1 — burst 是排队缓冲,nodelay 决定队列中的请求是"立即处理"还是"按速率延迟处理"
不带 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 的优雅用法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/;
        }
    }
}
限流不是银弹Nginx 限流是单机的。如果后端有多台 Nginx,每台都独立计数,实际总速率是 N 倍。真正的全局限流需要:
  • 在上一层负载均衡器做(如云厂商的 WAF/API 网关)
  • 或用 OpenResty + Redis 做分布式计数
另外 Nginx 限流是"单 IP"维度,对分布式代理池攻击效果有限。真正要防的是业务层(验证码、风控)。

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;
}
proxy_request_buffering off 的意义默认 Nginx 会先把整个请求体收完再转发给后端。上传 100MB 文件时,这 100MB 会先落到 Nginx 的临时目录(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-PolicyXSS、资源注入(最强但最难配)
Referrer-PolicyURL 中的敏感参数泄漏给第三方
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')
这是最容易被忽略的高危问题走 Nginx 反代后,后端端口如果还暴露在公网,攻击者可以完全绕过你所有的 Nginx 安全策略——限流、鉴权、安全头、IP 白名单,全部失效。

自查方法(从外部机器测,不要在本机测):
# 看哪些端口真的对外可达
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 加固检查清单


第六部分 · 缓存与性能

24浏览器缓存策略

24.1 两种缓存机制

强缓存协商缓存
靠什么Cache-Control: max-age / ExpiresETag / Last-Modified
是否发请求不发,直接用本地副本发请求,服务端可能返回 304
速度最快(0 请求)较快(1 个往返,无响应体)
适用带 hash 的静态资源HTML、可能变的资源
有本地缓存? 发请求到服务器 下载完整资源 200 OK,存入缓存 强缓存还有效? 直接用本地副本 0 请求,最快 协商缓存 带上 If-None-Match 请求 304 → 用缓存 200 → 全新下载
图 24.1 — 缓存判定流程:先看强缓存,过期了才走协商缓存

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 和 no-store 完全不同这是最常被搞混的一对:
  • 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;
    }
}
hash 命名是最重要的缓存策略现代前端构建工具(Vite / Webpack)会给文件名加内容 hash: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;
重复的 Cache-Controlexpires 又用 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 传输
由谁生成特点
ETagNginx 根据文件 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
只有文本类内容才有压缩价值——HTML、CSS、JS、JSON、SVG、XML。

25.2 压缩级别怎么选

级别压缩率CPU 开销建议
1较低极低CPU 极度紧张时
4-5良好高并发场景推荐
6接近最优中等默认推荐(性价比拐点)
7-9略好一点点高很多不推荐
6 是拐点从 6 提到 9,文件通常只再小 1-3%,但 CPU 开销可能翻倍。除非是"只压一次、缓存很久"的场景(如静态资源的预压缩),否则没必要上 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;
}
验证 .gz 是否被使用
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 的静态预压缩动态压缩 brotli 级别 6 的 CPU 开销约等于 gzip 级别 8-9。生产上更推荐预压缩
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)"
原始 268K gzip -1 92K gzip -6 68K gzip -9 66K

结论: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"
content-encoding: gzip content-length: 92384 vary: Accept-Encoding
gzip 与 proxy_buffering 冲突压缩需要完整响应才能开始,所以:
  • 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 限制(强得多)
缓存不是无脑开缓存了不该缓存的内容会造成数据泄漏——A 用户看到 B 用户的数据。历史上多次真实事故都是这个原因。只缓存明确可共享的内容(公开列表、配置、静态化的页面),含用户身份的一律不缓存。

26.2 完整配置

第 1 步:在 http 块定义缓存区(必须写在 http 里!)
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)
第 2 步:在 location 应用
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_headHEAD 转 GET 后缓存默认 on
bypass 与 no_cache 的区别
# 场景:?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
x-cache-status: MISS x-cache-status: HIT x-cache-status: HIT
算一下命中率
# 需要在日志里记录 $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;      # ★ 长连接上不等待,立即发送(与上面看似矛盾,实则各管一段)
}
sendfile off(传统方式) 磁盘 内核缓冲区 用户态内存 socket 缓冲 网卡 4 次内存拷贝 + 2 次上下文切换 sendfile on(零拷贝) 磁盘 内核缓冲区 socket 缓冲 网卡 数据不经用户态,零拷贝 CPU 占用明显下降
图 27.1 — sendfile 让文件数据绕过用户态,静态文件服务的关键优化
tcp_nopush 与 tcp_nodelay 为什么能共存它们作用在不同阶段
  • tcp_nopush:发送完整文件时,攒够一个 MSS 再发,减少包数量(吞吐优化)
  • tcp_nodelay:发送零散小数据(如响应头、小 JSON)时,立即发送不等(延迟优化)
Nginx 内部会根据场景切换,两个一起开是最优组合。

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=30s30 秒内没被访问就移出缓存
open_file_cache_valid 60s每 60 秒检查一次文件是否变化
open_file_cache_min_uses 2被访问 2 次以上才缓存(过滤长尾)
open_file_cache_errors on也缓存"文件不存在"的结果(防大量 404 打磁盘)
收益缓存了文件的 inode、大小、修改时间、文件描述符,之后的请求不用再 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
206 与 200 的区别很重要如果返回的是 200 而不是 206,说明 Range 请求没被正确处理——视频无法拖动进度条、下载无法续传。常见原因是中间有代理层丢掉了 Range 头,或用了 proxy_buffering on 把响应攒起来了。

27.5 静态文件优化清单


28全局参数与 worker 调优

28.1 worker_processes 怎么设

# 推荐:auto(= CPU 核数)
worker_processes auto;

# 手动指定(不推荐,除非有特殊考虑)
worker_processes 4;

# 查看核数
nproc
lscpu | grep "^CPU(s):"
为什么是核数Nginx 的 worker 是单线程事件循环,一个 worker 跑满一个核。设为核数能让所有 CPU 都被利用且没有多余的进程切换开销。
例外情况:如果服务大量依赖磁盘 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;
}
Cookie 太大导致 400 错误默认 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_statusHIT / 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_forXFF 头内容链路还原(可被伪造,只作参考)
$request完整请求行,如 GET /a?b=1 HTTP/1.1看请求方法、路径、协议版本
$request_method仅方法名统计 GET/POST 比例
$request_uri原始 URI(含 query,不解码)日志里记这个最忠实
$uri解码并按规则处理后的 URI看 rewrite 之后的实际路径
$argsquery 字符串调试参数
$status响应码错误率统计
$body_bytes_sent响应体字节数(不含头)流量统计
$bytes_sent含响应头的总字节更精确的带宽统计
$request_length请求总字节(含头与体)防大请求攻击的指标
$http_referer来源页流量来源分析
$http_user_agentUA识别爬虫、区分端
$http_host请求头里的 Host多域名共用日志时区分站点
$server_name命中的 server_name区分虚拟主机
$schemehttp 或 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;
}
escape=json 是必加的 不加这个参数,如果 URI 里带有 " 或反斜杠,日志里就会出现未转义的字符, 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
}
logrotate 后必须 reload 或发 USR1 日志切割的原理是 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)"
$ bash /root/scripts/nginx-log-rotate.sh rotated at Tue Sep 15 03:00:01 CST 2026 $ ls -lh /var/log/nginx/ -rw-r----- 1 www-data adm 12K Sep 15 03:00 access.log -rw-r----- 1 www-data adm 1.2M Sep 14 23:59 access.log.20260914.gz

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
$9 和 $7 是怎么来的 这依赖默认 combined 格式的字段顺序
$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;
debug 级别要编译支持才生效 官方源装的 Nginx 可能没带 --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 里是邪恶的 Nginx 官方文档自己写了 "if is evil"。iflocation 里的行为有反直觉的地方, 特别是和 try_filesproxy_pass 混用时会出意外(见第 33 章)。
上面这个例子里的 if 是安全的用法——只做 set,不做 return/rewrite。 能用 map 就别用 if,能用 return 就别用 rewrite

29.9 一个坑:$request_time 单位是秒,但要当心人为构造的慢请求

$request_time 是从"读完请求头"开始算的,不包括客户端发完请求体的时间。所以一个客户端慢慢悠悠传 1MB 上传的时间不会算进去。想覆盖这部分,看 $upstream_response_time 也没有——那只是后端时间。真正要防的是slowloris 攻击(客户端只发一半请求头占住连接),这靠调 client_header_timeoutclient_body_timeout 来防(见第 23 章)。

29.10 本章小结

30故障排查方法论

排障这件事,最怕的是"东试一下西改一点"。下面这套流程是按从外到内、从粗到细的顺序设计的——每一步都能把问题范围砍掉一半。

30.1 五分钟定位法

1. nginx -t 2. systemctl status nginx active? 端口在听吗 3. curl -v 打本地 curl -v -H "Host: 域名" http://127.0.0.1/ ↓ 分叉判断 本地 curl 通 + 外网不通 → 防火墙 / 安全组 / DNS 查 ss -lntp 是否只绑了 127.0.0.1 本地 curl 也不同 → 看 error.log 查 root 路径 / 权限 / SELinux curl 返回 5xx → 后端挂了 / 反代配置错 看 upstream_addr 与 upstatus ↓ 缩小到具体层 DNS 解析对? 端口通? 命中哪个 server? 命中哪个 location? dig / nslookup telnet IP PORT · nc -zv curl -H "Host: x" · 看 access.log 落哪个文件 error_log notice 级 + rewrite_log on

30.2 第一刀:配置语法对不对

# 只检查语法
nginx -t

# 检查 + 打印实际加载的完整配置(include 全部展开)
nginx -T

# 检查 + 指定配置文件
nginx -t -c /etc/nginx/nginx.conf

# 检查 + 指定前缀目录(测试别的安装)
nginx -t -p /opt/nginx/
$ nginx -t nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful # 出错的样子: $ nginx -t nginx: [emerg] unknown directive "prox_pass" in /etc/nginx/conf.d/site.conf:18 nginx: configuration file /etc/nginx/nginx.conf test failed
看输出的三个关键点 1. 文件路径与行号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
$ systemctl status nginx ● nginx.service - A high performance web server and a reverse proxy server Loaded: loaded (/lib/systemd/system/nginx.service; enabled; preset: enabled) Active: active (running) since Tue 2026-09-15 10:20:03 CST; 3h 12min ago Docs: man:nginx(8) Process: 1234 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS) Main PID: 1236 (nginx) Tasks: 5 (limit: 4587) Memory: 12.4M CPU: 1.203s CGroup: /system.slice/nginx.service ├─1236 "nginx: master process /usr/sbin/nginx -g daemon on; master_process on;" ├─1237 "nginx: worker process" ├─1238 "nginx: worker process" ├─1239 "nginx: worker process" └─1240 "nginx: worker process" $ ss -lntp | grep nginx LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1236,fd=6),("nginx",pid=1237,fd=6),...) LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1236,fd=7),("nginx",pid=1237,fd=7),...)
内存只有 12M 是正常的 不要看到 Memory: 12.4M 觉得是不是没加载什么。Nginx 静态托管确实就这么点内存, 每个 worker 也就几 MB。内存暴涨通常是缓冲区配置过大client_body_buffer_sizeproxy_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
$ curl -v -H "Host: example.com" http://127.0.0.1/ * Trying 127.0.0.1:80... * Connected to 127.0.0.1 (127.0.0.1) port 80 > GET / HTTP/1.1 > Host: example.com > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Server: nginx/1.24.0 (Ubuntu) < Date: Tue, 15 Sep 2026 03:32:11 GMT < Content-Type: text/html < Content-Length: 615 < Last-Modified: Mon, 14 Sep 2026 11:02:33 GMT < Connection: keep-alive < ETag: "68c6f1a9-267" < Accept-Ranges: bytes < { [615 bytes data] * Connection #0 to host 127.0.0.1 left intact
curl http://127.0.0.1 会命中 default_server 不带 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;
    }
}
add_header 的继承是"全有或全无" 如果你在 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
# 典型错误消息 → 病因对照 2026/09/15 03:30:11 [error] 1237#1237: *8912 open() "/var/www/html/favicon.ico" failed (2: No such file or directory), client: 1.2.3.4, server: example.com, request: "GET /favicon.ico HTTP/1.1", host: "example.com" → 文件不存在。要么补文件,要么配 location 拦截。 2026/09/15 03:30:12 [error] 1237#1237: *8913 connect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, request: "GET /api/x HTTP/1.1", upstream: "http://127.0.0.1:5000/api/x", host: "example.com" → 后端没起来 / 端口不对 / 后端只绑了 ::1 而 Nginx 连的 127.0.0.1。 2026/09/15 03:30:13 [error] 1237#1237: *8914 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 1.2.3.4, request: "GET /slow HTTP/1.1", upstream: "http://127.0.0.1:5000/slow", host: "example.com" → 后端处理超时。调 proxy_read_timeout,或去优化后端。 2026/09/15 03:30:14 [error] 1237#1237: *8915 directory index of "/var/www/html/foo/" is forbidden, client: 1.2.3.4, request: "GET /foo/ HTTP/1.1", host: "example.com" → 目录下没有 index 文件,且 autoindex off。 2026/09/15 03:30:15 [error] 1237#1237: *8916 open() "/var/www/html/x.html" failed (13: Permission denied), client: 1.2.3.4, request: "GET /x.html HTTP/1.1", host: "example.com" → 权限问题。检查文件权限 + 各级目录的 x 位 + www-data 是否有权。 2026/09/15 03:30:16 [error] 1237#1237: *8917 no live upstreams while connecting to upstream, client: 1.2.3.4, request: "GET /api/x HTTP/1.1", upstream: "http://example_backend/api/x", host: "example.com" → 所有后端都被标记为 down 了(max_fails 触发)。也会出现在后端全挂时。 2026/09/15 03:30:17 [error] 1237#1237: *8918 limiting requests, excess: 5.000 by zone "api", client: 1.2.3.4, request: "POST /api/x HTTP/1.1", host: "example.com" → 限流触发了。excess 是超出量。不是 bug。 2026/09/15 03:30:18 [error] 1237#1237: *8919 SSL_do_handshake() failed (SSL: error:0A00006C:SSL routines::bad key share) while SSL handshaking, client: 1.2.3.4, server: 0.0.0.0:443 → TLS 握手失败。可能是扫端口的、或者客户端太老。量大时考虑封 IP。 2026/09/15 03:30:19 [error] 1237#1237: *8920 open() "/etc/nginx/ssl/x.crt" failed (2: No such file or directory) → 证书路径错。nginx -t 时就会 emerg,但改完没 reload 也可能出现。

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 的连接数
Recv-Q 非零 = backlog 溢出 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 本章小结

31常见错误码对照与处理

Nginx 能产生的状态码就那么些,但每个码后面的原因各不相同。这一章按码查因,当手册用。

31.1 4xx 客户端错误

含义Nginx 场景下的常见原因处理
400Bad Request 请求头过大超过 large_client_header_buffers
HTTP/2 里出现了非法字符;
客户端发了畸形请求(扫描器)
调大 client_header_buffer_sizelarge_client_header_buffers
扫器流量可忽略或封 IP
401Unauthorized auth_basic 生效,用户没带凭证或凭证错 确认 htpasswd 文件正确;auth_basic_user_file 路径对
403Forbidden 目录无 index 文件且未开 autoindex("directory index is forbidden");
文件或目录权限不足(www-data 读不到);
deny 规则拦了;
SELinux/AppArmor 拦截
补 index 文件或开 autoindex;chown -R www-data:www-datachmod -R a+rX
检查 allow/denygetenforce 查 SELinux
404Not Found root/alias 路径拼错;
try_files 兜底文件不存在;
SPA 没配 fallback
ls 确认实际路径;error_log 会打印它实际找的绝对路径
405Method Not Allowed 静态文件收到了 POST/PUT/DELETE;
limit_except 限制生效
静态目录用 limit_except GET HEAD { deny all; } 显式声明,报错更清晰
408Request Timeout 客户端建了连接不发送请求头,超过 client_header_timeout(默认 60s) 调小到 10s;这是 slowloris 攻击的典型特征
411Length Required POST 请求没带 Content-Length 也没用 chunked 客户端问题;也可以配 chunked_transfer_encoding on
413Payload Too Large 请求体超过 client_max_body_size(默认 1m) 上传功能必踩。调大:client_max_body_size 100m;
414URI Too Long URL 超过 large_client_header_buffers 限制 调大;但 URL 过长通常是设计问题
416Range Not Satisfiable 断点续传请求的 Range 超出文件大小 正常现象,客户端会重新完整下载
429Too Many Requests limit_reqlimit_conn 触发 检查 burst/rate 是否过严;给 API 单独放宽
499Client Closed Request 不是标准码,Nginx 私有。客户端在 Nginx 返回响应前主动断开 通常是用户点了停止/关了页面,或客户端超时太短。大量 499 且 rt 很大 = 后端太慢

31.2 5xx 服务端错误

含义原因处理
500Internal Server Error 后端的错(Nginx 只是转发);
FastCGI/PHP-FPM 返回的错;
少见的 Nginx 内部错误
看后端日志。Nginx 的 error_log 里通常只写 "upstream sent invalid header" 之类
501Not Implemented 请求方法 Nginx 不支持(如 PATCH 到静态文件) 反代后端;或显式拒绝
502Bad Gateway 最高频。后端没起来 / 端口错 / 后端崩了 / 后端返回了畸形响应;
后端只监听 IPv6;
upstream 全部被标记 down
ss -lntp 查后端在不在;curl 直连后端;看 error_log 的 upstream: 地址
503Service Unavailable limit_conn 触发(连接数满);
upstream 无可用节点;
worker_connections 用尽
看 error_log 有没有 "limiting connections";调大上限
504Gateway Timeout 后端在 proxy_read_timeout(默认 60s)内没返回 先确认是不是后端真慢(看 urt);确实慢就调大超时,但更重要的是优化后端
507Insufficient Storage Nginx 写入磁盘失败(日志盘满、临时文件盘满) df -h 查磁盘;清日志;检查 client_body_temp_path
502 和 504 的区别,一句话 502 = 连不上或连接断了;504 = 连上了但没等到响应。
502 往后端进程/端口/协议找;504 往后端处理时间/超时配置找。
另外 499 也值得注意:它说明客户端等不及走了——往往是 Nginx 表现得比用户预期慢, 虽然 Nginx 自己不认为这是错误,但对你来说是真实的用户体验损失。

31.3 3xx 重定向相关

含义语义典型用法
301Moved Permanently永久重定向,浏览器会长期缓存,后续直接跳HTTP → HTTPS;域名迁移;裸域 → www
302Found临时重定向,不缓存临时跳转;登录后跳回
307Temporary Redirect临时,且严格保持原请求方法(POST 还是 POST)API 临时迁移
308Permanent Redirect永久,且严格保持请求方法API 永久迁移(替代 301,避免 POST 变 GET)
304Not Modified协商缓存命中,让浏览器用本地副本If-Modified-Since/If-None-Match 触发
301 用错会很麻烦 浏览器对 301 的缓存几乎不受控——用户清了缓存才能摆脱。 如果你把 301 配错了(比如把根路径永久跳到某个页面),用户会一直跳错, 而你的服务器日志里根本看不到这些请求(浏览器自己就跳了)。
拿不准就用 302。确认稳定后再改 301。

31.4 一个决策表:502 到底查什么

502 Bad Gateway ss -lntp 后端端口在监听吗? 没在监听 后端挂了 / 端口不对 / 启动失败 → systemctl status 后端 在监听,但仍是 502 → 检查监听地址是否匹配 → 直连 curl 后端端口 IPv4/IPv6 不匹配 · backlog 满 · 后端自己返 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 别忘了加 不加 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;
}
444 是 Nginx 的私有关闭码 return 444 会让 Nginx 直接关闭 TCP 连接、不返回任何内容。 这对扫描器/爬虫是很有效的"我没有这个端口"信号,比返回 403/404 更省资源也更能隐藏信息。 日志里会记录 $status 为 444。

31.7 本章小结

第八部分 · 进阶主题

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 处理
redirect302否(返回给客户端)改变临时迁移
permanent301否(返回给客户端)改变永久迁移
last 里最多跑 10 次 Nginx 内部有个 rewrite 循环计数器,上限是 10 次。 如果你的规则互相指来指去(A rewrite 到 B,B 又 rewrite 回 A), 10 次之后 Nginx 返回 500 并在 error.log 里写 "rewrite or internal redirection cycle"
看到这个错误 = 你的规则有环。不要试图调大上限,去找那个环。

32.2 return 优先,能用就不用 rewrite

官方建议:能用 return 就不要用 rewritereturn 不需要跑正则引擎,性能更好,语义也更清楚。

# ── 整站跳 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;
}
$host vs $server_name:跳转时用 $host $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 的坑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 会丢掉 query string 这是极其常见的生产事故
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 ...)
server 层的 rewrite 用 last 效果等于 break 因为在 server 层还没有"当前 location"的概念,lastbreak 在这里行为一致——都是改完 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;
    }
}
迁移跳转的三条经验 1. 具体规则放前面,兜底规则放最后。rewrite 是按顺序第一个匹配就停。
2. 静态资源直接原样跳。重新映射路径会让 CDN 和浏览器缓存全部失效。
3. 老域名证书要留着。不保留证书,用户访问 https 老域名会先撞证书错误, 跳转根本没机会生效。老域名要一直续证书到它彻底没人访问为止。

32.8 本章小结

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 的三个特性 1. 惰性求值 — 只有真正用到 $目标变量 时才算,不用的 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;
        }
    }
}
为什么不能直接写 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 有个副作用 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;
        }
    }
}
CORS 的三个致命细节 1. 绝不能返回 * 且同时 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 是唯一正解 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';
}
split_clients 的百分比必须是整数且不能加起来超 100%10%33.3% 都是合法的,但总和超过 100% 时后面的会被忽略(不报错,很坑)。
种子选择很关键:用 $request_id 每次请求都随机(一人多次看到不同版本); 用 $remote_addr$cookie_userid 则同一用户稳定命中同一组——A/B 测试应该用后者

33.6 本章小结

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% 的配置结构错误。

nginx.conf(http 块内) http { gzip on; log_format ...; include conf.d/*.conf; } 原地展开 实际生效的等价配置 http { gzip on; log_format ...; server { ... } ← site-a.conf server { ... } ← api.conf } 所以 conf.d/*.conf 里: ✓ 只能写 server / upstream / map 等 http 级指令片段 —— 外面已经有 http 了 ✗ 不能再写 http { } —— 会嵌套成 http { http { } } 直接报错
最常见的配置错误 新手把完整配置粘进 /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;
    }
}
snippet 里的 add_header 和 location 里的会互相覆盖 如果 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;
shell 通配符按字典序,不是数字序 如果在 conf.d/ 里有: 1-a.conf10-b.conf2-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
这个警告的实际含义 Nginx 不会报错,只会警告,然后忽略后面的、用前面的
危险之处:你以为新配置生效了,其实没有——流量还在走旧的那个 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 配置管理的好习惯

34.9 本章小结

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";
    }
}
日志走 stdout 还是 /dev/stdout 两种写法都可以,但略有区别:
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 只保证启动顺序,不保证可用 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 进去,或者重启容器
在容器里执行 nginx 命令 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
非 root 必须同时改三处USER nginx 运行时,下面三处都必须可写,否则启动失败:
1. PID 文件 — 默认 /var/run/nginx.pid,非 root 无权写 /var/run,改成 /tmp/nginx.pid
2. 缓存目录/var/cache/nginx 下会建 proxy_temp、client_body_temp 等子目录
3. 日志目录 — 如果还写文件的话
另外 listen 80 在非 root 下也绑不上(1024 以下端口需要特权), 所以非 root 镜像内部要监听 8080,由 compose/k8s 映射成外部的 80。

35.7 K8s 环境的三点提示

35.8 本章小结

36Nginx 与前端 SPA 部署

SPA(单页面应用)的部署有几个固定套路,但每个套路都有坑。这一章把 React/Vue 部署到 Nginx 的完整细节讲清楚。

36.1 核心问题:前端路由 vs 服务端路由

SPA 的路由在前端(用 History API 操作地址栏)。当用户直接访问 /user/123刷新页面时,浏览器会真的向服务器请求 /user/123——而服务器上并没有这个文件,于是 404。

浏览器请求 GET /user/123 try_files $uri $uri/ /index.html 依次尝试,都不存在就返回 index.html /user/123 有? 无 → index.html 200 HTML 前端路由接管 关键前提: 1. index.html 必须 no-cache —— 否则用户永远加载到旧版本 2. 带 hash 的静态资源必须强缓存 —— 文件名变了才会重新下载

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;
    }
}
hash 命名 + HTML no-cache 是这个方案的根基 这套机制的逻辑链:
1. index.html 不缓存 → 用户每次访问都拿最新的 HTML
2. 新 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/ 的位置有讲究 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 的原子性 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;
    }
}
"$http_origin 回显"是有风险的 上面写 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.htmlno-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_typesapplication/javascript 补上类型;注意默认只压 text/html
"JS 加载成 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 本章小结

第九部分 · 实战

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"
真实用户 1.2.3.4 CDN / WAF 203.0.113.9 负载均衡 10.0.1.5 Nginx 10.0.1.6 应用 :5000 看到什么? 不配 real_ip 时,Nginx 看到: $remote_addr = 10.0.1.5 ← 负载均衡的 IP 配了 real_ip + 可信代理后: $remote_addr = 1.2.3.4 ← 真实用户 关键:set_real_ip_from 只能填「你自己控制的」代理 IP —— 填 0.0.0.0/0 等于信任任何人伪造 XFF

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 最右边的值(默认)
}
real_ip_recursive on 还是 off 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。只有一层代理两者等效。
绝对不能写 set_real_ip_from 0.0.0.0/0 这是严重的安全漏洞。填了它,等于告诉 Nginx "任何来源的 XFF 头我都信"。
攻击者只要发 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
}
$proxy_add_x_forwarded_for 做了什么 它的值是 $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();
ASP.NET Core 的 ForwardedHeaders 有两个大坑 坑 1:默认只信任 loopback。如果 Nginx 和容器不在同一网络命名空间(Docker 里很常见), 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');
trust proxy: true 是危险的 设成 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" .
第 3 步是最重要的自检 直接对外网发一个带 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;
    }
}
proxy_protocol 开了就不能直连了 一旦 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 本章小结

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= 否(每次请求随机) 不是灰度,是负载均衡
upstream weight 不能用来做灰度 这是最常见的误区。用 weight=1weight=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 不可动态调整 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 /;
        }
    }
}
为什么 Cookie 方案更好 1. 可控 — 测试同学访问 /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 和 consistent hash 怎么选 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;
        }
    }
}
A/B 测试的三个技术前提 1. 分桶必须稳定且均匀。用用户 ID(或已登录的用户 ID)作为 hash 种子, 不能用 $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 本章小结

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;
    }
}
$ curl -s http://127.0.0.1:8080/nginx_status Active connections: 291 server accepts handled requests 16630948 16630948 31070465 Reading: 6 Writing: 179 Waiting: 106 # ── 怎么读 ── # Active connections 当前活跃连接数(含 Reading/Writing/Waiting) # accepts 已接受的连接总数 # handled 已处理的连接总数 ← 若 accepts != handled,说明有连接被丢弃 # 常见原因:worker_connections 用尽、backlog 溢出 # requests 总请求数。requests/accepts = 平均每连接几个请求 # 这个比值低说明 keepalive 没生效 # Reading 正在读请求头的连接数(通常很小) # Writing 正在写响应的连接数 # Waiting 空闲的 keepalive 连接 ← 通常最多,正常
三个指标的诊断意义 accepts ≠ handled → 有连接被拒。worker_connections 不够或 backlog 太小。
requests / accepts < 2 → 每个连接平均不到 2 个请求,keepalive 基本没用上。检查 keepalive_timeout, 以及有没有哪个环节在发 Connection: close
Writing 持续很高 → 正在往外发数据,可能是大文件下载多,或者带宽打满了。
Reading 很高且持续 → 客户端发请求慢(slowloris 攻击特征),调小 client_header_timeout

39.3 压测工具选型

工具特点适合
abApache 自带,简单,单线程快速粗略测试;结果偏乐观
wrk多线程 + epoll,C 写的,性能强Linux 上压 Nginx 的首选
wrk2wrk 的改进版,支持恒定速率压测测延迟分布(更重要)
heyGo 写的,跨平台,易装Windows/Mac 本地测试
k6JS 脚本化,可写复杂场景业务级压测、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/
别在你要保护的机器上跑压测客户端 压测工具自己会吃掉大量 CPU 和网络。压测客户端必须在另一台机器上, 否则你测到的是"客户端 + 服务端互相抢 CPU"的结果,数字毫无意义。
另外不要对生产环境压测(除非有明确授权和隔离措施)。对线上下游服务压测可能触发限流、告警, 甚至真的把业务压垮。搭一套配置相同的压测环境是必要投入。

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                            ← 吞吐,注意是否网卡打满
看结果要看四个数,不是只看 QPS 1. Requests/sec — 吞吐量。静态文件 Nginx 单机能到几十万 QPS。
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 看网卡是否打满。
从这次实证里能提炼的四条规律 1. 收益排序:sendfile (+45%) > open_file_cache (+19%) > worker_processes(高并发下巨大)> gzip(负收益但省带宽)
2. 调优要一个一个来,每次都测。一起改五个参数,你不知道哪个有用、哪个有害。
3. 有些"优化"在低并发下测不出效果(backlog、worker_connections),不代表它们没用——是不符合当前测试场景。
4. 一定要找到瓶颈在哪再优化。最后 QPS 卡在 6.3 万是因为网卡打满了, 这时候再调 Nginx 参数是浪费时间。看 CPU、看网卡、看磁盘,先找到"哪个资源是满的"。

39.6 调优参数速查(按收益排序)

参数推荐值收益说明
sendfile onon极大零拷贝,静态文件必开
worker_processesauto极大(高并发)等于 CPU 核数
worker_connections4096+大(高并发)单 worker 最大连接数
open_file_cachemax=10000减少 stat 系统调用,小文件多时收益大
tcp_nopush onon配合 sendfile,攒满一个包再发
tcp_nodelay onon小包立即发,降延迟
keepalive_timeout15-65s高并发下调小
keepalive_requests1000单连接最多处理多少请求
access_logbuffer=32k flush=5s缓冲写日志,减少 IO
gzipon, level 6负(CPU)省带宽,但吃 CPU。视瓶颈决定
gzip_static onon正向用预压缩文件,几乎零 CPU 开销
proxy_bufferingon(默认)让后端快速释放,但要防大响应
upstream keepalive32-64大(反代场景)复用后端连接,降低后端压力
multi_accept onon一次 accept 所有新连接
reuseporton每个 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_cpu_affinity auto 的注意事项 这个参数让每个 worker 固定跑在某个 CPU 核上,提高 CPU 缓存命中率
但它要求 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;
}
upstream keepalive 不生效的三个原因 1. 没写 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 本章小结

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 章 一 · 入门 1-4 章 · 是什么 / 装 / 目录 / 命令 二 · 配置语法 5-7 章 · 块作用域 / 变量 / 陷阱 三 · 静态服务 8-12 章 · vhost / root / location 四 · 反向代理 13-18 章 · proxy / 头 / 长连接 / LB 五 · HTTPS 与安全 19-23 章 · TLS / HSTS / 限流 / 加固 六 · 缓存与性能 24-28 章 · 浏览器缓存 / gzip / 调优 七 · 日志与排障 29-31 章 · 日志格式 / 排查 / 错误码 八 · 进阶主题 32-36 章 · rewrite / map / 容器 / SPA 九 · 实战 37-40 章 · 真 IP / 灰度 / 压测 / 生产案例 推荐阅读路径 只做静态托管:一 → 二 → 三 → 七 → 八(34) = 约 18 章 要配反代 + HTTPS:上面全部 + 四 → 五 → 六 → 九 = 全部 40 章 已有环境要排障:直接跳 七 → 31 章错误码表 → 40 章验证清单

40.12 收尾:从"会改配置"到"能独立配站"

这份教程覆盖了从配置语法到生产运维的完整路径。如果只能带走五条,那就是这五条:

五条核心心法 1. 看懂配置和写对配置之间,隔着一整个"真正的掌握"。 找个环境把第 40 章的内容从零配一遍,遇到报错不许复制粘贴别人的答案—— 这比读十遍教程有用。

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 能告诉你瓶颈在哪一层。 猜出来的结论会把你带到更远的地方。

恭喜读完全部 40 章
这份教程覆盖了从配置语法到生产运维的完整路径。接下来最重要的一步是:在你自己的机器上 apt install nginx,然后照着第 40 章的完整站点案例动手配一遍
看懂配置和写对配置之间,隔着一整个"真正的掌握"——Nginx 的坑几乎全在细节里。