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


![Kubelet 占用 7.5 GiB 内存：Kubernetes 1.36 context 泄漏排查](https://img.lixueduan.com/kubernetes/cover/kubelet-context-memory-leak.jpg)

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

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

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

<!--more-->

## 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 从高到低排一下：

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

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

```text
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](https://github.com/kubernetes/kubernetes/issues/139823)，记录的也是 Kubernetes 1.36 中 kubelet 内存持续增长的问题。

**这是 Kubernetes 1.36 引入的回归问题，1.35 不受这处已知泄漏影响。** 上游在 [issue 的分析记录](https://github.com/kubernetes/kubernetes/issues/139823#issuecomment-4744629712)中将改动定位到了提交 [`e94e673`](https://github.com/kubernetes/kubernetes/commit/e94e67337dfe4864665db25b3d3d1957c6234a92)。

Pod 在运行过程中会反复同步，1.35 原本会复用已有的 context，1.36 的这次改动变成了每轮同步都重新创建。旧的取消函数被覆盖，又没有被调用，导致旧 context 一直被父级引用。随着同步次数增加，内存占用逐渐增长。
> 简单来说，旧 context 没有被正确取消，又一直被存活的父 context 引用，GC 无法回收，最终导致内存泄漏。

这个问题由 [PR #139850](https://github.com/kubernetes/kubernetes/pull/139850) 修复，随后通过 [PR #140066](https://github.com/kubernetes/kubernetes/pull/140066) 回补到 1.36 分支。具体影响版本和处理方式放在第 4 节说明。

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

## 3. 定位问题：pprof 分析

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

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

```bash
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 看具体分配位置：

```bash
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 工具链。

本次结果摘录如下：

```text
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`](https://pkg.go.dev/runtime/pprof#hdr-Heap_profile) 是仍存活对象的堆占用估算，不是程序启动以来的累计分配量。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](https://github.com/kubernetes/kubernetes/blob/v1.36.3/pkg/kubelet/pod_workers.go#L1165-L1169) 是首次包含修复的版本，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`](https://github.com/kubernetes/kubernetes/blob/v1.36.1/pkg/kubelet/pod_workers.go#L1231-L1234)。每次收到同步通知，都会先通过 `startPodSync` 取得 context，再将它传给 [`SyncPod`](https://github.com/kubernetes/kubernetes/blob/v1.36.1/pkg/kubelet/pod_workers.go#L1304-L1305)。后面的 [`WaitForAttachAndMount`](https://github.com/kubernetes/kubernetes/blob/v1.36.1/pkg/kubelet/kubelet.go#L2196) 也使用这个 context，和前面的分配调用栈就对应上了。

先对照一下 [v1.35.0 的 `startPodSync`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/kubelet/pod_workers.go#L1158-L1161)，原来的逻辑会保存并复用 context：

```go
if status.ctx == nil || status.ctx.Err() == context.Canceled {
    status.ctx, status.cancelFn = context.WithCancel(context.Background())
}
ctx = status.ctx
```

到了 [v1.36.1](https://github.com/kubernetes/kubernetes/blob/v1.36.1/pkg/kubelet/pod_workers.go#L1155)，这里变成了下面这一行：

```go
ctx, status.cancelFn = context.WithCancel(parentCtx)
```

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

那么取消函数在哪里调用呢？同一个文件的 [`UpdatePod`](https://github.com/kubernetes/kubernetes/blob/v1.36.1/pkg/kubelet/pod_workers.go#L990-L994) 中有下面这段逻辑：

```go
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` 集合](https://github.com/golang/go/blob/go1.26.0/src/context/context.go#L491-L504)中，用于传递取消信号。在这条路径中，父 context 仍然存活，旧的子 context 又没有取消。即使 `status` 不再保存旧的取消函数，这些对象仍然可达，GC 也无法回收。随着 Pod 反复同步，内存逐渐累积，普通 Pod 持续运行也可能触发这个问题。

### 修复改了什么

[PR #139850](https://github.com/kubernetes/kubernetes/pull/139850/files) 的核心修改都在 `pkg/kubelet/pod_workers.go` 中，主要有两处。

一处是在 [`podSyncStatus`](https://github.com/kubernetes/kubernetes/blob/v1.36.3/pkg/kubelet/pod_workers.go#L338-L350) 中增加 `ctx` 字段，把 context 和对应的取消函数一起保存下来。这里只保留相关字段：

```go
type podSyncStatus struct {
    ctx      context.Context
    cancelFn context.CancelFunc
    // 其余字段略
}
```

另一处是修改 [`startPodSync`](https://github.com/kubernetes/kubernetes/blob/v1.36.3/pkg/kubelet/pod_workers.go#L1165-L1169)，把每轮创建改为复用：

```diff
-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 同步时内存仍可能继续累积。


---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/kubernetes/73-kubelet-context-memory-leak/  

