1. Provider 的插件协议与 gRPC
一句话总结: Provider 本质是一个独立进程,通过 gRPC 与 Terraform Core 通信,Core 负责编排依赖图与状态,Provider 只负责「如何把声明翻译成云 API 调用」。
Terraform 的架构是「Core + 插件」:Core 解析 HCL、构建依赖图、管理 state;每个 Provider 是独立编译的二进制,Core 通过 go-plugin 启动它并建立 gRPC 通道。
# Provider 是独立进程,Core 通过 go-plugin 启动它并建立 gRPC 通道
ps aux | grep terraform-provider
通信协议的关键约定:
| 概念 | 含义 |
|---|---|
| Plugin Protocol | 版本化的 RPC 协议(5.x / 6.x) |
| GetProviderSchema | Core 启动时拉取全部 schema |
| PlanResourceChange | 计算计划变更(Core 侧合并) |
| ApplyResourceChange | 执行 Create/Update/Delete |
| ReadResource | refresh 时读取真实状态 |
| ImportResourceState | 导入已有资源 |
// 协议 6 使用 proto3,通过 tfprotov6 接口暴露
// Core 与 Provider 之间不共享内存,只有 RPC
一句话:理解了「Provider 只是 RPC 服务端」,就能明白为什么 Provider 无法访问 Core 的内存、为什么 schema 必须在启动时就确定。
2. 开发环境与脚手架
一句话总结: 现代 Provider 用 terraform-plugin-framework 开发,它比 SDKv2 提供更强的类型安全与更清晰的 CRUD 接口,脚手架可以从模板仓库一键生成。
两种框架的取舍:
| 维度 | SDKv2 | Plugin Framework |
|---|---|---|
| 类型系统 | schema.Schema + interface{} | schema.Schema + types 包 |
| 嵌套属性 | 有限,易错 | 原生 Attributes/Blocks |
| 状态升级 | StateUpgraders | UpgradeState |
| 推荐度 | 维护既有 | 新 Provider 首选 |
# 从官方模板生成脚手架
git clone https://github.com/hashicorp/terraform-provider-scaffolding-framework
cd terraform-provider-scaffolding-framework
make build # 生成 terraform-provider-<name> 二进制
make install # 安装到 ~/.terraform.d/plugins
func main() {
var debug bool
flag.BoolVar(&debug, "debug", false, "set to true to run with delve")
flag.Parse()
opts := providerserver.ServeOpts{
Address: "registry.terraform.io/example/example",
Debug: debug,
}
err := providerserver.Serve(context.Background(), New, opts)
if err != nil {
log.Fatal(err.Error())
}
}
2.1 本地调试配置
用 dev_overrides 让 Terraform 直接使用本地编译的二进制,跳过 Registry 下载。
# ~/.terraformrc
provider_installation {
dev_overrides {
"example/example" = "/Users/me/go/bin"
}
direct {}
}
# 覆盖生效后无需 terraform init,直接 plan
terraform plan
# 会打印警告:跳过 init,使用开发版本
一句话:
dev_overrides把「改代码到验证」的循环从几分钟压到几秒,是 Provider 开发效率的第一杠杆。
3. Schema 定义与 CRUD 生命周期
一句话总结: Schema 是 Provider 与用户的契约:每个属性都要声明类型、必填/可选/计算、是否敏感,Core 依据它做校验、diff 与计划合并。
func (r *WidgetResource) Schema(
ctx context.Context,
req resource.SchemaRequest,
resp *resource.SchemaResponse,
) {
resp.Schema = schema.Schema{
MarkdownDescription: "管理一个示例 Widget 资源",
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "资源唯一标识",
},
"name": schema.StringAttribute{
Required: true,
MarkdownDescription: "Widget 名称",
},
"size": schema.Int64Attribute{
Optional: true,
Computed: true,
Default: int64default.StaticInt64(1),
MarkdownDescription: "规格大小",
},
"api_token": schema.StringAttribute{
Optional: true,
Sensitive: true,
},
},
}
}
生命周期由 Core 驱动,Provider 只需实现对应方法。以 Create 为例,Core 的调用顺序是 ValidateConfig → PlanResourceChange → ApplyResourceChange(Create) → Read;Update 与 Delete 同理,Refresh 只走 ReadResource。
| 属性标志 | 语义 | 典型用途 |
|---|---|---|
| Required | 必须由用户提供 | 名称、CIDR |
| Optional | 用户可选,可设默认值 | 规格、标签 |
| Computed | 仅由 Provider 计算 | ID、ARN、创建时间 |
| Optional+Computed | 用户可给,不给则算 | 自动生成的名称 |
| Sensitive | 日志与输出中打码 | 密钥、密码 |
一句话:Schema 定义错了,后面所有 CRUD 都白写——先用一个最小资源把 Required/Optional/Computed 的组合想清楚。
4. 资源与数据源的实现
一句话总结: 资源实现 Create/Read/Update/Delete 四方法并维护状态映射,数据源只需实现 Read,二者的共同点是把 API 响应正确映射回 Terraform 的
types值。
type WidgetResource struct {
client *client.Client
}
type WidgetResourceModel struct {
ID types.String `tfsdk:"id"`
Name types.String `tfsdk:"name"`
Size types.Int64 `tfsdk:"size"`
APIToken types.String `tfsdk:"api_token"`
}
func (r *WidgetResource) Create(
ctx context.Context,
req resource.CreateRequest,
resp *resource.CreateResponse,
) {
var plan WidgetResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
w, err := r.client.CreateWidget(plan.Name.ValueString(), plan.Size.ValueInt64())
if err != nil {
resp.Diagnostics.AddError("创建 Widget 失败", err.Error())
return
}
plan.ID = types.StringValue(w.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
}
Read 方法必须处理「资源已被外部删除」的情况,把 ID 置空并移除状态:
// Read:从 API 读回真实属性,写回 state
w, err := r.client.GetWidget(state.ID.ValueString())
if errors.Is(err, client.ErrNotFound) {
// 资源已被外部删除:从 state 移除,下次 plan 会重新创建
resp.State.RemoveResource(ctx)
return
}
// 属性映射与 Create 类似,此处省略
4.1 数据源实现
数据源(data source)是只读查询,实现一个 Read 即可:从 req.Config 读取用户输入(如名称),调用 API 查询后把结果写回 resp.State,适合做「按名字查 ID」这类场景。它与资源的 Read 结构相同,只是数据来源从 state 变成配置。
func (d *WidgetDataSource) Read(
ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse,
) {
var config WidgetDataSourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
w, err := d.client.GetWidgetByName(config.Name.ValueString())
if err != nil {
resp.Diagnostics.AddError("查询 Widget 失败", err.Error())
return
}
config.ID = types.StringValue(w.ID)
resp.Diagnostics.Append(resp.State.Set(ctx, &config)...)
}
一句话:Update 里最易犯的错是「只改 API 不改 state」——两者必须同步,否则下次 plan 会显示永久差异。
5. 导入与状态升级
一句话总结: 导入让存量资源能被纳管,状态升级让 schema 演进不破坏已有用户,两者是 Provider 从「能用」走向「可信赖」的分水岭。
导入通过 ImportState 实现,把用户提供的 ID 写入状态,随后 Read 会补全其余属性。
func (r *WidgetResource) ImportState(
ctx context.Context,
req resource.ImportStateRequest,
resp *resource.ImportStateResponse,
) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
# 用户侧使用
import {
to = example_widget.legacy
id = "widget-12345"
}
当 schema 发生破坏性变化(如属性改名、类型变更),需要写状态升级器:
func (r *WidgetResource) UpgradeState(
ctx context.Context,
) map[int64]resource.StateUpgrader {
return map[int64]resource.StateUpgrader{
0: { // 从 schema 版本 0 升级到 1
PriorSchema: &schema.Schema{ /* 旧 schema */ },
StateUpgrader: func(
ctx context.Context,
req resource.UpgradeStateRequest,
resp *resource.UpgradeStateResponse,
) {
// 把旧字段 disk_size 迁移到新字段 size
var prior struct {
DiskSize types.Int64 `tfsdk:"disk_size"`
}
resp.Diagnostics.Append(req.State.Get(ctx, &prior)...)
resp.Diagnostics.Append(resp.State.SetAttribute(
ctx, path.Root("size"), prior.DiskSize)...)
},
},
}
}
| 变更类型 | 是否破坏 | 处理方式 |
|---|---|---|
| 新增 Optional 属性 | 否 | 直接加,无需升级 |
| 属性改名 | 是 | StateUpgrader + 版本号 +1 |
| 类型变更(string→int) | 是 | StateUpgrader 转换 |
| 删除属性 | 是 | StateUpgrader 丢弃旧值 |
一句话:每次破坏性 schema 变更都必须带版本升级器,否则用户一升级 Provider 就会看到「状态无法解析」的报错。
6. 验收测试与 TF_ACC
一句话总结: 验收测试真正调用云 API 创建资源并验证,必须显式设置
TF_ACC=1才会运行,是 Provider 质量的最终防线。
func TestAccWidgetResource_basic(t *testing.T) {
resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
CheckDestroy: testAccCheckWidgetDestroy,
Steps: []resource.TestStep{
{
Config: testAccWidgetConfig("demo"),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrSet("example_widget.test", "id"),
resource.TestCheckResourceAttr("example_widget.test", "name", "demo"),
resource.TestCheckResourceAttr("example_widget.test", "size", "1"),
),
},
{
// 验证导入:导入后配置与状态一致
ResourceName: "example_widget.test",
ImportState: true,
ImportStateVerify: true,
},
},
})
}
# 必须显式开启,否则测试被跳过(避免误建真实资源)
export TF_ACC=1
export EXAMPLE_API_TOKEN=xxx
go test ./internal/provider/ -run TestAcc -v -timeout 30m
testAccPreCheck 中检查必需环境变量,缺失时 t.Fatal 提前失败,避免测试跑一半才发现没有凭证。
6.1 测试的分层
| 层次 | 是否需凭证 | 运行时机 |
|---|---|---|
| 单元测试(schema 校验、映射函数) | 否 | 每次提交 |
验收测试 TestAcc | 是,TF_ACC=1 | 合并前 / 每日 |
文档校验 tfplugindocs | 否 | 每次提交 |
# 生成文档,确保 schema 与文档同步
go generate ./...
tfplugindocs generate
一句话:验收测试的
ImportStateVerify是最被低估的检查——它同时验证了导入路径与 state 映射的正确性。
7. 发布与版本管理
一句话总结: Provider 遵循语义化版本,用 GoReleaser 交叉编译多平台二进制并签名,通过 Registry 分发,破坏性变更必须升主版本号。
# .goreleaser.yml 片段:交叉编译多平台二进制
builds:
- env: [CGO_ENABLED=0]
goos: [windows, linux, darwin]
goarch: [amd64, arm64]
git tag v0.3.0 && git push origin v0.3.0 # 触发发布流水线
goreleaser release --clean # 产出签名清单,Registry 据此分发
版本号语义与用户影响:
| 版本变化 | 含义 | 用户侧动作 |
|---|---|---|
| v1.2.3 → v1.2.4 | 修 bug | 直接升级 |
| v1.2.3 → v1.3.0 | 新增属性/资源 | 直接升级 |
| v1.2.3 → v2.0.0 | 破坏性变更 | 需读迁移指南 |
# 用户侧用约束表达可接受的升级范围
terraform {
required_providers {
example = {
source = "example/example"
version = "~> 1.3"
}
}
}
一句话:Provider 的版本号是给用户的承诺——一旦发布 v1,任何让用户改配置的变更都只能进 v2。
8. 总结
Provider 开发是一条从「协议理解」到「发布承诺」的链路:
| 环节 | 关键点 | 易错处 |
|---|---|---|
| 插件协议 | 独立进程 + gRPC | 误以为能共享 Core 内存 |
| 脚手架 | framework + dev_overrides | 忘了本地覆盖导致测旧版 |
| Schema | Required/Optional/Computed 组合 | Sensitive 漏标导致密钥泄漏 |
| CRUD | 四方法状态同步 | Update 只改 API 不改 state |
| 数据源 | 只读 Read | 与资源的 Read 混用逻辑 |
| 导入 | ImportStatePassthroughID | 导入后属性不完整 |
| 状态升级 | StateUpgrader + 版本号 | 破坏性变更不升级 |
| 验收测试 | TF_ACC=1 + ImportStateVerify | CI 里忘了设 TF_ACC |
| 发布 | GoReleaser + 语义化版本 | 破坏性变更不升主版本 |
一句话收尾:写 Provider 是把「云 API 的能力」翻译成「Terraform 的语言」。从 gRPC 协议到 Schema 契约,从 CRUD 的状态同步到验收测试的真实资源验证,每一环都要求开发者既懂云 API 的边界,也懂 Terraform 的模型——这正是 Provider 生态能支撑起整个 IaC 世界的底层原因。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。