有状态服务的混合多租户架构:在 AWS 上实现强隔离而不必逐租户开账号

2026-05-12 17 预计阅读时间:1 分钟
来源:aws.amazon.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:14 分钟

多租户架构的老问题:隔离做得太粗,一个租户的故障拖垮所有人;做得太细,每个租户单独开 AWS 账号,运维成本直线上升。AWS 最近分享了一种"混合"思路——在少数几个 AWS 账号内,用 ALB 规则、独立 ECS 集群和 PrivateLink 拼出接近账号级隔离的效果,同时把共享依赖集中管理。下面拆开看每个环节怎么做,并给出可直接改造的配置示例。

隔离光谱上的折中位置

多租户隔离通常分三档:

模式 隔离强度 运维复杂度 典型场景
Silo(每租户独立账号) 最强 最高 金融、医疗合规
Hybrid(共享账号 + 独立计算) 中强 中等 SaaS 平台、有状态服务
Pool(全共享) 最弱 最低 内部工具、轻量 API

混合模式的核心思路:网络层和计算层按租户隔离,数据层和控制面共享。这样既避免了一个租户的 ECS 任务挤占另一个的 CPU / 内存,又不用为每个租户维护一整套 VPC、IAM 和监控体系。

Route 53 加权路由:跨账号流量分配

当你在多个 AWS 账号间部署同一服务的不同租户实例时,第一步是把流量正确分发到对应账号。

Route 53 加权路由(Weighted Routing)配合多账号 DNS 记录,实现按权重或按租户标签分流:

# 在主账号中创建托管区(假设域名 saas.example.com)
aws route53 create-hosted-zone \
  --name saas.example.com \
  --caller-reference "tenant-routing-$(date +%s)"

# 为租户 A(账号 111111111111)创建加权记录
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1PA6795UKMFR9 \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "app-a.saas.example.com",
        "Type": "A",
        "AliasTarget": {
          "DNSName": "alb-account-a.us-east-1.elb.amazonaws.com",
          "EvaluateTargetHealth": true,
          "HostedZoneId": "Z215JYRZR1E"
        },
        "Weight": 100
      }
    }]
  }'

# 为租户 B(账号 222222222222)创建另一条加权记录
aws route53 change-resource-record-sets \
  --hosted-zone-id Z1PA6795UKMFR9 \
  --change-batch '{
    "Changes": [{
      "Action": "CREATE",
      "ResourceRecordSet": {
        "Name": "app-b.saas.example.com",
        "Type": "A",
        "AliasTarget": {
          "DNSName": "alb-account-b.us-east-1.elb.amazonaws.com",
          "EvaluateTargetHealth": true,
          "HostedZoneId": "Z215JYRZR1E"
        },
        "Weight": 100
      }
    }]
  }'

关键点:每条记录指向不同账号的 ALB,EvaluateTargetHealth 开启后,如果某个账号的 ALB 健康检查失败,Route 53 自动把流量切走——这比手工切换快得多。

如果租户数量多但不需要跨账号,也可以在单账号内用 简单路由 + ALB 规则 替代加权路由,后面会看到。

ALB 监听器规则:租户级路由的精细控制

流量到了 ALB 之后,需要按租户标识路由到对应的 ECS 目标组。ALB 监听器规则支持按 Host header、Path、Query string 甚至自定义 Header 匹配,这正好对应不同的租户识别策略。

# 创建 ALB 监听器(假设 ALB 已存在)
aws elbv2 create-listener \
  --load-balancer-arn arn:aws:elasticloadbalancing:us-east-1:111111111111:loadbalancer/app/hybrid-alb/50dc6c495ef0ad96 \
  --protocol HTTPS \
  --port 443 \
  --default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:us-east-1:111111111111:targetgroup/default-tg/1234567890

# 为租户 A 添加基于 Host header 的路由规则
aws elbv2 create-rule \
  --listener-arn arn:aws:elasticloadbalancing:us-east-1:111111111111:listener/app/hybrid-alb/50dc6c495ef0ad96/abcd1234 \
  --priority 1 \
  --conditions Field=host-header,Values='tenant-a.saas.example.com' \
  --actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:us-east-1:111111111111:targetgroup/tenant-a-tg/9876543210

# 为租户 B 添加基于自定义 Header 的路由规则
aws elbv2 create-rule \
  --listener-arn arn:aws:elasticloadbalancing:us-east-1:111111111111:listener/app/hybrid-alb/50dc6c495ef0ad96/abcd1234 \
  --priority 2 \
  --conditions Field=http-header,HttpHeaderConfig={HeaderName=X-Tenant-Id,Values='tenant-b'} \
  --actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:us-east-1:111111111111:targetgroup/tenant-b-tg/5678901234

两种常见租户识别方式:

  • 子域名模式tenant-a.saas.example.com → Host header 匹配。适合租户有独立入口的场景。
  • Header / Path 模式X-Tenant-Id: tenant-b/b/api/... → Header / Path 匹配。适合统一入口、API 网关后端。

优先级数字越小越先匹配,确保租户规则排在默认规则前面。

每租户独立 ECS 集群:计算层隔离的核心

共享 ECS 集群里,一个租户的 CPU 密集型任务会挤压其他租户——这对有状态服务尤其致命(数据库连接池、缓存预热、长连接都在争资源)。混合架构的做法:每个租户一个 ECS 集群,共享账号内隔离计算资源。

# 为租户 A 创建独立 ECS 集群
aws ecs create-cluster \
  --cluster-name tenant-a-cluster \
  --tags key=TenantId,value=tenant-a

# 为租户 B 创建独立 ECS 集群
aws ecs create-cluster \
  --cluster-name tenant-b-cluster \
  --tags key=TenantId,value=tenant-b

集群创建后,用 Capacity Provider 把 Auto Scaling Group 绑定到对应集群,实现按租户独立扩缩容:

# 创建 ASG 并关联到租户 A 的集群
aws autoscaling create-auto-scaling-group \
  --auto-scaling-group-name tenant-a-asg \
  --min-size 2 --max-size 10 --desired-capacity 3 \
  --vpc-zone-identifier "subnet-aaa1,subnet-aaa2" \
  --launch-template LaunchTemplateId=lt-0abc1234,Version=1

# 创建 Capacity Provider 并关联
aws ecs create-capacity-provider \
  --name tenant-a-cp \
  --auto-scaling-group-provider autoScalingGroupArn=arn:aws:autoscaling:us-east-1:111111111111:autoScalingGroup:tenant-a-asg,managedScaling=status=ENABLED,targetCapacityPercent=80

# 把 Capacity Provider 关联到集群
aws ecs put-cluster-capacity-providers \
  --cluster tenant-a-cluster \
  --capacity-providers tenant-a-cp \
  --default-capacity-provider-strategy capacityProvider=tenant-a-cp,weight=1,base=1

这样租户 A 的任务只跑在租户 A 的 EC2 实例上,扩缩容也只看租户 A 的负载。租户 B 完全独立,互不干扰。

有状态服务通常依赖数据库、缓存、消息队列等共享组件。这些组件放在一个"共享服务"账号里,各租户账号通过 AWS PrivateLink(VPC Endpoint Service)访问,流量全程走 AWS 内网,不经过公网。

# 在共享服务账号中:为 RDS / ElastiCache 创建 NLB(PrivateLink 要求 NLB 作为前端)
aws elbv2 create-load-balancer \
  --name shared-services-nlb \
  --type network \
  --subnets subnet-shared1 subnet-shared2

# 创建 VPC Endpoint Service,暴露给租户账号
aws ec2 create-vpc-endpoint-service-configuration \
  --network-load-balancer-arns arn:aws:elasticloadbalancing:us-east-1:999999999999:loadbalancer/net/shared-services-nlb/abc123 \
  --acceptance-required True \
  --private-dns-name shared-db.saas.internal

# 在租户 A 账号中:创建 Interface VPC Endpoint 连接到共享服务
aws ec2 create-vpc-endpoint \
  --vpc-endpoint-type Interface \
  --vpc-id vpc-tenant-a \
  --service-name com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0 \
  --subnet-ids subnet-aaa1 subnet-aaa2 \
  --security-group-ids sg-tenant-a-endpoint

--acceptance-required True 意味着共享服务账号需要手动批准连接请求,这给了你一层访问控制——只有审核通过的租户才能连上来。

# 共享服务账号:批准租户 A 的连接请求
aws ec2 accept-vpc-endpoint-connections \
  --service-id vpce-svc-0123456789abcdef0 \
  --vpc-endpoint-ids vpce-0aaa1111bbb2222

PrivateLink 的好处:租户 VPC 不需要和共享 VPC 做 VPC Peering,没有 CIDR 冲突风险,也不需要路由表维护。每个租户只看到自己被授权的服务端点。

一套可改造的 Terraform 示例

把上面各环节串起来,以下是一个最小可运行的 Terraform 模块骨架。实际部署前需要替换 var 中的 VPC ID、子网 ID 和镜像地址。

# tenant-ecs-cluster.tf — 每租户独立 ECS 集群 + ALB 规则 + PrivateLink

variable "tenant_id" {
  description = "租户标识,如 tenant-a"
  type        = string
}

variable "vpc_id" {
  type = string
}

variable "subnet_ids" {
  type = list(string)
}

variable "shared_vpce_service_name" {
  description = "共享服务的 VPC Endpoint Service 名称"
  type        = string
}

variable "alb_listener_arn" {
  type = string
}

variable "container_image" {
  description = "有状态服务的容器镜像"
  type        = string
}

# ── ECS 集群 ──
resource "aws_ecs_cluster" "tenant" {
  name = "${var.tenant_id}-cluster"
  tags = { TenantId = var.tenant_id }
}

# ── 目标组 ──
resource "aws_lb_target_group" "tenant" {
  name     = "${var.tenant_id}-tg"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = var.vpc_id
  health_check {
    path = "/health"
    matcher = "200"
  }
}

# ── ALB 监听器规则:按 Host header 路由 ──
resource "aws_lb_listener_rule" "tenant" {
  listener_arn = var.alb_listener_arn
  priority     = 10  # 按租户数量分配不同优先级

  condition {
    host_header {
      values = ["${var.tenant_id}.saas.example.com"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.tenant.arn
  }
}

# ── ECS 任务定义 ──
resource "aws_ecs_task_definition" "stateful_app" {
  family                   = "${var.tenant_id}-stateful-app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["EC2"]
  cpu                      = "512"
  memory                   = "1024"

  container_definitions = jsonencode([
    {
      name      = "app"
      image     = var.container_image
      essential = true
      portMappings = [{ containerPort = 8080, protocol = "tcp" }]
      environment = [
        { name = "TENANT_ID", value = var.tenant_id },
        { name = "DB_HOST",   value = "shared-db.saas.internal" }  # PrivateLink 私有域名
      ]
      # 有状态服务建议设置较长的健康检查间隔
      healthCheck = {
        command     = ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
        interval    = 30
        timeout     = 5
        retries     = 3
        startPeriod = 60  # 给预热留足时间
      }
    }
  ])
}

# ── PrivateLink Endpoint:连接共享数据库 ──
resource "aws_vpc_endpoint" "shared_db" {
  vpc_id             = var.vpc_id
  service_name       = var.shared_vpce_service_name
  vpc_endpoint_type  = "Interface"
  subnet_ids         = var.subnet_ids
  security_group_ids = [aws_security_group.endpoint.id]
}

resource "aws_security_group" "endpoint" {
  name   = "${var.tenant_id}-vpce-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [var.vpc_id]  # 仅允许 VPC 内流量
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

使用方式:

# 为租户 A 部署
terraform apply \
  -var='tenant_id=tenant-a' \
  -var='vpc_id=vpc-0aaa1111' \
  -var='subnet_ids=["subnet-aaa1","subnet-aaa2"]' \
  -var='shared_vpce_service_name=com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0' \
  -var='alb_listener_arn=arn:aws:elasticloadbalancing:us-east-1:111111111111:listener/app/hybrid-alb/50dc6c495ef0ad96/abcd1234' \
  -var='container_image=123456789012.dkr.ecr.us-east-1.amazonaws.com/stateful-app:v1.2'

# 为租户 B 部署(换变量即可)
terraform apply \
  -var='tenant_id=tenant-b' \
  -var='vpc_id=vpc-0bbb2222' \
  ...

每个租户一次 apply,产出独立的集群、目标组、路由规则和 PrivateLink 连接。新增租户就是再跑一次,删除租户就是 terraform destroy

采纳建议与取舍清单

混合多租户不是万能方案,以下几条帮助判断是否适合你的场景:

适合的情况: - 有状态服务(数据库连接池、长生命周期缓存、WebSocket)对资源争抢敏感 - 租户数量在 10–100 之间,开独立账号太贵,全池化太危险 - 合规要求"逻辑隔离"但不强制"物理账号隔离"

不适合的情况: - 租户少于 5 个——直接开独立账号,运维负担可控,隔离更彻底 - 租户超过 500 个——独立 ECS 集群的管理成本会反噬,考虑回到池化 + 资源配额(cgroup / ECS task CPU/memory limits) - 无状态 API 服务——池化 + 请求级隔离足够,没必要为每个租户开集群

落地前的检查项:

  • [ ] Route 53 健康检查已开启 EvaluateTargetHealth,故障自动切流
  • [ ] ALB 规则优先级有明确分配策略,避免新租户规则意外覆盖旧规则
  • [ ] 每个 ECS 集群绑定了独立的 Capacity Provider 和 ASG,扩缩容不串
  • [ ] PrivateLink 连接的 acceptance-required 设为 True,共享服务审批后才能接入
  • [ ] 有状态服务的 ECS 任务定义中 startPeriod 足够长(≥ 60s),避免预热期被误判为不健康
  • [ ] 监控和告警按 TenantId tag 分维度,一个租户的延迟飙升不会淹没全局视图
  • [ ] CI/CD 流水线支持按租户参数化部署,新增租户不需要改代码

混合架构的本质是在隔离光谱上找一个可运营的中间点。它用 ECS 集群隔离计算、ALB 规则隔离流量、PrivateLink 隔离网络路径,同时保留共享账号的运维效率。如果你正在为"开账号太贵、全共享太险"的两难找出路,这套组合值得试一把。


相关推荐