阅读 64

Kubernetes 中网站无法访问,深入排查实战

开篇点题, 这其实是一次深入探索问题本质的一次排查故事,之所以想写这个,是因为这个问题的现象和最后分析出来的原因看起来有点千差万别。因为感觉排查过程可以抽象成一个通用的排查思维逻辑, 所以各位看完后可以这个抽象是否做成功了

起(问题发生)

故事的起因和大多数排查故事一样, 并没有什么特别的.就是普通的一天早上,正带着愉快心情上班时,突然被拉了一个会议,然后老板在会议中特别着急的表达了问题以及严重性,于是我也特别着急的开始了排查。

问题也是很普通,外部大客户发现一个容器里的应用无法响应请求了,特别着急的找到了我们这边。

从会议中听到的内容总结了一下, 大致是容器里的一个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(github.com/alibaba/pou…)+ containerd(github.com/containerd/…)+ runc(github.com/opencontain…)的方式来做分析

从低向上容器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的函数指针,看一下码,github.com/alibaba/pou…

看一下图中的代码,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,相关代码可以看一下这几个文件,github.com/containerd/…
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里,代码在这里,github.com/containerd/…

下面这张图是拷贝stdout的IO流的逻辑, 拷贝stderr也类似,rio.Stdout() 是上面shim创建的pipe的另外一端

看一下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

所以当真正的容器进程启动的时候自然也继承了runc init的stdio

合(后记)

看起来是个网络问题,最后发现是一个fifo的问题,但是循序渐进的分析下来,感觉一切都是合情合理的

类似排查网络问题的套路一样,问题排查一样也有套路(抽象方法)可循,也看过这方面的总结,但还是写下自己的理解,当套路被压缩到极致之后,就变成了高大上的逻辑思维方式

  1. 详细分析问题出现的现象,问题进程的大致工作流程,问题触发的条件

  2. 不要凭经验判断哪些组件不会出问题,详细分析组件日志和代码,尤其不要对任何代码有敬畏之心(不敬畏,但是尊重所有代码),尤其不要认为内核,系统库都是基本稳定的

  3. 问题链路上涉及的原理最好都去学习熟悉


作者:PHP开源社区
链接:https://juejin.cn/post/7018008001416003591


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐