当租户从几十个涨到几千个,server 块的数量会成为运维的核心矛盾:手写不可能,全量生成又会让 reload 变成灾难。多租户虚拟主机的关键不在于「怎么写出一个 server 块」,而在于「怎么让几千个 server 块在频繁变更下依然稳定」。本文围绕配置生成、证书管理、reload 策略三条主线展开。
1. 多租户的三条技术路线
在写任何配置之前,先选路线。三种路线的运维成本差异巨大。
| 路线 | 实现方式 | 适用规模 | 主要代价 |
|---|---|---|---|
| 静态多 server | 每租户一个 server 块,配置生成 | 数百个租户 | reload 成本随租户数线性增长 |
| 通配 server + 变量路由 | 一个 server 用 $host 分发 | 数千以上 | 无法用 server_name 做 TLS 精确匹配 |
| 动态模块(OpenResty/njs) | Lua 查库动态决策 | 数万以上 | 引入脚本复杂度与依赖 |
推荐路径:先做静态多 server,到 500 左右租户时评估迁到通配方案,到数千时再考虑动态模块。不要一上来就上动态方案,那会把「配置问题」变成「代码问题」。
1.1 静态多 server 的规模上限
每个 server 块在 reload 时都要重新解析、编译、建立哈希表。实测数据:单机 2000 个 server 块(每块含 TLS 配置),nginx -s reload 需要 1.5~3 秒;到 5000 个时超过 8 秒。更麻烦的是 reload 期间新旧 worker 并存,内存峰值接近翻倍。
因此静态方案的实际上限在数百到一千之间,超过就该考虑通配方案。
2. 通配与动态 server_name
通配方案用一个 server 块承接所有租户域名,用变量决定后端。
server {
listen 443 ssl;
http2 on;
# 通配匹配任意子域
server_name ~^(?<tenant>[a-z0-9-]+)\.app\.example\.com$;
ssl_certificate /etc/nginx/certs/wildcard-app.crt;
ssl_certificate_key /etc/nginx/certs/wildcard-app.key;
# 用捕获的租户名做路由
set $backend "tenant-${tenant}-svc.default.svc.cluster.local:8080";
location / {
proxy_pass http://$backend;
proxy_set_header Host $host;
proxy_set_header X-Tenant $tenant;
}
}
要点:
- 正则
server_name性能低于精确匹配,但只写一条时开销可以忽略;只有几十条正则时才需要关注。 - 命名捕获
(?<tenant>...)是 NGINX 1.11.8+ 的特性,捕获结果自动成为变量,比$1更可读。 proxy_pass使用变量时不会自动解析 DNS,需要用resolver指令显式指定 DNS 服务器,并且此时 upstream 不参与keepalive连接池管理。
resolver kube-dns.kube-system.svc.cluster.local valid=10s;
resolver_timeout 5s;
这是通配方案最大的坑:proxy_pass http://$backend 里带变量时,NGINX 在每次请求都做一次 DNS 查询(受 valid 缓存)。若不配 resolver,启动时会报 no resolver defined to resolve ...,服务直接不可用。
2.1 通配方案的证书困境
通配证书只能覆盖一级子域,且无法为每个租户单独配置。这带来两个限制:
*.app.example.com通配证书不覆盖tenant.sub.app.example.com(二级子域)。- 自定义域(租户绑定自己的域名)完全无法用通配证书覆盖。
因此通配方案通常要配合 SNI 动态证书。NGINX 原生不支持「按 SNI 从数据库加载证书」,需要 ssl_certificate_by_lua(OpenResty)或把证书预先落盘再用变量引用(NGINX 1.15.9+ 支持变量形式的 ssl_certificate)。
# NGINX 1.15.9+:证书路径可用变量
server {
listen 443 ssl;
server_name ~^(?<tenant>[a-z0-9-]+)\.example\.com$;
ssl_certificate /etc/nginx/certs/$tenant/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/$tenant/privkey.pem;
ssl_certificate_cache max=1000 inactive=1h; # 证书缓存,避免每次握手读盘
}
ssl_certificate_cache(NGINX 1.27.4+ / 1.26.2+)非常关键:没有它,每次 TLS 握手都会读取两个 PEM 文件并解析私钥,握手延迟会显著上升。老版本没有这个指令时,只能靠 open_file_cache 间接缓解。
3. 证书按租户签发
证书管理是多租户方案里最容易出安全事故的部分。目标有三:自动签发、自动续期、热更新不 reload。
3.1 签发策略
| 域名形态 | 证书方案 | 说明 |
|---|---|---|
tenant.app.example.com | 通配证书 *.app.example.com | 一张证书覆盖所有租户,最省事 |
tenant.example.com | 通配证书 *.example.com | 需要泛域名 DNS 校验 |
| 租户自定义域 | 单域名证书,ACME HTTP-01 或 DNS-01 | 每个租户独立申请与续期 |
通配证书用 DNS-01 校验签发,因为 HTTP-01 无法验证泛域名。租户自定义域用 HTTP-01 即可,但需要保证 /.well-known/acme-challenge/ 能被 ACME 服务器访问到。
# ACME HTTP-01 校验路径必须优先于业务路由
server {
listen 80;
server_name ~^(?<tenant>[a-z0-9-]+)\.example\.com$;
location ^~ /.well-known/acme-challenge/ {
root /var/www/acme;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
^~ 前缀确保挑战路径不会被正则 location 抢走。ACME 自动化的完整方案(客户端选型、续期钩子、失败告警)参见 Nginx 证书自动化与 ACME
。
3.2 热更新证书而不 reload
这是多租户场景的核心需求:某租户续期了证书,不能因此 reload 整个 NGINX(会影响其他所有租户的连接)。
方法一:变量路径 + 文件替换
# 证书续期后直接覆盖文件(原子替换)
cat new_fullchain.pem > /etc/nginx/certs/acme/fullchain.pem.tmp
mv /etc/nginx/certs/acme/fullchain.pem.tmp /etc/nginx/certs/acme/fullchain.pem
NGINX 在下次握手时会重新读取(受 ssl_certificate_cache 的 inactive 与 max 控制)。mv 是原子操作,避免读到写了一半的文件——不要用 cp 覆盖,那会有短暂的半文件窗口。
方法二:控制 API 动态更新(NGINX Plus 或 Unit)
开源 NGINX 没有证书热更新的 API,只能靠文件替换 + 缓存过期。若必须精确控制,可用 openssl 计算证书指纹并作为路径的一部分,续期后改路径变量,但这本质上还是一次配置变更。
# 用指纹做路径,续期即换路径,配合 map 决定当前路径
fingerprint=$(openssl x509 -in cert.pem -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':')
3.3 证书与私钥的权限隔离
多租户意味着多份私钥。若所有租户共用一个目录、统一权限,一旦某个租户的服务被攻破,所有私钥都可能泄露。
# 每租户独立目录,权限 700,属主为 nginx worker 用户
install -d -m 700 -o www-data -g www-data /etc/nginx/certs/$tenant
install -m 600 -o www-data -g www-data key.pem /etc/nginx/certs/$tenant/
更严格的场景(金融、医疗)应当考虑每租户独立 NGINX 实例或独立节点,用物理隔离替代配置隔离。mTLS 与双向证书校验的进阶配置参见 Nginx SSL 与 mTLS 进阶 。
4. 配置模板与校验流水线
手工维护几千个 server 块不可能,必须模板化生成。
4.1 模板设计
用 Go template、Jinja2 或 Helm 都行,关键是模板要可测试。
{# templates/tenant.conf.j2 #}
server {
listen 443 ssl;
http2 on;
server_name {{ tenant.domain }};
ssl_certificate {{ tenant.cert_path }};
ssl_certificate_key {{ tenant.key_path }};
ssl_protocols TLSv1.2 TLSv1.3;
{% if tenant.rate_limit %}
limit_req zone=tenant_{{ tenant.id }} burst={{ tenant.burst }} nodelay;
{% endif %}
location / {
proxy_pass http://{{ tenant.upstream }};
proxy_set_header Host $host;
proxy_set_header X-Tenant-Id "{{ tenant.id }}";
proxy_read_timeout {{ tenant.timeout | default(60) }}s;
}
access_log /var/log/nginx/{{ tenant.id }}.access.log main;
}
模板里的每个租户字段都来自数据库或配置中心,必须经过白名单校验。租户名若直接拼进配置,就可能注入 ;、} 等字符破坏配置结构,这是多租户配置生成最严重的安全风险。
import re
TENANT_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$')
def validate_tenant(t: dict) -> None:
if not TENANT_RE.match(t['name']):
raise ValueError(f"invalid tenant name: {t['name']}")
if not t['domain'].endswith('.example.com') and not t.get('custom_domain'):
raise ValueError("domain not allowed")
if not (1 <= t.get('burst', 1) <= 10000):
raise ValueError("burst out of range")
4.2 校验流水线
生成的配置在 reload 之前必须过三道关:
#!/usr/bin/env bash
set -euo pipefail
CONF_DIR=/etc/nginx/conf.d/tenants
# 1. 生成到临时目录,不直接覆盖线上
./generate-config --out "$CONF_DIR.new"
# 2. 语法校验(用临时配置,不影响线上)
nginx -t -c /etc/nginx/nginx.conf -g "include $CONF_DIR.new/*.conf;"
# 3. 语义校验:检查上游可达性、证书文件存在且未过期
./validate-config --dir "$CONF_DIR.new"
# 4. 原子切换
mv "$CONF_DIR" "$CONF_DIR.old"
mv "$CONF_DIR.new" "$CONF_DIR"
# 5. reload,失败则回滚
if ! nginx -s reload; then
mv "$CONF_DIR.old" "$CONF_DIR"
nginx -s reload
exit 1
fi
步骤 1 的「先写临时目录」是关键:直接写 conf.d/ 会让 nginx -t 与真实 reload 之间存在竞态,也可能在生成失败时留下半成品配置。CI 化的完整校验流程(含单元测试、lint、回归)参见 Nginx 配置测试与 CI
。
5. reload 风暴的成因与规避
这是多租户场景最具破坏性的问题。
5.1 什么是 reload 风暴
当租户配置频繁变更(新租户注册、证书续期、上游扩缩容),如果每次变更都触发一次 nginx -s reload,就会出现:
- reload 尚未完成(新旧 worker 并存),下一次 reload 又到来;
- 老 worker 因为还有长连接(WebSocket、SSE)无法退出,累积占用内存;
- 最终内存耗尽,OOM Killer 杀掉 worker,全站 5xx。
5.2 规避手段
手段一:合并变更(debounce)
把一段时间内的变更攒起来,一次性生成并 reload。
import time, threading
class ReloadScheduler:
def __init__(self, interval=10):
self.interval = interval
self._timer = None
self._lock = threading.Lock()
def schedule(self):
with self._lock:
if self._timer is not None:
self._timer.cancel()
self._timer = threading.Timer(self.interval, self._do_reload)
self._timer.start()
def _do_reload(self):
with self._lock:
self._timer = None
subprocess.run(["nginx", "-s", "reload"], check=True)
10 秒的合并窗口能把「100 次变更」压缩成「1 次 reload」,成本几乎为零,收益巨大。
手段二:分层配置,缩小 reload 影响面
把稳定的全局配置(http 块、日志格式、限流 zone)与易变的租户配置分开,租户配置用 include 引入独立目录。这样 reload 时虽然仍会重建全部 worker,但至少可以做到「只重新生成租户目录」。
手段三:给老 worker 设置退出上限
worker_shutdown_timeout 30s;
这强制 NGINX 在 30 秒后关闭仍有连接的 worker,避免长连接把老 worker 永久拖住。代价是长连接会被断开,需要客户端有重连逻辑。零停机 reload 的完整机制(信号处理、连接排空、优雅退出)参见 Nginx 零停机 reload 。
手段四:给 reload 加锁与限频
#!/usr/bin/env bash
# 用 flock 保证同一时刻只有一次 reload
exec 9>/var/lock/nginx-reload.lock
flock -n 9 || { echo "reload in progress, skipping"; exit 0; }
nginx -s reload
5.3 监控指标
reload 风暴必须可观测,否则只能在出事时才发现。关键指标:
nginx_worker_process_count:worker 数量异常增长说明老 worker 未退出。nginx_reload_total:reload 频率,超过每分钟一次就该告警。- 老 worker 存活时长(通过
ps -o etimes观察)。 - 内存 RSS 与连接数随时间的变化。
6. 租户隔离与配额
配置生成解决了「路由到哪个后端」,但没解决「一个租户不能拖垮其他人」。
http {
# 每租户一个限流 zone
limit_req_zone $tenant_key zone=tenant_default:10m rate=100r/s;
limit_conn_zone $tenant_key zone=tenant_conn:10m;
map $host $tenant_key {
default $host;
~^(?<t>[a-z0-9-]+)\.example\.com$ $t;
}
server {
listen 443 ssl;
server_name ~^(?<tenant>[a-z0-9-]+)\.example\.com$;
limit_req zone=tenant_default burst=200 nodelay;
limit_conn zone=tenant_conn 50;
location / {
proxy_pass http://$backend;
}
}
}
局限在于:limit_req_zone 的 key 用变量,但所有租户共享同一个 zone,无法给不同租户设不同速率。要给 VIP 租户更高配额,只能建多个 zone 并在生成配置时按租户等级选择,这又回到静态多 server 的方案。
因此常见做法是混合:普通租户走通配 server 共享配额,VIP 租户单独生成 server 块拥有独立 zone 与独立日志。这样既控制了 server 块总数,又满足了差异化的 SLA。
7. 总结
多租户虚拟主机的演进路径是「静态多 server → 通配 server + 变量路由 → 动态模块」,每进一步都换来更大规模,也换来更高复杂度。选路线的依据不是技术先进性,而是当前租户数量与变更频率。
三个必须提前设计好的点:证书热更新不能依赖 reload、配置生成必须做输入白名单校验、reload 必须合并与限频。这三点在租户数少时看不出价值,在租户数上千时决定系统能否稳定运行。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。