Series · Terraform Agents · Chapter 2

用 Terraform 给 AI Agent 上云(二):Provider、认证与 OSS 上的远程 State

钉死 alicloud provider 版本,在 AK/SK、AssumeRole、ECS RAM role 三种认证方式之间正确选择,把 tfstate 放到 OSS 并用 Tablestore 加锁,外加让 dev/staging/prod 不互相踩脚的 workspace 模式。再加上初学者第一天必踩的十几个坑。

这一篇你不再是读,是开始动手。读完之后你会有:

  1. alicloud Terraform provider 装好且版本钉死
  2. 认证接好——用对的方式,不是方便的方式
  3. 远程 state 放在 OSS,用 Tablestore 加锁
  4. 三个 workspace(devstagingprod),共用 backend、隔离 state
  5. 一个能跑通的 terraform plan(即使配置是空的)

本篇还不会建出任何 Agent 资源。我们打的是后续每一篇都会假设的地基。

第 0 步:装 Terraform

不展开——官方《Install Terraform》文档覆盖了所有 OS。macOS 上:

1
2
3
4
5
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
terraform version
# Terraform v1.9.x
# on darwin_arm64

钉到一个近期稳定版。阿里云文档以 >= 0.12 为基线测试,但新项目应该用 >= 1.9。新版本有真实的人体工程学改进(for_eachoptional()、改进过的 moved block)。

第 1 步:钉死 provider 版本

建一个项目目录、一个 versions.tf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# versions.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    alicloud = {
      source  = "aliyun/alicloud"
      version = "~> 1.230"   # 1.230.x 都允许,1.231.0 拒绝
    }
  }
}

~> 1.230 约束允许 1.230.01.230.x,挡住 1.231.0。这是合理默认。一旦你把 terraform init 自动生成的 .terraform.lock.hcl 也提交到 git,连 provider 的精确版本和 checksum 都被锁了。同事后面跑 terraform init 拿到的 provider 字节级一致。

早钉版本是便宜的保险。alicloud provider 在 minor 版本之间发布过 break change(最近一次大的是 1.220 附近的 OSS bucket schema 重构)。你早晚要升级——故意升、在 PR 里、看 plan diff 升,而不是某个同事电脑上意外升。

第 2 步:认证——三种选择按优劣排序

provider 需要阿里云凭证。真实选择有三种,按职业认可度递增排序:

三种 alicloud provider 认证方式

方案 A:静态 AK/SK(仅限个人电脑)

1
2
3
export ALICLOUD_ACCESS_KEY="LTAI5tXXXXXXXXXXXXXX"
export ALICLOUD_SECRET_KEY="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
export ALICLOUD_REGION="cn-shanghai"

provider 自动发现这些环境变量。绝对不要把 key 写进 .tf 文件。state file 不存 secret,但 provider {} block 存,而那个 block 是要提交到 git 的。

如果 AK/SK 是只能��� Terraform 管的资源的子账号,单人项目里这是可以接受的。任何共享场景,跳到方案 B。

方案 B:AssumeRole(CI runner)

CI runner 不该带长期有效的 AK。给 CI runner 一个只有一种权限的 AK——对目标 role 的 sts:AssumeRole——让 Terraform 在 apply 时去 assume:

1
2
3
4
5
6
7
8
9
provider "alicloud" {
  region = var.region

  assume_role {
    role_arn           = "acs:ram::${var.account_id}:role/TerraformDeployRole"
    session_name       = "ci-${var.commit_sha}"
    session_expiration = 3600
  }
}

真正的写权限在 role 上;AK 只有 assume 的权利。STS session 短期有效(默认一小时),ActionTrail 全审计,trust policy 一摘就立刻失效。GitLab CI、GitHub Actions、Jenkins runner 都该用这个模式。

方案 C:ECS RAM Role(堡垒机 / IaC 服务的 runner)

如果 terraform apply 跑在阿里云 ECS 实例上——比如团队的运维堡垒机,或阿里云托管的 IaC 服务 runner——给实例挂一个 RAM role,provider 自动从实例元数据拿凭证:

1
2
3
4
5
provider "alicloud" {
  region = var.region
  # 不需要 assume_role,不需要 env vars,provider 自动从
  # http://100.100.100.200/latest/meta-data/ram/security-credentials/ 读取
}

任何配置、任何环境变量、任何文件里都没有 secret。轮转自动。这是金标准。

实操提示: 不管选哪种,都要显式设 ALICLOUD_REGION(或者 provider { region = ... })。不设的话 provider 不取默认——你会在 terraform plan 时拿到一个让人莫名其妙的 “Region must be specified” 错误。这事我栽过不止一次。

第 3 步:State——为什么本地 tfstate 是个坑

terraform apply 时,默认 Terraform 会把 terraform.tfstate 写在当前目录。这个文件是你基础设施存在与否的真相源。三件事会出问题:

  1. 丢失。 删掉目录,Terraform 就以为什么都不存在。下次 apply 试图重建一切(或者因为重复而失败)。
  2. 冲突。 两个工程师同时跑 apply 能把 state 文件搞坏。
  3. 明文密钥。 某些 resource 属性(数据库密码、密钥材料)会落到 tfstate 里。放在笔记本上不好,提交到 git 更糟——而且真有人这么干。

修法是 远程 state + state 锁。阿里云上典型的搭配是 OSS + Tablestore:

OSS + Tablestore 的远程 state 与锁

OSS 持有真正的 terraform.tfstate 文件(开版本控制——出问题时一条 CLI 就能恢复)。Tablestore 持有一个很小的"锁"行,Terraform 在每次 apply 之前写、apply 之后删。如果第二个 apply 在第一个还持锁的时候启动,它会等或失败——绝不会同时跑两个。

第 4 步:bootstrap backend(先有鸡还是先有蛋)

承载 backend 的 OSS bucket 和 Tablestore……得先于 backend 存在。诚实的做法是用一个单独的 bootstrap/ 目录、用 本地 state file 把它们建出来,然后再也不动它。

 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
# bootstrap/main.tf —— 跑一次,只在这个目录里留 local tfstate
provider "alicloud" {
  region = "cn-shanghai"
}

resource "alicloud_oss_bucket" "tfstate" {
  bucket = "ck-tfstate-prod"
  acl    = "private"

  versioning {
    status = "Enabled"
  }

  server_side_encryption_rule {
    sse_algorithm = "KMS"
  }

  lifecycle {
    prevent_destroy = true   # 永远别让 terraform destroy 掉这个 bucket
  }
}

resource "alicloud_ots_instance" "tflock" {
  name        = "tf-state-lock"
  description = "Terraform state lock"
  instance_type = "Capacity"
}

resource "alicloud_ots_table" "tflock" {
  instance_name = alicloud_ots_instance.tflock.name
  table_name    = "TerraformLock"
  primary_key {
    name = "LockID"
    type = "String"
  }
  time_to_live  = -1
  max_version   = 1
}

bootstrap/terraform init && terraform apply。约 30 秒。然后把 local tfstate 归档到某处(我放 1Password 里做最后一道备份),从此不再从这个目录跑 Terraform。

第 5 步:配置 backend

回到真实项目,加上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# backend.tf
terraform {
  backend "oss" {
    bucket              = "ck-tfstate-prod"
    prefix              = "agents/"
    key                 = "terraform.tfstate"
    region              = "cn-shanghai"
    tablestore_endpoint = "https://tf-state-lock.cn-shanghai.ots.aliyuncs.com"
    tablestore_table    = "TerraformLock"
    encrypt             = true
  }
}

prefix 让你在一个 bucket 里塞多个 state 文件——后面把基础设施拆成多个 Terraform 项目时很方便。encrypt = true 开 OSS 端加密(bucket 级别我们已经开了 KMS 规则,但纵深防御从不嫌多)。

跑:

1
2
3
4
5
terraform init
# Initializing the backend...
# Successfully configured the backend "oss"!
# Initializing provider plugins...
# - Installing aliyun/alicloud v1.230.x...

如果挂 “AccessDenied”,是你的认证 role 没有 bucket 上的 oss:GetObject/PutObject。最小 role policy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["oss:GetObject", "oss:PutObject", "oss:DeleteObject"],
      "Resource": ["acs:oss:*:*:ck-tfstate-prod/agents/*"]
    },
    {
      "Effect": "Allow",
      "Action": ["ots:GetRow", "ots:PutRow", "ots:DeleteRow"],
      "Resource": ["acs:ots:*:*:instance/tf-state-lock/table/TerraformLock"]
    }
  ]
}

附给你认证用的 role。不要直接给 oss:*——backend role 也要最小权限,因为它在你的 CI runner 里。

第 6 步:用 workspace 隔离环境

workspace 就是同一个 backend 里的另一份 state 文件。默认 workspace——很贴心地——叫 default。把你需要的其他几个建出来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

terraform workspace list
#   default
# * prod
#   dev
#   staging

terraform workspace select dev

HCL 里 terraform.workspace 解析成当前 workspace 名,你可以用它参数化资源规模:

1
2
3
4
5
6
7
locals {
  is_prod = terraform.workspace == "prod"

  ecs_count        = local.is_prod ? 3 : 1
  ecs_instance_type = local.is_prod ? "ecs.c7.xlarge" : "ecs.c7.large"
  rds_class         = local.is_prod ? "pg.x4.large.2c" : "pg.n2.medium.1c"
}

干净的替代是每个环境一份 *.tfvars

1
2
terraform plan -var-file=env/dev.tfvars
terraform plan -var-file=env/prod.tfvars

我把 tfvars 用于"显然不同的配置"(CIDR、region、实例数),把 terraform.workspace 只用于条件式 is_prod 开关。混用没问题——一个项目里挑一种作为主机制就行。

第 7 步:五条命令的循环

日常 Terraform 就五条命令:

你会跑几百次的五条命令循环

1
2
3
4
5
terraform fmt        # 规范缩进,pre-commit hook
terraform validate   # 静态 schema 校验,<1s
terraform plan       # 期望 vs 真实的 diff,仔细看
terraform apply      # 发 API 调用
terraform show       # 看当前 state

三条规则:

  1. apply 之前一定要看 plan 输出。 它会告诉你接下来要发生什么——哪些资源会创建(+)、原地更新(~)、强制重建(-/+)、销毁(-)。原地→重建那种箭头尤其会偷偷带停机。
  2. CI 里把 plan 和 apply 拆成两步。terraform plan -out=tfplan,把 plan 输出贴到 PR,等人工 approve,merge 时 terraform apply tfplan。绝不要 push 自动 apply。
  3. 别跳过 state 命令。 terraform state list 列出所有当前管理的资源;terraform state show <addr> 看单个资源的全部属性。debug 奇怪 drift 时这就是起点。

第一天必踩的八个坑

按它们坑到我的顺序:

  1. terraform initError: Failed to query available provider packages GFW。设 HTTPS_PROXY,或者按官方《Configure an acceleration solution for Terraform initialization》文档——镜像在 https://mirrors.aliyun.com/terraform/
  2. Error: state lock 上一次 apply Ctrl-C 了,锁残留。terraform force-unlock <LOCK_ID>(错误信息里有 ID)。先确认确实没在跑别的 apply。
  3. Error: Region must be specifiedALICLOUD_REGION 环境变量或 provider block 里的 region
  4. backend init 时 AccessDenied OSS bucket prefix 上的 RAM 权限不对。再核第 5 步的 policy。
  5. Tablestore 上 InvalidParameter.NotFound bootstrap 错了 region。Tablestore endpoint 和 OSS bucket 必须在同一 region。
  6. Provider produced inconsistent result after apply 几乎总是 provider 升级后 .terraform/ 缓存没清。rm -rf .terraform .terraform.lock.hcl && terraform init
  7. Resource already exists 你在控制台手建过这个资源。要么删掉,要么 import:terraform import alicloud_vpc.main vpc-uf6xxxxxx
  8. 刚 apply 完一个资源,立刻 terraform plan 又出 diff。 “Drift”。要么有人去控制台动了,要么 provider 的读逻辑和创建逻辑不一致。看 diff 里具体哪个属性;通常修法是把那个属性显式写出来,让 Terraform 不再"注意到"差异。

实操提示: 每次 apply 之后立刻再跑一次 terraform plan,哪怕你以为没改。plan 应该是空的。如果不是,你已经有 drift——drift 留得越久越难和。

下一篇

第三篇造第一块真实基础设施:可复用的 vpc-baseline module。VPC、跨三个 zone 的三个 vSwitch、NAT 网关、EIP、安全组基线、KMS key。后面每一篇都会用它,它也是我所有 Agent stack 里被复制粘贴最多的 module。

如果这篇你跑通了,你现在应该能 terraform initterraform workspace select devterraform plan 看到 “No changes."。这就是地基。后面所有东西都摞在它上面。

Liked this piece?

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

GitHub