Series · Terraform Agents · Chapter 3

用 Terraform 给 AI Agent 上云(三):可复用的 VPC 与安全基线

第一个可复用 module——三可用区 VPC,公私网交换机分层,NAT 出网,按 tier 分层的安全组,以及按数据域分的 KMS 主密钥。同样的代码出现在我交付过的每一个 Agent stack 里,参数化但本体不变。

这一篇造的是我所有 Agent 项目里被复制粘贴最多的一段 Terraform:一个 vpc-baseline module,给后续每一个组件(ECS、RDS、OpenSearch、ACK)一个合理的落点。

读完之后你会拥有:

  • 一个跨三个可用区的 VPC
  • 六个交换机(每个区一个公网 + 一个私网),CIDR 不重叠
  • 一个 NAT 网关 + EIP,让私网子网能出网调 LLM API
  • 三个按 tier 叠的安全组(ALB → agent runtime → memory)
  • 三把 KMS 主密钥,每个数据域一把(memory、secrets、logs)
  • 干净的 module 接口:进 name + CIDR + zones,出一堆 ID

总共大概 200 行 HCL。一次写完,永久参考。

心智模型

代码之前先看图:

VPC 拓扑——三可用区、公私网、NAT 出网

为什么三个区?因为阿里云保留了任何一个周日对某个 zone 做维护的权利,单区部署意味着维护窗口期间你的 Agent 全离线。VPC 内跨区流量免费;三个区的唯一代价是子网算术的运维复杂度。

为什么公网 + 私网?Agent runtime 应该住在私网,这样一个配错的安全组也不会意外把它暴露到 0.0.0.0/0。公网子网放 ALB(负载均衡)和 NAT 网关——那些必须通互联网的东西。Agent 通过 NAT 出网,不直接出网。

我用的 CIDR 布局:

子网ZoneCIDR主机数
public-al10.20.0.0/2811
public-bm10.20.0.16/2811
public-cn10.20.0.32/2811
private-al10.20.1.0/24251
private-bm10.20.2.0/24251
private-cn10.20.3.0/24251

公网 /28 因为只放一个 NAT 和一个 ALB IP。私网 /24 因为 Agent ECS、RDS、OpenSearch 节点都住在那里。

Module 骨架

建目录:

modules/vpc-baseline/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf

输入(variables.tf):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
variable "name" {
  description = "所有资源的命名前缀,例如 agents-prod"
  type        = string
}

variable "cidr_block" {
  description = "顶层 VPC CIDR,子网从这里推导"
  type        = string
  default     = "10.20.0.0/16"
}

variable "zones" {
  description = "目标 region 的三个可用区 ID"
  type        = list(string)
  validation {
    condition     = length(var.zones) == 3
    error_message = "vpc-baseline 必须正好三个 zone。"
  }
}

variable "tags" {
  description = "module 创建的所有资源都打这些 tag"
  type        = map(string)
  default     = {}
}

强制三个 zone 是有立场的,但和图对得上。如果你需要两区或四区,fork 出来——别加条件分支。带条件的 module 会变得没法读。

VPC 和子网

main.tf,第一部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
resource "alicloud_vpc" "this" {
  vpc_name   = var.name
  cidr_block = var.cidr_block
  tags       = var.tags
}

resource "alicloud_vswitch" "public" {
  for_each = { for i, z in var.zones : i => z }

  vpc_id     = alicloud_vpc.this.id
  zone_id    = each.value
  cidr_block = cidrsubnet(var.cidr_block, 12, each.key)        # /28,从 .0 开始
  vswitch_name = "${var.name}-public-${substr(each.value, -1, 1)}"
  tags       = var.tags
}

resource "alicloud_vswitch" "private" {
  for_each = { for i, z in var.zones : i => z }

  vpc_id     = alicloud_vpc.this.id
  zone_id    = each.value
  cidr_block = cidrsubnet(var.cidr_block, 8, each.key + 1)     # /24,从 .1.0 开始
  vswitch_name = "${var.name}-private-${substr(each.value, -1, 1)}"
  tags       = var.tags
}

三件值得说的事:

  • cidrsubnet(prefix, newbits, netnum) 是 Terraform 的 CIDR 算术。cidrsubnet("10.20.0.0/16", 8, 1) 返回 "10.20.1.0/24"。背下来——你会反复用。
  • for_each 配 index/value map 给出稳定的 resource 地址——alicloud_vswitch.private["0"] 永远指第一个 zone,哪怕你重排列表。和 count 对比,count 重排会引发整体重建。
  • substr(each.value, -1, 1) 取 zone ID 最后一个字符(l/m/n),让资源名字排序好看。

NAT 网关 + EIP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
resource "alicloud_nat_gateway" "this" {
  vpc_id           = alicloud_vpc.this.id
  vswitch_id       = alicloud_vswitch.public["0"].id
  nat_gateway_name = "${var.name}-nat"
  nat_type         = "Enhanced"
  payment_type     = "PayAsYouGo"
  tags             = var.tags
}

resource "alicloud_eip_address" "nat" {
  address_name         = "${var.name}-nat-eip"
  bandwidth            = "100"
  internet_charge_type = "PayByTraffic"
  isp                  = "BGP"
  tags                 = var.tags
}

resource "alicloud_eip_association" "nat" {
  allocation_id = alicloud_eip_address.nat.id
  instance_id   = alicloud_nat_gateway.this.id
}

resource "alicloud_snat_entry" "private" {
  for_each = alicloud_vswitch.private

  snat_table_id     = alicloud_nat_gateway.this.snat_table_ids
  source_vswitch_id = each.value.id
  snat_ip           = alicloud_eip_address.nat.ip_address
}

Enhanced NAT 是现代版——Tablestore、PrivateLink 和大多数新服务必需。PayByTraffic 适合 Agent 这种突发出网(LLM 流式)而非稳态出网的场景。

SNAT 条目才是真正让私网实例出网的东西。没有它,private-a 里的 Agent 解析不到 dashscope.aliyuncs.com

安全组按 tier 分层

阿里云上正确的安全组用法是 每个 tier 一个 SG,规则引用 SG ID 而不是 CIDR:

安全组策略——紧入站、宽出站、按 tier 分层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
resource "alicloud_security_group" "alb_public" {
  name   = "${var.name}-alb-public"
  vpc_id = alicloud_vpc.this.id
  tags   = var.tags
}

resource "alicloud_security_group_rule" "alb_https_in" {
  security_group_id = alicloud_security_group.alb_public.id
  type              = "ingress"
  ip_protocol       = "tcp"
  port_range        = "443/443"
  cidr_ip           = "0.0.0.0/0"
  policy            = "accept"
  priority          = 1
}

resource "alicloud_security_group" "agent_runtime" {
  name   = "${var.name}-agent-runtime"
  vpc_id = alicloud_vpc.this.id
  tags   = var.tags
}

resource "alicloud_security_group_rule" "agent_from_alb" {
  security_group_id        = alicloud_security_group.agent_runtime.id
  type                     = "ingress"
  ip_protocol              = "tcp"
  port_range               = "8080/8080"
  source_security_group_id = alicloud_security_group.alb_public.id
  policy                   = "accept"
  priority                 = 1
}

关键一行是 source_security_group_id = alicloud_security_group.alb_public.id。它说"只接受来自 ALB SG 里任何实例的入站 8080"——而不是某个 CIDR。后面给 ALB 换 IP 不会破任何东西。

实操提示: 阿里云安全组的默认行为是入站全拒、出站全允。这个默认是对的——别加"出站全拒"规则,你只会把 SDK 调用搞挂。除非有明确合规要求,否则不要限制出站;Agent 系统全开出站是常态。

每个下游 tier 我都重复这个模式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
resource "alicloud_security_group" "memory_rds" {
  name   = "${var.name}-memory-rds"
  vpc_id = alicloud_vpc.this.id
  tags   = var.tags
}

resource "alicloud_security_group_rule" "rds_from_agent" {
  security_group_id        = alicloud_security_group.memory_rds.id
  type                     = "ingress"
  ip_protocol              = "tcp"
  port_range               = "5432/5432"
  source_security_group_id = alicloud_security_group.agent_runtime.id
  policy                   = "accept"
  priority                 = 1
}

resource "alicloud_security_group" "vector_store" {
  name   = "${var.name}-vector-store"
  vpc_id = alicloud_vpc.this.id
  tags   = var.tags
}

resource "alicloud_security_group_rule" "vector_from_agent" {
  security_group_id        = alicloud_security_group.vector_store.id
  type                     = "ingress"
  ip_protocol              = "tcp"
  port_range               = "9200/9200"
  source_security_group_id = alicloud_security_group.agent_runtime.id
  policy                   = "accept"
  priority                 = 1
}

写完之后给一台 ECS 挂正确的 SG 就一行 security_groups = [module.vpc.agent_runtime_sg_id],网络层结构性正确。

每个数据域一把 KMS

任何说得过去的合规框架都强制要求静态加密。阿里云方式是 每个数据域一把客户主密钥(CMK),这样轮转一把不影响另一把,按 key 审计访问。

KMS 加密——每个数据域一把 CMK

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
locals {
  cmks = {
    memory  = "RDS 数据和 OSS 对象的加密"
    secrets = "KMS Secrets Manager 条目加密"
    logs    = "SLS 日志数据加密"
  }
}

resource "alicloud_kms_key" "this" {
  for_each = local.cmks

  description            = each.value
  key_usage              = "ENCRYPT/DECRYPT"
  key_spec               = "Aliyun_AES_256"
  pending_window_in_days = 7
  status                 = "Enabled"
  automatic_rotation     = "Enabled"
  rotation_interval      = "365d"
  protection_level       = "SOFTWARE"
  tags                   = merge(var.tags, { Domain = each.key })
}

resource "alicloud_kms_alias" "this" {
  for_each = local.cmks

  alias_name = "alias/${var.name}-${each.key}"
  key_id     = alicloud_kms_key.this[each.key].id
}

为什么用别名?因为 CMK ID 是个没人记得住的 UUID;别名 alias/agents-prod-memory 人类可读且在 key 轮转时稳定。从 RDS、OSS 等地方引用别名,你可以换底层 key 而下游配置不变。

pending_window_in_days = 7 意味着删除的 key 有 7 天恢复窗口。别缩短——误删 key 是那种能终结职业生涯的错误。

Module 输出

outputs.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
output "vpc_id" {
  value = alicloud_vpc.this.id
}

output "private_vswitch_ids" {
  value = [for s in alicloud_vswitch.private : s.id]
}

output "public_vswitch_ids" {
  value = [for s in alicloud_vswitch.public : s.id]
}

output "nat_gateway_id" {
  value = alicloud_nat_gateway.this.id
}

output "nat_eip_address" {
  value = alicloud_eip_address.nat.ip_address
}

output "alb_public_sg_id" {
  value = alicloud_security_group.alb_public.id
}

output "agent_runtime_sg_id" {
  value = alicloud_security_group.agent_runtime.id
}

output "memory_rds_sg_id" {
  value = alicloud_security_group.memory_rds.id
}

output "vector_store_sg_id" {
  value = alicloud_security_group.vector_store.id
}

output "kms_keys" {
  value = { for k, v in alicloud_kms_key.this : k => v.id }
}

output "kms_aliases" {
  value = { for k, v in alicloud_kms_alias.this : k => v.alias_name }
}

这些刚好是后面五篇会用到的 ID。把输出有意识地命名和成形之后,调用方可以这样写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
module "vpc" {
  source = "./modules/vpc-baseline"
  name   = "agents-prod"
  zones  = ["cn-shanghai-l", "cn-shanghai-m", "cn-shanghai-n"]
}

resource "alicloud_instance" "agent" {
  vswitch_id      = module.vpc.private_vswitch_ids[0]
  security_groups = [module.vpc.agent_runtime_sg_id]
  # ...
}

调用 module

顶层 main.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
module "vpc" {
  source = "./modules/vpc-baseline"

  name       = "agents-${terraform.workspace}"
  cidr_block = "10.20.0.0/16"
  zones      = ["cn-shanghai-l", "cn-shanghai-m", "cn-shanghai-n"]

  tags = {
    Project     = "research-agent-stack"
    Environment = terraform.workspace
    ManagedBy   = "terraform"
  }
}

从项目根目录跑 terraform plan 会输出大致:

Plan: 27 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + agent_runtime_sg_id = (known after apply)
  + nat_eip_address     = (known after apply)
  + private_vswitch_ids = [
      + (known after apply),
      + (known after apply),
      + (known after apply),
    ]
  + vpc_id              = (known after apply)

27 个资源差不多对(1 VPC + 6 vSwitch + 1 NAT + 1 EIP + 1 EIP-assoc + 3 SNAT + 4 SG + 4 SG-rule + 3 KMS key + 3 KMS alias = 27)。Apply,约 90 秒,你拿到了一个生产级别的网络。

成本估算

cn-shanghai,按月粗略:

  • VPC、vSwitch、安全组、KMS key:免费
  • NAT 网关:Enhanced 类型 ~¥120/月,加按 GB 出网流量
  • EIP:~¥20/月 IP 保留费,加 PayByTraffic 流量
  • KMS:每 key 每天前 100 次调用免费,之后 ~¥0.005/次

中低流量下网络基线 ¥150-300/月。物有所值——后面每一篇都继承这副骨架。

下一篇

第四篇把计算落到这个网络上。三种模式——ECS 配 pm2、ACK 跑生产 fleet、Function Compute 跑事件触发的 Agent——以及我用来在它们之间选的成本拐点模型。然后是真正的 alicloud_instance 块,用 cloud-init 把 Python + Node + Agent runtime bootstrap 起来。

实操提示: 如果你将来需要加第四个 zone(阿里云会周期性地加),terraform apply 一下就行——for_each 模式干净处理更长的列表。但是:variables.tf 里的 validation block 会拒绝它,所以你得先把 validation 放宽。这个故意的摩擦就是重点——加 zone 是值得想一想的网络变更。

Liked this piece?

Follow on GitHub for the next one — usually one a week.

GitHub