参考架构
示意多租户 SaaS 平台
一种 SaaS 架构,将控制平面与应用平面分离,并让租户身份贯穿从边缘到数据存储的每一层。
问题
设想一款销售给众多组织的软件产品,客户既有使用自助套餐的小团队,也有带着安全问卷和采购流程的大型企业。所有客户都期望自己的数据对其他任何客户不可见,而大客户还期望有单点登录、审计日志和可预期的性能。
为每个客户单独部署一套,容易理解,但运行和升级的成本很高。让所有客户共用一套部署,效率高,却把隔离、资源的公平使用和按客户配置都变成了工程问题。架构必须决定每一档客户处在这条光谱的哪个位置。
设计必须满足的需求
- 一个租户绝不能读取或修改另一个租户的数据,而且这一点必须在应用代码之下的层面强制执行。
- 新租户必须能够自动开通,不需要工程师执行手工步骤。
- 企业租户必须能够使用自己的身份提供方,并通过自动化的账号开通来管理用户。
- 一个租户的高负载使用不得拖累其他租户的体验。
- 功能、限额和计费必须由租户的套餐决定,并且无需部署即可调整。
- 用量、错误和性能必须能够归属到具体的租户。
设计
组件
租户感知的边缘
- 自定义域名
- 租户解析
- WAF
- 按租户限流
身份与 SSO
- SAML 与 OIDC
- SCIM 账号开通
- 角色与权限
- MFA
控制平面
只管理租户,从不承载其流量
- 租户生命周期
- 套餐与权益
- 计费与计量
- 功能开关
应用平面
- 无状态服务
- 后台任务
- 租户上下文
- Webhook
数据隔离
按套餐层级选择隔离模型
- 行级安全
- 每租户独立 Schema
- 专属数据库
- 每租户独立密钥
按租户的可观测性
- 用量指标
- 带租户标签的链路
- 吵闹邻居告警
- 审计日志
租户感知的边缘
边缘根据子域名、自定义域名或令牌解析请求属于哪个租户,并拒绝无法归属的请求。它按租户限流,使某个客户的流量突增被控制在其自身范围内。自定义域名及其证书会自动签发和续期。
身份与单点登录
身份层为较小的客户提供密码加 MFA 登录,为企业客户提供 SAML 或 OIDC 联合登录以及 SCIM 账号开通。角色和权限按租户定义。每个签发的令牌都以签名声明的形式携带租户标识,下游服务信任这一声明,而不采信任何由客户端提供的值。
控制平面
控制平面负责租户的生命周期:注册、开通、套餐变更、暂停和删除。它保存套餐和权益的目录,是每个租户可以使用哪些功能的事实来源。它与应用平面分开部署、分开扩展,因此一方发生故障不会拖垮另一方。
应用平面
无状态服务处理产品流量,并在每个请求开始时,根据经过验证的令牌一次性建立租户上下文。该上下文会传递到数据库会话、后台任务、缓存和对外发出的事件。工作队列经过分区,使某个租户的积压不会让其他租户的任务得不到处理。
数据隔离
标准租户共用一个 PostgreSQL 数据库,其中的行级安全策略把每一条查询限制在当前租户范围内。要求更严格的租户可以被放入专属的 Schema 或专属的数据库,由路由层负责选择。缓存键、对象存储路径和搜索索引都按租户加前缀或分区。
计量与计费
服务发出用量事件,这些事件按租户聚合为套餐定价所依据的各项数量。计费集成通过支付服务商把这些数量转化为账单,权益检查也读取同一份数据来执行限额。用量事件是幂等的,因此重试不会导致重复计费。
按租户的可观测性
每一行日志、每一个指标和每一条链路追踪都带有租户标识。这让支持人员可以看到某一个客户的使用体验,也让工程师能在其他客户察觉之前发现“吵闹的邻居”。安全相关操作的审计日志向每个租户的管理员开放。
取舍
设计决策
| 决策 | 理由 |
|---|---|
| 默认采用共享数据库加行级安全 | 每个租户一个数据库可以提供最强的隔离,但成本、迁移时间和运维工作量会随着每一个客户成倍增加。共享加行级安全让实例规模保持精简、升级保持简单,并依靠由数据库强制执行的策略,使应用代码中漏掉一个过滤条件不至于演变成数据泄露。 |
| 专属 Schema 或数据库作为一个档位提供,而不是基线 | 出于合同或数据驻留方面的原因,有些客户需要共享模式无法提供的隔离。支持不止一种隔离模型会增加一个路由层,并使迁移更复杂,因此只留给那些需求确实值得这样做的租户。 |
| 控制平面独立于应用平面 | 把租户管理并入产品服务,起步阶段构建更快。把它分离出来意味着要部署两套系统,换来的是开通、权益和计费有了明确的归属,可以按自己的节奏变更,出故障时也不影响客户流量。 |
| 租户上下文只从签名令牌中获取 | 从请求头或请求体接受租户标识很方便,却是跨租户访问缺陷的常见来源。只从经过验证的声明中获取,可以消除这一类错误,代价是一些内部工具和支持流程需要更有意识地设计。 |
| 权益在运行时判定,而不是按套餐分别部署 | 为不同套餐交付不同的构建版本,会使测试碎片化并拖慢发布。单一代码库加运行时权益检查,会在请求路径上增加一次查询,而这次查询有缓存,并且让套餐和限额可以不经发布就调整。 |
