
如果在生产环境里已经叫了一整年的 Deployment 和 StatefulSet某天你突然发现无论如何都表达不了业务想要的“发布策略”或者运维同学说“能不能让研发直接提一个资源来申请数据库实例”这时候你基本就走到 Kubernetes 的高级扩展点面前了CRD。CRDCustomResourceDefinition自定义资源定义是 Kubernetes 可扩展性的核心机制。它允许你往集群里注册一种全新的 API 资源类型之后kubectl可以像操作 Deployment 一样去 get、apply、watch 这个新对象API Server 也会自动帮它做校验、持久化和权限控制。说得更直白一点内置资源是出厂预装的而 CRD 是运行当中自己注册的“新车型”。这篇文章会从原理讲到实操再到企业落地一定会踩的坑适合刚接触 Operator 概念的开发/运维同学也想给已经写过 CRD 但没跑通 controller 的人补上最后一块拼图。1. 打开 Kubernetes 自定义资源的大门1.1 从内置资源到 CRD一个可插拔的资源系统刚开始接触 Kubernetes 的时候很多人对“资源”的理解约等于 Deployment、Service、ConfigMap。这些内置资源构成了 K8s 对外的主要使用面但它们远远不是全部。Kubernetes 从来没有把自己绑定死在这十几个资源上它向下定义了一套 API 约定只要有一套 GroupAPI 组、Version版本和 Kind类型的三元组一个对象就能被 API Server 保存、校验、分发CRD 就是这套约定中面向用户开放的注册入口。为什么这件事这么重要因为大部分业务系统不只有“跑一组容器”这种需求还有“申请一个数据库实例”、“创建一条灰度策略”、“发布一个应用版本”这些更贴近业务领域的对象。传统做法是在 Kubernetes 外面写一张数据库表再写一堆接口让用户录入然后脚本去生成 Deployment整个过程既绕又不好维护。CRD 的方式是直接把业务对象变成 K8s 原生资源天然继承 etcd 持久化、kubectl 操作、RBAC 权限控制以及客户端基于 watch 机制实时感知变化的能力。等于把业务建模做进了集群底座里。另外一个很现实的点CRD 的复用价值极高。社区里 Prometheus Operator、Argo CD、Karpenter 等大量项目都在用 CRD 暴露自己的配置入口。你学会了 CRD未来看任何一个现代云原生项目的文档都不会被各种陌生资源类型吓到。反过来掌握 CRD 也会让团队内部工具建设走向标准化因为大家已经熟悉了kubectl apply -f这套心智模型。1.2 什么场景值得用 CRD什么场景别硬上从我的实践看CRD 的典型场景可以归成五类。第一类是基础设施即代码化数据库实例、消息队列、缓存集群用户希望像写 YAML 一样申请一个“存储服务”典型如 RedisCluster、MySQLOperator 这类。第二类是部署流程抽象把多个 Deployment、Service、Ingress 打包成一个“应用”交付给研发用户在界面上只看到业务自己的术语。第三类是策略类资源网络隔离策略、预算审批策略CRD 可以把这些配置带进集群并被控制器消费。第四类是设备与调度扩展比如把 GPU、FPGA 这类设备抽象成资源controller 负责上报和绑定。第五类是把外部系统拉进集群管理范围比如通过 CRD 描述一个云厂商负载均衡器再由 controller 调用 API 创建。但反过来不推荐硬上 CRD 的情况也存在。如果只是临时记录一些元数据或者外部系统需要直接通过数据库查询那 CRD 很可能只是徒增复杂度。如果一个对象没有任何关联的控制动作那它更像一张数据表用 ConfigMap 加文档约定也许就够了。CRD 最大的成本在于配套 controller 的开发和运维只有当你真的需要“声明式管理 自动调谐”这个组合时它才值回票价。所以判断标准永远不是“这功能听起来高级”而是“有没有一个控制器能消费这个定义并持续收敛状态”。2. CRD 是如何被 API Server 接纳的2.1 API 路径、Group/Version 与 Kind 三件套在写 CRD 之前至少要搞明白几个术语Group、Version、Kind 是 Kubernetes API 资源的身份标识。Group 通常是一个域名比如apps.example.com对应 API 路径的一部分/apis/apps.example.com/v1Version 就是v1、v1beta1这类Kind 是对象的类型名比如WebApp。注册完毕之后客户端会向https://apiserver/apis/apps.example.com/v1/namespaces/ns/webapps这样的 RESTful 路径发起请求。这种设计本质上就是一套标准的 REST API 规范kubectl 的能力比如kubectl get webapps只不过是对这些 HTTP 接口的封装。CRD 元数据本身是一种资源它定义在apiextensions.k8s.io/v1下可以理解为“注册表的注册项”。一个 CRD 支持同时声明多个版本其中必须有一个版本标记为storage: true由它负责把数据写到 etcd其他版本可以有served: true表示对外提供 API但只作为访问层面的展示版本API Server 会自动在版本之间转换。在 v1 版本的 CRD 中新增或修改 schema 会导致字段变化所以版本策略非常关键我在第 5 章会单独展开。这里还想强调一个容易忽略的细节CRD 的metadata.name必须是plural.group的格式比如你定义 group 是apps.example.com复数形式是webapps那么 CRD 名字就是webapps.apps.example.com。这是 API Server 识别 CRD 与 API 路径映射关系的固定规则写错了根本注册不上。类似这种“看起来无所谓但错一个字母就废”的约定在 K8s 里其实很多后面会继续提到。2.2 声明式模型的三要素Spec、Status 和 Controller如果你打开任意一个内置资源的 YAML会发现它的结构有套路spec表示用户声明的期望状态status表示系统观察到的当前状态metadata保存名字、标签、注解等元数据。CRD 诞生的目标之一就是让开发者按照同样的三要素去设计自定义资源否则 API Server 能存数据但没人保证数据会被“实现”。这不是技术洁癖而是 Kubernetes 控制循环的底层逻辑。内置资源 Deployment 能帮我们拉起 Pod不是 Deployment 对象本身有魔法而是 kube-controller-manager 里有一堆 controller 在 watch Deployment 和 Pod不断比较“期望副本数”和“实际 Pod 数”再通过 API 创建或删除 Pod。用户自定义的 CRD 如果没有对应的 controller它就像一个被存档的表格谁也不会来理它。所以我评价一个 CRD 设计是否合理通常看三件事有没有清晰的spec输入、有没有独立的status输出、有没有一个 controller 去消费 spec 并更新 status。三者齐全CRD 才算真正活起来。这一章如果只记住一句话那就是CRD 只是定义功能靠 controller。API Server 只负责把它当作普通对象存取不会因为你定义了一个 WebApp 就自动去创建 Deployment。很多人第一次写完 CRD创建了 CR 之后等半天看没有反应就开始怀疑集群出了问题其实更多的只是 controller 还没诞生。3. 手写一个可落地的 CRD从定义到创建实例3.1 案例设计与字段规划用一个大家都懂的案例团队里运维负责对外网站接入每次上线要创建一个 Deployment、一个 Service、一个 Ingress还要把公网域名给到申请者。现在把这个流程转换成 CRD比如叫WebApp。它放在 groupapps.example.com、versionv1、kindWebApp短名用wa。用户提交的 YAML 大概是这样的预期apiVersion: apps.example.com/v1 kind: WebApp metadata: name: my-blog spec: image: nginx:1.24 replicas: 2 domain: blog.example.com env: - name: TZ value: Asia/Shanghai字段选择要遵守一个原则只在 spec 放用户关心的声明不要把 controller 内部的临时状态塞进去。这里我们保留image、replicas、domain和env这四个字段已经足够支撑一个初级网站托管场景。如果以后需要暴露健康检查或资源限制可以再增加但要记住任何字段一旦对外承诺后续改起来就需要版本迁移所以首版宁可少而精。校验规则也要提前想清楚image必填domain必填且必须是合法域名replicas是整数范围 1 到 20env是数组每个元素是name/value。这些约束会在 API Server 层生效用户提交不合法配置会直接被拒绝controller 那边就少了很多脏数据防御工作。3.2 编写 CRD 的 YAML附全量可复制配置下面这份 CRD 配置是我在测试环境验证过的可以直接保存为webapp-crd.yaml并应用。为了便于理解我加了中文注释实际文件中注释不会影响使用。apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: webapps.apps.example.com spec: group: apps.example.com names: kind: WebApp singular: webapp plural: webapps shortNames: - wa scope: Namespaced versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object required: - image - domain properties: image: type: string domain: type: string pattern: ^[a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]$ replicas: type: integer minimum: 1 maximum: 20 default: 1 env: type: array items: type: object properties: name: type: string value: type: string required: - name additionalPrinterColumns: - name: Domain type: string jsonPath: .spec.domain - name: Replicas type: integer jsonPath: .spec.replicas - name: Age type: date jsonPath: .metadata.creationTimestamp这段配置的要点有几个。metadata.name必须是plural.group也就是webapps.apps.example.com这是硬性约定。scope可以是Namespaced或Cluster大多数业务资源用 Namespaced全局策略才用 Cluster。versions里至少有一个版本要storage: true这里我们只有一个v1所以自然它就是存储版本。additionalPrinterColumns负责让kubectl get的结果更好看它不会影响 API 存储但对日常巡检帮助非常大。3.3 安装 CRD 并创建自定义资源实例保存后执行kubectl apply -f webapp-crd.yaml过几秒用kubectl get crd | grep webapp就能看到webapps.apps.example.com说明 API 类型已经注册成功。接着写一个实例文件my-blog.yamlapiVersion: apps.example.com/v1 kind: WebApp metadata: name: my-blog spec: image: nginx:1.24 replicas: 2 domain: blog.example.com env: - name: TZ value: Asia/Shanghai执行kubectl apply -f my-blog.yaml然后kubectl get webapps也可以简写成kubectl get wa。现在你应该能看到 Domain 和 Replicas 两列输出。注意这时候执行kubectl describe webapp my-blog会发现只有 spec 内容没有任何 status 字段。这是正常的因为我们还没给这个 CRD 配 controllerAPI Server 不会主动填 status。很多新手在这里以为创建失败或者 controller 没装上其实这就是“定义已完成实现未启动”的状态。3.4 校验机制让错误配置在入口就拦下schema 里的约束不是摆设。比如把replicas设为 0 或者 30kubectl apply会直接报错信息类似spec.replicas: Invalid value: 30: must be less than or equal to 20。把image字段删掉也会被拒绝因为required已经声明了。这套校验发生在 API Server 侧即使绕过 kubectl通过 curl 或客户端发起请求一样会被拦截。它带来的好处是你可以把大量配置错误拦截在源头controller 不需要再写一堆防御性代码去处理非法输入。实际使用中建议把kubectl explain用起来。CRD 注册后kubectl explain webapp.spec会输出 schema 中定义的字段结构这对研发同学写资源清单非常友好。另外注意v1 的 CRD 默认情况下会对未知字段做 prune剔除如果用户打错了一个字段名kubectl apply不会报错但kubectl get -o yaml里看不到错字段这个行为容易造成困惑。如果想要更严格可以配合 CEL 或 admission webhook 增加额外验证这一部分放在第 5 章讲。4. 让 CRD 真正干活Controller 与调谐流程4.1 CRD 只是数据字典Controller 才是引擎到了这一步你应该已经发现光有一个 CRD 什么也做不了。要让它变成能自动拉起一套网站必须写一个 controller。Controller 的概念可以理解成一个一直盯着集群看的巡检员它用 Kubernetes 的 Watch 机制监听webapps资源的变化拿到一个对象后对照对象的spec去查看集群里是否已经有了对应的 Deployment、Service、Ingress有且配置一致就不动作没有或者配置有偏差就通过 Kubernetes API 去创建、修改或删除。整个过程不断重复直到实际状态收敛到期望状态。Controller 的工程实现可以自己写也可以用现成框架。生产上绝大多数 Operator 都是基于 Go 的 controller-runtime 写的它把 informer、workqueue、leader-election 都封装好开发者只需要实现一个Reconcile函数。如果团队是 Python 或 Node也可以用对应的 client 版本实现一个简化 controller前提是自己处理事件重试和状态冲突。我的建议是如果只是快速验证用 Python 的 kubernetes 客户端写一个 watch 循环完全够用如果要做成生产级 Operator认真上 controller-runtime它避免了很多本地缓存和并发回调的坑。4.2 一个最简 Controller 的观察、对比与执行用 Python 演示核心逻辑会更容易让人看懂。下面不是完整可上生产代码但已经把最重要的流程串起来了from kubernetes import client, config, watch def reconcile(webapp): ns webapp[metadata][namespace] name webapp[metadata][name] spec webapp[spec] dep_name fwebapp-{name} apps_v1 client.AppsV1Api() desired_replicas spec.get(replicas, 1) try: dep apps_v1.read_namespaced_deployment(dep_name, ns) if dep.spec.replicas ! desired_replicas: dep.spec.replicas desired_replicas apps_v1.replace_namespaced_deployment(dep_name, ns, dep) except client.exceptions.ApiException as e: if e.status 404: dep_body { apiVersion: apps/v1, kind: Deployment, metadata: {name: dep_name, namespace: ns}, spec: { replicas: desired_replicas, selector: {matchLabels: {app: name}}, template: { metadata: {labels: {app: name}}, spec: {containers: [{name: main, image: spec[image]}]}, }, }, } apps_v1.create_namespaced_deployment(ns, dep_body) else: raise def main(): config.load_kube_config() crd_api client.CustomObjectsApi() stream watch.Watch() for event in stream.stream(crd_api.list_cluster_custom_object, groupapps.example.com, versionv1, pluralwebapps): evt_obj event[object] if event[type] in (ADDED, MODIFIED): reconcile(evt_obj) # DELETED 情况记得清理 Deployment这里省略这段代码有几个关键点。watch会保持长连接推送事件事件类型有ADDED、MODIFIED、DELETED。reconcile函数需要是幂等的重复执行多次结果一致因为不管事件来几次都是先对比再操作。这里最常被忽略的是处理DELETED事件当用户删掉一个webappcontroller 应该清理对应的 Deployment、Service、Ingress否则集群里会留下一堆孤儿资源。实际操作中还要设置 Finalizer否则用户删 CR 的流程会失控这个问题放到第 6 章讲。4.3 别把 Controller 当成事件回调来用在带团队时我发现一个非常普遍的误解很多人以为 CRD controller 是“加一个注解然后触发一个业务回调”的事件系统。这是对声明式控制最大的误读。Controller 不保证事件一定会被及时处理它保证的是“当前实际状态向期望状态持续收敛”。比如你的 controller 崩溃了十分钟这期间所有 CR 变化可能排队也可能被跳过等 controller 恢复后它不应该依赖丢失的事件而应该通过 List 全量扫描把每个 WebApp 重新 reconcile。这也是为什么 controller-runtime 框架往往采用 workqueue 加周期性的 re-list而不仅仅是 watch。如果确实需要一个“CR 变更后立刻执行一次脚本”的需求CRD 也能做但要注意把执行结果写回 status并且 controller 要能处理执行失败的情况提供重试与退避。换句话说哪怕业务是一次性任务也要按“可重入任务”来设计。这一节最后留一个经验生产 controller 一定要加 leader election多副本同时跑同一个 controller 会引发竞态出现 Deployment 被反复重建的诡异现象。我们曾经用四个副本跑过一段时间最后查出就是没用 leader election 导致教训很深。5. 企业落地必用的高级配置5.1 用 AdditionalPrinterColumns 提升 kubectl 巡检体验前面 CRD 定义里已经放了一个additionalPrinterColumns示例这里展开说说它的价值。默认情况下kubectl get webapp只会显示NAME和AGE两列对状态了解很有限。通过给 CRD 增加打印列比如域名、副本数、当前状态、Ready 数量巡检时一眼就能判断资源是否健康。列定义写在 versions 节点下面jsonPath 直接指向字段路径比如.status.readyReplicas。它不会改变存储和 API 结构只是 kubectl 打印的辅助元数据所以后续增加一列不会带来版本兼容问题非常推荐从第一版就加上。用一手经验说话我们在平台上线了三十多个 CRD凡是用了additionalPrinterColumns的资源大家巡检效率远远高于看完整 YAML 的资源。另外还可以配合kubectl get xxx -o wide如果列足够多Debug 时可以少打很多命令。运维同学在 Grafana 接入告警后平时看板都不用进直接在终端对着列输出排查速度提升非常明显。5.2 Subresource 的权限隔离status 与 scale默认 CRD 的所有字段都平等任何能读写该资源的用户都可能直接篡改 status。这不是企业想要的。通常我们希望“普通用户只能改 speccontroller 才有权写 status”这就要启用子资源。你只需在版本定义中加一段subresources: status: {}加上之后API Server 会为/status单独生成一个 HTTP 端点。kubectl get webapp my-blog -o yaml仍然能同时看到 spec 和 status但普通客户端如果尝试把整个对象写回去并修改 status会被拒绝或忽略只有 controller 通过 status 子资源更新才生效。这个机制极大保护了控制面数据的可信度也符合声明式架构中“观察结果由控制器汇报”的原则。除了status还有scale子资源。启用后能支持kubectl scale webapp my-blog --replicas3以及 HPA 自动扩缩容。YAML 如下subresources: status: {} scale: specReplicasPath: .spec.replicas statusReplicasPath: .status.replicas labelSelectorPath: .status.selector不过要注意如果spec.replicas和status.replicas字段没有定义API Server 会报错所以启用scale前要保证 schema 里有对应字段。这个功能的收益在于你的 CRD 可以接入 K8s 原生的扩缩容生态而不是自己再造一套轮子去监听外部指标。5.3 高级 OpenAPI 校验和 CEL 表达式CRD 的 schema 不只是声明类型和必填还支持很多约束。最常用的是minimum、maximum、pattern、enum等这些属于 OpenAPI v3 的基础件。到了 Kubernetes 1.25 之后CRD 原生支持 CELCommon Expression Language校验这让我们能表达跨字段的逻辑比如“域名不能等于 namespace 名”、“副本数必须大于 minReadyReplicas”。示例spec: type: object properties: spec: type: object properties: replicas: type: integer minReadyReplicas: type: integer x-kubernetes-validations: - rule: self.spec.replicas self.spec.minReadyReplicas message: replicas must be greater than or equal to minReadyReplicas.注意 CEL 校验是写在 schema 层级内的可以放在对象级、数组级或字段级。初期会有学习成本但比 admission webhook 更轻、更稳定。如果业务校验极其复杂比如要调外部系统校验配额才需要自建 webhook。我的经验是能用 CEL 解决的就别碰 webhookwebhook 一旦故障会直接影响集群写路径而且证书轮换、超时配置都是额外负担。5.4 多版本共存与升级策略CRD 资源上线只是开始真正麻烦的是后面改字段。你可能会从v1alpha1升到v1beta1或者给 v1 定义里新增字段。这里最安全的做法是协商好版本兼容策略。一个 CRD 可以同时声明多个 versions比如有v1alpha1和v1但只有一个是 storage 版本。API Server 在收到旧版本请求时会把对象自动转换到 storage 版本再存所以如果没有写转换逻辑不同版本的字段命名要尽量保持兼容或者使用无转换但字段仍然兼容的策略。如果两个版本结构差异很大就需要 conversion webhook那又是一大坨运维负担。我的建议很直接内部工具从第一版就用v1不要轻易引入v1alpha1给字段加限制时尽量宽松因为收窄限制会造成旧对象无法通过新校验API Server 在查询这些对象时可能直接报错。我见过有团队因此整个 CRD 无法升级最终只能靠写迁移 Job 人肉处理存量数据。如果真到了结构不兼容那一步宁可新增资源类型也不要强行改同一个 CRD。6. 实战中的坑与排查套路6.1 资源创建成功但没反应我们经常收到这种反馈kubectl apply一个 CR 成功后等了十分钟什么都没发生。第一步不是去看集群日志而是确认有没有 controller 在监听这个资源。可以kubectl get deploy看有没有 controller 部署的 Deployment再kubectl logs -f controller-pod看有没有对应的 watch 事件。常见原因是 controller 镜像没有启动、RBAC 没授权它访问这个 CRD或者 controller 连接的集群上下文不对。还有一种情况是事件到了但 controller 内部 panic 了这种往往在日志里有 stack trace。从经验来说超过一半的“没反应”都是权限或运行环境问题而不是业务逻辑问题。这里给一个排查命令清单先kubectl get crd确认 CRD 存在再看kubectl api-resources | grep webapp确认当前 kubeconfig 能访问最后看 controller 的kubectl logs。很多团队把 controller 权限授予到所有 namespace 时容易漏掉customresourcedefinitions本身记得 ClusterRole 里要加上对应 group。也可以单独开一个 terminal 执行kubectl get webapp -w观察 API Server 是否真的把事件推给了 controller。现象可能原因快速排查动作CR 创建后无任何反应controller 没运行查看 controller Deployment 状态和日志CR 创建后偶尔触发RBAC 未授权 watch CRD检查 ClusterRole 中 apiGroups/resourcescontroller 崩溃重启代码 panic 或连接断开用kubectl logs --previous看前一轮日志事件丢失但最终能自愈依赖全量 re-listcontroller 日志看是否周期性 reconcile6.2 CRD 升级失败和字段被卡住CRD 升级时常遇的一个报错是structural schema error或must preserve unknown fields。v1 的 CRD 要求 schema 是“结构化的”即不允许随意声明 unknown fields。如果你最初把一个字段定义为 object 但没写 properties后续想往里面塞任意结构API Server 会拒绝更新。这时除非你显式设置x-kubernetes-preserve-unknown-fields: true否则没法绕过。这意味着设计 schema 时要给可能变化的 object 提前留出 map 类型或者直接声明为 preserve unknown fields否则后面升级基本等于重写。另外一个坑是你在新版本里给某个字段增加了minimum: 10的约束结果 etcd 里已经躺着 replicas5 的旧对象API Server 在版本校验或查询时可能会直接返回错误导致资源不可读。升级前一定要做数据兼容检查必要时写一个迁移 Job 用补丁把存量对象改到符合新 schema再更新 CRD。这个顺序不能反否则线上资源会突然全部 get 不出来。6.3 删除 CRD 之后 Namespace 一直 Terminating喜欢用 Finalizer 是好事但如果 controller 没有实现 finalizer 的清理逻辑删除 CRD 或 namespace 时就会卡在 Terminating。具体表现是删掉一个 WebApp 对象后它一直显示 Terminating因为 controller 收到 DELETE 事件后没有调用移除 finalizer如果你在整套 controller 已下线的情况下删 CRDnamespace 也会卡住。排查时kubectl get webapp my-blog -o yaml | grep finalizers看确认有webapp.example.com/finalizer然后去 controller 日志确认 DELETE reconcile 是否成功。解决办法其实很简单controller 在处理 DELETED 时先做清理操作比如删除对应 Deployment再通过 API 把这个 CR 的 finalizers 列表清空或者直接 PUT 不带 finalizer。如果没有 controller你可以手工 patch 移除 finalizer 应急但不建议养成习惯因为可能留下孤儿资源。更根本的做法是在 CRD 设计阶段就想清楚哪些资源生命周期绑定 CR哪些需要独立保留。6.4 RBAC 与资源名的坑最后整理一个高频误区RBAC 规则里的resources字段写的是复数资源名也就是webapps不需要带 group。很多人会把apiGroups写成extensions或者空或者把resourceNames写错结果 controller 启动后有权限读取但 watch 被拒。还有自定义资源的名字偶发会与内置资源重名在kubectl get all里展示时很容易混淆所以 group 尽量使用有辨识度的域名不要用太通用的example或demo。我见过有人用apps作为 group结果和内置apps组重叠权限规则全乱了排查了一下午才发现是命名冲突。关于 CRD 本身的运维我还有一个习惯给到读者把 CRD 的配置文件提交到 Git用 ArgoCD 或 FluxCD 做持续交付这样版本可回滚环境差异可追踪。生产环境改 CRD 一定要走双人 code review因为这个文件一旦出错影响的是整个集群的 API 面。我个人在实际操作中的体会是CRD 的入门门槛不高写一个 CRD YAML 十分钟就能学会但真正让它健康运行一年依赖的是你对 Kubernetes 控制循环、RBAC、版本兼容这些底层机制的尊重。我不止一次看到团队因为图省事跳过 Finalizer 或 status 子资源最后在运维事故里付出更大代价。不要急先把一个最小可用的 CRD 加上 controller 跑通再逐步加校验和高级功能。这套能力一旦掌握它带给你的不仅是“能自定义资源”更是一种把业务需求翻译成集群能力的架构思维。最后再分享一个小技巧每新增一个 CRD先写使用说明和 demo YAML这些文档的价值会在半年后你忘了细节时彻底体现出来。