1. 故障现场:Pod像薛定谔的猫一样反复消失
前几天线上K8s集群报警响了,运维同事在群里甩了一张图:某个核心服务的Pod频繁被杀,重启状态持续了快一个小时。我接手排查,第一反应是"代码写炸了?",但看业务日志又没发现明显异常,进程起来没多久就消失。后来定位发现,问题根源不在业务代码,而在K8s资源限制配置上。这个坑值得写出来,因为很多人(包括我自己)都只关注了"资源限制能防止Pod吃光节点",却忽略了一个关键点:资源限制设置得不对,K8s自己的调度和回收机制也会反过来杀Pod。
先说结论现象:那批Pod显示状态是CrashLoopBackOff,kubectl describe拉出来,Last State是Terminated,Reason是OOMKilled,Exit Code是137。看到这个组合,问题基本就锁定在资源限制的范畴了。如果你也遇到过类似情况,这篇文章能帮你少走弯路。主要内容包括:资源限制里requests和limits到底怎么影响Pod存活、QoS等级决定Pod被杀的顺序、完整排查流程和常用命令、以及几个真实踩过的配置坑。
这篇文章适合K8s运维、后端开发、以及所有给应用做容器化的同学。不管你是刚开始接触K8s,还是已经在生产环境折腾过一轮,里面的排障思路和命令都可以直接抄作业。
2. 资源限制的两个维度:requests和limits,少一个都不行
2.1 requests决定调度,limits决定运行上限
先讲最基础但最容易混淆的概念。在Pod定义里,每个容器都可以配置两个维度的资源参数:requests和limits。
requests是"至少预留"的意思,它告诉调度器:给我这个Pod安排一个能满足这些资源的节点。比如一个容器requests.memory=256Mi,调度器会找一个剩余内存大于等于256Mi的节点来放它。这个值是调度时用的,也是资源不足时抢占优先级的重要依据。
limits则是"最多能用多少",它是硬上限。如果容器运行时的实际资源使用量超过limits指定的值,K8s底层的cgroup就会强制执行限制。CPU超了会被throttle(限流),内存超了会直接触发OOM Killer把进程杀掉。
很多人在配置时容易犯两个错。第一个是只设limits不设requests,这样调度器不知道容器到底需要多少资源,可能把Pod调度到一个剩余资源很少的节点,运行时又因为limits限制被杀死,形成"调度没问题、运行就崩"的诡异现象。第二个是requests和limits差距过大,导致调度器认为节点资源很充裕,疯狂往节点上塞Pod,结果节点实际内存被打满,触发节点级驱逐,一批Pod跟着陪葬。
举一个生活中比较好理解的例子:requests相当于你去酒店订房间时跟前台说"我要一个双床房",前台得保证有这间房给你;limits相当于入住后酒店规定"房间最多住3个人",你硬塞10个人进去,酒店保安就会来赶人。前者管"住不住得下",后者管"超不超员"。
2.2 QoS等级:Pod在K8s里的"社会阶级"
围绕requests和limits,K8s会把Pod划分成三个QoS(Quality of Service)等级,这个等级直接决定了节点资源紧张时谁先被杀:
- Guaranteed:每个容器都设置了
requests和limits,并且两者相等。这类Pod等级最高,资源紧张时最后才被杀。 - Burstable:至少有一个容器设置了
requests或limits,但没满足requests=limits的条件。这类Pod是中产,资源紧张时比Guaranteed先被杀。 - BestEffort:所有容器都没有设置
requests和limits。这类Pod基本就是"路边摊",节点资源一紧张,第一个被清理的就是它们。
我这次排查的Pod就是个典型的Burstable配置:requests.memory=256Mi,limits.memory=512Mi。表面看弹性给得很合理,但实际上业务是Java服务,JVM堆内存加线程栈加元数据区,正常跑起来常驻就要700Mi左右。512Mi的地板上限根本盖不住,cgroup一看到内存用量超过limits,立刻把进程杀了。更惨的是,requests和limits不一样,QoS是Burstable,节点内存压力大时驱逐排序又往前靠了一档。
这里有个容易被忽视的细节:QoS等级不是只看Pod开头的配置,而且要逐个容器判断。如果一个Pod里有多个容器,每个容器都必须是requests=limits才能算Guaranteed。只要有一个容器不满足,整个Pod的QoS就降级。很多多容器Pod(比如业务容器和sidecar容器共存)都会在无声无息中掉到Burstable,排查时要特别注意。
2.3 CPU是"弹性压缩",内存是"一刀切"
资源限制还要区分一个更底层的特性:CPU和内存对超限的反应完全不同。
CPU是可压缩资源。容器超过limits.cpu时,cgroup会限制CPU时间片,容器表现为变慢、延迟变高,但进程不会死。CPU的limits设置得越小,throttle次数越多,服务响应越慢。如果这个服务还挂着了就绪探针(readinessProbe)和存活探针(livenessProbe),慢到探针超时,K8s就会认为容器不健康,自动重启它。杀你的不是OOM,而是慢性CPU饥饿导致的自愈机制。
内存是不可压缩资源。容器超过limits.memory时,没有"降速"这个选项,cgroup直接杀掉进程。进程收到的信号是SIGKILL,所以Exit Code会是137。与此同时,内核日志里会留下Out of memory记录。如果你连logs都看不到业务异常输出,大概率就是这里的问题。
所以排查Pod被杀,第一步不是去看业务代码,而是看清Status、Reason、Exit Code和Events。这四个信息能把问题范围迅速收窄,方向对了,后面就是体力活。
3. 完整排查过程:从现象到根因
3.1 用三个命令快速锁定问题范围
接手一个"Pod频繁被杀"的问题,我一般按下面的顺序执行命令,每一步都能滤掉一批可能性:
kubectl get pod -o wide:看Pod的当前状态挂在哪个节点上、重启了多少次。如果能看到CrashLoopBackOff,基本说明是"起来就死"的循环。kubectl describe pod <pod-name> -n <namespace>:看Last State和Exit Code。这一步能把问题分裂成三类:业务异常退出、资源限制杀掉、被驱逐。kubectl logs <pod-name> --previous=true -n <namespace>:如果容器还在循环重启,用--previous可以拉取上一轮进程退出前的日志。这个参数是很多新手不知道的,默认的kubectl logs只能看到当前容器的输出,而循环重启时当前容器往往不打印任何东西。
我当时执行describe看到的关键信息是:
Last State: Terminated Reason: OOMKilled Exit Code: 137OOMKilled这个Reason是cgroup层面的明确信号,含义是:这个容器的内存使用量超过了它自身的limits.memory上限,被内核杀掉。注意这不是节点内存不足导致的,节点可能内存还很充裕,单纯是容器自己的"房顶"太矮。
接下来我用kubectl top pod -n <namespace>看实时使用量,发现容器的内存使用量曲线一直在500Mi-800Mi之间波动,而limits只给了512Mi。到了内存高峰,使用量超过512Mi的瞬间,cgroup触发OOM,进程被杀,然后重启,再涨,再被杀。这个循环的图谱跟业务日志里的"看起来正常"完全吻合——业务代码确实没有抛异常,是底层资源裁决强制断电。
3.2 区分OOMKilled、Evicted和Exit Code 137
这里补充一个容易混淆的知识点。新手容易把OOMKilled和Evicted混为一谈,但它们发生在不同层面:
OOMKilled:容器自身使用内存超过limits.memory,cgroup杀进程。跟节点其他Pod无关,只跟本容器有关。Evicted:节点层面的资源压力(通常是全局内存不足或磁盘空间不足),调度器/节点回收机制把Pod从节点上驱逐出去。触发原因是节点级的kubelet策略,比如memory.available低于阈值,或者nodefs.inodesFree不足。这类Pod的Events里会写明原因,比如The node was low on resource: memory。Exit Code 137:表示进程被SIGKILL杀掉,但这只是表象,它既可能来自cgroup OOM,也可能来自节点OOM,还可能因为手动kubectl delete时Pod被强制终止。需要结合Reason和节点内核日志才能完全确定。
区分它们有一个实用技巧:kubectl describe的Events里如果出现Killing,而且Reason是OOMKilled,说明是容器自身内存超限;如果Events里出现Evicted或者FailedScheduling,则要去检查节点状态和节点上的其他Pod。
这次排查中,我特意登录节点看了内核日志:
journalctl -k | grep oom输出里能看到Out of memory: Killed process的记录,并且日志中标注了被杀的进程PID和对应的cgroup路径。cgroup路径里通常带有Pod的UID和容器名,可以直接对应到具体是哪个容器的哪个进程被杀。这一步做完,根因基本死锁:容器的内存上限和实际需求严重不匹配。
3.3 用监控曲线验证,而不是拍脑袋调参
排查到这一步,有人可能会直接说"把limits调大不就行了"。但我建议先看一下监控曲线,再把数值夯实。因为"调大limits"有两种可能:一是上限确实小于实际需求,调大能解决;二是业务本身存在内存泄漏,调大只是延迟爆炸时间。
我当时打开Prometheus,看的是这几个指标:
container_memory_working_set_bytes:容器的真实内存工作集,比container_memory_rss更能反映"会被OOM计入的用量"。container_memory_max_usage_bytes:容器的历史峰值内存,判断该把limits调到多少。container_cpu_usage_seconds_total:CPU累计使用量,用于分析是否还有CPU throttle的问题。kube_pod_container_status_restarts_total:重启次数时间线,确认问题是否在某个节点状态变化或流量高点集中爆发。
曲线出来后非常清晰:Pod在每天流量高峰期都会内存爬升到峰值,然后瞬间归零,归零的节奏与重启次数完全重合。这说明不是偶发抖动,而是规律性超限被杀。通过max_usage算出的峰值在780Mi左右,考虑到安全余量,我把limits调整到1Gi才是比较稳妥的选择,而不是象征性调到600Mi。
另外一个加分操作是看节点上的/sys/fs/cgroup/memory.events(cgroup v2)或memory.oom_control(cgroup v1),它们会记录OOM触发的次数和原因。虽然kubectl describe已经给了Reason,但这类系统级文件能帮你确认是不是还有别的容器在同一个cgroup路径下被误杀。
3.4 根因复盘:为什么这个"坑"这么深
复盘这次故障,最核心的问题出在资源配额的设定逻辑上。
团队当初给这个Java服务配limits时,参考的是开发环境里JVM参数-Xmx512m。所有人都觉得"JVM最大堆512M,那容器内存给512Mi肯定够"。但这是典型的只看堆内存,不看全貌。Java进程在JVM堆之外还需要线程栈、元数据区、GC相关数据结构、JIT编译缓存、直接缓冲区。加上容器里可能还有其他子进程或sidecar,-Xmx512m不等于"整个容器只需要512Mi"。
在K8s中,limits.memory限制的是整个cgroup的内存用量,包括页缓存。就算JVM堆只用了512M,页缓存、线程栈、网络缓冲区加起来很容易超过限制。而cgroup的OOM判定主要看实际分配的内存页,不看"JVM堆还剩多少"。
这个坑的本质是:资源限制不只是一个"数字",而是与业务运行特征、语言运行时、节点调度策略耦合的综合问题。单看任何一面,都会掉坑。
4. 解决方案与配置参数:把资源限制从"坑"变成"护城河"
4.1 案例一:内存型应用,设置合理的内存上限
对于本次Java服务,我最终的修复配置是这样:
resources: requests: memory: 800Mi cpu: 500m limits: memory: 1Gi cpu: "1"为什么这样定?有三个依据:
- 监控峰值在780Mi左右,limits给1Gi,留出约30%余量应对GC抖动和流量毛刺。
- requests内存给到800Mi,使得调度器能提前为这个Pod预留真实所需的内存,避免节点超卖后在高峰时段出现节点级内存压力。
- requests和limits不完全相等,QoS是Burstable。对于这类对延迟敏感的核心服务,后续我其实更推荐直接做成Guaranteed,即
requests.memory=limits.memory、requests.cpu=limits.cpu。虽然可调度性变低,但在节点资源紧张时被杀风险最小。
这里要特别提醒:调整内存不是改完yaml就完事了,要改的是"需求评估方法"。以后再有人问"这个服务该配多少内存上限",我会让他先跑一周监控,拿出max_usage再说。没有监控数据支撑的资源限制,基本等同于拍脑袋。
4.2 案例二:CPU limits过低引发雪崩
另一个我遇到过的经典场景是CPU限制杀Pod。有个Go服务,单实例平时CPU占用很低,但秒杀活动时会跑满多个核。配置文件里CPU limits写的是500m,相当于0.5个核。活动流量一来,容器CPU被强制throttle,大量请求积压,响应时间从几十毫秒一路涨到几秒。存活探针的timeoutSeconds是3秒,探针连续几次超时后,K8s直接判定容器不健康,开始重启。重启过程中流量又被分发到其他Pod,形成雪崩。
这种情况从kubectl describe看,Last State的Reason是Completed或Error,Exit Code也可能不是137,而是正常退出码,但其实触发点是CPU饥饿。排查方法是在监控里看container_cpu_usage_seconds_total有没有周期性平台期,以及container_cpu_cfs_periods和container_cpu_cfs_throttled_periods这两个指标中,throttled的时间占比是否异常高。如果throttled period占比超过50%,说明CPU限制形同虚设,甚至压制了业务能力。
CPU是压缩资源,超限不会直接杀进程,但会通过健康检查链路间接杀。很多K8s用户查"Pod被杀"只盯着内存和OOM,忽略了CPU throttle这条隐蔽路径。我在实际排障中,见过服务"被重启"时业务日志毫无异常,最后定位到CPU throttle的案例不在少数。
4.3 通用配置建议:给经验不足的团队一个安全公式
如果你的团队刚从传统部署迁移到K8s,对资源需求没有数据积累,我建议先用一套保守安全但不过分浪费的公式:
- 内存限额:取监控
max_usage的1.5倍,且不低于当前配置的2倍。宁可先给足,稳定运行后再逐步下调。 - CPU限额:取监控平均CPU的2倍,但要注意单核服务的CPU上限不要低于
1000m。 - requests设置:内存requests建议等于或略低于limits(比如limits的80%),CPU requests可以用实际平均值的70%到80%。
- 推荐Guaranteed:对于核心服务、有状态服务、主备切换依赖IP/域名不变的服务,把requests和limits设成相等,尽量把QoS拉到Guaranteed,降低被驱逐概率。
- 临时存储不能忘:Pod声明里还可以配置
ephemeral-storage的requests和limits,不然容器写临时文件打满节点磁盘时,会触发节点级驱逐,连带整个节点的Pod遭殃。
还有一点要提醒:设置了limits后,K8s会把Pod对应的cgroup写入内存限制,但并不是所有语言运行时都会自动感知cgroup限制。Java的默认行为是在物理机内存很大的节点上,按节点内存百分比计算堆大小。如果你只给容器设置了1Gi limits,但节点有64G内存,JVM可能把堆初始化成16G,一启动就撞上cgroup限制被杀。这类问题在Java 8/9的老版本容器里特别常见,解决办法是显式设置-Xmx、-XX:MaxRAMPercentage等参数,让JVM读取cgroup限制而不是节点物理内存。
配置示例:
resources: requests: memory: 1Gi cpu: 500m limits: memory: 1Gi cpu: 500m同时JVM启动参数配合:
java -Xms512m -Xmx700m -XX:MaxRAMPercentage=65.0 -XX:MaxMetaspaceSize=256m -jar app.jarMaxRAMPercentage加上MaxMetaspaceSize的组合,可以确保JVM整体内存占用被压在一个可控区间,而不是无脑吃满节点内存。
4.4 配角也很重要:sidecar容器的资源限制
前面提到QoS是按Pod中所有容器共同计算的,这里再展开讲一个大坑。很多Pod会带sidecar容器,比如Istio的Envoy、日志采集agent、配置热更新agent。主容器配置完整,但sidecar容器经常是"裸奔"的:既没有requests也没有limits,或者只有limits没有requests。
这种配置会让Pod在整体QoS评估时掉档。假设主容器是requests=limits的Guaranteed,只要sidecar是BestEffort,整个Pod就变成Burstable。节点内存紧张时,本来不该被杀的核心服务,因为sidecar的"连累"反而被优先驱逐。
我曾经帮一个团队排查过奇怪的Pod漂移现象:每隔一周左右Pod就会从节点A漂移到节点B,没有OOM,没有CrashLoopBackOff,就是Evicted。查到最后发现是日志采集sidecar没设limits,在日志量大的时候把节点临时存储打满,触发了nodefs驱逐,整个Pod被连带清理。
所以配置资源限制时,要把Pod里的每个容器都纳入考虑,不能只管主容器。统一给sidecar设一个合理的requests和limits,哪怕稍微保守一点,也比完全不设要好。
5. 常见问题与排查技巧实录
5.1 排查命令速查表
把这次排查中高频使用的命令整理成一张速查表,遇到Pod被杀问题时按顺序执行:
| 命令 | 作用 | 关键输出 |
|---|---|---|
kubectl get pod -o wide | 查看Pod状态和所在节点 | CrashLoopBackOff、Evicted |
kubectl describe pod <pod> -n <ns> | 查看退出原因和事件 | Last State、Reason、Events |
kubectl logs <pod> --previous=true -n <ns> | 拉取上一轮退出前日志 | 业务侧是否有异常 |
kubectl top pod -n <ns> | 查看Pod实时资源用量 | 内存/CPU实时值 |
kubectl top node | 查看节点资源使用率 | 是否节点级超卖 |
journalctl -k | grep -i oom | 登录节点查看内核OOM记录 | Out of memory: Killed process |
cat /sys/fs/cgroup/memory.events | cgroup v2 内存事件 | oom_kill计数 |
kubectl get events --sort-by=.lastTimestamp | 查看集群事件时间线 | 驱逐/调度失败记录 |
5.2 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| Exit Code 137,Reason=OOMKilled | 容器内存超过limits | 调大limits或优化内存占用 |
| Exit Code 137,Reason=Evicted | 节点内存/磁盘不足 | 检查节点资源、其他Pod占用 |
| 进程没被杀但频繁重启 | CPU throttle导致健康检查失败 | 看throttle指标,调大CPU limits |
The node was low on resource: memory | 节点内存压力过大 | 增加节点或降低Pod requests |
The node was low on resource: ephemeral-storage | 临时存储超限 | 清理日志、配置临时存储limits |
| 启动即死,日志无明显异常 | JVM/运行时未感知cgroup限制 | 检查-XX:MaxRAMPercentage等参数 |
5.3 三个真实的"我以为不会犯"的坑
第一个坑是把limits当成"运行标配",而不是"紧急预案"。很多人配limits的初衷是防止内存泄漏拖垮节点,但在业务高峰期,这个limits反而成了业务扩张的天花板。调大limits之前,一定要确认请求量上涨带来的内存增长是否还在良性范围内,不能一超限就调,否则会掩盖内存泄漏问题。
第二个坑是只调Pod配置,没看节点分配。某次我调整完limits后,Pod还是被杀,点开节点指标发现节点本身内存已经用了95%。这种情况下就算Pod上限给到很大,节点级驱逐依然会执行。资源限制是Pod和节点两个层面的共同约束,只修一头没用。
第三个坑是以为"重启就好了"。CrashLoopBackOff的自愈逻辑确实会自动拉起Pod,但如果没有找到并消灭根本原因,每次重启都只是无限循环。我在排查中习惯把每个被杀Pod的Events截图留档,对比多次事件的规律。比如不同节点的Pod被杀时间是否集中在同一时段,是否与某些慢查询、大流量、定时任务重合。规律越明显,根因定位越快。
我觉得排查K8s Pod被杀问题,最怕的不是技术复杂,而是方向错了还在努力。先把describe和日志看明白,再结合监控曲线和数据做判断,资源限制这个坑是完全可以避免的。现在再遇到"Pod又双叒叕挂了"的告警,我会先问三个问题:Exit Code是多少?OOM还是Evicted?监控曲线有没有规律?这三个问题问完,一半的答案已经在我心里了。