ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AWS SaaS平台架构实战:多租户隔离、CDK部署与套餐计费

2026/9/23 15:25:08 拓冰建站 浏览量
AWS SaaS平台架构实战:多租户隔离、CDK部署与套餐计费 简介这份PPT资料面向正在或计划将产品转型为SaaS模式的独立软件供应商、架构师与技术决策者系统梳理了基于AWS构建SaaS平台的整体架构思路与关键设计要点。内容围绕为何选择SaaS、为何AWS适合承载SaaS展开深入讲解身份管理、多租户的Silo/Bridge/Pool三种模式、应用分层隔离与数据隔离策略并覆盖管理监控、测量计费、DevOps业务敏捷性以及大数据、物联网、人工智能等AWS服务在SaaS场景中的落地方式。资源包内含1个pptx文件约1.21MB以架构图与要点式幻灯片呈现便于快速理解整体框架并用于内部方案讨论。目前已有143人学习适合需要搭建多租户SaaS平台、评估AWS技术选型或准备架构分享的读者参考借鉴。1. 从一张 PPT 到能跑的 SaaSAWS 平台架构到底在解决什么很多团队第一次画 SaaS 平台架构都是在 PPT 里堆方块一个负载均衡、几台 EC2、一个 RDS、一个 S3箭头一连看起来就完事了。真到要上线多租户、要按套餐计费、要做数据隔离的时候才发现这张图根本落不了地。基于 AWS 的 SaaS 平台架构核心要回答的其实就三件事租户怎么隔离、资源怎么按套餐弹性伸缩、成本怎么摊到每个租户头上。它适合正在从单体应用往多租户转型的后端和架构同学也适合需要给老板讲清楚「为什么不能一套代码复制十份」的技术负责人。这篇笔记不讲空泛的云原生概念而是把一张架构 PPT 拆成能动手复现的模块从租户模型选型一路讲到计费埋点和踩坑排查。2. 租户隔离模型选型池化、桥接还是竖井2.1 三种隔离模型在 AWS 上的真实成本差异SaaS 平台架构的第一刀永远砍在租户隔离上。业内常见三种模型池化Pool、桥接Bridge、竖井Silo。池化是所有租户共享同一套计算和存储资源靠 tenant_id 字段做逻辑隔离竖井是每个租户一套独立资源栈桥接介于两者之间比如共享计算但独立数据库。在 AWS 上这三种模型的成本差异非常直观。池化模型下你跑一个 ECS 服务或者 EKS 集群所有租户的请求打进来应用层根据 JWT 里的 tenant_id 路由到对应数据。竖井模型下你得为每个租户创建独立的 CloudFormation Stack包含独立的 EC2、RDS、S3 Bucket。桥接模型则可能是共享 ECS 但每个租户一个 RDS Schema 或者一个 DynamoDB 表。我一般会建议早期 SaaS 从池化起步因为租户数量少的时候竖井的运维成本会吃掉你所有利润。一个租户一套 RDS哪怕是最小的 t4g.micro一个月也是十几美元一百个租户就是一千多美元而池化模型下一台 m6g.large 可能就扛住了。但池化不是银弹当某个租户的数据量或者请求量暴涨时会拖垮整个池子这就是所谓的「吵闹邻居」问题。选型时还要考虑合规要求。金融、医疗类租户往往要求数据物理隔离这时候竖井或者桥接就是硬性要求。我的经验是先做池化但在数据模型和路由层预留 tenant_id 的强隔离能力等大客户来了再平滑迁移到桥接或竖井。下面这张表是我在实际项目中用来做决策的参考维度池化桥接竖井单租户成本极低中高隔离强度逻辑隔离逻辑部分物理物理隔离运维复杂度低中高吵闹邻居风险高中无合规适配一般较好最好适合阶段MVP 到成长期成长期到成熟期大客户定制2.2 用 JWT 声明和中间件实现租户上下文注入选完模型下一步就是让每个请求都能带上租户身份。常见做法是在 API Gateway 或者 ALB 后面挂一个 Lambda Authorizer解析 JWT 里的 tenant_id 和 tier 字段然后把租户上下文透传给后端服务。后端服务用一个中间件把 tenant_id 塞进请求上下文后续所有数据库操作都从这个上下文里取 tenant_id而不是从请求参数里取。下面是一个 Node.js Express 中间件的例子假设 JWT 已经由上游验证过tenant_id 放在x-tenant-id头里// tenantContext.js // 从请求头提取租户ID和套餐等级注入到请求上下文 function tenantContext(req, res, next) { const tenantId req.headers[x-tenant-id]; const tier req.headers[x-tenant-tier] || basic; if (!tenantId) { return res.status(400).json({ error: missing tenant id }); } // 把租户信息挂到 req 上后续 handler 直接用 req.tenant { id: tenantId, tier: tier, // 根据套餐决定连接池或者 schema dbSchema: tenant_${tenantId} }; next(); } module.exports tenantContext;这段代码的逻辑很直白所有下游 handler 不再信任请求体里的 tenant_id只信任经过验证的头部。参数说明x-tenant-id由 API Gateway 的 Authorizer 注入外部请求无法伪造x-tenant-tier用来做后续的限流和功能开关。如果你用的是 Python FastAPI思路一样用依赖注入把 tenant 对象塞进路由函数。注意千万不要在业务代码里从 query string 或者 body 里读 tenant_id这是多租户系统最常见的越权漏洞来源。2.3 数据层隔离Row Level Security 与 Schema 切换数据层是隔离的最后一公里。池化模型下PostgreSQL 的 Row Level SecurityRLS是一个被低估的利器。你可以在每张表上启用 RLS然后创建一个策略让当前会话只能看到tenant_id current_setting(app.tenant_id)的行。这样即使业务代码忘了加 where 条件数据库层也会兜底。-- 在 PostgreSQL 中启用行级安全 ALTER TABLE orders ENABLE ROW LEVEL SECURITY; -- 创建策略只允许访问当前租户的数据 CREATE POLICY tenant_isolation ON orders USING (tenant_id current_setting(app.tenant_id)::uuid); -- 应用连接后设置当前租户 SET app.tenant_id a1b2c3d4-...;逻辑说明current_setting读取的是会话级别的变量每个请求进来后由连接池或者中间件设置。参数上要注意app.tenant_id这个变量名可以自定义但必须在连接初始化时设置否则查询会报错。RLS 的性能开销在大多数场景下可以接受但如果你的表有几十亿行建议在 tenant_id 上建索引并且考虑分区表。桥接模型下常见做法是每个租户一个 Schema用search_path切换。竖井模型下每个租户一个独立数据库实例连接信息存在 DynamoDB 或者 Secrets Manager 里应用根据 tenant_id 动态获取。这三种数据层方案没有绝对优劣关键看你的租户规模和合规要求。3. 用 CDK 把多租户基础设施写成代码3.1 为什么选 CDK 而不是手点控制台SaaS 平台架构的第二个难点是基础设施的重复创建。如果你用竖井或者桥接模型每来一个新租户就要创建一套资源。手点控制台不仅慢而且容易漏配安全组或者 IAM 角色。AWS CDK 的好处是用 TypeScript 或者 Python 把基础设施定义成代码新租户上线就是跑一次cdk deploy参数化 tenant_id 就行。我一般会把租户的基础设施拆成两个 Stack一个是共享层包含 VPC、ALB、ECS Cluster另一个是租户层包含该租户的 RDS、S3 Bucket、Secrets。共享层只部署一次租户层按需部署。下面是一个 Python CDK 的片段展示如何为一个新租户创建独立的 S3 Bucket 和 RDS 实例# tenant_stack.py from aws_cdk import ( Stack, aws_s3 as s3, aws_rds as rds, aws_ec2 as ec2, RemovalPolicy, ) from constructs import Construct class TenantStack(Stack): def __init__(self, scope: Construct, tenant_id: str, vpc: ec2.Vpc, **kwargs): super().__init__(scope, fTenant-{tenant_id}, **kwargs) # 每个租户独立的 S3 Bucket命名带 tenant_id bucket s3.Bucket( self, TenantBucket, bucket_namefsaas-tenant-{tenant_id}, removal_policyRemovalPolicy.RETAIN, # 防止误删 encryptions3.BucketEncryption.S3_MANAGED, ) # 每个租户独立的 RDS 实例放在共享 VPC 的私有子网 db rds.DatabaseInstance( self, TenantDB, enginerds.DatabaseInstanceEngine.postgres( versionrds.PostgresEngineVersion.VER_15 ), instance_typeec2.InstanceType.of( ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO ), vpcvpc, vpc_subnetsec2.SubnetSelection(subnet_typeec2.SubnetType.PRIVATE_ISOLATED), database_nameftenant_{tenant_id}, removal_policyRemovalPolicy.SNAPSHOT, # 删除时保留快照 )逻辑说明这个 Stack 接收一个tenant_id和共享 VPC然后创建独立的 Bucket 和 RDS。参数上RemovalPolicy.RETAIN和SNAPSHOT是血泪经验——曾经有一次误删 Stack 导致租户数据丢失后来所有生产资源都加了保留策略。instance_type可以根据租户套餐动态传入比如 basic 用 t4g.micropro 用 m6g.large。3.2 用 CDK Context 和参数表管理租户配置租户多了以后不能每次部署都改代码。CDK 的 Context 机制可以让你从cdk.context.json或者环境变量里读取租户列表然后循环生成 Stack。更工程化的做法是把租户配置存在 DynamoDB 里CDK 部署时从表里读。# app.py import aws_cdk as cdk from tenant_stack import TenantStack app cdk.App() # 从 context 读取租户列表格式[tenant-a, tenant-b] tenant_ids app.node.try_get_context(tenant_ids) or [] # 共享 VPC 的 ID 从环境变量读取 vpc_id app.node.try_get_context(vpc_id) for tid in tenant_ids: TenantStack(app, tid, vpc_idvpc_id)参数说明try_get_context会依次从命令行-c tenant_ids...、cdk.context.json、环境变量里找。部署时用cdk deploy --all -c tenant_ids[a,b]就能批量创建。注意租户 ID 要符合 CloudFormation 的命名规范不能有下划线和大写字母我一般统一用短横线小写。提示CDK 的 Stack 数量有默认限制租户超过 200 个时建议改用 Terraform 或者自研控制器否则 CloudFormation 的 API 限流会让你很头疼。3.3 共享层与租户层的网络打通共享层和租户层之间的网络打通是另一个容易翻车的地方。共享 VPC 里的 ECS 任务需要访问租户 RDS但租户 RDS 在私有隔离子网里默认没有路由。常见做法是用 VPC Peering 或者 Transit Gateway但更简单的方案是把租户 RDS 放在共享 VPC 的私有子网里通过安全组引用来放行流量。# 在共享层导出安全组 shared_sg ec2.SecurityGroup(self, SharedSG, vpcvpc) # 在租户层允许共享层安全组访问 RDS db.connections.allow_from(shared_sg, ec2.Port.tcp(5432))这段代码的关键是allow_from它会自动在 RDS 的安全组上添加入站规则源是共享层安全组的 ID。参数上端口 5432 是 PostgreSQL 默认端口如果你用的是 MySQL 就改成 3306。这样配置的好处是安全组引用不依赖 IP 地址共享层扩容或者换 IP 都不影响。4. 套餐计费与用量采集把成本摊到每个租户4.1 用量事件从哪来、怎么存SaaS 平台架构如果只谈技术不谈钱就是耍流氓。套餐计费的核心是「用量采集」每个租户用了多少 API 调用、多少存储、多少计算时长。这些数据必须从第一天就开始采集否则后面补埋点会非常痛苦。常见做法是在 API Gateway 上开启访问日志把请求日志推到 Kinesis Data Firehose再落到 S3。然后用 Lambda 或者 Glue 做 ETL把原始日志聚合成每小时的用量记录存到 DynamoDB 或者 Timestream。下面是一个 Lambda 函数的片段从 Kinesis 记录里解析 tenant_id 和请求路径写入 DynamoDB# usage_collector.py import json import boto3 from datetime import datetime dynamodb boto3.resource(dynamodb) table dynamodb.Table(TenantUsage) def handler(event, context): for record in event[Records]: # Kinesis 数据是 base64 编码的 payload json.loads( boto3.client(kinesis).get_record(...) # 简化示意 ) tenant_id payload.get(tenantId) path payload.get(path) timestamp datetime.utcnow().strftime(%Y-%m-%dT%H:00:00Z) # 按租户小时维度累加调用次数 table.update_item( Key{tenantId: tenant_id, hour: timestamp}, UpdateExpressionADD apiCalls :inc, ExpressionAttributeValues{:inc: 1} )逻辑说明每条 Kinesis 记录代表一次 API 调用Lambda 按 tenant_id 和小时维度做原子累加。参数上ADD是 DynamoDB 的原子操作适合计数器场景hour字段用 ISO 格式方便后续按时间范围查询。注意 DynamoDB 的写入容量要提前预估大促期间用量事件可能暴涨。4.2 套餐限额在 API Gateway 和 Lambda 上怎么落地采集了用量下一步是执行套餐限额。比如 basic 套餐每月 10000 次 API 调用pro 套餐 100000 次。常见做法是在 API Gateway 上配置 Usage Plan 和 API Key每个租户一个 Key超过限额直接返回 429。但 API Gateway 的 Usage Plan 是按 Key 限流的如果你的租户有多个用户需要把 Key 和 tenant_id 做映射。更灵活的做法是在 Lambda Authorizer 里查 DynamoDB 的用量表如果当月用量超过套餐上限直接拒绝请求。下面是一个 Authorizer 的伪代码# authorizer.py def handler(event, context): tenant_id event[headers][x-tenant-id] tier event[headers][x-tenant-tier] # 查当月用量 usage table.get_item( Key{tenantId: tenant_id, month: current_month()} ).get(Item, {}) limit TIER_LIMITS[tier] # {basic: 10000, pro: 100000} if usage.get(apiCalls, 0) limit: raise Exception(quota exceeded) # API Gateway 返回 403 return generate_policy(tenant_id, Allow, event[methodArn])参数说明TIER_LIMITS是一个字典把套餐等级映射到限额。current_month()返回YYYY-MM格式。注意 Authorizer 的缓存时间要设短一点比如 60 秒否则超额后还有缓存窗口。这个方案比 API Gateway Usage Plan 灵活因为限额逻辑完全可控还能做套餐升级后的即时生效。4.3 成本分摊用 Cost Allocation Tag 做租户级账单技术上的用量采集是一回事财务上的成本分摊是另一回事。AWS 的 Cost Explorer 支持按标签分摊成本你可以在所有租户资源上打tenant_id标签然后在 Billing 里按标签查看每个租户的实际成本。CDK 里可以用Tags.of(stack).add(tenant_id, tid)批量打标签。但标签不是万能的共享资源比如 ALB、NAT Gateway的成本无法直接归属到某个租户。常见做法是按用量比例分摊比如按每个租户的 API 调用次数占比来分摊 ALB 的成本。这部分逻辑可以放在 Lambda 里每月跑一次生成内部账单。注意Cost Allocation Tag 需要在 Billing 控制台手动激活而且激活前的历史数据不会追溯。建议项目第一天就把标签规范定好。5. 多租户 SaaS 上线的避坑与排查清单5.1 租户 ID 泄漏导致越权访问现象A 租户的用户能查到 B 租户的订单数据。原因某个查询接口忘了加 tenant_id 过滤条件或者从请求参数里读了 tenant_id。解决所有数据库查询强制走 RLS 或者 ORM 的全局过滤器代码审查时把「没有 tenant_id 条件的查询」列为阻断项。我一般会在 CI 里加一个静态检查扫描 SQL 语句里有没有WHERE tenant_id。5.2 吵闹邻居拖垮整个池子现象一个租户突然发起大量请求其他租户的响应时间从 200ms 飙到 5s。原因池化模型下没有做租户级限流。解决在 API Gateway 或者应用层做 per-tenant 限流用 Redis 的令牌桶或者 DynamoDB 的原子计数器。参数上basic 套餐给 100 QPSpro 给 1000 QPS超过就返回 429。同时监控每个租户的 P99 延迟发现异常租户及时隔离。5.3 CDK 部署时 CloudFormation 卡在 UPDATE_ROLLBACK现象新租户部署失败Stack 卡在 UPDATE_ROLLBACK_COMPLETE无法删除也无法更新。原因通常是 RDS 或者 S3 的删除策略配置不当比如 RDS 有删除保护或者 S3 Bucket 非空。解决部署前用cdk diff检查变更RDS 设置RemovalPolicy.SNAPSHOTS3 设置auto_delete_objectsTrue仅限非生产环境。生产环境保留策略要人工确认。5.4 用量采集延迟导致计费不准现象租户当月用量已经超了但系统没有及时限流月底账单对不上。原因Kinesis 到 DynamoDB 的 ETL 有延迟或者 Lambda 并发不够导致积压。解决监控 Kinesis 的 IteratorAge 指标超过 60 秒就告警Lambda 预留并发设大一点计费系统用「预扣结算」模式请求进来先扣一个预估值异步修正。5.5 跨区域部署时的数据同步问题现象租户在 us-east-1 写入的数据在 eu-west-1 读不到。原因SaaS 平台做了多区域部署但数据层没有做跨区域复制。解决如果合规允许用 Aurora Global Database 做跨区域复制如果不允许数据出境就在每个区域独立部署一套租户注册时绑定区域后续请求路由到对应区域。6. 用合成流量验证多租户隔离是否真的生效架构搭完、坑也踩过之后最后一件事是验证。我习惯在每次大版本上线前跑一轮合成流量测试模拟多个租户并发读写然后检查三件事数据有没有串、限额有没有生效、成本标签有没有打上。具体做法是用 Locust 或者 k6 写一个脚本为每个租户生成独立的 JWT然后并发调用核心接口。下面是一个 k6 的片段// tenant_isolation_test.js import http from k6/http; import { check } from k6; const tenants [tenant-a, tenant-b, tenant-c]; export default function () { tenants.forEach((tid) { const res http.get(https://api.example.com/orders, { headers: { x-tenant-id: tid, Authorization: Bearer ${__ENV[TOKEN_${tid.toUpperCase()}]}, }, }); // 检查返回的数据里没有其他租户的 ID check(res, { status is 200: (r) r.status 200, no cross-tenant data: (r) !r.body.includes(tenant-) || r.body.includes(tid), }); }); }逻辑说明每个虚拟用户轮流用三个租户的身份请求订单接口检查返回体里是否只包含自己的租户 ID。参数上__ENV从环境变量读取每个租户的 Token避免硬编码。这个测试跑 10 分钟如果no cross-tenant data的通过率不是 100%就说明隔离有问题。除了功能验证我还会用 AWS Cost Explorer 的标签报表核对每个租户的成本是否合理。如果某个租户的成本突然飙升要么是用量采集漏了要么是资源没有正确打标签。这一步经常能发现一些「幽灵资源」——比如某个租户的测试环境忘了关一直在跑 EC2。最后一个习惯每次新租户上线我都会手动跑一遍「租户上线检查清单」包括 RLS 是否启用、安全组是否最小权限、成本标签是否打上、限额是否配置。这个清单救过我很多次尤其是凌晨两点紧急上线的时候。希望帮到你。本文还有配套的精品资源点击获取