接手新项目的第一天,同事就甩来一段报错截图:Deployment创建成功,可 Pod 一直卡在ImagePullBackOff,几个人排查了半天,最后发现清单里把imagePullPolicy拼成了imagPullPolicy。这种场景在 K8s 群里太常见了——Kubernetes 资源配置清单看起来就一段 YAML,真出问题的时候,错一个字母、少一个缩进、选错一个 apiVersion,都能让人折腾大半天。
这篇是 K8s 系列的第八篇,专门聊聊资源清单。我不会给你抄一堆官方文档,而是把我在真实项目里写清单、调清单的完整思路拆开讲:一个清单的骨架是什么、每个字段到底在跟集群说什么、哪些坑是我踩过之后才明白的、以及怎么用 kubectl 自带能力省掉手工排错的痛苦。如果你已经会用kubectl run和kubectl get,但一说到写 YAML 就心里发慌,这篇应该能帮你把整条链路串起来。
1. 先搞明白:资源配置清单到底在跟集群说什么
1.1 它是"期望状态"的书面表达
很多人写清单,第一反应是"这就是 K8s 的配置文件",这么理解其实不够准确。配置文件描述的是"程序的运行参数",而 K8s 清单描述的是"整个集群里应该长成什么样子"——我想要 3 个 Nginx 副本,每个副本 512MB 内存上限,要能够通过 Service 访问到它们。你把这些写进清单,交给 API Server,剩下的由控制器去干。
这个区别非常关键。命令式操作是"你告诉集群做什么",比如kubectl run、kubectl scale,一条命令一个动作;清单是"你告诉集群最终要变成什么样子",集群里的控制器会自己去比对当前状态和目标状态,差了就补,多了就收,这个循环也就是常说的 reconcile loop。所以配置清单在交付、迁移、备份场景里特别有价值——它不再是一串操作步骤,而是一份可持久化的"集群期望说明书"。
我在团队里一直强调:能用清单管理的资源,尽量不要用命令去改。原因很简单,命令没有留下审计痕迹,别人接手的时候根本不知道这个 Service 当时为什么选了 30000 端口;而清单一提交,git 历史里清清楚楚。这也是后面讲kubectl apply时的一个前提认知。
1.2 一个最小清单的骨架拆解
看一份最简单的 Deployment 清单,很多新手会问:为什么有的字段缩进多、有的字段缩进少?为什么containers是个带横线的列表,而metadata下面却直接是键值对?
先看骨架:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.25.3 ports: - containerPort: 80逐层拆:apiVersion声明的是"这份清单使用哪套 API 规范",kind声明资源类型,metadata是身份信息,spec才是这个资源真正想要的配置。Deployment 的spec下面比较特殊,它里面嵌了一个template,这其实就是"我要把 Pod 生成成什么样"的模板,template.metadata里面给 Pod 打标签,template.spec里面写容器配置。
刚开始不熟悉层级关系的时候,不要靠死记硬背,而是用kubectl explain一级一级往下查。比如想查 Deployment 的 spec 支持哪些字段,就敲kubectl explain deployment.spec;想查容器的字段,就kubectl explain deployment.spec.template.spec.containers。这个命令是官方文档的浓缩版,而且版本一定和你集群匹配,比在网上乱搜复制粘贴靠谱得多。
1.3 为什么命令式操作不能替代清单
总有人说"我用kubectl run nginx --image=nginx也能跑起来,为什么非要写 YAML?" 短期看确实可以,但一旦涉及下面的场景,命令式就会暴露问题:
- 你没法把
kubectl run这行命令直接提交到代码评审里; - 别人无法知道你当时还加了哪些 flag;
- 集群重建、环境迁移时,事情得重来一遍;
- 出问题时想回滚,没有一份"之前到底是什么样"的存档。
而清单可以把整个期望状态放在 git 里托管,代码评审、diff、留痕全部顺理成章。我经历过一次生产集群节点池故障,因为所有核心应用都有清单存档,新集群一键批量 apply 就恢复了;反观旁边那些用命令随手起的临时服务,恢复的时候等于重新配置一遍。所以,配置清单不是"写文档",它本身就是一个工程化资产。
2. 动笔之前先定两件事:API版本和资源类型
2.1 apiVersion不是可以照抄的装饰字段
我见过不少人从网上复制清单,第一行apiVersion往往原封不动跟着复制。要知道,不同资源类型的 apiVersion 规则完全不同,而且随着 K8s 版本演进,老版本会被废弃。
举一个最经典的例子:Ingress 资源。K8s 1.19 以前,很多人写apiVersion: extensions/v1beta1;后来官方把 Ingress 挪到了networking.k8s.io/v1,旧版本在新集群里直接创建失败,报错类似no matches for kind "Ingress" in version "extensions/v1beta1"。同样的事情也发生在Deployment上,早期版本可能见过apps/v1beta1,如今稳定版是apps/v1。
那怎么判断该用哪个版本?最简单的方式是看集群支持什么,而不是看网上的教程写了什么。两个命令足够了:
kubectl api-resources | grep deployment kubectl api-resources | grep ingress输出里会告诉你资源名、所属 API 组、默认命名空间行为等信息。比如 Deployment 对应的是apps/v1,Ingress 对应的是networking.k8s.io/v1。api-resources列出的版本就是当前集群真正支持的版本,照这个写,基本不会踩"版本不存在"的坑。
2.2 kubectl api-resources 与 kubectl explain 的正确用法
kubectl api-resources解决的是"这个资源在当前集群存不存在、属于哪个 API 组";kubectl explain解决的是"这个资源可以写哪些字段、每个字段什么类型"。两个配合起来,就是一本和集群版本完全同步的活文档。
举个例子,我要写一个 CronJob,不确定字段怎么写的时候:
kubectl explain cronjob kubectl explain cronjob.spec kubectl explain cronjob.spec.jobTemplate.spec.template.spec.containers每一层往下走,输出的结构都跟 YAML 的层级一一对应。输出里还会标明字段是 required 还是 optional,required 就是写清单时必须带上的,漏了会被 API Server 拒绝。我习惯把 explain 当成"查字典"而不是"看教程",遇到不确定的字段直接查,几分钟就能把一份陌生资源的清单结构摸清楚。
2.3 字段增删的版本陷阱
apiVersion 的变化不只是字符串变化,字段的语义和约束也可能跟着变。还是以 Ingress 为例:比较老的版本里,pathType字段是可选的,到了networking.k8s.io/v1,它就变成必填项了,如果你不写pathType: Prefix或pathType: Exact,apply 会直接被拒绝。很多从旧教程抄 Ingress 清单的人,第一反应是"为什么明明照着抄还是报错",其实差别就在这个版本迁移上。
另外一些字段在稳定版里会被重命名。比如早期extensions/v1beta1的某些字段,到了新版本可能改成完全不同的名字,kubectl explain查不到就说明这个字段在当前版本根本不存在。你能看到网上成百上千份"能跑"的旧清单,但里面的字段可能在新版本已经被替换了。所以在动笔之前先确认版本,不是吹毛求疵,是避免把时间浪费在版本迁移上。我自己现在写任何新清单,第一步永远是看一眼kubectl api-resources,第二步用kubectl explain检查字段,这两步花不了两分钟,但能挡住后面一小时的排错。
3. 元数据区比看起来更重要:名字、命名空间和标签
3.1 name/namespace/uid 这些字段的边界
metadata区最容易被人忽略,但这里也是很多线上事故的起点。先说name,它一旦在某个资源类型里定了,就不能随意乱改,因为很多控制器、Service、Ingress 都是通过名字来找目标的。名字也有格式限制:小写字母、数字、中划线、点号,而且不能以数字或中划线开头。想不明白规则的时候,直接用kubectl create deployment --dry-run=client -o yaml生成一个,看它生成的合法名字长什么样,比自己硬记规则更直观。
namespace是另一个高频疑问点。不写的话,资源默认落在default命名空间。对于初学者,namespace 的直观价值就是"把资源隔开"。但它带来的问题往往是"我明明创建了,为什么查不到"——大概率是当前 kubectl 上下文在另一个 namespace。我建议在较正式的环境里,每个业务用固定 namespace,并把所有资源的清单都显式写上 namespace 字段,杜绝隐式依赖。
还有一个常见误区:把metadata.uid、metadata.resourceVersion、metadata.generation这类"系统维护字段"手动写进清单。这些字段是集群内部用来做并发控制和版本追踪的,写入属于"修改系统元数据",直接报错或者被忽略。规范做法是清单里只保留 name、namespace、labels、annotations 这些可写字段。
3.2 labels 与 selector 的联动机制
labels(标签)和 selector(选择器)是 K8s 里特别容易出问题的组合,原因在于它们不是"随便填的备注信息",而是资源关联的桥梁。
拿 Deployment 来说,Deployment 的spec.selector.matchLabels必须能和template.metadata.labels匹配上。Deployment 创建之后,selector 是不允许修改的,因为控制器要基于它去往现有 Pod 上"贴标签管理"。如果 selector 改了,ReplicaSet 就找不到自己该管哪些 Pod,滚动更新会直接乱掉。
再往上一层:Service 要把流量转发到 Pod,靠的也是 selector。Service 的selector要和 Pod 的标签对得上。标签多一个、少一个都不行——Selector 是精确匹配全部标签的。所以我在实际项目里有个习惯:Pod 模板里的标签尽量精简,只保留必需的标识标签,比如app、tier,不要图省事把一堆变更频繁的版本号也放进 selector,否则每次版本变化都要同步改 Service 的 selector,特别容易漏。
3.3 annotations 的实用价值
很多人分不清 labels 和 annotations,简单说:labels 是给系统"选资源"用的,annotations 是给人或工具"查信息"用的。annotations 可以放任意非结构化内容,比如联系方式、监控告警规则说明、Ingress 的某些控制器参数等。
Annotations 最大的好处是"不参与资源选择",所以你可以放任何想记录的信息而不影响系统行为。我最常用的场景:在关键业务 Deployment 上记录负责人和告警调整原因,例如team: platform、alert-notes: "扩容期间临时调大 limit"。团队排查问题的时候,一眼能看到当初为什么这样配置,省掉考古式翻聊天记录的过程。
4. spec常见字段的真实语义:被示例清单误导最多的地方
4.1 container配置:镜像、拉取策略与资源申请
进入spec.template.spec.containers,这是整个清单里大家最熟悉也最容易写错的地方。先说镜像,清单里的image只写名字和 tag,比如nginx:1.25.3;但注意,tag 其实是可变的。如果写nginx:latest,每次重新调度可能拉到的镜像都不一样,这在生产环境里是个很大的不确定性。我的建议是清单里尽量使用明确 tag 或摘要。
imagePullPolicy也是一个被大量误用的字段。默认情况下,如果镜像 tag 是latest,拉取策略是Always;如果 tag 是具体版本,默认是IfNotPresent。很多人手动把imagePullPolicy写成Always,结果发布一次就反复拉镜像,私有仓库没配好就各种拉取失败。我一般只在明确需要每次拉取新镜像的时候(比如开发环境热更新)才设置Always。
再就是resources,这块最值得展开。资源配置清单里的资源字段分requests和limits,这两个概念完全不同:
requests是调度器选择节点的依据,相当于"我至少要这么多资源才愿意被调度到这个节点上";limits是运行时限制,相当于"你最多只能用到这么多,超过了要么被限制,要么被干掉"。
CPU 是压缩型资源,超出 limit 会被限流(throttling),表现是变慢但不死;内存是非压缩型资源,超出 limit 会直接触发 OOMKilled,Pod 被杀。这也是为什么"内存写小 limit"在线上最容易引发问题——进程明明需要 600MB,你给了 512MB,它一启动就被杀,重启又被杀,反复 CrashLoopBackOff。所以写清单的时候我建议:requests 按真实基线写,limits 内存一定要给够余量,CPU limits 反而要慎重,给得太紧会让进程长期被限流,表现成莫名其妙的请求卡顿。
Containers 数组里的ports.containerPort也常被误解。它只是"声明"容器监听哪个端口,方便其他资源查看,并不会因为你不写这个字段,容器就监听不了对应端口。真正决定行为的是容器进程本身。所以端口写不写都不影响服务是否可达,但写了之后,后续暴露端口、排查网络问题会方便很多。
4.2 服务如何找到Pod:从selector到端口流
Service 是理解资源清单怎么串联起来的关键。一份常见 Service 清单长这样:
apiVersion: v1 kind: Service metadata: name: nginx-demo-svc spec: selector: app: nginx-demo ports: - port: 80 targetPort: 80 protocol: TCP type: ClusterIP这里有几个字段要讲清楚:
selector:决定流量转发给哪些 Pod,Pod 的 labels 必须完全匹配;port:Service 对外暴露的端口,其他服务通过服务名.命名空间:port访问;targetPort:Pod 内容器实际监听的端口,流量进来后要从port转到这个端口上;protocol:一般写 TCP,偶尔用 UDP 的注意别漏;type:默认 ClusterIP,只在集群内部可达;要外部访问可以改成NodePort或者LoadBalancer。
如果targetPort忘记写,默认会等于port,这在容器监听端口和 Service 端口不一致的时候,流量就全丢了。很多新手把 Service 写在defaultnamespace,Pod 也在defaultnamespace,靠 Service 名访问没问题;一旦拆分 namespace,记得要写完整的 Service DNS 名称。
4.3 从"Nginx + NodePort"实例串起完整清单
把 Deployment 和 Service 串起来,看一个能直接落地的小例子:
apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo namespace: web spec: replicas: 2 selector: matchLabels: app: nginx-demo template: metadata: labels: app: nginx-demo spec: containers: - name: nginx image: nginx:1.25.3 ports: - containerPort: 80 resources: requests: cpu: 100m memory: 128Mi limits: memory: 256Mi --- apiVersion: v1 kind: Service metadata: name: nginx-demo-svc namespace: web spec: selector: app: nginx-demo ports: - port: 80 targetPort: 80 protocol: TCP type: NodePort提交之后,访问kubectl get svc -n web会看到 Service 分配了一个 NodePort 端口,比如31234。这个时候的链路是:外部请求到节点 31234 端口 -> Service 规则把流量转到某个 Pod 的 80 端口 -> 由 Nginx 进程处理。把这段链路想清楚,后面排查网络问题就有方向:先看 Pod 是否 Ready、再看 Service 的 Endpoints 是否列出 Pod 地址、最后再看端口映射是否匹配。
5. 一套能复用的清单校验与回滚流程
5.1 dry-run 模拟提交的两种粒度
写完清单直接apply,如果出错就是一波告警和日志轰炸。更稳的做法是先做模拟提交,也就是 dry-run。kubectl 提供了两个看似相似但粒度不同的选项:
kubectl apply --dry-run=client -f nginx.yaml客户端 dry-run,只是本地做一次格式和 schema 检查,不发给 API Server。它能挡住明显的语法和缩进问题,但挡不住服务端侧的校验,比如字段值不在合法范围内、必填字段缺失。
更接近真实提交的是服务端 dry-run:
kubectl apply --dry-run=server -f nginx.yaml它会真正把请求发给 API Server,走完认证、鉴权、准入控制、校验这一整套流程,但不会持久化到 etcd。这相当于"试提交"一次,知道结果再决定要不要真提交。我在 CI 里就把服务端 dry-run 作为清单发布前的必备步骤。
还有个隐蔽但会用到的参数:--validate=false。默认情况下 kubectl 会在客户端做 schema 校验,但某些场景下,比如你要 apply 一个当前集群还没有 CRD 的自定义资源,默认校验会误报,这时候加--validate=false能跳过客户端校验,直接依赖服务端和 CRD 自己的校验逻辑。注意这是权宜之计,只在你清楚为什么关的时候去关。
5.2 diff:提交前看清改动
kubectl apply --dry-run=server能告诉你"这份清单能不能通过校验",但能不能看出来"它会对线上造成什么改动"是另一回事。kubectl diff就是解决这个问题的:
kubectl diff -f nginx.yaml它会把你本地清单的期望状态和集群当前实际状态做对比,像一个"git diff",看到哪些字段会被修改、哪些资源会被新建。这个命令太适合在运维变更评审阶段使用了,我能看到这次改清单改了 4 个字段,可能就是改了一个环境变量,也可能是把副本数从 2 改成了 20,提前发现的话,就少一次事故。
5.3 apply 之后如何核对与回退
提交完清单,只是"期望状态"更新了,真正的落地过程还要看控制器执行得怎么样。我核对时一般按顺序看四层:
kubectl get deployment -n web -o wide kubectl get rs -n web kubectl get pods -n web kubectl get events -n web --sort-by=.lastTimestamp先看 Deployment 是否显示可用副本数;再看 ReplicaSet 是否生成了新版本;再看 Pod 有没有 Running/Ready;最后直接看 events,里面会写清楚真正失败的原因,比如镜像拉取失败、探针失败、资源不足。events 是我最常上的"第一现场"。
真的改出了问题,apply的方式也有优势,因为apply会在资源对象里记录上次提交的清单内容,存在kubectl.kubernetes.io/last-applied-configuration这个 annotation 里。回滚逻辑很简单:把上次正常的清单重新apply一遍。如果你是每一个历史版本都提交了一份完整清单,配合 git 就能做到真正的时间点回退,这是命令式操作完全做不到的。
6. 不想从零手写?用生成器打底再裁剪
6.1 kubectl create 的快速生成套路
写了这么多,肯定有人问:我能不能不让 API Server 或者某种方式先把基础的 YAML 生成出来,我再改?完全可以,而且这是效率很高的做法。kubectl 自带生成器,一行命令就能输出一份合法清单:
kubectl create deployment nginx-demo --image=nginx:1.25.3 --port=80 --replicas=2 --dry-run=client -o yaml这条命令会在终端打印出一份完整 Deployment 清单。得到的结构是符合当前集群 API 版本的,字段约束也不会错,比纯手写可靠。类似的还有:
kubectl create configmap app-config --from-file=config.properties --dry-run=client -o yaml kubectl create secret generic db-secret --from-literal=password=xxxx --dry-run=client -o yaml kubectl create service nodeport nginx-demo-svc --tcp=80:80 --dry-run=client -o yaml这些命令的主要价值是把资源的骨架、必填字段、命名规则都处理好了,你只需要在生成结果上做个性化修改。
6.2 从生成清单到线上清单的裁剪顺序
我的习惯是:先生成,再按下面顺序裁剪:
- 删掉
status段。生成器有时会输出 status 字段,这是系统管理的,不应该手动写; - 检查
metadata里的 name、namespace 是否符合业务规范; - 补上
resources、env、volumeMounts这类运行必需字段; - 检查 label,确保和 Service、Ingress 的选择器一致;
- 用
kubectl apply --dry-run=server校验一遍再提交。
关于kubectl create configmap这类命令,它生成的清单里的data区就是你提供的键值、文件内容,后续如果要维护大量环境配置,建议让配置内容本身进入版本管理,而不是只在集群里有,不然集群重建的时候配置来源又变成"某个人本地的文件"了。
多环境管理的时候,我也不会每套环境复制一份完整的冗长清单。更推荐用渲染方式,把差异部分抽出来,基础部分复用,比如用 Kustomize 的base/overlay思路,或者用模板引擎生成多环境清单。配置清单一旦多起来,维护成本和出错概率会快速上升,这不只是写的问题,更是结构设计的问题。
6.3 关于"抄现成清单"的最后提醒
很多人写清单的最后一步,也是第一步:从网上找一份示例,改几个字段直接 apply。这条路径不是不可以,但照着抄之前,先对照kubectl api-resources看版本、用kubectl explain看字段,把不认识的字段删掉再提交。我处理过的现场故障里,相当一部分来自"示例清单里某个字段在当前集群已经废弃",提交时校验不报错,运行行为却和你预期完全不一样。
另一种常见情况是在生产集群里直接 copy 一个正在运行的kubectl get deployment -o yaml作为新清单。这个输出里会带上很多系统状态字段,比如status、metadata.generation、resourceVersion,这些字段手动写回去就是给自己埋雷。正确做法是拿kubectl get deployment -o yaml当参考,看别人的镜像、探针、资源怎么配,但写新清单时仍然要回到干净的骨架,只留该有的字段。
写配置清单这件事,越往后越像"写代码"而不像"填表格"。你需要理解每个字段的语义、版本之间的关系、资源之间的引用逻辑,还得有规范的发布流程兜底。我刚入门那阵子也整天复制粘贴,后来养成了"先用 kubectl 生成再逐字段清理 + 服务端 dry-run + diff 后提交"的习惯,配置相关的事故一下子就少了很多。最后分享一个小习惯:线上每个 namespace 都配一份资源配额(ResourceQuota),你会发现某些资源在创建阶段就被挡下来了,而不是等它跑到一半才出问题——这是一种更前置的保护。Kubernetes 的这套清单机制,上手时觉得繁琐,用顺了以后,反而会成为你最可靠的一层保障。