Kubelet 占用 7.5 GiB 内存:Kubernetes 1.36 Context 泄漏排查

Kubelet 占用 7.5 GiB 内存:Kubernetes 1.36 context 泄漏排查

最近在测试环境部署服务时,发现一台主机的可用内存仅剩几百 MiB。清理之前的旧服务后,内存占用依然偏高。

使用 ps 命令看了一下进程,发现 kubelet 占用了约 7.5 GiB 内存。这台机器总共也只有约 15 GiB 内存,kubelet 一个进程就用掉了约一半。

本文主要记录这次排查过程,以及对应的 kubelet 源码和上游修复逻辑。

TL;DR

  • 本次 kubelet 占用了约 7.5 GiB 内存,堆分配集中在 context,与上游已知的 Pod 同步 context 泄漏问题吻合。
  • 这处回归影响 Kubernetes v1.36.0~v1.36.2,v1.35 不受影响。
  • v1.36.3 是首次包含修复的版本,v1.37.0 也已包含修复。升级时选择所在分支中包含修复的较新补丁版本。
  • 暂时无法升级时,可以重启 kubelet 释放已积累的内存,但后续 Pod 同步时内存仍可能继续累积。

1. 问题现象

现场配置如下:

  • Ubuntu 24.04.3 amd64 8c16g
  • Kubernetes v1.36.1 containerd 2.2.4

最初检查时,主机可用内存约为 435 MiB,同时观察到部分组件反复重启。

先看系统内存,再把进程按 RSS 从高到低排一下:

1
2
free -h
ps -eo pid,ppid,comm,rss,%mem,etime --sort=-rss | head -20

清理之前的服务后,可用内存上升到约 2.75 GiB,但 kubelet 的占用没有明显变化。根据后续采样记录,将主要进程的 RSS 换算为 GiB 后如下,数值取近似值:

1
2
3
4
5
COMMAND              RSS (GiB)
kubelet                   7.50
kube-apiserver            1.50
java (Keycloak)           0.85
dcgm-exporter             0.43

kubelet 的内存占用会随 Pod 数量和负载变化,但这次一个进程就用了约一半主机内存,还是得查一下原因。

2. 定位到上游 #139823

先搜索下社区有没有类似的问题,果然在 K8s 的 GitHub 仓库中,找到了 Issue #139823,记录的也是 Kubernetes 1.36 中 kubelet 内存持续增长的问题。

这是 Kubernetes 1.36 引入的回归问题,1.35 不受这处已知泄漏影响。 上游在 issue 的分析记录中将改动定位到了提交 e94e673。

Pod 在运行过程中会反复同步,1.35 原本会复用已有的 context,1.36 的这次改动变成了每轮同步都重新创建。旧的取消函数被覆盖,又没有被调用,导致旧 context 一直被父级引用。随着同步次数增加,内存占用逐渐增长。

简单来说,旧 context 没有被正确取消,又一直被存活的父 context 引用,GC 无法回收,最终导致内存泄漏。

这个问题由 PR #139850 修复,随后通过 PR #140066 回补到 1.36 分支。具体影响版本和处理方式放在第 4 节说明。

本次节点运行的也是 v1.36.1,符合受影响的版本条件。

3. 定位问题:pprof 分析

不过,仅凭版本和内存占用还不能确定,接下来再看现场是否也有大量 context 分配。

先通过 Kubernetes API 查看 kubelet 的 Go 堆和进程内存。本次是单节点集群,直接获取第一个节点的名称即可;多节点集群需要指定实际排查的节点。

1
2
3
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl get --raw "/api/v1/nodes/${NODE}/proxy/metrics" \
  | grep -E '^(go_memstats_heap_alloc_bytes|process_resident_memory_bytes) '

本次采样中,Go 堆约为 6.42 GiB,进程驻留内存约为 7.49 GiB。大部分占用都在 Go 堆里,继续用 pprof 看具体分配位置:

1
2
3
4
5
NODE=$(kubectl get nodes -o jsonpath='{.items[0].metadata.name}')
kubectl --request-timeout=30s get --raw "/api/v1/nodes/${NODE}/proxy/debug/pprof/heap" \
  > kubelet-heap.pb.gz

go tool pprof -top -inuse_space -nodecount=30 kubelet-heap.pb.gz

这条采集路径需要 nodes/proxy 访问权限,以及 kubelet 调试接口支持;执行分析命令的节点需要安装 Go 工具链。

本次结果摘录如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
File: kubelet
Type: inuse_space
Showing nodes accounting for 6007.96MB, 97.45% of 6165.42MB total

      flat  flat%        cum   cum%
 3643.23MB 59.09%  5031.88MB 81.61%  context.(*cancelCtx).propagateCancel
 1388.65MB 22.52%  1388.65MB 22.52%  context.(*cancelCtx).Done
  975.57MB 15.82%  1225.96MB 19.88%  context.withCancel
         0     0%  4612.69MB 74.82%  k8s.io/apimachinery/pkg/util/wait.PollUntilContextTimeout
         0     0%  4625.76MB 75.03%  k8s.io/kubernetes/pkg/kubelet.(*Kubelet).SyncPod
         0     0%  4612.69MB 74.82%  k8s.io/kubernetes/pkg/kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount
         0     0%  1225.96MB 19.88%  k8s.io/kubernetes/pkg/kubelet.(*podWorkers).startPodSync

这里的 inuse_space 是仍存活对象的堆占用估算,不是程序启动以来的累计分配量。heap profile 主要反映最近一次完成 GC 时的情况,和前面堆指标的采样时刻、统计口径不同,数值不必完全一致。flat 归属函数自身,cum 包含下游调用,同一份内存不能把多层 cum 直接相加。

可以看到大部分内存都集中在 context.(*cancelCtx).propagateCancel、context.(*cancelCtx).Done 和 context.withCancel。相关调用又经过了 SyncPod、WaitForAttachAndMount 和 startPodSync,与上游 issue 中的特征比较接近。

到这里,Kubernetes 版本、context 分配和 Pod 同步调用栈都与上游已知问题高度吻合。

4. 影响范围和处理方式

哪些版本受影响

这处回归影响 Kubernetes v1.36.0~v1.36.2,v1.35 不受这处泄漏影响。v1.36.3 是首次包含修复的版本,v1.37.0 也已包含修复。

怎么处理

对于本次的 v1.36.1,在 1.36 分支内选择包含修复、经过兼容性验证的较新补丁版本即可,不必专门跨到 1.37。修复点在 kubelet,升级后要确认各节点实际运行的 kubelet 版本已更新。

暂时无法升级时,可以在维护窗口重启 kubelet,释放已经积累的内存。重启期间,节点状态上报和 Pod 管理会短暂中断。

重启只是临时缓解,后续 Pod 同步时内存仍可能继续累积。

本次现场先通过重启缓解,尚未升级到修复版本,升级后的长期运行效果仍待验证。

下面再看一下,这些 context 是怎么积累起来的,上游又改了什么。

5. 源码分析

取消函数被覆盖了

问题代码基于 Kubernetes v1.36.1,修复后的代码对照 v1.36.3。

源码入口在 pkg/kubelet/pod_workers.go 的 podWorkerLoop。每次收到同步通知,都会先通过 startPodSync 取得 context,再将它传给 SyncPod。后面的 WaitForAttachAndMount 也使用这个 context,和前面的分配调用栈就对应上了。

先对照一下 v1.35.0 的 startPodSync,原来的逻辑会保存并复用 context:

1
2
3
4
if status.ctx == nil || status.ctx.Err() == context.Canceled {
    status.ctx, status.cancelFn = context.WithCancel(context.Background())
}
ctx = status.ctx

到了 v1.36.1,这里变成了下面这一行:

1
ctx, status.cancelFn = context.WithCancel(parentCtx)

问题就在这里。每轮同步都会创建新的 context,并把取消函数保存到 status.cancelFn。下一轮再执行时,旧的取消函数就被覆盖了。

那么取消函数在哪里调用呢?同一个文件的 UpdatePod 中有下面这段逻辑:

1
2
3
4
5
if (becameTerminating || wasGracePeriodShortened) && status.cancelFn != nil {
    updateLogger.V(3).Info("Cancelling current pod sync", "workType", status.WorkType())
    status.cancelFn()
    return
}

这里会在 Pod 进入终止阶段,或者终止宽限期缩短时取消当前同步,普通的一轮同步成功不会走到这个分支。而且,即使后续调用了 status.cancelFn(),取消的也只是最后一次保存的 context,之前被覆盖的取消函数不会被补调用。

旧的取消函数被覆盖后,父 context 仍然保留对子 context 的引用。

Go 会把这类子 context 登记到父 context 的 children 集合中,用于传递取消信号。在这条路径中,父 context 仍然存活,旧的子 context 又没有取消。即使 status 不再保存旧的取消函数,这些对象仍然可达,GC 也无法回收。随着 Pod 反复同步,内存逐渐累积,普通 Pod 持续运行也可能触发这个问题。

修复改了什么

PR #139850 的核心修改都在 pkg/kubelet/pod_workers.go 中,主要有两处。

一处是在 podSyncStatus 中增加 ctx 字段,把 context 和对应的取消函数一起保存下来。这里只保留相关字段:

1
2
3
4
5
type podSyncStatus struct {
    ctx      context.Context
    cancelFn context.CancelFunc
    // 其余字段略
}

另一处是修改 startPodSync,把每轮创建改为复用:

1
2
3
4
5
-ctx, status.cancelFn = context.WithCancel(parentCtx)
+if status.ctx == nil || status.ctx.Err() != nil {
+    status.ctx, status.cancelFn = context.WithCancel(context.WithoutCancel(parentCtx))
+}
+ctx = status.ctx

具体逻辑如下:

  • 第一次同步时,status.ctx 为空,创建 context,同时保存取消函数。
  • 后续同步时,只要这个 context 还有效,就直接复用,不会再覆盖 cancelFn。
  • UpdatePod 调用取消函数后,status.ctx.Err() 变为非空,下一轮同步再创建新的 context。

这里的 context.WithoutCancel(parentCtx) 会保留父 context 中的值,但不继承父级的取消信号和 deadline。外面再套一层 WithCancel,取消就由 Pod worker 自己控制,也不会继续把每轮新建的可取消 context 挂到父级取消树上。

6. 小结

  • 这处 context 泄漏是 Kubernetes 1.36 引入的回归,影响 v1.36.0~v1.36.2,v1.35 不受影响。
  • v1.36.3 首次修复这处泄漏,v1.37.0 也已包含修复。实际升级时选择所在分支中包含修复的较新补丁版本。
  • 暂时无法升级时,可以重启 kubelet 释放已积累的内存,但这只是临时缓解,后续 Pod 同步时内存仍可能继续累积。
0%
微信公众号
探索云原生
微信扫码关注公众号探索云原生