Linux 基础体系 · 第 71/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux Nginx 反向代理:进程模型、TLS、负载均衡、限流和日志

Nginx 是一个位于客户端与后端服务之间的网络代理。客户端把请求发送给 Nginx,Nginx 再根据配置决定是否终止 TLS、选择哪个后端、是否限流、如何转发请求以及记录哪些日志。这个过程看似只是“监听端口并转发”,实际上涉及 Linux socket、事件循环、TLS 状态机、HTTP 语义、共享内存和故障重试。

本文示例面向现代主流 Linux 发行版,使用发行版包管理器安装的 Nginx。不同发行版可能采用不同的配置目录、服务用户和 systemd 单元名称;修改生产配置前应确认 nginx -Vnginx -t、systemd 状态以及当前流量入口。


一、先建立完整的数据流模型

一个典型的 HTTPS 反向代理链路如下:

flowchart LR
    C[客户端] -->|TCP 连接 443| N[Nginx worker]
    N -->|TLS 握手与证书| C
    C -->|HTTP 请求| N
    N -->|限流与访问控制| L[请求处理阶段]
    L -->|选择后端| B1[Backend A]
    L -->|选择后端| B2[Backend B]
    N -->|记录 access/error 日志| LOG[日志文件或 journald]
    B1 --> N
    B2 --> N
    N --> C

一次请求通常经历以下状态:

  1. Linux 内核在监听 socket 上接收新连接。
  2. Nginx worker 接管连接,并通过事件通知机制等待读写事件。
  3. 如果监听的是 HTTPS 端口,Nginx 先进行 TLS 握手。
  4. TLS 握手中的 SNI 可用于选择虚拟主机和证书。
  5. Nginx 读取 HTTP 请求头,匹配 serverlocation
  6. 请求经过访问控制、限流、缓存等阶段。
  7. 如果是反向代理请求,Nginx 选择一个 upstream 后端。
  8. Nginx 与后端建立连接,转发请求,并读取响应。
  9. 响应返回客户端,同时写入访问日志;异常写入错误日志。

“反向代理”与“正向代理”的区别在于代理服务的代表对象不同:

  • 正向代理代表客户端访问外部服务,后端服务通常不知道真实客户端。
  • 反向代理代表服务端接收客户端请求,客户端通常只看到 Nginx 的地址。

Nginx 的 proxy_pass 不会自动解决所有协议和身份问题。它只决定请求如何转发;客户端到 Nginx 的 TLS、Nginx 到后端的 TLS、客户端真实地址传递、后端健康状态,都需要分别配置。


二、Linux socket 与 Nginx 进程模型

2.1 监听 socket 的基本状态

Nginx 监听 TCP 端口时,内核中至少涉及两类连接队列:

  • SYN 队列:保存尚未完成 TCP 三次握手的连接。
  • accept 队列:保存已建立、等待应用调用 accept() 取走的连接。

可以用 ss 查看监听状态:

sudo ss -ltnp

可能看到:

LISTEN 0 511 0.0.0.0:443 0.0.0.0:* users:(("nginx",pid=1200,fd=8))

字段含义包括:

  • LISTEN:socket 正在监听。
  • 0:当前等待应用取走的连接数。
  • 511:监听队列上限的一个实现相关表示,不能简单等同于系统所有连接容量。
  • 0.0.0.0:443:监听所有 IPv4 地址的 443 端口。
  • users:持有该 socket 的进程信息。

ss 展示的是内核观察结果,不等于 Nginx 已经成功处理的请求数。连接可能已建立但尚未被 worker 读取,也可能在 TLS 或 HTTP 阶段等待。

Linux 网络接口和地址可以用 iproute2 查看:

ip addr show
ip route show
ip route get 203.0.113.10

ip route get 显示内核对于某个目标地址会选择的出口接口、源地址和下一跳。排查 Nginx 无法连接后端时,这比只看 Nginx 配置更直接,因为路由、策略路由、防火墙和源地址选择都可能改变连接结果。

2.2 master 与 worker

典型 Nginx 进程结构如下:

nginx: master process /usr/sbin/nginx
nginx: worker process
nginx: worker process
...

可以查看:

ps -o pid,ppid,user,stat,cmd -C nginx

master 进程主要负责:

  • 读取和解析配置;
  • 创建监听 socket;
  • fork worker;
  • 接收信号;
  • 平滑重载和优雅退出;
  • 管理日志文件打开与关闭等控制操作。

worker 进程主要负责:

  • 接收客户端连接;
  • 处理 HTTP 和 TLS;
  • 连接后端;
  • 读写网络数据;
  • 执行请求处理阶段。

Nginx 常见模型是“少量进程 + 事件驱动”,而不是“每个连接一个线程”或“每个请求一个进程”。一个 worker 可以同时维护大量连接:当某个连接等待网络数据时,worker 不会阻塞在那里,而是处理其他就绪事件。

事件循环的抽象过程可以表示为:

while (worker_running) {
    events = wait_for_kernel_events();
    for (event in events) {
        process_read_or_write(event);
        advance_http_or_tls_state_machine();
    }
}

在 Linux 上,Nginx 通常使用 epoll 作为事件通知机制。epoll 的核心意义不是“自动提高性能”,而是让应用可以等待一组文件描述符中真正就绪的那些描述符,避免轮询每一个连接。

2.3 worker 数量不是越多越好

常见配置:

worker_processes auto;

auto 通常根据可见 CPU 数量选择 worker 数,但这只是合理起点,不是性能保证。worker 数量过多会导致:

  • 上下文切换增加;
  • TLS 加密和日志写入争抢 CPU;
  • 缓存、连接和内核资源竞争;
  • 容器中 CPU 配额与宿主机 CPU 数量不一致时产生误判。

如果服务受 cgroup CPU 限制,应结合实际配额和压测结果调整,而不能只根据宿主机物理核心数决定。

2.4 accept、共享监听 socket 与连接分配

master 创建监听 socket 后,worker 通常继承同一个文件描述符。多个 worker 可能同时等待新连接,这会涉及连接分配和“惊群”问题。

常见配置:

events {
    worker_connections 4096;
    multi_accept on;
}

含义:

  • worker_connections 是单个 worker 可打开连接数的配置上限,客户端连接和后端连接都可能占用连接资源,因此不能简单理解为“可服务请求数”。
  • multi_accept on 允许一次事件中接受多个连接,但可能使单个 worker 在短时间内拿走更多连接;是否有利取决于流量形态。
  • accept_mutex 控制 worker 是否通过互斥方式竞争新连接。在现代 Linux 和具体 Nginx 版本中,默认行为可能与旧版本不同,不能脱离版本直接推断。需要查看当前编译版本和官方文档。

某些 Linux/Nginx 组合还可以使用:

listen 443 ssl reuseport;

reuseport 允许多个 socket 绑定同一地址端口,由内核将连接分配给不同 socket。它可能改善多核扩展,但也改变连接分布特征,不应在没有验证的生产环境中盲目开启。

2.5 信号、重载与优雅退出

先检查配置:

sudo nginx -t

成功通常包括:

syntax is ok
test is successful

重载配置:

sudo systemctl reload nginx

重载的核心过程通常是:

  1. master 读取新配置;
  2. 新配置解析失败时,保留旧配置继续运行;
  3. 解析成功后,master 启动新 worker;
  4. 新 worker 使用新配置接受新连接;
  5. 旧 worker 停止接受新连接;
  6. 旧 worker 等待已有请求完成,然后退出。

因此,重载并不等于瞬间关闭所有连接,也不等于所有长连接立即切换。WebSocket、长轮询、大文件下载可能使旧 worker 长时间存在。

典型信号语义包括:

  • HUP:重读配置并平滑重载;
  • QUIT:优雅退出;
  • TERM:快速停止;
  • USR1:重新打开日志文件;
  • USR2:二进制升级流程的一部分。

应优先使用 systemd:

sudo systemctl reload nginx
sudo systemctl status nginx --no-pager

直接向 PID 发送信号时,必须确认 PID 文件和进程身份,避免误杀其他进程。


三、最小可运行的 HTTP 反向代理

假设后端服务监听 127.0.0.1:8080,可以配置:

http {
    upstream app_backend {
        server 127.0.0.1:8080;
    }

    server {
        listen 80;
        server_name example.test;

        location / {
            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;
        }
    }
}

若发行版主配置已经包含 /etc/nginx/conf.d/*.conf/etc/nginx/sites-enabled/*,应把 upstreamserver 放到相应文件,而不是重复嵌套一个新的 http 块。

验证配置并重载:

sudo nginx -t
sudo systemctl reload nginx
curl -v -H 'Host: example.test' http://127.0.0.1/

3.1 proxy_pass 路径行为

路径末尾的斜杠会改变 URI 转发结果,这是常见事故来源。

配置:

location /api/ {
    proxy_pass http://127.0.0.1:8080;
}

请求:

GET /api/users

通常转发为:

GET /api/users

而配置:

location /api/ {
    proxy_pass http://127.0.0.1:8080/;
}

通常会把匹配到的 /api/ 替换成 /,转发为:

GET /users

因此:

  • 无 URI 的 proxy_pass http://backend;:通常保留原始请求 URI。
  • 带 URI 的 proxy_pass http://backend/;:按匹配 location 的 URI 替换规则构造新 URI。

必须结合精确的 location 匹配和重写规则验证,不要只凭斜杠记忆。可以临时让后端回显请求 URI,或用后端访问日志确认。

3.2 Host 和客户端地址

默认情况下,后端看到的 Host 可能不是客户端原始 Host,而是由代理模块构造的值。因此常见配置显式传递:

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;

这些头部不是加密认证机制。客户端可以伪造 X-Forwarded-For,除非 Nginx 位于可信入口之后,并且使用 set_real_ip_fromreal_ip_header 等配置正确重建真实地址。

若 Nginx 直接面向互联网,不应无条件信任客户端提交的 X-Forwarded-For。例如:

set_real_ip_from 10.0.0.0/8;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

上述配置只有在这些网段确实是可信代理网络时才成立;错误配置会让攻击者伪造源地址并绕过基于 IP 的审计或限流。


四、TLS:客户端连接和后端连接是两段独立的安全关系

4.1 TLS 终止的含义

TLS 终止是指客户端与 Nginx 建立 TLS 会话,Nginx 解密 HTTP,然后以明文或另一条 TLS 连接访问后端:

客户端 ==TLS==> Nginx ==HTTP==> 后端

如果后端也使用 TLS,则是:

客户端 ==TLS==> Nginx ==TLS==> 后端

这两段 TLS 的证书、信任根、SNI、校验策略彼此独立。客户端验证的是 Nginx 证书;Nginx 验证的是后端证书。

4.2 基本 HTTPS 配置

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate     /etc/nginx/tls/example.com.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/example.com.key;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

ssl_certificate 通常应指向包含服务器证书及中间证书的链文件,而不是只包含叶子证书的文件。浏览器可能通过缓存或 AIA 补齐链,但不能依赖这种行为;命令行客户端和部分非浏览器客户端通常会更严格。

私钥权限应限制为 Nginx master 能读取的范围,例如:

sudo chown root:root /etc/nginx/tls/example.com.key
sudo chmod 600 /etc/nginx/tls/example.com.key
sudo nginx -t

实际运行用户和 master 读取私钥的方式受发行版打包配置影响,应以 psnginx -T 和文件权限验证为准。生产环境不应把私钥放在普通用户可写目录。

4.3 SNI 与虚拟主机选择

SNI 是 TLS ClientHello 中的服务器名称扩展。客户端在握手早期发送:

example.com

Nginx 可以据此选择对应的 server 和证书。一个地址端口上部署多个 HTTPS 域名时,SNI 是证书选择的关键。

验证证书和 SNI:

openssl s_client \
  -connect 127.0.0.1:443 \
  -servername example.com \
  -showcerts </dev/null

如果不指定 -servername,Nginx 可能返回该地址端口的默认 server 证书。此时“浏览器访问正常但 openssl 看到错误证书”可能只是测试没有发送 SNI。

TLS 握手的关键顺序是:

  1. 客户端发送支持的协议、密码套件和 SNI。
  2. Nginx 根据监听地址、端口和 SNI 选择虚拟主机。
  3. Nginx 返回证书链和协商参数。
  4. 双方验证证书、协商密钥。
  5. 握手完成后,HTTP 才在加密通道中传输。

TLS 证书错误通常发生在第 2 至第 4 步,不能用后端应用日志解释。

4.4 后端 HTTPS 与证书校验

如果 Nginx 到后端也使用 TLS:

location / {
    proxy_pass https://app_backend;

    proxy_ssl_server_name on;
    proxy_ssl_name api.internal.example;

    proxy_ssl_verify on;
    proxy_ssl_trusted_certificate /etc/nginx/tls/internal-ca.pem;
    proxy_ssl_verify_depth 3;

    proxy_set_header Host api.internal.example;
}

这里有三个不同概念:

  • proxy_pass https://...:Nginx 到后端使用 TLS。
  • proxy_ssl_server_name on:向后端 TLS 握手发送 SNI。
  • proxy_ssl_verify on:验证后端证书,而不是仅加密传输。

如果 upstream 使用域名,DNS 解析、证书名称、SNI 名称和 Host 头部可能需要一致,但它们不是同一个字段。关闭 proxy_ssl_verify 可以暂时绕过证书校验,却会失去对端身份认证,不应当作为长期修复。

4.5 TLS 排障的分层方法

先确认监听:

sudo ss -ltnp | grep ':443'

再确认握手:

openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -verify_return_error </dev/null

再确认 HTTP:

curl -v https://example.com/

分层判断:

  • TCP 连接失败:检查监听地址、防火墙、路由、安全组。
  • TCP 成功但 TLS 失败:检查证书、私钥、协议、SNI、时间和链。
  • TLS 成功但 HTTP 返回 4xx/5xx:检查 server/location、认证和后端。
  • Nginx 返回 502:重点检查到后端的 TCP、TLS、协议和响应。
  • Nginx 返回 504:重点检查后端响应超时或网络路径。

五、upstream 与负载均衡

5.1 负载均衡解决什么问题

负载均衡是把多个请求分配给多个后端实例。它可以提高容量和可用性,但不会自动保证:

  • 请求在后端之间完全均匀;
  • 有状态会话能够跨实例工作;
  • 某个后端故障能立即被发现;
  • 重试不会造成重复写入;
  • 数据库和缓存具备一致性。

配置示例:

upstream app_backend {
    server 10.0.0.11:8080 weight=3;
    server 10.0.0.12:8080 weight=1;
    server 10.0.0.13:8080 backup;
}

server {
    listen 80;
    server_name app.example.com;

    location / {
        proxy_pass http://app_backend;
    }
}

默认分配方法通常是加权轮询。权重为 3:1 时,长期观察下新请求的分配倾向约为 3/41/4,但短时间窗口、连接失败、长请求和重试都会破坏这个比例。

backup 后端通常只有在非 backup 后端不可用时才接收请求。它不是普通的低权重节点。

5.2 常见算法及状态

加权轮询

upstream app_backend {
    server 10.0.0.11:8080 weight=3;
    server 10.0.0.12:8080 weight=1;
}

适合无状态请求。它按请求选择,而不是按字节数或 CPU 消耗选择,因此一个大文件请求可能让“请求数均衡”与“资源消耗均衡”完全不同。

最少连接

upstream app_backend {
    least_conn;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

选择当前活动连接数较少的节点。它更适合请求耗时差异较大的场景,但“连接少”不等于“CPU 空闲”,HTTP/2 多路复用还会使连接数与请求数的关系更加复杂。

IP hash

upstream app_backend {
    ip_hash;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

同一个客户端地址倾向于访问同一个后端。这只能提供弱粘性:

  • NAT 会让大量用户共享一个地址;
  • 移动网络和代理会改变源地址;
  • 节点增删会导致映射变化;
  • 它不能替代共享 session 存储。

通用 hash

upstream app_backend {
    hash $request_uri consistent;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

consistent 使用一致性哈希思想,减少节点变更造成的整体映射迁移。它适合缓存键或明确的业务分片场景,但如果直接按 URI 哈希,热点 URI 仍可能集中到一个节点。

5.3 被动故障判断

可以配置:

upstream app_backend {
    server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
}

常见含义是:在 fail_timeout 时间窗口内,某节点失败达到 max_fails 后,Nginx 暂时认为它不可用。这里的失败是 Nginx 在代理请求过程中观察到的失败,不等于完整的应用健康检查。

开源 Nginx 的 upstream 健康判断主要是被动的。商业版或第三方模块可能提供主动健康检查,但不能把其他产品或模块的指令当作开源版默认能力。

5.4 超时与连接失败

location / {
    proxy_connect_timeout 3s;
    proxy_send_timeout 30s;
    proxy_read_timeout 60s;

    proxy_pass http://app_backend;
}

三个超时分别对应不同阶段:

  • proxy_connect_timeout:连接后端建立 TCP 连接的时间。
  • proxy_send_timeout:向后端发送请求数据时,两次写操作之间允许的间隔。
  • proxy_read_timeout:从后端读取响应时,两次读操作之间允许的间隔。

它们通常不是整个请求的绝对总时限。例如后端每 50 秒返回一小段数据,proxy_read_timeout 60s 可能一直不超时。若需要业务级总时限,应在应用或更高层实现。

5.5 重试的安全边界

proxy_next_upstream error timeout http_502 http_503 http_504;

这表示在特定失败条件下,Nginx 可以尝试另一个后端。但重试有副作用:

客户端发送 POST
        |
        v
后端 A 已经执行写入,但响应在网络中丢失
        |
        v
Nginx 重试到后端 B
        |
        v
业务写入执行两次

因此,读请求和具备幂等键的写请求更适合重试。所谓幂等,不是“请求方法叫 GET/POST”这么简单,而是重复执行是否产生相同业务结果。支付、下单、扣库存等操作必须由应用使用请求唯一 ID、数据库约束或幂等表保证重复提交安全。

5.6 被动均衡的反例

如果一个后端进程仍然监听端口,但每个请求都返回 500,TCP 连接和 Nginx 的连接建立可能完全成功。此时只依赖“端口是否可连接”的外部检查,会把一个业务不可用节点误判为健康。

反过来,如果后端健康接口很慢,但真实业务接口正常,过于严格的主动检查也可能错误摘除节点。健康检查应代表实际服务能力,而不是只检查一个无关的端口。


六、限流:请求速率、并发数与容量不是同一个概念

Nginx 常用两类限流:

  • limit_req:限制请求速率。
  • limit_conn:限制并发连接数。

它们解决不同问题。每秒请求数低,并不表示连接数低;一个请求可能持续数分钟。反之,大量短请求会消耗请求处理能力,但不一定长期占用连接。

6.1 limit_req 的漏桶模型

配置:

http {
    limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;

    server {
        listen 80;
        server_name app.example.com;

        location /api/ {
            limit_req zone=per_ip burst=20 nodelay;
            proxy_pass http://app_backend;
        }
    }
}

含义:

  • $binary_remote_addr:以客户端地址作为限流键,二进制形式比字符串更节省共享内存。
  • zone=per_ip:10m:创建名为 per_ip、大小为 10 MB 的共享内存区域。
  • rate=10r/s:平均速率为每秒 10 个请求。
  • burst=20:允许一定数量的突发请求等待或排队。
  • nodelay:不等待,允许突发请求立即通过,直到突发容量耗尽。

可以用令牌桶近似理解,但 Nginx 的 limit_req 更准确地表现为按速率排队的漏桶行为。设:

  • 平均速率为 rr
  • 某一时刻到达 nn 个请求;
  • 配置突发容量为 bb

如果当前没有积压,瞬时到达的请求中,超出平均速率部分最多可在 burst 范围内处理或等待。没有 nodelay 时,Nginx 会通过延迟请求把输出速率拉回 rr;有 nodelay 时,突发请求直接放行,但之后仍会受到速率状态约束。

例如 rate=10r/s, burst=20

  1. 短时间内到达 10 个请求:通常立即放行。
  2. 再来 20 个请求:若配置允许突发,它们占用 burst 额度。
  3. 超过可用额度:返回限流错误,默认通常为 503
  4. 没有 nodelay 时,部分请求可能等待,而不是立即得到响应。

这不是“每秒固定窗口最多 10 个”的算法。固定窗口会产生边界突发,例如前一秒末尾通过 10 个、后一秒开头再通过 10 个;漏桶模型的状态是连续变化的。

6.2 限流状态存在哪里

limit_req_zone 的状态放在共享内存中,使多个 worker 能看到同一个限流状态。若使用客户端 IP 作为 key,Nginx 处理请求时会:

  1. 计算 key;
  2. 在共享内存中查找该 key;
  3. 读取该 key 的最近请求状态;
  4. 根据经过时间和速率计算是否可通过;
  5. 更新状态;
  6. 释放共享内存锁并继续请求。

因此,限流区会消耗内存和锁竞争。使用高基数 key,例如任意用户输入、完整 URI 或大量随机 Header,可能导致内存耗尽、旧状态淘汰和行为不稳定。

6.3 按用户而不是按 IP 限流

如果认证后有稳定用户变量,可以使用:

limit_req_zone $http_authorization zone=per_token:20m rate=5r/s;

但直接使用整个 Authorization 头存在安全和容量问题:

  • 未认证请求可能都没有该头;
  • token 很长,内存占用更高;
  • 日志或调试时可能泄露凭据;
  • token 轮换会产生大量 key。

更合理的做法通常是由认证层生成经过验证的用户标识,并确保未认证请求使用单独的低权限策略。限流键必须在信任边界内生成,不能把客户端任意提交的用户 ID 当作真实身份。

6.4 limit_conn:限制并发连接

http {
    limit_conn_zone $binary_remote_addr zone=addr_conn:10m;

    server {
        location /download/ {
            limit_conn addr_conn 2;
            proxy_pass http://app_backend;
        }
    }
}

它限制的是同一 key 的并发连接数。若客户端使用 HTTP/2,多路请求可能共享一个 TCP 连接,因此“连接数”不一定等于“并发请求数”。如果业务要限制并发下载任务,应在应用层按任务或用户控制。

6.5 限流状态码和验证

可以显式修改状态码:

limit_req_status 429;
limit_conn_status 429;

验证方法:

for i in $(seq 1 50); do
    curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1/api/test
done | sort | uniq -c

预期可能看到:

  20 200
  30 429

实际数量受速率、请求间隔、后端响应速度和 worker 调度影响,不应把这个数字当成固定保证。生产测试还应观察 $request_time、错误日志和后端接收量,确认请求是在 Nginx 入口被拒绝,而不是后端返回了同样的状态码。


七、日志:把一次请求的因果链记录下来

7.1 access log 与 error log

  • access log:记录请求正常完成或被处理后的访问事实,例如客户端地址、请求行、状态码、响应字节数和耗时。
  • error log:记录配置、连接、TLS、upstream、权限等错误和诊断信息。

基本配置:

http {
    log_format main
        '$remote_addr - $remote_user [$time_local] '
        '"$request" $status $body_bytes_sent '
        '"$http_referer" "$http_user_agent" '
        'rt=$request_time '
        'uct=$upstream_connect_time '
        'uht=$upstream_header_time '
        'urt=$upstream_response_time '
        'ua="$upstream_addr" us="$upstream_status" '
        'rid="$request_id"';

    access_log /var/log/nginx/access.log main;
    error_log  /var/log/nginx/error.log warn;
}

重要变量:

  • $request_time:从读取客户端第一个字节到日志写入前的请求处理时间。
  • $upstream_connect_time:连接后端的时间。
  • $upstream_header_time:连接后端后等待响应头的时间。
  • $upstream_response_time:从后端读取响应所涉及的时间。
  • $upstream_status:后端返回的状态。
  • $upstream_addr:实际使用的后端地址。
  • $request_id:Nginx 生成的请求标识,适合串联日志,但不能自动等同于业务请求 ID。

这些时间不是同一段时钟:

request_time
|----------------------------------------------------|
客户端读请求 + 排队 + upstream + 向客户端发送响应

upstream_response_time
             |---------------------|
             连接后端并读取响应

request_time - upstream_response_time
≈ 客户端收发、Nginx 排队、限流延迟等其他部分

这个差值不是严格的 CPU 时间,也不是精确的网络延迟,但可以帮助区分“后端慢”和“客户端慢”。

7.2 真实客户端地址与代理链

如果 Nginx 前面还有 CDN、负载均衡或 Keepalived 漂移的入口,$remote_addr 可能只是上一跳代理地址。此时应先定义可信代理边界,再配置 real IP 模块,并在日志中同时记录:

log_format proxy_audit
    'peer=$remote_addr real=$realip_remote_addr '
    'xff="$http_x_forwarded_for" '
    'request="$request" status=$status rt=$request_time';

变量的具体含义和处理结果依赖 real IP 配置。不能看到一个 X-Forwarded-For 就直接把它当作真实来源。

7.3 日志文件轮转

Nginx 打开的日志文件不会因为外部 mv 自动切换到新文件。常见轮转过程是:

sudo mv /var/log/nginx/access.log /var/log/nginx/access.log.1
sudo kill -USR1 "$(cat /run/nginx.pid)"

systemd 或 logrotate 通常会自动完成类似动作。轮转后必须让 Nginx 重新打开日志,否则它可能继续写入已经被重命名但仍保持打开的旧 inode。

检查文件和进程持有关系:

sudo lsof -p "$(cat /run/nginx.pid)" 2>/dev/null
sudo lsof | grep '/var/log/nginx'

日志包含 URL、查询参数、用户代理和可能的身份信息。不要在日志中记录密码、完整 token、Cookie 或未经脱敏的敏感请求体。


八、一个相对完整的生产型示例

下面示例把 TLS、upstream、请求头、超时、限流和日志放在一起:

worker_processes auto;

events {
    worker_connections 4096;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    log_format proxy_main
        '$remote_addr [$time_iso8601] '
        'rid=$request_id "$request" status=$status '
        'bytes=$body_bytes_sent rt=$request_time '
        'uct=$upstream_connect_time '
        'uht=$upstream_header_time '
        'urt=$upstream_response_time '
        'upstream=$upstream_addr '
        'up_status=$upstream_status';

    access_log /var/log/nginx/access.log proxy_main;
    error_log  /var/log/nginx/error.log warn;

    limit_req_zone $binary_remote_addr zone=api_per_ip:10m rate=20r/s;
    limit_conn_zone $binary_remote_addr zone=download_per_ip:10m;

    upstream app_backend {
        least_conn;
        server 10.0.0.11:8080 max_fails=3 fail_timeout=10s;
        server 10.0.0.12:8080 max_fails=3 fail_timeout=10s;
    }

    server {
        listen 80;
        server_name app.example.com;

        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl;
        server_name app.example.com;

        ssl_certificate     /etc/nginx/tls/app.example.com.fullchain.pem;
        ssl_certificate_key /etc/nginx/tls/app.example.com.key;
        ssl_protocols TLSv1.2 TLSv1.3;

        location /api/ {
            limit_req zone=api_per_ip burst=40 nodelay;
            limit_req_status 429;

            proxy_connect_timeout 3s;
            proxy_send_timeout 30s;
            proxy_read_timeout 60s;

            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 https;
            proxy_set_header X-Request-ID $request_id;
        }

        location /download/ {
            limit_conn download_per_ip 2;
            limit_conn_status 429;

            proxy_pass http://app_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-Proto https;
        }
    }
}

部署步骤:

sudo nginx -t
sudo systemctl reload nginx
sudo systemctl status nginx --no-pager

逐层验证:

curl -I http://app.example.com/
curl -vk https://app.example.com/api/health
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log

验证结果应分别回答:

  1. HTTP 是否被重定向到 HTTPS?
  2. HTTPS 证书和 SNI 是否正确?
  3. /api/health 是否到达后端?
  4. 响应头中是否有期望的请求 ID?
  5. access log 中的 upstream 地址、状态和耗时是否符合预期?
  6. 限流时是否由 Nginx 返回 429,而不是后端返回?

九、失败路径与诊断顺序

9.1 502 Bad Gateway

502 通常表示 Nginx 作为网关从上游获得了无效响应,常见原因包括:

  • 后端端口未监听;
  • TCP 连接被拒绝;
  • 后端提前关闭连接;
  • HTTP 响应格式错误;
  • Nginx 与后端协议不匹配,例如把 HTTPS 后端按 HTTP 访问;
  • upstream DNS 或地址配置错误。

诊断:

sudo ss -ltnp | grep ':8080'
curl -v http://10.0.0.11:8080/health
sudo tail -n 100 /var/log/nginx/error.log

如果后端是 HTTPS:

curl -vk https://10.0.0.11:8443/health
openssl s_client -connect 10.0.0.11:8443 -servername api.internal.example </dev/null

不要看到 502 就立即增加超时。连接拒绝和证书校验失败不是“等待时间太短”。

9.2 504 Gateway Timeout

504 更常见于:

  • 后端接受连接但迟迟不返回响应头;
  • 后端读取过程中长时间没有数据;
  • 网络路径丢包或中间设备丢弃连接;
  • 配置的 proxy_read_timeout 太短。

应结合:

uct:后端连接是否建立得慢
uht:后端响应头是否迟迟不来
urt:后端响应读取是否耗时
rt:客户端整个请求是否更慢

如果 uht 很大,问题多半在后端排队、数据库或应用处理;如果 urt 很大而 uht 较小,可能是响应体生成或传输慢。

9.3 配置通过但服务无法启动

nginx -t 成功只表示配置语法和部分文件检查通过,不保证:

  • 端口没有被其他进程占用;
  • SELinux/AppArmor 允许访问证书和日志;
  • systemd 运行目录存在;
  • 后端网络可达;
  • 证书在运行时仍有效。

检查:

sudo systemctl status nginx --no-pager
sudo journalctl -u nginx -b --no-pager
sudo ss -ltnp | grep -E ':(80|443)\b'
sudo nginx -T

nginx -T 会展开并打印完整配置,适合确认 include 后最终生效的内容;其中可能包含敏感路径和配置,不应随意粘贴到公开工单。


十、生产边界:哪些问题 Nginx 不能替应用解决

10.1 会话状态

如果用户登录状态存储在单个后端进程内存中,负载均衡后可能出现:

第一次请求 -> Backend A,建立 session
第二次请求 -> Backend B,提示未登录

解决方向是:

  • 把 session 放在共享存储;
  • 使用加密且可验证的无状态 cookie;
  • 临时使用粘性策略。

IP hash 只能降低切换概率,不能提供可靠会话一致性。

10.2 TLS 与端到端加密

Nginx 终止 TLS 后,客户端到 Nginx 的机密性并不自动延伸到 Nginx 到后端。若后端跨主机、跨租户或经过不可信网络,应使用后端 TLS,并启用证书验证和正确的 SNI。

10.3 Keepalived 与 VRRP 的关系

Keepalived/VRRP 可以让多个 Nginx 节点共享一个虚拟 IP,但它只解决入口 IP 的漂移,不会同步:

  • Nginx worker 内存状态;
  • limit_req 共享区;
  • 本地 session;
  • TLS 会话缓存;
  • upstream 被动失败状态;
  • 日志文件。

因此,VRRP 切换后,限流计数可能从另一台节点重新开始,连接也可能因网络路径变化而重建。健康检查脚本必须避免“进程存在即健康”的误判;一个已经无法连接后端的 Nginx 进程仍然可能存活。

脑裂时两个节点同时宣告持有 VIP,会导致 ARP/邻居缓存不断变化、流量随机进入不同节点。VRRP 不是分布式一致性协议,不能单独保证脑裂场景下的数据一致性。

10.4 WebSocket 和长连接

WebSocket 需要显式传递升级头:

location /ws/ {
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    proxy_read_timeout 1h;
    proxy_pass http://app_backend;
}

WebSocket 长时间占用连接,因此:

  • worker_connections 要按客户端连接和后端连接共同估算;
  • limit_conn 可能比 limit_req 更重要;
  • 重载和后端切换不会自动迁移已有连接;
  • 负载均衡算法对长连接的分布可能长期不均衡。

十一、几个容易被忽略的反例

反例一:把 worker_connections 当作并发用户数

假设有 4 个 worker、每个 worker_connections 4096,不能直接得出可服务 16384 个用户。原因包括:

  • 一个客户端连接可能对应一个后端连接;
  • TLS、HTTP/2、WebSocket 的连接模型不同;
  • 文件描述符总上限可能更低;
  • 连接还需要 socket 缓冲区、TLS 状态和请求内存;
  • upstream、日志和其他模块也会占用资源。

可检查:

ulimit -n
cat /proc/$(pgrep -o nginx)/limits | grep 'open files'

反例二:只检查后端端口

端口可连接只能证明 TCP 层可能建立连接,不证明:

  • TLS 证书正确;
  • HTTP 协议正确;
  • 依赖的数据库正常;
  • 请求能在时限内完成;
  • 返回内容符合业务要求。

健康检查应覆盖真正影响流量的关键路径,但检查接口本身也不能依赖过多外部系统,否则会把局部故障扩大成全量摘除。

反例三:按 IP 限流等于按用户公平限流

企业出口、移动运营商和 NAT 会让很多用户共享一个 IP。按 IP 限流可能让一个大组织的所有用户互相争抢额度;攻击者也可能通过代理池改变源 IP。IP 限流是网络边界上的粗粒度保护,不是身份级配额系统。

反例四:增加重试就能提高可用性

对于查询请求,重试可能绕过瞬时故障;对于写请求,重试可能造成重复副作用。可用性提升必须与幂等设计、超时预算和后端容量一起评估。


十二、从内核到应用的完整排查示例

假设用户报告“HTTPS 偶尔返回 504”,可以按以下顺序缩小范围:

第一步:确认入口连接

sudo ss -s
sudo ss -tn state established '( sport = :443 )'

如果 443 上连接数暴增,先确认是否为真实流量、长连接堆积或攻击;不要直接扩大 Nginx worker 数。

第二步:确认 TLS 是否成功

curl -vk --connect-timeout 5 https://app.example.com/api/

如果握手阶段就失败,504 还没有发生,问题在 TLS 或入口网络。

第三步:检查 access log 的时间分解

sudo tail -f /var/log/nginx/access.log

重点比较:

rt=...
uct=...
uht=...
urt=...
up_status=...

如果 uht 接近 proxy_read_timeout,说明后端迟迟没有返回响应头。

第四步:从 Nginx 主机直接访问后端

curl -v --connect-timeout 3 http://10.0.0.11:8080/api/
curl -v --connect-timeout 3 http://10.0.0.12:8080/api/

若只有一个节点慢,检查该节点应用、数据库连接池和系统资源;若所有节点同时慢,优先检查共享依赖、网络或流量突增。

第五步:观察内核和进程资源

vmstat 1
iostat -xz 1
free -m
ss -tan state syn-recv
ss -tan state established

这些命令分别帮助判断 CPU 调度、磁盘等待、内存压力、半连接堆积和已建立连接数量。它们不能单独证明根因,但能把“应用慢”和“网络入口拥塞”区分开。

第六步:修改前保留回滚路径

sudo cp -a /etc/nginx /etc/nginx.backup.$(date +%Y%m%d%H%M%S)
sudo nginx -t
sudo systemctl reload nginx

若新配置已经重载但行为异常,先回滚配置并重新测试,而不是连续叠加超时、重试和限流参数。每次只改变一个主要变量,才能建立因果关系。


十三、核心关系的总结

Nginx 反向代理可以拆成五个相互关联但不能混为一谈的层次:

  1. Linux socket 与进程模型决定连接如何进入 worker,以及系统能承受多少连接。
  2. TLS决定客户端或后端的身份认证、加密和证书链验证;两段 TLS 是两个独立会话。
  3. 负载均衡决定请求进入哪个 upstream,并受算法、节点状态、长连接和重试影响。
  4. 限流通过共享内存维护速率或并发状态,保护入口资源,但不等于用户配额或应用级并发控制。
  5. 日志把客户端、Nginx、upstream 和时间分解串联起来,是验证数据流和定位失败阶段的证据。

真正可靠的代理配置,不是参数越多越好,而是每个参数都对应一个明确的状态、一个可验证的假设和一个可恢复的失败路径:监听是否成功、TLS 是否完成、请求是否被限流、后端是否被选中、响应在哪个阶段变慢,以及日志是否足以重建这条链路。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。