多租户虚拟主机与配置生成

面向 SaaS 多租户场景的 NGINX 虚拟主机管理方案,覆盖通配域名与动态 server_name 路由、按租户签发与热更新证书、配置模板化生成与校验流水线、reload 风暴的成因与规避手段,以及租户隔离、限流配额与安全边界的实践要点。

当租户从几十个涨到几千个,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 必须合并与限频。这三点在租户数少时看不出价值,在租户数上千时决定系统能否稳定运行。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. NGINX 容器镜像精简与加固
  2. 从 Apache 与 Traefik 迁移到 NGINX
  3. njs 模块与 JavaScript 扩展