ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kubernetes 控制循环实战:Pod、调度、资源与排错

Kubernetes 控制循环实战:Pod、调度、资源与排错

简介:《Kubernetes课件分享》是一份面向容器与云原生初学者、运维开发人员及课堂教学场景的入门级演示文稿,围绕容器集群管理的基础知识展开,帮助读者建立集群架构与核心对象体系的整体认知。压缩包内仅含1个pptx演示文稿文件,大小约1.25MB,篇幅精炼,适合课堂讲授、自学梳理和面试前快速复盘。课件按章节讲解集群组件职责、最小部署单元、控制器、服务、标签与命名空间等核心对象,并介绍集群状态查看、资源监控、组件与应用日志管理、应用创建升级回滚和弹性伸缩等常用操作。目录还延伸至调度、存储、集群安全等主题,以概念图、命令示例和配置片段串联关键知识点,便于对照实操查漏补缺。目前已有2191人学习,适合按章节做笔记、课堂演示或搭建学习路线时参考。

1. 控制循环:为什么 k8s 里没有“安装完成”这个状态

很多人第一次接触 k8s,会带着装软件的心智:敲一条命令,等一个 done,服务就“在那儿”了。这套课件里反复出现的一组 YAML 会打破这个直觉——同一份 Deployment 提交三次,集群的反应可能完全不同:第一次创建三个 Pod,第二次什么都不做,第三次因为有人手动删了一个 Pod 又补回来一个。k8s 不是部署工具,它是一组长期运行的控制循环,把实际状态往你声明的期望状态上拽。理解这一点之后,后面所有看着奇怪的行为——滚动升级卡住、Pod 反复重启、删掉又被拉起来——都有统一解释。这份资料把架构、调度、存储、安全串成一条线,正好可以从控制循环这个根上往下拆。

2. 声明式 API 与 Pod:一份 YAML 提交之后到底发生了什么

2.1 Pod 是逻辑概念,infra container 才是它真正的骨架

课件里有一句话容易被略过:Pod 是最小部署单元,是一组容器的集合,容器之间共享网络命名空间。问题在于,容器天然是被 Namespace 隔离的,那“共享”是怎么做到的?Pod 本身没有任何实体,它只是 etcd 里的一个对象、kubelet 眼中的一个编排单位,真正把“一组容器”捏成一个整体的,是那个先启动的小容器。

常见的实现思路是:kubelet 在创建 Pod 时,先拉起一个极小的 infra container(也叫 pause 容器),它只做一件事——持有网络命名空间不放。随后业务容器以 join 这个命名空间的方式启动,于是所有容器看到的是同一张网卡、同一个 IP、同一份路由表,彼此之间用 127.0.0.1 就能通信。这也解释了两个现象:为什么 Pod 内容器不能各自占用同一个端口,以及为什么 Pod 重建后 IP 会变——IP 是挂在 infra container 的命名空间上的,它没了,网络栈就没了。

存储共享走的是另一条路,和命名空间无关:同一个 Volume 被同时挂载到多个容器的挂载点上,容器看到的路径各自独立,但底层指向同一份数据。课件里给出的形态大致如下,两个容器分工不同却共用一份目录:

apiVersion: v1 kind: Pod metadata: name: web spec: containers: - name: nginx image: demo/nginx-php:1.0 volumeMounts: - name: app-logs # 容器内的日志目录 mountPath: /usr/local/nginx/logs - name: exporter image: demo/log-agent:1.0 volumeMounts: - name: app-logs # 同一个 volume,挂到另一个容器的不同路径 mountPath: /data/logs volumes: - name: app-logs # volume 名字必须与上面两个 mount 一一对应 emptyDir: {}

这里的关键不是语法,而是name这条隐式契约:volumeMounts[].name必须能在volumes[].name里找到同名的条目,否则 Pod 会一直卡在 ContainerCreating。后面避坑章节会专门讲这个错误,因为课件里的示例本身就埋了一个这样的坑。

现代 k8s 还允许通过shareProcessNamespace: true让 Pod 内容器共享 PID 命名空间,这样容器 A 能看见容器 B 的进程。它默认关闭,因为大多数“亲密性”需求只涉及网络和文件,把进程视图也合并进来反而增加了误杀风险。

2.2 一条 kubectl apply 的完整链路:从 apiserver 到容器运行时

初学者常有的错觉是 kubectl 直接连到了某台 Node 上,命令它去启动容器。实际链路要绕得多,而且每一段都是“看”而不是“推”。把课件里的组件图按时间顺序摊开,大致是这么一条链:

第一步,kubectl 读 kubeconfig 里的集群地址和客户端证书,把 YAML 序列化后发到 kube-apiserver 的 REST 接口。apiserver 依次做三件事:认证(这张证书是谁)、鉴权(这个身份能不能在 default 命名空间创建 deployment)、准入控制(默认值填充、ResourceQuota/LimitRange 校验、ServiceAccount 挂载注入)。全部通过后才写入 etcd。注意此时集群里还没有任何容器,只有一条记录。

第二步,kube-controller-manager 里的 Deployment 控制器一直在 watch 这类对象,发现新的 Deployment 后,它并不直接建 Pod,而是创建一个 ReplicaSet,把副本数、Pod 模板抄过去。

第三步,ReplicaSet 控制器发现自己该有 3 个副本、实际 0 个,于是创建 3 个 Pod 对象写入 etcd。此刻 Pod 的spec.nodeName是空的。

第四步,kube-scheduler watch 到这些“未绑定”的 Pod,执行过滤和打分,选定节点后写回一个 Binding 结果。这一步只改一个字段,不碰容器。

第五步,目标节点上的 kubelet watch 到“分配给我的 Pod”,开始真正干活:调用 CRI 拉镜像、创建沙箱(就是 2.1 的 infra container)、调用 CNI 配网络、调用 CSI 挂卷、启动业务容器。每一步的成败都通过 Pod 的 status 上报回 apiserver。

第六步,kube-proxy watch Service 和 EndpointSlice 的变化,在本机维护转发规则,让 Service 的 ClusterIP 能落到具体的 Pod IP 上。

整条链路里,kubectl 只和 apiserver 说过话,组件之间全部靠 watch etcd 或 apiserver 来感知变化。这就是声明式的代价与红利:任何一环挂掉,只要状态还在 etcd 里,恢复后能接着收敛;但也意味着“命令执行成功”和“服务真的可用”之间隔了好几跳,中间任何一跳出问题,你看到的都是同一句deployment created。

想亲眼看见这个渐进过程,用--watch盯住 Pod 的状态迁移最直观:

# 开启一个窗口持续观察,创建后会依次看到 Pending -> ContainerCreating -> Running kubectl get pod -w # 只看某个 Deployment 下的 Pod,并在滚动升级时观察新旧 Pod 的交替 kubectl get pod -l app=web -w # 查看某个 Pod 的完整状态流转原因(Events 在输出末尾,从下往上读) kubectl describe pod web-7d9c8f6b5-2xk4l

-w的价值在于它把“离散的快照”变成了“连续的状态机”。很多问题在快照里看不出来,比如一个 Pod 在 ContainerCreating 停留了 40 秒才变 Running,快照只会告诉你 Running,-w会告诉你中间等了多久——那 40 秒通常就是镜像在拉。

2.3 Deployment、ReplicaSet、Pod:三层控制器如何各管一段

课件把 Deployment 归到“无状态应用部署”,但没有解释为什么中间要夹一个 ReplicaSet。如果把这三层拆开看,每一层的职责其实非常清晰:Deployment 记录“我想要什么版本、要几个”,ReplicaSet 记录“某个版本要几个”,Pod 是真正的实例。多出这一层的全部意义,在于版本切换时新旧两套副本需要同时存在。

滚动升级时,Deployment 控制器新建一个 ReplicaSet(名字里带 Pod 模板的哈希),把它从 0 扩到目标数,同时把旧 ReplicaSet 从目标数缩到 0,两边按maxSurge和maxUnavailable控制节奏。回滚就更简单了——旧 ReplicaSet 对象并没有被删,只是副本数被缩到 0,执行 undo 就是把它重新扩起来、把当前的缩下去。这也解释了为什么回滚几乎瞬间完成:镜像早就拉过了,模板也没变。

# 两个 RS 并存:DESIRED 为 0 的那个就是上一版本,随时可以回滚 kubectl get rs -l app=web # 查看历史版本号与对应的镜像,--to-revision 用的就是这个号 kubectl rollout history deployment/web # 只回滚到指定版本,而不是无脑退回上一版 kubectl rollout undo deployment/web --to-revision=2

revisionHistoryLimit决定了旧 ReplicaSet 保留多少个,默认 10。超过的会被清理掉,清理后再想--to-revision回到那个版本就会报 not found。这是一个很容易被忽略的“后悔药有效期”。

2.4 镜像拉取策略:imagePullPolicy 的默认值比你想的更聪明

课件列了三个取值:IfNotPresent、Always、Never,并说 IfNotPresent 是默认值。这句话对,但不完整,漏掉的那半句才是线上事故的高发区:默认值并不是固定的,它取决于镜像的 tag。

镜像写法默认 imagePullPolicy实际行为
demo/app:1.0IfNotPresent节点上有就复用,没有才拉
demo/app:latestAlways每次创建 Pod 都去仓库问一次
demo/app(省略 tag)Always等价于 latest,同样每次拉
demo/app@sha256:...IfNotPresent按摘要校验,内容天然不可变

规则背后是一套朴素的假设:带具体 tag 的镜像被认为内容不会变,而 latest 被认为是流动的。问题在于,很多人会用同一个 tag 反复覆盖推送,比如永远推demo/app:test。这时 IfNotPresent 会让节点上的旧镜像一直生效,代码怎么改都不生效,重启 Pod、删 Pod 都没用,只有换节点或者手动清镜像才行。这种问题的恶心之处在于它不报错,只是行为“像上个版本”。

课件里还提到了imagePullSecrets,那是私有仓库的认证凭据。它的生效范围是 Pod 级别,需要显式写在spec.imagePullSecrets里,或者挂在 Pod 所用的 ServiceAccount 上——后者更省事,因为同一个命名空间下的 Pod 会自动继承。

3. 调度与资源配额:requests 和 limits 为什么不能随便写

3.1 scheduler 的两阶段:Filter 先排除,Score 再排序

调度的输入是一个待绑定的 Pod 和一份节点清单,输出是一个节点名。整个过程被拆成两个阶段:过滤和打分。

过滤阶段回答“这个节点能不能放”,任何一条硬性条件不满足就出局。常见的过滤项包括:节点剩余可分配资源是否覆盖 Pod 的 requests 之和、nodeSelector 与节点亲和性能否匹配、节点污点是否被 Pod 容忍、Pod 需要的 PVC 能否在该节点所在区域绑定、拓扑分布约束是否还允许再放一个。全部节点被过滤掉时,Pod 就停在 Pending,事件里会写清楚每个节点被排除的原因,比如0/3 nodes are available: 3 Insufficient cpu。

打分阶段回答“放哪个更好”,对通过过滤的节点逐项打分再加权求和。常见的打分插件有:资源均衡度(让各节点使用率接近)、镜像本地性(节点上已有该镜像则加分,能省一次拉取)、Pod 间亲和性(尽量和指定的 Pod 靠近)、拓扑分布(尽量打散到不同可用区)。分数最高的节点胜出,相同分数时随机挑一个,这也是为什么同一份 Deployment 反复重建,Pod 的分布看起来总在变——调度本身没有记忆。

这里有个反直觉的点值得强调:调度看的是 requests 的累加值,不是节点的真实使用率。一台 16 核的机器上跑着一个吃满 15 核但 requests 只写了 1 核的 Pod,调度器依然认为它还有 15 核可分配。反过来,一个 requests 写得很夸张的 Pod 会让节点“看起来”满了,实际 CPU 大量闲置。想看清这笔账,得直接查节点的分配情况:

# Allocated resources 一段列出的是所有 Pod requests 之和与节点容量的比值 # 它和 kubectl top node 看到的实际使用率经常差很远,两个数要分开看 kubectl describe node <node-name> | sed -n '/Allocated resources/,/Events/p'

3.2 requests 决定调度与 QoS,limits 决定 cgroup 与生死

这两个字段经常被混为一谈,其实它们作用在不同的层面,语义完全不能互换。

requests是“我至少要这么多”,它不限制任何东西,只是给调度器看的账本,同时决定 Pod 的 QoS 等级、HPA 的百分比基线、以及节点资源不足时谁先被驱逐。limits是“我最多只能用这么多”,它被写进容器的 cgroup 配置,是真正的硬约束。

apiVersion: v1 kind: Pod metadata: name: web spec: containers: - name: java image: demo/java-app:1.0 resources: requests: memory: "64Mi" # 调度账本:节点必须至少有 64Mi 未分配 cpu: "250m" # 250m = 0.25 核,调度按这个数扣减 limits: memory: "128Mi" # cgroup memory.max:超过就 OOM cpu: "500m" # cgroup cpu.max:超过就限流,不杀进程

CPU 和内存在这件事上表现完全不同,这个差异必须记住。CPU 是“可压缩资源”,超了 limits 只是被内核的 CFS 配额限流,进程还在跑,表现为响应变慢、延迟毛刺,容器里能看到nr_throttled计数增长。内存是“不可压缩资源”,超了 limits 就是 OOM Killer 直接杀进程,容器退出码 137,kubectl 里看到的是OOMKilled和不断上涨的 RESTARTS。

requests和limits之间留多少余量,是个纯经验问题。写得太紧,Java 这类需要预留堆外内存的进程会被误杀;写得太松,集群整体装箱率下降,成本上去了。比较稳的做法是:内存的 requests 和 limits 设成相等(避免被 OOM 的同时也拿到更高的 QoS),CPU 的 requests 按稳态设、limits 按峰值设,允许一定程度的超卖。

3.3 三种 QoS 等级与节点压力下的驱逐顺序

只要写了 requests 和 limits,k8s 就会给 Pod 打上一个隐含的标签——QoS 等级。判定规则很机械,但组合起来容易记混:

QoS 等级判定条件oom_score_adj 大致取值节点压力时
Guaranteed所有容器都设置了 requests 和 limits,且 cpu、memory 两项都相等最低(约 -997)最后被驱逐
Burstable至少一个容器设了 requests,但不满足 Guaranteed按 requests 占比计算中间
BestEffort所有容器都没设 requests 和 limits最高(1000)最先被驱逐

oom_score_adj决定了系统级内存紧张时内核先挑谁下手,数值越高越容易被杀。BestEffort 的 Pod 相当于把自己放在了最前面,这在测试环境无所谓,在生产环境里意味着节点一紧张,最先死的就是那些没写 resources 的服务——而它们往往是最早被部署、最没人管的那批。

节点压力驱逐是另一套机制,由 kubelet 独立完成,触发条件是节点资源低于evictionHard阈值,比如可用内存跌破 100Mi。它按 QoS 从低到高挑 Pod,同等级内再按“用量超出 requests 的程度”排序。这解释了为什么有时候明明是别的服务把内存吃光了,被杀的却是你的 Pod——只要你的 requests 写得比实际用量低很多,超出的比例就会很靠前。

课件里没有展开 QoS 的判定细节,但这是理解“为什么我的 Pod 老是被杀”绕不开的一环。一个实用的自检方式是直接查 Pod 的 qosClass 字段:

# 一眼看出这个 Pod 被判定成了哪个等级 kubectl get pod web -o jsonpath='{.status.qosClass}{"\n"}' # 批量检查某个命名空间下所有 Pod 的 QoS,找出 BestEffort 的漏网之鱼 kubectl get pod -n default -o custom-columns='NAME:.metadata.name,QOS:.status.qosClass'

3.4 HPA 的百分比基线:为什么没写 requests 就扩不了容

自动扩缩容那一条命令看起来最省事:kubectl autoscale deployment web --min=10 --max=15 --cpu-percent=80。但这里的 80 不是“CPU 使用率 80%”,而是“当前 CPU 用量达到 requests 的 80%”。也就是说,HPA 的分母是 requests,不是节点核数。

后果很直接:如果容器没写 requests,或者写了但 HPA 拿不到指标,kubectl get hpa的 TARGETS 一栏会显示<unknown>/80%,扩缩容逻辑直接失效,永远不会触发。这也是 metrics-server 必须先在集群里跑起来的原因——HPA 控制器是通过 metrics API 去取这些数的。

还有两个容易被忽略的行为:扩容是激进的,缩容是有冷却窗口的(默认 5 分钟),避免流量抖动导致副本数反复横跳;HPA 只调整副本数,不负责加节点,节点资源不够时新 Pod 会一直 Pending,那是另一层(节点自动扩缩)要解决的问题。

4. 监控与日志:三条数据链路,别指望一条命令看全

4.1 metrics-server 的数据从哪来:kubelet Summary API 与 cAdvisor

kubectl top node能打出 CPU 和内存的百分比,这个数不是 apiserver 自己算的,也不是从 etcd 里读的。整条链路是这样的:每个节点的 kubelet 内嵌了 cAdvisor,持续采集本机所有容器的 cgroup 统计;kubelet 把这些数据整理成 Summary API(/stats/summary);metrics-server 定期向所有 kubelet 拉取,聚合成一份集群级的资源视图;最后通过 APIService 机制注册成metrics.k8s.io这个聚合 API,kubectl top 读的就是它。

这条链路决定了 metrics-server 的定位:它只提供近实时的瞬时值,不存储历史。所以kubectl top只能回答“现在多少”,回答不了“过去一小时峰值多少”。要看趋势就得另起一套——通常是 Prometheus 定期抓 kubelet 的/metrics/cadvisor,把样本落进时序库。课件把 metrics-server 和 cAdvisor 放在一起讲,其实是在提示这条链路的上下游关系。

# 集群级信息:apiserver 地址、DNS 服务地址等,用来确认 kubectl 连的是哪个集群 kubectl cluster-info # 节点与 Pod 的实时资源占用,数据源就是 metrics-server kubectl top node kubectl top pod --sort-by=memory # 确认聚合 API 是否注册成功,没就绪时 top 会直接报错 kubectl get apiservice v1beta1.metrics.k8s.io

最后那条命令是排查kubectl top报错的第一个动作。如果 APIService 的 AVAILABLE 是 False,问题一定在 metrics-server 这一侧——Pod 没起来、证书没配好、或者 kubelet 的只读端口被禁用,跟你要查的业务 Pod 一点关系都没有。

4.2 kubectl top、describe、--watch 分别读的是什么

这三个命令经常被当成同一类工具用,其实它们读的是完全不同的数据源,回答的也是不同的问题。

kubectl top读聚合 API,给的是数值,用于判断“现在忙不忙”。kubectl describe读的是对象本身加上关联的 Event,给的是状态和原因,用于判断“为什么卡住了”。kubectl get --watch读的是对象的版本流,给的是变化过程,用于判断“它是什么时候变成这样的”。

知道这个分层,排查时就不会乱试。Pod 起不来的时候盯着 top 看没有意义,因为没起来的容器根本没有 cgroup 数据;反过来,服务变慢的时候翻 describe 的 Events 也看不出什么,因为事件只记录状态变更,不记录性能拐点。

describe输出的末尾那段 Events 是最有价值的部分,但要按时间从下往上读——最新的事件在最下面。常见的几条信号值得记住:FailedScheduling指向调度器,FailedMount指向存储或 Secret,Failed加Failed to pull image指向镜像仓库,Unhealthy指向探针。每条事件的末尾还带着发事件的组件名,能直接告诉你该去看哪个组件的日志。

4.3 组件日志与应用日志:journalctl 与 kubectl logs 的边界在哪

课件把日志分成了“k8s 组件日志”和“k8s 应用日志”两类,这个划分对应的是两套完全不同的采集路径,混用会白费很多时间。

kubelet 这类以 systemd 服务形式运行在节点上的组件,日志进了 journald,只能用journalctl -u kubelet看,加上-f可以持续跟踪。这条路子在某些发行版上还要注意 journald 自己的持久化配置,默认可能只存在内存里,重启就没了。

apiserver、controller-manager、scheduler、kube-proxy 这些通常是以静态 Pod 的形式跑在 kube-system 命名空间里的,它们的日志要用kubectl logs看,加上-n kube-system。kube-proxy 是 DaemonSet,每个节点一个,名字后面带随机后缀,得先get pod找到具体那个。

应用容器的日志是第三条路。kubectl logs读的是容器运行时写在节点磁盘上的标准输出文件,路径形如/var/log/pods/<ns>_<pod>_<uid>/<container>/0.log。这意味着两件事:它只能看到 stdout 和 stderr,写进容器内文件里的日志抓不到;它默认只看当前这一份,容器崩溃重启后上一次的输出要靠--previous才能翻出来。后者在排查 CrashLoopBackOff 时几乎是必用技巧——正在跑的容器可能刚起来还没输出,真正有用的堆栈在上一份里。

# 跟踪某个多容器 Pod 里指定容器的日志,不写 -c 时默认第一个 kubectl logs -f web-7d9c8f6b5-2xk4l -c nginx # 查看上一次崩溃实例的日志,排查反复重启的关键入口 kubectl logs web-7d9c8f6b5-2xk4l --previous # 节点上的组件日志,kubelet 走 journald,加 -f 持续跟踪 journalctl -u kubelet -f --since "10 min ago"

4.4 日志落盘:hostPath、emptyDir 与 sidecar 采集的取舍

容器标准输出的日志由 kubelet 负责轮转,参数是containerLogMaxSize和containerLogMaxFiles,默认 10Mi 和 5 份。这个量级对付调试够了,对付审计或者需要长期留存的业务日志远远不够。所以课件里给了一个把日志目录挂出来的例子,思路是把容器内的日志路径映射到宿主机目录,再由节点上的采集 agent 统一收走。

那份示例里有个细节值得单独拎出来:hostPath的type字段。写Directory表示“这个目录必须已经存在”,不存在就挂载失败,Pod 卡在 ContainerCreating;写DirectoryOrCreate则会自动创建。生产上如果目录是人工预建的,用Directory更安全,能及早暴露配置错误;如果希望自愈,就用DirectoryOrCreate。这个选择在课件里没展开,但它决定了你是想“快速失败”还是“自动兜底”。

修正后的形态大致是这样,注意 volumeMounts 和 volumes 两处的 name 必须严格对应:

apiVersion: v1 kind: Pod metadata: name: my-pod spec: containers: - name: web image: demo/nginx-php:1.0 volumeMounts: - name: logs # 引用下面 volumes 里的同名条目 mountPath: /usr/local/nginx/logs volumes: - name: logs # 名字与上面的 volumeMounts 对应 hostPath: path: /tmp/logs type: DirectoryOrCreate # 目录不存在时自动创建,避免挂载失败

三种方案的取舍可以这么理解:emptyDir生命周期跟 Pod 绑死,Pod 一删日志全没,适合临时缓存和容器间交换文件;hostPath把日志留在节点上,Pod 删了文件还在,但换节点就找不到了,且多个 Pod 写同一个目录容易互相覆盖;sidecar 或 DaemonSet 采集则是把日志实时送出去,代价是多一层组件和一份资源开销。选哪个取决于日志是要“事后翻一下”还是“必须长期可查”。

5. 避坑与排查:五类“部署成功却跑不起来”的现场

5.1 五条踩坑记录

卷名对不上,Pod 卡在 ContainerCreating。现象是kubectl get pod一直显示 ContainerCreating,几分钟不动。原因是volumeMounts[].name写的是logs,而volumes[].name写的是data,两个名字对不上,kubelet 找不到要挂的卷,只能一直重试。解决方式是kubectl describe pod看 Events,会看到volume "logs" not found这类提示,把两处名字改成一致即可。这个错误在复制粘贴 YAML 时特别高发,因为名字不是必填校验项,apiserver 不会在提交时拦你。

同一个 tag 反复覆盖推送,代码改了却没生效。现象是镜像明明重新构建推送了,Pod 也重建了,行为还是老样子。原因是非 latest 的 tag 默认imagePullPolicy: IfNotPresent,节点上已经有同名 tag 的镜像就直接复用,压根不去仓库问。解决方式是改用不可变 tag(带构建号或 commit 短哈希),或者临时把策略改成 Always 验证一次。长期看,用 digest 引用是最稳的。

requests 写得过大,Pod 永远 Pending。现象是 Pod 一直 Pending,describe 里写着0/3 nodes are available: 3 Insufficient cpu。原因是 requests 是调度账本,写多少就要扣多少,跟实际用多少无关。解决方式是先看kubectl describe node里的 Allocated resources,算出真实余量,再回头调小 requests;或者给节点池扩容。顺带一提,如果 requests 完全没写,Pod 能调度上去,但 QoS 会掉到 BestEffort,节点一紧张第一个被杀。

内存 limits 卡太死,容器反复 OOMKilled。现象是 RESTARTS 一路涨,describe 的 Last State 显示Reason: OOMKilled、Exit Code: 137。原因是内存是不可压缩资源,超过 cgroup 上限就是内核直接杀进程。解决方式是先用kubectl top pod观察稳态用量,把 limits 提到峰值的 1.2 倍以上;Java 应用还要注意堆内存只占进程内存的一部分,堆外还有元空间、线程栈、直接内存,把 limits 设成堆大小是必然翻车的。

滚动升级卡住,rollout status 一直 waiting。现象是kubectl rollout status挂在那里不动,get pod能看到新 ReplicaSet 的 Pod 一直没 Ready。原因是新 Pod 没通过 readinessProbe,可能是探针路径写错、启动时间不够、或者镜像根本拉不下来。解决方式是先describe新 Pod 看 Events 定位是探针还是镜像,探针问题就补initialDelaySeconds或改用 startupProbe,镜像问题就查仓库凭据。另外别忘了revisionHistoryLimit会把旧 ReplicaSet 清掉,回滚窗口是有限的。

5.2 从 Events 往上倒推的固定排查顺序

出问题时的本能反应是到处敲命令,效率很低。比较稳的顺序是自下而上:先确认 Pod 处于什么阶段,再看它为什么停在那儿,最后才去看应用自己输出了什么。

第一层看状态:kubectl get pod的 STATUS 列已经能分流一大半问题。Pending 意味着还没调度,ContainerCreating 意味着调度完了但容器没起来,ImagePullBackOff 是镜像问题,CrashLoopBackOff 是容器起来又挂了,Running 但没流量则是 Service 或探针的问题。

第二层看原因:kubectl describe pod的 Events 段落给出具体原因,按时间从下往上读。这一步基本能覆盖 80% 的故障,因为 kubelet 会把每一次失败都记成事件。

第三层看应用:状态是 Running 但行为不对,才轮到kubectl logs。别忘了--previous,崩溃重启的场景下当前实例的日志往往是空的。

第四层看节点:Pod 被驱逐、节点 NotReady、磁盘压力,这些要看kubectl describe node和节点上的journalctl -u kubelet。到了这一层,问题通常已经不是单个 Pod 的事了。

5.3 一套可以直接抄走的排查命令

# 1. 定位:找出异常的 Pod,按重启次数排序最快暴露问题 kubectl get pod -A --sort-by='.status.containerStatuses[0].restartCount' # 2. 归因:Events 从下往上读,重点看 FailedScheduling / FailedMount / Unhealthy kubectl describe pod <pod-name> -n <ns> | sed -n '/Events/,$p' # 3. 应用侧:当前日志看不出东西时,翻上一次崩溃实例的输出 kubectl logs <pod-name> -n <ns> --previous --tail=200 # 4. 节点侧:确认是不是节点本身的问题(磁盘压力、PLEG 异常等) kubectl describe node <node-name> | sed -n '/Conditions/,/Addresses/p' # 5. 组件侧:kubelet 的日志里能看到 CRI 和 CNI 的原始报错 journalctl -u kubelet --since "15 min ago" | grep -i -E "error|failed"

这套顺序的价值在于它是有方向的:每一步都在缩小范围,而不是碰运气。养成习惯之后,多数故障能在五分钟内定位到层,剩下的时间才是解决。

6. 存储与安全的设计取舍:复杂度为什么留给了使用者

把课件里存储和安全两块放在一起看,会发现同一个设计取向:k8s 只定义接口,不规定实现。存储侧抽出 CSI,网络侧抽出 CNI,运行时抽出 CRI,安全侧抽出 RBAC 和准入控制链。好处是生态能长出来,代价是所有“默认值”只保证能跑,不保证跑得好。

存储这块的取舍最典型。hostPath简单直接,但把 Pod 和节点绑死了,Pod 换节点数据就丢,还会带来权限和配额上的隐患——容器往宿主机目录里写什么,宿主机就得接着。往上一层是 PV/PVC,把“我要多大、什么访问模式”和“具体用哪块盘”解耦,ReadWriteOnce和ReadWriteMany的差别会直接决定你的 Deployment 能不能水平扩容。再往上是 StorageClass 动态供给,PVC 一提交就自动创建底层卷。有状态服务还要多一层:StatefulSet 的volumeClaimTemplates让每个副本拥有独立的 PVC,缩容时不删卷,扩回来还能接上——这是 Deployment 做不到的。

安全这块的取舍更隐蔽。Secret 只是 base64 编码,不是加密,任何有读权限的人kubectl get secret -o yaml就能还原;真正的保护来自 etcd 的静态加密和 RBAC 的最小权限。ServiceAccount 是 Pod 访问 apiserver 的身份,默认挂载的 token 让容器内的进程天然拥有一定权限,不需要时应该显式关掉自动挂载。NetworkPolicy 定义了 Pod 间的流量规则,但它依赖 CNI 插件实现,装了策略却没有支持的插件,规则会被静默忽略——这种“配了但没生效”比报错更危险。

我一般会用一套很笨的验证方法来兜底:把 Pod 的 spec 拉出来,逐项确认 requests/limits 是否齐全、QoS 等级是什么、探针有没有配、卷的类型和访问模式对不对、ServiceAccount 是不是最小权限。这套动作做熟了大概两分钟,但能挡掉绝大多数上线后才发现的问题。

# 上线前的一次性体检:QoS、卷、ServiceAccount、探针一次看全 kubectl get pod <pod-name> -n <ns> -o jsonpath='QOS={.status.qosClass}{"\n"}SA={.spec.serviceAccountName}{"\n"}VOL={.spec.volumes[*].name}{"\n"}'

从那以后我每次提交工作负载前,都会强制走一遍这个检查,尤其是 requests 和探针这两项——它们不出问题时毫无存在感,出问题时又都发生在半夜。这份课件把概念、组件、调度、存储、安全串在同一条线上,当作随身查阅的底稿正好。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进