WEBKT

如何在 K8s 中动态调整超大内存 Pod 的 OOM Score:自研 Controller 与 Node Agent 的落地实践

4 0 0 0

在超大规模的 Kubernetes 集群中,混部(Co-location)和高密度部署是压榨物理机资源的常见手段。然而,当大促、秒杀等高并发业务峰值到来时,集群内的流量暴涨会导致某些超大内存 Pod(如 128G+ 的 JVM、缓存服务、向量数据库、大模型推理实例等)内存迅速爬升。

按照 Kubernetes 默认的 QoS(Quality of Service)机制:

  • Guaranteed 的 Pod 默认 oom_score_adj-997
  • Burstable 的 Pod 根据内存申请比例,其 oom_score_adj2999 之间波动。
  • BestEffort 则固定为 1000

在极端的整机内存(Host OOM)水位下,即便这些大内存 Pod 是业务核心,Linux 内核的 OOM Killer 也极有可能因为它们“绝对内存占用过大”而优先将其杀掉。更棘手的是,业务高峰期我们往往希望临时提高这些核心大内存 Pod 的免疫力(降低其 oom_score_adj),在低谷期再将其恢复,以保证整机的容灾弹性。

Kubernetes 的 Kubelet 并不支持动态调整这个值,它是一次性写入、静态管控的。为了解决这一痛点,我们需要设计并实现一套自研 Controller + Node Agent 的闭环控制方案,在避开 Kubelet 强管控的同时,实现 OOM Score 的动态秒级微调。


架构设计:两阶段控制环

直接从集群外部通过 SSH 修改宿主机上的 /proc/{pid}/oom_score_adj 是非常不优雅且极难维护的。在云原生架构下,标准做法是采用 控制面(Control Plane)决策 + 数据面(Data Plane)执行 的双层架构。

+-------------------------------------------------------------+
|                        控制面 (Control Plane)               |
|                                                             |
|   +-------------------+      Watch      +---------------+   |
|   |   Prometheus /    | --------------> |  Dynamic-OOM  |   |
|   |   Cron Trigger    |                 |  Controller   |   |
|   +-------------------+                 +---------------+   |
|                                                 |           |
|                                                 | Update    |
|                                                 v           |
|                                         +---------------+   |
|                                         |  Pod Metadata |   |
|                                         |  (Annotation) |   |
|                                         +---------------+   |
+-------------------------------------------------|-----------+
                                                  |
                                                  | Watch (Reflector API)
                                                  v
+-------------------------------------------------------------+
|                        数据面 (Node DaemonSet)               |
|                                                             |
|                         +---------------+                   |
|                         |  OOM Agent    |                   |
|                         +---------------+                   |
|                           /     |     \                     |
|                  Find PID/      |      \ Write              |
|               Read Cgroup       |       \                   |
|                   v             v        v                  |
|             +-----------+ +-----------+ +-----------+       |
|             | Container | | Container | | Container |       |
|             |    Pid    | |  OOM Adj  | |   Tasks   |       |
|             +-----------+ +-----------+ +-----------+       |
+-------------------------------------------------------------+

1. 控制面:Dynamic-OOM Controller

基于 controller-runtime 开发。它并不直接操作宿主机,而是扮演“决策者”。

  • 触发源:支持两种模式。一是 Time-based(定时 Cron,例如大促前 10 分钟),二是 Metrics-based(对接 Prometheus API,检测到业务 QPS 突增)。
  • 执行动作:当触发条件达成,Controller 会在目标 Pod 的 Metadata.Annotations 中注入或更新特定的控制声明:
    metadata:
      annotations:
        oom-adjuster.infra.io/desired-score: "-900"
        oom-adjuster.infra.io/active-until: "2026-03-30T22:00:00Z"
    

2. 数据面:Node-local OOM Agent

以 DaemonSet 形式运行在每个节点上,共享宿主机的 PIDCgroup 命名空间(hostPID: true)。

  • Watch 机制:Agent 只 Watch 本节点上的 Pod。通过 client-goFieldSelector 过滤 spec.nodeName,开销极小。
  • 绑定机制:当检测到本地 Pod 被贴上 desired-score 标签时,Agent 解析该值,并定位该 Pod 在宿主机上的真实进程 PID,最后修改对应的 /proc/{PID}/oom_score_adj
  • 反向对抗 Kubelet:Kubelet 的 SyncPod 循环或容器重启会尝试将 oom_score_adj 重置回默认值。Agent 需要维持一个定时的 Reconcile 周期(例如 5 秒),强制将数值拉回期望状态,并在 Annotation 的有效期(active-until)过期后,主动恢复 Kubelet 默认值。

核心实现难点:如何在 Node 侧精准定位容器 PID?

在自研 Node Agent 时,如何通过 K8s API 中的 Pod 找到其在宿主机上的物理 PID,是整个链路中最关键的一步。

由于现在主流集群均已切换到 ContainerdCRI-O,不能再依赖早已弃用的 Docker Daemon API。最健壮且不依赖 CRI Socket 的方式是直接读取 Cgroups 路径

对于 Cgroups v2,Pod 的路径规则非常清晰。我们可以直接读取 /sys/fs/cgroup/kubepods.slice/ 下的进程列表:

package oom

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
)

// FindPidsByPodUID 遍历 Cgroup 路径,找到该 Pod 下所有容器对应的宿主机 PID
func FindPidsByPodUID(podUID string) ([]string, error) {
    // 兼容 Cgroups v1 和 v2 的基本路径
    // 格式通常为: /sys/fs/cgroup/kubepods.slice/kubepods-pod<UID>.slice/
    formattedUID := strings.ReplaceAll(podUID, "-", "_")
    podSlicePath := filepath.Join("/sys/fs/cgroup", "kubepods.slice", fmt.Sprintf("kubepods-pod%s.slice", formattedUID))

    // 如果不存在,尝试不带下划线的标准格式 (Cgroup v2 视系统配置而定)
    if _, err := os.Stat(podSlicePath); os.IsNotExist(err) {
        podSlicePath = filepath.Join("/sys/fs/cgroup", "kubepods.slice", fmt.Sprintf("kubepods-pod%s.slice", podUID))
    }

    var pids []string
    err := filepath.Walk(podSlicePath, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        // 寻找到容器级别的 cgroup.procs 文件
        if info.Name() == "cgroup.procs" {
            content, err := os.ReadFile(path)
            if err != nil {
                return nil // 部分临时容器退出可能会读取失败,忽略
            }
            lines := strings.Split(string(content), "\n")
            for _, line := range lines {
                pid := strings.TrimSpace(line)
                if pid != "" {
                    pids = append(pids, pid)
                }
            }
        }
        return nil
    })

    return pids, err
}

核心实现:OOM Score 写入与对抗策略

在获取到宿主机 PID 后,Agent 需要对 /proc/{PID}/oom_score_adj 进行原子性写入。

由于 Kubelet 在检测到 Pod 发生漂移、重置或者容器内部 OOM 重启后,会重新调用内置的 GetContainerOOMScoreAdjust 并覆写该值。这就要求 Agent 的写入逻辑必须具备幂等性周期性校验

下面是 Agent 执行写入的核心逻辑:

package oom

import (
    "fmt"
    "os"
    "strconv"
    "strings"
    "syscall"
)

// ApplyOOMScoreAdjustment 将目标 PID 的 oom_score_adj 修改为 desiredScore
func ApplyOOMScoreAdjustment(pid string, desiredScore int) error {
    adjPath := fmt.Sprintf("/proc/%s/oom_score_adj", pid)
    
    // 1. 读取当前值,避免无意义的频繁写入引发内核上下文开销
    currentContent, err := os.ReadFile(adjPath)
    if err != nil {
        return fmt.Errorf("failed to read %s: %w", adjPath, err)
    }
    
    currentScore, err := strconv.Atoi(strings.TrimSpace(string(currentContent)))
    if err == nil && currentScore == desiredScore {
        // 值已符合预期,直接跳过
        return nil
    }

    // 2. 写入新值
    err = os.WriteFile(adjPath, []byte(strconv.Itoa(desiredScore)), 0644)
    if err != nil {
        // 针对进程可能刚退出的边缘情况进行容错
        if os.IsNotExist(err) || err == syscall.ESRCH {
            return nil
        }
        return fmt.Errorf("failed to write %d to %s: %w", desiredScore, adjPath, err)
    }

    return nil
}

控制器控制回路 (Reconciler) 的核心逻辑

这是运行在 Node Agent 内部的本地控制循环片段,它只对自己所在 Node 的 Pod 负责:

package controller

import (
    "context"
    "time"

    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
    
    "my-infra/oom"
)

type NodePodOOMReconciler struct {
    client.Client
    NodeName string
}

func (r *NodePodOOMReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
    pod := &corev1.Pod{}
    err := r.Get(ctx, req.NamespacedName, pod)
    if err != nil {
        if errors.IsNotFound(err) {
            return reconcile.Result{}, nil
        }
        return reconcile.Result{}, err
    }

    // 过滤:如果该 Pod 漂移到了其他节点,本节点 Agent 不再管辖
    if pod.Spec.NodeName != r.NodeName {
        return reconcile.Result{}, nil
    }

    desiredScoreStr, hasAnnotation := pod.Annotations["oom-adjuster.infra.io/desired-score"]
    activeUntilStr, hasExpiry := pod.Annotations["oom-adjuster.infra.io/active-until"]

    if !hasAnnotation {
        return reconcile.Result{}, nil
    }

    // 校验有效期
    if hasExpiry {
        expiry, err := time.Parse(time.RFC3339, activeUntilStr)
        if err == nil && time.Now().After(expiry) {
            // 如果已经过期,不执行逻辑,或者在此处设计“恢复初始值”逻辑
            // 通常由于 Kubelet 也会在下次 Sync 时覆写,我们直接不干预即可,或者主动将其重置回默认的 QoS 档位
            return reconcile.Result{}, nil
        }
    }

    desiredScore, err := strconv.Atoi(desiredScoreStr)
    if err != nil || desiredScore < -1000 || desiredScore > 1000 {
        // 过滤非法非法输入
        return reconcile.Result{}, nil
    }

    // 根据 Pod UID 检索主机 PID 组
    pids, err := oom.FindPidsByPodUID(string(pod.UID))
    if err != nil {
        return reconcile.Result{RequeueAfter: 5 * time.Second}, err
    }

    // 执行动态下刷
    for _, pid := range pids {
        _ = oom.ApplyOOMScoreAdjustment(pid, desiredScore)
    }

    // 5秒后重新对齐,对抗 Kubelet 内置的同步协程
    return reconcile.Result{RequeueAfter: 5 * time.Second}, nil
}

生产环境落地的核心避坑指南

1. 警惕“套娃式 OOM”导致系统崩盘

如果我们为了保护超大内存的 JVM Pod,在高峰期将它们的 oom_score_adj 全部调整为 -900
一旦整机内存彻底枯竭,Linux 内核在寻找可杀进程时,由于大内存 Pod 受到保护,它将不得不去杀其他进程。
这可能导致:

  • 系统的关键守护进程(如 sshdsystemd-journald 甚至 kubelet 本身)被杀。
  • 节点直接夯死(Kernel Panic),最终整机雪崩。

对策

  • 绝对禁止将业务 Pod 调整为 -1000(这是保留给 sshkubelet 等极少数底层关键系统进程的安全边界)。
  • 给业务 Pod 设置调整底线(例如最低不低于 -900),保留 -901-999 的区间给系统基础组件。
  • 在大内存 Pod 所在节点,务必留足预留内存(Kubelet 的 --eviction-hard--system-reserved),通过 K8s 的主动驱逐(Eviction)防线,阻断底层内核 OOM 被触发。

2. cgroups v1 与 v2 的路径兼容性

不同的 Linux 发行版及 Kubernetes 版本(比如从 1.22 升级到 1.25+)会导致 Cgroup Driver 从 cgroupfs 切换到 systemd,且 Cgroup 布局由 v1 升级为 v2。
在 cgroups v2 下,Pod UID 的连字符(-)在目录命名中会被转换(例如变为 kubepods-pod<UID_with_underscores>.slice)。
在编写 Node Agent 的文件寻址逻辑时,务必对两种格式都做好兼容测试,或者通过 mount | grep cgroup2 先行判断当前系统版本。

3. 限流与 API 压力

如果集群规模很大(例如 1000+ 节点,上万 Pod),在进入流量高峰时,控制器批量更新 Pod Annotation 可能会对 kube-apiserver 造成瞬间的高 QPS 压力。

  • 优化手段
    • 在 Node Agent 侧,使用 Informer 机制配合本地 Cache,禁止高频直连 kube-apiserver
    • 在 Controller 侧,可以引入“分批平滑更新”机制,避免数万个 Pod Annotation 同时变更引起 Etcd 写入风暴。

总结

通过自研 Controller 配合 Node Agent,我们成功地在 Kubernetes 集群中建立起了一套可动态伸缩、自适应流量高峰的 OOM 防御盾牌。这不仅提高了核心超大内存 Pod 在大促高并发期间的生存率,也最大化保留了集群在低谷期的弹性和超卖空间。
这种设计思路展现了典型的“云原生套路”:将控制策略上浮至 Kubernetes 控制面,将高危系统操作下沉至 Node 级 Agent,两者互不干扰却又高效协同。

云原生践行者 KubernetesGoLinux 内核

评论点评