问题现象

这次排查的过程也同样比较有意思, 其中也涉及到了很多的知识点, 所以在这里我还是记录一下。
问题的开始仍然是监控平台告警:

关于这个监控组件我之前写过一篇文章:https://testerhome.com/articles/32478K8S , 它利用 K8S 内部的 watch 机制来订阅相关事件并判断是否出现异常。根据这个告警信息可以知道在初始化容器时挂载目标目录失败,它找不到目标目录。

初步分析

登陆到该容器所在节点查看该目录,也就是/data/kubernetes/kubelet/pods/92f2591b-66b5-4843-b7a7-42d0acd2be07/volumes/kubernetes.io~csi/dashboard-folder/mount, 我们发现这个挂载点目录是存在的。

而在这里需要说明一下在 K8S 中有很多中 volume(数据卷)类型,K8S 会把这些 volume 保存在一个固定的目录下,不同的类型的 volume 会有不同的文件夹,比如:

而在我们的场景中报错的路径是属于 kubernetes.io~csi 目录下的,这代表该 volume 是由 CSI(Container Storage Interface)维护的, CSI 类似一种插件机制, 用户可以根据 CSI 规范开发自己的存储插件并加入到 K8S 中。而这个 Pod 则正是使用了研发团队提供的 CSI 来保存数据的。 这个 CSI 是利用的是:ceph 文件存储服务以 ceph-fuse(ceph 用户态文件系统)形式挂载到节点中 ,然后 CSI 把 cpeh 中的目录挂载到容器中。相比如 hostpath,利用 CSI 可以提供更多的功能,比如权限控制,容量限制等。所以对于这个 Pod 的存储的链路为:先把 ceph 文件存储以 ceph-use 的形式挂载到机器中,然后再把 ceph 中的目录挂载到报错信息中的/data/kubernetes/kubelet/pods/92f2591b-66b5-4843-b7a7-42d0acd2be07/volumes/kubernetes.io~csi/dashboard-folder/mount 目录中(所以该报错目录本质上也是一个挂载点),然后再把该目录挂载到容器中。

根据报错信息中的 mount through procfd: no such file or directory: unknown,我猜测是某个目录不存在导致的,而刚才我根据报错目录找到了该目录确实存在,所以又去该挂载目录对应的 ceph 存储上查看,发现目录也是存在的,这就让我很困惑一时间没有了思路。于是为了排除是否 K8S 本身的问题,我使用 docker 原生的命令启动容器并挂载该目录,发现会抛出同样的错误,并且跟镜像无关(不论我使用任何镜像启动容器都会抛出同样的错误),并且如果我选择挂载其他目录的话就会一切正常。 也就是说是这个挂载点目录出现了问题,而不是 K8S 本身的问题。如下图:

runc 的排查

通过上面的排查,说明问题可能不在 K8S 甚至不在 Docker 上,问题可能出 ceph 存储本身或者底层 runc 的问题。 所以我当时先想看看 runc 有没有什么问题,runc 在用户角度看一个命令行工具,但实际上为了让容器生态更加开放,Linux 基金会发起 OCI(Open Container Initiative),目标是标准化容器格式和运行时,其中一个重要产物就是 CRI(Container Runtime Interface),抽象了容器运行时接口,使得上层调控容器更加便捷。containerd 和 runC 都是其中代表产物,从 dockerd 中剥离出 containerd,向上提供 rpc 接口,再通过 containerd 去管理 runC。containerd 在初期也是直接对 runC 进行管理,但为了解决 containerd 进行升级等操作时会造成不可用的问题,containerd 再拆出 containerd-shim,独立对接 runC。containerd 从 Runtime、Distribution、Bundle 维度提供容器全生命周期的管理能力,runC 专注于 Runtime。

所以我选择根据报错信息 mount through procfd 和 runc 在谷歌上进行查询。 最后定位到 runc 的一段代码:


根据代码注释和代码逻辑的研究,发现这段异常确实是从该代码中抛出的,并且这段代码提交是为了修复一个名为挂载逃逸的安全缺陷。在谷歌上查了一下这个挂载逃逸的问题。这里有个链接讲解了一下:https://cloud.tencent.com/developer/article/1864475?from=article.detail.1512483&areaSource=106000.12&traceId=NWIhsf2Zg3rwe3CFYDHef。当讲实话想看懂这段挺难的,尤其里面一开始还要求读者看一篇几乎不是用人话讲解的 ** 文件路径条件竞争 ** 的文章。 所以这里我简单说一下,所谓的文件路径条件竞争跟我们在编写代码时的多线程访问资源是一样的,专业术语叫 race condition。例如当线程 A 访问某个文件后,线程 B 立刻也访问了该文件并修改了文件名称等信息,但由于线程 A 已经拿到了文件的 fd 所以即便文件的名称等信息已经改变了,线程 A 仍然可以访问,但明显 A 读取到的文件已经被改变了。 所以所有的编程语言才会有锁这个东西,防止 A 在读取文件的过程中有其他线程修改文件。而这个安全漏洞就是跟这个有关系的。 为了保证容器的安全性,不能让容器随便访问宿主机中的关键文件,所以 runc 会检查容器的挂载目录,确定它的挂载是in container 而不是outside container的,而在它检查目录和进行真正的挂载之间有一个非常小的时间间隔,在这个时间间隔内用户可以用一些方式修改挂载目录的指向(根据提出该安全漏洞的工程师的描述,是使用软链接的形式可以把目录指向到宿主机的/目录上,这样在容器中就对宿主机有全部的访问权限),这就发生了一个 race condition 了。
该工程师的博客在这里:https://blog.champtar.fr/runc-symlink-CVE-2021-30465/

而回到 runc 中修复这个漏洞的代码逻辑,runc 为了修复这个安全漏洞加入了 WithProcfd 这个函数。 所有挂载目录的检查都在这个函数里进行统一的检查,尤其 runc 在挂载目录的时候会再次检查之前他获取的目录是不是被人修改过。其中它会利用/proc/self/fd/$n 来执行 mount 操作。
所以根据 runc 代码的追查和报错信息内容, 高度怀疑是在这里出的问题:

在通过/proc/self/fd/$n 这个目录执行挂载操作的时候,抛出了找不到文件的异常。

ceph-fuse 的排查

于是这个问题就更诡异了, 因为根据我们之前的排查, 不论是 ceph 目录还是挂载点目录都是存在的,并且看上面代码的抛错位置,这是已经经过了 WithProcfd 的检查了,它是根据函数返回的路径进行的挂载操作。 也就是说 WithProcfd 判断目录存在但是执行挂载的时候却抛出了目录不存在的异常。于是我又开始查看这个挂载点有什么特殊的, 创建一个新目录并通过 mount --bind 命令来把有问题的挂载点 bind 到新的目录上, 奇怪的是在这里竟然抛出了一样的错误,还是 no such file or directory。这证明了我之前怀疑 runc 出问题的思路是错的, 这是挂载点本身 就出了问题。 于是再通过 find mount 查看该挂载点信息发现了一个特殊的地方。我分别对比 了两个环境下该目录的区别(一个是正常没有异常的环境, 一个是抛出了该异常的环境)如下图:

我发现异常的环境里挂载点的 source 信息里被标记成了 deleted,那么这就是引起异常的原因了,在目录仍然存在的前提下,挂载点的 source 被标记成 deleted,这就是为什么经过了 WithProcfd 函数针对目录的检查后,仍然在挂载的时候抛出了找不到目录的原因。 所以问题出在了 ceph-fuse 上, 于是查看该节点中 ceph-fuse 的进程

这里有两个问题:

我们的研发人员暂时判断以上两个现象导致了该问题的发生, 当 ceph-fuse 重启后加上 non-empty 这个参数引发了,某些挂载点没有在 ceph-fuse 重启后恢复。所以根据这个信息,我们修改后持续观察了 2,3 天。目前还 没复现该问题。

结尾

K8S 还是相当复杂的, 排查过程中很多知识点我也不是了解的很详细。 这其中也是翻阅了很多资料。


↙↙↙阅读原文可查看相关链接,并与作者交流