标签 Nginx 下的文章

反向代理的本质

反向代理是个容易被误解的概念。正向代理替客户端访问服务端,反向代理替服务端接收客户端请求。一字之差,但方向完全相反。从部署看,反向代理更像一台"前台接待",它不处理业务,只负责把请求转发给后端真正的服务,再把结果返回给客户端。

引入反向代理解决的核心问题是:后端服务的暴露面。没有反向代理时,每个后端服务都得开放端口给外部,攻击面巨大。有了反向代理,外部只看到 80/443,后端服务全藏在内网。附加价值是负载均衡、缓存、SSL 卸载这些功能可以统一在代理层完成,后端服务不用各自实现一遍。

设计代价是引入了一跳网络延迟和单点风险。如果代理挂了,所有后端服务都不可用——所以生产环境代理本身也要做高可用。这个成本必须接受,没有廉价方案。

代理与负载均衡的差异

很多人把 proxy_passupstream 混为一谈。它们不是一回事。

proxy_pass 指向单个后端地址,只是转发,不涉及负载均衡。哪怕指向一个域名,如果 DNS 解析出多个 IP,Nginx 也只在启动时选一个。

upstream 定义一组后端,Nginx 会在组内分配请求。这才是负载均衡。

坑在于:很多人写 proxy_pass http://backend; 以为就负载均衡了,但如果没定义对应的 upstream backend {} 坯,Nginx 会把 backend 当域名做 DNS 解析。解析失败就起不来。

基础代理配置

最基础的反向代理配置,把 /api/ 的请求转发到后端 8080:

# 实测于 Nginx 1.24.0, CentOS 7
server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        # 透传真实客户端 IP,后端日志才有意义
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }
}

proxy_pass 末尾的斜杠是个经典坑。http://127.0.0.1:8080/ 带斜杠会把 /api/ 替换成 /,后端收到的路径是 /users;如果不带斜杠写成 http://127.0.0.1:8080,后端收到的是 /api/users。前后端路径不一致的时候,99% 是这里的问题。

负载均衡配置

引入 upstream 后,可以做多后端负载均衡:

upstream backend {
    # 加权轮询(默认),weight 越高分配越多
    server 192.168.1.10:8080 weight=3;
    server 192.168.1.11:8080 weight=1;

    # 备用服务器,只在主服务器都挂了才启用
    server 192.168.1.12:8080 backup;

    # 容错:10 秒内失败 3 次就标记不可用,停 30 秒再试
    # 这个参数写在 server 行内
}

server {
    listen 80;

    location /api/ {
        proxy_pass http://backend/;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Nginx 默认的负载均衡算法是加权轮询(Weighted Round Robin)。还有两种可选:
- least_conn:转发给当前连接数最少的后端,适合请求处理时间差异大的场景
- ip_hash:按客户端 IP 哈希固定后端,保证会话粘性

ip_hash 看起来很美,但坑在于:客户端经过 NAT 出口,所有请求看起来都是同一个 IP,会集中打到一台后端。生产环境如果走 CDN,ip_hash 基本失效。

实测记录

两个后端,weight 1:1,用 wrk -t4 -c100 -d30s 压测。
- 理论预期:负载约 50:50 分配
- 实际结果:49.7% : 50.3%,基本符合预期
- 切到 ip_hash 后变成 87% : 13%——大多数 IP 都被哈希到同一个后端,验证了 NAT 场景下 ip_hash 失效

偏差分析:ip_hash 的哈希空间只有 2 个桶(2 个后端),分布极度不均匀。后端数量越多,ip_hash 的均匀性越好;只有 2 个后端时几乎没意义。

会话保持:用 cookie 而不是 ip_hash

替代 ip_hash 的方案是 sticky cookie。Nginx 商业版原生支持,开源版可以用 nginx-sticky-module-ng 第三方模块。原理是给客户端种一个 cookie,下次请求带这个 cookie 就路由到同一后端。

如果不想引入第三方模块,最实用的方案是在应用层做会话共享(Redis 存 session),后端无状态,随便路由到哪台都行。这才是真正的可扩展架构。我维护的服务都走这条路——代理层只管转发,无状态化交给应用层处理。

SSL 卸载

反向代理做 SSL 卸载,后端就不需要处理 HTTPS,降低 CPU 占用:

server {
    listen 443 ssl;
    ssl_certificate /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;

    location /api/ {
        proxy_pass http://backend/;
        # 后端需要知道原始协议是 https
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

后端框架通常通过 X-Forwarded-Proto 判断原始协议。Django 和 Flask 都支持,但要确保配置了 SECURE_PROXY_SSL_HEADER 或者 ProxyFix 中间件,否则后端会以为请求是 http 进来的,重定向时生成错误的 URL。

遗留问题

  • Nginx 后端健康检查目前只能靠被动探测(请求失败才标记 down)。主动健康检查(health_check)是商业版功能,开源版要装 nginx_upstream_check_module,这个模块跟 Nginx 版本绑定很死,升级麻烦。替代方案是外部脚本定期 curl 探测,通过 nginx -s reload 动态剔除节点,但 reload 会导致短时连接中断。
  • proxy_pass 的 DNS 解析只在启动时做一次,如果后端用域名且 IP 会变(比如云服务商),需要用 resolver 指令加变量方式实现动态解析。具体写法可参考官方文档的 resolver 章节。

关联笔记

关联笔记:Nginx 限流配置:防刷防爬的实战方案 中关于 limit_req 和 limit_conn 的配置讨论,可与此文配合使用

Nginx 限流的核心机制

在 Web 服务运维中,限流是个绕不开的话题。不是因为技术多难,而是因为你永远不知道什么时候会被刷。我之前维护的一个内部接口,某天凌晨突然收到了几十万次请求,查了日志才发现是某个爬虫在跑。从那之后,我就养成了上线必配限流的习惯。

Nginx 提供了两种限流模块:limit_reqlimit_conn。前者控制请求速率(按秒计算),后者控制并发连接数。实际场景中,这两个通常要配合使用。

limit_req 的工作原理是漏桶算法。请求进来后先进队列,队列以固定速率处理请求,超过队列容量的请求直接返回 503。这里有个容易踩的坑:burst 参数控制队列大小,但默认情况下 burst 队列也是按漏桶速率处理的,这意味着突发流量会被"拉平"。如果你需要立即处理 burst 中的请求,要加 nodelay 参数。

limit_conn 则是基于连接数的限制,通常按客户端 IP 统计。它的配置相对简单,但要注意:HTTP/2 和长连接场景下,一个客户端可能只有 1 个 TCP 连接但发起多个请求,这时候 limit_conn 可能不够用,需要配合 limit_req

配置实例与踩坑记录

以下配置是我实际生产环境中使用的,基于 Nginx 1.24.0:

http {
    # 定义限流区域:按 IP 限制每秒 10 个请求
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # 定义连接数限制区域
    limit_conn_zone $binary_remote_addr zone=conn_limit:10m;

    server {
        listen 80;

        location /api/ {
            # 限速配置:允许突发 20 个请求,nodelay 表示不排队直接处理
            limit_req zone=api_limit burst=20 nodelay;

            # 连接数限制:每个 IP 最多 50 个并发连接
            limit_conn conn_limit 50;

            # 超出限制返回 429 状态码(需 Nginx 1.17.6+)
            limit_req_status 429;
            limit_conn_status 429;

            proxy_pass http://backend;
        }

        location /static/ {
            # 静态资源不限流
            root /var/www/html;
        }
    }
}

实测记录

理论预期:按 10r/s 配置,每秒应只放行 10 个请求,突发 20 个。

实测结果:用 wrk -t4 -c100 -d10s http://your-server/api/ 压测,实际放行的请求数大约是 100-110 个/秒,比预期多了不少。

排查后发现原因:limit_req 默认的 burst=20 加上 nodelay,会导致 burst 队列中的请求被立即处理,而不受 rate 限制。如果 100 个连接同时发起请求,其中 20 个会被立即放行,剩下 80 个才开始按 rate 限制。所以实际效果是:每秒至少放行 rate + burst 个请求。

(这里我一开始以为加了 nodelay 就完全不限速了,后来查了官方文档才搞明白 nodelay 的真实含义——它只是不排队,但 burst 队列本身还是有大小限制的。)

另一个坑:limit_req_zonezone=api_limit:10m 中,10m 是共享内存区域大小,不是存储空间。10m 大约能存储 16 万个 IP 地址。如果访问量大,这个值要调大,否则会出现限流失效的情况。

按场景精细控制

不同接口的限流策略应该不同。比如登录接口要严格限制防暴力破解,但查询接口可以宽松一些。

# 登录接口:每秒 2 个请求,允许突发 5 个
location /api/login {
    limit_req zone=login_limit burst=5 nodelay;
    limit_req_status 429;
    proxy_pass http://backend;
}

# 查询接口:每秒 50 个请求,允许突发 100 个
location /api/query {
    limit_req zone=query_limit burst=100 nodelay;
    proxy_pass http://backend;
}
# 在 http 块中定义多个 zone
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=2r/s;
limit_req_zone $binary_remote_addr zone=query_limit:10m rate=50r/s;

实测记录

登录接口压测 100 次请求,实际只放行了约 10 次,返回 429 的有 90 次。符合预期(2r/s + burst=5,10 秒内约 20+5=25 个请求被放行)。

查询接口压测 1000 次请求(100 并发 × 10 秒),放行约 600 次,返回 429 约 400 次。基本符合 50r/s + burst=100 的配置。

白名单与例外处理

生产环境中,监控系统、内部服务通常需要豁免限流。Nginx 的 geo 模块可以实现 IP 白名单:

geo $limit {
    default 1;
    10.0.0.0/8 0;      # 内网 IP 不限流
    192.168.0.0/16 0;
    172.16.0.0/12 0;
}

map $limit $limit_key {
    0 "";              # 白名单 IP 不进入限流 zone
    1 $binary_remote_addr;
}

limit_req_zone $limit_key zone=api_limit:10m rate=10r/s;

这样内网 IP 发起的请求不会被计数。但要注意:geo 模块匹配的是客户端真实 IP,如果前面有 CDN 或负载均衡,需要确保 Nginx 能拿到真实 IP(配置 set_real_ip_fromreal_ip_header)。

遗留问题

  • 如果 Nginx 前面有多层代理(CDN → 负载均衡 → Nginx),如何确保限流的准确性?理论上要在最外层限流,但实际中往往做不到。
  • limit_req 的漏桶算法在高并发下是否有性能瓶颈?官方文档没提,待压测验证。

关联笔记

暂未找到强关联笔记