结论先行:在 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=5Gi意味着"limit + swap 总预算 5Gi",不是"多给 5Gi"。这个语义 K8s 文档写得很清楚,但 90% 的 SRE 第一次配都会当增量理解。- 必须配 swap 观测(3.2 的 exporter),否则开了等于裸奔。
- 必须同步抬 liveness 的
failureThreshold到 ≥6——见下节。
3.5 补一节:swap × liveness 的联动放大(2026-10-21 修订)
这一节是后来补的:本文初版只讲了「swap 让 reclaim 更久、让驱逐更早」,但漏了一个更隐蔽的放大器——liveness 探针本身会依赖 DB / 下游 IO,reclaim 窗口内探针超时,kubelet 直接杀 Pod,形成第二个正反馈回路。我们线上这次 Redis 的"每天 3 次重启",真正拆开看是:
- reclaim 窗口 30s~3min,期间 Redis 单线程被 stall,
PING从 0.1ms 涨到 200ms+; - 我们的 liveness 探针是
exec redis-cli ping,timeout=3s / period=10s / failureThreshold=3; - reclaim 一超过 30s,3 次探针全超时 → kubelet 杀 Pod → Pod 重建 → 冷启动 20s 里上游重试打进来 → 内存再次冲高 → 再次 reclaim → 再杀一次。
断法三件套(和站内 1988 那条"等连接超时正反馈回路"是同一类问题在 K8s 侧的镜像):
- liveness 不依赖下游 IO:liveness 只探"进程活着 + 端口通"(
tcpSocket或httpGet /healthz返回 200),DB / Redis / MQ 依赖挪到 readiness。这一条最重要,K8s 社区反复被踩; - readiness 的下游依赖用独立的资源池:比如 Redis 探针走一个只读副本连接、DB 探针走 1 连接的隔离池,和业务池物理隔离,避免"业务池满 → 探针等连接 → probe 超时"的级联;
failureThreshold至少 6,periodSeconds配合业务 P99:failureThreshold × periodSeconds ≥ 5 × P99(reclaim 窗口)——我们线上把 Redis 的failureThreshold从 3 调到 8 后,"reclaim 期间被误杀"的事件直接归零。
一个容易漏的数据点:PSI full avg10 在 reclaim 窗口里稳定在 30~60 是常态(本文 3.3 的压测表已给),这个指标本身就是一个天然的"reclaim 预警"——full avg10 > 10 for 30s 就该告警,比等 P99 飙起来早 30s。我补一条可直接抄的告警:
- alert: ContainerMemoryPSIHigh
expr: |
container_memory_pressure_full_avg10 > 10
for: 30s
labels:
severity: warning
annotations:
summary: "{{ $labels.pod }} PSI full avg10 > 10,reclaim 已在进行,P99 即将劣化"
这条比 container_memory_swap_bytes > 25% 更早触发——PSI 是"内核已经在回收"的直接证据,swap 用量只是"回收的原料",两者有 10~30s 的相位差。
修订记录:v2(2026-10-21)新增 3.5 节「swap × liveness 联动放大」,补 failureThreshold 调参判据与 PSI 告警规则;呼应站内 1988 正反馈回路讨论。