企业出海 SaaS 多租户架构设计:Pool/Bridge/Silo 隔离模型 + vCluster 虚拟集群 + 行级数据隔离实战(2026最新版)
Meta Description: 2026 出海 SaaS 多租户架构指南:Pool/Bridge/Silo 三种隔离模型、vCluster 虚拟集群、ResourceQuota 与 NetworkPolicy、PostgreSQL 行级安全、噪声邻居治理与三云能力对比,附 90 天落地路线。
- 先说结论:多租户不是"加个 tenantid",而是四道边界一起划
- 三种部署模型:Pool / Bridge / Silo 与那条"隔离强度 × 成本 ≈ 常数"的曲线
- 判定表:你的产品该停在哪一档
- 计算隔离 L1:namespace + ResourceQuota + LimitRange
- 计算隔离 L2:vCluster 虚拟集群(namespace 之后的下一步)
- 网络隔离:默认拒绝 + 显式放行 + 别忘了 DNS
- 数据隔离:三种模式与那张必须一起看的对比表
- 对象存储与文件:前缀不是隔离,策略才是
Meta Description: 2026 出海 SaaS 多租户架构指南:Pool/Bridge/Silo 三种隔离模型、vCluster 虚拟集群、ResourceQuota 与 NetworkPolicy、PostgreSQL 行级安全、噪声邻居治理与三云能力对比,附 90 天落地路线。
关键词: 多租户架构、租户隔离、vCluster、行级安全 RLS、SaaS 出海
一、先说结论:多租户不是"加个 tenant_id",而是四道边界一起划
多租户架构最容易犯的错,是把它当成一个数据库字段问题——表里加个 tenant_id,查询带上 where tenant_id = ?,就当隔离做完了。真出海跑起来,出事的往往不是"数据查错了",而是某个大客户的批量任务把整个集群的 CPU 吃满、某个租户的 Pod 通过内网访问到了另一个租户的缓存、一个漏写 where 的报表 SQL 把 300 家客户的数据导进了同一份 CSV。
所以本文的定义是:多租户 = 计算、网络、数据、身份四条边界同时成立,任何一条缺失都等于没有隔离。
先把本文与站内既有文章的边界划清,避免重复造轮子:
| 既有文章 | 它解决的问题 | 本文的位置 |
|---|---|---|
| 09-18 多云管理平台 CMP | 平台自身的租户与权限模型(谁能申请什么资源) | 本文讲产品侧租户之间的隔离(客户 A 碰不到客户 B) |
| 10-02 单元化架构 | 故障影响面收敛(Cell 爆炸半径) | 租户是分片维度,单元是故障域;两者可叠加但不是一回事 |
| 08-19 容器运行时安全 | 不可信代码的内核级隔离(gVisor/Kata) | 本文是逻辑隔离,沙箱是最后一道物理边界 |
| 08-25 零信任网络 | 身份到流量的持续验证 | 本文只取其中"租户边界"这一层做落地 |
| 09-23 数据治理 | 元数据、血缘、质量、数据网格 | 本文只讲一行数据的归属与可见性 |
| 10-14 K8s 成本治理 | 工作负载级成本归因 | 本文把归因对象从"团队"换成"租户",用于分账 |
| 09-10 对象存储 | 跨云数据湖的桶与生命周期 | 本文讲桶内前缀级租户隔离与临时凭证 |
一句话记法:CMP 管"你能申请什么",本文管"你只能看见你自己的"。
二、三种部署模型:Pool / Bridge / Silo 与那条"隔离强度 × 成本 ≈ 常数"的曲线
业界最通用的分类来自 AWS Well-Architected 的 SaaS Lens:Silo(每个租户一套独立基础设施)、Pool(全部租户共享一套)、Bridge(混合,按租户分档)。这三者不是"先进与落后"的关系,而是同一条成本-隔离曲线上的三个点。
| 维度 | Silo 全隔离 | Pool 全共享 | Bridge 混合分层 |
|---|---|---|---|
| 计算 | 每租户独立集群/账号 | 共享集群,namespace 隔离 | 大客户 Silo,长尾 Pool |
| 数据 | 每租户独立库/实例 | 共享库 + tenant_id + RLS | 分档:独立库 or 共享表 |
| 单位成本 | 最高(利用率最低) | 最低(规模效应最强) | 居中,可控 |
| 故障影响面 | 单租户 | 全租户 | 按档位 |
| 单租户定制 | 自由(可改版本) | 极难(版本必须统一) | 大客户可定制 |
| 运维复杂度 | 随租户数线性膨胀 | 恒定 | 双轨并行,最考工程能力 |
| 典型适用 | 金融、医疗、政企、强合规 | PLG 型 SaaS、中小客户长尾 | 出海 SaaS 的默认答案 |
核心判断:只要你的客户里同时存在"年付 50 万美元要求独立部署"和"月付 99 美元自助注册",你就已经身处 Bridge 模型了——问题不是要不要选,而是分档线画在哪、以及两档能不能共用一套发布流程。
┌──────────────────────── Bridge 混合模型(出海 SaaS 常见形态)────────────────────────┐
│ │
│ ┌──────────── Silo 档(企业客户 / 强合规)────────────┐ ┌──── Pool 档(长尾)───┐ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │ ┌─────────────────┐ │ │
│ │ │ 独立 VPC/账号 │ │ 独立 VPC/账号 │ │ │ │ 共享集群 │ │ │
│ │ │ ┌──────────┐ │ │ ┌──────────┐ │ │ │ │ ┌─────┐ ┌─────┐ │ │ │
│ │ │ │ 独立 DB │ │ │ │ 独立 DB │ │ │ │ │ │ns: A│ │ns: B│ │ │ │
│ │ │ └──────────┘ │ │ └──────────┘ │ │ │ │ └─────┘ └─────┘ │ │ │
│ │ └──────────────┘ └──────────────┘ │ │ │ 共享 DB + RLS │ │ │
│ └────────────────────────────────────────────────────┘ │ └─────────────────┘ │ │
│ └───────────────────────┘ │
│ 统一控制面:租户目录 / 计费 / 发布流水线 / 审计日志(必须一套,不能两套) │
└──────────────────────────────────────────────────────────────────────────────────────┘
这套模型的四层隔离架构可以画成一张依赖图——越往下越硬,越难临时补:
graph TD
A[身份边界: JWT tenant_id / 网关校验] --> B[网络边界: NetworkPolicy + egress 白名单]
B --> C[计算边界: namespace / vCluster / 配额]
C --> D[数据边界: RLS / schema / 独立库]
D --> E[物理边界: 沙箱 / 独立集群 / 独立账号]
A -.漏了.-> X[越权读别人的数据]
B -.漏了.-> Y[横向移动到别人的缓存/中间件]
C -.漏了.-> Z[噪声邻居打满全站]
D -.漏了.-> W[一条漏写 where 的 SQL 泄露全量]
三条铁律,先记住再往下看:
- tenant_id 必须在请求入口注入,不能由客户端传——任何"相信 Header 里的 X-Tenant-Id"的实现都是漏洞。
- 默认拒绝——网络、权限、数据可见性,初始状态一律是"看不见",靠显式授权打开。
- 隔离等级要与客单价匹配——给月付 99 美元的客户做独立集群,是在用毛利补贴架构洁癖。
三、判定表:你的产品该停在哪一档
不要凭感觉选,用下面六个问题打分(每命中一条 +1):
| 判据 | 命中说明 |
|---|---|
| 客户合同里出现"数据不得与其他客户共库" | 法务/合规硬约束 |
| 需要按租户单独升级版本(客户拒绝跟随主线) | 版本碎片化已发生 |
| 单租户峰值会打满共享集群 20% 以上资源 | 噪声邻居风险高 |
| 需要提供租户级 RTO/RPO 承诺(如 15 分钟) | 故障域必须切开 |
| 有客户要求指定地域/指定机房驻留 | 数据主权约束 |
| 客户会做渗透测试并要求出具隔离证明 | 举证责任在你这侧 |
- 0–1 条:Pool 起步,把 tenant_id 与配额从第一天做对即可。
- 2–3 条:Bridge,按"是否命中前两条"划出 Silo 档。
- 4 条以上:默认 Silo,Pool 只留给自助注册的试用租户。
最贵的错误不是选错档,而是没有"分档线"这个概念——所有租户挤在 Pool 里,等第一个大客户提出独立部署时,才发现代码里到处是"假设只有一个库"。
四、计算隔离 L1:namespace + ResourceQuota + LimitRange
共享集群里最先要做、性价比最高的三件事,全在原生 API 内,不需要装任何东西。
4.1 三者的分工(最常被混为一谈)
| 对象 | 管什么 | 不管什么 | 典型误用 |
|---|---|---|---|
| Namespace | 逻辑分组 + RBAC 作用域 + 网络策略作用域 | 不提供任何资源上限 | 以为分了 ns 就隔离了 |
| ResourceQuota | 命名空间总量上限(申请值/上限值/对象数) | 不约束单个 Pod 的形态 | 只设 requests 不设 limits |
| LimitRange | 单个 Pod/容器的默认值与上下限 | 不约束命名空间总量 | 设了 LimitRange 就以为有配额 |
一句话:Quota 管"这一户总共能用多少",LimitRange 管"这一户里单个容器最大能多大"。 缺前者会被一个租户吃满集群,缺后者会有一个 Pod 申请 64 核把调度器玩坏。
4.2 租户命名空间的完整基线
apiVersion: v1
kind: Namespace
metadata:
name: tenant-acme-prod
labels:
tenant: acme # 租户标识,用于配额模板与计费归因
tier: silo # 档位:silo / pool
env: prod
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: tenant-quota
namespace: tenant-acme-prod
spec:
hard:
requests.cpu: "40"
requests.memory: 80Gi
limits.cpu: "80"
limits.memory: 160Gi
pods: "200"
services.loadbalancers: "2" # 防止租户随手开一堆 LB 烧钱
services.nodeports: "0" # NodePort 直接禁掉,出口统一走 Ingress
persistentvolumeclaims: "20"
requests.storage: 2Ti
count/jobs.batch: "30"
---
apiVersion: v1
kind: LimitRange
metadata:
name: tenant-defaults
namespace: tenant-acme-prod
spec:
limits:
- type: Container
default: # 未写 limits 时的兜底
cpu: 500m
memory: 512Mi
defaultRequest: # 未写 requests 时的兜底
cpu: 100m
memory: 128Mi
max: # 单容器上限,防"一个 Pod 申请 64 核"
cpu: "16"
memory: 64Gi
min:
cpu: 50m
memory: 64Mi
三个实测坑,写进你的入职文档:
- Quota 不会驱逐已存在的 Pod。调低配额后,超额部分照跑,只是新建被拒。要真正收敛,得配合
kubectl drain或滚动重启。 - 设了
limits不设requests时,K8s 会把 requests 默认成 limits(若 LimitRange 提供了defaultRequest则以它为准),这会让调度器按峰值预留容量,集群看起来"很空但调度不上"。 services.loadbalancers与nodeports是省钱配额,不是安全配额。真正防横向移动的是下一节的 NetworkPolicy。
4.3 把配额做成模板,而不是手抄
二十个租户靠手写 YAML 一定会漂移。用 Kustomize 的 replacements 或 Helm 的 values 生成,把"每租户资源档位"变成一张表:
| 档位 | requests.cpu | limits.cpu | Pod 上限 | 适用 |
|---|---|---|---|---|
| starter | 4 | 8 | 30 | 试用/月付长尾 |
| growth | 16 | 32 | 100 | 中型客户 |
| enterprise | 40 | 80 | 200 | 大客户(共享集群内) |
配合 kubectl create quota 做不到模板化,正确做法是把它放进 Git,用 Argo CD/Flux 按租户目录渲染——租户开通 = 一次 PR,这样每一次隔离变更都有 diff 可审。
五、计算隔离 L2:vCluster 虚拟集群(namespace 之后的下一步)
当"共享 namespace"开始不够用时(租户要装自己的 CRD、要自己的 RBAC 管理员、要自己的 Kubernetes 版本),最省力的中间档是虚拟集群。
5.1 它到底隔离了什么
vCluster 在宿主集群的一个 namespace 里跑一个轻量控制面(默认 k3s,也支持 k0s),租户拿到的是一个独立的 API Server:自己的 CRD、自己的 RBAC、自己的 Service CIDR、自己的 kubectl get nodes 视图。工作负载最终以 Pod 形式落在宿主 namespace 中,由宿主调度。
| 能力 | 共享 namespace | vCluster 虚拟集群 | 独立物理集群 |
|---|---|---|---|
| 租户可自建 CRD | ❌ 会污染全集群 | ✅ 隔离在虚拟 API 内 | ✅ |
| 租户可做 cluster-admin | ❌ 等于给了全集群 | ✅ 只在自己虚拟集群内 | ✅ |
| 内核/容器运行时隔离 | 共享 | 共享(这点常被误解) | 独立 |
| 单租户控制面成本 | 0 | 低(单副本即可) | 高(含控制面费用) |
| 跨租户网络默认拒绝 | 需手写 NetworkPolicy | 需手写(同一宿主网络) | 天然 |
| 适用 | 长尾小客户 | 中大型客户、需自定义扩展 | 强合规、强隔离 |
最关键的一句:vCluster 提升的是控制面隔离与"Kubernetes 体验隔离",不是内核隔离。租户的 Pod 仍与别人共享宿主节点内核——需要内核级隔离时,回到 08-19 的沙箱方案(RuntimeClass 定向到 gVisor/Kata)。
5.2 落地命令(OSS 版,不依赖商业平台)
helm repo add loft https://charts.loft.sh
helm repo update
vcluster create acme-prod -n host-acme --namespace acme-prod --connect=false
kubectl get ns host-acme
kubectl -n host-acme get pods
vcluster connect acme-prod -n host-acme
kubectl get nodes
kubectl get crd
前两条装 CLI/仓库,第三条在宿主 namespace host-acme 里创建虚拟集群,第四五条验证宿主侧资源,第六七条切换 kubeconfig 到虚拟集群内验证"租户视角"——如果 kubectl get nodes 在虚拟集群里看到的是宿主节点名,说明隔离只到 API 层,这是预期行为,不是配置错误。
三个运维要点:
- 宿主 namespace 仍要配额。虚拟集群自己不会限制总用量,必须在
host-acme上套 ResourceQuota,否则"虚拟"只是幻觉。 - 宿主侧要拒绝 hostPath/hostNetwork/privileged。用 Pod Security Admission(
pod-security.kubernetes.io/enforce: baseline或restricted)打在宿主 namespace 上,这是虚拟集群最大的逃逸面。 - 备份要分两层:虚拟集群的 etcd(业务 CRD/ConfigMap)+ 宿主侧的 PV。只备份一边,恢复时会缺对象。
5.3 Capsule:不想引入第二套控制面时的轻量替代
Capsule(Clastix 开源)走另一条路:不创建新 API Server,而是用 Tenant CRD 把"一组 namespace + 配额 + 网络策略 + 镜像仓库白名单 + Pod 安全等级"打包,由租户 owner 自助创建 namespace。
| 方案 | 机制 | 适合 |
|---|---|---|
| Capsule | Tenant CRD 编排原生 namespace(不新增控制面) | 只需"自助开 ns + 强制配额",运维最轻 |
| vCluster | 独立虚拟 API Server | 租户要 CRD/自定义控制器/cluster-admin |
| HNC(Hierarchical Namespace) | namespace 树 + 策略继承 | 组织层级深、需父子继承配额 |
两者可叠加:Capsule 管"谁能开 namespace",vCluster 给需要重度自定义的租户。
六、网络隔离:默认拒绝 + 显式放行 + 别忘了 DNS
数据泄露事故里,比"SQL 查错"更常见的是网络可达——租户 A 的 Pod 直接连上了租户 B 的 Redis,因为两者在同一 VPC 的同一个扁平网段里。
6.1 两条基线策略
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: tenant-acme-prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-and-same-namespace
namespace: tenant-acme-prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector: {}
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
- to:
- podSelector: {}
第一条是"先关死":命名空间内所有 Pod 的进出流量全拒。第二条是"按需开":只允许同 namespace 内互访与访问 kube-dns。
这里有一个 100% 会踩的坑:默认拒绝 egress 后,如果没显式放行 53 端口,所有域名解析都会失败,报错是 no such host / i/o timeout,看起来像"网络不通",实际是 DNS 被自己的策略拦了。线上事故里这条能占到一半。
6.2 南北向与出网:租户级出口
| 方向 | 推荐做法 | 反模式 |
|---|---|---|
| 用户 → 租户服务 | 统一 Ingress/网关按 Host 或路径分流,TLS 在入口终止 | 每租户开一个公网 LB(成本爆炸 + 攻击面) |
| 租户服务 → 公网 | 共享 NAT 网关 + 出网白名单(域名/FQDN 或固定 IP 段) | 给租户 Pod 直接挂 EIP |
| 租户 → 云服务(对象存储/DB) | VPC 端点(PrivateLink/终端节点),流量不出 VPC | 走公网绕一圈 |
| 租户 A → 租户 B | 默认拒绝,仅当业务确有需要时用显式策略开单向 | 同 VPC 全通 + 靠"约定"约束 |
出网白名单是很多出海团队的盲区:租户的服务会调第三方 API(支付、短信、地图),如果共享 NAT 且不限出网,一旦某个租户的服务被入侵,攻击者可以把它当跳板访问你的内网其他部分。出网策略要按"域名 + 端口"而不是"IP 段"来写——云厂商 IP 会漂移,域名不会。
6.3 三个常被忽略的横向通道
- 共享中间件:所有租户连同一个 Kafka/RabbitMQ 时,必须用 vhost/topic 前缀 + 独立账号做 ACL,仅靠"不同 topic 名"不是隔离。
- 共享缓存:Redis 的
SELECT db不是隔离(运维一条KEYS *全看得见),要用独立实例或 ACL 用户 + key 前缀强校验。 - 服务网格:Istio 的
AuthorizationPolicy能按source.namespaces限制东西向,但它只作用于经过 sidecar 的流量,直连 Pod IP 仍要看 NetworkPolicy。
七、数据隔离:三种模式与那张必须一起看的对比表
这是多租户里唯一"改起来最贵"的一层。选型必须在写第一张表之前定,事后迁移的成本是设计期的十倍。
| 维度 | 共享表 + tenant_id(+RLS) | Schema-per-tenant | DB/实例-per-tenant |
|---|---|---|---|
| 隔离强度 | 逻辑(依赖 SQL 正确性) | 中(schema 边界 + 权限) | 强(连接串就是边界) |
| 租户数量上限 | 十万级 | 数百~数千(迁移与连接数受限) | 数十~数百 |
| 单租户数据导出 | 需按 tenant_id 全表扫 | pg_dump -n schema 一行 | 直接 dump 实例 |
| 大租户性能影响 | 有(同表、同索引、同 buffer) | 较小(索引独立) | 无 |
| 迁移/DDL | 一次 | N 次(要遍历所有 schema) | N 次(要遍历所有实例) |
| 连接数 | 省(一个池) | 中 | 爆(每实例都要池) |
| 合规举证 | 弱(需自证 where 写对了) | 中 | 强(物理可举证) |
选择口诀:长尾用共享表 + RLS,中大客户用 schema,强合规客户用独立实例——这正好对应第二节的 Bridge 分档。
7.1 共享表 + PostgreSQL 行级安全(RLS)
只靠应用层写 where tenant_id 是不可靠的:一个报表导出、一个后台脚本、一个新同事的裸 SQL 就能绕过。RLS 把这条约束下沉到数据库,即使 SQL 忘了写 where,也查不到别人的行。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE INDEX CONCURRENTLY idx_orders_tenant_created
ON orders (tenant_id, created_at DESC);
四行 SQL,四个必须理解的细节:
FORCE ROW LEVEL SECURITY:默认情况下表 owner 绕过 RLS。如果你的应用用 owner 账号连库(很常见),不写 FORCE 等于策略不存在。current_setting(..., true):第二个参数missing_ok=true。不传它,未设置变量时直接报错;传了则返回 NULL,结果是"查不到任何行"——失败方向是安全的,这比报错更适合做兜底。- 超级用户与
BYPASSRLS角色不受约束:应用账号必须是普通角色,别图省事给它SUPERUSER。 WITH CHECK管写入,USING管读取:只写USING会导致"读不到但能写进去别人的行",是最隐蔽的一种半吊子配置。
7.2 连接池的致命细节:SET 泄漏到别的租户
租户标识通过会话变量传递:
SET LOCAL app.tenant_id = '3f2a1c88-...';
SELECT * FROM orders WHERE status = 'paid';
必须用 SET LOCAL,不能用 SET。 原因:
SET是会话级,连接归还池子后变量仍然存在;下一个借到这条连接的请求如果没设置(走了一条不经过中间件的分支),就会继承上一个租户的身份——这是最典型、也最难复现的跨租户数据泄露。SET LOCAL只在当前事务内有效,事务结束自动失效,且必须包在显式事务里(autocommit 下SET LOCAL几乎立刻失效)。
配合 PgBouncer 时还要注意:transaction pooling 模式下会话状态不保证,SET 的语义会漂移,只有 SET LOCAL 在事务内是安全的。如果框架不好改,就在连接建立回调里显式 RESET ALL,把风险降到可控范围。
7.3 schema-per-tenant 的两个隐性成本
- DDL 扇出:一次加字段要遍历 N 个 schema,且必须可重入、可续跑。用
flyway/golang-migrate的 per-schema 版本表,配合一个for循环脚本,别手动点。 search_path注入:切换租户靠SET search_path TO tenant_x,同样是会话状态问题——必须SET LOCAL,且search_path里不能包含public,否则未限定的表名会落到公共 schema,形成越权读。
7.4 数据层隔离的三条验收标准
- 用一个没有 tenant_id 条件的 SQL 去查,返回 0 行(而不是报错,也不是全量)。
- 用租户 A 的凭证写一条
tenant_id为 B 的记录,被WITH CHECK拒绝。 - 在连接池里连续跑 A → B → A 三个请求,用
pg_stat_activity确认没有残留的会话变量。
八、对象存储与文件:前缀不是隔离,策略才是
数据库做完了,文件和对象常常是最后漏掉的一环。S3/OSS/COS 的"每个租户一个前缀"只是命名约定,真正的边界是 IAM 条件。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-saas-files/*",
"Condition": {
"StringLike": {
"s3:prefix": ["${aws:PrincipalTag/tenant_id}/*"]
}
}
}
]
}
要点:
${aws:PrincipalTag/tenant_id}来自会话标签,不是客户端随便传的。用 STSAssumeRole时通过--tags Key=tenant_id,Value=acme打上,且信任策略里必须允许sts:TagSession,否则标签根本带不进去(这个报错信息很不友好,容易查半天)。- 不要让租户拿到长期 AK。正确链路是:登录态 → 后端 AssumeRole(带租户标签)→ 返回 15 分钟~1 小时的临时凭证。
- 预签名 URL 的过期时间设短(S3 SigV4 上限 7 天),且签名时要绑定
Content-Type与大小,避免被当成上传任意文件的入口。 - 列桶(ListBucket)权限单独收紧:只给
GetObject/PutObject而禁ListBucket时,租户无法枚举别人的前缀,这是一个成本极低但收益很高的开关。
九、身份边界:五个必须堵死的越权口子
前面四层都做对了,只要入口这一层信错了东西,全部白做。
| 口子 | 表现 | 正确做法 |
|---|---|---|
| 信任客户端 Header | 后端读 X-Tenant-Id 决定查哪个库 | 只从已验签的 JWT claim 取值,网关剥离外来的同名 Header |
| 只校验登录不校验归属 | 登录成功后 /api/orders/{id} 直接按 id 查,不校验该订单属于当前租户 | 每次资源访问做归属校验(或让 RLS 兜底) |
| 管理端与租户端共用接口 | 运营后台的 ?tenant_id= 参数被前端可改 | 管理端走独立路由 + 独立鉴权,参数只从管理员上下文取 |
| JWT 里 tenant_id 可伪造 | 用了 alg: none 或未校验签名 | 固定算法白名单、校验 iss/aud/exp、密钥轮换 |
| 缓存 key 不含租户 | cache:get:order:123 跨租户命中 | key 前缀强制 t:{tenant_id}:,在缓存客户端封装层统一注入 |
这一整类问题在 OWASP Top 10(2021)里叫 A01 失效的访问控制,长期排在第一。它对多租户产品的杀伤力尤其大,因为一个越权 bug 等于一次跨客户数据泄露,而不是"一个用户看到了别人的一条记录"。
工程上最省力的兜底,是把"归属校验"变成框架级能力:数据访问层强制要求一个 TenantContext,缺失即抛异常(fail closed),而不是默默用 null 去查。
十、噪声邻居:多租户特有的性能故障
单租户系统里没有这类故障。多租户里它是头号 P1 来源,而且症状极具迷惑性:监控上看是"全站变慢",根因是某一个租户跑了一个全表扫描的报表。
| 噪声类型 | 打爆什么 | 手段 |
|---|---|---|
| CPU 密集(批量计算/视频转码) | 节点 CPU、调度延迟 | 配额 + 独立节点池 + 优先级 |
| 内存增长(缓存无界/大对象) | 节点 OOM、连带驱逐邻居 Pod | limits 必设 + 独立节点池 |
| IO 密集(全表扫/大文件) | 共享存储 IOPS、数据库 buffer | 独立只读副本 + 查询超时 + 资源组 |
| 连接数(连接池打满) | 数据库连接上限 | 每租户连接上限 + 代理层排队 |
| 请求风暴(重试/爬虫) | 网关、下游限流 | 租户级限流(令牌桶) |
| 大 key / 大对象 | 缓存与网络抖动 | 单次返回大小上限 + 分页强制 |
三道防线,按成本从低到高:
- 配额与限制(第 4 节):让"打满"变成"打不满",但配额是静态的,挡不住瞬时脉冲。
- 优先级与抢占:给核心租户的负载设更高
PriorityClass,让低优先级批处理先被抢占——这是 K8s 原生能力,很多团队完全没用。 - 物理隔离节点池:把最大的 3~5 个租户的负载用
nodeSelector/taints固定到专属节点池。贵,但比赔 SLA 便宜。
监控上要有的四个租户维度指标(普通监控里通常缺失):
| 指标 | 含义 | 告警建议 |
|---|---|---|
tenant_cpu_usage_ratio | 租户实际用量 / 其配额 | > 85% 持续 30 分钟 |
tenant_throttled_seconds | CFS 限流累计时长 | 环比突增 3 倍 |
tenant_p99_latency | 按租户切分的接口延迟 | 与自身 7 天基线比 > 2 倍 |
tenant_error_budget_burn | 租户级错误预算燃烧率 | 1 小时燃烧 > 5% |
关键设计:告警必须能回答"是哪个租户在影响别人",而不只是"集群 CPU 高"。这要求从 Pod 到 namespace 到租户的三级映射在指标里始终是完整标签——tenant 标签一旦缺失,事后只能靠猜。
十一、三云托管能力对比:三朵云都没有"租户"这个一等公民
| 维度 | AWS(EKS) | 阿里云(ACK) | 腾讯云(TKE) |
|---|---|---|---|
| 原生租户对象 | ❌ 无(namespace + RBAC) | ❌ 无 | ❌ 无 |
| 命名空间级配额 | ✅ ResourceQuota | ✅ ResourceQuota | ✅ ResourceQuota |
| 网络策略 | ✅ 需 CNI 支持(VPC CNI 需启用相应能力,或装 Calico/Cilium) | ✅ Terway 支持 NetworkPolicy | ✅ 需确认网络插件能力(GlobalRouter 常需自装 Cilium/Calico) |
| 虚拟集群 | ✅ 可自建 vCluster(OSS) | ✅ 可自建 vCluster | ✅ 可自建 vCluster |
| 多集群统一视图 | ✅ EKS + Argo CD / Fleet 类方案 | ✅ ACK One 多集群管理 | ✅ TKE 集群联邦/多集群管理 |
| 租户级成本归因 | ✅ 分账数据 + OpenCost/Kubecost | ✅ ACK 成本洞察(按命名空间) | ✅ TKE 成本洞察(账单下钻) |
| 审计日志含租户线索 | ✅ CloudTrail + 请求级日志 | ✅ ActionTrail | ✅ CloudAudit |
| Pod 安全基线 | ✅ Pod Security Admission | ✅ 安全策略/PSP 替代方案 | ✅ 类似机制 |
| 内核级隔离 | ✅ 支持 gVisor/Kata(自管节点) | ✅ 支持 sandboxed 容器 | ✅ 支持 |
三条结论:
- 别等云厂商给你"租户"对象。三家的能力上限都是"namespace + 配额 + 策略",租户这层抽象必须你自己建(Capsule/vCluster/自研 operator 三选一)。
- 网络策略的可用性取决于网络插件,这是三云最容易踩差异的地方。上线前必须在测试集群用一条
default-deny+ 一个探针 Pod 实测,别信文档描述。 - 审计日志要能把操作映射到租户。三云的审计都只到"谁调了什么 API",租户归属需要你在标签或资源命名里带出来,否则出事时无法回答"这个变更影响了哪些客户"。
十二、成本:多租户的账要算"隔离溢价"
隔离不是免费的,但也不是线性的。用一组示意算例说明量级(单价按站内 10-14 篇的折算口径派生,仅作口径演示,不构成报价):
| 方案 | 控制面成本 | 每租户增量 | 100 租户月成本量级 |
|---|---|---|---|
| 共享 namespace | 0(复用集群) | ≈ 0 | 仅业务负载 |
| vCluster(每租户一个) | 0(OSS) | 约 0.1~0.2 vCPU / 128~256 MiB(经验区间,需实测) | 约 10~20 vCPU 额外开销 |
| 独立集群(每租户一个) | EKS 控制面 0.10 USD/小时 ≈ 73 USD/月 | 至少 1 个 2 vCPU/4 GB 节点 ≈ 51 USD/月(0.029×2 + 0.003×4 = 0.070 USD/小时 × 730) | ≈ 12,400 USD/月(仅控制面 + 最小节点) |
由表可得三条判断:
- Silo 档的成本几乎全部由"最小可用规格"决定,而不是由负载决定——100 个租户就是 100 份控制面费用,跟客户用了多少无关。这就是为什么 Silo 档必须对应高客单价。
- vCluster 的溢价主要是"每租户一份控制面副本"的资源,通常在个位数百分比,是性价比最高的中间档。
- 共享 namespace 的隐性成本在别处:噪声邻居导致的 SLA 赔付、越权事故的处置成本、以及"一个租户要独立部署时重构"的一次性投入。把这些算进去,很多团队会发现自己"省下来的钱"其实没省。
分账落地接 10-14 的方案:OpenCost/Kubecost 按 namespace 聚合,把 tenant 标签作为第一聚合维度,再叠加"共享组件(Ingress、监控、日志)按用量比例分摊"的规则——不分摊共享组件,租户看到的成本会系统性偏低。
十三、合规视角:删除、审计与举证
多租户把三个合规动作的难度放大了:
| 动作 | 难点 | 做法 |
|---|---|---|
| 租户数据删除(DSAR/注销) | 数据散在库、对象存储、备份、日志、缓存五处 | 建"租户数据地图",删除清单化并可验证;备份走"到期自然过期 + 记录留存期" |
| 审计举证 | 需证明"技术上做不到跨租户访问" | 保留 RLS 策略、NetworkPolicy、IAM 策略的版本化证据 + 渗透测试报告 |
| 数据驻留 | 不同租户要求不同地域 | 租户目录里记录 home_region,写入路径按它路由(接 10-01 数据主权篇) |
最容易被忽略的是备份:删了主库的记录,备份里还在。合规上通常可接受"备份在留存期内不可检索、到期自动销毁",但必须在隐私政策里写明,否则就是"承诺删除但没删干净"。
十四、90 天落地路线
| 阶段 | 时间 | 交付物 | 验收标准 |
|---|---|---|---|
| 一、定边界 | 第 1~2 周 | 租户分档表 + 四层隔离基线文档 | 能回答"某客户属于哪档、每层用什么机制" |
| 二、堵致命口子 | 第 3~5 周 | JWT tenant claim + RLS(含 FORCE)+ 默认拒绝网络策略 | 三条数据层验收标准全过;无 where 的 SQL 返回 0 行 |
| 三、建自助能力 | 第 6~9 周 | 配额模板 + Capsule/vCluster + 租户开通 PR 流水线 | 新租户开通 ≤ 30 分钟,全流程可审计 |
| 四、可观测与分账 | 第 10~12 周 | 租户维度四指标 + 成本按租户聚合 | 任一告警能定位到具体租户;账单能出到租户级 |
15 分钟验收四问(拿这四个问题去问团队,答不上来就还没做完):
- 用一个没有
tenant_id条件的查询,能拿到别人的数据吗? - 客户端伪造
X-Tenant-Id会发生什么? - 某个租户把 CPU 打满,其他租户的 p99 会掉多少?有数据吗?
- 客户要求"删掉我们全部数据",你要动几个系统、多久能给出证明?
总结
多租户架构的难点从来不是"怎么加 tenant_id",而是四条边界要同时成立、且隔离等级要和客单价匹配:
- 计算:Quota 管总量、LimitRange 管单体,vCluster 解决"租户要自己的 Kubernetes",但别把它当内核隔离。
- 网络:先默认拒绝,再显式放行——记得放行 DNS,这一条能省掉一半的线上排障。
- 数据:RLS 是兜底不是替代,
FORCE与SET LOCAL是两个决定成败的开关。 - 身份:只信已验签的 claim,永远不信客户端传来的租户标识。
最后一句:隔离做得好不好,不看架构图画得多漂亮,看"把 where 条件删掉"和"伪造一个租户 ID"这两件事发生时,系统会不会兜住你。
🚀 企业出海需要云架构咨询?通过 7.chengzicloud.cloud 联系我们,获取专属方案。我们提供 SaaS 多租户隔离架构设计、vCluster 与配额体系落地、行级安全与跨租户越权测试、租户级成本分账与 90 天实施路线的一站式咨询与实施服务。
相关阅读
- 多云单元化架构与韧性设计 — 租户是分片维度,单元是故障域,两者叠加才是完整的影响面控制
- 多云管理平台 CMP 与自助服务目录 — 平台自身的多租户权限模型,与本文的产品侧隔离互补
- 容器运行时安全加固与沙箱隔离 — 内核级隔离,多租户的最后一道物理边界
- 多云 Kubernetes 成本治理(Kubecost/OpenCost) — 把成本归因对象从团队换成租户,用于分账
- 多云零信任网络架构 — 身份到流量的持续验证,本文取其租户边界一层
- 多云数据治理(元数据/血缘/质量/网格) — 数据的"治理面",本文讲的是"归属与可见性"
本文由 7.chengzicloud.cloud 提供,点击访问首页了解更多
常见问题(FAQ)
Q1:共享表 + tenantid 到底安不安全?
在正确配置 RLS 且应用账号非 owner/非超级用户的前提下,它是安全的,也是十万级租户唯一经济可行的方案。不安全的是"仅靠应用层写 where"——那种方案在生产上必然出一次事故,只是时间问题。判断标准很简单:把 where 删掉,看数据库会不会兜住你。
Q2:vCluster 和独立集群,隔离强度差在哪?
差在内核与节点。vCluster 隔离的是 API Server、CRD、RBAC 与对象视图,Pod 仍共享宿主节点内核与网络数据面。所以它能解决"租户要装自己的 CRD""租户要 cluster-admin"这类需求,但解决不了"客户要求内核级隔离"或"客户要求专属节点"。后者只有独立集群(或沙箱运行时 + 专属节点池)能做到。
Q3:schema-per-tenant 什么时候值得用?
三个条件同时成立时值得:租户数在数十到数百之间、单租户数据量差异大(大租户单独索引有实际收益)、且你能接受 DDL 扇出。如果租户数会到几千,schema 数量会让 pg_dump、连接数、catalog 查询都变慢,那时候共享表 + RLS 更合适。
Q4:用了 RLS 还需要在代码里写 where 吗?
要写,但目的变了:RLS 是兜底,代码里的 where 是性能。没有 where 时 RLS 会先用 tenant_id 过滤,但查询计划可能退化成全表扫再过滤,索引用不上。正确写法是两者都有,并在 CI 里加一条检查:高风险表的查询必须带租户条件。
Q5:小团队只有几十个客户,要不要一上来就做四层隔离?
不必全做,但两件事必须第一天就有:一是从入口注入的 tenant_id(且写进所有表),二是每张业务表上的 tenant_id 索引。这两件事后补的成本极高(要改所有查询和所有历史数据),其余三层都可以随客户增长逐步加。判断顺序:先堵越权(身份 + 数据),再做噪声邻居(配额),最后才是虚拟集群这类体验型隔离。
Q6:租户级限流放在哪一层做?
放在网关/入口层,不要放在业务代码里。原因:业务代码里的限流在实例扩容后失效(每实例各限一份),而网关是流量的唯一入口。实现上用"租户维度 + 接口维度"两级令牌桶,租户维度给总量,接口维度给热点保护。已有的 API 网关(10-05 篇)就带这个能力,直接复用比自研划算。
Q7:多云部署时,租户的"家"放在哪?
在租户目录里显式记录 home_region 与 home_cloud,并让它成为写入路径的唯一依据。跨云读可以就近,跨云写不行——跨云写会带来一致性、延迟与跨境流量费三重问题(10-09 缓存篇已量化过跨境流量费)。租户迁移是"数据搬迁 + 目录改指向 + DNS 切换"三件事,别指望只改一个配置项。
Q8:怎么向客户证明"我们真的隔离了"?
三样东西:一是技术证据(RLS 策略、NetworkPolicy、IAM 策略的版本化 diff),二是测试证据(每次发布跑的跨租户越权测试用例,含"用 A 的 token 访问 B 的资源应返回 404 而非 403"),三是第三方证据(年度渗透测试报告)。注意越权应返回 404 而不是 403——403 会泄露"这个资源存在",这本身也是一条信息泄露。