从”管住”到”编排”
前面写过 2018 年我们用 Terraform 把 AWS 资源统一管理起来的事。那篇文章发出去之后,有人留言问,不就是写几个 .tf 文件吗,有这么玄乎?
我理解这种印象。Terraform 入门确实不难,照着官方教程两小时就能拉起一个 VPC。但真正难的不是”能跑起来”,而是两年后还能不能跑得稳。
到 2020 年下半年,我们的 Terraform 仓库规模大概是:
- 8 个 AWS 账号(dev/staging/prod 各一套 + 沙盒 + 安全审计)。
- 上百个 module 实例。
- state 文件几十个,加起来代表了几千个 AWS 资源。
到这个规模,问题就从”会不会写 HCL”变成怎么让这些东西不失控。这篇聊的就是这一层。
Module 切分:粒度是个艺术
Terraform module 这件事,粒度太粗等于没切,粒度太细等于自虐。
我们试过几种粒度,最后沉淀下来的原则是:
1. 资源生命周期一致的,放一个 module
比如 VPC + 子网 + 路由表 + IGW + NAT Gateway,这一组东西生命周期完全绑定,要么一起创建,要么一起销毁。把它们放一个 module,调用方一次调用就拿到一个完整的网络栈。
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
| # modules/vpc/variables.tf variable "cidr" { type = string } variable "project" { type = string } variable "env" { type = string } variable "azs" { type = list(string) } variable "public_subnets" { type = list(string) } variable "private_subnets" { type = list(string) } variable "enable_nat" { type = bool, default = true }
# modules/vpc/main.tf resource "aws_vpc" "this" { cidr_block = var.cidr enable_dns_hostnames = true enable_dns_support = true tags = merge(local.common_tags, { Name = "${var.project}-${var.env}-vpc" }) }
resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = merge(local.common_tags, { Name = "${var.project}-${var.env}-igw" }) }
resource "aws_nat_gateway" "this" { count = var.enable_nat ? length(var.azs) : 0 allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id tags = merge(local.common_tags, { Name = "${var.project}-${var.env}-nat-${count.index}" }) depends_on = [aws_internet_gateway.this] }
|
2. 业务方不直接调底层 module
业务团队不应该自己组装 VPC + RDS + ECS 这种组合。我们提供”服务级 module”,一个 module 拉起一整套业务所需的基础设施:
1 2 3 4 5 6 7 8 9 10 11
| # 业务方写这样就够了 module "user_service" { source = "git::https://gitlab.internal/infra/modules//service-stack?ref=v2.1.0"
service_name = "user-service" env = "prod" port = 3000 desired = 3 db_required = true db_size = "db.t3.medium" }
|
这个 service-stack module 内部组合了 VPC 引用、ALB listener rule、ECS service、CloudWatch alarm、IAM role,业务方完全不需要知道这些细节。
好的 module 抽象让业务方不需要懂就能用对,坏的抽象让业务方必须懂才能用,那就是没抽象。
3. 别把所有东西塞一个 module
我们见过一些团队把整个生产环境塞到一个巨型 module 里,几百个 resource,terraform plan 跑五分钟。这种做法的问题:
- plan 输出几千行,没人能 review。
- 一个小改动可能引发连锁反应。
- state 文件巨大,操作慢且容易出错。
module 的边界应该沿着独立的部署单元切。一个微服务一个目录,一个共享平台一个目录。
多环境:用 workspaces 还是目录分开?
Terraform 多环境管理是个老话题。主流方案两种:
方案 A:terraform workspaces
1 2 3
| terraform workspace new dev terraform workspace new prod terraform apply -var-file="environments/$(terraform workspace show).tfvars"
|
方案 B:目录物理分开
1 2 3 4 5 6 7
| environments/ ├── dev/ │ └── main.tf ├── staging/ │ └── main.tf └── prod/ └── main.tf
|
我们最后选了方案 B 的变体——目录分开,但共享 module:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| infra/ ├── modules/ # 共享 module │ ├── vpc/ │ ├── service-stack/ │ └── ... └── environments/ ├── dev/ │ ├── main.tf # 调用 module │ ├── backend.tf # 独立 state │ └── terraform.tfvars ├── staging/ │ ├── main.tf │ ├── backend.tf │ └── terraform.tfvars └── prod/ ├── main.tf ├── backend.tf └── terraform.tfvars
|
为什么不用 workspaces?因为 workspaces 让多个环境共享同一份 .tf 配置,看似 DRY,实则危险:
- workspace 切错了一个
terraform destroy 就是灾难。
- 不同环境的资源拓扑往往不同(dev 不需要 NAT,prod 需要),共享配置会塞满
count = var.env == "prod" ? 1 : 0 这种丑陋代码。
- state 隔离不彻底,初学者容易踩坑。
目录分开的好处是 dev 的 .tf 文件和 prod 的 .tf 文件物理隔离,误操作的半径被限制在当前目录。代价是有一些重复代码,但通过 module 复用已经把重复降到很低。
1 2 3 4 5 6 7 8 9 10 11 12
| # environments/prod/main.tf module "vpc" { source = "../../modules/vpc"
cidr = "10.10.0.0/16" project = "coreteam" env = "prod" azs = ["us-east-1a", "us-east-1b", "us-east-1c"] public_subnets = ["10.10.1.0/24", "10.10.2.0/24", "10.10.3.0/24"] private_subnets = ["10.10.101.0/24", "10.10.102.0/24", "10.10.103.0/24"] enable_nat = true }
|
prod 这个目录里只有”prod 专属的配置”,逻辑全在 module 里。
State 管理:drift 检测是必修课
跑了一两年 Terraform 之后,state drift 几乎一定会发生。原因无非几种:
- 有人在 Console 手动改了东西(违反规矩但就是会发生)。
- 某个 AWS 自动修复改了资源属性。
- Terraform 升级版本时 provider 行为变化。
drift 不检测,下次 terraform apply 可能就是惊喜,而且不是好的那种。
我们用一套定时任务做 drift 检测:
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
| #!/usr/bin/env bash
set -euo pipefail
ENV=$1 WORKDIR="environments/${ENV}"
cd "$WORKDIR" terraform init -backend=false
terraform plan -detailed-exitcode -out=/tmp/plan.bin > /tmp/plan.txt EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then echo "✅ ${ENV}: no drift" elif [ $EXIT_CODE -eq 1 ]; then echo "❌ ${ENV}: plan error" cat /tmp/plan.txt exit 1 elif [ $EXIT_CODE -eq 2 ]; then echo "⚠️ ${ENV}: drift detected" cat /tmp/plan.txt curl -X POST "$SLACK_WEBHOOK" -d "{\"text\":\"Terraform drift in ${ENV}!\"}" fi
|
-detailed-exitcode 这个 flag 是关键:返回 0 表示无变更,2 表示有变更(即 drift),1 表示出错。这个脚本每天跑一次,任何 drift 当天就能发现。
发现 drift 之后怎么办?我的原则是先搞清楚为什么 drift,而不是马上 apply 抹平。如果是有人手动改的,要么补回 Terraform,要么把这次改动纳入 Terraform(写进 .tf 文件)。
state drift 的本质是现实与代码不一致。解决它有两步:先纠正现实(或代码),再让两者对齐。盲目 apply 是赌博。
多账号策略
最后聊一下多账号。dev 和 prod 共享一个 AWS 账号是灾难,dev 一个失控的脚本能把 prod 的数据库删了。
我们用 AWS Organizations + Terraform 管理多账号:
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
| # modules/organization/main.tf (简化) resource "aws_organizations_account" "dev" { name = "coreteam-dev" email = "aws+dev@coreteam.internal" parent_id = aws_organizations_organizational_unit.platform.id }
resource "aws_organizations_account" "prod" { name = "coreteam-prod" email = "aws+prod@coreteam.internal" parent_id = aws_organizations_organizational_unit.platform.id }
# 用 SCP 限制 dev 账号不能创建某些危险资源 resource "aws_organizations_policy" "dev_guardrails" { name = "dev-guardrails" content = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Deny" Action = ["iam:DeleteRole", "rds:DeleteDBCluster"] Resource = "*" } ] }) }
|
每个账号有独立的 state、独立的 IAM、独立的 CloudTrail。物理隔离加上 SCP 兜底,比任何策略逻辑都靠谱。
一些血泪经验
- Terraform 版本锁定。
.terraform-version 文件写死版本,团队所有人用同一个版本。版本不一致导致 state format 不兼容的事我们踩过。
- provider 版本也要锁。
version = "~> 3.0" 比 version = ">= 3.0" 安全。
- state 加密必须开。
encrypt = true 在 backend 配置里,没开的话 state 里的密钥是明文存 S3。
- 大改动分步走。一次 PR 别动几十个 resource,分几个 PR 逐步推进,每个 PR 都能独立 plan/apply。
- 永远先 plan 再 apply。生产环境禁止
terraform apply -auto-approve,必须人眼 review plan 输出。
走到这一步
2020 年底回头看,Terraform 在我们这里已经不是”管 AWS 资源的工具”,而是整个基建的真理来源。任何 AWS 资源的状态,都以 Terraform 为准;任何变更,都必须通过 Terraform PR。
这条路走通的关键不是我们用了 Terraform,而是我们把 Terraform 当成基建的宪法来执行。工具有,执行力才是分水岭。
下一篇继续 ECS 的话题,任务编排和发布策略,越往深走越有意思。