一次 Rancher 内存占用过高问题排查

我们使用 Rancher、k3s 来跑边缘计算的集群,但 Rancher 的 agent 隔一段时间就内存占用过高,达到十几个 G,被 linux oom-kill 杀掉。

一个问题其实出现了挺长时间了,一直没有去分析。Rancher 其实挺复杂的,代码咱也不懂,就通过典型的问题定位手段来一步步分析和解决。

分析过程

内存占用过高,本能就是先通过 dump 类的工具分析内存中到底存储的是什么。go 比较先进的是自身集成了厉害的分析工具 PProf,可以非常方便的分析 cpu、内存、goroutine、Mutex 相关的数据。

shell 复制代码
curl http://127.0.0.1:6060/debug/pprof/heap > heap.out
go tool pprof -http=:6001 heap.out

通过看火焰图,可以看到跟 rancher 子项目 steve 有关。

通过打日志,进一步确认内存分配的函数调用栈

bash 复制代码
runtime/debug.Stack()
        /opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:24 +0x65
runtime/debug.PrintStack()
        /opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:16 +0x19
github.com/rancher/steve/pkg/stores/metrics.ResourceClientWithMetrics.List(...)
        steve/pkg/stores/metrics/metrics_client.go:62
github.com/rancher/steve/pkg/stores/proxy.(*Store).list(0xc000ae69b0?, 0x16?, 0xc000f773b0?, {0x0?, 0x0?})
        steve/pkg/stores/proxy/proxy_store.go:248 +0x5b
github.com/rancher/steve/pkg/stores/proxy.(*Store).List(0xc0008a52c0, 0xc002a34780, 0x0?)
        steve/pkg/stores/proxy/proxy_store.go:237 +0xb1
github.com/rancher/steve/pkg/stores/proxy.(*byNameOrNamespaceStore).List(0xc0030c5bc0?, 0x406e819?, 0x5?)
        steve/pkg/stores/proxy/rbac_store.go:106 +0x77
github.com/rancher/steve/pkg/stores/partition.(*Store).listPartition(0x4a1c8a?, {0x498f878, 0xc000b3a190}, 0xc002a34690, 0x3df2dc0?, {0x4964f40?, 0x6ddda40?}, {0x0, 0x0}, {0x0, ...}, ...)
        steve/pkg/stores/partition/store.go:150 +0x253
github.com/rancher/steve/pkg/stores/partition.(*Store).List.func1({0x498f878?, 0xc000b3a190?}, {0x4964f40?, 0x6ddda40?}, {0x0?, 0x3cf9301?}, {0x0?, 0x49f1ff?}, 0x498f920?)
        steve/pkg/stores/partition/store.go:167 +0x78
github.com/rancher/steve/pkg/stores/partition.(*ParallelPartitionLister).feeder.func2()
        steve/pkg/stores/partition/parallel.go:179 +0x1fa
golang.org/x/sync/errgroup.(*Group).Go.func1()
        /opt/homebrew/Cellar/go/1.19/pkg/mod/golang.org/x/sync@v0.1.0/errgroup/errgroup.go:75 +0x64
created by golang.org/x/sync/errgroup.(*Group).Go
        /opt/homebrew/Cellar/go/1.19/pkg/mod/golang.org/x/sync@v0.1.0/errgroup/errgroup.go:72 +0xa5

通过看代码发现了可疑对象,它会把从 PartitionLister 拿回的数据缓存到 Store 的成员变量 listCache 中

这个 listCache 是一个 LRU cache,看代码会把结果缓存 30 分钟。

go 复制代码
type Store struct {
	Partitioner Partitioner
	listCache   *cache.LRUExpireCache
	asl         accesscontrol.AccessSetLookup
}

经过测试,这个结果集比较大,有几百 k 到几 M 的大小,默认大小为 1000 个。

首先先通过增加一个环境变量把缓存关掉 CATTLE_REQUEST_CACHE_DISABLED=true,经过这样的处理以后,内存增长过快的问题得到了缓解。

通过 dump 改动之前的内存,可以同步印证这个做法。

bash 复制代码
# dump.sh

cat /proc/$1/maps | grep -Fv ".so" | grep " 0 " | awk '{print $1}' | grep $2 | ( IFS="-"
while read a b; do
dd if=/proc/$1/mem bs=$( getconf PAGESIZE ) iflag=skip_bytes,count_bytes \
skip=$(( 0x$a )) count=$(( 0x$b - 0x$a )) of="$1_mem_$a.bin"
done )

$ ./dump.sh pid addr

$ ls -l
39.mem_c00a800000.bin

通过 strings 39.mem_c00a800000.bin 可以提取内存 dump 文件中的字符串文件,可以看到大量的 json 结果。

Goroutine 协程泄露

解决完这个问题,可以发现内存增长明显变慢,但还是有缓存的增长,通过内存分析,看到内存都集中在了 io.CopyBuffer

这个 io.CopyBuffer 是在一个新启动的 go 协程中执行的,看下 go 协程的分析。

可以看到有一个明显看起来不合理的 remotedialer 模块的 go 协程数量加起来有接近 12000 个,于是去分析它的协程是如何创建的,为什么没有及时退出。

通过在创建协程的位置打印堆栈,可以看到是由 steve 模块的 aggregation.serve 在一直不停的创建协程,而且通过更详细日志的分析,这个协程是不会退出的。

bash 复制代码
/opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:24 +0x65
runtime/debug.PrintStack()
/opt/homebrew/opt/go/libexec/src/runtime/debug/stack.go:16 +0x19
github.com/rancher/remotedialer.(*Session).serveMessage
remotedialer/session.go:131 +0x56
github.com/rancher/remotedialer.(*Session).Serve
remotedialer/session.go:124 +0xea
github.com/rancher/steve/pkg/aggregation.serve
steve/pkg/aggregation/server.go:94 +0x53e
github.com/rancher/steve/pkg/aggregation.ListenAndServe
steve/pkg/aggregation/server.go:48 +0x415
created by github.com/rancher/steve/pkg/aggregation.(*handler).OnSecret
steve/pkg/aggregation/watch.go:63 +0x31d

这段代码功能还有待深入研究,但这里的协程持续运行没有什么必要,这里的协程创建由 http 接口触发,但 http 短连接结束以后,这个协程并没有退出。

临时改动

于是做了一个简单的实验性改动验证,把这个协程 2s 以后强制断开。

重新构建发布,发现协程数量稳定了下来,没有持续上涨,而且内存经过长时间运行一直稳定在了 170M 左右。

后续改动计划

接下来就是分析代码真正的作用,做出更合理的改造,还在持续分析中。

后记

分析内存泄露最重要的是能先观测、得到到底哪里是分配内存、哪里在持有内存。各大语言都有相应的分析的工具,java 有 jmap、rust 有 jemalloc 的 jeprof。

对于 Go 除了内置的 PProf,还可以基于 ebpf 去观测 go 内置的函数,比如 runtime.newObject 等获取内存分配的链路和火焰图,有机会再写一个这个相关的文章。

相关推荐
代码什么用44 分钟前
Spring基础使用
java·后端·spring
明月_清风1 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
蜗牛互联网2 小时前
Java 17 HttpClient调用文件转写API的超时与失败回退
java·人工智能·后端
Dawson Zhu3 小时前
《Agentic Design Patterns》第 9 章导读:学习与适应(Learning and Adaptation)
人工智能·语言模型·架构·aigc·agi
用户69371750013844 小时前
2026,程序员的时代拐点到了
android·前端·后端
姜鱼问生4 小时前
Linux 日志增量统计:inode + offset 方案(不丢不重)
架构
惜鸟4 小时前
从源码看 pi 的上下文管理:原始数据永久保留,模型视角按需裁剪
后端
软件工程师_罗小东4 小时前
我把这套 AI 落地方案,讲成一条任务闭环
架构
量化分析码农4 小时前
【Python量化策略评估体系 #05】极端行情扛得住吗?2008/2015/2020 三场景压力测试
后端
十年Java程序媛4 小时前
Java 接口和抽象类对比|Java8 新特性,抛弃老旧八股,正确选型
java·spring boot·后端