背景 🔗
一次生产环境中,发现 kubelet 内存达到上 GB 以上,这个不符合平常使用情况,首先想到的是内存泄漏,那么肯定使用 pprof 以及调查为什么会产生内存泄漏
profile 🔗
基础 🔗
- cpu
/debug/pprof/profile
,主要分析耗时和优化算法,得到 profile 文件 - heap:
/debug/pprof/heap
,查看活动对象的内存分配情况,得到 profile 文件 - threadcreate:
/debug/pprof/threadcreate
, 线程创建概况报告程序中导致创建新的操作系统线程的部分 - goroutine: 报告所有当前 goroutine 的堆栈信息,没有 profile 文件
- trace:
/debug/pprof/trace
当前程序的执行跟踪,go tool trace 中使用
kubelet 🔗
查看平台的内存使用情况,如下 注:(以下数据非当时环境,是之后重新复现后取的,比发生泄漏环境要低的多)
# top
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
31599 root 20 0 2877396 983.5m 66424 S 4.3 6.2 28:00.93 kubelet
kubelet 数据默认打开 pprof, 通过 pprof 拿到数据 , 通过以下命令方式
# pprof -tls_ca {cafile} -tls_key {keyfile} -tls_cert {certfile}
https://{host}:10250/debug/pprof/heap
拿到数据后,还可以拿到正常节点的数据做对比,如下
正常 kubelet 内存使用
不正常 kubelet 内存使用
基础 🔗
描述下 kubelet 当前目录和会涉及的代码片段
pkg/kubelet/
├── cri #cri接口
├── server # http hanlder入口
├── stats # 容器 cpu/memory,filesystem info 抓取
├── kuberuntime # 桥接容器运行时 和 kubelet操作
└── cm #container manager缩写,控制器内容包括 cgroup,topology,device等
可以直接跨越到 cri/ 内查看 当前接口实现,未设置超时时间
func (r *remoteRuntimeService) ListContainerStats(filter *runtimeapi.ContainerStatsFilter) ([]*runtimeapi.ContainerStats, error) {
// Do not set timeout, because writable layer stats collection takes time.
// TODO(random-liu): Should we assume runtime should cache the result, and set timeout here?
ctx, cancel := getContextWithCancel()
defer cancel()
// 这里未设置超时时间
resp, err := r.runtimeClient.ListContainerStats(ctx, &runtimeapi.ListContainerStatsRequest{
Filter: filter,
})
当前调用链大致如下 , 可以看到及时客户端退出,但是 kubelet 到 containerd 的连接还是会保持,直到拿到数据为止
那么考虑就是如何复现这个问题,但通过测试代码 [1] 并未复现问题,这里其实一直是阻塞调查的地方,那么不妨换个思路,现继续挖下为什么 containerd 没有返回数据呢?
到这里,其实可以发现 kubelet 都集中在 stats.ListPodStats
花费上,该调用会最终到 containerd 的 Stats 接口上,那么应该继续分析 containerd ,这应该是产生问题的根本原因
containerd 🔗
通过抓取 heap 和 goroutine 信息分析,而我们环境中 containerd 的 debug socket 已经打开,所以比较方便拿到 containerd profile 信息
# ctr pprof heap > heap.profile
# ctr pprof goroutines > goroutine.profile
拿到数据后,还可以拿到正常节点的数据做对比
正常 containerd 内存使用
不正常 containerd 内存使用
看到的是 SPDY 内存消耗较多,我们知道 SPDY 是 HTTP/2 前身,主要用于流式连接,当前主要是 接口 exec, attach, portfoward 在接口上,以下是简单对 exec 的代码理解 , 然后使用了 exec 确能稳定复现问题,复现代码如下
#!/bin/bash
i=0
NUM=$1
while [ "$i" -lt $NUM ]; do
(kubectl exec -i test-74cf75b654-gw5hk -- ls /opt)&
let "i += 1"
done
exec 流程 🔗
流程需要从 kubelet 开始分析 , 通过前文中对 kubelet 目录的简单介绍,那么入口肯定是在 server 目录内,直接到主题 Exec,对应函数是 getExec(request *restful.Request, response *restful.Response)
, 行为简单描述如下
- 校验参数,通常 stdout/stderr 为 true,目前常用的是 -it
,也就是需要配置 stdin 和 ttry 是否为 true
- 查询 pod 当前是否支持 exec
- 执行 GetExec 获取 url
- 创建代理,转发 apiserver 数据 stdin 和 stdout
cri 服务 主要分为两部分,重点描述 ServerExec
- GetExec:创建 url 路由,增加 token host:port/exec/{token}
- ServerExec: 创建 task 和 process,并绑定标准输入和输出等
ServerExec
- 升级 http 为 SPDY stream,看上去 spdy 是比较重,因为要至少建立三个 goroutine
handler.waitForStreams(streamCh, expectedStreams, expired.C)
- 执行 Exec (在 streamRuntime 内),等待 process 结束,或者上游退出
//创建Task, task实际是 containerd/containerd/task.go 类型
task, err := container.Task(ctx, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to load task")
}
pspec := spec.Process
pspec.Args = opts.cmd
pspec.Terminal = opts.tty
if opts.tty {
oci.WithEnv([]string{"TERM=xterm"})(ctx, nil, nil, spec)
}
volatileRootDir := c.getVolatileContainerRootDir(id)
var execIO *cio.ExecIO
// 创建process, 实际是 containerd/containerd/process.go 类型
process, err := task.Exec(ctx, execID, pspec,
func(id string) (containerdio.IO, error) {
var err error
execIO, err = cio.NewExecIO(id, volatileRootDir, opts.tty, opts.stdin != nil)
return execIO, err
},
)
// 获取 process 退出管道
exitCh, err := process.Wait(ctx)
err := process.Start(ctx)
// 将execIO 和 http 流绑定
// 内部会创建协程 stdin ,stdout, waitGroup
attachDone := execIO.Attach(cio.AttachOptions{
Stdin: opts.stdin,
Stdout: opts.stdout,
Stderr: opts.stderr,
Tty: opts.tty,
StdinOnce: true,
CloseStdin: func() error {
return process.CloseIO(ctx, containerd.WithStdinCloser)
},
})
select {
case <-execCtx.Done():
case exitRes := <-exitCh:
}
这里是阻塞在调用 exec 上,但是和 cpuAndMemoryStats 并无关系,如果是有关系,那么也只有在 shim 这一级,因为都要调用 shim 的 rpc 接口
shim 🔗
当前 runc 使用的 shim 是 containerd-shim-runc-v2, 在内置的 shim server 上有一个方便调试的方式,发送 USER1 信号 可以获取 goroutines 信息,具体代码如下
// runtime/v2/shim/shim_unix.go
func setupDumpStacks(dump chan<- os.Signal) {
signal.Notify(dump, syscall.SIGUSR1)
}
// 发送 USER1 信号
kill -10 {pid}
// 获取containerd 日志,获取BEGIN goroutine stack dump 日志
journalctl -eu containerd >contaienrd.log
查看 shim 的 goroutines 信息,因为和 ttrpc 有关,那直接找 ttrpc 信息吧,可以发现以下信息,这里显然不应该有那么多 goroutine hang 在 vendor/github.com/containerd/ttrpc/server.go:444
行,那么继续看下 ttrpc 有关实现呢
$ cat shim.goroutine.profile |grep ttrpc/server | sort |uniq -c |sort
1 vendor/github.com/containerd/ttrpc/server.go:362 +0x149
1 vendor/github.com/containerd/ttrpc/server.go:404 +0x5ee
1 vendor/github.com/containerd/ttrpc/server.go:431 +0x41a
1 vendor/github.com/containerd/ttrpc/server.go:459 +0x6bd
1 vendor/github.com/containerd/ttrpc/server.go:87 +0x107
2 vendor/github.com/containerd/ttrpc/server.go:127 +0x2a7
2 vendor/github.com/containerd/ttrpc/server.go:332 +0x2ce
6 vendor/github.com/containerd/ttrpc/server.go:438 +0xf2
164 vendor/github.com/containerd/ttrpc/server.go:444 +0x245
169 vendor/github.com/containerd/ttrpc/server.go:434 +0x63f
ttrpc 🔗
reqCh, respCh, msgCh 均为非缓存 channel
服务端大致如下
- 每创建一个连接, 都会产生 2 个协程来处理会话
- recv 协程: 从客户端读取数据,并校验合法性写入 Channel 中,交给 worker 来处理
- worker 协程:收到请求后,并发创建协程调用注册的服务
1.0.1 版本 客户端大致如下
- 和服务端类似,工作协程同时处理接收和发送请求,
- 重点是 发送和接收是在一个协程中处理,并通过内部 waitCall 来同步数据
发生死锁的情况是 客户端 阻塞在 send 过程,此时因为无法处理返回的 Resp 信息,继而导致服务端的应答数据阻塞在 net write buffer 中
什么情况会导致 send 阻塞,网络发送过程有以下情况 , 进程将数据拷贝到内核缓存区,之后由软中断发送出去,该控制写缓存大小为 tcp_wmem, 内核参数配置为 4096 16384 4194304
针对上述导致死锁的情况,有一个相关 patch[2] 解决,该 patch 修改方式如下图
1.1.0 版本 客户端
发送和接收 都通过不同的协程处理,不再出现竞争情况发生
参考 🔗
[1]. https://gist.github.com/yylt/0d3f2d554fa7eddd9cafe406ef0c9d75 [2]. https://github.com/containerd/ttrpc/pull/94