修复 Kubernetes 1.36 中 kubelet 的内存泄漏

Hacker News Top 新闻

摘要

详细记录追踪并修复 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最大的内存泄漏

Mitchell Hashimoto

一篇详细的技术文章,介绍了诊断并修复Ghostty终端仿真器中一个重大内存泄漏的过程。该泄漏是由于在非标准页面大小下滚动缓冲区修剪逻辑错误导致的。修复已合并,将包含在即将发布的1.3版本中。

你的Rust服务没有内存泄漏——可能是分配器的问题

Lobsters Hottest

本文描述了一次调试过程:一个Rust服务在负载下内存持续居高不下,但并没有真正泄漏,原因是glibc的ptmalloc分配器没有将释放的内存归还给操作系统。文章解释了分配器的行为,并为Rust开发者提供了见解。

CopyFail: 从Pod到主机

Hacker News Top

Copy Fail 是一种新的 Linux 本地提权漏洞,它利用内核内存损坏缺陷重写页缓存,从而实现跨容器攻击和容器逃逸。

将 Kubernetes 扩展到 2,500 个节点

OpenAI Blog

OpenAI 分享了将 Kubernetes 扩展到 2,500 个节点的基础设施经验教训,详细介绍了容器镜像拉取的优化方案,包括 kubelet 配置更改、Docker overlay2 迁移和预加载策略,以解决 Pending pod 的问题。