结论先行:K8s 给容器设了「上限」(
memory.max),但没给它设「下限」(memory.min)。cgroup v2 的memory.min是内核层面唯一能阻止全局内存压力回收某个 cgroup file cache 的机制,而 K8s 至今没有把它暴露为任何资源声明字段。结果是:一个 QoS=Guaranteed、memory.current只有 limit 47% 的容器,它的 page cache 照样会被内核回收,P99 延迟翻 40 倍,而kubectl top和所有 K8s 指标都显示"一切正常"。
一、现象:内存远低于 limit,P99 却翻 40 倍
环境:4GB / 4C 的 ARM64 边缘节点(RK3588,Linux 6.1.0-cgroup v2,K3s v1.29.3),两个 Pod:
- Pod A:Go 1.21 推理服务,
requests=limits=1.5Gi(QoS Guaranteed),启动时读入 1.2GB 模型文件进 page cache,稳态memory.current ≈ 720MB(anon 450MB + file 270MB),cache 命中率 98%,P99 延迟 3ms; - Pod B:Python 数据处理任务,
requests=limits=1Gi,运行时需要 780MB 匿名内存(NumPy 数组)。
节点总内存:4GB,减去内核 + K3s 自身开销后,用户态可用约 3.4GB。Pod A + Pod B 的 limit 合计 2.5Gi,调度器认为"没问题"。
触发条件:Pod B 开始执行一次 780MB 的 np.zeros((100_000_000, 8), dtype='float64') 分配(780MB anon),节点剩余 free 从 480MB 掉到 190MB,跌破 kswapd 的 min_free_kbytes 阈值,kswapd 被唤醒,开始全节点回收。
结果:
- kswapd 走 cgroup 树找可回收页:Pod A 的 file cache(270MB file + 此前缓存的 880MB 模型文件页)是最容易回收的;Pod B 的 anon 内存不可回收(无 swap)。
- 内核从 Pod A 的 cgroup 里回收了约 720MB file 页 → Pod A 的 cache 命中率从 98% 掉到 41%,P99 延迟从 3ms 飙到 120ms,持续 8 分钟(Pod B 分配完成后,Pod A 重新读文件,命中率缓慢恢复)。
- 全程:Pod A 没有 OOM,
memory.events里oom_kill=0、max=0;kubectl top pod显示 Pod A 内存 720Mi,远低于 1.5Gi limit;所有 K8s 告警规则静默。
关键矛盾:Pod A 的 memory.current / memory.max = 720MB / 1536MB = 47%,K8s 视角"一切正常",但应用侧 P99 已经翻了 40 倍。这不是 Pod A 自己超 limit,是节点全局内存压力触发了跨 cgroup 的 page cache 回收。
二、原理:K8s 只给了上限,没给下限
2.1 cgroup v2 的内存模型有"地板",K8s 没暴露
cgroup v2 为每个 memory cgroup 提供了四个关键文件:
| 文件 | 语义 | K8s 是否暴露 |
|---|---|---|
| memory.max | 硬上限,超了 OOM | ✅ 对应 limits.memory |
| memory.high | 软上限,触了触发主动回收(非 OOM) | ❌ K8s 不写此文件 |
| memory.min | 硬下限:内核全局回收时,此 cgroup 的 file 页在 memory.min 以内不被回收 | ❌ K8s 完全不暴露 |
| memory.low | 软下限:内核优先回收别的 cgroup,但紧急时仍会碰 | ❌ K8s 完全不暴露 |
K8s 的 requests.memory 只影响调度阶段的 bin-packing(调度器用它算节点可用内存),对运行时内核回收行为没有任何约束力。QoS=Guaranteed 在 K8s 语义里是"驱逐顺序最后",但内核的 kswapd 不认识 QoS——它只认 cgroup 的 memory.min / memory.low。
2.2 内核回收时的"可回收量"计算
kswapd 在 cgroup v2 下回收时,对每个 cgroup 计算:
reclaimable = max(0, cgroup.file_pages - cgroup.memory.min)
memory.min = 0(K8s 的默认状态)→ 所有 file 页都是可回收的;memory.min = X→ 至少 X 字节的 file 页受保护,kswapd 跳过,去碰别的 cgroup。
这就是为什么没有 memory.min 的 Guaranteed Pod,它的 page cache 和其他 Burstable Pod 一样脆弱。K8s 的 QoS 分级是"驱逐优先级",不是"内核回收优先级"——这是两个完全不同的机制,前者是 kubelet 的决策,后者是内核的行为。
三、具体做法:用 nsenter + DaemonSet 补上 memory.min
3.1 一次性验证(确认问题)
# 找到 Pod A 的 cgroup 路径(K3s 用 containerd)
CGPATH=$(cat /sys/fs/cgroup/kubepods.slice/kubepods-pod\*.slice/$(kubectl get pod -o name | sed 's/pod\///')-\*/*/*.cgroup 2>/dev/null | head -1)
# 更简单:直接看
grep -r "memory.current" /sys/fs/cgroup/kubepods.slice/ 2>/dev/null | grep "720"
# 确认当前 memory.min 是 0(K8s 默认)
cat /sys/fs/cgroup/kubepods.slice/kubepods-pod\*/\*/\*/memory.min
# 输出:0 ← 这就是"没有地板"的直接证据
3.2 设置 memory.min(nsenter 进 host namespace)
# 1. 拿到 Pod A 的 cgroup 完整路径
POD_UID=$(kubectl get pod inference-svc -o jsonpath='{.metadata.uid}')
CG="/sys/fs/cgroup/kubepods.slice/kubepods-pod\|${POD_UID//\//-}.slice"
# 2. 设置 memory.min = 1.4Gi(模型文件 1.2GB + 200MB 余量)
# 注意:必须 nsenter 进 host cgroup namespace,否则路径不对
nsenter -t 1 -m -p -- sh -c "echo 1503238553 > ${CG}/memory.min"
# 3. 验证
nsenter -t 1 -m -p -- cat ${CG}/memory.min
# 输出:1503238553 ← 1.4Gi,内核现在会保护这 1.4Gi 的 file 页
设置后,kswapd 再遇到节点内存压力时,Pod A 的 file 页在 1.4Gi 以内不被回收,去碰其他 cgroup 或触发全局 OOM(后者在 4GB 节点上需要 Pod B 的 anon 分配 + 其他 cgroup 的 file 页都回收完才会发生,比 Pod A 的 cache 被清空要好得多——至少 Pod A 的推理延迟不会崩)。
3.3 持久化:DaemonSet 监控 + 自动重设
memory.min 在 Pod 重建、节点重启后会丢失(cgroup 重新创建,memory.min 重置为 0)。需要一个 DaemonSet 守护:
# memory-min-agent.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: memory-min-agent
namespace: kube-system
spec:
selector:
matchLabels:
app: memory-min-agent
template:
metadata:
labels:
app: memory-min-agent
spec:
priorityClassName: system-node-critical
hostPID: true
hostNetwork: true
containers:
- name: agent
image: alpine:3.19
command: ["sh", "-c"]
args:
- |
# 每 10s 扫描一次,对带 annotation 的 pod 重设 memory.min
while true; do
for cg in /sys/fs/cgroup/kubepods.slice/kubepods-pod*; do
# 从 annotation 读目标值(简化:这里用固定映射)
uid=$(echo $cg | sed 's/.*kubepods-pod|//;s/\.slice//')
# 查 pod annotation
min_bytes=$(kubectl get pod -A -o json 2>/dev/null \
| jq -r --arg uid "$uid" \
'.items[] | select(.metadata.uid==$uid) | .metadata.annotations."memory-min.agent/sz" // empty')
if [ -n "$min_bytes" ] && [ -f "$cg/memory.min" ]; then
current=$(cat $cg/memory.min)
if [ "$current" != "$min_bytes" ]; then
echo $min_bytes > $cg/memory.min
echo "[$(date)] set memory.min=$min_bytes for $cg"
fi
fi
done
sleep 10
done
securityContext:
privileged: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 50m
memory: 64Mi
Pod 侧只需加一个 annotation:
metadata:
annotations:
memory-min.agent/sz: "1503238553" # 1.4Gi,单位字节
3.4 监控:把"隐性回收"变成可观测事件
PSI(Pressure Stall Information)——cgroup v2 内置,无需额外工具:
# 每 5s 采样一次 Pod A 的 memory PSI
CG=/sys/fs/cgroup/kubepods.slice/kubepods-pod\|<uid>.slice
while true; do
echo "$(date +%H:%M:%S) $(cat $CG/memory.pressure)"
sleep 5
done
# 输出示例:
# 14:32:05 some avg10=2.34 avg60=0.12 avg300=0.01 total=1842231
# 14:32:10 some avg10=8.71 avg60=0.45 avg300=0.02 total=1922104
# ↑ avg10 从 2.34% 跳到 8.71% = 内核正在回收此 cgroup 的内存
Prometheus 规则(node_exporter + cAdvisor):
groups:
- name: cgroup-memory-pressure
rules:
- alert: PodMemoryReclaimPressure
expr: |
# PSI some avg10 > 5% 持续 60s,说明此 pod 正在被内核回收
rate(node_pressure_memory_some_total{pod=~".+"}[5m]) > 0.05
for: 60s
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.pod }} 正在经历内核内存回收(PSI some avg10 > 5%)"
- alert: PodFileCacheEvicted
expr: |
# file 页在 60s 内掉了 100MB 以上
(
container_memory_working_set_bytes{pod=~".+"}
-
container_memory_cache_bytes{pod=~".+"}
) -
(
container_memory_working_set_bytes{pod=~".+"} offset 60s
-
container_memory_cache_bytes{pod=~".+"} offset 60s
) > 104857600
labels:
severity: warning
annotations:
summary: "Pod {{ $labels.pod }} 的 file cache 在 60s 内被回收了 >100MB"
应用侧指标(最可靠的信号):
// Go 服务:暴露 cache 命中率 + 读延迟
func (s *Server) Metrics() {
hitRate := float64(s.cacheHits) / float64(s.cacheHits+s.cacheMisses)
p99 := s.latencyHist.Percentile(99) // 纳秒
prometheus.GaugeVec(cacheHitRate, []string{"pod"}).WithLabelValues(s.pod).Set(hitRate)
prometheus.GaugeVec(readLatencyP99, []string{"pod"}).WithLabelValues(s.pod).Set(float64(p99) / 1e6) // ms
}
// 告警:cacheHitRate < 0.70 持续 30s = page cache 正在被回收
四、踩过的坑
-
memory.min设太高会反噬:在 4GB 节点上,如果两个 Pod 都设了memory.min=1.4Gi,合计 2.8Gi,加上 anon 内存和内核开销,节点实际可用内存被锁死,Pod B 的 anon 分配会直接触发全局 OOM(不是 Pod A 的 OOM,是节点级 OOM,kubelet 驱逐顺序按 QoS,Guaranteed 最后被杀——但你的推理服务是 Guaranteed,它活下来了,代价是其他 Pod 被杀)。memory.min的总量不要超过节点可用内存的 30%,否则你在制造一个新的单点故障。 -
memory.min对 anon 内存无效:它只保护 file 页(page cache)。如果 Pod A 的 450MB anon 是堆内存(Go heap),memory.min对 anon 的保护是通过"不回收此 cgroup"间接实现的——内核回收 anon 需要 swap,无 swap 环境下 anon 本来就不可回收,memory.min对 anon 没有额外效果。不要指望memory.min保护堆内存,它只保护 page cache。 -
K3s 和 K8s 的 cgroup 路径略有差异:K3s 用
kubepods.slice(和标准 K8s 一样),但 containerd 的 cgroup 驱动有时会把 Pod 放在kubepods.slice/kubepods-pod|<uid>.slice/<containerID>.scope,而不是kubepods-pod|<uid>.slice直接下面。写脚本时不要硬编码路径层级,用find /sys/fs/cgroup -name "memory.min" -path "*kubepods*"动态查找。 -
ARM64 内核版本:cgroup v2 的
memory.min/memory.pressure需要内核 ≥ 5.5(memory.min在 5.5 引入,PSI cgroup 在 5.2 稳定)。RK3588 出厂镜像如果是 4.19(U-Boot 默认),这两个文件根本不存在,先升级内核再谈方案。 -
kubectl top的working_set_bytes包含 file 页:Pod A 的kubectl top显示 720Mi,其中 270Mi 是 file 页——这 270Mi 是"内核认为可回收的",不代表应用真的在用。working_set_bytes = usage - inactive_file,但 active file 页(刚读进来的)仍算在 working_set 里。所以kubectl top的数字会"虚高",不能直接用来判断"应用还剩多少 headroom"。
五、结论与可复用清单
核心结论:K8s 的内存模型只给了容器"天花板"(limits.memory → memory.max),没给"地板"(memory.min)。在节点内存紧张的场景(边缘计算、高密度部署、多租户共享节点),Guaranteed QoS 不能阻止内核回收你的 page cache,requests 也不能。memory.min 是内核层面唯一的"地板"机制,K8s 至今没有暴露它,需要你自己在节点侧补。
可复用清单:
- [ ] 对 page cache 敏感的服务(模型加载、大文件读、数据库 buffer pool),在 Pod annotation 里声明
memory-min.agent/sz,DaemonSet 自动重设; - [ ]
memory.min的总量 ≤ 节点可用内存的 30%,否则制造节点级 OOM 风险; - [ ] 监控
memory.pressure(PSI some avg10 > 5%)作为"隐性回收"的告警信号,比 OOM 早 30s 以上; - [ ] 应用侧暴露 cache 命中率和读延迟 P99,这是最可靠的"page cache 正在被回收"信号;
- [ ] ARM64 边缘节点先确认内核 ≥ 5.5,否则
memory.min/ PSI 不可用; - [ ]
kubectl top的working_set_bytes包含 active file 页,不能直接当"应用可用 headroom"用。
一句话:K8s 的内存保障是"调度器视角"的,不是"内核视角"的。requests 和 QoS 管的是"谁先被驱逐",管不了"谁的 page cache 先被回收"。要管后者,得直接跟内核谈——memory.min 就是那个接口。