做者李志宇,腾讯云后台开发工程师,平常负责集群节点和运行时相关的工做,熟悉 containerd、docker、runc 等运行时组件。近期在为某位客户提供技术支持过程当中,遇到了 containerd 镜像丢失文件问题,通过一系列分析、推断、复现、排查,最终成功找到根因并给出解决方案。现将整个详细处理过程整理成文分享出来,但愿可以为你们提供一个有价值的问题处理思路以及帮助你们更好地理解相关原理。git
近期有客户反映某些容器镜像出现了文件丢失的奇怪现象,通过模拟复现汇总出丢失状况以下:github
某些特定的镜像会稳定丢失文件;docker
“丢失”在某些发行版稳定复现,但在 ubuntu 上不会出现;json
v1.2 版本的 containerd 会文件丢失,而 v1.3 不会。ubuntu
经过阅读源码和文档,最终解决了这个 containerd 镜像丢失问题,并写下了这篇文章,但愿和你们分享下解决问题的经历和镜像生成的原理。为了方便某些心急的同窗,本文接下来将首先揭晓该问题的答案~数组
因为内核 overlay 模块 Bug,当 containerd 从镜像仓库下载镜像的“压缩包”生成镜像的“层”时,overlay 错误地把trusted.overlay.opaque=y这个 xattrs 从下层传递到了上层。若是某个目录设置了这个属性,overlay 则会认为这个目录是不透明的,以致于在进行联合挂载时该目录将会把下面的目录覆盖掉,进而致使镜像文件丢失的问题。bash
这个问题的解决方案能够有两种,一种简单粗暴,直接升级内核中 overlay 模块便可。网络
另一种能够考虑把 containerd 从 v1.2 版本升级到 v1.3,缘由在于 containerd v1.3 中会主动设置上述 opaque 属性,该版本 containerd 不会触发 overlayfs 的 bug。固然,这种方式是规避而非完全解决 Bug。app
虽然根本缘由看起来比较简单,但分析的过程仍是比较曲折的。在分享下这个问题的排查过程和收获以前,为了方便你们理解,本小节将集中讲解问题排查过程涉及到的 containerd 和 overlayfs 的知识,比较了解或者不感兴趣的同窗能够直接跳过。函数
与 docker daemon 一开始的设计不一样,为了减小耦合性,containerd 经过插件的方式由多个模块组成。结合下图能够看出,其中与镜像相关的模块包含如下几种:
content 是负责保存 blob 的模块,其保存的关于镜像的内容通常分为三种:
容器镜像规范主要有 docker 和 oci v一、v2 三种,考虑到这三种规范在原理上大同小异,能够参考如下示例,将 manifest 看成是每一个镜像只有一份的元信息,用于指向镜像的 config 和每层 layer。其中,config 即为镜像配置,把镜像做为容器运行时须要;layer 即为镜像的每一层。
type manifest struct { c config layers []layer }
镜像下载流程与图 1 中数字标注出来的顺序一致,每一个步骤做用总结以下:
首先在 metadata 模块中添加一个 image,这样咱们在执行 list image 时可看到这个 image。
其次是须要下载镜像,由于镜像是有 manifest、config、layers 等多个部分组成,因此先下载镜像的 manifest 并保存到 content 模块,再解析 manifest 获取 config 的地址和 layers 的地址。接下来分别把 config 和每一个 layer 下载并保存到 content 模块,这里须要强调镜像的 layer 原本应该是目录,当建立容器时联合挂载到 root 下,可是为了方便网络传输和存储,这里会用 tar + 压缩的方式保存。这里保存到 content 也是不解压的。
③、④、⑤的做用关联性比较强,此处放在一块儿解释。snapshot 模块去 content 模块读取 manifest,找到镜像的全部层,再去 content 模块把这些层自“下”而“上”读取出来,逐一解压并加工,最后放到 snapshot 模块的目录下,像图 1 中的 1001/fs、1002/fs 这些都是镜像的层。(当建立容器时,须要把这些层联合挂载生成容器的 rootfs,能够理解成1001/fs + 1002/fs + ... => 1008/work)。
整个流程的函数调用关系以下图 2,喜欢阅读源码的同窗能够照着这个去看下。
为了方便理解,接下来用 layer 表示 snapshot 中的层,把刚下载未通过加工的“层”称之为镜像层的 tar 包或者是 tar 包。
下载镜像保存入 content 的流程比较简单,直接跳过就好。而经过镜像的 tar 包生成 snapshot 中的 layer 这个过程比较巧妙,甚至 bug 也是出如今这里,接下来进行重点描述。
首先经过 content 拿到了镜像的 manifest,这样咱们得知镜像是有哪些层组成的。最下面一层镜像比较简单,直接解压到 snapshot 提供的目录就能够了,好比 10/fs。假设接下来要在 11/fs 生成第二层(此时 11/fs 仍是空的),snapshot 会使用mount -t overlay overlay -o lowerdir=10/fs,upperdir=11/fs,workdir=11/work tmp把已经生成好的 layer 10 和还未生成的 layer 11 挂载到一个 tmp 目录上,其中写入层是 11/fs 也就是咱们想要生成的 layer。去 content 中拿到 layer 11 对应的 tar 包,遍历这个 tar 包,根据 tar 包中不一样的文件对挂载点 tmp 进行写入或者删除文件的操做(由于是联合挂载,因此对于挂载点的操做都会变成对写入层的操做)。把 tar 包转化成 layer 的具体逻辑和下面通过简化的源码一致,能够看到若是 tar 包中存在 whiteout 文件或者当前的层好比 11/fs 和以前的层有冲突好比 10/fs,会把底层目录删掉。在把 tar 包的文件写入到目录后,会根据 tar 包中记录的 PAXRecords 给文件添加 xattr,PAXRecords 能够看作是 tar 中每一个文件都带有的 kv 数组,能够用来映射文件系统中文件属性。
// 这里的tmp就是overlay的挂载点 applyNaive(tar, tmp) { for tar.hashNext() { tar_file := tar.Next() // tar包中的文件 real_file := path.Join(root, file.base) // 现实世界的文件 // 按照规则删除文件 if isWhiteout(info) { whiteRM(real_file) } if !(file.IsDir() && IsDir(real_file)) { rm(real_file) } // 把tar包的文件写入到layer中 createFileOrDir(tar_file, real_file) for k, v := range tar_file.PAXRecords { setxattr(real_file, k, v) } } }
须要删除的这些状况总结以下:
若是存在同名目录,二者进行 merge
若是存在同名但不都是目录,须要删除掉下层目录(上文件下目录、上目录下文件、上文件下文件)
若是存在 .wh. 文件,须要移除底层应该被覆盖掉的目录,好比目录下存在 .wh..wh.opaque 文件,就须要删除 lowerdir 中的对应目录。
固然这里的删除也没那么简单,还记得当前的操做都是经过挂载点来删除底层的文件么?在 overlay 中,若是经过挂载点删除 lower 层的内容,不会把文件真的从 lower 的文件目录中干掉,而是会在 upper 层中添加 whiteout,添加 whiteout 的其中一种方式就是设置上层目录的 xattr trusted.overlay.opaque=y。
当 tar 包遍历结束之后,对 tmp 作个 umount,获得的 11/fs 就是咱们想要的 layer,当咱们想要生成 12/fs 这个 layer 时,只须要把 10/fs,11/fs 做为 lowerdir,把 12/fs 做为 upperdir 联合挂载就能够。也就是说,以后镜像的每个 layer 生成都是须要把以前的 layer 挂载,下面图说明了整个流程。
能够考虑下为何要这么大费周章?关键有两点。
一是镜像中的删除下层文件是要遵循 image-spec 中对于 whiteout 文件的定义(image-spec),这个文件只会在 tar 包中做为标识,并不会产生真正的影响。而起到真正做用的是在 applyNaive 碰到了 whiteout 文件,会调用联合文件系统对底层目录进行删除,固然这个删除对于 overlay 就是标记 opaque。
二是由于存在文件和目录相互覆盖的现象,每个 tar 包中的文件都须要和以前全部 tar包 中的内容进行比对,若是不借用联合文件系统的“超能力”,咱们就只能拿着 tar 中的每个文件对以前的层遍历。
了解了镜像相关的知识,咱们来看看这个问题的排查过程。首先咱们观察用户的容器,通过简化和打码目录结构以下,其中目录 modules 就是事故多发地。
/data └── prom ├── bin └── modules ├── file └── lib/
再观察下用户的镜像的各个层。咱们把镜像的层按照从下往上用递增的 ID 来标注,对这个目录有修改的有 509九、510一、510二、510三、5104 这几层。把容器运行起来后,看到的 modules 目录和 5104 提供的同样。并无把 5103 等“下面”的镜像合并起来,至关于 5104 把下面的目录都覆盖掉了(固然,5104 和 5103 文件是有区别的)。
看到这里,首先想到是否是建立容器的 rootfs 时参数出现了问题,致使少 mount 了一些层?因而模拟手动挂载mount -t overlay overlay -o lowerdir=5104:5103 point把最上两层挂载,结果 5104 依然把 5103 覆盖了。这里推断多是存在 overlay 的 .wh. 文件,因而尝试在这两层中搜 .wh. 文件,无果。因而去查 overlayfs 的文档:
A directory is made opaque by setting the xattr "trusted.overlay.opaque"
to "y". Where the upper filesystem contains an opaque directory, any
directory in the lower filesystem with the same name is ignored.
设置了属性 trusted.overlay.opaque=y 的目录会变成“不透明”的,当上层文件系统被设置为“不透明”时,下层中同名的目录会被忽略。overlay 若是想要在上层把下层覆盖掉,就须要设置这个属性。
经过命令getfattr -n "trusted.overlay.opaque" dir查看发现,5104 下面的 /data/asr_offline/modules 果真带有这个属性,这一现象也进而致使了下层目录被“覆盖”。
[root@]$ getfattr -n "trusted.overlay.opaque" 5104/fs/data/asr_offline/modules # file: 5102/fs/data/asr_offline/modules trusted.overlay.opaque="y"
一波多折,层层追究
那么问题来了,为何只有特定的发行版会出现这个现象?咱们尝试在 ubuntu 拉下镜像,发现“同源”目录竟然没有设置 opaque!因为镜像的层经过把源文件解压和解包生成的,咱们决定在确保不一样操做系统中的“镜像源文件”的 md5 相同以后,在各个操做系统上把镜像源文件经过tar -zxf进行解包并从新手动挂载,发现 5104 均不会把 5103 覆盖。
根据以上现象推断,多是某些发行版下的 containerd 从 content 读取 tar 包并解压制做 snapshot 的 layer 时出现问题,错误地把 snapshot 的目录设置上了这个属性。
为验证该推断,决定进行源代码梳理,由此发现了其中的疑点(相关代码以下)——生成 layers 时遍历 tar 包会读取每一个文件的 PAXRecords 而且把这个设置在文件的 xattr 上( tar 包给每一个文件都准备了 PAXRecords,和 Pod 的 labels 等价)。
func applyNaive() { // ... for k, v := range tar_file.PAXRecords { setxattr(real_file, k, v) } } func setxattr(path, key, value string) error { return unix.Lsetxattr(path, key, []byte(value), 0) }
由于以前实验过 v1.3 的 containerd 不会出现这个问题,因此对照了下二者的代码,发现二者从 tar 包中抽取 PAXRecords 设置 xattr 的逻辑二者是不同的。v1.3 的代码以下:
func setxattr(path, key, value string) error { // Do not set trusted attributes if strings.HasPrefix(key, "trusted.") { return errors.Wrap(unix.ENOTSUP, "admin attributes from archive not supported") } return unix.Lsetxattr(path, key, []byte(value), 0) }
也就是说 v1.3.0 中不会设置以trusted.开头的 xattr!若是 tar 包中某目录带有trusted.overlay.opaque=y这个 PAX,低版本的 containerd 可能就会把这些属性设置到 snapshot 的目录上,而高版本的却不会。那么,当用户在打包时,若是把 opaque 也打到 tar 包中,解压获得的 layer 对应目录也就会带有这个属性。5104 这个目录可能就是这个缘由才变成 opaque 的。
为了验证这个观点,我写了一段简单的程序来扫描与 layer 对应的 content 来寻找这个属性,结果发现 5102、5103、5104 几个层都没有这个属性。这时我也开始怀疑这个观点了,毕竟若是只是 tar 包中有特别的标识,应该不会在不一样的操做系统表现不一样。
抱着最后一丝但愿扫描了 5099 和 5101,果真也并无这个属性。但在扫描的过程当中,注意到 5101 的 tar 包里存在 /data/asr_offline/modules/.wh..wh.opq 这个文件。记得当时看代码 applyNaive 时若是遇到了 .wh..wh.opq 对应的操做应该是在挂载点删除 /data/asr_offline/modules,而在 overlay 中删除 lower 目录会给 upper 同名目录加上trusted.overlay.opaque=y。也就是说,在生成 layer 5101 时(须要提早挂载好 5100 和 5099),遍历 tar 包遇到了这个 wh 文件,应该先在挂载点删除 modules,也就是会在 5101 对应目录加上 opaque=y。
再次以验证源代码成果的心态,去 snapshot 的 5101/fs 下查看目录 modules 的 opaque,果真和想象的同样。这些文件应该都是在 lower层,因此对应的 overlayfs 的操做应该是在 upper 也就是 5101 层的 /data/asr_offline/modules 目录设置trusted.overlay.opaque=y。去查看 5101 的这个目录,果真带有这个属性,好奇心驱使着我继续查看了 5102、5103、5104 这几层的目录,发现竟然都有这个属性。
也就是这些 layer 每一个都会把下面的覆盖掉?这好像不符合常理。因而,去表现正常的 ubuntu 中查看,发现只有 5101 有这个属性。通过反复确认 5102、5103、5104 的 tar 包中的确没有目录 modules 的 whiteout 文件,也就是说镜像本来的意图就是让 5101 把下面的层覆盖掉,再把 5101、5102、5103、5104 这几层的 modules 目录 merge 起来。整个生成镜像的流程里,只有“借用”overlay 生成 snapshot 的 layer 会涉及到操做系统。
咱们不妨大胆猜想一下,会不会像下图这样,在生成 layer 5102 时,由于内核或 overlay 的 bug 把 modules 也添加了不透明的属性?
为了对这个特性作单独的测试,写了个简单的脚本。运行脚本以后,果真发如今这个发行版中,若是 overlay 的低层目录有这个属性而且在 upper 层中建立了一样的目录,会把这个 opaque“传播”到 upper 层的目录中。若是像 containerd 那样递推生成镜像,确定从有 whiteout 层开始上面的每一层都会具备这个属性,也就致使了最终容器在某些特定的目录只能看到最上面一层。
`#!/bin/bash mkdir 1 2 work p mkdir 1/func touch 1/func/min mount -t overlay overlay p -o lowerdir=1,upperdir=2,workdir=work rm -rf p/func mkdir -p p/func touch p/func/max umount p getfattr -n "trusted.overlay.opaque" 2/func mkdir 3 mount -t overlay overlay p -o lowerdir=2:1,upperdir=3,workdir=work touch p/func/sqrt umount p getfattr -n "trusted.overlay.opaque" 3/func`
在几个内核大佬的帮助下,确认了是内核 overlayfs 模块的 bug。在 lower 层调用 copy_up 时并无检测 xattr,从而致使 opaque 这个 xattr 传播到了 upper 层。作联合挂载时,若是上层的文件获得了这个属性,天然会把下层文件覆盖掉,也就出现了镜像中丢失文件的现象。反思整个排查过程,其实很难在一开始就把问题定位到内核的某个模块上,好在能够另辟蹊径经过测试和阅读源码逐步逼近“真相”,成功寻得解决方案。