如果你把 Kubernetes 想象成一个极其守规矩的调度中心,那资源清单就是递交上去的“申请单”,每一行都要按它认的格式说话。很多人容器已经跑得很熟,反而在“写清单”这个环节卡住:能在命令行用kubectl run临时起一个 Pod,但要写出能长期躺在代码仓库里的 YAML,就总觉得哪里不对。我自己也经历过这个阶段,后来发现,资源清单的难点不在 YAML 语法,而在你能不能回答三个问题:这个东西属于哪个 API 版本、它和谁建立关联、它期望达到什么状态。这篇是 04 篇,正好把“Kubernetes 资源清单”从格式问题拆成设计问题来讲。
我先说结论:凡是能在命令行里做出来的操作,几乎都能用一份 YAML 清单复现,而且用清单管理最大的好处是可重复、可审查、可回滚。但反过来,只会敲命令的人写清单会有一种“词汇量不够”的感觉,因为命令自带默认值会帮你隐藏很多细节,写清单时这些细节全部暴露出来。所以这不仅是记字段,更是重新认识一次 K8s 的对象模型。
1. 资源清单的四段式骨架:apiVersion、kind、metadata、spec 各管哪一块
资源清单说穿了就是一个 YAML 对象,开头四行几乎可以套用到所有资源上:apiVersion告诉 API Server 用哪个版本的接口,kind告诉它创建什么类型的对象,metadata是对象的身份证,spec才是你真正关心的期望状态。这四个字段看着简单,但我帮别人排查清单问题时发现,恰恰是前两个字段最容易翻车。
1.1 apiVersion:版本号决定的是接口契约,不是“随便填一个”
很多刚接触的人会把apiVersion当成固定前缀,从网上抄一段就完事。实际上它不是常量,而是一组“接口契约”。同一个资源在不同版本里,字段含义可能完全不同;写错版本,轻则告警,重则直接报错拒绝创建。
Kubernetes 的 API 版本大致分三类:v1是一批很早就稳定下来的基础资源,比如 Pod、Service、ConfigMap、Secret、Namespace 都在这里;apps/v1是工作负载控制器的主场,Deployment、StatefulSet、DaemonSet 都归它管;batch/v1管 Job 和 CronJob。这个分法不是随意定的,而是跟随资源自身的演进节奏。比如 Deployment 在很早期版本里挂在extensions/v1beta1下面,后来才逐渐收敛到apps/v1,现在如果你写extensions/v1beta1,API Server 会直接回一句no matches for kind,根本不认识。
我见过最典型的报错,是把 Deployment 写成了apiVersion: v1。看起来只是版本号差了半行,但 API Server 拿到后找不到对应的类型映射,会直接拒绝整个清单。这种事只靠人眼很难盯出来,所以后面我会提到dry-run的用法,它可以帮你在提交前就暴露这类问题。
1.2 kind:资源类型的分工,决定了清单文件的组织方式
kind字段决定你创建的是哪种对象,它和apiVersion是成对出现的。把apiVersion看作“用哪个接口”,那kind就是“调用哪个资源类型”。
按用途划分,常见资源可以分成四组:
| 类别 | 常见 kind | 典型作用 |
|---|---|---|
| 工作负载 | Deployment、StatefulSet、DaemonSet、Job、CronJob | 管理 Pod 的创建、更新、伸缩 |
| 网络接入 | Service、Ingress、NetworkPolicy | 提供稳定的访问入口和流量策略 |
| 存储 | PersistentVolumeClaim、StorageClass、PersistentVolume | 让应用使用持久化存储 |
| 配置与元数据 | ConfigMap、Secret、Namespace、ResourceQuota | 存放配置、约束范围、隔离环境 |
这个分类提醒我一件很重要的事:写清单之前,先想清楚要创建的种类。比如一个定时任务,需求是每天跑一次,你该写 CronJob,而不是写 Deployment;一个日志采集组件要在每台节点上各跑一份,该用 DaemonSet,而不是手写一堆重复 Pod。kind选错了,后面的spec怎么写都不顺手,因为每种资源都有自己的字段结构。
1.3 metadata:name 和 labels 才是最重要的“寻址信息”
metadata里最常见的是name和labels。name在同一个命名空间内必须唯一,很多资源名称还会受 DNS 命名规范限制,只能用小写字母、数字和短横线。你给 Deployment 起名叫my_app,apply 时可能不会立刻报错,但一旦通过 Service 去访问 Pod,名字里的下划线就会变成麻烦源,因为 Kubernetes 内部的域名解析不认下划线。
labels是容易被低估的字段。它不是装饰,而是整个系统的“寻址方式”。Deployment 通过标签选择器找到自己要管理的 Pod,Service 通过标签选择器决定把流量转发给谁,节点亲和、网络策略也大量依赖标签。所以写清单时我会先约定一套标签规范,至少要包含“应用名”和“环境名”两层,比如app: order-service、env: production。没有这套约定,清单之间就会像没有门牌号的建筑,谁都不知道该把流量送到哪里。
2. 工作负载清单的常见形态:Deployment 为什么比裸写 Pod 更值得用
工作负载类资源是所有清单里出现频率最高的一类,其中 Deployment 又是绝对主力。很多人问过我:既然最终也是创建 Pod,为什么不直接写 Pod?答案是,裸 Pod 没有“控制器”帮你兜底,它不会自愈,不会滚动更新,也不会保证副本数量,节点一挂,Pod 就跟节点一起没了。Deployment 这类控制器存在的意义,就是把“期望状态”和“实际状态”持续拉齐。
2.1 一份最小的 Deployment 清单,拆开看每行在做什么
下面这份清单是我在项目里常用的最小模板,去掉所有装饰,只看核心:
apiVersion: apps/v1 kind: Deployment metadata: name: order-service labels: app: order-service spec: replicas: 3 selector: matchLabels: app: order-service template: metadata: labels: app: order-service spec: containers: - name: order-service image: registry.example.com/order-service:v1.2.3 ports: - containerPort: 8080 resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi逐段理解一下。spec.replicas声明期望的副本数;selector.matchLabels声明这个 Deployment 管理哪些 Pod;template是 Pod 模板,里面 Pod 的标签必须和selector匹配,否则控制器会拒绝创建。template.spec.containers才是真正描述容器的部分,包括镜像、端口和资源配额。
有一个特别容易被忽略的点:template里的metadata.labels看起来很像metadata.name的配角,但它承担着“匹配依据”的职责。如果selector.matchLabels写的是app: order-service,而 Pod 模板里恰好漏了这个标签,apply 会直接报错,因为 Deployment 无法从一个不匹配的模板里选择 Pod 来管理。
2.2 selector 为什么不能改:不可变字段背后的设计
Deployment 一旦创建,spec.selector就不可修改了。很多初学者会在这里踩坑:想给 Pod 模板加个标签,顺手改了matchLabels,然后被 API Server 拒绝,错误信息大致是field is immutable。
这个限制不是 Kubernetes 故意刁难人,而是一种一致性保护。控制器需要根据 selector 去认领 Pod,如果中途允许修改,历史创建的 Pod 和新 Pod 会分裂成两拨,Deployment 就不知道自己到底该管理谁。正确的做法是,selector从创建之初就保持稳定,只固定最核心的那一两个标签,比如应用名;至于版本号、功能开关这类会变的信息,不要放进matchLabels,可以放进 Pod 模板的labels里,用来做发布跟踪和监控过滤,这样就不会触发不可变字段错误。
2.3 StatefulSet、DaemonSet、Job 和 Deployment 的分工差异
Deployment 适合无状态应用,但并非所有场景都适用。如果应用需要稳定的网络标识、持久化存储且每个副本都有独立身份,那更适合用 StatefulSet;如果需要在每台节点上都跑一个副本,比如日志采集、节点监控,用 DaemonSet 更省心;而一次性任务或定时任务,则用 Job 和 CronJob。它们的 YAML 结构大体相似,差别集中在spec的关键字段上。
| 控制器 | 适合场景 | 典型差异 |
|---|---|---|
| Deployment | 无状态服务 | 支持滚动更新、可任意扩缩 |
| StatefulSet | 有状态服务 | 每个副本有稳定名称和独立存储 |
| DaemonSet | 每节点一个实例 | 新节点加入时自动补副本 |
| Job | 一次性任务 | 执行完成后 Pod 退出 |
| CronJob | 定时任务 | 在 Job 基础上增加调度时间 |
选型是个见仁见智的过程,但有一个很实用的小建议:默认优先用 Deployment,只有它确实满足不了需求时再换其他控制器。从代码维护角度来说,Deployment 的清单结构是大家最熟悉的,接手成本最低;StatefulSet 因为要维护存储和网络标识,发布流程通常更谨慎,不建议无脑套用。
3. 网络和存储清单:Service、Ingress、PVC 是怎么协同工作的
光有工作负载还不够,一个应用想被访问、想保存数据,就得写网络和存储资源。这一部分我觉得是清单里最容易出现“各写各的”问题的地方:Deployment 写完,Service 写完,PVC 写完,看起来每个文件都没问题,但合在一起就是不通。原因往往是它们之间的关联字段没有对齐。
3.1 Service 的 selector:Pod 找不到,问题大多在这
Service 的作用是给一组 Pod 提供稳定的访问入口。它的清单核心是selector,用来匹配 Pod 的标签。这里很多人的误区在于,以为 Service 写个名字就能自动关联同名 Deployment。实际上,Service 只认标签,不认名字。
apiVersion: v1 kind: Service metadata: name: order-service spec: type: ClusterIP selector: app: order-service ports: - port: 8080 targetPort: 8080这个 Service 会把流量转发给所有带app: order-service标签的 Pod。如果 Deployment 的 Pod 模板里写的是app: order-svc,那恭喜,流量就全丢到空气里了,因为 Pod 和 Service 的标签对不上。这种问题在命令行里看kubectl get endpoints会非常明显:endpoints 列表是空的,说明没有 Pod 被选中。这也是我一直强调标签规范的意义所在。
targetPort也需要格外注意。它指向的是容器内部监听的端口,也就是 Dockerfile 里EXPOSE那个端口;而port是 Service 对外暴露的端口。很多排障经验里都会提醒:如果kubectl get service看到的端口没问题,但 curl 不通,先查targetPort到底写没写对。
3.2 Ingress 清单:从“七层转发规则”到 TLS 配置
Service 解决了集群内部的访问问题,想从外部访问,很多场景会用到 Ingress。Ingress 的清单本身并不复杂,它定义的是“转发规则”,真正干活的是后端的 Ingress Controller。你可以把 Ingress 理解成一张路由表,把域名和路径映射到不同的 Service 上。
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: order-ingress spec: rules: - host: api.example.com http: paths: - path: /orders pathType: Prefix backend: service: name: order-service port: number: 8080写 Ingress 有三个高频坑。第一,apiVersion一定要用networking.k8s.io/v1,旧版的extensions/v1beta1在新集群上已经不能用了。第二,pathType必须显式声明,是Prefix还是Exact,这决定路径匹配的严格程度。第三,如果有 HTTPS 需求,需要额外配置tls字段,并提前创建好 Secret 存放证书,不是简单加一行就能生效。
3.3 PVC 与 StorageClass:应用视角不关心底层磁盘
存储相关的清单,最常见的组合是 PVC 加 StorageClass。应用只需要声明“我要多少容量、什么访问模式”,底层真正创建卷的工作由 StorageClass 完成。
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: order-data spec: accessModes: - ReadWriteOnce storageClassName: standard resources: requests: storage: 10Gi这个清单里,accessModes决定了卷的挂载方式。ReadWriteOnce表示一个节点可以读写,ReadWriteMany表示多个节点都能读写,选型必须跟应用实际使用方式匹配。storageClassName绑定存储类型,如果集群里的默认 StorageClass 已经配置好,也可以省略,但显式写出来更清楚,尤其是在多环境部署时,不同环境可能用不同存储方案。
有个经验之谈:PVC 不要和 Deployment 写在同一个文件里,更不要在命名上和业务强绑定到没法复用。PVC 是命名空间级的资源,一个环境一套,单独一个文件方便按环境调整容量,也方便在需要时单独回收和重建。
4. 从报错里总结出来的清单细节:不可变字段、资源单位与 dry-run 验证
写了几年清单之后,我发现真正让人头疼的不是大段的业务逻辑,而是那些看起来不起眼的小细节。一个资源单位写错,一个布尔值没加引号,都可能让 apply 失败,或者更糟:apply 成功但行为完全不是你想要的。
4.1 资源请求和限制的写法:为什么 128M 和 128Mi 不一样
Kubernetes 的资源单位是新手最容易误解的地方。CPU 可以用整数核数,比如2,也可以用毫核,比如500m,意思就是 0.5 个核。内存则更为隐蔽,写128M不会报错,但它的含义是十进制下的 128 兆字节,也就是 128,000,000 字节;而128Mi代表二进制下的 128 兆,即 134,217,728 字节,两者相差约 5%。因为 Kubernetes 做调度和限制时按字节计算,这个差异在某些内存敏感的场景下会造成 OOM。
我的习惯是:CPU 一律用毫核单位m,内存一律用Mi、Gi这类二进制后缀,避免任何歧义。另一个相关建议是,requests和limits不要只写一档。只写limits虽然能限制容器使用上限,但调度器在做节点资源分配时,看的是requests。如果不写requests,调度器会按零资源占用去调度,等流量上来后,同一台节点上很容易积压大量 Pod,互相抢内存,最终触发杀进程。下面这两行是最小建议:
resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512Mi这个配比意味着:调度时按 100m CPU 和 128Mi 内存去占位,运行时可突发到 500m CPU 和 512Mi 内存。实际值怎么定,取决于业务压测和监控数据,但“至少要有 requests”这条底线,我是认真推荐的。
4.2 kubectl dry-run:apply 之前先拿到“服务端答案”
清单写好后,如果直接kubectl apply,一旦出错就是真刀真枪影响集群。Kubernetes 为此提供了dry-run机制,可以提前验证。我常用的组合是:
kubectl apply -f deployment.yaml --dry-run=client -o yaml--dry-run=client会在本地完成大部分校验,能抓到 YAML 解析错误、缺失必填字段、kind 写错这类问题,然后-o yaml把合并后的最终对象打印出来,方便你检查实际会被提交的内容。如果想更彻底,可以用--dry-run=server,它会把请求真的发给 API Server,走一遍准入控制流程,但不真正持久化。对于接入了校验类 Webhook 的环境,服务端 dry-run 能提前暴露更多问题。
这个习惯救过我不少次。有一次我改了一份 CronJob,时区相关的字段格式没写对,客户端 dry-run 没报错,但服务端 dry-run 直接给出参数校验失败的详细信息,省得我反复 apply 失败去猜原因。现在我的标准流程是:客户端 dry-run 改格式,服务端 dry-run 验准入,都过了再正式 apply。
4.3 YAML 解析的隐藏坑:引号、布尔值和缩进
YAML 本身不复杂,但有几个解析细节会让清单表现得很诡异。第一,缩进不能用制表符,必须用空格,而且层级要一致,混用空格和制表符会导致解析错误,报错信息还经常指不到真正位置。第二,某些字段的值如果像布尔值或数字,不加引号会被自动转换。比如enabled: yes在多数 YAML 解析器里会被当成true,而你可能原本只是想写字符串"yes";再比如镜像标签image: nginx:1.25没问题,但如果镜像名里带有@sha256:这类特殊字符,最好给整个值加引号,避免解析器误判。第三,多资源文件里用---分隔,如果漏掉分隔符,后一个资源会被当成前一个资源的附带字段,apply 时出现莫名其妙的字段不识别错误。
这些坑都不难规避,核心习惯是“写完即校验”。我在本地编辑器里装了 YAML 语法高亮和校验插件,又在 CI 里跑kubectl apply --dry-run=client,两层检查之后,基本能拦掉 90% 的低级错误。
5. 从单个文件到目录规模:我组织多环境资源清单的实操顺序
单个应用的清单可能只有三五个文件,但一旦应用变多、环境变多,文件数量会迅速膨胀。如果还是随手往目录里一丢,后面维护的人会非常痛苦。我自己就是从“十几个 YAML 平铺在一个目录里”的阶段走过来的,后来总结出一套相对顺手的组织方式。
5.1 文件放哪、怎么起名:把目录本身变成文档
我的习惯是每个应用一个目录,目录名就是应用名,目录下按资源类型命名文件:
order-service/ namespace.yaml configmap.yaml secret.yaml pvc.yaml deployment.yaml service.yaml ingress.yaml这样目录结构本身就说明了应用包含哪些资源,新同事接手时不用逐个打开文件猜内容。namespace.yaml单独放也有实际意义:命名空间是资源隔离边界,单独一个文件方便在环境初始化时创建,也可以和其他应用共享同一个 Namespace,视团队规范而定。文件命名固定一套规则后,批量操作也很方便,比如只发布网络相关的变更,可以精确到指定文件,而不是整个目录。
5.2 批量 apply 的顺序意识:先底座,后工作负载
写清单时文件是独立的,但 apply 之间是有依赖顺序的。最典型的例子:如果 Datastore 的 PVC 和 Deployment 一起 apply,且 Deployment 里的存储声明依赖该 PVC,APIServer 创建对象的顺序通常是先处理 PVC 声明,但 PVC 的卷绑定过程可能还没完成,Pod 调度就会卡在等待存储。还有一个更明显的例子是,Secret 还没创建就 apply Deployment,容器会因拉不到环境变量里的 Secret 而启动失败。
所以我的执行顺序很固定:先 Namespace,再 ConfigMap 和 Secret,再存储相关资源,最后是工作负载和网络入口。用目录批量操作时,我会按这个顺序拆成几次kubectl apply -f,而不是一个命令把所有文件都塞进去。这不是死板,而是让每一次 apply 的失败面可控,出了问题能迅速定位是第几步导致的。
5.3 多环境差异:用 Kustomize overlay 而不是复制粘贴
当同一套清单要部署到开发、测试、生产三个环境时,最直接的做法是复制三个目录,然后各自改配置。但复制粘贴意味着三份内容会逐渐漂移,开发环境改了镜像地址,生产环境没同步,最后谁也说不清哪个是真的。我在实操中更推荐用 Kustomize 处理环境差异。
Kustomize 的基本思路是 base 加 overlay。base 目录放所有环境通用的一份清单,overlay 目录只放差异部分,比如镜像版本、副本数、环境变量。命令也很简单:
kubectl apply -k overlays/production-k会按 overlay 里的 kustomization 配置,把 base 和 overlay 的 patch 合并成最终清单,再提交给 API Server。这样你不需要维护三份完整 YAML,只需要维护一份 base 和若干个小 patch,差异点全部显式暴露出来。这种组织方式配合 Git 分支或标签管理,每次变更都能追溯到具体提交,比复制粘贴目录要稳健得多。
还有一个额外的体会:无论用不用 Kustomize,清单都应该做到幂等。所谓幂等,就是同一份清单连续 apply 两次,结果不产生新的变化。Kubernetes 的 apply 合并策略天然支持这一点,但前提是不要在清单里写随机生成的名字、不要手工给资源加时间戳后缀,否则每次 apply 都会得到一个新的对象,根本没法稳定交付。
回到开头那个问题:资源清单到底难在哪里?我觉得难点从来不在 YAML 语法本身,而在你有没有把清单当成一份“契约”来写。版本号、标签选择器、资源单位、文件组织,这些东西在单个文件里看都很小,但把它们放进一个多应用、多环境、多团队协作的系统里,这些细节就决定了清单能不能被稳定地重复执行。写清单的这几年,我最大的感受是:宁可多花十分钟把目录结构和字段规范定好,也不要依赖“这次碰巧能用”的运气。把清单当作和基础设施的一次严肃对话,它会回报给你极大的确定性。