搭建 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 | s3://coding-infra-tfstate-{account_id}/ |
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 | data "terraform_remote_state" "env" { |
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 | 外部请求进来:互联网 → IGW → ALB(公有子网)→ ECS 容器(私有子网) |
NAT Gateway 约 $1/天,不用时记得 destroy。
2.3 跨可用区
一个 AWS 可用区(AZ)本质是一个数据中心,会停电、会光纤断。跨两个 AZ 部署,子网各两个,让 ALB 和 ECS 在两个 AZ 都有实例——一个 AZ 故障,流量自动切到另一个。
网络搭好了,接下来把容器放上去。
3. ECS Fargate:容器上云
容器跑起来了,怎么放到 AWS 上?K8s 对一个内部工具来说太重了。ECS Fargate 是个不错的折中:托管的容器调度,不用管机器,按容器实际用量计费。
ECS 的概念层级:
1 | Cluster(集群) |
类比一下: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 | /app-a → app-a 的容器 |
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 | locals { |
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 | // 危险写法 |
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 | # 不用这个 |
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 权限宁可细,不要用
*图省事 - 临时目录不可靠,关键操作不要依赖本地历史状态