前阵子我们这套某在线服务的告警又响了,kubectl top pod看到内存已经摸到容器 limit 边缘,还没等我把第二张监控图点开,Pod 就直接进入了OOMKilled状态。那已经不是第一次了。这种场景做过容器平台运维的朋友应该都不陌生:内存泄漏的曲线像温水煮青蛙,等触发固定阈值告警的时候往往已经来不及;更折磨人的是“定位”环节——要翻指标、对时间线、查日志、猜代码,全靠人肉拼手速。
这篇文章是我在搭建一套 K8s + AIOps 定位自愈体系时的完整记录,核心解决两件事:一是 CPU / 内存泄漏和 OOM 的自动定位,二是定位结果出来之后的自动自愈。文章会从问题机理讲到监控采集、从异常检测聊到策略引擎,最后给一次完整的模拟事故复盘。适合正在被 Pod 频繁 OOM 困扰、想搭建自动化排障体系的运维和 SRE 同学参考,也可以当作一个容器可观测性 + 自动化运维的落地蓝图。
1. 为什么需要 K8s + AIOps 这套组合
1.1 传统排查方式到底慢在哪里
先说一个很扎心的事实:很多团队排查 OOM 其实还停留在“收到告警 → 登服务器 → 翻监控 → 看日志 → 猜原因 → 重启了事”的阶段。这套流程在 Pod 数量少、发布频率低的时候勉强能转,但集群规模上来之后,问题就全都暴露了。
第一个问题是告警阈值太死板。固定 80% 内存使用率告警,遇到业务高峰本来就该涨到 85%,误报一堆;反过来,有的服务平时只跑 30%,泄漏到 70% 其实已经很危险,但因为没到阈值,愣是没人管。第二个问题是定位效率太低。内存泄漏和 CPU 异常往往要看“趋势”而不是看“瞬时值”,人肉翻 Grafana 曲线非常消耗精力,尤其当业务方告诉你“我感觉没啥问题”的时候,你连反驳的证据都拿不出来。第三个问题是恢复太慢。即使定位到问题 Pod,从写 kubectl 命令到观察恢复,再快也要几分钟,而这几分钟业务可能已经在报错了。
还有一个容易被忽略的痛点:告警风暴。集群里有几十个服务,每个服务十几个 Pod,一旦某个节点或某个依赖出问题,告警会像连锁反应一样爆发。等到值班同学逐条看完,最重要的根因早就淹没在通知列表里了。
1.2 AIOps 在这里解决的不是“智能”,而是效率
很多人一听到 AIOps 就想到机器学习、神经网络,以为要训练什么高深模型。从我实际落地的感受来看,AIOps 在当前阶段最务实的价值不是“预测未来”,而是把人工定位的路径自动化——用机器替人把“看监控、找异常、查事件、关联日志、给结论”这一套重复劳动做了。
具体到我们这个场景,AIOps 层做了四件事:
- 动态基线:不再用固定阈值,而是基于 Pod 自身历史指标计算基线,偏离基线才告警。
- 自动关联:把 Prometheus 指标、K8s Events、容器日志中的关键词放在同一条时间线上进行关联分析。
- 根因提示:输出结构化结论,比如“内存工作集持续增长 2 小时,OOMKilled 事件高频出现,判定为内存泄漏”,而不是甩给你十张曲线图。
- 告警收敛:把同一工作负载下多个异常 Pod 的告警聚合成一条,附带受影响副本数。
这些能力不需要多么高深的算法,动态基线用 EWMA 或分位数就能做,关联规则用决策树就能写清楚。但效果立竿见影——我们落地之后,单次 OOM 的定位时间从平均 30 分钟缩短到了 3 分钟以内,而且很多恢复动作完全不需要人参与。这才是 AIOps 的实用主义。
1.3 整体架构与选型思路
整套体系我分了四层来搭,尽量复用 K8s 和 Prometheus 生态原生的能力,没有重复造轮子:
| 层级 | 组件 | 职责 |
|---|---|---|
| 采集层 | Prometheus + cAdvisor + Metrics Server + Event Exporter | 采集容器资源指标、节点指标、K8s 事件 |
| 存储层 | Prometheus(长周期归档可接 Thanos) | 指标存储与查询 |
| 分析层 | 自研轻量分析器(Python) | 基线计算、异常检测、事件关联、根因输出 |
| 执行层 | K8s 原生控制器(HPA / 探针 / PDB)+ 自研自愈引擎 | 扩容、重启、驱逐、通知 |
选型时坚持一个原则:能用原生能力兜底的,尽量不自己做。比如重启 Pod、水平扩容这些动作,K8s 本身就有 ReplicaSet 和 HPA,我们只需要在合适的时候触发它们,而不是去写一个 controller 管理 Pod 生命周期。分析器只负责输出“该做什么”,执行动作尽量交给 K8s。这样既降低了开发量,也避免自己实现的调度逻辑跟调度器打架。
2. 搞懂 CPU / 内存泄漏和 OOM 的底层规律
2.1 容器视角下的 OOM 到底怎么发生的
在 K8s 里讨论 OOM,首先要分清两个层面:一个是cgroup 层面的 OOM Killer,另一个是节点层面的系统 OOM,两者触发的条件不一样,Pod 的表现也不一样。
每个容器创建时都会写入 cgroup 的内存限制,当容器内进程的内存使用量超过这个限制时,内核的 OOM Killer 就会介入,选择一个进程杀掉。被杀的进程通常是容器内占用内存最大的进程,表现到 K8s 上就是 Pod 状态变为OOMKilled,退出码 137(SIGKILL)。如果这个 Pod 的restartPolicy是默认的 Always,那么 kubelet 会按照退避策略不断重启它,于是我们就看到了经典的CrashLoopBackOff。
节点层面的 OOM 更可怕。当整台节点的内存即将耗尽时,内核会遍历所有进程并按照评分机制挑选“牺牲者”。K8s 为了避免业务 Pod 被随机杀掉,根据 QoS 等级严格控制了驱逐顺序:BestEffort最优先被杀,Burstable次之,Guaranteed(设置了相等的 requests 和 limits)最安全。所以在设计资源配额时,核心业务尽量用 Guaranteed,这是一个非常重要的兜底手段。
从运维观测的角度,一个 OOM 事故通常看这几个信号:Pod 状态为OOMKilled、kubelet 事件里有Killing container with id、container_memory_working_set_bytes曲线在死亡前迅速触顶。这些信号就是自动化定位的第一手线索。
2.2 内存泄漏的典型指标长相:持续上涨不回头
内存泄漏最显著的特征是内存占用持续上升,且在任何回收动作之后都不回落。比如一个 Java 服务,正常时堆内存会呈现锯齿状波动,GC 之后明显下降;如果 FullGC 之后内存曲线依然一路向北,那基本可以断定有对象被 GC Roots 引用而无法回收。
在 K8s 指标里,我们主要关注两个:
container_memory_usage_bytes:容器当前内存使用量,包含 page cache。container_memory_working_set_bytes:真正占用物理内存的部分,kubelet 计算驱逐和 OOM 时主要看这个指标。
对比这两个指标很有用。如果usage_bytes很高但working_set_bytes正常,往往只是页缓存,不是泄漏,可以不用管;如果两个都在涨,就要怀疑应用程序本身了。
为了区分“正常流量增长”和“真实泄漏”,我习惯计算内存增长斜率。正常业务的波峰波谷会随流量变化,但总体的中位数是稳定的;泄漏的曲线则是一个持续正向的斜率,即使流量平稳也在涨。这个特征非常适合用程序自动识别。
2.3 CPU 泄漏和 CPU 异常的画像
CPU 泄漏这个词不太严谨,更常见的说法是 CPU 消耗异常,一般由这几类问题引起:
- 死循环或空转:某个协程/线程把 CPU 跑满,而且永远不会主动退出。
- goroutine 或线程泄漏:创建了大量并发任务但没有回收机制,CPU 被调度器不断切换上下文消耗掉。
- 频繁 GC:内存分配压力大导致 GC 线程频繁触发,CPU 时间大量花在回收上。
- 子进程失控:应用 fork 了很多子进程做脏活,子进程不退出,CPU 持续被占用。
CPU 异常和内存泄漏有一个重要区别:CPU 高不一定会导致容器被杀,除非它叠加了 liveness 探针超时,让 kubelet 认为应用已经失去响应而主动杀掉重启。所以我们在自愈体系里把 CPU 异常定位和内存 OOM 定位做成了两条链路,前者最终动作往往是“滚动重启 + 扩容”,后者才是“立即重启 + 检查版本”。
CPU 指标主要看rate(container_cpu_usage_seconds_total[1m]),取容器 CPU 使用速率,再和 requests 对比看是否超额。判断 CPU 泄漏同样看趋势:如果一个 Pod 的 CPU 使用率在没有任何发布和流量变更的情况下,持续处于接近上限的高水位,那就是异常信号。
3. 自动定位:从“人工看图”到“机器给结论”
3.1 第一步:让机器学会每个 Pod 的“正常长什么样”
自动定位的起点是动态基线。固定阈值最大的问题是不了解每个服务的正常状态,而动态基线相当于给每个工作负载建了一个“健康档案”。
我用的方法很朴素:对每个 Deployment/StatefulSet 维护两个指标序列——过去 7 天的内存使用率 P95 分位数和过去 1 小时的 EWMA 均值。异常判断规则是:当前指标持续 3 个采集周期(每个周期 30 秒)超过P95 基线 × 1.2 + 残差容差,就标记为可疑。
EWMA 的 Python 实现非常轻量,核心就几行:
def ewma(current, prev_avg, alpha=0.3): return alpha * current + (1 - alpha) * prev_avg实际用的时候,alpha取 0.3 到 0.5 之间,对短时抖动比较敏感,适合捕捉泄漏这种“持续偏离”的模式。算出来的 EWMA 值不直接当告警依据,而是作为“当前趋势方向”的参考:如果 EWMA 持续高于基线且一阶导数始终为正,就说明曲线在稳定爬坡。
提示:动态基线的计算一定要排除掉已知故障时间段的数据。否则上次 OOM 前的异常高水位会被算进“正常范围”,下次泄漏要到更高才告警,完全失去意义。我是在分析器里维护了一个“故障时间窗口表”,每次计算基线前先把这些窗口切掉。
3.2 第二步:根因关联——把指标、事件、日志串成一条线
动态基线只能告诉我们“有问题”,但“是什么问题”需要进一步关联分析。我的分析器在发现有 Pod 偏离基线后,会走一个决策树:
- 查最近 15 分钟内该 Pod 是否有
OOMKilled事件,退出码是否为 137。如果是,直接进入“内存泄漏高风险”分支。 - 如果没有 OOM 事件,就看内存工作集曲线是否呈持续正向斜率,且查询应用日志是否包含
OutOfMemoryError、panic:、allocation failure等关键词。 - 如果内存曲线正常,转而检查 CPU 速率曲线和 liveness 探针失败次数,判断是否为 CPU 异常导致探针超时被杀。
- 如果单 Pod 正常,就把同一工作负载下的所有 Pod 放在一起算方差,揪出“群体中的离群者”。
这里的关键是多源数据的时间对齐。Prometheus 指标自带时间戳,K8s Events 也有时间,日志时间要稍微花点功夫处理。我统一用kubectl get events --sort-by=.lastTimestamp和日志采集器的毫秒时间戳做关联,误差控制在 5 秒内就能满足判断需求。
为了更容易被下游消费,分析器最终输出的是固定格式的 JSON 结论:
{ "workload": "sim-order-service", "pod": "sim-order-service-7d9f8c6b4-abc12", "root_cause": "memory_leak", "confidence": 0.87, "evidence": [ "working_set_bytes slop=0.12 for 1800s", "OOMKilled event at 14:03:22", "FullGC no reclaim effect", "exit_code=137" ], "suggested_actions": ["restart_pod", "scale_out", "notify_oncall"] }3.3 第三步:采集与查询的落地细节
分析器的数据来源是 Prometheus,所以采集侧必须先把该采的指标采全。我们用的是社区标准的 kube-prometheus 栈,额外加了 Event Exporter 把 K8s 事件也暴露成指标。关键 PromQL 查询我直接列在这里,方便你直接拿去用。
内存工作集趋势查询,定位 Pod 粒度:
sum by (pod, namespace) ( container_memory_working_set_bytes{container!="POD", container!=""} )内存增长斜率,用来识别泄漏趋势:
deriv( sum by (pod) ( container_memory_working_set_bytes{container!="POD"} )[30m:] )CPU 速率:
sum by (pod, namespace) ( rate(container_cpu_usage_seconds_total{container!="POD", container!=""}[5m]) )分析器每 30 秒拉取一次这几条查询结果,缓存起来计算基线和斜率。Prometheus 查询的性能在几百个 Pod 规模下完全没问题,但注意deriv()这种聚合查询尽量给足时间范围,避免因为数据点不足返回空值。
注意:
container_memory_usage_bytes包含页缓存,很多场景下会虚高;建议所有内存类判断都以working_set_bytes为准,页缓存导致的临时上涨不要触发自愈动作,否则会产生一堆无效重启。
4. 自愈机制:能治标,也尽量缓治本
4.1 先让 K8s 原生能力把底兜住
在写自愈引擎之前,必须先把 K8s 自带的那套机制配置好。原因很简单:原生机制可靠性最高,没有额外的程序依赖,即使 AIOps 分析器挂了,业务也有基本保障。
第一个是 HPA。我们给所有关键业务配置了基于 CPU 和内存的 HPA,内存指标需要开启 Metrics Server 的聚合层支持。配置示例:
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: sim-order-service-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: sim-order-service minReplicas: 3 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 60 - type: Resource resource: name: memory target: type: Utilization averageUtilization: 75第二个是探针。livenessProbe负责发现应用假死然后重启容器,readinessProbe负责摘流量。配置探针时注意failureThreshold不要设太大,否则 CPU 被占满时探针要失败很多次才触发重启,恢复太慢;也不要用exec执行复杂命令,否则探针本身会被 CPU 饥饿影响而误判。
第三个是 PDB。PDB 保证驱逐和滚动更新时不会同时杀掉多个副本,Pod 最小可用数量可控。内存 OOM 自愈要重启实例,如果没有 PDB 兜底,发布系统或者节点维护时可能在几秒钟内把一批 Pod 全部重启。PDB 配合topologySpreadConstraints一起用,业务高可用的底子就有了。
4.2 分场景设计自愈策略:止血和治本要分开
自愈动作不是简单的“看到 OOM 就重启”。我按场景拆成了三级策略,先止血,再缓治本:
| 场景 | 判定特征 | 自愈动作 | 备注 |
|---|---|---|---|
| 单 Pod 内存持续攀升未到 limit | 斜率正向 + 趋势异常 | 重启该 Pod + 通知 | 重启前优雅下线,等待几分钟看是否复发 |
| 单 Pod OOMKilled | 退出码 137 + OOM 事件 | 重启实例 + 临时扩容副本 + 通知 | 扩容是为了保证重启期间总容量够用 |
| 多个 Pod 同时内存飙升 | 方差小 + 群体性上涨 | 滚动重启全部实例 + 扩容 | 如果容器 limit 设置过低,直接调高 limit |
| CPU 异常导致探针超时 | CPU 速率持续超限 + 探针失败 | 滚动重启 + 触发限流策略 | 排查是否存在死循环,必要时回滚版本 |
执行自愈动作时,我要求优先使用 K8s API 操作而不是直接连容器 runtime 杀进程。比如要重启 Pod,直接kubectl delete pod让 ReplicaSet 重新调度,而不是用 docker kill。这样能保证 kubelet 的状态机一致,不会出现“进程已经死了但 Pod 还显示 Running”的诡异状态。
4.3 自愈引擎的幂等与防风暴设计
自愈引擎本质上是一个事件驱动的规则执行器。我从 Prometheus 拉取异常结论,然后匹配规则表,决定“做什么动作”。伪代码长这样:
RULES = [ { "name": "oom_restart", "match": lambda r: r["root_cause"] == "memory_leak" and r["has_oom_event"], "actions": ["evict_pod", "scale_out_by_2", "notify"], "cooldown_minutes": 10, }, { "name": "memory_growth_restart", "match": lambda r: r["root_cause"] == "memory_leak" and not r["memory_leak"], "actions": ["restart_pod", "notify"], "cooldown_minutes": 30, }, ] def execute_actions(rule_name, finding, k8s_client): lock = acquire_workload_lock(finding["workload"]) if not lock: return # 已有自愈任务在跑,跳过 try: for action in RULES[rule_name]["actions"]: run_action(action, finding) finally: release_workload_lock(lock)这里最容易被忽略的是幂等和冷却。如果没有冷却机制,一个 MemoryLeak 结论每 30 秒触发一次重启,Pod 可能永远处于 CrashLoopBackOff,甚至引发“自愈风暴”。我在每个工作负载上加了分布式锁,同一工作负载的同一规则在冷却窗口内只执行一次,冷却时间根据动作不同设置为 10 到 30 分钟。
另外一个细节:自愈引擎执行完动作后,要主动把分析器的“故障时间窗口表”更新一下,把这次故障区间排除出未来的基线计算。这一步如果漏了,下一次基线就会把当前的高水位当成正常,导致告警失灵。
5. 完整实战复盘:一次 OOM 自动定位与自愈的全过程
5.1 演练场景设计
为了验证整套体系,我自己搭了一个模拟项目 X:一个 Java 服务,正常内存占用约 300MB,容器 limit 设为 1Gi。代码里故意留了一个泄漏点——一个static List不断添加对象,模拟真实内存泄漏。
初始副本 3 个,HPA 最小 3 最大 10,探针配置为 HTTP/healthz,PDB 允许最少 2 个副本。AIOps 分析器已经运行了 3 天,基线数据积累得比较充分。整个演练的目标是:不人工干预,看系统能否自动发现、定位、止血并通知。
5.2 从异常发生到恢复的完整时间线
整个过程用时间线记录如下:
| 时间 | 事件 | 系统动作 |
|---|---|---|
| 14:02:10 | 泄漏 Pod 内存超过动态基线 P95×1.2 | 分析器标记可疑 Pod |
| 14:02:40 | 内存斜率连续 6 个周期为正 | 分析器进入根因关联流程 |
| 14:03:22 | 容器达到 limit,被 cgroup OOM Killer 杀死 | K8s 记录OOMKilled事件,退出码 137 |
| 14:03:30 | 事件与指标关联完成,根因结论输出 | 分析器输出memory_leak,置信度 0.93 |
| 14:03:31 | 自愈引擎匹配oom_restart规则 | 执行:删除坏 Pod + 扩容副本数从 3 到 5 |
| 14:03:40 | ReplicaSet 重新拉起新 Pod | 新 Pod 进入启动流程,状态 Running |
| 14:04:10 | 新 Pod readiness 通过,开始接流量 | 服务恢复,HPA 根据请求负载自动缩回 3 |
| 14:04:30 | 自愈引擎发送 Webhook 通知 | 值班同学收到“已自动处理”的消息 |
从 OOM 发生到服务完全恢复,整个过程只用了不到 2 分钟。最有价值的是,值班同学收到的不是一条干巴巴的告警,而是附带了完整根因证据链和已执行动作的通知,可以直接进入下一步业务侧排查。
5.3 关键日志与命令现场
演练过程中,我记录了几个关键现场。分析器定位阶段的日志片段:
[14:02:10] sim-order-service-7d9f8c6b4-abc12 memory deviation: current=812Mi, baseline_p95=526Mi, alpha=1.54 [14:02:40] memory slop=+0.14Mi/s for 180s, trend=pos, flag=memory_leak_candidate [14:03:30] root_cause=memory_leak, has_oom_event=true, exit_code=137, confidence=0.93事件层面的 K8s 记录:
kubectl get events --field-selector involvedObject.name=sim-order-service-7d9f8c6b4-abc12输出关键行是:
LAST SEEN TYPE REASON MESSAGE 14:03:22 Warning Killing Container sim-order-service failed liveness probe, killing container 14:03:22 Warning OOMKilling Memory cgroup out of memory: Kill process 12345 (java)自愈引擎的审计记录:
[14:03:31] action=evict_pod, target=sim-order-service-7d9f8c6b4-abc12, reason=oom_restart [14:03:31] action=scale_out, workload=sim-order-service, replicas=3->5, reason=temporary_capacity_boost [14:04:30] notification sent, channel=webhook, receivers=sre-oncall-group这套记录既是审计凭证,也是后续复盘的重要输入。
6. 踩坑记录与避坑清单
6.1 排查工具与命令速查
有些朋友问我,定位容器问题到底先看什么。我的习惯顺序是:先看事件,再看趋势,最后看日志。
- 事件:
kubectl get events --sort-by=.lastTimestamp,快速发现 OOMKilled、驱逐、探针失败。 - 趋势:Prometheus 查
container_memory_working_set_bytes和 CPU rate,确认是瞬时抖动还是持续趋势。 - 日志:
kubectl logs <pod> --previous看崩溃前最后输出,查 GC 日志或 panic 堆栈。
进入运行中的容器内部查看更细的信息也有用,比如exec进去看/proc下的内存统计、线程数等。但优先级永远低于前三个,因为线上环境 exec 操作有安全风险,而且很多镜像里根本没有 shell。
6.2 常见问题速查表
我把自己踩过和帮别人看过的典型问题整理成了表格,方便你快速对上号:
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| Pod 反复重启但没 OOM | livenessProbe 超时被杀 | 调整探针超时和 failureThreshold,检查应用是否有长时间阻塞 |
| 内存曲线上涨但没告警 | limit 设置太高,relative 涨幅看不出来 | 缩小 requests/limits 差距,让使用率更有参考性 |
| OOM 重启后立刻再次 OOM | 重启流量瞬间集中到剩余 Pod | 配置 preStop 优雅退出 + 扩容后再重启 |
| HPA 不生效 | metrics-server 没采集到指标或 target 值不合理 | kubectl get hpa -o yaml看 conditions,确认 metrics 类型 |
| 动态基线一直误报 | 异常历史没排除 | 完善故障时间窗口表,把已有事故区间剔除 |
| 自愈动作没执行 | 冷却窗口抑制或规则没匹配上 | 查自愈引擎日志,确认规则 match 条件和锁状态 |
| 节点内存压力导致批量驱逐 | 节点总内存不足 | 检查节点规格,必要时提前触发工作负载疏散 |
6.3 让这套体系更可靠的小技巧
最后分享几个让整套体系稳定运行的经验。第一,监控和分析器本身要纳入可观测范围。分析器挂了,等于自愈体系瞎了,所以它自己也必须暴露指标和告警,最好以 Deployment 方式部署,享受和业务一样的探针、PDB 和监控。第二,自愈动作要灰度放开。刚上线时只配“重启 + 通知”,跑一段时间确认无误报,再加“自动扩容”和“自动限流”,不要一上来就把权限拉到最大。第三,定期做混沌演练。光有规则不演练,真出事时你可能发现分析器进程内存先爆了,或者 HPA 和自愈引擎抢着改副本数互相打架。我们现在的做法是每个月随机挑一个非核心服务跑一次注入实验,把问题提前暴露掉。
我个人在几次真实事故中最大的体会是:AIOps 不是玄学,它做的其实就是把资深工程师“看曲线、翻日志、查事件”的经验沉淀成规则和自动化的脚本,核心价值在于把定位效率和恢复速度提上来。先别急着追求花哨的模型,把指标采准、把原生自愈配置好、把分析逻辑写清楚,这套体系就已经能解决绝大多数 OOM 和 CPU 异常的痛点了。后续的优化方向,我打算再接入调用链数据,让根因分析从“资源层”进一步下探到“应用层”,比如精确到某个接口的某次调用导致的内存分配飙升。
最后再补一个小技巧:自愈动作一定要有审计记录,而且最好把审计记录直接打到独立的日志主题里。这样每次事故结束,你都能回头确认“系统做了什么、为什么这么做”,既方便复盘,也能慢慢迭代出一套更符合自己业务场景的自愈策略。