「Provider 开发实战」

从零实现一个 Terraform Provider:插件协议与 gRPC 通信原理、Schema 定义与 CRUD 生命周期、资源与数据源的实现、导入与状态升级,以及基于 TF_ACC 的验收测试与发布版本管理。

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)
GetProviderSchemaCore 启动时拉取全部 schema
PlanResourceChange计算计划变更(Core 侧合并)
ApplyResourceChange执行 Create/Update/Delete
ReadResourcerefresh 时读取真实状态
ImportResourceState导入已有资源
// 协议 6 使用 proto3,通过 tfprotov6 接口暴露
// Core 与 Provider 之间不共享内存,只有 RPC

一句话:理解了「Provider 只是 RPC 服务端」,就能明白为什么 Provider 无法访问 Core 的内存、为什么 schema 必须在启动时就确定。

2. 开发环境与脚手架

一句话总结: 现代 Provider 用 terraform-plugin-framework 开发,它比 SDKv2 提供更强的类型安全与更清晰的 CRUD 接口,脚手架可以从模板仓库一键生成。

两种框架的取舍:

维度SDKv2Plugin Framework
类型系统schema.Schema + interface{}schema.Schema + types 包
嵌套属性有限,易错原生 Attributes/Blocks
状态升级StateUpgradersUpgradeState
推荐度维护既有新 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忘了本地覆盖导致测旧版
SchemaRequired/Optional/Computed 组合Sensitive 漏标导致密钥泄漏
CRUD四方法状态同步Update 只改 API 不改 state
数据源只读 Read与资源的 Read 混用逻辑
导入ImportStatePassthroughID导入后属性不完整
状态升级StateUpgrader + 版本号破坏性变更不升级
验收测试TF_ACC=1 + ImportStateVerifyCI 里忘了设 TF_ACC
发布GoReleaser + 语义化版本破坏性变更不升主版本

一句话收尾:写 Provider 是把「云 API 的能力」翻译成「Terraform 的语言」。从 gRPC 协议到 Schema 契约,从 CRUD 的状态同步到验收测试的真实资源验证,每一环都要求开发者既懂云 API 的边界,也懂 Terraform 的模型——这正是 Provider 生态能支撑起整个 IaC 世界的底层原因。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「terraform」更多文章

  1. Helm Provider 与应用发布:值注入与回滚
  2. 模块注册表与分发:版本、文档与测试
  3. DNS 与证书编排:托管区域与自动验证