关于tidb:Chaos-Mesh®-技术内幕-如何注入-IO-故障

31次阅读

共计 7688 个字符,预计需要花费 20 分钟才能阅读完成。

在生产环境中,时常会因为磁盘故障、误操作等起因呈现文件系统的谬误。Chaos Mesh 很早就提供了注入文件系统谬误的能力。用户只须要增加一个 IOChaos 资源,就可能让对指定文件的文件系统操作失败或返回谬误的数据。在 Chaos Mesh 1.0 之前,应用 IOChaos 须要对 Pod 注入 sidecar 容器,并且须要改写启动命令;哪怕没有注入谬误,被注入 sidecar 的容器也总是有较大的性能开销。随着 Chaos Mesh 1.0 的公布,提供了运行时注入文件系统谬误的性能,使得 IOChaos 的应用和其余所有类型的 Chaos 一样简略不便。这篇文章将会介绍它的实现形式。

前置

本文的内容假设你曾经把握以下常识。当然,你不用在此时就去浏览;但当遇到没见过的名词的时能够回过头来搜寻学习。

我会尽我所能提供相干的学习材料,但我不会将它们提炼和复述,一是因为这些常识通过简略的 Google 就能学到;二是因为大部分时候学习一手的常识成果远比二手要好,学习 n 手的常识成果远比 (n+1) 手的要好。

  1. FUSE. Wikipedia, man(4)
  2. mount_namespaces. man, k8s Mount propagation
  3. x86 assembly language. Wikipedia
  4. mount. man(2) 特地是 MS_MOVE
  5. Mutating admission webhooks. k8s Document
  6. syscall. man(2) 留神浏览一下调用约定
  7. ptrace. man(2)
  8. Device node, char devices Device names, device nodes, and major/minor numbers

浏览与 TimeChaos 相干的 文章 对了解本文也有很大的帮忙,因为它们应用着类似的技术。

此外,我心愿在浏览这份文档时,读者可能被动地思考每一步的起因和成果。这之中没有简单的须要头脑高速运转的常识,只有一步一步的(给计算机的)行动指南。也心愿你可能在大脑里一直地构思“如果我本人要实现运行时文件系统注入,应该怎么做?”,这样这篇文章就从单纯的灌输变为了见解的交换,会乏味很多。

谬误注入

寻找谬误注入形式的一个广泛办法就是先察看未注入时的调用门路:咱们在 TimeChaos 的实现过程当中,通过观察应用程序获取工夫的形式,理解到大部分程序会通过 vDSO 拜访工夫,从而选取了批改目标程序 vDSO 局部内存来批改工夫的形式。

那么在应用程序发动 read, write 等零碎调用,到这些申请达到指标文件系统,这之间是否存在可供注入的突破口呢?事实上是存在的,你能够应用 bpf 的形式注入相干的零碎调用,但它无奈被用于注入提早。另一种形式就是在指标文件系统前再加一层文件系统,咱们暂且称之为 ChaosFS:

ChaosFS 以原本的指标文件系统作为后端,承受来自操作系统的写入申请,使得整个调用链路变为 Targer Program syscall -> Linux Kernel -> ChaosFS -> Target Filesystem. 因为咱们能够自定义 ChaosFS 文件系统的实现,所以能够任意地增加提早、返回谬误。

如果你在此时曾经开始构思本人的文件系统谬误注入实现,聪慧的你肯定曾经发现了一些问题:

  1. ChaosFS 如果也要往指标文件系统里读写文件,这意味着它的挂载门路与指标文件夹不同。因为挂载门路简直是拜访一个文件系统惟一的形式了。

    即,如果目标程序想要写入 /mnt/a,于是 ChaosFS 也得挂载于 /mnt/a,那么指标文件夹就不能是 /mnt/a 了!然而 pod 的配置里写了要把指标文件系统挂载在 /mnt 呀,这可怎么办。

  2. 这不能满足运行时注入的要求。因为如果目标程序曾经关上了一些原指标零碎的文件,那么新挂载的文件系统只对新 open 的文件无效。(更何况还有上述文件系统门路笼罩的问题)。想要可能对目标程序注入文件系统谬误,必须得在指标过程启动之前将 ChaosFS 挂载好。
  3. 还得想方法把文件系统给挂载进指标容器的 mnt namespace 中去。

对于这三个问题,原初的 IOChaos 都是应用 Mutating Webhook 来达成的:

  1. 应用 Mutating Webhook 在指标容器中先运行脚本挪动目录。比方将 /mnt/a 挪动至 /mnt/a_bak。这样一来 ChaosFS 的存储后端就能够是 /mnt/a_bak 目录,而本人挂载在 /mnt/a 下了。
  2. 应用 Mutating Webhook 批改 Pod 的启动命令,比方自身启动命令是 /app,咱们要将它批改成 /waitfs.sh /app,而咱们提供的 waitfs.sh 会一直查看文件系统是否曾经挂载胜利,如果曾经胜利就再启动 /app
  3. 天然的,咱们仍旧应用 Mutating Webhook 来在 Pod 中多退出一个容器用于运行 ChaosFS。运行 ChaosFS 的容器须要与指标容器共享某个 volume,比方 /mnt。而后将它挂载至目标目录,比方 /mnt/a。同时开启适当的 mount propagation,来让 ChaosFS 容器的 volume 中的挂载穿透(share)至 host,再由 host 穿透(slave)至指标。(如果你理解 mnt namespace 和 mount,那么肯定晓得 share 和 slave 是什么意思)。

这样一来,就实现了对目标程序 IO 过程的注入。但它是如此的不好用:

  1. 只能对某个 volume 的子目录注入,而无奈对整个 volume 注入。
  2. 要求 Pod 中明文写有 command,而不能是隐含应用镜像的 command。因为如果应用镜像隐含的 command 的话,/waitfs.sh 就不晓得在挂载胜利之后应该如何启动利用了。
  3. 要求对应容器有足够的 mount propagation 的配置。当然咱们能够在 Mutating Webhook 里偷偷摸摸加上,但动用户的容器总是不太妙的(甚至可能引发平安问题)。
  4. 注入配置要填的货色太多啦!配置起来真麻烦。而且在配置实现之后还得新建 pod 能力被注入。
  5. 无奈在运行时撤出 ChaosFS,所以哪怕不施加提早或谬误,依然对性能有不小的影响。

其中第一个问题是能够克服的,只有用 mount move 来代替 mv(rename),就能够挪动指标 volume 的挂载点。而前面几个问题就不那么好克服了。

运行时注入谬误

联合应用你领有的其余常识(比方 namespace 的常识和 ptrace 的用法),从新扫视这两点,就能找到解决的方法。咱们齐全依赖 Mutating Webhook 来结构了这个实现,但大部分的蹩脚之处也都是由 Mutating Webhook 的办法带来的。(如果你喜爱,能够管这种办法叫做 Sidecar 的办法。很多我的项目都这么叫,然而这种称说将实现给暗藏了,也没省太多字,我不是很喜爱)。接下来咱们将展现如何不应用 Mutating Webhook 来达到以上目标。

侵入命名空间

咱们应用 Mutating Webhook 增加一个用于运行 ChaosFS 的容器的目标是为了通过 mount propagation 的机制将文件系统挂载至指标容器内。而要达到这个目标并非只有这一种抉择 —— 咱们还能够间接应用 Linux 提供的 setns 零碎调用来批改以后过程的 namespace。事实上在 Chaos Mesh 的大部分实现中都应用了 nsenter 命令、setns 零碎调用等形式来进入指标容器的 namespace,而非向 Pod 中增加容器。这是因为前者在应用时更加不便,开发时也更加灵便。

也就是说能够先通过 setns 来让以后线程进入指标容器的 mnt namespace,而后在这个 namespace 中调用 mount 等零碎调用实现 ChaosFS 的挂载。

假设咱们须要注入的文件系统是 /mnt

  1. 通过 setns 让以后线程进入指标容器的 mnt namespace;
  2. 通过 mount –move 将 /mnt 挪动至 /mnt_bak
  3. 将 ChaosFS 挂载至 /mnt,并以 /mnt_bak 为存储后端。

能够看到,这时注入流程曾经大抵实现了,指标容器如果再次关上、读写 /mnt 中的文件,就会通过 ChaosFS,从而被它注入提早或谬误。

而它还剩下两个问题:

  1. 指标过程曾经关上的文件该怎么办?
  2. 该如何复原?毕竟在有文件被关上的状况下是无奈 umount 的。

后文将用同一个伎俩解决这两个问题:应用 ptrace 的办法在运行时替换曾经关上的 fd。(本文以 fd 为例,事实上除了 fd 还有 cwd,mmap 等须要替换,实现形式是类似的,就不独自形容了)

动静替换 fd

咱们次要应用 ptrace 来对 fd 进行动静地替换,在介绍具体的办法之前,无妨先感受一下 ptrace 的威力:

  1. 应用 ptrace 可能让 tracee(被 ptrace 的线程)运行任意零碎调用这是怎么做到的呢?综合使用 ptrace 和 x86_64 的常识来看这个问题并不算难。因为 ptrace 能够批改寄存器,同时 x86_64 架构中 rip 寄存器(instruction pointer)总是指向下一个要运行的指令的地址,所以只须要将以后 rip 指向的局部内存批改为 0x050f(对应 syscall 指令),再按照零碎调用的调用约定将各个寄存器的值设为对应的零碎调用编号或参数,而后应用 ptrace 单步执行,就能从 rax 寄存器中拿到零碎调用的返回值。在实现调用之后记得将寄存器和批改的内存都还原。

    在以上过程中应用了 ptrace 的 POKE_TEXTSETREGSGETREGSSINGLESTEP 等性能,如果不相熟能够查阅 ptrace 的手册。

  2. 应用 ptrace 可能让 tracee(指 ptrace 的指标过程)运行任意二进制程序。

    运行任意二进制程序的思路是相似的。能够与运行零碎调用一样,将 rip 后一部分的内训批改为本人想要运行的程序,并在程序开端加上 int3 指令以触发断点。在执行实现之后复原目标程序的寄存器和内存就好了。

    而事实上咱们能够选用一种稍稍洁净些的形式:应用 ptrace 在目标程序中调用 mmap,调配出须要的内存,而后将二进制程序写入新调配出的内存区域中,将 rip 指向它。在运行完结之后调用 munmap 就能放弃内存区域的洁净。

在实践中,咱们应用 process_vm_writev 代替了应用 ptrace POKE_TEXT 写入,在写入大量内容的时候它更加稳固高效一些。

在领有以上伎俩之后,如果一个过程本人有方法替换本人的 fd,那么通过 ptrace,就能让它运行同样的一段程序来替换 fd。这样一来问题就简略了:咱们只须要找到一个过程本人替换本人的 fd 的办法。如果对 Linux 的零碎调用较为相熟的话,马上就能找到答案:dup2。

应用 dup2 替换 fd

dup2 的函数签名是 int dup2(int oldfd, int newfd);,它的作用是创立一份 oldfd 的拷贝,并且这个拷贝的 fd 号是 newfd。如果 newfd 本来就有关上着的 fd,它会被主动地 close。

假设当初过程正关上着 /var/run/__chaosfs__test__/a,fd 为 1,心愿替换成 /var/run/test/a,那么它须要做的事件有:

  1. 应用通过 fcntl 零碎调用获取 /var/run/__chaosfs__test__/a 的 OFlags(即 open 调用时的参数,比方 O_WRONLY);
  2. 应用 lseek 零碎调用获取以后的 seek 地位;
  3. 应用 open 零碎调用,以雷同的 OFlags 关上 /var/run/test/a,假如 fd 为 2;
  4. 应用 lseek 扭转新关上的 fd 2 的 seek 地位;
  5. 应用 dup2(2, 1) 用新关上的 fd 2 来替换 /var/run/__chaosfs__test__/a 的 fd 1;
  6. 将 fd 2 关掉。

这样之后,以后过程的 fd 1 就会指向 /var/run/test/a,任何对于它的操作都会通过 FUSE,可能被注入谬误了。

应用 ptrace 让指标过程运行替换 fd 的程序

那么只有联合“应用 ptrace 可能让 tracee 运行任意二进制程序”的常识和“应用 dup2 替换本人曾经关上的 fd”的办法,就可能让 tracee 本人把曾经关上的 fd 给替换掉啦!

对照前文形容的步骤,联合 syscall 指令的用法,写出对应的汇编代码是容易的,你能够在这里看到对应的源码,应用汇编器能够将它输入为可供使用的二进制程序(咱们应用的是 dynasm-rs)。而后用 ptrace 让指标过程运行这段程序,就实现了在运行时对 fd 的替换。

读者能够稍稍思考如何应用相似的形式来更换 cwd,替换 mmap 呢?它们的流程齐全是相似的。

注:实现中假设了目标程序按照 Posix Thread,指标过程与它的线程之间共享关上的文件,即 clone 创立线程时指定了 CLONE_FILES。所以将只会对一个线程组的第一个线程进行 fd 替换。

流程总览

在理解了这所有技术之后,实现运行时文件系统的思路该当曾经逐步清晰了起来。在这一节我将间接展现出整个注入实现的流程图:

平行的数条线示意不同的线程,从左至右按照工夫先后顺序。能够看到对“挂载 / 卸载文件系统”和“进行 fd 等资源的替换”这两个工作进行了较为精密的工夫程序的安顿,这是有必要的。为什么呢?如果读者对整个过程的理解曾经足够清晰,无妨试着本人思考它的答案。

细枝末节的问题

mnt namespace 可能引发的 mmap 生效

在 mnt namespace 切换之后,曾经创立实现的 mmap 是否还无效呢?比方一个 mmap 指向 /a/b,而在切换 mnt namespace 之后 /a/b 隐没了,再拜访这个 mmap 时是否会造成意料之外的解体呢?值得注意的是,动态链接库全是通过 mmap 载入进内存的,拜访它们是否会有问题呢?

事实上,是不会有问题的。这波及到 mnt namespace 的形式和目标。mnt namespace 只波及到对线程可见性的管制,具体的做法,则是在调用 setns 时批改内核中某一线程 task_struct 内 vfsmount 指针的批改,从而当线程应用任何传入门路的零碎调用的时候(比方 open、rename 等)的时候,Linux 内核内通过 vfsmount 从路径名查问到文件(作为 file 构造体),会受到 namespace 的影响。而对于曾经关上的 fd(指向一个 file 构造体),它的 open、write、read 等操作间接指向对应文件系统的函数指针,不会受到 namespace 的影响;对于一个曾经关上的 mmap(指向一个 address_space 构造体),它的 writepage, readpage 等操作也间接指向对应文件系统的函数指针,也不受到 namespace 的影响。

注入的范畴

因为在注入过程中,不可能将机器上运行的所有过程暂停并查看它们曾经关上的 fd 和 mmap 等资源,这样做的开销不可承受。在实践中,咱们抉择事后进入指标容器的 pid namespace,并对这个 namespace 中能看见的所有过程进行暂停和查看。

所以注入和复原的范畴是全副 pid namespace 中的过程。而切换 pid namespace 意味着须要事后设定子过程的 pid namespace 再 clone(因为 Linux 并不容许切换以后过程的 pid namespace),这又将带来诸多问题。

切换 namespace 对 clone flag 有些限度

切换 mnt namespace 将不容许 clone 时携带参数 CLONE_FS。而事后设定好子过程 pid namespace 的状况下,将不容许 clone 时携带参数 CLONE_THREAD。为了应答这个问题,咱们抉择批改 glibc 的源码,可能在 chaos-mesh/toda-glibc 中找到批改后的 glibc 的源码。批改的只有 pthread 局部 clone 时传入的参数。

在去掉 CLONE_THREADCLONE_FS 之后,pthread 的体现与原先有较大差别。其中最大的差别便是新建的 pthread 线程不再是原有过程的 tasks,而是一个新的过程,它们的 tgid 是不同的。这样 pthread 线程之间的关系从过程与 tasks 变成了过程与子过程。这又会带来一些麻烦,比方在退出时可能须要对子过程进行额定的清理。

在更低版本的内核中,也不容许不同 pid namespace 的过程共享 SIGHAND,所以还须要把 CLONE_SIGHAND 去掉。

为什么不应用 nsenter

在 chaos-daemon 中,很多须要在指标命名空间中的操作都是通过 nsenter 实现的,比方 nsenter iptables 这样联结应用。而 nsenter 却无奈应答 IOChaos 的场景,因为如果在过程启动时就已进入指标 mnt namespace,那将找不到适合的动态链接库(比方 libfuse.so 和自制的 glibc)。

结构 /dev/fuse

因为指标容器中不肯定有 /dev/fuse(事实上更可能没有),所以在进入指标容器的 mnt namespace 后挂载 FUSE 时会遇到谬误。所以在进入指标的 mnt namespace 后须要结构 /dev/fuse。这个结构的过程还是很容易的,因为 fuse 的 major number 和 minor number 是固定的 10 和 229。所以只有应用 makedev 函数和 mknod 零碎调用,就可能发明出 /dev/fuse。

去掉 CLONE_THREAD 之后期待子过程死亡的问题

在子过程死亡时,会向父过程发送 SIGCHLD 信号告诉本人的死亡。如果父过程没有妥善的解决这个信号(显式地疏忽或是在信号处理中 wait),那么子过程就会继续处于 defunct 状态。

而在咱们的场景下,这个问题变得更加简单了:因为当一个过程的父过程死亡之后,它的父过程会被从新置为它所在的 pid namespace 的 1 号过程。通常来说一个好的 init 过程(比方 systemd)会负责清理这些 defunct 过程,但在容器的场景下,作为 pid 1 的利用通常并没有被设计为一个好的 init 过程,不会负责解决掉这些 defunct 过程。

为了解决这个问题,咱们应用 subreaper 的机制来让一个过程的父过程死亡时并不是间接将父过程置为 1,而是过程树上离得最近的 subreaper。而后应用 wait 来期待所有子过程死亡再退出。

waitpid 在不同内核版本下体现不统一

waitpid 在不同版本内核下体现不统一,在较低版本的内核中,对一个作为子线程(指并非主线程的线程)的 tracee 应用 waitpid 会返回 ECHILD,还没有确定这样的起因是什么,也没有找到相干的文档。

欢送奉献

在实现了以上形容的实现之后,运行时文件系统注入的性能就大抵实现了,咱们的实现在 chaos-mesh/toda 我的项目里。然而离完满依然还有很长的路要走:

  1. 对 generation number 没有反对;
  2. 对 ioctl 等操作没有提供反对;
  3. 在挂载文件系统之后没有被动判断它是否实现,而是期待 1s。

如果读者对这项性能的实现感兴趣,或是违心和咱们一起改良它,欢送退出咱们的 slack 频道参加探讨或提交 issue 和 PR ????

本篇为 Chaos Mesh 技术底细系列文章的第一篇,如果读者还想理解种种其余谬误注入的实现和背地的技术,还请期待同系列之后的文章哟。

正文完
 0