多租户系统域名化改造:从IP参数到二级域名的完整实践
1. 为什么必须做这次改造被IP参数折磨的现实问题如果你维护过一个跑了几年的多租户系统八成见过这种场景客户反馈说后台地址一串数字加端口号实在记不住运维说要给系统加HTTPS但一张证书只能绑一个IP浏览器里打开A租户的页面顺手再开B租户的链接页面直接串了数据。我之前那个系统租户识别全靠URL里的IP参数形如http://10.20.3.8:8080/login?tenantacme这套东西硬撑了两年多直到客户数量到了几十家问题集中爆发。于是我们启动了这次域名化改造把IP参数识别租户整条链路替换成二级域名识别租户每个客户一个独立子域比如acme.example.com、globex.example.com。这篇文章把整个改造过程拆开讲包括选型思路、Nginx配置、后端改造、老链路兼容和回滚预案算是一份可以直接照着落地的实操记录。1.1 参数链接的三宗罪难记、难配、难缓存先说最直接的痛点。http://10.20.3.8:8080/login?tenantacme这种链接客户要发给合作伙伴的时候对方在公司内网里根本打不开机房一换IP一变所有已经分发出去的链接全部失效售后群瞬间变成客服现场。这还只是表面的麻烦真正让我下定决心改的是下面三个技术问题。第一个是缓存串号。浏览器判断一个页面能不能复用缓存的依据是完整origin协议域名端口参数不在隔离范围里。也就是说?tenantacme和?tenantglobex在浏览器眼里是同一个资源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参数tenantxxx二级域名acme.example.com入口地址http://10.20.3.8:8080/login?tenantacmehttps://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/healthz2.2 通配符证书单级通配与多级通配的坑证书是整个改造里最容易踩坑的一环也是很多人第一反应就问证书怎么办的地方。我们的方案是申请一张*.example.com的通配符证书加上example.com主域本身一张证书覆盖所有租户子域。申请通配符证书Lets 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则让对方以为这是个无效域名。租户信息和校验结果一定要加缓存。我们用的Rediskey设计为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 IDcookie被设成对整个.example.com有效然后他访问globex.example.com浏览器会自动带上同一个cookie。如果后端没有额外校验这就是一个严重的越权漏洞——跨租户会话复用。正确的做法是设置cookie时不带domain属性让浏览器把cookie限制在发起设置的host上。大多数Web框架默认行为就是这样真正出问题的往往是谁手欠在全局配置里写了cookie_domain.example.com。我们当时自查代码发现老代码里真有一行这样的配置是当年为了做SSO单点登录加的改域名后这行代码必须删掉。另外即使cookie隔离了服务端的session存储也需要做租户前缀。我们用的是Redis存sessionkey从session:{sid}改成了session:{tenant_name}:{sid}。这么做是纵深防御万一哪条链路漏了导致cookie串了后端在加载session时发现key的前缀和当前访问的租户不一致也能及时终止请求并强制重新登录。# session 校验时加一道租户核对 session_data redis.get(fsession:{g.tenant.name}:{session_id}) if not session_data: # session不存在或属于其他租户销毁并跳登录 abort(401)3.3 内部URL生成、跳转与跨域资源的一堆暗坑域名化之后系统中凡是涉及生成链接的地方都得重新过一遍。最典型的坑出现在邮件通知里。以前生成邮件里的链接可以写相对路径因为用户是从系统内点开的但邮件客户端没有上下文收到的链接必须完整带协议和域名。我们当时写了一个统一的URL生成函数所有业务方调用它来拼链接def build_tenant_url(tenant_name, path, queryNone): url fhttps://{tenant_name}.example.com{path} if query: url url ? .join(f{k}{v} for k, v in query.items()) return url这个函数上线后我们全局搜索了一遍所有拼接URL的代码愣是找出了十多个漏网的硬编码。这种事迟早会来不如在一开始就把所有URL生成收敛到一个入口。登录跳转是另一个大坑。改造前是login?tenantacme登录成功跳回租户首页改造后登录页本身就在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?tenantacme。直接下线意味着客户骂娘售后电话被打爆。我们的方案是保留一段时间的双轨运行后端兼容代码继续支持从query参数解析租户但只对来源IP白名单内、且Host头不是合法租户域名的请求生效。也就是说老IP地址仍然能打开系统但只有从我们内部网络或者客户专线访问时才有效。公网进来的人肉扫描直接404不给他们留任何入口。老地址上的登录页我们还临时加了一个横幅内容是访问地址已升级为 https://acme.example.com请更新您的收藏夹。这个横幅其实很管用很多客户看到之后主动去更新文档比一个个发通知效率高得多。4.2 301跳转与参数透传细节双轨运行只是一段时间内的妥协最终目标是把所有老链接引导到新地址。我们加了跳转逻辑把所有带tenant参数的请求301/302到对应的租户子域。这里有一个细节跳转之后的query参数要不要保留、要不要剔除tenant。我的建议是过滤掉tenant保留其余参数。原因很简单新地址的租户身份已经在域名里了再保留一个?tenantacme会造成双份标识下游日志、埋点都要多处理一层还容易产生两处不一致的隐患。以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, code302)关于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做分流甚至可以给个别租户单独指定后端upstreammap $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隔离和灰度回滚这两块值得多花时间反复验证。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →