共计 7780 个字符,预计需要花费 20 分钟才能阅读完成。
一、先看两种配置长什么样
1.1 方案 A:upstream 块(本方案采用)
# ─── 定义后端池 ───
upstream wordpress_backend {
server wordpress:80 max_fails=3 fail_timeout=10s;
keepalive 32;
}
# ─── 反向代理 ───
location / {
proxy_pass http://wordpress_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
# ...
}
1.2 方案 B:变量 + resolver(另一种写法)
# ─── 指定 DNS 服务器 ───
resolver 127.0.0.11 valid=10s ipv6=off;
resolver_timeout 5s;
# ─── 反向代理 ───
location / {
set $backend "wordpress";
proxy_pass http://$backend;
proxy_http_version 1.1;
# ...
}
表面上看,两者都是”把请求转发到 wordpress:80“,但 Nginx 内部的处理路径完全不同。
二、核心差异:什么时候知道”后端是谁”
这是理解一切差异的关键。
2.1 upstream:配置加载时就确定了后端
Nginx 启动 / reload
│
▼
解析 nginx.conf
│
├── 遇到 upstream wordpress_backend { server wordpress:80; }
│
▼
调用系统 DNS 解析 "wordpress" → 172.18.0.3
│
▼
将 IP 地址存入 ngx_http_upstream_t 结构体
│
▼
创建连接池(keepalive 32)
│
▼
配置加载完成,开始接受请求
│
▼
请求到达 → 直接从连接池取连接 → 转发
后端地址在”编译时”(配置加载阶段)就固定了。 运行期间不再做 DNS 查询。
2.2 变量 + resolver:每次请求时才解析后端
Nginx 启动 / reload
│
▼
解析 nginx.conf
│
├── 遇到 set $backend "wordpress"
│ → 只记录"有一个变量叫 $backend,值是字符串 'wordpress'"
│ → 不解析,不建立连接
│
▼
配置加载完成,开始接受请求
│
▼
请求到达
│
▼
进入 location / 处理
│
├── 对 $backend 求值 → "wordpress"
│
├── 发现是域名 → 查询 resolver 缓存
│ ├── 缓存命中且未过期 → 使用缓存的 IP
│ └── 缓存未命中或已过期 → 向 127.0.0.11 发起 DNS 查询
│
├── 拿到 IP → 新建 TCP 连接
│
├── 转发请求
│
├── 接收响应
│
└── 关闭 TCP 连接 ← 不复用!
后端地址在”运行时”(每个请求处理阶段)才确定。
三、逐项对比
3.1 DNS 解析
| 维度 | upstream | 变量 + resolver |
|---|---|---|
| 解析时机 | 启动/reload 时一次 | 每个请求(受 valid 缓存控制) |
| 解析频率 | 一次 | 每 valid 秒至少一次 |
| 解析失败 | Nginx 启动失败(快速暴露问题) | 返回 502(用户看到错误页) |
| 运行时开销 | 零 | 每次请求有缓存查找或 DNS 查询 |
# upstream 方式:启动时解析
upstream wordpress_backend {
server wordpress:80; # 启动时解析,失败则 nginx -t 报错
}
# 变量方式:运行时解析
resolver 127.0.0.11 valid=10s;
set $backend "wordpress";
proxy_pass http://$backend; # 每个请求都要查缓存或做 DNS 查询
valid=10s 意味着 DNS 结果缓存 10 秒。第 11 秒的请求会重新查询。即使缓存命中,也有缓存查找的开销。
3.2 连接复用(最关键的差异)
upstream 方式:
upstream wordpress_backend {
server wordpress:80;
keepalive 32; # ← 连接池:最多保持 32 个空闲长连接
}
请求处理流程:
请求 1 → 池中无空闲连接 → 新建连接 A → 处理 → 连接 A 归还池中
请求 2 → 池中有连接 A → 复用连接 A → 处理 → 连接 A 归还池中
请求 3 → 池中有连接 A → 复用连接 A → 处理 → 连接 A 归还池中
...
请求 100 → 复用连接 A → 处理 → 归还
100 个请求只用了 1 次 TCP 握手。
变量 + resolver 方式:
set $backend "wordpress";
proxy_pass http://$backend;
# 没有 keepalive,也无法配置
请求处理流程:
请求 1 → 新建连接 A → 处理 → 关闭连接 A
请求 2 → 新建连接 B → 处理 → 关闭连接 B
请求 3 → 新建连接 C → 处理 → 关闭连接 C
...
请求 100 → 新建连接 XX → 处理 → 关闭连接 XX
100 个请求 = 100 次 TCP 握手 + 100 次 TCP 挥手。
3.3 为什么变量方式无法 keepalive?
这不是”忘了实现”,而是架构上不可能。
Nginx 的连接池(keepalive)是这样工作的:
┌─────────────────────────────────────────────────┐
│ ngx_http_upstream_t │
│ │
│ peers: [ { addr: 172.18.0.3:80 } ] │
│ │
│ keepalive 连接池: │
│ ┌──────────────────────────────┐ │
│ │ 空闲连接列表 │ │
│ │ conn_1 (fd=14, idle=2s) │ │
│ │ conn_2 (fd=15, idle=5s) │ │
│ │ ... │ │
│ └──────────────────────────────┘ │
│ │
│ 索引键: 目标地址 (172.18.0.3:80) │
└─────────────────────────────────────────────────┘
连接池在配置加载阶段创建,按目标地址索引。请求完成后,连接归还到对应地址的池中。
当使用变量时:
请求到达 → $backend 求值 → "wordpress" → DNS 解析 → 172.18.0.3
│
▼
这个地址在配置加载时
根本不存在,没有对应的
连接池结构体
│
▼
只能新建连接
用完后无处归还
│
▼
关闭连接
变量方式走的是完全不同的代码路径:
| 代码路径 | 触发条件 | 函数入口 |
|---|---|---|
| upstream 路径 | proxy_pass http://upstream_name; |
ngx_http_upstream_init() → 连接池 |
| 变量路径 | proxy_pass http://$variable; |
ngx_http_proxy_pass_variable() → 每次新建 |
在 Nginx 源码中,ngx_http_upstream_keepalive_module 只挂载在 upstream 块的 postconfiguration 阶段。变量方式的请求根本不经过这个模块。
3.4 健康检查
| 维度 | upstream | 变量 + resolver |
|---|---|---|
| 被动健康检查 | ✅ max_fails=3 fail_timeout=10s |
❌ 无 |
| 故障摘除 | ✅ 连续失败 3 次后 10 秒内不再转发 | ❌ 每次都尝试 |
| 故障恢复 | ✅ 10 秒后自动重新尝试 | — |
# upstream:WordPress 容器短暂不可用时
upstream wordpress_backend {
server wordpress:80 max_fails=3 fail_timeout=10s;
# 连续 3 次失败 → 标记为不可用 → 10 秒内跳过 → 快速返回 502
# 而不是让每个请求都等待超时
}
# 变量方式:没有健康检查
# 每次请求都尝试连接 → 如果容器挂了 → 每个请求都等 connect_timeout 才报错
3.5 负载均衡
| 维度 | upstream | 变量 + resolver |
|---|---|---|
| 多后端 | ✅ 支持 | ❌ 不支持 |
| 负载均衡算法 | round-robin / least_conn / ip_hash | — |
| 权重 | ✅ weight=5 |
— |
# upstream 支持多后端
upstream wordpress_backend {
server wordpress_1:80 weight=3;
server wordpress_2:80 weight=1;
keepalive 32;
}
# 变量方式只能指向一个目标
set $backend "wordpress";
proxy_pass http://$backend;
四、性能量化对比
以本方案为例,假设:
- 容器内网延迟:~0.1ms
- TCP 三次握手:~0.3ms(容器内)
- DNS 查询(缓存未命中):~1-5ms
- WordPress 处理时间:~50ms
4.1 单个请求的延迟
| 阶段 | upstream | 变量 + resolver |
|---|---|---|
| DNS 解析 | 0(启动时已完成) | 0~5ms(缓存命中 0,未命中需查询) |
| TCP 握手 | 0(复用连接) | ~0.3ms |
| 请求处理 | 50ms | 50ms |
| TCP 关闭 | 0(连接保留) | ~0.1ms |
| 总延迟 | ~50ms |
单个请求差异不大。但看并发场景:
4.2 100 QPS 下的资源消耗
| 指标 | upstream (keepalive 32) | 变量 + resolver |
|---|---|---|
| TCP 握手次数/秒 | ~0(连接复用) | 100 |
| TCP 关闭次数/秒 | ~0 | 100 |
| 并发连接数 | ≤ 32 | 100(每个请求一个) |
| socket 创建/销毁 | ~0 | 200 次/秒 |
| 内核开销 | 极低 | 显著(fd 分配、TCP 状态机) |
| DNS 查询 | 0 | ~6 次/分钟(valid=10s) |
4.3 TIME_WAIT 堆积
变量方式下,每个请求关闭连接后,主动关闭方(Nginx)会进入 TIME_WAIT 状态(默认 60 秒):
100 QPS × 60 秒 = 6000 个 TIME_WAIT 连接
这些连接占用文件描述符和内核内存。在极端情况下:
# 检查 TIME_WAIT 数量
ss -s | grep TIME-WAIT
# 或
netstat -n | grep TIME_WAIT | wc -l
upstream + keepalive 方式下,连接不关闭,TIME_WAIT 几乎为零。
五、变量 + resolver 方式真正的适用场景
说了这么多缺点,那这种方式完全没有用武之地吗?不是。它有特定的适用场景:
| 场景 | 为什么用变量 + resolver |
|---|---|
| 后端地址是动态的 | 如根据请求参数、Header、Cookie 决定转发目标 |
| K8s / 容器编排中 Pod 频繁重建 | IP 随时变化,不能用启动时固定的地址 |
| 利用 DNS 做服务发现 | 后端通过 DNS 注册,TTL 控制更新频率 |
| 后端数量极多且动态变化 | 无法在 upstream 中静态列举 |
但在 Docker Compose 场景中:
- 容器名(
wordpress、dbweb)是固定的 - 容器重建后,Docker DNS 会更新解析结果,但容器名不变
- 后端数量少且确定(就 2 个)
- 不需要动态路由
所以 upstream 是更优选择。
六、如果非要用变量方式,配置长什么样?
为了对比,这里给出完整的变量方式配置:
http {
# DNS 解析器(Docker 内置)
resolver 127.0.0.11 valid=10s ipv6=off;
resolver_timeout 5s;
# 注意:没有 upstream 块
# 注意:没有 keepalive
# 注意:没有健康检查
server {
listen 443 ssl;
listen 443 quic reuseport;
http2 on;
server_name cnix.win;
# ... TLS、安全头等配置 ...
location / {
# 必须用变量,不能直接写域名
# 直接写 proxy_pass http://wordpress:80;
# 效果等同于 upstream(启动时解析),但没有连接池
set $backend "wordpress";
proxy_pass http://$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_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
}
注意一个常见误解:
# 这不是"变量方式",这是"直接域名方式"
proxy_pass http://wordpress:80;
# 这才是"变量方式"
set $backend "wordpress";
proxy_pass http://$backend;
直接写域名(proxy_pass http://wordpress:80)时,Nginx 在启动时解析一次,行为接近 upstream(但没有连接池和健康检查)。用变量后,才触发运行时解析。
七、三种方式的完整对比
实际上存在三种方式:
| 方式 | 配置写法 | DNS 时机 | 连接池 | 健康检查 |
|---|---|---|---|---|
| ① upstream 块 | proxy_pass http://upstream_name |
启动时 | ✅ | ✅ |
| ② 直接域名 | proxy_pass http://wordpress:80 |
启动时 | ❌ | ❌ |
| ③ 变量 + resolver | set $b "wordpress"; proxy_pass http://$b |
每次请求 | ❌ | ❌ |
方式 ② 是一个容易被忽略的中间态:启动时解析(和 ① 一样),但没有连接池(和 ③ 一样)。
本方案选择 ① 的理由:
① upstream:启动时解析 ✅ + 连接池 ✅ + 健康检查 ✅ + 负载均衡 ✅
② 直接域名:启动时解析 ✅ + 连接池 ❌ + 健康检查 ❌
③ 变量: 运行时解析 ❌ + 连接池 ❌ + 健康检查 ❌
八、验证方法
8.1 确认 upstream keepalive 生效
# 查看 Nginx 到 WordPress 的 TCP 连接
docker exec nginx ss -tnp | grep :80
# 如果 keepalive 生效,你会看到少量(≤32)ESTABLISHED 连接
# 如果每次请求都新建连接,你会看到大量连接或 TIME_WAIT
8.2 对比观察
# 终端 1:持续监控连接数
watch -n 1 "docker exec nginx ss -tn | grep :80 | wc -l"
# 终端 2:发送 100 个请求
for i in $(seq 1 100); do
curl -s -o /dev/null https://cnix.win/
done
# upstream + keepalive:连接数稳定在 1~5
# 变量方式:连接数飙升到 100,然后慢慢下降(TIME_WAIT)
九、为什么本方案里仍然写上了 resolver 指令
9.1 Nginx 中两套独立的 DNS 解析体系
体系一:系统解析器(给 upstream 用)
- 解析时机: 配置加载阶段(
nginx -s reload或启动时) - 解析方式: 系统调用
getaddrinfo() - DNS 来源: 容器内
/etc/resolv.conf(Docker 容器中通常已指向127.0.0.11) resolver指令是否参与: ❌ 不参与- 解析失败后果: Nginx 启动失败,
nginx -t报错
体系二:resolver 指令(给变量用)
- 解析时机: 每个请求处理阶段(运行时)
- 解析方式: Nginx 内置的异步 DNS 解析器
- DNS 来源:
resolver指令指定的地址 resolver指令是否参与: ✅ 这是它唯一的工作- 解析失败后果: 返回 502
对比表
| 维度 | 系统解析器(upstream) | resolver 指令(变量) |
|---|---|---|
| 触发场景 | upstream { server domain; } |
proxy_pass http://$var |
| 解析时机 | 启动 / reload | 每个请求 |
| DNS 来源 | /etc/resolv.conf |
resolver 指令 |
是否受 resolver 指令控制 |
❌ | ✅ |
| 解析频率 | 一次 | 每 valid 秒 |
| 失败后果 | 启动失败 | 运行时 502 |
在 Docker 容器中,/etc/resolv.conf 的内容通常是:
nameserver 127.0.0.11
所以 getaddrinfo("wordpress") 自然就能通过 Docker 内置 DNS 解析到容器 IP。
9.2 完整解析流程图
Nginx 启动
│
├── 解析 upstream 块
│ └── server wordpress:80
│ └── 调用 getaddrinfo("wordpress")
│ └── 读取 /etc/resolv.conf → 127.0.0.11
│ └── Docker DNS 返回 172.18.0.3
│ └── 存入 upstream 结构体 ✅
│
├── 解析 resolver 指令
│ └── 记录:运行时 DNS = 127.0.0.11,缓存 10s
│ └── 等待变量方式使用(本方案中未触发)
│
└── 开始接受请求
│
├── proxy_pass http://wordpress_backend
│ └── 直接使用启动时解析好的 IP
│ └── 不经过 resolver ✅
│
└── (假设)proxy_pass http://$variable
└── 运行时求值 → 查 resolver 缓存 → 可能触发 DNS
└── 这里才用到 resolver 指令
9.3 本方案中 resolver 指令到底有什么用?
严格来说,在纯 upstream 方案中,resolver 指令不是必需的。
保留它的理由:
| 理由 | 说明 |
|---|---|
| 防御性配置 | 如果将来某个 location 需要用变量方式(如动态路由),不需要临时加 |
| 某些第三方模块依赖 | 部分 Nginx 模块在运行时需要 resolver 做 DNS 查询 |
| 配置习惯 | 在 Docker 环境中声明 resolver 127.0.0.11 是常见惯例 |
| 无害 | 不使用变量时,这条指令不会产生任何额外开销 |
十、总结
┌─────────────────────────────────────────────────────────────────┐
│ 请求到达 Nginx │
│ │ │
│ ┌──────────┴──────────┐ │
│ ▼ ▼ │
│ proxy_pass http:// proxy_pass http://$var │
│ upstream_name │ │
│ │ ▼ │
│ │ 运行时求值变量 │
│ │ │ │
│ │ ▼ │
│ │ 查询 resolver 缓存 │
│ │ (可能触发 DNS) │
│ │ │ │
│ ▼ ▼ │
│ 从连接池取连接 新建 TCP 连接 │
│ (可能 0 次握手) (必须 3 次握手) │
│ │ │ │
│ ▼ ▼ │
│ 转发请求 转发请求 │
│ │ │ │
│ ▼ ▼ │
│ 接收响应 接收响应 │
│ │ │ │
│ ▼ ▼ │
│ 连接归还池中 关闭连接 │
│ (等待复用) (TIME_WAIT) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ upstream 路径 │ │ 变量路径 │ │
│ │ 编译时确定后端 │ │ 运行时确定后端 │ │
│ │ 连接池复用 │ │ 每次新建连接 │ │
│ │ 健康检查 │ │ 无健康检查 │ │
│ │ 负载均衡 │ │ 无负载均衡 │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
| 维度 | upstream | 变量 + resolver |
|---|---|---|
| DNS 解析 | 启动时一次 | 每次请求 |
| TCP 连接 | 复用(0 次握手) | 每次新建(3 次握手) |
| keepalive | ✅ | ❌(架构上不可能) |
| 健康检查 | ✅ | ❌ |
| 负载均衡 | ✅ | ❌ |
| TIME_WAIT | 极少 | 大量 |
| 配置复杂度 | 略高(多一个 upstream 块) | 略低 |
| 适用场景 | 后端固定、需要高性能 | 后端动态、需要运行时路由 |
一句话:
upstream是”先建好高速公路,车辆直接通行”;变量 + resolver 是”每辆车到了才临时修一条路,用完就拆”。对于 WordPress 博客这种后端固定的场景,没有理由选择后者。
本文内容在完整版教程 Docker WordPress 建站指南:从零到生产级部署 基础上进行讨论