☰
多租户系统域名化改造:从IP参数到二级域名的完整实践
2026/10/2 2:58:09 网站建设 项目流程

1. 为什么必须做这次改造:被IP参数折磨的现实问题

如果你维护过一个跑了几年的多租户系统,八成见过这种场景:客户反馈说后台地址一串数字加端口号实在记不住,运维说要给系统加HTTPS但一张证书只能绑一个IP,浏览器里打开A租户的页面顺手再开B租户的链接,页面直接串了数据。我之前那个系统,租户识别全靠URL里的IP参数,形如http://10.20.3.8:8080/login?tenant=acme,这套东西硬撑了两年多,直到客户数量到了几十家,问题集中爆发。于是我们启动了这次域名化改造:把"IP参数识别租户"整条链路替换成"二级域名识别租户",每个客户一个独立子域,比如acme.example.com、globex.example.com。这篇文章把整个改造过程拆开讲,包括选型思路、Nginx配置、后端改造、老链路兼容和回滚预案,算是一份可以直接照着落地的实操记录。

1.1 参数链接的三宗罪:难记、难配、难缓存

先说最直接的痛点。http://10.20.3.8:8080/login?tenant=acme这种链接,客户要发给合作伙伴的时候,对方在公司内网里根本打不开;机房一换IP一变,所有已经分发出去的链接全部失效,售后群瞬间变成客服现场。这还只是表面的麻烦,真正让我下定决心改的是下面三个技术问题。

第一个是缓存串号。浏览器判断一个页面能不能复用缓存的依据是完整origin(协议+域名+端口),参数不在隔离范围里。也就是说,?tenant=acme和?tenant=globex在浏览器眼里是同一个资源,localStorage、SessionStorage、HTTP缓存全部共享。我们线上出现过几次低概率但极其恶劣的串数据事故:A租户的运营人员登录后台,打开管理页面,看到的是B租户的列表数据——因为前端把列表缓存在了 localStorage,切换租户时脚本读到了同一个key。这种问题排查起来非常费劲,因为不是必现,只在特定浏览器和特定操作路径下才触发。

第二个是HTTPS证书。要给这套系统上加密,就得面对一个尴尬事实:证书是绑定域名的,不认IP参数。虽然技术上可以给IP申请证书,但每换一次IP、每加一台入口机器都要重新走一遍申请流程,成本高且容易漏。结果就是我们整个系统一直裸奔在HTTP上,登录密码、会话cookie都是明文传输,这在2024年的安全审计里基本是不过关的。

第三个是日志和审计的可读性。所有访问日志里租户ID只是一个query参数,每天几百万条日志混在一起,想按租户做限流、做安全分析、做访问量统计,都得先解析参数。而且tenant参数会出现在Referer头里,用户点了外链,租户ID就跟着泄漏给了第三方站点。

1.2 域名化改造到底改了什么

很多朋友一听"域名化改造",以为就是把入口地址从IP换成域名,后端逻辑不用动。这是最大的误解。这次改造的本质,是把租户身份从"业务参数"提升到了"网络层可识别信息"。

改造前,租户ID是应用层的一个参数,只有后端业务代码认识它;改造后,租户ID变成了DNS和Host头的一部分,从负载均衡、Nginx到后端服务,整条链路每一层都能直接识别租户。这个变化带来的好处是全方位的:

维度改造前改造后
租户标识位置URL query参数tenant=xxx二级域名acme.example.com
入口地址http://10.20.3.8:8080/login?tenant=acmehttps://acme.example.com/login
识别机制后端业务代码解析参数DNS解析 + Host头识别
HTTPS证书单IP单证,成本高易漏一张通配符证书覆盖所有租户
缓存与本地存储同origin共享,需业务层处理天然不同host,按域隔离
日志可读性需过滤解析参数从hostname直接看出租户
按租户运维只能靠参数二次加工可做独立限流、独立路由、独立部署

说白了,改造前每次想隔离点什么都要写代码;改造后很多隔离是基础设施白送的。

2. 域名方案的选型取舍:泛解析、通配符证书与代理层设计

2.1 泛解析不是"一条A记录"那么简单

域名化改造的第一步,是让所有租户子域都能解析到我们的入口。常规操作是在DNS管理后台加一条*.example.com的A记录,指向负载均衡的公网IP。这一步本身很简单,但有几个细节必须提前想清楚。

第一,泛解析只匹配一层。acme.example.com能命中,但login.acme.example.com是三级域名,需要单独配置解析记录。我们在设计租户体系的时候就定了一条规矩:所有租户只允许注册一个二级子域,不再往下拆三级,避免DNS配置走向失控。

第二,新租户上线后,要尽快补一条显式解析记录,不要长期只依赖泛解析。泛解析等于告诉全网"这个域名下任意子域都存在",安全扫描器可以遍历字典,找出你没想到的子域。配合Nginx的server_name匹配,如果某个子域正好撞上一个宽松的配置,就可能成为攻击入口。

第三,切换期间把TTL调低一点。默认的DNS TTL可能是600秒甚至3600秒,我们当时调成了300秒。原因很简单:改记录后如果域名解析不生效,客户那边访问还是老的,排查起来会非常痛苦。TTL短意味着全网节点能在5分钟内拿到新记录,虽然理论上会增加一点DNS查询量,对几十个租户的体量来说可以忽略不计。

内部测试环境没有DNS权限的时候,用/etc/hosts加几行映射就能模拟:

# /etc/hosts 10.20.3.8 acme.example.com globex.example.com

更严谨的做法是用curl --resolve,它可以在不改系统hosts的情况下,对单次请求指定域名解析结果:

curl --resolve acme.example.com:443:10.20.3.8 https://acme.example.com/healthz

2.2 通配符证书:单级通配与多级通配的坑

证书是整个改造里最容易踩坑的一环,也是很多人第一反应就问"证书怎么办"的地方。我们的方案是申请一张*.example.com的通配符证书,加上example.com主域本身,一张证书覆盖所有租户子域。

申请通配符证书,Let's Encrypt默认的HTTP-01验证方式是走不通的,因为验证服务器访问的是http://acme.example.com/.well-known/...,而你要验证的恰恰是所有未知子域。所以必须用DNS-01验证:在DNS里添加一条_acme-challenge.example.com的TXT记录。手动操作一次可以,自动续期就必须走DNS服务商的API。

我用的是acme.sh加DNS服务商的API插件,配置好之后续期全自动。这里有一个必须强调的坑:通配符证书只覆盖一层子域。*.example.com能覆盖acme.example.com,但绝对不覆盖login.acme.example.com。我们当时因为业务系统里确实存在几个三级子域的需求,临时给它们单独申请了证书,然后在Nginx里用多张证书配置不同的server块才解决。如果你判断业务未来会有三级域名需求,要么一开始就设计成每租户独立证书,要么确认证书签发渠道支持下级泛域名,否则后面会很被动。

续期脚本写完后,一定要加监控和告警。通配符证书过期不是单个租户出问题,而是所有租户同时无法访问,属于最高级别事故。我们是在监控系统里加了一个每天跑一次的脚本,检查证书剩余有效期,低于14天就告警。

2.3 Nginx上按子域名分流:正则server_name实战

域名解析和证书就绪之后,真正的核心工作在Nginx这一层。我们的入口架构是:DNS泛解析 → 负载均衡(LB)→ Nginx集群 → 后端应用。Nginx承担了租户识别的第一站,这也是我最推荐的方案——租户识别越靠前,后面每条业务链路越省事。

核心配置如下:

# 租户入口:所有 *.example.com 的请求先进这里 server { listen 443 ssl http2; server_name ~^(?<tenant>[a-z0-9][a-z0-9-]{1,62})\.example\.com$; ssl_certificate /etc/nginx/certs/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem; # 把从子域名提取的租户名传给后端 proxy_set_header X-Tenant-Name $tenant; proxy_set_header Host $host; proxy_pass http://backend_upstream; }

关键点在于server_name里用了命名正则捕获组(?<tenant>...),这样Nginx就能把子域名提取成一个变量$tenant,后续做路由、限流、灰度都直接基于这个变量。正则里的字符集[a-z0-9][a-z0-9-]{1,62}限制了租户名只能是小写字母、数字和连字符,且必须以字母数字开头——这跟DNS命名规则是绑定的,租户名里但凡出现下划线,这段正则就不会匹配,比后端再去校验省事得多。

还有一个必须配的兜底server,否则会被安全扫描扫出漏洞:

# 兜底:不是合法租户域名的请求,直接断开连接 server { listen 443 ssl default_server; ssl_certificate /etc/nginx/certs/example.com/fullchain.pem; ssl_certificate_key /etc/nginx/certs/example.com/privkey.pem; return 444; }

return 444是Nginx特有的,直接关闭连接不给任何响应。搭配一台Nginx版本在1.19.4以上的话,也可以用ssl_reject_handshake on;在TLS握手阶段就拒绝,连证书信息都不暴露,更彻底。

这里要提醒一下:proxy_set_header Host $host;很重要,它保证转发给后端时Host头保留的是原始域名,后端应用才能继续用Host头做租户识别。如果你图省事把Host设成固定的,后端就再也不知道用户访问的是哪个租户了。

3. 应用层改造的核心链路:从Host头解析到租户会话隔离

3.1 租户识别中间件:拿到子域名的第一件事

Nginx把X-Tenant-Name传给了后端,但后端不能只依赖这个头,因为理论上任何客户端都可以伪造请求头直接访问后端。我们采取的做法是:后端不信任代理层传的值,自己从Host头重新解析校验。两层都算一遍,结果一致才放行。

中间件的核心逻辑很简单:

# app/middleware/tenant.py import re from flask import request, g, abort SUBDOMAIN_RE = re.compile(r'^([a-z0-9][a-z0-9-]{1,62})\.example\.com$') def tenant_middleware(): host = request.host if ':' in host: host = host.split(':')[0] match = SUBDOMAIN_RE.match(host) if not match: # 不是合法租户入口,可能是管理端、监控探测或攻击扫描 abort(404) tenant_name = match.group(1) # 关键:白名单校验,不能拿到子域就直接信 tenant = get_tenant_from_cache(tenant_name) if not tenant or tenant.status != 'active': abort(404) g.tenant = tenant

拿到子域名之后的第一件事不是放行,而是白名单校验。泛解析的副作用是任何人都能拼接任意的whatever.example.com来打你的入口,如果后端不做校验,等于你把所有租户的"门牌号"都贴在了公网上。我们选择返回404而不是403,是为了不暴露租户是否存在——403等于告诉对方"这个地方有东西但你进不去",404则让对方以为这是个无效域名。

租户信息和校验结果一定要加缓存。我们用的Redis,key设计为tenant:info:{name},TTL五分钟。每次请求都去查一次数据库的话,几十个租户高峰期根本扛不住。

3.2 Cookie和Session在改域名之后的地盘之争

这是整个改造里坑最深的地方,单独拿出来说。改造前,应用跑在IP上,cookie是跟着这个origin走的,A租户和B租户的服务端会话天然隔离。改完域名之后,如果你图省事把cookie的domain设成了.example.com,那就闯了大祸。

.example.com域名的cookie会发给所有租户子域。假设A租户的用户在acme.example.com登录,拿到一个合法的session ID,cookie被设成对整个.example.com有效;然后他访问globex.example.com,浏览器会自动带上同一个cookie。如果后端没有额外校验,这就是一个严重的越权漏洞——跨租户会话复用。

正确的做法是:设置cookie时不带domain属性,让浏览器把cookie限制在发起设置的host上。大多数Web框架默认行为就是这样,真正出问题的往往是谁手欠在全局配置里写了cookie_domain=.example.com。我们当时自查代码,发现老代码里真有一行这样的配置,是当年为了做SSO单点登录加的,改域名后这行代码必须删掉。

另外,即使cookie隔离了,服务端的session存储也需要做租户前缀。我们用的是Redis存session,key从session:{sid}改成了session:{tenant_name}:{sid}。这么做是纵深防御:万一哪条链路漏了导致cookie串了,后端在加载session时发现key的前缀和当前访问的租户不一致,也能及时终止请求并强制重新登录。

# session 校验时加一道租户核对 session_data = redis.get(f"session:{g.tenant.name}:{session_id}") if not session_data: # session不存在或属于其他租户,销毁并跳登录 abort(401)

3.3 内部URL生成、跳转与跨域资源的一堆暗坑

域名化之后,系统中凡是涉及"生成链接"的地方都得重新过一遍。最典型的坑出现在邮件通知里。以前生成邮件里的链接可以写相对路径,因为用户是从系统内点开的;但邮件客户端没有上下文,收到的链接必须完整带协议和域名。我们当时写了一个统一的URL生成函数,所有业务方调用它来拼链接:

def build_tenant_url(tenant_name, path, query=None): url = f"https://{tenant_name}.example.com{path}" if query: url = url + '?' + '&'.join(f"{k}={v}" for k, v in query.items()) return url

这个函数上线后,我们全局搜索了一遍所有拼接URL的代码,愣是找出了十多个漏网的硬编码。这种事迟早会来,不如在一开始就把所有URL生成收敛到一个入口。

登录跳转是另一个大坑。改造前是login?tenant=acme,登录成功跳回租户首页;改造后登录页本身就在acme.example.com下,跳转目标必须严格限制在当前租户域名内。我们踩过一个Open Redirect的漏洞扫描报告:登录接口接受next参数,如果不校验next指向的域名,攻击者可以构造一个链接,用户登录后被带到钓鱼网站。修复方案是在跳转前校验next的host必须和当前租户子域一致。

前端资源这块也要提前想。我们的静态资源原来放在/static/下,同源加载,cookie会自动带上。改域名后如果还把静态资源放在主域下,那cookie还是会跟着静态资源请求走,虽然不影响功能,但浪费流量而且暴露会话信息。更稳妥的玩法是把静态资源挪到独立域名static.example.com,和租户业务域彻底分开,这样浏览器发静态资源请求时根本不会带上任何租户cookie。代价是跨域请求需要配CORS,我们在Nginx层统一配了:

location ~ ^/static/ { add_header Access-Control-Allow-Origin "https://static.example.com"; add_header Access-Control-Allow-Credentials "false"; }

如果前端框架用了webpack这类构建工具,注意publicPath不能写死成老的IP地址,否则打包出来的资源URL全是错的。

4. 老链路兼容与灰度切换:不能直接把IP参数砍掉

4.1 双轨运行期的兼容方案

改造完成不等于老链接立刻失效。几十个租户,几百个活跃用户,他们的浏览器收藏夹、公司内部文档、甚至打印出来的操作手册里都还写着老地址http://10.20.3.8:8080/login?tenant=acme。直接下线意味着客户骂娘,售后电话被打爆。

我们的方案是保留一段时间的双轨运行:后端兼容代码继续支持从query参数解析租户,但只对来源IP白名单内、且Host头不是合法租户域名的请求生效。也就是说,老IP地址仍然能打开系统,但只有从我们内部网络或者客户专线访问时才有效。公网进来的人肉扫描直接404,不给他们留任何入口。

老地址上的登录页,我们还临时加了一个横幅,内容是"访问地址已升级为 https://acme.example.com,请更新您的收藏夹"。这个横幅其实很管用,很多客户看到之后主动去更新文档,比一个个发通知效率高得多。

4.2 301跳转与参数透传细节

双轨运行只是一段时间内的妥协,最终目标是把所有老链接引导到新地址。我们加了跳转逻辑,把所有带tenant参数的请求301/302到对应的租户子域。

这里有一个细节:跳转之后的query参数要不要保留、要不要剔除tenant。我的建议是过滤掉tenant,保留其余参数。原因很简单,新地址的租户身份已经在域名里了,再保留一个?tenant=acme会造成双份标识,下游日志、埋点都要多处理一层,还容易产生"两处不一致"的隐患。

以Python后端为例,跳转逻辑长这样:

@app.route('/legacy/login') def legacy_login(): tenant = request.args.get('tenant') if not tenant: abort(400) other_params = {k: v for k, v in request.args.items() if k != 'tenant'} target = build_tenant_url(tenant, request.path) if other_params: target += '?' + '&'.join(f"{k}={v}" for k, v in other_params.items()) return redirect(target, code=302)

关于301还是302,我们踩过一个小教训。301是永久重定向,浏览器会长期缓存这个跳转结果;一旦你在灰度期间把某个老地址配错了,客户端的浏览器会一直记着那个错误的跳转地址,而且很难清除。所以我们灰度期间全部用302,等确认所有跳转都正确稳定,再统一切换成301。收藏夹和书签虽然慢一点,但在可接受范围内。

4.3 分批切换与回滚预案

整个切换过程我们分了三个阶段,每个阶段都有明确的退出条件:

阶段内容退出条件
A. 新链路可用DNS泛解析+通配符证书+Nginx分流+后端中间件上线,挑选2-3个配合度高的租户先切新链路连续观察3天无异常,耗时无劣化
B. 老地址跳转老入口对白名单IP外请求做302跳转,对白名单内IP保持直连跳转日志正常,无大量客户投诉
C. 移除老逻辑删除query参数解析租户的代码,老IP地址默认404双轨运行满一个月,老链路调用量低于阈值

回滚预案必须提前想清楚,而且要演练。我们当时的回滚方案是:如果新链路出现严重故障,Nginx配置回退到"不提取子域名、直接透传"的模式,后端兼容代码还在(阶段B/C之间我们有意保留了一个发布窗口的兼容逻辑),所以代码不用重新部署,只回切Nginx配置和DNS就能在十分钟内恢复老链路。这个冗余代码我们保留了整整两个版本迭代周期,确认无人使用后才删掉。

另外提醒一句,切换期前端资源缓存容易造成"新旧混合"的假故障。用户浏览器里还留着老域名的JS和CSS,访问新域名时可能拿到的是老代码。我们在阶段A给所有前端资源URL加上了版本号参数,强制刷新,这个细节帮我们避免了很多无效排查。

5. 改造后的收益复盘与教训清单

5.1 域名化带来的附加收益

改造完成后,很多之前要写代码解决的问题变成了配置问题。日志系统里,光看hostname就能知道是哪个租户的访问,安全审计和故障定界都轻松很多。CDN缓存天然按域名隔离,cache key里不用再费劲拼接租户ID。

最让我惊喜的是,按租户做精细运维变得可能了。以前想给某个用量异常的大租户单独限流,得在业务代码里写一堆判断;现在直接基于$tenant变量在Nginx层用map做分流,甚至可以给个别租户单独指定后端upstream:

map $tenant $backend_pool { default backend_shared; acme backend_acme_dedicated; globex backend_globex_dedicated; } server { listen 443 ssl http2; server_name ~^(?<tenant>[a-z0-9][a-z0-9-]{1,62})\.example\.com$; proxy_pass http://$backend_pool; }

这意味着未来某个大客户需要独立部署、独立资源池的时候,运维只需要加一行映射,业务代码一行不用动。这个架构弹性,在参数模式下是想都不敢想的。

5.2 教训清单:再来一次我会提前做的事

复盘整个项目,有一些决策如果重来一次,我会提前做,写在这里给大家做个参考。

  • 提前统一URL生成入口。我们花了大量时间全局搜索硬编码URL,因为老代码里各处都在拼字符串。如果在一开始就建立build_tenant_url这样的统一函数,改造期能省一半的排查工作量。
  • 提前把session key加租户前缀。这个改造其实和域名化无关,属于安全加固,但它大大降低了切换期"串号"事故的风险。如果能重来,我会在项目启动第一天就做,而不是等到切换前才补。
  • 测试环境提前用curl --resolve验证,不要等DNS泛解析生效才开始测。很多问题其实在测试阶段就能发现,比如Nginx正则写错、Cookie域名未清理,但因为没有提前模拟域名访问,全都拖到了灰度当天才暴露。
  • 通配符证书的自动续期一定要有独立监控。这张证书覆盖所有租户,它过期等于全站事故,重要性远超普通业务证书。
  • 不要把301和302混着用。灰度期统一用302,转正后统一切301,并且把切换动作写进发布流程里,省得有人临时起意改配置。
  • HTTP Host头攻击测试要上线前就做。用curl -H "Host: acme.example.com" http://负载均衡IP/直接打入口,确认不是合法租户时返回的是444或404,而不是正常页面。泛解析最大的安全风险就在这里,别等扫描器帮你发现问题。

这次改造前后花了三周,其中一半时间花在排查存量代码和验证边界条件上。最大的体会是:租户身份放在哪一层,决定了这个系统未来能省多少事。把租户识别下沉到网络层之后,缓存隔离、日志审计、证书管理、按租户运维全都变成了基础设施层面的能力,业务代码反而变干净了。这套方案不复杂,但每一步都需要耐心把细节磨到位,尤其是Cookie隔离和灰度回滚这两块,值得多花时间反复验证。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询