修复 Kubernetes 1.36 中 kubelet 的内存泄漏
摘要
详细记录追踪并修复 Kubernetes 1.36 中 kubelet 组件的一个内存泄漏问题,该问题由 startPodSync 中的上下文泄漏引起。作者使用 Go pprof 分析工具识别了该问题并实施了修复。
暂无内容
查看缓存全文
缓存时间: 2026/07/01 17:00
# 修复 Kubernetes 1.36 中的 kubelet 内存泄漏 | 博客 | HeyOnCall
来源:https://heyoncall.com/blog/fixing-kubernetes-kubelet-memory-leak
我是如何追踪到 Kubernetes kubelet 中一个微小的 Go 生命周期 bug 的——该 bug 导致每次调用 `startPodSync` 都会泄漏一个 context。
几周前,我开始收到一个小型 Kubernetes 集群的告警:一个单节点 Kubernetes 测试集群,没有运行任何生产工作负载。我刚刚将这个集群升级到 v1.36,托管在 DigitalOcean 的 DOKS 托管服务上。**我的节俭意外地带来了回报:这个小小的(咳,便宜的!)2 GiB 内存节点承受了如此大的内存压力,以至于迅速暴露了 Kubernetes 1.36 中的一个更深层次的问题**——如果内存充裕,这个问题可能需要更长时间才会显现。
调查告警后发现 Pod 正在被重启,但 `kubectl top pods` 没有显示任何异常大的 Pod。节点上运行的应用程序没有出现内存增长,也远未达到其内存限制。
揭开 Kubernetes 的表层,我在节点上打开了一个 root shell,快速运行 `htop` 并按 `M` 排序后,很快发现 `kubelet` 进程本身已经增长,并且还在持续增长!
在节点上快速执行 `systemctl restart kubelet` 让集群恢复了正常,但潜在的泄漏仍然存在,如果不找出泄漏的根源,它很快就会再次出现。
### 转储 kubelet 的堆
Kubernetes 是用 Go 编写的,kubelet 是 Kubernetes 工作的核心组件:它运行在每个节点上,负责保持该节点上的容器与期望的集群状态同步。
Go 的 `pprof` 包(https://pkg.go.dev/net/http/pprof)可以让你从正在运行的进程中捕获堆内存配置文件,我将其保存到一个文件中:
```
kubectl get --raw "/api/v1/nodes/${NODE}/proxy/debug/pprof/heap?debug=0" > "kubelet_pprof_heap.pb.gz"
```
之后,可以使用 `go tool pprof -top` 来查看情况,既可以按总大小查看,也可以按对象数量查看。
按对象数量查看:
```
go tool pprof -top -sample_index=inuse_objects kubelet_pprof_heap.pb.gz
flat flat% sum% cum cum%
642456 45.52% 45.52% 918672 65.09% context.(*cancelCtx).propagateCancel
380137 26.93% 72.45% 380195 26.94% context.withCancel (inline)
276216 19.57% 92.02% 276216 19.57% context.(*cancelCtx).Done
10923 0.77% 92.80% 10923 0.77% container/list.(*List).insertValue (inline)
10923 0.77% 93.57% 10923 0.77% container/list.New (inline)
10923 0.77% 94.34% 10923 0.77% golang.org/x/net/http2.(*clientConnReadLoop).handleResponse
10923 0.77% 95.12% 10923 0.77% google.golang.org/protobuf/internal/impl.consumeStringValueValidateUTF8
10923 0.77% 95.89% 10923 0.77% k8s.io/api/core/v1.(*VolumeMount).Unmarshal
10923 0.77% 96.67% 10923 0.77% os.(*File).readdir
4681 0.33% 97.00% 16833 1.19% k8s.io/apimachinery/pkg/watch.(*StreamWatcher).receive
19 0.0013% 97.00% 21865 1.55% k8s.io/utils/internal/third_party/forked/golang/golang-lru.(*Cache).Add
0 0% 97.00% 10923 0.77% container/list.(*List).PushFront (inline)
0 0% 97.00% 380195 26.94% context.WithCancel
0 0% 97.00% 918614 65.08% context.WithDeadline (inline)
0 0% 97.00% 918614 65.08% context.WithDeadlineCause
0 0% 97.00% 918614 65.08% context.WithTimeout
...
0 0% 97.00% 918206 65.06% k8s.io/apimachinery/pkg/util/wait.PollUntilContextTimeout
... 0 0% 97.00% 918215 65.06% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).SyncPod
...
0 0% 97.00% 22742 1.61% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).syncLoopIteration
0 0% 97.00% 1309333 92.77% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).UpdatePod.func1
0 0% 97.00% 1309333 92.77% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop
0 0% 97.00% 929138 65.83% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop.func1 (inline)
0 0% 97.00% 380195 26.94% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).startPodSync
...
0 0% 97.00% 907283 64.28% k8s.io/kubernetes/pkg/kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount
```
按总堆内存使用量查看:
```
go tool pprof -top -sample_index=inuse_space kubelet_pprof_heap.pb.gz
flat flat% sum% cum cum%
86.33MB 51.28% 51.28% 115.83MB 68.80% context.(*cancelCtx).propagateCancel
29.50MB 17.52% 68.80% 29.50MB 17.52% context.(*cancelCtx).Done
29MB 17.23% 86.03% 30.54MB 18.14% context.withCancel (inline)
1.66MB 0.99% 87.02% 1.66MB 0.99% google.golang.org/grpc/mem.(*sizedBufferPool).Get
1.50MB 0.89% 87.91% 2.50MB 1.49% github.com/google/cadvisor/container/libcontainer.newContainerStats
1MB 0.59% 88.50% 1MB 0.59% reflect.unsafe_New
1MB 0.59% 89.10% 1MB 0.59% github.com/google/cadvisor/container/libcontainer.diskStatsCopy
1MB 0.59% 89.69% 1MB 0.59% k8s.io/apimachinery/pkg/util/sets.Set[go.shape.string].Insert (inline)
1MB 0.59% 90.28% 1MB 0.59% internal/bytealg.MakeNoZero
...
0 0% 91.48% 30.54MB 18.14% context.WithCancel
0 0% 91.48% 114.29MB 67.89% context.WithDeadline (inline)
0 0% 91.48% 114.29MB 67.89% context.WithDeadlineCause
0 0% 91.48% 114.29MB 67.89% context.WithTimeout
...
0 0% 91.48% 103.52MB 61.49% k8s.io/apimachinery/pkg/util/wait.PollUntilContextTimeout
...
0 0% 91.48% 104.04MB 61.80% k8s.io/kubernetes/pkg/kubelet.(*Kubelet).SyncPod
...
0 0% 91.48% 135.08MB 80.24% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).UpdatePod.func1
0 0% 91.48% 135.08MB 80.24% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop
0 0% 91.48% 104.54MB 62.10% k8s.io/kubernetes/pkg/kubelet.(*podWorkers).podWorkerLoop.func1 (inline)
...
0 0% 91.48% 103.02MB 61.19% k8s.io/kubernetes/pkg/kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount
```
尽管我以前从未查看过 kubelet 的实现,但立即发现近百万个 context 占据了其大部分内存使用量,这让我大吃一惊!这听起来就不对劲。
### Codex 找到了回归
能够跳进不熟悉的新代码库并提问,是新一代 AI 编码工具最强大的能力之一。虽然我怀疑 `kubelet/volumemanager.(*volumeManager).WaitForAttachAndMount` 这一行(也许是基于 exec 的就绪探针和存活探针出了什么问题?),但 Codex 立即将我引向了正确的问题:**Kubernetes 1.36 中的一个更改(于 2026-02-19 引入)**,其中这段代码:
```
// initialize a context for the worker if one does not exist
if status.ctx == nil || status.ctx.Err() == context.Canceled {
status.ctx, status.cancelFn = context.WithCancel(context.Background())
}
ctx = status.ctx
```
被替换成了:
```
ctx, status.cancelFn = context.WithCancel(parentCtx)
```
这段代码在每次 `startPodSync` 时都会执行,而这是每个 Pod 的核心协调循环。单独看,这行新代码似乎无害:它创建了一个新的可取消 context 并存储了取消函数。
问题在于第二次处理时会发生什么:
如果 `status.cancelFn` 已经指向了之前的取消函数,那么这个赋值会覆盖它。如果旧的取消函数从未被调用过(在典型的成功情况下它确实不会被调用),那么旧的子 context 仍然挂在其父 context 上。Go 的 context 文档(https://pkg.go.dev/context)明确指出,调用 CancelFunc 会移除父 context 对子 context 的引用,而**如果不调用它,子 context 会一直泄漏,直到父 context 被取消。**
每次 Pod 协调循环都会调用这段代码,因此在几天内,它增长到了近百万个泄漏的 context。如果是在一个更繁忙的集群上,泄漏的数量会更多!
### 报告与补丁
我以前从未向 Kubernetes 项目提交过代码,但作为新人的体验是,他们运行着非常棒的流程,能够快速分类问题并支持我合并补丁。
另一个复杂的情况是,我最初的补丁尝试通过了本地测试,但在 Kubernetes CI 环境中的 E2E 集成测试中失败了。这些测试揭示了另一个问题:处理就绪探针和存活探针的 prober worker 在使用 context 方面也不完全正确!为了解决内存泄漏问题,团队引导我简化补丁,只回滚直接导致问题的变更,将更广泛的 context 修复留待以后。
我在代码中留下了一些“小心”注释,供下一个有勇气进行更深层清理的人参考:
```
// 注意不要泄漏 context(参见 #139823)。
// 注意长期运行的 goroutine(例如 prober worker)的存活时间
// 会超过单个 startPodSync 取消 context 的生命周期。
```
### 时间线
- 2026-06-17:报告了内存泄漏问题(https://github.com/kubernetes/kubernetes/issues/139823)
- 2026-06-18:在 `startPodSync` 中找到了导致问题的代码变更
- 2026-06-18:维护者标记为 `accepted`、`important-soon`、`regression`
- 2026-06-18:提交了 PR(https://github.com/kubernetes/kubernetes/pull/139850)
- 2026-06-19:由于 prober worker 失败,简化了 PR
- 2026-06-23:维护者标记为 `lgtm`
- 2026-06-25:维护者标记为 `approved` 并合并到 `master` 分支(该分支将进入 v1.37)
- 2026-06-28:针对 `release-1.36` 分支提交了回滚 PR(https://github.com/kubernetes/kubernetes/pull/140066)(以便将其纳入 v1.36 的补丁版本)
- (尚未发布):Kubernetes v1.36.3 的补丁版本
### 经验教训
Kubernetes 团队非常棒,合作愉快。
堆内存分析是一项超能力。
内存泄漏的表现形式与过去不同。
长期运行的生产系统有一个时间维度,而短期测试往往缺乏这个维度。
有时问题不在于应用程序级别的工作负载,而在于其下的所有基础设施。
“重启一下”仍然屡试不爽。:)
---
##### 附录:一行命令检查 `kubelet` 内存使用情况
```
kubectl get --raw "/api/v1/nodes/${NODE}/proxy/metrics" | grep process_resident_memory_bytes
```
之前:
```
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 1.021227008e+09
```
执行 `systemctl restart kubelet` 之后:
```
# HELP process_resident_memory_bytes Resident memory size in bytes.
# TYPE process_resident_memory_bytes gauge
process_resident_memory_bytes 1.15019776e+08
```
从 974 MiB 下降到了 110 MiB。
相似文章
发现并修复Ghostty最大的内存泄漏
一篇详细的技术文章,介绍了诊断并修复Ghostty终端仿真器中一个重大内存泄漏的过程。该泄漏是由于在非标准页面大小下滚动缓冲区修剪逻辑错误导致的。修复已合并,将包含在即将发布的1.3版本中。
你的Rust服务没有内存泄漏——可能是分配器的问题
本文描述了一次调试过程:一个Rust服务在负载下内存持续居高不下,但并没有真正泄漏,原因是glibc的ptmalloc分配器没有将释放的内存归还给操作系统。文章解释了分配器的行为,并为Rust开发者提供了见解。
一次Linux内核零日漏洞之旅——从受限的UAF到物理内存读写
本文详细介绍了在网络调度子系统(red调度器)中发现并利用的一个Linux内核零日漏洞,将一个受限的slab释放后使用(UAF)转化为完全的物理内存读写,最终实现root权限提升。该漏洞存在了2.5年,于2026年6月被修复。
CopyFail: 从Pod到主机
Copy Fail 是一种新的 Linux 本地提权漏洞,它利用内核内存损坏缺陷重写页缓存,从而实现跨容器攻击和容器逃逸。
将 Kubernetes 扩展到 2,500 个节点
OpenAI 分享了将 Kubernetes 扩展到 2,500 个节点的基础设施经验教训,详细介绍了容器镜像拉取的优化方案,包括 kubelet 配置更改、Docker overlay2 迁移和预加载策略,以解决 Pending pod 的问题。