ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

多租户系统从IP参数到二级域名改造的完整实践

多租户系统从IP参数到二级域名改造的完整实践 1. 改造的起点IP参数模式的底细与硬伤1.1 当初为什么选了“IP租户参数”我最早维护的那套多租户系统访问地址长这样http://10.24.1.8:8080/dashboard?tenantacme。租户标识完全靠URL参数传递前端每次请求都得带一个tenant字段后端再根据这个参数去切数据库、切缓存、切配置。这套模式在系统刚起步时效率极高开发不用折腾DNS也不用管域名证书反代服务器只要把请求原样转发到后端就行了。早期这套系统基本是私有化部署用户都在内网环境IP地址固定浏览器直接输IP也不会觉得别扭。租户数量也不多手工维护一份租户ID字典完全够用。最关键的是一开始业务验证的需求远大于架构优雅性谁也不会为了“将来域名好看”去提前押注一套复杂方案。说白了IP参数是当时投入产出比最高的选择。可问题在于多租户系统一旦跑起来租户数量只会涨不会跌。等接入的客户进了几十家访问入口从内网扩展到公网IP参数这套模式就开始四处漏风。1.2 IP参数在真实运维里暴露的四类问题第一个问题就是安全层面的暴露面扩散。租户ID直接放在URL里意味着任何人拿到一个链接就能看到?tenantxxx如果系统没有做额外的权限校验换一个参数值就可能越权访问其他租户的数据。就算网关层做了拦截URL参数本身也会被浏览器历史记录、服务器访问日志、反向代理日志、第三方统计脚本层层留存租户标识等于半公开状态。第二个问题是配置与缓存的耦合越来越恶心。租户ID作为参数时Nginx层做缓存就需要把tenant作为缓存key的一部分否则一个租户的响应可能被另一个租户命中。CDN缓存、浏览器缓存、服务端本地缓存全部要绕开动态参数配置复杂度直线上升。更麻烦的是后端服务之间调用时tenant参数需要手动透传漏传一个环节数据隔离就出漏洞。第三个问题是运营体验上的割裂感。用户记不住10.24.1.8:8080这种地址更记不住?tenantacme01这种参数。给客户的访问入口发过去对方IT同事第一反应永远是“这什么东西”。系统内部做租户画像、活跃统计、错误排查时日志里全是IP和参数堆在一起检索体验差到不行。第四个问题是链路追踪和多环境隔离时几乎无解。要有测试环境、预发环境、生产环境每个环境IP不一样租户参数可能还要加环境后缀一套配置在不同环境里经常互相覆盖。我后来总结了一句只要访问入口缺少语义化标识后续所有和租户相关的运维动作都会变得拧巴。1.3 什么情况下该下决心做域名化改造也不是所有多租户系统都得立刻改造。我的判断标准主要有三条踩中两条就可以提上日程。第一条是租户数量超过三十个且还在持续增长第二条是系统开始对外提供SaaS化服务第三条是安全审计或客户合同中明确提出了访问隔离要求。如果系统纯内网、租户常年稳定在个位数、业务方对访问地址完全没感知那域名化改造的收益就有限不值得为技术情怀增加运维成本。但反过来一旦外部客户开始把系统访问地址写进内部说明书二级域名带来的专业感和隔离感就是刚性需求了。2. 域名化改造的整体方案想清楚再动手2.1 租户到域名的映射关系怎么定改造的第一步不是配DNS而是设计租户与域名的映射模型。我踩过的坑是一上来就写*.example.com的通配符解析结果租户命名规则没定好后面出现了一堆acme-qa-01.example.com这种又长又丑还容易撞车的子域名。我的建议是先建一张租户域名映射表核心字段就四个租户ID、租户别名、系统默认域名、自定义域名。租户别名由系统自动生成规则是字母开头、允许连字符、不允许下划线、长度不超过二十位。这样做的原因是二级域名只能包含字母、数字和连字符下划线在DNS解析里不合法中文域名又要做punycode转换运维复杂度会显著上升。默认域名的规则建议统一采用别名.example.com这样用户看一眼域名就能猜到是哪个租户。自定义域名则留给有品牌需求的客户比如客户自己有acme.com希望用oa.acme.com访问系统我们就在映射表里加一条记录网关层根据Host头去查这张表。还有个容易忽略的问题系统内置的管理后台、帮助中心、静态资源服务也要预留固定域名。我当时规划了admin.example.com和static.example.com避免管理后台和租户域名混在同一套通配规则里后续做权限控制会舒服很多。2.2 通配符域名与解析架构的取舍域名化改造通常有两种解析策略。第一种是每个租户手工添加一条DNS记录优点是控制粒度细但租户一多就变成运维噩梦每次新增租户都要等DNS生效。第二种是配置一条*.example.com的通配符解析记录所有子域统一指向网关集群的入口IP新增租户不需要再动DNS这是绝大多数SaaS系统的标准做法。我选择的是通配符方案同时额外添加了admin.example.com和static.example.com的显式解析记录。这里有个小细节通配符记录不能覆盖已存在的显式记录所以把系统级子域名单独拎出来配置可以避免网关层再多做一层路由判断。通配符解析的TTL建议设置在300秒左右既不会让DNS查询压力过大也能在网关入口IP变更时快速生效。考虑到网关有多个节点前端通常会再加一层负载均衡或SLB通配符记录直接指向负载均衡的虚拟IP这样后端扩容缩容不会影响外部域名解析。2.3 证书方案是这次改造最容易低估的环节域名化改造绕不开HTTPS证书而且不是随便签一张证书就行的。每个租户对应一个子域如果给每个子域单独签发证书管理成本会成倍上升。我的选择是使用通配符证书*.example.com一张证书覆盖所有租户子域同时另配一张example.com的基础域名证书用于根域访问。通配符证书的签发有两种常见路径。一种是购买商业通配符证书一年一签价格看供应商和品牌适合证书变更频率低、需要人工介入的场景。另一种是使用自动化签发工具比如certbot配合DNS验证完成通配符证书的申请和自动续期。自动化方案在多数场景下稳定性足够但要注意单域名证书签发数量的限额大规模租户批量申请时可能触发频率限制。证书落地后的部署位置也有讲究。正常情况下是挂在负载均衡或网关入口证书私钥不要下发到每一台后端节点减少私钥泄露面。如果用了多网关节点集群证书目录要做好同步机制否则会出现部分节点证书已更新、部分节点还在用旧证书的情况用户访问时就会间歇性告警。3. 实操细节一条访问链路逐层改3.1 DNS与网关层的配置长什么样通配符解析落地后网关层要做的第一件事是从Host头里提取租户标识。以Nginx为例配置核心是启用server_name的正则匹配server { listen 443 ssl; server_name ~^(?tenant.)\.example\.com$; ssl_certificate /etc/nginx/certs/example_com_fullchain.pem; ssl_certificate_key /etc/nginx/certs/example_com.key; set $tenant_name $tenant; location / { proxy_pass http://backend_upstream; proxy_set_header Host $host; proxy_set_header X-Tenant-Name $tenant_name; proxy_set_header X-Forwarded-Proto https; } }~^(?tenant.)\.example\.com$会从请求的Host头里把子域前缀提取出来放进$tenant变量再以X-Tenant-Name头传递给后端。后端拿到这个头就可以代替原来的URL参数完成租户识别。这样做的好处是租户信息不再出现在请求路径里安全性提升而且对后端代码的侵入降到最低。如果用的是Spring Cloud Gateway这类微服务网关思路完全一样只是把正则提取逻辑放到路由断言里spring: cloud: gateway: routes: - id: tenant_route uri: lb://backend-service predicates: - Host*.example.com filters: - SetRequestHeaderX-Tenant-Name, ${HOST[0]...}这里要特别提醒网关层提取的租户标识必须作为不可信任的输入处理后端不能直接用这个Header去查库而是要根据租户标识查询映射表确认租户状态正常后才允许进入业务逻辑。3.2 后端应用怎么做到租户自动识别网关把X-Tenant-Name传到后端后后端需要在自己的请求处理链路里加一个租户上下文过滤器。我见过一些团队图省事直接在后端代码里读request.getHeader(X-Tenant-Name)用的时候才取结果同一请求里多个服务节点各取各的跨服务调用时租户上下文直接丢失。正确做法是在网关之后的第一个服务节点内创建租户上下文并把租户ID写入调用链的透传Header。比如Java服务里用一个Filter拦截所有请求从X-Tenant-Name解析出租户ID放入ThreadLocal同时在使用RestTemplate或OpenFeign发起下游请求时强制把租户ID写进透传头Component public class TenantContextFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { String tenantName request.getHeader(X-Tenant-Name); if (tenantName null || tenantName.isBlank()) { chain.doFilter(request, response); return; } TenantContext.set(tenantName); try { chain.doFilter(request, response); } finally { TenantContext.clear(); } } }租户上下文里到底放什么也值得斟酌。我建议放租户ID不放租户名因为后续查数据库、查缓存、做数据隔离时用的都是租户ID。租户名主要用来构建域名和展示一旦放到上下文里服务端每次都要多一次映射查询。数据库层面也要考虑租户隔离策略。如果是独立数据库模式要根据租户ID动态选择数据源如果是共享库加租户ID字段模式则要保证所有SQL都能自动带上租户条件。这部分的改造工作量通常比网关层大需要业务代码配合。3.3 登录会话和Cookie的域问题这应该是整个改造里最容易掉链子的地方没有之一。IP参数模式下Cookie基本都是种在IP域名下的改成二级域名访问后很多团队发现用户登录成功跳转回系统登录态却丢了或者A租户的登录状态跑到了B租户页面上。问题的根源是Cookie的Domain属性。如果你在网关返回的Set-Cookie里把Domain设置成了example.com那么所有子域都能读到这个Cookie这通常是我们想要的。但如果你把Domain设置成不带点的某个具体子域或者漏设了Domain导致Cookie被种在访问的那个子域下那么用户从acme.example.com跳到beta.example.com时两边各有一套独立的Cookie登录态自然对不上。推荐的设置方式是在统一认证服务里下发Cookie时显式指定Domain为主域Set-Cookie: session_tokenxxx; Domainexample.com; Path/; HttpOnly; Secure; SameSiteLaxDomain设为example.com后acme.example.com和beta.example.com都能读到同一个session_token。这个方案适合登录态必须跨子域共享的场景。如果租户隔离要求很高、希望各租户完全独立会话那就不要共享Cookie每个子域各发各的让用户到每个子域都重新登录一遍。我在实际改造中还遇到过一个隐藏坑系统内部有个“切换租户”的功能。在参数模式下切换租户就是改URL参数的事。改成子域后切换租户变成了一次跨域跳转前端需要在跳转前清除当前子域的本地缓存否则新租户页面会读取到旧租户的localStorage数据。3.4 静态资源与老访问参数的兼容处理域名化改造后静态资源请求会产生新的问题。原来所有资源都在同一个IP下Cookie会自动带上。现在每个租户子域不同如果前端页面里的JS、CSS、图片都走租户子域加载会产生大量没有必要的Cookie传递还可能导致CDN缓存命中率下降。我的做法是把静态资源统一放到static.example.com这个单独的域名下页面里的资源地址全部指向这个固定域名同时给静态资源设置较长的缓存时间。这样用户访问任何租户子域时静态资源都只从同一套域名加载浏览器复用连接CDN也能有统一的缓存命名空间。老链接兼容问题也要提前规划。改造上线后老用户手上可能还存着http://10.24.1.8:8080/dashboard?tenantacme这类链接。最好的处理方式是在网关层做一次301跳转把带租户参数的老地址重写到新域名的对应路径if ($arg_tenant) { return 301 https://$arg_tenant.example.com$request_uri; }这个规则的顺序要放在子域匹配之前同时注意只处理GET请求避免POST请求被错误跳转。我当时的方案是先做两周的“新旧共存”带参数的请求仍然允许通过但请求头里增加一个标记内部统计还有多少流量在走老入口统计结果显示老入口流量降到零之后再关停参数通道。4. 常见问题排查这些坑我替你踩过了4.1 通配符解析“时好时坏”怎么办通配符解析配置完成后最常见的问题是“有时能解析有时不能”。我排查过的案例里大部分是本地DNS缓存导致。客户端电脑上缓存的DNS记录包含旧IP更换网关入口后没有刷新就会出现一半请求去旧地址、一半请求去新地址的诡异现象。处理方法是先在本地用nslookup acme.example.com和nslookup beta.example.com分别验证解析结果如果返回IP不是预期网关IP执行ipconfig /flushdns或systemd-resolve --flush-caches清掉缓存。如果多台机器都解析到奇异地址那就要检查公司内部DNS服务器有没有把通配符解析拦截掉。有些企业AD域DNS会优先匹配内网zone导致*.example.com这种记录不生效。我还遇到过一个更隐蔽的情况某个租户域名和公司在阿里云或腾讯云上买的其它产品域名产生了冲突。比如客户既使用你的平台又配置了同名的企业邮箱子域两边DNS记录互相覆盖。这类问题要在租户开通时加一道校验用命令检测目标子域当前是否已有解析记录有记录就提示管理员确认归属。4.2 证书不定期告警的排查思路通配符证书部署后我最怕收到证书过期告警。自动化续期虽然好用但排障链路一旦出问题所有租户都会同时遭殃。一次典型的续期失败场景是certbot使用DNS验证时调用DNS服务商的API密钥过期续期任务连续几天没有执行等到告警邮件发出来才发现。这里有几个经验分享。一是续期脚本一定加失败重试和告警建议每天凌晨跑一次连续失败就触发Webhook通知。二是证书文件不要直接放在Nginx配置引用的固定路径里而是用符号链接指向当前版本目录续期脚本更新符号链接后reload Nginx。三是如果有多台网关节点证书目录的同步要考虑原子性避免某台机器读到半截证书。用certbot申请通配符证书的参考命令如下DNS验证方式需要提前配置好DNS服务商的API凭据certbot certonly \ --dns-dnsimple \ --dns-dnsimple-credentials /etc/certbot/credentials.ini \ -d example.com -d *.example.com \ --propagation-seconds 60如果是纯内网系统、没有外部DNS验证条件那就需要搭建私有CA并把根证书分发给所有客户端。这一步还要考虑老客户电脑可能没装根证书访问时出现“证书不受信任”告警比公网证书的运维工作量更大。4.3 用户登录态串号或丢失的定位方法登录态问题排查的通用起点是抓包看Cookie。打开浏览器开发者工具切换不同租户子域观察Set-Cookie响应头里的Domain和Path属性。如果Domain设成了具体子域那基本可以断定跨子域登录态不共享就是这个问题。如果Cookie Domain设置正确登录态仍然丢失就要检查后端Session的存储Key是否包含租户信息。IP参数时代很多系统直接用session:userId做Redis Key改成多子域后同一个用户在不同子域登录会被识别为同一条会话导致数据串号。正确做法是在Session Key里拼上租户ID比如session:tenantId:userId。另一个容易被忽略的地方是浏览器SameSite策略。如果统一认证入口和业务子域不是同源SameSite默认值Lax在某些浏览器下会阻止跨站发送Cookie。遇到“登录跳转回来还是未登录”的情况先把Cookie的SameSite设为None并开启Secure属性然后再看是否恢复。4.4 监控系统被海量子域标签刷爆改成二级域名后Prometheus和Grafana这类监控系统会遇到一个快乐而痛苦的问题指标里多了无数个host标签。以前只有一个IP入口现在每个租户子域都算一个标签值几个大租户的流量一波动标签基数直接拉满查询性能肉眼可见地下降甚至把监控存储搞爆。处理方案是在采集端做标签降维。我的做法是指标里保留tenant标签但Tenant标签统一归一到租户ID不再保留完整Host名。同时在监控大盘上增加一个“按租户聚合”的视图把高频指标按租户ID聚合展示排查问题时不看原始Host而是先确定租户再往下钻。日志系统的处理也类似。原来request_uri里能看到?tenantxxx改造后看不到租户信息了先别急着拍手叫好反而要检查日志采集器有没有把X-Tenant-Name这个请求头纳入日志字段。用Nginx的话可以在日志格式里加上$http_x_tenant_name确保排查问题时还能快速定位到租户维度。5. 兼容期过渡与回退预案5.1 新旧访问方式并存期怎么设计任何改造都不能一步到位尤其是多租户系统老客户可能正在用旧链接访问直接切域名会导致他们在没通知的情况下“被下线”。我的做法是设一个兼容窗口期至少保留两周的旧入口。兼容期的设计规则是新域名正常服务旧入口收到带tenant参数的请求时网关返回301跳转到新域名对应路径。这个跳转要在HTTP层完成不要在页面里用JavaScript二次跳转因为爬虫、脚本、旧收藏夹里的链接都依赖服务端跳转。跳转的同时在网关层打印一条包含tenant参数和跳转目标域名的访问日志。这个日志太重要了它能让你在关停旧入口之前看到哪些租户还在使用老链接。如果某些租户的流量一直不降说明他们的系统里可能还有别的地方写死了旧地址需要提前联系对方IT更新。5.2 回退方案宁可白做也不能不做域名化改造涉及DNS、网关、应用代码、前端页面四个层面一旦上线后出现重大问题回退不是简单的“把代码回滚”就能解决。我准备回退预案的思路是网关层留一个配置开关可以随时关闭域名化路由改回IP参数的处理逻辑。具体做法是在Nginx配置里通过文件判断是否启用域名化模式。如果检测到存在/etc/nginx/conf.d/disable_tenant_domain.conf文件就走旧的租户参数提取逻辑否则走新的Host头提取逻辑。这样一个文件就能控制整个网关的行为回退时不需要重新编译配置也不影响其它正在进行的变更。DNS层面也要留后路。建议在改造前记录原有解析记录和配置备份万一需要回退DNS切回旧IP不需要等太长时间。不过说实话这个回退预案我准备了但最终没用上但如果让我再来一次我还是会准备。因为域名化改造最容易出问题的是登录态而登录态问题一旦发生就是全部租户同时受影响没有预案的时候只能干着急。6. 最后分享几个值得记住的细节这次改造做完之后我最大的体会是域名化改造表面上是在改访问入口实际上是在重写整套租户识别逻辑。原来靠着URL参数到处传递的租户信息必须变成链路里自动流转的上下文这个转变一旦完成系统的代码会清爽很多。最后分享一个实操小技巧改造完成后一定用无痕模式把所有租户子域依次访问一遍每次之间清一次浏览器缓存。不要用普通浏览器手动登录测试因为Cookie复用会掩盖掉登录态隔离问题。我带着团队花了一下午做这件事最后抓出了三个测试环境下完全复现不了、生产环境下一定会遇到的串号隐患。如果你正在做类似改造我建议先拿两个低频租户做试点跑通全链路后再放开全量。这个方案看起来慢实际是最快的。多租户系统的改造不怕慢怕的是改完之后你不知道哪个租户在哪个环节被漏掉了。
返回列表