开篇点题, 这其实是一次深入探索问题本质的一次排查故事,之所以想写这个,是因为这个问题的现象和最后分析出来的原因看起来有点千差万别。因为感觉排查过程可以抽象成一个通用的排查思维逻辑, 所以各位看完后可以这个抽象是否做成功了
起(问题发生)
故事的起因和大多数排查故事一样, 并没有什么特别的.就是普通的一天早上,正带着愉快心情上班时,突然被拉了一个会议,然后老板在会议中特别着急的表达了问题以及严重性,于是我也特别着急的开始了排查。
问题也是很普通,外部大客户发现一个容器里的应用无法响应请求了,特别着急的找到了我们这边。
从会议中听到的内容总结了一下, 大致是容器里的一个 server 进程没法响应 http 请求,我包括其他同学理所当然的以为容器网络可能出问题了,然后我登录到宿主机上, 按套路查看容器网络联通性,路由等,发现网络正常没有任何问题,折腾完了之后完全一脸懵,不知道到底是啥情况
承(开始排查,居然不是网络问题)
按正常套路排查没有任何结果后,我又咨询了上层应用同学关于服务的信息, 希望从用户的部署服务类型看出一点信息,上层应用同学从 k8s 集群中查看了 pod 的信息,发现是一个普通的 java 应用,参数也没有奇怪的地方.
这里简直没有头绪,我又问了一下问题出现前线上有没有做变更,结果果然有做,昨天晚上刚更新了容器引擎的版本,也就是说容器引擎被重启了.正是因为我对容器太了解了,理所当然的觉得容器引擎重启不会对已运行的容器有任何影响,所以暂时对这个线索不是很上心.
到了这,线上的排查基本结束,线上的问题只能先重新创建 pod 来解决.当时对 k8s 还不熟,我们请上层同学先尝试复现问题,然后再进行线下的排查.也多亏了一位同学线下复现出了问题,排查才又有了进展
复现时用 kubectl 查看 pod 日志时偶然发现当应用请求卡住的时候,容器的标准输出也断了.结合线上用户的 bug 案例,作出了一个简单的分析,应该是应用进程在响应用户请求时需要打印一些内容,当这个步骤卡住时,就无法继续响应请求,表面上看就是用户的请求卡住了.排查进入到这里,距离发现最终 bug 的根因就比较近了
转(定位根因)
问题的触发的条件
1. 进程要向容器标准输出打印日志
2. 容器引擎重启
问题触发是因为容器引擎重启触发的,重启后发现容器的标准输出就断了,容器里的进程也无法响应请求了,并且通过 debug 发现容器收到了 SIGPIPE 的信号.再结合容器是如何转发 stdio 到容器引擎的原理,基本上定位了原因,原来是容器用来转发 stdio 的 fifo(linux 命名管道)断了.去看了线上 shim 打开的 fifo fd 已经被关闭也确认了这点
原因定位了,但是代码 bug 还没有找出来,虽然我当时对容器非常熟,但是我对 fifo 的工作原理可非常不熟,如果当时我对 fifo 的原理了解的话,可能下午就定位出了问题,不至于用了一天的时间(这个问题后续又发生了一次,也是 fifo 的问题,但是是另外一个 bug,第二次等我自己线下复现之后,下午就定位出来了).
下面先介绍一下和问题相关的 fifo 部分的工作原理
不打开 fifo 读端或多次重新打开读端, 只写方式打开 fifo 写端, 若写入 fifo 里的数据超过缓冲区,fifo 写端报 EPIPE(Broken pipe)错误退出, 发出 SIGPIPE 的信号.如果读写方式打开 fifo 写端,就不会有这个问题
对比了问题代码,打开 fifo 的方式正是 O_WRONLY 的方式,之前没有出问题居然是因为从来没有更新过容器引擎,昨天晚上第一次更新直接触发了这个问题.困扰大家一天的问题竟然只需要改一个单词,把 O_WRONLY -> O_RDWR 就可以了
也可以用下面这段简单的代码来自行验证一下
好了,问题分析完了,下面我要开始写容器引擎接管 stdio 的原理了,对容器部分原理没有兴趣的同学可以直接跳到"合"的章节了
原理解析(容器创建原理及接管 stdio)
稍微提一下,出问题的不是 runc 容器,是 kata 安全容器, runc 容器毕竟用的多 bug 也比较少了
以下原理解析我都以 pouch(https://github.com/alibaba/pouch)+ containerd(https://github.com/containerd/containerd)+ runc(https://github.com/opencontainers/runc.git)的方式来做分析
从低向上容器 1 号进程 IO 的流转
进程的 stdio 指向 pipe 一端 -> shim 进程打开的 pipe 另一端 ->shim 进程打开的 fifo 写端 -> pouch 打开的 fifo 写端 -> pouch 指定的 IO 输出地址,默认是 json 文件
容器 IO 的创建和是否需要 terminal,是否有 stdin 有关系,为了简单起见,我们下面的流程介绍都是后台运行一个容器为例来讲解,即只会创建容器的 stdout 和 stderr,用 pouch 命令来表述,就是执行下面的命令后,如何从 pouch logs 看到进程的日志输出
pouch run -d nigix
pouch 创建容器与初始化 IO
简单介绍一下 pouch 创建容器的流程,pouch 和 dokcer 一样,也是基于 containerd 去管理容器的,即 pouch 启动会拉起一个 containerd 进程,pouch 发起各种容器相关的请求时,通过 grpc 和 containerd 通信,containerd 收到请求后,调用对应的 runtime 接口操作容器,这里的 runtime 可以有很多类型,大家最常用的就是 runc 了,当然也可以是上方案例中的 kata 安全容器,你也可以按照 oci 标准自己实现一个自己 runtime,这是题外话了.
pouch 调用 containerd 的 NewTask 接口发起一个创建容器命令,这个函数的第二个参数是初始化 IO 的函数指针,看一下代码,https://github.com/alibaba/pouch/blob/master/ctrd/container.go#L677-L685
初始化 IO,也就是创建 fifo 并打开 fifo 读端的函数在 NewTask 执行的第一行就会被调用
pouch 也是调用 containerd 中的 cio 包去打开 fifo 的,pouch 指定了 fifo 路径,最终调用 containerd 中的 fifo 包去创建并打开 fifo, 用的图中的 fifo.OpenFifo 函数
看一下图中的代码,cio 包里代码是只读阻塞的模式(虽然 flags 传了 NONBLOCK,但是在 fifo 包里会被去掉)打开 stdout 和 stderr2 个 fifo 的,pouch 打开 2 个 fifo 后,会开始拷贝 2 个容器 IO 流,io.Copy 的读端是 fifo 的输入,写端是可以自定义的,写端可以是 json 文件,syslog 或其他.换个说法,这里的写端就是容器引擎配置的 log-driver
这里打开 fifo 的部分要注意一下,containerd fifo 包封装了整个流程,和直接调用是不一样的,最直接看出不同的地方就是打开文件的个数,重新放一张上面案例中发过的示例图,代码里对 stdout 和 stderr2 个 fifo 文件只打开了一次,但是这个 fd 显示文件被打开了 2 次,这是因为 fifo 包里对 fifo 的处理加了一层,打开了 2 次,第一次打开的是 fifo 文件,即下面的路径,第二次按参数指定的 flag 打开了第一次打开的 fd 文件, 即/proc/self/fd/22.
之所以打开 2 次是为了 fifo 文件在物理上被删除后,内存中打开的 fd 也可以被关闭
containerd 创建容器与初始化 IO
还是先介绍一下 containerd 创建容器的大致原理,其实这里还有一个 shim 进程,准确来说,shim 是实际管理容器进程,也就是说 shim 是容器 1 号进程的父进程,containerd 和 shim 之间通过 ttrpc 交互(ttrpc 是 containerd 社区实现的低内存占用的 grpc 版本),containerd 收到创建容器请求时,会创建一个 shim 进程,然后通过 ttrpc 发送后续的相关请求.
shim 创建容器的同时会初始化容器 IO,相关代码可以看一下这几个文件,https://github.com/containerd/containerd/tree/master/pkg/process
shim 先创建 os.Pipe,因为这个容器只需要 stdout 和 stderr,所以这里只会创建 stdout 和 stderr 的 2 个 pipe,作用是其中一端用来作为容器 1 号进程的输入和输出,另一端输出到 pouch 创建的 fifo 里, 这样 pouch 就读到了容器进程的标准输出
看一下下面这张图,cmd 封装了 shim 调用 runccreate, cmd 的 stdio 就是容器进程的 stdio, 这里的原因在第 3 步 runc 创建容器里细讲
调用 runc create 返回后,shim 开始拷贝容器 IO 到 pouch 创建的 fifo 里,代码在这里,https://github.com/containerd/containerd/blob/master/pkg/process/io.go#L135-L232
下面这张图是拷贝 stdout 的 IO 流的逻辑, 拷贝 stderr 也类似,rio.Stdout() 是上面 shim 创建的 pipe 的另外一端
看一下 rio.Stdout() 函数就知道了
i.out.w 作为 cmd 的 STDOUT,就是说容器进程输出到 i.out.w,pipe 的另一端 i.out.r 读到数据,再把数据拷贝到 fifo 里,wc 是只写方式打开的 pouch fifo 文件,结合第一步里的过程, pouch 读方式打开的 fifo 读到这里的输入数据,就拿到了容器进程输出
看一下 shim 进程打开的 fd,发现 stdout 和 stderr fifo 都打开了 2 次,这是因为不打开 fifo 读端或多次重新打开读端, 只写方式打开 fifo 写端, 若写入 fifo 里的数据超过缓冲区,fifo 写端报 EPIPE (Broken pipe)错误退出,所以这里分别用读写方式打开了 2 次 fifo
runc 创建容器与初始化 IO
这里是最后一个创建容器的步骤, containerd 调用实际的容器运行时创建容器,我以大家最常用的 runc 来做介绍
这里插一句,案例里的 kata 安全容器也是一种 OCI 标准的运行时,简单来说安全容器就是有自己的内核,不和宿主机共享内核,这样才是安全可靠的.kata 是基于 qemu 来做, 可以理解他有 2 层,第一层在宿主机上,和 qemu 以及 qemu 里的进程交互,第二层在 qemu 里,接收第一层发来的请求,实际完成的代码就是封装了 runc 的 libcontainer.所以 kata 的 stdio 相比于 runc 多转发了一次
同样我先简单概括一下 runc 创建容器的流程,shim 创建容器需要调用 2 次 runc,第一次是 runccreate,这个命令完成后,容器的用户进程还没有被拉起,runc 启动了一个 init 进程,这个 init 进程把容器启动的所有准备都做完, 包括切换 ns,cgroup 隔离,挂载镜像 rootfs, volume 等,runc init 进程最后会向一个 fifo(和 pouch fifo 没有关系, runc 自己用的一个 fifo 文件)写 0,在 0 被读取出来之前 runc init 会一直 hang 着
shim 的第二次调用是 runcstart,runc start 做的工作很简单,从 fifo 中读出数据,这时 hang 住的 runc init 会往下执行,调用 execve 加载用户进程, 这时容器的用户进程才开始运行
在介绍 runc 创建容器 IO 之前,我们先看一下容器进程的 stdio 的 fd 指向吧,因为没有标准输入,所以进程 0 号 fd 是指向/dev/null 的,1 号和 2 号 fd 分别指向了一个 pipe,这个 pipe 就是第二步里 shim 创建的 pipe
可以打开 shim 进程的 proc 文件确认一下, 13 和 15 号 fd 打开的 fd 号是和容器进程打开的 2 个 pipe 是一样的, 说明 2 个进程打开的是同样的 pipe
上面说到 runc 启动的第一个进程是 runc init, 启动进程的流程同样也是封装了一个 cmd 命令,cmd 的 stdio 是指向 process 的 stdio
这个 process 在 runc 中代表了 init 或 exec 的进程,当容器不需要 tty 时,runc 把 process 的 stdio 设置为继承自身的 stdio,图中的 os.Stdin/os.Stdout/os.Stderr 指的是本进程的 0,1,2 fd
所以当真正的容器进程启动的时候自然也继承了 runc init 的 stdio
合(后记)
看起来是个网络问题,最后发现是一个 fifo 的问题,但是循序渐进的分析下来,感觉一切都是合情合理的
类似排查网络问题的套路一样,问题排查一样也有套路(抽象方法)可循,也看过这方面的总结,但还是写下自己的理解,当套路被压缩到极致之后,就变成了高大上的逻辑思维方式
1. 详细分析问题出现的现象,问题进程的大致工作流程,问题触发的条件
2. 不要凭经验判断哪些组件不会出问题,详细分析组件日志和代码,尤其不要对任何代码有敬畏之心(不敬畏,但是尊重所有代码),尤其不要认为内核,系统库都是基本稳定的
3. 问题链路上涉及的原理最好都去学习熟悉
希望上述排查思路可以给其他同学定位问题时带来灵感