Chaos Mesh® 技术内幕 | 如何注入 I/O 故障?

在生产环境中,时常会由于磁盘故障、误操做等缘由出现文件系统的错误。Chaos Mesh 很早就提供了注入文件系统错误的能力。用户只须要添加一个 IOChaos 资源,就可以让对指定文件的文件系统操做失败或返回错误的数据。在 Chaos Mesh 1.0 以前,使用 IOChaos 须要对 Pod 注入 sidecar 容器,而且须要改写启动命令;哪怕没有注入错误,被注入 sidecar 的容器也老是有较大的性能开销。随着 Chaos Mesh 1.0 的发布,提供了运行时注入文件系统错误的功能,使得 IOChaos 的使用和其余全部类型的 Chaos 同样简单方便。这篇文章将会介绍它的实现方式。html

前置

本文的内容假定你已经掌握如下知识。固然,你没必要在此时就去阅读;但当遇到没见过的名词的时能够回过头来搜索学习。node

我会尽我所能提供相关的学习资料,但我不会将它们提炼和复述,一是由于这些知识经过简单的 Google 就能学到;二是由于大部分时候学习一手的知识效果远比二手要好,学习 n 手的知识效果远比(n+1)手的要好。linux

  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 相关的 文章 对理解本文也有很大的帮助,由于它们使用着类似的技术。git

此外,我但愿在阅读这份文档时,读者可以主动地思考每一步的缘由和效果。这之中没有复杂的须要头脑高速运转的知识,只有一步一步的(给计算机的)行动指南。也但愿你可以在大脑里不断地构思“若是我本身要实现运行时文件系统注入,应该怎样作?”,这样这篇文章就从单纯的灌输变为了看法的交流,会有趣不少。github

错误注入

寻找错误注入方式的一个广泛方法就是先观察未注入时的调用路径:咱们在 TimeChaos 的实现过程中,经过观察应用程序获取时间的方式,了解到大部分程序会经过 vDSO 访问时间,从而选取了修改目标程序 vDSO 部份内存来修改时间的方式。web

那么在应用程序发起 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 呀,这可怎么办。app

  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 技术内幕系列文章的第一篇,若是读者还想了解种种其余错误注入的实现和背后的技术,还请期待同系列以后的文章哟。

相关文章
相关标签/搜索