搭建 AI 内部部署平台

背景一句话:我们想让团队的 AI Coding Agent 直接把写好的代码部署到 AWS,不需要人工介入。于是做了一个内部工具 Runway——runway deploy 一条命令,从代码到运行。

1. State:谁来 “ 记住 “ 我创建了什么

最开始写 Terraform,我以为它就像一个脚本——运行一遍,资源就创建好了。等到第二次运行,才意识到一个核心问题:Terraform 怎么知道哪些资源是它创建的,哪些是已经存在的?

答案是 State 文件。terraform.tfstate 是 Terraform 维护的 “ 世界快照 “,记录了 “ 上次创建了这些资源,它们的 ID 是这些 “。下次 apply 时,Terraform 拿当前代码和 State 对比,计算出增量操作。

1.1 State 放哪里

最初把 state 放本地,队友 apply 一次,我再 apply 一次,就出现了重复创建同名资源的问题。解法是把 state 放到 S3,所有人共享同一份。同时用 DynamoDB 做分布式锁——两个人同时 apply 会有一个被阻塞,等另一个完成才能继续。

1
2
3
4
s3://coding-infra-tfstate-{account_id}/

environments/dev/terraform.tfstate # VPC、ECS 集群、ALB
apps/dev/{app-name}/terraform.tfstate # 每个应用的 ECS Service

1.2 State 分层

基础设施分成三层 state——bootstrap(S3/DynamoDB 本身)、环境(VPC/ECS/ALB)、应用(ECS Service)。每层独立管理,销毁某个应用不会影响整个 VPC。

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
    A["bootstrap 层"] --> B["环境层"]
    B --> C["应用层 app-a"]
    B --> D["应用层 app-b"]

    classDef layer1 fill:#3B82F6,stroke:#2563EB,color:#fff
    classDef layer2 fill:#10B981,stroke:#059669,color:#fff
    classDef layer3 fill:#F59E0B,stroke:#D97706,color:#fff

    class A layer1
    class B layer2
    class C,D layer3
  • bootstrap 层:管 S3 bucket 和 DynamoDB lock table 本身
  • 环境层:管 VPC、ECS 集群、ALB
  • 应用层:管单个 ECS Service,每个应用独立一份 state

应用层的 Terraform 需要读取环境层的 VPC ID、集群 ARN 等信息,通过 terraform_remote_state 跨 state 读取:

1
2
3
4
5
6
7
8
9
10
data "terraform_remote_state" "env" {
backend = "s3"
config = {
bucket = "coding-infra-tfstate-..."
key = "environments/dev/terraform.tfstate"
}
}

# 然后直接用
vpc_id = data.terraform_remote_state.env.outputs.vpc_id

1.3 踩坑:destroy 静默失败

runway destroy 的实现是:找到上次 deploy 留下的临时目录,对它执行 terraform destroy。逻辑上没问题,但临时目录在重启或新会话后会被清空。当 /tmp/runway/my-app/dev/ 不存在时,destroy 跳过 terraform 直接打印 “ 销毁完成 “——资源还在 AWS 上跑着,还在计费

修复方案:destroy 时不依赖历史临时目录,每次都重新解压模块、重新生成配置、重新 terraform init,再 destroy。慢一点,但可靠。

理解了 State 的管理方式后,下一步是搭建容器运行的网络基础。

2. VPC 和网络:给应用一个 “ 家 “

在本地跑 Docker 容器,所有容器天然在同一个网络里互相访问。到了 AWS,这个 “ 默认网络 “ 叫 VPC(Virtual Private Cloud),需要先创建一个 VPC 才能在里面放任何资源。

2.1 CIDR 选段

第一次建 VPC,随手用了 10.0.0.0/16——AWS 控制台的默认值。当时没觉得有问题,直到意识到:公司内网、其他 VPC、VPN 也很可能用同一个网段。一旦需要把两个 VPC 连起来(VPC Peering),CIDR 重叠会直接失败,没有任何绕过办法

后来改成了 10.42.0.0/16——不常见,不容易冲突。

2.2 公有子网与私有子网

最初以为把所有东西放一个子网就行了。但安全需求会迫使你分开:

  • 公有子网:ALB(负载均衡器)放这里,需要有公网 IP 接收外部流量
  • 私有子网:ECS 应用容器、数据库放这里,不需要公网 IP,也不应该暴露

私有子网的应用需要访问外网(拉镜像、调用第三方 API),走的是 NAT Gateway——一个放在公有子网里的 “ 出口路由器 “,把私有子网的流量转发出去。

1
2
外部请求进来:互联网 → IGW → ALB(公有子网)→ ECS 容器(私有子网)
容器访问外网:ECS 容器(私有子网)→ NAT Gateway(公有子网)→ 互联网

NAT Gateway 约 $1/天,不用时记得 destroy。

2.3 跨可用区

一个 AWS 可用区(AZ)本质是一个数据中心,会停电、会光纤断。跨两个 AZ 部署,子网各两个,让 ALB 和 ECS 在两个 AZ 都有实例——一个 AZ 故障,流量自动切到另一个。

网络搭好了,接下来把容器放上去。

3. ECS Fargate:容器上云

容器跑起来了,怎么放到 AWS 上?K8s 对一个内部工具来说太重了。ECS Fargate 是个不错的折中:托管的容器调度,不用管机器,按容器实际用量计费。

ECS 的概念层级:

1
2
3
4
Cluster(集群)
└── Service(服务,保证 N 个容器持续运行)
└── Task(实际运行的容器实例)
└── Container(容器)

类比一下:Cluster 是机房,Service 是 “ 这个应用要跑 2 个实例 “ 的声明,Task 是实际起来的那个进程,Container 是进程里的 Docker 容器。

3.1 踩坑:exec format error

第一次部署,容器起不来,CloudWatch 日志显示:

1
exec ./server: exec format error

原因:MacBook(Apple Silicon, ARM64)上构建的镜像,推到 ECR 后,ECS Fargate 默认用 linux/amd64(x86)来跑——两种 CPU 指令集,当然无法运行。

修复一行:

1
docker build --platform linux/amd64 -t my-image .

在 M1/M2/M3 Mac 上本地 build 的镜像,不加这行全都会有这个问题。

3.2 Awsvpc 网络模式

Fargate 强制使用 awsvpc 网络模式,每个 Task 都有独立的网络接口(ENI)和私有 IP。不同应用都监听 8080 端口完全没问题,各自的 ENI 互相独立,不存在端口冲突。

3.3 IAM 双角色

ECS 有两个 IAM 角色,初学时很容易混淆:

  • Execution Role:给 ECS Agent 用的,负责 “ 把容器跑起来 “——从 ECR 拉镜像、往 CloudWatch 写日志
  • Task Role:给容器里的应用代码用的,负责应用运行时的权限——读 S3、查 Secrets Manager、访问 RDS

分开的原因是最小权限原则:应用代码不需要有拉镜像的权限,ECS Agent 不需要有读 S3 的权限。

容器运行起来了,但外部流量怎么进来?需要一个统一入口。

4. ALB 路由:一个入口,多个应用

多个应用共用一个 ALB,通过路径区分:

1
2
3
4
/app-a     → app-a 的容器
/app-a/* → app-a 的容器
/app-b → app-b 的容器
/app-b/* → app-b 的容器

ALB 的核心概念是 Listener → Rule → Target Group → Targets:

  • Listener 监听 80/443 端口
  • Rule 匹配路径,决定转发给哪个 Target Group
  • Target Group 维护一组后端(ECS Task 的 IP + 端口)
  • ALB 把请求转发给 Target Group 里健康的实例

4.1 踩坑:Target Group 名称限制 32 字符

命名规范 {project}-{env}-{app_name}-tg,例如 coding-infra-dev-my-long-app-name-tg,超过 32 个字符时 AWS API 直接报错。

修复用截断 + hash:

1
2
3
4
5
6
locals {
tg_name_raw = "${var.project_name}-${var.environment}-${var.app_name}-tg"
tg_name = length(local.tg_name_raw) <= 32
? local.tg_name_raw
: "${substr(local.tg_name_raw, 0, 26)}-${substr(md5(local.tg_name_raw), 0, 5)}"
}

4.2 健康检查

ALB 每隔几秒向每个 Task 发 GET /health,返回 200 才认为健康。不健康的 Task 会被摘出 Target Group,ECS Service 会触发重启。

每个应用必须实现 GET /health 返回 200——没有这个端点,容器刚起来就会被反复杀掉重启。

流量能进来了,下一个问题是:谁能访问谁?

5. 安全边界:安全组链

安全组(Security Group)是 AWS 上的防火墙,控制 “ 哪些流量可以进来、哪些可以出去 “。不同于传统防火墙按 IP 写规则,安全组可以引用另一个安全组作为来源——规则变得非常简洁。

本项目三个安全组形成一条链:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart LR
    A["互联网"] -->|"80/443"| B["ALB SG"]
    B -->|"来自 ALB SG"| C["ECS SG"]
    C -->|"来自 ECS SG"| D["RDS SG"]

    classDef external fill:#F59E0B,stroke:#D97706,color:#fff
    classDef sg fill:#3B82F6,stroke:#2563EB,color:#fff
    classDef db fill:#10B981,stroke:#059669,color:#fff

    class A external
    class B,C sg
    class D db
  • ALB SG:入允许 0.0.0.0/0 的 80/443(对外开放),出允许所有
  • ECS SG:入只允许来自 ALB SG 的流量(容器不直接对外暴露),出允许所有
  • RDS SG:入只允许来自 ECS SG 的流量(数据库只接受应用访问)

5.1 踩坑:IAM 里用通配符图省事

IAM policy 里,最初把 Resource 写成 * 或者在 ARN 里用 * 代替 account ID:

1
2
// 危险写法
"Resource": "arn:aws:secretsmanager:us-west-2:*:secret:my-app*"

account ID 用 * 时,理论上这个 role 可以访问其他 AWS 账号(如果有 cross-account 权限)的同名 secret。虽然实际上要有跨账号配置才触发,但这是一个不必要的风险口子。

更常见的是 Resource: * 用在 kms:Decrypt——意味着这个应用可以解密账户下任意 KMS key 加密的内容,不限于自己的数据。改成限定到具体 account 和 region 的 key 是正确做法。

安全边界设好后,最后一个问题是镜像本身。

6. 镜像构建的坑

6.1 Docker Hub 拉不到

Dockerfile 里写 FROM node:20-alpine,在国内环境构建时经常失败——Docker Hub 限速或被封锁。

AWS 提供了 ECR Public Gallery 作为替代,public.ecr.aws/docker/library/ 是官方镜像的镜像站,国内稳定可达:

1
2
3
4
5
# 不用这个
FROM node:20-alpine

# 改用这个
FROM public.ecr.aws/docker/library/node:20-alpine

6.2 go.mod 破坏 Go embed.FS

Runway 把 Terraform 模块和项目模板用 //go:embed 打包进二进制,这样用户安装一个 binary 就够了。但 go-api 模板目录里有 go.mod,按 Go 的 embed 规则——“ 如果子目录里有 go.mod,那它是另一个 module,不在当前 module 的 embed 范围内 “——整个模板目录就从打包内容里消失了。

解法:把模板里的 go.mod 重命名为 _go.mod,在 init 命令展开模板时再改回来:

1
relPath = strings.ReplaceAll(relPath, "_go.mod", "go.mod")

6.3 npm ci 需要 Lockfile

模板里用了 npm ci,这个命令要求 package-lock.json 存在。模板是骨架代码,没有 lockfile,npm ci 直接报错退出。改成 npm install 解决。

7. 费用参考

us-west-2 区域,不跑任何应用时的空转费用:

资源约费用
NAT Gateway~$1/天
ALB~$0.67/天
Dev 环境空转~$1.67/天,约 $50/月

不开发时:cd terraform/environments/dev && terraform destroy -auto-approve

恢复时:terraform apply -auto-approve(ECR 镜像不会丢,重新 deploy 即可)

8. 回顾

做完这个项目,真正内化的是具体的判断:

  • CIDR 选不常见的段,省去扩展时的麻烦
  • State 分层管理,每层独立销毁互不干扰
  • Mac 上 build 镜像必须加 --platform linux/amd64
  • AWS 资源命名要防御性编程,提前考虑长度限制
  • IAM 权限宁可细,不要用 * 图省事
  • 临时目录不可靠,关键操作不要依赖本地历史状态