分类: K8s进阶与实战

  • 深入 K8S Operator 内存雪崩排查:全局缓存滥用引发的 Informer OOM 与按需过滤实战

    排查发现,大量 Operator OOMKilled 并非代码内存泄漏,而是直接 Watch corev1.Secret 等基础资源时,Controller-Runtime 默认拉取全集群数据并构建本地 Indexer 缓存引发的。解决核心是在 Manager 初始化时引入 cache.Options.ByObject(v0.15.0+),在 Reflector 建立 Watch Stream 侧实施 Label 过滤,阻断无关数据进入 DeltaFIFO,将内存从 4GB 压降至 50MB。

    案发现场:一次常规 Watch 引发的血案

    某次排查线上自研 DB Operator 时,发现该组件的 Pod 频繁重启,Exit Code 137,监控面板显示内存使用率呈标准的锯齿状(直奔 4GB Limit 后被内核 OOM Killer 击溃)。同时,集群 kube-apiserver 的 CPU 出现周期性异常飙升,网络出带宽被打满。

    直接拉取 Operator 的 pprof heap 剖析文件:

    go tool pprof -http=:8080 http://localhost:8081/debug/pprof/heap
    

    Top 资源消耗显示,90% 以上的内存被 client-go 的底层缓存结构吃掉:

    (pprof) top
    Showing nodes accounting for 3.6GB, 92% of 3.9GB total
    Dropped 120 nodes (cum <= 0.02GB)
          flat  flat%   sum%        cum   cum%
         2.1GB 53.84% 53.84%      2.1GB 53.84%  k8s.io/client-go/tools/cache.(*ThreadSafeStore).Add
         1.0GB 25.64% 79.48%      1.0GB 25.64%  k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.(*Unstructured).DeepCopy
         0.5GB 12.82% 92.30%      3.6GB 92.30%  k8s.io/client-go/tools/cache.(*sharedIndexInformer).HandleDeltas
    

    检查业务代码,开发人员在 Setup 阶段写了如下逻辑,目的是为了监听 Operator 自身下发给 DB 实例的 Secret(用于 TLS 证书轮转):

    // 埋雷代码
    err = ctrl.NewControllerManagedBy(mgr).
        For(&dbv1.MyDatabase{}).
        Watches(&corev1.Secret{}, &handler.EnqueueRequestForOwner{
            OwnerType:    &dbv1.MyDatabase{},
            IsController: true,
        }).
        Complete(r)
    

    为什么一个简单的 Watch 会打爆整个 Indexer 缓存?

    很多新人对 Controller-Runtime 的 Cache 机制存在误解,以为在 Reconcile 里过滤或者用 EnqueueRequestForOwner 就能限制内存使用。这是典型的防御边界后置

    在 Controller-Runtime 的底层架构中,所有通过 Client 读取的操作(非直接指定 client.Reader)和 Watch 操作,都会由全局共享的 Cache 组件(Backed by Informer)接管。

    1. 无差别 LIST/WATCH: 当你调用 Watches(&corev1.Secret{}) 时,Manager 会检查对应的 Informer 是否启动。如果没有,它会创建一个针对 corev1.Secret 的全局 Informer。

    2. 全量同步: Reflector 会向 API Server 发送一个 不带任何过滤条件LIST /api/v1/secrets 请求。如果你的集群里有 10 万个 Secret(比如 Helm Release 的历史记录、各种 ServiceAccount Token),这 10 万个对象会被全量拉取。

    3. 入库 Indexer: 拉取到的数据经过 DeltaFIFO,最终塞进本地的 ThreadSafeStore(一个带有读写锁的 map)。

    4. 爆炸: Kubernetes 的 Secret 对象通常包含巨大的 Base64 payload(如证书、配置)。将 10 万个 Secret 全量缓存在 Operator 的进程内存中,不仅当场撑爆 4GB,还会导致 API Server 在建立 Watch stream 时耗尽缓冲区。

    EnqueueRequestForOwner 只是限制了事件入队 (WorkQueue) 的范围,并没有限制 Informer 缓存 (Cache) 的范围。内存早就被底层 map 吃干抹净了。

    破局:在 Watch Stream 侧实施按需过滤

    解决这个问题的关键在于:将过滤逻辑前置推送到 API Server 端

    sigs.k8s.io/controller-runtime v0.15.0 开始,Cache 配置选项被重新设计,我们可以利用 cache.Options.ByObject 为特定资源指定 LabelSelector 或 FieldSelector。这样,底层 Reflector 在构造 ListWatch 请求时,就会带上相应的查询参数。

    重构 Manager 初始化逻辑:

    import (
        "k8s.io/apimachinery/pkg/labels"
        "sigs.k8s.io/controller-runtime/pkg/cache"
        "sigs.k8s.io/controller-runtime/pkg/client"
        ctrl "sigs.k8s.io/controller-runtime"
    )
    
    func main() {
        // 1. 定义我们只关心带有特定 Label 的 Secret
        secretLabelSelector := labels.SelectorFromSet(labels.Set{
            "app.kubernetes.io/managed-by": "my-db-operator",
        })
    
        // 2. 配置 Cache 策略
        mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
            Scheme: scheme,
            Cache: cache.Options{
                ByObject: map[client.Object]cache.ByObject{
                    &corev1.Secret{}: {
                        Label: secretLabelSelector,
                        // 如果只需要监听特定 NS,也可以配合 FieldSelector
                        // Field: fields.SelectorFromSet(fields.Set{"metadata.namespace": "db-system"}),
                    },
                },
            },
        })
    
        if err != nil {
            setupLog.Error(err, "unable to start manager")
            os.Exit(1)
        }
        // ... 后续启动逻辑
    }
    

    修改后重启 Operator,API Server 侧接收到的请求变为: LIST /api/v1/secrets?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dmy-db-operator

    仅拉取和缓存 Operator 真正管理的几十个 Secret,内存消耗瞬间从 4GB 暴跌至 50MB 左右,GC 压力释放,CPU 利用率趋于一条平滑的直线。

    常见问题 (FAQ)

    Q1:为了绕过缓存导致 OOM,我直接使用 mgr.GetAPIReader() 替代 mgr.GetClient() 来读取 Secret 可以吗? 不可以滥用 APIReader。APIReader 会直接穿透缓存向 API Server 发起实时查询。如果在高频的 Reconcile 循环中对基础资源使用 APIReader,会产生可怕的 Read QPS,极易触发 API Server 的流控(RateLimiting),甚至拖垮主节点 etcd。基础资源读取必须走缓存,关键在于“控缓存规模”而非“弃用缓存”。

    Q2:如果我需要监听多个特定 Namespace 下的资源,而不是依赖 Label,该怎么配置? 可以通过 cache.Options.DefaultNamespaces 来限制全局缓存的命名空间范围。如果你需要监听 ns-ans-b

    Cache: cache.Options{
        DefaultNamespaces: map[string]cache.Config{
            "ns-a": {},
            "ns-b": {},
        },
    }
    

    这在底层会实例化 MultiNamespacedCache,对每个指定 NS 启动独立的 Watcher。

    Q3:我在 Cache 中配置了 LabelSelector,这会影响 Garbage Collection (GC) 和 OwnerReference 的级联删除吗? 会产生直接影响。如果父对象 A 创建了子对象 B,且通过 cache.ByObject 过滤了子对象 B(比如子对象 B 没有打上对应的 Label),那么当 B 发生状态变化或被意外删除时,Operator 的 Informer 将无法收到事件,也就无法触发针对父对象 A 的 Reconcile,导致级联恢复机制失效。 最佳实践: 只要是你 Operator 创建并需要跟踪生命周期的附属资源,必须在创建时强行注入统一的管控 Label,并在 Manager Cache 配置中对齐该 Label。