上周帮一个朋友排查生产环境故障,集群里某个服务从下午两点开始不断重启,日志滚动刷屏,一眼看去全是资源相关的错误。我第一反应不是去看代码,而是打开终端敲下kubectl get pod和kubectl describe pod,看到对象详情之后,问题很快就定位到了resources.limits上——内存上限顶得太死,容器一过阈值就被杀掉,于是无限循环地 CrashLoopBackOff。
类似的情况这几年我碰到过太多次。每次复盘,最后都会落到同一个问题上:对 Kubernetes 里的“资源与对象”理解得够不够透。很多人装好了集群、跑通了 Nginx 示例,就以为入门了,可一旦涉及线上排障、资源配额、滚动发布,马上露馅。这篇就来完整聊聊 K8s 资源与对象,以及我们日常到底用什么方式管理它们,从模型设计到 YAML 写法,从资源隔离到生产故障排查顺序,一次性讲清楚。
如果你正准备 K8s 面试,或者刚接触集群想系统梳理概念,又或者你已经维护着生产环境但经常被诡异现象绕晕,这篇都适合你。文章里所有内容都来自真实运维场景,不是文档搬运,按照这个思路往下走,你会少踩很多坑。
1. 先把K8s资源模型的底子打好:对象、资源类型和apiVersion
1.1 一切皆对象:K8s里的“对象”到底是什么
很多第一次接触 Kubernetes 的人会被“对象”这个词搞混。我们在写 Java、Python 时,new出来的那个实例也叫对象,两者根本不是一回事。K8s 里的对象是 API Server 里的一条条持久化记录,你通过kubectl看到的 Pod、Service、Deployment,本质上都是对象。你可以把它想象成数据库里的一张表,每行数据描述了一个“期望状态”,然后由控制器去把这个状态变成现实。
每个对象一般包含三大块:metadata存元数据,比如名称、命名空间、标签(labels)、注解(annotations)、UID;spec是期望状态,也就是“你想让它变成什么样”;status是当前状态,由系统实时更新。平时排查问题,status里的信息往往比日志还重要,比如kubectl get pod -o yaml里能看到status.conditions,会告诉你 Pod 为什么没就绪。
这个设计和面向对象编程里的“实例”最大的区别在于:K8s 对象是声明式的。你只负责把spec写清楚,至于怎么创建容器、怎么调度、怎么维护副本数,都不用手工干预,控制器会盯着status和spec的差距并不断逼近。
1.2 资源类型大盘:从工作负载到集群策略
K8s 资源类型非常多,刚接触时不用全部记住,但要有一个“分类地图”。我把日常用得最多的资源整理成了一张表:
| 分类 | 常见资源 | 作用 |
|---|---|---|
| 工作负载 | Deployment、StatefulSet、DaemonSet、Job、CronJob | 管 Pod 的创建、副本、更新和生命周期 |
| 网络接入 | Service、Ingress、EndpointSlice、NetworkPolicy | 暴露服务、负载均衡、流量策略 |
| 配置与密钥 | ConfigMap、Secret | 把配置和敏感信息从镜像里剥离开 |
| 存储 | PV、PVC、StorageClass | 持久化数据,解耦 Pod 和底层存储 |
| 配额与策略 | ResourceQuota、LimitRange、PodSecurity | 限制资源使用,保证集群不被单个应用打爆 |
| 集群系统 | Namespace、Node、RBAC 相关对象、CRD | 管理集群自身的结构和权限体系 |
这里面最容易被忽略的是“资源依赖”这件事。一个 Deployment 可能依赖 ConfigMap 里的配置、PVC 里的存储、Service 的访问入口,如果依赖对象没有创建成功,Deployment 本身是起不来的。比如你把环境变量写进了 ConfigMap,但 ConfigMap 和 Deployment 在同一个 Namespace 下才能直接引用,一旦跨 Namespace 就报找不到。日常排查时可以先用kubectl get configmap,secret,deployment -n <namespace>整体看一眼依赖项是否齐全,再往下查。
1.3 apiVersion 为什么总在变:版本里的门道
写过 YAML 的人应该都有一个疑惑:为什么有些对象的apiVersion是v1,有些是apps/v1、batch/v1,还有一堆看起来像“旧版本”的名字,比如extensions/v1beta1。这实际上和 K8s 的 API 分组、版本等级有关。
K8s 的资源归在不同的 API 组里,比如核心组用v1,工作负载在apps组,批处理任务在batch组。版本分为 alpha、beta、stable 三个阶段,alpha 版本不稳定,开箱可能就变;beta 版本基本可用;stable 版本才是生产环境应该用的。以 Deployment 为例,早期很多人写过extensions/v1beta1,这个版本早就不推荐了,现在必须写apps/v1,否则kubectl apply会直接提示 no matches for kind。
想查看某个资源当前支持的版本,可以用kubectl api-resources和kubectl explain pod.spec这类命令。我习惯在写 YAML 之前先kubectl explain一下,顺手确认字段大小写。版本问题踩过一次坑之后,我就给自己定了个规矩:所有线上 YAML 全部使用稳定版本,绝不为了图新功能去用 alpha 资源。
1.4 命名空间与集群级对象的边界
K8s 对象分两种:一种是限定在 Namespace 内的,比如 Pod、Deployment、Service;另一种是集群级的,比如 Node、Namespace、ClusterRole、CustomResourceDefinition。很多权限问题、资源隔离问题,都是因为没分清这个边界。你创建了一个 PVC,忘了指定 Namespace,它就会落到默认的 default 空间,然后 Deployment 在另一个 Namespace 里怎么都找不到它。
查看一个资源到底是 Namespace 级还是集群级,直接看kubectl api-resources | grep <资源名>,输出里会标明NAMESPACED列是 true 还是 false。这个操作在排查十字谜题的时候特别有用,有时候你以为是自己账号权限不够,实际是对象放错了位置。集群里像 kube-system、kube-public 这些内置 Namespace 也有各自的职责,我之前见过有人把业务应用直接丢到 kube-system 里,结果和系统组件抢资源,最后被 Evicted,尽量别这么做。
2. 声明式YAML才是管理对象的日常:apply、diff与资源编辑
2.1 YAML的固定骨架:每个对象都长一个样
K8s 对象的 YAML 骨架非常统一:apiVersion、kind、metadata、spec,有的对象还会有status。只要记住这个套路,看到任何陌生资源都能快速上手。下面给一个生产环境里常见的 Deployment 示例,我把需要注意的字段都标出来:
apiVersion: apps/v1 kind: Deployment metadata: name: order-service namespace: prod 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:2025.04.01 ports: - containerPort: 8080 protocol: TCP resources: requests: cpu: 500m memory: 512Mi limits: cpu: "1" memory: 1Gi readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5这里最容易被新手忽略的是spec.selector.matchLabels,它的值必须和template.metadata.labels保持一致,否则控制器会拒绝创建。一旦 Deployment 上线后,selector 是不能改的,改了就等于换了一个资源。每次 apply 之前我都会把这两处对照着检查一遍,省得被一堆无意义的报错浪费时间。
2.2 kubectl 命令矩阵:create、run、apply、edit 到底怎么选
Kubectl 提供了很多管理对象的方式,它们各有适用场景,也各有坑。kubectl create是命令式管理,它要求对象不存在,重复执行会报 AlreadyExists。kubectl run可以快速创建一个 Deployment,适合临时测试,但不适合长期维护——因为它的参数一旦固化到命令行里,后续更新必须靠其他命令补齐,很容易造成线上规格和实际配置不一致。
真正适合生产环境的是声明式的kubectl apply。它有个底层机制叫三方合并补丁,K8s 会把上一次 apply 记录的配置、当前线上配置、新 YAML 配置三方做合并,这样你只修改 YAML 里某一处字段,其他字段不会被莫名其妙覆盖。这也是 GitOps 能成立的基础。
kubectl edit则适合小规模临时修改,它会直接打开编辑器和线上对象交互。但我自己的习惯是:能用 YAML 解决的问题,绝不用 edit 去裸改线上对象。因为 edit 改完了不留痕,出了问题连回滚都不知道从哪开始。要改东西就改文件、走 apply,这是我在团队里反复强调的一条纪律。
2.3 我的日常发布节奏:dry-run、diff、apply 三步走
很多人在测试环境直接kubectl apply -f,报错了再慢慢看。这效率太低。我维护的流程是三步:
先kubectl apply --dry-run=client -f order-service.yaml,这一步只做客户端本地校验,能查出不存在的字段、格式错误;再kubectl apply --dry-run=server -f order-service.yaml,服务器端校验,能更准确地发现准入控制层面的问题,比如资源配额不足;最后kubectl diff -f order-service.yaml,把新 YAML 和线上当前对象做对比,差量一目了然。
没有问题之后再真正kubectl apply -f。这个流程看起来多花了几秒钟,实际上能救回很多次现场。我见过有人直接 apply 一个改坏了镜像版本的 YAML,把整个生产服务带崩,就是因为跳过了 diff 步骤。别嫌这步烦,线上事故的代价永远比这几十秒贵得多。
关于“资源编辑”还有一个高频场景:临时把 Deployment 的副本数调大。有人直接用kubectl scale deployment xxx --replicas=10,这没问题,但我更推荐把 YAML 里 replicas 改掉再 apply。因为后期如果别人重新 apply 旧 YAML,会把副本数改回去,造成一次意外缩容。把所有变更都沉淀到 YAML 里,也是一种可追溯性。
2.4 从YAML到GitOps:对象不该只活在命令行里
单独一个 YAML 文件管理起来还行,但业务一多,几十个应用分布在多个环境,很快就乱套。这时候可以考虑引入 Kustomize 或者 Helm。Kustomize 是 K8s 原生支持的配置管理工具,没有模板语法,只做叠加和覆盖;Helm 则是一套完整的打包体系,适合发布复杂应用。
再往上走就是 GitOps。简单说,git 仓库成为唯一事实源,CI/CD 工具监听仓库变化,自动把变更应用到集群。这样既保留了声明式管理的优势,又给了团队一个审计和回滚的抓手。我见过很多团队从第一阶段“手敲命令”到第二阶段“统一 YAML 管理”之后,线上故障率明显下降,原因很简单:人的记忆力不靠谱,而文件的变更历史是靠谱的。
3. 容器资源隔离与配额:requests、limits 没有你想的那么简单
3.1 CPU和内存的单位陷阱:从数字到数值,处处是坑
CPU 的单位是“核”,但可以用毫核表示:100m就是 0.1 核,500m就是 0.5 核,写"1"表示整核。注意这里必须加引号,因为 YAML 解析时会把1误读成浮点数,某些版本下会报错。
内存单位则要区分Mi和M。1Mi是二进制单位,等于 1024Ki,也就是 1048576 字节;1M是十进制单位,等于 1000000 字节。容器运行时按字节验收,差出来的 4.8% 在某些场景下足以导致OOMKilled。我建议大家都用Mi、Gi这种二进制形式,避免换算时出歧义。
还有一点要注意:limits里的 CPU 可以写小数,但requests和limits的数值不能超过节点实际可分配资源。比如节点是 4 核 8Gi,你给 Pod 申请 4 核,那这个节点只能放得下这一个 Pod,剩下的应用全部 Pending。资源请求不是“往大了写就有保障”,写得太大反而会把自己调度死。
3.2 QoS等级与驱逐优先级:同一批Pod也不平等
K8s 把 Pod 的服务质量分成三个等级,这个等级直接决定节点内存不足时谁先被驱逐。规则很简单:如果所有容器都设置了 requests 和 limits,并且两者完全相等,这个 Pod 就是 Guaranteed;如果设置了部分字段或者 requests 和 limits 不一致,是 Burstable;如果一个都没设,就是 BestEffort。
| QoS 等级 | 判定条件 | 内存压力时的驱逐优先级 |
|---|---|---|
| Guaranteed | requests == limits | 最低,极不容易被驱逐 |
| Burstable | requests 存在且与 limits 不一定相等 | 中等 |
| BestEffort | 完全不写 requests/limits | 最高,第一个被清理 |
最佳实践是生产环境尽量把对象做成 Guaranteed。如果你只写 requests 不写 limits,调度器按 requests 调度,但运行时不限制内存,一旦整机内存不够,它仍然可能被 Evicted,而且优先级更低。在线上见过太多 Pod 被驱逐的例子,最后排查发现就是没写 limits。资源限制不是用来约束人的,是用来兜底的。
3.3 ResourceQuota与LimitRange:给整个Namespaces套上缰绳
如果只用 Deployment 里的 requests/limits,只能管单个应用,无法防止某个团队把整个集群塞满。这种场景需要 Namespace 级别的 ResourceQuota。下面是一个示例:
apiVersion: v1 kind: ResourceQuota metadata: name: prod-quota namespace: prod spec: hard: requests.cpu: "16" requests.memory: 32Gi limits.cpu: "24" limits.memory: 64Gi pods: "50" services: "10"配额可以限制计算资源、存储资源、对象数量多个维度。配额生效后,创建的 Pod 如果没有写 requests 或 limits,可能会直接报错,因为 Admission 控制器无法统计它的用量。
LimitRange 则是给单个 Pod 设置默认值和上下限的工具。比如你希望这个 Namespace 下的每个 Pod 至少申请100mCPU,最多限制到2核,就可以通过 LimitRange 统一注入,开发人员写 YAML 时不用每个都显式声明也能兜底。这两类对象经常搭配出现,配额管总量,LimitRange 管单个对象,组合起来才能构成一个相对完整的资源治理体系。
3.4 一个真实调参过程:从监控到压测的完整闭环
说了这么多,实际调参到底怎么操作?我拿之前调优一个 Java 微服务的过程举例。这个服务平时峰值 CPU 在 400m 左右,内存峰值在 700Mi,滚动日志里有零星 GC 耗时增加但还没到 OOM。当时线上配置的 limits 是cpu: 500m, memory: 900Mi,问题暴露在流量高峰,Pod 频繁被杀。
我的调整策略是:requests 给峰值留 20% 余量,CPU 写成500m,内存写成768Mi;limits 给到峰值的 1.5 到 2 倍,CPU 写成"1",内存写成1Gi。同时把jvm的堆大小显式限制到 512Mi,避免 JVM 发现物理内存大就无限扩容,和 limits 顶牛。改完后在压测环境跑了一天,观察 QPS、GC 频率和 Pod 重启次数,确认不再 OOM 后才上生产。
要注意的是,requests 和 limits 差距过大会导致另一种问题——调度器和运行时之间的“账”对不上。节点明明还有很多内存,但个别 Pod 被限制得死死的,一压就重启。这个平衡需要根据业务特点来,数据库、搜索这类重内存业务和普通 Web 服务完全是两套配法。
4. 控制器在背后做了什么:为什么不能直接改Pod
4.1 控制器与Reconcile循环:期望状态如何变成现实
K8s 最核心的机制就是控制器循环。以 Deployment 为例,它的控制器逻辑大致是:持续检查集群里和spec.replicas匹配的 Pod 数量,如果少了就创建,多了就回收,镜像变了就触发滚动更新。这个循环有一个专门术语叫 reconcile,中文社区常译作“调和”,意思就是让现实状态不断向期望状态靠拢。
这个机制带来一个很反直觉的结论:你手工去修改一个由 Deployment 管理的 Pod,几乎没有任何意义。比如你kubectl edit pod xxx改了环境变量,控制器发现 Pod 的字段和 Deployment 模板不一致,会直接删掉这个 Pod,重新拉一个新的起来。你的修改在几秒内就会消失。与其做这种徒劳的操作,不如老老实实去改 Deployment 的 YAML。
真正适合手工操作的场景极少。临时调试时可以kubectl exec进入容器看现场,这没有问题;但任何对规格的修改,都应该走 Deployment 或 StatefulSet 这个上层对象。
4.2 ownerReferences与级联删除:为什么删除对象会牵一发动全身
每个由上层控制器创建的子对象,都会在 metadata 里记录一个ownerReferences字段,指向它的父对象。所以当你删除一个 Deployment 时,它下面所有的 ReplicaSet 和 Pod 都会被级联删除,这就是垃圾回收机制在起作用。
这带来一个注意点:很多人觉得“我把 Pod 删了,Deployment 会自动重建一个,这不就是重启服务吗”?对,但只在单一 Pod 场景下成立。如果你有 3 个副本,手动删一个,控制器会立刻新建一个,新建的这个不会继承你手工改过的字段,也不会帮你恢复到一个“干净”状态。如果你是想触发一次滚动发布,正确做法是更新 Deployment 的镜像标签或注解,而不是删 Pod。
级联删除的方向也要注意。有人想删 StatefulSet 但保留数据,直接kubectl delete statefulset xxx,结果 PVC 也被连带删了,数据全没了。删有状态服务之前必须想清楚:是删除控制器对象但保留 PVC,还是连同存储一起删。这个后果不可逆,行动前多看一眼字段比什么都强。
4.3 从内置对象到自定义资源:Operator到底在做什么
控制器模式不只能用于内置资源,还能管理你自己定义的对象。K8s 提供了 CRD(CustomResourceDefinition),让你注册一个新对象类型,比如MySQLCluster,然后写一个控制器去监听它的变化,自动创建 StatefulSet、Service、备份任务等。这种“自定义对象 + 控制器”的组合就是 Operator 的基本架构。
很多流行软件都用这套模式来做运维自动化,比如 Prometheus Operator 管理监控实例,cert-manager 管理证书。维护这类组件时,你会发现它和你平时创建 Deployment 没什么本质区别:都是写 YAML、apply、看 status。只不过背后的控制器在做更多事情。理解这一点,看很多 K8s 生态工具的设计就会顺很多。热搜词里提到的“K8s 中 Operator 案例”,其实核心就是理解 controller 如何调和自定义资源,而不用背一堆工具命令。
4.4 声明式心智:从“命令机器”转向“描述目标”
用 K8s 时间越久,越能体会到声明式管理的价值。传统运维是写脚本告诉机器“先做这个、再做那个”,而 K8s 只需要你描述最终结果。比如我需要 5 个副本,就写 replicas: 5;节点挂了,调度器会自己去其他节点补回来,这个逻辑不是由你的脚本控制的,而是由控制器内置的。
这种心智转变对公司制度也有影响。之前我们团队发生过一次事故:有人在服务器上手动改了容器配置,结果重新调度后配置丢了,服务起不来。后来我们彻底把 YAML 纳入代码仓库,任何变更都走 MR 和 CI,再也没出现过这种问题。声明式不是一句口号,它意味着所有变更都有记录、所有状态都可回滚。无论你是用原生的 apply、Kustomize,还是 Helm,核心都是这句话。
5. 生产环境常见的故障现场:排查顺序和避坑技巧
5.1 第一现场:先看Events、再看日志
很多新手拿到一个故障 Pod,第一反应就是kubectl logs。日志当然要看,但不是第一步。正确的顺序是先看对象当前的状态和 Events。kubectl describe pod <pod-name>会显示生命周期事件,包括镜像拉取失败、探针探测失败、被驱逐等信息。日志只能告诉你应用内部发生了什么,而 Events 告诉你的是 K8s 视角里发生了什么。
常见的事件和原因我整理成了表,排查时直接对照:
| 事件/状态 | 常见原因 | 排查方向 |
|---|---|---|
| Pending | 节点资源不足、调度约束不满足 | kubectl describe node看资源水位 |
| ImagePullBackOff | 镜像不存在、仓库访问异常、tag 错误 | 检查镜像名和仓库连通性 |
| CrashLoopBackOff | 应用启动失败、启动探针失败、OOM | 先看日志,再看资源 limits |
| OOMKilled | 内存超限 | 调大 limits 或优化应用内存 |
| Evicted | 节点内存/磁盘压力大 | 补 requests、limits,清理节点日志 |
有一次线上服务突然批量重启,Events 里全是被驱逐的记录,但单看应用日志根本没异常。最终发现是节点磁盘被日志文件打满,kubelet 开始逐出 Pod。如果你只看日志,恐怕排查到天亮也找不到原因。所以我的习惯是:任何故障都先describe,用 Events 缩小范围,再决定要不要深入容器。
5.2 集群初始化报“api server is not healthy”怎么办
很多人在搭建集群时遇到过这种报错:初始化 Master 节点后,提示 the api server is not healthy,然后卡住退出。这个问题的常见根源其实就几类,不用慌。第一步先看 kubelet 是否正常运行,systemctl status kubelet,如果 kubelet 没起来,大概率是容器运行时的问题或者配置错误。
第二步检查容器运行时状态,比如 containerd 是否存活、和 kubelet 的 cgroup 驱动是否一致。这个细节最容易踩坑,runtime 用的是 systemd cgroup,而 kubelet 用的是 cgroupfs,两边不一致就会导致节点状态反复横跳。第三步看一下是否有 CNI 网络插件,比如 Calico、Flannel。没有网络插件时,控制平面组件之间互相访问不了,API Server 虽然进程起来了,但健康检查过不去。
还有一个常见原因是资源不足或 swap 未关闭。K8s 要求节点关闭 swap,否则 kubelet 状态异常。这些都是环境层面的东西,和业务代码无关,排查时要从底层往上走,别一上来就怀疑集群版本有问题。
5.3 ExternalIP 与 Service 的神秘行为
热搜词里有“k8s externalips”,这个功能经常让人困惑。Service 的spec.externalIPs字段可以把外部 IP 绑定到 Service 上,但很多人配置之后发现访问不通。原因在于 externalIPs 不是“自动创建公网入口”,它只是把带有该 IP 的流量导向后端的规则记录下来。前提是:该 IP 必须已经配置到某个节点网卡上、节点能路由到这个 IP、防火墙也放行了对应端口。
如果这三个条件缺一个,流量就到不了 Service。排查时先确认 IP 归属,ip addr看节点上有没有这个地址;再确认 Service 的端口和 targetPort 对应关系;最后检查安全组、防火墙。生产环境我一般不建议用 externalIPs 作为标准暴露方案,它调试成本太高,又依赖底层网络,不如直接用 LoadBalancer 或 NodePort 来得直观。
5.4 扩展资源与GPU调度:资源管理的另一个维度
前文说的都是 CPU 和内存,K8s 还支持自定义扩展资源,常见的就是 GPU。要让集群支持 GPU 调度,通常需要在节点上安装设备插件,然后 Pod 的资源请求里写nvidia.com/gpu: 1。注意扩展资源不像 CPU 那样支持小数,只能申请整数。
有一个坑经常被踩:扩展资源要求 requests 和 limits 保持一致,否则调度器会拒绝。这类资源在容器里看/dev/nvidia0是否存在。如果设备插件挂了,节点上报的数量会变成 0,Pod 自然起不来。排查时先看节点资源,kubectl describe node | grep nvidia,没有相应列表就是设备插件的问题,再往底查驱动版本和插件日志。
量产环境里还有一种“资源受限”场景,比如机器人、边缘设备算力有限,K8s 依然可以通过 requests 和 limits 来控制每个容器的占用量,达到资源隔离的目的。这个思路和云服务器上跑微服务是相通的,只不过节点规模小了很多,更要精细设计水位。
5.5 我的排查方法论:避免“瞎重启”
最后分享一套通用的排查顺序,适用于大多数生产问题。第一步确认集群本身健康,kubectl get nodes,有不 Ready 的节点先看 kubelet;第二步确认目标对象的 Events,Describing 一下;第三步看对象引用了哪些依赖项,比如 ConfigMap、Secret、PVC 是否齐全;第四步才进入容器看日志,kubectl logs <pod> --previous可以看上一个容器实例的日志,这是 CrashLoopBackOff 场景的利器;最后一步才是看代码和业务逻辑。
这套顺序的核心是“由外到内”。我见过太多人一上线就kubectl delete pod重启,结果问题反复出现,因为根因在资源限制或依赖项上,重启解决不了任何问题。真正高效的定位顺序,永远是先看对象,再看环境,最后看代码。
个人体会是,K8s 里的资源与对象并不是两个孤立的概念:资源是对象的属性约束,对象是资源在 API 世界里的一等公民。搞懂它们之间的关系,集群的管理逻辑就顺了。最后再分享一个小习惯:每次执行kubectl apply之前,先跑一次kubectl diff,这个输出比任何文档都更直接,它会不断提醒你,线上真正运行的是你写的 YAML,不是你脑子里想的那个版本。