凌晨两点半,值班手机把我吵醒。监控面板上一台 32C64G 的 worker 节点 MemoryPressure 亮红,十六个 Pod 在三分钟内被驱逐,其中两个是我们核心的 Redis 从节点。当时第一个念头是"内存不够了要扩容",可查完之后发现,真正的原因根本不是容量不够,而是 kubelet 的 Eviction 机制在按照它自己那套"资源模型"做了一次过于激进的回收。
从那次之后,我才开始把"调度"和"驱逐"放在同一个闭环里看。K8S 的调度不是把 Pod 放到节点上就结束了,放上去之后,节点上的资源模型会通过 Eviction 做二次审计——超用、抖动、缓存堆积,都会被这种机制找回来。这篇文章打算把这条链路完整讲清楚:资源模型怎么定义、Eviction 怎么依据这个模型执行回收,以及我们该怎么设计阈值和 QoS,让回收动作变得可预测、可干预、不误伤。
1. 先从一次"明明离墙还有距离却被赶下车"说起
1.1 当时的现场还原
那台节点的内存总量是 64Gi,驱逐前监控显示可用内存还有不到 300Mi,但注意,当时节点上并没有出现 OOM Killer 的痕迹,系统负载也不高。问题出在 kubelet 的 Eviction Manager 判定memory.available低于了硬阈值。
这里有个新手容易误解的点:Eviction 并不是等内存彻底耗尽才动手。kubelet 有一条默认的硬阈值,比如memory.available<100Mi,也就是说,节点内存可用量一旦低于 100Mi,kubelet 就会开始逐 Pod。这个memory.available也不是简单的"剩余内存",它是 kubelet 通过 cgroup 统计计算出来的"可回收视角下的可用内存",Page Cache、闲置的 file-backed 内存都会被算成可回收资源。
我那次事故的现场实际上是有大量 Page Cache 堆积的。我们的一个训练任务疯狂读文件,把节点的缓存区撑得很大,free -h看起来内存几乎耗尽,但那些缓存其实是可以被回收的。kubelet 在计算memory.available时对这部分有自己的处理逻辑,某些版本、某些 cgroup 配置下,它并不会像内核那样优先把可回收缓存让出来,而是直接判定内存压力成立,触发驱逐。
1.2 "距离墙还有多远"谁说了算
Kubernetes 里涉及资源判断的口径有好几套,最核心的是这两套:
- 调度器的口径:只看 Pod 声明的
requests。每个 Pod 请求了多少资源,调度器就按这个做加法,判断节点是否"装得下"。 - kubelet 的口径:看节点侧实际用量,参考
limits。Pod 实际吃到多少内存、多少磁盘、多少 inode,kubelet 按这个做减法,判断节点是否"撑得住"。
这两套口径的差异,就是一切调度和驱逐问题的根源。调度器说"装得下",不代表运行时不爆;运行时没爆,也不代表requests声明合理。Eviction 就是第二套口径的执行者——它依据的"资源模型",本质上是节点层面的实时用量模型,而不是调度器手里的请求声明模型。
1.3 这个机制存在的意义
很多人恨 Eviction,觉得它老是"没事找事"。但从集群治理的角度看,Eviction 是资源模型的最后一道兜底:保证节点不会因为个别 Pod 失控而整体崩溃。没有 Eviction,一个内存泄漏的 Pod 会直接把节点打 OOM,进而拖累同节点所有业务。有了 Eviction,kubelet 能在崩溃前把部分 Pod 迁走,牺牲局部保全局。
理解了这一点,后面的所有设计决策都顺了——我们要做的不是"关掉 Eviction",而是让它在正确的时机、按正确的顺序、驱逐正确的对象。
2. 资源模型的原点:requests 与 limits 的分工
2.1 买票和实际占座是两回事
用一个生活化的类比:requests相当于你买票时申报的"我大概会占几个座位",调度器根据这个决定放不放你上车;limits相当于硬性规定的"最多占几个座",超过就会被按超载处理。
requests只用于调度,不限制运行时。limits不参与调度拟合,但会限制 CGroup 资源上限(CPU 是 throttle,内存是 OOM 或驱逐)。
这就产生了一个很常见的坑:一个 Pod 声明requests: 100m,limits: 4Gi,调度器看到它只占 100m 的 CPU 额度,就把它塞进一个已经很满的节点。运行起来之后它实际吃到 3Gi 内存,别的 Pod 就遭殃了。等到 kubelet 触发 Eviction,你会发现最先被驱逐的往往是那些"实际用量远超 requests"的 Pod——因为它超用了自己申报的份额。
2.2 QoS 等级:Eviction 的优先队列
Pod 的requests和limits是否相等、是否设置,决定了 Pod 的 QoS 等级。Eviction 在选择驱逐对象时,QoS 等级是核心排序依据。
| QoS 等级 | 配置特征 | Eviction 中的地位 | 典型风险 |
|---|---|---|---|
| Guaranteed | requests == limits,且每个容器都设置 | 最后动,只有超用或极端压力才会被驱逐 | 数量多时挤占其他 Pod |
| Burstable | requests 存在,但至少有一个容器 requests < limits | 中间档,优先驱逐"实际用量超过 requests"的 | 声明不实,容易被误判 |
| BestEffort | 全部容器都不设置 requests/limits | 最先动,压力一来基本全没 | 运维视角的"隐形炸弹" |
我见过很多团队把核心数据库 Pod 配成 Guaranteed,却把日志采集 DaemonSet 留成 BestEffort。平时没事,一旦节点有压力,日志采集先死,然后监控断流,你连驱逐现场都看不到,直接变瞎子。这是最典型的"资源模型设计失败"。
2.3 Node Allocatable 里藏着被我们忽视的三块扣减
调度器和 kubelet 都基于节点的Allocatable做判断,但很多人不知道这个值是怎么算出来的。官方公式是:
Allocatable = Node Capacity - Kube-Reserved - System-Reserved - Hard-Eviction-Threshold也就是说,节点总容量在分配给 Pod 之前,已经被预留了三份:kubelet 自身、系统进程(sshd、systemd 等)、以及 Eviction 硬阈值对应的那部分内存。
如果没配--system-reserved和--kube-reserved,这两个值默认是 0,那公式就退化成Capacity - Eviction-Threshold。这带来的问题是:节点上系统进程本身也要吃内存,而调度器看不到这部分占用,依然按满容量塞 Pod。等到系统进程把内存吃掉一截,kubelet 再用低于阈值的memory.available触发驱逐,Pod 就成了替罪羊。
我在生产环境里见过太多莫名其妙的驱逐,最后查出来就是没预留系统进程的内存配额。给 kubelet 配置里加上systemReserved和kubeReserved,很多"灵异驱逐"直接就消失了。
3. Kubelet Eviction 的参数棋盘:信号、阈值与宽限期
3.1 六大驱逐信号表
Eviction Manager 不只看内存,它监控的信号有六类。每个信号对应一个资源维度,任何一个跌破阈值都会进入 Pressure 状态。
| 信号 | 含义 | 常见阈值写法 | 触发后果 |
|---|---|---|---|
| memory.available | 节点可用内存(含可回收缓存口径) | memory.available<500Mi | MemoryPressure |
| nodefs.available | kubelet 根目录所在文件系统可用空间 | nodefs.available<10% | DiskPressure |
| nodefs.inodesFree | 根文件系统可用 inode 数 | nodefs.inodesFree<5% | DiskPressure |
| imagefs.available | 容器镜像所在文件系统可用空间 | imagefs.available<15% | DiskPressure |
| imagefs.inodesFree | 镜像文件系统可用 inode 数 | imagefs.inodesFree<5% | DiskPressure |
| pid.available | 可用 PID 数量 | pid.available<10% | PIDPressure |
注意nodefs和imagefs的区分:如果 /var/lib/kubelet 和容器镜像目录在同一个文件系统上,kubelet 只会用nodefs的信号来判断,imagefs的配置会失效。很多同学配置了 imagefs 的阈值却发现不生效,大概率就是这个原因。
3.2 软阈值、硬阈值与宽限期的配合
evictionHard是硬性指标,一旦突破,kubelet 立即开始驱逐,没有商量余地。evictionSoft是软性指标,突破后不会马上驱逐,而是给 Pod 一个evictionSoftGracePeriod宽限期,在这个时间内如果能恢复到阈值以上,就一切照旧。
我推荐的做法是把两者配合使用:软阈值报警、硬阈值兜底。比如:
apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration evictionSoft: memory.available: "1Gi" evictionSoftGracePeriod: memory.available: "1m30s" evictionHard: memory.available: "300Mi" nodefs.available: "10%" nodefs.inodesFree: "5%" imagefs.available: "15%" pid.available: "5%" evictionPressureTransitionPeriod: "3m" evictionMinimumReclaim: memory.available: "200Mi"宽限期是这个机制里最有价值的一个旋钮。它让 kubelet 在真正动手前,给 GC 线程、给运维介入留了时间窗口。一个 90 秒的软宽限期,往往比多 200Mi 的硬阈值更能保护业务。
3.3 最小回收量:防止"驱逐完又立刻触发"的抖动
evictionMinimumReclaim的作用是:当 kubelet 决定执行驱逐时,至少要回收这么多资源才停手。没有这个参数,kubelet 可能驱逐一个 Pod 只回收了 100Mi,内存刚刚回到阈值以上,过几秒又被顶破,于是再次驱逐,形成一个驱逐风暴。
我给生产环境配evictionMinimumReclaim.memory.available = 200Mi之后,驱逐次数明显下降。因为它一次性把水位拉得足够高,给了后续调度和业务恢复的时间。
3.4 镜像 GC 与容器 GC 的协同
很多人不知道,DiskPressure 其实有更温和的回收路径:镜像 GC 和容器 GC。kubelet 在触发 Pod 驱逐之前,会先尝试删除不再使用的容器镜像和已经死掉的容器。默认的image-gc-high-threshold是 85%,image-gc-low-threshold是 80%。
也就是说,磁盘使用率超过 85%,kubelet 就开始删镜像,删到 80% 为止。如果删完镜像仍然低于nodefs.available的驱逐阈值,才轮到驱逐 Pod。
这也是为什么我建议把nodefs.available的硬阈值设在 10% 而不是 5%——要给镜像 GC 留出操作空间。5% 的阈值意味着磁盘几乎满了才开始处理,GC 可能来不及腾挪就直接驱逐业务。
4. QoS 与驱逐次序:kubelet 是怎么"点名"的
4.1 一个相当反直觉的排序规则
kubelet 选驱逐对象时,不是简单按 QoS 低到高排队。实际规则有三层:
- 先看 Pod 的实际用量是否超过它的
requests,超用的排在最前面; - 再用 QoS 等级排序,BestEffort 排最前,Burstable 其次,Guaranteed 最后;
- 最后在同等级内看 PriorityClass,优先级的先保,低优先级的先牺牲。
这就解释了为什么 Guaranteed 的 Pod 也会被驱逐:Guaranteed 要求 requests == limits,理论上不可能"超用",但如果你把 CPU 的 requests 和 limits 配成相同值,内存没有设置 limits,或者反过来,有些容器没有同时满足所有资源维度的相等条件,QoS 就会降级,或者运行时因为 cgroup 配置问题实际用量超过限制。只要"实际用量超过 requests"这个条件成立,无论什么 QoS 都会被排到前面。
4.2 PriorityClass 能救命,但不是万能的
给核心业务配上高优先级的 PriorityClass,在 kubelet 驱逐排序里确实能往后排。但要清楚,PriorityClass 真正强势的场合是调度器的抢占(Preemption),而不是 kubelet 的节点压力驱逐。
调度抢占是这么工作的:一个高优先级 Pod 调度不上,调度器会把节点上低优先级的 Pod 赶走(标记为 victim),把位置腾出来给高优先级 Pod。这个流程里,受害者 Pod 的 PDB 一般也拦不住。
而 kubelet 的节点压力驱逐更"现实":内存都爆了,谁实际吃得多先赶谁。PriorityClass 只是同等级内部的排序键,如果节点上没有其他能替代的高耗内存 Pod,你的核心业务就算优先级调到最高,也照样可能被驱逐。真正能让它活下来的,是合理设置 requests/limits,保证它不超用。
4.3 PDB 在节点压力驱逐下只是"参考意见"
先说结论:PodDisruptionBudget 主要用于保护主动驱逐场景,比如我们主动kubectl drain节点、或者通过 Eviction API 驱赶 Pod。节点压力驱逐场景下,kubelet 在无法回收足够资源时,是可以驱逐被 PDB 保护的 Pod 的。
这个点很关键,因为它决定了一个设计原则:不要把 PDB 当作节点压力下的安全网。PDB 是给运维人员自己操作时上的"纪律约束",不是给 kubelet 设置的"免死金牌"。你在做集群维护排空节点时,PDB 帮你保证业务最少存活副本;但节点内存爆炸时,kubelet 认资源不认 PDB。
4.4 四种"驱逐"容易混,先分清
日常工作中"驱逐"这个词被用滥了,我梳理一下四种完全不同的机制:
| 机制 | 触发方 | 依据 | 是否尊重 PDB |
|---|---|---|---|
| Kubelet 节点压力驱逐 | kubelet Eviction Manager | 节点实际资源信号 | 不保证 |
| 调度器抢占 | kube-scheduler | 高优先级 Pod 无法调度 | 一般不尊重 |
| Eviction API / drain | 运维人员 | 主动操作 | 尊重 |
| Descheduler | 被调度的工具 | 集群资源不平衡 | 尊重 |
其中 Descheduler 是前几年开始被广泛使用的一个独立组件,不是 Kubernetes 核心自带的。它会根据集群的整体资源分布,把某些 Pod 主动驱逐掉,让调度器重新放置,从而平衡各节点负载。这算是"基于资源模型的资源回收"里最温和、最可控的一种手段,强烈建议大型集群引入。
5. 实战复盘:一次 MemoryPressure 驱逐的完整排查链路
5.1 从告警到定位,按什么顺序排查
回到开头那次事故。我在排查时走的路径,现在整理成可复现的清单:
先看节点状态和事件,确认驱逐确实发生了:
kubectl describe node worker-03 | grep -A10 Conditions kubectl get events -A --sort-by=.lastTimestamp | tail -50describe node里的 Conditions 会显示MemoryPressure=True的起始时间,以及 Kubernetes 的LastHeartbeatTime。events里能直接看到类似Evicting container kube-proxy的驱逐动作记录。
然后拉 kubelet 日志,找驱逐决策的直接原因:
journalctl -u kubelet --since "2024-11-20 02:15:00" | grep -i evict日志里会打印类似eviction manager: attempting to reclaim memory和具体的 threshold 信息。这一步能确认是哪个信号、哪个阈值触发的。
再看节点和 Pod 的实际资源使用:
kubectl top node worker-03 kubectl top pod -A --sort-by='memory' | grep worker-035.2 那次事故的根因链
排查到这一步,根因基本清晰了:
- 节点上有一个日志采集的 DaemonSet,没配 requests/limits,实际内存吃到 2.8Gi,QoS 是 BestEffort,被 kubelet 第一个点名;
- 有两个批量训练任务 Pod,声明了 requests 但声明值极低(300Mi),实际各吃了 6Gi,属于典型的"声明与用量严重脱节",被排在驱逐名单第二梯队;
- 我们核心的 Redis 从节点,配置的是 Burstable,requests 给的 1Gi,实际因为流量高峰吃到 2.5Gi,超用了 requests,排在了同一梯队里。
最终 kubelet 从日志采集和训练任务开始驱逐,回收内存仍然不够,于是轮到了超用状态的 Redis 从节点。两个 Redis 从节点被驱逐后,主从复制拓扑直接重建,业务出现秒级抖动。
这个案例里没有任何一个环节是"系统不知道"的:kubelet 知道每个 Pod 的实际用量,知道它们的 QoS,知道它们是否超用requests。它只是按照规则执行了回收,而规则本身暴露了我们的资源模型设计问题——核心业务的 requests 给低了,非核心业务的资源根本没有声明。
5.3 cgroup 层面的细节验证
如果要更深入地确认内存压力来源,可以进到节点上看 cgroup 统计。cgroup v2 环境下:
cat /sys/fs/cgroup/memory.events这个文件会记录high和max事件的累计次数。high事件频繁出现但max没触发,说明有 cgroup 的内存上限在压着,但没有直接 OOM。再配合:
cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.stat | grep -E '^(anon|file|slab) '可以拆分出匿名页、文件页和 slab 的占比。如果 file 占比很高,说明 Page Cache 是主要压力来源,这时候驱逐 Pod 其实不是最优解,更该做的是调内核参数或者检查是否有异常的读文件行为。这也是当时我们判断"内存其实够用,但 kubelet 不这么认为"的关键证据。
6. 回收策略设计:把 Eviction 当调度闭环的一部分来布置
6.1 阈值设置的实践建议
经过多次踩坑,我在新集群上的 kubelet 配置基本稳定在这套参数:
memory.available硬阈值:500Mi~1Gi,软阈值可高些到 1.5Gi,宽限期 90 秒;nodefs.available硬阈值:10%,软阈值 15%;nodefs.inodesFree:5%,inode 耗尽比空间耗尽更难处理,提醒一定要监控;pid.available:5%~10%,过低会导致系统无法 fork 新进程,比内存还致命;evictionMinimumReclaim:内存至少回收 200Mi~500Mi,磁盘至少 5%。
这个组合的核心思想是:给 kubelet 留出"从预警到行动"的时间,同时避免它反复横跳。阈值不是越小越好,也不是越大越好,它要和节点的实际规格、业务特性匹配。比如 64G 内存的节点,你设 100Mi 的硬阈值,基本等于没有;设 2Gi,又可能过度敏感。
6.2 从资源模型源头减少驱逐概率
与其事后调 Eviction 参数,不如从源头把资源模型做健康。我的强制要求有三条:
- 所有 Pod 必须有 requests,核心业务 requests 要贴近实际用量的 70%~80%,不能拍脑袋;
- 所有核心业务配 Guaranteed QoS,也就是 requests == limits;
- 非核心批处理任务单独用 PriorityClass 降低优先级,并允许被抢占。
做到这三点,调度器的"加法"和 kubelet 的"减法"就基本对齐了。调度器不会再因为 requests 声明过低而把太多 Pod 塞进同一节点,kubelet 驱逐时的候选名单也不会把真正的核心业务排在最前面。
6.3 监控预警要跑在 Eviction 前面
Eviction 是最后一道防线,不能让它成为你的第一道告警。我现在的监控策略是:
- 节点内存可用量低于软阈值的两倍时,就触发 Warning 告警;
- kubelet 出现任何 eviction 事件,立即告警并保留现场日志;
- 定期用
kubectl describe node检查每个节点的资源分配率(Allocated vs Allocatable),超过 80% 就评估扩容或疏散; - 对 BestEffort Pod 做专项扫描,发现一个整改一个。
把告警水位线提前,你会发现真正触发 Eviction 的次数会变得极少。它应该是一个"理论上永远不该触发"的保险机制,而不是日常运维里频繁登场的角色。
6.4 主动验证回收策略的小技巧
最后分享一个我常用的小操作:在测试环境里专门挑一台节点,用一个临时 Pod 持续灌内存,模拟内存压力逐渐逼近阈值的过程。脚本里可以按一定速率递增分配内存,同时观察监控面板上MemoryPressure什么时候出现、kubelet 从哪个 Pod 开始驱逐、回收之后水位如何回落。
这个验证跑上几轮,你对 eviction manager 的行为模式会有非常直观的体感,比读十篇源码分析都有用。我第一次把evictionMinimumReclaim加上去,就是因为在测试里看到驱逐风暴的实际触发过程,才确定这个参数对生产环境不可或缺。
K8S 的资源治理,说到底是让"声明"尽量接近"现实",让"调度"和"回收"互相咬合。Eviction 不是敌人,它是这个闭环里最后一个肯说真话的角色——它把你资源模型里的所有水分都挤出来给你看。与其想着怎么绕过它,不如好好利用它帮我们暴露问题、逼我们改进声明规范。我现在的习惯是每次看到驱逐事件,第一反应不是"又要扩容了",而是"哪个 Pod 的资源声明又在撒谎"。