一、现象:一台 4GB 节点,容器 limit 总和只有 2.8Gi,却一天被驱逐 6 次
先交代环境和数字。我们一批边缘网关节点:RK3588,4 核 Cortex-A76,4GB LPDDR4X,内核 5.10(cgroup v2),K3s v1.28.9+k3s1,containerd 1.7.13。节点上跑 3 个业务容器 + 1 个 filebeat 日志采集 sidecar:
| 容器 | requests | limits | |---|---|---| | 业务 A(Go) | 512Mi | 1Gi | | 业务 B(Node) | 512Mi | 1Gi | | 业务 C(Python) | 256Mi | 512Mi | | filebeat | 256Mi | 256Mi |
limit 总和 2.77Gi,看着离 4GB 很远,很安全。上线三周后监控显示:
node_memory_MemAvailable稳定在 700MB~1.1GB,从没跌破 500MB;- 但 kubelet 日志一周内出现 41 次
Evicting node due to insufficient memory (memory.available),集中在业务 B 的 Node 进程跑批的时段; - 每次驱逐后 Pod 重建 20~40 秒,业务侧表现为 1~2 分钟的请求失败。
最反直觉的一点:被驱逐的 Pod 里没有任何一个容器的 working set 超过自己的 limit。用 kubectl describe pod 看事件,全是节点级驱逐,不是容器级 OOM。
二、根因:三本账没对齐
这个故障的本质是「三本账」口径不一致:
- kubelet 的驱逐账:默认
memory.available < 100Mi就驱逐。memory.available是从/proc/meminfo的MemAvailable算的,它已经把 page cache 算成"可回收",所以这个数字偏乐观。 - 内核的记账口径:cgroup v2 下每个容器的
memory.current= RSS + 该容器的 file page cache(未回收成功的部分)+ swap 占用。filebeat 在 4GB 小盘上读日志,内核会把大量日志页塞进它的 file cache——这部分页在节点级MemAvailable里算"可回收",但在容器级memory.current里是实打实的占用。 - 驱逐候选排序账:kubelet 驱逐时按 QoS(BestEffort → Burstable → Guaranteed)+ 使用量超出 request 的比例排序,不是按"谁最重"。我们 4 个容器 requests < limits,全是 Burstable,排序里 filebeat(requests 256Mi / 实际用到 480Mi)反而常排在前面。
结果就是:filebeat 的 file cache 把节点级 MemAvailable 压到临界,kubelet 触发驱逐,候选排序又把 filebeat 或业务 B 顶到队首——真正"吃内存"的 page cache 被驱逐当替罪羊,而它其实只是缓存。
三、具体做法(可复现)
三层对齐,按顺序做:
1. 显式给节点留系统预算,而不是靠"感觉够"。
4GB 节点,给 kubelet + 系统 + 镜像/日志留 800Mi,剩余 3.2Gi 给容器。K3s 里通过 --kubelet-arg:
--kubelet-arg=eviction-hard=memory.available<500Mi
--kubelet-arg=memory-reservation=512Mi
把驱逐阈值从默认 100Mi 抬到 500Mi——小内存节点上 100Mi 的余量根本不够内核回收一轮 cache 的耗时,等触发驱逐时系统已经接近 OOM。
2. 用 memory.high 给容器加软限,让内核先回收 cache 再考虑 OOM。
K8s 的 limits.memory 只映射到 memory.max(硬限,超了直接 OOM)。但 cgroup v2 多一个 memory.high:memory.current 摸到它时内核主动发起回收(回收 file cache 优先),不会 kill。做法是节点上跑一个 DaemonSet,用 privileged 容器把每个业务容器的 memory.high 设成 limit - 128Mi:
# 以业务 A(limit=1Gi)为例
HIGH=$(( (1024 - 128) * 1024 * 1024 ))
echo $HIGH > /sys/fs/cgroup/kubepods.slice/.../memory.high
效果:filebeat 的 file cache 在接近 limit 前就被内核回收掉了,节点级 MemAvailable 不会被打穿。这一步在我们的节点上,驱逐事件从一周 41 次降到 0 次,filebeat 的日志采集吞吐只掉了约 8%(cache 命中变低,可接受)。
3. 驱逐候选排序:给关键容器设 requests = limits,拿到 Guaranteed QoS。 业务 A 是核心链路,把 requests 从 512Mi 抬到 1Gi(等于 limit),QoS 变 Guaranteed,驱逐排序里排到最后。这一步不是"加资源",是买一个被最后驱逐的优先级——4GB 节点上这是性价比最高的一档。
四、踩过的坑 / 反直觉之处
MemAvailable高不等于安全。 它把 page cache 算成可回收,但"可回收"有回收耗时和回收顺序,小内存节点上内核来不及回收,驱逐就触发了。- 驱逐排序不是按内存占用,是按 QoS + 超出 request 比例。 最"重"的容器不一定先死;我们死得最多的是超出 request 比例最高的 filebeat。
memory.high在 K8s 里没有声明字段。 只能靠 DaemonSet + privileged 写 cgroup,升级/重建 Pod 后要重新写——这是目前最脏的一步,但收益最大。limits总和 < 节点内存 ≠ 安全。 还要看 file cache、镜像层、kubelet 自身、容器日志(json-file 默认无上限,4GB 节点上建议max-size: 20m, max-file: 3)。
五、结论与可复用清单
小内存节点(≤8GB)跑 K8s,内存预算不是"limit 总和 < 节点内存"就完事,要做三层对齐:
- 节点层:
eviction-hard=memory.available<500Mi(不是默认 100Mi),给内核留回收时间; - 容器层:用 DaemonSet 给每个容器写
memory.high = limit - 128Mi,让内核先回收 cache 再考虑 OOM; - 排序层:关键容器设 requests = limits 拿 Guaranteed QoS,买"最后被驱逐"的优先级;
- 日志层:containerd 日志
max-size: 20m, max-file: 3,别让 json-file 把节点内存/磁盘吃穿。
这套做法在我们 120 台 4GB 边缘节点上跑了 6 周,驱逐事件从日均 5~6 次降到 0,核心链路 P99 延迟从 210ms 回落到 38ms。核心思路一句话:别让内核在"来不及回收 cache"的时刻做驱逐决定,用 memory.high 把回收提前,用 QoS 把排序控制在自己手里。