Nginx 破局:从反向代理到高并发网关的架构心智
1. 引言:为什么我们需要一道”护城河”?
假设你刚写完一个 Node.js 服务,监听在 127.0.0.1:3000。你兴奋地买了一台云服务器,SSH 上去 node server.js,然后用浏览器访问 http://你的公网IP:3000——通了。你觉得自己掌握了部署的全部奥秘。
三天后,你的服务器 CPU 飙到 100%,日志里塞满了来自全球各地的恶意扫描请求。你的 Node.js 进程——那个仅用于渲染 JSON 数据的单线程小家伙——正在徒手接全世界扔过来的板砖。
问题出在哪?你把一个应用服务器直接暴露在了公网上。这就像把保险柜放在马路边,只靠它自带的锁来防御一切。
在真实的生产环境里,你的业务服务器永远不应该直接面向公网。它们应该躲在一道”护城河”后面——这道护城河,在绝大多数现代 Web 架构中,就是 Nginx。
架构重点:正向代理 vs 反向代理——两句话厘清终身困惑
正向代理 (Forward Proxy):代理的是客户端。你公司内网的浏览器不能直接访问外网,所有请求先发给一台代理服务器,由它替你出门。客户端知道代理的存在,目标服务器不知道客户端的真实 IP——它只看到代理的 IP。典型应用:翻墙工具、企业上网行为管理。
反向代理 (Reverse Proxy):代理的是服务端。互联网上的用户以为自己访问的是
api.example.com,实际上这个域名指向的是 Nginx,Nginx 再把请求默默地转发给内网里的一台或多台业务服务器。客户端不知道后端服务器的存在(甚至不知道有多少台),后端服务器不知道真实客户端的 IP(除非 Nginx 主动告知)。典型应用:负载均衡、SSL 卸载、缓存加速、安全防护。一张图搞懂两者的拓扑差异:
正向代理: 你 (客户端) → 代理服务器 → 互联网上的任意目标服务器 反向代理: 互联网上的任意用户 → Nginx (反向代理) → 内网业务服务器集群
Nginx 在现代 Web 架构中的枢纽位置——完整流量拓扑图:
flowchart TB subgraph PUBLIC["🌐 互联网公网"] U1["真实用户<br/>Chrome/Safari/Firefox"] U2["移动 App<br/>iOS/Android"] U3["第三方 API 调用方<br/>(Webhook/集成)"] HACKER["恶意扫描器/爬虫<br/>/DDoS Botnet"] end subgraph DMZ["🔒 DMZ 区 / 边缘网关层"] DNS["DNS 解析<br/>api.example.com → 公网 IP"] NGINX["Nginx 反向代理集群<br/>(×2 ~ ×N 节点,Keepalived VIP)"] DNS --> NGINX end subgraph SECURE["🔐 Nginx 层安全能力"] SSL_UNLOAD["SSL/TLS 卸载<br/>证书在此终结"] LIMIT["限流 & 防刷<br/>limit_req / limit_conn"] CACHE["静态资源缓存<br/>proxy_cache / expires"] REWRITE["路由重写<br/>rewrite / return 301"] end subgraph BACKEND["🏗️ 内网业务服务器集群 (VPC / 私有网络)"] UPSTREAM["upstream 负载均衡池"] NODE1["Node.js API Server #1<br/>10.0.1.10:3000"] NODE2["Node.js API Server #2<br/>10.0.1.11:3000"] SPRING1["Spring Boot 微服务 #1<br/>10.0.1.20:8080"] SPRING2["Spring Boot 微服务 #2<br/>10.0.1.21:8080"] STATIC["静态资源 OSS/CDN<br/>(脱离 Nginx 直出)"] end subgraph MIDDLE["⚙️ 中间件 & 数据层"] REDIS["Redis 集群<br/>Session / 缓存"] MQ["RabbitMQ / Kafka<br/>异步任务"] DB["MySQL / PostgreSQL<br/>持久化存储"] end U1 -->|"HTTPS :443"| NGINX U2 -->|"HTTPS :443"| NGINX U3 -->|"HTTPS :443"| NGINX HACKER -.->|"恶意流量——> 被限流 / 被 WAF 规则拦截"| NGINX NGINX --> SSL_UNLOAD NGINX --> LIMIT NGINX --> CACHE NGINX --> REWRITE NGINX -->|"proxy_pass<br/>按 upstream 策略分发"| UPSTREAM UPSTREAM --> NODE1 UPSTREAM --> NODE2 UPSTREAM --> SPRING1 UPSTREAM --> SPRING2 NODE1 --> REDIS NODE1 --> MQ NODE1 --> DB SPRING1 --> REDIS SPRING1 --> MQ SPRING1 --> DB style PUBLIC fill:#e3f2fd style DMZ fill:#fff3e0 style SECURE fill:#fce4ec style BACKEND fill:#e8f5e9 style MIDDLE fill:#f3e5f5 style NGINX fill:#ff9800,color:#fff style HACKER fill:#f44336,color:#fff style UPSTREAM fill:#4caf50,color:#fff
看这张图,Nginx 所处的位置不是”可选的中间件”,而是整个系统面向公网的第一道,往往也是唯一一道关卡。在流量到达你的业务代码之前,它要完成 SSL 解密、请求合法性判断、路由分发、缓存命中检查。你的 Node.js 或 Spring Boot 服务根本不知道互联网上发生了什么——它们只看到 Nginx 递过来的、干干净净的、已经验证过的请求。
这就是 Nginx 在微服务架构中的真正定位:它不是”转发器”,而是网关层面的防御性基础设施。
2. 核心解密:Nginx 凭什么抗住百万并发?
你肯定听说过这个数字:Nginx 可以轻松处理 10 万并发连接,极端场景下百万级也不在话下。而传统的 Apache(prefork 模式)在几千并发时就开始跪。
这不是广告词,这是架构基因决定的。
2.1 Master-Worker 进程模型:先理解”谁在干活”
Nginx 启动后,你执行 ps aux | grep nginx,会看到类似这样的输出:
root 12345 nginx: master process /usr/sbin/nginx
www-data 12346 nginx: worker process
www-data 12347 nginx: worker process
www-data 12348 nginx: worker process
www-data 12349 nginx: worker process
一个 master 进程,若干个 worker 进程。分工极其明确:
┌──────────────────────────────────────────────────┐
│ Master 进程 (root 权限) │
│ │
│ 职责: │
│ · 读取并校验配置文件 │
│ · 创建 socket、绑定端口 (80/443) │
│ · 启动/终止/管理 Worker 进程 │
│ · 响应 nginx -s reload / stop 等管理信号 │
│ │
│ 不干什么: │
│ · 不处理任何用户请求——Master 永远不碰业务流量 │
└──────────┬───────────┬───────────┬────────────────┘
│ │ │
┌──────▼──┐ ┌─────▼───┐ ┌───▼──────┐
│ Worker 1│ │ Worker 2│ │ Worker N │
│ (实际处 │ │ (实际处 │ │ (实际处 │
│ 理请求) │ │ 理请求) │ │ 理请求) │
│ │ │ │ │ │
│ · Epoll │ │ · Epoll │ │ · Epoll │
│ · HTTP │ │ · HTTP │ │ · HTTP │
│ · 代理 │ │ · 代理 │ │ · 代理 │
└─────────┘ └─────────┘ └──────────┘
↑ 全部监听同一个端口 (SO_REUSEPORT)
架构重点:Worker 数量到底该设多少?
Nginx 官方建议
worker_processes auto;——让 Nginx 自动设为 CPU 核数。背后的逻辑是:每个 Worker 是单线程的,通过 epoll 在一个线程内轮询成千上万个连接。如果 Worker 数量超过 CPU 核数,多出来的 Worker 只是在争抢 CPU,反而会因为上下文切换降低吞吐。1 核 1 Worker,是 Nginx 进程模型的最优配置。唯一的例外是:当你的 Nginx 做了大量磁盘 I/O 操作(如
proxy_cache写磁盘)或需要处理大量阻塞型 SSL 握手时,可以适当把 Worker 数调高到 CPU 核数的 1.5~2 倍,但这也是权宜之计——更好的做法是把 SSL 卸载和缓存卸载到独立的硬件/层上去。
2.2 Epoll 事件驱动模型:为什么 Apache 做不到而 Nginx 可以?
Apache(prefork/worker 模式)的工作方式是:每来一个连接,fork 一个进程(或线程)去处理。 1000 个并发连接 = 1000 个进程/线程。每个进程都吃内存(默认 ~2MB/进程),每个线程的上下文切换都消耗 CPU。
Nginx 的做法完全不同——一个 Worker 线程,通过 epoll 同时监听数万个连接:
flowchart LR subgraph CLIENT["客户端连接"] C1["连接 1"] C2["连接 2"] C3["连接 3 (...)"] C100k["连接 100,000"] end subgraph KERNEL["Linux 内核"] EPOLL["epoll (事件通知机制)<br/>只返回有事件发生的 fd<br/>不是遍历所有连接!"] end subgraph WORKER["Nginx Worker (单线程)"] LOOP["Event Loop<br/>┌─────────────────────────┐<br/>│ epoll_wait() 阻塞等待事件 │<br/>│ 有事件 → 处理 → 回到等待 │<br/>│ 处理时间极短 (非阻塞 I/O) │<br/>└─────────────────────────┘"] end C1 -->|"数据到达"| EPOLL C2 -->|"数据到达"| EPOLL C100k -.->|"空闲连接——无事件,不返回"| EPOLL EPOLL -->|"就绪 fd 列表"| LOOP style EPOLL fill:#4caf50,color:#fff style LOOP fill:#ff9800,color:#fff
epoll 的核心优势一句话:它只通知那些”有事发生”的连接,而不是让你去遍历所有连接。
用伪代码对比两种模型:
❌ Apache prefork 模型 (简化为伪代码) —— 每连接一线程:
for each connection in accept_loop():
thread = create_thread()
thread.handle(connection) // 这个线程在这个连接上阻塞到请求结束
// 10000 连接 = 10000 线程 = 内核调度器在哭
✅ Nginx epoll 模型 (简化为伪代码) —— 一个线程处理所有连接:
epoll_fd = epoll_create()
for each socket:
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket, EPOLLIN) // 注册到 epoll
set_nonblocking(socket) // 关键:设置为非阻塞模式
while true:
events = epoll_wait(epoll_fd, timeout) // 阻塞直到有事件
for each ready_event in events: // 只遍历有事件的连接!
if event.is_readable(): handle_read(event.socket)
if event.is_writeable(): handle_write(event.socket)
// 10000 连接,但只有 50 个活跃 → epoll_wait 只返回这 50 个
// 99950 个空闲连接对 CPU 的开销 ≈ 零
开发者视角:Nginx Event Loop vs Node.js vs Netty——殊途同归的异步非阻塞
如果你写过 Node.js,你不会对上面的伪代码感到陌生。Node.js 的
libuv在 Linux 下也是基于 epoll 的,代码结构几乎一致:一个主线程,事件循环,非阻塞 I/O,回调。如果你用过 Java 的 Netty,它的
NioEventLoopGroup本质上也是在复刻 Nginx 的 Worker 模型——多个 EventLoop 线程,每个持有一个Selector(Java NIO 对 epoll/kqueue 的封装),注册 Channel,轮询就绪事件。这不是巧合。 Nginx(2004 年发布)是最早大规模实践”单线程事件驱动 + 非阻塞 I/O”的应用之一。Node.js(2009 年发布)的诞生深受 Nginx 影响——Ryan Dahl 在他的演讲中明确提到,Node.js 的 Event Loop 理念直接来自 Nginx 和 Python 的 Twisted 框架。而 Netty(2008 年起源于 JBoss),作为 Java 生态最重要的 NIO 框架,同样遵循了相同的模式。
理解了 Nginx 的 epoll 模型,你就理解了整个”异步非阻塞”范式的底层逻辑。这不是某个框架的专利,这是操作系统提供的能力——epoll(Linux)、kqueue(BSD/macOS)、IOCP(Windows)是内核级的 I/O 多路复用原语,所有上层框架只是它们的”翻译官”。
2.3 优雅热重启:nginx -s reload 的底层发生了什么?
nginx -s reload 可能是你敲过最多次的 Nginx 命令之一,但绝大多数人不知道”reload”和”restart”的区别——以及为什么 reload 可以做到流量零中断。
运维视角:为什么 reload 不会丢请求?
nginx -s reload的完整流程如下:
- Master 进程收到
SIGHUP信号。- Master 重新读取并校验配置文件。如果配置有语法错误,整个 reload 操作中止,现有 Worker 进程继续正常运行——这就是为什么 reload 比 restart 安全得多。restart 先停再启,配置错了服务就真挂了。
- 如果配置校验通过,Master 启动一批新的 Worker 进程——它们加载新配置,开始监听同一个端口(新配置如
listen 443 ssl;之类的变更会在这里生效)。- Master 向旧的 Worker 进程发送
SIGQUIT信号,通知它们”优雅关闭”。- 旧 Worker 在收到
SIGQUIT后,停止接受新连接,但继续处理当前已建立的连接——直到这些连接上的请求全部处理完毕(或超时),Worker 才退出。- 过渡期间,新旧两批 Worker 进程同时存在。内核通过
SO_REUSEPORT(Linux 3.9+)将新连接分发给新 Worker,旧 Worker 只处理存量连接。- 所有旧连接处理完毕后,旧 Worker 进程全部退出。reload 完成。
整个过程,没有一个 TCP 连接被强制断开。 这就是”优雅热重启”的真正含义。
生产痛点:
nginx -s reload不是万能的——长连接 (Keep-Alive) 引发的"幽灵 Worker"如果你的后端服务或客户端使用了 HTTP Keep-Alive 长连接,旧 Worker 可能会因为连接迟迟不关闭而长时间存留。默认的
worker_shutdown_timeout是没有限制的——这意味着一个挂起的 Keep-Alive 连接可以让一个旧 Worker 永远不死。修复方式:在配置中显式限制 worker 的优雅关闭超时时间:
# 旧 Worker 在收到 SIGQUIT 后最多存活 30 秒,超时后强制退出 worker_shutdown_timeout 30s;
3. 流量调度室:反向代理与负载均衡实战
3.1 反向代理 (proxy_pass):不只是”转发”
一个最简的、仅用于把请求转发给后端 Node.js 服务的 Nginx 配置看起来是这样的:
server {
listen 80;
server_name api.example.com; # 此 server 块只处理这个域名的请求
location / {
# 把匹配 / 的请求全部转发给本机 3000 端口上的 Node.js 进程
proxy_pass http://127.0.0.1:3000;
}
}这能跑。但它在生产环境里是一个残缺的配置。缺失的是最重要的东西——Header 透传。
当 Nginx 作为反向代理中转请求时,你的后端服务 127.0.0.1:3000 看到的连接是这样的:
连接来源 IP: 127.0.0.1 ← 这是 Nginx 的 IP,不是真实客户端!
请求 Host: 127.0.0.1:3000 ← 这也不是客户端请求的原始域名
协议: HTTP ← 即使客户端访问的是 HTTPS,到后端也是 HTTP(默认)
结果就是你后端的日志里,所有请求的来源 IP 全是 127.0.0.1。你的风控系统、分析系统、访问统计——全部报废。
生产痛点:拿不到真实用户 IP——反向代理的"身份丢失"问题
几乎所有第一次配 Nginx 反向代理的人都会踩的坑。 这不是 Nginx 的 bug,而是 HTTP 协议的固有设计:TCP 连接是端到端的,中间的代理节点会重新建立与后端的 TCP 连接,所以后端的
remote_addr永远是 Nginx 的 IP。解决方案是 通过 HTTP Header 显式传递原始请求信息——这是行业标准做法,不是 hack:
server {
listen 80;
server_name api.example.com;
location / {
# ============ 代理转发配置 ============
# 1. 目标地址——请求最终去哪
proxy_pass http://backend_upstream;
# ============ 关键 Header 透传——绝对不要把这块删掉! ============
# 2. Host: 将客户端请求的原始域名传给后端
# 如果你有多个域名指向同一个后端(如 api.example.com 和 admin.example.com),
# 后端需要靠这个头来区分业务逻辑
proxy_set_header Host $host;
# 3. X-Real-IP: 客户端的真实 IP 地址(单个 IP)
# $remote_addr 是 Nginx 变量,值为与 Nginx 建立 TCP 连接的 IP
# 对于直接访问 Nginx 的客户端,这就是真实 IP
proxy_set_header X-Real-IP $remote_addr;
# 4. X-Forwarded-For: 完整的代理链 IP 列表(可能有多个代理)
# $proxy_add_x_forwarded_for =
# 如果请求已有 X-Forwarded-For → 追加 ", $remote_addr"
# 如果请求没有 X-Forwarded-For → 直接设为 $remote_addr
# 这是多级代理场景下唯一的真实 IP 追踪手段
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 5. X-Forwarded-Proto: 客户端访问 Nginx 时使用的协议 (http / https)
# 后端需要知道客户端用的是 HTTPS 还是 HTTP——
# 比如生成绝对 URL、判断是否需要跳转到 HTTPS 等
proxy_set_header X-Forwarded-Proto $scheme;
# 6. X-Forwarded-Host: 客户端请求的原始 Host 头
proxy_set_header X-Forwarded-Host $host;
# 7. X-Forwarded-Port: 客户端访问的端口
proxy_set_header X-Forwarded-Port $server_port;
# ============ 连接与超时优化 ============
# 8. 使用 HTTP/1.1 与后端通信(支持 Keep-Alive 连接复用)
proxy_http_version 1.1;
proxy_set_header Connection ""; # 清空 Connection 头,让 Nginx 自己管理连接复用
# 9. 超时设置——至关重要,直接影响用户体验和系统稳定性
proxy_connect_timeout 5s; # 与后端建立连接的超时(不宜太长——连不上就快速失败)
proxy_send_timeout 30s; # 向后端发送请求的超时
proxy_read_timeout 60s; # 等待后端响应的超时(长耗时接口要调大这个值)
# 10. 缓冲设置——防止大响应撑爆 Nginx 内存
proxy_buffering on;
proxy_buffer_size 4k; # 响应头的缓冲区大小
proxy_buffers 8 16k; # 响应体的缓冲区:8 个 × 16KB = 128KB
proxy_busy_buffers_size 32k; # 在响应未完全读完时可以开始向客户端发送的最大缓冲
proxy_max_temp_file_size 256m;# 超大响应写入临时文件的最大体积
# 11. 后端错误时的重试机制
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
proxy_next_upstream_tries 2; # 最多重试 2 个不同的后端节点
proxy_next_upstream_timeout 10s; # 重试总时长上限
}
}最佳实践:如果你的 Nginx 和业务服务在同一台机器上(如 Docker 环境),
127.0.0.1就够了。但如果是跨主机部署,后端地址应该用内网 IP(如10.0.1.10),并且建议配置proxy_bind显式指定源 IP——避免在有多网卡的机器上走了错误的网络接口。
3.2 负载均衡 (upstream):从单点到集群的架构跃迁
当你只有一个后端节点时,proxy_pass http://127.0.0.1:3000; 就够了。但当你的业务增长到需要多台服务器时——事情变了。
upstream 块是 Nginx 的”服务发现”原语。它定义了一组后端服务器,然后通过 proxy_pass http://upstream_name; 引用。Nginx 根据你指定的调度策略决定每个请求发往哪台后端。
# =====================================
# upstream 定义:后端服务器集群
# =====================================
upstream backend_api {
# === 策略 1: 默认轮询 (Round Robin) ===
# 请求按顺序逐一分配给后端节点。
# 适用场景:所有后端服务器配置一致、无状态服务
server 10.0.1.10:3000;
server 10.0.1.11:3000;
server 10.0.1.12:3000;
# 注意:上面的写法就是默认轮询。它会严格按 server 的书写顺序
# 逐一分配——请求 1 → .10, 请求 2 → .11, 请求 3 → .12, 请求 4 → .10 ...
# === 策略 2: 权重轮询 (Weighted Round Robin) ===
# 高性能机器处理更多请求,低性能机器少处理。
# 适用场景:后端服务器硬件配置不同(如 8C16G vs 4C8G)
#
# server 10.0.1.10:3000 weight=5; # 这台性能好,承担 50% 流量 (5/10)
# server 10.0.1.11:3000 weight=3; # 承担 30% 流量
# server 10.0.1.12:3000 weight=2; # 承担 20% 流量
# 注意:权重不是绝对的并发数,而是"被选中的概率比例"
# === 策略 3: IP 哈希 (ip_hash) ===
# 根据客户端 IP 的哈希值,将同一个 IP 的请求始终发往同一台后端。
# 适用场景:后端服务有会话状态 (Session),且没有外部 Session 共享机制(如 Redis)
#
# ip_hash;
# server 10.0.1.10:3000;
# server 10.0.1.11:3000;
# server 10.0.1.12:3000;
#
# 注意:ip_hash 的致命缺点是——如果一台后端挂了,原本路由到它的所有用户
# 会被重新分配到其他节点,Session 全部丢失。生产环境强烈建议用 Redis
# 集中管理 Session + 轮询/权重策略,而不是依赖 ip_hash。
# === 策略 4: 最少连接 (least_conn) ===
# 把新请求分配给当前活跃连接数最少的后端服务器。
# 适用场景:请求处理时间不均匀(有些接口 50ms,有些要 5 秒)
# 轮询会把慢请求都堆到同一台机器上,least_conn 可以动态均衡
#
# least_conn;
# server 10.0.1.10:3000;
# server 10.0.1.11:3000;
# server 10.0.1.12:3000;
# === 健康检查 (Health Check) ===
# Nginx Plus (商业版) 有主动健康检查。
# 开源版只有被动健康检查——当请求到一个后端失败达到一定次数后,
# Nginx 会将它标记为 "down" 一段时间。
# max_fails: 在 fail_timeout 时间段内,最多允许的失败次数
# fail_timeout: 当失败次数达到 max_fails 后,将该服务器标记为不可用的时长
# backup: 备用服务器——只有在所有主服务器都不可用时才启用
# down: 手动将该服务器标记为下线(灰度下线时有用)
#
server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
server 10.0.1.12:3000 max_fails=3 fail_timeout=30s backup; # 备用节点
# server 10.0.1.13:3000 down; # 手动下线,维护/灰度时用
# === Keep-Alive 连接池 ===
# 保持与后端的空闲连接数。减少 TCP 握手开销,显著降低延迟。
keepalive 32;
# 每个 Worker 进程保持与 upstream 的 32 个空闲连接。
# 值不宜太大——一般设为 16~64 之间。太多了会浪费后端资源(尤其是 PHP-FPM
# 这类每连接消耗一个进程的场景)。
}
# =====================================
# server 块:使用上面定义的 upstream
# =====================================
server {
listen 80;
server_name api.example.com;
location / {
# 使用 upstream 定义的名称而不是直接写 IP:Port
proxy_pass http://backend_api;
# Header 透传(省略,同 3.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;
# 连接池复用需配合 HTTP/1.1
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}架构重点:为什么"轮询就够了"是有前提的
很多工程师说”默认轮询就行”,但在以下场景下轮询是灾难:
- 长连接 / WebSocket 场景:轮询会把新的长连接均匀分给所有后端,但一个 WebSocket 可能在某个后端上活跃数小时,其他请求却在别的后端上空转。此时
least_conn更好。- 后端处理能力不均:如果你在做蓝绿部署 / 金丝雀发布,新旧版本可能在同一个 upstream 里。旧版本慢但还在承接流量,轮询会让用户体验忽快忽慢。此时应该用权重 + 动态调整。
- 缓存热度问题:轮询会把请求均匀分发,但每个后端独立维护自己的本地缓存(如进程内 LRU),导致缓存命中率只有 1/N。此时
ip_hash或hash $request_uri可以通过”请求亲和性”提升缓存效率——但这个问题的正解是上 Redis 做集中式缓存,而不是用 Nginx 分配策略去补。
4. 守卫大门:HTTPS 加密与安全基线
4.1 SSL/TLS 卸载:为什么要在 Nginx 层做证书终结?
假设你有 10 台 Spring Boot 实例处理业务。如果你让每台实例都自己处理 HTTPS:
问题清单:
✗ 你需要购买/续期/部署 10 份证书(或用同一份证书在 10 台机器上配置)
✗ 每台机器都要做非对称加密的 TLS 握手——这是 CPU 密集型操作
✗ 后端代码(Java/Kotlin/Python)要处理 SSL Context、KeyStore、证书格式
✗ 更新证书 = 10 台机器逐一操作 / 或依赖配置管理工具额外编排
SSL/TLS 卸载 (SSL/TLS Termination) 的做法是把加密的外衣在 Nginx 层脱掉,内网明文传输:
客户端 ── HTTPS (加密) ──→ Nginx ── HTTP (明文) ──→ 内网后端集群
架构重点:内网 HTTP 明文传输安全吗?
在云原生的 VPC 私有网络内——安全。 原因是:
- VPC 网络在链路层就已经隔离——同一 VPC 之外的机器无法嗅探内网流量。
- 你的后端服务器没有公网 IP,外部攻击者根本访问不到它们。
- 即使同一 VPC 内有恶意的其他租户(共享物理机场景),现代云平台的虚拟化隔离也保证了网络流量的不可见性。
- 如果你的安全合规要求极端严格(如金融/医疗行业),可以在 Nginx 和后端之间也启用 TLS——这叫 SSL/TLS 桥接 (Re-encryption),Nginx 解密后用自己的证书重新加密再发给后端,性能开销加倍但安全等级最高。
4.2 完整 HTTPS 配置:从证书到强制跳转
# =====================================
# HTTP → HTTPS 强制跳转(80 端口唯一的作用)
# =====================================
server {
listen 80;
listen [::]:80; # IPv6 支持
server_name example.com www.example.com;
# 方法 1:301 永久重定向——推荐
# return 301 比 rewrite 效率高很多(rewrite 需要正则匹配,return 直接返回)
return 301 https://$host$request_uri;
# 方法 2:用 rewrite(不推荐,性能差)
# rewrite ^(.*)$ https://$host$1 permanent;
# 方法 3:如果只需要重定向特定路径
# location /admin {
# return 301 https://$host$request_uri;
# }
}
# =====================================
# HTTPS 主配置
# =====================================
server {
# HTTP/2 需要 TLS,所以 listen 指令要加 ssl 和 http2
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com www.example.com;
# ========== SSL 证书配置 ==========
# 证书与私钥——绝对路径,别用相对路径,排查问题时你会感谢自己
ssl_certificate /etc/nginx/ssl/example.com/fullchain.pem; # 证书链(证书 + 中间 CA)
ssl_certificate_key /etc/nginx/ssl/example.com/privkey.pem; # 私钥(权限 600!)
# ========== SSL 协议 & 加密套件——安全基线 ==========
# 只启用 TLSv1.2 和 TLSv1.3
# TLSv1.0 和 TLSv1.1 已被 PCI DSS 和主流浏览器弃用
# 不要贪兼容性——支持过旧的协议=给攻击者留后门
ssl_protocols TLSv1.2 TLSv1.3;
# 加密套件优先级:让服务器决定用哪个加密算法(而非客户端)
# 这样你可以强制使用更安全的套件
ssl_prefer_server_ciphers on;
# TLSv1.2 的加密套件列表——精选高强度算法
# 禁用 RC4、3DES、MD5、SHA1 等已被证明不安全的算法
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 会话缓存——显著降低 TLS 握手开销 ==========
# 共享内存缓存:10MB 可以存储约 40000 个 session
ssl_session_cache shared:SSL:10m;
# 客户端在 session_timeout 时间内重连可复用 TLS session(免完整握手)
ssl_session_timeout 1h;
# 开启 TLS session ticket(替代方案,有些场景下更高效)
ssl_session_tickets on;
# ========== 安全增强 Header ==========
# OCSP Stapling:Nginx 帮客户端验证证书吊销状态,减少客户端延迟
ssl_stapling on;
ssl_stapling_verify on;
# OCSP 应答缓存
ssl_stapling_cache shared:ocsp:128k;
# HSTS (HTTP Strict Transport Security):告诉浏览器"永远用 HTTPS 访问我"
# max-age=63072000 秒 = 2 年
# includeSubDomains: 子域名也强制 HTTPS
# preload: 允许浏览器将此域名硬编码进 HSTS 预加载列表
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# 其他安全 Header
add_header X-Content-Type-Options nosniff always; # 禁止浏览器 MIME 类型嗅探
add_header X-Frame-Options DENY always; # 禁止页面被嵌入 iframe(防点击劫持)
add_header X-XSS-Protection "1; mode=block" always; # 开启浏览器 XSS 过滤器
# ========== gzip 压缩——节省带宽 ==========
gzip on;
gzip_vary on;
gzip_min_length 1024; # 小于 1KB 的不压(压了反而更大)
gzip_comp_level 6; # 压缩级别 1~9(6 是性能与压缩率的平衡点)
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
# 注意:不要压缩图片(jpg/png/webp)——它们已经被编码器压缩过了
# ========== 业务路由 ==========
location / {
proxy_pass http://backend_api;
# Header 透传(省略,同 3.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;
}
# 静态资源——Nginx 直接返回,不经过后端
location /static/ {
alias /var/www/static/;
expires 30d; # 30 天浏览器缓存
add_header Cache-Control "public, immutable";
# immutable: 告诉浏览器"这个资源绝对不会变",跳过 304 验证请求
}
# Let's Encrypt 证书自动续期的验证目录
location /.well-known/acme-challenge/ {
alias /var/www/acme-challenge/;
}
}4.3 防刷限流 (limit_req):在网关层拦住恶意流量
如果你的业务代码里写了一个”每秒最多请求 10 次”的限流逻辑,那这个”限流”发生的时候——恶意请求已经穿越了 Nginx、走完了 TCP 握手、TLS 握手、HTTP 解析、代理转发、到达了你的业务代码、消耗了数据库连接池——然后才被你的 if 语句拦住。
生产痛点:业务层限流 vs 网关层限流的巨大成本差异
一个到达业务层的恶意请求消耗的是整个调用链的资源。极限情况下,一个简单的 CC 攻击可能每秒 10000 个请求——你的 Nginx 确实能扛住,但你的 Node.js/Java 进程被 10000 的并发打满了线程池/事件循环,正常的用户请求全在排队。
限流必须发生在系统的最外层。 Nginx 的
limit_req模块处理一个被限流的请求几乎不消耗 CPU——它连proxy_pass都不走,直接在 Nginx 层返回 503 或 429。被拦截的请求不会对你的后端产生任何影响。
# =====================================
# 限流配置:定义"漏桶"的容量和速率
# =====================================
# 必须在 http 块中定义 limit_req_zone
# 语法:limit_req_zone key zone=名称:大小 rate=速率;
# key: 以什么为标识限流($binary_remote_addr = 客户端 IP 的二进制形式,比 $remote_addr 省内存)
# zone=名称:大小:在共享内存中创建一个 zone,大小决定了能存多少个限流状态
# rate=速率:允许的请求速率,如 10r/s = 每秒 10 个请求
http {
# Zone 1: 全站级别限流——每个 IP 每秒最多 10 个请求
limit_req_zone $binary_remote_addr zone=per_ip:10m rate=10r/s;
# Zone 2: 登录接口——每个 IP 每分钟最多 5 次尝试(防暴力破解)
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
# Zone 3: API 接口级别——按 API Key 限流(而非 IP)
# 如果你的 API 使用 API Key 认证(通过 Header 传递),可以对每个 Key 限流
# limit_req_zone $http_x_api_key zone=api_key_limit:10m rate=100r/m;
server {
listen 443 ssl http2;
server_name api.example.com;
# 全站默认限流
location / {
# burst=20: 允许瞬间突发 20 个请求排队等待(而不是直接拒绝)
# nodelay: 突发请求立即处理——不加 nodelay 的话突发请求会排队限速处理
# 关键区别:
# 不加 nodelay:rate=10r/s, burst=20 → 前 10 个请求立即处理,后 20 个排队
# 按每秒 10 个的速度处理(总共要 3 秒处理完 30 个)
# 加 nodelay: rate=10r/s, burst=20 → 前 30 个请求全部立即处理
# 但之后要等 2 秒才能处理下一个
limit_req zone=per_ip burst=20 nodelay;
# 被限流时返回 429 Too Many Requests
limit_req_status 429;
proxy_pass http://backend_api;
# ... Header 透传省略
}
# 登录接口——更严格的限流
location /api/login {
limit_req zone=login_limit burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend_api;
# ... Header 透传省略
}
# 提供一个专门处理限流错误的自定义页面
error_page 429 /429.html;
location = /429.html {
internal; # 只允许内部重定向,外部无法直接访问
return 429 '{"error": "请求过于频繁,请稍后再试。","retry_after": 1}\n';
add_header Content-Type application/json;
add_header Retry-After 1;
}
}
}最佳实践:限流粒度选择——IP vs User vs API Key
- 按 IP 限流 (
$binary_remote_addr):最简单,适合防扫站、防爬虫。缺点是公司/学校共用出口 IP 的场景下会误伤正常用户。- 按用户限流 (
$http_authorization或$cookie_session):更精确,但需要依赖应用层的认证信息(JWT Token / Session Cookie),而且 Nginx 不会验证 Token 是否有效——攻击者可以用伪造 Token 绕过。- 按 API Key 限流:最精准,但前提是你的系统有 API Key 机制。对于对外开放的 API,这是标准做法。
- 组合限流:三层叠加——IP 层粗粒度(全站 10r/s)+ 认证用户中粒度(100r/m)+ 特定接口细粒度(登录 5r/m)。“纵深防御”不是口号,是每一层的实际配置。
危险操作:千万不要用
$remote_addr而不是$binary_remote_addr来定义limit_req_zone的 key
$remote_addr是字符串(IPv4 最长 15 字节,IPv6 最长 45 字节),$binary_remote_addr是固定 4 字节(IPv4)或 16 字节(IPv6)的二进制值。用$remote_addr会导致共享内存消耗增加 310 倍。对于 10MB 的 zone,用8 万。在流量高峰期 zone 被撑满后——新的 IP 完全无法记录限流状态,相当于限流功能直接失效。$binary_remote_addr可以存约 16 万个 IP 的状态,用$remote_addr可能只能存 4
5. 结语:网关层面的防御性思维
回到文章开头的那个场景——你把 Node.js 端口直接暴露在公网上,然后被扫得生活不能自理。
现在你应该理解了,这件事错不在 Node.js 性能不够、也不在攻击者太猖獗,而在于系统设计层面缺少了一层防御。这层防御的专业名称叫 API Gateway / Edge Proxy,而 Nginx 是这一层的工业标准实现。
让我们回顾一下 Nginx 在这篇文章里扮演的角色,它们分别构成了网关层防御体系的四个维度:
┌──────────────┐
│ 互联网流量 │
└──────┬───────┘
│
┌────────────▼────────────────────────────┐
│ Nginx 网关层 │
│ │
│ 1. SSL/TLS 卸载 —— 加解密在此终结 │
│ · 后端服务只处理明文 HTTP │
│ · 证书管理集中化 │
│ │
│ 2. 安全防护 —— 恶意流量在此被拦截 │
│ · limit_req 限流 → 防 CC │
│ · IP 黑白名单 / GeoIP → 地域封禁 │
│ · 安全 Header → HSTS / CSP / X-Frame │
│ │
│ 3. 流量调度 —— 决定请求去往何方 │
│ · 负载均衡 (轮询 / 权重 / IP哈希) │
│ · 健康检查 → 自动摘除故障节点 │
│ · 灰度 / 蓝绿 → 按 Header 路由 │
│ │
│ 4. 缓冲与减负 —— 替后端扛住流量冲击 │
│ · 静态资源缓存 → expires / CDN 回源 │
│ · gzip 压缩 → 节省带宽 │
│ · 连接复用 → Keep-Alive 减少握手 │
└─────────────────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ 内网业务服务器 │
│ Node.js · Spring Boot · Go · Python │
│ (只处理核心业务逻辑,不操心网络安全) │
└──────────────────────────────────────────┘
架构重点:Nginx 是微服务的第一道防线,也是最后一道
在云原生架构中,我们花了大量精力讨论 Service Mesh(Istio/Linkerd)、API Gateway(Kong/APISIX)、Ingress Controller 这些概念。但无论你的架构多复杂,无论你用的是 Kubernetes 还是传统部署,在你系统的某个节点上,一定有一个东西在监听 80/443 端口,接收互联网上的每一个 HTTP 请求。这个东西,在绝大多数场景下,就是 Nginx。
Service Mesh 管理的是东西向流量(服务之间),API Gateway 管理的是南北向流量(外部进入系统)。Nginx 既可以做南北向的 API Gateway(本文的主题),也可以做东西向的 Service Proxy(Nginx 的
stream模块可以做四层 TCP/UDP 代理,包括 MySQL/Redis 等非 HTTP 协议)。当你把 Nginx 从”一个需要背诵的配置语法”变成”一个你理解了架构原理后自然知道该怎么配的组件”时,你就完成了从”能写业务代码”到”能设计系统架构”的认知跃迁。
Nginx 不是黑盒,它是一台透明的、精致的、经过二十年战争检验的流量调度机器。 你需要做的不是记住所有指令的参数,而是理解它站在系统的什么位置、承担什么角色、在什么场景下做出什么取舍。
下一次,当你面对一个线上故障时——请求超时、502 错误、SSL 证书过期——你不会再 log 进后端服务器无头苍蝇般地翻应用日志。你会先 tail -f /var/log/nginx/error.log,因为你知道了,答案往往就在网关层的日志里,只不过以前你不知道去看。