Crossplane 深度实战:从 Managed Resource、XRD/Composition 到 Provider 与 Function 管道的控制平面工程全解
基础设施即代码(IaC)走到今天,实际上分化成了两条技术路线。第一条以 Terraform 为代表:客户端声明式——你写 HCL,本地 terraform plan 拿 state 做 diff,然后 apply 调云端 API,一次执行就结束。第二条以 Crossplane 为代表:控制平面式——你写 Kubernetes YAML,API Server 把它存成对象,控制器进入永不结束的 reconcile 循环,持续把真实世界拉回期望状态。
这两条路的分歧点不在语法,而在谁来持有真相、谁来承担漂移。Terraform 的真相在 state 文件里,apply 之外没人看守;Crossplane 的真相在 etcd 里,controller 每 N 小时重新 Observe 一遍。理解这一点,你才能判断 Crossplane 究竟解决了什么问题,以及什么时候它其实是个坏主意。
一、四层对象模型:MR / XR / XRC 与 XRD 的关系
Crossplane 的对象模型是新手最容易绕晕的地方。其实只要记住四个角色:
- Managed Resource(MR):对一朵云上真实资源的 1:1 映射。
Instance、Bucket、DatabaseInstance这类 CRD 由 Provider 提供,字段基本就是云 API 的镜像。 - Composite Resource(XR):平台团队自定义的"抽象资源",比如
XPostgreSQLInstance。它不直接对应云资源,而是组合若干 MR。 - Claim(XRC):应用团队在命名空间里提交的"申请",比如
PostgreSQLInstance。Claim 会自动在集群作用域创建一个对应的 XR。 - CompositeResourceDefinition(XRD):XR 的 schema 声明,等价于 CRD 之于 CR,外加版本转换与
claimNames配置。
一个 Claim 触发的完整链路是:
应用团队 kubectl apply PostgreSQLInstance (namespaced)
│
▼ claim controller 创建对应 XR
CompositePostgreSQLInstance (cluster-scoped) ← spec 被复制
│
▼ composition controller 选中 Composition
Composition → Function pipeline → 渲染出一组 MR 模板
│
▼ 创建/更新
RDSInstance + DBSubnetGroup + SecurityGroup ... (MR)
│
▼ provider 控制器 Observe/Create/Update
AWS 真实资源
Claim 与 XR 分离的意义在于权限边界:应用团队只在自己的 namespace 里有 RBAC,看不到也改不动集群作用域的 XR,更看不到底层 MR 里的 VPC ID。这是 Crossplane 做内部平台(Internal Developer Platform)的核心卖点。
二、Provider 内部:Observe / Create / Update / Delete 四段式
Provider 本质是一组 Kubernetes 控制器,每个 MR 类型一个。所有 Provider 的 reconciler 都实现同一个接口契约:
// crossplane-runtime 的 ExternalClient 契约(简化)
type ExternalClient interface {
Observe(ctx context.Context, mg resource.Managed) (ExternalObservation, error)
Create(ctx context.Context, mg resource.Managed) (ExternalCreation, error)
Update(ctx context.Context, mg resource.Managed) (ExternalUpdate, error)
Delete(ctx context.Context, mg resource.Managed) (ExternalDelete, error)
}
type ExternalObservation struct {
ResourceExists bool // 云上到底有没有
ResourceUpToDate bool // spec 与真实状态是否一致
ResourceLateInitialized bool
ConnectionDetails managed.ConnectionDetails // 连接串等敏感信息
}
这段代码看着平淡,但藏着 Crossplane 与 Terraform 最本质的差别:Observe 是幂等的、无副作用的、可被反复调用的。Terraform 的 refresh 是 apply 之前的一个步骤,而 Crossplane 的 Observe 是每个 reconcile 循环的起点。这意味着:
- 漂移会被自动纠正。resource 被人在控制台改了,
Observe下一轮发现ResourceUpToDate=false,直接进Update拉回来。Terraform 里这需要有人再跑一次 apply。 ResourceUpToDate的判定质量决定一切。写 Provider 最难的部分不是 Create/Update,而是如何判定"一致"。云 API 往往只返回部分字段、大小写不敏感、有默认值填充,判定过严会导致无限 Update 循环(reconcile 风暴),过松会导致漂移永远修不回来。- Late Initialization 是必需的。云会给字段填默认值(比如 RDS 的
backupRetentionPeriod=7),Provider 必须把这些值写回 MR 的spec.forProvider,否则下一轮 diff 又认为不一致。这就是ResourceLateInitialized的含义。
再看一个真实 Provider 里的 Observe 骨架,注意它如何处理"不存在的资源":
func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) {
cr, ok := mg.(*v1alpha1.Bucket)
if !ok {
return managed.ExternalObservation{}, errors.New(errNotBucket)
}
// external-name 是 MR 与云上资源的唯一锚点
name := meta.GetExternalName(cr)
resp, err := c.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(name)})
if err != nil {
var nf *types.NotFound
if errors.As(err, &nf) {
// 云上不存在:告诉 runtime 去 Create
return managed.ExternalObservation{ResourceExists: false}, nil
}
return managed.ExternalObservation{}, errorutils.Wrap(err, errDescribe)
}
cr.Status.AtProvider = generateBucketObservation(resp)
meta.SetExternalName(cr, name)
lateInit := lateInitialize(&cr.Spec.ForProvider, resp)
upToDate := isBucketUpToDate(&cr.Spec.ForProvider, resp)
return managed.ExternalObservation{
ResourceExists: true,
ResourceUpToDate: upToDate,
ResourceLateInitialized: lateInit,
ConnectionDetails: managed.ConnectionDetails{
"endpoint": []byte(fmt.Sprintf("https://%s.s3.amazonaws.com", name)),
},
}, nil
}
external-name 是这里的关键设计。MR 的 metadata.name 是集群内唯一,云上资源 ID 是云内唯一,两者必须有一个显式映射。Crossplane 用 annotation crossplane.io/external-name 保存这个映射。导入已有资源时,你要手动打这个 annotation;如果丢了它,Provider 会以为资源不存在并重新创建——这是生产事故的高发点。
三、upjet:为什么 Crossplane 能白嫖整个 Terraform 生态
手写 Provider 极其昂贵。Crossplane 的解法是 upjet:一个代码生成器,读 Terraform Provider 的 schema,自动生成对应的 CRD、deepcopy 代码和基于 terraform-plugin-sdk 的 ExternalClient 实现。
生成出来的 Provider 并不直接调云 API,而是在 Pod 里内嵌一个 Terraform CLI 进程,把 MR 的 spec 转成 HCL,跑 terraform apply,再把 state 存进 MR 的 annotation 或外部 secret。
这个方案的取舍非常清晰:
- 收益:几百个 Terraform Provider 一夜之间变成 Crossplane Provider(AWS Provider 生成出 1800+ 种 MR),覆盖率不是问题。
- 代价一:每个 MR 一个 Terraform 进程,内存和启动开销大,几百个 MR 同时 reconcile 时 Pod 内存能吃掉数 GB。
- 代价二:Terraform state 被塞回 Kubernetes,需要额外考虑 state 的大小与备份。
- 代价三:Terraform 的 schema 语义(
Computed、Optional+Computed、ForceNew)映射到 Kubernetes 的 CRD 校验时会有信息损失,ForceNew字段变更表现为 Delete+Create。
实战结论:用 upjet 生成的 Provider 覆盖长尾资源,用原生 Provider(provider-aws 的 native 部分、provider-kubernetes、provider-helm)覆盖高频热点资源。不要指望一个方案吃下所有场景。
四、Composition 与 Function 管道
Composition 负责回答:"一个 XR 进来,渲染出哪些 MR"。老版本用 Patch & Transform(一堆 patchSets、fromFieldPath、transforms 声明式拼接),v1.14 之后官方推荐 Composition Functions:Composition 只是一条 gRPC 管道,每个 Function 是一个实现了 FunctionRunnerService 的服务,接收期望状态 + 上下文,返回期望状态。
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xpostgresqlinstance.aws
spec:
compositeTypeRef:
apiVersion: platform.ybb.press/v1alpha1
kind: XPostgreSQLInstance
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: subnetGroup
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
region: us-east-1
subnetIdSelector:
matchLabels:
tier: private
patches:
- fromFieldPath: metadata.name
toFieldPath: metadata.name
transforms:
- type: string
string:
fmt: "%s-subnet-group"
- name: instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: us-east-1
engine: postgres
dbSubnetGroupNameSelector:
matchControllerRef: true # 引用同一次合成创建的 SubnetGroup
skipFinalSnapshotBeforeDeletion: true
patches:
- fromFieldPath: spec.parameters.size
toFieldPath: spec.forProvider.instanceClass
transforms:
- type: map
map:
small: db.t3.medium
medium: db.m6g.large
large: db.m6g.2xlarge
- fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
- step: connection-details
functionRef:
name: function-auto-ready
注意 matchControllerRef: true 这个补丁:它利用 Kubernetes 的 ownerReference 做跨资源引用,不需要你手工拼名字。这是 Composition 里非常实用但常被忽略的技巧。
Function 管道的真正威力在于可以插入任意代码。比如用 Go 写一个 Function,从 XR 里读一个"合规等级",动态决定要不要加加密和备份策略:
func (f *Fn) RunFunction(ctx context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) {
rsp := &fnv1.RunFunctionResponse{Meta: req.Meta, TTL: durationpb.New(60 * time.Second)}
xr := resource.MustNewCompositeFromStruct(req.GetObserved().GetComposite().GetResource())
tier, _ := xr.GetString("spec.complianceTier") // gold | silver | bronze
var desired struct {
StorageEncrypted bool
BackupRetentionPeriod int
MultiAZ bool
}
switch tier {
case "gold":
desired = struct{StorageEncrypted bool; BackupRetentionPeriod int; MultiAZ bool}{true, 35, true}
case "silver":
desired = ...{true, 7, false}
default:
desired = ...{false, 1, false}
}
// 把结果写进 desired composite / 或直接追加 MR
cd := resource.NewDesiredComposed()
...
rsp.Desired = &fnv1.State{Resources: map[string]*fnv1.Resource{"instance": cd.AsResource()}}
return rsp, nil
}
这里有个工程上的重要约束:Function 必须是纯函数。它拿不到真实云上状态(除非通过 Observed 传入),不能做网络调用(可以,但会破坏可测试性和确定性),输出只依赖输入。所有跟云交互的逻辑必须留在 Provider 的 ExternalClient 里。混淆这两层会导致 Composition 不可复现、难以本地测试。
Crossplane 提供了 crossplane beta render 来做纯本地的组合渲染测试——不需要集群:
crossplane beta render xr.yaml composition.yaml functions.yaml
这个命令是 CI 里验证平台变更的利器:改了 Composition 之后,用一组 fixture XR 跑一遍,diff 输出,就能在合并前知道会不会意外销毁重建资源。
五、生产落地的几个硬骨头
1)Reconcile 风暴与 API Server 压力。 每个 MR 一个控制器,AWS Provider 加载 1800 个 CRD 会让 API Server 的 discovery 开销显著上升。实践中要用 --enable-ssa、限制 Provider 只加载需要的 CRD 子集(upbound 官方镜像支持 EXCLUDE_LIST / 精简版 family provider,如 provider-family-aws + provider-aws-s3),并调大 poll-interval(默认 10 分钟,长尾资源可以放到 1 小时)。
2)删除语义。 MR 的 spec.deletionPolicy 有 Delete 和 Orphan 两种。Delete 会真的删云资源,Orphan 只移除 K8s 对象。生产环境删 XR 级联删库是常见事故,建议:数据库/存储类资源在 XRD 层面就不暴露删除路径,或者用 Management Policies(["Observe","Create","Update","LateInitialize"],明确不含 Delete)来禁止删除。
3)Observe Only 模式。 Management Policies 只给 ["Observe"] 时,Crossplane 变成纯粹的"资源清单 + 漂移检测器":只读云上资源、写回 status,绝不修改。这是把已有基础设施逐步纳入平台的第一步,风险最低。
4)多租户与 ProviderConfig。 Provider 通过 ProviderConfig 引用凭证(IRSA / Workload Identity / Secret)。平台团队需要用 RBAC 限制谁能引用哪个 ProviderConfig,否则应用团队可以指定 prod 账号的 config 去创建资源。这是 Crossplane 多租户模型里最容易被忽略的一个洞。
5)与 Argo CD / Flux 的配合。 GitOps 控制器和 Crossplane 都是 reconcile,两者叠加会出现"互相打架":Argo 认为 MR 的字段被 Crossplane 的 late-init 改了于是 rollback,Crossplane 又改回来。解法是在 Argo 的 ignoreDifferences 里屏蔽 spec.forProvider 和 status,或者用 annotation 让 Argo 不去管理 MR 层,只管理 XR/Claim 层。
六、什么时候不该用 Crossplane
说几点实话。
- 小团队、单一云、资源类型少于几十种:Terraform 更简单,Crossplane 引入的 Kubernetes 依赖和 CRD 治理成本不划算。
- 需要复杂的图依赖编排:Crossplane 的依赖靠 ownerReference 和 selector 隐式表达,调试起来比 Terraform 的显式 DAG 痛苦得多。Terraform 规划阶段的依赖图可视化目前无可替代。
- 对 plan 预览有强诉求:Crossplane 没有
terraform plan那种"先看清楚会改什么"的一等公民体验。crossplane beta render只能渲染 Composition 层,渲染不了 Provider 的真实 diff。这是当前最大的能力缺口。 - API Server 已经成为瓶颈的集群:别再往里塞 1800 个 CRD。
七、我的判断
Crossplane 真正的产品价值不在于"用 YAML 替代 HCL"——那是表面。它的价值在于把基础设施抽象从"团队约定"升级为"API 契约"。Terraform module 的变量是约定,谁能传什么值靠 code review 和 lint 约束;Crossplane 的 XRD 是 CRD,是 API Server 强制校验的 schema,配合 RBAC 可以做真正的权限隔离,配合 Composition 可以做真正的策略注入(gold tier 必须加密,这条规则写在 Function 里,没人能绕过)。
如果你的组织正在建内部开发者平台、需要给几十个应用团队提供"自助但不失控"的基础设施入口,Crossplane 是目前工程上最扎实的答案。如果你只是想管理三十台 EC2,那它是一把过重的大锤。
技术选型的分水岭始终是:你要解决的是资源创建问题,还是资源治理问题。

发表评论 取消回复