前两篇我把 k8s 的组件组成和集群初始化讲完了,结果后台留言里问得最多的不是“怎么装”,而是“装好之后为什么老出怪问题”。这篇 k8s - 3 我换个思路,不讲架构图,不贴 YAML 模板,专门聊最近几个月在几个不同环境里处理过的线上问题:Pod 被驱逐、PVC 挂不上、Service 访问超时。这些问题的根因大多不在 k8s 自身的调度逻辑,而在资源模型、存储插件、网络组件之间的配合缝隙里。这篇文章不是给第一次接触 k8s 的纯新手准备的,而是给集群已经跑起来、但时不时被稳定性问题折腾到半夜的人看。
如果你属于“容器能跑通,YAML 能敲出来,但一上量就出幺蛾子”的阶段,这篇应该会对你有用。我会尽量省去控制面组件的原理重复,直接把矛头对准数据面和资源面:Pod 在节点上的生存质量、存储卷的挂载链路、网络访问的可疑环节。每个问题我都按“现象→排查→根因→目前能接受的规避方案”来写,尽量做到看完能直接套用到自己的环境里。
1. 先对齐一个观念:能调度成功和稳定运行是两码事
1.1 前两篇的进度,以及这一篇的抓手
前两篇讲完了 k8s 的基础组件和怎么把集群拉起来,但这只是起点。集群拉起来之后,你面对的就不再是“能不能创建”,而是“创建之后能不能活得久、稳不稳、出问题怎么定位”。调度器把 Pod 放到某个节点上,只代表那一刻节点上有名义上的剩余资源;不代表接下来几天这个节点不会因为磁盘压力、内存水位、PID 耗尽把你刚部署的服务撵走。
所以这一篇的抓手选在“稳定性”而不是“功能”。你可能已经看过很多文章讲 Pod 的 requests 和 limits 怎么填、Service 的 YAML 怎么写,但很少有人把这几个环节串成一条完整链路给你看。我在这篇里要做的就是把链路串起来:资源声明→调度→节点压力→驱逐→存储事件→网络规则,每一步都有对应的现象和日志,也有我实际踩过之后总结的定位方法。
1.2 你盯着控制面,我建议你先盯数据面
很多人集群出问题第一反应是看控制面组件,比如 apiserver、调度器这些日志。但根据我的经验,大部分线上故障现象出现在数据面:Pod 起不来、Pod 起来了但不健康、Service 有 Endpoints 但访问超时。这些现象背后的根因,往往藏在节点 kubelet 的决策、存储 CSI 插件的事件、网络组件维护的转发规则里。
因此这篇的章节顺序也按“数据面排查”来设计:先讲资源与驱逐(决定 Pod 能否存活),再讲存储挂载(决定 Pod 能否正常使用数据),后讲网络访问(决定流量能否正确进入 Pod),最后给一个踩坑后的检查清单。这个顺序不是说控制面不重要,而是说排障的时候必须按这条链路由下往上查,先把数据面的嫌疑排除干净,再回头怀疑控制面。
2. requests 与 limits:驱逐、OOM、优先级这些事全从这里来
2.1 requests 和 limits 的语义差异
先做一个可能有点反直觉的说明:k8s 里的资源模型不是“你给 Pod 分配了 1 核 2G,它就真的独占这些资源”。requests 是调度时的承诺值,limits 是运行时强制上限。调度器只看 requests,不看 limits。也就是说,两个 Pod 都声明 requests 为 0.5 核,但它们各自的 limits 可能是 1 核,也可能是 4 核;只要节点的可分配资源还够 0.5+0.5,调度器就会把两个 Pod 放在同一个节点上,即使它们之后都可能把 CPU 冲到 4 核。这个设计本身是合理的,它允许节点资源被超卖,但你必须知道超卖的代价是什么:节点 CPU 争抢、内存水位上涨、最终触发驱逐。
内存这边更容易踩坑。limits.memory 一旦设置,kubelet 会通过容器运行时为 Pod 创建对应的 cgroup 限制,内核在内存超限时会按 cgroup 的层级做回收和 OOM。很多人的误解是“Pod 内存超限应该被 kill 掉”,确实会,但 kill 的触发点不一定是 Pod 里的主进程。如果 Pod 里有多个容器,或者容器内部有多个进程组,内核的 OOM 选择器会结合 oom_score 来决定先杀谁,这经常导致一些你不认识的辅助进程先消失,而主进程还在苟活,表现出来就是“服务没崩,但状态不对”。这块光加内存没用,得先把 requests/limits 和 QoS 等级一起想清楚。
2.2 QoS 等级:为什么有的 Pod 先被驱逐
资源声明方式决定了 Pod 的 QoS 等级。三个等级分别是 Guaranteed、Burstable、BestEffort,优先级从高到低。只有“所有容器都同时设置 CPU 和内存的 requests 和 limits 且两者相等”的 Pod 才是 Guaranteed;只设置了 requests 或只设置了 limits 的算 Burstable;完全不设置的算 BestEffort。
这个等级直接决定节点内存压力下的驱逐顺序:先 BestEffort,再 Burstable,最后才轮到 Guaranteed。我见过不少线上事故,都是因为业务 Pod 图省事只写了 limits 没写 requests,结果节点一紧张,业务 Pod 比那些写完整的工具 Pod 更早被驱逐。记住一个原则:你越想让业务稳定,越要把 CPU 和内存的 requests、limits 都写上,并让它们相等。这不是教条,是 QoS 分级的实际规则。很多生产环境的最佳实践都会强调这个写法,原因就在这里。
2.3 一个真实案例:CPU 饥饿引发的 Pending
说个小案例。A 同学在某个环境里部署了一个计算任务,Pod 的 requests.cpu 写了 2,节点只有 4 核,两个同样规格的任务 Pod 被调度到同一节点。任务实际跑起来 CPU 瞬间超过 limits 的 4 核,结果 cgroup 的 CPU 配额把两个 Pod 都压到接近 2 核,加上系统本身还有基础进程要 CPU,任务从预期 30 分钟跑成了 90 分钟。
这还没完,节点负载升高后,kubelet 的 eviction manager 检测到节点内存压力信号,把节点标记为 MemoryPressure,新的 Pod 进入 Pending。A 同学一开始反复看调度器,觉得是资源不够,其实节点上的可分配资源还有,只是节点被标记了压力状态,调度器默认不会再往这个节点放新的 BestEffort 和高内存请求的 Pod。这种“看着像调度问题、实际是节点压力问题”的案例,没有资源模型的概念很难定位。后面我会在第 5 节展开节点压力怎么排查。
3. PVC 挂载失败的完整排查链路(从事件到插件日志)
3.1 现象:Pod 反复 FailedMount 或 CrashLoopBackOff
先说最常见的现象:Pod 里引用了 PVC,但 PVC 显示 Bound,Pod 事件里却反复出现 FailedMount,或者容器一直 CreateContainerConfigError。很多人第一反应是检查 PVC 的容量和 storageClass 名字,但这两个都没问题时,问题往往在挂载链路的下一层。
这里有个细节值得注意:PVC 显示 Bound 只代表 PV 对象与 PVC 对象完成了绑定,不代表底层卷真的可用了。存储系统的卷创建是异步的,尤其是一些云存储或网络存储,底层卷在创建过程中可能处于“预置中”状态,这会让挂载动作一直失败。你盯着 YAML 看半天看不出问题,因为问题根本不在 YAML 层面。
3.2 排查顺序:describe → 存储插件日志 → kubelet 日志
完整排查我先给一个可复现的顺序,这也是我自己踩过几次坑之后固定下来的操作链路。
第一步,用命令查看 Pod 详情,重点看 Events 段里 FailedMount 的完整原文。这个信息比你想的值钱,因为它会告诉你具体是“无法找到卷”还是“挂载超时”还是“权限拒绝”。这三种原因对应的排查路径完全不同,如果省略这一步直接去翻代码,基本是在浪费时间。
第二步,如果是“无法找到卷”,先去确认存储服务端侧的卷状态。很多存储系统有自己的控制台或者命令行,卷可能处于“正在创建中”或“异常”状态,表面上看 PVC 是 Bound,但底层卷没有被真正准备好,挂载自然失败。此时在存储服务端把卷的状态调正常,Pod 往往能在下一次重试后自动恢复。
第三步,如果卷正常,但挂载还是失败,那就去查存储 CSI 插件和 kubelet 的日志。存储插件的容器日志可以直接通过查看对应 Pod 日志拿到;kubelet 日志在节点上,一般用系统服务日志拉取。我遇到过一个案例,挂载失败的原因居然是节点上缺少某个内核模块,存储插件的初始化命令返回了一个非常模糊的错误码,但这个错误码在 kubelet 日志里对应着一条“operation not supported”,再往前翻才发现是文件系统类型不匹配。这类问题没有第二、第三步的日志根本定位不到。
3.3 容量扩容与回收的那些坑
PVC 的容量扩存在几个很隐蔽的坑。首先,很多存储插件的扩容需要卷处于可扩容状态,如果 PVC 用的是旧的 storageClass,或者存储系统本身不支持在线扩容,你改了 size 之后 PVC 会一直保持 Pending。其次,即使扩容成功,Pod 里的文件系统也需要手动 resizefs(有些 CSI 插件会自动做,有些不会),如果没做,df 看到的容量还是老的,业务层会以为扩容失败。
回收策略同样容易被忽略。PVC 的 reclaimPolicy 设成 Delete 时,删除 PVC 就会连带删除后端卷,这个操作没有二次确认。某个环境里一次误删 PVC,直接导致一批数据找不回来。我现在的习惯是:所有存数据的 PVC 一律用 Retain,宁可手动清理卷,也不要让删除动作变成数据事故。这一点在测试环境尤其重要,因为测试环境最容易出现“顺手删一下”的情况。
4. Service 访问不稳定:连通性之外的三处盲区
4.1 DNS 解析异常:第一层盲区
Pod 内访问 Service 最常见的问题不是路由不通,而是 DNS 解析异常。很多业务容器的镜像里没有 dig、nslookup 这类工具,一旦出现解析失败,排查效率非常低。我的建议是:镜像里至少保留一个可用的小工具,比如 wget 或 curl;同时在集群侧记录一次 DNS 查询日志,确认核心组件是否响应。
DNS 解析出问题的原因很杂:配置了无效的 search 域、上游 DNS 响应慢、Pod 内的 resolv.conf 被覆盖,甚至有时候只是容器的 /etc/hosts 缺了条目。用一个小技巧可以快速定位:进入 Pod 后先用 getent hosts 命令看解析结果,如果有结果但业务还是超时,问题在连接层;如果没有结果,问题在 DNS 配置或组件。不要一上来就怀疑 Service 路由,先把解析这关确认掉。
4.2 后端摘除延迟:滚动更新时的连接残留
另一个隐蔽问题出现在滚动更新期间:Service 背后有 Endpoints 列表,但 Pod 从 ready 变成 not-ready 到 Endpoints 真正移除,中间存在一段时间差。如果业务 Pod 在收到终止信号后不主动排空连接,而 Service 的 targetPort 还在转发,就会出现请求打到正在销毁的 Pod 上,造成偶发超时。
这也是为什么我强调要配置 preStop 钩子和合理的 terminationGracePeriodSeconds。preStop 里 sleep 几秒,让前端的负载均衡器或调用方把连接切走,比在代码里反复处理“连接被重置”要省事得多。通常 sleep 5 到 10 秒就够,具体要看 Service 的更新频率和调用方的超时设置。如果你发现滚动发布时总有少量 5xx,这个方向优先检查。
4.3 节点网卡与连接跟踪表:沉默的三号盲区
第三种盲区最容易被忽略:节点网络层。流量从 Service 转发到 Pod,后端是 iptables 或 IPVS 规则,这些规则由网络组件维护。规则本身一般不会坏,但连接跟踪表(conntrack)可能会满。当一个节点上连接数非常高,conntrack 表被占满,新连接会被丢弃或超时,表现为“通但不稳定”:偶尔通,偶尔超时,重试几次又好了。
定位方式很简单:在节点上查看 conntrack 统计,或者观察内核日志里有没有 conntrack 相关警告。如果确认是这个问题,常规做法是调大 conntrack 上限、缩短超时时间、或者优化业务侧的连接复用。我这里更想提醒的是:很多“Service 不稳定”的锅最后都甩给网络组件,实际上大部分是 conntrack 压力和后端摘除延迟两个原因,它们都在节点侧,不在组件规则侧。
5. 节点压力导致的连锁反应,比你想的更隐蔽
5.1 磁盘压力与镜像清理
节点压力不只是内存压力一种。磁盘压力(DiskPressure)是另一大类,表现是节点上有镜像无法拉取、Pod 无法创建新容器,甚至已有容器被驱逐。磁盘压力经常不是因为你业务写了很多数据,而是容器日志、镜像层垃圾、临时文件累积导致。
我遇到过这样一个场景:一个节点上并存多个应用,日志没有做轮转,单个日志文件涨到几十 GB,kubelet 在周期性检查可用空间时触发了驱逐信号。当时我第一反应是“磁盘快满了”,df 一看根分区还有空间,问题出在 kubelet 配置的镜像目录和日志路径挂载在一个独立分区,那个分区已经 90% 了。所以排查磁盘压力,先确认 kubelet 的根目录和数据目录各指向哪个分区,再去查对应的容量,别被根分区的剩余空间误导。
5.2 PID 压力:最冷门的驱逐信号
内存和磁盘很多文章都讲过,PID 压力(PIDPressure)反而是最容易被忽略的。节点上的 PID 数量有上限,如果某个节点的进程或线程总数过高,新容器可能无法创建进程,kubelet 会把节点标记为压力状态。这类问题在 Java 类应用、大量线程的中间件上特别常见。
诊断 PID 压力,直接用节点系统命令查看当前 PID 数量和上限,对比 kubelet 或内核日志里的压力信号即可。处理手段有两层:治标的做法是给节点上的 Pod 设置合理的 PID limit,避免单个 Pod 的线程无限膨胀;治本的做法是排查应用层创建了大量线程或协程泄漏。我见过一个中间件因为配置了过大的连接池,每个连接都起线程,直接把节点 PID 撑爆,最后整个节点上的新 Pod 都起不来。
5.3 驱逐预算:留多少余量才算合理
如果把驱逐视为 kubelet 的自我保护机制,那你要做的不是“让驱逐永不发生”,而是“让驱逐尽量发生在不重要的 Pod 上”。这也是为什么要设置 PodDisruptionBudget(PDB),它约束的是自愿中断场景;对非自愿的节点驱逐,PDB 不直接拦,但它能帮助你在滚动更新和集群维护时避免一次掉太多副本。
实际项目里,我会给有状态服务配置 PDB,允许的最小可用副本数保持在 n-1 或 50% 左右;同时把核心服务的 QoS 提升到 Guaranteed。这样即使节点压力真的来了,最坏情况也是先驱逐非核心 Pod,核心服务有更高的存活概率。这个“预算”思路比单纯调大节点内存更实用,因为节点资源总有限,关键是让有限资源优先保谁。
6. 我沉淀下来的排查顺序与检查清单
6.1 一个固定的排查顺序
历经各种线上问题之后,我给自己定了一个固定顺序,遇到“Pod 不稳定、Service 访问异常、存储挂载失败”这类问题,基本按这个顺序走,能省一半时间:先看现象和事件(describe 和 get events 是第一步),再看节点水位(节点内存、磁盘、PID),然后看对应组件日志(存储插件、网络组件、kubelet),最后才去翻业务代码和配置。顺序不能乱,否则很容易在业务侧浪费大量时间,最后发现根因在节点层。
我见过不少同事一上来就进 Pod 调代码,折腾半天发现是节点压力驱逐;也见过有人反复重建 Service,结果问题在 conntrack 表。固定顺序的意义不是迷信,而是让你在慌乱的时候有个不犯错的操作基线。排障最怕的不是慢,是乱。
6.2 可以直接抄走的检查清单
最后放一张我实际在用的检查清单,你可以直接拿去对号入座。
| 检查项 | 常用命令或方法 | 常见根因 |
|---|---|---|
| Pod 反复重启 | describe 查看 Pod、日志查看容器 | requests/limits 不合理、探针过严、节点压力驱逐 |
| PVC 一直 Pending | describe 查看 PVC | storageClass 不存在、容量超配额、存储插件未部署 |
| FailedMount | describe 查看 Pod 事件 | 后端卷异常、节点缺内核模块、CSI 插件日志报错 |
| Service 偶发超时 | 进入 Pod 用 getent 解析、节点查 conntrack | DNS 解析慢、后端摘除延迟、conntrack 表满 |
| 节点 MemoryPressure | describe 查看节点 | requests 总量超过节点可分配、有 Pod 内存泄漏 |
| 节点 DiskPressure | 查看 kubelet 数据目录所在分区 | 日志未轮转、镜像残留、临时文件累积 |
| 节点 PIDPressure | 节点查看 PID 上限与当前值 | 应用线程泄漏、连接池过大 |
这张表我打印过贴在工位边上,每次排障直接按表格拉一遍。它不保证一次定位,但能保证你不会在错误的方向上停留太久。时间在故障处理里是最贵的,方向错了比查得慢更致命。
最后再分享一个很实际的体会:k8s 的排障没有银弹,但它有“链路意识”。只要你始终记得资源声明、节点状态、存储卷、网络转发这四层是层层联动的,出问题时从上往下或从下往上顺一遍,大多数问题都能在半小时内缩小到具体组件。这篇 k8s - 3 如果只能留下一句话,我希望是:别只在控制面找答案,去节点、去事件、去日志里看数据面到底发生了什么。