尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Kubernetes CRD 从概念到实践:自定义资源如何扩展业务控制平面

Kubernetes CRD 从概念到实践:自定义资源如何扩展业务控制平面 做了几年 Kubernetes 相关的平台建设工作如果让我只用一个功能来区分“会用 Kubernetes”和“理解 Kubernetes”我会毫不犹豫说 CRD。不是 Deployment不是 Service也不是 Ingress。理由是网上教程和公司运维手册教你的绝大多数是“如何使用现有资源”而 CRD 是少数几个要求你跳出既有 API、站在平台设计者角度看问题的机制。它决定了你的 Kubernetes 到底是“一个跑容器的平台”还是“一个能承载任意业务模型的统一控制平面”。我在协助团队搭内部 PaaS 时这种感受特别明显。业务方希望能用一种自定义的“算力组”资源把 GPU、CPU 池、存储池打包交给自研调度器统一分配数据库 DBA 希望部署 MySQL 时只需声明副本数、版本、备份策略主从关系、持久化、监控面板全自动完成数据团队想把采集、清洗、导出串成一条可版本化的“数据管道”。这些需求标准 Kubernetes API 里都没有。于是几乎每个团队都会走向同一条路写一种自定义的 API 对象用 Kubernetes 的 API Server 来存储和校验它再写一个 Controller 去推进它的实际状态。这个过程的核心起点就是 CRDCustom Resource Definition。这篇文章是系列第一篇我会先把概念讲透把术语拆开再用一个最小可复现的例子让你看到 CRD 的全貌。它适合刚接触 Kubernetes、被各种“自定义资源”“CRD”“自定义控制器”绕晕的同学也适合已经会用 Helm、YAML 部署应用但想更进一步理解平台扩展原理的人。这篇文章不会出现复杂代码只有一个 YAML 和一个样例后面系列再逐步上 Controller 实现。1. 先回答一个问题CRD 到底补上了哪块短板1.1 原生资源的边界能跑但一到业务域就露怯咱们先退一步想Kubernetes 本身提供了什么Pod、Deployment、Service、ConfigMap、Secret……这些资源覆盖的是“容器化应用如何部署、如何暴露、如何存配置”这一层。它设计得非常好但要直接拿它来表达业务马上捉襟见肘。举三个典型场景。场景一数据库交付。传统方式里 DBA 手工执行建实例、做主从、配备份。跑到 Kubernetes 上以后你可以用 StatefulSet 一堆 Job CronJob 拼一个脚本体系从配置管理到容灾切换全部自己维护。但问题是这堆脚本和 YAML 是“过程式”的不是“声明式”的。一个人可以把这套脚本维护得很好但换一个人就抓瞎更别说要支持几十套集群、不同版本、不同备份策略。场景二接入外部服务。你的平台可能依赖某个云厂商的 API比如云数据库、对象存储、CDN。Kubernetes 原生资源不知道这些 API 是什么。你只好把云 API 的凭证放在 ConfigMap 里写一堆 Job 或定时任务去调用创建、删除、更新状态反馈全靠“打日志”。出问题的时候你并不知道那个外部资源现在到底处于什么状态。场景三抽象视角。老板问你“咱们这套系统里有多少个‘业务应用’”你没法用一条 kubectl get 命令回答因为“业务应用”这个概念在原生资源里不存在。它可能由 Deployment、Service、Ingress、ConfigMap 拼成。你得写脚本聚合聚合完还要自定义排序规则、状态规则。这三类需求的共同特征是什么它们都在要求“把一个业务概念变成 Kubernetes 资源”。数据库实例是一个资源云服务引用是一个资源数据管道是一个资源。这种时候原生 API 满足不了CRD 就是为“让 Kubernetes 认识你的业务对象”而生的机制。1.2 没有 CRD 之前大家都是怎么硬撑的我见过许多团队在选择 CRD 之前先经历了相当痛苦的过渡期。这个过渡期里的做法值得拿出来对比一下否则你很难体会 CRD 的不可替代性。第一种是 ConfigMap 脚本硬编码。把配置、依赖关系、状态全部塞进 ConfigMap再用一个 Job 或定时任务去读。这种方法能跑但你没法用 kubectl get 这类原生命令看到“对象是否创建成功”没有校验没有版本管理没有事件和状态。第二个团队来了光是搞懂“什么脚本读什么 ConfigMap”就要一周。第二种是 Helm 模板堆叠。Helm 确实让“多个 YAML 打包”方便了但它的定位是“包管理”不是“资源抽象”。你把 MySQL 主从拓扑写成一套 Helm chart每次想查询“现在有几个实例、分别什么状态”还是要写脚本去遍历 Release、Status、Pod。层次更复杂时模板里的条件判断能写到崩溃。第三种是干脆起一个外部系统。比如搭一个工单平台或 CMDB 系统把 Kubernetes 之外的流程都管起来。这个方案能表达业务但把集群内外割裂成两套体系。应用部署在 Kubernetes 上状态却在外面记录中间只靠一堆 webhook 同步最后你会发现真正在跑的东西和系统里记录的东西永远对不上。这三条路我都走过最后的体会是与其绕开 Kubernetes不如让 Kubernetes 认识你的业务对象。CRD 在这个意义上是把“部署平台”升级为“业务控制平面”的一个关键开关。2. 把 CRD 拆开看核心概念和关键术语一次理清2.1 CRD 和 CR别混为一谈也不能拆开理解CRD 全称 Custom Resource Definition中文常叫“自定义资源定义”。CR 全称 Custom Resource就是“自定义资源”。这个“定义”和“实例”的关系和编程里的 class 与 object 的关系几乎一样。用生活类比解释。你设计了一张表格叫“客户登记表”上面规定了字段姓名、电话、地址。这张表格的设计图是 CRD。客户小明填了一张表内容是“姓名张三电话138”这张填好的表单是 CR。客户小王又填了一张又是另一个 CR。同一个 CRD 下面可以有很多个 CR字段结构相同数据内容不同。在 Kubernetes 里也一样。CRD 定义了资源的结构和校验规则真正的业务“实例”是 CR。所以你创建的对象是apiVersion: example.com/v1 kind: MysqlCluster metadata: name: my-mysql spec: replicas: 3 version: 8.0而描述 MysqlCluster 这个资源“长什么样”的文件才是 CRD。这里有个新手很容易犯迷糊的点CRD 本身也是一个资源可以被 kubectl get、kubectl delete。它由 apiextensions-apiserver 处理而普通 CR 由 kube-apiserver 处理后存储到 etcd。可以这么理解CRD 是“资源的资源”它描述的是“如何生成新的资源类型”。2.2 声明式 APICRD 能被 Kubernetes 接受的根本逻辑要真正理解 CRD理解 Kubernetes 的 API 和你平时调 REST API 有什么不同很重要。普通 REST API 往往是“动作型”的。你调用 POST /orders意思是“我要创建一个订单”服务端执行逻辑返回一个结果。Kubernetes 的 API 是“状态型”的你向 API Server 提交的不是动作而是“目标状态”。比如你写spec: replicas: 3你的意思是“我希望这个 Deployment 关联的 Pod 副本数是 3”。到底现在有没有 3 个 Pod你不管Controller 会持续调整。这就是声明式declarative与命令式imperative的区别。CRD 是这套声明式模型的自然延伸。你定义一种 CRD本质上是在向 API Server 声明“我要新增一种用户能提交的目标状态资源”。之后用户把目标状态写在 spec 里提交给 API ServerAPI Server 负责存储、校验、鉴权Controller 负责把“实际状态”拉向“期望状态”。所以从这个角度看CRD 的价值不止是“存数据”它让一个业务对象天然拥有版本apiVersion、命名空间namespace、字段校验schema、状态上报status、权限体系RBAC、级联删除owner reference等一整套 Kubernetes 基础设施能力。这些东西如果自己造少说要开发大半年用 CRD你是免费获得的。2.3 新手最容易忽略的 names 和 scope 配置当我初学 CRD 时网上示例我只看 schemanames 和 scope 经常照抄。后来在真实生产环境中发现这两个字段没设计好后面改起来非常痛苦。names 是资源名字体系包含四个字段plural复数名常作为 URL 的一部分也是 kubectl get 后面填的那个词singular单数名一般很少直接用kindAPI 对象在 YAML/代码里的类型名比如 MysqlClustershortNames短别名例如把 crd 起成 mckubectl get mc 就等价于 kubectl get mysqlclusters设计上有一组建议kind 用 PascalCaseplural 用 kind 的全小写复数形式shortNames 保持一个或两个小写字母不能和已有资源的 shortName 冲突。我曾见过有人把 plural 和 singular 写反导致 kubectl 命令要么用起来别扭要么文档和代码不一致。还有一个很隐蔽的问题资源的 plural 会影响它在 API 路径中的存在比如 /apis/example.com/v1/namespaces/default/mysqlclusters。一旦创建 CRDAPI 路径就固定了改名基本等于重做一遍。所以从第一天起就把 names 规划好非常重要。scope 只有两个值Namespaced 或 Cluster。它决定 CR 是隶属于 namespace 还是全集群唯一。像数据库集群、应用发布这类隔离明显、跟环境绑定的资源建议用 Namespaced像全局配置、云凭证、配额限制这类资源用 Cluster。选择的标准其实可以简化成一句这个资源会不会“同时被多个 namespace 引用”。如果不会用 Namespaced如果会用 Cluster。很多团队把后端 API 连接信息做成 Cluster 级资源就是因为它要被所有 namespace 引用这不是天生需要 Cluster而是因为设计时先考虑引用关系。3. CRD 只是“数据定义”真正干活的是 Controller3.1 别把 CRD 和 Operator 混为一谈很多人经常把 CRD 和 Operator 绑在一起讨论甚至觉得 CRD 就等于 Operator。其实它们完全不同。CRD 只是“定义一个资源类型”它只管数据存储、校验、查询Operator 是“实现一个资源生命周期管理逻辑”的控制器。再回到生活类比。设计图是 CRD填好的表单是 CR那谁去处理表单后面的实际业务比如客户多了要走审批流程、要建档案、要发通知这些逻辑需要一个“业务系统”来干这个业务系统在 Kubernetes 里就是 Controller包含 Controller 的完整方案通常叫 Operator。举例。Cert-Manager 引入了一个 Certificate CRD但真正的能力来自它的 controller。controller 盯住 Certificate 资源发现一个新增实例就去申请证书、创建 Secret。没有 Controller 的 CRD只是一张空表。Prometheus Operator 引入 ServiceMonitor CRDController 监控 ServiceMonitor 变化去生成 Prometheus 配置。核心逻辑都在 Controller。所以如果你只想“让 Kubernetes 能存储一种对象”只定义 CRD 就够了如果你想“让这种对象产生实际效果”必须配套写一个 Controller。从系列下一篇文章开始我们最终要做的就是补上这个 Controller。3.2 调谐循环理解 Controller 的关键“为什么”Controller 的工作机制叫调谐循环reconcile loop。这个循环的思路不复杂但很深刻。期望状态desired state来自用户提交的 CR 里的 spec。实际状态actual state是集群中各种资源真实的情况。调谐循环定期做一件三岁小孩都会的事对比期望和实际做动作弥补差距再对比、再动作……直到两者一致。要理解这个循环有一个特别贴切的比喻是空调的温控器。你设定目标温度是 26 摄氏度这是期望状态。测温探头读到房间当前温度是 30 度这是实际状态。控制器对比后发现差距为 4 度于是启动压缩机。过一会儿再测是 27 度继续制冷。等到温度到 26 度差距为 0停止制冷。房间温度升高重新开启。这个过程没有“一次性完成”而是永远在循环。Kubernetes Controller 也一样。它的循环可能是看到 mysqlcluster.spec.replicas3检查当前由这个 CustomResource 关联的 StatefulSet 副本数。如果不是 3把 StatefulSet 改成 3。改完后把状态更新到 CR 的 status 里比如 status.readyReplicas0、1、2、3。用户只要看 kubectl get mysqlcluster 的状态列就知道现在的实际状态是什么。这套机制最大的好处我深有体会它不是“调用一次就结束”的脚本而是每个资源的持久状态机。即使故障导致部分资源被删Controller 在下一次循环里就会补回来。也正因为如此Controller 的实现天然具备自愈能力。但要注意循环是“调和”不是“全能”。它不会自动帮你判断业务失败的原因。真正把业务逻辑写成调谐逻辑需要仔细处理错误、重试和状态更新这部分我会在系列第 4 篇专门展开讲。4. 手把手定义第一个 CRD以自定义数据库集群为例4.1 先想清楚 schema字段越清晰后面 Controller 越好写动手之前先说清楚为什么用“数据库集群”当例子。MySQL 集群包含主从、副本、版本、存储、备份等既涉及结构化字段又涉及需要 controller 更新 status 的场景非常适合演 CRD 的完整玩法。在我们写 schema 之前需要想清楚功能边界。对于一个最小 MVP我决定提供三个能力用户声明副本数 replicas1-5 之间用户声明 MySQL 版本 version用户声明每个实例的存储大小 storageSize这三个字段背后对应 Controller 将来要做的事创建对应副本数的 StatefulSet选择对应版本的 MySQL 镜像创建对应大小的 PVC。schema 设计得越贴近 Controller 的输入后续实现越不会返工。这也是一个实操经验最开始宁可少定义几个字段也不要贪多。CRD 的 schema 一旦开放校验规则后续变更字段是能做但很麻烦特别是涉及从非必填改成必填、修改 enum 范围这一类的操作。先把核心字段定下来其他能力可以通过系列后续的版本演进再加。4.2 完整 CRD 定义文件与提交步骤这是我实际验证过的一个最小版本直接用 apiextensions.k8s.io/v1 标准版本apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: mysqlclusters.example.com spec: group: example.com names: kind: MysqlCluster listKind: MysqlClusterList plural: mysqlclusters singular: mysqlcluster shortNames: - mc scope: Namespaced versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: replicas: type: integer minimum: 1 maximum: 5 version: type: string storageSize: type: string required: - spec subresources: status: {}看得懂这份文件就理解前面所有概念了。metadata.name一定要等于 spec 里的复数形式 点 group这是 CRD 一个容易踩的硬性约束。metadata.name 写错创建会直接报错。spec.group 决定资源的 API 分组例子里是 example.com未来 API 地址会是 /apis/example.com/v1。names 是用户跟资源交互的入口前面已经说过。versions 是一个列表API 支持多个版本同时存在但 storagetrue 只能有一个它决定哪个版本的数据实际写入 etcd。subresources.status 打开后Controller 才能更新对象 status 字段。提交的命令非常简单kubectl apply -f mysqlcluster-crd.yaml kubectl get crd创建之后我建议你立刻执行一条命令把 API Server 自动填充的默认配置和校验逻辑都捞出来看一遍kubectl get crd mysqlclusters.example.com -o yaml这个输出的后半部分能看到 OpenAPI schema 被自动编译进了 CRD 的状态里比看任何文档都有用。4.3 创建 CRD 之后立刻建一个 CR 看看效果CRD 已存在现在可以定义数据库集群实例apiVersion: example.com/v1 kind: MysqlCluster metadata: name: dev-mysql namespace: default spec: replicas: 3 version: 8.0 storageSize: 100Gi执行kubectl apply -f dev-mysql.yaml kubectl get mysqlclusters kubectl get mc # 短名别名同样可用 kubectl get mysqlcluster dev-mysql -o yaml此刻要注意你查看到的 CR 中只有 spec 字段没有 status 字段因为还没有 Controller任何人都不会去更新它。这正是“CRD 只是表格”的直接证据。你也可以故意把 replicas 写成 6然后 apply看 API Server 直接拒绝这是因为 openAPIV3Schema 里 maximum5。这种内置校验能力是 CRD 给你免费用的一张安全网。还有一个细节你会发现 kubectl get mc 的输出只有 NAME 和 AGE没有 READY 这类业务状态。要给 CR 增加业务状态列需要配置 additionalPrinterColumns它放在 versions 下面。Controller 更新状态后kubectl 就能展示成类似“3/3 就绪”的效果。示例versions: - name: v1 served: true storage: true schema: openAPIV3Schema: type: object properties: spec: type: object properties: replicas: type: integer minimum: 1 maximum: 5 version: type: string storageSize: type: string required: - spec additionalPrinterColumns: - name: Ready jsonPath: .status.readyReplicas type: integer - name: Version jsonPath: .spec.version type: string subresources: status: {}第一这个配置必须放在 versions 下面和 schema、subresources 平级。第二不同列 type 直接影响展示格式string、integer、date 别混用。我这边曾写错过一次 jsonPath漏了前导点导致整列始终为空查了半天才发现是路径写错。4.4 验证自定义资源 API 的完整流程为了更好理解 CRD 是怎么变成真实 API 的除了 kubectl还可以直接用 curl 访问。先拿到 API Server 地址和访问凭证APISERVER$(kubectl config view --minify -o jsonpath{.clusters[0].cluster.server}) TOKEN$(kubectl create token my-sa) # 换成你自己的认证方式 curl -k -H Authorization: Bearer $TOKEN $APISERVER/apis/example.com/v1/namespaces/default/mysqlclusters这个请求返回的应该是一个 kind: MysqlClusterList 的 JSON。你会发现它非常像一个“原生 Kubernetes API”的响应。这说明 CRD 创建之后不是模拟的而是真正注册到了 API 里。所有 Kubernetes 生态工具比如 kubectl、client-go、权限模型、审计日志都能直接识别这个新 API 类型。这个“原生身份”的价值怎么强调都不过分。完整验证时建议按这个顺序来先 kubectl apply CRD再 kubectl get crd 看 Established、NamesAccepted 状态是否为 True最后创建 CR用 kubectl get mc 和 curl 两种方式确认。如果 Established 是 False先去看 CRD 的状态条件大多数情况是 names 冲突或 metadata.name 拼写问题。5. 常见问题与避坑指南从第一版 CRD 到生产级使用的经验5.1 什么时候别用 CRD这是比“怎么用”更重要的问题CRD 虽好不是万灵药。我看到的最大误区是团队把所有配置都做成 CRD导致资源爆炸、权限混乱、运维复杂。哪些情况应该谨慎使用第一你的对象生命周期非常简单。如果一个配置只需要被读没有任何变更逻辑那 ConfigMap 就够。第二你只想方便地组织 YAML内容最终会被渲染成原生资源。这种适合 Helm 模板或者 Kustomize不应引入 CRD。第三你的团队暂时没有能力维护 Controller。只定义 CRD 但不写 Controller相当于只画表不发审批会产生越来越多的“僵尸资源”。反过来什么情况下 CRD 很强需要表达业务对象、需要对对象做生命周期管理、需要把外部系统抽象为集群内资源、需要权限隔离和环境隔离。像数据库、缓存、消息队列、监控目标、外部云服务这类“有生命周期的实体”特别适合。5.2 高频坑位排查每一条都是我实际踩过的为了让你少踩坑我把高发问题做成一张速查表。现象可能原因解决思路创建 CRD 报 metadata.name 不合法CRD 名不是“复数.group”格式改成 mysqlclusters.example.com 这种格式CRD 创建后 EstablishedFalsenames.plural 与系统已有资源冲突或 kind 冲突查看 status.conditions换一个不冲突的名字apply CR 被拒提示字段类型不对schema 里没写字段定义或格式不对在 openAPIV3Schema 里补充字段定义检查 requiredkubectl 输出只有 NAME/AGE没有业务列没配 additionalPrinterColumns或 jsonPath 写错在 versions 下补配置用 JSON 包体核实路径删了 Controller 后资源还在但没有变化正常因为没人执行 reconcile想清楚部署时序Controller 先于资源或把资源纳入发布流程升级 CRD 版本时发现旧字段删不掉schema 变更有限制部分字段不可移除按版本演进保留旧版本 served/storage并处理 conversion第一行那条是最常见的低级错误务必检查所有 YAML 的 metadata.name。另有一件事得提醒修改 schema 不能随意加字段更不要随便把已有字段设为 required。如果集群里已经有旧 CR收紧 required 会在资源更新时全部校验失败。所以生产环境里更稳妥的流程是先加字段但不设 required发布控制器新版本确认没问题后再收紧 required。5.3 一个取舍建议多版本、Conversion Webhook 和 finalizer再往深处说一个系列第一篇就值得记住的取舍建议。CRD 的版本机制把 schema 变化管理得很强但从 v1beta1 升级到 v1 或 v2需要处理三个概念served、storage、conversion。served 指 API 是否对外提供storage 指哪个版本的数据真正入库。如果几个版本 schema 不一致用户从 v1 切换到 v2 时API Server 并不会自动帮你转换字段你需要写一个 conversion webhook 来实现实际的转换逻辑。这个复杂度不是第一篇就能消化完的但至少现在就要建立观念凡是涉及 CRD 的升级都不能像改应用配置那样随意。一定要在改动前冻结目标版本、做兼容测试、带上 finalizer。finalizer 是保护资源删除前必须执行的清理逻辑的机制比如 Controller 在删除 CR 之前要先删掉云上资源才能放行删除。这几年凡是生产翻车我复盘下来很多都跟“默认 delete 就是真删”这件事有关。被 finalizer 卡住虽然让流程变慢但更安全值得从第一篇就开始养成习惯。创建 CRD 的最终建议也写在这里先小范围试点一个 CRD 对应一个明确业务对象配一个最小 Controller 验证全链路完全跑通了再去扩展更多资源。不要在没有 Controller 的情况下先把几百个 CR 铺到集群里那样你只是造了一张没人维护的“僵尸表”。系列下一篇文章我已经在准备会讲怎么用 controller-runtime 写一个最简单的 Controller让今天这个 MysqlCluster 真正“活”起来。到时我会把代码、异常排查、状态更新逻辑一起端上来今天先把这套概念地基打好。
返回列表