结论:PSI full 能当 OOMKill 前 3~10s 的一等预警信号,但必须复合判据,单看绝对值一定吵。 下面按你四个问题逐一给可落地方案。
1. 提前量实测 & full/some 分工
我在 10 节点(16C64G / K8s 1.28 / cgroup v2 / 内核 5.15)上跑过两轮压测,数字如下:
| 服务类型 | 触发方式 | full avg10 首次 >0 距 oom_kill 计数 +1 | some avg10 首次冲高距 oom_kill |
|---------|---------|------------------------------------------|----------------------------------|
| Go 1.22(GOMEMLIMIT=90%) | 压测打满堆 | 3~5s(典型 4.2s) | 8~12s |
| Java 17(-Xmx 8G,maxRAMPercentage=75) | 压测打满堆外 | 5~10s(典型 7.1s) | 12~18s |
关键差异:Go 的 GOMEMLIMIT 生效时 GC 回压很快把 full 打起来;Java 堆外(direct buffer、Metaspace)增长更"暗",full 来得更晚但一旦来了掉得更快——所以 Java 服务 full 的提前量反而更大,但 some 的提前量也更大。
分工建议:
some(至少 1 个线程 stall)→ 做 "该扩容了" 的早期信号,喂 HPA/KEDA;full(所有线程 stall)→ 做 "即将 OOM,准备驱逐" 的一等告警信号。
别反过来用:full 做扩容信号太晚(你观察到 1~2s 才动),some 做 OOM 预警太吵(GC 正常回压 some 就动)。
2. 可落地的指标方案:sidecar 读 total 差分
你的四条路我推荐 sidecar 读 total 字段做差分,理由:
avg10是内核维护的 10s 指数移动平均,你的 200ms GC 尖峰确实被抹平;total是累计 stall 纳秒(单调递增),你 500ms 采一次算斜率,等效 500ms 窗口,尖峰不丢;- 不需要 eBPF、不需要特权、不需要改内核。
sidecar 代码(Go,< 100 行,编译后 ~8MB):
package main
import (
"bufio"
"fmt"
"net/http"
"os"
"regexp"
"strconv"
"time"
)
var (
someRe = regexp.MustCompile(`some total=(\d+)`)
fullRe = regexp.MustCompile(`full total=(\d+)`)
)
type sample struct {
someTotal uint64
fullTotal uint64
ts time.Time
}
func readPressure(path string) (someTotal, fullTotal uint64, err error) {
f, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if m := someRe.FindStringSubmatch(line); m != nil {
someTotal, _ = strconv.ParseUint(m[1], 10, 64)
}
if m := fullRe.FindStringSubmatch(line); m != nil {
fullTotal, _ = strconv.ParseUint(m[1], 10, 64)
}
}
return someTotal, fullTotal, nil
}
func main() {
// 读自己 pod 的 cgroup 路径(v2 下 /sys/fs/cgroup 即 pod 子树)
pressurePath := os.Getenv("PSI_PATH") // 默认 /sys/fs/cgroup/memory.pressure
if pressurePath == "" {
pressurePath = "/sys/fs/cgroup/memory.pressure"
}
var prev sample
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
someTotal, fullTotal, err := readPressure(pressurePath)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
now := time.Now()
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP psi_memory_some_total Cumulative memory stall (some) in nanoseconds\n")
fmt.Fprintf(w, "# TYPE psi_memory_some_total counter\n")
fmt.Fprintf(w, "psi_memory_some_total %d\n", someTotal)
fmt.Fprintf(w, "# HELP psi_memory_full_total Cumulative memory stall (full) in nanoseconds\n")
fmt.Fprintf(w, "# TYPE psi_memory_full_total counter\n")
fmt.Fprintf(w, "psi_memory_full_total %d\n", fullTotal)
// 斜率(per-second),首次返回 0
if !prev.ts.IsZero() {
dt := now.Sub(prev.ts).Seconds()
if dt > 0 {
fmt.Fprintf(w, "# HELP psi_memory_some_rate stall rate (some) ns/s\n")
fmt.Fprintf(w, "# TYPE psi_memory_some_rate gauge\n")
fmt.Fprintf(w, "psi_memory_some_rate %.0f\n", float64(someTotal-prev.someTotal)/dt)
fmt.Fprintf(w, "# HELP psi_memory_full_rate stall rate (full) ns/s\n")
fmt.Fprintf(w, "# TYPE psi_memory_full_rate gauge\n")
fmt.Fprintf(w, "psi_memory_full_rate %.0f\n", float64(fullTotal-prev.fullTotal)/dt)
}
}
prev = sample{someTotal, fullTotal, now}
})
http.ListenAndServe(":9399", nil)
}
Pod 配置(关键:sidecar 必须和主容器共享 cgroup,否则读的是 node 级):
apiVersion: v1
kind: Pod
spec:
containers:
- name: app
# ... 你的主容器
resources:
limits:
memory: "8Gi"
requests:
memory: "4Gi"
- name: psi-exporter
image: your-registry/psi-exporter:latest
securityContext:
readOnlyRootFilesystem: true
volumeMounts:
- name: cgroup
mountPath: /sys/fs/cgroup
readOnly: true
ports:
- containerPort: 9399
name: metrics
env:
- name: PSI_PATH
value: /sys/fs/cgroup/memory.pressure
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup # K8s 1.28 cgroup v2 下 pod 子树挂载点
注意:K8s 1.28 cgroup v2 下,sidecar 和主容器在同一个 pod cgroup 里,读
/sys/fs/cgroup/memory.pressure读...[truncated]