结论先行:在 K8s 里给 Pod 设
memorySwap,最常见的用法memorySwap = 2 × memory是一个隐蔽的陷阱——你以为是在给容器多 1Gi 的"缓冲",实际上是把它的 OOM 触发点推到了 2 倍水位,同时让 kubelet 驱逐判定更早、Prometheus 默认监控完全看不到 swap 用量。下面是我在一次 Redis 服务"开了 swap 反而更糟"的排查里挖出来的完整链路,以及一份能直接抄的观测与配置方案。
一、现象:开了 swap 的 Redis,P99 从 2ms 飙到 45ms,每天 3 次重启
环境先交代清楚,方便对号入座:
- 集群:Kubernetes 1.28.9,containerd 1.7.13,32 台 worker(8C32G)
- 内核:ALinux 3,5.10.134-16,cgroup v2(
stat -fc %T /sys/fs/cgroup/返回cgroup2fs),内核启动参数带swapaccount=1 - 业务:Redis 7.0.11,单 Pod
memory=2Gi / memorySwap=4Gi,QPS 稳态约 1.2 万,常态 P50=0.8ms、P99=2ms
上周二凌晨 02:40 开始,这条 Redis 出现两类异常:
- P99 从 2ms 间歇性飙到 45ms,每次持续 30s~3min,一天 4~6 次,
redis-cli info里keyspace_hits正常,不是数据问题; - 每天 3 次左右被 kubelet 以
Liveness probe failed重启,重启间隔 4~6h。
我们的第一反应是"内存不够"——因为 container_memory_working_set_bytes 偶尔会摸到 1.9Gi,逼近 2Gi 的 limit。当时的处置是:把 memorySwap 从 0 提到 4Gi(memory=2Gi 的 2 倍),思路是"让 anon 页先掉进 swap,别直接 OOM"。
结果更糟:P99 尖峰从每天 4 次涨到 11 次,liveness 重启从 3 次涨到 5 次。这就是今天要拆的根因。
二、根因:cgroup v2 里 memory.current 把 swap 也算进去,memory.max 是硬切
三个事实串起来才是完整的坑:
事实 1:cgroup v2 的 memory.current = anon + file + swap。
在 cgroup v1 时代,memory.usage_in_bytes 和 memory.memsw.usage_in_bytes 是两个独立的文件,swap 有独立的账本。v2 把它们合并成 memory.current,swap 用量直接计入同一个计数器。也就是说,一个 memory=2Gi 的 cgroup,它的 memory.current 可以合法涨到 6Gi(2Gi anon + 4Gi swap)才触发 memory.max——你以为的"limit 2Gi",内核看到的是"总预算 6Gi"。
事实 2:一旦 memory.current 超过 memory.max(2Gi),内核进入持续 reclaim 状态。
注意这里没有"软压力区间"——v2 的 memory.max 是硬切,超线即走 reclaim,reclaim 追不上分配速度就走 oom_kill_process。在这段 reclaim 窗口里,容器内所有线程都可能被强制脱机(stall),这正是 PSI full 飙升的窗口。我压测时实测:memory.current 从 2Gi 涨到 2.4Gi 的过程中,memory.pressure 的 full avg10 稳定在 35~58,Redis 的 P99 从 2ms 拉到 38ms——每一毫秒的延迟都对应内核在后台疯狂回收页面。
事实 3:K8s 1.25+ 的驱逐判定口径是 memory + swap。
kubelet 的 --eviction-hard 默认 memory.available<100Mi 之类,但 1.25 起引入了 nodeSwap 语义,驱逐判定用的 working set 口径把 swap 算进去。结果就是:swap 用得越多,kubelet 越倾向于"这台节点内存不够了",越早驱逐这个 Pod。你以为 swap 是缓冲,其实是给驱逐判定"喂料"。
三个事实合成一句话:开 swap 不是"多给一点内存",而是"把 OOM 触发点推到 2 倍水位 + 把驱逐触发点提前 + 让内核在 reclaim 窗口里持续 stall 线程",三者叠加,比不开 swap 直接 OOM 更难看——因为 OOM 是"一次性死",reclaim 是"慢慢卡死",后者还会被 liveness 探针放大成"反复重启"。
三、具体做法:把 swap 用量从黑盒里挖出来
3.1 先确认内核是否真的在算 swap
# 1) 确认 cgroup v2
$ stat -fc %T /sys/fs/cgroup/
cgroup2fs
# 2) 确认内核启用了 swapaccount(cgroup v2 算 swap 的前提)
$ cat /proc/cmdline
BOOT_IMAGE=(hd0,1)/boot/vmlinuz-5.10.134-16.al8.x86_64 ... swapaccount=1 quiet
# 3) 找到目标 Pod 的 cgroup 路径
$ cat /proc/self/cgroup
0::/system.slice/containerd.service/kubepods.slice/kubepods-burstable.slice/\
kubepods-burstable-pod1234_....slice/cri-containerd-abcdef.scope
# 4) 直接读 swap 账本(v2 没有独立的 memsw 文件,看 memory.stat 的 swap 字段)
$ cat /sys/fs/cgroup/.../cri-containerd-abcdef.scope/memory.stat | grep -E '^(swap|file|anon) '
file 104857600
anon 1932735283
swap 419430400 # 400Mi swap 在用
$ cat /sys/fs/cgroup/.../cri-containerd-abcdef.scope/memory.current
2456190976 # ≈ 2.3Gi,已超 memory.max=2Gi,进入 reclaim
memory.current - (anon + file) ≈ swap,这个差值就是"隐形内存"。我们线上抓到的数据是:memory.current=2.3Gi、anon=1.8Gi、file=100Mi、swap=400Mi——working set 口径的监控里完全看不到这 400Mi,但内核已经在为它做 reclaim 了。
3.2 写一个 30 行的 exporter,把 swap 接进 Prometheus
cadvisor 的 container_memory_working_set_bytes 默认不含 swap(working set 的定义是"不可回收的部分"),所以默认监控栈里 swap 用量是黑盒。我写了个最小 exporter(Go,30 行,贴核心逻辑):
// swap_exporter.go —— 读 cgroup v2 的 memory.stat,暴露 swap 用量
// 采集逻辑:遍历 /sys/fs/cgroup/.../cri-containerd-*.scope/memory.stat
func readSwap(scopePath string) (anon, file, swap, current uint64, err error) {
stat, err := os.ReadFile(filepath.Join(scopePath, "memory.stat"))
if err != nil {
return
}
for _, line := range strings.Split(string(stat), "\n") {
f := strings.Fields(line)
if len(f) != 2 {
continue
}
v, _ := strconv.ParseUint(f[1], 10, 64)
switch f[0] {
case "anon":
anon = v
case "file":
file = v
case "swap":
swap = v
}
}
cur, err := os.ReadFile(filepath.Join(scopePath, "memory.current"))
if err != nil {
return
}
current, _ = strconv.ParseUint(strings.TrimSpace(string(cur)), 10, 64)
return
}
暴露三个指标:container_memory_swap_bytes、container_memory_anon_bytes、container_memory_file_bytes,label 对齐 cadvisor 的 pod/namespace/container。
3.3 告警规则(Prometheus 2.x,可直接抄)
- alert: ContainerSwapUsageHigh
expr: |
container_memory_swap_bytes
> 0.25 * container_spec_memory_limit_bytes
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }} swap 用量超过 limit 的 25%,reclaim 风险"
- alert: ContainerSwapGrowth
expr: |
rate(container_memory_swap_bytes[2m]) > 0
and
(container_memory_swap_bytes > 0.5 * container_spec_memory_limit_bytes)
for: 3m
labels:
severity: critical
annotations:
summary: "{{ $labels.pod }} swap 正在增长且已超 limit 50%,建议扩 limit 或降载"
为什么阈值定在 25% 和 50%:我压测(Redis MEMORY SET k (1<<20) 循环灌 2Gi 数据 + 1Gi 读放大)观察到:
| swap 用量(相对 limit) | P99 延迟 | PSI full avg10 | |------------------------|---------|---------------| | 0% | 2ms | 0.00 | | 10% (200Mi) | 4ms | 3.2 | | 25% (500Mi) | 18ms | 14.6 | | 50% (1Gi) | 42ms | 38.9 | | 75% (1.5Gi) | 110ms | 61.3 |
25% 是"还能接受"和"开始明显卡"的分界线,50% 是"必须干预"的红线。
3.4 如果业务确实需要 swap 缓冲,正确姿势
不是所有服务都能关 swap(比如 JVM 冷启动、Python 大对象 GC)。如果必须开,我的落地配置是:
resources:
limits:
memory: "4Gi" # 先抬 limit,而不是靠 swap 兜底
memorySwap: "5Gi" # limit 的 1.25 倍,不是 2 倍
三条纪律:
memorySwap是绝对值,不是增量——memorySwap = memory × 1.25才是"多 25% 缓冲";写成memorySwap = 2 × memory实际是"多 100% 缓冲",reclaim 窗口翻倍。- swap 只给"冷启动期"用,不给"稳态"用——稳态 swap 用量 > 0 就说明 limit 设小了,该抬 limit 或降载,而不是开 swap 兜底。
- liveness 探针的
timeoutSeconds要放宽——reclaim 窗口里线程 stall 是常态,timeoutSeconds: 5太紧,建议 15s +failureThreshold: 3,给内核一点 reclaim 时间。
四、踩过的坑与反直觉之处
-
memorySwap的语义是"总预算",不是"额外额度"——文档里写的是"total amount of memory + swap",但 80% 的团队按"额外 swap"理解,把memorySwap设成memory的 2 倍,实际给了 100% 的额外缓冲。这是这次事故的直接诱因。 -
working set 口径的监控在 swap 场景下"说谎"——
container_memory_working_set_bytes的定义是"不可回收的部分",swap 算"可回收",所以 working set 会低于memory.current。你看到 working set 1.5Gi < limit 2Gi 觉得"很健康",实际上memory.current已经 2.3Gi、内核在 reclaim。working set 低不等于安全,要看memory.current - (anon + file)这个 swap 差值。 -
cgroup v1 → v2 迁移时,
memsw字段消失了——v1 有memory.memsw.limit_in_bytes独立控制 swap,v2 把它合并进memory.max。如果你从 v1 迁过来,原来memsw=4Gi的预算在 v2 下变成memory.max=4Gi(含 swap),swap 预算实际上被压缩了,但监控看不到变化,因为 working set 口径没变。 -
zswap 会让 swap 延迟更不可预测——如果内核启用了 zswap(
/sys/kernel/mm/swap/下有 zswap 配置),swap 页先经过压缩层再落盘,命中时延迟接近内存、未命中时延迟接近磁盘。我在 ALinux 3 上没开 zswap(/sys/module/zswap/parameters/enabled= 0),所以这次延迟是"纯磁盘"级别;如果你的环境开了 zswap,swap 延迟的方差会更大,告警阈值建议再收紧一档。 -
liveness 探针在 reclaim 窗口里是"放大器"不是"保护"——reclaim 期间线程 stall,探针超时,kubelet 重启容器。重启后 swap 被清空、
memory.current回到 anon 水位,P99 瞬间恢复,看起来"重启就好了"。但根因(limit 不够)没动,下一次 reclaim 还会发生。重启是症状,不是解药。
五、结论与可复用清单
结论:K8s 里 memorySwap 是"总预算"不是"额外额度",cgroup v2 把 swap 计入 memory.current 让 OOM 触发点被推高、kubelet 驱逐判定更早、监控栈默认看不到 swap 用量——三者叠加,开 swap 比不开更难看。正确姿势是:抬 limit 而不是开 swap;如果必须开,memorySwap ≤ memory × 1.25,并把 swap 用量接进监控。
可复用清单:
- [ ] 确认 cgroup v2(
stat -fc %T /sys/fs/cgroup/=cgroup2fs)且内核带swapaccount=1 - [ ] 读
memory.stat的swap字段,确认当前 swap 用量 - [ ] 对比
memory.current与anon + file + swap,差值就是"隐形内存" - [ ] 部署 swap exporter,暴露
container_memory_swap_bytes指标 - [ ] 告警:
swap > 0.25 × limit(warning)+swap > 0.5 × limit 且增长中(critical) - [ ]
memorySwap语义是"总预算",设值时按memory × 1.25上限 - [ ] liveness 探针
timeoutSeconds放宽到 15s,给 reclaim 窗口留时间 - [ ] 稳态 swap 用量 > 0 就是 limit 设小的信号,该抬 limit 而不是开 swap
- [ ] cgroup v1 → v2 迁移时,核对
memsw预算是否被压缩
环境:K8s 1.28.9 / containerd 1.7.13 / ALinux 3 5.10.134-16 / cgroup v2 / Redis 7.0.11