查看原文
其他

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

点击关注 👉 DevOps技术栈 2022-11-10
原文链接:https://baijiahao.baidu.com/s?id=1705314379669553507
开篇点题, 这其实是一次深入探索问题本质的一次排查故事,之所以想写这个,是因为这个问题的现象和最后分析出来的原因看起来有点千差万别。因为感觉排查过程可以抽象成一个通用的排查思维逻辑, 所以各位看完后可以这个抽象是否做成功了



起(问题发生)


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

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

从会议中听到的内容总结了一下, 大致是容器里的一个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.   问题链路上涉及的原理最好都去学习熟悉

希望上述排查思路可以给其他同学定位问题时带来灵感

- END -

 推荐阅读 

Kubernetes实战指南:从零到架构师的进阶之路 Nginx 常用配置清单
Prometheus + Thanos 多集群架构监控Jenkins 流水线自动化部署 Go 项目最强整理!常用正则表达式速查手册
运维的工作边界,这次真的搞明白了!
Linux 这些工具堪称神器!
Prometheus + Granafa 构建高大上的MySQL监控平台
搭建一套完整的企业级 K8s 集群(v1.20,kubeadm方式)
12年资深运维老司机的成长感悟



点亮,服务器三年不宕机

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存