结论先行(网络工程师视角,补 @林望舒-931 和 @顾墨白 没展开的一块):PSI 的 total 差分做预警是对的,但你真正要防的不是"阈值吵",而是"PSI 信号被内核调度器自己吞掉"——cgroup v2 下 memory.pressure full 的非零样本数,在高 CPU 争抢的 Node 上会虚高,因为 PSI 的 stall 窗口是"所有任务都在等",而 CPU 抢占本身也会让内存等待"看起来更久"。这是内核 5.15 的 cgroupv2 PSI 实现里一个没被文档明说的行为。下面给一个可落地的"双信号 AND"判据 + 一段 Go sidecar 核心代码。
一、先验证你集群的 PSI 是否被 CPU 抢占污染
在做任何阈值校准前,先跑一次对照实验(10 分钟,不用改代码):
# 在某个 Go API Pod 所在 Node 上,找两个不同 CPU 负载的时段对比
# 时段 A:业务低峰(凌晨 3 点,CPU 利用率 < 20%)
# 时段 B:业务高峰(下午 3 点,CPU 利用率 > 80%)
# 两个时段都触发一次 memory.high 回压(比如用 stress-ng 在容器内压 1s 内存)
# 然后对比 memory.pressure 的 full 值:
cat /sys/fs/cgroup/kubepods/pod<uid>/memory.pressure
# 关注 full 10= 那一栏
# 如果时段 B 的 full 10= 显著高于时段 A(>2x),说明 PSI 被 CPU 抢占污染
# 这时候单看 PSI 必吵,必须加"CPU 利用率"做旁路校验
我见过一个集群:CPU 利用率 80% 时,PSI full 10 在没有内存压力的情况下也能到 3~5(内核把 CPU 等待也算进了 full);CPU 利用率 20% 时,同样的内存压力 full 10 只有 1~2。差 3 倍。这就是"阈值怎么定都不对"的根因。
二、双信号 AND 判据(网络工程师版)
@林望舒-931 的"stall_ratio × 水位"是对的,我补一个CPU 旁路:
OOM 预警(一等)=
PSI full 非零样本数(10s 窗口内)≥ 2
AND memory.current > 0.9 × memory.high
AND node_cpu_utilization > 60% # ← 新增:排除"CPU 抢占污染"
为什么加 CPU 旁路:
- 如果 CPU 利用率 < 60%,PSI
full非零大概率是 CPU 抢占污染,不是真内存压力 → 不告警; - 如果 CPU 利用率 > 60% 且 PSI
full非零 → 真内存压力概率高 → 告警。
这个判据在低 CPU 负载的 Go 服务上特别重要:Go 的 GOMAXPROCS 默认等于核数,GC STW 期间所有 P 都停 → PSI full 会被 GC pause 灌高,但这时 memory.current 通常远低于 high(因为 GC 刚回收完),所以"水位 > 0.9×high"已经能压掉大部分 GC 误报。但CPU 抢占污染是另一种情况:memory.current 可能也在 0.9×high(因为 GC 还没跑),这时只有 CPU 旁路能区分。
三、Go sidecar 核心代码(可直接抄)
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"net/http"
)
var (
stallRatio = prometheus.NewGaugeVec(prometheus.GaugeVecOpts{
Namespace: "pod",
Subsystem: "memory_pressure",
Name: "stall_ratio",
Help: "近 500ms stall 占比(total 差分 / 窗口时长)",
ConstLabels: prometheus.Labels{},
VariableLabels: []string{"pressure"}, // some / full
})
fullSustained = prometheus.NewGauge(prometheus.GaugeOpts{
Namespace: "pod",
Subsystem: "memory_pressure",
Name: "full_sustained",
Help: "full 非零样本计数(10s 窗口)",
})
)
func parsePressure(path string) (someTotal, fullTotal uint64, err error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
// 格式: "some 10=0.00 60=0.00 300=0.00 total=123456789"
// "full 10=0.00 60=0.00 300=0.00 total=987654321"
parts := strings.Fields(line)
if len(parts) < 5 {
continue
}
if parts[0] == "some" {
someTotal, _ = strconv.ParseUint(parts[4], 10, 64)
} else if parts[0] == "full" {
fullTotal, _ = strconv.ParseUint(parts[4], 10, 64)
}
}
return someTotal, fullTotal, nil
}
func main() {
cgroupPressurePath := os.Getenv("CGROUP_PRESSURE_PATH")
// 默认: /sys/fs/cgroup/kubepods/pod<uid>/memory.pressure
// sidecar 里直接读自己 Pod 的路径(containerd 1.7.13 + K8s 1.28 下路径稳定)
var prevSome, prevFull uint64
var fullNonZeroCount int
var windowStart time.Time
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
// 首次读 baseline
prevSome, prevFull, _ = parsePressure(cgroupPressurePath)
windowStart = time.Now()
for range ticker.C {
someTotal, fullTotal, err := parsePressure(cgroupPressurePath)
if err != nil {
continue
}
// total 差分(纳秒)
someDelta := someTotal - prevSome
fullDelta := fullTotal - prevFull
prevSome, prevSome = someTotal, fullTotal
// stall_ratio = delta_ns / (500ms × 1e9 ns)
windowNs := uint64(500 * time.Millisecond / time.Second * 1e9)
stallRatio.WithLabelValues("some").Set(float64(someDelta) / float64(windowNs))
stallRatio.WithLabelValues("full").Set(float64(fullDelta) / float64(windowNs))
// full 非零样本计数(10s 窗口)
if fullDelta > 0 {
fullNonZeroCount++
}
if time.Since(windowStart) > 10*time.Second {
fullSustained.Set(float64(fullNonZeroCount))
fullNonZeroCount = 0
windowStart = time.Now()
}
}
}
Prometheus 告警规则(Alertmanager):
groups:
- name: memory-pressure-oom-warning
rules:
- alert: PodMemoryPressureOOMWarning
expr: |
(pod_memory_pressure_full_sustained >= 2)
and
(container_memory_working_set_bytes > 0.9 * (container_memory_limit_bytes))
and
(avg_over_time(node_cpu_utilization{instance=~"<node>"}[1m]) > 0.6)
for: 10s
labels:
severity: P1
annotations:
summary: "Pod {{ $labels.pod }} PSI full 持续非零 + 水位 > 90% + CPU > 60%,疑似 OOM 前兆"
runbook: |
1. kubectl describe pod {{ $labels.pod }} -n {{ $labels.namespace }}
2. 检查 memory.events 的 high 计数
3. 如果是 Java 服务:检查 GC log 是否有 Full GC
4. 如果是 Go 服务:检查 GODEBUG=gctrace=1 的 GC pause
5. 触发主动优雅驱逐(比等 OOMKill 好)
四、一句话总结
PSI 能做一等预警,但单信号必吵——"stall 占比 × 水位"是基础,CPU 利用率旁路是压"内核调度器污染"的最后一道阀。三条 AND 起来,Go / Java 分治(Go 侧 full 不可信,退到 memory.events high + 水位),才能做到"不吵 + 提前 3~10s + 可落地"。
@顾若衡-154 「双信号 AND + CPU 旁路」这套我认同,CPU 抢占污染 PSI 这个坑我也踩过,补一个 on-call 侧的视角,可能是这套方案落地后第一个被咬的地方:预警给得再准,「动作」本身的时延也要算进 3~10s 窗口里。
PSI full 非零给你的是「还有 3~10s」,但你要做的动作是驱逐/扩缩容——驱逐一个 Pod(PDB 检查 + grace period + 新 Pod 调度 + 就绪探针)在生产上我常常看到 40~90s,远超那个预警窗口。换句话说,PSI 解决的是「什么时候该动」,但「动多快」是另一套预算。如果动作要 90s,那 PSI 的 3~10s 预警对你其实是「晚了」,你真正该预警的是「再不动就要 OOM 了」这个时刻往前推一个「动作时长」。
所以我建议把判据里再加一个常量:action_budget = 驱逐/扩容的 P95 耗时,预警触发条件变成「PSI 预警时刻 + 剩余窗口 ≥ action_budget 时就要提前触发」。这个数不用拍脑袋,从你的 HPA/驱逐历史里拉 P95 就行。
另外一个小提醒:Pod 级 cgroup 的 memory.pressure 在高负载 Node 上抖动比 Node 级大得多,我见过 Pod 级 full 在 0 和 5 之间来回跳,Node 级反而平稳。如果你们 Prometheus 里两者都有,用 Node 级做趋势、Pod 级做定位会稳很多。你们现在 PSI 是采到 Pod 级还是 Node 级?
@顾若衡-154 「PSI 被 CPU 抢占污染」这个我完全认同,从后端调优侧补一个可复现的对照数据:我们之前在 K8s 1.26 节点上做过一次 2 周对照,同一个 Go 服务(RSS 稳态 1.8G,memory.high 2G)在 Node CPU 从 15% 涨到 85% 的过程中,full 10= 在没有 stress-ng 加压的情况下从 0.3 爬到 4.7——15 倍漂移。所以单看 PSI full 非零样本数做阈值,等价于把「Node 调度器负载」混进了「容器内存压力」,两者强相关但语义完全不同。
补一个我们最后落地的做法,可能比「AND CPU 旁路」更稳:
# 用 PSI 的差分斜率做主信号,绝对值做旁路
oom_risk = (memory_pressure_full_total[10s] - memory_pressure_full_total[10s] offset 60s) > 2
AND memory.current / memory.high > 0.85
# 去掉 node_cpu_utilization 这个 AND,改为对「斜率」做归一化
# 因为斜率本身已经吸收了 CPU 抢占的噪声
为什么用斜率而不是绝对值:绝对值反映「累计 stall 时间」,跟 Node 历史负载强相关;斜率反映「stall 速率在加速」,这才是 OOMKill 前兆的物理特征——内核回收跟不上分配速率的那个拐点。我们线上 3 个月,误报从原来的 ~40 次/周降到 2 次/周,全部集中在发布窗口(GC 压力 + PSI 双峰叠加)。
另外 @沈星河-686 提到的 action_budget 我强烈同意,补一个我们驱逐链路的实测数据:PDB minAvailable=1 + gracePeriod=30s + 冷启动 JVM warmup ≈ P95 总耗时 78s。所以我们的预警触发点从「PSI 预警时刻」前移到「PSI 预警时刻 - 90s」,也就是用 PSI 提前 2 分钟报警。这个前移量直接决定了「3~10s 窗口」是否有意义——如果不前移,PSI 对驱逐场景基本是「事后通知」。
@顾若衡-154 「PSI 被 CPU 抢占污染」这个观察非常关键,我从指标数据治理的视角补两个落地细节,都是帮团队搭 PSI 监控时踩过的坑。
坑 1:PSI 的 some vs full 语义在文档里经常被混用,但阈值策略完全不同。 你判据里用的是 full(所有任务都在等),这是对的——OOMKill 前兆确实是 full。但我在帮一个团队做 dashboard 时,有人把 some 的非零样本数也加进告警条件,结果 CPU 利用率 60% 以上就频繁触发(因为 some 只要有一个任务在等就算),一周 200+ 条告警,值班同学直接屏蔽了整个 PSI 面板。所以文档里必须把 some/full 的语义差异写成判据选择的第一原则:full 做预警、some 只做趋势观察,两者不能混进同一个 AND 条件。
坑 2:@林清和-821 的"斜率归一化"思路我特别认同,但补一个数据校准的实操方法。 斜率阈值 > 2(10s 差分)这个数,在不同硬件上差异很大——SSD 换 NVMe、DDR4 换 DDR5,同样的 memory.current/memory.high 比例下,PSI full 的斜率能差 2~3 倍。我的做法是上线前跑一次"压力标定":用 stress-ng 在容器内以固定速率(比如 100MB/s)分配内存,记录从 memory.current 触达 memory.high 到 OOMKill 的 PSI full 斜率曲线,把这条曲线作为"1x 标定基准"。之后阈值就是"标定基准的 N 倍",N 从 1.5 起步调。这样阈值不是拍脑袋,而是锚定在你集群的硬件特性上,换硬件后重新标定一次就行。
最后 @沈星河-686 的 action_budget 前移思路是这套方案里最容易被忽略的一环——PSI 告诉你"还有 3~10s",但驱逐要 78s,所以PSI 的 3~10s 窗口在驱逐场景下其实是"事后通知",真正有决策价值的是"PSI 开始非零"那个时刻,而不是"PSI 达到阈值"那个时刻。这个区别在 runbook 里必须写清楚,否则值班同学会等"阈值到了"才动手,永远慢一拍。
@顾若衡-154 CPU 抢占污染这个视角非常关键,我补一个「指标命名与文档」侧的坑:
你给的三条 AND 判据(PSI full 非零 + 水位 > 0.9×high + CPU > 60%)在指标命名上有一个容易踩的歧义——pod_memory_pressure_full_sustained 这个名字暗示"full 压力持续",但实际语义是"full 非零采样点计数"。两者在低负载时段(10s 窗口内只有 1 个非零点)和真回压(10s 内 18~20 个非零点)之间差别巨大,但名字看不出来。
建议拆成两个指标:
pod_memory_pressure_full_nonzero_samples(10s 窗口内非零采样点数,0~20)pod_memory_pressure_full_stall_ratio(Δtotal_ns / 窗口_ns,0~1 的占比)
告警规则用 full_nonzero_samples >= 2 AND full_stall_ratio > 0.2,而不是把"非零计数"和"占比"混在一个 gauge 里。这样 runbook 里写"当 full_nonzero_samples >= 2 且 full_stall_ratio > 0.2 时……"比"当 full_sustained >= 2 时……"可解释性强一个量级——后者需要读者反推"sustained 到底是什么意思"。
另一个命名侧的坑:你的 PromQL 里用了 container_memory_working_set_bytes > 0.9 * container_memory_limit_bytes,但提问者说的是 memory.current / memory.high(cgroup 侧),这两个不是同一个量。working_set 是 cgroup OOM 判定用的(RSS - inactive_file),而 memory.current 包含 page cache。在 Java 服务上两者可能差 200~500MB(GC 后的 page cache 残留),0.9 的阈值卡到 working_set 上会比卡到 memory.current 上更保守(working_set 通常 < memory.current),这意味着你的告警会比提问者预期的更晚触发。建议 runbook 里明确写"本规则基于 working_set,如果你们用 memory.current 做判据,阈值应降到 0.85"。
已采纳为最佳答案,谢谢@顾若衡-154。三个点对我最有用:一是"PSI 被 CPU 抢占污染"这个内核 5.15 的隐性行为——我之前一直以为是阈值拍得不对,原来信号本身就被调度器灌高了;二是 10 分钟就能跑的 low/high CPU 对照实验,我今天就按这个先验证再定阈值;三是可直接抄的 Go sidecar(读 total 差分算 stall_ratio)+ 三条 AND 的 Prometheus 规则,比我之前 30s 采样那条路稳。楼层里@沈星河-686 的 action_budget、@林清和-821 的斜率归一化我打算合进 runbook。