如果你同时管着两朵云,Terraform 大概是你每天都要用的家伙。可一旦某个资源不明不白地创建失败,报错里全是 provider、version 这种词,很多人当场就懵了。我也有过这么一次,印象特别深刻。

一、那个让我加班的晚上

那是一个周五的下午,我往原来的 AWS 配置里追加了一块阿里云 OSS。原本想得很简单:初始化,看计划,应用,然后下班。结果在应用阶段直接报错,说 S3 桶的参数不认识了。当时项目里同时在用 AWS 的 provider 和阿里云的 provider,同事前几天为了一个新功能把 AWS provider 从 4.x 升到了 5.x,而我的老分支上还停在 4.x。

错误大致长这样:

Error: Unsupported argument

  on main.tf line 10, in resource "aws_s3_bucket" "data":
  10: acl = "private"

The provider hashicorp/aws v5.x does not support argument "acl"
on resource "aws_s3_bucket". Did you mean to use the
aws_s3_bucket_acl resource?

看到这里,我并没有觉得是版本问题,第一反应是代码写错了。后来翻了 git 历史和 lock 文件才明白,是 provider 插件版本悄悄变了。这个场景放到多云环境里太常见了:同一个 Terraform 工程里要接 AWS、阿里云、Azure,每家云的 provider 更新节奏不一样,插件版本互相之间没有冲突,但会因为你自己的工程结构或者升级时间点产生矛盾。

二、先搞懂 Terraform 插件版本是怎么一回事

Terraform 自己本身不直接操作云厂商的 API,它靠的是一个个 provider 插件。你可以把 provider 理解成一个翻译官:Terraform 用 HCL 写配置,云厂商用各自 API,翻译官负责把两边的话传明白。

每个 provider 都有版本,而你在配置文件里写下的版本范围,就是告诉 Terraform:你只能接受哪个范围内的翻译官。

示例统一使用 Terraform 的语言 HCL(Terraform Configuration Language)来写,命令行操作用 Bash 展示。

# Terraform (HCL) 一个最常见的多 provider 声明
terraform {
  required_version = ">= 1.3"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"   # 允许 >=5.0 且 <6.0 的所有版本
    }

    alicloud = {
      source  = "aliyun/alicloud"
      version = "~> 1.200.0"  # 允许 >=1.200.0 且 <1.201.0 的所有版本
    }
  }
}

这里出现了一个带着波浪线的符号,它叫悲观约束。它管得比较严,目的是防止某个小版本升级带来意外。

2.1 版本约束写法速查

# Terraform (HCL) 版本约束写法速查
">= 1.0"     # 版本不小于 1.0
"<= 2.0"     # 版本不大于 2.0
"~> 4.0"     # 允许 >=4.0 且 <5.0
"~> 4.16"    # 允许 >=4.16 且 <5.0
"~> 1.2.3"   # 允许 >=1.2.3 且 <1.3.0

多个模块如果都对同一个 provider 写了约束,Terraform 会想尽办法找到一个同时满足这些约束的版本。找不到,就报错。

除了配置文件里的 provider 声明块,还有一个文件至关重要,叫 lock 文件。它会把实际下载的 provider 版本和校验值全部记下来,下次无论谁重新初始化,只要 lock 文件没变,就会拿到同一批插件。这也是团队协作时避免版本漂移的关键。这里截取一段 lock 文件的内容:

# .terraform.lock.hcl 节选(HCL)
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.31.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:xxxxxxxxxxxx",
    "h1:yyyyyyyyyyyy",
  ]
}

记住,这个文件是自动生成的,不要手工改校验值。你只需要把它提交到 git,并且保证大家用的 Terraform 版本不能太低。

三、一个典型的冲突现场

光说不练是没用的。我们来看一个真实的工程结构。

根模块像下面这样:

# main.tf Terraform (HCL)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

module "network" {
  source = "./modules/network"
}

module "app" {
  source = "./modules/app"
}

network 模块内部的 provider 声明:

# modules/network/provider.tf Terraform (HCL)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}

app 模块内部的 provider 声明:

# modules/app/provider.tf Terraform (HCL)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.0"
    }
  }
}

你看,根模块支持 3.x,network 模块也只要 3.x,但 app 模块明显要 4.x。这三个约束放在一起,没有任何一个 provider 版本能同时满足这两组区间,于是初始化就挂了。如果你的项目是在应用资源的时候中途挂掉的,多半是因为已存在的资源是用老版本 provider 创建的,新版本 provider 不认它留下的状态。

还有一种更隐蔽的情况:只改某一个小功能,但有人用过升级参数来重新初始化。这个动作会把 lock 文件里的版本提示去掉,然后自动去拉最新版本。如果最新版本正好是下一个大版本,配置里面已经废弃的参数就会在准备变更或应用资源的时候给你一个晴天霹雳。

3.1 状态文件带来的历史包袱

很多人以为 provider 版本只影响初始化,其实它还影响已经写进 state 文件里的资源。比如你之前用 AWS provider 4.x 建了一个 S3 桶,配置里把访问权限写成了私有。在 4.x 上没问题,到了 5.x,这个参数被拆到了独立的 S3 bucket ACL 资源里。旧 state 里的资源元数据还在,但新 provider 读取配置时发现你还在用旧参数,于是立刻报错。这有点像一个老员工退休前没有交接,新员工接不了活一样。

四、哪些场景最容易踩雷

讲了这么多,下面咱们盘点一下最容易遇到这种问题的实际场景。

4.1 多云同时管理

AWS、阿里云、Azure 一起管,本来就是 Terraform 最常见的使用方式。每个云厂商对自己的 provider 都有独立的发版节奏,某个云厂商的 provider 升级后,你可能为了新功能必须升,但另外一边的代码还没准备好,冲突自然就来了。

4.2 团队协作时 lock 文件没人管

如果 lock 文件没有老老实实提交到 git,每个人本地都会生成一份自己的 lock 文件。你锁的是 4.16,我锁的是 5.2,一旦合并,问题就出现了。哪怕你们用的是同一个云厂商,只是区域不同,这种事情也经常发生。

4.3 公共模块被反复引用

一个公共模块可能在多个项目里被使用,某个项目给模块打了补丁并升了 provider 版本,其他项目引用时,约束就互相踩踏。特别是当你用 git 地址引用模块时,版本不像 npm 那样固定,一变就牵动所有人。

4.4 私有镜像源/离线源版本不全

有些公司为了安全,把 provider 放到内部仓库做镜像。镜像仓库如果更新不及时,明明有些版本已经锁定了,但重新初始化时却拉不下来,同样会报版本冲突。所以镜像源本身也要有配套的版本同步机制,不能只做一次性拷贝。

五、版本锁定这件事的优缺点

你可能会问:版本锁定不是好事吗?确实,它有两头。

5.1 优点

  • 可重复执行:今天跑是这个结果,三个月后跑还是这个结果。
  • 安全可控:校验值能防止有人把篡改过的插件挂到仓库里。
  • 团队一致:只要 lock 文件提交了,大家用的插件一模一样。

5.2 缺点

  • 版本粘滞:锁得太死,想升级一个安全补丁都要费半天劲。
  • 多平台麻烦:lock 文件里存的是校验值,如果你的电脑是 ARM 架构,别人是 x86,需要分别生成对应平台的 hash,否则会报“没有匹配的校验值”。
  • 掩盖需求:有些冲突其实是因为代码已经过期,但 lock 文件不更新,导致系统一直用旧版本,问题被藏起来。

六、到底怎么解决

下面给出几条经过实践检验的解决方案。

6.1 统一模块约束

遇到模块冲突,先别急着改代码,把每个模块的 provider 声明都列出来,找出冲突点。能统一到同一个大版本,就尽量统一。比如把上面 example 里的 app 模块改成 3.x,或者把 network 模块升到 4.x,两边对齐就解决了。

这里的原则是:不要随意在根模块写一个很宽的 range,因为子模块的约束无法被根模块覆盖,必须所有模块一起对齐。

6.2 主动升级,而不是被动踩坑

如果你要升级 provider,请有意识地做一次迁移。以一个 AWS provider 4 升 5 为例,你需要先查 release notes,把废弃的参数改成新写法。比如 S3 桶,之前直接在桶上写访问控制参数,5.x 以后要拆成单独的 S3 bucket ACL 资源。改造后的 HCL 大概是:

# Terraform (HCL) 适配 AWS provider 5.x 的 S3 写法
resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"

  # 注意:这里不要再写 acl 了
}

resource "aws_s3_bucket_acl" "data_acl" {
  bucket = aws_s3_bucket.data.id
  acl    = "private"
}

改完之后再做一个计划,确认变更内容符合预期,再应用。这样至少不会被“半生不熟”的版本状态卡住。

6.3 用命令生成多平台 lock 文件

如果你和队友用的是不同操作系统,或者 CI 跑在 linux 上,本机是 macOS,需要提前生成多平台校验值。

# 生成锁定文件,并同时记录多种操作系统的校验值
terraform providers lock -platform=linux_amd64 -platform=darwin_amd64 -platform=darwin_arm64

执行完以后,lock 文件里会多出几组 hash。以后大家在各自的平台上重新初始化,都能找到对应的校验值,不会再出现“你的电脑能跑,我的电脑不能跑”的怪事。

6.4 使用多个工作目录或独立状态

如果是多云适配,而且两朵云之间没有强依赖,建议把 AWS 和阿里云拆成两个独立的工作目录,每个目录有自己的 provider 版本和 lock 文件。这样升级一个云不会影响另一个云,虽然维护麻烦一点,但故障边界很清楚。你甚至可以给 AWS 用 4.x,给阿里云用 1.200.x,互不干扰。

6.5 注意事项:别在危险时刻执行升级参数

这句可能很多人不爱听,但真的别随手在危险时刻执行带升级参数的初始化动作。升级 provider 应该是一件有计划、有回归测试的事情,而不是出现在你急着创建资源的前五分钟。如果你一定要试,请先备份 lock 文件,并且把升级造成的计划变更让同事 review 一下。

七、总结

provider 插件版本冲突,听起来像一个纯技术难题,实际上更像一门“版本管理”的手艺。它不要求你把所有版本都锁死,也不鼓励你永远用最新版,而是要在可重复和可升级之间找个平衡点。

对付这个问题,最核心的办法是:把版本约束写到配置里、把 lock 文件提交到仓库、把升级动作变成有计划的迁移、把多云环境拆成分界清晰的独立状态。做到这几步,绝大多数“资源莫名其妙创建失败”的锅,都不会再甩到 provider 版本头上。

Terraform 本身不会害你,害你的是那些没人管、不知道什么时候变了的插件版本。下次再看到 provider 有关的报错,记得深呼吸,看一眼 lock 文件,再决定要不要动手。